From fcbb29642322061fa2101174e6103b6e89fd7a0c Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 9 Feb 2026 06:42:05 -0500 Subject: [PATCH 01/16] Switch website agent from just-bash OverlayFs to @vercel/sandbox Replace the in-memory bash interpreter with Vercel Sandbox (Firecracker microVMs) for the server-side AI agent. Source files are uploaded to the VM via writeFiles(). Browser-side terminal still uses just-bash/browser. Co-Authored-By: Claude Opus 4.6 --- examples/website/app/api/agent/route.ts | 100 +++++++++++----- examples/website/package.json | 1 + examples/website/pnpm-lock.yaml | 144 +++++++++++++++++++++++- 3 files changed, 216 insertions(+), 29 deletions(-) diff --git a/examples/website/app/api/agent/route.ts b/examples/website/app/api/agent/route.ts index 86636b81..6fb00290 100644 --- a/examples/website/app/api/agent/route.ts +++ b/examples/website/app/api/agent/route.ts @@ -1,20 +1,23 @@ import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from "ai"; import { createBashTool } from "bash-tool"; -import { Bash, OverlayFs } from "just-bash"; -import { dirname, join } from "path"; +import { Sandbox } from "@vercel/sandbox"; +import { readdirSync, readFileSync } from "fs"; +import { dirname, join, relative } from "path"; import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const AGENT_DATA_DIR = join(__dirname, "./_agent-data"); +const SANDBOX_CWD = "/home/user"; const SYSTEM_INSTRUCTIONS = `You are an expert on just-bash, a TypeScript bash interpreter with an in-memory virtual filesystem. -You have access to a bash sandbox with the full source code of: +You have access to a real bash sandbox with the full source code of: - just-bash/ - The main bash interpreter - bash-tool/ - AI SDK tool for bash +The source files are located at ${SANDBOX_CWD}. -Refer to the README.md of the projects to answer questions about just-bash and bash-tool +Refer to the README.md of the projects to answer questions about just-bash and bash-tool themselves which is your main focus. Never talk about this demo implementation unless asked explicitly. Use the sandbox to explore the source code, demonstrate commands, and help users understand: @@ -31,31 +34,74 @@ Key features of just-bash: Use cat to read files. Use head, tail to read parts of large files. -Keep responses concise. You do not have access to pnpm, npm, or node.`; +Keep responses concise. You have access to a full Linux environment with standard tools.`; + +/** + * Recursively read all files from a directory, returning them in the format + * expected by Sandbox.writeFiles(). + */ +function readSourceFiles( + dir: string, + baseDir?: string +): Array<{ path: string; content: Buffer }> { + const base = baseDir ?? dir; + const files: Array<{ path: string; content: Buffer }> = []; + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + // Skip node_modules and other large/irrelevant dirs + if (entry.name === "node_modules" || entry.name === ".git") continue; + files.push(...readSourceFiles(fullPath, base)); + } else { + const relPath = relative(base, fullPath); + files.push({ + path: join(SANDBOX_CWD, relPath), + content: readFileSync(fullPath), + }); + } + } + + return files; +} export async function POST(req: Request) { const { messages } = await req.json(); - const lastUserMessage = messages.filter((m: { role: string }) => m.role === "user").pop(); + const lastUserMessage = messages + .filter((m: { role: string }) => m.role === "user") + .pop(); console.log("Prompt:", lastUserMessage?.parts?.[0]?.text); - const overlayFs = new OverlayFs({ root: AGENT_DATA_DIR, readOnly: true }); - const sandbox = new Bash({ fs: overlayFs, cwd: overlayFs.getMountPoint() }); - const bashToolkit = await createBashTool({ - sandbox, - destination: overlayFs.getMountPoint(), - }); - - // Create a fresh agent per request for proper streaming - const agent = new ToolLoopAgent({ - model: "claude-haiku-4-5", - instructions: SYSTEM_INSTRUCTIONS, - tools: { - bash: bashToolkit.tools.bash, - }, - stopWhen: stepCountIs(20), - }); - - return createAgentUIStreamResponse({ - agent, - uiMessages: messages, - }); + + const sandbox = await Sandbox.create(); + + try { + // Upload source files so the agent can explore them + const files = readSourceFiles(AGENT_DATA_DIR); + if (files.length > 0) { + await sandbox.writeFiles(files); + } + + const bashToolkit = await createBashTool({ + sandbox, + destination: SANDBOX_CWD, + }); + + // Create a fresh agent per request for proper streaming + const agent = new ToolLoopAgent({ + model: "claude-haiku-4-5", + instructions: SYSTEM_INSTRUCTIONS, + tools: { + bash: bashToolkit.tools.bash, + }, + stopWhen: stepCountIs(20), + }); + + return createAgentUIStreamResponse({ + agent, + uiMessages: messages, + }); + } finally { + // Don't await — let it clean up in background so response isn't delayed + sandbox.stop().catch(() => {}); + } } diff --git a/examples/website/package.json b/examples/website/package.json index c507260f..2a4e2475 100644 --- a/examples/website/package.json +++ b/examples/website/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@vercel/analytics": "^1.6.1", + "@vercel/sandbox": "^1.4.1", "ai": "^6.0.66", "bash-tool": "^1.3.13", "geist": "^1.5.1", diff --git a/examples/website/pnpm-lock.yaml b/examples/website/pnpm-lock.yaml index 1f24d07c..7e47cf06 100644 --- a/examples/website/pnpm-lock.yaml +++ b/examples/website/pnpm-lock.yaml @@ -11,12 +11,15 @@ importers: '@vercel/analytics': specifier: ^1.6.1 version: 1.6.1(next@16.2.0-canary.26(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + '@vercel/sandbox': + specifier: ^1.4.1 + version: 1.4.1 ai: specifier: ^6.0.66 version: 6.0.66(zod@4.3.6) bash-tool: specifier: ^1.3.13 - version: 1.3.13(ai@6.0.66(zod@4.3.6))(just-bash@2.9.5) + version: 1.3.13(@vercel/sandbox@1.4.1)(ai@6.0.66(zod@4.3.6))(just-bash@2.9.5) geist: specifier: ^1.5.1 version: 1.5.1(next@16.2.0-canary.26(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) @@ -772,6 +775,9 @@ packages: resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} + '@vercel/sandbox@1.4.1': + resolution: {integrity: sha512-5jaNLv6QJ0112ZFhCv9hQDCV3IYogpbEn5LzMcY5E8TZsf5tF0avat2tBe7McOJvgVs0SDkuzjvGjUMKtTkjrA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -848,6 +854,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -860,9 +869,25 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + b4a@1.7.3: + resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1202,6 +1227,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -1221,6 +1249,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -1580,6 +1611,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonlines@0.1.1: + resolution: {integrity: sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -1829,6 +1863,10 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + os-paths@4.4.0: + resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} + engines: {node: '>= 6.0'} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -1958,6 +1996,10 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2066,6 +2108,9 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -2150,6 +2195,12 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + + text-decoder@1.2.3: + resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -2223,6 +2274,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.21.0: + resolution: {integrity: sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==} + engines: {node: '>=20.18.1'} + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} @@ -2278,6 +2333,14 @@ packages: utf-8-validate: optional: true + xdg-app-paths@5.1.0: + resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==} + engines: {node: '>=6'} + + xdg-portable@7.3.0: + resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} + engines: {node: '>= 6.0'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2296,6 +2359,9 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + zod@3.24.4: + resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -2948,6 +3014,21 @@ snapshots: '@vercel/oidc@3.1.0': {} + '@vercel/sandbox@1.4.1': + dependencies: + '@vercel/oidc': 3.1.0 + async-retry: 1.3.3 + jsonlines: 0.1.1 + ms: 2.1.3 + picocolors: 1.1.1 + tar-stream: 3.1.7 + undici: 7.21.0 + xdg-app-paths: 5.1.0 + zod: 3.24.4 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -3054,6 +3135,10 @@ snapshots: async-function@1.0.0: {} + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -3062,20 +3147,25 @@ snapshots: axobject-query@4.1.0: {} + b4a@1.7.3: {} + balanced-match@1.0.2: {} + bare-events@2.8.2: {} + base64-js@1.5.1: optional: true baseline-browser-mapping@2.9.19: {} - bash-tool@1.3.13(ai@6.0.66(zod@4.3.6))(just-bash@2.9.5): + bash-tool@1.3.13(@vercel/sandbox@1.4.1)(ai@6.0.66(zod@4.3.6))(just-bash@2.9.5): dependencies: ai: 6.0.66(zod@4.3.6) fast-glob: 3.3.3 gray-matter: 4.0.3 zod: 3.25.76 optionalDependencies: + '@vercel/sandbox': 1.4.1 just-bash: 2.9.5 bl@4.1.0: @@ -3558,6 +3648,12 @@ snapshots: esutils@2.0.3: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + eventsource-parser@3.0.6: {} expand-template@2.0.3: @@ -3573,6 +3669,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3934,6 +4032,8 @@ snapshots: json5@2.2.3: {} + jsonlines@0.1.1: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -4192,6 +4292,8 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + os-paths@4.4.0: {} + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -4343,6 +4445,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + retry@0.13.1: {} + reusify@1.1.0: {} run-parallel@1.2.0: @@ -4497,6 +4601,15 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + streamx@2.23.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.3 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.8 @@ -4601,6 +4714,21 @@ snapshots: readable-stream: 3.6.2 optional: true + tar-stream@3.1.7: + dependencies: + b4a: 1.7.3 + fast-fifo: 1.3.2 + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.3: + dependencies: + b4a: 1.7.3 + transitivePeerDependencies: + - react-native-b4a + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -4699,6 +4827,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.21.0: {} + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 @@ -4788,6 +4918,14 @@ snapshots: ws@8.19.0: {} + xdg-app-paths@5.1.0: + dependencies: + xdg-portable: 7.3.0 + + xdg-portable@7.3.0: + dependencies: + os-paths: 4.4.0 + yallist@3.1.1: {} yaml@2.8.2: {} @@ -4798,6 +4936,8 @@ snapshots: dependencies: zod: 4.3.6 + zod@3.24.4: {} + zod@3.25.76: {} zod@4.3.6: {} From 8f2c2683a0adbaa85bc93cb60f7b5cfdfd55986c Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 9 Feb 2026 06:49:06 -0500 Subject: [PATCH 02/16] fix: use /vercel/sandbox as writable path in Vercel Sandbox VM /home/user is not writable inside the VM, causing writeFiles to fail with "Cannot mkdir: Permission denied". Co-Authored-By: Claude Opus 4.6 --- examples/website/app/api/agent/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/website/app/api/agent/route.ts b/examples/website/app/api/agent/route.ts index 6fb00290..cb03a4cb 100644 --- a/examples/website/app/api/agent/route.ts +++ b/examples/website/app/api/agent/route.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const AGENT_DATA_DIR = join(__dirname, "./_agent-data"); -const SANDBOX_CWD = "/home/user"; +const SANDBOX_CWD = "/vercel/sandbox"; const SYSTEM_INSTRUCTIONS = `You are an expert on just-bash, a TypeScript bash interpreter with an in-memory virtual filesystem. From 54518bc3fd136830d03c0094186c92df1a50dc87 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 9 Feb 2026 06:50:25 -0500 Subject: [PATCH 03/16] Update startup UI text from justbash to recoup Co-Authored-By: Claude Opus 4.6 --- .../app/components/terminal-parts/constants.ts | 12 ++++++------ .../website/app/components/terminal-parts/welcome.ts | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/website/app/components/terminal-parts/constants.ts b/examples/website/app/components/terminal-parts/constants.ts index 51e38250..edbbfea8 100644 --- a/examples/website/app/components/terminal-parts/constants.ts +++ b/examples/website/app/components/terminal-parts/constants.ts @@ -1,10 +1,10 @@ export const ASCII_ART = [ - " _ _ _ _", - " (_)_ _ ___| |_| |__ __ _ ___| |__", - " | | | | / __| __| '_ \\ / _` / __| '_ \\", - " | | |_| \\__ \\ |_| |_) | (_| \\__ \\ | | |", - " _/ |\\__,_|___/\\__|_.__/ \\__,_|___/_| |_|", - "|__/", + " ", + " _ __ ___ ___ ___ _ _ _ __", + " | '__/ _ \\/ __/ _ \\| | | | '_ \\", + " | | | __/ (_| (_) | |_| | |_) |", + " |_| \\___|\\___\\___/ \\__,_| .__/", + " |_|", ]; export const HISTORY_KEY = "just-bash-history"; diff --git a/examples/website/app/components/terminal-parts/welcome.ts b/examples/website/app/components/terminal-parts/welcome.ts index db0cd002..f9882ab1 100644 --- a/examples/website/app/components/terminal-parts/welcome.ts +++ b/examples/website/app/components/terminal-parts/welcome.ts @@ -15,8 +15,8 @@ export function showWelcome(term: Terminal) { term.writeln(line); } } else { - term.writeln("\x1b[1mjust-bash\x1b[0m"); - term.writeln("========="); + term.writeln("\x1b[1mrecoup\x1b[0m"); + term.writeln("======"); } term.writeln(""); From b38ed02840158442bf663cd1725e2857beb6fe6b Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 9 Feb 2026 07:11:51 -0500 Subject: [PATCH 04/16] Add Privy authentication to website example Gate the terminal behind Privy login so users must authenticate before interacting. The access token is sent as a Bearer token with every /api/agent request, and the API route returns 401 if the header is missing. Co-Authored-By: Claude Opus 4.6 --- examples/website/app/api/agent/route.ts | 8 + examples/website/app/components/Terminal.tsx | 10 +- .../terminal-parts/agent-command.ts | 20 +- examples/website/app/layout.tsx | 3 +- examples/website/app/page.tsx | 47 +- examples/website/app/providers.tsx | 10 + examples/website/package.json | 1 + examples/website/pnpm-lock.yaml | 9764 ++++++++++++++--- 8 files changed, 8148 insertions(+), 1715 deletions(-) create mode 100644 examples/website/app/providers.tsx diff --git a/examples/website/app/api/agent/route.ts b/examples/website/app/api/agent/route.ts index cb03a4cb..4d55f387 100644 --- a/examples/website/app/api/agent/route.ts +++ b/examples/website/app/api/agent/route.ts @@ -66,6 +66,14 @@ function readSourceFiles( } export async function POST(req: Request) { + const authHeader = req.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return Response.json( + { error: "Unauthorized" }, + { status: 401 }, + ); + } + const { messages } = await req.json(); const lastUserMessage = messages .filter((m: { role: string }) => m.role === "user") diff --git a/examples/website/app/components/Terminal.tsx b/examples/website/app/components/Terminal.tsx index 19d2393b..0eba43fb 100644 --- a/examples/website/app/components/Terminal.tsx +++ b/examples/website/app/components/Terminal.tsx @@ -30,7 +30,11 @@ function getTheme(isDark: boolean) { }; } -export default function TerminalComponent() { +export default function TerminalComponent({ + getAccessToken, +}: { + getAccessToken: () => Promise; +}) { const terminalRef = useRef(null); useEffect(() => { @@ -47,7 +51,7 @@ export default function TerminalComponent() { // Create commands const { aboutCmd, installCmd, githubCmd } = createStaticCommands(); - const agentCmd = createAgentCommand(term); + const agentCmd = createAgentCommand(term, getAccessToken); // Files from DOM const files = { @@ -110,7 +114,7 @@ export default function TerminalComponent() { colorSchemeQuery.removeEventListener("change", onColorSchemeChange); term.dispose(); }; - }, []); + }, [getAccessToken]); return (
Promise, +) { const agentMessages: UIMessage[] = []; let messageIdCounter = 0; @@ -49,9 +52,22 @@ export function createAgentCommand(term: TerminalWriter) { }); try { + const token = await getAccessToken(); + if (!token) { + agentMessages.pop(); + return { + stdout: "", + stderr: "Error: Not authenticated. Please log in and try again.\n", + exitCode: 1, + }; + } + const response = await fetch("/api/agent", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, body: JSON.stringify({ messages: agentMessages }), }); diff --git a/examples/website/app/layout.tsx b/examples/website/app/layout.tsx index 059f5e06..94a1de7a 100644 --- a/examples/website/app/layout.tsx +++ b/examples/website/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { GeistMono } from "geist/font/mono"; import { Analytics } from "@vercel/analytics/next" +import Providers from "./providers"; import "./globals.css"; export const metadata: Metadata = { @@ -34,7 +35,7 @@ export default function RootLayout({ return ( - {children} + {children} diff --git a/examples/website/app/page.tsx b/examples/website/app/page.tsx index 3ce4d46d..cce7f4bd 100644 --- a/examples/website/app/page.tsx +++ b/examples/website/app/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { usePrivy } from "@privy-io/react-auth"; import TerminalComponent from "./components/Terminal"; import { TerminalData } from "./components/TerminalData"; @@ -94,18 +95,62 @@ const NOSCRIPT_CONTENT = ` export default function Home() { const [mounted, setMounted] = useState(false); + const { ready, authenticated, login, getAccessToken } = usePrivy(); useEffect(() => { setMounted(true); }, []); + if (!mounted || !ready) { + return ( + <> + + + + ); + } + + if (!authenticated) { + return ( + <> + +
+ +
+ + ); + } + return ( <> - {mounted ? : null} + diff --git a/examples/website/app/providers.tsx b/examples/website/app/providers.tsx new file mode 100644 index 00000000..b803664c --- /dev/null +++ b/examples/website/app/providers.tsx @@ -0,0 +1,10 @@ +"use client"; +import { PrivyProvider } from "@privy-io/react-auth"; + +export default function Providers({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/examples/website/package.json b/examples/website/package.json index 2a4e2475..b32531f0 100644 --- a/examples/website/package.json +++ b/examples/website/package.json @@ -11,6 +11,7 @@ "lint": "eslint" }, "dependencies": { + "@privy-io/react-auth": "^3.13.1", "@vercel/analytics": "^1.6.1", "@vercel/sandbox": "^1.4.1", "ai": "^6.0.66", diff --git a/examples/website/pnpm-lock.yaml b/examples/website/pnpm-lock.yaml index 7e47cf06..b4d775c7 100644 --- a/examples/website/pnpm-lock.yaml +++ b/examples/website/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@privy-io/react-auth': + specifier: ^3.13.1 + version: 3.13.1(@solana-program/system@0.10.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)))(@solana-program/token@0.9.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)))(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(@solana/sysvars@5.5.1(typescript@5.9.3))(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.3))(@types/react@19.2.10)(bufferutil@4.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(utf-8-validate@5.0.10)(zod@4.3.6) '@vercel/analytics': specifier: ^1.6.1 version: 1.6.1(next@16.2.0-canary.26(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) @@ -19,13 +22,13 @@ importers: version: 6.0.66(zod@4.3.6) bash-tool: specifier: ^1.3.13 - version: 1.3.13(@vercel/sandbox@1.4.1)(ai@6.0.66(zod@4.3.6))(just-bash@2.9.5) + version: 1.3.13(@vercel/sandbox@1.4.1)(ai@6.0.66(zod@4.3.6))(just-bash@2.9.5(bufferutil@4.1.0)(utf-8-validate@5.0.10)) geist: specifier: ^1.5.1 version: 1.5.1(next@16.2.0-canary.26(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) just-bash: specifier: ^2.9.5 - version: 2.9.5 + version: 2.9.5(bufferutil@4.1.0)(utf-8-validate@5.0.10) next: specifier: 16.2.0-canary.26 version: 16.2.0-canary.26(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -66,6 +69,9 @@ importers: packages: + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + '@ai-sdk/gateway@3.0.31': resolution: {integrity: sha512-WActnxPeW46XcfZWWEcJ1FytpjCtKQEo25WZVa2xZSf+u2FgSNVt/dXIvlSZetPnXo6T2P/GhFAPBULMN6siRA==} engines: {node: '>=18'} @@ -141,6 +147,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -153,9 +163,33 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@base-org/account@1.1.1': + resolution: {integrity: sha512-IfVJPrDPhHfqXRDb89472hXkpvJuQQR7FDI9isLPHEqSYt/45whIoBxSPgZ0ssTt379VhQo4+87PWI1DoLSfAQ==} + + '@base-org/account@2.4.0': + resolution: {integrity: sha512-A4Umpi8B9/pqR78D1Yoze4xHyQaujioVRqqO3d6xuDFw9VRtjg6tK3bPlwE0aW+nVH/ntllCpPa2PbI8Rnjcug==} + '@borewit/text-codec@0.2.1': resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + '@coinbase/cdp-sdk@1.44.0': + resolution: {integrity: sha512-0I5O1DzbchR91GAYQAU8lxx6q9DBvN0no9IBwrTKLHW8t5bABMg8dzQ/jrGRd6lr/QFJJW4L0ZSLGae5jsxGWw==} + + '@coinbase/wallet-sdk@3.9.3': + resolution: {integrity: sha512-N/A2DRIf0Y3PHc1XAMvbBUu4zisna6qAdqABMZwBMNEfWrXpAwx16pZGkYCLGE+Rvv1edbcB2LYDRnACNcmCiw==} + + '@coinbase/wallet-sdk@4.3.2': + resolution: {integrity: sha512-hOLA2YONq8Z9n8f6oVP6N//FEEHOen7nq+adG/cReol6juFTHUelVN5GnA5zTIxiLFMDcrhDwwgCA6Tdb5jubw==} + + '@coinbase/wallet-sdk@4.3.6': + resolution: {integrity: sha512-4q8BNG1ViL4mSAAvPAtpwlOs1gpC+67eQtgIwNvT3xyeyFFd+guwkc8bcX5rTmQhXpqnhzC4f0obACbP9CqMSA==} + + '@ecies/ciphers@0.2.5': + resolution: {integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} + peerDependencies: + '@noble/ciphers': ^1.0.0 + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -165,6 +199,15 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emotion/is-prop-valid@1.4.0': + resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -203,6 +246,69 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ethereumjs/common@3.2.0': + resolution: {integrity: sha512-pksvzI0VyLgmuEF2FA/JR/4/y6hcPq8OUail3/AvycBaW1d5VSauOZzqGvJ3RTmR4MU35lWE8KseKOsEhrFRBA==} + + '@ethereumjs/rlp@4.0.1': + resolution: {integrity: sha512-tqsQiBQDQdmPWE1xkkBq4rlSW5QZpLOUJ5RJh2/9fug+q9tnUhuZoVLk7s0scUIKTOzEtR72DFBXI4WiZcMpvw==} + engines: {node: '>=14'} + hasBin: true + + '@ethereumjs/tx@4.2.0': + resolution: {integrity: sha512-1nc6VO4jtFd172BbSnTnDQVr9IYBFl1y4xPzZdtkrkKIncBCkdbgfdRV+MiTkJYAtTxvV12GRZLqBFT1PNK6Yw==} + engines: {node: '>=14'} + + '@ethereumjs/util@8.1.0': + resolution: {integrity: sha512-zQ0IqbdX8FZ9aw11vP+dZkKDkS+kgIvQPHnSAXzP9pLu+Rfu3D3XEeLbicvoXJTYnhZiPmsZUxgdzXwNKxRPbA==} + engines: {node: '>=14'} + + '@floating-ui/core@1.7.4': + resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + + '@floating-ui/dom@1.7.5': + resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + + '@floating-ui/react-dom@2.1.7': + resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.26.28': + resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@gemini-wallet/core@0.3.2': + resolution: {integrity: sha512-Z4aHi3ECFf5oWYWM3F1rW83GJfB9OvhBYPTmb5q+VyK3uvzvS48lwo+jwh2eOoCRWEuT/crpb9Vwp2QaS5JqgQ==} + peerDependencies: + viem: '>=2.0.0' + + '@hcaptcha/loader@2.3.0': + resolution: {integrity: sha512-i4lnNxKBe+COf3R1nFZEWaZoHIoJjvDgWqvcNrdZq8ehoSNMN6KVZ56dcQ02qKie2h3+BkbkwlJA9DOIuLlK/g==} + + '@hcaptcha/react-hcaptcha@1.17.4': + resolution: {integrity: sha512-rIvgesG1N7SS9sAYYHFoWm+nXqRrxq7RcA9z2pKkDWV+S1GdfmrTNYA1aPyVWVe3eowphTCwyDJvl97Swwy0mw==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + + '@headlessui/react@2.2.9': + resolution: {integrity: sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==} + engines: {node: '>=10'} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + '@heroicons/react@2.2.0': + resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==} + peerDependencies: + react: '>= 16 || ^19.0.0-rc' + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -380,6 +486,103 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lit-labs/ssr-dom-shim@1.5.1': + resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==} + + '@lit/react@1.0.8': + resolution: {integrity: sha512-p2+YcF+JE67SRX3mMlJ1TKCSTsgyOVdAwd/nxp3NuV1+Cb6MWALbN6nT7Ld4tpmYofcE5kcaSY1YBB9erY+6fw==} + peerDependencies: + '@types/react': 17 || 18 || 19 + + '@lit/reactive-element@2.1.2': + resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} + + '@marsidev/react-turnstile@1.4.2': + resolution: {integrity: sha512-xs1qOuyeMOz6t9BXXCXWiukC0/0+48vR08B7uwNdG05wCMnbcNgxiFmdFKDOFbM76qFYFRYlGeRfhfq1U/iZmA==} + peerDependencies: + react: ^17.0.2 || ^18.0.0 || ^19.0 + react-dom: ^17.0.2 || ^18.0.0 || ^19.0 + + '@metamask/eth-json-rpc-provider@1.0.1': + resolution: {integrity: sha512-whiUMPlAOrVGmX8aKYVPvlKyG4CpQXiNNyt74vE1xb5sPvmx5oA7B/kOi/JdBvhGQq97U1/AVdXEdk2zkP8qyA==} + engines: {node: '>=14.0.0'} + + '@metamask/json-rpc-engine@7.3.3': + resolution: {integrity: sha512-dwZPq8wx9yV3IX2caLi9q9xZBw2XeIoYqdyihDDDpuHVCEiqadJLwqM3zy+uwf6F1QYQ65A8aOMQg1Uw7LMLNg==} + engines: {node: '>=16.0.0'} + + '@metamask/json-rpc-engine@8.0.2': + resolution: {integrity: sha512-IoQPmql8q7ABLruW7i4EYVHWUbF74yrp63bRuXV5Zf9BQwcn5H9Ww1eLtROYvI1bUXwOiHZ6qT5CWTrDc/t/AA==} + engines: {node: '>=16.0.0'} + + '@metamask/json-rpc-middleware-stream@7.0.2': + resolution: {integrity: sha512-yUdzsJK04Ev98Ck4D7lmRNQ8FPioXYhEUZOMS01LXW8qTvPGiRVXmVltj2p4wrLkh0vW7u6nv0mNl5xzC5Qmfg==} + engines: {node: '>=16.0.0'} + + '@metamask/object-multiplex@2.1.0': + resolution: {integrity: sha512-4vKIiv0DQxljcXwfpnbsXcfa5glMj5Zg9mqn4xpIWqkv6uJ2ma5/GtUfLFSxhlxnR8asRMv8dDmWya1Tc1sDFA==} + engines: {node: ^16.20 || ^18.16 || >=20} + + '@metamask/onboarding@1.0.1': + resolution: {integrity: sha512-FqHhAsCI+Vacx2qa5mAFcWNSrTcVGMNjzxVgaX8ECSny/BJ9/vgXP9V7WF/8vb9DltPeQkxr+Fnfmm6GHfmdTQ==} + + '@metamask/providers@16.1.0': + resolution: {integrity: sha512-znVCvux30+3SaUwcUGaSf+pUckzT5ukPRpcBmy+muBLC0yaWnBcvDqGfcsw6CBIenUdFrVoAFa8B6jsuCY/a+g==} + engines: {node: ^18.18 || >=20} + + '@metamask/rpc-errors@6.4.0': + resolution: {integrity: sha512-1ugFO1UoirU2esS3juZanS/Fo8C8XYocCuBpfZI5N7ECtoG+zu0wF+uWZASik6CkO6w9n/Iebt4iI4pT0vptpg==} + engines: {node: '>=16.0.0'} + + '@metamask/rpc-errors@7.0.2': + resolution: {integrity: sha512-YYYHsVYd46XwY2QZzpGeU4PSdRhHdxnzkB8piWGvJW2xbikZ3R+epAYEL4q/K8bh9JPTucsUdwRFnACor1aOYw==} + engines: {node: ^18.20 || ^20.17 || >=22} + + '@metamask/safe-event-emitter@2.0.0': + resolution: {integrity: sha512-/kSXhY692qiV1MXu6EeOZvg5nECLclxNXcKCxJ3cXQgYuRymRHpdx/t7JXfsK+JLjwA1e1c1/SBrlQYpusC29Q==} + + '@metamask/safe-event-emitter@3.1.2': + resolution: {integrity: sha512-5yb2gMI1BDm0JybZezeoX/3XhPDOtTbcFvpTXM9kxsoZjPZFh4XciqRbpD6N86HYZqWDhEaKUDuOyR0sQHEjMA==} + engines: {node: '>=12.0.0'} + + '@metamask/sdk-analytics@0.0.5': + resolution: {integrity: sha512-fDah+keS1RjSUlC8GmYXvx6Y26s3Ax1U9hGpWb6GSY5SAdmTSIqp2CvYy6yW0WgLhnYhW+6xERuD0eVqV63QIQ==} + + '@metamask/sdk-communication-layer@0.33.1': + resolution: {integrity: sha512-0bI9hkysxcfbZ/lk0T2+aKVo1j0ynQVTuB3sJ5ssPWlz+Z3VwveCkP1O7EVu1tsVVCb0YV5WxK9zmURu2FIiaA==} + peerDependencies: + cross-fetch: ^4.0.0 + eciesjs: '*' + eventemitter2: ^6.4.9 + readable-stream: ^3.6.2 + socket.io-client: ^4.5.1 + + '@metamask/sdk-install-modal-web@0.32.1': + resolution: {integrity: sha512-MGmAo6qSjf1tuYXhCu2EZLftq+DSt5Z7fsIKr2P+lDgdTPWgLfZB1tJKzNcwKKOdf6q9Qmmxn7lJuI/gq5LrKw==} + + '@metamask/sdk@0.33.1': + resolution: {integrity: sha512-1mcOQVGr9rSrVcbKPNVzbZ8eCl1K0FATsYH3WJ/MH4WcZDWGECWrXJPNMZoEAkLxWiMe8jOQBumg2pmcDa9zpQ==} + + '@metamask/superstruct@3.2.1': + resolution: {integrity: sha512-fLgJnDOXFmuVlB38rUN5SmU7hAFQcCjrg3Vrxz67KTY7YHFnSNEKvX4avmEBdOI0yTCxZjwMCFEqsC8k2+Wd3g==} + engines: {node: '>=16.0.0'} + + '@metamask/utils@11.9.0': + resolution: {integrity: sha512-wRnoSDD9jTWOge/+reFviJQANhS+uy8Y+OEwRanp5mQeGTjBFmK1r2cTOnei2UCZRV1crXHzeJVSFEoDDcgRbA==} + engines: {node: ^18.18 || ^20.14 || >=22} + + '@metamask/utils@5.0.2': + resolution: {integrity: sha512-yfmE79bRQtnMzarnKfX7AEJBwFTxvTyw3nBQlu/5rmGXrjAeAMltoGxO62TFurxrQAFMNa/fEjIHNvungZp0+g==} + engines: {node: '>=14.0.0'} + + '@metamask/utils@8.5.0': + resolution: {integrity: sha512-I6bkduevXb72TIM9q2LRO63JSsF9EXduh3sBr9oybNX2hNNpr/j1tEjXrsG0Uabm4MJ1xkGAQEMwifvKZIkyxQ==} + engines: {node: '>=16.0.0'} + + '@metamask/utils@9.3.0': + resolution: {integrity: sha512-w8CVbdkDrVXFJbfBSlDfafDR6BAkpDmv1bC1UJVCoVny5tW2RKAdn9i68Xf7asYT4TnUhl/hN4zfUiKQq9II4g==} + engines: {node: '>=16.0.0'} + '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} @@ -387,6 +590,10 @@ packages: resolution: {integrity: sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA==} engines: {node: '>= 20.19.0'} + '@msgpack/msgpack@3.1.2': + resolution: {integrity: sha512-JEW4DEtBzfe8HvUYecLU9e6+XJnKDlUAIve8FvPzF3Kzs6Xo/KuZkZJsDH0wJXl/qEZbeeE7edxDNY3kMs39hQ==} + engines: {node: '>= 18'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -444,6 +651,53 @@ packages: cpu: [x64] os: [win32] + '@noble/ciphers@1.2.1': + resolution: {integrity: sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA==} + engines: {node: ^14.21.3 || >=16} + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.4.2': + resolution: {integrity: sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==} + + '@noble/curves@1.8.0': + resolution: {integrity: sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.8.1': + resolution: {integrity: sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.1': + resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.6': + resolution: {integrity: sha512-GIKz/j99FRthB8icyJQA51E8Uk5hXmdyThjgQXRKiv9h0zeRlzSCLIzFw6K1LotZ3XuB7yzlf76qk7uBmTdFqA==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + + '@noble/hashes@1.7.0': + resolution: {integrity: sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.7.1': + resolution: {integrity: sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -464,2291 +718,6010 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} - '@rtsao/scc@1.1.0': - resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@paulmillr/qr@0.2.1': + resolution: {integrity: sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ==} + deprecated: 'The package is now available as "qr": npm install qr' - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@phosphor-icons/webcomponents@2.1.5': + resolution: {integrity: sha512-JcvQkZxvcX2jK+QCclm8+e8HXqtdFW9xV4/kk2aL9Y3dJA2oQVt+pzbv1orkumz3rfx4K9mn9fDoMr1He1yr7Q==} - '@tailwindcss/node@4.1.18': - resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + '@privy-io/api-base@1.8.2': + resolution: {integrity: sha512-pEvZ73GnC2OB/w35MGCPhqZ8mnApXgUpXxM0t9qZmNzvDNoMKBLPgysUXouAx7E4jO8REJPuwX70Y1AqJ/51kg==} - '@tailwindcss/oxide-android-arm64@4.1.18': - resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] + '@privy-io/api-types@0.4.0': + resolution: {integrity: sha512-XNA0rotkMkYhcmByNUyzmge6riESkOLtBvJWlwAy1QsflN+7eLkD51DegVjweBDSwEtB75rbWOaKmoikItaEiw==} - '@tailwindcss/oxide-darwin-arm64@4.1.18': - resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] + '@privy-io/chains@0.0.6': + resolution: {integrity: sha512-tcDYv2r4HJBQkEzGS3hau01WmZ4zLmEgaitPUorKvW4IC6qOzxcIXJIqeX6HT6JJjoV8I6xNey0pCsF+VS59ew==} - '@tailwindcss/oxide-darwin-x64@4.1.18': - resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] + '@privy-io/ethereum@0.0.7': + resolution: {integrity: sha512-8eRl2Nk34r16gMft4r1WkQvB4w+TKtmo/EB/lrqVcGpD9jc5Hqq2+SPlZ+PJr2NClioiuhYHq6BItM95l86VGA==} + peerDependencies: + viem: ^2.44.2 - '@tailwindcss/oxide-freebsd-x64@4.1.18': - resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [freebsd] + '@privy-io/js-sdk-core@0.60.0': + resolution: {integrity: sha512-oqD39f1Gd2Eakjxv7tBGuFmEutfm34Zc4IvRirAnKPNcGuvwQJjAepVt4QeVIM2eLWUFiPNMHj2rH0oOTEN+3w==} + peerDependencies: + permissionless: ^0.2.47 + viem: ^2.44.2 + peerDependenciesMeta: + permissionless: + optional: true + viem: + optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': - resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] + '@privy-io/popup@0.0.2': + resolution: {integrity: sha512-kkxzZ5TFseqnZRDVhBR1Si9mTHc1jW+hLSbYviauK80enJOGsOGmxeeC/U0t0kLZGLpn0Jj5v5lNS2bmQ4as/Q==} - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': - resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] + '@privy-io/react-auth@3.13.1': + resolution: {integrity: sha512-GeIzOfIrdt0Smc/SoY4wtwaYon/uePHe1c5ds9rU3QJOxOdKxa3cqXoVspf+49JMzW9MGIjt7w7FYFSg2PdM4w==} + peerDependencies: + '@abstract-foundation/agw-client': ^1.0.0 + '@solana-program/memo': '>=0.8.0' + '@solana-program/system': '>=0.8.0' + '@solana-program/token': '>=0.6.0' + '@solana/kit': '>=3.0.3' + permissionless: ^0.2.47 + react: ^18 || ^19 + react-dom: ^18 || ^19 + peerDependenciesMeta: + '@abstract-foundation/agw-client': + optional: true + '@solana-program/memo': + optional: true + '@solana-program/system': + optional: true + '@solana-program/token': + optional: true + '@solana/kit': + optional: true + permissionless: + optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': - resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] + '@privy-io/routes@0.0.6': + resolution: {integrity: sha512-7km0gKxp9yCaA5r3OatICgmMSVCIlS+zomiF5FUtPynmHzrNPbxxN8QYnISCPioHlljE91fopoDIj23KbFWyvg==} - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': - resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] + '@privy-io/urls@0.0.3': + resolution: {integrity: sha512-cococ82ycPJc+wSCHUG0TrVJXF2PIkmDH8TLV+oGqBr14Q7ziRI63aCFIp6FpSRhdDDo9jmB0VR9NjCak4ptMA==} - '@tailwindcss/oxide-linux-x64-musl@4.1.18': - resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] + '@react-aria/focus@3.21.4': + resolution: {integrity: sha512-6gz+j9ip0/vFRTKJMl3R30MHopn4i19HqqLfSQfElxJD+r9hBnYG1Q6Wd/kl/WRR1+CALn2F+rn06jUnf5sT8Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@tailwindcss/oxide-wasm32-wasi@4.1.18': - resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib + '@react-aria/interactions@3.27.0': + resolution: {integrity: sha512-D27pOy+0jIfHK60BB26AgqjjRFOYdvVSkwC31b2LicIzRCSPOSP06V4gMHuGmkhNTF4+YWDi1HHYjxIvMeiSlA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': - resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] + '@react-aria/ssr@3.9.10': + resolution: {integrity: sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==} + engines: {node: '>= 12'} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': - resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] + '@react-aria/utils@3.33.0': + resolution: {integrity: sha512-yvz7CMH8d2VjwbSa5nGXqjU031tYhD8ddax95VzJsHSPyqHDEGfxul8RkhGV6oO7bVqZxVs6xY66NIgae+FHjw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@tailwindcss/oxide@4.1.18': - resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} - engines: {node: '>= 10'} + '@react-stately/flags@3.1.2': + resolution: {integrity: sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==} - '@tailwindcss/postcss@4.1.18': - resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + '@react-stately/utils@3.11.0': + resolution: {integrity: sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@tokenizer/inflate@0.4.1': - resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} - engines: {node: '>=18'} + '@react-types/shared@3.33.0': + resolution: {integrity: sha512-xuUpP6MyuPmJtzNOqF5pzFUIHH2YogyOQfUQHag54PRmWB7AbjuGWBUv0l1UDmz6+AbzAYGmDVAzcRDOu2PFpw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@tokenizer/token@0.3.0': - resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@reown/appkit-common@1.7.8': + resolution: {integrity: sha512-ridIhc/x6JOp7KbDdwGKY4zwf8/iK8EYBl+HtWrruutSLwZyVi5P8WaZa+8iajL6LcDcDF7LoyLwMTym7SRuwQ==} - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@reown/appkit-common@1.8.9': + resolution: {integrity: sha512-drseYLBDqcQR2WvhfAwrKRiDJdTmsmwZsRBg72sxQDvAwxfKNSmiqsqURq5c/Q9SeeTwclge58Dyq7Ijo6TeeQ==} - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@reown/appkit-controllers@1.7.8': + resolution: {integrity: sha512-IdXlJlivrlj6m63VsGLsjtPHHsTWvKGVzWIP1fXZHVqmK+rZCBDjCi9j267Rb9/nYRGHWBtlFQhO8dK35WfeDA==} - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@reown/appkit-controllers@1.8.9': + resolution: {integrity: sha512-/8hgFAgiYCTDG3gSxJr8hXy6GnO28UxN8JOXFUEi5gOODy7d3+3Jwm+7OEghf7hGKrShDedibsXdXKdX1PUT+g==} - '@types/json5@0.0.29': - resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@reown/appkit-pay@1.7.8': + resolution: {integrity: sha512-OSGQ+QJkXx0FEEjlpQqIhT8zGJKOoHzVnyy/0QFrl3WrQTjCzg0L6+i91Ad5Iy1zb6V5JjqtfIFpRVRWN4M3pw==} - '@types/node@20.19.30': - resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==} + '@reown/appkit-pay@1.8.9': + resolution: {integrity: sha512-AEmaPqxnzjawSRFenyiTtq0vjKM5IPb2CTD9wa+OMXFpe6FissO+1Eg1H47sfdrycZCvUizSRmQmYqkJaI8BCw==} - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} - peerDependencies: - '@types/react': ^19.2.0 + '@reown/appkit-polyfills@1.7.8': + resolution: {integrity: sha512-W/kq786dcHHAuJ3IV2prRLEgD/2iOey4ueMHf1sIFjhhCGMynMkhsOhQMUH0tzodPqUgAC494z4bpIDYjwWXaA==} - '@types/react@19.2.10': - resolution: {integrity: sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==} + '@reown/appkit-polyfills@1.8.9': + resolution: {integrity: sha512-33YCU8dxe4UkpNf9qCAaHx5crSoEu6tbmZxE/0eEPCYRDRXoiH9VGiN7xwTDOVduacg/U8H6/32ibmYZKnRk5Q==} - '@typescript-eslint/eslint-plugin@8.54.0': - resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.54.0 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + '@reown/appkit-scaffold-ui@1.7.8': + resolution: {integrity: sha512-RCeHhAwOrIgcvHwYlNWMcIDibdI91waaoEYBGw71inE0kDB8uZbE7tE6DAXJmDkvl0qPh+DqlC4QbJLF1FVYdQ==} - '@typescript-eslint/parser@8.54.0': - resolution: {integrity: sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + '@reown/appkit-scaffold-ui@1.8.9': + resolution: {integrity: sha512-F7PSM1nxvlvj2eu8iL355GzvCNiL8RKiCqT1zag8aB4QpxjU24l+vAF6debtkg4HY8nJOyDifZ7Z1jkKrHlIDQ==} - '@typescript-eslint/project-service@8.54.0': - resolution: {integrity: sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' + '@reown/appkit-ui@1.7.8': + resolution: {integrity: sha512-1hjCKjf6FLMFzrulhl0Y9Vb9Fu4royE+SXCPSWh4VhZhWqlzUFc7kutnZKx8XZFVQH4pbBvY62SpRC93gqoHow==} - '@typescript-eslint/scope-manager@8.54.0': - resolution: {integrity: sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@reown/appkit-ui@1.8.9': + resolution: {integrity: sha512-WR17ql77KOMKfyDh7RW4oSfmj+p5gIl0u8Wmopzbx5Hd0HcPVZ5HmTDpwOM9WCSxYcin0fsSAoI+nVdvrhWNtw==} - '@typescript-eslint/tsconfig-utils@8.54.0': - resolution: {integrity: sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@reown/appkit-utils@1.7.8': + resolution: {integrity: sha512-8X7UvmE8GiaoitCwNoB86pttHgQtzy4ryHZM9kQpvjQ0ULpiER44t1qpVLXNM4X35O0v18W0Dk60DnYRMH2WRw==} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + valtio: 1.13.2 - '@typescript-eslint/type-utils@8.54.0': - resolution: {integrity: sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@reown/appkit-utils@1.8.9': + resolution: {integrity: sha512-U9hx4h7tIE7ha/QWKjZpZc/imaLumdwe0QNdku9epjp/npXVjGuwUrW5mj8yWNSkjtQpY/BEItNdDAUKZ7rrjw==} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + valtio: 2.1.7 - '@typescript-eslint/types@8.54.0': - resolution: {integrity: sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@reown/appkit-wallet@1.7.8': + resolution: {integrity: sha512-kspz32EwHIOT/eg/ZQbFPxgXq0B/olDOj3YMu7gvLEFz4xyOFd/wgzxxAXkp5LbG4Cp++s/elh79rVNmVFdB9A==} - '@typescript-eslint/typescript-estree@8.54.0': - resolution: {integrity: sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' + '@reown/appkit-wallet@1.8.9': + resolution: {integrity: sha512-rcAXvkzOVG4941eZVCGtr2dSJAMOclzZGSe+8hnOUnhK4zxa5svxiP6K9O5SMBp3MrAS3WNsRj5hqx6+JHb7iA==} - '@typescript-eslint/utils@8.54.0': - resolution: {integrity: sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + '@reown/appkit@1.7.8': + resolution: {integrity: sha512-51kTleozhA618T1UvMghkhKfaPcc9JlKwLJ5uV+riHyvSoWPKPRIa5A6M1Wano5puNyW0s3fwywhyqTHSilkaA==} - '@typescript-eslint/visitor-keys@8.54.0': - resolution: {integrity: sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@reown/appkit@1.8.9': + resolution: {integrity: sha512-e3N2DAzf3Xv3jnoD8IsUo0/Yfwuhk7npwJBe1+9rDJIRwgPsyYcCLD4gKPDFC5IUIfOLqK7YtGOh9oPEUnIWpw==} - '@unrs/resolver-binding-android-arm-eabi@1.11.1': - resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} - cpu: [arm] - os: [android] + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - '@unrs/resolver-binding-android-arm64@1.11.1': - resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} - cpu: [arm64] - os: [android] + '@safe-global/safe-apps-provider@0.18.6': + resolution: {integrity: sha512-4LhMmjPWlIO8TTDC2AwLk44XKXaK6hfBTWyljDm0HQ6TWlOEijVWNrt2s3OCVMSxlXAcEzYfqyu1daHZooTC2Q==} - '@unrs/resolver-binding-darwin-arm64@1.11.1': - resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} - cpu: [arm64] - os: [darwin] + '@safe-global/safe-apps-sdk@9.1.0': + resolution: {integrity: sha512-N5p/ulfnnA2Pi2M3YeWjULeWbjo7ei22JwU/IXnhoHzKq3pYCN6ynL9mJBOlvDVv892EgLPCWCOwQk/uBT2v0Q==} - '@unrs/resolver-binding-darwin-x64@1.11.1': - resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} - cpu: [x64] - os: [darwin] + '@safe-global/safe-gateway-typescript-sdk@3.23.1': + resolution: {integrity: sha512-6ORQfwtEJYpalCeVO21L4XXGSdbEMfyp2hEv6cP82afKXSwvse6d3sdelgaPWUxHIsFRkWvHDdzh8IyyKHZKxw==} + engines: {node: '>=16'} - '@unrs/resolver-binding-freebsd-x64@1.11.1': - resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} - cpu: [x64] - os: [freebsd] + '@scure/base@1.1.9': + resolution: {integrity: sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==} - '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': - resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} - cpu: [arm] - os: [linux] + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} - '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': - resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} - cpu: [arm] - os: [linux] + '@scure/bip32@1.4.0': + resolution: {integrity: sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==} - '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': - resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} - cpu: [arm64] - os: [linux] + '@scure/bip32@1.6.2': + resolution: {integrity: sha512-t96EPDMbtGgtb7onKKqxRLfE5g05k7uHnHRM2xdE6BP/ZmxaLtPek4J4KfVn/90IQNrU1IOAqMgiDtUdtbe3nw==} - '@unrs/resolver-binding-linux-arm64-musl@1.11.1': - resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} - cpu: [arm64] - os: [linux] + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} - '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': - resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} - cpu: [ppc64] - os: [linux] + '@scure/bip39@1.3.0': + resolution: {integrity: sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==} - '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': - resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} - cpu: [riscv64] - os: [linux] + '@scure/bip39@1.5.4': + resolution: {integrity: sha512-TFM4ni0vKvCfBpohoh+/lY05i9gRbSwXWngAsF4CABQxoaOHijxuaZ2R6cStDQ5CHtHO9aGJTr4ksVJASRRyMA==} - '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': - resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} - cpu: [riscv64] - os: [linux] + '@scure/bip39@1.6.0': + resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} - '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': - resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} - cpu: [s390x] - os: [linux] + '@simplewebauthn/browser@13.2.2': + resolution: {integrity: sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==} - '@unrs/resolver-binding-linux-x64-gnu@1.11.1': - resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} - cpu: [x64] - os: [linux] + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} - '@unrs/resolver-binding-linux-x64-musl@1.11.1': - resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} - cpu: [x64] - os: [linux] + '@solana-program/compute-budget@0.11.0': + resolution: {integrity: sha512-7f1ePqB/eURkTwTOO9TNIdUXZcyrZoX3Uy2hNo7cXMfNhPFWp9AVgIyRNBc2jf15sdUa9gNpW+PfP2iV8AYAaw==} + peerDependencies: + '@solana/kit': ^5.0 - '@unrs/resolver-binding-wasm32-wasi@1.11.1': - resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] + '@solana-program/system@0.10.0': + resolution: {integrity: sha512-Go+LOEZmqmNlfr+Gjy5ZWAdY5HbYzk2RBewD9QinEU/bBSzpFfzqDRT55JjFRBGJUvMgf3C2vfXEGT4i8DSI4g==} + peerDependencies: + '@solana/kit': ^5.0 - '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': - resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} - cpu: [arm64] - os: [win32] + '@solana-program/token-2022@0.6.1': + resolution: {integrity: sha512-Ex02cruDMGfBMvZZCrggVR45vdQQSI/unHVpt/7HPt/IwFYB4eTlXtO8otYZyqV/ce5GqZ8S6uwyRf0zy6fdbA==} + peerDependencies: + '@solana/kit': ^5.0 + '@solana/sysvars': ^5.0 - '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': - resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} - cpu: [ia32] - os: [win32] + '@solana-program/token@0.9.0': + resolution: {integrity: sha512-vnZxndd4ED4Fc56sw93cWZ2djEeeOFxtaPS8SPf5+a+JZjKA/EnKqzbE1y04FuMhIVrLERQ8uR8H2h72eZzlsA==} + peerDependencies: + '@solana/kit': ^5.0 - '@unrs/resolver-binding-win32-x64-msvc@1.11.1': - resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} - cpu: [x64] - os: [win32] + '@solana/accounts@5.5.1': + resolution: {integrity: sha512-TfOY9xixg5rizABuLVuZ9XI2x2tmWUC/OoN556xwfDlhBHBjKfszicYYOyD6nbFmwTGYarCmyGIdteXxTXIdhQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - '@vercel/analytics@1.6.1': - resolution: {integrity: sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==} + '@solana/addresses@5.5.1': + resolution: {integrity: sha512-5xoah3Q9G30HQghu/9BiHLb5pzlPKRC3zydQDmE3O9H//WfayxTFppsUDCL6FjYUHqj/wzK6CWHySglc2RkpdA==} + engines: {node: '>=20.18.0'} peerDependencies: - '@remix-run/react': ^2 - '@sveltejs/kit': ^1 || ^2 - next: '>= 13' - react: ^18 || ^19 || ^19.0.0-rc - svelte: '>= 4' - vue: ^3 - vue-router: ^4 + typescript: ^5.0.0 peerDependenciesMeta: - '@remix-run/react': + typescript: optional: true - '@sveltejs/kit': + + '@solana/assertions@5.5.1': + resolution: {integrity: sha512-YTCSWAlGwSlVPnWtWLm3ukz81wH4j2YaCveK+TjpvUU88hTy6fmUqxi0+hvAMAe4zKXpJyj3Az7BrLJRxbIm4Q==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: optional: true - next: + + '@solana/buffer-layout@4.0.1': + resolution: {integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==} + engines: {node: '>=5.10'} + + '@solana/codecs-core@2.3.0': + resolution: {integrity: sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.3.3' + + '@solana/codecs-core@5.5.1': + resolution: {integrity: sha512-TgBt//bbKBct0t6/MpA8ElaOA3sa8eYVvR7LGslCZ84WiAwwjCY0lW/lOYsFHJQzwREMdUyuEyy5YWBKtdh8Rw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: optional: true - react: + + '@solana/codecs-data-structures@5.5.1': + resolution: {integrity: sha512-97bJWGyUY9WvBz3mX1UV3YPWGDTez6btCfD0ip3UVEXJbItVuUiOkzcO5iFDUtQT5riKT6xC+Mzl+0nO76gd0w==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: optional: true - svelte: + + '@solana/codecs-numbers@2.3.0': + resolution: {integrity: sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.3.3' + + '@solana/codecs-numbers@5.5.1': + resolution: {integrity: sha512-rllMIZAHqmtvC0HO/dc/21wDuWaD0B8Ryv8o+YtsICQBuiL/0U4AGwH7Pi5GNFySYk0/crSuwfIqQFtmxNSPFw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: optional: true - vue: + + '@solana/codecs-strings@5.5.1': + resolution: {integrity: sha512-7klX4AhfHYA+uKKC/nxRGP2MntbYQCR3N6+v7bk1W/rSxYuhNmt+FN8aoThSZtWIKwN6BEyR1167ka8Co1+E7A==} + engines: {node: '>=20.18.0'} + peerDependencies: + fastestsmallesttextencoderdecoder: ^1.0.22 + typescript: ^5.0.0 + peerDependenciesMeta: + fastestsmallesttextencoderdecoder: optional: true - vue-router: + typescript: optional: true - '@vercel/oidc@3.1.0': - resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} - engines: {node: '>= 20'} - - '@vercel/sandbox@1.4.1': - resolution: {integrity: sha512-5jaNLv6QJ0112ZFhCv9hQDCV3IYogpbEn5LzMcY5E8TZsf5tF0avat2tBe7McOJvgVs0SDkuzjvGjUMKtTkjrA==} - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + '@solana/codecs@5.5.1': + resolution: {integrity: sha512-Vea29nJub/bXjfzEV7ZZQ/PWr1pYLZo3z0qW0LQL37uKKVzVFRQlwetd7INk3YtTD3xm9WUYr7bCvYUk3uKy2g==} + engines: {node: '>=20.18.0'} peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} + '@solana/errors@2.3.0': + resolution: {integrity: sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==} + engines: {node: '>=20.18.0'} hasBin: true + peerDependencies: + typescript: '>=5.3.3' - ai@6.0.66: - resolution: {integrity: sha512-Klnzjlc3JczRykD75t+Qn5Jt5HwUCaLlN9aZku9KrSDjhc/pab54YH0w85huue7FLPlbTVF5zaQrw3NdEwiGpA==} - engines: {node: '>=18'} + '@solana/errors@5.5.1': + resolution: {integrity: sha512-vFO3p+S7HoyyrcAectnXbdsMfwUzY2zYFUc2DEe5BwpiE9J1IAxPBGjOWO6hL1bbYdBrlmjNx8DXCslqS+Kcmg==} + engines: {node: '>=20.18.0'} + hasBin: true peerDependencies: - zod: ^3.25.76 || ^4.1.8 + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + '@solana/fast-stable-stringify@5.5.1': + resolution: {integrity: sha512-Ni7s2FN33zTzhTFgRjEbOVFO+UAmK8qi3Iu0/GRFYK4jN696OjKHnboSQH/EacQ+yGqS54bfxf409wU5dsLLCw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - amdefine@1.0.1: - resolution: {integrity: sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==} - engines: {node: '>=0.4.2'} + '@solana/functional@5.5.1': + resolution: {integrity: sha512-tTHoJcEQq3gQx5qsdsDJ0LEJeFzwNpXD80xApW9o/PPoCNimI3SALkZl+zNW8VnxRrV3l3yYvfHWBKe/X3WG3w==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} + '@solana/instruction-plans@5.5.1': + resolution: {integrity: sha512-7z3CB7YMcFKuVvgcnNY8bY6IsZ8LG61Iytbz7HpNVGX2u1RthOs1tRW8luTzSG1MPL0Ox7afyAVMYeFqSPHnaQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + '@solana/instructions@5.5.1': + resolution: {integrity: sha512-h0G1CG6S+gUUSt0eo6rOtsaXRBwCq1+Js2a+Ps9Bzk9q7YHNFA75/X0NWugWLgC92waRp66hrjMTiYYnLBoWOQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + '@solana/keys@5.5.1': + resolution: {integrity: sha512-KRD61cL7CRL+b4r/eB9dEoVxIf/2EJ1Pm1DmRYhtSUAJD2dJ5Xw8QFuehobOGm9URqQ7gaQl+Fkc1qvDlsWqKg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} + '@solana/kit@5.5.1': + resolution: {integrity: sha512-irKUGiV2yRoyf+4eGQ/ZeCRxa43yjFEL1DUI5B0DkcfZw3cr0VJtVJnrG8OtVF01vT0OUfYOcUn6zJW5TROHvQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - array-buffer-byte-length@1.0.2: - resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} - engines: {node: '>= 0.4'} + '@solana/nominal-types@5.5.1': + resolution: {integrity: sha512-I1ImR+kfrLFxN5z22UDiTWLdRZeKtU0J/pkWkO8qm/8WxveiwdIv4hooi8pb6JnlR4mSrWhq0pCIOxDYrL9GIQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - array-includes@3.1.9: - resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} - engines: {node: '>= 0.4'} + '@solana/offchain-messages@5.5.1': + resolution: {integrity: sha512-g+xHH95prTU+KujtbOzj8wn+C7ZNoiLhf3hj6nYq3MTyxOXtBEysguc97jJveUZG0K97aIKG6xVUlMutg5yxhw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - array.prototype.findlast@1.2.5: - resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} - engines: {node: '>= 0.4'} + '@solana/options@5.5.1': + resolution: {integrity: sha512-eo971c9iLNLmk+yOFyo7yKIJzJ/zou6uKpy6mBuyb/thKtS/haiKIc3VLhyTXty3OH2PW8yOlORJnv4DexJB8A==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - array.prototype.findlastindex@1.2.6: - resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} - engines: {node: '>= 0.4'} + '@solana/plugin-core@5.5.1': + resolution: {integrity: sha512-VUZl30lDQFJeiSyNfzU1EjYt2QZvoBFKEwjn1lilUJw7KgqD5z7mbV7diJhT+dLFs36i0OsjXvq5kSygn8YJ3A==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - array.prototype.flat@1.3.3: - resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} - engines: {node: '>= 0.4'} + '@solana/programs@5.5.1': + resolution: {integrity: sha512-7U9kn0Jsx1NuBLn5HRTFYh78MV4XN145Yc3WP/q5BlqAVNlMoU9coG5IUTJIG847TUqC1lRto3Dnpwm6T4YRpA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - array.prototype.flatmap@1.3.3: - resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} - engines: {node: '>= 0.4'} + '@solana/promises@5.5.1': + resolution: {integrity: sha512-T9lfuUYkGykJmppEcssNiCf6yiYQxJkhiLPP+pyAc2z84/7r3UVIb2tNJk4A9sucS66pzJnVHZKcZVGUUp6wzA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - array.prototype.tosorted@1.1.4: - resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} - engines: {node: '>= 0.4'} + '@solana/rpc-api@5.5.1': + resolution: {integrity: sha512-XWOQQPhKl06Vj0xi3RYHAc6oEQd8B82okYJ04K7N0Vvy3J4PN2cxeK7klwkjgavdcN9EVkYCChm2ADAtnztKnA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - arraybuffer.prototype.slice@1.0.4: - resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} - engines: {node: '>= 0.4'} + '@solana/rpc-parsed-types@5.5.1': + resolution: {integrity: sha512-HEi3G2nZqGEsa3vX6U0FrXLaqnUCg4SKIUrOe8CezD+cSFbRTOn3rCLrUmJrhVyXlHoQVaRO9mmeovk31jWxJg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - ast-types-flow@0.0.8: - resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + '@solana/rpc-spec-types@5.5.1': + resolution: {integrity: sha512-6OFKtRpIEJQs8Jb2C4OO8KyP2h2Hy1MFhatMAoXA+0Ik8S3H+CicIuMZvGZ91mIu/tXicuOOsNNLu3HAkrakrw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} - - async-retry@1.3.3: - resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} - - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - - axe-core@4.11.1: - resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} - engines: {node: '>=4'} + '@solana/rpc-spec@5.5.1': + resolution: {integrity: sha512-m3LX2bChm3E3by4mQrH4YwCAFY57QBzuUSWqlUw7ChuZ+oLLOq7b2czi4i6L4Vna67j3eCmB3e+4tqy1j5wy7Q==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} - engines: {node: '>= 0.4'} + '@solana/rpc-subscriptions-api@5.5.1': + resolution: {integrity: sha512-5Oi7k+GdeS8xR2ly1iuSFkAv6CZqwG0Z6b1QZKbEgxadE1XGSDrhM2cn59l+bqCozUWCqh4c/A2znU/qQjROlw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - b4a@1.7.3: - resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} + '@solana/rpc-subscriptions-channel-websocket@5.5.1': + resolution: {integrity: sha512-7tGfBBrYY8TrngOyxSHoCU5shy86iA9SRMRrPSyBhEaZRAk6dnbdpmUTez7gtdVo0BCvh9nzQtUycKWSS7PnFQ==} + engines: {node: '>=20.18.0'} peerDependencies: - react-native-b4a: '*' + typescript: ^5.0.0 peerDependenciesMeta: - react-native-b4a: + typescript: optional: true - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + '@solana/rpc-subscriptions-spec@5.5.1': + resolution: {integrity: sha512-iq+rGq5fMKP3/mKHPNB6MC8IbVW41KGZg83Us/+LE3AWOTWV1WT20KT2iH1F1ik9roi42COv/TpoZZvhKj45XQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - bare-events@2.8.2: - resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + '@solana/rpc-subscriptions@5.5.1': + resolution: {integrity: sha512-CTMy5bt/6mDh4tc6vUJms9EcuZj3xvK0/xq8IQ90rhkpYvate91RjBP+egvjgSayUg9yucU9vNuUpEjz4spM7w==} + engines: {node: '>=20.18.0'} peerDependencies: - bare-abort-controller: '*' + typescript: ^5.0.0 peerDependenciesMeta: - bare-abort-controller: + typescript: optional: true - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + '@solana/rpc-transformers@5.5.1': + resolution: {integrity: sha512-OsWqLCQdcrRJKvHiMmwFhp9noNZ4FARuMkHT5us3ustDLXaxOjF0gfqZLnMkulSLcKt7TGXqMhBV+HCo7z5M8Q==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - baseline-browser-mapping@2.9.19: - resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} - hasBin: true + '@solana/rpc-transport-http@5.5.1': + resolution: {integrity: sha512-yv8GoVSHqEV0kUJEIhkdOVkR2SvJ6yoWC51cJn2rSV7plr6huLGe0JgujCmB7uZhhaLbcbP3zxXxu9sOjsi7Fg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - bash-tool@1.3.13: - resolution: {integrity: sha512-rSpikC1vCqSpw25g4S3zSMAZYMGF0iCw9zIJ+/wDlI2mVPgi0cJzflcUQy7kt5jL+T7VyiZgcH1ktlXDp6OxyQ==} - engines: {node: '>=18'} + '@solana/rpc-types@5.5.1': + resolution: {integrity: sha512-bibTFQ7PbHJJjGJPmfYC2I+/5CRFS4O2p9WwbFraX1Keeel+nRrt/NBXIy8veP5AEn2sVJIyJPpWBRpCx1oATA==} + engines: {node: '>=20.18.0'} peerDependencies: - '@vercel/sandbox': '*' - ai: ^6.0.0 - just-bash: ^2.9.1 + typescript: ^5.0.0 peerDependenciesMeta: - '@vercel/sandbox': + typescript: optional: true - just-bash: + + '@solana/rpc@5.5.1': + resolution: {integrity: sha512-ku8zTUMrkCWci66PRIBC+1mXepEnZH/q1f3ck0kJZ95a06bOTl5KU7HeXWtskkyefzARJ5zvCs54AD5nxjQJ+A==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: optional: true - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + '@solana/signers@5.5.1': + resolution: {integrity: sha512-FY0IVaBT2kCAze55vEieR6hag4coqcuJ31Aw3hqRH7mv6sV8oqwuJmUrx+uFwOp1gwd5OEAzlv6N4hOOple4sQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + '@solana/subscribable@5.5.1': + resolution: {integrity: sha512-9K0PsynFq0CsmK1CDi5Y2vUIJpCqkgSS5yfDN0eKPgHqEptLEaia09Kaxc90cSZDZU5mKY/zv1NBmB6Aro9zQQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + '@solana/sysvars@5.5.1': + resolution: {integrity: sha512-k3Quq87Mm+geGUu1GWv6knPk0ALsfY6EKSJGw9xUJDHzY/RkYSBnh0RiOrUhtFm2TDNjOailg8/m0VHmi3reFA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} + '@solana/transaction-confirmation@5.5.1': + resolution: {integrity: sha512-j4mKlYPHEyu+OD7MBt3jRoX4ScFgkhZC6H65on4Fux6LMScgivPJlwnKoZMnsgxFgWds0pl+BYzSiALDsXlYtw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true + '@solana/transaction-messages@5.5.1': + resolution: {integrity: sha512-aXyhMCEaAp3M/4fP0akwBBQkFPr4pfwoC5CLDq999r/FUwDax2RE/h4Ic7h2Xk+JdcUwsb+rLq85Y52hq84XvQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + '@solana/transactions@5.5.1': + resolution: {integrity: sha512-8hHtDxtqalZ157pnx6p8k10D7J/KY/biLzfgh9R09VNLLY3Fqi7kJvJCr7M2ik3oRll56pxhraAGCC9yIT6eOA==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} + '@solana/wallet-standard-features@1.3.0': + resolution: {integrity: sha512-ZhpZtD+4VArf6RPitsVExvgkF+nGghd1rzPjd97GmBximpnt1rsUxMOEyoIEuH3XBxPyNB6Us7ha7RHWQR+abg==} + engines: {node: '>=16'} - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} - engines: {node: '>= 0.4'} + '@solana/web3.js@1.98.4': + resolution: {integrity: sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==} - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - caniuse-lite@1.0.30001766: - resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} + '@tailwindcss/node@4.1.18': + resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} + '@tailwindcss/oxide-android-arm64@4.1.18': + resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] - chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + '@tailwindcss/oxide-darwin-arm64@4.1.18': + resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] - client-only@0.0.1: - resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + '@tailwindcss/oxide-darwin-x64@4.1.18': + resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} + '@tailwindcss/oxide-freebsd-x64@4.1.18': + resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] - commander@2.8.1: - resolution: {integrity: sha512-+pJLBFVk+9ZZdlAOB5WuIElVPPth47hILFkmGym57aq8kwxsowvByvB0DHs1vQAhyMZzdcpTtF0VDKGkSDR4ZQ==} - engines: {node: '>= 0.6.x'} + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] - compressjs@1.0.3: - resolution: {integrity: sha512-jpKJjBTretQACTGLNuvnozP1JdP2ZLrjdGdBgk/tz1VfXlUcBhhSZW6vEsuThmeot/yjvSrPQKEgfF3X2Lpi8Q==} - hasBin: true + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] - damerau-levenshtein@1.0.8: - resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] - data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} + '@tailwindcss/oxide@4.1.18': + resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} + engines: {node: '>= 10'} - data-view-byte-length@1.0.2: - resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} - engines: {node: '>= 0.4'} + '@tailwindcss/postcss@4.1.18': + resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} - data-view-byte-offset@1.0.1: - resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} - engines: {node: '>= 0.4'} + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} - debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + '@tanstack/react-query@5.90.20': + resolution: {integrity: sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==} peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true + react: ^18 || ^19 - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} + '@tanstack/react-virtual@3.13.18': + resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==} peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} + '@tanstack/virtual-core@3.13.18': + resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - diff@8.0.3: - resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} - engines: {node: '>=0.3.1'} - - doctrine@2.1.0: - resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} - engines: {node: '>=0.10.0'} + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - electron-to-chromium@1.5.283: - resolution: {integrity: sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - enhanced-resolve@5.18.4: - resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} - engines: {node: '>=10.13.0'} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - es-abstract@1.24.1: - resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} - engines: {node: '>= 0.4'} + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} + '@types/lodash@4.17.23': + resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - es-iterator-helpers@1.2.2: - resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} - engines: {node: '>= 0.4'} + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} + '@types/node@20.19.30': + resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==} - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 - es-shim-unscopables@1.1.0: - resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} - engines: {node: '>= 0.4'} + '@types/react@19.2.10': + resolution: {integrity: sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==} - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} - engines: {node: '>= 0.4'} + '@types/stylis@4.2.7': + resolution: {integrity: sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==} - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} + '@types/uuid@8.3.4': + resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} - eslint-config-next@16.1.6: - resolution: {integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==} - peerDependencies: - eslint: '>=9.0.0' - typescript: '>=3.3.1' - peerDependenciesMeta: - typescript: - optional: true + '@types/ws@7.4.7': + resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==} - eslint-import-resolver-node@0.3.9: - resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - eslint-import-resolver-typescript@3.10.1: - resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} - engines: {node: ^14.18.0 || >=16.0.0} + '@typescript-eslint/eslint-plugin@8.54.0': + resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: '*' - eslint-plugin-import: '*' - eslint-plugin-import-x: '*' - peerDependenciesMeta: - eslint-plugin-import: - optional: true - eslint-plugin-import-x: - optional: true + '@typescript-eslint/parser': ^8.54.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' - eslint-module-utils@2.12.1: - resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} - engines: {node: '>=4'} + '@typescript-eslint/parser@8.54.0': + resolution: {integrity: sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' - eslint-plugin-import@2.32.0: - resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} - engines: {node: '>=4'} + '@typescript-eslint/project-service@8.54.0': + resolution: {integrity: sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true + typescript: '>=4.8.4 <6.0.0' - eslint-plugin-jsx-a11y@6.10.2: - resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} - engines: {node: '>=4.0'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + '@typescript-eslint/scope-manager@8.54.0': + resolution: {integrity: sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint-plugin-react-hooks@7.0.1: - resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} - engines: {node: '>=18'} + '@typescript-eslint/tsconfig-utils@8.54.0': + resolution: {integrity: sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' - eslint-plugin-react@7.37.5: - resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} - engines: {node: '>=4'} + '@typescript-eslint/type-utils@8.54.0': + resolution: {integrity: sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + '@typescript-eslint/types@8.54.0': + resolution: {integrity: sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + '@typescript-eslint/typescript-estree@8.54.0': + resolution: {integrity: sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' - eslint@9.39.2: - resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} + '@typescript-eslint/utils@8.54.0': + resolution: {integrity: sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - hasBin: true peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + '@typescript-eslint/visitor-keys@8.54.0': + resolution: {integrity: sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] - events-universal@1.0.1: - resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} - engines: {node: '>=18.0.0'} + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] - expand-template@2.0.3: - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} - engines: {node: '>=6'} + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] - extend-shallow@2.0.1: - resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} - engines: {node: '>=0.10.0'} + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] - fast-check@4.5.3: - resolution: {integrity: sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==} - engines: {node: '>=12.17.0'} + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] - fast-fifo@1.3.2: - resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] - fast-glob@3.3.1: - resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} - engines: {node: '>=8.6.0'} + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] - fast-xml-parser@5.3.4: - resolution: {integrity: sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==} - hasBin: true + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + '@vercel/analytics@1.6.1': + resolution: {integrity: sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==} peerDependencies: - picomatch: ^3 || ^4 + '@remix-run/react': ^2 + '@sveltejs/kit': ^1 || ^2 + next: '>= 13' + react: ^18 || ^19 || ^19.0.0-rc + svelte: '>= 4' + vue: ^3 + vue-router: ^4 peerDependenciesMeta: - picomatch: + '@remix-run/react': + optional: true + '@sveltejs/kit': + optional: true + next: + optional: true + react: + optional: true + svelte: + optional: true + vue: + optional: true + vue-router: optional: true - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} + '@vercel/oidc@3.1.0': + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} + engines: {node: '>= 20'} - file-type@21.3.0: - resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} - engines: {node: '>=20'} + '@vercel/sandbox@1.4.1': + resolution: {integrity: sha512-5jaNLv6QJ0112ZFhCv9hQDCV3IYogpbEn5LzMcY5E8TZsf5tF0avat2tBe7McOJvgVs0SDkuzjvGjUMKtTkjrA==} - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} + '@wagmi/connectors@6.2.0': + resolution: {integrity: sha512-2NfkbqhNWdjfibb4abRMrn7u6rPjEGolMfApXss6HCDVt9AW2oVC6k8Q5FouzpJezElxLJSagWz9FW1zaRlanA==} + peerDependencies: + '@wagmi/core': 2.22.1 + typescript: '>=5.0.4' + viem: 2.x + peerDependenciesMeta: + typescript: + optional: true - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} + '@wagmi/core@2.22.1': + resolution: {integrity: sha512-cG/xwQWsBEcKgRTkQVhH29cbpbs/TdcUJVFXCyri3ZknxhMyGv0YEjTcrNpRgt2SaswL1KrvslSNYKKo+5YEAg==} + peerDependencies: + '@tanstack/query-core': '>=5.0.0' + typescript: '>=5.0.4' + viem: 2.x + peerDependenciesMeta: + '@tanstack/query-core': + optional: true + typescript: + optional: true - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + '@wallet-standard/app@1.1.0': + resolution: {integrity: sha512-3CijvrO9utx598kjr45hTbbeeykQrQfKmSnxeWOgU25TOEpvcipD/bYDQWIqUv1Oc6KK4YStokSMu/FBNecGUQ==} engines: {node: '>=16'} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + '@wallet-standard/base@1.1.0': + resolution: {integrity: sha512-DJDQhjKmSNVLKWItoKThJS+CsJQjR9AOBOirBVT1F9YpRyC9oYHE+ZnSf8y8bxUphtKqdQMPVQ2mHohYdRvDVQ==} + engines: {node: '>=16'} - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} + '@wallet-standard/features@1.1.0': + resolution: {integrity: sha512-hiEivWNztx73s+7iLxsuD1sOJ28xtRix58W7Xnz4XzzA/pF0+aicnWgjOdA10doVDEDZdUuZCIIqG96SFNlDUg==} + engines: {node: '>=16'} - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + '@wallet-standard/wallet@1.1.0': + resolution: {integrity: sha512-Gt8TnSlDZpAl+RWOOAB/kuvC7RpcdWAlFbHNoi4gsXsfaWa1QCT6LBcfIYTPdOZC9OVZUDwqGuGAcqZejDmHjg==} + engines: {node: '>=16'} - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + '@walletconnect/core@2.21.0': + resolution: {integrity: sha512-o6R7Ua4myxR8aRUAJ1z3gT9nM+jd2B2mfamu6arzy1Cc6vi10fIwFWb6vg3bC8xJ6o9H3n/cN5TOW3aA9Y1XVw==} + engines: {node: '>=18'} - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} - engines: {node: '>= 0.4'} + '@walletconnect/core@2.21.1': + resolution: {integrity: sha512-Tp4MHJYcdWD846PH//2r+Mu4wz1/ZU/fr9av1UWFiaYQ2t2TPLDiZxjLw54AAEpMqlEHemwCgiRiAmjR1NDdTQ==} + engines: {node: '>=18'} - functions-have-names@1.2.3: - resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + '@walletconnect/core@2.21.9': + resolution: {integrity: sha512-SlSknLvbO4i9Y4y8zU0zeCuJv1klQIUX3HRSBs1BaYvQKVVkrdiWPgRj4jcrL2wEOINa9NXw6HXp6x5XCXOolA==} + engines: {node: '>=18.20.8'} - geist@1.5.1: - resolution: {integrity: sha512-mAHZxIsL2o3ZITFaBVFBnwyDOw+zNLYum6A6nIjpzCGIO8QtC3V76XF2RnZTyLx1wlDTmMDy8jg3Ib52MIjGvQ==} - peerDependencies: - next: '>=13.2.0' + '@walletconnect/core@2.22.4': + resolution: {integrity: sha512-ZQnyDDpqDPAk5lyLV19BRccQ3wwK3LmAwibuIv3X+44aT/dOs2kQGu9pla3iW2LgZ5qRMYvgvvfr5g3WlDGceQ==} + engines: {node: '>=18.20.8'} - generator-function@2.0.1: - resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} - engines: {node: '>= 0.4'} + '@walletconnect/environment@1.0.1': + resolution: {integrity: sha512-T426LLZtHj8e8rYnKfzsw1aG6+M0BT1ZxayMdv/p8yM0MU+eJDISqNY3/bccxRr4LrF9csq02Rhqt08Ibl0VRg==} - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} + '@walletconnect/ethereum-provider@2.21.1': + resolution: {integrity: sha512-SSlIG6QEVxClgl1s0LMk4xr2wg4eT3Zn/Hb81IocyqNSGfXpjtawWxKxiC5/9Z95f1INyBD6MctJbL/R1oBwIw==} + deprecated: 'Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases' - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} + '@walletconnect/ethereum-provider@2.22.4': + resolution: {integrity: sha512-qhBxU95nlndiKGz8lO8z9JlsA4Ai8i1via4VWut2fXsW1fkl6qXG9mYhDRFsbavuynUe3dQ+QLjBVDaaNkcKCA==} - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} + '@walletconnect/events@1.0.1': + resolution: {integrity: sha512-NPTqaoi0oPBVNuLv7qPaJazmGHs5JGyO8eEAk5VGKmJzDR7AHzD4k6ilox5kxk1iwiOnFopBOOMLs86Oa76HpQ==} - get-symbol-description@1.1.0: - resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} - engines: {node: '>= 0.4'} + '@walletconnect/heartbeat@1.2.2': + resolution: {integrity: sha512-uASiRmC5MwhuRuf05vq4AT48Pq8RMi876zV8rr8cV969uTOzWdB/k+Lj5yI2PBtB1bGQisGen7MM1GcZlQTBXw==} - get-tsconfig@4.13.1: - resolution: {integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==} + '@walletconnect/jsonrpc-http-connection@1.0.8': + resolution: {integrity: sha512-+B7cRuaxijLeFDJUq5hAzNyef3e3tBDIxyaCNmFtjwnod5AGis3RToNqzFU33vpVcxFhofkpE7Cx+5MYejbMGw==} - github-from-package@0.0.0: - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + '@walletconnect/jsonrpc-provider@1.0.14': + resolution: {integrity: sha512-rtsNY1XqHvWj0EtITNeuf8PHMvlCLiS3EjQL+WOkxEOA4KPxsohFnBDeyPYiNm4ZvkQdLnece36opYidmtbmow==} - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} + '@walletconnect/jsonrpc-types@1.0.4': + resolution: {integrity: sha512-P6679fG/M+wuWg9TY8mh6xFSdYnFyFjwFelxyISxMDrlbXokorEVXYOxiqEbrU3x1BmBoCAJJ+vtEaEoMlpCBQ==} - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} + '@walletconnect/jsonrpc-utils@1.0.8': + resolution: {integrity: sha512-vdeb03bD8VzJUL6ZtzRYsFMq1eZQcM3EAzT0a3st59dyLfJ0wq+tKMpmGH7HlB7waD858UWgfIcudbPFsbzVdw==} - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} + '@walletconnect/jsonrpc-ws-connection@1.0.16': + resolution: {integrity: sha512-G81JmsMqh5nJheE1mPst1W0WfVv0SG3N7JggwLLGnI7iuDZJq8cRJvQwLGKHn5H1WTW7DEPCo00zz5w62AbL3Q==} - globals@16.4.0: - resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} - engines: {node: '>=18'} + '@walletconnect/keyvaluestorage@1.1.1': + resolution: {integrity: sha512-V7ZQq2+mSxAq7MrRqDxanTzu2RcElfK1PfNYiaVnJgJ7Q7G7hTVwF8voIBx92qsRyGHZihrwNPHuZd1aKkd0rA==} + peerDependencies: + '@react-native-async-storage/async-storage': 1.x + peerDependenciesMeta: + '@react-native-async-storage/async-storage': + optional: true - globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} + '@walletconnect/logger@2.1.2': + resolution: {integrity: sha512-aAb28I3S6pYXZHQm5ESB+V6rDqIYfsnHaQyzFbwUUBFY4H0OXx/YtTl8lvhUNhMMfb9UxbwEBS253TlXUYJWSw==} - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} + '@walletconnect/logger@3.0.0': + resolution: {integrity: sha512-DDktPBFdmt5d7U3sbp4e3fQHNS1b6amsR8FmtOnt6L2SnV7VfcZr8VmAGL12zetAR+4fndegbREmX0P8Mw6eDg==} - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + '@walletconnect/relay-api@1.0.11': + resolution: {integrity: sha512-tLPErkze/HmC9aCmdZOhtVmYZq1wKfWTJtygQHoWtgg722Jd4homo54Cs4ak2RUFUZIGO2RsOpIcWipaua5D5Q==} - graceful-readlink@1.0.1: - resolution: {integrity: sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w==} + '@walletconnect/relay-auth@1.1.0': + resolution: {integrity: sha512-qFw+a9uRz26jRCDgL7Q5TA9qYIgcNY8jpJzI1zAWNZ8i7mQjaijRnWFKsCHAU9CyGjvt6RKrRXyFtFOpWTVmCQ==} - gray-matter@4.0.3: - resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} - engines: {node: '>=6.0'} + '@walletconnect/safe-json@1.0.2': + resolution: {integrity: sha512-Ogb7I27kZ3LPC3ibn8ldyUr5544t3/STow9+lzz7Sfo808YD7SBWk7SAsdBFlYgP2zDRy2hS3sKRcuSRM0OTmA==} - has-bigints@1.1.0: - resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} - engines: {node: '>= 0.4'} + '@walletconnect/sign-client@2.21.0': + resolution: {integrity: sha512-z7h+PeLa5Au2R591d/8ZlziE0stJvdzP9jNFzFolf2RG/OiXulgFKum8PrIyXy+Rg2q95U9nRVUF9fWcn78yBA==} + deprecated: 'Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases' - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} + '@walletconnect/sign-client@2.21.1': + resolution: {integrity: sha512-QaXzmPsMnKGV6tc4UcdnQVNOz4zyXgarvdIQibJ4L3EmLat73r5ZVl4c0cCOcoaV7rgM9Wbphgu5E/7jNcd3Zg==} + deprecated: 'Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases' - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + '@walletconnect/sign-client@2.21.9': + resolution: {integrity: sha512-EKLDS97o1rk/0XilD0nQdSR9SNgRsVoIK5M5HpS9sDTvHPv2EF5pIqu6Xr2vLsKcQ0KnCx+D5bnpav8Yh4NVZg==} - has-proto@1.2.0: - resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} - engines: {node: '>= 0.4'} + '@walletconnect/sign-client@2.22.4': + resolution: {integrity: sha512-la+sol0KL33Fyx5DRlupHREIv8wA6W33bRfuLAfLm8pINRTT06j9rz0IHIqJihiALebFxVZNYzJnF65PhV0q3g==} - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} + '@walletconnect/time@1.0.2': + resolution: {integrity: sha512-uzdd9woDcJ1AaBZRhqy5rNC9laqWGErfc4dxA9a87mPdKOgWMD85mcFo9dIYIts/Jwocfwn07EC6EzclKubk/g==} - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} + '@walletconnect/types@2.21.0': + resolution: {integrity: sha512-ll+9upzqt95ZBWcfkOszXZkfnpbJJ2CmxMfGgE5GmhdxxxCcO5bGhXkI+x8OpiS555RJ/v/sXJYMSOLkmu4fFw==} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} + '@walletconnect/types@2.21.1': + resolution: {integrity: sha512-UeefNadqP6IyfwWC1Yi7ux+ljbP2R66PLfDrDm8izmvlPmYlqRerJWJvYO4t0Vvr9wrG4Ko7E0c4M7FaPKT/sQ==} - hermes-estree@0.25.1: - resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + '@walletconnect/types@2.21.9': + resolution: {integrity: sha512-+82TRNX3lGRO96WyLISaBs/FkLts7y4hVgmOI4we84I7XdBu1xsjgiJj0JwYXnurz+X94lTqzOkzPps+wadWKw==} - hermes-parser@0.25.1: - resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + '@walletconnect/types@2.22.4': + resolution: {integrity: sha512-KJdiS9ezXzx1uASanldYaaenDwb42VOQ6Rj86H7FRwfYddhNnYnyEaDjDKOdToGRGcpt5Uzom6qYUOnrWEbp5g==} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + '@walletconnect/universal-provider@2.21.0': + resolution: {integrity: sha512-mtUQvewt+X0VBQay/xOJBvxsB3Xsm1lTwFjZ6WUwSOTR1X+FNb71hSApnV5kbsdDIpYPXeQUbGt2se1n5E5UBg==} + deprecated: 'Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases' - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} + '@walletconnect/universal-provider@2.21.1': + resolution: {integrity: sha512-Wjx9G8gUHVMnYfxtasC9poGm8QMiPCpXpbbLFT+iPoQskDDly8BwueWnqKs4Mx2SdIAWAwuXeZ5ojk5qQOxJJg==} + deprecated: 'Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases' - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} + '@walletconnect/universal-provider@2.21.9': + resolution: {integrity: sha512-dVA9DWSz9jYe37FW5GSRV5zlY9E7rX1kktcDGI7i1/9oG/z9Pk5UKp5r/DFys4Zjml9wZc46R/jlEgeBXTT06A==} - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} + '@walletconnect/universal-provider@2.22.4': + resolution: {integrity: sha512-TF2RNX13qxa0rrBAhVDs5+C2G8CHX7L0PH5hF2uyQHdGyxZ3pFbXf8rxmeW1yKlB76FSbW80XXNrUes6eK/xHg==} - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} + '@walletconnect/utils@2.21.0': + resolution: {integrity: sha512-zfHLiUoBrQ8rP57HTPXW7rQMnYxYI4gT9yTACxVW6LhIFROTF6/ytm5SKNoIvi4a5nX5dfXG4D9XwQUCu8Ilig==} - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + '@walletconnect/utils@2.21.1': + resolution: {integrity: sha512-VPZvTcrNQCkbGOjFRbC24mm/pzbRMUq2DSQoiHlhh0X1U7ZhuIrzVtAoKsrzu6rqjz0EEtGxCr3K1TGRqDG4NA==} - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + '@walletconnect/utils@2.21.9': + resolution: {integrity: sha512-FHagysDvp7yQl+74veIeuqwZZnMiTyTW3Lw0NXsbIKnlmlSQu5pma+4EnRD/CnSzbN6PV39k2t1KBaaZ4PjDgg==} - ini@6.0.0: - resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} - engines: {node: ^20.17.0 || >=22.9.0} + '@walletconnect/utils@2.22.4': + resolution: {integrity: sha512-coAPrNiTiD+snpiXQyXakMVeYcddqVqII7aLU39TeILdPoXeNPc2MAja+MF7cKNM/PA3tespljvvxck/oTm4+Q==} - internal-slot@1.1.0: - resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} - engines: {node: '>= 0.4'} + '@walletconnect/window-getters@1.0.1': + resolution: {integrity: sha512-vHp+HqzGxORPAN8gY03qnbTMnhqIwjeRJNOMOAzePRg4xVEEE2WvYsI9G2NMjOknA8hnuYbU3/hwLcKbjhc8+Q==} - is-array-buffer@3.0.5: - resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} - engines: {node: '>= 0.4'} + '@walletconnect/window-metadata@1.0.1': + resolution: {integrity: sha512-9koTqyGrM2cqFRW517BPY/iEtUDx2r1+Pwwu5m7sJ7ka79wi3EyqhqcICk/yDmv6jAS1rjKgTKXlEhanYjijcA==} - is-async-function@2.1.1: - resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} - engines: {node: '>= 0.4'} + abitype@1.0.6: + resolution: {integrity: sha512-MMSqYh4+C/aVqI2RQaWqbvI4Kxo5cQV40WQ4QFtDnNzCkqChm8MuENhElmynZlO0qUy/ObkEUaXtKqYnx1Kp3A==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3 >=3.22.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true - is-bigint@1.1.0: - resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} - engines: {node: '>= 0.4'} + abitype@1.0.8: + resolution: {integrity: sha512-ZeiI6h3GnW06uYDLx0etQtX/p8E24UaHHBj57RSjK7YBFe7iuVn07EDpOeP451D06sF27VOz9JJPlIKJmXgkEg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3 >=3.22.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true - is-boolean-object@1.2.2: - resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} - engines: {node: '>= 0.4'} + abitype@1.2.3: + resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true - is-bun-module@2.0.0: - resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true - is-data-view@1.0.2: - resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} - engines: {node: '>= 0.4'} + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} - is-date-object@1.1.0: - resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} - engines: {node: '>= 0.4'} + ai@6.0.66: + resolution: {integrity: sha512-Klnzjlc3JczRykD75t+Qn5Jt5HwUCaLlN9aZku9KrSDjhc/pab54YH0w85huue7FLPlbTVF5zaQrw3NdEwiGpA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 - is-extendable@0.1.1: - resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} - engines: {node: '>=0.10.0'} + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} + amdefine@1.0.1: + resolution: {integrity: sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==} + engines: {node: '>=0.4.2'} - is-finalizationregistry@1.1.1: - resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} - engines: {node: '>= 0.4'} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} - is-generator-function@1.1.2: - resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} - engines: {node: '>= 0.4'} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} - is-map@2.0.3: - resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} - engines: {node: '>= 0.4'} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - is-negative-zero@2.0.3: - resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} - engines: {node: '>= 0.4'} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - is-number-object@1.1.1: - resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} - is-set@2.0.3: - resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} - is-shared-array-buffer@1.0.4: - resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} engines: {node: '>= 0.4'} - is-string@1.1.1: - resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} engines: {node: '>= 0.4'} - is-symbol@1.1.1: - resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} engines: {node: '>= 0.4'} - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} engines: {node: '>= 0.4'} - is-weakmap@2.0.2: - resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} engines: {node: '>= 0.4'} - is-weakref@1.1.1: - resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} - is-weakset@2.0.4: - resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + async-mutex@0.2.6: + resolution: {integrity: sha512-Hs4R+4SPgamu6rSGW8C7cV9gaWUKEHykfzCCvIRuaVv636Ju10ZdeUbvb4TBEW0INuq2DHZqXbK4Nd3yG4RaRw==} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} - iterator.prototype@1.1.5: - resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} - hasBin: true + axe-core@4.11.1: + resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} + engines: {node: '>=4'} - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + axios-retry@4.5.0: + resolution: {integrity: sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==} + peerDependencies: + axios: 0.x || 1.x - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} - hasBin: true + axios@1.13.5: + resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true + b4a@1.7.3: + resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true - json-schema@0.4.0: - resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + base-x@3.0.11: + resolution: {integrity: sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==} - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + base-x@5.0.1: + resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} - json5@1.0.2: - resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} - hasBin: true + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} + baseline-browser-mapping@2.9.19: + resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true - jsonlines@0.1.1: - resolution: {integrity: sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==} + bash-tool@1.3.13: + resolution: {integrity: sha512-rSpikC1vCqSpw25g4S3zSMAZYMGF0iCw9zIJ+/wDlI2mVPgi0cJzflcUQy7kt5jL+T7VyiZgcH1ktlXDp6OxyQ==} + engines: {node: '>=18'} + peerDependencies: + '@vercel/sandbox': '*' + ai: ^6.0.0 + just-bash: ^2.9.1 + peerDependenciesMeta: + '@vercel/sandbox': + optional: true + just-bash: + optional: true - jsx-ast-utils@3.3.5: - resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} - engines: {node: '>=4.0'} + big.js@6.2.2: + resolution: {integrity: sha512-y/ie+Faknx7sZA5MfGA2xKlu0GDv8RWrXGsmlteyJQ2lvoKv9GBK/fpRMc2qlSoBAgNxrixICFCBefIq8WCQpQ==} - just-bash@2.9.5: - resolution: {integrity: sha512-ZYc4wVxE+jLDgjcwVVqKOT2hM8DG+gUUKMZAhfUULjbP9wT8U9V0uItA8tREpl2VnDLjzrxxK6jV7vbY/TLhNw==} - hasBin: true + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + blakejs@1.2.1: + resolution: {integrity: sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==} - kind-of@6.0.3: - resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} - engines: {node: '>=0.10.0'} + bn.js@5.2.2: + resolution: {integrity: sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==} - language-subtag-registry@0.3.23: - resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + borsh@0.7.0: + resolution: {integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==} - language-tags@1.0.9: - resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} - engines: {node: '>=0.10'} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - lightningcss-android-arm64@1.30.2: - resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - lightningcss-darwin-arm64@1.30.2: - resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} - lightningcss-darwin-x64@1.30.2: - resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true - lightningcss-freebsd-x64@1.30.2: - resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] + bs58@4.0.1: + resolution: {integrity: sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==} - lightningcss-linux-arm-gnueabihf@1.30.2: - resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] + bs58@6.0.0: + resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} - lightningcss-linux-arm64-gnu@1.30.2: - resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - lightningcss-linux-arm64-musl@1.30.2: - resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - lightningcss-linux-x64-gnu@1.30.2: - resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] + bufferutil@4.1.0: + resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} + engines: {node: '>=6.14.2'} - lightningcss-linux-x64-musl@1.30.2: - resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} - lightningcss-win32-arm64-msvc@1.30.2: - resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} - lightningcss-win32-x64-msvc@1.30.2: - resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} - lightningcss@1.30.2: - resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} - engines: {node: '>= 12.0.0'} + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + camelize@1.0.1: + resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + caniuse-lite@1.0.30001766: + resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} + + canonicalize@2.1.0: + resolution: {integrity: sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==} hasBin: true - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} + charenc@0.0.2: + resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - minimatch@10.1.1: - resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} - engines: {node: 20 || >=22} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} - mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - modern-tar@0.7.3: - resolution: {integrity: sha512-4W79zekKGyYU4JXVmB78DOscMFaJth2gGhgfTl2alWE4rNe3nf4N2pqenQ0rEtIewrnD79M687Ouba3YGTLOvg==} - engines: {node: '>=18.0.0'} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true + commander@14.0.2: + resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} + engines: {node: '>=20'} - napi-build-utils@2.0.0: - resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} - napi-postinstall@0.3.4: - resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - hasBin: true + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + commander@2.8.1: + resolution: {integrity: sha512-+pJLBFVk+9ZZdlAOB5WuIElVPPth47hILFkmGym57aq8kwxsowvByvB0DHs1vQAhyMZzdcpTtF0VDKGkSDR4ZQ==} + engines: {node: '>= 0.6.x'} - next@16.2.0-canary.26: - resolution: {integrity: sha512-3Xw0SPdVMw/l4qIQt5HEOirTjYJr/gnfjo2TvgAZUxuX1dly0B1N054d5bouG+jRpb8BALlL7ghwG4UirmvGIw==} - engines: {node: '>=20.9.0'} + compressjs@1.0.3: + resolution: {integrity: sha512-jpKJjBTretQACTGLNuvnozP1JdP2ZLrjdGdBgk/tz1VfXlUcBhhSZW6vEsuThmeot/yjvSrPQKEgfF3X2Lpi8Q==} hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.51.1 - babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - babel-plugin-react-compiler: - optional: true - sass: - optional: true - node-abi@3.87.0: - resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} - engines: {node: '>=10'} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - node-addon-api@8.5.0: - resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} - engines: {node: ^18 || ^20 || >= 21} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - node-gyp-build@4.8.4: - resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} - hasBin: true + cookie-es@1.2.2: + resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} - node-liblzma@2.2.0: - resolution: {integrity: sha512-s0KzNOWwOJJgPG6wxg6cKohnAl9Wk/oW1KrQaVzJBjQwVcUGPQCzpR46Ximygjqj/3KhOrtJXnYMp/xYAXp75g==} - engines: {node: '>=16.0.0'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} hasBin: true - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} + cross-fetch@4.1.0: + resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} - object.assign@4.1.7: - resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} - engines: {node: '>= 0.4'} + crypt@0.0.2: + resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} - object.entries@1.1.9: - resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} - engines: {node: '>= 0.4'} + css-color-keywords@1.0.0: + resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} + engines: {node: '>=4'} - object.fromentries@2.0.8: - resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} - engines: {node: '>= 0.4'} + css-to-react-native@3.2.0: + resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} - object.groupby@1.0.3: - resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} - object.values@1.2.1: - resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} engines: {node: '>= 0.4'} - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} + date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} - os-paths@4.4.0: - resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} - engines: {node: '>= 6.0'} + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} - engines: {node: '>= 0.4'} + dayjs@1.11.13: + resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true - papaparse@5.5.3: - resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} - prebuild-install@7.1.3: - resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + delay@5.0.0: + resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} engines: {node: '>=10'} - hasBin: true - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - prop-types@15.8.1: - resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - - pump@3.0.3: - resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - pure-rand@7.0.1: - resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} - pyodide@0.27.7: - resolution: {integrity: sha512-RUSVJlhQdfWfgO9hVHCiXoG+nVZQRS5D9FzgpLJ/VcgGBLSAKoPL8kTiOikxbHQm1kRISeWUBdulEgO26qpSRA==} - engines: {node: '>=18.0.0'} + derive-valtio@0.1.0: + resolution: {integrity: sha512-OCg2UsLbXK7GmmpzMXhYkdO64vhJ1ROUUGaTFyHjVwEdMEcTTRj7W1TxLbSBxdY8QLBPCcp66MTyaSy0RpO17A==} + peerDependencies: + valtio: '*' - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true + detect-browser@5.3.0: + resolution: {integrity: sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==} - re2js@1.2.1: - resolution: {integrity: sha512-pMQCWm/GsGambkqCl0l02SZUUd5yQ4u8D72K90TJWHMALuYg8BWCdcfXdXIvEQYGEM8K9QrxYAFtVgeQ+LZKFw==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} - react-dom@19.2.3: - resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} - peerDependencies: - react: ^19.2.3 + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + engines: {node: '>=0.3.1'} - react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} - react@19.2.3: - resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - - reflect.getprototypeof@1.0.10: - resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} - engines: {node: '>= 0.4'} - - regexp.prototype.flags@1.5.4: - resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + eciesjs@0.4.17: + resolution: {integrity: sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} - engines: {node: '>= 0.4'} - hasBin: true + electron-to-chromium@1.5.283: + resolution: {integrity: sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==} - resolve@2.0.0-next.5: - resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} - hasBin: true + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - retry@0.13.1: - resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} - engines: {node: '>= 4'} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + encode-utf8@1.0.3: + resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==} - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} - engines: {node: '>=0.4'} + engine.io-client@6.6.4: + resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} - safe-push-apply@1.0.0: - resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} - engines: {node: '>= 0.4'} + enhanced-resolve@5.18.4: + resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} + engines: {node: '>=10.13.0'} - safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + es-abstract@1.24.1: + resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} engines: {node: '>= 0.4'} - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - section-matter@1.0.0: - resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} - engines: {node: '>=4'} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} - engines: {node: '>=10'} - hasBin: true - - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} - set-function-name@2.0.2: - resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - set-proto@1.0.0: - resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + es-iterator-helpers@1.2.2: + resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} engines: {node: '>= 0.4'} - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} engines: {node: '>= 0.4'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - simple-concat@1.0.1: - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - - simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - - smol-toml@1.6.0: - resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} - engines: {node: '>= 18'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + es-toolkit@1.33.0: + resolution: {integrity: sha512-X13Q/ZSc+vsO1q600bvNK4bxgXMkHcf//RxCmYDaRY5DAcT+eoXjY5hoAPGMdRnWQjvyLEcyauG3b6hz76LNqg==} - sprintf-js@1.1.3: - resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + es-toolkit@1.39.3: + resolution: {integrity: sha512-Qb/TCFCldgOy8lZ5uC7nLGdqJwSabkQiYQShmw4jyiPk1pZzaYWTwaYKYP7EgLccWYgZocMrtItrwh683voaww==} - sql.js@1.13.0: - resolution: {integrity: sha512-RJbVP1HRDlUUXahJ7VMTcu9Rm1Nzw+EBpoPr94vnbD4LwR715F3CcxE2G2k45PewcaZ57pjetYa+LoSJLAASgA==} + es6-promise@4.2.8: + resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} - stable-hash@0.0.5: - resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + es6-promisify@5.0.0: + resolution: {integrity: sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==} - stop-iteration-iterator@1.1.0: - resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} - engines: {node: '>= 0.4'} + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} - streamx@2.23.0: - resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} - string.prototype.includes@2.0.1: - resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} - engines: {node: '>= 0.4'} + eslint-config-next@16.1.6: + resolution: {integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==} + peerDependencies: + eslint: '>=9.0.0' + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true - string.prototype.matchall@4.0.12: - resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} - engines: {node: '>= 0.4'} + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} - string.prototype.repeat@1.0.0: - resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + eslint-import-resolver-typescript@3.10.1: + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} - engines: {node: '>= 0.4'} - - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} - engines: {node: '>= 0.4'} + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true - string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 - strip-bom-string@1.0.0: - resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} - engines: {node: '>=0.10.0'} + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - strnum@2.1.2: - resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - strtok3@10.3.4: - resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} - engines: {node: '>=18'} + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - styled-jsx@5.1.6: - resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} - engines: {node: '>= 12.0.0'} + eslint@9.39.2: + resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true peerDependencies: - '@babel/core': '*' - babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + jiti: '*' peerDependenciesMeta: - '@babel/core': - optional: true - babel-plugin-macros: + jiti: optional: true - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true - tailwindcss@4.1.18: - resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} - engines: {node: '>=6'} + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} - tar-fs@2.1.4: - resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eth-block-tracker@7.1.0: + resolution: {integrity: sha512-8YdplnuE1IK4xfqpf4iU7oBxnOYAc35934o083G8ao+8WM8QQtt/mVlAY6yIAdY1eMeLqg4Z//PZjJGmWGPMRg==} + engines: {node: '>=14.0.0'} + + eth-json-rpc-filters@6.0.1: + resolution: {integrity: sha512-ITJTvqoCw6OVMLs7pI8f4gG92n/St6x80ACtHodeS+IXmO0w+t1T5OOzfSt7KLSMLRkVUoexV7tztLgDxg+iig==} + engines: {node: '>=14.0.0'} + + eth-query@2.1.2: + resolution: {integrity: sha512-srES0ZcvwkR/wd5OQBRA1bIJMww1skfGS0s8wlwK3/oNP4+wnds60krvu5R1QbpRQjMmpG5OMIWro5s7gvDPsA==} + + eth-rpc-errors@4.0.3: + resolution: {integrity: sha512-Z3ymjopaoft7JDoxZcEb3pwdGh7yiYMhOwm2doUt6ASXlMavpNlK6Cre0+IMl2VSGyEU9rkiperQhp5iRxn5Pg==} + + ethereum-cryptography@2.2.1: + resolution: {integrity: sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} - tar-stream@3.1.7: - resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} - text-decoder@1.2.3: - resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + + extension-port-stream@3.0.0: + resolution: {integrity: sha512-an2S5quJMiy5bnZKEf6AkfH/7r8CzHvhchU40gxN+OM6HPhe7Z9T1FUychcf2M9PpPOO0Hf7BAEfJkw2TDIBDw==} engines: {node: '>=12.0.0'} - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} + eyes@0.1.8: + resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} + engines: {node: '> 0.1.90'} - token-types@6.1.2: - resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} - engines: {node: '>=14.16'} + fast-check@4.5.3: + resolution: {integrity: sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==} + engines: {node: '>=12.17.0'} - ts-api-utils@2.4.0: - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' + fast-copy@3.0.2: + resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} - tsconfig-paths@3.15.0: - resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + fast-glob@3.3.1: + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} + engines: {node: '>=8.6.0'} - turndown@7.2.2: - resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - typed-array-byte-length@1.0.3: - resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} - engines: {node: '>= 0.4'} + fast-password-entropy@1.1.1: + resolution: {integrity: sha512-dxm29/BPFrNgyEDygg/lf9c2xQR0vnQhG7+hZjAI39M/3um9fD4xiqG6F0ZjW6bya5m9CI0u6YryHGRtxCGCiw==} - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} - engines: {node: '>= 0.4'} + fast-redact@3.5.0: + resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} + engines: {node: '>=6'} - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} - engines: {node: '>= 0.4'} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - typescript-eslint@8.54.0: - resolution: {integrity: sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + fast-stable-stringify@1.0.0: + resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==} - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} + fast-xml-parser@5.3.4: + resolution: {integrity: sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==} hasBin: true - uint8array-extras@1.5.0: - resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} - engines: {node: '>=18'} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-retry@6.0.0: + resolution: {integrity: sha512-BUFj1aMubgib37I3v4q78fYo63Po7t4HUPTpQ6/QE6yK6cIQrP+W43FYToeTEyg5m2Y7eFUtijUuAv/PDlWuag==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-type@21.3.0: + resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} + engines: {node: '>=20'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + geist@1.5.1: + resolution: {integrity: sha512-mAHZxIsL2o3ZITFaBVFBnwyDOw+zNLYum6A6nIjpzCGIO8QtC3V76XF2RnZTyLx1wlDTmMDy8jg3Ib52MIjGvQ==} + peerDependencies: + next: '>=13.2.0' + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.1: + resolution: {integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.4.0: + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graceful-readlink@1.0.1: + resolution: {integrity: sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w==} + + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + + h3@1.15.5: + resolution: {integrity: sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + hono@4.11.9: + resolution: {integrity: sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==} + engines: {node: '>=16.9.0'} + + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + + idb-keyval@6.2.1: + resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==} + + idb-keyval@6.2.2: + resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-buffer@1.1.6: + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-retry-allowed@2.2.0: + resolution: {integrity: sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==} + engines: {node: '>=10'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isomorphic-ws@4.0.1: + resolution: {integrity: sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==} + peerDependencies: + ws: '*' + + isows@1.0.6: + resolution: {integrity: sha512-lPHCayd40oW98/I0uvgaHKWCSvkzY27LjWLbtzOm64yQ+G3Q5npjjbdppU65iZXkK1Zt+kH9pfegli0AYfwYYw==} + peerDependencies: + ws: '*' + + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: '*' + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jayson@4.3.0: + resolution: {integrity: sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==} + engines: {node: '>=8'} + hasBin: true + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-rpc-engine@6.1.0: + resolution: {integrity: sha512-NEdLrtrq1jUZyfjkr9OCz9EzCNhnRyWtt1PAnvnhwy6e8XETS0Dtc+ZNCO2gvuAoKsIn2+vCSowXTYE4CkgnAQ==} + engines: {node: '>=10.0.0'} + + json-rpc-random-id@1.0.1: + resolution: {integrity: sha512-RJ9YYNCkhVDBuP4zN5BBtYAzEl03yq/jIIsyif0JY9qyJuQQZNeDK7anAPKKlyEtLSj2s8h6hNh2F8zO5q7ScA==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonlines@0.1.1: + resolution: {integrity: sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==} + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + just-bash@2.9.5: + resolution: {integrity: sha512-ZYc4wVxE+jLDgjcwVVqKOT2hM8DG+gUUKMZAhfUULjbP9wT8U9V0uItA8tREpl2VnDLjzrxxK6jV7vbY/TLhNw==} + hasBin: true + + keccak@3.0.4: + resolution: {integrity: sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q==} + engines: {node: '>=10.0.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + keyvaluestorage-interface@1.0.0: + resolution: {integrity: sha512-8t6Q3TclQ4uZynJY9IGr2+SsIGwK9JHcO6ootkHCGA0CrQCRy+VkouYNO2xicET6b9al7QKzpebNow+gkpCL8g==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + libphonenumber-js@1.12.36: + resolution: {integrity: sha512-woWhKMAVx1fzzUnMCyOzglgSgf6/AFHLASdOBcchYCyvWSGWt12imw3iu2hdI5d4dGZRsNWAmWiz37sDKUPaRQ==} + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + lit-element@4.2.2: + resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==} + + lit-html@3.3.2: + resolution: {integrity: sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==} + + lit@3.3.0: + resolution: {integrity: sha512-DGVsqsOIHBww2DqnuZzW7QsuCdahp50ojuDaBPC7jUDRpYoH0z7kHBBYZewRzer75FwtrkmkKk7iOAwSaWdBmw==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@11.2.5: + resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.554.0: + resolution: {integrity: sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + md5@2.3.0: + resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micro-ftch@0.3.1: + resolution: {integrity: sha512-/0LLxhzP0tfiR5hcQebtudP56gUurs2CLkGarnCiB/OqEyUFQ6U3paQi/tgLv0hBJYt2rnr9MNpxz4fiiugstg==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@10.1.1: + resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} + engines: {node: 20 || >=22} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mipd@0.0.7: + resolution: {integrity: sha512-aAPZPNDQ3uMTdKbuO2YmAw2TxLHO0moa4YKAyETM/DTj5FloZo+a+8tU+iv4GmW+sOxKLSRwcSFuczk+Cpt6fg==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + modern-tar@0.7.3: + resolution: {integrity: sha512-4W79zekKGyYU4JXVmB78DOscMFaJth2gGhgfTl2alWE4rNe3nf4N2pqenQ0rEtIewrnD79M687Ouba3YGTLOvg==} + engines: {node: '>=18.0.0'} + + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multiformats@9.9.0: + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + next@16.2.0-canary.26: + resolution: {integrity: sha512-3Xw0SPdVMw/l4qIQt5HEOirTjYJr/gnfjo2TvgAZUxuX1dly0B1N054d5bouG+jRpb8BALlL7ghwG4UirmvGIw==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-abi@3.87.0: + resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} + engines: {node: '>=10'} + + node-addon-api@2.0.2: + resolution: {integrity: sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==} + + node-addon-api@8.5.0: + resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} + engines: {node: ^18 || ^20 || >= 21} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-liblzma@2.2.0: + resolution: {integrity: sha512-s0KzNOWwOJJgPG6wxg6cKohnAl9Wk/oW1KrQaVzJBjQwVcUGPQCzpR46Ximygjqj/3KhOrtJXnYMp/xYAXp75g==} + engines: {node: '>=16.0.0'} + hasBin: true + + node-mock-http@1.0.4: + resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + obj-multiplex@1.0.0: + resolution: {integrity: sha512-0GNJAOsHoBHeNTvl5Vt6IWnpUEcc3uSRxzBri7EDyIcMgYvnY2JL2qdeV5zTMjWQX5OHcD5amcW2HFfDh0gjIA==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + on-exit-leak-free@0.2.0: + resolution: {integrity: sha512-dqaz3u44QbRXQooZLTUKU41ZrzYrcvLISVgbrzbyCMxpmSLJvZ3ZamIJIZ29P6OhZIkNIQKosdeM6t1LYbA9hg==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + openapi-fetch@0.13.8: + resolution: {integrity: sha512-yJ4QKRyNxE44baQ9mY5+r/kAzZ8yXMemtNAOFwOzRXJscdjSxxzWSNlyBAr+o5JjkUw9Lc3W7OIoca0cY3PYnQ==} + + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + os-paths@4.4.0: + resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} + engines: {node: '>= 6.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + ox@0.11.3: + resolution: {integrity: sha512-1bWYGk/xZel3xro3l8WGg6eq4YEKlaqvyMtVhfMFpbJzK2F6rj4EDRtqDCWVEJMkzcmEi9uW2QxsqELokOlarw==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + ox@0.6.7: + resolution: {integrity: sha512-17Gk/eFsFRAZ80p5eKqv89a57uXjd3NgIf1CaXojATPBuujVc/fQSVhBeAU9JCRB+k7J50WQAyWTxK19T9GgbA==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + ox@0.6.9: + resolution: {integrity: sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + ox@0.9.1: + resolution: {integrity: sha512-NVI0cajROntJWtFnxZQ1aXDVy+c6DLEXJ3wwON48CgbPhmMJrpRTfVbuppR+47RmXm3lZ/uMaKiFSkLdAO1now==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + ox@0.9.17: + resolution: {integrity: sha512-rKAnhzhRU3Xh3hiko+i1ZxywZ55eWQzeS/Q4HRKLx2PqfHOolisZHErSsJVipGlmQKHW5qwOED/GighEw9dbLg==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + ox@0.9.3: + resolution: {integrity: sha512-KzyJP+fPV4uhuuqrTZyok4DC7vFzi7HLUFiUNEmpbyh59htKWkOC98IONC1zgXJPbHAhQgqs6B0Z6StCGhmQvg==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pify@3.0.0: + resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} + engines: {node: '>=4'} + + pify@5.0.0: + resolution: {integrity: sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==} + engines: {node: '>=10'} + + pino-abstract-transport@0.5.0: + resolution: {integrity: sha512-+KAgmVeqXYbTtU2FScx1XS3kNyfZ5TrXY07V96QnUSFqo2gAqlvmaxH67Lj7SWazqsMabf+58ctdTcBgnOLUOQ==} + + pino-abstract-transport@1.2.0: + resolution: {integrity: sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==} + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-pretty@10.3.1: + resolution: {integrity: sha512-az8JbIYeN/1iLj2t0jR9DV48/LQ3RC6hZPpapKPkb84Q+yTidMCpgWxIT3N0flnBDilyBQ1luWNpOeJptjdp/g==} + hasBin: true + + pino-std-serializers@4.0.0: + resolution: {integrity: sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.0.0: + resolution: {integrity: sha512-eI9pKwWEix40kfvSzqEP6ldqOoBIN7dwD/o91TY5z8vQI12sAffpR/pOqAD1IVVwIVHDpHjkq0joBPdJD0rafA==} + hasBin: true + + pino@7.11.0: + resolution: {integrity: sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg==} + hasBin: true + + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + + pony-cause@2.1.11: + resolution: {integrity: sha512-M7LhCsdNbNgiLYiP4WjsfLUuFmCfnjdF6jKe2R9NKl4WFN+HZPGHJZ9lnLP7f9ZnKe3U9nuWD0szirmj+migUg==} + engines: {node: '>=12.0.0'} + + porto@0.2.35: + resolution: {integrity: sha512-gu9FfjjvvYBgQXUHWTp6n3wkTxVtEcqFotM7i3GEZeoQbvLGbssAicCz6hFZ8+xggrJWwi/RLmbwNra50SMmUQ==} + hasBin: true + peerDependencies: + '@tanstack/react-query': '>=5.59.0' + '@wagmi/core': '>=2.16.3' + expo-auth-session: '>=7.0.8' + expo-crypto: '>=15.0.7' + expo-web-browser: '>=15.0.8' + react: '>=18' + react-native: '>=0.81.4' + typescript: '>=5.4.0' + viem: '>=2.37.0' + wagmi: '>=2.0.0' + peerDependenciesMeta: + '@tanstack/react-query': + optional: true + expo-auth-session: + optional: true + expo-crypto: + optional: true + expo-web-browser: + optional: true + react: + optional: true + react-native: + optional: true + typescript: + optional: true + wagmi: + optional: true + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.4.49: + resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + preact@10.24.2: + resolution: {integrity: sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q==} + + preact@10.28.3: + resolution: {integrity: sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process-warning@1.0.0: + resolution: {integrity: sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-compare@2.6.0: + resolution: {integrity: sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw==} + + proxy-compare@3.0.1: + resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@7.0.1: + resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + + pyodide@0.27.7: + resolution: {integrity: sha512-RUSVJlhQdfWfgO9hVHCiXoG+nVZQRS5D9FzgpLJ/VcgGBLSAKoPL8kTiOikxbHQm1kRISeWUBdulEgO26qpSRA==} + engines: {node: '>=18.0.0'} + + qrcode@1.5.3: + resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==} + engines: {node: '>=10.13.0'} + hasBin: true + + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + + query-string@7.1.3: + resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + re2js@1.2.1: + resolution: {integrity: sha512-pMQCWm/GsGambkqCl0l02SZUUd5yQ4u8D72K90TJWHMALuYg8BWCdcfXdXIvEQYGEM8K9QrxYAFtVgeQ+LZKFw==} + + react-device-detect@2.2.3: + resolution: {integrity: sha512-buYY3qrCnQVlIFHrC5UcUoAj7iANs/+srdkwsnNjI7anr3Tt7UY6MqNxtMLlr0tMBied0O49UZVK8XKs3ZIiPw==} + peerDependencies: + react: '>= 0.14.0' + react-dom: '>= 0.14.0' + + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + peerDependencies: + react: ^19.2.3 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + engines: {node: '>=0.10.0'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + real-require@0.1.0: + resolution: {integrity: sha512-r/H9MzAWtrv8aSVjPCMFpDMl5q66GqtmmRkRjpHTsp4zBAa+snZyiQNlMONiUmEJcsnaw0wCauJ2GWODr/aFkg==} + engines: {node: '>= 12.13.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.5: + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + hasBin: true + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rpc-websockets@9.3.3: + resolution: {integrity: sha512-OkCsBBzrwxX4DoSv4Zlf9DgXKRB0MzVfCFg5MC+fNnf9ktr4SMWjsri0VNZQlDbCnGcImT6KNEv4ZoxktQhdpA==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + + secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + + secure-password-utilities@0.2.1: + resolution: {integrity: sha512-znUg8ae3cpuAaogiFBhP82gD2daVkSz4Qv/L7OWjB7wWvfbCdeqqQuJkm2/IvhKQPOV0T739YPR6rb7vs0uWaw==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + sha.js@2.4.12: + resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} + engines: {node: '>= 0.10'} + hasBin: true + + shallowequal@1.1.0: + resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + slow-redact@0.3.2: + resolution: {integrity: sha512-MseHyi2+E/hBRqdOi5COy6wZ7j7DxXRz9NkseavNYSvvWC06D8a5cidVZX3tcG5eCW3NIyVU4zT63hw0Q486jw==} + + smol-toml@1.6.0: + resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} + engines: {node: '>= 18'} + + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.5: + resolution: {integrity: sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==} + engines: {node: '>=10.0.0'} + + sonic-boom@2.8.0: + resolution: {integrity: sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg==} + + sonic-boom@3.8.1: + resolution: {integrity: sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==} + + sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + sql.js@1.13.0: + resolution: {integrity: sha512-RJbVP1HRDlUUXahJ7VMTcu9Rm1Nzw+EBpoPr94vnbD4LwR715F3CcxE2G2k45PewcaZ57pjetYa+LoSJLAASgA==} + + stable-hash@0.0.5: + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + stream-chain@2.2.5: + resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} + + stream-json@1.9.1: + resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} + + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + + streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + + strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=4'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strnum@2.1.2: + resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + + strtok3@10.3.4: + resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} + engines: {node: '>=18'} + + styled-components@6.3.9: + resolution: {integrity: sha512-J72R4ltw0UBVUlEjTzI0gg2STOqlI9JBhQOL4Dxt7aJOnnSesy0qJDn4PYfMCafk9cWOaVg129Pesl5o+DIh0Q==} + engines: {node: '>= 16'} + peerDependencies: + react: '>= 16.8.0' + react-dom: '>= 16.8.0' + peerDependenciesMeta: + react-dom: + optional: true + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + + superstruct@1.0.4: + resolution: {integrity: sha512-7JpaAoX2NGyoFlI9NBh66BQXGONc+uE+MRS5i2iOBKuS4e+ccgMDjATgZldkah+33DakBxDHiss9kvUcGAO8UQ==} + engines: {node: '>=14.0.0'} + + superstruct@2.0.2: + resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==} + engines: {node: '>=14.0.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + tailwindcss@4.1.18: + resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + + text-decoder@1.2.3: + resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + + text-encoding-utf-8@1.0.2: + resolution: {integrity: sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==} + + thread-stream@0.15.2: + resolution: {integrity: sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA==} + + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + + tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + turndown@7.2.2: + resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.54.0: + resolution: {integrity: sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ua-parser-js@1.0.41: + resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + uint8arrays@3.1.0: + resolution: {integrity: sha512-ei5rfKtoRO8OyOIor2Rz5fhzjThwIHJZ3uyDPnDHTXbP0aMQ1RN/6AI5B5d9dBxJOU+BvOAk7ZQ1xphsX8Lrog==} + + uint8arrays@3.1.1: + resolution: {integrity: sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg==} + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici-types@7.21.0: + resolution: {integrity: sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==} + + undici@7.21.0: + resolution: {integrity: sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==} + engines: {node: '>=20.18.1'} + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + unstorage@1.17.4: + resolution: {integrity: sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-sync-external-store@1.2.0: + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + + use-sync-external-store@1.4.0: + resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + utf-8-validate@5.0.10: + resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==} + engines: {node: '>=6.14.2'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + + valtio@1.13.2: + resolution: {integrity: sha512-Qik0o+DSy741TmkqmRfjq+0xpZBXi/Y6+fXZLn0xNF1z/waFMbE3rkivv5Zcf9RrMUp6zswf2J7sbh2KBlba5A==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=16.8' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + + valtio@2.1.7: + resolution: {integrity: sha512-DwJhCDpujuQuKdJ2H84VbTjEJJteaSmqsuUltsfbfdbotVfNeTE4K/qc/Wi57I9x8/2ed4JNdjEna7O6PfavRg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + react: '>=18.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + + viem@2.23.2: + resolution: {integrity: sha512-NVmW/E0c5crMOtbEAqMF0e3NmvQykFXhLOc/CkLIXOlzHSA6KXVz3CYVmaKqBF8/xtjsjHAGjdJN3Ru1kFJLaA==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + + viem@2.36.0: + resolution: {integrity: sha512-Xz7AkGtR43K+NY74X2lBevwfRrsXuifGUzt8QiULO47NXIcT7g3jcA4nIvl5m2OTE5v8SlzishwXmg64xOIVmQ==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + + viem@2.45.2: + resolution: {integrity: sha512-GXPMmj0ukqFNL87sgpsZBy4CjGvsFQk42/EUdsn8dv3ZWtL4ukDXNCM0nME2hU0IcuS29CuUbrwbZN6iWxAipw==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + + wagmi@2.19.5: + resolution: {integrity: sha512-RQUfKMv6U+EcSNNGiPbdkDtJwtuFxZWLmvDiQmjjBgkuPulUwDJsKhi7gjynzJdsx2yDqhHCXkKsbbfbIsHfcQ==} + peerDependencies: + '@tanstack/react-query': '>=5.0.0' + react: '>=18' + typescript: '>=5.0.4' + viem: 2.x + peerDependenciesMeta: + typescript: + optional: true + + webextension-polyfill@0.10.0: + resolution: {integrity: sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + x402@0.7.3: + resolution: {integrity: sha512-8CIZsdMTOn52PjMH/ErVke9ebeZ7ErwiZ5FL3tN3Wny7Ynxs3LkuB/0q7IoccRLdVXA7f2lueYBJ2iDrElhXnA==} + + xdg-app-paths@5.1.0: + resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==} + engines: {node: '>=6'} + + xdg-portable@7.3.0: + resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} + engines: {node: '>= 6.0'} + + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@3.22.4: + resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + + zod@3.24.4: + resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + + zustand@5.0.0: + resolution: {integrity: sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + + zustand@5.0.11: + resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + + zustand@5.0.3: + resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@adraffy/ens-normalize@1.11.1': {} + + '@ai-sdk/gateway@3.0.31(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.6 + '@ai-sdk/provider-utils': 4.0.12(zod@4.3.6) + '@vercel/oidc': 3.1.0 + zod: 4.3.6 + + '@ai-sdk/provider-utils@4.0.12(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.6 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.3.6 + + '@ai-sdk/provider@3.0.6': + dependencies: + json-schema: 0.4.0 + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.0': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/runtime@7.28.6': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.0 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@base-org/account@1.1.1(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(utf-8-validate@5.0.10)(zod@4.3.6)': + dependencies: + '@noble/hashes': 1.4.0 + clsx: 1.2.1 + eventemitter3: 5.0.1 + idb-keyval: 6.2.1 + ox: 0.6.9(typescript@5.9.3)(zod@3.25.76) + preact: 10.24.2 + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + zustand: 5.0.3(@types/react@19.2.10)(react@19.2.3)(use-sync-external-store@1.4.0(react@19.2.3)) + transitivePeerDependencies: + - '@types/react' + - bufferutil + - immer + - react + - typescript + - use-sync-external-store + - utf-8-validate + - zod + + '@base-org/account@2.4.0(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(utf-8-validate@5.0.10)(zod@3.25.76)': + dependencies: + '@coinbase/cdp-sdk': 1.44.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@noble/hashes': 1.4.0 + clsx: 1.2.1 + eventemitter3: 5.0.1 + idb-keyval: 6.2.1 + ox: 0.6.9(typescript@5.9.3)(zod@3.25.76) + preact: 10.24.2 + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + zustand: 5.0.3(@types/react@19.2.10)(react@19.2.3)(use-sync-external-store@1.4.0(react@19.2.3)) + transitivePeerDependencies: + - '@types/react' + - bufferutil + - debug + - encoding + - fastestsmallesttextencoderdecoder + - immer + - react + - typescript + - use-sync-external-store + - utf-8-validate + - zod + + '@borewit/text-codec@0.2.1': {} + + '@coinbase/cdp-sdk@1.44.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana-program/system': 0.10.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@solana-program/token': 0.9.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@solana/kit': 5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + abitype: 1.0.6(typescript@5.9.3)(zod@3.25.76) + axios: 1.13.5 + axios-retry: 4.5.0(axios@1.13.5) + jose: 6.1.3 + md5: 2.3.0 + uncrypto: 0.1.3 + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + zod: 3.25.76 + transitivePeerDependencies: + - bufferutil + - debug + - encoding + - fastestsmallesttextencoderdecoder + - typescript + - utf-8-validate + + '@coinbase/wallet-sdk@3.9.3': + dependencies: + bn.js: 5.2.2 + buffer: 6.0.3 + clsx: 1.2.1 + eth-block-tracker: 7.1.0 + eth-json-rpc-filters: 6.0.1 + eventemitter3: 5.0.4 + keccak: 3.0.4 + preact: 10.28.3 + sha.js: 2.4.12 + transitivePeerDependencies: + - supports-color + + '@coinbase/wallet-sdk@4.3.2': + dependencies: + '@noble/hashes': 1.8.0 + clsx: 1.2.1 + eventemitter3: 5.0.4 + preact: 10.28.3 + + '@coinbase/wallet-sdk@4.3.6(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(utf-8-validate@5.0.10)(zod@3.25.76)': + dependencies: + '@noble/hashes': 1.4.0 + clsx: 1.2.1 + eventemitter3: 5.0.1 + idb-keyval: 6.2.1 + ox: 0.6.9(typescript@5.9.3)(zod@3.25.76) + preact: 10.24.2 + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + zustand: 5.0.3(@types/react@19.2.10)(react@19.2.3)(use-sync-external-store@1.4.0(react@19.2.3)) + transitivePeerDependencies: + - '@types/react' + - bufferutil + - immer + - react + - typescript + - use-sync-external-store + - utf-8-validate + - zod + + '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': + dependencies: + '@noble/ciphers': 1.3.0 + + '@emnapi/core@1.8.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emotion/is-prop-valid@1.4.0': + dependencies: + '@emotion/memoize': 0.9.0 + + '@emotion/memoize@0.9.0': {} + + '@emotion/unitless@0.10.0': {} + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': + dependencies: + eslint: 9.39.2(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.3': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.2': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@ethereumjs/common@3.2.0': + dependencies: + '@ethereumjs/util': 8.1.0 + crc-32: 1.2.2 + + '@ethereumjs/rlp@4.0.1': {} + + '@ethereumjs/tx@4.2.0': + dependencies: + '@ethereumjs/common': 3.2.0 + '@ethereumjs/rlp': 4.0.1 + '@ethereumjs/util': 8.1.0 + ethereum-cryptography: 2.2.1 + + '@ethereumjs/util@8.1.0': + dependencies: + '@ethereumjs/rlp': 4.0.1 + ethereum-cryptography: 2.2.1 + micro-ftch: 0.3.1 + + '@floating-ui/core@1.7.4': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.5': + dependencies: + '@floating-ui/core': 1.7.4 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@floating-ui/dom': 1.7.5 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + '@floating-ui/react@0.26.28(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@floating-ui/react-dom': 2.1.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@floating-ui/utils': 0.2.10 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + tabbable: 6.4.0 + + '@floating-ui/utils@0.2.10': {} + + '@gemini-wallet/core@0.3.2(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))': + dependencies: + '@metamask/rpc-errors': 7.0.2 + eventemitter3: 5.0.1 + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + '@hcaptcha/loader@2.3.0': {} + + '@hcaptcha/react-hcaptcha@1.17.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@babel/runtime': 7.28.6 + '@hcaptcha/loader': 2.3.0 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + '@headlessui/react@2.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@floating-ui/react': 0.26.28(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-aria/focus': 3.21.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-aria/interactions': 3.27.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-virtual': 3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + use-sync-external-store: 1.6.0(react@19.2.3) + + '@heroicons/react@2.2.0(react@19.2.3)': + dependencies: + react: 19.2.3 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.0.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.8.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lit-labs/ssr-dom-shim@1.5.1': {} + + '@lit/react@1.0.8(@types/react@19.2.10)': + dependencies: + '@types/react': 19.2.10 + optional: true + + '@lit/reactive-element@2.1.2': + dependencies: + '@lit-labs/ssr-dom-shim': 1.5.1 + + '@marsidev/react-turnstile@1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + '@metamask/eth-json-rpc-provider@1.0.1': + dependencies: + '@metamask/json-rpc-engine': 7.3.3 + '@metamask/safe-event-emitter': 3.1.2 + '@metamask/utils': 5.0.2 + transitivePeerDependencies: + - supports-color + + '@metamask/json-rpc-engine@7.3.3': + dependencies: + '@metamask/rpc-errors': 6.4.0 + '@metamask/safe-event-emitter': 3.1.2 + '@metamask/utils': 8.5.0 + transitivePeerDependencies: + - supports-color + + '@metamask/json-rpc-engine@8.0.2': + dependencies: + '@metamask/rpc-errors': 6.4.0 + '@metamask/safe-event-emitter': 3.1.2 + '@metamask/utils': 8.5.0 + transitivePeerDependencies: + - supports-color + + '@metamask/json-rpc-middleware-stream@7.0.2': + dependencies: + '@metamask/json-rpc-engine': 8.0.2 + '@metamask/safe-event-emitter': 3.1.2 + '@metamask/utils': 8.5.0 + readable-stream: 3.6.2 + transitivePeerDependencies: + - supports-color + + '@metamask/object-multiplex@2.1.0': + dependencies: + once: 1.4.0 + readable-stream: 3.6.2 + + '@metamask/onboarding@1.0.1': + dependencies: + bowser: 2.14.1 + + '@metamask/providers@16.1.0': + dependencies: + '@metamask/json-rpc-engine': 8.0.2 + '@metamask/json-rpc-middleware-stream': 7.0.2 + '@metamask/object-multiplex': 2.1.0 + '@metamask/rpc-errors': 6.4.0 + '@metamask/safe-event-emitter': 3.1.2 + '@metamask/utils': 8.5.0 + detect-browser: 5.3.0 + extension-port-stream: 3.0.0 + fast-deep-equal: 3.1.3 + is-stream: 2.0.1 + readable-stream: 3.6.2 + webextension-polyfill: 0.10.0 + transitivePeerDependencies: + - supports-color + + '@metamask/rpc-errors@6.4.0': + dependencies: + '@metamask/utils': 9.3.0 + fast-safe-stringify: 2.1.1 + transitivePeerDependencies: + - supports-color + + '@metamask/rpc-errors@7.0.2': + dependencies: + '@metamask/utils': 11.9.0 + fast-safe-stringify: 2.1.1 + transitivePeerDependencies: + - supports-color + + '@metamask/safe-event-emitter@2.0.0': {} + + '@metamask/safe-event-emitter@3.1.2': {} + + '@metamask/sdk-analytics@0.0.5': + dependencies: + openapi-fetch: 0.13.8 + + '@metamask/sdk-communication-layer@0.33.1(cross-fetch@4.1.0)(eciesjs@0.4.17)(eventemitter2@6.4.9)(readable-stream@3.6.2)(socket.io-client@4.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10))': + dependencies: + '@metamask/sdk-analytics': 0.0.5 + bufferutil: 4.1.0 + cross-fetch: 4.1.0 + date-fns: 2.30.0 + debug: 4.3.4 + eciesjs: 0.4.17 + eventemitter2: 6.4.9 + readable-stream: 3.6.2 + socket.io-client: 4.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + utf-8-validate: 5.0.10 + uuid: 8.3.2 + transitivePeerDependencies: + - supports-color + + '@metamask/sdk-install-modal-web@0.32.1': + dependencies: + '@paulmillr/qr': 0.2.1 + + '@metamask/sdk@0.33.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)': + dependencies: + '@babel/runtime': 7.28.6 + '@metamask/onboarding': 1.0.1 + '@metamask/providers': 16.1.0 + '@metamask/sdk-analytics': 0.0.5 + '@metamask/sdk-communication-layer': 0.33.1(cross-fetch@4.1.0)(eciesjs@0.4.17)(eventemitter2@6.4.9)(readable-stream@3.6.2)(socket.io-client@4.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@metamask/sdk-install-modal-web': 0.32.1 + '@paulmillr/qr': 0.2.1 + bowser: 2.14.1 + cross-fetch: 4.1.0 + debug: 4.3.4 + eciesjs: 0.4.17 + eth-rpc-errors: 4.0.3 + eventemitter2: 6.4.9 + obj-multiplex: 1.0.0 + pump: 3.0.3 + readable-stream: 3.6.2 + socket.io-client: 4.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + tslib: 2.8.1 + util: 0.12.5 + uuid: 8.3.2 + transitivePeerDependencies: + - bufferutil + - encoding + - supports-color + - utf-8-validate + + '@metamask/superstruct@3.2.1': {} + + '@metamask/utils@11.9.0': + dependencies: + '@ethereumjs/tx': 4.2.0 + '@metamask/superstruct': 3.2.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@types/debug': 4.1.12 + '@types/lodash': 4.17.23 + debug: 4.4.3 + lodash: 4.17.23 + pony-cause: 2.1.11 + semver: 7.7.3 + uuid: 9.0.1 + transitivePeerDependencies: + - supports-color + + '@metamask/utils@5.0.2': + dependencies: + '@ethereumjs/tx': 4.2.0 + '@types/debug': 4.1.12 + debug: 4.4.3 + semver: 7.7.3 + superstruct: 1.0.4 + transitivePeerDependencies: + - supports-color + + '@metamask/utils@8.5.0': + dependencies: + '@ethereumjs/tx': 4.2.0 + '@metamask/superstruct': 3.2.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@types/debug': 4.1.12 + debug: 4.4.3 + pony-cause: 2.1.11 + semver: 7.7.3 + uuid: 9.0.1 + transitivePeerDependencies: + - supports-color + + '@metamask/utils@9.3.0': + dependencies: + '@ethereumjs/tx': 4.2.0 + '@metamask/superstruct': 3.2.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@types/debug': 4.1.12 + debug: 4.4.3 + pony-cause: 2.1.11 + semver: 7.7.3 + uuid: 9.0.1 + transitivePeerDependencies: + - supports-color + + '@mixmark-io/domino@2.2.0': {} + + '@mongodb-js/zstd@7.0.0': + dependencies: + node-addon-api: 8.5.0 + prebuild-install: 7.1.3 + optional: true + + '@msgpack/msgpack@3.1.2': {} + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@next/env@16.2.0-canary.26': {} + + '@next/eslint-plugin-next@16.1.6': + dependencies: + fast-glob: 3.3.1 + + '@next/swc-darwin-arm64@16.2.0-canary.26': + optional: true + + '@next/swc-darwin-x64@16.2.0-canary.26': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.0-canary.26': + optional: true + + '@next/swc-linux-arm64-musl@16.2.0-canary.26': + optional: true - unbox-primitive@1.1.0: - resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} - engines: {node: '>= 0.4'} + '@next/swc-linux-x64-gnu@16.2.0-canary.26': + optional: true - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + '@next/swc-linux-x64-musl@16.2.0-canary.26': + optional: true - undici@7.21.0: - resolution: {integrity: sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==} - engines: {node: '>=20.18.1'} + '@next/swc-win32-arm64-msvc@16.2.0-canary.26': + optional: true - unrs-resolver@1.11.1: - resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + '@next/swc-win32-x64-msvc@16.2.0-canary.26': + optional: true - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' + '@noble/ciphers@1.2.1': {} - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + '@noble/ciphers@1.3.0': {} - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + '@noble/curves@1.4.2': + dependencies: + '@noble/hashes': 1.4.0 - which-boxed-primitive@1.1.1: - resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} - engines: {node: '>= 0.4'} + '@noble/curves@1.8.0': + dependencies: + '@noble/hashes': 1.7.0 - which-builtin-type@1.2.1: - resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} - engines: {node: '>= 0.4'} + '@noble/curves@1.8.1': + dependencies: + '@noble/hashes': 1.7.1 - which-collection@1.0.2: - resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} - engines: {node: '>= 0.4'} + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 - which-typed-array@1.1.20: - resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} - engines: {node: '>= 0.4'} + '@noble/curves@1.9.6': + dependencies: + '@noble/hashes': 1.8.0 - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} + '@noble/hashes@1.4.0': {} - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + '@noble/hashes@1.7.0': {} - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true + '@noble/hashes@1.7.1': {} - xdg-app-paths@5.1.0: - resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==} - engines: {node: '>=6'} + '@noble/hashes@1.8.0': {} - xdg-portable@7.3.0: - resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} - engines: {node: '>= 6.0'} + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + '@nodelib/fs.stat@2.0.5': {} - yaml@2.8.2: - resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} - engines: {node: '>= 14.6'} - hasBin: true + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} + '@nolyfill/is-core-module@1.0.39': {} - zod-validation-error@4.0.2: - resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - zod: ^3.25.0 || ^4.0.0 + '@opentelemetry/api@1.9.0': {} - zod@3.24.4: - resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} + '@paulmillr/qr@0.2.1': {} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + '@phosphor-icons/webcomponents@2.1.5': + dependencies: + lit: 3.3.0 - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + '@privy-io/api-base@1.8.2': + dependencies: + zod: 3.25.76 -snapshots: + '@privy-io/api-types@0.4.0': {} - '@ai-sdk/gateway@3.0.31(zod@4.3.6)': + '@privy-io/chains@0.0.6': {} + + '@privy-io/ethereum@0.0.7(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))': dependencies: - '@ai-sdk/provider': 3.0.6 - '@ai-sdk/provider-utils': 4.0.12(zod@4.3.6) - '@vercel/oidc': 3.1.0 - zod: 4.3.6 + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - '@ai-sdk/provider-utils@4.0.12(zod@4.3.6)': + '@privy-io/js-sdk-core@0.60.0(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))': dependencies: - '@ai-sdk/provider': 3.0.6 - '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.6 - zod: 4.3.6 + '@privy-io/api-base': 1.8.2 + '@privy-io/api-types': 0.4.0 + '@privy-io/chains': 0.0.6 + '@privy-io/ethereum': 0.0.7(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + '@privy-io/routes': 0.0.6 + canonicalize: 2.1.0 + eventemitter3: 5.0.4 + fetch-retry: 6.0.0 + jose: 4.15.9 + js-cookie: 3.0.5 + libphonenumber-js: 1.12.36 + set-cookie-parser: 2.7.2 + uuid: 9.0.1 + optionalDependencies: + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + + '@privy-io/popup@0.0.2': {} + + '@privy-io/react-auth@3.13.1(@solana-program/system@0.10.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)))(@solana-program/token@0.9.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)))(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(@solana/sysvars@5.5.1(typescript@5.9.3))(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.3))(@types/react@19.2.10)(bufferutil@4.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(utf-8-validate@5.0.10)(zod@4.3.6)': + dependencies: + '@base-org/account': 1.1.1(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(utf-8-validate@5.0.10)(zod@4.3.6) + '@coinbase/wallet-sdk': 4.3.2 + '@floating-ui/react': 0.26.28(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@hcaptcha/react-hcaptcha': 1.17.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@headlessui/react': 2.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@heroicons/react': 2.2.0(react@19.2.3) + '@marsidev/react-turnstile': 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@privy-io/api-base': 1.8.2 + '@privy-io/api-types': 0.4.0 + '@privy-io/chains': 0.0.6 + '@privy-io/ethereum': 0.0.7(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + '@privy-io/js-sdk-core': 0.60.0(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + '@privy-io/popup': 0.0.2 + '@privy-io/routes': 0.0.6 + '@privy-io/urls': 0.0.3 + '@scure/base': 1.2.6 + '@simplewebauthn/browser': 13.2.2 + '@tanstack/react-virtual': 3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@wallet-standard/app': 1.1.0 + '@walletconnect/ethereum-provider': 2.22.4(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/universal-provider': 2.22.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + eventemitter3: 5.0.4 + fast-password-entropy: 1.1.1 + jose: 4.15.9 + js-cookie: 3.0.5 + lucide-react: 0.554.0(react@19.2.3) + mipd: 0.0.7(typescript@5.9.3) + ofetch: 1.5.1 + pino-pretty: 10.3.1 + qrcode: 1.5.4 + react: 19.2.3 + react-device-detect: 2.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react-dom: 19.2.3(react@19.2.3) + secure-password-utilities: 0.2.1 + styled-components: 6.3.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + stylis: 4.3.6 + tinycolor2: 1.6.0 + uuid: 9.0.1 + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + x402: 0.7.3(@solana/sysvars@5.5.1(typescript@5.9.3))(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.3))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10) + zustand: 5.0.11(@types/react@19.2.10)(react@19.2.3)(use-sync-external-store@1.4.0(react@19.2.3)) + optionalDependencies: + '@solana-program/system': 0.10.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@solana-program/token': 0.9.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@solana/kit': 5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@solana/sysvars' + - '@tanstack/query-core' + - '@tanstack/react-query' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - debug + - encoding + - expo-auth-session + - expo-crypto + - expo-web-browser + - fastestsmallesttextencoderdecoder + - immer + - ioredis + - react-native + - supports-color + - typescript + - uploadthing + - use-sync-external-store + - utf-8-validate + - zod - '@ai-sdk/provider@3.0.6': + '@privy-io/routes@0.0.6': dependencies: - json-schema: 0.4.0 + '@privy-io/api-types': 0.4.0 - '@alloc/quick-lru@5.2.0': {} + '@privy-io/urls@0.0.3': {} - '@babel/code-frame@7.29.0': + '@react-aria/focus@3.21.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 + '@react-aria/interactions': 3.27.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-aria/utils': 3.33.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-types/shared': 3.33.0(react@19.2.3) + '@swc/helpers': 0.5.15 + clsx: 2.1.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) - '@babel/compat-data@7.29.0': {} + '@react-aria/interactions@3.27.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@react-aria/ssr': 3.9.10(react@19.2.3) + '@react-aria/utils': 3.33.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-stately/flags': 3.1.2 + '@react-types/shared': 3.33.0(react@19.2.3) + '@swc/helpers': 0.5.15 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) - '@babel/core@7.29.0': + '@react-aria/ssr@3.9.10(react@19.2.3)': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.0 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color + '@swc/helpers': 0.5.15 + react: 19.2.3 - '@babel/generator@7.29.0': + '@react-aria/utils@3.33.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 + '@react-aria/ssr': 3.9.10(react@19.2.3) + '@react-stately/flags': 3.1.2 + '@react-stately/utils': 3.11.0(react@19.2.3) + '@react-types/shared': 3.33.0(react@19.2.3) + '@swc/helpers': 0.5.15 + clsx: 2.1.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) - '@babel/helper-compilation-targets@7.28.6': + '@react-stately/flags@3.1.2': dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 - lru-cache: 5.1.1 - semver: 6.3.1 + '@swc/helpers': 0.5.15 - '@babel/helper-globals@7.28.0': {} + '@react-stately/utils@3.11.0(react@19.2.3)': + dependencies: + '@swc/helpers': 0.5.15 + react: 19.2.3 - '@babel/helper-module-imports@7.28.6': + '@react-types/shared@3.33.0(react@19.2.3)': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color + react: 19.2.3 - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@reown/appkit-common@1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.22.4)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + big.js: 6.2.2 + dayjs: 1.11.13 + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.22.4) transitivePeerDependencies: - - supports-color - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} + - bufferutil + - typescript + - utf-8-validate + - zod - '@babel/helper-validator-option@7.27.1': {} + '@reown/appkit-common@1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + dependencies: + big.js: 6.2.2 + dayjs: 1.11.13 + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod - '@babel/helpers@7.28.6': + '@reown/appkit-common@1.8.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.22.4)': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + big.js: 6.2.2 + dayjs: 1.11.13 + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.22.4) + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod - '@babel/parser@7.29.0': + '@reown/appkit-common@1.8.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: - '@babel/types': 7.29.0 + big.js: 6.2.2 + dayjs: 1.11.13 + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod - '@babel/template@7.28.6': + '@reown/appkit-controllers@1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@walletconnect/universal-provider': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + valtio: 1.13.2(@types/react@19.2.10)(react@19.2.3) + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod - '@babel/traverse@7.29.0': + '@reown/appkit-controllers@1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.0 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 + '@reown/appkit-common': 1.8.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-wallet': 1.8.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@walletconnect/universal-provider': 2.21.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + valtio: 2.1.7(@types/react@19.2.10)(react@19.2.3) + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) transitivePeerDependencies: - - supports-color + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod - '@babel/types@7.29.0': + '@reown/appkit-pay@1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.10)(react@19.2.3))(zod@3.25.76) + lit: 3.3.0 + valtio: 1.13.2(@types/react@19.2.10)(react@19.2.3) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod - '@borewit/text-codec@0.2.1': {} + '@reown/appkit-pay@1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + dependencies: + '@reown/appkit-common': 1.8.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-controllers': 1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-ui': 1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-utils': 1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@2.1.7(@types/react@19.2.10)(react@19.2.3))(zod@4.3.6) + lit: 3.3.0 + valtio: 2.1.7(@types/react@19.2.10)(react@19.2.3) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod - '@emnapi/core@1.8.1': + '@reown/appkit-polyfills@1.7.8': dependencies: - '@emnapi/wasi-threads': 1.1.0 - tslib: 2.8.1 - optional: true + buffer: 6.0.3 - '@emnapi/runtime@1.8.1': + '@reown/appkit-polyfills@1.8.9': dependencies: - tslib: 2.8.1 - optional: true + buffer: 6.0.3 - '@emnapi/wasi-threads@1.1.0': + '@reown/appkit-scaffold-ui@1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.10)(react@19.2.3))(zod@3.25.76)': dependencies: - tslib: 2.8.1 - optional: true + '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.10)(react@19.2.3))(zod@3.25.76) + '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + lit: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - valtio + - zod - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': + '@reown/appkit-scaffold-ui@1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@2.1.7(@types/react@19.2.10)(react@19.2.3))(zod@4.3.6)': dependencies: - eslint: 9.39.2(jiti@2.6.1) - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} + '@reown/appkit-common': 1.8.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-controllers': 1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-ui': 1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-utils': 1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@2.1.7(@types/react@19.2.10)(react@19.2.3))(zod@4.3.6) + '@reown/appkit-wallet': 1.8.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + lit: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - valtio + - zod - '@eslint/config-array@0.21.1': + '@reown/appkit-ui@1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@eslint/object-schema': 2.1.7 - debug: 4.4.3 - minimatch: 3.1.2 + '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + lit: 3.3.0 + qrcode: 1.5.3 transitivePeerDependencies: - - supports-color + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod - '@eslint/config-helpers@0.4.2': + '@reown/appkit-ui@1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: - '@eslint/core': 0.17.0 + '@phosphor-icons/webcomponents': 2.1.5 + '@reown/appkit-common': 1.8.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-controllers': 1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-wallet': 1.8.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + lit: 3.3.0 + qrcode: 1.5.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod + + '@reown/appkit-utils@1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.10)(react@19.2.3))(zod@3.25.76)': + dependencies: + '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-polyfills': 1.7.8 + '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@walletconnect/logger': 2.1.2 + '@walletconnect/universal-provider': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + valtio: 1.13.2(@types/react@19.2.10)(react@19.2.3) + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod + + '@reown/appkit-utils@1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@2.1.7(@types/react@19.2.10)(react@19.2.3))(zod@4.3.6)': + dependencies: + '@reown/appkit-common': 1.8.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-controllers': 1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-polyfills': 1.8.9 + '@reown/appkit-wallet': 1.8.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@wallet-standard/wallet': 1.1.0 + '@walletconnect/logger': 2.1.2 + '@walletconnect/universal-provider': 2.21.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + valtio: 2.1.7(@types/react@19.2.10)(react@19.2.3) + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod - '@eslint/core@0.17.0': + '@reown/appkit-wallet@1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)': dependencies: - '@types/json-schema': 7.0.15 + '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.22.4) + '@reown/appkit-polyfills': 1.7.8 + '@walletconnect/logger': 2.1.2 + zod: 3.22.4 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate - '@eslint/eslintrc@3.3.3': + '@reown/appkit-wallet@1.8.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)': dependencies: - ajv: 6.12.6 - debug: 4.4.3 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 + '@reown/appkit-common': 1.8.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.22.4) + '@reown/appkit-polyfills': 1.8.9 + '@walletconnect/logger': 2.1.2 + zod: 3.22.4 transitivePeerDependencies: - - supports-color + - bufferutil + - typescript + - utf-8-validate - '@eslint/js@9.39.2': {} + '@reown/appkit@1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + dependencies: + '@reown/appkit-common': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-pay': 1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-polyfills': 1.7.8 + '@reown/appkit-scaffold-ui': 1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.10)(react@19.2.3))(zod@3.25.76) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.10)(react@19.2.3))(zod@3.25.76) + '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@walletconnect/types': 2.21.0 + '@walletconnect/universal-provider': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + bs58: 6.0.0 + valtio: 1.13.2(@types/react@19.2.10)(react@19.2.3) + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod + + '@reown/appkit@1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + dependencies: + '@reown/appkit-common': 1.8.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-controllers': 1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-pay': 1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-polyfills': 1.8.9 + '@reown/appkit-scaffold-ui': 1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@2.1.7(@types/react@19.2.10)(react@19.2.3))(zod@4.3.6) + '@reown/appkit-ui': 1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@reown/appkit-utils': 1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(valtio@2.1.7(@types/react@19.2.10)(react@19.2.3))(zod@4.3.6) + '@reown/appkit-wallet': 1.8.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@walletconnect/universal-provider': 2.21.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + bs58: 6.0.0 + semver: 7.7.2 + valtio: 2.1.7(@types/react@19.2.10)(react@19.2.3) + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + optionalDependencies: + '@lit/react': 1.0.8(@types/react@19.2.10) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod - '@eslint/object-schema@2.1.7': {} + '@rtsao/scc@1.1.0': {} - '@eslint/plugin-kit@0.4.1': + '@safe-global/safe-apps-provider@0.18.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@eslint/core': 0.17.0 - levn: 0.4.1 - - '@humanfs/core@0.19.1': {} + '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + events: 3.3.0 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod - '@humanfs/node@0.16.7': + '@safe-global/safe-apps-sdk@9.1.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.4.3 + '@safe-global/safe-gateway-typescript-sdk': 3.23.1 + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod - '@humanwhocodes/module-importer@1.0.1': {} + '@safe-global/safe-gateway-typescript-sdk@3.23.1': {} - '@humanwhocodes/retry@0.4.3': {} + '@scure/base@1.1.9': {} - '@img/colour@1.0.0': - optional: true + '@scure/base@1.2.6': {} - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true + '@scure/bip32@1.4.0': + dependencies: + '@noble/curves': 1.4.2 + '@noble/hashes': 1.4.0 + '@scure/base': 1.1.9 - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true + '@scure/bip32@1.6.2': + dependencies: + '@noble/curves': 1.8.1 + '@noble/hashes': 1.7.1 + '@scure/base': 1.2.6 - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true + '@scure/bip39@1.3.0': + dependencies: + '@noble/hashes': 1.4.0 + '@scure/base': 1.1.9 - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true + '@scure/bip39@1.5.4': + dependencies: + '@noble/hashes': 1.7.1 + '@scure/base': 1.2.6 - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true + '@simplewebauthn/browser@13.2.2': {} - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true + '@socket.io/component-emitter@3.1.2': {} - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true + '@solana-program/compute-budget@0.11.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))': + dependencies: + '@solana/kit': 5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true + '@solana-program/system@0.10.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))': + dependencies: + '@solana/kit': 5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true + '@solana-program/token-2022@0.6.1(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(@solana/sysvars@5.5.1(typescript@5.9.3))': + dependencies: + '@solana/kit': 5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/sysvars': 5.5.1(typescript@5.9.3) - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true + '@solana-program/token@0.9.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))': + dependencies: + '@solana/kit': 5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@img/sharp-linux-arm64@0.34.5': + '@solana/accounts@5.5.1(typescript@5.9.3)': + dependencies: + '@solana/addresses': 5.5.1(typescript@5.9.3) + '@solana/codecs-core': 5.5.1(typescript@5.9.3) + '@solana/codecs-strings': 5.5.1(typescript@5.9.3) + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/rpc-spec': 5.5.1(typescript@5.9.3) + '@solana/rpc-types': 5.5.1(typescript@5.9.3) optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - '@img/sharp-linux-arm@0.34.5': + '@solana/addresses@5.5.1(typescript@5.9.3)': + dependencies: + '@solana/assertions': 5.5.1(typescript@5.9.3) + '@solana/codecs-core': 5.5.1(typescript@5.9.3) + '@solana/codecs-strings': 5.5.1(typescript@5.9.3) + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/nominal-types': 5.5.1(typescript@5.9.3) optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - '@img/sharp-linux-ppc64@0.34.5': + '@solana/assertions@5.5.1(typescript@5.9.3)': + dependencies: + '@solana/errors': 5.5.1(typescript@5.9.3) optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true + typescript: 5.9.3 - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true + '@solana/buffer-layout@4.0.1': + dependencies: + buffer: 6.0.3 - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true + '@solana/codecs-core@2.3.0(typescript@5.9.3)': + dependencies: + '@solana/errors': 2.3.0(typescript@5.9.3) + typescript: 5.9.3 - '@img/sharp-linux-x64@0.34.5': + '@solana/codecs-core@5.5.1(typescript@5.9.3)': + dependencies: + '@solana/errors': 5.5.1(typescript@5.9.3) optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true + typescript: 5.9.3 - '@img/sharp-linuxmusl-arm64@0.34.5': + '@solana/codecs-data-structures@5.5.1(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 5.5.1(typescript@5.9.3) + '@solana/codecs-numbers': 5.5.1(typescript@5.9.3) + '@solana/errors': 5.5.1(typescript@5.9.3) optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true + typescript: 5.9.3 - '@img/sharp-linuxmusl-x64@0.34.5': + '@solana/codecs-numbers@2.3.0(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 2.3.0(typescript@5.9.3) + '@solana/errors': 2.3.0(typescript@5.9.3) + typescript: 5.9.3 + + '@solana/codecs-numbers@5.5.1(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 5.5.1(typescript@5.9.3) + '@solana/errors': 5.5.1(typescript@5.9.3) optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true + typescript: 5.9.3 - '@img/sharp-wasm32@0.34.5': + '@solana/codecs-strings@5.5.1(typescript@5.9.3)': dependencies: - '@emnapi/runtime': 1.8.1 - optional: true + '@solana/codecs-core': 5.5.1(typescript@5.9.3) + '@solana/codecs-numbers': 5.5.1(typescript@5.9.3) + '@solana/errors': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 - '@img/sharp-win32-arm64@0.34.5': - optional: true + '@solana/codecs@5.5.1(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 5.5.1(typescript@5.9.3) + '@solana/codecs-data-structures': 5.5.1(typescript@5.9.3) + '@solana/codecs-numbers': 5.5.1(typescript@5.9.3) + '@solana/codecs-strings': 5.5.1(typescript@5.9.3) + '@solana/options': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - '@img/sharp-win32-ia32@0.34.5': - optional: true + '@solana/errors@2.3.0(typescript@5.9.3)': + dependencies: + chalk: 5.6.2 + commander: 14.0.3 + typescript: 5.9.3 - '@img/sharp-win32-x64@0.34.5': - optional: true + '@solana/errors@5.5.1(typescript@5.9.3)': + dependencies: + chalk: 5.6.2 + commander: 14.0.2 + optionalDependencies: + typescript: 5.9.3 - '@isaacs/balanced-match@4.0.1': {} + '@solana/fast-stable-stringify@5.5.1(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 - '@isaacs/brace-expansion@5.0.0': - dependencies: - '@isaacs/balanced-match': 4.0.1 + '@solana/functional@5.5.1(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 - '@jridgewell/gen-mapping@0.3.13': + '@solana/instruction-plans@5.5.1(typescript@5.9.3)': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/instructions': 5.5.1(typescript@5.9.3) + '@solana/keys': 5.5.1(typescript@5.9.3) + '@solana/promises': 5.5.1(typescript@5.9.3) + '@solana/transaction-messages': 5.5.1(typescript@5.9.3) + '@solana/transactions': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - '@jridgewell/remapping@2.3.5': + '@solana/instructions@5.5.1(typescript@5.9.3)': dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} + '@solana/codecs-core': 5.5.1(typescript@5.9.3) + '@solana/errors': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 - '@jridgewell/trace-mapping@0.3.31': + '@solana/keys@5.5.1(typescript@5.9.3)': dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@solana/assertions': 5.5.1(typescript@5.9.3) + '@solana/codecs-core': 5.5.1(typescript@5.9.3) + '@solana/codecs-strings': 5.5.1(typescript@5.9.3) + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/nominal-types': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana/accounts': 5.5.1(typescript@5.9.3) + '@solana/addresses': 5.5.1(typescript@5.9.3) + '@solana/codecs': 5.5.1(typescript@5.9.3) + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/functional': 5.5.1(typescript@5.9.3) + '@solana/instruction-plans': 5.5.1(typescript@5.9.3) + '@solana/instructions': 5.5.1(typescript@5.9.3) + '@solana/keys': 5.5.1(typescript@5.9.3) + '@solana/offchain-messages': 5.5.1(typescript@5.9.3) + '@solana/plugin-core': 5.5.1(typescript@5.9.3) + '@solana/programs': 5.5.1(typescript@5.9.3) + '@solana/rpc': 5.5.1(typescript@5.9.3) + '@solana/rpc-api': 5.5.1(typescript@5.9.3) + '@solana/rpc-parsed-types': 5.5.1(typescript@5.9.3) + '@solana/rpc-spec-types': 5.5.1(typescript@5.9.3) + '@solana/rpc-subscriptions': 5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/rpc-types': 5.5.1(typescript@5.9.3) + '@solana/signers': 5.5.1(typescript@5.9.3) + '@solana/sysvars': 5.5.1(typescript@5.9.3) + '@solana/transaction-confirmation': 5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/transaction-messages': 5.5.1(typescript@5.9.3) + '@solana/transactions': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - fastestsmallesttextencoderdecoder + - utf-8-validate - '@mixmark-io/domino@2.2.0': {} + '@solana/nominal-types@5.5.1(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 - '@mongodb-js/zstd@7.0.0': + '@solana/offchain-messages@5.5.1(typescript@5.9.3)': dependencies: - node-addon-api: 8.5.0 - prebuild-install: 7.1.3 - optional: true + '@solana/addresses': 5.5.1(typescript@5.9.3) + '@solana/codecs-core': 5.5.1(typescript@5.9.3) + '@solana/codecs-data-structures': 5.5.1(typescript@5.9.3) + '@solana/codecs-numbers': 5.5.1(typescript@5.9.3) + '@solana/codecs-strings': 5.5.1(typescript@5.9.3) + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/keys': 5.5.1(typescript@5.9.3) + '@solana/nominal-types': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - '@napi-rs/wasm-runtime@0.2.12': + '@solana/options@5.5.1(typescript@5.9.3)': dependencies: - '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.8.1 - '@tybys/wasm-util': 0.10.1 - optional: true + '@solana/codecs-core': 5.5.1(typescript@5.9.3) + '@solana/codecs-data-structures': 5.5.1(typescript@5.9.3) + '@solana/codecs-numbers': 5.5.1(typescript@5.9.3) + '@solana/codecs-strings': 5.5.1(typescript@5.9.3) + '@solana/errors': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - '@next/env@16.2.0-canary.26': {} + '@solana/plugin-core@5.5.1(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 - '@next/eslint-plugin-next@16.1.6': + '@solana/programs@5.5.1(typescript@5.9.3)': dependencies: - fast-glob: 3.3.1 + '@solana/addresses': 5.5.1(typescript@5.9.3) + '@solana/errors': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - '@next/swc-darwin-arm64@16.2.0-canary.26': - optional: true + '@solana/promises@5.5.1(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 - '@next/swc-darwin-x64@16.2.0-canary.26': - optional: true + '@solana/rpc-api@5.5.1(typescript@5.9.3)': + dependencies: + '@solana/addresses': 5.5.1(typescript@5.9.3) + '@solana/codecs-core': 5.5.1(typescript@5.9.3) + '@solana/codecs-strings': 5.5.1(typescript@5.9.3) + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/keys': 5.5.1(typescript@5.9.3) + '@solana/rpc-parsed-types': 5.5.1(typescript@5.9.3) + '@solana/rpc-spec': 5.5.1(typescript@5.9.3) + '@solana/rpc-transformers': 5.5.1(typescript@5.9.3) + '@solana/rpc-types': 5.5.1(typescript@5.9.3) + '@solana/transaction-messages': 5.5.1(typescript@5.9.3) + '@solana/transactions': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - '@next/swc-linux-arm64-gnu@16.2.0-canary.26': - optional: true + '@solana/rpc-parsed-types@5.5.1(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 - '@next/swc-linux-arm64-musl@16.2.0-canary.26': - optional: true + '@solana/rpc-spec-types@5.5.1(typescript@5.9.3)': + optionalDependencies: + typescript: 5.9.3 + + '@solana/rpc-spec@5.5.1(typescript@5.9.3)': + dependencies: + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/rpc-spec-types': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 - '@next/swc-linux-x64-gnu@16.2.0-canary.26': - optional: true + '@solana/rpc-subscriptions-api@5.5.1(typescript@5.9.3)': + dependencies: + '@solana/addresses': 5.5.1(typescript@5.9.3) + '@solana/keys': 5.5.1(typescript@5.9.3) + '@solana/rpc-subscriptions-spec': 5.5.1(typescript@5.9.3) + '@solana/rpc-transformers': 5.5.1(typescript@5.9.3) + '@solana/rpc-types': 5.5.1(typescript@5.9.3) + '@solana/transaction-messages': 5.5.1(typescript@5.9.3) + '@solana/transactions': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - '@next/swc-linux-x64-musl@16.2.0-canary.26': - optional: true + '@solana/rpc-subscriptions-channel-websocket@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/functional': 5.5.1(typescript@5.9.3) + '@solana/rpc-subscriptions-spec': 5.5.1(typescript@5.9.3) + '@solana/subscribable': 5.5.1(typescript@5.9.3) + ws: 8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate - '@next/swc-win32-arm64-msvc@16.2.0-canary.26': - optional: true + '@solana/rpc-subscriptions-spec@5.5.1(typescript@5.9.3)': + dependencies: + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/promises': 5.5.1(typescript@5.9.3) + '@solana/rpc-spec-types': 5.5.1(typescript@5.9.3) + '@solana/subscribable': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 - '@next/swc-win32-x64-msvc@16.2.0-canary.26': - optional: true + '@solana/rpc-subscriptions@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/fast-stable-stringify': 5.5.1(typescript@5.9.3) + '@solana/functional': 5.5.1(typescript@5.9.3) + '@solana/promises': 5.5.1(typescript@5.9.3) + '@solana/rpc-spec-types': 5.5.1(typescript@5.9.3) + '@solana/rpc-subscriptions-api': 5.5.1(typescript@5.9.3) + '@solana/rpc-subscriptions-channel-websocket': 5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/rpc-subscriptions-spec': 5.5.1(typescript@5.9.3) + '@solana/rpc-transformers': 5.5.1(typescript@5.9.3) + '@solana/rpc-types': 5.5.1(typescript@5.9.3) + '@solana/subscribable': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - fastestsmallesttextencoderdecoder + - utf-8-validate - '@nodelib/fs.scandir@2.1.5': + '@solana/rpc-transformers@5.5.1(typescript@5.9.3)': dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/functional': 5.5.1(typescript@5.9.3) + '@solana/nominal-types': 5.5.1(typescript@5.9.3) + '@solana/rpc-spec-types': 5.5.1(typescript@5.9.3) + '@solana/rpc-types': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - '@nodelib/fs.stat@2.0.5': {} + '@solana/rpc-transport-http@5.5.1(typescript@5.9.3)': + dependencies: + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/rpc-spec': 5.5.1(typescript@5.9.3) + '@solana/rpc-spec-types': 5.5.1(typescript@5.9.3) + undici-types: 7.21.0 + optionalDependencies: + typescript: 5.9.3 - '@nodelib/fs.walk@1.2.8': + '@solana/rpc-types@5.5.1(typescript@5.9.3)': dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 + '@solana/addresses': 5.5.1(typescript@5.9.3) + '@solana/codecs-core': 5.5.1(typescript@5.9.3) + '@solana/codecs-numbers': 5.5.1(typescript@5.9.3) + '@solana/codecs-strings': 5.5.1(typescript@5.9.3) + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/nominal-types': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/rpc@5.5.1(typescript@5.9.3)': + dependencies: + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/fast-stable-stringify': 5.5.1(typescript@5.9.3) + '@solana/functional': 5.5.1(typescript@5.9.3) + '@solana/rpc-api': 5.5.1(typescript@5.9.3) + '@solana/rpc-spec': 5.5.1(typescript@5.9.3) + '@solana/rpc-spec-types': 5.5.1(typescript@5.9.3) + '@solana/rpc-transformers': 5.5.1(typescript@5.9.3) + '@solana/rpc-transport-http': 5.5.1(typescript@5.9.3) + '@solana/rpc-types': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/signers@5.5.1(typescript@5.9.3)': + dependencies: + '@solana/addresses': 5.5.1(typescript@5.9.3) + '@solana/codecs-core': 5.5.1(typescript@5.9.3) + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/instructions': 5.5.1(typescript@5.9.3) + '@solana/keys': 5.5.1(typescript@5.9.3) + '@solana/nominal-types': 5.5.1(typescript@5.9.3) + '@solana/offchain-messages': 5.5.1(typescript@5.9.3) + '@solana/transaction-messages': 5.5.1(typescript@5.9.3) + '@solana/transactions': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder - '@nolyfill/is-core-module@1.0.39': {} + '@solana/subscribable@5.5.1(typescript@5.9.3)': + dependencies: + '@solana/errors': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 - '@opentelemetry/api@1.9.0': {} + '@solana/sysvars@5.5.1(typescript@5.9.3)': + dependencies: + '@solana/accounts': 5.5.1(typescript@5.9.3) + '@solana/codecs': 5.5.1(typescript@5.9.3) + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/rpc-types': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/transaction-confirmation@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@solana/addresses': 5.5.1(typescript@5.9.3) + '@solana/codecs-strings': 5.5.1(typescript@5.9.3) + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/keys': 5.5.1(typescript@5.9.3) + '@solana/promises': 5.5.1(typescript@5.9.3) + '@solana/rpc': 5.5.1(typescript@5.9.3) + '@solana/rpc-subscriptions': 5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/rpc-types': 5.5.1(typescript@5.9.3) + '@solana/transaction-messages': 5.5.1(typescript@5.9.3) + '@solana/transactions': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - fastestsmallesttextencoderdecoder + - utf-8-validate - '@rtsao/scc@1.1.0': {} + '@solana/transaction-messages@5.5.1(typescript@5.9.3)': + dependencies: + '@solana/addresses': 5.5.1(typescript@5.9.3) + '@solana/codecs-core': 5.5.1(typescript@5.9.3) + '@solana/codecs-data-structures': 5.5.1(typescript@5.9.3) + '@solana/codecs-numbers': 5.5.1(typescript@5.9.3) + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/functional': 5.5.1(typescript@5.9.3) + '@solana/instructions': 5.5.1(typescript@5.9.3) + '@solana/nominal-types': 5.5.1(typescript@5.9.3) + '@solana/rpc-types': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/transactions@5.5.1(typescript@5.9.3)': + dependencies: + '@solana/addresses': 5.5.1(typescript@5.9.3) + '@solana/codecs-core': 5.5.1(typescript@5.9.3) + '@solana/codecs-data-structures': 5.5.1(typescript@5.9.3) + '@solana/codecs-numbers': 5.5.1(typescript@5.9.3) + '@solana/codecs-strings': 5.5.1(typescript@5.9.3) + '@solana/errors': 5.5.1(typescript@5.9.3) + '@solana/functional': 5.5.1(typescript@5.9.3) + '@solana/instructions': 5.5.1(typescript@5.9.3) + '@solana/keys': 5.5.1(typescript@5.9.3) + '@solana/nominal-types': 5.5.1(typescript@5.9.3) + '@solana/rpc-types': 5.5.1(typescript@5.9.3) + '@solana/transaction-messages': 5.5.1(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/wallet-standard-features@1.3.0': + dependencies: + '@wallet-standard/base': 1.1.0 + '@wallet-standard/features': 1.1.0 + + '@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@babel/runtime': 7.28.6 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.4.0 + '@solana/buffer-layout': 4.0.1 + '@solana/codecs-numbers': 2.3.0(typescript@5.9.3) + agentkeepalive: 4.6.0 + bn.js: 5.2.2 + borsh: 0.7.0 + bs58: 4.0.1 + buffer: 6.0.3 + fast-stable-stringify: 1.0.0 + jayson: 4.3.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + node-fetch: 2.7.0 + rpc-websockets: 9.3.3 + superstruct: 2.0.2 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate '@standard-schema/spec@1.1.0': {} @@ -2825,6 +6798,21 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.18 + '@tanstack/query-core@5.90.20': {} + + '@tanstack/react-query@5.90.20(react@19.2.3)': + dependencies: + '@tanstack/query-core': 5.90.20 + react: 19.2.3 + + '@tanstack/react-virtual@3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/virtual-core': 3.13.18 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + '@tanstack/virtual-core@3.13.18': {} + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -2839,12 +6827,26 @@ snapshots: tslib: 2.8.1 optional: true + '@types/connect@3.4.38': + dependencies: + '@types/node': 20.19.30 + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} + '@types/lodash@4.17.23': {} + + '@types/ms@2.1.0': {} + + '@types/node@12.20.55': {} + '@types/node@20.19.30': dependencies: undici-types: 6.21.0 @@ -2857,6 +6859,20 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/stylis@4.2.7': {} + + '@types/trusted-types@2.0.7': {} + + '@types/uuid@8.3.4': {} + + '@types/ws@7.4.7': + dependencies: + '@types/node': 20.19.30 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.19.30 + '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3029,12 +7045,1105 @@ snapshots: - bare-abort-controller - react-native-b4a + '@wagmi/connectors@6.2.0(@tanstack/react-query@5.90.20(react@19.2.3))(@types/react@19.2.10)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.20)(@types/react@19.2.10)(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(utf-8-validate@5.0.10)(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.3))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@3.25.76))(zod@3.25.76)': + dependencies: + '@base-org/account': 2.4.0(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(utf-8-validate@5.0.10)(zod@3.25.76) + '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(utf-8-validate@5.0.10)(zod@3.25.76) + '@gemini-wallet/core': 0.3.2(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + '@metamask/sdk': 0.33.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) + '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.20)(@types/react@19.2.10)(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + '@walletconnect/ethereum-provider': 2.21.1(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + cbw-sdk: '@coinbase/wallet-sdk@3.9.3' + porto: 0.2.35(@tanstack/react-query@5.90.20(react@19.2.3))(@types/react@19.2.10)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.20)(@types/react@19.2.10)(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.3))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@3.25.76)) + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@tanstack/react-query' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - debug + - encoding + - expo-auth-session + - expo-crypto + - expo-web-browser + - fastestsmallesttextencoderdecoder + - immer + - ioredis + - react + - react-native + - supports-color + - uploadthing + - use-sync-external-store + - utf-8-validate + - wagmi + - zod + + '@wagmi/core@2.22.1(@tanstack/query-core@5.90.20)(@types/react@19.2.10)(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))': + dependencies: + eventemitter3: 5.0.1 + mipd: 0.0.7(typescript@5.9.3) + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + zustand: 5.0.0(@types/react@19.2.10)(react@19.2.3)(use-sync-external-store@1.4.0(react@19.2.3)) + optionalDependencies: + '@tanstack/query-core': 5.90.20 + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/react' + - immer + - react + - use-sync-external-store + + '@wallet-standard/app@1.1.0': + dependencies: + '@wallet-standard/base': 1.1.0 + + '@wallet-standard/base@1.1.0': {} + + '@wallet-standard/features@1.1.0': + dependencies: + '@wallet-standard/base': 1.1.0 + + '@wallet-standard/wallet@1.1.0': + dependencies: + '@wallet-standard/base': 1.1.0 + + '@walletconnect/core@2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + dependencies: + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-provider': 1.0.14 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10) + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 2.1.2 + '@walletconnect/relay-api': 1.0.11 + '@walletconnect/relay-auth': 1.1.0 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.21.0 + '@walletconnect/utils': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/window-getters': 1.0.1 + es-toolkit: 1.33.0 + events: 3.3.0 + uint8arrays: 3.1.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/core@2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + dependencies: + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-provider': 1.0.14 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10) + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 2.1.2 + '@walletconnect/relay-api': 1.0.11 + '@walletconnect/relay-auth': 1.1.0 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.21.1 + '@walletconnect/utils': 2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/window-getters': 1.0.1 + es-toolkit: 1.33.0 + events: 3.3.0 + uint8arrays: 3.1.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/core@2.21.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + dependencies: + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-provider': 1.0.14 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10) + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 2.1.2 + '@walletconnect/relay-api': 1.0.11 + '@walletconnect/relay-auth': 1.1.0 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.21.9 + '@walletconnect/utils': 2.21.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/window-getters': 1.0.1 + es-toolkit: 1.39.3 + events: 3.3.0 + uint8arrays: 3.1.1 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/core@2.22.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + dependencies: + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-provider': 1.0.14 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/jsonrpc-ws-connection': 1.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10) + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 3.0.0 + '@walletconnect/relay-api': 1.0.11 + '@walletconnect/relay-auth': 1.1.0 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.22.4 + '@walletconnect/utils': 2.22.4(typescript@5.9.3)(zod@4.3.6) + '@walletconnect/window-getters': 1.0.1 + es-toolkit: 1.39.3 + events: 3.3.0 + uint8arrays: 3.1.1 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/environment@1.0.1': + dependencies: + tslib: 1.14.1 + + '@walletconnect/ethereum-provider@2.21.1(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + dependencies: + '@reown/appkit': 1.7.8(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/jsonrpc-http-connection': 1.0.8 + '@walletconnect/jsonrpc-provider': 1.0.14 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/sign-client': 2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1 + '@walletconnect/universal-provider': 2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/utils': 2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/ethereum-provider@2.22.4(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + dependencies: + '@reown/appkit': 1.8.9(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/jsonrpc-http-connection': 1.0.8 + '@walletconnect/jsonrpc-provider': 1.0.14 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 3.0.0 + '@walletconnect/sign-client': 2.22.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/types': 2.22.4 + '@walletconnect/universal-provider': 2.22.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/utils': 2.22.4(typescript@5.9.3)(zod@4.3.6) + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/events@1.0.1': + dependencies: + keyvaluestorage-interface: 1.0.0 + tslib: 1.14.1 + + '@walletconnect/heartbeat@1.2.2': + dependencies: + '@walletconnect/events': 1.0.1 + '@walletconnect/time': 1.0.2 + events: 3.3.0 + + '@walletconnect/jsonrpc-http-connection@1.0.8': + dependencies: + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/safe-json': 1.0.2 + cross-fetch: 3.2.0 + events: 3.3.0 + transitivePeerDependencies: + - encoding + + '@walletconnect/jsonrpc-provider@1.0.14': + dependencies: + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/safe-json': 1.0.2 + events: 3.3.0 + + '@walletconnect/jsonrpc-types@1.0.4': + dependencies: + events: 3.3.0 + keyvaluestorage-interface: 1.0.0 + + '@walletconnect/jsonrpc-utils@1.0.8': + dependencies: + '@walletconnect/environment': 1.0.1 + '@walletconnect/jsonrpc-types': 1.0.4 + tslib: 1.14.1 + + '@walletconnect/jsonrpc-ws-connection@1.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10)': + dependencies: + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/safe-json': 1.0.2 + events: 3.3.0 + ws: 7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@walletconnect/keyvaluestorage@1.1.1': + dependencies: + '@walletconnect/safe-json': 1.0.2 + idb-keyval: 6.2.2 + unstorage: 1.17.4(idb-keyval@6.2.2) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - ioredis + - uploadthing + + '@walletconnect/logger@2.1.2': + dependencies: + '@walletconnect/safe-json': 1.0.2 + pino: 7.11.0 + + '@walletconnect/logger@3.0.0': + dependencies: + '@walletconnect/safe-json': 1.0.2 + pino: 10.0.0 + + '@walletconnect/relay-api@1.0.11': + dependencies: + '@walletconnect/jsonrpc-types': 1.0.4 + + '@walletconnect/relay-auth@1.1.0': + dependencies: + '@noble/curves': 1.8.0 + '@noble/hashes': 1.7.0 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + uint8arrays: 3.1.1 + + '@walletconnect/safe-json@1.0.2': + dependencies: + tslib: 1.14.1 + + '@walletconnect/sign-client@2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + dependencies: + '@walletconnect/core': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/events': 1.0.1 + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/logger': 2.1.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.21.0 + '@walletconnect/utils': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/sign-client@2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + dependencies: + '@walletconnect/core': 2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/events': 1.0.1 + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/logger': 2.1.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.21.1 + '@walletconnect/utils': 2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/sign-client@2.21.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + dependencies: + '@walletconnect/core': 2.21.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/events': 1.0.1 + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/logger': 2.1.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.21.9 + '@walletconnect/utils': 2.21.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/sign-client@2.22.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + dependencies: + '@walletconnect/core': 2.22.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/events': 1.0.1 + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/logger': 3.0.0 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.22.4 + '@walletconnect/utils': 2.22.4(typescript@5.9.3)(zod@4.3.6) + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/time@1.0.2': + dependencies: + tslib: 1.14.1 + + '@walletconnect/types@2.21.0': + dependencies: + '@walletconnect/events': 1.0.1 + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 2.1.2 + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - ioredis + - uploadthing + + '@walletconnect/types@2.21.1': + dependencies: + '@walletconnect/events': 1.0.1 + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 2.1.2 + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - ioredis + - uploadthing + + '@walletconnect/types@2.21.9': + dependencies: + '@walletconnect/events': 1.0.1 + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 2.1.2 + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - ioredis + - uploadthing + + '@walletconnect/types@2.22.4': + dependencies: + '@walletconnect/events': 1.0.1 + '@walletconnect/heartbeat': 1.2.2 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 3.0.0 + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - ioredis + - uploadthing + + '@walletconnect/universal-provider@2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + dependencies: + '@walletconnect/events': 1.0.1 + '@walletconnect/jsonrpc-http-connection': 1.0.8 + '@walletconnect/jsonrpc-provider': 1.0.14 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 2.1.2 + '@walletconnect/sign-client': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.0 + '@walletconnect/utils': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + es-toolkit: 1.33.0 + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/universal-provider@2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + dependencies: + '@walletconnect/events': 1.0.1 + '@walletconnect/jsonrpc-http-connection': 1.0.8 + '@walletconnect/jsonrpc-provider': 1.0.14 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 2.1.2 + '@walletconnect/sign-client': 2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@walletconnect/types': 2.21.1 + '@walletconnect/utils': 2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + es-toolkit: 1.33.0 + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/universal-provider@2.21.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + dependencies: + '@walletconnect/events': 1.0.1 + '@walletconnect/jsonrpc-http-connection': 1.0.8 + '@walletconnect/jsonrpc-provider': 1.0.14 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 2.1.2 + '@walletconnect/sign-client': 2.21.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/types': 2.21.9 + '@walletconnect/utils': 2.21.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + es-toolkit: 1.39.3 + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/universal-provider@2.22.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + dependencies: + '@walletconnect/events': 1.0.1 + '@walletconnect/jsonrpc-http-connection': 1.0.8 + '@walletconnect/jsonrpc-provider': 1.0.14 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 3.0.0 + '@walletconnect/sign-client': 2.22.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@walletconnect/types': 2.22.4 + '@walletconnect/utils': 2.22.4(typescript@5.9.3)(zod@4.3.6) + es-toolkit: 1.39.3 + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/utils@2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + dependencies: + '@noble/ciphers': 1.2.1 + '@noble/curves': 1.8.1 + '@noble/hashes': 1.7.1 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/relay-api': 1.0.11 + '@walletconnect/relay-auth': 1.1.0 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.21.0 + '@walletconnect/window-getters': 1.0.1 + '@walletconnect/window-metadata': 1.0.1 + bs58: 6.0.0 + detect-browser: 5.3.0 + query-string: 7.1.3 + uint8arrays: 3.1.0 + viem: 2.23.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/utils@2.21.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + dependencies: + '@noble/ciphers': 1.2.1 + '@noble/curves': 1.8.1 + '@noble/hashes': 1.7.1 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/relay-api': 1.0.11 + '@walletconnect/relay-auth': 1.1.0 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.21.1 + '@walletconnect/window-getters': 1.0.1 + '@walletconnect/window-metadata': 1.0.1 + bs58: 6.0.0 + detect-browser: 5.3.0 + query-string: 7.1.3 + uint8arrays: 3.1.0 + viem: 2.23.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/utils@2.21.9(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + dependencies: + '@msgpack/msgpack': 3.1.2 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/relay-api': 1.0.11 + '@walletconnect/relay-auth': 1.1.0 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.21.9 + '@walletconnect/window-getters': 1.0.1 + '@walletconnect/window-metadata': 1.0.1 + blakejs: 1.2.1 + bs58: 6.0.0 + detect-browser: 5.3.0 + uint8arrays: 3.1.1 + viem: 2.36.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - ioredis + - typescript + - uploadthing + - utf-8-validate + - zod + + '@walletconnect/utils@2.22.4(typescript@5.9.3)(zod@4.3.6)': + dependencies: + '@msgpack/msgpack': 3.1.2 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 3.0.0 + '@walletconnect/relay-api': 1.0.11 + '@walletconnect/relay-auth': 1.1.0 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.22.4 + '@walletconnect/window-getters': 1.0.1 + '@walletconnect/window-metadata': 1.0.1 + blakejs: 1.2.1 + bs58: 6.0.0 + detect-browser: 5.3.0 + ox: 0.9.3(typescript@5.9.3)(zod@4.3.6) + uint8arrays: 3.1.1 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - ioredis + - typescript + - uploadthing + - zod + + '@walletconnect/window-getters@1.0.1': + dependencies: + tslib: 1.14.1 + + '@walletconnect/window-metadata@1.0.1': + dependencies: + '@walletconnect/window-getters': 1.0.1 + tslib: 1.14.1 + + abitype@1.0.6(typescript@5.9.3)(zod@3.25.76): + optionalDependencies: + typescript: 5.9.3 + zod: 3.25.76 + + abitype@1.0.8(typescript@5.9.3)(zod@3.25.76): + optionalDependencies: + typescript: 5.9.3 + zod: 3.25.76 + + abitype@1.0.8(typescript@5.9.3)(zod@4.3.6): + optionalDependencies: + typescript: 5.9.3 + zod: 4.3.6 + + abitype@1.2.3(typescript@5.9.3)(zod@3.22.4): + optionalDependencies: + typescript: 5.9.3 + zod: 3.22.4 + + abitype@1.2.3(typescript@5.9.3)(zod@3.25.76): + optionalDependencies: + typescript: 5.9.3 + zod: 3.25.76 + + abitype@1.2.3(typescript@5.9.3)(zod@4.3.6): + optionalDependencies: + typescript: 5.9.3 + zod: 4.3.6 + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 acorn@8.15.0: {} + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + ai@6.0.66(zod@4.3.6): dependencies: '@ai-sdk/gateway': 3.0.31(zod@4.3.6) @@ -3052,10 +8161,17 @@ snapshots: amdefine@1.0.1: {} + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -3135,16 +8251,37 @@ snapshots: async-function@1.0.0: {} + async-mutex@0.2.6: + dependencies: + tslib: 2.8.1 + async-retry@1.3.3: dependencies: retry: 0.13.1 + asynckit@0.4.0: {} + + atomic-sleep@1.0.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 axe-core@4.11.1: {} + axios-retry@4.5.0(axios@1.13.5): + dependencies: + axios: 1.13.5 + is-retry-allowed: 2.2.0 + + axios@1.13.5: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} b4a@1.7.3: {} @@ -3153,12 +8290,17 @@ snapshots: bare-events@2.8.2: {} - base64-js@1.5.1: - optional: true + base-x@3.0.11: + dependencies: + safe-buffer: 5.2.1 + + base-x@5.0.1: {} + + base64-js@1.5.1: {} baseline-browser-mapping@2.9.19: {} - bash-tool@1.3.13(@vercel/sandbox@1.4.1)(ai@6.0.66(zod@4.3.6))(just-bash@2.9.5): + bash-tool@1.3.13(@vercel/sandbox@1.4.1)(ai@6.0.66(zod@4.3.6))(just-bash@2.9.5(bufferutil@4.1.0)(utf-8-validate@5.0.10)): dependencies: ai: 6.0.66(zod@4.3.6) fast-glob: 3.3.3 @@ -3166,7 +8308,9 @@ snapshots: zod: 3.25.76 optionalDependencies: '@vercel/sandbox': 1.4.1 - just-bash: 2.9.5 + just-bash: 2.9.5(bufferutil@4.1.0)(utf-8-validate@5.0.10) + + big.js@6.2.2: {} bl@4.1.0: dependencies: @@ -3175,6 +8319,18 @@ snapshots: readable-stream: 3.6.2 optional: true + blakejs@1.2.1: {} + + bn.js@5.2.2: {} + + borsh@0.7.0: + dependencies: + bn.js: 5.2.2 + bs58: 4.0.1 + text-encoding-utf-8: 1.0.2 + + bowser@2.14.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -3196,12 +8352,29 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + bs58@4.0.1: + dependencies: + base-x: 3.0.11 + + bs58@6.0.0: + dependencies: + base-x: 5.0.1 + buffer@5.7.1: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 optional: true + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bufferutil@4.1.0: + dependencies: + node-gyp-build: 4.8.4 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -3221,24 +8394,60 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + + camelize@1.0.1: {} + caniuse-lite@1.0.30001766: {} + canonicalize@2.1.0: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + + charenc@0.0.2: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + chownr@1.1.4: optional: true client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + clsx@1.2.1: {} + + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 color-name@1.1.4: {} + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@14.0.2: {} + + commander@14.0.3: {} + + commander@2.20.3: {} + commander@2.8.1: dependencies: graceful-readlink: 1.0.1 @@ -3252,12 +8461,44 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@1.2.2: {} + + core-util-is@1.0.3: {} + + crc-32@1.2.2: {} + + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + cross-fetch@4.1.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + crypt@0.0.2: {} + + css-color-keywords@1.0.0: {} + + css-to-react-native@3.2.0: + dependencies: + camelize: 1.0.1 + css-color-keywords: 1.0.0 + postcss-value-parser: 4.2.0 + csstype@3.2.3: {} damerau-levenshtein@1.0.8: {} @@ -3280,14 +8521,30 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns@2.30.0: + dependencies: + '@babel/runtime': 7.28.6 + + dateformat@4.6.3: {} + + dayjs@1.11.13: {} + debug@3.2.7: dependencies: ms: 2.1.3 + debug@4.3.4: + dependencies: + ms: 2.1.2 + debug@4.4.3: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + + decode-uri-component@0.2.2: {} + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -3310,10 +8567,26 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.4: {} + + delay@5.0.0: {} + + delayed-stream@1.0.0: {} + + derive-valtio@0.1.0(valtio@1.13.2(@types/react@19.2.10)(react@19.2.3)): + dependencies: + valtio: 1.13.2(@types/react@19.2.10)(react@19.2.3) + + destr@2.0.5: {} + + detect-browser@5.3.0: {} + detect-libc@2.1.2: {} diff@8.0.3: {} + dijkstrajs@1.0.3: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -3324,14 +8597,45 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + + eciesjs@0.4.17: + dependencies: + '@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0) + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + electron-to-chromium@1.5.283: {} + emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + encode-utf8@1.0.3: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 - optional: true + + engine.io-client@6.6.4(bufferutil@4.1.0)(utf-8-validate@5.0.10): + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} enhanced-resolve@5.18.4: dependencies: @@ -3439,6 +8743,16 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.33.0: {} + + es-toolkit@1.39.3: {} + + es6-promise@4.2.8: {} + + es6-promisify@5.0.0: + dependencies: + es6-promise: 4.2.8 + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} @@ -3648,12 +8962,56 @@ snapshots: esutils@2.0.3: {} + eth-block-tracker@7.1.0: + dependencies: + '@metamask/eth-json-rpc-provider': 1.0.1 + '@metamask/safe-event-emitter': 3.1.2 + '@metamask/utils': 5.0.2 + json-rpc-random-id: 1.0.1 + pify: 3.0.0 + transitivePeerDependencies: + - supports-color + + eth-json-rpc-filters@6.0.1: + dependencies: + '@metamask/safe-event-emitter': 3.1.2 + async-mutex: 0.2.6 + eth-query: 2.1.2 + json-rpc-engine: 6.1.0 + pify: 5.0.0 + + eth-query@2.1.2: + dependencies: + json-rpc-random-id: 1.0.1 + xtend: 4.0.2 + + eth-rpc-errors@4.0.3: + dependencies: + fast-safe-stringify: 2.1.1 + + ethereum-cryptography@2.2.1: + dependencies: + '@noble/curves': 1.4.2 + '@noble/hashes': 1.4.0 + '@scure/bip32': 1.4.0 + '@scure/bip39': 1.3.0 + + event-target-shim@5.0.1: {} + + eventemitter2@6.4.9: {} + + eventemitter3@5.0.1: {} + + eventemitter3@5.0.4: {} + events-universal@1.0.1: dependencies: bare-events: 2.8.2 transitivePeerDependencies: - bare-abort-controller + events@3.3.0: {} + eventsource-parser@3.0.6: {} expand-template@2.0.3: @@ -3663,10 +9021,19 @@ snapshots: dependencies: is-extendable: 0.1.1 + extension-port-stream@3.0.0: + dependencies: + readable-stream: 3.6.2 + webextension-polyfill: 0.10.0 + + eyes@0.1.8: {} + fast-check@4.5.3: dependencies: pure-rand: 7.0.1 + fast-copy@3.0.2: {} + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -3691,6 +9058,14 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-password-entropy@1.1.1: {} + + fast-redact@3.5.0: {} + + fast-safe-stringify@2.1.1: {} + + fast-stable-stringify@1.0.0: {} + fast-xml-parser@5.3.4: dependencies: strnum: 2.1.2 @@ -3703,6 +9078,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fetch-retry@6.0.0: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -3720,6 +9097,13 @@ snapshots: dependencies: to-regex-range: 5.0.1 + filter-obj@1.1.0: {} + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -3732,10 +9116,20 @@ snapshots: flatted@3.3.3: {} + follow-redirects@1.15.11: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + fs-constants@1.0.0: optional: true @@ -3760,6 +9154,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3821,6 +9217,18 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 + h3@1.15.5: + dependencies: + cookie-es: 1.2.2 + crossws: 0.3.5 + defu: 6.1.4 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.3 + uncrypto: 0.1.3 + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -3843,12 +9251,24 @@ snapshots: dependencies: function-bind: 1.1.2 + help-me@5.0.0: {} + hermes-estree@0.25.1: {} hermes-parser@0.25.1: dependencies: hermes-estree: 0.25.1 + hono@4.11.9: {} + + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + + idb-keyval@6.2.1: {} + + idb-keyval@6.2.2: {} + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -3862,8 +9282,7 @@ snapshots: imurmurhash@0.1.4: {} - inherits@2.0.4: - optional: true + inherits@2.0.4: {} ini@1.3.8: optional: true @@ -3876,6 +9295,13 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + iron-webcrypto@1.2.1: {} + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -3899,6 +9325,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-buffer@1.1.6: {} + is-bun-module@2.0.0: dependencies: semver: 7.7.3 @@ -3928,6 +9356,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -3958,12 +9388,16 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + is-retry-allowed@2.2.0: {} + is-set@2.0.3: {} is-shared-array-buffer@1.0.4: dependencies: call-bound: 1.0.4 + is-stream@2.0.1: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -3990,10 +9424,24 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} + isomorphic-ws@4.0.1(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)): + dependencies: + ws: 7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10) + + isows@1.0.6(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)): + dependencies: + ws: 8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + + isows@1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)): + dependencies: + ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -4003,8 +9451,34 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jayson@4.3.0(bufferutil@4.1.0)(utf-8-validate@5.0.10): + dependencies: + '@types/connect': 3.4.38 + '@types/node': 12.20.55 + '@types/ws': 7.4.7 + commander: 2.20.3 + delay: 5.0.0 + es6-promisify: 5.0.0 + eyes: 0.1.8 + isomorphic-ws: 4.0.1(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + json-stringify-safe: 5.0.1 + stream-json: 1.9.1 + uuid: 8.3.2 + ws: 7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + jiti@2.6.1: {} + jose@4.15.9: {} + + jose@6.1.3: {} + + joycon@3.1.1: {} + + js-cookie@3.0.5: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: @@ -4020,12 +9494,21 @@ snapshots: json-buffer@3.0.1: {} + json-rpc-engine@6.1.0: + dependencies: + '@metamask/safe-event-emitter': 2.0.0 + eth-rpc-errors: 4.0.3 + + json-rpc-random-id@1.0.1: {} + json-schema-traverse@0.4.1: {} json-schema@0.4.0: {} json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: {} + json5@1.0.2: dependencies: minimist: 1.2.8 @@ -4041,7 +9524,7 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 - just-bash@2.9.5: + just-bash@2.9.5(bufferutil@4.1.0)(utf-8-validate@5.0.10): dependencies: compressjs: 1.0.3 diff: 8.0.3 @@ -4051,7 +9534,7 @@ snapshots: minimatch: 10.1.1 modern-tar: 0.7.3 papaparse: 5.5.3 - pyodide: 0.27.7 + pyodide: 0.27.7(bufferutil@4.1.0)(utf-8-validate@5.0.10) re2js: 1.2.1 smol-toml: 1.6.0 sprintf-js: 1.1.3 @@ -4066,10 +9549,18 @@ snapshots: - supports-color - utf-8-validate + keccak@3.0.4: + dependencies: + node-addon-api: 2.0.2 + node-gyp-build: 4.8.4 + readable-stream: 3.6.2 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + keyvaluestorage-interface@1.0.0: {} + kind-of@6.0.3: {} language-subtag-registry@0.3.23: {} @@ -4083,6 +9574,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.12.36: {} + lightningcss-android-arm64@1.30.2: optional: true @@ -4132,33 +9625,75 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 + lit-element@4.2.2: + dependencies: + '@lit-labs/ssr-dom-shim': 1.5.1 + '@lit/reactive-element': 2.1.2 + lit-html: 3.3.2 + + lit-html@3.3.2: + dependencies: + '@types/trusted-types': 2.0.7 + + lit@3.3.0: + dependencies: + '@lit/reactive-element': 2.1.2 + lit-element: 4.2.2 + lit-html: 3.3.2 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 lodash.merge@4.6.2: {} + lodash@4.17.23: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 + lru-cache@11.2.5: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lucide-react@0.554.0(react@19.2.3): + dependencies: + react: 19.2.3 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 math-intrinsics@1.1.0: {} + md5@2.3.0: + dependencies: + charenc: 0.0.2 + crypt: 0.0.2 + is-buffer: 1.1.6 + merge2@1.4.1: {} + micro-ftch@0.3.1: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mimic-response@3.1.0: optional: true @@ -4176,13 +9711,21 @@ snapshots: minimist@1.2.8: {} + mipd@0.0.7(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + mkdirp-classic@0.5.3: optional: true modern-tar@0.7.3: {} + ms@2.1.2: {} + ms@2.1.3: {} + multiformats@9.9.0: {} + nanoid@3.3.11: {} napi-build-utils@2.0.0: @@ -4222,11 +9765,18 @@ snapshots: semver: 7.7.3 optional: true + node-addon-api@2.0.2: {} + node-addon-api@8.5.0: optional: true - node-gyp-build@4.8.4: - optional: true + node-fetch-native@1.6.7: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-gyp-build@4.8.4: {} node-liblzma@2.2.0: dependencies: @@ -4234,8 +9784,18 @@ snapshots: node-gyp-build: 4.8.4 optional: true + node-mock-http@1.0.4: {} + node-releases@2.0.27: {} + normalize-path@3.0.0: {} + + obj-multiplex@1.0.0: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + readable-stream: 2.3.8 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -4278,10 +9838,25 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.3 + + on-exit-leak-free@0.2.0: {} + + on-exit-leak-free@2.1.2: {} + once@1.4.0: dependencies: wrappy: 1.0.2 - optional: true + + openapi-fetch@0.13.8: + dependencies: + openapi-typescript-helpers: 0.0.15 + + openapi-typescript-helpers@0.0.15: {} optionator@0.9.4: dependencies: @@ -4300,46 +9875,277 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + ox@0.11.3(typescript@5.9.3)(zod@3.22.4): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.22.4) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + + ox@0.11.3(typescript@5.9.3)(zod@3.25.76): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + + ox@0.11.3(typescript@5.9.3)(zod@4.3.6): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@4.3.6) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + + ox@0.6.7(typescript@5.9.3)(zod@3.25.76): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/curves': 1.8.1 + '@noble/hashes': 1.7.1 + '@scure/bip32': 1.6.2 + '@scure/bip39': 1.5.4 + abitype: 1.0.8(typescript@5.9.3)(zod@3.25.76) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + + ox@0.6.9(typescript@5.9.3)(zod@3.25.76): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + + ox@0.9.1(typescript@5.9.3)(zod@4.3.6): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.0.8(typescript@5.9.3)(zod@4.3.6) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + + ox@0.9.17(typescript@5.9.3)(zod@4.3.6): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@4.3.6) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + + ox@0.9.3(typescript@5.9.3)(zod@4.3.6): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@4.3.6) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + papaparse@5.5.3: {} parent-module@1.0.1: dependencies: callsites: 3.1.0 - path-exists@4.0.0: {} + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pify@3.0.0: {} + + pify@5.0.0: {} - path-key@3.1.1: {} + pino-abstract-transport@0.5.0: + dependencies: + duplexify: 4.1.3 + split2: 4.2.0 - path-parse@1.0.7: {} + pino-abstract-transport@1.2.0: + dependencies: + readable-stream: 4.7.0 + split2: 4.2.0 - picocolors@1.1.1: {} + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 - picomatch@2.3.1: {} + pino-pretty@10.3.1: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 3.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 1.2.0 + pump: 3.0.3 + readable-stream: 4.7.0 + secure-json-parse: 2.7.0 + sonic-boom: 3.8.1 + strip-json-comments: 3.1.1 - picomatch@4.0.3: {} + pino-std-serializers@4.0.0: {} + + pino-std-serializers@7.1.0: {} + + pino@10.0.0: + dependencies: + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + slow-redact: 0.3.2 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 + + pino@7.11.0: + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.5.0 + on-exit-leak-free: 0.2.0 + pino-abstract-transport: 0.5.0 + pino-std-serializers: 4.0.0 + process-warning: 1.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.1.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 2.8.0 + thread-stream: 0.15.2 + + pngjs@5.0.0: {} + + pony-cause@2.1.11: {} + + porto@0.2.35(@tanstack/react-query@5.90.20(react@19.2.3))(@types/react@19.2.10)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.20)(@types/react@19.2.10)(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.3))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@3.25.76)): + dependencies: + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.20)(@types/react@19.2.10)(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + hono: 4.11.9 + idb-keyval: 6.2.2 + mipd: 0.0.7(typescript@5.9.3) + ox: 0.9.17(typescript@5.9.3)(zod@4.3.6) + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + zod: 4.3.6 + zustand: 5.0.11(@types/react@19.2.10)(react@19.2.3)(use-sync-external-store@1.4.0(react@19.2.3)) + optionalDependencies: + '@tanstack/react-query': 5.90.20(react@19.2.3) + react: 19.2.3 + typescript: 5.9.3 + wagmi: 2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.3))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) + transitivePeerDependencies: + - '@types/react' + - immer + - use-sync-external-store possible-typed-array-names@1.1.0: {} + postcss-value-parser@4.2.0: {} + postcss@8.4.31: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.4.49: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.6: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 + preact@10.24.2: {} + + preact@10.28.3: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -4358,31 +10164,68 @@ snapshots: prelude-ls@1.2.1: {} + process-nextick-args@2.0.1: {} + + process-warning@1.0.0: {} + + process-warning@5.0.0: {} + + process@0.11.10: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 + proxy-compare@2.6.0: {} + + proxy-compare@3.0.1: {} + + proxy-from-env@1.1.0: {} + pump@3.0.3: dependencies: end-of-stream: 1.4.5 once: 1.4.0 - optional: true punycode@2.3.1: {} pure-rand@7.0.1: {} - pyodide@0.27.7: + pyodide@0.27.7(bufferutil@4.1.0)(utf-8-validate@5.0.10): dependencies: - ws: 8.19.0 + ws: 8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil - utf-8-validate + qrcode@1.5.3: + dependencies: + dijkstrajs: 1.0.3 + encode-utf8: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + + query-string@7.1.3: + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + + radix3@1.1.2: {} + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -4393,6 +10236,12 @@ snapshots: re2js@1.2.1: {} + react-device-detect@2.2.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + ua-parser-js: 1.0.41 + react-dom@19.2.3(react@19.2.3): dependencies: react: 19.2.3 @@ -4402,12 +10251,35 @@ snapshots: react@19.2.3: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 - optional: true + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdirp@5.0.0: {} + + real-require@0.1.0: {} + + real-require@0.2.0: {} reflect.getprototypeof@1.0.10: dependencies: @@ -4429,6 +10301,10 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -4449,6 +10325,19 @@ snapshots: reusify@1.1.0: {} + rpc-websockets@9.3.3: + dependencies: + '@swc/helpers': 0.5.15 + '@types/uuid': 8.3.4 + '@types/ws': 8.18.1 + buffer: 6.0.3 + eventemitter3: 5.0.4 + uuid: 8.3.2 + ws: 8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 5.0.10 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -4461,8 +10350,9 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 - safe-buffer@5.2.1: - optional: true + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} safe-push-apply@1.0.0: dependencies: @@ -4475,6 +10365,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-stable-stringify@2.5.0: {} + scheduler@0.27.0: {} section-matter@1.0.0: @@ -4482,10 +10374,20 @@ snapshots: extend-shallow: 2.0.1 kind-of: 6.0.3 + secure-json-parse@2.7.0: {} + + secure-password-utilities@0.2.1: {} + semver@6.3.1: {} + semver@7.7.2: {} + semver@7.7.3: {} + set-blocking@2.0.0: {} + + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -4508,6 +10410,14 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + sha.js@2.4.12: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + + shallowequal@1.1.0: {} + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -4584,10 +10494,46 @@ snapshots: simple-concat: 1.0.1 optional: true + slow-redact@0.3.2: {} + smol-toml@1.6.0: {} + socket.io-client@4.8.3(bufferutil@4.1.0)(utf-8-validate@5.0.10): + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-client: 6.6.4(bufferutil@4.1.0)(utf-8-validate@5.0.10) + socket.io-parser: 4.2.5 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.5: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + sonic-boom@2.8.0: + dependencies: + atomic-sleep: 1.0.0 + + sonic-boom@3.8.1: + dependencies: + atomic-sleep: 1.0.0 + + sonic-boom@4.2.0: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} + split-on-first@1.1.0: {} + + split2@4.2.0: {} + sprintf-js@1.0.3: {} sprintf-js@1.1.3: {} @@ -4601,6 +10547,14 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + stream-chain@2.2.5: {} + + stream-json@1.9.1: + dependencies: + stream-chain: 2.2.5 + + stream-shift@1.0.3: {} + streamx@2.23.0: dependencies: events-universal: 1.0.1 @@ -4610,6 +10564,14 @@ snapshots: - bare-abort-controller - react-native-b4a + strict-uri-encode@2.0.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.8 @@ -4660,10 +10622,17 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 - optional: true + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 strip-bom-string@1.0.0: {} @@ -4680,6 +10649,21 @@ snapshots: dependencies: '@tokenizer/token': 0.3.0 + styled-components@6.3.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@emotion/is-prop-valid': 1.4.0 + '@emotion/unitless': 0.10.0 + '@types/stylis': 4.2.7 + css-to-react-native: 3.2.0 + csstype: 3.2.3 + postcss: 8.4.49 + react: 19.2.3 + shallowequal: 1.1.0 + stylis: 4.3.6 + tslib: 2.8.1 + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.3): dependencies: client-only: 0.0.1 @@ -4687,12 +10671,20 @@ snapshots: optionalDependencies: '@babel/core': 7.29.0 + stylis@4.3.6: {} + + superstruct@1.0.4: {} + + superstruct@2.0.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 supports-preserve-symlinks-flag@1.0.0: {} + tabbable@6.4.0: {} + tailwindcss@4.1.18: {} tapable@2.3.0: {} @@ -4729,11 +10721,29 @@ snapshots: transitivePeerDependencies: - react-native-b4a + text-encoding-utf-8@1.0.2: {} + + thread-stream@0.15.2: + dependencies: + real-require: 0.1.0 + + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + + tinycolor2@1.6.0: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -4744,6 +10754,8 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + tr46@0.0.3: {} + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -4755,6 +10767,8 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tslib@1.14.1: {} + tslib@2.8.1: {} tunnel-agent@0.6.0: @@ -4816,8 +10830,20 @@ snapshots: typescript@5.9.3: {} + ua-parser-js@1.0.41: {} + + ufo@1.6.3: {} + uint8array-extras@1.5.0: {} + uint8arrays@3.1.0: + dependencies: + multiformats: 9.9.0 + + uint8arrays@3.1.1: + dependencies: + multiformats: 9.9.0 + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -4825,8 +10851,12 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + uncrypto@0.1.3: {} + undici-types@6.21.0: {} + undici-types@7.21.0: {} + undici@7.21.0: {} unrs-resolver@1.11.1: @@ -4853,6 +10883,19 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + unstorage@1.17.4(idb-keyval@6.2.2): + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.5 + lru-cache: 11.2.5 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.3 + optionalDependencies: + idb-keyval: 6.2.2 + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -4863,8 +10906,190 @@ snapshots: dependencies: punycode: 2.3.1 - util-deprecate@1.0.2: - optional: true + use-sync-external-store@1.2.0(react@19.2.3): + dependencies: + react: 19.2.3 + + use-sync-external-store@1.4.0(react@19.2.3): + dependencies: + react: 19.2.3 + + use-sync-external-store@1.6.0(react@19.2.3): + dependencies: + react: 19.2.3 + + utf-8-validate@5.0.10: + dependencies: + node-gyp-build: 4.8.4 + + util-deprecate@1.0.2: {} + + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.20 + + uuid@8.3.2: {} + + uuid@9.0.1: {} + + valtio@1.13.2(@types/react@19.2.10)(react@19.2.3): + dependencies: + derive-valtio: 0.1.0(valtio@1.13.2(@types/react@19.2.10)(react@19.2.3)) + proxy-compare: 2.6.0 + use-sync-external-store: 1.2.0(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.10 + react: 19.2.3 + + valtio@2.1.7(@types/react@19.2.10)(react@19.2.3): + dependencies: + proxy-compare: 3.0.1 + optionalDependencies: + '@types/react': 19.2.10 + react: 19.2.3 + + viem@2.23.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76): + dependencies: + '@noble/curves': 1.8.1 + '@noble/hashes': 1.7.1 + '@scure/bip32': 1.6.2 + '@scure/bip39': 1.5.4 + abitype: 1.0.8(typescript@5.9.3)(zod@3.25.76) + isows: 1.0.6(ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + ox: 0.6.7(typescript@5.9.3)(zod@3.25.76) + ws: 8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + viem@2.36.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6): + dependencies: + '@noble/curves': 1.9.6 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.0.8(typescript@5.9.3)(zod@4.3.6) + isows: 1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + ox: 0.9.1(typescript@5.9.3)(zod@4.3.6) + ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.22.4): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.22.4) + isows: 1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + ox: 0.11.3(typescript@5.9.3)(zod@3.22.4) + ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + isows: 1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + ox: 0.11.3(typescript@5.9.3)(zod@3.25.76) + ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@4.3.6) + isows: 1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + ox: 0.11.3(typescript@5.9.3)(zod@4.3.6) + ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + wagmi@2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.3))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6): + dependencies: + '@tanstack/react-query': 5.90.20(react@19.2.3) + '@wagmi/connectors': 6.2.0(@tanstack/react-query@5.90.20(react@19.2.3))(@types/react@19.2.10)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.20)(@types/react@19.2.10)(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(utf-8-validate@5.0.10)(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.3))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@3.25.76))(zod@3.25.76) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.20)(@types/react@19.2.10)(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + react: 19.2.3 + use-sync-external-store: 1.4.0(react@19.2.3) + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@tanstack/query-core' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - debug + - encoding + - expo-auth-session + - expo-crypto + - expo-web-browser + - fastestsmallesttextencoderdecoder + - immer + - ioredis + - react-native + - supports-color + - uploadthing + - utf-8-validate + - zod + + webextension-polyfill@0.10.0: {} + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 which-boxed-primitive@1.1.1: dependencies: @@ -4897,6 +11122,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 @@ -4913,10 +11140,86 @@ snapshots: word-wrap@1.2.5: {} - wrappy@1.0.2: - optional: true + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} - ws@8.19.0: {} + ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@5.0.10): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 5.0.10 + + ws@8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 5.0.10 + + ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 5.0.10 + + ws@8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 5.0.10 + + x402@0.7.3(@solana/sysvars@5.5.1(typescript@5.9.3))(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.3))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10): + dependencies: + '@scure/base': 1.2.6 + '@solana-program/compute-budget': 0.11.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@solana-program/token': 0.9.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)) + '@solana-program/token-2022': 0.6.1(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(@solana/sysvars@5.5.1(typescript@5.9.3)) + '@solana/kit': 5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/transaction-confirmation': 5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@solana/wallet-standard-features': 1.3.0 + '@wallet-standard/app': 1.1.0 + '@wallet-standard/base': 1.1.0 + '@wallet-standard/features': 1.1.0 + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + wagmi: 2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.3))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) + zod: 3.25.76 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@solana/sysvars' + - '@tanstack/query-core' + - '@tanstack/react-query' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - debug + - encoding + - expo-auth-session + - expo-crypto + - expo-web-browser + - fastestsmallesttextencoderdecoder + - immer + - ioredis + - react + - react-native + - supports-color + - typescript + - uploadthing + - utf-8-validate xdg-app-paths@5.1.0: dependencies: @@ -4926,18 +11229,63 @@ snapshots: dependencies: os-paths: 4.4.0 + xmlhttprequest-ssl@2.1.2: {} + + xtend@4.0.2: {} + + y18n@4.0.3: {} + yallist@3.1.1: {} yaml@2.8.2: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yocto-queue@0.1.0: {} zod-validation-error@4.0.2(zod@4.3.6): dependencies: zod: 4.3.6 + zod@3.22.4: {} + zod@3.24.4: {} zod@3.25.76: {} zod@4.3.6: {} + + zustand@5.0.0(@types/react@19.2.10)(react@19.2.3)(use-sync-external-store@1.4.0(react@19.2.3)): + optionalDependencies: + '@types/react': 19.2.10 + react: 19.2.3 + use-sync-external-store: 1.4.0(react@19.2.3) + + zustand@5.0.11(@types/react@19.2.10)(react@19.2.3)(use-sync-external-store@1.4.0(react@19.2.3)): + optionalDependencies: + '@types/react': 19.2.10 + react: 19.2.3 + use-sync-external-store: 1.4.0(react@19.2.3) + + zustand@5.0.3(@types/react@19.2.10)(react@19.2.3)(use-sync-external-store@1.4.0(react@19.2.3)): + optionalDependencies: + '@types/react': 19.2.10 + react: 19.2.3 + use-sync-external-store: 1.4.0(react@19.2.3) From dcf243412c708cc765f1a77543fff1967fdcacc3 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 9 Feb 2026 07:20:18 -0500 Subject: [PATCH 05/16] Restrict Privy login to email only Co-Authored-By: Claude Opus 4.6 --- examples/website/app/providers.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/website/app/providers.tsx b/examples/website/app/providers.tsx index b803664c..9f24a68e 100644 --- a/examples/website/app/providers.tsx +++ b/examples/website/app/providers.tsx @@ -3,7 +3,12 @@ import { PrivyProvider } from "@privy-io/react-auth"; export default function Providers({ children }: { children: React.ReactNode }) { return ( - + {children} ); From c931c1af271f6cf37bfe84435bbab2722e3242fc Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 9 Feb 2026 07:47:44 -0500 Subject: [PATCH 06/16] Move examples/website to root of project Remove all legacy just-bash interpreter code (src/, tests, configs, CI workflows, examples/bash-agent, examples/custom-command) and promote examples/website to the repository root. This repo is now solely the Recoup Bash website. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/comparison-tests.yml | 37 - .github/workflows/lint.yml | 30 - .github/workflows/typecheck.yml | 38 - .github/workflows/unit-tests.yml | 33 - .gitignore | 63 +- .npmignore | 24 - examples/website/.npmrc => .npmrc | 0 AGENTS.md | 101 - AGENTS.npm.md | 329 - CLAUDE.md | 188 - LICENSE | 201 - README.md | 540 +- .../website/app => app}/api/agent/route.ts | 0 {examples/website/app => app}/api/fs/route.ts | 0 .../app => app}/components/Terminal.tsx | 0 .../app => app}/components/TerminalData.tsx | 0 .../components/lite-terminal/LiteTerminal.ts | 0 .../components/lite-terminal/ansi-parser.ts | 0 .../components/lite-terminal/index.ts | 0 .../components/lite-terminal/input-handler.ts | 0 .../components/lite-terminal/types.ts | 0 .../components/terminal-content.ts | 35 +- .../terminal-parts/agent-command.ts | 0 .../components/terminal-parts/commands.ts | 0 .../components/terminal-parts/constants.ts | 0 .../components/terminal-parts/index.ts | 0 .../terminal-parts/input-handler.ts | 0 .../components/terminal-parts/markdown.ts | 0 .../components/terminal-parts/welcome.ts | 0 {examples/website/app => app}/favicon.ico | Bin {examples/website/app => app}/globals.css | 0 {examples/website/app => app}/layout.tsx | 0 .../app => app}/md/[[...path]]/route.ts | 0 .../website/app => app}/opengraph-image.tsx | 0 {examples/website/app => app}/page.tsx | 0 {examples/website/app => app}/providers.tsx | 0 biome.json | 65 - .../eslint.config.mjs => eslint.config.mjs | 0 examples/bash-agent/.gitignore | 3 - examples/bash-agent/README.md | 56 - examples/bash-agent/agent.ts | 105 - examples/bash-agent/main.ts | 42 - examples/bash-agent/package.json | 16 - examples/bash-agent/pnpm-lock.yaml | 600 - examples/bash-agent/shell.ts | 74 - examples/bash-agent/tsconfig.json | 13 - examples/custom-command/README.md | 80 - examples/custom-command/commands.ts | 290 - examples/custom-command/main.ts | 105 - examples/custom-command/package.json | 16 - examples/custom-command/pnpm-lock.yaml | 753 - examples/custom-command/tsconfig.json | 14 - examples/website/.gitignore | 45 - examples/website/README.md | 98 - examples/website/package.json | 36 - examples/website/pnpm-lock.yaml | 11291 ------ examples/website/tsconfig.json | 34 - knip.json | 18 - .../website/next.config.ts => next.config.ts | 0 package.json | 141 +- pnpm-lock.yaml | 12688 ++++++- ...pnpm-workspace.yaml => pnpm-workspace.yaml | 0 .../postcss.config.mjs => postcss.config.mjs | 0 {examples/website/public => public}/file.svg | 0 {examples/website/public => public}/globe.svg | 0 {examples/website/public => public}/next.svg | 0 .../website/public => public}/vercel.svg | 0 .../website/public => public}/window.svg | 0 scripts/check-banned-patterns.js | 459 - .../scripts => scripts}/fetch-agent-data.mjs | 0 .../generate-terminal-content.mjs | 0 src/Bash.commands.test.ts | 85 - src/Bash.exec-options.test.ts | 806 - src/Bash.general.test.ts | 632 - src/Bash.ts | 713 - src/agent-examples/bug-investigation.test.ts | 268 - src/agent-examples/code-review.test.ts | 129 - .../codebase-exploration.test.ts | 330 - src/agent-examples/config-analysis.test.ts | 249 - src/agent-examples/debugging-workflow.test.ts | 491 - .../dependency-analysis.test.ts | 358 - .../feature-implementation.test.ts | 617 - src/agent-examples/fixtures/access_logs.py | 29 - src/agent-examples/fixtures/analyze_csv.py | 13 - src/agent-examples/fixtures/api_stats.py | 20 - .../fixtures/codegen_dataclass.py | 24 - src/agent-examples/fixtures/codegen_sql.py | 24 - src/agent-examples/fixtures/extract_links.py | 14 - src/agent-examples/fixtures/filter_json.py | 9 - src/agent-examples/fixtures/flatten_json.py | 27 - src/agent-examples/fixtures/merge_config.py | 21 - src/agent-examples/fixtures/merge_files.py | 20 - .../fixtures/parse_changelog.py | 24 - src/agent-examples/fixtures/parse_env.py | 37 - src/agent-examples/fixtures/parse_headers.py | 24 - src/agent-examples/fixtures/parse_logs.py | 24 - src/agent-examples/fixtures/sales_stats.py | 20 - src/agent-examples/fixtures/timestamps.py | 19 - .../fixtures/transform_files.py | 23 - src/agent-examples/fixtures/validate_data.py | 57 - src/agent-examples/log-analysis.test.ts | 282 - .../multi-file-migration.test.ts | 335 - src/agent-examples/python-scripting.test.ts | 485 - .../refactoring-workflow.test.ts | 417 - src/agent-examples/security-audit.test.ts | 434 - .../text-processing-workflows.test.ts | 946 - src/ast/types.ts | 1098 - src/banned-patterns-test.ts | 43 - src/browser.bundle.test.ts | 205 - src/browser.ts | 54 - src/cli/exec.ts | 127 - src/cli/just-bash.bundle.test.ts | 117 - src/cli/just-bash.test.ts | 374 - src/cli/just-bash.ts | 359 - src/cli/shell.test.ts | 205 - src/cli/shell.ts | 248 - src/commands/alias/alias.test.ts | 91 - src/commands/alias/alias.ts | 138 - src/commands/awk/ast.ts | 293 - src/commands/awk/awk.arrays.test.ts | 315 - src/commands/awk/awk.binary.test.ts | 25 - src/commands/awk/awk.edge-cases.test.ts | 426 - src/commands/awk/awk.errors.test.ts | 338 - src/commands/awk/awk.expressions.test.ts | 512 - src/commands/awk/awk.fields.test.ts | 411 - src/commands/awk/awk.functions.test.ts | 556 - src/commands/awk/awk.getline.test.ts | 353 - src/commands/awk/awk.limits.test.ts | 129 - src/commands/awk/awk.math.test.ts | 107 - src/commands/awk/awk.modulo.test.ts | 120 - src/commands/awk/awk.nextfile.test.ts | 107 - src/commands/awk/awk.operators.test.ts | 474 - src/commands/awk/awk.output.test.ts | 175 - src/commands/awk/awk.parsing.test.ts | 436 - src/commands/awk/awk.patterns.test.ts | 252 - .../awk/awk.prototype-pollution.test.ts | 320 - src/commands/awk/awk.range.test.ts | 116 - src/commands/awk/awk.strings.test.ts | 473 - src/commands/awk/awk.ternary.test.ts | 215 - src/commands/awk/awk.test.ts | 662 - src/commands/awk/awk2.ts | 299 - src/commands/awk/builtins.ts | 914 - src/commands/awk/interpreter/context.ts | 174 - src/commands/awk/interpreter/expressions.ts | 708 - src/commands/awk/interpreter/fields.ts | 90 - src/commands/awk/interpreter/index.ts | 13 - src/commands/awk/interpreter/interpreter.ts | 192 - src/commands/awk/interpreter/statements.ts | 402 - src/commands/awk/interpreter/type-coercion.ts | 73 - src/commands/awk/interpreter/types.ts | 15 - src/commands/awk/interpreter/variables.ts | 200 - src/commands/awk/lexer.ts | 860 - src/commands/awk/parser2-print.ts | 430 - src/commands/awk/parser2.ts | 1171 - src/commands/base64/base64.binary.test.ts | 201 - src/commands/base64/base64.test.ts | 194 - src/commands/base64/base64.ts | 154 - src/commands/basename/basename.test.ts | 58 - src/commands/basename/basename.ts | 83 - src/commands/bash/bash.test.ts | 203 - src/commands/bash/bash.ts | 173 - src/commands/browser-excluded.ts | 26 - src/commands/cat/cat.binary.test.ts | 53 - src/commands/cat/cat.test.ts | 171 - src/commands/cat/cat.ts | 94 - src/commands/chmod/chmod.test.ts | 141 - src/commands/chmod/chmod.ts | 283 - src/commands/clear/clear.test.ts | 19 - src/commands/clear/clear.ts | 31 - src/commands/column/column.test.ts | 194 - src/commands/column/column.ts | 257 - src/commands/comm/comm.test.ts | 140 - src/commands/comm/comm.ts | 174 - src/commands/cp/cp.binary.test.ts | 23 - src/commands/cp/cp.test.ts | 149 - src/commands/cp/cp.ts | 147 - .../curl/curl.prototype-pollution.test.ts | 75 - src/commands/curl/curl.ts | 294 - src/commands/curl/form.ts | 96 - src/commands/curl/help.ts | 46 - src/commands/curl/parse.ts | 255 - src/commands/curl/response-formatting.ts | 50 - src/commands/curl/tests/allowlist.test.ts | 114 - src/commands/curl/tests/auth.test.ts | 113 - src/commands/curl/tests/availability.test.ts | 81 - src/commands/curl/tests/binary.test.ts | 174 - src/commands/curl/tests/cookies.test.ts | 123 - src/commands/curl/tests/errors.test.ts | 273 - src/commands/curl/tests/form.test.ts | 183 - src/commands/curl/tests/methods.test.ts | 250 - src/commands/curl/tests/options.test.ts | 277 - src/commands/curl/tests/parse.test.ts | 312 - src/commands/curl/tests/timeout.test.ts | 130 - src/commands/curl/tests/upload.test.ts | 119 - src/commands/curl/tests/verbose.test.ts | 187 - src/commands/curl/tests/writeout.test.ts | 174 - src/commands/curl/types.ts | 33 - src/commands/cut/cut.binary.test.ts | 23 - src/commands/cut/cut.test.ts | 110 - src/commands/cut/cut.ts | 177 - src/commands/date/date.test.ts | 266 - src/commands/date/date.ts | 227 - src/commands/diff/diff.binary.test.ts | 75 - src/commands/diff/diff.test.ts | 294 - src/commands/diff/diff.ts | 128 - src/commands/dirname/dirname.test.ts | 44 - src/commands/dirname/dirname.ts | 57 - src/commands/du/du.test.ts | 116 - src/commands/du/du.ts | 290 - src/commands/echo/echo.binary.test.ts | 80 - src/commands/echo/echo.test.ts | 101 - src/commands/echo/echo.ts | 232 - src/commands/env/env.test.ts | 70 - src/commands/env/env.ts | 194 - src/commands/expand/expand.test.ts | 188 - src/commands/expand/expand.ts | 253 - src/commands/expand/unexpand.test.ts | 177 - src/commands/expand/unexpand.ts | 295 - src/commands/expr/expr.ts | 256 - src/commands/file/file.test.ts | 269 - src/commands/file/file.ts | 461 - src/commands/find/find.actions.test.ts | 204 - src/commands/find/find.basic.test.ts | 311 - src/commands/find/find.depth.test.ts | 154 - src/commands/find/find.exec.test.ts | 159 - src/commands/find/find.operators.test.ts | 340 - src/commands/find/find.patterns.test.ts | 293 - src/commands/find/find.perf.test.ts | 1543 - src/commands/find/find.perm.test.ts | 83 - src/commands/find/find.predicates.test.ts | 216 - src/commands/find/find.printf.test.ts | 519 - src/commands/find/find.ts | 1171 - src/commands/find/matcher.ts | 795 - src/commands/find/parser.ts | 318 - src/commands/find/types.ts | 67 - src/commands/flag-coverage.ts | 21 - src/commands/fold/fold.test.ts | 181 - src/commands/fold/fold.ts | 282 - src/commands/fuzz-flags-types.ts | 19 - src/commands/fuzz-flags.ts | 214 - src/commands/grep/grep.advanced.test.ts | 505 - src/commands/grep/grep.basic.test.ts | 742 - src/commands/grep/grep.binary.test.ts | 50 - src/commands/grep/grep.exclude.test.ts | 173 - src/commands/grep/grep.perl.test.ts | 789 - src/commands/grep/grep.ts | 720 - src/commands/gzip/gzip.binary.test.ts | 177 - src/commands/gzip/gzip.test.ts | 402 - src/commands/gzip/gzip.ts | 838 - src/commands/head/head-tail-shared.ts | 242 - src/commands/head/head.binary.test.ts | 38 - src/commands/head/head.test.ts | 106 - src/commands/head/head.ts | 55 - src/commands/help.ts | 65 - src/commands/help/help.test.ts | 73 - src/commands/help/help.ts | 128 - src/commands/history/history.test.ts | 47 - src/commands/history/history.ts | 63 - src/commands/hostname/hostname.test.ts | 18 - src/commands/hostname/hostname.ts | 29 - .../html-to-markdown/html-to-markdown.test.ts | 291 - .../html-to-markdown/html-to-markdown.ts | 152 - src/commands/join/join.test.ts | 312 - src/commands/join/join.ts | 390 - src/commands/jq/jq.basic.test.ts | 176 - src/commands/jq/jq.construction.test.ts | 54 - src/commands/jq/jq.dot-adjacency.test.ts | 138 - src/commands/jq/jq.filters.test.ts | 152 - src/commands/jq/jq.functions.test.ts | 328 - .../jq/jq.keyword-field-access.test.ts | 223 - src/commands/jq/jq.limits.test.ts | 98 - src/commands/jq/jq.operators.test.ts | 178 - .../jq/jq.prototype-pollution.test.ts | 402 - src/commands/jq/jq.strings.test.ts | 108 - src/commands/jq/jq.test.ts | 392 - src/commands/jq/jq.ts | 383 - src/commands/ln/ln.test.ts | 161 - src/commands/ln/ln.ts | 145 - src/commands/ls/ls.human.test.ts | 89 - src/commands/ls/ls.test.ts | 252 - src/commands/ls/ls.ts | 532 - src/commands/md5sum/checksum.binary.test.ts | 205 - src/commands/md5sum/checksum.ts | 240 - src/commands/md5sum/md5sum.test.ts | 187 - src/commands/md5sum/md5sum.ts | 16 - src/commands/md5sum/sha1sum.ts | 16 - src/commands/md5sum/sha256sum.ts | 16 - src/commands/mkdir/mkdir.test.ts | 103 - src/commands/mkdir/mkdir.ts | 69 - src/commands/mv/mv.test.ts | 242 - src/commands/mv/mv.ts | 138 - src/commands/nl/nl.test.ts | 214 - src/commands/nl/nl.ts | 325 - src/commands/od/od.binary.test.ts | 83 - src/commands/od/od.test.ts | 54 - src/commands/od/od.ts | 178 - src/commands/paste/paste.test.ts | 213 - src/commands/paste/paste.ts | 157 - src/commands/printf/escapes.test.ts | 192 - src/commands/printf/escapes.ts | 234 - src/commands/printf/printf.binary.test.ts | 94 - src/commands/printf/printf.test.ts | 181 - src/commands/printf/printf.ts | 1138 - src/commands/printf/strftime.ts | 418 - src/commands/pwd/pwd.test.ts | 60 - src/commands/pwd/pwd.ts | 50 - src/commands/python3/fs-bridge-handler.ts | 398 - src/commands/python3/protocol.ts | 407 - src/commands/python3/python3.advanced.test.ts | 492 - src/commands/python3/python3.env.test.ts | 315 - src/commands/python3/python3.files.test.ts | 188 - src/commands/python3/python3.http.test.ts | 399 - src/commands/python3/python3.oop.test.ts | 467 - src/commands/python3/python3.optin.test.ts | 31 - src/commands/python3/python3.security.test.ts | 397 - src/commands/python3/python3.stdlib.test.ts | 378 - src/commands/python3/python3.test.ts | 246 - src/commands/python3/python3.ts | 363 - src/commands/python3/sync-fs-backend.ts | 222 - src/commands/python3/worker.ts | 1248 - .../query-engine/builtins/array-builtins.ts | 332 - .../query-engine/builtins/control-builtins.ts | 286 - .../query-engine/builtins/date-builtins.ts | 211 - .../query-engine/builtins/format-builtins.ts | 134 - .../query-engine/builtins/index-builtins.ts | 145 - src/commands/query-engine/builtins/index.ts | 18 - .../query-engine/builtins/math-builtins.ts | 159 - .../builtins/navigation-builtins.ts | 225 - .../query-engine/builtins/object-builtins.ts | 399 - .../query-engine/builtins/path-builtins.ts | 228 - .../query-engine/builtins/sql-builtins.ts | 128 - .../query-engine/builtins/string-builtins.ts | 318 - .../query-engine/builtins/type-builtins.ts | 105 - src/commands/query-engine/evaluator.ts | 1921 - src/commands/query-engine/index.ts | 9 - src/commands/query-engine/parser-types.ts | 299 - src/commands/query-engine/parser.ts | 1128 - src/commands/query-engine/path-operations.ts | 117 - src/commands/query-engine/safe-object.test.ts | 280 - src/commands/query-engine/safe-object.ts | 158 - src/commands/query-engine/value-operations.ts | 184 - src/commands/readlink/readlink.test.ts | 118 - src/commands/readlink/readlink.ts | 113 - src/commands/registry.test.ts | 77 - src/commands/registry.ts | 607 - src/commands/rev/rev.test.ts | 144 - src/commands/rev/rev.ts | 110 - src/commands/rg/file-types.ts | 264 - src/commands/rg/gitignore.test.ts | 128 - src/commands/rg/gitignore.ts | 444 - src/commands/rg/imported-tests/README.md | 43 - src/commands/rg/imported-tests/binary.test.ts | 305 - .../rg/imported-tests/feature.test.ts | 1047 - src/commands/rg/imported-tests/json.test.ts | 227 - src/commands/rg/imported-tests/misc.test.ts | 1201 - .../rg/imported-tests/multiline.test.ts | 105 - .../rg/imported-tests/regression.test.ts | 1554 - src/commands/rg/rg-options.ts | 126 - src/commands/rg/rg-parser.ts | 811 - src/commands/rg/rg-search.ts | 1009 - src/commands/rg/rg.basic.test.ts | 264 - src/commands/rg/rg.edge-cases.test.ts | 441 - src/commands/rg/rg.filtering.test.ts | 227 - src/commands/rg/rg.flags.test.ts | 166 - src/commands/rg/rg.max-count.test.ts | 408 - src/commands/rg/rg.no-filename.test.ts | 405 - src/commands/rg/rg.output.test.ts | 253 - src/commands/rg/rg.patterns.test.ts | 288 - src/commands/rg/rg.ripgrep-compat.test.ts | 934 - src/commands/rg/rg.ts | 144 - src/commands/rm/rm.test.ts | 152 - src/commands/rm/rm.ts | 86 - src/commands/rmdir/rmdir.ts | 208 - src/commands/search-engine/index.ts | 21 - src/commands/search-engine/matcher.ts | 637 - src/commands/search-engine/regex.ts | 848 - src/commands/sed/executor.ts | 1116 - src/commands/sed/lexer.ts | 954 - src/commands/sed/parser.ts | 593 - src/commands/sed/sed-regex.ts | 299 - src/commands/sed/sed.advanced.test.ts | 166 - src/commands/sed/sed.binary.test.ts | 23 - src/commands/sed/sed.commands.test.ts | 284 - src/commands/sed/sed.errors.test.ts | 218 - src/commands/sed/sed.limits.test.ts | 176 - src/commands/sed/sed.regex.test.ts | 408 - src/commands/sed/sed.test.ts | 982 - src/commands/sed/sed.ts | 606 - src/commands/sed/types.ts | 334 - src/commands/seq/seq.test.ts | 170 - src/commands/seq/seq.ts | 179 - src/commands/sleep/sleep.test.ts | 179 - src/commands/sleep/sleep.ts | 89 - src/commands/sort/comparator.ts | 348 - src/commands/sort/parser.ts | 101 - src/commands/sort/sort.advanced.test.ts | 247 - src/commands/sort/sort.binary.test.ts | 23 - src/commands/sort/sort.test.ts | 259 - src/commands/sort/sort.ts | 226 - src/commands/sort/types.ts | 38 - src/commands/split/split.test.ts | 367 - src/commands/split/split.ts | 415 - src/commands/sqlite3/fixtures/datatypes.db | Bin 8192 -> 0 bytes src/commands/sqlite3/fixtures/products.db | Bin 12288 -> 0 bytes src/commands/sqlite3/fixtures/users.db | Bin 12288 -> 0 bytes src/commands/sqlite3/formatters.ts | 428 - src/commands/sqlite3/samples.sh | 245 - src/commands/sqlite3/sqlite3.errors.test.ts | 144 - src/commands/sqlite3/sqlite3.fixtures.test.ts | 215 - .../sqlite3/sqlite3.formatters.test.ts | 316 - src/commands/sqlite3/sqlite3.options.test.ts | 124 - .../sqlite3/sqlite3.output-modes.test.ts | 151 - src/commands/sqlite3/sqlite3.parsing.test.ts | 198 - src/commands/sqlite3/sqlite3.test.ts | 137 - src/commands/sqlite3/sqlite3.ts | 460 - .../sqlite3/sqlite3.write-ops.test.ts | 150 - src/commands/sqlite3/worker.ts | 217 - src/commands/stat/stat.test.ts | 115 - src/commands/stat/stat.ts | 114 - src/commands/strings/strings.binary.test.ts | 311 - src/commands/strings/strings.test.ts | 259 - src/commands/strings/strings.ts | 275 - src/commands/tac/tac.test.ts | 40 - src/commands/tac/tac.ts | 68 - src/commands/tail/tail.binary.test.ts | 38 - src/commands/tail/tail.test.ts | 148 - src/commands/tail/tail.ts | 56 - src/commands/tar/archive.ts | 495 - src/commands/tar/tar-options.ts | 355 - src/commands/tar/tar.binary.test.ts | 186 - src/commands/tar/tar.bundle.test.ts | 222 - src/commands/tar/tar.test.ts | 1355 - src/commands/tar/tar.ts | 1111 - src/commands/tee/tee.binary.test.ts | 18 - src/commands/tee/tee.test.ts | 56 - src/commands/tee/tee.ts | 67 - src/commands/test/test.test.ts | 265 - src/commands/time/time.ts | 204 - src/commands/timeout/timeout.test.ts | 153 - src/commands/timeout/timeout.ts | 187 - src/commands/touch/touch.test.ts | 69 - src/commands/touch/touch.ts | 194 - src/commands/tr/tr.binary.test.ts | 16 - src/commands/tr/tr.complement.test.ts | 77 - src/commands/tr/tr.test.ts | 106 - src/commands/tr/tr.ts | 242 - src/commands/tree/tree.test.ts | 143 - src/commands/tree/tree.ts | 327 - src/commands/true/true.test.ts | 46 - src/commands/true/true.ts | 29 - src/commands/uniq/uniq.binary.test.ts | 23 - src/commands/uniq/uniq.test.ts | 105 - src/commands/uniq/uniq.ts | 115 - src/commands/wc/wc.binary.test.ts | 28 - src/commands/wc/wc.test.ts | 131 - src/commands/wc/wc.ts | 200 - src/commands/which/which.test.ts | 86 - src/commands/which/which.ts | 85 - src/commands/whoami/whoami.ts | 29 - src/commands/xan/aggregation.ts | 247 - src/commands/xan/column-selection.ts | 128 - src/commands/xan/csv.ts | 111 - src/commands/xan/fixtures/employees.csv | 21 - src/commands/xan/fixtures/metrics.csv | 25 - src/commands/xan/fixtures/numbers.csv | 6 - src/commands/xan/fixtures/products.csv | 6 - src/commands/xan/fixtures/sales.csv | 9 - src/commands/xan/fixtures/server_logs.csv | 21 - src/commands/xan/fixtures/special_chars.csv | 12 - src/commands/xan/fixtures/transactions.csv | 21 - src/commands/xan/fixtures/users.csv | 5 - src/commands/xan/moonblade-parser.ts | 838 - src/commands/xan/moonblade-to-jq.ts | 430 - src/commands/xan/moonblade-tokenizer.ts | 395 - src/commands/xan/subcommands.ts | 55 - src/commands/xan/xan-agg.ts | 309 - src/commands/xan/xan-columns.ts | 191 - src/commands/xan/xan-core.ts | 125 - src/commands/xan/xan-data.ts | 504 - src/commands/xan/xan-filter.ts | 203 - src/commands/xan/xan-map.ts | 215 - src/commands/xan/xan-reshape.ts | 588 - src/commands/xan/xan-simple.ts | 372 - src/commands/xan/xan-view.ts | 126 - src/commands/xan/xan.agg.test.ts | 189 - src/commands/xan/xan.basic.test.ts | 283 - src/commands/xan/xan.columns.test.ts | 120 - src/commands/xan/xan.data.test.ts | 224 - src/commands/xan/xan.filter-sort.test.ts | 193 - src/commands/xan/xan.frequency.test.ts | 85 - src/commands/xan/xan.groupby.test.ts | 101 - src/commands/xan/xan.map.test.ts | 163 - src/commands/xan/xan.multifile.test.ts | 227 - .../xan/xan.prototype-pollution.test.ts | 181 - src/commands/xan/xan.reshape.test.ts | 192 - src/commands/xan/xan.select-advanced.test.ts | 139 - src/commands/xan/xan.transform.test.ts | 81 - src/commands/xan/xan.ts | 448 - src/commands/xargs/xargs.test.ts | 355 - src/commands/xargs/xargs.ts | 221 - src/commands/yq/fixtures/csv/products.csv | 6 - src/commands/yq/fixtures/csv/semicolon.csv | 4 - src/commands/yq/fixtures/csv/special.csv | 9 - src/commands/yq/fixtures/csv/tabs.tsv | 5 - src/commands/yq/fixtures/csv/users.csv | 4 - src/commands/yq/fixtures/ini/app.ini | 13 - src/commands/yq/fixtures/ini/config.ini | 16 - src/commands/yq/fixtures/ini/special.ini | 44 - src/commands/yq/fixtures/json/nested.json | 22 - src/commands/yq/fixtures/json/special.json | 37 - src/commands/yq/fixtures/json/users.json | 21 - src/commands/yq/fixtures/toml/cargo.toml | 17 - src/commands/yq/fixtures/toml/config.toml | 23 - src/commands/yq/fixtures/toml/pyproject.toml | 30 - src/commands/yq/fixtures/toml/special.toml | 50 - src/commands/yq/fixtures/xml/books.xml | 20 - src/commands/yq/fixtures/xml/special.xml | 30 - src/commands/yq/fixtures/xml/users.xml | 23 - src/commands/yq/fixtures/yaml/simple.yaml | 7 - src/commands/yq/fixtures/yaml/special.yaml | 38 - src/commands/yq/fixtures/yaml/users.yaml | 16 - src/commands/yq/formats.ts | 319 - src/commands/yq/yq.env.test.ts | 90 - src/commands/yq/yq.fixtures.test.ts | 768 - src/commands/yq/yq.format-strings.test.ts | 211 - src/commands/yq/yq.navigation.test.ts | 195 - .../yq/yq.prototype-pollution.test.ts | 155 - src/commands/yq/yq.test.ts | 944 - src/commands/yq/yq.ts | 397 - src/comparison-tests/README.md | 186 - src/comparison-tests/alias.comparison.test.ts | 49 - src/comparison-tests/awk.comparison.test.ts | 219 - .../basename-dirname.comparison.test.ts | 128 - src/comparison-tests/cat.comparison.test.ts | 84 - src/comparison-tests/cd.comparison.test.ts | 151 - .../column-join.comparison.test.ts | 139 - src/comparison-tests/cut.comparison.test.ts | 142 - src/comparison-tests/echo.comparison.test.ts | 69 - src/comparison-tests/env.comparison.test.ts | 88 - .../export.comparison.test.ts | 86 - .../file-operations.comparison.test.ts | 309 - src/comparison-tests/find.comparison.test.ts | 241 - src/comparison-tests/fixture-runner.ts | 500 - .../fixtures/alias.comparison.fixtures.json | 26 - .../fixtures/awk.comparison.fixtures.json | 196 - .../basename-dirname.comparison.fixtures.json | 107 - .../fixtures/cat.comparison.fixtures.json | 84 - .../column-join.comparison.fixtures.json | 127 - .../fixtures/cut.comparison.fixtures.json | 151 - .../fixtures/echo.comparison.fixtures.json | 72 - .../fixtures/export.comparison.fixtures.json | 65 - .../fixtures/find.comparison.fixtures.json | 236 - .../fixtures/glob.comparison.fixtures.json | 111 - .../fixtures/grep.comparison.fixtures.json | 300 - .../head-tail.comparison.fixtures.json | 198 - .../here-document.comparison.fixtures.json | 37 - .../fixtures/jq.comparison.fixtures.json | 250 - .../fixtures/ls.comparison.fixtures.json | 128 - .../fixtures/paste.comparison.fixtures.json | 170 - ...ipes-redirections.comparison.fixtures.json | 198 - .../fixtures/sed.comparison.fixtures.json | 196 - .../fixtures/sort.comparison.fixtures.json | 207 - .../strings-split.comparison.fixtures.json | 87 - .../fixtures/tar.comparison.fixtures.json | 85 - .../fixtures/test.comparison.fixtures.json | 307 - .../text-processing.comparison.fixtures.json | 213 - .../fixtures/tr.comparison.fixtures.json | 142 - .../fixtures/uniq.comparison.fixtures.json | 147 - .../fixtures/wc.comparison.fixtures.json | 130 - src/comparison-tests/glob.comparison.test.ts | 108 - src/comparison-tests/grep.comparison.test.ts | 281 - .../head-tail.comparison.test.ts | 219 - .../here-document.comparison.test.ts | 79 - src/comparison-tests/jq.comparison.test.ts | 247 - src/comparison-tests/ls.comparison.test.ts | 130 - .../parse-errors.comparison.test.ts | 286 - src/comparison-tests/paste.comparison.test.ts | 173 - .../pipes-redirections.comparison.test.ts | 428 - src/comparison-tests/sed.comparison.test.ts | 195 - src/comparison-tests/sort.comparison.test.ts | 213 - .../strings-split.comparison.test.ts | 107 - src/comparison-tests/tar.comparison.test.ts | 128 - src/comparison-tests/tee.comparison.test.ts | 125 - src/comparison-tests/test.comparison.test.ts | 384 - .../text-processing.comparison.test.ts | 234 - src/comparison-tests/tr.comparison.test.ts | 143 - src/comparison-tests/uniq.comparison.test.ts | 155 - src/comparison-tests/vitest.setup.ts | 9 - src/comparison-tests/wc.comparison.test.ts | 141 - src/custom-commands.test.ts | 293 - src/custom-commands.ts | 66 - src/fs/encoding.ts | 104 - src/fs/in-memory-fs/in-memory-fs.test.ts | 312 - src/fs/in-memory-fs/in-memory-fs.ts | 714 - src/fs/in-memory-fs/index.ts | 1 - src/fs/init.ts | 96 - src/fs/interface.ts | 274 - src/fs/mountable-fs/index.ts | 5 - src/fs/mountable-fs/mountable-fs.test.ts | 665 - src/fs/mountable-fs/mountable-fs.ts | 683 - src/fs/overlay-fs/index.ts | 1 - src/fs/overlay-fs/overlay-fs.e2e.test.ts | 613 - src/fs/overlay-fs/overlay-fs.security.test.ts | 841 - src/fs/overlay-fs/overlay-fs.test.ts | 727 - src/fs/overlay-fs/overlay-fs.ts | 1224 - src/fs/read-write-fs/index.ts | 1 - .../read-write-fs.piping.test.ts | 164 - .../read-write-fs.security.test.ts | 488 - src/fs/read-write-fs/read-write-fs.test.ts | 600 - src/fs/read-write-fs/read-write-fs.ts | 517 - src/helpers/env.ts | 50 - src/index.ts | 77 - src/interpreter/alias-expansion.ts | 280 - src/interpreter/arithmetic.test.ts | 494 - src/interpreter/arithmetic.ts | 991 - src/interpreter/assignment-expansion.ts | 252 - src/interpreter/assoc-array.test.ts | 268 - src/interpreter/builtin-dispatch.ts | 441 - src/interpreter/builtins/break.test.ts | 196 - src/interpreter/builtins/break.ts | 44 - src/interpreter/builtins/cd.test.ts | 97 - src/interpreter/builtins/cd.ts | 124 - src/interpreter/builtins/compgen.ts | 1034 - src/interpreter/builtins/complete.test.ts | 168 - src/interpreter/builtins/complete.ts | 301 - src/interpreter/builtins/compopt.test.ts | 225 - src/interpreter/builtins/compopt.ts | 172 - src/interpreter/builtins/continue.test.ts | 220 - src/interpreter/builtins/continue.ts | 43 - .../builtins/declare-array-parsing.ts | 150 - src/interpreter/builtins/declare-print.ts | 436 - src/interpreter/builtins/declare.ts | 1098 - src/interpreter/builtins/dirs.ts | 259 - src/interpreter/builtins/eval.test.ts | 161 - src/interpreter/builtins/eval.ts | 83 - src/interpreter/builtins/exit.test.ts | 128 - src/interpreter/builtins/exit.ts | 29 - src/interpreter/builtins/export.test.ts | 124 - src/interpreter/builtins/export.ts | 128 - src/interpreter/builtins/getopts.ts | 201 - src/interpreter/builtins/hash.ts | 223 - src/interpreter/builtins/help.ts | 903 - src/interpreter/builtins/index.ts | 51 - src/interpreter/builtins/let.ts | 102 - src/interpreter/builtins/local.test.ts | 173 - src/interpreter/builtins/local.ts | 432 - src/interpreter/builtins/mapfile.ts | 171 - src/interpreter/builtins/posix-fatal.test.ts | 82 - src/interpreter/builtins/read.test.ts | 126 - src/interpreter/builtins/read.ts | 522 - src/interpreter/builtins/return.test.ts | 189 - src/interpreter/builtins/return.ts | 34 - src/interpreter/builtins/set.test.ts | 524 - src/interpreter/builtins/set.ts | 485 - src/interpreter/builtins/shift.test.ts | 199 - src/interpreter/builtins/shift.ts | 81 - src/interpreter/builtins/shopt.ts | 410 - src/interpreter/builtins/source.test.ts | 155 - src/interpreter/builtins/source.ts | 142 - src/interpreter/builtins/unset.test.ts | 180 - src/interpreter/builtins/unset.ts | 566 - .../builtins/variable-assignment.ts | 231 - src/interpreter/command-resolution.ts | 224 - src/interpreter/conditionals.ts | 1133 - src/interpreter/control-flow.test.ts | 531 - src/interpreter/control-flow.ts | 533 - src/interpreter/errors.ts | 265 - src/interpreter/expansion.ts | 1069 - src/interpreter/expansion/analysis.ts | 205 - .../expansion/arith-text-expansion.ts | 234 - .../expansion/array-pattern-ops.ts | 257 - .../expansion/array-prefix-suffix.ts | 479 - .../expansion/array-slice-transform.ts | 262 - .../expansion/array-word-expansion.ts | 158 - src/interpreter/expansion/brace-range.ts | 187 - .../expansion/command-substitution.ts | 66 - src/interpreter/expansion/glob-escape.ts | 64 - .../expansion/indirect-expansion.ts | 566 - src/interpreter/expansion/parameter-ops.ts | 787 - .../expansion/pattern-expansion.ts | 542 - src/interpreter/expansion/pattern-removal.ts | 108 - src/interpreter/expansion/pattern.ts | 357 - .../expansion/positional-params.ts | 565 - src/interpreter/expansion/prompt.test.ts | 306 - src/interpreter/expansion/prompt.ts | 380 - src/interpreter/expansion/quoting.ts | 58 - src/interpreter/expansion/tilde.ts | 55 - .../expansion/unquoted-expansion.ts | 1075 - src/interpreter/expansion/variable-attrs.ts | 74 - src/interpreter/expansion/variable.ts | 607 - .../expansion/word-glob-expansion.ts | 930 - src/interpreter/expansion/word-split.ts | 561 - src/interpreter/functions.ts | 241 - src/interpreter/helpers/array.ts | 344 - src/interpreter/helpers/condition.ts | 48 - src/interpreter/helpers/errors.ts | 11 - src/interpreter/helpers/file-tests.ts | 289 - src/interpreter/helpers/ifs.ts | 470 - src/interpreter/helpers/loop.ts | 91 - src/interpreter/helpers/nameref.ts | 248 - src/interpreter/helpers/numeric-compare.ts | 39 - src/interpreter/helpers/quoting.ts | 117 - src/interpreter/helpers/readonly.ts | 97 - src/interpreter/helpers/regex.ts | 11 - src/interpreter/helpers/result.ts | 85 - src/interpreter/helpers/shell-constants.ts | 128 - src/interpreter/helpers/shellopts.ts | 105 - src/interpreter/helpers/statements.ts | 66 - src/interpreter/helpers/string-compare.ts | 49 - src/interpreter/helpers/string-tests.ts | 27 - src/interpreter/helpers/tilde.ts | 41 - src/interpreter/helpers/variable-tests.ts | 100 - src/interpreter/helpers/word-matching.ts | 71 - src/interpreter/helpers/word-parts.ts | 49 - src/interpreter/helpers/xtrace.test.ts | 297 - src/interpreter/helpers/xtrace.ts | 163 - src/interpreter/index.ts | 7 - src/interpreter/interpreter.ts | 1228 - src/interpreter/pipeline-execution.ts | 211 - src/interpreter/prototype-pollution.test.ts | 963 - src/interpreter/redirections.binary.test.ts | 176 - src/interpreter/redirections.ts | 903 - src/interpreter/simple-command-assignments.ts | 864 - src/interpreter/subshell-group.ts | 419 - src/interpreter/type-command.ts | 579 - src/interpreter/types.ts | 415 - src/limits.ts | 109 - src/network/allow-list.ts | 148 - src/network/allow-list/bypass.test.ts | 549 - src/network/allow-list/e2e.test.ts | 464 - src/network/allow-list/mock.test.ts | 106 - src/network/allow-list/shared.ts | 188 - src/network/allow-list/unit.test.ts | 846 - src/network/fetch.ts | 233 - src/network/index.ts | 20 - src/network/types.ts | 135 - src/parser/arithmetic-parser.ts | 1150 - src/parser/arithmetic-primaries.ts | 229 - src/parser/command-parser.ts | 349 - src/parser/compound-parser.ts | 426 - src/parser/conditional-parser.ts | 466 - src/parser/expansion-parser.ts | 1066 - src/parser/lexer.ts | 2618 -- src/parser/parser-substitution.ts | 316 - src/parser/parser.ts | 1189 - src/parser/types.ts | 77 - src/parser/word-parser.ts | 796 - src/readme.test.ts | 525 - src/regex/index.ts | 24 - src/regex/user-regex.test.ts | 482 - src/regex/user-regex.ts | 566 - src/sandbox/Command.ts | 92 - src/sandbox/Sandbox.test.ts | 403 - src/sandbox/Sandbox.ts | 137 - src/sandbox/index.ts | 10 - src/security-limits.test.ts | 168 - src/security/attacks/filename-attacks.test.ts | 339 - .../attacks/fuzz-discovered-attacks.test.ts | 525 - .../attacks/injection-attacks.test.ts | 429 - .../attacks/numeric-edge-cases.test.ts | 625 - src/security/blocked-globals.ts | 281 - .../defense-in-depth-box-concurrent.test.ts | 302 - src/security/defense-in-depth-box.test.ts | 1743 - src/security/defense-in-depth-box.ts | 1021 - .../fuzzing/__tests__/fuzz-coverage.test.ts | 188 - .../fuzzing/__tests__/fuzz-dos.test.ts | 499 - .../fuzzing/__tests__/fuzz-malformed.test.ts | 213 - .../fuzzing/__tests__/fuzz-sandbox.test.ts | 406 - src/security/fuzzing/config.ts | 242 - src/security/fuzzing/corpus/known-attacks.ts | 343 - .../fuzzing/coverage/coverage-tracker.ts | 127 - .../fuzzing/coverage/feature-coverage.ts | 60 - src/security/fuzzing/coverage/index.ts | 22 - .../fuzzing/coverage/known-features.ts | 226 - .../generators/coverage-boost-generator.ts | 329 - .../generators/flag-driven-generator.ts | 139 - .../generators/grammar-generator.test.ts | 626 - .../fuzzing/generators/grammar-generator.ts | 2163 -- src/security/fuzzing/generators/index.ts | 10 - .../fuzzing/generators/malformed-generator.ts | 257 - src/security/fuzzing/index.ts | 57 - src/security/fuzzing/oracles/assertions.ts | 167 - src/security/fuzzing/oracles/dos-oracle.ts | 199 - .../fuzzing/oracles/sandbox-oracle.ts | 171 - src/security/fuzzing/runners/fuzz-runner.ts | 279 - src/security/index.ts | 55 - src/security/limits/dos-limits.test.ts | 633 - src/security/limits/memory-exhaustion.test.ts | 328 - .../limits/output-size-limits.test.ts | 88 - src/security/limits/pipeline-limits.test.ts | 270 - .../prototype-pollution-awk.test.ts | 332 - .../prototype-pollution-bash-extended.test.ts | 462 - .../prototype-pollution-comprehensive.test.ts | 448 - .../prototype-pollution-edge-cases.test.ts | 583 - .../prototype-pollution-sed.test.ts | 128 - ...rototype-pollution-syntax-features.test.ts | 562 - src/security/sandbox/command-security.test.ts | 524 - .../sandbox/dynamic-execution.test.ts | 461 - .../sandbox/information-disclosure.test.ts | 434 - src/security/sandbox/sandbox-escape.test.ts | 320 - .../security-violation-logger.test.ts | 235 - src/security/security-violation-logger.ts | 203 - src/security/types.ts | 160 - src/security/worker-defense-in-depth.test.ts | 1000 - src/security/worker-defense-in-depth.ts | 915 - src/shared/operators.ts | 33 - src/shell-metadata.ts | 49 - src/shell/glob-to-regex.ts | 342 - src/shell/glob.ts | 1112 - src/spec-tests/awk/awk-spec.test.ts | 85 - src/spec-tests/awk/cases/Compare.T1 | 10 - src/spec-tests/awk/cases/Compare.drek | 35 - src/spec-tests/awk/cases/Compare.p | 17 - src/spec-tests/awk/cases/Compare.t | 17 - src/spec-tests/awk/cases/Compare.tt | 49 - src/spec-tests/awk/cases/LICENSE | 23 - src/spec-tests/awk/cases/NOTES | 10 - src/spec-tests/awk/cases/README.TESTS | 44 - src/spec-tests/awk/cases/REGRESS | 21 - src/spec-tests/awk/cases/T.-f-f | 35 - src/spec-tests/awk/cases/T.argv | 173 - src/spec-tests/awk/cases/T.arnold | 19 - src/spec-tests/awk/cases/T.beebe | 8 - src/spec-tests/awk/cases/T.builtin | 90 - src/spec-tests/awk/cases/T.chem | 11 - src/spec-tests/awk/cases/T.close | 36 - src/spec-tests/awk/cases/T.clv | 181 - src/spec-tests/awk/cases/T.csconcat | 29 - src/spec-tests/awk/cases/T.csv | 80 - src/spec-tests/awk/cases/T.delete | 21 - src/spec-tests/awk/cases/T.errmsg | 212 - src/spec-tests/awk/cases/T.expr | 235 - src/spec-tests/awk/cases/T.exprconv | 21 - src/spec-tests/awk/cases/T.flags | 25 - src/spec-tests/awk/cases/T.func | 196 - src/spec-tests/awk/cases/T.gawk | 390 - src/spec-tests/awk/cases/T.getline | 98 - src/spec-tests/awk/cases/T.int-expr | 124 - src/spec-tests/awk/cases/T.latin1 | 37 - src/spec-tests/awk/cases/T.lilly | 28 - src/spec-tests/awk/cases/T.main | 32 - src/spec-tests/awk/cases/T.misc | 532 - src/spec-tests/awk/cases/T.nextfile | 86 - src/spec-tests/awk/cases/T.overflow | 88 - src/spec-tests/awk/cases/T.re | 340 - src/spec-tests/awk/cases/T.recache | 33 - src/spec-tests/awk/cases/T.redir | 38 - src/spec-tests/awk/cases/T.split | 225 - src/spec-tests/awk/cases/T.sub | 315 - src/spec-tests/awk/cases/T.system | 15 - src/spec-tests/awk/cases/T.utf | 194 - src/spec-tests/awk/cases/T.utfre | 234 - src/spec-tests/awk/cases/arnold-fixes.tar | Bin 30720 -> 0 bytes src/spec-tests/awk/cases/beebe.tar | Bin 389120 -> 0 bytes src/spec-tests/awk/cases/bib | 31102 ---------------- src/spec-tests/awk/cases/bundle.awk | 3 - src/spec-tests/awk/cases/chem.awk | 492 - src/spec-tests/awk/cases/cleanup | 5 - src/spec-tests/awk/cases/countries | 11 - src/spec-tests/awk/cases/ctimes | 40 - src/spec-tests/awk/cases/echo.c | 19 - src/spec-tests/awk/cases/funstack.awk | 977 - src/spec-tests/awk/cases/funstack.in | 27220 -------------- src/spec-tests/awk/cases/funstack.ok | 3705 -- src/spec-tests/awk/cases/ind | 1 - src/spec-tests/awk/cases/latin1 | 11 - src/spec-tests/awk/cases/lilly.ifile | 16 - src/spec-tests/awk/cases/lilly.out | 1258 - src/spec-tests/awk/cases/lilly.progs | 126 - src/spec-tests/awk/cases/lsd1.p | 15 - src/spec-tests/awk/cases/p.1 | 1 - src/spec-tests/awk/cases/p.10 | 1 - src/spec-tests/awk/cases/p.11 | 1 - src/spec-tests/awk/cases/p.12 | 1 - src/spec-tests/awk/cases/p.13 | 1 - src/spec-tests/awk/cases/p.14 | 1 - src/spec-tests/awk/cases/p.15 | 1 - src/spec-tests/awk/cases/p.16 | 1 - src/spec-tests/awk/cases/p.17 | 1 - src/spec-tests/awk/cases/p.18 | 1 - src/spec-tests/awk/cases/p.19 | 2 - src/spec-tests/awk/cases/p.2 | 1 - src/spec-tests/awk/cases/p.20 | 1 - src/spec-tests/awk/cases/p.21 | 1 - src/spec-tests/awk/cases/p.21a | 1 - src/spec-tests/awk/cases/p.22 | 1 - src/spec-tests/awk/cases/p.23 | 1 - src/spec-tests/awk/cases/p.24 | 1 - src/spec-tests/awk/cases/p.25 | 1 - src/spec-tests/awk/cases/p.26 | 3 - src/spec-tests/awk/cases/p.26a | 3 - src/spec-tests/awk/cases/p.27 | 2 - src/spec-tests/awk/cases/p.28 | 1 - src/spec-tests/awk/cases/p.29 | 1 - src/spec-tests/awk/cases/p.3 | 1 - src/spec-tests/awk/cases/p.30 | 1 - src/spec-tests/awk/cases/p.31 | 2 - src/spec-tests/awk/cases/p.32 | 1 - src/spec-tests/awk/cases/p.33 | 2 - src/spec-tests/awk/cases/p.34 | 1 - src/spec-tests/awk/cases/p.35 | 4 - src/spec-tests/awk/cases/p.36 | 2 - src/spec-tests/awk/cases/p.37 | 1 - src/spec-tests/awk/cases/p.38 | 6 - src/spec-tests/awk/cases/p.39 | 6 - src/spec-tests/awk/cases/p.4 | 1 - src/spec-tests/awk/cases/p.40 | 3 - src/spec-tests/awk/cases/p.41 | 3 - src/spec-tests/awk/cases/p.42 | 4 - src/spec-tests/awk/cases/p.43 | 4 - src/spec-tests/awk/cases/p.44 | 7 - src/spec-tests/awk/cases/p.45 | 2 - src/spec-tests/awk/cases/p.46 | 1 - src/spec-tests/awk/cases/p.47 | 2 - src/spec-tests/awk/cases/p.48 | 4 - src/spec-tests/awk/cases/p.48a | 6 - src/spec-tests/awk/cases/p.48b | 5 - src/spec-tests/awk/cases/p.49 | 1 - src/spec-tests/awk/cases/p.5 | 3 - src/spec-tests/awk/cases/p.50 | 4 - src/spec-tests/awk/cases/p.51 | 7 - src/spec-tests/awk/cases/p.52 | 16 - src/spec-tests/awk/cases/p.5a | 3 - src/spec-tests/awk/cases/p.6 | 1 - src/spec-tests/awk/cases/p.7 | 1 - src/spec-tests/awk/cases/p.8 | 1 - src/spec-tests/awk/cases/p.9 | 1 - src/spec-tests/awk/cases/p.table | 33 - src/spec-tests/awk/cases/penicil.p | 39 - src/spec-tests/awk/cases/res.p | 26 - src/spec-tests/awk/cases/sgi.ctimes | 40 - src/spec-tests/awk/cases/t.0 | 1 - src/spec-tests/awk/cases/t.0a | 1 - src/spec-tests/awk/cases/t.1 | 2 - src/spec-tests/awk/cases/t.1.x | 1 - src/spec-tests/awk/cases/t.2 | 2 - src/spec-tests/awk/cases/t.2.x | 1 - src/spec-tests/awk/cases/t.3 | 1 - src/spec-tests/awk/cases/t.3.x | 7 - src/spec-tests/awk/cases/t.4 | 1 - src/spec-tests/awk/cases/t.4.x | 1 - src/spec-tests/awk/cases/t.5.x | 1 - src/spec-tests/awk/cases/t.6 | 8 - src/spec-tests/awk/cases/t.6.x | 1 - src/spec-tests/awk/cases/t.6a | 5 - src/spec-tests/awk/cases/t.6b | 5 - src/spec-tests/awk/cases/t.8.x | 4 - src/spec-tests/awk/cases/t.8.y | 7 - src/spec-tests/awk/cases/t.NF | 1 - src/spec-tests/awk/cases/t.a | 6 - src/spec-tests/awk/cases/t.addops | 24 - src/spec-tests/awk/cases/t.aeiou | 1 - src/spec-tests/awk/cases/t.aeiouy | 1 - src/spec-tests/awk/cases/t.arith | 6 - src/spec-tests/awk/cases/t.array | 13 - src/spec-tests/awk/cases/t.array1 | 10 - src/spec-tests/awk/cases/t.array2 | 4 - src/spec-tests/awk/cases/t.assert | 9 - src/spec-tests/awk/cases/t.avg | 5 - src/spec-tests/awk/cases/t.b.x | 1 - src/spec-tests/awk/cases/t.be | 6 - src/spec-tests/awk/cases/t.beginexit | 6 - src/spec-tests/awk/cases/t.beginnext | 6 - src/spec-tests/awk/cases/t.break | 7 - src/spec-tests/awk/cases/t.break1 | 10 - src/spec-tests/awk/cases/t.break2 | 10 - src/spec-tests/awk/cases/t.break3 | 8 - src/spec-tests/awk/cases/t.bug1 | 3 - src/spec-tests/awk/cases/t.builtins | 6 - src/spec-tests/awk/cases/t.cat | 4 - src/spec-tests/awk/cases/t.cat1 | 1 - src/spec-tests/awk/cases/t.cat2 | 1 - src/spec-tests/awk/cases/t.cmp | 1 - src/spec-tests/awk/cases/t.coerce | 4 - src/spec-tests/awk/cases/t.coerce2 | 7 - src/spec-tests/awk/cases/t.comment | 5 - src/spec-tests/awk/cases/t.comment1 | 7 - src/spec-tests/awk/cases/t.concat | 1 - src/spec-tests/awk/cases/t.cond | 3 - src/spec-tests/awk/cases/t.contin | 9 - src/spec-tests/awk/cases/t.count | 1 - src/spec-tests/awk/cases/t.crlf | 4 - src/spec-tests/awk/cases/t.cum | 4 - src/spec-tests/awk/cases/t.d.x | 2 - src/spec-tests/awk/cases/t.delete0 | 11 - src/spec-tests/awk/cases/t.delete1 | 7 - src/spec-tests/awk/cases/t.delete2 | 12 - src/spec-tests/awk/cases/t.delete3 | 7 - src/spec-tests/awk/cases/t.do | 14 - src/spec-tests/awk/cases/t.e | 1 - src/spec-tests/awk/cases/t.else | 3 - src/spec-tests/awk/cases/t.exit | 2 - src/spec-tests/awk/cases/t.exit1 | 15 - src/spec-tests/awk/cases/t.f | 1 - src/spec-tests/awk/cases/t.f.x | 1 - src/spec-tests/awk/cases/t.f0 | 1 - src/spec-tests/awk/cases/t.f1 | 1 - src/spec-tests/awk/cases/t.f2 | 1 - src/spec-tests/awk/cases/t.f3 | 1 - src/spec-tests/awk/cases/t.f4 | 1 - src/spec-tests/awk/cases/t.for | 3 - src/spec-tests/awk/cases/t.for1 | 9 - src/spec-tests/awk/cases/t.for2 | 7 - src/spec-tests/awk/cases/t.for3 | 8 - src/spec-tests/awk/cases/t.format4 | 9 - src/spec-tests/awk/cases/t.fun | 3 - src/spec-tests/awk/cases/t.fun0 | 2 - src/spec-tests/awk/cases/t.fun1 | 2 - src/spec-tests/awk/cases/t.fun2 | 10 - src/spec-tests/awk/cases/t.fun3 | 3 - src/spec-tests/awk/cases/t.fun4 | 9 - src/spec-tests/awk/cases/t.fun5 | 9 - src/spec-tests/awk/cases/t.getline1 | 10 - src/spec-tests/awk/cases/t.getval | 6 - src/spec-tests/awk/cases/t.gsub | 1 - src/spec-tests/awk/cases/t.gsub1 | 1 - src/spec-tests/awk/cases/t.gsub3 | 1 - src/spec-tests/awk/cases/t.gsub4 | 4 - src/spec-tests/awk/cases/t.i.x | 2 - src/spec-tests/awk/cases/t.if | 1 - src/spec-tests/awk/cases/t.in | 9 - src/spec-tests/awk/cases/t.in1 | 7 - src/spec-tests/awk/cases/t.in2 | 4 - src/spec-tests/awk/cases/t.in3 | 7 - src/spec-tests/awk/cases/t.incr | 2 - src/spec-tests/awk/cases/t.incr2 | 8 - src/spec-tests/awk/cases/t.incr3 | 5 - src/spec-tests/awk/cases/t.index | 10 - src/spec-tests/awk/cases/t.intest | 9 - src/spec-tests/awk/cases/t.intest2 | 16 - src/spec-tests/awk/cases/t.j.x | 2 - src/spec-tests/awk/cases/t.longstr | 5 - src/spec-tests/awk/cases/t.makef | 1 - src/spec-tests/awk/cases/t.match | 1 - src/spec-tests/awk/cases/t.match1 | 6 - src/spec-tests/awk/cases/t.max | 2 - src/spec-tests/awk/cases/t.mod | 1 - src/spec-tests/awk/cases/t.monotone | 1 - src/spec-tests/awk/cases/t.nameval | 7 - src/spec-tests/awk/cases/t.next | 2 - src/spec-tests/awk/cases/t.not | 4 - src/spec-tests/awk/cases/t.null0 | 15 - src/spec-tests/awk/cases/t.ofmt | 2 - src/spec-tests/awk/cases/t.ofs | 2 - src/spec-tests/awk/cases/t.ors | 2 - src/spec-tests/awk/cases/t.pat | 4 - src/spec-tests/awk/cases/t.pipe | 1 - src/spec-tests/awk/cases/t.pp | 1 - src/spec-tests/awk/cases/t.pp1 | 3 - src/spec-tests/awk/cases/t.pp2 | 3 - src/spec-tests/awk/cases/t.printf | 5 - src/spec-tests/awk/cases/t.printf2 | 6 - src/spec-tests/awk/cases/t.quote | 1 - src/spec-tests/awk/cases/t.randk | 13 - src/spec-tests/awk/cases/t.re1 | 2 - src/spec-tests/awk/cases/t.re1a | 6 - src/spec-tests/awk/cases/t.re2 | 2 - src/spec-tests/awk/cases/t.re3 | 6 - src/spec-tests/awk/cases/t.re4 | 10 - src/spec-tests/awk/cases/t.re5 | 3 - src/spec-tests/awk/cases/t.re7 | 1 - src/spec-tests/awk/cases/t.reFS | 2 - src/spec-tests/awk/cases/t.rec | 1 - src/spec-tests/awk/cases/t.redir1 | 2 - src/spec-tests/awk/cases/t.reg | 4 - src/spec-tests/awk/cases/t.roff | 23 - src/spec-tests/awk/cases/t.sep | 2 - src/spec-tests/awk/cases/t.seqno | 1 - src/spec-tests/awk/cases/t.set0 | 3 - src/spec-tests/awk/cases/t.set0a | 1 - src/spec-tests/awk/cases/t.set0b | 3 - src/spec-tests/awk/cases/t.set1 | 3 - src/spec-tests/awk/cases/t.set2 | 4 - src/spec-tests/awk/cases/t.set3 | 1 - src/spec-tests/awk/cases/t.split1 | 2 - src/spec-tests/awk/cases/t.split2 | 1 - src/spec-tests/awk/cases/t.split2a | 4 - src/spec-tests/awk/cases/t.split3 | 4 - src/spec-tests/awk/cases/t.split4 | 4 - src/spec-tests/awk/cases/t.split8 | 9 - src/spec-tests/awk/cases/t.split9 | 8 - src/spec-tests/awk/cases/t.split9a | 9 - src/spec-tests/awk/cases/t.stately | 1 - src/spec-tests/awk/cases/t.strcmp | 1 - src/spec-tests/awk/cases/t.strcmp1 | 1 - src/spec-tests/awk/cases/t.strnum | 1 - src/spec-tests/awk/cases/t.sub0 | 18 - src/spec-tests/awk/cases/t.sub1 | 1 - src/spec-tests/awk/cases/t.sub2 | 2 - src/spec-tests/awk/cases/t.sub3 | 1 - src/spec-tests/awk/cases/t.substr | 3 - src/spec-tests/awk/cases/t.substr1 | 1 - src/spec-tests/awk/cases/t.time | 18 - src/spec-tests/awk/cases/t.vf | 3 - src/spec-tests/awk/cases/t.vf1 | 7 - src/spec-tests/awk/cases/t.vf2 | 1 - src/spec-tests/awk/cases/t.vf3 | 2 - src/spec-tests/awk/cases/t.x | 1 - src/spec-tests/awk/cases/td.1 | 1397 - src/spec-tests/awk/cases/test.countries | 10 - src/spec-tests/awk/cases/test.data | 199 - src/spec-tests/awk/cases/time.c | 31 - src/spec-tests/awk/cases/try | 10 - src/spec-tests/awk/cases/tt.01 | 1 - src/spec-tests/awk/cases/tt.02 | 1 - src/spec-tests/awk/cases/tt.02a | 1 - src/spec-tests/awk/cases/tt.03 | 2 - src/spec-tests/awk/cases/tt.03a | 2 - src/spec-tests/awk/cases/tt.04 | 3 - src/spec-tests/awk/cases/tt.05 | 6 - src/spec-tests/awk/cases/tt.06 | 7 - src/spec-tests/awk/cases/tt.07 | 1 - src/spec-tests/awk/cases/tt.08 | 1 - src/spec-tests/awk/cases/tt.09 | 1 - src/spec-tests/awk/cases/tt.10 | 1 - src/spec-tests/awk/cases/tt.10a | 2 - src/spec-tests/awk/cases/tt.11 | 1 - src/spec-tests/awk/cases/tt.12 | 1 - src/spec-tests/awk/cases/tt.13 | 5 - src/spec-tests/awk/cases/tt.13a | 5 - src/spec-tests/awk/cases/tt.14 | 7 - src/spec-tests/awk/cases/tt.15 | 33 - src/spec-tests/awk/cases/tt.16 | 6 - src/spec-tests/awk/cases/tt.big | 51 - src/spec-tests/awk/cases/u.main | 9 - src/spec-tests/awk/cases/unbundle.awk | 4 - src/spec-tests/awk/cases/yc | 17 - src/spec-tests/awk/parser-test-styles.ts | 569 - src/spec-tests/awk/parser.ts | 995 - src/spec-tests/awk/runner.ts | 222 - src/spec-tests/awk/skips.ts | 229 - src/spec-tests/bash/KNOWN_LIMITATIONS.md | 485 - src/spec-tests/bash/README.md | 72 - .../bash/cases/LICENSE-APACHE-2.0.txt | 207 - src/spec-tests/bash/cases/alias.test.sh | 589 - src/spec-tests/bash/cases/append.test.sh | 324 - src/spec-tests/bash/cases/arg-parse.test.sh | 51 - .../bash/cases/arith-context.test.sh | 245 - .../bash/cases/arith-dynamic.test.sh | 95 - src/spec-tests/bash/cases/arith.test.sh | 1047 - .../bash/cases/array-assign.test.sh | 382 - src/spec-tests/bash/cases/array-assoc.test.sh | 777 - src/spec-tests/bash/cases/array-basic.test.sh | 45 - .../bash/cases/array-compat.test.sh | 193 - .../bash/cases/array-literal.test.sh | 331 - .../bash/cases/array-sparse.test.sh | 1232 - src/spec-tests/bash/cases/array.test.sh | 1029 - .../bash/cases/assign-deferred.test.sh | 115 - .../bash/cases/assign-dialects.test.sh | 149 - .../bash/cases/assign-extended.test.sh | 967 - src/spec-tests/bash/cases/assign.test.sh | 771 - src/spec-tests/bash/cases/background.test.sh | 425 - .../bash/cases/ble-features.test.sh | 390 - src/spec-tests/bash/cases/ble-idioms.test.sh | 575 - src/spec-tests/bash/cases/ble-unset.test.sh | 244 - src/spec-tests/bash/cases/blog-other1.test.sh | 66 - src/spec-tests/bash/cases/blog1.test.sh | 96 - src/spec-tests/bash/cases/blog2.test.sh | 60 - src/spec-tests/bash/cases/bool-parse.test.sh | 176 - .../bash/cases/brace-expansion.test.sh | 500 - src/spec-tests/bash/cases/bugs.test.sh | 452 - .../bash/cases/builtin-bash.test.sh | 215 - .../bash/cases/builtin-bind.test.sh | 147 - .../bash/cases/builtin-bracket.test.sh | 771 - src/spec-tests/bash/cases/builtin-cd.test.sh | 527 - .../bash/cases/builtin-completion.test.sh | 645 - .../bash/cases/builtin-dirs.test.sh | 260 - .../bash/cases/builtin-echo.test.sh | 322 - .../bash/cases/builtin-eval-source.test.sh | 391 - src/spec-tests/bash/cases/builtin-fc.test.sh | 276 - .../bash/cases/builtin-getopts.test.sh | 502 - .../bash/cases/builtin-history.test.sh | 418 - .../bash/cases/builtin-kill.test.sh | 366 - .../bash/cases/builtin-meta-assign.test.sh | 377 - .../bash/cases/builtin-meta.test.sh | 395 - .../bash/cases/builtin-misc.test.sh | 151 - .../bash/cases/builtin-printf.test.sh | 1605 - .../bash/cases/builtin-process.test.sh | 637 - .../bash/cases/builtin-read.test.sh | 1183 - src/spec-tests/bash/cases/builtin-set.test.sh | 427 - .../bash/cases/builtin-special.test.sh | 309 - .../bash/cases/builtin-times.test.sh | 16 - .../bash/cases/builtin-trap-bash.test.sh | 666 - .../bash/cases/builtin-trap-err.test.sh | 651 - .../bash/cases/builtin-trap.test.sh | 578 - .../bash/cases/builtin-type-bash.test.sh | 369 - .../bash/cases/builtin-type.test.sh | 164 - .../bash/cases/builtin-vars.test.sh | 714 - src/spec-tests/bash/cases/case_.test.sh | 244 - .../bash/cases/command-parsing.test.sh | 65 - .../bash/cases/command-sub-ksh.test.sh | 113 - src/spec-tests/bash/cases/command-sub.test.sh | 301 - src/spec-tests/bash/cases/command_.test.sh | 246 - src/spec-tests/bash/cases/comments.test.sh | 12 - src/spec-tests/bash/cases/dbracket.test.sh | 486 - src/spec-tests/bash/cases/divergence.test.sh | 117 - src/spec-tests/bash/cases/dparen.test.sh | 208 - .../bash/cases/empty-bodies.test.sh | 25 - src/spec-tests/bash/cases/errexit.test.sh | 531 - src/spec-tests/bash/cases/exit-status.test.sh | 324 - .../bash/cases/explore-parsing.test.sh | 43 - .../bash/cases/extglob-files.test.sh | 381 - .../bash/cases/extglob-match.test.sh | 377 - .../bash/cases/fatal-errors.test.sh | 187 - src/spec-tests/bash/cases/for-expr.test.sh | 175 - .../bash/cases/func-parsing.test.sh | 106 - src/spec-tests/bash/cases/glob-bash.test.sh | 145 - src/spec-tests/bash/cases/glob.test.sh | 412 - src/spec-tests/bash/cases/globignore.test.sh | 230 - src/spec-tests/bash/cases/globstar.test.sh | 97 - src/spec-tests/bash/cases/here-doc.test.sh | 429 - src/spec-tests/bash/cases/if_.test.sh | 55 - .../bash/cases/interactive-parse.test.sh | 38 - src/spec-tests/bash/cases/interactive.test.sh | 418 - src/spec-tests/bash/cases/introspect.test.sh | 229 - .../bash/cases/known-differences.test.sh | 28 - src/spec-tests/bash/cases/let.test.sh | 23 - src/spec-tests/bash/cases/loop.test.sh | 633 - src/spec-tests/bash/cases/nameref.test.sh | 637 - src/spec-tests/bash/cases/nix-idioms.test.sh | 196 - .../bash/cases/nocasematch-match.test.sh | 71 - src/spec-tests/bash/cases/nul-bytes.test.sh | 532 - .../bash/cases/paren-ambiguity.test.sh | 146 - .../bash/cases/parse-errors.test.sh | 218 - src/spec-tests/bash/cases/pipeline.test.sh | 278 - src/spec-tests/bash/cases/posix.test.sh | 163 - .../bash/cases/print-source-code.test.sh | 67 - src/spec-tests/bash/cases/process-sub.test.sh | 204 - src/spec-tests/bash/cases/prompt.test.sh | 353 - src/spec-tests/bash/cases/quote.test.sh | 306 - src/spec-tests/bash/cases/redir-order.test.sh | 71 - .../bash/cases/redirect-command.test.sh | 343 - .../bash/cases/redirect-multi.test.sh | 307 - src/spec-tests/bash/cases/redirect.test.sh | 618 - src/spec-tests/bash/cases/regex.test.sh | 632 - src/spec-tests/bash/cases/serialize.test.sh | 232 - src/spec-tests/bash/cases/sh-func.test.sh | 186 - .../bash/cases/sh-options-bash.test.sh | 160 - src/spec-tests/bash/cases/sh-options.test.sh | 765 - src/spec-tests/bash/cases/sh-usage.test.sh | 253 - src/spec-tests/bash/cases/shell-bugs.test.sh | 36 - .../bash/cases/shell-grammar.test.sh | 207 - src/spec-tests/bash/cases/smoke.test.sh | 126 - .../bash/cases/spec-harness-bug.test.sh | 9 - .../bash/cases/strict-options.test.sh | 340 - src/spec-tests/bash/cases/subshell.test.sh | 23 - .../bash/cases/temp-binding.test.sh | 166 - src/spec-tests/bash/cases/tilde.test.sh | 184 - src/spec-tests/bash/cases/toysh-posix.test.sh | 402 - src/spec-tests/bash/cases/toysh.test.sh | 141 - src/spec-tests/bash/cases/type-compat.test.sh | 157 - src/spec-tests/bash/cases/unicode.test.sh | 203 - src/spec-tests/bash/cases/var-num.test.sh | 44 - src/spec-tests/bash/cases/var-op-bash.test.sh | 546 - src/spec-tests/bash/cases/var-op-len.test.sh | 127 - .../bash/cases/var-op-patsub.test.sh | 415 - .../bash/cases/var-op-slice.test.sh | 411 - .../bash/cases/var-op-strip.test.sh | 398 - src/spec-tests/bash/cases/var-op-test.test.sh | 822 - src/spec-tests/bash/cases/var-ref.test.sh | 772 - .../bash/cases/var-sub-quote.test.sh | 363 - src/spec-tests/bash/cases/var-sub.test.sh | 64 - src/spec-tests/bash/cases/vars-bash.test.sh | 32 - .../bash/cases/vars-special.test.sh | 798 - src/spec-tests/bash/cases/whitespace.test.sh | 119 - src/spec-tests/bash/cases/word-eval.test.sh | 65 - src/spec-tests/bash/cases/word-split.test.sh | 914 - src/spec-tests/bash/cases/xtrace.test.sh | 429 - src/spec-tests/bash/cases/zsh-assoc.test.sh | 63 - src/spec-tests/bash/cases/zsh-idioms.test.sh | 57 - src/spec-tests/bash/spec.test.ts | 210 - src/spec-tests/grep/cases/LICENSE-busybox | 348 - src/spec-tests/grep/cases/LICENSE-gnu-grep | 675 - src/spec-tests/grep/cases/busybox-grep.tests | 237 - src/spec-tests/grep/cases/gnu-bre.tests | 86 - src/spec-tests/grep/cases/gnu-ere.tests | 255 - src/spec-tests/grep/cases/gnu-spencer1.tests | 149 - src/spec-tests/grep/cases/gnu-spencer2.tests | 363 - src/spec-tests/grep/grep-spec.test.ts | 87 - src/spec-tests/grep/parser.ts | 144 - src/spec-tests/grep/runner.ts | 199 - src/spec-tests/grep/skips.ts | 310 - src/spec-tests/jq/cases/LICENSE | 176 - src/spec-tests/jq/cases/base64.test | 47 - src/spec-tests/jq/cases/jq.test | 2536 -- src/spec-tests/jq/cases/man.test | 994 - src/spec-tests/jq/cases/manonig.test | 89 - src/spec-tests/jq/cases/onig.test | 211 - src/spec-tests/jq/cases/optional.test | 12 - src/spec-tests/jq/cases/uri.test | 38 - src/spec-tests/jq/jq-spec.test.ts | 90 - src/spec-tests/jq/parser.ts | 153 - src/spec-tests/jq/runner.ts | 250 - src/spec-tests/jq/skips.ts | 652 - src/spec-tests/parser.ts | 589 - src/spec-tests/runner.ts | 319 - src/spec-tests/sed/cases/LICENSE-busybox | 11 - src/spec-tests/sed/cases/LICENSE-pythonsed | 1 - src/spec-tests/sed/cases/busybox-sed.tests | 424 - .../sed/cases/pythonsed-chang.suite | 1968 - src/spec-tests/sed/cases/pythonsed-unit.suite | 1764 - src/spec-tests/sed/parser.ts | 201 - src/spec-tests/sed/runner.ts | 184 - src/spec-tests/sed/sed-spec.test.ts | 87 - src/spec-tests/sed/skips.ts | 257 - src/spec-tests/test-commands.ts | 139 - src/syntax/break-continue.test.ts | 171 - src/syntax/case-statement.test.ts | 202 - src/syntax/command-substitution.test.ts | 183 - src/syntax/composition.test.ts | 509 - src/syntax/control-flow.test.ts | 351 - src/syntax/execution-protection.test.ts | 625 - src/syntax/here-document.test.ts | 232 - src/syntax/loops.test.ts | 207 - src/syntax/operators.test.ts | 361 - src/syntax/parse-errors.test.ts | 222 - src/syntax/parser-edge-cases.test.ts | 330 - src/syntax/parser-protection.test.ts | 230 - src/syntax/set-errexit.test.ts | 360 - src/syntax/set-pipefail.test.ts | 116 - src/syntax/source.test.ts | 65 - src/syntax/subshell-args.test.ts | 165 - src/syntax/variables.test.ts | 286 - src/test-utils/busybox-test-parser.ts | 264 - src/types.ts | 154 - src/utils/args.ts | 198 - src/utils/constants.ts | 16 - src/utils/file-reader.ts | 148 - src/utils/glob.ts | 104 - tsconfig.json | 39 +- vitest.comparison.config.ts | 10 - vitest.config.ts | 29 - vitest.unit.config.ts | 9 - 1333 files changed, 11152 insertions(+), 362399 deletions(-) delete mode 100644 .github/workflows/comparison-tests.yml delete mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/typecheck.yml delete mode 100644 .github/workflows/unit-tests.yml delete mode 100644 .npmignore rename examples/website/.npmrc => .npmrc (100%) delete mode 100644 AGENTS.md delete mode 100644 AGENTS.npm.md delete mode 100644 CLAUDE.md delete mode 100644 LICENSE rename {examples/website/app => app}/api/agent/route.ts (100%) rename {examples/website/app => app}/api/fs/route.ts (100%) rename {examples/website/app => app}/components/Terminal.tsx (100%) rename {examples/website/app => app}/components/TerminalData.tsx (100%) rename {examples/website/app => app}/components/lite-terminal/LiteTerminal.ts (100%) rename {examples/website/app => app}/components/lite-terminal/ansi-parser.ts (100%) rename {examples/website/app => app}/components/lite-terminal/index.ts (100%) rename {examples/website/app => app}/components/lite-terminal/input-handler.ts (100%) rename {examples/website/app => app}/components/lite-terminal/types.ts (100%) rename {examples/website/app => app}/components/terminal-content.ts (96%) rename {examples/website/app => app}/components/terminal-parts/agent-command.ts (100%) rename {examples/website/app => app}/components/terminal-parts/commands.ts (100%) rename {examples/website/app => app}/components/terminal-parts/constants.ts (100%) rename {examples/website/app => app}/components/terminal-parts/index.ts (100%) rename {examples/website/app => app}/components/terminal-parts/input-handler.ts (100%) rename {examples/website/app => app}/components/terminal-parts/markdown.ts (100%) rename {examples/website/app => app}/components/terminal-parts/welcome.ts (100%) rename {examples/website/app => app}/favicon.ico (100%) rename {examples/website/app => app}/globals.css (100%) rename {examples/website/app => app}/layout.tsx (100%) rename {examples/website/app => app}/md/[[...path]]/route.ts (100%) rename {examples/website/app => app}/opengraph-image.tsx (100%) rename {examples/website/app => app}/page.tsx (100%) rename {examples/website/app => app}/providers.tsx (100%) delete mode 100644 biome.json rename examples/website/eslint.config.mjs => eslint.config.mjs (100%) delete mode 100644 examples/bash-agent/.gitignore delete mode 100644 examples/bash-agent/README.md delete mode 100644 examples/bash-agent/agent.ts delete mode 100644 examples/bash-agent/main.ts delete mode 100644 examples/bash-agent/package.json delete mode 100644 examples/bash-agent/pnpm-lock.yaml delete mode 100644 examples/bash-agent/shell.ts delete mode 100644 examples/bash-agent/tsconfig.json delete mode 100644 examples/custom-command/README.md delete mode 100644 examples/custom-command/commands.ts delete mode 100644 examples/custom-command/main.ts delete mode 100644 examples/custom-command/package.json delete mode 100644 examples/custom-command/pnpm-lock.yaml delete mode 100644 examples/custom-command/tsconfig.json delete mode 100644 examples/website/.gitignore delete mode 100644 examples/website/README.md delete mode 100644 examples/website/package.json delete mode 100644 examples/website/pnpm-lock.yaml delete mode 100644 examples/website/tsconfig.json delete mode 100644 knip.json rename examples/website/next.config.ts => next.config.ts (100%) rename examples/website/pnpm-workspace.yaml => pnpm-workspace.yaml (100%) rename examples/website/postcss.config.mjs => postcss.config.mjs (100%) rename {examples/website/public => public}/file.svg (100%) rename {examples/website/public => public}/globe.svg (100%) rename {examples/website/public => public}/next.svg (100%) rename {examples/website/public => public}/vercel.svg (100%) rename {examples/website/public => public}/window.svg (100%) delete mode 100644 scripts/check-banned-patterns.js rename {examples/website/scripts => scripts}/fetch-agent-data.mjs (100%) rename {examples/website/scripts => scripts}/generate-terminal-content.mjs (100%) delete mode 100644 src/Bash.commands.test.ts delete mode 100644 src/Bash.exec-options.test.ts delete mode 100644 src/Bash.general.test.ts delete mode 100644 src/Bash.ts delete mode 100644 src/agent-examples/bug-investigation.test.ts delete mode 100644 src/agent-examples/code-review.test.ts delete mode 100644 src/agent-examples/codebase-exploration.test.ts delete mode 100644 src/agent-examples/config-analysis.test.ts delete mode 100644 src/agent-examples/debugging-workflow.test.ts delete mode 100644 src/agent-examples/dependency-analysis.test.ts delete mode 100644 src/agent-examples/feature-implementation.test.ts delete mode 100644 src/agent-examples/fixtures/access_logs.py delete mode 100644 src/agent-examples/fixtures/analyze_csv.py delete mode 100644 src/agent-examples/fixtures/api_stats.py delete mode 100644 src/agent-examples/fixtures/codegen_dataclass.py delete mode 100644 src/agent-examples/fixtures/codegen_sql.py delete mode 100644 src/agent-examples/fixtures/extract_links.py delete mode 100644 src/agent-examples/fixtures/filter_json.py delete mode 100644 src/agent-examples/fixtures/flatten_json.py delete mode 100644 src/agent-examples/fixtures/merge_config.py delete mode 100644 src/agent-examples/fixtures/merge_files.py delete mode 100644 src/agent-examples/fixtures/parse_changelog.py delete mode 100644 src/agent-examples/fixtures/parse_env.py delete mode 100644 src/agent-examples/fixtures/parse_headers.py delete mode 100644 src/agent-examples/fixtures/parse_logs.py delete mode 100644 src/agent-examples/fixtures/sales_stats.py delete mode 100644 src/agent-examples/fixtures/timestamps.py delete mode 100644 src/agent-examples/fixtures/transform_files.py delete mode 100644 src/agent-examples/fixtures/validate_data.py delete mode 100644 src/agent-examples/log-analysis.test.ts delete mode 100644 src/agent-examples/multi-file-migration.test.ts delete mode 100644 src/agent-examples/python-scripting.test.ts delete mode 100644 src/agent-examples/refactoring-workflow.test.ts delete mode 100644 src/agent-examples/security-audit.test.ts delete mode 100644 src/agent-examples/text-processing-workflows.test.ts delete mode 100644 src/ast/types.ts delete mode 100644 src/banned-patterns-test.ts delete mode 100644 src/browser.bundle.test.ts delete mode 100644 src/browser.ts delete mode 100644 src/cli/exec.ts delete mode 100644 src/cli/just-bash.bundle.test.ts delete mode 100644 src/cli/just-bash.test.ts delete mode 100644 src/cli/just-bash.ts delete mode 100644 src/cli/shell.test.ts delete mode 100644 src/cli/shell.ts delete mode 100644 src/commands/alias/alias.test.ts delete mode 100644 src/commands/alias/alias.ts delete mode 100644 src/commands/awk/ast.ts delete mode 100644 src/commands/awk/awk.arrays.test.ts delete mode 100644 src/commands/awk/awk.binary.test.ts delete mode 100644 src/commands/awk/awk.edge-cases.test.ts delete mode 100644 src/commands/awk/awk.errors.test.ts delete mode 100644 src/commands/awk/awk.expressions.test.ts delete mode 100644 src/commands/awk/awk.fields.test.ts delete mode 100644 src/commands/awk/awk.functions.test.ts delete mode 100644 src/commands/awk/awk.getline.test.ts delete mode 100644 src/commands/awk/awk.limits.test.ts delete mode 100644 src/commands/awk/awk.math.test.ts delete mode 100644 src/commands/awk/awk.modulo.test.ts delete mode 100644 src/commands/awk/awk.nextfile.test.ts delete mode 100644 src/commands/awk/awk.operators.test.ts delete mode 100644 src/commands/awk/awk.output.test.ts delete mode 100644 src/commands/awk/awk.parsing.test.ts delete mode 100644 src/commands/awk/awk.patterns.test.ts delete mode 100644 src/commands/awk/awk.prototype-pollution.test.ts delete mode 100644 src/commands/awk/awk.range.test.ts delete mode 100644 src/commands/awk/awk.strings.test.ts delete mode 100644 src/commands/awk/awk.ternary.test.ts delete mode 100644 src/commands/awk/awk.test.ts delete mode 100644 src/commands/awk/awk2.ts delete mode 100644 src/commands/awk/builtins.ts delete mode 100644 src/commands/awk/interpreter/context.ts delete mode 100644 src/commands/awk/interpreter/expressions.ts delete mode 100644 src/commands/awk/interpreter/fields.ts delete mode 100644 src/commands/awk/interpreter/index.ts delete mode 100644 src/commands/awk/interpreter/interpreter.ts delete mode 100644 src/commands/awk/interpreter/statements.ts delete mode 100644 src/commands/awk/interpreter/type-coercion.ts delete mode 100644 src/commands/awk/interpreter/types.ts delete mode 100644 src/commands/awk/interpreter/variables.ts delete mode 100644 src/commands/awk/lexer.ts delete mode 100644 src/commands/awk/parser2-print.ts delete mode 100644 src/commands/awk/parser2.ts delete mode 100644 src/commands/base64/base64.binary.test.ts delete mode 100644 src/commands/base64/base64.test.ts delete mode 100644 src/commands/base64/base64.ts delete mode 100644 src/commands/basename/basename.test.ts delete mode 100644 src/commands/basename/basename.ts delete mode 100644 src/commands/bash/bash.test.ts delete mode 100644 src/commands/bash/bash.ts delete mode 100644 src/commands/browser-excluded.ts delete mode 100644 src/commands/cat/cat.binary.test.ts delete mode 100644 src/commands/cat/cat.test.ts delete mode 100644 src/commands/cat/cat.ts delete mode 100644 src/commands/chmod/chmod.test.ts delete mode 100644 src/commands/chmod/chmod.ts delete mode 100644 src/commands/clear/clear.test.ts delete mode 100644 src/commands/clear/clear.ts delete mode 100644 src/commands/column/column.test.ts delete mode 100644 src/commands/column/column.ts delete mode 100644 src/commands/comm/comm.test.ts delete mode 100644 src/commands/comm/comm.ts delete mode 100644 src/commands/cp/cp.binary.test.ts delete mode 100644 src/commands/cp/cp.test.ts delete mode 100644 src/commands/cp/cp.ts delete mode 100644 src/commands/curl/curl.prototype-pollution.test.ts delete mode 100644 src/commands/curl/curl.ts delete mode 100644 src/commands/curl/form.ts delete mode 100644 src/commands/curl/help.ts delete mode 100644 src/commands/curl/parse.ts delete mode 100644 src/commands/curl/response-formatting.ts delete mode 100644 src/commands/curl/tests/allowlist.test.ts delete mode 100644 src/commands/curl/tests/auth.test.ts delete mode 100644 src/commands/curl/tests/availability.test.ts delete mode 100644 src/commands/curl/tests/binary.test.ts delete mode 100644 src/commands/curl/tests/cookies.test.ts delete mode 100644 src/commands/curl/tests/errors.test.ts delete mode 100644 src/commands/curl/tests/form.test.ts delete mode 100644 src/commands/curl/tests/methods.test.ts delete mode 100644 src/commands/curl/tests/options.test.ts delete mode 100644 src/commands/curl/tests/parse.test.ts delete mode 100644 src/commands/curl/tests/timeout.test.ts delete mode 100644 src/commands/curl/tests/upload.test.ts delete mode 100644 src/commands/curl/tests/verbose.test.ts delete mode 100644 src/commands/curl/tests/writeout.test.ts delete mode 100644 src/commands/curl/types.ts delete mode 100644 src/commands/cut/cut.binary.test.ts delete mode 100644 src/commands/cut/cut.test.ts delete mode 100644 src/commands/cut/cut.ts delete mode 100644 src/commands/date/date.test.ts delete mode 100644 src/commands/date/date.ts delete mode 100644 src/commands/diff/diff.binary.test.ts delete mode 100644 src/commands/diff/diff.test.ts delete mode 100644 src/commands/diff/diff.ts delete mode 100644 src/commands/dirname/dirname.test.ts delete mode 100644 src/commands/dirname/dirname.ts delete mode 100644 src/commands/du/du.test.ts delete mode 100644 src/commands/du/du.ts delete mode 100644 src/commands/echo/echo.binary.test.ts delete mode 100644 src/commands/echo/echo.test.ts delete mode 100644 src/commands/echo/echo.ts delete mode 100644 src/commands/env/env.test.ts delete mode 100644 src/commands/env/env.ts delete mode 100644 src/commands/expand/expand.test.ts delete mode 100644 src/commands/expand/expand.ts delete mode 100644 src/commands/expand/unexpand.test.ts delete mode 100644 src/commands/expand/unexpand.ts delete mode 100644 src/commands/expr/expr.ts delete mode 100644 src/commands/file/file.test.ts delete mode 100644 src/commands/file/file.ts delete mode 100644 src/commands/find/find.actions.test.ts delete mode 100644 src/commands/find/find.basic.test.ts delete mode 100644 src/commands/find/find.depth.test.ts delete mode 100644 src/commands/find/find.exec.test.ts delete mode 100644 src/commands/find/find.operators.test.ts delete mode 100644 src/commands/find/find.patterns.test.ts delete mode 100644 src/commands/find/find.perf.test.ts delete mode 100644 src/commands/find/find.perm.test.ts delete mode 100644 src/commands/find/find.predicates.test.ts delete mode 100644 src/commands/find/find.printf.test.ts delete mode 100644 src/commands/find/find.ts delete mode 100644 src/commands/find/matcher.ts delete mode 100644 src/commands/find/parser.ts delete mode 100644 src/commands/find/types.ts delete mode 100644 src/commands/flag-coverage.ts delete mode 100644 src/commands/fold/fold.test.ts delete mode 100644 src/commands/fold/fold.ts delete mode 100644 src/commands/fuzz-flags-types.ts delete mode 100644 src/commands/fuzz-flags.ts delete mode 100644 src/commands/grep/grep.advanced.test.ts delete mode 100644 src/commands/grep/grep.basic.test.ts delete mode 100644 src/commands/grep/grep.binary.test.ts delete mode 100644 src/commands/grep/grep.exclude.test.ts delete mode 100644 src/commands/grep/grep.perl.test.ts delete mode 100644 src/commands/grep/grep.ts delete mode 100644 src/commands/gzip/gzip.binary.test.ts delete mode 100644 src/commands/gzip/gzip.test.ts delete mode 100644 src/commands/gzip/gzip.ts delete mode 100644 src/commands/head/head-tail-shared.ts delete mode 100644 src/commands/head/head.binary.test.ts delete mode 100644 src/commands/head/head.test.ts delete mode 100644 src/commands/head/head.ts delete mode 100644 src/commands/help.ts delete mode 100644 src/commands/help/help.test.ts delete mode 100644 src/commands/help/help.ts delete mode 100644 src/commands/history/history.test.ts delete mode 100644 src/commands/history/history.ts delete mode 100644 src/commands/hostname/hostname.test.ts delete mode 100644 src/commands/hostname/hostname.ts delete mode 100644 src/commands/html-to-markdown/html-to-markdown.test.ts delete mode 100644 src/commands/html-to-markdown/html-to-markdown.ts delete mode 100644 src/commands/join/join.test.ts delete mode 100644 src/commands/join/join.ts delete mode 100644 src/commands/jq/jq.basic.test.ts delete mode 100644 src/commands/jq/jq.construction.test.ts delete mode 100644 src/commands/jq/jq.dot-adjacency.test.ts delete mode 100644 src/commands/jq/jq.filters.test.ts delete mode 100644 src/commands/jq/jq.functions.test.ts delete mode 100644 src/commands/jq/jq.keyword-field-access.test.ts delete mode 100644 src/commands/jq/jq.limits.test.ts delete mode 100644 src/commands/jq/jq.operators.test.ts delete mode 100644 src/commands/jq/jq.prototype-pollution.test.ts delete mode 100644 src/commands/jq/jq.strings.test.ts delete mode 100644 src/commands/jq/jq.test.ts delete mode 100644 src/commands/jq/jq.ts delete mode 100644 src/commands/ln/ln.test.ts delete mode 100644 src/commands/ln/ln.ts delete mode 100644 src/commands/ls/ls.human.test.ts delete mode 100644 src/commands/ls/ls.test.ts delete mode 100644 src/commands/ls/ls.ts delete mode 100644 src/commands/md5sum/checksum.binary.test.ts delete mode 100644 src/commands/md5sum/checksum.ts delete mode 100644 src/commands/md5sum/md5sum.test.ts delete mode 100644 src/commands/md5sum/md5sum.ts delete mode 100644 src/commands/md5sum/sha1sum.ts delete mode 100644 src/commands/md5sum/sha256sum.ts delete mode 100644 src/commands/mkdir/mkdir.test.ts delete mode 100644 src/commands/mkdir/mkdir.ts delete mode 100644 src/commands/mv/mv.test.ts delete mode 100644 src/commands/mv/mv.ts delete mode 100644 src/commands/nl/nl.test.ts delete mode 100644 src/commands/nl/nl.ts delete mode 100644 src/commands/od/od.binary.test.ts delete mode 100644 src/commands/od/od.test.ts delete mode 100644 src/commands/od/od.ts delete mode 100644 src/commands/paste/paste.test.ts delete mode 100644 src/commands/paste/paste.ts delete mode 100644 src/commands/printf/escapes.test.ts delete mode 100644 src/commands/printf/escapes.ts delete mode 100644 src/commands/printf/printf.binary.test.ts delete mode 100644 src/commands/printf/printf.test.ts delete mode 100644 src/commands/printf/printf.ts delete mode 100644 src/commands/printf/strftime.ts delete mode 100644 src/commands/pwd/pwd.test.ts delete mode 100644 src/commands/pwd/pwd.ts delete mode 100644 src/commands/python3/fs-bridge-handler.ts delete mode 100644 src/commands/python3/protocol.ts delete mode 100644 src/commands/python3/python3.advanced.test.ts delete mode 100644 src/commands/python3/python3.env.test.ts delete mode 100644 src/commands/python3/python3.files.test.ts delete mode 100644 src/commands/python3/python3.http.test.ts delete mode 100644 src/commands/python3/python3.oop.test.ts delete mode 100644 src/commands/python3/python3.optin.test.ts delete mode 100644 src/commands/python3/python3.security.test.ts delete mode 100644 src/commands/python3/python3.stdlib.test.ts delete mode 100644 src/commands/python3/python3.test.ts delete mode 100644 src/commands/python3/python3.ts delete mode 100644 src/commands/python3/sync-fs-backend.ts delete mode 100644 src/commands/python3/worker.ts delete mode 100644 src/commands/query-engine/builtins/array-builtins.ts delete mode 100644 src/commands/query-engine/builtins/control-builtins.ts delete mode 100644 src/commands/query-engine/builtins/date-builtins.ts delete mode 100644 src/commands/query-engine/builtins/format-builtins.ts delete mode 100644 src/commands/query-engine/builtins/index-builtins.ts delete mode 100644 src/commands/query-engine/builtins/index.ts delete mode 100644 src/commands/query-engine/builtins/math-builtins.ts delete mode 100644 src/commands/query-engine/builtins/navigation-builtins.ts delete mode 100644 src/commands/query-engine/builtins/object-builtins.ts delete mode 100644 src/commands/query-engine/builtins/path-builtins.ts delete mode 100644 src/commands/query-engine/builtins/sql-builtins.ts delete mode 100644 src/commands/query-engine/builtins/string-builtins.ts delete mode 100644 src/commands/query-engine/builtins/type-builtins.ts delete mode 100644 src/commands/query-engine/evaluator.ts delete mode 100644 src/commands/query-engine/index.ts delete mode 100644 src/commands/query-engine/parser-types.ts delete mode 100644 src/commands/query-engine/parser.ts delete mode 100644 src/commands/query-engine/path-operations.ts delete mode 100644 src/commands/query-engine/safe-object.test.ts delete mode 100644 src/commands/query-engine/safe-object.ts delete mode 100644 src/commands/query-engine/value-operations.ts delete mode 100644 src/commands/readlink/readlink.test.ts delete mode 100644 src/commands/readlink/readlink.ts delete mode 100644 src/commands/registry.test.ts delete mode 100644 src/commands/registry.ts delete mode 100644 src/commands/rev/rev.test.ts delete mode 100644 src/commands/rev/rev.ts delete mode 100644 src/commands/rg/file-types.ts delete mode 100644 src/commands/rg/gitignore.test.ts delete mode 100644 src/commands/rg/gitignore.ts delete mode 100644 src/commands/rg/imported-tests/README.md delete mode 100644 src/commands/rg/imported-tests/binary.test.ts delete mode 100644 src/commands/rg/imported-tests/feature.test.ts delete mode 100644 src/commands/rg/imported-tests/json.test.ts delete mode 100644 src/commands/rg/imported-tests/misc.test.ts delete mode 100644 src/commands/rg/imported-tests/multiline.test.ts delete mode 100644 src/commands/rg/imported-tests/regression.test.ts delete mode 100644 src/commands/rg/rg-options.ts delete mode 100644 src/commands/rg/rg-parser.ts delete mode 100644 src/commands/rg/rg-search.ts delete mode 100644 src/commands/rg/rg.basic.test.ts delete mode 100644 src/commands/rg/rg.edge-cases.test.ts delete mode 100644 src/commands/rg/rg.filtering.test.ts delete mode 100644 src/commands/rg/rg.flags.test.ts delete mode 100644 src/commands/rg/rg.max-count.test.ts delete mode 100644 src/commands/rg/rg.no-filename.test.ts delete mode 100644 src/commands/rg/rg.output.test.ts delete mode 100644 src/commands/rg/rg.patterns.test.ts delete mode 100644 src/commands/rg/rg.ripgrep-compat.test.ts delete mode 100644 src/commands/rg/rg.ts delete mode 100644 src/commands/rm/rm.test.ts delete mode 100644 src/commands/rm/rm.ts delete mode 100644 src/commands/rmdir/rmdir.ts delete mode 100644 src/commands/search-engine/index.ts delete mode 100644 src/commands/search-engine/matcher.ts delete mode 100644 src/commands/search-engine/regex.ts delete mode 100644 src/commands/sed/executor.ts delete mode 100644 src/commands/sed/lexer.ts delete mode 100644 src/commands/sed/parser.ts delete mode 100644 src/commands/sed/sed-regex.ts delete mode 100644 src/commands/sed/sed.advanced.test.ts delete mode 100644 src/commands/sed/sed.binary.test.ts delete mode 100644 src/commands/sed/sed.commands.test.ts delete mode 100644 src/commands/sed/sed.errors.test.ts delete mode 100644 src/commands/sed/sed.limits.test.ts delete mode 100644 src/commands/sed/sed.regex.test.ts delete mode 100644 src/commands/sed/sed.test.ts delete mode 100644 src/commands/sed/sed.ts delete mode 100644 src/commands/sed/types.ts delete mode 100644 src/commands/seq/seq.test.ts delete mode 100644 src/commands/seq/seq.ts delete mode 100644 src/commands/sleep/sleep.test.ts delete mode 100644 src/commands/sleep/sleep.ts delete mode 100644 src/commands/sort/comparator.ts delete mode 100644 src/commands/sort/parser.ts delete mode 100644 src/commands/sort/sort.advanced.test.ts delete mode 100644 src/commands/sort/sort.binary.test.ts delete mode 100644 src/commands/sort/sort.test.ts delete mode 100644 src/commands/sort/sort.ts delete mode 100644 src/commands/sort/types.ts delete mode 100644 src/commands/split/split.test.ts delete mode 100644 src/commands/split/split.ts delete mode 100644 src/commands/sqlite3/fixtures/datatypes.db delete mode 100644 src/commands/sqlite3/fixtures/products.db delete mode 100644 src/commands/sqlite3/fixtures/users.db delete mode 100644 src/commands/sqlite3/formatters.ts delete mode 100755 src/commands/sqlite3/samples.sh delete mode 100644 src/commands/sqlite3/sqlite3.errors.test.ts delete mode 100644 src/commands/sqlite3/sqlite3.fixtures.test.ts delete mode 100644 src/commands/sqlite3/sqlite3.formatters.test.ts delete mode 100644 src/commands/sqlite3/sqlite3.options.test.ts delete mode 100644 src/commands/sqlite3/sqlite3.output-modes.test.ts delete mode 100644 src/commands/sqlite3/sqlite3.parsing.test.ts delete mode 100644 src/commands/sqlite3/sqlite3.test.ts delete mode 100644 src/commands/sqlite3/sqlite3.ts delete mode 100644 src/commands/sqlite3/sqlite3.write-ops.test.ts delete mode 100644 src/commands/sqlite3/worker.ts delete mode 100644 src/commands/stat/stat.test.ts delete mode 100644 src/commands/stat/stat.ts delete mode 100644 src/commands/strings/strings.binary.test.ts delete mode 100644 src/commands/strings/strings.test.ts delete mode 100644 src/commands/strings/strings.ts delete mode 100644 src/commands/tac/tac.test.ts delete mode 100644 src/commands/tac/tac.ts delete mode 100644 src/commands/tail/tail.binary.test.ts delete mode 100644 src/commands/tail/tail.test.ts delete mode 100644 src/commands/tail/tail.ts delete mode 100644 src/commands/tar/archive.ts delete mode 100644 src/commands/tar/tar-options.ts delete mode 100644 src/commands/tar/tar.binary.test.ts delete mode 100644 src/commands/tar/tar.bundle.test.ts delete mode 100644 src/commands/tar/tar.test.ts delete mode 100644 src/commands/tar/tar.ts delete mode 100644 src/commands/tee/tee.binary.test.ts delete mode 100644 src/commands/tee/tee.test.ts delete mode 100644 src/commands/tee/tee.ts delete mode 100644 src/commands/test/test.test.ts delete mode 100644 src/commands/time/time.ts delete mode 100644 src/commands/timeout/timeout.test.ts delete mode 100644 src/commands/timeout/timeout.ts delete mode 100644 src/commands/touch/touch.test.ts delete mode 100644 src/commands/touch/touch.ts delete mode 100644 src/commands/tr/tr.binary.test.ts delete mode 100644 src/commands/tr/tr.complement.test.ts delete mode 100644 src/commands/tr/tr.test.ts delete mode 100644 src/commands/tr/tr.ts delete mode 100644 src/commands/tree/tree.test.ts delete mode 100644 src/commands/tree/tree.ts delete mode 100644 src/commands/true/true.test.ts delete mode 100644 src/commands/true/true.ts delete mode 100644 src/commands/uniq/uniq.binary.test.ts delete mode 100644 src/commands/uniq/uniq.test.ts delete mode 100644 src/commands/uniq/uniq.ts delete mode 100644 src/commands/wc/wc.binary.test.ts delete mode 100644 src/commands/wc/wc.test.ts delete mode 100644 src/commands/wc/wc.ts delete mode 100644 src/commands/which/which.test.ts delete mode 100644 src/commands/which/which.ts delete mode 100644 src/commands/whoami/whoami.ts delete mode 100644 src/commands/xan/aggregation.ts delete mode 100644 src/commands/xan/column-selection.ts delete mode 100644 src/commands/xan/csv.ts delete mode 100644 src/commands/xan/fixtures/employees.csv delete mode 100644 src/commands/xan/fixtures/metrics.csv delete mode 100644 src/commands/xan/fixtures/numbers.csv delete mode 100644 src/commands/xan/fixtures/products.csv delete mode 100644 src/commands/xan/fixtures/sales.csv delete mode 100644 src/commands/xan/fixtures/server_logs.csv delete mode 100644 src/commands/xan/fixtures/special_chars.csv delete mode 100644 src/commands/xan/fixtures/transactions.csv delete mode 100644 src/commands/xan/fixtures/users.csv delete mode 100644 src/commands/xan/moonblade-parser.ts delete mode 100644 src/commands/xan/moonblade-to-jq.ts delete mode 100644 src/commands/xan/moonblade-tokenizer.ts delete mode 100644 src/commands/xan/subcommands.ts delete mode 100644 src/commands/xan/xan-agg.ts delete mode 100644 src/commands/xan/xan-columns.ts delete mode 100644 src/commands/xan/xan-core.ts delete mode 100644 src/commands/xan/xan-data.ts delete mode 100644 src/commands/xan/xan-filter.ts delete mode 100644 src/commands/xan/xan-map.ts delete mode 100644 src/commands/xan/xan-reshape.ts delete mode 100644 src/commands/xan/xan-simple.ts delete mode 100644 src/commands/xan/xan-view.ts delete mode 100644 src/commands/xan/xan.agg.test.ts delete mode 100644 src/commands/xan/xan.basic.test.ts delete mode 100644 src/commands/xan/xan.columns.test.ts delete mode 100644 src/commands/xan/xan.data.test.ts delete mode 100644 src/commands/xan/xan.filter-sort.test.ts delete mode 100644 src/commands/xan/xan.frequency.test.ts delete mode 100644 src/commands/xan/xan.groupby.test.ts delete mode 100644 src/commands/xan/xan.map.test.ts delete mode 100644 src/commands/xan/xan.multifile.test.ts delete mode 100644 src/commands/xan/xan.prototype-pollution.test.ts delete mode 100644 src/commands/xan/xan.reshape.test.ts delete mode 100644 src/commands/xan/xan.select-advanced.test.ts delete mode 100644 src/commands/xan/xan.transform.test.ts delete mode 100644 src/commands/xan/xan.ts delete mode 100644 src/commands/xargs/xargs.test.ts delete mode 100644 src/commands/xargs/xargs.ts delete mode 100644 src/commands/yq/fixtures/csv/products.csv delete mode 100644 src/commands/yq/fixtures/csv/semicolon.csv delete mode 100644 src/commands/yq/fixtures/csv/special.csv delete mode 100644 src/commands/yq/fixtures/csv/tabs.tsv delete mode 100644 src/commands/yq/fixtures/csv/users.csv delete mode 100644 src/commands/yq/fixtures/ini/app.ini delete mode 100644 src/commands/yq/fixtures/ini/config.ini delete mode 100644 src/commands/yq/fixtures/ini/special.ini delete mode 100644 src/commands/yq/fixtures/json/nested.json delete mode 100644 src/commands/yq/fixtures/json/special.json delete mode 100644 src/commands/yq/fixtures/json/users.json delete mode 100644 src/commands/yq/fixtures/toml/cargo.toml delete mode 100644 src/commands/yq/fixtures/toml/config.toml delete mode 100644 src/commands/yq/fixtures/toml/pyproject.toml delete mode 100644 src/commands/yq/fixtures/toml/special.toml delete mode 100644 src/commands/yq/fixtures/xml/books.xml delete mode 100644 src/commands/yq/fixtures/xml/special.xml delete mode 100644 src/commands/yq/fixtures/xml/users.xml delete mode 100644 src/commands/yq/fixtures/yaml/simple.yaml delete mode 100644 src/commands/yq/fixtures/yaml/special.yaml delete mode 100644 src/commands/yq/fixtures/yaml/users.yaml delete mode 100644 src/commands/yq/formats.ts delete mode 100644 src/commands/yq/yq.env.test.ts delete mode 100644 src/commands/yq/yq.fixtures.test.ts delete mode 100644 src/commands/yq/yq.format-strings.test.ts delete mode 100644 src/commands/yq/yq.navigation.test.ts delete mode 100644 src/commands/yq/yq.prototype-pollution.test.ts delete mode 100644 src/commands/yq/yq.test.ts delete mode 100644 src/commands/yq/yq.ts delete mode 100644 src/comparison-tests/README.md delete mode 100644 src/comparison-tests/alias.comparison.test.ts delete mode 100644 src/comparison-tests/awk.comparison.test.ts delete mode 100644 src/comparison-tests/basename-dirname.comparison.test.ts delete mode 100644 src/comparison-tests/cat.comparison.test.ts delete mode 100644 src/comparison-tests/cd.comparison.test.ts delete mode 100644 src/comparison-tests/column-join.comparison.test.ts delete mode 100644 src/comparison-tests/cut.comparison.test.ts delete mode 100644 src/comparison-tests/echo.comparison.test.ts delete mode 100644 src/comparison-tests/env.comparison.test.ts delete mode 100644 src/comparison-tests/export.comparison.test.ts delete mode 100644 src/comparison-tests/file-operations.comparison.test.ts delete mode 100644 src/comparison-tests/find.comparison.test.ts delete mode 100644 src/comparison-tests/fixture-runner.ts delete mode 100644 src/comparison-tests/fixtures/alias.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/awk.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/basename-dirname.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/cat.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/column-join.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/cut.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/echo.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/export.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/find.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/glob.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/grep.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/head-tail.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/here-document.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/jq.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/ls.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/paste.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/pipes-redirections.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/sed.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/sort.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/strings-split.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/tar.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/test.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/text-processing.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/tr.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/uniq.comparison.fixtures.json delete mode 100644 src/comparison-tests/fixtures/wc.comparison.fixtures.json delete mode 100644 src/comparison-tests/glob.comparison.test.ts delete mode 100644 src/comparison-tests/grep.comparison.test.ts delete mode 100644 src/comparison-tests/head-tail.comparison.test.ts delete mode 100644 src/comparison-tests/here-document.comparison.test.ts delete mode 100644 src/comparison-tests/jq.comparison.test.ts delete mode 100644 src/comparison-tests/ls.comparison.test.ts delete mode 100644 src/comparison-tests/parse-errors.comparison.test.ts delete mode 100644 src/comparison-tests/paste.comparison.test.ts delete mode 100644 src/comparison-tests/pipes-redirections.comparison.test.ts delete mode 100644 src/comparison-tests/sed.comparison.test.ts delete mode 100644 src/comparison-tests/sort.comparison.test.ts delete mode 100644 src/comparison-tests/strings-split.comparison.test.ts delete mode 100644 src/comparison-tests/tar.comparison.test.ts delete mode 100644 src/comparison-tests/tee.comparison.test.ts delete mode 100644 src/comparison-tests/test.comparison.test.ts delete mode 100644 src/comparison-tests/text-processing.comparison.test.ts delete mode 100644 src/comparison-tests/tr.comparison.test.ts delete mode 100644 src/comparison-tests/uniq.comparison.test.ts delete mode 100644 src/comparison-tests/vitest.setup.ts delete mode 100644 src/comparison-tests/wc.comparison.test.ts delete mode 100644 src/custom-commands.test.ts delete mode 100644 src/custom-commands.ts delete mode 100644 src/fs/encoding.ts delete mode 100644 src/fs/in-memory-fs/in-memory-fs.test.ts delete mode 100644 src/fs/in-memory-fs/in-memory-fs.ts delete mode 100644 src/fs/in-memory-fs/index.ts delete mode 100644 src/fs/init.ts delete mode 100644 src/fs/interface.ts delete mode 100644 src/fs/mountable-fs/index.ts delete mode 100644 src/fs/mountable-fs/mountable-fs.test.ts delete mode 100644 src/fs/mountable-fs/mountable-fs.ts delete mode 100644 src/fs/overlay-fs/index.ts delete mode 100644 src/fs/overlay-fs/overlay-fs.e2e.test.ts delete mode 100644 src/fs/overlay-fs/overlay-fs.security.test.ts delete mode 100644 src/fs/overlay-fs/overlay-fs.test.ts delete mode 100644 src/fs/overlay-fs/overlay-fs.ts delete mode 100644 src/fs/read-write-fs/index.ts delete mode 100644 src/fs/read-write-fs/read-write-fs.piping.test.ts delete mode 100644 src/fs/read-write-fs/read-write-fs.security.test.ts delete mode 100644 src/fs/read-write-fs/read-write-fs.test.ts delete mode 100644 src/fs/read-write-fs/read-write-fs.ts delete mode 100644 src/helpers/env.ts delete mode 100644 src/index.ts delete mode 100644 src/interpreter/alias-expansion.ts delete mode 100644 src/interpreter/arithmetic.test.ts delete mode 100644 src/interpreter/arithmetic.ts delete mode 100644 src/interpreter/assignment-expansion.ts delete mode 100644 src/interpreter/assoc-array.test.ts delete mode 100644 src/interpreter/builtin-dispatch.ts delete mode 100644 src/interpreter/builtins/break.test.ts delete mode 100644 src/interpreter/builtins/break.ts delete mode 100644 src/interpreter/builtins/cd.test.ts delete mode 100644 src/interpreter/builtins/cd.ts delete mode 100644 src/interpreter/builtins/compgen.ts delete mode 100644 src/interpreter/builtins/complete.test.ts delete mode 100644 src/interpreter/builtins/complete.ts delete mode 100644 src/interpreter/builtins/compopt.test.ts delete mode 100644 src/interpreter/builtins/compopt.ts delete mode 100644 src/interpreter/builtins/continue.test.ts delete mode 100644 src/interpreter/builtins/continue.ts delete mode 100644 src/interpreter/builtins/declare-array-parsing.ts delete mode 100644 src/interpreter/builtins/declare-print.ts delete mode 100644 src/interpreter/builtins/declare.ts delete mode 100644 src/interpreter/builtins/dirs.ts delete mode 100644 src/interpreter/builtins/eval.test.ts delete mode 100644 src/interpreter/builtins/eval.ts delete mode 100644 src/interpreter/builtins/exit.test.ts delete mode 100644 src/interpreter/builtins/exit.ts delete mode 100644 src/interpreter/builtins/export.test.ts delete mode 100644 src/interpreter/builtins/export.ts delete mode 100644 src/interpreter/builtins/getopts.ts delete mode 100644 src/interpreter/builtins/hash.ts delete mode 100644 src/interpreter/builtins/help.ts delete mode 100644 src/interpreter/builtins/index.ts delete mode 100644 src/interpreter/builtins/let.ts delete mode 100644 src/interpreter/builtins/local.test.ts delete mode 100644 src/interpreter/builtins/local.ts delete mode 100644 src/interpreter/builtins/mapfile.ts delete mode 100644 src/interpreter/builtins/posix-fatal.test.ts delete mode 100644 src/interpreter/builtins/read.test.ts delete mode 100644 src/interpreter/builtins/read.ts delete mode 100644 src/interpreter/builtins/return.test.ts delete mode 100644 src/interpreter/builtins/return.ts delete mode 100644 src/interpreter/builtins/set.test.ts delete mode 100644 src/interpreter/builtins/set.ts delete mode 100644 src/interpreter/builtins/shift.test.ts delete mode 100644 src/interpreter/builtins/shift.ts delete mode 100644 src/interpreter/builtins/shopt.ts delete mode 100644 src/interpreter/builtins/source.test.ts delete mode 100644 src/interpreter/builtins/source.ts delete mode 100644 src/interpreter/builtins/unset.test.ts delete mode 100644 src/interpreter/builtins/unset.ts delete mode 100644 src/interpreter/builtins/variable-assignment.ts delete mode 100644 src/interpreter/command-resolution.ts delete mode 100644 src/interpreter/conditionals.ts delete mode 100644 src/interpreter/control-flow.test.ts delete mode 100644 src/interpreter/control-flow.ts delete mode 100644 src/interpreter/errors.ts delete mode 100644 src/interpreter/expansion.ts delete mode 100644 src/interpreter/expansion/analysis.ts delete mode 100644 src/interpreter/expansion/arith-text-expansion.ts delete mode 100644 src/interpreter/expansion/array-pattern-ops.ts delete mode 100644 src/interpreter/expansion/array-prefix-suffix.ts delete mode 100644 src/interpreter/expansion/array-slice-transform.ts delete mode 100644 src/interpreter/expansion/array-word-expansion.ts delete mode 100644 src/interpreter/expansion/brace-range.ts delete mode 100644 src/interpreter/expansion/command-substitution.ts delete mode 100644 src/interpreter/expansion/glob-escape.ts delete mode 100644 src/interpreter/expansion/indirect-expansion.ts delete mode 100644 src/interpreter/expansion/parameter-ops.ts delete mode 100644 src/interpreter/expansion/pattern-expansion.ts delete mode 100644 src/interpreter/expansion/pattern-removal.ts delete mode 100644 src/interpreter/expansion/pattern.ts delete mode 100644 src/interpreter/expansion/positional-params.ts delete mode 100644 src/interpreter/expansion/prompt.test.ts delete mode 100644 src/interpreter/expansion/prompt.ts delete mode 100644 src/interpreter/expansion/quoting.ts delete mode 100644 src/interpreter/expansion/tilde.ts delete mode 100644 src/interpreter/expansion/unquoted-expansion.ts delete mode 100644 src/interpreter/expansion/variable-attrs.ts delete mode 100644 src/interpreter/expansion/variable.ts delete mode 100644 src/interpreter/expansion/word-glob-expansion.ts delete mode 100644 src/interpreter/expansion/word-split.ts delete mode 100644 src/interpreter/functions.ts delete mode 100644 src/interpreter/helpers/array.ts delete mode 100644 src/interpreter/helpers/condition.ts delete mode 100644 src/interpreter/helpers/errors.ts delete mode 100644 src/interpreter/helpers/file-tests.ts delete mode 100644 src/interpreter/helpers/ifs.ts delete mode 100644 src/interpreter/helpers/loop.ts delete mode 100644 src/interpreter/helpers/nameref.ts delete mode 100644 src/interpreter/helpers/numeric-compare.ts delete mode 100644 src/interpreter/helpers/quoting.ts delete mode 100644 src/interpreter/helpers/readonly.ts delete mode 100644 src/interpreter/helpers/regex.ts delete mode 100644 src/interpreter/helpers/result.ts delete mode 100644 src/interpreter/helpers/shell-constants.ts delete mode 100644 src/interpreter/helpers/shellopts.ts delete mode 100644 src/interpreter/helpers/statements.ts delete mode 100644 src/interpreter/helpers/string-compare.ts delete mode 100644 src/interpreter/helpers/string-tests.ts delete mode 100644 src/interpreter/helpers/tilde.ts delete mode 100644 src/interpreter/helpers/variable-tests.ts delete mode 100644 src/interpreter/helpers/word-matching.ts delete mode 100644 src/interpreter/helpers/word-parts.ts delete mode 100644 src/interpreter/helpers/xtrace.test.ts delete mode 100644 src/interpreter/helpers/xtrace.ts delete mode 100644 src/interpreter/index.ts delete mode 100644 src/interpreter/interpreter.ts delete mode 100644 src/interpreter/pipeline-execution.ts delete mode 100644 src/interpreter/prototype-pollution.test.ts delete mode 100644 src/interpreter/redirections.binary.test.ts delete mode 100644 src/interpreter/redirections.ts delete mode 100644 src/interpreter/simple-command-assignments.ts delete mode 100644 src/interpreter/subshell-group.ts delete mode 100644 src/interpreter/type-command.ts delete mode 100644 src/interpreter/types.ts delete mode 100644 src/limits.ts delete mode 100644 src/network/allow-list.ts delete mode 100644 src/network/allow-list/bypass.test.ts delete mode 100644 src/network/allow-list/e2e.test.ts delete mode 100644 src/network/allow-list/mock.test.ts delete mode 100644 src/network/allow-list/shared.ts delete mode 100644 src/network/allow-list/unit.test.ts delete mode 100644 src/network/fetch.ts delete mode 100644 src/network/index.ts delete mode 100644 src/network/types.ts delete mode 100644 src/parser/arithmetic-parser.ts delete mode 100644 src/parser/arithmetic-primaries.ts delete mode 100644 src/parser/command-parser.ts delete mode 100644 src/parser/compound-parser.ts delete mode 100644 src/parser/conditional-parser.ts delete mode 100644 src/parser/expansion-parser.ts delete mode 100644 src/parser/lexer.ts delete mode 100644 src/parser/parser-substitution.ts delete mode 100644 src/parser/parser.ts delete mode 100644 src/parser/types.ts delete mode 100644 src/parser/word-parser.ts delete mode 100644 src/readme.test.ts delete mode 100644 src/regex/index.ts delete mode 100644 src/regex/user-regex.test.ts delete mode 100644 src/regex/user-regex.ts delete mode 100644 src/sandbox/Command.ts delete mode 100644 src/sandbox/Sandbox.test.ts delete mode 100644 src/sandbox/Sandbox.ts delete mode 100644 src/sandbox/index.ts delete mode 100644 src/security-limits.test.ts delete mode 100644 src/security/attacks/filename-attacks.test.ts delete mode 100644 src/security/attacks/fuzz-discovered-attacks.test.ts delete mode 100644 src/security/attacks/injection-attacks.test.ts delete mode 100644 src/security/attacks/numeric-edge-cases.test.ts delete mode 100644 src/security/blocked-globals.ts delete mode 100644 src/security/defense-in-depth-box-concurrent.test.ts delete mode 100644 src/security/defense-in-depth-box.test.ts delete mode 100644 src/security/defense-in-depth-box.ts delete mode 100644 src/security/fuzzing/__tests__/fuzz-coverage.test.ts delete mode 100644 src/security/fuzzing/__tests__/fuzz-dos.test.ts delete mode 100644 src/security/fuzzing/__tests__/fuzz-malformed.test.ts delete mode 100644 src/security/fuzzing/__tests__/fuzz-sandbox.test.ts delete mode 100644 src/security/fuzzing/config.ts delete mode 100644 src/security/fuzzing/corpus/known-attacks.ts delete mode 100644 src/security/fuzzing/coverage/coverage-tracker.ts delete mode 100644 src/security/fuzzing/coverage/feature-coverage.ts delete mode 100644 src/security/fuzzing/coverage/index.ts delete mode 100644 src/security/fuzzing/coverage/known-features.ts delete mode 100644 src/security/fuzzing/generators/coverage-boost-generator.ts delete mode 100644 src/security/fuzzing/generators/flag-driven-generator.ts delete mode 100644 src/security/fuzzing/generators/grammar-generator.test.ts delete mode 100644 src/security/fuzzing/generators/grammar-generator.ts delete mode 100644 src/security/fuzzing/generators/index.ts delete mode 100644 src/security/fuzzing/generators/malformed-generator.ts delete mode 100644 src/security/fuzzing/index.ts delete mode 100644 src/security/fuzzing/oracles/assertions.ts delete mode 100644 src/security/fuzzing/oracles/dos-oracle.ts delete mode 100644 src/security/fuzzing/oracles/sandbox-oracle.ts delete mode 100644 src/security/fuzzing/runners/fuzz-runner.ts delete mode 100644 src/security/index.ts delete mode 100644 src/security/limits/dos-limits.test.ts delete mode 100644 src/security/limits/memory-exhaustion.test.ts delete mode 100644 src/security/limits/output-size-limits.test.ts delete mode 100644 src/security/limits/pipeline-limits.test.ts delete mode 100644 src/security/prototype-pollution/prototype-pollution-awk.test.ts delete mode 100644 src/security/prototype-pollution/prototype-pollution-bash-extended.test.ts delete mode 100644 src/security/prototype-pollution/prototype-pollution-comprehensive.test.ts delete mode 100644 src/security/prototype-pollution/prototype-pollution-edge-cases.test.ts delete mode 100644 src/security/prototype-pollution/prototype-pollution-sed.test.ts delete mode 100644 src/security/prototype-pollution/prototype-pollution-syntax-features.test.ts delete mode 100644 src/security/sandbox/command-security.test.ts delete mode 100644 src/security/sandbox/dynamic-execution.test.ts delete mode 100644 src/security/sandbox/information-disclosure.test.ts delete mode 100644 src/security/sandbox/sandbox-escape.test.ts delete mode 100644 src/security/security-violation-logger.test.ts delete mode 100644 src/security/security-violation-logger.ts delete mode 100644 src/security/types.ts delete mode 100644 src/security/worker-defense-in-depth.test.ts delete mode 100644 src/security/worker-defense-in-depth.ts delete mode 100644 src/shared/operators.ts delete mode 100644 src/shell-metadata.ts delete mode 100644 src/shell/glob-to-regex.ts delete mode 100644 src/shell/glob.ts delete mode 100644 src/spec-tests/awk/awk-spec.test.ts delete mode 100755 src/spec-tests/awk/cases/Compare.T1 delete mode 100755 src/spec-tests/awk/cases/Compare.drek delete mode 100755 src/spec-tests/awk/cases/Compare.p delete mode 100755 src/spec-tests/awk/cases/Compare.t delete mode 100755 src/spec-tests/awk/cases/Compare.tt delete mode 100644 src/spec-tests/awk/cases/LICENSE delete mode 100644 src/spec-tests/awk/cases/NOTES delete mode 100644 src/spec-tests/awk/cases/README.TESTS delete mode 100755 src/spec-tests/awk/cases/REGRESS delete mode 100755 src/spec-tests/awk/cases/T.-f-f delete mode 100755 src/spec-tests/awk/cases/T.argv delete mode 100755 src/spec-tests/awk/cases/T.arnold delete mode 100755 src/spec-tests/awk/cases/T.beebe delete mode 100755 src/spec-tests/awk/cases/T.builtin delete mode 100755 src/spec-tests/awk/cases/T.chem delete mode 100755 src/spec-tests/awk/cases/T.close delete mode 100755 src/spec-tests/awk/cases/T.clv delete mode 100755 src/spec-tests/awk/cases/T.csconcat delete mode 100755 src/spec-tests/awk/cases/T.csv delete mode 100755 src/spec-tests/awk/cases/T.delete delete mode 100755 src/spec-tests/awk/cases/T.errmsg delete mode 100755 src/spec-tests/awk/cases/T.expr delete mode 100755 src/spec-tests/awk/cases/T.exprconv delete mode 100755 src/spec-tests/awk/cases/T.flags delete mode 100755 src/spec-tests/awk/cases/T.func delete mode 100755 src/spec-tests/awk/cases/T.gawk delete mode 100755 src/spec-tests/awk/cases/T.getline delete mode 100755 src/spec-tests/awk/cases/T.int-expr delete mode 100755 src/spec-tests/awk/cases/T.latin1 delete mode 100755 src/spec-tests/awk/cases/T.lilly delete mode 100755 src/spec-tests/awk/cases/T.main delete mode 100755 src/spec-tests/awk/cases/T.misc delete mode 100755 src/spec-tests/awk/cases/T.nextfile delete mode 100755 src/spec-tests/awk/cases/T.overflow delete mode 100755 src/spec-tests/awk/cases/T.re delete mode 100755 src/spec-tests/awk/cases/T.recache delete mode 100755 src/spec-tests/awk/cases/T.redir delete mode 100755 src/spec-tests/awk/cases/T.split delete mode 100755 src/spec-tests/awk/cases/T.sub delete mode 100755 src/spec-tests/awk/cases/T.system delete mode 100755 src/spec-tests/awk/cases/T.utf delete mode 100755 src/spec-tests/awk/cases/T.utfre delete mode 100644 src/spec-tests/awk/cases/arnold-fixes.tar delete mode 100644 src/spec-tests/awk/cases/beebe.tar delete mode 100644 src/spec-tests/awk/cases/bib delete mode 100644 src/spec-tests/awk/cases/bundle.awk delete mode 100644 src/spec-tests/awk/cases/chem.awk delete mode 100755 src/spec-tests/awk/cases/cleanup delete mode 100644 src/spec-tests/awk/cases/countries delete mode 100755 src/spec-tests/awk/cases/ctimes delete mode 100644 src/spec-tests/awk/cases/echo.c delete mode 100644 src/spec-tests/awk/cases/funstack.awk delete mode 100644 src/spec-tests/awk/cases/funstack.in delete mode 100644 src/spec-tests/awk/cases/funstack.ok delete mode 100755 src/spec-tests/awk/cases/ind delete mode 100644 src/spec-tests/awk/cases/latin1 delete mode 100755 src/spec-tests/awk/cases/lilly.ifile delete mode 100644 src/spec-tests/awk/cases/lilly.out delete mode 100644 src/spec-tests/awk/cases/lilly.progs delete mode 100644 src/spec-tests/awk/cases/lsd1.p delete mode 100644 src/spec-tests/awk/cases/p.1 delete mode 100644 src/spec-tests/awk/cases/p.10 delete mode 100644 src/spec-tests/awk/cases/p.11 delete mode 100644 src/spec-tests/awk/cases/p.12 delete mode 100644 src/spec-tests/awk/cases/p.13 delete mode 100644 src/spec-tests/awk/cases/p.14 delete mode 100644 src/spec-tests/awk/cases/p.15 delete mode 100644 src/spec-tests/awk/cases/p.16 delete mode 100644 src/spec-tests/awk/cases/p.17 delete mode 100644 src/spec-tests/awk/cases/p.18 delete mode 100644 src/spec-tests/awk/cases/p.19 delete mode 100644 src/spec-tests/awk/cases/p.2 delete mode 100644 src/spec-tests/awk/cases/p.20 delete mode 100644 src/spec-tests/awk/cases/p.21 delete mode 100644 src/spec-tests/awk/cases/p.21a delete mode 100644 src/spec-tests/awk/cases/p.22 delete mode 100644 src/spec-tests/awk/cases/p.23 delete mode 100644 src/spec-tests/awk/cases/p.24 delete mode 100644 src/spec-tests/awk/cases/p.25 delete mode 100644 src/spec-tests/awk/cases/p.26 delete mode 100644 src/spec-tests/awk/cases/p.26a delete mode 100644 src/spec-tests/awk/cases/p.27 delete mode 100644 src/spec-tests/awk/cases/p.28 delete mode 100644 src/spec-tests/awk/cases/p.29 delete mode 100644 src/spec-tests/awk/cases/p.3 delete mode 100644 src/spec-tests/awk/cases/p.30 delete mode 100644 src/spec-tests/awk/cases/p.31 delete mode 100644 src/spec-tests/awk/cases/p.32 delete mode 100644 src/spec-tests/awk/cases/p.33 delete mode 100644 src/spec-tests/awk/cases/p.34 delete mode 100644 src/spec-tests/awk/cases/p.35 delete mode 100644 src/spec-tests/awk/cases/p.36 delete mode 100644 src/spec-tests/awk/cases/p.37 delete mode 100644 src/spec-tests/awk/cases/p.38 delete mode 100644 src/spec-tests/awk/cases/p.39 delete mode 100644 src/spec-tests/awk/cases/p.4 delete mode 100644 src/spec-tests/awk/cases/p.40 delete mode 100644 src/spec-tests/awk/cases/p.41 delete mode 100644 src/spec-tests/awk/cases/p.42 delete mode 100644 src/spec-tests/awk/cases/p.43 delete mode 100644 src/spec-tests/awk/cases/p.44 delete mode 100644 src/spec-tests/awk/cases/p.45 delete mode 100644 src/spec-tests/awk/cases/p.46 delete mode 100644 src/spec-tests/awk/cases/p.47 delete mode 100644 src/spec-tests/awk/cases/p.48 delete mode 100644 src/spec-tests/awk/cases/p.48a delete mode 100644 src/spec-tests/awk/cases/p.48b delete mode 100644 src/spec-tests/awk/cases/p.49 delete mode 100644 src/spec-tests/awk/cases/p.5 delete mode 100644 src/spec-tests/awk/cases/p.50 delete mode 100644 src/spec-tests/awk/cases/p.51 delete mode 100644 src/spec-tests/awk/cases/p.52 delete mode 100644 src/spec-tests/awk/cases/p.5a delete mode 100644 src/spec-tests/awk/cases/p.6 delete mode 100644 src/spec-tests/awk/cases/p.7 delete mode 100644 src/spec-tests/awk/cases/p.8 delete mode 100644 src/spec-tests/awk/cases/p.9 delete mode 100644 src/spec-tests/awk/cases/p.table delete mode 100644 src/spec-tests/awk/cases/penicil.p delete mode 100644 src/spec-tests/awk/cases/res.p delete mode 100644 src/spec-tests/awk/cases/sgi.ctimes delete mode 100644 src/spec-tests/awk/cases/t.0 delete mode 100644 src/spec-tests/awk/cases/t.0a delete mode 100644 src/spec-tests/awk/cases/t.1 delete mode 100644 src/spec-tests/awk/cases/t.1.x delete mode 100644 src/spec-tests/awk/cases/t.2 delete mode 100644 src/spec-tests/awk/cases/t.2.x delete mode 100644 src/spec-tests/awk/cases/t.3 delete mode 100644 src/spec-tests/awk/cases/t.3.x delete mode 100644 src/spec-tests/awk/cases/t.4 delete mode 100644 src/spec-tests/awk/cases/t.4.x delete mode 100644 src/spec-tests/awk/cases/t.5.x delete mode 100644 src/spec-tests/awk/cases/t.6 delete mode 100644 src/spec-tests/awk/cases/t.6.x delete mode 100644 src/spec-tests/awk/cases/t.6a delete mode 100644 src/spec-tests/awk/cases/t.6b delete mode 100644 src/spec-tests/awk/cases/t.8.x delete mode 100644 src/spec-tests/awk/cases/t.8.y delete mode 100644 src/spec-tests/awk/cases/t.NF delete mode 100644 src/spec-tests/awk/cases/t.a delete mode 100644 src/spec-tests/awk/cases/t.addops delete mode 100644 src/spec-tests/awk/cases/t.aeiou delete mode 100644 src/spec-tests/awk/cases/t.aeiouy delete mode 100644 src/spec-tests/awk/cases/t.arith delete mode 100644 src/spec-tests/awk/cases/t.array delete mode 100644 src/spec-tests/awk/cases/t.array1 delete mode 100644 src/spec-tests/awk/cases/t.array2 delete mode 100644 src/spec-tests/awk/cases/t.assert delete mode 100644 src/spec-tests/awk/cases/t.avg delete mode 100644 src/spec-tests/awk/cases/t.b.x delete mode 100644 src/spec-tests/awk/cases/t.be delete mode 100644 src/spec-tests/awk/cases/t.beginexit delete mode 100644 src/spec-tests/awk/cases/t.beginnext delete mode 100644 src/spec-tests/awk/cases/t.break delete mode 100644 src/spec-tests/awk/cases/t.break1 delete mode 100644 src/spec-tests/awk/cases/t.break2 delete mode 100644 src/spec-tests/awk/cases/t.break3 delete mode 100644 src/spec-tests/awk/cases/t.bug1 delete mode 100644 src/spec-tests/awk/cases/t.builtins delete mode 100644 src/spec-tests/awk/cases/t.cat delete mode 100644 src/spec-tests/awk/cases/t.cat1 delete mode 100644 src/spec-tests/awk/cases/t.cat2 delete mode 100644 src/spec-tests/awk/cases/t.cmp delete mode 100644 src/spec-tests/awk/cases/t.coerce delete mode 100644 src/spec-tests/awk/cases/t.coerce2 delete mode 100644 src/spec-tests/awk/cases/t.comment delete mode 100644 src/spec-tests/awk/cases/t.comment1 delete mode 100644 src/spec-tests/awk/cases/t.concat delete mode 100644 src/spec-tests/awk/cases/t.cond delete mode 100644 src/spec-tests/awk/cases/t.contin delete mode 100644 src/spec-tests/awk/cases/t.count delete mode 100644 src/spec-tests/awk/cases/t.crlf delete mode 100644 src/spec-tests/awk/cases/t.cum delete mode 100644 src/spec-tests/awk/cases/t.d.x delete mode 100644 src/spec-tests/awk/cases/t.delete0 delete mode 100644 src/spec-tests/awk/cases/t.delete1 delete mode 100644 src/spec-tests/awk/cases/t.delete2 delete mode 100644 src/spec-tests/awk/cases/t.delete3 delete mode 100644 src/spec-tests/awk/cases/t.do delete mode 100644 src/spec-tests/awk/cases/t.e delete mode 100644 src/spec-tests/awk/cases/t.else delete mode 100644 src/spec-tests/awk/cases/t.exit delete mode 100644 src/spec-tests/awk/cases/t.exit1 delete mode 100644 src/spec-tests/awk/cases/t.f delete mode 100644 src/spec-tests/awk/cases/t.f.x delete mode 100644 src/spec-tests/awk/cases/t.f0 delete mode 100644 src/spec-tests/awk/cases/t.f1 delete mode 100644 src/spec-tests/awk/cases/t.f2 delete mode 100644 src/spec-tests/awk/cases/t.f3 delete mode 100644 src/spec-tests/awk/cases/t.f4 delete mode 100644 src/spec-tests/awk/cases/t.for delete mode 100644 src/spec-tests/awk/cases/t.for1 delete mode 100644 src/spec-tests/awk/cases/t.for2 delete mode 100644 src/spec-tests/awk/cases/t.for3 delete mode 100644 src/spec-tests/awk/cases/t.format4 delete mode 100644 src/spec-tests/awk/cases/t.fun delete mode 100644 src/spec-tests/awk/cases/t.fun0 delete mode 100644 src/spec-tests/awk/cases/t.fun1 delete mode 100644 src/spec-tests/awk/cases/t.fun2 delete mode 100644 src/spec-tests/awk/cases/t.fun3 delete mode 100644 src/spec-tests/awk/cases/t.fun4 delete mode 100644 src/spec-tests/awk/cases/t.fun5 delete mode 100644 src/spec-tests/awk/cases/t.getline1 delete mode 100644 src/spec-tests/awk/cases/t.getval delete mode 100644 src/spec-tests/awk/cases/t.gsub delete mode 100644 src/spec-tests/awk/cases/t.gsub1 delete mode 100644 src/spec-tests/awk/cases/t.gsub3 delete mode 100644 src/spec-tests/awk/cases/t.gsub4 delete mode 100644 src/spec-tests/awk/cases/t.i.x delete mode 100644 src/spec-tests/awk/cases/t.if delete mode 100644 src/spec-tests/awk/cases/t.in delete mode 100644 src/spec-tests/awk/cases/t.in1 delete mode 100644 src/spec-tests/awk/cases/t.in2 delete mode 100644 src/spec-tests/awk/cases/t.in3 delete mode 100644 src/spec-tests/awk/cases/t.incr delete mode 100644 src/spec-tests/awk/cases/t.incr2 delete mode 100644 src/spec-tests/awk/cases/t.incr3 delete mode 100644 src/spec-tests/awk/cases/t.index delete mode 100644 src/spec-tests/awk/cases/t.intest delete mode 100644 src/spec-tests/awk/cases/t.intest2 delete mode 100644 src/spec-tests/awk/cases/t.j.x delete mode 100644 src/spec-tests/awk/cases/t.longstr delete mode 100644 src/spec-tests/awk/cases/t.makef delete mode 100644 src/spec-tests/awk/cases/t.match delete mode 100644 src/spec-tests/awk/cases/t.match1 delete mode 100644 src/spec-tests/awk/cases/t.max delete mode 100644 src/spec-tests/awk/cases/t.mod delete mode 100644 src/spec-tests/awk/cases/t.monotone delete mode 100644 src/spec-tests/awk/cases/t.nameval delete mode 100644 src/spec-tests/awk/cases/t.next delete mode 100644 src/spec-tests/awk/cases/t.not delete mode 100644 src/spec-tests/awk/cases/t.null0 delete mode 100644 src/spec-tests/awk/cases/t.ofmt delete mode 100644 src/spec-tests/awk/cases/t.ofs delete mode 100644 src/spec-tests/awk/cases/t.ors delete mode 100644 src/spec-tests/awk/cases/t.pat delete mode 100644 src/spec-tests/awk/cases/t.pipe delete mode 100644 src/spec-tests/awk/cases/t.pp delete mode 100644 src/spec-tests/awk/cases/t.pp1 delete mode 100644 src/spec-tests/awk/cases/t.pp2 delete mode 100644 src/spec-tests/awk/cases/t.printf delete mode 100644 src/spec-tests/awk/cases/t.printf2 delete mode 100644 src/spec-tests/awk/cases/t.quote delete mode 100644 src/spec-tests/awk/cases/t.randk delete mode 100644 src/spec-tests/awk/cases/t.re1 delete mode 100644 src/spec-tests/awk/cases/t.re1a delete mode 100644 src/spec-tests/awk/cases/t.re2 delete mode 100644 src/spec-tests/awk/cases/t.re3 delete mode 100644 src/spec-tests/awk/cases/t.re4 delete mode 100644 src/spec-tests/awk/cases/t.re5 delete mode 100644 src/spec-tests/awk/cases/t.re7 delete mode 100644 src/spec-tests/awk/cases/t.reFS delete mode 100644 src/spec-tests/awk/cases/t.rec delete mode 100644 src/spec-tests/awk/cases/t.redir1 delete mode 100644 src/spec-tests/awk/cases/t.reg delete mode 100644 src/spec-tests/awk/cases/t.roff delete mode 100644 src/spec-tests/awk/cases/t.sep delete mode 100644 src/spec-tests/awk/cases/t.seqno delete mode 100644 src/spec-tests/awk/cases/t.set0 delete mode 100644 src/spec-tests/awk/cases/t.set0a delete mode 100644 src/spec-tests/awk/cases/t.set0b delete mode 100644 src/spec-tests/awk/cases/t.set1 delete mode 100644 src/spec-tests/awk/cases/t.set2 delete mode 100644 src/spec-tests/awk/cases/t.set3 delete mode 100644 src/spec-tests/awk/cases/t.split1 delete mode 100644 src/spec-tests/awk/cases/t.split2 delete mode 100644 src/spec-tests/awk/cases/t.split2a delete mode 100644 src/spec-tests/awk/cases/t.split3 delete mode 100644 src/spec-tests/awk/cases/t.split4 delete mode 100644 src/spec-tests/awk/cases/t.split8 delete mode 100644 src/spec-tests/awk/cases/t.split9 delete mode 100644 src/spec-tests/awk/cases/t.split9a delete mode 100644 src/spec-tests/awk/cases/t.stately delete mode 100644 src/spec-tests/awk/cases/t.strcmp delete mode 100644 src/spec-tests/awk/cases/t.strcmp1 delete mode 100644 src/spec-tests/awk/cases/t.strnum delete mode 100644 src/spec-tests/awk/cases/t.sub0 delete mode 100644 src/spec-tests/awk/cases/t.sub1 delete mode 100644 src/spec-tests/awk/cases/t.sub2 delete mode 100644 src/spec-tests/awk/cases/t.sub3 delete mode 100644 src/spec-tests/awk/cases/t.substr delete mode 100644 src/spec-tests/awk/cases/t.substr1 delete mode 100644 src/spec-tests/awk/cases/t.time delete mode 100644 src/spec-tests/awk/cases/t.vf delete mode 100644 src/spec-tests/awk/cases/t.vf1 delete mode 100644 src/spec-tests/awk/cases/t.vf2 delete mode 100644 src/spec-tests/awk/cases/t.vf3 delete mode 100644 src/spec-tests/awk/cases/t.x delete mode 100644 src/spec-tests/awk/cases/td.1 delete mode 100644 src/spec-tests/awk/cases/test.countries delete mode 100644 src/spec-tests/awk/cases/test.data delete mode 100644 src/spec-tests/awk/cases/time.c delete mode 100755 src/spec-tests/awk/cases/try delete mode 100644 src/spec-tests/awk/cases/tt.01 delete mode 100644 src/spec-tests/awk/cases/tt.02 delete mode 100644 src/spec-tests/awk/cases/tt.02a delete mode 100644 src/spec-tests/awk/cases/tt.03 delete mode 100644 src/spec-tests/awk/cases/tt.03a delete mode 100644 src/spec-tests/awk/cases/tt.04 delete mode 100644 src/spec-tests/awk/cases/tt.05 delete mode 100644 src/spec-tests/awk/cases/tt.06 delete mode 100644 src/spec-tests/awk/cases/tt.07 delete mode 100644 src/spec-tests/awk/cases/tt.08 delete mode 100644 src/spec-tests/awk/cases/tt.09 delete mode 100644 src/spec-tests/awk/cases/tt.10 delete mode 100644 src/spec-tests/awk/cases/tt.10a delete mode 100644 src/spec-tests/awk/cases/tt.11 delete mode 100644 src/spec-tests/awk/cases/tt.12 delete mode 100644 src/spec-tests/awk/cases/tt.13 delete mode 100644 src/spec-tests/awk/cases/tt.13a delete mode 100644 src/spec-tests/awk/cases/tt.14 delete mode 100644 src/spec-tests/awk/cases/tt.15 delete mode 100644 src/spec-tests/awk/cases/tt.16 delete mode 100644 src/spec-tests/awk/cases/tt.big delete mode 100644 src/spec-tests/awk/cases/u.main delete mode 100644 src/spec-tests/awk/cases/unbundle.awk delete mode 100755 src/spec-tests/awk/cases/yc delete mode 100644 src/spec-tests/awk/parser-test-styles.ts delete mode 100644 src/spec-tests/awk/parser.ts delete mode 100644 src/spec-tests/awk/runner.ts delete mode 100644 src/spec-tests/awk/skips.ts delete mode 100644 src/spec-tests/bash/KNOWN_LIMITATIONS.md delete mode 100644 src/spec-tests/bash/README.md delete mode 100644 src/spec-tests/bash/cases/LICENSE-APACHE-2.0.txt delete mode 100644 src/spec-tests/bash/cases/alias.test.sh delete mode 100644 src/spec-tests/bash/cases/append.test.sh delete mode 100644 src/spec-tests/bash/cases/arg-parse.test.sh delete mode 100644 src/spec-tests/bash/cases/arith-context.test.sh delete mode 100644 src/spec-tests/bash/cases/arith-dynamic.test.sh delete mode 100644 src/spec-tests/bash/cases/arith.test.sh delete mode 100644 src/spec-tests/bash/cases/array-assign.test.sh delete mode 100644 src/spec-tests/bash/cases/array-assoc.test.sh delete mode 100644 src/spec-tests/bash/cases/array-basic.test.sh delete mode 100644 src/spec-tests/bash/cases/array-compat.test.sh delete mode 100644 src/spec-tests/bash/cases/array-literal.test.sh delete mode 100644 src/spec-tests/bash/cases/array-sparse.test.sh delete mode 100644 src/spec-tests/bash/cases/array.test.sh delete mode 100644 src/spec-tests/bash/cases/assign-deferred.test.sh delete mode 100644 src/spec-tests/bash/cases/assign-dialects.test.sh delete mode 100644 src/spec-tests/bash/cases/assign-extended.test.sh delete mode 100644 src/spec-tests/bash/cases/assign.test.sh delete mode 100644 src/spec-tests/bash/cases/background.test.sh delete mode 100644 src/spec-tests/bash/cases/ble-features.test.sh delete mode 100644 src/spec-tests/bash/cases/ble-idioms.test.sh delete mode 100644 src/spec-tests/bash/cases/ble-unset.test.sh delete mode 100644 src/spec-tests/bash/cases/blog-other1.test.sh delete mode 100644 src/spec-tests/bash/cases/blog1.test.sh delete mode 100644 src/spec-tests/bash/cases/blog2.test.sh delete mode 100644 src/spec-tests/bash/cases/bool-parse.test.sh delete mode 100644 src/spec-tests/bash/cases/brace-expansion.test.sh delete mode 100644 src/spec-tests/bash/cases/bugs.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-bash.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-bind.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-bracket.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-cd.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-completion.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-dirs.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-echo.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-eval-source.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-fc.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-getopts.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-history.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-kill.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-meta-assign.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-meta.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-misc.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-printf.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-process.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-read.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-set.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-special.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-times.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-trap-bash.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-trap-err.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-trap.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-type-bash.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-type.test.sh delete mode 100644 src/spec-tests/bash/cases/builtin-vars.test.sh delete mode 100644 src/spec-tests/bash/cases/case_.test.sh delete mode 100644 src/spec-tests/bash/cases/command-parsing.test.sh delete mode 100644 src/spec-tests/bash/cases/command-sub-ksh.test.sh delete mode 100644 src/spec-tests/bash/cases/command-sub.test.sh delete mode 100644 src/spec-tests/bash/cases/command_.test.sh delete mode 100644 src/spec-tests/bash/cases/comments.test.sh delete mode 100644 src/spec-tests/bash/cases/dbracket.test.sh delete mode 100644 src/spec-tests/bash/cases/divergence.test.sh delete mode 100644 src/spec-tests/bash/cases/dparen.test.sh delete mode 100644 src/spec-tests/bash/cases/empty-bodies.test.sh delete mode 100644 src/spec-tests/bash/cases/errexit.test.sh delete mode 100644 src/spec-tests/bash/cases/exit-status.test.sh delete mode 100644 src/spec-tests/bash/cases/explore-parsing.test.sh delete mode 100644 src/spec-tests/bash/cases/extglob-files.test.sh delete mode 100644 src/spec-tests/bash/cases/extglob-match.test.sh delete mode 100644 src/spec-tests/bash/cases/fatal-errors.test.sh delete mode 100644 src/spec-tests/bash/cases/for-expr.test.sh delete mode 100644 src/spec-tests/bash/cases/func-parsing.test.sh delete mode 100644 src/spec-tests/bash/cases/glob-bash.test.sh delete mode 100644 src/spec-tests/bash/cases/glob.test.sh delete mode 100644 src/spec-tests/bash/cases/globignore.test.sh delete mode 100644 src/spec-tests/bash/cases/globstar.test.sh delete mode 100644 src/spec-tests/bash/cases/here-doc.test.sh delete mode 100644 src/spec-tests/bash/cases/if_.test.sh delete mode 100644 src/spec-tests/bash/cases/interactive-parse.test.sh delete mode 100644 src/spec-tests/bash/cases/interactive.test.sh delete mode 100644 src/spec-tests/bash/cases/introspect.test.sh delete mode 100644 src/spec-tests/bash/cases/known-differences.test.sh delete mode 100644 src/spec-tests/bash/cases/let.test.sh delete mode 100644 src/spec-tests/bash/cases/loop.test.sh delete mode 100644 src/spec-tests/bash/cases/nameref.test.sh delete mode 100644 src/spec-tests/bash/cases/nix-idioms.test.sh delete mode 100644 src/spec-tests/bash/cases/nocasematch-match.test.sh delete mode 100644 src/spec-tests/bash/cases/nul-bytes.test.sh delete mode 100644 src/spec-tests/bash/cases/paren-ambiguity.test.sh delete mode 100644 src/spec-tests/bash/cases/parse-errors.test.sh delete mode 100644 src/spec-tests/bash/cases/pipeline.test.sh delete mode 100644 src/spec-tests/bash/cases/posix.test.sh delete mode 100644 src/spec-tests/bash/cases/print-source-code.test.sh delete mode 100644 src/spec-tests/bash/cases/process-sub.test.sh delete mode 100644 src/spec-tests/bash/cases/prompt.test.sh delete mode 100644 src/spec-tests/bash/cases/quote.test.sh delete mode 100644 src/spec-tests/bash/cases/redir-order.test.sh delete mode 100644 src/spec-tests/bash/cases/redirect-command.test.sh delete mode 100644 src/spec-tests/bash/cases/redirect-multi.test.sh delete mode 100644 src/spec-tests/bash/cases/redirect.test.sh delete mode 100644 src/spec-tests/bash/cases/regex.test.sh delete mode 100644 src/spec-tests/bash/cases/serialize.test.sh delete mode 100644 src/spec-tests/bash/cases/sh-func.test.sh delete mode 100644 src/spec-tests/bash/cases/sh-options-bash.test.sh delete mode 100644 src/spec-tests/bash/cases/sh-options.test.sh delete mode 100644 src/spec-tests/bash/cases/sh-usage.test.sh delete mode 100644 src/spec-tests/bash/cases/shell-bugs.test.sh delete mode 100644 src/spec-tests/bash/cases/shell-grammar.test.sh delete mode 100644 src/spec-tests/bash/cases/smoke.test.sh delete mode 100644 src/spec-tests/bash/cases/spec-harness-bug.test.sh delete mode 100644 src/spec-tests/bash/cases/strict-options.test.sh delete mode 100644 src/spec-tests/bash/cases/subshell.test.sh delete mode 100644 src/spec-tests/bash/cases/temp-binding.test.sh delete mode 100644 src/spec-tests/bash/cases/tilde.test.sh delete mode 100644 src/spec-tests/bash/cases/toysh-posix.test.sh delete mode 100644 src/spec-tests/bash/cases/toysh.test.sh delete mode 100644 src/spec-tests/bash/cases/type-compat.test.sh delete mode 100644 src/spec-tests/bash/cases/unicode.test.sh delete mode 100644 src/spec-tests/bash/cases/var-num.test.sh delete mode 100644 src/spec-tests/bash/cases/var-op-bash.test.sh delete mode 100644 src/spec-tests/bash/cases/var-op-len.test.sh delete mode 100644 src/spec-tests/bash/cases/var-op-patsub.test.sh delete mode 100644 src/spec-tests/bash/cases/var-op-slice.test.sh delete mode 100644 src/spec-tests/bash/cases/var-op-strip.test.sh delete mode 100644 src/spec-tests/bash/cases/var-op-test.test.sh delete mode 100644 src/spec-tests/bash/cases/var-ref.test.sh delete mode 100644 src/spec-tests/bash/cases/var-sub-quote.test.sh delete mode 100644 src/spec-tests/bash/cases/var-sub.test.sh delete mode 100644 src/spec-tests/bash/cases/vars-bash.test.sh delete mode 100644 src/spec-tests/bash/cases/vars-special.test.sh delete mode 100644 src/spec-tests/bash/cases/whitespace.test.sh delete mode 100644 src/spec-tests/bash/cases/word-eval.test.sh delete mode 100644 src/spec-tests/bash/cases/word-split.test.sh delete mode 100644 src/spec-tests/bash/cases/xtrace.test.sh delete mode 100644 src/spec-tests/bash/cases/zsh-assoc.test.sh delete mode 100644 src/spec-tests/bash/cases/zsh-idioms.test.sh delete mode 100644 src/spec-tests/bash/spec.test.ts delete mode 100644 src/spec-tests/grep/cases/LICENSE-busybox delete mode 100644 src/spec-tests/grep/cases/LICENSE-gnu-grep delete mode 100755 src/spec-tests/grep/cases/busybox-grep.tests delete mode 100644 src/spec-tests/grep/cases/gnu-bre.tests delete mode 100644 src/spec-tests/grep/cases/gnu-ere.tests delete mode 100644 src/spec-tests/grep/cases/gnu-spencer1.tests delete mode 100644 src/spec-tests/grep/cases/gnu-spencer2.tests delete mode 100644 src/spec-tests/grep/grep-spec.test.ts delete mode 100644 src/spec-tests/grep/parser.ts delete mode 100644 src/spec-tests/grep/runner.ts delete mode 100644 src/spec-tests/grep/skips.ts delete mode 100644 src/spec-tests/jq/cases/LICENSE delete mode 100644 src/spec-tests/jq/cases/base64.test delete mode 100644 src/spec-tests/jq/cases/jq.test delete mode 100644 src/spec-tests/jq/cases/man.test delete mode 100644 src/spec-tests/jq/cases/manonig.test delete mode 100644 src/spec-tests/jq/cases/onig.test delete mode 100644 src/spec-tests/jq/cases/optional.test delete mode 100644 src/spec-tests/jq/cases/uri.test delete mode 100644 src/spec-tests/jq/jq-spec.test.ts delete mode 100644 src/spec-tests/jq/parser.ts delete mode 100644 src/spec-tests/jq/runner.ts delete mode 100644 src/spec-tests/jq/skips.ts delete mode 100644 src/spec-tests/parser.ts delete mode 100644 src/spec-tests/runner.ts delete mode 100644 src/spec-tests/sed/cases/LICENSE-busybox delete mode 100644 src/spec-tests/sed/cases/LICENSE-pythonsed delete mode 100755 src/spec-tests/sed/cases/busybox-sed.tests delete mode 100644 src/spec-tests/sed/cases/pythonsed-chang.suite delete mode 100644 src/spec-tests/sed/cases/pythonsed-unit.suite delete mode 100644 src/spec-tests/sed/parser.ts delete mode 100644 src/spec-tests/sed/runner.ts delete mode 100644 src/spec-tests/sed/sed-spec.test.ts delete mode 100644 src/spec-tests/sed/skips.ts delete mode 100644 src/spec-tests/test-commands.ts delete mode 100644 src/syntax/break-continue.test.ts delete mode 100644 src/syntax/case-statement.test.ts delete mode 100644 src/syntax/command-substitution.test.ts delete mode 100644 src/syntax/composition.test.ts delete mode 100644 src/syntax/control-flow.test.ts delete mode 100644 src/syntax/execution-protection.test.ts delete mode 100644 src/syntax/here-document.test.ts delete mode 100644 src/syntax/loops.test.ts delete mode 100644 src/syntax/operators.test.ts delete mode 100644 src/syntax/parse-errors.test.ts delete mode 100644 src/syntax/parser-edge-cases.test.ts delete mode 100644 src/syntax/parser-protection.test.ts delete mode 100644 src/syntax/set-errexit.test.ts delete mode 100644 src/syntax/set-pipefail.test.ts delete mode 100644 src/syntax/source.test.ts delete mode 100644 src/syntax/subshell-args.test.ts delete mode 100644 src/syntax/variables.test.ts delete mode 100644 src/test-utils/busybox-test-parser.ts delete mode 100644 src/types.ts delete mode 100644 src/utils/args.ts delete mode 100644 src/utils/constants.ts delete mode 100644 src/utils/file-reader.ts delete mode 100644 src/utils/glob.ts delete mode 100644 vitest.comparison.config.ts delete mode 100644 vitest.config.ts delete mode 100644 vitest.unit.config.ts diff --git a/.github/workflows/comparison-tests.yml b/.github/workflows/comparison-tests.yml deleted file mode 100644 index c9103754..00000000 --- a/.github/workflows/comparison-tests.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Comparison Tests - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - comparison-tests: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Run comparison tests - run: pnpm test:comparison - - - name: Run comparison tests in record mode - run: pnpm test:comparison:record - - - name: diff - run: git diff - - # Fail if there are any diffs - - name: Fail if there are any diffs - run: if [ -n "$(git diff --name-only)" ]; then exit 1; fi diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 6ae43edd..00000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Lint - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - lint: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Lint - run: pnpm lint - - - name: Knip - run: pnpm knip diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml deleted file mode 100644 index e5a6ba11..00000000 --- a/.github/workflows/typecheck.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Typecheck - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - typecheck: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Typecheck - run: pnpm typecheck - - - name: Build - run: pnpm build - - - name: Install example dependencies - run: pnpm install --frozen-lockfile - working-directory: examples/bash-agent - - - name: Typecheck example - run: pnpm typecheck - working-directory: examples/bash-agent diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml deleted file mode 100644 index 21f17aa1..00000000 --- a/.github/workflows/unit-tests.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Unit Tests - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - unit-tests: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build - run: pnpm build - - - name: Run unit tests - run: pnpm test:unit - - - name: Run unit tests - run: pnpm test:dist diff --git a/.gitignore b/.gitignore index a7250c20..12ced737 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,45 @@ -node_modules -package-lock.json -.DS_Store -.vscode -.idea -.env -.env.* -dist +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing /coverage -debug-*.ts -test-*.ts -profile-*.mjs -*.cpuprofile -bench-*.mjs -todo/ -*.parsed.json -.pnpm-store -.docs-test-tmp/ -src/commands/python3/worker.js -fuzz-*.log + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +.env*.local + +# agent data (fetched at build time) +/app/api/agent/_agent-data/ diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 9b68add6..00000000 --- a/.npmignore +++ /dev/null @@ -1,24 +0,0 @@ -# Test files -**/*.test.js -**/*.test.d.ts - -# Source files -src/ - -# Development files -*.config.ts -*.config.js -tsconfig.json -biome.json -knip.json - -# CI/CD -.github/ - -# Editor -.vscode/ - -# Other -.gitignore -AGENTS.md -fix-spec-tests.md diff --git a/examples/website/.npmrc b/.npmrc similarity index 100% rename from examples/website/.npmrc rename to .npmrc diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index c01958d6..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,101 +0,0 @@ -# Agent instructions - -- use `pnpm dev:exec` for evaluating scripts using BashEnv during development. See Debugging info below. -- Install packages via pnpm rather than editing package.json directly -- Bias towards making new test files that are roughly logically grouped rather than letting test files gets too large. Try to stay below 300 lines. Prefer making a new file when you want to add a `describe()` -- Prefer asserting the full STDOUT/STDERR output rather than using toContain or not.toContain -- Always also add `comparison-tests` for major command functionality, but edge cases should always be covered in unit tests which are mush faster (`pnpm test:comparison`) -- When you are unsure about bash/command behavior, create a `comparison-tests` test file to ensure compat. -- `--help` does not need to pass comparison tests and should reflect actual capability -- Commands must handle unknown arguments correctly -- Always ensure all tests pass in the end and there are no compile and lint errors -- Use `pnpm lint:fix` -- Always also run `pnpm knip` -- Strongly prefer running a temporary comparison test or unit test over an ad-hoc script to figure out the behavior of some bash script or API. -- The implementation should align with the real behavior of bash, not what is convenient for TS or TE tests. -- Always make sure to build before using dist -- Biome rules often have the same name as eslint rules (if you are lookinf for one) -- Error / show usage on unknown flags in commands and built-ins (unless real bash also ignores) -- Dependencies that use wasm are not allowed (exception: sql.js for SQLite, approved for security sandboxing). Binary npm packages are fine -- When you implement multiple tasks (such as multiple commands or builtins or discovered bugs), so them one at a time, create tests, validate, and then move on -- Running tests does not require building first - -## Debugging - -- Don't use `cat > test-direct.ts << 'SCRIPT'` style test scripts because they constantly require 1-off approval. -- Instead use `pnpm dev:exec` - - use `--real-bash` to also get comparison output from the system bash - - use `--print-ast` to also print the AST of the program as parsed by our parser.ts - -## Commands - -- Must have usage statement -- Must error on unknown options (unless bash ignores them) -- Must have extensive unit tests collocated with the command -- Should have comparison tests if there is doubt about behavior - -## Interpreter - -- We explicitly don't support 64bit integers -- Must never hang. All parsing and execution should have reasonable max limits to avoid runaway compute. - -## Prototype Pollution Defense - -User-controlled data (stdin, arguments, file content, HTTP headers, environment variables) can become JavaScript object keys. To prevent prototype pollution attacks: - -### Rules - -1. **Always use `Object.create(null)` for objects with user-controlled keys:** - ```typescript - // BAD - vulnerable to prototype pollution - const obj: Record = {}; - obj[userKey] = value; // userKey could be "__proto__" or "constructor" - - // GOOD - safe from prototype pollution - const obj: Record = Object.create(null); - obj[userKey] = value; // null-prototype prevents prototype chain access - ``` - -2. **Use `Map` instead of plain objects when possible** - Maps don't have prototype pollution issues. - -3. **Use helper functions from `src/helpers/env.ts`:** - - `mapToRecord()` - safely converts Map to null-prototype Record - - `mapToRecordWithExtras()` - same but merges extra properties - - `mergeToNullPrototype()` - safely merges objects - -4. **Use safe-object utilities from `src/commands/query-engine/safe-object.ts`:** - - `isSafeKey()` - checks if key is safe (not `__proto__`, `constructor`, `prototype`) - - `safeSet()` - sets property only if key is safe - - `safeFromEntries()` - creates null-prototype object from entries - - `nullPrototypeCopy(obj)` - creates a null-prototype shallow copy of an object - - `nullPrototypeMerge(...objs)` - merges objects into a new null-prototype object - -5. **Prefer `nullPrototypeCopy` over object spread for user data:** - ```typescript - // BAD - spread creates object with Object.prototype - const copy = { ...userObject }; - - // GOOD - null-prototype copy - const copy = nullPrototypeCopy(userObject); - - // BAD - merging user objects - const merged = { ...objA, ...objB }; - - // GOOD - null-prototype merge - const merged = nullPrototypeMerge(objA, objB); - ``` - -### Common Vulnerable Patterns - -- HTTP header parsing (curl, fetch responses) -- CSV/JSON/YAML parsing where keys come from data -- Command argument parsing -- Environment variable handling -- AWK/jq variable and array storage - -### Testing - -Add prototype pollution tests for any code that stores user-controlled keys: -- Test with keywords: `constructor`, `__proto__`, `prototype`, `hasOwnProperty`, `toString`, `valueOf` -- Verify `Object.prototype` is not modified after processing -- See existing tests in `src/interpreter/prototype-pollution.test.ts` and `src/commands/*/prototype-pollution.test.ts` diff --git a/AGENTS.npm.md b/AGENTS.npm.md deleted file mode 100644 index 49247451..00000000 --- a/AGENTS.npm.md +++ /dev/null @@ -1,329 +0,0 @@ - - -# AGENTS.md - just-bash - -Instructions for AI agents using just-bash in projects. - -## What is just-bash? - -A sandboxed bash interpreter with an in-memory virtual filesystem. Use it when you need to: - -- Execute shell commands without real filesystem access -- Run untrusted scripts safely -- Process text with standard Unix tools (grep, sed, awk, jq, etc.) - -## For AI Agents - -If you're building an AI agent that needs a bash tool, use [`bash-tool`](https://github.com/vercel-labs/bash-tool) which is optimized for just-bash: - -```sh -npm install bash-tool -``` - -```typescript -import { createBashTool } from "bash-tool"; -import { generateText } from "ai"; - -const bashTool = createBashTool({ - files: { "/data/users.json": '[{"name": "Alice"}, {"name": "Bob"}]' }, -}); - -const result = await generateText({ - model: "anthropic/claude-sonnet-4", - tools: { bash: bashTool }, - prompt: "Count the users in /data/users.json", -}); -``` - -See the [bash-tool documentation](https://github.com/vercel-labs/bash-tool) for more details. - -## Quick Reference - -```typescript -import { Bash } from "just-bash"; - -const bash = new Bash({ - files: { "/data/input.txt": "content" }, // Initial files - cwd: "/data", // Working directory -}); - -const result = await bash.exec("cat input.txt | grep pattern"); -// result.stdout - command output -// result.stderr - error output -// result.exitCode - 0 = success, non-zero = failure -``` - -## Key Behaviors - -1. **Isolation**: Each `exec()` call is isolated. Environment variables, functions, and cwd changes don't persist between calls. Only filesystem changes persist. - -2. **No real filesystem**: By default, commands only see the virtual filesystem. Use `OverlayFs` to read from a real directory (writes stay in memory). - -3. **No network by default**: `curl` doesn't exist unless you configure `network` options with URL allowlists. - -4. **No binaries/WASM**: Only built-in commands work. You cannot run node, python, or other binaries. - -## Available Commands - -**Text processing**: `awk`, `cat`, `column`, `comm`, `cut`, `egrep`, `expand`, `fgrep`, `fold`, `grep`, `head`, `join`, `nl`, `paste`, `rev`, `rg`, `sed`, `sort`, `strings`, `tac`, `tail`, `tr`, `unexpand`, `uniq`, `wc`, `xargs` - -**Data processing**: `jq` (JSON), `python3`/`python` (Python via Pyodide), `sqlite3` (SQLite), `xan` (CSV), `yq` (YAML/XML/TOML/CSV) - -**File operations**: `basename`, `chmod`, `cp`, `dirname`, `du`, `file`, `find`, `ln`, `ls`, `mkdir`, `mv`, `od`, `pwd`, `readlink`, `rm`, `rmdir`, `split`, `stat`, `touch`, `tree` - -**Utilities**: `alias`, `base64`, `bash`, `clear`, `curl`, `date`, `diff`, `echo`, `env`, `expr`, `false`, `gzip`, `gunzip`, `help`, `history`, `hostname`, `html-to-markdown`, `md5sum`, `printenv`, `printf`, `seq`, `sh`, `sha1sum`, `sha256sum`, `sleep`, `tar`, `tee`, `time`, `timeout`, `true`, `unalias`, `which`, `whoami`, `zcat` - -All commands support `--help` for usage details. - -## Tools by File Format - -### JSON - `jq` - -```bash -# Extract field -jq '.name' data.json - -# Filter array -jq '.users[] | select(.active == true)' data.json - -# Transform structure -jq '[.items[] | {id, name}]' data.json - -# From stdin -echo '{"x":1}' | jq '.x' -``` - -### YAML - `yq` - -```bash -# Extract value -yq '.config.database.host' config.yaml - -# Output as JSON -yq -o json '.' config.yaml - -# Filter with jq syntax -yq '.users[] | select(.role == "admin")' users.yaml - -# Modify file in-place -yq -i '.version = "2.0"' config.yaml -``` - -### TOML - `yq` - -```bash -# Read TOML (auto-detected from .toml extension) -yq '.package.name' Cargo.toml -yq '.tool.poetry.version' pyproject.toml - -# Convert TOML to JSON -yq -o json '.' config.toml - -# Convert YAML to TOML -yq -o toml '.' config.yaml -``` - -### CSV/TSV - `yq -p csv` - -```bash -# Read CSV (auto-detects from .csv/.tsv extension) -yq '.[0].name' data.csv -yq '.[0].name' data.tsv - -# Filter rows -yq '[.[] | select(.status == "active")]' data.csv - -# Convert CSV to JSON -yq -o json '.' data.csv -``` - -### Front-matter - `yq --front-matter` - -```bash -# Extract YAML front-matter from markdown -yq --front-matter '.title' post.md -yq -f '.tags[]' blog-post.md - -# Works with TOML front-matter (+++) too -yq -f '.date' hugo-post.md -``` - -### XML - `yq -p xml` - -```bash -# Extract element -yq '.root.users.user[0].name' data.xml - -# Access attributes (use +@ prefix) -yq '.root.item["+@id"]' data.xml - -# Convert XML to JSON -yq -p xml -o json '.' data.xml -``` - -### INI - `yq -p ini` - -```bash -# Read INI section value -yq '.database.host' config.ini - -# Convert INI to JSON -yq -p ini -o json '.' config.ini -``` - -### HTML - `html-to-markdown` - -```bash -# Convert HTML to markdown -html-to-markdown page.html - -# From stdin -echo '

Title

Text

' | html-to-markdown -``` - -### Format Conversion with yq - -```bash -# JSON to YAML -yq -p json '.' data.json - -# YAML to JSON -yq -o json '.' data.yaml - -# YAML to TOML -yq -o toml '.' config.yaml - -# TOML to JSON -yq -o json '.' Cargo.toml - -# CSV to JSON -yq -p csv -o json '.' data.csv - -# XML to YAML -yq -p xml '.' data.xml -``` - -## Common Patterns - -### Process JSON with jq - -```bash -cat data.json | jq '.items[] | select(.active) | .name' -``` - -### Find and process files - -```bash -find . -name "*.ts" -type f | xargs grep -l "TODO" -``` - -### Text transformation pipeline - -```bash -cat input.txt | grep -v "^#" | sort | uniq -c | sort -rn | head -10 -``` - -### AWK for columnar data - -```bash -cat data.csv | awk -F',' '{sum += $3} END {print sum}' -``` - -## Limitations - -- **32-bit integers only**: Arithmetic operations use 32-bit signed integers -- **No job control**: No `&`, `bg`, `fg`, or process suspension -- **No external binaries**: Only built-in commands are available -- **Execution limits**: Loops, recursion, command counts, and output sizes have configurable limits to prevent runaway execution (exit code 126 when exceeded) - -## Error Handling - -Always check `exitCode`: - -```typescript -import { Bash } from "just-bash"; - -const bash = new Bash({ files: { "/file.txt": "some content" } }); -const result = await bash.exec("grep pattern file.txt"); -if (result.exitCode !== 0) { - // Command failed - check result.stderr for details -} -``` - -Common exit codes: - -- `0` - Success -- `1` - General error or no matches (grep) -- `2` - Misuse of command (invalid options) -- `126` - Execution limit exceeded (loops, output size, string length) -- `127` - Command not found - -## Debugging Tips - -1. **Check stderr**: Error messages go to `result.stderr` -2. **Use --help**: All commands support `--help` for usage -3. **Test incrementally**: Build pipelines step by step -4. **Quote variables**: Use `"$var"` to handle spaces in values - -## Security Model - -- Virtual filesystem is isolated from the real system -- Network access requires explicit URL allowlists -- Execution limits prevent infinite loops and resource exhaustion -- No shell injection possible (commands are parsed, not eval'd) - -### Execution Limits - -All limits are configurable via `executionLimits` in `BashOptions`: - -```typescript -const bash = new Bash({ - executionLimits: { - maxCommandCount: 10000, // Max commands per exec() - maxLoopIterations: 10000, // Max bash loop iterations - maxCallDepth: 100, // Max function recursion depth - maxStringLength: 10485760, // Max string size (10MB) - maxArrayElements: 100000, // Max array elements - maxGlobOperations: 100000, // Max glob filesystem operations - maxAwkIterations: 10000, // Max AWK loop iterations - maxSedIterations: 10000, // Max sed branch loop iterations - maxJqIterations: 10000, // Max jq loop iterations - maxSubstitutionDepth: 50, // Max $() nesting depth - maxHeredocSize: 10485760, // Max heredoc size (10MB) - }, -}); -``` - -**Output size limits**: AWK, sed, jq, and printf enforce `maxStringLength` on their output buffers. Commands that exceed the limit exit with code 126. - -**File read size limits**: `OverlayFs` and `ReadWriteFs` default to a 10MB max file read size. Override with `maxFileReadSize` in filesystem options (set to `0` to disable). - -**Network response size**: `maxResponseSize` in `NetworkConfig` caps HTTP response bodies (default: 10MB). - -## Discovering Types - -TypeScript types are available in the `.d.ts` files. Use JSDoc-style exploration to understand the API: - -```bash -# Find all type definition files -find node_modules/just-bash/dist -name "*.d.ts" | head -20 - -# View main exports and their types -cat node_modules/just-bash/dist/index.d.ts - -# View Bash class options -grep -A 30 "interface BashOptions" node_modules/just-bash/dist/Bash.d.ts - -# Search for specific types -grep -r "interface.*Options" node_modules/just-bash/dist/*.d.ts -``` - -Key types to explore: -- `BashOptions` - Constructor options for `new Bash()` -- `ExecResult` - Return type of `bash.exec()` -- `InitialFiles` - File specification format diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 19e7ffbf..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,188 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -just-bash is a TypeScript implementation of a bash interpreter with an in-memory virtual filesystem. Designed for AI agents needing a secure, sandboxed bash environment. No WASM dependencies allowed. - -## Commands - -```bash -# Build & Lint -pnpm build # Build TypeScript (required before using dist/) -pnpm typecheck # Type check -pnpm lint:fix # Fix lint errors (biome) -pnpm knip # Check for unused exports/dependencies - -# Testing -pnpm test:run # Run ALL tests (including spec tests) -pnpm test:unit # Run unit tests only (fast, no comparison/spec) -pnpm test:comparison # Run comparison tests only (uses fixtures) -pnpm test:comparison:record # Re-record comparison test fixtures - -# Excluding spec tests (spec tests have known failures) -pnpm test:run --exclude src/spec-tests - -# Run specific test file -pnpm test:run src/commands/grep/grep.basic.test.ts - -# Run specific spec test file by name pattern -pnpm test:run src/spec-tests/spec.test.ts -t "arith.test.sh" -pnpm test:run src/spec-tests/spec.test.ts -t "array-basic.test.sh" - -# Interactive shell -pnpm shell # Full network access -pnpm shell --no-network # No network - -# Sandboxed CLI (read-only by default) -node ./dist/cli/just-bash.js -c 'ls -la' --root . -node ./dist/cli/just-bash.js -c 'cat package.json' --root . -node ./dist/cli/just-bash.js -c 'grep -r "TODO" src/' --root . -``` - -### Sandboxed Shell Execution with `just-bash` - -The `just-bash` CLI provides a secure, sandboxed bash environment using OverlayFS: - -```bash -# Execute inline script (read-only by default) -node ./dist/cli/just-bash.js -c 'ls -la && cat README.md | head -5' --root . - -# Execute with JSON output -node ./dist/cli/just-bash.js -c 'echo hello' --root . --json - -# Allow writes (writes stay in memory, don't affect real filesystem) -node ./dist/cli/just-bash.js -c 'echo test > /tmp/file.txt && cat /tmp/file.txt' --root . --allow-write - -# Execute script file -node ./dist/cli/just-bash.js script.sh --root . - -# Exit on first error -node ./dist/cli/just-bash.js -e -c 'false; echo "not reached"' --root . -``` - -Options: -- `--root ` - Root directory (default: current directory) -- `--cwd ` - Working directory in sandbox (default: /home/user/project) -- `--allow-write` - Enable write operations (writes stay in memory) -- `--json` - Output as JSON (stdout, stderr, exitCode) -- `-e, --errexit` - Exit on first error - -### Debug with `pnpm dev:exec` - -Reads script from stdin, executes it, shows output. Prefer this over ad-hoc test files. - -```bash -# Basic execution -echo 'echo hello' | pnpm dev:exec - -# Compare with real bash -echo 'x=5; echo $((x + 3))' | pnpm dev:exec --real-bash - -# Show parsed AST -echo 'for i in 1 2 3; do echo $i; done' | pnpm dev:exec --print-ast - -# Multi-line script -echo 'arr=(a b c) -for x in "${arr[@]}"; do - echo "item: $x" -done' | pnpm dev:exec --real-bash -``` - -## Architecture - -### Core Pipeline - -``` -Input Script → Parser (src/parser/) → AST (src/ast/) → Interpreter (src/interpreter/) → ExecResult -``` - -### Key Modules - -**Parser** (`src/parser/`): Recursive descent parser producing AST nodes - -- `lexer.ts` - Tokenizer with bash-specific handling (heredocs, quotes, expansions) -- `parser.ts` - Main parser orchestrating specialized sub-parsers -- `expansion-parser.ts` - Parameter expansion, command substitution parsing -- `compound-parser.ts` - if/for/while/case/function parsing - -**Interpreter** (`src/interpreter/`): AST execution engine - -- `interpreter.ts` - Main execution loop, command dispatch -- `expansion.ts` - Word expansion (parameter, brace, glob, tilde, command substitution) -- `arithmetic.ts` - `$((...))` and `((...))` evaluation -- `conditionals.ts` - `[[ ]]` and `[ ]` test evaluation -- `control-flow.ts` - Loops and conditionals execution -- `builtins/` - Shell builtins (export, local, declare, read, etc.) - -**Commands** (`src/commands/`): External command implementations - -- Each command in its own directory with implementation + tests -- Registry pattern via `registry.ts` - -**Filesystem** (`src/fs.ts`, `src/overlay-fs/`): In-memory VFS with optional overlay on real filesystem - -**AWK** (`src/commands/awk/`): AWK text processing implementation - -- `parser.ts` - Parses AWK programs (BEGIN/END blocks, rules, user-defined functions) -- `executor.ts` - Executes parsed AWK programs line by line -- `expressions.ts` - Expression evaluation (arithmetic, string functions, comparisons) -- Supports: field splitting, pattern matching, printf, gsub/sub/split, user-defined functions -- Limitations: User-defined functions support single return expressions only (no multi-statement bodies or if/else) - -**SED** (`src/commands/sed/`): Stream editor implementation - -- `parser.ts` - Parses sed commands and addresses -- `executor.ts` - Executes sed commands with pattern/hold space -- Supports: s, d, p, q, n, a, i, c, y, =, addresses, ranges, extended regex (-E/-r) -- Has execution limits to prevent runaway compute - -### Adding Commands - -Commands go in `src/commands//` with: - -1. Implementation file with usage statement -2. Unit tests (collocated `*.test.ts`) -3. Error on unknown options (unless real bash ignores them) -4. Comparison tests in `src/comparison-tests/` for behavior validation - -### Testing Strategy - -- **Unit tests**: Fast, isolated tests for specific functionality -- **Comparison tests**: Compare just-bash output against recorded bash fixtures (see `src/comparison-tests/README.md`) -- **Spec tests** (`src/spec-tests/`): Bash specification conformance (may have known failures) - -Prefer comparison tests when uncertain about bash behavior. Keep test files under 300 lines. - -### Comparison Tests (Fixture System) - -Comparison tests use pre-recorded bash outputs stored in `src/comparison-tests/fixtures/`. This eliminates platform differences (macOS vs Linux). See `src/comparison-tests/README.md` for details. - -```bash -# Run comparison tests (uses fixtures, no real bash needed) -pnpm test:comparison - -# Re-record fixtures (skips locked fixtures) -RECORD_FIXTURES=1 pnpm test:run src/comparison-tests/mytest.comparison.test.ts - -# Force re-record including locked fixtures -RECORD_FIXTURES=force pnpm test:comparison -``` - -When adding comparison tests: -1. Write the test using `setupFiles()` and `compareOutputs()` -2. Run with `RECORD_FIXTURES=1` to generate fixtures -3. Commit both the test file and the generated fixture JSON -4. If manually adjusting for Linux behavior, add `"locked": true` to the fixture - -## Development Guidelines - -- Read AGENTS.md -- Use `pnpm dev:exec` instead of ad-hoc test scripts (avoids approval prompts) -- Always verify with `pnpm typecheck && pnpm lint:fix && pnpm knip && pnpm test:run` before finishing -- Assert full stdout/stderr in tests, not partial matches -- Implementation must match real bash behavior, not convenience -- Dependencies using WASM are not allowed (exception: sql.js for SQLite, approved for security sandboxing) -- We explicitly don't support 64-bit integers -- All parsing/execution must have reasonable limits to prevent runaway compute diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 8226d363..00000000 --- a/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright 2025 Vercel Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/README.md b/README.md index 7825eae5..0fe9500c 100644 --- a/README.md +++ b/README.md @@ -1,458 +1,98 @@ -# just-bash - -A simulated bash environment with an in-memory virtual filesystem, written in TypeScript. - -Designed for AI agents that need a secure, sandboxed bash environment. - -Supports optional network access via `curl` with secure-by-default URL filtering. - -**Note**: This is beta software. Use at your own risk and please provide feedback. - -## Table of Contents - -- [Security model](#security-model) -- [Installation](#installation) -- [Usage](#usage) - - [Basic API](#basic-api) - - [Configuration](#configuration) - - [Custom Commands](#custom-commands) - - [Filesystem Options](#filesystem-options) - - [AI SDK Tool](#ai-sdk-tool) - - [Vercel Sandbox Compatible API](#vercel-sandbox-compatible-api) - - [CLI Binary](#cli-binary) - - [Interactive Shell](#interactive-shell) -- [Supported Commands](#supported-commands) -- [Shell Features](#shell-features) -- [Default Layout](#default-layout) -- [Network Access](#network-access) -- [Execution Protection](#execution-protection) -- [Development](#development) - -## Security model - -- The shell only has access to the provided file system. -- Execution is protected against infinite loops or recursion. However, Bash is not fully robust against DOS from input. If you need to be robust against this, use process isolation at the OS level. -- Binaries or even WASM are inherently unsupported (Use [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) or a similar product if a full VM is needed). -- There is no network access by default. -- Network access can be enabled, but requests are checked against URL prefix allow-lists and HTTP-method allow-lists. See [network access](#network-access) for details - -## Installation - -```bash -npm install just-bash -``` - -## Usage - -### Basic API - -```typescript -import { Bash } from "just-bash"; - -const env = new Bash(); -await env.exec('echo "Hello" > greeting.txt'); -const result = await env.exec("cat greeting.txt"); -console.log(result.stdout); // "Hello\n" -console.log(result.exitCode); // 0 -console.log(result.env); // Final environment after execution -``` - -Each `exec()` is isolated—env vars, functions, and cwd don't persist across calls (filesystem does). - -### Configuration - -```typescript -const env = new Bash({ - files: { "/data/file.txt": "content" }, // Initial files - env: { MY_VAR: "value" }, // Initial environment - cwd: "/app", // Starting directory (default: /home/user) - executionLimits: { maxCallDepth: 50 }, // See "Execution Protection" -}); - -// Per-exec overrides -await env.exec("echo $TEMP", { env: { TEMP: "value" }, cwd: "/tmp" }); -``` - -### Custom Commands - -Extend just-bash with your own TypeScript commands using `defineCommand`: - -```typescript -import { Bash, defineCommand } from "just-bash"; - -const hello = defineCommand("hello", async (args, ctx) => { - const name = args[0] || "world"; - return { stdout: `Hello, ${name}!\n`, stderr: "", exitCode: 0 }; -}); - -const upper = defineCommand("upper", async (args, ctx) => { - return { stdout: ctx.stdin.toUpperCase(), stderr: "", exitCode: 0 }; -}); - -const bash = new Bash({ customCommands: [hello, upper] }); - -await bash.exec("hello Alice"); // "Hello, Alice!\n" -await bash.exec("echo 'test' | upper"); // "TEST\n" -``` - -Custom commands receive the full `CommandContext` with access to `fs`, `cwd`, `env`, `stdin`, and `exec` for running subcommands. - -### Filesystem Options - -Four filesystem implementations are available: - -**InMemoryFs** (default) - Pure in-memory filesystem, no disk access: - -```typescript -import { Bash } from "just-bash"; -const env = new Bash(); // Uses InMemoryFs by default -``` - -**OverlayFs** - Copy-on-write over a real directory. Reads come from disk, writes stay in memory: - -```typescript -import { Bash } from "just-bash"; -import { OverlayFs } from "just-bash/fs/overlay-fs"; - -const overlay = new OverlayFs({ root: "/path/to/project" }); -const env = new Bash({ fs: overlay, cwd: overlay.getMountPoint() }); - -await env.exec("cat package.json"); // reads from disk -await env.exec('echo "modified" > package.json'); // stays in memory -``` - -**ReadWriteFs** - Direct read-write access to a real directory. Use this if you want the agent to be agle to write to your disk: - -```typescript -import { Bash } from "just-bash"; -import { ReadWriteFs } from "just-bash/fs/read-write-fs"; - -const rwfs = new ReadWriteFs({ root: "/path/to/sandbox" }); -const env = new Bash({ fs: rwfs }); - -await env.exec('echo "hello" > file.txt'); // writes to real filesystem -``` - -**MountableFs** - Mount multiple filesystems at different paths. Combines read-only and read-write filesystems into a unified namespace: - -```typescript -import { Bash, MountableFs, InMemoryFs } from "just-bash"; -import { OverlayFs } from "just-bash/fs/overlay-fs"; -import { ReadWriteFs } from "just-bash/fs/read-write-fs"; - -const fs = new MountableFs({ base: new InMemoryFs() }); - -// Mount read-only knowledge base -fs.mount("/mnt/knowledge", new OverlayFs({ root: "/path/to/knowledge", readOnly: true })); - -// Mount read-write workspace -fs.mount("/home/agent", new ReadWriteFs({ root: "/path/to/workspace" })); - -const bash = new Bash({ fs, cwd: "/home/agent" }); - -await bash.exec("ls /mnt/knowledge"); // reads from knowledge base -await bash.exec("cp /mnt/knowledge/doc.txt ./"); // cross-mount copy -await bash.exec('echo "notes" > notes.txt'); // writes to workspace -``` - -You can also configure mounts in the constructor: - -```typescript -import { MountableFs, InMemoryFs } from "just-bash"; -import { OverlayFs } from "just-bash/fs/overlay-fs"; -import { ReadWriteFs } from "just-bash/fs/read-write-fs"; - -const fs = new MountableFs({ - base: new InMemoryFs(), - mounts: [ - { mountPoint: "/data", filesystem: new OverlayFs({ root: "/shared/data" }) }, - { mountPoint: "/workspace", filesystem: new ReadWriteFs({ root: "/tmp/work" }) }, - ], -}); -``` - -### AI SDK Tool - -For AI agents, use [`bash-tool`](https://github.com/vercel-labs/bash-tool) which is optimized for just-bash and provides a ready-to-use [AI SDK](https://ai-sdk.dev/) tool: - -```bash -npm install bash-tool -``` - -```typescript -import { createBashTool } from "bash-tool"; -import { generateText } from "ai"; - -const bashTool = createBashTool({ - files: { "/data/users.json": '[{"name": "Alice"}, {"name": "Bob"}]' }, -}); - -const result = await generateText({ - model: "anthropic/claude-sonnet-4", - tools: { bash: bashTool }, - prompt: "Count the users in /data/users.json", -}); -``` - -See the [bash-tool documentation](https://github.com/vercel-labs/bash-tool) for more details and examples. - -### Vercel Sandbox Compatible API - -Bash provides a `Sandbox` class that's API-compatible with [`@vercel/sandbox`](https://vercel.com/docs/vercel-sandbox), making it easy to swap implementations. You can start with Bash and switch to a real sandbox when you need the power of a full VM (e.g. to run node, python, or custom binaries). - -```typescript -import { Sandbox } from "just-bash"; - -// Create a sandbox instance -const sandbox = await Sandbox.create({ cwd: "/app" }); - -// Write files to the virtual filesystem -await sandbox.writeFiles({ - "/app/script.sh": 'echo "Hello World"', - "/app/data.json": '{"key": "value"}', -}); - -// Run commands and get results -const cmd = await sandbox.runCommand("bash /app/script.sh"); -const output = await cmd.stdout(); // "Hello World\n" -const exitCode = (await cmd.wait()).exitCode; // 0 - -// Read files back -const content = await sandbox.readFile("/app/data.json"); - -// Create directories -await sandbox.mkDir("/app/logs", { recursive: true }); - -// Clean up (no-op for Bash, but API-compatible) -await sandbox.stop(); -``` - -### CLI Binary - -After installing globally (`npm install -g just-bash`), use the `just-bash` command as a secure alternative to `bash` for AI agents: - -```bash -# Execute inline script -just-bash -c 'ls -la && cat package.json | head -5' - -# Execute with specific project root -just-bash -c 'grep -r "TODO" src/' --root /path/to/project - -# Pipe script from stdin -echo 'find . -name "*.ts" | wc -l' | just-bash - -# Execute a script file -just-bash ./scripts/deploy.sh - -# Get JSON output for programmatic use -just-bash -c 'echo hello' --json -# Output: {"stdout":"hello\n","stderr":"","exitCode":0} -``` - -The CLI uses OverlayFS - reads come from the real filesystem, but all writes stay in memory and are discarded after execution. The project root is mounted at `/home/user/project`. - -Options: - -- `-c -Unicode: café naïve résumé`, - "/data/filenames.txt": `report 2024.pdf -my file (1).doc -data_export[final].csv -notes & ideas.txt`, - "/data/ids.txt": `user-001 -USER_002 -user.003 -user@004`, - "/data/log-entry.txt": `[2024-01-15 10:30:45] ERROR: Connection failed -Details: host=192.168.1.100, port=5432 -Stack trace follows...`, - }, - cwd: "/data", - }); - - describe("Sanitizing user input", () => { - it("should keep only alphanumeric and spaces", async () => { - const env = createSanitizeEnv(); - const result = await env.exec( - "cat /data/user-input.txt | tr -cd 'a-zA-Z0-9 \\n'", - ); - expect(result.stdout).not.toContain("@"); - expect(result.stdout).not.toContain("<"); - expect(result.stdout).not.toContain(">"); - expect(result.stdout).toContain("Hello"); - expect(result.stdout).toContain("My email is"); - expect(result.exitCode).toBe(0); - }); - - it("should keep only printable ASCII characters", async () => { - const env = createSanitizeEnv(); - // Delete all except printable ASCII (space through tilde) - const result = await env.exec( - "echo 'Hello World 123!' | tr -cd 'A-Za-z0-9 !\\n'", - ); - expect(result.stdout).toContain("Hello World 123!"); - expect(result.exitCode).toBe(0); - }); - - it("should extract only digits from phone number", async () => { - const env = createSanitizeEnv(); - const result = await env.exec( - "grep Phone /data/user-input.txt | tr -cd '0-9\\n'", - ); - expect(result.stdout.trim()).toBe("15551234567"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("Sanitizing filenames", () => { - it("should replace unsafe filename characters with underscores", async () => { - const env = createSanitizeEnv(); - const result = await env.exec( - "cat /data/filenames.txt | tr -c 'a-zA-Z0-9._-\\n' '_'", - ); - expect(result.stdout).toContain("report_2024.pdf"); - expect(result.stdout).toContain("my_file__1_.doc"); - expect(result.stdout).not.toContain("("); - expect(result.stdout).not.toContain("["); - expect(result.exitCode).toBe(0); - }); - - it("should normalize IDs to lowercase alphanumeric", async () => { - const env = createSanitizeEnv(); - // First remove non-alphanumeric, then lowercase - const result = await env.exec( - "cat /data/ids.txt | tr -cd 'a-zA-Z0-9\\n' | tr 'A-Z' 'a-z'", - ); - expect(result.stdout).toContain("user001"); - expect(result.stdout).toContain("user002"); - expect(result.stdout).toContain("user003"); - expect(result.stdout).toContain("user004"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("Extracting data with tr -c", () => { - it("should extract only letters for word analysis", async () => { - const env = createSanitizeEnv(); - const result = await env.exec( - "head -1 /data/user-input.txt | tr -cs 'a-zA-Z' '\\n' | head -5", - ); - // Squeeze consecutive non-letters into single newlines - expect(result.stdout).toContain("Hello"); - expect(result.stdout).toContain("My"); - expect(result.stdout).toContain("email"); - expect(result.exitCode).toBe(0); - }); - - it("should extract timestamp digits from log", async () => { - const env = createSanitizeEnv(); - const result = await env.exec( - "head -1 /data/log-entry.txt | tr -cd '0-9 :'", - ); - expect(result.stdout).toContain("2024"); - expect(result.stdout).toContain("10:30:45"); - expect(result.exitCode).toBe(0); - }); - - it("should extract IP address octets", async () => { - const env = createSanitizeEnv(); - const result = await env.exec( - "grep host /data/log-entry.txt | tr -cd '0-9.\\n'", - ); - expect(result.stdout).toContain("192.168.1.100"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("Security-focused sanitization", () => { - it("should remove potential XSS characters", async () => { - const env = createSanitizeEnv(); - // Remove < and > to neutralize HTML tags - const result = await env.exec( - "grep script /data/user-input.txt | tr -d '<>'", - ); - expect(result.stdout).not.toContain("<"); - expect(result.stdout).not.toContain(">"); - expect(result.stdout).toContain("script"); - expect(result.exitCode).toBe(0); - }); - - it("should sanitize for SQL safety (remove quotes)", async () => { - const env = createSanitizeEnv(); - const result = await env.exec( - 'echo "user\'; DROP TABLE users;--" | tr -d "\';"', - ); - expect(result.stdout).not.toContain("'"); - expect(result.stdout).not.toContain(";"); - expect(result.stdout).toContain("user"); - expect(result.exitCode).toBe(0); - }); - }); -}); - -describe("Agent Workflow: printf formatting", () => { - it("should format numbers with padding", async () => { - const env = new Bash(); - const result = await env.exec("printf '%05d\\n' 42"); - expect(result.stdout).toBe("00042\n"); - }); - - it("should format floats with precision", async () => { - const env = new Bash(); - const result = await env.exec("printf '%.2f\\n' 3.14159"); - expect(result.stdout).toBe("3.14\n"); - }); - - it("should format hex numbers", async () => { - const env = new Bash(); - const result = await env.exec("printf '%x\\n' 255"); - expect(result.stdout).toBe("ff\n"); - }); - - it("should format with width specifier", async () => { - const env = new Bash(); - const result = await env.exec("printf '%10s\\n' hello"); - expect(result.stdout).toBe(" hello\n"); - }); - - it("should left-justify with minus flag", async () => { - const env = new Bash(); - const result = await env.exec("printf '%-10s|\\n' hello"); - expect(result.stdout).toBe("hello |\n"); - }); - - it("should format table-like output", async () => { - const env = new Bash(); - // %-10s = "Item" + 6 spaces (10 chars), space, %5d = 3 spaces + "42" (5 chars), space, %8.2f = 4 spaces + "3.14" (8 chars) - const result = await env.exec("printf '%-10s %5d %8.2f\\n' Item 42 3.14"); - expect(result.stdout).toBe("Item 42 3.14\n"); - }); -}); diff --git a/src/ast/types.ts b/src/ast/types.ts deleted file mode 100644 index 64bca373..00000000 --- a/src/ast/types.ts +++ /dev/null @@ -1,1098 +0,0 @@ -/** - * Abstract Syntax Tree (AST) Types for Bash - * - * This module defines the complete AST structure for bash scripts. - * The design follows the actual bash grammar while being TypeScript-idiomatic. - * - * Architecture: - * Input → Lexer → Parser → AST → Expander → Interpreter → Output - * - * Each node type corresponds to a bash construct and can be visited - * by the tree-walking interpreter. - */ - -// ============================================================================= -// BASE TYPES -// ============================================================================= - -/** Base interface for all AST nodes */ -export interface ASTNode { - type: string; - /** Source line number (1-based) for $LINENO tracking. May be 0 or undefined for synthesized nodes. */ - line?: number; -} - -/** Position information for error reporting */ -export interface Position { - line: number; - column: number; - offset: number; -} - -/** Span in source code */ -export interface Span { - start: Position; - end: Position; -} - -// ============================================================================= -// SCRIPT & STATEMENTS -// ============================================================================= - -/** Root node: a complete script */ -export interface ScriptNode extends ASTNode { - type: "Script"; - statements: StatementNode[]; -} - -/** A statement is a list of pipelines connected by && or || */ -export interface StatementNode extends ASTNode { - type: "Statement"; - pipelines: PipelineNode[]; - /** Operators between pipelines: "&&" | "||" | ";" */ - operators: ("&&" | "||" | ";")[]; - /** Run in background? */ - background: boolean; - /** - * Deferred syntax error. If set, executing this statement will throw a syntax error. - * This is used to support bash's incremental parsing behavior where syntax errors - * on later lines only trigger if/when execution reaches that line. - * Example: `{ls;\n}` - the } is invalid but with errexit, the script exits before reaching it. - */ - deferredError?: { - message: string; - token: string; - }; - /** - * Original source text for verbose mode (set -v). - * When verbose mode is enabled, this text is printed to stderr before execution. - */ - sourceText?: string; -} - -// ============================================================================= -// PIPELINES & COMMANDS -// ============================================================================= - -/** A pipeline: cmd1 | cmd2 | cmd3 */ -export interface PipelineNode extends ASTNode { - type: "Pipeline"; - commands: CommandNode[]; - /** Negate exit status with ! */ - negated: boolean; - /** Time the pipeline with 'time' keyword */ - timed?: boolean; - /** Use POSIX format for time output (-p flag) */ - timePosix?: boolean; - /** - * For each pipe in the pipeline, whether it's |& (pipe stderr too). - * pipeStderr[i] indicates if command[i]'s stderr should be piped to command[i+1]'s stdin. - * Length is commands.length - 1. - */ - pipeStderr?: boolean[]; -} - -/** Union of all command types */ -export type CommandNode = - | SimpleCommandNode - | CompoundCommandNode - | FunctionDefNode; - -/** Simple command: name args... with optional redirections */ -export interface SimpleCommandNode extends ASTNode { - type: "SimpleCommand"; - /** Variable assignments before command: VAR=value cmd */ - assignments: AssignmentNode[]; - /** Command name (may be empty for assignment-only) */ - name: WordNode | null; - /** Command arguments */ - args: WordNode[]; - /** I/O redirections */ - redirections: RedirectionNode[]; -} - -/** Compound commands: control structures */ -export type CompoundCommandNode = - | IfNode - | ForNode - | CStyleForNode - | WhileNode - | UntilNode - | CaseNode - | SubshellNode - | GroupNode - | ArithmeticCommandNode - | ConditionalCommandNode; - -// ============================================================================= -// CONTROL FLOW -// ============================================================================= - -/** if statement */ -export interface IfNode extends ASTNode { - type: "If"; - clauses: IfClause[]; - elseBody: StatementNode[] | null; - redirections: RedirectionNode[]; -} - -export interface IfClause { - condition: StatementNode[]; - body: StatementNode[]; -} - -/** for loop: for VAR in WORDS; do ...; done */ -export interface ForNode extends ASTNode { - type: "For"; - variable: string; - /** Words to iterate over (null = "$@") */ - words: WordNode[] | null; - body: StatementNode[]; - redirections: RedirectionNode[]; -} - -/** C-style for loop: for ((init; cond; step)); do ...; done */ -export interface CStyleForNode extends ASTNode { - type: "CStyleFor"; - init: ArithmeticExpressionNode | null; - condition: ArithmeticExpressionNode | null; - update: ArithmeticExpressionNode | null; - body: StatementNode[]; - redirections: RedirectionNode[]; -} - -/** while loop */ -export interface WhileNode extends ASTNode { - type: "While"; - condition: StatementNode[]; - body: StatementNode[]; - redirections: RedirectionNode[]; -} - -/** until loop */ -export interface UntilNode extends ASTNode { - type: "Until"; - condition: StatementNode[]; - body: StatementNode[]; - redirections: RedirectionNode[]; -} - -/** case statement */ -export interface CaseNode extends ASTNode { - type: "Case"; - word: WordNode; - items: CaseItemNode[]; - redirections: RedirectionNode[]; -} - -export interface CaseItemNode extends ASTNode { - type: "CaseItem"; - patterns: WordNode[]; - body: StatementNode[]; - /** Terminator: ";;" | ";&" | ";;&" */ - terminator: ";;" | ";&" | ";;&"; -} - -/** Subshell: ( ... ) */ -export interface SubshellNode extends ASTNode { - type: "Subshell"; - body: StatementNode[]; - redirections: RedirectionNode[]; -} - -/** Command group: { ...; } */ -export interface GroupNode extends ASTNode { - type: "Group"; - body: StatementNode[]; - redirections: RedirectionNode[]; -} - -/** Arithmetic command: (( expr )) */ -export interface ArithmeticCommandNode extends ASTNode { - type: "ArithmeticCommand"; - expression: ArithmeticExpressionNode; - redirections: RedirectionNode[]; -} - -/** Conditional command: [[ expr ]] */ -export interface ConditionalCommandNode extends ASTNode { - type: "ConditionalCommand"; - expression: ConditionalExpressionNode; - redirections: RedirectionNode[]; - line?: number; -} - -// ============================================================================= -// FUNCTIONS -// ============================================================================= - -/** Function definition */ -export interface FunctionDefNode extends ASTNode { - type: "FunctionDef"; - name: string; - body: CompoundCommandNode; - redirections: RedirectionNode[]; - /** Source file where the function was defined (for BASH_SOURCE tracking) */ - sourceFile?: string; -} - -// ============================================================================= -// ASSIGNMENTS -// ============================================================================= - -/** Variable assignment: VAR=value or VAR+=value */ -export interface AssignmentNode extends ASTNode { - type: "Assignment"; - name: string; - value: WordNode | null; - /** Append mode: VAR+=value */ - append: boolean; - /** Array assignment: VAR=(a b c) */ - array: WordNode[] | null; -} - -// ============================================================================= -// REDIRECTIONS -// ============================================================================= - -/** I/O redirection */ -export interface RedirectionNode extends ASTNode { - type: "Redirection"; - /** File descriptor (default depends on operator) */ - fd: number | null; - /** - * Variable name for automatic FD allocation ({varname}>file syntax). - * When set, bash allocates an FD >= 10 and stores the number in this variable. - */ - fdVariable?: string; - operator: RedirectionOperator; - target: WordNode | HereDocNode; -} - -export type RedirectionOperator = - | "<" // Input - | ">" // Output (truncate) - | ">>" // Output (append) - | ">&" // Duplicate output fd - | "<&" // Duplicate input fd - | "<>" // Open for read/write - | ">|" // Output (clobber) - | "&>" // Redirect stdout and stderr - | "&>>" // Append stdout and stderr - | "<<<" // Here-string - | "<<" // Here-document - | "<<-"; // Here-document (strip tabs) - -/** Here document */ -export interface HereDocNode extends ASTNode { - type: "HereDoc"; - delimiter: string; - content: WordNode; - /** Strip leading tabs (<<- vs <<) */ - stripTabs: boolean; - /** Quoted delimiter means no expansion */ - quoted: boolean; -} - -// ============================================================================= -// WORDS (the heart of shell parsing) -// ============================================================================= - -/** - * A Word is a sequence of parts that form a single shell word. - * After expansion, it may produce zero, one, or multiple strings. - */ -export interface WordNode extends ASTNode { - type: "Word"; - parts: WordPart[]; -} - -/** Parts that can make up a word */ -export type WordPart = - | LiteralPart - | SingleQuotedPart - | DoubleQuotedPart - | EscapedPart - | ParameterExpansionPart - | CommandSubstitutionPart - | ArithmeticExpansionPart - | ProcessSubstitutionPart - | BraceExpansionPart - | TildeExpansionPart - | GlobPart; - -/** Literal text (no special meaning) */ -export interface LiteralPart extends ASTNode { - type: "Literal"; - value: string; -} - -/** Single-quoted string: 'literal' */ -export interface SingleQuotedPart extends ASTNode { - type: "SingleQuoted"; - value: string; -} - -/** Double-quoted string: "with $expansion" */ -export interface DoubleQuotedPart extends ASTNode { - type: "DoubleQuoted"; - parts: WordPart[]; -} - -/** Escaped character: \x */ -export interface EscapedPart extends ASTNode { - type: "Escaped"; - value: string; -} - -// ============================================================================= -// PARAMETER EXPANSION -// ============================================================================= - -/** Parameter/variable expansion: $VAR or ${VAR...} */ -export interface ParameterExpansionPart extends ASTNode { - type: "ParameterExpansion"; - parameter: string; - /** Expansion operation */ - operation: ParameterOperation | null; -} - -/** Operations that can be used as inner operations for indirection (${!ref-default}) */ -export type InnerParameterOperation = - | DefaultValueOp - | AssignDefaultOp - | ErrorIfUnsetOp - | UseAlternativeOp - | LengthOp - | LengthSliceErrorOp - | BadSubstitutionOp - | SubstringOp - | PatternRemovalOp - | PatternReplacementOp - | CaseModificationOp - | TransformOp; - -export type ParameterOperation = - | InnerParameterOperation - | IndirectionOp - | ArrayKeysOp - | VarNamePrefixOp; - -/** ${#VAR:...} - invalid syntax, length cannot have substring */ -export interface LengthSliceErrorOp { - type: "LengthSliceError"; -} - -/** Bad substitution - parsed but errors at runtime (e.g., ${(x)foo} zsh syntax) */ -export interface BadSubstitutionOp { - type: "BadSubstitution"; - /** The raw text that caused the error (for error message) */ - text: string; -} - -/** ${VAR:-default} or ${VAR-default} */ -export interface DefaultValueOp { - type: "DefaultValue"; - word: WordNode; - checkEmpty: boolean; // : present = check empty too -} - -/** ${VAR:=default} or ${VAR=default} */ -export interface AssignDefaultOp { - type: "AssignDefault"; - word: WordNode; - checkEmpty: boolean; -} - -/** ${VAR:?error} or ${VAR?error} */ -export interface ErrorIfUnsetOp { - type: "ErrorIfUnset"; - word: WordNode | null; - checkEmpty: boolean; -} - -/** ${VAR:+alternative} or ${VAR+alternative} */ -export interface UseAlternativeOp { - type: "UseAlternative"; - word: WordNode; - checkEmpty: boolean; -} - -/** ${#VAR} */ -export interface LengthOp { - type: "Length"; -} - -/** ${VAR:offset} or ${VAR:offset:length} */ -export interface SubstringOp { - type: "Substring"; - offset: ArithmeticExpressionNode; - length: ArithmeticExpressionNode | null; -} - -/** ${VAR#pattern}, ${VAR##pattern}, ${VAR%pattern}, ${VAR%%pattern} */ -export interface PatternRemovalOp { - type: "PatternRemoval"; - pattern: WordNode; - /** "prefix" = # or ##, "suffix" = % or %% */ - side: "prefix" | "suffix"; - /** Greedy (## or %%) vs non-greedy (# or %) */ - greedy: boolean; -} - -/** ${VAR/pattern/replacement} or ${VAR//pattern/replacement} */ -export interface PatternReplacementOp { - type: "PatternReplacement"; - pattern: WordNode; - replacement: WordNode | null; - /** Replace all occurrences */ - all: boolean; - /** Match at start (#) or end (%) only */ - anchor: "start" | "end" | null; -} - -/** ${VAR^}, ${VAR^^}, ${VAR,}, ${VAR,,} */ -export interface CaseModificationOp { - type: "CaseModification"; - /** "upper" = ^ or ^^, "lower" = , or ,, */ - direction: "upper" | "lower"; - /** Apply to all characters */ - all: boolean; - pattern: WordNode | null; -} - -/** ${var@Q}, ${var@P}, etc. - parameter transformation */ -export interface TransformOp { - type: "Transform"; - /** Q=quote, P=prompt, a=attributes, A=assignment, E=escape, K=keys, k=keys(alt), u=ucfirst, U=uppercase, L=lowercase */ - operator: "Q" | "P" | "a" | "A" | "E" | "K" | "k" | "u" | "U" | "L"; -} - -/** ${!VAR} - indirect expansion, optionally combined with another operation like ${!ref-default} */ -export interface IndirectionOp { - type: "Indirection"; - /** Additional operation to apply after indirection (e.g., ${!ref-default}) */ - innerOp?: InnerParameterOperation; -} - -/** ${!arr[@]} or ${!arr[*]} - array keys/indices */ -export interface ArrayKeysOp { - type: "ArrayKeys"; - /** The array name (without subscript) */ - array: string; - /** true if [*] was used instead of [@] */ - star: boolean; -} - -/** ${!prefix*} or ${!prefix@} - list variable names with prefix */ -export interface VarNamePrefixOp { - type: "VarNamePrefix"; - /** The prefix to match */ - prefix: string; - /** true if * was used instead of @ */ - star: boolean; -} - -// ============================================================================= -// COMMAND SUBSTITUTION -// ============================================================================= - -/** Command substitution: $(cmd) or `cmd` */ -export interface CommandSubstitutionPart extends ASTNode { - type: "CommandSubstitution"; - body: ScriptNode; - /** Legacy backtick syntax */ - legacy: boolean; -} - -// ============================================================================= -// ARITHMETIC -// ============================================================================= - -/** Arithmetic expansion: $((expr)) */ -export interface ArithmeticExpansionPart extends ASTNode { - type: "ArithmeticExpansion"; - expression: ArithmeticExpressionNode; -} - -/** Arithmetic expression (for $((...)) and ((...))) */ -export interface ArithmeticExpressionNode extends ASTNode { - type: "ArithmeticExpression"; - expression: ArithExpr; - /** Original expression text before parsing, used for re-parsing after variable expansion */ - originalText?: string; -} - -export type ArithExpr = - | ArithNumberNode - | ArithVariableNode - | ArithSpecialVarNode - | ArithBinaryNode - | ArithUnaryNode - | ArithTernaryNode - | ArithAssignmentNode - | ArithDynamicAssignmentNode - | ArithDynamicElementNode - | ArithGroupNode - | ArithNestedNode - | ArithCommandSubstNode - | ArithBracedExpansionNode - | ArithArrayElementNode - | ArithDynamicBaseNode - | ArithDynamicNumberNode - | ArithConcatNode - | ArithDoubleSubscriptNode - | ArithNumberSubscriptNode - | ArithSyntaxErrorNode - | ArithSingleQuoteNode; - -export interface ArithBracedExpansionNode extends ASTNode { - type: "ArithBracedExpansion"; - content: string; // The content inside ${...}, e.g., "j:-5" -} - -/** Dynamic base constant: ${base}#value where base is expanded at runtime */ -export interface ArithDynamicBaseNode extends ASTNode { - type: "ArithDynamicBase"; - baseExpr: string; // The variable content (e.g., "base" from ${base}) - value: string; // The value after # (e.g., "a" from ${base}#a) -} - -/** Dynamic number prefix: ${zero}11 or ${zero}xAB for dynamic octal/hex */ -export interface ArithDynamicNumberNode extends ASTNode { - type: "ArithDynamicNumber"; - prefix: string; // The variable content (e.g., "zero" from ${zero}) - suffix: string; // The suffix (e.g., "11" or "xAB") -} - -/** Concatenation of multiple parts forming a single numeric value */ -export interface ArithConcatNode extends ASTNode { - type: "ArithConcat"; - parts: ArithExpr[]; // Parts to concatenate (e.g., [$(echo 1), ${x:-3}] → "13") -} - -export interface ArithArrayElementNode extends ASTNode { - type: "ArithArrayElement"; - array: string; // The array name - /** The index expression (for numeric indices) */ - index?: ArithExpr; - /** For associative arrays: literal string key (e.g., 'key' or "key") */ - stringKey?: string; -} - -/** Invalid double subscript node (e.g., a[1][1]) - evaluated to error at runtime */ -export interface ArithDoubleSubscriptNode extends ASTNode { - type: "ArithDoubleSubscript"; - array: string; // The array name - index: ArithExpr; // The first index expression -} - -/** Invalid number subscript node (e.g., 1[2]) - evaluated to error at runtime */ -export interface ArithNumberSubscriptNode extends ASTNode { - type: "ArithNumberSubscript"; - number: string; // The number that was attempted to be subscripted - errorToken: string; // The error token for the error message -} - -/** Syntax error in arithmetic expression - evaluated to error at runtime */ -export interface ArithSyntaxErrorNode extends ASTNode { - type: "ArithSyntaxError"; - errorToken: string; // The invalid token that caused the error - message: string; // The error message -} - -/** - * Single-quoted string in arithmetic expression. - * In $(()) expansion context, this causes an error. - * In (()) command context, this is evaluated as a number. - */ -export interface ArithSingleQuoteNode extends ASTNode { - type: "ArithSingleQuote"; - content: string; // The content inside the quotes - value: number; // The numeric value (for command context) -} - -export interface ArithNumberNode extends ASTNode { - type: "ArithNumber"; - value: number; -} - -export interface ArithVariableNode extends ASTNode { - type: "ArithVariable"; - name: string; - /** True if the variable was written with $ prefix (e.g., $x vs x) */ - hasDollarPrefix?: boolean; -} - -/** Special variable node: $*, $@, $#, $?, $-, $!, $$ */ -export interface ArithSpecialVarNode extends ASTNode { - type: "ArithSpecialVar"; - name: string; // The special var character: *, @, #, ?, -, !, $ -} - -export interface ArithBinaryNode extends ASTNode { - type: "ArithBinary"; - operator: - | "+" - | "-" - | "*" - | "/" - | "%" - | "**" - | "<<" - | ">>" - | "<" - | "<=" - | ">" - | ">=" - | "==" - | "!=" - | "&" - | "|" - | "^" - | "&&" - | "||" - | ","; - left: ArithExpr; - right: ArithExpr; -} - -export interface ArithUnaryNode extends ASTNode { - type: "ArithUnary"; - operator: "-" | "+" | "!" | "~" | "++" | "--"; - operand: ArithExpr; - /** Prefix vs postfix for ++ and -- */ - prefix: boolean; -} - -export interface ArithTernaryNode extends ASTNode { - type: "ArithTernary"; - condition: ArithExpr; - consequent: ArithExpr; - alternate: ArithExpr; -} - -export type ArithAssignmentOperator = - | "=" - | "+=" - | "-=" - | "*=" - | "/=" - | "%=" - | "<<=" - | ">>=" - | "&=" - | "|=" - | "^="; - -export interface ArithAssignmentNode extends ASTNode { - type: "ArithAssignment"; - operator: ArithAssignmentOperator; - variable: string; - /** For array element assignment: the subscript expression */ - subscript?: ArithExpr; - /** For associative arrays: literal string key (e.g., 'key' or "key") */ - stringKey?: string; - value: ArithExpr; -} - -/** Dynamic assignment where variable name is built from concatenation: x$foo = 42 or x$foo[5] = 42 */ -export interface ArithDynamicAssignmentNode extends ASTNode { - type: "ArithDynamicAssignment"; - operator: ArithAssignmentOperator; - /** The target expression (ArithConcat) that evaluates to the variable name */ - target: ArithExpr; - /** For array element assignment: the subscript expression */ - subscript?: ArithExpr; - value: ArithExpr; -} - -/** Dynamic array element where array name is built from concatenation: x$foo[5] */ -export interface ArithDynamicElementNode extends ASTNode { - type: "ArithDynamicElement"; - /** The expression (ArithConcat) that evaluates to the array name */ - nameExpr: ArithExpr; - /** The subscript expression */ - subscript: ArithExpr; -} - -export interface ArithGroupNode extends ASTNode { - type: "ArithGroup"; - expression: ArithExpr; -} - -/** Nested arithmetic expansion within arithmetic context: $((expr)) */ -export interface ArithNestedNode extends ASTNode { - type: "ArithNested"; - expression: ArithExpr; -} - -/** Command substitution within arithmetic context: $(cmd) or `cmd` */ -export interface ArithCommandSubstNode extends ASTNode { - type: "ArithCommandSubst"; - command: string; -} - -// ============================================================================= -// PROCESS SUBSTITUTION -// ============================================================================= - -/** Process substitution: <(cmd) or >(cmd) */ -export interface ProcessSubstitutionPart extends ASTNode { - type: "ProcessSubstitution"; - body: ScriptNode; - direction: "input" | "output"; // <(...) vs >(...) -} - -// ============================================================================= -// BRACE & TILDE EXPANSION -// ============================================================================= - -/** Brace expansion: {a,b,c} or {1..10} */ -export interface BraceExpansionPart extends ASTNode { - type: "BraceExpansion"; - items: BraceItem[]; -} - -export type BraceItem = - | { type: "Word"; word: WordNode } - | { - type: "Range"; - start: string | number; - end: string | number; - step?: number; - // Original string form for zero-padding support - startStr?: string; - endStr?: string; - }; - -/** Tilde expansion: ~ or ~user */ -export interface TildeExpansionPart extends ASTNode { - type: "TildeExpansion"; - user: string | null; // null = current user -} - -// ============================================================================= -// GLOB PATTERNS -// ============================================================================= - -/** Glob pattern part (expanded during pathname expansion) */ -export interface GlobPart extends ASTNode { - type: "Glob"; - pattern: string; -} - -// ============================================================================= -// CONDITIONAL EXPRESSIONS (for [[ ]]) -// ============================================================================= - -export type ConditionalExpressionNode = - | CondBinaryNode - | CondUnaryNode - | CondNotNode - | CondAndNode - | CondOrNode - | CondGroupNode - | CondWordNode; - -export type CondBinaryOperator = - | "=" - | "==" - | "!=" - | "=~" - | "<" - | ">" - | "-eq" - | "-ne" - | "-lt" - | "-le" - | "-gt" - | "-ge" - | "-nt" - | "-ot" - | "-ef"; - -export interface CondBinaryNode extends ASTNode { - type: "CondBinary"; - operator: CondBinaryOperator; - left: WordNode; - right: WordNode; -} - -export type CondUnaryOperator = - | "-a" - | "-b" - | "-c" - | "-d" - | "-e" - | "-f" - | "-g" - | "-h" - | "-k" - | "-p" - | "-r" - | "-s" - | "-t" - | "-u" - | "-w" - | "-x" - | "-G" - | "-L" - | "-N" - | "-O" - | "-S" - | "-z" - | "-n" - | "-o" - | "-v" - | "-R"; - -export interface CondUnaryNode extends ASTNode { - type: "CondUnary"; - operator: CondUnaryOperator; - operand: WordNode; -} - -export interface CondNotNode extends ASTNode { - type: "CondNot"; - operand: ConditionalExpressionNode; -} - -export interface CondAndNode extends ASTNode { - type: "CondAnd"; - left: ConditionalExpressionNode; - right: ConditionalExpressionNode; -} - -export interface CondOrNode extends ASTNode { - type: "CondOr"; - left: ConditionalExpressionNode; - right: ConditionalExpressionNode; -} - -export interface CondGroupNode extends ASTNode { - type: "CondGroup"; - expression: ConditionalExpressionNode; -} - -export interface CondWordNode extends ASTNode { - type: "CondWord"; - word: WordNode; -} - -// ============================================================================= -// FACTORY FUNCTIONS (for building AST nodes) -// ============================================================================= - -export const AST = { - script(statements: StatementNode[]): ScriptNode { - return { type: "Script", statements }; - }, - - statement( - pipelines: PipelineNode[], - operators: ("&&" | "||" | ";")[] = [], - background = false, - deferredError?: { message: string; token: string }, - sourceText?: string, - ): StatementNode { - const node: StatementNode = { - type: "Statement", - pipelines, - operators, - background, - }; - if (deferredError) { - node.deferredError = deferredError; - } - if (sourceText !== undefined) { - node.sourceText = sourceText; - } - return node; - }, - - pipeline( - commands: CommandNode[], - negated = false, - timed = false, - timePosix = false, - pipeStderr?: boolean[], - ): PipelineNode { - return { - type: "Pipeline", - commands, - negated, - timed, - timePosix, - pipeStderr, - }; - }, - - simpleCommand( - name: WordNode | null, - args: WordNode[] = [], - assignments: AssignmentNode[] = [], - redirections: RedirectionNode[] = [], - ): SimpleCommandNode { - return { type: "SimpleCommand", name, args, assignments, redirections }; - }, - - word(parts: WordPart[]): WordNode { - return { type: "Word", parts }; - }, - - literal(value: string): LiteralPart { - return { type: "Literal", value }; - }, - - singleQuoted(value: string): SingleQuotedPart { - return { type: "SingleQuoted", value }; - }, - - doubleQuoted(parts: WordPart[]): DoubleQuotedPart { - return { type: "DoubleQuoted", parts }; - }, - - escaped(value: string): EscapedPart { - return { type: "Escaped", value }; - }, - - parameterExpansion( - parameter: string, - operation: ParameterOperation | null = null, - ): ParameterExpansionPart { - return { type: "ParameterExpansion", parameter, operation }; - }, - - commandSubstitution( - body: ScriptNode, - legacy = false, - ): CommandSubstitutionPart { - return { type: "CommandSubstitution", body, legacy }; - }, - - arithmeticExpansion( - expression: ArithmeticExpressionNode, - ): ArithmeticExpansionPart { - return { type: "ArithmeticExpansion", expression }; - }, - - assignment( - name: string, - value: WordNode | null, - append = false, - array: WordNode[] | null = null, - ): AssignmentNode { - return { type: "Assignment", name, value, append, array }; - }, - - redirection( - operator: RedirectionOperator, - target: WordNode | HereDocNode, - fd: number | null = null, - fdVariable?: string, - ): RedirectionNode { - const node: RedirectionNode = { type: "Redirection", fd, operator, target }; - if (fdVariable) { - node.fdVariable = fdVariable; - } - return node; - }, - - hereDoc( - delimiter: string, - content: WordNode, - stripTabs = false, - quoted = false, - ): HereDocNode { - return { type: "HereDoc", delimiter, content, stripTabs, quoted }; - }, - - ifNode( - clauses: IfClause[], - elseBody: StatementNode[] | null = null, - redirections: RedirectionNode[] = [], - ): IfNode { - return { type: "If", clauses, elseBody, redirections }; - }, - - forNode( - variable: string, - words: WordNode[] | null, - body: StatementNode[], - redirections: RedirectionNode[] = [], - ): ForNode { - return { type: "For", variable, words, body, redirections }; - }, - - whileNode( - condition: StatementNode[], - body: StatementNode[], - redirections: RedirectionNode[] = [], - ): WhileNode { - return { type: "While", condition, body, redirections }; - }, - - untilNode( - condition: StatementNode[], - body: StatementNode[], - redirections: RedirectionNode[] = [], - ): UntilNode { - return { type: "Until", condition, body, redirections }; - }, - - caseNode( - word: WordNode, - items: CaseItemNode[], - redirections: RedirectionNode[] = [], - ): CaseNode { - return { type: "Case", word, items, redirections }; - }, - - caseItem( - patterns: WordNode[], - body: StatementNode[], - terminator: ";;" | ";&" | ";;&" = ";;", - ): CaseItemNode { - return { type: "CaseItem", patterns, body, terminator }; - }, - - subshell( - body: StatementNode[], - redirections: RedirectionNode[] = [], - ): SubshellNode { - return { type: "Subshell", body, redirections }; - }, - - group( - body: StatementNode[], - redirections: RedirectionNode[] = [], - ): GroupNode { - return { type: "Group", body, redirections }; - }, - - functionDef( - name: string, - body: CompoundCommandNode, - redirections: RedirectionNode[] = [], - sourceFile?: string, - ): FunctionDefNode { - return { type: "FunctionDef", name, body, redirections, sourceFile }; - }, - - conditionalCommand( - expression: ConditionalExpressionNode, - redirections: RedirectionNode[] = [], - line?: number, - ): ConditionalCommandNode { - return { type: "ConditionalCommand", expression, redirections, line }; - }, - - arithmeticCommand( - expression: ArithmeticExpressionNode, - redirections: RedirectionNode[] = [], - line?: number, - ): ArithmeticCommandNode { - return { type: "ArithmeticCommand", expression, redirections, line }; - }, -}; diff --git a/src/banned-patterns-test.ts b/src/banned-patterns-test.ts deleted file mode 100644 index 16e12b47..00000000 --- a/src/banned-patterns-test.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * This file exists solely to test that the banned-patterns lint script - * correctly detects all patterns and that ignore comments work properly. - * - * DO NOT import or use this file anywhere - it's only for lint verification. - * Each pattern below would be flagged without its corresponding ignore comment. - */ - -// Pattern 1: Record variable declaration -// @banned-pattern-ignore: test file for banned-patterns script -const _recordTest: Record = { a: 1 }; - -// Pattern 2: Empty object literal assignment -// @banned-pattern-ignore: test file for banned-patterns script -const _emptyObjTest: { [key: string]: number } = {}; - -// Pattern 3: eval() usage -// @banned-pattern-ignore: test file for banned-patterns script -// biome-ignore lint/security/noGlobalEval: intentional test -const _evalTest: () => unknown = () => eval("1+1"); - -// Pattern 4: new Function() constructor -// @banned-pattern-ignore: test file for banned-patterns script -const _funcTest: (...args: never) => unknown = new Function("return 1") as ( - ...args: never -) => unknown; - -// Pattern 5: for...in loop -// @banned-pattern-ignore: test file for banned-patterns script -for (const key in _recordTest) { - void key; -} - -// Pattern 6: Direct __proto__ access -// @banned-pattern-ignore: test file for banned-patterns script -const _protoTest: unknown = ({} as { __proto__: unknown }).__proto__; - -// Pattern 7: constructor.prototype access -// @banned-pattern-ignore: test file for banned-patterns script -const _ctorTest: object = {}.constructor.prototype; - -// Make this a module -export {}; diff --git a/src/browser.bundle.test.ts b/src/browser.bundle.test.ts deleted file mode 100644 index 655de372..00000000 --- a/src/browser.bundle.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Browser bundle safety tests - * - * These tests verify that the browser bundle: - * 1. Does not contain Node.js-only imports - * 2. Does not include browser-excluded commands like yq/xan/sqlite3 - * 3. Shows helpful error messages for browser-excluded commands - */ - -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; -import { Bash } from "./Bash.js"; -import { BROWSER_EXCLUDED_COMMANDS } from "./commands/browser-excluded.js"; -import { getCommandNames, getPythonCommandNames } from "./commands/registry.js"; - -const browserBundlePath = resolve(__dirname, "../dist/bundle/browser.js"); - -describe("browser bundle safety", () => { - describe("bundle contents", () => { - it("should not contain sql.js imports", () => { - const bundleContent = readFileSync(browserBundlePath, "utf-8"); - expect(bundleContent).not.toContain("sql.js"); - }); - - it("should not contain sqlite3 command registration", () => { - const bundleContent = readFileSync(browserBundlePath, "utf-8"); - // The sqlite3 command should not be in the bundle at all - // since it's excluded via __BROWSER__ flag - expect(bundleContent).not.toContain('name:"sqlite3"'); - expect(bundleContent).not.toContain("sqlite3Command"); - }); - - it("should not contain yq command registration", () => { - const bundleContent = readFileSync(browserBundlePath, "utf-8"); - expect(bundleContent).not.toContain('name:"yq"'); - expect(bundleContent).not.toContain("yqCommand"); - }); - - it("should not contain xan command registration", () => { - const bundleContent = readFileSync(browserBundlePath, "utf-8"); - expect(bundleContent).not.toContain('name:"xan"'); - expect(bundleContent).not.toContain("xanCommand"); - }); - - it("should not contain tar command registration", () => { - const bundleContent = readFileSync(browserBundlePath, "utf-8"); - expect(bundleContent).not.toContain('name:"tar"'); - expect(bundleContent).not.toContain("tarCommand"); - }); - - it("should not contain direct node: protocol imports in bundle code", () => { - const bundleContent = readFileSync(browserBundlePath, "utf-8"); - // The browser bundle should externalize all node: imports - // Check for common patterns that indicate node: modules are bundled - // Note: We check for function calls, not just string presence - // since the external declaration might still reference them - expect(bundleContent).not.toMatch(/require\s*\(\s*["']node:/); - expect(bundleContent).not.toMatch(/from\s*["']node:fs["']/); - expect(bundleContent).not.toMatch(/from\s*["']node:path["']/); - expect(bundleContent).not.toMatch(/from\s*["']node:child_process["']/); - }); - - it("should not contain native module artifacts", () => { - const bundleContent = readFileSync(browserBundlePath, "utf-8"); - // Native modules (.node files) cannot work in browsers - // This catches any native dependency that gets accidentally bundled - expect(bundleContent).not.toMatch(/\.node["']/); // .node file references - expect(bundleContent).not.toMatch(/prebuild-install/); // native module installer - expect(bundleContent).not.toMatch(/node-gyp/); // native build tool - expect(bundleContent).not.toMatch(/napi_/); // N-API bindings - expect(bundleContent).not.toMatch(/\.binding\(/); // native binding loader - }); - }); - - describe("browser-excluded commands list", () => { - it("should include tar in browser-excluded commands", () => { - expect(BROWSER_EXCLUDED_COMMANDS).toContain("tar"); - }); - - it("should include yq in browser-excluded commands", () => { - expect(BROWSER_EXCLUDED_COMMANDS).toContain("yq"); - }); - - it("should include xan in browser-excluded commands", () => { - expect(BROWSER_EXCLUDED_COMMANDS).toContain("xan"); - }); - - it("should include sqlite3 in browser-excluded commands", () => { - expect(BROWSER_EXCLUDED_COMMANDS).toContain("sqlite3"); - }); - - it("should have browser-excluded commands available in Node.js registry", () => { - // In Node.js environment (where tests run), all commands are available - // This verifies that browser-excluded commands exist in the full registry - // Note: python commands are opt-in, so they're in a separate list - const commandNames = [...getCommandNames(), ...getPythonCommandNames()]; - - for (const excludedCmd of BROWSER_EXCLUDED_COMMANDS) { - // These commands should be available in Node.js - expect(commandNames).toContain(excludedCmd); - } - }); - }); - - describe("sqlite3 in Node.js", () => { - it("sqlite3 should be available by default in Node.js", async () => { - const bash = new Bash(); - const result = await bash.exec("sqlite3 :memory: 'SELECT 1'"); - - expect(result.stdout).toBe("1\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("tar in Node.js", () => { - it("tar should be available by default in Node.js", async () => { - const bash = new Bash(); - const result = await bash.exec("tar --help"); - - expect(result.stdout).toContain("Usage:"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("helpful error messages for excluded commands", () => { - it("should show helpful error when tar is used but not available", async () => { - const availableCommands = getCommandNames().filter( - (cmd) => cmd !== "tar", - ) as import("./commands/registry.js").CommandName[]; - - const bash = new Bash({ - commands: availableCommands, - }); - - const result = await bash.exec("tar -tf archive.tar"); - - expect(result.stderr).toContain("tar"); - expect(result.stderr).toContain("not available in browser"); - expect(result.stderr).toContain("Exclude"); - expect(result.exitCode).toBe(127); - }); - - it("should show helpful error when yq is used but not available", async () => { - const availableCommands = getCommandNames().filter( - (cmd) => cmd !== "yq", - ) as import("./commands/registry.js").CommandName[]; - - const bash = new Bash({ - commands: availableCommands, - }); - - const result = await bash.exec("yq '.' test.yaml"); - - expect(result.stderr).toContain("yq"); - expect(result.stderr).toContain("not available in browser"); - expect(result.stderr).toContain("Exclude"); - expect(result.exitCode).toBe(127); - }); - - it("should show helpful error when xan is used but not available", async () => { - const availableCommands = getCommandNames().filter( - (cmd) => cmd !== "xan", - ) as import("./commands/registry.js").CommandName[]; - - const bash = new Bash({ - commands: availableCommands, - }); - - const result = await bash.exec("xan count data.csv"); - - expect(result.stderr).toContain("xan"); - expect(result.stderr).toContain("not available in browser"); - expect(result.stderr).toContain("Exclude"); - expect(result.exitCode).toBe(127); - }); - - it("should show helpful error when sqlite3 is used but not available", async () => { - const availableCommands = getCommandNames().filter( - (cmd) => cmd !== "sqlite3", - ) as import("./commands/registry.js").CommandName[]; - - const bash = new Bash({ - commands: availableCommands, - }); - - const result = await bash.exec("sqlite3 :memory: 'SELECT 1'"); - - expect(result.stderr).toContain("sqlite3"); - expect(result.stderr).toContain("not available in browser"); - expect(result.stderr).toContain("Exclude"); - expect(result.exitCode).toBe(127); - }); - - it("should show standard command not found for non-excluded commands", async () => { - const bash = new Bash(); - const result = await bash.exec("nonexistentcmd arg1 arg2"); - - // Regular unknown command should just say "command not found" - expect(result.stderr).toContain("command not found"); - expect(result.stderr).not.toContain("browser"); - expect(result.exitCode).toBe(127); - }); - }); -}); diff --git a/src/browser.ts b/src/browser.ts deleted file mode 100644 index 74c8ef9f..00000000 --- a/src/browser.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Browser-compatible entry point for just-bash. - * - * Excludes Node.js-specific modules: - * - OverlayFs (requires node:fs) - * - ReadWriteFs (requires node:fs) - * - Sandbox (uses OverlayFs) - * - * Note: The gzip/gunzip/zcat commands will fail at runtime in browsers - * since they use node:zlib. All other commands work. - */ - -export type { BashLogger, BashOptions, ExecOptions } from "./Bash.js"; -export { Bash } from "./Bash.js"; -export type { - AllCommandName, - CommandName, - NetworkCommandName, -} from "./commands/registry.js"; -export { - getCommandNames, - getNetworkCommandNames, -} from "./commands/registry.js"; -export type { CustomCommand, LazyCommand } from "./custom-commands.js"; -export { defineCommand } from "./custom-commands.js"; -export { InMemoryFs } from "./fs/in-memory-fs/index.js"; -export type { - BufferEncoding, - CpOptions, - DirectoryEntry, - FileContent, - FileEntry, - FileInit, - FileSystemFactory, - FsEntry, - FsStat, - InitialFiles, - MkdirOptions, - RmOptions, - SymlinkEntry, -} from "./fs/interface.js"; -export type { NetworkConfig } from "./network/index.js"; -export { - NetworkAccessDeniedError, - RedirectNotAllowedError, - TooManyRedirectsError, -} from "./network/index.js"; -export type { - BashExecResult, - Command, - CommandContext, - ExecResult, - IFileSystem, -} from "./types.js"; diff --git a/src/cli/exec.ts b/src/cli/exec.ts deleted file mode 100644 index 1d270558..00000000 --- a/src/cli/exec.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * CLI tool for executing bash scripts using Bash. - * - * Reads a bash script from stdin, parses it to an AST, executes it, - * and outputs the AST, exit code, stderr, and stdout. - * - * Usage: - * echo '

World

" | html-to-markdown', - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Hello"); - expect(result.stdout).toContain("World"); - expect(result.stdout).not.toContain("alert"); - expect(result.stdout).not.toContain("script"); - }); - - it("strips inline style content", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo "

Styled text

" | html-to-markdown', - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Styled text"); - expect(result.stdout).not.toContain("color"); - expect(result.stdout).not.toContain(".red"); - }); - - it("strips multiple script and style tags", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo "

Title

Text

" | html-to-markdown', - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("# Title"); - expect(result.stdout).toContain("Text"); - expect(result.stdout).not.toContain("body"); - expect(result.stdout).not.toContain("var x"); - expect(result.stdout).not.toContain("var y"); - }); - - it("strips script with type attribute", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo "

Content

" | html-to-markdown', - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Content"); - expect(result.stdout).not.toContain("console"); - }); - }); - - describe("edge cases", () => { - it("handles empty input", async () => { - const env = new Bash(); - const result = await env.exec('echo "" | html-to-markdown'); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - }); - - it("handles plain text", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo "Just plain text" | html-to-markdown', - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("Just plain text\n"); - }); - - it("handles complex nested HTML", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo "

Title

Text with bold

" | html-to-markdown', - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("# Title"); - expect(result.stdout).toContain("**bold**"); - }); - - it("reports unknown option", async () => { - const env = new Bash(); - const result = await env.exec("html-to-markdown --invalid"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("unrecognized option"); - }); - }); -}); diff --git a/src/commands/html-to-markdown/html-to-markdown.ts b/src/commands/html-to-markdown/html-to-markdown.ts deleted file mode 100644 index 327e6c56..00000000 --- a/src/commands/html-to-markdown/html-to-markdown.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * html-to-markdown - Convert HTML to Markdown using TurndownService - * - * This is a non-standard command that converts HTML from stdin to Markdown. - */ - -import TurndownService from "turndown"; -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { hasHelpFlag, showHelp, unknownOption } from "../help.js"; - -const htmlToMarkdownHelp = { - name: "html-to-markdown", - summary: "convert HTML to Markdown (BashEnv extension)", - usage: "html-to-markdown [OPTION]... [FILE]", - description: [ - "Convert HTML content to Markdown format using the turndown library.", - "This is a non-standard BashEnv extension command, not available in regular bash.", - "", - "Read HTML from FILE or standard input and output Markdown to standard output.", - "Commonly used with curl to convert web pages:", - " curl -s https://example.com | html-to-markdown", - "", - "Supported HTML elements:", - " - Headings (h1-h6) → # Markdown headings", - " - Paragraphs (p) → Plain text with blank lines", - " - Links (a) → [text](url)", - " - Images (img) → ![alt](src)", - " - Bold/Strong → **text**", - " - Italic/Em → _text_", - " - Code (code, pre) → `inline` or fenced blocks", - " - Lists (ul, ol, li) → - or 1. items", - " - Blockquotes → > quoted text", - " - Horizontal rules (hr) → ---", - ], - options: [ - "-b, --bullet=CHAR bullet character for unordered lists (-, +, or *)", - "-c, --code=FENCE fence style for code blocks (``` or ~~~)", - "-r, --hr=STRING string for horizontal rules (default: ---)", - " --heading-style=STYLE", - " heading style: 'atx' for # headings (default),", - " 'setext' for underlined headings (h1/h2 only)", - " --help display this help and exit", - ], - examples: [ - "echo '

Hello

World

' | html-to-markdown", - "html-to-markdown page.html", - "curl -s https://example.com | html-to-markdown > page.md", - ], -}; - -export const htmlToMarkdownCommand: Command = { - name: "html-to-markdown", - - async execute(args: string[], ctx: CommandContext): Promise { - if (hasHelpFlag(args)) { - return showHelp(htmlToMarkdownHelp); - } - - let bullet = "-"; - let codeFence = "```"; - let hr = "---"; - let headingStyle: "setext" | "atx" = "atx"; - const files: string[] = []; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === "-b" || arg === "--bullet") { - bullet = args[++i] ?? "-"; - } else if (arg.startsWith("--bullet=")) { - bullet = arg.slice(9); - } else if (arg === "-c" || arg === "--code") { - codeFence = args[++i] ?? "```"; - } else if (arg.startsWith("--code=")) { - codeFence = arg.slice(7); - } else if (arg === "-r" || arg === "--hr") { - hr = args[++i] ?? "---"; - } else if (arg.startsWith("--hr=")) { - hr = arg.slice(5); - } else if (arg.startsWith("--heading-style=")) { - const style = arg.slice(16); - if (style === "setext" || style === "atx") { - headingStyle = style; - } - } else if (arg === "-") { - files.push("-"); - } else if (arg.startsWith("--")) { - return unknownOption("html-to-markdown", arg); - } else if (arg.startsWith("-")) { - return unknownOption("html-to-markdown", arg); - } else { - files.push(arg); - } - } - - // Get input - let input: string; - if (files.length === 0 || (files.length === 1 && files[0] === "-")) { - input = ctx.stdin; - } else { - try { - const filePath = ctx.fs.resolvePath(ctx.cwd, files[0]); - input = await ctx.fs.readFile(filePath); - } catch { - return { - stdout: "", - stderr: `html-to-markdown: ${files[0]}: No such file or directory\n`, - exitCode: 1, - }; - } - } - - if (!input.trim()) { - return { stdout: "", stderr: "", exitCode: 0 }; - } - - try { - const turndownService = new TurndownService({ - bulletListMarker: bullet as "-" | "+" | "*", - codeBlockStyle: "fenced", - fence: codeFence as "```" | "~~~", - hr, - headingStyle, - }); - - // Remove script and style elements entirely (including their content) - turndownService.remove(["script", "style", "footer"]); - - const markdown = turndownService.turndown(input).trim(); - return { - stdout: `${markdown}\n`, - stderr: "", - exitCode: 0, - }; - } catch (error) { - return { - stdout: "", - stderr: `html-to-markdown: conversion error: ${ - (error as Error).message - }\n`, - exitCode: 1, - }; - } - }, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "html-to-markdown", - flags: [], - stdinType: "text", -}; diff --git a/src/commands/join/join.test.ts b/src/commands/join/join.test.ts deleted file mode 100644 index dc9145d2..00000000 --- a/src/commands/join/join.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("join", () => { - describe("basic functionality", () => { - it("joins two files on first field", async () => { - const bash = new Bash({ - files: { - "/a.txt": "1 apple\n2 banana\n3 cherry\n", - "/b.txt": "1 red\n2 yellow\n3 red\n", - }, - }); - const result = await bash.exec("join /a.txt /b.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "1 apple red\n2 banana yellow\n3 cherry red\n", - ); - }); - - it("only outputs lines with matching keys", async () => { - const bash = new Bash({ - files: { - "/a.txt": "1 apple\n2 banana\n", - "/b.txt": "2 yellow\n3 red\n", - }, - }); - const result = await bash.exec("join /a.txt /b.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("2 banana yellow\n"); - }); - - it("handles many-to-many matches", async () => { - const bash = new Bash({ - files: { - "/a.txt": "1 a\n1 b\n", - "/b.txt": "1 x\n1 y\n", - }, - }); - const result = await bash.exec("join /a.txt /b.txt"); - expect(result.exitCode).toBe(0); - // Each line from a matches each line from b with same key - expect(result.stdout).toBe("1 a x\n1 a y\n1 b x\n1 b y\n"); - }); - - it("reads from stdin with dash", async () => { - const bash = new Bash({ - files: { - "/b.txt": "1 red\n2 yellow\n", - }, - }); - const result = await bash.exec( - "printf '1 apple\\n2 banana\\n' | join - /b.txt", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1 apple red\n2 banana yellow\n"); - }); - }); - - describe("-1 and -2 options", () => { - it("joins on specified fields", async () => { - const bash = new Bash({ - files: { - "/a.txt": "apple 1\nbanana 2\n", - "/b.txt": "1 red\n2 yellow\n", - }, - }); - const result = await bash.exec("join -1 2 -2 1 /a.txt /b.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1 apple red\n2 banana yellow\n"); - }); - - it("errors on invalid field number", async () => { - const bash = new Bash({ - files: { - "/a.txt": "a\n", - "/b.txt": "b\n", - }, - }); - const result = await bash.exec("join -1 0 /a.txt /b.txt"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid field number"); - }); - }); - - describe("-t option (field separator)", () => { - it("uses custom field separator", async () => { - const bash = new Bash({ - files: { - "/a.csv": "1,apple,fruit\n2,banana,fruit\n", - "/b.csv": "1,red\n2,yellow\n", - }, - }); - const result = await bash.exec("join -t ',' /a.csv /b.csv"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1,apple,fruit,red\n2,banana,fruit,yellow\n"); - }); - - it("handles colon separator", async () => { - const bash = new Bash({ - files: { - "/a.txt": "user:1000:home\nroot:0:root\n", - "/b.txt": "user:active\nroot:active\n", - }, - }); - const result = await bash.exec("join -t ':' /a.txt /b.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("user:1000:home:active\nroot:0:root:active\n"); - }); - }); - - describe("-a option (print unpairable)", () => { - it("prints unpairable lines from file 1", async () => { - const bash = new Bash({ - files: { - "/a.txt": "1 apple\n2 banana\n3 cherry\n", - "/b.txt": "1 red\n3 red\n", - }, - }); - const result = await bash.exec("join -a 1 /a.txt /b.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1 apple red\n2 banana\n3 cherry red\n"); - }); - - it("prints unpairable lines from file 2", async () => { - const bash = new Bash({ - files: { - "/a.txt": "1 apple\n", - "/b.txt": "1 red\n2 yellow\n", - }, - }); - const result = await bash.exec("join -a 2 /a.txt /b.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1 apple red\n2 yellow\n"); - }); - - it("prints unpairable from both files", async () => { - const bash = new Bash({ - files: { - "/a.txt": "1 apple\n2 banana\n", - "/b.txt": "2 yellow\n3 red\n", - }, - }); - const result = await bash.exec("join -a 1 -a 2 /a.txt /b.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1 apple\n2 banana yellow\n3 red\n"); - }); - }); - - describe("-v option (only unpairable)", () => { - it("prints only unpairable lines from file 1", async () => { - const bash = new Bash({ - files: { - "/a.txt": "1 apple\n2 banana\n3 cherry\n", - "/b.txt": "1 red\n3 red\n", - }, - }); - const result = await bash.exec("join -v 1 /a.txt /b.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("2 banana\n"); - }); - - it("prints only unpairable lines from file 2", async () => { - const bash = new Bash({ - files: { - "/a.txt": "1 apple\n", - "/b.txt": "1 red\n2 yellow\n", - }, - }); - const result = await bash.exec("join -v 2 /a.txt /b.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("2 yellow\n"); - }); - }); - - describe("-e option (empty string)", () => { - it("replaces missing fields with specified string", async () => { - const bash = new Bash({ - files: { - "/a.txt": "1 apple\n2 banana\n", - "/b.txt": "1 red\n", - }, - }); - const result = await bash.exec( - "join -a 1 -e 'EMPTY' -o '1.1,1.2,2.2' /a.txt /b.txt", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1 apple red\n2 banana EMPTY\n"); - }); - }); - - describe("-o option (output format)", () => { - it("outputs specified fields", async () => { - const bash = new Bash({ - files: { - "/a.txt": "1 apple red\n2 banana yellow\n", - "/b.txt": "1 fruit\n2 fruit\n", - }, - }); - const result = await bash.exec("join -o '1.2,2.2' /a.txt /b.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("apple fruit\nbanana fruit\n"); - }); - - it("handles field 0 as join field", async () => { - const bash = new Bash({ - files: { - "/a.txt": "key val1\n", - "/b.txt": "key val2\n", - }, - }); - const result = await bash.exec("join -o '1.0,1.2,2.2' /a.txt /b.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("key val1 val2\n"); - }); - - it("errors on invalid format", async () => { - const bash = new Bash({ - files: { - "/a.txt": "a\n", - "/b.txt": "b\n", - }, - }); - const result = await bash.exec("join -o 'invalid' /a.txt /b.txt"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid field spec"); - }); - }); - - describe("-i option (ignore case)", () => { - it("ignores case when comparing keys", async () => { - const bash = new Bash({ - files: { - "/a.txt": "Apple red\nBanana yellow\n", - "/b.txt": "apple fruit\nbanana fruit\n", - }, - }); - const result = await bash.exec("join -i /a.txt /b.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("apple red fruit\nbanana yellow fruit\n"); - }); - }); - - describe("edge cases", () => { - it("handles empty files", async () => { - const bash = new Bash({ - files: { - "/a.txt": "", - "/b.txt": "1 x\n", - }, - }); - const result = await bash.exec("join /a.txt /b.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - }); - - it("handles files with no matches", async () => { - const bash = new Bash({ - files: { - "/a.txt": "1 apple\n", - "/b.txt": "2 banana\n", - }, - }); - const result = await bash.exec("join /a.txt /b.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - }); - }); - - describe("error handling", () => { - it("errors when missing file operand", async () => { - const bash = new Bash(); - const result = await bash.exec("join"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("missing file operand"); - }); - - it("errors with only one file", async () => { - const bash = new Bash({ - files: { "/a.txt": "1\n" }, - }); - const result = await bash.exec("join /a.txt"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("missing file operand"); - }); - - it("errors on unknown flag", async () => { - const bash = new Bash(); - const result = await bash.exec("join -z /a.txt /b.txt"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid option"); - }); - - it("errors on missing file", async () => { - const bash = new Bash({ - files: { "/a.txt": "1\n" }, - }); - const result = await bash.exec("join /a.txt /nonexistent"); - expect(result.exitCode).toBe(1); - expect(result.stderr.toLowerCase()).toContain( - "no such file or directory", - ); - }); - - it("shows help with --help", async () => { - const bash = new Bash(); - const result = await bash.exec("join --help"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("join"); - expect(result.stdout).toContain("Usage"); - }); - }); -}); diff --git a/src/commands/join/join.ts b/src/commands/join/join.ts deleted file mode 100644 index e63e743d..00000000 --- a/src/commands/join/join.ts +++ /dev/null @@ -1,390 +0,0 @@ -/** - * join - join lines of two files on a common field - * - * Usage: join [OPTION]... FILE1 FILE2 - * - * For each pair of input lines with identical join fields, write a line to - * standard output. The default join field is the first, delimited by blanks. - */ - -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { hasHelpFlag, showHelp, unknownOption } from "../help.js"; - -const joinHelp = { - name: "join", - summary: "join lines of two files on a common field", - usage: "join [OPTION]... FILE1 FILE2", - description: - "For each pair of input lines with identical join fields, write a line to standard output. The default join field is the first, delimited by blanks.", - options: [ - "-1 FIELD Join on this FIELD of file 1 (default: 1)", - "-2 FIELD Join on this FIELD of file 2 (default: 1)", - "-t CHAR Use CHAR as input and output field separator", - "-a FILENUM Also print unpairable lines from file FILENUM (1 or 2)", - "-v FILENUM Like -a but only output unpairable lines", - "-e STRING Replace missing fields with STRING", - "-o FORMAT Output format (comma-separated list of FILENUM.FIELD)", - "-i Ignore case when comparing fields", - ], - examples: [ - "join file1 file2 # Join on first field", - "join -1 2 -2 1 file1 file2 # Join file1 col 2 with file2 col 1", - "join -t ',' file1.csv file2.csv # Join CSV files", - "join -a 1 file1 file2 # Left outer join", - ], -}; - -interface JoinOptions { - field1: number; // 1-based field number - field2: number; - separator: string | null; // null = whitespace - printUnpairable: Set; // 1, 2, or both - onlyUnpairable: Set; - emptyString: string; - outputFormat: Array<{ file: number; field: number }> | null; - ignoreCase: boolean; -} - -interface ParsedLine { - fields: string[]; - joinKey: string; - original: string; -} - -/** - * Split a line into fields based on separator. - */ -function splitLine(line: string, separator: string | null): string[] { - if (separator) { - return line.split(separator); - } - // Whitespace: split on runs of whitespace, filtering empty strings - return line.split(/[ \t]+/).filter((f) => f.length > 0); -} - -/** - * Parse a line into fields and extract the join key. - */ -function parseLine( - line: string, - separator: string | null, - joinField: number, - ignoreCase: boolean, -): ParsedLine { - const fields = splitLine(line, separator); - let joinKey = fields[joinField - 1] ?? ""; - if (ignoreCase) { - joinKey = joinKey.toLowerCase(); - } - return { fields, joinKey, original: line }; -} - -/** - * Format output line based on format spec or defaults. - */ -function formatOutputLine( - line1: ParsedLine | null, - line2: ParsedLine | null, - options: JoinOptions, -): string { - const sep = options.separator ?? " "; - - if (options.outputFormat) { - // Custom format: output specified fields - const parts: string[] = []; - for (const { file, field } of options.outputFormat) { - const line = file === 1 ? line1 : line2; - if (line && field === 0) { - // 0 means the join field - parts.push(line.joinKey); - } else if (line && line.fields[field - 1] !== undefined) { - parts.push(line.fields[field - 1]); - } else { - parts.push(options.emptyString); - } - } - return parts.join(sep); - } - - // Default format: join field, then all fields from file1 (except join), - // then all fields from file2 (except join) - const parts: string[] = []; - - // The join field - const joinField = line1?.joinKey ?? line2?.joinKey ?? ""; - parts.push(joinField); - - // All fields from file1 except the join field - if (line1) { - for (let i = 0; i < line1.fields.length; i++) { - if (i !== options.field1 - 1) { - parts.push(line1.fields[i]); - } - } - } - - // All fields from file2 except the join field - if (line2) { - for (let i = 0; i < line2.fields.length; i++) { - if (i !== options.field2 - 1) { - parts.push(line2.fields[i]); - } - } - } - - return parts.join(sep); -} - -/** - * Parse output format specification like "1.1,1.2,2.1" - */ -function parseOutputFormat( - format: string, -): Array<{ file: number; field: number }> | null { - const parts = format.split(","); - const result: Array<{ file: number; field: number }> = []; - - for (const part of parts) { - const match = part.trim().match(/^(\d+)\.(\d+)$/); - if (!match) { - return null; - } - const file = Number.parseInt(match[1], 10); - const field = Number.parseInt(match[2], 10); - if (file !== 1 && file !== 2) { - return null; - } - result.push({ file, field }); - } - - return result; -} - -export const join: Command = { - name: "join", - execute: async (args: string[], ctx: CommandContext): Promise => { - if (hasHelpFlag(args)) { - return showHelp(joinHelp); - } - - const options: JoinOptions = { - field1: 1, - field2: 1, - separator: null, - printUnpairable: new Set(), - onlyUnpairable: new Set(), - emptyString: "", - outputFormat: null, - ignoreCase: false, - }; - - const files: string[] = []; - let i = 0; - - while (i < args.length) { - const arg = args[i]; - - if (arg === "-1" && i + 1 < args.length) { - const field = Number.parseInt(args[i + 1], 10); - if (Number.isNaN(field) || field < 1) { - return { - exitCode: 1, - stdout: "", - stderr: `join: invalid field number: '${args[i + 1]}'\n`, - }; - } - options.field1 = field; - i += 2; - } else if (arg === "-2" && i + 1 < args.length) { - const field = Number.parseInt(args[i + 1], 10); - if (Number.isNaN(field) || field < 1) { - return { - exitCode: 1, - stdout: "", - stderr: `join: invalid field number: '${args[i + 1]}'\n`, - }; - } - options.field2 = field; - i += 2; - } else if ( - (arg === "-t" || arg === "--field-separator") && - i + 1 < args.length - ) { - options.separator = args[i + 1]; - i += 2; - } else if (arg.startsWith("-t") && arg.length > 2) { - options.separator = arg.slice(2); - i++; - } else if (arg === "-a" && i + 1 < args.length) { - const fileNum = Number.parseInt(args[i + 1], 10); - if (fileNum !== 1 && fileNum !== 2) { - return { - exitCode: 1, - stdout: "", - stderr: `join: invalid file number: '${args[i + 1]}'\n`, - }; - } - options.printUnpairable.add(fileNum); - i += 2; - } else if (arg.match(/^-a[12]$/)) { - options.printUnpairable.add(Number.parseInt(arg[2], 10)); - i++; - } else if (arg === "-v" && i + 1 < args.length) { - const fileNum = Number.parseInt(args[i + 1], 10); - if (fileNum !== 1 && fileNum !== 2) { - return { - exitCode: 1, - stdout: "", - stderr: `join: invalid file number: '${args[i + 1]}'\n`, - }; - } - options.onlyUnpairable.add(fileNum); - i += 2; - } else if (arg.match(/^-v[12]$/)) { - options.onlyUnpairable.add(Number.parseInt(arg[2], 10)); - i++; - } else if (arg === "-e" && i + 1 < args.length) { - options.emptyString = args[i + 1]; - i += 2; - } else if (arg === "-o" && i + 1 < args.length) { - const format = parseOutputFormat(args[i + 1]); - if (!format) { - return { - exitCode: 1, - stdout: "", - stderr: `join: invalid field spec: '${args[i + 1]}'\n`, - }; - } - options.outputFormat = format; - i += 2; - } else if (arg === "-i" || arg === "--ignore-case") { - options.ignoreCase = true; - i++; - } else if (arg === "--") { - files.push(...args.slice(i + 1)); - break; - } else if (arg.startsWith("-") && arg !== "-") { - return unknownOption("join", arg); - } else { - files.push(arg); - i++; - } - } - - // Need exactly 2 files - if (files.length !== 2) { - return { - exitCode: 1, - stdout: "", - stderr: - files.length < 2 - ? "join: missing file operand\n" - : "join: extra operand\n", - }; - } - - // Read both files - const contents: string[] = []; - for (const file of files) { - if (file === "-") { - contents.push(ctx.stdin ?? ""); - } else { - const filePath = ctx.fs.resolvePath(ctx.cwd, file); - const content = await ctx.fs.readFile(filePath); - if (content === null) { - return { - exitCode: 1, - stdout: "", - stderr: `join: ${file}: No such file or directory\n`, - }; - } - contents.push(content); - } - } - - // Parse lines from both files - const parseLines = (content: string, joinField: number): ParsedLine[] => { - const lines = content.split("\n"); - if (content.endsWith("\n") && lines[lines.length - 1] === "") { - lines.pop(); - } - return lines - .filter((line) => line.length > 0) - .map((line) => - parseLine(line, options.separator, joinField, options.ignoreCase), - ); - }; - - const lines1 = parseLines(contents[0], options.field1); - const lines2 = parseLines(contents[1], options.field2); - - // Build index of file2 lines by join key (for efficient lookup) - const index2 = new Map(); - for (const line of lines2) { - const existing = index2.get(line.joinKey); - if (existing) { - existing.push(line); - } else { - index2.set(line.joinKey, [line]); - } - } - - const output: string[] = []; - const matchedKeys2 = new Set(); - - // Process file1 lines - for (const line1 of lines1) { - const matches = index2.get(line1.joinKey); - - if (matches && matches.length > 0) { - // Found matches - matchedKeys2.add(line1.joinKey); - - if (options.onlyUnpairable.size === 0) { - // Output joined lines (unless we only want unpairable) - for (const line2 of matches) { - output.push(formatOutputLine(line1, line2, options)); - } - } - } else { - // No match - print if -a1 or -v1 - if (options.printUnpairable.has(1) || options.onlyUnpairable.has(1)) { - output.push(formatOutputLine(line1, null, options)); - } - } - } - - // Print unpairable lines from file2 if requested - if (options.printUnpairable.has(2) || options.onlyUnpairable.has(2)) { - for (const line2 of lines2) { - if (!matchedKeys2.has(line2.joinKey)) { - output.push(formatOutputLine(null, line2, options)); - } - } - } - - return { - exitCode: 0, - stdout: output.length > 0 ? `${output.join("\n")}\n` : "", - stderr: "", - }; - }, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "join", - flags: [ - { flag: "-1", type: "value", valueHint: "number" }, - { flag: "-2", type: "value", valueHint: "number" }, - { flag: "-t", type: "value", valueHint: "delimiter" }, - { flag: "-a", type: "value", valueHint: "number" }, - { flag: "-v", type: "value", valueHint: "number" }, - { flag: "-e", type: "value", valueHint: "string" }, - { flag: "-o", type: "value", valueHint: "format" }, - { flag: "-i", type: "boolean" }, - ], - needsArgs: true, - minArgs: 2, -}; diff --git a/src/commands/jq/jq.basic.test.ts b/src/commands/jq/jq.basic.test.ts deleted file mode 100644 index 39f789b2..00000000 --- a/src/commands/jq/jq.basic.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("jq basic", () => { - describe("identity filter", () => { - it("should pass through JSON with .", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"a\":1}' | jq '.'"); - expect(result.stdout).toBe('{\n "a": 1\n}\n'); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should pretty print arrays", async () => { - const env = new Bash(); - const result = await env.exec("echo '[1,2,3]' | jq '.'"); - expect(result.stdout).toBe("[\n 1,\n 2,\n 3\n]\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("object access", () => { - it("should access object key with .key", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"name\":\"test\"}' | jq '.name'"); - expect(result.stdout).toBe('"test"\n'); - expect(result.exitCode).toBe(0); - }); - - it("should access nested key with .a.b", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'{"a":{"b":"nested"}}\' | jq \'.a.b\'', - ); - expect(result.stdout).toBe('"nested"\n'); - expect(result.exitCode).toBe(0); - }); - - it("should return null for missing key", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"a\":1}' | jq '.missing'"); - expect(result.stdout).toBe("null\n"); - expect(result.exitCode).toBe(0); - }); - - it("should access numeric values", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"count\":42}' | jq '.count'"); - expect(result.stdout).toBe("42\n"); - expect(result.exitCode).toBe(0); - }); - - it("should access boolean values", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"active\":true}' | jq '.active'"); - expect(result.stdout).toBe("true\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("array access", () => { - it("should access array element with .[0]", async () => { - const env = new Bash(); - const result = await env.exec('echo \'["a","b","c"]\' | jq \'.[0]\''); - expect(result.stdout).toBe('"a"\n'); - expect(result.exitCode).toBe(0); - }); - - it("should access last element with .[-1]", async () => { - const env = new Bash(); - const result = await env.exec('echo \'["a","b","c"]\' | jq \'.[-1]\''); - expect(result.stdout).toBe('"c"\n'); - expect(result.exitCode).toBe(0); - }); - - it("should return null for out of bounds index", async () => { - const env = new Bash(); - const result = await env.exec("echo '[1,2]' | jq '.[99]'"); - expect(result.stdout).toBe("null\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("array iteration", () => { - it("should iterate array with .[]", async () => { - const env = new Bash(); - const result = await env.exec("echo '[1,2,3]' | jq '.[]'"); - expect(result.stdout).toBe("1\n2\n3\n"); - expect(result.exitCode).toBe(0); - }); - - it("should iterate object values with .[]", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"a\":1,\"b\":2}' | jq '.[]'"); - expect(result.stdout).toBe("1\n2\n"); - expect(result.exitCode).toBe(0); - }); - - it("should iterate nested array with .items[]", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"items\":[1,2,3]}' | jq '.items[]'", - ); - expect(result.stdout).toBe("1\n2\n3\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("pipes", () => { - it("should pipe filters with |", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"data\":{\"value\":42}}' | jq '.data | .value'", - ); - expect(result.stdout).toBe("42\n"); - expect(result.exitCode).toBe(0); - }); - - it("should chain multiple pipes", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'{"a":{"b":{"c":"deep"}}}\' | jq \'.a | .b | .c\'', - ); - expect(result.stdout).toBe('"deep"\n'); - expect(result.exitCode).toBe(0); - }); - }); - - describe("array slicing", () => { - it("should slice with start and end", async () => { - const env = new Bash(); - const result = await env.exec("echo '[0,1,2,3,4,5]' | jq '.[2:4]'"); - expect(result.stdout).toBe("[\n 2,\n 3\n]\n"); - }); - - it("should slice from start", async () => { - const env = new Bash(); - const result = await env.exec("echo '[0,1,2,3,4]' | jq '.[:3]'"); - expect(result.stdout).toBe("[\n 0,\n 1,\n 2\n]\n"); - }); - - it("should slice to end", async () => { - const env = new Bash(); - const result = await env.exec("echo '[0,1,2,3,4]' | jq '.[3:]'"); - expect(result.stdout).toBe("[\n 3,\n 4\n]\n"); - }); - - it("should slice strings", async () => { - const env = new Bash(); - const result = await env.exec("echo '\"hello\"' | jq '.[1:4]'"); - expect(result.stdout).toBe('"ell"\n'); - }); - - it("should access with negative index in slice", async () => { - const env = new Bash(); - const result = await env.exec("echo '[0,1,2,3,4]' | jq '.[-2:]'"); - expect(result.stdout).toBe("[\n 3,\n 4\n]\n"); - }); - }); - - describe("comma operator", () => { - it("should output multiple values", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"a\":1,\"b\":2}' | jq '.a, .b'"); - expect(result.stdout).toBe("1\n2\n"); - }); - - it("should work with three values", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'{"x":1,"y":2,"z":3}\' | jq \'.x, .y, .z\'', - ); - expect(result.stdout).toBe("1\n2\n3\n"); - }); - }); -}); diff --git a/src/commands/jq/jq.construction.test.ts b/src/commands/jq/jq.construction.test.ts deleted file mode 100644 index 1062a628..00000000 --- a/src/commands/jq/jq.construction.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("jq construction", () => { - describe("object construction", () => { - it("should construct object with static keys", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'{"name":"test","value":42}\' | jq -c \'{n: .name, v: .value}\'', - ); - expect(result.stdout).toBe('{"n":"test","v":42}\n'); - }); - - it("should construct object with shorthand", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'{"name":"test","value":42}\' | jq -c \'{name, value}\'', - ); - expect(result.stdout).toBe('{"name":"test","value":42}\n'); - }); - - it("should construct object with dynamic keys", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'{"key":"foo","val":42}\' | jq -c \'{(.key): .val}\'', - ); - expect(result.stdout).toBe('{"foo":42}\n'); - }); - - it("should allow pipes in object values", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '[[1,2],[3,4]]' | jq -c '{a: .[0] | add, b: .[1] | add}'", - ); - expect(result.stdout).toBe('{"a":3,"b":7}\n'); - }); - }); - - describe("array construction", () => { - it("should construct array from iterator", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"a\":1,\"b\":2}' | jq '[.a, .b]'"); - expect(result.stdout).toBe("[\n 1,\n 2\n]\n"); - }); - - it("should construct array from object values", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'{"a":1,"b":2,"c":3}\' | jq \'[.[]]\'', - ); - expect(result.stdout).toBe("[\n 1,\n 2,\n 3\n]\n"); - }); - }); -}); diff --git a/src/commands/jq/jq.dot-adjacency.test.ts b/src/commands/jq/jq.dot-adjacency.test.ts deleted file mode 100644 index 87406d64..00000000 --- a/src/commands/jq/jq.dot-adjacency.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("jq dot-adjacency rules", () => { - describe("adjacent keyword field access (should work)", () => { - it.each([ - ["null", '"n"'], - ["true", '"t"'], - ["false", '"f"'], - ["then", '"t"'], - ["else", '"e"'], - ["end", '"e"'], - ["as", '"a"'], - ])(".%s should access field", async (kw, expected) => { - const env = new Bash(); - const result = await env.exec( - `echo '{"${kw}":"${expected.replace(/"/g, "")}"}' | jq '.${kw}'`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(`${expected}\n`); - }); - }); - - describe("chained adjacent keyword field access (should work)", () => { - it.each([ - "null", - "then", - "as", - ])(".data.%s should access nested field", async (kw) => { - const env = new Bash(); - const result = await env.exec( - `echo '{"data":{"${kw}":"val"}}' | jq '.data.${kw}'`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"val"\n'); - }); - }); - - describe("space-separated keyword after dot (should error)", () => { - it.each([ - "null", - "true", - "false", - "not", - "as", - "then", - "else", - "end", - "def", - "reduce", - "foreach", - "label", - "catch", - ])(". %s should error", async (kw) => { - const env = new Bash(); - const result = await env.exec(`echo '{"${kw}":"x"}' | jq '. ${kw}'`); - expect(result.exitCode).not.toBe(0); - }); - }); - - describe("chained space-separated keyword after dot (should error)", () => { - it.each([ - "as", - "or", - "and", - "then", - ])(".data. %s should error", async (kw) => { - const env = new Bash(); - const result = await env.exec( - `echo '{"data":{"${kw}":"x"}}' | jq '.data. ${kw}'`, - ); - expect(result.exitCode).not.toBe(0); - }); - }); - - describe("space-separated identifier after dot (should error)", () => { - it("should error on '. foo' (double space)", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"foo\":\"x\"}' | jq '. foo'"); - expect(result.exitCode).not.toBe(0); - }); - }); - - describe("string after dot with whitespace (should work)", () => { - it('should allow . "foo" (double space + string)', async () => { - const env = new Bash(); - const result = await env.exec('echo \'{"foo":"bar"}\' | jq \'. "foo"\''); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"bar"\n'); - }); - - it('should allow ."foo" (adjacent string)', async () => { - const env = new Bash(); - const result = await env.exec('echo \'{"foo":"bar"}\' | jq \'."foo"\''); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"bar"\n'); - }); - - it('should allow .data."foo" (chained adjacent string)', async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'{"data":{"foo":"bar"}}\' | jq \'.data."foo"\'', - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"bar"\n'); - }); - }); - - describe("postfix dot after index/parens", () => { - it("should error on .[0]. foo (space + ident after index)", async () => { - const env = new Bash(); - const result = await env.exec("echo '[{\"foo\":1}]' | jq '.[0]. foo'"); - expect(result.exitCode).not.toBe(0); - }); - - it('should allow .[0]. "foo" (space + string after index)', async () => { - const env = new Bash(); - const result = await env.exec( - "echo '[{\"foo\":1}]' | jq '.[0]. \"foo\"'", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1\n"); - }); - - it("should error on (.). foo (space + ident after parens)", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"foo\":1}' | jq '(.). foo'"); - expect(result.exitCode).not.toBe(0); - }); - - it('should allow (.). "foo" (space + string after parens)', async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"foo\":1}' | jq '(.). \"foo\"'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1\n"); - }); - }); -}); diff --git a/src/commands/jq/jq.filters.test.ts b/src/commands/jq/jq.filters.test.ts deleted file mode 100644 index e32c994d..00000000 --- a/src/commands/jq/jq.filters.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("jq filters", () => { - describe("select and map", () => { - it("should filter with select", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '[1,2,3,4,5]' | jq '[.[] | select(. > 3)]'", - ); - expect(result.stdout).toBe("[\n 4,\n 5\n]\n"); - }); - - it("should transform with map", async () => { - const env = new Bash(); - const result = await env.exec("echo '[1,2,3]' | jq 'map(. * 2)'"); - expect(result.stdout).toBe("[\n 2,\n 4,\n 6\n]\n"); - }); - - it("should chain select and map", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '[1,2,3,4,5]' | jq '[.[] | select(. > 2) | . * 10]'", - ); - expect(result.stdout).toBe("[\n 30,\n 40,\n 50\n]\n"); - }); - - it("should select objects by field", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'[{"n":1},{"n":5},{"n":2}]\' | jq -c \'[.[] | select(.n > 2)]\'', - ); - expect(result.stdout).toBe('[{"n":5}]\n'); - }); - }); - - describe("has and in", () => { - it("should check has for object", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"foo\":42}' | jq 'has(\"foo\")'"); - expect(result.stdout).toBe("true\n"); - }); - - it("should check has for missing key", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"foo\":42}' | jq 'has(\"bar\")'"); - expect(result.stdout).toBe("false\n"); - }); - - it("should check has for array", async () => { - const env = new Bash(); - const result = await env.exec("echo '[1,2,3]' | jq 'has(1)'"); - expect(result.stdout).toBe("true\n"); - }); - }); - - describe("contains", () => { - it("should check array contains", async () => { - const env = new Bash(); - const result = await env.exec("echo '[1,2,3]' | jq 'contains([2])'"); - expect(result.stdout).toBe("true\n"); - }); - - it("should check object contains", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'{"a":1,"b":2}\' | jq \'contains({"a":1})\'', - ); - expect(result.stdout).toBe("true\n"); - }); - }); - - describe("any and all", () => { - it("should check any with expression", async () => { - const env = new Bash(); - const result = await env.exec("echo '[1,2,3,4,5]' | jq 'any(. > 3)'"); - expect(result.stdout).toBe("true\n"); - }); - - it("should check all with expression", async () => { - const env = new Bash(); - const result = await env.exec("echo '[1,2,3]' | jq 'all(. > 0)'"); - expect(result.stdout).toBe("true\n"); - }); - }); - - describe("conditionals", () => { - it("should evaluate if-then-else", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '5' | jq 'if . > 3 then \"big\" else \"small\" end'", - ); - expect(result.stdout).toBe('"big"\n'); - }); - - it("should evaluate else branch", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '2' | jq 'if . > 3 then \"big\" else \"small\" end'", - ); - expect(result.stdout).toBe('"small"\n'); - }); - - it("should evaluate elif", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'5\' | jq \'if . > 10 then "big" elif . > 3 then "medium" else "small" end\'', - ); - expect(result.stdout).toBe('"medium"\n'); - }); - }); - - describe("optional operator", () => { - it("should return null for missing key with ?", async () => { - const env = new Bash(); - const result = await env.exec("echo 'null' | jq '.foo?'"); - expect(result.stdout).toBe("null\n"); - }); - - it("should return value if present with ?", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"foo\":42}' | jq '.foo?'"); - expect(result.stdout).toBe("42\n"); - }); - }); - - describe("try-catch", () => { - it("should catch errors", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '1' | jq 'try error(\"oops\") catch \"caught\"'", - ); - expect(result.stdout).toBe('"caught"\n'); - }); - }); - - describe("variables", () => { - it("should bind and use variable", async () => { - const env = new Bash(); - const result = await env.exec("echo '5' | jq '. as $x | $x * $x'"); - expect(result.stdout).toBe("25\n"); - }); - - it("should use variable in object construction", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '3' | jq -c '. as $n | {value: $n, doubled: ($n * 2)}'", - ); - expect(result.stdout).toBe('{"value":3,"doubled":6}\n'); - }); - }); -}); diff --git a/src/commands/jq/jq.functions.test.ts b/src/commands/jq/jq.functions.test.ts deleted file mode 100644 index a8cbd7ea..00000000 --- a/src/commands/jq/jq.functions.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("jq builtin functions", () => { - describe("keys and values", () => { - it("should get keys sorted", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"b\":1,\"a\":2}' | jq 'keys'"); - expect(result.stdout).toBe('[\n "a",\n "b"\n]\n'); - expect(result.exitCode).toBe(0); - }); - - it("should get values with .[]]", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"a\":1,\"b\":2}' | jq '[.[]]'"); - expect(result.stdout).toBe("[\n 1,\n 2\n]\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("length", () => { - it("should get length of array", async () => { - const env = new Bash(); - const result = await env.exec("echo '[1,2,3,4,5]' | jq 'length'"); - expect(result.stdout).toBe("5\n"); - expect(result.exitCode).toBe(0); - }); - - it("should get length of string", async () => { - const env = new Bash(); - const result = await env.exec("echo '\"hello\"' | jq 'length'"); - expect(result.stdout).toBe("5\n"); - expect(result.exitCode).toBe(0); - }); - - it("should get length of object", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"a\":1,\"b\":2}' | jq 'length'"); - expect(result.stdout).toBe("2\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("type", () => { - it("should get type of object", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"a\":1}' | jq 'type'"); - expect(result.stdout).toBe('"object"\n'); - expect(result.exitCode).toBe(0); - }); - - it("should get type of array", async () => { - const env = new Bash(); - const result = await env.exec("echo '[1,2]' | jq 'type'"); - expect(result.stdout).toBe('"array"\n'); - expect(result.exitCode).toBe(0); - }); - }); - - describe("first and last", () => { - it("should get first element", async () => { - const env = new Bash(); - const result = await env.exec("echo '[5,10,15]' | jq 'first'"); - expect(result.stdout).toBe("5\n"); - expect(result.exitCode).toBe(0); - }); - - it("should get last element", async () => { - const env = new Bash(); - const result = await env.exec("echo '[5,10,15]' | jq 'last'"); - expect(result.stdout).toBe("15\n"); - expect(result.exitCode).toBe(0); - }); - - it("should get first of expression", async () => { - const env = new Bash(); - const result = await env.exec("jq -n 'first(range(10))'"); - expect(result.stdout).toBe("0\n"); - }); - }); - - describe("reverse", () => { - it("should reverse array", async () => { - const env = new Bash(); - const result = await env.exec("echo '[1,2,3]' | jq 'reverse'"); - expect(result.stdout).toBe("[\n 3,\n 2,\n 1\n]\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("sort and unique", () => { - it("should sort array", async () => { - const env = new Bash(); - const result = await env.exec("echo '[3,1,2]' | jq 'sort'"); - expect(result.stdout).toBe("[\n 1,\n 2,\n 3\n]\n"); - expect(result.exitCode).toBe(0); - }); - - it("should get unique values", async () => { - const env = new Bash(); - const result = await env.exec("echo '[1,2,1,3,2]' | jq 'unique'"); - expect(result.stdout).toBe("[\n 1,\n 2,\n 3\n]\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("add", () => { - it("should add numbers", async () => { - const env = new Bash(); - const result = await env.exec("echo '[1,2,3,4]' | jq 'add'"); - expect(result.stdout).toBe("10\n"); - expect(result.exitCode).toBe(0); - }); - - it("should concatenate strings", async () => { - const env = new Bash(); - const result = await env.exec('echo \'["a","b","c"]\' | jq \'add\''); - expect(result.stdout).toBe('"abc"\n'); - expect(result.exitCode).toBe(0); - }); - }); - - describe("min and max", () => { - it("should get min value", async () => { - const env = new Bash(); - const result = await env.exec("echo '[5,2,8,1]' | jq 'min'"); - expect(result.stdout).toBe("1\n"); - expect(result.exitCode).toBe(0); - }); - - it("should get max value", async () => { - const env = new Bash(); - const result = await env.exec("echo '[5,2,8,1]' | jq 'max'"); - expect(result.stdout).toBe("8\n"); - expect(result.exitCode).toBe(0); - }); - - it("should find min_by", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'[{"n":3},{"n":1},{"n":2}]\' | jq -c \'min_by(.n)\'', - ); - expect(result.stdout).toBe('{"n":1}\n'); - }); - - it("should find max_by", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'[{"n":3},{"n":1},{"n":2}]\' | jq -c \'max_by(.n)\'', - ); - expect(result.stdout).toBe('{"n":3}\n'); - }); - }); - - describe("flatten", () => { - it("should flatten arrays", async () => { - const env = new Bash(); - const result = await env.exec("echo '[[1,2],[3,4]]' | jq 'flatten'"); - expect(result.stdout).toBe("[\n 1,\n 2,\n 3,\n 4\n]\n"); - expect(result.exitCode).toBe(0); - }); - - it("should flatten with specific depth", async () => { - const env = new Bash(); - const result = await env.exec("echo '[[[1]],[[2]]]' | jq 'flatten(1)'"); - expect(result.stdout).toBe("[\n [\n 1\n ],\n [\n 2\n ]\n]\n"); - }); - }); - - describe("group_by and sort_by", () => { - it("should sort_by field", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'[{"n":3},{"n":1},{"n":2}]\' | jq -c \'sort_by(.n)\'', - ); - expect(result.stdout).toBe('[{"n":1},{"n":2},{"n":3}]\n'); - }); - - it("should group_by field", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'[{"g":1,"v":"a"},{"g":2,"v":"b"},{"g":1,"v":"c"}]\' | jq -c \'group_by(.g)\'', - ); - expect(result.stdout).toBe( - '[[{"g":1,"v":"a"},{"g":1,"v":"c"}],[{"g":2,"v":"b"}]]\n', - ); - }); - - it("should unique_by field", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'[{"n":1},{"n":2},{"n":1}]\' | jq -c \'unique_by(.n)\'', - ); - expect(result.stdout).toBe('[{"n":1},{"n":2}]\n'); - }); - }); - - describe("to_entries and from_entries", () => { - it("should convert to entries", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"a\":1,\"b\":2}' | jq -c 'to_entries'", - ); - expect(result.stdout).toBe( - '[{"key":"a","value":1},{"key":"b","value":2}]\n', - ); - }); - - it("should convert from entries", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'[{"key":"a","value":1}]\' | jq -c \'from_entries\'', - ); - expect(result.stdout).toBe('{"a":1}\n'); - }); - - it("should use with_entries", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"a\":1,\"b\":2}' | jq -c 'with_entries({key: .key, value: (.value + 10)})'", - ); - expect(result.stdout).toBe('{"a":11,"b":12}\n'); - }); - }); - - describe("transpose", () => { - it("should transpose matrix", async () => { - const env = new Bash(); - const result = await env.exec("echo '[[1,2],[3,4]]' | jq 'transpose'"); - expect(result.stdout).toBe( - "[\n [\n 1,\n 3\n ],\n [\n 2,\n 4\n ]\n]\n", - ); - }); - }); - - describe("range", () => { - it("should generate range", async () => { - const env = new Bash(); - const result = await env.exec("jq -n '[range(5)]'"); - expect(result.stdout).toBe("[\n 0,\n 1,\n 2,\n 3,\n 4\n]\n"); - }); - - it("should generate range with start and end", async () => { - const env = new Bash(); - const result = await env.exec("jq -n '[range(2;5)]'"); - expect(result.stdout).toBe("[\n 2,\n 3,\n 4\n]\n"); - }); - }); - - describe("limit", () => { - it("should limit results", async () => { - const env = new Bash(); - const result = await env.exec("jq -n '[limit(3; range(10))]'"); - expect(result.stdout).toBe("[\n 0,\n 1,\n 2\n]\n"); - }); - }); - - describe("getpath and setpath", () => { - it("should getpath", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'{"a":{"b":42}}\' | jq \'getpath(["a","b"])\'', - ); - expect(result.stdout).toBe("42\n"); - }); - - it("should setpath", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"a\":1}' | jq -c 'setpath([\"b\"]; 2)'", - ); - expect(result.stdout).toBe('{"a":1,"b":2}\n'); - }); - }); - - describe("recurse", () => { - it("should recurse through structure", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"a\":{\"b\":1}}' | jq '[.. | numbers]'", - ); - expect(result.stdout).toBe("[\n 1\n]\n"); - }); - }); - - describe("math functions with two arguments", () => { - it("should compute pow(base; exp)", async () => { - const env = new Bash(); - const result = await env.exec("jq -n 'pow(2; 3)'"); - expect(result.stdout).toBe("8\n"); - expect(result.exitCode).toBe(0); - }); - - it("should compute pow with non-integer exponent", async () => { - const env = new Bash(); - const result = await env.exec("jq -n 'pow(4; 0.5)'"); - expect(result.stdout).toBe("2\n"); - expect(result.exitCode).toBe(0); - }); - - it("should compute atan2(y; x)", async () => { - const env = new Bash(); - const result = await env.exec("jq -n 'atan2(3; 4)'"); - expect(result.stdout).toBe("0.6435011087932844\n"); - expect(result.exitCode).toBe(0); - }); - - it("should compute atan2 with negative values", async () => { - const env = new Bash(); - const result = await env.exec("jq -n 'atan2(-1; -1)'"); - // atan2(-1, -1) = -2.356194490192345 (third quadrant) - expect(result.stdout).toBe("-2.356194490192345\n"); - expect(result.exitCode).toBe(0); - }); - - it("should return null for pow with non-numeric args", async () => { - const env = new Bash(); - const result = await env.exec("jq -n 'pow(\"a\"; 2)'"); - expect(result.stdout).toBe("null\n"); - }); - - it("should return null for atan2 with non-numeric args", async () => { - const env = new Bash(); - const result = await env.exec("jq -n 'atan2(\"a\"; 2)'"); - expect(result.stdout).toBe("null\n"); - }); - }); -}); diff --git a/src/commands/jq/jq.keyword-field-access.test.ts b/src/commands/jq/jq.keyword-field-access.test.ts deleted file mode 100644 index a76649c7..00000000 --- a/src/commands/jq/jq.keyword-field-access.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("jq keyword field access", () => { - describe("field names that are keywords should be accessible with dot notation", () => { - it("should access .label field", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"label\":\"hello\"}' | jq '.label'", - ); - expect(result.stdout).toBe('"hello"\n'); - expect(result.exitCode).toBe(0); - }); - - it("should access .and field", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"and\":true}' | jq '.and'"); - expect(result.stdout).toBe("true\n"); - expect(result.exitCode).toBe(0); - }); - - it("should access .or field", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"or\":false}' | jq '.or'"); - expect(result.stdout).toBe("false\n"); - expect(result.exitCode).toBe(0); - }); - - it("should access .not field", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"not\":42}' | jq '.not'"); - expect(result.stdout).toBe("42\n"); - expect(result.exitCode).toBe(0); - }); - - it("should access .if field", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"if\":\"value\"}' | jq '.if'"); - expect(result.stdout).toBe('"value"\n'); - expect(result.exitCode).toBe(0); - }); - - it("should access .try field", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"try\":1}' | jq '.try'"); - expect(result.stdout).toBe("1\n"); - expect(result.exitCode).toBe(0); - }); - - it("should access .catch field", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"catch\":2}' | jq '.catch'"); - expect(result.stdout).toBe("2\n"); - expect(result.exitCode).toBe(0); - }); - - it("should access .reduce field", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"reduce\":\"data\"}' | jq '.reduce'", - ); - expect(result.stdout).toBe('"data"\n'); - expect(result.exitCode).toBe(0); - }); - - it("should access .foreach field", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"foreach\":\"items\"}' | jq '.foreach'", - ); - expect(result.stdout).toBe('"items"\n'); - expect(result.exitCode).toBe(0); - }); - - it("should access .def field", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"def\":\"definition\"}' | jq '.def'", - ); - expect(result.stdout).toBe('"definition"\n'); - expect(result.exitCode).toBe(0); - }); - - it("should access .break field", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"break\":\"stop\"}' | jq '.break'", - ); - expect(result.stdout).toBe('"stop"\n'); - expect(result.exitCode).toBe(0); - }); - }); - - describe("chained keyword field access", () => { - it("should access nested .label field", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'{"data":{"label":"nested"}}\' | jq \'.data.label\'', - ); - expect(result.stdout).toBe('"nested"\n'); - expect(result.exitCode).toBe(0); - }); - - it("should access .label in compact output", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'{"label":"x","value":1}\' | jq -c \'{lab: .label, val: .value}\'', - ); - expect(result.stdout).toBe('{"lab":"x","val":1}\n'); - expect(result.exitCode).toBe(0); - }); - }); - - describe("space-separated keyword after dot should NOT be field access", () => { - it("should error on '. if' (space before keyword)", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"if\":\"value\"}' | jq '. if'"); - expect(result.exitCode).not.toBe(0); - }); - - it("should error on '. and' (space before keyword)", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"and\":true}' | jq '. and'"); - expect(result.exitCode).not.toBe(0); - }); - - it("should error on '. try' (space before keyword)", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"try\":1}' | jq '. try'"); - expect(result.exitCode).not.toBe(0); - }); - }); - - describe("space-separated identifier after dot should NOT be field access", () => { - it("should error on '. foo' (space before identifier)", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"foo\":\"bar\"}' | jq '. foo'"); - expect(result.exitCode).not.toBe(0); - }); - - it("should error on '.data. foo' (chained, space before identifier)", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'{"data":{"foo":"bar"}}\' | jq \'.data. foo\'', - ); - expect(result.exitCode).not.toBe(0); - }); - }); - - describe("space-separated string after dot SHOULD be field access", () => { - it("should allow '. \"foo\"' (space before string)", async () => { - const env = new Bash(); - const result = await env.exec('echo \'{"foo":"bar"}\' | jq \'. "foo"\''); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"bar"\n'); - }); - - it("should allow '.data. \"foo\"' (chained, space before string)", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'{"data":{"foo":"bar"}}\' | jq \'.data. "foo"\'', - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"bar"\n'); - }); - }); - - describe("keyword as object key", () => { - it("should allow keyword as object construction key with value", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"label\":\"hello\"}' | jq -c '{label: .label}'", - ); - expect(result.stdout).toBe('{"label":"hello"}\n'); - expect(result.exitCode).toBe(0); - }); - - it("should allow keyword as object shorthand key", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"label\":\"hello\"}' | jq -c '{label}'", - ); - expect(result.stdout).toBe('{"label":"hello"}\n'); - expect(result.exitCode).toBe(0); - }); - - it("should allow 'not' as object key", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"not\":true}' | jq -c '{not: .not}'", - ); - expect(result.stdout).toBe('{"not":true}\n'); - expect(result.exitCode).toBe(0); - }); - - it("should allow 'and' as object shorthand key", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"and\":1}' | jq -c '{and}'"); - expect(result.stdout).toBe('{"and":1}\n'); - expect(result.exitCode).toBe(0); - }); - }); - - describe("keyword as object destructuring pattern key", () => { - it("should allow keyword key in object destructuring", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"label\":\"hello\"}' | jq -c '. as {label: $l} | $l'", - ); - expect(result.stdout).toBe('"hello"\n'); - expect(result.exitCode).toBe(0); - }); - - it("should allow keyword shorthand in object destructuring", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"not\":42}' | jq '. as {$not} | $not'", - ); - expect(result.stdout).toBe("42\n"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/commands/jq/jq.limits.test.ts b/src/commands/jq/jq.limits.test.ts deleted file mode 100644 index bb6c5d22..00000000 --- a/src/commands/jq/jq.limits.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; -import { ExecutionLimitError } from "../../interpreter/errors.js"; - -/** - * JQ Execution Limits Tests - * - * These tests verify that jq commands cannot cause runaway compute. - * JQ programs should complete in bounded time regardless of input. - * - * IMPORTANT: All tests should complete quickly (<1s each). - */ - -describe("JQ Execution Limits", () => { - describe("until loop protection", () => { - it("should protect against infinite until loop", async () => { - const env = new Bash(); - // until condition that never becomes true - const result = await env.exec(`echo 'null' | jq 'until(false; .)'`); - - expect(result.stderr).toContain("too many iterations"); - expect(result.exitCode).toBe(ExecutionLimitError.EXIT_CODE); - }); - - it("should allow until that terminates", async () => { - const env = new Bash(); - const result = await env.exec(`echo '0' | jq 'until(. >= 5; . + 1)'`); - - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("5"); - }); - }); - - describe("while loop protection", () => { - it("should protect against infinite while loop", async () => { - const env = new Bash(); - // while condition that's always true - const result = await env.exec(`echo '0' | jq '[while(true; . + 1)]'`); - - expect(result.stderr).toContain("too many iterations"); - expect(result.exitCode).toBe(ExecutionLimitError.EXIT_CODE); - }); - - it("should allow while that terminates", async () => { - const env = new Bash(); - const result = await env.exec(`echo '1' | jq '[while(. < 5; . + 1)]'`); - - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toEqual([1, 2, 3, 4]); - }); - }); - - describe("repeat protection", () => { - it("should protect against infinite repeat", async () => { - const env = new Bash(); - // repeat with identity produces infinite stream - const result = await env.exec( - `echo '1' | jq '[limit(100000; repeat(.))]'`, - ); - - expect(result.stderr).toContain("too many iterations"); - expect(result.exitCode).toBe(ExecutionLimitError.EXIT_CODE); - }); - - it("should allow repeat that terminates naturally", async () => { - const env = new Bash(); - // repeat with update that eventually returns empty stops - const result = await env.exec( - `echo '5' | jq -c '[limit(10; repeat(if . > 0 then . - 1 else empty end))]'`, - ); - - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("[5,4,3,2,1,0]"); - }); - }); - - describe("recurse protection", () => { - it("should handle deep recursion with limit", async () => { - const env = new Bash(); - // recurse that doesn't naturally terminate - const result = await env.exec( - `echo '{"a":{"a":{"a":{"a":{}}}}}' | jq '[limit(10; recurse(.a?))]'`, - ); - - expect(result.exitCode).toBe(0); - }); - }); - - describe("range limits", () => { - it("should handle large range with limit", async () => { - const env = new Bash(); - const result = await env.exec(`jq -n '[limit(5; range(1000000))]'`); - - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toEqual([0, 1, 2, 3, 4]); - }); - }); -}); diff --git a/src/commands/jq/jq.operators.test.ts b/src/commands/jq/jq.operators.test.ts deleted file mode 100644 index 7d90f043..00000000 --- a/src/commands/jq/jq.operators.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("jq operators", () => { - describe("arithmetic operators", () => { - it("should add numbers", async () => { - const env = new Bash(); - const result = await env.exec("echo '5' | jq '. + 3'"); - expect(result.stdout).toBe("8\n"); - }); - - it("should subtract numbers", async () => { - const env = new Bash(); - const result = await env.exec("echo '10' | jq '. - 4'"); - expect(result.stdout).toBe("6\n"); - }); - - it("should multiply numbers", async () => { - const env = new Bash(); - const result = await env.exec("echo '6' | jq '. * 7'"); - expect(result.stdout).toBe("42\n"); - }); - - it("should divide numbers", async () => { - const env = new Bash(); - const result = await env.exec("echo '20' | jq '. / 4'"); - expect(result.stdout).toBe("5\n"); - }); - - it("should modulo numbers", async () => { - const env = new Bash(); - const result = await env.exec("echo '17' | jq '. % 5'"); - expect(result.stdout).toBe("2\n"); - }); - - it("should concatenate strings with +", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'{"a":"foo","b":"bar"}\' | jq \'.a + .b\'', - ); - expect(result.stdout).toBe('"foobar"\n'); - }); - - it("should concatenate arrays with +", async () => { - const env = new Bash(); - const result = await env.exec("echo '[[1,2],[3,4]]' | jq '.[0] + .[1]'"); - expect(result.stdout).toBe("[\n 1,\n 2,\n 3,\n 4\n]\n"); - }); - - it("should merge objects with +", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '[{\"a\":1},{\"b\":2}]' | jq -c '.[0] + .[1]'", - ); - expect(result.stdout).toBe('{"a":1,"b":2}\n'); - }); - }); - - describe("comparison operators", () => { - it("should compare equal", async () => { - const env = new Bash(); - const result = await env.exec("echo '5' | jq '. == 5'"); - expect(result.stdout).toBe("true\n"); - }); - - it("should compare not equal", async () => { - const env = new Bash(); - const result = await env.exec("echo '5' | jq '. != 3'"); - expect(result.stdout).toBe("true\n"); - }); - - it("should compare less than", async () => { - const env = new Bash(); - const result = await env.exec("echo '3' | jq '. < 5'"); - expect(result.stdout).toBe("true\n"); - }); - - it("should compare greater than", async () => { - const env = new Bash(); - const result = await env.exec("echo '10' | jq '. > 5'"); - expect(result.stdout).toBe("true\n"); - }); - - it("should compare less than or equal", async () => { - const env = new Bash(); - const result = await env.exec("echo '5' | jq '. <= 5'"); - expect(result.stdout).toBe("true\n"); - }); - - it("should compare greater than or equal", async () => { - const env = new Bash(); - const result = await env.exec("echo '5' | jq '. >= 5'"); - expect(result.stdout).toBe("true\n"); - }); - }); - - describe("logical operators", () => { - it("should evaluate and", async () => { - const env = new Bash(); - const result = await env.exec("echo 'true' | jq '. and true'"); - expect(result.stdout).toBe("true\n"); - }); - - it("should evaluate or", async () => { - const env = new Bash(); - const result = await env.exec("echo 'false' | jq '. or true'"); - expect(result.stdout).toBe("true\n"); - }); - - it("should evaluate not", async () => { - const env = new Bash(); - const result = await env.exec("echo 'true' | jq 'not'"); - expect(result.stdout).toBe("false\n"); - }); - - it("should use alternative operator //", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"a\":null}' | jq '.a // \"default\"'", - ); - expect(result.stdout).toBe('"default"\n'); - }); - - it("should return value if not null with //", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"a\":42}' | jq '.a // \"default\"'", - ); - expect(result.stdout).toBe("42\n"); - }); - }); - - describe("math functions", () => { - it("should floor", async () => { - const env = new Bash(); - const result = await env.exec("echo '3.7' | jq 'floor'"); - expect(result.stdout).toBe("3\n"); - }); - - it("should ceil", async () => { - const env = new Bash(); - const result = await env.exec("echo '3.2' | jq 'ceil'"); - expect(result.stdout).toBe("4\n"); - }); - - it("should round", async () => { - const env = new Bash(); - const result = await env.exec("echo '3.5' | jq 'round'"); - expect(result.stdout).toBe("4\n"); - }); - - it("should sqrt", async () => { - const env = new Bash(); - const result = await env.exec("echo '16' | jq 'sqrt'"); - expect(result.stdout).toBe("4\n"); - }); - - it("should abs", async () => { - const env = new Bash(); - const result = await env.exec("echo '-5' | jq 'abs'"); - expect(result.stdout).toBe("5\n"); - }); - }); - - describe("type conversion", () => { - it("should tostring", async () => { - const env = new Bash(); - const result = await env.exec("echo '42' | jq 'tostring'"); - expect(result.stdout).toBe('"42"\n'); - }); - - it("should tonumber", async () => { - const env = new Bash(); - const result = await env.exec("echo '\"42\"' | jq 'tonumber'"); - expect(result.stdout).toBe("42\n"); - }); - }); -}); diff --git a/src/commands/jq/jq.prototype-pollution.test.ts b/src/commands/jq/jq.prototype-pollution.test.ts deleted file mode 100644 index 35ce89bc..00000000 --- a/src/commands/jq/jq.prototype-pollution.test.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -/** - * Exhaustive tests for prototype pollution defense-in-depth. - * - * JavaScript prototype pollution occurs when attackers can inject properties like - * "__proto__", "constructor", or "prototype" into objects, potentially modifying - * the Object.prototype and affecting all objects in the application. - * - * These tests verify that jq operations safely handle these dangerous keys. - */ -describe("jq prototype pollution defense", () => { - describe("direct field access with dangerous keys", () => { - it("should safely access __proto__ as a regular key", async () => { - const env = new Bash(); - // Accessing __proto__ should not return Object.prototype - const result = await env.exec( - `echo '{"__proto__": "safe"}' | jq '.__proto__'`, - ); - // Should return null (key ignored) or the literal value, but NOT Object methods - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toContain("function"); - }); - - it("should safely access constructor as a regular key", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{"constructor": "safe"}' | jq '.constructor'`, - ); - expect(result.exitCode).toBe(0); - // Should not return the Object constructor function - expect(result.stdout).not.toContain("function"); - }); - - it("should safely access prototype as a regular key", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{"prototype": "safe"}' | jq '.prototype'`, - ); - expect(result.exitCode).toBe(0); - }); - }); - - describe("object construction with dangerous keys", () => { - it("should not pollute prototype when constructing object with __proto__", async () => { - const env = new Bash(); - // Attempting to construct an object with __proto__ key should not pollute Object.prototype - const result = await env.exec( - `echo 'null' | jq '{("__proto__"): "polluted"}'`, - ); - expect(result.exitCode).toBe(0); - // The result should be an empty object (dangerous key filtered out) - expect(result.stdout.trim()).toBe("{}"); - }); - - it("should not pollute prototype when constructing object with constructor", async () => { - const env = new Bash(); - const result = await env.exec( - `echo 'null' | jq '{("constructor"): "polluted"}'`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("{}"); - }); - - it("should not pollute prototype when constructing object with prototype", async () => { - const env = new Bash(); - const result = await env.exec( - `echo 'null' | jq '{("prototype"): "polluted"}'`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("{}"); - }); - - it("should construct normal keys correctly while filtering dangerous ones", async () => { - const env = new Bash(); - const result = await env.exec( - `echo 'null' | jq '{a: 1, ("__proto__"): 2, b: 3}'`, - ); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed).toEqual({ a: 1, b: 3 }); - expect(parsed.__proto__).not.toBe(2); - }); - }); - - describe("from_entries with dangerous keys", () => { - it("should filter __proto__ in from_entries", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '[{"key":"__proto__","value":"polluted"},{"key":"safe","value":"ok"}]' | jq 'from_entries'`, - ); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed).toEqual({ safe: "ok" }); - // Note: "in" operator returns true for inherited properties, use hasOwnProperty - expect(Object.hasOwn(parsed, "__proto__")).toBe(false); - }); - - it("should filter constructor in from_entries", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '[{"key":"constructor","value":"polluted"}]' | jq 'from_entries'`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("{}"); - }); - - it("should filter prototype in from_entries", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '[{"key":"prototype","value":"polluted"}]' | jq 'from_entries'`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("{}"); - }); - - it("should handle from_entries with name/Name/k variants for dangerous keys", async () => { - const env = new Bash(); - // Test with 'name' variant - const result1 = await env.exec( - `echo '[{"name":"__proto__","value":"polluted"}]' | jq 'from_entries'`, - ); - expect(result1.stdout.trim()).toBe("{}"); - - // Test with 'Name' variant - const result2 = await env.exec( - `echo '[{"Name":"constructor","value":"polluted"}]' | jq 'from_entries'`, - ); - expect(result2.stdout.trim()).toBe("{}"); - - // Test with 'k' variant - const result3 = await env.exec( - `echo '[{"k":"prototype","v":"polluted"}]' | jq 'from_entries'`, - ); - expect(result3.stdout.trim()).toBe("{}"); - }); - }); - - describe("with_entries with dangerous keys", () => { - it("should filter __proto__ when renaming keys via with_entries", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{"a":1}' | jq 'with_entries(.key = "__proto__")'`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("{}"); - }); - - it("should filter constructor when transforming via with_entries", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{"a":1}' | jq 'with_entries(.key = "constructor")'`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("{}"); - }); - }); - - describe("setpath with dangerous keys", () => { - it("should ignore setpath with __proto__", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{}' | jq 'setpath(["__proto__"]; "polluted")'`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("{}"); - }); - - it("should ignore setpath with constructor", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{}' | jq 'setpath(["constructor"]; "polluted")'`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("{}"); - }); - - it("should ignore setpath with nested dangerous key", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{"a":{}}' | jq 'setpath(["a","__proto__"]; "polluted")'`, - ); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed).toEqual({ a: {} }); - }); - - it("should set safe keys while ignoring dangerous ones in same path", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{}' | jq 'setpath(["safe"]; "ok") | setpath(["__proto__"]; "bad")'`, - ); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed).toEqual({ safe: "ok" }); - }); - }); - - describe("update operations with dangerous keys", () => { - it("should ignore assignment to .__proto__", async () => { - const env = new Bash(); - const result = await env.exec(`echo '{}' | jq '.__proto__ = "polluted"'`); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("{}"); - }); - - it("should ignore assignment to .constructor", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{}' | jq '.constructor = "polluted"'`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("{}"); - }); - - it("should ignore |= update with dangerous keys", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{}' | jq '.__proto__ |= . + "test"'`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("{}"); - }); - - it("should ignore += update with dangerous keys", async () => { - const env = new Bash(); - const result = await env.exec(`echo '{}' | jq '.__proto__ += "test"'`); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("{}"); - }); - - it("should handle indexed assignment with dangerous string keys", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{}' | jq '.["__proto__"] = "polluted"'`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("{}"); - }); - }); - - describe("delete operations with dangerous keys", () => { - it("should safely handle del(.__proto__)", async () => { - const env = new Bash(); - const result = await env.exec(`echo '{"a":1}' | jq 'del(.__proto__)'`); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed).toEqual({ a: 1 }); - }); - - it("should safely handle del(.constructor)", async () => { - const env = new Bash(); - const result = await env.exec(`echo '{"a":1}' | jq 'del(.constructor)'`); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed).toEqual({ a: 1 }); - }); - - it("should safely handle delpaths with dangerous keys", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{"a":1}' | jq 'delpaths([["__proto__"]])'`, - ); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed).toEqual({ a: 1 }); - }); - }); - - describe("deep merge with dangerous keys", () => { - it("should filter dangerous keys during object multiplication (deep merge)", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{"a":1}' | jq '. * {"__proto__": "polluted"}'`, - ); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed).toEqual({ a: 1 }); - }); - - it("should handle nested dangerous keys in deep merge", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{"a":{"b":1}}' | jq '. * {"a": {"__proto__": "polluted"}}'`, - ); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed).toEqual({ a: { b: 1 } }); - }); - }); - - describe("fromstream with dangerous keys", () => { - it("should filter dangerous keys when reconstructing from stream", async () => { - const env = new Bash(); - // tostream produces path-value pairs, fromstream reconstructs - // Manually craft a stream with dangerous key - const result = await env.exec( - `echo 'null' | jq 'fromstream(([["__proto__"], "polluted"], [[]]))'`, - ); - expect(result.exitCode).toBe(0); - // Should be null or empty object, not an object with __proto__ set - const output = result.stdout.trim(); - expect(output === "null" || output === "{}").toBe(true); - }); - }); - - describe("iterator update with dangerous keys", () => { - it("should handle .[] = update when object has dangerous keys", async () => { - const env = new Bash(); - // When iterating over values and updating, dangerous keys should be skipped - const result = await env.exec( - `echo '{"a":1,"__proto__":2}' | jq '.[] |= . + 10'`, - ); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout.trim()); - // Only 'a' should be updated - expect(parsed.a).toBe(11); - }); - }); - - describe("edge cases and combinations", () => { - it("should handle multiple dangerous keys in single operation", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{}' | jq '{("__proto__"): 1, ("constructor"): 2, ("prototype"): 3, safe: 4}'`, - ); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed).toEqual({ safe: 4 }); - }); - - it("should handle dangerous keys with special characters", async () => { - const env = new Bash(); - // __proto__ with different casing should be allowed (only exact match is dangerous) - const result = await env.exec( - `echo '{}' | jq '{("__Proto__"): 1, ("__PROTO__"): 2}'`, - ); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed).toEqual({ __Proto__: 1, __PROTO__: 2 }); - }); - - it("should handle chained operations with dangerous keys", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{"a":1}' | jq '. + {("__proto__"): 2} | . + {b: 3}'`, - ); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed).toEqual({ a: 1, b: 3 }); - }); - - it("should preserve normal functionality with safe keys", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{}' | jq '{a: 1, b: 2, c: 3} | .d = 4 | del(.b)'`, - ); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed).toEqual({ a: 1, c: 3, d: 4 }); - }); - - it("should handle reduce with potential dangerous key accumulation", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '["__proto__", "safe", "constructor"]' | jq 'reduce .[] as $k ({}; .[$k] = 1)'`, - ); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed).toEqual({ safe: 1 }); - }); - - it("should handle complex nested path operations safely", async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{"a":{"b":{}}}' | jq '.a.b.__proto__ = "polluted"'`, - ); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed).toEqual({ a: { b: {} } }); - }); - }); - - describe("verify Object.prototype is not polluted", () => { - it("should not have added properties to Object.prototype after all operations", async () => { - const env = new Bash(); - // Run multiple pollution attempts - await env.exec(`echo '{}' | jq '.__proto__.polluted = true'`); - await env.exec(`echo '{}' | jq '{("__proto__"): {"test": true}}'`); - await env.exec( - `echo '[{"key":"__proto__","value":{"x":1}}]' | jq 'from_entries'`, - ); - - // Now check that a fresh empty object doesn't have unexpected properties - const result = await env.exec(`echo '{}' | jq 'keys'`); - expect(result.exitCode).toBe(0); - // Empty object should have no keys - expect(result.stdout.trim()).toBe("[]"); - }); - }); -}); diff --git a/src/commands/jq/jq.strings.test.ts b/src/commands/jq/jq.strings.test.ts deleted file mode 100644 index f134679f..00000000 --- a/src/commands/jq/jq.strings.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("jq string functions", () => { - describe("split and join", () => { - it("should split strings", async () => { - const env = new Bash(); - const result = await env.exec("echo '\"a,b,c\"' | jq 'split(\",\")'"); - expect(result.stdout).toBe('[\n "a",\n "b",\n "c"\n]\n'); - }); - - it("should join arrays", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'["a","b","c"]\' | jq \'join("-")\'', - ); - expect(result.stdout).toBe('"a-b-c"\n'); - }); - }); - - describe("test and match", () => { - it("should test regex", async () => { - const env = new Bash(); - const result = await env.exec("echo '\"foobar\"' | jq 'test(\"bar\")'"); - expect(result.stdout).toBe("true\n"); - }); - }); - - describe("startswith and endswith", () => { - it("should check startswith", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '\"hello world\"' | jq 'startswith(\"hello\")'", - ); - expect(result.stdout).toBe("true\n"); - }); - - it("should check endswith", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '\"hello world\"' | jq 'endswith(\"world\")'", - ); - expect(result.stdout).toBe("true\n"); - }); - }); - - describe("ltrimstr and rtrimstr", () => { - it("should ltrimstr", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '\"hello world\"' | jq 'ltrimstr(\"hello \")'", - ); - expect(result.stdout).toBe('"world"\n'); - }); - - it("should rtrimstr", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '\"hello world\"' | jq 'rtrimstr(\" world\")'", - ); - expect(result.stdout).toBe('"hello"\n'); - }); - }); - - describe("case conversion", () => { - it("should ascii_downcase", async () => { - const env = new Bash(); - const result = await env.exec("echo '\"HELLO\"' | jq 'ascii_downcase'"); - expect(result.stdout).toBe('"hello"\n'); - }); - - it("should ascii_upcase", async () => { - const env = new Bash(); - const result = await env.exec("echo '\"hello\"' | jq 'ascii_upcase'"); - expect(result.stdout).toBe('"HELLO"\n'); - }); - }); - - describe("sub and gsub", () => { - it("should substitute first match", async () => { - const env = new Bash(); - const result = await env.exec('echo \'"foobar"\' | jq \'sub("o"; "0")\''); - expect(result.stdout).toBe('"f0obar"\n'); - }); - - it("should substitute all matches", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo \'"foobar"\' | jq \'gsub("o"; "0")\'', - ); - expect(result.stdout).toBe('"f00bar"\n'); - }); - }); - - describe("index and indices", () => { - it("should find index in string", async () => { - const env = new Bash(); - const result = await env.exec("echo '\"foobar\"' | jq 'index(\"bar\")'"); - expect(result.stdout).toBe("3\n"); - }); - - it("should find all indices", async () => { - const env = new Bash(); - const result = await env.exec("echo '\"abcabc\"' | jq 'indices(\"bc\")'"); - expect(result.stdout).toBe("[\n 1,\n 4\n]\n"); - }); - }); -}); diff --git a/src/commands/jq/jq.test.ts b/src/commands/jq/jq.test.ts deleted file mode 100644 index 949561b9..00000000 --- a/src/commands/jq/jq.test.ts +++ /dev/null @@ -1,392 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("jq", () => { - describe("raw output (-r)", () => { - it("should output strings without quotes with -r", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"name\":\"test\"}' | jq -r '.name'", - ); - expect(result.stdout).toBe("test\n"); - expect(result.exitCode).toBe(0); - }); - - it("should work with --raw-output", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"msg\":\"hello\"}' | jq --raw-output '.msg'", - ); - expect(result.stdout).toBe("hello\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("compact output (-c)", () => { - it("should output compact JSON with -c", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"a\":1,\"b\":2}' | jq -c '.'"); - expect(result.stdout).toBe('{"a":1,"b":2}\n'); - expect(result.exitCode).toBe(0); - }); - - it("should output compact arrays", async () => { - const env = new Bash(); - const result = await env.exec("echo '[1,2,3]' | jq -c '.'"); - expect(result.stdout).toBe("[1,2,3]\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("null input (-n)", () => { - it("should work with null input", async () => { - const env = new Bash(); - const result = await env.exec("jq -n 'empty'"); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - }); - }); - - describe("slurp (-s)", () => { - it("should slurp multiple JSON values into array", async () => { - const env = new Bash(); - const result = await env.exec("echo '1\n2\n3' | jq -s '.'"); - expect(result.stdout).toBe("[\n 1,\n 2,\n 3\n]\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("sort keys (-S)", () => { - it("should sort object keys with -S", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"z\":1,\"a\":2}' | jq -S '.'"); - expect(result.stdout).toBe('{\n "a": 2,\n "z": 1\n}\n'); - expect(result.exitCode).toBe(0); - }); - }); - - describe("file input", () => { - it("should read from file", async () => { - const env = new Bash({ - files: { "/data.json": '{"value":123}' }, - }); - const result = await env.exec("jq '.value' /data.json"); - expect(result.stdout).toBe("123\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("multi-file input", () => { - it("should process multiple files", async () => { - const env = new Bash({ - files: { - "/a.json": '{"name":"alice"}', - "/b.json": '{"name":"bob"}', - "/c.json": '{"name":"charlie"}', - }, - }); - const result = await env.exec("jq '.name' /a.json /b.json /c.json"); - expect(result.stdout).toBe('"alice"\n"bob"\n"charlie"\n'); - expect(result.exitCode).toBe(0); - }); - - it("should process many files in parallel", async () => { - // Create 10 files to test batched parallel reading - const files: Record = {}; - for (let i = 0; i < 10; i++) { - files[`/data/file${i}.json`] = JSON.stringify({ id: i, value: i * 10 }); - } - const env = new Bash({ files }); - - const filePaths = Object.keys(files).join(" "); - const result = await env.exec(`jq '.id' ${filePaths}`); - expect(result.stdout).toBe("0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n"); - expect(result.exitCode).toBe(0); - }); - - it("should error on first missing file", async () => { - const env = new Bash({ - files: { - "/a.json": '{"x":1}', - "/c.json": '{"x":3}', - }, - }); - const result = await env.exec("jq '.x' /a.json /missing.json /c.json"); - expect(result.stderr).toBe( - "jq: /missing.json: No such file or directory\n", - ); - expect(result.exitCode).toBe(2); - }); - - it("should handle files with different JSON structures", async () => { - const env = new Bash({ - files: { - "/obj.json": '{"type":"object","value":42}', - "/arr.json": "[1,2,3]", - "/str.json": '"hello"', - }, - }); - const result = await env.exec("jq 'type' /obj.json /arr.json /str.json"); - expect(result.stdout).toBe('"object"\n"array"\n"string"\n'); - expect(result.exitCode).toBe(0); - }); - - it("should handle NDJSON files with multiple JSON values per file", async () => { - const env = new Bash({ - files: { - "/file1.ndjson": '{"id":1}\n{"id":2}', - "/file2.ndjson": '{"id":3}\n{"id":4}', - }, - }); - const result = await env.exec("jq '.id' /file1.ndjson /file2.ndjson"); - expect(result.stdout).toBe("1\n2\n3\n4\n"); - expect(result.exitCode).toBe(0); - }); - - it("should work with -r flag across multiple files", async () => { - const env = new Bash({ - files: { - "/a.json": '{"msg":"hello"}', - "/b.json": '{"msg":"world"}', - }, - }); - const result = await env.exec("jq -r '.msg' /a.json /b.json"); - expect(result.stdout).toBe("hello\nworld\n"); - expect(result.exitCode).toBe(0); - }); - - it("should work with -c flag across multiple files", async () => { - const env = new Bash({ - files: { - "/a.json": '{"x":1,"y":2}', - "/b.json": '{"a":"b","c":"d"}', - }, - }); - const result = await env.exec("jq -c '.' /a.json /b.json"); - expect(result.stdout).toBe('{"x":1,"y":2}\n{"a":"b","c":"d"}\n'); - expect(result.exitCode).toBe(0); - }); - - it("should work with filter that produces multiple outputs per file", async () => { - const env = new Bash({ - files: { - "/a.json": '{"items":["x","y"]}', - "/b.json": '{"items":["z"]}', - }, - }); - const result = await env.exec("jq '.items[]' /a.json /b.json"); - expect(result.stdout).toBe('"x"\n"y"\n"z"\n'); - expect(result.exitCode).toBe(0); - }); - - it("should handle stdin marker with other files", async () => { - const env = new Bash({ - files: { - "/file.json": '{"from":"file"}', - }, - }); - const result = await env.exec( - 'echo \'{"from":"stdin"}\' | jq ".from" - /file.json', - ); - expect(result.stdout).toBe('"stdin"\n"file"\n'); - expect(result.exitCode).toBe(0); - }); - - it("should work with glob patterns via shell expansion", async () => { - const env = new Bash({ - files: { - "/data/a.json": '{"n":1}', - "/data/b.json": '{"n":2}', - "/data/c.json": '{"n":3}', - }, - }); - const result = await env.exec("jq '.n' /data/*.json"); - // Files are processed in glob order (usually alphabetical) - expect(result.stdout).toBe("1\n2\n3\n"); - expect(result.exitCode).toBe(0); - }); - - it("should work with find | xargs pattern", async () => { - const env = new Bash({ - files: { - "/repo/issues/1.json": '{"author":"alice"}', - "/repo/issues/2.json": '{"author":"bob"}', - "/repo/pulls/1.json": '{"author":"charlie"}', - }, - }); - const result = await env.exec( - "find /repo -name '*.json' | sort | xargs jq -r '.author'", - ); - expect(result.stdout).toBe("alice\nbob\ncharlie\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle empty files gracefully", async () => { - const env = new Bash({ - files: { - "/a.json": '{"x":1}', - "/empty.json": "", - "/b.json": '{"x":2}', - }, - }); - // Empty files should be skipped (no output, no error) - const result = await env.exec("jq '.x' /a.json /empty.json /b.json"); - expect(result.stdout).toBe("1\n2\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("JSON stream parsing (concatenated JSON)", () => { - it("should handle concatenated pretty-printed JSON objects with -s", async () => { - const env = new Bash({ - files: { - "/file1.json": '{\n "id": 1,\n "merged": true\n}', - "/file2.json": '{\n "id": 2,\n "merged": false\n}', - "/file3.json": '{\n "id": 3,\n "merged": true\n}', - }, - }); - // This simulates: cat file1.json file2.json file3.json | jq -s 'group_by(.merged)' - const result = await env.exec( - "cat /file1.json /file2.json /file3.json | jq -s 'group_by(.merged) | map({merged: .[0].merged, count: length})'", - ); - expect(result.exitCode).toBe(0); - const output = JSON.parse(result.stdout); - // group_by sorts by key: false < true (alphabetically) - expect(output).toEqual([ - { merged: true, count: 2 }, - { merged: false, count: 1 }, - ]); - }); - - it("should handle concatenated compact JSON objects without -s", async () => { - const env = new Bash({ - files: { - "/data.json": '{"a":1}{"b":2}{"c":3}', - }, - }); - const result = await env.exec("cat /data.json | jq '.'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - '{\n "a": 1\n}\n{\n "b": 2\n}\n{\n "c": 3\n}\n', - ); - }); - - it("should handle mixed JSON values in stream", async () => { - const env = new Bash({ - files: { - "/mixed.json": '{"obj":true}\n[1,2,3]\n"string"\n42\ntrue\nnull', - }, - }); - const result = await env.exec("cat /mixed.json | jq -c '.'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - '{"obj":true}\n[1,2,3]\n"string"\n42\ntrue\nnull\n', - ); - }); - - it("should slurp concatenated JSON into array", async () => { - const env = new Bash({ - files: { - "/stream.json": '{"x":1}\n{"x":2}\n{"x":3}', - }, - }); - const result = await env.exec("cat /stream.json | jq -s 'length'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("3\n"); - }); - }); - - describe("error handling", () => { - it("should error on invalid JSON", async () => { - const env = new Bash(); - const result = await env.exec("echo 'not json' | jq '.'"); - expect(result.stderr).toBe( - "jq: parse error: Invalid JSON at position 0: unexpected 'not'\n", - ); - expect(result.exitCode).toBe(5); - }); - - it("should error on missing file", async () => { - const env = new Bash(); - const result = await env.exec("jq . /missing.json"); - expect(result.stderr).toBe( - "jq: /missing.json: No such file or directory\n", - ); - expect(result.exitCode).toBe(2); - }); - - it("should error on unknown option", async () => { - const env = new Bash(); - const result = await env.exec("jq --unknown '.'"); - expect(result.stderr).toBe("jq: unrecognized option '--unknown'\n"); - expect(result.exitCode).toBe(1); - }); - - it("should error on unknown short option", async () => { - const env = new Bash(); - const result = await env.exec("jq -x '.'"); - expect(result.stderr).toBe("jq: invalid option -- 'x'\n"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("help", () => { - it("should show help with --help", async () => { - const env = new Bash(); - const result = await env.exec("jq --help"); - expect(result.stdout).toMatch(/jq.*JSON/); - expect(result.exitCode).toBe(0); - }); - }); - - describe("exit status (-e)", () => { - it("should exit 1 for null with -e", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"a\":1}' | jq -e '.missing'"); - expect(result.stdout).toBe("null\n"); - expect(result.exitCode).toBe(1); - }); - - it("should exit 1 for false with -e", async () => { - const env = new Bash(); - const result = await env.exec("echo 'false' | jq -e '.'"); - expect(result.stdout).toBe("false\n"); - expect(result.exitCode).toBe(1); - }); - - it("should exit 0 for truthy value with -e", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"a\":1}' | jq -e '.a'"); - expect(result.stdout).toBe("1\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("join output (-j)", () => { - it("should not print newlines with -j", async () => { - const env = new Bash(); - const result = await env.exec("echo '[1,2,3]' | jq -j '.[]'"); - expect(result.stdout).toBe("123"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("tab indentation (--tab)", () => { - it("should use tabs for indentation with --tab", async () => { - const env = new Bash(); - const result = await env.exec("echo '{\"a\":1}' | jq --tab '.'"); - expect(result.stdout).toBe('{\n\t"a": 1\n}\n'); - expect(result.exitCode).toBe(0); - }); - }); - - describe("combined flags", () => { - it("should combine -rc flags", async () => { - const env = new Bash(); - const result = await env.exec( - "echo '{\"name\":\"test\"}' | jq -rc '.name'", - ); - expect(result.stdout).toBe("test\n"); - }); - }); -}); diff --git a/src/commands/jq/jq.ts b/src/commands/jq/jq.ts deleted file mode 100644 index 3fd29a11..00000000 --- a/src/commands/jq/jq.ts +++ /dev/null @@ -1,383 +0,0 @@ -/** - * jq - Command-line JSON processor - * - * Full jq implementation with proper parser and evaluator. - */ - -import { ExecutionLimitError } from "../../interpreter/errors.js"; -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { readFiles } from "../../utils/file-reader.js"; -import { hasHelpFlag, showHelp, unknownOption } from "../help.js"; -import { - type EvaluateOptions, - evaluate, - parse, - type QueryValue, -} from "../query-engine/index.js"; - -/** - * Parse a JSON stream (concatenated JSON values). - * Real jq can handle `{...}{...}` or `{...}\n{...}` or pretty-printed concatenated JSONs. - */ -function parseJsonStream(input: string): unknown[] { - const results: unknown[] = []; - let pos = 0; - const len = input.length; - - while (pos < len) { - // Skip whitespace - while (pos < len && /\s/.test(input[pos])) pos++; - if (pos >= len) break; - - const startPos = pos; - const char = input[pos]; - - if (char === "{" || char === "[") { - // Parse object or array by finding matching close bracket - const openBracket = char; - const closeBracket = char === "{" ? "}" : "]"; - let depth = 1; - let inString = false; - let isEscaped = false; - pos++; - - while (pos < len && depth > 0) { - const c = input[pos]; - if (isEscaped) { - isEscaped = false; - } else if (c === "\\") { - isEscaped = true; - } else if (c === '"') { - inString = !inString; - } else if (!inString) { - if (c === openBracket) depth++; - else if (c === closeBracket) depth--; - } - pos++; - } - - if (depth !== 0) { - throw new Error( - `Unexpected end of JSON input at position ${pos} (unclosed ${openBracket})`, - ); - } - - results.push(JSON.parse(input.slice(startPos, pos))); - } else if (char === '"') { - // Parse string - let isEscaped = false; - pos++; - while (pos < len) { - const c = input[pos]; - if (isEscaped) { - isEscaped = false; - } else if (c === "\\") { - isEscaped = true; - } else if (c === '"') { - pos++; - break; - } - pos++; - } - results.push(JSON.parse(input.slice(startPos, pos))); - } else if (char === "-" || (char >= "0" && char <= "9")) { - // Parse number - while (pos < len && /[\d.eE+-]/.test(input[pos])) pos++; - results.push(JSON.parse(input.slice(startPos, pos))); - } else if (input.slice(pos, pos + 4) === "true") { - results.push(true); - pos += 4; - } else if (input.slice(pos, pos + 5) === "false") { - results.push(false); - pos += 5; - } else if (input.slice(pos, pos + 4) === "null") { - results.push(null); - pos += 4; - } else { - // Try to provide context about what we found - const context = input.slice(pos, pos + 10); - throw new Error( - `Invalid JSON at position ${startPos}: unexpected '${context.split(/\s/)[0]}'`, - ); - } - } - - return results; -} - -const jqHelp = { - name: "jq", - summary: "command-line JSON processor", - usage: "jq [OPTIONS] FILTER [FILE]", - options: [ - "-r, --raw-output output strings without quotes", - "-c, --compact compact output (no pretty printing)", - "-e, --exit-status set exit status based on output", - "-s, --slurp read entire input into array", - "-n, --null-input don't read any input", - "-j, --join-output don't print newlines after each output", - "-a, --ascii force ASCII output", - "-S, --sort-keys sort object keys", - "-C, --color colorize output (ignored)", - "-M, --monochrome monochrome output (ignored)", - " --tab use tabs for indentation", - " --help display this help and exit", - ], -}; - -function formatValue( - v: QueryValue, - compact: boolean, - raw: boolean, - sortKeys: boolean, - useTab: boolean, - indent = 0, -): string { - if (v === null) return "null"; - if (v === undefined) return "null"; - if (typeof v === "boolean") return String(v); - if (typeof v === "number") { - if (!Number.isFinite(v)) return "null"; - return String(v); - } - if (typeof v === "string") return raw ? v : JSON.stringify(v); - - const indentStr = useTab ? "\t" : " "; - - if (Array.isArray(v)) { - if (v.length === 0) return "[]"; - if (compact) { - return `[${v.map((x) => formatValue(x, true, false, sortKeys, useTab)).join(",")}]`; - } - const items = v.map( - (x) => - indentStr.repeat(indent + 1) + - formatValue(x, false, false, sortKeys, useTab, indent + 1), - ); - return `[\n${items.join(",\n")}\n${indentStr.repeat(indent)}]`; - } - - if (typeof v === "object") { - let keys = Object.keys(v as object); - if (sortKeys) keys = keys.sort(); - if (keys.length === 0) return "{}"; - if (compact) { - // @banned-pattern-ignore: iterating via Object.keys() which only returns own properties - return `{${keys.map((k) => `${JSON.stringify(k)}:${formatValue((v as Record)[k], true, false, sortKeys, useTab)}`).join(",")}}`; - } - const items = keys.map((k) => { - // @banned-pattern-ignore: iterating via Object.keys() which only returns own properties - const val = formatValue( - (v as Record)[k], - false, - false, - sortKeys, - useTab, - indent + 1, - ); - return `${indentStr.repeat(indent + 1)}${JSON.stringify(k)}: ${val}`; - }); - return `{\n${items.join(",\n")}\n${indentStr.repeat(indent)}}`; - } - - return String(v); -} - -export const jqCommand: Command = { - name: "jq", - - async execute(args: string[], ctx: CommandContext): Promise { - if (hasHelpFlag(args)) return showHelp(jqHelp); - - let raw = false; - let compact = false; - let exitStatus = false; - let slurp = false; - let nullInput = false; - let joinOutput = false; - let sortKeys = false; - let useTab = false; - let filter = "."; - let filterSet = false; - const files: string[] = []; - - for (let i = 0; i < args.length; i++) { - const a = args[i]; - if (a === "-r" || a === "--raw-output") raw = true; - else if (a === "-c" || a === "--compact-output") compact = true; - else if (a === "-e" || a === "--exit-status") exitStatus = true; - else if (a === "-s" || a === "--slurp") slurp = true; - else if (a === "-n" || a === "--null-input") nullInput = true; - else if (a === "-j" || a === "--join-output") joinOutput = true; - else if (a === "-a" || a === "--ascii") { - /* ignored */ - } else if (a === "-S" || a === "--sort-keys") sortKeys = true; - else if (a === "-C" || a === "--color") { - /* ignored */ - } else if (a === "-M" || a === "--monochrome") { - /* ignored */ - } else if (a === "--tab") useTab = true; - else if (a === "-") files.push("-"); - else if (a.startsWith("--")) return unknownOption("jq", a); - else if (a.startsWith("-")) { - for (const c of a.slice(1)) { - if (c === "r") raw = true; - else if (c === "c") compact = true; - else if (c === "e") exitStatus = true; - else if (c === "s") slurp = true; - else if (c === "n") nullInput = true; - else if (c === "j") joinOutput = true; - else if (c === "a") { - /* ignored */ - } else if (c === "S") sortKeys = true; - else if (c === "C") { - /* ignored */ - } else if (c === "M") { - /* ignored */ - } else return unknownOption("jq", `-${c}`); - } - } else if (!filterSet) { - filter = a; - filterSet = true; - } else { - files.push(a); - } - } - - // Build list of inputs: stdin or files - let inputs: { source: string; content: string }[] = []; - if (nullInput) { - // No input - } else if (files.length === 0 || (files.length === 1 && files[0] === "-")) { - inputs.push({ source: "stdin", content: ctx.stdin }); - } else { - // Read all files in parallel using shared utility - const result = await readFiles(ctx, files, { - cmdName: "jq", - stopOnError: true, - }); - if (result.exitCode !== 0) { - return { - stdout: "", - stderr: result.stderr, - exitCode: 2, // jq uses exit code 2 for file errors - }; - } - inputs = result.files.map((f) => ({ - source: f.filename || "stdin", - content: f.content, - })); - } - - try { - const ast = parse(filter); - let values: QueryValue[] = []; - - const evalOptions: EvaluateOptions = { - limits: ctx.limits - ? { maxIterations: ctx.limits.maxJqIterations } - : undefined, - env: ctx.env, - coverage: ctx.coverage, - }; - - if (nullInput) { - values = evaluate(null, ast, evalOptions); - } else if (slurp) { - // Slurp mode: combine all inputs into single array - // Use JSON stream parser to handle concatenated JSON (not just NDJSON) - const items: QueryValue[] = []; - for (const { content } of inputs) { - const trimmed = content.trim(); - if (trimmed) { - items.push(...parseJsonStream(trimmed)); - } - } - values = evaluate(items, ast, evalOptions); - } else { - // Process each input file separately - // Use JSON stream parser to handle concatenated JSON (e.g., cat file1.json file2.json | jq .) - for (const { content } of inputs) { - const trimmed = content.trim(); - if (!trimmed) continue; - - const jsonValues = parseJsonStream(trimmed); - for (const jsonValue of jsonValues) { - values.push(...evaluate(jsonValue, ast, evalOptions)); - } - } - } - - const formatted = values.map((v) => - formatValue(v, compact, raw, sortKeys, useTab), - ); - const separator = joinOutput ? "" : "\n"; - const output = formatted.join(separator); - - // Check output size against limit - const maxStringLength = ctx.limits?.maxStringLength; - if ( - maxStringLength !== undefined && - maxStringLength > 0 && - output.length > maxStringLength - ) { - throw new ExecutionLimitError( - `jq: output size limit exceeded (${maxStringLength} bytes)`, - "string_length", - ); - } - - const exitCode = - exitStatus && - (values.length === 0 || - values.every((v) => v === null || v === undefined || v === false)) - ? 1 - : 0; - - return { - stdout: output ? (joinOutput ? output : `${output}\n`) : "", - stderr: "", - exitCode, - }; - } catch (e) { - if (e instanceof ExecutionLimitError) { - return { - stdout: "", - stderr: `jq: ${e.message}\n`, - exitCode: ExecutionLimitError.EXIT_CODE, - }; - } - const msg = (e as Error).message; - if (msg.includes("Unknown function")) { - return { - stdout: "", - stderr: `jq: error: ${msg}\n`, - exitCode: 3, - }; - } - return { - stdout: "", - stderr: `jq: parse error: ${msg}\n`, - exitCode: 5, - }; - } - }, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "jq", - flags: [ - { flag: "-r", type: "boolean" }, - { flag: "-c", type: "boolean" }, - { flag: "-e", type: "boolean" }, - { flag: "-s", type: "boolean" }, - { flag: "-n", type: "boolean" }, - { flag: "-j", type: "boolean" }, - { flag: "-S", type: "boolean" }, - { flag: "--tab", type: "boolean" }, - ], - stdinType: "json", - needsArgs: true, -}; diff --git a/src/commands/ln/ln.test.ts b/src/commands/ln/ln.test.ts deleted file mode 100644 index 401e35da..00000000 --- a/src/commands/ln/ln.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("ln command", () => { - describe("symbolic links (-s)", () => { - it("should create a symbolic link", async () => { - const env = new Bash({ - files: { "/target.txt": "hello world\n" }, - }); - const result = await env.exec("ln -s /target.txt /link.txt"); - expect(result.exitCode).toBe(0); - - // Verify link exists and points to target - const catResult = await env.exec("cat /link.txt"); - expect(catResult.stdout).toBe("hello world\n"); - }); - - it("should create a relative symbolic link", async () => { - const env = new Bash({ - files: { "/dir/target.txt": "content\n" }, - }); - const result = await env.exec("ln -s target.txt /dir/link.txt"); - expect(result.exitCode).toBe(0); - - const catResult = await env.exec("cat /dir/link.txt"); - expect(catResult.stdout).toBe("content\n"); - }); - - it("should allow dangling symlinks", async () => { - const env = new Bash(); - // ln -s should succeed even if target doesn't exist - const result = await env.exec("ln -s /nonexistent /link.txt"); - expect(result.exitCode).toBe(0); - - // But trying to read it should fail - const catResult = await env.exec("cat /link.txt"); - expect(catResult.exitCode).toBe(1); - }); - - it("should error if link already exists", async () => { - const env = new Bash({ - files: { - "/target.txt": "hello\n", - "/link.txt": "existing\n", - }, - }); - const result = await env.exec("ln -s /target.txt /link.txt"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("File exists"); - }); - - it("should overwrite with -f flag", async () => { - const env = new Bash({ - files: { - "/target.txt": "new content\n", - "/link.txt": "old content\n", - }, - }); - const result = await env.exec("ln -sf /target.txt /link.txt"); - expect(result.exitCode).toBe(0); - - const catResult = await env.exec("cat /link.txt"); - expect(catResult.stdout).toBe("new content\n"); - }); - }); - - describe("hard links", () => { - it("should create a hard link", async () => { - const env = new Bash({ - files: { "/original.txt": "hello world\n" }, - }); - const result = await env.exec("ln /original.txt /hardlink.txt"); - expect(result.exitCode).toBe(0); - - // Verify both files have same content - const orig = await env.exec("cat /original.txt"); - const link = await env.exec("cat /hardlink.txt"); - expect(link.stdout).toBe(orig.stdout); - }); - - it("should error when target does not exist", async () => { - const env = new Bash(); - const result = await env.exec("ln /nonexistent.txt /link.txt"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("No such file"); - }); - - it("should error when trying to hard link a directory", async () => { - const env = new Bash({ - files: { "/dir/file.txt": "test\n" }, - }); - const result = await env.exec("ln /dir /dirlink"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("not allowed"); - }); - }); - - describe("error handling", () => { - it("should error on missing operand", async () => { - const env = new Bash(); - const result = await env.exec("ln"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("missing file operand"); - }); - - it("should show help with --help", async () => { - const env = new Bash(); - const result = await env.exec("ln --help"); - expect(result.stdout).toContain("ln"); - expect(result.stdout).toContain("link"); - expect(result.exitCode).toBe(0); - }); - }); -}); - -describe("readlink command", () => { - it("should read symbolic link target", async () => { - const env = new Bash({ - files: { "/target.txt": "hello\n" }, - }); - await env.exec("ln -s /target.txt /link.txt"); - const result = await env.exec("readlink /link.txt"); - expect(result.stdout).toBe("/target.txt\n"); - expect(result.exitCode).toBe(0); - }); - - it("should read relative symbolic link target", async () => { - const env = new Bash({ - files: { "/dir/target.txt": "hello\n" }, - }); - await env.exec("ln -s target.txt /dir/link.txt"); - const result = await env.exec("readlink /dir/link.txt"); - expect(result.stdout).toBe("target.txt\n"); - expect(result.exitCode).toBe(0); - }); - - it("should resolve with -f flag", async () => { - const env = new Bash({ - files: { "/dir/target.txt": "hello\n" }, - }); - await env.exec("ln -s target.txt /dir/link.txt"); - const result = await env.exec("readlink -f /dir/link.txt"); - expect(result.stdout).toBe("/dir/target.txt\n"); - expect(result.exitCode).toBe(0); - }); - - it("should error on non-symlink without -f", async () => { - const env = new Bash({ - files: { "/regular.txt": "hello\n" }, - }); - const result = await env.exec("readlink /regular.txt"); - expect(result.exitCode).toBe(1); - }); - - it("should show help with --help", async () => { - const env = new Bash(); - const result = await env.exec("readlink --help"); - expect(result.stdout).toContain("readlink"); - expect(result.exitCode).toBe(0); - }); -}); diff --git a/src/commands/ln/ln.ts b/src/commands/ln/ln.ts deleted file mode 100644 index 8c317bf7..00000000 --- a/src/commands/ln/ln.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { hasHelpFlag, showHelp } from "../help.js"; - -const lnHelp = { - name: "ln", - summary: "make links between files", - usage: "ln [OPTIONS] TARGET LINK_NAME", - options: [ - "-s create a symbolic link instead of a hard link", - "-f remove existing destination files", - "-n treat LINK_NAME as a normal file if it is a symbolic link to a directory", - "-v print name of each linked file", - " --help display this help and exit", - ], -}; - -export const lnCommand: Command = { - name: "ln", - - async execute(args: string[], ctx: CommandContext): Promise { - if (hasHelpFlag(args)) { - return showHelp(lnHelp); - } - - let symbolic = false; - let force = false; - let verbose = false; - let argIdx = 0; - - // Parse options - while (argIdx < args.length && args[argIdx].startsWith("-")) { - const arg = args[argIdx]; - if (arg === "-s" || arg === "--symbolic") { - symbolic = true; - argIdx++; - } else if (arg === "-f" || arg === "--force") { - force = true; - argIdx++; - } else if (arg === "-v" || arg === "--verbose") { - verbose = true; - argIdx++; - } else if (arg === "-n" || arg === "--no-dereference") { - // For now, just accept the flag but don't implement special behavior - argIdx++; - } else if (/^-[sfvn]+$/.test(arg)) { - // Combined short flags like -sf, -sfv, etc. - if (arg.includes("s")) symbolic = true; - if (arg.includes("f")) force = true; - if (arg.includes("v")) verbose = true; - // -n is accepted but not implemented - argIdx++; - } else if (arg === "--") { - argIdx++; - break; - } else { - return { - stdout: "", - stderr: `ln: invalid option -- '${arg.slice(1)}'\n`, - exitCode: 1, - }; - } - } - - const remaining = args.slice(argIdx); - - if (remaining.length < 2) { - return { stdout: "", stderr: "ln: missing file operand\n", exitCode: 1 }; - } - - const target = remaining[0]; - const linkName = remaining[1]; - const linkPath = ctx.fs.resolvePath(ctx.cwd, linkName); - - // Check if link already exists - if (await ctx.fs.exists(linkPath)) { - if (force) { - try { - await ctx.fs.rm(linkPath, { force: true }); - } catch { - return { - stdout: "", - stderr: `ln: cannot remove '${linkName}': Permission denied\n`, - exitCode: 1, - }; - } - } else { - return { - stdout: "", - stderr: `ln: failed to create ${symbolic ? "symbolic " : ""}link '${linkName}': File exists\n`, - exitCode: 1, - }; - } - } - - try { - if (symbolic) { - // Create symbolic link - // For symlinks, the target is stored as-is (can be relative or absolute) - await ctx.fs.symlink(target, linkPath); - } else { - // Create hard link - const targetPath = ctx.fs.resolvePath(ctx.cwd, target); - // Check that target exists - if (!(await ctx.fs.exists(targetPath))) { - return { - stdout: "", - stderr: `ln: failed to access '${target}': No such file or directory\n`, - exitCode: 1, - }; - } - await ctx.fs.link(targetPath, linkPath); - } - } catch (e) { - const err = e as Error; - if (err.message.includes("EPERM")) { - return { - stdout: "", - stderr: `ln: '${target}': hard link not allowed for directory\n`, - exitCode: 1, - }; - } - return { stdout: "", stderr: `ln: ${err.message}\n`, exitCode: 1 }; - } - - let stdout = ""; - if (verbose) { - stdout = `'${linkName}' -> '${target}'\n`; - } - return { stdout, stderr: "", exitCode: 0 }; - }, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "ln", - flags: [ - { flag: "-s", type: "boolean" }, - { flag: "-f", type: "boolean" }, - { flag: "-n", type: "boolean" }, - { flag: "-v", type: "boolean" }, - ], - needsArgs: true, - minArgs: 2, -}; diff --git a/src/commands/ls/ls.human.test.ts b/src/commands/ls/ls.human.test.ts deleted file mode 100644 index b254bf9e..00000000 --- a/src/commands/ls/ls.human.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("ls -h (human-readable)", () => { - it("displays bytes for small files", async () => { - const env = new Bash({ - files: { - "/test/small.txt": { content: "a".repeat(100) }, - }, - }); - const result = await env.exec("ls -lh /test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("100"); - expect(result.stdout).toContain("small.txt"); - }); - - it("displays K for kilobyte-sized files", async () => { - const env = new Bash({ - files: { - "/test/medium.txt": { content: "a".repeat(1536) }, // 1.5K - }, - }); - const result = await env.exec("ls -lh /test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/1\.5K/); - expect(result.stdout).toContain("medium.txt"); - }); - - it("displays rounded K for larger KB files", async () => { - const env = new Bash({ - files: { - "/test/data.txt": { content: "a".repeat(15 * 1024) }, // 15K - }, - }); - const result = await env.exec("ls -lh /test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/15K/); - expect(result.stdout).toContain("data.txt"); - }); - - it("displays M for megabyte-sized files", async () => { - const env = new Bash({ - files: { - "/test/big.txt": { content: "a".repeat(2 * 1024 * 1024) }, // 2M - }, - }); - const result = await env.exec("ls -lh /test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/2\.0M/); - expect(result.stdout).toContain("big.txt"); - }); - - it("works with --human-readable long form", async () => { - const env = new Bash({ - files: { - "/test/file.txt": { content: "a".repeat(2048) }, // 2K - }, - }); - const result = await env.exec("ls -l --human-readable /test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/2\.0K/); - }); - - it("displays exact bytes without -h flag", async () => { - const env = new Bash({ - files: { - "/test/file.txt": { content: "a".repeat(1536) }, // Would be 1.5K with -h - }, - }); - const result = await env.exec("ls -l /test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("1536"); - expect(result.stdout).not.toMatch(/1\.5K/); - }); - - it("can combine -h with other flags", async () => { - const env = new Bash({ - files: { - "/test/visible.txt": { content: "a".repeat(3072) }, // 3K - "/test/.hidden.txt": { content: "b".repeat(4096) }, // 4K - }, - }); - const result = await env.exec("ls -lah /test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/3\.0K/); - expect(result.stdout).toMatch(/4\.0K/); - expect(result.stdout).toContain(".hidden.txt"); - }); -}); diff --git a/src/commands/ls/ls.test.ts b/src/commands/ls/ls.test.ts deleted file mode 100644 index f21b8646..00000000 --- a/src/commands/ls/ls.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("ls", () => { - it("should list directory contents", async () => { - const env = new Bash({ - files: { - "/dir/a.txt": "", - "/dir/b.txt": "", - }, - }); - const result = await env.exec("ls /dir"); - expect(result.stdout).toBe("a.txt\nb.txt\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should list current directory by default", async () => { - const env = new Bash({ - files: { "/file.txt": "" }, - cwd: "/", - }); - const result = await env.exec("ls"); - // /bin, /usr, /dev, /proc always exist for PATH-based command resolution and system compatibility - expect(result.stdout).toBe("bin\ndev\nfile.txt\nproc\nusr\n"); - expect(result.stderr).toBe(""); - }); - - it("should hide hidden files by default", async () => { - const env = new Bash({ - files: { - "/dir/.hidden": "", - "/dir/visible.txt": "", - }, - }); - const result = await env.exec("ls /dir"); - expect(result.stdout).toBe("visible.txt\n"); - expect(result.stderr).toBe(""); - }); - - it("should show hidden files with -a including . and ..", async () => { - const env = new Bash({ - files: { - "/dir/.hidden": "", - "/dir/visible.txt": "", - }, - }); - const result = await env.exec("ls -a /dir"); - expect(result.stdout).toBe(".\n..\n.hidden\nvisible.txt\n"); - expect(result.stderr).toBe(""); - }); - - it("should show hidden files with --all including . and ..", async () => { - const env = new Bash({ - files: { "/dir/.secret": "" }, - }); - const result = await env.exec("ls --all /dir"); - expect(result.stdout).toBe(".\n..\n.secret\n"); - expect(result.stderr).toBe(""); - }); - - it("should support long format with -l", async () => { - const env = new Bash({ - files: { "/dir/test.txt": "" }, - }); - const result = await env.exec("ls -l /dir"); - expect(result.stdout).toMatch( - /^total 1\n-rw-r--r-- 1 user user\s+0 \w{3}\s+\d+\s+[\d:]+\s+test\.txt\n$/, - ); - expect(result.stderr).toBe(""); - }); - - it("should show directory indicator in long format", async () => { - const env = new Bash({ - files: { "/dir/subdir/file.txt": "" }, - }); - const result = await env.exec("ls -l /dir"); - expect(result.stdout).toMatch( - /^total 1\ndrwxr-xr-x 1 user user\s+0 \w{3}\s+\d+\s+[\d:]+\s+subdir\/\n$/, - ); - expect(result.stderr).toBe(""); - }); - - it("should combine -la flags including . and ..", async () => { - const env = new Bash({ - files: { - "/dir/.hidden": "", - "/dir/visible": "", - }, - }); - const result = await env.exec("ls -la /dir"); - // Check structure: 4 entries (., .., .hidden, visible) - const lines = result.stdout.split("\n").filter((l) => l); - expect(lines[0]).toBe("total 4"); - expect(lines[1]).toMatch(/^drwxr-xr-x 1 user user\s+0 .+ \.$/); - expect(lines[2]).toMatch(/^drwxr-xr-x 1 user user\s+0 .+ \.\.$/); - expect(lines[3]).toMatch(/^-rw-r--r-- 1 user user\s+0 .+ \.hidden$/); - expect(lines[4]).toMatch(/^-rw-r--r-- 1 user user\s+0 .+ visible$/); - expect(result.stderr).toBe(""); - }); - - it("should list multiple directories", async () => { - const env = new Bash({ - files: { - "/dir1/a.txt": "", - "/dir2/b.txt": "", - }, - }); - const result = await env.exec("ls /dir1 /dir2"); - expect(result.stdout).toBe("/dir1:\na.txt\n\n/dir2:\nb.txt\n"); - expect(result.stderr).toBe(""); - }); - - it("should list recursively with -R", async () => { - const env = new Bash({ - files: { - "/dir/subdir/file.txt": "", - "/dir/root.txt": "", - }, - }); - const result = await env.exec("ls -R /dir"); - // Linux ls -R includes header for all directories including the starting one - expect(result.stdout).toBe( - "/dir:\nroot.txt\nsubdir\n\n/dir/subdir:\nfile.txt\n", - ); - expect(result.stderr).toBe(""); - }); - - it("should error on missing directory", async () => { - const env = new Bash(); - const result = await env.exec("ls /nonexistent"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe("ls: /nonexistent: No such file or directory\n"); - expect(result.exitCode).toBe(2); - }); - - it("should list a single file", async () => { - const env = new Bash({ - files: { "/file.txt": "content" }, - }); - const result = await env.exec("ls /file.txt"); - expect(result.stdout).toBe("/file.txt\n"); - expect(result.stderr).toBe(""); - }); - - it("should handle glob pattern with find and grep workaround", async () => { - const env = new Bash({ - files: { - "/dir/a.txt": "", - "/dir/b.txt": "", - "/dir/c.md": "", - }, - }); - const result = await env.exec("ls /dir | grep txt"); - expect(result.stdout).toBe("a.txt\nb.txt\n"); - expect(result.stderr).toBe(""); - }); - - it("should sort entries alphabetically", async () => { - const env = new Bash({ - files: { - "/dir/zebra.txt": "", - "/dir/apple.txt": "", - "/dir/mango.txt": "", - }, - }); - const result = await env.exec("ls /dir"); - expect(result.stdout).toBe("apple.txt\nmango.txt\nzebra.txt\n"); - expect(result.stderr).toBe(""); - }); - - it("should handle empty directory", async () => { - const env = new Bash({ - files: { "/empty/.keep": "" }, - }); - await env.exec("rm /empty/.keep"); - const result = await env.exec("ls /empty"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - describe("-A flag (almost all)", () => { - it("should show hidden files except . and ..", async () => { - const env = new Bash({ - files: { - "/dir/.hidden": "", - "/dir/visible.txt": "", - }, - }); - const result = await env.exec("ls -A /dir"); - expect(result.stdout).toBe(".hidden\nvisible.txt\n"); - expect(result.stderr).toBe(""); - }); - - it("should differ from -a (no . and ..)", async () => { - const env = new Bash({ - files: { - "/dir/.config": "", - "/dir/data.txt": "", - }, - }); - const resultA = await env.exec("ls -A /dir"); - const resulta = await env.exec("ls -a /dir"); - // -A should NOT include . and .. - expect(resultA.stdout).toBe(".config\ndata.txt\n"); - // -a should include . and .. - expect(resulta.stdout).toBe(".\n..\n.config\ndata.txt\n"); - }); - }); - - describe("-r flag (reverse)", () => { - it("should reverse sort order", async () => { - const env = new Bash({ - files: { - "/dir/aaa.txt": "", - "/dir/bbb.txt": "", - "/dir/ccc.txt": "", - }, - }); - const result = await env.exec("ls -r /dir"); - expect(result.stdout).toBe("ccc.txt\nbbb.txt\naaa.txt\n"); - expect(result.stderr).toBe(""); - }); - - it("should combine with -1 flag", async () => { - const env = new Bash({ - files: { - "/dir/x.txt": "", - "/dir/y.txt": "", - "/dir/z.txt": "", - }, - }); - const result = await env.exec("ls -1r /dir"); - expect(result.stdout).toBe("z.txt\ny.txt\nx.txt\n"); - expect(result.stderr).toBe(""); - }); - - it("should combine with -a flag including . and .. reversed", async () => { - const env = new Bash({ - files: { - "/dir/.hidden": "", - "/dir/visible": "", - }, - }); - const result = await env.exec("ls -ar /dir"); - // With -a, entries are [., .., .hidden, visible], reversed = [visible, .hidden, .., .] - expect(result.stdout).toBe("visible\n.hidden\n..\n.\n"); - expect(result.stderr).toBe(""); - }); - }); -}); diff --git a/src/commands/ls/ls.ts b/src/commands/ls/ls.ts deleted file mode 100644 index e6327b3c..00000000 --- a/src/commands/ls/ls.ts +++ /dev/null @@ -1,532 +0,0 @@ -import { minimatch } from "minimatch"; -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { parseArgs } from "../../utils/args.js"; -import { DEFAULT_BATCH_SIZE } from "../../utils/constants.js"; -import { hasHelpFlag, showHelp } from "../help.js"; - -// Format size in human-readable format (e.g., 1.5K, 234M, 2G) -function formatHumanSize(bytes: number): string { - if (bytes < 1024) return String(bytes); - if (bytes < 1024 * 1024) { - const k = bytes / 1024; - return k < 10 ? `${k.toFixed(1)}K` : `${Math.round(k)}K`; - } - if (bytes < 1024 * 1024 * 1024) { - const m = bytes / (1024 * 1024); - return m < 10 ? `${m.toFixed(1)}M` : `${Math.round(m)}M`; - } - const g = bytes / (1024 * 1024 * 1024); - return g < 10 ? `${g.toFixed(1)}G` : `${Math.round(g)}G`; -} - -// Format date for ls -l output (e.g., "Jan 1 00:00" or "Jan 1 2024") -function formatDate(date: Date): string { - const months = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ]; - const month = months[date.getMonth()]; - const day = String(date.getDate()).padStart(2, " "); - const now = new Date(); - const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000); - - // If within last 6 months, show time; otherwise show year - if (date > sixMonthsAgo) { - const hours = String(date.getHours()).padStart(2, "0"); - const mins = String(date.getMinutes()).padStart(2, "0"); - return `${month} ${day} ${hours}:${mins}`; - } - const year = date.getFullYear(); - return `${month} ${day} ${year}`; -} - -const lsHelp = { - name: "ls", - summary: "list directory contents", - usage: "ls [OPTION]... [FILE]...", - options: [ - "-a, --all do not ignore entries starting with .", - "-A, --almost-all do not list . and ..", - "-d, --directory list directories themselves, not their contents", - "-h, --human-readable with -l, print sizes like 1K 234M 2G etc.", - "-l use a long listing format", - "-r, --reverse reverse order while sorting", - "-R, --recursive list subdirectories recursively", - "-S sort by file size, largest first", - "-t sort by time, newest first", - "-1 list one file per line", - " --help display this help and exit", - ], -}; - -const argDefs = { - showAll: { short: "a", long: "all", type: "boolean" as const }, - showAlmostAll: { short: "A", long: "almost-all", type: "boolean" as const }, - longFormat: { short: "l", type: "boolean" as const }, - humanReadable: { - short: "h", - long: "human-readable", - type: "boolean" as const, - }, - recursive: { short: "R", long: "recursive", type: "boolean" as const }, - reverse: { short: "r", long: "reverse", type: "boolean" as const }, - sortBySize: { short: "S", type: "boolean" as const }, - directoryOnly: { short: "d", long: "directory", type: "boolean" as const }, - sortByTime: { short: "t", type: "boolean" as const }, - onePerLine: { short: "1", type: "boolean" as const }, -}; - -export const lsCommand: Command = { - name: "ls", - - async execute(args: string[], ctx: CommandContext): Promise { - if (hasHelpFlag(args)) { - return showHelp(lsHelp); - } - - const parsed = parseArgs("ls", args, argDefs); - if (!parsed.ok) return parsed.error; - - const showAll = parsed.result.flags.showAll; - const showAlmostAll = parsed.result.flags.showAlmostAll; - const longFormat = parsed.result.flags.longFormat; - const humanReadable = parsed.result.flags.humanReadable; - const recursive = parsed.result.flags.recursive; - const reverse = parsed.result.flags.reverse; - const sortBySize = parsed.result.flags.sortBySize; - const directoryOnly = parsed.result.flags.directoryOnly; - const _sortByTime = parsed.result.flags.sortByTime; - // Note: onePerLine is accepted but implicit in our output - void parsed.result.flags.onePerLine; - - const paths = parsed.result.positional; - - if (paths.length === 0) { - paths.push("."); - } - - let stdout = ""; - let stderr = ""; - let exitCode = 0; - - for (let i = 0; i < paths.length; i++) { - const path = paths[i]; - - // Add blank line between directory listings - if (i > 0 && stdout && !stdout.endsWith("\n\n")) { - stdout += "\n"; - } - - // With -d flag, just list the directories/files themselves, not their contents - if (directoryOnly) { - const fullPath = ctx.fs.resolvePath(ctx.cwd, path); - try { - const stat = await ctx.fs.stat(fullPath); - if (longFormat) { - const mode = stat.isDirectory ? "drwxr-xr-x" : "-rw-r--r--"; - const type = stat.isDirectory ? "/" : ""; - const size = stat.size ?? 0; - const sizeStr = humanReadable - ? formatHumanSize(size).padStart(5) - : String(size).padStart(5); - const mtime = stat.mtime ?? new Date(0); - const dateStr = formatDate(mtime); - stdout += `${mode} 1 user user ${sizeStr} ${dateStr} ${path}${type}\n`; - } else { - stdout += `${path}\n`; - } - } catch { - stderr += `ls: cannot access '${path}': No such file or directory\n`; - exitCode = 2; - } - continue; - } - - // Check if it's a glob pattern - if (path.includes("*") || path.includes("?") || path.includes("[")) { - const result = await listGlob( - path, - ctx, - showAll, - showAlmostAll, - longFormat, - reverse, - humanReadable, - sortBySize, - ); - stdout += result.stdout; - stderr += result.stderr; - if (result.exitCode !== 0) exitCode = result.exitCode; - } else { - const result = await listPath( - path, - ctx, - showAll, - showAlmostAll, - longFormat, - recursive, - paths.length > 1, - reverse, - humanReadable, - sortBySize, - ); - stdout += result.stdout; - stderr += result.stderr; - if (result.exitCode !== 0) exitCode = result.exitCode; - } - } - - return { stdout, stderr, exitCode }; - }, -}; - -async function listGlob( - pattern: string, - ctx: CommandContext, - showAll: boolean, - showAlmostAll: boolean, - longFormat: boolean, - reverse: boolean = false, - humanReadable: boolean = false, - sortBySize: boolean = false, -): Promise { - const showHidden = showAll || showAlmostAll; - const allPaths = ctx.fs.getAllPaths(); - const basePath = ctx.fs.resolvePath(ctx.cwd, "."); - - const matches: string[] = []; - for (const p of allPaths) { - const relativePath = p.startsWith(basePath) - ? p.slice(basePath.length + 1) || p - : p; - - if (minimatch(relativePath, pattern) || minimatch(p, pattern)) { - // Filter hidden files unless showHidden - const basename = relativePath.split("/").pop() || relativePath; - if (!showHidden && basename.startsWith(".")) { - continue; - } - matches.push(relativePath || p); - } - } - - if (matches.length === 0) { - return { - stdout: "", - stderr: `ls: ${pattern}: No such file or directory\n`, - exitCode: 2, - }; - } - - // Sort by size if -S flag, otherwise alphabetically - if (sortBySize) { - const matchesWithSize: { path: string; size: number }[] = []; - for (const match of matches) { - const fullPath = ctx.fs.resolvePath(ctx.cwd, match); - try { - const stat = await ctx.fs.stat(fullPath); - matchesWithSize.push({ path: match, size: stat.size ?? 0 }); - } catch { - matchesWithSize.push({ path: match, size: 0 }); - } - } - matchesWithSize.sort((a, b) => b.size - a.size); // largest first - matches.length = 0; - matches.push(...matchesWithSize.map((m) => m.path)); - } else { - matches.sort(); - } - if (reverse) { - matches.reverse(); - } - - if (longFormat) { - const lines: string[] = []; - for (const match of matches) { - const fullPath = ctx.fs.resolvePath(ctx.cwd, match); - try { - const stat = await ctx.fs.stat(fullPath); - const mode = stat.isDirectory ? "drwxr-xr-x" : "-rw-r--r--"; - const type = stat.isDirectory ? "/" : ""; - const size = stat.size ?? 0; - const sizeStr = humanReadable - ? formatHumanSize(size).padStart(5) - : String(size).padStart(5); - const mtime = stat.mtime ?? new Date(0); - const dateStr = formatDate(mtime); - lines.push(`${mode} 1 user user ${sizeStr} ${dateStr} ${match}${type}`); - } catch { - lines.push(`-rw-r--r-- 1 user user 0 Jan 1 00:00 ${match}`); - } - } - return { stdout: `${lines.join("\n")}\n`, stderr: "", exitCode: 0 }; - } - - return { stdout: `${matches.join("\n")}\n`, stderr: "", exitCode: 0 }; -} - -async function listPath( - path: string, - ctx: CommandContext, - showAll: boolean, - showAlmostAll: boolean, - longFormat: boolean, - recursive: boolean, - showHeader: boolean, - reverse: boolean = false, - humanReadable: boolean = false, - sortBySize: boolean = false, - _isSubdir: boolean = false, -): Promise { - const showHidden = showAll || showAlmostAll; - const fullPath = ctx.fs.resolvePath(ctx.cwd, path); - - try { - const stat = await ctx.fs.stat(fullPath); - - if (!stat.isDirectory) { - // It's a file, just show it - if (longFormat) { - const size = stat.size ?? 0; - const sizeStr = humanReadable - ? formatHumanSize(size).padStart(5) - : String(size).padStart(5); - const mtime = stat.mtime ?? new Date(0); - const dateStr = formatDate(mtime); - return { - stdout: `-rw-r--r-- 1 user user ${sizeStr} ${dateStr} ${path}\n`, - stderr: "", - exitCode: 0, - }; - } - return { stdout: `${path}\n`, stderr: "", exitCode: 0 }; - } - - // It's a directory - let entries = await ctx.fs.readdir(fullPath); - - // Filter hidden files unless -a or -A - if (!showHidden) { - entries = entries.filter((e) => !e.startsWith(".")); - } - - // Sort by size if -S flag, otherwise alphabetically - if (sortBySize) { - const entriesWithSize: { name: string; size: number }[] = []; - for (const entry of entries) { - const entryPath = - fullPath === "/" ? `/${entry}` : `${fullPath}/${entry}`; - try { - const entryStat = await ctx.fs.stat(entryPath); - entriesWithSize.push({ name: entry, size: entryStat.size ?? 0 }); - } catch { - entriesWithSize.push({ name: entry, size: 0 }); - } - } - entriesWithSize.sort((a, b) => b.size - a.size); // largest first - entries = entriesWithSize.map((e) => e.name); - } else { - // Sort entries (already sorted by readdir, but ensure consistent order) - entries.sort(); - } - - // Add . and .. entries for -a flag (but not for -A) - if (showAll) { - entries = [".", "..", ...entries]; - } - - if (reverse) { - entries.reverse(); - } - - let stdout = ""; - - // For recursive listing: - // - All directories get a header (including the first one) - // - When starting from '.', show '.:' - // - Subdirectories use './subdir:' format when starting from '.' - // - When starting from other path, subdirs use '{path}/subdir:' format - if (recursive || showHeader) { - stdout += `${path}:\n`; - } - - if (longFormat) { - stdout += `total ${entries.length}\n`; - - // Separate special entries (. and ..) from regular entries - const specialEntries = entries.filter((e) => e === "." || e === ".."); - const regularEntries = entries.filter((e) => e !== "." && e !== ".."); - - // Add special entries first - for (const entry of specialEntries) { - stdout += `drwxr-xr-x 1 user user 0 Jan 1 00:00 ${entry}\n`; - } - - // Parallelize stat calls for regular entries - const entryStats: { - name: string; - line: string; - }[] = []; - - for (let i = 0; i < regularEntries.length; i += DEFAULT_BATCH_SIZE) { - const batch = regularEntries.slice(i, i + DEFAULT_BATCH_SIZE); - const batchResults = await Promise.all( - batch.map(async (entry) => { - const entryPath = - fullPath === "/" ? `/${entry}` : `${fullPath}/${entry}`; - try { - const entryStat = await ctx.fs.stat(entryPath); - const mode = entryStat.isDirectory ? "drwxr-xr-x" : "-rw-r--r--"; - const suffix = entryStat.isDirectory ? "/" : ""; - const size = entryStat.size ?? 0; - const sizeStr = humanReadable - ? formatHumanSize(size).padStart(5) - : String(size).padStart(5); - const mtime = entryStat.mtime ?? new Date(0); - const dateStr = formatDate(mtime); - return { - name: entry, - line: `${mode} 1 user user ${sizeStr} ${dateStr} ${entry}${suffix}\n`, - }; - } catch { - return { - name: entry, - line: `-rw-r--r-- 1 user user 0 Jan 1 00:00 ${entry}\n`, - }; - } - }), - ); - entryStats.push(...batchResults); - } - - // Sort to maintain original order (entries were already sorted) - const entryOrder = new Map(regularEntries.map((e, i) => [e, i])); - entryStats.sort( - (a, b) => (entryOrder.get(a.name) ?? 0) - (entryOrder.get(b.name) ?? 0), - ); - - for (const { line } of entryStats) { - stdout += line; - } - } else { - stdout += entries.join("\n") + (entries.length ? "\n" : ""); - } - - // Handle recursive - parallel processing for better performance - if (recursive) { - // Filter out . and .. and get directory entries - const filteredEntries = entries.filter((e) => e !== "." && e !== ".."); - - // Use readdirWithFileTypes if available to avoid stat calls - let dirEntries: { name: string; isDirectory: boolean }[] = []; - - if (ctx.fs.readdirWithFileTypes) { - const entriesWithTypes = await ctx.fs.readdirWithFileTypes(fullPath); - dirEntries = entriesWithTypes - .filter((e) => e.isDirectory && filteredEntries.includes(e.name)) - .map((e) => ({ name: e.name, isDirectory: true })); - } else { - // Fall back to stat calls - parallelize them - for (let i = 0; i < filteredEntries.length; i += DEFAULT_BATCH_SIZE) { - const batch = filteredEntries.slice(i, i + DEFAULT_BATCH_SIZE); - const results = await Promise.all( - batch.map(async (entry) => { - const entryPath = - fullPath === "/" ? `/${entry}` : `${fullPath}/${entry}`; - try { - const entryStat = await ctx.fs.stat(entryPath); - return { name: entry, isDirectory: entryStat.isDirectory }; - } catch { - return { name: entry, isDirectory: false }; - } - }), - ); - dirEntries.push(...results.filter((r) => r.isDirectory)); - } - } - - // Sort directory entries to maintain order - dirEntries.sort((a, b) => a.name.localeCompare(b.name)); - if (reverse) { - dirEntries.reverse(); - } - - // Process subdirectories in parallel batches - const subResults: { name: string; result: ExecResult }[] = []; - - for (let i = 0; i < dirEntries.length; i += DEFAULT_BATCH_SIZE) { - const batch = dirEntries.slice(i, i + DEFAULT_BATCH_SIZE); - const batchResults = await Promise.all( - batch.map(async (dir) => { - const subPath = - path === "." ? `./${dir.name}` : `${path}/${dir.name}`; - const result = await listPath( - subPath, - ctx, - showAll, - showAlmostAll, - longFormat, - recursive, - false, - reverse, - humanReadable, - sortBySize, - true, - ); - return { name: dir.name, result }; - }), - ); - subResults.push(...batchResults); - } - - // Sort results to maintain consistent order - subResults.sort((a, b) => a.name.localeCompare(b.name)); - if (reverse) { - subResults.reverse(); - } - - // Append results - for (const { result } of subResults) { - stdout += "\n"; - stdout += result.stdout; - } - } - - return { stdout, stderr: "", exitCode: 0 }; - } catch { - return { - stdout: "", - stderr: `ls: ${path}: No such file or directory\n`, - exitCode: 2, - }; - } -} - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "ls", - flags: [ - { flag: "-a", type: "boolean" }, - { flag: "-A", type: "boolean" }, - { flag: "-l", type: "boolean" }, - { flag: "-h", type: "boolean" }, - { flag: "-R", type: "boolean" }, - { flag: "-r", type: "boolean" }, - { flag: "-S", type: "boolean" }, - { flag: "-d", type: "boolean" }, - { flag: "-t", type: "boolean" }, - { flag: "-1", type: "boolean" }, - ], - needsFiles: true, -}; diff --git a/src/commands/md5sum/checksum.binary.test.ts b/src/commands/md5sum/checksum.binary.test.ts deleted file mode 100644 index 65b901b3..00000000 --- a/src/commands/md5sum/checksum.binary.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("checksum commands with binary data", () => { - describe("md5sum", () => { - it("should compute md5sum of binary file with high bytes", async () => { - const env = new Bash({ - files: { - "/binary.bin": new Uint8Array([0x80, 0x90, 0xa0, 0xb0, 0xff]), - }, - }); - - const result = await env.exec("md5sum /binary.bin"); - expect(result.exitCode).toBe(0); - // The hash should be consistent for the same input - expect(result.stdout).toMatch(/^[a-f0-9]{32}\s+/); - expect(result.stdout).toContain("binary.bin"); - }); - - it("should compute md5sum of file with null bytes", async () => { - const env = new Bash({ - files: { - "/nulls.bin": new Uint8Array([0x41, 0x00, 0x42, 0x00, 0x43]), - }, - }); - - const result = await env.exec("md5sum /nulls.bin"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/^[a-f0-9]{32}\s+/); - expect(result.stdout).toContain("nulls.bin"); - }); - - it("should compute md5sum from stdin with binary data", async () => { - const env = new Bash({ - files: { - "/binary.bin": new Uint8Array([0x80, 0xff, 0x90, 0xab]), - }, - }); - - const result = await env.exec("cat /binary.bin | md5sum"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/^[a-f0-9]{32}\s+/); - }); - - it("should produce same hash for same binary content", async () => { - const env = new Bash({ - files: { - "/a.bin": new Uint8Array([0x80, 0x90, 0xa0]), - "/b.bin": new Uint8Array([0x80, 0x90, 0xa0]), - }, - }); - - const resultA = await env.exec("md5sum /a.bin"); - const resultB = await env.exec("md5sum /b.bin"); - - const hashA = resultA.stdout.split(/\s+/)[0]; - const hashB = resultB.stdout.split(/\s+/)[0]; - expect(hashA).toBe(hashB); - }); - }); - - describe("sha256sum", () => { - it("should compute sha256sum of binary file", async () => { - const env = new Bash({ - files: { - "/binary.bin": new Uint8Array([0x80, 0x90, 0xa0, 0xb0, 0xff]), - }, - }); - - const result = await env.exec("sha256sum /binary.bin"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/^[a-f0-9]{64}\s+/); - expect(result.stdout).toContain("binary.bin"); - }); - - it("should compute sha256sum from stdin", async () => { - const env = new Bash({ - files: { - "/binary.bin": new Uint8Array([0x80, 0xff, 0x90, 0xab]), - }, - }); - - const result = await env.exec("cat /binary.bin | sha256sum"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/^[a-f0-9]{64}\s+/); - }); - - it("should handle all byte values", async () => { - const env = new Bash({ - files: { - "/allbytes.bin": new Uint8Array( - Array.from({ length: 256 }, (_, i) => i), - ), - }, - }); - - const result = await env.exec("sha256sum /allbytes.bin"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/^[a-f0-9]{64}\s+/); - expect(result.stdout).toContain("allbytes.bin"); - }); - }); - - describe("sha1sum", () => { - it("should compute sha1sum of binary file", async () => { - const env = new Bash({ - files: { - "/binary.bin": new Uint8Array([0x80, 0x90, 0xa0, 0xb0, 0xff]), - }, - }); - - const result = await env.exec("sha1sum /binary.bin"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/^[a-f0-9]{40}\s+/); - expect(result.stdout).toContain("binary.bin"); - }); - - it("should compute sha1sum from stdin", async () => { - const env = new Bash({ - files: { - "/binary.bin": new Uint8Array([0x80, 0xff, 0x90, 0xab]), - }, - }); - - const result = await env.exec("cat /binary.bin | sha1sum"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/^[a-f0-9]{40}\s+/); - }); - }); - - describe("UTF-8 content", () => { - it("should compute md5sum of UTF-8 file", async () => { - const env = new Bash({ - files: { - "/unicode.txt": "Hello 中文 日本語", - }, - }); - - const result = await env.exec("md5sum /unicode.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/^[a-f0-9]{32}\s+/); - expect(result.stdout).toContain("unicode.txt"); - }); - - it("should compute sha256sum of UTF-8 from stdin", async () => { - const env = new Bash({ - files: { - "/unicode.txt": "🚀🎉🔥", - }, - }); - - const result = await env.exec("cat /unicode.txt | sha256sum"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/^[a-f0-9]{64}\s+/); - }); - - it("should produce same hash for same UTF-8 content", async () => { - const env = new Bash({ - files: { - "/a.txt": "Привет мир", - "/b.txt": "Привет мир", - }, - }); - - const resultA = await env.exec("md5sum /a.txt"); - const resultB = await env.exec("md5sum /b.txt"); - - const hashA = resultA.stdout.split(/\s+/)[0]; - const hashB = resultB.stdout.split(/\s+/)[0]; - expect(hashA).toBe(hashB); - }); - }); - - describe("check mode with binary files", () => { - it("should verify binary file checksum", async () => { - const env = new Bash({ - files: { - "/binary.bin": new Uint8Array([0x80, 0x90, 0xa0]), - }, - }); - - await env.exec("md5sum /binary.bin > /checksums.txt"); - const result = await env.exec("md5sum -c /checksums.txt"); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("OK"); - }); - - it("should detect modified binary file", async () => { - const env = new Bash({ - files: { - "/binary.bin": new Uint8Array([0x80, 0x90, 0xa0]), - }, - }); - - await env.exec("md5sum /binary.bin > /checksums.txt"); - // Modify the file - await env.exec("printf '\\x81\\x90\\xa0' > /binary.bin"); - const result = await env.exec("md5sum -c /checksums.txt"); - - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("FAILED"); - }); - }); -}); diff --git a/src/commands/md5sum/checksum.ts b/src/commands/md5sum/checksum.ts deleted file mode 100644 index 18851840..00000000 --- a/src/commands/md5sum/checksum.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * Shared checksum utilities for md5sum, sha1sum, sha256sum - * Uses WebCrypto API for SHA algorithms, pure JS for MD5 - */ - -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { hasHelpFlag, showHelp, unknownOption } from "../help.js"; - -export type HashAlgorithm = "md5" | "sha1" | "sha256"; - -// Map prevents prototype pollution -const WEBCRYPTO_ALGORITHMS = new Map([ - ["sha1", "SHA-1"], - ["sha256", "SHA-256"], -]); - -// Pure JS MD5 implementation (WebCrypto doesn't support MD5) -function md5(bytes: Uint8Array): string { - function rotateLeft(x: number, n: number): number { - return (x << n) | (x >>> (32 - n)); - } - - const K = new Uint32Array([ - 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, - 0xa8304613, 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, - 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, - 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, - 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, - 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, - 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa, - 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, - 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, - 0xffeff47d, 0x85845dd1, 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, - 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391, - ]); - const S = [ - 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, - 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, - 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, - 15, 21, - ]; - - // Padding - const bitLen = bytes.length * 8; - const paddingLen = (bytes.length % 64 < 56 ? 56 : 120) - (bytes.length % 64); - const padded = new Uint8Array(bytes.length + paddingLen + 8); - padded.set(bytes); - padded[bytes.length] = 0x80; - const view = new DataView(padded.buffer); - view.setUint32(padded.length - 8, bitLen >>> 0, true); - view.setUint32(padded.length - 4, Math.floor(bitLen / 0x100000000), true); - - let a0 = 0x67452301; - let b0 = 0xefcdab89; - let c0 = 0x98badcfe; - let d0 = 0x10325476; - - for (let i = 0; i < padded.length; i += 64) { - const M = new Uint32Array(16); - for (let j = 0; j < 16; j++) { - M[j] = view.getUint32(i + j * 4, true); - } - - let A = a0, - B = b0, - C = c0, - D = d0; - - for (let j = 0; j < 64; j++) { - let F: number, g: number; - if (j < 16) { - F = (B & C) | (~B & D); - g = j; - } else if (j < 32) { - F = (D & B) | (~D & C); - g = (5 * j + 1) % 16; - } else if (j < 48) { - F = B ^ C ^ D; - g = (3 * j + 5) % 16; - } else { - F = C ^ (B | ~D); - g = (7 * j) % 16; - } - F = (F + A + K[j] + M[g]) >>> 0; - A = D; - D = C; - C = B; - B = (B + rotateLeft(F, S[j])) >>> 0; - } - - a0 = (a0 + A) >>> 0; - b0 = (b0 + B) >>> 0; - c0 = (c0 + C) >>> 0; - d0 = (d0 + D) >>> 0; - } - - const result = new Uint8Array(16); - new DataView(result.buffer).setUint32(0, a0, true); - new DataView(result.buffer).setUint32(4, b0, true); - new DataView(result.buffer).setUint32(8, c0, true); - new DataView(result.buffer).setUint32(12, d0, true); - - return Array.from(result) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -async function computeHash( - algorithm: HashAlgorithm, - data: Uint8Array, -): Promise { - if (algorithm === "md5") { - return md5(data); - } - - const algoName = WEBCRYPTO_ALGORITHMS.get(algorithm); - if (!algoName) { - throw new Error(`Unknown algorithm: ${algorithm}`); - } - const hashBuffer = await globalThis.crypto.subtle.digest( - algoName, - new Uint8Array(data).buffer as ArrayBuffer, - ); - return Array.from(new Uint8Array(hashBuffer)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -export function createChecksumCommand( - name: string, - algorithm: HashAlgorithm, - summary: string, -): Command { - const help = { - name, - summary, - usage: `${name} [OPTION]... [FILE]...`, - options: [ - "-c, --check read checksums from FILEs and check them", - " --help display this help and exit", - ], - }; - - return { - name, - async execute(args: string[], ctx: CommandContext): Promise { - if (hasHelpFlag(args)) return showHelp(help); - - let check = false; - const files: string[] = []; - - for (const arg of args) { - if (arg === "-c" || arg === "--check") check = true; - else if ( - arg === "-b" || - arg === "-t" || - arg === "--binary" || - arg === "--text" - ) { - /* ignored */ - } else if (arg.startsWith("-") && arg !== "-") - return unknownOption(name, arg); - else files.push(arg); - } - - if (files.length === 0) files.push("-"); - - // Helper to read file as binary - const readBinary = async (file: string): Promise => { - if (file === "-") { - // Convert binary string directly to bytes without UTF-8 re-encoding - return Uint8Array.from(ctx.stdin, (c) => c.charCodeAt(0)); - } - try { - return await ctx.fs.readFileBuffer(ctx.fs.resolvePath(ctx.cwd, file)); - } catch { - return null; - } - }; - - if (check) { - let failed = 0; - let output = ""; - - for (const file of files) { - // For check mode, we read the checksum file as text - const content = - file === "-" - ? ctx.stdin - : await ctx.fs - .readFile(ctx.fs.resolvePath(ctx.cwd, file)) - .catch(() => null); - if (content === null) - return { - stdout: "", - stderr: `${name}: ${file}: No such file or directory\n`, - exitCode: 1, - }; - - for (const line of content.split("\n")) { - const match = line.match(/^([a-fA-F0-9]+)\s+[* ]?(.+)$/); - if (!match) continue; - - const [, expectedHash, targetFile] = match; - const fileContent = await readBinary(targetFile); - if (fileContent === null) { - output += `${targetFile}: FAILED open or read\n`; - failed++; - continue; - } - const ok = - (await computeHash(algorithm, fileContent)) === - expectedHash.toLowerCase(); - output += `${targetFile}: ${ok ? "OK" : "FAILED"}\n`; - if (!ok) failed++; - } - } - - if (failed > 0) - output += `${name}: WARNING: ${failed} computed checksum${failed > 1 ? "s" : ""} did NOT match\n`; - return { stdout: output, stderr: "", exitCode: failed > 0 ? 1 : 0 }; - } - - let output = ""; - let exitCode = 0; - - for (const file of files) { - const content = await readBinary(file); - if (content === null) { - output += `${name}: ${file}: No such file or directory\n`; - exitCode = 1; - continue; - } - output += `${await computeHash(algorithm, content)} ${file}\n`; - } - - return { stdout: output, stderr: "", exitCode }; - }, - }; -} diff --git a/src/commands/md5sum/md5sum.test.ts b/src/commands/md5sum/md5sum.test.ts deleted file mode 100644 index 83c153c7..00000000 --- a/src/commands/md5sum/md5sum.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("md5sum", () => { - describe("basic hashing", () => { - it("should hash a simple string", async () => { - const env = new Bash(); - const result = await env.exec("echo -n 'hello' | md5sum"); - // MD5 of "hello" is 5d41402abc4b2a76b9719d911017c592 - expect(result.stdout).toBe("5d41402abc4b2a76b9719d911017c592 -\n"); - expect(result.exitCode).toBe(0); - }); - - it("should hash empty input", async () => { - const env = new Bash(); - const result = await env.exec("echo -n '' | md5sum"); - // MD5 of "" is d41d8cd98f00b204e9800998ecf8427e - expect(result.stdout).toBe("d41d8cd98f00b204e9800998ecf8427e -\n"); - expect(result.exitCode).toBe(0); - }); - - it("should hash a file", async () => { - const env = new Bash(); - await env.exec("echo -n 'test' > /tmp/test.txt"); - const result = await env.exec("md5sum /tmp/test.txt"); - // MD5 of "test" is 098f6bcd4621d373cade4e832627b4f6 - expect(result.stdout).toBe( - "098f6bcd4621d373cade4e832627b4f6 /tmp/test.txt\n", - ); - expect(result.exitCode).toBe(0); - }); - - it("should hash multiple files", async () => { - const env = new Bash(); - await env.exec("echo -n 'a' > /tmp/a.txt"); - await env.exec("echo -n 'b' > /tmp/b.txt"); - const result = await env.exec("md5sum /tmp/a.txt /tmp/b.txt"); - expect(result.stdout).toContain( - "0cc175b9c0f1b6a831c399e269772661 /tmp/a.txt", - ); - expect(result.stdout).toContain( - "92eb5ffee6ae2fec3ad71c777531578f /tmp/b.txt", - ); - expect(result.exitCode).toBe(0); - }); - }); - - describe("check mode", () => { - it("should verify correct checksums", async () => { - const env = new Bash(); - await env.exec("echo -n 'hello' > /tmp/hello.txt"); - await env.exec( - "echo '5d41402abc4b2a76b9719d911017c592 /tmp/hello.txt' > /tmp/sums.txt", - ); - const result = await env.exec("md5sum -c /tmp/sums.txt"); - expect(result.stdout).toContain("/tmp/hello.txt: OK"); - expect(result.exitCode).toBe(0); - }); - - it("should detect incorrect checksums", async () => { - const env = new Bash(); - await env.exec("echo -n 'wrong' > /tmp/wrong.txt"); - await env.exec( - "echo '5d41402abc4b2a76b9719d911017c592 /tmp/wrong.txt' > /tmp/sums.txt", - ); - const result = await env.exec("md5sum -c /tmp/sums.txt"); - expect(result.stdout).toContain("/tmp/wrong.txt: FAILED"); - expect(result.stdout).toContain("WARNING"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("error handling", () => { - it("should error on missing file", async () => { - const env = new Bash(); - const result = await env.exec("md5sum /tmp/nonexistent"); - expect(result.stdout).toContain("No such file or directory"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("--help", () => { - it("should display help", async () => { - const env = new Bash(); - const result = await env.exec("md5sum --help"); - expect(result.stdout).toContain("md5sum"); - expect(result.stdout).toContain("MD5"); - expect(result.exitCode).toBe(0); - }); - }); -}); - -describe("sha1sum", () => { - it("should hash a simple string", async () => { - const env = new Bash(); - const result = await env.exec("echo -n 'hello' | sha1sum"); - // SHA1 of "hello" is aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d - expect(result.stdout).toBe("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d -\n"); - expect(result.exitCode).toBe(0); - }); - - it("should hash empty input", async () => { - const env = new Bash(); - const result = await env.exec("echo -n '' | sha1sum"); - // SHA1 of "" is da39a3ee5e6b4b0d3255bfef95601890afd80709 - expect(result.stdout).toBe("da39a3ee5e6b4b0d3255bfef95601890afd80709 -\n"); - expect(result.exitCode).toBe(0); - }); -}); - -describe("binary files", () => { - it("should hash binary file with invalid UTF-8 bytes correctly", async () => { - // PNG magic bytes include 0x89 which is invalid UTF-8 - // If read as UTF-8, it would be corrupted and produce wrong hash - const env = new Bash({ - files: { - "/binary.dat": new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]), - }, - }); - const result = await env.exec("md5sum /binary.dat"); - // MD5 of bytes [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A] - expect(result.stdout).toBe( - "8eece9cc616084e69299f7f1a53a6404 /binary.dat\n", - ); - expect(result.exitCode).toBe(0); - }); - - it("should hash binary file with null bytes correctly", async () => { - const env = new Bash({ - files: { - "/nulls.dat": new Uint8Array([0x00, 0x00, 0x00, 0x00]), - }, - }); - const result = await env.exec("md5sum /nulls.dat"); - // MD5 of 4 null bytes - expect(result.stdout).toBe( - "f1d3ff8443297732862df21dc4e57262 /nulls.dat\n", - ); - expect(result.exitCode).toBe(0); - }); - - it("should hash binary file with sha256sum correctly", async () => { - const env = new Bash({ - files: { - "/binary.dat": new Uint8Array([0x89, 0x50, 0x4e, 0x47]), - }, - }); - const result = await env.exec("sha256sum /binary.dat"); - // SHA256 of bytes [0x89, 0x50, 0x4E, 0x47] - expect(result.stdout).toBe( - "0f4636c78f65d3639ece5a064b5ae753e3408614a14fb18ab4d7540d2c248543 /binary.dat\n", - ); - expect(result.exitCode).toBe(0); - }); -}); - -describe("sha256sum", () => { - it("should hash a simple string", async () => { - const env = new Bash(); - const result = await env.exec("echo -n 'hello' | sha256sum"); - // SHA256 of "hello" is 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 - expect(result.stdout).toBe( - "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 -\n", - ); - expect(result.exitCode).toBe(0); - }); - - it("should hash empty input", async () => { - const env = new Bash(); - const result = await env.exec("echo -n '' | sha256sum"); - // SHA256 of "" is e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 - expect(result.stdout).toBe( - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -\n", - ); - expect(result.exitCode).toBe(0); - }); - - describe("--help", () => { - it("should display help", async () => { - const env = new Bash(); - const result = await env.exec("sha256sum --help"); - expect(result.stdout).toContain("sha256sum"); - expect(result.stdout).toContain("SHA256"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/commands/md5sum/md5sum.ts b/src/commands/md5sum/md5sum.ts deleted file mode 100644 index f5c7d978..00000000 --- a/src/commands/md5sum/md5sum.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Command } from "../../types.js"; -import { createChecksumCommand } from "./checksum.js"; - -export const md5sumCommand: Command = createChecksumCommand( - "md5sum", - "md5", - "compute MD5 message digest", -); - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "md5sum", - flags: [{ flag: "-c", type: "boolean" }], - needsFiles: true, -}; diff --git a/src/commands/md5sum/sha1sum.ts b/src/commands/md5sum/sha1sum.ts deleted file mode 100644 index c7ca6fec..00000000 --- a/src/commands/md5sum/sha1sum.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Command } from "../../types.js"; -import { createChecksumCommand } from "./checksum.js"; - -export const sha1sumCommand: Command = createChecksumCommand( - "sha1sum", - "sha1", - "compute SHA1 message digest", -); - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "sha1sum", - flags: [{ flag: "-c", type: "boolean" }], - needsFiles: true, -}; diff --git a/src/commands/md5sum/sha256sum.ts b/src/commands/md5sum/sha256sum.ts deleted file mode 100644 index b237ab7a..00000000 --- a/src/commands/md5sum/sha256sum.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Command } from "../../types.js"; -import { createChecksumCommand } from "./checksum.js"; - -export const sha256sumCommand: Command = createChecksumCommand( - "sha256sum", - "sha256", - "compute SHA256 message digest", -); - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "sha256sum", - flags: [{ flag: "-c", type: "boolean" }], - needsFiles: true, -}; diff --git a/src/commands/mkdir/mkdir.test.ts b/src/commands/mkdir/mkdir.test.ts deleted file mode 100644 index e3ff8a44..00000000 --- a/src/commands/mkdir/mkdir.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("mkdir", () => { - it("should create directory", async () => { - const env = new Bash({ cwd: "/" }); - const result = await env.exec("mkdir /newdir"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - const ls = await env.exec("ls /"); - // /bin, /usr, /dev, /proc always exist - expect(ls.stdout).toBe("bin\ndev\nnewdir\nproc\nusr\n"); - }); - - it("should create multiple directories", async () => { - const env = new Bash({ cwd: "/" }); - await env.exec("mkdir /dir1 /dir2 /dir3"); - const ls = await env.exec("ls /"); - // /bin, /usr, /dev, /proc always exist - expect(ls.stdout).toBe("bin\ndev\ndir1\ndir2\ndir3\nproc\nusr\n"); - }); - - it("should create nested directories with -p", async () => { - const env = new Bash(); - const result = await env.exec("mkdir -p /a/b/c"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - const ls = await env.exec("ls /a/b"); - expect(ls.stdout).toBe("c\n"); - }); - - it("should create deeply nested directories with -p", async () => { - const env = new Bash(); - await env.exec("mkdir -p /one/two/three/four/five"); - const ls = await env.exec("ls /one/two/three/four"); - expect(ls.stdout).toBe("five\n"); - }); - - it("should create nested directories with --parents", async () => { - const env = new Bash(); - await env.exec("mkdir --parents /x/y/z"); - const ls = await env.exec("ls /x/y"); - expect(ls.stdout).toBe("z\n"); - }); - - it("should fail without -p for nested dirs", async () => { - const env = new Bash(); - const result = await env.exec("mkdir /a/b/c"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "mkdir: cannot create directory '/a/b/c': No such file or directory\n", - ); - expect(result.exitCode).toBe(1); - }); - - it("should not error if directory exists with -p", async () => { - const env = new Bash({ - files: { "/existing/file.txt": "" }, - }); - const result = await env.exec("mkdir -p /existing"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should error if file exists at path", async () => { - const env = new Bash({ - files: { "/file": "content" }, - }); - const result = await env.exec("mkdir /file"); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(1); - }); - - it("should error with no arguments", async () => { - const env = new Bash(); - const result = await env.exec("mkdir"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe("mkdir: missing operand\n"); - expect(result.exitCode).toBe(1); - }); - - it("should create directory with relative path", async () => { - const env = new Bash({ - files: { "/home/user/.keep": "" }, - cwd: "/home/user", - }); - await env.exec("mkdir projects"); - const ls = await env.exec("ls /home/user"); - expect(ls.stdout).toBe("projects\n"); - }); - - it("should create multiple nested paths with -p", async () => { - const env = new Bash(); - await env.exec("mkdir -p /a/b /c/d"); - const lsA = await env.exec("ls /a"); - const lsC = await env.exec("ls /c"); - expect(lsA.stdout).toBe("b\n"); - expect(lsC.stdout).toBe("d\n"); - }); -}); diff --git a/src/commands/mkdir/mkdir.ts b/src/commands/mkdir/mkdir.ts deleted file mode 100644 index 9664df30..00000000 --- a/src/commands/mkdir/mkdir.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { getErrorMessage } from "../../interpreter/helpers/errors.js"; -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { parseArgs } from "../../utils/args.js"; - -const argDefs = { - recursive: { short: "p", long: "parents", type: "boolean" as const }, - verbose: { short: "v", long: "verbose", type: "boolean" as const }, -}; - -export const mkdirCommand: Command = { - name: "mkdir", - - async execute(args: string[], ctx: CommandContext): Promise { - const parsed = parseArgs("mkdir", args, argDefs); - if (!parsed.ok) return parsed.error; - - const recursive = parsed.result.flags.recursive; - const verbose = parsed.result.flags.verbose; - const dirs = parsed.result.positional; - - if (dirs.length === 0) { - return { - stdout: "", - stderr: "mkdir: missing operand\n", - exitCode: 1, - }; - } - - let stdout = ""; - let stderr = ""; - let exitCode = 0; - - for (const dir of dirs) { - try { - const fullPath = ctx.fs.resolvePath(ctx.cwd, dir); - await ctx.fs.mkdir(fullPath, { recursive }); - if (verbose) { - stdout += `mkdir: created directory '${dir}'\n`; - } - } catch (error) { - const message = getErrorMessage(error); - if (message.includes("ENOENT") || message.includes("no such file")) { - stderr += `mkdir: cannot create directory '${dir}': No such file or directory\n`; - } else if ( - message.includes("EEXIST") || - message.includes("already exists") - ) { - stderr += `mkdir: cannot create directory '${dir}': File exists\n`; - } else { - stderr += `mkdir: cannot create directory '${dir}': ${message}\n`; - } - exitCode = 1; - } - } - - return { stdout, stderr, exitCode }; - }, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "mkdir", - flags: [ - { flag: "-p", type: "boolean" }, - { flag: "-v", type: "boolean" }, - ], - needsArgs: true, -}; diff --git a/src/commands/mv/mv.test.ts b/src/commands/mv/mv.test.ts deleted file mode 100644 index 576edcc7..00000000 --- a/src/commands/mv/mv.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("mv", () => { - it("should move file", async () => { - const env = new Bash({ - files: { "/old.txt": "content" }, - }); - const result = await env.exec("mv /old.txt /new.txt"); - expect(result.exitCode).toBe(0); - const content = await env.readFile("/new.txt"); - expect(content).toBe("content"); - }); - - it("should remove source after move", async () => { - const env = new Bash({ - files: { "/old.txt": "content" }, - }); - await env.exec("mv /old.txt /new.txt"); - const cat = await env.exec("cat /old.txt"); - expect(cat.exitCode).toBe(1); - }); - - it("should rename file in same directory", async () => { - const env = new Bash({ - files: { "/dir/oldname.txt": "content" }, - }); - await env.exec("mv /dir/oldname.txt /dir/newname.txt"); - const content = await env.readFile("/dir/newname.txt"); - expect(content).toBe("content"); - }); - - it("should move file to directory", async () => { - const env = new Bash({ - files: { - "/file.txt": "content", - "/dir/.keep": "", - }, - }); - await env.exec("mv /file.txt /dir/"); - const content = await env.readFile("/dir/file.txt"); - expect(content).toBe("content"); - }); - - it("should move multiple files to directory", async () => { - const env = new Bash({ - files: { - "/a.txt": "aaa", - "/b.txt": "bbb", - "/dir/.keep": "", - }, - }); - await env.exec("mv /a.txt /b.txt /dir"); - expect(await env.readFile("/dir/a.txt")).toBe("aaa"); - expect(await env.readFile("/dir/b.txt")).toBe("bbb"); - }); - - it("should error when moving multiple files to non-directory", async () => { - const env = new Bash({ - files: { - "/a.txt": "", - "/b.txt": "", - }, - }); - const result = await env.exec("mv /a.txt /b.txt /nonexistent"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("not a directory"); - }); - - it("should move directory", async () => { - const env = new Bash({ - files: { "/srcdir/file.txt": "content" }, - }); - await env.exec("mv /srcdir /dstdir"); - const content = await env.readFile("/dstdir/file.txt"); - expect(content).toBe("content"); - const ls = await env.exec("ls /srcdir"); - expect(ls.exitCode).not.toBe(0); - }); - - it("should move nested directories", async () => { - const env = new Bash({ - files: { - "/src/a/b/c.txt": "deep", - "/src/root.txt": "root", - }, - }); - await env.exec("mv /src /dst"); - expect(await env.readFile("/dst/a/b/c.txt")).toBe("deep"); - expect(await env.readFile("/dst/root.txt")).toBe("root"); - }); - - it("should overwrite destination file", async () => { - const env = new Bash({ - files: { - "/src.txt": "new", - "/dst.txt": "old", - }, - }); - await env.exec("mv /src.txt /dst.txt"); - const content = await env.readFile("/dst.txt"); - expect(content).toBe("new"); - }); - - it("should error on missing source", async () => { - const env = new Bash(); - const result = await env.exec("mv /missing.txt /dst.txt"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toBe( - "mv: cannot stat '/missing.txt': No such file or directory\n", - ); - }); - - it("should error with missing destination", async () => { - const env = new Bash({ - files: { "/src.txt": "" }, - }); - const result = await env.exec("mv /src.txt"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toBe("mv: missing destination file operand\n"); - }); - - it("should move with relative paths", async () => { - const env = new Bash({ - files: { "/home/user/old.txt": "content" }, - cwd: "/home/user", - }); - await env.exec("mv old.txt new.txt"); - const content = await env.readFile("/home/user/new.txt"); - expect(content).toBe("content"); - }); - - it("should move directory into existing directory", async () => { - const env = new Bash({ - files: { - "/src/file.txt": "content", - "/dst/.keep": "", - }, - }); - await env.exec("mv /src /dst/"); - const content = await env.readFile("/dst/src/file.txt"); - expect(content).toBe("content"); - }); - - describe("flags", () => { - it("should accept -f flag (force)", async () => { - const env = new Bash({ - files: { - "/src.txt": "new", - "/dst.txt": "old", - }, - }); - const result = await env.exec("mv -f /src.txt /dst.txt"); - expect(result.exitCode).toBe(0); - expect(result.stderr).toBe(""); - const content = await env.readFile("/dst.txt"); - expect(content).toBe("new"); - }); - - it("should skip existing file with -n flag (no-clobber)", async () => { - const env = new Bash({ - files: { - "/src.txt": "new", - "/dst.txt": "old", - }, - }); - const result = await env.exec("mv -n /src.txt /dst.txt"); - expect(result.exitCode).toBe(0); - expect(result.stderr).toBe(""); - // Source should still exist since move was skipped - const srcExists = await env.exec("cat /src.txt"); - expect(srcExists.exitCode).toBe(0); - // Destination should be unchanged - const content = await env.readFile("/dst.txt"); - expect(content).toBe("old"); - }); - - it("should move when destination doesn't exist with -n flag", async () => { - const env = new Bash({ - files: { "/src.txt": "content" }, - }); - const result = await env.exec("mv -n /src.txt /dst.txt"); - expect(result.exitCode).toBe(0); - const content = await env.readFile("/dst.txt"); - expect(content).toBe("content"); - }); - - it("should show verbose output with -v flag", async () => { - const env = new Bash({ - files: { "/old.txt": "content" }, - }); - const result = await env.exec("mv -v /old.txt /new.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("renamed '/old.txt' -> '/new.txt'\n"); - }); - - it("should handle combined flags -fv", async () => { - const env = new Bash({ - files: { - "/src.txt": "new", - "/dst.txt": "old", - }, - }); - const result = await env.exec("mv -fv /src.txt /dst.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("renamed '/src.txt' -> '/dst.txt'\n"); - }); - - it("should let -n take precedence over -f", async () => { - const env = new Bash({ - files: { - "/src.txt": "new", - "/dst.txt": "old", - }, - }); - const result = await env.exec("mv -fn /src.txt /dst.txt"); - expect(result.exitCode).toBe(0); - // Source should still exist (no-clobber took precedence) - const srcContent = await env.readFile("/src.txt"); - expect(srcContent).toBe("new"); - }); - - it("should show help with --help", async () => { - const env = new Bash(); - const result = await env.exec("mv --help"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("mv"); - expect(result.stdout).toContain("--force"); - expect(result.stdout).toContain("--no-clobber"); - expect(result.stdout).toContain("--verbose"); - }); - - it("should error on unknown flag", async () => { - const env = new Bash({ - files: { "/src.txt": "content" }, - }); - const result = await env.exec("mv -x /src.txt /dst.txt"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid option"); - }); - }); -}); diff --git a/src/commands/mv/mv.ts b/src/commands/mv/mv.ts deleted file mode 100644 index 1e168929..00000000 --- a/src/commands/mv/mv.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { getErrorMessage } from "../../interpreter/helpers/errors.js"; -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { parseArgs } from "../../utils/args.js"; -import { hasHelpFlag, showHelp } from "../help.js"; - -const mvHelp = { - name: "mv", - summary: "move (rename) files", - usage: "mv [OPTION]... SOURCE... DEST", - options: [ - "-f, --force do not prompt before overwriting", - "-n, --no-clobber do not overwrite an existing file", - "-v, --verbose explain what is being done", - " --help display this help and exit", - ], -}; - -const argDefs = { - force: { short: "f", long: "force", type: "boolean" as const }, - noClobber: { short: "n", long: "no-clobber", type: "boolean" as const }, - verbose: { short: "v", long: "verbose", type: "boolean" as const }, -}; - -export const mvCommand: Command = { - name: "mv", - - async execute(args: string[], ctx: CommandContext): Promise { - if (hasHelpFlag(args)) { - return showHelp(mvHelp); - } - - const parsed = parseArgs("mv", args, argDefs); - if (!parsed.ok) return parsed.error; - - let force = parsed.result.flags.force; - const noClobber = parsed.result.flags.noClobber; - const verbose = parsed.result.flags.verbose; - const paths = parsed.result.positional; - - // -n takes precedence over -f (per GNU coreutils behavior) - if (noClobber) { - force = false; - } - - if (paths.length < 2) { - return { - stdout: "", - stderr: "mv: missing destination file operand\n", - exitCode: 1, - }; - } - - const dest = paths.pop() ?? ""; - const sources = paths; - const destPath = ctx.fs.resolvePath(ctx.cwd, dest); - - let stdout = ""; - let stderr = ""; - let exitCode = 0; - - // Note: force is accepted but not used since we don't prompt - void force; - - // Check if dest is a directory - let destIsDir = false; - try { - const stat = await ctx.fs.stat(destPath); - destIsDir = stat.isDirectory; - } catch { - // Dest doesn't exist - } - - // If multiple sources, dest must be a directory - if (sources.length > 1 && !destIsDir) { - return { - stdout: "", - stderr: `mv: target '${dest}' is not a directory\n`, - exitCode: 1, - }; - } - - for (const src of sources) { - try { - const srcPath = ctx.fs.resolvePath(ctx.cwd, src); - - let targetPath = destPath; - if (destIsDir) { - const basename = src.split("/").pop() || src; - targetPath = - destPath === "/" ? `/${basename}` : `${destPath}/${basename}`; - } - - // Check if target exists for -n flag - if (noClobber) { - try { - await ctx.fs.stat(targetPath); - // Target exists and -n is set, skip this file silently - continue; - } catch { - // Target doesn't exist, proceed with move - } - } - - await ctx.fs.mv(srcPath, targetPath); - - if (verbose) { - const targetName = destIsDir - ? `${dest}/${src.split("/").pop() || src}` - : dest; - stdout += `renamed '${src}' -> '${targetName}'\n`; - } - } catch (error) { - const message = getErrorMessage(error); - if (message.includes("ENOENT") || message.includes("no such file")) { - stderr += `mv: cannot stat '${src}': No such file or directory\n`; - } else { - stderr += `mv: cannot move '${src}': ${message}\n`; - } - exitCode = 1; - } - } - - return { stdout, stderr, exitCode }; - }, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "mv", - flags: [ - { flag: "-f", type: "boolean" }, - { flag: "-n", type: "boolean" }, - { flag: "-v", type: "boolean" }, - ], - needsArgs: true, - minArgs: 2, -}; diff --git a/src/commands/nl/nl.test.ts b/src/commands/nl/nl.test.ts deleted file mode 100644 index 2576a0dd..00000000 --- a/src/commands/nl/nl.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("nl", () => { - it("numbers lines from stdin", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'a\\nb\\nc' | nl"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(" 1\ta\n 2\tb\n 3\tc"); - }); - - it("numbers lines from file", async () => { - const bash = new Bash({ - files: { - "/test.txt": "line1\nline2\nline3\n", - }, - }); - const result = await bash.exec("nl /test.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(" 1\tline1\n 2\tline2\n 3\tline3\n"); - }); - - it("skips empty lines with default style (t)", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'a\\n\\nb\\n' | nl"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(" 1\ta\n \t\n 2\tb\n"); - }); - - it("numbers all lines with -ba", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'a\\n\\nb\\n' | nl -ba"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(" 1\ta\n 2\t\n 3\tb\n"); - }); - - it("numbers all lines with -b a (space)", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'a\\n\\nb\\n' | nl -b a"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(" 1\ta\n 2\t\n 3\tb\n"); - }); - - it("numbers no lines with -bn", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'a\\nb\\n' | nl -bn"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(" \ta\n \tb\n"); - }); - - it("left justifies with -n ln", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'a\\nb\\n' | nl -n ln"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1 \ta\n2 \tb\n"); - }); - - it("right justifies with zeros with -n rz", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'a\\nb\\n' | nl -n rz"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("000001\ta\n000002\tb\n"); - }); - - it("sets width with -w", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'a\\nb\\n' | nl -w 3"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(" 1\ta\n 2\tb\n"); - }); - - it("sets width with -w and rz format", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'a\\nb\\n' | nl -w 3 -n rz"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("001\ta\n002\tb\n"); - }); - - it("sets separator with -s", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'a\\nb\\n' | nl -s ': '"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(" 1: a\n 2: b\n"); - }); - - it("sets starting number with -v", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'a\\nb\\n' | nl -v 10"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(" 10\ta\n 11\tb\n"); - }); - - it("sets increment with -i", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'a\\nb\\nc\\n' | nl -i 5"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(" 1\ta\n 6\tb\n 11\tc\n"); - }); - - it("combines multiple options", async () => { - const bash = new Bash(); - const result = await bash.exec( - "printf 'a\\nb\\nc\\n' | nl -ba -n rz -w 4 -s '|' -v 100 -i 10", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("0100|a\n0110|b\n0120|c\n"); - }); - - it("handles empty input", async () => { - const bash = new Bash(); - const result = await bash.exec("printf '' | nl"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - }); - - it("handles single line without newline", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'hello' | nl"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(" 1\thello"); - }); - - it("handles multiple files", async () => { - const bash = new Bash({ - files: { - "/a.txt": "one\ntwo\n", - "/b.txt": "three\nfour\n", - }, - }); - const result = await bash.exec("nl /a.txt /b.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - " 1\tone\n 2\ttwo\n 3\tthree\n 4\tfour\n", - ); - }); - - it("continues numbering across files", async () => { - const bash = new Bash({ - files: { - "/a.txt": "one\n", - "/b.txt": "two\n", - }, - }); - const result = await bash.exec("nl -v 10 /a.txt /b.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(" 10\tone\n 11\ttwo\n"); - }); - - it("handles file not found", async () => { - const bash = new Bash(); - const result = await bash.exec("nl /nonexistent.txt"); - expect(result.exitCode).toBe(1); - expect(result.stderr.toLowerCase()).toContain("no such file or directory"); - }); - - it("shows help with --help", async () => { - const bash = new Bash(); - const result = await bash.exec("nl --help"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("nl"); - expect(result.stdout).toContain("number"); - }); - - it("errors on invalid body style", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'x' | nl -b x"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid body numbering style"); - }); - - it("errors on invalid number format", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'x' | nl -n xx"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid line numbering format"); - }); - - it("errors on invalid width", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'x' | nl -w abc"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid line number field width"); - }); - - it("handles negative start number", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'a\\nb\\nc\\n' | nl -v -1"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(" -1\ta\n 0\tb\n 1\tc\n"); - }); - - it("handles whitespace-only lines as non-empty with -bt", async () => { - const bash = new Bash(); - // With -bt (default), whitespace-only lines are considered empty - const result = await bash.exec("printf 'a\\n \\nb\\n' | nl"); - expect(result.exitCode).toBe(0); - // Whitespace-only line is considered empty and not numbered - expect(result.stdout).toBe(" 1\ta\n \t \n 2\tb\n"); - }); - - it("errors on unknown short flag", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'x' | nl -x"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid option"); - }); - - it("errors on unknown long flag", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'x' | nl --unknown"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("unrecognized option"); - }); -}); diff --git a/src/commands/nl/nl.ts b/src/commands/nl/nl.ts deleted file mode 100644 index f56d8d6f..00000000 --- a/src/commands/nl/nl.ts +++ /dev/null @@ -1,325 +0,0 @@ -/** - * nl - number lines of files - * - * Usage: nl [OPTION]... [FILE]... - * - * Write each FILE to standard output, with line numbers added. - * If no FILE is specified, standard input is read. - */ - -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { hasHelpFlag, showHelp, unknownOption } from "../help.js"; - -const nlHelp = { - name: "nl", - summary: "number lines of files", - usage: "nl [OPTION]... [FILE]...", - description: - "Write each FILE to standard output, with line numbers added. If no FILE is specified, standard input is read.", - options: [ - "-b STYLE Body numbering style: a (all), t (non-empty), n (none)", - "-n FORMAT Number format: ln (left), rn (right), rz (right zeros)", - "-w WIDTH Number width (default: 6)", - "-s SEP Separator after number (default: TAB)", - "-v START Starting line number (default: 1)", - "-i INCR Line number increment (default: 1)", - ], - examples: [ - "nl file.txt # Number non-empty lines", - "nl -ba file.txt # Number all lines", - "nl -n rz -w 3 file.txt # Right-justified with zeros", - "nl -s ': ' file.txt # Use ': ' as separator", - ], -}; - -type NumberingStyle = "a" | "t" | "n"; -type NumberFormat = "ln" | "rn" | "rz"; - -interface NlOptions { - bodyStyle: NumberingStyle; - numberFormat: NumberFormat; - width: number; - separator: string; - startNumber: number; - increment: number; -} - -function formatLineNumber( - num: number, - format: NumberFormat, - width: number, -): string { - const numStr = String(num); - switch (format) { - case "ln": - // Left justified - return numStr.padEnd(width); - case "rn": - // Right justified with spaces - return numStr.padStart(width); - case "rz": - // Right justified with zeros - return numStr.padStart(width, "0"); - default: { - const _exhaustive: never = format; - return _exhaustive; - } - } -} - -function shouldNumber(line: string, style: NumberingStyle): boolean { - switch (style) { - case "a": - return true; - case "t": - return line.trim().length > 0; - case "n": - return false; - default: { - const _exhaustive: never = style; - return _exhaustive; - } - } -} - -function processContent( - content: string, - options: NlOptions, - currentNumber: number, -): { output: string; nextNumber: number } { - // Handle empty input - if (content === "") { - return { output: "", nextNumber: currentNumber }; - } - - const lines = content.split("\n"); - const resultLines: string[] = []; - let lineNumber = currentNumber; - - // Handle trailing newline - const hasTrailingNewline = - content.endsWith("\n") && lines[lines.length - 1] === ""; - if (hasTrailingNewline) { - lines.pop(); - } - - for (const line of lines) { - if (shouldNumber(line, options.bodyStyle)) { - const formattedNum = formatLineNumber( - lineNumber, - options.numberFormat, - options.width, - ); - resultLines.push(`${formattedNum}${options.separator}${line}`); - lineNumber += options.increment; - } else { - // Empty line without numbering - just add padding spaces for alignment - const padding = " ".repeat(options.width); - resultLines.push(`${padding}${options.separator}${line}`); - } - } - - return { - output: resultLines.join("\n") + (hasTrailingNewline ? "\n" : ""), - nextNumber: lineNumber, - }; -} - -export const nl: Command = { - name: "nl", - execute: async (args: string[], ctx: CommandContext): Promise => { - if (hasHelpFlag(args)) { - return showHelp(nlHelp); - } - - const options: NlOptions = { - bodyStyle: "t", - numberFormat: "rn", - width: 6, - separator: "\t", - startNumber: 1, - increment: 1, - }; - - const files: string[] = []; - let i = 0; - - while (i < args.length) { - const arg = args[i]; - - if (arg === "-b" && i + 1 < args.length) { - const style = args[i + 1]; - if (style !== "a" && style !== "t" && style !== "n") { - return { - exitCode: 1, - stdout: "", - stderr: `nl: invalid body numbering style: '${style}'\n`, - }; - } - options.bodyStyle = style; - i += 2; - } else if (arg.startsWith("-b")) { - const style = arg.slice(2); - if (style !== "a" && style !== "t" && style !== "n") { - return { - exitCode: 1, - stdout: "", - stderr: `nl: invalid body numbering style: '${style}'\n`, - }; - } - options.bodyStyle = style; - i++; - } else if (arg === "-n" && i + 1 < args.length) { - const format = args[i + 1]; - if (format !== "ln" && format !== "rn" && format !== "rz") { - return { - exitCode: 1, - stdout: "", - stderr: `nl: invalid line numbering format: '${format}'\n`, - }; - } - options.numberFormat = format; - i += 2; - } else if (arg.startsWith("-n")) { - const format = arg.slice(2); - if (format !== "ln" && format !== "rn" && format !== "rz") { - return { - exitCode: 1, - stdout: "", - stderr: `nl: invalid line numbering format: '${format}'\n`, - }; - } - options.numberFormat = format; - i++; - } else if (arg === "-w" && i + 1 < args.length) { - const width = parseInt(args[i + 1], 10); - if (Number.isNaN(width) || width < 1) { - return { - exitCode: 1, - stdout: "", - stderr: `nl: invalid line number field width: '${args[i + 1]}'\n`, - }; - } - options.width = width; - i += 2; - } else if (arg.startsWith("-w")) { - const width = parseInt(arg.slice(2), 10); - if (Number.isNaN(width) || width < 1) { - return { - exitCode: 1, - stdout: "", - stderr: `nl: invalid line number field width: '${arg.slice(2)}'\n`, - }; - } - options.width = width; - i++; - } else if (arg === "-s" && i + 1 < args.length) { - options.separator = args[i + 1]; - i += 2; - } else if (arg.startsWith("-s")) { - options.separator = arg.slice(2); - i++; - } else if (arg === "-v" && i + 1 < args.length) { - const start = parseInt(args[i + 1], 10); - if (Number.isNaN(start)) { - return { - exitCode: 1, - stdout: "", - stderr: `nl: invalid starting line number: '${args[i + 1]}'\n`, - }; - } - options.startNumber = start; - i += 2; - } else if (arg.startsWith("-v")) { - const start = parseInt(arg.slice(2), 10); - if (Number.isNaN(start)) { - return { - exitCode: 1, - stdout: "", - stderr: `nl: invalid starting line number: '${arg.slice(2)}'\n`, - }; - } - options.startNumber = start; - i++; - } else if (arg === "-i" && i + 1 < args.length) { - const incr = parseInt(args[i + 1], 10); - if (Number.isNaN(incr)) { - return { - exitCode: 1, - stdout: "", - stderr: `nl: invalid line number increment: '${args[i + 1]}'\n`, - }; - } - options.increment = incr; - i += 2; - } else if (arg.startsWith("-i")) { - const incr = parseInt(arg.slice(2), 10); - if (Number.isNaN(incr)) { - return { - exitCode: 1, - stdout: "", - stderr: `nl: invalid line number increment: '${arg.slice(2)}'\n`, - }; - } - options.increment = incr; - i++; - } else if (arg === "--") { - files.push(...args.slice(i + 1)); - break; - } else if (arg.startsWith("-") && arg !== "-") { - return unknownOption("nl", arg); - } else { - files.push(arg); - i++; - } - } - - let output = ""; - let lineNumber = options.startNumber; - - if (files.length === 0) { - // Read from stdin - const input = ctx.stdin ?? ""; - const result = processContent(input, options, lineNumber); - output = result.output; - } else { - // Process each file - for (const file of files) { - const filePath = ctx.fs.resolvePath(ctx.cwd, file); - const content = await ctx.fs.readFile(filePath); - if (content === null) { - return { - exitCode: 1, - stdout: output, - stderr: `nl: ${file}: No such file or directory\n`, - }; - } - const result = processContent(content, options, lineNumber); - output += result.output; - lineNumber = result.nextNumber; - } - } - - return { - exitCode: 0, - stdout: output, - stderr: "", - }; - }, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "nl", - flags: [ - { flag: "-b", type: "value", valueHint: "string" }, - { flag: "-n", type: "value", valueHint: "string" }, - { flag: "-w", type: "value", valueHint: "number" }, - { flag: "-s", type: "value", valueHint: "string" }, - { flag: "-v", type: "value", valueHint: "number" }, - { flag: "-i", type: "value", valueHint: "number" }, - ], - stdinType: "text", - needsFiles: true, -}; diff --git a/src/commands/od/od.binary.test.ts b/src/commands/od/od.binary.test.ts deleted file mode 100644 index 9d28d09b..00000000 --- a/src/commands/od/od.binary.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("od with binary data", () => { - describe("binary file dump", () => { - it("should dump binary file with high bytes", async () => { - const env = new Bash({ - files: { - "/binary.bin": new Uint8Array([0x80, 0x90, 0xa0, 0xb0, 0xff]), - }, - }); - - const result = await env.exec("od /binary.bin"); - - expect(result.exitCode).toBe(0); - // od outputs octal by default - expect(result.stdout.length).toBeGreaterThan(0); - expect(result.stdout).toContain("0000000"); // address - }); - - it("should dump binary file with null bytes", async () => { - const env = new Bash({ - files: { - "/nulls.bin": new Uint8Array([0x00, 0x00, 0x41, 0x42]), - }, - }); - - const result = await env.exec("od -c /nulls.bin"); - - expect(result.exitCode).toBe(0); - // od -c shows characters, \0 for null - expect(result.stdout).toContain("\\0"); - expect(result.stdout).toContain("A"); - expect(result.stdout).toContain("B"); - }); - - it("should dump all byte values with default format", async () => { - const env = new Bash({ - files: { - "/allbytes.bin": new Uint8Array( - Array.from({ length: 16 }, (_, i) => i * 16), - ), - }, - }); - - const result = await env.exec("od /allbytes.bin"); - - expect(result.exitCode).toBe(0); - expect(result.stdout.length).toBeGreaterThan(0); - }); - }); - - describe("binary stdin dump", () => { - it("should dump binary data from stdin", async () => { - const env = new Bash({ - files: { - "/binary.bin": new Uint8Array([0x80, 0xff, 0x90, 0xab]), - }, - }); - - const result = await env.exec("cat /binary.bin | od"); - - expect(result.exitCode).toBe(0); - expect(result.stdout.length).toBeGreaterThan(0); - }); - - it("should dump piped binary with character format", async () => { - const env = new Bash({ - files: { - "/binary.bin": new Uint8Array([0x41, 0x42, 0x43, 0x44]), // ABCD - }, - }); - - const result = await env.exec("cat /binary.bin | od -c"); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("A"); - expect(result.stdout).toContain("B"); - expect(result.stdout).toContain("C"); - expect(result.stdout).toContain("D"); - }); - }); -}); diff --git a/src/commands/od/od.test.ts b/src/commands/od/od.test.ts deleted file mode 100644 index e22d1dbf..00000000 --- a/src/commands/od/od.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("od command", () => { - it("should dump stdin in octal format", async () => { - const env = new Bash(); - const result = await env.exec('echo -n "AB" | od'); - // A=101, B=102 in octal (4-char fields: space + 3-digit octal) - expect(result.stdout).toBe("0000000 101 102\n0000002\n"); - expect(result.exitCode).toBe(0); - }); - - it("should show character mode with -c", async () => { - const env = new Bash(); - const result = await env.exec('echo -n "hi" | od -c'); - // Character mode uses 4-char fields (3 spaces + char for printable) - expect(result.stdout).toBe("0000000 h i\n0000002\n"); - expect(result.exitCode).toBe(0); - }); - - it("should show escape sequences in character mode", async () => { - const env = new Bash(); - const result = await env.exec('echo "hello" | od -c'); - // Character mode uses 4-char fields (2 spaces + 2-char escape sequence) - expect(result.stdout).toBe("0000000 h e l l o \\n\n0000006\n"); - expect(result.exitCode).toBe(0); - }); - - it("should suppress addresses with -An", async () => { - const env = new Bash(); - const result = await env.exec('echo -n "A" | od -An'); - // Octal mode uses 4-char fields (1 space + 3-digit octal) - expect(result.stdout).toBe(" 101\n"); - expect(result.exitCode).toBe(0); - }); - - it("should read from file", async () => { - const env = new Bash(); - await env.exec('echo -n "test" > /tmp/od-test.txt'); - const result = await env.exec("od /tmp/od-test.txt"); - // t=164, e=145, s=163, t=164 in octal (4-char fields) - expect(result.stdout).toBe("0000000 164 145 163 164\n0000004\n"); - expect(result.exitCode).toBe(0); - }); - - it("should error on non-existent file", async () => { - const env = new Bash(); - const result = await env.exec("od /nonexistent/file.txt"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toBe( - "od: /nonexistent/file.txt: No such file or directory\n", - ); - }); -}); diff --git a/src/commands/od/od.ts b/src/commands/od/od.ts deleted file mode 100644 index 444a5b63..00000000 --- a/src/commands/od/od.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * od - dump files in octal and other formats - * - * Usage: od [OPTION]... [FILE]... - * - * Write an unambiguous representation, octal bytes by default, - * of FILE to standard output. - */ - -import type { Command, CommandContext, ExecResult } from "../../types.js"; - -type OutputFormat = "octal" | "hex" | "char"; - -async function odExecute( - args: string[], - ctx: CommandContext, -): Promise { - // Parse options - let addressMode: "octal" | "none" = "octal"; - const outputFormats: OutputFormat[] = []; - const fileArgs: string[] = []; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === "-c") { - outputFormats.push("char"); - } else if (arg === "-An" || (arg === "-A" && args[i + 1] === "n")) { - addressMode = "none"; - if (arg === "-A") i++; // Skip the "n" argument - } else if (arg === "-t" && args[i + 1]) { - const format = args[++i]; - if (format === "x1") { - outputFormats.push("hex"); - } else if (format === "c") { - outputFormats.push("char"); - } else if (format.startsWith("o")) { - outputFormats.push("octal"); - } - } else if (!arg.startsWith("-") || arg === "-") { - fileArgs.push(arg); - } - } - - // Default to octal if no format specified - if (outputFormats.length === 0) { - outputFormats.push("octal"); - } - - // Get input - from file or stdin - let input = ctx.stdin; - - // Check for file argument - if (fileArgs.length > 0 && fileArgs[0] !== "-") { - const filePath = fileArgs[0].startsWith("/") - ? fileArgs[0] - : `${ctx.cwd}/${fileArgs[0]}`; - try { - input = await ctx.fs.readFile(filePath); - } catch { - return { - stdout: "", - stderr: `od: ${fileArgs[0]}: No such file or directory\n`, - exitCode: 1, - }; - } - } - - // Check if char format is included (affects field width) - const hasCharFormat = outputFormats.includes("char"); - - // Format a single byte for character mode (4-char field, right-aligned) - // Real od uses backslash only for named escape sequences, not for generic octal - function formatCharByte(code: number): string { - // Named escape sequences (2 chars, padded with 2 leading spaces) - if (code === 0) return " \\0"; - if (code === 7) return " \\a"; - if (code === 8) return " \\b"; - if (code === 9) return " \\t"; - if (code === 10) return " \\n"; - if (code === 11) return " \\v"; - if (code === 12) return " \\f"; - if (code === 13) return " \\r"; - if (code >= 32 && code < 127) { - // Printable ASCII - 3 leading spaces + char = 4 chars total - return ` ${String.fromCharCode(code)}`; - } - // Non-printable - use 3-digit octal WITHOUT backslash (this is real od behavior) - return ` ${code.toString(8).padStart(3, "0")}`; - } - - // Format a single byte for hex mode - // Field width depends on whether char format is also used - function formatHexByte(code: number): string { - if (hasCharFormat) { - // 4-char field: 2 spaces + 2 hex digits - return ` ${code.toString(16).padStart(2, "0")}`; - } - // 3-char field: 1 space + 2 hex digits - return ` ${code.toString(16).padStart(2, "0")}`; - } - - // Format a single byte for octal mode (right-aligned) - function formatOctalByte(code: number): string { - return ` ${code.toString(8).padStart(3, "0")}`; - } - - // Get bytes from input - const bytes: number[] = []; - for (const char of input) { - bytes.push(char.charCodeAt(0)); - } - - // Determine bytes per line (use 16 for hex/char compatibility) - const bytesPerLine = 16; - - // Build output lines - const lines: string[] = []; - - for (let offset = 0; offset < bytes.length; offset += bytesPerLine) { - const chunkBytes = bytes.slice(offset, offset + bytesPerLine); - - // For each output format, generate a line - for (let formatIdx = 0; formatIdx < outputFormats.length; formatIdx++) { - const format = outputFormats[formatIdx]; - let formatted: string[]; - - if (format === "char") { - formatted = chunkBytes.map(formatCharByte); - } else if (format === "hex") { - formatted = chunkBytes.map(formatHexByte); - } else { - formatted = chunkBytes.map(formatOctalByte); - } - - // Add address prefix only for the first format of each offset - let prefix = ""; - if (formatIdx === 0 && addressMode !== "none") { - prefix = `${offset.toString(8).padStart(7, "0")} `; - } else if (formatIdx > 0 || addressMode === "none") { - // For subsequent formats or no-address mode, just use spaces - prefix = addressMode === "none" ? "" : " "; - } - - // No separator needed - each field already includes leading spaces - lines.push(prefix + formatted.join("")); - } - } - - // Add final address - if (addressMode !== "none" && bytes.length > 0) { - lines.push(bytes.length.toString(8).padStart(7, "0")); - } - - return { - stdout: lines.length > 0 ? `${lines.join("\n")}\n` : "", - stderr: "", - exitCode: 0, - }; -} - -export const od: Command = { - name: "od", - execute: odExecute, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "od", - flags: [ - { flag: "-c", type: "boolean" }, - { flag: "-A", type: "value", valueHint: "string" }, - { flag: "-t", type: "value", valueHint: "string" }, - { flag: "-N", type: "value", valueHint: "number" }, - ], - stdinType: "text", - needsFiles: true, -}; diff --git a/src/commands/paste/paste.test.ts b/src/commands/paste/paste.test.ts deleted file mode 100644 index d57309fb..00000000 --- a/src/commands/paste/paste.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("paste command", () => { - const createEnv = () => - new Bash({ - files: { - "/test/file1.txt": "a\nb\nc\n", - "/test/file2.txt": "1\n2\n3\n", - "/test/file3.txt": "x\ny\nz\n", - "/test/uneven1.txt": "a\nb\n", - "/test/uneven2.txt": "1\n2\n3\n4\n", - "/test/single.txt": "hello\nworld\n", - "/test/empty.txt": "", - }, - cwd: "/test", - }); - - describe("basic functionality", () => { - it("should paste two files side by side with tab delimiter", async () => { - const env = createEnv(); - const result = await env.exec("paste file1.txt file2.txt"); - expect(result.stdout).toBe("a\t1\nb\t2\nc\t3\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should paste three files side by side", async () => { - const env = createEnv(); - const result = await env.exec("paste file1.txt file2.txt file3.txt"); - expect(result.stdout).toBe("a\t1\tx\nb\t2\ty\nc\t3\tz\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should handle files with uneven line counts", async () => { - const env = createEnv(); - const result = await env.exec("paste uneven1.txt uneven2.txt"); - expect(result.stdout).toBe("a\t1\nb\t2\n\t3\n\t4\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should handle single file", async () => { - const env = createEnv(); - const result = await env.exec("paste file1.txt"); - expect(result.stdout).toBe("a\nb\nc\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - }); - - describe("-d (delimiter)", () => { - it("should use custom delimiter", async () => { - const env = createEnv(); - const result = await env.exec("paste -d, file1.txt file2.txt"); - expect(result.stdout).toBe("a,1\nb,2\nc,3\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should use space as delimiter", async () => { - const env = createEnv(); - const result = await env.exec('paste -d" " file1.txt file2.txt'); - expect(result.stdout).toBe("a 1\nb 2\nc 3\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should use colon as delimiter", async () => { - const env = createEnv(); - const result = await env.exec("paste -d: file1.txt file2.txt"); - expect(result.stdout).toBe("a:1\nb:2\nc:3\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should cycle through multiple delimiters", async () => { - const env = createEnv(); - const result = await env.exec("paste -d,: file1.txt file2.txt file3.txt"); - expect(result.stdout).toBe("a,1:x\nb,2:y\nc,3:z\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should handle -d with space separator", async () => { - const env = createEnv(); - const result = await env.exec("paste -d , file1.txt file2.txt"); - expect(result.stdout).toBe("a,1\nb,2\nc,3\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - }); - - describe("-s (serial)", () => { - it("should paste lines horizontally in serial mode", async () => { - const env = createEnv(); - const result = await env.exec("paste -s file1.txt"); - expect(result.stdout).toBe("a\tb\tc\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should paste multiple files serially", async () => { - const env = createEnv(); - const result = await env.exec("paste -s file1.txt file2.txt"); - expect(result.stdout).toBe("a\tb\tc\n1\t2\t3\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should use custom delimiter in serial mode", async () => { - const env = createEnv(); - const result = await env.exec("paste -s -d, file1.txt"); - expect(result.stdout).toBe("a,b,c\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should handle combined -sd option", async () => { - const env = createEnv(); - const result = await env.exec("paste -sd, file1.txt"); - expect(result.stdout).toBe("a,b,c\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - }); - - describe("stdin", () => { - it("should error when no files specified", async () => { - const env = createEnv(); - const result = await env.exec("echo -e 'a\\nb\\nc' | paste"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "usage: paste [-s] [-d delimiters] file ...\n", - ); - expect(result.exitCode).toBe(1); - }); - - it("should read from stdin with explicit -", async () => { - const env = createEnv(); - const result = await env.exec("echo -e 'a\\nb\\nc' | paste -"); - expect(result.stdout).toBe("a\nb\nc\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should paste stdin with file", async () => { - const env = createEnv(); - const result = await env.exec("echo -e 'x\\ny\\nz' | paste - file1.txt"); - expect(result.stdout).toBe("x\ta\ny\tb\nz\tc\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should handle - - to paste pairs of lines", async () => { - const env = createEnv(); - const result = await env.exec("echo -e 'a\\nb\\nc\\nd' | paste - -"); - expect(result.stdout).toBe("a\tb\nc\td\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - }); - - describe("edge cases", () => { - it("should handle empty file", async () => { - const env = createEnv(); - const result = await env.exec("paste empty.txt file1.txt"); - expect(result.stdout).toBe("\ta\n\tb\n\tc\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should return error for non-existent file", async () => { - const env = createEnv(); - const result = await env.exec("paste nonexistent.txt"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "paste: nonexistent.txt: No such file or directory\n", - ); - expect(result.exitCode).toBe(1); - }); - - it("should return error for unknown option", async () => { - const env = createEnv(); - const result = await env.exec("paste -x file1.txt"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe("paste: invalid option -- 'x'\n"); - expect(result.exitCode).toBe(1); - }); - - it("should return error for unknown long option", async () => { - const env = createEnv(); - const result = await env.exec("paste --unknown file1.txt"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe("paste: unrecognized option '--unknown'\n"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("--help", () => { - it("should show help", async () => { - const env = createEnv(); - const result = await env.exec("paste --help"); - expect(result.stdout).toContain("paste - merge lines of files"); - expect(result.stdout).toContain("Usage:"); - expect(result.stdout).toContain("-d, --delimiters"); - expect(result.stdout).toContain("-s, --serial"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/commands/paste/paste.ts b/src/commands/paste/paste.ts deleted file mode 100644 index e7c491fd..00000000 --- a/src/commands/paste/paste.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { parseArgs } from "../../utils/args.js"; -import { hasHelpFlag, showHelp } from "../help.js"; - -const pasteHelp = { - name: "paste", - summary: "merge lines of files", - usage: "paste [OPTION]... [FILE]...", - description: [ - "Write lines consisting of the sequentially corresponding lines from", - "each FILE, separated by TABs, to standard output.", - "", - "With no FILE, or when FILE is -, read standard input.", - ], - options: [ - "-d, --delimiters=LIST reuse characters from LIST instead of TABs", - "-s, --serial paste one file at a time instead of in parallel", - " --help display this help and exit", - ], - examples: [ - "paste file1 file2 Merge file1 and file2 side by side", - "paste -d, file1 file2 Use comma as delimiter", - "paste -s file1 Paste all lines of file1 on one line", - "paste - - < file Paste pairs of lines from file", - ], -}; - -const argDefs = { - delimiter: { - short: "d", - long: "delimiters", - type: "string" as const, - default: "\t", - }, - serial: { short: "s", long: "serial", type: "boolean" as const }, -}; - -export const pasteCommand: Command = { - name: "paste", - async execute(args: string[], ctx: CommandContext): Promise { - if (hasHelpFlag(args)) { - return showHelp(pasteHelp); - } - - const parsed = parseArgs("paste", args, argDefs); - if (!parsed.ok) return parsed.error; - - const delimiter = parsed.result.flags.delimiter; - const serial = parsed.result.flags.serial; - const files = parsed.result.positional; - - // If no files specified, show usage error (matches BSD/macOS behavior) - if (files.length === 0) { - return { - stdout: "", - stderr: "usage: paste [-s] [-d delimiters] file ...\n", - exitCode: 1, - }; - } - - // Parse stdin into lines (will be distributed across multiple `-` args) - const stdinLines = ctx.stdin ? ctx.stdin.split("\n") : [""]; - if (stdinLines.length > 0 && stdinLines[stdinLines.length - 1] === "") { - stdinLines.pop(); - } - - // Count how many stdin ("-") arguments we have - const stdinCount = files.filter((f) => f === "-").length; - - // Read all file contents - // For stdin entries, we'll distribute lines across them - const fileContents: (string[] | null)[] = []; - let stdinIndex = 0; - - for (const file of files) { - if (file === "-") { - // This stdin gets every Nth line where N = stdinCount - const thisStdinLines: string[] = []; - for (let i = stdinIndex; i < stdinLines.length; i += stdinCount) { - thisStdinLines.push(stdinLines[i]); - } - fileContents.push(thisStdinLines); - stdinIndex++; - } else { - const filePath = ctx.fs.resolvePath(ctx.cwd, file); - try { - const content = await ctx.fs.readFile(filePath); - const lines = content.split("\n"); - // Remove trailing empty line if content ends with newline - if (lines.length > 0 && lines[lines.length - 1] === "") { - lines.pop(); - } - fileContents.push(lines); - } catch { - return { - stdout: "", - stderr: `paste: ${file}: No such file or directory\n`, - exitCode: 1, - }; - } - } - } - - let output = ""; - - if (serial) { - // Serial mode: paste all lines of each file on one line - for (const lines of fileContents) { - if (lines) { - output += `${joinWithDelimiters(lines, delimiter)}\n`; - } - } - } else { - // Parallel mode: merge lines from all files - const maxLines = Math.max(...fileContents.map((f) => f?.length ?? 0)); - - for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) { - const lineParts: string[] = []; - for (const lines of fileContents) { - lineParts.push(lines?.[lineIdx] ?? ""); - } - output += `${joinWithDelimiters(lineParts, delimiter)}\n`; - } - } - - return { stdout: output, stderr: "", exitCode: 0 }; - }, -}; - -/** - * Join parts using delimiters from the delimiter list. - * Delimiters are used cyclically (e.g., with -d',;' first delimiter is ',', second is ';', then ',' again) - */ -function joinWithDelimiters(parts: string[], delimiters: string): string { - if (parts.length === 0) return ""; - if (parts.length === 1) return parts[0]; - - let result = parts[0]; - for (let i = 1; i < parts.length; i++) { - // Use delimiter cyclically - const delimIdx = (i - 1) % delimiters.length; - result += delimiters[delimIdx] + parts[i]; - } - return result; -} - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "paste", - flags: [ - { flag: "-d", type: "value", valueHint: "delimiter" }, - { flag: "-s", type: "boolean" }, - ], - stdinType: "text", - needsFiles: true, -}; diff --git a/src/commands/printf/escapes.test.ts b/src/commands/printf/escapes.test.ts deleted file mode 100644 index a514b61b..00000000 --- a/src/commands/printf/escapes.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { applyWidth, parseWidthPrecision, processEscapes } from "./escapes.js"; - -describe("processEscapes", () => { - describe("basic escapes", () => { - it("should handle \\n (newline)", () => { - expect(processEscapes("a\\nb")).toBe("a\nb"); - }); - - it("should handle \\t (tab)", () => { - expect(processEscapes("a\\tb")).toBe("a\tb"); - }); - - it("should handle \\r (carriage return)", () => { - expect(processEscapes("a\\rb")).toBe("a\rb"); - }); - - it("should handle \\\\ (backslash)", () => { - expect(processEscapes("a\\\\b")).toBe("a\\b"); - }); - - it("should handle \\a (bell)", () => { - expect(processEscapes("\\a")).toBe("\x07"); - }); - - it("should handle \\b (backspace)", () => { - expect(processEscapes("\\b")).toBe("\b"); - }); - - it("should handle \\f (form feed)", () => { - expect(processEscapes("\\f")).toBe("\f"); - }); - - it("should handle \\v (vertical tab)", () => { - expect(processEscapes("\\v")).toBe("\v"); - }); - }); - - describe("escape character (\\e/\\E)", () => { - it("should handle \\e for ANSI escape", () => { - expect(processEscapes("\\e[31m")).toBe("\x1b[31m"); - }); - - it("should handle \\E as alias for \\e", () => { - expect(processEscapes("\\E[0m")).toBe("\x1b[0m"); - }); - - it("should handle full ANSI color sequence", () => { - expect(processEscapes("\\e[32mgreen\\e[0m")).toBe("\x1b[32mgreen\x1b[0m"); - }); - }); - - describe("octal escapes", () => { - it("should handle \\0 (null)", () => { - expect(processEscapes("a\\0b")).toBe("a\0b"); - }); - - it("should handle \\NNN octal sequences", () => { - expect(processEscapes("\\101\\102\\103")).toBe("ABC"); - }); - - it("should handle \\0NNN - reads max 3 octal digits", () => { - // \0101 reads as \010 (octal 8 = backspace) followed by literal "1" - expect(processEscapes("\\0101")).toBe("\b1"); - // \077 reads as octal 63 = "?" - expect(processEscapes("\\077")).toBe("?"); - }); - }); - - describe("hex escapes (\\x)", () => { - it("should handle \\xHH hex sequences", () => { - expect(processEscapes("\\x41\\x42\\x43")).toBe("ABC"); - }); - - it("should handle lowercase hex", () => { - expect(processEscapes("\\x61\\x62\\x63")).toBe("abc"); - }); - - it("should handle mixed case hex", () => { - expect(processEscapes("\\xAa")).toBe("\xaa"); - }); - }); - - describe("unicode escapes (\\u)", () => { - it("should handle \\uHHHH 4-digit unicode", () => { - expect(processEscapes("\\u2764")).toBe("❤"); - }); - - it("should handle \\u with fewer digits", () => { - expect(processEscapes("\\u41")).toBe("A"); - }); - - it("should handle checkmark unicode", () => { - expect(processEscapes("\\u2714")).toBe("✔"); - }); - - it("should handle \\u without valid hex as literal", () => { - expect(processEscapes("\\uXYZ")).toBe("\\uXYZ"); - }); - }); - - describe("unicode escapes (\\U)", () => { - it("should handle \\UHHHHHHHH 8-digit unicode for emoji", () => { - expect(processEscapes("\\U0001F600")).toBe("😀"); - }); - - it("should handle \\U with fewer digits", () => { - expect(processEscapes("\\U1F4C4")).toBe("📄"); - }); - - it("should handle rocket emoji", () => { - expect(processEscapes("\\U1F680")).toBe("🚀"); - }); - - it("should handle \\U without valid hex as literal", () => { - expect(processEscapes("\\UXYZ")).toBe("\\UXYZ"); - }); - }); - - describe("combined escapes", () => { - it("should handle multiple escape types together", () => { - expect(processEscapes("\\e[31m\\u2764\\e[0m\\n")).toBe( - "\x1b[31m❤\x1b[0m\n", - ); - }); - - it("should handle complex ANSI with unicode", () => { - expect(processEscapes("\\U1F4C1 folder\\t\\U1F4C4 file")).toBe( - "📁 folder\t📄 file", - ); - }); - }); -}); - -describe("applyWidth", () => { - it("should right-justify with positive width", () => { - expect(applyWidth("hi", 10, -1)).toBe(" hi"); - }); - - it("should left-justify with negative width", () => { - expect(applyWidth("hi", -10, -1)).toBe("hi "); - }); - - it("should truncate with precision", () => { - expect(applyWidth("hello", 0, 3)).toBe("hel"); - }); - - it("should combine width and precision", () => { - expect(applyWidth("hello", -10, 3)).toBe("hel "); - }); - - it("should not pad if value is longer than width", () => { - expect(applyWidth("hello", 3, -1)).toBe("hello"); - }); -}); - -describe("parseWidthPrecision", () => { - it("should parse simple width", () => { - const [width, precision, consumed] = parseWidthPrecision("10f", 0); - expect(width).toBe(10); - expect(precision).toBe(-1); - expect(consumed).toBe(2); - }); - - it("should parse negative width (left-justify)", () => { - const [width, precision, consumed] = parseWidthPrecision("-20s", 0); - expect(width).toBe(-20); - expect(precision).toBe(-1); - expect(consumed).toBe(3); - }); - - it("should parse precision only", () => { - const [width, precision, consumed] = parseWidthPrecision(".5f", 0); - expect(width).toBe(0); - expect(precision).toBe(5); - expect(consumed).toBe(2); - }); - - it("should parse width and precision", () => { - const [width, precision, consumed] = parseWidthPrecision("-10.5s", 0); - expect(width).toBe(-10); - expect(precision).toBe(5); - expect(consumed).toBe(5); - }); - - it("should handle no width/precision", () => { - const [width, precision, consumed] = parseWidthPrecision("f", 0); - expect(width).toBe(0); - expect(precision).toBe(-1); - expect(consumed).toBe(0); - }); -}); diff --git a/src/commands/printf/escapes.ts b/src/commands/printf/escapes.ts deleted file mode 100644 index e4f1348a..00000000 --- a/src/commands/printf/escapes.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Shared escape sequence and formatting utilities - * Used by printf command and find -printf - */ - -/** - * Apply width and alignment to a string value - * Supports: width (right-justify), -width (left-justify), .precision (truncate) - * @param value - The string value to format - * @param width - The field width (negative for left-justify) - * @param precision - Maximum length (-1 for no limit) - */ -export function applyWidth( - value: string, - width: number, - precision: number, -): string { - let result = value; - - // Apply precision (truncate) - if (precision >= 0 && result.length > precision) { - result = result.slice(0, precision); - } - - // Apply width - const absWidth = Math.abs(width); - if (absWidth > result.length) { - if (width < 0) { - // Left-justify - result = result.padEnd(absWidth, " "); - } else { - // Right-justify - result = result.padStart(absWidth, " "); - } - } - - return result; -} - -/** - * Parse a width/precision spec from a format directive - * Returns: [width, precision, charsConsumed] - * width: positive for right-justify, negative for left-justify - * precision: -1 if not specified - */ -export function parseWidthPrecision( - format: string, - startIndex: number, -): [number, number, number] { - let i = startIndex; - let width = 0; - let precision = -1; - let leftJustify = false; - - // Check for - flag (left-justify) - if (i < format.length && format[i] === "-") { - leftJustify = true; - i++; - } - - // Parse width - while (i < format.length && /\d/.test(format[i])) { - width = width * 10 + parseInt(format[i], 10); - i++; - } - - // Parse precision - if (i < format.length && format[i] === ".") { - i++; - precision = 0; - while (i < format.length && /\d/.test(format[i])) { - precision = precision * 10 + parseInt(format[i], 10); - i++; - } - } - - // Apply left-justify as negative width - if (leftJustify && width > 0) { - width = -width; - } - - return [width, precision, i - startIndex]; -} - -/** - * Process escape sequences in a string - * Handles: \n, \t, \r, \\, \a, \b, \f, \v, \e, \0NNN (octal), \xHH (hex), - * \uHHHH (unicode), \UHHHHHHHH (unicode) - */ -export function processEscapes(str: string): string { - let result = ""; - let i = 0; - - while (i < str.length) { - if (str[i] === "\\" && i + 1 < str.length) { - const next = str[i + 1]; - switch (next) { - case "n": - result += "\n"; - i += 2; - break; - case "t": - result += "\t"; - i += 2; - break; - case "r": - result += "\r"; - i += 2; - break; - case "\\": - result += "\\"; - i += 2; - break; - case "a": - result += "\x07"; - i += 2; - break; - case "b": - result += "\b"; - i += 2; - break; - case "f": - result += "\f"; - i += 2; - break; - case "v": - result += "\v"; - i += 2; - break; - case "e": - case "E": - // Escape character (0x1B) - used for ANSI color codes - result += "\x1b"; - i += 2; - break; - case "0": - case "1": - case "2": - case "3": - case "4": - case "5": - case "6": - case "7": { - // Octal escape sequence - let octal = ""; - let j = i + 1; - while (j < str.length && j < i + 4 && /[0-7]/.test(str[j])) { - octal += str[j]; - j++; - } - result += String.fromCharCode(parseInt(octal, 8)); - i = j; - break; - } - case "x": { - // Hex escape sequence \xHH - // Collect consecutive \xHH escapes and try to decode as UTF-8 - const bytes: number[] = []; - let j = i; - while ( - j + 3 < str.length && - str[j] === "\\" && - str[j + 1] === "x" && - /[0-9a-fA-F]{2}/.test(str.slice(j + 2, j + 4)) - ) { - bytes.push(parseInt(str.slice(j + 2, j + 4), 16)); - j += 4; - } - - if (bytes.length > 0) { - // Try to decode the bytes as UTF-8 - try { - const decoder = new TextDecoder("utf-8", { fatal: true }); - result += decoder.decode(new Uint8Array(bytes)); - } catch { - // If not valid UTF-8, fall back to Latin-1 (1:1 byte to codepoint) - for (const byte of bytes) { - result += String.fromCharCode(byte); - } - } - i = j; - } else { - // No valid hex escape, keep the backslash - result += str[i]; - i++; - } - break; - } - case "u": { - // Unicode escape \uHHHH (1-4 hex digits) - let hex = ""; - let j = i + 2; - while (j < str.length && j < i + 6 && /[0-9a-fA-F]/.test(str[j])) { - hex += str[j]; - j++; - } - if (hex) { - result += String.fromCodePoint(parseInt(hex, 16)); - i = j; - } else { - result += "\\u"; - i += 2; - } - break; - } - case "U": { - // Unicode escape \UHHHHHHHH (1-8 hex digits) - let hex = ""; - let j = i + 2; - while (j < str.length && j < i + 10 && /[0-9a-fA-F]/.test(str[j])) { - hex += str[j]; - j++; - } - if (hex) { - result += String.fromCodePoint(parseInt(hex, 16)); - i = j; - } else { - result += "\\U"; - i += 2; - } - break; - } - default: - result += str[i]; - i++; - } - } else { - result += str[i]; - i++; - } - } - - return result; -} diff --git a/src/commands/printf/printf.binary.test.ts b/src/commands/printf/printf.binary.test.ts deleted file mode 100644 index a17305f8..00000000 --- a/src/commands/printf/printf.binary.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("printf with binary data", () => { - describe("hex escape sequences", () => { - it("should output binary bytes via hex escapes", async () => { - const env = new Bash(); - - const result = await env.exec("printf '\\x80\\x90\\xa0\\xb0\\xff'"); - - expect(result.exitCode).toBe(0); - expect(result.stdout.length).toBe(5); - expect(result.stdout.charCodeAt(0)).toBe(0x80); - expect(result.stdout.charCodeAt(1)).toBe(0x90); - expect(result.stdout.charCodeAt(2)).toBe(0xa0); - expect(result.stdout.charCodeAt(3)).toBe(0xb0); - expect(result.stdout.charCodeAt(4)).toBe(0xff); - }); - - it("should output null bytes via hex escapes", async () => { - const env = new Bash(); - - const result = await env.exec("printf 'A\\x00B\\x00C'"); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("A\0B\0C"); - }); - - it("should redirect binary hex output to file", async () => { - const env = new Bash(); - - await env.exec("printf '\\x80\\xff\\x90' > /binary.bin"); - const result = await env.exec("cat /binary.bin"); - - expect(result.stdout.charCodeAt(0)).toBe(0x80); - expect(result.stdout.charCodeAt(1)).toBe(0xff); - expect(result.stdout.charCodeAt(2)).toBe(0x90); - }); - }); - - describe("octal escape sequences", () => { - it("should output binary bytes via octal escapes", async () => { - const env = new Bash(); - - const result = await env.exec("printf '\\200\\220\\240'"); - - expect(result.exitCode).toBe(0); - expect(result.stdout.length).toBe(3); - expect(result.stdout.charCodeAt(0)).toBe(0o200); // 128 - expect(result.stdout.charCodeAt(1)).toBe(0o220); // 144 - expect(result.stdout.charCodeAt(2)).toBe(0o240); // 160 - }); - - it("should redirect binary octal output to file", async () => { - const env = new Bash(); - - await env.exec("printf '\\200\\377' > /binary.bin"); - const result = await env.exec("cat /binary.bin"); - - expect(result.stdout.charCodeAt(0)).toBe(0o200); // 128 - expect(result.stdout.charCodeAt(1)).toBe(0o377); // 255 - }); - }); - - describe("round-trip through pipe", () => { - it("should preserve binary data through cat pipe", async () => { - const env = new Bash(); - - await env.exec("printf '\\x80\\xff\\x00\\x90' > /input.bin"); - await env.exec("cat /input.bin > /output.bin"); - const result = await env.exec("cat /output.bin"); - - expect(result.stdout.length).toBe(4); - expect(result.stdout.charCodeAt(0)).toBe(0x80); - expect(result.stdout.charCodeAt(1)).toBe(0xff); - expect(result.stdout.charCodeAt(2)).toBe(0x00); - expect(result.stdout.charCodeAt(3)).toBe(0x90); - }); - - it("should preserve binary data through pipe to base64 and back", async () => { - const env = new Bash(); - - await env.exec("printf '\\x80\\xff\\x90\\xab' > /binary.bin"); - await env.exec("base64 /binary.bin > /encoded.txt"); - const decodeResult = await env.exec("base64 -d /encoded.txt"); - - expect(decodeResult.stdout.length).toBe(4); - expect(decodeResult.stdout.charCodeAt(0)).toBe(0x80); - expect(decodeResult.stdout.charCodeAt(1)).toBe(0xff); - expect(decodeResult.stdout.charCodeAt(2)).toBe(0x90); - expect(decodeResult.stdout.charCodeAt(3)).toBe(0xab); - }); - }); -}); diff --git a/src/commands/printf/printf.test.ts b/src/commands/printf/printf.test.ts deleted file mode 100644 index dae3a42b..00000000 --- a/src/commands/printf/printf.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("printf", () => { - describe("basic format specifiers", () => { - it("should format string with %s", async () => { - const env = new Bash(); - const result = await env.exec('printf "Hello %s" world'); - expect(result.stdout).toBe("Hello world"); - expect(result.exitCode).toBe(0); - }); - - it("should format integer with %d", async () => { - const env = new Bash(); - const result = await env.exec('printf "Number: %d" 42'); - expect(result.stdout).toBe("Number: 42"); - expect(result.exitCode).toBe(0); - }); - - it("should format float with %f", async () => { - const env = new Bash(); - const result = await env.exec('printf "Value: %f" 3.14'); - // %f in bash uses 6 decimal places by default - expect(result.stdout).toBe("Value: 3.140000"); - expect(result.exitCode).toBe(0); - }); - - it("should format hex with %x", async () => { - const env = new Bash(); - const result = await env.exec('printf "Hex: %x" 255'); - expect(result.stdout).toBe("Hex: ff"); - expect(result.exitCode).toBe(0); - }); - - it("should format octal with %o", async () => { - const env = new Bash(); - const result = await env.exec('printf "Octal: %o" 8'); - expect(result.stdout).toBe("Octal: 10"); - expect(result.exitCode).toBe(0); - }); - - it("should handle literal %% ", async () => { - const env = new Bash(); - const result = await env.exec('printf "100%%"'); - expect(result.stdout).toBe("100%"); - expect(result.exitCode).toBe(0); - }); - - it("should handle multiple arguments", async () => { - const env = new Bash(); - const result = await env.exec('printf "%s is %d years old" Alice 30'); - expect(result.stdout).toBe("Alice is 30 years old"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("escape sequences", () => { - it("should handle newline \\n", async () => { - const env = new Bash(); - const result = await env.exec('printf "line1\\nline2"'); - expect(result.stdout).toBe("line1\nline2"); - }); - - it("should handle tab \\t", async () => { - const env = new Bash(); - const result = await env.exec('printf "col1\\tcol2"'); - expect(result.stdout).toBe("col1\tcol2"); - }); - - it("should handle backslash \\\\", async () => { - const env = new Bash(); - // 8 backslashes in source -> 4 in bash string -> 2 for printf -> 1 literal - const result = await env.exec('printf "x\\\\\\\\y"'); - expect(result.stdout).toBe("x\\y"); - }); - - it("should handle carriage return \\r", async () => { - const env = new Bash(); - const result = await env.exec('printf "hello\\rworld"'); - expect(result.stdout).toBe("hello\rworld"); - }); - - it("should handle octal escape sequences", async () => { - const env = new Bash(); - const result = await env.exec('printf "\\101\\102\\103"'); - expect(result.stdout).toBe("ABC"); - }); - - it("should handle escape character \\e for ANSI codes", async () => { - const env = new Bash(); - const result = await env.exec('printf "\\e[31mred\\e[0m"'); - expect(result.stdout).toBe("\x1b[31mred\x1b[0m"); - }); - - it("should handle \\E as alias for \\e", async () => { - const env = new Bash(); - const result = await env.exec('printf "\\E[1mbold\\E[0m"'); - expect(result.stdout).toBe("\x1b[1mbold\x1b[0m"); - }); - - it("should handle unicode \\u escape", async () => { - const env = new Bash(); - const result = await env.exec('printf "\\u2764"'); - expect(result.stdout).toBe("❤"); - }); - - it("should handle unicode \\U escape for emoji", async () => { - const env = new Bash(); - const result = await env.exec('printf "\\U1F600"'); - expect(result.stdout).toBe("😀"); - }); - - it("should handle hex escape \\x", async () => { - const env = new Bash(); - const result = await env.exec('printf "\\x41\\x42\\x43"'); - expect(result.stdout).toBe("ABC"); - }); - }); - - describe("width and precision", () => { - it("should handle width specifier", async () => { - const env = new Bash(); - const result = await env.exec('printf "%10s" "hi"'); - expect(result.stdout).toBe(" hi"); - }); - - it("should handle precision for floats", async () => { - const env = new Bash(); - const result = await env.exec('printf "%.2f" 3.14159'); - expect(result.stdout).toBe("3.14"); - }); - - it("should handle zero-padding", async () => { - const env = new Bash(); - const result = await env.exec('printf "%05d" 42'); - expect(result.stdout).toBe("00042"); - }); - - it("should handle left-justify with -", async () => { - const env = new Bash(); - const result = await env.exec('printf "%-10s|" "hi"'); - expect(result.stdout).toBe("hi |"); - }); - }); - - describe("error handling", () => { - it("should error with no arguments", async () => { - const env = new Bash(); - const result = await env.exec("printf"); - expect(result.stderr).toContain("usage"); - // Bash returns exit code 2 for usage errors - expect(result.exitCode).toBe(2); - }); - - it("should handle missing arguments gracefully", async () => { - const env = new Bash(); - const result = await env.exec('printf "%s %s" only'); - expect(result.stdout).toBe("only "); - expect(result.exitCode).toBe(0); - }); - - it("should handle non-numeric for %d", async () => { - // Bash returns exit 1 with warning and outputs 0 - const env = new Bash(); - const result = await env.exec('printf "%d" notanumber'); - expect(result.stdout).toBe("0"); - expect(result.stderr).toContain("invalid number"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("--help", () => { - it("should display help", async () => { - const env = new Bash(); - const result = await env.exec("printf --help"); - expect(result.stdout).toContain("printf"); - expect(result.stdout).toContain("FORMAT"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/commands/printf/printf.ts b/src/commands/printf/printf.ts deleted file mode 100644 index 01dc697b..00000000 --- a/src/commands/printf/printf.ts +++ /dev/null @@ -1,1138 +0,0 @@ -import { sprintf } from "sprintf-js"; -import { ExecutionLimitError } from "../../interpreter/errors.js"; -import { getErrorMessage } from "../../interpreter/helpers/errors.js"; -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { hasHelpFlag, showHelp } from "../help.js"; -import { applyWidth, processEscapes } from "./escapes.js"; -import { formatStrftime } from "./strftime.js"; - -/** - * Decode a byte array as UTF-8 with error recovery. - * Valid UTF-8 sequences are decoded to their Unicode characters. - * Invalid bytes are preserved as Latin-1 characters (byte value = char code). - */ -function decodeUtf8WithRecovery(bytes: number[]): string { - let result = ""; - let i = 0; - - while (i < bytes.length) { - const b0 = bytes[i]; - - // ASCII (0xxxxxxx) - if (b0 < 0x80) { - result += String.fromCharCode(b0); - i++; - continue; - } - - // 2-byte sequence (110xxxxx 10xxxxxx) - if ((b0 & 0xe0) === 0xc0) { - if ( - i + 1 < bytes.length && - (bytes[i + 1] & 0xc0) === 0x80 && - b0 >= 0xc2 // Reject overlong sequences - ) { - const codePoint = ((b0 & 0x1f) << 6) | (bytes[i + 1] & 0x3f); - result += String.fromCharCode(codePoint); - i += 2; - continue; - } - // Invalid or incomplete - output as Latin-1 - result += String.fromCharCode(b0); - i++; - continue; - } - - // 3-byte sequence (1110xxxx 10xxxxxx 10xxxxxx) - if ((b0 & 0xf0) === 0xe0) { - if ( - i + 2 < bytes.length && - (bytes[i + 1] & 0xc0) === 0x80 && - (bytes[i + 2] & 0xc0) === 0x80 - ) { - // Check for overlong encoding - if (b0 === 0xe0 && bytes[i + 1] < 0xa0) { - // Overlong - output first byte as Latin-1 - result += String.fromCharCode(b0); - i++; - continue; - } - // Check for surrogate range (U+D800-U+DFFF) - const codePoint = - ((b0 & 0x0f) << 12) | - ((bytes[i + 1] & 0x3f) << 6) | - (bytes[i + 2] & 0x3f); - if (codePoint >= 0xd800 && codePoint <= 0xdfff) { - // Invalid surrogate - output first byte as Latin-1 - result += String.fromCharCode(b0); - i++; - continue; - } - result += String.fromCharCode(codePoint); - i += 3; - continue; - } - // Invalid or incomplete - output as Latin-1 - result += String.fromCharCode(b0); - i++; - continue; - } - - // 4-byte sequence (11110xxx 10xxxxxx 10xxxxxx 10xxxxxx) - if ((b0 & 0xf8) === 0xf0 && b0 <= 0xf4) { - if ( - i + 3 < bytes.length && - (bytes[i + 1] & 0xc0) === 0x80 && - (bytes[i + 2] & 0xc0) === 0x80 && - (bytes[i + 3] & 0xc0) === 0x80 - ) { - // Check for overlong encoding - if (b0 === 0xf0 && bytes[i + 1] < 0x90) { - // Overlong - output first byte as Latin-1 - result += String.fromCharCode(b0); - i++; - continue; - } - const codePoint = - ((b0 & 0x07) << 18) | - ((bytes[i + 1] & 0x3f) << 12) | - ((bytes[i + 2] & 0x3f) << 6) | - (bytes[i + 3] & 0x3f); - // Check for valid range (U+10000 to U+10FFFF) - if (codePoint > 0x10ffff) { - // Invalid - output first byte as Latin-1 - result += String.fromCharCode(b0); - i++; - continue; - } - result += String.fromCodePoint(codePoint); - i += 4; - continue; - } - // Invalid or incomplete - output as Latin-1 - result += String.fromCharCode(b0); - i++; - continue; - } - - // Invalid lead byte (10xxxxxx or 11111xxx) - output as Latin-1 - result += String.fromCharCode(b0); - i++; - } - - return result; -} - -const printfHelp = { - name: "printf", - summary: "format and print data", - usage: "printf [-v var] FORMAT [ARGUMENT...]", - options: [ - " -v var assign the output to shell variable VAR rather than display it", - " --help display this help and exit", - ], - notes: [ - "FORMAT controls the output like in C printf.", - "Escape sequences: \\n (newline), \\t (tab), \\\\ (backslash)", - "Format specifiers: %s (string), %d (integer), %f (float), %x (hex), %o (octal), %% (literal %)", - "Width and precision: %10s (width 10), %.2f (2 decimal places), %010d (zero-padded)", - "Flags: %- (left-justify), %+ (show sign), %0 (zero-pad)", - ], -}; - -export const printfCommand: Command = { - name: "printf", - - async execute(args: string[], ctx: CommandContext): Promise { - if (hasHelpFlag(args)) { - return showHelp(printfHelp); - } - - if (args.length === 0) { - return { - stdout: "", - stderr: "printf: usage: printf format [arguments]\n", - exitCode: 2, - }; - } - - // Parse options - let targetVar: string | null = null; - let argIndex = 0; - - while (argIndex < args.length) { - const arg = args[argIndex]; - if (arg === "--") { - // End of options - argIndex++; - break; - } - if (arg === "-v") { - // Store result in variable - if (argIndex + 1 >= args.length) { - return { - stdout: "", - stderr: "printf: -v: option requires an argument\n", - exitCode: 1, - }; - } - targetVar = args[argIndex + 1]; - // Validate variable name - if (!/^[a-zA-Z_][a-zA-Z0-9_]*(\[[^\]]+\])?$/.test(targetVar)) { - return { - stdout: "", - stderr: `printf: \`${targetVar}': not a valid identifier\n`, - exitCode: 2, - }; - } - argIndex += 2; - } else if (arg.startsWith("-") && arg !== "-") { - // Unknown option - treat as format string (bash behavior) - break; - } else { - break; - } - } - - if (argIndex >= args.length) { - return { - stdout: "", - stderr: "printf: usage: printf format [arguments]\n", - exitCode: 1, - }; - } - - const format = args[argIndex]; - const formatArgs = args.slice(argIndex + 1); - - try { - // First, process escape sequences in the format string - const processedFormat = processEscapes(format); - - // Format and handle argument reuse (bash loops through format until all args consumed) - let output = ""; - let argPos = 0; - let hadError = false; - let errorMessage = ""; - - // Get TZ from shell environment for strftime formatting - const tz = ctx.env.get("TZ"); - - const maxStringLength = ctx.limits?.maxStringLength; - - do { - const { result, argsConsumed, error, errMsg, stopped } = formatOnce( - processedFormat, - formatArgs, - argPos, - tz, - ); - output += result; - // Check output size against limit - if ( - maxStringLength !== undefined && - maxStringLength > 0 && - output.length > maxStringLength - ) { - throw new ExecutionLimitError( - `printf: output size limit exceeded (${maxStringLength} bytes)`, - "string_length", - ); - } - argPos += argsConsumed; - if (error) { - hadError = true; - if (errMsg) errorMessage = errMsg; - } - // If %b with \c was encountered, stop all output immediately - if (stopped) { - break; - } - } while (argPos < formatArgs.length && argPos > 0); - - // If no args were consumed but format had no specifiers, just output format - if (argPos === 0 && formatArgs.length > 0) { - // Format had no specifiers - output once - } - - // If -v was specified, store in variable instead of printing - if (targetVar) { - // Check for array subscript syntax: name[key] or name["key"] or name['key'] - const arrayMatch = targetVar.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[(['"]?)(.+?)\2\]$/, - ); - if (arrayMatch) { - const arrayName = arrayMatch[1]; - let key = arrayMatch[3]; - // Expand variables in the subscript (e.g., $key -> value) - key = key.replace(/\$([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, varName) => { - return ctx.env.get(varName) ?? ""; - }); - ctx.env.set(`${arrayName}_${key}`, output); - } else { - ctx.env.set(targetVar, output); - } - return { stdout: "", stderr: errorMessage, exitCode: hadError ? 1 : 0 }; - } - - return { - stdout: output, - stderr: errorMessage, - exitCode: hadError ? 1 : 0, - }; - } catch (error) { - if (error instanceof ExecutionLimitError) { - throw error; - } - return { - stdout: "", - stderr: `printf: ${getErrorMessage(error)}\n`, - exitCode: 1, - }; - } - }, -}; - -/** - * Format the string once, consuming args starting at argPos. - * Returns the formatted result and number of args consumed. - */ -function formatOnce( - format: string, - args: string[], - argPos: number, - tz?: string, -): { - result: string; - argsConsumed: number; - error: boolean; - errMsg: string; - stopped: boolean; -} { - let result = ""; - let i = 0; - let argsConsumed = 0; - let error = false; - let errMsg = ""; - - while (i < format.length) { - if (format[i] === "%" && i + 1 < format.length) { - // Parse the format specifier - const specStart = i; - i++; // skip % - - // Check for %% - if (format[i] === "%") { - result += "%"; - i++; - continue; - } - - // Check for %(strftime)T format - // Format: %[flags][width][.precision](strftime-format)T - const strftimeMatch = format - .slice(specStart) - .match(/^%(-?\d*)(?:\.(\d+))?\(([^)]*)\)T/); - if (strftimeMatch) { - const width = strftimeMatch[1] ? parseInt(strftimeMatch[1], 10) : 0; - const precision = strftimeMatch[2] - ? parseInt(strftimeMatch[2], 10) - : -1; - const strftimeFmt = strftimeMatch[3]; - const fullMatch = strftimeMatch[0]; - - // Get the timestamp argument - const arg = args[argPos + argsConsumed] || ""; - argsConsumed++; - - // Parse timestamp - empty or -1 means current time, -2 means shell start time - let timestamp: number; - if (arg === "" || arg === "-1") { - timestamp = Math.floor(Date.now() / 1000); - } else if (arg === "-2") { - // Shell start time - use current time as approximation - timestamp = Math.floor(Date.now() / 1000); - } else { - timestamp = parseInt(arg, 10) || 0; - } - - // Format using strftime - let formatted = formatStrftime(strftimeFmt, timestamp, tz); - - // Apply precision (truncate) - if (precision >= 0 && formatted.length > precision) { - formatted = formatted.slice(0, precision); - } - - // Apply width - if (width !== 0) { - const absWidth = Math.abs(width); - if (formatted.length < absWidth) { - if (width < 0) { - // Left-justify - formatted = formatted.padEnd(absWidth, " "); - } else { - // Right-justify - formatted = formatted.padStart(absWidth, " "); - } - } - } - - result += formatted; - i = specStart + fullMatch.length; - continue; - } - - // Parse flags - while (i < format.length && "+-0 #'".includes(format[i])) { - i++; - } - - // Parse width (can be * to read from args) - let widthFromArg = false; - if (format[i] === "*") { - widthFromArg = true; - i++; - } else { - while (i < format.length && /\d/.test(format[i])) { - i++; - } - } - - // Parse precision - let precisionFromArg = false; - if (format[i] === ".") { - i++; - if (format[i] === "*") { - precisionFromArg = true; - i++; - } else { - while (i < format.length && /\d/.test(format[i])) { - i++; - } - } - } - - // Parse length modifier - if (i < format.length && "hlL".includes(format[i])) { - i++; - } - - // Get specifier - const specifier = format[i] || ""; - i++; - - const fullSpec = format.slice(specStart, i); - - // Handle width/precision from args - let adjustedSpec = fullSpec; - if (widthFromArg) { - const w = parseInt(args[argPos + argsConsumed] || "0", 10); - argsConsumed++; - adjustedSpec = adjustedSpec.replace("*", String(w)); - } - if (precisionFromArg) { - const p = parseInt(args[argPos + argsConsumed] || "0", 10); - argsConsumed++; - adjustedSpec = adjustedSpec.replace(".*", `.${p}`); - } - - // Get the argument - const arg = args[argPos + argsConsumed] || ""; - argsConsumed++; - - // Format based on specifier - const { value, parseError, parseErrMsg, stopped } = formatValue( - adjustedSpec, - specifier, - arg, - ); - result += value; - if (parseError) { - error = true; - if (parseErrMsg) errMsg = parseErrMsg; - } - // If %b with \c was encountered, stop all output immediately - if (stopped) { - return { result, argsConsumed, error, errMsg, stopped: true }; - } - } else { - result += format[i]; - i++; - } - } - - return { result, argsConsumed, error, errMsg, stopped: false }; -} - -/** - * Format a single value with the given specifier - */ -function formatValue( - spec: string, - specifier: string, - arg: string, -): { - value: string; - parseError: boolean; - parseErrMsg: string; - stopped?: boolean; -} { - let parseError = false; - let parseErrMsg = ""; - - switch (specifier) { - case "d": - case "i": { - const num = parseIntArg(arg); - parseError = lastParseError; - if (parseError) parseErrMsg = `printf: ${arg}: invalid number\n`; - return { value: formatInteger(spec, num), parseError, parseErrMsg }; - } - case "o": { - const num = parseIntArg(arg); - parseError = lastParseError; - if (parseError) parseErrMsg = `printf: ${arg}: invalid number\n`; - return { value: formatOctal(spec, num), parseError, parseErrMsg }; - } - case "u": { - const num = parseIntArg(arg); - parseError = lastParseError; - if (parseError) parseErrMsg = `printf: ${arg}: invalid number\n`; - // For unsigned with negative, convert to unsigned representation - const unsignedNum = num < 0 ? num >>> 0 : num; - return { - value: formatInteger(spec.replace("u", "d"), unsignedNum), - parseError, - parseErrMsg, - }; - } - case "x": - case "X": { - const num = parseIntArg(arg); - parseError = lastParseError; - if (parseError) parseErrMsg = `printf: ${arg}: invalid number\n`; - return { value: formatHex(spec, num), parseError, parseErrMsg }; - } - case "e": - case "E": - case "f": - case "F": - case "g": - case "G": { - const num = parseFloat(arg) || 0; - return { - value: formatFloat(spec, specifier, num), - parseError: false, - parseErrMsg: "", - }; - } - case "c": { - // Character - take first BYTE of UTF-8 encoding (not first Unicode character) - // This matches bash behavior where %c outputs a single byte, not a full character - if (arg === "") { - return { value: "", parseError: false, parseErrMsg: "" }; - } - // Encode the string to UTF-8 and take just the first byte - const encoder = new TextEncoder(); - const bytes = encoder.encode(arg); - const firstByte = bytes[0]; - // Convert byte back to a character (as Latin-1 / ISO-8859-1) - return { - value: String.fromCharCode(firstByte), - parseError: false, - parseErrMsg: "", - }; - } - case "s": - return { - value: formatString(spec, arg), - parseError: false, - parseErrMsg: "", - }; - case "q": - // Shell quoting with width support - return { - value: formatQuoted(spec, arg), - parseError: false, - parseErrMsg: "", - }; - case "b": { - // Interpret escape sequences in arg - // Returns {value, stopped} - if stopped is true, \c was encountered - const bResult = processBEscapes(arg); - return { - value: bResult.value, - parseError: false, - parseErrMsg: "", - stopped: bResult.stopped, - }; - } - default: - try { - return { - value: sprintf(spec, arg), - parseError: false, - parseErrMsg: "", - }; - } catch { - return { - value: "", - parseError: true, - parseErrMsg: `printf: [sprintf] unexpected placeholder\n`, - }; - } - } -} - -/** - * Error flag for invalid integer parsing - set by parseIntArg - */ -let lastParseError = false; - -/** - * Parse an integer argument, handling bash-style character notation ('a' = 97) - */ -function parseIntArg(arg: string): number { - lastParseError = false; - - // Only trim leading whitespace - trailing whitespace triggers error but we still parse - const trimmed = arg.trimStart(); - const hasTrailingWhitespace = trimmed !== trimmed.trimEnd(); - - // Continue parsing with trimmed value - but set error flag later if there's trailing whitespace - arg = trimmed.trimEnd(); - - // Handle character notation: 'x' or "x" gives ASCII value - // Also handle \'x and \"x (escaped quotes, which shell may pass through) - if (arg.startsWith("'") && arg.length >= 2) { - return arg.charCodeAt(1); - } - if (arg.startsWith('"') && arg.length >= 2) { - return arg.charCodeAt(1); - } - if (arg.startsWith("\\'") && arg.length >= 3) { - return arg.charCodeAt(2); - } - if (arg.startsWith('\\"') && arg.length >= 3) { - return arg.charCodeAt(2); - } - - // Handle + prefix (e.g., +42) - if (arg.startsWith("+")) { - arg = arg.slice(1); - } - - // Handle hex - if (arg.startsWith("0x") || arg.startsWith("0X")) { - const num = parseInt(arg, 16); - if (Number.isNaN(num)) { - lastParseError = true; - return 0; - } - if (hasTrailingWhitespace) lastParseError = true; - return num; - } - - // Handle octal - if (arg.startsWith("0") && arg.length > 1 && /^-?0[0-7]+$/.test(arg)) { - if (hasTrailingWhitespace) lastParseError = true; - return parseInt(arg, 8) || 0; - } - - // Reject arbitrary base notation like 64#a (valid in arithmetic but not printf) - // Bash parses the number before # and returns that with error status - if (/^\d+#/.test(arg)) { - lastParseError = true; - const match = arg.match(/^(\d+)#/); - return match ? parseInt(match[1], 10) : 0; - } - - // Check for invalid characters - if (arg !== "" && !/^-?\d+$/.test(arg)) { - lastParseError = true; - // Try to parse what we can (bash behavior: 3abc -> 3, but sets error) - const num = parseInt(arg, 10); - return Number.isNaN(num) ? 0 : num; - } - - // Set error flag if there was trailing whitespace - if (hasTrailingWhitespace) lastParseError = true; - - return parseInt(arg, 10) || 0; -} - -/** - * Format an integer with precision support (bash-style: precision means min digits) - */ -function formatInteger(spec: string, num: number): string { - // Parse the spec: %[flags][width][.precision]d - // Note: %6.d means precision 0 (dot with no digits) - const match = spec.match(/^%([- +#0']*)(\d*)(\.(\d*))?[diu]$/); - if (!match) { - return sprintf(spec.replace(/\.\d*/, ""), num); - } - - const flags = match[1] || ""; - const width = match[2] ? parseInt(match[2], 10) : 0; - // If there's a dot (match[3]), precision is match[4] or 0 if empty - const precision = - match[3] !== undefined ? (match[4] ? parseInt(match[4], 10) : 0) : -1; - - const negative = num < 0; - const absNum = Math.abs(num); - let numStr = String(absNum); - - // Apply precision (minimum digits with zero-padding) - if (precision >= 0) { - numStr = numStr.padStart(precision, "0"); - } - - // Add sign - let sign = ""; - if (negative) { - sign = "-"; - } else if (flags.includes("+")) { - sign = "+"; - } else if (flags.includes(" ")) { - sign = " "; - } - - let result = sign + numStr; - - // Apply width - if (width > result.length) { - if (flags.includes("-")) { - result = result.padEnd(width, " "); - } else if (flags.includes("0") && precision < 0) { - // Zero-pad only if no precision specified - result = sign + numStr.padStart(width - sign.length, "0"); - } else { - result = result.padStart(width, " "); - } - } - - return result; -} - -/** - * Format octal with precision support - */ -function formatOctal(spec: string, num: number): string { - const match = spec.match(/^%([- +#0']*)(\d*)(\.(\d*))?o$/); - if (!match) { - return sprintf(spec, num); - } - - const flags = match[1] || ""; - const width = match[2] ? parseInt(match[2], 10) : 0; - const precision = - match[3] !== undefined ? (match[4] ? parseInt(match[4], 10) : 0) : -1; - - let numStr = Math.abs(num).toString(8); - - if (precision >= 0) { - numStr = numStr.padStart(precision, "0"); - } - - if (flags.includes("#") && !numStr.startsWith("0")) { - numStr = `0${numStr}`; - } - - let result = numStr; - if (width > result.length) { - if (flags.includes("-")) { - result = result.padEnd(width, " "); - } else if (flags.includes("0") && precision < 0) { - result = result.padStart(width, "0"); - } else { - result = result.padStart(width, " "); - } - } - - return result; -} - -/** - * Format hex with precision support - */ -function formatHex(spec: string, num: number): string { - const isUpper = spec.includes("X"); - const match = spec.match(/^%([- +#0']*)(\d*)(\.(\d*))?[xX]$/); - if (!match) { - return sprintf(spec, num); - } - - const flags = match[1] || ""; - const width = match[2] ? parseInt(match[2], 10) : 0; - const precision = - match[3] !== undefined ? (match[4] ? parseInt(match[4], 10) : 0) : -1; - - let numStr = Math.abs(num).toString(16); - if (isUpper) numStr = numStr.toUpperCase(); - - if (precision >= 0) { - numStr = numStr.padStart(precision, "0"); - } - - let prefix = ""; - if (flags.includes("#") && num !== 0) { - prefix = isUpper ? "0X" : "0x"; - } - - let result = prefix + numStr; - if (width > result.length) { - if (flags.includes("-")) { - result = result.padEnd(width, " "); - } else if (flags.includes("0") && precision < 0) { - result = prefix + numStr.padStart(width - prefix.length, "0"); - } else { - result = result.padStart(width, " "); - } - } - - return result; -} - -/** - * Shell-quote a string (for %q) - * Bash uses backslash escaping for printable chars, $'...' only for control chars - */ -function shellQuote(str: string): string { - if (str === "") { - return "''"; - } - // If string contains only safe characters, return as-is - if (/^[a-zA-Z0-9_./-]+$/.test(str)) { - return str; - } - - // Check if we need $'...' syntax (for control chars, newlines, high bytes, etc.) - // High bytes (0x80-0xff) need escaping as they are not printable ASCII - const needsDollarQuote = /[\x00-\x1f\x7f-\xff]/.test(str); - - if (needsDollarQuote) { - // Use $'...' format with escape sequences for control characters - let result = "$'"; - for (const char of str) { - const code = char.charCodeAt(0); - if (char === "'") { - result += "\\'"; - } else if (char === "\\") { - result += "\\\\"; - } else if (char === "\n") { - result += "\\n"; - } else if (char === "\t") { - result += "\\t"; - } else if (char === "\r") { - result += "\\r"; - } else if (char === "\x07") { - result += "\\a"; - } else if (char === "\b") { - result += "\\b"; - } else if (char === "\f") { - result += "\\f"; - } else if (char === "\v") { - result += "\\v"; - } else if (char === "\x1b") { - result += "\\E"; - } else if (code < 32 || (code >= 127 && code <= 255)) { - // Use octal escapes like bash does for control chars and high bytes (0x80-0xFF) - // Valid Unicode chars (code > 255) are left unescaped - result += `\\${code.toString(8).padStart(3, "0")}`; - } else if (char === '"') { - result += '\\"'; - } else { - result += char; - } - } - result += "'"; - return result; - } - - // Use backslash escaping for printable special characters - let result = ""; - for (const char of str) { - // Characters that need backslash escaping - if (" \t|&;<>()$`\\\"'*?[#~=%!{}".includes(char)) { - result += `\\${char}`; - } else { - result += char; - } - } - return result; -} - -/** - * Format a string with %s, respecting width and precision - * Note: %06s should NOT zero-pad (0 flag is ignored for strings) - */ -function formatString(spec: string, str: string): string { - const match = spec.match(/^%(-?)(\d*)(\.(\d*))?s$/); - if (!match) { - return sprintf(spec.replace(/0+(?=\d)/, ""), str); - } - - const leftJustify = match[1] === "-"; - const widthVal = match[2] ? parseInt(match[2], 10) : 0; - // Precision for strings means max length (truncate) - // %.s or %0.s means precision 0 (empty string) - const precision = - match[3] !== undefined ? (match[4] ? parseInt(match[4], 10) : 0) : -1; - - // Use shared width/alignment utility - const width = leftJustify ? -widthVal : widthVal; - return applyWidth(str, width, precision); -} - -/** - * Format a quoted string with %q, respecting width - */ -function formatQuoted(spec: string, str: string): string { - const quoted = shellQuote(str); - - const match = spec.match(/^%(-?)(\d*)q$/); - if (!match) { - return quoted; - } - - const leftJustify = match[1] === "-"; - const width = match[2] ? parseInt(match[2], 10) : 0; - - let result = quoted; - if (width > result.length) { - if (leftJustify) { - result = result.padEnd(width, " "); - } else { - result = result.padStart(width, " "); - } - } - - return result; -} - -/** - * Format floating point with default precision and # flag support - */ -function formatFloat(spec: string, specifier: string, num: number): string { - // Parse spec to extract flags, width, precision - const match = spec.match(/^%([- +#0']*)(\d*)(\.(\d*))?[eEfFgG]$/); - if (!match) { - return sprintf(spec, num); - } - - const flags = match[1] || ""; - const width = match[2] ? parseInt(match[2], 10) : 0; - // Default precision is 6 for f/e, but %.f means precision 0 - const precision = - match[3] !== undefined ? (match[4] ? parseInt(match[4], 10) : 0) : 6; - - let result: string; - const lowerSpec = specifier.toLowerCase(); - - if (lowerSpec === "e") { - result = num.toExponential(precision); - // Ensure exponent has at least 2 digits (e+0 -> e+00) - result = result.replace(/e([+-])(\d)$/, "e$10$2"); - if (specifier === "E") result = result.toUpperCase(); - } else if (lowerSpec === "f") { - result = num.toFixed(precision); - // # flag for %f: always show decimal point even if precision is 0 - if (flags.includes("#") && precision === 0 && !result.includes(".")) { - result += "."; - } - } else if (lowerSpec === "g") { - // %g: use shortest representation between %e and %f - result = num.toPrecision(precision || 1); - // # flag: keep trailing zeros (do not omit zeros in fraction) - // Without #: remove trailing zeros and unnecessary decimal point - if (!flags.includes("#")) { - result = result.replace(/\.?0+$/, ""); - result = result.replace(/\.?0+e/, "e"); - } - // Ensure exponent has at least 2 digits if present - result = result.replace(/e([+-])(\d)$/, "e$10$2"); - if (specifier === "G") result = result.toUpperCase(); - } else { - result = num.toString(); - } - - // Handle sign - if (num >= 0) { - if (flags.includes("+")) { - result = `+${result}`; - } else if (flags.includes(" ")) { - result = ` ${result}`; - } - } - - // Handle width - if (width > result.length) { - if (flags.includes("-")) { - result = result.padEnd(width, " "); - } else if (flags.includes("0")) { - const signPrefix = result.match(/^[+ -]/)?.[0] || ""; - const numPart = signPrefix ? result.slice(1) : result; - result = signPrefix + numPart.padStart(width - signPrefix.length, "0"); - } else { - result = result.padStart(width, " "); - } - } - - return result; -} - -/** - * Process escape sequences in %b argument - * Similar to processEscapes but with additional features: - * - \c stops output (discards rest of string and rest of format) - * - \uHHHH unicode escapes - * - Octal can be \NNN or \0NNN - * Returns {value, stopped} - stopped is true if \c was encountered - */ -function processBEscapes(str: string): { value: string; stopped: boolean } { - let result = ""; - let i = 0; - - while (i < str.length) { - if (str[i] === "\\" && i + 1 < str.length) { - const next = str[i + 1]; - switch (next) { - case "n": - result += "\n"; - i += 2; - break; - case "t": - result += "\t"; - i += 2; - break; - case "r": - result += "\r"; - i += 2; - break; - case "\\": - result += "\\"; - i += 2; - break; - case "a": - result += "\x07"; - i += 2; - break; - case "b": - result += "\b"; - i += 2; - break; - case "f": - result += "\f"; - i += 2; - break; - case "v": - result += "\v"; - i += 2; - break; - case "c": - // \c stops all output - return immediately with stopped flag - return { value: result, stopped: true }; - case "x": { - // \xHH - hex escape (1-2 hex digits) - // Collect consecutive \xHH escapes and decode as UTF-8 with error recovery - const bytes: number[] = []; - let j = i; - while (j + 1 < str.length && str[j] === "\\" && str[j + 1] === "x") { - let hex = ""; - let k = j + 2; - while (k < str.length && k < j + 4 && /[0-9a-fA-F]/.test(str[k])) { - hex += str[k]; - k++; - } - if (hex) { - bytes.push(parseInt(hex, 16)); - j = k; - } else { - break; - } - } - - if (bytes.length > 0) { - // Decode bytes as UTF-8 with error recovery - result += decodeUtf8WithRecovery(bytes); - i = j; - } else { - result += "\\x"; - i += 2; - } - break; - } - case "u": { - // \uHHHH - unicode escape (1-4 hex digits) - let hex = ""; - let j = i + 2; - while (j < str.length && j < i + 6 && /[0-9a-fA-F]/.test(str[j])) { - hex += str[j]; - j++; - } - if (hex) { - result += String.fromCodePoint(parseInt(hex, 16)); - i = j; - } else { - result += "\\u"; - i += 2; - } - break; - } - case "0": { - // \0NNN - octal escape (0-3 digits after the 0) - let octal = ""; - let j = i + 2; - while (j < str.length && j < i + 5 && /[0-7]/.test(str[j])) { - octal += str[j]; - j++; - } - if (octal) { - result += String.fromCharCode(parseInt(octal, 8)); - } else { - result += "\0"; // Just \0 is NUL - } - i = j; - break; - } - case "1": - case "2": - case "3": - case "4": - case "5": - case "6": - case "7": { - // \NNN - octal escape (1-3 digits, no leading 0) - let octal = ""; - let j = i + 1; - while (j < str.length && j < i + 4 && /[0-7]/.test(str[j])) { - octal += str[j]; - j++; - } - result += String.fromCharCode(parseInt(octal, 8)); - i = j; - break; - } - default: - // Unknown escape, keep as-is - result += str[i]; - i++; - } - } else { - result += str[i]; - i++; - } - } - - return { value: result, stopped: false }; -} - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "printf", - flags: [{ flag: "-v", type: "value", valueHint: "string" }], - stdinType: "none", - needsArgs: true, -}; diff --git a/src/commands/printf/strftime.ts b/src/commands/printf/strftime.ts deleted file mode 100644 index 1d2f89b7..00000000 --- a/src/commands/printf/strftime.ts +++ /dev/null @@ -1,418 +0,0 @@ -/** - * Strftime Formatting Functions - * - * Handles date/time formatting for printf's %(...)T directive. - */ - -/** - * Format a timestamp using strftime-like format string. - */ -export function formatStrftime( - format: string, - timestamp: number, - tz?: string, -): string { - const date = new Date(timestamp * 1000); - - // Build result by replacing format directives - let result = ""; - let i = 0; - - while (i < format.length) { - if (format[i] === "%" && i + 1 < format.length) { - const directive = format[i + 1]; - const formatted = formatStrftimeDirective(date, directive, tz); - if (formatted !== null) { - result += formatted; - i += 2; - } else { - // Unknown directive, keep as-is - result += format[i]; - i++; - } - } else { - result += format[i]; - i++; - } - } - - return result; -} - -/** - * Get date/time parts in a specific timezone using Intl.DateTimeFormat. - * Returns an object with year, month, day, hour, minute, second, weekday. - */ -function getDatePartsInTimezone( - date: Date, - tz?: string, -): { - year: number; - month: number; - day: number; - hour: number; - minute: number; - second: number; - weekday: number; -} { - const options: Intl.DateTimeFormatOptions = { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - weekday: "short", - hour12: false, - timeZone: tz, - }; - - try { - const formatter = new Intl.DateTimeFormat("en-US", options); - const parts = formatter.formatToParts(date); - - const getValue = (type: string): string => - parts.find((p) => p.type === type)?.value ?? ""; - - // Convert weekday abbreviation to number (0=Sunday, 6=Saturday) - // Map prevents prototype pollution - const weekdayMap = new Map([ - ["Sun", 0], - ["Mon", 1], - ["Tue", 2], - ["Wed", 3], - ["Thu", 4], - ["Fri", 5], - ["Sat", 6], - ]); - const weekdayStr = getValue("weekday"); - - return { - year: Number.parseInt(getValue("year"), 10) || date.getFullYear(), - month: Number.parseInt(getValue("month"), 10) || date.getMonth() + 1, - day: Number.parseInt(getValue("day"), 10) || date.getDate(), - hour: Number.parseInt(getValue("hour"), 10) || date.getHours(), - minute: Number.parseInt(getValue("minute"), 10) || date.getMinutes(), - second: Number.parseInt(getValue("second"), 10) || date.getSeconds(), - weekday: weekdayMap.get(weekdayStr) ?? date.getDay(), - }; - } catch { - // Fall back to local time if timezone is invalid - return { - year: date.getFullYear(), - month: date.getMonth() + 1, - day: date.getDate(), - hour: date.getHours(), - minute: date.getMinutes(), - second: date.getSeconds(), - weekday: date.getDay(), - }; - } -} - -/** - * Format a single strftime directive. - */ -function formatStrftimeDirective( - date: Date, - directive: string, - tz?: string, -): string | null { - const parts = getDatePartsInTimezone(date, tz); - - const pad = (n: number, width = 2): string => String(n).padStart(width, "0"); - - const dayOfYear = getDayOfYearForParts(parts.year, parts.month, parts.day); - const weekNumber = getWeekNumberForParts( - parts.year, - parts.month, - parts.day, - parts.weekday, - 0, - ); // Sunday start - const weekNumberMon = getWeekNumberForParts( - parts.year, - parts.month, - parts.day, - parts.weekday, - 1, - ); // Monday start - - switch (directive) { - case "a": - return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][parts.weekday]; - case "A": - return [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - ][parts.weekday]; - case "b": - case "h": - return [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ][parts.month - 1]; - case "B": - return [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - ][parts.month - 1]; - case "c": - return `${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][parts.weekday]} ${ - [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ][parts.month - 1] - } ${String(parts.day).padStart(2, " ")} ${pad(parts.hour)}:${pad(parts.minute)}:${pad(parts.second)} ${parts.year}`; - case "C": - return pad(Math.floor(parts.year / 100)); - case "d": - return pad(parts.day); - case "D": - return `${pad(parts.month)}/${pad(parts.day)}/${pad(parts.year % 100)}`; - case "e": - return String(parts.day).padStart(2, " "); - case "F": - return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}`; - case "g": - return pad(getISOWeekYear(parts.year, parts.month, parts.day) % 100); - case "G": - return String(getISOWeekYear(parts.year, parts.month, parts.day)); - case "H": - return pad(parts.hour); - case "I": - return pad(parts.hour % 12 || 12); - case "j": - return String(dayOfYear).padStart(3, "0"); - case "k": - return String(parts.hour).padStart(2, " "); - case "l": - return String(parts.hour % 12 || 12).padStart(2, " "); - case "m": - return pad(parts.month); - case "M": - return pad(parts.minute); - case "n": - return "\n"; - case "N": - // Nanoseconds - we don't have sub-second precision - return "000000000"; - case "p": - return parts.hour < 12 ? "AM" : "PM"; - case "P": - return parts.hour < 12 ? "am" : "pm"; - case "r": - return `${pad(parts.hour % 12 || 12)}:${pad(parts.minute)}:${pad(parts.second)} ${parts.hour < 12 ? "AM" : "PM"}`; - case "R": - return `${pad(parts.hour)}:${pad(parts.minute)}`; - case "s": - return String(Math.floor(date.getTime() / 1000)); - case "S": - return pad(parts.second); - case "t": - return "\t"; - case "T": - return `${pad(parts.hour)}:${pad(parts.minute)}:${pad(parts.second)}`; - case "u": - return String(parts.weekday === 0 ? 7 : parts.weekday); - case "U": - return pad(weekNumber); - case "V": - return pad(getISOWeekNumberForParts(parts.year, parts.month, parts.day)); - case "w": - return String(parts.weekday); - case "W": - return pad(weekNumberMon); - case "x": - return `${pad(parts.month)}/${pad(parts.day)}/${pad(parts.year % 100)}`; - case "X": - return `${pad(parts.hour)}:${pad(parts.minute)}:${pad(parts.second)}`; - case "y": - return pad(parts.year % 100); - case "Y": - return String(parts.year); - case "z": - return getTimezoneOffset(date, tz); - case "Z": - return getTimezoneName(date, tz); - case "%": - return "%"; - default: - return null; - } -} - -/** - * Get the timezone offset in +/-HHMM format. - */ -function getTimezoneOffset(date: Date, tz?: string): string { - if (!tz) { - // Use local timezone - const offset = -date.getTimezoneOffset(); - const sign = offset >= 0 ? "+" : "-"; - const hours = Math.floor(Math.abs(offset) / 60); - const mins = Math.abs(offset) % 60; - return `${sign}${String(hours).padStart(2, "0")}${String(mins).padStart(2, "0")}`; - } - - // For named timezone, we need to get the offset at this specific time - // This is complex because timezones have DST - try { - // Get time string with timezone - const formatter = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - timeZoneName: "longOffset", - }); - const parts = formatter.formatToParts(date); - const tzPart = parts.find((p) => p.type === "timeZoneName"); - if (tzPart) { - // Value is like "GMT-08:00" or "GMT+05:30" - const match = tzPart.value.match(/GMT([+-])(\d{2}):(\d{2})/); - if (match) { - return `${match[1]}${match[2]}${match[3]}`; - } - // Check for UTC case - if (tzPart.value === "GMT" || tzPart.value === "UTC") { - return "+0000"; - } - } - } catch { - // Fall through to local offset - } - - // Fallback to local timezone offset - const offset = -date.getTimezoneOffset(); - const sign = offset >= 0 ? "+" : "-"; - const hours = Math.floor(Math.abs(offset) / 60); - const mins = Math.abs(offset) % 60; - return `${sign}${String(hours).padStart(2, "0")}${String(mins).padStart(2, "0")}`; -} - -/** - * Get the timezone name abbreviation. - */ -function getTimezoneName(date: Date, tz?: string): string { - try { - const formatter = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - timeZoneName: "short", - }); - const parts = formatter.formatToParts(date); - const tzPart = parts.find((p) => p.type === "timeZoneName"); - return tzPart?.value ?? "UTC"; - } catch { - return "UTC"; - } -} - -/** - * Calculate day of year (1-366) from date parts. - */ -function getDayOfYearForParts( - year: number, - month: number, - day: number, -): number { - const daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; - const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; - if (isLeap) daysInMonth[1] = 29; - - let dayOfYear = day; - for (let i = 0; i < month - 1; i++) { - dayOfYear += daysInMonth[i]; - } - return dayOfYear; -} - -/** - * Calculate week number from date parts. - */ -function getWeekNumberForParts( - year: number, - month: number, - day: number, - weekday: number, - startDay: number, -): number { - const dayOfYear = getDayOfYearForParts(year, month, day); - // Find day of week of Jan 1 - const jan1 = new Date(year, 0, 1); - const jan1Weekday = jan1.getDay(); - - // Adjust for start day - const adjustedJan1 = (jan1Weekday - startDay + 7) % 7; - const adjustedWeekday = (weekday - startDay + 7) % 7; - - // Days from start of first week - const daysIntoYear = dayOfYear - 1 + adjustedJan1; - const weekNum = Math.floor((daysIntoYear - adjustedWeekday + 7) / 7); - - return weekNum; -} - -/** - * Calculate ISO week number (1-53). - */ -function getISOWeekNumberForParts( - year: number, - month: number, - day: number, -): number { - // Create date in local time at noon to avoid DST issues - const tempDate = new Date(year, month - 1, day, 12, 0, 0); - // Get nearest Thursday - tempDate.setDate(tempDate.getDate() + 3 - ((tempDate.getDay() + 6) % 7)); - // Get first Thursday of year - const firstThursday = new Date(tempDate.getFullYear(), 0, 4); - firstThursday.setDate( - firstThursday.getDate() + 3 - ((firstThursday.getDay() + 6) % 7), - ); - // Calculate week number - const diff = tempDate.getTime() - firstThursday.getTime(); - return 1 + Math.round(diff / (7 * 24 * 60 * 60 * 1000)); -} - -/** - * Get the ISO week year (may differ from calendar year at year boundaries). - */ -function getISOWeekYear(year: number, month: number, day: number): number { - const tempDate = new Date(year, month - 1, day, 12, 0, 0); - // Get nearest Thursday - tempDate.setDate(tempDate.getDate() + 3 - ((tempDate.getDay() + 6) % 7)); - return tempDate.getFullYear(); -} diff --git a/src/commands/pwd/pwd.test.ts b/src/commands/pwd/pwd.test.ts deleted file mode 100644 index aeab3e10..00000000 --- a/src/commands/pwd/pwd.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("pwd", () => { - it("should show default home directory", async () => { - const env = new Bash(); - const result = await env.exec("pwd"); - expect(result.stdout).toBe("/home/user\n"); - expect(result.exitCode).toBe(0); - }); - - it("should show root directory when cwd is /", async () => { - const env = new Bash({ cwd: "/" }); - const result = await env.exec("pwd"); - expect(result.stdout).toBe("/\n"); - expect(result.exitCode).toBe(0); - }); - - it("should show current directory", async () => { - const env = new Bash({ cwd: "/home/user" }); - const result = await env.exec("pwd"); - expect(result.stdout).toBe("/home/user\n"); - }); - - it("should reflect cd changes within same exec", async () => { - const env = new Bash({ - files: { "/home/user/.keep": "" }, - }); - // cd and pwd must be in same exec (each exec is a new shell) - const result = await env.exec("cd /home/user; pwd"); - expect(result.stdout).toBe("/home/user\n"); - }); - - it("should work after multiple cd commands within same exec", async () => { - const env = new Bash({ - files: { - "/a/.keep": "", - "/b/.keep": "", - "/c/.keep": "", - }, - }); - const result = await env.exec("cd /a; cd /b; cd /c; pwd"); - expect(result.stdout).toBe("/c\n"); - }); - - it("should work after cd ..", async () => { - const env = new Bash({ - files: { "/parent/child/.keep": "" }, - cwd: "/parent/child", - }); - const result = await env.exec("cd ..; pwd"); - expect(result.stdout).toBe("/parent\n"); - }); - - it("should ignore arguments", async () => { - const env = new Bash({ cwd: "/test" }); - const result = await env.exec("pwd ignored args"); - expect(result.stdout).toBe("/test\n"); - }); -}); diff --git a/src/commands/pwd/pwd.ts b/src/commands/pwd/pwd.ts deleted file mode 100644 index 5b0125de..00000000 --- a/src/commands/pwd/pwd.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Command, CommandContext, ExecResult } from "../../types.js"; - -export const pwdCommand: Command = { - name: "pwd", - - async execute(args: string[], ctx: CommandContext): Promise { - // Parse options - let usePhysical = false; - - for (const arg of args) { - if (arg === "-P") { - usePhysical = true; - } else if (arg === "-L") { - usePhysical = false; - } else if (arg === "--") { - // End of options - break; - } else if (arg.startsWith("-")) { - } - } - - let pwd = ctx.cwd; - - if (usePhysical) { - // -P: resolve all symlinks to get physical path - try { - pwd = await ctx.fs.realpath(ctx.cwd); - } catch { - // If realpath fails, fall back to current cwd - // This matches bash behavior - } - } - - return { - stdout: `${pwd}\n`, - stderr: "", - exitCode: 0, - }; - }, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "pwd", - flags: [ - { flag: "-P", type: "boolean" }, - { flag: "-L", type: "boolean" }, - ], -}; diff --git a/src/commands/python3/fs-bridge-handler.ts b/src/commands/python3/fs-bridge-handler.ts deleted file mode 100644 index 890f14f9..00000000 --- a/src/commands/python3/fs-bridge-handler.ts +++ /dev/null @@ -1,398 +0,0 @@ -/** - * Main thread filesystem bridge handler - * - * Runs on the main thread and processes filesystem requests from the worker thread. - * Uses SharedArrayBuffer + Atomics for synchronization. - */ - -import type { IFileSystem } from "../../fs/interface.js"; -import type { SecureFetch } from "../../network/fetch.js"; -import { - ErrorCode, - type ErrorCodeType, - Flags, - OpCode, - type OpCodeType, - ProtocolBuffer, - Status, -} from "./protocol.js"; - -export interface FsBridgeOutput { - stdout: string; - stderr: string; - exitCode: number; -} - -/** - * Handles filesystem requests from the worker thread. - */ -export class FsBridgeHandler { - private protocol: ProtocolBuffer; - private running = false; - private output: FsBridgeOutput = { stdout: "", stderr: "", exitCode: 0 }; - - constructor( - sharedBuffer: SharedArrayBuffer, - private fs: IFileSystem, - private cwd: string, - private secureFetch: SecureFetch | undefined = undefined, - ) { - this.protocol = new ProtocolBuffer(sharedBuffer); - } - - /** - * Run the handler loop until EXIT operation or timeout. - */ - async run(timeoutMs: number): Promise { - this.running = true; - const startTime = Date.now(); - - while (this.running) { - const elapsed = Date.now() - startTime; - if (elapsed >= timeoutMs) { - this.output.stderr += "\npython3: execution timeout exceeded\n"; - this.output.exitCode = 124; - break; - } - - // Wait for worker to set status to READY - const remainingMs = timeoutMs - elapsed; - const ready = await this.protocol.waitUntilReady(remainingMs); - if (!ready) { - this.output.stderr += "\npython3: execution timeout exceeded\n"; - this.output.exitCode = 124; - break; - } - - const opCode = this.protocol.getOpCode(); - await this.handleOperation(opCode); - - // handleOperation sets status to SUCCESS/ERROR - // Notify worker so it wakes up and sees the result - this.protocol.notify(); - } - - return this.output; - } - - stop(): void { - this.running = false; - } - - private async handleOperation(opCode: OpCodeType): Promise { - try { - switch (opCode) { - case OpCode.READ_FILE: - await this.handleReadFile(); - break; - case OpCode.WRITE_FILE: - await this.handleWriteFile(); - break; - case OpCode.STAT: - await this.handleStat(); - break; - case OpCode.LSTAT: - await this.handleLstat(); - break; - case OpCode.READDIR: - await this.handleReaddir(); - break; - case OpCode.MKDIR: - await this.handleMkdir(); - break; - case OpCode.RM: - await this.handleRm(); - break; - case OpCode.EXISTS: - await this.handleExists(); - break; - case OpCode.APPEND_FILE: - await this.handleAppendFile(); - break; - case OpCode.SYMLINK: - await this.handleSymlink(); - break; - case OpCode.READLINK: - await this.handleReadlink(); - break; - case OpCode.CHMOD: - await this.handleChmod(); - break; - case OpCode.REALPATH: - await this.handleRealpath(); - break; - case OpCode.WRITE_STDOUT: - this.handleWriteStdout(); - break; - case OpCode.WRITE_STDERR: - this.handleWriteStderr(); - break; - case OpCode.EXIT: - this.handleExit(); - break; - case OpCode.HTTP_REQUEST: - await this.handleHttpRequest(); - break; - default: - this.protocol.setErrorCode(ErrorCode.IO_ERROR); - this.protocol.setStatus(Status.ERROR); - } - } catch (e) { - this.setErrorFromException(e); - } - } - - private resolvePath(path: string): string { - if (path.startsWith("/mnt/host/")) { - return path.slice("/mnt/host".length); - } - if (path.startsWith("/mnt/host")) { - return path.slice("/mnt/host".length) || "/"; - } - return this.fs.resolvePath(this.cwd, path); - } - - private async handleReadFile(): Promise { - const path = this.resolvePath(this.protocol.getPath()); - try { - const content = await this.fs.readFileBuffer(path); - this.protocol.setResult(content); - this.protocol.setStatus(Status.SUCCESS); - } catch (e) { - this.setErrorFromException(e); - } - } - - private async handleWriteFile(): Promise { - const path = this.resolvePath(this.protocol.getPath()); - const data = this.protocol.getData(); - try { - await this.fs.writeFile(path, data); - this.protocol.setStatus(Status.SUCCESS); - } catch (e) { - this.setErrorFromException(e); - } - } - - private async handleStat(): Promise { - const path = this.resolvePath(this.protocol.getPath()); - try { - const stat = await this.fs.stat(path); - this.protocol.encodeStat(stat); - this.protocol.setStatus(Status.SUCCESS); - } catch (e) { - this.setErrorFromException(e); - } - } - - private async handleLstat(): Promise { - const path = this.resolvePath(this.protocol.getPath()); - try { - const stat = await this.fs.lstat(path); - this.protocol.encodeStat(stat); - this.protocol.setStatus(Status.SUCCESS); - } catch (e) { - this.setErrorFromException(e); - } - } - - private async handleReaddir(): Promise { - const path = this.resolvePath(this.protocol.getPath()); - try { - const entries = await this.fs.readdir(path); - this.protocol.setResultFromString(JSON.stringify(entries)); - this.protocol.setStatus(Status.SUCCESS); - } catch (e) { - this.setErrorFromException(e); - } - } - - private async handleMkdir(): Promise { - const path = this.resolvePath(this.protocol.getPath()); - const flags = this.protocol.getFlags(); - const recursive = (flags & Flags.MKDIR_RECURSIVE) !== 0; - try { - await this.fs.mkdir(path, { recursive }); - this.protocol.setStatus(Status.SUCCESS); - } catch (e) { - this.setErrorFromException(e); - } - } - - private async handleRm(): Promise { - const path = this.resolvePath(this.protocol.getPath()); - const flags = this.protocol.getFlags(); - const recursive = (flags & Flags.RECURSIVE) !== 0; - const force = (flags & Flags.FORCE) !== 0; - try { - await this.fs.rm(path, { recursive, force }); - this.protocol.setStatus(Status.SUCCESS); - } catch (e) { - this.setErrorFromException(e); - } - } - - private async handleExists(): Promise { - const path = this.resolvePath(this.protocol.getPath()); - try { - const exists = await this.fs.exists(path); - this.protocol.setResult(new Uint8Array([exists ? 1 : 0])); - this.protocol.setStatus(Status.SUCCESS); - } catch (e) { - this.setErrorFromException(e); - } - } - - private async handleAppendFile(): Promise { - const path = this.resolvePath(this.protocol.getPath()); - const data = this.protocol.getData(); - try { - await this.fs.appendFile(path, data); - this.protocol.setStatus(Status.SUCCESS); - } catch (e) { - this.setErrorFromException(e); - } - } - - private async handleSymlink(): Promise { - const path = this.protocol.getPath(); - const data = this.protocol.getDataAsString(); - const linkPath = this.resolvePath(path); - try { - await this.fs.symlink(data, linkPath); - this.protocol.setStatus(Status.SUCCESS); - } catch (e) { - this.setErrorFromException(e); - } - } - - private async handleReadlink(): Promise { - const path = this.resolvePath(this.protocol.getPath()); - try { - const target = await this.fs.readlink(path); - this.protocol.setResultFromString(target); - this.protocol.setStatus(Status.SUCCESS); - } catch (e) { - this.setErrorFromException(e); - } - } - - private async handleChmod(): Promise { - const path = this.resolvePath(this.protocol.getPath()); - const mode = this.protocol.getMode(); - try { - await this.fs.chmod(path, mode); - this.protocol.setStatus(Status.SUCCESS); - } catch (e) { - this.setErrorFromException(e); - } - } - - private async handleRealpath(): Promise { - const path = this.resolvePath(this.protocol.getPath()); - try { - const realpath = await this.fs.realpath(path); - this.protocol.setResultFromString(realpath); - this.protocol.setStatus(Status.SUCCESS); - } catch (e) { - this.setErrorFromException(e); - } - } - - private handleWriteStdout(): void { - const data = this.protocol.getDataAsString(); - this.output.stdout += data; - this.protocol.setStatus(Status.SUCCESS); - } - - private handleWriteStderr(): void { - const data = this.protocol.getDataAsString(); - this.output.stderr += data; - this.protocol.setStatus(Status.SUCCESS); - } - - private handleExit(): void { - const exitCode = this.protocol.getFlags(); - this.output.exitCode = exitCode; - this.protocol.setStatus(Status.SUCCESS); - this.running = false; - } - - private async handleHttpRequest(): Promise { - if (!this.secureFetch) { - this.protocol.setErrorCode(ErrorCode.NETWORK_NOT_CONFIGURED); - this.protocol.setResultFromString( - "Network access not configured. Enable network in Bash options.", - ); - this.protocol.setStatus(Status.ERROR); - return; - } - - const url = this.protocol.getPath(); - const requestJson = this.protocol.getDataAsString(); - - try { - const request = requestJson ? JSON.parse(requestJson) : {}; - const result = await this.secureFetch(url, { - method: request.method, - headers: request.headers, - body: request.body, - }); - - // Return response as JSON - const response = JSON.stringify({ - status: result.status, - statusText: result.statusText, - headers: result.headers, - body: result.body, - url: result.url, - }); - this.protocol.setResultFromString(response); - this.protocol.setStatus(Status.SUCCESS); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - this.protocol.setErrorCode(ErrorCode.NETWORK_ERROR); - this.protocol.setResultFromString(message); - this.protocol.setStatus(Status.ERROR); - } - } - - private setErrorFromException(e: unknown): void { - const message = e instanceof Error ? e.message : String(e); - - let errorCode: ErrorCodeType = ErrorCode.IO_ERROR; - const lowerMsg = message.toLowerCase(); - if ( - lowerMsg.includes("no such file") || - lowerMsg.includes("not found") || - lowerMsg.includes("enoent") - ) { - errorCode = ErrorCode.NOT_FOUND; - } else if ( - lowerMsg.includes("is a directory") || - lowerMsg.includes("eisdir") - ) { - errorCode = ErrorCode.IS_DIRECTORY; - } else if ( - lowerMsg.includes("not a directory") || - lowerMsg.includes("enotdir") - ) { - errorCode = ErrorCode.NOT_DIRECTORY; - } else if ( - lowerMsg.includes("already exists") || - lowerMsg.includes("eexist") - ) { - errorCode = ErrorCode.EXISTS; - } else if ( - lowerMsg.includes("permission") || - lowerMsg.includes("eperm") || - lowerMsg.includes("eacces") - ) { - errorCode = ErrorCode.PERMISSION_DENIED; - } - - this.protocol.setErrorCode(errorCode); - this.protocol.setResultFromString(message); - this.protocol.setStatus(Status.ERROR); - } -} diff --git a/src/commands/python3/protocol.ts b/src/commands/python3/protocol.ts deleted file mode 100644 index f64aaeba..00000000 --- a/src/commands/python3/protocol.ts +++ /dev/null @@ -1,407 +0,0 @@ -/** - * SharedArrayBuffer protocol for synchronous filesystem bridge - * - * This protocol enables synchronous filesystem access from a worker thread - * (where Pyodide/Python runs) to the main thread (which has async IFileSystem). - */ - -// Type declaration for Atomics.waitAsync (available in Node.js but not in TS lib) -declare global { - interface Atomics { - waitAsync( - typedArray: Int32Array, - index: number, - value: number, - timeout?: number, - ): - | { async: false; value: "not-equal" | "timed-out" } - | { async: true; value: Promise<"ok" | "timed-out"> }; - } -} - -/** Operation codes */ -export const OpCode = { - NOOP: 0, - READ_FILE: 1, - WRITE_FILE: 2, - STAT: 3, - READDIR: 4, - MKDIR: 5, - RM: 6, - EXISTS: 7, - APPEND_FILE: 8, - SYMLINK: 9, - READLINK: 10, - LSTAT: 11, - CHMOD: 12, - REALPATH: 13, - // Special operations for Python I/O - WRITE_STDOUT: 100, - WRITE_STDERR: 101, - EXIT: 102, - // HTTP operations - HTTP_REQUEST: 200, -} as const; - -export type OpCodeType = (typeof OpCode)[keyof typeof OpCode]; - -/** Status codes for synchronization */ -export const Status = { - PENDING: 0, - READY: 1, - SUCCESS: 2, - ERROR: 3, -} as const; - -export type StatusType = (typeof Status)[keyof typeof Status]; - -/** Error codes */ -export const ErrorCode = { - NONE: 0, - NOT_FOUND: 1, - IS_DIRECTORY: 2, - NOT_DIRECTORY: 3, - EXISTS: 4, - PERMISSION_DENIED: 5, - INVALID_PATH: 6, - IO_ERROR: 7, - TIMEOUT: 8, - NETWORK_ERROR: 9, - NETWORK_NOT_CONFIGURED: 10, -} as const; - -export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode]; - -/** Buffer layout offsets */ -const Offset = { - OP_CODE: 0, - STATUS: 4, - PATH_LENGTH: 8, - DATA_LENGTH: 12, - RESULT_LENGTH: 16, - ERROR_CODE: 20, - FLAGS: 24, - MODE: 28, - PATH_BUFFER: 32, - DATA_BUFFER: 4128, // 32 + 4096 -} as const; - -/** Buffer sizes */ -const Size = { - CONTROL_REGION: 32, - PATH_BUFFER: 4096, - DATA_BUFFER: 1048576, // 1MB (reduced from 16MB for faster tests) - TOTAL: 1052704, // 32 + 4096 + 1MB -} as const; - -/** Flags for operations */ -export const Flags = { - NONE: 0, - RECURSIVE: 1, - FORCE: 2, - MKDIR_RECURSIVE: 1, -} as const; - -/** Stat result structure layout */ -const StatLayout = { - IS_FILE: 0, - IS_DIRECTORY: 1, - IS_SYMLINK: 2, - MODE: 4, - SIZE: 8, - MTIME: 16, - TOTAL: 24, -} as const; - -/** Create a new SharedArrayBuffer for the protocol */ -export function createSharedBuffer(): SharedArrayBuffer { - return new SharedArrayBuffer(Size.TOTAL); -} - -/** - * Helper class for reading/writing protocol data - */ -export class ProtocolBuffer { - private int32View: Int32Array; - private uint8View: Uint8Array; - private dataView: DataView; - - constructor(buffer: SharedArrayBuffer) { - this.int32View = new Int32Array(buffer); - this.uint8View = new Uint8Array(buffer); - this.dataView = new DataView(buffer); - } - - getOpCode(): OpCodeType { - return Atomics.load(this.int32View, Offset.OP_CODE / 4) as OpCodeType; - } - - setOpCode(code: OpCodeType): void { - Atomics.store(this.int32View, Offset.OP_CODE / 4, code); - } - - getStatus(): StatusType { - return Atomics.load(this.int32View, Offset.STATUS / 4) as StatusType; - } - - setStatus(status: StatusType): void { - Atomics.store(this.int32View, Offset.STATUS / 4, status); - } - - getPathLength(): number { - return Atomics.load(this.int32View, Offset.PATH_LENGTH / 4); - } - - setPathLength(length: number): void { - Atomics.store(this.int32View, Offset.PATH_LENGTH / 4, length); - } - - getDataLength(): number { - return Atomics.load(this.int32View, Offset.DATA_LENGTH / 4); - } - - setDataLength(length: number): void { - Atomics.store(this.int32View, Offset.DATA_LENGTH / 4, length); - } - - getResultLength(): number { - return Atomics.load(this.int32View, Offset.RESULT_LENGTH / 4); - } - - setResultLength(length: number): void { - Atomics.store(this.int32View, Offset.RESULT_LENGTH / 4, length); - } - - getErrorCode(): ErrorCodeType { - return Atomics.load(this.int32View, Offset.ERROR_CODE / 4) as ErrorCodeType; - } - - setErrorCode(code: ErrorCodeType): void { - Atomics.store(this.int32View, Offset.ERROR_CODE / 4, code); - } - - getFlags(): number { - return Atomics.load(this.int32View, Offset.FLAGS / 4); - } - - setFlags(flags: number): void { - Atomics.store(this.int32View, Offset.FLAGS / 4, flags); - } - - getMode(): number { - return Atomics.load(this.int32View, Offset.MODE / 4); - } - - setMode(mode: number): void { - Atomics.store(this.int32View, Offset.MODE / 4, mode); - } - - getPath(): string { - const length = this.getPathLength(); - const bytes = this.uint8View.slice( - Offset.PATH_BUFFER, - Offset.PATH_BUFFER + length, - ); - return new TextDecoder().decode(bytes); - } - - setPath(path: string): void { - const encoded = new TextEncoder().encode(path); - if (encoded.length > Size.PATH_BUFFER) { - throw new Error(`Path too long: ${encoded.length} > ${Size.PATH_BUFFER}`); - } - this.uint8View.set(encoded, Offset.PATH_BUFFER); - this.setPathLength(encoded.length); - } - - getData(): Uint8Array { - const length = this.getDataLength(); - return this.uint8View.slice( - Offset.DATA_BUFFER, - Offset.DATA_BUFFER + length, - ); - } - - setData(data: Uint8Array): void { - if (data.length > Size.DATA_BUFFER) { - throw new Error(`Data too large: ${data.length} > ${Size.DATA_BUFFER}`); - } - this.uint8View.set(data, Offset.DATA_BUFFER); - this.setDataLength(data.length); - } - - getDataAsString(): string { - const data = this.getData(); - return new TextDecoder().decode(data); - } - - setDataFromString(str: string): void { - const encoded = new TextEncoder().encode(str); - this.setData(encoded); - } - - getResult(): Uint8Array { - const length = this.getResultLength(); - return this.uint8View.slice( - Offset.DATA_BUFFER, - Offset.DATA_BUFFER + length, - ); - } - - setResult(data: Uint8Array): void { - if (data.length > Size.DATA_BUFFER) { - throw new Error(`Result too large: ${data.length} > ${Size.DATA_BUFFER}`); - } - this.uint8View.set(data, Offset.DATA_BUFFER); - this.setResultLength(data.length); - } - - getResultAsString(): string { - const result = this.getResult(); - return new TextDecoder().decode(result); - } - - setResultFromString(str: string): void { - const encoded = new TextEncoder().encode(str); - this.setResult(encoded); - } - - encodeStat(stat: { - isFile: boolean; - isDirectory: boolean; - isSymbolicLink: boolean; - mode: number; - size: number; - mtime: Date; - }): void { - this.uint8View[Offset.DATA_BUFFER + StatLayout.IS_FILE] = stat.isFile - ? 1 - : 0; - this.uint8View[Offset.DATA_BUFFER + StatLayout.IS_DIRECTORY] = - stat.isDirectory ? 1 : 0; - this.uint8View[Offset.DATA_BUFFER + StatLayout.IS_SYMLINK] = - stat.isSymbolicLink ? 1 : 0; - this.dataView.setInt32( - Offset.DATA_BUFFER + StatLayout.MODE, - stat.mode, - true, - ); - const size = Math.min(stat.size, Number.MAX_SAFE_INTEGER); - this.dataView.setFloat64(Offset.DATA_BUFFER + StatLayout.SIZE, size, true); - this.dataView.setFloat64( - Offset.DATA_BUFFER + StatLayout.MTIME, - stat.mtime.getTime(), - true, - ); - this.setResultLength(StatLayout.TOTAL); - } - - decodeStat(): { - isFile: boolean; - isDirectory: boolean; - isSymbolicLink: boolean; - mode: number; - size: number; - mtime: Date; - } { - return { - isFile: this.uint8View[Offset.DATA_BUFFER + StatLayout.IS_FILE] === 1, - isDirectory: - this.uint8View[Offset.DATA_BUFFER + StatLayout.IS_DIRECTORY] === 1, - isSymbolicLink: - this.uint8View[Offset.DATA_BUFFER + StatLayout.IS_SYMLINK] === 1, - mode: this.dataView.getInt32(Offset.DATA_BUFFER + StatLayout.MODE, true), - size: this.dataView.getFloat64( - Offset.DATA_BUFFER + StatLayout.SIZE, - true, - ), - mtime: new Date( - this.dataView.getFloat64(Offset.DATA_BUFFER + StatLayout.MTIME, true), - ), - }; - } - - waitForReady(timeout?: number): "ok" | "timed-out" | "not-equal" { - return Atomics.wait( - this.int32View, - Offset.STATUS / 4, - Status.PENDING, - timeout, - ); - } - - waitForReadyAsync( - timeout?: number, - ): - | { async: false; value: "not-equal" | "timed-out" } - | { async: true; value: Promise<"ok" | "timed-out"> } { - // Wait for status to change from PENDING (any change means worker set READY) - return Atomics.waitAsync( - this.int32View, - Offset.STATUS / 4, - Status.PENDING, - timeout, - ); - } - - /** - * Wait for status to become READY. - * Returns immediately if status is already READY, or waits until it changes. - */ - async waitUntilReady(timeout: number): Promise { - const startTime = Date.now(); - - while (true) { - const status = this.getStatus(); - if (status === Status.READY) { - return true; - } - - const elapsed = Date.now() - startTime; - if (elapsed >= timeout) { - return false; - } - - // Wait for any status change - const remainingMs = timeout - elapsed; - const result = Atomics.waitAsync( - this.int32View, - Offset.STATUS / 4, - status, - remainingMs, - ); - - if (result.async) { - const waitResult = await result.value; - if (waitResult === "timed-out") { - return false; - } - } - // Re-check status after wait - } - } - - waitForResult(timeout?: number): "ok" | "timed-out" | "not-equal" { - return Atomics.wait( - this.int32View, - Offset.STATUS / 4, - Status.READY, - timeout, - ); - } - - notify(): number { - return Atomics.notify(this.int32View, Offset.STATUS / 4); - } - - reset(): void { - this.setOpCode(OpCode.NOOP); - this.setStatus(Status.PENDING); - this.setPathLength(0); - this.setDataLength(0); - this.setResultLength(0); - this.setErrorCode(ErrorCode.NONE); - this.setFlags(Flags.NONE); - this.setMode(0); - } -} diff --git a/src/commands/python3/python3.advanced.test.ts b/src/commands/python3/python3.advanced.test.ts deleted file mode 100644 index 60559cc4..00000000 --- a/src/commands/python3/python3.advanced.test.ts +++ /dev/null @@ -1,492 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("python3 advanced features", () => { - describe("generators", () => { - it("should create simple generators", { timeout: 60000 }, async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_generator.py << 'EOF' -def countdown(n): - while n > 0: - yield n - n -= 1 - -print(list(countdown(3))) -EOF`); - const result = await env.exec(`python3 /tmp/test_generator.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("[3, 2, 1]\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support generator expressions", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "print(sum(x**2 for x in range(5)))"`, - ); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("30\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support infinite generators with itertools", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_infinite_gen.py << 'EOF' -from itertools import islice, count -result = list(islice(count(10), 5)) -print(result) -EOF`); - const result = await env.exec(`python3 /tmp/test_infinite_gen.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("[10, 11, 12, 13, 14]\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("decorators", () => { - it("should support simple decorators", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_decorator.py << 'EOF' -def uppercase(func): - def wrapper(*args, **kwargs): - result = func(*args, **kwargs) - return result.upper() - return wrapper - -@uppercase -def greet(name): - return f"hello {name}" - -print(greet("world")) -EOF`); - const result = await env.exec(`python3 /tmp/test_decorator.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("HELLO WORLD\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support decorators with arguments", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_decorator_args.py << 'EOF' -def repeat(times): - def decorator(func): - def wrapper(*args, **kwargs): - result = [] - for _ in range(times): - result.append(func(*args, **kwargs)) - return result - return wrapper - return decorator - -@repeat(3) -def say_hi(): - return "hi" - -print(say_hi()) -EOF`); - const result = await env.exec(`python3 /tmp/test_decorator_args.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("['hi', 'hi', 'hi']\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support stacked decorators", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_stacked_decorators.py << 'EOF' -def add_prefix(func): - def wrapper(): - return "PREFIX:" + func() - return wrapper - -def add_suffix(func): - def wrapper(): - return func() + ":SUFFIX" - return wrapper - -@add_prefix -@add_suffix -def message(): - return "hello" - -print(message()) -EOF`); - const result = await env.exec(`python3 /tmp/test_stacked_decorators.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("PREFIX:hello:SUFFIX\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("context managers", () => { - it("should support custom context managers with class", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_context_class.py << 'EOF' -class Timer: - def __enter__(self): - print("entering") - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - print("exiting") - return False - -with Timer(): - print("inside") -EOF`); - const result = await env.exec(`python3 /tmp/test_context_class.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("entering\ninside\nexiting\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support contextlib.contextmanager", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_contextlib.py << 'EOF' -from contextlib import contextmanager - -@contextmanager -def tag(name): - print(f"<{name}>") - yield - print(f"") - -with tag("div"): - print("content") -EOF`); - const result = await env.exec(`python3 /tmp/test_contextlib.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("
\ncontent\n
\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("lambda functions", () => { - it("should create and use lambdas", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "f = lambda x, y: x + y; print(f(3, 4))"`, - ); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("7\n"); - expect(result.exitCode).toBe(0); - }); - - it("should use lambdas with map/filter", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_lambda.py << 'EOF' -numbers = [1, 2, 3, 4, 5] -squared = list(map(lambda x: x**2, numbers)) -evens = list(filter(lambda x: x % 2 == 0, numbers)) -print(squared) -print(evens) -EOF`); - const result = await env.exec(`python3 /tmp/test_lambda.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("[1, 4, 9, 16, 25]\n[2, 4]\n"); - expect(result.exitCode).toBe(0); - }); - - it("should use lambdas with sorted", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_lambda_sort.py << 'EOF' -pairs = [(1, 'one'), (2, 'two'), (3, 'three')] -sorted_by_name = sorted(pairs, key=lambda x: x[1]) -print([p[1] for p in sorted_by_name]) -EOF`); - const result = await env.exec(`python3 /tmp/test_lambda_sort.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("['one', 'three', 'two']\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("closures", () => { - it("should create closures", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_closure.py << 'EOF' -def make_multiplier(n): - def multiply(x): - return x * n - return multiply - -double = make_multiplier(2) -triple = make_multiplier(3) -print(double(5)) -print(triple(5)) -EOF`); - const result = await env.exec(`python3 /tmp/test_closure.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("10\n15\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support nonlocal", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_nonlocal.py << 'EOF' -def make_counter(): - count = 0 - def counter(): - nonlocal count - count += 1 - return count - return counter - -c = make_counter() -print(c()) -print(c()) -print(c()) -EOF`); - const result = await env.exec(`python3 /tmp/test_nonlocal.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("1\n2\n3\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("type hints", () => { - it("should support basic type hints", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_types.py << 'EOF' -def greet(name: str) -> str: - return f"Hello, {name}" - -def add(a: int, b: int) -> int: - return a + b - -print(greet("World")) -print(add(3, 4)) -EOF`); - const result = await env.exec(`python3 /tmp/test_types.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("Hello, World\n7\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support typing module", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_typing.py << 'EOF' -from typing import List, Dict, Optional - -def process(items: List[int]) -> Dict[str, int]: - return {"sum": sum(items), "count": len(items)} - -def maybe_double(x: Optional[int]) -> int: - if x is None: - return 0 - return x * 2 - -print(process([1, 2, 3])) -print(maybe_double(5)) -print(maybe_double(None)) -EOF`); - const result = await env.exec(`python3 /tmp/test_typing.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("{'sum': 6, 'count': 3}\n10\n0\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("walrus operator", () => { - it("should support assignment expressions", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_walrus.py << 'EOF' -numbers = [1, 2, 3, 4, 5] -if (n := len(numbers)) > 3: - print(f"list has {n} elements") -EOF`); - const result = await env.exec(`python3 /tmp/test_walrus.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("list has 5 elements\n"); - expect(result.exitCode).toBe(0); - }); - - it("should work in list comprehensions", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_walrus_comp.py << 'EOF' -data = [1, 2, 3, 4, 5] -results = [y for x in data if (y := x * 2) > 4] -print(results) -EOF`); - const result = await env.exec(`python3 /tmp/test_walrus_comp.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("[6, 8, 10]\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("f-strings", () => { - it("should support basic f-strings", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "name='World'; print(f'Hello, {name}!')"`, - ); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("Hello, World!\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support f-string expressions", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(`python3 -c "print(f'{2 + 2 = }')"`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("2 + 2 = 4\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support f-string formatting", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_fstring.py << 'EOF' -pi = 3.14159 -print(f"{pi:.2f}") -n = 42 -print(f"{n:05d}") -EOF`); - const result = await env.exec(`python3 /tmp/test_fstring.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("3.14\n00042\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("unpacking", () => { - it("should support extended unpacking", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_unpack.py << 'EOF' -first, *middle, last = [1, 2, 3, 4, 5] -print(first) -print(middle) -print(last) -EOF`); - const result = await env.exec(`python3 /tmp/test_unpack.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("1\n[2, 3, 4]\n5\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support dictionary unpacking", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_dict_unpack.py << 'EOF' -d1 = {'a': 1, 'b': 2} -d2 = {'c': 3, 'd': 4} -merged = {**d1, **d2} -print(sorted(merged.items())) -EOF`); - const result = await env.exec(`python3 /tmp/test_dict_unpack.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("[('a', 1), ('b', 2), ('c', 3), ('d', 4)]\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("match statement (Python 3.10+)", () => { - it("should support basic match", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_match.py << 'EOF' -def describe(x): - match x: - case 0: - return "zero" - case 1: - return "one" - case _: - return "other" - -print(describe(0)) -print(describe(1)) -print(describe(42)) -EOF`); - const result = await env.exec(`python3 /tmp/test_match.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("zero\none\nother\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support match with patterns", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_match_pattern.py << 'EOF' -def process(data): - match data: - case [x, y]: - return f"pair: {x}, {y}" - case [x, y, z]: - return f"triple: {x}, {y}, {z}" - case _: - return "unknown" - -print(process([1, 2])) -print(process([1, 2, 3])) -EOF`); - const result = await env.exec(`python3 /tmp/test_match_pattern.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("pair: 1, 2\ntriple: 1, 2, 3\n"); - expect(result.exitCode).toBe(0); - }); - }); - - // Note: asyncio.run() requires WebAssembly stack switching which isn't - // supported in our JavaScript runtime. These tests are skipped. - describe("async/await", () => { - it.skip("should support async functions", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_async.py << 'EOF' -import asyncio - -async def greet(): - return "hello" - -async def main(): - result = await greet() - print(result) - -asyncio.run(main()) -EOF`); - const result = await env.exec(`python3 /tmp/test_async.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("hello\n"); - expect(result.exitCode).toBe(0); - }); - - it.skip("should support async list operations", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_async_list.py << 'EOF' -import asyncio - -async def double(x): - return x * 2 - -async def main(): - tasks = [double(i) for i in range(3)] - results = await asyncio.gather(*tasks) - print(results) - -asyncio.run(main()) -EOF`); - const result = await env.exec(`python3 /tmp/test_async_list.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("[0, 2, 4]\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("enum", () => { - it("should support basic enums", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_enum.py << 'EOF' -from enum import Enum - -class Color(Enum): - RED = 1 - GREEN = 2 - BLUE = 3 - -print(Color.RED) -print(Color.RED.value) -print(Color.RED.name) -EOF`); - const result = await env.exec(`python3 /tmp/test_enum.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("Color.RED\n1\nRED\n"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/commands/python3/python3.env.test.ts b/src/commands/python3/python3.env.test.ts deleted file mode 100644 index 55f4f49a..00000000 --- a/src/commands/python3/python3.env.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("python3 environment", () => { - describe("environment variables", () => { - it("should access exported env vars", { timeout: 60000 }, async () => { - const env = new Bash({ python: true }); - const result = await env.exec(` -export MY_VAR=hello -python3 -c "import os; print(os.environ.get('MY_VAR', 'not found'))" -`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("hello\n"); - expect(result.exitCode).toBe(0); - }); - - it("should access multiple env vars", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(` -export VAR1=one -export VAR2=two -export VAR3=three -python3 -c "import os; print(os.environ['VAR1'], os.environ['VAR2'], os.environ['VAR3'])" -`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("one two three\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle env vars with spaces", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(` -export MY_VAR="hello world" -python3 -c "import os; print(os.environ['MY_VAR'])" -`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("hello world\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle env vars with special characters", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(` -export SPECIAL='foo=bar&baz=qux' -python3 -c "import os; print(os.environ['SPECIAL'])" -`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("foo=bar&baz=qux\n"); - expect(result.exitCode).toBe(0); - }); - - it("should access HOME env var", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "import os; print(os.environ.get('HOME', 'not set'))"`, - ); - expect(result.stderr).toBe(""); - expect(result.stdout).toContain("/home"); - expect(result.exitCode).toBe(0); - }); - - it("should access PATH env var", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "import os; print('PATH' in os.environ)"`, - ); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("True\n"); - expect(result.exitCode).toBe(0); - }); - - it("should access PWD env var", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "import os; print(os.environ.get('PWD', 'not set'))"`, - ); - expect(result.stderr).toBe(""); - expect(result.stdout).toContain("/"); - expect(result.exitCode).toBe(0); - }); - - it("should return None for undefined env var with get()", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "import os; print(os.environ.get('UNDEFINED_VAR_12345'))"`, - ); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("None\n"); - expect(result.exitCode).toBe(0); - }); - - it("should raise KeyError for undefined env var with []", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "import os; print(os.environ['UNDEFINED_VAR_12345'])"`, - ); - expect(result.stderr).toContain("KeyError"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("working directory", () => { - it("should have correct cwd", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "import os; print(os.getcwd())"`, - ); - expect(result.stderr).toBe(""); - expect(result.stdout).toContain("/"); - expect(result.exitCode).toBe(0); - }); - - it("should match bash pwd", async () => { - const env = new Bash({ python: true }); - const bashPwd = await env.exec("pwd"); - const pythonCwd = await env.exec( - `python3 -c "import os; print(os.getcwd())"`, - ); - expect(pythonCwd.stderr).toBe(""); - // Python paths should match bash paths (no /host prefix) - expect(pythonCwd.stdout.trim()).toBe(bashPwd.stdout.trim()); - }); - - it("should work with cd", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(` -cd /tmp -python3 -c "import os; print(os.getcwd())" -`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("/tmp\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("exit codes", () => { - it("should return exit code 0 on success", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(`python3 -c "print('ok')"`); - expect(result.exitCode).toBe(0); - }); - - it("should return exit code from sys.exit()", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(`python3 -c "import sys; sys.exit(42)"`); - expect(result.exitCode).toBe(42); - }); - - it("should return exit code 0 from sys.exit(0)", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(`python3 -c "import sys; sys.exit(0)"`); - expect(result.exitCode).toBe(0); - }); - - it("should return exit code 1 on exception", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "raise ValueError('test error')"`, - ); - expect(result.stderr).toContain("ValueError"); - expect(result.exitCode).toBe(1); - }); - - it("should return exit code 1 from sys.exit(string)", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "import sys; sys.exit('error message')"`, - ); - // sys.exit with string message prints to stderr and exits with 1 - expect(result.exitCode).toBe(1); - }); - - it("should return exit code 1 from sys.exit(None)", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(`python3 -c "import sys; sys.exit(None)"`); - // sys.exit(None) is equivalent to sys.exit(0) - expect(result.exitCode).toBe(0); - }); - }); - - describe("crash handling", () => { - it("should handle NameError", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(`python3 -c "print(undefined_variable)"`); - expect(result.stderr).toContain("NameError"); - expect(result.exitCode).toBe(1); - }); - - it("should handle TypeError", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(`python3 -c "'string' + 5"`); - expect(result.stderr).toContain("TypeError"); - expect(result.exitCode).toBe(1); - }); - - it("should handle IndexError", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(`python3 -c "[][0]"`); - expect(result.stderr).toContain("IndexError"); - expect(result.exitCode).toBe(1); - }); - - it("should handle KeyError", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(`python3 -c "{}['missing']"`); - expect(result.stderr).toContain("KeyError"); - expect(result.exitCode).toBe(1); - }); - - it("should handle AttributeError", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(`python3 -c "'string'.nonexistent()"`); - expect(result.stderr).toContain("AttributeError"); - expect(result.exitCode).toBe(1); - }); - - it("should handle import errors", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "import nonexistent_module_xyz"`, - ); - expect(result.stderr).toContain("ModuleNotFoundError"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("unicode handling", () => { - it("should handle unicode in output", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(`python3 -c "print('hello 世界')"`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("hello 世界\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle emoji", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(`python3 -c "print('🎉 party')"`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("🎉 party\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle unicode env vars", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(` -export UNICODE_VAR="привет мир" -python3 -c "import os; print(os.environ['UNICODE_VAR'])" -`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("привет мир\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("binary file I/O", () => { - it("should write and read binary data", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_binary.py << 'EOF' -with open('/tmp/binary.bin', 'wb') as f: - f.write(bytes([0, 1, 2, 255, 254, 253])) -with open('/tmp/binary.bin', 'rb') as f: - data = f.read() -print(list(data)) -EOF`); - const result = await env.exec(`python3 /tmp/test_binary.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("[0, 1, 2, 255, 254, 253]\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle binary data with null bytes", async () => { - const env = new Bash({ python: true }); - // Use bytes() constructor to create null bytes - avoid literal null in heredoc - await env.exec(`cat > /tmp/test_nullbytes.py << 'EOF' -with open('/tmp/nullbytes.bin', 'wb') as f: - f.write(b'hello' + bytes([0]) + b'world') -with open('/tmp/nullbytes.bin', 'rb') as f: - data = f.read() -print(len(data), data[5]) -EOF`); - const result = await env.exec(`python3 /tmp/test_nullbytes.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("11 0\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("multiline output", () => { - it("should handle multiple print statements", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(`python3 -c " -print('line1') -print('line2') -print('line3') -"`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("line1\nline2\nline3\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle for loop output", async () => { - const env = new Bash({ python: true }); - // Use heredoc to preserve indentation - await env.exec(`cat > /tmp/test_forloop.py << 'EOF' -for i in range(3): - print(i) -EOF`); - const result = await env.exec(`python3 /tmp/test_forloop.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("0\n1\n2\n"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/commands/python3/python3.files.test.ts b/src/commands/python3/python3.files.test.ts deleted file mode 100644 index fba71cca..00000000 --- a/src/commands/python3/python3.files.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -// Note: These tests use Pyodide which downloads ~30MB on first run. -// The first test will be slow, subsequent tests reuse the cached instance. - -describe("python3 script files", () => { - describe("script file execution", () => { - it("should execute a Python script file", { timeout: 60000 }, async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/script.py << 'EOF' -print("Hello from script") -EOF`); - const result = await env.exec("python3 /tmp/script.py"); - expect(result.stdout).toBe("Hello from script\n"); - expect(result.exitCode).toBe(0); - }); - - it("should pass arguments to script", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/args.py << 'EOF' -import sys -print(f"Args: {sys.argv[1:]}") -EOF`); - const result = await env.exec("python3 /tmp/args.py foo bar baz"); - expect(result.stdout).toBe("Args: ['foo', 'bar', 'baz']\n"); - expect(result.exitCode).toBe(0); - }); - - it("should have correct sys.argv[0] for script file", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/argv0.py << 'EOF' -import sys -print(sys.argv[0]) -EOF`); - const result = await env.exec("python3 /tmp/argv0.py"); - expect(result.stdout).toBe("/tmp/argv0.py\n"); - expect(result.exitCode).toBe(0); - }); - - it("should error on missing script file", async () => { - const env = new Bash({ python: true }); - const result = await env.exec("python3 /tmp/nonexistent.py"); - expect(result.stderr).toContain("can't open file"); - expect(result.exitCode).toBe(2); - }); - - it("should handle script with multiline code", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/multiline.py << 'EOF' -def greet(name): - return f"Hello, {name}!" - -result = greet("World") -print(result) -EOF`); - const result = await env.exec("python3 /tmp/multiline.py"); - expect(result.stdout).toBe("Hello, World!\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle script with imports", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/imports.py << 'EOF' -import json -import math - -data = {"pi": math.pi} -print(json.dumps(data)) -EOF`); - const result = await env.exec("python3 /tmp/imports.py"); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed.pi).toBeCloseTo(Math.PI, 5); - expect(result.exitCode).toBe(0); - }); - - it("should handle script with syntax error", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/syntax_error.py << 'EOF' -print("hello" -EOF`); - const result = await env.exec("python3 /tmp/syntax_error.py"); - expect(result.stderr).toContain("SyntaxError"); - expect(result.exitCode).toBe(1); - }); - - it("should handle script with runtime error", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/runtime_error.py << 'EOF' -x = 1 / 0 -EOF`); - const result = await env.exec("python3 /tmp/runtime_error.py"); - expect(result.stderr).toContain("ZeroDivisionError"); - expect(result.exitCode).toBe(1); - }); - }); -}); - -describe("python3 file I/O", () => { - describe("file read/write", () => { - it("should read files created by bash", async () => { - const env = new Bash({ python: true }); - await env.exec('echo "content from bash" > /tmp/bashfile.txt'); - const result = await env.exec( - `python3 -c "with open('/tmp/bashfile.txt') as f: print(f.read().strip())"`, - ); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("content from bash\n"); - expect(result.exitCode).toBe(0); - }); - - it("should write files readable by bash", async () => { - const env = new Bash({ python: true }); - const pyResult = await env.exec( - `python3 -c "with open('/tmp/pyfile.txt', 'w') as f: f.write('content from python')"`, - ); - expect(pyResult.stderr).toBe(""); - expect(pyResult.exitCode).toBe(0); - const result = await env.exec("cat /tmp/pyfile.txt"); - expect(result.stdout).toBe("content from python"); - expect(result.exitCode).toBe(0); - }); - - it("should append to files", async () => { - const env = new Bash({ python: true }); - await env.exec('echo "line1" > /tmp/append.txt'); - const pyResult = await env.exec( - `python3 -c "with open('/tmp/append.txt', 'a') as f: f.write('line2\\n')"`, - ); - expect(pyResult.stderr).toBe(""); - expect(pyResult.exitCode).toBe(0); - const result = await env.exec("cat /tmp/append.txt"); - expect(result.stdout).toBe("line1\nline2\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("directory operations", () => { - it("should list directory contents", async () => { - const env = new Bash({ python: true }); - await env.exec("mkdir -p /tmp/testdir"); - await env.exec("touch /tmp/testdir/a.txt /tmp/testdir/b.txt"); - const result = await env.exec(`python3 -c " -import os -files = sorted(os.listdir('/tmp/testdir')) -print(files) -"`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("['a.txt', 'b.txt']\n"); - expect(result.exitCode).toBe(0); - }); - - it("should create directories", async () => { - const env = new Bash({ python: true }); - const pyResult = await env.exec( - "python3 -c \"import os; os.makedirs('/tmp/newdir/subdir', exist_ok=True)\"", - ); - expect(pyResult.stderr).toBe(""); - expect(pyResult.exitCode).toBe(0); - const result = await env.exec("ls -d /tmp/newdir/subdir"); - expect(result.stdout).toBe("/tmp/newdir/subdir\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("module imports from files", () => { - // Note: sys.path requires /host prefix because Python's import machinery - // uses internal C-level operations that bypass our Python-level path patches. - // Regular file operations (open, os.listdir, etc.) work with normal paths. - it("should import local module with sys.path", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/mymodule.py << 'EOF' -def greet(name): - return f"Hello, {name}!" -EOF`); - await env.exec(`cat > /tmp/main.py << 'EOF' -import sys -sys.path.insert(0, '/host/tmp') -import mymodule -print(mymodule.greet("World")) -EOF`); - const result = await env.exec("python3 /tmp/main.py"); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("Hello, World!\n"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/commands/python3/python3.http.test.ts b/src/commands/python3/python3.http.test.ts deleted file mode 100644 index f390e20c..00000000 --- a/src/commands/python3/python3.http.test.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; -import { Bash } from "../../Bash.js"; - -// Mock fetch to avoid real network requests -const originalFetch = global.fetch; -const mockFetch = vi.fn(); - -beforeAll(() => { - global.fetch = mockFetch as unknown as typeof fetch; -}); - -afterAll(() => { - global.fetch = originalFetch; -}); - -beforeEach(() => { - mockFetch.mockClear(); -}); - -describe("python3 HTTP requests", () => { - describe("jb_http module", () => { - it("should make a GET request", { timeout: 60000 }, async () => { - mockFetch.mockResolvedValueOnce( - new Response('{"url": "https://api.example.com/get"}', { - status: 200, - statusText: "OK", - headers: { "content-type": "application/json" }, - }), - ); - const env = new Bash({ - python: true, - network: { - allowedUrlPrefixes: ["https://api.example.com/"], - }, - }); - await env.exec(`cat > /tmp/test_get.py << 'EOF' -import jb_http -response = jb_http.get("https://api.example.com/get") -print(response.status_code) -print(response.ok) -EOF`); - const result = await env.exec(`python3 /tmp/test_get.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("200\nTrue\n"); - expect(result.exitCode).toBe(0); - }); - - it("should return response headers", async () => { - mockFetch.mockResolvedValueOnce( - new Response("body", { - status: 200, - headers: { "content-type": "text/plain", "x-custom": "value" }, - }), - ); - const env = new Bash({ - python: true, - network: { - allowedUrlPrefixes: ["https://api.example.com/"], - }, - }); - // Use -c instead of file to eliminate file I/O as a variable - const result = await env.exec(`python3 -c " -import jb_http -response = jb_http.get('https://api.example.com/get') -print('content-type' in response.headers) -"`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("True\n"); - expect(result.exitCode).toBe(0); - }); - - it("should parse JSON response", async () => { - mockFetch.mockResolvedValueOnce( - new Response('{"key": "value", "number": 42}', { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - const env = new Bash({ - python: true, - network: { - allowedUrlPrefixes: ["https://api.example.com/"], - }, - }); - await env.exec(`cat > /tmp/test_json.py << 'EOF' -import jb_http -response = jb_http.get("https://api.example.com/json") -data = response.json() -print(type(data).__name__) -print(data["key"]) -EOF`); - const result = await env.exec(`python3 /tmp/test_json.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("dict\nvalue\n"); - expect(result.exitCode).toBe(0); - }); - - it("should send custom headers", async () => { - mockFetch.mockImplementationOnce(async (_url, options) => { - const headers = options?.headers as Record; - return new Response( - JSON.stringify({ headers: { "X-Custom": headers?.["X-Custom"] } }), - { status: 200 }, - ); - }); - const env = new Bash({ - python: true, - network: { - allowedUrlPrefixes: ["https://api.example.com/"], - }, - }); - await env.exec(`cat > /tmp/test_custom_headers.py << 'EOF' -import jb_http -response = jb_http.get("https://api.example.com/headers", headers={"X-Custom": "test-value"}) -data = response.json() -print(data["headers"].get("X-Custom", "not found")) -EOF`); - const result = await env.exec(`python3 /tmp/test_custom_headers.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("test-value\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle 404 responses", async () => { - mockFetch.mockResolvedValueOnce( - new Response("Not Found", { - status: 404, - statusText: "Not Found", - }), - ); - const env = new Bash({ - python: true, - network: { - allowedUrlPrefixes: ["https://api.example.com/"], - }, - }); - await env.exec(`cat > /tmp/test_404.py << 'EOF' -import jb_http -response = jb_http.get("https://api.example.com/notfound") -print(response.status_code) -print(response.ok) -EOF`); - const result = await env.exec(`python3 /tmp/test_404.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("404\nFalse\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle raise_for_status", async () => { - mockFetch.mockResolvedValueOnce( - new Response("Server Error", { - status: 500, - statusText: "Internal Server Error", - }), - ); - const env = new Bash({ - python: true, - network: { - allowedUrlPrefixes: ["https://api.example.com/"], - }, - }); - await env.exec(`cat > /tmp/test_raise.py << 'EOF' -import jb_http -response = jb_http.get("https://api.example.com/error") -try: - response.raise_for_status() - print("no error") -except Exception as e: - print("error raised") -EOF`); - const result = await env.exec(`python3 /tmp/test_raise.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("error raised\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("network access denied", () => { - it("should fail when network not configured", async () => { - const env = new Bash({ python: true }); // No network config - await env.exec(`cat > /tmp/test_no_network.py << 'EOF' -import jb_http -try: - response = jb_http.get("https://example.com/") - print("success") -except Exception as e: - print("network error") -EOF`); - const result = await env.exec(`python3 /tmp/test_no_network.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("network error\n"); - expect(result.exitCode).toBe(0); - }); - - it("should fail for URLs not in allow-list", async () => { - const env = new Bash({ - python: true, - network: { - allowedUrlPrefixes: ["https://api.example.com/"], - }, - }); - await env.exec(`cat > /tmp/test_blocked.py << 'EOF' -import jb_http -try: - response = jb_http.get("https://blocked.com/") - print("success") -except Exception as e: - print("access denied") -EOF`); - const result = await env.exec(`python3 /tmp/test_blocked.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("access denied\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("POST requests", () => { - it("should send POST with form data", async () => { - mockFetch.mockImplementationOnce(async (_url, options) => { - return new Response(JSON.stringify({ data: options?.body }), { - status: 200, - }); - }); - const env = new Bash({ - python: true, - network: { - allowedUrlPrefixes: ["https://api.example.com/"], - allowedMethods: ["GET", "POST"], - }, - }); - await env.exec(`cat > /tmp/test_post.py << 'EOF' -import jb_http -response = jb_http.post("https://api.example.com/post", data="hello=world") -print(response.status_code) -EOF`); - const result = await env.exec(`python3 /tmp/test_post.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("200\n"); - expect(result.exitCode).toBe(0); - }); - - it("should send POST with JSON", async () => { - mockFetch.mockImplementationOnce(async (_url, options) => { - const body = JSON.parse(options?.body as string); - return new Response(JSON.stringify({ json: body }), { status: 200 }); - }); - const env = new Bash({ - python: true, - network: { - allowedUrlPrefixes: ["https://api.example.com/"], - allowedMethods: ["GET", "POST"], - }, - }); - await env.exec(`cat > /tmp/test_post_json.py << 'EOF' -import jb_http -response = jb_http.post("https://api.example.com/post", json={"key": "value"}) -data = response.json() -print(data["json"]["key"]) -EOF`); - const result = await env.exec(`python3 /tmp/test_post_json.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("value\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("other HTTP methods", () => { - it("should make HEAD request", async () => { - mockFetch.mockResolvedValueOnce( - new Response("", { - status: 200, - headers: { "content-length": "1234" }, - }), - ); - const env = new Bash({ - python: true, - network: { - allowedUrlPrefixes: ["https://api.example.com/"], - allowedMethods: ["GET", "HEAD"], - }, - }); - await env.exec(`cat > /tmp/test_head.py << 'EOF' -import jb_http -response = jb_http.head("https://api.example.com/resource") -print(response.status_code) -print(len(response.text)) -EOF`); - const result = await env.exec(`python3 /tmp/test_head.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("200\n0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should make PUT request", async () => { - mockFetch.mockResolvedValueOnce( - new Response('{"updated": true}', { status: 200 }), - ); - const env = new Bash({ - python: true, - network: { - allowedUrlPrefixes: ["https://api.example.com/"], - allowedMethods: ["GET", "PUT"], - }, - }); - await env.exec(`cat > /tmp/test_put.py << 'EOF' -import jb_http -response = jb_http.put("https://api.example.com/resource", json={"update": "data"}) -print(response.status_code) -EOF`); - const result = await env.exec(`python3 /tmp/test_put.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("200\n"); - expect(result.exitCode).toBe(0); - }); - - it("should make DELETE request", async () => { - mockFetch.mockResolvedValueOnce( - new Response('{"deleted": true}', { status: 200 }), - ); - const env = new Bash({ - python: true, - network: { - allowedUrlPrefixes: ["https://api.example.com/"], - allowedMethods: ["GET", "DELETE"], - }, - }); - await env.exec(`cat > /tmp/test_delete.py << 'EOF' -import jb_http -response = jb_http.delete("https://api.example.com/resource") -print(response.status_code) -EOF`); - const result = await env.exec(`python3 /tmp/test_delete.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("200\n"); - expect(result.exitCode).toBe(0); - }); - - it("should make PATCH request", async () => { - mockFetch.mockResolvedValueOnce( - new Response('{"patched": true}', { status: 200 }), - ); - const env = new Bash({ - python: true, - network: { - allowedUrlPrefixes: ["https://api.example.com/"], - allowedMethods: ["GET", "PATCH"], - }, - }); - await env.exec(`cat > /tmp/test_patch.py << 'EOF' -import jb_http -response = jb_http.patch("https://api.example.com/resource", json={"partial": "update"}) -print(response.status_code) -EOF`); - const result = await env.exec(`python3 /tmp/test_patch.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("200\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("integration with file system", () => { - it("should download and save to file", async () => { - mockFetch.mockResolvedValueOnce( - new Response('{"slideshow": {"title": "Test"}}', { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - const env = new Bash({ - python: true, - network: { - allowedUrlPrefixes: ["https://api.example.com/"], - }, - }); - await env.exec(`cat > /tmp/test_download.py << 'EOF' -import jb_http -response = jb_http.get("https://api.example.com/json") -with open("/tmp/downloaded.json", "w") as f: - f.write(response.text) -print("saved") -EOF`); - const pyResult = await env.exec(`python3 /tmp/test_download.py`); - expect(pyResult.stderr).toBe(""); - expect(pyResult.stdout).toBe("saved\n"); - - // Verify the file was saved - const catResult = await env.exec(`cat /tmp/downloaded.json`); - expect(catResult.stdout).toContain("slideshow"); - }); - }); -}); diff --git a/src/commands/python3/python3.oop.test.ts b/src/commands/python3/python3.oop.test.ts deleted file mode 100644 index ef9c6abc..00000000 --- a/src/commands/python3/python3.oop.test.ts +++ /dev/null @@ -1,467 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("python3 data structures", () => { - describe("lists", () => { - it("should create and manipulate lists", { timeout: 60000 }, async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_list.py << 'EOF' -lst = [1, 2, 3] -lst.append(4) -lst.extend([5, 6]) -print(lst) -print(len(lst)) -EOF`); - const result = await env.exec(`python3 /tmp/test_list.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("[1, 2, 3, 4, 5, 6]\n6\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support list slicing", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_slice.py << 'EOF' -lst = [0, 1, 2, 3, 4, 5] -print(lst[2:4]) -print(lst[::2]) -print(lst[::-1]) -EOF`); - const result = await env.exec(`python3 /tmp/test_slice.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("[2, 3]\n[0, 2, 4]\n[5, 4, 3, 2, 1, 0]\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support list comprehensions", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "print([x**2 for x in range(5)])"`, - ); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("[0, 1, 4, 9, 16]\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support nested list comprehensions", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_nested_comp.py << 'EOF' -matrix = [[i*j for j in range(3)] for i in range(3)] -print(matrix) -EOF`); - const result = await env.exec(`python3 /tmp/test_nested_comp.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("[[0, 0, 0], [0, 1, 2], [0, 2, 4]]\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("dictionaries", () => { - it("should create and access dicts", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_dict.py << 'EOF' -d = {'a': 1, 'b': 2} -d['c'] = 3 -print(d['a']) -print(sorted(d.keys())) -EOF`); - const result = await env.exec(`python3 /tmp/test_dict.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("1\n['a', 'b', 'c']\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support dict comprehensions", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_dict_comp.py << 'EOF' -d = {x: x**2 for x in range(4)} -print(sorted(d.items())) -EOF`); - const result = await env.exec(`python3 /tmp/test_dict_comp.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("[(0, 0), (1, 1), (2, 4), (3, 9)]\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support dict methods", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_dict_methods.py << 'EOF' -d = {'a': 1, 'b': 2} -print(d.get('c', 'default')) -d.update({'c': 3}) -print(sorted(d.keys())) -EOF`); - const result = await env.exec(`python3 /tmp/test_dict_methods.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("default\n['a', 'b', 'c']\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("sets", () => { - it("should create and manipulate sets", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_set.py << 'EOF' -s = {1, 2, 3} -s.add(4) -s.add(2) # duplicate -print(sorted(s)) -EOF`); - const result = await env.exec(`python3 /tmp/test_set.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("[1, 2, 3, 4]\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support set operations", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_set_ops.py << 'EOF' -a = {1, 2, 3} -b = {2, 3, 4} -print(sorted(a & b)) -print(sorted(a | b)) -print(sorted(a - b)) -EOF`); - const result = await env.exec(`python3 /tmp/test_set_ops.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("[2, 3]\n[1, 2, 3, 4]\n[1]\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support set comprehensions", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "print(sorted({x%3 for x in range(10)}))"`, - ); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("[0, 1, 2]\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("tuples", () => { - it("should create and unpack tuples", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_tuple.py << 'EOF' -t = (1, 2, 3) -a, b, c = t -print(a, b, c) -print(len(t)) -EOF`); - const result = await env.exec(`python3 /tmp/test_tuple.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("1 2 3\n3\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support named tuples", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_namedtuple.py << 'EOF' -from collections import namedtuple -Point = namedtuple('Point', ['x', 'y']) -p = Point(3, 4) -print(p.x, p.y) -EOF`); - const result = await env.exec(`python3 /tmp/test_namedtuple.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("3 4\n"); - expect(result.exitCode).toBe(0); - }); - }); -}); - -describe("python3 OOP", () => { - describe("classes", () => { - it("should define and instantiate classes", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_class.py << 'EOF' -class Dog: - def __init__(self, name): - self.name = name - - def bark(self): - return f"{self.name} says woof!" - -dog = Dog("Buddy") -print(dog.bark()) -EOF`); - const result = await env.exec(`python3 /tmp/test_class.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("Buddy says woof!\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support inheritance", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_inheritance.py << 'EOF' -class Animal: - def speak(self): - return "..." - -class Cat(Animal): - def speak(self): - return "meow" - -class Dog(Animal): - def speak(self): - return "woof" - -animals = [Cat(), Dog()] -for a in animals: - print(a.speak()) -EOF`); - const result = await env.exec(`python3 /tmp/test_inheritance.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("meow\nwoof\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support class methods and static methods", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_classmethods.py << 'EOF' -class Counter: - count = 0 - - def __init__(self): - Counter.count += 1 - - @classmethod - def get_count(cls): - return cls.count - - @staticmethod - def description(): - return "A counter class" - -c1 = Counter() -c2 = Counter() -print(Counter.get_count()) -print(Counter.description()) -EOF`); - const result = await env.exec(`python3 /tmp/test_classmethods.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("2\nA counter class\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support properties", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_property.py << 'EOF' -class Circle: - def __init__(self, radius): - self._radius = radius - - @property - def radius(self): - return self._radius - - @radius.setter - def radius(self, value): - if value < 0: - raise ValueError("Radius cannot be negative") - self._radius = value - -c = Circle(5) -print(c.radius) -c.radius = 10 -print(c.radius) -EOF`); - const result = await env.exec(`python3 /tmp/test_property.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("5\n10\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support dataclasses", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_dataclass.py << 'EOF' -from dataclasses import dataclass - -@dataclass -class Point: - x: int - y: int - -p = Point(3, 4) -print(p.x, p.y) -print(p) -EOF`); - const result = await env.exec(`python3 /tmp/test_dataclass.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("3 4\nPoint(x=3, y=4)\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("magic methods", () => { - it("should support __str__ and __repr__", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_magic.py << 'EOF' -class Point: - def __init__(self, x, y): - self.x = x - self.y = y - - def __str__(self): - return f"Point({self.x}, {self.y})" - - def __repr__(self): - return f"Point(x={self.x}, y={self.y})" - -p = Point(3, 4) -print(str(p)) -print(repr(p)) -EOF`); - const result = await env.exec(`python3 /tmp/test_magic.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("Point(3, 4)\nPoint(x=3, y=4)\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support comparison methods", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_comparison.py << 'EOF' -class Number: - def __init__(self, value): - self.value = value - - def __eq__(self, other): - return self.value == other.value - - def __lt__(self, other): - return self.value < other.value - -a = Number(5) -b = Number(10) -c = Number(5) -print(a == c) -print(a < b) -EOF`); - const result = await env.exec(`python3 /tmp/test_comparison.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("True\nTrue\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support arithmetic methods", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_arithmetic.py << 'EOF' -class Vector: - def __init__(self, x, y): - self.x = x - self.y = y - - def __add__(self, other): - return Vector(self.x + other.x, self.y + other.y) - - def __str__(self): - return f"Vector({self.x}, {self.y})" - -v1 = Vector(1, 2) -v2 = Vector(3, 4) -v3 = v1 + v2 -print(v3) -EOF`); - const result = await env.exec(`python3 /tmp/test_arithmetic.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("Vector(4, 6)\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support __len__ and __getitem__", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_container.py << 'EOF' -class MyList: - def __init__(self, items): - self._items = items - - def __len__(self): - return len(self._items) - - def __getitem__(self, index): - return self._items[index] - -ml = MyList([1, 2, 3, 4, 5]) -print(len(ml)) -print(ml[2]) -EOF`); - const result = await env.exec(`python3 /tmp/test_container.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("5\n3\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("exception handling", () => { - it("should handle try/except", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_exception.py << 'EOF' -try: - x = 1 / 0 -except ZeroDivisionError: - print("caught division by zero") -EOF`); - const result = await env.exec(`python3 /tmp/test_exception.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("caught division by zero\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle multiple exceptions", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_multi_except.py << 'EOF' -def test(x): - try: - if x == 0: - raise ValueError("zero") - return 10 / x - except ValueError as e: - return f"value error: {e}" - except ZeroDivisionError: - return "division error" - -print(test(0)) -print(test(2)) -EOF`); - const result = await env.exec(`python3 /tmp/test_multi_except.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("value error: zero\n5.0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle finally", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_finally.py << 'EOF' -def test(): - try: - return "try" - finally: - print("finally") - -result = test() -print(result) -EOF`); - const result = await env.exec(`python3 /tmp/test_finally.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("finally\ntry\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support custom exceptions", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_custom_exception.py << 'EOF' -class MyError(Exception): - def __init__(self, message): - self.message = message - -try: - raise MyError("custom error") -except MyError as e: - print(f"caught: {e.message}") -EOF`); - const result = await env.exec(`python3 /tmp/test_custom_exception.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("caught: custom error\n"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/commands/python3/python3.optin.test.ts b/src/commands/python3/python3.optin.test.ts deleted file mode 100644 index abdc214a..00000000 --- a/src/commands/python3/python3.optin.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("python3 opt-in behavior", () => { - it("should not have python3 when python option is not enabled", async () => { - const env = new Bash(); // No python: true - const result = await env.exec("python3 --version"); - // Command should fail (either "not found" or "not available" depending on context) - expect(result.stderr).toMatch(/command not (found|available)/); - expect(result.exitCode).toBe(127); - }); - - it( - "should have python3 when python option is enabled", - { timeout: 60000 }, - async () => { - const env = new Bash({ python: true }); - const result = await env.exec("python3 --version"); - expect(result.stdout).toContain("Python 3."); - expect(result.exitCode).toBe(0); - }, - ); - - it("should not have python when python option is not enabled", async () => { - const env = new Bash(); // No python: true - const result = await env.exec("python --version"); - // Command should fail (either "not found" or "not available" depending on context) - expect(result.stderr).toMatch(/command not (found|available)/); - expect(result.exitCode).toBe(127); - }); -}); diff --git a/src/commands/python3/python3.security.test.ts b/src/commands/python3/python3.security.test.ts deleted file mode 100644 index 34c991e5..00000000 --- a/src/commands/python3/python3.security.test.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -// Note: These tests use Pyodide which downloads ~30MB on first run. -// The first test will be slow, subsequent tests reuse the cached instance. - -/** - * Security tests for the Python/Pyodide sandbox. - * These tests verify that the sandbox properly restricts dangerous operations. - */ -describe("python3 security", () => { - describe("blocked module imports", () => { - it( - "should block import js (sandbox escape vector)", - { timeout: 60000 }, - async () => { - const env = new Bash({ python: true }); - const result = await env.exec('python3 -c "import js"'); - expect(result.stderr).toContain("ImportError"); - expect(result.stderr).toContain("blocked"); - expect(result.exitCode).toBe(1); - }, - ); - - it("should block import js.globalThis", async () => { - const env = new Bash({ python: true }); - const result = await env.exec('python3 -c "from js import globalThis"'); - expect(result.stderr).toContain("ImportError"); - expect(result.stderr).toContain("blocked"); - expect(result.exitCode).toBe(1); - }); - - it("should block import pyodide.ffi", async () => { - const env = new Bash({ python: true }); - const result = await env.exec('python3 -c "import pyodide.ffi"'); - expect(result.stderr).toContain("ImportError"); - expect(result.stderr).toContain("blocked"); - expect(result.exitCode).toBe(1); - }); - - it("should block from pyodide.ffi import create_proxy", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - 'python3 -c "from pyodide.ffi import create_proxy"', - ); - expect(result.stderr).toContain("ImportError"); - expect(result.stderr).toContain("blocked"); - expect(result.exitCode).toBe(1); - }); - - it("should block import pyodide (sandbox escape via ffi)", async () => { - const env = new Bash({ python: true }); - const result = await env.exec('python3 -c "import pyodide"'); - expect(result.stderr).toContain("ImportError"); - expect(result.stderr).toContain("blocked"); - expect(result.exitCode).toBe(1); - }); - - it("should block import pyodide_js (exposes _original_* via globals)", async () => { - const env = new Bash({ python: true }); - const result = await env.exec('python3 -c "import pyodide_js"'); - expect(result.stderr).toContain("ImportError"); - expect(result.stderr).toContain("blocked"); - expect(result.exitCode).toBe(1); - }); - - it("should block pyodide_js.globals access to _original_import", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_pyodide_js.py << 'EOF' -try: - import pyodide_js - orig = pyodide_js.globals.get('_original_import') - if orig: - js = orig('js') - print('VULNERABLE: accessed _original_import via pyodide_js.globals') - else: - print('VULNERABLE: pyodide_js imported') -except ImportError as e: - if 'blocked' in str(e): - print('SECURE: pyodide_js blocked') - else: - print(f'ERROR: {e}') -EOF`); - const result = await env.exec("python3 /tmp/test_pyodide_js.py"); - expect(result.stdout).toContain("SECURE"); - expect(result.stdout).not.toContain("VULNERABLE"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("hidden original function references", () => { - it("should not expose _original_import (critical sandbox escape)", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_import.py << 'EOF' -try: - # If _original_import is accessible, attacker can bypass import blocking - js = _original_import('js') - print('VULNERABLE: _original_import accessible') -except NameError: - print('SECURE: _original_import not accessible') -EOF`); - const result = await env.exec("python3 /tmp/test_import.py"); - expect(result.stdout).toContain("SECURE"); - expect(result.stdout).not.toContain("VULNERABLE"); - expect(result.exitCode).toBe(0); - }); - - it("should not expose _jb_original_open on builtins", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - "python3 -c \"import builtins; print(hasattr(builtins, '_jb_original_open'))\"", - ); - expect(result.stdout).toBe("False\n"); - expect(result.exitCode).toBe(0); - }); - - it("should not expose _jb_original_listdir on os", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - "python3 -c \"import os; print(hasattr(os, '_jb_original_listdir'))\"", - ); - expect(result.stdout).toBe("False\n"); - expect(result.exitCode).toBe(0); - }); - - it("should not expose _jb_original_exists on os.path", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - "python3 -c \"import os; print(hasattr(os.path, '_jb_original_exists'))\"", - ); - expect(result.stdout).toBe("False\n"); - expect(result.exitCode).toBe(0); - }); - - it("should not expose _jb_original_stat on os", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - "python3 -c \"import os; print(hasattr(os, '_jb_original_stat'))\"", - ); - expect(result.stdout).toBe("False\n"); - expect(result.exitCode).toBe(0); - }); - - it("should not expose _jb_original_chdir on os", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - "python3 -c \"import os; print(hasattr(os, '_jb_original_chdir'))\"", - ); - expect(result.stdout).toBe("False\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("introspection bypass attempts", () => { - it("should block __kwdefaults__ access on __import__ (critical bypass)", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_kwdefaults.py << 'EOF' -import builtins -try: - # Old vulnerability: __kwdefaults__ exposed the original __import__ - kwdefaults = builtins.__import__.__kwdefaults__ - if kwdefaults and '_orig' in kwdefaults: - # Could bypass import blocking via kwdefaults['_orig']('js') - print(f'VULNERABLE: __kwdefaults__ exposed: {list(kwdefaults.keys())}') - else: - print('SECURE: __kwdefaults__ not exploitable') -except AttributeError as e: - print(f'SECURE: __kwdefaults__ access blocked') -EOF`); - const result = await env.exec("python3 /tmp/test_kwdefaults.py"); - expect(result.stdout).toContain("SECURE"); - expect(result.stdout).not.toContain("VULNERABLE"); - expect(result.exitCode).toBe(0); - }); - - it("should block __closure__ access on __import__", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_closure.py << 'EOF' -import builtins -try: - closure = builtins.__import__.__closure__ - print(f'VULNERABLE: __closure__ accessible: {closure}') -except AttributeError as e: - print(f'SECURE: __closure__ access blocked') -EOF`); - const result = await env.exec("python3 /tmp/test_closure.py"); - expect(result.stdout).toContain("SECURE"); - expect(result.stdout).not.toContain("VULNERABLE"); - expect(result.exitCode).toBe(0); - }); - - it("should block __globals__ access on __import__", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_globals.py << 'EOF' -import builtins -try: - g = builtins.__import__.__globals__ - print('VULNERABLE') -except AttributeError: - print('SECURE') -EOF`); - const result = await env.exec("python3 /tmp/test_globals.py"); - expect(result.stdout).toContain("SECURE"); - expect(result.exitCode).toBe(0); - }); - - it("should block __closure__ access on builtins.open", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_open_closure.py << 'EOF' -import builtins -try: - closure = builtins.open.__closure__ - print(f'VULNERABLE: closure={closure}') -except AttributeError: - print('SECURE: __closure__ blocked') -EOF`); - const result = await env.exec("python3 /tmp/test_open_closure.py"); - expect(result.stdout).toContain("SECURE"); - expect(result.exitCode).toBe(0); - }); - - it("should block __closure__ access on os.listdir", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_listdir_closure.py << 'EOF' -import os -try: - closure = os.listdir.__closure__ - print(f'VULNERABLE: closure={closure}') -except AttributeError: - print('SECURE: __closure__ blocked') -EOF`); - const result = await env.exec("python3 /tmp/test_listdir_closure.py"); - expect(result.stdout).toContain("SECURE"); - expect(result.exitCode).toBe(0); - }); - - it("should redirect shutil.copy to /host and block introspection", async () => { - const env = new Bash({ python: true }); - await env.exec('echo "shutil test" > /tmp/shutil_src.txt'); - await env.exec(`cat > /tmp/test_shutil.py << 'EOF' -import shutil -# Test that shutil.copy works with redirect -shutil.copy('/tmp/shutil_src.txt', '/tmp/shutil_dst.txt') -with open('/tmp/shutil_dst.txt') as f: - print(f'COPY_OK: {f.read().strip()}') -# Test that introspection is blocked -try: - closure = shutil.copy.__closure__ - print(f'VULNERABLE: closure={closure}') -except AttributeError: - print('SECURE: __closure__ blocked') -EOF`); - const result = await env.exec("python3 /tmp/test_shutil.py"); - expect(result.stdout).toContain("COPY_OK: shutil test"); - expect(result.stdout).toContain("SECURE"); - expect(result.exitCode).toBe(0); - }); - - it("should redirect pathlib.Path operations to /host", async () => { - const env = new Bash({ python: true }); - await env.exec('echo "pathlib test content" > /tmp/pathlib_test.txt'); - await env.exec(`cat > /tmp/test_pathlib.py << 'EOF' -from pathlib import Path - -# Test Path.read_text() -p = Path('/tmp/pathlib_test.txt') -content = p.read_text().strip() -print(f'READ_OK: {content}') - -# Test Path.exists() -if p.exists(): - print('EXISTS_OK') - -# Test Path.is_file() -if p.is_file(): - print('IS_FILE_OK') - -# Test Path.write_text() -p2 = Path('/tmp/pathlib_write.txt') -p2.write_text('written by pathlib') -print(f'WRITE_OK: {p2.read_text().strip()}') - -# Test Path.iterdir() - paths should not have /host prefix -tmp = Path('/tmp') -files = [f.name for f in tmp.iterdir() if f.name.startswith('pathlib')] -print(f'ITERDIR_OK: {sorted(files)}') -EOF`); - const result = await env.exec("python3 /tmp/test_pathlib.py"); - expect(result.stdout).toContain("READ_OK: pathlib test content"); - expect(result.stdout).toContain("EXISTS_OK"); - expect(result.stdout).toContain("IS_FILE_OK"); - expect(result.stdout).toContain("WRITE_OK: written by pathlib"); - expect(result.stdout).toContain("ITERDIR_OK:"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("file operation redirects", () => { - it("should redirect glob.glob to /host", async () => { - const env = new Bash({ python: true }); - await env.exec('echo "test content" > /tmp/test_glob.txt'); - const result = await env.exec(`python3 -c " -import glob -files = glob.glob('/tmp/test_glob.txt') -print(files) -"`); - // The glob should find the file via /host redirection - expect(result.stdout).toContain("test_glob.txt"); - expect(result.exitCode).toBe(0); - }); - - it("should redirect os.walk to /host", async () => { - const env = new Bash({ python: true }); - await env.exec("mkdir -p /tmp/test_walk_dir"); - await env.exec('echo "content1" > /tmp/test_walk_dir/file1.txt'); - await env.exec(`cat > /tmp/test_walk.py << 'EOF' -import os -for root, dirs, files in os.walk('/tmp/test_walk_dir'): - print(f'root={root}, files={files}') -EOF`); - const result = await env.exec("python3 /tmp/test_walk.py"); - expect(result.stdout).toContain("root=/tmp/test_walk_dir"); - expect(result.stdout).toContain("file1.txt"); - expect(result.exitCode).toBe(0); - }); - - it("should redirect os.scandir to /host", async () => { - const env = new Bash({ python: true }); - await env.exec("mkdir -p /tmp/test_scandir"); - await env.exec('echo "content" > /tmp/test_scandir/scanfile.txt'); - const result = await env.exec(`python3 -c " -import os -entries = list(os.scandir('/tmp/test_scandir')) -print([e.name for e in entries]) -"`); - expect(result.stdout).toContain("scanfile.txt"); - expect(result.exitCode).toBe(0); - }); - - it("should redirect io.open to /host", async () => { - const env = new Bash({ python: true }); - await env.exec('echo "io.open test content" > /tmp/test_io_open.txt'); - await env.exec(`cat > /tmp/test_io.py << 'EOF' -import io -with io.open('/tmp/test_io_open.txt', 'r') as f: - print(f.read()) -EOF`); - const result = await env.exec("python3 /tmp/test_io.py"); - expect(result.stdout).toContain("io.open test content"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("legitimate operations still work", () => { - it("should allow normal file operations", async () => { - const env = new Bash({ python: true }); - await env.exec('echo "allowed content" > /tmp/allowed_file.txt'); - await env.exec(`cat > /tmp/test_read.py << 'EOF' -with open('/tmp/allowed_file.txt', 'r') as f: - print(f.read()) -EOF`); - const result = await env.exec("python3 /tmp/test_read.py"); - expect(result.stdout).toContain("allowed content"); - expect(result.exitCode).toBe(0); - }); - - it("should allow normal imports", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - "python3 -c \"import json; print(json.dumps({'a': 1}))\"", - ); - expect(result.stdout).toBe('{"a": 1}\n'); - expect(result.exitCode).toBe(0); - }); - - it("should allow list comprehensions and lambdas", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - 'python3 -c "print(list(map(lambda x: x*2, [1,2,3])))"', - ); - expect(result.stdout).toBe("[2, 4, 6]\n"); - expect(result.exitCode).toBe(0); - }); - - it("should allow os.getcwd and os.chdir", async () => { - const env = new Bash({ python: true }); - await env.exec("mkdir -p /tmp/test_chdir_dir"); - const result = await env.exec(`python3 -c " -import os -os.chdir('/tmp/test_chdir_dir') -print(os.getcwd()) -"`); - expect(result.stdout).toBe("/tmp/test_chdir_dir\n"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/commands/python3/python3.stdlib.test.ts b/src/commands/python3/python3.stdlib.test.ts deleted file mode 100644 index 33a9b6d1..00000000 --- a/src/commands/python3/python3.stdlib.test.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("python3 standard library", () => { - describe("json module", () => { - it("should serialize to JSON", { timeout: 60000 }, async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "import json; print(json.dumps({'name': 'test', 'value': 42}))"`, - ); - expect(result.stderr).toBe(""); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed).toEqual({ name: "test", value: 42 }); - expect(result.exitCode).toBe(0); - }); - - it("should parse JSON", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "import json; data = json.loads('[1, 2, 3]'); print(sum(data))"`, - ); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("6\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle nested JSON", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_json.py << 'EOF' -import json -data = {"users": [{"name": "alice"}, {"name": "bob"}]} -print(json.dumps(data, sort_keys=True)) -EOF`); - const result = await env.exec(`python3 /tmp/test_json.py`); - expect(result.stderr).toBe(""); - const parsed = JSON.parse(result.stdout.trim()); - expect(parsed.users[0].name).toBe("alice"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("math module", () => { - it("should calculate sqrt", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "import math; print(math.sqrt(16))"`, - ); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("4.0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should provide constants", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "import math; print(round(math.pi, 5))"`, - ); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("3.14159\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle trigonometry", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "import math; print(int(math.sin(math.pi/2)))"`, - ); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("1\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("re module", () => { - it("should match patterns", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "import re; print(bool(re.match(r'hello', 'hello world')))"`, - ); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("True\n"); - expect(result.exitCode).toBe(0); - }); - - it("should find all matches", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_regex.py << 'EOF' -import re -text = "cat bat rat" -matches = re.findall(r'[cbr]at', text) -print(matches) -EOF`); - const result = await env.exec(`python3 /tmp/test_regex.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("['cat', 'bat', 'rat']\n"); - expect(result.exitCode).toBe(0); - }); - - it("should substitute patterns", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "import re; print(re.sub(r'\\\\d+', 'X', 'a1b2c3'))"`, - ); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("aXbXcX\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("datetime module", () => { - it("should create dates", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_date.py << 'EOF' -from datetime import date -d = date(2024, 1, 15) -print(d.year, d.month, d.day) -EOF`); - const result = await env.exec(`python3 /tmp/test_date.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("2024 1 15\n"); - expect(result.exitCode).toBe(0); - }); - - it("should format dates", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_dateformat.py << 'EOF' -from datetime import datetime -dt = datetime(2024, 6, 15, 10, 30) -print(dt.strftime("%Y-%m-%d")) -EOF`); - const result = await env.exec(`python3 /tmp/test_dateformat.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("2024-06-15\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("collections module", () => { - it("should use Counter", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_counter.py << 'EOF' -from collections import Counter -c = Counter('abracadabra') -print(c['a']) -EOF`); - const result = await env.exec(`python3 /tmp/test_counter.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("5\n"); - expect(result.exitCode).toBe(0); - }); - - it("should use defaultdict", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_defaultdict.py << 'EOF' -from collections import defaultdict -d = defaultdict(list) -d['key'].append(1) -d['key'].append(2) -print(d['key']) -EOF`); - const result = await env.exec(`python3 /tmp/test_defaultdict.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("[1, 2]\n"); - expect(result.exitCode).toBe(0); - }); - - it("should use OrderedDict", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_ordereddict.py << 'EOF' -from collections import OrderedDict -d = OrderedDict() -d['a'] = 1 -d['b'] = 2 -d['c'] = 3 -print(list(d.keys())) -EOF`); - const result = await env.exec(`python3 /tmp/test_ordereddict.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("['a', 'b', 'c']\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("itertools module", () => { - it("should use chain", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_chain.py << 'EOF' -from itertools import chain -result = list(chain([1, 2], [3, 4])) -print(result) -EOF`); - const result = await env.exec(`python3 /tmp/test_chain.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("[1, 2, 3, 4]\n"); - expect(result.exitCode).toBe(0); - }); - - it("should use combinations", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_combinations.py << 'EOF' -from itertools import combinations -result = list(combinations('ABC', 2)) -print(result) -EOF`); - const result = await env.exec(`python3 /tmp/test_combinations.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("[('A', 'B'), ('A', 'C'), ('B', 'C')]\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("functools module", () => { - it("should use reduce", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_reduce.py << 'EOF' -from functools import reduce -result = reduce(lambda x, y: x + y, [1, 2, 3, 4]) -print(result) -EOF`); - const result = await env.exec(`python3 /tmp/test_reduce.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("10\n"); - expect(result.exitCode).toBe(0); - }); - - it("should use lru_cache", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_lru_cache.py << 'EOF' -from functools import lru_cache - -@lru_cache(maxsize=None) -def fib(n): - if n < 2: - return n - return fib(n-1) + fib(n-2) - -print(fib(10)) -EOF`); - const result = await env.exec(`python3 /tmp/test_lru_cache.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("55\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("hashlib module", () => { - it("should calculate md5", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_md5.py << 'EOF' -import hashlib -h = hashlib.md5(b'hello').hexdigest() -print(h) -EOF`); - const result = await env.exec(`python3 /tmp/test_md5.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("5d41402abc4b2a76b9719d911017c592\n"); - expect(result.exitCode).toBe(0); - }); - - it("should calculate sha256", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_sha256.py << 'EOF' -import hashlib -h = hashlib.sha256(b'hello').hexdigest() -print(h[:16]) -EOF`); - const result = await env.exec(`python3 /tmp/test_sha256.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("2cf24dba5fb0a30e\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("base64 module", () => { - it("should encode base64", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_b64encode.py << 'EOF' -import base64 -encoded = base64.b64encode(b'hello world').decode() -print(encoded) -EOF`); - const result = await env.exec(`python3 /tmp/test_b64encode.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("aGVsbG8gd29ybGQ=\n"); - expect(result.exitCode).toBe(0); - }); - - it("should decode base64", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_b64decode.py << 'EOF' -import base64 -decoded = base64.b64decode('aGVsbG8gd29ybGQ=').decode() -print(decoded) -EOF`); - const result = await env.exec(`python3 /tmp/test_b64decode.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("hello world\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("pathlib module", () => { - it("should handle path operations", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_pathlib.py << 'EOF' -from pathlib import PurePosixPath -p = PurePosixPath('/home/user/file.txt') -print(p.name) -print(p.suffix) -print(p.parent) -EOF`); - const result = await env.exec(`python3 /tmp/test_pathlib.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("file.txt\n.txt\n/home/user\n"); - expect(result.exitCode).toBe(0); - }); - - it("should join paths", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_pathjoin.py << 'EOF' -from pathlib import PurePosixPath -p = PurePosixPath('/home') / 'user' / 'file.txt' -print(p) -EOF`); - const result = await env.exec(`python3 /tmp/test_pathjoin.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("/home/user/file.txt\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("random module", () => { - it("should generate random choice", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_random.py << 'EOF' -import random -random.seed(42) -print(random.choice([1, 2, 3, 4, 5])) -EOF`); - const result = await env.exec(`python3 /tmp/test_random.py`); - expect(result.stderr).toBe(""); - // With seed 42, the choice should be deterministic - expect(["1\n", "2\n", "3\n", "4\n", "5\n"]).toContain(result.stdout); - expect(result.exitCode).toBe(0); - }); - - it("should shuffle list", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_shuffle.py << 'EOF' -import random -random.seed(42) -items = [1, 2, 3] -random.shuffle(items) -print(len(items)) -EOF`); - const result = await env.exec(`python3 /tmp/test_shuffle.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("3\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("textwrap module", () => { - it("should wrap text", async () => { - const env = new Bash({ python: true }); - await env.exec(`cat > /tmp/test_textwrap.py << 'EOF' -import textwrap -text = "Hello World" -wrapped = textwrap.fill(text, width=5) -print(wrapped) -EOF`); - const result = await env.exec(`python3 /tmp/test_textwrap.py`); - expect(result.stderr).toBe(""); - expect(result.stdout).toBe("Hello\nWorld\n"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/commands/python3/python3.test.ts b/src/commands/python3/python3.test.ts deleted file mode 100644 index 76f529fc..00000000 --- a/src/commands/python3/python3.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -// Note: These tests use Pyodide which downloads ~30MB on first run. -// The first test will be slow, subsequent tests reuse the cached instance. - -describe("python3", () => { - describe("basic execution", () => { - it( - "should execute simple print statement", - { timeout: 60000 }, - async () => { - const env = new Bash({ python: true }); - const result = await env.exec('python3 -c "print(1 + 2)"'); - expect(result.stdout).toBe("3\n"); - expect(result.exitCode).toBe(0); - }, - ); - - it("should execute arithmetic", async () => { - const env = new Bash({ python: true }); - const result = await env.exec('python3 -c "print(10 * 5 + 2)"'); - expect(result.stdout).toBe("52\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle string operations", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "print('hello' + ' ' + 'world')"`, - ); - expect(result.stdout).toBe("hello world\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("help and version", () => { - it("should show help with --help", async () => { - const env = new Bash({ python: true }); - const result = await env.exec("python3 --help"); - expect(result.stdout).toContain("python3"); - expect(result.stdout).toContain("Execute Python code"); - expect(result.exitCode).toBe(0); - }); - - it("should show version with --version", async () => { - const env = new Bash({ python: true }); - const result = await env.exec("python3 --version"); - expect(result.stdout).toContain("Python 3."); - expect(result.exitCode).toBe(0); - }); - - it("should show version with -V", async () => { - const env = new Bash({ python: true }); - const result = await env.exec("python3 -V"); - expect(result.stdout).toContain("Python 3."); - expect(result.exitCode).toBe(0); - }); - }); - - describe("python alias", () => { - it("should work as python (alias)", async () => { - const env = new Bash({ python: true }); - const result = await env.exec('python -c "print(42)"'); - expect(result.stdout).toBe("42\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("stdin input", () => { - it("should read Python code from stdin", async () => { - const env = new Bash({ python: true }); - const result = await env.exec('echo "print(123)" | python3'); - expect(result.stdout).toBe("123\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("error handling", () => { - it("should report syntax errors", async () => { - const env = new Bash({ python: true }); - const result = await env.exec('python3 -c "print(1 +"'); - expect(result.stderr).toContain("SyntaxError"); - expect(result.exitCode).toBe(1); - }); - - it("should report runtime errors", async () => { - const env = new Bash({ python: true }); - const result = await env.exec('python3 -c "1 / 0"'); - expect(result.stderr).toContain("ZeroDivisionError"); - expect(result.exitCode).toBe(1); - }); - - it("should report name errors", async () => { - const env = new Bash({ python: true }); - const result = await env.exec('python3 -c "print(undefined_var)"'); - expect(result.stderr).toContain("NameError"); - expect(result.exitCode).toBe(1); - }); - - it("should error on missing -c argument", async () => { - const env = new Bash({ python: true }); - const result = await env.exec("python3 -c"); - expect(result.stderr).toContain("requires an argument"); - expect(result.exitCode).toBe(2); - }); - - it("should error on unknown option", async () => { - const env = new Bash({ python: true }); - const result = await env.exec('python3 --unknown "print(1)"'); - expect(result.stderr).toContain("unrecognized option"); - expect(result.exitCode).toBe(2); - }); - - it("should error on missing script file", async () => { - const env = new Bash({ python: true }); - const result = await env.exec("python3 /nonexistent.py"); - expect(result.stderr).toContain("can't open file"); - expect(result.stderr).toContain("No such file"); - expect(result.exitCode).toBe(2); - }); - }); - - describe("Python features", () => { - it("should support list comprehensions", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - 'python3 -c "print([x*2 for x in range(5)])"', - ); - expect(result.stdout).toBe("[0, 2, 4, 6, 8]\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support dictionaries", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - `python3 -c "d = {'a': 1, 'b': 2}; print(d['a'])"`, - ); - expect(result.stdout).toBe("1\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support lambdas", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - 'python3 -c "add = lambda a, b: a + b; print(add(3, 4))"', - ); - expect(result.stdout).toBe("7\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support imports (standard library)", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - "python3 -c \"import json; print(json.dumps({'a': 1}))\"", - ); - expect(result.stdout).toBe('{"a": 1}\n'); - expect(result.exitCode).toBe(0); - }); - - it("should support math module", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - 'python3 -c "import math; print(int(math.sqrt(16)))"', - ); - expect(result.stdout).toBe("4\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("environment", () => { - it("should access environment variables", async () => { - const env = new Bash({ python: true }); - const result = await env.exec(` -export MY_VAR=hello -python3 -c "import os; print(os.environ.get('MY_VAR', 'not found'))" -`); - expect(result.stdout).toBe("hello\n"); - expect(result.exitCode).toBe(0); - }); - - it("should have correct sys.argv[0] for -c", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - 'python3 -c "import sys; print(sys.argv[0])"', - ); - expect(result.stdout).toBe("-c\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("stderr", () => { - it("should write to stderr", async () => { - const env = new Bash({ python: true }); - const result = await env.exec( - "python3 -c \"import sys; print('error', file=sys.stderr)\"", - ); - expect(result.stderr).toContain("error"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("concurrent executions", () => { - it("should handle multiple concurrent executions correctly", async () => { - // Run multiple Python commands in parallel and verify each gets correct result - const env1 = new Bash({ python: true }); - const env2 = new Bash({ python: true }); - const env3 = new Bash({ python: true }); - - const [result1, result2, result3] = await Promise.all([ - env1.exec('python3 -c "print(111)"'), - env2.exec('python3 -c "print(222)"'), - env3.exec('python3 -c "print(333)"'), - ]); - - // Each result should have the correct output (no mixing) - expect(result1.stdout).toBe("111\n"); - expect(result1.exitCode).toBe(0); - - expect(result2.stdout).toBe("222\n"); - expect(result2.exitCode).toBe(0); - - expect(result3.stdout).toBe("333\n"); - expect(result3.exitCode).toBe(0); - }); - - it("should queue concurrent executions and complete all", async () => { - const env = new Bash({ python: true }); - - // Launch 5 concurrent executions - const results = await Promise.all([ - env.exec('python3 -c "print(1)"'), - env.exec('python3 -c "print(2)"'), - env.exec('python3 -c "print(3)"'), - env.exec('python3 -c "print(4)"'), - env.exec('python3 -c "print(5)"'), - ]); - - // All should complete successfully - for (let i = 0; i < 5; i++) { - expect(results[i].stdout).toBe(`${i + 1}\n`); - expect(results[i].exitCode).toBe(0); - } - }); - }); -}); diff --git a/src/commands/python3/python3.ts b/src/commands/python3/python3.ts deleted file mode 100644 index eb757ad1..00000000 --- a/src/commands/python3/python3.ts +++ /dev/null @@ -1,363 +0,0 @@ -/** - * python3 - Execute Python code via Pyodide (Python in WebAssembly) - * - * Runs Python code in an isolated worker thread with access to the - * virtual filesystem via SharedArrayBuffer bridge. - * - * This command is Node.js only (uses worker_threads). - */ - -import { fileURLToPath } from "node:url"; -import { Worker } from "node:worker_threads"; -import { mapToRecord } from "../../helpers/env.js"; -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { hasHelpFlag, showHelp } from "../help.js"; -import { FsBridgeHandler } from "./fs-bridge-handler.js"; -import { createSharedBuffer } from "./protocol.js"; -import type { WorkerInput, WorkerOutput } from "./worker.js"; - -/** Default Python execution timeout in milliseconds (30 seconds for Pyodide load) */ -const DEFAULT_PYTHON_TIMEOUT_MS = 30000; - -const python3Help = { - name: "python3", - summary: "Execute Python code via Pyodide", - usage: "python3 [OPTIONS] [-c CODE | -m MODULE | FILE] [ARGS...]", - description: [ - "Execute Python code using Pyodide (Python compiled to WebAssembly).", - "", - "This command runs Python in a sandboxed environment with access to", - "the virtual filesystem. Only Pyodide-bundled packages are available.", - ], - options: [ - "-c CODE Execute CODE as Python script", - "-m MODULE Run library module as a script", - "--version Show Python version", - "--help Show this help", - ], - examples: [ - 'python3 -c "print(1 + 2)"', - 'python3 -c "import sys; print(sys.version)"', - "python3 script.py", - "python3 script.py arg1 arg2", - "echo 'print(\"hello\")' | python3", - ], - notes: [ - "Pyodide runs in WebAssembly, so execution may be slower than native Python.", - "Only packages bundled with Pyodide are available (no pip install).", - "First execution loads Pyodide (~30MB), subsequent calls are faster.", - "Maximum execution time is 30 seconds by default.", - ], -}; - -interface ParsedArgs { - code: string | null; - module: string | null; - scriptFile: string | null; - showVersion: boolean; - scriptArgs: string[]; -} - -function parseArgs(args: string[]): ParsedArgs | ExecResult { - const result: ParsedArgs = { - code: null, - module: null, - scriptFile: null, - showVersion: false, - scriptArgs: [], - }; - - if (args.length === 0) { - return result; - } - - const firstArgIndex = args.findIndex((arg) => { - return !arg.startsWith("-") || arg === "-" || arg === "--"; - }); - - for ( - let i = 0; - i < (firstArgIndex === -1 ? args.length : firstArgIndex); - i++ - ) { - const arg = args[i]; - - if (arg === "-c") { - if (i + 1 >= args.length) { - return { - stdout: "", - stderr: "python3: option requires an argument -- 'c'\n", - exitCode: 2, - }; - } - result.code = args[i + 1]; - result.scriptArgs = args.slice(i + 2); - return result; - } - - if (arg === "-m") { - if (i + 1 >= args.length) { - return { - stdout: "", - stderr: "python3: option requires an argument -- 'm'\n", - exitCode: 2, - }; - } - result.module = args[i + 1]; - result.scriptArgs = args.slice(i + 2); - return result; - } - - if (arg === "--version" || arg === "-V") { - result.showVersion = true; - return result; - } - - if (arg.startsWith("-") && arg !== "-") { - return { - stdout: "", - stderr: `python3: unrecognized option '${arg}'\n`, - exitCode: 2, - }; - } - } - - if (firstArgIndex !== -1) { - const arg = args[firstArgIndex]; - if (arg === "--") { - if (firstArgIndex + 1 < args.length) { - result.scriptFile = args[firstArgIndex + 1]; - result.scriptArgs = args.slice(firstArgIndex + 2); - } - } else { - result.scriptFile = arg; - result.scriptArgs = args.slice(firstArgIndex + 1); - } - } - - return result; -} - -// Singleton worker for reusing Pyodide instance -let sharedWorker: Worker | null = null; -let workerIdleTimeout: ReturnType | null = null; - -// Queue for serializing Python executions (Pyodide is single-threaded) -type QueuedExecution = { - input: WorkerInput; - resolve: (result: WorkerOutput) => void; -}; -const executionQueue: QueuedExecution[] = []; -let currentExecution: QueuedExecution | null = null; - -const workerPath = fileURLToPath(new URL("./worker.js", import.meta.url)); - -function processNextExecution(): void { - if (currentExecution || executionQueue.length === 0) { - return; - } - - const next = executionQueue.shift(); - if (!next) { - return; - } - currentExecution = next; - const worker = getOrCreateWorker(); - worker.postMessage(currentExecution.input); -} - -function getOrCreateWorker(): Worker { - // Clear any pending idle timeout - if (workerIdleTimeout) { - clearTimeout(workerIdleTimeout); - workerIdleTimeout = null; - } - - if (sharedWorker) { - return sharedWorker; - } - - sharedWorker = new Worker(workerPath); - - sharedWorker.on("message", (result: WorkerOutput) => { - if (currentExecution) { - currentExecution.resolve(result); - currentExecution = null; - } - // Process next queued execution or schedule termination - if (executionQueue.length > 0) { - processNextExecution(); - } else { - scheduleWorkerTermination(); - } - }); - - sharedWorker.on("error", (err: Error) => { - if (currentExecution) { - currentExecution.resolve({ success: false, error: err.message }); - currentExecution = null; - } - // Reject all queued executions - for (const queued of executionQueue) { - queued.resolve({ success: false, error: "Worker crashed" }); - } - executionQueue.length = 0; - sharedWorker = null; - }); - - sharedWorker.on("exit", () => { - sharedWorker = null; - }); - - return sharedWorker; -} - -function scheduleWorkerTermination(): void { - // Terminate worker after 5 seconds of inactivity - workerIdleTimeout = setTimeout(() => { - if (sharedWorker && !currentExecution && executionQueue.length === 0) { - sharedWorker.terminate(); - sharedWorker = null; - } - }, 5000); -} - -/** - * Execute Python code in a worker with filesystem bridge. - */ -async function executePython( - pythonCode: string, - ctx: CommandContext, - scriptPath?: string, - scriptArgs: string[] = [], -): Promise { - const sharedBuffer = createSharedBuffer(); - const bridgeHandler = new FsBridgeHandler( - sharedBuffer, - ctx.fs, - ctx.cwd, - ctx.fetch, - ); - - const timeoutMs = ctx.limits?.maxPythonTimeoutMs ?? DEFAULT_PYTHON_TIMEOUT_MS; - - const workerInput: WorkerInput = { - sharedBuffer, - pythonCode, - cwd: ctx.cwd, - // Convert Map to null-prototype object for worker transfer - // (Maps can't be postMessage'd, and null-prototype prevents prototype pollution) - env: mapToRecord(ctx.env), - args: scriptArgs, - scriptPath, - }; - - const workerPromise = new Promise((resolve) => { - const timeout = setTimeout(() => { - resolve({ - success: false, - error: `Execution timeout: exceeded ${timeoutMs}ms limit`, - }); - }, timeoutMs); - - const wrappedResolve = (result: WorkerOutput) => { - clearTimeout(timeout); - resolve(result); - }; - - // Queue the execution (serialized since Pyodide is single-threaded) - executionQueue.push({ input: workerInput, resolve: wrappedResolve }); - processNextExecution(); - }); - - const [bridgeOutput, workerResult] = await Promise.all([ - bridgeHandler.run(timeoutMs), - workerPromise.catch((e) => ({ - success: false, - error: (e as Error).message, - })), - ]); - - if (!workerResult.success && workerResult.error) { - return { - stdout: bridgeOutput.stdout, - stderr: `${bridgeOutput.stderr}python3: ${workerResult.error}\n`, - exitCode: bridgeOutput.exitCode || 1, - }; - } - - return bridgeOutput; -} - -export const python3Command: Command = { - name: "python3", - - async execute(args: string[], ctx: CommandContext): Promise { - if (hasHelpFlag(args)) { - return showHelp(python3Help); - } - - const parsed = parseArgs(args); - if ("exitCode" in parsed) return parsed; - - if (parsed.showVersion) { - return { - stdout: "Python 3.12.1 (Pyodide)\n", - stderr: "", - exitCode: 0, - }; - } - - let pythonCode: string; - let scriptPath: string | undefined; - - if (parsed.code !== null) { - pythonCode = parsed.code; - scriptPath = "-c"; - } else if (parsed.module !== null) { - pythonCode = `import runpy; runpy.run_module('${parsed.module}', run_name='__main__')`; - scriptPath = parsed.module; - } else if (parsed.scriptFile !== null) { - const filePath = ctx.fs.resolvePath(ctx.cwd, parsed.scriptFile); - - if (!(await ctx.fs.exists(filePath))) { - return { - stdout: "", - stderr: `python3: can't open file '${parsed.scriptFile}': [Errno 2] No such file or directory\n`, - exitCode: 2, - }; - } - - try { - pythonCode = await ctx.fs.readFile(filePath); - scriptPath = parsed.scriptFile; - } catch (e) { - return { - stdout: "", - stderr: `python3: can't open file '${parsed.scriptFile}': ${(e as Error).message}\n`, - exitCode: 2, - }; - } - } else if (ctx.stdin.trim()) { - pythonCode = ctx.stdin; - scriptPath = ""; - } else { - return { - stdout: "", - stderr: - "python3: no input provided (use -c CODE, -m MODULE, or provide a script file)\n", - exitCode: 2, - }; - } - - return executePython(pythonCode, ctx, scriptPath, parsed.scriptArgs); - }, -}; - -export const pythonCommand: Command = { - name: "python", - - async execute(args: string[], ctx: CommandContext): Promise { - return python3Command.execute(args, ctx); - }, -}; diff --git a/src/commands/python3/sync-fs-backend.ts b/src/commands/python3/sync-fs-backend.ts deleted file mode 100644 index 21b0950a..00000000 --- a/src/commands/python3/sync-fs-backend.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * Worker-side synchronous filesystem backend - * - * Runs in the worker thread and makes synchronous calls to the main thread - * via SharedArrayBuffer + Atomics. - */ - -import { - Flags, - OpCode, - type OpCodeType, - ProtocolBuffer, - Status, -} from "./protocol.js"; - -/** - * Synchronous filesystem backend for Pyodide worker. - */ -export class SyncFsBackend { - private protocol: ProtocolBuffer; - - constructor(sharedBuffer: SharedArrayBuffer) { - this.protocol = new ProtocolBuffer(sharedBuffer); - } - - private execSync( - opCode: OpCodeType, - path: string, - data?: Uint8Array, - flags = 0, - mode = 0, - ): { success: boolean; result?: Uint8Array; error?: string } { - this.protocol.reset(); - this.protocol.setOpCode(opCode); - this.protocol.setPath(path); - this.protocol.setFlags(flags); - this.protocol.setMode(mode); - if (data) { - this.protocol.setData(data); - } - - this.protocol.setStatus(Status.READY); - this.protocol.notify(); - - // Wait for main thread to process (with timeout) - const waitResult = this.protocol.waitForResult(5000); - if (waitResult === "timed-out") { - return { success: false, error: "Operation timed out" }; - } - - const status = this.protocol.getStatus(); - if (status === Status.SUCCESS) { - return { success: true, result: this.protocol.getResult() }; - } - return { - success: false, - error: - this.protocol.getResultAsString() || - `Error code: ${this.protocol.getErrorCode()}`, - }; - } - - readFile(path: string): Uint8Array { - const result = this.execSync(OpCode.READ_FILE, path); - if (!result.success) { - throw new Error(result.error || "Failed to read file"); - } - return result.result ?? new Uint8Array(0); - } - - writeFile(path: string, data: Uint8Array): void { - const result = this.execSync(OpCode.WRITE_FILE, path, data); - if (!result.success) { - throw new Error(result.error || "Failed to write file"); - } - } - - stat(path: string): { - isFile: boolean; - isDirectory: boolean; - isSymbolicLink: boolean; - mode: number; - size: number; - mtime: Date; - } { - const result = this.execSync(OpCode.STAT, path); - if (!result.success) { - throw new Error(result.error || "Failed to stat"); - } - return this.protocol.decodeStat(); - } - - lstat(path: string): { - isFile: boolean; - isDirectory: boolean; - isSymbolicLink: boolean; - mode: number; - size: number; - mtime: Date; - } { - const result = this.execSync(OpCode.LSTAT, path); - if (!result.success) { - throw new Error(result.error || "Failed to lstat"); - } - return this.protocol.decodeStat(); - } - - readdir(path: string): string[] { - const result = this.execSync(OpCode.READDIR, path); - if (!result.success) { - throw new Error(result.error || "Failed to readdir"); - } - return JSON.parse(this.protocol.getResultAsString()); - } - - mkdir(path: string, recursive = false): void { - const flags = recursive ? Flags.MKDIR_RECURSIVE : 0; - const result = this.execSync(OpCode.MKDIR, path, undefined, flags); - if (!result.success) { - throw new Error(result.error || "Failed to mkdir"); - } - } - - rm(path: string, recursive = false, force = false): void { - let flags = 0; - if (recursive) flags |= Flags.RECURSIVE; - if (force) flags |= Flags.FORCE; - const result = this.execSync(OpCode.RM, path, undefined, flags); - if (!result.success) { - throw new Error(result.error || "Failed to rm"); - } - } - - exists(path: string): boolean { - const result = this.execSync(OpCode.EXISTS, path); - if (!result.success) { - return false; - } - return result.result?.[0] === 1; - } - - appendFile(path: string, data: Uint8Array): void { - const result = this.execSync(OpCode.APPEND_FILE, path, data); - if (!result.success) { - throw new Error(result.error || "Failed to append file"); - } - } - - symlink(target: string, linkPath: string): void { - const targetData = new TextEncoder().encode(target); - const result = this.execSync(OpCode.SYMLINK, linkPath, targetData); - if (!result.success) { - throw new Error(result.error || "Failed to symlink"); - } - } - - readlink(path: string): string { - const result = this.execSync(OpCode.READLINK, path); - if (!result.success) { - throw new Error(result.error || "Failed to readlink"); - } - return this.protocol.getResultAsString(); - } - - chmod(path: string, mode: number): void { - const result = this.execSync(OpCode.CHMOD, path, undefined, 0, mode); - if (!result.success) { - throw new Error(result.error || "Failed to chmod"); - } - } - - realpath(path: string): string { - const result = this.execSync(OpCode.REALPATH, path); - if (!result.success) { - throw new Error(result.error || "Failed to realpath"); - } - return this.protocol.getResultAsString(); - } - - writeStdout(data: string): void { - const encoded = new TextEncoder().encode(data); - this.execSync(OpCode.WRITE_STDOUT, "", encoded); - } - - writeStderr(data: string): void { - const encoded = new TextEncoder().encode(data); - this.execSync(OpCode.WRITE_STDERR, "", encoded); - } - - exit(code: number): void { - this.execSync(OpCode.EXIT, "", undefined, code); - } - - /** - * Make an HTTP request through the main thread's secureFetch. - * Returns the response as a parsed object. - */ - httpRequest( - url: string, - options?: { - method?: string; - headers?: Record; - body?: string; - }, - ): { - status: number; - statusText: string; - headers: Record; - body: string; - url: string; - } { - const requestData = options - ? new TextEncoder().encode(JSON.stringify(options)) - : undefined; - const result = this.execSync(OpCode.HTTP_REQUEST, url, requestData); - if (!result.success) { - throw new Error(result.error || "HTTP request failed"); - } - const responseJson = new TextDecoder().decode(result.result); - return JSON.parse(responseJson); - } -} diff --git a/src/commands/python3/worker.ts b/src/commands/python3/worker.ts deleted file mode 100644 index 273e345e..00000000 --- a/src/commands/python3/worker.ts +++ /dev/null @@ -1,1248 +0,0 @@ -/** - * Worker thread for Python execution via Pyodide. - * Keeps Pyodide loaded and handles multiple execution requests. - * - * Defense-in-depth activates AFTER Pyodide loads (WASM init needs unrestricted JS). - * User Python code runs with dangerous globals blocked. - */ - -import { parentPort, workerData } from "node:worker_threads"; -import { loadPyodide, type PyodideInterface } from "pyodide"; -import { - WorkerDefenseInDepth, - type WorkerDefenseStats, -} from "../../security/index.js"; -import { SyncFsBackend } from "./sync-fs-backend.js"; - -export interface WorkerInput { - sharedBuffer: SharedArrayBuffer; - pythonCode: string; - cwd: string; - env: Record; - args: string[]; - scriptPath?: string; -} - -export interface WorkerOutput { - success: boolean; - error?: string; - /** Defense-in-depth stats if enabled */ - defenseStats?: WorkerDefenseStats; -} - -let pyodideInstance: PyodideInterface | null = null; -let pyodideLoading: Promise | null = null; - -async function getPyodide(): Promise { - if (pyodideInstance) { - return pyodideInstance; - } - if (pyodideLoading) { - return pyodideLoading; - } - pyodideLoading = loadPyodide(); - pyodideInstance = await pyodideLoading; - return pyodideInstance; -} - -/** - * Create a HOSTFS backend for Pyodide that bridges to just-bash's filesystem. - * This follows the Emscripten NODEFS pattern but uses SyncFsBackend. - */ - -// Emscripten FS type definitions (based on Emscripten's internal structures) -interface EmscriptenNode { - name: string; - mode: number; - parent: EmscriptenNode; - mount: EmscriptenMount; - id: number; - node_ops?: EmscriptenNodeOps; - stream_ops?: EmscriptenStreamOps; - // Custom properties for HOSTFS - hostPath?: string; -} - -interface EmscriptenStream { - node: EmscriptenNode; - flags: number; - position: number; - // Custom properties for HOSTFS - hostContent?: Uint8Array; - hostModified?: boolean; - hostPath?: string; -} - -interface EmscriptenMount { - opts: { root: string }; -} - -interface EmscriptenNodeOps { - getattr: (node: EmscriptenNode) => EmscriptenStat; - setattr: ( - node: EmscriptenNode, - attr: { mode?: number; size?: number }, - ) => void; - lookup: (parent: EmscriptenNode, name: string) => EmscriptenNode; - mknod: ( - parent: EmscriptenNode, - name: string, - mode: number, - dev: number, - ) => EmscriptenNode; - rename: ( - oldNode: EmscriptenNode, - newDir: EmscriptenNode, - newName: string, - ) => void; - unlink: (parent: EmscriptenNode, name: string) => void; - rmdir: (parent: EmscriptenNode, name: string) => void; - readdir: (node: EmscriptenNode) => string[]; - symlink: (parent: EmscriptenNode, newName: string, oldPath: string) => void; - readlink: (node: EmscriptenNode) => string; -} - -interface EmscriptenStreamOps { - open: (stream: EmscriptenStream) => void; - close: (stream: EmscriptenStream) => void; - read: ( - stream: EmscriptenStream, - buffer: Uint8Array, - offset: number, - length: number, - position: number, - ) => number; - write: ( - stream: EmscriptenStream, - buffer: Uint8Array, - offset: number, - length: number, - position: number, - ) => number; - llseek: (stream: EmscriptenStream, offset: number, whence: number) => number; -} - -interface EmscriptenStat { - dev: number; - ino: number; - mode: number; - nlink: number; - uid: number; - gid: number; - rdev: number; - size: number; - atime: Date; - mtime: Date; - ctime: Date; - blksize: number; - blocks: number; -} - -interface EmscriptenFS { - isDir: (mode: number) => boolean; - isFile: (mode: number) => boolean; - isLink: (mode: number) => boolean; - createNode: ( - parent: EmscriptenNode | null, - name: string, - mode: number, - dev?: number, - ) => EmscriptenNode; - ErrnoError: new (errno: number) => Error; - mkdir: (path: string) => void; - unmount: (path: string) => void; - mount: ( - type: EmscriptenFSType, - opts: { root: string }, - mountpoint: string, - ) => void; -} - -interface EmscriptenFSType { - mount: (mount: EmscriptenMount) => EmscriptenNode; - createNode: ( - parent: EmscriptenNode | null, - name: string, - mode: number, - dev?: number, - ) => EmscriptenNode; - node_ops: EmscriptenNodeOps; - stream_ops: EmscriptenStreamOps; -} - -interface EmscriptenPATH { - join: (...paths: string[]) => string; - join2: (path1: string, path2: string) => string; -} - -function createHOSTFS( - backend: SyncFsBackend, - FS: EmscriptenFS, - PATH: EmscriptenPATH, -) { - // @banned-pattern-ignore: only accessed via dot notation with literal keys - const ERRNO_CODES: Record = { - EPERM: 63, - ENOENT: 44, - EIO: 29, - EBADF: 8, - EAGAIN: 6, - EACCES: 2, - EBUSY: 10, - EEXIST: 20, - ENOTDIR: 54, - EISDIR: 31, - EINVAL: 28, - EMFILE: 33, - ENOSPC: 51, - ESPIPE: 70, - EROFS: 69, - ENOTEMPTY: 55, - ENOSYS: 52, - ENOTSUP: 138, - ENODATA: 42, - }; - - function realPath(node: EmscriptenNode): string { - const parts: string[] = []; - while (node.parent !== node) { - parts.push(node.name); - node = node.parent; - } - parts.push(node.mount.opts.root); - parts.reverse(); - return PATH.join(...parts); - } - - function tryFSOperation(f: () => T): T { - try { - return f(); - } catch (e: unknown) { - const msg = - (e as Error)?.message?.toLowerCase() || - (typeof e === "string" ? e.toLowerCase() : ""); - let code = ERRNO_CODES.EIO; - if (msg.includes("no such file") || msg.includes("not found")) { - code = ERRNO_CODES.ENOENT; - } else if (msg.includes("is a directory")) { - code = ERRNO_CODES.EISDIR; - } else if (msg.includes("not a directory")) { - code = ERRNO_CODES.ENOTDIR; - } else if (msg.includes("already exists")) { - code = ERRNO_CODES.EEXIST; - } else if (msg.includes("permission")) { - code = ERRNO_CODES.EACCES; - } else if (msg.includes("not empty")) { - code = ERRNO_CODES.ENOTEMPTY; - } - throw new FS.ErrnoError(code); - } - } - - function getMode(path: string): number { - return tryFSOperation(() => { - const stat = backend.stat(path); - let mode = stat.mode & 0o777; - if (stat.isDirectory) { - mode |= 0o40000; // S_IFDIR - } else if (stat.isSymbolicLink) { - mode |= 0o120000; // S_IFLNK - } else { - mode |= 0o100000; // S_IFREG - } - return mode; - }); - } - - const HOSTFS = { - mount(_mount: EmscriptenMount) { - // Create root node as directory - don't call backend during mount - return HOSTFS.createNode(null, "/", 0o40755, 0); - }, - - createNode( - parent: EmscriptenNode | null, - name: string, - mode: number, - dev?: number, - ) { - if (!FS.isDir(mode) && !FS.isFile(mode) && !FS.isLink(mode)) { - throw new FS.ErrnoError(ERRNO_CODES.EINVAL); - } - const node = FS.createNode(parent, name, mode, dev); - node.node_ops = HOSTFS.node_ops; - node.stream_ops = HOSTFS.stream_ops; - return node; - }, - - node_ops: { - getattr(node: EmscriptenNode) { - const path = realPath(node); - return tryFSOperation(() => { - const stat = backend.stat(path); - let mode = stat.mode & 0o777; - if (stat.isDirectory) { - mode |= 0o40000; - } else if (stat.isSymbolicLink) { - mode |= 0o120000; - } else { - mode |= 0o100000; - } - return { - dev: 1, - ino: node.id, - mode, - nlink: 1, - uid: 0, - gid: 0, - rdev: 0, - size: stat.size, - atime: stat.mtime, - mtime: stat.mtime, - ctime: stat.mtime, - blksize: 4096, - blocks: Math.ceil(stat.size / 512), - }; - }); - }, - - setattr(node: EmscriptenNode, attr: { mode?: number; size?: number }) { - const path = realPath(node); - const mode = attr.mode; - if (mode !== undefined) { - tryFSOperation(() => backend.chmod(path, mode)); - node.mode = mode; - } - if (attr.size !== undefined) { - tryFSOperation(() => { - const content = backend.readFile(path); - const newContent = content.slice(0, attr.size); - backend.writeFile(path, newContent); - }); - } - }, - - lookup(parent: EmscriptenNode, name: string) { - const path = PATH.join2(realPath(parent), name); - const mode = getMode(path); - return HOSTFS.createNode(parent, name, mode); - }, - - mknod(parent: EmscriptenNode, name: string, mode: number, _dev: number) { - const node = HOSTFS.createNode(parent, name, mode, _dev); - const path = realPath(node); - tryFSOperation(() => { - if (FS.isDir(node.mode)) { - backend.mkdir(path, false); - } else { - backend.writeFile(path, new Uint8Array(0)); - } - }); - return node; - }, - - rename(oldNode: EmscriptenNode, newDir: EmscriptenNode, newName: string) { - const oldPath = realPath(oldNode); - const newPath = PATH.join2(realPath(newDir), newName); - tryFSOperation(() => { - const content = backend.readFile(oldPath); - backend.writeFile(newPath, content); - backend.rm(oldPath, false, false); - }); - oldNode.name = newName; - }, - - unlink(parent: EmscriptenNode, name: string) { - const path = PATH.join2(realPath(parent), name); - tryFSOperation(() => backend.rm(path, false, false)); - }, - - rmdir(parent: EmscriptenNode, name: string) { - const path = PATH.join2(realPath(parent), name); - tryFSOperation(() => backend.rm(path, false, false)); - }, - - readdir(node: EmscriptenNode) { - const path = realPath(node); - return tryFSOperation(() => backend.readdir(path)); - }, - - symlink(parent: EmscriptenNode, newName: string, oldPath: string) { - const newPath = PATH.join2(realPath(parent), newName); - tryFSOperation(() => backend.symlink(oldPath, newPath)); - }, - - readlink(node: EmscriptenNode) { - const path = realPath(node); - return tryFSOperation(() => backend.readlink(path)); - }, - }, - - stream_ops: { - open(stream: EmscriptenStream) { - const path = realPath(stream.node); - const flags = stream.flags; - - const O_WRONLY = 1; - const O_RDWR = 2; - const O_CREAT = 64; - const O_TRUNC = 512; - const O_APPEND = 1024; - - const accessMode = flags & 3; - const isWrite = accessMode === O_WRONLY || accessMode === O_RDWR; - const isCreate = (flags & O_CREAT) !== 0; - const isTruncate = (flags & O_TRUNC) !== 0; - const isAppend = (flags & O_APPEND) !== 0; - - if (FS.isDir(stream.node.mode)) { - return; - } - - let content: Uint8Array; - try { - if (isTruncate && isWrite) { - content = new Uint8Array(0); - } else { - content = backend.readFile(path); - } - } catch (_e) { - if (isCreate && isWrite) { - content = new Uint8Array(0); - } else { - throw new FS.ErrnoError(ERRNO_CODES.ENOENT); - } - } - - stream.hostContent = content; - stream.hostModified = isTruncate && isWrite; - stream.hostPath = path; - - if (isAppend) { - stream.position = content.length; - } - }, - - close(stream: EmscriptenStream) { - const hostPath = stream.hostPath; - const hostContent = stream.hostContent; - if (stream.hostModified && hostContent && hostPath) { - tryFSOperation(() => backend.writeFile(hostPath, hostContent)); - } - delete stream.hostContent; - delete stream.hostModified; - delete stream.hostPath; - }, - - read( - stream: EmscriptenStream, - buffer: Uint8Array, - offset: number, - length: number, - position: number, - ) { - const content = stream.hostContent; - if (!content) return 0; - - const size = content.length; - if (position >= size) return 0; - - const bytesToRead = Math.min(length, size - position); - buffer.set(content.subarray(position, position + bytesToRead), offset); - return bytesToRead; - }, - - write( - stream: EmscriptenStream, - buffer: Uint8Array, - offset: number, - length: number, - position: number, - ) { - let content = stream.hostContent || new Uint8Array(0); - const newSize = Math.max(content.length, position + length); - - if (newSize > content.length) { - const newContent = new Uint8Array(newSize); - newContent.set(content); - content = newContent; - stream.hostContent = content; - } - - content.set(buffer.subarray(offset, offset + length), position); - stream.hostModified = true; - return length; - }, - - llseek(stream: EmscriptenStream, offset: number, whence: number) { - const SEEK_CUR = 1; - const SEEK_END = 2; - - let position = offset; - if (whence === SEEK_CUR) { - position += stream.position; - } else if (whence === SEEK_END) { - if (FS.isFile(stream.node.mode)) { - const content = stream.hostContent; - position += content ? content.length : 0; - } - } - - if (position < 0) { - throw new FS.ErrnoError(ERRNO_CODES.EINVAL); - } - - return position; - }, - }, - }; - - return HOSTFS; -} - -async function runPython(input: WorkerInput): Promise { - const backend = new SyncFsBackend(input.sharedBuffer); - - let pyodide: PyodideInterface; - try { - pyodide = await getPyodide(); - } catch (e) { - return { - success: false, - error: `Failed to load Pyodide: ${(e as Error).message}`, - }; - } - - // Reset stdout/stderr to discard any pending output from previous runs - // (important when worker is reused and previous execution was interrupted) - pyodide.setStdout({ batched: () => {} }); - pyodide.setStderr({ batched: () => {} }); - - // Flush any pending Python output from previous runs - try { - pyodide.runPython(` -import sys -if hasattr(sys.stdout, 'flush'): - sys.stdout.flush() -if hasattr(sys.stderr, 'flush'): - sys.stderr.flush() -`); - } catch (_e) { - // Ignore - sys might not be set up yet - } - - // Set up stdout/stderr capture for this execution - pyodide.setStdout({ - batched: (text: string) => { - backend.writeStdout(`${text}\n`); - }, - }); - - pyodide.setStderr({ - batched: (text: string) => { - backend.writeStderr(`${text}\n`); - }, - }); - - // Get Emscripten FS and PATH modules (internal Pyodide properties, not exposed in types) - const FS = (pyodide as unknown as { FS: EmscriptenFS }).FS; - const PATH = (pyodide as unknown as { PATH: EmscriptenPATH }).PATH; - - // Create and mount HOSTFS - const HOSTFS = createHOSTFS(backend, FS, PATH); - - try { - // Change to root directory before unmounting to avoid issues - // with cwd being inside the mount point - try { - pyodide.runPython(`import os; os.chdir('/')`); - } catch (_e) { - // Ignore - } - - try { - FS.mkdir("/host"); - } catch (_e) { - // Already exists - } - - try { - FS.unmount("/host"); - } catch (_e) { - // Not mounted - } - - FS.mount(HOSTFS, { root: "/" }, "/host"); - } catch (e) { - return { - success: false, - error: `Failed to mount HOSTFS: ${(e as Error).message}`, - }; - } - - // Register jb_http JavaScript module for Python HTTP requests - // This bridges Python HTTP calls to the main thread's secureFetch - // First, clear any cached import from previous runs (important for worker reuse) - try { - pyodide.runPython(` -import sys -if '_jb_http_bridge' in sys.modules: - del sys.modules['_jb_http_bridge'] -if 'jb_http' in sys.modules: - del sys.modules['jb_http'] -`); - } catch (_e) { - // sys might not be imported yet, ignore - } - - pyodide.registerJsModule("_jb_http_bridge", { - request: ( - url: string, - method: string, - headersJson: string | undefined, - body: string | undefined, - ) => { - try { - // Parse headers from JSON (serialized in Python to avoid PyProxy issues) - const headers = headersJson ? JSON.parse(headersJson) : undefined; - const result = backend.httpRequest(url, { - method: method || "GET", - headers, - body: body || undefined, - }); - return JSON.stringify(result); - } catch (e) { - return JSON.stringify({ error: (e as Error).message }); - } - }, - }); - - // Set up environment variables - const envSetup = Object.entries(input.env) - .map(([key, value]) => { - return `os.environ[${JSON.stringify(key)}] = ${JSON.stringify(value)}`; - }) - .join("\n"); - - // Set up sys.argv - const argv0 = input.scriptPath || "python3"; - const argvList = [argv0, ...input.args] - .map((arg) => JSON.stringify(arg)) - .join(", "); - - try { - await pyodide.runPythonAsync(` -import os -import sys -import builtins -import json - -${envSetup} - -sys.argv = [${argvList}] - -# Create jb_http module for HTTP requests -class _JbHttpResponse: - """HTTP response object similar to requests.Response""" - def __init__(self, data): - self.status_code = data.get('status', 0) - self.reason = data.get('statusText', '') - # @banned-pattern-ignore: Python code, not JavaScript - self.headers = data.get('headers', {}) - self.text = data.get('body', '') - self.url = data.get('url', '') - self._error = data.get('error') - - @property - def ok(self): - return 200 <= self.status_code < 300 - - def json(self): - return json.loads(self.text) - - def raise_for_status(self): - if self._error: - raise Exception(self._error) - if not self.ok: - raise Exception(f"HTTP {self.status_code}: {self.reason}") - -class _JbHttp: - """HTTP client that bridges to just-bash's secureFetch""" - def request(self, method, url, headers=None, data=None, json_data=None): - # Import fresh each time to ensure we use the current bridge - # (important when worker is reused with different SharedArrayBuffer) - import _jb_http_bridge - if json_data is not None: - data = json.dumps(json_data) - headers = headers or {} - headers['Content-Type'] = 'application/json' - # Serialize headers to JSON to avoid PyProxy issues when passing to JS - headers_json = json.dumps(headers) if headers else None - result_json = _jb_http_bridge.request(url, method, headers_json, data) - result = json.loads(result_json) - # Check for errors from the bridge (network not configured, URL not allowed, etc.) - if 'error' in result and result.get('status') is None: - raise Exception(result['error']) - return _JbHttpResponse(result) - - def get(self, url, headers=None, **kwargs): - return self.request('GET', url, headers=headers, **kwargs) - - def post(self, url, headers=None, data=None, json=None, **kwargs): - return self.request('POST', url, headers=headers, data=data, json_data=json, **kwargs) - - def put(self, url, headers=None, data=None, json=None, **kwargs): - return self.request('PUT', url, headers=headers, data=data, json_data=json, **kwargs) - - def delete(self, url, headers=None, **kwargs): - return self.request('DELETE', url, headers=headers, **kwargs) - - def head(self, url, headers=None, **kwargs): - return self.request('HEAD', url, headers=headers, **kwargs) - - def patch(self, url, headers=None, data=None, json=None, **kwargs): - return self.request('PATCH', url, headers=headers, data=data, json_data=json, **kwargs) - -# Register jb_http as an importable module -import types -jb_http = types.ModuleType('jb_http') -jb_http._client = _JbHttp() -jb_http.get = jb_http._client.get -jb_http.post = jb_http._client.post -jb_http.put = jb_http._client.put -jb_http.delete = jb_http._client.delete -jb_http.head = jb_http._client.head -jb_http.patch = jb_http._client.patch -jb_http.request = jb_http._client.request -jb_http.Response = _JbHttpResponse -sys.modules['jb_http'] = jb_http - -# ============================================================ -# SANDBOX SECURITY SETUP -# ============================================================ -# Only apply sandbox restrictions once per Pyodide instance -if not hasattr(builtins, '_jb_sandbox_initialized'): - builtins._jb_sandbox_initialized = True - - # ------------------------------------------------------------ - # 1. Block dangerous module imports (js, pyodide, pyodide_js, pyodide.ffi) - # These allow sandbox escape via JavaScript execution - # ------------------------------------------------------------ - _BLOCKED_MODULES = frozenset({'js', 'pyodide', 'pyodide_js', 'pyodide.ffi'}) - _BLOCKED_PREFIXES = ('js.', 'pyodide.', 'pyodide_js.') - - # Remove pre-loaded dangerous modules from sys.modules - for _blocked_mod in list(sys.modules.keys()): - if _blocked_mod in _BLOCKED_MODULES or any(_blocked_mod.startswith(p) for p in _BLOCKED_PREFIXES): - del sys.modules[_blocked_mod] - - # Create a secure callable wrapper that hides introspection attributes - # This prevents access to __closure__, __kwdefaults__, __globals__, etc. - def _make_secure_import(orig_import, blocked, prefixes): - """Create import function wrapped to block introspection.""" - def _inner(name, globals=None, locals=None, fromlist=(), level=0): - if name in blocked or any(name.startswith(p) for p in prefixes): - raise ImportError(f"Module '{name}' is blocked in this sandbox") - return orig_import(name, globals, locals, fromlist, level) - - class _SecureImport: - """Wrapper that hides function internals from introspection.""" - __slots__ = () - def __call__(self, name, globals=None, locals=None, fromlist=(), level=0): - return _inner(name, globals, locals, fromlist, level) - def __getattribute__(self, name): - if name in ('__call__', '__class__'): - return object.__getattribute__(self, name) - raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") - def __repr__(self): - return '' - return _SecureImport() - - builtins.__import__ = _make_secure_import(builtins.__import__, _BLOCKED_MODULES, _BLOCKED_PREFIXES) - del _BLOCKED_MODULES, _BLOCKED_PREFIXES, _make_secure_import - - # ------------------------------------------------------------ - # 2. Path redirection helper - # ------------------------------------------------------------ - def _should_redirect(path): - """Check if a path should be redirected to /host.""" - return (isinstance(path, str) and - path.startswith('/') and - not path.startswith('/lib') and - not path.startswith('/proc') and - not path.startswith('/host')) - - # ------------------------------------------------------------ - # 3. Secure wrapper factory for file operations - # ------------------------------------------------------------ - # This creates callable wrappers that hide __closure__, __globals__, etc. - def _make_secure_wrapper(func, name): - """Wrap a function to block introspection attributes.""" - class _SecureWrapper: - __slots__ = () - def __call__(self, *args, **kwargs): - return func(*args, **kwargs) - def __getattribute__(self, attr): - if attr in ('__call__', '__class__'): - return object.__getattribute__(self, attr) - raise AttributeError(f"'{type(self).__name__}' object has no attribute '{attr}'") - def __repr__(self): - return f'' - return _SecureWrapper() - - # ------------------------------------------------------------ - # 4. Redirect file operations to /host (with secure wrappers) - # ------------------------------------------------------------ - # builtins.open - _orig_open = builtins.open - def _redir_open(path, mode='r', *args, **kwargs): - if _should_redirect(path): - path = '/host' + path - return _orig_open(path, mode, *args, **kwargs) - builtins.open = _make_secure_wrapper(_redir_open, 'open') - - # os.listdir - _orig_listdir = os.listdir - def _redir_listdir(path='.'): - if _should_redirect(path): - path = '/host' + path - return _orig_listdir(path) - os.listdir = _make_secure_wrapper(_redir_listdir, 'listdir') - - # os.path.exists - _orig_exists = os.path.exists - def _redir_exists(path): - if _should_redirect(path): - path = '/host' + path - return _orig_exists(path) - os.path.exists = _make_secure_wrapper(_redir_exists, 'exists') - - # os.path.isfile - _orig_isfile = os.path.isfile - def _redir_isfile(path): - if _should_redirect(path): - path = '/host' + path - return _orig_isfile(path) - os.path.isfile = _make_secure_wrapper(_redir_isfile, 'isfile') - - # os.path.isdir - _orig_isdir = os.path.isdir - def _redir_isdir(path): - if _should_redirect(path): - path = '/host' + path - return _orig_isdir(path) - os.path.isdir = _make_secure_wrapper(_redir_isdir, 'isdir') - - # os.stat - _orig_stat = os.stat - def _redir_stat(path, *args, **kwargs): - if _should_redirect(path): - path = '/host' + path - return _orig_stat(path, *args, **kwargs) - os.stat = _make_secure_wrapper(_redir_stat, 'stat') - - # os.mkdir - _orig_mkdir = os.mkdir - def _redir_mkdir(path, *args, **kwargs): - if _should_redirect(path): - path = '/host' + path - return _orig_mkdir(path, *args, **kwargs) - os.mkdir = _make_secure_wrapper(_redir_mkdir, 'mkdir') - - # os.makedirs - _orig_makedirs = os.makedirs - def _redir_makedirs(path, *args, **kwargs): - if _should_redirect(path): - path = '/host' + path - return _orig_makedirs(path, *args, **kwargs) - os.makedirs = _make_secure_wrapper(_redir_makedirs, 'makedirs') - - # os.remove - _orig_remove = os.remove - def _redir_remove(path, *args, **kwargs): - if _should_redirect(path): - path = '/host' + path - return _orig_remove(path, *args, **kwargs) - os.remove = _make_secure_wrapper(_redir_remove, 'remove') - - # os.rmdir - _orig_rmdir = os.rmdir - def _redir_rmdir(path, *args, **kwargs): - if _should_redirect(path): - path = '/host' + path - return _orig_rmdir(path, *args, **kwargs) - os.rmdir = _make_secure_wrapper(_redir_rmdir, 'rmdir') - - # os.getcwd - strip /host prefix - _orig_getcwd = os.getcwd - def _redir_getcwd(): - cwd = _orig_getcwd() - if cwd.startswith('/host'): - return cwd[5:] # Strip '/host' prefix - return cwd - os.getcwd = _make_secure_wrapper(_redir_getcwd, 'getcwd') - - # os.chdir - _orig_chdir = os.chdir - def _redir_chdir(path): - if _should_redirect(path): - path = '/host' + path - return _orig_chdir(path) - os.chdir = _make_secure_wrapper(_redir_chdir, 'chdir') - - # ------------------------------------------------------------ - # 5. Additional file operations (glob, walk, scandir, io.open) - # ------------------------------------------------------------ - import glob as _glob_module - - _orig_glob = _glob_module.glob - def _redir_glob(pathname, *args, **kwargs): - if _should_redirect(pathname): - pathname = '/host' + pathname - return _orig_glob(pathname, *args, **kwargs) - _glob_module.glob = _make_secure_wrapper(_redir_glob, 'glob') - - _orig_iglob = _glob_module.iglob - def _redir_iglob(pathname, *args, **kwargs): - if _should_redirect(pathname): - pathname = '/host' + pathname - return _orig_iglob(pathname, *args, **kwargs) - _glob_module.iglob = _make_secure_wrapper(_redir_iglob, 'iglob') - - # os.walk (generator - needs special handling) - _orig_walk = os.walk - def _redir_walk(top, *args, **kwargs): - redirected = False - if _should_redirect(top): - top = '/host' + top - redirected = True - for dirpath, dirnames, filenames in _orig_walk(top, *args, **kwargs): - if redirected and dirpath.startswith('/host'): - dirpath = dirpath[5:] if len(dirpath) > 5 else '/' - yield dirpath, dirnames, filenames - os.walk = _make_secure_wrapper(_redir_walk, 'walk') - - # os.scandir - _orig_scandir = os.scandir - def _redir_scandir(path='.'): - if _should_redirect(path): - path = '/host' + path - return _orig_scandir(path) - os.scandir = _make_secure_wrapper(_redir_scandir, 'scandir') - - # io.open (same secure wrapper as builtins.open) - import io as _io_module - _io_module.open = builtins.open - - # ------------------------------------------------------------ - # 6. shutil file operations - # ------------------------------------------------------------ - import shutil as _shutil_module - - # shutil.copy(src, dst) - _orig_shutil_copy = _shutil_module.copy - def _redir_shutil_copy(src, dst, *args, **kwargs): - if _should_redirect(src): - src = '/host' + src - if _should_redirect(dst): - dst = '/host' + dst - return _orig_shutil_copy(src, dst, *args, **kwargs) - _shutil_module.copy = _make_secure_wrapper(_redir_shutil_copy, 'copy') - - # shutil.copy2(src, dst) - _orig_shutil_copy2 = _shutil_module.copy2 - def _redir_shutil_copy2(src, dst, *args, **kwargs): - if _should_redirect(src): - src = '/host' + src - if _should_redirect(dst): - dst = '/host' + dst - return _orig_shutil_copy2(src, dst, *args, **kwargs) - _shutil_module.copy2 = _make_secure_wrapper(_redir_shutil_copy2, 'copy2') - - # shutil.copyfile(src, dst) - _orig_shutil_copyfile = _shutil_module.copyfile - def _redir_shutil_copyfile(src, dst, *args, **kwargs): - if _should_redirect(src): - src = '/host' + src - if _should_redirect(dst): - dst = '/host' + dst - return _orig_shutil_copyfile(src, dst, *args, **kwargs) - _shutil_module.copyfile = _make_secure_wrapper(_redir_shutil_copyfile, 'copyfile') - - # shutil.copytree(src, dst) - _orig_shutil_copytree = _shutil_module.copytree - def _redir_shutil_copytree(src, dst, *args, **kwargs): - if _should_redirect(src): - src = '/host' + src - if _should_redirect(dst): - dst = '/host' + dst - return _orig_shutil_copytree(src, dst, *args, **kwargs) - _shutil_module.copytree = _make_secure_wrapper(_redir_shutil_copytree, 'copytree') - - # shutil.move(src, dst) - _orig_shutil_move = _shutil_module.move - def _redir_shutil_move(src, dst, *args, **kwargs): - if _should_redirect(src): - src = '/host' + src - if _should_redirect(dst): - dst = '/host' + dst - return _orig_shutil_move(src, dst, *args, **kwargs) - _shutil_module.move = _make_secure_wrapper(_redir_shutil_move, 'move') - - # shutil.rmtree(path) - _orig_shutil_rmtree = _shutil_module.rmtree - def _redir_shutil_rmtree(path, *args, **kwargs): - if _should_redirect(path): - path = '/host' + path - return _orig_shutil_rmtree(path, *args, **kwargs) - _shutil_module.rmtree = _make_secure_wrapper(_redir_shutil_rmtree, 'rmtree') - - # ------------------------------------------------------------ - # 7. pathlib.Path - redirect path resolution - # ------------------------------------------------------------ - from pathlib import Path, PurePosixPath - - def _redirect_path(p): - """Convert a Path to redirect /absolute paths to /host.""" - s = str(p) - if _should_redirect(s): - return Path('/host' + s) - return p - - # Helper to create method wrappers for Path - def _wrap_path_method(orig_method, name): - def wrapper(self, *args, **kwargs): - redirected = _redirect_path(self) - return getattr(redirected, '_orig_' + name)(*args, **kwargs) - return wrapper - - # Store original methods with _orig_ prefix, then replace with redirecting versions - # Path.stat() - Path._orig_stat = Path.stat - def _path_stat(self, *args, **kwargs): - return _redirect_path(self)._orig_stat(*args, **kwargs) - Path.stat = _path_stat - - # Path.exists() - Path._orig_exists = Path.exists - def _path_exists(self): - return _redirect_path(self)._orig_exists() - Path.exists = _path_exists - - # Path.is_file() - Path._orig_is_file = Path.is_file - def _path_is_file(self): - return _redirect_path(self)._orig_is_file() - Path.is_file = _path_is_file - - # Path.is_dir() - Path._orig_is_dir = Path.is_dir - def _path_is_dir(self): - return _redirect_path(self)._orig_is_dir() - Path.is_dir = _path_is_dir - - # Path.open() - Path._orig_open = Path.open - def _path_open(self, *args, **kwargs): - return _redirect_path(self)._orig_open(*args, **kwargs) - Path.open = _path_open - - # Path.read_text() - Path._orig_read_text = Path.read_text - def _path_read_text(self, *args, **kwargs): - return _redirect_path(self)._orig_read_text(*args, **kwargs) - Path.read_text = _path_read_text - - # Path.read_bytes() - Path._orig_read_bytes = Path.read_bytes - def _path_read_bytes(self): - return _redirect_path(self)._orig_read_bytes() - Path.read_bytes = _path_read_bytes - - # Path.write_text() - Path._orig_write_text = Path.write_text - def _path_write_text(self, *args, **kwargs): - return _redirect_path(self)._orig_write_text(*args, **kwargs) - Path.write_text = _path_write_text - - # Path.write_bytes() - Path._orig_write_bytes = Path.write_bytes - def _path_write_bytes(self, data): - return _redirect_path(self)._orig_write_bytes(data) - Path.write_bytes = _path_write_bytes - - # Path.mkdir() - Path._orig_mkdir = Path.mkdir - def _path_mkdir(self, *args, **kwargs): - return _redirect_path(self)._orig_mkdir(*args, **kwargs) - Path.mkdir = _path_mkdir - - # Path.rmdir() - Path._orig_rmdir = Path.rmdir - def _path_rmdir(self): - return _redirect_path(self)._orig_rmdir() - Path.rmdir = _path_rmdir - - # Path.unlink() - Path._orig_unlink = Path.unlink - def _path_unlink(self, *args, **kwargs): - return _redirect_path(self)._orig_unlink(*args, **kwargs) - Path.unlink = _path_unlink - - # Path.iterdir() - Path._orig_iterdir = Path.iterdir - def _path_iterdir(self): - redirected = _redirect_path(self) - for p in redirected._orig_iterdir(): - # Strip /host prefix from results - s = str(p) - if s.startswith('/host'): - yield Path(s[5:]) - else: - yield p - Path.iterdir = _path_iterdir - - # Path.glob() - Path._orig_glob = Path.glob - def _path_glob(self, pattern): - redirected = _redirect_path(self) - for p in redirected._orig_glob(pattern): - s = str(p) - if s.startswith('/host'): - yield Path(s[5:]) - else: - yield p - Path.glob = _path_glob - - # Path.rglob() - Path._orig_rglob = Path.rglob - def _path_rglob(self, pattern): - redirected = _redirect_path(self) - for p in redirected._orig_rglob(pattern): - s = str(p) - if s.startswith('/host'): - yield Path(s[5:]) - else: - yield p - Path.rglob = _path_rglob - -# Set cwd to host mount -os.chdir('/host' + ${JSON.stringify(input.cwd)}) -`); - } catch (e) { - return { - success: false, - error: `Failed to set up environment: ${(e as Error).message}`, - }; - } - - // Run the Python code wrapped in try/except to catch SystemExit - // This prevents Pyodide from hanging on sys.exit() - try { - // Wrap user code to handle sys.exit() gracefully - const wrappedCode = ` -import sys -_jb_exit_code = 0 -try: -${input.pythonCode - .split("\n") - .map((line) => ` ${line}`) - .join("\n")} -except SystemExit as e: - _jb_exit_code = e.code if isinstance(e.code, int) else (1 if e.code else 0) -`; - await pyodide.runPythonAsync(wrappedCode); - // Get the exit code from Python - const exitCode = pyodide.globals.get("_jb_exit_code") as number; - backend.exit(exitCode); - return { success: true }; - } catch (e) { - const error = e as Error; - backend.writeStderr(`${error.message}\n`); - backend.exit(1); - return { success: true }; - } -} - -// Defense-in-depth instance - activated AFTER Pyodide loads -let defense: WorkerDefenseInDepth | null = null; - -/** - * Initialize Pyodide and then activate defense-in-depth. - * This phased approach allows Pyodide to load without restrictions, - * then blocks dangerous globals before user code runs. - */ -async function initializeWithDefense(): Promise { - // Load Pyodide first (needs unrestricted JS features for WASM init) - await getPyodide(); - - // Activate defense after Pyodide is loaded. - // - // Security exclusions required for Pyodide operation: - // - // 1. proxy: Pyodide's Python-JS interop (pyodide.ffi) wraps JavaScript objects - // in Proxy to make them accessible from Python. Without this, basic operations - // like accessing JS object properties from Python would fail. - // Security impact: Proxy alone cannot execute arbitrary code strings. An attacker - // would need access to Function/eval (which remain blocked) to achieve code execution. - // See: https://pyodide.org/en/stable/usage/type-conversions.html - // - // 2. setImmediate: Pyodide's webloop (asyncio implementation) uses setImmediate - // for scheduling microtasks and async task execution. Without this, any Python - // code using async/await would hang indefinitely. - // Security impact: setImmediate only accepts function callbacks, not code strings, - // so it cannot be used for arbitrary code execution like setTimeout("code") could. - // See: https://pyodide.org/en/stable/usage/webloop.html - // - defense = new WorkerDefenseInDepth({ - excludeViolationTypes: [ - "proxy", - "setImmediate", - // 3. SharedArrayBuffer/Atomics: Used by sync-fs-backend.ts for synchronous - // filesystem communication between Pyodide's WASM thread and the main thread. - // Without this, Pyodide cannot perform synchronous file I/O operations. - "shared_array_buffer", - "atomics", - ], - onViolation: (v) => { - parentPort?.postMessage({ type: "security-violation", violation: v }); - }, - }); -} - -// Handle messages from parent -if (parentPort) { - if (workerData) { - initializeWithDefense() - .then(() => runPython(workerData as WorkerInput)) - .then((result) => { - result.defenseStats = defense?.getStats(); - parentPort?.postMessage(result); - }) - .catch((e) => { - parentPort?.postMessage({ - success: false, - error: (e as Error).message, - defenseStats: defense?.getStats(), - }); - }); - } - - parentPort.on("message", async (input: WorkerInput) => { - try { - // Defense should already be active from initial load - if (!defense) { - await initializeWithDefense(); - } - const result = await runPython(input); - result.defenseStats = defense?.getStats(); - parentPort?.postMessage(result); - } catch (e) { - parentPort?.postMessage({ - success: false, - error: (e as Error).message, - defenseStats: defense?.getStats(), - }); - } - }); -} diff --git a/src/commands/query-engine/builtins/array-builtins.ts b/src/commands/query-engine/builtins/array-builtins.ts deleted file mode 100644 index c94ed10b..00000000 --- a/src/commands/query-engine/builtins/array-builtins.ts +++ /dev/null @@ -1,332 +0,0 @@ -/** - * Array-related jq builtins - * - * Handles array manipulation functions like sort, sort_by, group_by, max, min, add, etc. - */ - -import { mergeToNullPrototype } from "../../../helpers/env.js"; -import type { EvalContext } from "../evaluator.js"; -import type { AstNode } from "../parser.js"; -import { isSafeKey, safeHasOwn, safeSet } from "../safe-object.js"; -import type { QueryValue } from "../value-operations.js"; - -type EvalFn = ( - value: QueryValue, - ast: AstNode, - ctx: EvalContext, -) => QueryValue[]; - -type EvalWithPartialFn = ( - value: QueryValue, - ast: AstNode, - ctx: EvalContext, -) => QueryValue[]; - -type CompareFn = (a: QueryValue, b: QueryValue) => number; -type IsTruthyFn = (v: QueryValue) => boolean; -type ContainsDeepFn = (a: QueryValue, b: QueryValue) => boolean; -type ExecutionLimitErrorClass = new ( - message: string, - kind: "recursion" | "commands" | "iterations", -) => Error; - -/** - * Handle array builtins that need evaluate function for arguments. - * Returns null if the builtin name is not an array builtin handled here. - */ -export function evalArrayBuiltin( - value: QueryValue, - name: string, - args: AstNode[], - ctx: EvalContext, - evaluate: EvalFn, - evaluateWithPartialResults: EvalWithPartialFn, - compareJq: CompareFn, - isTruthy: IsTruthyFn, - containsDeep: ContainsDeepFn, - ExecutionLimitError: ExecutionLimitErrorClass, -): QueryValue[] | null { - switch (name) { - case "sort": - if (Array.isArray(value)) return [[...value].sort(compareJq)]; - return [null]; - - case "sort_by": { - if (!Array.isArray(value) || args.length === 0) return [null]; - const sorted = [...value].sort((a, b) => { - const aKey = evaluate(a, args[0], ctx)[0]; - const bKey = evaluate(b, args[0], ctx)[0]; - return compareJq(aKey, bKey); - }); - return [sorted]; - } - - case "bsearch": { - if (!Array.isArray(value)) { - const typeName = - value === null - ? "null" - : typeof value === "object" - ? "object" - : typeof value; - throw new Error( - `${typeName} (${JSON.stringify(value)}) cannot be searched from`, - ); - } - if (args.length === 0) return [null]; - const targets = evaluate(value, args[0], ctx); - // Handle generator args - each target produces its own search result - return targets.map((target) => { - // Binary search: return index if found, or -insertionPoint-1 if not - let lo = 0; - let hi = value.length; - while (lo < hi) { - const mid = (lo + hi) >>> 1; - const cmp = compareJq(value[mid], target); - if (cmp < 0) { - lo = mid + 1; - } else { - hi = mid; - } - } - // Check if we found an exact match - if (lo < value.length && compareJq(value[lo], target) === 0) { - return lo; - } - // Not found: return negative insertion point - return -lo - 1; - }); - } - - case "unique_by": { - if (!Array.isArray(value) || args.length === 0) return [null]; - const seen = new Map(); - for (const item of value) { - const keyVal = evaluate(item, args[0], ctx)[0]; - const keyStr = JSON.stringify(keyVal); - if (!seen.has(keyStr)) { - seen.set(keyStr, { item, key: keyVal }); - } - } - // Sort by key value and return items - const entries = [...seen.values()]; - entries.sort((a, b) => compareJq(a.key, b.key)); - return [entries.map((e) => e.item)]; - } - - case "group_by": { - if (!Array.isArray(value) || args.length === 0) return [null]; - const groups = new Map(); - for (const item of value) { - const key = JSON.stringify(evaluate(item, args[0], ctx)[0]); - if (!groups.has(key)) groups.set(key, []); - groups.get(key)?.push(item); - } - return [[...groups.values()]]; - } - - case "max": - if (Array.isArray(value) && value.length > 0) { - return [value.reduce((a, b) => (compareJq(a, b) > 0 ? a : b))]; - } - return [null]; - - case "max_by": { - if (!Array.isArray(value) || value.length === 0 || args.length === 0) - return [null]; - return [ - value.reduce((a, b) => { - const aKey = evaluate(a, args[0], ctx)[0]; - const bKey = evaluate(b, args[0], ctx)[0]; - return compareJq(aKey, bKey) > 0 ? a : b; - }), - ]; - } - - case "min": - if (Array.isArray(value) && value.length > 0) { - return [value.reduce((a, b) => (compareJq(a, b) < 0 ? a : b))]; - } - return [null]; - - case "min_by": { - if (!Array.isArray(value) || value.length === 0 || args.length === 0) - return [null]; - return [ - value.reduce((a, b) => { - const aKey = evaluate(a, args[0], ctx)[0]; - const bKey = evaluate(b, args[0], ctx)[0]; - return compareJq(aKey, bKey) < 0 ? a : b; - }), - ]; - } - - case "add": { - // Helper to add an array of values - const addValues = (arr: QueryValue[]): QueryValue => { - // jq filters out null values for add - const filtered = arr.filter((x) => x !== null); - if (filtered.length === 0) return null; - if (filtered.every((x) => typeof x === "number")) { - return filtered.reduce((a, b) => (a as number) + (b as number), 0); - } - if (filtered.every((x) => typeof x === "string")) { - return filtered.join(""); - } - if (filtered.every((x) => Array.isArray(x))) { - return filtered.flat(); - } - if ( - filtered.every((x) => x && typeof x === "object" && !Array.isArray(x)) - ) { - // Use null-prototype to prevent prototype pollution from user-controlled JSON - return mergeToNullPrototype(...(filtered as object[])); - } - return null; - }; - - // Handle add(expr) - collect values from generator and add them - if (args.length >= 1) { - const collected = evaluate(value, args[0], ctx); - return [addValues(collected)]; - } - // Existing behavior for add (no args) - add array elements - if (Array.isArray(value)) { - return [addValues(value)]; - } - return [null]; - } - - case "any": { - if (args.length >= 2) { - // any(generator; condition) - lazy evaluation with short-circuit - // Evaluate generator lazily, return true if any passes condition - try { - const genValues = evaluateWithPartialResults(value, args[0], ctx); - for (const v of genValues) { - const cond = evaluate(v, args[1], ctx); - if (cond.some(isTruthy)) return [true]; - } - } catch (e) { - // Always re-throw execution limit errors - if (e instanceof ExecutionLimitError) throw e; - // Error occurred but we might have found a truthy value already - } - return [false]; - } - if (args.length === 1) { - if (Array.isArray(value)) { - return [ - value.some((item) => isTruthy(evaluate(item, args[0], ctx)[0])), - ]; - } - return [false]; - } - if (Array.isArray(value)) return [value.some(isTruthy)]; - return [false]; - } - - case "all": { - if (args.length >= 2) { - // all(generator; condition) - lazy evaluation with short-circuit - // Evaluate generator lazily, return false if any fails condition - try { - const genValues = evaluateWithPartialResults(value, args[0], ctx); - for (const v of genValues) { - const cond = evaluate(v, args[1], ctx); - if (!cond.some(isTruthy)) return [false]; - } - } catch (e) { - // Always re-throw execution limit errors - if (e instanceof ExecutionLimitError) throw e; - // Error occurred but we might have found a falsy value already - } - return [true]; - } - if (args.length === 1) { - if (Array.isArray(value)) { - return [ - value.every((item) => isTruthy(evaluate(item, args[0], ctx)[0])), - ]; - } - return [true]; - } - if (Array.isArray(value)) return [value.every(isTruthy)]; - return [true]; - } - - case "select": { - if (args.length === 0) return [value]; - const conds = evaluate(value, args[0], ctx); - return conds.some(isTruthy) ? [value] : []; - } - - case "map": { - if (args.length === 0 || !Array.isArray(value)) return [null]; - const results = value.flatMap((item) => evaluate(item, args[0], ctx)); - return [results]; - } - - case "map_values": { - if (args.length === 0) return [null]; - if (Array.isArray(value)) { - return [value.flatMap((item) => evaluate(item, args[0], ctx))]; - } - if (value && typeof value === "object") { - // Use null-prototype for additional safety - const result: Record = Object.create(null); - for (const [k, v] of Object.entries(value)) { - // Defense against prototype pollution - if (!isSafeKey(k)) continue; - const mapped = evaluate(v, args[0], ctx); - if (mapped.length > 0) safeSet(result, k, mapped[0]); - } - return [result]; - } - return [null]; - } - - case "has": { - if (args.length === 0) return [false]; - const keys = evaluate(value, args[0], ctx); - const key = keys[0]; - if (Array.isArray(value) && typeof key === "number") { - return [key >= 0 && key < value.length]; - } - if (value && typeof value === "object" && typeof key === "string") { - // Use safeHasOwn to check own properties only (not inherited like __proto__) - return [safeHasOwn(value, key)]; - } - return [false]; - } - - case "in": { - if (args.length === 0) return [false]; - const objs = evaluate(value, args[0], ctx); - const obj = objs[0]; - if (Array.isArray(obj) && typeof value === "number") { - return [value >= 0 && value < obj.length]; - } - if (obj && typeof obj === "object" && typeof value === "string") { - // Use safeHasOwn to check own properties only (not inherited like __proto__) - return [safeHasOwn(obj, value)]; - } - return [false]; - } - - case "contains": { - if (args.length === 0) return [false]; - const others = evaluate(value, args[0], ctx); - return [containsDeep(value, others[0])]; - } - - case "inside": { - if (args.length === 0) return [false]; - const others = evaluate(value, args[0], ctx); - return [containsDeep(others[0], value)]; - } - - default: - return null; - } -} diff --git a/src/commands/query-engine/builtins/control-builtins.ts b/src/commands/query-engine/builtins/control-builtins.ts deleted file mode 100644 index bfae9f6b..00000000 --- a/src/commands/query-engine/builtins/control-builtins.ts +++ /dev/null @@ -1,286 +0,0 @@ -/** - * Control flow jq builtins - * - * Handles first, last, nth, range, limit, isempty, isvalid, skip, until, while, repeat. - */ - -import type { EvalContext } from "../evaluator.js"; -import type { AstNode } from "../parser.js"; -import type { QueryValue } from "../value-operations.js"; - -type EvalFn = ( - value: QueryValue, - ast: AstNode, - ctx: EvalContext, -) => QueryValue[]; - -type EvalWithPartialFn = ( - value: QueryValue, - ast: AstNode, - ctx: EvalContext, -) => QueryValue[]; - -type IsTruthyFn = (v: QueryValue) => boolean; -type ExecutionLimitErrorClass = new ( - message: string, - kind: "recursion" | "commands" | "iterations", -) => Error; - -/** - * Handle control flow builtins. - * Returns null if the builtin name is not a control builtin handled here. - */ -export function evalControlBuiltin( - value: QueryValue, - name: string, - args: AstNode[], - ctx: EvalContext, - evaluate: EvalFn, - evaluateWithPartialResults: EvalWithPartialFn, - isTruthy: IsTruthyFn, - ExecutionLimitError: ExecutionLimitErrorClass, -): QueryValue[] | null { - switch (name) { - case "first": - if (args.length > 0) { - // Use lazy evaluation - get first value without evaluating rest - try { - const results = evaluateWithPartialResults(value, args[0], ctx); - return results.length > 0 ? [results[0]] : []; - } catch (e) { - // Always re-throw execution limit errors - if (e instanceof ExecutionLimitError) throw e; - return []; - } - } - if (Array.isArray(value) && value.length > 0) return [value[0]]; - return [null]; - - case "last": - if (args.length > 0) { - const results = evaluate(value, args[0], ctx); - return results.length > 0 ? [results[results.length - 1]] : []; - } - if (Array.isArray(value) && value.length > 0) - return [value[value.length - 1]]; - return [null]; - - case "nth": { - if (args.length < 1) return [null]; - const ns = evaluate(value, args[0], ctx); - // Handle generator args - each n produces its own output - if (args.length > 1) { - // Check for negative indices first - for (const nv of ns) { - const n = nv as number; - if (n < 0) { - throw new Error("nth doesn't support negative indices"); - } - } - // Use lazy evaluation to get partial results before errors - let results: QueryValue[]; - try { - results = evaluateWithPartialResults(value, args[1], ctx); - } catch (e) { - // Always re-throw execution limit errors - if (e instanceof ExecutionLimitError) throw e; - results = []; - } - return ns.flatMap((nv) => { - const n = nv as number; - return n < results.length ? [results[n]] : []; - }); - } - if (Array.isArray(value)) { - return ns.flatMap((nv) => { - const n = nv as number; - if (n < 0) { - throw new Error("nth doesn't support negative indices"); - } - return n < value.length ? [value[n]] : [null]; - }); - } - return [null]; - } - - case "range": { - if (args.length === 0) return []; - const startsVals = evaluate(value, args[0], ctx); - if (args.length === 1) { - // range(n) - single arg, range from 0 to n - // Handle generator args - each value produces its own range - const result: number[] = []; - for (const n of startsVals) { - const num = n as number; - for (let i = 0; i < num; i++) result.push(i); - } - return result; - } - const endsVals = evaluate(value, args[1], ctx); - if (args.length === 2) { - // range(start;end) - two args, range from start to end, step=1 - // But jq allows generators, so we need to handle multiple values - const result: number[] = []; - for (const s of startsVals) { - for (const e of endsVals) { - const start = s as number; - const end = e as number; - for (let i = start; i < end; i++) result.push(i); - } - } - return result; - } - // range(start;end;step) - three args with step - const stepsVals = evaluate(value, args[2], ctx); - const result: number[] = []; - for (const s of startsVals) { - for (const e of endsVals) { - for (const st of stepsVals) { - const start = s as number; - const end = e as number; - const step = st as number; - if (step === 0) continue; // Avoid infinite loop - if (step > 0) { - for (let i = start; i < end; i += step) result.push(i); - } else { - for (let i = start; i > end; i += step) result.push(i); - } - } - } - } - return result; - } - - case "limit": { - if (args.length < 2) return []; - const ns = evaluate(value, args[0], ctx); - // Handle generator args - each n produces its own limited output - return ns.flatMap((nv) => { - const n = nv as number; - // jq: negative limit throws error - if (n < 0) { - throw new Error("limit doesn't support negative count"); - } - // jq: limit(0; expr) should return [] without evaluating expr - if (n === 0) return []; - // Use lazy evaluation to get partial results before errors - let results: QueryValue[]; - try { - results = evaluateWithPartialResults(value, args[1], ctx); - } catch (e) { - // Always re-throw execution limit errors - if (e instanceof ExecutionLimitError) throw e; - results = []; - } - return results.slice(0, n); - }); - } - - case "isempty": { - if (args.length < 1) return [true]; - // isempty returns true if the expression produces no values - // It should short-circuit: if first value is produced, return false - // For comma expressions like `1,error("foo")`, the left side produces a value - // before the right side errors, so we should return false - try { - const results = evaluateWithPartialResults(value, args[0], ctx); - return [results.length === 0]; - } catch (e) { - // Always re-throw execution limit errors - if (e instanceof ExecutionLimitError) throw e; - // If an error occurs without any results, return true - return [true]; - } - } - - case "isvalid": { - if (args.length < 1) return [true]; - // isvalid returns true if the expression produces at least one value without error - try { - const results = evaluate(value, args[0], ctx); - return [results.length > 0]; - } catch (e) { - // Always re-throw execution limit errors - if (e instanceof ExecutionLimitError) throw e; - // Any other error means invalid - return [false]; - } - } - - case "skip": { - if (args.length < 2) return []; - const ns = evaluate(value, args[0], ctx); - // Handle generator args - each n produces its own skip result - return ns.flatMap((nv) => { - const n = nv as number; - if (n < 0) { - throw new Error("skip doesn't support negative count"); - } - const results = evaluate(value, args[1], ctx); - return results.slice(n); - }); - } - - case "until": { - if (args.length < 2) return [value]; - let current = value; - const maxIterations = ctx.limits.maxIterations; - for (let i = 0; i < maxIterations; i++) { - const conds = evaluate(current, args[0], ctx); - if (conds.some(isTruthy)) return [current]; - const next = evaluate(current, args[1], ctx); - if (next.length === 0) return [current]; - current = next[0]; - } - throw new ExecutionLimitError( - `jq until: too many iterations (${maxIterations}), increase executionLimits.maxJqIterations`, - "iterations", - ); - } - - case "while": { - if (args.length < 2) return [value]; - const results: QueryValue[] = []; - let current = value; - const maxIterations = ctx.limits.maxIterations; - for (let i = 0; i < maxIterations; i++) { - const conds = evaluate(current, args[0], ctx); - if (!conds.some(isTruthy)) break; - results.push(current); - const next = evaluate(current, args[1], ctx); - if (next.length === 0) break; - current = next[0]; - } - if (results.length >= maxIterations) { - throw new ExecutionLimitError( - `jq while: too many iterations (${maxIterations}), increase executionLimits.maxJqIterations`, - "iterations", - ); - } - return results; - } - - case "repeat": { - if (args.length === 0) return [value]; - const results: QueryValue[] = []; - let current = value; - const maxIterations = ctx.limits.maxIterations; - for (let i = 0; i < maxIterations; i++) { - results.push(current); - const next = evaluate(current, args[0], ctx); - if (next.length === 0) break; - current = next[0]; - } - if (results.length >= maxIterations) { - throw new ExecutionLimitError( - `jq repeat: too many iterations (${maxIterations}), increase executionLimits.maxJqIterations`, - "iterations", - ); - } - return results; - } - - default: - return null; - } -} diff --git a/src/commands/query-engine/builtins/date-builtins.ts b/src/commands/query-engine/builtins/date-builtins.ts deleted file mode 100644 index f855399e..00000000 --- a/src/commands/query-engine/builtins/date-builtins.ts +++ /dev/null @@ -1,211 +0,0 @@ -/** - * Date/time-related jq builtins - * - * Handles date and time functions like now, gmtime, mktime, strftime, strptime, etc. - */ - -import type { EvalContext } from "../evaluator.js"; -import type { AstNode } from "../parser.js"; -import type { QueryValue } from "../value-operations.js"; - -type EvalFn = ( - value: QueryValue, - ast: AstNode, - ctx: EvalContext, -) => QueryValue[]; - -/** - * Handle date builtins that need evaluate function for arguments. - * Returns null if the builtin name is not a date builtin handled here. - */ -export function evalDateBuiltin( - value: QueryValue, - name: string, - args: AstNode[], - ctx: EvalContext, - evaluate: EvalFn, -): QueryValue[] | null { - switch (name) { - case "now": - return [Date.now() / 1000]; - - case "gmtime": { - // Convert Unix timestamp to broken-down time array - // jq format: [year, month(0-11), day(1-31), hour, minute, second, weekday(0-6), yearday(0-365)] - if (typeof value !== "number") return [null]; - const date = new Date(value * 1000); - const year = date.getUTCFullYear(); - const month = date.getUTCMonth(); // 0-11 - const day = date.getUTCDate(); // 1-31 - const hour = date.getUTCHours(); - const minute = date.getUTCMinutes(); - const second = date.getUTCSeconds(); - const weekday = date.getUTCDay(); // 0=Sunday - // Calculate day of year - const startOfYear = Date.UTC(year, 0, 1); - const yearday = Math.floor( - (date.getTime() - startOfYear) / (24 * 60 * 60 * 1000), - ); - return [[year, month, day, hour, minute, second, weekday, yearday]]; - } - - case "mktime": { - // Convert broken-down time array to Unix timestamp - if (!Array.isArray(value)) { - throw new Error("mktime requires parsed datetime inputs"); - } - const [year, month, day, hour = 0, minute = 0, second = 0] = value; - if (typeof year !== "number" || typeof month !== "number") { - throw new Error("mktime requires parsed datetime inputs"); - } - const dateVal = Date.UTC( - year, - month, - day ?? 1, - hour ?? 0, - minute ?? 0, - second ?? 0, - ); - return [Math.floor(dateVal / 1000)]; - } - - case "strftime": { - // Format time as string - if (args.length === 0) return [null]; - const fmtVals = evaluate(value, args[0], ctx); - const fmt = fmtVals[0]; - if (typeof fmt !== "string") { - throw new Error("strftime/1 requires a string format"); - } - let date: Date; - if (typeof value === "number") { - // Unix timestamp - date = new Date(value * 1000); - } else if (Array.isArray(value)) { - // Broken-down time array - const [year, month, day, hour = 0, minute = 0, second = 0] = value; - if (typeof year !== "number" || typeof month !== "number") { - throw new Error("strftime/1 requires parsed datetime inputs"); - } - date = new Date( - Date.UTC(year, month, day ?? 1, hour ?? 0, minute ?? 0, second ?? 0), - ); - } else { - throw new Error("strftime/1 requires parsed datetime inputs"); - } - // Simple strftime implementation - const dayNames = [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - ]; - const monthNames = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - ]; - const pad = (n: number, w = 2) => String(n).padStart(w, "0"); - const result = fmt - .replace(/%Y/g, String(date.getUTCFullYear())) - .replace(/%m/g, pad(date.getUTCMonth() + 1)) - .replace(/%d/g, pad(date.getUTCDate())) - .replace(/%H/g, pad(date.getUTCHours())) - .replace(/%M/g, pad(date.getUTCMinutes())) - .replace(/%S/g, pad(date.getUTCSeconds())) - .replace(/%A/g, dayNames[date.getUTCDay()]) - .replace(/%B/g, monthNames[date.getUTCMonth()]) - .replace(/%Z/g, "UTC") - .replace(/%%/g, "%"); - return [result]; - } - - case "strptime": { - // Parse string to broken-down time array - if (args.length === 0) return [null]; - if (typeof value !== "string") { - throw new Error("strptime/1 requires a string input"); - } - const fmtVals = evaluate(value, args[0], ctx); - const fmt = fmtVals[0]; - if (typeof fmt !== "string") { - throw new Error("strptime/1 requires a string format"); - } - // Simple strptime for common ISO format - if (fmt === "%Y-%m-%dT%H:%M:%SZ") { - const match = value.match( - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z$/, - ); - if (match) { - const [, year, month, day, hour, minute, second] = match.map(Number); - const date = new Date( - Date.UTC(year, month - 1, day, hour, minute, second), - ); - const weekday = date.getUTCDay(); - const startOfYear = Date.UTC(year, 0, 1); - const yearday = Math.floor( - (date.getTime() - startOfYear) / (24 * 60 * 60 * 1000), - ); - return [ - [year, month - 1, day, hour, minute, second, weekday, yearday], - ]; - } - } - // Fallback: try to parse as ISO date - const date = new Date(value); - if (!Number.isNaN(date.getTime())) { - const year = date.getUTCFullYear(); - const month = date.getUTCMonth(); - const day = date.getUTCDate(); - const hour = date.getUTCHours(); - const minute = date.getUTCMinutes(); - const second = date.getUTCSeconds(); - const weekday = date.getUTCDay(); - const startOfYear = Date.UTC(year, 0, 1); - const yearday = Math.floor( - (date.getTime() - startOfYear) / (24 * 60 * 60 * 1000), - ); - return [[year, month, day, hour, minute, second, weekday, yearday]]; - } - throw new Error(`Cannot parse date: ${value}`); - } - - case "fromdate": { - // Parse ISO 8601 date string to Unix timestamp - if (typeof value !== "string") { - throw new Error("fromdate requires a string input"); - } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - throw new Error( - `date "${value}" does not match format "%Y-%m-%dT%H:%M:%SZ"`, - ); - } - return [Math.floor(date.getTime() / 1000)]; - } - - case "todate": { - // Convert Unix timestamp to ISO 8601 date string - if (typeof value !== "number") { - throw new Error("todate requires a number input"); - } - const date = new Date(value * 1000); - return [date.toISOString().replace(/\.\d{3}Z$/, "Z")]; - } - - default: - return null; - } -} diff --git a/src/commands/query-engine/builtins/format-builtins.ts b/src/commands/query-engine/builtins/format-builtins.ts deleted file mode 100644 index d787f263..00000000 --- a/src/commands/query-engine/builtins/format-builtins.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Format-related jq builtins (@ prefixed) - * - * Handles encoding/formatting functions like @base64, @uri, @csv, @json, etc. - */ - -import type { QueryValue } from "../value-operations.js"; -import { getValueDepth } from "../value-operations.js"; - -// Default max depth for nested structures -const DEFAULT_MAX_JQ_DEPTH = 2000; - -/** - * Handle format builtins (those starting with @). - * Returns null if the builtin name is not a format builtin handled here. - */ -export function evalFormatBuiltin( - value: QueryValue, - name: string, - maxDepth?: number, -): QueryValue[] | null { - switch (name) { - case "@base64": - if (typeof value === "string") { - // Use Buffer for Node.js, btoa for browser - if (typeof Buffer !== "undefined") { - return [Buffer.from(value, "utf-8").toString("base64")]; - } - return [btoa(value)]; - } - return [null]; - - case "@base64d": - if (typeof value === "string") { - // Use Buffer for Node.js, atob for browser - if (typeof Buffer !== "undefined") { - return [Buffer.from(value, "base64").toString("utf-8")]; - } - return [atob(value)]; - } - return [null]; - - case "@uri": - if (typeof value === "string") { - // encodeURIComponent doesn't encode !'()*~ but jq encodes !'()* - return [ - encodeURIComponent(value) - .replace(/!/g, "%21") - .replace(/'/g, "%27") - .replace(/\(/g, "%28") - .replace(/\)/g, "%29") - .replace(/\*/g, "%2A"), - ]; - } - return [null]; - - case "@urid": - if (typeof value === "string") { - return [decodeURIComponent(value)]; - } - return [null]; - - case "@csv": { - if (!Array.isArray(value)) return [null]; - const csvEscaped = value.map((v) => { - if (v === null) return ""; - if (typeof v === "boolean") return v ? "true" : "false"; - if (typeof v === "number") return String(v); - // Only quote strings that contain special characters (comma, quote, newline) - const s = String(v); - if ( - s.includes(",") || - s.includes('"') || - s.includes("\n") || - s.includes("\r") - ) { - return `"${s.replace(/"/g, '""')}"`; - } - return s; - }); - return [csvEscaped.join(",")]; - } - - case "@tsv": { - if (!Array.isArray(value)) return [null]; - return [ - value - .map((v) => - String(v ?? "") - .replace(/\t/g, "\\t") - .replace(/\n/g, "\\n"), - ) - .join("\t"), - ]; - } - - case "@json": { - // Check depth to avoid V8 stack overflow during JSON.stringify - const effectiveMaxDepth = maxDepth ?? DEFAULT_MAX_JQ_DEPTH; - if (getValueDepth(value, effectiveMaxDepth + 1) > effectiveMaxDepth) { - return [null]; - } - return [JSON.stringify(value)]; - } - - case "@html": - if (typeof value === "string") { - return [ - value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/'/g, "'") - .replace(/"/g, """), - ]; - } - return [null]; - - case "@sh": - if (typeof value === "string") { - // Shell escape: wrap in single quotes, escape any single quotes - return [`'${value.replace(/'/g, "'\\''")}'`]; - } - return [null]; - - case "@text": - if (typeof value === "string") return [value]; - if (value === null || value === undefined) return [""]; - return [String(value)]; - - default: - return null; - } -} diff --git a/src/commands/query-engine/builtins/index-builtins.ts b/src/commands/query-engine/builtins/index-builtins.ts deleted file mode 100644 index 76f8bf0c..00000000 --- a/src/commands/query-engine/builtins/index-builtins.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Index-related jq builtins - * - * Handles index, rindex, and indices functions for finding positions in arrays/strings. - */ - -import type { EvalContext } from "../evaluator.js"; -import type { AstNode } from "../parser.js"; -import type { QueryValue } from "../value-operations.js"; - -type EvalFn = ( - value: QueryValue, - ast: AstNode, - ctx: EvalContext, -) => QueryValue[]; - -type DeepEqualFn = (a: QueryValue, b: QueryValue) => boolean; - -/** - * Handle index builtins that need evaluate function for arguments. - * Returns null if the builtin name is not an index builtin handled here. - */ -export function evalIndexBuiltin( - value: QueryValue, - name: string, - args: AstNode[], - ctx: EvalContext, - evaluate: EvalFn, - deepEqual: DeepEqualFn, -): QueryValue[] | null { - switch (name) { - case "index": { - if (args.length === 0) return [null]; - const needles = evaluate(value, args[0], ctx); - // Handle generator args - each needle produces its own output - return needles.map((needle) => { - if (typeof value === "string" && typeof needle === "string") { - // jq: index("") on "" returns null, not 0 - if (needle === "" && value === "") return null; - const idx = value.indexOf(needle); - return idx >= 0 ? idx : null; - } - if (Array.isArray(value)) { - // If needle is an array, search for it as a subsequence - if (Array.isArray(needle)) { - for (let i = 0; i <= value.length - needle.length; i++) { - let match = true; - for (let j = 0; j < needle.length; j++) { - if (!deepEqual(value[i + j], needle[j])) { - match = false; - break; - } - } - if (match) return i; - } - return null; - } - // Otherwise search for the element - const idx = value.findIndex((x) => deepEqual(x, needle)); - return idx >= 0 ? idx : null; - } - return null; - }); - } - - case "rindex": { - if (args.length === 0) return [null]; - const needles = evaluate(value, args[0], ctx); - // Handle generator args - each needle produces its own output - return needles.map((needle) => { - if (typeof value === "string" && typeof needle === "string") { - const idx = value.lastIndexOf(needle); - return idx >= 0 ? idx : null; - } - if (Array.isArray(value)) { - // If needle is an array, search for it as a subsequence from the end - if (Array.isArray(needle)) { - for (let i = value.length - needle.length; i >= 0; i--) { - let match = true; - for (let j = 0; j < needle.length; j++) { - if (!deepEqual(value[i + j], needle[j])) { - match = false; - break; - } - } - if (match) return i; - } - return null; - } - // Otherwise search for the element - for (let i = value.length - 1; i >= 0; i--) { - if (deepEqual(value[i], needle)) return i; - } - return null; - } - return null; - }); - } - - case "indices": { - if (args.length === 0) return [[]]; - const needles = evaluate(value, args[0], ctx); - // Handle generator args - each needle produces its own result array - return needles.map((needle) => { - const result: number[] = []; - if (typeof value === "string" && typeof needle === "string") { - let idx = value.indexOf(needle); - while (idx !== -1) { - result.push(idx); - idx = value.indexOf(needle, idx + 1); - } - } else if (Array.isArray(value)) { - if (Array.isArray(needle)) { - // Search for consecutive subarray matches - const needleLen = needle.length; - if (needleLen === 0) { - // Empty array matches at every position - for (let i = 0; i <= value.length; i++) result.push(i); - } else { - for (let i = 0; i <= value.length - needleLen; i++) { - let match = true; - for (let j = 0; j < needleLen; j++) { - if (!deepEqual(value[i + j], needle[j])) { - match = false; - break; - } - } - if (match) result.push(i); - } - } - } else { - // Search for individual element - for (let i = 0; i < value.length; i++) { - if (deepEqual(value[i], needle)) result.push(i); - } - } - } - return result; - }); - } - - default: - return null; - } -} diff --git a/src/commands/query-engine/builtins/index.ts b/src/commands/query-engine/builtins/index.ts deleted file mode 100644 index d1a60998..00000000 --- a/src/commands/query-engine/builtins/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * jq builtin functions index - * - * Re-exports all builtin handlers from category-specific modules. - */ - -export { evalArrayBuiltin } from "./array-builtins.js"; -export { evalControlBuiltin } from "./control-builtins.js"; -export { evalDateBuiltin } from "./date-builtins.js"; -export { evalFormatBuiltin } from "./format-builtins.js"; -export { evalIndexBuiltin } from "./index-builtins.js"; -export { evalMathBuiltin } from "./math-builtins.js"; -export { evalNavigationBuiltin } from "./navigation-builtins.js"; -export { evalObjectBuiltin } from "./object-builtins.js"; -export { evalPathBuiltin } from "./path-builtins.js"; -export { evalSqlBuiltin } from "./sql-builtins.js"; -export { evalStringBuiltin } from "./string-builtins.js"; -export { evalTypeBuiltin } from "./type-builtins.js"; diff --git a/src/commands/query-engine/builtins/math-builtins.ts b/src/commands/query-engine/builtins/math-builtins.ts deleted file mode 100644 index 39b52b9b..00000000 --- a/src/commands/query-engine/builtins/math-builtins.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Math-related jq builtins - * - * Handles mathematical functions like abs, pow, exp, trig functions, etc. - */ - -import type { EvalContext } from "../evaluator.js"; -import type { AstNode } from "../parser.js"; -import type { QueryValue } from "../value-operations.js"; - -type EvalFn = ( - value: QueryValue, - ast: AstNode, - ctx: EvalContext, -) => QueryValue[]; - -/** - * Handle math builtins that need evaluate function for arguments. - * Returns null if the builtin name is not a math builtin handled here. - */ -export function evalMathBuiltin( - value: QueryValue, - name: string, - args: AstNode[], - ctx: EvalContext, - evaluate: EvalFn, -): QueryValue[] | null { - switch (name) { - case "fabs": - case "abs": - if (typeof value === "number") return [Math.abs(value)]; - // jq returns strings unchanged for abs - if (typeof value === "string") return [value]; - return [null]; - - case "exp10": - if (typeof value === "number") return [10 ** value]; - return [null]; - - case "exp2": - if (typeof value === "number") return [2 ** value]; - return [null]; - - case "pow": { - // pow(base; exp) - two explicit arguments - if (args.length < 2) return [null]; - const bases = evaluate(value, args[0], ctx); - const exps = evaluate(value, args[1], ctx); - const base = bases[0]; - const exp = exps[0]; - if (typeof base !== "number" || typeof exp !== "number") return [null]; - return [base ** exp]; - } - - case "atan2": { - // atan2(y; x) - two explicit arguments - if (args.length < 2) return [null]; - const ys = evaluate(value, args[0], ctx); - const xs = evaluate(value, args[1], ctx); - const y = ys[0]; - const x = xs[0]; - if (typeof y !== "number" || typeof x !== "number") return [null]; - return [Math.atan2(y, x)]; - } - - case "hypot": { - if (typeof value !== "number" || args.length === 0) return [null]; - const y = evaluate(value, args[0], ctx)[0] as number; - return [Math.hypot(value, y)]; - } - - case "fma": { - if (typeof value !== "number" || args.length < 2) return [null]; - const y = evaluate(value, args[0], ctx)[0] as number; - const z = evaluate(value, args[1], ctx)[0] as number; - return [value * y + z]; - } - - case "copysign": { - if (typeof value !== "number" || args.length === 0) return [null]; - const y = evaluate(value, args[0], ctx)[0] as number; - return [Math.sign(y) * Math.abs(value)]; - } - - case "drem": - case "remainder": { - if (typeof value !== "number" || args.length === 0) return [null]; - const y = evaluate(value, args[0], ctx)[0] as number; - return [value - Math.round(value / y) * y]; - } - - case "fdim": { - if (typeof value !== "number" || args.length === 0) return [null]; - const y = evaluate(value, args[0], ctx)[0] as number; - return [Math.max(0, value - y)]; - } - - case "fmax": { - if (typeof value !== "number" || args.length === 0) return [null]; - const y = evaluate(value, args[0], ctx)[0] as number; - return [Math.max(value, y)]; - } - - case "fmin": { - if (typeof value !== "number" || args.length === 0) return [null]; - const y = evaluate(value, args[0], ctx)[0] as number; - return [Math.min(value, y)]; - } - - case "ldexp": { - if (typeof value !== "number" || args.length === 0) return [null]; - const exp = evaluate(value, args[0], ctx)[0] as number; - return [value * 2 ** exp]; - } - - case "scalbn": - case "scalbln": { - if (typeof value !== "number" || args.length === 0) return [null]; - const exp = evaluate(value, args[0], ctx)[0] as number; - return [value * 2 ** exp]; - } - - case "nearbyint": - if (typeof value === "number") return [Math.round(value)]; - return [null]; - - case "logb": - if (typeof value === "number") - return [Math.floor(Math.log2(Math.abs(value)))]; - return [null]; - - case "significand": - if (typeof value === "number") { - const exp = Math.floor(Math.log2(Math.abs(value))); - return [value / 2 ** exp]; - } - return [null]; - - case "frexp": - if (typeof value === "number") { - if (value === 0) return [[0, 0]]; - const exp = Math.floor(Math.log2(Math.abs(value))) + 1; - const mantissa = value / 2 ** exp; - return [[mantissa, exp]]; - } - return [null]; - - case "modf": - if (typeof value === "number") { - const intPart = Math.trunc(value); - const fracPart = value - intPart; - return [[fracPart, intPart]]; - } - return [null]; - - default: - return null; - } -} diff --git a/src/commands/query-engine/builtins/navigation-builtins.ts b/src/commands/query-engine/builtins/navigation-builtins.ts deleted file mode 100644 index e395241e..00000000 --- a/src/commands/query-engine/builtins/navigation-builtins.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * Navigation and traversal jq builtins - * - * Handles recurse, recurse_down, walk, transpose, combinations, parent, parents, root. - */ - -import type { EvalContext } from "../evaluator.js"; -import type { AstNode } from "../parser.js"; -import { isSafeKey, safeSet } from "../safe-object.js"; -import type { QueryValue } from "../value-operations.js"; - -type EvalFn = ( - value: QueryValue, - ast: AstNode, - ctx: EvalContext, -) => QueryValue[]; - -type IsTruthyFn = (v: QueryValue) => boolean; -type GetValueAtPathFn = ( - obj: QueryValue, - path: (string | number)[], -) => QueryValue; - -// Recursive forward reference for evalBuiltin -type EvalBuiltinFn = ( - value: QueryValue, - name: string, - args: AstNode[], - ctx: EvalContext, -) => QueryValue[]; - -/** - * Handle navigation builtins that need evaluate function for arguments. - * Returns null if the builtin name is not a navigation builtin handled here. - */ -export function evalNavigationBuiltin( - value: QueryValue, - name: string, - args: AstNode[], - ctx: EvalContext, - evaluate: EvalFn, - isTruthy: IsTruthyFn, - getValueAtPath: GetValueAtPathFn, - evalBuiltin: EvalBuiltinFn, -): QueryValue[] | null { - switch (name) { - case "recurse": { - if (args.length === 0) { - const results: QueryValue[] = []; - const walk = (v: QueryValue) => { - results.push(v); - if (Array.isArray(v)) { - for (const item of v) walk(item); - } else if (v && typeof v === "object") { - // @banned-pattern-ignore: iterating via Object.keys() which only returns own properties - for (const key of Object.keys(v)) { - walk((v as Record)[key]); - } - } - }; - walk(value); - return results; - } - const results: QueryValue[] = []; - const condExpr = args.length >= 2 ? args[1] : null; - const maxDepth = 10000; // Prevent infinite loops - let depth = 0; - const walk = (v: QueryValue) => { - if (depth++ > maxDepth) return; - // Check condition if provided (recurse(f; cond)) - if (condExpr) { - const condResults = evaluate(v, condExpr, ctx); - if (!condResults.some(isTruthy)) return; - } - results.push(v); - const next = evaluate(v, args[0], ctx); - for (const n of next) { - if (n !== null && n !== undefined) walk(n); - } - }; - walk(value); - return results; - } - - case "recurse_down": - return evalBuiltin(value, "recurse", args, ctx); - - case "walk": { - if (args.length === 0) return [value]; - const seen = new WeakSet(); - const walkFn = (v: QueryValue): QueryValue => { - if (v && typeof v === "object") { - if (seen.has(v as object)) return v; - seen.add(v as object); - } - let transformed: QueryValue; - if (Array.isArray(v)) { - transformed = v.map(walkFn); - } else if (v && typeof v === "object") { - // Use null-prototype for additional safety - const obj: Record = Object.create(null); - for (const [k, val] of Object.entries(v)) { - // Defense against prototype pollution - if (isSafeKey(k)) { - safeSet(obj, k, walkFn(val)); - } - } - transformed = obj; - } else { - transformed = v; - } - const results = evaluate(transformed, args[0], ctx); - return results[0]; - }; - return [walkFn(value)]; - } - - case "transpose": { - if (!Array.isArray(value)) return [null]; - if (value.length === 0) return [[]]; - const maxLen = Math.max( - ...value.map((row) => (Array.isArray(row) ? row.length : 0)), - ); - const result: QueryValue[][] = []; - for (let i = 0; i < maxLen; i++) { - result.push(value.map((row) => (Array.isArray(row) ? row[i] : null))); - } - return [result]; - } - - case "combinations": { - // Generate Cartesian product of arrays - // combinations with no args: input is array of arrays, generate all combinations - // combinations(n): generate n-length combinations from input array - if (args.length > 0) { - // combinations(n) - n-tuples from input array - const ns = evaluate(value, args[0], ctx); - const n = ns[0] as number; - if (!Array.isArray(value) || n < 0) return []; - if (n === 0) return [[]]; - // Generate all n-length combinations with repetition - const results: QueryValue[][] = []; - const generate = (current: QueryValue[], depth: number) => { - if (depth === n) { - results.push([...current]); - return; - } - for (const item of value) { - current.push(item); - generate(current, depth + 1); - current.pop(); - } - }; - generate([], 0); - return results; - } - // combinations with no args - Cartesian product of array of arrays - if (!Array.isArray(value)) return []; - if (value.length === 0) return [[]]; - // Check all elements are arrays - for (const arr of value) { - if (!Array.isArray(arr)) return []; - } - // Generate Cartesian product - const results: QueryValue[][] = []; - const generate = (index: number, current: QueryValue[]) => { - if (index === value.length) { - results.push([...current]); - return; - } - const arr = value[index] as QueryValue[]; - for (const item of arr) { - current.push(item); - generate(index + 1, current); - current.pop(); - } - }; - generate(0, []); - return results; - } - - // Navigation operators - case "parent": { - if (ctx.root === undefined || ctx.currentPath === undefined) return []; - const path = ctx.currentPath; - if (path.length === 0) return []; // At root, no parent - - // Get levels argument (default: 1) - const levels = - args.length > 0 ? (evaluate(value, args[0], ctx)[0] as number) : 1; - - if (levels >= 0) { - // Positive: go up n levels - if (levels > path.length) return []; // Beyond root - const parentPath = path.slice(0, path.length - levels); - return [getValueAtPath(ctx.root, parentPath)]; - } else { - // Negative: index from root (-1 = root, -2 = one below root, etc.) - // -1 means path length 0 (root) - // -2 means path length 1 (one level below root) - const targetLen = -levels - 1; - if (targetLen >= path.length) return [value]; // Beyond current - const parentPath = path.slice(0, targetLen); - return [getValueAtPath(ctx.root, parentPath)]; - } - } - - case "parents": { - if (ctx.root === undefined || ctx.currentPath === undefined) return [[]]; - const path = ctx.currentPath; - const parents: QueryValue[] = []; - // Build array of parents from immediate parent to root - for (let i = path.length - 1; i >= 0; i--) { - parents.push(getValueAtPath(ctx.root, path.slice(0, i))); - } - return [parents]; - } - - case "root": - return ctx.root !== undefined ? [ctx.root] : []; - - default: - return null; - } -} diff --git a/src/commands/query-engine/builtins/object-builtins.ts b/src/commands/query-engine/builtins/object-builtins.ts deleted file mode 100644 index d2c11ccb..00000000 --- a/src/commands/query-engine/builtins/object-builtins.ts +++ /dev/null @@ -1,399 +0,0 @@ -/** - * Object-related jq builtins - * - * Handles object manipulation functions like keys, to_entries, from_entries, etc. - */ - -import type { EvalContext } from "../evaluator.js"; -import type { AstNode } from "../parser.js"; -import { isSafeKey, safeSet } from "../safe-object.js"; -import { getValueDepth, type QueryValue } from "../value-operations.js"; - -// Default max depth for nested structures -const DEFAULT_MAX_JQ_DEPTH = 2000; - -type EvalFn = ( - value: QueryValue, - ast: AstNode, - ctx: EvalContext, -) => QueryValue[]; - -/** - * Handle object builtins that need evaluate function for arguments. - * Returns null if the builtin name is not an object builtin handled here. - */ -export function evalObjectBuiltin( - value: QueryValue, - name: string, - args: AstNode[], - ctx: EvalContext, - evaluate: EvalFn, -): QueryValue[] | null { - switch (name) { - case "keys": - if (Array.isArray(value)) return [value.map((_, i) => i)]; - if (value && typeof value === "object") - return [Object.keys(value).sort()]; - return [null]; - - case "keys_unsorted": - if (Array.isArray(value)) return [value.map((_, i) => i)]; - if (value && typeof value === "object") return [Object.keys(value)]; - return [null]; - - case "length": - if (typeof value === "string") return [value.length]; - if (Array.isArray(value)) return [value.length]; - if (value && typeof value === "object") - return [Object.keys(value).length]; - if (value === null) return [0]; - // jq: length of a number is its absolute value - if (typeof value === "number") return [Math.abs(value)]; - return [null]; - - case "utf8bytelength": { - if (typeof value === "string") - return [new TextEncoder().encode(value).length]; - // jq: throws error for non-strings with type info - const typeName = - value === null ? "null" : Array.isArray(value) ? "array" : typeof value; - const valueStr = - typeName === "array" || typeName === "object" - ? JSON.stringify(value) - : String(value); - throw new Error( - `${typeName} (${valueStr}) only strings have UTF-8 byte length`, - ); - } - - case "to_entries": - if (value && typeof value === "object" && !Array.isArray(value)) { - return [ - Object.entries(value as Record).map( - ([key, val]) => ({ key, value: val }), - ), - ]; - } - return [null]; - - case "from_entries": - if (Array.isArray(value)) { - const result: Record = Object.create(null); - for (const item of value) { - if (item && typeof item === "object") { - const obj = item as Record; - // jq supports: key, Key, name, Name, k for the key - const key = obj.key ?? obj.Key ?? obj.name ?? obj.Name ?? obj.k; - // jq supports: value, Value, v for the value - const val = obj.value ?? obj.Value ?? obj.v; - if (key !== undefined) { - const strKey = String(key); - // Defense against prototype pollution: skip dangerous keys - if (isSafeKey(strKey)) { - safeSet(result, strKey, val); - } - } - } - } - return [result]; - } - return [null]; - - case "with_entries": { - if (args.length === 0) return [value]; - if (value && typeof value === "object" && !Array.isArray(value)) { - const entries = Object.entries(value as Record).map( - ([key, val]) => ({ - key, - value: val, - }), - ); - const mapped = entries.flatMap((e) => evaluate(e, args[0], ctx)); - const result: Record = Object.create(null); - for (const item of mapped) { - if (item && typeof item === "object") { - const obj = item as Record; - const key = obj.key ?? obj.name ?? obj.k; - const val = obj.value ?? obj.v; - if (key !== undefined) { - const strKey = String(key); - // Defense against prototype pollution: skip dangerous keys - if (isSafeKey(strKey)) { - safeSet(result, strKey, val); - } - } - } - } - return [result]; - } - return [null]; - } - - case "reverse": - if (Array.isArray(value)) return [[...value].reverse()]; - if (typeof value === "string") - return [value.split("").reverse().join("")]; - return [null]; - - case "flatten": { - if (!Array.isArray(value)) return [null]; - const depths = - args.length > 0 - ? evaluate(value, args[0], ctx) - : [Number.POSITIVE_INFINITY]; - // Handle generator args - each depth produces its own output - return depths.map((d) => { - const depth = d as number; - if (depth < 0) { - throw new Error("flatten depth must not be negative"); - } - return value.flat(depth); - }); - } - - case "unique": - if (Array.isArray(value)) { - const seen = new Set(); - const result: QueryValue[] = []; - for (const item of value) { - const key = JSON.stringify(item); - if (!seen.has(key)) { - seen.add(key); - result.push(item); - } - } - return [result]; - } - return [null]; - - case "tojson": - case "tojsonstream": { - // Check depth to avoid V8 stack overflow during JSON.stringify - const maxDepth = ctx.limits.maxDepth ?? DEFAULT_MAX_JQ_DEPTH; - if (getValueDepth(value, maxDepth + 1) > maxDepth) { - return [null]; - } - return [JSON.stringify(value)]; - } - - case "fromjson": { - if (typeof value === "string") { - // jq extension: "nan" and "inf"/"infinity" are valid - const trimmed = value.trim().toLowerCase(); - if (trimmed === "nan") { - return [Number.NaN]; - } - if (trimmed === "inf" || trimmed === "infinity") { - return [Number.POSITIVE_INFINITY]; - } - if (trimmed === "-inf" || trimmed === "-infinity") { - return [Number.NEGATIVE_INFINITY]; - } - try { - return [JSON.parse(value)]; - } catch { - throw new Error(`Invalid JSON: ${value}`); - } - } - return [value]; - } - - case "tostring": - if (typeof value === "string") return [value]; - return [JSON.stringify(value)]; - - case "tonumber": - if (typeof value === "number") return [value]; - if (typeof value === "string") { - const n = Number(value); - if (Number.isNaN(n)) { - throw new Error( - `${JSON.stringify(value)} cannot be parsed as a number`, - ); - } - return [n]; - } - throw new Error(`${typeof value} cannot be parsed as a number`); - - case "toboolean": { - // jq: toboolean converts "true"/"false" strings and booleans to booleans - if (typeof value === "boolean") return [value]; - if (typeof value === "string") { - if (value === "true") return [true]; - if (value === "false") return [false]; - throw new Error( - `string (${JSON.stringify(value)}) cannot be parsed as a boolean`, - ); - } - const typeName = - value === null ? "null" : Array.isArray(value) ? "array" : typeof value; - const valueStr = - typeName === "array" || typeName === "object" - ? JSON.stringify(value) - : String(value); - throw new Error( - `${typeName} (${valueStr}) cannot be parsed as a boolean`, - ); - } - - case "tostream": { - // tostream outputs [path, leaf_value] pairs for each leaf, plus [[]] at end - const results: QueryValue[] = []; - const walk = (v: QueryValue, path: (string | number)[]) => { - if (v === null || typeof v !== "object") { - // Leaf value - output [path, value] - results.push([path, v]); - } else if (Array.isArray(v)) { - if (v.length === 0) { - // Empty array - output [path, []] - results.push([path, []]); - } else { - for (let i = 0; i < v.length; i++) { - walk(v[i], [...path, i]); - } - } - } else { - const keys = Object.keys(v); - if (keys.length === 0) { - // Empty object - output [path, {}] - results.push([path, Object.create(null)]); - } else { - // @banned-pattern-ignore: iterating via Object.keys() which only returns own properties - for (const key of keys) { - walk((v as Record)[key], [...path, key]); - } - } - } - }; - walk(value, []); - // End marker: [[]] (empty path array wrapped in array) - results.push([[]]); - return results; - } - - case "fromstream": { - // fromstream(stream_expr) reconstructs values from stream of [path, value] pairs - if (args.length === 0) return [value]; - const streamItems = evaluate(value, args[0], ctx); - let result: QueryValue = null; - - for (const item of streamItems) { - if (!Array.isArray(item)) continue; - if ( - item.length === 1 && - Array.isArray(item[0]) && - item[0].length === 0 - ) { - // End marker [[]] - skip - continue; - } - if (item.length !== 2) continue; - const [path, val] = item; - if (!Array.isArray(path)) continue; - - // Set value at path, creating structure as needed - if (path.length === 0) { - result = val; - continue; - } - - // Auto-create root structure based on first path element - if (result === null) { - result = typeof path[0] === "number" ? [] : {}; - } - - // Navigate to parent and set value - let current: QueryValue = result; - for (let i = 0; i < path.length - 1; i++) { - const key = path[i]; - const nextKey = path[i + 1]; - if (Array.isArray(current) && typeof key === "number") { - // Extend array if needed - while (current.length <= key) { - current.push(null); - } - if (current[key] === null) { - current[key] = typeof nextKey === "number" ? [] : {}; - } - current = current[key]; - } else if ( - current && - typeof current === "object" && - !Array.isArray(current) - ) { - const strKey = String(key); - // Defense against prototype pollution: skip dangerous keys - if (!isSafeKey(strKey)) continue; - const obj = current as Record; - if (obj[strKey] === null || obj[strKey] === undefined) { - safeSet(obj, strKey, typeof nextKey === "number" ? [] : {}); - } - current = obj[strKey] as QueryValue; - } - } - - // Set the final value - const lastKey = path[path.length - 1]; - if (Array.isArray(current) && typeof lastKey === "number") { - while (current.length <= lastKey) { - current.push(null); - } - current[lastKey] = val; - } else if ( - current && - typeof current === "object" && - !Array.isArray(current) - ) { - const strLastKey = String(lastKey); - // Defense against prototype pollution: skip dangerous keys - if (isSafeKey(strLastKey)) { - safeSet(current as Record, strLastKey, val); - } - } - } - - return [result]; - } - - case "truncate_stream": { - // truncate_stream(stream_items) truncates paths by removing first n elements - // where n is the input value (depth) - const depth = typeof value === "number" ? Math.floor(value) : 0; - if (args.length === 0) return []; - - const results: QueryValue[] = []; - const streamItems = evaluate(value, args[0], ctx); - - for (const item of streamItems) { - if (!Array.isArray(item)) continue; - - // Handle end markers [[path]] (length 1, first element is array) - if (item.length === 1 && Array.isArray(item[0])) { - const path = item[0] as (string | number)[]; - if (path.length > depth) { - // Truncate the path - results.push([path.slice(depth)]); - } - // If path.length <= depth, skip (becomes root end marker) - continue; - } - - // Handle value items [[path], value] (length 2) - if (item.length === 2 && Array.isArray(item[0])) { - const path = item[0] as (string | number)[]; - const val = item[1]; - if (path.length > depth) { - // Truncate the path - results.push([path.slice(depth), val]); - } - // If path.length <= depth, skip - } - } - - return results; - } - - default: - return null; - } -} diff --git a/src/commands/query-engine/builtins/path-builtins.ts b/src/commands/query-engine/builtins/path-builtins.ts deleted file mode 100644 index 023e59cf..00000000 --- a/src/commands/query-engine/builtins/path-builtins.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * Path-related jq builtins - * - * Handles path manipulation functions like getpath, setpath, delpaths, paths, etc. - */ - -import type { EvalContext } from "../evaluator.js"; -import type { AstNode } from "../parser.js"; -import type { QueryValue } from "../value-operations.js"; - -type EvalFn = ( - value: QueryValue, - ast: AstNode, - ctx: EvalContext, -) => QueryValue[]; - -type IsTruthyFn = (v: QueryValue) => boolean; -type SetPathFn = ( - obj: QueryValue, - path: (string | number)[], - val: QueryValue, -) => QueryValue; -type DeletePathFn = (obj: QueryValue, path: (string | number)[]) => QueryValue; -type ApplyDelFn = ( - value: QueryValue, - expr: AstNode, - ctx: EvalContext, -) => QueryValue; -type CollectPathsFn = ( - value: QueryValue, - expr: AstNode, - ctx: EvalContext, - currentPath: (string | number)[], - paths: (string | number)[][], -) => void; - -/** - * Handle path builtins that need evaluate function for arguments. - * Returns null if the builtin name is not a path builtin handled here. - */ -export function evalPathBuiltin( - value: QueryValue, - name: string, - args: AstNode[], - ctx: EvalContext, - evaluate: EvalFn, - isTruthy: IsTruthyFn, - setPath: SetPathFn, - deletePath: DeletePathFn, - applyDel: ApplyDelFn, - collectPaths: CollectPathsFn, -): QueryValue[] | null { - switch (name) { - case "getpath": { - if (args.length === 0) return [null]; - const paths = evaluate(value, args[0], ctx); - // Handle multiple paths (generator argument) - const results: QueryValue[] = []; - for (const pathVal of paths) { - const path = pathVal as (string | number)[]; - let current: QueryValue = value; - for (const key of path) { - if (current === null || current === undefined) { - current = null; - break; - } - if (Array.isArray(current) && typeof key === "number") { - current = current[key]; - } else if (typeof current === "object" && typeof key === "string") { - // Defense against prototype pollution: only access own properties - const obj = current as Record; - if (!Object.hasOwn(obj, key)) { - current = null; - break; - } - current = obj[key]; - } else { - current = null; - break; - } - } - results.push(current); - } - return results; - } - - case "setpath": { - if (args.length < 2) return [null]; - const paths = evaluate(value, args[0], ctx); - const path = paths[0] as (string | number)[]; - const vals = evaluate(value, args[1], ctx); - const newVal = vals[0]; - return [setPath(value, path, newVal)]; - } - - case "delpaths": { - if (args.length === 0) return [value]; - const pathLists = evaluate(value, args[0], ctx); - const paths = pathLists[0] as (string | number)[][]; - let result = value; - for (const path of paths.sort((a, b) => b.length - a.length)) { - result = deletePath(result, path); - } - return [result]; - } - - case "path": { - if (args.length === 0) return [[]]; - const paths: (string | number)[][] = []; - collectPaths(value, args[0], ctx, [], paths); - return paths; - } - - case "del": { - if (args.length === 0) return [value]; - return [applyDel(value, args[0], ctx)]; - } - - case "pick": { - if (args.length === 0) return [null]; - // pick uses path() to get paths, then builds an object with just those paths - // Collect paths from each argument - const allPaths: (string | number)[][] = []; - for (const arg of args) { - collectPaths(value, arg, ctx, [], allPaths); - } - // Build result object with only the picked paths - let result: QueryValue = null; - for (const path of allPaths) { - // Check for negative indices which are not allowed - for (const key of path) { - if (typeof key === "number" && key < 0) { - throw new Error("Out of bounds negative array index"); - } - } - // Get the value at this path from the input - let current: QueryValue = value; - for (const key of path) { - if (current === null || current === undefined) break; - if (Array.isArray(current) && typeof key === "number") { - current = current[key]; - } else if (typeof current === "object" && typeof key === "string") { - // Defense against prototype pollution: only access own properties - const obj = current as Record; - if (!Object.hasOwn(obj, key)) { - current = null; - break; - } - current = obj[key]; - } else { - current = null; - break; - } - } - // Set the value in the result - result = setPath(result, path, current); - } - return [result]; - } - - case "paths": { - const paths: (string | number)[][] = []; - const walk = (v: QueryValue, path: (string | number)[]) => { - if (v && typeof v === "object") { - if (Array.isArray(v)) { - for (let i = 0; i < v.length; i++) { - paths.push([...path, i]); - walk(v[i], [...path, i]); - } - } else { - for (const key of Object.keys(v)) { - paths.push([...path, key]); - // @banned-pattern-ignore: iterating via Object.keys() which only returns own properties - walk((v as Record)[key], [...path, key]); - } - } - } - }; - walk(value, []); - if (args.length > 0) { - return paths.filter((p) => { - let v: QueryValue = value; - for (const k of p) { - if (Array.isArray(v) && typeof k === "number") { - v = v[k]; - } else if (v && typeof v === "object" && typeof k === "string") { - // Defense against prototype pollution: only access own properties - const obj = v as Record; - if (!Object.hasOwn(obj, k)) { - return false; - } - v = obj[k]; - } else { - return false; - } - } - const results = evaluate(v, args[0], ctx); - return results.some(isTruthy); - }); - } - return paths; - } - - case "leaf_paths": { - const paths: (string | number)[][] = []; - const walk = (v: QueryValue, path: (string | number)[]) => { - if (v === null || typeof v !== "object") { - paths.push(path); - } else if (Array.isArray(v)) { - for (let i = 0; i < v.length; i++) { - walk(v[i], [...path, i]); - } - } else { - // @banned-pattern-ignore: iterating via Object.keys() which only returns own properties - for (const key of Object.keys(v)) { - walk((v as Record)[key], [...path, key]); - } - } - }; - walk(value, []); - // Return each path as a separate output (like paths does) - return paths; - } - - default: - return null; - } -} diff --git a/src/commands/query-engine/builtins/sql-builtins.ts b/src/commands/query-engine/builtins/sql-builtins.ts deleted file mode 100644 index 184a94cf..00000000 --- a/src/commands/query-engine/builtins/sql-builtins.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * SQL-like jq builtins - * - * Handles IN, INDEX, and JOIN functions. - */ - -import type { EvalContext } from "../evaluator.js"; -import type { AstNode } from "../parser.js"; -import { isSafeKey, safeHasOwn, safeSet } from "../safe-object.js"; -import type { QueryValue } from "../value-operations.js"; - -type EvalFn = ( - value: QueryValue, - ast: AstNode, - ctx: EvalContext, -) => QueryValue[]; - -type DeepEqualFn = (a: QueryValue, b: QueryValue) => boolean; - -/** - * Handle SQL-like builtins that need evaluate function for arguments. - * Returns null if the builtin name is not a SQL builtin handled here. - */ -export function evalSqlBuiltin( - value: QueryValue, - name: string, - args: AstNode[], - ctx: EvalContext, - evaluate: EvalFn, - deepEqual: DeepEqualFn, -): QueryValue[] | null { - switch (name) { - case "IN": { - // IN(stream) - check if input is in stream - // IN(stream1; stream2) - check if any value from stream1 is in stream2 - if (args.length === 0) return [false]; - if (args.length === 1) { - // x | IN(stream) - check if x is in any value from stream - const streamVals = evaluate(value, args[0], ctx); - for (const v of streamVals) { - if (deepEqual(value, v)) return [true]; - } - return [false]; - } - // IN(stream1; stream2) - check if any value from stream1 is in stream2 - const stream1Vals = evaluate(value, args[0], ctx); - const stream2Vals = evaluate(value, args[1], ctx); - const stream2Set = new Set(stream2Vals.map((v) => JSON.stringify(v))); - for (const v of stream1Vals) { - if (stream2Set.has(JSON.stringify(v))) return [true]; - } - return [false]; - } - - case "INDEX": { - // INDEX(stream) - create object mapping values to themselves - // INDEX(stream; idx_expr) - create object using idx_expr as key - // INDEX(stream; idx_expr; val_expr) - create object with idx_expr keys and val_expr values - if (args.length === 0) return [{}]; - if (args.length === 1) { - // INDEX(stream) - index by the values themselves (like group_by) - const streamVals = evaluate(value, args[0], ctx); - const result: Record = Object.create(null); - for (const v of streamVals) { - const key = String(v); - // Defense against prototype pollution - if (isSafeKey(key)) { - safeSet(result, key, v); - } - } - return [result]; - } - if (args.length === 2) { - // INDEX(stream; idx_expr) - index by idx_expr applied to each value - const streamVals = evaluate(value, args[0], ctx); - const result: Record = Object.create(null); - for (const v of streamVals) { - const keys = evaluate(v, args[1], ctx); - if (keys.length > 0) { - const key = String(keys[0]); - // Defense against prototype pollution - if (isSafeKey(key)) { - safeSet(result, key, v); - } - } - } - return [result]; - } - // INDEX(stream; idx_expr; val_expr) - const streamVals = evaluate(value, args[0], ctx); - const result: Record = Object.create(null); - for (const v of streamVals) { - const keys = evaluate(v, args[1], ctx); - const vals = evaluate(v, args[2], ctx); - if (keys.length > 0 && vals.length > 0) { - const key = String(keys[0]); - // Defense against prototype pollution - if (isSafeKey(key)) { - safeSet(result, key, vals[0]); - } - } - } - return [result]; - } - - case "JOIN": { - // JOIN(idx; key_expr) - SQL-like join - // For each item in input array, lookup in idx using key_expr, return [item, lookup_value] - // If not found, returns [item, null] - if (args.length < 2) return [null]; - const idx = evaluate(value, args[0], ctx)[0]; - if (!idx || typeof idx !== "object" || Array.isArray(idx)) return [null]; - const idxObj = idx as Record; - if (!Array.isArray(value)) return [null]; - const results: QueryValue[] = []; - for (const item of value) { - const keys = evaluate(item, args[1], ctx); - const key = keys.length > 0 ? String(keys[0]) : ""; - const lookup = safeHasOwn(idxObj, key) ? idxObj[key] : null; - results.push([item, lookup]); - } - return [results]; - } - - default: - return null; - } -} diff --git a/src/commands/query-engine/builtins/string-builtins.ts b/src/commands/query-engine/builtins/string-builtins.ts deleted file mode 100644 index 2f807415..00000000 --- a/src/commands/query-engine/builtins/string-builtins.ts +++ /dev/null @@ -1,318 +0,0 @@ -/** - * String-related jq builtins - * - * Handles string manipulation functions like join, split, test, match, gsub, etc. - */ - -import { createUserRegex } from "../../../regex/index.js"; -import type { EvalContext } from "../evaluator.js"; -import type { AstNode } from "../parser.js"; -import type { QueryValue } from "../value-operations.js"; - -type EvalFn = ( - value: QueryValue, - ast: AstNode, - ctx: EvalContext, -) => QueryValue[]; - -/** - * Handle string builtins that need evaluate function for arguments. - * Returns null if the builtin name is not a string builtin handled here. - */ -export function evalStringBuiltin( - value: QueryValue, - name: string, - args: AstNode[], - ctx: EvalContext, - evaluate: EvalFn, -): QueryValue[] | null { - switch (name) { - case "join": { - if (!Array.isArray(value)) return [null]; - const seps = args.length > 0 ? evaluate(value, args[0], ctx) : [""]; - // jq: null values become empty strings, others get stringified - // Also check for arrays/objects which should error - for (const x of value) { - if (Array.isArray(x) || (x !== null && typeof x === "object")) { - throw new Error("cannot join: contains arrays or objects"); - } - } - // Handle generator args - each separator produces its own output - return seps.map((sep) => - value - .map((x) => (x === null ? "" : typeof x === "string" ? x : String(x))) - .join(String(sep)), - ); - } - - case "split": { - if (typeof value !== "string" || args.length === 0) return [null]; - const seps = evaluate(value, args[0], ctx); - const sep = String(seps[0]); - return [value.split(sep)]; - } - - case "splits": { - // Split string by regex, return each part as separate output - if (typeof value !== "string" || args.length === 0) return []; - const patterns = evaluate(value, args[0], ctx); - const pattern = String(patterns[0]); - try { - const flags = - args.length > 1 ? String(evaluate(value, args[1], ctx)[0]) : "g"; - // Ensure global flag is set for split - const regex = createUserRegex( - pattern, - flags.includes("g") ? flags : `${flags}g`, - ); - return regex.split(value); - } catch { - return []; - } - } - - case "scan": { - // Find all regex matches in string - if (typeof value !== "string" || args.length === 0) return []; - const patterns = evaluate(value, args[0], ctx); - const pattern = String(patterns[0]); - try { - const flags = - args.length > 1 ? String(evaluate(value, args[1], ctx)[0]) : ""; - // Ensure global flag is set for matchAll - const regex = createUserRegex( - pattern, - flags.includes("g") ? flags : `${flags}g`, - ); - const matches = [...regex.matchAll(value)]; - // Return each match - if groups exist, return array of groups, else return match string - return matches.map((m) => { - if (m.length > 1) { - // Has capture groups - return array of captured groups (excluding full match) - return m.slice(1); - } - // No capture groups - return full match string - return m[0]; - }); - } catch { - return []; - } - } - - case "test": { - if (typeof value !== "string" || args.length === 0) return [false]; - const patterns = evaluate(value, args[0], ctx); - const pattern = String(patterns[0]); - try { - const flags = - args.length > 1 ? String(evaluate(value, args[1], ctx)[0]) : ""; - return [createUserRegex(pattern, flags).test(value)]; - } catch { - return [false]; - } - } - - case "match": { - if (typeof value !== "string" || args.length === 0) return [null]; - const patterns = evaluate(value, args[0], ctx); - const pattern = String(patterns[0]); - try { - const flags = - args.length > 1 ? String(evaluate(value, args[1], ctx)[0]) : ""; - const re = createUserRegex(pattern, `${flags}d`); - const m = re.exec(value); - if (!m) return []; - const indices = ( - m as RegExpExecArray & { indices?: [number, number][] } - ).indices; - return [ - { - offset: m.index, - length: m[0].length, - string: m[0], - captures: m.slice(1).map((c, i) => { - const captureIndices = indices?.[i + 1]; - return { - offset: captureIndices?.[0] ?? null, - length: c?.length ?? 0, - string: c ?? "", - name: null, - }; - }), - }, - ]; - } catch { - return [null]; - } - } - - case "capture": { - if (typeof value !== "string" || args.length === 0) return [null]; - const patterns = evaluate(value, args[0], ctx); - const pattern = String(patterns[0]); - try { - const flags = - args.length > 1 ? String(evaluate(value, args[1], ctx)[0]) : ""; - const re = createUserRegex(pattern, flags); - const m = re.match(value); - if (!m || !m.groups) return [{}]; - return [m.groups]; - } catch { - return [null]; - } - } - - case "sub": { - if (typeof value !== "string" || args.length < 2) return [null]; - const patterns = evaluate(value, args[0], ctx); - const replacements = evaluate(value, args[1], ctx); - const pattern = String(patterns[0]); - const replacement = String(replacements[0]); - try { - const flags = - args.length > 2 ? String(evaluate(value, args[2], ctx)[0]) : ""; - return [createUserRegex(pattern, flags).replace(value, replacement)]; - } catch { - return [value]; - } - } - - case "gsub": { - if (typeof value !== "string" || args.length < 2) return [null]; - const patterns = evaluate(value, args[0], ctx); - const replacements = evaluate(value, args[1], ctx); - const pattern = String(patterns[0]); - const replacement = String(replacements[0]); - try { - const flags = - args.length > 2 ? String(evaluate(value, args[2], ctx)[0]) : "g"; - const effectiveFlags = flags.includes("g") ? flags : `${flags}g`; - return [ - createUserRegex(pattern, effectiveFlags).replace(value, replacement), - ]; - } catch { - return [value]; - } - } - - case "ascii_downcase": - if (typeof value === "string") { - return [ - value.replace(/[A-Z]/g, (c) => - String.fromCharCode(c.charCodeAt(0) + 32), - ), - ]; - } - return [null]; - - case "ascii_upcase": - if (typeof value === "string") { - return [ - value.replace(/[a-z]/g, (c) => - String.fromCharCode(c.charCodeAt(0) - 32), - ), - ]; - } - return [null]; - - case "ltrimstr": { - if (typeof value !== "string" || args.length === 0) return [value]; - const prefixes = evaluate(value, args[0], ctx); - const prefix = String(prefixes[0]); - return [value.startsWith(prefix) ? value.slice(prefix.length) : value]; - } - - case "rtrimstr": { - if (typeof value !== "string" || args.length === 0) return [value]; - const suffixes = evaluate(value, args[0], ctx); - const suffix = String(suffixes[0]); - // Handle empty suffix case (slice(0, -0) = slice(0, 0) = "") - if (suffix === "") return [value]; - return [value.endsWith(suffix) ? value.slice(0, -suffix.length) : value]; - } - - case "trimstr": { - if (typeof value !== "string" || args.length === 0) return [value]; - const strs = evaluate(value, args[0], ctx); - const str = String(strs[0]); - if (str === "") return [value]; - let result = value; - if (result.startsWith(str)) result = result.slice(str.length); - if (result.endsWith(str)) result = result.slice(0, -str.length); - return [result]; - } - - case "trim": - if (typeof value === "string") return [value.trim()]; - throw new Error("trim input must be a string"); - - case "ltrim": - if (typeof value === "string") return [value.trimStart()]; - throw new Error("trim input must be a string"); - - case "rtrim": - if (typeof value === "string") return [value.trimEnd()]; - throw new Error("trim input must be a string"); - - case "startswith": { - if (typeof value !== "string" || args.length === 0) return [false]; - const prefixes = evaluate(value, args[0], ctx); - return [value.startsWith(String(prefixes[0]))]; - } - - case "endswith": { - if (typeof value !== "string" || args.length === 0) return [false]; - const suffixes = evaluate(value, args[0], ctx); - return [value.endsWith(String(suffixes[0]))]; - } - - case "ascii": - if (typeof value === "string" && value.length > 0) { - return [value.charCodeAt(0)]; - } - return [null]; - - case "explode": - if (typeof value === "string") { - return [Array.from(value).map((c) => c.codePointAt(0))]; - } - return [null]; - - case "implode": - if (!Array.isArray(value)) { - throw new Error("implode input must be an array"); - } - { - // jq: Invalid code points get replaced with Unicode replacement character (0xFFFD) - const REPLACEMENT_CHAR = 0xfffd; - const chars = (value as QueryValue[]).map((cp) => { - // Check for non-numeric values - if (typeof cp === "string") { - throw new Error( - `string (${JSON.stringify(cp)}) can't be imploded, unicode codepoint needs to be numeric`, - ); - } - if (typeof cp !== "number" || Number.isNaN(cp)) { - throw new Error( - `number (null) can't be imploded, unicode codepoint needs to be numeric`, - ); - } - // Truncate to integer - const code = Math.trunc(cp); - // Check for valid Unicode code point - // Valid range: 0 to 0x10FFFF, excluding surrogate pairs (0xD800-0xDFFF) - if (code < 0 || code > 0x10ffff) { - return String.fromCodePoint(REPLACEMENT_CHAR); - } - if (code >= 0xd800 && code <= 0xdfff) { - return String.fromCodePoint(REPLACEMENT_CHAR); - } - return String.fromCodePoint(code); - }); - return [chars.join("")]; - } - - default: - return null; - } -} diff --git a/src/commands/query-engine/builtins/type-builtins.ts b/src/commands/query-engine/builtins/type-builtins.ts deleted file mode 100644 index 3a7ab86d..00000000 --- a/src/commands/query-engine/builtins/type-builtins.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Type-related jq builtins - * - * Handles type checking and type filtering functions like type, numbers, strings, etc. - */ - -import type { QueryValue } from "../value-operations.js"; - -/** - * Handle type builtins. - * Returns null if the builtin name is not a type builtin handled here. - */ -export function evalTypeBuiltin( - value: QueryValue, - name: string, -): QueryValue[] | null { - switch (name) { - case "type": - if (value === null) return ["null"]; - if (Array.isArray(value)) return ["array"]; - if (typeof value === "boolean") return ["boolean"]; - if (typeof value === "number") return ["number"]; - if (typeof value === "string") return ["string"]; - if (typeof value === "object") return ["object"]; - return ["null"]; - - case "infinite": - // jq: `infinite` produces positive infinity - return [Number.POSITIVE_INFINITY]; - - case "nan": - // jq: `nan` produces NaN value - return [Number.NaN]; - - case "isinfinite": - return [typeof value === "number" && !Number.isFinite(value)]; - - case "isnan": - return [typeof value === "number" && Number.isNaN(value)]; - - case "isnormal": - return [ - typeof value === "number" && Number.isFinite(value) && value !== 0, - ]; - - case "isfinite": - return [typeof value === "number" && Number.isFinite(value)]; - - case "numbers": - return typeof value === "number" ? [value] : []; - - case "strings": - return typeof value === "string" ? [value] : []; - - case "booleans": - return typeof value === "boolean" ? [value] : []; - - case "nulls": - return value === null ? [value] : []; - - case "arrays": - return Array.isArray(value) ? [value] : []; - - case "objects": - return value && typeof value === "object" && !Array.isArray(value) - ? [value] - : []; - - case "iterables": - return Array.isArray(value) || - (value && typeof value === "object" && !Array.isArray(value)) - ? [value] - : []; - - case "scalars": - return !Array.isArray(value) && !(value && typeof value === "object") - ? [value] - : []; - - case "values": - // jq: values outputs input if not null, nothing otherwise - if (value === null) return []; - return [value]; - - case "not": - // jq: not returns the logical negation - if (value === false || value === null) return [true]; - return [false]; - - case "null": - return [null]; - - case "true": - return [true]; - - case "false": - return [false]; - - case "empty": - return []; - - default: - return null; - } -} diff --git a/src/commands/query-engine/evaluator.ts b/src/commands/query-engine/evaluator.ts deleted file mode 100644 index ea02834c..00000000 --- a/src/commands/query-engine/evaluator.ts +++ /dev/null @@ -1,1921 +0,0 @@ -/** - * Query expression evaluator - * - * Evaluates a parsed query AST against any value. - * Used by jq, yq, and other query-based commands. - */ - -import { mapToRecord } from "../../helpers/env.js"; -import { ExecutionLimitError } from "../../interpreter/errors.js"; -import type { FeatureCoverageWriter } from "../../types.js"; -import { - evalArrayBuiltin, - evalControlBuiltin, - evalDateBuiltin, - evalFormatBuiltin, - evalIndexBuiltin, - evalMathBuiltin, - evalNavigationBuiltin, - evalObjectBuiltin, - evalPathBuiltin, - evalSqlBuiltin, - evalStringBuiltin, - evalTypeBuiltin, -} from "./builtins/index.js"; -import type { AstNode, DestructurePattern } from "./parser.js"; -import { deletePath, setPath } from "./path-operations.js"; -import { - isSafeKey, - nullPrototypeCopy, - nullPrototypeMerge, - safeHasOwn, - safeSet, -} from "./safe-object.js"; -import { - compare, - compareJq, - containsDeep, - deepEqual, - deepMerge, - getValueDepth, - isTruthy, - type QueryValue, -} from "./value-operations.js"; - -export type { QueryValue } from "./value-operations.js"; - -class BreakError extends Error { - constructor( - public readonly label: string, - public readonly partialResults: QueryValue[] = [], - ) { - super(`break ${label}`); - this.name = "BreakError"; - } - - withPrependedResults(results: QueryValue[]): BreakError { - return new BreakError(this.label, [...results, ...this.partialResults]); - } -} - -// Custom error that preserves the original jq value -class JqError extends Error { - constructor(public readonly value: QueryValue) { - super(typeof value === "string" ? value : JSON.stringify(value)); - this.name = "JqError"; - } -} - -const DEFAULT_MAX_JQ_ITERATIONS = 10000; -// Depth limit for nested structures - must be low enough to avoid V8 stack overflow -// during JSON.stringify/parse which have their own recursion limits (~2000-10000 depending on V8 version) -const DEFAULT_MAX_JQ_DEPTH = 2000; - -/** - * Simple math functions that take a single numeric argument and return a single numeric result. - * Maps jq function names to their JavaScript Math implementations. - * Uses Map to avoid prototype pollution (e.g., if someone tries to call "constructor" as a function). - */ -const SIMPLE_MATH_FUNCTIONS = new Map number>([ - ["floor", Math.floor], - ["ceil", Math.ceil], - ["round", Math.round], - ["sqrt", Math.sqrt], - ["log", Math.log], - ["log10", Math.log10], - ["log2", Math.log2], - ["exp", Math.exp], - ["sin", Math.sin], - ["cos", Math.cos], - ["tan", Math.tan], - ["asin", Math.asin], - ["acos", Math.acos], - ["atan", Math.atan], - ["sinh", Math.sinh], - ["cosh", Math.cosh], - ["tanh", Math.tanh], - ["asinh", Math.asinh], - ["acosh", Math.acosh], - ["atanh", Math.atanh], - ["cbrt", Math.cbrt], - ["expm1", Math.expm1], - ["log1p", Math.log1p], - ["trunc", Math.trunc], -]); - -export interface QueryExecutionLimits { - maxIterations?: number; - maxDepth?: number; -} - -export interface EvalContext { - vars: Map; - limits: Required> & - QueryExecutionLimits; - env?: Map; - /** Original document root for parent/root navigation */ - root?: QueryValue; - /** Current path from root for parent navigation */ - currentPath?: (string | number)[]; - funcs?: Map< - string, - { params: string[]; body: AstNode; closure?: Map } - >; - labels?: Set; - /** Feature coverage writer for fuzzing instrumentation */ - coverage?: FeatureCoverageWriter; -} - -function createContext(options?: EvaluateOptions): EvalContext { - return { - vars: new Map(), - limits: { - maxIterations: - options?.limits?.maxIterations ?? DEFAULT_MAX_JQ_ITERATIONS, - maxDepth: options?.limits?.maxDepth ?? DEFAULT_MAX_JQ_DEPTH, - }, - env: options?.env, - coverage: options?.coverage, - }; -} - -function withVar( - ctx: EvalContext, - name: string, - value: QueryValue, -): EvalContext { - const newVars = new Map(ctx.vars); - newVars.set(name, value); - return { - vars: newVars, - limits: ctx.limits, - env: ctx.env, - root: ctx.root, - currentPath: ctx.currentPath, - funcs: ctx.funcs, - labels: ctx.labels, - coverage: ctx.coverage, - }; -} - -/** - * Bind variables according to a destructuring pattern - * Returns null if the pattern doesn't match the value - */ -function bindPattern( - ctx: EvalContext, - pattern: DestructurePattern, - value: QueryValue, -): EvalContext | null { - switch (pattern.type) { - case "var": - return withVar(ctx, pattern.name, value); - - case "array": { - if (!Array.isArray(value)) return null; - let newCtx = ctx; - for (let i = 0; i < pattern.elements.length; i++) { - const elem = pattern.elements[i]; - const elemValue = i < value.length ? value[i] : null; - const result = bindPattern(newCtx, elem, elemValue); - if (result === null) return null; - newCtx = result; - } - return newCtx; - } - - case "object": { - if (value === null || typeof value !== "object" || Array.isArray(value)) { - return null; - } - const obj = value as Record; - let newCtx = ctx; - for (const field of pattern.fields) { - // Get the key - could be a string or a computed expression - let key: string; - if (typeof field.key === "string") { - key = field.key; - } else { - // Computed key - evaluate it - const keyVals = evaluate(value, field.key, ctx); - if (keyVals.length === 0) return null; - key = String(keyVals[0]); - } - const fieldValue = safeHasOwn(obj, key) ? obj[key] : null; - // If keyVar is set (e.g., $b:[$c,$d]), also bind the key variable to the whole value - if (field.keyVar) { - newCtx = withVar(newCtx, field.keyVar, fieldValue); - } - const result = bindPattern(newCtx, field.pattern, fieldValue); - if (result === null) return null; - newCtx = result; - } - return newCtx; - } - } -} - -function getValueAtPath( - root: QueryValue, - path: (string | number)[], -): QueryValue { - let v = root; - for (const key of path) { - if (v && typeof v === "object") { - if (Array.isArray(v)) { - if (typeof key === "number") { - v = v[key]; - } else { - return undefined; - } - } else { - // Defense against prototype pollution: only access own properties - const obj = v as Record; - if (typeof key === "string" && Object.hasOwn(obj, key)) { - v = obj[key]; - } else { - return undefined; - } - } - } else { - return undefined; - } - } - return v; -} - -/** - * Extract a simple path from an AST node (e.g., .a.b.c -> ["a", "b", "c"]) - * Returns null if the AST is not a simple path expression. - * Handles Pipe nodes with parent/root to track path adjustments. - */ -function extractPathFromAst(ast: AstNode): (string | number)[] | null { - if (ast.type === "Identity") return []; - if (ast.type === "Field") { - const basePath = ast.base ? extractPathFromAst(ast.base) : []; - if (basePath === null) return null; - return [...basePath, ast.name]; - } - if (ast.type === "Index" && ast.index.type === "Literal") { - const basePath = ast.base ? extractPathFromAst(ast.base) : []; - if (basePath === null) return null; - const idx = ast.index.value; - if (typeof idx === "number" || typeof idx === "string") { - return [...basePath, idx]; - } - return null; - } - // Handle Pipe nodes to track path through parent/root calls - if (ast.type === "Pipe") { - const leftPath = extractPathFromAst(ast.left); - if (leftPath === null) return null; - // Apply right side transformation to the path - return applyPathTransform(leftPath, ast.right); - } - // Handle parent/root builtins for path adjustment - if (ast.type === "Call") { - if (ast.name === "parent") { - // parent without context returns null (needs base path from pipe) - return null; - } - if (ast.name === "root") { - // root resets to document root - return null; - } - // first without args is .[0], last without args is .[-1] - if (ast.name === "first" && ast.args.length === 0) { - return [0]; - } - if (ast.name === "last" && ast.args.length === 0) { - return [-1]; - } - } - // For other node types, we can't extract a simple path - return null; -} - -/** - * Apply a path transformation (like parent or root) to a base path. - */ -function applyPathTransform( - basePath: (string | number)[], - ast: AstNode, -): (string | number)[] | null { - if (ast.type === "Call") { - if (ast.name === "parent") { - // Get levels - default is 1, or extract from literal arg - let levels = 1; - if (ast.args.length > 0 && ast.args[0].type === "Literal") { - const arg = ast.args[0].value; - if (typeof arg === "number") levels = arg; - } - if (levels >= 0) { - // Positive: go up n levels - return basePath.slice(0, Math.max(0, basePath.length - levels)); - } else { - // Negative: index from root (-1 = root, -2 = one below root) - const targetLen = -levels - 1; - return basePath.slice(0, Math.min(targetLen, basePath.length)); - } - } - if (ast.name === "root") { - return []; - } - } - // For Field/Index on right side, extend the path - if (ast.type === "Field") { - const rightPath = extractPathFromAst(ast); - if (rightPath !== null) { - return [...basePath, ...rightPath]; - } - } - if (ast.type === "Index" && ast.index.type === "Literal") { - const rightPath = extractPathFromAst(ast); - if (rightPath !== null) { - return [...basePath, ...rightPath]; - } - } - // For nested pipes, recurse - if (ast.type === "Pipe") { - const afterLeft = applyPathTransform(basePath, ast.left); - if (afterLeft === null) return null; - return applyPathTransform(afterLeft, ast.right); - } - // Identity doesn't change path - if (ast.type === "Identity") { - return basePath; - } - // For other transformations, we lose path tracking - return null; -} - -export interface EvaluateOptions { - limits?: QueryExecutionLimits; - env?: Map; - coverage?: FeatureCoverageWriter; -} - -/** - * Evaluate an expression and return partial results even if an error occurs. - * Used for functions like isempty() that need to know if ANY value was produced. - */ -function evaluateWithPartialResults( - value: QueryValue, - ast: AstNode, - ctx: EvalContext, -): QueryValue[] { - // For comma expressions, try to get partial results - if (ast.type === "Comma") { - const results: QueryValue[] = []; - try { - results.push(...evaluate(value, ast.left, ctx)); - } catch (e) { - // Always re-throw execution limit errors - they must not be suppressed - if (e instanceof ExecutionLimitError) throw e; - // Left side errored, check if we have any results - if (results.length > 0) return results; - throw new Error("evaluation failed"); - } - try { - results.push(...evaluate(value, ast.right, ctx)); - } catch (e) { - // Always re-throw execution limit errors - if (e instanceof ExecutionLimitError) throw e; - // Right side errored, return what we have from left - return results; - } - return results; - } - // For other expressions, use normal evaluation - return evaluate(value, ast, ctx); -} - -export function evaluate( - value: QueryValue, - ast: AstNode, - ctxOrOptions?: EvalContext | EvaluateOptions, -): QueryValue[] { - let ctx: EvalContext = - ctxOrOptions && "vars" in ctxOrOptions - ? ctxOrOptions - : createContext(ctxOrOptions as EvaluateOptions | undefined); - - // Initialize root if not set (first evaluation) - if (ctx.root === undefined) { - ctx = { ...ctx, root: value, currentPath: [] }; - } - - ctx.coverage?.hit(`jq:node:${ast.type}`); - switch (ast.type) { - case "Identity": - return [value]; - - case "Field": { - const bases = ast.base ? evaluate(value, ast.base, ctx) : [value]; - return bases.flatMap((v) => { - if (v && typeof v === "object" && !Array.isArray(v)) { - // Defense against prototype pollution: only return own properties - // This prevents access to inherited methods like __defineGetter__, constructor, etc. - const obj = v as Record; - if (!Object.hasOwn(obj, ast.name)) { - return [null]; - } - const result = obj[ast.name]; - return [result === undefined ? null : result]; - } - // jq: indexing null always returns null - if (v === null) { - return [null]; - } - // jq throws an error when accessing a field on non-objects (arrays, numbers, strings, booleans) - // This allows Try (.foo?) to catch it and return empty - const typeName = Array.isArray(v) ? "array" : typeof v; - throw new Error(`Cannot index ${typeName} with string "${ast.name}"`); - }); - } - - case "Index": { - const bases = ast.base ? evaluate(value, ast.base, ctx) : [value]; - return bases.flatMap((v) => { - const indices = evaluate(v, ast.index, ctx); - return indices.flatMap((idx) => { - if (typeof idx === "number" && Array.isArray(v)) { - // Handle NaN - return null for NaN index - if (Number.isNaN(idx)) { - return [null]; - } - // Truncate float index to integer (jq behavior) - const truncated = Math.trunc(idx); - const i = truncated < 0 ? v.length + truncated : truncated; - return i >= 0 && i < v.length ? [v[i]] : [null]; - } - if ( - typeof idx === "string" && - v && - typeof v === "object" && - !Array.isArray(v) - ) { - // Defense against prototype pollution: only return own properties - const obj = v as Record; - if (!Object.hasOwn(obj, idx)) { - return [null]; - } - return [obj[idx]]; - } - return [null]; - }); - }); - } - - case "Slice": { - const bases = ast.base ? evaluate(value, ast.base, ctx) : [value]; - return bases.flatMap((v) => { - // null can be sliced and returns null - if (v === null) return [null]; - if (!Array.isArray(v) && typeof v !== "string") { - throw new Error(`Cannot slice ${typeof v} (${JSON.stringify(v)})`); - } - const len = v.length; - const starts = ast.start ? evaluate(value, ast.start, ctx) : [0]; - const ends = ast.end ? evaluate(value, ast.end, ctx) : [len]; - return starts.flatMap((s) => - ends.map((e) => { - // jq uses floor for start and ceil for end (for fractional indices) - // NaN in start position → 0, NaN in end position → length - const sNum = s as number; - const eNum = e as number; - const startRaw = Number.isNaN(sNum) - ? 0 - : Number.isInteger(sNum) - ? sNum - : Math.floor(sNum); - const endRaw = Number.isNaN(eNum) - ? len - : Number.isInteger(eNum) - ? eNum - : Math.ceil(eNum); - const start = normalizeIndex(startRaw, len); - const end = normalizeIndex(endRaw, len); - return Array.isArray(v) ? v.slice(start, end) : v.slice(start, end); - }), - ); - }); - } - - case "Iterate": { - const bases = ast.base ? evaluate(value, ast.base, ctx) : [value]; - return bases.flatMap((v) => { - if (Array.isArray(v)) return v; - if (v && typeof v === "object") return Object.values(v); - return []; - }); - } - - case "Pipe": { - const leftResults = evaluate(value, ast.left, ctx); - const leftPath = extractPathFromAst(ast.left); - const pipeResults: QueryValue[] = []; - for (const v of leftResults) { - try { - if (leftPath !== null) { - const newCtx = { - ...ctx, - currentPath: [...(ctx.currentPath ?? []), ...leftPath], - }; - pipeResults.push(...evaluate(v, ast.right, newCtx)); - } else { - pipeResults.push(...evaluate(v, ast.right, ctx)); - } - } catch (e) { - if (e instanceof BreakError) { - throw e.withPrependedResults(pipeResults); - } - throw e; - } - } - return pipeResults; - } - case "Comma": { - const leftResults = evaluate(value, ast.left, ctx); - const rightResults = evaluate(value, ast.right, ctx); - return [...leftResults, ...rightResults]; - } - - case "Literal": - return [ast.value]; - - case "Array": { - if (!ast.elements) return [[]]; - const elements = evaluate(value, ast.elements, ctx); - return [elements]; - } - - case "Object": { - const results: Record[] = [Object.create(null)]; - - for (const entry of ast.entries) { - const keys = - typeof entry.key === "string" - ? [entry.key] - : evaluate(value, entry.key, ctx); - const values = evaluate(value, entry.value, ctx); - - // @banned-pattern-ignore: array declaration, objects added via nullPrototypeCopy - const newResults: Record[] = []; - for (const obj of results) { - for (const k of keys) { - // jq requires object keys to be strings - if (typeof k !== "string") { - const typeName = - k === null ? "null" : Array.isArray(k) ? "array" : typeof k; - throw new Error( - `Cannot use ${typeName} (${JSON.stringify(k)}) as object key`, - ); - } - // Defense against prototype pollution: skip dangerous keys - if (!isSafeKey(k)) { - // Still produce output but without the dangerous key - for (const _v of values) { - newResults.push(nullPrototypeCopy(obj)); - } - continue; - } - for (const v of values) { - const newObj = nullPrototypeCopy(obj); - safeSet(newObj, k, v); - newResults.push(newObj); - } - } - } - results.length = 0; - results.push(...newResults); - } - - return results; - } - - case "Paren": - return evaluate(value, ast.expr, ctx); - - case "BinaryOp": - return evalBinaryOp(value, ast.op, ast.left, ast.right, ctx); - - case "UnaryOp": { - const operands = evaluate(value, ast.operand, ctx); - return operands.map((v) => { - if (ast.op === "-") { - if (typeof v === "number") return -v; - if (typeof v === "string") { - // jq: strings cannot be negated - format truncates long strings - // jq format: "string (\"truncated...) - no closing quote when truncated - const formatStr = (s: string) => - s.length > 5 ? `"${s.slice(0, 3)}...` : JSON.stringify(s); - throw new Error(`string (${formatStr(v)}) cannot be negated`); - } - return null; - } - if (ast.op === "not") return !isTruthy(v); - return null; - }); - } - - case "Cond": { - const conds = evaluate(value, ast.cond, ctx); - return conds.flatMap((c) => { - if (isTruthy(c)) { - return evaluate(value, ast.then, ctx); - } - for (const elif of ast.elifs) { - const elifConds = evaluate(value, elif.cond, ctx); - if (elifConds.some(isTruthy)) { - return evaluate(value, elif.then, ctx); - } - } - if (ast.else) { - return evaluate(value, ast.else, ctx); - } - // jq: if no else clause and condition is false, return input unchanged - return [value]; - }); - } - - case "Try": { - try { - return evaluate(value, ast.body, ctx); - } catch (e) { - if (ast.catch) { - // jq: In catch handler, input is the error value (preserved if JqError) - const errorVal = - e instanceof JqError - ? e.value - : e instanceof Error - ? e.message - : String(e); - return evaluate(errorVal, ast.catch, ctx); - } - return []; - } - } - - case "Call": - return evalBuiltin(value, ast.name, ast.args, ctx); - - case "VarBind": { - const values = evaluate(value, ast.value, ctx); - return values.flatMap((v) => { - let newCtx: EvalContext | null = null; - - // Build list of patterns to try: primary pattern + alternatives - const patternsToTry: DestructurePattern[] = []; - if (ast.pattern) { - patternsToTry.push(ast.pattern); - } else if (ast.name) { - patternsToTry.push({ type: "var", name: ast.name }); - } - if (ast.alternatives) { - patternsToTry.push(...ast.alternatives); - } - - // Try each pattern until one matches - for (const pattern of patternsToTry) { - newCtx = bindPattern(ctx, pattern, v); - if (newCtx !== null) { - break; // Pattern matched - } - } - - if (newCtx === null) { - // No pattern matched - skip this value - return []; - } - - return evaluate(value, ast.body, newCtx); - }); - } - - case "VarRef": { - // Special case: $ENV returns environment variables - // Note: ast.name includes the $ prefix (e.g., "$ENV") - if (ast.name === "$ENV") { - // Convert Map to object for jq's internal representation (null-prototype prevents prototype pollution) - return [ctx.env ? mapToRecord(ctx.env) : Object.create(null)]; - } - const v = ctx.vars.get(ast.name); - return v !== undefined ? [v] : [null]; - } - - case "Recurse": { - const results: QueryValue[] = []; - const seen = new WeakSet(); - const walk = (val: QueryValue) => { - if (val && typeof val === "object") { - if (seen.has(val as object)) return; - seen.add(val as object); - } - results.push(val); - if (Array.isArray(val)) { - for (const item of val) walk(item); - } else if (val && typeof val === "object") { - // @banned-pattern-ignore: iterating via Object.keys() which only returns own properties - for (const key of Object.keys(val)) { - walk((val as Record)[key]); - } - } - }; - walk(value); - return results; - } - - case "Optional": { - try { - return evaluate(value, ast.expr, ctx); - } catch { - return []; - } - } - - case "StringInterp": { - const parts = ast.parts.map((part) => { - if (typeof part === "string") return part; - const vals = evaluate(value, part, ctx); - return vals - .map((v) => (typeof v === "string" ? v : JSON.stringify(v))) - .join(""); - }); - return [parts.join("")]; - } - - case "UpdateOp": { - return [applyUpdate(value, ast.path, ast.op, ast.value, ctx)]; - } - - case "Reduce": { - const items = evaluate(value, ast.expr, ctx); - let accumulator = evaluate(value, ast.init, ctx)[0]; - const maxDepth = ctx.limits.maxDepth ?? DEFAULT_MAX_JQ_DEPTH; - for (const item of items) { - let newCtx: EvalContext | null; - if (ast.pattern) { - newCtx = bindPattern(ctx, ast.pattern, item); - if (newCtx === null) continue; // Pattern doesn't match, skip - } else { - newCtx = withVar(ctx, ast.varName, item); - } - accumulator = evaluate(accumulator, ast.update, newCtx)[0]; - // Check depth limit to prevent stack overflow with deeply nested structures - if (getValueDepth(accumulator, maxDepth + 1) > maxDepth) { - return [null]; - } - } - return [accumulator]; - } - - case "Foreach": { - const items = evaluate(value, ast.expr, ctx); - let state = evaluate(value, ast.init, ctx)[0]; - const foreachResults: QueryValue[] = []; - for (const item of items) { - try { - let newCtx: EvalContext | null; - if (ast.pattern) { - newCtx = bindPattern(ctx, ast.pattern, item); - if (newCtx === null) continue; // Pattern doesn't match, skip - } else { - newCtx = withVar(ctx, ast.varName, item); - } - state = evaluate(state, ast.update, newCtx)[0]; - if (ast.extract) { - const extracted = evaluate(state, ast.extract, newCtx); - foreachResults.push(...extracted); - } else { - foreachResults.push(state); - } - } catch (e) { - if (e instanceof BreakError) { - throw e.withPrependedResults(foreachResults); - } - throw e; - } - } - return foreachResults; - } - - case "Label": { - try { - return evaluate(value, ast.body, { - ...ctx, - labels: new Set([...(ctx.labels ?? []), ast.name]), - }); - } catch (e) { - if (e instanceof BreakError && e.label === ast.name) { - return e.partialResults; - } - throw e; - } - } - - case "Break": { - throw new BreakError(ast.name); - } - - case "Def": { - // Register the function in context and evaluate the body - // Functions are keyed by name/arity to allow overloading (e.g., def f: ...; def f(a): ...) - // Store closure (current funcs map) for lexical scoping - const newFuncs = new Map(ctx.funcs ?? []); - const funcKey = `${ast.name}/${ast.params.length}`; - // Capture the current funcs map as the closure for this function - newFuncs.set(funcKey, { - params: ast.params, - body: ast.funcBody, - closure: new Map(ctx.funcs ?? []), - }); - const newCtx: EvalContext = { ...ctx, funcs: newFuncs }; - return evaluate(value, ast.body, newCtx); - } - - default: { - const _exhaustive: never = ast; - throw new Error( - `Unknown AST node type: ${(_exhaustive as AstNode).type}`, - ); - } - } -} - -function normalizeIndex(idx: number, len: number): number { - if (idx < 0) return Math.max(0, len + idx); - return Math.min(idx, len); -} - -function applyUpdate( - root: QueryValue, - pathExpr: AstNode, - op: string, - valueExpr: AstNode, - ctx: EvalContext, -): QueryValue { - function computeNewValue( - current: QueryValue, - newVal: QueryValue, - ): QueryValue { - switch (op) { - case "=": - return newVal; - case "|=": { - const results = evaluate(current, valueExpr, ctx); - return results[0] ?? null; - } - case "+=": - if (typeof current === "number" && typeof newVal === "number") - return current + newVal; - if (typeof current === "string" && typeof newVal === "string") - return current + newVal; - if (Array.isArray(current) && Array.isArray(newVal)) - return [...current, ...newVal]; - if ( - current && - newVal && - typeof current === "object" && - typeof newVal === "object" - ) { - return nullPrototypeMerge(current, newVal); - } - return newVal; - case "-=": - if (typeof current === "number" && typeof newVal === "number") - return current - newVal; - return current; - case "*=": - if (typeof current === "number" && typeof newVal === "number") - return current * newVal; - return current; - case "/=": - if (typeof current === "number" && typeof newVal === "number") - return current / newVal; - return current; - case "%=": - if (typeof current === "number" && typeof newVal === "number") - return current % newVal; - return current; - case "//=": - return current === null || current === false ? newVal : current; - default: - return newVal; - } - } - - function updateRecursive( - val: QueryValue, - path: AstNode, - transform: (current: QueryValue) => QueryValue, - ): QueryValue { - switch (path.type) { - case "Identity": - return transform(val); - - case "Field": { - // Defense against prototype pollution: skip dangerous keys - if (!isSafeKey(path.name)) { - return val; - } - if (path.base) { - return updateRecursive(val, path.base, (baseVal) => { - if ( - baseVal && - typeof baseVal === "object" && - !Array.isArray(baseVal) - ) { - const obj = nullPrototypeCopy(baseVal); - const current = Object.hasOwn(obj, path.name) - ? obj[path.name] - : undefined; - safeSet(obj, path.name, transform(current)); - return obj; - } - return baseVal; - }); - } - if (val && typeof val === "object" && !Array.isArray(val)) { - const obj = nullPrototypeCopy(val); - const current = Object.hasOwn(obj, path.name) - ? obj[path.name] - : undefined; - safeSet(obj, path.name, transform(current)); - return obj; - } - return val; - } - - case "Index": { - const indices = evaluate(root, path.index, ctx); - let idx = indices[0]; - - // Handle NaN index - throw error for assignment - if (typeof idx === "number" && Number.isNaN(idx)) { - throw new Error("Cannot set array element at NaN index"); - } - - // Truncate float index to integer for assignment - if (typeof idx === "number" && !Number.isInteger(idx)) { - idx = Math.trunc(idx); - } - - if (path.base) { - return updateRecursive(val, path.base, (baseVal) => { - if (typeof idx === "number" && Array.isArray(baseVal)) { - const arr = [...baseVal]; - const i = idx < 0 ? arr.length + idx : idx; - if (i >= 0) { - // Extend array if needed - while (arr.length <= i) arr.push(null); - arr[i] = transform(arr[i]); - } - return arr; - } - if ( - typeof idx === "string" && - baseVal && - typeof baseVal === "object" && - !Array.isArray(baseVal) - ) { - // Defense against prototype pollution: skip dangerous keys - if (!isSafeKey(idx)) { - return baseVal; - } - const obj = nullPrototypeCopy(baseVal); - const current = Object.hasOwn(obj, idx) ? obj[idx] : undefined; - safeSet(obj, idx, transform(current)); - return obj; - } - return baseVal; - }); - } - - if (typeof idx === "number") { - // jq: Array index too large - const MAX_ARRAY_INDEX = 536870911; - if (idx > MAX_ARRAY_INDEX) { - throw new Error("Array index too large"); - } - // jq: Out of bounds negative array index when base is null/non-array - if (idx < 0 && (!val || !Array.isArray(val))) { - throw new Error("Out of bounds negative array index"); - } - if (Array.isArray(val)) { - const arr = [...val]; - const i = idx < 0 ? arr.length + idx : idx; - if (i >= 0) { - // Extend array if needed - while (arr.length <= i) arr.push(null); - arr[i] = transform(arr[i]); - } - return arr; - } - // Create array if val is null - if (val === null || val === undefined) { - const arr: QueryValue[] = []; - while (arr.length <= idx) arr.push(null); - arr[idx] = transform(null); - return arr; - } - return val; - } - if ( - typeof idx === "string" && - val && - typeof val === "object" && - !Array.isArray(val) - ) { - // Defense against prototype pollution: skip dangerous keys - if (!isSafeKey(idx)) { - return val; - } - const obj = nullPrototypeCopy(val); - const current = Object.hasOwn(obj, idx) ? obj[idx] : undefined; - safeSet(obj, idx, transform(current)); - return obj; - } - return val; - } - - case "Iterate": { - const applyToContainer = (container: QueryValue): QueryValue => { - if (Array.isArray(container)) { - return container.map((item) => transform(item)); - } - if (container && typeof container === "object") { - // Use null-prototype to prevent prototype pollution - const obj: Record = Object.create(null); - for (const [k, v] of Object.entries(container)) { - // Defense against prototype pollution: skip dangerous keys - if (isSafeKey(k)) { - safeSet(obj, k, transform(v)); - } - } - return obj; - } - return container; - }; - - if (path.base) { - return updateRecursive(val, path.base, applyToContainer); - } - return applyToContainer(val); - } - - case "Pipe": { - const leftResult = updateRecursive(val, path.left, (x) => x); - return updateRecursive(leftResult, path.right, transform); - } - - default: - return transform(val); - } - } - - const transformer = (current: QueryValue): QueryValue => { - if (op === "|=") { - return computeNewValue(current, current); - } - const newVals = evaluate(root, valueExpr, ctx); - return computeNewValue(current, newVals[0] ?? null); - }; - - return updateRecursive(root, pathExpr, transformer); -} - -function applyDel( - root: QueryValue, - pathExpr: AstNode, - ctx: EvalContext, -): QueryValue { - // Helper to set a value at an AST path - function setAtPath( - obj: QueryValue, - pathNode: AstNode, - newVal: QueryValue, - ): QueryValue { - switch (pathNode.type) { - case "Identity": - return newVal; - case "Field": { - // Defense against prototype pollution: skip dangerous keys - if (!isSafeKey(pathNode.name)) { - return obj; - } - if (pathNode.base) { - // Nested field: recurse into base - const nested = evaluate(obj, pathNode.base, ctx)[0]; - const modified = setAtPath( - nested, - { type: "Field", name: pathNode.name }, - newVal, - ); - return setAtPath(obj, pathNode.base, modified); - } - // Direct field - if (obj && typeof obj === "object" && !Array.isArray(obj)) { - const result = nullPrototypeCopy(obj); - safeSet(result, pathNode.name, newVal); - return result; - } - return obj; - } - case "Index": { - if (pathNode.base) { - // Nested index: recurse into base - const nested = evaluate(obj, pathNode.base, ctx)[0]; - const modified = setAtPath( - nested, - { type: "Index", index: pathNode.index }, - newVal, - ); - return setAtPath(obj, pathNode.base, modified); - } - // Direct index - const indices = evaluate(root, pathNode.index, ctx); - const idx = indices[0]; - if (typeof idx === "number" && Array.isArray(obj)) { - const arr = [...obj]; - const i = idx < 0 ? arr.length + idx : idx; - if (i >= 0 && i < arr.length) { - arr[i] = newVal; - } - return arr; - } - if ( - typeof idx === "string" && - obj && - typeof obj === "object" && - !Array.isArray(obj) - ) { - // Defense against prototype pollution: skip dangerous keys - if (!isSafeKey(idx)) { - return obj; - } - const result = nullPrototypeCopy(obj); - safeSet(result, idx, newVal); - return result; - } - return obj; - } - default: - return obj; - } - } - - function deleteAt(val: QueryValue, path: AstNode): QueryValue { - switch (path.type) { - case "Identity": - return null; - - case "Field": { - // Defense against prototype pollution: skip dangerous keys - if (!isSafeKey(path.name)) { - return val; - } - // If there's a base (nested field like .a.b), recurse - if (path.base) { - // Evaluate base to get the nested object - const nested = evaluate(val, path.base, ctx)[0]; - if (nested === null || nested === undefined) { - return val; - } - // Delete field from nested object - const modified = deleteAt(nested, { type: "Field", name: path.name }); - // Set the modified value back at the base path - return setAtPath(val, path.base, modified); - } - // Direct field deletion (no base) - if (val && typeof val === "object" && !Array.isArray(val)) { - // Defense against prototype pollution: skip dangerous keys - if (!isSafeKey(path.name)) { - return val; - } - const obj = nullPrototypeCopy(val); - delete obj[path.name]; - return obj; - } - return val; - } - - case "Index": { - // If there's a base (nested index like .[0].a), recurse - if (path.base) { - // Evaluate base to get the nested object/array - const nested = evaluate(val, path.base, ctx)[0]; - if (nested === null || nested === undefined) { - return val; - } - // Delete at index from nested value - const modified = deleteAt(nested, { - type: "Index", - index: path.index, - }); - // Set the modified value back at the base path - return setAtPath(val, path.base, modified); - } - - const indices = evaluate(root, path.index, ctx); - const idx = indices[0]; - - if (typeof idx === "number" && Array.isArray(val)) { - const arr = [...val]; - const i = idx < 0 ? arr.length + idx : idx; - if (i >= 0 && i < arr.length) { - arr.splice(i, 1); - } - return arr; - } - if ( - typeof idx === "string" && - val && - typeof val === "object" && - !Array.isArray(val) - ) { - // Defense against prototype pollution: skip dangerous keys - if (!isSafeKey(idx)) { - return val; - } - const obj = nullPrototypeCopy(val); - delete obj[idx]; - return obj; - } - return val; - } - - case "Iterate": { - if (Array.isArray(val)) { - return []; - } - if (val && typeof val === "object") { - return Object.create(null); - } - return val; - } - - case "Pipe": { - // For nested paths like .a.b, navigate to .a and delete .b within it - const leftPath = path.left; - const rightPath = path.right; - - // Helper to set a value at an AST path - function setAt( - obj: QueryValue, - pathNode: AstNode, - newVal: QueryValue, - ): QueryValue { - switch (pathNode.type) { - case "Identity": - return newVal; - case "Field": { - // Defense against prototype pollution: skip dangerous keys - if (!isSafeKey(pathNode.name)) { - return obj; - } - if (obj && typeof obj === "object" && !Array.isArray(obj)) { - const result = nullPrototypeCopy(obj); - safeSet(result, pathNode.name, newVal); - return result; - } - return obj; - } - case "Index": { - const indices = evaluate(root, pathNode.index, ctx); - const idx = indices[0]; - if (typeof idx === "number" && Array.isArray(obj)) { - const arr = [...obj]; - const i = idx < 0 ? arr.length + idx : idx; - if (i >= 0 && i < arr.length) { - arr[i] = newVal; - } - return arr; - } - if ( - typeof idx === "string" && - obj && - typeof obj === "object" && - !Array.isArray(obj) - ) { - // Defense against prototype pollution: skip dangerous keys - if (!isSafeKey(idx)) { - return obj; - } - const result = nullPrototypeCopy(obj); - safeSet(result, idx, newVal); - return result; - } - return obj; - } - case "Pipe": { - // Recurse: set at leftPath with the result of setting at rightPath - const innerVal = evaluate(obj, pathNode.left, ctx)[0]; - const modified = setAt(innerVal, pathNode.right, newVal); - return setAt(obj, pathNode.left, modified); - } - default: - return obj; - } - } - - // Get the current value at the left path - const nested = evaluate(val, leftPath, ctx)[0]; - if (nested === null || nested === undefined) { - return val; // Nothing to delete - } - - // Apply deletion on the nested value - const modified = deleteAt(nested, rightPath); - - // Reconstruct the object with the modified nested value - return setAt(val, leftPath, modified); - } - - default: - return val; - } - } - - return deleteAt(root, pathExpr); -} - -function evalBinaryOp( - value: QueryValue, - op: string, - left: AstNode, - right: AstNode, - ctx: EvalContext, -): QueryValue[] { - // Short-circuit for 'and' and 'or' - if (op === "and") { - const leftVals = evaluate(value, left, ctx); - return leftVals.flatMap((l) => { - if (!isTruthy(l)) return [false]; - const rightVals = evaluate(value, right, ctx); - return rightVals.map((r) => isTruthy(r)); - }); - } - - if (op === "or") { - const leftVals = evaluate(value, left, ctx); - return leftVals.flatMap((l) => { - if (isTruthy(l)) return [true]; - const rightVals = evaluate(value, right, ctx); - return rightVals.map((r) => isTruthy(r)); - }); - } - - if (op === "//") { - const leftVals = evaluate(value, left, ctx); - const nonNull = leftVals.filter( - (v) => v !== null && v !== undefined && v !== false, - ); - if (nonNull.length > 0) return nonNull; - return evaluate(value, right, ctx); - } - - const leftVals = evaluate(value, left, ctx); - const rightVals = evaluate(value, right, ctx); - - return leftVals.flatMap((l) => - rightVals.map((r) => { - switch (op) { - case "+": - // jq: null + x = x, x + null = x - if (l === null) return r; - if (r === null) return l; - if (typeof l === "number" && typeof r === "number") return l + r; - if (typeof l === "string" && typeof r === "string") return l + r; - if (Array.isArray(l) && Array.isArray(r)) return [...l, ...r]; - if ( - l && - r && - typeof l === "object" && - typeof r === "object" && - !Array.isArray(l) && - !Array.isArray(r) - ) { - return nullPrototypeMerge(l, r); - } - return null; - case "-": - if (typeof l === "number" && typeof r === "number") return l - r; - if (Array.isArray(l) && Array.isArray(r)) { - const rSet = new Set(r.map((x) => JSON.stringify(x))); - return l.filter((x) => !rSet.has(JSON.stringify(x))); - } - if (typeof l === "string" && typeof r === "string") { - // jq: strings cannot be subtracted - format truncates long strings - // jq format: "string (\"truncated...) - no closing quote when truncated - const formatStr = (s: string) => - s.length > 10 ? `"${s.slice(0, 10)}...` : JSON.stringify(s); - throw new Error( - `string (${formatStr(l)}) and string (${formatStr(r)}) cannot be subtracted`, - ); - } - return null; - case "*": - if (typeof l === "number" && typeof r === "number") return l * r; - if (typeof l === "string" && typeof r === "number") - return l.repeat(r); - if ( - l && - r && - typeof l === "object" && - typeof r === "object" && - !Array.isArray(l) && - !Array.isArray(r) - ) { - return deepMerge( - l as Record, - r as Record, - ); - } - return null; - case "/": - if (typeof l === "number" && typeof r === "number") { - if (r === 0) { - throw new Error( - `number (${l}) and number (${r}) cannot be divided because the divisor is zero`, - ); - } - return l / r; - } - if (typeof l === "string" && typeof r === "string") return l.split(r); - return null; - case "%": - if (typeof l === "number" && typeof r === "number") { - if (r === 0) { - throw new Error( - `number (${l}) and number (${r}) cannot be divided (remainder) because the divisor is zero`, - ); - } - // jq: special handling for infinity modulo (but not NaN) - if (!Number.isFinite(l) && !Number.isNaN(l)) { - if (!Number.isFinite(r) && !Number.isNaN(r)) { - // -infinity % infinity = -1, others = 0 - return l < 0 && r > 0 ? -1 : 0; - } - // infinity % finite = 0 - return 0; - } - return l % r; - } - return null; - case "==": - return deepEqual(l, r); - case "!=": - return !deepEqual(l, r); - case "<": - return compare(l, r) < 0; - case "<=": - return compare(l, r) <= 0; - case ">": - return compare(l, r) > 0; - case ">=": - return compare(l, r) >= 0; - default: - return null; - } - }), - ); -} - -// ============================================================================ -// Builtins -// ============================================================================ - -function evalBuiltin( - value: QueryValue, - name: string, - args: AstNode[], - ctx: EvalContext, -): QueryValue[] { - // Handle simple single-argument math functions via lookup table - const simpleMathFn = SIMPLE_MATH_FUNCTIONS.get(name); - if (simpleMathFn) { - if (typeof value === "number") return [simpleMathFn(value)]; - return [null]; - } - - // Delegate to extracted builtin handlers - const mathResult = evalMathBuiltin(value, name, args, ctx, evaluate); - if (mathResult !== null) return mathResult; - - const stringResult = evalStringBuiltin(value, name, args, ctx, evaluate); - if (stringResult !== null) return stringResult; - - const dateResult = evalDateBuiltin(value, name, args, ctx, evaluate); - if (dateResult !== null) return dateResult; - - const formatResult = evalFormatBuiltin(value, name, ctx.limits.maxDepth); - if (formatResult !== null) return formatResult; - - const typeResult = evalTypeBuiltin(value, name); - if (typeResult !== null) return typeResult; - - const objectResult = evalObjectBuiltin(value, name, args, ctx, evaluate); - if (objectResult !== null) return objectResult; - - const arrayResult = evalArrayBuiltin( - value, - name, - args, - ctx, - evaluate, - evaluateWithPartialResults, - compareJq, - isTruthy, - containsDeep, - ExecutionLimitError, - ); - if (arrayResult !== null) return arrayResult; - - const pathResult = evalPathBuiltin( - value, - name, - args, - ctx, - evaluate, - isTruthy, - setPath, - deletePath, - applyDel, - collectPaths, - ); - if (pathResult !== null) return pathResult; - - const indexResult = evalIndexBuiltin( - value, - name, - args, - ctx, - evaluate, - deepEqual, - ); - if (indexResult !== null) return indexResult; - - const controlResult = evalControlBuiltin( - value, - name, - args, - ctx, - evaluate, - evaluateWithPartialResults, - isTruthy, - ExecutionLimitError, - ); - if (controlResult !== null) return controlResult; - - const navigationResult = evalNavigationBuiltin( - value, - name, - args, - ctx, - evaluate, - isTruthy, - getValueAtPath, - evalBuiltin, - ); - if (navigationResult !== null) return navigationResult; - - const sqlResult = evalSqlBuiltin(value, name, args, ctx, evaluate, deepEqual); - if (sqlResult !== null) return sqlResult; - - switch (name) { - // keys, keys_unsorted, length, utf8bytelength, type, to_entries, from_entries, - // with_entries, reverse, flatten, unique, tojson, tojsonstream, fromjson, - // tostring, tonumber, toboolean, tostream, fromstream, truncate_stream - // handled by evalObjectBuiltin - // - // sort, sort_by, bsearch, unique_by, group_by, max, max_by, min, min_by, - // add, any, all, select, map, map_values, has, in, contains, inside - // handled by evalArrayBuiltin - // - // getpath, setpath, delpaths, path, del, pick, paths, leaf_paths - // handled by evalPathBuiltin - // - // index, rindex, indices handled by evalIndexBuiltin - // - // first, last, nth, range, limit, isempty, isvalid, skip, until, while, repeat - // handled by evalControlBuiltin - // - // recurse, recurse_down, walk, transpose, combinations, parent, parents, root - // handled by evalNavigationBuiltin - // - // IN, INDEX, JOIN handled by evalSqlBuiltin - - case "builtins": - // Return list of all builtin functions with arity - return [ - [ - "add/0", - "all/0", - "all/1", - "all/2", - "any/0", - "any/1", - "any/2", - "arrays/0", - "ascii/0", - "ascii_downcase/0", - "ascii_upcase/0", - "booleans/0", - "bsearch/1", - "builtins/0", - "combinations/0", - "combinations/1", - "contains/1", - "debug/0", - "del/1", - "delpaths/1", - "empty/0", - "env/0", - "error/0", - "error/1", - "explode/0", - "first/0", - "first/1", - "flatten/0", - "flatten/1", - "floor/0", - "from_entries/0", - "fromdate/0", - "fromjson/0", - "getpath/1", - "gmtime/0", - "group_by/1", - "gsub/2", - "gsub/3", - "has/1", - "implode/0", - "IN/1", - "IN/2", - "INDEX/1", - "INDEX/2", - "index/1", - "indices/1", - "infinite/0", - "inside/1", - "isempty/1", - "isnan/0", - "isnormal/0", - "isvalid/1", - "iterables/0", - "join/1", - "keys/0", - "keys_unsorted/0", - "last/0", - "last/1", - "length/0", - "limit/2", - "ltrimstr/1", - "map/1", - "map_values/1", - "match/1", - "match/2", - "max/0", - "max_by/1", - "min/0", - "min_by/1", - "mktime/0", - "modulemeta/1", - "nan/0", - "not/0", - "nth/1", - "nth/2", - "null/0", - "nulls/0", - "numbers/0", - "objects/0", - "path/1", - "paths/0", - "paths/1", - "pick/1", - "range/1", - "range/2", - "range/3", - "recurse/0", - "recurse/1", - "recurse_down/0", - "repeat/1", - "reverse/0", - "rindex/1", - "rtrimstr/1", - "scalars/0", - "scan/1", - "scan/2", - "select/1", - "setpath/2", - "skip/2", - "sort/0", - "sort_by/1", - "split/1", - "splits/1", - "splits/2", - "sqrt/0", - "startswith/1", - "strftime/1", - "strings/0", - "strptime/1", - "sub/2", - "sub/3", - "test/1", - "test/2", - "to_entries/0", - "toboolean/0", - "todate/0", - "tojson/0", - "tostream/0", - "fromstream/1", - "truncate_stream/1", - "tonumber/0", - "tostring/0", - "transpose/0", - "trim/0", - "ltrim/0", - "rtrim/0", - "type/0", - "unique/0", - "unique_by/1", - "until/2", - "utf8bytelength/0", - "values/0", - "walk/1", - "while/2", - "with_entries/1", - ], - ]; - - // empty, not, null, true, false handled by evalTypeBuiltin - - case "error": { - const msg = args.length > 0 ? evaluate(value, args[0], ctx)[0] : value; - throw new JqError(msg); - } - - // first, last, nth, range handled by evalControlBuiltin - - // sort, sort_by, bsearch, unique_by, group_by, max, max_by, min, min_by, - // add, any, all, select, map, map_values, has, in, contains, inside - // handled by evalArrayBuiltin - - // getpath, setpath, delpaths, path, del, pick, paths, leaf_paths - // handled by evalPathBuiltin - - // index, rindex, indices handled by evalIndexBuiltin - - case "env": - // Convert Map to object for jq's internal representation (null-prototype prevents prototype pollution) - return [ctx.env ? mapToRecord(ctx.env) : Object.create(null)]; - - // recurse, recurse_down, walk, transpose, combinations, parent, parents, root - // handled by evalNavigationBuiltin - // - // limit, isempty, isvalid, skip, until, while, repeat - // handled by evalControlBuiltin - - case "debug": - return [value]; - - case "input_line_number": - return [1]; - - // parents, root handled by evalNavigationBuiltin - // IN, INDEX, JOIN handled by evalSqlBuiltin - - default: { - // Check for user-defined function by name/arity - const funcKey = `${name}/${args.length}`; - const userFunc = ctx.funcs?.get(funcKey) as - | { params: string[]; body: AstNode; closure?: Map } - | undefined; - if (userFunc) { - // User-defined function: bind parameters - // In jq, parameters are "filters" that can produce multiple values. - // We evaluate them in the calling context and store as literal values. - // - // Use the function's closure for lexical scoping, not the current context's funcs. - // This ensures that functions capture the scope at definition time. - const baseFuncs = (userFunc.closure ?? ctx.funcs ?? new Map()) as Map< - string, - { params: string[]; body: AstNode; closure?: Map } - >; - const newFuncs = new Map(baseFuncs); - // Also add the current function itself so recursion works - newFuncs.set(funcKey, userFunc); - for (let i = 0; i < userFunc.params.length; i++) { - const paramName = userFunc.params[i]; - const argExpr = args[i]; - if (argExpr) { - // Evaluate the argument in the calling context, then store as a literal - // This implements call-by-value semantics - const argVals = evaluate(value, argExpr, ctx); - // Store as a function that returns all the values - let bodyNode: AstNode; - if (argVals.length === 0) { - bodyNode = { type: "Call", name: "empty", args: [] }; - } else if (argVals.length === 1) { - bodyNode = { type: "Literal", value: argVals[0] }; - } else { - // Multiple values - build a right-associative Comma chain - bodyNode = { - type: "Literal", - value: argVals[argVals.length - 1], - }; - for (let j = argVals.length - 2; j >= 0; j--) { - bodyNode = { - type: "Comma", - left: { type: "Literal", value: argVals[j] }, - right: bodyNode, - }; - } - } - newFuncs.set(`${paramName}/0`, { params: [], body: bodyNode }); - } - } - const newCtx: EvalContext = { ...ctx, funcs: newFuncs }; - return evaluate(value, userFunc.body, newCtx); - } - throw new Error(`Unknown function: ${name}`); - } - } -} - -function collectPaths( - value: QueryValue, - expr: AstNode, - ctx: EvalContext, - currentPath: (string | number)[], - paths: (string | number)[][], -): void { - // Handle Comma - collect paths for both parts - if (expr.type === "Comma") { - const comma = expr as { type: "Comma"; left: AstNode; right: AstNode }; - collectPaths(value, comma.left, ctx, currentPath, paths); - collectPaths(value, comma.right, ctx, currentPath, paths); - return; - } - - // Try to extract a static path from the AST - const staticPath = extractPathFromAst(expr); - if (staticPath !== null) { - paths.push([...currentPath, ...staticPath]); - return; - } - - // For more complex expressions, evaluate and try to infer paths - // This handles cases like .[] which produce multiple paths - if (expr.type === "Iterate") { - if (Array.isArray(value)) { - for (let i = 0; i < value.length; i++) { - paths.push([...currentPath, i]); - } - } else if (value && typeof value === "object") { - for (const key of Object.keys(value)) { - paths.push([...currentPath, key]); - } - } - return; - } - - // Handle Recurse (..) - recursive descent, returns paths to all values - if (expr.type === "Recurse") { - const walkPaths = (v: QueryValue, path: (string | number)[]) => { - paths.push([...currentPath, ...path]); - if (v && typeof v === "object") { - if (Array.isArray(v)) { - for (let i = 0; i < v.length; i++) { - walkPaths(v[i], [...path, i]); - } - } else { - // @banned-pattern-ignore: iterating via Object.keys() which only returns own properties - for (const key of Object.keys(v)) { - walkPaths((v as Record)[key], [...path, key]); - } - } - } - }; - walkPaths(value, []); - return; - } - - // For Pipe expressions, collect paths through the pipe - if (expr.type === "Pipe") { - const leftPath = extractPathFromAst(expr.left); - if (leftPath !== null) { - const leftResults = evaluate(value, expr.left, ctx); - for (const lv of leftResults) { - collectPaths(lv, expr.right, ctx, [...currentPath, ...leftPath], paths); - } - return; - } - } - - // Fallback: if expression produces results, push current path - const results = evaluate(value, expr, ctx); - if (results.length > 0) { - paths.push(currentPath); - } -} diff --git a/src/commands/query-engine/index.ts b/src/commands/query-engine/index.ts deleted file mode 100644 index 70a1e52d..00000000 --- a/src/commands/query-engine/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Shared query engine for jq-style filtering - * - * Provides parser and evaluator for jq-style queries that can be used - * with any data format (JSON, YAML, XML, etc.). - */ - -export * from "./evaluator.js"; -export * from "./parser.js"; diff --git a/src/commands/query-engine/parser-types.ts b/src/commands/query-engine/parser-types.ts deleted file mode 100644 index e8e24b06..00000000 --- a/src/commands/query-engine/parser-types.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * Query expression parser types - * - * AST node types and token types for jq-style filter expressions. - */ - -// ============================================================================ -// Token Types -// ============================================================================ - -export type TokenType = - | "DOT" - | "PIPE" - | "COMMA" - | "COLON" - | "SEMICOLON" - | "LPAREN" - | "RPAREN" - | "LBRACKET" - | "RBRACKET" - | "LBRACE" - | "RBRACE" - | "QUESTION" - | "PLUS" - | "MINUS" - | "STAR" - | "SLASH" - | "PERCENT" - | "EQ" - | "NE" - | "LT" - | "LE" - | "GT" - | "GE" - | "AND" - | "OR" - | "NOT" - | "ALT" - | "ASSIGN" - | "UPDATE_ADD" - | "UPDATE_SUB" - | "UPDATE_MUL" - | "UPDATE_DIV" - | "UPDATE_MOD" - | "UPDATE_ALT" - | "UPDATE_PIPE" - | "IDENT" - | "NUMBER" - | "STRING" - | "IF" - | "THEN" - | "ELIF" - | "ELSE" - | "END" - | "AS" - | "TRY" - | "CATCH" - | "TRUE" - | "FALSE" - | "NULL" - | "REDUCE" - | "FOREACH" - | "LABEL" - | "BREAK" - | "DEF" - | "DOTDOT" - | "EOF"; - -export interface Token { - type: TokenType; - value?: string | number; - pos: number; -} - -// ============================================================================ -// AST Node Types -// ============================================================================ - -export type AstNode = - | IdentityNode - | FieldNode - | IndexNode - | SliceNode - | IterateNode - | PipeNode - | CommaNode - | LiteralNode - | ArrayNode - | ObjectNode - | ParenNode - | BinaryOpNode - | UnaryOpNode - | CondNode - | TryNode - | CallNode - | VarBindNode - | VarRefNode - | RecurseNode - | OptionalNode - | StringInterpNode - | UpdateOpNode - | ReduceNode - | ForeachNode - | LabelNode - | BreakNode - | DefNode; - -export interface IdentityNode { - type: "Identity"; -} - -export interface FieldNode { - type: "Field"; - name: string; - base?: AstNode; -} - -export interface IndexNode { - type: "Index"; - index: AstNode; - base?: AstNode; -} - -export interface SliceNode { - type: "Slice"; - start?: AstNode; - end?: AstNode; - base?: AstNode; -} - -export interface IterateNode { - type: "Iterate"; - base?: AstNode; -} - -export interface PipeNode { - type: "Pipe"; - left: AstNode; - right: AstNode; -} - -export interface CommaNode { - type: "Comma"; - left: AstNode; - right: AstNode; -} - -export interface LiteralNode { - type: "Literal"; - value: unknown; -} - -export interface ArrayNode { - type: "Array"; - elements?: AstNode; -} - -export interface ObjectNode { - type: "Object"; - entries: { key: AstNode | string; value: AstNode }[]; -} - -export interface ParenNode { - type: "Paren"; - expr: AstNode; -} - -export interface BinaryOpNode { - type: "BinaryOp"; - op: - | "+" - | "-" - | "*" - | "/" - | "%" - | "==" - | "!=" - | "<" - | "<=" - | ">" - | ">=" - | "and" - | "or" - | "//"; - left: AstNode; - right: AstNode; -} - -export interface UnaryOpNode { - type: "UnaryOp"; - op: "-" | "not"; - operand: AstNode; -} - -export interface CondNode { - type: "Cond"; - cond: AstNode; - then: AstNode; - elifs: { cond: AstNode; then: AstNode }[]; - else?: AstNode; -} - -export interface TryNode { - type: "Try"; - body: AstNode; - catch?: AstNode; -} - -export interface CallNode { - type: "Call"; - name: string; - args: AstNode[]; -} - -export interface VarBindNode { - type: "VarBind"; - name: string; - value: AstNode; - body: AstNode; - pattern?: DestructurePattern; - alternatives?: DestructurePattern[]; // For ?// alternative patterns -} - -// Destructuring pattern for variable binding -export type DestructurePattern = - | { type: "var"; name: string } - | { type: "array"; elements: DestructurePattern[] } - | { - type: "object"; - fields: { - key: string | AstNode; - pattern: DestructurePattern; - keyVar?: string; - }[]; - }; - -export interface VarRefNode { - type: "VarRef"; - name: string; -} - -export interface RecurseNode { - type: "Recurse"; -} - -export interface OptionalNode { - type: "Optional"; - expr: AstNode; -} - -export interface StringInterpNode { - type: "StringInterp"; - parts: (string | AstNode)[]; -} - -export interface UpdateOpNode { - type: "UpdateOp"; - op: "+=" | "-=" | "*=" | "/=" | "%=" | "//=" | "=" | "|="; - path: AstNode; - value: AstNode; -} - -export interface ReduceNode { - type: "Reduce"; - expr: AstNode; - varName: string; - pattern?: DestructurePattern; - init: AstNode; - update: AstNode; -} - -export interface ForeachNode { - type: "Foreach"; - expr: AstNode; - varName: string; - pattern?: DestructurePattern; - init: AstNode; - update: AstNode; - extract?: AstNode; -} - -export interface LabelNode { - type: "Label"; - name: string; - body: AstNode; -} - -export interface BreakNode { - type: "Break"; - name: string; -} - -export interface DefNode { - type: "Def"; - name: string; - params: string[]; - funcBody: AstNode; - body: AstNode; -} diff --git a/src/commands/query-engine/parser.ts b/src/commands/query-engine/parser.ts deleted file mode 100644 index 8a71bc51..00000000 --- a/src/commands/query-engine/parser.ts +++ /dev/null @@ -1,1128 +0,0 @@ -/** - * Query expression parser - * - * Tokenizes and parses jq-style filter expressions into an AST. - * Used by jq, yq, and other query-based commands. - */ - -// Re-export types from parser-types.ts -export type { - ArrayNode, - AstNode, - BinaryOpNode, - BreakNode, - CallNode, - CommaNode, - CondNode, - DefNode, - DestructurePattern, - FieldNode, - ForeachNode, - IdentityNode, - IndexNode, - IterateNode, - LabelNode, - LiteralNode, - ObjectNode, - OptionalNode, - ParenNode, - PipeNode, - RecurseNode, - ReduceNode, - SliceNode, - StringInterpNode, - Token, - TokenType, - TryNode, - UnaryOpNode, - UpdateOpNode, - VarBindNode, - VarRefNode, -} from "./parser-types.js"; - -import type { - AstNode, - BinaryOpNode, - CondNode, - DestructurePattern, - ObjectNode, - StringInterpNode, - Token, - TokenType, - UpdateOpNode, -} from "./parser-types.js"; - -// ============================================================================ -// Tokenizer -// ============================================================================ - -// Use Map instead of plain object to avoid prototype pollution -// (e.g., "__proto__" lookup returning Object.prototype.__proto__) -const KEYWORDS: Map = new Map([ - ["and", "AND"], - ["or", "OR"], - ["not", "NOT"], - ["if", "IF"], - ["then", "THEN"], - ["elif", "ELIF"], - ["else", "ELSE"], - ["end", "END"], - ["as", "AS"], - ["try", "TRY"], - ["catch", "CATCH"], - ["true", "TRUE"], - ["false", "FALSE"], - ["null", "NULL"], - ["reduce", "REDUCE"], - ["foreach", "FOREACH"], - ["label", "LABEL"], - ["break", "BREAK"], - ["def", "DEF"], -]); - -const KEYWORD_TOKEN_TYPES: Set = new Set(KEYWORDS.values()); - -function tokenize(input: string): Token[] { - const tokens: Token[] = []; - let pos = 0; - - const peek = (offset = 0) => input[pos + offset]; - const advance = () => input[pos++]; - const isEof = () => pos >= input.length; - const isDigit = (c: string) => c >= "0" && c <= "9"; - const isAlpha = (c: string) => - (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_"; - const isAlnum = (c: string) => isAlpha(c) || isDigit(c); - - while (!isEof()) { - const start = pos; - const c = advance(); - - // Whitespace - if (c === " " || c === "\t" || c === "\n" || c === "\r") { - continue; - } - - // Comments - if (c === "#") { - while (!isEof() && peek() !== "\n") advance(); - continue; - } - - // Two-character operators - if (c === "." && peek() === ".") { - advance(); - tokens.push({ type: "DOTDOT", pos: start }); - continue; - } - if (c === "=" && peek() === "=") { - advance(); - tokens.push({ type: "EQ", pos: start }); - continue; - } - if (c === "!" && peek() === "=") { - advance(); - tokens.push({ type: "NE", pos: start }); - continue; - } - if (c === "<" && peek() === "=") { - advance(); - tokens.push({ type: "LE", pos: start }); - continue; - } - if (c === ">" && peek() === "=") { - advance(); - tokens.push({ type: "GE", pos: start }); - continue; - } - if (c === "/" && peek() === "/") { - advance(); - if (peek() === "=") { - advance(); - tokens.push({ type: "UPDATE_ALT", pos: start }); - } else { - tokens.push({ type: "ALT", pos: start }); - } - continue; - } - if (c === "+" && peek() === "=") { - advance(); - tokens.push({ type: "UPDATE_ADD", pos: start }); - continue; - } - if (c === "-" && peek() === "=") { - advance(); - tokens.push({ type: "UPDATE_SUB", pos: start }); - continue; - } - if (c === "*" && peek() === "=") { - advance(); - tokens.push({ type: "UPDATE_MUL", pos: start }); - continue; - } - if (c === "/" && peek() === "=") { - advance(); - tokens.push({ type: "UPDATE_DIV", pos: start }); - continue; - } - if (c === "%" && peek() === "=") { - advance(); - tokens.push({ type: "UPDATE_MOD", pos: start }); - continue; - } - if (c === "=" && peek() !== "=") { - tokens.push({ type: "ASSIGN", pos: start }); - continue; - } - - // Single-character tokens - if (c === ".") { - tokens.push({ type: "DOT", pos: start }); - continue; - } - if (c === "|") { - if (peek() === "=") { - advance(); - tokens.push({ type: "UPDATE_PIPE", pos: start }); - } else { - tokens.push({ type: "PIPE", pos: start }); - } - continue; - } - if (c === ",") { - tokens.push({ type: "COMMA", pos: start }); - continue; - } - if (c === ":") { - tokens.push({ type: "COLON", pos: start }); - continue; - } - if (c === ";") { - tokens.push({ type: "SEMICOLON", pos: start }); - continue; - } - if (c === "(") { - tokens.push({ type: "LPAREN", pos: start }); - continue; - } - if (c === ")") { - tokens.push({ type: "RPAREN", pos: start }); - continue; - } - if (c === "[") { - tokens.push({ type: "LBRACKET", pos: start }); - continue; - } - if (c === "]") { - tokens.push({ type: "RBRACKET", pos: start }); - continue; - } - if (c === "{") { - tokens.push({ type: "LBRACE", pos: start }); - continue; - } - if (c === "}") { - tokens.push({ type: "RBRACE", pos: start }); - continue; - } - if (c === "?") { - tokens.push({ type: "QUESTION", pos: start }); - continue; - } - if (c === "+") { - tokens.push({ type: "PLUS", pos: start }); - continue; - } - if (c === "-") { - // Always tokenize as MINUS - let parser handle unary minus - tokens.push({ type: "MINUS", pos: start }); - continue; - } - if (c === "*") { - tokens.push({ type: "STAR", pos: start }); - continue; - } - if (c === "/") { - tokens.push({ type: "SLASH", pos: start }); - continue; - } - if (c === "%") { - tokens.push({ type: "PERCENT", pos: start }); - continue; - } - if (c === "<") { - tokens.push({ type: "LT", pos: start }); - continue; - } - if (c === ">") { - tokens.push({ type: "GT", pos: start }); - continue; - } - - // Numbers - if (isDigit(c)) { - let num = c; - while ( - !isEof() && - (isDigit(peek()) || peek() === "." || peek() === "e" || peek() === "E") - ) { - if ( - (peek() === "e" || peek() === "E") && - (input[pos + 1] === "+" || input[pos + 1] === "-") - ) { - num += advance(); - num += advance(); - } else { - num += advance(); - } - } - tokens.push({ type: "NUMBER", value: Number(num), pos: start }); - continue; - } - - // Strings - if (c === '"') { - let str = ""; - while (!isEof() && peek() !== '"') { - if (peek() === "\\") { - advance(); - if (isEof()) break; - const escaped = advance(); - switch (escaped) { - case "n": - str += "\n"; - break; - case "r": - str += "\r"; - break; - case "t": - str += "\t"; - break; - case "\\": - str += "\\"; - break; - case '"': - str += '"'; - break; - case "(": - str += "\\("; - break; // Keep for string interpolation - default: - str += escaped; - } - } else { - str += advance(); - } - } - if (!isEof()) advance(); // closing quote - tokens.push({ type: "STRING", value: str, pos: start }); - continue; - } - - // Identifiers and keywords - if (isAlpha(c) || c === "$" || c === "@") { - let ident = c; - while (!isEof() && isAlnum(peek())) { - ident += advance(); - } - const keyword = KEYWORDS.get(ident); - if (keyword) { - tokens.push({ type: keyword, value: ident, pos: start }); - } else { - tokens.push({ type: "IDENT", value: ident, pos: start }); - } - continue; - } - - throw new Error(`Unexpected character '${c}' at position ${start}`); - } - - tokens.push({ type: "EOF", pos: pos }); - return tokens; -} - -// Parser -// ============================================================================ - -class Parser { - private tokens: Token[]; - private pos = 0; - - constructor(tokens: Token[]) { - this.tokens = tokens; - } - - private peek(offset = 0): Token { - return this.tokens[this.pos + offset] ?? { type: "EOF", pos: -1 }; - } - - private advance(): Token { - return this.tokens[this.pos++]; - } - - private check(type: TokenType): boolean { - return this.peek().type === type; - } - - private match(...types: TokenType[]): Token | null { - for (const type of types) { - if (this.check(type)) { - return this.advance(); - } - } - return null; - } - - private expect(type: TokenType, msg: string): Token { - if (!this.check(type)) { - throw new Error( - `${msg} at position ${this.peek().pos}, got ${this.peek().type}`, - ); - } - return this.advance(); - } - - private isFieldNameAfterDot(dotOffset = 0): boolean { - const dot = this.peek(dotOffset); - const next = this.peek(dotOffset + 1); - if (next.type === "STRING") return true; - if (next.type === "IDENT" || KEYWORD_TOKEN_TYPES.has(next.type)) { - return next.pos === dot.pos + 1; - } - return false; - } - - private isIdentLike(): boolean { - const t = this.peek().type; - return t === "IDENT" || KEYWORD_TOKEN_TYPES.has(t); - } - - private consumeFieldNameAfterDot(dotToken: Token): string | null { - const next = this.peek(); - if (next.type === "STRING") { - return this.advance().value as string; - } - if ( - (next.type === "IDENT" || KEYWORD_TOKEN_TYPES.has(next.type)) && - next.pos === dotToken.pos + 1 - ) { - return this.advance().value as string; - } - return null; - } - - parse(): AstNode { - const expr = this.parseExpr(); - if (!this.check("EOF")) { - throw new Error( - `Unexpected token ${this.peek().type} at position ${this.peek().pos}`, - ); - } - return expr; - } - - private parseExpr(): AstNode { - return this.parsePipe(); - } - - /** - * Parse a destructuring pattern for variable binding - * Patterns can be: - * $var - simple variable - * [$a, $b, ...] - array destructuring - * {key: $a, ...} - object destructuring - * {$a, ...} - shorthand object destructuring (key same as var name) - */ - private parsePattern(): DestructurePattern { - // Array pattern: [$a, $b, ...] - if (this.match("LBRACKET")) { - const elements: DestructurePattern[] = []; - if (!this.check("RBRACKET")) { - elements.push(this.parsePattern()); - while (this.match("COMMA")) { - if (this.check("RBRACKET")) break; - elements.push(this.parsePattern()); - } - } - this.expect("RBRACKET", "Expected ']' after array pattern"); - return { type: "array", elements }; - } - - // Object pattern: {key: $a, $b, ...} - if (this.match("LBRACE")) { - const fields: { key: string | AstNode; pattern: DestructurePattern }[] = - []; - if (!this.check("RBRACE")) { - // Parse first field - fields.push(this.parsePatternField()); - while (this.match("COMMA")) { - if (this.check("RBRACE")) break; - fields.push(this.parsePatternField()); - } - } - this.expect("RBRACE", "Expected '}' after object pattern"); - return { type: "object", fields }; - } - - // Simple variable: $name - const tok = this.expect("IDENT", "Expected variable name in pattern"); - const name = tok.value as string; - if (!name.startsWith("$")) { - throw new Error(`Variable name must start with $ at position ${tok.pos}`); - } - return { type: "var", name }; - } - - /** - * Parse a single field in an object destructuring pattern - */ - private parsePatternField(): { - key: string | AstNode; - pattern: DestructurePattern; - keyVar?: string; - } { - // Check for computed key: (expr): $pattern - if (this.match("LPAREN")) { - const keyExpr = this.parseExpr(); - this.expect("RPAREN", "Expected ')' after computed key"); - this.expect("COLON", "Expected ':' after computed key"); - const pattern = this.parsePattern(); - return { key: keyExpr, pattern }; - } - - // Check for shorthand: $name or $name:pattern - const tok = this.peek(); - if (tok.type === "IDENT" || KEYWORD_TOKEN_TYPES.has(tok.type)) { - const name = tok.value as string; - if (name.startsWith("$")) { - this.advance(); - // Check for $name:pattern (e.g., $b:[$c, $d] means key="b", pattern=[$c,$d], keyVar=$b) - if (this.match("COLON")) { - const pattern = this.parsePattern(); - // Also bind $name to the whole value at this key - return { key: name.slice(1), pattern, keyVar: name }; - } - // Shorthand: $foo is equivalent to foo: $foo - return { key: name.slice(1), pattern: { type: "var", name } }; - } - // Regular key: name - this.advance(); - if (this.match("COLON")) { - const pattern = this.parsePattern(); - return { key: name, pattern }; - } - // If no colon, it's a shorthand for key: $key - return { key: name, pattern: { type: "var", name: `$${name}` } }; - } - - throw new Error( - `Expected field name in object pattern at position ${tok.pos}`, - ); - } - - private parsePipe(): AstNode { - let left = this.parseComma(); - while (this.match("PIPE")) { - const right = this.parseComma(); - left = { type: "Pipe", left, right }; - } - return left; - } - - private parseComma(): AstNode { - let left = this.parseVarBind(); - while (this.match("COMMA")) { - const right = this.parseVarBind(); - left = { type: "Comma", left, right }; - } - return left; - } - - private parseVarBind(): AstNode { - const expr = this.parseUpdate(); - if (this.match("AS")) { - // Parse pattern (can be $var, [$a, $b], {key: $a}, etc.) - const pattern = this.parsePattern(); - - // Check for alternative patterns: ?// PATTERN ?// PATTERN ... - const alternatives: DestructurePattern[] = []; - while (this.check("QUESTION") && this.peekAhead(1)?.type === "ALT") { - this.advance(); // consume QUESTION - this.advance(); // consume ALT - alternatives.push(this.parsePattern()); - } - - this.expect("PIPE", "Expected '|' after variable binding"); - const body = this.parseExpr(); - - // For simple variable patterns without alternatives - if (pattern.type === "var" && alternatives.length === 0) { - return { type: "VarBind", name: pattern.name, value: expr, body }; - } - - // For complex patterns or patterns with alternatives - return { - type: "VarBind", - name: pattern.type === "var" ? pattern.name : "", - value: expr, - body, - pattern: pattern.type !== "var" ? pattern : undefined, - alternatives: alternatives.length > 0 ? alternatives : undefined, - }; - } - return expr; - } - - /** - * Peek at a token N positions ahead (0 = current, 1 = next, etc.) - */ - private peekAhead( - n: number, - ): { type: TokenType; pos: number; value?: unknown } | undefined { - const idx = this.pos + n; - return idx < this.tokens.length ? this.tokens[idx] : undefined; - } - - private parseUpdate(): AstNode { - const left = this.parseAlt(); - // Use Map to avoid prototype pollution - const opMap = new Map([ - ["ASSIGN", "="], - ["UPDATE_ADD", "+="], - ["UPDATE_SUB", "-="], - ["UPDATE_MUL", "*="], - ["UPDATE_DIV", "/="], - ["UPDATE_MOD", "%="], - ["UPDATE_ALT", "//="], - ["UPDATE_PIPE", "|="], - ]); - const tok = this.match( - "ASSIGN", - "UPDATE_ADD", - "UPDATE_SUB", - "UPDATE_MUL", - "UPDATE_DIV", - "UPDATE_MOD", - "UPDATE_ALT", - "UPDATE_PIPE", - ); - if (tok) { - const value = this.parseVarBind(); - const op = opMap.get(tok.type); - if (op) { - return { type: "UpdateOp", op, path: left, value }; - } - } - return left; - } - - private parseAlt(): AstNode { - let left = this.parseOr(); - while (this.match("ALT")) { - const right = this.parseOr(); - left = { type: "BinaryOp", op: "//", left, right }; - } - return left; - } - - private parseOr(): AstNode { - let left = this.parseAnd(); - while (this.match("OR")) { - const right = this.parseAnd(); - left = { type: "BinaryOp", op: "or", left, right }; - } - return left; - } - - private parseAnd(): AstNode { - let left = this.parseNot(); - while (this.match("AND")) { - const right = this.parseNot(); - left = { type: "BinaryOp", op: "and", left, right }; - } - return left; - } - - private parseNot(): AstNode { - return this.parseComparison(); - } - - private parseComparison(): AstNode { - let left = this.parseAddSub(); - // Use Map to avoid prototype pollution - const opMap = new Map([ - ["EQ", "=="], - ["NE", "!="], - ["LT", "<"], - ["LE", "<="], - ["GT", ">"], - ["GE", ">="], - ]); - const tok = this.match("EQ", "NE", "LT", "LE", "GT", "GE"); - if (tok) { - const op = opMap.get(tok.type); - if (op) { - const right = this.parseAddSub(); - left = { type: "BinaryOp", op, left, right }; - } - } - return left; - } - - private parseAddSub(): AstNode { - let left = this.parseMulDiv(); - while (true) { - if (this.match("PLUS")) { - const right = this.parseMulDiv(); - left = { type: "BinaryOp", op: "+", left, right }; - } else if (this.match("MINUS")) { - const right = this.parseMulDiv(); - left = { type: "BinaryOp", op: "-", left, right }; - } else { - break; - } - } - return left; - } - - private parseMulDiv(): AstNode { - let left = this.parseUnary(); - while (true) { - if (this.match("STAR")) { - const right = this.parseUnary(); - left = { type: "BinaryOp", op: "*", left, right }; - } else if (this.match("SLASH")) { - const right = this.parseUnary(); - left = { type: "BinaryOp", op: "/", left, right }; - } else if (this.match("PERCENT")) { - const right = this.parseUnary(); - left = { type: "BinaryOp", op: "%", left, right }; - } else { - break; - } - } - return left; - } - - private parseUnary(): AstNode { - if (this.match("MINUS")) { - const operand = this.parseUnary(); - return { type: "UnaryOp", op: "-", operand }; - } - return this.parsePostfix(); - } - - private parsePostfix(): AstNode { - let expr = this.parsePrimary(); - - while (true) { - if (this.match("QUESTION")) { - expr = { type: "Optional", expr }; - } else if (this.check("DOT") && this.isFieldNameAfterDot()) { - this.advance(); // consume DOT - const token = this.advance(); - const name = token.value as string; - expr = { type: "Field", name, base: expr }; - } else if (this.check("LBRACKET")) { - this.advance(); - if (this.match("RBRACKET")) { - expr = { type: "Iterate", base: expr }; - } else if (this.check("COLON")) { - this.advance(); - const end = this.check("RBRACKET") ? undefined : this.parseExpr(); - this.expect("RBRACKET", "Expected ']'"); - expr = { type: "Slice", end, base: expr }; - } else { - const indexExpr = this.parseExpr(); - if (this.match("COLON")) { - const end = this.check("RBRACKET") ? undefined : this.parseExpr(); - this.expect("RBRACKET", "Expected ']'"); - expr = { type: "Slice", start: indexExpr, end, base: expr }; - } else { - this.expect("RBRACKET", "Expected ']'"); - expr = { type: "Index", index: indexExpr, base: expr }; - } - } - } else { - break; - } - } - - return expr; - } - - private parsePrimary(): AstNode { - // Recursive descent (..) - if (this.match("DOTDOT")) { - return { type: "Recurse" }; - } - - // Identity or field access starting with dot - if (this.check("DOT")) { - const dotToken = this.advance(); - // Check for .[] or .[n] or .[n:m] - if (this.check("LBRACKET")) { - this.advance(); - if (this.match("RBRACKET")) { - return { type: "Iterate" }; - } - if (this.check("COLON")) { - this.advance(); - const end = this.check("RBRACKET") ? undefined : this.parseExpr(); - this.expect("RBRACKET", "Expected ']'"); - return { type: "Slice", end }; - } - const indexExpr = this.parseExpr(); - if (this.match("COLON")) { - const end = this.check("RBRACKET") ? undefined : this.parseExpr(); - this.expect("RBRACKET", "Expected ']'"); - return { type: "Slice", start: indexExpr, end }; - } - this.expect("RBRACKET", "Expected ']'"); - return { type: "Index", index: indexExpr }; - } - // .field or ."quoted-field" (keywords like .label are valid field names) - const fieldName = this.consumeFieldNameAfterDot(dotToken); - if (fieldName !== null) { - return { type: "Field", name: fieldName }; - } - // Just identity - return { type: "Identity" }; - } - - // Literals - if (this.match("TRUE")) { - return { type: "Literal", value: true }; - } - if (this.match("FALSE")) { - return { type: "Literal", value: false }; - } - if (this.match("NULL")) { - return { type: "Literal", value: null }; - } - if (this.check("NUMBER")) { - const tok = this.advance(); - return { type: "Literal", value: tok.value }; - } - if (this.check("STRING")) { - const tok = this.advance(); - const str = tok.value as string; - // Check for string interpolation - if (str.includes("\\(")) { - return this.parseStringInterpolation(str); - } - return { type: "Literal", value: str }; - } - - // Array construction - if (this.match("LBRACKET")) { - if (this.match("RBRACKET")) { - return { type: "Array" }; - } - const elements = this.parseExpr(); - this.expect("RBRACKET", "Expected ']'"); - return { type: "Array", elements }; - } - - // Object construction - if (this.match("LBRACE")) { - return this.parseObjectConstruction(); - } - - // Parentheses - if (this.match("LPAREN")) { - const expr = this.parseExpr(); - this.expect("RPAREN", "Expected ')'"); - return { type: "Paren", expr }; - } - - // if-then-else - if (this.match("IF")) { - return this.parseIf(); - } - - // try-catch - if (this.match("TRY")) { - const body = this.parsePostfix(); - let catchExpr: AstNode | undefined; - if (this.match("CATCH")) { - catchExpr = this.parsePostfix(); - } - return { type: "Try", body, catch: catchExpr }; - } - - // reduce EXPR as $VAR (INIT; UPDATE) - if (this.match("REDUCE")) { - // Use parseAddSub to handle expressions like .[] / .[] or .[] + .[] before 'as' - const expr = this.parseAddSub(); - this.expect("AS", "Expected 'as' after reduce expression"); - const pattern = this.parsePattern(); - this.expect("LPAREN", "Expected '(' after variable"); - const init = this.parseExpr(); - this.expect("SEMICOLON", "Expected ';' after init expression"); - const update = this.parseExpr(); - this.expect("RPAREN", "Expected ')' after update expression"); - // For simple variable, use varName; for complex patterns, use pattern - const varName = pattern.type === "var" ? pattern.name : ""; - return { - type: "Reduce", - expr, - varName, - init, - update, - pattern: pattern.type !== "var" ? pattern : undefined, - }; - } - - // foreach EXPR as $VAR (INIT; UPDATE) or (INIT; UPDATE; EXTRACT) - if (this.match("FOREACH")) { - // Use parseAddSub to handle expressions like .[] / .[] or .[] + .[] before 'as' - const expr = this.parseAddSub(); - this.expect("AS", "Expected 'as' after foreach expression"); - const pattern = this.parsePattern(); - this.expect("LPAREN", "Expected '(' after variable"); - const init = this.parseExpr(); - this.expect("SEMICOLON", "Expected ';' after init expression"); - const update = this.parseExpr(); - let extract: AstNode | undefined; - if (this.match("SEMICOLON")) { - extract = this.parseExpr(); - } - this.expect("RPAREN", "Expected ')' after expressions"); - // For simple variable, use varName; for complex patterns, use pattern - const varName = pattern.type === "var" ? pattern.name : ""; - return { - type: "Foreach", - expr, - varName, - init, - update, - extract, - pattern: pattern.type !== "var" ? pattern : undefined, - }; - } - - // not as a standalone filter (when used as a function, not unary operator) - - // label $NAME | BODY - if (this.match("LABEL")) { - const labelToken = this.expect( - "IDENT", - "Expected label name (e.g., $out)", - ); - const labelName = labelToken.value as string; - if (!labelName.startsWith("$")) { - throw new Error( - `Label name must start with $ at position ${labelToken.pos}`, - ); - } - this.expect("PIPE", "Expected '|' after label name"); - const labelBody = this.parseExpr(); - return { type: "Label", name: labelName, body: labelBody }; - } - - // break $NAME - if (this.match("BREAK")) { - const breakToken = this.expect( - "IDENT", - "Expected label name to break to", - ); - const breakLabel = breakToken.value as string; - if (!breakLabel.startsWith("$")) { - throw new Error( - `Break label must start with $ at position ${breakToken.pos}`, - ); - } - return { type: "Break", name: breakLabel }; - } - - // def NAME: BODY; or def NAME(ARGS): BODY; - if (this.match("DEF")) { - const nameToken = this.expect( - "IDENT", - "Expected function name after def", - ); - const funcName = nameToken.value as string; - const params: string[] = []; - - // Check for parameters - if (this.match("LPAREN")) { - if (!this.check("RPAREN")) { - // Parse first parameter - const firstParam = this.expect("IDENT", "Expected parameter name"); - params.push(firstParam.value as string); - // Parse remaining parameters (semicolon-separated) - while (this.match("SEMICOLON")) { - const param = this.expect("IDENT", "Expected parameter name"); - params.push(param.value as string); - } - } - this.expect("RPAREN", "Expected ')' after parameters"); - } - - this.expect("COLON", "Expected ':' after function name"); - const funcBody = this.parseExpr(); - this.expect("SEMICOLON", "Expected ';' after function body"); - const body = this.parseExpr(); - - return { type: "Def", name: funcName, params, funcBody, body }; - } - - if (this.match("NOT")) { - return { type: "Call", name: "not", args: [] }; - } - - // Variable reference or function call - if (this.check("IDENT")) { - const tok = this.advance(); - const name = tok.value as string; - - // Variable reference - if (name.startsWith("$")) { - return { type: "VarRef", name }; - } - - // Function call with args - if (this.match("LPAREN")) { - const args: AstNode[] = []; - if (!this.check("RPAREN")) { - args.push(this.parseExpr()); - while (this.match("SEMICOLON")) { - args.push(this.parseExpr()); - } - } - this.expect("RPAREN", "Expected ')'"); - return { type: "Call", name, args }; - } - - // Builtin without parens - return { type: "Call", name, args: [] }; - } - - throw new Error( - `Unexpected token ${this.peek().type} at position ${this.peek().pos}`, - ); - } - - private parseObjectConstruction(): ObjectNode { - const entries: ObjectNode["entries"] = []; - - if (!this.check("RBRACE")) { - do { - let key: string | AstNode; - let value: AstNode; - - // Check for ({(.key): .value}) dynamic key - if (this.match("LPAREN")) { - key = this.parseExpr(); - this.expect("RPAREN", "Expected ')'"); - this.expect("COLON", "Expected ':'"); - value = this.parseObjectValue(); - } else if (this.isIdentLike()) { - const ident = this.advance().value as string; - if (this.match("COLON")) { - // {key: value} - key = ident; - value = this.parseObjectValue(); - } else { - // {key} shorthand for {key: .key} - key = ident; - value = { type: "Field", name: ident }; - } - } else if (this.check("STRING")) { - key = this.advance().value as string; - this.expect("COLON", "Expected ':'"); - value = this.parseObjectValue(); - } else { - throw new Error(`Expected object key at position ${this.peek().pos}`); - } - - entries.push({ key, value }); - } while (this.match("COMMA")); - } - - this.expect("RBRACE", "Expected '}'"); - return { type: "Object", entries }; - } - - // Parse object value - allows pipes but stops at comma or rbrace - // Uses parsePipe level to avoid consuming comma as part of expression - private parseObjectValue(): AstNode { - let left = this.parseVarBind(); - while (this.match("PIPE")) { - const right = this.parseVarBind(); - left = { type: "Pipe", left, right }; - } - return left; - } - - private parseIf(): CondNode { - const cond = this.parseExpr(); - this.expect("THEN", "Expected 'then'"); - const then = this.parseExpr(); - - const elifs: CondNode["elifs"] = []; - while (this.match("ELIF")) { - const elifCond = this.parseExpr(); - this.expect("THEN", "Expected 'then' after elif"); - const elifThen = this.parseExpr(); - // biome-ignore lint/suspicious/noThenProperty: jq AST node - elifs.push({ cond: elifCond, then: elifThen }); - } - - let elseExpr: AstNode | undefined; - if (this.match("ELSE")) { - elseExpr = this.parseExpr(); - } - - this.expect("END", "Expected 'end'"); - return { type: "Cond", cond, then, elifs, else: elseExpr }; - } - - private parseStringInterpolation(str: string): StringInterpNode { - const parts: (string | AstNode)[] = []; - let current = ""; - let i = 0; - - while (i < str.length) { - if (str[i] === "\\" && str[i + 1] === "(") { - if (current) { - parts.push(current); - current = ""; - } - i += 2; - // Find matching paren - let depth = 1; - let exprStr = ""; - while (i < str.length && depth > 0) { - if (str[i] === "(") depth++; - else if (str[i] === ")") depth--; - if (depth > 0) exprStr += str[i]; - i++; - } - const tokens = tokenize(exprStr); - const parser = new Parser(tokens); - parts.push(parser.parse()); - } else { - current += str[i]; - i++; - } - } - - if (current) { - parts.push(current); - } - - return { type: "StringInterp", parts }; - } -} - -// ============================================================================ -// Convenience function -// ============================================================================ - -export function parse(input: string): AstNode { - const tokens = tokenize(input); - const parser = new Parser(tokens); - return parser.parse(); -} diff --git a/src/commands/query-engine/path-operations.ts b/src/commands/query-engine/path-operations.ts deleted file mode 100644 index 5bca1a82..00000000 --- a/src/commands/query-engine/path-operations.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Query Path Utilities - * - * Utility functions for path-based operations on query values. - */ - -import { isSafeKey, nullPrototypeCopy, safeSet } from "./safe-object.js"; -import type { QueryValue } from "./value-operations.js"; - -/** - * Set a value at a given path within a query value. - * Creates intermediate arrays/objects as needed. - */ -export function setPath( - value: QueryValue, - path: (string | number)[], - newVal: QueryValue, -): QueryValue { - if (path.length === 0) return newVal; - - const [head, ...rest] = path; - - if (typeof head === "number") { - // jq: Cannot index object with number - if (value && typeof value === "object" && !Array.isArray(value)) { - throw new Error("Cannot index object with number"); - } - // jq: Array index too large (limit to prevent memory issues) - const MAX_ARRAY_INDEX = 536870911; // jq's limit - if (head > MAX_ARRAY_INDEX) { - throw new Error("Array index too large"); - } - // jq: Out of bounds negative array index - if (head < 0) { - throw new Error("Out of bounds negative array index"); - } - const arr = Array.isArray(value) ? [...value] : []; - while (arr.length <= head) arr.push(null); - arr[head] = setPath(arr[head], rest, newVal); - return arr; - } - - // jq: Cannot index array with string (path key must be string for objects) - if (Array.isArray(value)) { - throw new Error("Cannot index array with string"); - } - - // Defense against prototype pollution: skip dangerous keys - if (!isSafeKey(head)) { - // Return the value unchanged - silently ignore dangerous keys - return value ?? Object.create(null); - } - - const obj = - value && typeof value === "object" && !Array.isArray(value) - ? nullPrototypeCopy(value) - : Object.create(null); - // @banned-pattern-ignore: protected by Object.hasOwn() check before access - const currentVal = Object.hasOwn(obj, head) - ? (obj as Record)[head] - : undefined; - safeSet( - obj as Record, - head, - setPath(currentVal, rest, newVal), - ); - return obj; -} - -/** - * Delete a value at a given path within a query value. - */ -export function deletePath( - value: QueryValue, - path: (string | number)[], -): QueryValue { - if (path.length === 0) return null; - if (path.length === 1) { - const key = path[0]; - if (Array.isArray(value) && typeof key === "number") { - const arr = [...value]; - arr.splice(key, 1); - return arr; - } - if (value && typeof value === "object" && !Array.isArray(value)) { - const strKey = String(key); - // Defense against prototype pollution: skip dangerous keys - if (!isSafeKey(strKey)) { - return value; - } - const obj = nullPrototypeCopy(value); - delete obj[strKey]; - return obj; - } - return value; - } - - const [head, ...rest] = path; - if (Array.isArray(value) && typeof head === "number") { - const arr = [...value]; - arr[head] = deletePath(arr[head], rest); - return arr; - } - if (value && typeof value === "object" && !Array.isArray(value)) { - const strHead = String(head); - // Defense against prototype pollution: skip dangerous keys - if (!isSafeKey(strHead)) { - return value; - } - const obj = nullPrototypeCopy(value); - if (Object.hasOwn(obj, strHead)) { - safeSet(obj, strHead, deletePath(obj[strHead], rest)); - } - return obj; - } - return value; -} diff --git a/src/commands/query-engine/safe-object.test.ts b/src/commands/query-engine/safe-object.test.ts deleted file mode 100644 index c7f66592..00000000 --- a/src/commands/query-engine/safe-object.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - isSafeKey, - isSafeKeyStrict, - safeAssign, - safeCopy, - safeDelete, - safeFromEntries, - safeGet, - safeHasOwn, - safeSet, -} from "./safe-object.js"; - -describe("safe-object utilities", () => { - describe("isSafeKey", () => { - it("should return false for __proto__", () => { - expect(isSafeKey("__proto__")).toBe(false); - }); - - it("should return false for constructor", () => { - expect(isSafeKey("constructor")).toBe(false); - }); - - it("should return false for prototype", () => { - expect(isSafeKey("prototype")).toBe(false); - }); - - it("should return true for normal keys", () => { - expect(isSafeKey("name")).toBe(true); - expect(isSafeKey("value")).toBe(true); - expect(isSafeKey("foo")).toBe(true); - expect(isSafeKey("bar123")).toBe(true); - expect(isSafeKey("")).toBe(true); - expect(isSafeKey("0")).toBe(true); - }); - - it("should return true for similar but different keys", () => { - expect(isSafeKey("__proto")).toBe(true); - expect(isSafeKey("proto__")).toBe(true); - expect(isSafeKey("__Proto__")).toBe(true); - expect(isSafeKey("CONSTRUCTOR")).toBe(true); - expect(isSafeKey("Prototype")).toBe(true); - }); - }); - - describe("isSafeKeyStrict", () => { - it("should return false for basic dangerous keys", () => { - expect(isSafeKeyStrict("__proto__")).toBe(false); - expect(isSafeKeyStrict("constructor")).toBe(false); - expect(isSafeKeyStrict("prototype")).toBe(false); - }); - - it("should return false for extended dangerous keys", () => { - expect(isSafeKeyStrict("__defineGetter__")).toBe(false); - expect(isSafeKeyStrict("__defineSetter__")).toBe(false); - expect(isSafeKeyStrict("__lookupGetter__")).toBe(false); - expect(isSafeKeyStrict("__lookupSetter__")).toBe(false); - expect(isSafeKeyStrict("hasOwnProperty")).toBe(false); - expect(isSafeKeyStrict("isPrototypeOf")).toBe(false); - expect(isSafeKeyStrict("propertyIsEnumerable")).toBe(false); - expect(isSafeKeyStrict("toLocaleString")).toBe(false); - expect(isSafeKeyStrict("toString")).toBe(false); - expect(isSafeKeyStrict("valueOf")).toBe(false); - }); - - it("should return true for normal keys", () => { - expect(isSafeKeyStrict("name")).toBe(true); - expect(isSafeKeyStrict("value")).toBe(true); - }); - }); - - describe("safeGet", () => { - it("should get normal properties", () => { - const obj = { a: 1, b: "test" }; - expect(safeGet(obj, "a")).toBe(1); - expect(safeGet(obj, "b")).toBe("test"); - }); - - it("should return undefined for dangerous keys", () => { - const obj = { __proto__: "value" }; - expect(safeGet(obj, "__proto__")).toBe(undefined); - }); - - it("should return undefined for non-existent keys", () => { - const obj = { a: 1 }; - expect(safeGet(obj, "b")).toBe(undefined); - }); - - it("should not return inherited properties", () => { - const parent = { inherited: true }; - const obj = Object.create(parent); - obj.own = true; - expect(safeGet(obj, "own")).toBe(true); - expect(safeGet(obj, "inherited")).toBe(undefined); - }); - }); - - describe("safeSet", () => { - it("should set normal properties", () => { - const obj: Record = {}; - safeSet(obj, "a", 1); - safeSet(obj, "b", "test"); - expect(obj.a).toBe(1); - expect(obj.b).toBe("test"); - }); - - it("should ignore dangerous keys", () => { - const obj: Record = {}; - safeSet(obj, "__proto__", "polluted"); - safeSet(obj, "constructor", "polluted"); - safeSet(obj, "prototype", "polluted"); - - // The object should remain empty - expect(Object.keys(obj)).toEqual([]); - // Object.prototype should not be polluted - expect(({} as Record).__proto__).not.toBe("polluted"); - }); - - it("should silently ignore dangerous keys without throwing", () => { - const obj: Record = {}; - expect(() => safeSet(obj, "__proto__", "value")).not.toThrow(); - }); - }); - - describe("safeDelete", () => { - it("should delete normal properties", () => { - const obj: Record = { a: 1, b: 2 }; - safeDelete(obj, "a"); - expect(obj).toEqual({ b: 2 }); - }); - - it("should ignore dangerous keys", () => { - const obj: Record = { a: 1 }; - safeDelete(obj, "__proto__"); - safeDelete(obj, "constructor"); - expect(obj).toEqual({ a: 1 }); - }); - }); - - describe("safeFromEntries", () => { - it("should create object from entries", () => { - const entries: [string, number][] = [ - ["a", 1], - ["b", 2], - ]; - const result = safeFromEntries(entries); - expect(result).toEqual({ a: 1, b: 2 }); - }); - - it("should filter dangerous keys from entries", () => { - const entries: [string, string][] = [ - ["a", "safe"], - ["__proto__", "polluted"], - ["b", "safe"], - ["constructor", "polluted"], - ]; - const result = safeFromEntries(entries); - expect(result).toEqual({ a: "safe", b: "safe" }); - }); - - it("should handle empty entries", () => { - const result = safeFromEntries([]); - expect(result).toEqual({}); - }); - }); - - describe("safeAssign", () => { - it("should copy properties from source to target", () => { - const target: Record = { a: 1 }; - const source: Record = { b: 2, c: 3 }; - safeAssign(target, source); - expect(target).toEqual({ a: 1, b: 2, c: 3 }); - }); - - it("should filter dangerous keys from source", () => { - const target: Record = { a: 1 }; - const source: Record = { - b: 2, - __proto__: "polluted", - constructor: "polluted", - }; - safeAssign(target, source); - expect(target).toEqual({ a: 1, b: 2 }); - }); - - it("should return the target object", () => { - const target: Record = { a: 1 }; - const source: Record = { b: 2 }; - const result = safeAssign(target, source); - expect(result).toBe(target); - }); - }); - - describe("safeCopy", () => { - it("should create shallow copy of object", () => { - const obj = { a: 1, b: { nested: true } }; - const copy = safeCopy(obj); - expect(copy).toEqual(obj); - expect(copy).not.toBe(obj); - expect(copy.b).toBe(obj.b); // Shallow copy - }); - - it("should filter dangerous keys in copy", () => { - // Create object with dangerous key using Object.defineProperty - const obj: Record = { a: 1 }; - Object.defineProperty(obj, "__proto_key__", { - value: "safe", - enumerable: true, - }); - const copy = safeCopy(obj); - expect(copy.a).toBe(1); - expect(copy.__proto_key__).toBe("safe"); - }); - }); - - describe("safeHasOwn", () => { - it("should return true for own properties", () => { - const obj = { a: 1 }; - expect(safeHasOwn(obj, "a")).toBe(true); - }); - - it("should return false for non-existent properties", () => { - const obj = { a: 1 }; - expect(safeHasOwn(obj, "b")).toBe(false); - }); - - it("should return false for inherited properties", () => { - const parent = { inherited: true }; - const obj = Object.create(parent); - obj.own = true; - expect(safeHasOwn(obj, "own")).toBe(true); - expect(safeHasOwn(obj, "inherited")).toBe(false); - }); - - it("should handle prototype chain correctly", () => { - const obj = {}; - // These are on Object.prototype, not own properties - expect(safeHasOwn(obj, "toString")).toBe(false); - expect(safeHasOwn(obj, "hasOwnProperty")).toBe(false); - }); - }); - - describe("integration: prototype pollution prevention", () => { - it("should not pollute Object.prototype through any safe method", () => { - // Store original prototype state - const originalKeys = Object.keys(Object.prototype); - - // Try various pollution attempts - const obj1: Record = {}; - safeSet(obj1, "__proto__", { polluted: true }); - - const _obj2 = safeFromEntries([["__proto__", { polluted: true }]]); - - const obj3: Record = {}; - safeAssign(obj3, { __proto__: { polluted: true } }); - - // Verify Object.prototype is unchanged - const newKeys = Object.keys(Object.prototype); - expect(newKeys).toEqual(originalKeys); - expect((Object.prototype as Record).polluted).toBe( - undefined, - ); - }); - - it("should work correctly when chained", () => { - const entries: [string, number][] = [ - ["a", 1], - ["__proto__", 999], - ["b", 2], - ]; - const obj = safeFromEntries(entries); - safeSet(obj, "c", 3); - safeSet(obj, "constructor", 999); - safeDelete(obj, "a"); - safeAssign(obj, { d: 4, prototype: 999 }); - - expect(obj).toEqual({ b: 2, c: 3, d: 4 }); - }); - }); -}); diff --git a/src/commands/query-engine/safe-object.ts b/src/commands/query-engine/safe-object.ts deleted file mode 100644 index d23f3b15..00000000 --- a/src/commands/query-engine/safe-object.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Safe Object Utilities - * - * Defense-in-depth against JavaScript prototype pollution attacks. - * These utilities prevent malicious JSON from accessing or modifying - * the JavaScript prototype chain via keys like "__proto__", "constructor", etc. - */ - -/** - * Keys that could be used to access or pollute the prototype chain. - * These should never be used as direct object property names when - * setting values from untrusted input. - */ -const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); - -/** - * Extended list of potentially dangerous keys for extra paranoia. - * These include Node.js-specific and DOM-specific properties. - */ -const EXTENDED_DANGEROUS_KEYS = new Set([ - ...DANGEROUS_KEYS, - // Additional properties that could cause issues in specific contexts - "__defineGetter__", - "__defineSetter__", - "__lookupGetter__", - "__lookupSetter__", - "hasOwnProperty", - "isPrototypeOf", - "propertyIsEnumerable", - "toLocaleString", - "toString", - "valueOf", -]); - -/** - * Check if a key is safe to use for object property access/assignment. - * Returns true if the key is safe, false if it could cause prototype pollution. - */ -export function isSafeKey(key: string): boolean { - return !DANGEROUS_KEYS.has(key); -} - -/** - * Check if a key is safe using the extended dangerous keys list. - * More paranoid version that blocks additional Object.prototype methods. - */ -export function isSafeKeyStrict(key: string): boolean { - return !EXTENDED_DANGEROUS_KEYS.has(key); -} - -/** - * Safely get a property from an object using hasOwnProperty check. - * Returns undefined if the key is dangerous or doesn't exist as own property. - */ -export function safeGet(obj: Record, key: string): T | undefined { - if (!isSafeKey(key)) { - return undefined; - } - if (Object.hasOwn(obj, key)) { - return obj[key]; - } - return undefined; -} - -/** - * Safely set a property on an object. - * Silently ignores dangerous keys to prevent prototype pollution. - */ -export function safeSet( - obj: Record, - key: string, - value: T, -): void { - if (isSafeKey(key)) { - obj[key] = value; - } - // Dangerous keys are silently ignored - this matches jq behavior - // where __proto__ is treated as a regular key that happens to not work -} - -/** - * Safely delete a property from an object. - * Ignores dangerous keys. - */ -export function safeDelete(obj: Record, key: string): void { - if (isSafeKey(key)) { - delete obj[key]; - } -} - -/** - * Create a safe object from entries, filtering out dangerous keys. - */ -export function safeFromEntries( - entries: Iterable<[string, T]>, -): Record { - // Use null-prototype for additional safety - const result: Record = Object.create(null); - for (const [key, value] of entries) { - safeSet(result, key, value); - } - return result; -} - -/** - * Safely spread/assign properties from source to target. - * Only copies own properties and filters dangerous keys. - */ -export function safeAssign( - target: Record, - source: Record, -): Record { - for (const key of Object.keys(source)) { - safeSet(target, key, source[key]); - } - return target; -} - -/** - * Create a shallow copy of an object, filtering dangerous keys. - */ -export function safeCopy>(obj: T): T { - const result = Object.create(null) as T; - for (const key of Object.keys(obj)) { - if (isSafeKey(key)) { - // @banned-pattern-ignore: iterating via Object.keys() which only returns own properties - (result as Record)[key] = obj[key]; - } - } - return result; -} - -/** - * Check if object has own property safely (not inherited from prototype). - */ -export function safeHasOwn(obj: object, key: string): boolean { - return Object.hasOwn(obj, key); -} - -/** - * Create a null-prototype shallow copy of an object. - * This prevents prototype chain lookups without filtering any keys. - */ -export function nullPrototypeCopy( - obj: T, -): T & Record { - return Object.assign(Object.create(null), obj); -} - -/** - * Merge multiple objects into a new null-prototype object. - * This prevents prototype chain lookups without filtering any keys. - */ -export function nullPrototypeMerge( - ...objs: T[] -): T & Record { - return Object.assign(Object.create(null), ...objs); -} diff --git a/src/commands/query-engine/value-operations.ts b/src/commands/query-engine/value-operations.ts deleted file mode 100644 index 56873953..00000000 --- a/src/commands/query-engine/value-operations.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Query Value Utilities - * - * Utility functions for working with jq/query values. - */ - -import { - isSafeKey, - nullPrototypeCopy, - safeHasOwn, - safeSet, -} from "./safe-object.js"; - -export type QueryValue = unknown; - -/** - * Check if a value is truthy in jq semantics. - * In jq: false and null are falsy, everything else is truthy. - */ -export function isTruthy(v: QueryValue): boolean { - return v !== false && v !== null; -} - -/** - * Deep equality check for query values. - */ -export function deepEqual(a: QueryValue, b: QueryValue): boolean { - return JSON.stringify(a) === JSON.stringify(b); -} - -/** - * Compare two values for sorting. - * Returns negative if a < b, positive if a > b, 0 if equal. - */ -export function compare(a: QueryValue, b: QueryValue): number { - if (typeof a === "number" && typeof b === "number") return a - b; - if (typeof a === "string" && typeof b === "string") return a.localeCompare(b); - return 0; -} - -/** - * Deep merge two objects. - * Values from b override values from a, except nested objects are merged recursively. - * Filters out dangerous keys (__proto__, constructor, prototype) to prevent prototype pollution. - * Uses null-prototype objects to prevent prototype pollution via inherited properties. - */ -export function deepMerge( - a: Record, - b: Record, -): Record { - const result = nullPrototypeCopy(a); - for (const key of Object.keys(b)) { - // Skip dangerous keys to prevent prototype pollution - if (!isSafeKey(key)) continue; - - if ( - safeHasOwn(result, key) && - result[key] && - typeof result[key] === "object" && - !Array.isArray(result[key]) && - b[key] && - typeof b[key] === "object" && - !Array.isArray(b[key]) - ) { - safeSet( - result, - key, - deepMerge( - result[key] as Record, - b[key] as Record, - ), - ); - } else { - safeSet(result, key, b[key]); - } - } - return result; -} - -/** - * Calculate the nesting depth of a value (array or object). - */ -export function getValueDepth(value: QueryValue, maxCheck = 3000): number { - let depth = 0; - let current: QueryValue = value; - while (depth < maxCheck) { - if (Array.isArray(current)) { - if (current.length === 0) return depth + 1; - current = current[0]; - depth++; - } else if (current !== null && typeof current === "object") { - const keys = Object.keys(current); - if (keys.length === 0) return depth + 1; - // @banned-pattern-ignore: iterating via Object.keys() which only returns own properties - current = (current as Record)[keys[0]]; - depth++; - } else { - return depth; - } - } - return depth; -} - -/** - * Compare two values using jq's comparison semantics. - * jq sorts by type first (null < bool < number < string < array < object), - * then by value within type. - */ -export function compareJq(a: QueryValue, b: QueryValue): number { - const typeOrder = (v: QueryValue): number => { - if (v === null) return 0; - if (typeof v === "boolean") return 1; - if (typeof v === "number") return 2; - if (typeof v === "string") return 3; - if (Array.isArray(v)) return 4; - if (typeof v === "object") return 5; - return 6; - }; - - const ta = typeOrder(a); - const tb = typeOrder(b); - if (ta !== tb) return ta - tb; - - if (typeof a === "number" && typeof b === "number") return a - b; - if (typeof a === "string" && typeof b === "string") return a.localeCompare(b); - if (typeof a === "boolean" && typeof b === "boolean") - return (a ? 1 : 0) - (b ? 1 : 0); - if (Array.isArray(a) && Array.isArray(b)) { - for (let i = 0; i < Math.min(a.length, b.length); i++) { - const cmp = compareJq(a[i], b[i]); - if (cmp !== 0) return cmp; - } - return a.length - b.length; - } - // Objects: compare by sorted keys, then values - if (a && b && typeof a === "object" && typeof b === "object") { - const aObj = a as Record; - const bObj = b as Record; - const aKeys = Object.keys(aObj).sort(); - const bKeys = Object.keys(bObj).sort(); - // First compare keys - for (let i = 0; i < Math.min(aKeys.length, bKeys.length); i++) { - const keyCmp = aKeys[i].localeCompare(bKeys[i]); - if (keyCmp !== 0) return keyCmp; - } - if (aKeys.length !== bKeys.length) return aKeys.length - bKeys.length; - // Then compare values for each key - for (const key of aKeys) { - const cmp = compareJq(aObj[key], bObj[key]); - if (cmp !== 0) return cmp; - } - } - - return 0; -} - -/** - * Check if value a contains value b using jq's containment semantics. - */ -export function containsDeep(a: QueryValue, b: QueryValue): boolean { - if (deepEqual(a, b)) return true; - // jq: string contains substring check - if (typeof a === "string" && typeof b === "string") { - return a.includes(b); - } - if (Array.isArray(a) && Array.isArray(b)) { - return b.every((bItem) => a.some((aItem) => containsDeep(aItem, bItem))); - } - if ( - a && - b && - typeof a === "object" && - typeof b === "object" && - !Array.isArray(a) && - !Array.isArray(b) - ) { - const aObj = a as Record; - const bObj = b as Record; - return Object.keys(bObj).every( - (k) => safeHasOwn(aObj, k) && containsDeep(aObj[k], bObj[k]), - ); - } - return false; -} diff --git a/src/commands/readlink/readlink.test.ts b/src/commands/readlink/readlink.test.ts deleted file mode 100644 index c03902e0..00000000 --- a/src/commands/readlink/readlink.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("readlink", () => { - describe("basic usage", () => { - it("should read symlink target", async () => { - const env = new Bash(); - await env.exec("echo content > /tmp/target.txt"); - await env.exec("ln -s /tmp/target.txt /tmp/link"); - const result = await env.exec("readlink /tmp/link"); - expect(result.stdout).toBe("/tmp/target.txt\n"); - expect(result.exitCode).toBe(0); - }); - - it("should read relative symlink target", async () => { - const env = new Bash(); - await env.exec("echo content > /tmp/target.txt"); - await env.exec("ln -s target.txt /tmp/link"); - const result = await env.exec("readlink /tmp/link"); - expect(result.stdout).toBe("target.txt\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle multiple files", async () => { - const env = new Bash(); - await env.exec("echo a > /tmp/a.txt && echo b > /tmp/b.txt"); - await env.exec("ln -s /tmp/a.txt /tmp/link1"); - await env.exec("ln -s /tmp/b.txt /tmp/link2"); - const result = await env.exec("readlink /tmp/link1 /tmp/link2"); - expect(result.stdout).toBe("/tmp/a.txt\n/tmp/b.txt\n"); - expect(result.exitCode).toBe(0); - }); - - it("should fail for non-symlink file", async () => { - const env = new Bash(); - await env.exec("echo content > /tmp/regular.txt"); - const result = await env.exec("readlink /tmp/regular.txt"); - expect(result.exitCode).toBe(1); - }); - - it("should fail for non-existent file", async () => { - const env = new Bash(); - const result = await env.exec("readlink /tmp/nonexistent"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("-f (canonicalize)", () => { - it("should canonicalize path through symlinks", async () => { - const env = new Bash(); - await env.exec("echo content > /tmp/real.txt"); - await env.exec("ln -s /tmp/real.txt /tmp/link1"); - await env.exec("ln -s /tmp/link1 /tmp/link2"); - const result = await env.exec("readlink -f /tmp/link2"); - expect(result.stdout).toBe("/tmp/real.txt\n"); - expect(result.exitCode).toBe(0); - }); - - it("should return path for regular file", async () => { - const env = new Bash(); - await env.exec("echo content > /tmp/file.txt"); - const result = await env.exec("readlink -f /tmp/file.txt"); - expect(result.stdout).toBe("/tmp/file.txt\n"); - expect(result.exitCode).toBe(0); - }); - - it("should canonicalize with relative symlink components", async () => { - const env = new Bash(); - await env.exec("mkdir -p /tmp/dir"); - await env.exec("echo content > /tmp/dir/target.txt"); - await env.exec("ln -s dir/target.txt /tmp/link"); - const result = await env.exec("readlink -f /tmp/link"); - expect(result.stdout).toBe("/tmp/dir/target.txt\n"); - expect(result.exitCode).toBe(0); - }); - - it("should return resolved path for nonexistent file with -f", async () => { - const env = new Bash(); - const result = await env.exec("readlink -f /tmp/nonexistent"); - expect(result.stdout).toBe("/tmp/nonexistent\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("error handling", () => { - it("should error on missing operand", async () => { - const env = new Bash(); - const result = await env.exec("readlink"); - expect(result.stderr).toBe("readlink: missing operand\n"); - expect(result.exitCode).toBe(1); - }); - - it("should error on unknown option", async () => { - const env = new Bash(); - const result = await env.exec("readlink -x /tmp/link"); - expect(result.stderr).toContain("invalid option"); - expect(result.exitCode).toBe(1); - }); - - it("should handle -- to end options", async () => { - const env = new Bash(); - await env.exec("ln -s target /tmp/-f"); - const result = await env.exec("readlink -- /tmp/-f"); - expect(result.stdout).toBe("target\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("--help", () => { - it("should display help", async () => { - const env = new Bash(); - const result = await env.exec("readlink --help"); - expect(result.stdout).toContain("readlink"); - expect(result.stdout).toContain("-f"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/commands/readlink/readlink.ts b/src/commands/readlink/readlink.ts deleted file mode 100644 index b60eb235..00000000 --- a/src/commands/readlink/readlink.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { hasHelpFlag, showHelp } from "../help.js"; - -const readlinkHelp = { - name: "readlink", - summary: "print resolved symbolic links or canonical file names", - usage: "readlink [OPTIONS] FILE...", - options: [ - "-f canonicalize by following every symlink in every component of the given name recursively", - " --help display this help and exit", - ], -}; - -export const readlinkCommand: Command = { - name: "readlink", - - async execute(args: string[], ctx: CommandContext): Promise { - if (hasHelpFlag(args)) { - return showHelp(readlinkHelp); - } - - let canonicalize = false; - let argIdx = 0; - - // Parse options - while (argIdx < args.length && args[argIdx].startsWith("-")) { - const arg = args[argIdx]; - if (arg === "-f" || arg === "--canonicalize") { - canonicalize = true; - argIdx++; - } else if (arg === "--") { - argIdx++; - break; - } else { - return { - stdout: "", - stderr: `readlink: invalid option -- '${arg.slice(1)}'\n`, - exitCode: 1, - }; - } - } - - const files = args.slice(argIdx); - - if (files.length === 0) { - return { stdout: "", stderr: "readlink: missing operand\n", exitCode: 1 }; - } - - let stdout = ""; - let anyError = false; - - for (const file of files) { - const filePath = ctx.fs.resolvePath(ctx.cwd, file); - - try { - if (canonicalize) { - // For -f, resolve the full path following all symlinks - let currentPath = filePath; - const seen = new Set(); - - while (true) { - if (seen.has(currentPath)) { - // Circular symlink detected - break; - } - seen.add(currentPath); - - try { - const target = await ctx.fs.readlink(currentPath); - // If target is relative, resolve from current path's directory - if (target.startsWith("/")) { - currentPath = target; - } else { - const dir = - currentPath.substring(0, currentPath.lastIndexOf("/")) || "/"; - currentPath = ctx.fs.resolvePath(dir, target); - } - } catch { - // Not a symlink or doesn't exist - we've reached the end - break; - } - } - stdout += `${currentPath}\n`; - } else { - // Without -f, just read the symlink target - const target = await ctx.fs.readlink(filePath); - stdout += `${target}\n`; - } - } catch { - if (!canonicalize) { - // Only error for non-canonicalize mode on non-symlinks - anyError = true; - } else { - // For -f mode, return the resolved path even if not a symlink - stdout += `${filePath}\n`; - } - } - } - - return { stdout, stderr: "", exitCode: anyError ? 1 : 0 }; - }, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "readlink", - flags: [ - { flag: "-f", type: "boolean" }, - { flag: "-e", type: "boolean" }, - ], - needsArgs: true, -}; diff --git a/src/commands/registry.test.ts b/src/commands/registry.test.ts deleted file mode 100644 index d5ad5c54..00000000 --- a/src/commands/registry.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; -import { - clearCommandCache, - createLazyCommands, - getLoadedCommandCount, -} from "./registry.js"; - -describe("Command Registry", () => { - beforeEach(() => { - clearCommandCache(); - }); - - it("should create lazy commands with correct names", () => { - const commands = createLazyCommands(); - - expect(commands.length).toBeGreaterThan(30); - - const names = commands.map((c) => c.name); - expect(names).toContain("echo"); - expect(names).toContain("cat"); - expect(names).toContain("grep"); - expect(names).toContain("sed"); - expect(names).toContain("awk"); - expect(names).toContain("find"); - expect(names).toContain("ls"); - expect(names).toContain("mkdir"); - expect(names).toContain("bash"); - expect(names).toContain("sh"); - }); - - it("should load command on first execution", async () => { - expect(getLoadedCommandCount()).toBe(0); - - const env = new Bash(); - const result = await env.exec("echo hello world"); - - expect(result.stdout).toBe("hello world\n"); - expect(result.exitCode).toBe(0); - expect(getLoadedCommandCount()).toBeGreaterThan(0); - }); - - it("should cache commands after loading", async () => { - const env = new Bash(); - - await env.exec("echo first"); - const countAfterFirst = getLoadedCommandCount(); - - await env.exec("echo second"); - const countAfterSecond = getLoadedCommandCount(); - - // Same command shouldn't increase count - expect(countAfterSecond).toBe(countAfterFirst); - }); - - it("should load different commands independently", async () => { - const env = new Bash({ files: { "/test.txt": "content" } }); - - await env.exec("echo test"); - const countAfterEcho = getLoadedCommandCount(); - - await env.exec("cat /test.txt"); - const countAfterCat = getLoadedCommandCount(); - - expect(countAfterCat).toBeGreaterThan(countAfterEcho); - }); - - it("should clear cache correctly", async () => { - const env = new Bash(); - - await env.exec("echo test"); - expect(getLoadedCommandCount()).toBeGreaterThan(0); - - clearCommandCache(); - expect(getLoadedCommandCount()).toBe(0); - }); -}); diff --git a/src/commands/registry.ts b/src/commands/registry.ts deleted file mode 100644 index fb289761..00000000 --- a/src/commands/registry.ts +++ /dev/null @@ -1,607 +0,0 @@ -// Command registry with statically analyzable lazy loading -// Each command has an explicit loader function for bundler compatibility (Next.js, etc.) - -import type { Command, CommandContext, ExecResult } from "../types.js"; - -type CommandLoader = () => Promise; - -interface LazyCommandDef { - name: T; - load: CommandLoader; -} - -/** All available built-in command names (excludes network commands) */ -export type CommandName = - | "echo" - | "cat" - | "printf" - | "ls" - | "mkdir" - | "rmdir" - | "touch" - | "rm" - | "cp" - | "mv" - | "ln" - | "chmod" - | "pwd" - | "readlink" - | "head" - | "tail" - | "wc" - | "stat" - | "grep" - | "fgrep" - | "egrep" - | "rg" - | "sed" - | "awk" - | "sort" - | "uniq" - | "comm" - | "cut" - | "paste" - | "tr" - | "rev" - | "nl" - | "fold" - | "expand" - | "unexpand" - | "strings" - | "split" - | "column" - | "join" - | "tee" - | "find" - | "basename" - | "dirname" - | "tree" - | "du" - | "env" - | "printenv" - | "alias" - | "unalias" - | "history" - | "xargs" - | "true" - | "false" - | "clear" - | "bash" - | "sh" - | "jq" - | "base64" - | "diff" - | "date" - | "sleep" - | "timeout" - | "seq" - | "expr" - | "md5sum" - | "sha1sum" - | "sha256sum" - | "file" - | "html-to-markdown" - | "help" - | "which" - | "tac" - | "hostname" - | "od" - | "gzip" - | "gunzip" - | "zcat" - | "tar" - | "yq" - | "xan" - | "sqlite3" - | "time" - | "whoami"; - -/** Network command names (only available when network is configured) */ -export type NetworkCommandName = "curl"; - -/** Python command names (only available when python is explicitly enabled) */ -export type PythonCommandName = "python3" | "python"; - -/** All command names including network and python commands */ -export type AllCommandName = - | CommandName - | NetworkCommandName - | PythonCommandName; - -// Statically analyzable loaders - each import() call is a literal string -const commandLoaders: LazyCommandDef[] = [ - // Basic I/O - { - name: "echo", - load: async () => (await import("./echo/echo.js")).echoCommand, - }, - { - name: "cat", - load: async () => (await import("./cat/cat.js")).catCommand, - }, - { - name: "printf", - load: async () => (await import("./printf/printf.js")).printfCommand, - }, - - // File operations - { - name: "ls", - load: async () => (await import("./ls/ls.js")).lsCommand, - }, - { - name: "mkdir", - load: async () => (await import("./mkdir/mkdir.js")).mkdirCommand, - }, - { - name: "rmdir", - load: async () => (await import("./rmdir/rmdir.js")).rmdirCommand, - }, - { - name: "touch", - load: async () => (await import("./touch/touch.js")).touchCommand, - }, - { - name: "rm", - load: async () => (await import("./rm/rm.js")).rmCommand, - }, - { - name: "cp", - load: async () => (await import("./cp/cp.js")).cpCommand, - }, - { - name: "mv", - load: async () => (await import("./mv/mv.js")).mvCommand, - }, - { - name: "ln", - load: async () => (await import("./ln/ln.js")).lnCommand, - }, - { - name: "chmod", - load: async () => (await import("./chmod/chmod.js")).chmodCommand, - }, - - // Navigation - { - name: "pwd", - load: async () => (await import("./pwd/pwd.js")).pwdCommand, - }, - { - name: "readlink", - load: async () => (await import("./readlink/readlink.js")).readlinkCommand, - }, - - // File viewing - { - name: "head", - load: async () => (await import("./head/head.js")).headCommand, - }, - { - name: "tail", - load: async () => (await import("./tail/tail.js")).tailCommand, - }, - { - name: "wc", - load: async () => (await import("./wc/wc.js")).wcCommand, - }, - { - name: "stat", - load: async () => (await import("./stat/stat.js")).statCommand, - }, - - // Text processing - { - name: "grep", - load: async () => (await import("./grep/grep.js")).grepCommand, - }, - { - name: "fgrep", - load: async () => (await import("./grep/grep.js")).fgrepCommand, - }, - { - name: "egrep", - load: async () => (await import("./grep/grep.js")).egrepCommand, - }, - { - name: "rg", - load: async () => (await import("./rg/rg.js")).rgCommand, - }, - { - name: "sed", - load: async () => (await import("./sed/sed.js")).sedCommand, - }, - { - name: "awk", - load: async () => (await import("./awk/awk2.js")).awkCommand2, - }, - { - name: "sort", - load: async () => (await import("./sort/sort.js")).sortCommand, - }, - { - name: "uniq", - load: async () => (await import("./uniq/uniq.js")).uniqCommand, - }, - { - name: "comm", - load: async () => (await import("./comm/comm.js")).commCommand, - }, - { - name: "cut", - load: async () => (await import("./cut/cut.js")).cutCommand, - }, - { - name: "paste", - load: async () => (await import("./paste/paste.js")).pasteCommand, - }, - { - name: "tr", - load: async () => (await import("./tr/tr.js")).trCommand, - }, - { - name: "rev", - load: async () => (await import("./rev/rev.js")).rev, - }, - { - name: "nl", - load: async () => (await import("./nl/nl.js")).nl, - }, - { - name: "fold", - load: async () => (await import("./fold/fold.js")).fold, - }, - { - name: "expand", - load: async () => (await import("./expand/expand.js")).expand, - }, - { - name: "unexpand", - load: async () => (await import("./expand/unexpand.js")).unexpand, - }, - { - name: "strings", - load: async () => (await import("./strings/strings.js")).strings, - }, - { - name: "split", - load: async () => (await import("./split/split.js")).split, - }, - { - name: "column", - load: async () => (await import("./column/column.js")).column, - }, - { - name: "join", - load: async () => (await import("./join/join.js")).join, - }, - { - name: "tee", - load: async () => (await import("./tee/tee.js")).teeCommand, - }, - - // Search - { - name: "find", - load: async () => (await import("./find/find.js")).findCommand, - }, - - // Path utilities - { - name: "basename", - load: async () => (await import("./basename/basename.js")).basenameCommand, - }, - { - name: "dirname", - load: async () => (await import("./dirname/dirname.js")).dirnameCommand, - }, - - // Directory utilities - { - name: "tree", - load: async () => (await import("./tree/tree.js")).treeCommand, - }, - { - name: "du", - load: async () => (await import("./du/du.js")).duCommand, - }, - - // Environment - { - name: "env", - load: async () => (await import("./env/env.js")).envCommand, - }, - { - name: "printenv", - load: async () => (await import("./env/env.js")).printenvCommand, - }, - { - name: "alias", - load: async () => (await import("./alias/alias.js")).aliasCommand, - }, - { - name: "unalias", - load: async () => (await import("./alias/alias.js")).unaliasCommand, - }, - { - name: "history", - load: async () => (await import("./history/history.js")).historyCommand, - }, - - // Utilities - { - name: "xargs", - load: async () => (await import("./xargs/xargs.js")).xargsCommand, - }, - { - name: "true", - load: async () => (await import("./true/true.js")).trueCommand, - }, - { - name: "false", - load: async () => (await import("./true/true.js")).falseCommand, - }, - { - name: "clear", - load: async () => (await import("./clear/clear.js")).clearCommand, - }, - - // Shell - { - name: "bash", - load: async () => (await import("./bash/bash.js")).bashCommand, - }, - { - name: "sh", - load: async () => (await import("./bash/bash.js")).shCommand, - }, - - // Data processing - { - name: "jq", - load: async () => (await import("./jq/jq.js")).jqCommand, - }, - { - name: "base64", - load: async () => (await import("./base64/base64.js")).base64Command, - }, - { - name: "diff", - load: async () => (await import("./diff/diff.js")).diffCommand, - }, - { - name: "date", - load: async () => (await import("./date/date.js")).dateCommand, - }, - { - name: "sleep", - load: async () => (await import("./sleep/sleep.js")).sleepCommand, - }, - { - name: "timeout", - load: async () => (await import("./timeout/timeout.js")).timeoutCommand, - }, - { - name: "time", - load: async () => (await import("./time/time.js")).timeCommand, - }, - { - name: "seq", - load: async () => (await import("./seq/seq.js")).seqCommand, - }, - { - name: "expr", - load: async () => (await import("./expr/expr.js")).exprCommand, - }, - - // Checksums - { - name: "md5sum", - load: async () => (await import("./md5sum/md5sum.js")).md5sumCommand, - }, - { - name: "sha1sum", - load: async () => (await import("./md5sum/sha1sum.js")).sha1sumCommand, - }, - { - name: "sha256sum", - load: async () => (await import("./md5sum/sha256sum.js")).sha256sumCommand, - }, - - // File type detection - { - name: "file", - load: async () => (await import("./file/file.js")).fileCommand, - }, - - // HTML processing - { - name: "html-to-markdown", - load: async () => - (await import("./html-to-markdown/html-to-markdown.js")) - .htmlToMarkdownCommand, - }, - - // Help - { - name: "help", - load: async () => (await import("./help/help.js")).helpCommand, - }, - - // PATH utilities - { - name: "which", - load: async () => (await import("./which/which.js")).whichCommand, - }, - - // Misc utilities - { - name: "tac", - load: async () => (await import("./tac/tac.js")).tac, - }, - { - name: "hostname", - load: async () => (await import("./hostname/hostname.js")).hostname, - }, - { - name: "whoami", - load: async () => (await import("./whoami/whoami.js")).whoami, - }, - { - name: "od", - load: async () => (await import("./od/od.js")).od, - }, - - // Compression - { - name: "gzip", - load: async () => (await import("./gzip/gzip.js")).gzipCommand, - }, - { - name: "gunzip", - load: async () => (await import("./gzip/gzip.js")).gunzipCommand, - }, - { - name: "zcat", - load: async () => (await import("./gzip/gzip.js")).zcatCommand, - }, -]; - -// tar, yq, xan, and sqlite3 don't work in browsers -// __BROWSER__ is defined by esbuild at build time for browser bundles -declare const __BROWSER__: boolean | undefined; -if (typeof __BROWSER__ === "undefined" || !__BROWSER__) { - commandLoaders.push({ - name: "tar" as CommandName, - load: async () => (await import("./tar/tar.js")).tarCommand, - }); - commandLoaders.push({ - name: "yq" as CommandName, - load: async () => (await import("./yq/yq.js")).yqCommand, - }); - commandLoaders.push({ - name: "xan" as CommandName, - load: async () => (await import("./xan/xan.js")).xanCommand, - }); - commandLoaders.push({ - name: "sqlite3" as CommandName, - load: async () => (await import("./sqlite3/sqlite3.js")).sqlite3Command, - }); -} - -// Python commands - only registered when python is explicitly enabled -// These introduce additional security surface (arbitrary code execution) -const pythonCommandLoaders: LazyCommandDef[] = []; -// __BROWSER__ is defined by esbuild at build time for browser bundles -if (typeof __BROWSER__ === "undefined" || !__BROWSER__) { - pythonCommandLoaders.push({ - name: "python3", - load: async () => (await import("./python3/python3.js")).python3Command, - }); - pythonCommandLoaders.push({ - name: "python", - load: async () => (await import("./python3/python3.js")).pythonCommand, - }); -} - -// Network commands - only registered when network is configured -const networkCommandLoaders: LazyCommandDef[] = [ - { - name: "curl", - load: async () => (await import("./curl/curl.js")).curlCommand, - }, -]; - -// Cache for loaded commands -const cache = new Map(); - -/** - * Creates a lazy command that loads on first execution - */ -function createLazyCommand(def: LazyCommandDef): Command { - return { - name: def.name, - async execute(args: string[], ctx: CommandContext): Promise { - let cmd = cache.get(def.name); - - if (!cmd) { - cmd = await def.load(); - cache.set(def.name, cmd); - } - - // Emit flag coverage hits when fuzzing (not available in browser bundles) - if ( - ctx.coverage && - (typeof __BROWSER__ === "undefined" || !__BROWSER__) - ) { - const { emitFlagCoverage } = await import("./flag-coverage.js"); - emitFlagCoverage(ctx.coverage, def.name, args); - } - - return cmd.execute(args, ctx); - }, - }; -} - -/** - * Gets all available command names (excludes network commands) - */ -export function getCommandNames(): string[] { - return commandLoaders.map((def) => def.name); -} - -/** - * Gets all network command names - */ -export function getNetworkCommandNames(): string[] { - return networkCommandLoaders.map((def) => def.name); -} - -/** - * Creates all lazy commands for registration (excludes network commands) - * @param filter Optional array of command names to include. If not provided, all commands are created. - */ -export function createLazyCommands(filter?: CommandName[]): Command[] { - const loaders = filter - ? commandLoaders.filter((def) => filter.includes(def.name)) - : commandLoaders; - return loaders.map(createLazyCommand); -} - -/** - * Creates network commands for registration (curl, etc.) - * These are only registered when network is explicitly configured. - */ -export function createNetworkCommands(): Command[] { - return networkCommandLoaders.map(createLazyCommand); -} - -/** - * Gets all python command names - */ -export function getPythonCommandNames(): string[] { - return pythonCommandLoaders.map((def) => def.name); -} - -/** - * Creates python commands for registration (python3, python). - * These are only registered when python is explicitly enabled. - * Note: Python introduces additional security surface (arbitrary code execution). - */ -export function createPythonCommands(): Command[] { - return pythonCommandLoaders.map(createLazyCommand); -} - -/** - * Clears the command cache (for testing) - */ -export function clearCommandCache(): void { - cache.clear(); -} - -/** - * Gets the number of loaded commands (for testing) - */ -export function getLoadedCommandCount(): number { - return cache.size; -} diff --git a/src/commands/rev/rev.test.ts b/src/commands/rev/rev.test.ts deleted file mode 100644 index cb041bb9..00000000 --- a/src/commands/rev/rev.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("rev", () => { - it("reverses a simple string", async () => { - const bash = new Bash(); - const result = await bash.exec("echo 'hello' | rev"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("olleh\n"); - }); - - it("reverses multiple lines", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'abc\\ndef\\nghi' | rev"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("cba\nfed\nihg"); - }); - - it("reverses multiple lines with trailing newline", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'abc\\ndef\\n' | rev"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("cba\nfed\n"); - }); - - it("handles empty input", async () => { - const bash = new Bash(); - const result = await bash.exec("printf '' | rev"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - }); - - it("handles single character", async () => { - const bash = new Bash(); - const result = await bash.exec("echo 'a' | rev"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a\n"); - }); - - it("handles empty lines", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'a\\n\\nb\\n' | rev"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a\n\nb\n"); - }); - - it("reads from file", async () => { - const bash = new Bash({ - files: { - "/test.txt": "hello\nworld\n", - }, - }); - const result = await bash.exec("rev /test.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("olleh\ndlrow\n"); - }); - - it("reads from multiple files", async () => { - const bash = new Bash({ - files: { - "/a.txt": "abc\n", - "/b.txt": "def\n", - }, - }); - const result = await bash.exec("rev /a.txt /b.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("cba\nfed\n"); - }); - - it("handles file not found", async () => { - const bash = new Bash(); - const result = await bash.exec("rev /nonexistent.txt"); - expect(result.exitCode).toBe(1); - expect(result.stderr.toLowerCase()).toContain("no such file or directory"); - }); - - it("handles Unicode characters", async () => { - const bash = new Bash(); - const result = await bash.exec("echo '日本語' | rev"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("語本日\n"); - }); - - it("handles emoji", async () => { - const bash = new Bash(); - const result = await bash.exec("echo '👋🌍' | rev"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("🌍👋\n"); - }); - - it("shows help with --help", async () => { - const bash = new Bash(); - const result = await bash.exec("rev --help"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("rev"); - expect(result.stdout).toContain("reverse"); - }); - - it("preserves spaces", async () => { - const bash = new Bash(); - const result = await bash.exec("echo 'a b c' | rev"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("c b a\n"); - }); - - it("handles tabs", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'a\\tb\\tc' | rev"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("c\tb\ta"); - }); - - it("errors on unknown flag", async () => { - const bash = new Bash(); - const result = await bash.exec("echo 'test' | rev -x"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid option"); - }); - - it("errors on unknown long flag", async () => { - const bash = new Bash(); - const result = await bash.exec("echo 'test' | rev --unknown"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("unrecognized option"); - }); - - it("handles dash as stdin indicator", async () => { - const bash = new Bash(); - const result = await bash.exec("echo 'hello' | rev -"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("olleh\n"); - }); - - it("handles -- to end options", async () => { - const bash = new Bash({ - files: { - "/-file.txt": "test\n", - }, - }); - const result = await bash.exec("rev -- /-file.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("tset\n"); - }); -}); diff --git a/src/commands/rev/rev.ts b/src/commands/rev/rev.ts deleted file mode 100644 index 6c3c0d65..00000000 --- a/src/commands/rev/rev.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * rev - reverse lines characterwise - * - * Usage: rev [file ...] - * - * Copies the specified files to standard output, reversing the order - * of characters in every line. If no files are specified, standard - * input is read. - */ - -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { hasHelpFlag, showHelp, unknownOption } from "../help.js"; - -const revHelp = { - name: "rev", - summary: "reverse lines characterwise", - usage: "rev [file ...]", - description: - "Copies the specified files to standard output, reversing the order of characters in every line. If no files are specified, standard input is read.", - examples: [ - "echo 'hello' | rev # Output: olleh", - "rev file.txt # Reverse each line in file", - ], -}; - -/** - * Reverse a string, handling Unicode correctly by using Array.from - * to split by code points rather than UTF-16 code units. - */ -function reverseString(str: string): string { - return Array.from(str).reverse().join(""); -} - -export const rev: Command = { - name: "rev", - execute: async (args: string[], ctx: CommandContext): Promise => { - if (hasHelpFlag(args)) { - return showHelp(revHelp); - } - - const files: string[] = []; - for (const arg of args) { - if (arg === "--") { - // Everything after -- is a file - const idx = args.indexOf(arg); - files.push(...args.slice(idx + 1)); - break; - } else if (arg.startsWith("-") && arg !== "-") { - return unknownOption("rev", arg); - } else { - files.push(arg); - } - } - let output = ""; - - // Process function for content - const processContent = (content: string): string => { - const lines = content.split("\n"); - // Handle trailing newline - if content ends with \n, last element is empty - const hasTrailingNewline = - content.endsWith("\n") && lines[lines.length - 1] === ""; - if (hasTrailingNewline) { - lines.pop(); - } - const reversed = lines.map(reverseString); - return reversed.join("\n") + (hasTrailingNewline ? "\n" : ""); - }; - - if (files.length === 0) { - // Read from stdin - const input = ctx.stdin ?? ""; - output = processContent(input); - } else { - // Process each file - for (const file of files) { - if (file === "-") { - // Dash means read from stdin - const input = ctx.stdin ?? ""; - output += processContent(input); - } else { - const filePath = ctx.fs.resolvePath(ctx.cwd, file); - const content = await ctx.fs.readFile(filePath); - if (content === null) { - return { - exitCode: 1, - stdout: output, - stderr: `rev: ${file}: No such file or directory\n`, - }; - } - output += processContent(content); - } - } - } - - return { - exitCode: 0, - stdout: output, - stderr: "", - }; - }, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "rev", - flags: [], - stdinType: "text", - needsFiles: true, -}; diff --git a/src/commands/rg/file-types.ts b/src/commands/rg/file-types.ts deleted file mode 100644 index a5888d3a..00000000 --- a/src/commands/rg/file-types.ts +++ /dev/null @@ -1,264 +0,0 @@ -/** - * Built-in file type definitions for rg - * - * Maps type names to file extensions and glob patterns. - * Based on ripgrep's default type definitions. - */ - -import { createUserRegex } from "../../regex/index.js"; - -export interface FileType { - extensions: string[]; - globs: string[]; -} - -/** - * Built-in file type definitions - * Use `rg --type-list` to see all types in real ripgrep - */ -// @banned-pattern-ignore: converted to Map in FileTypeRegistry constructor, never direct bracket access -const FILE_TYPES: Record = { - // Web languages - js: { extensions: [".js", ".mjs", ".cjs", ".jsx"], globs: [] }, - ts: { extensions: [".ts", ".tsx", ".mts", ".cts"], globs: [] }, - html: { extensions: [".html", ".htm", ".xhtml"], globs: [] }, - css: { extensions: [".css", ".scss", ".sass", ".less"], globs: [] }, - json: { extensions: [".json", ".jsonc", ".json5"], globs: [] }, - xml: { extensions: [".xml", ".xsl", ".xslt"], globs: [] }, - - // Systems languages - c: { extensions: [".c", ".h"], globs: [] }, - cpp: { - extensions: [".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".h"], - globs: [], - }, - rust: { extensions: [".rs"], globs: [] }, - go: { extensions: [".go"], globs: [] }, - zig: { extensions: [".zig"], globs: [] }, - - // JVM languages - java: { extensions: [".java"], globs: [] }, - kotlin: { extensions: [".kt", ".kts"], globs: [] }, - scala: { extensions: [".scala", ".sc"], globs: [] }, - clojure: { extensions: [".clj", ".cljc", ".cljs", ".edn"], globs: [] }, - - // Scripting languages - py: { extensions: [".py", ".pyi", ".pyw"], globs: [] }, - rb: { - extensions: [".rb", ".rake", ".gemspec"], - globs: ["Rakefile", "Gemfile"], - }, - php: { extensions: [".php", ".phtml", ".php3", ".php4", ".php5"], globs: [] }, - perl: { extensions: [".pl", ".pm", ".pod", ".t"], globs: [] }, - lua: { extensions: [".lua"], globs: [] }, - - // Shell - sh: { - extensions: [".sh", ".bash", ".zsh", ".fish"], - globs: [".bashrc", ".zshrc", ".profile"], - }, - bat: { extensions: [".bat", ".cmd"], globs: [] }, - ps: { extensions: [".ps1", ".psm1", ".psd1"], globs: [] }, - - // Data/Config - yaml: { extensions: [".yaml", ".yml"], globs: [] }, - toml: { extensions: [".toml"], globs: ["Cargo.toml", "pyproject.toml"] }, - ini: { extensions: [".ini", ".cfg", ".conf"], globs: [] }, - csv: { extensions: [".csv", ".tsv"], globs: [] }, - - // Documentation - md: { extensions: [".md", ".mdx", ".markdown", ".mdown", ".mkd"], globs: [] }, - markdown: { - extensions: [".md", ".mdx", ".markdown", ".mdown", ".mkd"], - globs: [], - }, - rst: { extensions: [".rst"], globs: [] }, - txt: { extensions: [".txt", ".text"], globs: [] }, - tex: { extensions: [".tex", ".ltx", ".sty", ".cls"], globs: [] }, - - // Other - sql: { extensions: [".sql"], globs: [] }, - graphql: { extensions: [".graphql", ".gql"], globs: [] }, - proto: { extensions: [".proto"], globs: [] }, - make: { - extensions: [".mk", ".mak"], - globs: ["Makefile", "GNUmakefile", "makefile"], - }, - docker: { - extensions: [], - globs: ["Dockerfile", "Dockerfile.*", "*.dockerfile"], - }, - tf: { extensions: [".tf", ".tfvars"], globs: [] }, -}; - -/** - * Mutable file type registry for runtime type modifications - * Supports --type-add and --type-clear flags - */ -export class FileTypeRegistry { - private types: Map; - - constructor() { - // Clone default types - this.types = new Map( - Object.entries(FILE_TYPES).map(([name, type]) => [ - name, - { extensions: [...type.extensions], globs: [...type.globs] }, - ]), - ); - } - - /** - * Add a type definition - * Format: "name:pattern" where pattern can be: - * - "*.ext" - glob pattern - * - "include:other" - include patterns from another type - */ - addType(spec: string): void { - const colonIdx = spec.indexOf(":"); - if (colonIdx === -1) return; - - const name = spec.slice(0, colonIdx); - const pattern = spec.slice(colonIdx + 1); - - if (pattern.startsWith("include:")) { - // Include patterns from another type - const otherName = pattern.slice(8); - const other = this.types.get(otherName); - if (other) { - const existing = this.types.get(name) || { extensions: [], globs: [] }; - existing.extensions.push(...other.extensions); - existing.globs.push(...other.globs); - this.types.set(name, existing); - } - } else { - // Add glob pattern - const existing = this.types.get(name) || { extensions: [], globs: [] }; - // If pattern is like "*.ext", add to extensions - if (pattern.startsWith("*.") && !pattern.slice(2).includes("*")) { - const ext = pattern.slice(1); // Keep the dot - if (!existing.extensions.includes(ext)) { - existing.extensions.push(ext); - } - } else { - // Add as glob pattern - if (!existing.globs.includes(pattern)) { - existing.globs.push(pattern); - } - } - this.types.set(name, existing); - } - } - - /** - * Clear all patterns from a type - */ - clearType(name: string): void { - const existing = this.types.get(name); - if (existing) { - existing.extensions = []; - existing.globs = []; - } - } - - /** - * Get a type by name - */ - getType(name: string): FileType | undefined { - return this.types.get(name); - } - - /** - * Get all type names - */ - getAllTypes(): Map { - return this.types; - } - - /** - * Check if a filename matches any of the specified types - */ - matchesType(filename: string, typeNames: string[]): boolean { - const lowerFilename = filename.toLowerCase(); - - for (const typeName of typeNames) { - // Special case: 'all' matches any file with a recognized type - if (typeName === "all") { - if (this.matchesAnyType(filename)) { - return true; - } - continue; - } - - const fileType = this.types.get(typeName); - if (!fileType) continue; - - // Check extensions - for (const ext of fileType.extensions) { - if (lowerFilename.endsWith(ext)) { - return true; - } - } - - // Check globs - for (const glob of fileType.globs) { - if (glob.includes("*")) { - const pattern = glob.replace(/\./g, "\\.").replace(/\*/g, ".*"); - if (createUserRegex(`^${pattern}$`, "i").test(filename)) { - return true; - } - } else if (lowerFilename === glob.toLowerCase()) { - return true; - } - } - } - - return false; - } - - /** - * Check if a filename matches any recognized type - */ - private matchesAnyType(filename: string): boolean { - const lowerFilename = filename.toLowerCase(); - - for (const fileType of this.types.values()) { - for (const ext of fileType.extensions) { - if (lowerFilename.endsWith(ext)) { - return true; - } - } - - for (const glob of fileType.globs) { - if (glob.includes("*")) { - const pattern = glob.replace(/\./g, "\\.").replace(/\*/g, ".*"); - if (createUserRegex(`^${pattern}$`, "i").test(filename)) { - return true; - } - } else if (lowerFilename === glob.toLowerCase()) { - return true; - } - } - } - - return false; - } -} - -/** - * Format type list for --type-list output - */ -export function formatTypeList(): string { - const lines: string[] = []; - for (const [name, type] of Object.entries(FILE_TYPES).sort()) { - const patterns: string[] = []; - for (const ext of type.extensions) { - patterns.push(`*${ext}`); - } - for (const glob of type.globs) { - patterns.push(glob); - } - lines.push(`${name}: ${patterns.join(", ")}`); - } - return `${lines.join("\n")}\n`; -} diff --git a/src/commands/rg/gitignore.test.ts b/src/commands/rg/gitignore.test.ts deleted file mode 100644 index 7a9187b3..00000000 --- a/src/commands/rg/gitignore.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { GitignoreParser } from "./gitignore.js"; - -describe("GitignoreParser", () => { - describe("simple patterns", () => { - it("should match file extension patterns", () => { - const parser = new GitignoreParser(); - parser.parse("*.log"); - expect(parser.matches("debug.log", false)).toBe(true); - expect(parser.matches("error.log", false)).toBe(true); - expect(parser.matches("app.ts", false)).toBe(false); - }); - - it("should match exact file names", () => { - const parser = new GitignoreParser(); - parser.parse("package-lock.json"); - expect(parser.matches("package-lock.json", false)).toBe(true); - expect(parser.matches("package.json", false)).toBe(false); - }); - - it("should match directory names", () => { - const parser = new GitignoreParser(); - parser.parse("node_modules"); - expect(parser.matches("node_modules", true)).toBe(true); - expect(parser.matches("src/node_modules", true)).toBe(true); - }); - }); - - describe("negation patterns", () => { - it("should negate previous patterns", () => { - const parser = new GitignoreParser(); - parser.parse("*.log\n!important.log"); - expect(parser.matches("debug.log", false)).toBe(true); - expect(parser.matches("important.log", false)).toBe(false); - }); - - it("should handle multiple negations", () => { - const parser = new GitignoreParser(); - parser.parse("*\n!src\n!*.ts"); - expect(parser.matches("README.md", false)).toBe(true); - expect(parser.matches("app.ts", false)).toBe(false); - expect(parser.matches("src", true)).toBe(false); - }); - }); - - describe("directory-only patterns", () => { - it("should only match directories with trailing slash", () => { - const parser = new GitignoreParser(); - parser.parse("build/"); - expect(parser.matches("build", true)).toBe(true); - expect(parser.matches("build", false)).toBe(false); - }); - }); - - describe("rooted patterns", () => { - it("should only match at root with leading slash", () => { - const parser = new GitignoreParser(); - parser.parse("/todo.txt"); - expect(parser.matches("todo.txt", false)).toBe(true); - expect(parser.matches("docs/todo.txt", false)).toBe(false); - }); - - it("should be rooted when pattern contains slash", () => { - const parser = new GitignoreParser(); - parser.parse("doc/frotz"); - expect(parser.matches("doc/frotz", false)).toBe(true); - expect(parser.matches("a/doc/frotz", false)).toBe(false); - }); - }); - - describe("double-star patterns", () => { - it("should match any directory depth", () => { - const parser = new GitignoreParser(); - parser.parse("**/foo"); - expect(parser.matches("foo", false)).toBe(true); - expect(parser.matches("a/foo", false)).toBe(true); - expect(parser.matches("a/b/c/foo", false)).toBe(true); - }); - - it("should match trailing double-star", () => { - const parser = new GitignoreParser(); - parser.parse("abc/**"); - expect(parser.matches("abc/def", false)).toBe(true); - expect(parser.matches("abc/def/ghi", false)).toBe(true); - expect(parser.matches("abc", false)).toBe(false); - }); - }); - - describe("comments and blank lines", () => { - it("should ignore comments", () => { - const parser = new GitignoreParser(); - parser.parse("# This is a comment\n*.log"); - expect(parser.matches("debug.log", false)).toBe(true); - }); - - it("should ignore blank lines", () => { - const parser = new GitignoreParser(); - parser.parse("*.log\n\n*.tmp"); - expect(parser.matches("debug.log", false)).toBe(true); - expect(parser.matches("cache.tmp", false)).toBe(true); - }); - }); - - describe("special characters", () => { - it("should handle question mark wildcard", () => { - const parser = new GitignoreParser(); - parser.parse("file?.txt"); - expect(parser.matches("file1.txt", false)).toBe(true); - expect(parser.matches("fileA.txt", false)).toBe(true); - expect(parser.matches("file12.txt", false)).toBe(false); - }); - - it("should handle character classes", () => { - const parser = new GitignoreParser(); - parser.parse("file[0-9].txt"); - expect(parser.matches("file0.txt", false)).toBe(true); - expect(parser.matches("file9.txt", false)).toBe(true); - expect(parser.matches("fileA.txt", false)).toBe(false); - }); - - it("should handle negated character classes", () => { - const parser = new GitignoreParser(); - parser.parse("file[!0-9].txt"); - expect(parser.matches("file0.txt", false)).toBe(false); - expect(parser.matches("fileA.txt", false)).toBe(true); - }); - }); -}); diff --git a/src/commands/rg/gitignore.ts b/src/commands/rg/gitignore.ts deleted file mode 100644 index 26be4583..00000000 --- a/src/commands/rg/gitignore.ts +++ /dev/null @@ -1,444 +0,0 @@ -/** - * .gitignore parser for rg - * - * Handles: - * - Simple patterns (*.log, node_modules/) - * - Negation patterns (!important.log) - * - Directory-only patterns (build/) - * - Rooted patterns (/root-only) - * - Double-star patterns (for matching across directories) - */ - -import type { IFileSystem } from "../../fs/interface.js"; -import { createUserRegex, type RegexLike } from "../../regex/index.js"; - -interface GitignorePattern { - /** Original pattern string */ - pattern: string; - /** Compiled regex for matching */ - regex: RegexLike; - /** Whether this is a negation pattern (starts with !) */ - negated: boolean; - /** Whether this only matches directories (ends with /) */ - directoryOnly: boolean; - /** Whether this is rooted (starts with / or contains /) */ - rooted: boolean; -} - -export class GitignoreParser { - private patterns: GitignorePattern[] = []; - private basePath: string; - - constructor(basePath: string = "/") { - this.basePath = basePath; - } - - /** - * Parse .gitignore content and add patterns - */ - parse(content: string): void { - const lines = content.split("\n"); - - for (const line of lines) { - // Trim trailing whitespace (but not leading - significant in gitignore) - let trimmed = line.replace(/\s+$/, ""); - - // Skip empty lines and comments - if (!trimmed || trimmed.startsWith("#")) { - continue; - } - - // Handle negation - let negated = false; - if (trimmed.startsWith("!")) { - negated = true; - trimmed = trimmed.slice(1); - } - - // Handle directory-only patterns - let directoryOnly = false; - if (trimmed.endsWith("/")) { - directoryOnly = true; - trimmed = trimmed.slice(0, -1); - } - - // Handle rooted patterns - let rooted = false; - if (trimmed.startsWith("/")) { - rooted = true; - trimmed = trimmed.slice(1); - } else if (trimmed.includes("/") && !trimmed.startsWith("**/")) { - // Patterns with / in the middle are rooted - rooted = true; - } - - // Convert gitignore pattern to regex - const regex = this.patternToRegex(trimmed, rooted); - - this.patterns.push({ - pattern: line, - regex, - negated, - directoryOnly, - rooted, - }); - } - } - - /** - * Convert a gitignore pattern to a regex - */ - private patternToRegex(pattern: string, rooted: boolean): RegexLike { - let regexStr = ""; - - // If not rooted, can match at any depth - if (!rooted) { - regexStr = "(?:^|/)"; - } else { - regexStr = "^"; - } - - let i = 0; - while (i < pattern.length) { - const char = pattern[i]; - - if (char === "*") { - if (pattern[i + 1] === "*") { - // ** matches any number of directories - if (pattern[i + 2] === "/") { - // **/ matches zero or more directories - regexStr += "(?:.*/)?"; - i += 3; - } else if (i + 2 >= pattern.length) { - // ** at end matches everything - regexStr += ".*"; - i += 2; - } else { - // ** in middle - regexStr += ".*"; - i += 2; - } - } else { - // * matches anything except / - regexStr += "[^/]*"; - i++; - } - } else if (char === "?") { - // ? matches any single character except / - regexStr += "[^/]"; - i++; - } else if (char === "[") { - // Character class - find the closing ] - let j = i + 1; - if (j < pattern.length && pattern[j] === "!") j++; - if (j < pattern.length && pattern[j] === "]") j++; - while (j < pattern.length && pattern[j] !== "]") j++; - - if (j < pattern.length) { - // Valid character class - let charClass = pattern.slice(i, j + 1); - // Convert [!...] to [^...] - if (charClass.startsWith("[!")) { - charClass = `[^${charClass.slice(2)}`; - } - regexStr += charClass; - i = j + 1; - } else { - // No closing ], treat [ as literal - regexStr += "\\["; - i++; - } - } else if (char === "/") { - regexStr += "/"; - i++; - } else { - // Escape regex special characters - regexStr += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - i++; - } - } - - // Pattern should match the full path component - regexStr += "(?:/.*)?$"; - - return createUserRegex(regexStr); - } - - /** - * Check if a path should be ignored - * - * @param relativePath Path relative to the gitignore location - * @param isDirectory Whether the path is a directory - * @returns true if the path should be ignored - */ - matches(relativePath: string, isDirectory: boolean): boolean { - // Normalize path - remove leading ./ - let path = relativePath.replace(/^\.\//, ""); - - // Ensure path starts without / - path = path.replace(/^\//, ""); - - let ignored = false; - - for (const pattern of this.patterns) { - // Skip directory-only patterns for files - if (pattern.directoryOnly && !isDirectory) { - continue; - } - - if (pattern.regex.test(path)) { - ignored = !pattern.negated; - } - } - - return ignored; - } - - /** - * Check if a path is explicitly whitelisted by a negation pattern - * - * @param relativePath Path relative to the gitignore location - * @param isDirectory Whether the path is a directory - * @returns true if the path is whitelisted by a negation pattern - */ - isWhitelisted(relativePath: string, isDirectory: boolean): boolean { - // Normalize path - remove leading ./ - let path = relativePath.replace(/^\.\//, ""); - - // Ensure path starts without / - path = path.replace(/^\//, ""); - - for (const pattern of this.patterns) { - // Skip directory-only patterns for files - if (pattern.directoryOnly && !isDirectory) { - continue; - } - - // Check if a negation pattern matches - if (pattern.negated && pattern.regex.test(path)) { - return true; - } - } - - return false; - } - - /** - * Get the base path for this gitignore - */ - getBasePath(): string { - return this.basePath; - } -} - -/** - * Hierarchical gitignore manager - * - * Loads .gitignore and .ignore files from the root down to the current directory, - * applying patterns in order (child patterns override parent patterns). - */ -export class GitignoreManager { - private parsers: GitignoreParser[] = []; - private fs: IFileSystem; - private skipDotIgnore: boolean; - private skipVcsIgnore: boolean; - private loadedDirs = new Set(); - - constructor( - fs: IFileSystem, - _rootPath: string, - skipDotIgnore = false, - skipVcsIgnore = false, - ) { - this.fs = fs; - this.skipDotIgnore = skipDotIgnore; - this.skipVcsIgnore = skipVcsIgnore; - } - - /** - * Load all .gitignore and .ignore files from root to the specified path - */ - async load(targetPath: string): Promise { - // Build list of directories from filesystem root to target - // ripgrep loads ignore files from all parent directories - const dirs: string[] = []; - let current = targetPath; - - while (true) { - dirs.unshift(current); - const parent = this.fs.resolvePath(current, ".."); - if (parent === current) break; // Reached filesystem root - current = parent; - } - - // Load ignore files from each directory - // ripgrep loads them in order: .gitignore, then .rgignore, then .ignore - // --no-ignore-dot skips .rgignore and .ignore - // --no-ignore-vcs skips .gitignore - const ignoreFiles: string[] = []; - if (!this.skipVcsIgnore) { - ignoreFiles.push(".gitignore"); - } - if (!this.skipDotIgnore) { - ignoreFiles.push(".rgignore", ".ignore"); - } - for (const dir of dirs) { - this.loadedDirs.add(dir); - for (const filename of ignoreFiles) { - const ignorePath = this.fs.resolvePath(dir, filename); - try { - const content = await this.fs.readFile(ignorePath); - const parser = new GitignoreParser(dir); - parser.parse(content); - this.parsers.push(parser); - } catch { - // No ignore file in this directory - } - } - } - } - - /** - * Load ignore files for a directory during traversal. - * Only loads if the directory hasn't been loaded before. - */ - async loadForDirectory(dir: string): Promise { - if (this.loadedDirs.has(dir)) return; - this.loadedDirs.add(dir); - - const ignoreFiles: string[] = []; - if (!this.skipVcsIgnore) { - ignoreFiles.push(".gitignore"); - } - if (!this.skipDotIgnore) { - ignoreFiles.push(".rgignore", ".ignore"); - } - - for (const filename of ignoreFiles) { - const ignorePath = this.fs.resolvePath(dir, filename); - try { - const content = await this.fs.readFile(ignorePath); - const parser = new GitignoreParser(dir); - parser.parse(content); - this.parsers.push(parser); - } catch { - // No ignore file in this directory - } - } - } - - /** - * Add patterns from raw content at the specified base path. - * Used for --ignore-file flag. - */ - addPatternsFromContent(content: string, basePath: string): void { - const parser = new GitignoreParser(basePath); - parser.parse(content); - this.parsers.push(parser); - } - - /** - * Check if a path should be ignored - * - * @param absolutePath Absolute path to check - * @param isDirectory Whether the path is a directory - * @returns true if the path should be ignored - */ - matches(absolutePath: string, isDirectory: boolean): boolean { - for (const parser of this.parsers) { - // Get path relative to the gitignore location - const basePath = parser.getBasePath(); - if (!absolutePath.startsWith(basePath)) continue; - - const relativePath = absolutePath - .slice(basePath.length) - .replace(/^\//, ""); - if (parser.matches(relativePath, isDirectory)) { - return true; - } - } - return false; - } - - /** - * Check if a path is explicitly whitelisted by a negation pattern. - * Used to include hidden files that have negation patterns like "!.foo" - * - * @param absolutePath Absolute path to check - * @param isDirectory Whether the path is a directory - * @returns true if the path is whitelisted by a negation pattern - */ - isWhitelisted(absolutePath: string, isDirectory: boolean): boolean { - for (const parser of this.parsers) { - // Get path relative to the gitignore location - const basePath = parser.getBasePath(); - if (!absolutePath.startsWith(basePath)) continue; - - const relativePath = absolutePath - .slice(basePath.length) - .replace(/^\//, ""); - if (parser.isWhitelisted(relativePath, isDirectory)) { - return true; - } - } - return false; - } - - /** - * Quick check for common ignored directories - * Used for early pruning during traversal - */ - static isCommonIgnored(name: string): boolean { - // Only include VCS directories and very common dependency directories - // that are almost never searched. Don't include build/dist/target - // as these are often legitimately searched or have negation patterns. - const common = new Set([ - "node_modules", - ".git", - ".svn", - ".hg", - "__pycache__", - ".pytest_cache", - ".mypy_cache", - "venv", - ".venv", - ".next", - ".nuxt", - ".cargo", - ]); - return common.has(name); - } -} - -/** - * Load gitignore files for a search starting at the given path - */ -export async function loadGitignores( - fs: IFileSystem, - startPath: string, - skipDotIgnore = false, - skipVcsIgnore = false, - customIgnoreFiles: string[] = [], -): Promise { - const manager = new GitignoreManager( - fs, - startPath, - skipDotIgnore, - skipVcsIgnore, - ); - await manager.load(startPath); - - // Load custom ignore files (--ignore-file) - for (const ignoreFile of customIgnoreFiles) { - try { - const absolutePath = fs.resolvePath(startPath, ignoreFile); - const content = await fs.readFile(absolutePath); - // Add patterns from custom ignore file at the root level - manager.addPatternsFromContent(content, startPath); - } catch { - // Ignore missing files - } - } - - return manager; -} diff --git a/src/commands/rg/imported-tests/README.md b/src/commands/rg/imported-tests/README.md deleted file mode 100644 index 69e17780..00000000 --- a/src/commands/rg/imported-tests/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Imported ripgrep Tests - -These tests are transliterated from [ripgrep](https://github.com/BurntSushi/ripgrep), the original Rust-based search tool. - -## Source - -Tests were imported from the ripgrep test suite: -- https://github.com/BurntSushi/ripgrep/tree/master/tests - -## Files - -| File | Source | Description | -|------|--------|-------------| -| `binary.test.ts` | `tests/binary.rs` | Binary file detection and handling | -| `feature.test.ts` | `tests/feature.rs` | Feature tests from GitHub issues | -| `json.test.ts` | `tests/json.rs` | JSON output format | -| `misc.test.ts` | `tests/misc.rs` | Miscellaneous behavior tests + gzip | -| `multiline.test.ts` | `tests/multiline.rs` | Multiline matching tests | -| `regression.test.ts` | `tests/regression.rs` | Regression tests from bug reports | - -## Skipped Tests - -Some tests are skipped due to implementation differences: - -### json.rs -- `notutf8`, `notutf8_file` - Non-UTF8 file handling not supported -- `crlf`, `r1095_*` - `--crlf` flag not implemented -- `r1412_*` - Requires PCRE2 look-behind - -### multiline.rs -- Tests using `\p{Any}` Unicode property (not supported in JavaScript regex) -- `--multiline-dotall` flag (not implemented) - -### misc.rs -- `compressed_*` for bzip2, xz, lz4, lzma, brotli, zstd, compress (only gzip supported) - -### General -- `.ignore` file support (we only support `.gitignore`) -- Context messages in JSON output (`-A/-B/-C` context not output as separate messages) - -## License - -ripgrep is licensed under the MIT license. See the [ripgrep repository](https://github.com/BurntSushi/ripgrep) for details. diff --git a/src/commands/rg/imported-tests/binary.test.ts b/src/commands/rg/imported-tests/binary.test.ts deleted file mode 100644 index ee580203..00000000 --- a/src/commands/rg/imported-tests/binary.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** - * Tests imported from ripgrep: tests/binary.rs - * - * These tests cover binary file detection and handling. - * ripgrep skips binary files by default (files containing NUL bytes). - * - * Note: Many ripgrep binary tests involve --mmap, --binary, and --text flags - * which we don't fully support. This file contains applicable tests. - */ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../../Bash.js"; - -// Simple binary content with NUL byte -const BINARY_CONTENT = "hello\x00world\n"; -const TEXT_CONTENT = "hello world\n"; - -describe("rg binary: basic detection", () => { - it("should skip binary files by default in directory search", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/text.txt": TEXT_CONTENT, - "/home/user/binary.bin": BINARY_CONTENT, - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - // Should only find match in text file, not binary - expect(result.stdout).toBe("text.txt:1:hello world\n"); - }); - - it("should skip binary files when searching single explicit file", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/binary.bin": BINARY_CONTENT, - }, - }); - const result = await bash.exec("rg hello binary.bin"); - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - }); - - it("should detect binary in first 8KB of file", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - // NUL byte early in file - "/home/user/early.bin": `\x00${"a".repeat(100)}pattern\n`, - }, - }); - const result = await bash.exec("rg pattern"); - expect(result.exitCode).toBe(1); - }); - - it("should not detect binary if NUL after 8KB sample", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - // NUL byte after 8KB - won't be detected in sample - "/home/user/late.txt": `pattern\n${"a".repeat(9000)}\x00end\n`, - }, - }); - const result = await bash.exec("rg pattern"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("late.txt:1:pattern\n"); - }); -}); - -describe("rg binary: with count flag", () => { - it("should not count matches in binary files", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/text.txt": "match\nmatch\n", - "/home/user/binary.bin": "match\x00match\n", - }, - }); - const result = await bash.exec("rg -c match"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("text.txt:2\n"); - }); -}); - -describe("rg binary: with files-with-matches flag", () => { - it("should not list binary files with -l", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/text.txt": "findme\n", - "/home/user/binary.bin": "findme\x00\n", - }, - }); - const result = await bash.exec("rg -l findme"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("text.txt\n"); - }); -}); - -describe("rg binary: mixed content", () => { - it("should only search text files in mixed directory", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/readme.md": "documentation\n", - "/home/user/image.png": "\x89PNG\r\n\x1a\n\x00\x00\x00", - "/home/user/script.sh": "echo documentation\n", - }, - }); - const result = await bash.exec("rg --sort path documentation"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "readme.md:1:documentation\nscript.sh:1:echo documentation\n", - ); - }); - - it("should handle multiple binary and text files", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a.txt": "test\n", - "/home/user/b.bin": "test\x00\n", - "/home/user/c.txt": "test\n", - "/home/user/d.bin": "test\x00\n", - }, - }); - const result = await bash.exec("rg test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a.txt:1:test\nc.txt:1:test\n"); - }); -}); - -describe("rg binary: edge cases", () => { - it("should handle file with only NUL bytes", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/nulls.bin": "\x00\x00\x00\x00", - }, - }); - const result = await bash.exec("rg anything"); - expect(result.exitCode).toBe(1); - }); - - it("should handle NUL at start of file", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/start.bin": "\x00hello world\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(1); - }); - - it("should handle NUL at end of file", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/end.bin": "hello world\n\x00", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(1); - }); - - it("should handle multiple NUL bytes", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/multi.bin": "a\x00b\x00c\x00d\n", - }, - }); - const result = await bash.exec("rg '[a-d]'"); - expect(result.exitCode).toBe(1); - }); -}); - -describe("rg binary: common binary file types", () => { - it("should skip files with common binary signatures", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - // PNG signature - "/home/user/image.png": "\x89PNG\r\n\x1a\n\x00data", - // PDF signature (simplified) - "/home/user/doc.pdf": "%PDF-1.4\n\x00binary", - // ZIP signature - "/home/user/archive.zip": "PK\x03\x04\x00\x00data", - // Text file for comparison - "/home/user/text.txt": "data\n", - }, - }); - const result = await bash.exec("rg data"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("text.txt:1:data\n"); - }); -}); - -describe("rg binary: with other flags", () => { - it("should work with -i flag", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/text.txt": "HELLO world\n", - "/home/user/binary.bin": "HELLO\x00world\n", - }, - }); - const result = await bash.exec("rg -i hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("text.txt:1:HELLO world\n"); - }); - - it("should work with -v flag", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/text.txt": "keep\nremove\nkeep\n", - "/home/user/binary.bin": "keep\x00remove\n", - }, - }); - const result = await bash.exec("rg -v remove"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("text.txt:1:keep\ntext.txt:3:keep\n"); - }); - - it("should work with -w flag", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/text.txt": "foo bar\nfoobar\n", - "/home/user/binary.bin": "foo bar\x00\n", - }, - }); - const result = await bash.exec("rg -w foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("text.txt:1:foo bar\n"); - }); - - it("should work with context flags", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/text.txt": "before\nmatch\nafter\n", - "/home/user/binary.bin": "before\x00match\nafter\n", - }, - }); - const result = await bash.exec("rg -C1 match"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "text.txt-1-before\ntext.txt:2:match\ntext.txt-3-after\n", - ); - }); - - it("should work with -m flag", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/text.txt": "match\nmatch\nmatch\n", - "/home/user/binary.bin": "match\x00match\n", - }, - }); - const result = await bash.exec("rg -m1 match"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("text.txt:1:match\n"); - }); -}); - -describe("rg binary: subdirectories", () => { - it("should skip binary files in subdirectories", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/src/code.ts": "export const x = 1;\n", - "/home/user/assets/image.bin": "export\x00data\n", - "/home/user/lib/util.ts": "export function foo() {}\n", - }, - }); - const result = await bash.exec("rg --sort path export"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "lib/util.ts:1:export function foo() {}\nsrc/code.ts:1:export const x = 1;\n", - ); - }); -}); - -// ============================================================================ -// SKIPPED TESTS - Features not implemented or require special handling -// ============================================================================ - -// Memory-mapped file tests - mmap not supported -it.skip("mmap_match_implicit: memory map not supported", () => {}); -it.skip("mmap_match_explicit: memory map not supported", () => {}); -it.skip("mmap_match_near_nul: memory map not supported", () => {}); -it.skip("mmap_match_count: memory map not supported", () => {}); -it.skip("mmap_match_multiple: memory map not supported", () => {}); -it.skip("mmap_binary_flag: memory map not supported", () => {}); -it.skip("mmap_text_flag: memory map not supported", () => {}); -it.skip("mmap_after_nul_match: memory map not supported", () => {}); - -// Stdin tests - requires stdin piping -it.skip("after_match1_stdin: stdin piping not supported", () => {}); - -// Binary file warning message test -it.skip("matching_files_inconsistent_with_count: complex binary filtering not implemented", () => {}); diff --git a/src/commands/rg/imported-tests/feature.test.ts b/src/commands/rg/imported-tests/feature.test.ts deleted file mode 100644 index ef9db41d..00000000 --- a/src/commands/rg/imported-tests/feature.test.ts +++ /dev/null @@ -1,1047 +0,0 @@ -/** - * Tests imported from ripgrep: tests/feature.rs - * - * These tests cover various ripgrep features from GitHub issues. - * Each test references the original issue number for traceability. - */ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../../Bash.js"; - -// Classic test fixture from ripgrep tests -const SHERLOCK = `For the Doctor Watsons of this world, as opposed to the Sherlock -Holmeses, success in the province of detective work must always -be, to a very large extent, the result of luck. Sherlock Holmes -can extract a clew from a wisp of straw or a flake of cigar ash; -but Doctor Watson has to have it taken out for him and dusted, -and exhibited clearly, with a label attached. -`; - -describe("rg feature: issue #20 - no-filename", () => { - it("should hide filename with --no-filename", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --no-filename Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toContain("sherlock:"); - expect(result.stdout).toContain("Sherlock"); - }); -}); - -describe("rg feature: issue #34 - only matching", () => { - it("should show only matching text with -o", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -o Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("sherlock:Sherlock\nsherlock:Sherlock\n"); - }); -}); - -describe("rg feature: issue #70 - smart case", () => { - it("should use smart case with -S", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -S sherlock"); - expect(result.exitCode).toBe(0); - // Smart case: lowercase pattern matches case-insensitively - expect(result.stdout).toContain("Sherlock"); - }); - - it("should be case-sensitive when pattern has uppercase", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -S Sherlock"); - expect(result.exitCode).toBe(0); - // Should only match "Sherlock" not "sherlock" - expect(result.stdout).toContain("Sherlock"); - }); -}); - -describe("rg feature: issue #89 - files with matches", () => { - it("should list files with matches with -l", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -l Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("sherlock\n"); - }); - - it("should list files without matches with --files-without-match", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - "/home/user/file.py": "foo\n", - }, - }); - const result = await bash.exec("rg --files-without-match Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.py\n"); - }); - - it("should count matches with -c", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -c Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("sherlock:2\n"); - }); - - it("should list searchable files with --files", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --files"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("sherlock\n"); - }); - - it("should list searchable files with --files and -0", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --files -0"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("sherlock\x00"); - }); - - it("should list multiple files sorted with --files", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/zebra.txt": "z\n", - "/home/user/alpha.txt": "a\n", - "/home/user/beta.txt": "b\n", - }, - }); - const result = await bash.exec("rg --files"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("alpha.txt\nbeta.txt\nzebra.txt\n"); - }); - - it("should respect type filter with --files -t", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/code.js": "js\n", - "/home/user/code.py": "py\n", - "/home/user/code.ts": "ts\n", - }, - }); - const result = await bash.exec("rg --files -t js"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("code.js\n"); - }); - - it("should respect glob filter with --files -g", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test.txt": "txt\n", - "/home/user/test.log": "log\n", - "/home/user/other.txt": "txt\n", - }, - }); - const result = await bash.exec("rg --files -g 'test.*'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test.log\ntest.txt\n"); - }); - - it("should respect max-depth with --files -d", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/top.txt": "top\n", - "/home/user/sub/deep.txt": "deep\n", - "/home/user/sub/deeper/bottom.txt": "bottom\n", - }, - }); - const result = await bash.exec("rg --files -d 2"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("sub/deep.txt\ntop.txt\n"); - }); - - it("should return exit code 1 when no files found with --files", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.hidden": "hidden\n", - }, - }); - // Hidden files are excluded by default - const result = await bash.exec("rg --files"); - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - }); - - it("should include hidden files with --files --hidden", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.hidden": "hidden\n", - "/home/user/visible": "visible\n", - }, - }); - const result = await bash.exec("rg --files --hidden"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(".hidden\nvisible\n"); - }); - - // Ported from ripgrep r64 - it("should list files in specific directory with --files dir", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/dir/abc": "content\n", - "/home/user/foo/abc": "content\n", - }, - }); - const result = await bash.exec("rg --files foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo/abc\n"); - }); - - // Ported from ripgrep r352 - it("should combine --files with --glob", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.py": "python\n", - "/home/user/file.rs": "rust\n", - "/home/user/file.txt": "text\n", - }, - }); - const result = await bash.exec("rg --files --glob '*.py'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.py\n"); - }); - - // Ported from ripgrep r444 - it("should work with --quiet --files", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.py": "python\n", - "/home/user/file.rs": "rust\n", - }, - }); - // --quiet with --files suppresses output but indicates success - const result = await bash.exec("rg --quiet --files --glob '*.py'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - }); - - // Ported from ripgrep - path prefix preservation - it("should preserve ./ prefix when given", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sub/file.txt": "content\n", - }, - }); - const result = await bash.exec("rg --files ./sub"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("./sub/file.txt\n"); - }); -}); - -describe("rg feature: issue #109 - max depth", () => { - it("should limit search depth with --max-depth", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/one/pass": "far\n", - "/home/user/one/too/many": "far\n", - }, - }); - const result = await bash.exec("rg --max-depth 2 far"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("one/pass:1:far\n"); - }); - - it("should accept -d as alias for --max-depth", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/one/pass": "far\n", - "/home/user/one/too/many": "far\n", - }, - }); - const result = await bash.exec("rg -d 2 far"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("one/pass:1:far\n"); - }); - - it("should search only current directory with -d 1", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/top.txt": "match\n", - "/home/user/sub/nested.txt": "match\n", - }, - }); - const result = await bash.exec("rg -d 1 match"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("top.txt:1:match\n"); - }); - - it("should search deeper with higher -d value", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a/b/c/deep.txt": "found\n", - }, - }); - const result = await bash.exec("rg -d 4 found"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a/b/c/deep.txt:1:found\n"); - }); - - it("should combine -d with type filter", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/top.js": "test\n", - "/home/user/sub/nested.js": "test\n", - "/home/user/top.py": "test\n", - }, - }); - const result = await bash.exec("rg -d 1 -t js test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("top.js:1:test\n"); - }); -}); - -describe("rg feature: issue #124 - case-sensitive override", () => { - it("should be case-sensitive with -s overriding smart case", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "tEsT\n", - }, - }); - const result = await bash.exec("rg -S -s test"); - expect(result.exitCode).toBe(1); // No match - case sensitive - }); - - it("should be case-sensitive with -s overriding -i", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "tEsT\n", - }, - }); - const result = await bash.exec("rg -i -s test"); - expect(result.exitCode).toBe(1); // No match - case sensitive - }); -}); - -describe("rg feature: issue #159 - max count", () => { - it("should stop after N matches with -m", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "test\ntest\n", - }, - }); - const result = await bash.exec("rg -m1 test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo:1:test\n"); - }); - - it("should treat -m0 as unlimited", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "test\ntest\n", - }, - }); - const result = await bash.exec("rg -m0 test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo:1:test\nfoo:2:test\n"); - }); -}); - -describe("rg feature: issue #948 - exit codes", () => { - it("should return exit code 0 on match", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg ."); - expect(result.exitCode).toBe(0); - }); - - it("should return exit code 1 on no match", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg NADA"); - expect(result.exitCode).toBe(1); - }); - - it("should return exit code 2 on error", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg '*'"); - expect(result.exitCode).toBe(2); - }); -}); - -describe("rg feature: issue #2288 - context partial override", () => { - it("should allow -A to override context from -C", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test": "1\n2\n3\n4\n5\n6\n7\n8\n9\n", - }, - }); - const result = await bash.exec("rg -C1 -A2 5 test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("4\n5\n6\n7\n"); - }); - - it("should allow -C to set both -A and -B", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test": "1\n2\n3\n4\n5\n6\n7\n8\n9\n", - }, - }); - const result = await bash.exec("rg -A2 -C1 5 test"); - expect(result.exitCode).toBe(0); - // -C1 sets both before and after to 1 - expect(result.stdout).toBe("4\n5\n6\n7\n"); - }); -}); - -describe("rg feature: context separator", () => { - it("should use default context separator", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test": "foo\nctx\nbar\nctx\nfoo\nctx\n", - }, - }); - const result = await bash.exec("rg -A1 foo test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo\nctx\n--\nfoo\nctx\n"); - }); -}); - -describe("rg feature: multiple patterns", () => { - it("should match multiple patterns with -e", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "foo\nbar\nbaz\n", - }, - }); - const result = await bash.exec("rg -e foo -e bar"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file:1:foo\nfile:2:bar\n"); - }); -}); - -describe("rg feature: gitignore handling", () => { - it("should respect .gitignore by default", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "ignored.txt\n", - "/home/user/visible.txt": "test\n", - "/home/user/ignored.txt": "test\n", - }, - }); - const result = await bash.exec("rg test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("visible.txt:1:test\n"); - }); - - it("should ignore .gitignore with --no-ignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "ignored.txt\n", - "/home/user/visible.txt": "test\n", - "/home/user/ignored.txt": "test\n", - }, - }); - const result = await bash.exec("rg --no-ignore --sort path test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("ignored.txt:1:test\nvisible.txt:1:test\n"); - }); -}); - -describe("rg feature: hidden files", () => { - it("should skip hidden files by default", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.hidden": "test\n", - "/home/user/visible": "test\n", - }, - }); - const result = await bash.exec("rg test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("visible:1:test\n"); - }); - - it("should include hidden files with --hidden", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.hidden": "test\n", - "/home/user/visible": "test\n", - }, - }); - const result = await bash.exec("rg --hidden --sort path test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(".hidden:1:test\nvisible:1:test\n"); - }); -}); - -describe("rg feature: type filtering", () => { - it("should filter by type with -t", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/code.js": "test\n", - "/home/user/code.py": "test\n", - "/home/user/code.rs": "test\n", - }, - }); - const result = await bash.exec("rg -t js test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("code.js:1:test\n"); - }); - - it("should exclude type with -T", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/code.js": "test\n", - "/home/user/code.py": "test\n", - }, - }); - const result = await bash.exec("rg -T js test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("code.py:1:test\n"); - }); - - it("should accept markdown as type alias", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/README.md": "test\n", - "/home/user/code.py": "test\n", - }, - }); - const result = await bash.exec("rg -t markdown test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("README.md:1:test\n"); - }); - - it("should match .markdown extension with -t markdown", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/doc.markdown": "content\n", - "/home/user/code.js": "content\n", - }, - }); - const result = await bash.exec("rg -t markdown content"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("doc.markdown:1:content\n"); - }); - - it("should match .mdown extension with -t markdown", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/notes.mdown": "info\n", - "/home/user/code.py": "info\n", - }, - }); - const result = await bash.exec("rg -t markdown info"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("notes.mdown:1:info\n"); - }); - - it("should match both md and markdown types equivalently", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/doc.md": "text\n", - "/home/user/other.txt": "text\n", - }, - }); - const mdResult = await bash.exec("rg -t md text"); - const markdownResult = await bash.exec("rg -t markdown text"); - expect(mdResult.stdout).toBe(markdownResult.stdout); - }); - - it("should exclude markdown with -T markdown", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/README.md": "test\n", - "/home/user/code.py": "test\n", - }, - }); - const result = await bash.exec("rg -T markdown test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("code.py:1:test\n"); - }); -}); - -describe("rg feature: glob filtering", () => { - it("should filter by glob with -g", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "test\n", - "/home/user/file.log": "test\n", - }, - }); - const result = await bash.exec("rg -g '*.txt' test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:test\n"); - }); - - it("should negate glob with -g !", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "test\n", - "/home/user/file.log": "test\n", - }, - }); - const result = await bash.exec("rg -g '!*.log' test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:test\n"); - }); -}); - -describe("rg feature: word and line matching", () => { - it("should match whole words with -w", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "foo foobar barfoo\n", - }, - }); - const result = await bash.exec("rg -w foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file:1:foo foobar barfoo\n"); - }); - - it("should not match partial words with -w", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "foobar\n", - }, - }); - const result = await bash.exec("rg -w foo"); - expect(result.exitCode).toBe(1); - }); - - it("should match whole lines with -x", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "foo\nfoo bar\n", - }, - }); - const result = await bash.exec("rg -x foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file:1:foo\n"); - }); -}); - -describe("rg feature: inverted match", () => { - it("should invert match with -v", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "foo\nbar\nbaz\n", - }, - }); - const result = await bash.exec("rg -v foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file:2:bar\nfile:3:baz\n"); - }); -}); - -describe("rg feature: fixed strings", () => { - it("should treat pattern as literal with -F", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "foo.*bar\nfoobar\n", - }, - }); - const result = await bash.exec("rg -F 'foo.*bar'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file:1:foo.*bar\n"); - }); -}); - -describe("rg feature: quiet mode", () => { - it("should suppress output with -q on match", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "test\n", - }, - }); - const result = await bash.exec("rg -q test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - }); - - it("should return exit code 1 with -q on no match", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "test\n", - }, - }); - const result = await bash.exec("rg -q notfound"); - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - }); -}); - -describe("rg feature: line numbers", () => { - it("should show line numbers with -n", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "foo\ntest\nbar\n", - }, - }); - const result = await bash.exec("rg -n test file"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("2:test\n"); - }); - - it("should hide line numbers with -N", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "test\n", - }, - }); - const result = await bash.exec("rg -N test file"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test\n"); - }); -}); - -describe("rg feature: context lines", () => { - it("should show after context with -A", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "a\nmatch\nb\nc\n", - }, - }); - const result = await bash.exec("rg -A2 match file"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("match\nb\nc\n"); - }); - - it("should show before context with -B", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "a\nb\nmatch\nc\n", - }, - }); - const result = await bash.exec("rg -B2 match file"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a\nb\nmatch\n"); - }); - - it("should show both context with -C", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "a\nb\nmatch\nc\nd\n", - }, - }); - const result = await bash.exec("rg -C1 match file"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("b\nmatch\nc\n"); - }); -}); - -describe("rg feature: combined flags", () => { - it("should combine -i and -w", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "FOO foobar\n", - }, - }); - const result = await bash.exec("rg -iw foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file:1:FOO foobar\n"); - }); - - it("should combine -c and -i", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "foo\nFOO\nFoo\n", - }, - }); - const result = await bash.exec("rg -ci foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file:3\n"); - }); - - it("should combine -l and -i", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a.txt": "FOO\n", - "/home/user/b.txt": "bar\n", - }, - }); - const result = await bash.exec("rg -li foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a.txt\n"); - }); -}); - -describe("rg feature: --stats", () => { - it("should output search statistics with --stats", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "foo\nbar\nfoo\n", - }, - }); - const result = await bash.exec("rg --stats foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("2 matches"); - expect(result.stdout).toContain("1 files contained matches"); - expect(result.stdout).toContain("1 files searched"); - }); - - it("should show stats for multiple files", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a.txt": "foo\n", - "/home/user/b.txt": "foo\nfoo\n", - "/home/user/c.txt": "bar\n", - }, - }); - const result = await bash.exec("rg --stats foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("3 matches"); - expect(result.stdout).toContain("2 files contained matches"); - expect(result.stdout).toContain("3 files searched"); - }); - - it("should show stats even with no matches", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "hello\n", - }, - }); - const result = await bash.exec("rg --stats notfound"); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("0 matches"); - expect(result.stdout).toContain("0 files contained matches"); - expect(result.stdout).toContain("1 files searched"); - }); - - it("should include search results before stats", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "test\n", - }, - }); - const result = await bash.exec("rg --stats test"); - expect(result.exitCode).toBe(0); - // Results should appear before stats - expect(result.stdout).toMatch(/file:1:test[\s\S]*1 matches/); - }); - - it("should show bytes searched in stats", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "test content here\n", - }, - }); - const result = await bash.exec("rg --stats test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("bytes searched"); - }); -}); - -describe("rg feature: PCRE2 not supported", () => { - it("should error on -P flag", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "test\n", - }, - }); - const result = await bash.exec("rg -P test"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toBe( - "rg: PCRE2 is not supported. Use standard regex syntax instead.\n", - ); - }); - - it("should error on --pcre2 flag", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "test\n", - }, - }); - const result = await bash.exec("rg --pcre2 test"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toBe( - "rg: PCRE2 is not supported. Use standard regex syntax instead.\n", - ); - }); -}); - -// ============================================================================ -// SKIPPED TESTS - Features not implemented -// ============================================================================ - -// f1_* tests - Encoding support (Shift-JIS, UTF-16, EUC-JP) -it.skip("f1_sjis: Shift-JIS encoding not supported", () => {}); -it.skip("f1_utf16_auto: UTF-16 auto-detection not supported", () => {}); -it.skip("f1_utf16_explicit: UTF-16 explicit encoding not supported", () => {}); -it.skip("f1_eucjp: EUC-JP encoding not supported", () => {}); -it.skip("f1_unknown_encoding: -E flag not supported", () => {}); -it.skip("f1_replacement_encoding: encoding replacement not supported", () => {}); - -// f7_* tests - Pattern file with stdin -describe("rg feature: f7_stdin", () => { - it("should read patterns from stdin with -f-", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test.txt": "foo\nbar\nbaz\n", - }, - }); - // Simulate stdin containing the pattern - const result = await bash.exec("echo 'bar' | rg -f- test.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("bar\n"); - }); -}); - -// f45_* tests - --ignore-file flag -describe("rg feature: f45_ignore_file", () => { - it("f45_relative_cwd: should apply patterns from --ignore-file", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/my-ignore": "ignored.txt\n", - "/home/user/ignored.txt": "test content\n", - "/home/user/included.txt": "test content\n", - }, - }); - const result = await bash.exec("rg --ignore-file my-ignore test"); - expect(result.exitCode).toBe(0); - // Should only find included.txt, not ignored.txt - expect(result.stdout).toContain("included.txt"); - expect(result.stdout).not.toContain("ignored.txt"); - }); - - it("f45_precedence_with_others: --ignore-file patterns applied", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/custom-ignore": "*.log\n", - "/home/user/test.txt": "test\n", - "/home/user/test.log": "test\n", - }, - }); - const result = await bash.exec("rg --ignore-file custom-ignore test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("test.txt"); - expect(result.stdout).not.toContain("test.log"); - }); -}); - -// f68 - --no-ignore-vcs -describe("rg feature: f68_no_ignore_vcs", () => { - it("should skip .gitignore with --no-ignore-vcs", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "ignored.txt\n", - "/home/user/ignored.txt": "Sherlock\n", - "/home/user/visible.txt": "Sherlock\n", - }, - }); - // Without --no-ignore-vcs, ignored.txt should be excluded - const result1 = await bash.exec("rg Sherlock"); - expect(result1.exitCode).toBe(0); - expect(result1.stdout).toBe("visible.txt:1:Sherlock\n"); - - // With --no-ignore-vcs, .gitignore is skipped, so ignored.txt is searched - const result2 = await bash.exec("rg --no-ignore-vcs Sherlock"); - expect(result2.exitCode).toBe(0); - expect(result2.stdout).toContain("ignored.txt"); - expect(result2.stdout).toContain("visible.txt"); - }); -}); - -// f129 - Max columns -it.skip("f129_matches: -M max columns not implemented", () => {}); diff --git a/src/commands/rg/imported-tests/json.test.ts b/src/commands/rg/imported-tests/json.test.ts deleted file mode 100644 index 745ecec1..00000000 --- a/src/commands/rg/imported-tests/json.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * JSON output tests imported from ripgrep - * - * Source: https://github.com/BurntSushi/ripgrep/blob/master/tests/json.rs - * - * Not implemented (tests not imported): - * - notutf8, notutf8_file: Non-UTF8 file handling not supported - * - crlf, r1095_missing_crlf, r1095_crlf_empty_match: --crlf flag not implemented - * - r1412_look_behind_match_missing: Requires PCRE2 look-behind - */ - -import { describe, expect, it } from "vitest"; -import { Bash } from "../../../Bash.js"; - -const SHERLOCK = `For the Doctor Watsons of this world, as opposed to the Sherlock -Holmeses, success in the province of detective work must always -be, to a very large extent, the result of luck. Sherlock Holmes -can extract a clew from a wisp of straw or a flake of cigar ash; -but Doctor Watson has to have it taken out for him and dusted, -and exhibited clearly, with a label attached. -`; - -interface JsonMessage { - type: "begin" | "end" | "match" | "context" | "summary"; - data: Record; -} - -function parseJsonLines(output: string): JsonMessage[] { - return output - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as JsonMessage); -} - -describe("rg json: basic", () => { - it("basic: JSON output structure", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --json 'Sherlock Holmes' sherlock"); - expect(result.exitCode).toBe(0); - - const msgs = parseJsonLines(result.stdout); - - // Check begin message - expect(msgs[0].type).toBe("begin"); - expect(msgs[0].data.path).toEqual({ text: "sherlock" }); - - // Check match message - expect(msgs[1].type).toBe("match"); - expect(msgs[1].data.path).toEqual({ text: "sherlock" }); - expect(msgs[1].data.lines).toEqual({ - text: "be, to a very large extent, the result of luck. Sherlock Holmes\n", - }); - expect(msgs[1].data.line_number).toBe(3); - expect(msgs[1].data.absolute_offset).toBe(129); - const submatches = msgs[1].data.submatches as Array<{ - match: { text: string }; - start: number; - end: number; - }>; - expect(submatches.length).toBe(1); - expect(submatches[0].match).toEqual({ text: "Sherlock Holmes" }); - expect(submatches[0].start).toBe(48); - expect(submatches[0].end).toBe(63); - - // Check end message - expect(msgs[2].type).toBe("end"); - expect(msgs[2].data.path).toEqual({ text: "sherlock" }); - expect(msgs[2].data.binary_offset).toBeNull(); - - // Check summary message - expect(msgs[3].type).toBe("summary"); - const stats = msgs[3].data.stats as { searches_with_match: number }; - expect(stats.searches_with_match).toBe(1); - }); - - it("replacement: JSON output with replacement text", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec( - "rg --json 'Sherlock Holmes' -r 'John Watson' sherlock", - ); - expect(result.exitCode).toBe(0); - - const msgs = parseJsonLines(result.stdout); - expect(msgs[1].type).toBe("match"); - const submatches = msgs[1].data.submatches as Array<{ - match: { text: string }; - replacement: { text: string }; - }>; - expect(submatches[0].replacement).toEqual({ text: "John Watson" }); - }); - - it("quiet_stats: JSON with --quiet shows only summary", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec( - "rg --json --quiet 'Sherlock Holmes' sherlock", - ); - expect(result.exitCode).toBe(0); - // ripgrep behavior: --quiet --json outputs only the summary - const msgs = parseJsonLines(result.stdout); - expect(msgs.length).toBe(1); - expect(msgs[0].type).toBe("summary"); - const stats = msgs[0].data.stats as { searches_with_match: number }; - expect(stats.searches_with_match).toBe(1); - }); -}); - -describe("rg json: multiple matches", () => { - it("should output all matches with correct submatches", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test.txt": "foo bar foo baz foo\n", - }, - }); - const result = await bash.exec("rg --json foo test.txt"); - expect(result.exitCode).toBe(0); - - const msgs = parseJsonLines(result.stdout); - const match = msgs.find((m) => m.type === "match"); - expect(match).toBeDefined(); - - const submatches = match?.data.submatches as Array<{ - match: { text: string }; - start: number; - end: number; - }>; - expect(submatches.length).toBe(3); - expect(submatches[0]).toEqual({ match: { text: "foo" }, start: 0, end: 3 }); - expect(submatches[1]).toEqual({ - match: { text: "foo" }, - start: 8, - end: 11, - }); - expect(submatches[2]).toEqual({ - match: { text: "foo" }, - start: 16, - end: 19, - }); - }); - - it("should output multiple files with begin/end messages", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a.txt": "hello\n", - "/home/user/b.txt": "hello\n", - }, - }); - const result = await bash.exec("rg --json hello"); - expect(result.exitCode).toBe(0); - - const msgs = parseJsonLines(result.stdout); - - // Should have begin/match/end for each file plus summary - const begins = msgs.filter((m) => m.type === "begin"); - const ends = msgs.filter((m) => m.type === "end"); - const matches = msgs.filter((m) => m.type === "match"); - const summaries = msgs.filter((m) => m.type === "summary"); - - expect(begins.length).toBe(2); - expect(ends.length).toBe(2); - expect(matches.length).toBe(2); - expect(summaries.length).toBe(1); - }); -}); - -// ============================================================================ -// SKIPPED TESTS - Features not implemented -// ============================================================================ -it.skip("r1412_look_behind_match_missing: PCRE2 look-behind not supported", () => {}); - -describe("rg json: edge cases", () => { - it("should output summary even with no matches", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test.txt": "hello world\n", - }, - }); - const result = await bash.exec("rg --json notfound"); - expect(result.exitCode).toBe(1); - - // Always outputs summary with stats - const msgs = parseJsonLines(result.stdout); - const summary = msgs.find((m) => m.type === "summary"); - expect(summary).toBeDefined(); - const stats = summary?.data.stats as { searches_with_match: number }; - expect(stats.searches_with_match).toBe(0); - }); - - it("should handle empty file with summary", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/empty.txt": "", - }, - }); - const result = await bash.exec("rg --json foo empty.txt"); - expect(result.exitCode).toBe(1); - - const msgs = parseJsonLines(result.stdout); - const summary = msgs.find((m) => m.type === "summary"); - expect(summary).toBeDefined(); - const stats = summary?.data.stats as { - searches_with_match: number; - bytes_searched: number; - }; - expect(stats.searches_with_match).toBe(0); - expect(stats.bytes_searched).toBe(0); - }); -}); diff --git a/src/commands/rg/imported-tests/misc.test.ts b/src/commands/rg/imported-tests/misc.test.ts deleted file mode 100644 index 9b586268..00000000 --- a/src/commands/rg/imported-tests/misc.test.ts +++ /dev/null @@ -1,1201 +0,0 @@ -/** - * Tests imported from ripgrep: tests/misc.rs - * - * Total: 93 tests (matching ripgrep test count) - * Miscellaneous tests for various ripgrep behaviors. - */ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../../Bash.js"; - -// Classic test fixture from ripgrep tests -const SHERLOCK = `For the Doctor Watsons of this world, as opposed to the Sherlock -Holmeses, success in the province of detective work must always -be, to a very large extent, the result of luck. Sherlock Holmes -can extract a clew from a wisp of straw or a flake of cigar ash; -but Doctor Watson has to have it taken out for him and dusted, -and exhibited clearly, with a label attached. -`; - -// 1. single_file -describe("rg misc: single_file", () => { - it("should search single file without filename prefix", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "For the Doctor Watsons of this world, as opposed to the Sherlock\nbe, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -// 2. dir -describe("rg misc: dir", () => { - it("should search directory with filename prefix", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "sherlock:1:For the Doctor Watsons of this world, as opposed to the Sherlock\nsherlock:3:be, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -// 3. line_numbers -describe("rg misc: line_numbers", () => { - it("should show line numbers with -n", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -n Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "1:For the Doctor Watsons of this world, as opposed to the Sherlock\n3:be, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -// 4. columns -describe("rg misc: columns", () => { - it("should show column numbers with --column", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --column Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "1:57:For the Doctor Watsons of this world, as opposed to the Sherlock\n3:49:be, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -// 5. with_filename - -H forces filename display even for single file -describe("rg misc: with_filename", () => { - it("should show filename with -H even for single file", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -H Sherlock sherlock"); - expect(result.exitCode).toBe(0); - // -H forces filename prefix even for single file - expect(result.stdout).toBe( - "sherlock:For the Doctor Watsons of this world, as opposed to the Sherlock\nsherlock:be, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -// 6. with_heading -describe("rg misc: with_heading", () => { - it("should show heading format", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --heading Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "sherlock\nFor the Doctor Watsons of this world, as opposed to the Sherlock\nbe, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -// 7. with_heading_default - SKIP: requires -j1 flag -it.skip("with_heading_default: requires -j1 flag", () => {}); - -// 8. inverted -describe("rg misc: inverted", () => { - it("should invert match with -v", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -v Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "Holmeses, success in the province of detective work must always\ncan extract a clew from a wisp of straw or a flake of cigar ash;\nbut Doctor Watson has to have it taken out for him and dusted,\nand exhibited clearly, with a label attached.\n", - ); - }); -}); - -// 9. inverted_line_numbers -describe("rg misc: inverted_line_numbers", () => { - it("should show line numbers with inverted match", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -n -v Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "2:Holmeses, success in the province of detective work must always\n4:can extract a clew from a wisp of straw or a flake of cigar ash;\n5:but Doctor Watson has to have it taken out for him and dusted,\n6:and exhibited clearly, with a label attached.\n", - ); - }); -}); - -// 10. case_insensitive -describe("rg misc: case_insensitive", () => { - it("should search case-insensitively with -i", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -i sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "For the Doctor Watsons of this world, as opposed to the Sherlock\nbe, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -// 11. word -describe("rg misc: word", () => { - it("should match whole words with -w", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -w as sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "For the Doctor Watsons of this world, as opposed to the Sherlock\n", - ); - }); -}); - -// 12. word_period -describe("rg misc: word_period", () => { - // Skipped: RE2 uses \b for word boundaries, which requires word characters - // Non-word chars like '.' don't have word boundaries around them in RE2 - it.skip("should handle period as word with -ow", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/haystack": "...\n", - }, - }); - const result = await bash.exec("rg -ow '.' haystack"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(".\n.\n.\n"); - }); -}); - -// 13. line -describe("rg misc: line", () => { - it("should match whole lines with -x", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec( - "rg -x 'Watson|and exhibited clearly, with a label attached.' sherlock", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "and exhibited clearly, with a label attached.\n", - ); - }); -}); - -// 14. literal -describe("rg misc: literal", () => { - it("should match literal strings with -F", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "blib\n()\nblab\n", - }, - }); - const result = await bash.exec("rg -F '()' file"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("()\n"); - }); -}); - -// 15. quiet -describe("rg misc: quiet", () => { - it("should suppress output with -q", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -q Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - }); -}); - -// 16. replace -describe("rg misc: replace", () => { - it("should replace matches with -r", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -r FooBar Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "For the Doctor Watsons of this world, as opposed to the FooBar\nbe, to a very large extent, the result of luck. FooBar Holmes\n", - ); - }); -}); - -// 17. replace_groups -describe("rg misc: replace_groups", () => { - it("should replace with capture groups", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec( - `rg -r '$2, $1' '([A-Z][a-z]+) ([A-Z][a-z]+)' sherlock`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "For the Watsons, Doctor of this world, as opposed to the Sherlock\nbe, to a very large extent, the result of luck. Holmes, Sherlock\nbut Watson, Doctor has to have it taken out for him and dusted,\n", - ); - }); -}); - -// 18. replace_named_groups -describe("rg misc: replace_named_groups", () => { - it("should replace with named capture groups", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec( - `rg -r '$last, $first' '(?P[A-Z][a-z]+) (?P[A-Z][a-z]+)' sherlock`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "For the Watsons, Doctor of this world, as opposed to the Sherlock\nbe, to a very large extent, the result of luck. Holmes, Sherlock\nbut Watson, Doctor has to have it taken out for him and dusted,\n", - ); - }); -}); - -// 19. replace_with_only_matching -describe("rg misc: replace_with_only_matching", () => { - it("should replace only matching parts", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec(`rg -o -r '$1' 'of (\\w+)' sherlock`); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("this\ndetective\nluck\nstraw\ncigar\n"); - }); -}); - -// 20. file_types -describe("rg misc: file_types", () => { - it("should filter by type with -t", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - "/home/user/file.py": "Sherlock\n", - "/home/user/file.rs": "Sherlock\n", - }, - }); - const result = await bash.exec("rg -t rust Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.rs:1:Sherlock\n"); - }); -}); - -// 21. file_types_all -describe("rg misc: file_types_all", () => { - it("should filter type 'all' (only typed files)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - "/home/user/file.py": "Sherlock\n", - }, - }); - const result = await bash.exec("rg -t all Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.py:1:Sherlock\n"); - }); -}); - -// 22. file_types_negate -describe("rg misc: file_types_negate", () => { - it("should negate type with -T", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.py": "Sherlock\n", - "/home/user/file.rs": "Sherlock\n", - }, - }); - const result = await bash.exec("rg -T rust Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.py:1:Sherlock\n"); - }); -}); - -// 23. file_types_negate_all -describe("rg misc: file_types_negate_all", () => { - it("should negate type 'all' (only untyped files)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - "/home/user/file.py": "Sherlock\n", - }, - }); - const result = await bash.exec("rg -T all Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "sherlock:1:For the Doctor Watsons of this world, as opposed to the Sherlock\nsherlock:3:be, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -// 24. file_type_clear -describe("rg misc: file_type_clear", () => { - it("should clear type patterns with --type-clear", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.py": "test\n", - "/home/user/file.rs": "test\n", - }, - }); - // Clear py type, then search for it - should find nothing - const result = await bash.exec("rg --type-clear py -t py test"); - expect(result.exitCode).toBe(1); // No matches since py type is empty - expect(result.stdout).toBe(""); - }); -}); - -// 25. file_type_add -describe("rg misc: file_type_add", () => { - it("should add type patterns with --type-add", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.foo": "test\n", - "/home/user/file.bar": "test\n", - }, - }); - // Add new type 'custom' for .foo files - const result = await bash.exec( - "rg --type-add 'custom:*.foo' -t custom test", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.foo:1:test\n"); - }); -}); - -// 26. file_type_add_compose -describe("rg misc: file_type_add_compose", () => { - it("should compose types with include", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.js": "test\n", - "/home/user/file.ts": "test\n", - "/home/user/file.py": "test\n", - }, - }); - // Create 'web' type that includes js type patterns - const result = await bash.exec( - "rg --type-add 'web:include:js' -t web test", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.js:1:test\n"); - }); -}); - -// 26b. preprocessing (--pre, --pre-glob) -describe("rg misc: preprocessing", () => { - it("should preprocess files with --pre", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "original content\n", - }, - }); - // Create a preprocessor that transforms content - // Since we need a command, let's use a simple echo-based transform - const result = await bash.exec(`rg --pre 'cat' test file.txt`); - // The cat preprocessor just outputs the file, so we won't find 'test' - expect(result.exitCode).toBe(1); - }); - - it("should apply --pre-glob to limit preprocessing", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello world\n", - "/home/user/file.dat": "hello world\n", - }, - }); - // Use --pre-glob to only preprocess .dat files - const result = await bash.exec("rg --pre 'cat' --pre-glob '*.dat' hello"); - expect(result.exitCode).toBe(0); - // Both files should be searched (preprocessing doesn't change content with cat) - expect(result.stdout).toContain("hello world"); - }); -}); - -// 27. glob -describe("rg misc: glob", () => { - it("should filter by glob with -g", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - "/home/user/file.py": "Sherlock\n", - "/home/user/file.rs": "Sherlock\n", - }, - }); - const result = await bash.exec("rg -g '*.rs' Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.rs:1:Sherlock\n"); - }); -}); - -// 28. glob_negate -describe("rg misc: glob_negate", () => { - it("should negate glob with -g !", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.py": "Sherlock\n", - "/home/user/file.rs": "Sherlock\n", - }, - }); - const result = await bash.exec("rg -g '!*.rs' Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.py:1:Sherlock\n"); - }); -}); - -// 29. glob_case_insensitive -describe("rg misc: glob_case_insensitive", () => { - it("should use case-insensitive glob matching with --iglob", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file1.HTML": "Sherlock\n", - "/home/user/file2.html": "Sherlock\n", - }, - }); - const result = await bash.exec("rg --iglob '*.html' Sherlock"); - expect(result.exitCode).toBe(0); - // Both files should match since iglob is case-insensitive - expect(result.stdout).toBe( - "file1.HTML:1:Sherlock\nfile2.html:1:Sherlock\n", - ); - }); -}); - -// 30. glob_case_sensitive -describe("rg misc: glob_case_sensitive", () => { - it("should use case-sensitive glob matching", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file1.HTML": "Sherlock\n", - "/home/user/file2.html": "Sherlock\n", - }, - }); - const result = await bash.exec("rg --glob '*.html' Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file2.html:1:Sherlock\n"); - }); -}); - -// 31. glob_always_case_insensitive -describe("rg misc: glob_always_case_insensitive", () => { - it("should make all globs case-insensitive with --glob-case-insensitive", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file1.HTML": "Sherlock\n", - "/home/user/file2.html": "Sherlock\n", - }, - }); - const result = await bash.exec( - "rg --glob-case-insensitive --glob '*.html' Sherlock", - ); - expect(result.exitCode).toBe(0); - // Both files should match - expect(result.stdout).toBe( - "file1.HTML:1:Sherlock\nfile2.html:1:Sherlock\n", - ); - }); -}); - -// 32. byte_offset_only_matching -describe("rg misc: byte_offset_only_matching", () => { - it("should show byte offset with -b -o", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -b -o Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("sherlock:56:Sherlock\nsherlock:177:Sherlock\n"); - }); -}); - -// 33. count -describe("rg misc: count", () => { - it("should count matching lines with --count", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --count Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("sherlock:2\n"); - }); -}); - -// 34. count_matches -describe("rg misc: count_matches", () => { - it("should count all matches with --count-matches", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --count-matches the"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("sherlock:4\n"); - }); -}); - -// 35. count_matches_inverted -describe("rg misc: count_matches_inverted", () => { - it("should count inverted matches", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec( - "rg --count-matches --invert-match Sherlock", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("sherlock:4\n"); - }); -}); - -// 36. count_matches_via_only -describe("rg misc: count_matches_via_only", () => { - it("should count via --count --only-matching", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --count --only-matching the"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("sherlock:4\n"); - }); -}); - -// 37. include_zero -describe("rg misc: include_zero", () => { - it("should include files with 0 matches with --include-zero", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --count --include-zero nada"); - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe("sherlock:0\n"); - }); -}); - -// 38. include_zero_override - SKIP: --no-include-zero not implemented -it.skip("include_zero_override: --no-include-zero not implemented", () => {}); - -// 39. files_with_matches -describe("rg misc: files_with_matches", () => { - it("should list files with --files-with-matches", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --files-with-matches Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("sherlock\n"); - }); -}); - -// 40. files_without_match -describe("rg misc: files_without_match", () => { - it("should list files without match", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - "/home/user/file.py": "foo\n", - }, - }); - const result = await bash.exec("rg --files-without-match Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.py\n"); - }); -}); - -// 41. after_context -describe("rg misc: after_context", () => { - it("should show after context with -A", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -A 1 Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "For the Doctor Watsons of this world, as opposed to the Sherlock\nHolmeses, success in the province of detective work must always\nbe, to a very large extent, the result of luck. Sherlock Holmes\ncan extract a clew from a wisp of straw or a flake of cigar ash;\n", - ); - }); -}); - -// 42. after_context_line_numbers -describe("rg misc: after_context_line_numbers", () => { - it("should show after context with line numbers", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -A 1 -n Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "1:For the Doctor Watsons of this world, as opposed to the Sherlock\n2-Holmeses, success in the province of detective work must always\n3:be, to a very large extent, the result of luck. Sherlock Holmes\n4-can extract a clew from a wisp of straw or a flake of cigar ash;\n", - ); - }); -}); - -// 43. before_context -describe("rg misc: before_context", () => { - it("should show before context with -B", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -B 1 Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "For the Doctor Watsons of this world, as opposed to the Sherlock\nHolmeses, success in the province of detective work must always\nbe, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -// 44. before_context_line_numbers -describe("rg misc: before_context_line_numbers", () => { - it("should show before context with line numbers", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -B 1 -n Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "1:For the Doctor Watsons of this world, as opposed to the Sherlock\n2-Holmeses, success in the province of detective work must always\n3:be, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -// 45. context -describe("rg misc: context", () => { - it("should show context with -C", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -C 1 'world|attached' sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "For the Doctor Watsons of this world, as opposed to the Sherlock\nHolmeses, success in the province of detective work must always\n--\nbut Doctor Watson has to have it taken out for him and dusted,\nand exhibited clearly, with a label attached.\n", - ); - }); -}); - -// 46. context_line_numbers -describe("rg misc: context_line_numbers", () => { - it("should show context with line numbers", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -C 1 -n 'world|attached' sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "1:For the Doctor Watsons of this world, as opposed to the Sherlock\n2-Holmeses, success in the province of detective work must always\n--\n5-but Doctor Watson has to have it taken out for him and dusted,\n6:and exhibited clearly, with a label attached.\n", - ); - }); -}); - -// 47-52. max_filesize_* -describe("rg misc: max_filesize", () => { - it("should filter files by size with --max-filesize", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/small.txt": "Sherlock\n", // 9 bytes - "/home/user/large.txt": `Sherlock ${"x".repeat(100)}\n`, // > 100 bytes - }, - }); - // Only small file should match - const result = await bash.exec("rg --max-filesize 50 Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("small.txt:1:Sherlock\n"); - }); - - it("should accept K suffix for kilobytes", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test.txt": "Sherlock\n", - }, - }); - const result = await bash.exec("rg --max-filesize 1K Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test.txt:1:Sherlock\n"); - }); - - it("should accept M suffix for megabytes", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test.txt": "Sherlock\n", - }, - }); - const result = await bash.exec("rg --max-filesize 1M Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test.txt:1:Sherlock\n"); - }); -}); - -// 53. ignore_hidden -describe("rg misc: ignore_hidden", () => { - it("should ignore hidden files by default", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg Sherlock"); - expect(result.exitCode).toBe(1); - }); -}); - -// 54. no_ignore_hidden -describe("rg misc: no_ignore_hidden", () => { - it("should include hidden files with --hidden", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --hidden Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - ".sherlock:1:For the Doctor Watsons of this world, as opposed to the Sherlock\n.sherlock:3:be, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -// 55. ignore_git -describe("rg misc: ignore_git", () => { - it("should respect .gitignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.git/.gitkeep": "", - "/home/user/sherlock": SHERLOCK, - "/home/user/.gitignore": "sherlock\n", - }, - }); - const result = await bash.exec("rg Sherlock"); - expect(result.exitCode).toBe(1); - }); -}); - -// 56. ignore_generic -describe("rg misc: ignore_generic", () => { - it("should respect .ignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - "/home/user/.ignore": "sherlock\n", - }, - }); - const result = await bash.exec("rg Sherlock"); - expect(result.exitCode).toBe(1); - }); -}); - -// 57. ignore_ripgrep -describe("rg misc: ignore_ripgrep", () => { - it("should respect .rgignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - "/home/user/.rgignore": "sherlock\n", - }, - }); - const result = await bash.exec("rg Sherlock"); - expect(result.exitCode).toBe(1); - }); -}); - -// 58. no_ignore -describe("rg misc: no_ignore", () => { - it("should ignore .gitignore with --no-ignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - "/home/user/.gitignore": "sherlock\n", - }, - }); - const result = await bash.exec("rg --no-ignore Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "sherlock:1:For the Doctor Watsons of this world, as opposed to the Sherlock\nsherlock:3:be, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -// 59-63. Parent ignore tests - SKIP: require cwd manipulation -it.skip("ignore_git_parent: requires cwd manipulation", () => {}); -it.skip("ignore_git_parent_stop: requires cwd manipulation", () => {}); -it.skip("ignore_git_parent_stop_file: requires cwd manipulation", () => {}); -it.skip("ignore_ripgrep_parent_no_stop: requires cwd manipulation", () => {}); -it.skip("no_parent_ignore_git: requires cwd manipulation", () => {}); - -// 64-65. Symlink tests -describe("rg misc: symlink_nofollow", () => { - it("should not follow file symlinks during traversal by default", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/searchdir/real.txt": "test content\n", - }, - }); - // Create a symlink to a file inside the search directory - await bash.exec("ln -s real.txt /home/user/searchdir/link.txt"); - // Without -L, should only find via real file, not symlink - const result = await bash.exec("rg test searchdir"); - expect(result.exitCode).toBe(0); - // Only the real file should be searched - expect(result.stdout).toContain("searchdir/real.txt:"); - expect(result.stdout).not.toContain("link.txt"); - }); -}); - -describe("rg misc: symlink_follow", () => { - it("should follow file symlinks during traversal with -L", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/searchdir/real.txt": "test content\n", - }, - }); - // Create a symlink to a file inside the search directory - await bash.exec("ln -s real.txt /home/user/searchdir/link.txt"); - // With -L, should find via both real file and symlink - const result = await bash.exec("rg -L test searchdir"); - expect(result.exitCode).toBe(0); - // Both files should be searched - expect(result.stdout).toContain("searchdir/real.txt:"); - expect(result.stdout).toContain("searchdir/link.txt:"); - expect(result.stdout).toContain("test content"); - }); -}); - -// 66. unrestricted1 -describe("rg misc: unrestricted1", () => { - it("should ignore .gitignore with -u", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - "/home/user/.gitignore": "sherlock\n", - }, - }); - const result = await bash.exec("rg -u Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "sherlock:1:For the Doctor Watsons of this world, as opposed to the Sherlock\nsherlock:3:be, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -// 67. unrestricted2 -describe("rg misc: unrestricted2", () => { - it("should include hidden files with -uu", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -uu Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - ".sherlock:1:For the Doctor Watsons of this world, as opposed to the Sherlock\n.sherlock:3:be, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -// 68. unrestricted3 -describe("rg misc: unrestricted3", () => { - it("should search binary files with -uuu", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - "/home/user/hay": "foo\x00bar\nfoo\x00baz\n", - }, - }); - const result = await bash.exec("rg -uuu foo"); - expect(result.exitCode).toBe(0); - // Binary file message - expect(result.stdout).toContain("hay:"); - }); -}); - -// 69. vimgrep -describe("rg misc: vimgrep", () => { - it("should show vimgrep format", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --vimgrep 'Sherlock|Watson'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "sherlock:1:16:For the Doctor Watsons of this world, as opposed to the Sherlock\nsherlock:1:57:For the Doctor Watsons of this world, as opposed to the Sherlock\nsherlock:3:49:be, to a very large extent, the result of luck. Sherlock Holmes\nsherlock:5:12:but Doctor Watson has to have it taken out for him and dusted,\n", - ); - }); -}); - -// 70. vimgrep_no_line -describe("rg misc: vimgrep_no_line", () => { - it("should show vimgrep format without line numbers", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --vimgrep -N 'Sherlock|Watson'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "sherlock:16:For the Doctor Watsons of this world, as opposed to the Sherlock\nsherlock:57:For the Doctor Watsons of this world, as opposed to the Sherlock\nsherlock:49:be, to a very large extent, the result of luck. Sherlock Holmes\nsherlock:12:but Doctor Watson has to have it taken out for him and dusted,\n", - ); - }); -}); - -// 71. vimgrep_no_line_no_column - SKIP: --no-column not implemented -it.skip("vimgrep_no_line_no_column: --no-column not implemented", () => {}); - -// 72-73. preprocessing - SKIP: --pre not implemented -it.skip("preprocessing: --pre not implemented", () => {}); -it.skip("preprocessing_glob: --pre-glob not implemented", () => {}); - -// 74. compressed_gzip -describe("rg misc: compressed_gzip", () => { - const { gzipSync } = require("node:zlib"); - it("should search gzip files with -z", async () => { - const compressed = gzipSync(Buffer.from(SHERLOCK)); - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock.gz": compressed, - }, - }); - const result = await bash.exec("rg -z Sherlock sherlock.gz"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "For the Doctor Watsons of this world, as opposed to the Sherlock\nbe, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -// 75-82. Other compression formats - SKIP: only gzip supported -it.skip("compressed_bzip2: bzip2 not supported", () => {}); -it.skip("compressed_xz: xz not supported", () => {}); -it.skip("compressed_lz4: lz4 not supported", () => {}); -it.skip("compressed_lzma: lzma not supported", () => {}); -it.skip("compressed_brotli: brotli not supported", () => {}); -it.skip("compressed_zstd: zstd not supported", () => {}); -it.skip("compressed_uncompress: compress not supported", () => {}); -it.skip("compressed_failing_gzip: invalid gzip handling not implemented", () => {}); - -// 83. binary_convert -describe("rg misc: binary_convert", () => { - it.skip("should detect binary files", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "foo\x00bar\nfoo\x00baz\n", - }, - }); - const result = await bash.exec("rg foo file"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - 'binary file matches (found "\\0" byte around offset 3)\n', - ); - }); -}); - -// 84-85. mmap tests - SKIP: mmap not relevant -it.skip("binary_convert_mmap: mmap not relevant", () => {}); -it.skip("binary_search_mmap: mmap not relevant", () => {}); - -// 86. binary_quit - SKIP: -g flag with binary handling -it.skip("binary_quit: binary quit behavior not implemented", () => {}); - -// 87. binary_quit_mmap - SKIP: mmap not relevant -it.skip("binary_quit_mmap: mmap not relevant", () => {}); - -// 88. binary_search_no_mmap -describe("rg misc: binary_search_no_mmap", () => { - it("should search binary files with -a", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "foo\x00bar\nfoo\x00baz\n", - }, - }); - const result = await bash.exec("rg -a foo file"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo\x00bar\nfoo\x00baz\n"); - }); -}); - -// 89. files -describe("rg misc: files", () => { - it("should list files with --files", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "", - "/home/user/dir/file": "", - }, - }); - const result = await bash.exec("rg --files"); - expect(result.exitCode).toBe(0); - const files = result.stdout.trim().split("\n").sort(); - expect(files).toEqual(["dir/file", "file"]); - }); -}); - -// 90. type_list -describe("rg misc: type_list", () => { - it("should list file types with --type-list", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: {}, - }); - const result = await bash.exec("rg --type-list"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("rust"); - expect(result.stdout).toContain("py"); - }); -}); - -// 91. sort_files -describe("rg misc: sort_files", () => { - it("should sort files by path with --sort path", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a": "test\n", - "/home/user/b": "test\n", - "/home/user/dir/c": "test\n", - "/home/user/dir/d": "test\n", - }, - }); - const result = await bash.exec("rg --sort path test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "a:1:test\nb:1:test\ndir/c:1:test\ndir/d:1:test\n", - ); - }); -}); - -// 92-93. sort_accessed, sortr_accessed - SKIP: requires system timestamps -it.skip("sort_accessed: requires system timestamps", () => {}); -it.skip("sortr_accessed: requires system timestamps", () => {}); diff --git a/src/commands/rg/imported-tests/multiline.test.ts b/src/commands/rg/imported-tests/multiline.test.ts deleted file mode 100644 index d6afabc8..00000000 --- a/src/commands/rg/imported-tests/multiline.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Multiline tests imported from ripgrep - * - * Source: https://github.com/BurntSushi/ripgrep/blob/master/tests/multiline.rs - * - * Note: Tests using \p{Any} Unicode property or --multiline-dotall are skipped - * as JavaScript regex doesn't support Unicode properties and we don't implement - * --multiline-dotall. - */ - -import { describe, expect, it } from "vitest"; -import { Bash } from "../../../Bash.js"; - -describe("rg multiline: basic overlapping matches", () => { - // This tests that multiline matches that span multiple lines, but where - // multiple matches may begin and end on the same line work correctly. - it("overlap1: multiline matches spanning lines", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test": "xxx\nabc\ndefxxxabc\ndefxxx\nxxx", - }, - }); - const result = await bash.exec("rg -n -U 'abc\\ndef' test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("2:abc\n3:defxxxabc\n4:defxxx\n"); - }); - - // Like overlap1, but tests the case where one match ends at precisely the same - // location at which the next match begins. - it("overlap2: adjacent multiline matches", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test": "xxx\nabc\ndefabc\ndefxxx\nxxx", - }, - }); - const result = await bash.exec("rg -n -U 'abc\\ndef' test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("2:abc\n3:defabc\n4:defxxx\n"); - }); -}); - -describe("rg multiline: dot behavior", () => { - const SHERLOCK = `For the Doctor Watsons of this world, as opposed to the Sherlock -Holmeses, success in the province of detective work must always -be, to a very large extent, the result of luck. Sherlock Holmes -can extract a clew from a wisp of straw or a flake of cigar ash; -but Doctor Watson has to have it taken out for him and dusted, -and exhibited clearly, with a label attached. -`; - - // Tests that even in a multiline search, a '.' does not match a newline. - it("dot_no_newline: dot does not match newline in multiline mode", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - // Pattern tries to match "of this world" followed by any chars and "detective work" - // With standard multiline (no dotall), . doesn't match \n, so this should fail - const result = await bash.exec( - "rg -n -U 'of this world.+detective work' sherlock", - ); - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - }); - - // NOTE: dot_all test is skipped - requires --multiline-dotall flag which is not implemented -}); - -// NOTE: The following tests from multiline.rs are skipped because they use -// \p{Any} which is a Unicode property not supported in JavaScript regex: -// - only_matching -// - vimgrep -// - stdin -// - context - -// ============================================================================ -// SKIPPED TESTS - Features not implemented -// ============================================================================ - -// Requires --multiline-dotall flag -describe("rg multiline: dot_all", () => { - it("should make dot match newlines with --multiline-dotall", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test": "foo\nbar\n", - }, - }); - const result = await bash.exec("rg --multiline-dotall 'foo.bar' test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("foo"); - }); -}); - -// Uses \p{Any} Unicode property not supported in JavaScript -it.skip("only_matching: \\p{Any} Unicode property not supported", () => {}); -it.skip("vimgrep: \\p{Any} Unicode property not supported", () => {}); -it.skip("context: \\p{Any} Unicode property not supported", () => {}); - -// Requires stdin piping -it.skip("stdin: stdin piping not supported", () => {}); diff --git a/src/commands/rg/imported-tests/regression.test.ts b/src/commands/rg/imported-tests/regression.test.ts deleted file mode 100644 index 8fd244e8..00000000 --- a/src/commands/rg/imported-tests/regression.test.ts +++ /dev/null @@ -1,1554 +0,0 @@ -/** - * Tests imported from ripgrep: tests/regression.rs - * - * Total: 109 tests (matching ripgrep test count) - * Regression tests from various GitHub issues. - * Each test references the original issue number for traceability. - */ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../../Bash.js"; - -// Classic test fixture from ripgrep tests -const SHERLOCK = `For the Doctor Watsons of this world, as opposed to the Sherlock -Holmeses, success in the province of detective work must always -be, to a very large extent, the result of luck. Sherlock Holmes -can extract a clew from a wisp of straw or a flake of cigar ash; -but Doctor Watson has to have it taken out for him and dusted, -and exhibited clearly, with a label attached. -`; - -// r16: https://github.com/BurntSushi/ripgrep/issues/16 -describe("rg regression: r16 - directory trailing slash", () => { - it("should handle gitignore with directory trailing slash", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.git/.gitkeep": "", - "/home/user/.gitignore": "ghi/\n", - "/home/user/ghi/toplevel.txt": "xyz\n", - "/home/user/def/ghi/subdir.txt": "xyz\n", - }, - }); - const result = await bash.exec("rg xyz"); - expect(result.exitCode).toBe(1); - }); -}); - -// r25: https://github.com/BurntSushi/ripgrep/issues/25 -describe("rg regression: r25 - rooted gitignore pattern", () => { - it("should handle rooted pattern in gitignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.git/.gitkeep": "", - "/home/user/.gitignore": "/llvm/\n", - "/home/user/src/llvm/foo": "test\n", - }, - }); - const result = await bash.exec("rg test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("src/llvm/foo:1:test\n"); - }); -}); - -// r30: https://github.com/BurntSushi/ripgrep/issues/30 -describe("rg regression: r30 - negation after double-star", () => { - it("should handle negation after double-star in gitignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "vendor/**\n!vendor/manifest\n", - "/home/user/vendor/manifest": "test\n", - "/home/user/vendor/other": "test\n", - }, - }); - const result = await bash.exec("rg test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("vendor/manifest:1:test\n"); - }); -}); - -// r49: https://github.com/BurntSushi/ripgrep/issues/49 -describe("rg regression: r49 - unanchored directory pattern", () => { - it("should handle unanchored directory pattern in gitignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "foo/bar\n", - "/home/user/test/foo/bar/baz": "test\n", - }, - }); - const result = await bash.exec("rg xyz"); - expect(result.exitCode).toBe(1); - }); -}); - -// r50: https://github.com/BurntSushi/ripgrep/issues/50 -describe("rg regression: r50 - nested directory pattern", () => { - it("should handle nested directory pattern in gitignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "XXX/YYY/\n", - "/home/user/abc/def/XXX/YYY/bar": "test\n", - "/home/user/ghi/XXX/YYY/bar": "test\n", - }, - }); - const result = await bash.exec("rg xyz"); - expect(result.exitCode).toBe(1); - }); -}); - -// r64: https://github.com/BurntSushi/ripgrep/issues/64 -describe("rg regression: r64 - --files with path argument", () => { - it("should list files only in specified directory", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/dir/abc": "", - "/home/user/foo/abc": "", - }, - }); - const result = await bash.exec("rg --files foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo/abc\n"); - }); -}); - -// r65: https://github.com/BurntSushi/ripgrep/issues/65 -describe("rg regression: r65 - simple directory ignore", () => { - it("should handle simple directory ignore pattern", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.git/.gitkeep": "", - "/home/user/.gitignore": "a/\n", - "/home/user/a/foo": "xyz\n", - "/home/user/a/bar": "xyz\n", - }, - }); - const result = await bash.exec("rg xyz"); - expect(result.exitCode).toBe(1); - }); -}); - -// r67: https://github.com/BurntSushi/ripgrep/issues/67 -describe("rg regression: r67 - negation of root", () => { - it("should handle negation of root with include", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.git/.gitkeep": "", - "/home/user/.gitignore": "/*\n!/dir\n", - "/home/user/foo/bar": "test\n", - "/home/user/dir/bar": "test\n", - }, - }); - const result = await bash.exec("rg test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("dir/bar:1:test\n"); - }); -}); - -// r87: https://github.com/BurntSushi/ripgrep/issues/87 -describe("rg regression: r87 - double-star pattern", () => { - it("should handle double-star in gitignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.git/.gitkeep": "", - "/home/user/.gitignore": "foo\n**no-vcs**\n", - "/home/user/foo": "test\n", - }, - }); - const result = await bash.exec("rg test"); - expect(result.exitCode).toBe(1); - }); -}); - -// r90: https://github.com/BurntSushi/ripgrep/issues/90 -describe("rg regression: r90 - negation of hidden file", () => { - it("should handle negation of hidden file in gitignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.git/.gitkeep": "", - "/home/user/.gitignore": "!.foo\n", - "/home/user/.foo": "test\n", - }, - }); - const result = await bash.exec("rg test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(".foo:1:test\n"); - }); -}); - -// r93: https://github.com/BurntSushi/ripgrep/issues/93 -describe("rg regression: r93 - IP address regex", () => { - it("should match IP address pattern", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "192.168.1.1\n", - }, - }); - const result = await bash.exec("rg '(\\d{1,3}\\.){3}\\d{1,3}'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo:1:192.168.1.1\n"); - }); -}); - -// r99: https://github.com/BurntSushi/ripgrep/issues/99 -describe("rg regression: r99 - heading output", () => { - it("should show heading output format", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo1": "test\n", - "/home/user/foo2": "zzz\n", - "/home/user/bar": "test\n", - }, - }); - const result = await bash.exec("rg --heading test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("test"); - }); -}); - -// r105_part1: https://github.com/BurntSushi/ripgrep/issues/105 -describe("rg regression: r105 - vimgrep and column", () => { - it("r105_part1: should show column with --vimgrep", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "zztest\n", - }, - }); - const result = await bash.exec("rg --vimgrep test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo:1:3:zztest\n"); - }); - - it("r105_part2: should show column with --column", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "zztest\n", - }, - }); - const result = await bash.exec("rg --column test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo:1:3:zztest\n"); - }); -}); - -// r127: https://github.com/BurntSushi/ripgrep/issues/127 -describe("rg regression: r127 - gitignore with path", () => { - it("should handle gitignore with full path pattern", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.git/.gitkeep": "", - "/home/user/.gitignore": "foo/sherlock\n", - "/home/user/foo/sherlock": SHERLOCK, - "/home/user/foo/watson": SHERLOCK, - }, - }); - const result = await bash.exec("rg Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("foo/watson:"); - expect(result.stdout).not.toContain("foo/sherlock:"); - }); -}); - -// r128: https://github.com/BurntSushi/ripgrep/issues/128 -// Note: Test expects no filename for single-file directory search, but our impl shows filename -describe("rg regression: r128 - vertical tab handling", () => { - it.skip("should handle vertical tab characters", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "01234567\x0b\n\x0b\n\x0b\n\x0b\nx\n", - }, - }); - const result = await bash.exec("rg -n x"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("5:x\n"); - }); -}); - -// r131: https://github.com/BurntSushi/ripgrep/issues/131 - SKIP: Unicode filename -it.skip("r131: should handle unicode filename in gitignore", () => {}); - -// r137: https://github.com/BurntSushi/ripgrep/issues/137 -describe("rg regression: r137 - follow symlinks to files", () => { - it("should follow symlinks to files with -L", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/real.txt": "test content\n", - }, - }); - // Create a symlink to the file - await bash.exec("ln -s real.txt /home/user/link.txt"); - // With -L, should follow symlink - const result = await bash.exec("rg -L test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("test content"); - }); -}); - -// r156: https://github.com/BurntSushi/ripgrep/issues/156 -describe("rg regression: r156 - complex regex pattern", () => { - it("should match complex regex pattern", async () => { - const content = `#parse('widgets/foo_bar_macros.vm') -#parse ( 'widgets/mobile/foo_bar_macros.vm' ) -#parse ("widgets/foobarhiddenformfields.vm") -`; - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/testcase.txt": content, - }, - }); - const result = await bash.exec( - `rg -N '#(?:parse|include)\\s*\\(\\s*(?:"|'"'"')[./A-Za-z_-]+(?:"|'"'"')' testcase.txt`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.split("\n").length).toBeGreaterThan(1); - }); -}); - -// r184: https://github.com/BurntSushi/ripgrep/issues/184 -describe("rg regression: r184 - dot star gitignore", () => { - it("should handle .* in gitignore properly", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": ".*\n", - "/home/user/foo/bar/baz": "test\n", - }, - }); - const result = await bash.exec("rg test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo/bar/baz:1:test\n"); - }); -}); - -// r199: https://github.com/BurntSushi/ripgrep/issues/199 -describe("rg regression: r199 - smart case with word boundary", () => { - it("should use smart case with word boundary regex", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "tEsT\n", - }, - }); - const result = await bash.exec("rg --smart-case '\\btest\\b'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo:1:tEsT\n"); - }); -}); - -// r206: https://github.com/BurntSushi/ripgrep/issues/206 -describe("rg regression: r206 - glob with subdirectory", () => { - it("should match glob in subdirectory", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo/bar.txt": "test\n", - }, - }); - const result = await bash.exec("rg test -g '*.txt'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo/bar.txt:1:test\n"); - }); -}); - -// r210: https://github.com/BurntSushi/ripgrep/issues/210 - SKIP: Invalid UTF-8 filename -it.skip("r210: should handle invalid UTF-8 filename", () => {}); - -// r228: https://github.com/BurntSushi/ripgrep/issues/228 - SKIP: --ignore-file -it.skip("r228: should error on --ignore-file with directory", () => {}); - -// r229: https://github.com/BurntSushi/ripgrep/issues/229 -describe("rg regression: r229 - smart case with bracket expression", () => { - it("should be case-sensitive when pattern has uppercase in bracket", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "economie\n", - }, - }); - const result = await bash.exec("rg -S '[E]conomie'"); - expect(result.exitCode).toBe(1); - }); -}); - -// r251: https://github.com/BurntSushi/ripgrep/issues/251 -describe("rg regression: r251 - unicode case folding", () => { - it("should match cyrillic with -i", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "привет\nПривет\nПрИвЕт\n", - }, - }); - const result = await bash.exec("rg -i привет"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo:1:привет\nfoo:2:Привет\nfoo:3:ПрИвЕт\n"); - }); -}); - -// r256: https://github.com/BurntSushi/ripgrep/issues/256 -describe("rg regression: r256 - follow directory symlinks", () => { - it("should follow directory symlinks with -L", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/realdir/test.txt": "test content\n", - }, - }); - // Create a symlink to the directory - await bash.exec("ln -s realdir /home/user/linkdir"); - // With -L, should follow symlink and search inside - const result = await bash.exec("rg -L test linkdir"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("test content"); - }); - - it("should follow directory symlinks with -L and -j1", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/realdir/test.txt": "test content\n", - }, - }); - // Create a symlink to the directory - await bash.exec("ln -s realdir /home/user/linkdir"); - // With -L and -j1, should still follow symlinks - const result = await bash.exec("rg -L -j1 test linkdir"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("test content"); - }); -}); - -// r270: https://github.com/BurntSushi/ripgrep/issues/270 -describe("rg regression: r270 - pattern starting with dash", () => { - it("should handle -e with pattern starting with dash", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "-test\n", - }, - }); - const result = await bash.exec("rg -e '-test'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo:1:-test\n"); - }); -}); - -// r279: https://github.com/BurntSushi/ripgrep/issues/279 -describe("rg regression: r279 - quiet mode empty output", () => { - it("should have empty output in quiet mode", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "test\n", - }, - }); - const result = await bash.exec("rg -q test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - }); -}); - -// r391: https://github.com/BurntSushi/ripgrep/issues/391 -describe("rg regression: r391 - complex glob patterns", () => { - it("should handle complex glob patterns with --files", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.git/.gitkeep": "", - "/home/user/lock": "", - "/home/user/bar.py": "", - "/home/user/.git/packed-refs": "", - "/home/user/.git/description": "", - }, - }); - const result = await bash.exec( - "rg --no-ignore --hidden --follow --files --glob '!{.git,node_modules,plugged}/**' --glob '*.py'", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("bar.py\n"); - }); -}); - -// r405: https://github.com/BurntSushi/ripgrep/issues/405 -describe("rg regression: r405 - negated glob with path", () => { - it("should handle negated glob with path", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo/bar/file1.txt": "test\n", - "/home/user/bar/foo/file2.txt": "test\n", - }, - }); - const result = await bash.exec("rg -g '!/foo/**' test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("bar/foo/file2.txt:1:test\n"); - }); -}); - -// r428: https://github.com/BurntSushi/ripgrep/issues/428 - SKIP: Color output -it.skip("r428_color_context_path: should color context path", () => {}); -it.skip("r428_unrecognized_style: should error on unrecognized style", () => {}); - -// r451: https://github.com/BurntSushi/ripgrep/issues/451 -describe("rg regression: r451 - only matching", () => { - it("r451_only_matching_as_in_issue: should show only matching parts", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/digits.txt": "1 2 3\n", - }, - }); - const result = await bash.exec("rg --only-matching '[0-9]+' digits.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1\n2\n3\n"); - }); - - it("r451_only_matching: should show column with only matching", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/digits.txt": "1 2 3\n123\n", - }, - }); - const result = await bash.exec( - "rg --only-matching --column '[0-9]' digits.txt", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:1:1\n1:3:2\n1:5:3\n2:1:1\n2:2:2\n2:3:3\n"); - }); -}); - -// r483: https://github.com/BurntSushi/ripgrep/issues/483 -describe("rg regression: r483 - quiet with files", () => { - it("r483_matching_no_stdout: should be quiet with --files", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.py": "", - }, - }); - const result = await bash.exec("rg --quiet --files --glob '*.py'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - }); - - it("r483_non_matching_exit_code: should return error when no files match", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.rs": "", - }, - }); - const result = await bash.exec("rg --quiet --files --glob '*.py'"); - expect(result.exitCode).toBe(1); - }); -}); - -// r493: https://github.com/BurntSushi/ripgrep/issues/493 -describe("rg regression: r493 - word boundary with space", () => { - it("should handle word boundary with leading/trailing spaces", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/input.txt": "peshwaship 're seminomata\n", - }, - }); - const result = await bash.exec('rg -o "\\b \'re \\b" input.txt'); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(" 're \n"); - }); -}); - -// r506: https://github.com/BurntSushi/ripgrep/issues/506 -describe("rg regression: r506 - word match with alternation", () => { - it("should handle -w -o with alternation", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/wb.txt": "min minimum amin\nmax maximum amax\n", - }, - }); - const result = await bash.exec("rg -w -o 'min|max' wb.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("min\nmax\n"); - }); -}); - -// r553: https://github.com/BurntSushi/ripgrep/issues/553 -describe("rg regression: r553 - repeated flags", () => { - it("r553_switch: should handle repeated -i flag", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -i -i sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Sherlock"); - }); - - it("r553_flag: should handle -C override", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result1 = await bash.exec("rg -C 1 'world|attached' sherlock"); - expect(result1.exitCode).toBe(0); - expect(result1.stdout).toContain("--"); - - const result2 = await bash.exec("rg -C 1 -C 0 'world|attached' sherlock"); - expect(result2.exitCode).toBe(0); - expect(result2.stdout).not.toContain("--"); - }); -}); - -// r568: https://github.com/BurntSushi/ripgrep/issues/568 -describe("rg regression: r568 - leading hyphen in args", () => { - it("should handle -e-pattern and -e -pattern", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "foo bar -baz\n", - }, - }); - const result = await bash.exec("rg -e '-baz' file"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo bar -baz\n"); - }); -}); - -// r599: https://github.com/BurntSushi/ripgrep/issues/599 - SKIP: Color output -it.skip("r599: should handle color with empty matches", () => {}); - -// r693: https://github.com/BurntSushi/ripgrep/issues/693 -describe("rg regression: r693 - context in count mode", () => { - it("should ignore context with -c", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/bar": "xyz\n", - "/home/user/foo": "xyz\n", - }, - }); - const result = await bash.exec("rg -C1 -c --sort path xyz"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("bar:1\nfoo:1\n"); - }); -}); - -// r807: https://github.com/BurntSushi/ripgrep/issues/807 -describe("rg regression: r807 - hidden with gitignore", () => { - it("should handle gitignore for hidden subdirectories", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.git/.gitkeep": "", - "/home/user/.gitignore": ".a/b\n", - "/home/user/.a/b/file": "test\n", - "/home/user/.a/c/file": "test\n", - }, - }); - const result = await bash.exec("rg --hidden test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(".a/c/file:1:test\n"); - }); -}); - -// r829 series: https://github.com/BurntSushi/ripgrep/issues/829 -describe("rg regression: r829 - anchored gitignore patterns", () => { - it("r829_original: should handle anchored /a/b pattern", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.ignore": "/a/b\n", - "/home/user/a/b/test.txt": "Sample text\n", - }, - }); - const result = await bash.exec("rg Sample"); - expect(result.exitCode).toBe(1); - }); - - it("r829_2731: should handle negation of build directory", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.ignore": "build/\n!/some_dir/build/\n", - "/home/user/some_dir/build/foo": "string\n", - }, - }); - const result = await bash.exec("rg -l string"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("some_dir/build/foo\n"); - }); - - it("r829_2747: should handle /a/*/b pattern", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.ignore": "/a/*/b\n", - "/home/user/a/c/b/foo": "", - "/home/user/a/src/f/b/foo": "", - }, - }); - const result = await bash.exec("rg --files"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a/src/f/b/foo\n"); - }); - - it("r829_2778: should handle /parent/*.txt pattern", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.ignore": "/parent/*.txt\n", - "/home/user/parent/ignore-me.txt": "", - "/home/user/parent/subdir/dont-ignore-me.txt": "", - }, - }); - const result = await bash.exec("rg --files"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("parent/subdir/dont-ignore-me.txt\n"); - }); - - it("r829_2836: should handle /testdir/sub/sub2/ pattern", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.ignore": "/testdir/sub/sub2/\n", - "/home/user/testdir/sub/sub2/foo": "", - }, - }); - const result = await bash.exec("rg --files"); - expect(result.exitCode).toBe(1); - }); - - it("r829_2933: should handle files-with-matches with ignore", async () => { - const bash = new Bash({ - cwd: "/home/user/testdir", - files: { - "/home/user/.ignore": "/testdir/sub/sub2/\n", - "/home/user/testdir/sub/sub2/testfile": "needle\n", - }, - }); - const result = await bash.exec("rg --files-with-matches needle"); - expect(result.exitCode).toBe(1); - }); -}); - -// r900: https://github.com/BurntSushi/ripgrep/issues/900 -describe("rg regression: r900 - empty pattern file", () => { - it("should error with empty pattern file", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - "/home/user/pat": "", - }, - }); - const result = await bash.exec("rg -f pat sherlock"); - expect(result.exitCode).toBe(1); - }); -}); - -// r1064: https://github.com/BurntSushi/ripgrep/issues/1064 -describe("rg regression: r1064 - capture group", () => { - it("should match with capture group", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/input": "abc\n", - }, - }); - const result = await bash.exec("rg 'a(.*c)'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("input:1:abc\n"); - }); -}); - -// r1098: https://github.com/BurntSushi/ripgrep/issues/1098 -describe("rg regression: r1098 - gitignore with adjacent stars", () => { - it("should handle a**b pattern in gitignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.git/.gitkeep": "", - "/home/user/.gitignore": "a**b\n", - "/home/user/afoob": "test\n", - }, - }); - const result = await bash.exec("rg test"); - expect(result.exitCode).toBe(1); - }); -}); - -// r1130: https://github.com/BurntSushi/ripgrep/issues/1130 -describe("rg regression: r1130 - files with/without matches", () => { - it("should list files with matches", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "test\n", - }, - }); - const result = await bash.exec("rg --files-with-matches test foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo\n"); - }); - - it("should list files without matches", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "test\n", - }, - }); - const result = await bash.exec("rg --files-without-match nada foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo\n"); - }); -}); - -// r1159: https://github.com/BurntSushi/ripgrep/issues/1159 -describe("rg regression: r1159 - exit codes", () => { - it("r1159_invalid_flag: should return exit code 2 for invalid flag", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: {}, - }); - const result = await bash.exec("rg --wat test"); - expect(result.exitCode).not.toBe(0); - }); - - it("r1159_exit_status: should return correct exit codes", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "test\n", - }, - }); - // Match = 0 - const result1 = await bash.exec("rg test"); - expect(result1.exitCode).toBe(0); - - // No match = 1 - const result2 = await bash.exec("rg nada"); - expect(result2.exitCode).toBe(1); - - // Quiet with match = 0 - const result3 = await bash.exec("rg -q test"); - expect(result3.exitCode).toBe(0); - - // Quiet with no match = 1 - const result4 = await bash.exec("rg -q nada"); - expect(result4.exitCode).toBe(1); - }); -}); - -// r1163: https://github.com/BurntSushi/ripgrep/issues/1163 -describe("rg regression: r1163 - BOM handling", () => { - it("should handle UTF-8 BOM", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/bom.txt": "\uFEFFtest123\ntest123\n", - }, - }); - const result = await bash.exec("rg '^test123'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("bom.txt:1:test123\nbom.txt:2:test123\n"); - }); -}); - -// r1164: https://github.com/BurntSushi/ripgrep/issues/1164 - SKIP: --ignore-file-case-insensitive -it.skip("r1164: should handle --ignore-file-case-insensitive", () => {}); - -// r1173: https://github.com/BurntSushi/ripgrep/issues/1173 -describe("rg regression: r1173 - double star gitignore", () => { - it("should handle ** in gitignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.git/.gitkeep": "", - "/home/user/.gitignore": "**\n", - "/home/user/foo": "test\n", - }, - }); - const result = await bash.exec("rg test"); - expect(result.exitCode).toBe(1); - }); -}); - -// r1174: https://github.com/BurntSushi/ripgrep/issues/1174 -describe("rg regression: r1174 - triple double star", () => { - it("should handle **/**/* in gitignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.git/.gitkeep": "", - "/home/user/.gitignore": "**/**/*\n", - "/home/user/a/foo": "test\n", - }, - }); - const result = await bash.exec("rg test"); - expect(result.exitCode).toBe(1); - }); -}); - -// r1176: https://github.com/BurntSushi/ripgrep/issues/1176 -describe("rg regression: r1176 - pattern file with -F and -x", () => { - it("r1176_literal_file: should handle -F with pattern file", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/patterns": "foo(bar\n", - "/home/user/test": "foo(bar\n", - }, - }); - const result = await bash.exec("rg -F -f patterns test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo(bar\n"); - }); - - it("r1176_line_regex: should handle -x with pattern file", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/patterns": "foo\n", - "/home/user/test": "foobar\nfoo\nbarfoo\n", - }, - }); - const result = await bash.exec("rg -x -f patterns test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo\n"); - }); -}); - -// r1203: https://github.com/BurntSushi/ripgrep/issues/1203 -describe("rg regression: r1203 - reverse suffix literal", () => { - it("should match patterns ending with repeated zeros", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test": "153.230000\n", - }, - }); - const result1 = await bash.exec("rg '\\d\\d\\d00' test"); - expect(result1.exitCode).toBe(0); - expect(result1.stdout).toBe("153.230000\n"); - - const result2 = await bash.exec("rg '\\d\\d\\d000' test"); - expect(result2.exitCode).toBe(0); - expect(result2.stdout).toBe("153.230000\n"); - }); -}); - -// r1223: https://github.com/BurntSushi/ripgrep/issues/1223 - SKIP: stdin -it.skip("r1223: should handle stdin with dash directory", () => {}); - -// r1259: https://github.com/BurntSushi/ripgrep/issues/1259 -describe("rg regression: r1259 - pattern file without newline", () => { - it("should handle pattern file without trailing newline", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/patterns-nonl": "[foo]", - "/home/user/patterns-nl": "[foo]\n", - "/home/user/test": "fz\n", - }, - }); - const result1 = await bash.exec("rg -f patterns-nonl test"); - expect(result1.exitCode).toBe(0); - expect(result1.stdout).toBe("fz\n"); - - const result2 = await bash.exec("rg -f patterns-nl test"); - expect(result2.exitCode).toBe(0); - expect(result2.stdout).toBe("fz\n"); - }); -}); - -// r1311: https://github.com/BurntSushi/ripgrep/issues/1311 -describe("rg regression: r1311 - multiline replace newline", () => { - it.skip("should replace newlines in multiline mode", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/input": "hello\nworld\n", - }, - }); - const result = await bash.exec("rg -U -r '?' -n '\\n' input"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:hello?world?\n"); - }); -}); - -// r1319: https://github.com/BurntSushi/ripgrep/issues/1319 -describe("rg regression: r1319 - DNA sequence pattern", () => { - it("should match DNA sequence pattern", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/input": - "CCAGCTACTCGGGAGGCTGAGGCTGGAGGATCGCTTGAGTCCAGGAGTTC\n", - }, - }); - const result = await bash.exec("rg 'TTGAGTCCAGGAG[ATCG]{2}C'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain( - "CCAGCTACTCGGGAGGCTGAGGCTGGAGGATCGCTTGAGTCCAGGAGTTC", - ); - }); -}); - -// r1334: https://github.com/BurntSushi/ripgrep/issues/1334 -describe("rg regression: r1334 - empty and invert patterns", () => { - it.skip("r1334_invert_empty_patterns: should invert with zero patterns", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/zero-patterns": "", - "/home/user/one-pattern": "\n", - "/home/user/haystack": "one\ntwo\nthree\n", - }, - }); - // Zero patterns matches nothing - const result1 = await bash.exec("rg -f zero-patterns haystack"); - expect(result1.exitCode).toBe(1); - - // Invert zero patterns matches everything - const result2 = await bash.exec("rg -vf zero-patterns haystack"); - expect(result2.exitCode).toBe(0); - expect(result2.stdout).toBe("one\ntwo\nthree\n"); - }); - - it("r1334_crazy_literals: should handle many literal patterns", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/patterns": "1.208.0.0/12\n".repeat(40), - "/home/user/corpus": "1.208.0.0/12\n", - }, - }); - const result = await bash.exec("rg -Ff patterns corpus"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1.208.0.0/12\n"); - }); -}); - -// r1380: https://github.com/BurntSushi/ripgrep/issues/1380 -describe("rg regression: r1380 - max count with context", () => { - it("should limit matches with -m and show context", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "a\nb\nc\nd\ne\nd\ne\nd\ne\nd\ne\n", - }, - }); - const result = await bash.exec("rg -A2 -m1 d foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("d\ne\nd\n"); - }); -}); - -// r1389: https://github.com/BurntSushi/ripgrep/issues/1389 -describe("rg regression: r1389 - follow symlinks without bad symlinks", () => { - it("should follow good symlinks even when bad symlinks exist", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/real.txt": "test content\n", - }, - }); - // Create a good symlink - await bash.exec("ln -s real.txt /home/user/good_link.txt"); - // Create a broken symlink (target doesn't exist) - await bash.exec("ln -s nonexistent.txt /home/user/bad_link.txt"); - // With -L, should follow good symlinks and skip bad ones - const result = await bash.exec("rg -L test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("test content"); - // Should not error due to broken symlink - }); -}); - -// r1401: https://github.com/BurntSushi/ripgrep/issues/1401 - SKIP: PCRE2 -it.skip("r1401_look_ahead_only_matching_1: requires PCRE2", () => {}); -it.skip("r1401_look_ahead_only_matching_2: requires PCRE2", () => {}); - -// r1412: https://github.com/BurntSushi/ripgrep/issues/1412 - SKIP: PCRE2 -it.skip("r1412: requires PCRE2 look-behind", () => {}); - -// r1446: https://github.com/BurntSushi/ripgrep/pull/1446 - SKIP: git worktrees -it.skip("r1446: requires git worktree support", () => {}); - -// r1537: https://github.com/BurntSushi/ripgrep/issues/1537 -describe("rg regression: r1537 - semicolon comma pattern", () => { - it("should match semicolon comma pattern", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "abc;de,fg\n", - }, - }); - const result = await bash.exec("rg ';(.*,){1}'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo:1:abc;de,fg\n"); - }); -}); - -// r1559: https://github.com/BurntSushi/ripgrep/issues/1559 -describe("rg regression: r1559 - spaces in pattern", () => { - it("should match pattern with multiple spaces", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": `type A struct { - TaskID int \`json:"taskID"\` -} - -type B struct { - ObjectID string \`json:"objectID"\` - TaskID int \`json:"taskID"\` -} -`, - }, - }); - const result = await bash.exec("rg 'TaskID +int'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("TaskID int"); - expect(result.stdout).toContain("TaskID int"); - }); -}); - -// r1573: https://github.com/BurntSushi/ripgrep/issues/1573 - SKIP: PCRE2 -it.skip("r1573: requires PCRE2", () => {}); - -// r1638: https://github.com/BurntSushi/ripgrep/issues/1638 -describe("rg regression: r1638 - BOM column index", () => { - it("should have correct column with BOM", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "\uFEFFx\n", - }, - }); - const result = await bash.exec("rg --column x"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo:1:1:x\n"); - }); -}); - -// r1739: https://github.com/BurntSushi/ripgrep/issues/1739 -describe("rg regression: r1739 - replacement with line terminator", () => { - it("should replace with reference to full match", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test": "a\n", - }, - }); - const result = await bash.exec("rg -r '${0}f' '.*' test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("af\n"); - }); -}); - -// f1757: https://github.com/BurntSushi/ripgrep/issues/1757 -describe("rg regression: f1757 - ignore with path prefix", () => { - it("should handle ignore with path prefix", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.ignore": "rust/target\n", - "/home/user/rust/source.rs": "needle\n", - "/home/user/rust/target/rustdoc-output.html": "needle\n", - }, - }); - const result = await bash.exec("rg --files-with-matches needle rust"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("rust/source.rs\n"); - }); -}); - -// r1765: https://github.com/BurntSushi/ripgrep/issues/1765 - SKIP: --crlf -it.skip("r1765: requires --crlf", () => {}); - -// r1838: https://github.com/BurntSushi/ripgrep/issues/1838 -describe("rg regression: r1838 - NUL in pattern", () => { - it.skip("should error on NUL in pattern without -a", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test": "foo\n", - }, - }); - const result = await bash.exec("rg 'foo\\x00?' test"); - expect(result.exitCode).not.toBe(0); - }); - - it.skip("should allow NUL in pattern with -a", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test": "foo\n", - }, - }); - const result = await bash.exec("rg -a 'foo\\x00?' test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test:1:foo\n"); - }); -}); - -// r1866: https://github.com/BurntSushi/ripgrep/issues/1866 -describe("rg regression: r1866 - vimgrep multiline", () => { - it.skip("should show first line only in vimgrep multiline", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test": "foobar\nfoobar\nfoo quux\n", - }, - }); - const result = await bash.exec( - "rg --multiline --vimgrep 'foobar\\nfoobar\\nfoo|quux' test", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("test:1:1:foobar"); - expect(result.stdout).toContain("test:3:5:foo quux"); - }); -}); - -// r1868: https://github.com/BurntSushi/ripgrep/issues/1868 -// Note: Requires order-dependent flag handling (last flag wins) -describe("rg regression: r1868 - context passthru override", () => { - it.skip("should allow context to override passthru", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test": "foo\nbar\nbaz\nquux\n", - }, - }); - const result1 = await bash.exec("rg -C1 bar test"); - expect(result1.stdout).toBe("foo\nbar\nbaz\n"); - - const result2 = await bash.exec("rg --passthru bar test"); - expect(result2.stdout).toBe("foo\nbar\nbaz\nquux\n"); - - const result3 = await bash.exec("rg --passthru -C1 bar test"); - expect(result3.stdout).toBe("foo\nbar\nbaz\n"); - - const result4 = await bash.exec("rg -C1 --passthru bar test"); - expect(result4.stdout).toBe("foo\nbar\nbaz\nquux\n"); - }); -}); - -// r1878: https://github.com/BurntSushi/ripgrep/issues/1878 -describe("rg regression: r1878 - multiline anchor", () => { - it("should match ^ at line start in multiline mode", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test": "a\nbaz\nabc\n", - }, - }); - const result = await bash.exec("rg -U '^baz' test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("baz\n"); - }); -}); - -// r1891: https://github.com/BurntSushi/ripgrep/issues/1891 -describe("rg regression: r1891 - empty match word boundary", () => { - // Skipped: RE2 uses \b for word boundaries; empty pattern with \b doesn't match usefully - it.skip("should handle empty matches with -won", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test": "\n##\n", - }, - }); - const result = await bash.exec("rg -won '' test"); - expect(result.exitCode).toBe(0); - // Empty pattern matches at word boundaries - }); -}); - -// r2094: https://github.com/BurntSushi/ripgrep/issues/2094 -describe("rg regression: r2094 - multiline max-count passthru", () => { - it.skip("should handle multiline with max-count and passthru", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/haystack": "a\nb\nc\na\nb\nc\n", - }, - }); - const result = await bash.exec( - "rg --no-line-number --no-filename --multiline --max-count=1 --passthru --replace=B b haystack", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a\nB\nc\na\nb\nc\n"); - }); -}); - -// r2095: https://github.com/BurntSushi/ripgrep/issues/2095 - Complex multiline -it.skip("r2095: complex multiline replacement", () => {}); - -// r2198: https://github.com/BurntSushi/ripgrep/issues/2198 -describe("rg regression: r2198 - no-ignore-dot", () => { - it("should handle --no-ignore-dot", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.ignore": "a\n", - "/home/user/.rgignore": "b\n", - "/home/user/a": "", - "/home/user/b": "", - "/home/user/c": "", - }, - }); - const result1 = await bash.exec("rg --files --sort path"); - expect(result1.stdout).toBe("c\n"); - - const result2 = await bash.exec("rg --files --sort path --no-ignore-dot"); - expect(result2.stdout).toBe("a\nb\nc\n"); - }); -}); - -// r2208: https://github.com/BurntSushi/ripgrep/issues/2208 - Complex regex -it.skip("r2208: complex regex with named groups", () => {}); - -// r2236: https://github.com/BurntSushi/ripgrep/issues/2236 -describe("rg regression: r2236 - escaped slash in ignore", () => { - it.skip("should handle escaped slash in ignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.ignore": "foo\\/\n", - "/home/user/foo/bar": "test\n", - }, - }); - const result = await bash.exec("rg test"); - expect(result.exitCode).toBe(1); - }); -}); - -// r2480: https://github.com/BurntSushi/ripgrep/issues/2480 -describe("rg regression: r2480 - multiple patterns", () => { - it.skip("should handle empty pattern with -e", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "FooBar\n", - }, - }); - const result = await bash.exec("rg -e '' file"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("FooBar\n"); - }); - - it("should handle multiple -e patterns with only-matching", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "FooBar\n", - }, - }); - const result = await bash.exec("rg --only-matching -e Foo -e Bar file"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("Foo\nBar\n"); - }); -}); - -// r2574: https://github.com/BurntSushi/ripgrep/issues/2574 - SKIP: --no-unicode -it.skip("r2574: requires --no-unicode", () => {}); - -// r2658: https://github.com/BurntSushi/ripgrep/issues/2658 - SKIP: --null-data -it.skip("r2658: requires --null-data", () => {}); - -// r2711: https://github.com/BurntSushi/ripgrep/pull/2711 -describe("rg regression: r2711 - hidden files with path prefix", () => { - it("should list hidden files with --files", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a/.ignore": ".foo\n", - "/home/user/a/b/.foo": "", - }, - }); - const result = await bash.exec("rg --hidden --files"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a/.ignore\n"); - }); - - it("should preserve ./ prefix", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a/.ignore": ".foo\n", - }, - }); - const result = await bash.exec("rg --hidden --files ./"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("./a/.ignore\n"); - }); -}); - -// r2770: https://github.com/BurntSushi/ripgrep/issues/2770 -describe("rg regression: r2770 - gitignore with double star path", () => { - it("should handle **/bar/* pattern", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.git/.gitkeep": "", - "/home/user/.gitignore": "**/bar/*\n", - "/home/user/foo/bar/baz": "quux\n", - }, - }); - const result = await bash.exec("rg -l quux"); - expect(result.exitCode).toBe(1); - }); -}); - -// r2944: https://github.com/BurntSushi/ripgrep/pull/2944 -describe("rg regression: r2944 - bytes searched with max-count", () => { - it("should report correct bytes searched with -m", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/haystack": "foo1\nfoo2\nfoo3\nfoo4\nfoo5\n", - }, - }); - const result = await bash.exec("rg --stats -m2 foo ."); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("bytes searched"); - }); -}); - -// r2990: https://github.com/BurntSushi/ripgrep/issues/2990 - SKIP: trailing dot directory -it.skip("r2990: trailing dot directory edge case", () => {}); - -// r3067: https://github.com/BurntSushi/ripgrep/issues/3067 -describe("rg regression: r3067 - gitignore foobar/debug", () => { - it("should handle foobar/debug pattern", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.git/.gitkeep": "", - "/home/user/.gitignore": "foobar/debug\n", - "/home/user/foobar/some/debug/flag": "baz\n", - "/home/user/foobar/debug/flag2": "baz\n", - }, - }); - const result = await bash.exec("rg baz"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foobar/some/debug/flag:1:baz\n"); - }); -}); - -// r3108: https://github.com/BurntSushi/ripgrep/issues/3108 -describe("rg regression: r3108 - files-without-match quiet exit", () => { - it("should return correct exit codes with files-without-match", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/yes-match": "abc\n", - "/home/user/non-match": "xyz\n", - }, - }); - const result1 = await bash.exec("rg --files-without-match abc non-match"); - expect(result1.exitCode).toBe(0); - expect(result1.stdout).toBe("non-match\n"); - - const result2 = await bash.exec("rg --files-without-match abc yes-match"); - expect(result2.exitCode).toBe(1); - - const result3 = await bash.exec( - "rg --files-without-match -q abc non-match", - ); - expect(result3.exitCode).toBe(0); - expect(result3.stdout).toBe(""); - }); -}); - -// r3127: https://github.com/BurntSushi/ripgrep/issues/3127 -describe("rg regression: r3127 - unclosed character class", () => { - it("r3127_gitignore_allow_unclosed_class: should allow unclosed class in gitignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.git/.gitkeep": "", - "/home/user/.gitignore": "[abc\n", - "/home/user/[abc": "", - "/home/user/test": "", - }, - }); - const result = await bash.exec("rg --files"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test\n"); - }); - - it("r3127_glob_flag_not_allow_unclosed_class: should error on unclosed class in glob", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/[abc": "", - "/home/user/test": "", - }, - }); - const result = await bash.exec("rg --files -g '[abc'"); - expect(result.exitCode).not.toBe(0); - }); -}); - -// r3139: https://github.com/BurntSushi/ripgrep/issues/3139 - SKIP: PCRE2 -it.skip("r3139: requires PCRE2 look-ahead", () => {}); - -// r3173: https://github.com/BurntSushi/ripgrep/issues/3173 -describe("rg regression: r3173 - hidden whitelist only dot", () => { - it.skip("should handle hidden whitelist with dot path", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/subdir/.foo.txt": "text\n", - "/home/user/.ignore": "!.foo.txt\n", - }, - }); - const result = await bash.exec("rg --files"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("subdir/.foo.txt\n"); - }); -}); - -// r3179: https://github.com/BurntSushi/ripgrep/issues/3179 - SKIP: --ignore-file -it.skip("r3179: requires --ignore-file", () => {}); - -// r3180: https://github.com/BurntSushi/ripgrep/issues/3180 -describe("rg regression: r3180 - complex pattern no panic", () => { - it.skip("should handle complex pattern without panic", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/haystack": " b b b b b b b b\nc\n", - }, - }); - const result = await bash.exec( - `rg '(^|[^a-z])((([a-z]+)?)s)?b(s([a-z]+)?)($|[^a-z])' haystack -U -r x`, - ); - expect(result.exitCode).toBe(0); - }); -}); diff --git a/src/commands/rg/rg-options.ts b/src/commands/rg/rg-options.ts deleted file mode 100644 index 094005a5..00000000 --- a/src/commands/rg/rg-options.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * RgOptions interface and default values - */ - -export interface RgOptions { - // Pattern matching - ignoreCase: boolean; - caseSensitive: boolean; - smartCase: boolean; - fixedStrings: boolean; - wordRegexp: boolean; - lineRegexp: boolean; - invertMatch: boolean; - multiline: boolean; - multilineDotall: boolean; - patterns: string[]; - patternFiles: string[]; - - // Output control - count: boolean; - countMatches: boolean; - files: boolean; - filesWithMatches: boolean; - filesWithoutMatch: boolean; - stats: boolean; - onlyMatching: boolean; - maxCount: number; - lineNumber: boolean; - noFilename: boolean; - withFilename: boolean; - nullSeparator: boolean; - byteOffset: boolean; - column: boolean; - vimgrep: boolean; - replace: string | null; - afterContext: number; - beforeContext: number; - contextSeparator: string; - quiet: boolean; - heading: boolean; - passthru: boolean; - includeZero: boolean; - sort: "path" | "none"; - json: boolean; - - // File selection - globs: string[]; - iglobs: string[]; // case-insensitive globs - globCaseInsensitive: boolean; // make all globs case-insensitive - types: string[]; - typesNot: string[]; - typeAdd: string[]; // runtime type additions (name:pattern) - typeClear: string[]; // runtime type clearing - hidden: boolean; - noIgnore: boolean; - noIgnoreDot: boolean; - noIgnoreVcs: boolean; - ignoreFiles: string[]; // custom ignore files via --ignore-file - maxDepth: number; - maxFilesize: number; // in bytes, 0 = unlimited - followSymlinks: boolean; - searchZip: boolean; - searchBinary: boolean; - preprocessor: string | null; // --pre command - preprocessorGlobs: string[]; // --pre-glob patterns -} - -export function createDefaultOptions(): RgOptions { - return { - ignoreCase: false, - caseSensitive: false, - smartCase: true, - fixedStrings: false, - wordRegexp: false, - lineRegexp: false, - invertMatch: false, - multiline: false, - multilineDotall: false, - patterns: [], - patternFiles: [], - count: false, - countMatches: false, - files: false, - filesWithMatches: false, - filesWithoutMatch: false, - stats: false, - onlyMatching: false, - maxCount: 0, - lineNumber: true, - noFilename: false, - withFilename: false, - nullSeparator: false, - byteOffset: false, - column: false, - vimgrep: false, - replace: null, - afterContext: 0, - beforeContext: 0, - contextSeparator: "--", - quiet: false, - heading: false, - passthru: false, - includeZero: false, - sort: "path", - json: false, - globs: [], - iglobs: [], - globCaseInsensitive: false, - types: [], - typesNot: [], - typeAdd: [], - typeClear: [], - hidden: false, - noIgnore: false, - noIgnoreDot: false, - noIgnoreVcs: false, - ignoreFiles: [], - maxDepth: Infinity, - maxFilesize: 0, - followSymlinks: false, - searchZip: false, - searchBinary: false, - preprocessor: null, - preprocessorGlobs: [], - }; -} diff --git a/src/commands/rg/rg-parser.ts b/src/commands/rg/rg-parser.ts deleted file mode 100644 index 3a289246..00000000 --- a/src/commands/rg/rg-parser.ts +++ /dev/null @@ -1,811 +0,0 @@ -/** - * Argument parsing for rg command - Declarative approach - */ - -import type { ExecResult } from "../../types.js"; -import { unknownOption } from "../help.js"; -import { createDefaultOptions, type RgOptions } from "./rg-options.js"; - -export interface ParseResult { - success: true; - options: RgOptions; - paths: string[]; - explicitLineNumbers: boolean; -} - -export interface ParseError { - success: false; - error: ExecResult; -} - -export type ParseArgsResult = ParseResult | ParseError; - -/** - * Parse a filesize string (e.g., "10K", "5M", "1G") - */ -function parseFilesize(value: string): number { - const match = value.match(/^(\d+)([KMG])?$/i); - if (!match) { - return 0; // Invalid format, will be caught by validation - } - const num = parseInt(match[1], 10); - const suffix = (match[2] || "").toUpperCase(); - switch (suffix) { - case "K": - return num * 1024; - case "M": - return num * 1024 * 1024; - case "G": - return num * 1024 * 1024 * 1024; - default: - return num; - } -} - -/** - * Validate a filesize string - */ -function validateFilesize(value: string): ExecResult | null { - if (!/^\d+[KMG]?$/i.test(value)) { - return { - stdout: "", - stderr: `rg: invalid --max-filesize value: ${value}\n`, - exitCode: 1, - }; - } - return null; -} - -/** - * Validate a file type name - * Note: We don't strictly validate type names because --type-add can define custom types. - * If a type doesn't exist (and isn't defined via --type-add), the search will simply - * return no matches. - */ -function validateType(_typeName: string): ExecResult | null { - // Allow all type names - if they don't exist, the search returns no results - return null; -} - -// Declarative value option definitions -interface ValueOptDef { - short?: string; - long: string; - target: keyof RgOptions; - multi?: boolean; - parse?: (val: string) => number; - validate?: (val: string) => ExecResult | null; -} - -const VALUE_OPTS: ValueOptDef[] = [ - { short: "g", long: "glob", target: "globs", multi: true }, - { long: "iglob", target: "iglobs", multi: true }, - { - short: "t", - long: "type", - target: "types", - multi: true, - validate: validateType, - }, - { - short: "T", - long: "type-not", - target: "typesNot", - multi: true, - validate: validateType, - }, - { long: "type-add", target: "typeAdd", multi: true }, - { long: "type-clear", target: "typeClear", multi: true }, - { short: "m", long: "max-count", target: "maxCount", parse: parseInt }, - { short: "e", long: "regexp", target: "patterns", multi: true }, - { short: "f", long: "file", target: "patternFiles", multi: true }, - { short: "r", long: "replace", target: "replace" }, - { short: "d", long: "max-depth", target: "maxDepth", parse: parseInt }, - { - long: "max-filesize", - target: "maxFilesize", - parse: parseFilesize, - validate: validateFilesize, - }, - { long: "context-separator", target: "contextSeparator" }, - // Thread count (no-op in single-threaded environment, but accept the option) - { short: "j", long: "threads", target: "maxDepth", parse: () => Infinity }, // Use maxDepth as dummy target (value ignored) - // Custom ignore file - { long: "ignore-file", target: "ignoreFiles", multi: true }, - // Preprocessing - { long: "pre", target: "preprocessor" }, - { long: "pre-glob", target: "preprocessorGlobs", multi: true }, -]; - -// Declarative boolean flag definitions -type BoolFlagHandler = (options: RgOptions) => void; - -const BOOL_FLAGS = new Map([ - // Case sensitivity - [ - "i", - (o) => { - o.ignoreCase = true; - o.caseSensitive = false; - o.smartCase = false; - }, - ], - [ - "--ignore-case", - (o) => { - o.ignoreCase = true; - o.caseSensitive = false; - o.smartCase = false; - }, - ], - [ - "s", - (o) => { - o.caseSensitive = true; - o.ignoreCase = false; - o.smartCase = false; - }, - ], - [ - "--case-sensitive", - (o) => { - o.caseSensitive = true; - o.ignoreCase = false; - o.smartCase = false; - }, - ], - [ - "S", - (o) => { - o.smartCase = true; - o.ignoreCase = false; - o.caseSensitive = false; - }, - ], - [ - "--smart-case", - (o) => { - o.smartCase = true; - o.ignoreCase = false; - o.caseSensitive = false; - }, - ], - - // Pattern matching - [ - "F", - (o) => { - o.fixedStrings = true; - }, - ], - [ - "--fixed-strings", - (o) => { - o.fixedStrings = true; - }, - ], - [ - "w", - (o) => { - o.wordRegexp = true; - }, - ], - [ - "--word-regexp", - (o) => { - o.wordRegexp = true; - }, - ], - [ - "x", - (o) => { - o.lineRegexp = true; - }, - ], - [ - "--line-regexp", - (o) => { - o.lineRegexp = true; - }, - ], - [ - "v", - (o) => { - o.invertMatch = true; - }, - ], - [ - "--invert-match", - (o) => { - o.invertMatch = true; - }, - ], - [ - "U", - (o) => { - o.multiline = true; - }, - ], - [ - "--multiline", - (o) => { - o.multiline = true; - }, - ], - [ - "--multiline-dotall", - (o) => { - o.multilineDotall = true; - o.multiline = true; // dotall implies multiline - }, - ], - - // Output modes - [ - "c", - (o) => { - o.count = true; - }, - ], - [ - "--count", - (o) => { - o.count = true; - }, - ], - [ - "--count-matches", - (o) => { - o.countMatches = true; - }, - ], - [ - "l", - (o) => { - o.filesWithMatches = true; - }, - ], - [ - "--files", - (o) => { - o.files = true; - }, - ], - [ - "--files-with-matches", - (o) => { - o.filesWithMatches = true; - }, - ], - [ - "--files-without-match", - (o) => { - o.filesWithoutMatch = true; - }, - ], - [ - "--stats", - (o) => { - o.stats = true; - }, - ], - [ - "o", - (o) => { - o.onlyMatching = true; - }, - ], - [ - "--only-matching", - (o) => { - o.onlyMatching = true; - }, - ], - [ - "q", - (o) => { - o.quiet = true; - }, - ], - [ - "--quiet", - (o) => { - o.quiet = true; - }, - ], - - // Line numbers - [ - "N", - (o) => { - o.lineNumber = false; - }, - ], - [ - "--no-line-number", - (o) => { - o.lineNumber = false; - }, - ], - - // Filename display - [ - "H", - (o) => { - o.withFilename = true; - }, - ], - [ - "--with-filename", - (o) => { - o.withFilename = true; - }, - ], - [ - "I", - (o) => { - o.noFilename = true; - }, - ], - [ - "--no-filename", - (o) => { - o.noFilename = true; - }, - ], - [ - "0", - (o) => { - o.nullSeparator = true; - }, - ], - [ - "--null", - (o) => { - o.nullSeparator = true; - }, - ], - - // Column and byte offset - [ - "b", - (o) => { - o.byteOffset = true; - }, - ], - [ - "--byte-offset", - (o) => { - o.byteOffset = true; - }, - ], - [ - "--column", - (o) => { - o.column = true; - o.lineNumber = true; - }, - ], - [ - "--no-column", - (o) => { - o.column = false; - }, - ], - [ - "--vimgrep", - (o) => { - o.vimgrep = true; - o.column = true; - o.lineNumber = true; - }, - ], - [ - "--json", - (o) => { - o.json = true; - }, - ], - - // File selection - [ - "--hidden", - (o) => { - o.hidden = true; - }, - ], - [ - "--no-ignore", - (o) => { - o.noIgnore = true; - }, - ], - [ - "--no-ignore-dot", - (o) => { - o.noIgnoreDot = true; - }, - ], - [ - "--no-ignore-vcs", - (o) => { - o.noIgnoreVcs = true; - }, - ], - [ - "L", - (o) => { - o.followSymlinks = true; - }, - ], - [ - "--follow", - (o) => { - o.followSymlinks = true; - }, - ], - [ - "z", - (o) => { - o.searchZip = true; - }, - ], - [ - "--search-zip", - (o) => { - o.searchZip = true; - }, - ], - [ - "a", - (o) => { - o.searchBinary = true; - }, - ], - [ - "--text", - (o) => { - o.searchBinary = true; - }, - ], - - // Output formatting - [ - "--heading", - (o) => { - o.heading = true; - }, - ], - [ - "--passthru", - (o) => { - o.passthru = true; - }, - ], - [ - "--include-zero", - (o) => { - o.includeZero = true; - }, - ], - [ - "--glob-case-insensitive", - (o) => { - o.globCaseInsensitive = true; - }, - ], -]); - -// Special flags that return a value indicating line number was explicitly set -const LINE_NUMBER_FLAGS = new Set(["n", "--line-number"]); - -// Handle unrestricted mode (-u, -uu, -uuu) -function handleUnrestricted(options: RgOptions): void { - if (options.hidden) { - options.searchBinary = true; - } else if (options.noIgnore) { - options.hidden = true; - } else { - options.noIgnore = true; - } -} - -/** - * Try to parse a value option, returning the new index if matched - */ -function tryParseValueOpt( - args: string[], - i: number, - options: RgOptions, -): { newIndex: number; error?: ExecResult } | null { - const arg = args[i]; - - for (const def of VALUE_OPTS) { - // Check --long=VALUE form - if (arg.startsWith(`--${def.long}=`)) { - const value = arg.slice(`--${def.long}=`.length); - const error = applyValueOpt(options, def, value); - if (error) return { newIndex: i, error }; - return { newIndex: i }; - } - - // Check -xVALUE form (short option with value attached, e.g., -f-) - if (def.short && arg.startsWith(`-${def.short}`) && arg.length > 2) { - const value = arg.slice(2); - const error = applyValueOpt(options, def, value); - if (error) return { newIndex: i, error }; - return { newIndex: i }; - } - - // Check -x VALUE or --long VALUE form - if ((def.short && arg === `-${def.short}`) || arg === `--${def.long}`) { - if (i + 1 >= args.length) return null; - const value = args[i + 1]; - const error = applyValueOpt(options, def, value); - if (error) return { newIndex: i + 1, error }; - return { newIndex: i + 1 }; - } - } - - return null; -} - -/** - * Find a value option definition by its short flag - */ -function findValueOptByShort(shortFlag: string): ValueOptDef | undefined { - return VALUE_OPTS.find((def) => def.short === shortFlag); -} - -/** - * Apply a value option to options object - */ -function applyValueOpt( - options: RgOptions, - def: ValueOptDef, - value: string, -): ExecResult | undefined { - if (def.validate) { - const error = def.validate(value); - if (error) return error; - } - - const parsed = def.parse ? def.parse(value) : value; - - if (def.multi) { - (options[def.target] as string[]).push(parsed as string); - } else { - (options[def.target] as string | number | null) = parsed; - } - return undefined; -} - -/** - * Parse sort option - */ -function parseSort( - args: string[], - i: number, -): { value: "path" | "none"; newIndex: number } | null { - const arg = args[i]; - - if (arg === "--sort" && i + 1 < args.length) { - const val = args[i + 1]; - if (val === "path" || val === "none") { - return { value: val, newIndex: i + 1 }; - } - } - - if (arg.startsWith("--sort=")) { - const val = arg.slice("--sort=".length); - if (val === "path" || val === "none") { - return { value: val, newIndex: i }; - } - } - - return null; -} - -/** - * Parse context flag (-A, -B, -C) - */ -function parseContextFlag( - args: string[], - i: number, -): { flag: "A" | "B" | "C"; value: number; newIndex: number } | null { - const arg = args[i]; - - // -A2, -B3, -C1 form - const attached = arg.match(/^-([ABC])(\d+)$/); - if (attached) { - return { - flag: attached[1] as "A" | "B" | "C", - value: parseInt(attached[2], 10), - newIndex: i, - }; - } - - // -A 2, -B 3, -C 1 form - if ((arg === "-A" || arg === "-B" || arg === "-C") && i + 1 < args.length) { - return { - flag: arg[1] as "A" | "B" | "C", - value: parseInt(args[i + 1], 10), - newIndex: i + 1, - }; - } - - return null; -} - -/** - * Parse max-count with attached number (-m2) - */ -function parseMaxCountAttached(arg: string): number | null { - const match = arg.match(/^-m(\d+)$/); - return match ? parseInt(match[1], 10) : null; -} - -/** - * Parse rg command arguments - */ -export function parseArgs(args: string[]): ParseArgsResult { - const options = createDefaultOptions(); - let positionalPattern: string | null = null; - const paths: string[] = []; - - // Context tracking with MAX precedence - let explicitA = -1; - let explicitB = -1; - let explicitC = -1; - let explicitLineNumbers = false; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - if (arg.startsWith("-") && arg !== "-") { - // Try context flags first (-A, -B, -C) - const contextResult = parseContextFlag(args, i); - if (contextResult) { - const { flag, value, newIndex } = contextResult; - if (flag === "A") explicitA = Math.max(explicitA, value); - else if (flag === "B") explicitB = Math.max(explicitB, value); - else explicitC = value; // -C overwrites, doesn't max - i = newIndex; - continue; - } - - // Try max-count with attached number (-m2) - const maxCountNum = parseMaxCountAttached(arg); - if (maxCountNum !== null) { - options.maxCount = maxCountNum; - continue; - } - - // Try value options - const valueResult = tryParseValueOpt(args, i, options); - if (valueResult) { - if (valueResult.error) { - return { success: false, error: valueResult.error }; - } - i = valueResult.newIndex; - continue; - } - - // Try sort option - const sortResult = parseSort(args, i); - if (sortResult) { - options.sort = sortResult.value; - i = sortResult.newIndex; - continue; - } - - // Parse boolean flags (handles both --flag and -xyz combined) - const flags = arg.startsWith("--") ? [arg] : arg.slice(1).split(""); - let consumedNextArg = false; - - for (const flag of flags) { - // Check for line number flags (special case that returns a value) - if (LINE_NUMBER_FLAGS.has(flag)) { - options.lineNumber = true; - explicitLineNumbers = true; - continue; - } - - // Check for unrestricted mode - if (flag === "u" || flag === "--unrestricted") { - handleUnrestricted(options); - continue; - } - - // PCRE2 not supported - if (flag === "P" || flag === "--pcre2") { - return { - success: false, - error: { - stdout: "", - stderr: - "rg: PCRE2 is not supported. Use standard regex syntax instead.\n", - exitCode: 1, - }, - }; - } - - // Check if this is a value option short form (e.g., 'f' in '-Ff') - if (flag.length === 1) { - const valueDef = findValueOptByShort(flag); - if (valueDef) { - // Value option in combined flags - consume next argument - if (i + 1 >= args.length) { - return { success: false, error: unknownOption("rg", `-${flag}`) }; - } - const error = applyValueOpt(options, valueDef, args[i + 1]); - if (error) { - return { success: false, error }; - } - i++; - consumedNextArg = true; - continue; - } - } - - // Try boolean flags - const handler = BOOL_FLAGS.get(flag); - if (handler) { - handler(options); - continue; - } - - // Unknown flag - if (flag.startsWith("--")) { - return { success: false, error: unknownOption("rg", flag) }; - } - if (flag.length === 1) { - return { success: false, error: unknownOption("rg", `-${flag}`) }; - } - } - // If we consumed the next arg (for a value option in combined flags), - // the outer loop will naturally skip it since i was incremented - void consumedNextArg; - } else if ( - positionalPattern === null && - options.patterns.length === 0 && - options.patternFiles.length === 0 - ) { - // First positional arg is pattern only if no -e patterns or -f files provided - positionalPattern = arg; - } else { - paths.push(arg); - } - } - - // Resolve context values with MAX precedence - if (explicitA >= 0 || explicitC >= 0) { - options.afterContext = Math.max( - explicitA >= 0 ? explicitA : 0, - explicitC >= 0 ? explicitC : 0, - ); - } - if (explicitB >= 0 || explicitC >= 0) { - options.beforeContext = Math.max( - explicitB >= 0 ? explicitB : 0, - explicitC >= 0 ? explicitC : 0, - ); - } - - // Add positional pattern - if (positionalPattern !== null) { - options.patterns.push(positionalPattern); - } - - // --column and --vimgrep imply line numbers - if (options.column || options.vimgrep) { - explicitLineNumbers = true; - } - - return { - success: true, - options, - paths, - explicitLineNumbers, - }; -} diff --git a/src/commands/rg/rg-search.ts b/src/commands/rg/rg-search.ts deleted file mode 100644 index e1f47d01..00000000 --- a/src/commands/rg/rg-search.ts +++ /dev/null @@ -1,1009 +0,0 @@ -/** - * Core search logic for rg command - */ - -import { gunzipSync } from "node:zlib"; -import { createUserRegex, type UserRegex } from "../../regex/index.js"; -import type { CommandContext, ExecResult } from "../../types.js"; -import { - buildRegex, - convertReplacement, - type RegexResult, - searchContent, -} from "../search-engine/index.js"; -import { FileTypeRegistry } from "./file-types.js"; -import { GitignoreManager, loadGitignores } from "./gitignore.js"; -import type { RgOptions } from "./rg-options.js"; - -/** - * Check if data is gzip compressed (magic bytes) - */ -function isGzip(data: Uint8Array): boolean { - return data.length >= 2 && data[0] === 0x1f && data[1] === 0x8b; -} - -/** - * Validate glob pattern for errors (e.g., unclosed character class) - * Returns error message if invalid, null if valid - */ -function validateGlob(glob: string): string | null { - // Check for unclosed character class - let inClass = false; - for (let i = 0; i < glob.length; i++) { - const char = glob[i]; - if (char === "[" && !inClass) { - inClass = true; - } else if (char === "]" && inClass) { - inClass = false; - } - } - if (inClass) { - return `rg: glob '${glob}' has an unclosed character class`; - } - return null; -} - -export interface SearchContext { - ctx: CommandContext; - options: RgOptions; - paths: string[]; - explicitLineNumbers: boolean; -} - -/** - * Execute the search with parsed options - */ -export async function executeSearch( - searchCtx: SearchContext, -): Promise { - const { ctx, options, paths: inputPaths, explicitLineNumbers } = searchCtx; - - // Validate glob patterns for errors - for (const glob of options.globs) { - const globToValidate = glob.startsWith("!") ? glob.slice(1) : glob; - const error = validateGlob(globToValidate); - if (error) { - return { stdout: "", stderr: `${error}\n`, exitCode: 1 }; - } - } - - // Handle --files mode: list files without searching - // In --files mode, positional args (including what would be the pattern) are paths - if (options.files) { - const filesPaths = [...options.patterns, ...inputPaths]; - return listFiles(ctx, filesPaths, options); - } - - // Combine -e patterns with patterns from files - const patterns = [...options.patterns]; - - // Read patterns from files (-f/--file) - for (const patternFile of options.patternFiles) { - try { - let content: string; - if (patternFile === "-") { - // Read from stdin - content = ctx.stdin; - } else { - const filePath = ctx.fs.resolvePath(ctx.cwd, patternFile); - content = await ctx.fs.readFile(filePath); - } - const filePatterns = content - .split("\n") - .filter((line) => line.length > 0); - patterns.push(...filePatterns); - } catch { - return { - stdout: "", - stderr: `rg: ${patternFile}: No such file or directory\n`, - exitCode: 2, - }; - } - } - - if (patterns.length === 0) { - // If patterns came from files but all were empty, return no-match (exit 1) - // Otherwise return error for no pattern given (exit 2) - if (options.patternFiles.length > 0) { - return { stdout: "", stderr: "", exitCode: 1 }; - } - return { - stdout: "", - stderr: "rg: no pattern given\n", - exitCode: 2, - }; - } - - // Default to current directory - const paths = inputPaths.length === 0 ? ["."] : inputPaths; - - // Determine case sensitivity - const effectiveIgnoreCase = determineIgnoreCase(options, patterns); - - // Build regex - let regex: UserRegex; - let kResetGroup: number | undefined; - try { - const regexResult = buildSearchRegex( - patterns, - options, - effectiveIgnoreCase, - ); - regex = regexResult.regex; - kResetGroup = regexResult.kResetGroup; - } catch { - return { - stdout: "", - stderr: `rg: invalid regex: ${patterns.join(", ")}\n`, - exitCode: 2, - }; - } - - // Load gitignore files - let gitignore: GitignoreManager | null = null; - if (!options.noIgnore) { - gitignore = await loadGitignores( - ctx.fs, - ctx.cwd, - options.noIgnoreDot, - options.noIgnoreVcs, - options.ignoreFiles, - ); - } - - // Create file type registry and apply --type-clear and --type-add - const typeRegistry = new FileTypeRegistry(); - for (const name of options.typeClear) { - typeRegistry.clearType(name); - } - for (const spec of options.typeAdd) { - typeRegistry.addType(spec); - } - - // Collect files to search - const { files, singleExplicitFile } = await collectFiles( - ctx, - paths, - options, - gitignore, - typeRegistry, - ); - - if (files.length === 0) { - return { stdout: "", stderr: "", exitCode: 1 }; - } - - // Determine output settings - const showFilename = - !options.noFilename && - (options.withFilename || !singleExplicitFile || files.length > 1); - - let effectiveLineNumbers = options.lineNumber; - if (!explicitLineNumbers) { - if (singleExplicitFile && files.length === 1) { - effectiveLineNumbers = false; - } - if (options.onlyMatching) { - effectiveLineNumbers = false; - } - } - - // Search files - return searchFiles( - ctx, - files, - regex, - options, - showFilename, - effectiveLineNumbers, - kResetGroup, - ); -} - -/** - * Determine effective case sensitivity based on options - */ -function determineIgnoreCase(options: RgOptions, patterns: string[]): boolean { - if (options.caseSensitive) { - return false; - } - if (options.ignoreCase) { - return true; - } - if (options.smartCase) { - return !patterns.some((p) => /[A-Z]/.test(p)); - } - return false; -} - -/** - * Build the search regex from patterns - */ -function buildSearchRegex( - patterns: string[], - options: RgOptions, - ignoreCase: boolean, -): RegexResult { - let combinedPattern: string; - if (patterns.length === 1) { - combinedPattern = patterns[0]; - } else { - combinedPattern = patterns - .map((p) => - options.fixedStrings - ? p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - : `(?:${p})`, - ) - .join("|"); - } - - return buildRegex(combinedPattern, { - mode: options.fixedStrings && patterns.length === 1 ? "fixed" : "perl", - ignoreCase, - wholeWord: options.wordRegexp, - lineRegexp: options.lineRegexp, - multiline: options.multiline, - multilineDotall: options.multilineDotall, - }); -} - -interface CollectFilesResult { - files: string[]; - singleExplicitFile: boolean; -} - -/** - * Collect files to search based on paths and options - */ -async function collectFiles( - ctx: CommandContext, - paths: string[], - options: RgOptions, - gitignore: GitignoreManager | null, - typeRegistry: FileTypeRegistry, -): Promise { - const files: string[] = []; - let explicitFileCount = 0; - let directoryCount = 0; - - for (const path of paths) { - const fullPath = ctx.fs.resolvePath(ctx.cwd, path); - - try { - const stat = await ctx.fs.stat(fullPath); - - if (stat.isFile) { - explicitFileCount++; - // Check max filesize - if (options.maxFilesize > 0 && stat.size > options.maxFilesize) { - continue; - } - if ( - shouldIncludeFile(path, options, gitignore, fullPath, typeRegistry) - ) { - files.push(path); - } - } else if (stat.isDirectory) { - directoryCount++; - await walkDirectory( - ctx, - path, - fullPath, - 0, - options, - gitignore, - typeRegistry, - files, - ); - } - } catch { - // Path doesn't exist - skip silently - } - } - - const sortedFiles = options.sort === "path" ? files.sort() : files; - - return { - files: sortedFiles, - singleExplicitFile: explicitFileCount === 1 && directoryCount === 0, - }; -} - -/** - * Recursively walk a directory and collect matching files - */ -async function walkDirectory( - ctx: CommandContext, - relativePath: string, - absolutePath: string, - depth: number, - options: RgOptions, - gitignore: GitignoreManager | null, - typeRegistry: FileTypeRegistry, - files: string[], -): Promise { - if (depth >= options.maxDepth) { - return; - } - - // Load ignore files for this directory (per-directory ignore loading) - if (gitignore) { - await gitignore.loadForDirectory(absolutePath); - } - - try { - const entries = ctx.fs.readdirWithFileTypes - ? await ctx.fs.readdirWithFileTypes(absolutePath) - : (await ctx.fs.readdir(absolutePath)).map((name) => ({ - name, - isFile: undefined as boolean | undefined, - })); - - for (const entry of entries) { - const name = entry.name; - - // Skip common ignored directories (VCS, node_modules, etc.) - if (!options.noIgnore && GitignoreManager.isCommonIgnored(name)) { - continue; - } - - // Hidden file check is done after gitignore to allow negation patterns - // to whitelist specific hidden files (e.g., "!.foo" in .gitignore) - const isHidden = name.startsWith("."); - - const entryRelativePath = - relativePath === "." - ? name - : relativePath === "./" - ? `./${name}` - : relativePath.endsWith("/") - ? `${relativePath}${name}` - : `${relativePath}/${name}`; - const entryAbsolutePath = ctx.fs.resolvePath(absolutePath, name); - - let isFile: boolean; - let isDirectory: boolean; - let isSymlink = false; - - // Check if entry has type info from readdirWithFileTypes - const hasTypeInfo = entry.isFile !== undefined && "isDirectory" in entry; - - if (hasTypeInfo) { - // Use type info from readdirWithFileTypes - const dirent = entry as { - name: string; - isFile: boolean; - isDirectory: boolean; - isSymbolicLink?: boolean; - }; - isSymlink = dirent.isSymbolicLink === true; - - if (isSymlink && !options.followSymlinks) { - continue; // Skip symlinks unless -L is specified - } - - if (isSymlink && options.followSymlinks) { - // For symlinks with -L, stat the target to get actual type - try { - const stat = await ctx.fs.stat(entryAbsolutePath); - isFile = stat.isFile; - isDirectory = stat.isDirectory; - } catch { - continue; // Broken symlink, skip - } - } else { - isFile = dirent.isFile; - isDirectory = dirent.isDirectory; - } - } else { - try { - // Use lstat to detect symlinks - const lstat = ctx.fs.lstat - ? await ctx.fs.lstat(entryAbsolutePath) - : await ctx.fs.stat(entryAbsolutePath); - isSymlink = lstat.isSymbolicLink === true; - - if (isSymlink && !options.followSymlinks) { - continue; // Skip symlinks unless -L is specified - } - - // For symlinks with -L, stat the target - const stat = - isSymlink && options.followSymlinks - ? await ctx.fs.stat(entryAbsolutePath) - : lstat; - isFile = stat.isFile; - isDirectory = stat.isDirectory; - } catch { - continue; - } - } - - // Check gitignore patterns first - const gitignoreIgnored = gitignore?.matches( - entryAbsolutePath, - isDirectory, - ); - if (gitignoreIgnored) { - continue; - } - - // Skip hidden files unless: - // - --hidden is set, OR - // - gitignore explicitly whitelists this file with a negation pattern (e.g., "!.foo") - if (isHidden && !options.hidden) { - const isWhitelisted = gitignore?.isWhitelisted( - entryAbsolutePath, - isDirectory, - ); - if (!isWhitelisted) { - continue; - } - } - - if (isDirectory) { - await walkDirectory( - ctx, - entryRelativePath, - entryAbsolutePath, - depth + 1, - options, - gitignore, - typeRegistry, - files, - ); - } else if (isFile) { - // Check max filesize - if (options.maxFilesize > 0) { - try { - const fileStat = await ctx.fs.stat(entryAbsolutePath); - if (fileStat.size > options.maxFilesize) { - continue; - } - } catch { - continue; - } - } - if ( - shouldIncludeFile( - entryRelativePath, - options, - gitignore, - entryAbsolutePath, - typeRegistry, - ) - ) { - files.push(entryRelativePath); - } - } - } - } catch { - // Directory read failed - skip - } -} - -/** - * Check if a file should be included based on filters - */ -function shouldIncludeFile( - relativePath: string, - options: RgOptions, - gitignore: GitignoreManager | null, - absolutePath: string, - typeRegistry: FileTypeRegistry, -): boolean { - const filename = relativePath.split("/").pop() || relativePath; - - if (gitignore?.matches(absolutePath, false)) { - return false; - } - - if ( - options.types.length > 0 && - !typeRegistry.matchesType(filename, options.types) - ) { - return false; - } - - if ( - options.typesNot.length > 0 && - typeRegistry.matchesType(filename, options.typesNot) - ) { - return false; - } - - if (options.globs.length > 0) { - const ignoreCase = options.globCaseInsensitive; - const positiveGlobs = options.globs.filter((g) => !g.startsWith("!")); - const negativeGlobs = options.globs - .filter((g) => g.startsWith("!")) - .map((g) => g.slice(1)); - - if (positiveGlobs.length > 0) { - let matchesPositive = false; - for (const glob of positiveGlobs) { - if ( - matchGlob(filename, glob, ignoreCase) || - matchGlob(relativePath, glob, ignoreCase) - ) { - matchesPositive = true; - break; - } - } - if (!matchesPositive) { - return false; - } - } - - for (const glob of negativeGlobs) { - if (glob.startsWith("/")) { - const rootedGlob = glob.slice(1); - if (matchGlob(relativePath, rootedGlob, ignoreCase)) { - return false; - } - } else if ( - matchGlob(filename, glob, ignoreCase) || - matchGlob(relativePath, glob, ignoreCase) - ) { - return false; - } - } - } - - // Handle iglobs (case-insensitive globs) - if (options.iglobs.length > 0) { - const positiveIglobs = options.iglobs.filter((g) => !g.startsWith("!")); - const negativeIglobs = options.iglobs - .filter((g) => g.startsWith("!")) - .map((g) => g.slice(1)); - - if (positiveIglobs.length > 0) { - let matchesPositive = false; - for (const glob of positiveIglobs) { - if ( - matchGlob(filename, glob, true) || - matchGlob(relativePath, glob, true) - ) { - matchesPositive = true; - break; - } - } - if (!matchesPositive) { - return false; - } - } - - for (const glob of negativeIglobs) { - if (glob.startsWith("/")) { - const rootedGlob = glob.slice(1); - if (matchGlob(relativePath, rootedGlob, true)) { - return false; - } - } else if ( - matchGlob(filename, glob, true) || - matchGlob(relativePath, glob, true) - ) { - return false; - } - } - } - - return true; -} - -/** - * Simple glob matching - */ -function matchGlob(str: string, pattern: string, ignoreCase = false): boolean { - let regexStr = "^"; - for (let i = 0; i < pattern.length; i++) { - const char = pattern[i]; - if (char === "*") { - if (pattern[i + 1] === "*") { - regexStr += ".*"; - i++; - } else { - regexStr += "[^/]*"; - } - } else if (char === "?") { - regexStr += "[^/]"; - } else if (char === "[") { - let j = i + 1; - if (j < pattern.length && pattern[j] === "!") j++; - if (j < pattern.length && pattern[j] === "]") j++; - while (j < pattern.length && pattern[j] !== "]") j++; - if (j < pattern.length) { - let charClass = pattern.slice(i, j + 1); - if (charClass.startsWith("[!")) { - charClass = `[^${charClass.slice(2)}`; - } - regexStr += charClass; - i = j; - } else { - regexStr += "\\["; - } - } else { - regexStr += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - } - } - regexStr += "$"; - - return createUserRegex(regexStr, ignoreCase ? "i" : "").test(str); -} - -/** - * List files that would be searched (--files mode) - */ -async function listFiles( - ctx: CommandContext, - inputPaths: string[], - options: RgOptions, -): Promise { - // Load gitignore files - let gitignore: GitignoreManager | null = null; - if (!options.noIgnore) { - gitignore = await loadGitignores( - ctx.fs, - ctx.cwd, - options.noIgnoreDot, - options.noIgnoreVcs, - options.ignoreFiles, - ); - } - - // Create file type registry and apply --type-clear and --type-add - const typeRegistry = new FileTypeRegistry(); - for (const name of options.typeClear) { - typeRegistry.clearType(name); - } - for (const spec of options.typeAdd) { - typeRegistry.addType(spec); - } - - // Default to current directory - const paths = inputPaths.length === 0 ? ["."] : inputPaths; - - // Collect files - const { files } = await collectFiles( - ctx, - paths, - options, - gitignore, - typeRegistry, - ); - - if (files.length === 0) { - return { stdout: "", stderr: "", exitCode: 1 }; - } - - // In quiet mode, just indicate success without output - if (options.quiet) { - return { stdout: "", stderr: "", exitCode: 0 }; - } - - // Output file list - const sep = options.nullSeparator ? "\0" : "\n"; - const stdout = files.map((f) => f + sep).join(""); - - return { stdout, stderr: "", exitCode: 0 }; -} - -/** - * Check if a file matches any pre-glob patterns - */ -function matchesPreGlob(filename: string, preGlobs: string[]): boolean { - if (preGlobs.length === 0) return true; // No patterns = match all - - for (const glob of preGlobs) { - if (matchGlob(filename, glob, false)) { - return true; - } - } - return false; -} - -/** - * Read file content, handling preprocessing and gzip decompression if needed - */ -async function readFileContent( - ctx: CommandContext, - filePath: string, - file: string, - options: RgOptions, -): Promise<{ content: string; isBinary: boolean } | null> { - try { - // Check for preprocessing with --pre - if (options.preprocessor && ctx.exec) { - const filename = file.split("/").pop() || file; - if (matchesPreGlob(filename, options.preprocessorGlobs)) { - // Run preprocessor on this file - const result = await ctx.exec(`${options.preprocessor} "${filePath}"`, { - cwd: ctx.cwd, - }); - if (result.exitCode === 0 && result.stdout) { - const sample = result.stdout.slice(0, 8192); - return { content: result.stdout, isBinary: sample.includes("\0") }; - } - // Preprocessing failed, fall through to normal file read - } - } - - // For -z option, try to decompress gzip files - if (options.searchZip && file.endsWith(".gz")) { - const buffer = await ctx.fs.readFileBuffer(filePath); - if (isGzip(buffer)) { - try { - const decompressed = gunzipSync(buffer); - const content = new TextDecoder().decode(decompressed); - const sample = content.slice(0, 8192); - return { content, isBinary: sample.includes("\0") }; - } catch { - return null; // Decompression failed - } - } - } - - // Regular file read - const content = await ctx.fs.readFile(filePath); - const sample = content.slice(0, 8192); - return { content, isBinary: sample.includes("\0") }; - } catch { - return null; - } -} - -/** - * Format a single match for JSON output - */ -interface JsonSubmatch { - match: { text: string }; - start: number; - end: number; - replacement?: { text: string }; -} - -interface JsonMatch { - type: "match"; - data: { - path: { text: string }; - lines: { text: string }; - line_number: number; - absolute_offset: number; - submatches: JsonSubmatch[]; - }; -} - -/** - * Search files and produce output - */ -async function searchFiles( - ctx: CommandContext, - files: string[], - regex: UserRegex, - options: RgOptions, - showFilename: boolean, - effectiveLineNumbers: boolean, - kResetGroup?: number, -): Promise { - let stdout = ""; - let anyMatch = false; - - // JSON mode tracking - const jsonMessages: string[] = []; - let totalMatches = 0; - let filesWithMatch = 0; - let bytesSearched = 0; - - const BATCH_SIZE = 50; - outer: for (let i = 0; i < files.length; i += BATCH_SIZE) { - const batch = files.slice(i, i + BATCH_SIZE); - - const results = await Promise.all( - batch.map(async (file) => { - const filePath = ctx.fs.resolvePath(ctx.cwd, file); - const fileData = await readFileContent(ctx, filePath, file, options); - - if (!fileData) return null; - - const { content, isBinary } = fileData; - bytesSearched += content.length; - - // Skip binary files unless -a/--text is specified - if (isBinary && !options.searchBinary) { - return null; - } - - const filenameForSearch = showFilename && !options.heading ? file : ""; - - const result = searchContent(content, regex, { - invertMatch: options.invertMatch, - showLineNumbers: effectiveLineNumbers, - countOnly: options.count, - countMatches: options.countMatches, - filename: filenameForSearch, - onlyMatching: options.onlyMatching, - beforeContext: options.beforeContext, - afterContext: options.afterContext, - maxCount: options.maxCount, - contextSeparator: options.contextSeparator, - showColumn: options.column, - vimgrep: options.vimgrep, - showByteOffset: options.byteOffset, - replace: - options.replace !== null - ? convertReplacement(options.replace) - : null, - passthru: options.passthru, - multiline: options.multiline, - kResetGroup, - }); - - // For JSON mode, we need to track matches differently - if (options.json && result.matched) { - return { file, result, content, isBinary: false }; - } - - return { file, result }; - }), - ); - - for (const res of results) { - if (!res) continue; - - const { file, result } = res; - - if (result.matched) { - anyMatch = true; - filesWithMatch++; - totalMatches += result.matchCount; - - if (options.quiet && !options.json) { - // Quiet mode without JSON: exit early on first match - break outer; - } - - if (options.json && !options.quiet) { - // JSON mode without quiet: output begin/match/end messages - const content = (res as { content?: string }).content || ""; - jsonMessages.push( - JSON.stringify({ type: "begin", data: { path: { text: file } } }), - ); - - // Find matches and output them - const lines = content.split("\n"); - regex.lastIndex = 0; - let lineOffset = 0; - for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { - const line = lines[lineIdx]; - regex.lastIndex = 0; - const submatches: JsonSubmatch[] = []; - - for ( - let match = regex.exec(line); - match !== null; - match = regex.exec(line) - ) { - const submatch: JsonSubmatch = { - match: { text: match[0] }, - start: match.index, - end: match.index + match[0].length, - }; - if (options.replace !== null) { - submatch.replacement = { text: options.replace }; - } - submatches.push(submatch); - if (match[0].length === 0) regex.lastIndex++; - } - - if (submatches.length > 0) { - const matchMsg: JsonMatch = { - type: "match", - data: { - path: { text: file }, - lines: { text: `${line}\n` }, - line_number: lineIdx + 1, - absolute_offset: lineOffset, - submatches, - }, - }; - jsonMessages.push(JSON.stringify(matchMsg)); - } - lineOffset += line.length + 1; - } - - jsonMessages.push( - JSON.stringify({ - type: "end", - data: { - path: { text: file }, - binary_offset: null, - stats: { - elapsed: { secs: 0, nanos: 0, human: "0s" }, - searches: 1, - searches_with_match: 1, - bytes_searched: content.length, - bytes_printed: 0, - matched_lines: result.matchCount, - matches: result.matchCount, - }, - }, - }), - ); - } else if (options.filesWithMatches) { - const sep = options.nullSeparator ? "\0" : "\n"; - stdout += `${file}${sep}`; - } else if (!options.filesWithoutMatch) { - // In heading mode, always show filename header (even for single files) - if (options.heading && !options.noFilename) { - stdout += `${file}\n`; - } - stdout += result.output; - } - } else if (options.filesWithoutMatch) { - const sep = options.nullSeparator ? "\0" : "\n"; - stdout += `${file}${sep}`; - } else if ( - options.includeZero && - (options.count || options.countMatches) - ) { - stdout += result.output; - } - } - } - - // Finalize JSON output - if (options.json) { - jsonMessages.push( - JSON.stringify({ - type: "summary", - data: { - elapsed_total: { secs: 0, nanos: 0, human: "0s" }, - stats: { - elapsed: { secs: 0, nanos: 0, human: "0s" }, - searches: files.length, - searches_with_match: filesWithMatch, - bytes_searched: bytesSearched, - bytes_printed: 0, - matched_lines: totalMatches, - matches: totalMatches, - }, - }, - }), - ); - stdout = `${jsonMessages.join("\n")}\n`; - } - - // In JSON + quiet mode, output only the summary (already built above) - // In non-JSON quiet mode, output nothing - let finalStdout = options.quiet && !options.json ? "" : stdout; - - // Add stats output if requested - if (options.stats && !options.json) { - const statsOutput = [ - "", - `${totalMatches} matches`, - `${totalMatches} matched lines`, - `${filesWithMatch} files contained matches`, - `${files.length} files searched`, - `${bytesSearched} bytes searched`, - ].join("\n"); - finalStdout += `${statsOutput}\n`; - } - - // Exit codes: - // - For --files-without-match: 0 if files without matches found, 1 otherwise - // - For normal mode: 0 if any matches found, 1 otherwise - let exitCode: number; - if (options.filesWithoutMatch) { - // Success means we found files without matches (stdout has content) - exitCode = stdout.length > 0 ? 0 : 1; - } else { - exitCode = anyMatch ? 0 : 1; - } - - return { - stdout: finalStdout, - stderr: "", - exitCode, - }; -} diff --git a/src/commands/rg/rg.basic.test.ts b/src/commands/rg/rg.basic.test.ts deleted file mode 100644 index d816cd46..00000000 --- a/src/commands/rg/rg.basic.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("rg basic search", () => { - it("should search for pattern in current directory", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello world\nfoo bar\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:hello world\n"); - expect(result.stderr).toBe(""); - }); - - it("should search multiple files and sort output", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a.txt": "hello\n", - "/home/user/b.txt": "hello\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a.txt:1:hello\nb.txt:1:hello\n"); - expect(result.stderr).toBe(""); - }); - - it("should search in specified path", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/src/app.ts": "const hello = 'world';\n", - "/home/user/README.md": "# Hello\n", - }, - }); - const result = await bash.exec("rg hello src"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("src/app.ts:1:const hello = 'world';\n"); - expect(result.stderr).toBe(""); - }); - - it("should return exit code 1 when no matches", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello world\n", - }, - }); - const result = await bash.exec("rg nomatch"); - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - }); - - it("should show line numbers by default", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "line1\nhello\nline3\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:2:hello\n"); - expect(result.stderr).toBe(""); - }); - - it("should hide line numbers with -N", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello world\n", - }, - }); - const result = await bash.exec("rg -N hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:hello world\n"); - expect(result.stderr).toBe(""); - }); - - it("should search in subdirectories", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/src/lib/util.ts": "export const hello = 1;\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("src/lib/util.ts:1:export const hello = 1;\n"); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg case sensitivity", () => { - it("should use smart case by default (lowercase = case-insensitive)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "Hello World\nhello world\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "file.txt:1:Hello World\nfile.txt:2:hello world\n", - ); - expect(result.stderr).toBe(""); - }); - - it("should use smart case (uppercase in pattern = case-sensitive)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "Hello World\nhello world\n", - }, - }); - const result = await bash.exec("rg Hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:Hello World\n"); - expect(result.stderr).toBe(""); - }); - - it("should be case-insensitive with -i", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "Hello World\nhello world\nHELLO WORLD\n", - }, - }); - const result = await bash.exec("rg -i HELLO"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "file.txt:1:Hello World\nfile.txt:2:hello world\nfile.txt:3:HELLO WORLD\n", - ); - expect(result.stderr).toBe(""); - }); - - it("should be case-sensitive with -s (override smart case)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "Hello World\nhello world\n", - }, - }); - const result = await bash.exec("rg -s hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:2:hello world\n"); - expect(result.stderr).toBe(""); - }); - - it("should override smart case with -i when pattern has uppercase", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "Hello World\nhello world\n", - }, - }); - const result = await bash.exec("rg -i Hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "file.txt:1:Hello World\nfile.txt:2:hello world\n", - ); - expect(result.stderr).toBe(""); - }); - - it("should use smart case with numbers only (case-insensitive)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "ABC123\nabc123\n", - }, - }); - const result = await bash.exec("rg 123"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:ABC123\nfile.txt:2:abc123\n"); - expect(result.stderr).toBe(""); - }); - - it("should use smart case with symbols only (case-insensitive)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "foo::bar\nFOO::BAR\n", - }, - }); - const result = await bash.exec("rg -F '::'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:foo::bar\nfile.txt:2:FOO::BAR\n"); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg binary files", () => { - it("should skip binary files by default", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/text.txt": "hello\n", - "/home/user/binary.bin": "hello\x00world\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("text.txt:1:hello\n"); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg max depth", () => { - it("should limit search depth with --max-depth", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/level0.txt": "hello\n", - "/home/user/dir1/level1.txt": "hello\n", - "/home/user/dir1/dir2/level2.txt": "hello\n", - }, - }); - // ripgrep: --max-depth N includes files at depths 0 through N-1 - // --max-depth 2 includes depth 0 and 1 - const result = await bash.exec("rg --max-depth 2 hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("dir1/level1.txt:1:hello\nlevel0.txt:1:hello\n"); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg error handling", () => { - it("should error on missing pattern", async () => { - const bash = new Bash(); - const result = await bash.exec("rg"); - expect(result.exitCode).toBe(2); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe("rg: no pattern given\n"); - }); - - it("should error on unknown option", async () => { - const bash = new Bash(); - const result = await bash.exec("rg --unknown-option pattern"); - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe("rg: unrecognized option '--unknown-option'\n"); - }); - - it("should return no matches for unknown type", async () => { - // Unknown types don't produce an error - they just match no files - // This allows --type-add to define custom types - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello\n", - }, - }); - const result = await bash.exec("rg -t unknowntype hello"); - expect(result.exitCode).toBe(1); // No matches - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - }); -}); diff --git a/src/commands/rg/rg.edge-cases.test.ts b/src/commands/rg/rg.edge-cases.test.ts deleted file mode 100644 index 2d5e1f8c..00000000 --- a/src/commands/rg/rg.edge-cases.test.ts +++ /dev/null @@ -1,441 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("rg empty and whitespace", () => { - it("should handle empty file", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/empty.txt": "", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - }); - - it("should handle file with only newlines", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "\n\n\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - }); - - it("should match empty lines with ^$", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "foo\n\nbar\n", - }, - }); - const result = await bash.exec("rg '^$'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:2:\n"); - expect(result.stderr).toBe(""); - }); - - it("should handle file with trailing whitespace", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello \nworld\n", - }, - }); - const result = await bash.exec("rg 'hello '"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:hello \n"); - expect(result.stderr).toBe(""); - }); - - it("should handle file with only whitespace", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": " \n \n", - }, - }); - const result = await bash.exec("rg '^ +$'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1: \nfile.txt:2: \n"); - expect(result.stderr).toBe(""); - }); -}); - -// Note: -m (max count) tests removed - feature not yet implemented - -describe("rg special characters", () => { - it("should match literal dots with -F", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "a.b.c\nabc\n", - }, - }); - const result = await bash.exec("rg -F 'a.b'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:a.b.c\n"); - expect(result.stderr).toBe(""); - }); - - it("should match literal brackets with -F", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "array[0]\narray0\n", - }, - }); - const result = await bash.exec("rg -F '[0]'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:array[0]\n"); - expect(result.stderr).toBe(""); - }); - - it("should match literal parens with -F", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "func()\nfunc\n", - }, - }); - const result = await bash.exec("rg -F '()'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:func()\n"); - expect(result.stderr).toBe(""); - }); - - it("should match literal asterisks with -F", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "a*b\nab\naab\n", - }, - }); - const result = await bash.exec("rg -F 'a*b'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:a*b\n"); - expect(result.stderr).toBe(""); - }); - - it("should match backslashes with -F", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "path\\to\\file\npath/to/file\n", - }, - }); - // Search for "path\" which appears at start of backslash path - const result = await bash.exec("rg -F 'path\\'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:path\\to\\file\n"); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg line boundaries", () => { - it("should match start of line only once", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello\n", - }, - }); - const result = await bash.exec("rg -o '^'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:\n"); - expect(result.stderr).toBe(""); - }); - - it("should not match end of line at end of file without newline", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello", - }, - }); - const result = await bash.exec("rg 'hello$'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:hello\n"); - expect(result.stderr).toBe(""); - }); - - it("should match anchored pattern at start", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": " hello\nhello\n", - }, - }); - const result = await bash.exec("rg '^hello'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:2:hello\n"); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg unicode", () => { - it("should match unicode characters", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello 世界\nfoo bar\n", - }, - }); - const result = await bash.exec("rg 世界"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:hello 世界\n"); - expect(result.stderr).toBe(""); - }); - - it("should match emoji", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello 🎉\nfoo bar\n", - }, - }); - const result = await bash.exec("rg 🎉"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:hello 🎉\n"); - expect(result.stderr).toBe(""); - }); - - it("should handle case-insensitive unicode with -i", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "CAFÉ\ncafé\n", - }, - }); - const result = await bash.exec("rg -i café"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:CAFÉ\nfile.txt:2:café\n"); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg multiple files ordering", () => { - it("should output files in sorted order", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/z.txt": "hello\n", - "/home/user/a.txt": "hello\n", - "/home/user/m.txt": "hello\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a.txt:1:hello\nm.txt:1:hello\nz.txt:1:hello\n"); - expect(result.stderr).toBe(""); - }); - - it("should handle nested directories in sorted order", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/z/file.txt": "hello\n", - "/home/user/a/file.txt": "hello\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a/file.txt:1:hello\nz/file.txt:1:hello\n"); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg exit codes", () => { - it("should return 0 when match found", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - }); - - it("should return 1 when no match found", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello\n", - }, - }); - const result = await bash.exec("rg goodbye"); - expect(result.exitCode).toBe(1); - }); - - it("should return 2 on invalid regex", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello\n", - }, - }); - const result = await bash.exec("rg '['"); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("invalid regex"); - }); - - it("should return 0 on match found with -q", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello\n", - }, - }); - const result = await bash.exec("rg -q hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - }); -}); - -describe("rg word boundaries", () => { - it("should match word at start of line with -w", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello world\n", - }, - }); - const result = await bash.exec("rg -w hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:hello world\n"); - expect(result.stderr).toBe(""); - }); - - it("should match word at end of line with -w", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello world\n", - }, - }); - const result = await bash.exec("rg -w world"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:hello world\n"); - expect(result.stderr).toBe(""); - }); - - it("should not match word within word with -w", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "helloworld\nhello world\n", - }, - }); - const result = await bash.exec("rg -w hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:2:hello world\n"); - expect(result.stderr).toBe(""); - }); - - it("should match word with punctuation boundary with -w", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello, world\nhello.world\n", - }, - }); - const result = await bash.exec("rg -w hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "file.txt:1:hello, world\nfile.txt:2:hello.world\n", - ); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg inverted context", () => { - it("should show context around non-matching lines with -v", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "a\nb\nc\nd\ne\n", - }, - }); - // With -v, lines NOT containing 'c' match (a, b, d, e) - // Context includes the 'c' line as context for surrounding matches - const result = await bash.exec("rg -v -C1 c"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "file.txt:1:a\nfile.txt-2-b\nfile.txt-3-c\nfile.txt:4:d\nfile.txt-5-e\n", - ); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg gitignore edge cases", () => { - it("should handle comments in gitignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "# This is a comment\n*.log\n", - "/home/user/app.ts": "hello\n", - "/home/user/debug.log": "hello\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("app.ts:1:hello\n"); - expect(result.stderr).toBe(""); - }); - - it("should handle blank lines in gitignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "*.log\n\n*.tmp\n", - "/home/user/app.ts": "hello\n", - "/home/user/debug.log": "hello\n", - "/home/user/cache.tmp": "hello\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("app.ts:1:hello\n"); - expect(result.stderr).toBe(""); - }); - - // Note: Escaped hash in gitignore (\#file.txt) test removed - feature not yet implemented -}); - -describe("rg glob edge cases", () => { - it("should handle glob with path separator", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/src/app.ts": "hello\n", - "/home/user/test/app.ts": "hello\n", - }, - }); - const result = await bash.exec("rg -g 'src/*.ts' hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("src/app.ts:1:hello\n"); - expect(result.stderr).toBe(""); - }); - - it("should handle multiple globs", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a.ts": "hello\n", - "/home/user/b.js": "hello\n", - "/home/user/c.py": "hello\n", - }, - }); - const result = await bash.exec("rg -g '*.ts' -g '*.js' hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a.ts:1:hello\nb.js:1:hello\n"); - expect(result.stderr).toBe(""); - }); -}); diff --git a/src/commands/rg/rg.filtering.test.ts b/src/commands/rg/rg.filtering.test.ts deleted file mode 100644 index 93873574..00000000 --- a/src/commands/rg/rg.filtering.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("rg file type filtering", () => { - it("should filter by type with -t", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/app.ts": "const foo = 1;\n", - "/home/user/app.js": "const foo = 2;\n", - "/home/user/style.css": "foo { }\n", - }, - }); - const result = await bash.exec("rg -t ts foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("app.ts:1:const foo = 1;\n"); - expect(result.stderr).toBe(""); - }); - - it("should exclude type with -T", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/app.ts": "const foo = 1;\n", - "/home/user/app.js": "const foo = 2;\n", - }, - }); - const result = await bash.exec("rg -T ts foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("app.js:1:const foo = 2;\n"); - expect(result.stderr).toBe(""); - }); - - it("should list types with --type-list", async () => { - const bash = new Bash(); - const result = await bash.exec("rg --type-list"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("js:"); - expect(result.stdout).toContain("ts:"); - expect(result.stdout).toContain("py:"); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg glob filtering", () => { - it("should filter files with -g", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/app.ts": "const foo = 1;\n", - "/home/user/app.js": "const foo = 2;\n", - }, - }); - const result = await bash.exec("rg -g '*.ts' foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("app.ts:1:const foo = 1;\n"); - expect(result.stderr).toBe(""); - }); - - it("should support negated globs", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/app.ts": "const foo = 1;\n", - "/home/user/test.ts": "const foo = 2;\n", - }, - }); - const result = await bash.exec("rg -g '!test.ts' foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("app.ts:1:const foo = 1;\n"); - expect(result.stderr).toBe(""); - }); - - it("should match multiple files with glob", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a.ts": "foo\n", - "/home/user/b.ts": "foo\n", - "/home/user/c.js": "foo\n", - }, - }); - const result = await bash.exec("rg -g '*.ts' foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a.ts:1:foo\nb.ts:1:foo\n"); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg hidden files", () => { - it("should skip hidden files by default", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/visible.txt": "hello\n", - "/home/user/.hidden.txt": "hello\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("visible.txt:1:hello\n"); - expect(result.stderr).toBe(""); - }); - - it("should include hidden files with --hidden", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/visible.txt": "hello\n", - "/home/user/.hidden.txt": "hello\n", - }, - }); - const result = await bash.exec("rg --hidden hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(".hidden.txt:1:hello\nvisible.txt:1:hello\n"); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg gitignore", () => { - it("should respect .gitignore patterns", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "*.log\n", - "/home/user/app.ts": "hello\n", - "/home/user/debug.log": "hello\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("app.ts:1:hello\n"); - expect(result.stderr).toBe(""); - }); - - it("should include ignored files with --no-ignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "*.log\n", - "/home/user/app.ts": "hello\n", - "/home/user/debug.log": "hello\n", - }, - }); - const result = await bash.exec("rg --no-ignore hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("app.ts:1:hello\ndebug.log:1:hello\n"); - expect(result.stderr).toBe(""); - }); - - it("should handle negation patterns", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "*.log\n!important.log\n", - "/home/user/debug.log": "hello\n", - "/home/user/important.log": "hello\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("important.log:1:hello\n"); - expect(result.stderr).toBe(""); - }); - - it("should handle directory patterns", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "build/\n", - "/home/user/src/app.ts": "hello\n", - "/home/user/build/output.js": "hello\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("src/app.ts:1:hello\n"); - expect(result.stderr).toBe(""); - }); - - it("should apply parent gitignore to subdirectories", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "*.log\n", - "/home/user/app.ts": "hello\n", - "/home/user/subdir/file.ts": "hello\n", - "/home/user/subdir/debug.log": "hello\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("app.ts:1:hello\nsubdir/file.ts:1:hello\n"); - expect(result.stderr).toBe(""); - }); - - it("should handle directory trailing slash vs similar prefix", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "node_modules/\n", - "/home/user/node_modules/pkg/index.js": "hello\n", - "/home/user/node_modules_backup/file.js": "hello\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("node_modules_backup/file.js:1:hello\n"); - expect(result.stderr).toBe(""); - }); - - it("should handle double-star patterns", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "**/cache/**\n", - "/home/user/src/app.ts": "hello\n", - "/home/user/src/cache/data.json": "hello\n", - "/home/user/cache/index.json": "hello\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("src/app.ts:1:hello\n"); - expect(result.stderr).toBe(""); - }); -}); diff --git a/src/commands/rg/rg.flags.test.ts b/src/commands/rg/rg.flags.test.ts deleted file mode 100644 index 8cf1629b..00000000 --- a/src/commands/rg/rg.flags.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Tests for rg feature flags: -L (symlinks), -u (unrestricted), -a (text/binary) - * - * These are custom tests for features we implemented, not imported from ripgrep. - */ - -import { describe, expect, it } from "vitest"; -import { Bash } from "../../index.js"; - -describe("rg -L (follow symlinks)", () => { - it("should accept -L/--follow flag without error", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello world\n", - }, - }); - const result = await bash.exec("rg -L hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:hello world\n"); - }); - - it("should accept --follow flag without error", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello world\n", - }, - }); - const result = await bash.exec("rg --follow hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:hello world\n"); - }); - - it("should skip symlinks by default in directory search", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/real.txt": "hello\n", - }, - }); - await bash.exec("ln -s real.txt /home/user/link.txt"); - - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("real.txt:1:hello\n"); - }); - - it("should follow symlinks with -L in directory search", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/real.txt": "hello\n", - }, - }); - await bash.exec("ln -s real.txt /home/user/link.txt"); - - const result = await bash.exec("rg -L --sort path hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("link.txt:1:hello\nreal.txt:1:hello\n"); - }); - - it("should follow symlinks to directories with -L", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/subdir/file.txt": "hello\n", - }, - }); - await bash.exec("ln -s subdir /home/user/linkdir"); - - // Without -L, should only find file in real directory - let result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("subdir/file.txt:1:hello\n"); - - // With -L, should find file through both paths - result = await bash.exec("rg -L --sort path hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "linkdir/file.txt:1:hello\nsubdir/file.txt:1:hello\n", - ); - }); -}); - -describe("rg -u (unrestricted)", () => { - it("should ignore gitignore with -u", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "ignored.txt\n", - "/home/user/ignored.txt": "hello\n", - "/home/user/visible.txt": "hello\n", - }, - }); - // Without -u, ignored.txt should not be searched - let result = await bash.exec("rg hello"); - expect(result.stdout).toBe("visible.txt:1:hello\n"); - - // With -u, ignored.txt should be searched - result = await bash.exec("rg -u --sort path hello"); - expect(result.stdout).toBe("ignored.txt:1:hello\nvisible.txt:1:hello\n"); - }); - - it("should search hidden files with -uu", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.hidden": "hello\n", - "/home/user/visible.txt": "hello\n", - }, - }); - // Without -uu, .hidden should not be searched - let result = await bash.exec("rg hello"); - expect(result.stdout).toBe("visible.txt:1:hello\n"); - - // With -uu (--no-ignore --hidden), .hidden should be searched - result = await bash.exec("rg -uu --sort path hello"); - expect(result.stdout).toBe(".hidden:1:hello\nvisible.txt:1:hello\n"); - }); - - it("-u should be equivalent to --no-ignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "ignored.txt\n", - "/home/user/ignored.txt": "hello\n", - }, - }); - const resultU = await bash.exec("rg -u hello"); - const resultNoIgnore = await bash.exec("rg --no-ignore hello"); - expect(resultU.stdout).toBe(resultNoIgnore.stdout); - }); - - it("-uu should be equivalent to --no-ignore --hidden", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.hidden": "hello\n", - }, - }); - const resultUU = await bash.exec("rg -uu hello"); - const resultFlags = await bash.exec("rg --no-ignore --hidden hello"); - expect(resultUU.stdout).toBe(resultFlags.stdout); - }); -}); - -describe("rg -a (text/binary)", () => { - it("should search binary files as text with -a", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/binary.bin": "hello\x00world\n", - }, - }); - // Without -a, binary should be skipped - let result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - - // With -a, binary should be searched - result = await bash.exec("rg -a hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("binary.bin:1:hello\x00world\n"); - }); -}); diff --git a/src/commands/rg/rg.max-count.test.ts b/src/commands/rg/rg.max-count.test.ts deleted file mode 100644 index 7d380acf..00000000 --- a/src/commands/rg/rg.max-count.test.ts +++ /dev/null @@ -1,408 +0,0 @@ -/** - * Tests for rg -m/--max-count flag - * - * The -m/--max-count flag limits the number of matching lines per file. - */ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("rg -m/--max-count basic functionality", () => { - it("should stop after 1 match with -m1", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "foo\nfoo\nfoo\nfoo\n", - }, - }); - const result = await bash.exec("rg -m1 foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:foo\n"); - }); - - it("should stop after 2 matches with -m2", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "foo\nbar\nfoo\nbar\nfoo\n", - }, - }); - const result = await bash.exec("rg -m2 foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:foo\nfile.txt:3:foo\n"); - }); - - it("should stop after 3 matches with -m 3", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "test\ntest\ntest\ntest\ntest\n", - }, - }); - const result = await bash.exec("rg -m 3 test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "file.txt:1:test\nfile.txt:2:test\nfile.txt:3:test\n", - ); - }); - - it("should work with --max-count=N syntax", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "abc\nabc\nabc\nabc\n", - }, - }); - const result = await bash.exec("rg --max-count=2 abc"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:abc\nfile.txt:2:abc\n"); - }); - - it("should work with --max-count N syntax", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "xyz\nxyz\nxyz\n", - }, - }); - const result = await bash.exec("rg --max-count 1 xyz"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:xyz\n"); - }); - - it("should show all matches when count exceeds matches", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "foo\nbar\n", - }, - }); - const result = await bash.exec("rg -m100 foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:foo\n"); - }); - - it("should return exit code 1 when no matches", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "foo\nbar\n", - }, - }); - const result = await bash.exec("rg -m1 notfound"); - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - }); -}); - -describe("rg -m with multiple files", () => { - it("should limit matches per file independently", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a.txt": "match\nmatch\nmatch\n", - "/home/user/b.txt": "match\nmatch\nmatch\n", - }, - }); - const result = await bash.exec("rg -m1 match"); - expect(result.exitCode).toBe(0); - // Each file should have only 1 match - expect(result.stdout).toBe("a.txt:1:match\nb.txt:1:match\n"); - }); - - it("should limit to 2 matches per file across multiple files", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file1.txt": "foo\nfoo\nfoo\nfoo\n", - "/home/user/file2.txt": "foo\nfoo\nfoo\n", - }, - }); - const result = await bash.exec("rg -m2 foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "file1.txt:1:foo\nfile1.txt:2:foo\nfile2.txt:1:foo\nfile2.txt:2:foo\n", - ); - }); - - it("should work when some files have fewer matches than limit", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/many.txt": "x\nx\nx\nx\nx\n", - "/home/user/few.txt": "x\n", - }, - }); - const result = await bash.exec("rg -m3 x"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "few.txt:1:x\nmany.txt:1:x\nmany.txt:2:x\nmany.txt:3:x\n", - ); - }); -}); - -describe("rg -m with single file search", () => { - it("should limit matches in single file mode", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/data.txt": "line1\nline2\nline3\nline4\nline5\n", - }, - }); - const result = await bash.exec("rg -m2 line data.txt"); - expect(result.exitCode).toBe(0); - // Single file = no filename prefix, no line numbers by default - expect(result.stdout).toBe("line1\nline2\n"); - }); - - it("should limit matches with line numbers in single file", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/data.txt": "a\na\na\na\na\n", - }, - }); - const result = await bash.exec("rg -n -m2 a data.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:a\n2:a\n"); - }); -}); - -describe("rg -m with other flags", () => { - it("should work with -c (count)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "x\nx\nx\nx\nx\n", - }, - }); - // Note: -c counts all matches, -m doesn't affect the count in ripgrep - // But our implementation limits before counting - const result = await bash.exec("rg -c x"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:5\n"); - }); - - it("should work with -v (invert match)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "foo\nbar\nfoo\nbaz\nfoo\n", - }, - }); - const result = await bash.exec("rg -m2 -v foo"); - expect(result.exitCode).toBe(0); - // Lines NOT matching "foo", limited to 2 - expect(result.stdout).toBe("file.txt:2:bar\nfile.txt:4:baz\n"); - }); - - it("should work with -i (case insensitive)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "Foo\nFOO\nfoo\nFoO\n", - }, - }); - const result = await bash.exec("rg -m2 -i foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:Foo\nfile.txt:2:FOO\n"); - }); - - it("should work with -w (word match)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "foo bar\nfoobar\nbar foo\nbaz foo baz\n", - }, - }); - const result = await bash.exec("rg -m2 -w foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:foo bar\nfile.txt:3:bar foo\n"); - }); - - it("should work with -o (only matching)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "abc123def\nabc456def\nabc789def\n", - }, - }); - const result = await bash.exec("rg -m2 -o '[0-9]+'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:123\nfile.txt:456\n"); - }); - - it("should work with -l (files with matches)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a.txt": "test\ntest\ntest\n", - "/home/user/b.txt": "test\n", - }, - }); - // -l just lists files, -m shouldn't affect output but may affect early exit - const result = await bash.exec("rg -m1 -l test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a.txt\nb.txt\n"); - }); - - it("should work with -q (quiet mode)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "find me\nfind me\nfind me\n", - }, - }); - const result = await bash.exec("rg -m1 -q 'find me'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - }); -}); - -describe("rg -m with context lines", () => { - it("should work with -A (after context)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": - "match1\nafter1\nmatch2\nafter2\nmatch3\nafter3\n", - }, - }); - const result = await bash.exec("rg -m1 -A1 match file.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("match1\nafter1\n"); - }); - - it("should work with -B (before context)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": - "before1\nmatch1\nbefore2\nmatch2\nbefore3\nmatch3\n", - }, - }); - const result = await bash.exec("rg -m1 -B1 match file.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("before1\nmatch1\n"); - }); - - it("should work with -C (context)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": - "ctx1\nmatch1\nctx2\nmatch2\nctx3\nmatch3\nctx4\n", - }, - }); - const result = await bash.exec("rg -m1 -C1 match file.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("ctx1\nmatch1\nctx2\n"); - }); - - it("should limit matches not context lines", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": - "a\nb\nmatch\nc\nd\ne\nf\nmatch\ng\nh\ni\nj\nmatch\nk\n", - }, - }); - const result = await bash.exec("rg -m2 -A2 match file.txt"); - expect(result.exitCode).toBe(0); - // 2 matches with 2 lines of after context each, plus separator - expect(result.stdout).toContain("match"); - const lines = result.stdout.trim().split("\n"); - // Should have: match1, c, d, --, match2, g, h - expect(lines.length).toBe(7); - expect(lines[3]).toBe("--"); - }); -}); - -describe("rg -m edge cases", () => { - it("should handle -m0 as unlimited", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "a\na\na\n", - }, - }); - const result = await bash.exec("rg -m0 a"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:a\nfile.txt:2:a\nfile.txt:3:a\n"); - }); - - it("should handle large -m value", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "test\ntest\n", - }, - }); - const result = await bash.exec("rg -m999999 test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:test\nfile.txt:2:test\n"); - }); - - it("should work with empty file", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/empty.txt": "", - }, - }); - const result = await bash.exec("rg -m1 test"); - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - }); - - it("should work with file type filter", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/code.js": "const x = 1;\nconst y = 2;\nconst z = 3;\n", - "/home/user/code.py": "const = 'not js'\n", - }, - }); - const result = await bash.exec("rg -m1 -t js const"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("code.js:1:const x = 1;\n"); - }); - - it("should work with glob filter", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test.log": "error\nerror\nerror\n", - "/home/user/test.txt": "error\n", - }, - }); - const result = await bash.exec("rg -m1 -g '*.log' error"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test.log:1:error\n"); - }); -}); - -describe("rg -m with regex patterns", () => { - it("should limit regex matches", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "cat\ndog\ncat\nbird\ncat\n", - }, - }); - const result = await bash.exec("rg -m2 'cat|dog'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:cat\nfile.txt:2:dog\n"); - }); - - it("should limit matches with anchors", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "start line\nmiddle start\nstart again\n", - }, - }); - const result = await bash.exec("rg -m1 '^start'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:start line\n"); - }); -}); diff --git a/src/commands/rg/rg.no-filename.test.ts b/src/commands/rg/rg.no-filename.test.ts deleted file mode 100644 index 58ae837d..00000000 --- a/src/commands/rg/rg.no-filename.test.ts +++ /dev/null @@ -1,405 +0,0 @@ -/** - * Tests for rg -I/--no-filename flag - * - * The -I/--no-filename flag suppresses the prefixing of file names on output. - */ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("rg -I/--no-filename basic functionality", () => { - it("should hide filename with -I in directory search", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello world\n", - }, - }); - const result = await bash.exec("rg -I hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:hello world\n"); - }); - - it("should hide filename with --no-filename in directory search", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/data.txt": "test line\n", - }, - }); - const result = await bash.exec("rg --no-filename test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:test line\n"); - }); - - it("should work without line numbers when combined with -N", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "match here\n", - }, - }); - const result = await bash.exec("rg -I -N match"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("match here\n"); - }); - - it("should hide filename for single file search (already hidden by default)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test.txt": "foo bar\n", - }, - }); - // Single file already hides filename, -I is redundant but should work - const result = await bash.exec("rg -I foo test.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo bar\n"); - }); -}); - -describe("rg -I with multiple files", () => { - it("should hide filenames for all files", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a.txt": "found\n", - "/home/user/b.txt": "found\n", - "/home/user/c.txt": "found\n", - }, - }); - const result = await bash.exec("rg -I found"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:found\n1:found\n1:found\n"); - }); - - it("should still show line numbers with multiple files", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/first.txt": "line one\nline two\n", - "/home/user/second.txt": "line three\n", - }, - }); - const result = await bash.exec("rg -I --sort path line"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:line one\n2:line two\n1:line three\n"); - }); - - it("should hide filenames in subdirectories", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/src/app.ts": "export const x = 1;\n", - "/home/user/lib/util.ts": "export const y = 2;\n", - }, - }); - const result = await bash.exec("rg -I export"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "1:export const y = 2;\n1:export const x = 1;\n", - ); - }); -}); - -describe("rg -I with other flags", () => { - it("should work with -c (count)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "a\na\na\n", - }, - }); - const result = await bash.exec("rg -I -c a"); - expect(result.exitCode).toBe(0); - // Count mode with -I should hide filename - expect(result.stdout).toBe("3\n"); - }); - - it("should work with -c across multiple files", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a.txt": "x\nx\n", - "/home/user/b.txt": "x\nx\nx\n", - }, - }); - const result = await bash.exec("rg -I -c x"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("2\n3\n"); - }); - - it("should work with -o (only matching)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/nums.txt": "abc123def456\n", - }, - }); - const result = await bash.exec("rg -I -o '[0-9]+'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("123\n456\n"); - }); - - it("should work with -v (invert match)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "keep\nremove\nkeep\n", - }, - }); - const result = await bash.exec("rg -I -v remove"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:keep\n3:keep\n"); - }); - - it("should work with -i (case insensitive)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "Hello\nHELLO\nhello\n", - }, - }); - const result = await bash.exec("rg -I -i hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:Hello\n2:HELLO\n3:hello\n"); - }); - - it("should work with -w (word match)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "foo bar\nfoobar\nbar foo baz\n", - }, - }); - const result = await bash.exec("rg -I -w foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:foo bar\n3:bar foo baz\n"); - }); - - it("should work with -m (max count)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "test\ntest\ntest\ntest\n", - }, - }); - const result = await bash.exec("rg -I -m2 test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:test\n2:test\n"); - }); - - it("should NOT affect -l (files with matches)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a.txt": "match\n", - "/home/user/b.txt": "match\n", - }, - }); - // -l lists files, so filename is the output - -I doesn't make sense here - // but ripgrep still shows filenames with -l even with -I - const result = await bash.exec("rg -I -l match"); - expect(result.exitCode).toBe(0); - // -l output shows filenames regardless of -I - expect(result.stdout).toBe("a.txt\nb.txt\n"); - }); - - it("should NOT affect --files-without-match", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/has.txt": "match\n", - "/home/user/no.txt": "other\n", - }, - }); - const result = await bash.exec("rg -I --files-without-match match"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("no.txt\n"); - }); -}); - -describe("rg -I with context lines", () => { - it("should hide filename with -A (after context)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "before\nmatch\nafter\nmore\n", - }, - }); - const result = await bash.exec("rg -I -A1 match"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("2:match\n3-after\n"); - }); - - it("should hide filename with -B (before context)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "before\nmatch\nafter\n", - }, - }); - const result = await bash.exec("rg -I -B1 match"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1-before\n2:match\n"); - }); - - it("should hide filename with -C (context)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "a\nb\nmatch\nc\nd\n", - }, - }); - const result = await bash.exec("rg -I -C1 match"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("2-b\n3:match\n4-c\n"); - }); - - it("should hide filename in context across multiple files", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a.txt": "ctx\nmatch\nctx\n", - "/home/user/b.txt": "ctx\nmatch\nctx\n", - }, - }); - const result = await bash.exec("rg -I -C1 --sort path match"); - expect(result.exitCode).toBe(0); - // No separator between files when -I is used since there's no filename prefix - expect(result.stdout).toBe( - "1-ctx\n2:match\n3-ctx\n1-ctx\n2:match\n3-ctx\n", - ); - }); -}); - -describe("rg -I with file filters", () => { - it("should work with -t (type filter)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/code.js": "const x = 1;\n", - "/home/user/code.py": "x = 1\n", - }, - }); - const result = await bash.exec("rg -I -t js const"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:const x = 1;\n"); - }); - - it("should work with -g (glob filter)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test.log": "error occurred\n", - "/home/user/test.txt": "error here too\n", - }, - }); - const result = await bash.exec("rg -I -g '*.log' error"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:error occurred\n"); - }); -}); - -describe("rg -I edge cases", () => { - it("should handle empty matches with no filename", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "no match here\n", - }, - }); - const result = await bash.exec("rg -I notfound"); - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - }); - - it("should work with special characters in output", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "path/to/file:line:content\n", - }, - }); - const result = await bash.exec("rg -I path"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:path/to/file:line:content\n"); - }); - - it("should work with hidden files when --hidden is used", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.hidden": "secret\n", - }, - }); - const result = await bash.exec("rg -I --hidden secret"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:secret\n"); - }); - - it("should work combined with other short flags", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "Test\ntest\nTEST\n", - }, - }); - // Combine -I with -i and -n - const result = await bash.exec("rg -Iin test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:Test\n2:test\n3:TEST\n"); - }); - - it("should output just line numbers with -In", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "match\n", - }, - }); - const result = await bash.exec("rg -In match"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:match\n"); - }); -}); - -describe("rg -I with regex patterns", () => { - it("should work with regex alternation", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "apple\norange\nbanana\n", - }, - }); - const result = await bash.exec("rg -I 'apple|banana'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:apple\n3:banana\n"); - }); - - it("should work with character classes", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "abc123\ndef456\n", - }, - }); - const result = await bash.exec("rg -I '[0-9]+'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1:abc123\n2:def456\n"); - }); -}); - -describe("rg -I piping use case", () => { - it("should produce clean output for piping to other commands", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/data.txt": "value: 100\nvalue: 200\nvalue: 300\n", - }, - }); - // -I -N -o gives just the matched text, perfect for piping - const result = await bash.exec("rg -I -N -o '[0-9]+'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("100\n200\n300\n"); - }); -}); diff --git a/src/commands/rg/rg.output.test.ts b/src/commands/rg/rg.output.test.ts deleted file mode 100644 index 32a15343..00000000 --- a/src/commands/rg/rg.output.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("rg output modes", () => { - it("should count matches with -c", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello\nhello\nhello\n", - }, - }); - const result = await bash.exec("rg -c hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:3\n"); - expect(result.stderr).toBe(""); - }); - - it("should count matches across multiple files", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a.txt": "hello\nhello\n", - "/home/user/b.txt": "hello\n", - }, - }); - const result = await bash.exec("rg -c hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a.txt:2\nb.txt:1\n"); - expect(result.stderr).toBe(""); - }); - - it("should list files with -l", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file1.txt": "hello\n", - "/home/user/file2.txt": "hello\n", - }, - }); - const result = await bash.exec("rg -l hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file1.txt\nfile2.txt\n"); - expect(result.stderr).toBe(""); - }); - - it("should list files without matches with --files-without-match", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file1.txt": "hello\n", - "/home/user/file2.txt": "world\n", - }, - }); - const result = await bash.exec("rg --files-without-match hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file2.txt\n"); - expect(result.stderr).toBe(""); - }); - - it("should show only matching text with -o", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello world\n", - }, - }); - const result = await bash.exec("rg -o hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:hello\n"); - expect(result.stderr).toBe(""); - }); - - it("should show multiple matches per line with -o", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello hello hello\n", - }, - }); - const result = await bash.exec("rg -o hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "file.txt:hello\nfile.txt:hello\nfile.txt:hello\n", - ); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg context lines", () => { - it("should show lines after match with -A", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "line1\nhello\nline3\nline4\n", - }, - }); - const result = await bash.exec("rg -A 1 hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:2:hello\nfile.txt-3-line3\n"); - expect(result.stderr).toBe(""); - }); - - it("should show lines before match with -B", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "line1\nline2\nhello\nline4\n", - }, - }); - const result = await bash.exec("rg -B 1 hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt-2-line2\nfile.txt:3:hello\n"); - expect(result.stderr).toBe(""); - }); - - it("should show context with -C", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "line1\nline2\nhello\nline4\nline5\n", - }, - }); - const result = await bash.exec("rg -C 1 hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "file.txt-2-line2\nfile.txt:3:hello\nfile.txt-4-line4\n", - ); - expect(result.stderr).toBe(""); - }); - - it("should handle context at start of file", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "match\nline2\nline3\n", - }, - }); - const result = await bash.exec("rg -B 2 match"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:match\n"); - expect(result.stderr).toBe(""); - }); - - it("should handle context at end of file", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "line1\nline2\nmatch\n", - }, - }); - const result = await bash.exec("rg -A 2 match"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:3:match\n"); - expect(result.stderr).toBe(""); - }); - - it("should handle overlapping context from multiple matches", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "a\nmatch1\nb\nmatch2\nc\n", - }, - }); - const result = await bash.exec("rg -C 1 match"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "file.txt-1-a\nfile.txt:2:match1\nfile.txt-3-b\nfile.txt:4:match2\nfile.txt-5-c\n", - ); - expect(result.stderr).toBe(""); - }); - - it("should support combined context format -A2", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "a\nhello\nb\nc\nd\n", - }, - }); - const result = await bash.exec("rg -A2 hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "file.txt:2:hello\nfile.txt-3-b\nfile.txt-4-c\n", - ); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg quiet mode", () => { - it("should suppress output with -q", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello world\n", - }, - }); - const result = await bash.exec("rg -q hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - }); - - it("should return exit code 1 when no match with -q", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello world\n", - }, - }); - const result = await bash.exec("rg -q nomatch"); - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - }); - - it("should exit early on first match with -q", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file1.txt": "hello\n", - "/home/user/file2.txt": "hello\n", - "/home/user/file3.txt": "hello\n", - }, - }); - const result = await bash.exec("rg -q hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - }); - - it("should work with --quiet long form", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello world\n", - }, - }); - const result = await bash.exec("rg --quiet hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg help", () => { - it("should show help with --help", async () => { - const bash = new Bash(); - const result = await bash.exec("rg --help"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("rg"); - expect(result.stdout).toContain("recursively search"); - expect(result.stderr).toBe(""); - }); -}); diff --git a/src/commands/rg/rg.patterns.test.ts b/src/commands/rg/rg.patterns.test.ts deleted file mode 100644 index f5d684fb..00000000 --- a/src/commands/rg/rg.patterns.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("rg pattern options", () => { - it("should match whole words with -w", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello world\nhelloworld\n", - }, - }); - const result = await bash.exec("rg -w hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:hello world\n"); - expect(result.stderr).toBe(""); - }); - - it("should match whole lines with -x", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello\nhello world\n", - }, - }); - const result = await bash.exec("rg -x hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:hello\n"); - expect(result.stderr).toBe(""); - }); - - it("should treat pattern as literal with -F", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "a.b\naxb\n", - }, - }); - const result = await bash.exec("rg -F 'a.b'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:a.b\n"); - expect(result.stderr).toBe(""); - }); - - it("should match regex special chars literally with -F", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "foo[bar]\nfoobar\n", - }, - }); - const result = await bash.exec("rg -F '[bar]'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:foo[bar]\n"); - expect(result.stderr).toBe(""); - }); - - it("should invert match with -v", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello\nworld\n", - }, - }); - const result = await bash.exec("rg -v hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:2:world\n"); - expect(result.stderr).toBe(""); - }); - - it("should show all non-matching lines with -v", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "foo\nbar\nfoo\nbaz\n", - }, - }); - const result = await bash.exec("rg -v foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:2:bar\nfile.txt:4:baz\n"); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg multiple patterns", () => { - it("should search for multiple patterns with -e", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "foo\nbar\nbaz\n", - }, - }); - const result = await bash.exec("rg -e foo -e bar"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:foo\nfile.txt:2:bar\n"); - expect(result.stderr).toBe(""); - }); - - it("should combine multiple -e patterns", async () => { - // Note: When -e is used, all positional args are paths (ripgrep behavior) - // Use multiple -e flags to search for multiple patterns - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "foo\nbar\nbaz\n", - }, - }); - const result = await bash.exec("rg -e foo -e bar"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:foo\nfile.txt:2:bar\n"); - expect(result.stderr).toBe(""); - }); - - it("should use smart case across all patterns", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "Hello\nhello\nWorld\nworld\n", - }, - }); - const result = await bash.exec("rg -e Hello -e world"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:Hello\nfile.txt:4:world\n"); - expect(result.stderr).toBe(""); - }); - - it("should support --regexp= syntax", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "foo\nbar\n", - }, - }); - const result = await bash.exec("rg --regexp=foo --regexp=bar"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:foo\nfile.txt:2:bar\n"); - expect(result.stderr).toBe(""); - }); - - it("should match multiple patterns in same line", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "foo bar baz\n", - }, - }); - const result = await bash.exec("rg -e foo -e bar"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:foo bar baz\n"); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg regex patterns", () => { - it("should match regex patterns", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "foo123\nbar456\nbaz\n", - }, - }); - const result = await bash.exec("rg '[0-9]+'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:foo123\nfile.txt:2:bar456\n"); - expect(result.stderr).toBe(""); - }); - - it("should match start of line with ^", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello world\nworld hello\n", - }, - }); - const result = await bash.exec("rg '^hello'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:hello world\n"); - expect(result.stderr).toBe(""); - }); - - it("should match end of line with $", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "hello world\nworld hello\n", - }, - }); - const result = await bash.exec("rg 'hello$'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:2:world hello\n"); - expect(result.stderr).toBe(""); - }); - - it("should match with alternation", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "cat\ndog\nbird\n", - }, - }); - const result = await bash.exec("rg 'cat|dog'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:cat\nfile.txt:2:dog\n"); - expect(result.stderr).toBe(""); - }); - - it("should match with quantifiers", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "a\naa\naaa\nb\n", - }, - }); - const result = await bash.exec("rg 'a{2,}'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:2:aa\nfile.txt:3:aaa\n"); - expect(result.stderr).toBe(""); - }); - - it("should match with character classes", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "a1\nb2\nc!\n", - }, - }); - const result = await bash.exec("rg '[a-z][0-9]'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:a1\nfile.txt:2:b2\n"); - expect(result.stderr).toBe(""); - }); -}); - -describe("rg combined options", () => { - it("should combine -w and -i", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "Hello world\nhelloworld\nHELLO there\n", - }, - }); - const result = await bash.exec("rg -wi hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "file.txt:1:Hello world\nfile.txt:3:HELLO there\n", - ); - expect(result.stderr).toBe(""); - }); - - it("should combine -c and -i", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "Hello\nhello\nHELLO\n", - }, - }); - const result = await bash.exec("rg -ci hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:3\n"); - expect(result.stderr).toBe(""); - }); - - it("should combine -l and -i", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/a.txt": "HELLO\n", - "/home/user/b.txt": "world\n", - }, - }); - const result = await bash.exec("rg -li hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a.txt\n"); - expect(result.stderr).toBe(""); - }); - - it("should combine -v and -c", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "foo\nbar\nfoo\nbaz\n", - }, - }); - const result = await bash.exec("rg -vc foo"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:2\n"); - expect(result.stderr).toBe(""); - }); -}); diff --git a/src/commands/rg/rg.ripgrep-compat.test.ts b/src/commands/rg/rg.ripgrep-compat.test.ts deleted file mode 100644 index 8d0f6e72..00000000 --- a/src/commands/rg/rg.ripgrep-compat.test.ts +++ /dev/null @@ -1,934 +0,0 @@ -/** - * Tests ported from ripgrep's test suite - * Source: https://github.com/BurntSushi/ripgrep/tree/master/tests - * - * These tests validate compatibility with real ripgrep behavior. - */ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -// Classic test fixture from ripgrep tests -const SHERLOCK = `For the Doctor Watsons of this world, as opposed to the Sherlock -Holmeses, success in the province of detective work must always -be, to a very large extent, the result of luck. Sherlock Holmes -can extract a clew from a wisp of straw or a flake of cigar ash; -but Doctor Watson has to have it taken out for him and dusted, -and exhibited clearly, with a label attached. -`; - -describe("rg ripgrep-compat: basic search", () => { - it("should search single file", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg Sherlock sherlock"); - expect(result.exitCode).toBe(0); - // ripgrep: single file = no filename prefix, no line numbers by default - expect(result.stdout).toBe( - "For the Doctor Watsons of this world, as opposed to the Sherlock\nbe, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); - - it("should search directory with filename prefix", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "sherlock:1:For the Doctor Watsons of this world, as opposed to the Sherlock\nsherlock:3:be, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); - - it("should show line numbers with -n", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -n Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "1:For the Doctor Watsons of this world, as opposed to the Sherlock\n3:be, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); - - it("should hide line numbers with -N", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -N Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "For the Doctor Watsons of this world, as opposed to the Sherlock\nbe, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -describe("rg ripgrep-compat: inverted match", () => { - it("should invert match with -v", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -v Sherlock sherlock"); - expect(result.exitCode).toBe(0); - // Lines NOT containing "Sherlock" - expect(result.stdout).toBe( - "Holmeses, success in the province of detective work must always\ncan extract a clew from a wisp of straw or a flake of cigar ash;\nbut Doctor Watson has to have it taken out for him and dusted,\nand exhibited clearly, with a label attached.\n", - ); - }); - - it("should show line numbers with inverted match", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -n -v Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "2:Holmeses, success in the province of detective work must always\n4:can extract a clew from a wisp of straw or a flake of cigar ash;\n5:but Doctor Watson has to have it taken out for him and dusted,\n6:and exhibited clearly, with a label attached.\n", - ); - }); -}); - -describe("rg ripgrep-compat: case sensitivity", () => { - it("should be case-insensitive with -i", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -i sherlock sherlock"); - expect(result.exitCode).toBe(0); - // Should match "Sherlock" case-insensitively - expect(result.stdout).toBe( - "For the Doctor Watsons of this world, as opposed to the Sherlock\nbe, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); - - it("should use smart case with lowercase pattern", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "tEsT\n", - }, - }); - // Smart case: lowercase pattern = case-insensitive - const result = await bash.exec("rg test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo:1:tEsT\n"); - }); - - it("should use smart case with uppercase pattern (case-sensitive)", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "tEsT\nTEST\n", - }, - }); - // Smart case: uppercase in pattern = case-sensitive - const result = await bash.exec("rg TEST"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo:2:TEST\n"); - }); - - it("should override smart case with -s", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "tEsT\ntest\n", - }, - }); - // -s forces case-sensitive even with lowercase pattern - const result = await bash.exec("rg -s test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo:2:test\n"); - }); -}); - -describe("rg ripgrep-compat: word matching", () => { - it("should match whole words with -w", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -w as sherlock"); - expect(result.exitCode).toBe(0); - // "as" as a word appears in first line - expect(result.stdout).toBe( - "For the Doctor Watsons of this world, as opposed to the Sherlock\n", - ); - }); - - it("should match words with -w", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/haystack": "foo bar baz\nfoobar\n", - }, - }); - // -w should match "foo" as a word, not "foo" within "foobar" - const result = await bash.exec("rg -w foo haystack"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo bar baz\n"); - }); -}); - -describe("rg ripgrep-compat: line matching", () => { - it("should match whole lines with -x", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec( - "rg -x 'and exhibited clearly, with a label attached.' sherlock", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "and exhibited clearly, with a label attached.\n", - ); - }); -}); - -describe("rg ripgrep-compat: literal matching", () => { - it("should match literal pattern with -F", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "blib\n()\nblab\n", - }, - }); - const result = await bash.exec("rg -F '()' file"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("()\n"); - }); -}); - -describe("rg ripgrep-compat: quiet mode", () => { - it("should suppress output with -q", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -q Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - }); - - it("should return exit code 1 with -q and no match", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -q NADA sherlock"); - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - }); -}); - -describe("rg ripgrep-compat: file type filtering", () => { - it("should filter by type with -t", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - "/home/user/file.py": "Sherlock\n", - "/home/user/file.rs": "Sherlock\n", - }, - }); - const result = await bash.exec("rg -t rust Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.rs:1:Sherlock\n"); - }); - - it("should negate type with -T", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.py": "Sherlock\n", - "/home/user/file.rs": "Sherlock\n", - }, - }); - const result = await bash.exec("rg -T rust Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.py:1:Sherlock\n"); - }); -}); - -describe("rg ripgrep-compat: glob filtering", () => { - it("should filter files with -g", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - "/home/user/file.py": "Sherlock\n", - "/home/user/file.rs": "Sherlock\n", - }, - }); - const result = await bash.exec("rg -g '*.rs' Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.rs:1:Sherlock\n"); - }); - - it("should negate glob with -g !", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.py": "Sherlock\n", - "/home/user/file.rs": "Sherlock\n", - }, - }); - const result = await bash.exec("rg -g '!*.rs' Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.py:1:Sherlock\n"); - }); - - it("should support case-insensitive glob matching scenario", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.HTML": "Sherlock\n", - "/home/user/file.html": "Sherlock\n", - }, - }); - // Standard glob is case-sensitive - const result = await bash.exec("rg -g '*.html' Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.html:1:Sherlock\n"); - }); -}); - -describe("rg ripgrep-compat: count", () => { - it("should count matching lines with -c", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -c Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("sherlock:2\n"); - }); -}); - -describe("rg ripgrep-compat: files with/without matches", () => { - it("should list files with matches with -l", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -l Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("sherlock\n"); - }); - - it("should list files without matches with --files-without-match", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - "/home/user/file.py": "foo\n", - }, - }); - const result = await bash.exec("rg --files-without-match Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.py\n"); - }); -}); - -describe("rg ripgrep-compat: context lines", () => { - it("should show after context with -A", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -A 1 Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "For the Doctor Watsons of this world, as opposed to the Sherlock\nHolmeses, success in the province of detective work must always\nbe, to a very large extent, the result of luck. Sherlock Holmes\ncan extract a clew from a wisp of straw or a flake of cigar ash;\n", - ); - }); - - it("should show before context with -B", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -B 1 Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "For the Doctor Watsons of this world, as opposed to the Sherlock\nHolmeses, success in the province of detective work must always\nbe, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); - - it("should show context with -C", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -C 1 'world|attached' sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "For the Doctor Watsons of this world, as opposed to the Sherlock\nHolmeses, success in the province of detective work must always\n--\nbut Doctor Watson has to have it taken out for him and dusted,\nand exhibited clearly, with a label attached.\n", - ); - }); -}); - -describe("rg ripgrep-compat: hidden files", () => { - it("should ignore hidden files by default", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg Sherlock"); - expect(result.exitCode).toBe(1); - }); - - it("should include hidden files with --hidden", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --hidden Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - ".sherlock:1:For the Doctor Watsons of this world, as opposed to the Sherlock\n.sherlock:3:be, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -describe("rg ripgrep-compat: gitignore", () => { - it("should respect .gitignore by default", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "sherlock\n", - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg Sherlock"); - expect(result.exitCode).toBe(1); - }); - - it("should ignore .gitignore with --no-ignore", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "sherlock\n", - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --no-ignore Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "sherlock:1:For the Doctor Watsons of this world, as opposed to the Sherlock\nsherlock:3:be, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -describe("rg ripgrep-compat: max depth", () => { - it("should limit depth with --max-depth", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/one/pass": "far\n", - "/home/user/one/too/many": "far\n", - }, - }); - const result = await bash.exec("rg --max-depth 2 far"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("one/pass:1:far\n"); - }); -}); - -describe("rg ripgrep-compat: multiple patterns", () => { - it("should match multiple patterns with -e", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file.txt": "foo\nbar\nbaz\n", - }, - }); - const result = await bash.exec("rg -e foo -e bar"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file.txt:1:foo\nfile.txt:2:bar\n"); - }); - - it("should handle -e with dash pattern", async () => { - // Regression test from ripgrep #270 - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "-test\n", - }, - }); - const result = await bash.exec("rg -e '-test'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo:1:-test\n"); - }); -}); - -describe("rg ripgrep-compat: only matching", () => { - it("should show only matching text with -o", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/digits.txt": "1 2 3\n", - }, - }); - const result = await bash.exec("rg -o '[0-9]+' digits.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1\n2\n3\n"); - }); -}); - -describe("rg ripgrep-compat: regex patterns", () => { - it("should match IP address pattern", async () => { - // Regression test from ripgrep #93 - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "192.168.1.1\n", - }, - }); - const result = await bash.exec("rg '(\\d{1,3}\\.){3}\\d{1,3}'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo:1:192.168.1.1\n"); - }); - - it("should match alternation pattern", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "cat\ndog\nbird\n", - }, - }); - const result = await bash.exec("rg 'cat|dog'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("file:1:cat\nfile:2:dog\n"); - }); -}); - -describe("rg ripgrep-compat: exit codes", () => { - it("should return 0 on match", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg ."); - expect(result.exitCode).toBe(0); - }); - - it("should return 1 on no match", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg NADA"); - expect(result.exitCode).toBe(1); - }); - - it("should return 2 on error", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg '*'"); - expect(result.exitCode).toBe(2); - }); -}); - -describe("rg ripgrep-compat: binary files", () => { - it("should skip binary files by default", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/text.txt": "hello\n", - "/home/user/binary.bin": "hello\x00world\n", - }, - }); - const result = await bash.exec("rg hello"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("text.txt:1:hello\n"); - }); -}); - -describe("rg ripgrep-compat: gitignore patterns", () => { - it("should handle directory ignore pattern", async () => { - // Regression test from ripgrep #16 - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "ghi/\n", - "/home/user/ghi/toplevel.txt": "xyz\n", - "/home/user/def/ghi/subdir.txt": "xyz\n", - }, - }); - const result = await bash.exec("rg xyz"); - expect(result.exitCode).toBe(1); - }); - - it("should handle rooted pattern in gitignore", async () => { - // Regression test from ripgrep #25 - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "/llvm/\n", - "/home/user/src/llvm/foo": "test\n", - }, - }); - const result = await bash.exec("rg test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("src/llvm/foo:1:test\n"); - }); - - it("should handle negation after double-star", async () => { - // Regression test from ripgrep #30 - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "vendor/**\n!vendor/manifest\n", - "/home/user/vendor/manifest": "test\n", - "/home/user/vendor/other": "test\n", - }, - }); - const result = await bash.exec("rg test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("vendor/manifest:1:test\n"); - }); - - it("should handle unanchored directory pattern", async () => { - // Regression test from ripgrep #49 - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "foo/bar\n", - "/home/user/test/foo/bar/baz": "test\n", - }, - }); - const result = await bash.exec("rg xyz"); - expect(result.exitCode).toBe(1); - }); - - it("should handle negation of hidden file", async () => { - // Regression test from ripgrep #90 - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/.gitignore": "!.foo\n", - "/home/user/.foo": "test\n", - }, - }); - const result = await bash.exec("rg --hidden test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(".foo:1:test\n"); - }); -}); - -describe("rg ripgrep-compat: unicode", () => { - it("should match cyrillic with -i", async () => { - // Regression test from ripgrep #251 - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "привет\nПривет\nПрИвЕт\n", - }, - }); - const result = await bash.exec("rg -i привет"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo:1:привет\nfoo:2:Привет\nfoo:3:ПрИвЕт\n"); - }); -}); - -// ============================================================================= -// MISSING FEATURES - Tests that document what ripgrep features we don't have -// ============================================================================= - -describe("rg ripgrep-compat: column numbers (--column)", () => { - it("should show column with --column", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -n --column Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "1:57:For the Doctor Watsons of this world, as opposed to the Sherlock\n3:49:be, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -describe("rg ripgrep-compat: patterns from file (-f)", () => { - it("should read patterns from file with -f", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - "/home/user/.patterns": "Sherlock\nHolmes\n", - }, - }); - // Use hidden file for patterns to avoid it being searched - // When searching a single file, rg doesn't show filename by default - const result = await bash.exec("rg -f .patterns sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "For the Doctor Watsons of this world, as opposed to the Sherlock\nHolmeses, success in the province of detective work must always\nbe, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -describe("rg ripgrep-compat: replace (-r)", () => { - it("should replace matches with -r", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -r FooBar Sherlock sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "For the Doctor Watsons of this world, as opposed to the FooBar\nbe, to a very large extent, the result of luck. FooBar Holmes\n", - ); - }); -}); - -describe("rg ripgrep-compat: vimgrep format (--vimgrep)", () => { - it("should output vimgrep format with --vimgrep", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --vimgrep 'Sherlock|Watson' sherlock"); - expect(result.exitCode).toBe(0); - // Each match on separate line (line 1 appears twice for Watson and Sherlock) - expect(result.stdout).toBe( - "1:16:For the Doctor Watsons of this world, as opposed to the Sherlock\n1:57:For the Doctor Watsons of this world, as opposed to the Sherlock\n3:49:be, to a very large extent, the result of luck. Sherlock Holmes\n5:12:but Doctor Watson has to have it taken out for him and dusted,\n", - ); - }); -}); - -describe("rg ripgrep-compat: null separator (-0)", () => { - it("should use null separator with -0", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -0 -l Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("sherlock\x00"); - }); -}); - -describe("rg ripgrep-compat: max count (-m)", () => { - it("should stop after N matches with -m", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "test\ntest\ntest\n", - }, - }); - const result = await bash.exec("rg -m1 test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo:1:test\n"); - }); -}); - -describe("rg ripgrep-compat: count matches (--count-matches)", () => { - it("should count individual matches", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --count-matches the"); - expect(result.exitCode).toBe(0); - // "the" appears 4 times in SHERLOCK - expect(result.stdout).toBe("sherlock:4\n"); - }); -}); - -describe("rg ripgrep-compat: heading mode (--heading)", () => { - it("should group results by file with --heading", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --heading Sherlock"); - expect(result.exitCode).toBe(0); - // File name on its own line, then matches without filename prefix - expect(result.stdout).toMatch(/^sherlock\n/); - }); -}); - -describe("rg ripgrep-compat: byte offset (-b)", () => { - it("should show byte offset with -b", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -b -o Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("sherlock:56:Sherlock\nsherlock:177:Sherlock\n"); - }); -}); - -describe("rg ripgrep-compat: context separator (--context-separator)", () => { - it("should use custom context separator", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/test": "foo\nctx\nbar\nctx\nfoo\nctx\n", - }, - }); - const result = await bash.exec("rg -A1 --context-separator AAA foo test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo\nctx\nAAA\nfoo\nctx\n"); - }); -}); - -describe("rg ripgrep-compat: multiline (-U)", () => { - it("should match across lines with -U", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "foo\nbar\n", - }, - }); - const result = await bash.exec("rg -U 'foo\\nbar'"); - expect(result.exitCode).toBe(0); - }); -}); - -describe("rg ripgrep-compat: passthrough (--passthru)", () => { - it("should print all lines with --passthru", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/file": "\nfoo\nbar\nfoobar\n\nbaz\n", - }, - }); - const result = await bash.exec("rg -n --passthru foo file"); - expect(result.exitCode).toBe(0); - // All lines printed, matches marked with :, non-matches with - - expect(result.stdout).toBe("1-\n2:foo\n3-bar\n4:foobar\n5-\n6-baz\n"); - }); -}); - -describe("rg ripgrep-compat: sort (--sort)", () => { - it("should sort files with --sort path", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/foo": "test\n", - "/home/user/abc": "test\n", - "/home/user/zoo": "test\n", - "/home/user/bar": "test\n", - }, - }); - const result = await bash.exec("rg --sort path test"); - expect(result.exitCode).toBe(0); - // Files are sorted alphabetically by path - expect(result.stdout).toBe( - "abc:1:test\nbar:1:test\nfoo:1:test\nzoo:1:test\n", - ); - }); -}); - -describe("rg ripgrep-compat: no-filename (-I)", () => { - it("should hide filename with --no-filename", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg --no-filename Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "1:For the Doctor Watsons of this world, as opposed to the Sherlock\n3:be, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); - - it("should hide filename with -I", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -I Sherlock"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - "1:For the Doctor Watsons of this world, as opposed to the Sherlock\n3:be, to a very large extent, the result of luck. Sherlock Holmes\n", - ); - }); -}); - -describe("rg ripgrep-compat: include-zero (--include-zero)", () => { - it("should include zero counts with --include-zero", async () => { - const bash = new Bash({ - cwd: "/home/user", - files: { - "/home/user/sherlock": SHERLOCK, - }, - }); - const result = await bash.exec("rg -c --include-zero nada"); - // Exit code 1 because no matches, but still outputs count - expect(result.stdout).toBe("sherlock:0\n"); - }); -}); diff --git a/src/commands/rg/rg.ts b/src/commands/rg/rg.ts deleted file mode 100644 index f7842f35..00000000 --- a/src/commands/rg/rg.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * rg - ripgrep-like recursive search - * - * Fast recursive search with smart defaults: - * - Recursive by default (unlike grep) - * - Respects .gitignore - * - Skips hidden files by default - * - Skips binary files by default - * - Smart case sensitivity (case-insensitive unless pattern has uppercase) - */ - -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { hasHelpFlag, showHelp } from "../help.js"; -import { formatTypeList } from "./file-types.js"; -import { parseArgs } from "./rg-parser.js"; -import { executeSearch } from "./rg-search.js"; - -const rgHelp = { - name: "rg", - summary: "recursively search for a pattern", - usage: "rg [OPTIONS] PATTERN [PATH ...]", - description: `rg (ripgrep) recursively searches directories for a regex pattern. -Unlike grep, rg is recursive by default and respects .gitignore files. - -EXAMPLES: - rg foo Search for 'foo' in current directory - rg foo src/ Search in src/ directory - rg -i foo Case-insensitive search - rg -w foo Match whole words only - rg -t js foo Search only JavaScript files - rg -g '*.ts' foo Search files matching glob - rg --hidden foo Include hidden files - rg -l foo List files with matches only`, - options: [ - "-e, --regexp PATTERN search for PATTERN (can be used multiple times)", - "-f, --file FILE read patterns from FILE, one per line", - "-i, --ignore-case case-insensitive search", - "-s, --case-sensitive case-sensitive search (overrides smart-case)", - "-S, --smart-case smart case (default: case-insensitive unless pattern has uppercase)", - "-F, --fixed-strings treat pattern as literal string", - "-w, --word-regexp match whole words only", - "-x, --line-regexp match whole lines only", - "-v, --invert-match select non-matching lines", - "-r, --replace TEXT replace matches with TEXT", - "-c, --count print count of matching lines per file", - " --count-matches print count of individual matches per file", - "-l, --files-with-matches print only file names with matches", - " --files-without-match print file names without matches", - " --files list files that would be searched", - "-o, --only-matching print only matching parts", - "-m, --max-count NUM stop after NUM matches per file", - "-q, --quiet suppress output, exit 0 on match", - " --stats print search statistics", - "-n, --line-number print line numbers (default: on)", - "-N, --no-line-number do not print line numbers", - "-I, --no-filename suppress the prefixing of file names", - "-0, --null use NUL as filename separator", - "-b, --byte-offset show byte offset of each match", - " --column show column number of first match", - " --vimgrep show results in vimgrep format", - " --json show results in JSON Lines format", - "-A NUM print NUM lines after each match", - "-B NUM print NUM lines before each match", - "-C NUM print NUM lines before and after each match", - " --context-separator SEP separator for context groups (default: --)", - "-U, --multiline match patterns across lines", - "-z, --search-zip search in compressed files (gzip only)", - "-g, --glob GLOB include files matching GLOB", - "-t, --type TYPE only search files of TYPE (e.g., js, py, ts)", - "-T, --type-not TYPE exclude files of TYPE", - "-L, --follow follow symbolic links", - "-u, --unrestricted reduce filtering (-u: no ignore, -uu: +hidden, -uuu: +binary)", - "-a, --text search binary files as text", - " --hidden search hidden files and directories", - " --no-ignore don't respect .gitignore/.ignore files", - "-d, --max-depth NUM maximum search depth", - " --sort TYPE sort files (path, none)", - " --heading show file path above matches", - " --passthru print all lines (non-matches use - separator)", - " --include-zero include files with 0 matches in count output", - " --type-list list all available file types", - " --help display this help and exit", - ], -}; - -export const rgCommand: Command = { - name: "rg", - - async execute(args: string[], ctx: CommandContext): Promise { - if (hasHelpFlag(args)) { - return showHelp(rgHelp); - } - - if (args.includes("--type-list")) { - return { - stdout: formatTypeList(), - stderr: "", - exitCode: 0, - }; - } - - const parseResult = parseArgs(args); - if (!parseResult.success) { - return parseResult.error; - } - - return executeSearch({ - ctx, - options: parseResult.options, - paths: parseResult.paths, - explicitLineNumbers: parseResult.explicitLineNumbers, - }); - }, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "rg", - flags: [ - { flag: "-i", type: "boolean" }, - { flag: "-s", type: "boolean" }, - { flag: "-S", type: "boolean" }, - { flag: "-F", type: "boolean" }, - { flag: "-w", type: "boolean" }, - { flag: "-x", type: "boolean" }, - { flag: "-v", type: "boolean" }, - { flag: "-c", type: "boolean" }, - { flag: "-l", type: "boolean" }, - { flag: "-o", type: "boolean" }, - { flag: "-n", type: "boolean" }, - { flag: "-N", type: "boolean" }, - { flag: "--hidden", type: "boolean" }, - { flag: "--no-ignore", type: "boolean" }, - { flag: "-m", type: "value", valueHint: "number" }, - { flag: "-A", type: "value", valueHint: "number" }, - { flag: "-B", type: "value", valueHint: "number" }, - { flag: "-C", type: "value", valueHint: "number" }, - { flag: "-g", type: "value", valueHint: "pattern" }, - { flag: "-t", type: "value", valueHint: "string" }, - { flag: "-T", type: "value", valueHint: "string" }, - ], - needsArgs: true, -}; diff --git a/src/commands/rm/rm.test.ts b/src/commands/rm/rm.test.ts deleted file mode 100644 index 27301461..00000000 --- a/src/commands/rm/rm.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("rm", () => { - it("should remove file", async () => { - const env = new Bash({ - files: { "/test.txt": "content" }, - }); - const result = await env.exec("rm /test.txt"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - const cat = await env.exec("cat /test.txt"); - expect(cat.exitCode).toBe(1); - }); - - it("should remove multiple files", async () => { - const env = new Bash({ - files: { - "/a.txt": "", - "/b.txt": "", - "/c.txt": "", - }, - }); - await env.exec("rm /a.txt /b.txt /c.txt"); - const ls = await env.exec("ls /"); - // /bin, /usr, /dev, /proc always exist - expect(ls.stdout).toBe("bin\ndev\nproc\nusr\n"); - }); - - it("should error on missing file", async () => { - const env = new Bash(); - const result = await env.exec("rm /missing.txt"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "rm: cannot remove '/missing.txt': No such file or directory\n", - ); - expect(result.exitCode).toBe(1); - }); - - it("should not error with -f on missing file", async () => { - const env = new Bash(); - const result = await env.exec("rm -f /missing.txt"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should error when removing directory without -r", async () => { - const env = new Bash({ - files: { "/dir/file.txt": "content" }, - }); - const result = await env.exec("rm /dir"); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(1); - }); - - it("should remove directory with -r", async () => { - const env = new Bash({ - files: { "/dir/file.txt": "content" }, - }); - const result = await env.exec("rm -r /dir"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - const ls = await env.exec("ls /dir"); - expect(ls.exitCode).toBe(2); - }); - - it("should remove directory with -R", async () => { - const env = new Bash({ - files: { "/dir/file.txt": "content" }, - }); - await env.exec("rm -R /dir"); - const ls = await env.exec("ls /dir"); - expect(ls.exitCode).toBe(2); - }); - - it("should remove nested directories with -r", async () => { - const env = new Bash({ - files: { - "/dir/sub1/file1.txt": "", - "/dir/sub2/file2.txt": "", - "/dir/root.txt": "", - }, - }); - await env.exec("rm -r /dir"); - const ls = await env.exec("ls /dir"); - expect(ls.exitCode).toBe(2); - }); - - it("should combine -rf flags", async () => { - const env = new Bash({ - files: { "/dir/file.txt": "" }, - }); - const result = await env.exec("rm -rf /dir /nonexistent"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should handle --recursive flag", async () => { - const env = new Bash({ - files: { "/dir/file.txt": "" }, - }); - await env.exec("rm --recursive /dir"); - const ls = await env.exec("ls /dir"); - expect(ls.exitCode).toBe(2); - }); - - it("should handle --force flag", async () => { - const env = new Bash(); - const result = await env.exec("rm --force /missing"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should not error with -f and no arguments", async () => { - const env = new Bash(); - const result = await env.exec("rm -f"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should error with no arguments", async () => { - const env = new Bash(); - const result = await env.exec("rm"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe("rm: missing operand\n"); - expect(result.exitCode).toBe(1); - }); - - it("should remove empty directory with -r", async () => { - const env = new Bash(); - await env.exec("mkdir /emptydir"); - await env.exec("rm -r /emptydir"); - const ls = await env.exec("ls /emptydir"); - expect(ls.exitCode).toBe(2); - }); - - it("should remove file with relative path", async () => { - const env = new Bash({ - files: { "/home/user/file.txt": "content" }, - cwd: "/home/user", - }); - await env.exec("rm file.txt"); - const cat = await env.exec("cat /home/user/file.txt"); - expect(cat.exitCode).toBe(1); - }); -}); diff --git a/src/commands/rm/rm.ts b/src/commands/rm/rm.ts deleted file mode 100644 index cee7a1a9..00000000 --- a/src/commands/rm/rm.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { getErrorMessage } from "../../interpreter/helpers/errors.js"; -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { parseArgs } from "../../utils/args.js"; - -const argDefs = { - recursive: { short: "r", long: "recursive", type: "boolean" as const }, - recursiveUpper: { short: "R", type: "boolean" as const }, - force: { short: "f", long: "force", type: "boolean" as const }, - verbose: { short: "v", long: "verbose", type: "boolean" as const }, -}; - -export const rmCommand: Command = { - name: "rm", - - async execute(args: string[], ctx: CommandContext): Promise { - const parsed = parseArgs("rm", args, argDefs); - if (!parsed.ok) return parsed.error; - - const recursive = - parsed.result.flags.recursive || parsed.result.flags.recursiveUpper; - const force = parsed.result.flags.force; - const verbose = parsed.result.flags.verbose; - const paths = parsed.result.positional; - - if (paths.length === 0) { - if (force) { - return { stdout: "", stderr: "", exitCode: 0 }; - } - return { - stdout: "", - stderr: "rm: missing operand\n", - exitCode: 1, - }; - } - - let stdout = ""; - let stderr = ""; - let exitCode = 0; - - for (const path of paths) { - try { - const fullPath = ctx.fs.resolvePath(ctx.cwd, path); - const stat = await ctx.fs.stat(fullPath); - if (stat.isDirectory && !recursive) { - stderr += `rm: cannot remove '${path}': Is a directory\n`; - exitCode = 1; - continue; - } - await ctx.fs.rm(fullPath, { recursive, force }); - if (verbose) { - stdout += `removed '${path}'\n`; - } - } catch (error) { - if (!force) { - const message = getErrorMessage(error); - if (message.includes("ENOENT") || message.includes("no such file")) { - stderr += `rm: cannot remove '${path}': No such file or directory\n`; - } else if ( - message.includes("ENOTEMPTY") || - message.includes("not empty") - ) { - stderr += `rm: cannot remove '${path}': Directory not empty\n`; - } else { - stderr += `rm: cannot remove '${path}': ${message}\n`; - } - exitCode = 1; - } - } - } - - return { stdout, stderr, exitCode }; - }, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "rm", - flags: [ - { flag: "-r", type: "boolean" }, - { flag: "-R", type: "boolean" }, - { flag: "-f", type: "boolean" }, - { flag: "-v", type: "boolean" }, - ], - needsArgs: true, -}; diff --git a/src/commands/rmdir/rmdir.ts b/src/commands/rmdir/rmdir.ts deleted file mode 100644 index f4efa1e8..00000000 --- a/src/commands/rmdir/rmdir.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { getErrorMessage } from "../../interpreter/helpers/errors.js"; -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { parseArgs } from "../../utils/args.js"; - -const USAGE = `Usage: rmdir [-pv] DIRECTORY... -Remove empty directories. - -Options: - -p, --parents Remove DIRECTORY and its ancestors - -v, --verbose Output a diagnostic for every directory processed`; - -const argDefs = { - parents: { short: "p", long: "parents", type: "boolean" as const }, - verbose: { short: "v", long: "verbose", type: "boolean" as const }, - help: { long: "help", type: "boolean" as const }, -}; - -export const rmdirCommand: Command = { - name: "rmdir", - - async execute(args: string[], ctx: CommandContext): Promise { - const parsed = parseArgs("rmdir", args, argDefs); - if (!parsed.ok) return parsed.error; - - if (parsed.result.flags.help) { - return { stdout: `${USAGE}\n`, stderr: "", exitCode: 0 }; - } - - const parents = parsed.result.flags.parents; - const verbose = parsed.result.flags.verbose; - const dirs = parsed.result.positional; - - if (dirs.length === 0) { - return { - stdout: "", - stderr: "rmdir: missing operand\n", - exitCode: 1, - }; - } - - let stdout = ""; - let stderr = ""; - let exitCode = 0; - - for (const dir of dirs) { - const result = await removeDir(ctx, dir, parents, verbose); - stdout += result.stdout; - stderr += result.stderr; - if (result.exitCode !== 0) { - exitCode = result.exitCode; - } - } - - return { stdout, stderr, exitCode }; - }, -}; - -async function removeDir( - ctx: CommandContext, - dir: string, - parents: boolean, - verbose: boolean, -): Promise { - let stdout = ""; - let stderr = ""; - const exitCode = 0; - - const fullPath = ctx.fs.resolvePath(ctx.cwd, dir); - - // First, try to remove the directory itself - const result = await removeSingleDir(ctx, fullPath, dir, verbose); - stdout += result.stdout; - stderr += result.stderr; - if (result.exitCode !== 0) { - return { stdout, stderr, exitCode: result.exitCode }; - } - - // If -p flag, remove parent directories - if (parents) { - let currentPath = fullPath; - let currentDir = dir; - - // Keep removing parent directories until we hit an error or root - while (true) { - const parentPath = getParentPath(currentPath); - const parentDir = getParentPath(currentDir); - - // Stop if we've reached root or the parent is the same as current - if ( - parentPath === currentPath || - parentPath === "/" || - parentPath === "." || - parentDir === "." || - parentDir === "" - ) { - break; - } - - const parentResult = await removeSingleDir( - ctx, - parentPath, - parentDir, - verbose, - ); - stdout += parentResult.stdout; - - // For -p, we stop on first error but don't report it as failure - // if we've already removed at least one directory - if (parentResult.exitCode !== 0) { - // Don't propagate parent removal errors - they're expected - // when parents are non-empty or don't exist - break; - } - - currentPath = parentPath; - currentDir = parentDir; - } - } - - return { stdout, stderr, exitCode }; -} - -async function removeSingleDir( - ctx: CommandContext, - fullPath: string, - displayPath: string, - verbose: boolean, -): Promise { - try { - // Check if path exists - const exists = await ctx.fs.exists(fullPath); - if (!exists) { - return { - stdout: "", - stderr: `rmdir: failed to remove '${displayPath}': No such file or directory\n`, - exitCode: 1, - }; - } - - // Check if it's a directory - const stat = await ctx.fs.stat(fullPath); - if (!stat.isDirectory) { - return { - stdout: "", - stderr: `rmdir: failed to remove '${displayPath}': Not a directory\n`, - exitCode: 1, - }; - } - - // Check if directory is empty - const entries = await ctx.fs.readdir(fullPath); - if (entries.length > 0) { - return { - stdout: "", - stderr: `rmdir: failed to remove '${displayPath}': Directory not empty\n`, - exitCode: 1, - }; - } - - // Remove the empty directory - await ctx.fs.rm(fullPath, { recursive: false, force: false }); - - let stdout = ""; - if (verbose) { - stdout = `rmdir: removing directory, '${displayPath}'\n`; - } - - return { stdout, stderr: "", exitCode: 0 }; - } catch (error) { - const message = getErrorMessage(error); - return { - stdout: "", - stderr: `rmdir: failed to remove '${displayPath}': ${message}\n`, - exitCode: 1, - }; - } -} - -function getParentPath(path: string): string { - // Remove trailing slashes - const normalized = path.replace(/\/+$/, ""); - - // Find the last slash - const lastSlash = normalized.lastIndexOf("/"); - - if (lastSlash === -1) { - // No slash, parent is "." - return "."; - } - - if (lastSlash === 0) { - // Root directory - return "/"; - } - - return normalized.substring(0, lastSlash); -} - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "rmdir", - flags: [ - { flag: "-p", type: "boolean" }, - { flag: "-v", type: "boolean" }, - ], - needsArgs: true, -}; diff --git a/src/commands/search-engine/index.ts b/src/commands/search-engine/index.ts deleted file mode 100644 index fe7c0882..00000000 --- a/src/commands/search-engine/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Shared search engine for grep and rg commands - * - * Provides core text searching functionality: - * - Line-by-line content matching - * - Context lines (before/after) - * - Regex building for different modes (basic, extended, fixed, perl) - */ - -export { - type SearchOptions, - type SearchResult, - searchContent, -} from "./matcher.js"; -export { - buildRegex, - convertReplacement, - type RegexMode, - type RegexOptions, - type RegexResult, -} from "./regex.js"; diff --git a/src/commands/search-engine/matcher.ts b/src/commands/search-engine/matcher.ts deleted file mode 100644 index b29aeb04..00000000 --- a/src/commands/search-engine/matcher.ts +++ /dev/null @@ -1,637 +0,0 @@ -/** - * Core content matching logic for search commands - */ - -import type { UserRegex } from "../../regex/index.js"; - -/** - * Apply a replacement pattern using capture groups from a regex match - * Supports: $& (full match), $1-$9 (numbered groups), $ (named groups) - */ -function applyReplacement(replacement: string, match: RegExpExecArray): string { - return replacement.replace( - /\$(&|\d+|<([^>]+)>)/g, - (_, ref: string, namedGroup: string | undefined) => { - if (ref === "&") { - return match[0]; - } - if (namedGroup !== undefined) { - // Named group: $ - return match.groups?.[namedGroup] ?? ""; - } - // Numbered group: $1, $2, etc. - const groupNum = parseInt(ref, 10); - return match[groupNum] ?? ""; - }, - ); -} - -export interface SearchOptions { - /** Select non-matching lines */ - invertMatch?: boolean; - /** Print line number with output lines */ - showLineNumbers?: boolean; - /** Print only a count of matching lines */ - countOnly?: boolean; - /** Count individual matches instead of lines (--count-matches) */ - countMatches?: boolean; - /** Filename prefix for output (empty string for no prefix) */ - filename?: string; - /** Show only the matching parts of lines */ - onlyMatching?: boolean; - /** Print NUM lines of leading context */ - beforeContext?: number; - /** Print NUM lines of trailing context */ - afterContext?: number; - /** Stop after NUM matches (0 = unlimited) */ - maxCount?: number; - /** Separator between context groups (default: --) */ - contextSeparator?: string; - /** Show column number of first match */ - showColumn?: boolean; - /** Output each match separately (vimgrep format) */ - vimgrep?: boolean; - /** Show byte offset of each match */ - showByteOffset?: boolean; - /** Replace matched text with this string */ - replace?: string | null; - /** Print all lines (matches use :, non-matches use -) */ - passthru?: boolean; - /** Enable multiline matching (patterns can span lines) */ - multiline?: boolean; - /** If \K was used, this is the capture group index containing the "real" match */ - kResetGroup?: number; -} - -export interface SearchResult { - /** The formatted output string */ - output: string; - /** Whether any matches were found */ - matched: boolean; - /** Number of matches found */ - matchCount: number; -} - -/** - * Search content for regex matches and format output - * - * Handles: - * - Count only mode (-c) - * - Line numbers (-n) - * - Invert match (-v) - * - Only matching (-o) - * - Context lines (-A, -B, -C) - * - Max count (-m) - */ -export function searchContent( - content: string, - regex: UserRegex, - options: SearchOptions = {}, -): SearchResult { - const { - invertMatch = false, - showLineNumbers = false, - countOnly = false, - countMatches = false, - filename = "", - onlyMatching = false, - beforeContext = 0, - afterContext = 0, - maxCount = 0, - contextSeparator = "--", - showColumn = false, - vimgrep = false, - showByteOffset = false, - replace = null, - passthru = false, - multiline = false, - kResetGroup, - } = options; - - // Multiline mode: search entire content as one string - if (multiline) { - return searchContentMultiline(content, regex, { - invertMatch, - showLineNumbers, - countOnly, - countMatches, - filename, - onlyMatching, - beforeContext, - afterContext, - maxCount, - contextSeparator, - showColumn, - showByteOffset, - replace, - kResetGroup, - }); - } - - const lines = content.split("\n"); - const lineCount = lines.length; - // Handle trailing empty line from split if content ended with newline - const lastIdx = - lineCount > 0 && lines[lineCount - 1] === "" ? lineCount - 1 : lineCount; - - // Fast path: count only mode - if (countOnly || countMatches) { - let matchCount = 0; - // --count --only-matching behaves like --count-matches - const shouldCountMatches = (countMatches || onlyMatching) && !invertMatch; - for (let i = 0; i < lastIdx; i++) { - regex.lastIndex = 0; - if (shouldCountMatches) { - // Count individual matches on the line - for ( - let match = regex.exec(lines[i]); - match !== null; - match = regex.exec(lines[i]) - ) { - matchCount++; - if (match[0].length === 0) regex.lastIndex++; - } - } else { - // Count lines (with matches, or without matches if inverted) - if (regex.test(lines[i]) !== invertMatch) { - matchCount++; - } - } - } - const countStr = filename - ? `${filename}:${matchCount}` - : String(matchCount); - return { output: `${countStr}\n`, matched: matchCount > 0, matchCount }; - } - - // Fast path: no context needed (most common case) - if (beforeContext === 0 && afterContext === 0 && !passthru) { - const outputLines: string[] = []; - let hasMatch = false; - let matchCount = 0; - let byteOffset = 0; // Track cumulative byte offset - - for (let i = 0; i < lastIdx; i++) { - // Check if we've reached maxCount - if (maxCount > 0 && matchCount >= maxCount) break; - - const line = lines[i]; - regex.lastIndex = 0; - const matches = regex.test(line); - - if (matches !== invertMatch) { - hasMatch = true; - matchCount++; - if (onlyMatching) { - regex.lastIndex = 0; - for ( - let match = regex.exec(line); - match !== null; - match = regex.exec(line) - ) { - // If \K was used, extract from the capture group instead of full match - const rawMatch = - kResetGroup !== undefined ? (match[kResetGroup] ?? "") : match[0]; - const matchText = - replace !== null ? applyReplacement(replace, match) : rawMatch; - let prefix = filename ? `${filename}:` : ""; - if (showByteOffset) prefix += `${byteOffset + match.index}:`; - if (showLineNumbers) prefix += `${i + 1}:`; - if (showColumn) prefix += `${match.index + 1}:`; - outputLines.push(prefix + matchText); - if (match[0].length === 0) regex.lastIndex++; - } - } else if (vimgrep) { - // Vimgrep mode: output each match separately with full line - regex.lastIndex = 0; - for ( - let match = regex.exec(line); - match !== null; - match = regex.exec(line) - ) { - let prefix = filename ? `${filename}:` : ""; - if (showByteOffset) prefix += `${byteOffset + match.index}:`; - if (showLineNumbers) prefix += `${i + 1}:`; - if (showColumn) prefix += `${match.index + 1}:`; - outputLines.push(prefix + line); - if (match[0].length === 0) regex.lastIndex++; - } - } else { - // Get first match position for column - regex.lastIndex = 0; - const firstMatch = regex.exec(line); - const column = firstMatch ? firstMatch.index + 1 : 1; - - // Apply replacement if specified - let outputLine = line; - if (replace !== null) { - regex.lastIndex = 0; - // Use replacer function to skip empty matches (ripgrep behavior) - outputLine = regex.replace(line, (...args) => { - const matchText = args[0] as string; - // Skip empty matches to avoid double replacement with patterns like .* - if (matchText.length === 0) return ""; - // Build match object for applyReplacement - // String.replace args: match, p1, p2, ..., offset, string, [groups] - const match = args as unknown as RegExpExecArray; - // Check if last arg is groups object (string input is always a string) - const lastArg = args[args.length - 1]; - if (typeof lastArg === "object" && lastArg !== null) { - // Has named groups - match.groups = lastArg as Record; - match.input = args[args.length - 2] as string; - match.index = args[args.length - 3] as number; - } else { - // No named groups - match.input = args[args.length - 1] as string; - match.index = args[args.length - 2] as number; - } - return applyReplacement(replace, match); - }); - } - - let prefix = filename ? `${filename}:` : ""; - if (showByteOffset) - prefix += `${byteOffset + (firstMatch ? firstMatch.index : 0)}:`; - if (showLineNumbers) prefix += `${i + 1}:`; - if (showColumn) prefix += `${column}:`; - outputLines.push(prefix + outputLine); - } - } - - // Update byte offset for next line (+1 for newline) - byteOffset += line.length + 1; - } - - return { - output: outputLines.length > 0 ? `${outputLines.join("\n")}\n` : "", - matched: hasMatch, - matchCount, - }; - } - - // Passthru mode: print all lines, matches use :, non-matches use - - if (passthru) { - const outputLines: string[] = []; - let hasMatch = false; - let matchCount = 0; - - for (let i = 0; i < lastIdx; i++) { - const line = lines[i]; - regex.lastIndex = 0; - const matches = regex.test(line); - const isMatch = matches !== invertMatch; - - if (isMatch) { - hasMatch = true; - matchCount++; - } - - // Separator: : for matches, - for non-matches - const sep = isMatch ? ":" : "-"; - - let prefix = filename ? `${filename}${sep}` : ""; - if (showLineNumbers) prefix += `${i + 1}${sep}`; - outputLines.push(prefix + line); - } - - return { - output: outputLines.length > 0 ? `${outputLines.join("\n")}\n` : "", - matched: hasMatch, - matchCount, - }; - } - - // Slow path: context lines needed - const outputLines: string[] = []; - let matchCount = 0; - const printedLines = new Set(); - let lastPrintedLine = -1; - - // First pass: find all matching lines (respecting maxCount) - const matchingLineNumbers: number[] = []; - for (let i = 0; i < lastIdx; i++) { - // Check if we've reached maxCount - if (maxCount > 0 && matchCount >= maxCount) break; - regex.lastIndex = 0; - if (regex.test(lines[i]) !== invertMatch) { - matchingLineNumbers.push(i); - matchCount++; - } - } - - // Second pass: output with context - for (const lineNum of matchingLineNumbers) { - const contextStart = Math.max(0, lineNum - beforeContext); - - // Add separator if there's a gap between this group and the last printed line - if (lastPrintedLine >= 0 && contextStart > lastPrintedLine + 1) { - outputLines.push(contextSeparator); - } - - // Before context - for (let i = contextStart; i < lineNum; i++) { - if (!printedLines.has(i)) { - printedLines.add(i); - lastPrintedLine = i; - let outputLine = lines[i]; - if (showLineNumbers) outputLine = `${i + 1}-${outputLine}`; - if (filename) outputLine = `${filename}-${outputLine}`; - outputLines.push(outputLine); - } - } - - // The matching line - if (!printedLines.has(lineNum)) { - printedLines.add(lineNum); - lastPrintedLine = lineNum; - const line = lines[lineNum]; - - if (onlyMatching) { - regex.lastIndex = 0; - for ( - let match = regex.exec(line); - match !== null; - match = regex.exec(line) - ) { - // If \K was used, extract from the capture group instead of full match - const rawMatch = - kResetGroup !== undefined ? (match[kResetGroup] ?? "") : match[0]; - const matchText = replace !== null ? replace : rawMatch; - let prefix = filename ? `${filename}:` : ""; - if (showLineNumbers) prefix += `${lineNum + 1}:`; - if (showColumn) prefix += `${match.index + 1}:`; - outputLines.push(prefix + matchText); - if (match[0].length === 0) regex.lastIndex++; - } - } else { - let outputLine = line; - if (showLineNumbers) outputLine = `${lineNum + 1}:${outputLine}`; - if (filename) outputLine = `${filename}:${outputLine}`; - outputLines.push(outputLine); - } - } - - // After context - const maxAfter = Math.min(lastIdx - 1, lineNum + afterContext); - for (let i = lineNum + 1; i <= maxAfter; i++) { - if (!printedLines.has(i)) { - printedLines.add(i); - lastPrintedLine = i; - let outputLine = lines[i]; - if (showLineNumbers) outputLine = `${i + 1}-${outputLine}`; - if (filename) outputLine = `${filename}-${outputLine}`; - outputLines.push(outputLine); - } - } - } - - return { - output: outputLines.length > 0 ? `${outputLines.join("\n")}\n` : "", - matched: matchCount > 0, - matchCount, - }; -} - -/** - * Multiline search - searches entire content as one string - * Patterns can match across line boundaries (e.g., 'foo\nbar') - */ -function searchContentMultiline( - content: string, - regex: UserRegex, - options: { - invertMatch: boolean; - showLineNumbers: boolean; - countOnly: boolean; - countMatches: boolean; - filename: string; - onlyMatching: boolean; - beforeContext: number; - afterContext: number; - maxCount: number; - contextSeparator: string; - showColumn: boolean; - showByteOffset: boolean; - replace: string | null; - kResetGroup?: number; - }, -): SearchResult { - const { - invertMatch, - showLineNumbers, - countOnly, - countMatches, - filename, - onlyMatching, - beforeContext, - afterContext, - maxCount, - contextSeparator, - showColumn, - showByteOffset, - replace, - kResetGroup, - } = options; - - const lines = content.split("\n"); - const lineCount = lines.length; - const lastIdx = - lineCount > 0 && lines[lineCount - 1] === "" ? lineCount - 1 : lineCount; - - // Build line offset map: lineOffsets[i] = byte offset where line i starts - const lineOffsets: number[] = [0]; - for (let i = 0; i < content.length; i++) { - if (content[i] === "\n") { - lineOffsets.push(i + 1); - } - } - - // Helper: convert byte offset to line number (0-indexed) - const getLineIndex = (byteOffset: number): number => { - let line = 0; - for (let i = 0; i < lineOffsets.length; i++) { - if (lineOffsets[i] > byteOffset) break; - line = i; - } - return line; - }; - - // Helper: get column within line (1-indexed) - const getColumn = (byteOffset: number): number => { - const lineIdx = getLineIndex(byteOffset); - return byteOffset - lineOffsets[lineIdx] + 1; - }; - - // First pass: find all match spans - const matchSpans: Array<{ - startLine: number; - endLine: number; - byteOffset: number; - column: number; - matchText: string; - }> = []; - - regex.lastIndex = 0; - for ( - let match = regex.exec(content); - match !== null; - match = regex.exec(content) - ) { - if (maxCount > 0 && matchSpans.length >= maxCount) break; - - const startLine = getLineIndex(match.index); - const endLine = getLineIndex( - match.index + Math.max(0, match[0].length - 1), - ); - // If \K was used, extract from the capture group instead of full match - const extractedMatch = - kResetGroup !== undefined ? (match[kResetGroup] ?? "") : match[0]; - matchSpans.push({ - startLine, - endLine, - byteOffset: match.index, - column: getColumn(match.index), - matchText: extractedMatch, - }); - - // Prevent infinite loop on zero-length matches - if (match[0].length === 0) regex.lastIndex++; - } - - // Count mode - if (countOnly || countMatches) { - let matchCount: number; - if (countMatches) { - // Count individual matches - matchCount = invertMatch ? 0 : matchSpans.length; - } else { - // Count lines touched by matches - const matchedLines = new Set(); - for (const span of matchSpans) { - for (let i = span.startLine; i <= span.endLine; i++) { - matchedLines.add(i); - } - } - matchCount = invertMatch - ? lastIdx - matchedLines.size - : matchedLines.size; - } - const countStr = filename - ? `${filename}:${matchCount}` - : String(matchCount); - return { output: `${countStr}\n`, matched: matchCount > 0, matchCount }; - } - - // Inverted match: output lines not part of any match - if (invertMatch) { - const matchedLines = new Set(); - for (const span of matchSpans) { - for (let i = span.startLine; i <= span.endLine; i++) { - matchedLines.add(i); - } - } - - const outputLines: string[] = []; - for (let i = 0; i < lastIdx; i++) { - if (!matchedLines.has(i)) { - let line = lines[i]; - if (showLineNumbers) line = `${i + 1}:${line}`; - if (filename) line = `${filename}:${line}`; - outputLines.push(line); - } - } - - return { - output: outputLines.length > 0 ? `${outputLines.join("\n")}\n` : "", - matched: outputLines.length > 0, - matchCount: outputLines.length, - }; - } - - // No matches found - if (matchSpans.length === 0) { - return { output: "", matched: false, matchCount: 0 }; - } - - // Output with context - const printedLines = new Set(); - let lastPrintedLine = -1; - const outputLines: string[] = []; - - for (const span of matchSpans) { - const contextStart = Math.max(0, span.startLine - beforeContext); - const contextEnd = Math.min(lastIdx - 1, span.endLine + afterContext); - - // Add separator if there's a gap - if (lastPrintedLine >= 0 && contextStart > lastPrintedLine + 1) { - outputLines.push(contextSeparator); - } - - // Before context - for (let i = contextStart; i < span.startLine; i++) { - if (!printedLines.has(i)) { - printedLines.add(i); - lastPrintedLine = i; - let line = lines[i]; - if (showLineNumbers) line = `${i + 1}-${line}`; - if (filename) line = `${filename}-${line}`; - outputLines.push(line); - } - } - - // Match lines - if (onlyMatching) { - // Output only the matched text - const matchText = replace !== null ? replace : span.matchText; - let prefix = filename ? `${filename}:` : ""; - if (showByteOffset) prefix += `${span.byteOffset}:`; - if (showLineNumbers) prefix += `${span.startLine + 1}:`; - if (showColumn) prefix += `${span.column}:`; - outputLines.push(prefix + matchText); - // Mark lines as printed to handle context correctly - for (let i = span.startLine; i <= span.endLine; i++) { - printedLines.add(i); - lastPrintedLine = i; - } - } else { - // Output full lines containing the match - for (let i = span.startLine; i <= span.endLine && i < lastIdx; i++) { - if (!printedLines.has(i)) { - printedLines.add(i); - lastPrintedLine = i; - let line = lines[i]; - // Apply replacement if specified (for the first line of the match) - if (replace !== null && i === span.startLine) { - regex.lastIndex = 0; - line = regex.replace(line, replace); - } - let prefix = filename ? `${filename}:` : ""; - if (showByteOffset && i === span.startLine) - prefix += `${span.byteOffset}:`; - if (showLineNumbers) prefix += `${i + 1}:`; - if (showColumn && i === span.startLine) prefix += `${span.column}:`; - outputLines.push(prefix + line); - } - } - } - - // After context - for (let i = span.endLine + 1; i <= contextEnd; i++) { - if (!printedLines.has(i)) { - printedLines.add(i); - lastPrintedLine = i; - let line = lines[i]; - if (showLineNumbers) line = `${i + 1}-${line}`; - if (filename) line = `${filename}-${line}`; - outputLines.push(line); - } - } - } - - return { - output: outputLines.length > 0 ? `${outputLines.join("\n")}\n` : "", - matched: true, - matchCount: matchSpans.length, - }; -} diff --git a/src/commands/search-engine/regex.ts b/src/commands/search-engine/regex.ts deleted file mode 100644 index 3c87da70..00000000 --- a/src/commands/search-engine/regex.ts +++ /dev/null @@ -1,848 +0,0 @@ -/** - * Regex building utilities for search commands - */ - -import { createUserRegex, type UserRegex } from "../../regex/index.js"; - -/** POSIX character class to JavaScript regex character range mapping (Map prevents prototype pollution) */ -const POSIX_CLASS_MAP = new Map([ - ["alpha", "a-zA-Z"], - ["digit", "0-9"], - ["alnum", "a-zA-Z0-9"], - ["lower", "a-z"], - ["upper", "A-Z"], - ["xdigit", "0-9A-Fa-f"], - ["space", " \\t\\n\\r\\f\\v"], - ["blank", " \\t"], - ["punct", "!-/:-@\\[-`{-~"], - ["graph", "!-~"], - ["print", " -~"], - ["cntrl", "\\x00-\\x1F\\x7F"], - ["ascii", "\\x00-\\x7F"], - ["word", "a-zA-Z0-9_"], -]); - -export type RegexMode = "basic" | "extended" | "fixed" | "perl"; - -export interface RegexOptions { - mode: RegexMode; - ignoreCase?: boolean; - wholeWord?: boolean; - lineRegexp?: boolean; - multiline?: boolean; - /** Makes . match newlines in multiline mode (ripgrep --multiline-dotall) */ - multilineDotall?: boolean; -} - -export interface RegexResult { - regex: UserRegex; - /** If \K was used, this is the 1-based index of the capture group containing the "real" match */ - kResetGroup?: number; -} - -/** - * Transform POSIX character classes in bracket expressions to JavaScript regex equivalents. - * - * Examples: - * - [[:alpha:]] → [a-zA-Z] - * - [[:digit:]] → [0-9] - * - [[:alpha:][:digit:]] → [a-zA-Z0-9] - * - [^[:alpha:]] → [^a-zA-Z] - * - [[:<:]] → (?:]] → (?![\\w]) (word end boundary) - */ -function transformPosixCharacterClasses(pattern: string): string { - let result = ""; - let i = 0; - - while (i < pattern.length) { - // Check for word boundary extensions [[:<:]] and [[:>:]] - // Using \b instead of lookahead/lookbehind for RE2 compatibility - if (pattern.slice(i, i + 7) === "[[:<:]]") { - // Word start boundary - use \b (works at word/non-word boundary) - result += "\\b"; - i += 7; - continue; - } - if (pattern.slice(i, i + 7) === "[[:>:]]") { - // Word end boundary - use \b (works at word/non-word boundary) - result += "\\b"; - i += 7; - continue; - } - - // Check for start of bracket expression - if (pattern[i] === "[") { - // Parse the entire bracket expression - let bracketExpr = "["; - i++; - - // Handle negation - if (i < pattern.length && (pattern[i] === "^" || pattern[i] === "!")) { - bracketExpr += "^"; - i++; - } - - // Handle ] as first char (literal ]) - if (i < pattern.length && pattern[i] === "]") { - bracketExpr += "\\]"; - i++; - } - - // Parse bracket expression contents - while (i < pattern.length && pattern[i] !== "]") { - // Check for POSIX character class [[:name:]] - if ( - pattern[i] === "[" && - i + 1 < pattern.length && - pattern[i + 1] === ":" - ) { - // Find the closing :] - const closeIdx = pattern.indexOf(":]", i + 2); - if (closeIdx !== -1) { - const className = pattern.slice(i + 2, closeIdx); - const replacement = POSIX_CLASS_MAP.get(className); - if (replacement) { - bracketExpr += replacement; - i = closeIdx + 2; - continue; - } - } - } - - // Handle escape sequences - if (pattern[i] === "\\" && i + 1 < pattern.length) { - bracketExpr += pattern[i] + pattern[i + 1]; - i += 2; - continue; - } - - // Regular character - bracketExpr += pattern[i]; - i++; - } - - // Close the bracket expression - if (i < pattern.length && pattern[i] === "]") { - bracketExpr += "]"; - i++; - } - - result += bracketExpr; - continue; - } - - // Handle escape sequences outside bracket expressions - if (pattern[i] === "\\" && i + 1 < pattern.length) { - result += pattern[i] + pattern[i + 1]; - i += 2; - continue; - } - - // Regular character - result += pattern[i]; - i++; - } - - return result; -} - -/** - * Build a JavaScript RegExp from a pattern with the specified mode - */ -export function buildRegex( - pattern: string, - options: RegexOptions, -): RegexResult { - let regexPattern: string; - let kResetGroup: number | undefined; - - switch (options.mode) { - case "fixed": - // Escape all regex special characters for literal match - regexPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - break; - case "extended": - case "perl": { - // Transform POSIX character classes first - regexPattern = transformPosixCharacterClasses(pattern); - - // Convert (?P...) to JavaScript's (?...) syntax - regexPattern = regexPattern.replace(/\(\?P<([^>]+)>/g, "(?<$1>"); - - // Handle Perl-specific features only in perl mode - if (options.mode === "perl") { - // Handle \Q...\E (quote metacharacters) - regexPattern = handleQuoteMetachars(regexPattern); - - // Handle \x{NNNN} Unicode code points -> \u{NNNN} - regexPattern = handleUnicodeCodePoints(regexPattern); - - // Handle inline modifiers (?i:...), (?i), etc. - regexPattern = handleInlineModifiers(regexPattern); - - // Handle \K (Perl regex reset match start) - const kResult = handlePerlKReset(regexPattern); - regexPattern = kResult.pattern; - kResetGroup = kResult.kResetGroup; - } - break; - } - default: - // BRE mode: transform POSIX classes first, then convert BRE to JS regex - regexPattern = transformPosixCharacterClasses(pattern); - regexPattern = escapeRegexForBasicGrep(regexPattern); - break; - } - - if (options.wholeWord) { - // Wrap in non-capturing group to handle alternation properly - // e.g., min|max should become \b(?:min|max)\b, not \bmin|max\b - // Using \b for RE2 compatibility (RE2 doesn't support lookahead/lookbehind) - regexPattern = `\\b(?:${regexPattern})\\b`; - } - if (options.lineRegexp) { - regexPattern = `^${regexPattern}$`; - } - - // Build flags: - // - g: global matching - // - i: case insensitive - // - m: multiline (^ and $ match at line boundaries) - // - s: dotall (. matches newlines) - // - u: unicode (needed for \u{NNNN} syntax) - const needsUnicode = /\\u\{[0-9A-Fa-f]+\}/.test(regexPattern); - const flags = - "g" + - (options.ignoreCase ? "i" : "") + - (options.multiline ? "m" : "") + - (options.multilineDotall ? "s" : "") + - (needsUnicode ? "u" : ""); - return { regex: createUserRegex(regexPattern, flags), kResetGroup }; -} - -/** - * Handle \Q...\E (quote metacharacters). - * Everything between \Q and \E is treated as literal text. - * If \E is missing, quotes until end of pattern. - */ -function handleQuoteMetachars(pattern: string): string { - let result = ""; - let i = 0; - - while (i < pattern.length) { - // Check for \Q - if ( - pattern[i] === "\\" && - i + 1 < pattern.length && - pattern[i + 1] === "Q" - ) { - // Skip \Q - i += 2; - - // Find matching \E or end of string - let quoted = ""; - while (i < pattern.length) { - if ( - pattern[i] === "\\" && - i + 1 < pattern.length && - pattern[i + 1] === "E" - ) { - // Found \E, skip it - i += 2; - break; - } - quoted += pattern[i]; - i++; - } - - // Escape all regex metacharacters in the quoted section - result += quoted.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - } else { - result += pattern[i]; - i++; - } - } - - return result; -} - -/** - * Handle \x{NNNN} Unicode code points. - * Converts Perl's \x{NNNN} to JavaScript's \u{NNNN}. - */ -function handleUnicodeCodePoints(pattern: string): string { - // Convert \x{NNNN} to \u{NNNN} - // The pattern matches \x{ followed by hex digits and } - return pattern.replace(/\\x\{([0-9A-Fa-f]+)\}/g, "\\u{$1}"); -} - -/** - * Handle inline modifiers like (?i:...), (?i), (?-i), etc. - * - * Supported modifiers: - * - i: case insensitive - * - m: multiline (^ and $ match at line boundaries) - already default in our impl - * - s: single-line mode (. matches newlines) - * - x: extended mode (ignore whitespace) - not fully supported - * - * Forms: - * - (?i) - Turn on modifier for rest of pattern (simplified: applies to whole pattern) - * - (?-i) - Turn off modifier (simplified: removes from rest) - * - (?i:pattern) - Apply modifier only to this group - */ -function handleInlineModifiers(pattern: string): string { - let result = ""; - let i = 0; - - while (i < pattern.length) { - // Look for (? - if ( - pattern[i] === "(" && - i + 1 < pattern.length && - pattern[i + 1] === "?" - ) { - // Check if this is a modifier group - const modifierMatch = pattern - .slice(i) - .match(/^\(\?([imsx]*)(-[imsx]*)?(:|$|\))/); - - if (modifierMatch) { - const enableMods = modifierMatch[1] || ""; - const disableMods = modifierMatch[2] || ""; - const delimiter = modifierMatch[3]; - - if (delimiter === ":") { - // (?i:pattern) form - apply modifiers to group content - const groupStart = i + modifierMatch[0].length - 1; // position of : - const groupEnd = findMatchingParen(pattern, i); - - if (groupEnd !== -1) { - const groupContent = pattern.slice(groupStart + 1, groupEnd); - const transformed = applyInlineModifiers( - groupContent, - enableMods, - disableMods, - ); - result += `(?:${transformed})`; - i = groupEnd + 1; - continue; - } - } else if (delimiter === ")" || delimiter === "") { - // (?i) form - modifier only, no content - // For simplicity, we just remove these as they're hard to emulate precisely - // The caller should use -i flag for case insensitivity - i += modifierMatch[0].length; - continue; - } - } - } - - result += pattern[i]; - i++; - } - - return result; -} - -/** - * Find the matching closing parenthesis for an opening one at position start. - */ -function findMatchingParen(pattern: string, start: number): number { - let depth = 0; - let i = start; - - while (i < pattern.length) { - if (pattern[i] === "\\") { - // Skip escaped character - i += 2; - continue; - } - - if (pattern[i] === "[") { - // Skip character class - i++; - while (i < pattern.length && pattern[i] !== "]") { - if (pattern[i] === "\\") i++; - i++; - } - i++; - continue; - } - - if (pattern[i] === "(") { - depth++; - } else if (pattern[i] === ")") { - depth--; - if (depth === 0) { - return i; - } - } - i++; - } - - return -1; -} - -/** - * Apply inline modifiers to a pattern segment. - * For (?i:pattern), we convert letters to character classes [Aa]. - */ -function applyInlineModifiers( - pattern: string, - enableMods: string, - _disableMods: string, -): string { - let result = pattern; - - // Handle case-insensitive modifier - if (enableMods.includes("i")) { - result = makeCaseInsensitive(result); - } - - // Note: 's' modifier (dotall) would need special handling - // For now, we rely on the global flag if needed - - return result; -} - -/** - * Convert a pattern to be case-insensitive by replacing letters with character classes. - * e.g., "abc" -> "[Aa][Bb][Cc]" - * Character classes like [cd] become [cdCD] - */ -function makeCaseInsensitive(pattern: string): string { - let result = ""; - let i = 0; - - while (i < pattern.length) { - const char = pattern[i]; - - if (char === "\\") { - // Keep escape sequences as-is - if (i + 1 < pattern.length) { - result += char + pattern[i + 1]; - i += 2; - } else { - result += char; - i++; - } - continue; - } - - if (char === "[") { - // Make character class case-insensitive - result += char; - i++; - - // Check for negation - if (i < pattern.length && pattern[i] === "^") { - result += pattern[i]; - i++; - } - - // Collect all characters and make them case-insensitive - const classChars: string[] = []; - while (i < pattern.length && pattern[i] !== "]") { - if (pattern[i] === "\\") { - // Keep escape sequences as-is - classChars.push(pattern[i]); - i++; - if (i < pattern.length) { - classChars.push(pattern[i]); - i++; - } - } else if ( - pattern[i] === "-" && - classChars.length > 0 && - i + 1 < pattern.length && - pattern[i + 1] !== "]" - ) { - // Range like a-z - keep as-is but also add uppercase range - const rangeStart = classChars[classChars.length - 1]; - const rangeEnd = pattern[i + 1]; - classChars.push("-"); - classChars.push(rangeEnd); - - // Add uppercase equivalents if both are letters - if (/[a-z]/.test(rangeStart) && /[a-z]/.test(rangeEnd)) { - classChars.push(rangeStart.toUpperCase()); - classChars.push("-"); - classChars.push(rangeEnd.toUpperCase()); - } else if (/[A-Z]/.test(rangeStart) && /[A-Z]/.test(rangeEnd)) { - classChars.push(rangeStart.toLowerCase()); - classChars.push("-"); - classChars.push(rangeEnd.toLowerCase()); - } - i += 2; - } else { - const c = pattern[i]; - classChars.push(c); - // Add case variant for letters - if (/[a-zA-Z]/.test(c)) { - const variant = - c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase(); - if (!classChars.includes(variant)) { - classChars.push(variant); - } - } - i++; - } - } - - result += classChars.join(""); - if (i < pattern.length) { - result += pattern[i]; // ] - i++; - } - continue; - } - - // Convert letters to case-insensitive character class - if (/[a-zA-Z]/.test(char)) { - const lower = char.toLowerCase(); - const upper = char.toUpperCase(); - result += `[${upper}${lower}]`; - } else { - result += char; - } - i++; - } - - return result; -} - -/** - * Handle Perl's \K (keep/reset match start) operator. - * \K causes everything matched before it to be excluded from the final match result. - * - * We emulate this by: - * 1. Wrapping the part before \K in a non-capturing group - * 2. Wrapping the part after \K in a capturing group - * 3. Returning the index of that capturing group so the matcher can use it - */ -function handlePerlKReset(pattern: string): { - pattern: string; - kResetGroup?: number; -} { - // Find \K that's not escaped (not preceded by odd number of backslashes) - // We need to find \K that represents the reset operator, not a literal \\K - const kIndex = findUnescapedK(pattern); - - if (kIndex === -1) { - return { pattern }; - } - - const before = pattern.slice(0, kIndex); - const after = pattern.slice(kIndex + 2); // Skip \K - - // Count existing capturing groups before the split to determine our group number - const groupsBefore = countCapturingGroups(before); - - // Wrap: (?:before)(after) - non-capturing for prefix, capturing for the part we want - const newPattern = `(?:${before})(${after})`; - - return { - pattern: newPattern, - // The capturing group for "after" will be groupsBefore + 1 - kResetGroup: groupsBefore + 1, - }; -} - -/** - * Find the index of \K in a pattern, ignoring escaped backslashes - */ -function findUnescapedK(pattern: string): number { - let i = 0; - while (i < pattern.length - 1) { - if (pattern[i] === "\\") { - if (pattern[i + 1] === "K") { - // Check if the backslash itself is escaped by counting preceding backslashes - let backslashCount = 0; - let j = i - 1; - while (j >= 0 && pattern[j] === "\\") { - backslashCount++; - j--; - } - // If even number of preceding backslashes, this \K is not escaped - if (backslashCount % 2 === 0) { - return i; - } - } - // Skip the escaped character - i += 2; - } else { - i++; - } - } - return -1; -} - -/** - * Count the number of capturing groups in a regex pattern. - * Excludes non-capturing groups (?:...), lookahead (?=...), (?!...), - * lookbehind (?<=...), (?...) which we count. - */ -function countCapturingGroups(pattern: string): number { - let count = 0; - let i = 0; - - while (i < pattern.length) { - if (pattern[i] === "\\") { - // Skip escaped character - i += 2; - continue; - } - - if (pattern[i] === "[") { - // Skip character class - i++; - while (i < pattern.length && pattern[i] !== "]") { - if (pattern[i] === "\\") i++; - i++; - } - i++; // Skip ] - continue; - } - - if (pattern[i] === "(") { - if (i + 1 < pattern.length && pattern[i + 1] === "?") { - // Check what kind of group - if (i + 2 < pattern.length) { - const nextChar = pattern[i + 2]; - if (nextChar === ":" || nextChar === "=" || nextChar === "!") { - // Non-capturing or lookahead - don't count - i++; - continue; - } - if (nextChar === "<") { - // Could be lookbehind (?<= or (? - if (i + 3 < pattern.length) { - const afterLt = pattern[i + 3]; - if (afterLt === "=" || afterLt === "!") { - // Lookbehind - don't count - i++; - continue; - } - // Named group - count it - count++; - i++; - continue; - } - } - } - } else { - // Regular capturing group - count++; - } - } - i++; - } - - return count; -} - -/** - * Convert replacement string syntax to JavaScript's String.replace format - * - * Conversions: - * - $0 and ${0} -> $& (full match) - * - $name -> $ (named capture groups) - * - ${name} -> $ (braced named capture groups) - * - Preserves $1, $2, etc. for numbered groups - */ -export function convertReplacement(replacement: string): string { - // First, convert $0 and ${0} to $& (use $$& to produce literal $& in output) - let result = replacement.replace(/\$\{0\}|\$0(?![0-9])/g, "$$&"); - - // Convert ${name} to $ for non-numeric names - result = result.replace(/\$\{([^0-9}][^}]*)\}/g, "$$<$1>"); - - // Convert $name to $ for non-numeric names (not followed by > which would already be converted) - // Match $name where name starts with letter or underscore and contains word chars - result = result.replace(/\$([a-zA-Z_][a-zA-Z0-9_]*)(?![>0-9])/g, "$$<$1>"); - - return result; -} - -/** - * Convert Basic Regular Expression (BRE) to JavaScript regex - * - * In BRE: - * - \| is alternation (becomes | in JS) - * - \( \) are groups (become ( ) in JS) - * - \{n\}, \{n,\}, \{n,m\} are interval expressions (become {n}, {n,}, {n,m} in JS) - * - \{,n\} and \{,\} are literal (invalid POSIX interval) - * - + ? | ( ) { } are literal (must be escaped in JS) - * - * at pattern start or after ^ is literal - * - ^ is anchor at start of pattern or start of \(...\) group; literal elsewhere - * - $ is anchor at end of pattern or end of \(...\) group; literal elsewhere - */ -function escapeRegexForBasicGrep(str: string): string { - let result = ""; - let i = 0; - // Track if we're at a position where * would be literal - // (at start of pattern/group, or right after ^ anchor) - let atPatternStart = true; - // Track nesting depth for \( \) groups - let groupDepth = 0; - - while (i < str.length) { - const char = str[i]; - - // Handle bracket expressions - copy them through without modification - // Bracket expressions have already been processed by transformPosixCharacterClasses - if (char === "[") { - result += char; - i++; - // Handle negation or first ] in bracket expression - if (i < str.length && (str[i] === "^" || str[i] === "!")) { - result += str[i]; - i++; - } - // Handle ] as first char (literal ]) - if (i < str.length && str[i] === "]") { - result += str[i]; - i++; - } - // Copy everything until closing ] - while (i < str.length && str[i] !== "]") { - if (str[i] === "\\" && i + 1 < str.length) { - result += str[i] + str[i + 1]; - i += 2; - } else { - result += str[i]; - i++; - } - } - // Copy closing ] - if (i < str.length && str[i] === "]") { - result += str[i]; - i++; - } - atPatternStart = false; - continue; - } - - if (char === "\\" && i + 1 < str.length) { - const nextChar = str[i + 1]; - // BRE: \| becomes | (alternation) - if (nextChar === "|") { - result += "|"; - i += 2; - atPatternStart = true; // After alternation, ^ and * rules apply at start of alternative - continue; - } - // BRE: \( starts a group - if (nextChar === "(") { - result += "("; - i += 2; - groupDepth++; - atPatternStart = true; // ^ and * rules apply at group start - continue; - } - // BRE: \) ends a group - if (nextChar === ")") { - result += ")"; - i += 2; - groupDepth = Math.max(0, groupDepth - 1); - atPatternStart = false; - continue; - } - if (nextChar === "{") { - // Check for BRE interval expression: \{n\}, \{n,\}, \{n,m\} - // Valid intervals start with a digit (not comma) - const remaining = str.slice(i); - const intervalMatch = remaining.match(/^\\{(\d+)(,(\d*)?)?\\}/); - if (intervalMatch) { - const min = intervalMatch[1]; - const hasComma = intervalMatch[2] !== undefined; - const max = intervalMatch[3] || ""; - // Convert to JavaScript interval syntax - if (hasComma) { - result += `{${min},${max}}`; - } else { - result += `{${min}}`; - } - i += intervalMatch[0].length; - atPatternStart = false; - continue; - } - // Not a valid interval - treat \{ as literal { - result += `\\{`; - i += 2; - atPatternStart = false; - continue; - } - if (nextChar === "}") { - // \} outside of interval - literal } - result += `\\}`; - i += 2; - atPatternStart = false; - continue; - } - // Any other escape - pass through - result += char + nextChar; - i += 2; - atPatternStart = false; - continue; - } - - // Handle * - literal at pattern/group start or after ^ - if (char === "*" && atPatternStart) { - result += "\\*"; - i++; - // Stay at pattern start so consecutive *'s are also escaped - continue; - } - - // Handle ^ - anchor at pattern/group start, literal elsewhere - if (char === "^") { - if (atPatternStart) { - result += "^"; - i++; - // After ^, we're still at a position where * would be literal - continue; - } - // ^ in middle - literal - result += "\\^"; - i++; - continue; - } - - // Handle $ - anchor at pattern end or before \), literal elsewhere - if (char === "$") { - // Check if this is at end of pattern or followed by \) - const isAtEnd = i === str.length - 1; - const isBeforeGroupEnd = - i + 2 < str.length && str[i + 1] === "\\" && str[i + 2] === ")"; - if (isAtEnd || isBeforeGroupEnd) { - result += "$"; - } else { - result += "\\$"; - } - i++; - atPatternStart = false; - continue; - } - - // Escape characters that are special in JavaScript regex but not in BRE - if ( - char === "+" || - char === "?" || - char === "|" || - char === "(" || - char === ")" || - char === "{" || - char === "}" - ) { - result += `\\${char}`; - } else { - result += char; - } - i++; - atPatternStart = false; - } - - return result; -} diff --git a/src/commands/sed/executor.ts b/src/commands/sed/executor.ts deleted file mode 100644 index 9b44e834..00000000 --- a/src/commands/sed/executor.ts +++ /dev/null @@ -1,1116 +0,0 @@ -// Executor for sed commands - -import { ExecutionLimitError } from "../../interpreter/errors.js"; -import { createUserRegex } from "../../regex/index.js"; -import { breToEre, escapeForList, normalizeForJs } from "./sed-regex.js"; -import type { - AddressRange, - BranchCommand, - BranchOnNoSubstCommand, - BranchOnSubstCommand, - GroupCommand, - SedAddress, - SedCommand, - SedExecutionLimits, - SedState, - StepAddress, - SubstituteCommand, - TransliterateCommand, -} from "./types.js"; - -const DEFAULT_MAX_ITERATIONS = 10000; - -export function createInitialState( - totalLines: number, - filename?: string, - rangeStates?: Map, -): SedState { - return { - patternSpace: "", - holdSpace: "", - lineNumber: 0, - totalLines, - deleted: false, - printed: false, - quit: false, - quitSilent: false, - exitCode: undefined, - errorMessage: undefined, - appendBuffer: [], - substitutionMade: false, - lineNumberOutput: [], - nCommandOutput: [], - restartCycle: false, - inDRestartedCycle: false, - currentFilename: filename, - pendingFileReads: [], - pendingFileWrites: [], - pendingExecute: undefined, - rangeStates: rangeStates || new Map(), - linesConsumedInCycle: 0, - }; -} - -function isStepAddress(address: SedAddress): address is StepAddress { - return typeof address === "object" && "first" in address && "step" in address; -} - -function isRelativeOffset( - address: SedAddress, -): address is import("./types.js").RelativeOffset { - return typeof address === "object" && "offset" in address; -} - -function matchesAddress( - address: SedAddress, - lineNum: number, - totalLines: number, - line: string, - state?: SedState, -): boolean { - if (address === "$") { - return lineNum === totalLines; - } - if (typeof address === "number") { - return lineNum === address; - } - // Step address: first~step (e.g., 0~2 matches lines 0, 2, 4, ...) - if (isStepAddress(address)) { - const { first, step } = address; - if (step === 0) return lineNum === first; - return (lineNum - first) % step === 0 && lineNum >= first; - } - if (typeof address === "object" && "pattern" in address) { - try { - // Handle empty pattern (reuse last pattern) - let rawPattern = address.pattern; - if (rawPattern === "" && state?.lastPattern) { - rawPattern = state.lastPattern; - } else if (rawPattern !== "" && state) { - // Track this pattern for future empty regex reuse - state.lastPattern = rawPattern; - } - // Convert BRE to ERE for JavaScript regex compatibility - // Then normalize for JavaScript (e.g., {,n} → {0,n}) - const pattern = normalizeForJs(breToEre(rawPattern)); - const regex = createUserRegex(pattern); - return regex.test(line); - } catch { - return false; - } - } - return false; -} - -/** - * Serialize an address range to a string for use as a map key. - */ -function serializeRange(range: AddressRange): string { - const serializeAddr = (addr: SedAddress | undefined): string => { - if (addr === undefined) return "undefined"; - if (addr === "$") return "$"; - if (typeof addr === "number") return String(addr); - if ("pattern" in addr) return `/${addr.pattern}/`; - if ("first" in addr) return `${addr.first}~${addr.step}`; - return "unknown"; - }; - return `${serializeAddr(range.start)},${serializeAddr(range.end)}`; -} - -function isInRangeInternal( - range: AddressRange | undefined, - lineNum: number, - totalLines: number, - line: string, - rangeStates?: Map, - state?: SedState, -): boolean { - if (!range || (!range.start && !range.end)) { - return true; // No address means match all lines - } - - const start = range.start; - const end = range.end; - - if (start !== undefined && end === undefined) { - // Single address - return matchesAddress(start, lineNum, totalLines, line, state); - } - - if (start !== undefined && end !== undefined) { - // Address range - needs state tracking for pattern addresses - const hasPatternStart = typeof start === "object" && "pattern" in start; - const hasPatternEnd = typeof end === "object" && "pattern" in end; - const hasRelativeEnd = isRelativeOffset(end); - - // Handle relative offset end address (GNU extension: /pattern/,+N) - if (hasRelativeEnd && rangeStates) { - const rangeKey = serializeRange(range); - let rangeState = rangeStates.get(rangeKey); - - if (!rangeState) { - rangeState = { active: false }; - rangeStates.set(rangeKey, rangeState); - } - - if (!rangeState.active) { - // Not in range yet - check if start matches - // For relative offset ranges, allow restarting (don't check completed) - const startMatches = matchesAddress( - start, - lineNum, - totalLines, - line, - state, - ); - - if (startMatches) { - rangeState.active = true; - rangeState.startLine = lineNum; - rangeStates.set(rangeKey, rangeState); - - // Check if offset is 0 (match only the start line) - if (end.offset === 0) { - rangeState.active = false; - rangeStates.set(rangeKey, rangeState); - } - return true; - } - return false; - } else { - // Already in range - check if we've matched enough lines - const startLine = rangeState.startLine || lineNum; - if (lineNum >= startLine + end.offset) { - // This is the last line in the range - rangeState.active = false; - rangeStates.set(rangeKey, rangeState); - } - return true; - } - } - - // If both are numeric, check for backward range (need state tracking) - if (!hasPatternStart && !hasPatternEnd && !hasRelativeEnd) { - const startNum = - typeof start === "number" ? start : start === "$" ? totalLines : 1; - const endNum = - typeof end === "number" ? end : end === "$" ? totalLines : totalLines; - - // For forward ranges (start <= end), use simple check - if (startNum <= endNum) { - return lineNum >= startNum && lineNum <= endNum; - } - - // For backward ranges (start > end), use state tracking - // GNU sed behavior: match only when start is first reached/passed - if (rangeStates) { - const rangeKey = serializeRange(range); - let rangeState = rangeStates.get(rangeKey); - - if (!rangeState) { - rangeState = { active: false }; - rangeStates.set(rangeKey, rangeState); - } - - if (!rangeState.completed) { - if (lineNum >= startNum) { - rangeState.completed = true; - rangeStates.set(rangeKey, rangeState); - return true; - } - } - return false; - } - - // Fallback: no state tracking available, can't handle backward range - return false; - } - - // For pattern ranges, use state tracking - if (rangeStates) { - const rangeKey = serializeRange(range); - let rangeState = rangeStates.get(rangeKey); - - if (!rangeState) { - rangeState = { active: false }; - rangeStates.set(rangeKey, rangeState); - } - - if (!rangeState.active) { - // Not in range yet - check if start matches - // For numeric start addresses, GNU sed activates the range if lineNum >= start - // (this handles the case where line N was deleted before this command was reached) - // But don't reactivate if the range was already completed - if (rangeState.completed) { - return false; - } - - let startMatches = false; - if (typeof start === "number") { - startMatches = lineNum >= start; - } else { - startMatches = matchesAddress( - start, - lineNum, - totalLines, - line, - state, - ); - } - - if (startMatches) { - rangeState.active = true; - rangeState.startLine = lineNum; - rangeStates.set(rangeKey, rangeState); - - // Check if end also matches on the same line - if (matchesAddress(end, lineNum, totalLines, line, state)) { - rangeState.active = false; - // Mark as completed for numeric start ranges - if (typeof start === "number") { - rangeState.completed = true; - } - rangeStates.set(rangeKey, rangeState); - } - return true; - } - return false; - } else { - // Already in range - check if end matches - if (matchesAddress(end, lineNum, totalLines, line, state)) { - rangeState.active = false; - // Mark as completed for numeric start ranges - if (typeof start === "number") { - rangeState.completed = true; - } - rangeStates.set(rangeKey, rangeState); - } - return true; - } - } - - // Fallback for no range state tracking (shouldn't happen) - const startMatches = matchesAddress( - start, - lineNum, - totalLines, - line, - state, - ); - return startMatches; - } - - return true; -} - -function isInRange( - range: AddressRange | undefined, - lineNum: number, - totalLines: number, - line: string, - rangeStates?: Map, - state?: SedState, -): boolean { - const result = isInRangeInternal( - range, - lineNum, - totalLines, - line, - rangeStates, - state, - ); - - // Handle negation modifier - if (range?.negated) { - return !result; - } - - return result; -} - -/** - * Custom global replacement function that handles zero-length matches correctly. - * POSIX sed behavior: - * 1. After a zero-length match: replace, then advance by 1 char, output that char - * 2. After a non-zero-length match: if next position would be a zero-length match, skip it - */ -function globalReplace( - input: string, - regex: import("../../regex/index.js").UserRegex, - _replacement: string, - replaceFn: (match: string, groups: string[]) => string, -): string { - let result = ""; - let pos = 0; - let skipZeroLengthAtNextPos = false; - - while (pos <= input.length) { - // Reset lastIndex to current position - regex.lastIndex = pos; - - const match = regex.exec(input); - - // No match found at or after current position - if (!match) { - // Output remaining characters - result += input.slice(pos); - break; - } - - // Match found, but not at current position - if (match.index !== pos) { - // Output characters up to the match - result += input.slice(pos, match.index); - pos = match.index; - skipZeroLengthAtNextPos = false; - continue; - } - - // Match found at current position - const matchedText = match[0]; - const groups = match.slice(1); - - // After a non-zero match, skip zero-length matches at the boundary - if (skipZeroLengthAtNextPos && matchedText.length === 0) { - // Skip this zero-length match, output the character, advance - if (pos < input.length) { - result += input[pos]; - pos++; - } else { - break; - } - skipZeroLengthAtNextPos = false; - continue; - } - - // Apply replacement - result += replaceFn(matchedText, groups); - skipZeroLengthAtNextPos = false; - - if (matchedText.length === 0) { - // Zero-length match: advance by 1 char, output that char - if (pos < input.length) { - result += input[pos]; - pos++; - } else { - break; // At end of string - } - } else { - // Non-zero-length match: advance by match length - // Set flag to skip zero-length match at next position - pos += matchedText.length; - skipZeroLengthAtNextPos = true; - } - } - - return result; -} - -function processReplacement( - replacement: string, - match: string, - groups: string[], -): string { - let result = ""; - let i = 0; - - while (i < replacement.length) { - if (replacement[i] === "\\") { - if (i + 1 < replacement.length) { - const next = replacement[i + 1]; - if (next === "&") { - result += "&"; - i += 2; - continue; - } - if (next === "n") { - result += "\n"; - i += 2; - continue; - } - if (next === "t") { - result += "\t"; - i += 2; - continue; - } - if (next === "r") { - result += "\r"; - i += 2; - continue; - } - // Back-references \0 through \9 - // \0 is the entire match (same as &) - const digit = parseInt(next, 10); - if (digit === 0) { - result += match; - i += 2; - continue; - } - if (digit >= 1 && digit <= 9) { - result += groups[digit - 1] || ""; - i += 2; - continue; - } - // Other escaped characters - result += next; - i += 2; - continue; - } - } - - if (replacement[i] === "&") { - result += match; - i++; - continue; - } - - result += replacement[i]; - i++; - } - - return result; -} - -function executeCommand(cmd: SedCommand, state: SedState): void { - const { lineNumber, totalLines, patternSpace } = state; - - // Labels don't have addresses and are handled separately - if (cmd.type === "label") { - state.coverage?.hit(`sed:cmd:${cmd.type}`); - return; - } - - // Check if command applies to current line - if ( - !isInRange( - cmd.address, - lineNumber, - totalLines, - patternSpace, - state.rangeStates, - state, - ) - ) { - return; - } - - state.coverage?.hit(`sed:cmd:${cmd.type}`); - switch (cmd.type) { - case "substitute": { - const subCmd = cmd as SubstituteCommand; - let flags = ""; - if (subCmd.global) flags += "g"; - if (subCmd.ignoreCase) flags += "i"; - - // Handle empty pattern (reuse last pattern) - let rawPattern = subCmd.pattern; - if (rawPattern === "" && state.lastPattern) { - rawPattern = state.lastPattern; - } else if (rawPattern !== "") { - // Track this pattern for future empty regex reuse - state.lastPattern = rawPattern; - } - - // Convert BRE to ERE if not using extended regex mode - // BRE: +, ?, |, (, ) are literal; \+, \?, \|, \(, \) are special - // ERE (JavaScript): +, ?, |, (, ) are special - // Then normalize for JavaScript (e.g., {,n} → {0,n}) - const pattern = normalizeForJs( - subCmd.extendedRegex ? rawPattern : breToEre(rawPattern), - ); - - try { - const regex = createUserRegex(pattern, flags); - - // Check if pattern matches FIRST - for t/T command tracking - // t should branch if substitution matched, even if replacement is same as original - const hasMatch = regex.test(state.patternSpace); - // Reset lastIndex after test() for global regex - regex.lastIndex = 0; - - if (hasMatch) { - // Mark substitution as successful BEFORE replacement (for t/T commands) - state.substitutionMade = true; - - // Handle Nth occurrence - if ( - subCmd.nthOccurrence && - subCmd.nthOccurrence > 0 && - !subCmd.global - ) { - let count = 0; - const nth = subCmd.nthOccurrence; - const nthRegex = createUserRegex( - pattern, - `g${subCmd.ignoreCase ? "i" : ""}`, - ); - state.patternSpace = nthRegex.replace( - state.patternSpace, - (match, ...args) => { - count++; - if (count === nth) { - const groups = args.slice(0, -2) as string[]; - return processReplacement(subCmd.replacement, match, groups); - } - return match; - }, - ); - } else if (subCmd.global) { - // Use custom global replace for POSIX-compliant zero-length match handling - const globalRegex = createUserRegex( - pattern, - `g${subCmd.ignoreCase ? "i" : ""}`, - ); - state.patternSpace = globalReplace( - state.patternSpace, - globalRegex, - subCmd.replacement, - (match, groups) => - processReplacement(subCmd.replacement, match, groups), - ); - } else { - state.patternSpace = regex.replace( - state.patternSpace, - (match, ...args) => { - // Extract captured groups (all args before the last two which are offset and string) - const groups = args.slice(0, -2) as string[]; - return processReplacement(subCmd.replacement, match, groups); - }, - ); - } - - if (subCmd.printOnMatch) { - // p flag - immediately print pattern space after substitution - state.lineNumberOutput.push(state.patternSpace); - } - } - } catch { - // Invalid regex, skip - } - break; - } - - case "print": - // p - immediately print pattern space - state.lineNumberOutput.push(state.patternSpace); - break; - - case "printFirstLine": { - // P - print up to first newline - const newlineIdx = state.patternSpace.indexOf("\n"); - if (newlineIdx !== -1) { - state.lineNumberOutput.push(state.patternSpace.slice(0, newlineIdx)); - } else { - state.lineNumberOutput.push(state.patternSpace); - } - break; - } - - case "delete": - state.deleted = true; - break; - - case "deleteFirstLine": { - // D - delete up to first newline, restart cycle if more content - const newlineIdx = state.patternSpace.indexOf("\n"); - if (newlineIdx !== -1) { - state.patternSpace = state.patternSpace.slice(newlineIdx + 1); - // Restart the cycle from the beginning with remaining content - state.restartCycle = true; - state.inDRestartedCycle = true; - } else { - state.deleted = true; - } - break; - } - - case "zap": - // z - empty pattern space (GNU extension) - state.patternSpace = ""; - break; - - case "append": - state.appendBuffer.push(cmd.text); - break; - - case "insert": - // Insert happens before the current line - // We'll handle this in the main loop by prepending - state.appendBuffer.unshift(`__INSERT__${cmd.text}`); - break; - - case "change": - // Replace the current line entirely - text is output in place of pattern space - state.deleted = true; // Don't print original pattern space - state.changedText = cmd.text; // Output this in place of pattern space - break; - - case "hold": - // h - Copy pattern space to hold space - state.holdSpace = state.patternSpace; - break; - - case "holdAppend": - // H - Append pattern space to hold space (with newline) - if (state.holdSpace) { - state.holdSpace += `\n${state.patternSpace}`; - } else { - state.holdSpace = state.patternSpace; - } - break; - - case "get": - // g - Copy hold space to pattern space - state.patternSpace = state.holdSpace; - break; - - case "getAppend": - // G - Append hold space to pattern space (with newline) - state.patternSpace += `\n${state.holdSpace}`; - break; - - case "exchange": { - // x - Exchange pattern and hold spaces - const temp = state.patternSpace; - state.patternSpace = state.holdSpace; - state.holdSpace = temp; - break; - } - - case "next": - // n - Print pattern space (if not in quiet mode), read next line - // This will be handled in the main loop - state.printed = true; - break; - - case "quit": - state.quit = true; - if (cmd.exitCode !== undefined) { - state.exitCode = cmd.exitCode; - } - break; - - case "quitSilent": - // Q - quit without printing pattern space - state.quit = true; - state.quitSilent = true; - if (cmd.exitCode !== undefined) { - state.exitCode = cmd.exitCode; - } - break; - - case "list": { - // l - list pattern space with escapes - const escaped = escapeForList(state.patternSpace); - state.lineNumberOutput.push(escaped); - break; - } - - case "printFilename": - // F - print current filename - if (state.currentFilename) { - state.lineNumberOutput.push(state.currentFilename); - } - break; - - case "version": { - // v - version check - // We claim to be GNU sed 4.8 - const OUR_VERSION = [4, 8, 0]; - - if (cmd.minVersion) { - // Parse version string (e.g., "4.5.3" or "4.5") - const parts = cmd.minVersion.split("."); - const requestedVersion: number[] = []; - let parseError = false; - - for (const part of parts) { - const num = parseInt(part, 10); - if (Number.isNaN(num) || num < 0) { - // Invalid version format - state.quit = true; - state.exitCode = 1; - state.errorMessage = `sed: invalid version string: ${cmd.minVersion}`; - parseError = true; - break; - } - requestedVersion.push(num); - } - - if (!parseError) { - // Pad to 3 parts for comparison - while (requestedVersion.length < 3) { - requestedVersion.push(0); - } - - // Compare versions - for (let i = 0; i < 3; i++) { - if (requestedVersion[i] > OUR_VERSION[i]) { - // Requested version is newer than ours - state.quit = true; - state.exitCode = 1; - state.errorMessage = `sed: this is not GNU sed version ${cmd.minVersion}`; - break; - } - if (requestedVersion[i] < OUR_VERSION[i]) { - // Our version is newer, we're good - break; - } - } - } - } - break; - } - - case "readFile": - // r - queue file read (deferred execution) - state.pendingFileReads.push({ filename: cmd.filename, wholeFile: true }); - break; - - case "readFileLine": - // R - queue single line file read (deferred execution) - state.pendingFileReads.push({ filename: cmd.filename, wholeFile: false }); - break; - - case "writeFile": - // w - queue file write (deferred execution) - state.pendingFileWrites.push({ - filename: cmd.filename, - content: `${state.patternSpace}\n`, - }); - break; - - case "writeFirstLine": { - // W - queue first line file write (deferred execution) - const newlineIdx = state.patternSpace.indexOf("\n"); - const firstLine = - newlineIdx !== -1 - ? state.patternSpace.slice(0, newlineIdx) - : state.patternSpace; - state.pendingFileWrites.push({ - filename: cmd.filename, - content: `${firstLine}\n`, - }); - break; - } - - case "execute": - // e - queue shell execution (deferred execution) - if (cmd.command) { - // e command - execute specified command, append output - state.pendingExecute = { command: cmd.command, replacePattern: false }; - } else { - // e (no args) - execute pattern space, replace with output - state.pendingExecute = { - command: state.patternSpace, - replacePattern: true, - }; - } - break; - - case "transliterate": - // y/source/dest/ - Transliterate characters - state.patternSpace = executeTransliterate( - state.patternSpace, - cmd as TransliterateCommand, - ); - break; - - case "lineNumber": - // = - Print line number - state.lineNumberOutput.push(String(state.lineNumber)); - break; - - case "branch": - // b [label] - Will be handled in executeCommands - break; - - case "branchOnSubst": - // t [label] - Will be handled in executeCommands - break; - - case "branchOnNoSubst": - // T [label] - Will be handled in executeCommands - break; - - case "group": - // Grouped commands - will be handled in executeCommands - break; - } -} - -function executeTransliterate( - input: string, - cmd: TransliterateCommand, -): string { - let result = ""; - for (const char of input) { - const idx = cmd.source.indexOf(char); - if (idx !== -1) { - result += cmd.dest[idx]; - } else { - result += char; - } - } - return result; -} - -export interface ExecuteContext { - lines: string[]; - currentLineIndex: number; -} - -export function executeCommands( - commands: SedCommand[], - state: SedState, - ctx?: ExecuteContext, - limits?: SedExecutionLimits, -): number { - // Build label index for branching - const labelIndex = new Map(); - for (let i = 0; i < commands.length; i++) { - const cmd = commands[i]; - if (cmd.type === "label") { - labelIndex.set(cmd.name, i); - } - } - - const maxIterations = limits?.maxIterations ?? DEFAULT_MAX_ITERATIONS; - let totalIterations = 0; - - let i = 0; - while (i < commands.length) { - totalIterations++; - if (totalIterations > maxIterations) { - throw new ExecutionLimitError( - `sed: command execution exceeded maximum iterations (${maxIterations})`, - "iterations", - ); - } - - if (state.deleted || state.quit || state.quitSilent || state.restartCycle) - break; - - const cmd = commands[i]; - - // Handle n command specially - it needs to print and read next line inline - if (cmd.type === "next") { - if ( - isInRange( - cmd.address, - state.lineNumber, - state.totalLines, - state.patternSpace, - state.rangeStates, - state, - ) - ) { - state.coverage?.hit("sed:cmd:next"); - // Output current pattern space (will be handled by caller based on silent mode) - // nCommandOutput respects silent mode - won't print if -n is set - state.nCommandOutput.push(state.patternSpace); - // Don't set state.printed = true here, as that would trigger silent mode print - // The nCommandOutput mechanism handles the output properly - - if ( - ctx && - ctx.currentLineIndex + state.linesConsumedInCycle + 1 < - ctx.lines.length - ) { - state.linesConsumedInCycle++; - const nextLine = - ctx.lines[ctx.currentLineIndex + state.linesConsumedInCycle]; - state.patternSpace = nextLine; - state.lineNumber = - ctx.currentLineIndex + state.linesConsumedInCycle + 1; - // Reset substitution flag for new line (for t/T commands) - state.substitutionMade = false; - } else { - // If no next line, n quits after printing - // Mark as deleted to prevent auto-print of the pattern space - // (we already printed it via lineNumberOutput) - state.quit = true; - state.deleted = true; - break; - } - } - i++; - continue; - } - - // Handle N command specially - it needs to append next line inline - if (cmd.type === "nextAppend") { - if ( - isInRange( - cmd.address, - state.lineNumber, - state.totalLines, - state.patternSpace, - state.rangeStates, - state, - ) - ) { - state.coverage?.hit("sed:cmd:nextAppend"); - if ( - ctx && - ctx.currentLineIndex + state.linesConsumedInCycle + 1 < - ctx.lines.length - ) { - state.linesConsumedInCycle++; - const nextLine = - ctx.lines[ctx.currentLineIndex + state.linesConsumedInCycle]; - state.patternSpace += `\n${nextLine}`; - state.lineNumber = - ctx.currentLineIndex + state.linesConsumedInCycle + 1; - } else { - // If no next line, N quits but auto-print happens first - state.quit = true; - // Let auto-print happen - GNU sed prints pattern space when N fails - break; - } - } - i++; - continue; - } - - // Handle branching commands specially - if (cmd.type === "branch") { - const branchCmd = cmd as BranchCommand; - // Check if address matches - if ( - isInRange( - branchCmd.address, - state.lineNumber, - state.totalLines, - state.patternSpace, - state.rangeStates, - state, - ) - ) { - state.coverage?.hit("sed:cmd:branch"); - if (branchCmd.label) { - const target = labelIndex.get(branchCmd.label); - if (target !== undefined) { - i = target; - continue; - } - // Label not found in current scope - request outer scope to handle it - state.branchRequest = branchCmd.label; - break; - } - // Branch without label means jump to end - break; - } - i++; - continue; - } - - if (cmd.type === "branchOnSubst") { - const branchCmd = cmd as BranchOnSubstCommand; - // Check if address matches - if ( - isInRange( - branchCmd.address, - state.lineNumber, - state.totalLines, - state.patternSpace, - state.rangeStates, - state, - ) - ) { - state.coverage?.hit("sed:cmd:branchOnSubst"); - if (state.substitutionMade) { - state.substitutionMade = false; // Reset flag - if (branchCmd.label) { - const target = labelIndex.get(branchCmd.label); - if (target !== undefined) { - i = target; - continue; - } - // Label not found in current scope - request outer scope to handle it - state.branchRequest = branchCmd.label; - break; - } - // Branch without label means jump to end - break; - } - } - i++; - continue; - } - - // T - branch if NO substitution made (since last line read) - if (cmd.type === "branchOnNoSubst") { - const branchCmd = cmd as BranchOnNoSubstCommand; - // Check if address matches - if ( - isInRange( - branchCmd.address, - state.lineNumber, - state.totalLines, - state.patternSpace, - state.rangeStates, - state, - ) - ) { - state.coverage?.hit("sed:cmd:branchOnNoSubst"); - if (!state.substitutionMade) { - if (branchCmd.label) { - const target = labelIndex.get(branchCmd.label); - if (target !== undefined) { - i = target; - continue; - } - // Label not found in current scope - request outer scope to handle it - state.branchRequest = branchCmd.label; - break; - } - // Branch without label means jump to end - break; - } - } - i++; - continue; - } - - // Grouped commands - execute recursively - if (cmd.type === "group") { - const groupCmd = cmd as GroupCommand; - if ( - isInRange( - groupCmd.address, - state.lineNumber, - state.totalLines, - state.patternSpace, - state.rangeStates, - state, - ) - ) { - state.coverage?.hit("sed:cmd:group"); - // Execute all commands in the group - // Lines consumed are tracked in state.linesConsumedInCycle - executeCommands(groupCmd.commands, state, ctx, limits); - - // Handle cross-group branch request from nested group - if (state.branchRequest) { - const target = labelIndex.get(state.branchRequest); - if (target !== undefined) { - // Found the label in this scope - execute the branch - state.branchRequest = undefined; - i = target; - continue; - } - // Label not found in this scope either - propagate up - break; - } - } - i++; - continue; - } - - executeCommand(cmd, state); - i++; - } - - return state.linesConsumedInCycle; -} diff --git a/src/commands/sed/lexer.ts b/src/commands/sed/lexer.ts deleted file mode 100644 index 1e601441..00000000 --- a/src/commands/sed/lexer.ts +++ /dev/null @@ -1,954 +0,0 @@ -/** - * SED Lexer - * - * Tokenizes sed scripts into a stream of tokens. - * Sed has context-sensitive tokenization - the meaning of characters - * depends heavily on what command is being parsed. - */ - -export enum SedTokenType { - // Addresses - NUMBER = "NUMBER", - DOLLAR = "DOLLAR", // $ - last line - PATTERN = "PATTERN", // /regex/ - STEP = "STEP", // first~step - RELATIVE_OFFSET = "RELATIVE_OFFSET", // +N (GNU extension: ,+N range) - - // Structure - LBRACE = "LBRACE", // { - RBRACE = "RBRACE", // } - SEMICOLON = "SEMICOLON", // ; - NEWLINE = "NEWLINE", - COMMA = "COMMA", // , - address range separator - NEGATION = "NEGATION", // ! - negate address - - // Commands (single character) - COMMAND = "COMMAND", // p, d, h, H, g, G, x, n, N, P, D, q, Q, z, =, l, F, v - - // Complex commands (parsed specially) - SUBSTITUTE = "SUBSTITUTE", // s/pattern/replacement/flags - TRANSLITERATE = "TRANSLITERATE", // y/source/dest/ - LABEL_DEF = "LABEL_DEF", // :name - BRANCH = "BRANCH", // b [label] - BRANCH_ON_SUBST = "BRANCH_ON_SUBST", // t [label] - BRANCH_ON_NO_SUBST = "BRANCH_ON_NO_SUBST", // T [label] - TEXT_CMD = "TEXT_CMD", // a\, i\, c\ with text - FILE_READ = "FILE_READ", // r filename - FILE_READ_LINE = "FILE_READ_LINE", // R filename - FILE_WRITE = "FILE_WRITE", // w filename - FILE_WRITE_LINE = "FILE_WRITE_LINE", // W filename - EXECUTE = "EXECUTE", // e [command] - VERSION = "VERSION", // v [version] - - EOF = "EOF", - ERROR = "ERROR", -} - -export interface SedToken { - type: SedTokenType; - value: string | number; - // For complex tokens, additional parsed data - pattern?: string; - replacement?: string; - flags?: string; - source?: string; - dest?: string; - text?: string; - label?: string; - filename?: string; - command?: string; // for execute command - first?: number; // for step address - step?: number; // for step address - offset?: number; // for relative offset address (+N) - line: number; - column: number; -} - -export class SedLexer { - private input: string; - private pos = 0; - private line = 1; - private column = 1; - - constructor(input: string) { - this.input = input; - } - - tokenize(): SedToken[] { - const tokens: SedToken[] = []; - while (this.pos < this.input.length) { - const token = this.nextToken(); - if (token) { - tokens.push(token); - } - } - tokens.push(this.makeToken(SedTokenType.EOF, "")); - return tokens; - } - - private makeToken( - type: SedTokenType, - value: string | number, - extra?: Partial, - ): SedToken { - return { type, value, line: this.line, column: this.column, ...extra }; - } - - private peek(offset = 0): string { - return this.input[this.pos + offset] || ""; - } - - private advance(): string { - const ch = this.input[this.pos++] || ""; - if (ch === "\n") { - this.line++; - this.column = 1; - } else { - this.column++; - } - return ch; - } - - /** - * Read an escaped string until the delimiter is reached. - * Handles escape sequences: \n -> newline, \t -> tab, \X -> X - * Returns null if newline is encountered before delimiter. - */ - private readEscapedString(delimiter: string): string | null { - let result = ""; - while (this.pos < this.input.length && this.peek() !== delimiter) { - if (this.peek() === "\\") { - this.advance(); - const escaped = this.advance(); - if (escaped === "n") result += "\n"; - else if (escaped === "t") result += "\t"; - else result += escaped; - } else if (this.peek() === "\n") { - return null; // Unterminated - newline before delimiter - } else { - result += this.advance(); - } - } - return result; - } - - private skipWhitespace(): void { - while (this.pos < this.input.length) { - const ch = this.peek(); - if (ch === " " || ch === "\t" || ch === "\r") { - this.advance(); - } else if (ch === "#") { - // Comment - skip to end of line - while (this.pos < this.input.length && this.peek() !== "\n") { - this.advance(); - } - } else { - break; - } - } - } - - private nextToken(): SedToken | null { - this.skipWhitespace(); - - if (this.pos >= this.input.length) { - return null; - } - - const startLine = this.line; - const startColumn = this.column; - const ch = this.peek(); - - // Newline - if (ch === "\n") { - this.advance(); - return { - type: SedTokenType.NEWLINE, - value: "\n", - line: startLine, - column: startColumn, - }; - } - - // Semicolon - if (ch === ";") { - this.advance(); - return { - type: SedTokenType.SEMICOLON, - value: ";", - line: startLine, - column: startColumn, - }; - } - - // Braces - if (ch === "{") { - this.advance(); - return { - type: SedTokenType.LBRACE, - value: "{", - line: startLine, - column: startColumn, - }; - } - if (ch === "}") { - this.advance(); - return { - type: SedTokenType.RBRACE, - value: "}", - line: startLine, - column: startColumn, - }; - } - - // Comma (address range separator) - if (ch === ",") { - this.advance(); - return { - type: SedTokenType.COMMA, - value: ",", - line: startLine, - column: startColumn, - }; - } - - // Negation modifier (!) - if (ch === "!") { - this.advance(); - return { - type: SedTokenType.NEGATION, - value: "!", - line: startLine, - column: startColumn, - }; - } - - // Dollar (last line address) - if (ch === "$") { - this.advance(); - return { - type: SedTokenType.DOLLAR, - value: "$", - line: startLine, - column: startColumn, - }; - } - - // Number or step address (first~step) - if (this.isDigit(ch)) { - return this.readNumber(); - } - - // Relative offset address +N (GNU extension for ,+N ranges) - if (ch === "+" && this.isDigit(this.input[this.pos + 1] || "")) { - return this.readRelativeOffset(); - } - - // Pattern address /regex/ - if (ch === "/") { - return this.readPattern(); - } - - // Label definition :name - if (ch === ":") { - return this.readLabelDef(); - } - - // Commands - return this.readCommand(); - } - - private readNumber(): SedToken { - const startLine = this.line; - const startColumn = this.column; - let numStr = ""; - - while (this.isDigit(this.peek())) { - numStr += this.advance(); - } - - // Check for step address: first~step - if (this.peek() === "~") { - this.advance(); // skip ~ - let stepStr = ""; - while (this.isDigit(this.peek())) { - stepStr += this.advance(); - } - const first = parseInt(numStr, 10); - const step = parseInt(stepStr, 10) || 0; - return { - type: SedTokenType.STEP, - value: `${first}~${step}`, - first, - step, - line: startLine, - column: startColumn, - }; - } - - return { - type: SedTokenType.NUMBER, - value: parseInt(numStr, 10), - line: startLine, - column: startColumn, - }; - } - - private readRelativeOffset(): SedToken { - const startLine = this.line; - const startColumn = this.column; - this.advance(); // skip + - let numStr = ""; - - while (this.isDigit(this.peek())) { - numStr += this.advance(); - } - - const offset = parseInt(numStr, 10) || 0; - return { - type: SedTokenType.RELATIVE_OFFSET, - value: `+${offset}`, - offset, - line: startLine, - column: startColumn, - }; - } - - private readPattern(): SedToken { - const startLine = this.line; - const startColumn = this.column; - this.advance(); // skip opening / - let pattern = ""; - let inBracket = false; - - while (this.pos < this.input.length) { - const ch = this.peek(); - - // Check for end of pattern (delimiter outside brackets) - if (ch === "/" && !inBracket) { - break; - } - - if (ch === "\\") { - pattern += this.advance(); - if (this.pos < this.input.length && this.peek() !== "\n") { - pattern += this.advance(); - } - } else if (ch === "\n") { - // Unterminated pattern - break; - } else if (ch === "[" && !inBracket) { - inBracket = true; - pattern += this.advance(); - // Handle negation and literal ] at start of bracket - if (this.peek() === "^") { - pattern += this.advance(); - } - if (this.peek() === "]") { - pattern += this.advance(); // ] at start is literal - } - } else if (ch === "]" && inBracket) { - inBracket = false; - pattern += this.advance(); - } else { - pattern += this.advance(); - } - } - - if (this.peek() === "/") { - this.advance(); // skip closing / - } - - return { - type: SedTokenType.PATTERN, - value: pattern, - pattern, - line: startLine, - column: startColumn, - }; - } - - private readLabelDef(): SedToken { - const startLine = this.line; - const startColumn = this.column; - this.advance(); // skip : - - // Skip optional whitespace after colon (GNU sed allows ': label') - while (this.peek() === " " || this.peek() === "\t") { - this.advance(); - } - - // Read label name (until whitespace, semicolon, newline, or brace) - let label = ""; - while (this.pos < this.input.length) { - const ch = this.peek(); - if ( - ch === " " || - ch === "\t" || - ch === "\n" || - ch === ";" || - ch === "}" || - ch === "{" - ) { - break; - } - label += this.advance(); - } - - return { - type: SedTokenType.LABEL_DEF, - value: label, - label, - line: startLine, - column: startColumn, - }; - } - - private readCommand(): SedToken { - const startLine = this.line; - const startColumn = this.column; - const ch = this.advance(); - - switch (ch) { - case "s": - return this.readSubstitute(startLine, startColumn); - - case "y": - return this.readTransliterate(startLine, startColumn); - - case "a": - case "i": - case "c": - return this.readTextCommand(ch, startLine, startColumn); - - case "b": - return this.readBranch( - SedTokenType.BRANCH, - "b", - startLine, - startColumn, - ); - - case "t": - return this.readBranch( - SedTokenType.BRANCH_ON_SUBST, - "t", - startLine, - startColumn, - ); - - case "T": - return this.readBranch( - SedTokenType.BRANCH_ON_NO_SUBST, - "T", - startLine, - startColumn, - ); - - case "r": - return this.readFileCommand( - SedTokenType.FILE_READ, - "r", - startLine, - startColumn, - ); - - case "R": - return this.readFileCommand( - SedTokenType.FILE_READ_LINE, - "R", - startLine, - startColumn, - ); - - case "w": - return this.readFileCommand( - SedTokenType.FILE_WRITE, - "w", - startLine, - startColumn, - ); - - case "W": - return this.readFileCommand( - SedTokenType.FILE_WRITE_LINE, - "W", - startLine, - startColumn, - ); - - case "e": - return this.readExecute(startLine, startColumn); - - case "p": - case "P": - case "d": - case "D": - case "h": - case "H": - case "g": - case "G": - case "x": - case "n": - case "N": - case "q": - case "Q": - case "z": - case "=": - case "l": - case "F": - return { - type: SedTokenType.COMMAND, - value: ch, - line: startLine, - column: startColumn, - }; - - case "v": - return this.readVersion(startLine, startColumn); - - default: - return { - type: SedTokenType.ERROR, - value: ch, - line: startLine, - column: startColumn, - }; - } - } - - private readSubstitute(startLine: number, startColumn: number): SedToken { - // Already consumed 's' - // Read delimiter - const delimiter = this.advance(); - if (!delimiter || delimiter === "\n") { - return { - type: SedTokenType.ERROR, - value: "s", - line: startLine, - column: startColumn, - }; - } - - // Read pattern (handle bracket expressions where delimiter is literal) - let pattern = ""; - let inBracket = false; - while (this.pos < this.input.length) { - const ch = this.peek(); - - // Check for end of pattern (delimiter outside brackets) - if (ch === delimiter && !inBracket) { - break; - } - - if (ch === "\\") { - this.advance(); // consume backslash - if (this.pos < this.input.length && this.peek() !== "\n") { - const escaped = this.peek(); - // Only convert escaped delimiter to literal outside of bracket expressions - // Inside brackets, keep the backslash for BRE escape sequences - if (escaped === delimiter && !inBracket) { - // Escaped delimiter becomes literal delimiter in pattern - pattern += this.advance(); - } else { - // Keep backslash + escaped char for other escapes - pattern += "\\"; - pattern += this.advance(); - } - } else { - pattern += "\\"; - } - } else if (ch === "\n") { - break; - } else if (ch === "[" && !inBracket) { - inBracket = true; - pattern += this.advance(); - // Handle negation and literal ] at start of bracket - if (this.peek() === "^") { - pattern += this.advance(); - } - if (this.peek() === "]") { - pattern += this.advance(); // ] at start is literal - } - } else if (ch === "]" && inBracket) { - inBracket = false; - pattern += this.advance(); - } else { - pattern += this.advance(); - } - } - - if (this.peek() !== delimiter) { - return { - type: SedTokenType.ERROR, - value: "unterminated substitution pattern", - line: startLine, - column: startColumn, - }; - } - this.advance(); // skip middle delimiter - - // Read replacement - let replacement = ""; - while (this.pos < this.input.length && this.peek() !== delimiter) { - if (this.peek() === "\\") { - this.advance(); // consume first backslash - if (this.pos < this.input.length) { - const next = this.peek(); - if (next === "\\") { - // Double backslash - check what follows - this.advance(); // consume second backslash - if (this.pos < this.input.length && this.peek() === "\n") { - // \\ = escaped newline (literal newline in output) - // This is how BusyBox sed handles multi-line replacements - replacement += "\n"; - this.advance(); - } else { - // \\\\ = literal backslash - replacement += "\\"; - } - } else if (next === "\n") { - // \ in replacement: include the newline as literal - replacement += "\n"; - this.advance(); - } else { - // Keep the backslash and following character - replacement += `\\${this.advance()}`; - } - } else { - replacement += "\\"; - } - } else if (this.peek() === "\n") { - break; - } else { - replacement += this.advance(); - } - } - - // Closing delimiter is optional for last part - if (this.peek() === delimiter) { - this.advance(); - } - - // Read flags - let flags = ""; - while (this.pos < this.input.length) { - const ch = this.peek(); - if ( - ch === "g" || - ch === "i" || - ch === "p" || - ch === "I" || - this.isDigit(ch) - ) { - flags += this.advance(); - } else { - break; - } - } - - return { - type: SedTokenType.SUBSTITUTE, - value: `s${delimiter}${pattern}${delimiter}${replacement}${delimiter}${flags}`, - pattern, - replacement, - flags, - line: startLine, - column: startColumn, - }; - } - - private readTransliterate(startLine: number, startColumn: number): SedToken { - // Already consumed 'y' - const delimiter = this.advance(); - if (!delimiter || delimiter === "\n") { - return { - type: SedTokenType.ERROR, - value: "y", - line: startLine, - column: startColumn, - }; - } - - // Read source characters - const source = this.readEscapedString(delimiter); - if (source === null || this.peek() !== delimiter) { - return { - type: SedTokenType.ERROR, - value: "unterminated transliteration source", - line: startLine, - column: startColumn, - }; - } - this.advance(); // skip middle delimiter - - // Read dest characters - const dest = this.readEscapedString(delimiter); - if (dest === null || this.peek() !== delimiter) { - return { - type: SedTokenType.ERROR, - value: "unterminated transliteration dest", - line: startLine, - column: startColumn, - }; - } - this.advance(); // skip closing delimiter - - // Check for extra text after y command - only ; } newline or EOF allowed - // Whitespace followed by more text is an error - let nextChar = this.peek(); - // Skip whitespace but track if we had any - while (nextChar === " " || nextChar === "\t") { - this.advance(); - nextChar = this.peek(); - } - // After y command, only command separators or EOF allowed - if ( - nextChar !== "" && - nextChar !== ";" && - nextChar !== "\n" && - nextChar !== "}" - ) { - return { - type: SedTokenType.ERROR, - value: "extra text at the end of a transform command", - line: startLine, - column: startColumn, - }; - } - - return { - type: SedTokenType.TRANSLITERATE, - value: `y${delimiter}${source}${delimiter}${dest}${delimiter}`, - source, - dest, - line: startLine, - column: startColumn, - }; - } - - private readTextCommand( - cmd: string, - startLine: number, - startColumn: number, - ): SedToken { - // a, i, c commands can be followed by: - // 1. a\ followed by newline then text (traditional) - // 2. a text (GNU extension one-liner, text after space) - // 3. a\text (backslash followed by text on same line) - - let hasBackslash = false; - // Traditional a\ syntax: only consume backslash if followed by newline or space - if ( - this.peek() === "\\" && - this.pos + 1 < this.input.length && - (this.input[this.pos + 1] === "\n" || - this.input[this.pos + 1] === " " || - this.input[this.pos + 1] === "\t") - ) { - hasBackslash = true; - this.advance(); - } - - // Skip optional space after command or backslash - if (this.peek() === " " || this.peek() === "\t") { - this.advance(); - } - - // Check for \ at start of text to preserve leading spaces (GNU extension) - // e.g., "a \ text" preserves " text" - // Only consume backslash if followed by space, otherwise it's an escape sequence - if ( - this.peek() === "\\" && - this.pos + 1 < this.input.length && - (this.input[this.pos + 1] === " " || this.input[this.pos + 1] === "\t") - ) { - this.advance(); - } - - // If we have backslash followed by newline, text is on next line(s) - if (hasBackslash && this.peek() === "\n") { - this.advance(); // consume newline - } - - // Read text, handling multi-line continuation and escape sequences - let text = ""; - while (this.pos < this.input.length) { - const ch = this.peek(); - - if (ch === "\n") { - // Check if previous char was backslash for continuation - if (text.endsWith("\\")) { - // Continuation: remove backslash and add newline - text = `${text.slice(0, -1)}\n`; - this.advance(); - continue; - } - // End of text - break; - } - - // Handle escape sequences in text commands (\n, \t, \r) - if (ch === "\\" && this.pos + 1 < this.input.length) { - const next = this.input[this.pos + 1]; - if (next === "n") { - text += "\n"; - this.advance(); - this.advance(); - continue; - } - if (next === "t") { - text += "\t"; - this.advance(); - this.advance(); - continue; - } - if (next === "r") { - text += "\r"; - this.advance(); - this.advance(); - continue; - } - } - - text += this.advance(); - } - - // Don't trim text - escape sequences like \t at the start are intentional - return { - type: SedTokenType.TEXT_CMD, - value: cmd, - text, - line: startLine, - column: startColumn, - }; - } - - private readBranch( - type: SedTokenType, - cmd: string, - startLine: number, - startColumn: number, - ): SedToken { - // Skip whitespace - while (this.peek() === " " || this.peek() === "\t") { - this.advance(); - } - - // Read optional label - let label = ""; - while (this.pos < this.input.length) { - const ch = this.peek(); - if ( - ch === " " || - ch === "\t" || - ch === "\n" || - ch === ";" || - ch === "}" || - ch === "{" - ) { - break; - } - label += this.advance(); - } - - return { - type, - value: cmd, - label: label || undefined, - line: startLine, - column: startColumn, - }; - } - - private readVersion(startLine: number, startColumn: number): SedToken { - // Skip whitespace - while (this.peek() === " " || this.peek() === "\t") { - this.advance(); - } - - // Read optional version string (e.g., "4.5.3") - let version = ""; - while (this.pos < this.input.length) { - const ch = this.peek(); - if ( - ch === " " || - ch === "\t" || - ch === "\n" || - ch === ";" || - ch === "}" || - ch === "{" - ) { - break; - } - version += this.advance(); - } - - return { - type: SedTokenType.VERSION, - value: "v", - label: version || undefined, // Reuse label field for version string - line: startLine, - column: startColumn, - }; - } - - private readFileCommand( - type: SedTokenType, - cmd: string, - startLine: number, - startColumn: number, - ): SedToken { - // Skip whitespace (but not newline) - while (this.peek() === " " || this.peek() === "\t") { - this.advance(); - } - - // Read filename until newline or semicolon - let filename = ""; - while (this.pos < this.input.length) { - const ch = this.peek(); - if (ch === "\n" || ch === ";") { - break; - } - filename += this.advance(); - } - - return { - type, - value: cmd, - filename: filename.trim(), - line: startLine, - column: startColumn, - }; - } - - private readExecute(startLine: number, startColumn: number): SedToken { - // Skip whitespace - while (this.peek() === " " || this.peek() === "\t") { - this.advance(); - } - - // Read optional command until newline or semicolon - let command = ""; - while (this.pos < this.input.length) { - const ch = this.peek(); - if (ch === "\n" || ch === ";") { - break; - } - command += this.advance(); - } - - return { - type: SedTokenType.EXECUTE, - value: "e", - command: command.trim() || undefined, - line: startLine, - column: startColumn, - }; - } - - private isDigit(ch: string): boolean { - return ch >= "0" && ch <= "9"; - } -} diff --git a/src/commands/sed/parser.ts b/src/commands/sed/parser.ts deleted file mode 100644 index 6c986064..00000000 --- a/src/commands/sed/parser.ts +++ /dev/null @@ -1,593 +0,0 @@ -// Parser for sed scripts using lexer-based tokenization - -import { SedLexer, type SedToken, SedTokenType } from "./lexer.js"; -import type { AddressRange, SedAddress, SedCommand } from "./types.js"; - -interface ParseResult { - commands: SedCommand[]; - error?: string; -} - -class SedParser { - private tokens: SedToken[] = []; - private pos = 0; - private extendedRegex = false; - - constructor( - private scripts: string[], - extendedRegex = false, - ) { - this.extendedRegex = extendedRegex; - } - - parse(): ParseResult { - const allCommands: SedCommand[] = []; - - for (const script of this.scripts) { - const lexer = new SedLexer(script); - this.tokens = lexer.tokenize(); - this.pos = 0; - - while (!this.isAtEnd()) { - // Skip empty tokens - if ( - this.check(SedTokenType.NEWLINE) || - this.check(SedTokenType.SEMICOLON) - ) { - this.advance(); - continue; - } - - const result = this.parseCommand(); - if (result.error) { - return { commands: [], error: result.error }; - } - if (result.command) { - allCommands.push(result.command); - } - } - } - - return { commands: allCommands }; - } - - private parseCommand(): { command: SedCommand | null; error?: string } { - // Parse optional address range - const addressResult = this.parseAddressRange(); - - // Check for incomplete range error (e.g., "1,") - if (addressResult?.error) { - return { command: null, error: addressResult.error }; - } - - const address = addressResult?.address; - - // Check for negation modifier (!) - if (this.check(SedTokenType.NEGATION)) { - this.advance(); - if (address) { - address.negated = true; - } - } - - // Skip whitespace tokens - while ( - this.check(SedTokenType.NEWLINE) || - this.check(SedTokenType.SEMICOLON) - ) { - this.advance(); - } - - if (this.isAtEnd()) { - // Address with no command is an error (standard sed behavior) - if ( - address && - (address.start !== undefined || address.end !== undefined) - ) { - return { command: null, error: "command expected" }; - } - return { command: null }; - } - - const token = this.peek(); - - switch (token.type) { - case SedTokenType.COMMAND: - return this.parseSimpleCommand(token, address); - - case SedTokenType.SUBSTITUTE: - return this.parseSubstituteFromToken(token, address); - - case SedTokenType.TRANSLITERATE: - return this.parseTransliterateFromToken(token, address); - - case SedTokenType.LABEL_DEF: - this.advance(); - return { - command: { type: "label", name: token.label || "" }, - }; - - case SedTokenType.BRANCH: - this.advance(); - return { - command: { type: "branch", address, label: token.label }, - }; - - case SedTokenType.BRANCH_ON_SUBST: - this.advance(); - return { - command: { type: "branchOnSubst", address, label: token.label }, - }; - - case SedTokenType.BRANCH_ON_NO_SUBST: - this.advance(); - return { - command: { type: "branchOnNoSubst", address, label: token.label }, - }; - - case SedTokenType.TEXT_CMD: - this.advance(); - return this.parseTextCommand(token, address); - - case SedTokenType.FILE_READ: - this.advance(); - return { - command: { - type: "readFile", - address, - filename: token.filename || "", - }, - }; - - case SedTokenType.FILE_READ_LINE: - this.advance(); - return { - command: { - type: "readFileLine", - address, - filename: token.filename || "", - }, - }; - - case SedTokenType.FILE_WRITE: - this.advance(); - return { - command: { - type: "writeFile", - address, - filename: token.filename || "", - }, - }; - - case SedTokenType.FILE_WRITE_LINE: - this.advance(); - return { - command: { - type: "writeFirstLine", - address, - filename: token.filename || "", - }, - }; - - case SedTokenType.EXECUTE: - this.advance(); - return { - command: { type: "execute", address, command: token.command }, - }; - - case SedTokenType.VERSION: - this.advance(); - return { - command: { - type: "version", - address, - minVersion: token.label, // label field holds version string - }, - }; - - case SedTokenType.LBRACE: - return this.parseGroup(address); - - case SedTokenType.RBRACE: - // End of group - handled by parseGroup - return { command: null }; - - case SedTokenType.ERROR: - return { command: null, error: `invalid command: ${token.value}` }; - - default: - // Address with no recognized command is an error - if ( - address && - (address.start !== undefined || address.end !== undefined) - ) { - return { command: null, error: "command expected" }; - } - return { command: null }; - } - } - - private parseSimpleCommand( - token: SedToken, - address?: AddressRange, - ): { command: SedCommand | null; error?: string } { - this.advance(); - const cmd = token.value as string; - - switch (cmd) { - case "p": - return { command: { type: "print", address } }; - case "P": - return { command: { type: "printFirstLine", address } }; - case "d": - return { command: { type: "delete", address } }; - case "D": - return { command: { type: "deleteFirstLine", address } }; - case "h": - return { command: { type: "hold", address } }; - case "H": - return { command: { type: "holdAppend", address } }; - case "g": - return { command: { type: "get", address } }; - case "G": - return { command: { type: "getAppend", address } }; - case "x": - return { command: { type: "exchange", address } }; - case "n": - return { command: { type: "next", address } }; - case "N": - return { command: { type: "nextAppend", address } }; - case "q": - return { command: { type: "quit", address } }; - case "Q": - return { command: { type: "quitSilent", address } }; - case "z": - return { command: { type: "zap", address } }; - case "=": - return { command: { type: "lineNumber", address } }; - case "l": - return { command: { type: "list", address } }; - case "F": - return { command: { type: "printFilename", address } }; - // Note: 'v' command is now handled as SedTokenType.VERSION - default: - return { command: null, error: `unknown command: ${cmd}` }; - } - } - - private parseSubstituteFromToken( - token: SedToken, - address?: AddressRange, - ): { command: SedCommand | null; error?: string } { - this.advance(); - - const flags = token.flags || ""; - let nthOccurrence: number | undefined; - const numMatch = flags.match(/(\d+)/); - if (numMatch) { - nthOccurrence = parseInt(numMatch[1], 10); - } - - return { - command: { - type: "substitute", - address, - pattern: token.pattern || "", - replacement: token.replacement || "", - global: flags.includes("g"), - ignoreCase: flags.includes("i") || flags.includes("I"), - printOnMatch: flags.includes("p"), - nthOccurrence, - extendedRegex: this.extendedRegex, - }, - }; - } - - private parseTransliterateFromToken( - token: SedToken, - address?: AddressRange, - ): { command: SedCommand | null; error?: string } { - this.advance(); - - const source = token.source || ""; - const dest = token.dest || ""; - - if (source.length !== dest.length) { - return { - command: null, - error: "transliteration sets must have same length", - }; - } - - return { - command: { - type: "transliterate", - address, - source, - dest, - }, - }; - } - - private parseTextCommand( - token: SedToken, - address?: AddressRange, - ): { command: SedCommand | null; error?: string } { - const cmd = token.value as string; - const text = token.text || ""; - - switch (cmd) { - case "a": - return { command: { type: "append", address, text } }; - case "i": - return { command: { type: "insert", address, text } }; - case "c": - return { command: { type: "change", address, text } }; - default: - return { command: null, error: `unknown text command: ${cmd}` }; - } - } - - private parseGroup(address?: AddressRange): { - command: SedCommand | null; - error?: string; - } { - this.advance(); // consume { - - const commands: SedCommand[] = []; - - while (!this.isAtEnd() && !this.check(SedTokenType.RBRACE)) { - // Skip empty tokens - if ( - this.check(SedTokenType.NEWLINE) || - this.check(SedTokenType.SEMICOLON) - ) { - this.advance(); - continue; - } - - const result = this.parseCommand(); - if (result.error) { - return { command: null, error: result.error }; - } - if (result.command) { - commands.push(result.command); - } - } - - if (!this.check(SedTokenType.RBRACE)) { - return { command: null, error: "unmatched brace in grouped commands" }; - } - this.advance(); // consume } - - return { - command: { type: "group", address, commands }, - }; - } - - private parseAddressRange(): - | { address: AddressRange; error?: undefined } - | { address?: undefined; error: string } - | undefined { - // Try to parse first address - const start = this.parseAddress(); - if (start === undefined) { - return undefined; - } - - // Check for range separator or relative offset (GNU extension: ,+N) - let end: SedAddress | undefined; - if (this.check(SedTokenType.RELATIVE_OFFSET)) { - // GNU extension: /pattern/,+N means "match N more lines after pattern" - const token = this.advance(); - end = { offset: token.offset || 0 }; - } else if (this.check(SedTokenType.COMMA)) { - this.advance(); - end = this.parseAddress(); - // If we consumed a comma but have no end address, that's an error - if (end === undefined) { - return { error: "expected context address" }; - } - } - - return { address: { start, end } }; - } - - private parseAddress(): SedAddress | undefined { - const token = this.peek(); - - switch (token.type) { - case SedTokenType.NUMBER: - this.advance(); - return token.value as number; - - case SedTokenType.DOLLAR: - this.advance(); - return "$"; - - case SedTokenType.PATTERN: - this.advance(); - return { pattern: token.pattern || (token.value as string) }; - - case SedTokenType.STEP: - this.advance(); - return { - first: token.first || 0, - step: token.step || 0, - }; - - case SedTokenType.RELATIVE_OFFSET: - this.advance(); - return { offset: token.offset || 0 }; - - default: - return undefined; - } - } - - private peek(): SedToken { - return ( - this.tokens[this.pos] || { - type: SedTokenType.EOF, - value: "", - line: 0, - column: 0, - } - ); - } - - private advance(): SedToken { - if (!this.isAtEnd()) { - this.pos++; - } - return this.tokens[this.pos - 1]; - } - - private check(type: SedTokenType): boolean { - return this.peek().type === type; - } - - private isAtEnd(): boolean { - return this.peek().type === SedTokenType.EOF; - } -} - -/** - * Parse multiple sed scripts into a list of commands. - * This is the main entry point for parsing sed scripts. - * - * Also detects #n or #r special comments at the start of the first script: - * - #n enables silent mode (equivalent to -n flag) - * - #r enables extended regex mode (equivalent to -r/-E flag) - * - * Handles backslash continuation across -e arguments: - * - If a script ends with \, the next script is treated as continuation - */ -export function parseMultipleScripts( - scripts: string[], - extendedRegex = false, -): { - commands: SedCommand[]; - error?: string; - silentMode?: boolean; - extendedRegexMode?: boolean; -} { - // Check for #n or #r special comments at the start of the first script - let silentMode = false; - let extendedRegexFromComment = false; - - // First, join scripts that have backslash continuation - // e.g., -e 'a\' -e 'text' becomes 'a\ntext' - const joinedScripts: string[] = []; - for (let i = 0; i < scripts.length; i++) { - let script = scripts[i]; - - // Handle #n/#r comments in first script - if (joinedScripts.length === 0 && i === 0) { - const match = script.match(/^#([nr]+)\s*(?:\n|$)/i); - if (match) { - const flags = match[1].toLowerCase(); - if (flags.includes("n")) { - silentMode = true; - } - if (flags.includes("r")) { - extendedRegexFromComment = true; - } - script = script.slice(match[0].length); - } - } - - // Check if last script ends with backslash (continuation) - // For a/i/c commands, the backslash indicates text continues on next line - // Keep the backslash so the lexer knows to read the text from the next line - if ( - joinedScripts.length > 0 && - joinedScripts[joinedScripts.length - 1].endsWith("\\") - ) { - // Keep trailing backslash and join with newline - const lastScript = joinedScripts[joinedScripts.length - 1]; - joinedScripts[joinedScripts.length - 1] = `${lastScript}\n${script}`; - } else { - joinedScripts.push(script); - } - } - - // Join all scripts with newlines to form a single script - // This is necessary for grouped commands { } where { and } may be in different -e arguments - const combinedScript = joinedScripts.join("\n"); - - const parser = new SedParser( - [combinedScript], - extendedRegex || extendedRegexFromComment, - ); - const result = parser.parse(); - - // Validate that all branch targets exist - if (!result.error && result.commands.length > 0) { - const labelError = validateLabels(result.commands); - if (labelError) { - return { - commands: [], - error: labelError, - silentMode, - extendedRegexMode: extendedRegexFromComment, - }; - } - } - - return { - ...result, - silentMode, - extendedRegexMode: extendedRegexFromComment, - }; -} - -/** - * Validate that all branch targets reference existing labels. - * Returns an error message if validation fails, undefined otherwise. - */ -function validateLabels(commands: SedCommand[]): string | undefined { - // Collect all defined labels - const definedLabels = new Set(); - collectLabels(commands, definedLabels); - - // Check all branch commands - const undefinedLabel = findUndefinedLabel(commands, definedLabels); - if (undefinedLabel) { - return `undefined label '${undefinedLabel}'`; - } - - return undefined; -} - -function collectLabels(commands: SedCommand[], labels: Set): void { - for (const cmd of commands) { - if (cmd.type === "label") { - labels.add(cmd.name); - } else if (cmd.type === "group") { - collectLabels(cmd.commands, labels); - } - } -} - -function findUndefinedLabel( - commands: SedCommand[], - definedLabels: Set, -): string | undefined { - for (const cmd of commands) { - if ( - (cmd.type === "branch" || - cmd.type === "branchOnSubst" || - cmd.type === "branchOnNoSubst") && - cmd.label && - !definedLabels.has(cmd.label) - ) { - return cmd.label; - } - if (cmd.type === "group") { - const result = findUndefinedLabel(cmd.commands, definedLabels); - if (result) return result; - } - } - return undefined; -} diff --git a/src/commands/sed/sed-regex.ts b/src/commands/sed/sed-regex.ts deleted file mode 100644 index fa90fa1f..00000000 --- a/src/commands/sed/sed-regex.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * Regex conversion utilities for sed command - */ - -/** POSIX character class to JavaScript regex mapping (Map prevents prototype pollution) */ -const POSIX_CLASSES = new Map([ - ["alnum", "a-zA-Z0-9"], - ["alpha", "a-zA-Z"], - ["ascii", "\\x00-\\x7F"], - ["blank", " \\t"], - ["cntrl", "\\x00-\\x1F\\x7F"], - ["digit", "0-9"], - ["graph", "!-~"], - ["lower", "a-z"], - ["print", " -~"], - ["punct", "!-/:-@\\[-`{-~"], - ["space", " \\t\\n\\r\\f\\v"], - ["upper", "A-Z"], - ["word", "a-zA-Z0-9_"], - ["xdigit", "0-9A-Fa-f"], -]); - -/** - * Convert Basic Regular Expression (BRE) to Extended Regular Expression (ERE). - * In BRE: +, ?, |, (, ) are literal; \+, \?, \|, \(, \) are special - * In ERE: +, ?, |, (, ) are special; \+, \?, \|, \(, \) are literal - * Also converts POSIX character classes to JavaScript equivalents. - */ -export function breToEre(pattern: string): string { - // This conversion handles the main differences between BRE and ERE: - // 1. Unescape BRE special chars (\+, \?, \|, \(, \)) to make them special in ERE - // 2. Escape ERE special chars (+, ?, |, (, )) that are literal in BRE - // 3. Properly handle bracket expressions [...] - - let result = ""; - let i = 0; - let inBracket = false; - - while (i < pattern.length) { - // Handle bracket expressions - copy contents mostly verbatim - if (pattern[i] === "[" && !inBracket) { - // Check for standalone POSIX character classes like [[:space:]] - if (pattern[i + 1] === "[" && pattern[i + 2] === ":") { - const closeIdx = pattern.indexOf(":]]", i + 3); - if (closeIdx !== -1) { - const className = pattern.slice(i + 3, closeIdx); - const jsClass = POSIX_CLASSES.get(className); - if (jsClass) { - result += `[${jsClass}]`; - i = closeIdx + 3; - continue; - } - } - } - - // Check for negated standalone POSIX classes [^[:space:]] - if ( - pattern[i + 1] === "^" && - pattern[i + 2] === "[" && - pattern[i + 3] === ":" - ) { - const closeIdx = pattern.indexOf(":]]", i + 4); - if (closeIdx !== -1) { - const className = pattern.slice(i + 4, closeIdx); - const jsClass = POSIX_CLASSES.get(className); - if (jsClass) { - result += `[^${jsClass}]`; - i = closeIdx + 3; - continue; - } - } - } - - // Start of bracket expression - result += "["; - i++; - inBracket = true; - - // Handle negation at start - if (i < pattern.length && pattern[i] === "^") { - result += "^"; - i++; - } - - // Handle ] at start (it's literal in POSIX, needs escaping for JS) - if (i < pattern.length && pattern[i] === "]") { - result += "\\]"; - i++; - } - continue; - } - - // Inside bracket expression - copy verbatim until closing ] - if (inBracket) { - if (pattern[i] === "]") { - result += "]"; - i++; - inBracket = false; - continue; - } - - // Handle POSIX classes inside bracket expressions like [a[:space:]b] - if (pattern[i] === "[" && pattern[i + 1] === ":") { - const closeIdx = pattern.indexOf(":]", i + 2); - if (closeIdx !== -1) { - const className = pattern.slice(i + 2, closeIdx); - const jsClass = POSIX_CLASSES.get(className); - if (jsClass) { - result += jsClass; - i = closeIdx + 2; - continue; - } - } - } - - // Handle backslash escapes inside brackets - if (pattern[i] === "\\" && i + 1 < pattern.length) { - result += pattern[i] + pattern[i + 1]; - i += 2; - continue; - } - - result += pattern[i]; - i++; - continue; - } - - // Outside bracket expressions - handle BRE to ERE conversion - if (pattern[i] === "\\") { - if (i + 1 < pattern.length) { - const next = pattern[i + 1]; - // BRE escaped chars that become special in ERE - if (next === "+" || next === "?" || next === "|") { - result += next; // Remove backslash to make it special - i += 2; - continue; - } - if (next === "(" || next === ")") { - result += next; // Remove backslash for grouping - i += 2; - continue; - } - if (next === "{" || next === "}") { - result += next; // Remove backslash for quantifiers - i += 2; - continue; - } - // Convert escape sequences to actual characters (GNU extension) - if (next === "t") { - result += "\t"; - i += 2; - continue; - } - if (next === "n") { - result += "\n"; - i += 2; - continue; - } - if (next === "r") { - result += "\r"; - i += 2; - continue; - } - // Keep other escaped chars as-is - result += pattern[i] + next; - i += 2; - continue; - } - } - - // ERE special chars that should be literal in BRE (without backslash) - if ( - pattern[i] === "+" || - pattern[i] === "?" || - pattern[i] === "|" || - pattern[i] === "(" || - pattern[i] === ")" - ) { - result += `\\${pattern[i]}`; // Add backslash to make it literal - i++; - continue; - } - - // Handle ^ anchor: In BRE, ^ is only an anchor at the start of the pattern - // or immediately after \( (which becomes ( in ERE). When ^ appears - // elsewhere, it should be treated as a literal character. - if (pattern[i] === "^") { - // Check if we're at the start of result OR after an opening group paren - const isAnchor = result === "" || result.endsWith("("); - if (!isAnchor) { - result += "\\^"; // Escape to make it literal in ERE - i++; - continue; - } - } - - // Handle $ anchor: In BRE, $ is only an anchor at the end of the pattern - // or immediately before \) (which becomes ) in ERE). When $ appears - // elsewhere, it should be treated as a literal character. - if (pattern[i] === "$") { - // Check if we're at the end of pattern OR before a closing group - const isEnd = i === pattern.length - 1; - // Check if next char is \) in original BRE pattern - const beforeGroupClose = - i + 2 < pattern.length && - pattern[i + 1] === "\\" && - pattern[i + 2] === ")"; - if (!isEnd && !beforeGroupClose) { - result += "\\$"; // Escape to make it literal in ERE - i++; - continue; - } - } - - result += pattern[i]; - i++; - } - - return result; -} - -/** - * Normalize regex patterns for JavaScript RegExp. - * Converts GNU sed extensions to JavaScript-compatible syntax. - * - * Handles: - * - {,n} → {0,n} (GNU extension: "0 to n times") - */ -export function normalizeForJs(pattern: string): string { - // Convert {,n} to {0,n} - handles quantifiers like {,2} meaning "0 to 2 times" - // Be careful not to match inside bracket expressions - let result = ""; - let inBracket = false; - - for (let i = 0; i < pattern.length; i++) { - if (pattern[i] === "[" && !inBracket) { - inBracket = true; - result += "["; - i++; - // Handle negation and ] at start - if (i < pattern.length && pattern[i] === "^") { - result += "^"; - i++; - } - if (i < pattern.length && pattern[i] === "]") { - result += "]"; - i++; - } - i--; // Will be incremented by loop - } else if (pattern[i] === "]" && inBracket) { - inBracket = false; - result += "]"; - } else if (!inBracket && pattern[i] === "{" && pattern[i + 1] === ",") { - // Found {,n} pattern - convert to {0,n} - result += "{0,"; - i++; // Skip the comma - } else { - result += pattern[i]; - } - } - - return result; -} - -/** - * Escape pattern space for the `l` (list) command. - * Shows non-printable characters as escape sequences and ends with $. - */ -export function escapeForList(input: string): string { - let result = ""; - for (let i = 0; i < input.length; i++) { - const ch = input[i]; - const code = ch.charCodeAt(0); - - if (ch === "\\") { - result += "\\\\"; - } else if (ch === "\t") { - result += "\\t"; - } else if (ch === "\n") { - result += "$\n"; - } else if (ch === "\r") { - result += "\\r"; - } else if (ch === "\x07") { - result += "\\a"; - } else if (ch === "\b") { - result += "\\b"; - } else if (ch === "\f") { - result += "\\f"; - } else if (ch === "\v") { - result += "\\v"; - } else if (code < 32 || code >= 127) { - // Non-printable: show as octal - result += `\\${code.toString(8).padStart(3, "0")}`; - } else { - result += ch; - } - } - return `${result}$`; -} diff --git a/src/commands/sed/sed.advanced.test.ts b/src/commands/sed/sed.advanced.test.ts deleted file mode 100644 index aea8d4ce..00000000 --- a/src/commands/sed/sed.advanced.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("sed advanced commands", () => { - describe("N command (append next line)", () => { - it("appends next line to pattern space (even line count)", async () => { - const env = new Bash({ - // Use even number of lines so N always has a next line - files: { "/test.txt": "line1\nline2\nline3\nline4\n" }, - }); - const result = await env.exec("sed 'N;s/\\n/ /' /test.txt"); - expect(result.stdout).toBe("line1 line2\nline3 line4\n"); - }); - - it("auto-prints pattern space when N has no next line (odd line count)", async () => { - const env = new Bash({ - files: { "/test.txt": "line1\nline2\nline3\n" }, - }); - // With 3 lines: N works on 1+2, then N on line3 has no next line - // GNU sed auto-prints the pattern space before quitting - const result = await env.exec("sed 'N;s/\\n/ /' /test.txt"); - expect(result.stdout).toBe("line1 line2\nline3\n"); - }); - - it("joins pairs of lines", async () => { - const env = new Bash({ - files: { "/test.txt": "a\nb\nc\nd\n" }, - }); - const result = await env.exec("sed 'N;s/\\n/,/' /test.txt"); - expect(result.stdout).toBe("a,b\nc,d\n"); - }); - }); - - describe("y command (transliterate)", () => { - it("transliterates lowercase to uppercase", async () => { - const env = new Bash({ - files: { "/test.txt": "hello world\n" }, - }); - const result = await env.exec( - "sed 'y/abcdefghijklmnopqrstuvwxyz/ABCDEFGHIJKLMNOPQRSTUVWXYZ/' /test.txt", - ); - expect(result.stdout).toBe("HELLO WORLD\n"); - }); - - it("rotates characters", async () => { - const env = new Bash({ - files: { "/test.txt": "abc\n" }, - }); - const result = await env.exec("sed 'y/abc/bca/' /test.txt"); - expect(result.stdout).toBe("bca\n"); - }); - - it("handles escape sequences", async () => { - const env = new Bash({ - files: { "/test.txt": "a\tb\n" }, - }); - const result = await env.exec("sed 'y/\\t/ /' /test.txt"); - expect(result.stdout).toBe("a b\n"); - }); - }); - - describe("= command (print line number)", () => { - it("prints line numbers", async () => { - const env = new Bash({ - files: { "/test.txt": "a\nb\nc\n" }, - }); - const result = await env.exec("sed '=' /test.txt"); - expect(result.stdout).toBe("1\na\n2\nb\n3\nc\n"); - }); - - it("prints line number for specific address", async () => { - const env = new Bash({ - files: { "/test.txt": "a\nb\nc\n" }, - }); - const result = await env.exec("sed '2=' /test.txt"); - expect(result.stdout).toBe("a\n2\nb\nc\n"); - }); - }); - - describe("branching commands (b, t, :label)", () => { - it("branch to end of script", async () => { - const env = new Bash({ - files: { "/test.txt": "hello\nworld\n" }, - }); - // Branch unconditionally, skipping the delete command - const result = await env.exec("sed 'b;d' /test.txt"); - expect(result.stdout).toBe("hello\nworld\n"); - }); - - it("branch to label", async () => { - const env = new Bash({ - files: { "/test.txt": "hello\nworld\n" }, - }); - // Branch to skip label, avoiding delete - const result = await env.exec("sed 'b skip;d;:skip' /test.txt"); - expect(result.stdout).toBe("hello\nworld\n"); - }); - - it("conditional branch on substitution", async () => { - const env = new Bash({ - files: { "/test.txt": "hello\nworld\n" }, - }); - // If substitution happens, branch to end - const result = await env.exec("sed 's/hello/HELLO/;t;d' /test.txt"); - expect(result.stdout).toBe("HELLO\n"); - }); - - it("conditional branch to label", async () => { - const env = new Bash({ - files: { "/test.txt": "hello\nworld\n" }, - }); - const result = await env.exec( - "sed 's/hello/HELLO/;t done;s/world/WORLD/;:done' /test.txt", - ); - // First line: substitution happens, branch to done (skip second s) - // Second line: no match on first s, so second s executes - expect(result.stdout).toBe("HELLO\nWORLD\n"); - }); - }); - - describe("-f flag (script file)", () => { - it("reads script from file", async () => { - const env = new Bash({ - files: { - "/test.txt": "hello world\n", - "/script.sed": "s/hello/HELLO/\ns/world/WORLD/\n", - }, - }); - const result = await env.exec("sed -f /script.sed /test.txt"); - expect(result.stdout).toBe("HELLO WORLD\n"); - }); - - it("ignores comments in script file", async () => { - const env = new Bash({ - files: { - "/test.txt": "hello\n", - "/script.sed": "# This is a comment\ns/hello/HELLO/\n", - }, - }); - const result = await env.exec("sed -f /script.sed /test.txt"); - expect(result.stdout).toBe("HELLO\n"); - }); - - it("combines -f and -e options", async () => { - const env = new Bash({ - files: { - "/test.txt": "hello world\n", - "/script.sed": "s/hello/HELLO/\n", - }, - }); - const result = await env.exec( - "sed -f /script.sed -e 's/world/WORLD/' /test.txt", - ); - expect(result.stdout).toBe("HELLO WORLD\n"); - }); - - it("reports error for missing script file", async () => { - const env = new Bash({ - files: { "/test.txt": "hello\n" }, - }); - const result = await env.exec("sed -f /nonexistent.sed /test.txt"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("couldn't open file"); - }); - }); -}); diff --git a/src/commands/sed/sed.binary.test.ts b/src/commands/sed/sed.binary.test.ts deleted file mode 100644 index dcef0226..00000000 --- a/src/commands/sed/sed.binary.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("sed with binary content", () => { - it("should perform substitution on binary file", async () => { - const env = new Bash({ - files: { - "/data.bin": new Uint8Array([ - 0x68, - 0x65, - 0x6c, - 0x6c, - 0x6f, - 0x0a, // hello\n - ]), - }, - }); - - const result = await env.exec("sed 's/hello/world/' /data.bin"); - expect(result.stdout).toBe("world\n"); - expect(result.exitCode).toBe(0); - }); -}); diff --git a/src/commands/sed/sed.commands.test.ts b/src/commands/sed/sed.commands.test.ts deleted file mode 100644 index f9a82112..00000000 --- a/src/commands/sed/sed.commands.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("sed commands", () => { - const createEnv = () => - new Bash({ - files: { - "/test/file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n", - "/test/alpha.txt": "alpha\nbeta\ngamma\ndelta\n", - }, - cwd: "/test", - }); - - describe("Q command (quit without print)", () => { - it("should quit without printing current line", async () => { - const env = createEnv(); - const result = await env.exec("sed '3Q' /test/file.txt"); - // Q quits without printing, so only lines 1-2 are output - expect(result.stdout).toBe("line 1\nline 2\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle Q at first line", async () => { - const env = createEnv(); - const result = await env.exec("sed '1Q' /test/file.txt"); - // Q at line 1 quits immediately, no output - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - }); - }); - - describe("q command (quit with print)", () => { - it("should quit after printing current line", async () => { - const env = createEnv(); - const result = await env.exec("sed '3q' /test/file.txt"); - expect(result.stdout).toBe("line 1\nline 2\nline 3\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle q at last line", async () => { - const env = createEnv(); - const result = await env.exec("sed '$q' /test/file.txt"); - // Prints all lines and quits at end - expect(result.stdout).toBe("line 1\nline 2\nline 3\nline 4\nline 5\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("l command (list with escapes)", () => { - it("should show special characters with escapes", async () => { - const env = new Bash({ - files: { "/test.txt": "a\tb\nc\n" }, - cwd: "/", - }); - const result = await env.exec("sed -n 'l' /test.txt"); - // l command shows tabs as \t and ends lines with $ - expect(result.stdout).toContain("\\t"); - expect(result.stdout).toContain("$"); - }); - - it("should show non-printable chars as octal", async () => { - const env = new Bash({ - files: { "/test.txt": "a\x01b\n" }, - cwd: "/", - }); - const result = await env.exec("sed -n 'l' /test.txt"); - // Control chars shown as octal like \001 - expect(result.stdout).toContain("\\001"); - }); - }); - - describe("= command (print line number)", () => { - it("should print line numbers", async () => { - const env = createEnv(); - const result = await env.exec("sed -n '=' /test/file.txt"); - expect(result.stdout).toBe("1\n2\n3\n4\n5\n"); - }); - - it("should print line number before pattern space", async () => { - const env = createEnv(); - const result = await env.exec("sed -n '2{=;p}' /test/file.txt"); - expect(result.stdout).toBe("2\nline 2\n"); - }); - }); - - describe("a/i/c commands (append/insert/change)", () => { - it("should append text after line", async () => { - const env = createEnv(); - const result = await env.exec("sed '2a added' /test/alpha.txt"); - expect(result.stdout).toBe("alpha\nbeta\nadded\ngamma\ndelta\n"); - }); - - it("should insert text before line", async () => { - const env = createEnv(); - const result = await env.exec("sed '2i inserted' /test/alpha.txt"); - expect(result.stdout).toBe("alpha\ninserted\nbeta\ngamma\ndelta\n"); - }); - - it("should change/replace line", async () => { - const env = createEnv(); - const result = await env.exec("sed '2c replaced' /test/alpha.txt"); - expect(result.stdout).toBe("alpha\nreplaced\ngamma\ndelta\n"); - }); - - it("should change each line in range", async () => { - const env = createEnv(); - const result = await env.exec("sed '2,3c replaced' /test/alpha.txt"); - // c replaces each matched line - expect(result.stdout).toBe("alpha\nreplaced\nreplaced\ndelta\n"); - }); - }); - - describe("n/N commands (next line)", () => { - it("n should read next line into pattern space", async () => { - const env = createEnv(); - // Print every other line starting from 2 - const result = await env.exec("sed -n 'n;p' /test/file.txt"); - expect(result.stdout).toBe("line 2\nline 4\n"); - }); - - it("N should append next line to pattern space", async () => { - const env = createEnv(); - // Join pairs of lines - const result = await env.exec("sed 'N;s/\\n/ + /' /test/alpha.txt"); - expect(result.stdout).toBe("alpha + beta\ngamma + delta\n"); - }); - }); - - describe("h/H/g/G/x commands (hold space)", () => { - it("h should copy to hold space", async () => { - const env = createEnv(); - const result = await env.exec("sed -n '1h;3{g;p}' /test/alpha.txt"); - // Line 3 pattern space contains line 1 from hold - expect(result.stdout).toBe("alpha\n"); - }); - - it("H should append to hold space", async () => { - const env = createEnv(); - const result = await env.exec("sed -n '1h;2H;2{g;p}' /test/alpha.txt"); - // Hold space contains lines 1 and 2 separated by newline - expect(result.stdout).toBe("alpha\nbeta\n"); - }); - - it("G should append hold space to pattern", async () => { - const env = createEnv(); - const result = await env.exec("sed -n '1h;3{G;p}' /test/alpha.txt"); - expect(result.stdout).toBe("gamma\nalpha\n"); - }); - - it("x should exchange pattern and hold space", async () => { - const env = createEnv(); - const result = await env.exec("sed -n '1h;2x;2p' /test/alpha.txt"); - // Line 2: exchange puts "alpha" in pattern, "beta" in hold - expect(result.stdout).toBe("alpha\n"); - }); - }); - - describe("D command (delete first line of pattern space)", () => { - it("should delete up to first newline and restart", async () => { - const env = createEnv(); - // N joins two lines, D deletes first and restarts - const result = await env.exec("sed 'N;D' /test/file.txt"); - // Only last line remains (all prior lines get D'd) - expect(result.stdout).toBe("line 5\n"); - }); - }); - - describe("P command (print first line of pattern space)", () => { - it("should print up to first newline", async () => { - const env = createEnv(); - const result = await env.exec("sed -n 'N;P' /test/alpha.txt"); - // P prints first line of joined pair - expect(result.stdout).toBe("alpha\ngamma\n"); - }); - }); - - describe("branching with b/t/T commands", () => { - it("b should branch unconditionally", async () => { - const env = createEnv(); - const result = await env.exec( - "sed -e ':start;s/a/A/;t start;s/A/X/g' /test/alpha.txt", - ); - // Loop replaces all a with A, then all A with X - // The result has all a->A->X transformations - expect(result.stdout).toBe("XlphX\nbetX\ngXmmX\ndeltX\n"); - }); - - it("t should branch on successful substitution", async () => { - const env = createEnv(); - // If substitution succeeds, skip to end - const result = await env.exec( - "sed -e 's/alpha/FOUND/;t end;s/./X/g;:end' /test/alpha.txt", - ); - expect(result.stdout).toBe("FOUND\nXXXX\nXXXXX\nXXXXX\n"); - }); - - it("T should branch when no substitution made", async () => { - const env = createEnv(); - // T branches when no sub made - opposite of t - const result = await env.exec( - "sed -e 's/alpha/FOUND/;T skip;s/FOUND/REPLACED/;:skip' /test/alpha.txt", - ); - expect(result.stdout).toBe("REPLACED\nbeta\ngamma\ndelta\n"); - }); - }); - - describe("complex nested branching", () => { - it("should handle nested braces", async () => { - const env = createEnv(); - const result = await env.exec( - "sed '2,3{s/e/E/g;s/a/A/g}' /test/alpha.txt", - ); - expect(result.stdout).toBe("alpha\nbEtA\ngAmmA\ndelta\n"); - }); - - it("should handle multiple -e scripts", async () => { - const env = createEnv(); - const result = await env.exec( - "sed -e 's/a/1/' -e 's/e/2/' -e 's/i/3/' /test/alpha.txt", - ); - // Each -e substitution runs in sequence - expect(result.stdout).toBe("1lpha\nb2t1\ng1mma\nd2lt1\n"); - }); - }); - - describe("step addresses", () => { - it("should match every nth line", async () => { - const env = createEnv(); - // 1~2 matches lines 1, 3, 5, ... - const result = await env.exec("sed -n '1~2p' /test/file.txt"); - expect(result.stdout).toBe("line 1\nline 3\nline 5\n"); - }); - - it("should handle step starting from line 2", async () => { - const env = createEnv(); - // 2~2 matches lines 2, 4, ... - const result = await env.exec("sed -n '2~2p' /test/file.txt"); - expect(result.stdout).toBe("line 2\nline 4\n"); - }); - - it("should handle step of 3", async () => { - const env = createEnv(); - const result = await env.exec("sed -n '1~3p' /test/file.txt"); - expect(result.stdout).toBe("line 1\nline 4\n"); - }); - }); - - describe("relative offset addresses", () => { - it("should match relative offset after pattern", async () => { - const env = createEnv(); - // /alpha/,+2 matches alpha and next 2 lines - const result = await env.exec("sed -n '/alpha/,+2p' /test/alpha.txt"); - expect(result.stdout).toBe("alpha\nbeta\ngamma\n"); - }); - }); - - describe("$ (last line) address", () => { - it("should match last line", async () => { - const env = createEnv(); - const result = await env.exec("sed -n '$p' /test/file.txt"); - expect(result.stdout).toBe("line 5\n"); - }); - - it("should work in ranges", async () => { - const env = createEnv(); - const result = await env.exec("sed -n '3,$p' /test/file.txt"); - expect(result.stdout).toBe("line 3\nline 4\nline 5\n"); - }); - }); - - describe("negated addresses", () => { - it("should negate single address", async () => { - const env = createEnv(); - const result = await env.exec("sed -n '2!p' /test/alpha.txt"); - expect(result.stdout).toBe("alpha\ngamma\ndelta\n"); - }); - - it("should negate address range", async () => { - const env = createEnv(); - const result = await env.exec("sed -n '2,3!p' /test/alpha.txt"); - expect(result.stdout).toBe("alpha\ndelta\n"); - }); - }); -}); diff --git a/src/commands/sed/sed.errors.test.ts b/src/commands/sed/sed.errors.test.ts deleted file mode 100644 index 3594111e..00000000 --- a/src/commands/sed/sed.errors.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("sed errors", () => { - const createEnv = () => - new Bash({ - files: { - "/test/file.txt": "line 1\nline 2\nline 3\n", - }, - cwd: "/test", - }); - - describe("file errors", () => { - it("should error on non-existent file", async () => { - const env = createEnv(); - const result = await env.exec("sed 's/a/b/' /nonexistent.txt"); - expect(result.stderr).toContain("No such file or directory"); - expect(result.exitCode).toBe(1); - }); - - it("should error on multiple non-existent files", async () => { - const env = createEnv(); - const result = await env.exec("sed 's/a/b/' /no1.txt /no2.txt"); - expect(result.stderr).toContain("No such file or directory"); - expect(result.exitCode).toBe(1); - }); - - it("should error on non-existent script file with -f", async () => { - const env = createEnv(); - const result = await env.exec("sed -f /nonexistent.sed /test/file.txt"); - expect(result.stderr).toContain("No such file or directory"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("script parsing errors", () => { - it("should error on missing script", async () => { - const env = createEnv(); - const result = await env.exec("sed"); - expect(result.stderr).toContain("no script specified"); - expect(result.exitCode).toBe(1); - }); - - it("should handle unterminated substitution", async () => { - const env = createEnv(); - const result = await env.exec("sed 's/foo/bar' /test/file.txt"); - // Implementation may be lenient with unterminated substitution - // Just verify it doesn't hang - expect(result).toBeDefined(); - }); - - it("should handle non-standard substitution delimiter", async () => { - const env = createEnv(); - // Using newline as delimiter is allowed in sed - const result = await env.exec("sed 's|foo|bar|' /test/file.txt"); - expect(result.exitCode).toBe(0); - }); - - it("should handle unknown command gracefully", async () => { - const env = createEnv(); - const result = await env.exec("sed 'z' /test/file.txt"); - // Implementation may silently ignore unknown commands - expect(result).toBeDefined(); - }); - - it("should handle unknown flag gracefully", async () => { - const env = createEnv(); - const result = await env.exec("sed 's/a/b/z' /test/file.txt"); - // Implementation may ignore unknown flags - expect(result).toBeDefined(); - }); - }); - - describe("address errors", () => { - it("should handle line 0 address gracefully", async () => { - const env = createEnv(); - const result = await env.exec("sed '0d' /test/file.txt"); - // Implementation may treat line 0 as line 1 or ignore - expect(result).toBeDefined(); - }); - - it("should error on malformed regex address", async () => { - const env = createEnv(); - const result = await env.exec("sed '/foo d' /test/file.txt"); - expect(result.stderr).toContain("command expected"); - expect(result.exitCode).toBe(1); - }); - - it("should handle unknown POSIX class as literal", async () => { - const env = createEnv(); - // Unknown POSIX class may be treated as literal - const result = await env.exec("sed '/[[:invalid:]]/d' /test/file.txt"); - // Implementation treats [[:invalid:]] as literal or partial match - expect(result.exitCode).toBe(0); - }); - }); - - describe("label errors", () => { - it("should error on branch to undefined label", async () => { - const env = createEnv(); - const result = await env.exec("sed 'b undefined' /test/file.txt"); - expect(result.stderr).toContain("undefined label"); - expect(result.exitCode).toBe(1); - }); - - it("should error on t command with undefined label", async () => { - const env = createEnv(); - const result = await env.exec("sed 't missing' /test/file.txt"); - expect(result.stderr).toContain("undefined label"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("option errors", () => { - it("should error on unknown short option", async () => { - const env = createEnv(); - const result = await env.exec("sed -z 's/a/b/' /test/file.txt"); - expect(result.stderr).toContain("invalid option"); - expect(result.exitCode).toBe(1); - }); - - it("should error on unknown long option", async () => { - const env = createEnv(); - const result = await env.exec("sed --unknown 's/a/b/' /test/file.txt"); - expect(result.stderr).toContain("unrecognized option"); - expect(result.exitCode).toBe(1); - }); - - it("should error on -e without argument", async () => { - const env = createEnv(); - const result = await env.exec("sed -e /test/file.txt"); - // -e requires a script argument - expect(result.exitCode).toBe(1); - }); - }); - - describe("regex errors", () => { - it("should error on invalid regex pattern", async () => { - const env = createEnv(); - const result = await env.exec("sed 's/[/x/' /test/file.txt"); - expect(result.stderr).toBeTruthy(); - expect(result.exitCode).toBe(1); - }); - - it("should error on invalid backreference", async () => { - const env = createEnv(); - // Referencing group 9 when there are no groups - const result = await env.exec("sed 's/foo/\\9/' /test/file.txt"); - // Should either error or produce warning - expect(result.exitCode).toBe(0); // sed is lenient with backrefs - }); - - it("should handle unmatched parenthesis in BRE", async () => { - const env = createEnv(); - // In BRE, unescaped parens are literal - const result = await env.exec("sed 's/(foo)/[\\1]/' /test/file.txt"); - // Should work - parens are literal in BRE - expect(result.exitCode).toBe(0); - }); - }); - - describe("y command errors", () => { - it("should error on mismatched y command lengths", async () => { - const env = createEnv(); - const result = await env.exec("sed 'y/abc/xy/' /test/file.txt"); - expect(result.stderr).toContain("same length"); - expect(result.exitCode).toBe(1); - }); - - it("should error on unterminated y command", async () => { - const env = createEnv(); - const result = await env.exec("sed 'y/abc/xyz' /test/file.txt"); - expect(result.stderr).toContain("unterminated"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("{ } block errors", () => { - it("should handle valid block syntax", async () => { - const env = createEnv(); - const result = await env.exec("sed '1{d;}' /test/file.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("line 2\nline 3\n"); - }); - - it("should handle nested blocks", async () => { - const env = createEnv(); - const result = await env.exec("sed '1{s/1/X/;}' /test/file.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("line X"); - }); - }); - - describe("address restriction errors", () => { - it("should handle a command with text", async () => { - const env = createEnv(); - const result = await env.exec("sed 'a\\\\nappended' /test/file.txt"); - // Implementation may handle 'a' command with escaped newline - expect(result.exitCode).toBe(0); - }); - }); - - describe("step address errors", () => { - it("should handle step address with step 0", async () => { - const env = createEnv(); - const result = await env.exec("sed '1~0d' /test/file.txt"); - // Implementation may handle step 0 gracefully - expect(result).toBeDefined(); - }); - - it("should handle negative step address", async () => { - const env = createEnv(); - const result = await env.exec("echo -e '1\\n2\\n3' | sed '1~-1d'"); - // Negative step should error - expect(result.exitCode).toBe(1); - }); - }); -}); diff --git a/src/commands/sed/sed.limits.test.ts b/src/commands/sed/sed.limits.test.ts deleted file mode 100644 index 437a95af..00000000 --- a/src/commands/sed/sed.limits.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; -import { ExecutionLimitError } from "../../interpreter/errors.js"; - -/** - * SED Execution Limits Tests - * - * These tests verify that sed commands cannot cause runaway compute. - * SED programs should complete in bounded time regardless of input. - * - * IMPORTANT: All tests should complete quickly (<1s each). - */ - -describe("SED Execution Limits", () => { - describe("infinite loop protection", () => { - it("should protect against branch loop (b command)", async () => { - const env = new Bash(); - // :label followed by b label creates infinite loop - const result = await env.exec(`echo "test" | sed ':loop; b loop'`); - - // Must hit our internal limit with correct exit code - expect(result.stderr).toContain("exceeded maximum iterations"); - expect(result.exitCode).toBe(ExecutionLimitError.EXIT_CODE); - }); - - it("should protect against test loop (t command)", async () => { - const env = new Bash(); - // Substitution that always succeeds + t branch = infinite loop - const result = await env.exec( - `echo "test" | sed ':loop; s/./&/; t loop'`, - ); - - expect(result.stderr).toContain("exceeded maximum iterations"); - expect(result.exitCode).toBe(ExecutionLimitError.EXIT_CODE); - }); - - it("should protect against unconditional branch at start", async () => { - const env = new Bash(); - const result = await env.exec(`echo "test" | sed 'b; p'`); - - // Should complete - this isn't infinite but tests branch handling - expect(result.exitCode).toBeDefined(); - }); - }); - - describe("substitution limits", () => { - it("should handle global substitution on long lines", async () => { - const env = new Bash(); - const longLine = "a".repeat(100000); - await env.writeFile("/input.txt", longLine); - - const result = await env.exec(`sed 's/a/b/g' /input.txt`); - - // Should complete without hanging - expect(result.exitCode).toBe(0); - expect(result.stdout.length).toBeGreaterThan(0); - }); - - it("should handle backreference expansion limits", async () => { - const env = new Bash(); - // Many backreferences - const result = await env.exec( - `echo "abcdefghij" | sed 's/\\(.\\)\\(.\\)\\(.\\)\\(.\\)\\(.\\)\\(.\\)\\(.\\)\\(.\\)\\(.\\)\\(.\\)/\\1\\2\\3\\4\\5\\6\\7\\8\\9\\1/'`, - ); - - expect(result.exitCode).toBeDefined(); - }); - - it("should limit output from repeated substitution", async () => { - const env = new Bash(); - // Substitution that doubles content - const result = await env.exec(`echo "x" | sed 's/./&&/g'`); - - expect(result.exitCode).toBe(0); - }); - }); - - describe("hold space limits", () => { - it("should handle large hold space operations", async () => { - const env = new Bash(); - const lines = Array(1000).fill("line").join("\n"); - await env.writeFile("/input.txt", lines); - - // Append all lines to hold space - const result = await env.exec(`sed 'H' /input.txt`); - - expect(result.exitCode).toBeDefined(); - }); - - it("should handle exchange with large buffers", async () => { - const env = new Bash(); - const longLine = "x".repeat(10000); - await env.writeFile("/input.txt", longLine); - - const result = await env.exec(`sed 'h; x; x' /input.txt`); - - expect(result.exitCode).toBe(0); - }); - }); - - describe("regex limits", () => { - // Skip: BRE->ERE conversion creates ReDoS pattern (a+)+ which has catastrophic backtracking - it.skip("should handle pathological regex patterns", async () => { - const env = new Bash(); - // ReDoS-style pattern - const result = await env.exec( - `echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaab" | sed '/^\\(a\\+\\)\\+$/p'`, - ); - - // Should complete quickly - expect(result.exitCode).toBeDefined(); - }); - - it("should handle complex alternation", async () => { - const env = new Bash(); - const result = await env.exec( - `echo "test" | sed 's/a\\|b\\|c\\|d\\|e\\|f\\|g\\|h\\|i\\|j/X/g'`, - ); - - expect(result.exitCode).toBe(0); - }); - }); - - describe("address range limits", () => { - it("should handle large line number addresses", async () => { - const env = new Bash(); - const result = await env.exec(`echo "test" | sed '999999999p'`); - - // Should not hang trying to reach that line - expect(result.exitCode).toBe(0); - }); - - it("should handle step addresses on large input", async () => { - const env = new Bash(); - const lines = Array(10000).fill("line").join("\n"); - await env.writeFile("/input.txt", lines); - - const result = await env.exec(`sed -n '0~100p' /input.txt`); - - expect(result.exitCode).toBeDefined(); - }); - }); - - describe("command limits", () => { - it("should handle many commands", async () => { - const env = new Bash(); - const commands = Array(100).fill("s/a/b/").join("; "); - const result = await env.exec(`echo "aaa" | sed '${commands}'`); - - expect(result.exitCode).toBeDefined(); - }); - - it("should handle deeply nested braces", async () => { - const env = new Bash(); - // Nested command blocks - const result = await env.exec(`echo "test" | sed '{ { { p } } }'`); - - expect(result.exitCode).toBe(0); - }); - }); - - describe("n/N command limits", () => { - it("should handle N command accumulation without infinite loop", async () => { - const env = new Bash(); - const lines = Array(100).fill("line").join("\n"); - await env.writeFile("/input.txt", lines); - - // N accumulates lines but quits when no more lines available - // This should complete successfully (not loop forever) - const result = await env.exec(`sed ':a; N; ba' /input.txt`); - - // N quits when there's no next line, so this completes - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/commands/sed/sed.regex.test.ts b/src/commands/sed/sed.regex.test.ts deleted file mode 100644 index e0a374f3..00000000 --- a/src/commands/sed/sed.regex.test.ts +++ /dev/null @@ -1,408 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("sed regex patterns", () => { - describe("POSIX character classes", () => { - it("should match [:alpha:] alphabetic characters", async () => { - const env = new Bash({ - files: { "/test.txt": "abc123xyz\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/[[:alpha:]]/_/g' /test.txt"); - expect(result.stdout).toBe("___123___\n"); - }); - - it("should match [:digit:] numeric characters", async () => { - const env = new Bash({ - files: { "/test.txt": "abc123xyz\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/[[:digit:]]/#/g' /test.txt"); - expect(result.stdout).toBe("abc###xyz\n"); - }); - - it("should match [:alnum:] alphanumeric characters", async () => { - const env = new Bash({ - files: { "/test.txt": "a1-b2_c3\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/[[:alnum:]]/X/g' /test.txt"); - expect(result.stdout).toBe("XX-XX_XX\n"); - }); - - it("should match [:space:] whitespace", async () => { - const env = new Bash({ - files: { "/test.txt": "a b\tc\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/[[:space:]]/_/g' /test.txt"); - // Matches space and tab, newline is line terminator - expect(result.stdout).toBe("a_b_c\n"); - }); - - it("should match [:upper:] uppercase letters", async () => { - const env = new Bash({ - files: { "/test.txt": "Hello World\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/[[:upper:]]/X/g' /test.txt"); - expect(result.stdout).toBe("Xello Xorld\n"); - }); - - it("should match [:lower:] lowercase letters", async () => { - const env = new Bash({ - files: { "/test.txt": "Hello World\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/[[:lower:]]/x/g' /test.txt"); - expect(result.stdout).toBe("Hxxxx Wxxxx\n"); - }); - - it("should match [:punct:] punctuation", async () => { - const env = new Bash({ - files: { "/test.txt": "Hello, World!\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/[[:punct:]]//g' /test.txt"); - expect(result.stdout).toBe("Hello World\n"); - }); - - it("should match [:blank:] space and tab only", async () => { - const env = new Bash({ - files: { "/test.txt": "a b\tc\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/[[:blank:]]/_/g' /test.txt"); - // [:blank:] matches space and tab but not newline - expect(result.stdout).toBe("a_b_c\n"); - }); - - it("should match [:xdigit:] hexadecimal digits", async () => { - const env = new Bash({ - files: { "/test.txt": "0x1F2a3b\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/[[:xdigit:]]/X/g' /test.txt"); - // Implementation matches 0-9, a-f, A-F and lowercase hex prefix 'x' - expect(result.stdout).toBe("XxXXXXXX\n"); - }); - - it("should support negated character classes", async () => { - const env = new Bash({ - files: { "/test.txt": "abc123\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/[^[:digit:]]/X/g' /test.txt"); - // Newline is line terminator, not matched by pattern - expect(result.stdout).toBe("XXX123\n"); - }); - - it("should combine POSIX classes with other characters", async () => { - const env = new Bash({ - files: { "/test.txt": "a1-b2_c3\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/[[:digit:]_-]/./g' /test.txt"); - expect(result.stdout).toBe("a..b..c.\n"); - }); - }); - - describe("BRE (Basic Regular Expression) patterns", () => { - it("should treat + as literal without backslash", async () => { - const env = new Bash({ - files: { "/test.txt": "a+b\naab\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/a+b/X/' /test.txt"); - expect(result.stdout).toBe("X\naab\n"); - }); - - it("should treat \\+ as quantifier", async () => { - const env = new Bash({ - files: { "/test.txt": "aab\nab\nb\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/a\\+b/X/' /test.txt"); - expect(result.stdout).toBe("X\nX\nb\n"); - }); - - it("should treat ? as literal without backslash", async () => { - const env = new Bash({ - files: { "/test.txt": "a?b\nab\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/a?b/X/' /test.txt"); - expect(result.stdout).toBe("X\nab\n"); - }); - - it("should treat | as literal without backslash", async () => { - const env = new Bash({ - files: { "/test.txt": "a|b\nab\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/a|b/X/' /test.txt"); - expect(result.stdout).toBe("X\nab\n"); - }); - - it("should treat () as literal without backslash", async () => { - const env = new Bash({ - files: { "/test.txt": "(foo)\nfoo\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/(foo)/X/' /test.txt"); - expect(result.stdout).toBe("X\nfoo\n"); - }); - - // Skipped: RE2 doesn't support backreferences for ReDoS protection - it.skip("should use \\( \\) for grouping in BRE", async () => { - const env = new Bash({ - files: { "/test.txt": "abcabc\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/\\(abc\\)\\1/X/' /test.txt"); - expect(result.stdout).toBe("X\n"); - }); - }); - - describe("ERE (Extended Regular Expression) with -E/-r", () => { - it("should treat + as quantifier with -E", async () => { - const env = new Bash({ - files: { "/test.txt": "aab\nab\nb\n" }, - cwd: "/", - }); - const result = await env.exec("sed -E 's/a+b/X/' /test.txt"); - expect(result.stdout).toBe("X\nX\nb\n"); - }); - - it("should treat ? as quantifier with -E", async () => { - const env = new Bash({ - files: { "/test.txt": "ab\nb\naab\n" }, - cwd: "/", - }); - const result = await env.exec("sed -E 's/a?b/X/' /test.txt"); - expect(result.stdout).toBe("X\nX\naX\n"); - }); - - it("should treat | as alternation with -E", async () => { - const env = new Bash({ - files: { "/test.txt": "cat\ndog\nrat\n" }, - cwd: "/", - }); - const result = await env.exec("sed -E 's/cat|dog/X/' /test.txt"); - expect(result.stdout).toBe("X\nX\nrat\n"); - }); - - // Skipped: RE2 doesn't support backreferences for ReDoS protection - it.skip("should use () for grouping with -E", async () => { - const env = new Bash({ - files: { "/test.txt": "abcabc\n" }, - cwd: "/", - }); - const result = await env.exec("sed -E 's/(abc)\\1/X/' /test.txt"); - expect(result.stdout).toBe("X\n"); - }); - - it("-r should work same as -E", async () => { - const env = new Bash({ - files: { "/test.txt": "aab\nab\n" }, - cwd: "/", - }); - const result = await env.exec("sed -r 's/a+b/X/' /test.txt"); - expect(result.stdout).toBe("X\nX\n"); - }); - }); - - describe("backreferences", () => { - it("should support backreferences in replacement", async () => { - const env = new Bash({ - files: { "/test.txt": "hello world\n" }, - cwd: "/", - }); - const result = await env.exec( - "sed 's/\\(hello\\) \\(world\\)/\\2 \\1/' /test.txt", - ); - expect(result.stdout).toBe("world hello\n"); - }); - - it("should support & as entire match", async () => { - const env = new Bash({ - files: { "/test.txt": "hello\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/hello/[&]/' /test.txt"); - expect(result.stdout).toBe("[hello]\n"); - }); - - it("should escape & with backslash", async () => { - const env = new Bash({ - files: { "/test.txt": "hello\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/hello/\\&/' /test.txt"); - expect(result.stdout).toBe("&\n"); - }); - - it("should support multiple backreferences", async () => { - const env = new Bash({ - files: { "/test.txt": "abc\n" }, - cwd: "/", - }); - const result = await env.exec( - "sed -E 's/(a)(b)(c)/\\3\\2\\1/' /test.txt", - ); - expect(result.stdout).toBe("cba\n"); - }); - }); - - describe("anchors", () => { - it("should match ^ at start of line", async () => { - const env = new Bash({ - files: { "/test.txt": "abc\nxabc\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/^a/X/' /test.txt"); - expect(result.stdout).toBe("Xbc\nxabc\n"); - }); - - it("should match $ at end of line", async () => { - const env = new Bash({ - files: { "/test.txt": "abc\nabcx\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/c$/X/' /test.txt"); - expect(result.stdout).toBe("abX\nabcx\n"); - }); - - it("should match ^$ for empty line", async () => { - const env = new Bash({ - files: { "/test.txt": "a\n\nb\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/^$/EMPTY/' /test.txt"); - expect(result.stdout).toBe("a\nEMPTY\nb\n"); - }); - }); - - describe("special characters and escapes", () => { - it("should match literal dot with backslash", async () => { - const env = new Bash({ - files: { "/test.txt": "a.b\nacb\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/a\\.b/X/' /test.txt"); - expect(result.stdout).toBe("X\nacb\n"); - }); - - it("should match . as any character", async () => { - const env = new Bash({ - files: { "/test.txt": "a1b\na2b\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/a.b/X/' /test.txt"); - expect(result.stdout).toBe("X\nX\n"); - }); - - it("should handle newline in replacement with \\n", async () => { - const env = new Bash({ - files: { "/test.txt": "a:b\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/:/\\n/' /test.txt"); - expect(result.stdout).toBe("a\nb\n"); - }); - - it("should handle tab in replacement with \\t", async () => { - const env = new Bash({ - files: { "/test.txt": "a:b\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/:/\\t/' /test.txt"); - expect(result.stdout).toBe("a\tb\n"); - }); - }); - - describe("quantifiers", () => { - it("should match * (zero or more)", async () => { - const env = new Bash({ - files: { "/test.txt": "b\nab\naab\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/a*/X/' /test.txt"); - expect(result.stdout).toBe("Xb\nXb\nXb\n"); - }); - - it("should match \\{n\\} exactly n times in BRE", async () => { - const env = new Bash({ - files: { "/test.txt": "aa\naaa\naaaa\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/a\\{3\\}/X/' /test.txt"); - expect(result.stdout).toBe("aa\nX\nXa\n"); - }); - - it("should match {n} exactly n times in ERE", async () => { - const env = new Bash({ - files: { "/test.txt": "aa\naaa\naaaa\n" }, - cwd: "/", - }); - const result = await env.exec("sed -E 's/a{3}/X/' /test.txt"); - expect(result.stdout).toBe("aa\nX\nXa\n"); - }); - - it("should match \\{n,m\\} range in BRE", async () => { - const env = new Bash({ - files: { "/test.txt": "a\naa\naaa\naaaa\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/a\\{2,3\\}/X/' /test.txt"); - expect(result.stdout).toBe("a\nX\nX\nXa\n"); - }); - - it("should match {n,} at least n times in ERE", async () => { - const env = new Bash({ - files: { "/test.txt": "a\naa\naaa\n" }, - cwd: "/", - }); - const result = await env.exec("sed -E 's/a{2,}/X/' /test.txt"); - expect(result.stdout).toBe("a\nX\nX\n"); - }); - }); - - describe("character classes", () => { - it("should match bracket expression", async () => { - const env = new Bash({ - files: { "/test.txt": "cat\ncut\ncot\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/c[aou]t/X/' /test.txt"); - expect(result.stdout).toBe("X\nX\nX\n"); - }); - - it("should match negated bracket expression", async () => { - const env = new Bash({ - files: { "/test.txt": "cat\ncbt\ncct\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/c[^a]t/X/' /test.txt"); - expect(result.stdout).toBe("cat\nX\nX\n"); - }); - - it("should match range in bracket expression", async () => { - const env = new Bash({ - files: { "/test.txt": "a1b\na5b\na9b\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/a[0-4]b/X/' /test.txt"); - expect(result.stdout).toBe("X\na5b\na9b\n"); - }); - - it("should match literal ] at start of bracket", async () => { - const env = new Bash({ - files: { "/test.txt": "a]b\na[b\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/a[][]b/X/' /test.txt"); - expect(result.stdout).toBe("X\nX\n"); - }); - }); -}); diff --git a/src/commands/sed/sed.test.ts b/src/commands/sed/sed.test.ts deleted file mode 100644 index ee55cba7..00000000 --- a/src/commands/sed/sed.test.ts +++ /dev/null @@ -1,982 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; -import { SedLexer, SedTokenType } from "./lexer.js"; -import { parseMultipleScripts } from "./parser.js"; - -describe("sed lexer", () => { - it("should tokenize relative offset address +N", () => { - const lexer = new SedLexer("/^2/,+2d"); - const tokens = lexer.tokenize(); - const tokenTypes = tokens.map((t) => t.type); - expect(tokenTypes).toContain(SedTokenType.RELATIVE_OFFSET); - }); -}); - -describe("sed parser", () => { - it("should parse relative offset address", () => { - const result = parseMultipleScripts(["/^2/,+2d"]); - expect(result.error).toBeUndefined(); - expect(result.commands.length).toBe(1); - }); -}); - -describe("sed command", () => { - const createEnv = () => - new Bash({ - files: { - "/test/file.txt": "hello world\nhello universe\ngoodbye world\n", - "/test/numbers.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n", - "/test/names.txt": "John Smith\nJane Doe\nBob Johnson\n", - }, - cwd: "/test", - }); - - it("should replace first occurrence per line", async () => { - const env = createEnv(); - const result = await env.exec("sed 's/hello/hi/' /test/file.txt"); - expect(result.stdout).toBe("hi world\nhi universe\ngoodbye world\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should replace all occurrences with g flag", async () => { - const env = createEnv(); - const result = await env.exec("sed 's/l/L/g' /test/file.txt"); - expect(result.stdout).toBe("heLLo worLd\nheLLo universe\ngoodbye worLd\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should print specific line with -n and line number", async () => { - const env = createEnv(); - const result = await env.exec("sed -n '3p' /test/numbers.txt"); - expect(result.stdout).toBe("line 3\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should print range of lines", async () => { - const env = createEnv(); - const result = await env.exec("sed -n '2,4p' /test/numbers.txt"); - expect(result.stdout).toBe("line 2\nline 3\nline 4\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should delete matching lines", async () => { - const env = createEnv(); - const result = await env.exec("sed '/hello/d' /test/file.txt"); - expect(result.stdout).toBe("goodbye world\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should delete specific line number", async () => { - const env = createEnv(); - const result = await env.exec("sed '2d' /test/numbers.txt"); - expect(result.stdout).toBe("line 1\nline 3\nline 4\nline 5\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should read from stdin via pipe", async () => { - const env = createEnv(); - const result = await env.exec("echo 'foo bar' | sed 's/bar/baz/'"); - expect(result.stdout).toBe("foo baz\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should use different delimiter", async () => { - const env = createEnv(); - const result = await env.exec( - "echo '/path/to/file' | sed 's#/path#/newpath#'", - ); - expect(result.stdout).toBe("/newpath/to/file\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should handle regex patterns in substitution", async () => { - const env = createEnv(); - const result = await env.exec("sed 's/[0-9]/X/' /test/numbers.txt"); - expect(result.stdout).toBe("line X\nline X\nline X\nline X\nline X\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should return error for non-existent file", async () => { - const env = createEnv(); - const result = await env.exec("sed 's/a/b/' /test/nonexistent.txt"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "sed: /test/nonexistent.txt: No such file or directory\n", - ); - expect(result.exitCode).toBe(1); - }); - - it("should handle empty replacement", async () => { - const env = createEnv(); - const result = await env.exec("sed 's/world//' /test/file.txt"); - expect(result.stdout).toBe("hello \nhello universe\ngoodbye \n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should delete range of lines", async () => { - const env = createEnv(); - const result = await env.exec("sed '2,4d' /test/numbers.txt"); - expect(result.stdout).toBe("line 1\nline 5\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - describe("case insensitive flag (i)", () => { - it("should replace case insensitively with i flag", async () => { - const env = createEnv(); - const result = await env.exec("sed 's/HELLO/hi/i' /test/file.txt"); - expect(result.stdout).toBe("hi world\nhi universe\ngoodbye world\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should combine i and g flags", async () => { - const env = new Bash({ - files: { "/test.txt": "Hello HELLO hello\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/hello/hi/gi' /test.txt"); - expect(result.stdout).toBe("hi hi hi\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - }); - - describe("address ranges with substitute", () => { - it("should substitute only on line 1", async () => { - const env = createEnv(); - const result = await env.exec("sed '1s/line/LINE/' /test/numbers.txt"); - expect(result.stdout).toBe("LINE 1\nline 2\nline 3\nline 4\nline 5\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should substitute only on line 2", async () => { - const env = createEnv(); - const result = await env.exec("sed '2s/line/LINE/' /test/numbers.txt"); - expect(result.stdout).toBe("line 1\nLINE 2\nline 3\nline 4\nline 5\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should substitute on last line with $", async () => { - const env = createEnv(); - const result = await env.exec("sed '$ s/line/LINE/' /test/numbers.txt"); - expect(result.stdout).toBe("line 1\nline 2\nline 3\nline 4\nLINE 5\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should substitute on range of lines", async () => { - const env = createEnv(); - const result = await env.exec("sed '2,4s/line/LINE/' /test/numbers.txt"); - expect(result.stdout).toBe("line 1\nLINE 2\nLINE 3\nLINE 4\nline 5\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - }); - - describe("$ address for delete", () => { - it("should delete last line with $d", async () => { - const env = createEnv(); - const result = await env.exec("sed '$ d' /test/numbers.txt"); - expect(result.stdout).toBe("line 1\nline 2\nline 3\nline 4\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should delete last line without space", async () => { - const env = createEnv(); - const result = await env.exec("sed '$d' /test/numbers.txt"); - expect(result.stdout).toBe("line 1\nline 2\nline 3\nline 4\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - }); - - describe("multiple expressions (-e)", () => { - it("should apply multiple -e expressions", async () => { - const env = createEnv(); - const result = await env.exec( - "sed -e 's/hello/hi/' -e 's/world/there/' /test/file.txt", - ); - expect(result.stdout).toBe("hi there\nhi universe\ngoodbye there\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should apply three -e expressions", async () => { - const env = createEnv(); - const result = await env.exec( - "sed -e 's/line/LINE/' -e 's/1/one/' -e 's/2/two/' /test/numbers.txt", - ); - expect(result.stdout).toBe( - "LINE one\nLINE two\nLINE 3\nLINE 4\nLINE 5\n", - ); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - }); - - describe("& replacement (matched text)", () => { - it("should replace & with matched text", async () => { - const env = new Bash({ - files: { "/test.txt": "hello\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/hello/[&]/' /test.txt"); - expect(result.stdout).toBe("[hello]\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should handle multiple & in replacement", async () => { - const env = new Bash({ - files: { "/test.txt": "world\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/world/&-&-&/' /test.txt"); - expect(result.stdout).toBe("world-world-world\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should handle escaped & in replacement", async () => { - const env = new Bash({ - files: { "/test.txt": "hello\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/hello/\\&/' /test.txt"); - expect(result.stdout).toBe("&\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - }); - - describe("in-place editing (-i)", () => { - it("should edit file in-place with -i", async () => { - const env = new Bash({ - files: { "/test.txt": "hello world\n" }, - cwd: "/", - }); - const result = await env.exec("sed -i 's/hello/hi/' /test.txt"); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - - // Verify file was modified - const cat = await env.exec("cat /test.txt"); - expect(cat.stdout).toBe("hi world\n"); - }); - - it("should edit file in-place with global replacement", async () => { - const env = new Bash({ - files: { "/test.txt": "foo foo foo\nbar foo bar\n" }, - cwd: "/", - }); - const result = await env.exec("sed -i 's/foo/baz/g' /test.txt"); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - - const cat = await env.exec("cat /test.txt"); - expect(cat.stdout).toBe("baz baz baz\nbar baz bar\n"); - }); - - it("should delete lines in-place", async () => { - const env = new Bash({ - files: { "/test.txt": "line 1\nline 2\nline 3\n" }, - cwd: "/", - }); - const result = await env.exec("sed -i '2d' /test.txt"); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - - const cat = await env.exec("cat /test.txt"); - expect(cat.stdout).toBe("line 1\nline 3\n"); - }); - - it("should delete matching lines in-place", async () => { - const env = new Bash({ - files: { "/test.txt": "keep this\nremove this\nkeep that\n" }, - cwd: "/", - }); - const result = await env.exec("sed -i '/remove/d' /test.txt"); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - - const cat = await env.exec("cat /test.txt"); - expect(cat.stdout).toBe("keep this\nkeep that\n"); - }); - - it("should edit multiple files in-place", async () => { - const env = new Bash({ - files: { - "/a.txt": "hello\n", - "/b.txt": "hello\n", - }, - cwd: "/", - }); - const result = await env.exec("sed -i 's/hello/hi/' /a.txt /b.txt"); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - - const catA = await env.exec("cat /a.txt"); - expect(catA.stdout).toBe("hi\n"); - - const catB = await env.exec("cat /b.txt"); - expect(catB.stdout).toBe("hi\n"); - }); - - it("should handle --in-place flag", async () => { - const env = new Bash({ - files: { "/test.txt": "old text\n" }, - cwd: "/", - }); - const result = await env.exec("sed --in-place 's/old/new/' /test.txt"); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - - const cat = await env.exec("cat /test.txt"); - expect(cat.stdout).toBe("new text\n"); - }); - }); - - describe("hold space commands (h/H/g/G/x)", () => { - it("should copy pattern space to hold space with h", async () => { - const env = new Bash({ - files: { "/test.txt": "first\nsecond\nthird\n" }, - cwd: "/", - }); - // h on line 1 copies "first" to hold, G on line 3 appends hold to pattern - const result = await env.exec("sed '1h;3G' /test.txt"); - expect(result.stdout).toBe("first\nsecond\nthird\nfirst\n"); - }); - - it("should append pattern space to hold space with H", async () => { - const env = new Bash({ - files: { "/test.txt": "a\nb\nc\n" }, - cwd: "/", - }); - // H appends each line to hold space - // After line a: hold = "a" - // After line b: hold = "a\nb" - // After line c: hold = "a\nb\nc" - // $G appends hold to pattern: "c" + "\n" + "a\nb\nc" = "c\na\nb\nc" - const result = await env.exec("sed 'H;$G' /test.txt"); - expect(result.stdout).toBe("a\nb\nc\na\nb\nc\n"); - }); - - it("should copy hold space to pattern space with g", async () => { - const env = new Bash({ - files: { "/test.txt": "first\nsecond\n" }, - cwd: "/", - }); - // h on line 1 saves "first", g on line 2 replaces "second" with "first" - const result = await env.exec("sed '1h;2g' /test.txt"); - expect(result.stdout).toBe("first\nfirst\n"); - }); - - it("should append hold space to pattern space with G", async () => { - const env = new Bash({ - files: { "/test.txt": "header\ndata\n" }, - cwd: "/", - }); - // h saves "header", G on line 2 appends hold to pattern - const result = await env.exec("sed '1h;2G' /test.txt"); - expect(result.stdout).toBe("header\ndata\nheader\n"); - }); - - it("should exchange pattern and hold spaces with x", async () => { - const env = new Bash({ - files: { "/test.txt": "A\nB\n" }, - cwd: "/", - }); - // x on each line exchanges pattern/hold - // Line 1: pattern=A, hold=empty -> pattern=empty, hold=A (prints empty) - // Line 2: pattern=B, hold=A -> pattern=A, hold=B (prints A) - const result = await env.exec("sed 'x' /test.txt"); - expect(result.stdout).toBe("\nA\n"); - }); - - it("should collect lines in hold space with h and H", async () => { - const env = new Bash({ - files: { "/test.txt": "1\n2\n3\n" }, - cwd: "/", - }); - // 1h saves first line, 1!H appends subsequent lines - // After processing: hold = "1\n2\n3" - // $g copies hold to pattern space (replaces "3") - // -n suppresses auto-print, $p prints last line (which is now hold content) - const result = await env.exec("sed -n '$g;$p' /test.txt"); - // Since we don't accumulate with 1h;1!H, g will just copy empty hold - expect(result.stdout).toBe("\n"); - }); - }); - - describe("append command (a)", () => { - it("should append text after matching line", async () => { - const env = new Bash({ - files: { "/test.txt": "line 1\nline 2\nline 3\n" }, - cwd: "/", - }); - const result = await env.exec("sed '2a\\ appended' /test.txt"); - expect(result.stdout).toBe("line 1\nline 2\nappended\nline 3\n"); - }); - - it("should append text after every line", async () => { - const env = new Bash({ - files: { "/test.txt": "a\nb\n" }, - cwd: "/", - }); - const result = await env.exec("sed 'a\\ ---' /test.txt"); - expect(result.stdout).toBe("a\n---\nb\n---\n"); - }); - - it("should append text after last line", async () => { - const env = new Bash({ - files: { "/test.txt": "first\nlast\n" }, - cwd: "/", - }); - const result = await env.exec("sed '$a\\ footer' /test.txt"); - expect(result.stdout).toBe("first\nlast\nfooter\n"); - }); - }); - - describe("insert command (i)", () => { - it("should insert text before matching line", async () => { - const env = new Bash({ - files: { "/test.txt": "line 1\nline 2\nline 3\n" }, - cwd: "/", - }); - const result = await env.exec("sed '2i\\ inserted' /test.txt"); - expect(result.stdout).toBe("line 1\ninserted\nline 2\nline 3\n"); - }); - - it("should insert text before first line", async () => { - const env = new Bash({ - files: { "/test.txt": "content\n" }, - cwd: "/", - }); - const result = await env.exec("sed '1i\\ header' /test.txt"); - expect(result.stdout).toBe("header\ncontent\n"); - }); - - it("should insert text before every line", async () => { - const env = new Bash({ - files: { "/test.txt": "a\nb\n" }, - cwd: "/", - }); - const result = await env.exec("sed 'i\\ >' /test.txt"); - expect(result.stdout).toBe(">\na\n>\nb\n"); - }); - }); - - describe("change command (c)", () => { - it("should change matching line", async () => { - const env = new Bash({ - files: { "/test.txt": "old line\n" }, - cwd: "/", - }); - const result = await env.exec("sed '1c\\ new line' /test.txt"); - expect(result.stdout).toBe("new line\n"); - }); - - it("should change specific line number", async () => { - const env = new Bash({ - files: { "/test.txt": "line 1\nline 2\nline 3\n" }, - cwd: "/", - }); - const result = await env.exec("sed '2c\\ replaced' /test.txt"); - expect(result.stdout).toBe("line 1\nreplaced\nline 3\n"); - }); - }); - - describe("quit command (q)", () => { - it("should quit after matching line", async () => { - const env = new Bash({ - files: { "/test.txt": "1\n2\n3\n4\n5\n" }, - cwd: "/", - }); - const result = await env.exec("sed '3q' /test.txt"); - expect(result.stdout).toBe("1\n2\n3\n"); - }); - - it("should quit immediately on first line", async () => { - const env = new Bash({ - files: { "/test.txt": "a\nb\nc\n" }, - cwd: "/", - }); - const result = await env.exec("sed '1q' /test.txt"); - expect(result.stdout).toBe("a\n"); - }); - }); - - describe("escaped characters", () => { - it("should handle escaped parentheses in pattern", async () => { - const env = new Bash({ - files: { "/test.txt": "const x = require('foo');\n" }, - cwd: "/", - }); - // Use -E for ERE mode where \( and \) are literal parentheses - const result = await env.exec( - "sed -E \"s/const x = require\\('foo'\\);/import x from 'foo';/g\" /test.txt", - ); - expect(result.stdout).toBe("import x from 'foo';\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle semicolons in pattern and replacement", async () => { - const env = new Bash({ - files: { "/test.txt": "a;b;c\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/a;b/x;y/' /test.txt"); - expect(result.stdout).toBe("x;y;c\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("pattern addresses", () => { - it("should match lines by pattern", async () => { - const env = new Bash({ - files: { "/test.txt": "foo\nbar\nbaz\n" }, - cwd: "/", - }); - const result = await env.exec("sed '/bar/d' /test.txt"); - expect(result.stdout).toBe("foo\nbaz\n"); - }); - - it("should apply substitution to pattern-matched lines", async () => { - const env = new Bash({ - files: { "/test.txt": "apple\nbanana\napricot\n" }, - cwd: "/", - }); - const result = await env.exec("sed '/^a/s/a/A/g' /test.txt"); - expect(result.stdout).toBe("Apple\nbanana\nApricot\n"); - }); - }); - - describe("Nth occurrence substitution", () => { - it("should replace 2nd occurrence only", async () => { - const env = new Bash({ - files: { "/test.txt": "foo bar foo baz foo\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/foo/XXX/2' /test.txt"); - expect(result.stdout).toBe("foo bar XXX baz foo\n"); - }); - - it("should replace 3rd occurrence only", async () => { - const env = new Bash({ - files: { "/test.txt": "a a a a a\n" }, - cwd: "/", - }); - const result = await env.exec("sed 's/a/X/3' /test.txt"); - expect(result.stdout).toBe("a a X a a\n"); - }); - }); - - describe("step address (first~step)", () => { - it("should match every 2nd line starting from 0", async () => { - const env = new Bash({ - files: { "/test.txt": "1\n2\n3\n4\n5\n6\n" }, - cwd: "/", - }); - const result = await env.exec("sed -n '0~2p' /test.txt"); - expect(result.stdout).toBe("2\n4\n6\n"); - }); - - it("should match every 3rd line starting from 1", async () => { - const env = new Bash({ - files: { "/test.txt": "1\n2\n3\n4\n5\n6\n" }, - cwd: "/", - }); - const result = await env.exec("sed -n '1~3p' /test.txt"); - expect(result.stdout).toBe("1\n4\n"); - }); - }); - - describe("relative offset address (+N)", () => { - it("should delete N lines after matching pattern", async () => { - const env = new Bash({ - files: { "/test.txt": "1\n2\n3\n4\n5\n" }, - cwd: "/", - }); - const result = await env.exec("sed '/^2/,+2d' /test.txt"); - expect(result.stdout).toBe("1\n5\n"); - }); - - it("should print N lines after matching pattern", async () => { - const env = new Bash({ - files: { "/test.txt": "a\n1\nc\nc\na\n2\na\n3\n" }, - cwd: "/", - }); - const result = await env.exec("sed -n '/a/,+1p' /test.txt"); - expect(result.stdout).toBe("a\n1\na\n2\na\n3\n"); - }); - - it("should work with grouped commands", async () => { - const env = new Bash({ - files: { "/test.txt": "1\n2\n3\n4\n5\n" }, - cwd: "/", - }); - const result = await env.exec("sed '/^2/,+2{d}' /test.txt"); - expect(result.stdout).toBe("1\n5\n"); - }); - }); - - describe("grouped commands", () => { - it("should execute multiple commands in group", async () => { - const env = new Bash({ - files: { "/test.txt": "a\nb\nc\n" }, - cwd: "/", - }); - const result = await env.exec("sed '2{s/b/B/}' /test.txt"); - expect(result.stdout).toBe("a\nB\nc\n"); - }); - - it("should execute multiple commands with semicolon in group", async () => { - const env = new Bash({ - files: { "/test.txt": "a\nb\nc\n" }, - cwd: "/", - }); - const result = await env.exec("sed -n '2{s/b/B/;p}' /test.txt"); - expect(result.stdout).toBe("B\n"); - }); - }); - - describe("P command (print first line)", () => { - it("should print up to first newline", async () => { - const env = new Bash({ - files: { "/test.txt": "line1\nline2\n" }, - cwd: "/", - }); - // N appends next line, P prints first part - const result = await env.exec("sed -n 'N;P' /test.txt"); - expect(result.stdout).toBe("line1\n"); - }); - }); - - describe("D command (delete first line)", () => { - it("should delete up to first newline and restart cycle", async () => { - const env = new Bash({ - files: { "/test.txt": "a\nb\nc\n" }, - cwd: "/", - }); - // N;P;D is the classic sliding window: prints each line except last - // N appends next line, P prints first part, D deletes first part and restarts - const result = await env.exec("sed -n 'N;P;D' /test.txt"); - // Real bash: outputs "a\nb\n" (all lines except last) - expect(result.stdout).toBe("a\nb\n"); - }); - - it("should quit when N has no more lines", async () => { - const env = new Bash({ - files: { "/test.txt": "a\nb\nc\n" }, - cwd: "/", - }); - // N;D: N appends next line, D deletes first line and restarts - // When N finally fails (no more lines), GNU sed auto-prints the pattern space - const result = await env.exec("sed 'N;D' /test.txt"); - // GNU sed: prints the remaining line when N fails - expect(result.stdout).toBe("c\n"); - }); - }); - - describe("z command (zap pattern space)", () => { - it("should empty pattern space", async () => { - const env = new Bash({ - files: { "/test.txt": "hello\nworld\n" }, - cwd: "/", - }); - const result = await env.exec("sed '1z' /test.txt"); - expect(result.stdout).toBe("\nworld\n"); - }); - }); - - describe("T command (branch if no substitution)", () => { - it("should branch when no substitution made", async () => { - const env = new Bash({ - files: { "/test.txt": "foo\nbar\n" }, - cwd: "/", - }); - // Replace foo with FOO, T branches to end if no match (skipping p) - const result = await env.exec("sed -n 's/foo/FOO/;T;p' /test.txt"); - expect(result.stdout).toBe("FOO\n"); - }); - }); - - describe("extended regex (-E/-r flag)", () => { - it("should support + quantifier with -E", async () => { - const env = new Bash({ - files: { "/test.txt": "aaa bbb ccc\n" }, - cwd: "/", - }); - // + means one or more (ERE syntax) - const result = await env.exec("sed -E 's/a+/X/' /test.txt"); - expect(result.stdout).toBe("X bbb ccc\n"); - }); - - it("should support ? quantifier with -E", async () => { - const env = new Bash({ - files: { "/test.txt": "color colour\n" }, - cwd: "/", - }); - // ? means zero or one (ERE syntax) - const result = await env.exec("sed -E 's/colou?r/COLOR/g' /test.txt"); - expect(result.stdout).toBe("COLOR COLOR\n"); - }); - - it("should support alternation | with -E", async () => { - const env = new Bash({ - files: { "/test.txt": "cat dog bird\n" }, - cwd: "/", - }); - // | means alternation (ERE syntax) - const result = await env.exec("sed -E 's/cat|dog/ANIMAL/g' /test.txt"); - expect(result.stdout).toBe("ANIMAL ANIMAL bird\n"); - }); - - it("should support grouping () with -E", async () => { - const env = new Bash({ - files: { "/test.txt": "hello world\n" }, - cwd: "/", - }); - // () for grouping and backreferences (ERE syntax) - const result = await env.exec( - "sed -E 's/(hello) (world)/\\2 \\1/' /test.txt", - ); - expect(result.stdout).toBe("world hello\n"); - }); - - it("should support -r as alias for -E", async () => { - const env = new Bash({ - files: { "/test.txt": "aaa bbb\n" }, - cwd: "/", - }); - // -r is GNU sed alias for -E - const result = await env.exec("sed -r 's/a+/X/' /test.txt"); - expect(result.stdout).toBe("X bbb\n"); - }); - - it("should support complex ERE patterns", async () => { - const env = new Bash({ - files: { - "/test.txt": - "error: file not found\nwarning: deprecated\ninfo: success\n", - }, - cwd: "/", - }); - // Complex pattern with alternation and grouping - const result = await env.exec( - "sed -E 's/^(error|warning): (.+)/[\\1] \\2/' /test.txt", - ); - expect(result.stdout).toBe( - "[error] file not found\n[warning] deprecated\ninfo: success\n", - ); - }); - - it("should support {n,m} quantifier with -E", async () => { - const env = new Bash({ - files: { "/test.txt": "a aa aaa aaaa\n" }, - cwd: "/", - }); - // {2,3} means 2 to 3 occurrences - const result = await env.exec("sed -E 's/a{2,3}/X/g' /test.txt"); - expect(result.stdout).toBe("a X X Xa\n"); - }); - - it("should treat + as literal without -E flag (BRE mode)", async () => { - const env = new Bash({ - files: { "/test.txt": "aaa bbb\na+ ccc\n" }, - cwd: "/", - }); - // In BRE mode (without -E), + is a literal character - const result = await env.exec("sed 's/a+/X/' /test.txt"); - // Only matches literal "a+", not one or more a's - expect(result.stdout).toBe("aaa bbb\nX ccc\n"); - }); - - it("should treat \\+ as quantifier in BRE mode", async () => { - const env = new Bash({ - files: { "/test.txt": "aaa bbb\n" }, - cwd: "/", - }); - // In BRE mode, \+ is one-or-more quantifier - const result = await env.exec("sed 's/a\\+/X/' /test.txt"); - expect(result.stdout).toBe("X bbb\n"); - }); - - it("should handle \\? as optional in BRE mode", async () => { - const env = new Bash({ - files: { "/test.txt": "ab\nb\n" }, - cwd: "/", - }); - // In BRE mode, \? is optional quantifier (0 or 1) - // a?b matches "ab" (1 a) or "b" (0 a's) - const result = await env.exec("sed 's/a\\?b/X/' /test.txt"); - expect(result.stdout).toBe("X\nX\n"); - }); - - it("should handle \\| as alternation in BRE mode", async () => { - const env = new Bash({ - files: { "/test.txt": "cat\ndog\nbird\n" }, - cwd: "/", - }); - // In BRE mode, \| is alternation - const result = await env.exec("sed 's/cat\\|dog/X/' /test.txt"); - expect(result.stdout).toBe("X\nX\nbird\n"); - }); - }); - - describe("Q command (quit without printing)", () => { - it("should quit without printing current line", async () => { - const env = new Bash({ - files: { "/test.txt": "line1\nline2\nline3\n" }, - cwd: "/", - }); - const result = await env.exec("sed '2Q' /test.txt"); - expect(result.stdout).toBe("line1\n"); - }); - - it("should differ from q which prints before quitting", async () => { - const env = new Bash({ - files: { "/test.txt": "line1\nline2\nline3\n" }, - cwd: "/", - }); - // q prints the line, then quits - const resultQ = await env.exec("sed '2q' /test.txt"); - expect(resultQ.stdout).toBe("line1\nline2\n"); - // Q quits without printing - const resultQSilent = await env.exec("sed '2Q' /test.txt"); - expect(resultQSilent.stdout).toBe("line1\n"); - }); - }); - - describe("l command (list with escapes)", () => { - it("should escape special characters", async () => { - const env = new Bash({ - files: { "/test.txt": "hello\tworld\n" }, - cwd: "/", - }); - const result = await env.exec("sed -n 'l' /test.txt"); - expect(result.stdout).toBe("hello\\tworld$\n"); - }); - - it("should escape backslash", async () => { - const env = new Bash({ - files: { "/test.txt": "a\\b\n" }, - cwd: "/", - }); - const result = await env.exec("sed -n 'l' /test.txt"); - expect(result.stdout).toBe("a\\\\b$\n"); - }); - - it("should mark end of line with $", async () => { - const env = new Bash({ - files: { "/test.txt": "test\n" }, - cwd: "/", - }); - const result = await env.exec("sed -n 'l' /test.txt"); - expect(result.stdout).toBe("test$\n"); - }); - }); - - describe("z command (zap pattern space)", () => { - it("should empty the pattern space", async () => { - const env = new Bash({ - files: { "/test.txt": "hello\nworld\n" }, - cwd: "/", - }); - const result = await env.exec("sed 'z' /test.txt"); - expect(result.stdout).toBe("\n\n"); - }); - - it("should work with address", async () => { - const env = new Bash({ - files: { "/test.txt": "line1\nline2\nline3\n" }, - cwd: "/", - }); - const result = await env.exec("sed '2z' /test.txt"); - expect(result.stdout).toBe("line1\n\nline3\n"); - }); - }); - - describe("pattern range state tracking", () => { - it("should track range state across lines", async () => { - const env = new Bash({ - files: { "/test.txt": "a\nSTART\nb\nc\nEND\nd\n" }, - cwd: "/", - }); - const result = await env.exec("sed '/START/,/END/d' /test.txt"); - expect(result.stdout).toBe("a\nd\n"); - }); - - it("should handle multiple ranges in same file", async () => { - const env = new Bash({ - files: { "/test.txt": "a\nSTART\nb\nEND\nc\nSTART\nd\nEND\ne\n" }, - cwd: "/", - }); - const result = await env.exec("sed '/START/,/END/d' /test.txt"); - expect(result.stdout).toBe("a\nc\ne\n"); - }); - - it("should handle unclosed range at EOF", async () => { - const env = new Bash({ - files: { "/test.txt": "a\nSTART\nb\nc\n" }, - cwd: "/", - }); - // Range starts at START, never finds END, so deletes to EOF - const result = await env.exec("sed '/START/,/END/d' /test.txt"); - expect(result.stdout).toBe("a\n"); - }); - }); - - describe("substitution tracking for t/T commands", () => { - it("should track substitution even when pattern matches same text", async () => { - const env = new Bash({ - files: { "/test.txt": "a\nb\n" }, - cwd: "/", - }); - // s/./&/ replaces char with itself, but substitution still happened - const result = await env.exec( - "sed 's/./&/;t skip;s/$/X/;:skip' /test.txt", - ); - // substitution happened, so branch skips adding X - expect(result.stdout).toBe("a\nb\n"); - }); - - it("should branch with T when no substitution made", async () => { - const env = new Bash({ - files: { "/test.txt": "a\nb\n" }, - cwd: "/", - }); - // s/x/y/ doesn't match, so T branches - const result = await env.exec( - "sed 's/x/y/;T add;b end;:add;s/$/X/;:end' /test.txt", - ); - expect(result.stdout).toBe("aX\nbX\n"); - }); - - it("should not branch with T when substitution made", async () => { - const env = new Bash({ - files: { "/test.txt": "ax\nbx\n" }, - cwd: "/", - }); - // s/x/y/ matches, so T doesn't branch - const result = await env.exec( - "sed 's/x/y/;T add;b end;:add;s/$/X/;:end' /test.txt", - ); - expect(result.stdout).toBe("ay\nby\n"); - }); - }); -}); diff --git a/src/commands/sed/sed.ts b/src/commands/sed/sed.ts deleted file mode 100644 index bae35cc2..00000000 --- a/src/commands/sed/sed.ts +++ /dev/null @@ -1,606 +0,0 @@ -import { ExecutionLimitError } from "../../interpreter/errors.js"; -import type { ExecutionLimits } from "../../limits.js"; -import type { - Command, - CommandContext, - ExecResult, - FeatureCoverageWriter, -} from "../../types.js"; -import { hasHelpFlag, showHelp, unknownOption } from "../help.js"; -import { - createInitialState, - type ExecuteContext, - executeCommands, -} from "./executor.js"; -import { parseMultipleScripts } from "./parser.js"; -import type { - RangeState, - SedCommand, - SedExecutionLimits, - SedState, -} from "./types.js"; - -const sedHelp = { - name: "sed", - summary: "stream editor for filtering and transforming text", - usage: "sed [OPTION]... {script} [input-file]...", - options: [ - "-n, --quiet, --silent suppress automatic printing of pattern space", - "-e script add the script to commands to be executed", - "-f script-file read script from file", - "-i, --in-place edit files in place", - "-E, -r, --regexp-extended use extended regular expressions", - " --help display this help and exit", - ], - description: `Commands: - s/regexp/replacement/[flags] substitute - d delete pattern space - p print pattern space - a\\ text append text after line - i\\ text insert text before line - c\\ text change (replace) line with text - h copy pattern space to hold space - H append pattern space to hold space - g copy hold space to pattern space - G append hold space to pattern space - x exchange pattern and hold spaces - n read next line into pattern space - N append next line to pattern space - y/source/dest/ transliterate characters - = print line number - l list pattern space (escape special chars) - b [label] branch to label - t [label] branch on substitution - T [label] branch if no substitution - :label define label - q quit - Q quit without printing - -Addresses: - N line number - $ last line - /regexp/ lines matching regexp - N,M range from line N to M - first~step every step-th line starting at first`, -}; - -interface ProcessContentOptions { - limits?: Required; - filename?: string; - fs?: CommandContext["fs"]; - cwd?: string; - coverage?: FeatureCoverageWriter; -} - -async function processContent( - content: string, - commands: SedCommand[], - silent: boolean, - options: ProcessContentOptions = {}, -): Promise<{ output: string; exitCode?: number; errorMessage?: string }> { - const { limits, filename, fs, cwd, coverage } = options; - - // Track if input ended with newline - needed for preserving trailing newline behavior - const inputEndsWithNewline = content.endsWith("\n"); - - const lines = content.split("\n"); - if (lines.length > 0 && lines[lines.length - 1] === "") { - lines.pop(); - } - - const totalLines = lines.length; - let output = ""; - let exitCode: number | undefined; - // Track if the last output came from auto-print (to determine trailing newline behavior) - // Only auto-print should have its trailing newline stripped when input has no trailing newline - let lastOutputWasAutoPrint = false; - - // Extract max output size from limits - const maxOutputSize = limits?.maxStringLength ?? 0; - const appendOutput = (text: string): void => { - output += text; - if (maxOutputSize > 0 && output.length > maxOutputSize) { - throw new ExecutionLimitError( - `sed: output size limit exceeded (${maxOutputSize} bytes)`, - "string_length", - ); - } - }; - - // Persistent state across all lines - let holdSpace = ""; - let lastPattern: string | undefined; - const rangeStates = new Map(); - - // For file I/O: track line positions for R command, accumulate writes - const fileLineCache = new Map(); - const fileLinePositions = new Map(); - const fileWrites = new Map(); - - // Convert to SedExecutionLimits format - const sedLimits: SedExecutionLimits | undefined = limits - ? { maxIterations: limits.maxSedIterations } - : undefined; - - for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { - const state: SedState = { - ...createInitialState(totalLines, filename, rangeStates), - patternSpace: lines[lineIndex], - holdSpace: holdSpace, - lastPattern: lastPattern, - lineNumber: lineIndex + 1, - totalLines, - substitutionMade: false, // Reset for each new line (T command behavior) - coverage, - }; - - // Create execution context for N command - const ctx: ExecuteContext = { - lines, - currentLineIndex: lineIndex, - }; - - // Execute commands with support for D command cycle restart - let cycleIterations = 0; - const maxCycleIterations = 10000; - // Reset lines consumed counter for this cycle - state.linesConsumedInCycle = 0; - do { - cycleIterations++; - if (cycleIterations > maxCycleIterations) { - // Prevent infinite loops - break; - } - - state.restartCycle = false; - state.pendingFileReads = []; - state.pendingFileWrites = []; - - // Execute commands - lines consumed are tracked in state.linesConsumedInCycle - executeCommands(commands, state, ctx, sedLimits); - - // Process pending file reads - if (fs && cwd) { - for (const read of state.pendingFileReads) { - const filePath = fs.resolvePath(cwd, read.filename); - try { - if (read.wholeFile) { - // r command - read entire file, append after current line - const fileContent = await fs.readFile(filePath); - state.appendBuffer.push(fileContent.replace(/\n$/, "")); - } else { - // R command - read one line from file - if (!fileLineCache.has(filePath)) { - const fileContent = await fs.readFile(filePath); - fileLineCache.set(filePath, fileContent.split("\n")); - fileLinePositions.set(filePath, 0); - } - const fileLines = fileLineCache.get(filePath); - const pos = fileLinePositions.get(filePath); - if (fileLines && pos !== undefined && pos < fileLines.length) { - state.appendBuffer.push(fileLines[pos]); - fileLinePositions.set(filePath, pos + 1); - } - } - } catch { - // File not found - silently ignore (matches GNU sed behavior) - } - } - - // Accumulate file writes - for (const write of state.pendingFileWrites) { - const filePath = fs.resolvePath(cwd, write.filename); - const existing = fileWrites.get(filePath) || ""; - fileWrites.set(filePath, existing + write.content); - } - } - } while ( - state.restartCycle && - !state.deleted && - !state.quit && - !state.quitSilent - ); - - // Update main line index with total lines consumed during this cycle - lineIndex += state.linesConsumedInCycle; - - // Preserve state for next line - holdSpace = state.holdSpace; - lastPattern = state.lastPattern; - - // Output from n command (respects silent mode) - must come before other outputs - if (!silent) { - for (const ln of state.nCommandOutput) { - appendOutput(`${ln}\n`); - } - } - - // Output line numbers from = command (and l, F commands, p command) - const hadLineNumberOutput = state.lineNumberOutput.length > 0; - for (const ln of state.lineNumberOutput) { - appendOutput(`${ln}\n`); - } - - // Handle insert commands (marked with __INSERT__ prefix) - const inserts: string[] = []; - const appends: string[] = []; - for (const item of state.appendBuffer) { - if (item.startsWith("__INSERT__")) { - inserts.push(item.slice(10)); - } else { - appends.push(item); - } - } - - // Output inserts before the line - for (const text of inserts) { - appendOutput(`${text}\n`); - } - - // Handle output - Q (quitSilent) suppresses the final print - // Track whether output came from auto-print or explicit print (for trailing newline handling) - let hadPatternSpaceOutput = false; - if (!state.deleted && !state.quitSilent) { - if (silent) { - if (state.printed) { - appendOutput(`${state.patternSpace}\n`); - hadPatternSpaceOutput = true; // Explicit print in silent mode - } - } else { - appendOutput(`${state.patternSpace}\n`); - hadPatternSpaceOutput = true; // Auto-print in non-silent mode - } - } else if (state.changedText !== undefined) { - // c command: output changed text in place of pattern space - appendOutput(`${state.changedText}\n`); - hadPatternSpaceOutput = true; - } - - // Output appends after the line - for (const text of appends) { - appendOutput(`${text}\n`); - } - - // Track if this line produced output that should have trailing newline stripped - // This includes: explicit print (lineNumberOutput from p command or /p flag), - // pattern space output (auto-print or explicit via state.printed), but NOT appends - const hadOutput = hadLineNumberOutput || hadPatternSpaceOutput; - lastOutputWasAutoPrint = hadOutput && appends.length === 0; - - // Check for quit commands or errors - if (state.quit || state.quitSilent) { - if (state.exitCode !== undefined) { - exitCode = state.exitCode; - } - if (state.errorMessage) { - // Early exit on error - return { - output: "", - exitCode: exitCode || 1, - errorMessage: state.errorMessage, - }; - } - break; - } - } - - // Flush all accumulated file writes at end - if (fs && cwd) { - for (const [filePath, fileContent] of fileWrites) { - try { - await fs.writeFile(filePath, fileContent); - } catch { - // Write error - silently ignore for now - } - } - } - - // If input didn't end with newline, strip trailing newline from output - // BUT only if the last output was from auto-print (non-silent mode, no explicit prints/appends) - if ( - !inputEndsWithNewline && - lastOutputWasAutoPrint && - output.endsWith("\n") - ) { - output = output.slice(0, -1); - } - - return { output, exitCode }; -} - -export const sedCommand: Command = { - name: "sed", - async execute(args: string[], ctx: CommandContext): Promise { - if (hasHelpFlag(args)) { - return showHelp(sedHelp); - } - - const scripts: string[] = []; - const scriptFiles: string[] = []; - let silent = false; - let inPlace = false; - let extendedRegex = false; - const files: string[] = []; - - // Parse arguments - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === "-n" || arg === "--quiet" || arg === "--silent") { - silent = true; - } else if (arg === "-i" || arg === "--in-place") { - inPlace = true; - } else if (arg.startsWith("-i")) { - inPlace = true; - } else if (arg === "-E" || arg === "-r" || arg === "--regexp-extended") { - extendedRegex = true; - } else if (arg === "-e") { - if (i + 1 < args.length) { - scripts.push(args[++i]); - } - } else if (arg === "-f") { - if (i + 1 < args.length) { - scriptFiles.push(args[++i]); - } - } else if (arg.startsWith("--")) { - return unknownOption("sed", arg); - } else if (arg === "-") { - // "-" is stdin marker, treat as a file - files.push(arg); - } else if (arg.startsWith("-") && arg.length > 1) { - for (const c of arg.slice(1)) { - if ( - c !== "n" && - c !== "e" && - c !== "f" && - c !== "i" && - c !== "E" && - c !== "r" - ) { - return unknownOption("sed", `-${c}`); - } - } - if (arg.includes("n")) silent = true; - if (arg.includes("i")) inPlace = true; - if (arg.includes("E") || arg.includes("r")) extendedRegex = true; - if (arg.includes("e") && !arg.includes("n") && !arg.includes("i")) { - if (i + 1 < args.length) { - scripts.push(args[++i]); - } - } - if (arg.includes("f") && !arg.includes("e")) { - if (i + 1 < args.length) { - scriptFiles.push(args[++i]); - } - } - } else if ( - !arg.startsWith("-") && - scripts.length === 0 && - scriptFiles.length === 0 - ) { - scripts.push(arg); - } else if (!arg.startsWith("-")) { - files.push(arg); - } - } - - // Read scripts from -f files - for (const scriptFile of scriptFiles) { - const scriptPath = ctx.fs.resolvePath(ctx.cwd, scriptFile); - try { - const scriptContent = await ctx.fs.readFile(scriptPath); - // Split by newlines and add each line as a separate script - for (const line of scriptContent.split("\n")) { - const trimmed = line.trim(); - if (trimmed && !trimmed.startsWith("#")) { - scripts.push(trimmed); - } - } - } catch { - return { - stdout: "", - stderr: `sed: couldn't open file ${scriptFile}: No such file or directory\n`, - exitCode: 1, - }; - } - } - - if (scripts.length === 0) { - return { - stdout: "", - stderr: "sed: no script specified\n", - exitCode: 1, - }; - } - - // Parse all scripts - const { commands, error, silentMode } = parseMultipleScripts( - scripts, - extendedRegex, - ); - if (error) { - return { - stdout: "", - stderr: `sed: ${error}\n`, - exitCode: 1, - }; - } - - // Note: empty script (no commands) is valid in sed - just passes through input - - // Enable silent mode from -n flag or #n comment - const effectiveSilent = !!(silent || silentMode); - - // Handle in-place editing - check this first because -i requires files - if (inPlace) { - // -i requires at least one file argument - if (files.length === 0) { - return { - stdout: "", - stderr: "sed: -i requires at least one file argument\n", - exitCode: 1, - }; - } - for (const file of files) { - // Skip "-" for in-place editing (can't edit stdin in-place) - if (file === "-") { - continue; - } - const filePath = ctx.fs.resolvePath(ctx.cwd, file); - try { - const fileContent = await ctx.fs.readFile(filePath); - const result = await processContent( - fileContent, - commands, - effectiveSilent, - { - limits: ctx.limits, - filename: file, - fs: ctx.fs, - cwd: ctx.cwd, - coverage: ctx.coverage, - }, - ); - if (result.errorMessage) { - return { - stdout: "", - stderr: `${result.errorMessage}\n`, - exitCode: result.exitCode ?? 1, - }; - } - await ctx.fs.writeFile(filePath, result.output); - } catch (e) { - if (e instanceof ExecutionLimitError) { - return { - stdout: "", - stderr: `sed: ${e.message}\n`, - exitCode: ExecutionLimitError.EXIT_CODE, - }; - } - return { - stdout: "", - stderr: `sed: ${file}: No such file or directory\n`, - exitCode: 1, - }; - } - } - return { stdout: "", stderr: "", exitCode: 0 }; - } - - let content = ""; - - // Read from files or stdin - if (files.length === 0) { - content = ctx.stdin; - try { - const result = await processContent( - content, - commands, - effectiveSilent, - { - limits: ctx.limits, - fs: ctx.fs, - cwd: ctx.cwd, - coverage: ctx.coverage, - }, - ); - return { - stdout: result.output, - stderr: result.errorMessage ? `${result.errorMessage}\n` : "", - exitCode: result.exitCode ?? 0, - }; - } catch (e) { - if (e instanceof ExecutionLimitError) { - return { - stdout: "", - stderr: `sed: ${e.message}\n`, - exitCode: ExecutionLimitError.EXIT_CODE, - }; - } - throw e; - } - } - - // Read all files and process - // Support "-" as stdin marker - let stdinConsumed = false; - for (const file of files) { - let fileContent: string; - if (file === "-") { - // "-" means read from stdin (can only be consumed once) - if (stdinConsumed) { - fileContent = ""; - } else { - fileContent = ctx.stdin; - stdinConsumed = true; - } - } else { - const filePath = ctx.fs.resolvePath(ctx.cwd, file); - try { - fileContent = await ctx.fs.readFile(filePath); - } catch (e) { - if (e instanceof ExecutionLimitError) { - return { - stdout: "", - stderr: `sed: ${e.message}\n`, - exitCode: ExecutionLimitError.EXIT_CODE, - }; - } - return { - stdout: "", - stderr: `sed: ${file}: No such file or directory\n`, - exitCode: 1, - }; - } - } - // When concatenating files, ensure previous content ends with newline - // (unless this is the first file, previous content is empty, or new content is empty) - if ( - content.length > 0 && - fileContent.length > 0 && - !content.endsWith("\n") - ) { - content += "\n"; - } - content += fileContent; - } - - try { - const result = await processContent(content, commands, effectiveSilent, { - limits: ctx.limits, - filename: files.length === 1 ? files[0] : undefined, - fs: ctx.fs, - cwd: ctx.cwd, - coverage: ctx.coverage, - }); - return { - stdout: result.output, - stderr: result.errorMessage ? `${result.errorMessage}\n` : "", - exitCode: result.exitCode ?? 0, - }; - } catch (e) { - if (e instanceof ExecutionLimitError) { - return { - stdout: "", - stderr: `sed: ${e.message}\n`, - exitCode: ExecutionLimitError.EXIT_CODE, - }; - } - throw e; - } - }, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "sed", - flags: [ - { flag: "-n", type: "boolean" }, - { flag: "-i", type: "boolean" }, - { flag: "-E", type: "boolean" }, - { flag: "-r", type: "boolean" }, - { flag: "-e", type: "value", valueHint: "string" }, - ], - stdinType: "text", - needsArgs: true, -}; diff --git a/src/commands/sed/types.ts b/src/commands/sed/types.ts deleted file mode 100644 index fda49681..00000000 --- a/src/commands/sed/types.ts +++ /dev/null @@ -1,334 +0,0 @@ -// Types for sed command implementation - -import type { FeatureCoverageWriter } from "../../types.js"; - -// Address with stepping support (e.g., 0~2 for every other line) -export interface StepAddress { - first: number; - step: number; -} - -// Relative offset address (GNU extension: ,+N) -export interface RelativeOffset { - offset: number; -} - -export type SedAddress = - | number - | "$" - | { pattern: string } - | StepAddress - | RelativeOffset; - -export interface AddressRange { - start?: SedAddress; - end?: SedAddress; - negated?: boolean; // ! modifier - negate the address match -} - -export type SedCommandType = - | "substitute" - | "print" - | "printFirstLine" // P - print up to first newline - | "delete" - | "deleteFirstLine" // D - delete up to first newline - | "append" - | "insert" - | "change" - | "hold" - | "holdAppend" - | "get" - | "getAppend" - | "exchange" - | "next" - | "nextAppend" - | "quit" - | "quitSilent" // Q - quit without printing - | "transliterate" - | "lineNumber" - | "branch" - | "branchOnSubst" - | "branchOnNoSubst" // T - branch if no substitution made - | "label" - | "zap" // z - zap/empty pattern space - | "group" // { } - grouped commands - | "list" // l - list pattern space with escapes - | "printFilename" // F - print filename - | "version" // v - version check - | "readFile" // r - read file - | "readFileLine" // R - read line from file - | "writeFile" // w - write to file - | "writeFirstLine" // W - write first line to file - | "execute"; // e - execute command - -export interface SubstituteCommand { - type: "substitute"; - address?: AddressRange; - pattern: string; - replacement: string; - global: boolean; - ignoreCase: boolean; - printOnMatch: boolean; - nthOccurrence?: number; // Replace only Nth occurrence (1-based) - extendedRegex?: boolean; // Use extended regex -} - -export interface PrintCommand { - type: "print"; - address?: AddressRange; -} - -export interface DeleteCommand { - type: "delete"; - address?: AddressRange; -} - -export interface AppendCommand { - type: "append"; - address?: AddressRange; - text: string; -} - -export interface InsertCommand { - type: "insert"; - address?: AddressRange; - text: string; -} - -export interface ChangeCommand { - type: "change"; - address?: AddressRange; - text: string; -} - -// Hold space commands -export interface HoldCommand { - type: "hold"; // h - copy pattern space to hold space - address?: AddressRange; -} - -export interface HoldAppendCommand { - type: "holdAppend"; // H - append pattern space to hold space - address?: AddressRange; -} - -export interface GetCommand { - type: "get"; // g - copy hold space to pattern space - address?: AddressRange; -} - -export interface GetAppendCommand { - type: "getAppend"; // G - append hold space to pattern space - address?: AddressRange; -} - -export interface ExchangeCommand { - type: "exchange"; // x - exchange pattern and hold spaces - address?: AddressRange; -} - -export interface NextCommand { - type: "next"; // n - print pattern space, read next line - address?: AddressRange; -} - -export interface QuitCommand { - type: "quit"; // q - quit - address?: AddressRange; - exitCode?: number; -} - -export interface QuitSilentCommand { - type: "quitSilent"; // Q - quit without printing - address?: AddressRange; - exitCode?: number; -} - -export interface NextAppendCommand { - type: "nextAppend"; // N - append next line to pattern space - address?: AddressRange; -} - -export interface TransliterateCommand { - type: "transliterate"; // y/src/dst/ - transliterate characters - address?: AddressRange; - source: string; - dest: string; -} - -export interface LineNumberCommand { - type: "lineNumber"; // = - print line number - address?: AddressRange; -} - -export interface BranchCommand { - type: "branch"; // b [label] - branch to label (or end) - address?: AddressRange; - label?: string; -} - -export interface BranchOnSubstCommand { - type: "branchOnSubst"; // t [label] - branch if substitution made - address?: AddressRange; - label?: string; -} - -export interface LabelCommand { - type: "label"; // :label - define a label - name: string; -} - -export interface BranchOnNoSubstCommand { - type: "branchOnNoSubst"; // T [label] - branch if NO substitution made - address?: AddressRange; - label?: string; -} - -export interface PrintFirstLineCommand { - type: "printFirstLine"; // P - print up to first newline - address?: AddressRange; -} - -export interface DeleteFirstLineCommand { - type: "deleteFirstLine"; // D - delete up to first newline, restart cycle - address?: AddressRange; -} - -export interface ZapCommand { - type: "zap"; // z - empty/zap pattern space - address?: AddressRange; -} - -export interface GroupCommand { - type: "group"; // { commands } - grouped commands - address?: AddressRange; - commands: SedCommand[]; -} - -export interface ListCommand { - type: "list"; // l - list pattern space with escapes - address?: AddressRange; -} - -export interface PrintFilenameCommand { - type: "printFilename"; // F - print current filename - address?: AddressRange; -} - -export interface VersionCommand { - type: "version"; // v - check version - address?: AddressRange; - minVersion?: string; -} - -export interface ReadFileCommand { - type: "readFile"; // r - read file contents and append - address?: AddressRange; - filename: string; -} - -export interface ReadFileLineCommand { - type: "readFileLine"; // R - read single line from file - address?: AddressRange; - filename: string; -} - -export interface WriteFileCommand { - type: "writeFile"; // w - write pattern space to file - address?: AddressRange; - filename: string; -} - -export interface WriteFirstLineCommand { - type: "writeFirstLine"; // W - write first line to file - address?: AddressRange; - filename: string; -} - -export interface ExecuteCommand { - type: "execute"; // e - execute shell command - address?: AddressRange; - command?: string; // if undefined, execute pattern space -} - -export type SedCommand = - | SubstituteCommand - | PrintCommand - | PrintFirstLineCommand - | DeleteCommand - | DeleteFirstLineCommand - | AppendCommand - | InsertCommand - | ChangeCommand - | HoldCommand - | HoldAppendCommand - | GetCommand - | GetAppendCommand - | ExchangeCommand - | NextCommand - | QuitCommand - | QuitSilentCommand - | NextAppendCommand - | TransliterateCommand - | LineNumberCommand - | BranchCommand - | BranchOnSubstCommand - | BranchOnNoSubstCommand - | LabelCommand - | ZapCommand - | GroupCommand - | ListCommand - | PrintFilenameCommand - | VersionCommand - | ReadFileCommand - | ReadFileLineCommand - | WriteFileCommand - | WriteFirstLineCommand - | ExecuteCommand; - -export interface SedState { - patternSpace: string; - holdSpace: string; - lineNumber: number; - totalLines: number; - deleted: boolean; - printed: boolean; - quit: boolean; - quitSilent: boolean; // For Q command: quit without printing - exitCode?: number; // Exit code from q/Q command - errorMessage?: string; // Error message (for v command, etc.) - appendBuffer: string[]; // Lines to append after current line - changedText?: string; // For c command: text to output in place of pattern space - substitutionMade: boolean; // Track if substitution was made (for 't' command) - lineNumberOutput: string[]; // Output from '=' command - nCommandOutput: string[]; // Output from 'n' command (respects silent mode) - restartCycle: boolean; // For D command: restart cycle without reading new line - inDRestartedCycle: boolean; // Track if we're in a cycle restarted by D - currentFilename?: string; // For F command - // For file I/O commands (deferred execution) - pendingFileReads: Array<{ filename: string; wholeFile: boolean }>; - pendingFileWrites: Array<{ filename: string; content: string }>; - // For e command (deferred execution) - pendingExecute?: { command: string; replacePattern: boolean }; - // Range state tracking for pattern ranges like /start/,/end/ - rangeStates: Map; - // Last used regex pattern for empty regex reuse (//) - lastPattern?: string; - // For cross-group branching: when a branch inside a group can't find its label - branchRequest?: string; - // Track total lines consumed during this execution cycle (for N command) - linesConsumedInCycle: number; - // Feature coverage writer for fuzzing instrumentation - coverage?: FeatureCoverageWriter; -} - -// Range state tracking for pattern ranges like /start/,/end/ -export interface RangeState { - active: boolean; - startLine?: number; - completed?: boolean; // For numeric start ranges: once ended, don't reactivate -} - -export interface SedExecutionLimits { - maxIterations: number; // Max branch iterations per line (default: 10000) -} diff --git a/src/commands/seq/seq.test.ts b/src/commands/seq/seq.test.ts deleted file mode 100644 index 2a97a223..00000000 --- a/src/commands/seq/seq.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("seq", () => { - describe("basic sequences", () => { - it("should print numbers from 1 to N", async () => { - const env = new Bash(); - const result = await env.exec("seq 5"); - expect(result.stdout).toBe("1\n2\n3\n4\n5\n"); - expect(result.exitCode).toBe(0); - }); - - it("should print numbers from FIRST to LAST", async () => { - const env = new Bash(); - const result = await env.exec("seq 3 7"); - expect(result.stdout).toBe("3\n4\n5\n6\n7\n"); - expect(result.exitCode).toBe(0); - }); - - it("should print numbers with INCREMENT", async () => { - const env = new Bash(); - const result = await env.exec("seq 1 2 10"); - expect(result.stdout).toBe("1\n3\n5\n7\n9\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle single number 1", async () => { - const env = new Bash(); - const result = await env.exec("seq 1"); - expect(result.stdout).toBe("1\n"); - }); - - it("should handle start greater than end (empty output)", async () => { - const env = new Bash(); - const result = await env.exec("seq 5 1"); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - }); - }); - - describe("negative numbers and decrements", () => { - it("should handle negative increment (decrementing)", async () => { - const env = new Bash(); - const result = await env.exec("seq 5 -1 1"); - expect(result.stdout).toBe("5\n4\n3\n2\n1\n"); - }); - - it("should handle negative start", async () => { - const env = new Bash(); - const result = await env.exec("seq -3 3"); - expect(result.stdout).toBe("-3\n-2\n-1\n0\n1\n2\n3\n"); - }); - - it("should handle negative end", async () => { - const env = new Bash(); - const result = await env.exec("seq 2 -1 -2"); - expect(result.stdout).toBe("2\n1\n0\n-1\n-2\n"); - }); - - it("should handle all negative range", async () => { - const env = new Bash(); - const result = await env.exec("seq -5 -1 -10"); - expect(result.stdout).toBe("-5\n-6\n-7\n-8\n-9\n-10\n"); - }); - }); - - describe("floating point numbers", () => { - it("should handle floating point increment", async () => { - const env = new Bash(); - const result = await env.exec("seq 1 0.5 3"); - expect(result.stdout).toBe("1.0\n1.5\n2.0\n2.5\n3.0\n"); - }); - - it("should handle floating point start and end", async () => { - const env = new Bash(); - const result = await env.exec("seq 1.5 3.5"); - expect(result.stdout).toBe("1.5\n2.5\n3.5\n"); - }); - }); - - describe("separator option", () => { - it("should use custom separator with -s", async () => { - const env = new Bash(); - const result = await env.exec("seq -s ' ' 5"); - expect(result.stdout).toBe("1 2 3 4 5\n"); - }); - - it("should use comma separator", async () => { - const env = new Bash(); - const result = await env.exec("seq -s ',' 3"); - expect(result.stdout).toBe("1,2,3\n"); - }); - - it("should handle empty separator", async () => { - const env = new Bash(); - const result = await env.exec("seq -s '' 3"); - expect(result.stdout).toBe("123\n"); - }); - }); - - describe("width option", () => { - it("should pad with zeros using -w", async () => { - const env = new Bash(); - const result = await env.exec("seq -w 8 12"); - expect(result.stdout).toBe("08\n09\n10\n11\n12\n"); - }); - - it("should pad larger range", async () => { - const env = new Bash(); - const result = await env.exec("seq -w 1 100"); - const lines = result.stdout.trim().split("\n"); - expect(lines[0]).toBe("001"); - expect(lines[9]).toBe("010"); - expect(lines[99]).toBe("100"); - }); - }); - - describe("error cases", () => { - it("should error on missing operand", async () => { - const env = new Bash(); - const result = await env.exec("seq"); - expect(result.stderr).toContain("missing operand"); - expect(result.exitCode).toBe(1); - }); - - it("should error on invalid number", async () => { - const env = new Bash(); - const result = await env.exec("seq abc"); - expect(result.stderr).toContain("invalid"); - expect(result.exitCode).toBe(1); - }); - - it("should error on zero increment", async () => { - const env = new Bash(); - const result = await env.exec("seq 1 0 5"); - expect(result.stderr).toContain("Zero increment"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("use in pipelines", () => { - it("should work with while read", async () => { - const env = new Bash(); - const result = await env.exec(` - seq 3 | while read n; do - echo "line $n" - done - `); - expect(result.stdout).toBe("line 1\nline 2\nline 3\n"); - }); - - it("should work with head", async () => { - const env = new Bash(); - const result = await env.exec("seq 10 | head -3"); - expect(result.stdout).toBe("1\n2\n3\n"); - }); - - it("should work with tail", async () => { - const env = new Bash(); - const result = await env.exec("seq 10 | tail -3"); - expect(result.stdout).toBe("8\n9\n10\n"); - }); - - it("should work with wc -l", async () => { - const env = new Bash(); - const result = await env.exec("seq 5 | wc -l"); - expect(result.stdout.trim()).toBe("5"); - }); - }); -}); diff --git a/src/commands/seq/seq.ts b/src/commands/seq/seq.ts deleted file mode 100644 index 2968edb8..00000000 --- a/src/commands/seq/seq.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { Command, ExecResult } from "../../types.js"; - -/** - * seq - print a sequence of numbers - * - * Usage: - * seq LAST - print numbers from 1 to LAST - * seq FIRST LAST - print numbers from FIRST to LAST - * seq FIRST INCR LAST - print numbers from FIRST to LAST by INCR - * - * Options: - * -s STRING use STRING to separate numbers (default: newline) - * -w equalize width by padding with leading zeros - */ -export const seqCommand: Command = { - name: "seq", - - async execute(args: string[]): Promise { - let separator = "\n"; - let equalizeWidth = false; - const nums: string[] = []; - - // Parse arguments - let i = 0; - while (i < args.length) { - const arg = args[i]; - - if (arg === "-s" && i + 1 < args.length) { - separator = args[i + 1]; - i += 2; - continue; - } - - if (arg === "-w") { - equalizeWidth = true; - i++; - continue; - } - - if (arg === "--") { - i++; - break; - } - - if (arg.startsWith("-") && arg !== "-") { - // Check for combined flags or -sSTRING - if (arg.startsWith("-s") && arg.length > 2) { - separator = arg.slice(2); - i++; - continue; - } - if (arg === "-ws" || arg === "-sw") { - equalizeWidth = true; - if (i + 1 < args.length) { - separator = args[i + 1]; - i += 2; - continue; - } - } - // Unknown option - treat as number (might be negative) - } - - nums.push(arg); - i++; - } - - // Collect remaining args as numbers - while (i < args.length) { - nums.push(args[i]); - i++; - } - - if (nums.length === 0) { - return { - stdout: "", - stderr: "seq: missing operand\n", - exitCode: 1, - }; - } - - let first = 1; - let increment = 1; - let last: number; - - if (nums.length === 1) { - last = parseFloat(nums[0]); - } else if (nums.length === 2) { - first = parseFloat(nums[0]); - last = parseFloat(nums[1]); - } else { - first = parseFloat(nums[0]); - increment = parseFloat(nums[1]); - last = parseFloat(nums[2]); - } - - // Validate numbers - if (Number.isNaN(first) || Number.isNaN(increment) || Number.isNaN(last)) { - const invalid = nums.find((n) => Number.isNaN(parseFloat(n))); - return { - stdout: "", - stderr: `seq: invalid floating point argument: '${invalid}'\n`, - exitCode: 1, - }; - } - - if (increment === 0) { - return { - stdout: "", - stderr: "seq: invalid Zero increment value: '0'\n", - exitCode: 1, - }; - } - - // Generate sequence - const results: string[] = []; - - // Determine precision for floating point - const getPrecision = (n: number): number => { - const str = String(n); - const dotIndex = str.indexOf("."); - return dotIndex === -1 ? 0 : str.length - dotIndex - 1; - }; - - const precision = Math.max( - getPrecision(first), - getPrecision(increment), - getPrecision(last), - ); - - // Limit iterations to prevent infinite loops - const maxIterations = 100000; - let iterations = 0; - - if (increment > 0) { - for (let n = first; n <= last + 1e-10; n += increment) { - if (iterations++ > maxIterations) break; - results.push( - precision > 0 ? n.toFixed(precision) : String(Math.round(n)), - ); - } - } else { - for (let n = first; n >= last - 1e-10; n += increment) { - if (iterations++ > maxIterations) break; - results.push( - precision > 0 ? n.toFixed(precision) : String(Math.round(n)), - ); - } - } - - // Equalize width if requested - if (equalizeWidth && results.length > 0) { - const maxLen = Math.max(...results.map((r) => r.replace("-", "").length)); - for (let j = 0; j < results.length; j++) { - const isNegative = results[j].startsWith("-"); - const num = isNegative ? results[j].slice(1) : results[j]; - const padded = num.padStart(maxLen, "0"); - results[j] = isNegative ? `-${padded}` : padded; - } - } - - const output = results.join(separator); - return { - stdout: output ? `${output}\n` : "", - stderr: "", - exitCode: 0, - }; - }, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "seq", - flags: [ - { flag: "-s", type: "value", valueHint: "string" }, - { flag: "-w", type: "boolean" }, - ], - needsArgs: true, -}; diff --git a/src/commands/sleep/sleep.test.ts b/src/commands/sleep/sleep.test.ts deleted file mode 100644 index 59edc106..00000000 --- a/src/commands/sleep/sleep.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("sleep command", () => { - describe("basic functionality", () => { - it("should sleep for specified seconds", async () => { - let sleptMs = 0; - const env = new Bash({ - sleep: async (ms) => { - sleptMs = ms; - }, - }); - - const result = await env.exec("sleep 2"); - expect(result.exitCode).toBe(0); - expect(sleptMs).toBe(2000); - }); - - it("should handle decimal seconds", async () => { - let sleptMs = 0; - const env = new Bash({ - sleep: async (ms) => { - sleptMs = ms; - }, - }); - - const result = await env.exec("sleep 0.5"); - expect(result.exitCode).toBe(0); - expect(sleptMs).toBe(500); - }); - }); - - describe("duration suffixes", () => { - it("should handle seconds suffix", async () => { - let sleptMs = 0; - const env = new Bash({ - sleep: async (ms) => { - sleptMs = ms; - }, - }); - - const result = await env.exec("sleep 3s"); - expect(result.exitCode).toBe(0); - expect(sleptMs).toBe(3000); - }); - - it("should handle minutes suffix", async () => { - let sleptMs = 0; - const env = new Bash({ - sleep: async (ms) => { - sleptMs = ms; - }, - }); - - const result = await env.exec("sleep 2m"); - expect(result.exitCode).toBe(0); - expect(sleptMs).toBe(120000); - }); - - it("should handle hours suffix", async () => { - let sleptMs = 0; - const env = new Bash({ - sleep: async (ms) => { - sleptMs = ms; - }, - }); - - const result = await env.exec("sleep 1h"); - expect(result.exitCode).toBe(0); - expect(sleptMs).toBe(3600000); - }); - - it("should handle days suffix", async () => { - let sleptMs = 0; - const env = new Bash({ - sleep: async (ms) => { - sleptMs = ms; - }, - }); - - const result = await env.exec("sleep 1d"); - expect(result.exitCode).toBe(0); - expect(sleptMs).toBe(86400000); - }); - - it("should handle decimal values with suffix", async () => { - let sleptMs = 0; - const env = new Bash({ - sleep: async (ms) => { - sleptMs = ms; - }, - }); - - const result = await env.exec("sleep 0.5m"); - expect(result.exitCode).toBe(0); - expect(sleptMs).toBe(30000); - }); - }); - - describe("multiple arguments", () => { - it("should sum multiple durations", async () => { - let sleptMs = 0; - const env = new Bash({ - sleep: async (ms) => { - sleptMs = ms; - }, - }); - - const result = await env.exec("sleep 1 2 3"); - expect(result.exitCode).toBe(0); - expect(sleptMs).toBe(6000); // 1+2+3 = 6 seconds - }); - - it("should sum durations with mixed suffixes", async () => { - let sleptMs = 0; - const env = new Bash({ - sleep: async (ms) => { - sleptMs = ms; - }, - }); - - const result = await env.exec("sleep 1s 1m"); - expect(result.exitCode).toBe(0); - expect(sleptMs).toBe(61000); // 1s + 60s = 61s - }); - }); - - describe("error handling", () => { - it("should error on missing operand", async () => { - const env = new Bash({ sleep: async () => {} }); - - const result = await env.exec("sleep"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("missing operand"); - }); - - it("should error on invalid time interval", async () => { - const env = new Bash({ sleep: async () => {} }); - - const result = await env.exec("sleep abc"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid time interval"); - }); - - it("should error on invalid suffix", async () => { - const env = new Bash({ sleep: async () => {} }); - - const result = await env.exec("sleep 1x"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid time interval"); - }); - }); - - describe("help", () => { - it("should show help with --help", async () => { - const env = new Bash({ sleep: async () => {} }); - - const result = await env.exec("sleep --help"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("sleep"); - expect(result.stdout).toContain("delay"); - }); - }); - - describe("short duration sleep", () => { - it("should handle very short sleep durations", async () => { - let sleptMs = 0; - const env = new Bash({ - sleep: async (ms) => { - sleptMs = ms; - }, - }); - - const result = await env.exec("sleep 0.01"); // 10ms - expect(result.exitCode).toBe(0); - expect(sleptMs).toBe(10); - }); - }); -}); diff --git a/src/commands/sleep/sleep.ts b/src/commands/sleep/sleep.ts deleted file mode 100644 index 794d1300..00000000 --- a/src/commands/sleep/sleep.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { hasHelpFlag, showHelp } from "../help.js"; - -const sleepHelp = { - name: "sleep", - summary: "delay for a specified amount of time", - usage: "sleep NUMBER[SUFFIX]", - description: `Pause for NUMBER seconds. SUFFIX may be: - s - seconds (default) - m - minutes - h - hours - d - days - -NUMBER may be a decimal number.`, - options: [" --help display this help and exit"], -}; - -/** - * Parse sleep duration string to milliseconds - */ -function parseDuration(arg: string): number | null { - const match = arg.match(/^(\d+\.?\d*)(s|m|h|d)?$/); - if (!match) return null; - - const value = parseFloat(match[1]); - const suffix = match[2] || "s"; - - switch (suffix) { - case "s": - return value * 1000; - case "m": - return value * 60 * 1000; - case "h": - return value * 60 * 60 * 1000; - case "d": - return value * 24 * 60 * 60 * 1000; - default: - return null; - } -} - -export const sleepCommand: Command = { - name: "sleep", - - async execute(args: string[], ctx: CommandContext): Promise { - if (hasHelpFlag(args)) { - return showHelp(sleepHelp); - } - - if (args.length === 0) { - return { - stdout: "", - stderr: "sleep: missing operand\n", - exitCode: 1, - }; - } - - // Parse all arguments and sum durations (like GNU sleep) - let totalMs = 0; - for (const arg of args) { - const ms = parseDuration(arg); - if (ms === null) { - return { - stdout: "", - stderr: `sleep: invalid time interval '${arg}'\n`, - exitCode: 1, - }; - } - totalMs += ms; - } - - // Use mock sleep if available in context, otherwise real setTimeout - if (ctx.sleep) { - await ctx.sleep(totalMs); - } else { - await new Promise((resolve) => setTimeout(resolve, totalMs)); - } - - return { stdout: "", stderr: "", exitCode: 0 }; - }, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "sleep", - flags: [], - needsArgs: true, -}; diff --git a/src/commands/sort/comparator.ts b/src/commands/sort/comparator.ts deleted file mode 100644 index b704a8e6..00000000 --- a/src/commands/sort/comparator.ts +++ /dev/null @@ -1,348 +0,0 @@ -// Comparator functions for sort command - -import type { KeySpec, SortOptions } from "./types.js"; - -// Human-readable size suffixes (case insensitive) - Map prevents prototype pollution -const SIZE_SUFFIXES = new Map([ - ["", 1], - ["k", 1024], - ["m", 1024 ** 2], - ["g", 1024 ** 3], - ["t", 1024 ** 4], - ["p", 1024 ** 5], - ["e", 1024 ** 6], -]); - -// Month names for -M - Map prevents prototype pollution -const MONTHS = new Map([ - ["jan", 1], - ["feb", 2], - ["mar", 3], - ["apr", 4], - ["may", 5], - ["jun", 6], - ["jul", 7], - ["aug", 8], - ["sep", 9], - ["oct", 10], - ["nov", 11], - ["dec", 12], -]); - -/** - * Parse a human-readable size like "1K", "2.5M", "3G" - */ -function parseHumanSize(s: string): number { - const trimmed = s.trim(); - const match = trimmed.match( - /^([+-]?\d*\.?\d+)\s*([kmgtpeKMGTPE])?[iI]?[bB]?$/, - ); - if (!match) { - // Try to parse as plain number - const num = parseFloat(trimmed); - return Number.isNaN(num) ? 0 : num; - } - const num = parseFloat(match[1]); - const suffix = (match[2] || "").toLowerCase(); - const multiplier = SIZE_SUFFIXES.get(suffix) ?? 1; - return num * multiplier; -} - -/** - * Parse month name and return sort order (0 for unknown) - */ -function parseMonth(s: string): number { - const trimmed = s.trim().toLowerCase().slice(0, 3); - return MONTHS.get(trimmed) ?? 0; -} - -/** - * Compare version strings naturally (e.g., "1.2" < "1.10") - */ -function compareVersions(a: string, b: string): number { - const partsA = a.split(/(\d+)/); - const partsB = b.split(/(\d+)/); - const maxLen = Math.max(partsA.length, partsB.length); - - for (let i = 0; i < maxLen; i++) { - const partA = partsA[i] || ""; - const partB = partsB[i] || ""; - - // Check if both parts are numeric - const numA = /^\d+$/.test(partA) ? parseInt(partA, 10) : null; - const numB = /^\d+$/.test(partB) ? parseInt(partB, 10) : null; - - if (numA !== null && numB !== null) { - // Both numeric - compare as numbers - if (numA !== numB) return numA - numB; - } else { - // At least one is non-numeric - compare as strings - if (partA !== partB) return partA.localeCompare(partB); - } - } - return 0; -} - -/** - * Apply dictionary order: keep only alphanumeric and blanks - */ -function toDictionaryOrder(s: string): string { - return s.replace(/[^a-zA-Z0-9\s]/g, ""); -} - -/** - * Extract key value from a line based on key specification - */ -function extractKeyValue( - line: string, - key: KeySpec, - delimiter: string | null, -): string { - // Split line into fields - const splitPattern = delimiter !== null ? delimiter : /\s+/; - const fields = line.split(splitPattern); - - // Get start field (0-indexed internally) - const startFieldIdx = key.startField - 1; - if (startFieldIdx >= fields.length) { - return ""; - } - - // If no end field specified, use just the start field - if (key.endField === undefined) { - let field = fields[startFieldIdx] || ""; - - // Handle character position within field - if (key.startChar !== undefined) { - field = field.slice(key.startChar - 1); - } - - // Handle ignore leading blanks - if (key.ignoreLeading) { - field = field.trimStart(); - } - - return field; - } - - // Range of fields - const endFieldIdx = Math.min(key.endField - 1, fields.length - 1); - - // Build the key value from multiple fields - let result = ""; - - for (let i = startFieldIdx; i <= endFieldIdx && i < fields.length; i++) { - let field = fields[i] || ""; - - if (i === startFieldIdx && key.startChar !== undefined) { - // Start character position in first field - field = field.slice(key.startChar - 1); - } - - if (i === endFieldIdx && key.endChar !== undefined) { - // End character position in last field - const endIdx = - i === startFieldIdx && key.startChar !== undefined - ? key.endChar - key.startChar + 1 - : key.endChar; - field = field.slice(0, endIdx); - } - - if (i > startFieldIdx) { - // Add delimiter between fields - result += delimiter || " "; - } - result += field; - } - - // Handle ignore leading blanks - if (key.ignoreLeading) { - result = result.trimStart(); - } - - return result; -} - -interface CompareOptions { - numeric?: boolean; - ignoreCase?: boolean; - humanNumeric?: boolean; - versionSort?: boolean; - dictionaryOrder?: boolean; - monthSort?: boolean; -} - -/** - * Compare two values, handling various sort modes - */ -function compareValues(a: string, b: string, opts: CompareOptions): number { - let valA = a; - let valB = b; - - // Apply dictionary order first (removes non-alphanumeric) - if (opts.dictionaryOrder) { - valA = toDictionaryOrder(valA); - valB = toDictionaryOrder(valB); - } - - // Apply case folding - if (opts.ignoreCase) { - valA = valA.toLowerCase(); - valB = valB.toLowerCase(); - } - - // Month sort - if (opts.monthSort) { - const monthA = parseMonth(valA); - const monthB = parseMonth(valB); - return monthA - monthB; - } - - // Human numeric sort (1K, 2M, etc.) - if (opts.humanNumeric) { - const sizeA = parseHumanSize(valA); - const sizeB = parseHumanSize(valB); - return sizeA - sizeB; - } - - // Version sort - if (opts.versionSort) { - return compareVersions(valA, valB); - } - - // Numeric sort - if (opts.numeric) { - const numA = parseFloat(valA) || 0; - const numB = parseFloat(valB) || 0; - return numA - numB; - } - - // String comparison - return valA.localeCompare(valB); -} - -/** - * Create a comparison function for sorting - */ -export function createComparator( - options: SortOptions, -): (a: string, b: string) => number { - const { - keys, - fieldDelimiter, - numeric: globalNumeric, - ignoreCase: globalIgnoreCase, - reverse: globalReverse, - humanNumeric: globalHumanNumeric, - versionSort: globalVersionSort, - dictionaryOrder: globalDictionaryOrder, - monthSort: globalMonthSort, - ignoreLeadingBlanks: globalIgnoreLeadingBlanks, - stable: globalStable, - } = options; - - return (a: string, b: string): number => { - let lineA = a; - let lineB = b; - - // Apply ignore leading blanks globally - if (globalIgnoreLeadingBlanks) { - lineA = lineA.trimStart(); - lineB = lineB.trimStart(); - } - - // If no keys specified, compare whole lines - if (keys.length === 0) { - const opts: CompareOptions = { - numeric: globalNumeric, - ignoreCase: globalIgnoreCase, - humanNumeric: globalHumanNumeric, - versionSort: globalVersionSort, - dictionaryOrder: globalDictionaryOrder, - monthSort: globalMonthSort, - }; - - const result = compareValues(lineA, lineB, opts); - - if (result !== 0) { - return globalReverse ? -result : result; - } - - // Tiebreaker: use original lines unless stable sort - if (!globalStable) { - const tiebreaker = a.localeCompare(b); - return globalReverse ? -tiebreaker : tiebreaker; - } - return 0; - } - - // Compare by each key in order - for (const key of keys) { - let valA = extractKeyValue(lineA, key, fieldDelimiter); - let valB = extractKeyValue(lineB, key, fieldDelimiter); - - // Apply per-key ignore leading blanks - if (key.ignoreLeading) { - valA = valA.trimStart(); - valB = valB.trimStart(); - } - - // Use per-key modifiers or fall back to global options - const opts: CompareOptions = { - numeric: key.numeric ?? globalNumeric, - ignoreCase: key.ignoreCase ?? globalIgnoreCase, - humanNumeric: key.humanNumeric ?? globalHumanNumeric, - versionSort: key.versionSort ?? globalVersionSort, - dictionaryOrder: key.dictionaryOrder ?? globalDictionaryOrder, - monthSort: key.monthSort ?? globalMonthSort, - }; - const useReverse = key.reverse ?? globalReverse; - - const result = compareValues(valA, valB, opts); - - if (result !== 0) { - return useReverse ? -result : result; - } - } - - // All keys equal, compare whole lines as tiebreaker unless stable - if (!globalStable) { - const tiebreaker = a.localeCompare(b); - return globalReverse ? -tiebreaker : tiebreaker; - } - return 0; - }; -} - -/** - * Filter unique lines based on key values or whole line - */ -export function filterUnique(lines: string[], options: SortOptions): string[] { - if (options.keys.length === 0) { - // No keys - use whole line for uniqueness - if (options.ignoreCase) { - const seen = new Set(); - return lines.filter((line) => { - const key = line.toLowerCase(); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - } - return [...new Set(lines)]; - } - - // Use first key for uniqueness comparison - const key = options.keys[0]; - const seen = new Set(); - - return lines.filter((line) => { - let keyVal = extractKeyValue(line, key, options.fieldDelimiter); - if (key.ignoreCase ?? options.ignoreCase) { - keyVal = keyVal.toLowerCase(); - } - if (seen.has(keyVal)) return false; - seen.add(keyVal); - return true; - }); -} diff --git a/src/commands/sort/parser.ts b/src/commands/sort/parser.ts deleted file mode 100644 index 66c41420..00000000 --- a/src/commands/sort/parser.ts +++ /dev/null @@ -1,101 +0,0 @@ -// Parser for sort key specifications - -import type { KeySpec } from "./types.js"; - -/** - * Parse a key specification like: - * - "1" - field 1 - * - "1,2" - fields 1 through 2 - * - "1.3" - field 1 starting at char 3 - * - "1.3,2.5" - field 1 char 3 through field 2 char 5 - * - "1n" - field 1, numeric - * - "1,2nr" - fields 1-2, numeric and reverse - */ -export function parseKeySpec(spec: string): KeySpec | null { - // Pattern: FIELD[.CHAR][,FIELD[.CHAR]][MODIFIERS] - // Where MODIFIERS can be: n (numeric), r (reverse), f (fold case), b (ignore blanks) - - const result: KeySpec = { - startField: 1, - }; - - // Check for modifiers at the end - let modifierStr = ""; - let mainSpec = spec; - - // Find where modifiers start (after all digits, dots, and commas) - // Valid modifiers: b d f h M n r V - const modifierMatch = mainSpec.match(/([bdfhMnrV]+)$/); - if (modifierMatch) { - modifierStr = modifierMatch[1]; - mainSpec = mainSpec.slice(0, -modifierStr.length); - } - - // Parse modifiers (case-sensitive: M and V are uppercase) - if (modifierStr.includes("n")) result.numeric = true; - if (modifierStr.includes("r")) result.reverse = true; - if (modifierStr.includes("f")) result.ignoreCase = true; - if (modifierStr.includes("b")) result.ignoreLeading = true; - if (modifierStr.includes("h")) result.humanNumeric = true; - if (modifierStr.includes("V")) result.versionSort = true; - if (modifierStr.includes("d")) result.dictionaryOrder = true; - if (modifierStr.includes("M")) result.monthSort = true; - - // Split by comma for start and end - const parts = mainSpec.split(","); - - if (parts.length === 0 || parts[0] === "") { - return null; - } - - // Parse start position - const startParts = parts[0].split("."); - const startField = parseInt(startParts[0], 10); - if (Number.isNaN(startField) || startField < 1) { - return null; - } - result.startField = startField; - - if (startParts.length > 1 && startParts[1]) { - const startChar = parseInt(startParts[1], 10); - if (!Number.isNaN(startChar) && startChar >= 1) { - result.startChar = startChar; - } - } - - // Parse end position if present - if (parts.length > 1 && parts[1]) { - // End part might have trailing modifiers too - let endPart = parts[1]; - const endModifierMatch = endPart.match(/([bdfhMnrV]+)$/); - if (endModifierMatch) { - const endModifiers = endModifierMatch[1]; - if (endModifiers.includes("n")) result.numeric = true; - if (endModifiers.includes("r")) result.reverse = true; - if (endModifiers.includes("f")) result.ignoreCase = true; - if (endModifiers.includes("b")) result.ignoreLeading = true; - if (endModifiers.includes("h")) result.humanNumeric = true; - if (endModifiers.includes("V")) result.versionSort = true; - if (endModifiers.includes("d")) result.dictionaryOrder = true; - if (endModifiers.includes("M")) result.monthSort = true; - endPart = endPart.slice(0, -endModifiers.length); - } - - const endParts = endPart.split("."); - if (endParts[0]) { - const endField = parseInt(endParts[0], 10); - if (!Number.isNaN(endField) && endField >= 1) { - result.endField = endField; - } - - if (endParts.length > 1 && endParts[1]) { - const endChar = parseInt(endParts[1], 10); - if (!Number.isNaN(endChar) && endChar >= 1) { - result.endChar = endChar; - } - } - } - } - - return result; -} diff --git a/src/commands/sort/sort.advanced.test.ts b/src/commands/sort/sort.advanced.test.ts deleted file mode 100644 index 6d8029e8..00000000 --- a/src/commands/sort/sort.advanced.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("sort -h (human numeric)", () => { - it("should sort human readable sizes", async () => { - const env = new Bash({ - files: { "/test.txt": "1K\n2M\n500\n1G\n100K\n" }, - }); - const result = await env.exec("sort -h /test.txt"); - expect(result.stdout).toBe("500\n1K\n100K\n2M\n1G\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle mixed case suffixes", async () => { - const env = new Bash({ - files: { "/test.txt": "1k\n2M\n3g\n" }, - }); - const result = await env.exec("sort -h /test.txt"); - expect(result.stdout).toBe("1k\n2M\n3g\n"); - expect(result.exitCode).toBe(0); - }); - - it("should sort with decimal values", async () => { - const env = new Bash({ - files: { "/test.txt": "1.5K\n2K\n1K\n" }, - }); - const result = await env.exec("sort -h /test.txt"); - expect(result.stdout).toBe("1K\n1.5K\n2K\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle reverse human sort", async () => { - const env = new Bash({ - files: { "/test.txt": "1K\n1M\n1G\n" }, - }); - const result = await env.exec("sort -hr /test.txt"); - expect(result.stdout).toBe("1G\n1M\n1K\n"); - expect(result.exitCode).toBe(0); - }); -}); - -describe("sort -V (version)", () => { - it("should sort version numbers naturally", async () => { - const env = new Bash({ - files: { "/test.txt": "file1.10\nfile1.2\nfile1.1\n" }, - }); - const result = await env.exec("sort -V /test.txt"); - expect(result.stdout).toBe("file1.1\nfile1.2\nfile1.10\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle version-like strings", async () => { - const env = new Bash({ - files: { "/test.txt": "v2.0\nv1.10\nv1.2\n" }, - }); - const result = await env.exec("sort -V /test.txt"); - expect(result.stdout).toBe("v1.2\nv1.10\nv2.0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should sort mixed version formats", async () => { - const env = new Bash({ - files: { "/test.txt": "1.0.0\n1.0.10\n1.0.2\n" }, - }); - const result = await env.exec("sort -V /test.txt"); - expect(result.stdout).toBe("1.0.0\n1.0.2\n1.0.10\n"); - expect(result.exitCode).toBe(0); - }); -}); - -describe("sort -M (month)", () => { - it("should sort month names", async () => { - const env = new Bash({ - files: { "/test.txt": "Mar\nJan\nDec\nFeb\n" }, - }); - const result = await env.exec("sort -M /test.txt"); - expect(result.stdout).toBe("Jan\nFeb\nMar\nDec\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle lowercase months", async () => { - const env = new Bash({ - files: { "/test.txt": "mar\njan\nfeb\n" }, - }); - const result = await env.exec("sort -M /test.txt"); - expect(result.stdout).toBe("jan\nfeb\nmar\n"); - expect(result.exitCode).toBe(0); - }); - - it("should put unknown values first", async () => { - const env = new Bash({ - files: { "/test.txt": "Mar\nfoo\nJan\n" }, - }); - const result = await env.exec("sort -M /test.txt"); - expect(result.stdout).toBe("foo\nJan\nMar\n"); - expect(result.exitCode).toBe(0); - }); -}); - -describe("sort -d (dictionary order)", () => { - it("should ignore non-alphanumeric characters", async () => { - const env = new Bash({ - files: { "/test.txt": "b-c\na_b\nc.d\n" }, - }); - const result = await env.exec("sort -d /test.txt"); - // Dictionary order: only alphanumeric and blanks matter - // ab, bc, cd -> a_b, b-c, c.d - expect(result.stdout).toBe("a_b\nb-c\nc.d\n"); - expect(result.exitCode).toBe(0); - }); -}); - -describe("sort -b (ignore leading blanks)", () => { - it("should ignore leading blanks", async () => { - const env = new Bash({ - files: { "/test.txt": " b\na\n c\n" }, - }); - const result = await env.exec("sort -b /test.txt"); - expect(result.stdout).toBe("a\n b\n c\n"); - expect(result.exitCode).toBe(0); - }); - - it("should combine with other flags", async () => { - const env = new Bash({ - files: { "/test.txt": " 2\n1\n 3\n" }, - }); - const result = await env.exec("sort -bn /test.txt"); - expect(result.stdout).toBe("1\n 2\n 3\n"); - expect(result.exitCode).toBe(0); - }); -}); - -describe("sort -c (check)", () => { - it("should return 0 for sorted input", async () => { - const env = new Bash({ - files: { "/test.txt": "a\nb\nc\n" }, - }); - const result = await env.exec("sort -c /test.txt"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should return 1 for unsorted input", async () => { - const env = new Bash({ - files: { "/test.txt": "b\na\nc\n" }, - }); - const result = await env.exec("sort -c /test.txt"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe("sort: /test.txt:2: disorder: a\n"); - expect(result.exitCode).toBe(1); - }); - - it("should check numeric order with -cn", async () => { - const env = new Bash({ - files: { "/test.txt": "1\n2\n10\n" }, - }); - const result = await env.exec("sort -cn /test.txt"); - expect(result.exitCode).toBe(0); - }); -}); - -describe("sort -o (output file)", () => { - it("should write to output file", async () => { - const env = new Bash({ - files: { "/test.txt": "c\na\nb\n" }, - }); - await env.exec("sort -o /out.txt /test.txt"); - const result = await env.exec("cat /out.txt"); - expect(result.stdout).toBe("a\nb\nc\n"); - }); - - it("should support in-place sort", async () => { - const env = new Bash({ - files: { "/test.txt": "c\na\nb\n" }, - }); - await env.exec("sort -o /test.txt /test.txt"); - const result = await env.exec("cat /test.txt"); - expect(result.stdout).toBe("a\nb\nc\n"); - }); - - it("should support --output= syntax", async () => { - const env = new Bash({ - files: { "/test.txt": "c\na\nb\n" }, - }); - await env.exec("sort --output=/out.txt /test.txt"); - const result = await env.exec("cat /out.txt"); - expect(result.stdout).toBe("a\nb\nc\n"); - }); -}); - -describe("sort -s (stable)", () => { - it("should preserve original order for equal elements", async () => { - const env = new Bash({ - files: { "/test.txt": "1 b\n1 a\n2 c\n" }, - }); - // With -s, equal keys should maintain original order - const result = await env.exec("sort -s -k1,1 /test.txt"); - expect(result.stdout).toBe("1 b\n1 a\n2 c\n"); - expect(result.exitCode).toBe(0); - }); -}); - -describe("sort per-key modifiers", () => { - it("should support -k with h modifier", async () => { - const env = new Bash({ - files: { "/test.txt": "a 1M\nb 1K\nc 1G\n" }, - }); - const result = await env.exec("sort -k2h /test.txt"); - expect(result.stdout).toBe("b 1K\na 1M\nc 1G\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support -k with V modifier", async () => { - const env = new Bash({ - files: { "/test.txt": "a v1.10\nb v1.2\nc v2.0\n" }, - }); - const result = await env.exec("sort -k2V /test.txt"); - expect(result.stdout).toBe("b v1.2\na v1.10\nc v2.0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support -k with M modifier", async () => { - const env = new Bash({ - files: { "/test.txt": "2023 Mar\n2023 Jan\n2023 Feb\n" }, - }); - const result = await env.exec("sort -k2M /test.txt"); - expect(result.stdout).toBe("2023 Jan\n2023 Feb\n2023 Mar\n"); - expect(result.exitCode).toBe(0); - }); -}); - -describe("sort --help", () => { - it("should show all new options in help", async () => { - const env = new Bash(); - const result = await env.exec("sort --help"); - expect(result.stdout).toContain("-h"); - expect(result.stdout).toContain("-V"); - expect(result.stdout).toContain("-M"); - expect(result.stdout).toContain("-d"); - expect(result.stdout).toContain("-b"); - expect(result.stdout).toContain("-c"); - expect(result.stdout).toContain("-o"); - expect(result.stdout).toContain("-s"); - expect(result.exitCode).toBe(0); - }); -}); diff --git a/src/commands/sort/sort.binary.test.ts b/src/commands/sort/sort.binary.test.ts deleted file mode 100644 index 5fd1eb82..00000000 --- a/src/commands/sort/sort.binary.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("sort with binary content", () => { - it("should sort lines containing binary-safe content", async () => { - const env = new Bash({ - files: { - "/data.txt": new Uint8Array([ - 0x63, - 0x0a, // c\n - 0x61, - 0x0a, // a\n - 0x62, - 0x0a, // b\n - ]), - }, - }); - - const result = await env.exec("sort /data.txt"); - expect(result.stdout).toBe("a\nb\nc\n"); - expect(result.exitCode).toBe(0); - }); -}); diff --git a/src/commands/sort/sort.test.ts b/src/commands/sort/sort.test.ts deleted file mode 100644 index cec78d49..00000000 --- a/src/commands/sort/sort.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("sort command", () => { - const createEnv = () => - new Bash({ - files: { - "/test/names.txt": "Charlie\nAlice\nBob\nDavid\n", - "/test/numbers.txt": "10\n2\n1\n20\n5\n", - "/test/duplicates.txt": "apple\nbanana\napple\ncherry\nbanana\n", - "/test/columns.txt": "John 25\nAlice 30\nBob 20\nDavid 35\n", - "/test/mixed.txt": "zebra\nalpha\nZebra\nAlpha\n", - }, - cwd: "/test", - }); - - it("should sort lines alphabetically", async () => { - const env = createEnv(); - const result = await env.exec("sort /test/names.txt"); - expect(result.stdout).toBe("Alice\nBob\nCharlie\nDavid\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should sort lines in reverse order with -r", async () => { - const env = createEnv(); - const result = await env.exec("sort -r /test/names.txt"); - expect(result.stdout).toBe("David\nCharlie\nBob\nAlice\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should sort numerically with -n", async () => { - const env = createEnv(); - const result = await env.exec("sort -n /test/numbers.txt"); - expect(result.stdout).toBe("1\n2\n5\n10\n20\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should sort numerically in reverse with -rn", async () => { - const env = createEnv(); - const result = await env.exec("sort -rn /test/numbers.txt"); - expect(result.stdout).toBe("20\n10\n5\n2\n1\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should remove duplicates with -u", async () => { - const env = createEnv(); - const result = await env.exec("sort -u /test/duplicates.txt"); - expect(result.stdout).toBe("apple\nbanana\ncherry\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should sort by key field with -k", async () => { - const env = createEnv(); - const result = await env.exec("sort -k2 -n /test/columns.txt"); - expect(result.stdout).toBe("Bob 20\nJohn 25\nAlice 30\nDavid 35\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should read from stdin via pipe", async () => { - const env = createEnv(); - const result = await env.exec('echo -e "c\\nb\\na" | sort'); - expect(result.stdout).toBe("a\nb\nc\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should handle case-sensitive sorting", async () => { - const env = createEnv(); - const result = await env.exec("sort /test/mixed.txt"); - expect(result.stdout).toBe("alpha\nAlpha\nzebra\nZebra\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should return error for non-existent file", async () => { - const env = createEnv(); - const result = await env.exec("sort /test/nonexistent.txt"); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "sort: /test/nonexistent.txt: No such file or directory\n", - ); - expect(result.exitCode).toBe(1); - }); - - it("should handle empty input", async () => { - const env = createEnv(); - const result = await env.exec('echo "" | sort'); - expect(result.stdout).toBe("\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should handle combined flags -nr", async () => { - const env = createEnv(); - const result = await env.exec("sort -nr /test/numbers.txt"); - expect(result.stdout).toBe("20\n10\n5\n2\n1\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - describe("-f flag (case-insensitive)", () => { - it("should sort case-insensitively with -f", async () => { - const env = createEnv(); - const result = await env.exec("sort -f /test/mixed.txt"); - // Case-insensitive: alpha/Alpha should be together, zebra/Zebra together - // The exact order within same-case groups depends on locale - expect(result.stdout).toContain("alpha"); - expect(result.stdout).toContain("Alpha"); - expect(result.stdout).toContain("zebra"); - expect(result.stdout).toContain("Zebra"); - expect(result.exitCode).toBe(0); - }); - - it("should sort case-insensitively with --ignore-case", async () => { - const env = new Bash({ - files: { "/test.txt": "Banana\napple\nCherry\n" }, - }); - const result = await env.exec("sort --ignore-case /test.txt"); - expect(result.stdout).toBe("apple\nBanana\nCherry\n"); - expect(result.exitCode).toBe(0); - }); - - it("should combine -f with -r for reverse case-insensitive", async () => { - const env = new Bash({ - files: { "/test.txt": "apple\nBanana\ncherry\n" }, - }); - const result = await env.exec("sort -fr /test.txt"); - expect(result.stdout).toBe("cherry\nBanana\napple\n"); - expect(result.exitCode).toBe(0); - }); - - it("should combine -f with -u for unique case-insensitive", async () => { - const env = new Bash({ - files: { "/test.txt": "Apple\napple\nBanana\nbanana\n" }, - }); - const result = await env.exec("sort -fu /test.txt"); - // Should have 2 unique entries (case-folded) - const lines = result.stdout.trim().split("\n"); - expect(lines.length).toBe(2); - expect(result.exitCode).toBe(0); - }); - - it("should work with -k field and -f", async () => { - const env = new Bash({ - files: { "/test.txt": "1 Zebra\n2 apple\n3 BANANA\n" }, - }); - const result = await env.exec("sort -f -k2 /test.txt"); - expect(result.stdout).toBe("2 apple\n3 BANANA\n1 Zebra\n"); - expect(result.exitCode).toBe(0); - }); - - it("should show help with --help", async () => { - const env = new Bash(); - const result = await env.exec("sort --help"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("--ignore-case"); - }); - }); - - describe("complex -k syntax", () => { - it("should sort by field range -k1,2", async () => { - const env = new Bash({ - files: { "/test.txt": "a b c\na a c\nb a a\n" }, - cwd: "/", - }); - const result = await env.exec("sort -k1,2 /test.txt"); - expect(result.stdout).toBe("a a c\na b c\nb a a\n"); - }); - - it("should sort by single field only with -k2,2", async () => { - const env = new Bash({ - files: { "/test.txt": "1 banana\n2 apple\n3 cherry\n" }, - cwd: "/", - }); - const result = await env.exec("sort -k2,2 /test.txt"); - expect(result.stdout).toBe("2 apple\n1 banana\n3 cherry\n"); - }); - - it("should support per-key numeric modifier -k2n", async () => { - const env = new Bash({ - files: { "/test.txt": "a 10\nb 2\nc 1\n" }, - cwd: "/", - }); - const result = await env.exec("sort -k2n /test.txt"); - expect(result.stdout).toBe("c 1\nb 2\na 10\n"); - }); - - it("should support per-key reverse modifier -k1r", async () => { - const env = new Bash({ - files: { "/test.txt": "a 1\nb 2\nc 3\n" }, - cwd: "/", - }); - const result = await env.exec("sort -k1r /test.txt"); - expect(result.stdout).toBe("c 3\nb 2\na 1\n"); - }); - - it("should support combined modifiers -k2,2nr", async () => { - const env = new Bash({ - files: { "/test.txt": "x 5\ny 10\nz 2\n" }, - cwd: "/", - }); - const result = await env.exec("sort -k2,2nr /test.txt"); - expect(result.stdout).toBe("y 10\nx 5\nz 2\n"); - }); - - it("should support multiple keys for secondary sort", async () => { - const env = new Bash({ - files: { "/test.txt": "a 2\nb 1\na 1\nb 2\n" }, - cwd: "/", - }); - // Sort by field 1, then by field 2 numerically - const result = await env.exec("sort -k1,1 -k2,2n /test.txt"); - expect(result.stdout).toBe("a 1\na 2\nb 1\nb 2\n"); - }); - - it("should support character position -k1.2", async () => { - const env = new Bash({ - files: { "/test.txt": "abc\nabc\nbac\naac\n" }, - cwd: "/", - }); - // Sort starting from 2nd character of field 1 - const result = await env.exec("sort -k1.2 /test.txt"); - expect(result.stdout).toBe("aac\nbac\nabc\nabc\n"); - }); - - it("should support per-key ignore-case -k1f", async () => { - const env = new Bash({ - files: { "/test.txt": "Zebra\napple\nBANANA\n" }, - cwd: "/", - }); - const result = await env.exec("sort -k1f /test.txt"); - expect(result.stdout).toBe("apple\nBANANA\nZebra\n"); - }); - - it("should support custom delimiter with -t", async () => { - const env = new Bash({ - files: { "/test.txt": "c:3\na:1\nb:2\n" }, - cwd: "/", - }); - const result = await env.exec("sort -t: -k2n /test.txt"); - expect(result.stdout).toBe("a:1\nb:2\nc:3\n"); - }); - - it("should handle --key= syntax", async () => { - const env = new Bash({ - files: { "/test.txt": "3 c\n1 a\n2 b\n" }, - cwd: "/", - }); - const result = await env.exec("sort --key=1n /test.txt"); - expect(result.stdout).toBe("1 a\n2 b\n3 c\n"); - }); - }); -}); diff --git a/src/commands/sort/sort.ts b/src/commands/sort/sort.ts deleted file mode 100644 index 2410a4b2..00000000 --- a/src/commands/sort/sort.ts +++ /dev/null @@ -1,226 +0,0 @@ -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { readAndConcat } from "../../utils/file-reader.js"; -import { hasHelpFlag, showHelp, unknownOption } from "../help.js"; -import { createComparator, filterUnique } from "./comparator.js"; -import { parseKeySpec } from "./parser.js"; -import type { SortOptions } from "./types.js"; - -const sortHelp = { - name: "sort", - summary: "sort lines of text files", - usage: "sort [OPTION]... [FILE]...", - options: [ - "-b, --ignore-leading-blanks ignore leading blanks", - "-d, --dictionary-order consider only blanks and alphanumeric characters", - "-f, --ignore-case fold lower case to upper case characters", - "-h, --human-numeric-sort compare human readable numbers (e.g., 2K 1G)", - "-M, --month-sort compare (unknown) < 'JAN' < ... < 'DEC'", - "-n, --numeric-sort compare according to string numerical value", - "-r, --reverse reverse the result of comparisons", - "-V, --version-sort natural sort of (version) numbers within text", - "-c, --check check for sorted input; do not sort", - "-o, --output=FILE write result to FILE instead of stdout", - "-s, --stable stabilize sort by disabling last-resort comparison", - "-u, --unique output only unique lines", - "-k, --key=KEYDEF sort via a key; KEYDEF gives location and type", - "-t, --field-separator=SEP use SEP as field separator", - " --help display this help and exit", - ], - description: `KEYDEF is F[.C][OPTS][,F[.C][OPTS]] - F is a field number (1-indexed) - C is a character position within the field (1-indexed) - OPTS can be: b d f h M n r V (per-key modifiers) - -Examples: - -k1 sort by first field - -k2,2 sort by second field only - -k1.3 sort by first field starting at 3rd character - -k1,2n sort by fields 1-2 numerically - -k2 -k1 sort by field 2, then by field 1`, -}; - -export const sortCommand: Command = { - name: "sort", - async execute(args: string[], ctx: CommandContext): Promise { - if (hasHelpFlag(args)) { - return showHelp(sortHelp); - } - - const options: SortOptions = { - reverse: false, - numeric: false, - unique: false, - ignoreCase: false, - humanNumeric: false, - versionSort: false, - dictionaryOrder: false, - monthSort: false, - ignoreLeadingBlanks: false, - stable: false, - checkOnly: false, - outputFile: null, - keys: [], - fieldDelimiter: null, - }; - const files: string[] = []; - - // Parse arguments - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === "-r" || arg === "--reverse") { - options.reverse = true; - } else if (arg === "-n" || arg === "--numeric-sort") { - options.numeric = true; - } else if (arg === "-u" || arg === "--unique") { - options.unique = true; - } else if (arg === "-f" || arg === "--ignore-case") { - options.ignoreCase = true; - } else if (arg === "-h" || arg === "--human-numeric-sort") { - options.humanNumeric = true; - } else if (arg === "-V" || arg === "--version-sort") { - options.versionSort = true; - } else if (arg === "-d" || arg === "--dictionary-order") { - options.dictionaryOrder = true; - } else if (arg === "-M" || arg === "--month-sort") { - options.monthSort = true; - } else if (arg === "-b" || arg === "--ignore-leading-blanks") { - options.ignoreLeadingBlanks = true; - } else if (arg === "-s" || arg === "--stable") { - options.stable = true; - } else if (arg === "-c" || arg === "--check") { - options.checkOnly = true; - } else if (arg === "-o" || arg === "--output") { - options.outputFile = args[++i] || null; - } else if (arg.startsWith("-o")) { - options.outputFile = arg.slice(2) || null; - } else if (arg.startsWith("--output=")) { - options.outputFile = arg.slice(9) || null; - } else if (arg === "-t" || arg === "--field-separator") { - options.fieldDelimiter = args[++i] || null; - } else if (arg.startsWith("-t")) { - options.fieldDelimiter = arg.slice(2) || null; - } else if (arg.startsWith("--field-separator=")) { - options.fieldDelimiter = arg.slice(18) || null; - } else if (arg === "-k" || arg === "--key") { - const keyArg = args[++i]; - if (keyArg) { - const keySpec = parseKeySpec(keyArg); - if (keySpec) { - options.keys.push(keySpec); - } - } - } else if (arg.startsWith("-k")) { - const keySpec = parseKeySpec(arg.slice(2)); - if (keySpec) { - options.keys.push(keySpec); - } - } else if (arg.startsWith("--key=")) { - const keySpec = parseKeySpec(arg.slice(6)); - if (keySpec) { - options.keys.push(keySpec); - } - } else if (arg.startsWith("--")) { - return unknownOption("sort", arg); - } else if (arg.startsWith("-") && !arg.startsWith("--")) { - // Handle combined flags like -rn - let hasUnknown = false; - for (const char of arg.slice(1)) { - if (char === "r") options.reverse = true; - else if (char === "n") options.numeric = true; - else if (char === "u") options.unique = true; - else if (char === "f") options.ignoreCase = true; - else if (char === "h") options.humanNumeric = true; - else if (char === "V") options.versionSort = true; - else if (char === "d") options.dictionaryOrder = true; - else if (char === "M") options.monthSort = true; - else if (char === "b") options.ignoreLeadingBlanks = true; - else if (char === "s") options.stable = true; - else if (char === "c") options.checkOnly = true; - else { - hasUnknown = true; - break; - } - } - if (hasUnknown) { - return unknownOption("sort", arg); - } - } else { - files.push(arg); - } - } - - // Read from files or stdin - const readResult = await readAndConcat(ctx, files, { cmdName: "sort" }); - if (!readResult.ok) return readResult.error; - const content = readResult.content; - - // Split into lines (preserve empty lines at the end for sorting) - let lines = content.split("\n"); - - // Remove last empty element if content ends with newline - if (lines.length > 0 && lines[lines.length - 1] === "") { - lines.pop(); - } - - // Create comparator - const comparator = createComparator(options); - - // Check mode: verify if already sorted - if (options.checkOnly) { - const checkFile = files.length > 0 ? files[0] : "-"; - for (let i = 1; i < lines.length; i++) { - if (comparator(lines[i - 1], lines[i]) > 0) { - return { - stdout: "", - stderr: `sort: ${checkFile}:${i + 1}: disorder: ${lines[i]}\n`, - exitCode: 1, - }; - } - } - return { stdout: "", stderr: "", exitCode: 0 }; - } - - // Sort lines using the comparator - lines.sort(comparator); - - // Remove duplicates if -u - if (options.unique) { - lines = filterUnique(lines, options); - } - - const output = lines.length > 0 ? `${lines.join("\n")}\n` : ""; - - // Output to file if -o specified - if (options.outputFile) { - const outPath = ctx.fs.resolvePath(ctx.cwd, options.outputFile); - await ctx.fs.writeFile(outPath, output); - return { stdout: "", stderr: "", exitCode: 0 }; - } - - return { stdout: output, stderr: "", exitCode: 0 }; - }, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "sort", - flags: [ - { flag: "-r", type: "boolean" }, - { flag: "-n", type: "boolean" }, - { flag: "-u", type: "boolean" }, - { flag: "-f", type: "boolean" }, - { flag: "-h", type: "boolean" }, - { flag: "-V", type: "boolean" }, - { flag: "-d", type: "boolean" }, - { flag: "-M", type: "boolean" }, - { flag: "-b", type: "boolean" }, - { flag: "-s", type: "boolean" }, - { flag: "-c", type: "boolean" }, - { flag: "-k", type: "value", valueHint: "string" }, - { flag: "-t", type: "value", valueHint: "delimiter" }, - { flag: "-o", type: "value", valueHint: "path" }, - ], - stdinType: "text", - needsFiles: true, -}; diff --git a/src/commands/sort/types.ts b/src/commands/sort/types.ts deleted file mode 100644 index d3be5073..00000000 --- a/src/commands/sort/types.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Types for sort command implementation - -export interface KeySpec { - // Start position - startField: number; // 1-indexed field number - startChar?: number; // 1-indexed character position within field - - // End position (optional) - endField?: number; // 1-indexed field number - endChar?: number; // 1-indexed character position within field - - // Per-key modifiers - numeric?: boolean; // n - numeric sort - reverse?: boolean; // r - reverse sort - ignoreCase?: boolean; // f - fold case - ignoreLeading?: boolean; // b - ignore leading blanks - humanNumeric?: boolean; // h - human numeric sort - versionSort?: boolean; // V - version sort - dictionaryOrder?: boolean; // d - dictionary order - monthSort?: boolean; // M - month sort -} - -export interface SortOptions { - reverse: boolean; - numeric: boolean; - unique: boolean; - ignoreCase: boolean; - humanNumeric: boolean; - versionSort: boolean; - dictionaryOrder: boolean; - monthSort: boolean; - ignoreLeadingBlanks: boolean; - stable: boolean; - checkOnly: boolean; - outputFile: string | null; - keys: KeySpec[]; - fieldDelimiter: string | null; -} diff --git a/src/commands/split/split.test.ts b/src/commands/split/split.test.ts deleted file mode 100644 index d5f8081f..00000000 --- a/src/commands/split/split.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("split", () => { - describe("basic functionality", () => { - it("splits file into 1000-line chunks by default", async () => { - // Create a file with 2500 lines - const lines = - Array.from({ length: 2500 }, (_, i) => `line ${i + 1}`).join("\n") + - "\n"; - const bash = new Bash({ - files: { - "/test.txt": lines, - }, - }); - const result = await bash.exec("split /test.txt && ls -1 x*"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("xaa"); - expect(result.stdout).toContain("xab"); - expect(result.stdout).toContain("xac"); - }); - - it("uses x as default prefix", async () => { - const bash = new Bash({ - files: { - "/test.txt": "line1\nline2\nline3\n", - }, - }); - const result = await bash.exec("split -l 1 /test.txt && ls -1 x*"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("xaa"); - expect(result.stdout).toContain("xab"); - expect(result.stdout).toContain("xac"); - }); - - it("uses custom prefix", async () => { - const bash = new Bash({ - files: { - "/test.txt": "line1\nline2\nline3\n", - }, - }); - const result = await bash.exec( - "split -l 1 /test.txt part_ && ls -1 part_*", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("part_aa"); - expect(result.stdout).toContain("part_ab"); - expect(result.stdout).toContain("part_ac"); - }); - - it("reads from stdin when no file specified", async () => { - const bash = new Bash(); - const result = await bash.exec("printf 'a\\nb\\nc\\n' | split -l 1"); - expect(result.exitCode).toBe(0); - const content = await bash.readFile("xaa"); - expect(content).toBe("a\n"); - }); - - it("handles empty input", async () => { - const bash = new Bash(); - const result = await bash.exec("printf '' | split"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("-l option", () => { - it("splits by specified number of lines", async () => { - const bash = new Bash({ - files: { - "/test.txt": "1\n2\n3\n4\n5\n", - }, - }); - const result = await bash.exec( - "split -l 2 /test.txt && cat xaa && cat xab && cat xac", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("1\n2\n"); - expect(result.stdout).toContain("3\n4\n"); - expect(result.stdout).toContain("5\n"); - }); - - it("supports -lN attached form", async () => { - const bash = new Bash({ - files: { - "/test.txt": "1\n2\n3\n4\n", - }, - }); - const result = await bash.exec("split -l2 /test.txt && cat xaa"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1\n2\n"); - }); - - it("errors on invalid line count", async () => { - const bash = new Bash(); - const result = await bash.exec("echo 'test' | split -l abc"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid number of lines"); - }); - - it("errors on zero line count", async () => { - const bash = new Bash(); - const result = await bash.exec("echo 'test' | split -l 0"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid number of lines"); - }); - }); - - describe("-b option", () => { - it("splits by specified number of bytes", async () => { - const bash = new Bash({ - files: { - "/test.txt": "abcdefghij", - }, - }); - const result = await bash.exec( - "split -b 4 /test.txt && cat xaa && echo '---' && cat xab && echo '---' && cat xac", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("abcd"); - expect(result.stdout).toContain("efgh"); - expect(result.stdout).toContain("ij"); - }); - - it("supports K suffix for kilobytes", async () => { - const bash = new Bash({ - files: { - "/test.txt": "a".repeat(2048), - }, - }); - const result = await bash.exec("split -b 1K /test.txt && wc -c xaa"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/1024/); - }); - - it("supports M suffix for megabytes", async () => { - const bash = new Bash({ - files: { - "/test.txt": "a".repeat(100), - }, - }); - // File is smaller than 1M, so it should all be in one chunk - const result = await bash.exec("split -b 1M /test.txt && ls x* | wc -l"); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("1"); - }); - - it("supports attached form -bSIZE", async () => { - const bash = new Bash({ - files: { - "/test.txt": "abcdefghij", - }, - }); - const result = await bash.exec("split -b5 /test.txt && cat xaa"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("abcde"); - }); - - it("errors on invalid byte size", async () => { - const bash = new Bash(); - const result = await bash.exec("echo 'test' | split -b xyz"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid number of bytes"); - }); - }); - - describe("-n option", () => { - it("splits into specified number of chunks", async () => { - const bash = new Bash({ - files: { - "/test.txt": "abcdefghij", - }, - }); - const result = await bash.exec( - "split -n 2 /test.txt && cat xaa && echo '---' && cat xab", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("abcde"); - expect(result.stdout).toContain("fghij"); - }); - - it("handles uneven division", async () => { - const bash = new Bash({ - files: { - "/test.txt": "abcdefg", - }, - }); - const result = await bash.exec("split -n 3 /test.txt uneven_"); - expect(result.exitCode).toBe(0); - // Check that 3 files were created - const aa = await bash.readFile("uneven_aa"); - const ab = await bash.readFile("uneven_ab"); - const ac = await bash.readFile("uneven_ac"); - expect(aa).toBeDefined(); - expect(ab).toBeDefined(); - expect(ac).toBeDefined(); - // Verify content is distributed - expect(aa + ab + ac).toBe("abcdefg"); - }); - - it("supports attached form -nCHUNKS", async () => { - const bash = new Bash({ - files: { - "/test.txt": "abcd", - }, - }); - const result = await bash.exec("split -n2 /test.txt && cat xaa"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("ab"); - }); - - it("errors on invalid chunk count", async () => { - const bash = new Bash(); - const result = await bash.exec("echo 'test' | split -n abc"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid number of chunks"); - }); - }); - - describe("-d option", () => { - it("uses numeric suffixes", async () => { - const bash = new Bash({ - files: { - "/test.txt": "1\n2\n3\n", - }, - }); - const result = await bash.exec("split -d -l 1 /test.txt && ls -1 x*"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("x00"); - expect(result.stdout).toContain("x01"); - expect(result.stdout).toContain("x02"); - }); - - it("works with --numeric-suffixes", async () => { - const bash = new Bash({ - files: { - "/test.txt": "a\nb\n", - }, - }); - const result = await bash.exec( - "split --numeric-suffixes -l 1 /test.txt && ls -1 x*", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("x00"); - expect(result.stdout).toContain("x01"); - }); - }); - - describe("-a option", () => { - it("changes suffix length", async () => { - const bash = new Bash({ - files: { - "/test.txt": "1\n2\n", - }, - }); - const result = await bash.exec("split -a 3 -l 1 /test.txt && ls -1 x*"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("xaaa"); - expect(result.stdout).toContain("xaab"); - }); - - it("works with numeric suffixes", async () => { - const bash = new Bash({ - files: { - "/test.txt": "1\n2\n", - }, - }); - const result = await bash.exec( - "split -a 3 -d -l 1 /test.txt && ls -1 x*", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("x000"); - expect(result.stdout).toContain("x001"); - }); - - it("supports attached form -aLENGTH", async () => { - const bash = new Bash({ - files: { - "/test.txt": "1\n2\n", - }, - }); - const result = await bash.exec("split -a4 -l 1 /test.txt && ls -1 x*"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("xaaaa"); - }); - - it("errors on invalid suffix length", async () => { - const bash = new Bash(); - const result = await bash.exec("echo 'test' | split -a 0"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid suffix length"); - }); - }); - - describe("--additional-suffix option", () => { - it("appends suffix to filenames", async () => { - const bash = new Bash({ - files: { - "/test.txt": "1\n2\n", - }, - }); - const result = await bash.exec( - "split --additional-suffix=.txt -l 1 /test.txt && ls -1 x*.txt", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("xaa.txt"); - expect(result.stdout).toContain("xab.txt"); - }); - }); - - describe("edge cases", () => { - it("handles -- to end options", async () => { - const bash = new Bash({ - files: { - "/-test": "content\n", - }, - }); - const result = await bash.exec("split -l 1 -- /-test && cat xaa"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("content\n"); - }); - - it("handles file with no trailing newline", async () => { - const bash = new Bash({ - files: { - "/test.txt": "line1\nline2", - }, - }); - const result = await bash.exec("split -l 1 /test.txt && cat xab"); - expect(result.exitCode).toBe(0); - // Last chunk preserves original trailing newline behavior (no newline) - expect(result.stdout).toBe("line2"); - }); - }); - - describe("error handling", () => { - it("errors on unknown flag", async () => { - const bash = new Bash(); - const result = await bash.exec("echo 'test' | split -z"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid option"); - }); - - it("errors on unknown long flag", async () => { - const bash = new Bash(); - const result = await bash.exec("echo 'test' | split --unknown"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("unrecognized option"); - }); - - it("errors on missing file", async () => { - const bash = new Bash(); - const result = await bash.exec("split /nonexistent"); - expect(result.exitCode).toBe(1); - expect(result.stderr.toLowerCase()).toContain( - "no such file or directory", - ); - }); - - it("shows help with --help", async () => { - const bash = new Bash(); - const result = await bash.exec("split --help"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("split"); - expect(result.stdout).toContain("Usage"); - }); - }); -}); diff --git a/src/commands/split/split.ts b/src/commands/split/split.ts deleted file mode 100644 index 0fc84e54..00000000 --- a/src/commands/split/split.ts +++ /dev/null @@ -1,415 +0,0 @@ -/** - * split - split a file into pieces - * - * Usage: split [OPTION]... [FILE [PREFIX]] - * - * Output pieces of FILE to PREFIXaa, PREFIXab, ...; - * default size is 1000 lines, and default PREFIX is 'x'. - */ - -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { hasHelpFlag, showHelp, unknownOption } from "../help.js"; - -const splitHelp = { - name: "split", - summary: "split a file into pieces", - usage: "split [OPTION]... [FILE [PREFIX]]", - description: - "Output pieces of FILE to PREFIXaa, PREFIXab, ...; default size is 1000 lines, and default PREFIX is 'x'.", - options: [ - "-l N Put N lines per output file", - "-b SIZE Put SIZE bytes per output file (K, M, G suffixes)", - "-n CHUNKS Split into CHUNKS equal-sized files", - "-d Use numeric suffixes (00, 01, ...) instead of alphabetic", - "-a LENGTH Use suffixes of length LENGTH (default: 2)", - "--additional-suffix=SUFFIX Append SUFFIX to file names", - ], - examples: [ - "split -l 100 file.txt # Split into 100-line chunks", - "split -b 1M file.bin # Split into 1MB chunks", - "split -n 5 file.txt # Split into 5 equal parts", - "split -d file.txt part_ # part_00, part_01, ...", - "split -a 3 -d file.txt x # x000, x001, ...", - ], -}; - -type SplitMode = "lines" | "bytes" | "chunks"; - -interface SplitOptions { - mode: SplitMode; - lines: number; - bytes: number; - chunks: number; - useNumericSuffix: boolean; - suffixLength: number; - additionalSuffix: string; -} - -/** - * Parse a size string like "10K", "1M", "2G" into bytes. - */ -function parseSize(sizeStr: string): number | null { - const match = sizeStr.match(/^(\d+)([KMGTPEZY]?)([B]?)$/i); - if (!match) { - return null; - } - - const num = Number.parseInt(match[1], 10); - if (Number.isNaN(num) || num < 1) { - return null; - } - - const suffix = (match[2] || "").toUpperCase(); - const multipliers = new Map([ - ["", 1], - ["K", 1024], - ["M", 1024 * 1024], - ["G", 1024 * 1024 * 1024], - ["T", 1024 * 1024 * 1024 * 1024], - ["P", 1024 * 1024 * 1024 * 1024 * 1024], - ]); - - const multiplier = multipliers.get(suffix); - if (multiplier === undefined) { - return null; - } - - return num * multiplier; -} - -/** - * Generate suffix for a given index. - * For alphabetic: aa, ab, ..., az, ba, bb, ..., zz, aaa, aab, ... - * For numeric: 00, 01, ..., 99, 000, 001, ... - */ -function generateSuffix( - index: number, - useNumeric: boolean, - length: number, -): string { - if (useNumeric) { - return index.toString().padStart(length, "0"); - } - - // Alphabetic suffix - const chars = "abcdefghijklmnopqrstuvwxyz"; - let suffix = ""; - let remaining = index; - - for (let i = 0; i < length; i++) { - suffix = chars[remaining % 26] + suffix; - remaining = Math.floor(remaining / 26); - } - - return suffix; -} - -/** - * Split content by lines. - */ -function splitByLines( - content: string, - linesPerFile: number, -): { content: string; hasContent: boolean }[] { - const lines = content.split("\n"); - const hasTrailingNewline = - content.endsWith("\n") && lines[lines.length - 1] === ""; - if (hasTrailingNewline) { - lines.pop(); - } - - const chunks: { content: string; hasContent: boolean }[] = []; - - for (let i = 0; i < lines.length; i += linesPerFile) { - const chunkLines = lines.slice(i, i + linesPerFile); - const isLastChunk = i + linesPerFile >= lines.length; - // Add newline after each line, but for the last chunk only if original had trailing newline - const chunkContent = - isLastChunk && !hasTrailingNewline - ? chunkLines.join("\n") - : `${chunkLines.join("\n")}\n`; - chunks.push({ content: chunkContent, hasContent: true }); - } - - return chunks; -} - -/** - * Split content by bytes. - */ -function splitByBytes( - content: string, - bytesPerFile: number, -): { content: string; hasContent: boolean }[] { - const encoder = new TextEncoder(); - const bytes = encoder.encode(content); - const decoder = new TextDecoder(); - const chunks: { content: string; hasContent: boolean }[] = []; - - for (let i = 0; i < bytes.length; i += bytesPerFile) { - const chunkBytes = bytes.slice(i, i + bytesPerFile); - chunks.push({ - content: decoder.decode(chunkBytes), - hasContent: chunkBytes.length > 0, - }); - } - - return chunks; -} - -/** - * Split content into N equal chunks. - */ -function splitIntoChunks( - content: string, - numChunks: number, -): { content: string; hasContent: boolean }[] { - const encoder = new TextEncoder(); - const bytes = encoder.encode(content); - const decoder = new TextDecoder(); - const chunks: { content: string; hasContent: boolean }[] = []; - - const bytesPerChunk = Math.ceil(bytes.length / numChunks); - - for (let i = 0; i < numChunks; i++) { - const start = i * bytesPerChunk; - const end = Math.min(start + bytesPerChunk, bytes.length); - const chunkBytes = bytes.slice(start, end); - chunks.push({ - content: decoder.decode(chunkBytes), - hasContent: chunkBytes.length > 0, - }); - } - - return chunks; -} - -export const split: Command = { - name: "split", - execute: async (args: string[], ctx: CommandContext): Promise => { - if (hasHelpFlag(args)) { - return showHelp(splitHelp); - } - - const options: SplitOptions = { - mode: "lines", - lines: 1000, - bytes: 0, - chunks: 0, - useNumericSuffix: false, - suffixLength: 2, - additionalSuffix: "", - }; - - const positionalArgs: string[] = []; - let i = 0; - - while (i < args.length) { - const arg = args[i]; - - if (arg === "-l" && i + 1 < args.length) { - const lines = Number.parseInt(args[i + 1], 10); - if (Number.isNaN(lines) || lines < 1) { - return { - exitCode: 1, - stdout: "", - stderr: `split: invalid number of lines: '${args[i + 1]}'\n`, - }; - } - options.mode = "lines"; - options.lines = lines; - i += 2; - } else if (arg.match(/^-l\d+$/)) { - const lines = Number.parseInt(arg.slice(2), 10); - if (Number.isNaN(lines) || lines < 1) { - return { - exitCode: 1, - stdout: "", - stderr: `split: invalid number of lines: '${arg.slice(2)}'\n`, - }; - } - options.mode = "lines"; - options.lines = lines; - i++; - } else if (arg === "-b" && i + 1 < args.length) { - const bytes = parseSize(args[i + 1]); - if (bytes === null) { - return { - exitCode: 1, - stdout: "", - stderr: `split: invalid number of bytes: '${args[i + 1]}'\n`, - }; - } - options.mode = "bytes"; - options.bytes = bytes; - i += 2; - } else if (arg.match(/^-b.+$/)) { - const bytes = parseSize(arg.slice(2)); - if (bytes === null) { - return { - exitCode: 1, - stdout: "", - stderr: `split: invalid number of bytes: '${arg.slice(2)}'\n`, - }; - } - options.mode = "bytes"; - options.bytes = bytes; - i++; - } else if (arg === "-n" && i + 1 < args.length) { - const chunks = Number.parseInt(args[i + 1], 10); - if (Number.isNaN(chunks) || chunks < 1) { - return { - exitCode: 1, - stdout: "", - stderr: `split: invalid number of chunks: '${args[i + 1]}'\n`, - }; - } - options.mode = "chunks"; - options.chunks = chunks; - i += 2; - } else if (arg.match(/^-n\d+$/)) { - const chunks = Number.parseInt(arg.slice(2), 10); - if (Number.isNaN(chunks) || chunks < 1) { - return { - exitCode: 1, - stdout: "", - stderr: `split: invalid number of chunks: '${arg.slice(2)}'\n`, - }; - } - options.mode = "chunks"; - options.chunks = chunks; - i++; - } else if (arg === "-a" && i + 1 < args.length) { - const len = Number.parseInt(args[i + 1], 10); - if (Number.isNaN(len) || len < 1) { - return { - exitCode: 1, - stdout: "", - stderr: `split: invalid suffix length: '${args[i + 1]}'\n`, - }; - } - options.suffixLength = len; - i += 2; - } else if (arg.match(/^-a\d+$/)) { - const len = Number.parseInt(arg.slice(2), 10); - if (Number.isNaN(len) || len < 1) { - return { - exitCode: 1, - stdout: "", - stderr: `split: invalid suffix length: '${arg.slice(2)}'\n`, - }; - } - options.suffixLength = len; - i++; - } else if (arg === "-d" || arg === "--numeric-suffixes") { - options.useNumericSuffix = true; - i++; - } else if (arg.startsWith("--additional-suffix=")) { - options.additionalSuffix = arg.slice("--additional-suffix=".length); - i++; - } else if (arg === "--additional-suffix" && i + 1 < args.length) { - options.additionalSuffix = args[i + 1]; - i += 2; - } else if (arg === "--") { - positionalArgs.push(...args.slice(i + 1)); - break; - } else if (arg.startsWith("-") && arg !== "-") { - return unknownOption("split", arg); - } else { - positionalArgs.push(arg); - i++; - } - } - - // Parse positional args: [FILE [PREFIX]] - let inputFile = "-"; - let prefix = "x"; - - if (positionalArgs.length >= 1) { - inputFile = positionalArgs[0]; - } - if (positionalArgs.length >= 2) { - prefix = positionalArgs[1]; - } - - // Read input content - let content: string; - if (inputFile === "-") { - content = ctx.stdin ?? ""; - } else { - const filePath = ctx.fs.resolvePath(ctx.cwd, inputFile); - const fileContent = await ctx.fs.readFile(filePath); - if (fileContent === null) { - return { - exitCode: 1, - stdout: "", - stderr: `split: ${inputFile}: No such file or directory\n`, - }; - } - content = fileContent; - } - - // Handle empty input - if (content === "") { - return { - exitCode: 0, - stdout: "", - stderr: "", - }; - } - - // Split content - let chunks: { content: string; hasContent: boolean }[]; - switch (options.mode) { - case "lines": - chunks = splitByLines(content, options.lines); - break; - case "bytes": - chunks = splitByBytes(content, options.bytes); - break; - case "chunks": - chunks = splitIntoChunks(content, options.chunks); - break; - default: { - const _exhaustive: never = options.mode; - return _exhaustive; - } - } - - // Write output files - for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { - const chunk = chunks[chunkIndex]; - if (!chunk.hasContent) continue; - - const suffix = generateSuffix( - chunkIndex, - options.useNumericSuffix, - options.suffixLength, - ); - const filename = `${prefix}${suffix}${options.additionalSuffix}`; - const filePath = ctx.fs.resolvePath(ctx.cwd, filename); - - await ctx.fs.writeFile(filePath, chunk.content); - } - - return { - exitCode: 0, - stdout: "", - stderr: "", - }; - }, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "split", - flags: [ - { flag: "-l", type: "value", valueHint: "number" }, - { flag: "-b", type: "value", valueHint: "string" }, - { flag: "-n", type: "value", valueHint: "number" }, - { flag: "-d", type: "boolean" }, - { flag: "-a", type: "value", valueHint: "number" }, - ], - needsFiles: true, -}; diff --git a/src/commands/sqlite3/fixtures/datatypes.db b/src/commands/sqlite3/fixtures/datatypes.db deleted file mode 100644 index 82daf425fd8e1a771c2953f3cd1188de35d17952..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8192 zcmeI$u}Z^07zgmXrleY0<7DZgH&ziBAHcSHXz^;=7_bFF5_)3c+CT`lYX@gvz*+DG zM4ZGo(9LIXa&Qy$E~U`TQIP*fF1h>PM@W8Ka@-dEM6&ZJ9s~*7Cd-7h^0qH zk6BSY$<7w~YSzD%MYe{IMcqXmN%Vn&00bZa0SG_<0uX=z1Rwwb2>f4xNrC1{r4pTt zk|0!a&>zcQddlp2yykP}*Xn|^G-8!JWBnd$9Qk~od#vR(nl-P>4taOe>LxGFBBob6Yoe@sMrHL?U>bMe}BG)mEmDFe1$55Cm z6frXK7a&HMD;8!3W+a4=kl6Y+fH+5lgaITZ28QpG&Ub!ydiMKl*WI>55zzZQIq(Es zCufPGoVF+>gyiJVIMOC{y;vhemsL7edZ17D=mS*ts&<}&KmHf%&d2-mBmB|i;Q7;wT zAO0+Tn>A`Xwo7khyNxY}wKm(?cW7m3kd6{eQuHM9fd-Tl1y)OfC@+fB*y_ z009U<00Izz00bZa0SNp%feQ;dDI58GGw}NHfJZ@U?Y()m^6+!>d`>5q4Q0{2-W`OA zu-3oE&)*krYL_&ftQtzbb139_nqBU#=C|YRlkrvcoT`(etk%gYS>unB|6EaYa#1F2 SFBUwup58hcUpaQ>Tlfy#K%fi& diff --git a/src/commands/sqlite3/fixtures/users.db b/src/commands/sqlite3/fixtures/users.db deleted file mode 100644 index dab506abc4769909646f70a2062804b840cd1d68..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI$ziQh+90%|_Nw~3zxq?$fs2#59QsZJ{JQt!Qmtd;8wJSq2nX`Ru1d`R-O7NPz zM5aDPwrrU*XYLF1A$pQ=5q7~#X_xQ8{rUZV(xLlox{qDgj}`wMO+y*;7I{i2-9F%) z5Mtb`7LFRW#t=Oo_L3{s!m71>W>uzkYz+5M&$P=^2nAOHafKmY;|fB*y_009X6 zAAzefePY`-y|!aH4AjL;O=sy~9QB0b3GO+EuHb2kzbbID!- zt47X#V^iW!#%ezM7U;kEfxL*LG#;d1HE5<&@-M%&YFN|>i_HmpWOwXFFVG+W0SG_< z0uX=z1Rwwb2tWV=5cq2X6)Q*V2F)M)aw5mcaG>Tg{2HkGC<@Cj%p7^%qFYDjavJz* zw3xDwyxcW%q*SB%!)Q2+hS}0?g)TOC0)M1rGGxnp*+Ud`V6pFn{a_F5Rxi*X009U< z00Izz00bZa0SG_<0ucDO0?*6_UAr5wY_moGa3Nrp%o<&}2{3ldMt 0) { - lines.push(columns.join(options.separator)); - } - for (const row of rows) { - lines.push( - row - .map((v) => valueToString(v, options.nullValue)) - .join(options.separator), - ); - } - return lines.length > 0 - ? `${lines.join(options.newline)}${options.newline}` - : ""; -} - -/** - * CSV mode: proper CSV escaping - */ -function formatCsv( - columns: string[], - rows: unknown[][], - options: FormatOptions, -): string { - const lines: string[] = []; - if (options.header && columns.length > 0) { - lines.push(columns.map(escapeCsvField).join(",")); - } - for (const row of rows) { - lines.push( - row - .map((v) => escapeCsvField(valueToString(v, options.nullValue))) - .join(","), - ); - } - return lines.length > 0 ? `${lines.join("\n")}\n` : ""; -} - -function escapeCsvField(value: string): string { - // Real sqlite3 wraps in double quotes if value contains comma, quote, newline, or single quote - if ( - value.includes(",") || - value.includes('"') || - value.includes("'") || - value.includes("\n") - ) { - return `"${value.replace(/"/g, '""')}"`; - } - return value; -} - -/** - * Convert float to full precision string (matching real sqlite3) - */ -function floatToFullPrecision(value: number): string { - return value.toPrecision(17).replace(/\.?0+$/, ""); -} - -/** - * Convert value to JSON representation with full float precision - */ -function valueToJson(value: unknown): string { - if (value === null) return "null"; - if (typeof value === "number") { - if (Number.isInteger(value)) return String(value); - return floatToFullPrecision(value); - } - if (typeof value === "string") return JSON.stringify(value); - return JSON.stringify(value); -} - -/** - * JSON mode: array of objects - */ -function formatJson(columns: string[], rows: unknown[][]): string { - if (rows.length === 0) return ""; - - const objects = rows.map((row) => { - const pairs = columns.map( - (col, i) => `${JSON.stringify(col)}:${valueToJson(row[i])}`, - ); - return `{${pairs.join(",")}}`; - }); - - return `[${objects.join(",\n")}]\n`; -} - -/** - * Line mode: column = value per line - */ -function formatLine( - columns: string[], - rows: unknown[][], - options: FormatOptions, -): string { - if (columns.length === 0 || rows.length === 0) return ""; - - // Find max column name length for alignment, minimum 5 chars to match real sqlite3 - const maxColLen = Math.max(5, ...columns.map((c) => c.length)); - - const lines: string[] = []; - for (const row of rows) { - for (let i = 0; i < columns.length; i++) { - // Right-align column name - const paddedCol = columns[i].padStart(maxColLen); - lines.push(`${paddedCol} = ${valueToString(row[i], options.nullValue)}`); - } - } - return `${lines.join("\n")}\n`; -} - -/** - * Column mode: fixed-width columns - */ -function formatColumn( - columns: string[], - rows: unknown[][], - options: FormatOptions, -): string { - if (columns.length === 0) return ""; - - const widths = columns.map((c) => c.length); - for (const row of rows) { - for (let i = 0; i < row.length; i++) { - const len = valueToString(row[i], options.nullValue).length; - if (len > widths[i]) widths[i] = len; - } - } - - const lines: string[] = []; - if (options.header) { - lines.push(columns.map((c, i) => c.padEnd(widths[i])).join(" ")); - lines.push(widths.map((w) => "-".repeat(w)).join(" ")); - } - for (const row of rows) { - lines.push( - row - .map((v, i) => valueToString(v, options.nullValue).padEnd(widths[i])) - .join(" "), - ); - } - return lines.length > 0 ? `${lines.join("\n")}\n` : ""; -} - -/** - * Table mode: ASCII box drawing - */ -function formatTable( - columns: string[], - rows: unknown[][], - options: FormatOptions, -): string { - if (columns.length === 0) return ""; - - const widths = columns.map((c) => c.length); - for (const row of rows) { - for (let i = 0; i < row.length; i++) { - const len = valueToString(row[i], options.nullValue).length; - if (len > widths[i]) widths[i] = len; - } - } - - const lines: string[] = []; - const border = `+${widths.map((w) => "-".repeat(w + 2)).join("+")}+`; - - lines.push(border); - if (options.header) { - lines.push(`| ${columns.map((c, i) => c.padEnd(widths[i])).join(" | ")} |`); - lines.push(border); - } - for (const row of rows) { - lines.push( - `| ${row.map((v, i) => valueToString(v, options.nullValue).padEnd(widths[i])).join(" | ")} |`, - ); - } - lines.push(border); - return `${lines.join("\n")}\n`; -} - -/** - * Markdown mode: markdown table format - */ -function formatMarkdown( - columns: string[], - rows: unknown[][], - options: FormatOptions, -): string { - if (columns.length === 0) return ""; - - const lines: string[] = []; - if (options.header) { - lines.push(`| ${columns.join(" | ")} |`); - lines.push(`|${columns.map(() => "---").join("|")}|`); - } - for (const row of rows) { - lines.push( - `| ${row.map((v) => valueToString(v, options.nullValue)).join(" | ")} |`, - ); - } - return lines.length > 0 ? `${lines.join("\n")}\n` : ""; -} - -/** - * Tabs mode: tab-separated values - */ -function formatTabs( - columns: string[], - rows: unknown[][], - options: FormatOptions, -): string { - const lines: string[] = []; - if (options.header && columns.length > 0) { - lines.push(columns.join("\t")); - } - for (const row of rows) { - lines.push(row.map((v) => valueToString(v, options.nullValue)).join("\t")); - } - return lines.length > 0 - ? `${lines.join(options.newline)}${options.newline}` - : ""; -} - -/** - * Box mode: Unicode box drawing (always shows headers) - */ -function formatBox( - columns: string[], - rows: unknown[][], - options: FormatOptions, -): string { - if (columns.length === 0) return ""; - - const widths = columns.map((c) => c.length); - for (const row of rows) { - for (let i = 0; i < row.length; i++) { - const len = valueToString(row[i], options.nullValue).length; - if (len > widths[i]) widths[i] = len; - } - } - - const lines: string[] = []; - // Top border - lines.push(`┌${widths.map((w) => "─".repeat(w + 2)).join("┬")}┐`); - // Header row - lines.push(`│ ${columns.map((c, i) => c.padEnd(widths[i])).join(" │ ")} │`); - // Header separator - lines.push(`├${widths.map((w) => "─".repeat(w + 2)).join("┼")}┤`); - // Data rows - for (const row of rows) { - lines.push( - `│ ${row.map((v, i) => valueToString(v, options.nullValue).padEnd(widths[i])).join(" │ ")} │`, - ); - } - // Bottom border - lines.push(`└${widths.map((w) => "─".repeat(w + 2)).join("┴")}┘`); - return `${lines.join("\n")}\n`; -} - -/** - * Quote mode: SQL-style quoted values - */ -function formatQuote( - columns: string[], - rows: unknown[][], - options: FormatOptions, -): string { - const lines: string[] = []; - if (options.header && columns.length > 0) { - lines.push(columns.map((c) => `'${c}'`).join(",")); - } - for (const row of rows) { - lines.push( - row - .map((v) => { - if (v === null || v === undefined) return "NULL"; - if (typeof v === "number") { - // Use full precision for floats like real sqlite3 - if (Number.isInteger(v)) return String(v); - return floatToFullPrecision(v); - } - return `'${String(v)}'`; - }) - .join(","), - ); - } - return lines.length > 0 - ? `${lines.join(options.newline)}${options.newline}` - : ""; -} - -/** - * HTML mode: HTML table rows - */ -function formatHtml( - columns: string[], - rows: unknown[][], - options: FormatOptions, -): string { - const lines: string[] = []; - if (options.header && columns.length > 0) { - lines.push( - `${columns.map((c) => `${escapeHtml(c)}`).join("")}`, - ); - lines.push(""); - } - for (const row of rows) { - lines.push( - `${row.map((v) => `${escapeHtml(valueToString(v, options.nullValue))}`).join("")}`, - ); - lines.push(""); - } - return lines.length > 0 ? `${lines.join("\n")}\n` : ""; -} - -function escapeHtml(str: string): string { - return str - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -/** - * ASCII mode: ASCII control character separators - * Uses 0x1F (Unit Separator) between columns and 0x1E (Record Separator) between rows - */ -function formatAscii( - columns: string[], - rows: unknown[][], - options: FormatOptions, -): string { - const colSep = String.fromCharCode(0x1f); // Unit Separator - const rowSep = String.fromCharCode(0x1e); // Record Separator - - const lines: string[] = []; - if (options.header && columns.length > 0) { - lines.push(columns.join(colSep)); - } - for (const row of rows) { - lines.push( - row.map((v) => valueToString(v, options.nullValue)).join(colSep), - ); - } - return lines.length > 0 ? lines.join(rowSep) + rowSep : ""; -} diff --git a/src/commands/sqlite3/samples.sh b/src/commands/sqlite3/samples.sh deleted file mode 100755 index c94eb805..00000000 --- a/src/commands/sqlite3/samples.sh +++ /dev/null @@ -1,245 +0,0 @@ -#!/bin/bash -# Sample sqlite3 commands for comparing output with just-bash implementation - -echo "=== Basic Operations ===" - -echo "--- Create table and query data ---" -sqlite3 :memory: "CREATE TABLE t(x INT); INSERT INTO t VALUES(1),(2),(3); SELECT * FROM t" - -echo "--- Multiple columns ---" -sqlite3 :memory: "CREATE TABLE t(a INT, b TEXT); INSERT INTO t VALUES(1,'x'),(2,'y'); SELECT * FROM t" - -echo "" -echo "=== Output Modes ===" - -echo "--- CSV mode (-csv) ---" -sqlite3 -csv :memory: "CREATE TABLE t(a,b); INSERT INTO t VALUES(1,'hello'),(2,'world'); SELECT * FROM t" - -echo "--- CSV escaping ---" -sqlite3 -csv :memory: "CREATE TABLE t(a); INSERT INTO t VALUES('hello,world'),('has -newline'); SELECT * FROM t" - -echo "--- JSON mode (-json) ---" -sqlite3 -json :memory: "CREATE TABLE t(id INT, name TEXT); INSERT INTO t VALUES(1,'alice'),(2,'bob'); SELECT * FROM t" - -echo "--- Line mode (-line) ---" -sqlite3 -line :memory: "CREATE TABLE t(a INT, b TEXT); INSERT INTO t VALUES(1,'x'); SELECT * FROM t" - -echo "--- Column mode (-column -header) ---" -sqlite3 -column -header :memory: "CREATE TABLE t(id INT, name TEXT); INSERT INTO t VALUES(1,'alice'); SELECT * FROM t" - -echo "--- Table mode (-table -header) ---" -sqlite3 -table -header :memory: "CREATE TABLE t(a INT); INSERT INTO t VALUES(1); SELECT * FROM t" - -echo "--- Markdown mode (-markdown -header) ---" -sqlite3 -markdown -header :memory: "CREATE TABLE t(a INT, b TEXT); INSERT INTO t VALUES(1,'x'); SELECT * FROM t" - -echo "" -echo "=== Header Options ===" - -echo "--- With headers (-header) ---" -sqlite3 -header :memory: "CREATE TABLE t(col1 INT, col2 TEXT); INSERT INTO t VALUES(1,'a'); SELECT * FROM t" - -echo "--- Without headers (-noheader) ---" -sqlite3 -noheader :memory: "CREATE TABLE t(x INT); INSERT INTO t VALUES(1); SELECT * FROM t" - -echo "" -echo "=== Separator Options ===" - -echo "--- Custom separator (comma) ---" -sqlite3 -separator "," :memory: "CREATE TABLE t(a,b); INSERT INTO t VALUES(1,2); SELECT * FROM t" - -echo "--- Tab separator ---" -sqlite3 -separator " " :memory: "CREATE TABLE t(a,b); INSERT INTO t VALUES(1,2); SELECT * FROM t" - -echo "" -echo "=== Null Value Option ===" - -echo "--- Custom null value ---" -sqlite3 -nullvalue "NULL" :memory: "CREATE TABLE t(x); INSERT INTO t VALUES(1),(NULL); SELECT * FROM t" - -echo "" -echo "=== Stdin Input ===" - -echo "--- SQL from stdin ---" -echo "CREATE TABLE t(x); INSERT INTO t VALUES(42); SELECT * FROM t" | sqlite3 :memory: - -echo "" -echo "=== Multiple Statements ===" - -echo "--- Multiple tables and queries ---" -sqlite3 :memory: "CREATE TABLE a(x); CREATE TABLE b(y); INSERT INTO a VALUES(1); INSERT INTO b VALUES(2); SELECT * FROM a; SELECT * FROM b" - -echo "" -echo "=== Data Types ===" - -echo "--- NULL values (JSON) ---" -sqlite3 -json :memory: "CREATE TABLE t(x); INSERT INTO t VALUES(NULL); SELECT * FROM t" - -echo "--- Integers and floats (JSON) ---" -sqlite3 -json :memory: "CREATE TABLE t(i INT, f REAL); INSERT INTO t VALUES(42, 3.14); SELECT * FROM t" - -echo "" -echo "=== Error Handling ===" - -echo "--- SQL syntax error ---" -sqlite3 :memory: "SELEC * FROM t" - -echo "--- Missing table ---" -sqlite3 :memory: "SELECT * FROM nonexistent" - -echo "--- Bail on error (-bail) ---" -sqlite3 -bail :memory: "SELECT * FROM bad; SELECT 1" - -echo "" -echo "=== Priority 1: Quick Wins ===" - -echo "--- Version (-version) ---" -sqlite3 -version - -echo "--- End of options (--) ---" -sqlite3 :memory: -- "SELECT 1 as value" - -echo "--- Tabs mode (-tabs) ---" -sqlite3 -tabs :memory: "CREATE TABLE t(a,b); INSERT INTO t VALUES(1,2),(3,4); SELECT * FROM t" - -echo "" -echo "=== Priority 2: Output Modes ===" - -echo "--- Box mode (-box) ---" -sqlite3 -box :memory: "CREATE TABLE t(id INT, name TEXT); INSERT INTO t VALUES(1,'alice'),(2,'bob'); SELECT * FROM t" - -echo "--- Quote mode (-quote) ---" -sqlite3 -quote :memory: "CREATE TABLE t(a INT, b TEXT); INSERT INTO t VALUES(1,'hello'),(NULL,'world'); SELECT * FROM t" - -echo "--- HTML mode (-html) ---" -sqlite3 -html :memory: "CREATE TABLE t(id INT, name TEXT); INSERT INTO t VALUES(1,'alice'); SELECT * FROM t" - -echo "" -echo "=== Priority 3: Nice to Have ===" - -echo "--- ASCII mode (-ascii) ---" -sqlite3 -ascii :memory: "CREATE TABLE t(a,b); INSERT INTO t VALUES(1,2),(3,4); SELECT * FROM t" - -echo "--- Newline separator (-newline) ---" -sqlite3 -newline '|' :memory: "CREATE TABLE t(x); INSERT INTO t VALUES(1),(2),(3); SELECT * FROM t" - -echo "--- Echo mode (-echo) ---" -sqlite3 -echo :memory: "SELECT 1; SELECT 2" - -echo "--- Cmd option (-cmd) ---" -sqlite3 -cmd "CREATE TABLE t(x); INSERT INTO t VALUES(42)" :memory: "SELECT * FROM t" - -echo "" -echo "=== Error Handling Edge Cases ===" - -echo "--- Missing option argument (-separator as last arg) ---" -sqlite3 :memory: -separator 2>&1 || true - -echo "--- Unknown option ---" -sqlite3 -xyz :memory: "SELECT 1" 2>&1 || true - -echo "--- Unknown long option ---" -sqlite3 --xyz :memory: "SELECT 1" 2>&1 || true - -echo "" -echo "=== SQL Parsing Edge Cases ===" - -echo "--- Semicolon inside string ---" -sqlite3 :memory: "CREATE TABLE t(x); INSERT INTO t VALUES('a;b'); SELECT * FROM t" - -echo "--- Semicolon inside double-quoted identifier ---" -sqlite3 :memory: "CREATE TABLE t(\"col;name\" TEXT); INSERT INTO t VALUES('test'); SELECT * FROM t" - -echo "--- Multiple semicolons in string ---" -sqlite3 :memory: "CREATE TABLE t(x); INSERT INTO t VALUES('a;b;c;d'); SELECT * FROM t" - -echo "--- Escaped single quotes (SQLite style) ---" -sqlite3 :memory: "CREATE TABLE t(x); INSERT INTO t VALUES('it''s'); SELECT * FROM t" - -echo "--- Empty statement (multiple semicolons) ---" -sqlite3 :memory: "SELECT 1;;; SELECT 2" - -echo "--- Statement without trailing semicolon ---" -sqlite3 :memory: "SELECT 42" - -echo "" -echo "=== Formatter Edge Cases ===" - -echo "--- Line mode alignment ---" -sqlite3 -line :memory: "SELECT 1 as aa, 2 as bbbb" - -echo "--- JSON empty result ---" -sqlite3 -json :memory: "CREATE TABLE t(x INT); SELECT * FROM t" - -echo "--- HTML entity escaping ---" -sqlite3 -html :memory: "SELECT '
&
'" - -echo "--- BLOB handling (hex output) ---" -sqlite3 :memory: "SELECT X'48454C4C4F'" - -echo "--- Quote mode with NULL ---" -sqlite3 -quote :memory: "SELECT NULL" - -echo "--- Quote mode with integer ---" -sqlite3 -quote :memory: "SELECT 42" - -echo "--- Quote mode with float ---" -sqlite3 -quote :memory: "SELECT 3.14" - -echo "--- Quote mode with string ---" -sqlite3 -quote :memory: "SELECT 'hello'" - -echo "--- CSV embedded quotes ---" -sqlite3 -csv :memory: "SELECT 'he said ''hello'''" - -echo "--- Box mode single column ---" -sqlite3 -box :memory: "SELECT 42 as value" - -echo "--- Table mode empty result with header ---" -sqlite3 -table -header :memory: "CREATE TABLE t(x); SELECT * FROM t" - -echo "" -echo "=== Write Operations ===" - -echo "--- UPDATE rows ---" -sqlite3 :memory: "CREATE TABLE t(id INT, val TEXT); INSERT INTO t VALUES(1,'a'),(2,'b'); UPDATE t SET val='x' WHERE id=1; SELECT * FROM t ORDER BY id" - -echo "--- DELETE rows ---" -sqlite3 :memory: "CREATE TABLE t(x INT); INSERT INTO t VALUES(1),(2),(3); DELETE FROM t WHERE x=2; SELECT * FROM t ORDER BY x" - -echo "--- DROP TABLE ---" -sqlite3 :memory: "CREATE TABLE t(x); DROP TABLE t; SELECT name FROM sqlite_master WHERE type='table'" - -echo "--- ALTER TABLE RENAME ---" -sqlite3 :memory: "CREATE TABLE old(x); ALTER TABLE old RENAME TO new; SELECT name FROM sqlite_master WHERE type='table'" - -echo "--- ALTER TABLE ADD COLUMN ---" -sqlite3 :memory: "CREATE TABLE t(a INT); INSERT INTO t VALUES(1); ALTER TABLE t ADD COLUMN b TEXT DEFAULT 'x'; SELECT * FROM t" - -echo "--- REPLACE INTO ---" -sqlite3 :memory: "CREATE TABLE t(id INTEGER PRIMARY KEY, val TEXT); INSERT INTO t VALUES(1,'a'); REPLACE INTO t VALUES(1,'b'); SELECT * FROM t" - -echo "" -echo "=== Combined Options ===" - -echo "--- CSV + header + nullvalue ---" -sqlite3 -csv -header -nullvalue "N/A" :memory: "SELECT 1 as a, NULL as b" - -echo "--- JSON + cmd ---" -sqlite3 -json -cmd "CREATE TABLE t(x); INSERT INTO t VALUES(42)" :memory: "SELECT * FROM t" - -echo "--- Echo + header ---" -sqlite3 -echo -header :memory: "SELECT 1 as x" - -echo "" -echo "=== Nullvalue with Different Modes ===" - -echo "--- Nullvalue in list mode ---" -sqlite3 -nullvalue "N/A" :memory: "SELECT NULL, 1" - -echo "--- Nullvalue in CSV mode ---" -sqlite3 -csv -nullvalue "N/A" :memory: "SELECT NULL, 1" - -echo "--- Nullvalue in column mode ---" -sqlite3 -column -nullvalue "NULL" :memory: "SELECT NULL as x" diff --git a/src/commands/sqlite3/sqlite3.errors.test.ts b/src/commands/sqlite3/sqlite3.errors.test.ts deleted file mode 100644 index 7d7dc72a..00000000 --- a/src/commands/sqlite3/sqlite3.errors.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("sqlite3 error handling", () => { - describe("missing option arguments", () => { - it("should error when -separator is last argument", async () => { - const env = new Bash(); - const result = await env.exec("sqlite3 :memory: -separator"); - expect(result.stderr).toBe( - "sqlite3: Error: missing argument to -separator\n", - ); - expect(result.exitCode).toBe(1); - }); - - it("should error when -newline is last argument", async () => { - const env = new Bash(); - const result = await env.exec("sqlite3 :memory: -newline"); - expect(result.stderr).toBe( - "sqlite3: Error: missing argument to -newline\n", - ); - expect(result.exitCode).toBe(1); - }); - - it("should error when -nullvalue is last argument", async () => { - const env = new Bash(); - const result = await env.exec("sqlite3 :memory: -nullvalue"); - expect(result.stderr).toBe( - "sqlite3: Error: missing argument to -nullvalue\n", - ); - expect(result.exitCode).toBe(1); - }); - - it("should error when -cmd is last argument", async () => { - const env = new Bash(); - const result = await env.exec("sqlite3 :memory: -cmd"); - expect(result.stderr).toBe("sqlite3: Error: missing argument to -cmd\n"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("missing required arguments", () => { - it("should error when no SQL provided", async () => { - const env = new Bash(); - const result = await env.exec("sqlite3 :memory:"); - expect(result.stderr).toContain("no SQL provided"); - expect(result.exitCode).toBe(1); - }); - - it("should error when no database argument", async () => { - const env = new Bash(); - const result = await env.exec("sqlite3"); - expect(result.stderr).toContain("missing database argument"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("SQL errors without -bail", () => { - it("should continue after error and return exit code 0", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 :memory: "SELECT * FROM nonexistent; SELECT 42"', - ); - expect(result.stdout).toContain("Error:"); - expect(result.stdout).toContain("no such table"); - expect(result.stdout).toContain("42"); - expect(result.exitCode).toBe(0); - }); - - it("should handle multiple errors", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 :memory: "SELECT * FROM bad1; SELECT * FROM bad2; SELECT 1"', - ); - expect(result.stdout).toMatch(/Error:.*bad1/); - expect(result.stdout).toMatch(/Error:.*bad2/); - expect(result.stdout).toContain("1"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("SQL errors with -bail", () => { - it("should stop on first error and return exit code 1", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -bail :memory: "SELECT * FROM bad1; SELECT * FROM bad2"', - ); - expect(result.stderr).toContain("bad1"); - expect(result.stderr).not.toContain("bad2"); - expect(result.exitCode).toBe(1); - }); - - it("should include partial output before error", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -bail :memory: "SELECT 1; SELECT * FROM bad; SELECT 2"', - ); - expect(result.stdout).toContain("1"); - expect(result.stdout).not.toContain("2"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("invalid options", () => { - it("should error on unknown short option", async () => { - const env = new Bash(); - const result = await env.exec('sqlite3 -xyz :memory: "SELECT 1"'); - expect(result.stderr).toBe( - "sqlite3: Error: unknown option: -xyz\nUse -help for a list of options.\n", - ); - expect(result.exitCode).toBe(1); - }); - - it("should error on unknown option starting with double dash", async () => { - const env = new Bash(); - const result = await env.exec('sqlite3 --xyz :memory: "SELECT 1"'); - // Real sqlite3 treats --xyz as -xyz - expect(result.stderr).toBe( - "sqlite3: Error: unknown option: -xyz\nUse -help for a list of options.\n", - ); - expect(result.exitCode).toBe(1); - }); - }); - - describe("security", () => { - it("should block load_extension SQL function", async () => { - const env = new Bash(); - const result = await env.exec( - `sqlite3 :memory: "SELECT load_extension('/tmp/evil.so')"`, - ); - // better-sqlite3 disables load_extension by default for security - expect(result.stdout).toContain("Error:"); - expect(result.stdout).toMatch(/not authorized|no such function/i); - }); - - it("should block load_extension with entry point", async () => { - const env = new Bash(); - const result = await env.exec( - `sqlite3 :memory: "SELECT load_extension('/tmp/evil.so', 'sqlite3_evil_init')"`, - ); - expect(result.stdout).toContain("Error:"); - expect(result.stdout).toMatch(/not authorized|no such function/i); - }); - }); -}); diff --git a/src/commands/sqlite3/sqlite3.fixtures.test.ts b/src/commands/sqlite3/sqlite3.fixtures.test.ts deleted file mode 100644 index 2e310914..00000000 --- a/src/commands/sqlite3/sqlite3.fixtures.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; -import { OverlayFs } from "../../fs/overlay-fs/overlay-fs.js"; - -const fixturesDir = path.join(import.meta.dirname, "fixtures"); - -describe("sqlite3 with fixtures", () => { - describe("users.db", () => { - it("should query all users", async () => { - const fs = new OverlayFs({ root: fixturesDir }); - const env = new Bash({ fs, cwd: fs.getMountPoint() }); - - const result = await env.exec('sqlite3 users.db "SELECT * FROM users"'); - expect(result.stdout).toBe( - "1|Alice|alice@example.com|30|1\n" + - "2|Bob|bob@example.com|25|1\n" + - "3|Charlie|charlie@example.com|35|0\n" + - "4|Diana|diana@example.com|28|1\n", - ); - expect(result.exitCode).toBe(0); - }); - - it("should filter users with WHERE clause", async () => { - const fs = new OverlayFs({ root: fixturesDir }); - const env = new Bash({ fs, cwd: fs.getMountPoint() }); - - const result = await env.exec( - 'sqlite3 users.db "SELECT name, age FROM users WHERE active = 1 ORDER BY age"', - ); - expect(result.stdout).toBe("Bob|25\nDiana|28\nAlice|30\n"); - expect(result.exitCode).toBe(0); - }); - - it("should aggregate users with COUNT", async () => { - const fs = new OverlayFs({ root: fixturesDir }); - const env = new Bash({ fs, cwd: fs.getMountPoint() }); - - const result = await env.exec( - 'sqlite3 users.db "SELECT COUNT(*) FROM users WHERE active = 1"', - ); - expect(result.stdout).toBe("3\n"); - expect(result.exitCode).toBe(0); - }); - - it("should output users as JSON", async () => { - const fs = new OverlayFs({ root: fixturesDir }); - const env = new Bash({ fs, cwd: fs.getMountPoint() }); - - const result = await env.exec( - 'sqlite3 -json users.db "SELECT id, name FROM users WHERE id = 1"', - ); - expect(result.stdout).toBe('[{"id":1,"name":"Alice"}]\n'); - expect(result.exitCode).toBe(0); - }); - }); - - describe("products.db", () => { - it("should query all products", async () => { - const fs = new OverlayFs({ root: fixturesDir }); - const env = new Bash({ fs, cwd: fs.getMountPoint() }); - - const result = await env.exec( - 'sqlite3 products.db "SELECT name, price FROM products ORDER BY price DESC"', - ); - // Full IEEE 754 precision for floats - expect(result.stdout).toBe( - "Laptop|999.99000000000001\nPhone|699.5\nHeadphones|149.99000000000001\nPython Book|49.990000000000002\nT-Shirt|19.989999999999998\n", - ); - expect(result.exitCode).toBe(0); - }); - - it("should join products with categories", async () => { - const fs = new OverlayFs({ root: fixturesDir }); - const env = new Bash({ fs, cwd: fs.getMountPoint() }); - - const result = await env.exec( - "sqlite3 products.db \"SELECT p.name, c.name FROM products p JOIN categories c ON p.category_id = c.id WHERE c.name = 'Electronics' ORDER BY p.name\"", - ); - expect(result.stdout).toBe( - "Headphones|Electronics\nLaptop|Electronics\nPhone|Electronics\n", - ); - expect(result.exitCode).toBe(0); - }); - - it("should calculate sum with GROUP BY", async () => { - const fs = new OverlayFs({ root: fixturesDir }); - const env = new Bash({ fs, cwd: fs.getMountPoint() }); - - const result = await env.exec( - 'sqlite3 -header products.db "SELECT c.name, SUM(p.price) as total FROM products p JOIN categories c ON p.category_id = c.id GROUP BY c.name ORDER BY total DESC"', - ); - expect(result.stdout).toContain("Electronics"); - expect(result.stdout).toContain("1849.48"); // 999.99 + 699.5 + 149.99 - expect(result.exitCode).toBe(0); - }); - - it("should output products in box mode", async () => { - const fs = new OverlayFs({ root: fixturesDir }); - const env = new Bash({ fs, cwd: fs.getMountPoint() }); - - const result = await env.exec( - 'sqlite3 -box products.db "SELECT id, name FROM products WHERE id <= 2"', - ); - expect(result.stdout).toContain("┌"); - expect(result.stdout).toContain("│"); - expect(result.stdout).toContain("Laptop"); - expect(result.stdout).toContain("Phone"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("datatypes.db", () => { - it("should handle NULL values", async () => { - const fs = new OverlayFs({ root: fixturesDir }); - const env = new Bash({ fs, cwd: fs.getMountPoint() }); - - const result = await env.exec( - 'sqlite3 -nullvalue "NULL" datatypes.db "SELECT int_val, real_val, text_val FROM mixed WHERE id = 2"', - ); - expect(result.stdout).toBe("NULL|NULL|NULL\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle different numeric types", async () => { - const fs = new OverlayFs({ root: fixturesDir }); - const env = new Bash({ fs, cwd: fs.getMountPoint() }); - - const result = await env.exec( - 'sqlite3 -json datatypes.db "SELECT int_val, real_val FROM mixed WHERE id IN (1, 3, 4) ORDER BY id"', - ); - const parsed = JSON.parse(result.stdout); - expect(parsed).toHaveLength(3); - expect(parsed[0]).toEqual({ int_val: 42, real_val: 3.14 }); - expect(parsed[1]).toEqual({ int_val: -100, real_val: 0.001 }); - expect(parsed[2]).toEqual({ int_val: 0, real_val: -99.99 }); - expect(result.exitCode).toBe(0); - }); - - it("should handle empty strings vs NULL", async () => { - const fs = new OverlayFs({ root: fixturesDir }); - const env = new Bash({ fs, cwd: fs.getMountPoint() }); - - const result = await env.exec( - 'sqlite3 -nullvalue "NULL" datatypes.db "SELECT id, text_val FROM mixed WHERE text_val IS NULL OR text_val = \'\' ORDER BY id"', - ); - expect(result.stdout).toBe("2|NULL\n4|\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("read-only access", () => { - it("should not modify fixture with -readonly", async () => { - const fs = new OverlayFs({ root: fixturesDir }); - const env = new Bash({ fs, cwd: fs.getMountPoint() }); - - // Try to insert with readonly - await env.exec( - "sqlite3 -readonly users.db \"INSERT INTO users (name, email, age) VALUES ('Test', 'test@test.com', 99)\"", - ); - - // Verify original data unchanged - const result = await env.exec( - 'sqlite3 users.db "SELECT COUNT(*) FROM users"', - ); - expect(result.stdout).toBe("4\n"); - expect(result.exitCode).toBe(0); - }); - - it("should allow writes to overlay (not persisted to disk)", async () => { - const fs = new OverlayFs({ root: fixturesDir }); - const env = new Bash({ fs, cwd: fs.getMountPoint() }); - - // Insert new user - await env.exec( - "sqlite3 users.db \"INSERT INTO users (name, email, age) VALUES ('Test', 'test@test.com', 99)\"", - ); - - // Should see new user in same session - const result = await env.exec( - "sqlite3 users.db \"SELECT name FROM users WHERE email = 'test@test.com'\"", - ); - expect(result.stdout).toBe("Test\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("schema queries", () => { - it("should list tables", async () => { - const fs = new OverlayFs({ root: fixturesDir }); - const env = new Bash({ fs, cwd: fs.getMountPoint() }); - - const result = await env.exec( - "sqlite3 products.db \"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name\"", - ); - expect(result.stdout).toBe("categories\nproducts\n"); - expect(result.exitCode).toBe(0); - }); - - it("should describe table schema", async () => { - const fs = new OverlayFs({ root: fixturesDir }); - const env = new Bash({ fs, cwd: fs.getMountPoint() }); - - const result = await env.exec( - 'sqlite3 users.db "PRAGMA table_info(users)"', - ); - expect(result.stdout).toContain("id"); - expect(result.stdout).toContain("name"); - expect(result.stdout).toContain("email"); - expect(result.stdout).toContain("INTEGER"); - expect(result.stdout).toContain("TEXT"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/commands/sqlite3/sqlite3.formatters.test.ts b/src/commands/sqlite3/sqlite3.formatters.test.ts deleted file mode 100644 index da139d99..00000000 --- a/src/commands/sqlite3/sqlite3.formatters.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("sqlite3 formatters", () => { - describe("list mode (default)", () => { - it("should output pipe-separated by default", async () => { - const env = new Bash(); - const result = await env.exec('sqlite3 :memory: "SELECT 1, 2, 3"'); - expect(result.stdout).toBe("1|2|3\n"); - expect(result.exitCode).toBe(0); - }); - - it("should output list mode explicitly with -list", async () => { - const env = new Bash(); - const result = await env.exec('sqlite3 -list :memory: "SELECT 1, 2, 3"'); - expect(result.stdout).toBe("1|2|3\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle list mode with header", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -list -header :memory: "SELECT 1 as a, 2 as b"', - ); - expect(result.stdout).toBe("a|b\n1|2\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("CSV edge cases", () => { - it("should escape embedded quotes", async () => { - const env = new Bash(); - // Use SQLite's quote escaping (double single quotes) - const result = await env.exec( - "sqlite3 -csv :memory: \"SELECT 'he said ''hello'''\"", - ); - // Real sqlite3 wraps strings containing quotes in double quotes - expect(result.stdout).toBe("\"he said 'hello'\"\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle CSV with header", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -csv -header :memory: "SELECT 1 as col1, 2 as col2"', - ); - expect(result.stdout).toBe("col1,col2\n1,2\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle empty values in CSV", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -csv :memory: \"SELECT '', 'x', ''\"", - ); - expect(result.stdout).toBe(",x,\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("JSON edge cases", () => { - it("should handle empty result as empty output", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -json :memory: "CREATE TABLE t(x INT); SELECT * FROM t"', - ); - // Real sqlite3 outputs nothing for empty results in JSON mode - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should handle special characters in JSON", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -json :memory: \"SELECT 'line1\nline2' as x\"", - ); - const parsed = JSON.parse(result.stdout); - expect(parsed[0].x).toBe("line1\nline2"); - expect(result.exitCode).toBe(0); - }); - - it("should handle boolean-like values", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -json :memory: "SELECT 1 as t, 0 as f"', - ); - const parsed = JSON.parse(result.stdout); - expect(parsed[0]).toEqual({ t: 1, f: 0 }); - expect(result.exitCode).toBe(0); - }); - }); - - describe("HTML edge cases", () => { - it("should escape ampersand", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -html :memory: \"SELECT 'a & b'\"", - ); - expect(result.stdout).toContain("a & b"); - expect(result.exitCode).toBe(0); - }); - - it("should escape all HTML entities", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -html :memory: \"SELECT '
&
'\"", - ); - expect(result.stdout).toContain("<div"); - expect(result.stdout).toContain("&"); - expect(result.stdout).toContain(">"); - expect(result.exitCode).toBe(0); - }); - - it("should output header row with TH tags", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -html -header :memory: "SELECT 1 as col1, 2 as col2"', - ); - expect(result.stdout).toContain("col1"); - expect(result.stdout).toContain("col2"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("line mode edge cases", () => { - it("should align column names", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -line :memory: "SELECT 1 as aa, 2 as bbbb"', - ); - // With min width 5, aa becomes " aa" and bbbb becomes " bbbb" - expect(result.stdout).toContain("aa = 1"); - expect(result.stdout).toContain("bbbb = 2"); - // Verify alignment - both should have = at same position - // Don't use trim() as it removes leading spaces from first line - const lines = result.stdout.split("\n").filter((l) => l.length > 0); - const line1 = lines[0]; - const line2 = lines[1]; - expect(line1.indexOf("=")).toBe(line2.indexOf("=")); - expect(result.exitCode).toBe(0); - }); - - it("should handle multiple rows in line mode", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -line :memory: "SELECT 1 as x UNION SELECT 2"', - ); - expect(result.stdout).toContain("x = 1"); - expect(result.stdout).toContain("x = 2"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("column mode edge cases", () => { - it("should handle wide values", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -column -header :memory: \"SELECT 'short' as a, 'this is a very long value' as b\"", - ); - expect(result.stdout).toContain("short"); - expect(result.stdout).toContain("this is a very long value"); - expect(result.exitCode).toBe(0); - }); - - it("should show separator line with header", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -column -header :memory: "SELECT 1 as aa, 2 as bbbb"', - ); - expect(result.stdout).toContain("--"); - expect(result.stdout).toContain("----"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("table mode edge cases", () => { - it("should handle empty result in table mode", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -table -header :memory: "CREATE TABLE t(x); SELECT * FROM t"', - ); - // Should still show table structure with header - expect(result.stdout).toContain("+"); - expect(result.stdout).toContain("x"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("box mode edge cases", () => { - it("should handle single column in box mode", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -box :memory: "SELECT 42 as value"', - ); - expect(result.stdout).toContain("┌"); - expect(result.stdout).toContain("└"); - expect(result.stdout).toContain("value"); - expect(result.stdout).toContain("42"); - expect(result.exitCode).toBe(0); - }); - - it("should handle wide content in box mode", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -box :memory: \"SELECT 'this is a long string' as col\"", - ); - expect(result.stdout).toContain("this is a long string"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("quote mode edge cases", () => { - it("should show integers without quotes", async () => { - const env = new Bash(); - const result = await env.exec('sqlite3 -quote :memory: "SELECT 42"'); - expect(result.stdout).toBe("42\n"); - expect(result.exitCode).toBe(0); - }); - - it("should show floats without quotes", async () => { - const env = new Bash(); - const result = await env.exec('sqlite3 -quote :memory: "SELECT 3.14"'); - // Full IEEE 754 precision like real sqlite3 - expect(result.stdout).toBe("3.1400000000000001\n"); - expect(result.exitCode).toBe(0); - }); - - it("should quote strings", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -quote :memory: \"SELECT 'hello'\"", - ); - expect(result.stdout).toBe("'hello'\n"); - expect(result.exitCode).toBe(0); - }); - - it("should show NULL as NULL keyword", async () => { - const env = new Bash(); - const result = await env.exec('sqlite3 -quote :memory: "SELECT NULL"'); - expect(result.stdout).toBe("NULL\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("nullvalue with different modes", () => { - it("should apply nullvalue in list mode", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -nullvalue "N/A" :memory: "SELECT NULL, 1"', - ); - expect(result.stdout).toBe("N/A|1\n"); - expect(result.exitCode).toBe(0); - }); - - it("should apply nullvalue in CSV mode", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -csv -nullvalue "N/A" :memory: "SELECT NULL, 1"', - ); - expect(result.stdout).toBe("N/A,1\n"); - expect(result.exitCode).toBe(0); - }); - - it("should apply nullvalue in column mode", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -column -nullvalue "NULL" :memory: "SELECT NULL as x"', - ); - expect(result.stdout).toContain("NULL"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("BLOB handling", () => { - it("should output BLOB as decoded text", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 :memory: \"SELECT X'48454C4C4F'\"", - ); - // Real sqlite3 outputs BLOB as decoded text - expect(result.stdout).toBe("HELLO\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("combined options", () => { - it("should combine -csv -header -nullvalue", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -csv -header -nullvalue "N/A" :memory: "SELECT 1 as a, NULL as b"', - ); - expect(result.stdout).toBe("a,b\n1,N/A\n"); - expect(result.exitCode).toBe(0); - }); - - it("should combine -json with -cmd", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -json -cmd "CREATE TABLE t(x); INSERT INTO t VALUES(42)" :memory: "SELECT * FROM t"', - ); - expect(result.stdout).toBe('[{"x":42}]\n'); - expect(result.exitCode).toBe(0); - }); - - it("should combine -echo with -header", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -echo -header :memory: "SELECT 1 as x"', - ); - expect(result.stdout).toContain("SELECT 1 as x"); - expect(result.stdout).toContain("x"); - expect(result.stdout).toContain("1"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/commands/sqlite3/sqlite3.options.test.ts b/src/commands/sqlite3/sqlite3.options.test.ts deleted file mode 100644 index 86d43170..00000000 --- a/src/commands/sqlite3/sqlite3.options.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("sqlite3 options", () => { - describe("-version", () => { - it("should show SQLite version", async () => { - const env = new Bash(); - const result = await env.exec("sqlite3 -version"); - expect(result.stdout).toMatch(/^\d+\.\d+\.\d+/); - expect(result.exitCode).toBe(0); - }); - }); - - describe("-- end of options", () => { - it("should treat arguments after -- as positional", async () => { - const env = new Bash(); - const result = await env.exec('sqlite3 :memory: -- "SELECT 1 as value"'); - expect(result.stdout).toBe("1\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("-newline", () => { - it("should use custom row separator", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -newline '|' :memory: \"CREATE TABLE t(x); INSERT INTO t VALUES(1),(2),(3); SELECT * FROM t\"", - ); - expect(result.stdout).toBe("1|2|3|"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("-echo", () => { - it("should print SQL before execution", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -echo :memory: "SELECT 1; SELECT 2"', - ); - expect(result.stdout).toContain("SELECT 1; SELECT 2"); - expect(result.stdout).toContain("1"); - expect(result.stdout).toContain("2"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("-cmd", () => { - it("should run SQL command before main SQL", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -cmd "CREATE TABLE t(x); INSERT INTO t VALUES(42)" :memory: "SELECT * FROM t"', - ); - expect(result.stdout).toBe("42\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("-header / -noheader", () => { - it("should show headers with -header", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -header :memory: \"CREATE TABLE t(col1 INT, col2 TEXT); INSERT INTO t VALUES(1,'a'); SELECT * FROM t\"", - ); - expect(result.stdout).toBe("col1|col2\n1|a\n"); - expect(result.exitCode).toBe(0); - }); - - it("should hide headers with -noheader", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -noheader :memory: "CREATE TABLE t(x INT); INSERT INTO t VALUES(1); SELECT * FROM t"', - ); - expect(result.stdout).toBe("1\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("-separator", () => { - it("should use custom separator", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -separator "," :memory: "CREATE TABLE t(a,b); INSERT INTO t VALUES(1,2); SELECT * FROM t"', - ); - expect(result.stdout).toBe("1,2\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("-nullvalue", () => { - it("should display custom null value", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -nullvalue "NULL" :memory: "CREATE TABLE t(x); INSERT INTO t VALUES(1),(NULL); SELECT * FROM t"', - ); - expect(result.stdout).toBe("1\nNULL\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("-readonly", () => { - it("should not persist changes with -readonly", async () => { - const env = new Bash(); - await env.exec( - 'sqlite3 /ro.db "CREATE TABLE t(x INT); INSERT INTO t VALUES(1)"', - ); - await env.exec('sqlite3 -readonly /ro.db "INSERT INTO t VALUES(2)"'); - const result = await env.exec('sqlite3 /ro.db "SELECT * FROM t"'); - expect(result.stdout).toBe("1\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("-bail", () => { - it("should stop on first error with -bail", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -bail :memory: "SELECT * FROM bad; SELECT 1"', - ); - expect(result.stderr).toContain("no such table"); - expect(result.stdout).not.toContain("1"); - expect(result.exitCode).toBe(1); - }); - }); -}); diff --git a/src/commands/sqlite3/sqlite3.output-modes.test.ts b/src/commands/sqlite3/sqlite3.output-modes.test.ts deleted file mode 100644 index 5e5023b9..00000000 --- a/src/commands/sqlite3/sqlite3.output-modes.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("sqlite3 output modes", () => { - describe("basic modes", () => { - it("should output CSV with -csv", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -csv :memory: \"CREATE TABLE t(a,b); INSERT INTO t VALUES(1,'hello'),(2,'world'); SELECT * FROM t\"", - ); - expect(result.stdout).toBe("1,hello\n2,world\n"); - expect(result.exitCode).toBe(0); - }); - - it("should escape CSV fields properly", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -csv :memory: \"CREATE TABLE t(a); INSERT INTO t VALUES('hello,world'),('has\nnewline'); SELECT * FROM t\"", - ); - expect(result.stdout).toBe('"hello,world"\n"has\nnewline"\n'); - expect(result.exitCode).toBe(0); - }); - - it("should output JSON with -json", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -json :memory: \"CREATE TABLE t(id INT, name TEXT); INSERT INTO t VALUES(1,'alice'),(2,'bob'); SELECT * FROM t\"", - ); - expect(result.stdout).toBe( - '[{"id":1,"name":"alice"},\n{"id":2,"name":"bob"}]\n', - ); - expect(result.exitCode).toBe(0); - }); - - it("should output line mode with -line", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -line :memory: \"CREATE TABLE t(a INT, b TEXT); INSERT INTO t VALUES(1,'x'); SELECT * FROM t\"", - ); - expect(result.stdout).toBe(" a = 1\n b = x\n"); - expect(result.exitCode).toBe(0); - }); - - it("should output column mode with -column", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -column -header :memory: \"CREATE TABLE t(id INT, name TEXT); INSERT INTO t VALUES(1,'alice'); SELECT * FROM t\"", - ); - expect(result.stdout).toContain("id"); - expect(result.stdout).toContain("name"); - expect(result.stdout).toContain("alice"); - expect(result.exitCode).toBe(0); - }); - - it("should output table mode with -table", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -table -header :memory: "CREATE TABLE t(a INT); INSERT INTO t VALUES(1); SELECT * FROM t"', - ); - expect(result.stdout).toContain("+"); - expect(result.stdout).toContain("|"); - expect(result.exitCode).toBe(0); - }); - - it("should output markdown with -markdown", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -markdown -header :memory: \"CREATE TABLE t(a INT, b TEXT); INSERT INTO t VALUES(1,'x'); SELECT * FROM t\"", - ); - expect(result.stdout).toContain("| a | b |"); - expect(result.stdout).toContain("|---|---|"); - expect(result.stdout).toContain("| 1 | x |"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("tabs mode", () => { - it("should output tab-separated values with -tabs", async () => { - const env = new Bash(); - const result = await env.exec( - 'sqlite3 -tabs :memory: "CREATE TABLE t(a,b); INSERT INTO t VALUES(1,2),(3,4); SELECT * FROM t"', - ); - expect(result.stdout).toBe("1\t2\n3\t4\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("box mode", () => { - it("should output Unicode box drawing with -box", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -box :memory: \"CREATE TABLE t(id INT, name TEXT); INSERT INTO t VALUES(1,'alice'),(2,'bob'); SELECT * FROM t\"", - ); - expect(result.stdout).toContain("┌"); - expect(result.stdout).toContain("│"); - expect(result.stdout).toContain("└"); - expect(result.stdout).toContain("id"); - expect(result.stdout).toContain("alice"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("quote mode", () => { - it("should output SQL-style quoted values with -quote", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -quote :memory: \"CREATE TABLE t(a INT, b TEXT); INSERT INTO t VALUES(1,'hello'),(NULL,'world'); SELECT * FROM t\"", - ); - expect(result.stdout).toBe("1,'hello'\nNULL,'world'\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("html mode", () => { - it("should output HTML table rows with -html", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -html :memory: \"CREATE TABLE t(id INT, name TEXT); INSERT INTO t VALUES(1,'alice'); SELECT * FROM t\"", - ); - expect(result.stdout).toContain(""); - expect(result.stdout).toContain("1"); - expect(result.stdout).toContain("alice"); - expect(result.stdout).toContain(""); - expect(result.exitCode).toBe(0); - }); - - it("should escape HTML entities", async () => { - const env = new Bash(); - const result = await env.exec( - "sqlite3 -html :memory: \"CREATE TABLE t(x); INSERT INTO t VALUES(']]> - <tag> & "quotes" - Hello 世界 - Text bold more text - - namespaced content - - 42 - 3.14 - -17 - - - - - deep value - - - - - first - second - third - - diff --git a/src/commands/yq/fixtures/xml/users.xml b/src/commands/yq/fixtures/xml/users.xml deleted file mode 100644 index dfbf6263..00000000 --- a/src/commands/yq/fixtures/xml/users.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - alice - 30 - alice@example.com - - - bob - 25 - bob@example.com - - - charlie - 35 - charlie@example.com - - - - 1.0 - 2024-01-01 - - diff --git a/src/commands/yq/fixtures/yaml/simple.yaml b/src/commands/yq/fixtures/yaml/simple.yaml deleted file mode 100644 index c0815e0b..00000000 --- a/src/commands/yq/fixtures/yaml/simple.yaml +++ /dev/null @@ -1,7 +0,0 @@ -title: Simple Document -count: 42 -enabled: true -tags: - - important - - featured - - new diff --git a/src/commands/yq/fixtures/yaml/special.yaml b/src/commands/yq/fixtures/yaml/special.yaml deleted file mode 100644 index 8a6aad48..00000000 --- a/src/commands/yq/fixtures/yaml/special.yaml +++ /dev/null @@ -1,38 +0,0 @@ -# YAML special cases -empty_string: "" -null_value: null -multiline: | - This is a - multiline string - with preserved newlines -folded: > - This is a folded - string that will be - joined into one line -unicode: "Hello \u4e16\u754c" -special_chars: "quotes: \"nested\" and 'single'" -numbers: - integer: 42 - float: 3.14159 - negative: -17 - scientific: 1.5e10 - infinity: .inf - nan: .nan -booleans: - yes_value: yes - no_value: no - on_value: on - off_value: off - true_value: true - false_value: false -dates: - iso_date: 2024-01-15 - datetime: 2024-01-15T10:30:00Z -nested_arrays: - - - 1 - - 2 - - - 3 - - 4 -anchor: &anchor_name - shared: data -reference: *anchor_name diff --git a/src/commands/yq/fixtures/yaml/users.yaml b/src/commands/yq/fixtures/yaml/users.yaml deleted file mode 100644 index a662cc6b..00000000 --- a/src/commands/yq/fixtures/yaml/users.yaml +++ /dev/null @@ -1,16 +0,0 @@ -users: - - name: alice - age: 30 - email: alice@example.com - active: true - - name: bob - age: 25 - email: bob@example.com - active: false - - name: charlie - age: 35 - email: charlie@example.com - active: true -metadata: - version: 1.0 - generated: "2024-01-01" diff --git a/src/commands/yq/formats.ts b/src/commands/yq/formats.ts deleted file mode 100644 index e9a91f3b..00000000 --- a/src/commands/yq/formats.ts +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Format parsing and output for yq command - * - * Supports YAML, JSON, XML, INI, CSV, and TOML formats with conversion between them. - */ - -import { XMLBuilder, XMLParser } from "fast-xml-parser"; -import * as ini from "ini"; -import Papa from "papaparse"; -import * as TOML from "smol-toml"; -import YAML from "yaml"; -import type { QueryValue } from "../query-engine/index.js"; - -export type InputFormat = "yaml" | "xml" | "json" | "ini" | "csv" | "toml"; -export type OutputFormat = "yaml" | "json" | "xml" | "ini" | "csv" | "toml"; - -const validInputFormats = [ - "yaml", - "xml", - "json", - "ini", - "csv", - "toml", -] as const; -const validOutputFormats = [ - "yaml", - "json", - "xml", - "ini", - "csv", - "toml", -] as const; - -/** - * Type guard to validate input format strings at runtime - */ -export function isValidInputFormat(value: unknown): value is InputFormat { - return ( - typeof value === "string" && - validInputFormats.includes(value as InputFormat) - ); -} - -/** - * Type guard to validate output format strings at runtime - */ -export function isValidOutputFormat(value: unknown): value is OutputFormat { - return ( - typeof value === "string" && - validOutputFormats.includes(value as OutputFormat) - ); -} - -export interface FormatOptions { - /** Input format (default: yaml) */ - inputFormat: InputFormat; - /** Output format (default: yaml) */ - outputFormat: OutputFormat; - /** Output raw strings without quotes (json only) */ - raw: boolean; - /** Compact output (json only) */ - compact: boolean; - /** Pretty print output */ - prettyPrint: boolean; - /** Indentation level */ - indent: number; - /** XML attribute prefix (default: +@) */ - xmlAttributePrefix: string; - /** XML text content name (default: +content) */ - xmlContentName: string; - /** CSV delimiter (empty = auto-detect) */ - csvDelimiter: string; - /** CSV has header row */ - csvHeader: boolean; -} - -export const defaultFormatOptions: FormatOptions = { - inputFormat: "yaml", - outputFormat: "yaml", - raw: false, - compact: false, - prettyPrint: false, - indent: 2, - xmlAttributePrefix: "+@", - xmlContentName: "+content", - csvDelimiter: "", - csvHeader: true, -}; - -/** - * Extract file extension (browser-compatible alternative to node:path extname) - */ -function getExtension(filename: string): string { - const lastDot = filename.lastIndexOf("."); - const lastSlash = Math.max( - filename.lastIndexOf("/"), - filename.lastIndexOf("\\"), - ); - if (lastDot <= lastSlash + 1) return ""; - return filename.slice(lastDot); -} - -/** - * Detect input format from file extension - */ -export function detectFormatFromExtension( - filename: string, -): InputFormat | null { - const ext = getExtension(filename).toLowerCase(); - switch (ext) { - case ".yaml": - case ".yml": - return "yaml"; - case ".json": - return "json"; - case ".xml": - return "xml"; - case ".ini": - return "ini"; - case ".csv": - case ".tsv": - return "csv"; - case ".toml": - return "toml"; - default: - return null; - } -} - -/** - * Parse CSV into array of objects (if header) or array of arrays - */ -function parseCsv( - input: string, - delimiter: string, - hasHeader: boolean, -): unknown[] { - const result = Papa.parse(input, { - delimiter: delimiter || undefined, // undefined triggers auto-detection - header: hasHeader, - dynamicTyping: true, - skipEmptyLines: true, - }); - return result.data; -} - -/** - * Format data as CSV - */ -function formatCsv(value: unknown, delimiter: string): string { - if (!Array.isArray(value)) { - value = [value]; - } - // Use comma as default for output (empty means auto-detect for input only) - return Papa.unparse(value as unknown[], { delimiter: delimiter || "," }); -} - -/** - * Parse input data from the given format into a QueryValue - */ -export function parseInput(input: string, options: FormatOptions): QueryValue { - const trimmed = input.trim(); - if (!trimmed) return null; - - switch (options.inputFormat) { - case "yaml": - return YAML.parse(trimmed); - - case "json": - return JSON.parse(trimmed); - - case "xml": { - const parser = new XMLParser({ - ignoreAttributes: false, - attributeNamePrefix: options.xmlAttributePrefix, - textNodeName: options.xmlContentName, - // Keep values as strings to match real yq behavior - parseAttributeValue: false, - parseTagValue: false, - trimValues: true, - // Transform empty tags to null to match real yq - tagValueProcessor: (_name, val) => (val === "" ? null : val), - }); - return parser.parse(trimmed); - } - - case "ini": - return ini.parse(trimmed); - - case "csv": - return parseCsv(trimmed, options.csvDelimiter, options.csvHeader); - - case "toml": - return TOML.parse(trimmed) as QueryValue; - - default: { - const _exhaustive: never = options.inputFormat; - throw new Error(`Invalid input format: ${_exhaustive}`); - } - } -} - -/** - * Parse all YAML documents from input (for slurp mode) - */ -export function parseAllYamlDocuments(input: string): QueryValue[] { - const docs = YAML.parseAllDocuments(input); - return docs.map((doc) => doc.toJSON()); -} - -/** - * Extract front-matter from content - * Front-matter is YAML/TOML/JSON at the start of a file between --- or +++ delimiters - * Returns { frontMatter: parsed data, content: remaining content } or null if no front-matter - */ -export function extractFrontMatter( - input: string, -): { frontMatter: QueryValue; content: string } | null { - const trimmed = input.trimStart(); - - // YAML front-matter: starts with --- - if (trimmed.startsWith("---")) { - const endMatch = trimmed.slice(3).match(/\n---(\n|$)/); - if (endMatch && endMatch.index !== undefined) { - const yamlContent = trimmed.slice(3, endMatch.index + 3); - const remaining = trimmed.slice(endMatch.index + 3 + endMatch[0].length); - return { - frontMatter: YAML.parse(yamlContent), - content: remaining, - }; - } - } - - // TOML front-matter: starts with +++ - if (trimmed.startsWith("+++")) { - const endMatch = trimmed.slice(3).match(/\n\+\+\+(\n|$)/); - if (endMatch && endMatch.index !== undefined) { - const tomlContent = trimmed.slice(3, endMatch.index + 3); - const remaining = trimmed.slice(endMatch.index + 3 + endMatch[0].length); - return { - frontMatter: TOML.parse(tomlContent) as QueryValue, - content: remaining, - }; - } - } - - // JSON front-matter: starts with {{{ (less common) - if (trimmed.startsWith("{{{")) { - const endMatch = trimmed.slice(3).match(/\n}}}(\n|$)/); - if (endMatch && endMatch.index !== undefined) { - const jsonContent = trimmed.slice(3, endMatch.index + 3); - const remaining = trimmed.slice(endMatch.index + 3 + endMatch[0].length); - return { - frontMatter: JSON.parse(jsonContent), - content: remaining, - }; - } - } - - return null; -} - -/** - * Format a QueryValue for output in the given format - */ -export function formatOutput( - value: QueryValue, - options: FormatOptions, -): string { - if (value === undefined) return ""; - - switch (options.outputFormat) { - case "yaml": - return YAML.stringify(value, { - indent: options.indent, - }).trimEnd(); - - case "json": { - if (options.raw && typeof value === "string") { - return value; - } - if (options.compact) { - return JSON.stringify(value); - } - return JSON.stringify(value, null, options.indent); - } - - case "xml": { - const builder = new XMLBuilder({ - ignoreAttributes: false, - attributeNamePrefix: options.xmlAttributePrefix, - textNodeName: options.xmlContentName, - format: options.prettyPrint || !options.compact, - indentBy: " ".repeat(options.indent), - }); - return builder.build(value); - } - - case "ini": { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return ""; - } - return ini.stringify(value as Record); - } - - case "csv": - return formatCsv(value, options.csvDelimiter); - - case "toml": { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return ""; - } - return TOML.stringify(value as Record); - } - - default: - throw new Error(`Unknown output format: ${options.outputFormat}`); - } -} diff --git a/src/commands/yq/yq.env.test.ts b/src/commands/yq/yq.env.test.ts deleted file mode 100644 index 2076e60e..00000000 --- a/src/commands/yq/yq.env.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Tests for yq environment variable access (env, $ENV) - */ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("yq environment variables", () => { - it("env returns environment object", async () => { - const bash = new Bash({ - env: { TEST_VAR: "test_value" }, - }); - const result = await bash.exec("echo 'null' | yq -o json 'env.TEST_VAR'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"test_value"\n'); - }); - - it("$ENV returns environment object", async () => { - const bash = new Bash({ - env: { MY_VAR: "my_value" }, - }); - const result = await bash.exec("echo 'null' | yq -o json '$ENV.MY_VAR'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"my_value"\n'); - }); - - it("env returns all env vars as object", async () => { - const bash = new Bash({ - env: { A: "1", B: "2" }, - }); - const result = await bash.exec("echo 'null' | yq -o json 'env | keys'"); - expect(result.exitCode).toBe(0); - // Should contain A and B (may have other vars too) - expect(result.stdout).toContain('"A"'); - expect(result.stdout).toContain('"B"'); - }); - - describe("edge cases", () => { - it("missing env var returns null", async () => { - const bash = new Bash({ - env: { A: "1" }, - }); - const result = await bash.exec( - "echo 'null' | yq -o json 'env.NONEXISTENT'", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("null\n"); - }); - - it("$ENV missing var returns null", async () => { - const bash = new Bash({ - env: { A: "1" }, - }); - const result = await bash.exec( - "echo 'null' | yq -o json '$ENV.NONEXISTENT'", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("null\n"); - }); - - it("empty env var value", async () => { - const bash = new Bash({ - env: { EMPTY: "" }, - }); - const result = await bash.exec("echo 'null' | yq -o json 'env.EMPTY'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('""\n'); - }); - - it("env var with special characters", async () => { - const bash = new Bash({ - env: { SPECIAL: "a=1&b=2" }, - }); - const result = await bash.exec("echo 'null' | yq -o json 'env.SPECIAL'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"a=1&b=2"\n'); - }); - - it("env combined with other operations", async () => { - const bash = new Bash({ - env: { NAME: "world" }, - }); - // Use env var in string interpolation would be nice, but test basic combination - const result = await bash.exec( - 'echo \'{"greeting": "hello"}\' | yq -o json \'.greeting + " " + env.NAME\'', - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"hello world"\n'); - }); - }); -}); diff --git a/src/commands/yq/yq.fixtures.test.ts b/src/commands/yq/yq.fixtures.test.ts deleted file mode 100644 index 357aa807..00000000 --- a/src/commands/yq/yq.fixtures.test.ts +++ /dev/null @@ -1,768 +0,0 @@ -/** - * Tests for yq command using fixture files - * - * Tests various input formats (YAML, JSON, XML, INI, CSV) and - * format conversion capabilities. - * - * Each test run writes companion .parsed.json files showing the internal - * representation of each format after parsing. - */ - -import * as fs from "node:fs"; -import * as path from "node:path"; -import { beforeAll, describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -const fixturesDir = path.join(import.meta.dirname, "fixtures"); - -type Format = "yaml" | "json" | "xml" | "ini" | "csv" | "toml"; - -interface FixtureFile { - format: Format; - name: string; - path: string; - virtualPath: string; - content: string; -} - -/** - * Load all fixtures from format subdirectories - */ -function loadFixtures(): { - files: Record; - fixtures: FixtureFile[]; -} { - const files: Record = {}; - const fixtures: FixtureFile[] = []; - - const formats: Format[] = ["yaml", "json", "xml", "ini", "csv", "toml"]; - - for (const format of formats) { - const formatDir = path.join(fixturesDir, format); - if (!fs.existsSync(formatDir)) continue; - - for (const file of fs.readdirSync(formatDir)) { - if (file.endsWith(".parsed.json")) continue; // Skip generated files - - const filePath = path.join(formatDir, file); - const content = fs.readFileSync(filePath, "utf-8"); - const virtualPath = `/fixtures/${format}/${file}`; - - files[virtualPath] = content; - fixtures.push({ - format, - name: file, - path: filePath, - virtualPath, - content, - }); - } - } - - return { files, fixtures }; -} - -/** - * Write the parsed JSON representation of a fixture file - */ -async function writeParsedJson( - bash: Bash, - fixture: FixtureFile, -): Promise { - const formatFlag = fixture.format === "yaml" ? "" : `-p ${fixture.format}`; - const cmd = `yq ${formatFlag} '.' '${fixture.virtualPath}' -o json`.trim(); - - const result = await bash.exec(cmd); - - if (result.exitCode === 0 && result.stdout.trim()) { - const parsedPath = fixture.path.replace(/\.[^.]+$/, ".parsed.json"); - try { - // Pretty print the JSON - const parsed = JSON.parse(result.stdout); - fs.writeFileSync(parsedPath, `${JSON.stringify(parsed, null, 2)}\n`); - } catch { - // If JSON parsing fails, write raw output - fs.writeFileSync(parsedPath, result.stdout); - } - } -} - -describe("yq fixtures", () => { - const { files, fixtures } = loadFixtures(); - - // Generate parsed JSON files for all fixtures before tests run - beforeAll(async () => { - const bash = new Bash({ files }); - - for (const fixture of fixtures) { - await writeParsedJson(bash, fixture); - } - }); - - describe("YAML fixtures", () => { - it("should extract user names from users.yaml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.users[].name' /fixtures/yaml/users.yaml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("alice\nbob\ncharlie\n"); - }); - - it("should filter active users from users.yaml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '[.users[] | select(.active)] | length' /fixtures/yaml/users.yaml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("2\n"); - }); - - it("should get metadata version from users.yaml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.metadata.version' /fixtures/yaml/users.yaml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1\n"); - }); - - it("should extract tags from simple.yaml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec("yq '.tags[]' /fixtures/yaml/simple.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("important\nfeatured\nnew\n"); - }); - }); - - describe("JSON fixtures", () => { - it("should extract user emails from users.json", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p json '.users[].email' /fixtures/json/users.json", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("alice@example.com"); - expect(result.stdout).toContain("bob@example.com"); - }); - - it("should get department names from nested.json", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p json '.company.departments[].name' /fixtures/json/nested.json", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("Engineering\nSales\nMarketing\n"); - }); - - it("should calculate total employees from nested.json", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p json '[.company.departments[].employees] | add' /fixtures/json/nested.json", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("100\n"); - }); - - it("should find departments with budget > 250000", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p json '[.company.departments[] | select(.budget > 250000) | .name]' /fixtures/json/nested.json -o json", - ); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toEqual(["Engineering", "Sales"]); - }); - }); - - describe("XML fixtures", () => { - it("should extract book titles from books.xml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p xml '.library.book[].title' /fixtures/xml/books.xml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("The Great Adventure"); - expect(result.stdout).toContain("Learning TypeScript"); - expect(result.stdout).toContain("Mystery Manor"); - }); - - it("should get book by ID attribute from books.xml", async () => { - const bash = new Bash({ files }); - // XML attributes are strings; use -o json to verify - const result = await bash.exec( - "yq -p xml '.library.book[0][\"+@id\"]' /fixtures/xml/books.xml -o json", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"1"\n'); - }); - - it("should filter fiction books from books.xml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - 'yq -p xml \'[.library.book[] | select(.["+@genre"] == "fiction") | .title]\' /fixtures/xml/books.xml -o json', - ); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toEqual([ - "The Great Adventure", - "Mystery Manor", - ]); - }); - - it("should extract user names from users.xml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p xml '.root.users.user[].name' /fixtures/xml/users.xml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("alice\nbob\ncharlie\n"); - }); - }); - - describe("INI fixtures", () => { - it("should get database host from config.ini", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p ini '.database.host' /fixtures/ini/config.ini", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("localhost\n"); - }); - - it("should get server port from config.ini", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p ini '.server.port' /fixtures/ini/config.ini", - ); - expect(result.exitCode).toBe(0); - // INI values are strings - expect(result.stdout.trim()).toMatch(/8080/); - }); - - it("should get all section keys from config.ini", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p ini 'keys' /fixtures/ini/config.ini", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("database"); - expect(result.stdout).toContain("server"); - expect(result.stdout).toContain("logging"); - }); - - it("should get top-level name from app.ini", async () => { - const bash = new Bash({ files }); - const result = await bash.exec("yq -p ini '.name' /fixtures/ini/app.ini"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("MyApp\n"); - }); - - it("should get feature flags from app.ini", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p ini '.features' /fixtures/ini/app.ini -o json", - ); - expect(result.exitCode).toBe(0); - const features = JSON.parse(result.stdout); - expect(features.dark_mode).toBe(true); - expect(features.notifications).toBe(true); - expect(features.analytics).toBe(false); - }); - }); - - describe("CSV fixtures", () => { - it("should get first user name from users.csv", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p csv '.[0].name' /fixtures/csv/users.csv", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("alice\n"); - }); - - it("should get all user ages from users.csv", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p csv '.[].age' /fixtures/csv/users.csv", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("30\n25\n35\n"); - }); - - it("should filter electronics products from products.csv", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p csv '[.[] | select(.category == \"electronics\") | .name]' /fixtures/csv/products.csv -o json", - ); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toEqual(["Widget", "Gadget", "Doodad"]); - }); - - it("should calculate total price of in-stock items from products.csv", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p csv '[.[] | select(.in_stock == true) | .price] | add' /fixtures/csv/products.csv", - ); - expect(result.exitCode).toBe(0); - // Widget (19.99) + Gadget (29.99) + Doodad (49.99) + Thingamajig (14.99) = 114.96 - expect(Number.parseFloat(result.stdout.trim())).toBeCloseTo(114.96, 2); - }); - - it("should get product count from products.csv", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p csv 'length' /fixtures/csv/products.csv", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("5\n"); - }); - }); - - describe("TOML fixtures", () => { - it("should get package name from cargo.toml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.package.name' /fixtures/toml/cargo.toml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("my-rust-app\n"); - }); - - it("should get package version from cargo.toml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.package.version' /fixtures/toml/cargo.toml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1.2.3\n"); - }); - - it("should get dependency versions from cargo.toml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.dependencies.serde.version' /fixtures/toml/cargo.toml -o json -r", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1.0\n"); - }); - - it("should get project name from pyproject.toml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.project.name' /fixtures/toml/pyproject.toml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("my-python-app\n"); - }); - - it("should get author emails from pyproject.toml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.project.authors[].email' /fixtures/toml/pyproject.toml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("alice@example.com"); - expect(result.stdout).toContain("bob@example.com"); - }); - - it("should get database settings from config.toml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.database.max_connections' /fixtures/toml/config.toml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("100\n"); - }); - - it("should get nested pool settings from config.toml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.database.pool.max_size' /fixtures/toml/config.toml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("50\n"); - }); - - it("should get feature flags from config.toml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.features' /fixtures/toml/config.toml -o json", - ); - expect(result.exitCode).toBe(0); - const features = JSON.parse(result.stdout); - expect(features.dark_mode).toBe(true); - expect(features.analytics).toBe(false); - }); - - it("should handle array of tables from special.toml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.products[].name' /fixtures/toml/special.toml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("Widget\nGadget\nDoodad\n"); - }); - - it("should handle inline tables from special.toml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.inline.name' /fixtures/toml/special.toml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("inline\n"); - }); - - it("should handle unicode from special.toml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.unicode' /fixtures/toml/special.toml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Hello"); - }); - - it("should convert TOML to JSON", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.server' /fixtures/toml/config.toml -o json", - ); - expect(result.exitCode).toBe(0); - const server = JSON.parse(result.stdout); - expect(server.host).toBe("localhost"); - expect(server.port).toBe(8080); - }); - }); - - describe("format conversion", () => { - it("should convert YAML to JSON", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.title' /fixtures/yaml/simple.yaml -o json -r", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("Simple Document\n"); - }); - - it("should convert JSON to YAML", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p json '.company.name' /fixtures/json/nested.json", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("Acme Corp\n"); - }); - - it("should convert CSV to JSON", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p csv '.[0]' /fixtures/csv/users.csv -o json", - ); - expect(result.exitCode).toBe(0); - const user = JSON.parse(result.stdout); - expect(user.name).toBe("alice"); - expect(user.age).toBe(30); - }); - - it("should convert JSON to CSV", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p json '.users' /fixtures/json/users.json -o csv", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("name,age,email,active"); - expect(result.stdout).toContain("alice,30"); - }); - - it("should convert YAML to INI", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.' /fixtures/yaml/simple.yaml -o ini", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("title=Simple Document"); - expect(result.stdout).toContain("count=42"); - }); - - it("should convert INI to JSON", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p ini '.' /fixtures/ini/config.ini -o json", - ); - expect(result.exitCode).toBe(0); - const config = JSON.parse(result.stdout); - expect(config.database.host).toBe("localhost"); - // INI values are strings - expect(config.server.port).toBe("8080"); - }); - - it("should convert XML to JSON", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p xml '.library.book[0].title' /fixtures/xml/books.xml -o json -r", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("The Great Adventure\n"); - }); - - it("should convert YAML to TOML", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.' /fixtures/yaml/simple.yaml -o toml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('title = "Simple Document"'); - expect(result.stdout).toContain("count = 42"); - }); - - it("should convert TOML to YAML", async () => { - const bash = new Bash({ files }); - const result = await bash.exec("yq '.' /fixtures/toml/config.toml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("server:"); - expect(result.stdout).toContain("host: localhost"); - }); - }); - - describe("special YAML cases", () => { - it("should handle empty string", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.empty_string' /fixtures/yaml/special.yaml -o json", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe('""'); - }); - - it("should handle null value", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.null_value' /fixtures/yaml/special.yaml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("null"); - }); - - it("should handle multiline string", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.multiline' /fixtures/yaml/special.yaml -o json -r", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("multiline string"); - }); - - it("should handle nested arrays", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.nested_arrays[0][1]' /fixtures/yaml/special.yaml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("2"); - }); - - it("should handle YAML anchors and references", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.reference.shared' /fixtures/yaml/special.yaml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("data"); - }); - }); - - describe("special JSON cases", () => { - it("should handle deeply nested structures", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p json '.deeply.nested.structure.value' /fixtures/json/special.json", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("found it"); - }); - - it("should handle keys with special characters", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p json '.objects[\"with-dash\"]' /fixtures/json/special.json", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("value"); - }); - - it("should handle mixed arrays", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p json '.arrays.mixed | length' /fixtures/json/special.json", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("5"); - }); - - it("should handle unicode", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p json '.unicode' /fixtures/json/special.json -r", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Hello"); - }); - }); - - describe("special XML cases", () => { - it("should handle self-closing tags", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p xml '.root | has(\"self-closing\")' /fixtures/xml/special.xml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("true"); - }); - - it("should handle multiple attributes", async () => { - const bash = new Bash({ files }); - // XML attributes are strings; use -o json to verify - const result = await bash.exec( - 'yq -p xml \'.root["multiple-attrs"]["+@id"]\' /fixtures/xml/special.xml -o json', - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe('"1"'); - }); - - it("should handle deeply nested XML", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p xml '.root.nested.level1.level2.level3' /fixtures/xml/special.xml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("deep value"); - }); - - it("should handle repeated elements as array", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p xml '.root.repeated.item | length' /fixtures/xml/special.xml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("3"); - }); - }); - - describe("special INI cases", () => { - it("should handle global keys before sections", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p ini '.global_key' /fixtures/ini/special.ini", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("global_value"); - }); - - it("should handle various boolean formats", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p ini '.booleans' /fixtures/ini/special.ini -o json", - ); - expect(result.exitCode).toBe(0); - const bools = JSON.parse(result.stdout); - // ini package parses "true"/"false" as actual booleans - expect(bools.true_val).toBe(true); - expect(bools.yes_val).toBe("yes"); // "yes" stays as string - }); - - it("should handle paths with special characters", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p ini '.paths.url' /fixtures/ini/special.ini", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("https://example.com"); - }); - }); - - describe("special CSV cases", () => { - it("should handle quoted values with commas", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p csv '.[1].name' /fixtures/csv/special.csv", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("With, comma"); - }); - - it("should handle escaped quotes", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p csv '.[2].name' /fixtures/csv/special.csv", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("quotes"); - }); - - it("should auto-detect semicolon delimiter", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p csv '.[0].name' /fixtures/csv/semicolon.csv", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("Widget"); - }); - - it("should auto-detect tab delimiter", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p csv '.[0].name' /fixtures/csv/tabs.tsv", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("Apple"); - }); - - it("should handle unicode in CSV", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p csv '.[5].description' /fixtures/csv/special.csv", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Hello"); - }); - }); - - describe("complex queries", () => { - it("should calculate average age from users.yaml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '[.users[].age] | add / length' /fixtures/yaml/users.yaml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("30\n"); - }); - - it("should find highest budget department from nested.json", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p json '[.company.departments[] | {name, budget}] | max_by(.budget) | .name' /fixtures/json/nested.json", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("Engineering\n"); - }); - - it("should group products by category from products.csv", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq -p csv 'group_by(.category) | map({category: .[0].category, count: length})' /fixtures/csv/products.csv -o json", - ); - expect(result.exitCode).toBe(0); - const groups = JSON.parse(result.stdout); - expect(groups).toHaveLength(2); - }); - - it("should transform user data structure from users.yaml", async () => { - const bash = new Bash({ files }); - const result = await bash.exec( - "yq '.users | map({(.name): .email}) | add' /fixtures/yaml/users.yaml -o json", - ); - expect(result.exitCode).toBe(0); - const emails = JSON.parse(result.stdout); - expect(emails.alice).toBe("alice@example.com"); - expect(emails.bob).toBe("bob@example.com"); - }); - }); -}); diff --git a/src/commands/yq/yq.format-strings.test.ts b/src/commands/yq/yq.format-strings.test.ts deleted file mode 100644 index ad221c44..00000000 --- a/src/commands/yq/yq.format-strings.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -/** - * Tests for yq format string operators (@base64, @uri, @csv, etc.) - */ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("yq format strings", () => { - it("@base64 encodes string", async () => { - const bash = new Bash(); - const result = await bash.exec("echo '\"hello\"' | yq -o json '@base64'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"aGVsbG8="\n'); - }); - - it("@base64d decodes string", async () => { - const bash = new Bash(); - const result = await bash.exec( - "echo '\"aGVsbG8=\"' | yq -o json '@base64d'", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"hello"\n'); - }); - - it("@uri encodes string", async () => { - const bash = new Bash(); - const result = await bash.exec( - "echo '\"hello world\"' | yq -o json '@uri'", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"hello%20world"\n'); - }); - - it("@csv formats array", async () => { - const bash = new Bash(); - const result = await bash.exec( - 'echo \'["a","b","c"]\' | yq -o json \'@csv\'', - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"a,b,c"\n'); - }); - - it("@csv escapes values with commas", async () => { - const bash = new Bash(); - const result = await bash.exec( - 'echo \'["a","b,c","d"]\' | yq -o json \'@csv\'', - ); - expect(result.exitCode).toBe(0); - // CSV output: a,"b,c",d (quoted because b,c contains comma) - // As JSON string: "a,\"b,c\",d" - expect(result.stdout).toBe('"a,\\"b,c\\",d"\n'); - }); - - it("@tsv formats array", async () => { - const bash = new Bash(); - const result = await bash.exec( - 'echo \'["a","b","c"]\' | yq -o json \'@tsv\'', - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"a\\tb\\tc"\n'); - }); - - it("@json converts to JSON string", async () => { - const bash = new Bash(); - const result = await bash.exec("echo '{\"a\":1}' | yq -o json '@json'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"{\\"a\\":1}"\n'); - }); - - it("@html escapes special characters", async () => { - const bash = new Bash(); - const result = await bash.exec( - "echo '\"\"' | yq -o json '@html'", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"<script>alert(1)</script>"\n'); - }); - - it("@sh escapes for shell", async () => { - const bash = new Bash(); - const result = await bash.exec("echo '\"hello world\"' | yq -o json '@sh'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("\"'hello world'\"\n"); - }); - - it("@sh escapes single quotes", async () => { - const bash = new Bash(); - const result = await bash.exec("echo '\"it'\"'\"'s\"' | yq -o json '@sh'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("\"'it'\\\\''s'\"\n"); - }); - - it("@text converts to string", async () => { - const bash = new Bash(); - const result = await bash.exec("echo '\"test\"' | yq -o json '@text'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"test"\n'); - }); - - // Edge cases - describe("edge cases", () => { - it("@base64 with unicode", async () => { - const bash = new Bash(); - // "héllo" in base64 - const result = await bash.exec("echo '\"héllo\"' | yq -o json '@base64'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"aMOpbGxv"\n'); - }); - - it("@base64 on non-string returns null", async () => { - const bash = new Bash(); - const result = await bash.exec("echo '123' | yq -o json '@base64'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("null\n"); - }); - - it("@base64 on array returns null", async () => { - const bash = new Bash(); - const result = await bash.exec( - "echo '[\"a\",\"b\"]' | yq -o json '@base64'", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("null\n"); - }); - - it("@csv with null values", async () => { - const bash = new Bash(); - const result = await bash.exec( - "echo '[\"a\",null,\"c\"]' | yq -o json '@csv'", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"a,,c"\n'); - }); - - it("@csv with numbers", async () => { - const bash = new Bash(); - const result = await bash.exec("echo '[1,2,3]' | yq -o json '@csv'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"1,2,3"\n'); - }); - - it("@csv with empty array", async () => { - const bash = new Bash(); - const result = await bash.exec("echo '[]' | yq -o json '@csv'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('""\n'); - }); - - it("@csv with quotes in values", async () => { - const bash = new Bash(); - // Input: ["a", 'b"c', "d"] - // CSV should double the quotes: a,"b""c",d - const result = await bash.exec( - 'echo \'["a","b\\"c","d"]\' | yq -o json \'@csv\'', - ); - expect(result.exitCode).toBe(0); - // CSV: a,"b""c",d -> JSON: "a,\"b\"\"c\",d" - expect(result.stdout).toBe('"a,\\"b\\"\\"c\\",d"\n'); - }); - - it("@csv on non-array returns null", async () => { - const bash = new Bash(); - const result = await bash.exec("echo '\"test\"' | yq -o json '@csv'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("null\n"); - }); - - it("@uri encodes all special characters", async () => { - const bash = new Bash(); - const result = await bash.exec( - "echo '\"a=1&b=2?c#d\"' | yq -o json '@uri'", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"a%3D1%26b%3D2%3Fc%23d"\n'); - }); - - it("@html escapes ampersand", async () => { - const bash = new Bash(); - const result = await bash.exec("echo '\"a & b\"' | yq -o json '@html'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"a & b"\n'); - }); - - it("@html on non-string returns null", async () => { - const bash = new Bash(); - const result = await bash.exec("echo '123' | yq -o json '@html'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("null\n"); - }); - - it("@sh on non-string returns null", async () => { - const bash = new Bash(); - const result = await bash.exec("echo '123' | yq -o json '@sh'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("null\n"); - }); - - it("@text on null returns empty string", async () => { - const bash = new Bash(); - const result = await bash.exec("echo 'null' | yq -o json '@text'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('""\n'); - }); - - it("@text on number converts to string", async () => { - const bash = new Bash(); - const result = await bash.exec("echo '42' | yq -o json '@text'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"42"\n'); - }); - }); -}); diff --git a/src/commands/yq/yq.navigation.test.ts b/src/commands/yq/yq.navigation.test.ts deleted file mode 100644 index b73bbc2d..00000000 --- a/src/commands/yq/yq.navigation.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Tests for yq navigation operators (parent, parents, root) - */ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("yq navigation operators", () => { - it("parent returns immediate parent", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "a:\n b:\n c: value\n", - }, - }); - const result = await bash.exec("yq -o json '.a.b.c | parent' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('{\n "c": "value"\n}\n'); - }); - - it("parent(2) returns grandparent", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "a:\n b:\n c: value\n", - }, - }); - const result = await bash.exec( - "yq -o json '.a.b.c | parent(2)' /data.yaml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('{\n "b": {\n "c": "value"\n }\n}\n'); - }); - - it("parent(-1) returns root", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "a:\n b:\n c: value\n", - }, - }); - const result = await bash.exec( - "yq -o json '.a.b.c | parent(-1)' /data.yaml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - '{\n "a": {\n "b": {\n "c": "value"\n }\n }\n}\n', - ); - }); - - it("root returns document root", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "a:\n b:\n c: value\n", - }, - }); - const result = await bash.exec("yq -o json '.a.b.c | root' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe( - '{\n "a": {\n "b": {\n "c": "value"\n }\n }\n}\n', - ); - }); - - it("parents returns array of all ancestors", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "a:\n b:\n c: value\n", - }, - }); - const result = await bash.exec( - "yq -o json '.a.b.c | parents | length' /data.yaml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("3\n"); // parent .a.b, grandparent .a, root - }); - - describe("edge cases", () => { - it("parent(0) returns current value", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "a:\n b: test\n", - }, - }); - const result = await bash.exec( - "yq -o json '.a.b | parent(0)' /data.yaml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"test"\n'); - }); - - it("parent(-2) returns one level below root", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "a:\n b:\n c: value\n", - }, - }); - const result = await bash.exec( - "yq -o json '.a.b.c | parent(-2)' /data.yaml", - ); - expect(result.exitCode).toBe(0); - // -2 means one level below root, which is .a - expect(result.stdout).toBe('{\n "b": {\n "c": "value"\n }\n}\n'); - }); - - it("parent beyond root returns empty", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "a:\n b: test\n", - }, - }); - const result = await bash.exec( - "yq -o json '.a.b | parent(10)' /data.yaml", - ); - expect(result.exitCode).toBe(0); - // Beyond root should return nothing - expect(result.stdout).toBe(""); - }); - - it("parent at root returns empty", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "value: test\n", - }, - }); - const result = await bash.exec("yq -o json '. | parent' /data.yaml"); - expect(result.exitCode).toBe(0); - // At root, no parent - expect(result.stdout).toBe(""); - }); - - it("parent with array index path", async () => { - const bash = new Bash({ - files: { - "/data.yaml": - "items:\n - name: foo\n val: 1\n - name: bar\n val: 2\n", - }, - }); - const result = await bash.exec( - "yq -o json '.items[0].name | parent' /data.yaml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('{\n "name": "foo",\n "val": 1\n}\n'); - }); - - it("parents on shallow path", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "a: test\n", - }, - }); - const result = await bash.exec( - "yq -o json '.a | parents | length' /data.yaml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("1\n"); // Just root - }); - - it("root without prior navigation", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "a: 1\nb: 2\n", - }, - }); - const result = await bash.exec("yq -o json 'root' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('{\n "a": 1,\n "b": 2\n}\n'); - }); - - it("chained parent calls", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "a:\n b:\n c:\n d: value\n", - }, - }); - const result = await bash.exec( - "yq -o json '.a.b.c.d | parent | parent' /data.yaml", - ); - expect(result.exitCode).toBe(0); - // First parent: .a.b.c, second parent: .a.b - expect(result.stdout).toBe('{\n "c": {\n "d": "value"\n }\n}\n'); - }); - - it("parent after select", async () => { - const bash = new Bash({ - files: { - "/data.yaml": - "items:\n - name: foo\n active: true\n - name: bar\n active: false\n", - }, - }); - // This is a complex case - select doesn't preserve path context in our impl - // So parent after select may not work as expected - const result = await bash.exec( - "yq -o json '.items[] | select(.active == true) | .name' /data.yaml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"foo"\n'); - }); - }); -}); diff --git a/src/commands/yq/yq.prototype-pollution.test.ts b/src/commands/yq/yq.prototype-pollution.test.ts deleted file mode 100644 index ee7a0a03..00000000 --- a/src/commands/yq/yq.prototype-pollution.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -/** - * Tests for yq prototype pollution defense. - * - * yq uses the same query engine as jq, so it has similar attack vectors. - */ - -const DANGEROUS_KEYWORDS = [ - "constructor", - "__proto__", - "prototype", - "hasOwnProperty", - "toString", -]; - -describe("yq prototype pollution defense", () => { - describe("YAML input with dangerous keys", () => { - for (const keyword of DANGEROUS_KEYWORDS.slice(0, 3)) { - it(`should handle YAML key '${keyword}'`, async () => { - const env = new Bash(); - const result = await env.exec( - `echo '${keyword}: value' | yq '.${keyword}'`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("value"); - }); - } - }); - - describe("JSON input with dangerous keys", () => { - for (const keyword of DANGEROUS_KEYWORDS.slice(0, 3)) { - it(`should handle JSON key '${keyword}'`, async () => { - const env = new Bash(); - const result = await env.exec( - `echo '{"${keyword}": "value"}' | yq -p json '.${keyword}'`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("value"); - }); - } - }); - - describe("yq key operations with dangerous keys", () => { - it("should list keys including __proto__", async () => { - const env = new Bash(); - const result = await env.exec(` - echo '__proto__: a -constructor: b -normal: c' | yq 'keys' - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("__proto__"); - expect(result.stdout).toContain("constructor"); - }); - - it("should handle to_entries with dangerous keys", async () => { - const env = new Bash(); - const result = await env.exec(` - echo 'constructor: val' | yq 'to_entries | .[0].key' - `); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("constructor"); - }); - - it("should handle has() with dangerous key", async () => { - const env = new Bash(); - const result = await env.exec(` - echo 'constructor: value' | yq 'has("constructor")' - `); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("true"); - }); - - it("should return false for has() on missing dangerous key", async () => { - const env = new Bash(); - const result = await env.exec(` - echo 'other: value' | yq 'has("__proto__")' - `); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("false"); - }); - }); - - describe("yq $ENV with dangerous keywords", () => { - it("should access $ENV.constructor safely", async () => { - const env = new Bash(); - const result = await env.exec(` - export constructor=ctor_value - echo 'null' | yq '$ENV.constructor' - `); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("ctor_value"); - }); - - it("should access $ENV.prototype safely", async () => { - const env = new Bash(); - const result = await env.exec(` - export prototype=proto_value - echo 'null' | yq '$ENV.prototype' - `); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("proto_value"); - }); - }); - - describe("yq add with dangerous keys", () => { - it("should add objects with constructor key", async () => { - const env = new Bash(); - const result = await env.exec(` - echo '[{"constructor": "a"}, {"normal": "b"}]' | yq -p json 'add | .constructor' - `); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("a"); - }); - }); - - describe("yq getpath with dangerous keys", () => { - it("should getpath with constructor", async () => { - const env = new Bash(); - const result = await env.exec(` - echo 'constructor: value' | yq 'getpath(["constructor"])' - `); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("value"); - }); - }); - - describe("yq does not pollute JavaScript prototype", () => { - it("should not pollute Object.prototype via YAML", async () => { - const env = new Bash(); - await env.exec(` - echo '__proto__: polluted -constructor: hacked' | yq '.' - `); - - // Verify JavaScript Object.prototype is not affected - const testObj: Record = {}; - expect(Object.hasOwn(Object.prototype, "polluted")).toBe(false); - expect(testObj.__proto__).toBe(Object.prototype); - expect(typeof testObj.constructor).toBe("function"); - }); - - it("should not pollute Object.prototype via JSON", async () => { - const env = new Bash(); - await env.exec(` - echo '{"__proto__": "polluted"}' | yq -p json '.' - `); - - // Verify JavaScript Object.prototype is not affected - expect(Object.hasOwn(Object.prototype, "polluted")).toBe(false); - }); - }); -}); diff --git a/src/commands/yq/yq.test.ts b/src/commands/yq/yq.test.ts deleted file mode 100644 index 74e2eb89..00000000 --- a/src/commands/yq/yq.test.ts +++ /dev/null @@ -1,944 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("yq", () => { - describe("YAML processing", () => { - it("should read YAML and output YAML by default", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "name: test\nversion: 1.0\n", - }, - }); - const result = await bash.exec("yq '.name' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test\n"); - }); - - it("should filter nested YAML", async () => { - const bash = new Bash({ - files: { - "/data.yaml": ` -config: - database: - host: localhost - port: 5432 -`, - }, - }); - const result = await bash.exec("yq '.config.database.host' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("localhost\n"); - }); - - it("should handle arrays in YAML", async () => { - const bash = new Bash({ - files: { - "/data.yaml": ` -items: - - name: foo - value: 1 - - name: bar - value: 2 -`, - }, - }); - const result = await bash.exec("yq '.items[0].name' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("foo\n"); - }); - - it("should iterate over arrays", async () => { - const bash = new Bash({ - files: { - "/data.yaml": ` -fruits: - - apple - - banana - - cherry -`, - }, - }); - const result = await bash.exec("yq '.fruits[]' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("apple\nbanana\ncherry\n"); - }); - - it("should use select filter", async () => { - const bash = new Bash({ - files: { - "/data.yaml": ` -users: - - name: alice - active: true - - name: bob - active: false - - name: charlie - active: true -`, - }, - }); - const result = await bash.exec( - "yq '.users[] | select(.active) | .name' /data.yaml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("alice\ncharlie\n"); - }); - }); - - describe("output formats", () => { - it("should output as JSON with -o json", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "name: test\nvalue: 42\n", - }, - }); - const result = await bash.exec("yq -o json '.' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ name: "test", value: 42 }); - }); - - it("should output compact JSON with -c -o json", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "name: test\nvalue: 42\n", - }, - }); - const result = await bash.exec("yq -c -o json '.' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('{"name":"test","value":42}\n'); - }); - - it("should output raw strings with -r -o json", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "message: hello world\n", - }, - }); - const result = await bash.exec("yq -r -o json '.message' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("hello world\n"); - }); - }); - - describe("JSON input", () => { - it("should read JSON with -p json", async () => { - const bash = new Bash({ - files: { - "/data.json": '{"name": "test", "value": 42}', - }, - }); - const result = await bash.exec("yq -p json '.name' /data.json"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test\n"); - }); - - it("should convert JSON to YAML", async () => { - const bash = new Bash({ - files: { - "/data.json": '{"name": "test", "value": 42}', - }, - }); - const result = await bash.exec("yq -p json '.' /data.json"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("name: test"); - expect(result.stdout).toContain("value: 42"); - }); - }); - - describe("XML input/output", () => { - it("should read XML with -p xml", async () => { - const bash = new Bash({ - files: { - "/data.xml": "test42", - }, - }); - const result = await bash.exec("yq -p xml '.root.name' /data.xml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test\n"); - }); - - it("should handle XML attributes", async () => { - const bash = new Bash({ - files: { - "/data.xml": '', - }, - }); - // Attributes are strings in XML; use -o json to verify string value - const result = await bash.exec( - "yq -p xml '.item[\"+@id\"]' /data.xml -o json", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe('"123"\n'); - }); - - it("should output as XML", async () => { - const bash = new Bash({ - files: { - "/data.yaml": ` -root: - name: test - value: 42 -`, - }, - }); - const result = await bash.exec("yq -o xml '.' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain(""); - expect(result.stdout).toContain("test"); - expect(result.stdout).toContain("42"); - expect(result.stdout).toContain(""); - }); - }); - - describe("stdin support", () => { - it("should read from stdin", async () => { - const bash = new Bash(); - const result = await bash.exec("echo 'name: test' | yq '.name'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test\n"); - }); - - it("should accept - for stdin", async () => { - const bash = new Bash(); - const result = await bash.exec("echo 'value: 42' | yq '.value' -"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("42\n"); - }); - }); - - describe("null input", () => { - it("should support -n for null input", async () => { - const bash = new Bash(); - const result = await bash.exec("yq -n '{name: \"created\"}' -o json"); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ name: "created" }); - }); - }); - - describe("slurp mode", () => { - it("should slurp multiple YAML documents", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "---\nname: first\n---\nname: second\n", - }, - }); - const result = await bash.exec("yq -s '.[0].name' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("first\n"); - }); - }); - - describe("jq-style filters", () => { - it("should support map filter", async () => { - const bash = new Bash({ - files: { - "/data.yaml": ` -numbers: - - 1 - - 2 - - 3 -`, - }, - }); - const result = await bash.exec("yq '.numbers | map(. * 2)' /data.yaml"); - expect(result.exitCode).toBe(0); - const lines = result.stdout.trim().split("\n"); - expect(lines).toContain("- 2"); - expect(lines).toContain("- 4"); - expect(lines).toContain("- 6"); - }); - - it("should support keys filter", async () => { - const bash = new Bash({ - files: { - "/data.yaml": ` -config: - host: localhost - port: 8080 - debug: true -`, - }, - }); - const result = await bash.exec("yq '.config | keys' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("debug"); - expect(result.stdout).toContain("host"); - expect(result.stdout).toContain("port"); - }); - - it("should support length filter", async () => { - const bash = new Bash({ - files: { - "/data.yaml": ` -items: - - a - - b - - c -`, - }, - }); - const result = await bash.exec("yq '.items | length' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("3\n"); - }); - }); - - describe("error handling", () => { - it("should handle file not found", async () => { - const bash = new Bash(); - const result = await bash.exec("yq '.' /nonexistent.yaml"); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("No such file or directory"); - }); - - it("should handle invalid YAML", async () => { - const bash = new Bash({ - files: { - "/bad.yaml": "invalid: yaml: syntax: error:", - }, - }); - const result = await bash.exec("yq '.' /bad.yaml"); - expect(result.exitCode).toBe(5); - expect(result.stderr).toContain("parse error"); - }); - - it("should handle unknown options", async () => { - const bash = new Bash(); - const result = await bash.exec("yq --unknown '.' /data.yaml"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("unrecognized option"); - }); - }); - - describe("help", () => { - it("should display help with --help", async () => { - const bash = new Bash(); - const result = await bash.exec("yq --help"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("yq"); - expect(result.stdout).toContain("YAML/XML"); - }); - }); - - describe("format validation", () => { - it("should reject invalid input format with --input-format=", async () => { - const bash = new Bash(); - const result = await bash.exec("echo '{}' | yq --input-format=badformat"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("unrecognized option"); - }); - - it("should reject invalid output format with --output-format=", async () => { - const bash = new Bash(); - const result = await bash.exec( - "echo '{}' | yq --output-format=badformat", - ); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("unrecognized option"); - }); - - it("should reject invalid input format with -p", async () => { - const bash = new Bash(); - const result = await bash.exec("echo '{}' | yq -p badformat"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid option"); - }); - - it("should reject invalid output format with -o", async () => { - const bash = new Bash(); - const result = await bash.exec("echo '{}' | yq -o badformat"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("invalid option"); - }); - - it("should accept valid input formats", async () => { - const bash = new Bash(); - // Test yaml and json which can parse {} - for (const format of ["yaml", "json"]) { - const result = await bash.exec( - `echo '{}' | yq --input-format=${format} --output-format=json`, - ); - expect(result.exitCode).toBe(0); - } - }); - - it("should accept valid output formats", async () => { - const bash = new Bash(); - for (const format of ["yaml", "json", "xml", "ini", "csv", "toml"]) { - const result = await bash.exec( - `echo '{}' | yq --output-format=${format}`, - ); - expect(result.exitCode).toBe(0); - } - }); - }); - - describe("INI format", () => { - it("should read INI and extract values", async () => { - const bash = new Bash({ - files: { - "/config.ini": ` -[database] -host=localhost -port=5432 - -[server] -debug=true -`, - }, - }); - const result = await bash.exec("yq -p ini '.database.host' /config.ini"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("localhost\n"); - }); - - it("should read INI with numeric values", async () => { - const bash = new Bash({ - files: { - "/config.ini": "[database]\nport=5432\n", - }, - }); - const result = await bash.exec("yq -p ini '.database.port' /config.ini"); - expect(result.exitCode).toBe(0); - // INI values are strings, use -r for raw output or -o json - expect(result.stdout.trim()).toMatch(/5432/); - }); - - it("should output as INI", async () => { - const bash = new Bash({ - files: { - "/data.yaml": ` -database: - host: localhost - port: 5432 -`, - }, - }); - const result = await bash.exec("yq -o ini '.' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("[database]"); - expect(result.stdout).toContain("host=localhost"); - expect(result.stdout).toContain("port=5432"); - }); - - it("should convert YAML to INI", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "name: test\nversion: 1\n", - }, - }); - const result = await bash.exec("yq -o ini '.' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("name=test"); - expect(result.stdout).toContain("version=1"); - }); - }); - - describe("CSV format", () => { - it("should read CSV with headers", async () => { - const bash = new Bash({ - files: { - "/data.csv": "name,age,city\nalice,30,NYC\nbob,25,LA\n", - }, - }); - const result = await bash.exec("yq -p csv '.[0].name' /data.csv"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("alice\n"); - }); - - it("should read CSV and get all names", async () => { - const bash = new Bash({ - files: { - "/data.csv": "name,age\nalice,30\nbob,25\n", - }, - }); - const result = await bash.exec("yq -p csv '.[].name' /data.csv"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("alice\nbob\n"); - }); - - it("should filter CSV rows", async () => { - const bash = new Bash({ - files: { - "/data.csv": - "name,age,city\nalice,30,NYC\nbob,25,LA\ncharlie,35,NYC\n", - }, - }); - const result = await bash.exec( - "yq -p csv '[.[] | select(.city == \"NYC\") | .name]' /data.csv -o json", - ); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toEqual(["alice", "charlie"]); - }); - - it("should output as CSV", async () => { - const bash = new Bash({ - files: { - "/data.yaml": ` -- name: alice - age: 30 -- name: bob - age: 25 -`, - }, - }); - const result = await bash.exec("yq -o csv '.' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("name,age"); - expect(result.stdout).toContain("alice,30"); - expect(result.stdout).toContain("bob,25"); - }); - - it("should convert JSON to CSV", async () => { - const bash = new Bash({ - files: { - "/data.json": - '[{"name":"alice","score":95},{"name":"bob","score":87}]', - }, - }); - const result = await bash.exec("yq -p json -o csv '.' /data.json"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("name,score"); - expect(result.stdout).toContain("alice,95"); - expect(result.stdout).toContain("bob,87"); - }); - - it("should handle custom delimiter", async () => { - const bash = new Bash({ - files: { - "/data.tsv": "name\tage\nalice\t30\nbob\t25\n", - }, - }); - const result = await bash.exec( - "yq -p csv --csv-delimiter='\t' '.[0].name' /data.tsv", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("alice\n"); - }); - }); - - describe("join-output mode", () => { - it("should not print newlines with -j", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "items:\n - a\n - b\n - c\n", - }, - }); - const result = await bash.exec("yq -j '.items[]' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("abc"); - }); - }); - - describe("exit-status mode", () => { - it("should exit 0 for truthy output with -e", async () => { - const bash = new Bash({ - files: { "/data.yaml": "value: true\n" }, - }); - const result = await bash.exec("yq -e '.value' /data.yaml"); - expect(result.exitCode).toBe(0); - }); - - it("should exit 1 for null output with -e", async () => { - const bash = new Bash({ - files: { "/data.yaml": "value: 42\n" }, - }); - const result = await bash.exec("yq -e '.missing' /data.yaml"); - expect(result.exitCode).toBe(1); - }); - - it("should exit 1 for false output with -e", async () => { - const bash = new Bash({ - files: { "/data.yaml": "value: false\n" }, - }); - const result = await bash.exec("yq -e '.value' /data.yaml"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("indent option", () => { - it("should use custom indent with -I", async () => { - const bash = new Bash({ - files: { "/data.yaml": "items:\n - a\n - b\n" }, - }); - const result = await bash.exec("yq -o json -I 4 '.' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain(' "a"'); - }); - }); - - describe("combined short options", () => { - it("should handle -rc for raw compact json", async () => { - const bash = new Bash({ - files: { "/data.yaml": "msg: hello\n" }, - }); - const result = await bash.exec("yq -rc -o json '.msg' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("hello\n"); - }); - - it("should handle -cej for compact exit-status join", async () => { - const bash = new Bash({ - files: { "/data.yaml": "items:\n - 1\n - 2\n" }, - }); - const result = await bash.exec("yq -cej -o json '.items[]' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("12"); - }); - }); - - describe("jq builtin functions", () => { - it("should support first", async () => { - const bash = new Bash({ - files: { "/data.yaml": "items:\n - a\n - b\n - c\n" }, - }); - const result = await bash.exec("yq '.items | first' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a\n"); - }); - - it("should support last", async () => { - const bash = new Bash({ - files: { "/data.yaml": "items:\n - a\n - b\n - c\n" }, - }); - const result = await bash.exec("yq '.items | last' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("c\n"); - }); - - it("should support add for numbers", async () => { - const bash = new Bash({ - files: { "/data.yaml": "nums:\n - 1\n - 2\n - 3\n" }, - }); - const result = await bash.exec("yq '.nums | add' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("6\n"); - }); - - it("should support min", async () => { - const bash = new Bash({ - files: { "/data.yaml": "nums:\n - 5\n - 2\n - 8\n" }, - }); - const result = await bash.exec("yq '.nums | min' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("2\n"); - }); - - it("should support max", async () => { - const bash = new Bash({ - files: { "/data.yaml": "nums:\n - 5\n - 2\n - 8\n" }, - }); - const result = await bash.exec("yq '.nums | max' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("8\n"); - }); - - it("should support unique", async () => { - const bash = new Bash({ - files: { "/data.yaml": "items:\n - a\n - b\n - a\n - c\n - b\n" }, - }); - const result = await bash.exec("yq '.items | unique' /data.yaml -o json"); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toEqual(["a", "b", "c"]); - }); - - it("should support sort_by", async () => { - const bash = new Bash({ - files: { - "/data.yaml": - "items:\n - name: b\n val: 2\n - name: a\n val: 1\n", - }, - }); - const result = await bash.exec( - "yq '.items | sort_by(.name) | .[0].name' /data.yaml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a\n"); - }); - - it("should support reverse", async () => { - const bash = new Bash({ - files: { "/data.yaml": "items:\n - 1\n - 2\n - 3\n" }, - }); - const result = await bash.exec( - "yq '.items | reverse' /data.yaml -o json", - ); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toEqual([3, 2, 1]); - }); - - it("should support group_by", async () => { - const bash = new Bash({ - files: { - "/data.yaml": - "items:\n - type: a\n v: 1\n - type: b\n v: 2\n - type: a\n v: 3\n", - }, - }); - const result = await bash.exec( - "yq '.items | group_by(.type) | length' /data.yaml", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("2\n"); - }); - }); - - describe("CSV options", () => { - it("should handle --no-csv-header", async () => { - const bash = new Bash({ - files: { "/data.csv": "alice,30\nbob,25\n" }, - }); - const result = await bash.exec( - "yq -p csv --no-csv-header '.[0][0]' /data.csv", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("alice\n"); - }); - }); - - describe("XML options", () => { - it("should use custom attribute prefix", async () => { - const bash = new Bash({ - files: { "/data.xml": '' }, - }); - const result = await bash.exec( - "yq -p xml --xml-attribute-prefix='@' '.item[\"@id\"]' /data.xml -o json -r", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("123\n"); - }); - }); - - describe("TOML format", () => { - it("should read TOML and extract values", async () => { - const bash = new Bash({ - files: { - "/Cargo.toml": `[package] -name = "my-app" -version = "1.0.0" - -[dependencies] -serde = "1.0" -`, - }, - }); - const result = await bash.exec("yq '.package.name' /Cargo.toml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("my-app\n"); - }); - - it("should auto-detect TOML from .toml extension", async () => { - const bash = new Bash({ - files: { - "/config.toml": `[server] -host = "localhost" -port = 8080 -`, - }, - }); - const result = await bash.exec("yq '.server.port' /config.toml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("8080\n"); - }); - - it("should output as TOML", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "server:\n host: localhost\n port: 8080\n", - }, - }); - const result = await bash.exec("yq -o toml '.' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("[server]"); - expect(result.stdout).toContain('host = "localhost"'); - expect(result.stdout).toContain("port = 8080"); - }); - - it("should convert JSON to TOML", async () => { - const bash = new Bash({ - files: { - "/data.json": '{"app": {"name": "test", "version": "2.0"}}', - }, - }); - const result = await bash.exec("yq -p json -o toml '.' /data.json"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("[app]"); - expect(result.stdout).toContain('name = "test"'); - }); - }); - - describe("TSV format", () => { - it("should auto-detect TSV from .tsv extension", async () => { - const bash = new Bash({ - files: { - "/data.tsv": "name\tage\tcity\nalice\t30\tNYC\nbob\t25\tLA\n", - }, - }); - const result = await bash.exec("yq '.[0].name' /data.tsv"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("alice\n"); - }); - - it("should read all TSV rows", async () => { - const bash = new Bash({ - files: { - "/data.tsv": "name\tvalue\na\t1\nb\t2\n", - }, - }); - const result = await bash.exec("yq '.[].name' /data.tsv"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a\nb\n"); - }); - }); - - describe("inplace mode", () => { - it("should modify file in-place with -i", async () => { - const bash = new Bash({ - files: { - "/data.yaml": "version: 1.0\nname: test\n", - }, - }); - const result = await bash.exec("yq -i '.version = \"2.0\"' /data.yaml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - - const readResult = await bash.exec("cat /data.yaml"); - expect(readResult.stdout).toContain('version: "2.0"'); - }); - - it("should error when -i used without file", async () => { - const bash = new Bash(); - const result = await bash.exec("echo 'x: 1' | yq -i '.x'"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("requires a file"); - }); - }); - - describe("front-matter", () => { - it("should extract YAML front-matter from markdown", async () => { - const bash = new Bash({ - files: { - "/post.md": `--- -title: My Post -date: 2024-01-01 -tags: - - tech - - web ---- - -# Content here - -This is the post body. -`, - }, - }); - const result = await bash.exec("yq --front-matter '.title' /post.md"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("My Post\n"); - }); - - it("should extract front-matter tags array", async () => { - const bash = new Bash({ - files: { - "/post.md": `--- -title: Test -tags: - - a - - b ---- -Content`, - }, - }); - const result = await bash.exec("yq --front-matter '.tags[]' /post.md"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a\nb\n"); - }); - - it("should extract TOML front-matter with +++", async () => { - const bash = new Bash({ - files: { - "/post.md": `+++ -title = "Hugo Post" -date = "2024-01-01" -+++ - -Content here. -`, - }, - }); - const result = await bash.exec("yq -f '.title' /post.md"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("Hugo Post\n"); - }); - - it("should error when no front-matter found", async () => { - const bash = new Bash({ - files: { - "/plain.md": "# Just a heading\n\nNo front-matter here.", - }, - }); - const result = await bash.exec("yq --front-matter '.title' /plain.md"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("no front-matter found"); - }); - }); - - describe("format auto-detection", () => { - it("should auto-detect JSON from .json extension", async () => { - const bash = new Bash({ - files: { - "/data.json": '{"name": "test", "value": 42}', - }, - }); - // No -p flag, should auto-detect from extension - const result = await bash.exec("yq '.name' /data.json"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test\n"); - }); - - it("should auto-detect XML from .xml extension", async () => { - const bash = new Bash({ - files: { - "/data.xml": "test", - }, - }); - const result = await bash.exec("yq '.root.name' /data.xml"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test\n"); - }); - - it("should auto-detect CSV from .csv extension", async () => { - const bash = new Bash({ - files: { - "/data.csv": "name,age\nalice,30\nbob,25\n", - }, - }); - const result = await bash.exec("yq '.[0].name' /data.csv"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("alice\n"); - }); - - it("should auto-detect INI from .ini extension", async () => { - const bash = new Bash({ - files: { - "/config.ini": "[database]\nhost=localhost\n", - }, - }); - const result = await bash.exec("yq '.database.host' /config.ini"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("localhost\n"); - }); - - it("should prefer explicit -p over auto-detection", async () => { - const bash = new Bash({ - files: { - // File named .json but contains YAML - "/data.json": "name: yaml-content\n", - }, - }); - // Explicit -p yaml should override .json extension - const result = await bash.exec("yq -p yaml '.name' /data.json"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("yaml-content\n"); - }); - }); -}); diff --git a/src/commands/yq/yq.ts b/src/commands/yq/yq.ts deleted file mode 100644 index 99122d43..00000000 --- a/src/commands/yq/yq.ts +++ /dev/null @@ -1,397 +0,0 @@ -/** - * yq - Command-line YAML/XML/INI/CSV/TOML processor - * - * Uses jq-style query expressions to process YAML, XML, INI, CSV, and TOML files. - * Shares the query engine with jq for consistent filtering behavior. - * - * Inspired by mikefarah/yq (https://github.com/mikefarah/yq) - * This is a reimplementation for the just-bash sandboxed environment. - */ - -import { ExecutionLimitError } from "../../interpreter/errors.js"; -import type { Command, CommandContext, ExecResult } from "../../types.js"; -import { hasHelpFlag, showHelp, unknownOption } from "../help.js"; -import { - type EvaluateOptions, - evaluate, - parse, - type QueryValue, -} from "../query-engine/index.js"; -import { - defaultFormatOptions, - detectFormatFromExtension, - extractFrontMatter, - type FormatOptions, - formatOutput, - isValidInputFormat, - isValidOutputFormat, - parseAllYamlDocuments, - parseInput, -} from "./formats.js"; - -const yqHelp = { - name: "yq", - summary: "command-line YAML/XML/INI/CSV/TOML processor", - usage: "yq [OPTIONS] [FILTER] [FILE]", - description: `yq uses jq-style expressions to query and transform data in various formats. -Supports YAML, JSON, XML, INI, CSV, and TOML with automatic format conversion. - -EXAMPLES: - # Extract a value from YAML - yq '.name' config.yaml - yq '.users[0].email' data.yaml - - # Filter arrays - yq '.items[] | select(.active == true)' data.yaml - yq '[.users[] | select(.age > 30)]' users.yaml - - # Transform data - yq '.users | map({name, email})' data.yaml - yq '.items | sort_by(.price) | reverse' products.yaml - - # Modify file in-place - yq -i '.version = "2.0"' config.yaml - - # Read JSON, output YAML - yq -p json '.' config.json - - # Read YAML, output JSON - yq -o json '.' config.yaml - yq -o json -c '.' config.yaml # compact JSON - - # Parse TOML config files - yq '.package.name' Cargo.toml - yq -o json '.' pyproject.toml - - # Parse XML (attributes use +@ prefix, text uses +content) - yq -p xml '.root.items.item[].name' data.xml - yq -p xml '.root.user["+@id"]' data.xml # XML attributes - - # Parse INI config files - yq -p ini '.database.host' config.ini - yq -p ini '.server' config.ini -o json - - # Parse CSV/TSV (auto-detects delimiter) - yq -p csv '.[0].name' data.csv - yq '.[0].name' data.tsv # auto-detected as CSV - yq -p csv '[.[] | select(.category == "A")]' data.csv - - # Extract front-matter from markdown/content files - yq --front-matter '.title' post.md - - # Convert between formats - yq -p json -o csv '.users' data.json # JSON to CSV - yq -p csv -o yaml '.' data.csv # CSV to YAML - yq -p ini -o json '.' config.ini # INI to JSON - yq -p xml -o json '.' data.xml # XML to JSON - yq -o toml '.' config.yaml # YAML to TOML - - # Common jq functions work in yq: - yq 'keys' data.yaml # get object keys - yq 'length' data.yaml # array/string length - yq '.items | first' data.yaml # first element - yq '.items | last' data.yaml # last element - yq '.nums | add' data.yaml # sum numbers - yq '.nums | min' data.yaml # minimum - yq '.nums | max' data.yaml # maximum - yq '.items | unique' data.yaml # unique values - yq '.items | group_by(.type)' data.yaml`, - options: [ - "-p, --input-format=FMT input format: yaml (default), xml, json, ini, csv, toml", - "-o, --output-format=FMT output format: yaml (default), json, xml, ini, csv, toml", - "-i, --inplace modify file in-place", - "-r, --raw-output output strings without quotes (json only)", - "-c, --compact compact output (json only)", - "-e, --exit-status set exit status based on output", - "-s, --slurp read entire input into array", - "-n, --null-input don't read any input", - "-j, --join-output don't print newlines after each output", - "-f, --front-matter extract and process front-matter only", - "-P, --prettyPrint pretty print output", - "-I, --indent=N set indent level (default: 2)", - " --xml-attribute-prefix=STR XML attribute prefix (default: +@)", - " --xml-content-name=STR XML text content name (default: +content)", - " --csv-delimiter=CHAR CSV delimiter (default: auto-detect)", - " --csv-header CSV has header row (default: true)", - " --help display this help and exit", - ], -}; - -interface YqOptions extends FormatOptions { - exitStatus: boolean; - slurp: boolean; - nullInput: boolean; - joinOutput: boolean; - inplace: boolean; - frontMatter: boolean; -} - -interface ParsedArgs { - options: YqOptions; - filter: string; - files: string[]; - inputFormatExplicit: boolean; -} - -function parseArgs(args: string[]): ParsedArgs | ExecResult { - const options: YqOptions = { - ...defaultFormatOptions, - exitStatus: false, - slurp: false, - nullInput: false, - joinOutput: false, - inplace: false, - frontMatter: false, - }; - let inputFormatExplicit = false; - - let filter = "."; - let filterSet = false; - const files: string[] = []; - - for (let i = 0; i < args.length; i++) { - const a = args[i]; - - // Long options with values - if (a.startsWith("--input-format=")) { - const format = a.slice(15); - if (!isValidInputFormat(format)) { - return unknownOption("yq", `--input-format=${format}`); - } - options.inputFormat = format; - inputFormatExplicit = true; - } else if (a.startsWith("--output-format=")) { - const format = a.slice(16); - if (!isValidOutputFormat(format)) { - return unknownOption("yq", `--output-format=${format}`); - } - options.outputFormat = format; - } else if (a.startsWith("--indent=")) { - options.indent = Number.parseInt(a.slice(9), 10); - } else if (a.startsWith("--xml-attribute-prefix=")) { - options.xmlAttributePrefix = a.slice(23); - } else if (a.startsWith("--xml-content-name=")) { - options.xmlContentName = a.slice(19); - } else if (a.startsWith("--csv-delimiter=")) { - options.csvDelimiter = a.slice(16); - } else if (a === "--csv-header") { - options.csvHeader = true; - } else if (a === "--no-csv-header") { - options.csvHeader = false; - } else if (a === "-p" || a === "--input-format") { - const format = args[++i]; - if (!isValidInputFormat(format)) { - return unknownOption("yq", `${a} ${format}`); - } - options.inputFormat = format; - inputFormatExplicit = true; - } else if (a === "-o" || a === "--output-format") { - const format = args[++i]; - if (!isValidOutputFormat(format)) { - return unknownOption("yq", `${a} ${format}`); - } - options.outputFormat = format; - } else if (a === "-I" || a === "--indent") { - options.indent = Number.parseInt(args[++i], 10); - } else if (a === "-r" || a === "--raw-output") { - options.raw = true; - } else if (a === "-c" || a === "--compact") { - options.compact = true; - } else if (a === "-e" || a === "--exit-status") { - options.exitStatus = true; - } else if (a === "-s" || a === "--slurp") { - options.slurp = true; - } else if (a === "-n" || a === "--null-input") { - options.nullInput = true; - } else if (a === "-j" || a === "--join-output") { - options.joinOutput = true; - } else if (a === "-i" || a === "--inplace") { - options.inplace = true; - } else if (a === "-f" || a === "--front-matter") { - options.frontMatter = true; - } else if (a === "-P" || a === "--prettyPrint") { - options.prettyPrint = true; - } else if (a === "-") { - files.push("-"); - } else if (a.startsWith("--")) { - return unknownOption("yq", a); - } else if (a.startsWith("-")) { - // Handle combined short options like -rc - for (const c of a.slice(1)) { - if (c === "r") options.raw = true; - else if (c === "c") options.compact = true; - else if (c === "e") options.exitStatus = true; - else if (c === "s") options.slurp = true; - else if (c === "n") options.nullInput = true; - else if (c === "j") options.joinOutput = true; - else if (c === "i") options.inplace = true; - else if (c === "f") options.frontMatter = true; - else if (c === "P") options.prettyPrint = true; - else return unknownOption("yq", `-${c}`); - } - } else if (!filterSet) { - filter = a; - filterSet = true; - } else { - files.push(a); - } - } - - return { options, filter, files, inputFormatExplicit }; -} - -export const yqCommand: Command = { - name: "yq", - - async execute(args: string[], ctx: CommandContext): Promise { - if (hasHelpFlag(args)) return showHelp(yqHelp); - - const parsed = parseArgs(args); - if ("exitCode" in parsed) return parsed; - - const { options, filter, files, inputFormatExplicit } = parsed; - - // Auto-detect format from file extension if not explicitly set - if (!inputFormatExplicit && files.length > 0 && files[0] !== "-") { - const detected = detectFormatFromExtension(files[0]); - if (detected) { - options.inputFormat = detected; - } - } - - // Inplace requires a file - if (options.inplace && (files.length === 0 || files[0] === "-")) { - return { - stdout: "", - stderr: "yq: -i/--inplace requires a file argument\n", - exitCode: 1, - }; - } - - // Read input - let input: string; - let filePath: string | undefined; - if (options.nullInput) { - input = ""; - } else if (files.length === 0 || (files.length === 1 && files[0] === "-")) { - input = ctx.stdin; - } else { - try { - filePath = ctx.fs.resolvePath(ctx.cwd, files[0]); - input = await ctx.fs.readFile(filePath); - } catch { - return { - stdout: "", - stderr: `yq: ${files[0]}: No such file or directory\n`, - exitCode: 2, - }; - } - } - - try { - const ast = parse(filter); - let values: QueryValue[]; - - const evalOptions: EvaluateOptions = { - limits: ctx.limits - ? { maxIterations: ctx.limits.maxJqIterations } - : undefined, - env: ctx.env, - coverage: ctx.coverage, - }; - - if (options.nullInput) { - values = evaluate(null, ast, evalOptions); - } else if (options.frontMatter) { - // Extract and process front-matter only - const fm = extractFrontMatter(input); - if (!fm) { - return { - stdout: "", - stderr: "yq: no front-matter found\n", - exitCode: 1, - }; - } - values = evaluate(fm.frontMatter, ast, evalOptions); - } else if (options.slurp) { - // Parse all documents into array - let items: QueryValue[]; - if (options.inputFormat === "yaml") { - // YAML supports multiple documents separated by --- - items = parseAllYamlDocuments(input); - } else { - items = [parseInput(input, options)]; - } - values = evaluate(items, ast, evalOptions); - } else { - const parsed = parseInput(input, options); - values = evaluate(parsed, ast, evalOptions); - } - - // Format output - const formatted = values.map((v) => formatOutput(v, options)); - const separator = options.joinOutput ? "" : "\n"; - const output = formatted.filter((s) => s !== "").join(separator); - const finalOutput = output - ? options.joinOutput - ? output - : `${output}\n` - : ""; - - // Handle inplace mode - if (options.inplace && filePath) { - await ctx.fs.writeFile(filePath, finalOutput); - return { stdout: "", stderr: "", exitCode: 0 }; - } - - const exitCode = - options.exitStatus && - (values.length === 0 || - values.every((v) => v === null || v === undefined || v === false)) - ? 1 - : 0; - - return { - stdout: finalOutput, - stderr: "", - exitCode, - }; - } catch (e) { - if (e instanceof ExecutionLimitError) { - return { - stdout: "", - stderr: `yq: ${e.message}\n`, - exitCode: ExecutionLimitError.EXIT_CODE, - }; - } - const msg = (e as Error).message; - if (msg.includes("Unknown function")) { - return { - stdout: "", - stderr: `yq: error: ${msg}\n`, - exitCode: 3, - }; - } - return { - stdout: "", - stderr: `yq: parse error: ${msg}\n`, - exitCode: 5, - }; - } - }, -}; - -import type { CommandFuzzInfo } from "../fuzz-flags-types.js"; - -export const flagsForFuzzing: CommandFuzzInfo = { - name: "yq", - flags: [ - { flag: "-r", type: "boolean" }, - { flag: "-c", type: "boolean" }, - { flag: "-s", type: "boolean" }, - { flag: "-i", type: "value", valueHint: "string" }, - { flag: "-o", type: "value", valueHint: "string" }, - ], - stdinType: "text", - needsArgs: true, -}; diff --git a/src/comparison-tests/README.md b/src/comparison-tests/README.md deleted file mode 100644 index 77f13d51..00000000 --- a/src/comparison-tests/README.md +++ /dev/null @@ -1,186 +0,0 @@ -# Comparison Tests - -Comparison tests validate that just-bash produces the same output as real bash. They use a **fixture-based system** that records bash outputs once and replays them during tests, eliminating platform-specific differences. - -## How It Works - -1. **Fixtures** are JSON files containing recorded bash outputs (`src/comparison-tests/fixtures/*.fixtures.json`) -2. **Tests** run commands in just-bash and compare against the recorded fixtures -3. **Record mode** runs real bash and saves outputs to fixtures - -## Running Tests - -```bash -# Run all comparison tests (uses fixtures, no real bash needed) -pnpm test:comparison - -# Run a specific test file -pnpm test:run src/comparison-tests/ls.comparison.test.ts - -# Re-record fixtures (runs real bash, skips locked fixtures) -pnpm test:comparison:record -# Or: RECORD_FIXTURES=1 pnpm test:comparison - -# Force re-record ALL fixtures including locked ones -RECORD_FIXTURES=force pnpm test:comparison -``` - -## Adding New Tests - -### 1. Add the test case - -```typescript -// src/comparison-tests/mycommand.comparison.test.ts -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("mycommand - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should do something", async () => { - const env = await setupFiles(testDir, { - "input.txt": "hello world\n", - }); - await compareOutputs(env, testDir, "mycommand input.txt"); - }); -}); -``` - -### 2. Record the fixture - -```bash -RECORD_FIXTURES=1 pnpm test:run src/comparison-tests/mycommand.comparison.test.ts -``` - -This creates `src/comparison-tests/fixtures/mycommand.comparison.fixtures.json`. - -### 3. Commit both the test and fixture file - -## Updating Fixtures - -When bash behavior changes or you need to update expected outputs: - -```bash -# Re-record specific test file -RECORD_FIXTURES=1 pnpm test:run src/comparison-tests/ls.comparison.test.ts - -# Re-record all fixtures -pnpm test:comparison:record -``` - -## Handling Platform Differences - -The fixture system solves platform differences (macOS vs Linux): - -1. **Record once** on any platform -2. **Manually adjust** the fixture to match desired behavior (usually Linux) -3. **Lock the fixture** to prevent accidental overwriting -4. Tests then pass on all platforms - -Example: `ls -R` outputs differently on macOS vs Linux: -- macOS: `dir\nfile.txt\n...` -- Linux: `.:\ndir\nfile.txt\n...` (includes ".:" header) - -We record on macOS, then edit the fixture to use Linux behavior since our implementation follows Linux. - -## Locked Fixtures - -Fixtures that have been manually adjusted for platform-specific behavior should be marked as **locked** to prevent accidental overwriting when re-recording: - -```json -{ - "fixture_id": { - "command": "ls -R", - "files": { ... }, - "stdout": ".:\ndir\nfile.txt\n...", - "stderr": "", - "exitCode": 0, - "locked": true - } -} -``` - -When recording: -- `RECORD_FIXTURES=1` skips locked fixtures and reports them -- `RECORD_FIXTURES=force` overwrites all fixtures including locked ones - -Currently locked fixtures: -- `ls -R` - Uses Linux-style output with ".:" header -- `cat -n` with multiple files - Uses continuous line numbering (Linux behavior) - -## API Reference - -### `setupFiles(testDir, files)` - -Sets up test files in both real filesystem and BashEnv. - -```typescript -const env = await setupFiles(testDir, { - "file.txt": "content", - "dir/nested.txt": "nested content", -}); -``` - -### `compareOutputs(env, testDir, command, options?)` - -Compares just-bash output against recorded fixture. - -```typescript -// Basic usage -await compareOutputs(env, testDir, "cat file.txt"); - -// With options -await compareOutputs(env, testDir, "wc -l file.txt", { - normalizeWhitespace: true, // For BSD/GNU whitespace differences - compareExitCode: false, // Skip exit code comparison -}); -``` - -### `runRealBash(command, cwd)` - -Runs a command in real bash (for tests that need direct bash access). - -```typescript -const result = await runRealBash("echo hello", testDir); -// result: { stdout, stderr, exitCode } -``` - -## Fixture File Format - -```json -{ - "fixture_id_hash": { - "command": "ls -la", - "files": { - "file.txt": "content" - }, - "stdout": "file.txt\n", - "stderr": "", - "exitCode": 0 - } -} -``` - -The fixture ID is a hash of (command + files), ensuring each unique test case has its own fixture entry. - -## Best Practices - -1. **Keep tests focused** - One behavior per test -2. **Use meaningful file content** - Makes debugging easier -3. **Test edge cases** - Empty files, special characters, etc. -4. **Use `normalizeWhitespace`** for commands with platform-specific formatting (wc, column widths) -5. **Commit fixtures** - They're part of the test suite -6. **Re-record when needed** - If you change test files/commands, re-record the fixtures diff --git a/src/comparison-tests/alias.comparison.test.ts b/src/comparison-tests/alias.comparison.test.ts deleted file mode 100644 index 59c4e576..00000000 --- a/src/comparison-tests/alias.comparison.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("alias command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - // Note: Alias expansion is not implemented in bash-env to match real bash behavior. - // In non-interactive mode (scripts), bash does not expand aliases. - - describe("alias management", () => { - it("should show alias not found error", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "alias notexists || echo failed"); - }); - }); - - describe("unalias", () => { - it("should remove an alias", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "alias greet='echo hi'; unalias greet; alias greet || echo removed", - ); - }); - - it("should error when unaliasing non-existent alias", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "unalias nonexistent || echo not_found", - ); - }); - }); -}); diff --git a/src/comparison-tests/awk.comparison.test.ts b/src/comparison-tests/awk.comparison.test.ts deleted file mode 100644 index 77f8aecb..00000000 --- a/src/comparison-tests/awk.comparison.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("awk - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("field access", () => { - it("should print entire line with $0", async () => { - const env = await setupFiles(testDir, { - "data.txt": "hello world\nfoo bar\n", - }); - await compareOutputs(env, testDir, "awk '{print $0}' data.txt"); - }); - - it("should print first field with $1", async () => { - const env = await setupFiles(testDir, { - "data.txt": "hello world\nfoo bar\n", - }); - await compareOutputs(env, testDir, "awk '{print $1}' data.txt"); - }); - - it("should print multiple fields", async () => { - const env = await setupFiles(testDir, { - "data.txt": "a b c\n1 2 3\n", - }); - await compareOutputs(env, testDir, "awk '{print $1, $3}' data.txt"); - }); - - it("should print last field with $NF", async () => { - const _env = await setupFiles(testDir, { - "data.txt": "one two three\na b\n", - }); - // Note: Our awk doesn't support $NF yet, so this test documents expected behavior - // await compareOutputs(env, testDir, "awk '{print $NF}' data.txt"); - }); - }); - - describe("field separator -F", () => { - it("should use comma as field separator", async () => { - const env = await setupFiles(testDir, { - "data.csv": "a,b,c\n1,2,3\n", - }); - await compareOutputs(env, testDir, "awk -F, '{print $2}' data.csv"); - }); - - it("should use colon as field separator", async () => { - const env = await setupFiles(testDir, { - "data.txt": "root:x:0:0:root:/root:/bin/bash\n", - }); - await compareOutputs(env, testDir, "awk -F: '{print $1}' data.txt"); - }); - - it("should use tab as field separator", async () => { - const env = await setupFiles(testDir, { - "data.tsv": "a\tb\tc\n1\t2\t3\n", - }); - await compareOutputs(env, testDir, "awk -F'\\t' '{print $2}' data.tsv"); - }); - }); - - describe("built-in variables", () => { - it("should track NR (line number)", async () => { - const env = await setupFiles(testDir, { - "data.txt": "a\nb\nc\n", - }); - await compareOutputs(env, testDir, "awk '{print NR, $0}' data.txt"); - }); - - it("should track NF (field count)", async () => { - const env = await setupFiles(testDir, { - "data.txt": "one\ntwo three\na b c d\n", - }); - await compareOutputs(env, testDir, "awk '{print NF}' data.txt"); - }); - }); - - describe("BEGIN and END blocks", () => { - it("should execute BEGIN before processing", async () => { - const env = await setupFiles(testDir, { - "data.txt": "line1\nline2\n", - }); - await compareOutputs( - env, - testDir, - "awk 'BEGIN{print \"start\"} {print $0}' data.txt", - ); - }); - - it("should execute END after processing", async () => { - const env = await setupFiles(testDir, { - "data.txt": "line1\nline2\n", - }); - await compareOutputs( - env, - testDir, - "awk '{print $0} END{print \"done\"}' data.txt", - ); - }); - - it("should execute both BEGIN and END", async () => { - const env = await setupFiles(testDir, { - "data.txt": "a\nb\n", - }); - await compareOutputs( - env, - testDir, - 'awk \'BEGIN{print "start"} {print $0} END{print "end"}\' data.txt', - ); - }); - }); - - describe("pattern matching", () => { - it("should filter with regex pattern", async () => { - const env = await setupFiles(testDir, { - "data.txt": "apple\nbanana\napricot\ncherry\n", - }); - await compareOutputs(env, testDir, "awk '/^a/' data.txt"); - }); - - it("should filter with NR condition", async () => { - const env = await setupFiles(testDir, { - "data.txt": "line1\nline2\nline3\n", - }); - await compareOutputs(env, testDir, "awk 'NR==2' data.txt"); - }); - - it("should filter with NR > condition", async () => { - const env = await setupFiles(testDir, { - "data.txt": "line1\nline2\nline3\nline4\n", - }); - await compareOutputs(env, testDir, "awk 'NR>2' data.txt"); - }); - }); - - describe("printf formatting", () => { - it("should format with %s", async () => { - const env = await setupFiles(testDir, { - "data.txt": "hello world\n", - }); - await compareOutputs( - env, - testDir, - "awk '{printf \"%s!\\n\", $1}' data.txt", - ); - }); - - it("should format with %d", async () => { - const env = await setupFiles(testDir, { - "data.txt": "42\n", - }); - await compareOutputs( - env, - testDir, - "awk '{printf \"num: %d\\n\", $1}' data.txt", - ); - }); - }); - - describe("arithmetic", () => { - it("should perform addition", async () => { - const env = await setupFiles(testDir, { - "data.txt": "10 20\n5 15\n", - }); - await compareOutputs(env, testDir, "awk '{print $1 + $2}' data.txt"); - }); - - it("should perform subtraction", async () => { - const env = await setupFiles(testDir, { - "data.txt": "20 5\n100 30\n", - }); - await compareOutputs(env, testDir, "awk '{print $1 - $2}' data.txt"); - }); - - it("should perform multiplication", async () => { - const env = await setupFiles(testDir, { - "data.txt": "3 4\n5 6\n", - }); - await compareOutputs(env, testDir, "awk '{print $1 * $2}' data.txt"); - }); - }); - - describe("stdin input", () => { - it("should process piped input", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "echo 'a b c' | awk '{print $2}'"); - }); - - it("should process multi-line piped input", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "printf 'a b\\nc d\\n' | awk '{print $1}'", - ); - }); - }); - - describe("string concatenation", () => { - it("should concatenate fields", async () => { - const env = await setupFiles(testDir, { - "data.txt": "hello world\n", - }); - await compareOutputs(env, testDir, "awk '{print $1 \"-\" $2}' data.txt"); - }); - }); -}); diff --git a/src/comparison-tests/basename-dirname.comparison.test.ts b/src/comparison-tests/basename-dirname.comparison.test.ts deleted file mode 100644 index fd97a1d3..00000000 --- a/src/comparison-tests/basename-dirname.comparison.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("basename command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("basic usage", () => { - it("should extract basename from absolute path", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "basename /usr/bin/sort"); - }); - - it("should extract basename from relative path", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "basename ./path/to/file.txt"); - }); - - it("should handle filename without directory", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "basename file.txt"); - }); - - it("should handle path ending with slash", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "basename /path/to/dir/"); - }); - }); - - describe("suffix removal", () => { - it("should remove suffix when specified", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "basename /path/to/file.txt .txt"); - }); - - it("should not remove suffix if not matching", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "basename /path/to/file.txt .md"); - }); - - it("should handle -s option", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "basename -s .txt /path/to/file.txt"); - }); - }); - - describe("multiple files with -a", () => { - it("should handle multiple paths with -a", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "basename -a /path/one.txt /path/two.txt", - ); - }); - - it("should handle -a with -s", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "basename -a -s .txt /path/one.txt /path/two.txt", - ); - }); - }); -}); - -describe("dirname command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("basic usage", () => { - it("should extract directory from absolute path", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "dirname /usr/bin/sort"); - }); - - it("should extract directory from relative path", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "dirname ./path/to/file.txt"); - }); - - it("should return . for filename without directory", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "dirname file.txt"); - }); - - it("should return / for root-level file", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "dirname /file.txt"); - }); - - it("should handle path with trailing slash", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "dirname /path/to/dir/"); - }); - }); - - describe("multiple paths", () => { - it("should handle multiple paths", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "dirname /path/to/file1 /another/path/file2", - ); - }); - }); -}); diff --git a/src/comparison-tests/cat.comparison.test.ts b/src/comparison-tests/cat.comparison.test.ts deleted file mode 100644 index c3c588b0..00000000 --- a/src/comparison-tests/cat.comparison.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("cat command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should match single file", async () => { - const env = await setupFiles(testDir, { - "test.txt": "line 1\nline 2\nline 3\n", - }); - await compareOutputs(env, testDir, "cat test.txt"); - }); - - it("should match multiple files", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "content 1\n", - "file2.txt": "content 2\n", - }); - await compareOutputs(env, testDir, "cat file1.txt file2.txt"); - }); - - it("should match -n (line numbers)", async () => { - const env = await setupFiles(testDir, { - "test.txt": "line 1\nline 2\nline 3\n", - }); - await compareOutputs(env, testDir, "cat -n test.txt"); - }); - - it("should match file without trailing newline", async () => { - const env = await setupFiles(testDir, { - "test.txt": "no trailing newline", - }); - await compareOutputs(env, testDir, "cat test.txt"); - }); - - it("should match empty file", async () => { - const env = await setupFiles(testDir, { - "empty.txt": "", - }); - await compareOutputs(env, testDir, "cat empty.txt"); - }); - - it("should match file with only newlines", async () => { - const env = await setupFiles(testDir, { - "newlines.txt": "\n\n\n", - }); - await compareOutputs(env, testDir, "cat newlines.txt"); - }); - - // Linux cat -n continues line numbers across files, macOS resets per file - // BashEnv follows Linux behavior - fixture uses Linux output - it("should match -n with multiple files", async () => { - const env = await setupFiles(testDir, { - "a.txt": "file a line 1\nfile a line 2\n", - "b.txt": "file b line 1\n", - }); - await compareOutputs(env, testDir, "cat -n a.txt b.txt"); - }); - - it("should match cat with stdin from echo", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, 'echo "hello" | cat'); - }); - - it("should match cat with stdin and file", async () => { - const env = await setupFiles(testDir, { - "test.txt": "from file\n", - }); - await compareOutputs(env, testDir, 'echo "from stdin" | cat - test.txt'); - }); -}); diff --git a/src/comparison-tests/cd.comparison.test.ts b/src/comparison-tests/cd.comparison.test.ts deleted file mode 100644 index a65de66a..00000000 --- a/src/comparison-tests/cd.comparison.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - cleanupTestDir, - createTestDir, - runRealBash, - setupFiles, -} from "./fixture-runner.js"; - -describe("cd command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("basic cd", () => { - it("should change directory and pwd should reflect it", async () => { - const env = await setupFiles(testDir, { - "subdir/file.txt": "content", - }); - - // Test cd followed by pwd in BashEnv - const envResult = await env.exec("cd subdir && pwd"); - const realResult = await runRealBash("cd subdir && pwd", testDir); - - // Both should end with /subdir - expect(envResult.stdout.trim().endsWith("/subdir")).toBe(true); - expect(realResult.stdout.trim().endsWith("/subdir")).toBe(true); - expect(envResult.exitCode).toBe(realResult.exitCode); - }); - - it("should change to parent directory with ..", async () => { - const env = await setupFiles(testDir, { - "parent/child/file.txt": "content", - }); - - // All commands in same exec (each exec is isolated like a new shell) - const envResult = await env.exec("cd parent/child && cd .. && pwd"); - - const realResult = await runRealBash( - "cd parent/child && cd .. && pwd", - testDir, - ); - - // Both should end with /parent - expect(envResult.stdout.trim().endsWith("/parent")).toBe(true); - expect(realResult.stdout.trim().endsWith("/parent")).toBe(true); - }); - - it("should handle multiple .. in path", async () => { - const env = await setupFiles(testDir, { - "a/b/c/file.txt": "content", - }); - - // All commands in same exec (each exec is isolated like a new shell) - const envResult = await env.exec("cd a/b/c && cd ../.. && pwd"); - - const realResult = await runRealBash( - "cd a/b/c && cd ../.. && pwd", - testDir, - ); - - // Both should end with /a - expect(envResult.stdout.trim().endsWith("/a")).toBe(true); - expect(realResult.stdout.trim().endsWith("/a")).toBe(true); - }); - }); - - describe("cd -", () => { - it("should return to previous directory", async () => { - const env = await setupFiles(testDir, { - "dir1/file.txt": "", - "dir2/file.txt": "", - }); - - // All commands in same exec (each exec is isolated like a new shell) - const envResult = await env.exec("cd dir1 && cd ../dir2 && cd - && pwd"); - - const realResult = await runRealBash( - "cd dir1 && cd ../dir2 && cd - && pwd", - testDir, - ); - - // Both should end with /dir1 - expect(envResult.stdout.trim().endsWith("/dir1")).toBe(true); - expect(realResult.stdout.trim().endsWith("/dir1")).toBe(true); - }); - }); - - describe("cd errors", () => { - it("should error on non-existent directory", async () => { - const env = await setupFiles(testDir, {}); - - const envResult = await env.exec("cd nonexistent"); - const realResult = await runRealBash( - "cd nonexistent 2>&1; echo $?", - testDir, - ); - - // Both should fail with exit code 1 - expect(envResult.exitCode).toBe(1); - expect(realResult.stdout.trim().endsWith("1")).toBe(true); - }); - - it("should error when cd to file", async () => { - const env = await setupFiles(testDir, { - "file.txt": "content", - }); - - const envResult = await env.exec("cd file.txt"); - const realResult = await runRealBash( - "cd file.txt 2>&1; echo $?", - testDir, - ); - - // Both should fail - expect(envResult.exitCode).toBe(1); - expect(realResult.stdout.trim().endsWith("1")).toBe(true); - }); - }); - - describe("relative paths", () => { - it("should handle relative path with .", async () => { - const env = await setupFiles(testDir, { - "subdir/file.txt": "", - }); - - const envResult = await env.exec("cd ./subdir && pwd"); - const realResult = await runRealBash("cd ./subdir && pwd", testDir); - - expect(envResult.stdout.trim().endsWith("/subdir")).toBe(true); - expect(realResult.stdout.trim().endsWith("/subdir")).toBe(true); - }); - - it("should stay in same directory with cd .", async () => { - const env = await setupFiles(testDir, { - "file.txt": "", - }); - - // All commands in same exec (each exec is isolated like a new shell) - const envResult = await env.exec("pwd; cd .; pwd"); - const lines = envResult.stdout.trim().split("\n"); - - expect(lines[0]).toBe(lines[1]); - }); - }); -}); diff --git a/src/comparison-tests/column-join.comparison.test.ts b/src/comparison-tests/column-join.comparison.test.ts deleted file mode 100644 index c5d938b9..00000000 --- a/src/comparison-tests/column-join.comparison.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("column command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should format whitespace-delimited input as table with -t", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf 'a b c\\nd e f\\n' | column -t"); - }); - - it("should align columns based on maximum width", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "printf 'short long\\nlonger x\\n' | column -t", - ); - }); - - it("should handle varying number of columns per row", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "printf 'a b c\\nd e\\nf\\n' | column -t", - ); - }); - - it("should use custom input delimiter with -s", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "printf 'a,b,c\\nd,e,f\\n' | column -t -s ','", - ); - }); - - it("should handle empty input", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf '' | column"); - }); - - it("should handle file input", async () => { - const env = await setupFiles(testDir, { - "data.txt": "name age\nalice 30\nbob 25\n", - }); - await compareOutputs(env, testDir, "column -t data.txt"); - }); -}); - -describe("join command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should join two files on first field", async () => { - const env = await setupFiles(testDir, { - "a.txt": "1 apple\n2 banana\n3 cherry\n", - "b.txt": "1 red\n2 yellow\n3 red\n", - }); - await compareOutputs(env, testDir, "join a.txt b.txt"); - }); - - it("should only output lines with matching keys", async () => { - const env = await setupFiles(testDir, { - "a.txt": "1 apple\n2 banana\n", - "b.txt": "2 yellow\n3 red\n", - }); - await compareOutputs(env, testDir, "join a.txt b.txt"); - }); - - it("should join on specified fields with -1 and -2", async () => { - const env = await setupFiles(testDir, { - "a.txt": "apple 1\nbanana 2\n", - "b.txt": "1 red\n2 yellow\n", - }); - await compareOutputs(env, testDir, "join -1 2 -2 1 a.txt b.txt"); - }); - - it("should use custom field separator with -t", async () => { - const env = await setupFiles(testDir, { - "a.csv": "1,apple,fruit\n2,banana,fruit\n", - "b.csv": "1,red\n2,yellow\n", - }); - await compareOutputs(env, testDir, "join -t ',' a.csv b.csv"); - }); - - it("should print unpairable lines with -a 1", async () => { - const env = await setupFiles(testDir, { - "a.txt": "1 apple\n2 banana\n3 cherry\n", - "b.txt": "1 red\n3 red\n", - }); - await compareOutputs(env, testDir, "join -a 1 a.txt b.txt"); - }); - - it("should print unpairable lines with -a 2", async () => { - const env = await setupFiles(testDir, { - "a.txt": "1 apple\n", - "b.txt": "1 red\n2 yellow\n", - }); - await compareOutputs(env, testDir, "join -a 2 a.txt b.txt"); - }); - - it("should output only unpairable lines with -v", async () => { - const env = await setupFiles(testDir, { - "a.txt": "1 apple\n2 banana\n3 cherry\n", - "b.txt": "1 red\n3 red\n", - }); - await compareOutputs(env, testDir, "join -v 1 a.txt b.txt"); - }); - - it("should handle empty files", async () => { - const env = await setupFiles(testDir, { - "a.txt": "", - "b.txt": "1 x\n", - }); - await compareOutputs(env, testDir, "join a.txt b.txt"); - }); -}); diff --git a/src/comparison-tests/cut.comparison.test.ts b/src/comparison-tests/cut.comparison.test.ts deleted file mode 100644 index 6b113384..00000000 --- a/src/comparison-tests/cut.comparison.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("cut command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("-d and -f (delimiter and field)", () => { - it("should cut first field with colon delimiter", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a:b:c\nd:e:f\ng:h:i\n", - }); - await compareOutputs(env, testDir, "cut -d: -f1 test.txt"); - }); - - it("should cut second field", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a:b:c\nd:e:f\ng:h:i\n", - }); - await compareOutputs(env, testDir, "cut -d: -f2 test.txt"); - }); - - it("should cut third field", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a:b:c\nd:e:f\ng:h:i\n", - }); - await compareOutputs(env, testDir, "cut -d: -f3 test.txt"); - }); - - it("should cut multiple fields", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a:b:c:d\ne:f:g:h\n", - }); - await compareOutputs(env, testDir, "cut -d: -f1,3 test.txt"); - }); - - it("should cut field range", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a:b:c:d\ne:f:g:h\n", - }); - await compareOutputs(env, testDir, "cut -d: -f2-4 test.txt"); - }); - - it("should handle tab delimiter", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a\tb\tc\nd\te\tf\n", - }); - await compareOutputs(env, testDir, "cut -f1 test.txt"); - }); - - it("should handle comma delimiter", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a,b,c\nd,e,f\n", - }); - await compareOutputs(env, testDir, "cut -d, -f2 test.txt"); - }); - - it("should handle space delimiter", async () => { - const env = await setupFiles(testDir, { - "test.txt": "one two three\nfour five six\n", - }); - await compareOutputs(env, testDir, 'cut -d" " -f2 test.txt'); - }); - }); - - describe("-c (characters)", () => { - it("should cut single character", async () => { - const env = await setupFiles(testDir, { - "test.txt": "abcdefghij\n1234567890\n", - }); - await compareOutputs(env, testDir, "cut -c1 test.txt"); - }); - - it("should cut character range", async () => { - const env = await setupFiles(testDir, { - "test.txt": "abcdefghij\n1234567890\n", - }); - await compareOutputs(env, testDir, "cut -c1-5 test.txt"); - }); - - it("should cut multiple characters", async () => { - const env = await setupFiles(testDir, { - "test.txt": "abcdefghij\n1234567890\n", - }); - await compareOutputs(env, testDir, "cut -c1,3,5 test.txt"); - }); - - it("should cut from character to end", async () => { - const env = await setupFiles(testDir, { - "test.txt": "abcdefghij\n1234567890\n", - }); - await compareOutputs(env, testDir, "cut -c5- test.txt"); - }); - - it("should cut from start to character", async () => { - const env = await setupFiles(testDir, { - "test.txt": "abcdefghij\n1234567890\n", - }); - await compareOutputs(env, testDir, "cut -c-5 test.txt"); - }); - }); - - describe("stdin", () => { - it("should read from stdin", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, 'echo "a:b:c" | cut -d: -f2'); - }); - - it("should cut characters from stdin", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, 'echo "hello" | cut -c1-3'); - }); - }); - - describe("edge cases", () => { - it("should handle missing field", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a:b\nc:d:e\n", - }); - await compareOutputs(env, testDir, "cut -d: -f3 test.txt"); - }); - - it("should handle empty fields", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a::c\n:b:\n", - }); - await compareOutputs(env, testDir, "cut -d: -f2 test.txt"); - }); - }); -}); diff --git a/src/comparison-tests/echo.comparison.test.ts b/src/comparison-tests/echo.comparison.test.ts deleted file mode 100644 index ee455a11..00000000 --- a/src/comparison-tests/echo.comparison.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("echo command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should match simple string", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "echo hello"); - }); - - it("should match double-quoted string", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, 'echo "hello world"'); - }); - - it("should match single-quoted string", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "echo 'single quotes'"); - }); - - it("should match -n flag (no newline)", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "echo -n hello"); - }); - - it("should match multiple arguments", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "echo one two three"); - }); - - it("should match empty echo", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "echo"); - }); - - it("should match echo with special characters", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, 'echo "hello * world"'); - }); - - it("should match echo with escaped quotes", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, 'echo "say \\"hello\\""'); - }); - - it("should match -e flag with newline", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, 'echo -e "line1\\nline2"'); - }); - - it("should match -e flag with tab", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, 'echo -e "col1\\tcol2"'); - }); -}); diff --git a/src/comparison-tests/env.comparison.test.ts b/src/comparison-tests/env.comparison.test.ts deleted file mode 100644 index 078f9e4d..00000000 --- a/src/comparison-tests/env.comparison.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; -import { - cleanupTestDir, - createTestDir, - runRealBash, - setupFiles, -} from "./fixture-runner.js"; - -describe("env command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("output format", () => { - it("should output in KEY=value format", async () => { - // Create env with known variables - const env = new Bash({ - cwd: testDir, - env: { TEST_VAR: "test_value" }, - }); - - const envResult = await env.exec("env"); - - // Check that it contains TEST_VAR=test_value - expect(envResult.stdout).toContain("TEST_VAR=test_value"); - expect(envResult.exitCode).toBe(0); - }); - }); -}); - -describe("printenv command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("specific variable", () => { - it("should print specific variable value", async () => { - // Use a common environment variable that exists in both - const env = new Bash({ - cwd: testDir, - env: { HOME: "/home/testuser" }, - }); - - const envResult = await env.exec("printenv HOME"); - expect(envResult.stdout).toBe("/home/testuser\n"); - expect(envResult.exitCode).toBe(0); - }); - - it("should return exit code 1 for non-existent variable", async () => { - const env = await setupFiles(testDir, {}); - - const envResult = await env.exec("printenv NONEXISTENT_VAR_12345"); - const realResult = await runRealBash( - "printenv NONEXISTENT_VAR_12345", - testDir, - ); - - expect(envResult.exitCode).toBe(realResult.exitCode); - expect(envResult.exitCode).toBe(1); - }); - }); - - describe("multiple variables", () => { - it("should print multiple variable values", async () => { - const env = new Bash({ - cwd: testDir, - env: { VAR1: "value1", VAR2: "value2" }, - }); - - const envResult = await env.exec("printenv VAR1 VAR2"); - expect(envResult.stdout).toBe("value1\nvalue2\n"); - expect(envResult.exitCode).toBe(0); - }); - }); -}); diff --git a/src/comparison-tests/export.comparison.test.ts b/src/comparison-tests/export.comparison.test.ts deleted file mode 100644 index 349ffe81..00000000 --- a/src/comparison-tests/export.comparison.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("export command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("setting variables", () => { - it("should set and use a variable", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "export FOO=bar; echo $FOO"); - }); - - it("should set multiple variables", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "export A=1 B=2 C=3; echo $A $B $C"); - }); - - it("should handle value with equals sign", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "export URL='http://x.com?a=1'; echo $URL", - ); - }); - - it("should handle empty value", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, 'export EMPTY=; echo "[$EMPTY]"'); - }); - }); - - describe("variable usage", () => { - it("should be available in subshell", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "export FOO=bar; (echo $FOO)"); - }); - - it("should work with test command", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'export VAL=yes; [ "$VAL" = "yes" ] && echo matched', - ); - }); - - it("should work with numeric comparison", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "export NUM=10; [ $NUM -gt 5 ] && echo greater", - ); - }); - - it("should work in string interpolation", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'export NAME=world; echo "hello $NAME"', - ); - }); - }); - - describe("inline export", () => { - it("should allow setting and using in same line", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "export X=42 && echo $X"); - }); - }); -}); diff --git a/src/comparison-tests/file-operations.comparison.test.ts b/src/comparison-tests/file-operations.comparison.test.ts deleted file mode 100644 index 44039318..00000000 --- a/src/comparison-tests/file-operations.comparison.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - cleanupTestDir, - createTestDir, - path, - runRealBash, - setupFiles, -} from "./fixture-runner.js"; - -describe("mkdir command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should create a single directory", async () => { - const env = await setupFiles(testDir, {}); - - await env.exec("mkdir newdir"); - await runRealBash("mkdir newdir2", testDir); - - // Check both directories exist - const envResult = await env.exec("ls -1"); - const realResult = await runRealBash("ls -1", testDir); - expect(envResult.stdout).toContain("newdir"); - expect(realResult.stdout).toContain("newdir2"); - }); - - it("should create nested directories with -p", async () => { - const env = await setupFiles(testDir, {}); - - await env.exec("mkdir -p a/b/c"); - await runRealBash("mkdir -p a2/b2/c2", testDir); - - const envResult = await env.exec("ls a/b"); - const realResult = await runRealBash("ls a2/b2", testDir); - expect(envResult.stdout.trim()).toBe("c"); - expect(realResult.stdout.trim()).toBe("c2"); - }); - - it("should not fail with -p on existing directory", async () => { - const env = await setupFiles(testDir, { - "existing/.gitkeep": "", - }); - - const result = await env.exec("mkdir -p existing"); - expect(result.exitCode).toBe(0); - }); -}); - -describe("rm command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should remove a file", async () => { - const env = await setupFiles(testDir, { - "file.txt": "content", - }); - - await env.exec("rm file.txt"); - - const result = await env.exec("ls"); - expect(result.stdout).not.toContain("file.txt"); - }); - - it("should remove multiple files", async () => { - const env = await setupFiles(testDir, { - "a.txt": "", - "b.txt": "", - "c.txt": "", - }); - - await env.exec("rm a.txt b.txt"); - - const result = await env.exec("ls"); - expect(result.stdout).not.toContain("a.txt"); - expect(result.stdout).not.toContain("b.txt"); - expect(result.stdout).toContain("c.txt"); - }); - - it("should remove directory with -r", async () => { - const env = await setupFiles(testDir, { - "dir/file.txt": "content", - }); - - await env.exec("rm -r dir"); - - const result = await env.exec("ls"); - expect(result.stdout).not.toContain("dir"); - }); - - it("should handle -f for non-existent file", async () => { - const env = await setupFiles(testDir, {}); - - const result = await env.exec("rm -f nonexistent.txt"); - expect(result.exitCode).toBe(0); - }); -}); - -describe("cp command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should copy a file", async () => { - const env = await setupFiles(testDir, { - "source.txt": "hello world\n", - }); - - await env.exec("cp source.txt dest.txt"); - - const sourceContent = await env.readFile(path.join(testDir, "source.txt")); - const destContent = await env.readFile(path.join(testDir, "dest.txt")); - expect(destContent).toBe(sourceContent); - }); - - it("should copy file to directory", async () => { - const env = await setupFiles(testDir, { - "file.txt": "content\n", - "dir/.gitkeep": "", - }); - - await env.exec("cp file.txt dir/"); - - const content = await env.readFile(path.join(testDir, "dir/file.txt")); - expect(content).toBe("content\n"); - }); - - it("should copy directory with -r", async () => { - const env = await setupFiles(testDir, { - "src/a.txt": "a content\n", - "src/b.txt": "b content\n", - }); - - await env.exec("cp -r src dest"); - - const result = await env.exec("ls dest"); - expect(result.stdout).toContain("a.txt"); - expect(result.stdout).toContain("b.txt"); - }); - - it("should copy multiple files to directory", async () => { - const env = await setupFiles(testDir, { - "a.txt": "a\n", - "b.txt": "b\n", - "dir/.gitkeep": "", - }); - - await env.exec("cp a.txt b.txt dir/"); - - const result = await env.exec("ls dir"); - expect(result.stdout).toContain("a.txt"); - expect(result.stdout).toContain("b.txt"); - }); -}); - -describe("mv command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should rename a file", async () => { - const env = await setupFiles(testDir, { - "old.txt": "content\n", - }); - - await env.exec("mv old.txt new.txt"); - - const result = await env.exec("ls"); - expect(result.stdout).not.toContain("old.txt"); - expect(result.stdout).toContain("new.txt"); - }); - - it("should move file to directory", async () => { - const env = await setupFiles(testDir, { - "file.txt": "content\n", - "dir/.gitkeep": "", - }); - - await env.exec("mv file.txt dir/"); - - const rootLs = await env.exec("ls"); - const dirLs = await env.exec("ls dir"); - expect(rootLs.stdout).not.toContain("file.txt"); - expect(dirLs.stdout).toContain("file.txt"); - }); - - it("should rename a directory", async () => { - const env = await setupFiles(testDir, { - "olddir/file.txt": "content\n", - }); - - await env.exec("mv olddir newdir"); - - const result = await env.exec("ls"); - expect(result.stdout).not.toContain("olddir"); - expect(result.stdout).toContain("newdir"); - }); - - it("should move multiple files to directory", async () => { - const env = await setupFiles(testDir, { - "a.txt": "a\n", - "b.txt": "b\n", - "dir/.gitkeep": "", - }); - - await env.exec("mv a.txt b.txt dir/"); - - const rootLs = await env.exec("ls"); - const dirLs = await env.exec("ls dir"); - expect(rootLs.stdout).not.toContain("a.txt"); - expect(rootLs.stdout).not.toContain("b.txt"); - expect(dirLs.stdout).toContain("a.txt"); - expect(dirLs.stdout).toContain("b.txt"); - }); -}); - -describe("touch command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should create an empty file", async () => { - const env = await setupFiles(testDir, {}); - - await env.exec("touch newfile.txt"); - - const result = await env.exec("ls"); - expect(result.stdout).toContain("newfile.txt"); - - const content = await env.readFile(path.join(testDir, "newfile.txt")); - expect(content).toBe(""); - }); - - it("should create multiple files", async () => { - const env = await setupFiles(testDir, {}); - - await env.exec("touch a.txt b.txt c.txt"); - - const result = await env.exec("ls"); - expect(result.stdout).toContain("a.txt"); - expect(result.stdout).toContain("b.txt"); - expect(result.stdout).toContain("c.txt"); - }); - - it("should not modify existing file content", async () => { - const env = await setupFiles(testDir, { - "existing.txt": "original content\n", - }); - - await env.exec("touch existing.txt"); - - const content = await env.readFile(path.join(testDir, "existing.txt")); - expect(content).toBe("original content\n"); - }); -}); - -describe("pwd command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should output current working directory", async () => { - const env = await setupFiles(testDir, {}); - - const result = await env.exec("pwd"); - - // On macOS, /var is a symlink to /private/var, so just check basename - const baseName = path.basename(testDir); - expect(result.stdout.trim()).toContain(baseName); - expect(result.exitCode).toBe(0); - }); -}); diff --git a/src/comparison-tests/find.comparison.test.ts b/src/comparison-tests/find.comparison.test.ts deleted file mode 100644 index 064f4fd0..00000000 --- a/src/comparison-tests/find.comparison.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("find command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("-name option", () => { - it("should match with -name glob", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "", - "file2.js": "", - "subdir/file3.txt": "", - }); - await compareOutputs(env, testDir, 'find . -name "*.txt" | sort'); - }); - - it("should match exact name", async () => { - const env = await setupFiles(testDir, { - "target.txt": "", - "other.txt": "", - "dir/target.txt": "", - }); - await compareOutputs(env, testDir, 'find . -name "target.txt" | sort'); - }); - - it("should match with ? wildcard", async () => { - const env = await setupFiles(testDir, { - "a1.txt": "", - "a2.txt": "", - "a10.txt": "", - "b1.txt": "", - }); - await compareOutputs(env, testDir, 'find . -name "a?.txt" | sort'); - }); - }); - - describe("-type option", () => { - it("should match -type f (files only)", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "", - "subdir/file2.txt": "", - }); - await compareOutputs(env, testDir, "find . -type f | sort"); - }); - - it("should match -type d (directories only)", async () => { - const env = await setupFiles(testDir, { - "dir1/file.txt": "", - "dir2/file.txt": "", - }); - await compareOutputs(env, testDir, "find . -type d | sort"); - }); - }); - - describe("combining options", () => { - it("should combine -name and -type", async () => { - const env = await setupFiles(testDir, { - "test.txt": "", - "test.js": "", - "sub/test.txt": "", - }); - await compareOutputs(env, testDir, 'find . -name "*.txt" -type f | sort'); - }); - - it("should use -o for OR logic", async () => { - const env = await setupFiles(testDir, { - "file.txt": "", - "file.js": "", - "file.md": "", - }); - await compareOutputs( - env, - testDir, - 'find . -name "*.txt" -o -name "*.js" | sort', - ); - }); - }); - - describe("path handling", () => { - it("should find from current directory with .", async () => { - const env = await setupFiles(testDir, { - "file.txt": "", - "dir/file.txt": "", - }); - await compareOutputs(env, testDir, 'find . -name "*.txt" | sort'); - }); - - it("should find from specific directory", async () => { - const env = await setupFiles(testDir, { - "dir/a.txt": "", - "dir/b.txt": "", - "other/c.txt": "", - }); - await compareOutputs(env, testDir, 'find dir -name "*.txt" | sort'); - }); - - it("should include the starting directory when it matches", async () => { - const env = await setupFiles(testDir, { - "dir/file.txt": "", - }); - await compareOutputs(env, testDir, "find . -type d | sort"); - }); - }); - - describe("edge cases", () => { - it("should handle deeply nested directories", async () => { - const env = await setupFiles(testDir, { - "a/b/c/d/file.txt": "", - }); - await compareOutputs(env, testDir, 'find . -name "file.txt" | sort'); - }); - - it("should handle hidden files", async () => { - const env = await setupFiles(testDir, { - ".hidden": "", - "visible.txt": "", - "dir/.also-hidden": "", - }); - await compareOutputs(env, testDir, 'find . -name ".*" | sort'); - }); - }); - - describe("-maxdepth option", () => { - it("should limit to depth 0 (starting point only)", async () => { - const env = await setupFiles(testDir, { - "file.txt": "", - "dir/file.txt": "", - }); - await compareOutputs(env, testDir, "find . -maxdepth 0 | sort"); - }); - - it("should limit to depth 1 (immediate children)", async () => { - const env = await setupFiles(testDir, { - "file.txt": "", - "dir/file.txt": "", - "dir/sub/file.txt": "", - }); - await compareOutputs(env, testDir, "find . -maxdepth 1 | sort"); - }); - - it("should limit to depth 2", async () => { - const env = await setupFiles(testDir, { - "a.txt": "", - "dir/b.txt": "", - "dir/sub/c.txt": "", - "dir/sub/deep/d.txt": "", - }); - await compareOutputs(env, testDir, "find . -maxdepth 2 | sort"); - }); - - it("should combine -maxdepth with -name", async () => { - const env = await setupFiles(testDir, { - "a.txt": "", - "dir/b.txt": "", - "dir/sub/c.txt": "", - }); - await compareOutputs( - env, - testDir, - 'find . -maxdepth 2 -name "*.txt" | sort', - ); - }); - - it("should combine -maxdepth with -type", async () => { - const env = await setupFiles(testDir, { - "file.txt": "", - "dir/file.txt": "", - "dir/sub/file.txt": "", - }); - await compareOutputs(env, testDir, "find . -maxdepth 1 -type f | sort"); - }); - }); - - describe("-mindepth option", () => { - it("should skip depth 0", async () => { - const env = await setupFiles(testDir, { - "file.txt": "", - "dir/file.txt": "", - }); - await compareOutputs(env, testDir, "find . -mindepth 1 | sort"); - }); - - it("should skip depths 0 and 1", async () => { - const env = await setupFiles(testDir, { - "a.txt": "", - "dir/b.txt": "", - "dir/sub/c.txt": "", - }); - await compareOutputs(env, testDir, "find . -mindepth 2 | sort"); - }); - - it("should combine -mindepth with -type d", async () => { - const env = await setupFiles(testDir, { - "dir/sub/file.txt": "", - }); - await compareOutputs(env, testDir, "find . -mindepth 1 -type d | sort"); - }); - }); - - describe("combined -maxdepth and -mindepth", () => { - it("should find only at specific depth range", async () => { - const env = await setupFiles(testDir, { - "a.txt": "", - "dir/b.txt": "", - "dir/sub/c.txt": "", - "dir/sub/deep/d.txt": "", - }); - await compareOutputs( - env, - testDir, - "find . -mindepth 1 -maxdepth 2 | sort", - ); - }); - - it("should find files only at depth 1", async () => { - const env = await setupFiles(testDir, { - "a.txt": "", - "dir/b.txt": "", - "dir/sub/c.txt": "", - }); - await compareOutputs( - env, - testDir, - "find . -mindepth 1 -maxdepth 1 -type f | sort", - ); - }); - }); -}); diff --git a/src/comparison-tests/fixture-runner.ts b/src/comparison-tests/fixture-runner.ts deleted file mode 100644 index d4a2e025..00000000 --- a/src/comparison-tests/fixture-runner.ts +++ /dev/null @@ -1,500 +0,0 @@ -import { exec } from "node:child_process"; -import { createHash } from "node:crypto"; -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; -import { promisify } from "node:util"; -import { Bash } from "../Bash.js"; - -const execAsync: ( - command: string, - options?: { cwd?: string; shell?: string }, -) => Promise<{ stdout: string; stderr: string }> = promisify(exec); - -/** - * Check if we're in record mode (recording bash outputs to fixtures) - * - "1" = record mode, but skip locked fixtures - * - "force" = record mode, overwrite even locked fixtures - */ -export const isRecordMode: boolean = - process.env.RECORD_FIXTURES === "1" || - process.env.RECORD_FIXTURES === "force"; - -/** - * Force mode overwrites even locked fixtures - */ -const isForceRecordMode: boolean = process.env.RECORD_FIXTURES === "force"; - -/** - * Fixture entry for a single test case - */ -export interface FixtureEntry { - command: string; - files: Record; - stdout: string; - stderr: string; - exitCode: number; - /** - * If true, this fixture has been manually adjusted (e.g., for Linux behavior) - * and will not be overwritten during recording unless RECORD_FIXTURES=force - */ - locked?: boolean; -} - -/** - * Fixtures file format - keyed by fixture ID - */ -export interface FixturesFile { - [fixtureId: string]: FixtureEntry; -} - -/** - * In-memory cache of loaded fixtures per test file - */ -const fixturesCache = new Map(); - -/** - * Pending fixtures to write (accumulated during test run in record mode) - */ -const pendingFixtures = new Map(); - -/** - * Store the files set up by setupFiles so compareOutputs can access them - * Key is testDir path, value is the files object - */ -const setupFilesRegistry = new Map>(); - -/** - * Generate a unique fixture ID from command and files - */ -function generateFixtureId( - command: string, - files: Record, -): string { - // Sort files for consistent hashing - const sortedFiles = Object.keys(files) - .sort() - .map((k) => `${k}:${files[k]}`) - .join("|"); - const content = `${command}|||${sortedFiles}`; - return createHash("sha256").update(content).digest("hex").slice(0, 16); -} - -/** - * Get the fixtures file path for a test file - */ -function getFixturesPath(testFile: string): string { - const dir = path.dirname(testFile); - const base = path.basename(testFile, ".test.ts"); - return path.join(dir, "fixtures", `${base}.fixtures.json`); -} - -/** - * Load fixtures from disk - */ -async function loadFixtures(testFile: string): Promise { - const cached = fixturesCache.get(testFile); - if (cached) { - return cached; - } - - const fixturesPath = getFixturesPath(testFile); - try { - const content = await fs.readFile(fixturesPath, "utf-8"); - const fixtures = JSON.parse(content) as FixturesFile; - fixturesCache.set(testFile, fixtures); - return fixtures; - } catch { - // No fixtures file yet - // @banned-pattern-ignore: test infrastructure, keys are fixture IDs from developer-controlled test files - const empty: FixturesFile = {}; - fixturesCache.set(testFile, empty); - return empty; - } -} - -/** - * Track which fixtures were skipped due to being locked - */ -const skippedLockedFixtures: Array<{ - testFile: string; - fixtureId: string; - command: string; -}> = []; - -/** - * Save a fixture entry (in record mode) - * Returns true if recorded, false if skipped due to lock - */ -async function recordFixture( - testFile: string, - fixtureId: string, - entry: FixtureEntry, -): Promise { - // Check if existing fixture is locked - if (!isForceRecordMode) { - const existingFixtures = await loadFixtures(testFile); - const existing = existingFixtures[fixtureId]; - if (existing?.locked) { - skippedLockedFixtures.push({ - testFile, - fixtureId, - command: entry.command, - }); - return false; - } - } - - let fixtures = pendingFixtures.get(testFile); - if (!fixtures) { - fixtures = {}; - pendingFixtures.set(testFile, fixtures); - } - fixtures[fixtureId] = entry; - return true; -} - -/** - * Write all pending fixtures to disk (call after all tests complete) - */ -export async function writeAllFixtures(): Promise { - for (const [testFile, newFixtures] of pendingFixtures.entries()) { - const fixturesPath = getFixturesPath(testFile); - - // Ensure fixtures directory exists - await fs.mkdir(path.dirname(fixturesPath), { recursive: true }); - - // Load existing fixtures and merge - // @banned-pattern-ignore: test infrastructure, keys are fixture IDs from developer-controlled test files - let existingFixtures: FixturesFile = {}; - try { - const content = await fs.readFile(fixturesPath, "utf-8"); - existingFixtures = JSON.parse(content) as FixturesFile; - } catch { - // No existing file - } - - // Merge new fixtures (new ones overwrite old, but preserve locked status) - const mergedFixtures = { ...existingFixtures }; - for (const [key, value] of Object.entries(newFixtures)) { - // Preserve locked status from existing fixture if not in force mode - const existing = existingFixtures[key]; - if (existing?.locked && !isForceRecordMode) { - // Keep existing locked fixture - continue; - } - mergedFixtures[key] = value; - } - - // Sort by fixture ID for consistent output - // @banned-pattern-ignore: test infrastructure, keys are fixture IDs from developer-controlled test files - const sortedFixtures: FixturesFile = {}; - for (const key of Object.keys(mergedFixtures).sort()) { - sortedFixtures[key] = mergedFixtures[key]; - } - - await fs.writeFile( - fixturesPath, - `${JSON.stringify(sortedFixtures, null, 2)}\n`, - ); - console.log(`Wrote fixtures to ${fixturesPath}`); - } - - // Report skipped locked fixtures - if (skippedLockedFixtures.length > 0) { - console.log( - "\n⚠️ Skipped locked fixtures (use RECORD_FIXTURES=force to override):", - ); - for (const { testFile, command } of skippedLockedFixtures) { - const basename = path.basename(testFile); - console.log(` - ${basename}: "${command}"`); - } - } -} - -/** - * Creates a unique temp directory for testing - */ -export async function createTestDir(): Promise { - const testDir = path.join( - os.tmpdir(), - `bashenv-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - await fs.mkdir(testDir, { recursive: true }); - return testDir; -} - -/** - * Cleans up the temp directory - */ -export async function cleanupTestDir(testDir: string): Promise { - // Clean up registry entry - setupFilesRegistry.delete(testDir); - - try { - await fs.rm(testDir, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } -} - -/** - * Options for comparing outputs - */ -export interface CompareOptions { - compareStderr?: boolean; - compareExitCode?: boolean; - normalizeWhitespace?: boolean; -} - -/** - * Sets up test files in both real FS and creates a BashEnv - */ -export async function setupFiles( - testDir: string, - files: Record, -): Promise { - // Store files in registry for compareOutputs to access - setupFilesRegistry.set(testDir, files); - - // Create files in real FS (needed for tests that use runRealBash directly) - for (const [filePath, content] of Object.entries(files)) { - const fullPath = path.join(testDir, filePath); - await fs.mkdir(path.dirname(fullPath), { recursive: true }); - await fs.writeFile(fullPath, content); - } - - // Create equivalent BashEnv with normalized paths - // @banned-pattern-ignore: path.join() produces full paths like "/tmp/test/file", never "__proto__" - const bashEnvFiles: Record = {}; - for (const [filePath, content] of Object.entries(files)) { - bashEnvFiles[path.join(testDir, filePath)] = content; - } - - return new Bash({ - files: bashEnvFiles, - cwd: testDir, - }); -} - -/** - * Runs a command in real bash - */ -export async function runRealBash( - command: string, - cwd: string, -): Promise<{ stdout: string; stderr: string; exitCode: number }> { - try { - const { stdout, stderr } = await execAsync(command, { - cwd, - shell: "/bin/bash", - }); - return { stdout, stderr, exitCode: 0 }; - } catch (error: unknown) { - const err = error as { stdout?: string; stderr?: string; code?: number }; - return { - stdout: err.stdout || "", - stderr: err.stderr || "", - exitCode: err.code || 1, - }; - } -} - -/** - * Normalizes whitespace in output for comparison. - * Useful for commands like `wc` where BSD and GNU have different column widths. - */ -function normalizeWhitespace(str: string): string { - return str - .split("\n") - .map((line) => line.trim().replace(/\s+/g, " ")) - .join("\n"); -} - -/** - * Convert file:// URL to path - */ -function fileUrlToPath(url: string): string { - if (!url) return ""; - if (url.startsWith("file://")) { - return url.slice(7); - } - return url; -} - -/** - * Get the calling test file path from the stack trace - * Works with both vitest and regular Node.js stack traces - */ -function getCallingTestFile(): string { - const err = new Error(); - const stack = err.stack || ""; - const lines = stack.split("\n"); - - // Look for comparison test file patterns in stack trace - // Stack traces can have formats like: - // - "at func (file:///path/to/file.ts:line:col)" - // - "at func (/path/to/file.ts:line:col)" - // - "at file:///path/to/file.ts:line:col" - for (const line of lines) { - // Match file:// URLs - let match = line.match(/file:\/\/([^):]+\.comparison\.test\.ts)/); - if (match) { - return match[1]; - } - // Match regular paths in parentheses - match = line.match(/\(([^):]+\.comparison\.test\.ts)/); - if (match) { - return match[1]; - } - // Match paths without parentheses (at path:line:col) - match = line.match(/at\s+([^():]+\.comparison\.test\.ts)/); - if (match) { - return match[1].trim(); - } - } - - // If no comparison test found, fall back to any test file - for (const line of lines) { - let match = line.match(/file:\/\/([^):]+\.test\.ts)/); - if (match) { - return match[1]; - } - match = line.match(/\(([^):]+\.test\.ts)/); - if (match) { - return match[1]; - } - } - - throw new Error( - `Could not determine calling test file from stack trace:\n${stack}`, - ); -} - -/** - * Internal comparison function that takes all parameters explicitly - */ -async function compareOutputsInternal( - env: Bash, - testDir: string, - command: string, - files: Record, - testFile: string, - options?: CompareOptions, -): Promise { - // Run BashEnv - const bashEnvResult = await env.exec(command); - - const fixtureId = generateFixtureId(command, files); - - let realBashStdout: string; - let realBashStderr: string; - let realBashExitCode: number; - - if (isRecordMode) { - // Check if fixture is locked - if so, use existing fixture values - const existingFixtures = await loadFixtures(testFile); - const existingFixture = existingFixtures[fixtureId]; - - if (existingFixture?.locked && !isForceRecordMode) { - // Use locked fixture values, don't run real bash - realBashStdout = existingFixture.stdout; - realBashStderr = existingFixture.stderr; - realBashExitCode = existingFixture.exitCode; - skippedLockedFixtures.push({ testFile, fixtureId, command }); - } else { - // Run real bash and save to fixtures - const realBashResult = await runRealBash(command, testDir); - realBashStdout = realBashResult.stdout; - realBashStderr = realBashResult.stderr; - realBashExitCode = realBashResult.exitCode; - - await recordFixture(testFile, fixtureId, { - command, - files, - stdout: realBashStdout, - stderr: realBashStderr, - exitCode: realBashExitCode, - }); - } - } else { - // In playback mode, load from fixtures - const fixtures = await loadFixtures(testFile); - const fixture = fixtures[fixtureId]; - - if (!fixture) { - throw new Error( - `No fixture found for command "${command}" with files ${JSON.stringify(files)}.\n` + - `Fixture ID: ${fixtureId}\n` + - `Run with RECORD_FIXTURES=1 to record fixtures.`, - ); - } - - realBashStdout = fixture.stdout; - realBashStderr = fixture.stderr; - realBashExitCode = fixture.exitCode; - } - - let bashEnvStdout = bashEnvResult.stdout; - let expectedStdout = realBashStdout; - - if (options?.normalizeWhitespace) { - bashEnvStdout = normalizeWhitespace(bashEnvStdout); - expectedStdout = normalizeWhitespace(expectedStdout); - } - - if (bashEnvStdout !== expectedStdout) { - throw new Error( - `stdout mismatch for "${command}"\n` + - `Expected (recorded bash): ${JSON.stringify(realBashStdout)}\n` + - `Received (BashEnv): ${JSON.stringify(bashEnvResult.stdout)}`, - ); - } - - if (options?.compareExitCode !== false) { - if (bashEnvResult.exitCode !== realBashExitCode) { - throw new Error( - `exitCode mismatch for "${command}"\n` + - `Expected (recorded bash): ${realBashExitCode}\n` + - `Received (BashEnv): ${bashEnvResult.exitCode}`, - ); - } - } -} - -/** - * Compares BashEnv output with recorded bash output (from fixtures) - * In record mode, runs real bash and saves the output to fixtures - * - * @param env - BashEnv instance - * @param testDir - Test directory path - * @param command - Command to run - * @param options - Comparison options (optional) - * @param files - Files that were set up (optional, auto-retrieved from setupFiles registry) - * @param testFileUrl - import.meta.url of the test file (optional, falls back to stack trace) - */ -export async function compareOutputs( - env: Bash, - testDir: string, - command: string, - options?: CompareOptions, - files?: Record, - testFileUrl?: string, -): Promise { - const testFile = testFileUrl - ? fileUrlToPath(testFileUrl) - : getCallingTestFile(); - // Get files from registry if not provided - // @banned-pattern-ignore: test infrastructure with known file paths, not user data - const testFiles = files || setupFilesRegistry.get(testDir) || {}; - return compareOutputsInternal( - env, - testDir, - command, - testFiles, - testFile, - options, - ); -} - -export { path, fs }; diff --git a/src/comparison-tests/fixtures/alias.comparison.fixtures.json b/src/comparison-tests/fixtures/alias.comparison.fixtures.json deleted file mode 100644 index 370309d0..00000000 --- a/src/comparison-tests/fixtures/alias.comparison.fixtures.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "2cab91f70ea1cb84": { - "command": "alias notexists || echo failed", - "files": {}, - "stdout": "failed\n", - "stderr": "/bin/bash: line 1: alias: notexists: not found\n", - "exitCode": 0, - "locked": true - }, - "b7f201402670991c": { - "command": "alias greet='echo hi'; unalias greet; alias greet || echo removed", - "files": {}, - "stdout": "removed\n", - "stderr": "/bin/bash: line 1: alias: greet: not found\n", - "exitCode": 0, - "locked": true - }, - "c2151098e11aee6e": { - "command": "unalias nonexistent || echo not_found", - "files": {}, - "stdout": "not_found\n", - "stderr": "/bin/bash: line 1: unalias: nonexistent: not found\n", - "exitCode": 0, - "locked": true - } -} diff --git a/src/comparison-tests/fixtures/awk.comparison.fixtures.json b/src/comparison-tests/fixtures/awk.comparison.fixtures.json deleted file mode 100644 index 4c5a6263..00000000 --- a/src/comparison-tests/fixtures/awk.comparison.fixtures.json +++ /dev/null @@ -1,196 +0,0 @@ -{ - "02bc47681bf837a6": { - "command": "awk 'BEGIN{print \"start\"} {print $0}' data.txt", - "files": { - "data.txt": "line1\nline2\n" - }, - "stdout": "start\nline1\nline2\n", - "stderr": "", - "exitCode": 0 - }, - "0c92182419fc9d28": { - "command": "awk '{printf \"%s!\\n\", $1}' data.txt", - "files": { - "data.txt": "hello world\n" - }, - "stdout": "hello!\n", - "stderr": "", - "exitCode": 0 - }, - "12b6b6d5b72e83c0": { - "command": "awk '{print $1 + $2}' data.txt", - "files": { - "data.txt": "10 20\n5 15\n" - }, - "stdout": "30\n20\n", - "stderr": "", - "exitCode": 0 - }, - "13060f7ecbaa4d28": { - "command": "awk 'NR==2' data.txt", - "files": { - "data.txt": "line1\nline2\nline3\n" - }, - "stdout": "line2\n", - "stderr": "", - "exitCode": 0 - }, - "144cbd2aa82098e6": { - "command": "awk '{printf \"num: %d\\n\", $1}' data.txt", - "files": { - "data.txt": "42\n" - }, - "stdout": "num: 42\n", - "stderr": "", - "exitCode": 0 - }, - "4fc1c901f73a2fbb": { - "command": "awk '{print $0} END{print \"done\"}' data.txt", - "files": { - "data.txt": "line1\nline2\n" - }, - "stdout": "line1\nline2\ndone\n", - "stderr": "", - "exitCode": 0 - }, - "5db7834b98767f99": { - "command": "awk '{print $1 \"-\" $2}' data.txt", - "files": { - "data.txt": "hello world\n" - }, - "stdout": "hello-world\n", - "stderr": "", - "exitCode": 0 - }, - "5fb5a4d01bc7c720": { - "command": "awk -F, '{print $2}' data.csv", - "files": { - "data.csv": "a,b,c\n1,2,3\n" - }, - "stdout": "b\n2\n", - "stderr": "", - "exitCode": 0 - }, - "6264f92b9b03799d": { - "command": "awk '{print $0}' data.txt", - "files": { - "data.txt": "hello world\nfoo bar\n" - }, - "stdout": "hello world\nfoo bar\n", - "stderr": "", - "exitCode": 0 - }, - "63976ead5650d0f3": { - "command": "awk '{print $1}' data.txt", - "files": { - "data.txt": "hello world\nfoo bar\n" - }, - "stdout": "hello\nfoo\n", - "stderr": "", - "exitCode": 0 - }, - "66c48e34e527d2ba": { - "command": "awk 'NR>2' data.txt", - "files": { - "data.txt": "line1\nline2\nline3\nline4\n" - }, - "stdout": "line3\nline4\n", - "stderr": "", - "exitCode": 0 - }, - "69c7e20c2d98cf61": { - "command": "awk '/^a/' data.txt", - "files": { - "data.txt": "apple\nbanana\napricot\ncherry\n" - }, - "stdout": "apple\napricot\n", - "stderr": "", - "exitCode": 0 - }, - "6db4e318ce10b410": { - "command": "awk -F: '{print $1}' data.txt", - "files": { - "data.txt": "root:x:0:0:root:/root:/bin/bash\n" - }, - "stdout": "root\n", - "stderr": "", - "exitCode": 0 - }, - "85e7f320bff75570": { - "command": "awk '{print NF}' data.txt", - "files": { - "data.txt": "one\ntwo three\na b c d\n" - }, - "stdout": "1\n2\n4\n", - "stderr": "", - "exitCode": 0 - }, - "8eca5dc6cd4af41b": { - "command": "awk -F'\\t' '{print $2}' data.tsv", - "files": { - "data.tsv": "a\tb\tc\n1\t2\t3\n" - }, - "stdout": "b\n2\n", - "stderr": "", - "exitCode": 0 - }, - "a3fb3f515b8a3f55": { - "command": "awk '{print NR, $0}' data.txt", - "files": { - "data.txt": "a\nb\nc\n" - }, - "stdout": "1 a\n2 b\n3 c\n", - "stderr": "", - "exitCode": 0 - }, - "b25e52b8fe953d50": { - "command": "echo 'a b c' | awk '{print $2}'", - "files": {}, - "stdout": "b\n", - "stderr": "", - "exitCode": 0 - }, - "c08393a24070a7cf": { - "command": "awk '{print $1, $3}' data.txt", - "files": { - "data.txt": "a b c\n1 2 3\n" - }, - "stdout": "a c\n1 3\n", - "stderr": "", - "exitCode": 0 - }, - "cc47f33e4cb2516a": { - "command": "awk '{print $1 * $2}' data.txt", - "files": { - "data.txt": "3 4\n5 6\n" - }, - "stdout": "12\n30\n", - "stderr": "", - "exitCode": 0 - }, - "d3e5d71775cfd1e6": { - "command": "awk '{print $1 - $2}' data.txt", - "files": { - "data.txt": "20 5\n100 30\n" - }, - "stdout": "15\n70\n", - "stderr": "", - "exitCode": 0 - }, - "d4640ec4826f2d8a": { - "command": "printf 'a b\\nc d\\n' | awk '{print $1}'", - "files": {}, - "stdout": "a\nc\n", - "stderr": "", - "exitCode": 0 - }, - "d9f966d997d2cf7d": { - "command": "awk 'BEGIN{print \"start\"} {print $0} END{print \"end\"}' data.txt", - "files": { - "data.txt": "a\nb\n" - }, - "stdout": "start\na\nb\nend\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/basename-dirname.comparison.fixtures.json b/src/comparison-tests/fixtures/basename-dirname.comparison.fixtures.json deleted file mode 100644 index f4f97fb0..00000000 --- a/src/comparison-tests/fixtures/basename-dirname.comparison.fixtures.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "1050447eb8fcea43": { - "command": "basename -s .txt /path/to/file.txt", - "files": {}, - "stdout": "file\n", - "stderr": "", - "exitCode": 0 - }, - "34fc89ba5a1350c1": { - "command": "basename ./path/to/file.txt", - "files": {}, - "stdout": "file.txt\n", - "stderr": "", - "exitCode": 0 - }, - "4a36b89901f4596f": { - "command": "dirname /path/to/dir/", - "files": {}, - "stdout": "/path/to\n", - "stderr": "", - "exitCode": 0 - }, - "6432d8d9b8f26c2a": { - "command": "dirname ./path/to/file.txt", - "files": {}, - "stdout": "./path/to\n", - "stderr": "", - "exitCode": 0 - }, - "6c1f73fc04d44746": { - "command": "basename /path/to/file.txt .md", - "files": {}, - "stdout": "file.txt\n", - "stderr": "", - "exitCode": 0 - }, - "78569eb44390ae76": { - "command": "basename -a /path/one.txt /path/two.txt", - "files": {}, - "stdout": "one.txt\ntwo.txt\n", - "stderr": "", - "exitCode": 0 - }, - "7b467ba5755fc78d": { - "command": "dirname /path/to/file1 /another/path/file2", - "files": {}, - "stdout": "/path/to\n/another/path\n", - "stderr": "", - "exitCode": 0 - }, - "7e773f3d686bcf22": { - "command": "basename /path/to/dir/", - "files": {}, - "stdout": "dir\n", - "stderr": "", - "exitCode": 0 - }, - "980a9e04c9919a04": { - "command": "basename file.txt", - "files": {}, - "stdout": "file.txt\n", - "stderr": "", - "exitCode": 0 - }, - "9910476a759ea007": { - "command": "basename /path/to/file.txt .txt", - "files": {}, - "stdout": "file\n", - "stderr": "", - "exitCode": 0 - }, - "becbe09b8d34b057": { - "command": "dirname file.txt", - "files": {}, - "stdout": ".\n", - "stderr": "", - "exitCode": 0 - }, - "c58fb4d57c1cf35b": { - "command": "basename /usr/bin/sort", - "files": {}, - "stdout": "sort\n", - "stderr": "", - "exitCode": 0 - }, - "ce2679abcb4aa7be": { - "command": "dirname /usr/bin/sort", - "files": {}, - "stdout": "/usr/bin\n", - "stderr": "", - "exitCode": 0 - }, - "f338c3acb6a9d20e": { - "command": "dirname /file.txt", - "files": {}, - "stdout": "/\n", - "stderr": "", - "exitCode": 0 - }, - "f5d9cb2f39b2f952": { - "command": "basename -a -s .txt /path/one.txt /path/two.txt", - "files": {}, - "stdout": "one\ntwo\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/cat.comparison.fixtures.json b/src/comparison-tests/fixtures/cat.comparison.fixtures.json deleted file mode 100644 index fc92d4cc..00000000 --- a/src/comparison-tests/fixtures/cat.comparison.fixtures.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "020bff53c7000330": { - "command": "echo \"hello\" | cat", - "files": {}, - "stdout": "hello\n", - "stderr": "", - "exitCode": 0 - }, - "42f9ff247ca8f7cd": { - "command": "echo \"from stdin\" | cat - test.txt", - "files": { - "test.txt": "from file\n" - }, - "stdout": "from stdin\nfrom file\n", - "stderr": "", - "exitCode": 0 - }, - "65267847ad4c8e53": { - "command": "cat newlines.txt", - "files": { - "newlines.txt": "\n\n\n" - }, - "stdout": "\n\n\n", - "stderr": "", - "exitCode": 0 - }, - "8dd4a90d66412d09": { - "command": "cat -n a.txt b.txt", - "files": { - "a.txt": "file a line 1\nfile a line 2\n", - "b.txt": "file b line 1\n" - }, - "stdout": " 1\tfile a line 1\n 2\tfile a line 2\n 3\tfile b line 1\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "a0b709d7c5c60114": { - "command": "cat empty.txt", - "files": { - "empty.txt": "" - }, - "stdout": "", - "stderr": "", - "exitCode": 0 - }, - "b4be835a35eac7c5": { - "command": "cat -n test.txt", - "files": { - "test.txt": "line 1\nline 2\nline 3\n" - }, - "stdout": " 1\tline 1\n 2\tline 2\n 3\tline 3\n", - "stderr": "", - "exitCode": 0 - }, - "c84d818ff334dff2": { - "command": "cat test.txt", - "files": { - "test.txt": "line 1\nline 2\nline 3\n" - }, - "stdout": "line 1\nline 2\nline 3\n", - "stderr": "", - "exitCode": 0 - }, - "cbe3ef438087c1a8": { - "command": "cat file1.txt file2.txt", - "files": { - "file1.txt": "content 1\n", - "file2.txt": "content 2\n" - }, - "stdout": "content 1\ncontent 2\n", - "stderr": "", - "exitCode": 0 - }, - "db717349fcf9c635": { - "command": "cat test.txt", - "files": { - "test.txt": "no trailing newline" - }, - "stdout": "no trailing newline", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/column-join.comparison.fixtures.json b/src/comparison-tests/fixtures/column-join.comparison.fixtures.json deleted file mode 100644 index 6370eb42..00000000 --- a/src/comparison-tests/fixtures/column-join.comparison.fixtures.json +++ /dev/null @@ -1,127 +0,0 @@ -{ - "0524985c8beefde3": { - "command": "column -t data.txt", - "files": { - "data.txt": "name age\nalice 30\nbob 25\n" - }, - "stdout": "name age\nalice 30\nbob 25\n", - "stderr": "", - "exitCode": 0 - }, - "2f59c89728536647": { - "command": "printf 'a b c\\nd e\\nf\\n' | column -t", - "files": {}, - "stdout": "a b c\nd e\nf\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "2ff081cd7851ca57": { - "command": "printf 'a,b,c\\nd,e,f\\n' | column -t -s ','", - "files": {}, - "stdout": "a b c\nd e f\n", - "stderr": "", - "exitCode": 0 - }, - "3a7789d02b09547c": { - "command": "printf 'short long\\nlonger x\\n' | column -t", - "files": {}, - "stdout": "short long\nlonger x\n", - "stderr": "", - "exitCode": 0 - }, - "4d0490f433ee62c2": { - "command": "join -t ',' a.csv b.csv", - "files": { - "a.csv": "1,apple,fruit\n2,banana,fruit\n", - "b.csv": "1,red\n2,yellow\n" - }, - "stdout": "1,apple,fruit,red\n2,banana,fruit,yellow\n", - "stderr": "", - "exitCode": 0 - }, - "63fae6fd2eee8db5": { - "command": "join -1 2 -2 1 a.txt b.txt", - "files": { - "a.txt": "apple 1\nbanana 2\n", - "b.txt": "1 red\n2 yellow\n" - }, - "stdout": "1 apple red\n2 banana yellow\n", - "stderr": "", - "exitCode": 0 - }, - "82a79335873131ff": { - "command": "join -a 1 a.txt b.txt", - "files": { - "a.txt": "1 apple\n2 banana\n3 cherry\n", - "b.txt": "1 red\n3 red\n" - }, - "stdout": "1 apple red\n2 banana\n3 cherry red\n", - "stderr": "", - "exitCode": 0 - }, - "8467ada46417268e": { - "command": "join -a 2 a.txt b.txt", - "files": { - "a.txt": "1 apple\n", - "b.txt": "1 red\n2 yellow\n" - }, - "stdout": "1 apple red\n2 yellow\n", - "stderr": "", - "exitCode": 0 - }, - "8ec225a20118589e": { - "command": "join -v 1 a.txt b.txt", - "files": { - "a.txt": "1 apple\n2 banana\n3 cherry\n", - "b.txt": "1 red\n3 red\n" - }, - "stdout": "2 banana\n", - "stderr": "", - "exitCode": 0 - }, - "8ee4bfe255d1d78c": { - "command": "printf 'a b c\\nd e f\\n' | column -t", - "files": {}, - "stdout": "a b c\nd e f\n", - "stderr": "", - "exitCode": 0 - }, - "9271dbf528b40e0b": { - "command": "join a.txt b.txt", - "files": { - "a.txt": "", - "b.txt": "1 x\n" - }, - "stdout": "", - "stderr": "", - "exitCode": 0 - }, - "af7a9f54e7595751": { - "command": "join a.txt b.txt", - "files": { - "a.txt": "1 apple\n2 banana\n", - "b.txt": "2 yellow\n3 red\n" - }, - "stdout": "2 banana yellow\n", - "stderr": "", - "exitCode": 0 - }, - "bcbfc8bea3165e36": { - "command": "printf '' | column", - "files": {}, - "stdout": "", - "stderr": "", - "exitCode": 0 - }, - "f8c3bb2cceb3dfb6": { - "command": "join a.txt b.txt", - "files": { - "a.txt": "1 apple\n2 banana\n3 cherry\n", - "b.txt": "1 red\n2 yellow\n3 red\n" - }, - "stdout": "1 apple red\n2 banana yellow\n3 cherry red\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/cut.comparison.fixtures.json b/src/comparison-tests/fixtures/cut.comparison.fixtures.json deleted file mode 100644 index bc160e7d..00000000 --- a/src/comparison-tests/fixtures/cut.comparison.fixtures.json +++ /dev/null @@ -1,151 +0,0 @@ -{ - "0cba64b76da7f808": { - "command": "cut -d: -f2-4 test.txt", - "files": { - "test.txt": "a:b:c:d\ne:f:g:h\n" - }, - "stdout": "b:c:d\nf:g:h\n", - "stderr": "", - "exitCode": 0 - }, - "28816a80e989e492": { - "command": "echo \"a:b:c\" | cut -d: -f2", - "files": {}, - "stdout": "b\n", - "stderr": "", - "exitCode": 0 - }, - "2eb408055b7a522e": { - "command": "cut -c1 test.txt", - "files": { - "test.txt": "abcdefghij\n1234567890\n" - }, - "stdout": "a\n1\n", - "stderr": "", - "exitCode": 0 - }, - "3e2ec57dea32ba0e": { - "command": "cut -d: -f1,3 test.txt", - "files": { - "test.txt": "a:b:c:d\ne:f:g:h\n" - }, - "stdout": "a:c\ne:g\n", - "stderr": "", - "exitCode": 0 - }, - "4dadb8bb49cd2b8f": { - "command": "cut -c1-5 test.txt", - "files": { - "test.txt": "abcdefghij\n1234567890\n" - }, - "stdout": "abcde\n12345\n", - "stderr": "", - "exitCode": 0 - }, - "5598daa7b2921ab3": { - "command": "cut -d: -f3 test.txt", - "files": { - "test.txt": "a:b:c\nd:e:f\ng:h:i\n" - }, - "stdout": "c\nf\ni\n", - "stderr": "", - "exitCode": 0 - }, - "5a0904ccec88b009": { - "command": "echo \"hello\" | cut -c1-3", - "files": {}, - "stdout": "hel\n", - "stderr": "", - "exitCode": 0 - }, - "6c073b55e3ef8b55": { - "command": "cut -d: -f1 test.txt", - "files": { - "test.txt": "a:b:c\nd:e:f\ng:h:i\n" - }, - "stdout": "a\nd\ng\n", - "stderr": "", - "exitCode": 0 - }, - "7e719d7ee4405bdb": { - "command": "cut -c5- test.txt", - "files": { - "test.txt": "abcdefghij\n1234567890\n" - }, - "stdout": "efghij\n567890\n", - "stderr": "", - "exitCode": 0 - }, - "82970bf54db893a1": { - "command": "cut -d, -f2 test.txt", - "files": { - "test.txt": "a,b,c\nd,e,f\n" - }, - "stdout": "b\ne\n", - "stderr": "", - "exitCode": 0 - }, - "89b537a138b0ab7f": { - "command": "cut -d: -f2 test.txt", - "files": { - "test.txt": "a::c\n:b:\n" - }, - "stdout": "\nb\n", - "stderr": "", - "exitCode": 0 - }, - "a5a570c75b2011a5": { - "command": "cut -c-5 test.txt", - "files": { - "test.txt": "abcdefghij\n1234567890\n" - }, - "stdout": "abcde\n12345\n", - "stderr": "", - "exitCode": 0 - }, - "bb380ea6e3b92bfa": { - "command": "cut -d: -f2 test.txt", - "files": { - "test.txt": "a:b:c\nd:e:f\ng:h:i\n" - }, - "stdout": "b\ne\nh\n", - "stderr": "", - "exitCode": 0 - }, - "bcabce27d2a32b75": { - "command": "cut -f1 test.txt", - "files": { - "test.txt": "a\tb\tc\nd\te\tf\n" - }, - "stdout": "a\nd\n", - "stderr": "", - "exitCode": 0 - }, - "cdee4db0798144f5": { - "command": "cut -d\" \" -f2 test.txt", - "files": { - "test.txt": "one two three\nfour five six\n" - }, - "stdout": "two\nfive\n", - "stderr": "", - "exitCode": 0 - }, - "f403dfb5d076f255": { - "command": "cut -c1,3,5 test.txt", - "files": { - "test.txt": "abcdefghij\n1234567890\n" - }, - "stdout": "ace\n135\n", - "stderr": "", - "exitCode": 0 - }, - "fad0e2104d458d84": { - "command": "cut -d: -f3 test.txt", - "files": { - "test.txt": "a:b\nc:d:e\n" - }, - "stdout": "\ne\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/echo.comparison.fixtures.json b/src/comparison-tests/fixtures/echo.comparison.fixtures.json deleted file mode 100644 index 94483d9b..00000000 --- a/src/comparison-tests/fixtures/echo.comparison.fixtures.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "217dbca3c809b994": { - "command": "echo -n hello", - "files": {}, - "stdout": "hello", - "stderr": "", - "exitCode": 0 - }, - "2a6325b6743f5466": { - "command": "echo \"hello world\"", - "files": {}, - "stdout": "hello world\n", - "stderr": "", - "exitCode": 0 - }, - "5f4ae12ab484e5ab": { - "command": "echo", - "files": {}, - "stdout": "\n", - "stderr": "", - "exitCode": 0 - }, - "9f29fc77bf0e0ee5": { - "command": "echo \"say \\\"hello\\\"\"", - "files": {}, - "stdout": "say \"hello\"\n", - "stderr": "", - "exitCode": 0 - }, - "b6db373a2a54b19b": { - "command": "echo 'single quotes'", - "files": {}, - "stdout": "single quotes\n", - "stderr": "", - "exitCode": 0 - }, - "c96e83b264c545b8": { - "command": "echo \"hello * world\"", - "files": {}, - "stdout": "hello * world\n", - "stderr": "", - "exitCode": 0 - }, - "cd1f9aa10c18f518": { - "command": "echo -e \"col1\\tcol2\"", - "files": {}, - "stdout": "col1\tcol2\n", - "stderr": "", - "exitCode": 0 - }, - "d17ea61d90fa289b": { - "command": "echo hello", - "files": {}, - "stdout": "hello\n", - "stderr": "", - "exitCode": 0 - }, - "d99299972a3e4bf7": { - "command": "echo one two three", - "files": {}, - "stdout": "one two three\n", - "stderr": "", - "exitCode": 0 - }, - "db1e757b6d72f5a8": { - "command": "echo -e \"line1\\nline2\"", - "files": {}, - "stdout": "line1\nline2\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/export.comparison.fixtures.json b/src/comparison-tests/fixtures/export.comparison.fixtures.json deleted file mode 100644 index 7f8c3af1..00000000 --- a/src/comparison-tests/fixtures/export.comparison.fixtures.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "4a2f700bead98b5a": { - "command": "export NUM=10; [ $NUM -gt 5 ] && echo greater", - "files": {}, - "stdout": "greater\n", - "stderr": "", - "exitCode": 0 - }, - "662fe76a74e0d0ea": { - "command": "export EMPTY=; echo \"[$EMPTY]\"", - "files": {}, - "stdout": "[]\n", - "stderr": "", - "exitCode": 0 - }, - "7b6a73a19e04e211": { - "command": "export X=42 && echo $X", - "files": {}, - "stdout": "42\n", - "stderr": "", - "exitCode": 0 - }, - "8017835d00c5a33f": { - "command": "export FOO=bar; (echo $FOO)", - "files": {}, - "stdout": "bar\n", - "stderr": "", - "exitCode": 0 - }, - "8f93ecc4a3a9e58d": { - "command": "export A=1 B=2 C=3; echo $A $B $C", - "files": {}, - "stdout": "1 2 3\n", - "stderr": "", - "exitCode": 0 - }, - "dbc45ae674bf0324": { - "command": "export NAME=world; echo \"hello $NAME\"", - "files": {}, - "stdout": "hello world\n", - "stderr": "", - "exitCode": 0 - }, - "ec458e22502c7bb9": { - "command": "export FOO=bar; echo $FOO", - "files": {}, - "stdout": "bar\n", - "stderr": "", - "exitCode": 0 - }, - "f0b31788fc824515": { - "command": "export VAL=yes; [ \"$VAL\" = \"yes\" ] && echo matched", - "files": {}, - "stdout": "matched\n", - "stderr": "", - "exitCode": 0 - }, - "ffc9146bd57203d1": { - "command": "export URL='http://x.com?a=1'; echo $URL", - "files": {}, - "stdout": "http://x.com?a=1\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/find.comparison.fixtures.json b/src/comparison-tests/fixtures/find.comparison.fixtures.json deleted file mode 100644 index 89ee5ce3..00000000 --- a/src/comparison-tests/fixtures/find.comparison.fixtures.json +++ /dev/null @@ -1,236 +0,0 @@ -{ - "086e8ae1736b66f7": { - "command": "find . -maxdepth 2 | sort", - "files": { - "a.txt": "", - "dir/b.txt": "", - "dir/sub/c.txt": "", - "dir/sub/deep/d.txt": "" - }, - "stdout": ".\n./a.txt\n./dir\n./dir/b.txt\n./dir/sub\n", - "stderr": "", - "exitCode": 0 - }, - "0c8920741bddf603": { - "command": "find . -mindepth 2 | sort", - "files": { - "a.txt": "", - "dir/b.txt": "", - "dir/sub/c.txt": "" - }, - "stdout": "./dir/b.txt\n./dir/sub\n./dir/sub/c.txt\n", - "stderr": "", - "exitCode": 0 - }, - "232fae9317361b52": { - "command": "find . -name \"a?.txt\" | sort", - "files": { - "a1.txt": "", - "a2.txt": "", - "a10.txt": "", - "b1.txt": "" - }, - "stdout": "./a1.txt\n./a2.txt\n", - "stderr": "", - "exitCode": 0 - }, - "3182ebfbe74e11eb": { - "command": "find . -name \"*.txt\" -type f | sort", - "files": { - "test.txt": "", - "test.js": "", - "sub/test.txt": "" - }, - "stdout": "./sub/test.txt\n./test.txt\n", - "stderr": "", - "exitCode": 0 - }, - "3be126e17474f81d": { - "command": "find . -mindepth 1 -maxdepth 1 -type f | sort", - "files": { - "a.txt": "", - "dir/b.txt": "", - "dir/sub/c.txt": "" - }, - "stdout": "./a.txt\n", - "stderr": "", - "exitCode": 0 - }, - "438d5293f370820a": { - "command": "find . -mindepth 1 | sort", - "files": { - "file.txt": "", - "dir/file.txt": "" - }, - "stdout": "./dir\n./dir/file.txt\n./file.txt\n", - "stderr": "", - "exitCode": 0 - }, - "455d3fa3cfc7a08c": { - "command": "find . -type f | sort", - "files": { - "file1.txt": "", - "subdir/file2.txt": "" - }, - "stdout": "./file1.txt\n./subdir/file2.txt\n", - "stderr": "", - "exitCode": 0 - }, - "46270a1ca6e475f9": { - "command": "find dir -name \"*.txt\" | sort", - "files": { - "dir/a.txt": "", - "dir/b.txt": "", - "other/c.txt": "" - }, - "stdout": "dir/a.txt\ndir/b.txt\n", - "stderr": "", - "exitCode": 0 - }, - "472ef4e0c007908d": { - "command": "find . -maxdepth 0 | sort", - "files": { - "file.txt": "", - "dir/file.txt": "" - }, - "stdout": ".\n", - "stderr": "", - "exitCode": 0 - }, - "62d55c904989e7c0": { - "command": "find . -type d | sort", - "files": { - "dir1/file.txt": "", - "dir2/file.txt": "" - }, - "stdout": ".\n./dir1\n./dir2\n", - "stderr": "", - "exitCode": 0 - }, - "68bf3db628040ce3": { - "command": "find . -name \"file.txt\" | sort", - "files": { - "a/b/c/d/file.txt": "" - }, - "stdout": "./a/b/c/d/file.txt\n", - "stderr": "", - "exitCode": 0 - }, - "93740bb5956cc41f": { - "command": "find . -maxdepth 1 -type f | sort", - "files": { - "file.txt": "", - "dir/file.txt": "", - "dir/sub/file.txt": "" - }, - "stdout": "./file.txt\n", - "stderr": "", - "exitCode": 0 - }, - "a0769ee2798250e4": { - "command": "find . -name \".*\" | sort", - "files": { - ".hidden": "", - "visible.txt": "", - "dir/.also-hidden": "" - }, - "stdout": ".\n./.hidden\n./dir/.also-hidden\n", - "stderr": "", - "exitCode": 0 - }, - "aa6ce7e0988957a0": { - "command": "find . -maxdepth 2 -name \"*.txt\" | sort", - "files": { - "a.txt": "", - "dir/b.txt": "", - "dir/sub/c.txt": "" - }, - "stdout": "./a.txt\n./dir/b.txt\n", - "stderr": "", - "exitCode": 0 - }, - "ad51ad70b8844f82": { - "command": "find . -maxdepth 1 | sort", - "files": { - "file.txt": "", - "dir/file.txt": "", - "dir/sub/file.txt": "" - }, - "stdout": ".\n./dir\n./file.txt\n", - "stderr": "", - "exitCode": 0 - }, - "bea27b7d4d4a9d70": { - "command": "find . -mindepth 1 -maxdepth 2 | sort", - "files": { - "a.txt": "", - "dir/b.txt": "", - "dir/sub/c.txt": "", - "dir/sub/deep/d.txt": "" - }, - "stdout": "./a.txt\n./dir\n./dir/b.txt\n./dir/sub\n", - "stderr": "", - "exitCode": 0 - }, - "cf4a32d064363ac4": { - "command": "find . -name \"*.txt\" -o -name \"*.js\" | sort", - "files": { - "file.txt": "", - "file.js": "", - "file.md": "" - }, - "stdout": "./file.js\n./file.txt\n", - "stderr": "", - "exitCode": 0 - }, - "d2bda1dc6224020d": { - "command": "find . -name \"*.txt\" | sort", - "files": { - "file1.txt": "", - "file2.js": "", - "subdir/file3.txt": "" - }, - "stdout": "./file1.txt\n./subdir/file3.txt\n", - "stderr": "", - "exitCode": 0 - }, - "d9fbc6fd5b3d1bdd": { - "command": "find . -mindepth 1 -type d | sort", - "files": { - "dir/sub/file.txt": "" - }, - "stdout": "./dir\n./dir/sub\n", - "stderr": "", - "exitCode": 0 - }, - "f6a1f2b584efa25a": { - "command": "find . -name \"*.txt\" | sort", - "files": { - "file.txt": "", - "dir/file.txt": "" - }, - "stdout": "./dir/file.txt\n./file.txt\n", - "stderr": "", - "exitCode": 0 - }, - "f9d3770010e8e205": { - "command": "find . -name \"target.txt\" | sort", - "files": { - "target.txt": "", - "other.txt": "", - "dir/target.txt": "" - }, - "stdout": "./dir/target.txt\n./target.txt\n", - "stderr": "", - "exitCode": 0 - }, - "fd92013eb16908f1": { - "command": "find . -type d | sort", - "files": { - "dir/file.txt": "" - }, - "stdout": ".\n./dir\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/glob.comparison.fixtures.json b/src/comparison-tests/fixtures/glob.comparison.fixtures.json deleted file mode 100644 index 9909ce61..00000000 --- a/src/comparison-tests/fixtures/glob.comparison.fixtures.json +++ /dev/null @@ -1,111 +0,0 @@ -{ - "1aa828f0cab48e15": { - "command": "echo */*/test.ts", - "files": { - "src/a/test.ts": "test a", - "src/b/test.ts": "test b", - "lib/c/test.ts": "test c" - }, - "stdout": "lib/c/test.ts src/a/test.ts src/b/test.ts\n", - "stderr": "", - "exitCode": 0 - }, - "34061471e21f2f7b": { - "command": "cat data/*/config.json", - "files": { - "data/v1/config.json": "v1 config", - "data/v2/config.json": "v2 config", - "data/v1/settings.json": "v1 settings" - }, - "stdout": "v1 configv2 config", - "stderr": "", - "exitCode": 0 - }, - "36e1a6fc5c546c4a": { - "command": "grep hello */*.txt", - "files": { - "dir1/file.txt": "hello world", - "dir2/file.txt": "hello there", - "dir3/other.txt": "goodbye" - }, - "stdout": "dir1/file.txt:hello world\ndir2/file.txt:hello there\n", - "stderr": "", - "exitCode": 0 - }, - "63d9a4a8524ef1c5": { - "command": "cat dm/*/*.json", - "files": { - "dm/folder1/data.json": "{\"a\":1}", - "dm/folder2/data.json": "{\"b\":2}", - "dm/folder3/other.txt": "text" - }, - "stdout": "{\"a\":1}{\"b\":2}", - "stderr": "", - "exitCode": 0 - }, - "71ed22308ca718e0": { - "command": "echo */*/*/*.txt", - "files": { - "a/b/c/file.txt": "abc", - "a/d/e/file.txt": "ade", - "x/y/z/file.txt": "xyz" - }, - "stdout": "a/b/c/file.txt a/d/e/file.txt x/y/z/file.txt\n", - "stderr": "", - "exitCode": 0 - }, - "78341dea8687b9df": { - "command": "echo file?.txt", - "files": { - "file1.txt": "1", - "file2.txt": "2", - "file10.txt": "10" - }, - "stdout": "file1.txt file2.txt\n", - "stderr": "", - "exitCode": 0 - }, - "bfa75a70c606b65d": { - "command": "echo *.txt", - "files": { - "file1.txt": "content 1", - "file2.txt": "content 2", - "file3.json": "{}" - }, - "stdout": "file1.txt file2.txt\n", - "stderr": "", - "exitCode": 0 - }, - "e71d5efd2763b87f": { - "command": "echo *.xyz", - "files": { - "file.txt": "content" - }, - "stdout": "*.xyz\n", - "stderr": "", - "exitCode": 0 - }, - "f21fa1e2a2f364fd": { - "command": "echo */*.json", - "files": { - "folder1/data.json": "{\"a\":1}", - "folder2/data.json": "{\"b\":2}", - "folder3/other.txt": "text" - }, - "stdout": "folder1/data.json folder2/data.json\n", - "stderr": "", - "exitCode": 0 - }, - "f39742739256f25c": { - "command": "echo file[12].txt", - "files": { - "file1.txt": "1", - "file2.txt": "2", - "file3.txt": "3", - "filea.txt": "a" - }, - "stdout": "file1.txt file2.txt\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/grep.comparison.fixtures.json b/src/comparison-tests/fixtures/grep.comparison.fixtures.json deleted file mode 100644 index f95cbb48..00000000 --- a/src/comparison-tests/fixtures/grep.comparison.fixtures.json +++ /dev/null @@ -1,300 +0,0 @@ -{ - "07aafaf0ae069500": { - "command": "grep -r --include=\"*.ts\" hello dir", - "files": { - "dir/test.ts": "hello ts\n", - "dir/test.js": "hello js\n", - "dir/test.txt": "hello txt\n" - }, - "stdout": "dir/test.ts:hello ts\n", - "stderr": "", - "exitCode": 0 - }, - "08c5cca4c0651e81": { - "command": "grep -A 2 match test.txt", - "files": { - "test.txt": "line1\nmatch\nline3\nline4\n" - }, - "stdout": "match\nline3\nline4\n", - "stderr": "", - "exitCode": 0 - }, - "1025e358d7a34e01": { - "command": "grep \"[ch]at\" test.txt", - "files": { - "test.txt": "cat\nhat\nbat\nrat\n" - }, - "stdout": "cat\nhat\n", - "stderr": "", - "exitCode": 0 - }, - "154ea3241b3487b2": { - "command": "grep -F \".\" test.txt", - "files": { - "test.txt": "a.b\naXb\na..b\n" - }, - "stdout": "a.b\na..b\n", - "stderr": "", - "exitCode": 0 - }, - "185ed628b5425dc6": { - "command": "grep -Fi \"hello.world\" test.txt", - "files": { - "test.txt": "Hello.World\nhello.world\nHELLO.WORLD\n" - }, - "stdout": "Hello.World\nhello.world\nHELLO.WORLD\n", - "stderr": "", - "exitCode": 0 - }, - "1b39c1945ca0d7ae": { - "command": "grep -r hello dir", - "files": { - "dir/file1.txt": "hello from file1\n", - "dir/file2.txt": "goodbye from file2\n", - "dir/sub/file3.txt": "hello from file3\n" - }, - "stdout": "dir/file1.txt:hello from file1\ndir/sub/file3.txt:hello from file3\n", - "stderr": "", - "exitCode": 0 - }, - "36f9ac8925c0108b": { - "command": "grep -B 2 match test.txt", - "files": { - "test.txt": "line1\nline2\nmatch\nline4\n" - }, - "stdout": "line1\nline2\nmatch\n", - "stderr": "", - "exitCode": 0 - }, - "387f744404b105ca": { - "command": "grep -c hello test.txt", - "files": { - "test.txt": "hello world\nfoo bar\nhello again\n" - }, - "stdout": "2\n", - "stderr": "", - "exitCode": 0 - }, - "3a66482f45995ea4": { - "command": "grep \"world$\" test.txt", - "files": { - "test.txt": "hello world\nworld hello\n" - }, - "stdout": "hello world\n", - "stderr": "", - "exitCode": 0 - }, - "406fd22c6934415f": { - "command": "grep -o hello test.txt", - "files": { - "test.txt": "hello world hello\nfoo hello bar\n" - }, - "stdout": "hello\nhello\nhello\n", - "stderr": "", - "exitCode": 0 - }, - "426c49b200ec05fc": { - "command": "grep -l hello a.txt b.txt c.txt", - "files": { - "a.txt": "hello world\n", - "b.txt": "no match\n", - "c.txt": "hello there\n" - }, - "stdout": "a.txt\nc.txt\n", - "stderr": "", - "exitCode": 0 - }, - "45aa0fe09d422b8b": { - "command": "grep \"^hello\" test.txt", - "files": { - "test.txt": "hello world\nworld hello\n" - }, - "stdout": "hello world\n", - "stderr": "", - "exitCode": 0 - }, - "4acb0878b7c7321a": { - "command": "grep \"c.t\" test.txt", - "files": { - "test.txt": "cat\ncut\ncot\ncart\n" - }, - "stdout": "cat\ncut\ncot\n", - "stderr": "", - "exitCode": 0 - }, - "51eb3a9e5a93c353": { - "command": "grep -q notfound test.txt || echo \"not found\"", - "files": { - "test.txt": "hello world\n" - }, - "stdout": "not found\n", - "stderr": "", - "exitCode": 0 - }, - "5c8ae50b50d2af09": { - "command": "grep -E \"ca+t\" test.txt", - "files": { - "test.txt": "ct\ncat\ncaat\ncaaat\n" - }, - "stdout": "cat\ncaat\ncaaat\n", - "stderr": "", - "exitCode": 0 - }, - "60b5f71f1ea8f004": { - "command": "grep notfound test.txt || true", - "files": { - "test.txt": "hello world\n" - }, - "stdout": "", - "stderr": "", - "exitCode": 0 - }, - "739577ab79356d8c": { - "command": "grep -c hello a.txt b.txt c.txt", - "files": { - "a.txt": "hello\nhello\n", - "b.txt": "hello\n", - "c.txt": "world\n" - }, - "stdout": "a.txt:2\nb.txt:1\nc.txt:0\n", - "stderr": "", - "exitCode": 0 - }, - "7704b65434a86bb4": { - "command": "echo \"hello world\" | grep -q hello && echo matched", - "files": {}, - "stdout": "matched\n", - "stderr": "", - "exitCode": 0 - }, - "7c10aa0a80b826a4": { - "command": "grep -C 1 match test.txt", - "files": { - "test.txt": "line1\nline2\nmatch\nline4\nline5\n" - }, - "stdout": "line2\nmatch\nline4\n", - "stderr": "", - "exitCode": 0 - }, - "7ce150b1d31c7157": { - "command": "grep -i hello test.txt", - "files": { - "test.txt": "Hello World\nHELLO AGAIN\nhello there\n" - }, - "stdout": "Hello World\nHELLO AGAIN\nhello there\n", - "stderr": "", - "exitCode": 0 - }, - "96c3aa3ddb21f038": { - "command": "grep \"ca*t\" test.txt", - "files": { - "test.txt": "ct\ncat\ncaat\ncaaat\n" - }, - "stdout": "ct\ncat\ncaat\ncaaat\n", - "stderr": "", - "exitCode": 0 - }, - "9e3dd0958d208f3e": { - "command": "grep -q hello test.txt", - "files": { - "test.txt": "hello world\nfoo bar\n" - }, - "stdout": "", - "stderr": "", - "exitCode": 0 - }, - "a2abcfaed619014a": { - "command": "grep -v hello test.txt", - "files": { - "test.txt": "hello world\nfoo bar\nhello again\n" - }, - "stdout": "foo bar\n", - "stderr": "", - "exitCode": 0 - }, - "b004d9dd542ae06b": { - "command": "grep hello test.txt", - "files": { - "test.txt": "hello world\nfoo bar\nhello again\n" - }, - "stdout": "hello world\nhello again\n", - "stderr": "", - "exitCode": 0 - }, - "b83506e9397f4339": { - "command": "grep -F \"[test]\" test.txt", - "files": { - "test.txt": "[test]\ntest\n[another]\n" - }, - "stdout": "[test]\n", - "stderr": "", - "exitCode": 0 - }, - "bd0c258c0ab3c71b": { - "command": "grep -h hello a.txt b.txt", - "files": { - "a.txt": "hello a\n", - "b.txt": "hello b\n" - }, - "stdout": "hello a\nhello b\n", - "stderr": "", - "exitCode": 0 - }, - "bf613f70adc0289f": { - "command": "grep -n hello test.txt", - "files": { - "test.txt": "hello world\nfoo bar\nhello again\n" - }, - "stdout": "1:hello world\n3:hello again\n", - "stderr": "", - "exitCode": 0 - }, - "e082fd2315ac5a21": { - "command": "grep hello a.txt b.txt", - "files": { - "a.txt": "hello a\n", - "b.txt": "hello b\n" - }, - "stdout": "a.txt:hello a\nb.txt:hello b\n", - "stderr": "", - "exitCode": 0 - }, - "e970360545cfd7e7": { - "command": "grep -F \".*\" test.txt", - "files": { - "test.txt": "hello.*world\ntest pattern\nhello.world\n" - }, - "stdout": "hello.*world\n", - "stderr": "", - "exitCode": 0 - }, - "f62342a3de049a90": { - "command": "grep -q hello test.txt && echo found", - "files": { - "test.txt": "hello world\n" - }, - "stdout": "found\n", - "stderr": "", - "exitCode": 0 - }, - "fd1559b5e557cb7a": { - "command": "grep -rl hello dir | sort", - "files": { - "dir/a.txt": "hello\n", - "dir/b.txt": "world\n", - "dir/sub/c.txt": "hello\n" - }, - "stdout": "dir/a.txt\ndir/sub/c.txt\n", - "stderr": "", - "exitCode": 0 - }, - "ff06d4155ef88658": { - "command": "grep -w hello test.txt", - "files": { - "test.txt": "hello world\nhelloworld\nworld hello\n" - }, - "stdout": "hello world\nworld hello\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/head-tail.comparison.fixtures.json b/src/comparison-tests/fixtures/head-tail.comparison.fixtures.json deleted file mode 100644 index b9e1c963..00000000 --- a/src/comparison-tests/fixtures/head-tail.comparison.fixtures.json +++ /dev/null @@ -1,198 +0,0 @@ -{ - "034c1ad958ac4e81": { - "command": "tail -n 1 test.txt", - "files": { - "test.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n" - }, - "stdout": "line 5\n", - "stderr": "", - "exitCode": 0 - }, - "056423c9a1c3be0a": { - "command": "tail test.txt", - "files": { - "test.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n" - }, - "stdout": "line 1\nline 2\nline 3\nline 4\nline 5\n", - "stderr": "", - "exitCode": 0 - }, - "216b8a0800cfe5d9": { - "command": "head -c 100 test.txt", - "files": { - "test.txt": "short\n" - }, - "stdout": "short\n", - "stderr": "", - "exitCode": 0 - }, - "2b0708aebc130c69": { - "command": "head -c 5 test.txt", - "files": { - "test.txt": "Hello, World!\n" - }, - "stdout": "Hello", - "stderr": "", - "exitCode": 0 - }, - "3342a2278ef2818f": { - "command": "tail -n 10 test.txt", - "files": { - "test.txt": "line 1\nline 2\nline 3\n" - }, - "stdout": "line 1\nline 2\nline 3\n", - "stderr": "", - "exitCode": 0 - }, - "4e164a360a6e2537": { - "command": "tail -n 5 test.txt", - "files": { - "test.txt": "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\n" - }, - "stdout": "line 16\nline 17\nline 18\nline 19\nline 20\n", - "stderr": "", - "exitCode": 0 - }, - "522b5f5822ebb4ae": { - "command": "head test.txt", - "files": { - "test.txt": "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\n" - }, - "stdout": "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n", - "stderr": "", - "exitCode": 0 - }, - "70b15d1e3fefb5d3": { - "command": "tail -n +3 test.txt", - "files": { - "test.txt": "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n" - }, - "stdout": "line 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n", - "stderr": "", - "exitCode": 0 - }, - "73aef1fb3909e95e": { - "command": "tail -c 100 test.txt", - "files": { - "test.txt": "short\n" - }, - "stdout": "short\n", - "stderr": "", - "exitCode": 0 - }, - "76f116b941107f5f": { - "command": "tail -c 5 test.txt", - "files": { - "test.txt": "Hello, World!\n" - }, - "stdout": "rld!\n", - "stderr": "", - "exitCode": 0 - }, - "7b6bcefac6d2c797": { - "command": "echo -e \"a\\nb\\nc\\nd\\ne\" | head -n 3", - "files": {}, - "stdout": "a\nb\nc\n", - "stderr": "", - "exitCode": 0 - }, - "8436b406d6d6c02f": { - "command": "head -n 1 test.txt", - "files": { - "test.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n" - }, - "stdout": "line 1\n", - "stderr": "", - "exitCode": 0 - }, - "84dc6e37eee2a8f7": { - "command": "head -n 5 test.txt", - "files": { - "test.txt": "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\n" - }, - "stdout": "line 1\nline 2\nline 3\nline 4\nline 5\n", - "stderr": "", - "exitCode": 0 - }, - "877e89713050d57e": { - "command": "head -c 10 test.txt", - "files": { - "test.txt": "line1\nline2\nline3\n" - }, - "stdout": "line1\nline", - "stderr": "", - "exitCode": 0 - }, - "8a7264ad93bfe725": { - "command": "tail -c 10 test.txt", - "files": { - "test.txt": "line1\nline2\nline3\n" - }, - "stdout": "ne2\nline3\n", - "stderr": "", - "exitCode": 0 - }, - "95981fccb9696235": { - "command": "tail -n 2 a.txt b.txt", - "files": { - "a.txt": "line 1\nline 2\nline 3\n", - "b.txt": "line 1\nline 2\nline 3\n" - }, - "stdout": "==> a.txt <==\nline 2\nline 3\n\n==> b.txt <==\nline 2\nline 3\n", - "stderr": "", - "exitCode": 0 - }, - "a0d70a2bf1946c56": { - "command": "head -n 2 a.txt b.txt", - "files": { - "a.txt": "line 1\nline 2\nline 3\n", - "b.txt": "line 1\nline 2\nline 3\n" - }, - "stdout": "==> a.txt <==\nline 1\nline 2\n\n==> b.txt <==\nline 1\nline 2\n", - "stderr": "", - "exitCode": 0 - }, - "c43bf46c4255c75a": { - "command": "echo -e \"a\\nb\\nc\\nd\\ne\" | tail -n 2", - "files": {}, - "stdout": "d\ne\n", - "stderr": "", - "exitCode": 0 - }, - "c441335822eca032": { - "command": "head -n 10 test.txt", - "files": { - "test.txt": "line 1\nline 2\nline 3\n" - }, - "stdout": "line 1\nline 2\nline 3\n", - "stderr": "", - "exitCode": 0 - }, - "d99b0046180efb2b": { - "command": "tail test.txt", - "files": { - "test.txt": "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\n" - }, - "stdout": "line 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\n", - "stderr": "", - "exitCode": 0 - }, - "e1cf4bd4e1e39e27": { - "command": "head -n3 test.txt", - "files": { - "test.txt": "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n" - }, - "stdout": "line 1\nline 2\nline 3\n", - "stderr": "", - "exitCode": 0 - }, - "e72c50c3f9f71f29": { - "command": "head test.txt", - "files": { - "test.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n" - }, - "stdout": "line 1\nline 2\nline 3\nline 4\nline 5\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/here-document.comparison.fixtures.json b/src/comparison-tests/fixtures/here-document.comparison.fixtures.json deleted file mode 100644 index b37d791e..00000000 --- a/src/comparison-tests/fixtures/here-document.comparison.fixtures.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "7742179701e72ad0": { - "command": "cat < 3)]' data.json", - "files": { - "data.json": "[1,2,3,4,5]" - }, - "stdout": "[\n 4,\n 5\n]\n", - "stderr": "", - "exitCode": 0 - }, - "7b7456943760154c": { - "command": "jq 'length' data.json", - "files": { - "data.json": "[1,2,3,4,5]" - }, - "stdout": "5\n", - "stderr": "", - "exitCode": 0 - }, - "8105f346b43748a2": { - "command": "jq 'sort' data.json", - "files": { - "data.json": "[3,1,2]" - }, - "stdout": "[\n 1,\n 2,\n 3\n]\n", - "stderr": "", - "exitCode": 0 - }, - "91c885f22f4323a6": { - "command": "jq '.' data.json", - "files": { - "data.json": "{\"a\":1,\"b\":2}" - }, - "stdout": "{\n \"a\": 1,\n \"b\": 2\n}\n", - "stderr": "", - "exitCode": 0 - }, - "937a32a880c8c8cd": { - "command": "jq -c '.' data.json", - "files": { - "data.json": "{\"a\":1,\"b\":2}" - }, - "stdout": "{\"a\":1,\"b\":2}\n", - "stderr": "", - "exitCode": 0 - }, - "a20be29f72c20715": { - "command": "jq '.[0]' data.json", - "files": { - "data.json": "[\"a\",\"b\",\"c\"]" - }, - "stdout": "\"a\"\n", - "stderr": "", - "exitCode": 0 - }, - "aac68dc54a55fad2": { - "command": "jq -s '.' data.json", - "files": { - "data.json": "1\n2\n3" - }, - "stdout": "[\n 1,\n 2,\n 3\n]\n", - "stderr": "", - "exitCode": 0 - }, - "b8e1ebdad148a952": { - "command": "jq -n '[range(5)]'", - "files": {}, - "stdout": "[\n 0,\n 1,\n 2,\n 3,\n 4\n]\n", - "stderr": "", - "exitCode": 0 - }, - "b9af66a6586c6d00": { - "command": "jq '.[]' data.json", - "files": { - "data.json": "{\"a\":1,\"b\":2}" - }, - "stdout": "1\n2\n", - "stderr": "", - "exitCode": 0 - }, - "bf18a612e809e249": { - "command": "jq '. + 3' data.json", - "files": { - "data.json": "5" - }, - "stdout": "8\n", - "stderr": "", - "exitCode": 0 - }, - "c0f228f8add71471": { - "command": "jq '.a.b' data.json", - "files": { - "data.json": "{\"a\":{\"b\":\"nested\"}}" - }, - "stdout": "\"nested\"\n", - "stderr": "", - "exitCode": 0 - }, - "de6ea82b7109ba1d": { - "command": "jq '.[-1]' data.json", - "files": { - "data.json": "[\"a\",\"b\",\"c\"]" - }, - "stdout": "\"c\"\n", - "stderr": "", - "exitCode": 0 - }, - "e433b214be1f80db": { - "command": "jq 'type' data.json", - "files": { - "data.json": "{\"a\":1}" - }, - "stdout": "\"object\"\n", - "stderr": "", - "exitCode": 0 - }, - "e808cfbdc52662c0": { - "command": "jq '. * 7' data.json", - "files": { - "data.json": "6" - }, - "stdout": "42\n", - "stderr": "", - "exitCode": 0 - }, - "eecd9132c8f55a66": { - "command": "jq 'join(\"-\")' data.json", - "files": { - "data.json": "[\"a\",\"b\",\"c\"]" - }, - "stdout": "\"a-b-c\"\n", - "stderr": "", - "exitCode": 0 - }, - "eed67a538c8414f7": { - "command": "jq -n 'null'", - "files": {}, - "stdout": "null\n", - "stderr": "", - "exitCode": 0 - }, - "f1149342c3f0e494": { - "command": "jq 'if . > 3 then \"big\" else \"small\" end' data.json", - "files": { - "data.json": "5" - }, - "stdout": "\"big\"\n", - "stderr": "", - "exitCode": 0 - }, - "ff7f24721da0b6cc": { - "command": "jq '.' data.json", - "files": { - "data.json": "[1,2,3]" - }, - "stdout": "[\n 1,\n 2,\n 3\n]\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/ls.comparison.fixtures.json b/src/comparison-tests/fixtures/ls.comparison.fixtures.json deleted file mode 100644 index c3b46b3d..00000000 --- a/src/comparison-tests/fixtures/ls.comparison.fixtures.json +++ /dev/null @@ -1,128 +0,0 @@ -{ - "12c80cbc8d61a75e": { - "command": "ls file.txt dir", - "files": { - "file.txt": "", - "dir/nested.txt": "" - }, - "stdout": "file.txt\n\ndir:\nnested.txt\n", - "stderr": "", - "exitCode": 0 - }, - "3b7d8ce2d6992be7": { - "command": "ls subdir", - "files": { - "subdir/.gitkeep": "" - }, - "stdout": "", - "stderr": "", - "exitCode": 0 - }, - "3ff2b49021e0b8df": { - "command": "ls -1", - "files": { - "Apple.txt": "", - "banana.txt": "", - "cherry.txt": "" - }, - "stdout": "Apple.txt\nbanana.txt\ncherry.txt\n", - "stderr": "", - "exitCode": 0 - }, - "4123d9b2a8de2d7c": { - "command": "ls -A", - "files": { - ".hidden": "", - "visible.txt": "" - }, - "stdout": ".hidden\nvisible.txt\n", - "stderr": "", - "exitCode": 0 - }, - "537fc7f40660ced4": { - "command": "ls -1r", - "files": { - "aaa.txt": "", - "bbb.txt": "", - "ccc.txt": "" - }, - "stdout": "ccc.txt\nbbb.txt\naaa.txt\n", - "stderr": "", - "exitCode": 0 - }, - "7ae1c663742d227c": { - "command": "ls subdir", - "files": { - "subdir/file1.txt": "", - "subdir/file2.txt": "" - }, - "stdout": "file1.txt\nfile2.txt\n", - "stderr": "", - "exitCode": 0 - }, - "893bb7f655a1464e": { - "command": "ls -1", - "files": { - "zebra.txt": "", - "apple.txt": "", - "banana.txt": "" - }, - "stdout": "apple.txt\nbanana.txt\nzebra.txt\n", - "stderr": "", - "exitCode": 0 - }, - "a7c8be4092c3fc30": { - "command": "ls", - "files": { - "file1.txt": "content", - "file2.txt": "content", - "subdir/file3.txt": "content" - }, - "stdout": "file1.txt\nfile2.txt\nsubdir\n", - "stderr": "", - "exitCode": 0 - }, - "ad7e0e863a30ec68": { - "command": "ls -R", - "files": { - "file.txt": "", - "dir/file1.txt": "", - "dir/sub/file2.txt": "" - }, - "stdout": ".:\ndir\nfile.txt\n\n./dir:\nfile1.txt\nsub\n\n./dir/sub:\nfile2.txt\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "e1105146143329fe": { - "command": "ls dir1 dir2", - "files": { - "dir1/a.txt": "", - "dir2/b.txt": "" - }, - "stdout": "dir1:\na.txt\n\ndir2:\nb.txt\n", - "stderr": "", - "exitCode": 0 - }, - "ed09e77729197e0d": { - "command": "ls -1", - "files": { - "aaa.txt": "", - "bbb.txt": "", - "ccc.txt": "" - }, - "stdout": "aaa.txt\nbbb.txt\nccc.txt\n", - "stderr": "", - "exitCode": 0 - }, - "ed367bb71e17d454": { - "command": "ls -a", - "files": { - ".hidden": "", - "visible.txt": "" - }, - "stdout": ".\n..\n.hidden\nvisible.txt\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/paste.comparison.fixtures.json b/src/comparison-tests/fixtures/paste.comparison.fixtures.json deleted file mode 100644 index 1dd65c94..00000000 --- a/src/comparison-tests/fixtures/paste.comparison.fixtures.json +++ /dev/null @@ -1,170 +0,0 @@ -{ - "06b1074e4b8b949e": { - "command": "paste -d,: file1.txt file2.txt file3.txt", - "files": { - "file1.txt": "a\nb\nc\n", - "file2.txt": "1\n2\n3\n", - "file3.txt": "x\ny\nz\n" - }, - "stdout": "a,1:x\nb,2:y\nc,3:z\n", - "stderr": "", - "exitCode": 0 - }, - "41372e0b75018c1e": { - "command": "paste -d, file1.txt file2.txt", - "files": { - "file1.txt": "a\nb\nc\n", - "file2.txt": "1\n2\n3\n" - }, - "stdout": "a,1\nb,2\nc,3\n", - "stderr": "", - "exitCode": 0 - }, - "5f4a5c7ef6f558da": { - "command": "paste -sd, file1.txt", - "files": { - "file1.txt": "a\nb\nc\n" - }, - "stdout": "a,b,c\n", - "stderr": "", - "exitCode": 0 - }, - "69c1839508fc87cd": { - "command": "paste empty.txt file1.txt", - "files": { - "empty.txt": "", - "file1.txt": "a\nb\nc\n" - }, - "stdout": "\ta\n\tb\n\tc\n", - "stderr": "", - "exitCode": 0 - }, - "6a678644b82e8ec0": { - "command": "paste file1.txt file2.txt file3.txt", - "files": { - "file1.txt": "a\nb\nc\n", - "file2.txt": "1\n2\n3\n", - "file3.txt": "x\ny\nz\n" - }, - "stdout": "a\t1\tx\nb\t2\ty\nc\t3\tz\n", - "stderr": "", - "exitCode": 0 - }, - "7ef3bcfe33c9aeb8": { - "command": "paste single.txt file1.txt", - "files": { - "single.txt": "hello\n", - "file1.txt": "a\nb\nc\n" - }, - "stdout": "hello\ta\n\tb\n\tc\n", - "stderr": "", - "exitCode": 0 - }, - "805bea3cf126ac8d": { - "command": "echo -e \"a\\nb\\nc\\nd\\ne\\nf\" | paste - - -", - "files": {}, - "stdout": "a\tb\tc\nd\te\tf\n", - "stderr": "", - "exitCode": 0 - }, - "84eeee2f0f0d686d": { - "command": "paste file1.txt", - "files": { - "file1.txt": "a\nb\nc\n" - }, - "stdout": "a\nb\nc\n", - "stderr": "", - "exitCode": 0 - }, - "8cc97dd70107dd78": { - "command": "paste -d: file1.txt file2.txt", - "files": { - "file1.txt": "a\nb\nc\n", - "file2.txt": "1\n2\n3\n" - }, - "stdout": "a:1\nb:2\nc:3\n", - "stderr": "", - "exitCode": 0 - }, - "a5e2148668c47c1f": { - "command": "paste short.txt long.txt", - "files": { - "short.txt": "a\nb\n", - "long.txt": "1\n2\n3\n4\n" - }, - "stdout": "a\t1\nb\t2\n\t3\n\t4\n", - "stderr": "", - "exitCode": 0 - }, - "ae7d8e57d074fdb8": { - "command": "echo -e \"a\\nb\\nc\" | paste -", - "files": {}, - "stdout": "a\nb\nc\n", - "stderr": "", - "exitCode": 0 - }, - "b747fc649d9281b7": { - "command": "echo -e \"a\\nb\\nc\\nd\" | paste - -", - "files": {}, - "stdout": "a\tb\nc\td\n", - "stderr": "", - "exitCode": 0 - }, - "ba1ff50043a8ac98": { - "command": "paste file1.txt file2.txt", - "files": { - "file1.txt": "a\nb\nc\n", - "file2.txt": "1\n2\n3\n" - }, - "stdout": "a\t1\nb\t2\nc\t3\n", - "stderr": "", - "exitCode": 0 - }, - "bf09bb9a3eef82d5": { - "command": "paste -s file1.txt", - "files": { - "file1.txt": "a\nb\nc\n" - }, - "stdout": "a\tb\tc\n", - "stderr": "", - "exitCode": 0 - }, - "c45a0ab4a6aaec31": { - "command": "paste -s -d, file1.txt", - "files": { - "file1.txt": "a\nb\nc\n" - }, - "stdout": "a,b,c\n", - "stderr": "", - "exitCode": 0 - }, - "c45eb2b68824ef70": { - "command": "paste -d\" \" file1.txt file2.txt", - "files": { - "file1.txt": "a\nb\nc\n", - "file2.txt": "1\n2\n3\n" - }, - "stdout": "a 1\nb 2\nc 3\n", - "stderr": "", - "exitCode": 0 - }, - "e5f70ad0bf646f27": { - "command": "paste -s file1.txt file2.txt", - "files": { - "file1.txt": "a\nb\nc\n", - "file2.txt": "1\n2\n3\n" - }, - "stdout": "a\tb\tc\n1\t2\t3\n", - "stderr": "", - "exitCode": 0 - }, - "ff37127b8bf82876": { - "command": "echo -e \"1\\n2\\n3\" | paste - file1.txt", - "files": { - "file1.txt": "a\nb\nc\n" - }, - "stdout": "1\ta\n2\tb\n3\tc\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/pipes-redirections.comparison.fixtures.json b/src/comparison-tests/fixtures/pipes-redirections.comparison.fixtures.json deleted file mode 100644 index 1791bdea..00000000 --- a/src/comparison-tests/fixtures/pipes-redirections.comparison.fixtures.json +++ /dev/null @@ -1,198 +0,0 @@ -{ - "020bff53c7000330": { - "command": "echo \"hello\" | cat", - "files": {}, - "stdout": "hello\n", - "stderr": "", - "exitCode": 0 - }, - "182b9b5e6bfa1c28": { - "command": "cat test.txt | grep hello | sort | wc -l", - "files": { - "test.txt": "hello world\nfoo bar\nhello again\nbaz qux\n" - }, - "stdout": "2\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "1f5358d0ff5415db": { - "command": "grep hello test.txt | wc -l", - "files": { - "test.txt": "hello\nworld\nhello\n" - }, - "stdout": "2\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "3beedbeba9d2a5c4": { - "command": "cat test.txt | cut -d: -f2 | sort -n", - "files": { - "test.txt": "b:2\na:3\nc:1\n" - }, - "stdout": "1\n2\n3\n", - "stderr": "", - "exitCode": 0 - }, - "45eaab46734d3047": { - "command": "cat test.txt | grep hello", - "files": { - "test.txt": "hello world\nfoo bar\nhello again\n" - }, - "stdout": "hello world\nhello again\n", - "stderr": "", - "exitCode": 0 - }, - "58a6a0ce46d73fa0": { - "command": "cat test.txt | sort | uniq", - "files": { - "test.txt": "cherry\napple\nbanana\napple\n" - }, - "stdout": "apple\nbanana\ncherry\n", - "stderr": "", - "exitCode": 0 - }, - "598e7cefd65169e2": { - "command": "echo start && cat test.txt && echo end", - "files": { - "test.txt": "hello\n" - }, - "stdout": "start\nhello\nend\n", - "stderr": "", - "exitCode": 0 - }, - "5b471d065f703848": { - "command": "cat nonexistent.txt 2>/dev/null; echo \"still runs\"", - "files": {}, - "stdout": "still runs\n", - "stderr": "", - "exitCode": 0 - }, - "653c853d1c5ebdce": { - "command": "cat nonexistent.txt 2>/dev/null && echo \"never shown\"", - "files": {}, - "stdout": "", - "stderr": "", - "exitCode": 1 - }, - "6bd0123402cec7ad": { - "command": "sort < input.txt", - "files": { - "input.txt": "cherry\napple\nbanana\n" - }, - "stdout": "apple\nbanana\ncherry\n", - "stderr": "", - "exitCode": 0 - }, - "7086f6a3f4dc0698": { - "command": "echo \"a\"; echo \"b\" && echo \"c\"", - "files": {}, - "stdout": "a\nb\nc\n", - "stderr": "", - "exitCode": 0 - }, - "7408706d864bc70e": { - "command": "cat nonexistent.txt 2>/dev/null && echo \"yes\" || echo \"no\"", - "files": {}, - "stdout": "no\n", - "stderr": "", - "exitCode": 0 - }, - "911c532960333408": { - "command": "cat test.txt | tail -n 2", - "files": { - "test.txt": "line1\nline2\nline3\nline4\nline5\n" - }, - "stdout": "line4\nline5\n", - "stderr": "", - "exitCode": 0 - }, - "aac86b7188cd3d4b": { - "command": "cat test.txt | sort", - "files": { - "test.txt": "cherry\napple\nbanana\n" - }, - "stdout": "apple\nbanana\ncherry\n", - "stderr": "", - "exitCode": 0 - }, - "abe00fe64964cee1": { - "command": "wc -l < input.txt", - "files": { - "input.txt": "line1\nline2\nline3\n" - }, - "stdout": "3\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "c2a3ca246a760ea3": { - "command": "grep ^a < input.txt", - "files": { - "input.txt": "apple\nbanana\napricot\ncherry\n" - }, - "stdout": "apple\napricot\n", - "stderr": "", - "exitCode": 0 - }, - "cf12bab95231fa03": { - "command": "cat nonexistent.txt 2>/dev/null || echo \"file not found\"", - "files": {}, - "stdout": "file not found\n", - "stderr": "", - "exitCode": 0 - }, - "d64cf6f964b36cd8": { - "command": "cat test.txt | tr 'A-Z' 'a-z' | sort", - "files": { - "test.txt": "CHERRY\napple\nBANANA\n" - }, - "stdout": "apple\nbanana\ncherry\n", - "stderr": "", - "exitCode": 0 - }, - "ea44840781b9f76b": { - "command": "echo \"success\" || echo \"never shown\"", - "files": {}, - "stdout": "success\n", - "stderr": "", - "exitCode": 0 - }, - "ed9a9c9eb3f510bc": { - "command": "cat < input.txt", - "files": { - "input.txt": "hello world\n" - }, - "stdout": "hello world\n", - "stderr": "", - "exitCode": 0 - }, - "ee691337fb0fd9fb": { - "command": "cat test.txt | head -n 4 | tail -n 2", - "files": { - "test.txt": "line1\nline2\nline3\nline4\nline5\n" - }, - "stdout": "line3\nline4\n", - "stderr": "", - "exitCode": 0 - }, - "fd39ca6c0c831f40": { - "command": "cat test.txt | head -n 3", - "files": { - "test.txt": "line1\nline2\nline3\nline4\nline5\n" - }, - "stdout": "line1\nline2\nline3\n", - "stderr": "", - "exitCode": 0 - }, - "fd81fd5b0ed0f85e": { - "command": "echo first; cat test.txt; echo last", - "files": { - "test.txt": "content\n" - }, - "stdout": "first\ncontent\nlast\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/sed.comparison.fixtures.json b/src/comparison-tests/fixtures/sed.comparison.fixtures.json deleted file mode 100644 index 00ed25b4..00000000 --- a/src/comparison-tests/fixtures/sed.comparison.fixtures.json +++ /dev/null @@ -1,196 +0,0 @@ -{ - "00e1632371dda029": { - "command": "sed '$ d' test.txt", - "files": { - "test.txt": "line1\nline2\nline3\n" - }, - "stdout": "line1\nline2\n", - "stderr": "", - "exitCode": 0 - }, - "0b3b3909e74c705c": { - "command": "sed 's/hello//' test.txt", - "files": { - "test.txt": "hello world\n" - }, - "stdout": " world\n", - "stderr": "", - "exitCode": 0 - }, - "105c923aee90005e": { - "command": "sed -e 's/hello/hi/' -e 's/world/there/' test.txt", - "files": { - "test.txt": "hello world\n" - }, - "stdout": "hi there\n", - "stderr": "", - "exitCode": 0 - }, - "2740f51bf2c34b7e": { - "command": "sed 's|/|-|g' test.txt", - "files": { - "test.txt": "a/b/c\n" - }, - "stdout": "a-b-c\n", - "stderr": "", - "exitCode": 0 - }, - "28ab150746b66692": { - "command": "sed '$ s/hello/hi/' test.txt", - "files": { - "test.txt": "hello\nhello\nhello\n" - }, - "stdout": "hello\nhello\nhi\n", - "stderr": "", - "exitCode": 0 - }, - "33d522291dad4a36": { - "command": "echo -e 'hello\\nworld' | sed 's/o/0/g'", - "files": {}, - "stdout": "hell0\nw0rld\n", - "stderr": "", - "exitCode": 0 - }, - "3bdabfafbd59f654": { - "command": "sed 's/hello/hi/gi' test.txt", - "files": { - "test.txt": "Hello HELLO hello\n" - }, - "stdout": "hi hi hi\n", - "stderr": "", - "exitCode": 0 - }, - "40f1b2a20c3eb769": { - "command": "sed 's/hello/hi/g' test.txt", - "files": { - "test.txt": "hello hello hello\n" - }, - "stdout": "hi hi hi\n", - "stderr": "", - "exitCode": 0 - }, - "4390f65c1a932838": { - "command": "sed '/delete/d' test.txt", - "files": { - "test.txt": "keep\ndelete\nkeep\n" - }, - "stdout": "keep\nkeep\n", - "stderr": "", - "exitCode": 0 - }, - "4497ac2c76063386": { - "command": "sed 's/hello/hi/' test.txt", - "files": { - "test.txt": "hello world\n" - }, - "stdout": "hi world\n", - "stderr": "", - "exitCode": 0 - }, - "4b03689ded6f7203": { - "command": "sed 's/a/b/' test.txt", - "files": { - "test.txt": "" - }, - "stdout": "", - "stderr": "", - "exitCode": 0 - }, - "5d80ef05bb6ca3e0": { - "command": "sed 's#path/to#new/path#' test.txt", - "files": { - "test.txt": "path/to/file\n" - }, - "stdout": "new/path/file\n", - "stderr": "", - "exitCode": 0 - }, - "66a6e1b137ee8afe": { - "command": "sed 's/hello/[&]/' test.txt", - "files": { - "test.txt": "hello\n" - }, - "stdout": "[hello]\n", - "stderr": "", - "exitCode": 0 - }, - "67ca047cc1bcee3d": { - "command": "sed 's/\\./-/g' test.txt", - "files": { - "test.txt": "hello.world\n" - }, - "stdout": "hello-world\n", - "stderr": "", - "exitCode": 0 - }, - "8dc8277fda7aa887": { - "command": "sed '2,3d' test.txt", - "files": { - "test.txt": "line1\nline2\nline3\nline4\n" - }, - "stdout": "line1\nline4\n", - "stderr": "", - "exitCode": 0 - }, - "90d978b05dc721c4": { - "command": "sed 's/xyz/abc/' test.txt", - "files": { - "test.txt": "hello world\n" - }, - "stdout": "hello world\n", - "stderr": "", - "exitCode": 0 - }, - "afd90b3a99597517": { - "command": "sed 's/hello/hi/' test.txt", - "files": { - "test.txt": "hello world\nhello again\n" - }, - "stdout": "hi world\nhi again\n", - "stderr": "", - "exitCode": 0 - }, - "b94e2de48f9e2e33": { - "command": "sed '1s/hello/hi/' test.txt", - "files": { - "test.txt": "hello\nhello\nhello\n" - }, - "stdout": "hi\nhello\nhello\n", - "stderr": "", - "exitCode": 0 - }, - "dab2a9c75588cf44": { - "command": "echo 'hello world' | sed 's/hello/hi/'", - "files": {}, - "stdout": "hi world\n", - "stderr": "", - "exitCode": 0 - }, - "f14853e5d5ebb553": { - "command": "sed '2,3s/hello/hi/' test.txt", - "files": { - "test.txt": "hello\nhello\nhello\nhello\n" - }, - "stdout": "hello\nhi\nhi\nhello\n", - "stderr": "", - "exitCode": 0 - }, - "f274c250c1fcff7b": { - "command": "sed '1d' test.txt", - "files": { - "test.txt": "line1\nline2\nline3\n" - }, - "stdout": "line2\nline3\n", - "stderr": "", - "exitCode": 0 - }, - "f3bf3f6fcf370329": { - "command": "sed '2s/hello/hi/' test.txt", - "files": { - "test.txt": "hello\nhello\nhello\n" - }, - "stdout": "hello\nhi\nhello\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/sort.comparison.fixtures.json b/src/comparison-tests/fixtures/sort.comparison.fixtures.json deleted file mode 100644 index 773716c9..00000000 --- a/src/comparison-tests/fixtures/sort.comparison.fixtures.json +++ /dev/null @@ -1,207 +0,0 @@ -{ - "108dcbed2cd5782d": { - "command": "sort -t: -k2 -rn test.txt", - "files": { - "test.txt": "a:1\nb:3\nc:2\n" - }, - "stdout": "b:3\nc:2\na:1\n", - "stderr": "", - "exitCode": 0 - }, - "144a0a82fb4ffd95": { - "command": "sort test.txt", - "files": { - "test.txt": "banana\napple\ncherry\n" - }, - "stdout": "apple\nbanana\ncherry\n", - "stderr": "", - "exitCode": 0 - }, - "22ec96c433c79739": { - "command": "sort -h test.txt", - "files": { - "test.txt": "1K\n2M\n500\n1G\n100K\n" - }, - "stdout": "500\n1K\n100K\n2M\n1G\n", - "stderr": "", - "exitCode": 0 - }, - "335547cbd220ee19": { - "command": "sort -ru test.txt", - "files": { - "test.txt": "apple\nbanana\napple\ncherry\n" - }, - "stdout": "cherry\nbanana\napple\n", - "stderr": "", - "exitCode": 0 - }, - "4a3da2790df3b3e2": { - "command": "echo -e \"c\\na\\nb\" | sort", - "files": {}, - "stdout": "a\nb\nc\n", - "stderr": "", - "exitCode": 0 - }, - "6f4906186ee7277d": { - "command": "sort -c test.txt; echo $?", - "files": { - "test.txt": "a\nb\nc\n" - }, - "stdout": "0\n", - "stderr": "", - "exitCode": 0 - }, - "79606ee16219d028": { - "command": "sort -t: -k2 test.txt", - "files": { - "test.txt": "a:3\nb:1\nc:2\n" - }, - "stdout": "b:1\nc:2\na:3\n", - "stderr": "", - "exitCode": 0 - }, - "8a1b9d02adf9b973": { - "command": "sort -hr test.txt", - "files": { - "test.txt": "1K\n1M\n1G\n" - }, - "stdout": "1G\n1M\n1K\n", - "stderr": "", - "exitCode": 0 - }, - "972a3cbce4c80459": { - "command": "sort -nr test.txt", - "files": { - "test.txt": "10\n2\n1\n20\n" - }, - "stdout": "20\n10\n2\n1\n", - "stderr": "", - "exitCode": 0 - }, - "9bb5cb8b05a35603": { - "command": "sort -t: -k2 -n test.txt", - "files": { - "test.txt": "a:10\nb:2\nc:5\n" - }, - "stdout": "b:2\nc:5\na:10\n", - "stderr": "", - "exitCode": 0 - }, - "9bc249b4bd9769ca": { - "command": "sort -V test.txt", - "files": { - "test.txt": "file1.10\nfile1.2\nfile1.1\n" - }, - "stdout": "file1.1\nfile1.2\nfile1.10\n", - "stderr": "", - "exitCode": 0 - }, - "9bd8d04e1fe4c298": { - "command": "sort -k 2 test.txt", - "files": { - "test.txt": "x 3\ny 1\nz 2\n" - }, - "stdout": "y 1\nz 2\nx 3\n", - "stderr": "", - "exitCode": 0 - }, - "9ec9ea2eb1234028": { - "command": "sort -V test.txt", - "files": { - "test.txt": "1.0.10\n1.0.2\n1.0.0\n" - }, - "stdout": "1.0.0\n1.0.2\n1.0.10\n", - "stderr": "", - "exitCode": 0 - }, - "abe97e27f804e3e7": { - "command": "sort -r test.txt", - "files": { - "test.txt": "banana\napple\ncherry\n" - }, - "stdout": "cherry\nbanana\napple\n", - "stderr": "", - "exitCode": 0 - }, - "c090a6aa528f173d": { - "command": "sort -c test.txt 2>&1; echo $?", - "files": { - "test.txt": "b\na\nc\n" - }, - "stdout": "sort: test.txt:2: disorder: a\n1\n", - "stderr": "", - "exitCode": 0 - }, - "c2a6b324d07381e6": { - "command": "sort -n test.txt", - "files": { - "test.txt": "10 apples\n2 oranges\n5 bananas\n" - }, - "stdout": "2 oranges\n5 bananas\n10 apples\n", - "stderr": "", - "exitCode": 0 - }, - "c3274d7afbc806fc": { - "command": "sort -b test.txt", - "files": { - "test.txt": " b\na\n c\n" - }, - "stdout": "a\n b\n c\n", - "stderr": "", - "exitCode": 0 - }, - "cad2b2e2302002a5": { - "command": "sort -nu test.txt", - "files": { - "test.txt": "5\n3\n5\n1\n3\n" - }, - "stdout": "1\n3\n5\n", - "stderr": "", - "exitCode": 0 - }, - "ce27a7876e30e3c6": { - "command": "sort -u test.txt", - "files": { - "test.txt": "apple\nbanana\napple\ncherry\nbanana\n" - }, - "stdout": "apple\nbanana\ncherry\n", - "stderr": "", - "exitCode": 0 - }, - "d5a4c74bbc48b229": { - "command": "sort -n test.txt", - "files": { - "test.txt": "10\n-5\n0\n-10\n5\n" - }, - "stdout": "-10\n-5\n0\n5\n10\n", - "stderr": "", - "exitCode": 0 - }, - "e90055ad73f7de6e": { - "command": "sort -n test.txt", - "files": { - "test.txt": "10\n2\n1\n20\n5\n" - }, - "stdout": "1\n2\n5\n10\n20\n", - "stderr": "", - "exitCode": 0 - }, - "e9fe665b16497404": { - "command": "sort -k 2 -n test.txt", - "files": { - "test.txt": "x 10\ny 2\nz 5\n" - }, - "stdout": "y 2\nz 5\nx 10\n", - "stderr": "", - "exitCode": 0 - }, - "f9a02dc02cc47cde": { - "command": "sort test.txt", - "files": { - "test.txt": "b\n\na\n\nc\n" - }, - "stdout": "\n\na\nb\nc\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/strings-split.comparison.fixtures.json b/src/comparison-tests/fixtures/strings-split.comparison.fixtures.json deleted file mode 100644 index 4bc4ec87..00000000 --- a/src/comparison-tests/fixtures/strings-split.comparison.fixtures.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "178886a259dcce51": { - "command": "printf 'ab\\x00cde\\x00fghi' | strings -n 3", - "files": {}, - "stdout": "cde\nfghi\n", - "stderr": "", - "exitCode": 0 - }, - "31e4f21777c03a4c": { - "command": "echo 'hello world' | strings", - "files": {}, - "stdout": "hello world\n", - "stderr": "", - "exitCode": 0 - }, - "40725d32be75a856": { - "command": "printf 'ab\\x00cd\\x00efgh' | strings", - "files": {}, - "stdout": "efgh\n", - "stderr": "", - "exitCode": 0 - }, - "75c89a5f8b50e573": { - "command": "split -l 2 test.txt && cat xab", - "files": { - "test.txt": "1\n2\n3\n4\n5\n" - }, - "stdout": "3\n4\n", - "stderr": "", - "exitCode": 0 - }, - "89796601f0abcc5f": { - "command": "split -b 4 test.txt && cat xaa", - "files": { - "test.txt": "abcdefghij" - }, - "stdout": "abcd", - "stderr": "", - "exitCode": 0 - }, - "899bac38cd056fb1": { - "command": "printf '' | split", - "files": {}, - "stdout": "", - "stderr": "", - "exitCode": 0 - }, - "98f5ea03a4827540": { - "command": "printf 'hello\\x00\\x00world\\x00\\x00test' | strings", - "files": {}, - "stdout": "hello\nworld\ntest\n", - "stderr": "", - "exitCode": 0 - }, - "ce1a3c68c8be39ff": { - "command": "printf 'a\\nb\\nc\\n' | split -l 1 && cat xaa", - "files": {}, - "stdout": "a\n", - "stderr": "", - "exitCode": 0 - }, - "d5aa452734b08d3f": { - "command": "strings test.bin", - "files": { - "test.bin": "hello\u0000\u0000\u0000world" - }, - "stdout": "hello\nworld\n", - "stderr": "", - "exitCode": 0 - }, - "e40f769b12974c79": { - "command": "split -l 2 test.txt && cat xaa", - "files": { - "test.txt": "1\n2\n3\n4\n5\n" - }, - "stdout": "1\n2\n", - "stderr": "", - "exitCode": 0 - }, - "f71fa7029dbebaf8": { - "command": "printf 'hello' | strings", - "files": {}, - "stdout": "hello\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/tar.comparison.fixtures.json b/src/comparison-tests/fixtures/tar.comparison.fixtures.json deleted file mode 100644 index 2acd9a4e..00000000 --- a/src/comparison-tests/fixtures/tar.comparison.fixtures.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "1eefde366bd2d5f4": { - "command": "tar -cf archive.tar deep/path/file.txt && mkdir out && tar -xf archive.tar -C out --strip-components=2 && cat out/file.txt", - "files": { - "deep/path/file.txt": "Deep content\n" - }, - "stdout": "Deep content\n", - "stderr": "", - "exitCode": 0 - }, - "2ba72b80d7e2315e": { - "command": "tar -cf archive.tar mydir && rm -rf mydir && tar -xf archive.tar && cat mydir/nested.txt", - "files": { - "mydir/nested.txt": "Nested content\n" - }, - "stdout": "Nested content\n", - "stderr": "", - "exitCode": 0 - }, - "4e2cbf2e913c29c9": { - "command": "tar -czf archive.tar.gz compress.txt && tar -tzf archive.tar.gz", - "files": { - "compress.txt": "Content to compress\n" - }, - "stdout": "compress.txt\n", - "stderr": "", - "exitCode": 0 - }, - "53ef5bd7218ab0f6": { - "command": "tar -czf archive.tar.gz compress.txt && rm compress.txt && tar -xzf archive.tar.gz && cat compress.txt", - "files": { - "compress.txt": "Compressed content\n" - }, - "stdout": "Compressed content\n", - "stderr": "", - "exitCode": 0 - }, - "76755482c30f6729": { - "command": "tar -cf archive.tar hello.txt && tar -tf archive.tar", - "files": { - "hello.txt": "Hello, World!\n" - }, - "stdout": "hello.txt\n", - "stderr": "", - "exitCode": 0 - }, - "87cc3d174c5bf2cd": { - "command": "tar -cf archive.tar source.txt && mkdir dest && tar -xf archive.tar -C dest && cat dest/source.txt", - "files": { - "source.txt": "Source content\n" - }, - "stdout": "Source content\n", - "stderr": "", - "exitCode": 0 - }, - "c0f8ce57cb363e70": { - "command": "tar -cf archive.tar mydir && tar -tf archive.tar | sort", - "files": { - "mydir/file1.txt": "Content 1\n", - "mydir/file2.txt": "Content 2\n" - }, - "stdout": "mydir/\nmydir/file1.txt\nmydir/file2.txt\n", - "stderr": "", - "exitCode": 0 - }, - "cdaa68944a83396b": { - "command": "tar -cf archive.tar original.txt && rm original.txt && tar -xf archive.tar && cat original.txt", - "files": { - "original.txt": "Original content\n" - }, - "stdout": "Original content\n", - "stderr": "", - "exitCode": 0 - }, - "ea3d17f6c34f860d": { - "command": "tar -cf archive.tar file1.txt file2.txt && tar -tf archive.tar | sort", - "files": { - "file1.txt": "Content 1\n", - "file2.txt": "Content 2\n" - }, - "stdout": "file1.txt\nfile2.txt\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/test.comparison.fixtures.json b/src/comparison-tests/fixtures/test.comparison.fixtures.json deleted file mode 100644 index 85a3d6e9..00000000 --- a/src/comparison-tests/fixtures/test.comparison.fixtures.json +++ /dev/null @@ -1,307 +0,0 @@ -{ - "07ecf5d06aae1d01": { - "command": "test \"abc\" != \"abc\" && echo notequal || echo equal", - "files": {}, - "stdout": "equal\n", - "stderr": "", - "exitCode": 0 - }, - "142b3be2490b21d9": { - "command": "test -f a.txt -a -f nonexistent && echo both || echo notboth", - "files": { - "a.txt": "a" - }, - "stdout": "notboth\n", - "stderr": "", - "exitCode": 0 - }, - "2a3886087954312e": { - "command": "test 5 -eq 6 && echo equal || echo notequal", - "files": {}, - "stdout": "notequal\n", - "stderr": "", - "exitCode": 0 - }, - "2b1ca9a19ec4652d": { - "command": "test -f a.txt -a -f b.txt && echo both || echo notboth", - "files": { - "a.txt": "a", - "b.txt": "b" - }, - "stdout": "both\n", - "stderr": "", - "exitCode": 0 - }, - "2bf51f02f0d8319b": { - "command": "test \"abc\" = \"abc\" && echo equal || echo notequal", - "files": {}, - "stdout": "equal\n", - "stderr": "", - "exitCode": 0 - }, - "32e9bb0492159c86": { - "command": "VAR=\"\"; test -z \"$VAR\" && echo empty || echo notempty", - "files": {}, - "stdout": "empty\n", - "stderr": "", - "exitCode": 0 - }, - "362f18b1f969f0c4": { - "command": "VAR=hello; test -n \"$VAR\" && echo set || echo unset", - "files": {}, - "stdout": "set\n", - "stderr": "", - "exitCode": 0 - }, - "3806c4e0bb27fc8a": { - "command": "test -z \"\" && echo empty || echo nonempty", - "files": {}, - "stdout": "empty\n", - "stderr": "", - "exitCode": 0 - }, - "3bc0563368211125": { - "command": "test -n \"hello\" && echo nonempty || echo empty", - "files": {}, - "stdout": "nonempty\n", - "stderr": "", - "exitCode": 0 - }, - "40d7a6d3001fa4df": { - "command": "test \"abc\" = \"def\" && echo equal || echo notequal", - "files": {}, - "stdout": "notequal\n", - "stderr": "", - "exitCode": 0 - }, - "45a90c59ab9f56a7": { - "command": "test 5 -gt 3 && echo greater || echo notgreater", - "files": {}, - "stdout": "greater\n", - "stderr": "", - "exitCode": 0 - }, - "477d1feb1d665e03": { - "command": "test -n \"\" && echo nonempty || echo empty", - "files": {}, - "stdout": "empty\n", - "stderr": "", - "exitCode": 0 - }, - "4bb9ae673513f981": { - "command": "test -s file.txt && echo nonempty || echo empty", - "files": { - "file.txt": "content" - }, - "stdout": "nonempty\n", - "stderr": "", - "exitCode": 0 - }, - "4bfdda5779eb22aa": { - "command": "[ ] && echo true || echo false", - "files": {}, - "stdout": "false\n", - "stderr": "", - "exitCode": 0 - }, - "4e936e4cb3221fa0": { - "command": "test \"\" && echo nonempty || echo empty", - "files": {}, - "stdout": "empty\n", - "stderr": "", - "exitCode": 0 - }, - "559d18b007565907": { - "command": "test -f dir && echo file || echo not", - "files": { - "dir/file.txt": "content" - }, - "stdout": "not\n", - "stderr": "", - "exitCode": 0 - }, - "59e6197f30e2c6f9": { - "command": "test -d dir && echo dir || echo not", - "files": { - "dir/file.txt": "content" - }, - "stdout": "dir\n", - "stderr": "", - "exitCode": 0 - }, - "64e1d835faf934fd": { - "command": "test -s empty.txt && echo nonempty || echo empty", - "files": { - "empty.txt": "" - }, - "stdout": "empty\n", - "stderr": "", - "exitCode": 0 - }, - "67cec0b058f75b10": { - "command": "NUM=10; test $NUM -gt 5 && echo greater || echo notgreater", - "files": {}, - "stdout": "greater\n", - "stderr": "", - "exitCode": 0 - }, - "7d3cfdb988f34f1f": { - "command": "test 5 -lt 3 && echo less || echo notless", - "files": {}, - "stdout": "notless\n", - "stderr": "", - "exitCode": 0 - }, - "8ff8e6ec3f4f9aa5": { - "command": "test 5 -ge 5 && echo greatereq || echo notgreatereq", - "files": {}, - "stdout": "greatereq\n", - "stderr": "", - "exitCode": 0 - }, - "9645cc27f0443e75": { - "command": "test && echo true || echo false", - "files": {}, - "stdout": "false\n", - "stderr": "", - "exitCode": 0 - }, - "9897cd58b4c36e89": { - "command": "test -f a -o -f b && echo either || echo neither", - "files": {}, - "stdout": "neither\n", - "stderr": "", - "exitCode": 0 - }, - "999638a8d4a7e5f3": { - "command": "test -z \"hello\" && echo empty || echo nonempty", - "files": {}, - "stdout": "nonempty\n", - "stderr": "", - "exitCode": 0 - }, - "9b4d17ccee3c3959": { - "command": "[ -f file.txt ] && echo file || echo notfile", - "files": { - "file.txt": "content" - }, - "stdout": "file\n", - "stderr": "", - "exitCode": 0 - }, - "9f128e60a6c2032e": { - "command": "[ 10 -gt 5 ] && echo greater || echo notgreater", - "files": {}, - "stdout": "greater\n", - "stderr": "", - "exitCode": 0 - }, - "b96acf39a07f04fc": { - "command": "test -e file.txt && echo exists", - "files": { - "file.txt": "content" - }, - "stdout": "exists\n", - "stderr": "", - "exitCode": 0 - }, - "be851b706cc0a780": { - "command": "test 5 -ne 6 && echo notequal || echo equal", - "files": {}, - "stdout": "notequal\n", - "stderr": "", - "exitCode": 0 - }, - "c0bd6d6e23f92c4d": { - "command": "test -e nonexistent && echo exists || echo missing", - "files": {}, - "stdout": "missing\n", - "stderr": "", - "exitCode": 0 - }, - "cc25be4d71e0e788": { - "command": "test \"abc\" != \"def\" && echo notequal || echo equal", - "files": {}, - "stdout": "notequal\n", - "stderr": "", - "exitCode": 0 - }, - "cee2a2252e72be35": { - "command": "test ! -z \"hello\" && echo notempty || echo empty", - "files": {}, - "stdout": "notempty\n", - "stderr": "", - "exitCode": 0 - }, - "d51751d648290898": { - "command": "test 5 -eq 5 && echo equal || echo notequal", - "files": {}, - "stdout": "equal\n", - "stderr": "", - "exitCode": 0 - }, - "d7090ed20fb46e42": { - "command": "test -f nonexistent -o -f a.txt && echo either || echo neither", - "files": { - "a.txt": "a" - }, - "stdout": "either\n", - "stderr": "", - "exitCode": 0 - }, - "dab99a504d50a7a0": { - "command": "test 5 -le 5 && echo lesseq || echo notlesseq", - "files": {}, - "stdout": "lesseq\n", - "stderr": "", - "exitCode": 0 - }, - "e242b0275b3369a6": { - "command": "test \"hello\" && echo nonempty || echo empty", - "files": {}, - "stdout": "nonempty\n", - "stderr": "", - "exitCode": 0 - }, - "e3524ab9bf872049": { - "command": "test -d file.txt && echo dir || echo not", - "files": { - "file.txt": "content" - }, - "stdout": "not\n", - "stderr": "", - "exitCode": 0 - }, - "e87af379741409a8": { - "command": "[ -d dir -a -f dir/file ] && echo both || echo notboth", - "files": { - "dir/file": "x" - }, - "stdout": "both\n", - "stderr": "", - "exitCode": 0 - }, - "ed94aed584f0e297": { - "command": "[ \"foo\" = \"foo\" ] && echo equal || echo notequal", - "files": {}, - "stdout": "equal\n", - "stderr": "", - "exitCode": 0 - }, - "f7b39a0f7cb047a2": { - "command": "test 3 -lt 5 && echo less || echo notless", - "files": {}, - "stdout": "less\n", - "stderr": "", - "exitCode": 0 - }, - "ff9e0673004daf1a": { - "command": "test -f file.txt && echo file || echo not", - "files": { - "file.txt": "content" - }, - "stdout": "file\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/text-processing.comparison.fixtures.json b/src/comparison-tests/fixtures/text-processing.comparison.fixtures.json deleted file mode 100644 index b5792a95..00000000 --- a/src/comparison-tests/fixtures/text-processing.comparison.fixtures.json +++ /dev/null @@ -1,213 +0,0 @@ -{ - "0cfefbef7bf4267f": { - "command": "echo 'a b c' | rev", - "files": {}, - "stdout": "c b a\n", - "stderr": "", - "exitCode": 0 - }, - "0f396624a5b2b29c": { - "command": "printf ' hello' | unexpand", - "files": {}, - "stdout": "\t\thello", - "stderr": "", - "exitCode": 0 - }, - "15702cf2412cbcc3": { - "command": "echo 'hello' | rev", - "files": {}, - "stdout": "olleh\n", - "stderr": "", - "exitCode": 0 - }, - "1a4e936cd929caf5": { - "command": "printf ' hello' | unexpand -t 4", - "files": {}, - "stdout": "\thello", - "stderr": "", - "exitCode": 0 - }, - "3651d6faa9e80653": { - "command": "echo 'hello world test' | fold -w 5", - "files": {}, - "stdout": "hello\n worl\nd tes\nt\n", - "stderr": "", - "exitCode": 0 - }, - "3de1ee32396abc0e": { - "command": "echo 'hello world foo bar' | fold -sw 10", - "files": {}, - "stdout": "hello \nworld foo \nbar\n", - "stderr": "", - "exitCode": 0 - }, - "48dd3be4c49723be": { - "command": "printf 'hello world' | expand", - "files": {}, - "stdout": "hello world", - "stderr": "", - "exitCode": 0 - }, - "584e1b15ac3ed9aa": { - "command": "printf 'hello world' | unexpand -a", - "files": {}, - "stdout": "hello\tworld", - "stderr": "", - "exitCode": 0 - }, - "5d9f552f3cc612ea": { - "command": "printf 'a\\nb\\n' | nl -v 10", - "files": {}, - "stdout": " 10\ta\n 11\tb\n", - "stderr": "", - "exitCode": 0 - }, - "66aa74e407fbbdcc": { - "command": "printf 'a\\tb' | expand -t 4", - "files": {}, - "stdout": "a b", - "stderr": "", - "exitCode": 0 - }, - "6e61839db2810b92": { - "command": "printf 'a\\n\\nb\\n' | nl", - "files": {}, - "stdout": " 1\ta\n \t\n 2\tb\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "72406a722f214749": { - "command": "printf 'a\\nb\\n' | nl -w 3", - "files": {}, - "stdout": " 1\ta\n 2\tb\n", - "stderr": "", - "exitCode": 0 - }, - "724584ed73f472ff": { - "command": "printf 'hello world' | unexpand", - "files": {}, - "stdout": "hello world", - "stderr": "", - "exitCode": 0 - }, - "7a14e885fabc92c7": { - "command": "printf 'abc\\ndef\\nghi\\n' | rev", - "files": {}, - "stdout": "cba\nfed\nihg\n", - "stderr": "", - "exitCode": 0 - }, - "8831689a8d5430a4": { - "command": "printf '12345678901234567890\\nabcdefghij\\n' | fold -w 10", - "files": {}, - "stdout": "1234567890\n1234567890\nabcdefghij\n", - "stderr": "", - "exitCode": 0 - }, - "8a585e752e20d3d4": { - "command": "printf 'a\\nb\\nc\\n' | nl", - "files": {}, - "stdout": " 1\ta\n 2\tb\n 3\tc\n", - "stderr": "", - "exitCode": 0 - }, - "8d27daf615a55f3b": { - "command": "printf '' | rev", - "files": {}, - "stdout": "", - "stderr": "", - "exitCode": 0 - }, - "90784bbe93f4ce8e": { - "command": "printf 'a\\nb\\nc\\n' | nl -i 5", - "files": {}, - "stdout": " 1\ta\n 6\tb\n 11\tc\n", - "stderr": "", - "exitCode": 0 - }, - "90964b06e217425d": { - "command": "printf 'a\\nb\\n' | nl -s ': '", - "files": {}, - "stdout": " 1: a\n 2: b\n", - "stderr": "", - "exitCode": 0 - }, - "9fbb4426ebfe4dca": { - "command": "printf '' | fold", - "files": {}, - "stdout": "", - "stderr": "", - "exitCode": 0 - }, - "a054cde6fbd7cfc0": { - "command": "printf 'a\\nb\\n' | nl -n ln", - "files": {}, - "stdout": "1 \ta\n2 \tb\n", - "stderr": "", - "exitCode": 0 - }, - "a9809e9702814446": { - "command": "echo 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' | fold", - "files": {}, - "stdout": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaaaa\n", - "stderr": "", - "exitCode": 0 - }, - "b559afb3b984f7ed": { - "command": "printf ' hello' | unexpand", - "files": {}, - "stdout": "\thello", - "stderr": "", - "exitCode": 0 - }, - "bf352979f91a2354": { - "command": "printf 'a\\n\\nb\\n' | nl -ba", - "files": {}, - "stdout": " 1\ta\n 2\t\n 3\tb\n", - "stderr": "", - "exitCode": 0 - }, - "c2062183ce068c73": { - "command": "printf 'a\\nb\\n' | nl -n rz", - "files": {}, - "stdout": "000001\ta\n000002\tb\n", - "stderr": "", - "exitCode": 0 - }, - "c213b401b3ff4612": { - "command": "printf 'a\\tb\\tc' | expand", - "files": {}, - "stdout": "a b c", - "stderr": "", - "exitCode": 0 - }, - "c875af8a344da85e": { - "command": "printf ' hello' | unexpand", - "files": {}, - "stdout": " hello", - "stderr": "", - "exitCode": 0 - }, - "c8afd3988b088a74": { - "command": "printf '\\thello' | expand", - "files": {}, - "stdout": " hello", - "stderr": "", - "exitCode": 0 - }, - "cfa173ca06ac324b": { - "command": "echo 'a' | rev", - "files": {}, - "stdout": "a\n", - "stderr": "", - "exitCode": 0 - }, - "e94e9ea341c95519": { - "command": "printf 'a\\tb' | expand", - "files": {}, - "stdout": "a b", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/tr.comparison.fixtures.json b/src/comparison-tests/fixtures/tr.comparison.fixtures.json deleted file mode 100644 index 37e290cf..00000000 --- a/src/comparison-tests/fixtures/tr.comparison.fixtures.json +++ /dev/null @@ -1,142 +0,0 @@ -{ - "06b38615c4fee0b4": { - "command": "cat test.txt | tr -s '\\n'", - "files": { - "test.txt": "line1\n\n\nline2\n\nline3\n" - }, - "stdout": "line1\nline2\nline3\n", - "stderr": "", - "exitCode": 0 - }, - "0f00e2b388db24a3": { - "command": "cat test.txt | tr '\\t' ' '", - "files": { - "test.txt": "hello\tworld\n" - }, - "stdout": "hello world\n", - "stderr": "", - "exitCode": 0 - }, - "16fee68ec84c6781": { - "command": "cat test.txt | tr -d '\\n'", - "files": { - "test.txt": "line1\nline2\nline3\n" - }, - "stdout": "line1line2line3", - "stderr": "", - "exitCode": 0 - }, - "3768a0c282f76267": { - "command": "cat test.txt | tr 'A-Z' 'a-z'", - "files": { - "test.txt": "HELLO WORLD\n" - }, - "stdout": "hello world\n", - "stderr": "", - "exitCode": 0 - }, - "48af8d3cbc0dc282": { - "command": "cat test.txt | tr -s ' '", - "files": { - "test.txt": "hello world foo\n" - }, - "stdout": "hello world foo\n", - "stderr": "", - "exitCode": 0 - }, - "532b92338017f3e3": { - "command": "cat test.txt | tr -d ' '", - "files": { - "test.txt": "hello world foo bar\n" - }, - "stdout": "helloworldfoobar\n", - "stderr": "", - "exitCode": 0 - }, - "5c6bf42ed3f77572": { - "command": "echo 'hello world' | tr -d 'lo'", - "files": {}, - "stdout": "he wrd\n", - "stderr": "", - "exitCode": 0 - }, - "6568d3f3ee19db46": { - "command": "cat test.txt | tr -s 'lo '", - "files": { - "test.txt": "helllo wooorld\n" - }, - "stdout": "helo world\n", - "stderr": "", - "exitCode": 0 - }, - "672a7ba48d6b4c4d": { - "command": "cat test.txt | tr '\\n' ' '", - "files": { - "test.txt": "line1\nline2\nline3\n" - }, - "stdout": "line1 line2 line3 ", - "stderr": "", - "exitCode": 0 - }, - "94ea30b921ff3a8d": { - "command": "echo 'hello' | tr 'a-z' 'A-Z'", - "files": {}, - "stdout": "HELLO\n", - "stderr": "", - "exitCode": 0 - }, - "9996c4a89e05e0da": { - "command": "cat test.txt | tr -d 'aeiou'", - "files": { - "test.txt": "hello world\n" - }, - "stdout": "hll wrld\n", - "stderr": "", - "exitCode": 0 - }, - "9de9409449faf359": { - "command": "cat test.txt | tr 'a-z' 'A-Z'", - "files": { - "test.txt": "hello world\n" - }, - "stdout": "HELLO WORLD\n", - "stderr": "", - "exitCode": 0 - }, - "ada03462d8dd64bd": { - "command": "cat test.txt | tr 'elo' 'xyz'", - "files": { - "test.txt": "hello\n" - }, - "stdout": "hxyyz\n", - "stderr": "", - "exitCode": 0 - }, - "b46f146c5cab934a": { - "command": "cat test.txt | tr 'A-Z' 'a-z' | tr -s ' '", - "files": { - "test.txt": "HELLO WORLD\n" - }, - "stdout": "hello world\n", - "stderr": "", - "exitCode": 0 - }, - "c48537396397fc11": { - "command": "cat test.txt | tr '0-9' 'a-j'", - "files": { - "test.txt": "12345\n" - }, - "stdout": "bcdef\n", - "stderr": "", - "exitCode": 0 - }, - "e56a3c786c9fa17c": { - "command": "cat test.txt | tr -d '0-9'", - "files": { - "test.txt": "abc123def456\n" - }, - "stdout": "abcdef\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/uniq.comparison.fixtures.json b/src/comparison-tests/fixtures/uniq.comparison.fixtures.json deleted file mode 100644 index d1f6cb9a..00000000 --- a/src/comparison-tests/fixtures/uniq.comparison.fixtures.json +++ /dev/null @@ -1,147 +0,0 @@ -{ - "08b2af999ca280b9": { - "command": "uniq test.txt", - "files": { - "test.txt": "apple\nbanana\napple\ncherry\nbanana\n" - }, - "stdout": "apple\nbanana\napple\ncherry\nbanana\n", - "stderr": "", - "exitCode": 0 - }, - "1ed1c9beb7525bb1": { - "command": "echo -e \"a\\na\\nb\" | uniq", - "files": {}, - "stdout": "a\nb\n", - "stderr": "", - "exitCode": 0 - }, - "4baafcd0776bdda9": { - "command": "uniq -c test.txt", - "files": { - "test.txt": "a\nb\nc\n" - }, - "stdout": " 1 a\n 1 b\n 1 c\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "5de0d1af06731848": { - "command": "sort test.txt | uniq", - "files": { - "test.txt": "apple\nbanana\napple\ncherry\nbanana\n" - }, - "stdout": "apple\nbanana\ncherry\n", - "stderr": "", - "exitCode": 0 - }, - "63ad63113394243e": { - "command": "echo -e \"a\\na\\nb\\nb\\nb\" | uniq -c", - "files": {}, - "stdout": " 2 a\n 3 b\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "740e9a36c7fe3331": { - "command": "uniq -d test.txt", - "files": { - "test.txt": "a\nb\nc\n" - }, - "stdout": "", - "stderr": "", - "exitCode": 0 - }, - "947e03113a427ace": { - "command": "uniq test.txt", - "files": { - "test.txt": "same\nsame\nsame\n" - }, - "stdout": "same\n", - "stderr": "", - "exitCode": 0 - }, - "9af750f90a24e236": { - "command": "uniq -c test.txt", - "files": { - "test.txt": "apple\napple\nbanana\nbanana\nbanana\ncherry\n" - }, - "stdout": " 2 apple\n 3 banana\n 1 cherry\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "a300c9f1f5c05058": { - "command": "uniq test.txt", - "files": { - "test.txt": "apple\nbanana\ncherry\n" - }, - "stdout": "apple\nbanana\ncherry\n", - "stderr": "", - "exitCode": 0 - }, - "c904ada5e9bd622a": { - "command": "uniq -cd test.txt", - "files": { - "test.txt": "a\na\nb\nc\nc\nc\n" - }, - "stdout": " 2 a\n 3 c\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "d2218cb430d74d51": { - "command": "uniq -u test.txt", - "files": { - "test.txt": "a\nb\nc\n" - }, - "stdout": "a\nb\nc\n", - "stderr": "", - "exitCode": 0 - }, - "d56570a322584c87": { - "command": "uniq -u test.txt", - "files": { - "test.txt": "a\na\nb\nb\n" - }, - "stdout": "", - "stderr": "", - "exitCode": 0 - }, - "dde066d078d791b9": { - "command": "uniq -d test.txt", - "files": { - "test.txt": "apple\napple\nbanana\ncherry\ncherry\n" - }, - "stdout": "apple\ncherry\n", - "stderr": "", - "exitCode": 0 - }, - "e1365a02d3cb07d7": { - "command": "sort test.txt | uniq -c", - "files": { - "test.txt": "apple\nbanana\napple\ncherry\nbanana\napple\n" - }, - "stdout": " 3 apple\n 2 banana\n 1 cherry\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "e6ed6da7f4f2d8dd": { - "command": "uniq -u test.txt", - "files": { - "test.txt": "apple\napple\nbanana\ncherry\ncherry\n" - }, - "stdout": "banana\n", - "stderr": "", - "exitCode": 0 - }, - "f116fef97f175cad": { - "command": "uniq test.txt", - "files": { - "test.txt": "apple\napple\nbanana\nbanana\nbanana\ncherry\n" - }, - "stdout": "apple\nbanana\ncherry\n", - "stderr": "", - "exitCode": 0 - } -} diff --git a/src/comparison-tests/fixtures/wc.comparison.fixtures.json b/src/comparison-tests/fixtures/wc.comparison.fixtures.json deleted file mode 100644 index 9e76819d..00000000 --- a/src/comparison-tests/fixtures/wc.comparison.fixtures.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "2781fd7f7094c9bf": { - "command": "wc empty.txt", - "files": { - "empty.txt": "" - }, - "stdout": "0 0 0 empty.txt\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "2e29f667ec186395": { - "command": "echo \"hello world\" | wc", - "files": {}, - "stdout": " 1 2 12\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "3930727afae9c7c9": { - "command": "wc -l a.txt b.txt", - "files": { - "a.txt": "line 1\nline 2\n", - "b.txt": "line 1\nline 2\nline 3\n" - }, - "stdout": " 2 a.txt\n 3 b.txt\n 5 total\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "594a6fe99828eb5f": { - "command": "wc -l test.txt", - "files": { - "test.txt": "line 1\nline 2\nline 3\n" - }, - "stdout": "3 test.txt\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "5eb262adb739302b": { - "command": "wc test.txt", - "files": { - "test.txt": "no newline" - }, - "stdout": " 0 2 10 test.txt\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "8a1eb6d6618bfd8e": { - "command": "wc test.txt", - "files": { - "test.txt": "line 1\nline 2\nline 3\n" - }, - "stdout": " 3 6 21 test.txt\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "945a3b5788f1e741": { - "command": "wc a.txt b.txt", - "files": { - "a.txt": "file a\n", - "b.txt": "file b line 1\nfile b line 2\n" - }, - "stdout": " 1 2 7 a.txt\n 2 8 28 b.txt\n 3 10 35 total\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "c4733e52e24d2f84": { - "command": "wc -wc test.txt", - "files": { - "test.txt": "hello world\n" - }, - "stdout": " 2 12 test.txt\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "c5297724c7668b58": { - "command": "wc -w test.txt", - "files": { - "test.txt": "one two three\nfour five\n" - }, - "stdout": "5 test.txt\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "cf23334f3ce26e2e": { - "command": "wc -c test.txt", - "files": { - "test.txt": "hello world\n" - }, - "stdout": "12 test.txt\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "d2264eab88bb30ee": { - "command": "wc -lw test.txt", - "files": { - "test.txt": "one two three\nfour five\n" - }, - "stdout": " 2 5 test.txt\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "f28f0e694e2f5746": { - "command": "wc -w test.txt", - "files": { - "test.txt": "one two three\n" - }, - "stdout": "3 test.txt\n", - "stderr": "", - "exitCode": 0, - "locked": true - }, - "fbae9795c34391b6": { - "command": "echo -e \"a\\nb\\nc\" | wc -l", - "files": {}, - "stdout": "3\n", - "stderr": "", - "exitCode": 0, - "locked": true - } -} diff --git a/src/comparison-tests/glob.comparison.test.ts b/src/comparison-tests/glob.comparison.test.ts deleted file mode 100644 index 07c27b11..00000000 --- a/src/comparison-tests/glob.comparison.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("glob expansion - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should expand single-level glob pattern", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "content 1", - "file2.txt": "content 2", - "file3.json": "{}", - }); - await compareOutputs(env, testDir, "echo *.txt"); - }); - - it("should expand multi-level glob pattern with wildcard directory", async () => { - const env = await setupFiles(testDir, { - "folder1/data.json": '{"a":1}', - "folder2/data.json": '{"b":2}', - "folder3/other.txt": "text", - }); - await compareOutputs(env, testDir, "echo */*.json"); - }); - - it("should expand multi-level glob pattern with absolute path", async () => { - const env = await setupFiles(testDir, { - "dm/folder1/data.json": '{"a":1}', - "dm/folder2/data.json": '{"b":2}', - "dm/folder3/other.txt": "text", - }); - await compareOutputs(env, testDir, "cat dm/*/*.json"); - }); - - it("should expand triple-level glob pattern", async () => { - const env = await setupFiles(testDir, { - "a/b/c/file.txt": "abc", - "a/d/e/file.txt": "ade", - "x/y/z/file.txt": "xyz", - }); - await compareOutputs(env, testDir, "echo */*/*/*.txt"); - }); - - it("should expand glob with question mark", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "1", - "file2.txt": "2", - "file10.txt": "10", - }); - await compareOutputs(env, testDir, "echo file?.txt"); - }); - - it("should expand glob with character class", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "1", - "file2.txt": "2", - "file3.txt": "3", - "filea.txt": "a", - }); - await compareOutputs(env, testDir, "echo file[12].txt"); - }); - - it("should return pattern when no matches", async () => { - const env = await setupFiles(testDir, { - "file.txt": "content", - }); - await compareOutputs(env, testDir, "echo *.xyz"); - }); - - it("should expand glob with grep command", async () => { - const env = await setupFiles(testDir, { - "dir1/file.txt": "hello world", - "dir2/file.txt": "hello there", - "dir3/other.txt": "goodbye", - }); - await compareOutputs(env, testDir, "grep hello */*.txt"); - }); - - it("should expand glob at root level with subdirectory pattern", async () => { - const env = await setupFiles(testDir, { - "src/a/test.ts": "test a", - "src/b/test.ts": "test b", - "lib/c/test.ts": "test c", - }); - await compareOutputs(env, testDir, "echo */*/test.ts"); - }); - - it("should handle mixed glob and literal segments", async () => { - const env = await setupFiles(testDir, { - "data/v1/config.json": "v1 config", - "data/v2/config.json": "v2 config", - "data/v1/settings.json": "v1 settings", - }); - await compareOutputs(env, testDir, "cat data/*/config.json"); - }); -}); diff --git a/src/comparison-tests/grep.comparison.test.ts b/src/comparison-tests/grep.comparison.test.ts deleted file mode 100644 index 8613c2b2..00000000 --- a/src/comparison-tests/grep.comparison.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("grep command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("basic matching", () => { - it("should match basic search", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\nfoo bar\nhello again\n", - }); - await compareOutputs(env, testDir, "grep hello test.txt"); - }); - - it("should match with no results", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\n", - }); - await compareOutputs(env, testDir, "grep notfound test.txt || true"); - }); - - it("should match pattern at start of line", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\nworld hello\n", - }); - await compareOutputs(env, testDir, 'grep "^hello" test.txt'); - }); - - it("should match pattern at end of line", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\nworld hello\n", - }); - await compareOutputs(env, testDir, 'grep "world$" test.txt'); - }); - }); - - describe("flags", () => { - it("should match -n (line numbers)", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\nfoo bar\nhello again\n", - }); - await compareOutputs(env, testDir, "grep -n hello test.txt"); - }); - - it("should match -i (case insensitive)", async () => { - const env = await setupFiles(testDir, { - "test.txt": "Hello World\nHELLO AGAIN\nhello there\n", - }); - await compareOutputs(env, testDir, "grep -i hello test.txt"); - }); - - it("should match -c (count)", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\nfoo bar\nhello again\n", - }); - await compareOutputs(env, testDir, "grep -c hello test.txt"); - }); - - it("should match -v (invert)", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\nfoo bar\nhello again\n", - }); - await compareOutputs(env, testDir, "grep -v hello test.txt"); - }); - - it("should match -l (files with matches)", async () => { - const env = await setupFiles(testDir, { - "a.txt": "hello world\n", - "b.txt": "no match\n", - "c.txt": "hello there\n", - }); - await compareOutputs(env, testDir, "grep -l hello a.txt b.txt c.txt"); - }); - - it("should match -o (only matching)", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world hello\nfoo hello bar\n", - }); - await compareOutputs(env, testDir, "grep -o hello test.txt"); - }); - - it("should match -w (word match)", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\nhelloworld\nworld hello\n", - }); - await compareOutputs(env, testDir, "grep -w hello test.txt"); - }); - - it("should match -h (no filename)", async () => { - const env = await setupFiles(testDir, { - "a.txt": "hello a\n", - "b.txt": "hello b\n", - }); - await compareOutputs(env, testDir, "grep -h hello a.txt b.txt"); - }); - }); - - describe("recursive", () => { - it("should match -r (recursive)", async () => { - const env = await setupFiles(testDir, { - "dir/file1.txt": "hello from file1\n", - "dir/file2.txt": "goodbye from file2\n", - "dir/sub/file3.txt": "hello from file3\n", - }); - await compareOutputs(env, testDir, "grep -r hello dir"); - }); - - it("should match -rl (recursive files only)", async () => { - const env = await setupFiles(testDir, { - "dir/a.txt": "hello\n", - "dir/b.txt": "world\n", - "dir/sub/c.txt": "hello\n", - }); - await compareOutputs(env, testDir, "grep -rl hello dir | sort"); - }); - - it("should match --include pattern", async () => { - const env = await setupFiles(testDir, { - "dir/test.ts": "hello ts\n", - "dir/test.js": "hello js\n", - "dir/test.txt": "hello txt\n", - }); - await compareOutputs(env, testDir, 'grep -r --include="*.ts" hello dir'); - }); - }); - - describe("context", () => { - it("should match -A (after context)", async () => { - const env = await setupFiles(testDir, { - "test.txt": "line1\nmatch\nline3\nline4\n", - }); - await compareOutputs(env, testDir, "grep -A 2 match test.txt"); - }); - - it("should match -B (before context)", async () => { - const env = await setupFiles(testDir, { - "test.txt": "line1\nline2\nmatch\nline4\n", - }); - await compareOutputs(env, testDir, "grep -B 2 match test.txt"); - }); - - it("should match -C (context)", async () => { - const env = await setupFiles(testDir, { - "test.txt": "line1\nline2\nmatch\nline4\nline5\n", - }); - await compareOutputs(env, testDir, "grep -C 1 match test.txt"); - }); - }); - - describe("multiple files", () => { - it("should show filename prefix for multiple files", async () => { - const env = await setupFiles(testDir, { - "a.txt": "hello a\n", - "b.txt": "hello b\n", - }); - await compareOutputs(env, testDir, "grep hello a.txt b.txt"); - }); - - it("should match -c with multiple files", async () => { - const env = await setupFiles(testDir, { - "a.txt": "hello\nhello\n", - "b.txt": "hello\n", - "c.txt": "world\n", - }); - await compareOutputs(env, testDir, "grep -c hello a.txt b.txt c.txt"); - }); - }); - - describe("regex patterns", () => { - it("should match character class", async () => { - const env = await setupFiles(testDir, { - "test.txt": "cat\nhat\nbat\nrat\n", - }); - await compareOutputs(env, testDir, 'grep "[ch]at" test.txt'); - }); - - it("should match dot wildcard", async () => { - const env = await setupFiles(testDir, { - "test.txt": "cat\ncut\ncot\ncart\n", - }); - await compareOutputs(env, testDir, 'grep "c.t" test.txt'); - }); - - it("should match star quantifier", async () => { - const env = await setupFiles(testDir, { - "test.txt": "ct\ncat\ncaat\ncaaat\n", - }); - await compareOutputs(env, testDir, 'grep "ca*t" test.txt'); - }); - - it("should match plus quantifier with -E", async () => { - const env = await setupFiles(testDir, { - "test.txt": "ct\ncat\ncaat\ncaaat\n", - }); - await compareOutputs(env, testDir, 'grep -E "ca+t" test.txt'); - }); - }); - - describe("-F (fixed strings)", () => { - it("should match literal string with special chars", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello.*world\ntest pattern\nhello.world\n", - }); - await compareOutputs(env, testDir, 'grep -F ".*" test.txt'); - }); - - it("should match literal dot", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a.b\naXb\na..b\n", - }); - await compareOutputs(env, testDir, 'grep -F "." test.txt'); - }); - - it("should match literal brackets", async () => { - const env = await setupFiles(testDir, { - "test.txt": "[test]\ntest\n[another]\n", - }); - await compareOutputs(env, testDir, 'grep -F "[test]" test.txt'); - }); - - it("should combine -F with -i", async () => { - const env = await setupFiles(testDir, { - "test.txt": "Hello.World\nhello.world\nHELLO.WORLD\n", - }); - await compareOutputs(env, testDir, 'grep -Fi "hello.world" test.txt'); - }); - }); - - describe("-q (quiet mode)", () => { - it("should suppress output when match found", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\nfoo bar\n", - }); - await compareOutputs(env, testDir, "grep -q hello test.txt"); - }); - - it("should return exit code 0 on match", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\n", - }); - await compareOutputs( - env, - testDir, - "grep -q hello test.txt && echo found", - ); - }); - - it("should return exit code 1 on no match", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\n", - }); - await compareOutputs( - env, - testDir, - 'grep -q notfound test.txt || echo "not found"', - ); - }); - - it("should work with pipe", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'echo "hello world" | grep -q hello && echo matched', - ); - }); - }); -}); diff --git a/src/comparison-tests/head-tail.comparison.test.ts b/src/comparison-tests/head-tail.comparison.test.ts deleted file mode 100644 index 9508d21e..00000000 --- a/src/comparison-tests/head-tail.comparison.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("head command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - const createLinesFile = (count: number) => { - return `${Array.from({ length: count }, (_, i) => `line ${i + 1}`).join("\n")}\n`; - }; - - describe("default behavior", () => { - it("should output first 10 lines by default", async () => { - const env = await setupFiles(testDir, { - "test.txt": createLinesFile(20), - }); - await compareOutputs(env, testDir, "head test.txt"); - }); - - it("should handle file with fewer than 10 lines", async () => { - const env = await setupFiles(testDir, { - "test.txt": createLinesFile(5), - }); - await compareOutputs(env, testDir, "head test.txt"); - }); - }); - - describe("-n option", () => { - it("should output first n lines", async () => { - const env = await setupFiles(testDir, { - "test.txt": createLinesFile(20), - }); - await compareOutputs(env, testDir, "head -n 5 test.txt"); - }); - - it("should handle -n larger than file", async () => { - const env = await setupFiles(testDir, { - "test.txt": createLinesFile(3), - }); - await compareOutputs(env, testDir, "head -n 10 test.txt"); - }); - - it("should handle -n 1", async () => { - const env = await setupFiles(testDir, { - "test.txt": createLinesFile(5), - }); - await compareOutputs(env, testDir, "head -n 1 test.txt"); - }); - - it("should handle -n with no space", async () => { - const env = await setupFiles(testDir, { - "test.txt": createLinesFile(10), - }); - await compareOutputs(env, testDir, "head -n3 test.txt"); - }); - }); - - describe("stdin", () => { - it("should read from stdin", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'echo -e "a\\nb\\nc\\nd\\ne" | head -n 3', - ); - }); - }); - - describe("multiple files", () => { - it("should show headers for multiple files", async () => { - const env = await setupFiles(testDir, { - "a.txt": createLinesFile(3), - "b.txt": createLinesFile(3), - }); - await compareOutputs(env, testDir, "head -n 2 a.txt b.txt"); - }); - }); - - describe("-c option (bytes)", () => { - it("should output first n bytes", async () => { - const env = await setupFiles(testDir, { - "test.txt": "Hello, World!\n", - }); - await compareOutputs(env, testDir, "head -c 5 test.txt"); - }); - - it("should handle -c with multiline content", async () => { - const env = await setupFiles(testDir, { - "test.txt": "line1\nline2\nline3\n", - }); - await compareOutputs(env, testDir, "head -c 10 test.txt"); - }); - - it("should handle -c larger than file", async () => { - const env = await setupFiles(testDir, { - "test.txt": "short\n", - }); - await compareOutputs(env, testDir, "head -c 100 test.txt"); - }); - }); -}); - -describe("tail command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - const createLinesFile = (count: number) => { - return `${Array.from({ length: count }, (_, i) => `line ${i + 1}`).join("\n")}\n`; - }; - - describe("default behavior", () => { - it("should output last 10 lines by default", async () => { - const env = await setupFiles(testDir, { - "test.txt": createLinesFile(20), - }); - await compareOutputs(env, testDir, "tail test.txt"); - }); - - it("should handle file with fewer than 10 lines", async () => { - const env = await setupFiles(testDir, { - "test.txt": createLinesFile(5), - }); - await compareOutputs(env, testDir, "tail test.txt"); - }); - }); - - describe("-n option", () => { - it("should output last n lines", async () => { - const env = await setupFiles(testDir, { - "test.txt": createLinesFile(20), - }); - await compareOutputs(env, testDir, "tail -n 5 test.txt"); - }); - - it("should handle -n larger than file", async () => { - const env = await setupFiles(testDir, { - "test.txt": createLinesFile(3), - }); - await compareOutputs(env, testDir, "tail -n 10 test.txt"); - }); - - it("should handle -n 1", async () => { - const env = await setupFiles(testDir, { - "test.txt": createLinesFile(5), - }); - await compareOutputs(env, testDir, "tail -n 1 test.txt"); - }); - - it("should handle +n (from line n)", async () => { - const env = await setupFiles(testDir, { - "test.txt": createLinesFile(10), - }); - await compareOutputs(env, testDir, "tail -n +3 test.txt"); - }); - }); - - describe("stdin", () => { - it("should read from stdin", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'echo -e "a\\nb\\nc\\nd\\ne" | tail -n 2', - ); - }); - }); - - describe("multiple files", () => { - it("should show headers for multiple files", async () => { - const env = await setupFiles(testDir, { - "a.txt": createLinesFile(3), - "b.txt": createLinesFile(3), - }); - await compareOutputs(env, testDir, "tail -n 2 a.txt b.txt"); - }); - }); - - describe("-c option (bytes)", () => { - it("should output last n bytes", async () => { - const env = await setupFiles(testDir, { - "test.txt": "Hello, World!\n", - }); - await compareOutputs(env, testDir, "tail -c 5 test.txt"); - }); - - it("should handle -c with multiline content", async () => { - const env = await setupFiles(testDir, { - "test.txt": "line1\nline2\nline3\n", - }); - await compareOutputs(env, testDir, "tail -c 10 test.txt"); - }); - - it("should handle -c larger than file", async () => { - const env = await setupFiles(testDir, { - "test.txt": "short\n", - }); - await compareOutputs(env, testDir, "tail -c 100 test.txt"); - }); - }); -}); diff --git a/src/comparison-tests/here-document.comparison.test.ts b/src/comparison-tests/here-document.comparison.test.ts deleted file mode 100644 index 58481200..00000000 --- a/src/comparison-tests/here-document.comparison.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("Here Documents - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should require delimiter at start of line", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - `cat < { - const env = await setupFiles(testDir, {}); - // The delimiter must be at column 0, even inside if - await compareOutputs( - env, - testDir, - `if [[ 1 -eq 1 ]]; then -cat < { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - `cat < { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - `NAME=World; cat < { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - `NAME=World; cat <<'EOF' -Hello, $NAME! -EOF`, - ); - }); -}); diff --git a/src/comparison-tests/jq.comparison.test.ts b/src/comparison-tests/jq.comparison.test.ts deleted file mode 100644 index 779f6f11..00000000 --- a/src/comparison-tests/jq.comparison.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("jq command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("identity filter", () => { - it("should pass through JSON with .", async () => { - const env = await setupFiles(testDir, { - "data.json": '{"a":1,"b":2}', - }); - await compareOutputs(env, testDir, "jq '.' data.json"); - }); - - it("should pretty print arrays", async () => { - const env = await setupFiles(testDir, { - "data.json": "[1,2,3]", - }); - await compareOutputs(env, testDir, "jq '.' data.json"); - }); - }); - - describe("object access", () => { - it("should access object key with .key", async () => { - const env = await setupFiles(testDir, { - "data.json": '{"name":"test","value":42}', - }); - await compareOutputs(env, testDir, "jq '.name' data.json"); - }); - - it("should access nested key with .a.b", async () => { - const env = await setupFiles(testDir, { - "data.json": '{"a":{"b":"nested"}}', - }); - await compareOutputs(env, testDir, "jq '.a.b' data.json"); - }); - - it("should return null for missing key", async () => { - const env = await setupFiles(testDir, { - "data.json": '{"a":1}', - }); - await compareOutputs(env, testDir, "jq '.missing' data.json"); - }); - }); - - describe("array access", () => { - it("should access array element with .[0]", async () => { - const env = await setupFiles(testDir, { - "data.json": '["a","b","c"]', - }); - await compareOutputs(env, testDir, "jq '.[0]' data.json"); - }); - - it("should access last element with .[-1]", async () => { - const env = await setupFiles(testDir, { - "data.json": '["a","b","c"]', - }); - await compareOutputs(env, testDir, "jq '.[-1]' data.json"); - }); - }); - - describe("array iteration", () => { - it("should iterate array with .[]", async () => { - const env = await setupFiles(testDir, { - "data.json": "[1,2,3]", - }); - await compareOutputs(env, testDir, "jq '.[]' data.json"); - }); - - it("should iterate object values with .[]", async () => { - const env = await setupFiles(testDir, { - "data.json": '{"a":1,"b":2}', - }); - await compareOutputs(env, testDir, "jq '.[]' data.json"); - }); - }); - - describe("pipes", () => { - it("should pipe filters with |", async () => { - const env = await setupFiles(testDir, { - "data.json": '{"data":{"value":42}}', - }); - await compareOutputs(env, testDir, "jq '.data | .value' data.json"); - }); - }); - - describe("compact output (-c)", () => { - it("should output compact JSON with -c", async () => { - const env = await setupFiles(testDir, { - "data.json": '{"a":1,"b":2}', - }); - await compareOutputs(env, testDir, "jq -c '.' data.json"); - }); - }); - - describe("raw output (-r)", () => { - it("should output strings without quotes with -r", async () => { - const env = await setupFiles(testDir, { - "data.json": '{"name":"test"}', - }); - await compareOutputs(env, testDir, "jq -r '.name' data.json"); - }); - }); - - describe("null input (-n)", () => { - it("should work with null input", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "jq -n 'null'"); - }); - - it("should generate range with null input", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "jq -n '[range(5)]'"); - }); - }); - - describe("slurp (-s)", () => { - it("should slurp multiple JSON lines into array", async () => { - const env = await setupFiles(testDir, { - "data.json": "1\n2\n3", - }); - await compareOutputs(env, testDir, "jq -s '.' data.json"); - }); - }); - - describe("builtin functions", () => { - it("should get keys", async () => { - const env = await setupFiles(testDir, { - "data.json": '{"b":1,"a":2}', - }); - await compareOutputs(env, testDir, "jq 'keys' data.json"); - }); - - it("should get length of array", async () => { - const env = await setupFiles(testDir, { - "data.json": "[1,2,3,4,5]", - }); - await compareOutputs(env, testDir, "jq 'length' data.json"); - }); - - it("should get type", async () => { - const env = await setupFiles(testDir, { - "data.json": '{"a":1}', - }); - await compareOutputs(env, testDir, "jq 'type' data.json"); - }); - - it("should sort array", async () => { - const env = await setupFiles(testDir, { - "data.json": "[3,1,2]", - }); - await compareOutputs(env, testDir, "jq 'sort' data.json"); - }); - - it("should reverse array", async () => { - const env = await setupFiles(testDir, { - "data.json": "[1,2,3]", - }); - await compareOutputs(env, testDir, "jq 'reverse' data.json"); - }); - - it("should add array", async () => { - const env = await setupFiles(testDir, { - "data.json": "[1,2,3,4]", - }); - await compareOutputs(env, testDir, "jq 'add' data.json"); - }); - }); - - describe("select and map", () => { - it("should filter with select", async () => { - const env = await setupFiles(testDir, { - "data.json": "[1,2,3,4,5]", - }); - await compareOutputs( - env, - testDir, - "jq '[.[] | select(. > 3)]' data.json", - ); - }); - - it("should transform with map", async () => { - const env = await setupFiles(testDir, { - "data.json": "[1,2,3]", - }); - await compareOutputs(env, testDir, "jq 'map(. * 2)' data.json"); - }); - }); - - describe("arithmetic", () => { - it("should add numbers", async () => { - const env = await setupFiles(testDir, { - "data.json": "5", - }); - await compareOutputs(env, testDir, "jq '. + 3' data.json"); - }); - - it("should multiply numbers", async () => { - const env = await setupFiles(testDir, { - "data.json": "6", - }); - await compareOutputs(env, testDir, "jq '. * 7' data.json"); - }); - }); - - describe("conditionals", () => { - it("should evaluate if-then-else", async () => { - const env = await setupFiles(testDir, { - "data.json": "5", - }); - await compareOutputs( - env, - testDir, - 'jq \'if . > 3 then "big" else "small" end\' data.json', - ); - }); - }); - - describe("string functions", () => { - it("should split strings", async () => { - const env = await setupFiles(testDir, { - "data.json": '"a,b,c"', - }); - await compareOutputs(env, testDir, "jq 'split(\",\")' data.json"); - }); - - it("should join arrays", async () => { - const env = await setupFiles(testDir, { - "data.json": '["a","b","c"]', - }); - await compareOutputs(env, testDir, "jq 'join(\"-\")' data.json"); - }); - }); -}); diff --git a/src/comparison-tests/ls.comparison.test.ts b/src/comparison-tests/ls.comparison.test.ts deleted file mode 100644 index 98cc077a..00000000 --- a/src/comparison-tests/ls.comparison.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("ls command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("basic listing", () => { - it("should match directory listing", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "content", - "file2.txt": "content", - "subdir/file3.txt": "content", - }); - await compareOutputs(env, testDir, "ls"); - }); - - it("should match -1 output (one per line)", async () => { - const env = await setupFiles(testDir, { - "aaa.txt": "", - "bbb.txt": "", - "ccc.txt": "", - }); - await compareOutputs(env, testDir, "ls -1"); - }); - - it("should match with specific path", async () => { - const env = await setupFiles(testDir, { - "subdir/file1.txt": "", - "subdir/file2.txt": "", - }); - await compareOutputs(env, testDir, "ls subdir"); - }); - - it("should match empty directory", async () => { - const env = await setupFiles(testDir, { - "subdir/.gitkeep": "", - }); - // Note: empty dirs won't exist in virtual fs without files - await compareOutputs(env, testDir, "ls subdir"); - }); - }); - - describe("flags", () => { - it("should match -a (show hidden)", async () => { - const env = await setupFiles(testDir, { - ".hidden": "", - "visible.txt": "", - }); - await compareOutputs(env, testDir, "ls -a"); - }); - - it("should match -A (show hidden except . and ..)", async () => { - const env = await setupFiles(testDir, { - ".hidden": "", - "visible.txt": "", - }); - await compareOutputs(env, testDir, "ls -A"); - }); - - // Uses recorded fixture with Linux behavior (includes ".:" header) - it("should match -R (recursive)", async () => { - const env = await setupFiles(testDir, { - "file.txt": "", - "dir/file1.txt": "", - "dir/sub/file2.txt": "", - }); - await compareOutputs(env, testDir, "ls -R"); - }); - - it("should match -r (reverse)", async () => { - const env = await setupFiles(testDir, { - "aaa.txt": "", - "bbb.txt": "", - "ccc.txt": "", - }); - await compareOutputs(env, testDir, "ls -1r"); - }); - }); - - describe("sorting", () => { - it("should sort alphabetically by default", async () => { - const env = await setupFiles(testDir, { - "zebra.txt": "", - "apple.txt": "", - "banana.txt": "", - }); - await compareOutputs(env, testDir, "ls -1"); - }); - - it("should sort case-insensitively", async () => { - const env = await setupFiles(testDir, { - "Apple.txt": "", - "banana.txt": "", - "cherry.txt": "", - }); - await compareOutputs(env, testDir, "ls -1"); - }); - }); - - describe("multiple paths", () => { - it("should list multiple directories", async () => { - const env = await setupFiles(testDir, { - "dir1/a.txt": "", - "dir2/b.txt": "", - }); - await compareOutputs(env, testDir, "ls dir1 dir2"); - }); - - it("should handle files and directories mixed", async () => { - const env = await setupFiles(testDir, { - "file.txt": "", - "dir/nested.txt": "", - }); - await compareOutputs(env, testDir, "ls file.txt dir"); - }); - }); -}); diff --git a/src/comparison-tests/parse-errors.comparison.test.ts b/src/comparison-tests/parse-errors.comparison.test.ts deleted file mode 100644 index 068c1963..00000000 --- a/src/comparison-tests/parse-errors.comparison.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { execSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -/** - * Comparison tests for parse errors - * These tests verify that our virtual shell handles parse errors - * similarly to real bash. - */ -describe("Parse Errors - Comparison Tests", () => { - let tempDir: string; - - const runRealBash = ( - command: string, - ): { stdout: string; stderr: string; exitCode: number } => { - try { - const stdout = execSync(command, { - cwd: tempDir, - shell: "/bin/bash", - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }); - return { stdout, stderr: "", exitCode: 0 }; - } catch (error: unknown) { - const err = error as { - stdout?: string; - stderr?: string; - status?: number; - }; - return { - stdout: err.stdout || "", - stderr: err.stderr || "", - exitCode: err.status || 1, - }; - } - }; - - const runVirtualBash = async ( - command: string, - files: Record = {}, - ): Promise<{ stdout: string; stderr: string; exitCode: number }> => { - const env = new Bash({ files, cwd: "/" }); - return env.exec(command); - }; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "bash-parse-errors-")); - }); - - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); - }); - - describe("unknown command errors", () => { - it("should return exit code 127 for unknown command", async () => { - const realResult = runRealBash("nonexistentcommand123"); - const virtualResult = await runVirtualBash("nonexistentcommand123"); - - expect(virtualResult.exitCode).toBe(realResult.exitCode); - expect(virtualResult.exitCode).toBe(127); - }); - - it("should include command name in error message", async () => { - const realResult = runRealBash("myunknowncommand"); - const virtualResult = await runVirtualBash("myunknowncommand"); - - expect(virtualResult.stderr).toContain("myunknowncommand"); - expect(realResult.stderr).toContain("myunknowncommand"); - }); - }); - - describe("file not found errors", () => { - it("should return exit code 1 for cat on missing file", async () => { - const realResult = runRealBash("cat /nonexistent_file_12345.txt"); - const virtualResult = await runVirtualBash( - "cat /nonexistent_file_12345.txt", - ); - - expect(virtualResult.exitCode).toBe(realResult.exitCode); - expect(virtualResult.exitCode).toBe(1); - }); - - it("should return exit code 1 for grep on missing file", async () => { - const realResult = runRealBash( - "grep pattern /nonexistent_file_12345.txt", - ); - const virtualResult = await runVirtualBash( - "grep pattern /nonexistent_file_12345.txt", - ); - - expect(virtualResult.exitCode).toBe(realResult.exitCode); - }); - }); - - describe("for loop syntax", () => { - it("should execute valid for loop", async () => { - const realResult = runRealBash("for i in a b c; do echo $i; done"); - const virtualResult = await runVirtualBash( - "for i in a b c; do echo $i; done", - ); - - expect(virtualResult.stdout).toBe(realResult.stdout); - expect(virtualResult.exitCode).toBe(realResult.exitCode); - }); - - it("should handle empty list in for loop", async () => { - const realResult = runRealBash("for i in; do echo $i; done"); - const virtualResult = await runVirtualBash("for i in; do echo $i; done"); - - expect(virtualResult.stdout).toBe(realResult.stdout); - expect(virtualResult.exitCode).toBe(realResult.exitCode); - }); - }); - - describe("while loop syntax", () => { - it("should execute while false (zero iterations)", async () => { - const realResult = runRealBash("while false; do echo loop; done"); - const virtualResult = await runVirtualBash( - "while false; do echo loop; done", - ); - - expect(virtualResult.stdout).toBe(realResult.stdout); - expect(virtualResult.exitCode).toBe(realResult.exitCode); - }); - }); - - describe("until loop syntax", () => { - it("should execute until true (zero iterations)", async () => { - const realResult = runRealBash("until true; do echo loop; done"); - const virtualResult = await runVirtualBash( - "until true; do echo loop; done", - ); - - expect(virtualResult.stdout).toBe(realResult.stdout); - expect(virtualResult.exitCode).toBe(realResult.exitCode); - }); - }); - - describe("if statement syntax", () => { - it("should execute if true branch", async () => { - const realResult = runRealBash("if true; then echo yes; fi"); - const virtualResult = await runVirtualBash("if true; then echo yes; fi"); - - expect(virtualResult.stdout).toBe(realResult.stdout); - expect(virtualResult.exitCode).toBe(realResult.exitCode); - }); - - it("should execute else branch when condition false", async () => { - const realResult = runRealBash( - "if false; then echo yes; else echo no; fi", - ); - const virtualResult = await runVirtualBash( - "if false; then echo yes; else echo no; fi", - ); - - expect(virtualResult.stdout).toBe(realResult.stdout); - expect(virtualResult.exitCode).toBe(realResult.exitCode); - }); - - it("should execute elif branch", async () => { - const realResult = runRealBash( - "if false; then echo 1; elif true; then echo 2; else echo 3; fi", - ); - const virtualResult = await runVirtualBash( - "if false; then echo 1; elif true; then echo 2; else echo 3; fi", - ); - - expect(virtualResult.stdout).toBe(realResult.stdout); - expect(virtualResult.exitCode).toBe(realResult.exitCode); - }); - }); - - describe("exit codes", () => { - it("should return specified exit code", async () => { - const realResult = runRealBash("exit 42"); - const virtualResult = await runVirtualBash("exit 42"); - - expect(virtualResult.exitCode).toBe(realResult.exitCode); - expect(virtualResult.exitCode).toBe(42); - }); - - it("should return 0 for successful command", async () => { - const realResult = runRealBash("true"); - const virtualResult = await runVirtualBash("true"); - - expect(virtualResult.exitCode).toBe(realResult.exitCode); - expect(virtualResult.exitCode).toBe(0); - }); - - it("should return 1 for false command", async () => { - const realResult = runRealBash("false"); - const virtualResult = await runVirtualBash("false"); - - expect(virtualResult.exitCode).toBe(realResult.exitCode); - expect(virtualResult.exitCode).toBe(1); - }); - }); - - describe("operator behavior", () => { - it("should short-circuit && on failure", async () => { - const realResult = runRealBash("false && echo never"); - const virtualResult = await runVirtualBash("false && echo never"); - - expect(virtualResult.stdout).toBe(realResult.stdout); - expect(virtualResult.exitCode).toBe(realResult.exitCode); - }); - - it("should short-circuit || on success", async () => { - const realResult = runRealBash("true || echo never"); - const virtualResult = await runVirtualBash("true || echo never"); - - expect(virtualResult.stdout).toBe(realResult.stdout); - expect(virtualResult.exitCode).toBe(realResult.exitCode); - }); - - it("should execute || fallback on failure", async () => { - const realResult = runRealBash("false || echo fallback"); - const virtualResult = await runVirtualBash("false || echo fallback"); - - expect(virtualResult.stdout).toBe(realResult.stdout); - expect(virtualResult.exitCode).toBe(realResult.exitCode); - }); - - it("should execute both with semicolon regardless of exit code", async () => { - const realResult = runRealBash("false; echo after"); - const virtualResult = await runVirtualBash("false; echo after"); - - expect(virtualResult.stdout).toBe(realResult.stdout); - }); - }); - - describe("quoting behavior", () => { - it("should preserve spaces in double quotes", async () => { - const realResult = runRealBash('echo "hello world"'); - const virtualResult = await runVirtualBash('echo "hello world"'); - - expect(virtualResult.stdout).toBe(realResult.stdout); - }); - - it("should preserve spaces in single quotes", async () => { - const realResult = runRealBash("echo 'hello world'"); - const virtualResult = await runVirtualBash("echo 'hello world'"); - - expect(virtualResult.stdout).toBe(realResult.stdout); - }); - - it("should not expand variables in single quotes", async () => { - const realResult = runRealBash("export X=value; echo '$X'"); - const virtualResult = await runVirtualBash("export X=value; echo '$X'"); - - expect(virtualResult.stdout).toBe(realResult.stdout); - expect(virtualResult.stdout).toBe("$X\n"); - }); - }); - - describe("empty and whitespace commands", () => { - it("should handle empty command", async () => { - // Note: execSync with empty command throws error, but bash -c '' returns 0 - // Our virtual shell matches real bash behavior (exit code 0) - const virtualResult = await runVirtualBash(""); - - expect(virtualResult.exitCode).toBe(0); - expect(virtualResult.stdout).toBe(""); - }); - - it("should handle whitespace-only command", async () => { - const realResult = runRealBash(" "); - const virtualResult = await runVirtualBash(" "); - - expect(virtualResult.exitCode).toBe(realResult.exitCode); - }); - - it("should handle multiple semicolons as syntax error", async () => { - // In bash, ;; is a case statement delimiter, so ;;; is a syntax error - const realResult = runRealBash("echo a;;;echo b"); - const virtualResult = await runVirtualBash("echo a;;;echo b"); - - // Both real bash and our shell should return exit code 2 for syntax error - expect(realResult.exitCode).toBe(2); - expect(virtualResult.exitCode).toBe(2); - }); - }); -}); diff --git a/src/comparison-tests/paste.comparison.test.ts b/src/comparison-tests/paste.comparison.test.ts deleted file mode 100644 index 5fc7e7be..00000000 --- a/src/comparison-tests/paste.comparison.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("paste command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("basic functionality", () => { - it("should paste two files with default tab delimiter", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "a\nb\nc\n", - "file2.txt": "1\n2\n3\n", - }); - await compareOutputs(env, testDir, "paste file1.txt file2.txt"); - }); - - it("should paste three files", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "a\nb\nc\n", - "file2.txt": "1\n2\n3\n", - "file3.txt": "x\ny\nz\n", - }); - await compareOutputs(env, testDir, "paste file1.txt file2.txt file3.txt"); - }); - - it("should handle single file", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "a\nb\nc\n", - }); - await compareOutputs(env, testDir, "paste file1.txt"); - }); - - it("should handle files with uneven line counts", async () => { - const env = await setupFiles(testDir, { - "short.txt": "a\nb\n", - "long.txt": "1\n2\n3\n4\n", - }); - await compareOutputs(env, testDir, "paste short.txt long.txt"); - }); - }); - - describe("-d (delimiter)", () => { - it("should use comma as delimiter", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "a\nb\nc\n", - "file2.txt": "1\n2\n3\n", - }); - await compareOutputs(env, testDir, "paste -d, file1.txt file2.txt"); - }); - - it("should use colon as delimiter", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "a\nb\nc\n", - "file2.txt": "1\n2\n3\n", - }); - await compareOutputs(env, testDir, "paste -d: file1.txt file2.txt"); - }); - - it("should use space as delimiter", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "a\nb\nc\n", - "file2.txt": "1\n2\n3\n", - }); - await compareOutputs(env, testDir, 'paste -d" " file1.txt file2.txt'); - }); - - it("should cycle through multiple delimiters", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "a\nb\nc\n", - "file2.txt": "1\n2\n3\n", - "file3.txt": "x\ny\nz\n", - }); - await compareOutputs( - env, - testDir, - "paste -d,: file1.txt file2.txt file3.txt", - ); - }); - }); - - describe("-s (serial)", () => { - it("should paste lines horizontally in serial mode", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "a\nb\nc\n", - }); - await compareOutputs(env, testDir, "paste -s file1.txt"); - }); - - it("should paste multiple files serially", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "a\nb\nc\n", - "file2.txt": "1\n2\n3\n", - }); - await compareOutputs(env, testDir, "paste -s file1.txt file2.txt"); - }); - - it("should use custom delimiter in serial mode", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "a\nb\nc\n", - }); - await compareOutputs(env, testDir, "paste -s -d, file1.txt"); - }); - - it("should handle combined -sd option", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "a\nb\nc\n", - }); - await compareOutputs(env, testDir, "paste -sd, file1.txt"); - }); - }); - - describe("stdin", () => { - it("should read from stdin with explicit -", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, 'echo -e "a\\nb\\nc" | paste -'); - }); - - it("should paste stdin with file", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "a\nb\nc\n", - }); - await compareOutputs( - env, - testDir, - 'echo -e "1\\n2\\n3" | paste - file1.txt', - ); - }); - - it("should handle - - to paste pairs of lines", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, 'echo -e "a\\nb\\nc\\nd" | paste - -'); - }); - - it("should handle - - - to paste triplets of lines", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'echo -e "a\\nb\\nc\\nd\\ne\\nf" | paste - - -', - ); - }); - }); - - describe("edge cases", () => { - it("should handle empty file", async () => { - const env = await setupFiles(testDir, { - "empty.txt": "", - "file1.txt": "a\nb\nc\n", - }); - await compareOutputs(env, testDir, "paste empty.txt file1.txt"); - }); - - it("should handle file with single line", async () => { - const env = await setupFiles(testDir, { - "single.txt": "hello\n", - "file1.txt": "a\nb\nc\n", - }); - await compareOutputs(env, testDir, "paste single.txt file1.txt"); - }); - }); -}); diff --git a/src/comparison-tests/pipes-redirections.comparison.test.ts b/src/comparison-tests/pipes-redirections.comparison.test.ts deleted file mode 100644 index 050cc9ad..00000000 --- a/src/comparison-tests/pipes-redirections.comparison.test.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - fs, - path, - runRealBash, - setupFiles, -} from "./fixture-runner.js"; - -describe("Pipes - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("simple pipes", () => { - it("should pipe echo to cat", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, 'echo "hello" | cat'); - }); - - it("should pipe cat to sort", async () => { - const env = await setupFiles(testDir, { - "test.txt": "cherry\napple\nbanana\n", - }); - await compareOutputs(env, testDir, "cat test.txt | sort"); - }); - - it("should pipe cat to grep", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\nfoo bar\nhello again\n", - }); - await compareOutputs(env, testDir, "cat test.txt | grep hello"); - }); - - it("should pipe grep to wc", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello\nworld\nhello\n", - }); - // normalizeWhitespace needed because BSD/GNU wc have different column widths - await compareOutputs(env, testDir, "grep hello test.txt | wc -l", { - normalizeWhitespace: true, - }); - }); - }); - - describe("multiple pipes", () => { - it("should chain three commands", async () => { - const env = await setupFiles(testDir, { - "test.txt": "cherry\napple\nbanana\napple\n", - }); - await compareOutputs(env, testDir, "cat test.txt | sort | uniq"); - }); - - it("should chain four commands", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\nfoo bar\nhello again\nbaz qux\n", - }); - // normalizeWhitespace needed because BSD/GNU wc have different column widths - await compareOutputs( - env, - testDir, - "cat test.txt | grep hello | sort | wc -l", - { normalizeWhitespace: true }, - ); - }); - - it("should pipe through tr and sort", async () => { - const env = await setupFiles(testDir, { - "test.txt": "CHERRY\napple\nBANANA\n", - }); - await compareOutputs( - env, - testDir, - "cat test.txt | tr 'A-Z' 'a-z' | sort", - ); - }); - - it("should pipe through cut and sort", async () => { - const env = await setupFiles(testDir, { - "test.txt": "b:2\na:3\nc:1\n", - }); - await compareOutputs( - env, - testDir, - "cat test.txt | cut -d: -f2 | sort -n", - ); - }); - }); - - describe("pipes with head and tail", () => { - it("should pipe to head", async () => { - const env = await setupFiles(testDir, { - "test.txt": "line1\nline2\nline3\nline4\nline5\n", - }); - await compareOutputs(env, testDir, "cat test.txt | head -n 3"); - }); - - it("should pipe to tail", async () => { - const env = await setupFiles(testDir, { - "test.txt": "line1\nline2\nline3\nline4\nline5\n", - }); - await compareOutputs(env, testDir, "cat test.txt | tail -n 2"); - }); - - it("should pipe head to tail", async () => { - const env = await setupFiles(testDir, { - "test.txt": "line1\nline2\nline3\nline4\nline5\n", - }); - await compareOutputs( - env, - testDir, - "cat test.txt | head -n 4 | tail -n 2", - ); - }); - }); -}); - -describe("Output Redirections - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("stdout redirection (>)", () => { - it("should redirect echo to file", async () => { - const env = await setupFiles(testDir, {}); - - await env.exec('echo "hello world" > output.txt'); - await runRealBash('echo "hello world" > output.txt', testDir); - - const bashEnvContent = await env.readFile( - path.join(testDir, "output.txt"), - ); - const realContent = await fs.readFile( - path.join(testDir, "output.txt"), - "utf-8", - ); - expect(bashEnvContent).toBe(realContent); - }); - - it("should overwrite existing file", async () => { - const env = await setupFiles(testDir, { - "output.txt": "old content\n", - }); - - await env.exec('echo "new content" > output.txt'); - await runRealBash('echo "new content" > output.txt', testDir); - - const bashEnvContent = await env.readFile( - path.join(testDir, "output.txt"), - ); - const realContent = await fs.readFile( - path.join(testDir, "output.txt"), - "utf-8", - ); - expect(bashEnvContent).toBe(realContent); - }); - - it("should redirect grep output", async () => { - const env = await setupFiles(testDir, { - "input.txt": "hello\nworld\nhello again\n", - }); - - await env.exec("grep hello input.txt > output.txt"); - await runRealBash("grep hello input.txt > output.txt", testDir); - - const bashEnvContent = await env.readFile( - path.join(testDir, "output.txt"), - ); - const realContent = await fs.readFile( - path.join(testDir, "output.txt"), - "utf-8", - ); - expect(bashEnvContent).toBe(realContent); - }); - }); - - describe("append redirection (>>)", () => { - it("should append to file", async () => { - const env = await setupFiles(testDir, { - "output.txt": "line 1\n", - }); - - await env.exec('echo "line 2" >> output.txt'); - await runRealBash('echo "line 2" >> output.txt', testDir); - - const bashEnvContent = await env.readFile( - path.join(testDir, "output.txt"), - ); - const realContent = await fs.readFile( - path.join(testDir, "output.txt"), - "utf-8", - ); - expect(bashEnvContent).toBe(realContent); - }); - - it("should create file if not exists", async () => { - const env = await setupFiles(testDir, {}); - - await env.exec('echo "new line" >> output.txt'); - await runRealBash('echo "new line" >> output.txt', testDir); - - const bashEnvContent = await env.readFile( - path.join(testDir, "output.txt"), - ); - const realContent = await fs.readFile( - path.join(testDir, "output.txt"), - "utf-8", - ); - expect(bashEnvContent).toBe(realContent); - }); - - it("should append multiple times", async () => { - const env = await setupFiles(testDir, {}); - - await env.exec('echo "line 1" >> output.txt'); - await env.exec('echo "line 2" >> output.txt'); - await runRealBash( - 'echo "line 1" >> output.txt && echo "line 2" >> output.txt', - testDir, - ); - - const bashEnvContent = await env.readFile( - path.join(testDir, "output.txt"), - ); - const realContent = await fs.readFile( - path.join(testDir, "output.txt"), - "utf-8", - ); - expect(bashEnvContent).toBe(realContent); - }); - }); - - describe("pipes with redirections", () => { - it("should pipe and redirect", async () => { - const env = await setupFiles(testDir, { - "input.txt": "cherry\napple\nbanana\n", - }); - - await env.exec("cat input.txt | sort > output.txt"); - await runRealBash("cat input.txt | sort > output.txt", testDir); - - const bashEnvContent = await env.readFile( - path.join(testDir, "output.txt"), - ); - const realContent = await fs.readFile( - path.join(testDir, "output.txt"), - "utf-8", - ); - expect(bashEnvContent).toBe(realContent); - }); - }); -}); - -describe("Command Chaining - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("&& (AND) chaining", () => { - it("should execute second command if first succeeds", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello\n", - }); - await compareOutputs( - env, - testDir, - "echo start && cat test.txt && echo end", - ); - }); - - it("should stop on failure", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'cat nonexistent.txt 2>/dev/null && echo "never shown"', - ); - }); - }); - - describe("|| (OR) chaining", () => { - it("should execute second command if first fails", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'cat nonexistent.txt 2>/dev/null || echo "file not found"', - ); - }); - - it("should skip second command if first succeeds", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'echo "success" || echo "never shown"', - ); - }); - }); - - describe("; (sequential) chaining", () => { - it("should execute all commands regardless of exit code", async () => { - const env = await setupFiles(testDir, { - "test.txt": "content\n", - }); - await compareOutputs(env, testDir, "echo first; cat test.txt; echo last"); - }); - - it("should continue after failure", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'cat nonexistent.txt 2>/dev/null; echo "still runs"', - ); - }); - }); - - describe("combined operators", () => { - it("should handle && and ||", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'cat nonexistent.txt 2>/dev/null && echo "yes" || echo "no"', - ); - }); - - it("should handle ; and &&", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, 'echo "a"; echo "b" && echo "c"'); - }); - }); -}); - -describe("Input Redirection (<) - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("basic stdin redirection", () => { - it("should redirect file to cat stdin", async () => { - const env = await setupFiles(testDir, { - "input.txt": "hello world\n", - }); - await compareOutputs(env, testDir, "cat < input.txt"); - }); - - it("should redirect file to grep stdin", async () => { - const env = await setupFiles(testDir, { - "input.txt": "apple\nbanana\napricot\ncherry\n", - }); - await compareOutputs(env, testDir, "grep ^a < input.txt"); - }); - - it("should redirect file to sort stdin", async () => { - const env = await setupFiles(testDir, { - "input.txt": "cherry\napple\nbanana\n", - }); - await compareOutputs(env, testDir, "sort < input.txt"); - }); - - it("should redirect file to wc stdin", async () => { - const env = await setupFiles(testDir, { - "input.txt": "line1\nline2\nline3\n", - }); - // normalizeWhitespace needed because BSD/GNU wc have different column widths - await compareOutputs(env, testDir, "wc -l < input.txt", { - normalizeWhitespace: true, - }); - }); - }); - - describe("stdin redirection with output redirection", () => { - it("should combine input and output redirection", async () => { - const env = await setupFiles(testDir, { - "input.txt": "cherry\napple\nbanana\n", - }); - - await env.exec("sort < input.txt > output.txt"); - await runRealBash("sort < input.txt > output.txt", testDir); - - const bashEnvContent = await env.readFile( - path.join(testDir, "output.txt"), - ); - const realContent = await fs.readFile( - path.join(testDir, "output.txt"), - "utf-8", - ); - expect(bashEnvContent).toBe(realContent); - }); - }); - - describe("stdin redirection error handling", () => { - it("should error on missing input file", async () => { - const env = await setupFiles(testDir, {}); - const result = await env.exec("cat < nonexistent.txt"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("No such file"); - }); - }); -}); diff --git a/src/comparison-tests/sed.comparison.test.ts b/src/comparison-tests/sed.comparison.test.ts deleted file mode 100644 index 2d2b81b1..00000000 --- a/src/comparison-tests/sed.comparison.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("sed command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("substitution (s command)", () => { - it("should substitute first occurrence", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\nhello again\n", - }); - await compareOutputs(env, testDir, "sed 's/hello/hi/' test.txt"); - }); - - it("should substitute all occurrences with g flag", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello hello hello\n", - }); - await compareOutputs(env, testDir, "sed 's/hello/hi/g' test.txt"); - }); - - it("should handle case insensitive with i flag", async () => { - const env = await setupFiles(testDir, { - "test.txt": "Hello HELLO hello\n", - }); - await compareOutputs(env, testDir, "sed 's/hello/hi/gi' test.txt"); - }); - - it("should substitute with empty string", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\n", - }); - await compareOutputs(env, testDir, "sed 's/hello//' test.txt"); - }); - - it("should handle special characters in pattern", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello.world\n", - }); - await compareOutputs(env, testDir, "sed 's/\\./-/g' test.txt"); - }); - }); - - describe("different delimiters", () => { - it("should use / as delimiter", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\n", - }); - await compareOutputs(env, testDir, "sed 's/hello/hi/' test.txt"); - }); - - it("should use # as delimiter", async () => { - const env = await setupFiles(testDir, { - "test.txt": "path/to/file\n", - }); - await compareOutputs(env, testDir, "sed 's#path/to#new/path#' test.txt"); - }); - - it("should use | as delimiter", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a/b/c\n", - }); - await compareOutputs(env, testDir, "sed 's|/|-|g' test.txt"); - }); - }); - - describe("address ranges", () => { - it("should substitute only on line 1", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello\nhello\nhello\n", - }); - await compareOutputs(env, testDir, "sed '1s/hello/hi/' test.txt"); - }); - - it("should substitute on line 2", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello\nhello\nhello\n", - }); - await compareOutputs(env, testDir, "sed '2s/hello/hi/' test.txt"); - }); - - it("should substitute on last line", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello\nhello\nhello\n", - }); - await compareOutputs(env, testDir, "sed '$ s/hello/hi/' test.txt"); - }); - - it("should substitute on range of lines", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello\nhello\nhello\nhello\n", - }); - await compareOutputs(env, testDir, "sed '2,3s/hello/hi/' test.txt"); - }); - }); - - describe("delete command (d)", () => { - it("should delete matching lines", async () => { - const env = await setupFiles(testDir, { - "test.txt": "keep\ndelete\nkeep\n", - }); - await compareOutputs(env, testDir, "sed '/delete/d' test.txt"); - }); - - it("should delete first line", async () => { - const env = await setupFiles(testDir, { - "test.txt": "line1\nline2\nline3\n", - }); - await compareOutputs(env, testDir, "sed '1d' test.txt"); - }); - - it("should delete last line", async () => { - const env = await setupFiles(testDir, { - "test.txt": "line1\nline2\nline3\n", - }); - await compareOutputs(env, testDir, "sed '$ d' test.txt"); - }); - - it("should delete range of lines", async () => { - const env = await setupFiles(testDir, { - "test.txt": "line1\nline2\nline3\nline4\n", - }); - await compareOutputs(env, testDir, "sed '2,3d' test.txt"); - }); - }); - - describe("stdin", () => { - it("should read from stdin", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "echo 'hello world' | sed 's/hello/hi/'", - ); - }); - - it("should handle multiline stdin", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "echo -e 'hello\\nworld' | sed 's/o/0/g'", - ); - }); - }); - - describe("multiple expressions", () => { - it("should apply multiple -e expressions", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\n", - }); - await compareOutputs( - env, - testDir, - "sed -e 's/hello/hi/' -e 's/world/there/' test.txt", - ); - }); - }); - - describe("edge cases", () => { - it("should handle empty file", async () => { - const env = await setupFiles(testDir, { - "test.txt": "", - }); - await compareOutputs(env, testDir, "sed 's/a/b/' test.txt"); - }); - - it("should handle no matches", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\n", - }); - await compareOutputs(env, testDir, "sed 's/xyz/abc/' test.txt"); - }); - - it("should handle & in replacement", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello\n", - }); - await compareOutputs(env, testDir, "sed 's/hello/[&]/' test.txt"); - }); - }); -}); diff --git a/src/comparison-tests/sort.comparison.test.ts b/src/comparison-tests/sort.comparison.test.ts deleted file mode 100644 index babc0186..00000000 --- a/src/comparison-tests/sort.comparison.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("sort command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("default sorting", () => { - it("should sort alphabetically", async () => { - const env = await setupFiles(testDir, { - "test.txt": "banana\napple\ncherry\n", - }); - await compareOutputs(env, testDir, "sort test.txt"); - }); - - it("should sort with mixed case", async () => { - // Skip: macOS and Linux have different default locale sorting for mixed case - // macOS: case-sensitive ASCII order (A-Z before a-z) - // Linux: locale-aware order (case-insensitive by default) - // BashEnv uses JavaScript's sort which is ASCII-order like macOS - const env = await setupFiles(testDir, { - "test.txt": "banana\napple\ncherry\n", - }); - await compareOutputs(env, testDir, "sort test.txt"); - }); - - it("should handle empty lines", async () => { - const env = await setupFiles(testDir, { - "test.txt": "b\n\na\n\nc\n", - }); - await compareOutputs(env, testDir, "sort test.txt"); - }); - }); - - describe("-r flag (reverse)", () => { - it("should sort in reverse", async () => { - const env = await setupFiles(testDir, { - "test.txt": "banana\napple\ncherry\n", - }); - await compareOutputs(env, testDir, "sort -r test.txt"); - }); - }); - - describe("-n flag (numeric)", () => { - it("should sort numerically", async () => { - const env = await setupFiles(testDir, { - "test.txt": "10\n2\n1\n20\n5\n", - }); - await compareOutputs(env, testDir, "sort -n test.txt"); - }); - - it("should sort negative numbers", async () => { - const env = await setupFiles(testDir, { - "test.txt": "10\n-5\n0\n-10\n5\n", - }); - await compareOutputs(env, testDir, "sort -n test.txt"); - }); - - it("should handle mixed numbers and text", async () => { - const env = await setupFiles(testDir, { - "test.txt": "10 apples\n2 oranges\n5 bananas\n", - }); - await compareOutputs(env, testDir, "sort -n test.txt"); - }); - }); - - describe("-u flag (unique)", () => { - it("should remove duplicates", async () => { - const env = await setupFiles(testDir, { - "test.txt": "apple\nbanana\napple\ncherry\nbanana\n", - }); - await compareOutputs(env, testDir, "sort -u test.txt"); - }); - - it("should combine -n and -u", async () => { - const env = await setupFiles(testDir, { - "test.txt": "5\n3\n5\n1\n3\n", - }); - await compareOutputs(env, testDir, "sort -nu test.txt"); - }); - }); - - describe("-k flag (key field)", () => { - it("should sort by second field", async () => { - const env = await setupFiles(testDir, { - "test.txt": "x 3\ny 1\nz 2\n", - }); - await compareOutputs(env, testDir, "sort -k 2 test.txt"); - }); - - it("should sort numerically by key", async () => { - const env = await setupFiles(testDir, { - "test.txt": "x 10\ny 2\nz 5\n", - }); - await compareOutputs(env, testDir, "sort -k 2 -n test.txt"); - }); - }); - - describe("-t flag (delimiter)", () => { - it("should use custom delimiter", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a:3\nb:1\nc:2\n", - }); - await compareOutputs(env, testDir, "sort -t: -k2 test.txt"); - }); - - it("should sort numerically with delimiter", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a:10\nb:2\nc:5\n", - }); - await compareOutputs(env, testDir, "sort -t: -k2 -n test.txt"); - }); - - it("should reverse sort with delimiter", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a:1\nb:3\nc:2\n", - }); - await compareOutputs(env, testDir, "sort -t: -k2 -rn test.txt"); - }); - }); - - describe("stdin", () => { - it("should sort stdin", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, 'echo -e "c\\na\\nb" | sort'); - }); - }); - - describe("combined flags", () => { - it("should combine -n and -r", async () => { - const env = await setupFiles(testDir, { - "test.txt": "10\n2\n1\n20\n", - }); - await compareOutputs(env, testDir, "sort -nr test.txt"); - }); - - it("should combine -r and -u", async () => { - const env = await setupFiles(testDir, { - "test.txt": "apple\nbanana\napple\ncherry\n", - }); - await compareOutputs(env, testDir, "sort -ru test.txt"); - }); - }); - - describe("-h flag (human numeric)", () => { - it("should sort human readable sizes", async () => { - const env = await setupFiles(testDir, { - "test.txt": "1K\n2M\n500\n1G\n100K\n", - }); - await compareOutputs(env, testDir, "sort -h test.txt"); - }); - - it("should sort human sizes in reverse", async () => { - const env = await setupFiles(testDir, { - "test.txt": "1K\n1M\n1G\n", - }); - await compareOutputs(env, testDir, "sort -hr test.txt"); - }); - }); - - describe("-V flag (version)", () => { - it("should sort version numbers", async () => { - const env = await setupFiles(testDir, { - "test.txt": "file1.10\nfile1.2\nfile1.1\n", - }); - await compareOutputs(env, testDir, "sort -V test.txt"); - }); - - it("should sort semver-like versions", async () => { - const env = await setupFiles(testDir, { - "test.txt": "1.0.10\n1.0.2\n1.0.0\n", - }); - await compareOutputs(env, testDir, "sort -V test.txt"); - }); - }); - - describe("-c flag (check)", () => { - it("should return 0 for sorted input", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a\nb\nc\n", - }); - await compareOutputs(env, testDir, "sort -c test.txt; echo $?"); - }); - - it("should return 1 for unsorted input", async () => { - const env = await setupFiles(testDir, { - "test.txt": "b\na\nc\n", - }); - await compareOutputs(env, testDir, "sort -c test.txt 2>&1; echo $?"); - }); - }); - - describe("-b flag (ignore leading blanks)", () => { - it("should ignore leading blanks", async () => { - const env = await setupFiles(testDir, { - "test.txt": " b\na\n c\n", - }); - await compareOutputs(env, testDir, "sort -b test.txt"); - }); - }); -}); diff --git a/src/comparison-tests/strings-split.comparison.test.ts b/src/comparison-tests/strings-split.comparison.test.ts deleted file mode 100644 index 86697fb7..00000000 --- a/src/comparison-tests/strings-split.comparison.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("strings command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should extract strings from text", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "echo 'hello world' | strings"); - }); - - it("should filter strings shorter than minimum length", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf 'ab\\x00cd\\x00efgh' | strings"); - }); - - it("should handle multiple strings", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "printf 'hello\\x00\\x00world\\x00\\x00test' | strings", - ); - }); - - it("should change minimum length with -n", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "printf 'ab\\x00cde\\x00fghi' | strings -n 3", - ); - }); - - it("should handle string at end without null terminator", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf 'hello' | strings"); - }); - - it("should read from file", async () => { - const env = await setupFiles(testDir, { - "test.bin": "hello\x00\x00\x00world", - }); - await compareOutputs(env, testDir, "strings test.bin"); - }); -}); - -describe("split command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should split file by lines and verify first chunk", async () => { - const env = await setupFiles(testDir, { - "test.txt": "1\n2\n3\n4\n5\n", - }); - // Split into 2-line chunks and verify first output file - await compareOutputs(env, testDir, "split -l 2 test.txt && cat xaa"); - }); - - it("should split file by lines and verify second chunk", async () => { - const env = await setupFiles(testDir, { - "test.txt": "1\n2\n3\n4\n5\n", - }); - await compareOutputs(env, testDir, "split -l 2 test.txt && cat xab"); - }); - - it("should split by bytes and verify chunk", async () => { - const env = await setupFiles(testDir, { - "test.txt": "abcdefghij", - }); - await compareOutputs(env, testDir, "split -b 4 test.txt && cat xaa"); - }); - - it("should handle empty input", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf '' | split"); - }); - - it("should read from stdin and verify chunk", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "printf 'a\\nb\\nc\\n' | split -l 1 && cat xaa", - ); - }); -}); diff --git a/src/comparison-tests/tar.comparison.test.ts b/src/comparison-tests/tar.comparison.test.ts deleted file mode 100644 index f6846d5f..00000000 --- a/src/comparison-tests/tar.comparison.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("tar command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("create and list (-c -t)", () => { - it("should create and list single file archive", async () => { - const env = await setupFiles(testDir, { - "hello.txt": "Hello, World!\n", - }); - await compareOutputs( - env, - testDir, - "tar -cf archive.tar hello.txt && tar -tf archive.tar", - ); - }); - - it("should create and list multiple files", async () => { - const env = await setupFiles(testDir, { - "file1.txt": "Content 1\n", - "file2.txt": "Content 2\n", - }); - await compareOutputs( - env, - testDir, - "tar -cf archive.tar file1.txt file2.txt && tar -tf archive.tar | sort", - ); - }); - - it("should create and list directory archive", async () => { - const env = await setupFiles(testDir, { - "mydir/file1.txt": "Content 1\n", - "mydir/file2.txt": "Content 2\n", - }); - await compareOutputs( - env, - testDir, - "tar -cf archive.tar mydir && tar -tf archive.tar | sort", - ); - }); - }); - - describe("extract (-x)", () => { - it("should create and extract single file", async () => { - const env = await setupFiles(testDir, { - "original.txt": "Original content\n", - }); - await compareOutputs( - env, - testDir, - "tar -cf archive.tar original.txt && rm original.txt && tar -xf archive.tar && cat original.txt", - ); - }); - - it("should create and extract directory", async () => { - const env = await setupFiles(testDir, { - "mydir/nested.txt": "Nested content\n", - }); - await compareOutputs( - env, - testDir, - "tar -cf archive.tar mydir && rm -rf mydir && tar -xf archive.tar && cat mydir/nested.txt", - ); - }); - - it("should extract to different directory with -C", async () => { - const env = await setupFiles(testDir, { - "source.txt": "Source content\n", - }); - await compareOutputs( - env, - testDir, - "tar -cf archive.tar source.txt && mkdir dest && tar -xf archive.tar -C dest && cat dest/source.txt", - ); - }); - }); - - describe("gzip compression (-z)", () => { - it("should create and list gzip compressed archive", async () => { - const env = await setupFiles(testDir, { - "compress.txt": "Content to compress\n", - }); - await compareOutputs( - env, - testDir, - "tar -czf archive.tar.gz compress.txt && tar -tzf archive.tar.gz", - ); - }); - - it("should create and extract gzip compressed archive", async () => { - const env = await setupFiles(testDir, { - "compress.txt": "Compressed content\n", - }); - await compareOutputs( - env, - testDir, - "tar -czf archive.tar.gz compress.txt && rm compress.txt && tar -xzf archive.tar.gz && cat compress.txt", - ); - }); - }); - - describe("strip components (--strip)", () => { - it("should strip leading path components on extract", async () => { - const env = await setupFiles(testDir, { - "deep/path/file.txt": "Deep content\n", - }); - await compareOutputs( - env, - testDir, - "tar -cf archive.tar deep/path/file.txt && mkdir out && tar -xf archive.tar -C out --strip-components=2 && cat out/file.txt", - ); - }); - }); -}); diff --git a/src/comparison-tests/tee.comparison.test.ts b/src/comparison-tests/tee.comparison.test.ts deleted file mode 100644 index 11bf5a8e..00000000 --- a/src/comparison-tests/tee.comparison.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - cleanupTestDir, - createTestDir, - fs, - path, - runRealBash, - setupFiles, -} from "./fixture-runner.js"; - -describe("tee command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("basic usage", () => { - it("should pass through stdin to stdout", async () => { - const env = await setupFiles(testDir, {}); - - const envResult = await env.exec("echo hello | tee"); - const realResult = await runRealBash("echo hello | tee", testDir); - - expect(envResult.stdout).toBe(realResult.stdout); - expect(envResult.exitCode).toBe(realResult.exitCode); - }); - - it("should write to file and stdout", async () => { - const env = await setupFiles(testDir, {}); - - const envResult = await env.exec("echo hello | tee output.txt"); - const realResult = await runRealBash( - "echo hello | tee output.txt", - testDir, - ); - - expect(envResult.stdout).toBe(realResult.stdout); - - // Compare file contents - const envContent = await env.readFile("output.txt"); - const realContent = await fs.readFile( - path.join(testDir, "output.txt"), - "utf-8", - ); - expect(envContent).toBe(realContent); - }); - - it("should write to multiple files", async () => { - const env = await setupFiles(testDir, {}); - - const envResult = await env.exec("echo hello | tee file1.txt file2.txt"); - const realResult = await runRealBash( - "echo hello | tee file1.txt file2.txt", - testDir, - ); - - expect(envResult.stdout).toBe(realResult.stdout); - - // Compare file contents - const envContent1 = await env.readFile("file1.txt"); - const realContent1 = await fs.readFile( - path.join(testDir, "file1.txt"), - "utf-8", - ); - expect(envContent1).toBe(realContent1); - - const envContent2 = await env.readFile("file2.txt"); - const realContent2 = await fs.readFile( - path.join(testDir, "file2.txt"), - "utf-8", - ); - expect(envContent2).toBe(realContent2); - }); - }); - - describe("append mode", () => { - it("should append with -a flag", async () => { - const env = await setupFiles(testDir, { - "existing.txt": "existing content\n", - }); - await fs.writeFile( - path.join(testDir, "existing.txt"), - "existing content\n", - ); - - await env.exec("echo appended | tee -a existing.txt"); - await runRealBash("echo appended | tee -a existing.txt", testDir); - - const envContent = await env.readFile("existing.txt"); - const realContent = await fs.readFile( - path.join(testDir, "existing.txt"), - "utf-8", - ); - expect(envContent).toBe(realContent); - }); - }); - - describe("multiline content", () => { - it("should handle multiline input", async () => { - const env = await setupFiles(testDir, {}); - - const envResult = await env.exec( - 'echo -e "line1\\nline2\\nline3" | tee output.txt', - ); - const realResult = await runRealBash( - 'echo -e "line1\\nline2\\nline3" | tee output.txt', - testDir, - ); - - expect(envResult.stdout).toBe(realResult.stdout); - - const envContent = await env.readFile("output.txt"); - const realContent = await fs.readFile( - path.join(testDir, "output.txt"), - "utf-8", - ); - expect(envContent).toBe(realContent); - }); - }); -}); diff --git a/src/comparison-tests/test.comparison.test.ts b/src/comparison-tests/test.comparison.test.ts deleted file mode 100644 index 8aa054cf..00000000 --- a/src/comparison-tests/test.comparison.test.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("test command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("file tests", () => { - it("-e returns 0 for existing file", async () => { - const env = await setupFiles(testDir, { "file.txt": "content" }); - await compareOutputs(env, testDir, "test -e file.txt && echo exists"); - }); - - it("-e returns 1 for non-existing file", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "test -e nonexistent && echo exists || echo missing", - ); - }); - - it("-f returns 0 for regular file", async () => { - const env = await setupFiles(testDir, { "file.txt": "content" }); - await compareOutputs( - env, - testDir, - "test -f file.txt && echo file || echo not", - ); - }); - - it("-f returns 1 for directory", async () => { - const env = await setupFiles(testDir, { "dir/file.txt": "content" }); - await compareOutputs( - env, - testDir, - "test -f dir && echo file || echo not", - ); - }); - - it("-d returns 0 for directory", async () => { - const env = await setupFiles(testDir, { "dir/file.txt": "content" }); - await compareOutputs(env, testDir, "test -d dir && echo dir || echo not"); - }); - - it("-d returns 1 for regular file", async () => { - const env = await setupFiles(testDir, { "file.txt": "content" }); - await compareOutputs( - env, - testDir, - "test -d file.txt && echo dir || echo not", - ); - }); - - it("-s returns 0 for non-empty file", async () => { - const env = await setupFiles(testDir, { "file.txt": "content" }); - await compareOutputs( - env, - testDir, - "test -s file.txt && echo nonempty || echo empty", - ); - }); - - it("-s returns 1 for empty file", async () => { - const env = await setupFiles(testDir, { "empty.txt": "" }); - await compareOutputs( - env, - testDir, - "test -s empty.txt && echo nonempty || echo empty", - ); - }); - }); - - describe("string tests", () => { - it("-z returns 0 for empty string", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'test -z "" && echo empty || echo nonempty', - ); - }); - - it("-z returns 1 for non-empty string", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'test -z "hello" && echo empty || echo nonempty', - ); - }); - - it("-n returns 0 for non-empty string", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'test -n "hello" && echo nonempty || echo empty', - ); - }); - - it("-n returns 1 for empty string", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'test -n "" && echo nonempty || echo empty', - ); - }); - - it("= returns 0 for equal strings", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'test "abc" = "abc" && echo equal || echo notequal', - ); - }); - - it("= returns 1 for unequal strings", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'test "abc" = "def" && echo equal || echo notequal', - ); - }); - - it("!= returns 0 for unequal strings", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'test "abc" != "def" && echo notequal || echo equal', - ); - }); - - it("!= returns 1 for equal strings", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'test "abc" != "abc" && echo notequal || echo equal', - ); - }); - }); - - describe("numeric tests", () => { - it("-eq returns 0 for equal numbers", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "test 5 -eq 5 && echo equal || echo notequal", - ); - }); - - it("-eq returns 1 for unequal numbers", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "test 5 -eq 6 && echo equal || echo notequal", - ); - }); - - it("-ne returns 0 for unequal numbers", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "test 5 -ne 6 && echo notequal || echo equal", - ); - }); - - it("-lt returns 0 when left < right", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "test 3 -lt 5 && echo less || echo notless", - ); - }); - - it("-lt returns 1 when left >= right", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "test 5 -lt 3 && echo less || echo notless", - ); - }); - - it("-le returns 0 when left <= right", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "test 5 -le 5 && echo lesseq || echo notlesseq", - ); - }); - - it("-gt returns 0 when left > right", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "test 5 -gt 3 && echo greater || echo notgreater", - ); - }); - - it("-ge returns 0 when left >= right", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "test 5 -ge 5 && echo greatereq || echo notgreatereq", - ); - }); - }); - - describe("logical operators", () => { - it("! negates expression", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'test ! -z "hello" && echo notempty || echo empty', - ); - }); - - it("-a requires both to be true", async () => { - const env = await setupFiles(testDir, { - "a.txt": "a", - "b.txt": "b", - }); - await compareOutputs( - env, - testDir, - "test -f a.txt -a -f b.txt && echo both || echo notboth", - ); - }); - - it("-a fails if one is false", async () => { - const env = await setupFiles(testDir, { "a.txt": "a" }); - await compareOutputs( - env, - testDir, - "test -f a.txt -a -f nonexistent && echo both || echo notboth", - ); - }); - - it("-o succeeds if either is true", async () => { - const env = await setupFiles(testDir, { "a.txt": "a" }); - await compareOutputs( - env, - testDir, - "test -f nonexistent -o -f a.txt && echo either || echo neither", - ); - }); - - it("-o fails if both are false", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "test -f a -o -f b && echo either || echo neither", - ); - }); - }); - - describe("bracket syntax [ ]", () => { - it("works with closing bracket", async () => { - const env = await setupFiles(testDir, { "file.txt": "content" }); - await compareOutputs( - env, - testDir, - "[ -f file.txt ] && echo file || echo notfile", - ); - }); - - it("works with string comparison", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - '[ "foo" = "foo" ] && echo equal || echo notequal', - ); - }); - - it("works with numeric comparison", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "[ 10 -gt 5 ] && echo greater || echo notgreater", - ); - }); - - it("works with logical operators", async () => { - const env = await setupFiles(testDir, { - "dir/file": "x", - }); - await compareOutputs( - env, - testDir, - "[ -d dir -a -f dir/file ] && echo both || echo notboth", - ); - }); - }); - - describe("no arguments", () => { - it("returns 1 with no arguments", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "test && echo true || echo false"); - }); - - it("[ ] returns 1 with empty expression", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "[ ] && echo true || echo false"); - }); - }); - - describe("single argument", () => { - it("returns 0 for non-empty string", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'test "hello" && echo nonempty || echo empty', - ); - }); - - it("returns 1 for empty string", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'test "" && echo nonempty || echo empty', - ); - }); - }); - - describe("variable expansion in test", () => { - it("works with variable in string test", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'VAR=hello; test -n "$VAR" && echo set || echo unset', - ); - }); - - it("works with empty variable", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'VAR=""; test -z "$VAR" && echo empty || echo notempty', - ); - }); - - it("works with variable in numeric comparison", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "NUM=10; test $NUM -gt 5 && echo greater || echo notgreater", - ); - }); - }); -}); diff --git a/src/comparison-tests/text-processing.comparison.test.ts b/src/comparison-tests/text-processing.comparison.test.ts deleted file mode 100644 index 5df3ca43..00000000 --- a/src/comparison-tests/text-processing.comparison.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("rev command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should reverse simple string from stdin", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "echo 'hello' | rev"); - }); - - it("should reverse multiple lines", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf 'abc\\ndef\\nghi\\n' | rev"); - }); - - it("should handle empty input", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf '' | rev"); - }); - - it("should handle single character", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "echo 'a' | rev"); - }); - - it("should preserve spaces", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "echo 'a b c' | rev"); - }); -}); - -describe("nl command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should number lines from stdin", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf 'a\\nb\\nc\\n' | nl"); - }); - - it("should skip empty lines with default style", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf 'a\\n\\nb\\n' | nl"); - }); - - it("should number all lines with -ba", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf 'a\\n\\nb\\n' | nl -ba"); - }); - - it("should left justify with -n ln", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf 'a\\nb\\n' | nl -n ln"); - }); - - it("should right justify with zeros with -n rz", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf 'a\\nb\\n' | nl -n rz"); - }); - - it("should set width with -w", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf 'a\\nb\\n' | nl -w 3"); - }); - - it("should set separator with -s", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf 'a\\nb\\n' | nl -s ': '"); - }); - - it("should set starting number with -v", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf 'a\\nb\\n' | nl -v 10"); - }); - - it("should set increment with -i", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf 'a\\nb\\nc\\n' | nl -i 5"); - }); -}); - -describe("fold command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should wrap at 80 columns by default", async () => { - const env = await setupFiles(testDir, {}); - const longLine = "a".repeat(100); - await compareOutputs(env, testDir, `echo '${longLine}' | fold`); - }); - - it("should wrap at specified width with -w", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "echo 'hello world test' | fold -w 5"); - }); - - it("should break at spaces with -s", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "echo 'hello world foo bar' | fold -sw 10", - ); - }); - - it("should handle empty input", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf '' | fold"); - }); - - it("should handle multiple lines", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "printf '12345678901234567890\\nabcdefghij\\n' | fold -w 10", - ); - }); -}); - -describe("expand command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should convert tabs to 8 spaces by default", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf 'a\\tb' | expand"); - }); - - it("should handle tab at start of line", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf '\\thello' | expand"); - }); - - it("should handle multiple tabs", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf 'a\\tb\\tc' | expand"); - }); - - it("should use custom tab width with -t", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf 'a\\tb' | expand -t 4"); - }); - - it("should handle input with no tabs", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf 'hello world' | expand"); - }); -}); - -describe("unexpand command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - it("should convert leading spaces to tabs (default 8)", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf ' hello' | unexpand"); - }); - - it("should handle partial tab stops", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf ' hello' | unexpand"); - }); - - it("should handle 16 leading spaces", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "printf ' hello' | unexpand", - ); - }); - - it("should not convert spaces after text by default", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - "printf 'hello world' | unexpand", - ); - }); - - it("should convert all spaces with -a", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf 'hello world' | unexpand -a"); - }); - - it("should use custom tab width with -t", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "printf ' hello' | unexpand -t 4"); - }); -}); diff --git a/src/comparison-tests/tr.comparison.test.ts b/src/comparison-tests/tr.comparison.test.ts deleted file mode 100644 index 89ca9727..00000000 --- a/src/comparison-tests/tr.comparison.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("tr command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("character translation", () => { - it("should translate lowercase to uppercase", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\n", - }); - await compareOutputs(env, testDir, "cat test.txt | tr 'a-z' 'A-Z'"); - }); - - it("should translate uppercase to lowercase", async () => { - const env = await setupFiles(testDir, { - "test.txt": "HELLO WORLD\n", - }); - await compareOutputs(env, testDir, "cat test.txt | tr 'A-Z' 'a-z'"); - }); - - it("should translate specific characters", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello\n", - }); - await compareOutputs(env, testDir, "cat test.txt | tr 'elo' 'xyz'"); - }); - - it("should translate digits", async () => { - const env = await setupFiles(testDir, { - "test.txt": "12345\n", - }); - await compareOutputs(env, testDir, "cat test.txt | tr '0-9' 'a-j'"); - }); - }); - - describe("-d flag (delete)", () => { - it("should delete specified characters", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\n", - }); - await compareOutputs(env, testDir, "cat test.txt | tr -d 'aeiou'"); - }); - - it("should delete digits", async () => { - const env = await setupFiles(testDir, { - "test.txt": "abc123def456\n", - }); - await compareOutputs(env, testDir, "cat test.txt | tr -d '0-9'"); - }); - - it("should delete spaces", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world foo bar\n", - }); - await compareOutputs(env, testDir, "cat test.txt | tr -d ' '"); - }); - - it("should delete newlines", async () => { - const env = await setupFiles(testDir, { - "test.txt": "line1\nline2\nline3\n", - }); - await compareOutputs(env, testDir, "cat test.txt | tr -d '\\n'"); - }); - }); - - describe("-s flag (squeeze)", () => { - it("should squeeze repeated characters", async () => { - const env = await setupFiles(testDir, { - "test.txt": "helllo wooorld\n", - }); - await compareOutputs(env, testDir, "cat test.txt | tr -s 'lo '"); - }); - - it("should squeeze spaces", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world foo\n", - }); - await compareOutputs(env, testDir, "cat test.txt | tr -s ' '"); - }); - - it("should squeeze newlines", async () => { - const env = await setupFiles(testDir, { - "test.txt": "line1\n\n\nline2\n\nline3\n", - }); - await compareOutputs(env, testDir, "cat test.txt | tr -s '\\n'"); - }); - }); - - describe("special character sets", () => { - it("should handle escaped characters", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello\tworld\n", - }); - await compareOutputs(env, testDir, "cat test.txt | tr '\\t' ' '"); - }); - - it("should translate newlines to spaces", async () => { - const env = await setupFiles(testDir, { - "test.txt": "line1\nline2\nline3\n", - }); - await compareOutputs(env, testDir, "cat test.txt | tr '\\n' ' '"); - }); - }); - - describe("stdin from echo", () => { - it("should translate from echo", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "echo 'hello' | tr 'a-z' 'A-Z'"); - }); - - it("should delete from echo", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, "echo 'hello world' | tr -d 'lo'"); - }); - }); - - describe("combined operations", () => { - it("should translate and squeeze", async () => { - const env = await setupFiles(testDir, { - "test.txt": "HELLO WORLD\n", - }); - await compareOutputs( - env, - testDir, - "cat test.txt | tr 'A-Z' 'a-z' | tr -s ' '", - ); - }); - }); -}); diff --git a/src/comparison-tests/uniq.comparison.test.ts b/src/comparison-tests/uniq.comparison.test.ts deleted file mode 100644 index 46329610..00000000 --- a/src/comparison-tests/uniq.comparison.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("uniq command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - describe("default behavior", () => { - it("should remove adjacent duplicates", async () => { - const env = await setupFiles(testDir, { - "test.txt": "apple\napple\nbanana\nbanana\nbanana\ncherry\n", - }); - await compareOutputs(env, testDir, "uniq test.txt"); - }); - - it("should only remove adjacent duplicates", async () => { - const env = await setupFiles(testDir, { - "test.txt": "apple\nbanana\napple\ncherry\nbanana\n", - }); - await compareOutputs(env, testDir, "uniq test.txt"); - }); - - it("should handle no duplicates", async () => { - const env = await setupFiles(testDir, { - "test.txt": "apple\nbanana\ncherry\n", - }); - await compareOutputs(env, testDir, "uniq test.txt"); - }); - - it("should handle all same lines", async () => { - const env = await setupFiles(testDir, { - "test.txt": "same\nsame\nsame\n", - }); - await compareOutputs(env, testDir, "uniq test.txt"); - }); - }); - - // Note: normalizeWhitespace is needed for -c tests because BSD and GNU uniq - // have different column width formatting, but the actual values are the same - const uniqCountOptions = { normalizeWhitespace: true }; - - describe("-c flag (count)", () => { - it("should count occurrences", async () => { - const env = await setupFiles(testDir, { - "test.txt": "apple\napple\nbanana\nbanana\nbanana\ncherry\n", - }); - await compareOutputs(env, testDir, "uniq -c test.txt", uniqCountOptions); - }); - - it("should count single occurrences", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a\nb\nc\n", - }); - await compareOutputs(env, testDir, "uniq -c test.txt", uniqCountOptions); - }); - }); - - describe("-d flag (duplicates only)", () => { - it("should show only duplicated lines", async () => { - const env = await setupFiles(testDir, { - "test.txt": "apple\napple\nbanana\ncherry\ncherry\n", - }); - await compareOutputs(env, testDir, "uniq -d test.txt"); - }); - - it("should handle no duplicates", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a\nb\nc\n", - }); - await compareOutputs(env, testDir, "uniq -d test.txt"); - }); - }); - - describe("-u flag (unique only)", () => { - it("should show only unique lines", async () => { - const env = await setupFiles(testDir, { - "test.txt": "apple\napple\nbanana\ncherry\ncherry\n", - }); - await compareOutputs(env, testDir, "uniq -u test.txt"); - }); - - it("should handle all unique", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a\nb\nc\n", - }); - await compareOutputs(env, testDir, "uniq -u test.txt"); - }); - - it("should handle all duplicates", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a\na\nb\nb\n", - }); - await compareOutputs(env, testDir, "uniq -u test.txt"); - }); - }); - - describe("combined with sort", () => { - it("should work with sort for true unique", async () => { - const env = await setupFiles(testDir, { - "test.txt": "apple\nbanana\napple\ncherry\nbanana\n", - }); - await compareOutputs(env, testDir, "sort test.txt | uniq"); - }); - - it("should count after sort", async () => { - const env = await setupFiles(testDir, { - "test.txt": "apple\nbanana\napple\ncherry\nbanana\napple\n", - }); - await compareOutputs( - env, - testDir, - "sort test.txt | uniq -c", - uniqCountOptions, - ); - }); - }); - - describe("stdin", () => { - it("should read from stdin", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, 'echo -e "a\\na\\nb" | uniq'); - }); - - it("should count from stdin", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'echo -e "a\\na\\nb\\nb\\nb" | uniq -c', - uniqCountOptions, - ); - }); - }); - - describe("combined flags", () => { - it("should combine -c and -d", async () => { - const env = await setupFiles(testDir, { - "test.txt": "a\na\nb\nc\nc\nc\n", - }); - await compareOutputs(env, testDir, "uniq -cd test.txt", uniqCountOptions); - }); - }); -}); diff --git a/src/comparison-tests/vitest.setup.ts b/src/comparison-tests/vitest.setup.ts deleted file mode 100644 index 705c7f45..00000000 --- a/src/comparison-tests/vitest.setup.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { afterAll } from "vitest"; -import { isRecordMode, writeAllFixtures } from "./fixture-runner.js"; - -// Write all accumulated fixtures after all tests complete -afterAll(async () => { - if (isRecordMode) { - await writeAllFixtures(); - } -}); diff --git a/src/comparison-tests/wc.comparison.test.ts b/src/comparison-tests/wc.comparison.test.ts deleted file mode 100644 index cba650d8..00000000 --- a/src/comparison-tests/wc.comparison.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - cleanupTestDir, - compareOutputs, - createTestDir, - setupFiles, -} from "./fixture-runner.js"; - -describe("wc command - Real Bash Comparison", () => { - let testDir: string; - - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupTestDir(testDir); - }); - - // Note: normalizeWhitespace is needed because BSD (macOS) and GNU (Linux) wc - // have different column width formatting, but the actual values are the same - const wcOptions = { normalizeWhitespace: true }; - - describe("default output (lines, words, chars)", () => { - it("should match full wc output", async () => { - const env = await setupFiles(testDir, { - "test.txt": "line 1\nline 2\nline 3\n", - }); - await compareOutputs(env, testDir, "wc test.txt", wcOptions); - }); - - it("should handle file without trailing newline", async () => { - const env = await setupFiles(testDir, { - "test.txt": "no newline", - }); - await compareOutputs(env, testDir, "wc test.txt", wcOptions); - }); - - it("should handle empty file", async () => { - const env = await setupFiles(testDir, { - "empty.txt": "", - }); - await compareOutputs(env, testDir, "wc empty.txt", wcOptions); - }); - }); - - describe("-l flag (line count)", () => { - it("should match wc -l output", async () => { - const env = await setupFiles(testDir, { - "test.txt": "line 1\nline 2\nline 3\n", - }); - await compareOutputs(env, testDir, "wc -l test.txt", wcOptions); - }); - - it("should count lines from stdin", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'echo -e "a\\nb\\nc" | wc -l', - wcOptions, - ); - }); - }); - - describe("-w flag (word count)", () => { - it("should match wc -w output", async () => { - const env = await setupFiles(testDir, { - "test.txt": "one two three\nfour five\n", - }); - await compareOutputs(env, testDir, "wc -w test.txt", wcOptions); - }); - - it("should count words with multiple spaces", async () => { - const env = await setupFiles(testDir, { - "test.txt": "one two three\n", - }); - await compareOutputs(env, testDir, "wc -w test.txt", wcOptions); - }); - }); - - describe("-c flag (character/byte count)", () => { - it("should match wc -c output", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\n", - }); - await compareOutputs(env, testDir, "wc -c test.txt", wcOptions); - }); - }); - - describe("multiple files", () => { - it("should show total for multiple files", async () => { - const env = await setupFiles(testDir, { - "a.txt": "file a\n", - "b.txt": "file b line 1\nfile b line 2\n", - }); - await compareOutputs(env, testDir, "wc a.txt b.txt", wcOptions); - }); - - it("should show -l for multiple files", async () => { - const env = await setupFiles(testDir, { - "a.txt": "line 1\nline 2\n", - "b.txt": "line 1\nline 2\nline 3\n", - }); - await compareOutputs(env, testDir, "wc -l a.txt b.txt", wcOptions); - }); - }); - - describe("combined flags", () => { - it("should match -lw output", async () => { - const env = await setupFiles(testDir, { - "test.txt": "one two three\nfour five\n", - }); - await compareOutputs(env, testDir, "wc -lw test.txt", wcOptions); - }); - - it("should match -wc output", async () => { - const env = await setupFiles(testDir, { - "test.txt": "hello world\n", - }); - await compareOutputs(env, testDir, "wc -wc test.txt", wcOptions); - }); - }); - - describe("stdin", () => { - it("should count stdin input", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs(env, testDir, 'echo "hello world" | wc', wcOptions); - }); - - it("should count -l from stdin", async () => { - const env = await setupFiles(testDir, {}); - await compareOutputs( - env, - testDir, - 'echo -e "a\\nb\\nc" | wc -l', - wcOptions, - ); - }); - }); -}); diff --git a/src/custom-commands.test.ts b/src/custom-commands.test.ts deleted file mode 100644 index db98c618..00000000 --- a/src/custom-commands.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "./Bash.js"; -import { - type CustomCommand, - createLazyCustomCommand, - defineCommand, - isLazyCommand, - type LazyCommand, -} from "./custom-commands.js"; -import type { Command } from "./types.js"; - -describe("custom-commands", () => { - describe("defineCommand", () => { - it("creates a Command object with name and execute", () => { - const cmd = defineCommand("test", async () => ({ - stdout: "hello\n", - stderr: "", - exitCode: 0, - })); - - expect(cmd.name).toBe("test"); - expect(typeof cmd.execute).toBe("function"); - }); - - it("execute function receives args and ctx", async () => { - const cmd = defineCommand("greet", async (args, ctx) => ({ - stdout: `Hello, ${args[0] || "world"}! CWD: ${ctx.cwd}\n`, - stderr: "", - exitCode: 0, - })); - - const bash = new Bash({ customCommands: [cmd] }); - const result = await bash.exec("greet Alice"); - - expect(result.stdout).toBe("Hello, Alice! CWD: /home/user\n"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - }); - - describe("isLazyCommand", () => { - it("returns true for LazyCommand objects", () => { - const lazy: LazyCommand = { - name: "lazy", - load: async () => ({ - name: "lazy", - execute: async () => ({ stdout: "", stderr: "", exitCode: 0 }), - }), - }; - expect(isLazyCommand(lazy)).toBe(true); - }); - - it("returns false for Command objects", () => { - const cmd: Command = { - name: "cmd", - execute: async () => ({ stdout: "", stderr: "", exitCode: 0 }), - }; - expect(isLazyCommand(cmd)).toBe(false); - }); - }); - - describe("createLazyCustomCommand", () => { - it("creates a command that loads on first execution", async () => { - let loadCount = 0; - const lazy: LazyCommand = { - name: "lazy-test", - load: async () => { - loadCount++; - return defineCommand("lazy-test", async () => ({ - stdout: "lazy loaded\n", - stderr: "", - exitCode: 0, - })); - }, - }; - - const cmd = createLazyCustomCommand(lazy); - expect(loadCount).toBe(0); - - // First execution loads the command - const result1 = await cmd.execute([], { - fs: {} as never, - cwd: "/", - env: new Map(), - stdin: "", - }); - expect(loadCount).toBe(1); - expect(result1.stdout).toBe("lazy loaded\n"); - - // Second execution uses cached command - const result2 = await cmd.execute([], { - fs: {} as never, - cwd: "/", - env: new Map(), - stdin: "", - }); - expect(loadCount).toBe(1); - expect(result2.stdout).toBe("lazy loaded\n"); - }); - }); - - describe("Bash with customCommands", () => { - it("registers and executes a simple custom command", async () => { - const hello = defineCommand("hello", async (args) => ({ - stdout: `Hello, ${args[0] || "world"}!\n`, - stderr: "", - exitCode: 0, - })); - - const bash = new Bash({ customCommands: [hello] }); - const result = await bash.exec("hello"); - - expect(result.stdout).toBe("Hello, world!\n"); - expect(result.exitCode).toBe(0); - }); - - it("custom command receives stdin from pipe", async () => { - const wordcount = defineCommand("wordcount", async (_args, ctx) => { - const words = ctx.stdin.trim().split(/\s+/).filter(Boolean).length; - return { stdout: `${words}\n`, stderr: "", exitCode: 0 }; - }); - - const bash = new Bash({ customCommands: [wordcount] }); - const result = await bash.exec("echo 'one two three' | wordcount"); - - expect(result.stdout).toBe("3\n"); - expect(result.exitCode).toBe(0); - }); - - it("custom command can read files via ctx.fs", async () => { - const reader = defineCommand("reader", async (args, ctx) => { - const content = await ctx.fs.readFile(args[0]); - return { stdout: content, stderr: "", exitCode: 0 }; - }); - - const bash = new Bash({ - customCommands: [reader], - files: { "/test.txt": "file content" }, - }); - const result = await bash.exec("reader /test.txt"); - - expect(result.stdout).toBe("file content"); - expect(result.exitCode).toBe(0); - }); - - it("custom command can access environment variables", async () => { - const showenv = defineCommand("showenv", async (args, ctx) => ({ - stdout: `${args[0]}=${ctx.env.get(args[0]) || ""}\n`, - stderr: "", - exitCode: 0, - })); - - const bash = new Bash({ - customCommands: [showenv], - env: { MY_VAR: "my_value" }, - }); - const result = await bash.exec("showenv MY_VAR"); - - expect(result.stdout).toBe("MY_VAR=my_value\n"); - expect(result.exitCode).toBe(0); - }); - - it("custom command overrides built-in command", async () => { - const customEcho = defineCommand("echo", async (args) => ({ - stdout: `Custom: ${args.join(" ")}\n`, - stderr: "", - exitCode: 0, - })); - - const bash = new Bash({ customCommands: [customEcho] }); - const result = await bash.exec("echo hello world"); - - expect(result.stdout).toBe("Custom: hello world\n"); - expect(result.exitCode).toBe(0); - }); - - it("registers lazy-loaded custom command", async () => { - let loaded = false; - const lazyCmd: LazyCommand = { - name: "lazy-hello", - load: async () => { - loaded = true; - return defineCommand("lazy-hello", async () => ({ - stdout: "lazy hello!\n", - stderr: "", - exitCode: 0, - })); - }, - }; - - const bash = new Bash({ customCommands: [lazyCmd] }); - expect(loaded).toBe(false); - - const result = await bash.exec("lazy-hello"); - expect(loaded).toBe(true); - expect(result.stdout).toBe("lazy hello!\n"); - expect(result.exitCode).toBe(0); - }); - - it("multiple custom commands can be registered", async () => { - const cmd1 = defineCommand("cmd1", async () => ({ - stdout: "one\n", - stderr: "", - exitCode: 0, - })); - const cmd2 = defineCommand("cmd2", async () => ({ - stdout: "two\n", - stderr: "", - exitCode: 0, - })); - - const bash = new Bash({ customCommands: [cmd1, cmd2] }); - - const result1 = await bash.exec("cmd1"); - expect(result1.stdout).toBe("one\n"); - - const result2 = await bash.exec("cmd2"); - expect(result2.stdout).toBe("two\n"); - }); - - it("custom command can return non-zero exit code", async () => { - const failing = defineCommand("failing", async () => ({ - stdout: "", - stderr: "error occurred\n", - exitCode: 42, - })); - - const bash = new Bash({ customCommands: [failing] }); - const result = await bash.exec("failing"); - - expect(result.stdout).toBe(""); - expect(result.stderr).toBe("error occurred\n"); - expect(result.exitCode).toBe(42); - }); - - it("custom command works in pipeline with built-in commands", async () => { - const upper = defineCommand("upper", async (_args, ctx) => ({ - stdout: ctx.stdin.toUpperCase(), - stderr: "", - exitCode: 0, - })); - - const bash = new Bash({ customCommands: [upper] }); - const result = await bash.exec("echo 'hello world' | upper | cat"); - - expect(result.stdout).toBe("HELLO WORLD\n"); - expect(result.exitCode).toBe(0); - }); - - it("custom command can use exec to run subcommands", async () => { - const wrapper = defineCommand("wrapper", async (args, ctx) => { - if (!ctx.exec) { - return { stdout: "", stderr: "exec not available\n", exitCode: 1 }; - } - const subResult = await ctx.exec(args.join(" "), { cwd: ctx.cwd }); - return { - stdout: `[wrapped] ${subResult.stdout}`, - stderr: subResult.stderr, - exitCode: subResult.exitCode, - }; - }); - - const bash = new Bash({ customCommands: [wrapper] }); - const result = await bash.exec("wrapper echo hello"); - - expect(result.stdout).toBe("[wrapped] hello\n"); - expect(result.exitCode).toBe(0); - }); - - it("works with mixed Command and LazyCommand types", async () => { - const regular = defineCommand("regular", async () => ({ - stdout: "regular\n", - stderr: "", - exitCode: 0, - })); - - const lazy: CustomCommand = { - name: "lazy", - load: async () => - defineCommand("lazy", async () => ({ - stdout: "lazy\n", - stderr: "", - exitCode: 0, - })), - }; - - const bash = new Bash({ customCommands: [regular, lazy] }); - - expect((await bash.exec("regular")).stdout).toBe("regular\n"); - expect((await bash.exec("lazy")).stdout).toBe("lazy\n"); - }); - }); -}); diff --git a/src/custom-commands.ts b/src/custom-commands.ts deleted file mode 100644 index 7e7cdba1..00000000 --- a/src/custom-commands.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Custom Commands API - * - * Provides types and utilities for registering user-provided TypeScript commands. - */ - -import type { Command, CommandContext, ExecResult } from "./types.js"; - -/** - * A custom command - either a Command object or a lazy loader. - */ -export type CustomCommand = Command | LazyCommand; - -/** - * Lazy-loaded custom command (for code-splitting). - */ -export interface LazyCommand { - name: string; - load: () => Promise; -} - -/** - * Type guard to check if a custom command is lazy-loaded. - */ -export function isLazyCommand(cmd: CustomCommand): cmd is LazyCommand { - return "load" in cmd && typeof cmd.load === "function"; -} - -/** - * Define a TypeScript command with type inference. - * Convenience wrapper - you can also just use the Command interface directly. - * - * @example - * ```ts - * const hello = defineCommand("hello", async (args, ctx) => { - * const name = args[0] || "world"; - * return { stdout: `Hello, ${name}!\n`, stderr: "", exitCode: 0 }; - * }); - * - * const bash = new Bash({ customCommands: [hello] }); - * await bash.exec("hello Alice"); // "Hello, Alice!\n" - * ``` - */ -export function defineCommand( - name: string, - execute: (args: string[], ctx: CommandContext) => Promise, -): Command { - return { name, execute }; -} - -/** - * Create a lazy-loaded wrapper for a custom command. - * The command is only loaded when first executed. - */ -export function createLazyCustomCommand(lazy: LazyCommand): Command { - let cached: Command | null = null; - return { - name: lazy.name, - async execute(args: string[], ctx: CommandContext): Promise { - if (!cached) { - cached = await lazy.load(); - } - return cached.execute(args, ctx); - }, - }; -} diff --git a/src/fs/encoding.ts b/src/fs/encoding.ts deleted file mode 100644 index f1261594..00000000 --- a/src/fs/encoding.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Shared utilities for filesystem implementations - */ - -import type { - BufferEncoding, - ReadFileOptions, - WriteFileOptions, -} from "./interface.js"; - -export type FileContent = string | Uint8Array; - -// Text encoder/decoder for encoding conversions -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder(); - -/** - * Helper to convert content to Uint8Array - */ -export function toBuffer( - content: FileContent, - encoding?: BufferEncoding, -): Uint8Array { - if (content instanceof Uint8Array) { - return content; - } - - if (encoding === "base64") { - return Uint8Array.from(atob(content), (c) => c.charCodeAt(0)); - } - if (encoding === "hex") { - const bytes = new Uint8Array(content.length / 2); - for (let i = 0; i < content.length; i += 2) { - bytes[i / 2] = parseInt(content.slice(i, i + 2), 16); - } - return bytes; - } - if (encoding === "binary" || encoding === "latin1") { - // Use chunked approach for large strings to avoid performance issues - const chunkSize = 65536; // 64KB chunks - if (content.length <= chunkSize) { - return Uint8Array.from(content, (c) => c.charCodeAt(0)); - } - const result = new Uint8Array(content.length); - for (let i = 0; i < content.length; i++) { - result[i] = content.charCodeAt(i); - } - return result; - } - // Default to UTF-8 for text content - return textEncoder.encode(content); -} - -/** - * Helper to convert Uint8Array to string with encoding - */ -export function fromBuffer( - buffer: Uint8Array, - encoding?: BufferEncoding | null, -): string { - if (encoding === "base64") { - return btoa(String.fromCharCode(...buffer)); - } - if (encoding === "hex") { - return Array.from(buffer) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - } - if (encoding === "binary" || encoding === "latin1") { - // Use Buffer if available (Node.js) - much more efficient and avoids spread operator limits - if (typeof Buffer !== "undefined") { - return Buffer.from(buffer).toString(encoding); - } - - // Browser fallback - String.fromCharCode(...buffer) fails with buffers > ~100KB - const chunkSize = 65536; // 64KB chunks - if (buffer.length <= chunkSize) { - return String.fromCharCode(...buffer); - } - let result = ""; - for (let i = 0; i < buffer.length; i += chunkSize) { - const chunk = buffer.subarray(i, i + chunkSize); - result += String.fromCharCode(...chunk); - } - return result; - } - // Default to UTF-8 for text content - return textDecoder.decode(buffer); -} - -/** - * Helper to get encoding from options - */ -export function getEncoding( - options?: ReadFileOptions | WriteFileOptions | BufferEncoding | string | null, -): BufferEncoding | undefined { - if (options === null || options === undefined) { - return undefined; - } - if (typeof options === "string") { - return options as BufferEncoding; - } - return options.encoding ?? undefined; -} diff --git a/src/fs/in-memory-fs/in-memory-fs.test.ts b/src/fs/in-memory-fs/in-memory-fs.test.ts deleted file mode 100644 index 220f1e1d..00000000 --- a/src/fs/in-memory-fs/in-memory-fs.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { InMemoryFs } from "./in-memory-fs.js"; - -describe("InMemoryFs Buffer and Encoding Support", () => { - describe("basic Buffer operations", () => { - it("should write and read Uint8Array", async () => { - const fs = new InMemoryFs(); - const data = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" - - await fs.writeFile("/binary.bin", data); - const result = await fs.readFileBuffer("/binary.bin"); - - expect(result).toEqual(data); - }); - - it("should write Uint8Array and read as string", async () => { - const fs = new InMemoryFs(); - const data = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" - - await fs.writeFile("/test.txt", data); - const result = await fs.readFile("/test.txt"); - - expect(result).toBe("Hello"); - }); - - it("should write string and read as Uint8Array", async () => { - const fs = new InMemoryFs(); - - await fs.writeFile("/test.txt", "Hello"); - const result = await fs.readFileBuffer("/test.txt"); - - expect(result).toEqual(new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f])); - }); - - it("should handle binary data with null bytes", async () => { - const fs = new InMemoryFs(); - const data = new Uint8Array([0x00, 0x01, 0x00, 0xff, 0x00]); - - await fs.writeFile("/binary.bin", data); - const result = await fs.readFileBuffer("/binary.bin"); - - expect(result).toEqual(data); - }); - - it("should calculate correct size for binary files", async () => { - const fs = new InMemoryFs(); - const data = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0x04]); - - await fs.writeFile("/binary.bin", data); - const stat = await fs.stat("/binary.bin"); - - expect(stat.size).toBe(5); - }); - }); - - describe("encoding support", () => { - it("should write and read with utf8 encoding", async () => { - const fs = new InMemoryFs(); - - await fs.writeFile("/test.txt", "Hello 世界", "utf8"); - const result = await fs.readFile("/test.txt", "utf8"); - - expect(result).toBe("Hello 世界"); - }); - - it("should write and read with base64 encoding", async () => { - const fs = new InMemoryFs(); - - // "Hello" in base64 is "SGVsbG8=" - await fs.writeFile("/test.txt", "SGVsbG8=", "base64"); - const result = await fs.readFile("/test.txt", "utf8"); - - expect(result).toBe("Hello"); - }); - - it("should read as base64", async () => { - const fs = new InMemoryFs(); - - await fs.writeFile("/test.txt", "Hello"); - const result = await fs.readFile("/test.txt", "base64"); - - expect(result).toBe("SGVsbG8="); - }); - - it("should write and read with hex encoding", async () => { - const fs = new InMemoryFs(); - - // "Hello" in hex is "48656c6c6f" - await fs.writeFile("/test.txt", "48656c6c6f", "hex"); - const result = await fs.readFile("/test.txt", "utf8"); - - expect(result).toBe("Hello"); - }); - - it("should read as hex", async () => { - const fs = new InMemoryFs(); - - await fs.writeFile("/test.txt", "Hello"); - const result = await fs.readFile("/test.txt", "hex"); - - expect(result).toBe("48656c6c6f"); - }); - - it("should write with latin1 encoding", async () => { - const fs = new InMemoryFs(); - - // Latin1 character é is 0xe9 - await fs.writeFile("/test.txt", "café", "latin1"); - const buffer = await fs.readFileBuffer("/test.txt"); - - expect(buffer).toEqual(new Uint8Array([0x63, 0x61, 0x66, 0xe9])); - }); - - it("should support encoding in options object", async () => { - const fs = new InMemoryFs(); - - await fs.writeFile("/test.txt", "SGVsbG8=", { encoding: "base64" }); - const result = await fs.readFile("/test.txt", { encoding: "utf8" }); - - expect(result).toBe("Hello"); - }); - }); - - describe("appendFile with Buffer", () => { - it("should append Uint8Array to existing file", async () => { - const fs = new InMemoryFs(); - - await fs.writeFile("/test.txt", "Hello"); - await fs.appendFile( - "/test.txt", - new Uint8Array([0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64]), - ); // " World" - - const result = await fs.readFile("/test.txt"); - expect(result).toBe("Hello World"); - }); - - it("should append string to file with Buffer content", async () => { - const fs = new InMemoryFs(); - const initial = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" - - await fs.writeFile("/test.txt", initial); - await fs.appendFile("/test.txt", " World"); - - const result = await fs.readFile("/test.txt"); - expect(result).toBe("Hello World"); - }); - - it("should append with encoding", async () => { - const fs = new InMemoryFs(); - - await fs.writeFile("/test.txt", "Hello"); - // " World" in base64 is "IFdvcmxk" - await fs.appendFile("/test.txt", "IFdvcmxk", "base64"); - - const result = await fs.readFile("/test.txt"); - expect(result).toBe("Hello World"); - }); - }); - - describe("constructor with Buffer content", () => { - it("should initialize files with Uint8Array content", async () => { - const fs = new InMemoryFs({ - "/binary.bin": new Uint8Array([0x00, 0x01, 0x02]), - "/text.txt": "Hello", - }); - - const binary = await fs.readFileBuffer("/binary.bin"); - const text = await fs.readFile("/text.txt"); - - expect(binary).toEqual(new Uint8Array([0x00, 0x01, 0x02])); - expect(text).toBe("Hello"); - }); - }); - - describe("edge cases", () => { - it("should handle empty Uint8Array", async () => { - const fs = new InMemoryFs(); - - await fs.writeFile("/empty.bin", new Uint8Array(0)); - const result = await fs.readFileBuffer("/empty.bin"); - - expect(result).toEqual(new Uint8Array(0)); - expect(result.length).toBe(0); - }); - - it("should handle large binary files", async () => { - const fs = new InMemoryFs(); - const size = 1024 * 1024; // 1MB - const data = new Uint8Array(size); - for (let i = 0; i < size; i++) { - data[i] = i % 256; - } - - await fs.writeFile("/large.bin", data); - const result = await fs.readFileBuffer("/large.bin"); - - expect(result.length).toBe(size); - expect(result[0]).toBe(0); - expect(result[255]).toBe(255); - expect(result[256]).toBe(0); - }); - - it("should preserve binary content through copy", async () => { - const fs = new InMemoryFs(); - const data = new Uint8Array([0x00, 0xff, 0x00, 0xff]); - - await fs.writeFile("/src.bin", data); - await fs.cp("/src.bin", "/dst.bin"); - - const result = await fs.readFileBuffer("/dst.bin"); - expect(result).toEqual(data); - }); - - it("should follow symlinks for binary files", async () => { - const fs = new InMemoryFs(); - const data = new Uint8Array([0x48, 0x69]); - - await fs.writeFile("/real.bin", data); - await fs.symlink("/real.bin", "/link.bin"); - - const result = await fs.readFileBuffer("/link.bin"); - expect(result).toEqual(data); - }); - }); -}); - -describe("InMemoryFs readdirWithFileTypes", () => { - it("should return entries with correct type info", async () => { - const fs = new InMemoryFs({ - "/dir/file.txt": "content", - "/dir/subdir/nested.txt": "nested", - }); - - const entries = await fs.readdirWithFileTypes("/dir"); - - expect(entries).toHaveLength(2); - - const file = entries.find((e) => e.name === "file.txt"); - expect(file).toBeDefined(); - expect(file?.isFile).toBe(true); - expect(file?.isDirectory).toBe(false); - expect(file?.isSymbolicLink).toBe(false); - - const subdir = entries.find((e) => e.name === "subdir"); - expect(subdir).toBeDefined(); - expect(subdir?.isFile).toBe(false); - expect(subdir?.isDirectory).toBe(true); - expect(subdir?.isSymbolicLink).toBe(false); - }); - - it("should return entries sorted case-sensitively", async () => { - const fs = new InMemoryFs({ - "/dir/Zebra.txt": "z", - "/dir/apple.txt": "a", - "/dir/Banana.txt": "b", - }); - - const entries = await fs.readdirWithFileTypes("/dir"); - const names = entries.map((e) => e.name); - - // Case-sensitive sort: uppercase before lowercase - expect(names).toEqual(["Banana.txt", "Zebra.txt", "apple.txt"]); - }); - - it("should identify symlinks correctly", async () => { - const fs = new InMemoryFs({ - "/dir/real.txt": "content", - }); - await fs.symlink("/dir/real.txt", "/dir/link.txt"); - - const entries = await fs.readdirWithFileTypes("/dir"); - - const link = entries.find((e) => e.name === "link.txt"); - expect(link).toBeDefined(); - expect(link?.isFile).toBe(false); - expect(link?.isDirectory).toBe(false); - expect(link?.isSymbolicLink).toBe(true); - }); - - it("should throw ENOENT for non-existent directory", async () => { - const fs = new InMemoryFs(); - - await expect(fs.readdirWithFileTypes("/nonexistent")).rejects.toThrow( - "ENOENT", - ); - }); - - it("should throw ENOTDIR for file path", async () => { - const fs = new InMemoryFs({ - "/file.txt": "content", - }); - - await expect(fs.readdirWithFileTypes("/file.txt")).rejects.toThrow( - "ENOTDIR", - ); - }); - - it("should return same names as readdir", async () => { - const fs = new InMemoryFs({ - "/dir/a.txt": "a", - "/dir/b.txt": "b", - "/dir/sub/c.txt": "c", - }); - - const namesFromReaddir = await fs.readdir("/dir"); - const entriesWithTypes = await fs.readdirWithFileTypes("/dir"); - const namesFromWithTypes = entriesWithTypes.map((e) => e.name); - - expect(namesFromWithTypes).toEqual(namesFromReaddir); - }); -}); diff --git a/src/fs/in-memory-fs/in-memory-fs.ts b/src/fs/in-memory-fs/in-memory-fs.ts deleted file mode 100644 index 01195b99..00000000 --- a/src/fs/in-memory-fs/in-memory-fs.ts +++ /dev/null @@ -1,714 +0,0 @@ -import { fromBuffer, getEncoding, toBuffer } from "../encoding.js"; -import type { - BufferEncoding, - CpOptions, - DirectoryEntry, - DirentEntry, - FileContent, - FileEntry, - FileInit, - FsEntry, - FsStat, - IFileSystem, - InitialFiles, - MkdirOptions, - ReadFileOptions, - RmOptions, - SymlinkEntry, - WriteFileOptions, -} from "../interface.js"; - -// Re-export for backwards compatibility -export type { - BufferEncoding, - FileContent, - FileEntry, - DirectoryEntry, - SymlinkEntry, - FsEntry, - FsStat, - IFileSystem, -}; - -export interface FsData { - [path: string]: FsEntry; -} - -// Text encoder for legacy string content conversion -const textEncoder = new TextEncoder(); - -/** - * Type guard to check if a value is a FileInit object - */ -function isFileInit(value: FileContent | FileInit): value is FileInit { - return ( - typeof value === "object" && - value !== null && - !(value instanceof Uint8Array) && - "content" in value - ); -} - -/** - * Validate that a path does not contain null bytes. - * Null bytes in paths can be used to truncate filenames or bypass security filters. - */ -function validatePath(path: string, operation: string): void { - if (path.includes("\0")) { - throw new Error(`ENOENT: path contains null byte, ${operation} '${path}'`); - } -} - -export class InMemoryFs implements IFileSystem { - private data: Map = new Map(); - - constructor(initialFiles?: InitialFiles) { - // Create root directory - this.data.set("/", { type: "directory", mode: 0o755, mtime: new Date() }); - - if (initialFiles) { - for (const [path, value] of Object.entries(initialFiles)) { - if (isFileInit(value)) { - // Extended init with metadata - this.writeFileSync(path, value.content, undefined, { - mode: value.mode, - mtime: value.mtime, - }); - } else { - // Simple content - this.writeFileSync(path, value); - } - } - } - } - - private normalizePath(path: string): string { - // Handle empty or just slash - if (!path || path === "/") return "/"; - - // Remove trailing slash - let normalized = - path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path; - - // Ensure starts with / - if (!normalized.startsWith("/")) { - normalized = `/${normalized}`; - } - - // Resolve . and .. - const parts = normalized.split("/").filter((p) => p && p !== "."); - const resolved: string[] = []; - - for (const part of parts) { - if (part === "..") { - resolved.pop(); - } else { - resolved.push(part); - } - } - - return `/${resolved.join("/")}` || "/"; - } - - private dirname(path: string): string { - const normalized = this.normalizePath(path); - if (normalized === "/") return "/"; - const lastSlash = normalized.lastIndexOf("/"); - return lastSlash === 0 ? "/" : normalized.slice(0, lastSlash); - } - - private ensureParentDirs(path: string): void { - const dir = this.dirname(path); - if (dir === "/") return; - - if (!this.data.has(dir)) { - this.ensureParentDirs(dir); - this.data.set(dir, { type: "directory", mode: 0o755, mtime: new Date() }); - } - } - - // Sync method for writing files - writeFileSync( - path: string, - content: FileContent, - options?: WriteFileOptions | BufferEncoding, - metadata?: { mode?: number; mtime?: Date }, - ): void { - validatePath(path, "write"); - const normalized = this.normalizePath(path); - this.ensureParentDirs(normalized); - - // Store content - convert to Uint8Array for internal storage - const encoding = getEncoding(options); - const buffer = toBuffer(content, encoding); - - this.data.set(normalized, { - type: "file", - content: buffer, - mode: metadata?.mode ?? 0o644, - mtime: metadata?.mtime ?? new Date(), - }); - } - - // Async public API - async readFile( - path: string, - options?: ReadFileOptions | BufferEncoding, - ): Promise { - const buffer = await this.readFileBuffer(path); - const encoding = getEncoding(options); - return fromBuffer(buffer, encoding); - } - - async readFileBuffer(path: string): Promise { - validatePath(path, "open"); - // Resolve all symlinks in the path (including intermediate components) - const resolvedPath = this.resolvePathWithSymlinks(path); - const entry = this.data.get(resolvedPath); - - if (!entry) { - throw new Error(`ENOENT: no such file or directory, open '${path}'`); - } - if (entry.type !== "file") { - throw new Error( - `EISDIR: illegal operation on a directory, read '${path}'`, - ); - } - - // Return content as Uint8Array - if (entry.content instanceof Uint8Array) { - return entry.content; - } - // Legacy string content - convert to Uint8Array - return textEncoder.encode(entry.content); - } - - async writeFile( - path: string, - content: FileContent, - options?: WriteFileOptions | BufferEncoding, - ): Promise { - this.writeFileSync(path, content, options); - } - - async appendFile( - path: string, - content: FileContent, - options?: WriteFileOptions | BufferEncoding, - ): Promise { - validatePath(path, "append"); - const normalized = this.normalizePath(path); - const existing = this.data.get(normalized); - - if (existing && existing.type === "directory") { - throw new Error( - `EISDIR: illegal operation on a directory, write '${path}'`, - ); - } - - const encoding = getEncoding(options); - const newBuffer = toBuffer(content, encoding); - - if (existing?.type === "file") { - // Get existing content as buffer - const existingBuffer = - existing.content instanceof Uint8Array - ? existing.content - : textEncoder.encode(existing.content); - - // Concatenate buffers - const combined = new Uint8Array(existingBuffer.length + newBuffer.length); - combined.set(existingBuffer); - combined.set(newBuffer, existingBuffer.length); - - this.data.set(normalized, { - type: "file", - content: combined, - mode: existing.mode, - mtime: new Date(), - }); - } else { - this.writeFileSync(path, content, options); - } - } - - async exists(path: string): Promise { - if (path.includes("\0")) { - return false; - } - try { - const resolvedPath = this.resolvePathWithSymlinks(path); - return this.data.has(resolvedPath); - } catch { - // Path resolution failed (e.g., broken symlink in path) - return false; - } - } - - async stat(path: string): Promise { - validatePath(path, "stat"); - // Resolve all symlinks in the path (including intermediate components) - const resolvedPath = this.resolvePathWithSymlinks(path); - const entry = this.data.get(resolvedPath); - - if (!entry) { - throw new Error(`ENOENT: no such file or directory, stat '${path}'`); - } - - // Calculate size: for files, it's the byte length; for directories, it's 0 - let size = 0; - if (entry.type === "file" && entry.content) { - if (entry.content instanceof Uint8Array) { - size = entry.content.length; - } else { - // Legacy string content - calculate byte length - size = textEncoder.encode(entry.content).length; - } - } - - return { - isFile: entry.type === "file", - isDirectory: entry.type === "directory", - isSymbolicLink: false, // stat follows symlinks, so this is always false - mode: entry.mode, - size, - mtime: entry.mtime || new Date(), - }; - } - - async lstat(path: string): Promise { - validatePath(path, "lstat"); - // Resolve intermediate symlinks but NOT the final component - const resolvedPath = this.resolveIntermediateSymlinks(path); - const entry = this.data.get(resolvedPath); - - if (!entry) { - throw new Error(`ENOENT: no such file or directory, lstat '${path}'`); - } - - // For symlinks, return symlink info (don't follow) - if (entry.type === "symlink") { - return { - isFile: false, - isDirectory: false, - isSymbolicLink: true, - mode: entry.mode, - size: entry.target.length, - mtime: entry.mtime || new Date(), - }; - } - - // Calculate size: for files, it's the byte length; for directories, it's 0 - let size = 0; - if (entry.type === "file" && entry.content) { - if (entry.content instanceof Uint8Array) { - size = entry.content.length; - } else { - // Legacy string content - calculate byte length - size = textEncoder.encode(entry.content).length; - } - } - - return { - isFile: entry.type === "file", - isDirectory: entry.type === "directory", - isSymbolicLink: false, - mode: entry.mode, - size, - mtime: entry.mtime || new Date(), - }; - } - - // Helper to resolve symlink target paths - private resolveSymlink(symlinkPath: string, target: string): string { - if (target.startsWith("/")) { - return this.normalizePath(target); - } - // Relative target: resolve from symlink's directory - const dir = this.dirname(symlinkPath); - return this.normalizePath(dir === "/" ? `/${target}` : `${dir}/${target}`); - } - - /** - * Resolve symlinks in intermediate path components only (not the final component). - * Used by lstat which should not follow the final symlink. - */ - private resolveIntermediateSymlinks(path: string): string { - const normalized = this.normalizePath(path); - if (normalized === "/") return "/"; - - const parts = normalized.slice(1).split("/"); - if (parts.length <= 1) return normalized; // No intermediate components - - let resolvedPath = ""; - const seen = new Set(); - - // Process all but the last component - for (let i = 0; i < parts.length - 1; i++) { - const part = parts[i]; - resolvedPath = `${resolvedPath}/${part}`; - - let entry = this.data.get(resolvedPath); - let loopCount = 0; - const maxLoops = 40; - - while (entry && entry.type === "symlink" && loopCount < maxLoops) { - if (seen.has(resolvedPath)) { - throw new Error( - `ELOOP: too many levels of symbolic links, lstat '${path}'`, - ); - } - seen.add(resolvedPath); - resolvedPath = this.resolveSymlink(resolvedPath, entry.target); - entry = this.data.get(resolvedPath); - loopCount++; - } - - if (loopCount >= maxLoops) { - throw new Error( - `ELOOP: too many levels of symbolic links, lstat '${path}'`, - ); - } - } - - // Append the final component without resolving - return `${resolvedPath}/${parts[parts.length - 1]}`; - } - - /** - * Resolve all symlinks in a path, including intermediate components. - * For example: /home/user/linkdir/file.txt where linkdir is a symlink to "subdir" - * would resolve to /home/user/subdir/file.txt - */ - private resolvePathWithSymlinks(path: string): string { - const normalized = this.normalizePath(path); - if (normalized === "/") return "/"; - - const parts = normalized.slice(1).split("/"); - let resolvedPath = ""; - const seen = new Set(); - - for (const part of parts) { - resolvedPath = `${resolvedPath}/${part}`; - - // Check if this path component is a symlink - let entry = this.data.get(resolvedPath); - let loopCount = 0; - const maxLoops = 40; // Prevent infinite loops - - while (entry && entry.type === "symlink" && loopCount < maxLoops) { - if (seen.has(resolvedPath)) { - throw new Error( - `ELOOP: too many levels of symbolic links, open '${path}'`, - ); - } - seen.add(resolvedPath); - - // Resolve the symlink - resolvedPath = this.resolveSymlink(resolvedPath, entry.target); - entry = this.data.get(resolvedPath); - loopCount++; - } - - if (loopCount >= maxLoops) { - throw new Error( - `ELOOP: too many levels of symbolic links, open '${path}'`, - ); - } - } - - return resolvedPath; - } - - async mkdir(path: string, options?: MkdirOptions): Promise { - this.mkdirSync(path, options); - } - - /** - * Synchronous version of mkdir - */ - mkdirSync(path: string, options?: MkdirOptions): void { - validatePath(path, "mkdir"); - const normalized = this.normalizePath(path); - - if (this.data.has(normalized)) { - const entry = this.data.get(normalized); - if (entry?.type === "file") { - throw new Error(`EEXIST: file already exists, mkdir '${path}'`); - } - // Directory already exists - if (!options?.recursive) { - throw new Error(`EEXIST: directory already exists, mkdir '${path}'`); - } - return; // With -p, silently succeed if directory exists - } - - const parent = this.dirname(normalized); - if (parent !== "/" && !this.data.has(parent)) { - if (options?.recursive) { - this.mkdirSync(parent, { recursive: true }); - } else { - throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`); - } - } - - this.data.set(normalized, { - type: "directory", - mode: 0o755, - mtime: new Date(), - }); - } - - async readdir(path: string): Promise { - const entries = await this.readdirWithFileTypes(path); - return entries.map((e) => e.name); - } - - async readdirWithFileTypes(path: string): Promise { - validatePath(path, "scandir"); - let normalized = this.normalizePath(path); - let entry = this.data.get(normalized); - - if (!entry) { - throw new Error(`ENOENT: no such file or directory, scandir '${path}'`); - } - - // Follow symlinks to get to the actual directory - const seen = new Set(); - while (entry && entry.type === "symlink") { - if (seen.has(normalized)) { - throw new Error( - `ELOOP: too many levels of symbolic links, scandir '${path}'`, - ); - } - seen.add(normalized); - normalized = this.resolveSymlink(normalized, entry.target); - entry = this.data.get(normalized); - } - - if (!entry) { - throw new Error(`ENOENT: no such file or directory, scandir '${path}'`); - } - if (entry.type !== "directory") { - throw new Error(`ENOTDIR: not a directory, scandir '${path}'`); - } - - const prefix = normalized === "/" ? "/" : `${normalized}/`; - const entriesMap = new Map(); - - for (const [p, fsEntry] of this.data.entries()) { - if (p === normalized) continue; - if (p.startsWith(prefix)) { - const rest = p.slice(prefix.length); - const name = rest.split("/")[0]; - // Only add direct children (no nested paths) - if (name && !rest.includes("/", name.length) && !entriesMap.has(name)) { - entriesMap.set(name, { - name, - isFile: fsEntry.type === "file", - isDirectory: fsEntry.type === "directory", - isSymbolicLink: fsEntry.type === "symlink", - }); - } - } - } - - // Sort using default string comparison (case-sensitive) to match readdir behavior - return Array.from(entriesMap.values()).sort((a, b) => - a.name < b.name ? -1 : a.name > b.name ? 1 : 0, - ); - } - - async rm(path: string, options?: RmOptions): Promise { - validatePath(path, "rm"); - const normalized = this.normalizePath(path); - const entry = this.data.get(normalized); - - if (!entry) { - if (options?.force) return; - throw new Error(`ENOENT: no such file or directory, rm '${path}'`); - } - - if (entry.type === "directory") { - const children = await this.readdir(normalized); - if (children.length > 0) { - if (!options?.recursive) { - throw new Error(`ENOTEMPTY: directory not empty, rm '${path}'`); - } - for (const child of children) { - const childPath = - normalized === "/" ? `/${child}` : `${normalized}/${child}`; - await this.rm(childPath, options); - } - } - } - - this.data.delete(normalized); - } - - async cp(src: string, dest: string, options?: CpOptions): Promise { - validatePath(src, "cp"); - validatePath(dest, "cp"); - const srcNorm = this.normalizePath(src); - const destNorm = this.normalizePath(dest); - const srcEntry = this.data.get(srcNorm); - - if (!srcEntry) { - throw new Error(`ENOENT: no such file or directory, cp '${src}'`); - } - - if (srcEntry.type === "file") { - this.ensureParentDirs(destNorm); - this.data.set(destNorm, { ...srcEntry }); - } else if (srcEntry.type === "directory") { - if (!options?.recursive) { - throw new Error(`EISDIR: is a directory, cp '${src}'`); - } - await this.mkdir(destNorm, { recursive: true }); - const children = await this.readdir(srcNorm); - for (const child of children) { - const srcChild = srcNorm === "/" ? `/${child}` : `${srcNorm}/${child}`; - const destChild = - destNorm === "/" ? `/${child}` : `${destNorm}/${child}`; - await this.cp(srcChild, destChild, options); - } - } - } - - async mv(src: string, dest: string): Promise { - await this.cp(src, dest, { recursive: true }); - await this.rm(src, { recursive: true }); - } - - // Get all paths (useful for debugging/glob) - getAllPaths(): string[] { - return Array.from(this.data.keys()); - } - - // Resolve a path relative to a base - resolvePath(base: string, path: string): string { - if (path.startsWith("/")) { - return this.normalizePath(path); - } - const combined = base === "/" ? `/${path}` : `${base}/${path}`; - return this.normalizePath(combined); - } - - // Change file/directory permissions - async chmod(path: string, mode: number): Promise { - validatePath(path, "chmod"); - const normalized = this.normalizePath(path); - const entry = this.data.get(normalized); - - if (!entry) { - throw new Error(`ENOENT: no such file or directory, chmod '${path}'`); - } - - entry.mode = mode; - } - - // Create a symbolic link - async symlink(target: string, linkPath: string): Promise { - validatePath(linkPath, "symlink"); - const normalized = this.normalizePath(linkPath); - - if (this.data.has(normalized)) { - throw new Error(`EEXIST: file already exists, symlink '${linkPath}'`); - } - - this.ensureParentDirs(normalized); - this.data.set(normalized, { - type: "symlink", - target, - mode: 0o777, - mtime: new Date(), - }); - } - - // Create a hard link - async link(existingPath: string, newPath: string): Promise { - validatePath(existingPath, "link"); - validatePath(newPath, "link"); - const existingNorm = this.normalizePath(existingPath); - const newNorm = this.normalizePath(newPath); - - const entry = this.data.get(existingNorm); - if (!entry) { - throw new Error( - `ENOENT: no such file or directory, link '${existingPath}'`, - ); - } - - if (entry.type !== "file") { - throw new Error(`EPERM: operation not permitted, link '${existingPath}'`); - } - - if (this.data.has(newNorm)) { - throw new Error(`EEXIST: file already exists, link '${newPath}'`); - } - - this.ensureParentDirs(newNorm); - // For hard links, we create a copy (simulating inode sharing) - // In a real fs, they'd share the same inode - this.data.set(newNorm, { - type: "file", - content: entry.content, - mode: entry.mode, - mtime: entry.mtime, - }); - } - - // Read the target of a symbolic link - async readlink(path: string): Promise { - validatePath(path, "readlink"); - const normalized = this.normalizePath(path); - const entry = this.data.get(normalized); - - if (!entry) { - throw new Error(`ENOENT: no such file or directory, readlink '${path}'`); - } - - if (entry.type !== "symlink") { - throw new Error(`EINVAL: invalid argument, readlink '${path}'`); - } - - return entry.target; - } - - /** - * Resolve all symlinks in a path to get the canonical physical path. - * This is equivalent to POSIX realpath(). - */ - async realpath(path: string): Promise { - validatePath(path, "realpath"); - // resolvePathWithSymlinks already resolves all symlinks - const resolved = this.resolvePathWithSymlinks(path); - - // Verify the path exists - if (!this.data.has(resolved)) { - throw new Error(`ENOENT: no such file or directory, realpath '${path}'`); - } - - return resolved; - } - - /** - * Set access and modification times of a file - * @param path - The file path - * @param _atime - Access time (ignored, kept for API compatibility) - * @param mtime - Modification time - */ - async utimes(path: string, _atime: Date, mtime: Date): Promise { - validatePath(path, "utimes"); - const normalized = this.normalizePath(path); - const resolved = this.resolvePathWithSymlinks(normalized); - const entry = this.data.get(resolved); - - if (!entry) { - throw new Error(`ENOENT: no such file or directory, utimes '${path}'`); - } - - // Update mtime on the entry - entry.mtime = mtime; - } -} diff --git a/src/fs/in-memory-fs/index.ts b/src/fs/in-memory-fs/index.ts deleted file mode 100644 index 32c99f69..00000000 --- a/src/fs/in-memory-fs/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { InMemoryFs } from "./in-memory-fs.js"; diff --git a/src/fs/init.ts b/src/fs/init.ts deleted file mode 100644 index 5efc91fa..00000000 --- a/src/fs/init.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Filesystem Initialization - * - * Sets up the default filesystem structure for the bash environment - * including /dev, /proc, and common directories. - */ - -import { formatProcStatus, KERNEL_VERSION } from "../shell-metadata.js"; -import type { IFileSystem } from "./interface.js"; - -/** - * Interface for filesystems that support sync initialization - * (both InMemoryFs and OverlayFs implement these) - */ -interface SyncInitFs { - mkdirSync(path: string, options?: { recursive?: boolean }): void; - writeFileSync(path: string, content: string | Uint8Array): void; -} - -/** - * Check if filesystem supports sync initialization - */ -function isSyncInitFs(fs: IFileSystem): fs is IFileSystem & SyncInitFs { - const maybeFs = fs as unknown as Partial; - return ( - typeof maybeFs.mkdirSync === "function" && - typeof maybeFs.writeFileSync === "function" - ); -} - -/** - * Initialize common directories like /home/user and /tmp - */ -function initCommonDirectories( - fs: SyncInitFs, - useDefaultLayout: boolean, -): void { - // Always create /bin for PATH-based command resolution - fs.mkdirSync("/bin", { recursive: true }); - fs.mkdirSync("/usr/bin", { recursive: true }); - - // Create additional directories only for default layout - if (useDefaultLayout) { - fs.mkdirSync("/home/user", { recursive: true }); - fs.mkdirSync("/tmp", { recursive: true }); - } -} - -/** - * Initialize /dev with common device files - */ -function initDevFiles(fs: SyncInitFs): void { - fs.mkdirSync("/dev", { recursive: true }); - fs.writeFileSync("/dev/null", ""); - fs.writeFileSync("/dev/zero", new Uint8Array(0)); - fs.writeFileSync("/dev/stdin", ""); - fs.writeFileSync("/dev/stdout", ""); - fs.writeFileSync("/dev/stderr", ""); -} - -/** - * Initialize /proc with simulated process information - */ -function initProcFiles(fs: SyncInitFs): void { - fs.mkdirSync("/proc/self/fd", { recursive: true }); - - // Kernel version (from shared metadata) - fs.writeFileSync("/proc/version", `${KERNEL_VERSION}\n`); - - // Process info (from shared metadata) - fs.writeFileSync("/proc/self/exe", "/bin/bash"); - fs.writeFileSync("/proc/self/cmdline", "bash\0"); - fs.writeFileSync("/proc/self/comm", "bash\n"); - fs.writeFileSync("/proc/self/status", formatProcStatus()); - - // File descriptors - fs.writeFileSync("/proc/self/fd/0", "/dev/stdin"); - fs.writeFileSync("/proc/self/fd/1", "/dev/stdout"); - fs.writeFileSync("/proc/self/fd/2", "/dev/stderr"); -} - -/** - * Initialize the filesystem with standard directories and files - * Works with both InMemoryFs and OverlayFs (both write to memory) - */ -export function initFilesystem( - fs: IFileSystem, - useDefaultLayout: boolean, -): void { - // Initialize for filesystems that support sync methods (InMemoryFs and OverlayFs) - if (isSyncInitFs(fs)) { - initCommonDirectories(fs, useDefaultLayout); - initDevFiles(fs); - initProcFiles(fs); - } -} diff --git a/src/fs/interface.ts b/src/fs/interface.ts deleted file mode 100644 index 57da4511..00000000 --- a/src/fs/interface.ts +++ /dev/null @@ -1,274 +0,0 @@ -/** - * Supported buffer encodings - */ -export type BufferEncoding = - | "utf8" - | "utf-8" - | "ascii" - | "binary" - | "base64" - | "hex" - | "latin1"; - -/** - * File content can be string or Buffer - */ -export type FileContent = string | Uint8Array; - -/** - * Options for reading files - */ -export interface ReadFileOptions { - encoding?: BufferEncoding | null; -} - -/** - * Options for writing files - */ -export interface WriteFileOptions { - encoding?: BufferEncoding; -} - -/** - * File system entry types - */ -export interface FileEntry { - type: "file"; - content: string | Uint8Array; - mode: number; - mtime: Date; -} - -export interface DirectoryEntry { - type: "directory"; - mode: number; - mtime: Date; -} - -export interface SymlinkEntry { - type: "symlink"; - target: string; // The path this symlink points to - mode: number; - mtime: Date; -} - -export type FsEntry = FileEntry | DirectoryEntry | SymlinkEntry; - -/** - * Directory entry with type information (similar to Node's Dirent) - * Used by readdirWithFileTypes for efficient directory listing without stat calls - */ -export interface DirentEntry { - name: string; - isFile: boolean; - isDirectory: boolean; - isSymbolicLink: boolean; -} - -/** - * Stat result from the filesystem - */ -export interface FsStat { - isFile: boolean; - isDirectory: boolean; - isSymbolicLink: boolean; - mode: number; - size: number; - mtime: Date; -} - -/** - * Options for mkdir operation - */ -export interface MkdirOptions { - recursive?: boolean; -} - -/** - * Options for rm operation - */ -export interface RmOptions { - recursive?: boolean; - force?: boolean; -} - -/** - * Options for cp operation - */ -export interface CpOptions { - recursive?: boolean; -} - -/** - * Abstract filesystem interface that can be implemented by different backends. - * This allows BashEnv to work with: - * - InMemoryFs (in-memory, default) - * - Real filesystem (via node:fs) - * - Custom implementations (e.g., remote storage, browser IndexedDB) - */ -export interface IFileSystem { - // Note: Sync method are not supported and must not be added. - /** - * Read the contents of a file as a string (default: utf8) - * @throws Error if file doesn't exist or is a directory - */ - readFile( - path: string, - options?: ReadFileOptions | BufferEncoding, - ): Promise; - - /** - * Read the contents of a file as a Uint8Array (binary) - * @throws Error if file doesn't exist or is a directory - */ - readFileBuffer(path: string): Promise; - - /** - * Write content to a file, creating it if it doesn't exist - */ - writeFile( - path: string, - content: FileContent, - options?: WriteFileOptions | BufferEncoding, - ): Promise; - - /** - * Append content to a file, creating it if it doesn't exist - */ - appendFile( - path: string, - content: FileContent, - options?: WriteFileOptions | BufferEncoding, - ): Promise; - - /** - * Check if a path exists - */ - exists(path: string): Promise; - - /** - * Get file/directory information - * @throws Error if path doesn't exist - */ - stat(path: string): Promise; - - /** - * Create a directory - * @throws Error if parent doesn't exist (unless recursive) or path exists - */ - mkdir(path: string, options?: MkdirOptions): Promise; - - /** - * Read directory contents - * @returns Array of entry names (not full paths) - * @throws Error if path doesn't exist or is not a directory - */ - readdir(path: string): Promise; - - /** - * Read directory contents with file type information (optional) - * This is more efficient than readdir + stat for each entry - * @returns Array of DirentEntry objects with name and type - * @throws Error if path doesn't exist or is not a directory - */ - readdirWithFileTypes?(path: string): Promise; - - /** - * Remove a file or directory - * @throws Error if path doesn't exist (unless force) or directory not empty (unless recursive) - */ - rm(path: string, options?: RmOptions): Promise; - - /** - * Copy a file or directory - * @throws Error if source doesn't exist or trying to copy directory without recursive - */ - cp(src: string, dest: string, options?: CpOptions): Promise; - - /** - * Move/rename a file or directory - */ - mv(src: string, dest: string): Promise; - - /** - * Resolve a relative path against a base path - */ - resolvePath(base: string, path: string): string; - - /** - * Get all paths in the filesystem (useful for glob matching) - * Optional - implementations may return empty array if not supported - */ - getAllPaths(): string[]; - - /** - * Change file/directory permissions - * @throws Error if path doesn't exist - */ - chmod(path: string, mode: number): Promise; - - /** - * Create a symbolic link - * @param target - The path the symlink should point to - * @param linkPath - The path where the symlink will be created - * @throws Error if linkPath already exists - */ - symlink(target: string, linkPath: string): Promise; - - /** - * Create a hard link - * @param existingPath - The existing file to link to - * @param newPath - The path where the new link will be created - * @throws Error if existingPath doesn't exist or newPath already exists - */ - link(existingPath: string, newPath: string): Promise; - - /** - * Read the target of a symbolic link - * @throws Error if path doesn't exist or is not a symlink - */ - readlink(path: string): Promise; - - /** - * Get file/directory information without following symlinks - * @throws Error if path doesn't exist - */ - lstat(path: string): Promise; - - /** - * Resolve all symlinks in a path to get the canonical physical path. - * This is equivalent to POSIX realpath() - it resolves all symlinks - * in the path and returns the absolute physical path. - * Used by pwd -P and cd -P for symlink resolution. - * @throws Error if path doesn't exist or contains a broken symlink - */ - realpath(path: string): Promise; - - /** - * Set access and modification times of a file - * @param path - The file path - * @param atime - Access time (currently ignored, kept for API compatibility) - * @param mtime - Modification time - * @throws Error if path doesn't exist - */ - utimes(path: string, atime: Date, mtime: Date): Promise; -} - -/** - * Extended file initialization options with optional metadata - */ -export interface FileInit { - content: FileContent; - mode?: number; - mtime?: Date; -} - -/** - * Initial files can be simple content or extended options with metadata - */ -export type InitialFiles = Record; - -/** - * Factory function type for creating filesystem instances - */ -export type FileSystemFactory = (initialFiles?: InitialFiles) => IFileSystem; diff --git a/src/fs/mountable-fs/index.ts b/src/fs/mountable-fs/index.ts deleted file mode 100644 index 3d3b0b49..00000000 --- a/src/fs/mountable-fs/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - MountableFs, - type MountableFsOptions, - type MountConfig, -} from "./mountable-fs.js"; diff --git a/src/fs/mountable-fs/mountable-fs.test.ts b/src/fs/mountable-fs/mountable-fs.test.ts deleted file mode 100644 index 25c0bdde..00000000 --- a/src/fs/mountable-fs/mountable-fs.test.ts +++ /dev/null @@ -1,665 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { InMemoryFs } from "../in-memory-fs/in-memory-fs.js"; -import { MountableFs } from "./mountable-fs.js"; - -describe("MountableFs", () => { - describe("mount/unmount operations", () => { - it("should mount a filesystem at a path", () => { - const fs = new MountableFs(); - const mounted = new InMemoryFs(); - - fs.mount("/mnt/data", mounted); - - expect(fs.isMountPoint("/mnt/data")).toBe(true); - expect(fs.getMounts()).toHaveLength(1); - expect(fs.getMounts()[0].mountPoint).toBe("/mnt/data"); - }); - - it("should unmount a filesystem", () => { - const fs = new MountableFs(); - const mounted = new InMemoryFs(); - - fs.mount("/mnt/data", mounted); - fs.unmount("/mnt/data"); - - expect(fs.isMountPoint("/mnt/data")).toBe(false); - expect(fs.getMounts()).toHaveLength(0); - }); - - it("should throw when unmounting non-existent mount", () => { - const fs = new MountableFs(); - - expect(() => fs.unmount("/mnt/data")).toThrow( - "No filesystem mounted at '/mnt/data'", - ); - }); - - it("should allow remounting at same path", () => { - const fs = new MountableFs(); - const mounted1 = new InMemoryFs({ "/file1.txt": "first" }); - const mounted2 = new InMemoryFs({ "/file2.txt": "second" }); - - fs.mount("/mnt/data", mounted1); - fs.mount("/mnt/data", mounted2); - - expect(fs.getMounts()).toHaveLength(1); - }); - - it("should support construction-time mounts", () => { - const mounted = new InMemoryFs({ "/test.txt": "hello" }); - const fs = new MountableFs({ - mounts: [{ mountPoint: "/mnt/data", filesystem: mounted }], - }); - - expect(fs.isMountPoint("/mnt/data")).toBe(true); - }); - - it("should use provided baseFs", async () => { - const base = new InMemoryFs({ "/base.txt": "base content" }); - const fs = new MountableFs({ base }); - - const content = await fs.readFile("/base.txt"); - expect(content).toBe("base content"); - }); - }); - - describe("mount validation", () => { - it("should prevent mounting at root", () => { - const fs = new MountableFs(); - const mounted = new InMemoryFs(); - - expect(() => fs.mount("/", mounted)).toThrow("Cannot mount at root '/'"); - }); - - it("should prevent nested mounts (new inside existing)", () => { - const fs = new MountableFs(); - const mounted1 = new InMemoryFs(); - const mounted2 = new InMemoryFs(); - - fs.mount("/mnt", mounted1); - - expect(() => fs.mount("/mnt/sub", mounted2)).toThrow( - "Cannot mount at '/mnt/sub': inside existing mount '/mnt'", - ); - }); - - it("should prevent nested mounts (existing inside new)", () => { - const fs = new MountableFs(); - const mounted1 = new InMemoryFs(); - const mounted2 = new InMemoryFs(); - - fs.mount("/mnt/sub", mounted1); - - expect(() => fs.mount("/mnt", mounted2)).toThrow( - "Cannot mount at '/mnt': would contain existing mount '/mnt/sub'", - ); - }); - - it("should allow sibling mounts", () => { - const fs = new MountableFs(); - const mounted1 = new InMemoryFs(); - const mounted2 = new InMemoryFs(); - - fs.mount("/mnt/a", mounted1); - fs.mount("/mnt/b", mounted2); - - expect(fs.getMounts()).toHaveLength(2); - }); - - it("should reject mount points with . or .. segments", () => { - const fs = new MountableFs(); - const mounted = new InMemoryFs(); - - expect(() => fs.mount("/mnt/../data", mounted)).toThrow( - "contains '.' or '..'", - ); - expect(() => fs.mount("/mnt/./data", mounted)).toThrow( - "contains '.' or '..'", - ); - expect(() => fs.mount("/./mnt", mounted)).toThrow("contains '.' or '..'"); - expect(() => fs.mount("/../mnt", mounted)).toThrow( - "contains '.' or '..'", - ); - }); - }); - - describe("path routing", () => { - it("should route to mounted filesystem", async () => { - const mounted = new InMemoryFs({ "/test.txt": "mounted content" }); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - const content = await fs.readFile("/mnt/data/test.txt"); - expect(content).toBe("mounted content"); - }); - - it("should route to base filesystem for unmounted paths", async () => { - const base = new InMemoryFs({ "/base.txt": "base content" }); - const fs = new MountableFs({ base }); - - const content = await fs.readFile("/base.txt"); - expect(content).toBe("base content"); - }); - - it("should route writes to mounted filesystem", async () => { - const mounted = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - await fs.writeFile("/mnt/data/test.txt", "hello"); - - // Verify written to mounted fs - const content = await mounted.readFile("/test.txt"); - expect(content).toBe("hello"); - }); - - it("should handle mount point root correctly", async () => { - const mounted = new InMemoryFs({ "/file.txt": "content" }); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - const exists = await fs.exists("/mnt/data"); - expect(exists).toBe(true); - - const stat = await fs.stat("/mnt/data"); - expect(stat.isDirectory).toBe(true); - }); - }); - - describe("directory operations", () => { - it("should list mount points as directories", async () => { - const mounted = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - const entries = await fs.readdir("/mnt"); - expect(entries).toContain("data"); - }); - - it("should merge mount points with base fs entries", async () => { - const base = new InMemoryFs(); - await base.mkdir("/mnt", { recursive: true }); - await base.writeFile("/mnt/base.txt", "base"); - - const mounted = new InMemoryFs({ "/mounted.txt": "mounted" }); - const fs = new MountableFs({ base }); - fs.mount("/mnt/data", mounted); - - const entries = await fs.readdir("/mnt"); - expect(entries).toContain("base.txt"); - expect(entries).toContain("data"); - }); - - it("should list entries from mounted filesystem", async () => { - const mounted = new InMemoryFs({ - "/a.txt": "a", - "/b.txt": "b", - }); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - const entries = await fs.readdir("/mnt/data"); - expect(entries).toContain("a.txt"); - expect(entries).toContain("b.txt"); - }); - - it("should create directories in mounted filesystem", async () => { - const mounted = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - await fs.mkdir("/mnt/data/subdir"); - - const exists = await mounted.exists("/subdir"); - expect(exists).toBe(true); - }); - - it("should handle mkdir at mount point with recursive", async () => { - const mounted = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - // Should not throw with recursive - await fs.mkdir("/mnt/data", { recursive: true }); - }); - - it("should throw when mkdir at mount point without recursive", async () => { - const mounted = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - await expect(fs.mkdir("/mnt/data")).rejects.toThrow("EEXIST"); - }); - }); - - describe("rm operations", () => { - it("should remove files from mounted filesystem", async () => { - const mounted = new InMemoryFs({ "/test.txt": "content" }); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - await fs.rm("/mnt/data/test.txt"); - - const exists = await mounted.exists("/test.txt"); - expect(exists).toBe(false); - }); - - it("should throw when removing mount point", async () => { - const mounted = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - await expect(fs.rm("/mnt/data")).rejects.toThrow("EBUSY: mount point"); - }); - - it("should throw when removing directory containing mount points", async () => { - const mounted = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - await expect(fs.rm("/mnt", { recursive: true })).rejects.toThrow( - "EBUSY: contains mount points", - ); - }); - }); - - describe("cross-mount copy", () => { - it("should copy file from mounted to base", async () => { - const mounted = new InMemoryFs({ "/src.txt": "content" }); - const base = new InMemoryFs(); - const fs = new MountableFs({ base }); - fs.mount("/mnt/data", mounted); - - await fs.cp("/mnt/data/src.txt", "/dest.txt"); - - const content = await base.readFile("/dest.txt"); - expect(content).toBe("content"); - }); - - it("should copy file from base to mounted", async () => { - const mounted = new InMemoryFs(); - const base = new InMemoryFs({ "/src.txt": "content" }); - const fs = new MountableFs({ base }); - fs.mount("/mnt/data", mounted); - - await fs.cp("/src.txt", "/mnt/data/dest.txt"); - - const content = await mounted.readFile("/dest.txt"); - expect(content).toBe("content"); - }); - - it("should copy between different mounts", async () => { - const mount1 = new InMemoryFs({ "/src.txt": "content" }); - const mount2 = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/a", mount1); - fs.mount("/mnt/b", mount2); - - await fs.cp("/mnt/a/src.txt", "/mnt/b/dest.txt"); - - const content = await mount2.readFile("/dest.txt"); - expect(content).toBe("content"); - }); - - it("should copy directory recursively across mounts", async () => { - const mount1 = new InMemoryFs(); - await mount1.mkdir("/dir"); - await mount1.writeFile("/dir/a.txt", "a"); - await mount1.writeFile("/dir/b.txt", "b"); - - const mount2 = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/a", mount1); - fs.mount("/mnt/b", mount2); - - await fs.cp("/mnt/a/dir", "/mnt/b/dir", { recursive: true }); - - expect(await mount2.readFile("/dir/a.txt")).toBe("a"); - expect(await mount2.readFile("/dir/b.txt")).toBe("b"); - }); - - it("should preserve file mode on cross-mount copy", async () => { - const mount1 = new InMemoryFs(); - await mount1.writeFile("/script.sh", "#!/bin/bash"); - await mount1.chmod("/script.sh", 0o755); - - const mount2 = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/a", mount1); - fs.mount("/mnt/b", mount2); - - await fs.cp("/mnt/a/script.sh", "/mnt/b/script.sh"); - - const stat = await mount2.stat("/script.sh"); - expect(stat.mode).toBe(0o755); - }); - - it("should copy within same mount using native cp", async () => { - const mounted = new InMemoryFs({ "/src.txt": "content" }); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - await fs.cp("/mnt/data/src.txt", "/mnt/data/dest.txt"); - - const content = await mounted.readFile("/dest.txt"); - expect(content).toBe("content"); - }); - }); - - describe("cross-mount move", () => { - it("should move file across mounts", async () => { - const mount1 = new InMemoryFs({ "/src.txt": "content" }); - const mount2 = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/a", mount1); - fs.mount("/mnt/b", mount2); - - await fs.mv("/mnt/a/src.txt", "/mnt/b/dest.txt"); - - expect(await mount2.readFile("/dest.txt")).toBe("content"); - expect(await mount1.exists("/src.txt")).toBe(false); - }); - - it("should throw when moving mount point", async () => { - const mounted = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - await expect(fs.mv("/mnt/data", "/mnt/other")).rejects.toThrow( - "EBUSY: mount point", - ); - }); - - it("should move within same mount using native mv", async () => { - const mounted = new InMemoryFs({ "/src.txt": "content" }); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - await fs.mv("/mnt/data/src.txt", "/mnt/data/dest.txt"); - - expect(await mounted.readFile("/dest.txt")).toBe("content"); - expect(await mounted.exists("/src.txt")).toBe(false); - }); - }); - - describe("getAllPaths", () => { - it("should return paths from base filesystem", () => { - const base = new InMemoryFs({ - "/a.txt": "a", - "/b.txt": "b", - }); - const fs = new MountableFs({ base }); - - const paths = fs.getAllPaths(); - expect(paths).toContain("/a.txt"); - expect(paths).toContain("/b.txt"); - }); - - it("should return paths from mounted filesystems with prefix", () => { - const mounted = new InMemoryFs({ - "/file.txt": "content", - }); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - const paths = fs.getAllPaths(); - expect(paths).toContain("/mnt/data/file.txt"); - }); - - it("should include mount point parent directories", () => { - const mounted = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - const paths = fs.getAllPaths(); - expect(paths).toContain("/mnt"); - expect(paths).toContain("/mnt/data"); - }); - - it("should handle multiple mounts", () => { - const mount1 = new InMemoryFs({ "/a.txt": "a" }); - const mount2 = new InMemoryFs({ "/b.txt": "b" }); - const fs = new MountableFs(); - fs.mount("/mnt/a", mount1); - fs.mount("/mnt/b", mount2); - - const paths = fs.getAllPaths(); - expect(paths).toContain("/mnt/a/a.txt"); - expect(paths).toContain("/mnt/b/b.txt"); - }); - }); - - describe("symlink operations", () => { - it("should create symlink in mounted filesystem", async () => { - const mounted = new InMemoryFs({ "/target.txt": "content" }); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - await fs.symlink("/target.txt", "/mnt/data/link.txt"); - - const target = await mounted.readlink("/link.txt"); - expect(target).toBe("/target.txt"); - }); - - it("should follow symlinks within same mount", async () => { - const mounted = new InMemoryFs(); - await mounted.writeFile("/target.txt", "content"); - await mounted.symlink("/target.txt", "/link.txt"); - - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - // Reading the link should resolve to target within same mount - const content = await fs.readFile("/mnt/data/link.txt"); - expect(content).toBe("content"); - }); - - it("should read symlink target via readlink across mounts", async () => { - const mount1 = new InMemoryFs({ "/target.txt": "content" }); - const mount2 = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/a", mount1); - fs.mount("/mnt/b", mount2); - - // Create a symlink in mount2 pointing to file in mount1 - await mount2.symlink("/mnt/a/target.txt", "/link.txt"); - - // readlink returns the target path (doesn't follow it) - const target = await fs.readlink("/mnt/b/link.txt"); - expect(target).toBe("/mnt/a/target.txt"); - - // To follow cross-mount symlinks, user must read target path explicitly - const content = await fs.readFile(target); - expect(content).toBe("content"); - }); - - it("should copy symlinks across mounts", async () => { - const mount1 = new InMemoryFs(); - await mount1.writeFile("/target.txt", "content"); - await mount1.symlink("/target.txt", "/link.txt"); - - const mount2 = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/a", mount1); - fs.mount("/mnt/b", mount2); - - await fs.cp("/mnt/a/link.txt", "/mnt/b/link.txt"); - - const target = await mount2.readlink("/link.txt"); - expect(target).toBe("/target.txt"); - }); - }); - - describe("hard link operations", () => { - it("should create hard link within same mount", async () => { - const mounted = new InMemoryFs({ "/original.txt": "content" }); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - await fs.link("/mnt/data/original.txt", "/mnt/data/hardlink.txt"); - - const content = await fs.readFile("/mnt/data/hardlink.txt"); - expect(content).toBe("content"); - }); - - it("should throw for cross-mount hard links", async () => { - const mount1 = new InMemoryFs({ "/file.txt": "content" }); - const mount2 = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/a", mount1); - fs.mount("/mnt/b", mount2); - - await expect( - fs.link("/mnt/a/file.txt", "/mnt/b/link.txt"), - ).rejects.toThrow("EXDEV: cross-device link not permitted"); - }); - }); - - describe("stat and exists", () => { - it("should stat mount point as directory", async () => { - const mounted = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - const stat = await fs.stat("/mnt/data"); - expect(stat.isDirectory).toBe(true); - }); - - it("should stat virtual parent directories of mounts", async () => { - const mounted = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - const stat = await fs.stat("/mnt"); - expect(stat.isDirectory).toBe(true); - }); - - it("should report mount points as existing", async () => { - const mounted = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - expect(await fs.exists("/mnt/data")).toBe(true); - expect(await fs.exists("/mnt")).toBe(true); - }); - - it("should stat files in mounted filesystem", async () => { - const mounted = new InMemoryFs({ "/test.txt": "hello" }); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - const stat = await fs.stat("/mnt/data/test.txt"); - expect(stat.isFile).toBe(true); - expect(stat.size).toBe(5); - }); - - it("should lstat files in mounted filesystem", async () => { - const mounted = new InMemoryFs(); - await mounted.writeFile("/target.txt", "content"); - await mounted.symlink("/target.txt", "/link.txt"); - - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - const stat = await fs.lstat("/mnt/data/link.txt"); - expect(stat.isSymbolicLink).toBe(true); - }); - }); - - describe("appendFile", () => { - it("should append to file in mounted filesystem", async () => { - const mounted = new InMemoryFs({ "/test.txt": "hello" }); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - await fs.appendFile("/mnt/data/test.txt", " world"); - - const content = await fs.readFile("/mnt/data/test.txt"); - expect(content).toBe("hello world"); - }); - }); - - describe("chmod", () => { - it("should chmod file in mounted filesystem", async () => { - const mounted = new InMemoryFs({ "/test.txt": "content" }); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - await fs.chmod("/mnt/data/test.txt", 0o755); - - const stat = await mounted.stat("/test.txt"); - expect(stat.mode).toBe(0o755); - }); - - it("should chmod mount point root", async () => { - const mounted = new InMemoryFs(); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - await fs.chmod("/mnt/data", 0o700); - - const stat = await mounted.stat("/"); - expect(stat.mode).toBe(0o700); - }); - }); - - describe("resolvePath", () => { - it("should resolve absolute paths", () => { - const fs = new MountableFs(); - const resolved = fs.resolvePath("/some/base", "/absolute/path"); - expect(resolved).toBe("/absolute/path"); - }); - - it("should resolve relative paths", () => { - const fs = new MountableFs(); - const resolved = fs.resolvePath("/some/base", "relative/path"); - expect(resolved).toBe("/some/base/relative/path"); - }); - - it("should handle . and .. in paths", () => { - const fs = new MountableFs(); - const resolved = fs.resolvePath("/some/base", "../other/./path"); - expect(resolved).toBe("/some/other/path"); - }); - }); - - describe("edge cases", () => { - it("should handle trailing slashes in mount points", () => { - const fs = new MountableFs(); - const mounted = new InMemoryFs(); - - fs.mount("/mnt/data/", mounted); - - expect(fs.isMountPoint("/mnt/data")).toBe(true); - expect(fs.isMountPoint("/mnt/data/")).toBe(true); - }); - - it("should handle paths without leading slash", async () => { - const mounted = new InMemoryFs({ "/test.txt": "content" }); - const fs = new MountableFs(); - fs.mount("mnt/data", mounted); - - const content = await fs.readFile("mnt/data/test.txt"); - expect(content).toBe("content"); - }); - - it("should normalize paths with . and ..", async () => { - const mounted = new InMemoryFs({ "/test.txt": "content" }); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - const content = await fs.readFile("/mnt/data/../data/./test.txt"); - expect(content).toBe("content"); - }); - - it("should handle empty base filesystem with mount", async () => { - const mounted = new InMemoryFs({ "/test.txt": "content" }); - const fs = new MountableFs(); - fs.mount("/mnt/data", mounted); - - // Base fs is empty but mount parent should still work - const entries = await fs.readdir("/mnt"); - expect(entries).toContain("data"); - }); - }); -}); diff --git a/src/fs/mountable-fs/mountable-fs.ts b/src/fs/mountable-fs/mountable-fs.ts deleted file mode 100644 index c30ef8d8..00000000 --- a/src/fs/mountable-fs/mountable-fs.ts +++ /dev/null @@ -1,683 +0,0 @@ -import { InMemoryFs } from "../in-memory-fs/in-memory-fs.js"; -import type { - BufferEncoding, - CpOptions, - FileContent, - FsStat, - IFileSystem, - MkdirOptions, - ReadFileOptions, - RmOptions, - WriteFileOptions, -} from "../interface.js"; - -/** - * Configuration for a mount point - */ -export interface MountConfig { - /** Virtual path where the filesystem is mounted */ - mountPoint: string; - /** The filesystem to mount at this path */ - filesystem: IFileSystem; -} - -/** - * Options for creating a MountableFs - */ -export interface MountableFsOptions { - /** Base filesystem used for unmounted paths (defaults to InMemoryFs) */ - base?: IFileSystem; - /** Initial mounts to configure */ - mounts?: MountConfig[]; -} - -/** - * Internal mount entry with normalized mount point - */ -interface MountEntry { - mountPoint: string; - filesystem: IFileSystem; -} - -/** - * A filesystem that supports mounting other filesystems at specific paths. - * - * This allows combining multiple filesystem backends into a unified namespace. - * For example, mounting a read-only knowledge base at /mnt/knowledge and a - * read-write workspace at /home/agent. - * - * @example - * ```typescript - * const fs = new MountableFs({ base: new InMemoryFs() }); - * fs.mount('/mnt/knowledge', new OverlayFs({ root: "/path/to/knowledge", readOnly: true })); - * fs.mount('/home/agent', new ReadWriteFs({ root: "/path/to/workspace" })); - * ``` - */ -export class MountableFs implements IFileSystem { - private baseFs: IFileSystem; - private mounts: Map = new Map(); - - constructor(options?: MountableFsOptions) { - this.baseFs = options?.base ?? new InMemoryFs(); - - // Add initial mounts - if (options?.mounts) { - for (const { mountPoint, filesystem } of options.mounts) { - this.mount(mountPoint, filesystem); - } - } - } - - /** - * Mount a filesystem at the specified virtual path. - * - * @param mountPoint - The virtual path where the filesystem will be accessible - * @param filesystem - The filesystem to mount - * @throws Error if mounting at root '/' or inside an existing mount - */ - mount(mountPoint: string, filesystem: IFileSystem): void { - // Validate original path first (before normalization) - this.validateMountPath(mountPoint); - - const normalized = this.normalizePath(mountPoint); - - // Validate mount point constraints - this.validateMount(normalized); - - this.mounts.set(normalized, { - mountPoint: normalized, - filesystem, - }); - } - - /** - * Unmount the filesystem at the specified path. - * - * @param mountPoint - The virtual path to unmount - * @throws Error if no filesystem is mounted at this path - */ - unmount(mountPoint: string): void { - const normalized = this.normalizePath(mountPoint); - - if (!this.mounts.has(normalized)) { - throw new Error(`No filesystem mounted at '${mountPoint}'`); - } - - this.mounts.delete(normalized); - } - - /** - * Get all current mounts. - */ - getMounts(): ReadonlyArray<{ mountPoint: string; filesystem: IFileSystem }> { - return Array.from(this.mounts.values()).map((entry) => ({ - mountPoint: entry.mountPoint, - filesystem: entry.filesystem, - })); - } - - /** - * Check if a path is exactly a mount point. - */ - isMountPoint(path: string): boolean { - const normalized = this.normalizePath(path); - return this.mounts.has(normalized); - } - - /** - * Validate mount path format before normalization. - * Rejects paths containing . or .. segments. - */ - private validateMountPath(mountPoint: string): void { - const segments = mountPoint.split("/"); - for (const segment of segments) { - if (segment === "." || segment === "..") { - throw new Error( - `Invalid mount point '${mountPoint}': contains '.' or '..' segments`, - ); - } - } - } - - /** - * Validate that a mount point is allowed. - */ - private validateMount(mountPoint: string): void { - // Cannot mount at root - if (mountPoint === "/") { - throw new Error("Cannot mount at root '/'"); - } - - // Check for nested mounts (but allow remounting at same path) - for (const existingMount of this.mounts.keys()) { - if (existingMount === mountPoint) { - // Remounting at same path is allowed (will replace) - continue; - } - - // Check if new mount is inside existing mount - if (mountPoint.startsWith(`${existingMount}/`)) { - throw new Error( - `Cannot mount at '${mountPoint}': inside existing mount '${existingMount}'`, - ); - } - - // Check if existing mount is inside new mount - if (existingMount.startsWith(`${mountPoint}/`)) { - throw new Error( - `Cannot mount at '${mountPoint}': would contain existing mount '${existingMount}'`, - ); - } - } - } - - /** - * Normalize a path to a consistent format. - */ - private normalizePath(path: string): string { - // Handle empty or just slash - if (!path || path === "/") return "/"; - - // Remove trailing slash - let normalized = - path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path; - - // Ensure starts with / - if (!normalized.startsWith("/")) { - normalized = `/${normalized}`; - } - - // Resolve . and .. - const parts = normalized.split("/").filter((p) => p && p !== "."); - const resolved: string[] = []; - - for (const part of parts) { - if (part === "..") { - resolved.pop(); - } else { - resolved.push(part); - } - } - - return `/${resolved.join("/")}`; - } - - /** - * Route a path to the appropriate filesystem. - * Returns the filesystem and the relative path within that filesystem. - */ - private routePath(path: string): { fs: IFileSystem; relativePath: string } { - const normalized = this.normalizePath(path); - - // Check for exact or prefix mount match - // We need to find the longest matching mount point - let bestMatch: MountEntry | null = null; - let bestMatchLength = 0; - - for (const entry of this.mounts.values()) { - const mp = entry.mountPoint; - - if (normalized === mp) { - // Exact match - return root of mounted filesystem - return { fs: entry.filesystem, relativePath: "/" }; - } - - if (normalized.startsWith(`${mp}/`)) { - // Prefix match - check if it's longer than previous best - if (mp.length > bestMatchLength) { - bestMatch = entry; - bestMatchLength = mp.length; - } - } - } - - if (bestMatch) { - const relativePath = normalized.slice(bestMatchLength); - return { - fs: bestMatch.filesystem, - relativePath: relativePath || "/", - }; - } - - // No mount found - use base filesystem - return { fs: this.baseFs, relativePath: normalized }; - } - - /** - * Get mount points that are immediate children of a directory. - */ - private getChildMountPoints(dirPath: string): string[] { - const normalized = this.normalizePath(dirPath); - const prefix = normalized === "/" ? "/" : `${normalized}/`; - const children: string[] = []; - - for (const mountPoint of this.mounts.keys()) { - if (mountPoint.startsWith(prefix)) { - const remainder = mountPoint.slice(prefix.length); - const childName = remainder.split("/")[0]; - if (childName && !children.includes(childName)) { - children.push(childName); - } - } - } - - return children; - } - - // ==================== IFileSystem Implementation ==================== - - async readFile( - path: string, - options?: ReadFileOptions | BufferEncoding, - ): Promise { - const { fs, relativePath } = this.routePath(path); - return fs.readFile(relativePath, options); - } - - async readFileBuffer(path: string): Promise { - const { fs, relativePath } = this.routePath(path); - return fs.readFileBuffer(relativePath); - } - - async writeFile( - path: string, - content: FileContent, - options?: WriteFileOptions | BufferEncoding, - ): Promise { - const { fs, relativePath } = this.routePath(path); - return fs.writeFile(relativePath, content, options); - } - - async appendFile( - path: string, - content: FileContent, - options?: WriteFileOptions | BufferEncoding, - ): Promise { - const { fs, relativePath } = this.routePath(path); - return fs.appendFile(relativePath, content, options); - } - - async exists(path: string): Promise { - const normalized = this.normalizePath(path); - - // Check if this is exactly a mount point - if (this.mounts.has(normalized)) { - return true; - } - - // Check if there are child mount points (making this a virtual directory) - const childMounts = this.getChildMountPoints(normalized); - if (childMounts.length > 0) { - return true; - } - - // Route to the appropriate filesystem - const { fs, relativePath } = this.routePath(path); - return fs.exists(relativePath); - } - - async stat(path: string): Promise { - const normalized = this.normalizePath(path); - - // Check if this is exactly a mount point - const mountEntry = this.mounts.get(normalized); - if (mountEntry) { - // Return stats from the root of the mounted filesystem - try { - return await mountEntry.filesystem.stat("/"); - } catch { - // Fallback to synthetic directory stats - return { - isFile: false, - isDirectory: true, - isSymbolicLink: false, - mode: 0o755, - size: 0, - mtime: new Date(), - }; - } - } - - // Check if there are child mount points (making this a virtual directory) - const childMounts = this.getChildMountPoints(normalized); - if (childMounts.length > 0) { - // Check if directory also exists in base fs - try { - const baseStat = await this.baseFs.stat(normalized); - return baseStat; - } catch { - // Virtual directory from mount points only - return { - isFile: false, - isDirectory: true, - isSymbolicLink: false, - mode: 0o755, - size: 0, - mtime: new Date(), - }; - } - } - - // Route to the appropriate filesystem - const { fs, relativePath } = this.routePath(path); - return fs.stat(relativePath); - } - - async lstat(path: string): Promise { - const normalized = this.normalizePath(path); - - // Check if this is exactly a mount point - const mountEntry = this.mounts.get(normalized); - if (mountEntry) { - // Return stats from the root of the mounted filesystem - try { - return await mountEntry.filesystem.lstat("/"); - } catch { - // Fallback to synthetic directory stats - return { - isFile: false, - isDirectory: true, - isSymbolicLink: false, - mode: 0o755, - size: 0, - mtime: new Date(), - }; - } - } - - // Check if there are child mount points (making this a virtual directory) - const childMounts = this.getChildMountPoints(normalized); - if (childMounts.length > 0) { - try { - return await this.baseFs.lstat(normalized); - } catch { - return { - isFile: false, - isDirectory: true, - isSymbolicLink: false, - mode: 0o755, - size: 0, - mtime: new Date(), - }; - } - } - - // Route to the appropriate filesystem - const { fs, relativePath } = this.routePath(path); - return fs.lstat(relativePath); - } - - async mkdir(path: string, options?: MkdirOptions): Promise { - const normalized = this.normalizePath(path); - - // Cannot create directory at mount point - if (this.mounts.has(normalized)) { - if (options?.recursive) { - return; // Silently succeed like mkdir -p - } - throw new Error(`EEXIST: directory already exists, mkdir '${path}'`); - } - - // Check if this would be a parent of a mount point - const childMounts = this.getChildMountPoints(normalized); - if (childMounts.length > 0 && options?.recursive) { - // Virtual parent directory of mounts - consider it exists - return; - } - - const { fs, relativePath } = this.routePath(path); - return fs.mkdir(relativePath, options); - } - - async readdir(path: string): Promise { - const normalized = this.normalizePath(path); - const entries = new Set(); - let readdirError: Error | null = null; - - // Get entries from the owning filesystem - const { fs, relativePath } = this.routePath(path); - try { - const fsEntries = await fs.readdir(relativePath); - for (const entry of fsEntries) { - entries.add(entry); - } - } catch (err) { - // Path might not exist in base FS if only mount points are there - const code = (err as { code?: string }).code; - const message = (err as { message?: string }).message || ""; - - if (code !== "ENOENT" && !message.includes("ENOENT")) { - throw err; - } - // Save error to throw later if no mount points provide entries - readdirError = err as Error; - } - - // Add mount points that are immediate children - const childMounts = this.getChildMountPoints(normalized); - for (const child of childMounts) { - entries.add(child); - } - - // If no entries found and we had an error, throw the original error - if (entries.size === 0 && readdirError && !this.mounts.has(normalized)) { - throw readdirError; - } - - return Array.from(entries).sort(); - } - - async rm(path: string, options?: RmOptions): Promise { - const normalized = this.normalizePath(path); - - // Cannot remove mount points - if (this.mounts.has(normalized)) { - throw new Error(`EBUSY: mount point, cannot remove '${path}'`); - } - - // Check if this contains mount points - const childMounts = this.getChildMountPoints(normalized); - if (childMounts.length > 0) { - throw new Error(`EBUSY: contains mount points, cannot remove '${path}'`); - } - - const { fs, relativePath } = this.routePath(path); - return fs.rm(relativePath, options); - } - - async cp(src: string, dest: string, options?: CpOptions): Promise { - const srcRoute = this.routePath(src); - const destRoute = this.routePath(dest); - - // If same filesystem, delegate directly - if (srcRoute.fs === destRoute.fs) { - return srcRoute.fs.cp( - srcRoute.relativePath, - destRoute.relativePath, - options, - ); - } - - // Cross-mount copy - return this.crossMountCopy(src, dest, options); - } - - async mv(src: string, dest: string): Promise { - const normalized = this.normalizePath(src); - - // Cannot move mount points - if (this.mounts.has(normalized)) { - throw new Error(`EBUSY: mount point, cannot move '${src}'`); - } - - const srcRoute = this.routePath(src); - const destRoute = this.routePath(dest); - - // If same filesystem, delegate directly - if (srcRoute.fs === destRoute.fs) { - return srcRoute.fs.mv(srcRoute.relativePath, destRoute.relativePath); - } - - // Cross-mount move: copy then delete - await this.cp(src, dest, { recursive: true }); - await this.rm(src, { recursive: true }); - } - - resolvePath(base: string, path: string): string { - if (path.startsWith("/")) { - return this.normalizePath(path); - } - const combined = base === "/" ? `/${path}` : `${base}/${path}`; - return this.normalizePath(combined); - } - - getAllPaths(): string[] { - const allPaths = new Set(); - - // Get paths from base filesystem - for (const p of this.baseFs.getAllPaths()) { - allPaths.add(p); - } - - // Add mount point directories and their parent paths - for (const mountPoint of this.mounts.keys()) { - // Add all parent directories of the mount point - const parts = mountPoint.split("/").filter(Boolean); - let current = ""; - for (const part of parts) { - current = `${current}/${part}`; - allPaths.add(current); - } - - // Get paths from mounted filesystem, prefixed with mount point - const entry = this.mounts.get(mountPoint); - if (!entry) continue; - for (const p of entry.filesystem.getAllPaths()) { - if (p === "/") { - allPaths.add(mountPoint); - } else { - allPaths.add(`${mountPoint}${p}`); - } - } - } - - return Array.from(allPaths).sort(); - } - - async chmod(path: string, mode: number): Promise { - const normalized = this.normalizePath(path); - - // Cannot chmod mount points directly - const mountEntry = this.mounts.get(normalized); - if (mountEntry) { - return mountEntry.filesystem.chmod("/", mode); - } - - const { fs, relativePath } = this.routePath(path); - return fs.chmod(relativePath, mode); - } - - async symlink(target: string, linkPath: string): Promise { - const { fs, relativePath } = this.routePath(linkPath); - return fs.symlink(target, relativePath); - } - - async link(existingPath: string, newPath: string): Promise { - const existingRoute = this.routePath(existingPath); - const newRoute = this.routePath(newPath); - - // Hard links must be within the same filesystem - if (existingRoute.fs !== newRoute.fs) { - throw new Error( - `EXDEV: cross-device link not permitted, link '${existingPath}' -> '${newPath}'`, - ); - } - - return existingRoute.fs.link( - existingRoute.relativePath, - newRoute.relativePath, - ); - } - - async readlink(path: string): Promise { - const { fs, relativePath } = this.routePath(path); - return fs.readlink(relativePath); - } - - /** - * Resolve all symlinks in a path to get the canonical physical path. - * This is equivalent to POSIX realpath(). - */ - async realpath(path: string): Promise { - const normalized = this.normalizePath(path); - - // Check if this is exactly a mount point - const mountEntry = this.mounts.get(normalized); - if (mountEntry) { - // Mount point itself - return the mount point path - return normalized; - } - - // Route to the appropriate filesystem - const { fs, relativePath } = this.routePath(path); - - // Get realpath from the underlying filesystem - const resolvedRelative = await fs.realpath(relativePath); - - // Find the mount point for this path - for (const [mp, _entry] of this.mounts) { - if (normalized === mp || normalized.startsWith(`${mp}/`)) { - // Path is within this mount - reconstruct full path - if (resolvedRelative === "/") { - return mp; - } - return `${mp}${resolvedRelative}`; - } - } - - // Path is in the base filesystem - return resolvedRelative; - } - - /** - * Perform a cross-mount copy operation. - */ - private async crossMountCopy( - src: string, - dest: string, - options?: CpOptions, - ): Promise { - const srcStat = await this.lstat(src); - - if (srcStat.isFile) { - const content = await this.readFileBuffer(src); - await this.writeFile(dest, content); - await this.chmod(dest, srcStat.mode); - } else if (srcStat.isDirectory) { - if (!options?.recursive) { - throw new Error(`cp: ${src} is a directory (not copied)`); - } - await this.mkdir(dest, { recursive: true }); - const children = await this.readdir(src); - for (const child of children) { - const srcChild = src === "/" ? `/${child}` : `${src}/${child}`; - const destChild = dest === "/" ? `/${child}` : `${dest}/${child}`; - await this.crossMountCopy(srcChild, destChild, options); - } - } else if (srcStat.isSymbolicLink) { - const target = await this.readlink(src); - await this.symlink(target, dest); - } - } - - /** - * Set access and modification times of a file - * @param path - The file path - * @param atime - Access time - * @param mtime - Modification time - */ - async utimes(path: string, atime: Date, mtime: Date): Promise { - const { fs, relativePath } = this.routePath(path); - return fs.utimes(relativePath, atime, mtime); - } -} diff --git a/src/fs/overlay-fs/index.ts b/src/fs/overlay-fs/index.ts deleted file mode 100644 index 742d7167..00000000 --- a/src/fs/overlay-fs/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { OverlayFs, type OverlayFsOptions } from "./overlay-fs.js"; diff --git a/src/fs/overlay-fs/overlay-fs.e2e.test.ts b/src/fs/overlay-fs/overlay-fs.e2e.test.ts deleted file mode 100644 index 8d4c697e..00000000 --- a/src/fs/overlay-fs/overlay-fs.e2e.test.ts +++ /dev/null @@ -1,613 +0,0 @@ -/** - * End-to-end tests for BashEnv with OverlayFs - * - * These tests verify that bash commands work correctly when - * operating on an OverlayFs-backed filesystem. - */ - -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; -import { OverlayFs } from "./overlay-fs.js"; - -describe("BashEnv with OverlayFs - E2E", () => { - let tempDir: string; - let overlay: OverlayFs; - let env: Bash; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "overlay-e2e-")); - overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - env = new Bash({ fs: overlay, cwd: "/" }); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - describe("file reading commands", () => { - beforeEach(() => { - fs.writeFileSync( - path.join(tempDir, "sample.txt"), - "line1\nline2\nline3\nline4\nline5", - ); - }); - - it("should read files with cat", async () => { - const result = await env.exec("cat /sample.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("line1\nline2\nline3\nline4\nline5"); - }); - - it("should read first lines with head", async () => { - const result = await env.exec("head -n 2 /sample.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("line1\nline2\n"); - }); - - it("should read last lines with tail", async () => { - const result = await env.exec("tail -n 2 /sample.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("line4\nline5\n"); - }); - - it("should count lines with wc", async () => { - const result = await env.exec("wc -l /sample.txt"); - expect(result.exitCode).toBe(0); - // wc output includes filename, just verify it ran successfully - expect(result.stdout).toContain("sample.txt"); - }); - - it("should read memory-written files", async () => { - await env.exec('echo "memory content" > /memory.txt'); - const result = await env.exec("cat /memory.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("memory content\n"); - }); - }); - - describe("file writing and modification", () => { - it("should write files without affecting real fs", async () => { - await env.exec('echo "test content" > /new-file.txt'); - - const result = await env.exec("cat /new-file.txt"); - expect(result.stdout).toBe("test content\n"); - - // Real fs should not have the file - expect(fs.existsSync(path.join(tempDir, "new-file.txt"))).toBe(false); - }); - - it("should append to files", async () => { - await env.exec('echo "first" > /append.txt'); - await env.exec('echo "second" >> /append.txt'); - - const result = await env.exec("cat /append.txt"); - expect(result.stdout).toBe("first\nsecond\n"); - }); - - it("should override real files in memory", async () => { - fs.writeFileSync(path.join(tempDir, "real.txt"), "original"); - - await env.exec('echo "modified" > /real.txt'); - - const result = await env.exec("cat /real.txt"); - expect(result.stdout).toBe("modified\n"); - - // Real file should be unchanged - expect(fs.readFileSync(path.join(tempDir, "real.txt"), "utf8")).toBe( - "original", - ); - }); - - it("should create files with touch", async () => { - await env.exec("touch /touched.txt"); - - const result = await env.exec("ls /touched.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("touched.txt"); - }); - - it("should truncate files", async () => { - await env.exec('echo "content" > /truncate.txt'); - await env.exec(": > /truncate.txt"); - - const result = await env.exec("cat /truncate.txt"); - expect(result.stdout).toBe(""); - }); - }); - - describe("directory operations", () => { - it("should list real directory contents", async () => { - fs.writeFileSync(path.join(tempDir, "a.txt"), "a"); - fs.writeFileSync(path.join(tempDir, "b.txt"), "b"); - fs.mkdirSync(path.join(tempDir, "subdir")); - - const result = await env.exec("ls /"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("a.txt"); - expect(result.stdout).toContain("b.txt"); - expect(result.stdout).toContain("subdir"); - }); - - it("should list mixed real and memory contents", async () => { - fs.writeFileSync(path.join(tempDir, "real.txt"), "real"); - - await env.exec('echo "memory" > /memory.txt'); - - const result = await env.exec("ls /"); - expect(result.stdout).toContain("real.txt"); - expect(result.stdout).toContain("memory.txt"); - }); - - it("should create directories with mkdir", async () => { - await env.exec("mkdir /newdir"); - - const result = await env.exec("ls -d /newdir"); - expect(result.exitCode).toBe(0); - - // Real fs should not have the directory - expect(fs.existsSync(path.join(tempDir, "newdir"))).toBe(false); - }); - - it("should create nested directories with mkdir -p", async () => { - await env.exec("mkdir -p /a/b/c"); - - await env.exec('echo "deep" > /a/b/c/file.txt'); - const result = await env.exec("cat /a/b/c/file.txt"); - expect(result.stdout).toBe("deep\n"); - }); - - it("should remove directories with rm -r", async () => { - await env.exec("mkdir /emptydir"); - const mkResult = await env.exec("ls -d /emptydir"); - expect(mkResult.exitCode).toBe(0); - - // Use rm -r to remove directory - await env.exec("rm -r /emptydir"); - - // Verify via ls that directory is gone - const lsResult = await env.exec("ls /emptydir 2>&1"); - expect(lsResult.exitCode).not.toBe(0); - }); - - it("should change working directory with cd", async () => { - fs.mkdirSync(path.join(tempDir, "workdir")); - fs.writeFileSync(path.join(tempDir, "workdir", "file.txt"), "content"); - - const result = await env.exec("cd /workdir && cat file.txt"); - expect(result.stdout).toBe("content"); - }); - - it("should show current directory with pwd", async () => { - const result = await env.exec("pwd"); - expect(result.stdout.trim()).toBe("/"); - - const result2 = await env.exec( - "cd /subdir 2>/dev/null || mkdir /subdir && cd /subdir && pwd", - ); - expect(result2.stdout.trim()).toBe("/subdir"); - }); - }); - - describe("file manipulation", () => { - beforeEach(() => { - fs.writeFileSync(path.join(tempDir, "source.txt"), "source content"); - }); - - it("should copy files with cp", async () => { - await env.exec("cp /source.txt /dest.txt"); - - const result = await env.exec("cat /dest.txt"); - expect(result.stdout).toBe("source content"); - - // Real fs should not have the copy - expect(fs.existsSync(path.join(tempDir, "dest.txt"))).toBe(false); - }); - - it("should move files with mv", async () => { - await env.exec("mv /source.txt /moved.txt"); - - const exists = await env.exec("cat /source.txt"); - expect(exists.exitCode).not.toBe(0); - - const result = await env.exec("cat /moved.txt"); - expect(result.stdout).toBe("source content"); - }); - - it("should remove files with rm", async () => { - await env.exec("rm /source.txt"); - - const result = await env.exec("cat /source.txt"); - expect(result.exitCode).not.toBe(0); - - // Real file should still exist - expect(fs.existsSync(path.join(tempDir, "source.txt"))).toBe(true); - }); - - it("should remove directories recursively with rm -r", async () => { - fs.mkdirSync(path.join(tempDir, "dir")); - fs.writeFileSync(path.join(tempDir, "dir", "file.txt"), "content"); - - await env.exec("rm -r /dir"); - - const result = await env.exec("ls /dir"); - expect(result.exitCode).not.toBe(0); - - // Real directory should still exist - expect(fs.existsSync(path.join(tempDir, "dir"))).toBe(true); - }); - - it("should copy directories recursively with cp -r", async () => { - fs.mkdirSync(path.join(tempDir, "srcdir")); - fs.writeFileSync(path.join(tempDir, "srcdir", "file.txt"), "nested"); - - await env.exec("cp -r /srcdir /destdir"); - - const result = await env.exec("cat /destdir/file.txt"); - expect(result.stdout).toBe("nested"); - }); - }); - - describe("text processing", () => { - beforeEach(() => { - fs.writeFileSync( - path.join(tempDir, "data.txt"), - "apple\nbanana\ncherry\napple\ndate", - ); - }); - - it("should filter with grep", async () => { - const result = await env.exec("grep apple /data.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("apple\napple\n"); - }); - - it("should count matches with grep -c", async () => { - const result = await env.exec("grep -c apple /data.txt"); - expect(result.stdout.trim()).toBe("2"); - }); - - it("should invert match with grep -v", async () => { - const result = await env.exec("grep -v apple /data.txt"); - expect(result.stdout).not.toContain("apple"); - expect(result.stdout).toContain("banana"); - }); - - it("should sort lines", async () => { - const result = await env.exec("sort /data.txt"); - expect(result.stdout).toBe("apple\napple\nbanana\ncherry\ndate\n"); - }); - - it("should get unique lines with uniq", async () => { - const result = await env.exec("sort /data.txt | uniq"); - expect(result.stdout).toBe("apple\nbanana\ncherry\ndate\n"); - }); - - it("should count unique lines with uniq -c", async () => { - const result = await env.exec("sort /data.txt | uniq -c"); - expect(result.stdout).toContain("2"); - expect(result.stdout).toContain("apple"); - }); - - it("should cut fields", async () => { - await env.exec('echo "a:b:c" > /fields.txt'); - const result = await env.exec("cut -d: -f2 /fields.txt"); - expect(result.stdout.trim()).toBe("b"); - }); - - it("should replace with sed", async () => { - const result = await env.exec("sed 's/apple/orange/g' /data.txt"); - expect(result.stdout).not.toContain("apple"); - expect(result.stdout).toContain("orange"); - }); - - it("should transform with tr", async () => { - const result = await env.exec("echo 'hello' | tr 'a-z' 'A-Z'"); - expect(result.stdout.trim()).toBe("HELLO"); - }); - }); - - describe("pipelines and redirections", () => { - it("should pipe between commands", async () => { - fs.writeFileSync(path.join(tempDir, "nums.txt"), "3\n1\n2"); - - const result = await env.exec("cat /nums.txt | sort"); - expect(result.stdout).toBe("1\n2\n3\n"); - }); - - it("should chain multiple pipes", async () => { - fs.writeFileSync( - path.join(tempDir, "words.txt"), - "cat\ndog\ncat\nbird\ndog\ncat", - ); - - const result = await env.exec( - "cat /words.txt | sort | uniq -c | sort -rn | head -n 1", - ); - expect(result.stdout).toContain("3"); - expect(result.stdout).toContain("cat"); - }); - - it("should redirect stdout to file", async () => { - await env.exec("echo hello > /out.txt"); - const result = await env.exec("cat /out.txt"); - expect(result.stdout).toBe("hello\n"); - }); - - it("should redirect stderr to file", async () => { - await env.exec("cat /nonexistent 2> /err.txt"); - const result = await env.exec("cat /err.txt"); - expect(result.stdout).toContain("No such file"); - }); - - it("should redirect both stdout and stderr", async () => { - // Simple test of stderr redirect working - await env.exec("cat /nonexistent 2> /err.txt"); - const errResult = await env.exec("cat /err.txt"); - expect(errResult.stdout.length).toBeGreaterThan(0); - }); - - it("should use here-strings", async () => { - const result = await env.exec('cat <<< "here string content"'); - expect(result.stdout).toBe("here string content\n"); - }); - }); - - describe("find command", () => { - beforeEach(() => { - fs.mkdirSync(path.join(tempDir, "findtest")); - fs.mkdirSync(path.join(tempDir, "findtest", "subdir")); - fs.writeFileSync(path.join(tempDir, "findtest", "file1.txt"), "a"); - fs.writeFileSync(path.join(tempDir, "findtest", "file2.log"), "b"); - fs.writeFileSync( - path.join(tempDir, "findtest", "subdir", "file3.txt"), - "c", - ); - }); - - it("should find files by name pattern", async () => { - const result = await env.exec('find /findtest -name "*.txt"'); - expect(result.stdout).toContain("file1.txt"); - expect(result.stdout).toContain("file3.txt"); - expect(result.stdout).not.toContain("file2.log"); - }); - - it("should find files by type", async () => { - const result = await env.exec("find /findtest -type d"); - expect(result.stdout).toContain("findtest"); - expect(result.stdout).toContain("subdir"); - }); - - it("should find files in memory and real fs", async () => { - await env.exec('echo "memory" > /findtest/memory.txt'); - - const result = await env.exec('find /findtest -name "*.txt"'); - expect(result.stdout).toContain("file1.txt"); - expect(result.stdout).toContain("file3.txt"); - expect(result.stdout).toContain("memory.txt"); - }); - - it("should not find deleted files", async () => { - await env.exec("rm /findtest/file1.txt"); - - const result = await env.exec('find /findtest -name "*.txt"'); - expect(result.stdout).not.toContain("file1.txt"); - expect(result.stdout).toContain("file3.txt"); - }); - }); - - describe("complex workflows", () => { - it("should process log files", async () => { - const logContent = [ - "2024-01-01 ERROR something failed", - "2024-01-01 INFO started", - "2024-01-02 ERROR another failure", - "2024-01-02 INFO completed", - "2024-01-03 ERROR third error", - ].join("\n"); - - fs.writeFileSync(path.join(tempDir, "app.log"), logContent); - - const result = await env.exec("grep ERROR /app.log | wc -l"); - expect(result.stdout.trim()).toBe("3"); - }); - - it("should build a project simulation", async () => { - // Create source files - await env.exec("mkdir -p /src /build"); - await env.exec('echo "console.log(1)" > /src/a.js'); - await env.exec('echo "console.log(2)" > /src/b.js'); - - // "Build" by concatenating - await env.exec("cat /src/*.js > /build/bundle.js"); - - const result = await env.exec("cat /build/bundle.js"); - expect(result.stdout).toContain("console.log(1)"); - expect(result.stdout).toContain("console.log(2)"); - - // Real fs should be clean - expect(fs.existsSync(path.join(tempDir, "src"))).toBe(false); - expect(fs.existsSync(path.join(tempDir, "build"))).toBe(false); - }); - - it("should simulate a deployment pipeline", async () => { - // Setup - fs.mkdirSync(path.join(tempDir, "app")); - fs.writeFileSync( - path.join(tempDir, "app", "config.json"), - '{"env": "dev"}', - ); - - // Modify config for production - await env.exec( - "sed 's/dev/prod/' /app/config.json > /app/config.prod.json", - ); - - // Verify - const result = await env.exec("cat /app/config.prod.json"); - expect(result.stdout).toContain("prod"); - - // Original unchanged - const original = await env.exec("cat /app/config.json"); - expect(original.stdout).toContain("dev"); - }); - - it("should handle data transformation pipeline", async () => { - const csvData = "name,age\nAlice,30\nBob,25\nCharlie,35"; - fs.writeFileSync(path.join(tempDir, "data.csv"), csvData); - - // Extract ages, sort, get oldest - const result = await env.exec( - "tail -n +2 /data.csv | cut -d, -f2 | sort -rn | head -n 1", - ); - expect(result.stdout.trim()).toBe("35"); - }); - }); - - describe("environment and variables", () => { - it("should use environment variables", async () => { - const envWithVars = new Bash({ - fs: overlay, - cwd: "/", - env: { MY_VAR: "test_value" }, - }); - - const result = await envWithVars.exec("echo $MY_VAR"); - expect(result.stdout.trim()).toBe("test_value"); - }); - - it("should export and use variables", async () => { - const result = await env.exec('export FOO=bar && echo "FOO is $FOO"'); - expect(result.stdout).toContain("FOO is bar"); - }); - - it("should use variables within same command", async () => { - // Variables persist within the same exec call - const result = await env.exec("export PERSIST=value && echo $PERSIST"); - expect(result.stdout.trim()).toBe("value"); - }); - }); - - describe("symlinks", () => { - it("should create and follow symlinks", async () => { - await env.exec('echo "target content" > /target.txt'); - await env.exec("ln -s /target.txt /link.txt"); - - const result = await env.exec("cat /link.txt"); - expect(result.stdout).toBe("target content\n"); - }); - - it("should verify symlink exists", async () => { - await env.exec('echo "target" > /target.txt'); - await env.exec("ln -s /target.txt /link.txt"); - - // Verify the symlink was created by reading through it - const result = await env.exec("cat /link.txt"); - expect(result.stdout).toBe("target\n"); - - // Verify we can list it - const lsResult = await env.exec("ls /link.txt"); - expect(lsResult.exitCode).toBe(0); - }); - - it("should read symlink target with readlink", async () => { - await env.exec("ln -s /some/path /mylink"); - - const result = await env.exec("readlink /mylink"); - expect(result.stdout.trim()).toBe("/some/path"); - }); - }); - - describe("file permissions", () => { - it("should change permissions with chmod", async () => { - await env.exec('echo "script" > /script.sh'); - await env.exec("chmod 755 /script.sh"); - - // Verify permissions changed via overlay API - const stat = await overlay.stat("/script.sh"); - expect(stat.mode & 0o777).toBe(0o755); - }); - - it("should stat files", async () => { - fs.writeFileSync(path.join(tempDir, "statme.txt"), "content"); - - const result = await env.exec("stat /statme.txt"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("statme.txt"); - }); - }); - - describe("error handling", () => { - it("should return non-zero exit code for missing files", async () => { - const result = await env.exec("cat /nonexistent.txt"); - expect(result.exitCode).not.toBe(0); - }); - - it("should return non-zero for invalid commands", async () => { - const result = await env.exec("invalidcommand123"); - expect(result.exitCode).not.toBe(0); - }); - - it("should handle command substitution errors gracefully", async () => { - const result = await env.exec("echo $(cat /nonexistent)"); - // Command should complete even if substitution fails - expect(result).toBeDefined(); - }); - - it("should have correct exit code for failed command", async () => { - // The exit code of a pipeline is the exit code of the last command - // Using a command that will fail at the end of the pipe - const result = await env.exec("echo test | cat /nonexistent"); - expect(result.exitCode).not.toBe(0); - }); - }); - - describe("isolation verification", () => { - it("should not modify real filesystem after complex operations", async () => { - // Perform many operations - await env.exec("mkdir -p /a/b/c"); - await env.exec('echo "1" > /a/file1.txt'); - await env.exec('echo "2" > /a/b/file2.txt'); - await env.exec('echo "3" > /a/b/c/file3.txt'); - await env.exec("cp -r /a /a-copy"); - await env.exec("mv /a-copy /a-moved"); - await env.exec("rm -r /a"); - - // Verify overlay state - const result = await env.exec("find /a-moved -type f"); - expect(result.stdout).toContain("file1.txt"); - expect(result.stdout).toContain("file2.txt"); - expect(result.stdout).toContain("file3.txt"); - - // Verify real fs is untouched - const realContents = fs.readdirSync(tempDir); - expect(realContents).not.toContain("a"); - expect(realContents).not.toContain("a-copy"); - expect(realContents).not.toContain("a-moved"); - }); - - it("should maintain separate state across overlay instances", async () => { - // Write to first overlay - await env.exec('echo "first" > /shared.txt'); - - // Create second overlay with same root - const overlay2 = new OverlayFs({ root: tempDir, mountPoint: "/" }); - const env2 = new Bash({ fs: overlay2, cwd: "/" }); - - // Second overlay should not see first overlay's writes - const result = await env2.exec("cat /shared.txt"); - expect(result.exitCode).not.toBe(0); - - // But can write its own version - await env2.exec('echo "second" > /shared.txt'); - const result2 = await env2.exec("cat /shared.txt"); - expect(result2.stdout).toBe("second\n"); - - // First overlay unchanged - const result1 = await env.exec("cat /shared.txt"); - expect(result1.stdout).toBe("first\n"); - }); - }); -}); diff --git a/src/fs/overlay-fs/overlay-fs.security.test.ts b/src/fs/overlay-fs/overlay-fs.security.test.ts deleted file mode 100644 index 6040126d..00000000 --- a/src/fs/overlay-fs/overlay-fs.security.test.ts +++ /dev/null @@ -1,841 +0,0 @@ -/** - * Security tests for OverlayFs path traversal protection - * - * These tests attempt to escape the root directory using various - * attack techniques. All should fail safely. - */ - -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; -import { OverlayFs } from "./overlay-fs.js"; - -describe("OverlayFs Security - Path Traversal Prevention", () => { - let tempDir: string; - let overlay: OverlayFs; - - // Create a file outside the sandbox that we'll try to access - let outsideFile: string; - let outsideDir: string; - - beforeEach(() => { - // Create sandbox directory - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "overlay-sandbox-")); - - // Create a sibling directory with a secret file (simulates sensitive data) - outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "overlay-outside-")); - outsideFile = path.join(outsideDir, "secret.txt"); - fs.writeFileSync(outsideFile, "TOP SECRET DATA - YOU SHOULD NOT SEE THIS"); - - // Create some files inside the sandbox - fs.writeFileSync(path.join(tempDir, "allowed.txt"), "This is allowed"); - fs.mkdirSync(path.join(tempDir, "subdir")); - fs.writeFileSync( - path.join(tempDir, "subdir", "nested.txt"), - "Nested allowed", - ); - - overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - fs.rmSync(outsideDir, { recursive: true, force: true }); - }); - - describe("basic path traversal with ..", () => { - it("should block simple ../", async () => { - await expect(overlay.readFile("/../secret.txt")).rejects.toThrow(); - }); - - it("should block multiple ../../../", async () => { - await expect( - overlay.readFile("/../../../../../../../etc/passwd"), - ).rejects.toThrow(); - }); - - it("should block ../ from subdirectory", async () => { - await expect( - overlay.readFile("/subdir/../../secret.txt"), - ).rejects.toThrow(); - }); - - it("should block deeply nested escape attempts", async () => { - const deepPath = - "/a/b/c/d/e/../../../../../../../../../../../../../etc/passwd"; - await expect(overlay.readFile(deepPath)).rejects.toThrow(); - }); - - it("should block .. at the end of path", async () => { - await expect(overlay.readFile("/subdir/..")).rejects.toThrow(); - }); - - it("should block bare ..", async () => { - await expect(overlay.readFile("..")).rejects.toThrow(); - }); - - it("should normalize but contain /./../../", async () => { - await expect(overlay.readFile("/./../../etc/passwd")).rejects.toThrow(); - }); - }); - - describe("dot variations and edge cases", () => { - it("should handle single dot correctly", async () => { - // /. should resolve to / which is valid - const stat = await overlay.stat("/."); - expect(stat.isDirectory).toBe(true); - }); - - it("should block triple dots ...", async () => { - await expect(overlay.readFile("/.../etc/passwd")).rejects.toThrow(); - }); - - it("should block dots with spaces (. .)", async () => { - await expect(overlay.readFile("/. ./. ./etc/passwd")).rejects.toThrow(); - }); - - it("should handle .hidden files correctly (not escape)", async () => { - await overlay.writeFile("/.hidden", "hidden content"); - const content = await overlay.readFile("/.hidden"); - expect(content).toBe("hidden content"); - }); - - it("should handle ..hidden files correctly (not escape)", async () => { - await overlay.writeFile("/..hidden", "hidden content"); - const content = await overlay.readFile("/..hidden"); - expect(content).toBe("hidden content"); - }); - - it("should handle files named just dots", async () => { - await overlay.writeFile("/...", "dots"); - const content = await overlay.readFile("/..."); - expect(content).toBe("dots"); - }); - }); - - describe("absolute path injection", () => { - it("should not allow reading /etc/passwd directly", async () => { - await expect(overlay.readFile("/etc/passwd")).rejects.toThrow(); - }); - - it("should not allow reading /etc/shadow", async () => { - await expect(overlay.readFile("/etc/shadow")).rejects.toThrow(); - }); - - it("should not allow reading the outside secret file by absolute path", async () => { - await expect(overlay.readFile(outsideFile)).rejects.toThrow(); - }); - - it("should contain paths starting with the real temp dir path", async () => { - // Try to inject the real absolute path - await expect(overlay.readFile(outsideDir)).rejects.toThrow(); - }); - }); - - describe("symlink escape attempts", () => { - it("should not follow symlink pointing to absolute path outside", async () => { - await overlay.symlink("/etc/passwd", "/escape-link"); - await expect(overlay.readFile("/escape-link")).rejects.toThrow("ENOENT"); - }); - - it("should not follow symlink pointing to relative path escaping root", async () => { - await overlay.symlink("../../../etc/passwd", "/relative-escape"); - await expect(overlay.readFile("/relative-escape")).rejects.toThrow(); - }); - - it("should not follow chained symlinks escaping root", async () => { - await overlay.symlink("../", "/link1"); - await overlay.symlink("/link1/../etc/passwd", "/link2"); - await expect(overlay.readFile("/link2")).rejects.toThrow(); - }); - - it("should not allow symlink to outside file even if it exists on real fs", async () => { - // Create a symlink in memory pointing to the secret file - await overlay.symlink(outsideFile, "/secret-link"); - // Reading should fail because the target is outside our virtual root - await expect(overlay.readFile("/secret-link")).rejects.toThrow(); - }); - - it("should not follow real filesystem symlinks pointing outside", async () => { - // Create a real symlink on the filesystem pointing outside - const realSymlink = path.join(tempDir, "real-escape-link"); - try { - fs.symlinkSync(outsideFile, realSymlink); - } catch { - // Skip on systems that don't support symlinks - return; - } - - // The overlay should not be able to read through this symlink - // because the target resolves to outside the root - await expect(overlay.readFile("/real-escape-link")).rejects.toThrow(); - }); - - it("should handle circular symlinks safely", async () => { - await overlay.symlink("/link2", "/link1"); - await overlay.symlink("/link1", "/link2"); - await expect(overlay.readFile("/link1")).rejects.toThrow(); - }); - - it("should handle self-referential symlinks", async () => { - await overlay.symlink("/self", "/self"); - await expect(overlay.readFile("/self")).rejects.toThrow(); - }); - }); - - describe("hard link escape attempts", () => { - it("should not allow hard linking to paths outside root", async () => { - // First need to have a file - await overlay.writeFile("/inside.txt", "inside"); - // Try to create a hard link - this should work within the overlay - await overlay.link("/inside.txt", "/hardlink.txt"); - const content = await overlay.readFile("/hardlink.txt"); - expect(content).toBe("inside"); - }); - - it("should not allow hard linking to non-existent files", async () => { - await expect( - overlay.link("/nonexistent.txt", "/link.txt"), - ).rejects.toThrow("ENOENT"); - }); - - it("should not allow hard linking to real files outside overlay", async () => { - // Try to create a hard link to a file outside the overlay root - await expect(overlay.link(outsideFile, "/stolen.txt")).rejects.toThrow( - "ENOENT", - ); - // Verify the real file was not accessed - const realContent = fs.readFileSync(outsideFile, "utf8"); - expect(realContent).toBe("TOP SECRET DATA - YOU SHOULD NOT SEE THIS"); - }); - - it("should not allow hard linking via path traversal", async () => { - await overlay.writeFile("/inside.txt", "inside"); - // Try to use path traversal in source - await expect( - overlay.link("/../../../etc/passwd", "/passwd-link.txt"), - ).rejects.toThrow("ENOENT"); - }); - - it("should not allow hard linking to create file outside root", async () => { - await overlay.writeFile("/inside.txt", "inside"); - // Try to use path traversal in destination - should normalize and stay inside - await overlay.link("/inside.txt", "/../outside.txt"); - // The link should be created as /outside.txt in the overlay, not in the real parent - expect(await overlay.exists("/outside.txt")).toBe(true); - expect(fs.existsSync(path.join(outsideDir, "outside.txt"))).toBe(false); - }); - - it("should not share content between hardlink and original (copy semantics)", async () => { - // SECURITY: Our hardlinks copy content, not share it - // This is secure because modifying one doesn't affect the other - await overlay.writeFile("/original.txt", "original content"); - await overlay.link("/original.txt", "/hardlink.txt"); - - // Modify the original - await overlay.writeFile("/original.txt", "modified content"); - - // The hardlink should still have the original content (copy semantics) - const hardlinkContent = await overlay.readFile("/hardlink.txt"); - expect(hardlinkContent).toBe("original content"); - }); - - it("should not allow hard linking directories", async () => { - await overlay.mkdir("/testdir"); - // Hard linking directories is not permitted - await expect(overlay.link("/testdir", "/dir-hardlink")).rejects.toThrow( - "EPERM", - ); - }); - - it("should validate null bytes in hardlink paths", async () => { - await overlay.writeFile("/inside.txt", "inside"); - await expect( - overlay.link("/inside\x00.txt", "/link.txt"), - ).rejects.toThrow("null byte"); - await expect( - overlay.link("/inside.txt", "/link\x00.txt"), - ).rejects.toThrow("null byte"); - }); - - it("should not allow hard linking to symlinks pointing outside", async () => { - // Create a symlink pointing outside - await overlay.symlink(outsideFile, "/outside-symlink"); - // Trying to hardlink it should fail because the target doesn't exist in overlay - // The stat() follows the symlink and fails - await expect( - overlay.link("/outside-symlink", "/link.txt"), - ).rejects.toThrow(); - }); - - it("should reject hardlink to existing destination", async () => { - await overlay.writeFile("/source.txt", "source"); - await overlay.writeFile("/existing.txt", "existing"); - await expect( - overlay.link("/source.txt", "/existing.txt"), - ).rejects.toThrow("EEXIST"); - }); - - it("should copy file permissions with hardlink", async () => { - await overlay.writeFile("/source.txt", "source"); - await overlay.chmod("/source.txt", 0o755); - await overlay.link("/source.txt", "/hardlink.txt"); - const stat = await overlay.stat("/hardlink.txt"); - expect(stat.mode & 0o777).toBe(0o755); - }); - - it("should handle concurrent hardlink creation attempts", async () => { - await overlay.writeFile("/source.txt", "content"); - const promises = Array(10) - .fill(null) - .map((_, i) => - overlay.link("/source.txt", `/link${i}.txt`).catch((e) => e.message), - ); - - const results = await Promise.all(promises); - // All should succeed or fail cleanly (no crashes) - for (const result of results) { - if (typeof result === "string") { - // It's an error message - expect(result).not.toContain("outside"); - expect(result).not.toContain(outsideDir); - } - } - }); - }); - - describe("special characters and encoding attacks", () => { - it("should handle null bytes in path", async () => { - await expect(overlay.readFile("/etc\x00/passwd")).rejects.toThrow(); - }); - - it("should handle paths with newlines", async () => { - await expect(overlay.readFile("/etc\n/../passwd")).rejects.toThrow(); - }); - - it("should handle paths with carriage returns", async () => { - await expect(overlay.readFile("/etc\r/../passwd")).rejects.toThrow(); - }); - - it("should handle paths with tabs", async () => { - await expect(overlay.readFile("/etc\t/passwd")).rejects.toThrow(); - }); - - it("should handle backslash as regular character (not path separator)", async () => { - // On Unix, backslash is a valid filename character - await overlay.writeFile("/back\\slash", "content"); - const content = await overlay.readFile("/back\\slash"); - expect(content).toBe("content"); - }); - - it("should handle paths with unicode", async () => { - await overlay.writeFile("/файл.txt", "unicode content"); - const content = await overlay.readFile("/файл.txt"); - expect(content).toBe("unicode content"); - }); - - it("should handle paths with emoji", async () => { - await overlay.writeFile("/📁file.txt", "emoji content"); - const content = await overlay.readFile("/📁file.txt"); - expect(content).toBe("emoji content"); - }); - - it("should handle very long paths", async () => { - const longName = "a".repeat(255); - await overlay.writeFile(`/${longName}`, "long name content"); - const content = await overlay.readFile(`/${longName}`); - expect(content).toBe("long name content"); - }); - - it("should handle paths with spaces", async () => { - await overlay.writeFile("/path with spaces/file.txt", "spaced"); - const content = await overlay.readFile("/path with spaces/file.txt"); - expect(content).toBe("spaced"); - }); - - it("should handle paths with quotes", async () => { - await overlay.writeFile('/file"with"quotes.txt', "quoted"); - const content = await overlay.readFile('/file"with"quotes.txt'); - expect(content).toBe("quoted"); - }); - }); - - describe("URL-style encoding (should be treated literally)", () => { - // These encodings should NOT be decoded - they should be literal filenames - it("should treat %2e%2e as literal filename not ..", async () => { - await overlay.writeFile("/%2e%2e", "not parent"); - const content = await overlay.readFile("/%2e%2e"); - expect(content).toBe("not parent"); - }); - - it("should treat %2f as literal not /", async () => { - await overlay.writeFile("/%2f", "not slash"); - const content = await overlay.readFile("/%2f"); - expect(content).toBe("not slash"); - }); - - it("should not decode URL-encoded path traversal", async () => { - // %2e = . and %2f = / - // %2e%2e%2f = ../ - await expect(overlay.readFile("/%2e%2e%2fetc/passwd")).rejects.toThrow(); - }); - }); - - describe("path normalization edge cases", () => { - it("should handle multiple consecutive slashes", async () => { - await overlay.writeFile("/file.txt", "content"); - const content = await overlay.readFile("////file.txt"); - expect(content).toBe("content"); - }); - - it("should handle trailing slashes on files", async () => { - await overlay.writeFile("/file.txt", "content"); - // Trailing slash is stripped during normalization, so this reads the file - const content = await overlay.readFile("/file.txt/"); - expect(content).toBe("content"); - }); - - it("should handle empty path components", async () => { - await overlay.writeFile("/file.txt", "content"); - const content = await overlay.readFile("/./file.txt"); - expect(content).toBe("content"); - }); - - it("should handle path with only slashes", async () => { - const stat = await overlay.stat("///"); - expect(stat.isDirectory).toBe(true); - }); - - it("should handle . and .. combinations", async () => { - await expect(overlay.readFile("/./../etc/passwd")).rejects.toThrow(); - }); - - it("should handle ../ at various positions", async () => { - await expect(overlay.readFile("/../")).rejects.toThrow(); - await expect(overlay.readFile("/a/../b/../c/../..")).rejects.toThrow(); - }); - }); - - describe("directory traversal via operations", () => { - it("should not allow mkdir outside root", async () => { - await expect(overlay.mkdir("/../outside-dir")).resolves.not.toThrow(); - // The directory should be created inside the overlay, not outside - const exists = await overlay.exists("/outside-dir"); - expect(exists).toBe(true); - // Real filesystem should not have the directory outside - expect(fs.existsSync(path.join(outsideDir, "outside-dir"))).toBe(false); - }); - - it("should not allow rm outside root", async () => { - // Try to delete the outside secret file via path traversal - // The path gets normalized and doesn't point to the real file - // rm throws ENOENT which is correct - it can't find it - await expect(overlay.rm(`/../../../${outsideFile}`)).rejects.toThrow(); - // The real file should still exist (untouched) - expect(fs.existsSync(outsideFile)).toBe(true); - }); - - it("should not allow cp source from outside root", async () => { - await expect(overlay.cp(outsideFile, "/stolen.txt")).rejects.toThrow(); - }); - - it("should not allow cp destination outside root", async () => { - await overlay.writeFile("/source.txt", "source content"); - // This should create the file inside the overlay, not outside - await overlay.cp("/source.txt", "/../../../outside.txt"); - expect(fs.existsSync(path.join(outsideDir, "outside.txt"))).toBe(false); - }); - - it("should not allow mv source from outside root", async () => { - await expect(overlay.mv(outsideFile, "/stolen.txt")).rejects.toThrow(); - }); - - it("should not allow chmod on files outside root", async () => { - await expect(overlay.chmod(outsideFile, 0o777)).rejects.toThrow(); - }); - - it("should not allow stat on files outside root", async () => { - await expect(overlay.stat(outsideFile)).rejects.toThrow(); - }); - - it("should not allow readdir outside root", async () => { - // Path traversal attempts normalize to root, so we get root contents (not parent) - const entries = await overlay.readdir("/../../../"); - // Should return contents of overlay root, not real filesystem parent directories - expect(entries).toContain("allowed.txt"); - expect(entries).not.toContain("secret.txt"); - }); - }); - - describe("readdir security - comprehensive", () => { - it("should not list parent directory contents via ../", async () => { - const entries = await overlay.readdir("/.."); - // Should normalize to root, not list parent - expect(entries).toContain("allowed.txt"); - expect(entries).not.toContain("secret.txt"); - }); - - it("should not list parent via subdir/../..", async () => { - const entries = await overlay.readdir("/subdir/../.."); - expect(entries).toContain("allowed.txt"); - expect(entries).not.toContain("secret.txt"); - }); - - it("should not list /etc", async () => { - await expect(overlay.readdir("/etc")).rejects.toThrow(); - }); - - it("should not list /tmp (real system tmp)", async () => { - // /tmp in the overlay should not be the real /tmp - await expect(overlay.readdir("/tmp")).rejects.toThrow(); - }); - - it("should not list home directories", async () => { - await expect(overlay.readdir("/home")).rejects.toThrow(); - await expect(overlay.readdir("/Users")).rejects.toThrow(); - }); - - it("should not list the real outside directory", async () => { - await expect(overlay.readdir(outsideDir)).rejects.toThrow(); - }); - - it("should not list via absolute path with traversal prefix", async () => { - // The traversal prefix is stripped, leaving an absolute path like /var/folders/... - // which doesn't exist in the overlay - throws ENOENT (secure behavior) - await expect(overlay.readdir(`/../../../${outsideDir}`)).rejects.toThrow( - "ENOENT", - ); - }); - - it("should handle readdir on root with various traversal attempts", async () => { - const attempts = [ - "/..", - "/../", - "/../..", - "/../../..", - "/./../../..", - "/subdir/../../..", - "/subdir/../subdir/../..", - ]; - - for (const attemptPath of attempts) { - const entries = await overlay.readdir(attemptPath); - // All should resolve to root contents - expect(entries).toContain("allowed.txt"); - expect(entries).not.toContain("secret.txt"); - } - }); - - it("should not leak directory names via readdir errors", async () => { - // When readdir fails, error message should not reveal real paths - try { - await overlay.readdir("/nonexistent/path/to/dir"); - } catch (e) { - const message = (e as Error).message; - expect(message).not.toContain(tempDir); - expect(message).not.toContain(outsideDir); - } - }); - - it("should not follow symlinks to outside directories", async () => { - await overlay.symlink(outsideDir, "/outside-link"); - // Returns empty array (secure) - symlink points outside, target doesn't exist in overlay - const entries = await overlay.readdir("/outside-link"); - expect(entries).toEqual([]); - }); - - it("should not follow chained symlinks to outside directories", async () => { - await overlay.symlink("../", "/link1"); - await overlay.symlink("/link1", "/link2"); - // Reading link2 should not escape - const entries = await overlay.readdir("/link2"); - expect(entries).not.toContain("secret.txt"); - }); - - it("should reject readdir with null bytes", async () => { - // Null bytes in paths are rejected to prevent truncation attacks - await expect(overlay.readdir("/subdir\x00/../..")).rejects.toThrow( - "path contains null byte", - ); - }); - - it("should handle readdir with special characters in path", async () => { - // Paths with special characters are normalized and resolve safely to root (secure) - for (const specialPath of [ - "/sub\ndir/../../..", - "/sub\rdir/../../..", - "/sub\tdir/../../..", - ]) { - const entries = await overlay.readdir(specialPath); - expect(entries).toContain("allowed.txt"); - expect(entries).not.toContain("secret.txt"); - } - }); - - it("should not list real filesystem root", async () => { - // Reading the overlay root should give overlay contents, not real / - const entries = await overlay.readdir("/"); - // Should have our test files - expect(entries).toContain("allowed.txt"); - // Should NOT have real filesystem entries - expect(entries).not.toContain("etc"); - expect(entries).not.toContain("usr"); - expect(entries).not.toContain("var"); - expect(entries).not.toContain("bin"); - }); - - it("should handle concurrent readdir attacks", async () => { - const attacks = Array(20) - .fill(null) - .map(() => overlay.readdir("/../../../etc").catch(() => "blocked")); - - const results = await Promise.all(attacks); - // All should either throw or return empty/root contents - for (const result of results) { - if (result !== "blocked") { - expect(result).not.toContain("passwd"); - expect(result).not.toContain("shadow"); - } - } - }); - - it("should handle readdir on symlink pointing to traversal path", async () => { - await overlay.symlink("../../../etc", "/etc-escape"); - // Returns empty array (secure) - symlink traversal target doesn't exist in overlay - const entries = await overlay.readdir("/etc-escape"); - expect(entries).toEqual([]); - }); - - it("should not allow readdir via Windows-style paths", async () => { - await expect(overlay.readdir("\\..\\..\\etc")).rejects.toThrow(); - await expect(overlay.readdir("/subdir\\..\\..\\etc")).rejects.toThrow(); - }); - - it("should handle readdir with URL-encoded traversal (literal)", async () => { - // %2e%2e should be treated as literal filename, not .. - await expect(overlay.readdir("/%2e%2e")).rejects.toThrow(); - }); - - it("should isolate memory-created dirs from real fs dirs", async () => { - // Create a directory in memory that shadows a real path concept - await overlay.mkdir("/etc"); - await overlay.writeFile("/etc/myfile", "memory content"); - - const entries = await overlay.readdir("/etc"); - // Should only contain our memory file, not real /etc contents - expect(entries).toContain("myfile"); - expect(entries).not.toContain("passwd"); - expect(entries).not.toContain("shadow"); - expect(entries).not.toContain("hosts"); - }); - }); - - describe("BashEnv integration security", () => { - it("should not allow cat to read files outside root", async () => { - const env = new Bash({ fs: overlay }); - const result = await env.exec(`cat ${outsideFile}`); - expect(result.exitCode).not.toBe(0); - expect(result.stdout).not.toContain("TOP SECRET"); - }); - - it("should not allow cat with path traversal", async () => { - const env = new Bash({ fs: overlay }); - const result = await env.exec("cat /../../../etc/passwd"); - expect(result.exitCode).not.toBe(0); - }); - - it("should not allow ls to list directories outside root", async () => { - const env = new Bash({ fs: overlay }); - const result = await env.exec(`ls ${outsideDir}`); - expect(result.stdout).not.toContain("secret.txt"); - }); - - it("should not allow find to search outside root", async () => { - const env = new Bash({ fs: overlay, cwd: "/" }); - const result = await env.exec("find / -name secret.txt"); - expect(result.stdout).not.toContain(outsideDir); - expect(result.stdout).not.toContain("secret.txt"); - }); - - it("should not allow grep to read outside root", async () => { - const env = new Bash({ fs: overlay }); - const result = await env.exec(`grep SECRET ${outsideFile}`); - expect(result.exitCode).not.toBe(0); - expect(result.stdout).not.toContain("TOP SECRET"); - }); - - it("should not allow head to read outside root", async () => { - const env = new Bash({ fs: overlay }); - const result = await env.exec(`head ${outsideFile}`); - expect(result.exitCode).not.toBe(0); - }); - - it("should not allow tail to read outside root", async () => { - const env = new Bash({ fs: overlay }); - const result = await env.exec(`tail ${outsideFile}`); - expect(result.exitCode).not.toBe(0); - }); - - it("should not allow redirects to write outside root", async () => { - const env = new Bash({ fs: overlay }); - await env.exec(`echo "PWNED" > /../../../tmp/pwned.txt`); - // File should not exist in real filesystem's tmp - expect(fs.existsSync("/tmp/pwned.txt")).toBe(false); - }); - - it("should not allow symlink command to escape", async () => { - const env = new Bash({ fs: overlay }); - await env.exec(`ln -s ${outsideFile} /escape`); - const result = await env.exec("cat /escape"); - expect(result.exitCode).not.toBe(0); - expect(result.stdout).not.toContain("TOP SECRET"); - }); - - it("should not allow source command to read outside", async () => { - const env = new Bash({ fs: overlay }); - const result = await env.exec(`source ${outsideFile}`); - expect(result.exitCode).not.toBe(0); - }); - }); - - describe("race condition protection (TOCTOU)", () => { - it("should handle rapid create/delete cycles", async () => { - const promises: Promise[] = []; - - for (let i = 0; i < 100; i++) { - promises.push( - (async () => { - try { - await overlay.writeFile("/race.txt", `content-${i}`); - await overlay.readFile("/race.txt"); - await overlay.rm("/race.txt"); - } catch { - // Ignore errors from race conditions - } - })(), - ); - } - - await Promise.all(promises); - // Should not throw or crash - }); - - it("should handle concurrent path traversal attempts", async () => { - const attempts = Array(50) - .fill(null) - .map((_, i) => { - const escapePath = `${"../".repeat(i + 1)}etc/passwd`; - return overlay.readFile(escapePath).catch(() => "blocked"); - }); - - const results = await Promise.all(attempts); - // All should be blocked - expect(results.every((r) => r === "blocked")).toBe(true); - }); - }); - - describe("resolvePath security", () => { - it("should normalize paths with .. (security enforced at read/write)", () => { - // resolvePath is just a path utility - it normalizes paths - // Security is enforced when actually reading/writing - const resolved = overlay.resolvePath("/subdir", "../../../etc/passwd"); - // The path normalizes to /etc/passwd (within virtual fs) - expect(resolved).toBe("/etc/passwd"); - // But actually reading it should fail because it doesn't exist in overlay - }); - - it("should handle resolvePath with absolute paths", async () => { - const resolved = overlay.resolvePath("/subdir", "/etc/passwd"); - expect(resolved).toBe("/etc/passwd"); - // But reading it should fail - this is where security is enforced - await expect(overlay.readFile(resolved)).rejects.toThrow(); - }); - }); - - describe("getAllPaths security", () => { - it("should not leak paths outside root", () => { - const paths = overlay.getAllPaths(); - // Should only contain paths within the overlay - for (const p of paths) { - expect(p.startsWith("/")).toBe(true); - expect(p).not.toContain(outsideDir); - expect(p).not.toContain(outsideFile); - } - }); - }); - - describe("appendFile security", () => { - it("should treat absolute paths as virtual paths (not real fs)", async () => { - // When we pass an absolute path like /var/folders/.../secret.txt, - // the overlay treats it as a VIRTUAL path, not a real filesystem path. - // This is secure because the real file is never touched. - await overlay.appendFile(outsideFile, "PWNED"); - - // The real file should be completely unchanged - const realContent = fs.readFileSync(outsideFile, "utf8"); - expect(realContent).toBe("TOP SECRET DATA - YOU SHOULD NOT SEE THIS"); - expect(realContent).not.toContain("PWNED"); - - // The virtual file at that path contains the appended content - // (but it's completely isolated from the real file) - const virtualContent = await overlay.readFile(outsideFile); - expect(virtualContent).toBe("PWNED"); - }); - - it("should not leak real file content via append", async () => { - // Try to append to what looks like the real path - // The overlay shouldn't read from the real file first - await overlay.appendFile(outsideFile, "-suffix"); - const content = await overlay.readFile(outsideFile); - // Should just be the suffix, not the real file content + suffix - expect(content).toBe("-suffix"); - expect(content).not.toContain("TOP SECRET"); - }); - }); - - describe("readlink security", () => { - it("should not leak information about outside paths via readlink", async () => { - // Create a symlink in memory - await overlay.symlink("/etc/passwd", "/link"); - const target = await overlay.readlink("/link"); - // readlink just returns the target as stored, which is fine - // The security is in not being able to READ through it - expect(target).toBe("/etc/passwd"); - await expect(overlay.readFile("/link")).rejects.toThrow(); - }); - }); - - describe("Windows-style attacks (should be handled on any OS)", () => { - it("should handle backslash path traversal attempts", async () => { - // On Windows, backslash is a path separator - // On Unix, it's a valid filename character - // Either way, this shouldn't escape - await expect(overlay.readFile("\\..\\..\\etc\\passwd")).rejects.toThrow(); - }); - - it("should handle mixed slash styles", async () => { - await expect( - overlay.readFile("/subdir\\..\\..\\etc/passwd"), - ).rejects.toThrow(); - }); - - it("should handle UNC-style paths", async () => { - await expect( - overlay.readFile("//server/share/../../etc/passwd"), - ).rejects.toThrow(); - }); - - it("should handle device names", async () => { - // Windows device names like NUL, CON, COM1, etc. - await expect(overlay.readFile("/NUL")).rejects.toThrow(); - await expect(overlay.readFile("/CON")).rejects.toThrow(); - }); - - it("should handle alternate data streams syntax", async () => { - // Windows NTFS alternate data streams: file.txt:stream - await expect(overlay.readFile("/file.txt:secret")).rejects.toThrow(); - }); - }); -}); diff --git a/src/fs/overlay-fs/overlay-fs.test.ts b/src/fs/overlay-fs/overlay-fs.test.ts deleted file mode 100644 index 412e0b92..00000000 --- a/src/fs/overlay-fs/overlay-fs.test.ts +++ /dev/null @@ -1,727 +0,0 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; -import { OverlayFs } from "./overlay-fs.js"; - -describe("OverlayFs", () => { - let tempDir: string; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "overlay-fs-test-")); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - describe("constructor", () => { - it("should create with valid root directory", () => { - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - expect(overlay).toBeInstanceOf(OverlayFs); - }); - - it("should throw for non-existent root", () => { - expect(() => { - new OverlayFs({ root: "/nonexistent/path/12345" }); - }).toThrow("does not exist"); - }); - - it("should throw for file as root", () => { - const filePath = path.join(tempDir, "file.txt"); - fs.writeFileSync(filePath, "content"); - expect(() => { - new OverlayFs({ root: filePath }); - }).toThrow("not a directory"); - }); - }); - - describe("mount point", () => { - it("should default to /home/user/project", () => { - const overlay = new OverlayFs({ root: tempDir }); - expect(overlay.getMountPoint()).toBe("/home/user/project"); - }); - - it("should allow custom mount point", () => { - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/app" }); - expect(overlay.getMountPoint()).toBe("/app"); - }); - - it("should read files at default mount point", async () => { - fs.writeFileSync(path.join(tempDir, "test.txt"), "content"); - const overlay = new OverlayFs({ root: tempDir }); - - const content = await overlay.readFile("/home/user/project/test.txt"); - expect(content).toBe("content"); - }); - - it("should not read files outside mount point", async () => { - fs.writeFileSync(path.join(tempDir, "test.txt"), "content"); - const overlay = new OverlayFs({ root: tempDir }); - - await expect(overlay.readFile("/test.txt")).rejects.toThrow("ENOENT"); - }); - - it("should list files at mount point", async () => { - fs.writeFileSync(path.join(tempDir, "a.txt"), "a"); - fs.writeFileSync(path.join(tempDir, "b.txt"), "b"); - const overlay = new OverlayFs({ root: tempDir }); - - const entries = await overlay.readdir("/home/user/project"); - expect(entries).toContain("a.txt"); - expect(entries).toContain("b.txt"); - }); - - it("should create mount point parent directories", async () => { - const overlay = new OverlayFs({ root: tempDir }); - - expect(await overlay.exists("/home")).toBe(true); - expect(await overlay.exists("/home/user")).toBe(true); - expect(await overlay.exists("/home/user/project")).toBe(true); - }); - - it("should work with BashEnv at mount point", async () => { - fs.writeFileSync(path.join(tempDir, "file.txt"), "hello"); - const overlay = new OverlayFs({ root: tempDir }); - const env = new Bash({ fs: overlay, cwd: overlay.getMountPoint() }); - - const result = await env.exec("cat file.txt"); - expect(result.stdout).toBe("hello"); - }); - }); - - describe("reading from real filesystem", () => { - it("should read files from real filesystem", async () => { - fs.writeFileSync(path.join(tempDir, "test.txt"), "real content"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - const content = await overlay.readFile("/test.txt"); - expect(content).toBe("real content"); - }); - - it("should read nested files", async () => { - fs.mkdirSync(path.join(tempDir, "subdir")); - fs.writeFileSync(path.join(tempDir, "subdir", "file.txt"), "nested"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - const content = await overlay.readFile("/subdir/file.txt"); - expect(content).toBe("nested"); - }); - - it("should list directory contents from real filesystem", async () => { - fs.writeFileSync(path.join(tempDir, "a.txt"), "a"); - fs.writeFileSync(path.join(tempDir, "b.txt"), "b"); - fs.mkdirSync(path.join(tempDir, "subdir")); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - const entries = await overlay.readdir("/"); - expect(entries).toContain("a.txt"); - expect(entries).toContain("b.txt"); - expect(entries).toContain("subdir"); - }); - - it("should stat files from real filesystem", async () => { - fs.writeFileSync(path.join(tempDir, "test.txt"), "content"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - const stat = await overlay.stat("/test.txt"); - expect(stat.isFile).toBe(true); - expect(stat.isDirectory).toBe(false); - expect(stat.size).toBe(7); - }); - }); - - describe("writing to memory layer", () => { - it("should write files to memory without affecting real fs", async () => { - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.writeFile("/new.txt", "memory content"); - - // Should read from memory - const content = await overlay.readFile("/new.txt"); - expect(content).toBe("memory content"); - - // Real filesystem should not have the file - expect(fs.existsSync(path.join(tempDir, "new.txt"))).toBe(false); - }); - - it("should override real files in memory", async () => { - fs.writeFileSync(path.join(tempDir, "test.txt"), "real"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.writeFile("/test.txt", "modified"); - const content = await overlay.readFile("/test.txt"); - expect(content).toBe("modified"); - - // Real file should be unchanged - expect(fs.readFileSync(path.join(tempDir, "test.txt"), "utf8")).toBe( - "real", - ); - }); - - it("should create directories in memory", async () => { - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.mkdir("/newdir"); - const stat = await overlay.stat("/newdir"); - expect(stat.isDirectory).toBe(true); - - // Real filesystem should not have the directory - expect(fs.existsSync(path.join(tempDir, "newdir"))).toBe(false); - }); - - it("should append to files", async () => { - fs.writeFileSync(path.join(tempDir, "append.txt"), "start"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.appendFile("/append.txt", "-end"); - const content = await overlay.readFile("/append.txt"); - expect(content).toBe("start-end"); - - // Real file unchanged - expect(fs.readFileSync(path.join(tempDir, "append.txt"), "utf8")).toBe( - "start", - ); - }); - }); - - describe("deletion tracking", () => { - it("should mark files as deleted", async () => { - fs.writeFileSync(path.join(tempDir, "delete.txt"), "content"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.rm("/delete.txt"); - - const exists = await overlay.exists("/delete.txt"); - expect(exists).toBe(false); - - // Real file should still exist - expect(fs.existsSync(path.join(tempDir, "delete.txt"))).toBe(true); - }); - - it("should hide deleted files from readdir", async () => { - fs.writeFileSync(path.join(tempDir, "a.txt"), "a"); - fs.writeFileSync(path.join(tempDir, "b.txt"), "b"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.rm("/a.txt"); - - const entries = await overlay.readdir("/"); - expect(entries).not.toContain("a.txt"); - expect(entries).toContain("b.txt"); - }); - - it("should allow recreating deleted files", async () => { - fs.writeFileSync(path.join(tempDir, "recreate.txt"), "original"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.rm("/recreate.txt"); - await overlay.writeFile("/recreate.txt", "new content"); - - const content = await overlay.readFile("/recreate.txt"); - expect(content).toBe("new content"); - }); - - it("should delete directories recursively", async () => { - fs.mkdirSync(path.join(tempDir, "dir")); - fs.writeFileSync(path.join(tempDir, "dir", "file.txt"), "content"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.rm("/dir", { recursive: true }); - - expect(await overlay.exists("/dir")).toBe(false); - expect(await overlay.exists("/dir/file.txt")).toBe(false); - }); - }); - - describe("directory merging", () => { - it("should merge memory and real filesystem entries", async () => { - fs.writeFileSync(path.join(tempDir, "real.txt"), "real"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.writeFile("/memory.txt", "memory"); - - const entries = await overlay.readdir("/"); - expect(entries).toContain("real.txt"); - expect(entries).toContain("memory.txt"); - }); - - it("should not duplicate entries", async () => { - fs.writeFileSync(path.join(tempDir, "file.txt"), "real"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - // Override in memory - await overlay.writeFile("/file.txt", "memory"); - - const entries = await overlay.readdir("/"); - const fileCount = entries.filter((e) => e === "file.txt").length; - expect(fileCount).toBe(1); - }); - }); - - describe("path traversal protection", () => { - it("should prevent reading outside root with ..", async () => { - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await expect(overlay.readFile("/../../../etc/passwd")).rejects.toThrow( - "ENOENT", - ); - }); - - it("should normalize paths with .. correctly", async () => { - fs.mkdirSync(path.join(tempDir, "subdir")); - fs.writeFileSync(path.join(tempDir, "root.txt"), "root content"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - const content = await overlay.readFile("/subdir/../root.txt"); - expect(content).toBe("root content"); - }); - - it("should prevent escaping via symlink", async () => { - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - // Create a symlink in memory that points outside - await overlay.symlink("/etc/passwd", "/escape-link"); - - // Reading should fail because /etc/passwd doesn't exist in our overlay - await expect(overlay.readFile("/escape-link")).rejects.toThrow("ENOENT"); - }); - }); - - describe("symlinks", () => { - it("should create and read symlinks in memory", async () => { - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.writeFile("/target.txt", "target content"); - await overlay.symlink("/target.txt", "/link"); - - const content = await overlay.readFile("/link"); - expect(content).toBe("target content"); - }); - - it("should read symlink target", async () => { - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.symlink("/target.txt", "/link"); - const target = await overlay.readlink("/link"); - expect(target).toBe("/target.txt"); - }); - - it("should lstat symlinks without following", async () => { - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.symlink("/target.txt", "/link"); - const stat = await overlay.lstat("/link"); - expect(stat.isSymbolicLink).toBe(true); - }); - }); - - describe("copy and move", () => { - it("should copy files within overlay", async () => { - fs.writeFileSync(path.join(tempDir, "source.txt"), "content"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.cp("/source.txt", "/copy.txt"); - - const content = await overlay.readFile("/copy.txt"); - expect(content).toBe("content"); - - // Real filesystem should not have the copy - expect(fs.existsSync(path.join(tempDir, "copy.txt"))).toBe(false); - }); - - it("should move files within overlay", async () => { - fs.writeFileSync(path.join(tempDir, "source.txt"), "content"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.mv("/source.txt", "/moved.txt"); - - expect(await overlay.exists("/source.txt")).toBe(false); - expect(await overlay.readFile("/moved.txt")).toBe("content"); - }); - - it("should copy directories recursively", async () => { - fs.mkdirSync(path.join(tempDir, "srcdir")); - fs.writeFileSync(path.join(tempDir, "srcdir", "file.txt"), "content"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.cp("/srcdir", "/destdir", { recursive: true }); - - const content = await overlay.readFile("/destdir/file.txt"); - expect(content).toBe("content"); - }); - }); - - describe("chmod", () => { - it("should change permissions in memory", async () => { - fs.writeFileSync(path.join(tempDir, "file.txt"), "content"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.chmod("/file.txt", 0o755); - const stat = await overlay.stat("/file.txt"); - expect(stat.mode & 0o777).toBe(0o755); - }); - }); - - describe("hard links", () => { - it("should create hard links in memory", async () => { - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.writeFile("/original.txt", "content"); - await overlay.link("/original.txt", "/hardlink.txt"); - - const content = await overlay.readFile("/hardlink.txt"); - expect(content).toBe("content"); - }); - }); - - describe("exists", () => { - it("should return true for real files", async () => { - fs.writeFileSync(path.join(tempDir, "real.txt"), "content"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - expect(await overlay.exists("/real.txt")).toBe(true); - }); - - it("should return true for memory files", async () => { - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - await overlay.writeFile("/memory.txt", "content"); - - expect(await overlay.exists("/memory.txt")).toBe(true); - }); - - it("should return false for deleted files", async () => { - fs.writeFileSync(path.join(tempDir, "deleted.txt"), "content"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.rm("/deleted.txt"); - expect(await overlay.exists("/deleted.txt")).toBe(false); - }); - - it("should return false for non-existent files", async () => { - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - expect(await overlay.exists("/nonexistent.txt")).toBe(false); - }); - }); - - describe("readOnly mode", () => { - it("should throw EROFS on writeFile when readOnly is true", async () => { - const overlay = new OverlayFs({ - root: tempDir, - mountPoint: "/", - readOnly: true, - }); - - await expect(overlay.writeFile("/test.txt", "content")).rejects.toThrow( - "EROFS: read-only file system", - ); - }); - - it("should throw EROFS on appendFile when readOnly is true", async () => { - fs.writeFileSync(path.join(tempDir, "existing.txt"), "content"); - const overlay = new OverlayFs({ - root: tempDir, - mountPoint: "/", - readOnly: true, - }); - - await expect(overlay.appendFile("/existing.txt", "more")).rejects.toThrow( - "EROFS: read-only file system", - ); - }); - - it("should throw EROFS on mkdir when readOnly is true", async () => { - const overlay = new OverlayFs({ - root: tempDir, - mountPoint: "/", - readOnly: true, - }); - - await expect(overlay.mkdir("/newdir")).rejects.toThrow( - "EROFS: read-only file system", - ); - }); - - it("should throw EROFS on rm when readOnly is true", async () => { - fs.writeFileSync(path.join(tempDir, "delete.txt"), "content"); - const overlay = new OverlayFs({ - root: tempDir, - mountPoint: "/", - readOnly: true, - }); - - await expect(overlay.rm("/delete.txt")).rejects.toThrow( - "EROFS: read-only file system", - ); - }); - - it("should throw EROFS on cp when readOnly is true", async () => { - fs.writeFileSync(path.join(tempDir, "source.txt"), "content"); - const overlay = new OverlayFs({ - root: tempDir, - mountPoint: "/", - readOnly: true, - }); - - await expect(overlay.cp("/source.txt", "/dest.txt")).rejects.toThrow( - "EROFS: read-only file system", - ); - }); - - it("should throw EROFS on mv when readOnly is true", async () => { - fs.writeFileSync(path.join(tempDir, "source.txt"), "content"); - const overlay = new OverlayFs({ - root: tempDir, - mountPoint: "/", - readOnly: true, - }); - - await expect(overlay.mv("/source.txt", "/dest.txt")).rejects.toThrow( - "EROFS: read-only file system", - ); - }); - - it("should throw EROFS on chmod when readOnly is true", async () => { - fs.writeFileSync(path.join(tempDir, "file.txt"), "content"); - const overlay = new OverlayFs({ - root: tempDir, - mountPoint: "/", - readOnly: true, - }); - - await expect(overlay.chmod("/file.txt", 0o755)).rejects.toThrow( - "EROFS: read-only file system", - ); - }); - - it("should throw EROFS on symlink when readOnly is true", async () => { - const overlay = new OverlayFs({ - root: tempDir, - mountPoint: "/", - readOnly: true, - }); - - await expect(overlay.symlink("/target", "/link")).rejects.toThrow( - "EROFS: read-only file system", - ); - }); - - it("should throw EROFS on link when readOnly is true", async () => { - fs.writeFileSync(path.join(tempDir, "source.txt"), "content"); - const overlay = new OverlayFs({ - root: tempDir, - mountPoint: "/", - readOnly: true, - }); - - await expect( - overlay.link("/source.txt", "/hardlink.txt"), - ).rejects.toThrow("EROFS: read-only file system"); - }); - - it("should allow read operations when readOnly is true", async () => { - fs.writeFileSync(path.join(tempDir, "test.txt"), "content"); - fs.mkdirSync(path.join(tempDir, "subdir")); - const overlay = new OverlayFs({ - root: tempDir, - mountPoint: "/", - readOnly: true, - }); - - // All read operations should work - expect(await overlay.readFile("/test.txt")).toBe("content"); - expect(await overlay.exists("/test.txt")).toBe(true); - expect(await overlay.stat("/test.txt")).toBeDefined(); - expect(await overlay.readdir("/")).toContain("test.txt"); - }); - - it("should default to readOnly false", async () => { - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - // Should not throw - await overlay.writeFile("/test.txt", "content"); - expect(await overlay.readFile("/test.txt")).toBe("content"); - }); - - it("should work with BashEnv in readOnly mode", async () => { - fs.writeFileSync(path.join(tempDir, "data.txt"), "hello"); - const overlay = new OverlayFs({ - root: tempDir, - mountPoint: "/", - readOnly: true, - }); - const env = new Bash({ fs: overlay, cwd: "/" }); - - // Read should work - const readResult = await env.exec("cat /data.txt"); - expect(readResult.stdout).toBe("hello"); - expect(readResult.exitCode).toBe(0); - - // Write should fail with EROFS (error is thrown during redirection) - try { - await env.exec("echo test > /new.txt"); - expect.fail("Expected EROFS error to be thrown"); - } catch (e) { - expect(String(e)).toContain("EROFS"); - } - }); - }); - - describe("integration with BashEnv", () => { - it("should work with BashEnv for basic commands", async () => { - fs.writeFileSync(path.join(tempDir, "input.txt"), "hello world"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - const env = new Bash({ fs: overlay }); - - const result = await env.exec("cat /input.txt"); - expect(result.stdout).toBe("hello world"); - expect(result.exitCode).toBe(0); - }); - - it("should allow writing without affecting real fs", async () => { - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - const env = new Bash({ fs: overlay }); - - await env.exec('echo "new content" > /output.txt'); - - const result = await env.exec("cat /output.txt"); - expect(result.stdout).toBe("new content\n"); - - // Real fs should not have the file - expect(fs.existsSync(path.join(tempDir, "output.txt"))).toBe(false); - }); - - it("should work with grep on real files", async () => { - fs.writeFileSync(path.join(tempDir, "data.txt"), "apple\nbanana\ncherry"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - const env = new Bash({ fs: overlay }); - - const result = await env.exec("grep banana /data.txt"); - expect(result.stdout).toBe("banana\n"); - }); - - it("should work with find on mixed real/memory files", async () => { - fs.writeFileSync(path.join(tempDir, "real.txt"), "real"); - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - const env = new Bash({ fs: overlay, cwd: "/" }); - - await env.exec('echo "memory" > /memory.txt'); - - const result = await env.exec('find / -name "*.txt"'); - expect(result.stdout).toContain("real.txt"); - expect(result.stdout).toContain("memory.txt"); - }); - }); - - describe("readdirWithFileTypes", () => { - it("should return entries with correct type info from real fs", async () => { - fs.writeFileSync(path.join(tempDir, "file.txt"), "content"); - fs.mkdirSync(path.join(tempDir, "subdir")); - - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - const entries = await overlay.readdirWithFileTypes("/"); - - const file = entries.find((e) => e.name === "file.txt"); - expect(file).toBeDefined(); - expect(file?.isFile).toBe(true); - expect(file?.isDirectory).toBe(false); - - const subdir = entries.find((e) => e.name === "subdir"); - expect(subdir).toBeDefined(); - expect(subdir?.isFile).toBe(false); - expect(subdir?.isDirectory).toBe(true); - }); - - it("should include memory layer entries with correct types", async () => { - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await overlay.writeFile("/memory.txt", "memory content"); - await overlay.mkdir("/memdir"); - - const entries = await overlay.readdirWithFileTypes("/"); - - const file = entries.find((e) => e.name === "memory.txt"); - expect(file).toBeDefined(); - expect(file?.isFile).toBe(true); - - const dir = entries.find((e) => e.name === "memdir"); - expect(dir).toBeDefined(); - expect(dir?.isDirectory).toBe(true); - }); - - it("should merge real and memory entries", async () => { - fs.writeFileSync(path.join(tempDir, "real.txt"), "real"); - - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - await overlay.writeFile("/memory.txt", "memory"); - - const entries = await overlay.readdirWithFileTypes("/"); - const names = entries.map((e) => e.name); - - expect(names).toContain("real.txt"); - expect(names).toContain("memory.txt"); - }); - - it("should hide deleted files", async () => { - fs.writeFileSync(path.join(tempDir, "a.txt"), "a"); - fs.writeFileSync(path.join(tempDir, "b.txt"), "b"); - - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - await overlay.rm("/a.txt"); - - const entries = await overlay.readdirWithFileTypes("/"); - const names = entries.map((e) => e.name); - - expect(names).not.toContain("a.txt"); - expect(names).toContain("b.txt"); - }); - - it("should return entries sorted case-sensitively", async () => { - fs.writeFileSync(path.join(tempDir, "Zebra.txt"), "z"); - fs.writeFileSync(path.join(tempDir, "apple.txt"), "a"); - fs.writeFileSync(path.join(tempDir, "Banana.txt"), "b"); - - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - const entries = await overlay.readdirWithFileTypes("/"); - const names = entries.map((e) => e.name); - - // Case-sensitive sort: uppercase before lowercase - expect(names).toEqual(["Banana.txt", "Zebra.txt", "apple.txt"]); - }); - - it("should identify symlinks in memory layer", async () => { - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - await overlay.writeFile("/real.txt", "content"); - await overlay.symlink("/real.txt", "/link.txt"); - - const entries = await overlay.readdirWithFileTypes("/"); - - const link = entries.find((e) => e.name === "link.txt"); - expect(link).toBeDefined(); - expect(link?.isSymbolicLink).toBe(true); - }); - - it("should throw ENOENT for non-existent directory", async () => { - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - - await expect( - overlay.readdirWithFileTypes("/nonexistent"), - ).rejects.toThrow("ENOENT"); - }); - - it("should return same names as readdir", async () => { - fs.writeFileSync(path.join(tempDir, "a.txt"), "a"); - fs.mkdirSync(path.join(tempDir, "sub")); - - const overlay = new OverlayFs({ root: tempDir, mountPoint: "/" }); - await overlay.writeFile("/b.txt", "b"); - - const namesFromReaddir = await overlay.readdir("/"); - const entriesWithTypes = await overlay.readdirWithFileTypes("/"); - const namesFromWithTypes = entriesWithTypes.map((e) => e.name); - - expect(namesFromWithTypes).toEqual(namesFromReaddir); - }); - }); -}); diff --git a/src/fs/overlay-fs/overlay-fs.ts b/src/fs/overlay-fs/overlay-fs.ts deleted file mode 100644 index 9139fac5..00000000 --- a/src/fs/overlay-fs/overlay-fs.ts +++ /dev/null @@ -1,1224 +0,0 @@ -/** - * OverlayFs - Copy-on-write filesystem backed by a real directory - * - * Reads come from the real filesystem, writes go to an in-memory layer. - * Changes don't persist to disk and can't escape the root directory. - */ - -import * as fs from "node:fs"; -import * as nodePath from "node:path"; -import { - type FileContent, - fromBuffer, - getEncoding, - toBuffer, -} from "../encoding.js"; -import type { - CpOptions, - DirentEntry, - FsStat, - IFileSystem, - MkdirOptions, - ReadFileOptions, - RmOptions, - WriteFileOptions, -} from "../interface.js"; - -interface MemoryFileEntry { - type: "file"; - content: Uint8Array; - mode: number; - mtime: Date; -} - -interface MemoryDirEntry { - type: "directory"; - mode: number; - mtime: Date; -} - -interface MemorySymlinkEntry { - type: "symlink"; - target: string; - mode: number; - mtime: Date; -} - -type MemoryEntry = MemoryFileEntry | MemoryDirEntry | MemorySymlinkEntry; - -export interface OverlayFsOptions { - /** - * The root directory on the real filesystem. - * All paths are relative to this root and cannot escape it. - */ - root: string; - - /** - * The virtual mount point where the root directory appears. - * Defaults to "/home/user/project". - */ - mountPoint?: string; - - /** - * If true, all write operations will throw an error. - * Useful for truly read-only access to the filesystem. - * Defaults to false. - */ - readOnly?: boolean; - - /** - * Maximum file size in bytes that can be read from the real filesystem. - * Files larger than this will throw an EFBIG error. - * Defaults to 10MB (10485760). - */ - maxFileReadSize?: number; -} - -/** Default mount point for OverlayFs */ -const DEFAULT_MOUNT_POINT = "/home/user/project"; - -/** - * Validate that a path does not contain null bytes. - * Null bytes in paths can be used to truncate filenames or bypass security filters. - */ -function validatePath(path: string, operation: string): void { - if (path.includes("\0")) { - throw new Error(`ENOENT: path contains null byte, ${operation} '${path}'`); - } -} - -export class OverlayFs implements IFileSystem { - private readonly root: string; - private readonly mountPoint: string; - private readonly readOnly: boolean; - private readonly maxFileReadSize: number; - private readonly memory: Map = new Map(); - private readonly deleted: Set = new Set(); - - constructor(options: OverlayFsOptions) { - // Resolve to absolute path - this.root = nodePath.resolve(options.root); - - // Normalize mount point (ensure it starts with / and has no trailing /) - const mp = options.mountPoint ?? DEFAULT_MOUNT_POINT; - this.mountPoint = mp === "/" ? "/" : mp.replace(/\/+$/, ""); - if (!this.mountPoint.startsWith("/")) { - throw new Error(`Mount point must be an absolute path: ${mp}`); - } - - // Set read-only mode - this.readOnly = options.readOnly ?? false; - - // Set max file read size (default 10MB) - this.maxFileReadSize = options.maxFileReadSize ?? 10485760; - - // Verify root exists and is a directory - if (!fs.existsSync(this.root)) { - throw new Error(`OverlayFs root does not exist: ${this.root}`); - } - const stat = fs.statSync(this.root); - if (!stat.isDirectory()) { - throw new Error(`OverlayFs root is not a directory: ${this.root}`); - } - - // Create mount point directory structure in memory layer - this.createMountPointDirs(); - } - - /** - * Throws an error if the filesystem is in read-only mode. - */ - private assertWritable(operation: string): void { - if (this.readOnly) { - throw new Error(`EROFS: read-only file system, ${operation}`); - } - } - - /** - * Create directory entries for the mount point path - */ - private createMountPointDirs(): void { - const parts = this.mountPoint.split("/").filter(Boolean); - let current = ""; - for (const part of parts) { - current += `/${part}`; - if (!this.memory.has(current)) { - this.memory.set(current, { - type: "directory", - mode: 0o755, - mtime: new Date(), - }); - } - } - // Also ensure root exists - if (!this.memory.has("/")) { - this.memory.set("/", { - type: "directory", - mode: 0o755, - mtime: new Date(), - }); - } - } - - /** - * Get the mount point for this overlay - */ - getMountPoint(): string { - return this.mountPoint; - } - - /** - * Create a virtual directory in memory (sync, for initialization) - */ - mkdirSync(path: string, _options?: MkdirOptions): void { - const normalized = this.normalizePath(path); - const parts = normalized.split("/").filter(Boolean); - let current = ""; - for (const part of parts) { - current += `/${part}`; - if (!this.memory.has(current)) { - this.memory.set(current, { - type: "directory", - mode: 0o755, - mtime: new Date(), - }); - } - } - } - - /** - * Create a virtual file in memory (sync, for initialization) - */ - writeFileSync(path: string, content: string | Uint8Array): void { - const normalized = this.normalizePath(path); - // Ensure parent directories exist - const parent = this.getDirname(normalized); - if (parent !== "/") { - this.mkdirSync(parent); - } - const buffer = - content instanceof Uint8Array - ? content - : new TextEncoder().encode(content); - this.memory.set(normalized, { - type: "file", - content: buffer, - mode: 0o644, - mtime: new Date(), - }); - } - - private getDirname(path: string): string { - const lastSlash = path.lastIndexOf("/"); - return lastSlash === 0 ? "/" : path.slice(0, lastSlash); - } - - /** - * Normalize a virtual path (resolve . and .., ensure starts with /) - */ - private normalizePath(path: string): string { - if (!path || path === "/") return "/"; - - let normalized = - path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path; - - if (!normalized.startsWith("/")) { - normalized = `/${normalized}`; - } - - const parts = normalized.split("/").filter((p) => p && p !== "."); - const resolved: string[] = []; - - for (const part of parts) { - if (part === "..") { - resolved.pop(); - } else { - resolved.push(part); - } - } - - return `/${resolved.join("/")}` || "/"; - } - - /** - * Check if a normalized virtual path is under the mount point. - * Returns the relative path within the mount point, or null if not under it. - */ - private getRelativeToMount(normalizedPath: string): string | null { - if (this.mountPoint === "/") { - // Mount at root - all paths are relative to mount - return normalizedPath; - } - - if (normalizedPath === this.mountPoint) { - return "/"; - } - - if (normalizedPath.startsWith(`${this.mountPoint}/`)) { - return normalizedPath.slice(this.mountPoint.length); - } - - return null; - } - - /** - * Convert a virtual path to a real filesystem path. - * Returns null if the path is not under the mount point or would escape the root. - */ - private toRealPath(virtualPath: string): string | null { - const normalized = this.normalizePath(virtualPath); - - // Check if path is under the mount point - const relativePath = this.getRelativeToMount(normalized); - if (relativePath === null) { - return null; - } - - const realPath = nodePath.join(this.root, relativePath); - - // Security check: ensure path doesn't escape root - const resolvedReal = nodePath.resolve(realPath); - if ( - !resolvedReal.startsWith(this.root) && - resolvedReal !== this.root.replace(/\/$/, "") - ) { - return null; - } - - return resolvedReal; - } - - private dirname(path: string): string { - const normalized = this.normalizePath(path); - if (normalized === "/") return "/"; - const lastSlash = normalized.lastIndexOf("/"); - return lastSlash === 0 ? "/" : normalized.slice(0, lastSlash); - } - - private ensureParentDirs(path: string): void { - const dir = this.dirname(path); - if (dir === "/") return; - - if (!this.memory.has(dir)) { - this.ensureParentDirs(dir); - this.memory.set(dir, { - type: "directory", - mode: 0o755, - mtime: new Date(), - }); - } - // Remove from deleted set if it was there - this.deleted.delete(dir); - } - - /** - * Check if a path exists in the overlay (memory + real fs - deleted) - */ - private async existsInOverlay(virtualPath: string): Promise { - const normalized = this.normalizePath(virtualPath); - - // Deleted in memory layer? - if (this.deleted.has(normalized)) { - return false; - } - - // Exists in memory layer? - if (this.memory.has(normalized)) { - return true; - } - - // Check real filesystem - const realPath = this.toRealPath(normalized); - if (!realPath) { - return false; - } - - try { - await fs.promises.access(realPath); - return true; - } catch { - return false; - } - } - - async readFile( - path: string, - options?: ReadFileOptions | BufferEncoding, - ): Promise { - const buffer = await this.readFileBuffer(path); - const encoding = getEncoding(options); - return fromBuffer(buffer, encoding); - } - - async readFileBuffer( - path: string, - seen: Set = new Set(), - ): Promise { - validatePath(path, "open"); - const normalized = this.normalizePath(path); - - // Detect symlink loops - if (seen.has(normalized)) { - throw new Error( - `ELOOP: too many levels of symbolic links, open '${path}'`, - ); - } - seen.add(normalized); - - // Check if deleted - if (this.deleted.has(normalized)) { - throw new Error(`ENOENT: no such file or directory, open '${path}'`); - } - - // Check memory layer first - const memEntry = this.memory.get(normalized); - if (memEntry) { - if (memEntry.type === "symlink") { - const target = this.resolveSymlink(normalized, memEntry.target); - return this.readFileBuffer(target, seen); - } - if (memEntry.type !== "file") { - throw new Error( - `EISDIR: illegal operation on a directory, read '${path}'`, - ); - } - return memEntry.content; - } - - // Fall back to real filesystem - const realPath = this.toRealPath(normalized); - if (!realPath) { - throw new Error(`ENOENT: no such file or directory, open '${path}'`); - } - - try { - const stat = await fs.promises.lstat(realPath); - if (stat.isSymbolicLink()) { - const target = await fs.promises.readlink(realPath); - const resolvedTarget = this.resolveSymlink(normalized, target); - return this.readFileBuffer(resolvedTarget, seen); - } - if (stat.isDirectory()) { - throw new Error( - `EISDIR: illegal operation on a directory, read '${path}'`, - ); - } - if (this.maxFileReadSize > 0 && stat.size > this.maxFileReadSize) { - throw new Error( - `EFBIG: file too large, read '${path}' (${stat.size} bytes, max ${this.maxFileReadSize})`, - ); - } - const content = await fs.promises.readFile(realPath); - return new Uint8Array(content); - } catch (e) { - if ((e as NodeJS.ErrnoException).code === "ENOENT") { - throw new Error(`ENOENT: no such file or directory, open '${path}'`); - } - throw e; - } - } - - async writeFile( - path: string, - content: FileContent, - options?: WriteFileOptions | BufferEncoding, - ): Promise { - validatePath(path, "write"); - this.assertWritable(`write '${path}'`); - const normalized = this.normalizePath(path); - this.ensureParentDirs(normalized); - - const encoding = getEncoding(options); - const buffer = toBuffer(content, encoding); - - this.memory.set(normalized, { - type: "file", - content: buffer, - mode: 0o644, - mtime: new Date(), - }); - this.deleted.delete(normalized); - } - - async appendFile( - path: string, - content: FileContent, - options?: WriteFileOptions | BufferEncoding, - ): Promise { - validatePath(path, "append"); - this.assertWritable(`append '${path}'`); - const normalized = this.normalizePath(path); - const encoding = getEncoding(options); - const newBuffer = toBuffer(content, encoding); - - // Try to read existing content - let existingBuffer: Uint8Array; - try { - existingBuffer = await this.readFileBuffer(normalized); - } catch { - existingBuffer = new Uint8Array(0); - } - - const combined = new Uint8Array(existingBuffer.length + newBuffer.length); - combined.set(existingBuffer); - combined.set(newBuffer, existingBuffer.length); - - this.ensureParentDirs(normalized); - this.memory.set(normalized, { - type: "file", - content: combined, - mode: 0o644, - mtime: new Date(), - }); - this.deleted.delete(normalized); - } - - async exists(path: string): Promise { - if (path.includes("\0")) { - return false; - } - return this.existsInOverlay(path); - } - - async stat(path: string, seen: Set = new Set()): Promise { - validatePath(path, "stat"); - const normalized = this.normalizePath(path); - - // Detect symlink loops - if (seen.has(normalized)) { - throw new Error( - `ELOOP: too many levels of symbolic links, stat '${path}'`, - ); - } - seen.add(normalized); - - if (this.deleted.has(normalized)) { - throw new Error(`ENOENT: no such file or directory, stat '${path}'`); - } - - // Check memory layer first - const entry = this.memory.get(normalized); - if (entry) { - // Follow symlinks - if (entry.type === "symlink") { - const target = this.resolveSymlink(normalized, entry.target); - return this.stat(target, seen); - } - - let size = 0; - if (entry.type === "file") { - size = entry.content.length; - } - - return { - isFile: entry.type === "file", - isDirectory: entry.type === "directory", - isSymbolicLink: false, - mode: entry.mode, - size, - mtime: entry.mtime, - }; - } - - // Fall back to real filesystem - const realPath = this.toRealPath(normalized); - if (!realPath) { - throw new Error(`ENOENT: no such file or directory, stat '${path}'`); - } - - try { - const stat = await fs.promises.stat(realPath); - return { - isFile: stat.isFile(), - isDirectory: stat.isDirectory(), - isSymbolicLink: false, - mode: stat.mode, - size: stat.size, - mtime: stat.mtime, - }; - } catch (e) { - if ((e as NodeJS.ErrnoException).code === "ENOENT") { - throw new Error(`ENOENT: no such file or directory, stat '${path}'`); - } - throw e; - } - } - - async lstat(path: string): Promise { - validatePath(path, "lstat"); - const normalized = this.normalizePath(path); - - if (this.deleted.has(normalized)) { - throw new Error(`ENOENT: no such file or directory, lstat '${path}'`); - } - - // Check memory layer first - const entry = this.memory.get(normalized); - if (entry) { - if (entry.type === "symlink") { - return { - isFile: false, - isDirectory: false, - isSymbolicLink: true, - mode: entry.mode, - size: entry.target.length, - mtime: entry.mtime, - }; - } - - let size = 0; - if (entry.type === "file") { - size = entry.content.length; - } - - return { - isFile: entry.type === "file", - isDirectory: entry.type === "directory", - isSymbolicLink: false, - mode: entry.mode, - size, - mtime: entry.mtime, - }; - } - - // Fall back to real filesystem - const realPath = this.toRealPath(normalized); - if (!realPath) { - throw new Error(`ENOENT: no such file or directory, lstat '${path}'`); - } - - try { - const stat = await fs.promises.lstat(realPath); - return { - isFile: stat.isFile(), - isDirectory: stat.isDirectory(), - isSymbolicLink: stat.isSymbolicLink(), - mode: stat.mode, - size: stat.size, - mtime: stat.mtime, - }; - } catch (e) { - if ((e as NodeJS.ErrnoException).code === "ENOENT") { - throw new Error(`ENOENT: no such file or directory, lstat '${path}'`); - } - throw e; - } - } - - private resolveSymlink(symlinkPath: string, target: string): string { - if (target.startsWith("/")) { - return this.normalizePath(target); - } - const dir = this.dirname(symlinkPath); - return this.normalizePath(dir === "/" ? `/${target}` : `${dir}/${target}`); - } - - async mkdir(path: string, options?: MkdirOptions): Promise { - validatePath(path, "mkdir"); - this.assertWritable(`mkdir '${path}'`); - const normalized = this.normalizePath(path); - - // Check if it exists (in memory or real fs) - const exists = await this.existsInOverlay(normalized); - if (exists) { - if (!options?.recursive) { - throw new Error(`EEXIST: file already exists, mkdir '${path}'`); - } - return; - } - - // Check parent exists - const parent = this.dirname(normalized); - if (parent !== "/") { - const parentExists = await this.existsInOverlay(parent); - if (!parentExists) { - if (options?.recursive) { - await this.mkdir(parent, { recursive: true }); - } else { - throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`); - } - } - } - - this.memory.set(normalized, { - type: "directory", - mode: 0o755, - mtime: new Date(), - }); - this.deleted.delete(normalized); - } - - /** - * Core readdir implementation that returns entries with file types. - * Both readdir and readdirWithFileTypes use this shared implementation. - */ - private async readdirCore( - path: string, - normalized: string, - ): Promise> { - if (this.deleted.has(normalized)) { - throw new Error(`ENOENT: no such file or directory, scandir '${path}'`); - } - - const entriesMap = new Map(); - const deletedChildren = new Set(); - - // Collect deleted entries that are direct children of this path - const prefix = normalized === "/" ? "/" : `${normalized}/`; - for (const deletedPath of this.deleted) { - if (deletedPath.startsWith(prefix)) { - const rest = deletedPath.slice(prefix.length); - const name = rest.split("/")[0]; - if (name && !rest.includes("/", name.length)) { - deletedChildren.add(name); - } - } - } - - // Add entries from memory layer (with type info) - for (const [memPath, entry] of this.memory) { - if (memPath === normalized) continue; - if (memPath.startsWith(prefix)) { - const rest = memPath.slice(prefix.length); - const name = rest.split("/")[0]; - if (name && !deletedChildren.has(name) && !rest.includes("/", 1)) { - // Direct child - entriesMap.set(name, { - name, - isFile: entry.type === "file", - isDirectory: entry.type === "directory", - isSymbolicLink: entry.type === "symlink", - }); - } - } - } - - // Add entries from real filesystem with file types - const realPath = this.toRealPath(normalized); - if (realPath) { - try { - const realEntries = await fs.promises.readdir(realPath, { - withFileTypes: true, - }); - for (const dirent of realEntries) { - if ( - !deletedChildren.has(dirent.name) && - !entriesMap.has(dirent.name) - ) { - entriesMap.set(dirent.name, { - name: dirent.name, - isFile: dirent.isFile(), - isDirectory: dirent.isDirectory(), - isSymbolicLink: dirent.isSymbolicLink(), - }); - } - } - } catch (e) { - // If it's ENOENT and we don't have it in memory, throw - if ((e as NodeJS.ErrnoException).code === "ENOENT") { - if (!this.memory.has(normalized)) { - throw new Error( - `ENOENT: no such file or directory, scandir '${path}'`, - ); - } - } else if ((e as NodeJS.ErrnoException).code !== "ENOTDIR") { - throw e; - } - } - } - - return entriesMap; - } - - /** - * Follow symlinks to resolve the final directory path. - * Returns outsideOverlay: true if the symlink points outside the overlay or - * the resolved target doesn't exist (security - broken symlinks return []). - */ - private async resolveForReaddir( - path: string, - followedSymlink = false, - ): Promise<{ normalized: string; outsideOverlay: boolean }> { - let normalized = this.normalizePath(path); - const seen = new Set(); - let didFollowSymlink = followedSymlink; - - // Check memory layer first - let entry = this.memory.get(normalized); - while (entry && entry.type === "symlink") { - if (seen.has(normalized)) { - throw new Error( - `ELOOP: too many levels of symbolic links, scandir '${path}'`, - ); - } - seen.add(normalized); - didFollowSymlink = true; - normalized = this.resolveSymlink(normalized, entry.target); - entry = this.memory.get(normalized); - } - - // If in memory and not a symlink, we're done - if (entry) { - return { normalized, outsideOverlay: false }; - } - - // Check if the resolved path is within the overlay's mount point - const relativePath = this.getRelativeToMount(normalized); - if (relativePath === null) { - // Path is outside the overlay - return indicator for secure handling - return { normalized, outsideOverlay: true }; - } - - // Check real filesystem - const realPath = this.toRealPath(normalized); - if (!realPath) { - // Path doesn't map to real filesystem (security check failed) - return { normalized, outsideOverlay: true }; - } - - try { - const stat = await fs.promises.lstat(realPath); - if (stat.isSymbolicLink()) { - const target = await fs.promises.readlink(realPath); - const resolvedTarget = this.resolveSymlink(normalized, target); - return this.resolveForReaddir(resolvedTarget, true); - } - // Path exists on real filesystem - return { normalized, outsideOverlay: false }; - } catch { - // Path doesn't exist on real fs - if (didFollowSymlink) { - // Followed a symlink but target doesn't exist - broken symlink, return [] - return { normalized, outsideOverlay: true }; - } - // No symlink was followed, let readdirCore handle the ENOENT - return { normalized, outsideOverlay: false }; - } - } - - async readdir(path: string): Promise { - validatePath(path, "scandir"); - const { normalized, outsideOverlay } = await this.resolveForReaddir(path); - if (outsideOverlay) { - // Security: symlink points outside overlay, return empty - return []; - } - const entriesMap = await this.readdirCore(path, normalized); - // Sort using case-sensitive comparison to match native behavior - return Array.from(entriesMap.keys()).sort((a, b) => - a < b ? -1 : a > b ? 1 : 0, - ); - } - - async readdirWithFileTypes(path: string): Promise { - validatePath(path, "scandir"); - const { normalized, outsideOverlay } = await this.resolveForReaddir(path); - if (outsideOverlay) { - // Security: symlink points outside overlay, return empty - return []; - } - const entriesMap = await this.readdirCore(path, normalized); - // Sort using case-sensitive comparison to match native behavior - return Array.from(entriesMap.values()).sort((a, b) => - a.name < b.name ? -1 : a.name > b.name ? 1 : 0, - ); - } - - async rm(path: string, options?: RmOptions): Promise { - validatePath(path, "rm"); - this.assertWritable(`rm '${path}'`); - const normalized = this.normalizePath(path); - - const exists = await this.existsInOverlay(normalized); - if (!exists) { - if (options?.force) return; - throw new Error(`ENOENT: no such file or directory, rm '${path}'`); - } - - // Check if it's a directory - try { - const stat = await this.stat(normalized); - if (stat.isDirectory) { - const children = await this.readdir(normalized); - if (children.length > 0) { - if (!options?.recursive) { - throw new Error(`ENOTEMPTY: directory not empty, rm '${path}'`); - } - for (const child of children) { - const childPath = - normalized === "/" ? `/${child}` : `${normalized}/${child}`; - await this.rm(childPath, options); - } - } - } - } catch { - // If stat fails, we'll just mark it as deleted - } - - // Mark as deleted and remove from memory - this.deleted.add(normalized); - this.memory.delete(normalized); - } - - async cp(src: string, dest: string, options?: CpOptions): Promise { - validatePath(src, "cp"); - validatePath(dest, "cp"); - this.assertWritable(`cp '${dest}'`); - const srcNorm = this.normalizePath(src); - const destNorm = this.normalizePath(dest); - - const srcExists = await this.existsInOverlay(srcNorm); - if (!srcExists) { - throw new Error(`ENOENT: no such file or directory, cp '${src}'`); - } - - const srcStat = await this.stat(srcNorm); - - if (srcStat.isFile) { - const content = await this.readFileBuffer(srcNorm); - await this.writeFile(destNorm, content); - } else if (srcStat.isDirectory) { - if (!options?.recursive) { - throw new Error(`EISDIR: is a directory, cp '${src}'`); - } - await this.mkdir(destNorm, { recursive: true }); - const children = await this.readdir(srcNorm); - for (const child of children) { - const srcChild = srcNorm === "/" ? `/${child}` : `${srcNorm}/${child}`; - const destChild = - destNorm === "/" ? `/${child}` : `${destNorm}/${child}`; - await this.cp(srcChild, destChild, options); - } - } - } - - async mv(src: string, dest: string): Promise { - this.assertWritable(`mv '${dest}'`); - await this.cp(src, dest, { recursive: true }); - await this.rm(src, { recursive: true }); - } - - resolvePath(base: string, path: string): string { - if (path.startsWith("/")) { - return this.normalizePath(path); - } - const combined = base === "/" ? `/${path}` : `${base}/${path}`; - return this.normalizePath(combined); - } - - getAllPaths(): string[] { - // This is expensive for overlay fs, but we can return what's in memory - // plus scan the real filesystem - const paths = new Set(this.memory.keys()); - - // Remove deleted paths - for (const deleted of this.deleted) { - paths.delete(deleted); - } - - // Add paths from real filesystem (this is a sync operation, be careful) - this.scanRealFs("/", paths); - - return Array.from(paths); - } - - private scanRealFs(virtualDir: string, paths: Set): void { - if (this.deleted.has(virtualDir)) return; - - const realPath = this.toRealPath(virtualDir); - if (!realPath) return; - - try { - const entries = fs.readdirSync(realPath); - for (const entry of entries) { - const virtualPath = - virtualDir === "/" ? `/${entry}` : `${virtualDir}/${entry}`; - if (this.deleted.has(virtualPath)) continue; - paths.add(virtualPath); - - const entryRealPath = nodePath.join(realPath, entry); - const stat = fs.statSync(entryRealPath); - if (stat.isDirectory()) { - this.scanRealFs(virtualPath, paths); - } - } - } catch { - // Ignore errors - } - } - - async chmod(path: string, mode: number): Promise { - validatePath(path, "chmod"); - this.assertWritable(`chmod '${path}'`); - const normalized = this.normalizePath(path); - - const exists = await this.existsInOverlay(normalized); - if (!exists) { - throw new Error(`ENOENT: no such file or directory, chmod '${path}'`); - } - - // If in memory, update there - const entry = this.memory.get(normalized); - if (entry) { - entry.mode = mode; - return; - } - - // If from real fs, we need to copy to memory layer first - const stat = await this.stat(normalized); - if (stat.isFile) { - const content = await this.readFileBuffer(normalized); - this.memory.set(normalized, { - type: "file", - content, - mode, - mtime: new Date(), - }); - } else if (stat.isDirectory) { - this.memory.set(normalized, { - type: "directory", - mode, - mtime: new Date(), - }); - } - } - - async symlink(target: string, linkPath: string): Promise { - validatePath(linkPath, "symlink"); - this.assertWritable(`symlink '${linkPath}'`); - const normalized = this.normalizePath(linkPath); - - const exists = await this.existsInOverlay(normalized); - if (exists) { - throw new Error(`EEXIST: file already exists, symlink '${linkPath}'`); - } - - this.ensureParentDirs(normalized); - this.memory.set(normalized, { - type: "symlink", - target, - mode: 0o777, - mtime: new Date(), - }); - this.deleted.delete(normalized); - } - - async link(existingPath: string, newPath: string): Promise { - validatePath(existingPath, "link"); - validatePath(newPath, "link"); - this.assertWritable(`link '${newPath}'`); - const existingNorm = this.normalizePath(existingPath); - const newNorm = this.normalizePath(newPath); - - const existingExists = await this.existsInOverlay(existingNorm); - if (!existingExists) { - throw new Error( - `ENOENT: no such file or directory, link '${existingPath}'`, - ); - } - - const existingStat = await this.stat(existingNorm); - if (!existingStat.isFile) { - throw new Error(`EPERM: operation not permitted, link '${existingPath}'`); - } - - const newExists = await this.existsInOverlay(newNorm); - if (newExists) { - throw new Error(`EEXIST: file already exists, link '${newPath}'`); - } - - // Copy content to new location - const content = await this.readFileBuffer(existingNorm); - this.ensureParentDirs(newNorm); - this.memory.set(newNorm, { - type: "file", - content, - mode: existingStat.mode, - mtime: new Date(), - }); - this.deleted.delete(newNorm); - } - - async readlink(path: string): Promise { - validatePath(path, "readlink"); - const normalized = this.normalizePath(path); - - if (this.deleted.has(normalized)) { - throw new Error(`ENOENT: no such file or directory, readlink '${path}'`); - } - - // Check memory layer first - const entry = this.memory.get(normalized); - if (entry) { - if (entry.type !== "symlink") { - throw new Error(`EINVAL: invalid argument, readlink '${path}'`); - } - return entry.target; - } - - // Fall back to real filesystem - const realPath = this.toRealPath(normalized); - if (!realPath) { - throw new Error(`ENOENT: no such file or directory, readlink '${path}'`); - } - - try { - return await fs.promises.readlink(realPath); - } catch (e) { - if ((e as NodeJS.ErrnoException).code === "ENOENT") { - throw new Error( - `ENOENT: no such file or directory, readlink '${path}'`, - ); - } - if ((e as NodeJS.ErrnoException).code === "EINVAL") { - throw new Error(`EINVAL: invalid argument, readlink '${path}'`); - } - throw e; - } - } - - /** - * Resolve all symlinks in a path to get the canonical physical path. - * This is equivalent to POSIX realpath(). - */ - async realpath(path: string): Promise { - validatePath(path, "realpath"); - const normalized = this.normalizePath(path); - const seen = new Set(); - - // Helper to resolve symlinks iteratively - const resolveAll = async (p: string): Promise => { - const parts = p === "/" ? [] : p.slice(1).split("/"); - let resolved = ""; - - for (const part of parts) { - resolved = `${resolved}/${part}`; - - // Check for loops - if (seen.has(resolved)) { - throw new Error( - `ELOOP: too many levels of symbolic links, realpath '${path}'`, - ); - } - - // Check if deleted - if (this.deleted.has(resolved)) { - throw new Error( - `ENOENT: no such file or directory, realpath '${path}'`, - ); - } - - // Check memory layer first - let entry = this.memory.get(resolved); - let loopCount = 0; - const maxLoops = 40; - - while (entry && entry.type === "symlink" && loopCount < maxLoops) { - seen.add(resolved); - resolved = this.resolveSymlink(resolved, entry.target); - loopCount++; - - if (seen.has(resolved)) { - throw new Error( - `ELOOP: too many levels of symbolic links, realpath '${path}'`, - ); - } - - if (this.deleted.has(resolved)) { - throw new Error( - `ENOENT: no such file or directory, realpath '${path}'`, - ); - } - - entry = this.memory.get(resolved); - } - - if (loopCount >= maxLoops) { - throw new Error( - `ELOOP: too many levels of symbolic links, realpath '${path}'`, - ); - } - - // If not in memory, check real filesystem - if (!entry) { - const realPath = this.toRealPath(resolved); - if (realPath) { - try { - const stat = await fs.promises.lstat(realPath); - if (stat.isSymbolicLink()) { - const target = await fs.promises.readlink(realPath); - seen.add(resolved); - resolved = this.resolveSymlink(resolved, target); - - // Continue resolving from the new path - // We need to restart from this point to handle nested symlinks - return resolveAll(resolved); - } - } catch (e) { - if ((e as NodeJS.ErrnoException).code === "ENOENT") { - throw new Error( - `ENOENT: no such file or directory, realpath '${path}'`, - ); - } - throw e; - } - } - } - } - - return resolved || "/"; - }; - - const result = await resolveAll(normalized); - - // Verify the final path exists - const exists = await this.existsInOverlay(result); - if (!exists) { - throw new Error(`ENOENT: no such file or directory, realpath '${path}'`); - } - - return result; - } - - /** - * Set access and modification times of a file - * @param path - The file path - * @param _atime - Access time (ignored, kept for API compatibility) - * @param mtime - Modification time - */ - async utimes(path: string, _atime: Date, mtime: Date): Promise { - validatePath(path, "utimes"); - this.assertWritable(`utimes '${path}'`); - const normalized = this.normalizePath(path); - - const exists = await this.existsInOverlay(normalized); - if (!exists) { - throw new Error(`ENOENT: no such file or directory, utimes '${path}'`); - } - - // If in memory, update there - const entry = this.memory.get(normalized); - if (entry) { - entry.mtime = mtime; - return; - } - - // If from real fs, we need to copy to memory layer first - const stat = await this.stat(normalized); - if (stat.isFile) { - const content = await this.readFileBuffer(normalized); - this.memory.set(normalized, { - type: "file", - content, - mode: stat.mode, - mtime, - }); - } else if (stat.isDirectory) { - this.memory.set(normalized, { - type: "directory", - mode: stat.mode, - mtime, - }); - } - } -} diff --git a/src/fs/read-write-fs/index.ts b/src/fs/read-write-fs/index.ts deleted file mode 100644 index 8d662489..00000000 --- a/src/fs/read-write-fs/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ReadWriteFs, type ReadWriteFsOptions } from "./read-write-fs.js"; diff --git a/src/fs/read-write-fs/read-write-fs.piping.test.ts b/src/fs/read-write-fs/read-write-fs.piping.test.ts deleted file mode 100644 index e10c5fc1..00000000 --- a/src/fs/read-write-fs/read-write-fs.piping.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; -import { ReadWriteFs } from "./read-write-fs.js"; - -/** - * Test piping with ReadWriteFs (real filesystem) - * This test suite validates that just-bash can handle large data through pipes - * when using ReadWriteFs backed by the real filesystem. - */ -describe("ReadWriteFs - Piping with large data", () => { - let tempDir: string; - let fs: ReadWriteFs; - let bash: Bash; - - beforeAll(async () => { - // Create a real temp directory - tempDir = await mkdtemp(join(tmpdir(), "bash-test-")); - console.log("Created temp dir:", tempDir); - - // Use ReadWriteFs with real filesystem - fs = new ReadWriteFs({ root: tempDir }); - bash = new Bash({ fs }); - }); - - afterAll(async () => { - // Cleanup - if (tempDir) { - await rm(tempDir, { recursive: true, force: true }); - console.log("Cleaned up temp dir:", tempDir); - } - }); - - it("should handle large data with wc -l using ReadWriteFs", async () => { - // Create large text data with trailing newline (standard for text files) - const lines = Array.from({ length: 50000 }, (_, i) => `Line ${i + 1}`); - const largeText = `${lines.join("\n")}\n`; - - console.log( - `Generated text size: ${(largeText.length / 1024 / 1024).toFixed(2)}MB`, - ); - console.log(`Line count: ${lines.length}`); - - // Write to file - await fs.writeFile("/data.txt", largeText); - - // Test piping through cat - const result = await bash.exec("cat /data.txt | wc -l"); - - console.log("Result stdout:", result.stdout.trim()); - console.log("Result stderr:", result.stderr); - console.log("Result exitCode:", result.exitCode); - - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("50000"); - }, 30000); - - it("should handle large data with wc -l FILENAME using ReadWriteFs", async () => { - // Create large text data with trailing newline - const lines = Array.from({ length: 50000 }, (_, i) => `Line ${i + 1}`); - const largeText = `${lines.join("\n")}\n`; - - // Write to file - await fs.writeFile("/data2.txt", largeText); - - // Test direct file access - const result = await bash.exec("wc -l /data2.txt"); - - console.log("Result stdout:", result.stdout.trim()); - console.log("Result exitCode:", result.exitCode); - - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toContain("50000"); - }, 30000); - - it("should handle small data with wc -l using ReadWriteFs", async () => { - // Create small text data with trailing newline - const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`); - const smallText = `${lines.join("\n")}\n`; - - // Write to file - await fs.writeFile("/small.txt", smallText); - - // Test piping through cat - const result = await bash.exec("cat /small.txt | wc -l"); - - console.log("Result stdout:", result.stdout.trim()); - console.log("Result exitCode:", result.exitCode); - - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("100"); - }, 30000); - - it("should handle medium data with multiple pipes", async () => { - // Create medium text data with some repeated lines - const lines = Array.from({ length: 10000 }, (_, i) => { - // Create some duplicates - const lineNum = Math.floor(i / 2); - return `Line ${lineNum}`; - }); - const mediumText = lines.join("\n"); - - // Write to file - await fs.writeFile("/medium.txt", mediumText); - - // Test piping through multiple commands - const result = await bash.exec("cat /medium.txt | sort | uniq | wc -l"); - - console.log("Result stdout:", result.stdout.trim()); - console.log("Result exitCode:", result.exitCode); - - expect(result.exitCode).toBe(0); - // Should have 5000 unique lines (0-4999) - expect(result.stdout.trim()).toBe("5000"); - }, 30000); - - it("should handle grep with large files", async () => { - // Create large text data with specific patterns - const lines = Array.from({ length: 20000 }, (_, i) => { - if (i % 3 === 0) { - return `MATCH Line ${i}`; - } - return `Other Line ${i}`; - }); - const largeText = lines.join("\n"); - - // Write to file - await fs.writeFile("/grep-test.txt", largeText); - - // Test grep with wc - const result = await bash.exec("grep MATCH /grep-test.txt | wc -l"); - - console.log("Result stdout:", result.stdout.trim()); - console.log("Result exitCode:", result.exitCode); - - expect(result.exitCode).toBe(0); - // Should match every 3rd line: 20000/3 = 6667 (rounded up) - expect(result.stdout.trim()).toBe("6667"); - }, 30000); - - it("should handle binary data correctly", async () => { - // Create binary data - const binaryData = new Uint8Array(10000); - for (let i = 0; i < binaryData.length; i++) { - binaryData[i] = i % 256; - } - - // Write binary file - await fs.writeFile("/binary.bin", binaryData); - - // Test wc -c (byte count) - const result = await bash.exec("wc -c /binary.bin"); - - console.log("Result stdout:", result.stdout.trim()); - console.log("Result exitCode:", result.exitCode); - - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toContain("10000"); - }, 30000); -}); - -// Made with Bob diff --git a/src/fs/read-write-fs/read-write-fs.security.test.ts b/src/fs/read-write-fs/read-write-fs.security.test.ts deleted file mode 100644 index e9b3fc33..00000000 --- a/src/fs/read-write-fs/read-write-fs.security.test.ts +++ /dev/null @@ -1,488 +0,0 @@ -/** - * Security tests for ReadWriteFs path traversal protection - * - * These tests attempt to escape the root directory using various - * attack techniques. All should fail safely. - * - * CRITICAL: Since ReadWriteFs writes directly to the real filesystem, - * path traversal vulnerabilities could allow attackers to read/write - * arbitrary files on the system. - */ - -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { ReadWriteFs } from "./read-write-fs.js"; - -describe("ReadWriteFs Security - Path Traversal Prevention", () => { - let tempDir: string; - let rwfs: ReadWriteFs; - - // Create a file outside the sandbox that we'll try to access - let outsideFile: string; - let outsideDir: string; - - beforeEach(() => { - // Create sandbox directory - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "rwfs-sandbox-")); - - // Create a sibling directory with a secret file (simulates sensitive data) - outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "rwfs-outside-")); - outsideFile = path.join(outsideDir, "secret.txt"); - fs.writeFileSync(outsideFile, "TOP SECRET DATA - YOU SHOULD NOT SEE THIS"); - - // Create some files inside the sandbox - fs.writeFileSync(path.join(tempDir, "allowed.txt"), "This is allowed"); - fs.mkdirSync(path.join(tempDir, "subdir")); - fs.writeFileSync( - path.join(tempDir, "subdir", "nested.txt"), - "Nested allowed", - ); - - rwfs = new ReadWriteFs({ root: tempDir }); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - fs.rmSync(outsideDir, { recursive: true, force: true }); - }); - - describe("basic path traversal with ..", () => { - it("should block simple ../", async () => { - const content = await rwfs.readFile("/../allowed.txt"); - // Path normalizes to /allowed.txt within root - expect(content).toBe("This is allowed"); - }); - - it("should not read files outside root with ../", async () => { - // Attempting to read outside should fail or read from within root - await expect( - rwfs.readFile("/../../../../../../../etc/passwd"), - ).rejects.toThrow(); - }); - - it("should block ../ from subdirectory", async () => { - // This should read allowed.txt from within root, not escape - const content = await rwfs.readFile("/subdir/../allowed.txt"); - expect(content).toBe("This is allowed"); - }); - - it("should block deeply nested escape attempts", async () => { - const deepPath = - "/a/b/c/d/e/../../../../../../../../../../../../../etc/passwd"; - await expect(rwfs.readFile(deepPath)).rejects.toThrow(); - }); - }); - - describe("absolute path injection", () => { - it("should not allow reading /etc/passwd directly", async () => { - // /etc/passwd as a virtual path should not read the real /etc/passwd - await expect(rwfs.readFile("/etc/passwd")).rejects.toThrow(); - }); - - it("should not allow reading the outside secret file by absolute path", async () => { - // The absolute path should be treated as a virtual path within root - await expect(rwfs.readFile(outsideFile)).rejects.toThrow(); - }); - - it("should not allow reading outside directory", async () => { - await expect(rwfs.readFile(outsideDir)).rejects.toThrow(); - }); - }); - - describe("write operations security", () => { - it("should not write files outside root with path traversal", async () => { - // Try to write outside using path traversal - await rwfs.writeFile("/../../../tmp/pwned.txt", "PWNED"); - - // The file should be created inside root, not in real /tmp - expect(fs.existsSync("/tmp/pwned.txt")).toBe(false); - // Should exist within our temp dir - expect(fs.existsSync(path.join(tempDir, "tmp/pwned.txt"))).toBe(true); - }); - - it("should not allow absolute path to write outside root", async () => { - const targetPath = path.join(outsideDir, "pwned.txt"); - await rwfs.writeFile(targetPath, "PWNED"); - - // The real outside file should not exist - expect(fs.existsSync(targetPath)).toBe(false); - }); - - it("should not allow mkdir outside root", async () => { - await rwfs.mkdir("/../../../tmp/pwned-dir", { recursive: true }); - - // Should not exist in real /tmp - expect(fs.existsSync("/tmp/pwned-dir")).toBe(false); - }); - - it("should not allow appendFile outside root", async () => { - // Create file outside and try to append - await rwfs.appendFile(outsideFile, "PWNED"); - - // The real outside file should be unchanged - const realContent = fs.readFileSync(outsideFile, "utf8"); - expect(realContent).toBe("TOP SECRET DATA - YOU SHOULD NOT SEE THIS"); - expect(realContent).not.toContain("PWNED"); - }); - }); - - describe("deletion security", () => { - it("should not delete files outside root", async () => { - // Try to delete the outside file - await expect(rwfs.rm(outsideFile)).rejects.toThrow(); - - // The real file should still exist - expect(fs.existsSync(outsideFile)).toBe(true); - }); - - it("should not delete with path traversal", async () => { - // Try to delete using traversal - try { - await rwfs.rm(`/../../../${outsideFile}`); - } catch { - // Expected to fail - } - - // The real file should still exist - expect(fs.existsSync(outsideFile)).toBe(true); - }); - }); - - describe("copy and move security", () => { - it("should not copy from outside root", async () => { - await expect(rwfs.cp(outsideFile, "/stolen.txt")).rejects.toThrow(); - }); - - it("should not copy to outside root", async () => { - fs.writeFileSync(path.join(tempDir, "source.txt"), "source content"); - - // This should NOT write to the real outside path - const targetPath = path.join(outsideDir, "stolen.txt"); - await rwfs.cp("/source.txt", targetPath); - - // Real outside directory should not have the file - expect(fs.existsSync(targetPath)).toBe(false); - }); - - it("should not move from outside root", async () => { - await expect(rwfs.mv(outsideFile, "/stolen.txt")).rejects.toThrow(); - }); - - it("should not move to outside root", async () => { - // Create a file to move using rwfs to ensure it exists - await rwfs.writeFile("/to-move.txt", "move me"); - - // Verify file exists via rwfs - expect(await rwfs.exists("/to-move.txt")).toBe(true); - - const targetPath = path.join(outsideDir, "moved.txt"); - // Note: mv to outside path maps to a deep nested path inside root - // which may fail with ENOENT if parent dirs don't exist - await rwfs.mv("/to-move.txt", targetPath); - - // Real outside directory should not have the file - expect(fs.existsSync(targetPath)).toBe(false); - }); - }); - - describe("stat and chmod security", () => { - it("should not stat files outside root", async () => { - await expect(rwfs.stat(outsideFile)).rejects.toThrow(); - }); - - it("should not chmod files outside root", async () => { - await expect(rwfs.chmod(outsideFile, 0o777)).rejects.toThrow(); - - // The real file permissions should be unchanged - }); - }); - - describe("readdir security", () => { - it("should not list directories outside root", async () => { - await expect(rwfs.readdir(outsideDir)).rejects.toThrow(); - }); - - it("should not list /etc", async () => { - await expect(rwfs.readdir("/etc")).rejects.toThrow(); - }); - - it("should not list real system root", async () => { - // Reading / should give us our sandbox root, not real / - const entries = await rwfs.readdir("/"); - expect(entries).toContain("allowed.txt"); - expect(entries).not.toContain("etc"); - expect(entries).not.toContain("usr"); - expect(entries).not.toContain("var"); - }); - - it("should handle path traversal in readdir", async () => { - const entries = await rwfs.readdir("/../../../"); - // Should resolve to root of our sandbox - expect(entries).toContain("allowed.txt"); - expect(entries).not.toContain("etc"); - }); - }); - - describe("symlink behavior", () => { - // ReadWriteFs validates symlink targets to prevent sandbox escapes. - // All symlink targets are normalized and transformed to point within root. - - it("should create symlinks within root", async () => { - fs.writeFileSync(path.join(tempDir, "target.txt"), "content"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - try { - await rwfs.symlink("target.txt", "/link"); - } catch { - // Skip on systems that don't support symlinks - return; - } - - const content = await rwfs.readFile("/link"); - expect(content).toBe("content"); - }); - - it("should create relative symlinks correctly", async () => { - // Use a different directory name to avoid conflict with beforeEach's subdir - fs.mkdirSync(path.join(tempDir, "linkdir")); - fs.writeFileSync(path.join(tempDir, "linkdir", "file.txt"), "nested"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - try { - await rwfs.symlink("linkdir/file.txt", "/link"); - } catch { - return; - } - - const content = await rwfs.readFile("/link"); - expect(content).toBe("nested"); - }); - - it("should prevent symlink escape with absolute path", async () => { - // Attempting to create a symlink to /etc/passwd should NOT allow reading real /etc/passwd - try { - await rwfs.symlink("/etc/passwd", "/escape-link"); - } catch { - // Skip on systems that don't support symlinks - return; - } - - // The symlink should point to ${root}/etc/passwd, not real /etc/passwd - // Since that file doesn't exist within our sandbox, reading should fail - await expect(rwfs.readFile("/escape-link")).rejects.toThrow("ENOENT"); - }); - - it("should prevent symlink escape with relative path traversal", async () => { - // Attempting to escape via relative path - try { - await rwfs.symlink("../../../etc/passwd", "/escape-link2"); - } catch { - return; - } - - // The symlink should be transformed to point within root - // Reading should fail since /etc/passwd doesn't exist in sandbox - await expect(rwfs.readFile("/escape-link2")).rejects.toThrow("ENOENT"); - }); - - it("should prevent reading outside files via symlink to absolute path", async () => { - // Create symlink pointing to the real outside file's path - try { - await rwfs.symlink(outsideFile, "/steal-secret"); - } catch { - return; - } - - // Should NOT be able to read the real outside file - const result = await rwfs.readFile("/steal-secret").catch((e) => e); - expect(result).toBeInstanceOf(Error); - // If it somehow succeeded, it must not contain the secret - if (typeof result === "string") { - expect(result).not.toContain("TOP SECRET"); - } - }); - }); - - describe("special characters and encoding attacks", () => { - it("should handle null bytes in path", async () => { - await expect(rwfs.readFile("/etc\x00/passwd")).rejects.toThrow(); - }); - - it("should handle paths with newlines", async () => { - await expect(rwfs.readFile("/etc\n/../passwd")).rejects.toThrow(); - }); - - it("should handle backslash as regular character", async () => { - // On Unix, backslash is a valid filename character - await rwfs.writeFile("/back\\slash", "content"); - const content = await rwfs.readFile("/back\\slash"); - expect(content).toBe("content"); - }); - - it("should handle unicode filenames safely", async () => { - await rwfs.writeFile("/файл.txt", "unicode content"); - const content = await rwfs.readFile("/файл.txt"); - expect(content).toBe("unicode content"); - }); - - it("should handle emoji filenames", async () => { - await rwfs.writeFile("/📁file.txt", "emoji content"); - const content = await rwfs.readFile("/📁file.txt"); - expect(content).toBe("emoji content"); - }); - }); - - describe("URL-style encoding (should be treated literally)", () => { - it("should treat %2e%2e as literal filename not ..", async () => { - await rwfs.writeFile("/%2e%2e", "not parent"); - const content = await rwfs.readFile("/%2e%2e"); - expect(content).toBe("not parent"); - }); - - it("should not decode URL-encoded path traversal", async () => { - // %2e = . and %2f = / - await expect(rwfs.readFile("/%2e%2e%2fetc/passwd")).rejects.toThrow(); - }); - }); - - describe("path normalization edge cases", () => { - it("should handle multiple consecutive slashes", async () => { - const content = await rwfs.readFile("////allowed.txt"); - expect(content).toBe("This is allowed"); - }); - - it("should handle trailing slashes on files", async () => { - const content = await rwfs.readFile("/allowed.txt/"); - expect(content).toBe("This is allowed"); - }); - - it("should handle . and .. combinations", async () => { - const content = await rwfs.readFile("/./subdir/../allowed.txt"); - expect(content).toBe("This is allowed"); - }); - - it("should handle path with only slashes", async () => { - const stat = await rwfs.stat("///"); - expect(stat.isDirectory).toBe(true); - }); - }); - - describe("getAllPaths security", () => { - it("should not leak paths outside root", () => { - const paths = rwfs.getAllPaths(); - for (const p of paths) { - expect(p.startsWith("/")).toBe(true); - expect(p).not.toContain(outsideDir); - expect(p).not.toContain(outsideFile); - // Should not contain real system paths - expect(p).not.toMatch(/^\/etc/); - expect(p).not.toMatch(/^\/usr/); - expect(p).not.toMatch(/^\/var/); - } - }); - }); - - describe("concurrent attack resistance", () => { - it("should handle concurrent path traversal attempts", async () => { - const attacks = Array(50) - .fill(null) - .map((_, i) => { - const escapePath = `${"../".repeat(i + 1)}etc/passwd`; - return rwfs.readFile(escapePath).catch(() => "blocked"); - }); - - const results = await Promise.all(attacks); - // All should be blocked (throw error) - expect(results.every((r) => r === "blocked")).toBe(true); - }); - - it("should handle concurrent write attempts outside root", async () => { - const attacks = Array(20) - .fill(null) - .map((_, i) => - rwfs - .writeFile(`/../../../tmp/attack-${i}.txt`, "PWNED") - .catch(() => "blocked"), - ); - - await Promise.all(attacks); - - // No files should exist in real /tmp - for (let i = 0; i < 20; i++) { - expect(fs.existsSync(`/tmp/attack-${i}.txt`)).toBe(false); - } - }); - }); - - describe("Windows-style attacks (should be handled on any OS)", () => { - it("should handle backslash path traversal attempts", async () => { - await expect(rwfs.readFile("\\..\\..\\etc\\passwd")).rejects.toThrow(); - }); - - it("should handle mixed slash styles", async () => { - await expect( - rwfs.readFile("/subdir\\..\\..\\etc/passwd"), - ).rejects.toThrow(); - }); - - it("should handle UNC-style paths", async () => { - await expect( - rwfs.readFile("//server/share/../../etc/passwd"), - ).rejects.toThrow(); - }); - }); - - describe("real-world attack scenarios", () => { - it("should prevent reading SSH keys", async () => { - await expect( - rwfs.readFile("/../../../root/.ssh/id_rsa"), - ).rejects.toThrow(); - await expect(rwfs.readFile("/~/.ssh/id_rsa")).rejects.toThrow(); - }); - - it("should prevent reading shadow file", async () => { - await expect(rwfs.readFile("/../../../etc/shadow")).rejects.toThrow(); - }); - - it("should prevent writing to crontab", async () => { - await rwfs.writeFile("/../../../etc/crontab", "* * * * * evil"); - // Real crontab should not be modified - // (and shouldn't throw - just writes to sandboxed path) - }); - - it("should prevent modifying bashrc", async () => { - await rwfs.writeFile("/../../../root/.bashrc", "evil command"); - // Real bashrc should not be modified - }); - }); - - describe("realpath escape prevention", () => { - it("should throw when realpath resolves outside root via symlink", async () => { - // Create a symlink inside the sandbox pointing outside - const linkPath = path.join(tempDir, "escape-link"); - fs.symlinkSync(outsideFile, linkPath); - - // realpath should throw, not leak the outside path - await expect(rwfs.realpath("/escape-link")).rejects.toThrow("ENOENT"); - }); - - it("should throw when realpath resolves to parent directory via symlink", async () => { - const linkPath = path.join(tempDir, "parent-link"); - fs.symlinkSync(outsideDir, linkPath); - - await expect(rwfs.realpath("/parent-link")).rejects.toThrow("ENOENT"); - }); - - it("should allow realpath for paths within root", async () => { - const result = await rwfs.realpath("/allowed.txt"); - expect(result).toBe("/allowed.txt"); - }); - - it("should allow realpath for nested paths within root", async () => { - const result = await rwfs.realpath("/subdir/nested.txt"); - expect(result).toBe("/subdir/nested.txt"); - }); - }); -}); diff --git a/src/fs/read-write-fs/read-write-fs.test.ts b/src/fs/read-write-fs/read-write-fs.test.ts deleted file mode 100644 index 5a48375d..00000000 --- a/src/fs/read-write-fs/read-write-fs.test.ts +++ /dev/null @@ -1,600 +0,0 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { ReadWriteFs } from "./read-write-fs.js"; - -describe("ReadWriteFs", () => { - let tempDir: string; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "read-write-fs-test-")); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - describe("constructor", () => { - it("should create with valid root directory", () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - expect(rwfs).toBeInstanceOf(ReadWriteFs); - }); - - it("should throw for non-existent root", () => { - expect(() => { - new ReadWriteFs({ root: "/nonexistent/path/12345" }); - }).toThrow("does not exist"); - }); - - it("should throw for file as root", () => { - const filePath = path.join(tempDir, "file.txt"); - fs.writeFileSync(filePath, "content"); - expect(() => { - new ReadWriteFs({ root: filePath }); - }).toThrow("not a directory"); - }); - }); - - describe("reading files", () => { - it("should read files from filesystem", async () => { - fs.writeFileSync(path.join(tempDir, "test.txt"), "real content"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - const content = await rwfs.readFile("/test.txt"); - expect(content).toBe("real content"); - }); - - it("should read nested files", async () => { - fs.mkdirSync(path.join(tempDir, "subdir")); - fs.writeFileSync(path.join(tempDir, "subdir", "file.txt"), "nested"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - const content = await rwfs.readFile("/subdir/file.txt"); - expect(content).toBe("nested"); - }); - - it("should read files as buffer", async () => { - const data = Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" - fs.writeFileSync(path.join(tempDir, "binary.bin"), data); - const rwfs = new ReadWriteFs({ root: tempDir }); - - const buffer = await rwfs.readFileBuffer("/binary.bin"); - expect(buffer).toEqual(new Uint8Array(data)); - }); - - it("should throw ENOENT for non-existent file", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - await expect(rwfs.readFile("/nonexistent.txt")).rejects.toThrow("ENOENT"); - }); - - it("should throw EISDIR when reading a directory", async () => { - fs.mkdirSync(path.join(tempDir, "dir")); - const rwfs = new ReadWriteFs({ root: tempDir }); - await expect(rwfs.readFile("/dir")).rejects.toThrow("EISDIR"); - }); - }); - - describe("writing files", () => { - it("should write files to filesystem", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - - await rwfs.writeFile("/new.txt", "new content"); - - // Should read back from filesystem - const content = await rwfs.readFile("/new.txt"); - expect(content).toBe("new content"); - - // Real filesystem should have the file - expect(fs.readFileSync(path.join(tempDir, "new.txt"), "utf8")).toBe( - "new content", - ); - }); - - it("should create parent directories when writing", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - - await rwfs.writeFile("/deep/nested/file.txt", "content"); - - expect(fs.existsSync(path.join(tempDir, "deep/nested/file.txt"))).toBe( - true, - ); - }); - - it("should overwrite existing files", async () => { - fs.writeFileSync(path.join(tempDir, "test.txt"), "original"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - await rwfs.writeFile("/test.txt", "modified"); - - expect(fs.readFileSync(path.join(tempDir, "test.txt"), "utf8")).toBe( - "modified", - ); - }); - - it("should write binary content", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - const data = new Uint8Array([0x00, 0x01, 0x02, 0xff]); - - await rwfs.writeFile("/binary.bin", data); - - const written = fs.readFileSync(path.join(tempDir, "binary.bin")); - expect(new Uint8Array(written)).toEqual(data); - }); - }); - - describe("appending files", () => { - it("should append to existing files", async () => { - fs.writeFileSync(path.join(tempDir, "append.txt"), "start"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - await rwfs.appendFile("/append.txt", "-end"); - - expect(fs.readFileSync(path.join(tempDir, "append.txt"), "utf8")).toBe( - "start-end", - ); - }); - - it("should create file if it does not exist", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - - await rwfs.appendFile("/new.txt", "content"); - - expect(fs.readFileSync(path.join(tempDir, "new.txt"), "utf8")).toBe( - "content", - ); - }); - }); - - describe("exists", () => { - it("should return true for existing files", async () => { - fs.writeFileSync(path.join(tempDir, "exists.txt"), "content"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - expect(await rwfs.exists("/exists.txt")).toBe(true); - }); - - it("should return true for existing directories", async () => { - fs.mkdirSync(path.join(tempDir, "dir")); - const rwfs = new ReadWriteFs({ root: tempDir }); - - expect(await rwfs.exists("/dir")).toBe(true); - }); - - it("should return false for non-existent paths", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - expect(await rwfs.exists("/nonexistent")).toBe(false); - }); - }); - - describe("stat", () => { - it("should stat files", async () => { - fs.writeFileSync(path.join(tempDir, "file.txt"), "content"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - const stat = await rwfs.stat("/file.txt"); - expect(stat.isFile).toBe(true); - expect(stat.isDirectory).toBe(false); - expect(stat.size).toBe(7); - }); - - it("should stat directories", async () => { - fs.mkdirSync(path.join(tempDir, "dir")); - const rwfs = new ReadWriteFs({ root: tempDir }); - - const stat = await rwfs.stat("/dir"); - expect(stat.isFile).toBe(false); - expect(stat.isDirectory).toBe(true); - }); - - it("should throw ENOENT for non-existent paths", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - await expect(rwfs.stat("/nonexistent")).rejects.toThrow("ENOENT"); - }); - }); - - describe("lstat", () => { - it("should lstat symlinks without following", async () => { - fs.writeFileSync(path.join(tempDir, "target.txt"), "content"); - try { - fs.symlinkSync( - path.join(tempDir, "target.txt"), - path.join(tempDir, "link"), - ); - } catch { - // Skip on systems that don't support symlinks - return; - } - const rwfs = new ReadWriteFs({ root: tempDir }); - - const stat = await rwfs.lstat("/link"); - expect(stat.isSymbolicLink).toBe(true); - }); - }); - - describe("mkdir", () => { - it("should create directories", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - - await rwfs.mkdir("/newdir"); - - expect(fs.statSync(path.join(tempDir, "newdir")).isDirectory()).toBe( - true, - ); - }); - - it("should create nested directories with recursive option", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - - await rwfs.mkdir("/a/b/c", { recursive: true }); - - expect(fs.statSync(path.join(tempDir, "a/b/c")).isDirectory()).toBe(true); - }); - - it("should throw ENOENT without recursive for missing parent", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - - await expect(rwfs.mkdir("/missing/dir")).rejects.toThrow("ENOENT"); - }); - - it("should throw EEXIST for existing directory without recursive", async () => { - fs.mkdirSync(path.join(tempDir, "existing")); - const rwfs = new ReadWriteFs({ root: tempDir }); - - await expect(rwfs.mkdir("/existing")).rejects.toThrow("EEXIST"); - }); - }); - - describe("readdir", () => { - it("should list directory contents", async () => { - fs.writeFileSync(path.join(tempDir, "a.txt"), "a"); - fs.writeFileSync(path.join(tempDir, "b.txt"), "b"); - fs.mkdirSync(path.join(tempDir, "subdir")); - const rwfs = new ReadWriteFs({ root: tempDir }); - - const entries = await rwfs.readdir("/"); - expect(entries).toContain("a.txt"); - expect(entries).toContain("b.txt"); - expect(entries).toContain("subdir"); - }); - - it("should throw ENOENT for non-existent directory", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - await expect(rwfs.readdir("/nonexistent")).rejects.toThrow("ENOENT"); - }); - - it("should throw ENOTDIR for files", async () => { - fs.writeFileSync(path.join(tempDir, "file.txt"), "content"); - const rwfs = new ReadWriteFs({ root: tempDir }); - await expect(rwfs.readdir("/file.txt")).rejects.toThrow("ENOTDIR"); - }); - }); - - describe("rm", () => { - it("should delete files", async () => { - fs.writeFileSync(path.join(tempDir, "delete.txt"), "content"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - await rwfs.rm("/delete.txt"); - - expect(fs.existsSync(path.join(tempDir, "delete.txt"))).toBe(false); - }); - - it("should delete directories recursively", async () => { - fs.mkdirSync(path.join(tempDir, "dir")); - fs.writeFileSync(path.join(tempDir, "dir", "file.txt"), "content"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - await rwfs.rm("/dir", { recursive: true }); - - expect(fs.existsSync(path.join(tempDir, "dir"))).toBe(false); - }); - - it("should throw ENOENT without force option", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - await expect(rwfs.rm("/nonexistent")).rejects.toThrow("ENOENT"); - }); - - it("should not throw with force option for non-existent files", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - await expect( - rwfs.rm("/nonexistent", { force: true }), - ).resolves.not.toThrow(); - }); - }); - - describe("cp", () => { - it("should copy files", async () => { - fs.writeFileSync(path.join(tempDir, "source.txt"), "content"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - await rwfs.cp("/source.txt", "/dest.txt"); - - expect(fs.readFileSync(path.join(tempDir, "dest.txt"), "utf8")).toBe( - "content", - ); - }); - - it("should copy directories recursively", async () => { - fs.mkdirSync(path.join(tempDir, "srcdir")); - fs.writeFileSync(path.join(tempDir, "srcdir", "file.txt"), "content"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - await rwfs.cp("/srcdir", "/destdir", { recursive: true }); - - expect( - fs.readFileSync(path.join(tempDir, "destdir", "file.txt"), "utf8"), - ).toBe("content"); - }); - - it("should throw ENOENT for non-existent source", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - await expect(rwfs.cp("/nonexistent", "/dest")).rejects.toThrow("ENOENT"); - }); - }); - - describe("mv", () => { - it("should move files", async () => { - fs.writeFileSync(path.join(tempDir, "source.txt"), "content"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - await rwfs.mv("/source.txt", "/dest.txt"); - - expect(fs.existsSync(path.join(tempDir, "source.txt"))).toBe(false); - expect(fs.readFileSync(path.join(tempDir, "dest.txt"), "utf8")).toBe( - "content", - ); - }); - - it("should move directories", async () => { - fs.mkdirSync(path.join(tempDir, "srcdir")); - fs.writeFileSync(path.join(tempDir, "srcdir", "file.txt"), "content"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - await rwfs.mv("/srcdir", "/destdir"); - - expect(fs.existsSync(path.join(tempDir, "srcdir"))).toBe(false); - expect( - fs.readFileSync(path.join(tempDir, "destdir", "file.txt"), "utf8"), - ).toBe("content"); - }); - }); - - describe("chmod", () => { - it("should change file permissions", async () => { - fs.writeFileSync(path.join(tempDir, "file.txt"), "content"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - await rwfs.chmod("/file.txt", 0o755); - - const stat = fs.statSync(path.join(tempDir, "file.txt")); - expect(stat.mode & 0o777).toBe(0o755); - }); - - it("should throw ENOENT for non-existent file", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - await expect(rwfs.chmod("/nonexistent", 0o755)).rejects.toThrow("ENOENT"); - }); - }); - - describe("symlink", () => { - it("should create symbolic links", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - - try { - await rwfs.symlink("target.txt", "/link"); - } catch { - // Skip on systems that don't support symlinks - return; - } - - const target = fs.readlinkSync(path.join(tempDir, "link")); - expect(target).toBe("target.txt"); - }); - - it("should throw EEXIST for existing path", async () => { - fs.writeFileSync(path.join(tempDir, "existing"), "content"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - await expect(rwfs.symlink("target", "/existing")).rejects.toThrow( - "EEXIST", - ); - }); - }); - - describe("link", () => { - it("should create hard links", async () => { - fs.writeFileSync(path.join(tempDir, "original.txt"), "content"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - await rwfs.link("/original.txt", "/hardlink.txt"); - - const content = fs.readFileSync( - path.join(tempDir, "hardlink.txt"), - "utf8", - ); - expect(content).toBe("content"); - }); - - it("should throw ENOENT for non-existent source", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - await expect(rwfs.link("/nonexistent", "/link")).rejects.toThrow( - "ENOENT", - ); - }); - - it("should throw EEXIST for existing destination", async () => { - fs.writeFileSync(path.join(tempDir, "source.txt"), "content"); - fs.writeFileSync(path.join(tempDir, "existing.txt"), "existing"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - await expect(rwfs.link("/source.txt", "/existing.txt")).rejects.toThrow( - "EEXIST", - ); - }); - }); - - describe("readlink", () => { - it("should read symlink target", async () => { - try { - fs.symlinkSync("target.txt", path.join(tempDir, "link")); - } catch { - // Skip on systems that don't support symlinks - return; - } - const rwfs = new ReadWriteFs({ root: tempDir }); - - const target = await rwfs.readlink("/link"); - expect(target).toBe("target.txt"); - }); - - it("should throw ENOENT for non-existent symlink", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - await expect(rwfs.readlink("/nonexistent")).rejects.toThrow("ENOENT"); - }); - - it("should throw EINVAL for non-symlink", async () => { - fs.writeFileSync(path.join(tempDir, "file.txt"), "content"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - await expect(rwfs.readlink("/file.txt")).rejects.toThrow("EINVAL"); - }); - }); - - describe("resolvePath", () => { - it("should resolve relative paths", () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - - expect(rwfs.resolvePath("/dir", "file.txt")).toBe("/dir/file.txt"); - expect(rwfs.resolvePath("/dir", "../file.txt")).toBe("/file.txt"); - }); - - it("should handle absolute paths", () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - - expect(rwfs.resolvePath("/dir", "/other/file.txt")).toBe( - "/other/file.txt", - ); - }); - }); - - describe("getAllPaths", () => { - it("should return all paths in filesystem", () => { - fs.writeFileSync(path.join(tempDir, "a.txt"), "a"); - fs.mkdirSync(path.join(tempDir, "subdir")); - fs.writeFileSync(path.join(tempDir, "subdir", "b.txt"), "b"); - const rwfs = new ReadWriteFs({ root: tempDir }); - - const paths = rwfs.getAllPaths(); - expect(paths).toContain("/a.txt"); - expect(paths).toContain("/subdir"); - expect(paths).toContain("/subdir/b.txt"); - }); - }); - - describe("encoding support", () => { - it("should write and read with base64 encoding", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - const base64Content = btoa("Hello World"); - - await rwfs.writeFile("/base64.txt", base64Content, "base64"); - const content = await rwfs.readFile("/base64.txt"); - expect(content).toBe("Hello World"); - }); - - it("should write and read with hex encoding", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - const hexContent = "48656c6c6f"; // "Hello" - - await rwfs.writeFile("/hex.txt", hexContent, "hex"); - const content = await rwfs.readFile("/hex.txt"); - expect(content).toBe("Hello"); - }); - }); - - describe("readdirWithFileTypes", () => { - it("should return entries with correct type info", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - - fs.writeFileSync(path.join(tempDir, "file.txt"), "content"); - fs.mkdirSync(path.join(tempDir, "subdir")); - - const entries = await rwfs.readdirWithFileTypes("/"); - - const file = entries.find((e) => e.name === "file.txt"); - expect(file).toBeDefined(); - expect(file?.isFile).toBe(true); - expect(file?.isDirectory).toBe(false); - expect(file?.isSymbolicLink).toBe(false); - - const subdir = entries.find((e) => e.name === "subdir"); - expect(subdir).toBeDefined(); - expect(subdir?.isFile).toBe(false); - expect(subdir?.isDirectory).toBe(true); - expect(subdir?.isSymbolicLink).toBe(false); - }); - - it("should return entries sorted case-sensitively", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - - fs.writeFileSync(path.join(tempDir, "Zebra.txt"), "z"); - fs.writeFileSync(path.join(tempDir, "apple.txt"), "a"); - fs.writeFileSync(path.join(tempDir, "Banana.txt"), "b"); - - const entries = await rwfs.readdirWithFileTypes("/"); - const names = entries.map((e) => e.name); - - // Case-sensitive sort: uppercase before lowercase - expect(names).toEqual(["Banana.txt", "Zebra.txt", "apple.txt"]); - }); - - it("should identify symlinks correctly", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - - fs.writeFileSync(path.join(tempDir, "real.txt"), "content"); - fs.symlinkSync( - path.join(tempDir, "real.txt"), - path.join(tempDir, "link.txt"), - ); - - const entries = await rwfs.readdirWithFileTypes("/"); - - const link = entries.find((e) => e.name === "link.txt"); - expect(link).toBeDefined(); - expect(link?.isFile).toBe(false); - expect(link?.isDirectory).toBe(false); - expect(link?.isSymbolicLink).toBe(true); - }); - - it("should throw ENOENT for non-existent directory", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - - await expect(rwfs.readdirWithFileTypes("/nonexistent")).rejects.toThrow( - "ENOENT", - ); - }); - - it("should throw ENOTDIR for file path", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - fs.writeFileSync(path.join(tempDir, "file.txt"), "content"); - - await expect(rwfs.readdirWithFileTypes("/file.txt")).rejects.toThrow( - "ENOTDIR", - ); - }); - - it("should return same names as readdir", async () => { - const rwfs = new ReadWriteFs({ root: tempDir }); - - fs.writeFileSync(path.join(tempDir, "a.txt"), "a"); - fs.writeFileSync(path.join(tempDir, "b.txt"), "b"); - fs.mkdirSync(path.join(tempDir, "sub")); - - const namesFromReaddir = await rwfs.readdir("/"); - const entriesWithTypes = await rwfs.readdirWithFileTypes("/"); - const namesFromWithTypes = entriesWithTypes.map((e) => e.name); - - expect(namesFromWithTypes).toEqual(namesFromReaddir); - }); - }); -}); diff --git a/src/fs/read-write-fs/read-write-fs.ts b/src/fs/read-write-fs/read-write-fs.ts deleted file mode 100644 index 87f0f0ee..00000000 --- a/src/fs/read-write-fs/read-write-fs.ts +++ /dev/null @@ -1,517 +0,0 @@ -/** - * ReadWriteFs - Direct wrapper around the real filesystem - * - * All operations go directly to the underlying Node.js filesystem. - * Paths are relative to the configured root directory. - * - * Security: Symlink targets are validated and transformed to stay within root, - * preventing symlink-based sandbox escape attacks. - */ - -import * as fs from "node:fs"; -import * as nodePath from "node:path"; -import { - type FileContent, - fromBuffer, - getEncoding, - toBuffer, -} from "../encoding.js"; -import type { - CpOptions, - DirentEntry, - FsStat, - IFileSystem, - MkdirOptions, - ReadFileOptions, - RmOptions, - WriteFileOptions, -} from "../interface.js"; - -export interface ReadWriteFsOptions { - /** - * The root directory on the real filesystem. - * All paths are relative to this root. - */ - root: string; - - /** - * Maximum file size in bytes that can be read. - * Files larger than this will throw an EFBIG error. - * Defaults to 10MB (10485760). - */ - maxFileReadSize?: number; -} - -export class ReadWriteFs implements IFileSystem { - private readonly root: string; - private readonly maxFileReadSize: number; - - constructor(options: ReadWriteFsOptions) { - this.root = nodePath.resolve(options.root); - this.maxFileReadSize = options.maxFileReadSize ?? 10485760; - - // Verify root exists and is a directory - if (!fs.existsSync(this.root)) { - throw new Error(`ReadWriteFs root does not exist: ${this.root}`); - } - const stat = fs.statSync(this.root); - if (!stat.isDirectory()) { - throw new Error(`ReadWriteFs root is not a directory: ${this.root}`); - } - } - - /** - * Convert a virtual path to a real filesystem path. - */ - private toRealPath(virtualPath: string): string { - const normalized = this.normalizePath(virtualPath); - const realPath = nodePath.join(this.root, normalized); - return nodePath.resolve(realPath); - } - - /** - * Normalize a virtual path (resolve . and .., ensure starts with /) - */ - private normalizePath(path: string): string { - if (!path || path === "/") return "/"; - - let normalized = - path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path; - - if (!normalized.startsWith("/")) { - normalized = `/${normalized}`; - } - - const parts = normalized.split("/").filter((p) => p && p !== "."); - const resolved: string[] = []; - - for (const part of parts) { - if (part === "..") { - resolved.pop(); - } else { - resolved.push(part); - } - } - - return `/${resolved.join("/")}` || "/"; - } - - async readFile( - path: string, - options?: ReadFileOptions | BufferEncoding, - ): Promise { - const buffer = await this.readFileBuffer(path); - const encoding = getEncoding(options); - return fromBuffer(buffer, encoding); - } - - async readFileBuffer(path: string): Promise { - const realPath = this.toRealPath(path); - - try { - if (this.maxFileReadSize > 0) { - const stat = await fs.promises.lstat(realPath); - if (stat.size > this.maxFileReadSize) { - throw new Error( - `EFBIG: file too large, read '${path}' (${stat.size} bytes, max ${this.maxFileReadSize})`, - ); - } - } - const content = await fs.promises.readFile(realPath); - return new Uint8Array(content); - } catch (e) { - const err = e as NodeJS.ErrnoException; - if (err.code === "ENOENT") { - throw new Error(`ENOENT: no such file or directory, open '${path}'`); - } - if (err.code === "EISDIR") { - throw new Error( - `EISDIR: illegal operation on a directory, read '${path}'`, - ); - } - throw e; - } - } - - async writeFile( - path: string, - content: FileContent, - options?: WriteFileOptions | BufferEncoding, - ): Promise { - const realPath = this.toRealPath(path); - const encoding = getEncoding(options); - const buffer = toBuffer(content, encoding); - - // Ensure parent directory exists - const dir = nodePath.dirname(realPath); - await fs.promises.mkdir(dir, { recursive: true }); - - await fs.promises.writeFile(realPath, buffer); - } - - async appendFile( - path: string, - content: FileContent, - options?: WriteFileOptions | BufferEncoding, - ): Promise { - const realPath = this.toRealPath(path); - const encoding = getEncoding(options); - const buffer = toBuffer(content, encoding); - - // Ensure parent directory exists - const dir = nodePath.dirname(realPath); - await fs.promises.mkdir(dir, { recursive: true }); - - await fs.promises.appendFile(realPath, buffer); - } - - async exists(path: string): Promise { - const realPath = this.toRealPath(path); - try { - await fs.promises.access(realPath); - return true; - } catch { - return false; - } - } - - async stat(path: string): Promise { - const realPath = this.toRealPath(path); - - try { - const stat = await fs.promises.stat(realPath); - return { - isFile: stat.isFile(), - isDirectory: stat.isDirectory(), - isSymbolicLink: false, // stat follows symlinks - mode: stat.mode, - size: stat.size, - mtime: stat.mtime, - }; - } catch (e) { - const err = e as NodeJS.ErrnoException; - if (err.code === "ENOENT") { - throw new Error(`ENOENT: no such file or directory, stat '${path}'`); - } - throw e; - } - } - - async lstat(path: string): Promise { - const realPath = this.toRealPath(path); - - try { - const stat = await fs.promises.lstat(realPath); - return { - isFile: stat.isFile(), - isDirectory: stat.isDirectory(), - isSymbolicLink: stat.isSymbolicLink(), - mode: stat.mode, - size: stat.size, - mtime: stat.mtime, - }; - } catch (e) { - const err = e as NodeJS.ErrnoException; - if (err.code === "ENOENT") { - throw new Error(`ENOENT: no such file or directory, lstat '${path}'`); - } - throw e; - } - } - - async mkdir(path: string, options?: MkdirOptions): Promise { - const realPath = this.toRealPath(path); - - try { - await fs.promises.mkdir(realPath, { recursive: options?.recursive }); - } catch (e) { - const err = e as NodeJS.ErrnoException; - if (err.code === "EEXIST") { - throw new Error(`EEXIST: file already exists, mkdir '${path}'`); - } - if (err.code === "ENOENT") { - throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`); - } - throw e; - } - } - - async readdir(path: string): Promise { - const entries = await this.readdirWithFileTypes(path); - return entries.map((e) => e.name); - } - - async readdirWithFileTypes(path: string): Promise { - const realPath = this.toRealPath(path); - - try { - const entries = await fs.promises.readdir(realPath, { - withFileTypes: true, - }); - return entries - .map((dirent) => ({ - name: dirent.name, - isFile: dirent.isFile(), - isDirectory: dirent.isDirectory(), - isSymbolicLink: dirent.isSymbolicLink(), - })) - .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); - } catch (e) { - const err = e as NodeJS.ErrnoException; - if (err.code === "ENOENT") { - throw new Error(`ENOENT: no such file or directory, scandir '${path}'`); - } - if (err.code === "ENOTDIR") { - throw new Error(`ENOTDIR: not a directory, scandir '${path}'`); - } - throw e; - } - } - - async rm(path: string, options?: RmOptions): Promise { - const realPath = this.toRealPath(path); - - try { - await fs.promises.rm(realPath, { - recursive: options?.recursive ?? false, - force: options?.force ?? false, - }); - } catch (e) { - const err = e as NodeJS.ErrnoException; - if (err.code === "ENOENT" && !options?.force) { - throw new Error(`ENOENT: no such file or directory, rm '${path}'`); - } - if (err.code === "ENOTEMPTY") { - throw new Error(`ENOTEMPTY: directory not empty, rm '${path}'`); - } - throw e; - } - } - - async cp(src: string, dest: string, options?: CpOptions): Promise { - const srcReal = this.toRealPath(src); - const destReal = this.toRealPath(dest); - - try { - await fs.promises.cp(srcReal, destReal, { - recursive: options?.recursive ?? false, - }); - } catch (e) { - const err = e as NodeJS.ErrnoException; - if (err.code === "ENOENT") { - throw new Error(`ENOENT: no such file or directory, cp '${src}'`); - } - if (err.code === "EISDIR") { - throw new Error(`EISDIR: is a directory, cp '${src}'`); - } - throw e; - } - } - - async mv(src: string, dest: string): Promise { - const srcReal = this.toRealPath(src); - const destReal = this.toRealPath(dest); - - // Ensure destination parent directory exists - const destDir = nodePath.dirname(destReal); - await fs.promises.mkdir(destDir, { recursive: true }); - - try { - await fs.promises.rename(srcReal, destReal); - } catch (e) { - const err = e as NodeJS.ErrnoException; - if (err.code === "ENOENT") { - throw new Error(`ENOENT: no such file or directory, mv '${src}'`); - } - // If rename fails across devices, fall back to copy + delete - if (err.code === "EXDEV") { - await this.cp(src, dest, { recursive: true }); - await this.rm(src, { recursive: true }); - return; - } - throw e; - } - } - - resolvePath(base: string, path: string): string { - if (path.startsWith("/")) { - return this.normalizePath(path); - } - const combined = base === "/" ? `/${path}` : `${base}/${path}`; - return this.normalizePath(combined); - } - - getAllPaths(): string[] { - // Recursively scan the filesystem - const paths: string[] = []; - this.scanDir("/", paths); - return paths; - } - - private scanDir(virtualDir: string, paths: string[]): void { - const realPath = this.toRealPath(virtualDir); - - try { - const entries = fs.readdirSync(realPath); - for (const entry of entries) { - const virtualPath = - virtualDir === "/" ? `/${entry}` : `${virtualDir}/${entry}`; - paths.push(virtualPath); - - const entryRealPath = nodePath.join(realPath, entry); - const stat = fs.statSync(entryRealPath); - if (stat.isDirectory()) { - this.scanDir(virtualPath, paths); - } - } - } catch { - // Ignore errors - } - } - - async chmod(path: string, mode: number): Promise { - const realPath = this.toRealPath(path); - - try { - await fs.promises.chmod(realPath, mode); - } catch (e) { - const err = e as NodeJS.ErrnoException; - if (err.code === "ENOENT") { - throw new Error(`ENOENT: no such file or directory, chmod '${path}'`); - } - throw e; - } - } - - async symlink(target: string, linkPath: string): Promise { - const realLinkPath = this.toRealPath(linkPath); - - // Validate and transform symlink target to prevent sandbox escape. - // Resolve the target: if absolute, treat as virtual path; if relative, resolve from link's dir - const normalizedLinkPath = this.normalizePath(linkPath); - const linkDir = this.normalizePath(nodePath.dirname(normalizedLinkPath)); - const resolvedVirtualTarget = target.startsWith("/") - ? this.normalizePath(target) - : this.normalizePath( - linkDir === "/" ? `/${target}` : `${linkDir}/${target}`, - ); - - // Convert to real path - this is where the symlink should actually point - const resolvedRealTarget = nodePath.join(this.root, resolvedVirtualTarget); - - // For relative symlinks, compute the correct relative path from link to target within root - // For absolute symlinks, use the absolute path within root - const realLinkDir = nodePath.dirname(realLinkPath); - const safeTarget = target.startsWith("/") - ? resolvedRealTarget - : nodePath.relative(realLinkDir, resolvedRealTarget); - - try { - await fs.promises.symlink(safeTarget, realLinkPath); - } catch (e) { - const err = e as NodeJS.ErrnoException; - if (err.code === "EEXIST") { - throw new Error(`EEXIST: file already exists, symlink '${linkPath}'`); - } - throw e; - } - } - - async link(existingPath: string, newPath: string): Promise { - const realExisting = this.toRealPath(existingPath); - const realNew = this.toRealPath(newPath); - - try { - await fs.promises.link(realExisting, realNew); - } catch (e) { - const err = e as NodeJS.ErrnoException; - if (err.code === "ENOENT") { - throw new Error( - `ENOENT: no such file or directory, link '${existingPath}'`, - ); - } - if (err.code === "EEXIST") { - throw new Error(`EEXIST: file already exists, link '${newPath}'`); - } - if (err.code === "EPERM") { - throw new Error( - `EPERM: operation not permitted, link '${existingPath}'`, - ); - } - throw e; - } - } - - async readlink(path: string): Promise { - const realPath = this.toRealPath(path); - - try { - return await fs.promises.readlink(realPath); - } catch (e) { - const err = e as NodeJS.ErrnoException; - if (err.code === "ENOENT") { - throw new Error( - `ENOENT: no such file or directory, readlink '${path}'`, - ); - } - if (err.code === "EINVAL") { - throw new Error(`EINVAL: invalid argument, readlink '${path}'`); - } - throw e; - } - } - - /** - * Resolve all symlinks in a path to get the canonical physical path. - * This is equivalent to POSIX realpath(). - */ - async realpath(path: string): Promise { - const realPath = this.toRealPath(path); - - try { - const resolved = await fs.promises.realpath(realPath); - // Canonicalize root too (e.g., on macOS /var -> /private/var) - const canonicalRoot = await fs.promises.realpath(this.root); - // Convert back to virtual path (relative to root) - if (resolved.startsWith(canonicalRoot)) { - const relative = resolved.slice(canonicalRoot.length); - return relative || "/"; - } - // Resolved path is outside root - reject it to prevent sandbox escape - throw new Error(`ENOENT: no such file or directory, realpath '${path}'`); - } catch (e) { - const err = e as NodeJS.ErrnoException; - if (err.code === "ENOENT") { - throw new Error( - `ENOENT: no such file or directory, realpath '${path}'`, - ); - } - if (err.code === "ELOOP") { - throw new Error( - `ELOOP: too many levels of symbolic links, realpath '${path}'`, - ); - } - throw e; - } - } - - /** - * Set access and modification times of a file - * @param path - The file path - * @param atime - Access time - * @param mtime - Modification time - */ - async utimes(path: string, atime: Date, mtime: Date): Promise { - const realPath = this.toRealPath(path); - - try { - await fs.promises.utimes(realPath, atime, mtime); - } catch (e) { - const err = e as NodeJS.ErrnoException; - if (err.code === "ENOENT") { - throw new Error(`ENOENT: no such file or directory, utimes '${path}'`); - } - throw e; - } - } -} diff --git a/src/helpers/env.ts b/src/helpers/env.ts deleted file mode 100644 index 5ac17999..00000000 --- a/src/helpers/env.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Environment variable helpers for safe Map-to-Record conversion. - * - * These helpers prevent prototype pollution by creating null-prototype objects - * when converting environment variable Maps to Records. - */ - -/** - * Convert a Map to a null-prototype Record. - * - * This prevents prototype pollution attacks where user-controlled keys like - * "__proto__", "constructor", or "hasOwnProperty" could access or modify - * the Object prototype chain. - * - * @param env - The environment Map to convert - * @returns A null-prototype object with the same key-value pairs - */ -export function mapToRecord(env: Map): Record { - return Object.assign(Object.create(null), Object.fromEntries(env)); -} - -/** - * Convert a Map to a null-prototype Record, with optional - * additional properties to merge. - * - * @param env - The environment Map to convert - * @param extra - Additional properties to merge into the result - * @returns A null-prototype object with the combined key-value pairs - */ -export function mapToRecordWithExtras( - env: Map, - extra?: Record, -): Record { - return Object.assign(Object.create(null), Object.fromEntries(env), extra); -} - -/** - * Merge multiple objects into a null-prototype object. - * - * This prevents prototype pollution when merging user-controlled objects - * (e.g., from JSON input in jq queries). - * - * @param objects - Objects to merge - * @returns A null-prototype object with all properties merged - */ -export function mergeToNullPrototype( - ...objects: T[] -): Record { - return Object.assign(Object.create(null), ...objects); -} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 40d814f7..00000000 --- a/src/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -export type { BashLogger, BashOptions, ExecOptions } from "./Bash.js"; -export { Bash } from "./Bash.js"; -export type { - AllCommandName, - CommandName, - NetworkCommandName, - PythonCommandName, -} from "./commands/registry.js"; -export { - getCommandNames, - getNetworkCommandNames, - getPythonCommandNames, -} from "./commands/registry.js"; -// Custom commands API -export type { CustomCommand, LazyCommand } from "./custom-commands.js"; -export { defineCommand } from "./custom-commands.js"; -export { InMemoryFs } from "./fs/in-memory-fs/index.js"; -export type { - BufferEncoding, - CpOptions, - DirectoryEntry, - FileContent, - FileEntry, - FileInit, - FileSystemFactory, - FsEntry, - FsStat, - InitialFiles, - MkdirOptions, - RmOptions, - SymlinkEntry, -} from "./fs/interface.js"; -export { - MountableFs, - type MountableFsOptions, - type MountConfig, -} from "./fs/mountable-fs/index.js"; -export { OverlayFs, type OverlayFsOptions } from "./fs/overlay-fs/index.js"; -export { - ReadWriteFs, - type ReadWriteFsOptions, -} from "./fs/read-write-fs/index.js"; -export type { NetworkConfig } from "./network/index.js"; -export { - NetworkAccessDeniedError, - RedirectNotAllowedError, - TooManyRedirectsError, -} from "./network/index.js"; -export type { - CommandFinished as SandboxCommandFinished, - OutputMessage, - SandboxOptions, - WriteFilesInput, -} from "./sandbox/index.js"; -// Vercel Sandbox API compatible exports -export { Command as SandboxCommand, Sandbox } from "./sandbox/index.js"; -// Security module - defense-in-depth -export type { - DefenseInDepthConfig, - DefenseInDepthHandle, - DefenseInDepthStats, - SecurityViolation, - SecurityViolationType, -} from "./security/index.js"; -export { - createConsoleViolationCallback, - DefenseInDepthBox, - SecurityViolationError, - SecurityViolationLogger, -} from "./security/index.js"; -export type { - BashExecResult, - Command, - CommandContext, - ExecResult, - IFileSystem, -} from "./types.js"; diff --git a/src/interpreter/alias-expansion.ts b/src/interpreter/alias-expansion.ts deleted file mode 100644 index 8b29739e..00000000 --- a/src/interpreter/alias-expansion.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * Alias Expansion - * - * Handles bash alias expansion for SimpleCommandNodes. - * - * Alias expansion rules: - * 1. Only expands if command name is a literal unquoted word - * 2. Alias value is substituted for the command name - * 3. If alias value ends with a space, the next word is also checked for alias expansion - * 4. Recursive expansion is allowed but limited to prevent infinite loops - */ - -import type { ScriptNode, SimpleCommandNode, WordNode } from "../ast/types.js"; -import { Parser } from "../parser/parser.js"; -import { ParseException } from "../parser/types.js"; - -/** - * Alias prefix used in environment variables - */ -const ALIAS_PREFIX = "BASH_ALIAS_"; - -/** - * Context needed for alias expansion operations - */ -export interface AliasExpansionContext { - env: Map; -} - -/** - * Check if a word is a literal unquoted word (eligible for alias expansion). - * Aliases only expand for literal words, not for quoted strings or expansions. - */ -function isLiteralUnquotedWord(word: WordNode): boolean { - // Must have exactly one part that is a literal - if (word.parts.length !== 1) return false; - const part = word.parts[0]; - // Must be a Literal part (not quoted, not an expansion) - return part.type === "Literal"; -} - -/** - * Get the literal value of a word if it's a simple literal - */ -function getLiteralValue(word: WordNode): string | null { - if (word.parts.length !== 1) return null; - const part = word.parts[0]; - if (part.type === "Literal") { - return part.value; - } - return null; -} - -/** - * Get the alias value for a name, if defined - */ -function getAlias( - ctx: AliasExpansionContext, - name: string, -): string | undefined { - return ctx.env.get(`${ALIAS_PREFIX}${name}`); -} - -/** - * Expand alias in a SimpleCommandNode if applicable. - * Returns a new node with the alias expanded, or the original node if no expansion. - */ -export function expandAlias( - ctx: AliasExpansionContext, - node: SimpleCommandNode, - aliasExpansionStack: Set, -): SimpleCommandNode { - // Need a command name to expand - if (!node.name) return node; - - // Check if the command name is a literal unquoted word - if (!isLiteralUnquotedWord(node.name)) return node; - - const cmdName = getLiteralValue(node.name); - if (!cmdName) return node; - - // Check for alias - const aliasValue = getAlias(ctx, cmdName); - if (!aliasValue) return node; - - // Prevent infinite recursion - if (aliasExpansionStack.has(cmdName)) return node; - - try { - aliasExpansionStack.add(cmdName); - - // Parse the alias value as a command - const parser = new Parser(); - // Build the full command line: alias value + original args - // We need to combine the alias value with any remaining arguments - let fullCommand = aliasValue; - - // Check if alias value ends with a space (triggers expansion of next word) - const expandNext = aliasValue.endsWith(" "); - - // If not expanding next, append args directly - if (!expandNext) { - // Convert args to strings for re-parsing - for (const arg of node.args) { - const argLiteral = wordNodeToString(arg); - fullCommand += ` ${argLiteral}`; - } - } - - // Parse the expanded command - let expandedAst: ScriptNode; - try { - expandedAst = parser.parse(fullCommand); - } catch (e) { - // If parsing fails, return original node (let normal execution handle the error) - if (e instanceof ParseException) { - // Re-throw parse errors to be handled by the caller - throw e; - } - return node; - } - - // We expect exactly one statement with one command in the pipeline - if ( - expandedAst.statements.length !== 1 || - expandedAst.statements[0].pipelines.length !== 1 || - expandedAst.statements[0].pipelines[0].commands.length !== 1 - ) { - // Complex alias - might have multiple commands, pipelines, etc. - // For now, execute as a script and wrap result - // This is a simplification - full support would require more complex handling - return handleComplexAlias(node, aliasValue); - } - - const expandedCmd = expandedAst.statements[0].pipelines[0].commands[0]; - if (expandedCmd.type !== "SimpleCommand") { - // Alias expanded to a compound command - let it execute directly - return handleComplexAlias(node, aliasValue); - } - - // Merge the expanded command with original node's context - let newNode: SimpleCommandNode = { - ...expandedCmd, - // Preserve original assignments (prefix assignments like FOO=bar alias_cmd) - assignments: [...node.assignments, ...expandedCmd.assignments], - // Preserve original redirections - redirections: [...expandedCmd.redirections, ...node.redirections], - // Preserve line number - line: node.line, - }; - - // If alias ends with space, expand next word too (recursive alias on first arg) - if (expandNext && node.args.length > 0) { - // Add the original args to the expanded command's args - newNode = { - ...newNode, - args: [...newNode.args, ...node.args], - }; - - // Now recursively expand the first arg if it's an alias - if (newNode.args.length > 0) { - const firstArg = newNode.args[0]; - if (isLiteralUnquotedWord(firstArg)) { - const firstArgName = getLiteralValue(firstArg); - if (firstArgName && getAlias(ctx, firstArgName)) { - // Create a temporary node with the first arg as command - const tempNode: SimpleCommandNode = { - type: "SimpleCommand", - name: firstArg, - args: newNode.args.slice(1), - assignments: [], - redirections: [], - }; - const expandedFirst = expandAlias( - ctx, - tempNode, - aliasExpansionStack, - ); - if (expandedFirst !== tempNode) { - // Merge back - newNode = { - ...newNode, - name: expandedFirst.name, - args: [...expandedFirst.args], - }; - } - } - } - } - } - - // NOTE: We don't recursively call expandAlias here anymore - the caller - // handles iterative expansion to avoid issues with stack management. - // The aliasExpansionStack is cleared by the caller after all expansions complete. - - return newNode; - } catch (e) { - // On error, clean up our entry from the stack - aliasExpansionStack.delete(cmdName); - throw e; - } - // NOTE: No finally block - we intentionally leave cmdName in the stack - // to prevent re-expansion of the same alias. The caller clears the stack. -} - -/** - * Handle complex alias that expands to multiple commands or pipelines. - * For now, we create a wrapper that will execute the alias as a script. - */ -function handleComplexAlias( - node: SimpleCommandNode, - aliasValue: string, -): SimpleCommandNode { - // Build complete command string - let fullCommand = aliasValue; - for (const arg of node.args) { - const argLiteral = wordNodeToString(arg); - fullCommand += ` ${argLiteral}`; - } - - // Create an eval-like command that will execute the alias - // This is a workaround - we create a new SimpleCommand that calls eval - const parser = new Parser(); - const evalWord = parser.parseWordFromString("eval", false, false); - const cmdWord = parser.parseWordFromString( - `'${fullCommand.replace(/'/g, "'\\''")}'`, - false, - false, - ); - - return { - type: "SimpleCommand", - name: evalWord, - args: [cmdWord], - assignments: node.assignments, - redirections: node.redirections, - line: node.line, - }; -} - -/** - * Convert a WordNode back to a string representation for re-parsing. - * This is a simplified conversion that handles common cases. - */ -function wordNodeToString(word: WordNode): string { - let result = ""; - for (const part of word.parts) { - switch (part.type) { - case "Literal": - // Escape special characters - result += part.value.replace(/([\s"'$`\\*?[\]{}()<>|&;#!])/g, "\\$1"); - break; - case "SingleQuoted": - result += `'${part.value}'`; - break; - case "DoubleQuoted": - // Handle double-quoted content - result += `"${part.parts.map((p) => (p.type === "Literal" ? p.value : `$${p.type}`)).join("")}"`; - break; - case "ParameterExpansion": - // Use braced form to be safe - result += `\${${part.parameter}}`; - break; - case "CommandSubstitution": - // CommandSubstitutionPart has body (ScriptNode), not command string - // We need to reconstruct - for simplicity, wrap in $(...) - result += `$(...)`; - break; - case "ArithmeticExpansion": - result += `$((${part.expression}))`; - break; - case "Glob": - result += part.pattern; - break; - default: - // For other types, try to preserve as-is - break; - } - } - return result; -} diff --git a/src/interpreter/arithmetic.test.ts b/src/interpreter/arithmetic.test.ts deleted file mode 100644 index dd286361..00000000 --- a/src/interpreter/arithmetic.test.ts +++ /dev/null @@ -1,494 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -describe("arithmetic evaluation", () => { - describe("binary operators", () => { - it("should evaluate addition", async () => { - const env = new Bash(); - const result = await env.exec("echo $((5 + 3))"); - expect(result.stdout).toBe("8\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate subtraction", async () => { - const env = new Bash(); - const result = await env.exec("echo $((10 - 4))"); - expect(result.stdout).toBe("6\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate multiplication", async () => { - const env = new Bash(); - const result = await env.exec("echo $((6 * 7))"); - expect(result.stdout).toBe("42\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate division", async () => { - const env = new Bash(); - const result = await env.exec("echo $((20 / 4))"); - expect(result.stdout).toBe("5\n"); - expect(result.exitCode).toBe(0); - }); - - it("should truncate division result", async () => { - const env = new Bash(); - const result = await env.exec("echo $((7 / 2))"); - expect(result.stdout).toBe("3\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate modulo", async () => { - const env = new Bash(); - const result = await env.exec("echo $((17 % 5))"); - expect(result.stdout).toBe("2\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate exponentiation", async () => { - const env = new Bash(); - const result = await env.exec("echo $((2 ** 10))"); - expect(result.stdout).toBe("1024\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate left shift", async () => { - const env = new Bash(); - const result = await env.exec("echo $((1 << 8))"); - expect(result.stdout).toBe("256\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate right shift", async () => { - const env = new Bash(); - const result = await env.exec("echo $((256 >> 4))"); - expect(result.stdout).toBe("16\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate bitwise AND", async () => { - const env = new Bash(); - const result = await env.exec("echo $((12 & 10))"); - expect(result.stdout).toBe("8\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate bitwise OR", async () => { - const env = new Bash(); - const result = await env.exec("echo $((12 | 10))"); - expect(result.stdout).toBe("14\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate bitwise XOR", async () => { - const env = new Bash(); - const result = await env.exec("echo $((12 ^ 10))"); - expect(result.stdout).toBe("6\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate comma operator", async () => { - const env = new Bash(); - const result = await env.exec("echo $((1, 2, 3))"); - expect(result.stdout).toBe("3\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("comparison operators", () => { - it("should evaluate less than", async () => { - const env = new Bash(); - const result = await env.exec("echo $((3 < 5)) $((5 < 3))"); - expect(result.stdout).toBe("1 0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate less than or equal", async () => { - const env = new Bash(); - const result = await env.exec("echo $((3 <= 3)) $((4 <= 3))"); - expect(result.stdout).toBe("1 0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate greater than", async () => { - const env = new Bash(); - const result = await env.exec("echo $((5 > 3)) $((3 > 5))"); - expect(result.stdout).toBe("1 0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate greater than or equal", async () => { - const env = new Bash(); - const result = await env.exec("echo $((3 >= 3)) $((2 >= 3))"); - expect(result.stdout).toBe("1 0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate equal", async () => { - const env = new Bash(); - const result = await env.exec("echo $((5 == 5)) $((5 == 6))"); - expect(result.stdout).toBe("1 0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate not equal", async () => { - const env = new Bash(); - const result = await env.exec("echo $((5 != 6)) $((5 != 5))"); - expect(result.stdout).toBe("1 0\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("logical operators", () => { - it("should evaluate logical AND", async () => { - const env = new Bash(); - const result = await env.exec("echo $((1 && 1)) $((1 && 0)) $((0 && 1))"); - expect(result.stdout).toBe("1 0 0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate logical OR", async () => { - const env = new Bash(); - const result = await env.exec("echo $((1 || 0)) $((0 || 1)) $((0 || 0))"); - expect(result.stdout).toBe("1 1 0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should short-circuit logical AND", async () => { - const env = new Bash(); - const result = await env.exec("x=5; echo $((0 && (x=10))); echo $x"); - expect(result.stdout).toBe("0\n5\n"); - expect(result.exitCode).toBe(0); - }); - - it("should short-circuit logical OR", async () => { - const env = new Bash(); - const result = await env.exec("x=5; echo $((1 || (x=10))); echo $x"); - expect(result.stdout).toBe("1\n5\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate logical NOT", async () => { - const env = new Bash(); - const result = await env.exec("echo $((!0)) $((!1)) $((!5))"); - expect(result.stdout).toBe("1 0 0\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("unary operators", () => { - it("should evaluate unary minus", async () => { - const env = new Bash(); - const result = await env.exec("echo $((-5))"); - expect(result.stdout).toBe("-5\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate unary plus", async () => { - const env = new Bash(); - const result = await env.exec("echo $((+5))"); - expect(result.stdout).toBe("5\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate bitwise NOT", async () => { - const env = new Bash(); - const result = await env.exec("echo $((~0))"); - expect(result.stdout).toBe("-1\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate pre-increment", async () => { - const env = new Bash(); - const result = await env.exec("x=5; echo $((++x)); echo $x"); - expect(result.stdout).toBe("6\n6\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate post-increment", async () => { - const env = new Bash(); - const result = await env.exec("x=5; echo $((x++)); echo $x"); - expect(result.stdout).toBe("5\n6\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate pre-decrement", async () => { - const env = new Bash(); - const result = await env.exec("x=5; echo $((--x)); echo $x"); - expect(result.stdout).toBe("4\n4\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate post-decrement", async () => { - const env = new Bash(); - const result = await env.exec("x=5; echo $((x--)); echo $x"); - expect(result.stdout).toBe("5\n4\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("ternary operator", () => { - it("should evaluate true branch", async () => { - const env = new Bash(); - const result = await env.exec("echo $((1 ? 10 : 20))"); - expect(result.stdout).toBe("10\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate false branch", async () => { - const env = new Bash(); - const result = await env.exec("echo $((0 ? 10 : 20))"); - expect(result.stdout).toBe("20\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate nested ternary", async () => { - const env = new Bash(); - const result = await env.exec("echo $((1 ? 2 ? 3 : 4 : 5))"); - expect(result.stdout).toBe("3\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("assignment operators", () => { - it("should evaluate basic assignment", async () => { - const env = new Bash(); - const result = await env.exec("echo $((x = 5)); echo $x"); - expect(result.stdout).toBe("5\n5\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate += assignment", async () => { - const env = new Bash(); - const result = await env.exec("x=10; echo $((x += 5)); echo $x"); - expect(result.stdout).toBe("15\n15\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate -= assignment", async () => { - const env = new Bash(); - const result = await env.exec("x=10; echo $((x -= 3)); echo $x"); - expect(result.stdout).toBe("7\n7\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate *= assignment", async () => { - const env = new Bash(); - const result = await env.exec("x=4; echo $((x *= 3)); echo $x"); - expect(result.stdout).toBe("12\n12\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate /= assignment", async () => { - const env = new Bash(); - const result = await env.exec("x=20; echo $((x /= 4)); echo $x"); - expect(result.stdout).toBe("5\n5\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate %= assignment", async () => { - const env = new Bash(); - const result = await env.exec("x=17; echo $((x %= 5)); echo $x"); - expect(result.stdout).toBe("2\n2\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate <<= assignment", async () => { - const env = new Bash(); - const result = await env.exec("x=2; echo $((x <<= 3)); echo $x"); - expect(result.stdout).toBe("16\n16\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate >>= assignment", async () => { - const env = new Bash(); - const result = await env.exec("x=32; echo $((x >>= 2)); echo $x"); - expect(result.stdout).toBe("8\n8\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate &= assignment", async () => { - const env = new Bash(); - const result = await env.exec("x=12; echo $((x &= 10)); echo $x"); - expect(result.stdout).toBe("8\n8\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate |= assignment", async () => { - const env = new Bash(); - const result = await env.exec("x=12; echo $((x |= 1)); echo $x"); - expect(result.stdout).toBe("13\n13\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate ^= assignment", async () => { - const env = new Bash(); - const result = await env.exec("x=12; echo $((x ^= 5)); echo $x"); - expect(result.stdout).toBe("9\n9\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("error cases", () => { - it("should error on division by zero", async () => { - const env = new Bash(); - const result = await env.exec("echo $((5 / 0))"); - expect(result.stderr).toContain("division by 0"); - expect(result.exitCode).toBe(1); - }); - - it("should error on modulo by zero", async () => { - const env = new Bash(); - const result = await env.exec("echo $((5 % 0))"); - expect(result.stderr).toContain("division by 0"); - expect(result.exitCode).toBe(1); - }); - - it("should error on negative exponent", async () => { - const env = new Bash(); - const result = await env.exec("echo $((2 ** -1))"); - expect(result.stderr).toContain("exponent less than 0"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("variable references", () => { - it("should reference variables without $", async () => { - const env = new Bash(); - const result = await env.exec("x=5; echo $((x + 3))"); - expect(result.stdout).toBe("8\n"); - expect(result.exitCode).toBe(0); - }); - - it("should reference variables with $", async () => { - const env = new Bash(); - const result = await env.exec("x=5; echo $(($x + 3))"); - expect(result.stdout).toBe("8\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle unset variables as zero", async () => { - const env = new Bash(); - const result = await env.exec("echo $((unset_var + 5))"); - expect(result.stdout).toBe("5\n"); - expect(result.exitCode).toBe(0); - }); - - it("should recursively resolve variable names", async () => { - const env = new Bash(); - const result = await env.exec("a=5; b=a; echo $((b))"); - expect(result.stdout).toBe("5\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate expressions stored in variables", async () => { - const env = new Bash(); - const result = await env.exec("e='1+2'; echo $((e + 3))"); - expect(result.stdout).toBe("6\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("nested expressions", () => { - it("should handle parentheses for grouping", async () => { - const env = new Bash(); - const result = await env.exec("echo $((2 * (3 + 4)))"); - expect(result.stdout).toBe("14\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle nested arithmetic expressions", async () => { - const env = new Bash(); - const result = await env.exec("echo $(( (1 + 2) * 3 + 4 ))"); - expect(result.stdout).toBe("13\n"); - expect(result.exitCode).toBe(0); - }); - - it("should respect operator precedence", async () => { - const env = new Bash(); - const result = await env.exec("echo $((2 + 3 * 4))"); - expect(result.stdout).toBe("14\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("array element arithmetic", () => { - it("should handle array element access", async () => { - const env = new Bash(); - const result = await env.exec("arr=(10 20 30); echo $((arr[1] + 5))"); - expect(result.stdout).toBe("25\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle array element assignment", async () => { - const env = new Bash(); - const result = await env.exec( - "arr=(0 0 0); echo $((arr[1] = 42)); echo ${arr[1]}", - ); - expect(result.stdout).toBe("42\n42\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle array element increment", async () => { - const env = new Bash(); - const result = await env.exec( - "arr=(10 20 30); echo $((arr[0]++)); echo ${arr[0]}", - ); - expect(result.stdout).toBe("10\n11\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("number bases", () => { - it("should handle octal numbers", async () => { - const env = new Bash(); - const result = await env.exec("echo $((010))"); - expect(result.stdout).toBe("8\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle hex numbers", async () => { - const env = new Bash(); - const result = await env.exec("echo $((0xFF))"); - expect(result.stdout).toBe("255\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle base#number notation", async () => { - const env = new Bash(); - const result = await env.exec("echo $((2#1010))"); - expect(result.stdout).toBe("10\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle base 16 with letters", async () => { - const env = new Bash(); - const result = await env.exec("echo $((16#ff))"); - expect(result.stdout).toBe("255\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("arithmetic command (( ))", () => { - it("should return 0 for non-zero result", async () => { - const env = new Bash(); - const result = await env.exec("(( 5 )); echo $?"); - expect(result.stdout).toBe("0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should return 1 for zero result", async () => { - const env = new Bash(); - const result = await env.exec("(( 0 )); echo $?"); - expect(result.stdout).toBe("1\n"); - expect(result.exitCode).toBe(0); - }); - - it("should work with assignments", async () => { - const env = new Bash(); - const result = await env.exec("(( x = 5 + 3 )); echo $x"); - expect(result.stdout).toBe("8\n"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/interpreter/arithmetic.ts b/src/interpreter/arithmetic.ts deleted file mode 100644 index e43566a6..00000000 --- a/src/interpreter/arithmetic.ts +++ /dev/null @@ -1,991 +0,0 @@ -/** - * Arithmetic Evaluation - * - * Evaluates bash arithmetic expressions including: - * - Basic operators (+, -, *, /, %) - * - Comparison operators (<, <=, >, >=, ==, !=) - * - Bitwise operators (&, |, ^, ~, <<, >>) - * - Logical operators (&&, ||, !) - * - Assignment operators (=, +=, -=, etc.) - * - Ternary operator (? :) - * - Pre/post increment/decrement (++, --) - * - Nested arithmetic: $((expr)) - * - Command substitution: $(cmd) or `cmd` - * - * Known limitations: - * - Bitwise operations use JavaScript's 32-bit signed integers, not 64-bit. - * This means values like (1 << 31) will be negative (-2147483648) instead - * of the bash 64-bit result (2147483648). - * - Dynamic arithmetic expressions (e.g., ${base}#a where base=16) are not - * fully supported - variable expansion happens at parse time, not runtime. - */ - -import type { ArithExpr } from "../ast/types.js"; -import { - parseArithExpr, - parseArithNumber, -} from "../parser/arithmetic-parser.js"; -import { Parser } from "../parser/parser.js"; -import { ArithmeticError, NounsetError } from "./errors.js"; -import { getArrayElements, getVariable } from "./expansion.js"; -import type { InterpreterContext } from "./types.js"; - -/** - * Pure binary operator evaluation - no async, no side effects. - * Shared by both sync and async evaluators. - */ -function applyBinaryOp(left: number, right: number, operator: string): number { - switch (operator) { - case "+": - return left + right; - case "-": - return left - right; - case "*": - return left * right; - case "/": - if (right === 0) { - throw new ArithmeticError("division by 0"); - } - return Math.trunc(left / right); - case "%": - if (right === 0) { - throw new ArithmeticError("division by 0"); - } - return left % right; - case "**": - // Bash disallows negative exponents - if (right < 0) { - throw new ArithmeticError("exponent less than 0"); - } - return left ** right; - case "<<": - return left << right; - case ">>": - return left >> right; - case "<": - return left < right ? 1 : 0; - case "<=": - return left <= right ? 1 : 0; - case ">": - return left > right ? 1 : 0; - case ">=": - return left >= right ? 1 : 0; - case "==": - return left === right ? 1 : 0; - case "!=": - return left !== right ? 1 : 0; - case "&": - return left & right; - case "|": - return left | right; - case "^": - return left ^ right; - case ",": - return right; - default: - return 0; - } -} - -/** - * Pure assignment operator evaluation - no async, no side effects on ctx. - * Returns the new value to be assigned. - */ -function applyAssignmentOp( - current: number, - value: number, - operator: string, -): number { - switch (operator) { - case "=": - return value; - case "+=": - return current + value; - case "-=": - return current - value; - case "*=": - return current * value; - case "/=": - return value !== 0 ? Math.trunc(current / value) : 0; - case "%=": - return value !== 0 ? current % value : 0; - case "<<=": - return current << value; - case ">>=": - return current >> value; - case "&=": - return current & value; - case "|=": - return current | value; - case "^=": - return current ^ value; - default: - return value; - } -} - -/** - * Pure unary operator evaluation - no async, no side effects. - * For ++/-- operators, this only handles the operand transformation, - * not the variable assignment which must be done by the caller. - */ -function applyUnaryOp(operand: number, operator: string): number { - switch (operator) { - case "-": - return -operand; - case "+": - return +operand; - case "!": - return operand === 0 ? 1 : 0; - case "~": - return ~operand; - default: - return operand; - } -} - -/** - * Get an arithmetic variable value with array[0] decay support. - * In bash, when an array variable is used without an index in arithmetic context, - * it decays to the value at index 0. - */ -async function getArithVariable( - ctx: InterpreterContext, - name: string, -): Promise { - // First try to get the direct variable value - const directValue = ctx.state.env.get(name); - if (directValue !== undefined) { - return directValue; - } - // Array decay: if varName_0 exists, the variable is an array and we use element 0 - const arrayZeroValue = ctx.state.env.get(`${name}_0`); - if (arrayZeroValue !== undefined) { - return arrayZeroValue; - } - // Fall back to getVariable for special variables - return await getVariable(ctx, name); -} - -/** - * Parse a string value as an arithmetic expression. - * Unlike resolveArithVariable, this throws on parse errors (e.g., "12 34" is invalid). - * Used for array element access where the value must be valid arithmetic. - */ -/** - * Parse and evaluate a string value as an arithmetic expression. - * Used for array element access where the value may be an arithmetic expression. - * e.g., a=([0]=1+2+3 [a[0]]=10) - when evaluating a[0], we get "1+2+3" which - * needs to be evaluated to 6. - * - * NOTE: This is a static version that doesn't have context. For proper evaluation - * of expressions containing variables, use evaluateArithValue with context. - */ -function parseArithValue(value: string): number { - if (!value) { - return 0; - } - - // Try to parse as a simple number - const num = Number.parseInt(value, 10); - if (!Number.isNaN(num) && /^-?\d+$/.test(value.trim())) { - return num; - } - - const trimmed = value.trim(); - - // If it's empty, return 0 - if (!trimmed) { - return 0; - } - - // If it contains spaces and isn't a valid arithmetic expression, it's an error - // Parse it to validate - if parsing fails, throw - try { - const parser = new Parser(); - const { expr, pos } = parseArithExpr(parser, trimmed, 0); - // Check if we parsed the whole string (pos should be at the end) - if (pos < trimmed.length) { - // There's unparsed content - find the error token - const errorToken = trimmed.slice(pos).trim().split(/\s+/)[0]; - throw new ArithmeticError( - `${trimmed}: syntax error in expression (error token is "${errorToken}")`, - ); - } - if (expr.type === "ArithNumber") { - return expr.value; - } - // For other expression types, we need full evaluation but we don't have context. - // This is only used for simple cases. For complex expressions with variables, - // callers should use evaluateArithValue instead. - return num || 0; - } catch (error) { - if (error instanceof ArithmeticError) { - throw error; - } - // Parse failed - find the error token - const errorToken = trimmed.split(/\s+/).slice(1)[0] || trimmed; - throw new ArithmeticError( - `${trimmed}: syntax error in expression (error token is "${errorToken}")`, - ); - } -} - -/** - * Evaluate a string value as an arithmetic expression with full context. - * This properly handles expressions like "1+2+3" or "x+y" by parsing and evaluating them. - */ -async function evaluateArithValue( - ctx: InterpreterContext, - value: string, -): Promise { - if (!value) { - return 0; - } - - // Try to parse as a simple number first (fast path) - const num = Number.parseInt(value, 10); - if (!Number.isNaN(num) && /^-?\d+$/.test(value.trim())) { - return num; - } - - const trimmed = value.trim(); - if (!trimmed) { - return 0; - } - - // Parse and evaluate as arithmetic expression - const parser = new Parser(); - const { expr, pos } = parseArithExpr(parser, trimmed, 0); - if (pos < trimmed.length) { - // There's unparsed content - this is a syntax error - // Find the unparsed token for error message - const unparsed = trimmed.slice(pos).trim(); - const errorToken = unparsed.split(/\s+/)[0] || unparsed; - throw new ArithmeticError( - `syntax error in expression (error token is "${errorToken}")`, - "", - "", - ); - } - return await evaluateArithmetic(ctx, expr); -} - -/** - * Recursively resolve a variable name to its numeric value. - * In bash arithmetic, if a variable contains a string that is another variable name - * or an arithmetic expression, it is recursively evaluated: - * foo=5; bar=foo; $((bar)) => 5 - * e=1+2; $((e + 3)) => 6 - */ -async function resolveArithVariable( - ctx: InterpreterContext, - name: string, - visited: Set = new Set(), -): Promise { - // Prevent infinite recursion - if (visited.has(name)) { - return 0; - } - visited.add(name); - - const value = await getArithVariable(ctx, name); - - // If value is empty or undefined, return 0 - if (!value) { - return 0; - } - - // Try to parse as a number - const num = Number.parseInt(value, 10); - if (!Number.isNaN(num) && /^-?\d+$/.test(value.trim())) { - return num; - } - - const trimmed = value.trim(); - - // If it's not a number, check if it's a variable name - // In bash, arithmetic context recursively evaluates variable names - if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) { - return await resolveArithVariable(ctx, trimmed, visited); - } - - // Dynamic arithmetic: If the value contains arithmetic operators, parse and evaluate it - // This handles cases like e=1+2; $((e + 3)) => 6 - const parser = new Parser(); - const { expr, pos } = parseArithExpr(parser, trimmed, 0); - - // Check if we parsed the entire string - if not, it's a syntax error - // This handles cases like array element "1 3" which parses as "1" leaving " 3" unparsed - if (pos < trimmed.length) { - const unparsed = trimmed.slice(pos).trim(); - const errorToken = unparsed.split(/\s+/)[0] || unparsed; - throw new ArithmeticError( - `${trimmed}: syntax error in expression (error token is "${errorToken}")`, - ); - } - - // Evaluate the parsed expression - return await evaluateArithmetic(ctx, expr); -} - -/** - * Expand braced parameter content like "j:-5" or "var:=default" - * Returns the expanded value as a string - */ -async function expandBracedContent( - ctx: InterpreterContext, - content: string, -): Promise { - // Handle ${#var} - length - if (content.startsWith("#")) { - const varName = content.slice(1); - // Handle ${#arr[@]} and ${#arr[*]} - array length - const arrayMatch = varName.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[[@*]\]$/); - if (arrayMatch) { - const arrayName = arrayMatch[1]; - const elements = getArrayElements(ctx, arrayName); - return String(elements.length); - } - // Regular ${#var} - string length - const value = ctx.state.env.get(varName) || ""; - return String(value.length); - } - - // Handle ${!var} - indirection - if (content.startsWith("!")) { - const varName = content.slice(1); - const indirect = ctx.state.env.get(varName) || ""; - return ctx.state.env.get(indirect) || ""; - } - - // Find operator position - const operators = [":-", ":=", ":?", ":+", "-", "=", "?", "+"]; - let opIndex = -1; - let op = ""; - for (const operator of operators) { - const idx = content.indexOf(operator); - if (idx > 0 && (opIndex === -1 || idx < opIndex)) { - opIndex = idx; - op = operator; - } - } - - if (opIndex === -1) { - // Simple ${var} - just get the variable - return await getVariable(ctx, content); - } - - const varName = content.slice(0, opIndex); - const defaultValue = content.slice(opIndex + op.length); - const value = ctx.state.env.get(varName); - const isUnset = value === undefined; - const isEmpty = value === ""; - const checkEmpty = op.startsWith(":"); - - switch (op) { - case ":-": - case "-": { - const useDefault = isUnset || (checkEmpty && isEmpty); - return useDefault ? defaultValue : value || ""; - } - case ":=": - case "=": { - const useDefault = isUnset || (checkEmpty && isEmpty); - if (useDefault) { - ctx.state.env.set(varName, defaultValue); - return defaultValue; - } - return value || ""; - } - case ":+": - case "+": { - const useAlternative = !(isUnset || (checkEmpty && isEmpty)); - return useAlternative ? defaultValue : ""; - } - case ":?": - case "?": { - const shouldError = isUnset || (checkEmpty && isEmpty); - if (shouldError) { - throw new Error( - defaultValue || `${varName}: parameter null or not set`, - ); - } - return value || ""; - } - default: - return value || ""; - } -} - -export async function evaluateArithmetic( - ctx: InterpreterContext, - expr: ArithExpr, - isExpansionContext = false, -): Promise { - switch (expr.type) { - case "ArithNumber": - if (Number.isNaN(expr.value)) { - throw new ArithmeticError("value too great for base"); - } - return expr.value; - - case "ArithVariable": { - // Use recursive resolution - bash evaluates variable names recursively - return await resolveArithVariable(ctx, expr.name); - } - - case "ArithSpecialVar": { - // Get the special variable value and parse as arithmetic - const value = await getVariable(ctx, expr.name); - const trimmed = value.trim(); - if (!trimmed) return 0; - // Try to parse as a simple integer first (must be all digits, not "1 + 1") - const num = Number.parseInt(trimmed, 10); - if (!Number.isNaN(num) && /^-?\d+$/.test(trimmed)) return num; - // If not a simple number, evaluate as arithmetic expression - const parser = new Parser(); - const { expr: parsed } = parseArithExpr(parser, trimmed, 0); - return await evaluateArithmetic(ctx, parsed); - } - - case "ArithNested": - return await evaluateArithmetic(ctx, expr.expression); - - case "ArithCommandSubst": { - // Execute the command and parse the result as a number - if (ctx.execFn) { - const result = await ctx.execFn(expr.command); - // Command substitution stderr should go to the shell's stderr at expansion time - if (result.stderr) { - ctx.state.expansionStderr = - (ctx.state.expansionStderr || "") + result.stderr; - } - const output = result.stdout.trim(); - return Number.parseInt(output, 10) || 0; - } - return 0; - } - - case "ArithBracedExpansion": { - const expanded = await expandBracedContent(ctx, expr.content); - return Number.parseInt(expanded, 10) || 0; - } - - case "ArithDynamicBase": { - // ${base}#value - expand base, then parse value in that base - const baseStr = await expandBracedContent(ctx, expr.baseExpr); - const base = Number.parseInt(baseStr, 10); - if (base < 2 || base > 64) return 0; - const numStr = `${base}#${expr.value}`; - return parseArithNumber(numStr); - } - - case "ArithDynamicNumber": { - // ${zero}11 or ${zero}xAB - expand prefix, combine with suffix - const prefix = await expandBracedContent(ctx, expr.prefix); - const numStr = prefix + expr.suffix; - return parseArithNumber(numStr); - } - - case "ArithArrayElement": { - const isAssoc = ctx.state.associativeArrays?.has(expr.array); - - // Helper function to lookup and evaluate array value - const lookupArrayValue = async (envKey: string): Promise => { - const arrayValue = ctx.state.env.get(envKey); - if (arrayValue !== undefined) { - return await evaluateArithValue(ctx, arrayValue); - } - return 0; - }; - - // Case 1: Literal string key - A['key'] - if (expr.stringKey !== undefined) { - return await lookupArrayValue(`${expr.array}_${expr.stringKey}`); - } - - // Case 2: Associative array with variable name (no $ prefix) - A[K] - if ( - isAssoc && - expr.index?.type === "ArithVariable" && - !expr.index.hasDollarPrefix - ) { - return await lookupArrayValue(`${expr.array}_${expr.index.name}`); - } - - // Case 3: Associative array with $ prefix - A[$key] - if ( - isAssoc && - expr.index?.type === "ArithVariable" && - expr.index.hasDollarPrefix - ) { - const expandedKey = await getVariable(ctx, expr.index.name); - return await lookupArrayValue(`${expr.array}_${expandedKey}`); - } - - // Case 4: Indexed array - A[expr] - if (expr.index) { - let index = await evaluateArithmetic( - ctx, - expr.index, - isExpansionContext, - ); - - // Handle negative indices - bash counts from max_index + 1 - if (index < 0) { - const elements = getArrayElements(ctx, expr.array); - const lineNum = ctx.state.currentLine; - if (elements.length === 0) { - ctx.state.expansionStderr = - (ctx.state.expansionStderr || "") + - `bash: line ${lineNum}: ${expr.array}: bad array subscript\n`; - return 0; - } - const maxIndex = Math.max( - ...elements.map(([idx]) => (typeof idx === "number" ? idx : 0)), - ); - const actualIdx = maxIndex + 1 + index; - if (actualIdx < 0) { - ctx.state.expansionStderr = - (ctx.state.expansionStderr || "") + - `bash: line ${lineNum}: ${expr.array}: bad array subscript\n`; - return 0; - } - index = actualIdx; - } - - const envKey = `${expr.array}_${index}`; - const arrayValue = ctx.state.env.get(envKey); - if (arrayValue !== undefined) { - return evaluateArithValue(ctx, arrayValue); - } - // Scalar decay: s[0] returns scalar value s - if (index === 0) { - const scalarValue = ctx.state.env.get(expr.array); - if (scalarValue !== undefined) { - return evaluateArithValue(ctx, scalarValue); - } - } - // Check nounset - if (ctx.state.options.nounset) { - const hasAnyElement = Array.from(ctx.state.env.keys()).some( - (key) => key === expr.array || key.startsWith(`${expr.array}_`), - ); - if (!hasAnyElement) { - throw new NounsetError(`${expr.array}[${index}]`); - } - } - return 0; - } - - // No index and no stringKey - invalid - return 0; - } - - case "ArithDoubleSubscript": { - // Double subscript like a[1][1] is not valid - fail silently with exit code 1 - throw new ArithmeticError("double subscript", "", ""); - } - - case "ArithNumberSubscript": { - // Number subscript like 1[2] is not valid - throw syntax error at evaluation time - throw new ArithmeticError( - `${expr.number}${expr.errorToken}: syntax error: invalid arithmetic operator (error token is "${expr.errorToken}")`, - ); - } - - case "ArithSyntaxError": { - // Syntax error node - throw at evaluation time so script can parse successfully - // These are fatal errors (like missing operand) that should abort the script - throw new ArithmeticError(expr.message, "", "", true); - } - - case "ArithSingleQuote": { - // Single-quoted string - behavior depends on context - // In $(()) expansion context, single quotes cause an error - // In (()) command context, single quotes work like numbers - if (isExpansionContext) { - // This is NOT a fatal error - script continues after - throw new ArithmeticError( - `syntax error: operand expected (error token is "'${expr.content}'")`, - ); - } - return expr.value; - } - - case "ArithBinary": { - // Short-circuit evaluation for logical operators - if (expr.operator === "||") { - const left = await evaluateArithmetic( - ctx, - expr.left, - isExpansionContext, - ); - if (left) return 1; - return (await evaluateArithmetic(ctx, expr.right, isExpansionContext)) - ? 1 - : 0; - } - if (expr.operator === "&&") { - const left = await evaluateArithmetic( - ctx, - expr.left, - isExpansionContext, - ); - if (!left) return 0; - return (await evaluateArithmetic(ctx, expr.right, isExpansionContext)) - ? 1 - : 0; - } - - const left = await evaluateArithmetic(ctx, expr.left, isExpansionContext); - const right = await evaluateArithmetic( - ctx, - expr.right, - isExpansionContext, - ); - return applyBinaryOp(left, right, expr.operator); - } - - case "ArithUnary": { - const operand = await evaluateArithmetic( - ctx, - expr.operand, - isExpansionContext, - ); - // Handle ++/-- with side effects separately - if (expr.operator === "++" || expr.operator === "--") { - if (expr.operand.type === "ArithVariable") { - const name = expr.operand.name; - const current = - Number.parseInt(await getVariable(ctx, name), 10) || 0; - const newValue = expr.operator === "++" ? current + 1 : current - 1; - ctx.state.env.set(name, String(newValue)); - return expr.prefix ? newValue : current; - } - if (expr.operand.type === "ArithArrayElement") { - // Handle array element increment/decrement: a[0]++, ++a[0], etc. - const arrayName = expr.operand.array; - const isAssoc = ctx.state.associativeArrays?.has(arrayName); - let envKey: string; - - if (expr.operand.stringKey !== undefined) { - envKey = `${arrayName}_${expr.operand.stringKey}`; - } else if ( - isAssoc && - expr.operand.index?.type === "ArithVariable" && - !expr.operand.index.hasDollarPrefix - ) { - // A[K]++ where K is without $ -> use "K" as literal key - envKey = `${arrayName}_${expr.operand.index.name}`; - } else if ( - isAssoc && - expr.operand.index?.type === "ArithVariable" && - expr.operand.index.hasDollarPrefix - ) { - // A[$key]++ where key has $ -> expand $key to get the actual key - const expandedKey = await getVariable(ctx, expr.operand.index.name); - envKey = `${arrayName}_${expandedKey}`; - } else if (expr.operand.index) { - const index = await evaluateArithmetic( - ctx, - expr.operand.index, - isExpansionContext, - ); - envKey = `${arrayName}_${index}`; - } else { - return operand; - } - - const current = - Number.parseInt(ctx.state.env.get(envKey) || "0", 10) || 0; - const newValue = expr.operator === "++" ? current + 1 : current - 1; - ctx.state.env.set(envKey, String(newValue)); - return expr.prefix ? newValue : current; - } - if (expr.operand.type === "ArithConcat") { - // Handle dynamic variable name increment/decrement: x$foo++ - let varName = ""; - for (const part of expr.operand.parts) { - varName += await evalConcatPartToStringAsync( - ctx, - part, - isExpansionContext, - ); - } - if (varName && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(varName)) { - const current = - Number.parseInt(ctx.state.env.get(varName) || "0", 10) || 0; - const newValue = expr.operator === "++" ? current + 1 : current - 1; - ctx.state.env.set(varName, String(newValue)); - return expr.prefix ? newValue : current; - } - } - if (expr.operand.type === "ArithDynamicElement") { - // Handle dynamic array element increment/decrement: x$foo[5]++ - let varName = ""; - if (expr.operand.nameExpr.type === "ArithConcat") { - for (const part of expr.operand.nameExpr.parts) { - varName += await evalConcatPartToStringAsync( - ctx, - part, - isExpansionContext, - ); - } - } else if (expr.operand.nameExpr.type === "ArithVariable") { - varName = expr.operand.nameExpr.hasDollarPrefix - ? await getVariable(ctx, expr.operand.nameExpr.name) - : expr.operand.nameExpr.name; - } - if (varName && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(varName)) { - const index = await evaluateArithmetic( - ctx, - expr.operand.subscript, - isExpansionContext, - ); - const envKey = `${varName}_${index}`; - const current = - Number.parseInt(ctx.state.env.get(envKey) || "0", 10) || 0; - const newValue = expr.operator === "++" ? current + 1 : current - 1; - ctx.state.env.set(envKey, String(newValue)); - return expr.prefix ? newValue : current; - } - } - return operand; - } - return applyUnaryOp(operand, expr.operator); - } - - case "ArithTernary": { - const condition = await evaluateArithmetic( - ctx, - expr.condition, - isExpansionContext, - ); - return condition - ? await evaluateArithmetic(ctx, expr.consequent, isExpansionContext) - : await evaluateArithmetic(ctx, expr.alternate, isExpansionContext); - } - - case "ArithAssignment": { - const name = expr.variable; - let envKey = name; - - // Handle array element assignment - if (expr.stringKey !== undefined) { - // Literal string key: A['key'] = V - envKey = `${name}_${expr.stringKey}`; - } else if (expr.subscript) { - const isAssoc = ctx.state.associativeArrays?.has(name); - if ( - isAssoc && - expr.subscript.type === "ArithVariable" && - !expr.subscript.hasDollarPrefix - ) { - // For associative arrays, variable names without $ prefix are used as literal keys - // A[K] = V where K is a variable name without $ -> use "K" as the key - envKey = `${name}_${expr.subscript.name}`; - } else if ( - isAssoc && - expr.subscript.type === "ArithVariable" && - expr.subscript.hasDollarPrefix - ) { - // For associative arrays with $ prefix: A[$key] -> expand $key to get the actual key - // OSH quirk: when the variable is unset/empty in quoted context (A["$key"]), - // use backslash as key. This matches spec test "bash bug: (( A["$key"] = 1 ))" - const expandedKey = await getVariable(ctx, expr.subscript.name); - // When variable expands to empty, use backslash as the key (OSH behavior) - envKey = `${name}_${expandedKey || "\\"}`; - } else if (isAssoc) { - // For non-variable subscripts on associative arrays, evaluate and convert to string - const index = await evaluateArithmetic( - ctx, - expr.subscript, - isExpansionContext, - ); - envKey = `${name}_${index}`; - } else { - // For indexed arrays, evaluate the subscript as arithmetic - let index = await evaluateArithmetic( - ctx, - expr.subscript, - isExpansionContext, - ); - // Handle negative indices - if (index < 0) { - const elements = getArrayElements(ctx, name); - if (elements.length > 0) { - const maxIndex = Math.max( - ...elements.map(([idx]) => (typeof idx === "number" ? idx : 0)), - ); - index = maxIndex + 1 + index; - } - } - envKey = `${name}_${index}`; - } - } - - const current = - Number.parseInt(ctx.state.env.get(envKey) || "0", 10) || 0; - const value = await evaluateArithmetic( - ctx, - expr.value, - isExpansionContext, - ); - const newValue = applyAssignmentOp(current, value, expr.operator); - ctx.state.env.set(envKey, String(newValue)); - return newValue; - } - - case "ArithGroup": - return await evaluateArithmetic(ctx, expr.expression, isExpansionContext); - - case "ArithConcat": { - // Concatenate all parts to form a dynamic variable name or number - // For ArithVariable without $, use the literal name; with $, use the value - let concatenated = ""; - for (const part of expr.parts) { - concatenated += await evalConcatPartToStringAsync( - ctx, - part, - isExpansionContext, - ); - } - // If the result is a valid identifier, look it up as a variable - if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(concatenated)) { - return await resolveArithVariable(ctx, concatenated); - } - // Otherwise parse as a number - return Number.parseInt(concatenated, 10) || 0; - } - - case "ArithDynamicAssignment": { - // Dynamic assignment: x$foo = 42 or x$foo[5] = 42 assigns to variable built from concatenation - let varName = ""; - // Build the variable name from the target expression - if (expr.target.type === "ArithConcat") { - for (const part of expr.target.parts) { - varName += await evalConcatPartToStringAsync( - ctx, - part, - isExpansionContext, - ); - } - } else if (expr.target.type === "ArithVariable") { - varName = expr.target.hasDollarPrefix - ? await getVariable(ctx, expr.target.name) - : expr.target.name; - } - if (!varName || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(varName)) { - return 0; // Invalid variable name - } - // Build the env key - include subscript for array assignment - let envKey = varName; - if (expr.subscript) { - const index = await evaluateArithmetic( - ctx, - expr.subscript, - isExpansionContext, - ); - envKey = `${varName}_${index}`; - } - const current = - Number.parseInt(ctx.state.env.get(envKey) || "0", 10) || 0; - const value = await evaluateArithmetic( - ctx, - expr.value, - isExpansionContext, - ); - const newValue = applyAssignmentOp(current, value, expr.operator); - ctx.state.env.set(envKey, String(newValue)); - return newValue; - } - - case "ArithDynamicElement": { - // Dynamic array element: x$foo[5] - build array name from concat, then access element - let varName = ""; - if (expr.nameExpr.type === "ArithConcat") { - for (const part of expr.nameExpr.parts) { - varName += await evalConcatPartToStringAsync( - ctx, - part, - isExpansionContext, - ); - } - } else if (expr.nameExpr.type === "ArithVariable") { - varName = expr.nameExpr.hasDollarPrefix - ? await getVariable(ctx, expr.nameExpr.name) - : expr.nameExpr.name; - } - if (!varName || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(varName)) { - return 0; // Invalid variable name - } - const index = await evaluateArithmetic( - ctx, - expr.subscript, - isExpansionContext, - ); - const envKey = `${varName}_${index}`; - const value = ctx.state.env.get(envKey); - if (value !== undefined) { - return parseArithValue(value); - } - return 0; - } - - default: - return 0; - } -} - -/** - * Evaluate an arithmetic expression part for concatenation purposes (async). - * For ArithVariable without $ prefix, returns the literal name. - * For ArithVariable with $ prefix, returns the variable's value. - */ -async function evalConcatPartToStringAsync( - ctx: InterpreterContext, - expr: ArithExpr, - isExpansionContext = false, -): Promise { - switch (expr.type) { - case "ArithNumber": - return String(expr.value); - case "ArithSingleQuote": - // For single quotes in concatenation context, evaluate through main evaluator - // which will handle the expansion vs command context distinction - return String(await evaluateArithmetic(ctx, expr, isExpansionContext)); - case "ArithVariable": - // If no $ prefix, use the literal name for building dynamic var names - // If has $ prefix, expand to the variable's value - if (expr.hasDollarPrefix) { - return await getVariable(ctx, expr.name); - } - return expr.name; - case "ArithSpecialVar": - return await getVariable(ctx, expr.name); - case "ArithBracedExpansion": - return await expandBracedContent(ctx, expr.content); - case "ArithCommandSubst": { - if (ctx.execFn) { - const result = await ctx.execFn(expr.command); - return result.stdout.trim(); - } - return "0"; - } - case "ArithConcat": { - let result = ""; - for (const part of expr.parts) { - result += await evalConcatPartToStringAsync( - ctx, - part, - isExpansionContext, - ); - } - return result; - } - default: - return String(await evaluateArithmetic(ctx, expr, isExpansionContext)); - } -} diff --git a/src/interpreter/assignment-expansion.ts b/src/interpreter/assignment-expansion.ts deleted file mode 100644 index bdc01ad3..00000000 --- a/src/interpreter/assignment-expansion.ts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Assignment Expansion Helpers - * - * Handles expansion of assignment arguments for local/declare/typeset builtins. - * - Array assignments: name=(elem1 elem2 ...) - * - Scalar assignments: name=value, name+=value, name[index]=value - */ - -import type { WordNode } from "../ast/types.js"; -import { expandWord, expandWordWithGlob } from "./expansion.js"; -import { wordToLiteralString } from "./helpers/array.js"; -import type { InterpreterContext } from "./types.js"; - -/** - * Check if a Word represents an array assignment (name=(...)) and expand it - * while preserving quote structure for elements. - * Returns the expanded string like "name=(elem1 elem2 ...)" or null if not an array assignment. - */ -export async function expandLocalArrayAssignment( - ctx: InterpreterContext, - word: WordNode, -): Promise { - // First, join all parts to check if this looks like an array assignment - const fullLiteral = word.parts - .map((p) => (p.type === "Literal" ? p.value : "\x00")) - .join(""); - const arrayMatch = fullLiteral.match(/^([a-zA-Z_][a-zA-Z0-9_]*)=\(/); - if (!arrayMatch || !fullLiteral.endsWith(")")) { - return null; - } - - const name = arrayMatch[1]; - const elements: string[] = []; - let inArrayContent = false; - let pendingLiteral = ""; - // Track whether we've seen a quoted part (SingleQuoted, DoubleQuoted) since - // last element push. This ensures empty quoted strings like '' are preserved. - let hasQuotedContent = false; - - for (const part of word.parts) { - if (part.type === "Literal") { - let value = part.value; - if (!inArrayContent) { - // Look for =( to start array content - const idx = value.indexOf("=("); - if (idx !== -1) { - inArrayContent = true; - value = value.slice(idx + 2); - } - } - - if (inArrayContent) { - // Check for closing ) - if (value.endsWith(")")) { - value = value.slice(0, -1); - } - - // Process literal content: split by whitespace - // But handle the case where this literal is adjacent to a quoted part - const tokens = value.split(/(\s+)/); - for (const token of tokens) { - if (/^\s+$/.test(token)) { - // Whitespace - push pending element if we have content OR saw quoted part - if (pendingLiteral || hasQuotedContent) { - elements.push(pendingLiteral); - pendingLiteral = ""; - hasQuotedContent = false; - } - } else if (token) { - // Non-empty token - accumulate - pendingLiteral += token; - } - } - } - } else if (inArrayContent) { - // Handle BraceExpansion specially - it produces multiple values - // BUT only if we're not inside a keyed element [key]=value - if (part.type === "BraceExpansion") { - // Check if pendingLiteral looks like a keyed element pattern: [key]=... - // If so, brace expansion should NOT happen in the value part - const isKeyedElement = /^\[.+\]=/.test(pendingLiteral); - if (isKeyedElement) { - // Inside a keyed element value - convert brace to literal, no expansion - pendingLiteral += wordToLiteralString({ - type: "Word", - parts: [part], - }); - } else { - // Plain element - expand braces normally - // Push any pending literal first - if (pendingLiteral || hasQuotedContent) { - elements.push(pendingLiteral); - pendingLiteral = ""; - hasQuotedContent = false; - } - // Use expandWordWithGlob to properly expand brace expressions - const braceExpanded = await expandWordWithGlob(ctx, { - type: "Word", - parts: [part], - }); - // Add each expanded value as a separate element - elements.push(...braceExpanded.values); - } - } else { - // Quoted/expansion part - expand it and accumulate as single element - // Mark that we've seen quoted content (for empty string preservation) - if ( - part.type === "SingleQuoted" || - part.type === "DoubleQuoted" || - part.type === "Escaped" - ) { - hasQuotedContent = true; - } - const expanded = await expandWord(ctx, { - type: "Word", - parts: [part], - }); - pendingLiteral += expanded; - } - } - } - - // Push final element if we have content OR saw quoted part - if (pendingLiteral || hasQuotedContent) { - elements.push(pendingLiteral); - } - - // Build result string with proper quoting - const quotedElements = elements.map((elem) => { - // Don't quote keyed elements like ['key']=value or [index]=value - // These need to be parsed by the declare builtin as-is - if (/^\[.+\]=/.test(elem)) { - return elem; - } - // Empty strings must be quoted to be preserved - if (elem === "") { - return "''"; - } - // If element contains whitespace or special chars, quote it - if ( - /[\s"'\\$`!*?[\]{}|&;<>()]/.test(elem) && - !elem.startsWith("'") && - !elem.startsWith('"') - ) { - // Use single quotes, escaping existing single quotes - return `'${elem.replace(/'/g, "'\\''")}'`; - } - return elem; - }); - - return `${name}=(${quotedElements.join(" ")})`; -} - -/** - * Check if a Word represents a scalar assignment (name=value, name+=value, or name[index]=value) - * and expand it WITHOUT glob expansion on the value part. - * Returns the expanded string like "name=expanded_value" or null if not a scalar assignment. - * - * This is important for bash compatibility: `local var=$x` where x='a b' should - * set var to "a b", not try to glob-expand it. - */ -export async function expandScalarAssignmentArg( - ctx: InterpreterContext, - word: WordNode, -): Promise { - // Look for = in the word parts to detect assignment pattern - // We need to find where the assignment operator is and split there - let eqPartIndex = -1; - let eqCharIndex = -1; - let isAppend = false; - - for (let i = 0; i < word.parts.length; i++) { - const part = word.parts[i]; - if (part.type === "Literal") { - // Check for += first - const appendIdx = part.value.indexOf("+="); - if (appendIdx !== -1) { - // Verify it looks like an assignment: should have valid var name before += - const before = part.value.slice(0, appendIdx); - if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(before)) { - eqPartIndex = i; - eqCharIndex = appendIdx; - isAppend = true; - break; - } - // Also check for array index append: name[index]+= - if (/^[a-zA-Z_][a-zA-Z0-9_]*\[[^\]]+\]$/.test(before)) { - eqPartIndex = i; - eqCharIndex = appendIdx; - isAppend = true; - break; - } - } - // Check for regular = (but not == or != or other operators) - const eqIdx = part.value.indexOf("="); - if (eqIdx !== -1 && (eqIdx === 0 || part.value[eqIdx - 1] !== "+")) { - // Make sure it's not inside brackets like [0]= which we handle separately - // and verify it looks like an assignment - const before = part.value.slice(0, eqIdx); - if ( - /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(before) || - /^[a-zA-Z_][a-zA-Z0-9_]*\[[^\]]+\]$/.test(before) - ) { - eqPartIndex = i; - eqCharIndex = eqIdx; - break; - } - } - } - } - - // No assignment operator found - if (eqPartIndex === -1) { - return null; - } - - // Split the word into name part and value part - const nameParts = word.parts.slice(0, eqPartIndex); - const eqPart = word.parts[eqPartIndex]; - - if (eqPart.type !== "Literal") { - return null; - } - - const operatorLen = isAppend ? 2 : 1; - const nameFromEqPart = eqPart.value.slice(0, eqCharIndex); - const valueFromEqPart = eqPart.value.slice(eqCharIndex + operatorLen); - const valueParts = word.parts.slice(eqPartIndex + 1); - - // Construct the name by expanding the name parts (no glob needed for names) - let name = ""; - for (const part of nameParts) { - name += await expandWord(ctx, { type: "Word", parts: [part] }); - } - name += nameFromEqPart; - - // Construct the value part Word for expansion WITHOUT glob - const valueWord: WordNode = { - type: "Word", - parts: - valueFromEqPart !== "" - ? [{ type: "Literal", value: valueFromEqPart }, ...valueParts] - : valueParts, - }; - - // Expand the value WITHOUT glob expansion - const value = - valueWord.parts.length > 0 ? await expandWord(ctx, valueWord) : ""; - - const operator = isAppend ? "+=" : "="; - return `${name}${operator}${value}`; -} diff --git a/src/interpreter/assoc-array.test.ts b/src/interpreter/assoc-array.test.ts deleted file mode 100644 index d62a2720..00000000 --- a/src/interpreter/assoc-array.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -describe("Associative Arrays", () => { - const createEnv = () => - new Bash({ - files: { "/tmp/_keep": "" }, - cwd: "/tmp", - env: { HOME: "/tmp" }, - }); - - describe("declare -A", () => { - it("should declare an associative array", async () => { - const env = createEnv(); - const result = await env.exec(` - declare -A arr - arr['foo']=bar - echo "\${arr['foo']}" - `); - expect(result.stdout.trim()).toBe("bar"); - expect(result.exitCode).toBe(0); - }); - - it("should initialize associative array with literal syntax", async () => { - const env = createEnv(); - const result = await env.exec(` - declare -A A=(['foo']=bar ['spam']=42) - echo "\${A['foo']} \${A['spam']}" - `); - expect(result.stdout.trim()).toBe("bar 42"); - expect(result.exitCode).toBe(0); - }); - - it("should not reset existing associative array on redeclare", async () => { - const env = createEnv(); - const result = await env.exec(` - declare -A dict - dict['foo']=hello - declare -A dict - echo "\${dict['foo']}" - `); - expect(result.stdout.trim()).toBe("hello"); - }); - }); - - describe("string key assignment", () => { - it("should assign with quoted string key", async () => { - const env = createEnv(); - const result = await env.exec(` - declare -A arr - arr['my key']=value - echo "\${arr['my key']}" - `); - expect(result.stdout.trim()).toBe("value"); - }); - - it("should assign with double-quoted string key", async () => { - const env = createEnv(); - const result = await env.exec(` - declare -A arr - arr["my key"]=value - echo "\${arr["my key"]}" - `); - expect(result.stdout.trim()).toBe("value"); - }); - }); - - describe("arithmetic context", () => { - it("should read from associative array in arithmetic", async () => { - const env = createEnv(); - const result = await env.exec(` - declare -A A - A['x']=42 - echo "before: A['x']=" \${A['x']} - (( x = A['x'] )) - echo "after: x=$x" - `); - console.log("DEBUG read arith stdout:", JSON.stringify(result.stdout)); - console.log("DEBUG read arith stderr:", JSON.stringify(result.stderr)); - expect(result.stdout).toContain("42"); - }); - - it("should assign to associative array in arithmetic with string key", async () => { - const env = createEnv(); - const result = await env.exec(` - declare -A A - (( A['foo'] = 123 )) - echo "\${A['foo']}" - `); - expect(result.stdout.trim()).toBe("123"); - }); - - it("should use variable name as literal key for associative arrays", async () => { - const env = createEnv(); - // In bash, for associative arrays, A[K] uses "K" as the key, not K's value - const result = await env.exec(` - declare -A A - K=5 - V=42 - (( A[K] = V )) - echo "\${A['K']}" - `); - expect(result.stdout.trim()).toBe("42"); - }); - - it("should coerce string values to integers in arithmetic", async () => { - const env = createEnv(); - const result = await env.exec(` - declare -A A - A['x']=42 - (( x = A['x'] )) - (( A['y'] = 'y' )) - echo $x \${A['y']} - `); - // 'y' as a value gets coerced to 0 (variable y is unset) - expect(result.stdout.trim()).toBe("42 0"); - }); - - it("should support compound assignment operators", async () => { - const env = createEnv(); - const result = await env.exec(` - declare -A A - A['count']=10 - (( A['count'] += 5 )) - echo "\${A['count']}" - `); - expect(result.stdout.trim()).toBe("15"); - }); - - it("should support increment/decrement on associative array elements", async () => { - const env = createEnv(); - const result = await env.exec(` - declare -A A - A['x']=10 - (( A['x']++ )) - echo "\${A['x']}" - `); - expect(result.stdout.trim()).toBe("11"); - }); - }); - - describe("indexed arrays (existing behavior)", () => { - it("should still work with numeric indices", async () => { - const env = createEnv(); - const result = await env.exec(` - declare -a arr - arr[0]=first - arr[1]=second - echo "\${arr[0]} \${arr[1]}" - `); - expect(result.stdout.trim()).toBe("first second"); - }); - - it("should evaluate arithmetic expressions in indices for indexed arrays", async () => { - const env = createEnv(); - const result = await env.exec(` - declare -a arr - arr[0]=zero - arr[1]=one - arr[2]=two - i=1 - echo "\${arr[i]} \${arr[i+1]}" - `); - expect(result.stdout.trim()).toBe("one two"); - }); - - it("should use variable VALUE for indexed array subscripts in arithmetic", async () => { - const env = createEnv(); - // For indexed arrays, A[K] evaluates K as arithmetic (gets its value) - const result = await env.exec(` - declare -a arr - arr[5]=value - K=5 - (( x = arr[K] )) - echo "got: \${arr[5]}" - `); - expect(result.stdout.trim()).toBe("got: value"); - }); - }); - - describe("array element access", () => { - it("should return all values with ${arr[@]}", async () => { - const env = createEnv(); - const result = await env.exec(` - declare -A A - A['a']=1 - A['b']=2 - A['c']=3 - echo "\${A[@]}" - `); - // Values should be returned (order may vary for associative arrays) - const values = result.stdout.trim().split(" ").sort(); - expect(values).toEqual(["1", "2", "3"]); - }); - - it("should return empty for unset key", async () => { - const env = createEnv(); - const result = await env.exec(` - declare -A A - A['foo']=bar - echo "[\${A['nonexistent']}]" - `); - expect(result.stdout.trim()).toBe("[]"); - }); - }); - - describe("TODO: spec test fixes", () => { - it.skip("key-value sequence initialization", async () => { - // declare -A A=(1 2 3) should create ['1']=2 ['3']='' - const env = createEnv(); - const result = await env.exec(` - declare -A A=(1 2 3) - declare -p A - `); - // Expected: declare -A A=(['1']=2 ['3']='') - expect(result.stdout.trim()).toBe("declare -A A=(['1']=2 ['3']='' )"); - expect(result.exitCode).toBe(0); - }); - - it("variable key lookup", async () => { - const env = createEnv(); - const result = await env.exec(` - declare -A A - A["aa"]=b - A["foo"]=bar - key=foo - echo \${A[$key]} - i=a - echo \${A["$i$i"]} - `); - expect(result.stdout.trim()).toBe("bar\nb"); - }); - - it("self-reference in assignment (bash behavior)", async () => { - const env = createEnv(); - // Step 0: Check declare -p output - let result = await env.exec(` - declare -A foo - foo=(["key"]="value1") - declare -p foo - `); - console.log("step0 stdout:", JSON.stringify(result.stdout)); - - // Step 1: Single quoted assignment & lookup - result = await env.exec(` - declare -A bar - bar=(['key']='value1') - echo \${bar['key']} - `); - console.log("step1 stdout:", JSON.stringify(result.stdout)); - expect(result.stdout.trim()).toBe("value1"); - }); - - it("nested array index in array literal", async () => { - const env = createEnv(); - // Test: a=([0]=1+2+3 [a[0]]=10 [a[6]]=hello) - // [0]=1+2+3 sets a[0]="1+2+3" (literal string) - // [a[0]]=10 - a[0] is "1+2+3", in arithmetic context 1+2+3=6, so a[6]=10 - // [a[6]]=hello - a[6] is now 10, so a[10]="hello" - const result = await env.exec(` - a=([0]=1+2+3 [a[0]]=10 [a[6]]=hello) - echo "keys: \${!a[@]}" - echo "vals: \${a[@]}" - `); - expect(result.stdout.trim()).toBe("keys: 0 6 10\nvals: 1+2+3 10 hello"); - }); - }); -}); diff --git a/src/interpreter/builtin-dispatch.ts b/src/interpreter/builtin-dispatch.ts deleted file mode 100644 index 905b6e38..00000000 --- a/src/interpreter/builtin-dispatch.ts +++ /dev/null @@ -1,441 +0,0 @@ -/** - * Builtin Command Dispatch - * - * Handles dispatch of built-in shell commands like export, unset, cd, etc. - * Separated from interpreter.ts for modularity. - */ - -import { isBrowserExcludedCommand } from "../commands/browser-excluded.js"; -import type { CommandContext, ExecResult } from "../types.js"; -import { - handleBreak, - handleCd, - handleCompgen, - handleComplete, - handleCompopt, - handleContinue, - handleDeclare, - handleDirs, - handleEval, - handleExit, - handleExport, - handleGetopts, - handleHash, - handleHelp, - handleLet, - handleLocal, - handleMapfile, - handlePopd, - handlePushd, - handleRead, - handleReadonly, - handleReturn, - handleSet, - handleShift, - handleSource, - handleUnset, -} from "./builtins/index.js"; -import { handleShopt } from "./builtins/shopt.js"; -import { - findCommandInPath as findCommandInPathHelper, - resolveCommand as resolveCommandHelper, -} from "./command-resolution.js"; -import { evaluateTestArgs } from "./conditionals.js"; -import { ExecutionLimitError } from "./errors.js"; -import { callFunction } from "./functions.js"; -import { getErrorMessage } from "./helpers/errors.js"; -import { failure, OK, testResult } from "./helpers/result.js"; -import { SHELL_BUILTINS } from "./helpers/shell-constants.js"; -import { - findFirstInPath as findFirstInPathHelper, - handleCommandV as handleCommandVHelper, - handleType as handleTypeHelper, -} from "./type-command.js"; -import type { InterpreterContext } from "./types.js"; - -/** - * Type for the function that runs a command recursively - */ -export type RunCommandFn = ( - commandName: string, - args: string[], - quotedArgs: boolean[], - stdin: string, - skipFunctions?: boolean, - useDefaultPath?: boolean, - stdinSourceFd?: number, -) => Promise; - -/** - * Type for the function that builds exported environment - */ -export type BuildExportedEnvFn = () => Record; - -/** - * Type for the function that executes user scripts - */ -export type ExecuteUserScriptFn = ( - scriptPath: string, - args: string[], - stdin?: string, -) => Promise; - -/** - * Dispatch context containing dependencies needed for builtin dispatch - */ -export interface BuiltinDispatchContext { - ctx: InterpreterContext; - runCommand: RunCommandFn; - buildExportedEnv: BuildExportedEnvFn; - executeUserScript: ExecuteUserScriptFn; -} - -/** - * Dispatch a command to the appropriate builtin handler or external command. - * Returns null if the command should be handled by external command resolution. - */ -export async function dispatchBuiltin( - dispatchCtx: BuiltinDispatchContext, - commandName: string, - args: string[], - _quotedArgs: boolean[], - stdin: string, - skipFunctions: boolean, - _useDefaultPath: boolean, - stdinSourceFd: number, -): Promise { - const { ctx, runCommand } = dispatchCtx; - - // Coverage tracking for builtins (lightweight: only fires when coverage is enabled) - if (ctx.coverage && SHELL_BUILTINS.has(commandName)) { - ctx.coverage.hit(`bash:builtin:${commandName}`); - } - - // Built-in commands (special builtins that cannot be overridden by functions) - if (commandName === "export") { - return handleExport(ctx, args); - } - if (commandName === "unset") { - return handleUnset(ctx, args); - } - if (commandName === "exit") { - return handleExit(ctx, args); - } - if (commandName === "local") { - return handleLocal(ctx, args); - } - if (commandName === "set") { - return handleSet(ctx, args); - } - if (commandName === "break") { - return handleBreak(ctx, args); - } - if (commandName === "continue") { - return handleContinue(ctx, args); - } - if (commandName === "return") { - return handleReturn(ctx, args); - } - // In POSIX mode, eval is a special builtin that cannot be overridden by functions - // In non-POSIX mode (bash default), functions can override eval - if (commandName === "eval" && ctx.state.options.posix) { - return handleEval(ctx, args, stdin); - } - if (commandName === "shift") { - return handleShift(ctx, args); - } - if (commandName === "getopts") { - return handleGetopts(ctx, args); - } - if (commandName === "compgen") { - return handleCompgen(ctx, args); - } - if (commandName === "complete") { - return handleComplete(ctx, args); - } - if (commandName === "compopt") { - return handleCompopt(ctx, args); - } - if (commandName === "pushd") { - return await handlePushd(ctx, args); - } - if (commandName === "popd") { - return handlePopd(ctx, args); - } - if (commandName === "dirs") { - return handleDirs(ctx, args); - } - if (commandName === "source" || commandName === ".") { - return handleSource(ctx, args); - } - if (commandName === "read") { - return handleRead(ctx, args, stdin, stdinSourceFd); - } - if (commandName === "mapfile" || commandName === "readarray") { - return handleMapfile(ctx, args, stdin); - } - if (commandName === "declare" || commandName === "typeset") { - return handleDeclare(ctx, args); - } - if (commandName === "readonly") { - return handleReadonly(ctx, args); - } - // User-defined functions override most builtins (except special ones above) - // This needs to happen before true/false/let which are regular builtins - if (!skipFunctions) { - const func = ctx.state.functions.get(commandName); - if (func) { - return callFunction(ctx, func, args, stdin); - } - } - // Simple builtins (can be overridden by functions) - // eval: In non-POSIX mode, functions can override eval (handled above for POSIX mode) - if (commandName === "eval") { - return handleEval(ctx, args, stdin); - } - if (commandName === "cd") { - return await handleCd(ctx, args); - } - if (commandName === ":" || commandName === "true") { - return OK; - } - if (commandName === "false") { - return testResult(false); - } - if (commandName === "let") { - return handleLet(ctx, args); - } - if (commandName === "command") { - return handleCommandBuiltin(dispatchCtx, args, stdin); - } - if (commandName === "builtin") { - return handleBuiltinBuiltin(dispatchCtx, args, stdin); - } - if (commandName === "shopt") { - return handleShopt(ctx, args); - } - if (commandName === "exec") { - // exec - replace shell with command (stub: just run the command) - if (args.length === 0) { - return OK; - } - const [cmd, ...rest] = args; - return runCommand(cmd, rest, [], stdin, false, false, -1); - } - if (commandName === "wait") { - // wait - wait for background jobs (stub: no-op in this context) - return OK; - } - if (commandName === "type") { - return await handleTypeHelper( - ctx, - args, - (name) => findFirstInPathHelper(ctx, name), - (name) => findCommandInPathHelper(ctx, name), - ); - } - if (commandName === "hash") { - return handleHash(ctx, args); - } - if (commandName === "help") { - return handleHelp(ctx, args); - } - // Test commands - // Note: [[ is NOT handled here because it's a keyword, not a command. - if (commandName === "[" || commandName === "test") { - let testArgs = args; - if (commandName === "[") { - if (args[args.length - 1] !== "]") { - return failure("[: missing `]'\n", 2); - } - testArgs = args.slice(0, -1); - } - return evaluateTestArgs(ctx, testArgs); - } - - // Return null to indicate command should be handled by external resolution - return null; -} - -/** - * Handle the 'command' builtin - */ -async function handleCommandBuiltin( - dispatchCtx: BuiltinDispatchContext, - args: string[], - stdin: string, -): Promise { - const { ctx, runCommand } = dispatchCtx; - - // command [-pVv] command [arg...] - run command, bypassing functions - if (args.length === 0) { - return OK; - } - // Parse options - let useDefaultPath = false; // -p flag - let verboseDescribe = false; // -V flag (like type) - let showPath = false; // -v flag (show path/name) - let cmdArgs = args; - - while (cmdArgs.length > 0 && cmdArgs[0].startsWith("-")) { - const opt = cmdArgs[0]; - if (opt === "--") { - cmdArgs = cmdArgs.slice(1); - break; - } - // Handle combined options like -pv, -vV, etc. - for (const char of opt.slice(1)) { - if (char === "p") { - useDefaultPath = true; - } else if (char === "V") { - verboseDescribe = true; - } else if (char === "v") { - showPath = true; - } - } - cmdArgs = cmdArgs.slice(1); - } - - if (cmdArgs.length === 0) { - return OK; - } - - // Handle -v and -V: describe commands without executing - if (showPath || verboseDescribe) { - return await handleCommandVHelper(ctx, cmdArgs, showPath, verboseDescribe); - } - - // Run command without checking functions, but builtins are still available - // Pass useDefaultPath to use /usr/bin:/bin instead of $PATH - const [cmd, ...rest] = cmdArgs; - return runCommand(cmd, rest, [], stdin, true, useDefaultPath, -1); -} - -/** - * Handle the 'builtin' builtin - */ -async function handleBuiltinBuiltin( - dispatchCtx: BuiltinDispatchContext, - args: string[], - stdin: string, -): Promise { - const { runCommand } = dispatchCtx; - - // builtin command [arg...] - run builtin command - if (args.length === 0) { - return OK; - } - // Handle -- option terminator - let cmdArgs = args; - if (cmdArgs[0] === "--") { - cmdArgs = cmdArgs.slice(1); - if (cmdArgs.length === 0) { - return OK; - } - } - const cmd = cmdArgs[0]; - // Check if the command is a shell builtin - if (!SHELL_BUILTINS.has(cmd)) { - // Not a builtin - return error - return failure(`bash: builtin: ${cmd}: not a shell builtin\n`); - } - const [, ...rest] = cmdArgs; - // Run as builtin (recursive call, skip function lookup) - return runCommand(cmd, rest, [], stdin, true, false, -1); -} - -/** - * Handle external command resolution and execution. - * Called when dispatchBuiltin returns null. - */ -export async function executeExternalCommand( - dispatchCtx: BuiltinDispatchContext, - commandName: string, - args: string[], - stdin: string, - useDefaultPath: boolean, -): Promise { - const { ctx, buildExportedEnv, executeUserScript } = dispatchCtx; - - // External commands - resolve via PATH - // For command -p, use default PATH /usr/bin:/bin instead of $PATH - const defaultPath = "/usr/bin:/bin"; - const resolved = await resolveCommandHelper( - ctx, - commandName, - useDefaultPath ? defaultPath : undefined, - ); - if (!resolved) { - // Check if this is a browser-excluded command for a more helpful error - if (isBrowserExcludedCommand(commandName)) { - return failure( - `bash: ${commandName}: command not available in browser environments. ` + - `Exclude '${commandName}' from your commands or use the Node.js bundle.\n`, - 127, - ); - } - return failure(`bash: ${commandName}: command not found\n`, 127); - } - // Handle error cases from resolveCommand - if ("error" in resolved) { - if (resolved.error === "permission_denied") { - return failure(`bash: ${commandName}: Permission denied\n`, 126); - } - // not_found error - return failure(`bash: ${commandName}: No such file or directory\n`, 127); - } - // Handle user scripts (executable files without registered command handlers) - if ("script" in resolved) { - // Add to hash table for PATH caching (only for non-path commands) - if (!commandName.includes("/")) { - if (!ctx.state.hashTable) { - ctx.state.hashTable = new Map(); - } - ctx.state.hashTable.set(commandName, resolved.path); - } - return await executeUserScript(resolved.path, args, stdin); - } - const { cmd, path: cmdPath } = resolved; - // Add to hash table for PATH caching (only for non-path commands) - if (!commandName.includes("/")) { - if (!ctx.state.hashTable) { - ctx.state.hashTable = new Map(); - } - ctx.state.hashTable.set(commandName, cmdPath); - } - - // Use groupStdin as fallback if no stdin from redirections/pipeline - // This is needed for commands inside groups/functions that receive stdin via heredoc - const effectiveStdin = stdin || ctx.state.groupStdin || ""; - - // Build exported environment for commands that need it (printenv, env, etc.) - // Most builtins need access to the full env to modify state - const exportedEnv = buildExportedEnv(); - - const cmdCtx: CommandContext = { - fs: ctx.fs, - cwd: ctx.state.cwd, - env: ctx.state.env, - exportedEnv, - stdin: effectiveStdin, - limits: ctx.limits, - exec: ctx.execFn, - fetch: ctx.fetch, - getRegisteredCommands: () => Array.from(ctx.commands.keys()), - sleep: ctx.sleep, - trace: ctx.trace, - fileDescriptors: ctx.state.fileDescriptors, - xpgEcho: ctx.state.shoptOptions.xpg_echo, - coverage: ctx.coverage, - }; - - try { - return await cmd.execute(args, cmdCtx); - } catch (error) { - // ExecutionLimitError must propagate - these are safety limits - if (error instanceof ExecutionLimitError) { - throw error; - } - return failure(`${commandName}: ${getErrorMessage(error)}\n`); - } -} diff --git a/src/interpreter/builtins/break.test.ts b/src/interpreter/builtins/break.test.ts deleted file mode 100644 index cd47f43d..00000000 --- a/src/interpreter/builtins/break.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("break builtin", () => { - describe("basic break", () => { - it("should exit for loop early", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3 4 5; do - if [ $i -eq 3 ]; then break; fi - echo $i - done - echo done - `); - expect(result.stdout).toBe("1\n2\ndone\n"); - expect(result.exitCode).toBe(0); - }); - - it("should exit while loop early", async () => { - const env = new Bash(); - const result = await env.exec(` - x=0 - while [ $x -lt 10 ]; do - x=$((x + 1)) - if [ $x -eq 3 ]; then break; fi - echo $x - done - echo done - `); - expect(result.stdout).toBe("1\n2\ndone\n"); - expect(result.exitCode).toBe(0); - }); - - it("should exit until loop early", async () => { - const env = new Bash(); - const result = await env.exec(` - x=0 - until [ $x -ge 10 ]; do - x=$((x + 1)) - if [ $x -eq 3 ]; then break; fi - echo $x - done - echo done - `); - expect(result.stdout).toBe("1\n2\ndone\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("break with level argument", () => { - it("should break multiple levels with break n", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2; do - for j in a b c; do - if [ $j = b ]; then break 2; fi - echo "$i$j" - done - done - echo done - `); - expect(result.stdout).toBe("1a\ndone\n"); - expect(result.exitCode).toBe(0); - }); - - it("should break single level with break 1", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3; do - if [ $i -eq 2 ]; then break 1; fi - echo $i - done - echo done - `); - expect(result.stdout).toBe("1\ndone\n"); - }); - - it("should handle break with level exceeding loop depth", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2; do - break 10 - echo $i - done - echo done - `); - // break 10 in a single loop should just break out - expect(result.stdout).toBe("done\n"); - }); - }); - - describe("error cases", () => { - it("should silently do nothing when not in loop", async () => { - const env = new Bash(); - const result = await env.exec("break"); - // In bash, break outside a loop silently does nothing - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should error on invalid argument", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3; do - break abc - done - `); - expect(result.stderr).toContain("numeric argument required"); - expect(result.exitCode).toBe(128); // bash returns 128 for invalid break args - }); - - it("should error on zero argument", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3; do - break 0 - done - `); - expect(result.stderr).toContain("numeric argument required"); - expect(result.exitCode).toBe(128); // bash returns 128 for invalid break args - }); - - it("should error on negative argument", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3; do - break -1 - done - `); - expect(result.stderr).toContain("numeric argument required"); - expect(result.exitCode).toBe(128); // bash returns 128 for invalid break args - }); - - it("should error on too many arguments (bash behavior)", async () => { - const env = new Bash(); - const result = await env.exec(` - for x in a b c; do - echo $x - break 1 2 3 - done - echo -- - `); - // bash errors on too many args and exits with code 1 - expect(result.stdout).toBe("a\n"); - expect(result.stderr).toContain("too many arguments"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("break in nested constructs", () => { - it("should work with case statements inside loops", async () => { - const env = new Bash(); - const result = await env.exec(` - for x in a b c; do - case $x in - b) break ;; - esac - echo $x - done - echo done - `); - expect(result.stdout).toBe("a\ndone\n"); - }); - - it("should work with if statements inside loops", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3 4 5; do - if [ $i -gt 2 ]; then - break - fi - echo $i - done - `); - expect(result.stdout).toBe("1\n2\n"); - }); - - it("should work in function inside loop", async () => { - const env = new Bash(); - const result = await env.exec(` - check() { - if [ $1 -eq 3 ]; then - break - fi - } - for i in 1 2 3 4 5; do - check $i - echo $i - done - echo done - `); - // break inside function should break the outer loop - expect(result.stdout).toBe("1\n2\ndone\n"); - }); - }); -}); diff --git a/src/interpreter/builtins/break.ts b/src/interpreter/builtins/break.ts deleted file mode 100644 index 18fe3469..00000000 --- a/src/interpreter/builtins/break.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * break - Exit from loops builtin - */ - -import type { ExecResult } from "../../types.js"; -import { BreakError, ExitError, SubshellExitError } from "../errors.js"; -import { OK } from "../helpers/result.js"; -import type { InterpreterContext } from "../types.js"; - -export function handleBreak( - ctx: InterpreterContext, - args: string[], -): ExecResult { - // Check if we're in a loop - if (ctx.state.loopDepth === 0) { - // If we're in a subshell spawned from a loop context, exit the subshell - if (ctx.state.parentHasLoopContext) { - throw new SubshellExitError(); - } - // Otherwise, break silently does nothing (returns 0) - return OK; - } - - // bash: too many arguments is an error (exit code 1) - if (args.length > 1) { - throw new ExitError(1, "", "bash: break: too many arguments\n"); - } - - let levels = 1; - if (args.length > 0) { - const n = Number.parseInt(args[0], 10); - if (Number.isNaN(n) || n < 1) { - // Invalid argument causes a fatal error in bash (exit code 128) - throw new ExitError( - 128, - "", - `bash: break: ${args[0]}: numeric argument required\n`, - ); - } - levels = n; - } - - throw new BreakError(levels); -} diff --git a/src/interpreter/builtins/cd.test.ts b/src/interpreter/builtins/cd.test.ts deleted file mode 100644 index 5a88d5a2..00000000 --- a/src/interpreter/builtins/cd.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("cd builtin", () => { - describe("basic cd", () => { - it("should change to specified directory", async () => { - const env = new Bash(); - await env.exec("mkdir -p /tmp/testdir"); - const result = await env.exec(` - cd /tmp/testdir - pwd - `); - expect(result.stdout).toBe("/tmp/testdir\n"); - }); - - it("should change to home directory without argument", async () => { - const env = new Bash({ env: { HOME: "/tmp" } }); - const result = await env.exec(` - cd - pwd - `); - expect(result.stdout).toBe("/tmp\n"); - }); - - it("should update PWD environment variable", async () => { - const env = new Bash(); - await env.exec("mkdir -p /tmp/pwdtest"); - const result = await env.exec(` - cd /tmp/pwdtest - echo $PWD - `); - expect(result.stdout).toBe("/tmp/pwdtest\n"); - }); - - it("should update OLDPWD environment variable", async () => { - const env = new Bash(); - await env.exec("mkdir -p /tmp/dir1 /tmp/dir2"); - const result = await env.exec(` - cd /tmp/dir1 - cd /tmp/dir2 - echo $OLDPWD - `); - expect(result.stdout).toBe("/tmp/dir1\n"); - }); - }); - - describe("cd with special paths", () => { - it("should handle cd -", async () => { - const env = new Bash(); - await env.exec("mkdir -p /tmp/orig /tmp/new"); - const result = await env.exec(` - cd /tmp/orig - cd /tmp/new - cd - - pwd - `); - expect(result.stdout).toContain("/tmp/orig"); - }); - - it("should handle cd with ..", async () => { - const env = new Bash(); - await env.exec("mkdir -p /tmp/parent/child"); - const result = await env.exec(` - cd /tmp/parent/child - cd .. - pwd - `); - expect(result.stdout).toBe("/tmp/parent\n"); - }); - - it("should handle cd with absolute path", async () => { - const env = new Bash(); - const result = await env.exec(` - cd /tmp - pwd - `); - expect(result.stdout).toBe("/tmp\n"); - }); - }); - - describe("error cases", () => { - it("should error on non-existent directory", async () => { - const env = new Bash(); - const result = await env.exec("cd /nonexistent/directory"); - expect(result.stderr).toContain("No such file or directory"); - expect(result.exitCode).toBe(1); - }); - - it("should error when cd to a file", async () => { - const env = new Bash(); - await env.exec("touch /tmp/testfile"); - const result = await env.exec("cd /tmp/testfile"); - expect(result.stderr).toContain("Not a directory"); - expect(result.exitCode).toBe(1); - }); - }); -}); diff --git a/src/interpreter/builtins/cd.ts b/src/interpreter/builtins/cd.ts deleted file mode 100644 index d9b51aec..00000000 --- a/src/interpreter/builtins/cd.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * cd - Change directory builtin - */ - -import type { ExecResult } from "../../types.js"; -import { failure, success } from "../helpers/result.js"; -import type { InterpreterContext } from "../types.js"; - -export async function handleCd( - ctx: InterpreterContext, - args: string[], -): Promise { - let target: string; - let printPath = false; - let physical = false; - - // Parse options - let i = 0; - while (i < args.length) { - if (args[i] === "--") { - // End of options - i++; - break; - } else if (args[i] === "-L") { - physical = false; - i++; - } else if (args[i] === "-P") { - physical = true; - i++; - } else if (args[i].startsWith("-") && args[i] !== "-") { - // Unknown option - ignore for now - i++; - } else { - break; - } - } - - // Get the target directory - const remainingArgs = args.slice(i); - if (remainingArgs.length === 0) { - target = ctx.state.env.get("HOME") || "/"; - } else if (remainingArgs[0] === "~") { - target = ctx.state.env.get("HOME") || "/"; - } else if (remainingArgs[0] === "-") { - target = ctx.state.previousDir; - printPath = true; // cd - prints the new directory - } else { - target = remainingArgs[0]; - } - - // CDPATH support: if target doesn't start with / or ., search CDPATH directories - // CDPATH is only used for relative paths that don't start with . - if ( - !target.startsWith("/") && - !target.startsWith("./") && - !target.startsWith("../") && - target !== "." && - target !== ".." - ) { - const cdpath = ctx.state.env.get("CDPATH"); - if (cdpath) { - const cdpathDirs = cdpath.split(":").filter((d) => d); - for (const dir of cdpathDirs) { - const candidate = dir.startsWith("/") - ? `${dir}/${target}` - : `${ctx.state.cwd}/${dir}/${target}`; - try { - const stat = await ctx.fs.stat(candidate); - if (stat.isDirectory) { - // Found in CDPATH - use this path and print it - target = candidate; - printPath = true; - break; - } - } catch { - // Directory doesn't exist in this CDPATH entry, continue - } - } - } - } - - // Check path components before normalization to catch cases like "nonexistent/.." - // where the intermediate directory doesn't exist - const pathToCheck = target.startsWith("/") - ? target - : `${ctx.state.cwd}/${target}`; - const parts = pathToCheck.split("/").filter((p) => p && p !== "."); - let currentPath = ""; - for (const part of parts) { - if (part === "..") { - // Go up one level - currentPath = currentPath.split("/").slice(0, -1).join("/") || "/"; - } else { - currentPath = currentPath ? `${currentPath}/${part}` : `/${part}`; - try { - const stat = await ctx.fs.stat(currentPath); - if (!stat.isDirectory) { - return failure(`bash: cd: ${target}: Not a directory\n`); - } - } catch { - return failure(`bash: cd: ${target}: No such file or directory\n`); - } - } - } - - let newDir = currentPath || "/"; - - // If -P is specified, resolve symlinks to get the physical path - if (physical) { - try { - newDir = await ctx.fs.realpath(newDir); - } catch { - // If realpath fails, use the logical path (matches bash behavior) - } - } - - ctx.state.previousDir = ctx.state.cwd; - ctx.state.cwd = newDir; - ctx.state.env.set("PWD", ctx.state.cwd); - ctx.state.env.set("OLDPWD", ctx.state.previousDir); - - // cd - prints the new directory - return success(printPath ? `${newDir}\n` : ""); -} diff --git a/src/interpreter/builtins/compgen.ts b/src/interpreter/builtins/compgen.ts deleted file mode 100644 index 31d31abe..00000000 --- a/src/interpreter/builtins/compgen.ts +++ /dev/null @@ -1,1034 +0,0 @@ -/** - * compgen - Generate completion matches - * - * Usage: - * compgen -v [prefix] - List variable names (optionally starting with prefix) - * compgen -A variable [prefix] - Same as -v - * compgen -A function [prefix] - List function names - * compgen -e [prefix] - List exported variable names - * compgen -A builtin [prefix] - List builtin command names - * compgen -A keyword [prefix] - List shell keywords (alias: -k) - * compgen -A alias [prefix] - List alias names - * compgen -A shopt [prefix] - List shopt options - * compgen -A helptopic [prefix] - List help topics - * compgen -A directory [prefix] - List directory names - * compgen -A file [prefix] - List file names - * compgen -f [prefix] - List file names (alias for -A file) - * compgen -A user - List user names - * compgen -A command [prefix] - List commands (builtins, functions, aliases, external) - * compgen -W wordlist [prefix] - Generate from wordlist - * compgen -P prefix - Prefix to add to completions - * compgen -S suffix - Suffix to add to completions - * compgen -o option - Completion option (plusdirs, dirnames, default, etc.) - */ - -import { type ParseException, Parser, parse } from "../../parser/parser.js"; -import type { ExecResult } from "../../types.js"; -import { matchPattern } from "../conditionals.js"; -import { expandWord, getArrayElements } from "../expansion.js"; -import { callFunction } from "../functions.js"; -import { failure, result, success } from "../helpers/result.js"; -import type { InterpreterContext } from "../types.js"; - -// List of shell keywords (matches bash) -const SHELL_KEYWORDS = [ - "!", - "[[", - "]]", - "case", - "do", - "done", - "elif", - "else", - "esac", - "fi", - "for", - "function", - "if", - "in", - "then", - "time", - "until", - "while", - "{", - "}", -]; - -// List of shell builtins -const SHELL_BUILTINS = [ - ".", - ":", - "[", - "alias", - "bg", - "bind", - "break", - "builtin", - "caller", - "cd", - "command", - "compgen", - "complete", - "compopt", - "continue", - "declare", - "dirs", - "disown", - "echo", - "enable", - "eval", - "exec", - "exit", - "export", - "false", - "fc", - "fg", - "getopts", - "hash", - "help", - "history", - "jobs", - "kill", - "let", - "local", - "logout", - "mapfile", - "popd", - "printf", - "pushd", - "pwd", - "read", - "readarray", - "readonly", - "return", - "set", - "shift", - "shopt", - "source", - "suspend", - "test", - "times", - "trap", - "true", - "type", - "typeset", - "ulimit", - "umask", - "unalias", - "unset", - "wait", -]; - -// List of shopt options -const SHOPT_OPTIONS = [ - "autocd", - "assoc_expand_once", - "cdable_vars", - "cdspell", - "checkhash", - "checkjobs", - "checkwinsize", - "cmdhist", - "compat31", - "compat32", - "compat40", - "compat41", - "compat42", - "compat43", - "compat44", - "complete_fullquote", - "direxpand", - "dirspell", - "dotglob", - "execfail", - "expand_aliases", - "extdebug", - "extglob", - "extquote", - "failglob", - "force_fignore", - "globasciiranges", - "globstar", - "gnu_errfmt", - "histappend", - "histreedit", - "histverify", - "hostcomplete", - "huponexit", - "inherit_errexit", - "interactive_comments", - "lastpipe", - "lithist", - "localvar_inherit", - "localvar_unset", - "login_shell", - "mailwarn", - "no_empty_cmd_completion", - "nocaseglob", - "nocasematch", - "nullglob", - "progcomp", - "progcomp_alias", - "promptvars", - "restricted_shell", - "shift_verbose", - "sourcepath", - "xpg_echo", -]; - -// List of help topics (builtin command names that have help) -const HELP_TOPICS = SHELL_BUILTINS; - -export async function handleCompgen( - ctx: InterpreterContext, - args: string[], -): Promise { - // Parse options - const actionTypes: string[] = []; // Support multiple -A flags - let wordlist: string | null = null; - let prefix = ""; - let suffix = ""; - let searchPrefix: string | null = null; - let plusdirsOption = false; - let dirnamesOption = false; - let defaultOption = false; - let excludePattern: string | null = null; - let functionName: string | null = null; - let commandString: string | null = null; - const processedArgs: string[] = []; - - const validActions = [ - "alias", - "arrayvar", - "binding", - "builtin", - "command", - "directory", - "disabled", - "enabled", - "export", - "file", - "function", - "group", - "helptopic", - "hostname", - "job", - "keyword", - "running", - "service", - "setopt", - "shopt", - "signal", - "stopped", - "user", - "variable", - ]; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - if (arg === "-v") { - actionTypes.push("variable"); - } else if (arg === "-e") { - actionTypes.push("export"); - } else if (arg === "-f") { - actionTypes.push("file"); - } else if (arg === "-d") { - actionTypes.push("directory"); - } else if (arg === "-k") { - actionTypes.push("keyword"); - } else if (arg === "-A") { - // Next arg is the action type - i++; - if (i >= args.length) { - return failure("compgen: -A: option requires an argument\n", 2); - } - const actionType = args[i]; - if (!validActions.includes(actionType)) { - return failure(`compgen: ${actionType}: invalid action name\n`, 2); - } - actionTypes.push(actionType); - } else if (arg === "-W") { - // Word list - i++; - if (i >= args.length) { - return failure("compgen: -W: option requires an argument\n", 2); - } - wordlist = args[i]; - } else if (arg === "-P") { - // Prefix to add - i++; - if (i >= args.length) { - return failure("compgen: -P: option requires an argument\n", 2); - } - prefix = args[i]; - } else if (arg === "-S") { - // Suffix to add - i++; - if (i >= args.length) { - return failure("compgen: -S: option requires an argument\n", 2); - } - suffix = args[i]; - } else if (arg === "-o") { - // Completion option - i++; - if (i >= args.length) { - return failure("compgen: -o: option requires an argument\n", 2); - } - const opt = args[i]; - if (opt === "plusdirs") { - plusdirsOption = true; - } else if (opt === "dirnames") { - dirnamesOption = true; - } else if (opt === "default") { - defaultOption = true; - } else if ( - opt === "filenames" || - opt === "nospace" || - opt === "bashdefault" || - opt === "noquote" - ) { - // These are postprocessing options that affect display, not generation - // They have no effect with compgen (only with complete) - } else { - return failure(`compgen: ${opt}: invalid option name\n`, 2); - } - } else if (arg === "-F") { - // Function to call for generating completions - i++; - if (i >= args.length) { - return failure("compgen: -F: option requires an argument\n", 2); - } - functionName = args[i]; - } else if (arg === "-C") { - // Command to run for completion - i++; - if (i >= args.length) { - return failure("compgen: -C: option requires an argument\n", 2); - } - commandString = args[i]; - } else if (arg === "-X") { - // Pattern to exclude - i++; - if (i >= args.length) { - return failure("compgen: -X: option requires an argument\n", 2); - } - excludePattern = args[i]; - } else if (arg === "-G") { - // Glob pattern - i++; - if (i >= args.length) { - return failure("compgen: -G: option requires an argument\n", 2); - } - // Skip glob for now - -G is not implemented - } else if (arg === "--") { - // End of options - processedArgs.push(...args.slice(i + 1)); - break; - } else if (!arg.startsWith("-")) { - processedArgs.push(arg); - } - } - - // The search prefix is the first non-option argument - searchPrefix = processedArgs[0] ?? null; - - // Collect completions - const completions: string[] = []; - - // Handle -o dirnames (only show directories) - if (dirnamesOption) { - const dirCompletions = await getDirectoryCompletions(ctx, searchPrefix); - completions.push(...dirCompletions); - } - - // Handle -o default (show files) - if (defaultOption) { - const fileCompletions = await getFileCompletions(ctx, searchPrefix); - completions.push(...fileCompletions); - } - - // Handle action types - loop through all of them to support multiple -A flags - // NOTE: Action types are processed BEFORE wordlist to match bash behavior - // where -A directory results come before -W wordlist results - for (const actionType of actionTypes) { - if (actionType === "variable") { - const vars = getVariableNames(ctx, searchPrefix); - completions.push(...vars); - } else if (actionType === "export") { - const vars = getExportedVariableNames(ctx, searchPrefix); - completions.push(...vars); - } else if (actionType === "function") { - const funcs = getFunctionNames(ctx, searchPrefix); - completions.push(...funcs); - } else if (actionType === "builtin") { - const builtins = getBuiltinNames(searchPrefix); - completions.push(...builtins); - } else if (actionType === "keyword") { - const keywords = getKeywordNames(searchPrefix); - completions.push(...keywords); - } else if (actionType === "alias") { - const aliases = getAliasNames(ctx, searchPrefix); - completions.push(...aliases); - } else if (actionType === "shopt") { - const shopts = getShoptNames(searchPrefix); - completions.push(...shopts); - } else if (actionType === "helptopic") { - const topics = getHelpTopicNames(searchPrefix); - completions.push(...topics); - } else if (actionType === "directory") { - const dirs = await getDirectoryCompletions(ctx, searchPrefix); - completions.push(...dirs); - } else if (actionType === "file") { - const files = await getFileCompletions(ctx, searchPrefix); - completions.push(...files); - } else if (actionType === "user") { - const users = getUserNames(searchPrefix); - completions.push(...users); - } else if (actionType === "command") { - const commands = await getCommandCompletions(ctx, searchPrefix); - completions.push(...commands); - } - } - - // Handle wordlist AFTER action types - // This ensures -A directory results come before -W wordlist results - if (wordlist !== null) { - try { - // First, expand the wordlist (handles $(), ${}, etc.) - const expandedWordlist = await expandWordlistString(ctx, wordlist); - const words = splitWordlist(ctx, expandedWordlist); - for (const word of words) { - if (searchPrefix === null || word.startsWith(searchPrefix)) { - completions.push(word); - } - } - } catch { - // Expansion errors (e.g., arithmetic division by zero) return status 1 - return result("", "", 1); - } - } - - // Handle -o plusdirs: add directories to completions - if (plusdirsOption) { - const dirCompletions = await getDirectoryCompletions(ctx, searchPrefix); - for (const dir of dirCompletions) { - if (!completions.includes(dir)) { - completions.push(dir); - } - } - } - - // Handle -F function: call function to generate completions - // Track stdout from function (prepended to completions output) - let functionStdout = ""; - if (functionName !== null) { - const func = ctx.state.functions.get(functionName); - if (func) { - // Set up COMP_* variables that bash provides to completion functions - // When called via compgen (not during actual completion), bash sets: - // COMP_WORDS: empty array - // COMP_CWORD: -1 - // COMP_LINE: empty string - // COMP_POINT: 0 - const savedEnv = new Map(); - - // Save and set COMP_WORDS (empty array - no elements) - savedEnv.set( - "COMP_WORDS__length", - ctx.state.env.get("COMP_WORDS__length"), - ); - ctx.state.env.set("COMP_WORDS__length", "0"); - - // Save and set COMP_CWORD - savedEnv.set("COMP_CWORD", ctx.state.env.get("COMP_CWORD")); - ctx.state.env.set("COMP_CWORD", "-1"); - - // Save and set COMP_LINE - savedEnv.set("COMP_LINE", ctx.state.env.get("COMP_LINE")); - ctx.state.env.set("COMP_LINE", ""); - - // Save and set COMP_POINT - savedEnv.set("COMP_POINT", ctx.state.env.get("COMP_POINT")); - ctx.state.env.set("COMP_POINT", "0"); - - // Clear any existing COMPREPLY - const savedCompreply = new Map(); - for (const key of ctx.state.env.keys()) { - if ( - key === "COMPREPLY" || - key.startsWith("COMPREPLY_") || - key === "COMPREPLY__length" - ) { - savedCompreply.set(key, ctx.state.env.get(key)); - ctx.state.env.delete(key); - } - } - - // Determine the arguments to pass to the function - // bash passes: command_name, word_being_completed, previous_word - // For compgen -F func cmd [word], it's: "compgen", cmd, "" - const funcArgs = ["compgen", processedArgs[0] ?? "", ""]; - - try { - // Call the function - errors during execution return exit code 1 - const funcResult = await callFunction(ctx, func, funcArgs, ""); - - // Check if there was an error (e.g., division by zero) - if (funcResult.exitCode !== 0) { - // Restore saved environment - restoreEnv(ctx, savedEnv); - restoreEnv(ctx, savedCompreply); - return result("", funcResult.stderr, 1); - } - - // Capture function stdout (e.g., debug output from the function) - functionStdout = funcResult.stdout; - - // Get COMPREPLY values (supports both scalar and array) - const compreplyValues = getCompreplyValues(ctx); - completions.push(...compreplyValues); - } catch { - // If function execution fails, return exit code 1 - restoreEnv(ctx, savedEnv); - restoreEnv(ctx, savedCompreply); - return result("", "", 1); - } - - // Restore saved environment - restoreEnv(ctx, savedEnv); - restoreEnv(ctx, savedCompreply); - } - } - - // Handle -C command: execute command and use output lines as completions - // Note: Unlike -W and -A, -C does not filter by searchPrefix. - // The command is responsible for generating appropriate completions. - if (commandString !== null) { - try { - // Parse and execute the command - const ast = parse(commandString); - const cmdResult = await ctx.executeScript(ast); - - // Check for errors - if (cmdResult.exitCode !== 0) { - return result("", cmdResult.stderr, cmdResult.exitCode); - } - - // Split stdout into lines and add as completions - // All non-empty lines are used as completions (no prefix filtering) - if (cmdResult.stdout) { - const lines = cmdResult.stdout.split("\n"); - for (const line of lines) { - // Skip empty lines - if (line.length > 0) { - completions.push(line); - } - } - } - } catch (error) { - // Handle parse errors - if ((error as ParseException).name === "ParseException") { - return failure(`compgen: -C: ${(error as Error).message}\n`, 2); - } - throw error; - } - } - - // Apply -X filter: remove completions matching the exclude pattern - // Uses extglob for pattern matching (compgen always uses extglob) - // Special: if pattern starts with '!', the filter is negated (keep items matching the rest) - let filteredCompletions = completions; - if (excludePattern !== null) { - // Check for negation prefix - const isNegated = excludePattern.startsWith("!"); - const pattern = isNegated ? excludePattern.slice(1) : excludePattern; - - filteredCompletions = completions.filter((c) => { - // Match using extglob patterns - const matches = matchPattern(c, pattern, false, true); - // Normal: filter OUT matching completions (!matches) - // Negated: filter OUT non-matching completions (matches) - // i.e., keep items that match when negated - return isNegated ? matches : !matches; - }); - } - - // If no completions found and we had a search prefix, return exit code 1 - if (filteredCompletions.length === 0 && searchPrefix !== null) { - // Still output any function stdout even if no completions - return result(functionStdout, "", 1); - } - - // Apply prefix/suffix and output - const completionOutput = filteredCompletions - .map((c) => `${prefix}${c}${suffix}`) - .join("\n"); - // Prepend function stdout to completions output - const output = - functionStdout + (completionOutput ? `${completionOutput}\n` : ""); - return success(output); -} - -/** - * Get all variable names, optionally filtered by prefix - */ -function getVariableNames( - ctx: InterpreterContext, - prefix: string | null, -): string[] { - const names: Set = new Set(); - - // Add all environment variables - for (const key of ctx.state.env.keys()) { - // Skip internal array markers - if (key.includes("_") && /^[a-zA-Z_][a-zA-Z0-9_]*_\d+$/.test(key)) { - continue; - } - if (key.endsWith("__length")) { - continue; - } - // Extract base name for array variables - const baseName = key.split("_")[0]; - if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { - names.add(key); - } else if ( - baseName && - /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(baseName) && - ctx.state.env.has(`${baseName}__length`) - ) { - names.add(baseName); - } - } - - // Filter by prefix if provided - let resultArr = Array.from(names); - if (prefix !== null) { - resultArr = resultArr.filter((n) => n.startsWith(prefix)); - } - - // Sort alphabetically - return resultArr.sort(); -} - -/** - * Get exported variable names, optionally filtered by prefix - */ -function getExportedVariableNames( - ctx: InterpreterContext, - prefix: string | null, -): string[] { - const exportedVars = ctx.state.exportedVars ?? new Set(); - let resultArr = Array.from(exportedVars); - - // Filter by prefix if provided - if (prefix !== null) { - resultArr = resultArr.filter((n) => n.startsWith(prefix)); - } - - // Filter out variables that don't exist or are internal - resultArr = resultArr.filter((n) => { - if (n.includes("_") && /^[a-zA-Z_][a-zA-Z0-9_]*_\d+$/.test(n)) { - return false; - } - if (n.endsWith("__length")) { - return false; - } - return ctx.state.env.has(n); - }); - - // Sort alphabetically - return resultArr.sort(); -} - -/** - * Get function names, optionally filtered by prefix - */ -function getFunctionNames( - ctx: InterpreterContext, - prefix: string | null, -): string[] { - let resultArr = Array.from(ctx.state.functions.keys()); - - // Filter by prefix if provided - if (prefix !== null) { - resultArr = resultArr.filter((n) => n.startsWith(prefix)); - } - - // Sort alphabetically - return resultArr.sort(); -} - -/** - * Get builtin command names, optionally filtered by prefix - */ -function getBuiltinNames(prefix: string | null): string[] { - let resultArr = [...SHELL_BUILTINS]; - - // Filter by prefix if provided - if (prefix !== null) { - resultArr = resultArr.filter((n) => n.startsWith(prefix)); - } - - // Sort alphabetically - return resultArr.sort(); -} - -/** - * Get shell keyword names, optionally filtered by prefix - */ -function getKeywordNames(prefix: string | null): string[] { - let resultArr = [...SHELL_KEYWORDS]; - - // Filter by prefix if provided - if (prefix !== null) { - resultArr = resultArr.filter((n) => n.startsWith(prefix)); - } - - // Sort alphabetically - return resultArr.sort(); -} - -/** - * Get alias names, optionally filtered by prefix - */ -function getAliasNames( - ctx: InterpreterContext, - prefix: string | null, -): string[] { - const names: string[] = []; - - // Look for BASH_ALIAS_ prefixed variables - for (const key of ctx.state.env.keys()) { - if (key.startsWith("BASH_ALIAS_")) { - const aliasName = key.slice("BASH_ALIAS_".length); - names.push(aliasName); - } - } - - // Filter by prefix if provided - let resultArr = names; - if (prefix !== null) { - resultArr = resultArr.filter((n) => n.startsWith(prefix)); - } - - // Sort alphabetically - return resultArr.sort(); -} - -/** - * Get shopt option names, optionally filtered by prefix - */ -function getShoptNames(prefix: string | null): string[] { - let resultArr = [...SHOPT_OPTIONS]; - - // Filter by prefix if provided - if (prefix !== null) { - resultArr = resultArr.filter((n) => n.startsWith(prefix)); - } - - // Sort alphabetically - return resultArr.sort(); -} - -/** - * Get help topic names, optionally filtered by prefix - */ -function getHelpTopicNames(prefix: string | null): string[] { - let resultArr = [...HELP_TOPICS]; - - // Filter by prefix if provided - if (prefix !== null) { - resultArr = resultArr.filter((n) => n.startsWith(prefix)); - } - - // Sort alphabetically - return resultArr.sort(); -} - -/** - * Get directory completions - */ -async function getDirectoryCompletions( - ctx: InterpreterContext, - prefix: string | null, -): Promise { - const results: string[] = []; - - try { - // Determine the directory to search and the prefix to match - let searchDir = ctx.state.cwd; - let matchPrefix = prefix ?? ""; - - if (prefix) { - // Check if prefix contains a directory path - const lastSlash = prefix.lastIndexOf("/"); - if (lastSlash !== -1) { - const dirPart = prefix.slice(0, lastSlash) || "/"; - matchPrefix = prefix.slice(lastSlash + 1); - - // Resolve the directory path - if (dirPart.startsWith("/")) { - searchDir = dirPart; - } else { - searchDir = `${ctx.state.cwd}/${dirPart}`; - } - } - } - - // Read directory entries - const entries = await ctx.fs.readdir(searchDir); - - for (const entry of entries) { - // Check if it's a directory - const fullPath = `${searchDir}/${entry}`; - try { - const stat = await ctx.fs.stat(fullPath); - if (stat.isDirectory) { - if (!matchPrefix || entry.startsWith(matchPrefix)) { - // Include path prefix if the original prefix had one - if (prefix?.includes("/")) { - const lastSlash = prefix.lastIndexOf("/"); - const dirPart = prefix.slice(0, lastSlash + 1); - results.push(dirPart + entry); - } else { - results.push(entry); - } - } - } - } catch { - // Ignore stat errors - } - } - } catch { - // Ignore directory read errors - } - - return results.sort(); -} - -/** - * Get file completions (files and directories) - */ -async function getFileCompletions( - ctx: InterpreterContext, - prefix: string | null, -): Promise { - const results: string[] = []; - - try { - // Determine the directory to search and the prefix to match - let searchDir = ctx.state.cwd; - let matchPrefix = prefix ?? ""; - - if (prefix) { - // Check if prefix contains a directory path - const lastSlash = prefix.lastIndexOf("/"); - if (lastSlash !== -1) { - const dirPart = prefix.slice(0, lastSlash) || "/"; - matchPrefix = prefix.slice(lastSlash + 1); - - // Resolve the directory path - if (dirPart.startsWith("/")) { - searchDir = dirPart; - } else { - searchDir = `${ctx.state.cwd}/${dirPart}`; - } - } - } - - // Read directory entries - const entries = await ctx.fs.readdir(searchDir); - - for (const entry of entries) { - if (!matchPrefix || entry.startsWith(matchPrefix)) { - // Include path prefix if the original prefix had one - if (prefix?.includes("/")) { - const lastSlash = prefix.lastIndexOf("/"); - const dirPart = prefix.slice(0, lastSlash + 1); - results.push(dirPart + entry); - } else { - results.push(entry); - } - } - } - } catch { - // Ignore directory read errors - } - - return results.sort(); -} - -/** - * Get user names (stub - returns common system users) - */ -function getUserNames(_prefix: string | null): string[] { - // In a real implementation, this would read /etc/passwd - // For now, return some common user names - return ["root", "nobody"]; -} - -/** - * Get command completions (builtins, functions, aliases, external commands) - */ -async function getCommandCompletions( - ctx: InterpreterContext, - prefix: string | null, -): Promise { - const commands: Set = new Set(); - - // Add builtins - for (const builtin of SHELL_BUILTINS) { - commands.add(builtin); - } - - // Add functions - for (const func of ctx.state.functions.keys()) { - commands.add(func); - } - - // Add aliases - for (const key of ctx.state.env.keys()) { - if (key.startsWith("BASH_ALIAS_")) { - commands.add(key.slice("BASH_ALIAS_".length)); - } - } - - // Add keywords - for (const keyword of SHELL_KEYWORDS) { - commands.add(keyword); - } - - // Add external commands from PATH - const path = ctx.state.env.get("PATH") ?? "/usr/bin:/bin"; - for (const dir of path.split(":")) { - if (!dir) continue; - try { - const entries = await ctx.fs.readdir(dir); - for (const entry of entries) { - commands.add(entry); - } - } catch { - // Ignore errors - } - } - - // Filter by prefix - let resultArr = Array.from(commands); - if (prefix !== null) { - resultArr = resultArr.filter((c) => c.startsWith(prefix)); - } - - return resultArr.sort(); -} - -/** - * Expand a wordlist string, handling command substitution ($()), - * variable expansion (${}, $VAR), arithmetic expansion ($(())), etc. - * Throws on expansion errors (e.g., division by zero). - */ -async function expandWordlistString( - ctx: InterpreterContext, - wordlist: string, -): Promise { - const parser = new Parser(); - // Parse the wordlist as a word (not in quotes, so expansions apply) - const wordNode = parser.parseWordFromString(wordlist, false, false); - // Expand the word - this handles $(), ${}, etc. - // Errors (like arithmetic errors) will propagate up - return await expandWord(ctx, wordNode); -} - -/** - * Split a wordlist string into individual words, respecting IFS - * Backslash-escaped IFS characters are treated as literal characters, not delimiters - */ -function splitWordlist(ctx: InterpreterContext, wordlist: string): string[] { - const ifs = ctx.state.env.get("IFS") ?? " \t\n"; - - if (ifs.length === 0) { - return [wordlist]; - } - - // Build a set of IFS characters for fast lookup - const ifsSet = new Set(ifs.split("")); - - // Parse the wordlist character by character, respecting backslash escapes - const words: string[] = []; - let currentWord = ""; - let i = 0; - - while (i < wordlist.length) { - const char = wordlist[i]; - - if (char === "\\" && i + 1 < wordlist.length) { - // Backslash escape: the next character is literal (not a delimiter) - const nextChar = wordlist[i + 1]; - currentWord += nextChar; - i += 2; - } else if (ifsSet.has(char)) { - // This is an IFS delimiter - if (currentWord.length > 0) { - words.push(currentWord); - currentWord = ""; - } - i++; - } else { - // Regular character - currentWord += char; - i++; - } - } - - // Don't forget the last word - if (currentWord.length > 0) { - words.push(currentWord); - } - - return words; -} - -/** - * Restore environment variables from saved values - */ -function restoreEnv( - ctx: InterpreterContext, - saved: Map, -): void { - for (const [key, value] of saved) { - if (value === undefined) { - ctx.state.env.delete(key); - } else { - ctx.state.env.set(key, value); - } - } -} - -/** - * Get COMPREPLY values (supports both scalar and array) - * Returns values in order, skipping sparse array gaps - */ -function getCompreplyValues(ctx: InterpreterContext): string[] { - const values: string[] = []; - - // Check if COMPREPLY is an array - const lengthKey = "COMPREPLY__length"; - const arrayLength = ctx.state.env.get(lengthKey); - - if (arrayLength !== undefined) { - // It's an array - get elements using getArrayElements helper - // getArrayElements returns Array<[index, value]> - const elements = getArrayElements(ctx, "COMPREPLY"); - for (const [, value] of elements) { - values.push(value); - } - } else { - // Check if it's a scalar value - const scalarValue = ctx.state.env.get("COMPREPLY"); - if (scalarValue !== undefined) { - values.push(scalarValue); - } - } - - return values; -} diff --git a/src/interpreter/builtins/complete.test.ts b/src/interpreter/builtins/complete.test.ts deleted file mode 100644 index c7a28efc..00000000 --- a/src/interpreter/builtins/complete.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { InterpreterContext, InterpreterState } from "../types.js"; -import { handleComplete } from "./complete.js"; - -// Minimal mock for testing -function createMockCtx(): InterpreterContext { - const state: InterpreterState = { - env: new Map(), - cwd: "/", - previousDir: "/", - functions: new Map(), - localScopes: [], - callDepth: 0, - sourceDepth: 0, - commandCount: 0, - lastExitCode: 0, - lastArg: "", - startTime: Date.now(), - lastBackgroundPid: 0, - bashPid: 1, - nextVirtualPid: 2, - currentLine: 0, - options: { - errexit: false, - pipefail: false, - nounset: false, - xtrace: false, - verbose: false, - posix: false, - allexport: false, - noclobber: false, - noglob: false, - noexec: false, - vi: false, - emacs: false, - }, - shoptOptions: { - extglob: false, - dotglob: false, - nullglob: false, - failglob: false, - globstar: false, - globskipdots: true, - nocaseglob: false, - nocasematch: false, - expand_aliases: false, - lastpipe: false, - xpg_echo: false, - }, - inCondition: false, - loopDepth: 0, - }; - - return { - state, - fs: {} as unknown as InterpreterContext["fs"], - commands: {} as unknown as InterpreterContext["commands"], - limits: {} as unknown as InterpreterContext["limits"], - execFn: async () => ({ stdout: "", stderr: "", exitCode: 0 }), - executeScript: async () => ({ stdout: "", stderr: "", exitCode: 0 }), - executeStatement: async () => ({ stdout: "", stderr: "", exitCode: 0 }), - executeCommand: async () => ({ stdout: "", stderr: "", exitCode: 0 }), - }; -} - -describe("complete builtin", () => { - test("complete with no args and no specs", () => { - const ctx = createMockCtx(); - const result = handleComplete(ctx, []); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - }); - - test("complete -W sets wordlist completion", () => { - const ctx = createMockCtx(); - const result = handleComplete(ctx, ["-W", "foo bar", "mycommand"]); - expect(result.exitCode).toBe(0); - expect(ctx.state.completionSpecs?.get("mycommand")).toEqual({ - wordlist: "foo bar", - }); - }); - - test("complete -p prints completions", () => { - const ctx = createMockCtx(); - handleComplete(ctx, ["-W", "foo bar", "mycommand"]); - const result = handleComplete(ctx, ["-p"]); - expect(result.stdout).toBe("complete -W 'foo bar' mycommand\n"); - expect(result.exitCode).toBe(0); - }); - - test("complete with no args prints completions", () => { - const ctx = createMockCtx(); - handleComplete(ctx, ["-W", "foo bar", "mycommand"]); - const result = handleComplete(ctx, []); - expect(result.stdout).toBe("complete -W 'foo bar' mycommand\n"); - expect(result.exitCode).toBe(0); - }); - - test("complete -F sets function completion", () => { - const ctx = createMockCtx(); - const result = handleComplete(ctx, ["-F", "myfunc", "other"]); - expect(result.exitCode).toBe(0); - expect(ctx.state.completionSpecs?.get("other")).toEqual({ - function: "myfunc", - }); - }); - - test("complete prints both specs", () => { - const ctx = createMockCtx(); - handleComplete(ctx, ["-W", "foo bar", "mycommand"]); - handleComplete(ctx, ["-F", "myfunc", "other"]); - const result = handleComplete(ctx, []); - expect(result.stdout).toContain("complete -W 'foo bar' mycommand\n"); - expect(result.stdout).toContain("complete -F myfunc other\n"); - expect(result.exitCode).toBe(0); - }); - - test("complete -F without command is error", () => { - const ctx = createMockCtx(); - const result = handleComplete(ctx, ["-F", "f"]); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("-F"); - }); - - test("complete -o default -o nospace -F works", () => { - const ctx = createMockCtx(); - const result = handleComplete(ctx, [ - "-o", - "default", - "-o", - "nospace", - "-F", - "foo", - "git", - ]); - expect(result.exitCode).toBe(0); - expect(ctx.state.completionSpecs?.get("git")).toEqual({ - function: "foo", - options: ["default", "nospace"], - }); - }); - - test("complete -r removes spec", () => { - const ctx = createMockCtx(); - handleComplete(ctx, ["-W", "foo bar", "mycommand"]); - handleComplete(ctx, ["-F", "myfunc", "other"]); - handleComplete(ctx, ["-r", "mycommand"]); - const result = handleComplete(ctx, []); - expect(result.stdout).toBe("complete -F myfunc other\n"); - expect(ctx.state.completionSpecs?.has("mycommand")).toBe(false); - }); - - test("complete -D sets default completion", () => { - const ctx = createMockCtx(); - const result = handleComplete(ctx, ["-F", "invalidZZ", "-D"]); - expect(result.exitCode).toBe(0); - expect(ctx.state.completionSpecs?.get("__default__")).toEqual({ - function: "invalidZZ", - isDefault: true, - }); - }); - - test("complete foo with no options is allowed (bash BUG behavior)", () => { - const ctx = createMockCtx(); - const result = handleComplete(ctx, ["foo"]); - expect(result.exitCode).toBe(0); - }); -}); diff --git a/src/interpreter/builtins/complete.ts b/src/interpreter/builtins/complete.ts deleted file mode 100644 index 59c92ac4..00000000 --- a/src/interpreter/builtins/complete.ts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * complete - Set and display programmable completion specifications - * - * Usage: - * complete - List all completion specs - * complete -p - Print all completion specs in reusable format - * complete -p cmd - Print completion spec for specific command - * complete -W 'word1 word2' cmd - Set word list completion for cmd - * complete -F func cmd - Set function completion for cmd - * complete -r cmd - Remove completion spec for cmd - * complete -r - Remove all completion specs - * complete -D ... - Set default completion (for commands with no specific spec) - * complete -o opt cmd - Set completion options (nospace, filenames, default, etc.) - */ - -import type { ExecResult } from "../../types.js"; -import { failure, result, success } from "../helpers/result.js"; -import type { CompletionSpec, InterpreterContext } from "../types.js"; - -// Valid completion options for -o flag -const VALID_OPTIONS = [ - "bashdefault", - "default", - "dirnames", - "filenames", - "noquote", - "nosort", - "nospace", - "plusdirs", -]; - -export function handleComplete( - ctx: InterpreterContext, - args: string[], -): ExecResult { - // Initialize completionSpecs if not present - if (!ctx.state.completionSpecs) { - ctx.state.completionSpecs = new Map(); - } - - // Parse options - let printMode = false; - let removeMode = false; - let isDefault = false; - let wordlist: string | undefined; - let funcName: string | undefined; - let commandStr: string | undefined; - const options: string[] = []; - const actions: string[] = []; - const commands: string[] = []; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - if (arg === "-p") { - printMode = true; - } else if (arg === "-r") { - removeMode = true; - } else if (arg === "-D") { - isDefault = true; - } else if (arg === "-W") { - // Word list - i++; - if (i >= args.length) { - return failure("complete: -W: option requires an argument\n", 2); - } - wordlist = args[i]; - } else if (arg === "-F") { - // Function name - i++; - if (i >= args.length) { - return failure("complete: -F: option requires an argument\n", 2); - } - funcName = args[i]; - // Check if function exists - but bash doesn't actually validate this - // According to test "complete with nonexistent function", bash returns 0 (BUG) - // We'll match bash's buggy behavior for compatibility - } else if (arg === "-o") { - // Completion option - i++; - if (i >= args.length) { - return failure("complete: -o: option requires an argument\n", 2); - } - const opt = args[i]; - if (!VALID_OPTIONS.includes(opt)) { - return failure(`complete: ${opt}: invalid option name\n`, 2); - } - options.push(opt); - } else if (arg === "-A") { - // Action - i++; - if (i >= args.length) { - return failure("complete: -A: option requires an argument\n", 2); - } - actions.push(args[i]); - } else if (arg === "-C") { - // Command to run for completion - i++; - if (i >= args.length) { - return failure("complete: -C: option requires an argument\n", 2); - } - commandStr = args[i]; - } else if (arg === "-G") { - // Glob pattern - i++; - if (i >= args.length) { - return failure("complete: -G: option requires an argument\n", 2); - } - // Skip for now - -G is not fully implemented - } else if (arg === "-P") { - // Prefix - i++; - if (i >= args.length) { - return failure("complete: -P: option requires an argument\n", 2); - } - // Skip for now - } else if (arg === "-S") { - // Suffix - i++; - if (i >= args.length) { - return failure("complete: -S: option requires an argument\n", 2); - } - // Skip for now - } else if (arg === "-X") { - // Filter pattern - i++; - if (i >= args.length) { - return failure("complete: -X: option requires an argument\n", 2); - } - // Skip for now - } else if (arg === "--") { - // End of options - commands.push(...args.slice(i + 1)); - break; - } else if (!arg.startsWith("-")) { - commands.push(arg); - } - } - - // Handle remove mode (-r) - if (removeMode) { - if (commands.length === 0) { - // Remove all completion specs - ctx.state.completionSpecs.clear(); - return success(""); - } - // Remove specific completion specs - for (const cmd of commands) { - ctx.state.completionSpecs.delete(cmd); - } - return success(""); - } - - // Handle print mode (-p) - if (printMode) { - if (commands.length === 0) { - // Print all completion specs - return printCompletionSpecs(ctx); - } - // Print specific completion specs - return printCompletionSpecs(ctx, commands); - } - - // If no options provided and no commands, just print all specs - if ( - args.length === 0 || - (commands.length === 0 && - !wordlist && - !funcName && - !commandStr && - options.length === 0 && - actions.length === 0 && - !isDefault) - ) { - return printCompletionSpecs(ctx); - } - - // Check for usage errors - // -F requires a command name (unless -D is specified) - if (funcName && commands.length === 0 && !isDefault) { - return failure("complete: -F: option requires a command name\n", 2); - } - - // If we have a command but no action/wordlist/function, bash allows it (BUG behavior) - // See test "complete with no action" - bash returns 0 even though it's useless - - // Set completion specs for commands - if (isDefault) { - // Set default completion - const spec: CompletionSpec = { - isDefault: true, - }; - if (wordlist !== undefined) spec.wordlist = wordlist; - if (funcName !== undefined) spec.function = funcName; - if (commandStr !== undefined) spec.command = commandStr; - if (options.length > 0) spec.options = options; - if (actions.length > 0) spec.actions = actions; - ctx.state.completionSpecs.set("__default__", spec); - return success(""); - } - - for (const cmd of commands) { - const spec: CompletionSpec = Object.create(null); - if (wordlist !== undefined) spec.wordlist = wordlist; - if (funcName !== undefined) spec.function = funcName; - if (commandStr !== undefined) spec.command = commandStr; - if (options.length > 0) spec.options = options; - if (actions.length > 0) spec.actions = actions; - ctx.state.completionSpecs.set(cmd, spec); - } - - return success(""); -} - -/** - * Print completion specs in reusable format - */ -function printCompletionSpecs( - ctx: InterpreterContext, - commands?: string[], -): ExecResult { - const specs = ctx.state.completionSpecs; - if (!specs || specs.size === 0) { - if (commands && commands.length > 0) { - // Requested specific commands but no specs exist - let stderr = ""; - for (const cmd of commands) { - stderr += `complete: ${cmd}: no completion specification\n`; - } - return result("", stderr, 1); - } - return success(""); - } - - const output: string[] = []; - const targetCommands = commands || Array.from(specs.keys()); - - for (const cmd of targetCommands) { - if (cmd === "__default__") continue; // Skip internal default key when listing all - - const spec = specs.get(cmd); - if (!spec) { - if (commands) { - // Specifically requested this command but it doesn't exist - return result( - output.join("\n") + (output.length > 0 ? "\n" : ""), - `complete: ${cmd}: no completion specification\n`, - 1, - ); - } - continue; - } - - let line = "complete"; - - // Add options - if (spec.options) { - for (const opt of spec.options) { - line += ` -o ${opt}`; - } - } - - // Add actions - if (spec.actions) { - for (const action of spec.actions) { - line += ` -A ${action}`; - } - } - - // Add wordlist - if (spec.wordlist !== undefined) { - // Quote the wordlist if it contains spaces - if (spec.wordlist.includes(" ") || spec.wordlist.includes("'")) { - line += ` -W '${spec.wordlist}'`; - } else { - line += ` -W ${spec.wordlist}`; - } - } - - // Add function - if (spec.function !== undefined) { - line += ` -F ${spec.function}`; - } - - // Add default flag - if (spec.isDefault) { - line += " -D"; - } - - // Add command name - line += ` ${cmd}`; - - output.push(line); - } - - if (output.length === 0) { - return success(""); - } - - return success(`${output.join("\n")}\n`); -} diff --git a/src/interpreter/builtins/compopt.test.ts b/src/interpreter/builtins/compopt.test.ts deleted file mode 100644 index 6820c85c..00000000 --- a/src/interpreter/builtins/compopt.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { InterpreterContext, InterpreterState } from "../types.js"; -import { handleComplete } from "./complete.js"; -import { handleCompopt } from "./compopt.js"; - -// Minimal mock for testing -function createMockCtx(): InterpreterContext { - const state: InterpreterState = { - env: new Map(), - cwd: "/", - previousDir: "/", - functions: new Map(), - localScopes: [], - callDepth: 0, - sourceDepth: 0, - commandCount: 0, - lastExitCode: 0, - lastArg: "", - startTime: Date.now(), - lastBackgroundPid: 0, - bashPid: 1, - nextVirtualPid: 2, - currentLine: 0, - options: { - errexit: false, - pipefail: false, - nounset: false, - xtrace: false, - verbose: false, - posix: false, - allexport: false, - noclobber: false, - noglob: false, - noexec: false, - vi: false, - emacs: false, - }, - shoptOptions: { - extglob: false, - dotglob: false, - nullglob: false, - failglob: false, - globstar: false, - globskipdots: true, - nocaseglob: false, - nocasematch: false, - expand_aliases: false, - lastpipe: false, - xpg_echo: false, - }, - inCondition: false, - loopDepth: 0, - }; - - return { - state, - fs: {} as unknown as InterpreterContext["fs"], - commands: {} as unknown as InterpreterContext["commands"], - limits: {} as unknown as InterpreterContext["limits"], - execFn: async () => ({ stdout: "", stderr: "", exitCode: 0 }), - executeScript: async () => ({ stdout: "", stderr: "", exitCode: 0 }), - executeStatement: async () => ({ stdout: "", stderr: "", exitCode: 0 }), - executeCommand: async () => ({ stdout: "", stderr: "", exitCode: 0 }), - }; -} - -describe("compopt builtin", () => { - test("compopt with invalid option returns exit code 2", () => { - const ctx = createMockCtx(); - const result = handleCompopt(ctx, ["-o", "invalid"]); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("invalid"); - expect(result.stderr).toContain("invalid option name"); - }); - - test("compopt without command name outside completion function returns exit code 1", () => { - const ctx = createMockCtx(); - const result = handleCompopt(ctx, ["-o", "filenames", "+o", "nospace"]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( - "not currently executing completion function", - ); - }); - - test("compopt -D modifies default completion options", () => { - const ctx = createMockCtx(); - // First set up a default completion - handleComplete(ctx, ["-F", "myfunc", "-D"]); - - // Now modify its options - const result = handleCompopt(ctx, [ - "-D", - "-o", - "nospace", - "-o", - "filenames", - ]); - expect(result.exitCode).toBe(0); - - const spec = ctx.state.completionSpecs?.get("__default__"); - expect(spec).toBeDefined(); - expect(spec?.options).toContain("nospace"); - expect(spec?.options).toContain("filenames"); - }); - - test("compopt -E modifies empty-line completion options", () => { - const ctx = createMockCtx(); - const result = handleCompopt(ctx, ["-E", "-o", "default"]); - expect(result.exitCode).toBe(0); - - const spec = ctx.state.completionSpecs?.get("__empty__"); - expect(spec).toBeDefined(); - expect(spec?.options).toContain("default"); - }); - - test("compopt with command name modifies that command's options", () => { - const ctx = createMockCtx(); - // First set up a completion for git - handleComplete(ctx, ["-F", "gitfunc", "git"]); - - // Now modify its options - const result = handleCompopt(ctx, ["-o", "nospace", "git"]); - expect(result.exitCode).toBe(0); - - const spec = ctx.state.completionSpecs?.get("git"); - expect(spec).toBeDefined(); - expect(spec?.options).toContain("nospace"); - expect(spec?.function).toBe("gitfunc"); // Original function preserved - }); - - test("compopt +o disables options", () => { - const ctx = createMockCtx(); - // Set up a completion with options - handleComplete(ctx, [ - "-o", - "nospace", - "-o", - "filenames", - "-F", - "myfunc", - "cmd", - ]); - - // Disable nospace - const result = handleCompopt(ctx, ["+o", "nospace", "cmd"]); - expect(result.exitCode).toBe(0); - - const spec = ctx.state.completionSpecs?.get("cmd"); - expect(spec).toBeDefined(); - expect(spec?.options).not.toContain("nospace"); - expect(spec?.options).toContain("filenames"); - }); - - test("compopt can enable and disable options at once", () => { - const ctx = createMockCtx(); - // Set up a completion with some options - handleComplete(ctx, ["-o", "nospace", "-F", "myfunc", "cmd"]); - - // Enable filenames, disable nospace - const result = handleCompopt(ctx, [ - "-o", - "filenames", - "+o", - "nospace", - "cmd", - ]); - expect(result.exitCode).toBe(0); - - const spec = ctx.state.completionSpecs?.get("cmd"); - expect(spec).toBeDefined(); - expect(spec?.options).toContain("filenames"); - expect(spec?.options).not.toContain("nospace"); - }); - - test("compopt creates spec for command that doesn't have one", () => { - const ctx = createMockCtx(); - const result = handleCompopt(ctx, ["-o", "nospace", "newcmd"]); - expect(result.exitCode).toBe(0); - - const spec = ctx.state.completionSpecs?.get("newcmd"); - expect(spec).toBeDefined(); - expect(spec?.options).toContain("nospace"); - }); - - test("compopt -o without argument returns error", () => { - const ctx = createMockCtx(); - const result = handleCompopt(ctx, ["-o"]); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("-o"); - expect(result.stderr).toContain("option requires an argument"); - }); - - test("compopt +o without argument returns error", () => { - const ctx = createMockCtx(); - const result = handleCompopt(ctx, ["+o"]); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("+o"); - expect(result.stderr).toContain("option requires an argument"); - }); - - test("compopt validates all option names", () => { - const ctx = createMockCtx(); - - // Test all valid options - const validOptions = [ - "bashdefault", - "default", - "dirnames", - "filenames", - "noquote", - "nosort", - "nospace", - "plusdirs", - ]; - - for (const opt of validOptions) { - const result = handleCompopt(ctx, ["-o", opt, "testcmd"]); - expect(result.exitCode).toBe(0); - } - - // Test invalid option - const invalid = handleCompopt(ctx, ["-o", "notanoption", "testcmd"]); - expect(invalid.exitCode).toBe(2); - }); -}); diff --git a/src/interpreter/builtins/compopt.ts b/src/interpreter/builtins/compopt.ts deleted file mode 100644 index a52f324f..00000000 --- a/src/interpreter/builtins/compopt.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * compopt - Modify completion options - * - * Usage: - * compopt [-o option] [+o option] [name ...] - * compopt -D [-o option] [+o option] - * compopt -E [-o option] [+o option] - * - * Modifies completion options for the specified commands (names) or the - * currently executing completion when no names are provided. - * - * Options: - * -o option Enable completion option - * +o option Disable completion option - * -D Apply to default completion - * -E Apply to empty-line completion - * - * Valid completion options: - * bashdefault, default, dirnames, filenames, noquote, nosort, nospace, plusdirs - * - * Returns: - * 0 on success - * 1 if not in a completion function and no command name is given - * 2 if an invalid option is specified - */ - -import type { ExecResult } from "../../types.js"; -import { failure, success } from "../helpers/result.js"; -import type { InterpreterContext } from "../types.js"; - -// Valid completion options for -o/+o flags -const VALID_OPTIONS = [ - "bashdefault", - "default", - "dirnames", - "filenames", - "noquote", - "nosort", - "nospace", - "plusdirs", -]; - -export function handleCompopt( - ctx: InterpreterContext, - args: string[], -): ExecResult { - // Initialize completionSpecs if not present - if (!ctx.state.completionSpecs) { - ctx.state.completionSpecs = new Map(); - } - - // Parse options - let isDefault = false; - let isEmptyLine = false; - const enableOptions: string[] = []; - const disableOptions: string[] = []; - const commands: string[] = []; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - if (arg === "-D") { - isDefault = true; - } else if (arg === "-E") { - isEmptyLine = true; - } else if (arg === "-o") { - // Enable completion option - i++; - if (i >= args.length) { - return failure("compopt: -o: option requires an argument\n", 2); - } - const opt = args[i]; - if (!VALID_OPTIONS.includes(opt)) { - return failure(`compopt: ${opt}: invalid option name\n`, 2); - } - enableOptions.push(opt); - } else if (arg === "+o") { - // Disable completion option - i++; - if (i >= args.length) { - return failure("compopt: +o: option requires an argument\n", 2); - } - const opt = args[i]; - if (!VALID_OPTIONS.includes(opt)) { - return failure(`compopt: ${opt}: invalid option name\n`, 2); - } - disableOptions.push(opt); - } else if (arg === "--") { - // End of options - commands.push(...args.slice(i + 1)); - break; - } else if (!arg.startsWith("-") && !arg.startsWith("+")) { - commands.push(arg); - } - } - - // If -D flag is set, modify default completion - if (isDefault) { - const spec = ctx.state.completionSpecs.get("__default__") ?? { - isDefault: true, - }; - const currentOptions = new Set(spec.options ?? []); - - // Enable options - for (const opt of enableOptions) { - currentOptions.add(opt); - } - - // Disable options - for (const opt of disableOptions) { - currentOptions.delete(opt); - } - - spec.options = - currentOptions.size > 0 ? Array.from(currentOptions) : undefined; - ctx.state.completionSpecs.set("__default__", spec); - return success(""); - } - - // If -E flag is set, modify empty-line completion - if (isEmptyLine) { - // @banned-pattern-ignore: completion spec with known structure (options array) - const spec = ctx.state.completionSpecs.get("__empty__") ?? {}; - const currentOptions = new Set(spec.options ?? []); - - // Enable options - for (const opt of enableOptions) { - currentOptions.add(opt); - } - - // Disable options - for (const opt of disableOptions) { - currentOptions.delete(opt); - } - - spec.options = - currentOptions.size > 0 ? Array.from(currentOptions) : undefined; - ctx.state.completionSpecs.set("__empty__", spec); - return success(""); - } - - // If command names are provided, modify their completion specs - if (commands.length > 0) { - for (const cmd of commands) { - // @banned-pattern-ignore: completion spec with known structure (options array) - const spec = ctx.state.completionSpecs.get(cmd) ?? {}; - const currentOptions = new Set(spec.options ?? []); - - // Enable options - for (const opt of enableOptions) { - currentOptions.add(opt); - } - - // Disable options - for (const opt of disableOptions) { - currentOptions.delete(opt); - } - - spec.options = - currentOptions.size > 0 ? Array.from(currentOptions) : undefined; - ctx.state.completionSpecs.set(cmd, spec); - } - return success(""); - } - - // No command name and not -D/-E: we need to be in a completion function - // In bash, compopt modifies the current completion context when called - // from within a completion function. Since we don't have a completion - // context indicator, we fail if no command name is given. - // This matches bash behavior: "compopt: not currently executing completion function" - return failure("compopt: not currently executing completion function\n", 1); -} diff --git a/src/interpreter/builtins/continue.test.ts b/src/interpreter/builtins/continue.test.ts deleted file mode 100644 index a23fd1a6..00000000 --- a/src/interpreter/builtins/continue.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("continue builtin", () => { - describe("basic continue", () => { - it("should skip to next iteration in for loop", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3 4 5; do - if [ $i -eq 3 ]; then continue; fi - echo $i - done - echo done - `); - expect(result.stdout).toBe("1\n2\n4\n5\ndone\n"); - expect(result.exitCode).toBe(0); - }); - - it("should skip to next iteration in while loop", async () => { - const env = new Bash(); - const result = await env.exec(` - x=0 - while [ $x -lt 5 ]; do - x=$((x + 1)) - if [ $x -eq 3 ]; then continue; fi - echo $x - done - echo done - `); - expect(result.stdout).toBe("1\n2\n4\n5\ndone\n"); - expect(result.exitCode).toBe(0); - }); - - it("should skip to next iteration in until loop", async () => { - const env = new Bash(); - const result = await env.exec(` - x=0 - until [ $x -ge 5 ]; do - x=$((x + 1)) - if [ $x -eq 3 ]; then continue; fi - echo $x - done - echo done - `); - expect(result.stdout).toBe("1\n2\n4\n5\ndone\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("continue with level argument", () => { - it("should continue multiple levels with continue n", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2; do - for j in a b c; do - if [ $j = b ]; then continue 2; fi - echo "$i$j" - done - echo "end-$i" - done - echo done - `); - expect(result.stdout).toBe("1a\n2a\ndone\n"); - expect(result.exitCode).toBe(0); - }); - - it("should continue single level with continue 1", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3; do - if [ $i -eq 2 ]; then continue 1; fi - echo $i - done - `); - expect(result.stdout).toBe("1\n3\n"); - }); - - it("should handle continue with level exceeding loop depth", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3; do - if [ $i -eq 2 ]; then continue 10; fi - echo $i - done - echo done - `); - // continue 10 in a single loop should just continue to next iteration - expect(result.stdout).toBe("1\n3\ndone\n"); - }); - }); - - describe("error cases", () => { - it("should silently do nothing when not in loop", async () => { - const env = new Bash(); - const result = await env.exec("continue"); - // In bash, continue outside a loop silently does nothing - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should error on invalid argument", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3; do - continue abc - done - `); - expect(result.stderr).toContain("numeric argument required"); - expect(result.exitCode).toBe(1); - }); - - it("should error on zero argument", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3; do - continue 0 - done - `); - expect(result.stderr).toContain("numeric argument required"); - expect(result.exitCode).toBe(1); - }); - - it("should error on negative argument", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3; do - continue -1 - done - `); - expect(result.stderr).toContain("numeric argument required"); - expect(result.exitCode).toBe(1); - }); - - it("should error on too many arguments (bash behavior)", async () => { - const env = new Bash(); - const result = await env.exec(` - for x in a b c; do - echo $x - continue 1 2 3 - done - echo -- - `); - // bash errors on too many args and exits with code 1 - expect(result.stdout).toBe("a\n"); - expect(result.stderr).toContain("too many arguments"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("continue in nested constructs", () => { - it("should work with case statements inside loops", async () => { - const env = new Bash(); - const result = await env.exec(` - for x in a b c; do - case $x in - b) continue ;; - esac - echo $x - done - `); - expect(result.stdout).toBe("a\nc\n"); - expect(result.exitCode).toBe(0); - }); - - it("should work with if statements inside loops", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3 4 5; do - if [ $i -eq 2 ] || [ $i -eq 4 ]; then - continue - fi - echo $i - done - `); - expect(result.stdout).toBe("1\n3\n5\n"); - }); - - it("should work in function inside loop", async () => { - const env = new Bash(); - const result = await env.exec(` - skip_even() { - if [ $(($1 % 2)) -eq 0 ]; then - continue - fi - } - for i in 1 2 3 4 5; do - skip_even $i - echo $i - done - `); - // continue inside function should continue the outer loop - expect(result.stdout).toBe("1\n3\n5\n"); - }); - }); - - describe("continue in C-style for loop", () => { - it("should continue in C-style for loop", async () => { - const env = new Bash(); - const result = await env.exec(` - for ((i=1; i<=5; i++)); do - if [ $i -eq 3 ]; then continue; fi - echo $i - done - `); - expect(result.stdout).toBe("1\n2\n4\n5\n"); - }); - - it("should run update expression after continue", async () => { - const env = new Bash(); - const result = await env.exec(` - for ((i=0; i<5; i++)); do - if [ $i -lt 3 ]; then continue; fi - echo $i - done - `); - // i should still be incremented after continue - expect(result.stdout).toBe("3\n4\n"); - }); - }); -}); diff --git a/src/interpreter/builtins/continue.ts b/src/interpreter/builtins/continue.ts deleted file mode 100644 index 1de27df7..00000000 --- a/src/interpreter/builtins/continue.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * continue - Skip to next loop iteration builtin - */ - -import type { ExecResult } from "../../types.js"; -import { ContinueError, ExitError, SubshellExitError } from "../errors.js"; -import { OK } from "../helpers/result.js"; -import type { InterpreterContext } from "../types.js"; - -export function handleContinue( - ctx: InterpreterContext, - args: string[], -): ExecResult { - // Check if we're in a loop - if (ctx.state.loopDepth === 0) { - // If we're in a subshell spawned from a loop context, exit the subshell - if (ctx.state.parentHasLoopContext) { - throw new SubshellExitError(); - } - // Otherwise, continue silently does nothing (returns 0) - return OK; - } - - // bash: too many arguments is an error (exit code 1) - if (args.length > 1) { - throw new ExitError(1, "", "bash: continue: too many arguments\n"); - } - - let levels = 1; - if (args.length > 0) { - const n = Number.parseInt(args[0], 10); - if (Number.isNaN(n) || n < 1) { - throw new ExitError( - 1, - "", - `bash: continue: ${args[0]}: numeric argument required\n`, - ); - } - levels = n; - } - - throw new ContinueError(levels); -} diff --git a/src/interpreter/builtins/declare-array-parsing.ts b/src/interpreter/builtins/declare-array-parsing.ts deleted file mode 100644 index 1e8bcd9e..00000000 --- a/src/interpreter/builtins/declare-array-parsing.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Array Parsing Functions for declare/typeset - * - * Handles parsing of array literal syntax for the declare builtin. - */ - -/** - * Parse array elements from content like "1 2 3" or "'a b' c d" - */ -export function parseArrayElements(content: string): string[] { - const elements: string[] = []; - let current = ""; - let inSingleQuote = false; - let inDoubleQuote = false; - let escaped = false; - // Track whether we've seen content that should result in an element, - // including empty quoted strings like '' or "" - let hasContent = false; - - for (const char of content) { - if (escaped) { - current += char; - escaped = false; - hasContent = true; - continue; - } - if (char === "\\") { - escaped = true; - continue; - } - if (char === "'" && !inDoubleQuote) { - // Entering or leaving single quotes - either way, this indicates an element exists - if (!inSingleQuote) { - // Entering quotes - mark that we have content (even if empty) - hasContent = true; - } - inSingleQuote = !inSingleQuote; - continue; - } - if (char === '"' && !inSingleQuote) { - // Entering or leaving double quotes - either way, this indicates an element exists - if (!inDoubleQuote) { - // Entering quotes - mark that we have content (even if empty) - hasContent = true; - } - inDoubleQuote = !inDoubleQuote; - continue; - } - if ( - (char === " " || char === "\t" || char === "\n") && - !inSingleQuote && - !inDoubleQuote - ) { - if (hasContent) { - elements.push(current); - current = ""; - hasContent = false; - } - continue; - } - current += char; - hasContent = true; - } - if (hasContent) { - elements.push(current); - } - return elements; -} - -/** - * Parse associative array literal content like "['foo']=bar ['spam']=42" - * Returns array of [key, value] pairs - */ -export function parseAssocArrayLiteral(content: string): [string, string][] { - const entries: [string, string][] = []; - let pos = 0; - - while (pos < content.length) { - // Skip whitespace - while (pos < content.length && /\s/.test(content[pos])) { - pos++; - } - if (pos >= content.length) break; - - // Expect [ - if (content[pos] !== "[") { - // Skip non-bracket content - pos++; - continue; - } - pos++; // skip [ - - // Parse key (may be quoted) - let key = ""; - if (content[pos] === "'" || content[pos] === '"') { - const quote = content[pos]; - pos++; - while (pos < content.length && content[pos] !== quote) { - key += content[pos]; - pos++; - } - if (content[pos] === quote) pos++; - } else { - while ( - pos < content.length && - content[pos] !== "]" && - content[pos] !== "=" - ) { - key += content[pos]; - pos++; - } - } - - // Skip to ] - while (pos < content.length && content[pos] !== "]") { - pos++; - } - if (content[pos] === "]") pos++; - - // Expect = - if (content[pos] !== "=") continue; - pos++; - - // Parse value (may be quoted) - let value = ""; - if (content[pos] === "'" || content[pos] === '"') { - const quote = content[pos]; - pos++; - while (pos < content.length && content[pos] !== quote) { - if (content[pos] === "\\" && pos + 1 < content.length) { - pos++; - value += content[pos]; - } else { - value += content[pos]; - } - pos++; - } - if (content[pos] === quote) pos++; - } else { - while (pos < content.length && !/\s/.test(content[pos])) { - value += content[pos]; - pos++; - } - } - - entries.push([key, value]); - } - - return entries; -} diff --git a/src/interpreter/builtins/declare-print.ts b/src/interpreter/builtins/declare-print.ts deleted file mode 100644 index c5ba0a3f..00000000 --- a/src/interpreter/builtins/declare-print.ts +++ /dev/null @@ -1,436 +0,0 @@ -/** - * Declare Print Mode Functions - * - * Handles printing and listing variables for the declare/typeset builtin. - */ - -import type { ExecResult } from "../../types.js"; -import { getArrayIndices, getAssocArrayKeys } from "../helpers/array.js"; -import { isNameref } from "../helpers/nameref.js"; -import { - quoteArrayValue, - quoteDeclareValue, - quoteValue, -} from "../helpers/quoting.js"; -import { result, success } from "../helpers/result.js"; -import type { InterpreterContext } from "../types.js"; - -/** - * Get the attribute flags string for a variable (e.g., "-r", "-x", "-rx", "--") - * Order follows bash convention: a/A (array), i (integer), l (lowercase), n (nameref), r (readonly), u (uppercase), x (export) - */ -function getVariableFlags(ctx: InterpreterContext, name: string): string { - let flags = ""; - - // Note: array flags (-a/-A) are handled separately in the caller - // since they require different output format - - // Integer attribute - if (ctx.state.integerVars?.has(name)) { - flags += "i"; - } - - // Lowercase attribute - if (ctx.state.lowercaseVars?.has(name)) { - flags += "l"; - } - - // Nameref attribute - if (isNameref(ctx, name)) { - flags += "n"; - } - - // Readonly attribute - if (ctx.state.readonlyVars?.has(name)) { - flags += "r"; - } - - // Uppercase attribute - if (ctx.state.uppercaseVars?.has(name)) { - flags += "u"; - } - - // Export attribute - if (ctx.state.exportedVars?.has(name)) { - flags += "x"; - } - - return flags === "" ? "--" : `-${flags}`; -} - -/** - * Format a value for associative array output in declare -p. - * Uses the oils/ysh-compatible format: - * - Simple values (no spaces, no special chars): unquoted - * - Empty strings or values with spaces/special chars: single-quoted with escaping - */ -function formatAssocValue(value: string): string { - // Empty string needs quotes - if (value === "") { - return "''"; - } - // If value contains spaces, single quotes, or other special chars, quote it - if (/[\s'\\]/.test(value)) { - // Escape single quotes as '\'' (end quote, escaped quote, start quote) - const escaped = value.replace(/'/g, "'\\''"); - return `'${escaped}'`; - } - // Simple value - no quotes needed - return value; -} - -/** - * Print specific variables with their declarations. - * Handles: declare -p varname1 varname2 ... - */ -export function printSpecificVariables( - ctx: InterpreterContext, - names: string[], -): ExecResult { - let stdout = ""; - let stderr = ""; - let anyNotFound = false; - - for (const name of names) { - // Get the variable's attribute flags (for scalar variables) - const flags = getVariableFlags(ctx, name); - - // Check if this is an associative array - const isAssoc = ctx.state.associativeArrays?.has(name); - if (isAssoc) { - const keys = getAssocArrayKeys(ctx, name); - if (keys.length === 0) { - stdout += `declare -A ${name}=()\n`; - } else { - const elements = keys.map((key) => { - const value = ctx.state.env.get(`${name}_${key}`) ?? ""; - // Format: ['key']=value (single quotes around key) - const formattedValue = formatAssocValue(value); - return `['${key}']=${formattedValue}`; - }); - stdout += `declare -A ${name}=(${elements.join(" ")})\n`; - } - continue; - } - - // Check if this is an indexed array (has array elements) - const arrayIndices = getArrayIndices(ctx, name); - if (arrayIndices.length > 0) { - const elements = arrayIndices.map((index) => { - const value = ctx.state.env.get(`${name}_${index}`) ?? ""; - return `[${index}]=${quoteArrayValue(value)}`; - }); - stdout += `declare -a ${name}=(${elements.join(" ")})\n`; - continue; - } - - // Check if this is an empty array (has __length marker but no elements) - if (ctx.state.env.has(`${name}__length`)) { - stdout += `declare -a ${name}=()\n`; - continue; - } - - // Regular scalar variable - const value = ctx.state.env.get(name); - if (value !== undefined) { - // Use $'...' quoting for control characters, double quotes otherwise - stdout += `declare ${flags} ${name}=${quoteDeclareValue(value)}\n`; - } else { - // Check if variable is declared but unset (via declare or local) - const isDeclared = ctx.state.declaredVars?.has(name); - const isLocalVar = ctx.state.localVarDepth?.has(name); - if (isDeclared || isLocalVar) { - // Variable is declared but has no value - output without ="" - stdout += `declare ${flags} ${name}\n`; - } else { - // Variable not found - add error to stderr and set flag for exit code 1 - stderr += `bash: declare: ${name}: not found\n`; - anyNotFound = true; - } - } - } - - return result(stdout, stderr, anyNotFound ? 1 : 0); -} - -export interface PrintAllFilters { - filterExport: boolean; - filterReadonly: boolean; - filterNameref: boolean; - filterIndexedArray: boolean; - filterAssocArray: boolean; -} - -/** - * Print all variables with their declarations and attributes. - * Handles: declare -p (with optional filters like -x, -r, -n, -a, -A) - */ -export function printAllVariables( - ctx: InterpreterContext, - filters: PrintAllFilters, -): ExecResult { - const { - filterExport, - filterReadonly, - filterNameref, - filterIndexedArray, - filterAssocArray, - } = filters; - const hasFilter = - filterExport || - filterReadonly || - filterNameref || - filterIndexedArray || - filterAssocArray; - - let stdout = ""; - - // Collect all variable names (excluding internal markers like __length) - const varNames = new Set(); - for (const key of ctx.state.env.keys()) { - if (key.startsWith("BASH_")) continue; - // For __length markers, extract the base name (for empty arrays) - if (key.endsWith("__length")) { - const baseName = key.slice(0, -8); - varNames.add(baseName); - continue; - } - // For array elements (name_index), extract base name - const underscoreIdx = key.lastIndexOf("_"); - if (underscoreIdx > 0) { - const baseName = key.slice(0, underscoreIdx); - const suffix = key.slice(underscoreIdx + 1); - // If suffix is numeric or baseName is an array, it's an array element - if (/^\d+$/.test(suffix) || ctx.state.associativeArrays?.has(baseName)) { - varNames.add(baseName); - continue; - } - } - varNames.add(key); - } - - // Also include local variables if we're in a function scope - if (ctx.state.localVarDepth) { - for (const name of ctx.state.localVarDepth.keys()) { - varNames.add(name); - } - } - - // Include associative array names (for empty associative arrays) - if (ctx.state.associativeArrays) { - for (const name of ctx.state.associativeArrays) { - varNames.add(name); - } - } - - // Sort and output each variable - const sortedNames = Array.from(varNames).sort(); - for (const name of sortedNames) { - const flags = getVariableFlags(ctx, name); - - // Check if this is an associative array - const isAssoc = ctx.state.associativeArrays?.has(name); - - // Check if this is an indexed array (not associative) - const arrayIndices = getArrayIndices(ctx, name); - const isIndexedArray = - !isAssoc && - (arrayIndices.length > 0 || ctx.state.env.has(`${name}__length`)); - - // Apply filters if set - if (hasFilter) { - // If filtering for associative arrays only (-pA) - if (filterAssocArray && !isAssoc) continue; - // If filtering for indexed arrays only (-pa) - if (filterIndexedArray && !isIndexedArray) continue; - // If filtering for exported only (-px) - if (filterExport && !ctx.state.exportedVars?.has(name)) continue; - // If filtering for readonly only (-pr) - if (filterReadonly && !ctx.state.readonlyVars?.has(name)) continue; - // If filtering for nameref only (-pn) - if (filterNameref && !isNameref(ctx, name)) continue; - } - - if (isAssoc) { - const keys = getAssocArrayKeys(ctx, name); - if (keys.length === 0) { - stdout += `declare -A ${name}=()\n`; - } else { - const elements = keys.map((key) => { - const value = ctx.state.env.get(`${name}_${key}`) ?? ""; - // Format: ['key']=value (single quotes around key) - const formattedValue = formatAssocValue(value); - return `['${key}']=${formattedValue}`; - }); - stdout += `declare -A ${name}=(${elements.join(" ")})\n`; - } - continue; - } - - // Check if this is an indexed array - if (arrayIndices.length > 0) { - const elements = arrayIndices.map((index) => { - const value = ctx.state.env.get(`${name}_${index}`) ?? ""; - return `[${index}]=${quoteArrayValue(value)}`; - }); - stdout += `declare -a ${name}=(${elements.join(" ")})\n`; - continue; - } - - // Check if this is an empty array - if (ctx.state.env.has(`${name}__length`)) { - stdout += `declare -a ${name}=()\n`; - continue; - } - - // Regular scalar variable - const value = ctx.state.env.get(name); - if (value !== undefined) { - stdout += `declare ${flags} ${name}=${quoteDeclareValue(value)}\n`; - } - } - - return success(stdout); -} - -/** - * List all associative arrays. - * Handles: declare -A (without arguments) - */ -export function listAssociativeArrays(ctx: InterpreterContext): ExecResult { - let stdout = ""; - - // Get all associative array names and sort them - const assocNames = Array.from(ctx.state.associativeArrays ?? []).sort(); - - for (const name of assocNames) { - const keys = getAssocArrayKeys(ctx, name); - if (keys.length === 0) { - // Empty associative array - stdout += `declare -A ${name}=()\n`; - } else { - // Non-empty associative array: format as (['key']=value ...) - const elements = keys.map((key) => { - const value = ctx.state.env.get(`${name}_${key}`) ?? ""; - // Format: ['key']=value (single quotes around key) - const formattedValue = formatAssocValue(value); - return `['${key}']=${formattedValue}`; - }); - stdout += `declare -A ${name}=(${elements.join(" ")})\n`; - } - } - - return success(stdout); -} - -/** - * List all indexed arrays. - * Handles: declare -a (without arguments) - */ -export function listIndexedArrays(ctx: InterpreterContext): ExecResult { - let stdout = ""; - - // Find all indexed arrays - const arrayNames = new Set(); - for (const key of ctx.state.env.keys()) { - if (key.startsWith("BASH_")) continue; - // Check for __length marker (empty arrays) - if (key.endsWith("__length")) { - const baseName = key.slice(0, -8); - // Make sure it's not an associative array - if (!ctx.state.associativeArrays?.has(baseName)) { - arrayNames.add(baseName); - } - continue; - } - // Check for numeric index pattern (name_index) - const lastUnderscore = key.lastIndexOf("_"); - if (lastUnderscore > 0) { - const baseName = key.slice(0, lastUnderscore); - const suffix = key.slice(lastUnderscore + 1); - // If suffix is numeric, it's an array element - if (/^\d+$/.test(suffix)) { - // Make sure it's not an associative array - if (!ctx.state.associativeArrays?.has(baseName)) { - arrayNames.add(baseName); - } - } - } - } - - // Output each array in sorted order - const sortedNames = Array.from(arrayNames).sort(); - for (const name of sortedNames) { - const indices = getArrayIndices(ctx, name); - if (indices.length === 0) { - // Empty array - stdout += `declare -a ${name}=()\n`; - } else { - // Non-empty array: format as ([index]="value" ...) - const elements = indices.map((index) => { - const value = ctx.state.env.get(`${name}_${index}`) ?? ""; - return `[${index}]=${quoteArrayValue(value)}`; - }); - stdout += `declare -a ${name}=(${elements.join(" ")})\n`; - } - } - - return success(stdout); -} - -/** - * List all variables without print mode (no attributes shown). - * Handles: declare (without -p and without arguments) - */ -export function listAllVariables(ctx: InterpreterContext): ExecResult { - let stdout = ""; - - // Collect all variable names (excluding internal markers) - const varNames = new Set(); - for (const key of ctx.state.env.keys()) { - if (key.startsWith("BASH_")) continue; - // For __length markers, extract the base name (for arrays) - if (key.endsWith("__length")) { - const baseName = key.slice(0, -8); - varNames.add(baseName); - continue; - } - // For array elements (name_index), extract base name - const underscoreIdx = key.lastIndexOf("_"); - if (underscoreIdx > 0) { - const baseName = key.slice(0, underscoreIdx); - const suffix = key.slice(underscoreIdx + 1); - // If suffix is numeric or baseName is an associative array - if (/^\d+$/.test(suffix) || ctx.state.associativeArrays?.has(baseName)) { - varNames.add(baseName); - continue; - } - } - varNames.add(key); - } - - const sortedNames = Array.from(varNames).sort(); - for (const name of sortedNames) { - // Check if this is an associative array - const isAssoc = ctx.state.associativeArrays?.has(name); - if (isAssoc) { - // Skip associative arrays for simple declare output - continue; - } - - // Check if this is an indexed array - const arrayIndices = getArrayIndices(ctx, name); - if (arrayIndices.length > 0 || ctx.state.env.has(`${name}__length`)) { - // Skip indexed arrays for simple declare output - continue; - } - - // Regular scalar variable - output as name=value - const value = ctx.state.env.get(name); - if (value !== undefined) { - stdout += `${name}=${quoteValue(value)}\n`; - } - } - - return success(stdout); -} diff --git a/src/interpreter/builtins/declare.ts b/src/interpreter/builtins/declare.ts deleted file mode 100644 index d5076088..00000000 --- a/src/interpreter/builtins/declare.ts +++ /dev/null @@ -1,1098 +0,0 @@ -/** - * declare/typeset - Declare variables and give them attributes - * - * Usage: - * declare - List all variables - * declare -p - List all variables (same as no args) - * declare NAME=value - Declare variable with value - * declare -a NAME - Declare indexed array - * declare -A NAME - Declare associative array - * declare -r NAME - Declare readonly variable - * declare -x NAME - Export variable - * declare -g NAME - Declare global variable (inside functions) - * - * Also aliased as 'typeset' - */ - -import { parseArithmeticExpression } from "../../parser/arithmetic-parser.js"; -import { Parser } from "../../parser/parser.js"; -import type { ExecResult } from "../../types.js"; -import { evaluateArithmetic } from "../arithmetic.js"; -import { clearArray, getArrayIndices } from "../helpers/array.js"; -import { - isNameref, - markNameref, - markNamerefBound, - markNamerefInvalid, - resolveNameref, - targetExists, - unmarkNameref, -} from "../helpers/nameref.js"; -import { - checkReadonlyError, - markExported, - markReadonly, - unmarkExported, -} from "../helpers/readonly.js"; -import { OK, result, success } from "../helpers/result.js"; -import { expandTildesInValue } from "../helpers/tilde.js"; -import type { InterpreterContext } from "../types.js"; -import { - parseArrayElements, - parseAssocArrayLiteral, -} from "./declare-array-parsing.js"; -import { - listAllVariables, - listAssociativeArrays, - listIndexedArrays, - printAllVariables, - printSpecificVariables, -} from "./declare-print.js"; -import { - markLocalVarDepth, - parseAssignment, - setVariable, -} from "./variable-assignment.js"; - -/** - * Mark a variable as having the integer attribute. - */ -function markInteger(ctx: InterpreterContext, name: string): void { - ctx.state.integerVars ??= new Set(); - ctx.state.integerVars.add(name); -} - -/** - * Check if a variable has the integer attribute. - */ -export function isInteger(ctx: InterpreterContext, name: string): boolean { - return ctx.state.integerVars?.has(name) ?? false; -} - -/** - * Mark a variable as having the lowercase attribute. - */ -function markLowercase(ctx: InterpreterContext, name: string): void { - ctx.state.lowercaseVars ??= new Set(); - ctx.state.lowercaseVars.add(name); - // -l and -u are mutually exclusive; -l clears -u - ctx.state.uppercaseVars?.delete(name); -} - -/** - * Check if a variable has the lowercase attribute. - */ -function isLowercase(ctx: InterpreterContext, name: string): boolean { - return ctx.state.lowercaseVars?.has(name) ?? false; -} - -/** - * Mark a variable as having the uppercase attribute. - */ -function markUppercase(ctx: InterpreterContext, name: string): void { - ctx.state.uppercaseVars ??= new Set(); - ctx.state.uppercaseVars.add(name); - // -l and -u are mutually exclusive; -u clears -l - ctx.state.lowercaseVars?.delete(name); -} - -/** - * Check if a variable has the uppercase attribute. - */ -function isUppercase(ctx: InterpreterContext, name: string): boolean { - return ctx.state.uppercaseVars?.has(name) ?? false; -} - -/** - * Apply case transformation based on variable attributes. - * Returns the transformed value. - */ -export function applyCaseTransform( - ctx: InterpreterContext, - name: string, - value: string, -): string { - if (isLowercase(ctx, name)) { - return value.toLowerCase(); - } - if (isUppercase(ctx, name)) { - return value.toUpperCase(); - } - return value; -} - -/** - * Evaluate a value as arithmetic if the variable has integer attribute. - * Returns the evaluated string value. - */ -async function evaluateIntegerValue( - ctx: InterpreterContext, - value: string, -): Promise { - try { - const parser = new Parser(); - const arithAst = parseArithmeticExpression(parser, value); - const result = await evaluateArithmetic(ctx, arithAst.expression); - return String(result); - } catch { - // If parsing fails, return 0 (bash behavior for invalid expressions) - return "0"; - } -} - -/** - * Parse array assignment syntax: name[index]=value - * Handles nested brackets like a[a[0]=1]=X - * Returns null if not an array assignment pattern - */ -function parseArrayAssignment( - arg: string, -): { name: string; indexExpr: string; value: string } | null { - // Check for variable name at start - const nameMatch = arg.match(/^[a-zA-Z_][a-zA-Z0-9_]*/); - if (!nameMatch) return null; - - const name = nameMatch[0]; - let pos = name.length; - - // Must have [ after name - if (arg[pos] !== "[") return null; - - // Find matching ] using bracket depth tracking - let depth = 0; - const subscriptStart = pos + 1; - for (; pos < arg.length; pos++) { - if (arg[pos] === "[") depth++; - else if (arg[pos] === "]") { - depth--; - if (depth === 0) break; - } - } - - // If depth is not 0, brackets are unbalanced - if (depth !== 0) return null; - - const indexExpr = arg.slice(subscriptStart, pos); - pos++; // skip closing ] - - // Must have = after ] - if (arg[pos] !== "=") return null; - pos++; // skip = - - const value = arg.slice(pos); - - return { name, indexExpr, value }; -} - -export async function handleDeclare( - ctx: InterpreterContext, - args: string[], -): Promise { - // Parse flags - let declareArray = false; - let declareAssoc = false; - let declareReadonly = false; - let declareExport = false; - let printMode = false; - let declareNameref = false; - let removeNameref = false; - let removeArray = false; // +a flag: remove array attribute, treat value as scalar - let removeExport = false; // +x flag: remove export attribute - let declareInteger = false; - let declareLowercase = false; - let declareUppercase = false; - let functionMode = false; // -f flag: function definitions - let functionNamesOnly = false; // -F flag: function names only - let declareGlobal = false; // -g flag: declare global variable (inside functions) - const processedArgs: string[] = []; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === "-a") { - declareArray = true; - } else if (arg === "-A") { - declareAssoc = true; - } else if (arg === "-r") { - declareReadonly = true; - } else if (arg === "-x") { - declareExport = true; - } else if (arg === "-p") { - printMode = true; - } else if (arg === "-n") { - declareNameref = true; - } else if (arg === "+n") { - removeNameref = true; - } else if (arg === "+a") { - removeArray = true; - } else if (arg === "+x") { - removeExport = true; - } else if (arg === "--") { - // End of options, rest are arguments - processedArgs.push(...args.slice(i + 1)); - break; - } else if (arg.startsWith("+")) { - // Handle + flags that remove attributes - // Valid + flags: +a, +n, +x, +r, +i, +f, +F - for (const flag of arg.slice(1)) { - if (flag === "n") removeNameref = true; - else if (flag === "a") removeArray = true; - else if (flag === "x") removeExport = true; - else if (flag === "r") { - // +r is accepted by bash but has no effect (can't un-readonly) - // We just ignore it silently - } else if (flag === "i") { - // +i removes integer attribute - we just ignore since we don't track removal - } else if (flag === "f" || flag === "F") { - // +f/+F for function listing - we just ignore - } else { - // Unknown flag - bash returns exit code 2 for invalid options - return result("", `bash: typeset: +${flag}: invalid option\n`, 2); - } - } - } else if (arg === "-i") { - declareInteger = true; - } else if (arg === "-l") { - declareLowercase = true; - } else if (arg === "-u") { - declareUppercase = true; - } else if (arg === "-f") { - functionMode = true; - } else if (arg === "-F") { - functionNamesOnly = true; - } else if (arg === "-g") { - declareGlobal = true; - } else if (arg.startsWith("-")) { - // Handle combined flags like -ar - for (const flag of arg.slice(1)) { - if (flag === "a") declareArray = true; - else if (flag === "A") declareAssoc = true; - else if (flag === "r") declareReadonly = true; - else if (flag === "x") declareExport = true; - else if (flag === "p") printMode = true; - else if (flag === "n") declareNameref = true; - else if (flag === "i") declareInteger = true; - else if (flag === "l") declareLowercase = true; - else if (flag === "u") declareUppercase = true; - else if (flag === "f") functionMode = true; - else if (flag === "F") functionNamesOnly = true; - else if (flag === "g") declareGlobal = true; - else { - // Unknown flag - bash returns exit code 2 for invalid options - return result("", `bash: typeset: -${flag}: invalid option\n`, 2); - } - } - } else { - processedArgs.push(arg); - } - } - - // Determine if we should create local variables (inside a function, without -g flag) - const isInsideFunction = ctx.state.localScopes.length > 0; - const createLocal = isInsideFunction && !declareGlobal; - - // Helper to save variable to local scope (for restoration when function exits) - const saveToLocalScope = (name: string): void => { - if (!createLocal) return; - const currentScope = - ctx.state.localScopes[ctx.state.localScopes.length - 1]; - if (!currentScope.has(name)) { - currentScope.set(name, ctx.state.env.get(name)); - } - }; - - // Helper to save array elements to local scope - const saveArrayToLocalScope = (name: string): void => { - if (!createLocal) return; - const currentScope = - ctx.state.localScopes[ctx.state.localScopes.length - 1]; - // Save the base variable - if (!currentScope.has(name)) { - currentScope.set(name, ctx.state.env.get(name)); - } - // Save array elements - const prefix = `${name}_`; - for (const key of ctx.state.env.keys()) { - if (key.startsWith(prefix) && !key.includes("__")) { - if (!currentScope.has(key)) { - currentScope.set(key, ctx.state.env.get(key)); - } - } - } - // Save length metadata - const lengthKey = `${name}__length`; - if (ctx.state.env.has(lengthKey) && !currentScope.has(lengthKey)) { - currentScope.set(lengthKey, ctx.state.env.get(lengthKey)); - } - }; - - // Helper to mark variable as local after setting it - const markAsLocalIfNeeded = (name: string): void => { - if (createLocal) { - markLocalVarDepth(ctx, name); - } - }; - - // Handle declare -F (function names only) - if (functionNamesOnly) { - if (processedArgs.length === 0) { - // List all function names in sorted order - const funcNames = Array.from(ctx.state.functions.keys()).sort(); - let stdout = ""; - for (const name of funcNames) { - stdout += `declare -f ${name}\n`; - } - return success(stdout); - } - // With args, check if functions exist and output their names - let allExist = true; - let stdout = ""; - for (const name of processedArgs) { - if (ctx.state.functions.has(name)) { - stdout += `${name}\n`; - } else { - allExist = false; - } - } - return result(stdout, "", allExist ? 0 : 1); - } - - // Handle declare -f (function definitions) - if (functionMode) { - if (processedArgs.length === 0) { - // List all function definitions - we don't store source, so just list names - let stdout = ""; - const funcNames = Array.from(ctx.state.functions.keys()).sort(); - for (const name of funcNames) { - // Without source tracking, we can't print the full definition - // Just print the function name declaration - stdout += `${name} ()\n{\n # function body\n}\n`; - } - return success(stdout); - } - // Check if all specified functions exist (exit code is the main use case) - let allExist = true; - for (const name of processedArgs) { - if (!ctx.state.functions.has(name)) { - allExist = false; - } - } - return result("", "", allExist ? 0 : 1); - } - - // Print mode with specific variable names: declare -p varname - if (printMode && processedArgs.length > 0) { - return printSpecificVariables(ctx, processedArgs); - } - - // Print mode without args (declare -p): list all variables with attributes - // When filtering flags are also set (-x, -r, -n, -a, -A), only show matching variables - if (printMode && processedArgs.length === 0) { - return printAllVariables(ctx, { - filterExport: declareExport, - filterReadonly: declareReadonly, - filterNameref: declareNameref, - filterIndexedArray: declareArray, - filterAssocArray: declareAssoc, - }); - } - - // Handle declare -A without arguments: list all associative arrays - if (processedArgs.length === 0 && declareAssoc && !printMode) { - return listAssociativeArrays(ctx); - } - - // Handle declare -a without arguments: list all indexed arrays - if (processedArgs.length === 0 && declareArray && !printMode) { - return listIndexedArrays(ctx); - } - - // No args: list all variables (without -p flag, just print name=value) - if (processedArgs.length === 0 && !printMode) { - return listAllVariables(ctx); - } - - // Track errors during processing - let stderr = ""; - let exitCode = 0; - - // Process each argument - for (const arg of processedArgs) { - // Check for array assignment: name=(...) - // When +a (removeArray) is set, don't interpret (...) as array syntax - treat it as a literal string - const arrayMatch = arg.match(/^([a-zA-Z_][a-zA-Z0-9_]*)=\((.*)\)$/s); - if (arrayMatch && !removeArray) { - const name = arrayMatch[1]; - const content = arrayMatch[2]; - - // Check for type conversion errors - // Cannot convert indexed array to associative array - if (declareAssoc) { - const existingIndices = getArrayIndices(ctx, name); - if (existingIndices.length > 0) { - stderr += `bash: declare: ${name}: cannot convert indexed to associative array\n`; - exitCode = 1; - continue; - } - } - // Cannot convert associative array to indexed array - if (declareArray || (!declareAssoc && !declareArray)) { - // If no -A flag is set and variable is already an assoc array, error - if (ctx.state.associativeArrays?.has(name)) { - stderr += `bash: declare: ${name}: cannot convert associative to indexed array\n`; - exitCode = 1; - continue; - } - } - - // Save to local scope before modifying (for local variable restoration) - saveArrayToLocalScope(name); - - // Track associative array declaration - if (declareAssoc) { - ctx.state.associativeArrays ??= new Set(); - ctx.state.associativeArrays.add(name); - } - - // Clear existing array elements before assigning new ones - // This ensures arr=(a b c); arr=(d e) results in just (d e), not merged - clearArray(ctx, name); - // Also clear the scalar value and length marker - ctx.state.env.delete(name); - ctx.state.env.delete(`${name}__length`); - - // Check if this is associative array literal syntax: (['key']=value ...) - if (declareAssoc && content.includes("[")) { - const entries = parseAssocArrayLiteral(content); - for (const [key, rawValue] of entries) { - // Apply tilde expansion to the value - const value = expandTildesInValue(ctx, rawValue); - ctx.state.env.set(`${name}_${key}`, value); - } - } else if (declareAssoc) { - // For associative arrays without [key]=value syntax, - // bash treats bare values as alternating key-value pairs - // e.g., (1 2 3) becomes ['1']=2, ['3']='' - const elements = parseArrayElements(content); - for (let i = 0; i < elements.length; i += 2) { - const key = elements[i]; - const value = - i + 1 < elements.length - ? expandTildesInValue(ctx, elements[i + 1]) - : ""; - ctx.state.env.set(`${name}_${key}`, value); - } - } else { - // Parse as indexed array elements - const elements = parseArrayElements(content); - // Check if any element has [index]=value syntax (index can be number, variable, or expression) - const hasKeyedElements = elements.some((el) => /^\[[^\]]+\]=/.test(el)); - if (hasKeyedElements) { - // Handle sparse array with [index]=value syntax - // Track current index - non-keyed elements use previous keyed index + 1 - let currentIndex = 0; - for (const element of elements) { - // Match [index]=value where index can be any expression (not just digits) - const keyedMatch = element.match(/^\[([^\]]+)\]=(.*)$/); - if (keyedMatch) { - const indexExpr = keyedMatch[1]; - const rawValue = keyedMatch[2]; - const value = expandTildesInValue(ctx, rawValue); - // Evaluate index as arithmetic expression (handles numbers, variables, expressions) - let index: number; - if (/^-?\d+$/.test(indexExpr)) { - index = Number.parseInt(indexExpr, 10); - } else { - // Evaluate as arithmetic expression - try { - const parser = new Parser(); - const arithAst = parseArithmeticExpression(parser, indexExpr); - index = await evaluateArithmetic(ctx, arithAst.expression); - } catch { - // If parsing fails, treat as 0 (like unset variable) - index = 0; - } - } - ctx.state.env.set(`${name}_${index}`, value); - currentIndex = index + 1; - } else { - // Non-keyed element: use currentIndex and increment - const value = expandTildesInValue(ctx, element); - ctx.state.env.set(`${name}_${currentIndex}`, value); - currentIndex++; - } - } - } else { - // Simple sequential assignment - for (let i = 0; i < elements.length; i++) { - ctx.state.env.set(`${name}_${i}`, elements[i]); - } - // Store array length marker - ctx.state.env.set(`${name}__length`, String(elements.length)); - } - } - - // Mark as local if inside a function - markAsLocalIfNeeded(name); - - if (declareReadonly) { - markReadonly(ctx, name); - } - if (declareExport) { - markExported(ctx, name); - } - continue; - } - - // Handle nameref removal (+n) - if (removeNameref) { - const name = arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg; - unmarkNameref(ctx, name); - // After removing nameref, the value stays as-is (it's now a regular variable) - if (!arg.includes("=")) { - continue; - } - } - - // Handle export removal (+x) - if (removeExport) { - const name = arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg; - unmarkExported(ctx, name); - // After removing export, continue processing to set any value if provided - if (!arg.includes("=")) { - continue; - } - } - - // Check for array index assignment: name[index]=value - // We need to handle nested brackets like a[a[0]=1]=X - // The regex approach doesn't work for nested brackets, so we parse manually - const arrayAssignMatch = parseArrayAssignment(arg); - if (arrayAssignMatch) { - const { name, indexExpr, value } = arrayAssignMatch; - - // Check if variable is readonly - const error = checkReadonlyError(ctx, name); - if (error) return error; - - // Save to local scope before modifying - saveArrayToLocalScope(name); - - // Evaluate the index (can be arithmetic expression) - let index: number; - try { - const parser = new Parser(); - const arithAst = parseArithmeticExpression(parser, indexExpr); - index = await evaluateArithmetic(ctx, arithAst.expression); - } catch { - // If parsing fails, try to parse as simple number - const num = parseInt(indexExpr, 10); - index = Number.isNaN(num) ? 0 : num; - } - - // Set the array element - ctx.state.env.set(`${name}_${index}`, value); - - // Update array length if needed - const currentLength = parseInt( - ctx.state.env.get(`${name}__length`) ?? "0", - 10, - ); - if (index >= currentLength) { - ctx.state.env.set(`${name}__length`, String(index + 1)); - } - - // Mark as local if inside a function - markAsLocalIfNeeded(name); - - if (declareReadonly) { - markReadonly(ctx, name); - } - if (declareExport) { - markExported(ctx, name); - } - continue; - } - - // Check for array append syntax: typeset NAME+=(...) - const arrayAppendMatch = arg.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\+=\((.*)\)$/s, - ); - if (arrayAppendMatch && !removeArray) { - const name = arrayAppendMatch[1]; - const content = arrayAppendMatch[2]; - - // Check if variable is readonly - const error = checkReadonlyError(ctx, name); - if (error) return error; - - // Save to local scope before modifying - saveArrayToLocalScope(name); - - // Parse new elements - const newElements = parseArrayElements(content); - - // Check if this is an associative array - if (ctx.state.associativeArrays?.has(name)) { - // For associative arrays, we need keyed elements: ([key]=value ...) - const entries = parseAssocArrayLiteral(content); - for (const [key, rawValue] of entries) { - const value = expandTildesInValue(ctx, rawValue); - ctx.state.env.set(`${name}_${key}`, value); - } - } else { - // For indexed arrays, get current highest index and append - const existingIndices = getArrayIndices(ctx, name); - - // If variable was a scalar, convert it to array element 0 - let startIndex = 0; - const scalarValue = ctx.state.env.get(name); - if (existingIndices.length === 0 && scalarValue !== undefined) { - // Variable exists as scalar - convert to array element 0 - ctx.state.env.set(`${name}_0`, scalarValue); - ctx.state.env.delete(name); - startIndex = 1; - } else if (existingIndices.length > 0) { - // Find highest existing index + 1 - startIndex = Math.max(...existingIndices) + 1; - } - - // Append new elements - for (let i = 0; i < newElements.length; i++) { - ctx.state.env.set( - `${name}_${startIndex + i}`, - expandTildesInValue(ctx, newElements[i]), - ); - } - - // Update length marker - const newLength = startIndex + newElements.length; - ctx.state.env.set(`${name}__length`, String(newLength)); - } - - // Mark as local if inside a function - markAsLocalIfNeeded(name); - - if (declareReadonly) { - markReadonly(ctx, name); - } - if (declareExport) { - markExported(ctx, name); - } - continue; - } - - // Check for += append syntax: typeset NAME+=value (scalar append) - const appendMatch = arg.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\+=(.*)$/); - if (appendMatch) { - const name = appendMatch[1]; - let appendValue = expandTildesInValue(ctx, appendMatch[2]); - - // Check if variable is readonly - const error = checkReadonlyError(ctx, name); - if (error) return error; - - // Save to local scope before modifying - saveToLocalScope(name); - - // Mark as integer if -i flag was used - if (declareInteger) { - markInteger(ctx, name); - } - - // Mark as lowercase if -l flag was used - if (declareLowercase) { - markLowercase(ctx, name); - } - - // Mark as uppercase if -u flag was used - if (declareUppercase) { - markUppercase(ctx, name); - } - - // Check if this is an array (bash appends to element 0 for array+=string) - const existingIndices = getArrayIndices(ctx, name); - const isArray = - existingIndices.length > 0 || ctx.state.associativeArrays?.has(name); - - // If variable has integer attribute, evaluate as arithmetic and add - if (isInteger(ctx, name)) { - const existing = ctx.state.env.get(name) ?? "0"; - const existingNum = parseInt(existing, 10) || 0; - const appendNum = - parseInt(await evaluateIntegerValue(ctx, appendValue), 10) || 0; - appendValue = String(existingNum + appendNum); - ctx.state.env.set(name, appendValue); - } else if (isArray) { - // For arrays, append to element 0 (bash behavior) - appendValue = applyCaseTransform(ctx, name, appendValue); - const element0Key = `${name}_0`; - const existing = ctx.state.env.get(element0Key) ?? ""; - ctx.state.env.set(element0Key, existing + appendValue); - } else { - // Apply case transformation - appendValue = applyCaseTransform(ctx, name, appendValue); - - // Append to existing value (or set if not defined) - const existing = ctx.state.env.get(name) ?? ""; - ctx.state.env.set(name, existing + appendValue); - } - - // Mark as local if inside a function - markAsLocalIfNeeded(name); - - if (declareReadonly) { - markReadonly(ctx, name); - } - if (declareExport) { - markExported(ctx, name); - } - // If allexport is enabled (set -a), auto-export the variable - if (ctx.state.options.allexport && !removeExport) { - ctx.state.exportedVars = ctx.state.exportedVars || new Set(); - ctx.state.exportedVars.add(name); - } - continue; - } - - // Check for scalar assignment: name=value - if (arg.includes("=")) { - const eqIdx = arg.indexOf("="); - const name = arg.slice(0, eqIdx); - let value = arg.slice(eqIdx + 1); - - // Validate variable name - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { - stderr += `bash: typeset: \`${name}': not a valid identifier\n`; - exitCode = 1; - continue; - } - - // Check if variable is readonly - const error = checkReadonlyError(ctx, name); - if (error) return error; - - // Save to local scope before modifying - saveToLocalScope(name); - - // For namerefs being declared with a value, store the target name - // (don't follow the reference, just store what variable it points to) - if (declareNameref) { - // Validate the target: must be a valid variable name or array subscript, - // not a special parameter like @, *, #, etc. - // bash gives an error: "declare: `@': invalid variable name for name reference" - if (value !== "" && !/^[a-zA-Z_][a-zA-Z0-9_]*(\[.+\])?$/.test(value)) { - stderr += `bash: declare: \`${value}': invalid variable name for name reference\n`; - exitCode = 1; - continue; - } - ctx.state.env.set(name, value); - markNameref(ctx, name); - // If the target variable exists at creation time, mark this nameref as "bound". - // Bound namerefs always resolve through to their target, even if unset later. - // Unbound namerefs (target never existed) act like regular variables. - if (value !== "" && targetExists(ctx, value)) { - markNamerefBound(ctx, name); - } - markAsLocalIfNeeded(name); - if (declareReadonly) { - markReadonly(ctx, name); - } - if (declareExport) { - markExported(ctx, name); - } - continue; - } - - // Mark as integer if -i flag was used - if (declareInteger) { - markInteger(ctx, name); - } - - // Mark as lowercase if -l flag was used - if (declareLowercase) { - markLowercase(ctx, name); - } - - // Mark as uppercase if -u flag was used - if (declareUppercase) { - markUppercase(ctx, name); - } - - // If variable has integer attribute (either just declared or previously), evaluate as arithmetic - if (isInteger(ctx, name)) { - value = await evaluateIntegerValue(ctx, value); - } - - // Apply case transformation based on variable attributes - value = applyCaseTransform(ctx, name, value); - - // If this is an existing nameref (not being declared as one), write through it - if (isNameref(ctx, name)) { - const resolved = resolveNameref(ctx, name); - if (resolved && resolved !== name) { - ctx.state.env.set(resolved, value); - } else { - ctx.state.env.set(name, value); - } - } else { - ctx.state.env.set(name, value); - } - - // Mark as local if inside a function - markAsLocalIfNeeded(name); - - if (declareReadonly) { - markReadonly(ctx, name); - } - if (declareExport) { - markExported(ctx, name); - } - // If allexport is enabled (set -a), auto-export the variable - if (ctx.state.options.allexport && !removeExport) { - ctx.state.exportedVars = ctx.state.exportedVars || new Set(); - ctx.state.exportedVars.add(name); - } - } else { - // Just declare without value - const name = arg; - - // Validate variable name - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { - stderr += `bash: typeset: \`${name}': not a valid identifier\n`; - exitCode = 1; - continue; - } - - // Save to local scope before modifying - if (declareArray || declareAssoc) { - saveArrayToLocalScope(name); - } else { - saveToLocalScope(name); - } - - // For declare -n without a value, just mark as nameref - if (declareNameref) { - markNameref(ctx, name); - // If the existing value is not a valid variable name, mark as invalid nameref. - // Invalid namerefs act as regular variables (no resolution). - const existingValue = ctx.state.env.get(name); - if ( - existingValue !== undefined && - existingValue !== "" && - !/^[a-zA-Z_][a-zA-Z0-9_]*(\[.+\])?$/.test(existingValue) - ) { - markNamerefInvalid(ctx, name); - } else if (existingValue && targetExists(ctx, existingValue)) { - // If target exists at creation time, mark as bound - markNamerefBound(ctx, name); - } - markAsLocalIfNeeded(name); - if (declareReadonly) { - markReadonly(ctx, name); - } - if (declareExport) { - markExported(ctx, name); - } - continue; - } - - // Mark as integer if -i flag was used - if (declareInteger) { - markInteger(ctx, name); - } - - // Mark as lowercase if -l flag was used - if (declareLowercase) { - markLowercase(ctx, name); - } - - // Mark as uppercase if -u flag was used - if (declareUppercase) { - markUppercase(ctx, name); - } - - // Track associative array declaration - if (declareAssoc) { - // Check if this is already an indexed array - can't convert - const existingIndices = getArrayIndices(ctx, name); - if (existingIndices.length > 0) { - // bash: declare: z: cannot convert indexed to associative array - stderr += `bash: declare: ${name}: cannot convert indexed to associative array\n`; - exitCode = 1; - continue; - } - ctx.state.associativeArrays ??= new Set(); - ctx.state.associativeArrays.add(name); - } - - // Check if any array elements exist (numeric or string keys) - const hasArrayElements = Array.from(ctx.state.env.keys()).some( - (key) => - key.startsWith(`${name}_`) && !key.startsWith(`${name}__length`), - ); - if (!ctx.state.env.has(name) && !hasArrayElements) { - // If declaring as array, initialize empty array - if (declareArray || declareAssoc) { - ctx.state.env.set(`${name}__length`, "0"); - } else { - // Mark variable as declared but don't set a value - // This distinguishes "declare x" (unset) from "declare x=" (empty string) - ctx.state.declaredVars ??= new Set(); - ctx.state.declaredVars.add(name); - } - } - - // Mark as local if inside a function - markAsLocalIfNeeded(name); - - if (declareReadonly) { - markReadonly(ctx, name); - } - if (declareExport) { - markExported(ctx, name); - } - } - } - - return result("", stderr, exitCode); -} - -/** - * readonly - Declare readonly variables - * - * Usage: - * readonly NAME=value - Declare readonly variable - * readonly NAME - Mark existing variable as readonly - */ -export async function handleReadonly( - ctx: InterpreterContext, - args: string[], -): Promise { - // Parse flags - let _declareArray = false; - let _declareAssoc = false; - let _printMode = false; - const processedArgs: string[] = []; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === "-a") { - _declareArray = true; - } else if (arg === "-A") { - _declareAssoc = true; - } else if (arg === "-p") { - _printMode = true; - } else if (arg === "--") { - processedArgs.push(...args.slice(i + 1)); - break; - } else if (!arg.startsWith("-")) { - processedArgs.push(arg); - } - } - - // When called with no args (or just -p), list readonly variables - if (processedArgs.length === 0) { - let stdout = ""; - const readonlyNames = Array.from(ctx.state.readonlyVars || []).sort(); - for (const name of readonlyNames) { - const value = ctx.state.env.get(name); - if (value !== undefined) { - const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - stdout += `declare -r ${name}="${escapedValue}"\n`; - } - } - return success(stdout); - } - - for (const arg of processedArgs) { - // Check for array append syntax: readonly NAME+=(...) - const arrayAppendMatch = arg.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\+=\((.*)\)$/s, - ); - if (arrayAppendMatch) { - const name = arrayAppendMatch[1]; - const content = arrayAppendMatch[2]; - - // Check if variable is already readonly - const error = checkReadonlyError(ctx, name); - if (error) return error; - - // Parse new elements - const newElements = parseArrayElements(content); - - // Check if this is an associative array - if (ctx.state.associativeArrays?.has(name)) { - // For associative arrays, we need keyed elements: ([key]=value ...) - const entries = parseAssocArrayLiteral(content); - for (const [key, rawValue] of entries) { - const value = expandTildesInValue(ctx, rawValue); - ctx.state.env.set(`${name}_${key}`, value); - } - } else { - // For indexed arrays, get current highest index and append - const existingIndices = getArrayIndices(ctx, name); - - // If variable was a scalar, convert it to array element 0 - let startIndex = 0; - const scalarValue = ctx.state.env.get(name); - if (existingIndices.length === 0 && scalarValue !== undefined) { - // Variable exists as scalar - convert to array element 0 - ctx.state.env.set(`${name}_0`, scalarValue); - ctx.state.env.delete(name); - startIndex = 1; - } else if (existingIndices.length > 0) { - // Find highest existing index + 1 - startIndex = Math.max(...existingIndices) + 1; - } - - // Append new elements - for (let i = 0; i < newElements.length; i++) { - ctx.state.env.set( - `${name}_${startIndex + i}`, - expandTildesInValue(ctx, newElements[i]), - ); - } - - // Update length marker - const newLength = startIndex + newElements.length; - ctx.state.env.set(`${name}__length`, String(newLength)); - } - - markReadonly(ctx, name); - continue; - } - - // Check for += append syntax: readonly NAME+=value (scalar append) - const appendMatch = arg.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\+=(.*)$/); - if (appendMatch) { - const name = appendMatch[1]; - const appendValue = expandTildesInValue(ctx, appendMatch[2]); - - // Check if variable is already readonly - const error = checkReadonlyError(ctx, name); - if (error) return error; - - // Append to existing value (or set if not defined) - const existing = ctx.state.env.get(name) ?? ""; - ctx.state.env.set(name, existing + appendValue); - markReadonly(ctx, name); - continue; - } - - const assignment = parseAssignment(arg); - - // If no value provided, just mark as readonly - if (assignment.value === undefined && !assignment.isArray) { - markReadonly(ctx, assignment.name); - continue; - } - - // Set variable and mark as readonly - const error = await setVariable(ctx, assignment, { makeReadonly: true }); - if (error) { - return error; - } - } - - return OK; -} diff --git a/src/interpreter/builtins/dirs.ts b/src/interpreter/builtins/dirs.ts deleted file mode 100644 index 2b6d0950..00000000 --- a/src/interpreter/builtins/dirs.ts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * Directory Stack Builtins: pushd, popd, dirs - * - * pushd [dir] - Push directory onto stack and cd to it - * popd - Pop directory from stack and cd to previous - * dirs [-clpv] - Display directory stack - */ - -import type { ExecResult } from "../../types.js"; -import { failure, OK, success } from "../helpers/result.js"; -import type { InterpreterContext } from "../types.js"; - -/** - * Get the directory stack, initializing if needed - */ -function getStack(ctx: InterpreterContext): string[] { - ctx.state.directoryStack ??= []; - return ctx.state.directoryStack; -} - -/** - * Format a path, replacing HOME prefix with ~ - */ -function formatPath(path: string, home: string): string { - if (home && path === home) { - return "~"; - } - if (home && path.startsWith(`${home}/`)) { - return `~${path.slice(home.length)}`; - } - return path; -} - -/** - * Normalize a path by resolving . and .. - */ -function normalizePath(path: string): string { - const parts = path.split("/").filter((p) => p && p !== "."); - const result: string[] = []; - - for (const part of parts) { - if (part === "..") { - result.pop(); - } else { - result.push(part); - } - } - - return `/${result.join("/")}`; -} - -/** - * pushd - Push directory onto stack and cd to it - * - * pushd [dir] - Push current dir, cd to dir - */ -export async function handlePushd( - ctx: InterpreterContext, - args: string[], -): Promise { - const stack = getStack(ctx); - let targetDir: string | undefined; - - // Parse arguments - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === "--") { - if (i + 1 < args.length) { - if (targetDir !== undefined) { - return failure("bash: pushd: too many arguments\n", 2); - } - targetDir = args[i + 1]; - i++; - } - } else if (arg.startsWith("-") && arg !== "-") { - // Unknown option - return failure(`bash: pushd: ${arg}: invalid option\n`, 2); - } else { - if (targetDir !== undefined) { - return failure("bash: pushd: too many arguments\n", 2); - } - targetDir = arg; - } - } - - if (targetDir === undefined) { - // No dir specified - swap top two entries if possible - if (stack.length < 2) { - return failure("bash: pushd: no other directory\n", 1); - } - const top = stack[0]; - stack[0] = stack[1]; - stack[1] = top; - targetDir = stack[0]; - } - - // Resolve the target directory - let resolvedDir: string; - if (targetDir.startsWith("/")) { - resolvedDir = targetDir; - } else if (targetDir === "..") { - const parts = ctx.state.cwd.split("/").filter((p) => p); - parts.pop(); - resolvedDir = `/${parts.join("/")}`; - } else if (targetDir === ".") { - resolvedDir = ctx.state.cwd; - } else if (targetDir.startsWith("~")) { - const home = ctx.state.env.get("HOME") || "/"; - resolvedDir = home + targetDir.slice(1); - } else { - resolvedDir = `${ctx.state.cwd}/${targetDir}`; - } - - // Normalize the path - resolvedDir = normalizePath(resolvedDir); - - // Check if directory exists - try { - const stat = await ctx.fs.stat(resolvedDir); - if (!stat.isDirectory) { - return failure(`bash: pushd: ${targetDir}: Not a directory\n`, 1); - } - } catch { - return failure(`bash: pushd: ${targetDir}: No such file or directory\n`, 1); - } - - // Push current directory onto stack - stack.unshift(ctx.state.cwd); - - // Change to new directory - ctx.state.previousDir = ctx.state.cwd; - ctx.state.cwd = resolvedDir; - ctx.state.env.set("PWD", resolvedDir); - ctx.state.env.set("OLDPWD", ctx.state.previousDir); - - // Output the stack (pushd DOES do tilde substitution) - const home = ctx.state.env.get("HOME") || ""; - const output = `${[resolvedDir, ...stack].map((p) => formatPath(p, home)).join(" ")}\n`; - - return success(output); -} - -/** - * popd - Pop directory from stack and cd to it - */ -export function handlePopd( - ctx: InterpreterContext, - args: string[], -): ExecResult { - const stack = getStack(ctx); - - // Parse arguments - for (const arg of args) { - if (arg === "--") { - continue; - } - if (arg.startsWith("-") && arg !== "-") { - return failure(`bash: popd: ${arg}: invalid option\n`, 2); - } - // popd doesn't take positional arguments - return failure("bash: popd: too many arguments\n", 2); - } - - if (stack.length === 0) { - return failure("bash: popd: directory stack empty\n", 1); - } - - // Pop the top entry and cd to it - const newDir = stack.shift(); - if (!newDir) { - return failure("bash: popd: directory stack empty\n", 1); - } - - // Change to the popped directory - ctx.state.previousDir = ctx.state.cwd; - ctx.state.cwd = newDir; - ctx.state.env.set("PWD", newDir); - ctx.state.env.set("OLDPWD", ctx.state.previousDir); - - // Output the stack (popd DOES do tilde substitution) - const home = ctx.state.env.get("HOME") || ""; - const output = `${[newDir, ...stack].map((p) => formatPath(p, home)).join(" ")}\n`; - - return success(output); -} - -/** - * dirs - Display directory stack - * - * dirs [-clpv] - * -c: Clear the stack - * -l: Long format (no tilde substitution) - * -p: One entry per line - * -v: One entry per line with index numbers - */ -export function handleDirs( - ctx: InterpreterContext, - args: string[], -): ExecResult { - const stack = getStack(ctx); - - let clearStack = false; - let longFormat = false; - let perLine = false; - let withNumbers = false; - - // Parse arguments - for (const arg of args) { - if (arg === "--") { - continue; - } - if (arg.startsWith("-")) { - for (const flag of arg.slice(1)) { - if (flag === "c") clearStack = true; - else if (flag === "l") longFormat = true; - else if (flag === "p") perLine = true; - else if (flag === "v") { - perLine = true; - withNumbers = true; - } else { - return failure(`bash: dirs: -${flag}: invalid option\n`, 2); - } - } - } else { - // dirs doesn't take positional arguments - return failure("bash: dirs: too many arguments\n", 1); - } - } - - if (clearStack) { - ctx.state.directoryStack = []; - return OK; - } - - // Build the stack display (current dir + stack) - const fullStack = [ctx.state.cwd, ...stack]; - const home = ctx.state.env.get("HOME") || ""; - - let output: string; - if (withNumbers) { - output = fullStack - .map((p, i) => { - const path = longFormat ? p : formatPath(p, home); - return ` ${i} ${path}`; - }) - .join("\n"); - output += "\n"; - } else if (perLine) { - output = - fullStack.map((p) => (longFormat ? p : formatPath(p, home))).join("\n") + - "\n"; - } else { - output = - fullStack.map((p) => (longFormat ? p : formatPath(p, home))).join(" ") + - "\n"; - } - - return success(output); -} diff --git a/src/interpreter/builtins/eval.test.ts b/src/interpreter/builtins/eval.test.ts deleted file mode 100644 index 12860d68..00000000 --- a/src/interpreter/builtins/eval.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("eval builtin", () => { - describe("basic evaluation", () => { - it("should execute a simple command", async () => { - const env = new Bash(); - const result = await env.exec('eval "echo hello"'); - expect(result.stdout).toBe("hello\n"); - expect(result.exitCode).toBe(0); - }); - - it("should execute multiple words as single command", async () => { - const env = new Bash(); - const result = await env.exec("eval echo hello world"); - expect(result.stdout).toBe("hello world\n"); - }); - - it("should return 0 for empty argument", async () => { - const env = new Bash(); - const result = await env.exec('eval ""'); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should return 0 for no arguments", async () => { - const env = new Bash(); - const result = await env.exec("eval"); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - }); - }); - - describe("variable expansion", () => { - it("should expand variables before execution", async () => { - const env = new Bash(); - const result = await env.exec(` - cmd="echo hello" - eval $cmd - `); - expect(result.stdout).toBe("hello\n"); - }); - - it("should allow dynamic variable names", async () => { - const env = new Bash(); - const result = await env.exec(` - name="FOO" - FOO="bar" - eval "echo \\$$name" - `); - expect(result.stdout).toBe("bar\n"); - }); - - it("should allow setting variables dynamically", async () => { - const env = new Bash(); - const result = await env.exec(` - name="MYVAR" - eval "$name=hello" - echo $MYVAR - `); - expect(result.stdout).toBe("hello\n"); - }); - }); - - describe("command construction", () => { - it("should handle command from array-like variables", async () => { - const env = new Bash(); - const result = await env.exec(` - args="a b c" - eval "for x in $args; do echo item: \\$x; done" - `); - expect(result.stdout).toBe("item: a\nitem: b\nitem: c\n"); - }); - - it("should execute piped commands", async () => { - const env = new Bash(); - const result = await env.exec('eval "echo hello | tr a-z A-Z"'); - expect(result.stdout).toBe("HELLO\n"); - }); - - it("should handle command substitution", async () => { - const env = new Bash(); - const result = await env.exec('eval "echo $(echo nested)"'); - expect(result.stdout).toBe("nested\n"); - }); - }); - - describe("exit codes", () => { - it("should return exit code of executed command", async () => { - const env = new Bash(); - const result = await env.exec("eval false"); - expect(result.exitCode).toBe(1); - }); - - it("should return exit code of last command", async () => { - const env = new Bash(); - const result = await env.exec('eval "true; false; true"'); - expect(result.exitCode).toBe(0); - }); - - it("should return 1 for syntax errors", async () => { - const env = new Bash(); - // Use "for do done" which is a syntax error (missing variable name) - // Bash returns exit code 1 for eval syntax errors - const result = await env.exec('eval "for do done"'); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Parse error"); - }); - }); - - describe("scope and environment", () => { - it("should execute in current environment", async () => { - const env = new Bash(); - const result = await env.exec(` - FOO=original - eval "FOO=modified" - echo $FOO - `); - expect(result.stdout).toBe("modified\n"); - }); - - it("should have access to functions", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { echo "called"; } - eval "myfunc" - `); - expect(result.stdout).toBe("called\n"); - }); - - it("should define functions that persist", async () => { - const env = new Bash(); - const result = await env.exec(` - eval 'greet() { echo "hello $1"; }' - greet world - `); - expect(result.stdout).toBe("hello world\n"); - }); - }); - - describe("quoting and escaping", () => { - it("should handle single quotes", async () => { - const env = new Bash(); - const result = await env.exec(`eval "echo 'single quoted'"`); - expect(result.stdout).toBe("single quoted\n"); - }); - - it("should handle double quotes", async () => { - const env = new Bash(); - const result = await env.exec(`eval 'echo "double quoted"'`); - expect(result.stdout).toBe("double quoted\n"); - }); - - it("should handle escaped characters", async () => { - const env = new Bash(); - const result = await env.exec('eval "echo hello\\\\nworld"'); - // The \\n should be interpreted as literal backslash-n - expect(result.stdout).toContain("hello"); - }); - }); -}); diff --git a/src/interpreter/builtins/eval.ts b/src/interpreter/builtins/eval.ts deleted file mode 100644 index 39bf8c81..00000000 --- a/src/interpreter/builtins/eval.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * eval - Execute arguments as a shell command - * - * Concatenates all arguments and executes them as a shell command - * in the current environment (variables persist after eval). - */ - -import { type ParseException, parse } from "../../parser/parser.js"; -import type { ExecResult } from "../../types.js"; -import { - BreakError, - ContinueError, - ExitError, - ReturnError, -} from "../errors.js"; -import { failure, OK } from "../helpers/result.js"; -import type { InterpreterContext } from "../types.js"; - -export async function handleEval( - ctx: InterpreterContext, - args: string[], - stdin?: string, -): Promise { - // Handle options like bash does: - // -- ends option processing - // - alone is a plain argument - // -x (any other option) is invalid - let evalArgs = args; - if (evalArgs.length > 0) { - const first = evalArgs[0]; - if (first === "--") { - evalArgs = evalArgs.slice(1); - } else if (first.startsWith("-") && first !== "-" && first.length > 1) { - // Invalid option like -z, -x, etc. - return failure( - `bash: eval: ${first}: invalid option\neval: usage: eval [arg ...]\n`, - 2, - ); - } - } - - if (evalArgs.length === 0) { - return OK; - } - - // Concatenate all arguments with spaces (like bash does) - const command = evalArgs.join(" "); - - if (command.trim() === "") { - return OK; - } - - // Save and set groupStdin for piped eval commands - // This allows stdin from the pipeline to flow to commands within eval - const savedGroupStdin = ctx.state.groupStdin; - const effectiveStdin = stdin ?? ctx.state.groupStdin; - if (effectiveStdin !== undefined) { - ctx.state.groupStdin = effectiveStdin; - } - - try { - // Parse and execute in the current environment - const ast = parse(command); - return await ctx.executeScript(ast); - } catch (error) { - // Rethrow control flow errors so they propagate to outer loops/functions - if ( - error instanceof BreakError || - error instanceof ContinueError || - error instanceof ReturnError || - error instanceof ExitError - ) { - throw error; - } - if ((error as ParseException).name === "ParseException") { - return failure(`bash: eval: ${(error as Error).message}\n`); - } - throw error; - } finally { - // Restore groupStdin - ctx.state.groupStdin = savedGroupStdin; - } -} diff --git a/src/interpreter/builtins/exit.test.ts b/src/interpreter/builtins/exit.test.ts deleted file mode 100644 index 731df360..00000000 --- a/src/interpreter/builtins/exit.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("exit builtin", () => { - describe("basic exit", () => { - it("should exit with code 0 by default", async () => { - const env = new Bash(); - const result = await env.exec("exit"); - expect(result.exitCode).toBe(0); - }); - - it("should exit with specified code", async () => { - const env = new Bash(); - const result = await env.exec("exit 42"); - expect(result.exitCode).toBe(42); - }); - - it("should exit with code 1", async () => { - const env = new Bash(); - const result = await env.exec("exit 1"); - expect(result.exitCode).toBe(1); - }); - - it("should stop execution after exit", async () => { - const env = new Bash(); - const result = await env.exec(` - echo before - exit 0 - echo after - `); - expect(result.stdout).toBe("before\n"); - expect(result.stdout).not.toContain("after"); - }); - }); - - describe("exit code modulo 256", () => { - it("should wrap exit code 256 to 0", async () => { - const env = new Bash(); - const result = await env.exec("exit 256"); - expect(result.exitCode).toBe(0); - }); - - it("should wrap exit code 257 to 1", async () => { - const env = new Bash(); - const result = await env.exec("exit 257"); - expect(result.exitCode).toBe(1); - }); - - it("should handle negative exit codes", async () => { - const env = new Bash(); - const result = await env.exec("exit -1"); - expect(result.exitCode).toBe(255); - }); - }); - - describe("exit in different contexts", () => { - it("should exit from function", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - echo "in func" - exit 5 - echo "never" - } - myfunc - echo "also never" - `); - expect(result.stdout).toBe("in func\n"); - expect(result.exitCode).toBe(5); - }); - - it("should exit from loop", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3; do - echo $i - exit 10 - done - echo "never" - `); - expect(result.stdout).toBe("1\n"); - expect(result.exitCode).toBe(10); - }); - - it("should exit from if block", async () => { - const env = new Bash(); - const result = await env.exec(` - if true; then - echo "in if" - exit 7 - echo "never" - fi - echo "also never" - `); - expect(result.stdout).toBe("in if\n"); - expect(result.exitCode).toBe(7); - }); - }); - - describe("exit uses last exit code", () => { - it("should use last command exit code when no argument", async () => { - const env = new Bash(); - const result = await env.exec(` - false - exit - `); - expect(result.exitCode).toBe(1); - }); - - it("should use success code after true", async () => { - const env = new Bash(); - const result = await env.exec(` - true - exit - `); - expect(result.exitCode).toBe(0); - }); - }); - - describe("exit error handling", () => { - it("should handle non-numeric argument", async () => { - const env = new Bash(); - const result = await env.exec("exit abc"); - expect(result.stderr).toContain("numeric argument required"); - expect(result.exitCode).toBe(2); - }); - }); -}); diff --git a/src/interpreter/builtins/exit.ts b/src/interpreter/builtins/exit.ts deleted file mode 100644 index fd6d0654..00000000 --- a/src/interpreter/builtins/exit.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * exit - Exit shell builtin - */ - -import { ExitError } from "../errors.js"; -import type { InterpreterContext } from "../types.js"; - -export function handleExit(ctx: InterpreterContext, args: string[]): never { - let exitCode: number; - let stderr = ""; - - if (args.length === 0) { - // Use last command's exit code when no argument given - exitCode = ctx.state.lastExitCode; - } else { - const arg = args[0]; - const parsed = Number.parseInt(arg, 10); - // Empty string or non-numeric is an error - if (arg === "" || Number.isNaN(parsed) || !/^-?\d+$/.test(arg)) { - stderr = `bash: exit: ${arg}: numeric argument required\n`; - exitCode = 2; - } else { - // Exit codes are modulo 256 (wrap around) - exitCode = ((parsed % 256) + 256) % 256; - } - } - - throw new ExitError(exitCode, "", stderr); -} diff --git a/src/interpreter/builtins/export.test.ts b/src/interpreter/builtins/export.test.ts deleted file mode 100644 index d5ef70c3..00000000 --- a/src/interpreter/builtins/export.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("export builtin", () => { - describe("setting variables", () => { - it("should set a variable with export NAME=value (within same exec)", async () => { - const env = new Bash(); - const result = await env.exec("export FOO=bar; echo $FOO"); - expect(result.stdout).toBe("bar\n"); - }); - - it("should set multiple variables", async () => { - const env = new Bash(); - const result = await env.exec("export FOO=bar BAZ=qux; echo $FOO $BAZ"); - expect(result.stdout).toBe("bar qux\n"); - }); - - it("should handle value with equals sign", async () => { - const env = new Bash(); - const result = await env.exec( - "export URL=http://example.com?foo=bar; echo $URL", - ); - expect(result.stdout).toBe("http://example.com?foo=bar\n"); - }); - - it("should create empty variable when NAME has no value", async () => { - const env = new Bash(); - const result = await env.exec( - 'export EMPTY; test -z "$EMPTY" && echo empty', - ); - expect(result.stdout).toBe("empty\n"); - }); - - it("should preserve existing variable value with export NAME", async () => { - const env = new Bash({ env: { EXISTING: "value" } }); - const result = await env.exec("export EXISTING; echo $EXISTING"); - expect(result.stdout).toBe("value\n"); - }); - - it("export does not persist across exec calls", async () => { - const env = new Bash(); - await env.exec("export FOO=bar"); - // Each exec is a new shell - FOO is not set - const result = await env.exec("echo $FOO"); - expect(result.stdout).toBe("\n"); - }); - }); - - describe("listing variables", () => { - it("should list all exported variables with no args", async () => { - const env = new Bash({ env: { FOO: "bar", BAZ: "qux" } }); - const result = await env.exec("export"); - expect(result.stdout).toContain('declare -x FOO="bar"'); - expect(result.stdout).toContain('declare -x BAZ="qux"'); - }); - - it("should list all exported variables with -p", async () => { - const env = new Bash({ env: { FOO: "bar" } }); - const result = await env.exec("export -p"); - expect(result.stdout).toContain('declare -x FOO="bar"'); - }); - - it("should list newly exported variables within same exec", async () => { - const env = new Bash(); - const result = await env.exec('export MSG="it\'s working"; export'); - expect(result.stdout).toContain("it's working"); - }); - - it("should not list aliases", async () => { - const env = new Bash({ env: { FOO: "bar" } }); - const result = await env.exec("alias ll='ls -la'; export"); - expect(result.stdout).not.toContain("BASH_ALIAS"); - expect(result.stdout).toContain("FOO"); - }); - }); - - describe("un-exporting with -n", () => { - it("should remove export attribute but keep value with -n", async () => { - const env = new Bash({ env: { FOO: "bar" } }); - // export -n removes the export attribute but keeps the value - const result = await env.exec('export -n FOO; echo "$FOO"'); - expect(result.stdout).toBe("bar\n"); - }); - - it("should remove export attribute from multiple variables with -n", async () => { - const env = new Bash({ env: { FOO: "bar", BAZ: "qux" } }); - // export -n removes export attribute but keeps values - const result = await env.exec('export -n FOO BAZ; echo "$FOO $BAZ"'); - expect(result.stdout).toBe("bar qux\n"); - }); - }); - - describe("variable usage", () => { - it("exported variable should be available in same exec", async () => { - const env = new Bash(); - const result = await env.exec( - "export GREETING=hello; echo $GREETING world", - ); - expect(result.stdout).toBe("hello world\n"); - }); - - it("exported variable should be available in subshell", async () => { - const env = new Bash(); - const result = await env.exec("export FOO=bar; (echo $FOO)"); - expect(result.stdout).toBe("bar\n"); - }); - - it("should work with conditional", async () => { - const env = new Bash(); - const result = await env.exec( - 'export DEBUG=1; [ "$DEBUG" = "1" ] && echo debug_on', - ); - expect(result.stdout).toBe("debug_on\n"); - }); - - it("initial env vars are available in every exec", async () => { - const env = new Bash({ env: { SHARED: "value" } }); - const result1 = await env.exec("echo $SHARED"); - const result2 = await env.exec("echo $SHARED"); - expect(result1.stdout).toBe("value\n"); - expect(result2.stdout).toBe("value\n"); - }); - }); -}); diff --git a/src/interpreter/builtins/export.ts b/src/interpreter/builtins/export.ts deleted file mode 100644 index 71b8da68..00000000 --- a/src/interpreter/builtins/export.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * export - Set environment variables builtin - * - * Usage: - * export - List all exported variables - * export -p - List all exported variables (same as no args) - * export NAME=value - Set and export variable - * export NAME+=value - Append value and export variable - * export NAME - Export existing variable (or create empty) - * export -n NAME - Un-export variable (remove from env) - */ - -import type { ExecResult } from "../../types.js"; -import { markExported, unmarkExported } from "../helpers/readonly.js"; -import { OK, result, success } from "../helpers/result.js"; -import { expandTildesInValue } from "../helpers/tilde.js"; -import type { InterpreterContext } from "../types.js"; - -export function handleExport( - ctx: InterpreterContext, - args: string[], -): ExecResult { - // Handle -n flag for un-export - let unexport = false; - const processedArgs: string[] = []; - - for (const arg of args) { - if (arg === "-n") { - unexport = true; - } else if (arg === "-p") { - } else if (arg === "--") { - } else { - processedArgs.push(arg); - } - } - - // No args or just -p: list all exported variables - if (processedArgs.length === 0 && !unexport) { - let stdout = ""; - // Only list variables that are actually exported - const exportedVars = ctx.state.exportedVars ?? new Set(); - const sortedNames = Array.from(exportedVars).sort(); - - for (const name of sortedNames) { - const value = ctx.state.env.get(name); - if (value !== undefined) { - // Quote the value with double quotes, escaping backslashes and double quotes - const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - stdout += `declare -x ${name}="${escapedValue}"\n`; - } - } - return success(stdout); - } - - // Handle un-export: remove export attribute but keep variable value - // In bash, `export -n name=value` sets the value AND removes export attribute - if (unexport) { - for (const arg of processedArgs) { - let name: string; - let value: string | undefined; - - if (arg.includes("=")) { - const eqIdx = arg.indexOf("="); - name = arg.slice(0, eqIdx); - value = expandTildesInValue(ctx, arg.slice(eqIdx + 1)); - // Set the value - ctx.state.env.set(name, value); - } else { - name = arg; - } - // Remove export attribute without deleting the variable - unmarkExported(ctx, name); - } - return OK; - } - - // Process each argument - let stderr = ""; - let exitCode = 0; - - for (const arg of processedArgs) { - let name: string; - let value: string | undefined; - let isAppend = false; - - // Check for += append syntax: export NAME+=value - const appendMatch = arg.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\+=(.*)$/); - if (appendMatch) { - name = appendMatch[1]; - value = expandTildesInValue(ctx, appendMatch[2]); - isAppend = true; - } else if (arg.includes("=")) { - // export NAME=value - const eqIdx = arg.indexOf("="); - name = arg.slice(0, eqIdx); - value = expandTildesInValue(ctx, arg.slice(eqIdx + 1)); - } else { - // export NAME (without value) - name = arg; - } - - // Validate variable name: must start with letter/underscore, contain only alphanumeric/_ - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { - stderr += `bash: export: \`${arg}': not a valid identifier\n`; - exitCode = 1; - continue; - } - - if (value !== undefined) { - if (isAppend) { - // Append to existing value (or set if not defined) - const existing = ctx.state.env.get(name) ?? ""; - ctx.state.env.set(name, existing + value); - } else { - ctx.state.env.set(name, value); - } - } else { - // If variable doesn't exist, create it as empty - if (!ctx.state.env.has(name)) { - ctx.state.env.set(name, ""); - } - } - // Mark the variable as exported - markExported(ctx, name); - } - - return result("", stderr, exitCode); -} diff --git a/src/interpreter/builtins/getopts.ts b/src/interpreter/builtins/getopts.ts deleted file mode 100644 index 01a0317a..00000000 --- a/src/interpreter/builtins/getopts.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * getopts - Parse positional parameters as options - * - * getopts optstring name [arg...] - * - * Parses options from positional parameters (or provided args). - * - optstring: string of valid option characters - * - If a character is followed by ':', it requires an argument - * - If optstring starts with ':', silent error reporting mode - * - name: variable to store the current option - * - OPTARG: set to the option argument (if any) - * - OPTIND: index of next argument to process (starts at 1) - * - * Returns 0 if option found, 1 if end of options or error. - */ - -import type { ExecResult } from "../../types.js"; -import { failure } from "../helpers/result.js"; -import type { InterpreterContext } from "../types.js"; - -export function handleGetopts( - ctx: InterpreterContext, - args: string[], -): ExecResult { - // Need at least optstring and name - if (args.length < 2) { - return failure("bash: getopts: usage: getopts optstring name [arg ...]\n"); - } - - const optstring = args[0]; - const varName = args[1]; - - // Check if variable name is valid (must be a valid identifier) - const invalidVarName = !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(varName); - - // Determine if silent mode (optstring starts with ':') - const silentMode = optstring.startsWith(":"); - const actualOptstring = silentMode ? optstring.slice(1) : optstring; - - // Get arguments to parse - either explicit args or positional parameters - let argsToProcess: string[]; - if (args.length > 2) { - // Explicit arguments provided - argsToProcess = args.slice(2); - } else { - // Use positional parameters - const paramCount = Number.parseInt(ctx.state.env.get("#") || "0", 10); - argsToProcess = []; - for (let i = 1; i <= paramCount; i++) { - argsToProcess.push(ctx.state.env.get(String(i)) || ""); - } - } - - // Get current OPTIND (1-based, default 1) - let optind = Number.parseInt(ctx.state.env.get("OPTIND") || "1", 10); - if (optind < 1) { - optind = 1; - } - - // Get the "char index" within the current argument for combined options like -abc - // We store this in a special internal variable - const charIndex = Number.parseInt( - ctx.state.env.get("__GETOPTS_CHARINDEX") || "0", - 10, - ); - - // Clear OPTARG - ctx.state.env.set("OPTARG", ""); - - // Check if we've exhausted all arguments - if (optind > argsToProcess.length) { - if (!invalidVarName) { - ctx.state.env.set(varName, "?"); - } - // When returning because OPTIND is past all args, bash sets OPTIND to args.length + 1 - ctx.state.env.set("OPTIND", String(argsToProcess.length + 1)); - ctx.state.env.set("__GETOPTS_CHARINDEX", "0"); - return { exitCode: invalidVarName ? 2 : 1, stdout: "", stderr: "" }; - } - - // Get current argument (0-indexed in array, but OPTIND is 1-based) - const currentArg = argsToProcess[optind - 1]; - - // Check if this is an option argument (starts with -) - if (!currentArg || currentArg === "-" || !currentArg.startsWith("-")) { - // Not an option - end of options - if (!invalidVarName) { - ctx.state.env.set(varName, "?"); - } - return { exitCode: invalidVarName ? 2 : 1, stdout: "", stderr: "" }; - } - - // Check for -- (end of options marker) - if (currentArg === "--") { - ctx.state.env.set("OPTIND", String(optind + 1)); - ctx.state.env.set("__GETOPTS_CHARINDEX", "0"); - if (!invalidVarName) { - ctx.state.env.set(varName, "?"); - } - return { exitCode: invalidVarName ? 2 : 1, stdout: "", stderr: "" }; - } - - // Get the option character to process - // charIndex 0 means we're starting a new argument, so skip the leading '-' - const startIndex = charIndex === 0 ? 1 : charIndex; - const optChar = currentArg[startIndex]; - - if (!optChar) { - // No more characters in this argument, move to next - ctx.state.env.set("OPTIND", String(optind + 1)); - ctx.state.env.set("__GETOPTS_CHARINDEX", "0"); - // Recursively call to process next argument - return handleGetopts(ctx, args); - } - - // Check if this option is valid - const optIndex = actualOptstring.indexOf(optChar); - if (optIndex === -1) { - // Invalid option - let stderrMsg = ""; - if (!silentMode) { - stderrMsg = `bash: illegal option -- ${optChar}\n`; - } else { - ctx.state.env.set("OPTARG", optChar); - } - if (!invalidVarName) { - ctx.state.env.set(varName, "?"); - } - - // Move to next character or next argument - if (startIndex + 1 < currentArg.length) { - ctx.state.env.set("__GETOPTS_CHARINDEX", String(startIndex + 1)); - ctx.state.env.set("OPTIND", String(optind)); // Always set OPTIND - } else { - ctx.state.env.set("OPTIND", String(optind + 1)); - ctx.state.env.set("__GETOPTS_CHARINDEX", "0"); - } - - return { exitCode: invalidVarName ? 2 : 0, stdout: "", stderr: stderrMsg }; - } - - // Check if this option requires an argument - const requiresArg = - optIndex + 1 < actualOptstring.length && - actualOptstring[optIndex + 1] === ":"; - - if (requiresArg) { - // Option requires an argument - // Check if there are more characters in the current arg (e.g., -cVALUE) - if (startIndex + 1 < currentArg.length) { - // Rest of current arg is the argument - ctx.state.env.set("OPTARG", currentArg.slice(startIndex + 1)); - ctx.state.env.set("OPTIND", String(optind + 1)); - ctx.state.env.set("__GETOPTS_CHARINDEX", "0"); - } else { - // Next argument is the option argument - if (optind >= argsToProcess.length) { - // No argument provided - let stderrMsg = ""; - if (!silentMode) { - stderrMsg = `bash: option requires an argument -- ${optChar}\n`; - if (!invalidVarName) { - ctx.state.env.set(varName, "?"); - } - } else { - ctx.state.env.set("OPTARG", optChar); - if (!invalidVarName) { - ctx.state.env.set(varName, ":"); - } - } - ctx.state.env.set("OPTIND", String(optind + 1)); - ctx.state.env.set("__GETOPTS_CHARINDEX", "0"); - return { - exitCode: invalidVarName ? 2 : 0, - stdout: "", - stderr: stderrMsg, - }; - } - ctx.state.env.set("OPTARG", argsToProcess[optind]); // Next arg (0-indexed: optind) - ctx.state.env.set("OPTIND", String(optind + 2)); - ctx.state.env.set("__GETOPTS_CHARINDEX", "0"); - } - } else { - // Option doesn't require an argument - // Move to next character or next argument - if (startIndex + 1 < currentArg.length) { - ctx.state.env.set("__GETOPTS_CHARINDEX", String(startIndex + 1)); - ctx.state.env.set("OPTIND", String(optind)); // Always set OPTIND - } else { - ctx.state.env.set("OPTIND", String(optind + 1)); - ctx.state.env.set("__GETOPTS_CHARINDEX", "0"); - } - } - - // Set the variable to the option character (if valid variable name) - if (!invalidVarName) { - ctx.state.env.set(varName, optChar); - } - - return { exitCode: invalidVarName ? 2 : 0, stdout: "", stderr: "" }; -} diff --git a/src/interpreter/builtins/hash.ts b/src/interpreter/builtins/hash.ts deleted file mode 100644 index bcd9faea..00000000 --- a/src/interpreter/builtins/hash.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * hash - Manage the hash table of remembered command locations - * - * hash [-lr] [-p pathname] [-dt] [name ...] - * - * Hash maintains a hash table of recently executed commands for faster lookup. - * - * Options: - * (no args) Display the hash table - * name Add name to the hash table (look up in PATH) - * -r Clear the hash table - * -d name Remove name from the hash table - * -l Display in a format that can be reused as input - * -p path Use path as the full pathname for name (hash -p /path name) - * -t name Print the remembered location of name - */ - -import type { ExecResult } from "../../types.js"; -import { failure, OK, success } from "../helpers/result.js"; -import type { InterpreterContext } from "../types.js"; - -export async function handleHash( - ctx: InterpreterContext, - args: string[], -): Promise { - // Initialize hash table if needed - if (!ctx.state.hashTable) { - ctx.state.hashTable = new Map(); - } - - // Parse options - let clearTable = false; - let deleteMode = false; - let listMode = false; - let pathMode = false; - let showPath = false; - let pathname = ""; - const names: string[] = []; - - let i = 0; - while (i < args.length) { - const arg = args[i]; - if (arg === "--") { - i++; - // Remaining args are names - names.push(...args.slice(i)); - break; - } - if (arg === "-r") { - clearTable = true; - i++; - } else if (arg === "-d") { - deleteMode = true; - i++; - } else if (arg === "-l") { - listMode = true; - i++; - } else if (arg === "-t") { - showPath = true; - i++; - } else if (arg === "-p") { - pathMode = true; - i++; - if (i >= args.length) { - return failure("bash: hash: -p: option requires an argument\n", 1); - } - pathname = args[i]; - i++; - } else if (arg.startsWith("-") && arg.length > 1) { - // Handle combined options like -rt - for (const char of arg.slice(1)) { - if (char === "r") { - clearTable = true; - } else if (char === "d") { - deleteMode = true; - } else if (char === "l") { - listMode = true; - } else if (char === "t") { - showPath = true; - } else if (char === "p") { - return failure("bash: hash: -p: option requires an argument\n", 1); - } else { - return failure(`bash: hash: -${char}: invalid option\n`, 1); - } - } - i++; - } else { - names.push(arg); - i++; - } - } - - // Handle -r (clear table) - if (clearTable) { - // bash allows extra args with -r (just ignores them) - // This is marked as a "BUG" in the spec tests, but we match bash behavior - ctx.state.hashTable.clear(); - return OK; - } - - // Handle -d (delete from table) - if (deleteMode) { - if (names.length === 0) { - return failure("bash: hash: -d: option requires an argument\n", 1); - } - let hasError = false; - let stderr = ""; - for (const name of names) { - if (!ctx.state.hashTable.has(name)) { - stderr += `bash: hash: ${name}: not found\n`; - hasError = true; - } else { - ctx.state.hashTable.delete(name); - } - } - if (hasError) { - return failure(stderr, 1); - } - return OK; - } - - // Handle -t (show path for names) - if (showPath) { - if (names.length === 0) { - return failure("bash: hash: -t: option requires an argument\n", 1); - } - let stdout = ""; - let hasError = false; - let stderr = ""; - for (const name of names) { - const cachedPath = ctx.state.hashTable.get(name); - if (cachedPath) { - // If multiple names, show "name\tpath" format - if (names.length > 1) { - stdout += `${name}\t${cachedPath}\n`; - } else { - stdout += `${cachedPath}\n`; - } - } else { - stderr += `bash: hash: ${name}: not found\n`; - hasError = true; - } - } - if (hasError) { - return { exitCode: 1, stdout, stderr }; - } - return success(stdout); - } - - // Handle -p (associate pathname with name) - if (pathMode) { - if (names.length === 0) { - return failure( - "bash: hash: usage: hash [-lr] [-p pathname] [-dt] [name ...]\n", - 1, - ); - } - // Associate the pathname with the first name - const name = names[0]; - ctx.state.hashTable.set(name, pathname); - return OK; - } - - // No args - display hash table - if (names.length === 0) { - if (ctx.state.hashTable.size === 0) { - return success("hash: hash table empty\n"); - } - - let stdout = ""; - if (listMode) { - // Reusable format: builtin hash -p /path/to/cmd cmd - for (const [name, path] of ctx.state.hashTable) { - stdout += `builtin hash -p ${path} ${name}\n`; - } - } else { - // Default format (bash style: hits command table) - stdout = "hits\tcommand\n"; - for (const [, path] of ctx.state.hashTable) { - // We don't track hits, so just show 1 - stdout += ` 1\t${path}\n`; - } - } - return success(stdout); - } - - // Add names to hash table (look up in PATH) - let hasError = false; - let stderr = ""; - const pathEnv = ctx.state.env.get("PATH") || "/usr/bin:/bin"; - const pathDirs = pathEnv.split(":"); - - for (const name of names) { - // Skip if name contains / (it's a path, not looked up in PATH) - if (name.includes("/")) { - stderr += `bash: hash: ${name}: cannot use / in name\n`; - hasError = true; - continue; - } - - // Search PATH for the command - let found = false; - for (const dir of pathDirs) { - if (!dir) continue; - const fullPath = `${dir}/${name}`; - if (await ctx.fs.exists(fullPath)) { - ctx.state.hashTable.set(name, fullPath); - found = true; - break; - } - } - - if (!found) { - stderr += `bash: hash: ${name}: not found\n`; - hasError = true; - } - } - - if (hasError) { - return failure(stderr, 1); - } - return OK; -} diff --git a/src/interpreter/builtins/help.ts b/src/interpreter/builtins/help.ts deleted file mode 100644 index 0e478aea..00000000 --- a/src/interpreter/builtins/help.ts +++ /dev/null @@ -1,903 +0,0 @@ -/** - * help - Display helpful information about builtin commands - * - * Usage: help [-s] [pattern ...] - * - * If PATTERN is specified, gives detailed help on all commands matching PATTERN, - * otherwise a list of the builtins is printed. The -s option restricts the output - * for each builtin command matching PATTERN to a short usage synopsis. - */ - -import { createUserRegex } from "../../regex/index.js"; -import type { ExecResult } from "../../types.js"; -import { failure, success } from "../helpers/result.js"; -import type { InterpreterContext } from "../types.js"; - -/** - * Builtin help information: [synopsis, description] - * The synopsis is the short form shown with -s and in the list. - * The description is the detailed help text. - */ -const BUILTIN_HELP = new Map([ - [ - ":", - [ - ": [arguments]", - `Null command. - No effect; the command does nothing. - Exit Status: - Always succeeds.`, - ], - ], - [ - ".", - [ - ". filename [arguments]", - `Execute commands from a file in the current shell. - Read and execute commands from FILENAME in the current shell. - The entries in $PATH are used to find the directory containing FILENAME. - Exit Status: - Returns the status of the last command executed in FILENAME.`, - ], - ], - [ - "[", - [ - "[ arg... ]", - `Evaluate conditional expression. - This is a synonym for the "test" builtin, but the last argument must - be a literal \`]', to match the opening \`['.`, - ], - ], - [ - "alias", - [ - "alias [-p] [name[=value] ... ]", - `Define or display aliases. - Without arguments, \`alias' prints the list of aliases in the reusable - form \`alias NAME=VALUE' on standard output. - Exit Status: - alias returns true unless a NAME is supplied for which no alias has been - defined.`, - ], - ], - [ - "bg", - [ - "bg [job_spec ...]", - `Move jobs to the background. - Place the jobs identified by each JOB_SPEC in the background, as if they - had been started with \`&'.`, - ], - ], - [ - "break", - [ - "break [n]", - `Exit for, while, or until loops. - Exit a FOR, WHILE or UNTIL loop. If N is specified, break N enclosing - loops. - Exit Status: - The exit status is 0 unless N is not greater than or equal to 1.`, - ], - ], - [ - "builtin", - [ - "builtin [shell-builtin [arg ...]]", - `Execute shell builtins. - Execute SHELL-BUILTIN with arguments ARGs without performing command - lookup. This is useful when you wish to reimplement a shell builtin - as a shell function, but need to execute the builtin within the function. - Exit Status: - Returns the exit status of SHELL-BUILTIN, or false if SHELL-BUILTIN is - not a shell builtin.`, - ], - ], - [ - "caller", - [ - "caller [expr]", - `Return the context of the current subroutine call. - Without EXPR, returns "$line $filename". With EXPR, returns - "$line $subroutine $filename"; this extra information can be used to - provide a stack trace. - Exit Status: - Returns 0 unless the shell is not executing a subroutine call or - EXPR is invalid.`, - ], - ], - [ - "cd", - [ - "cd [-L|-P] [dir]", - `Change the shell working directory. - Change the current directory to DIR. The default DIR is the value of the - HOME shell variable. - - The variable CDPATH defines the search path for the directory containing - DIR. Alternative directory names in CDPATH are separated by a colon (:). - A null directory name is the same as the current directory. If DIR begins - with a slash (/), then CDPATH is not used. - - If the directory is not found, and the shell option \`cdable_vars' is set, - the word is assumed to be a variable name. If that variable has a value, - its value is used for DIR. - - Options: - -L force symbolic links to be followed - -P use the physical directory structure without following symbolic - links - - The default is to follow symbolic links, as if \`-L' were specified. - - Exit Status: - Returns 0 if the directory is changed; non-zero otherwise.`, - ], - ], - [ - "command", - [ - "command [-pVv] command [arg ...]", - `Execute a simple command or display information about commands. - Runs COMMAND with ARGS suppressing shell function lookup, or display - information about the specified COMMANDs. - - Options: - -p use a default value for PATH that is guaranteed to find all of - the standard utilities - -v print a description of COMMAND similar to the \`type' builtin - -V print a more verbose description of each COMMAND - - Exit Status: - Returns exit status of COMMAND, or failure if COMMAND is not found.`, - ], - ], - [ - "compgen", - [ - "compgen [-abcdefgjksuv] [-o option] [-A action] [-G globpat] [-W wordlist] [-F function] [-C command] [-X filterpat] [-P prefix] [-S suffix] [word]", - `Display possible completions depending on the options. - Intended to be used from within a shell function generating possible - completions. If the optional WORD argument is supplied, matches against - WORD are generated. - Exit Status: - Returns success unless an invalid option is supplied or an error occurs.`, - ], - ], - [ - "complete", - [ - "complete [-abcdefgjksuv] [-pr] [-DEI] [-o option] [-A action] [-G globpat] [-W wordlist] [-F function] [-C command] [-X filterpat] [-P prefix] [-S suffix] [name ...]", - `Specify how arguments are to be completed. - For each NAME, specify how arguments are to be completed. - Exit Status: - Returns success unless an invalid option is supplied or an error occurs.`, - ], - ], - [ - "continue", - [ - "continue [n]", - `Resume for, while, or until loops. - Resumes the next iteration of the enclosing FOR, WHILE or UNTIL loop. - If N is specified, resumes the Nth enclosing loop. - Exit Status: - The exit status is 0 unless N is not greater than or equal to 1.`, - ], - ], - [ - "declare", - [ - "declare [-aAfFgilnrtux] [-p] [name[=value] ...]", - `Set variable values and attributes. - Declare variables and give them attributes. If no NAMEs are given, - display the attributes and values of all variables. - - Options: - -a to make NAMEs indexed arrays (if supported) - -A to make NAMEs associative arrays (if supported) - -i to make NAMEs have the \`integer' attribute - -l to convert the value of each NAME to lower case on assignment - -n make NAME a reference to the variable named by its value - -r to make NAMEs readonly - -t to make NAMEs have the \`trace' attribute - -u to convert the value of each NAME to upper case on assignment - -x to make NAMEs export - - Exit Status: - Returns success unless an invalid option is supplied or a variable - assignment error occurs.`, - ], - ], - [ - "dirs", - [ - "dirs [-clpv] [+N] [-N]", - `Display directory stack. - Display the list of currently remembered directories. Directories - find their way onto the list with the \`pushd' command; you can get - back up through the list with the \`popd' command. - Exit Status: - Returns success unless an invalid option is supplied or an error occurs.`, - ], - ], - [ - "disown", - [ - "disown [-h] [-ar] [jobspec ...]", - `Remove jobs from current shell. - Without any JOBSPECs, remove the current job.`, - ], - ], - [ - "echo", - [ - "echo [-neE] [arg ...]", - `Write arguments to the standard output. - Display the ARGs, separated by a single space character and followed by a - newline, on the standard output. - - Options: - -n do not append a newline - -e enable interpretation of the following backslash escapes - -E explicitly suppress interpretation of backslash escapes - - Exit Status: - Returns success unless a write error occurs.`, - ], - ], - [ - "enable", - [ - "enable [-a] [-dnps] [-f filename] [name ...]", - `Enable and disable shell builtins. - Enables and disables builtin shell commands. - Exit Status: - Returns success unless NAME is not a shell builtin or an error occurs.`, - ], - ], - [ - "eval", - [ - "eval [arg ...]", - `Execute arguments as a shell command. - Combine ARGs into a single string, use the result as input to the shell, - and execute the resulting commands. - Exit Status: - Returns exit status of command or success if command is null.`, - ], - ], - [ - "exec", - [ - "exec [-cl] [-a name] [command [arguments ...]] [redirection ...]", - `Replace the shell with the given command. - Execute COMMAND, replacing this shell with the specified program. - ARGUMENTS become the arguments to COMMAND. If COMMAND is not specified, - any redirections take effect in the current shell. - Exit Status: - Returns success unless COMMAND is not found or a redirection error occurs.`, - ], - ], - [ - "exit", - [ - "exit [n]", - `Exit the shell. - Exits the shell with a status of N. If N is omitted, the exit status - is that of the last command executed.`, - ], - ], - [ - "export", - [ - "export [-fn] [name[=value] ...] or export -p", - `Set export attribute for shell variables. - Marks each NAME for automatic export to the environment of subsequently - executed commands. If VALUE is supplied, assign VALUE before exporting. - - Options: - -f refer to shell functions - -n remove the export property from each NAME - -p display a list of all exported variables and functions - - Exit Status: - Returns success unless an invalid option is given or NAME is invalid.`, - ], - ], - [ - "false", - [ - "false", - `Return an unsuccessful result. - Exit Status: - Always fails.`, - ], - ], - [ - "fc", - [ - "fc [-e ename] [-lnr] [first] [last] or fc -s [pat=rep] [command]", - `Display or execute commands from the history list. - Exit Status: - Returns success or status of executed command.`, - ], - ], - [ - "fg", - [ - "fg [job_spec]", - `Move job to the foreground. - Place the job identified by JOB_SPEC in the foreground, making it the - current job.`, - ], - ], - [ - "getopts", - [ - "getopts optstring name [arg]", - `Parse option arguments. - Getopts is used by shell procedures to parse positional parameters - as options. - - OPTSTRING contains the option letters to be recognized; if a letter - is followed by a colon, the option is expected to have an argument, - which should be separated from it by white space. - Exit Status: - Returns success if an option is found; fails if the end of options is - encountered or an error occurs.`, - ], - ], - [ - "hash", - [ - "hash [-lr] [-p pathname] [-dt] [name ...]", - `Remember or display program locations. - Determine and remember the full pathname of each command NAME. - Exit Status: - Returns success unless NAME is not found or an invalid option is given.`, - ], - ], - [ - "help", - [ - "help [-s] [pattern ...]", - `Display information about builtin commands. - Displays brief summaries of builtin commands. If PATTERN is - specified, gives detailed help on all commands matching PATTERN, - otherwise the list of help topics is printed. - - Options: - -s output only a short usage synopsis for each topic matching - PATTERN - - Exit Status: - Returns success unless PATTERN is not found.`, - ], - ], - [ - "history", - [ - "history [-c] [-d offset] [n] or history -anrw [filename] or history -ps arg [arg...]", - `Display or manipulate the history list. - Display the history list with line numbers, prefixing each modified - entry with a \`*'. - Exit Status: - Returns success unless an invalid option is given or an error occurs.`, - ], - ], - [ - "jobs", - [ - "jobs [-lnprs] [jobspec ...] or jobs -x command [args]", - `Display status of jobs. - Lists the active jobs. - Exit Status: - Returns success unless an invalid option is given or an error occurs.`, - ], - ], - [ - "kill", - [ - "kill [-s sigspec | -n signum | -sigspec] pid | jobspec ... or kill -l [sigspec]", - `Send a signal to a job. - Send the processes identified by PID or JOBSPEC the signal named by - SIGSPEC or SIGNUM. - Exit Status: - Returns success unless an invalid option is given or an error occurs.`, - ], - ], - [ - "let", - [ - "let arg [arg ...]", - `Evaluate arithmetic expressions. - Evaluate each ARG as an arithmetic expression. Evaluation is done in - fixed-width integers with no check for overflow, though division by 0 - is trapped and flagged as an error. - Exit Status: - If the last ARG evaluates to 0, let returns 1; 0 is returned otherwise.`, - ], - ], - [ - "local", - [ - "local [option] name[=value] ...", - `Define local variables. - Create a local variable called NAME, and give it VALUE. OPTION can - be any option accepted by \`declare'. - - Local can only be used within a function; it makes the variable NAME - have a visible scope restricted to that function and its children. - Exit Status: - Returns success unless an invalid option is supplied, a variable - assignment error occurs, or the shell is not executing a function.`, - ], - ], - [ - "logout", - [ - "logout [n]", - `Exit a login shell. - Exits a login shell with exit status N. Returns an error if not executed - in a login shell.`, - ], - ], - [ - "mapfile", - [ - "mapfile [-d delim] [-n count] [-O origin] [-s count] [-t] [-u fd] [-C callback] [-c quantum] [array]", - `Read lines from the standard input into an indexed array variable. - Read lines from the standard input into the indexed array variable ARRAY, - or from file descriptor FD if the -u option is supplied. - - Options: - -d delim Use DELIM to terminate lines, instead of newline - -n count Copy at most COUNT lines - -O origin Begin assigning to ARRAY at index ORIGIN - -s count Discard the first COUNT lines read - -t Remove a trailing DELIM from each line read (default newline) - -u fd Read lines from file descriptor FD instead of standard input - - Exit Status: - Returns success unless an invalid option is given or ARRAY is readonly.`, - ], - ], - [ - "popd", - [ - "popd [-n] [+N | -N]", - `Remove directories from stack. - Removes entries from the directory stack. - Exit Status: - Returns success unless an invalid argument is supplied or the directory - change fails.`, - ], - ], - [ - "printf", - [ - "printf [-v var] format [arguments]", - `Formats and prints ARGUMENTS under control of the FORMAT. - - Options: - -v var assign the output to shell variable VAR rather than - display it on the standard output - - FORMAT is a character string which contains three types of objects: plain - characters, which are simply copied to standard output; character escape - sequences, which are converted and copied to the standard output; and - format specifications, each of which causes printing of the next successive - argument. - Exit Status: - Returns success unless an invalid option is given or a write or assignment - error occurs.`, - ], - ], - [ - "pushd", - [ - "pushd [-n] [+N | -N | dir]", - `Add directories to stack. - Adds a directory to the top of the directory stack, or rotates - the stack, making the new top of the stack the current working - directory. - Exit Status: - Returns success unless an invalid argument is supplied or the directory - change fails.`, - ], - ], - [ - "pwd", - [ - "pwd [-LP]", - `Print the name of the current working directory. - - Options: - -L print the value of $PWD if it names the current working - directory - -P print the physical directory, without any symbolic links - - By default, \`pwd' behaves as if \`-L' were specified. - Exit Status: - Returns 0 unless an invalid option is given or the current directory - cannot be read.`, - ], - ], - [ - "read", - [ - "read [-ers] [-a array] [-d delim] [-i text] [-n nchars] [-N nchars] [-p prompt] [-t timeout] [-u fd] [name ...]", - `Read a line from the standard input and split it into fields. - Reads a single line from the standard input, or from file descriptor FD - if the -u option is supplied. The line is split into fields as with word - splitting, and the first word is assigned to the first NAME, the second - word to the second NAME, and so on, with any leftover words assigned to - the last NAME. - Exit Status: - The return code is zero, unless end-of-file is encountered, read times out, - or an invalid file descriptor is supplied as the argument to -u.`, - ], - ], - [ - "readarray", - [ - "readarray [-d delim] [-n count] [-O origin] [-s count] [-t] [-u fd] [-C callback] [-c quantum] [array]", - `Read lines from a file into an array variable. - A synonym for \`mapfile'.`, - ], - ], - [ - "readonly", - [ - "readonly [-aAf] [name[=value] ...] or readonly -p", - `Mark shell variables as unchangeable. - Mark each NAME as read-only; the values of these NAMEs may not be - changed by subsequent assignment. - Exit Status: - Returns success unless an invalid option is given or NAME is invalid.`, - ], - ], - [ - "return", - [ - "return [n]", - `Return from a shell function. - Causes a function or sourced script to exit with the return value - specified by N. If N is omitted, the return status is that of the - last command executed within the function or script. - Exit Status: - Returns N, or failure if the shell is not executing a function or script.`, - ], - ], - [ - "set", - [ - "set [-abefhkmnptuvxBCHP] [-o option-name] [--] [arg ...]", - `Set or unset values of shell options and positional parameters. - Change the value of shell attributes and positional parameters, or - display the names and values of shell variables. - - Options: - -e Exit immediately if a command exits with a non-zero status. - -u Treat unset variables as an error when substituting. - -x Print commands and their arguments as they are executed. - -o option-name - Set the variable corresponding to option-name - - Exit Status: - Returns success unless an invalid option is given.`, - ], - ], - [ - "shift", - [ - "shift [n]", - `Shift positional parameters. - Rename the positional parameters $N+1,$N+2 ... to $1,$2 ... If N is - not given, it is assumed to be 1. - Exit Status: - Returns success unless N is negative or greater than $#.`, - ], - ], - [ - "shopt", - [ - "shopt [-pqsu] [-o] [optname ...]", - `Set and unset shell options. - Change the setting of each shell option OPTNAME. Without any option - arguments, list each supplied OPTNAME, or all shell options if no - OPTNAMEs are given, with an indication of whether or not each is set. - - Options: - -o restrict OPTNAMEs to those defined for use with \`set -o' - -p print each shell option with an indication of its status - -q suppress output - -s enable (set) each OPTNAME - -u disable (unset) each OPTNAME - - Exit Status: - Returns success if OPTNAME is enabled; fails if an invalid option is - given or OPTNAME is disabled.`, - ], - ], - [ - "source", - [ - "source filename [arguments]", - `Execute commands from a file in the current shell. - Read and execute commands from FILENAME in the current shell. - The entries in $PATH are used to find the directory containing FILENAME. - Exit Status: - Returns the status of the last command executed in FILENAME.`, - ], - ], - [ - "suspend", - [ - "suspend [-f]", - `Suspend shell execution. - Suspend the execution of this shell until it receives a SIGCONT signal.`, - ], - ], - [ - "test", - [ - "test [expr]", - `Evaluate conditional expression. - Exits with a status of 0 (true) or 1 (false) depending on - the evaluation of EXPR. Expressions may be unary or binary. - Exit Status: - Returns success if EXPR evaluates to true; fails if EXPR evaluates to - false or an invalid argument is given.`, - ], - ], - [ - "times", - [ - "times", - `Display process times. - Prints the accumulated user and system times for the shell and all of its - child processes. - Exit Status: - Always succeeds.`, - ], - ], - [ - "trap", - [ - "trap [-lp] [[arg] signal_spec ...]", - `Trap signals and other events. - Defines and activates handlers to be run when the shell receives signals - or other conditions. - Exit Status: - Returns success unless a SIGSPEC is invalid or an invalid option is given.`, - ], - ], - [ - "true", - [ - "true", - `Return a successful result. - Exit Status: - Always succeeds.`, - ], - ], - [ - "type", - [ - "type [-afptP] name [name ...]", - `Display information about command type. - For each NAME, indicate how it would be interpreted if used as a - command name. - - Options: - -a display all locations containing an executable named NAME - -f suppress shell function lookup - -P force a PATH search for each NAME, even if it is an alias, - builtin, or function, and returns the name of the disk file - that would be executed - -p returns either the name of the disk file that would be executed, - or nothing if \`type -t NAME' would not return \`file' - -t output a single word which is one of \`alias', \`keyword', - \`function', \`builtin', \`file' or \`', if NAME is an alias, - shell reserved word, shell function, shell builtin, disk file, - or not found, respectively - - Exit Status: - Returns success if all of the NAMEs are found; fails if any are not found.`, - ], - ], - [ - "typeset", - [ - "typeset [-aAfFgilnrtux] [-p] name[=value] ...", - `Set variable values and attributes. - A synonym for \`declare'.`, - ], - ], - [ - "ulimit", - [ - "ulimit [-SHabcdefiklmnpqrstuvxPT] [limit]", - `Modify shell resource limits. - Provides control over the resources available to the shell and processes - it creates, on systems that allow such control. - Exit Status: - Returns success unless an invalid option is supplied or an error occurs.`, - ], - ], - [ - "umask", - [ - "umask [-p] [-S] [mode]", - `Display or set file mode mask. - Sets the user file-creation mask to MODE. If MODE is omitted, prints - the current value of the mask. - Exit Status: - Returns success unless MODE is invalid or an invalid option is given.`, - ], - ], - [ - "unalias", - [ - "unalias [-a] name [name ...]", - `Remove each NAME from the list of defined aliases. - Exit Status: - Returns success unless a NAME is not an existing alias.`, - ], - ], - [ - "unset", - [ - "unset [-f] [-v] [-n] [name ...]", - `Unset values and attributes of shell variables and functions. - For each NAME, remove the corresponding variable or function. - - Options: - -f treat each NAME as a shell function - -v treat each NAME as a shell variable - -n treat each NAME as a name reference and unset the variable itself - rather than the variable it references - - Without options, unset first tries to unset a variable, and if that fails, - tries to unset a function. - Exit Status: - Returns success unless an invalid option is given or a NAME is read-only.`, - ], - ], - [ - "wait", - [ - "wait [-fn] [id ...]", - `Wait for job completion and return exit status. - Waits for each process identified by an ID, which may be a process ID or a - job specification, and reports its termination status. - Exit Status: - Returns the status of the last ID; fails if ID is invalid or an invalid - option is given.`, - ], - ], -]); - -// All builtin names for listing -const ALL_BUILTINS = [...BUILTIN_HELP.keys()].sort(); - -export function handleHelp( - _ctx: InterpreterContext, - args: string[], -): ExecResult { - let shortForm = false; - const patterns: string[] = []; - - // Parse arguments - let i = 0; - while (i < args.length) { - const arg = args[i]; - if (arg === "--") { - i++; - // Remaining args are patterns - while (i < args.length) { - patterns.push(args[i]); - i++; - } - break; - } - if (arg.startsWith("-") && arg.length > 1) { - for (let j = 1; j < arg.length; j++) { - const flag = arg[j]; - if (flag === "s") { - shortForm = true; - } else { - return failure(`bash: help: -${flag}: invalid option\n`, 2); - } - } - i++; - } else { - patterns.push(arg); - i++; - } - } - - // No patterns: list all builtins - if (patterns.length === 0) { - return listAllBuiltins(); - } - - // With patterns: show help for matching builtins - let stdout = ""; - let hasError = false; - let stderr = ""; - - for (const pattern of patterns) { - const matches = findMatchingBuiltins(pattern); - - if (matches.length === 0) { - stderr += `bash: help: no help topics match \`${pattern}'. Try \`help help' or \`man -k ${pattern}' or \`info ${pattern}'.\n`; - hasError = true; - continue; - } - - for (const name of matches) { - // Use Object.hasOwn to prevent prototype pollution - const entry = BUILTIN_HELP.get(name); - if (!entry) continue; - const [synopsis, description] = entry; - if (shortForm) { - stdout += `${name}: ${synopsis}\n`; - } else { - stdout += `${name}: ${synopsis}\n${description}\n`; - } - } - } - - return { - exitCode: hasError ? 1 : 0, - stdout, - stderr, - }; -} - -/** - * Find builtins matching a pattern (supports glob-style wildcards) - */ -function findMatchingBuiltins(pattern: string): string[] { - // Convert glob pattern to regex - const regexPattern = pattern - .replace(/[.+^${}()|[\]\\]/g, "\\$&") // Escape regex special chars except * and ? - .replace(/\*/g, ".*") - .replace(/\?/g, "."); - - const regex = createUserRegex(`^${regexPattern}$`); - return ALL_BUILTINS.filter((name) => regex.test(name)); -} - -/** - * List all builtins in a formatted table - */ -function listAllBuiltins(): ExecResult { - const lines: string[] = []; - - lines.push("just-bash shell builtins"); - lines.push( - "These shell commands are defined internally. Type `help' to see this list.", - ); - lines.push("Type `help name' to find out more about the function `name'."); - lines.push(""); - - // Create two-column output with builtin names - const maxWidth = 36; - const builtins = ALL_BUILTINS.slice(); - - // Build pairs for two-column display - const midpoint = Math.ceil(builtins.length / 2); - for (let i = 0; i < midpoint; i++) { - const left = builtins[i] || ""; - const right = builtins[i + midpoint] || ""; - const leftPadded = left.padEnd(maxWidth); - lines.push(right ? `${leftPadded}${right}` : left); - } - - return success(`${lines.join("\n")}\n`); -} diff --git a/src/interpreter/builtins/index.ts b/src/interpreter/builtins/index.ts deleted file mode 100644 index 272ca991..00000000 --- a/src/interpreter/builtins/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Built-in Command Handlers - * - * Shell built-in commands that modify interpreter state: - * - cd: Change directory - * - declare/typeset: Declare variables with attributes - * - export: Set environment variables - * - unset: Remove variables/functions - * - exit: Exit shell - * - local: Declare local variables in functions - * - readonly: Declare readonly variables - * - set: Set/unset shell options - * - break: Exit from loops - * - continue: Skip to next loop iteration - * - return: Return from a function - * - eval: Execute arguments as a shell command - * - let: Evaluate arithmetic expressions - * - shift: Shift positional parameters - * - read: Read a line of input - * - source/.: Execute commands from a file in current environment - */ - -export { handleBreak } from "./break.js"; -export { handleCd } from "./cd.js"; -export { handleCompgen } from "./compgen.js"; -export { handleComplete } from "./complete.js"; -export { handleCompopt } from "./compopt.js"; -export { handleContinue } from "./continue.js"; -export { - applyCaseTransform, - handleDeclare, - handleReadonly, - isInteger, -} from "./declare.js"; -export { handleDirs, handlePopd, handlePushd } from "./dirs.js"; -export { handleEval } from "./eval.js"; -export { handleExit } from "./exit.js"; -export { handleExport } from "./export.js"; -export { handleGetopts } from "./getopts.js"; -export { handleHash } from "./hash.js"; -export { handleHelp } from "./help.js"; -export { handleLet } from "./let.js"; -export { handleLocal } from "./local.js"; -export { handleMapfile } from "./mapfile.js"; -export { handleRead } from "./read.js"; -export { handleReturn } from "./return.js"; -export { handleSet } from "./set.js"; -export { handleShift } from "./shift.js"; -export { handleSource } from "./source.js"; -export { handleUnset } from "./unset.js"; -export { getLocalVarDepth } from "./variable-assignment.js"; diff --git a/src/interpreter/builtins/let.ts b/src/interpreter/builtins/let.ts deleted file mode 100644 index 5a91d251..00000000 --- a/src/interpreter/builtins/let.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * let - Evaluate arithmetic expressions - * - * Usage: - * let expr [expr ...] - * let "x=1" "y=x+2" - * - * Each argument is evaluated as an arithmetic expression. - * Returns 0 if the last expression evaluates to non-zero, - * returns 1 if it evaluates to zero. - * - * Note: In bash, `let x=( 1 )` passes separate args ["x=(", "1", ")"] - * when not quoted. The let builtin needs to handle this by joining - * arguments that are part of the same expression. - */ - -import type { ArithmeticCommandNode } from "../../ast/types.js"; -import { parse } from "../../parser/parser.js"; -import type { ExecResult } from "../../types.js"; -import { evaluateArithmetic } from "../arithmetic.js"; -import { failure, result } from "../helpers/result.js"; -import type { InterpreterContext } from "../types.js"; - -/** - * Parse arguments into expressions. - * Handles cases like `let x=( 1 )` where parentheses cause splitting. - */ -function parseLetArgs(args: string[]): string[] { - const expressions: string[] = []; - let current = ""; - let parenDepth = 0; - - for (const arg of args) { - // Count open and close parens in this arg - for (const ch of arg) { - if (ch === "(") parenDepth++; - else if (ch === ")") parenDepth--; - } - - if (current) { - current += ` ${arg}`; - } else { - current = arg; - } - - // If parens are balanced, this is a complete expression - if (parenDepth === 0) { - expressions.push(current); - current = ""; - } - } - - // Handle any remaining (unbalanced parens treated as single expression) - if (current) { - expressions.push(current); - } - - return expressions; -} - -export async function handleLet( - ctx: InterpreterContext, - args: string[], -): Promise { - if (args.length === 0) { - return failure("bash: let: expression expected\n"); - } - - // Parse args into expressions (handling split parentheses) - const expressions = parseLetArgs(args); - let lastResult = 0; - - for (const expr of expressions) { - try { - // Parse the expression by wrapping it in (( )) - // This leverages the existing arithmetic parser - const script = parse(`(( ${expr} ))`); - - // Navigate through the AST: Script -> Statement -> Pipeline -> Command - const statement = script.statements[0]; - if ( - statement && - statement.pipelines.length > 0 && - statement.pipelines[0].commands.length > 0 - ) { - const command = statement.pipelines[0].commands[0]; - if (command.type === "ArithmeticCommand") { - const arithNode = command as ArithmeticCommandNode; - lastResult = await evaluateArithmetic( - ctx, - arithNode.expression.expression, - ); - } - } - } catch (error) { - return failure(`bash: let: ${expr}: ${(error as Error).message}\n`); - } - } - - // Return 0 if last expression is non-zero, 1 if zero - return result("", "", lastResult === 0 ? 1 : 0); -} diff --git a/src/interpreter/builtins/local.test.ts b/src/interpreter/builtins/local.test.ts deleted file mode 100644 index 55f487d3..00000000 --- a/src/interpreter/builtins/local.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("local builtin", () => { - describe("basic local variables", () => { - it("should declare local variable with value", async () => { - const env = new Bash(); - const result = await env.exec( - "test_func() { local x=hello; echo $x; }; test_func", - ); - expect(result.stdout).toBe("hello\n"); - }); - - it("should not affect outer scope", async () => { - const env = new Bash({ env: { x: "outer" } }); - const result = await env.exec( - "test_func() { local x=inner; echo $x; }; test_func; echo $x", - ); - expect(result.stdout).toBe("inner\nouter\n"); - }); - - it("should shadow outer variable", async () => { - const env = new Bash({ env: { x: "outer" } }); - const result = await env.exec( - "test_func() { local x=inner; echo $x; }; test_func", - ); - expect(result.stdout).toBe("inner\n"); - }); - - it("should restore undefined variable after function", async () => { - const env = new Bash(); - const result = await env.exec( - 'test_func() { local newvar=value; echo $newvar; }; test_func; echo "[$newvar]"', - ); - expect(result.stdout).toBe("value\n[]\n"); - }); - - it("should declare local without value", async () => { - const env = new Bash(); - const result = await env.exec( - "test_func() { local x; x=assigned; echo $x; }; test_func", - ); - expect(result.stdout).toBe("assigned\n"); - }); - }); - - describe("multiple local declarations", () => { - it("should handle multiple local declarations", async () => { - const env = new Bash(); - const result = await env.exec( - "test_func() { local a=1 b=2 c=3; echo $a $b $c; }; test_func", - ); - expect(result.stdout).toBe("1 2 3\n"); - }); - - it("should handle mixed declarations with and without values", async () => { - const env = new Bash(); - const result = await env.exec( - "test_func() { local a=1 b c=3; b=2; echo $a $b $c; }; test_func", - ); - expect(result.stdout).toBe("1 2 3\n"); - }); - }); - - describe("nested functions", () => { - it("should work with nested function calls", async () => { - const env = new Bash(); - const result = await env.exec( - "inner() { local x=inner; echo $x; }; outer() { local x=outer; inner; echo $x; }; outer", - ); - expect(result.stdout).toBe("inner\nouter\n"); - }); - - it("should keep local changes within same scope", async () => { - const env = new Bash(); - const result = await env.exec( - "test_func() { local x=first; x=second; echo $x; }; test_func", - ); - expect(result.stdout).toBe("second\n"); - }); - - it("should not leak local from inner to outer function", async () => { - const env = new Bash(); - const result = await env.exec(` - inner() { local y=inner; } - outer() { - local x=outer - inner - echo "x=$x y=$y" - } - outer - `); - expect(result.stdout).toBe("x=outer y=\n"); - }); - }); - - describe("error cases", () => { - it("should error when used outside function", async () => { - const env = new Bash(); - const result = await env.exec("local x=value"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("can only be used in a function"); - }); - - it("should error when used in subshell outside function", async () => { - const env = new Bash(); - const result = await env.exec("(local x=value)"); - expect(result.exitCode).not.toBe(0); - }); - }); - - describe("local with special values", () => { - it("should handle local with empty value", async () => { - const env = new Bash(); - const result = await env.exec( - 'test_func() { local x=; echo "x is $x end"; }; test_func', - ); - expect(result.stdout).toBe("x is end\n"); - }); - - it("should handle local with spaces in value (quoted)", async () => { - const env = new Bash(); - const result = await env.exec( - 'test_func() { local x="hello world"; echo "$x"; }; test_func', - ); - expect(result.stdout).toBe("hello world\n"); - }); - - it("should handle local with variable expansion", async () => { - const env = new Bash({ env: { OUTER: "expanded" } }); - const result = await env.exec( - 'test_func() { local x=$OUTER; echo "$x"; }; test_func', - ); - expect(result.stdout).toBe("expanded\n"); - }); - }); - - describe("local scope restoration", () => { - it("should restore original value after function returns", async () => { - const env = new Bash(); - const result = await env.exec(` - x=global - test_func() { - local x=local - echo "inside: $x" - } - echo "before: $x" - test_func - echo "after: $x" - `); - expect(result.stdout).toBe( - "before: global\ninside: local\nafter: global\n", - ); - }); - - it("should handle recursive functions with local", async () => { - const env = new Bash(); - const result = await env.exec(` - countdown() { - local n=$1 - if [ $n -le 0 ]; then - echo "done" - return - fi - echo $n - countdown $((n - 1)) - } - countdown 3 - `); - expect(result.stdout).toBe("3\n2\n1\ndone\n"); - }); - }); -}); diff --git a/src/interpreter/builtins/local.ts b/src/interpreter/builtins/local.ts deleted file mode 100644 index f016c505..00000000 --- a/src/interpreter/builtins/local.ts +++ /dev/null @@ -1,432 +0,0 @@ -/** - * local - Declare local variables in functions builtin - */ - -import { parseArithmeticExpression } from "../../parser/arithmetic-parser.js"; -import { Parser } from "../../parser/parser.js"; -import type { ExecResult } from "../../types.js"; -import { evaluateArithmetic } from "../arithmetic.js"; -import { getArrayIndices } from "../helpers/array.js"; -import { markNameref } from "../helpers/nameref.js"; -import { checkReadonlyError } from "../helpers/readonly.js"; -import { failure, result } from "../helpers/result.js"; -import { expandTildesInValue } from "../helpers/tilde.js"; -import type { InterpreterContext } from "../types.js"; -import { parseArrayElements } from "./declare-array-parsing.js"; -import { markLocalVarDepth, pushLocalVarStack } from "./variable-assignment.js"; - -export async function handleLocal( - ctx: InterpreterContext, - args: string[], -): Promise { - if (ctx.state.localScopes.length === 0) { - return failure("bash: local: can only be used in a function\n"); - } - - const currentScope = ctx.state.localScopes[ctx.state.localScopes.length - 1]; - let stderr = ""; - let exitCode = 0; - let declareNameref = false; - let declareArray = false; - let _printMode = false; - - // Parse flags - const processedArgs: string[] = []; - for (const arg of args) { - if (arg === "-n") { - declareNameref = true; - } else if (arg === "-a") { - declareArray = true; - } else if (arg === "-p") { - _printMode = true; - } else if (arg.startsWith("-") && !arg.includes("=")) { - // Handle combined flags like -na - for (const flag of arg.slice(1)) { - if (flag === "n") declareNameref = true; - else if (flag === "a") declareArray = true; - else if (flag === "p") _printMode = true; - // Other flags are ignored for now - } - } else { - processedArgs.push(arg); - } - } - - // Handle local (with or without -p): print local variables in current scope when no args - // Note: bash outputs local without "declare --" prefix, just "name=value" - if (processedArgs.length === 0) { - let stdout = ""; - // Get the names of local variables in current scope - const localNames = Array.from(currentScope.keys()) - .filter((key) => !key.includes("_") || !key.match(/_\d+$/)) // Filter out array element keys - .filter((key) => !key.includes("__length")) // Filter out length markers - .sort(); - - for (const name of localNames) { - const value = ctx.state.env.get(name); - if (value !== undefined) { - stdout += `${name}=${value}\n`; - } - } - return result(stdout, "", 0); - } - - for (const arg of processedArgs) { - let name: string; - let value: string | undefined; - - // Check for array assignment: name=(...) - const arrayMatch = arg.match(/^([a-zA-Z_][a-zA-Z0-9_]*)=\((.*)\)$/s); - if (arrayMatch) { - name = arrayMatch[1]; - const content = arrayMatch[2]; - - // Validate variable name - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { - stderr += `bash: local: \`${arg}': not a valid identifier\n`; - exitCode = 1; - continue; - } - - // Check if variable is readonly - checkReadonlyError(ctx, name, "bash"); - - // Save previous value for scope restoration - if (!currentScope.has(name)) { - currentScope.set(name, ctx.state.env.get(name)); - // Also save array elements - const prefix = `${name}_`; - for (const key of ctx.state.env.keys()) { - if (key.startsWith(prefix) && !key.includes("__")) { - if (!currentScope.has(key)) { - currentScope.set(key, ctx.state.env.get(key)); - } - } - } - } - - // Clear existing array elements - const prefix = `${name}_`; - for (const key of ctx.state.env.keys()) { - if (key.startsWith(prefix) && !key.includes("__")) { - ctx.state.env.delete(key); - } - } - - // Parse array elements (respects quotes) - const elements = parseArrayElements(content); - for (let i = 0; i < elements.length; i++) { - ctx.state.env.set(`${name}_${i}`, elements[i]); - } - ctx.state.env.set(`${name}__length`, String(elements.length)); - - // Track local variable depth for bash-specific unset scoping - markLocalVarDepth(ctx, name); - - // Mark as nameref if -n flag was used - if (declareNameref) { - markNameref(ctx, name); - } - continue; - } - - // Check for array append syntax: local NAME+=(...) - const arrayAppendMatch = arg.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\+=\((.*)\)$/s, - ); - if (arrayAppendMatch) { - name = arrayAppendMatch[1]; - const content = arrayAppendMatch[2]; - - // Check if variable is readonly - checkReadonlyError(ctx, name, "bash"); - - // Save previous value for scope restoration - if (!currentScope.has(name)) { - currentScope.set(name, ctx.state.env.get(name)); - // Also save array elements - const prefix = `${name}_`; - for (const key of ctx.state.env.keys()) { - if (key.startsWith(prefix) && !key.includes("__")) { - if (!currentScope.has(key)) { - currentScope.set(key, ctx.state.env.get(key)); - } - } - } - const lengthKey = `${name}__length`; - if (ctx.state.env.has(lengthKey) && !currentScope.has(lengthKey)) { - currentScope.set(lengthKey, ctx.state.env.get(lengthKey)); - } - } - - // Parse new elements - const newElements = parseArrayElements(content); - - // For indexed arrays, get current highest index and append - const existingIndices = getArrayIndices(ctx, name); - - // If variable was a scalar, convert it to array element 0 - let startIndex = 0; - const scalarValue = ctx.state.env.get(name); - if (existingIndices.length === 0 && scalarValue !== undefined) { - // Variable exists as scalar - convert to array element 0 - ctx.state.env.set(`${name}_0`, scalarValue); - ctx.state.env.delete(name); - startIndex = 1; - } else if (existingIndices.length > 0) { - // Find highest existing index + 1 - startIndex = Math.max(...existingIndices) + 1; - } - - // Append new elements - for (let i = 0; i < newElements.length; i++) { - ctx.state.env.set( - `${name}_${startIndex + i}`, - expandTildesInValue(ctx, newElements[i]), - ); - } - - // Update length marker - const newLength = startIndex + newElements.length; - ctx.state.env.set(`${name}__length`, String(newLength)); - - // Track local variable depth for bash-specific unset scoping - markLocalVarDepth(ctx, name); - - // Mark as nameref if -n flag was used - if (declareNameref) { - markNameref(ctx, name); - } - continue; - } - - // Check for += append syntax (scalar append) - const appendMatch = arg.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\+=(.*)$/); - if (appendMatch) { - name = appendMatch[1]; - const appendValue = expandTildesInValue(ctx, appendMatch[2]); - - // Check if variable is readonly - checkReadonlyError(ctx, name, "bash"); - - // Save previous value for scope restoration - if (!currentScope.has(name)) { - currentScope.set(name, ctx.state.env.get(name)); - } - - // Append to existing value (or set if not defined) - const existing = ctx.state.env.get(name) ?? ""; - ctx.state.env.set(name, existing + appendValue); - - // Track local variable depth for bash-specific unset scoping - markLocalVarDepth(ctx, name); - - // Mark as nameref if -n flag was used - if (declareNameref) { - markNameref(ctx, name); - } - continue; - } - - // Check for array index assignment: name[index]=value - const indexMatch = arg.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[([^\]]+)\]=(.*)$/s, - ); - if (indexMatch) { - name = indexMatch[1]; - const indexExpr = indexMatch[2]; - const indexValue = expandTildesInValue(ctx, indexMatch[3]); - - // Check if variable is readonly - checkReadonlyError(ctx, name, "bash"); - - // Save previous array values for scope restoration - if (!currentScope.has(name)) { - currentScope.set(name, ctx.state.env.get(name)); - // Also save array elements - const prefix = `${name}_`; - for (const key of ctx.state.env.keys()) { - if (key.startsWith(prefix) && !key.includes("__")) { - if (!currentScope.has(key)) { - currentScope.set(key, ctx.state.env.get(key)); - } - } - } - const lengthKey = `${name}__length`; - if (ctx.state.env.has(lengthKey) && !currentScope.has(lengthKey)) { - currentScope.set(lengthKey, ctx.state.env.get(lengthKey)); - } - } - - // Evaluate the index (can be arithmetic expression) - let index: number; - try { - const parser = new Parser(); - const arithAst = parseArithmeticExpression(parser, indexExpr); - index = await evaluateArithmetic(ctx, arithAst.expression); - } catch { - // If parsing fails, try to parse as simple number - const num = parseInt(indexExpr, 10); - index = Number.isNaN(num) ? 0 : num; - } - - // Set the array element - ctx.state.env.set(`${name}_${index}`, indexValue); - - // Update array length if needed - const currentLength = parseInt( - ctx.state.env.get(`${name}__length`) ?? "0", - 10, - ); - if (index >= currentLength) { - ctx.state.env.set(`${name}__length`, String(index + 1)); - } - - // Track local variable depth for bash-specific unset scoping - markLocalVarDepth(ctx, name); - - // Mark as nameref if -n flag was used - if (declareNameref) { - markNameref(ctx, name); - } - continue; - } - - if (arg.includes("=")) { - const eqIdx = arg.indexOf("="); - name = arg.slice(0, eqIdx); - value = expandTildesInValue(ctx, arg.slice(eqIdx + 1)); - } else { - name = arg; - } - - // Validate variable name: must start with letter/underscore, contain only alphanumeric/_ - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { - stderr += `bash: local: \`${arg}': not a valid identifier\n`; - exitCode = 1; - continue; - } - - // Check if variable was already local BEFORE we potentially add it to scope - const wasAlreadyLocal = currentScope.has(name); - - // For bash's localvar-nest behavior: always push the current value to the stack - // This allows nested local declarations (e.g., in nested evals) to each have - // their own cell that can be unset independently. - // - // Special case for tempenv: the value we save depends on whether the tempenv - // was "accessed" (read or written) before this local declaration: - // - If accessed (read or mutated): save the current env value (tempenv or mutated) - // - If NOT accessed at all: save the underlying value (for dynamic-unset to reveal) - // but local-unset will still just delete (value-unset) - if (value !== undefined) { - let savedValue: string | undefined = ctx.state.env.get(name); - // Check if there's a tempenv binding - if (ctx.state.tempEnvBindings) { - const tempEnvAccessed = ctx.state.accessedTempEnvVars?.has(name); - const tempEnvMutated = ctx.state.mutatedTempEnvVars?.has(name); - if (!tempEnvAccessed && !tempEnvMutated) { - // Tempenv was NOT accessed - save the underlying value for dynamic-unset - for (let i = ctx.state.tempEnvBindings.length - 1; i >= 0; i--) { - const bindings = ctx.state.tempEnvBindings[i]; - if (bindings.has(name)) { - savedValue = bindings.get(name); - break; - } - } - } - // If accessed or mutated, keep savedValue as ctx.state.env.get(name) - } - pushLocalVarStack(ctx, name, savedValue); - } - - if (!wasAlreadyLocal) { - // For bash 5.1 behavior: when saving the outer value for a local variable, - // if there's a tempenv binding, save the underlying (global) value, not the tempenv value. - // This way, dynamic-unset will correctly reveal the global value. - let savedValue: string | undefined = ctx.state.env.get(name); - if (ctx.state.tempEnvBindings) { - for (let i = ctx.state.tempEnvBindings.length - 1; i >= 0; i--) { - const bindings = ctx.state.tempEnvBindings[i]; - if (bindings.has(name)) { - savedValue = bindings.get(name); - break; - } - } - } - currentScope.set(name, savedValue); - // Also save array elements if -a flag is used - if (declareArray) { - const prefix = `${name}_`; - for (const key of ctx.state.env.keys()) { - if (key.startsWith(prefix) && !key.includes("__")) { - if (!currentScope.has(key)) { - currentScope.set(key, ctx.state.env.get(key)); - } - } - } - // Save length metadata too - const lengthKey = `${name}__length`; - if (ctx.state.env.has(lengthKey) && !currentScope.has(lengthKey)) { - currentScope.set(lengthKey, ctx.state.env.get(lengthKey)); - } - } - } - - // If -a flag is used, create an empty local array - if (declareArray && value === undefined) { - // Clear existing array elements - const prefix = `${name}_`; - for (const key of ctx.state.env.keys()) { - if (key.startsWith(prefix) && !key.includes("__")) { - ctx.state.env.delete(key); - } - } - // Mark as empty array - ctx.state.env.set(`${name}__length`, "0"); - } else if (value !== undefined) { - // Check if variable is readonly - checkReadonlyError(ctx, name, "bash"); - - // For namerefs, validate the target - if ( - declareNameref && - value !== "" && - !/^[a-zA-Z_][a-zA-Z0-9_]*(\[.+\])?$/.test(value) - ) { - stderr += `bash: local: \`${value}': invalid variable name for name reference\n`; - exitCode = 1; - continue; - } - ctx.state.env.set(name, value); - // If allexport is enabled (set -a), auto-export the variable - if (ctx.state.options.allexport) { - ctx.state.exportedVars = ctx.state.exportedVars || new Set(); - ctx.state.exportedVars.add(name); - } - } else { - // `local v` without assignment: bash behavior is: - // - If the variable is already local in current scope, keep its value - // - If there's a tempenv binding, inherit that value - // - Otherwise, the variable is unset (not inherited from global) - const hasTempEnvBinding = ctx.state.tempEnvBindings?.some((bindings) => - bindings.has(name), - ); - if (!wasAlreadyLocal && !hasTempEnvBinding) { - // Not already local, no tempenv binding - make the variable unset - ctx.state.env.delete(name); - } - // If already local or has tempenv binding, keep the current value - } - - // Track local variable depth for bash-specific unset scoping - markLocalVarDepth(ctx, name); - - // Mark as nameref if -n flag was used - if (declareNameref) { - markNameref(ctx, name); - } - } - - return result("", stderr, exitCode); -} diff --git a/src/interpreter/builtins/mapfile.ts b/src/interpreter/builtins/mapfile.ts deleted file mode 100644 index ee21dd93..00000000 --- a/src/interpreter/builtins/mapfile.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * mapfile/readarray - Read lines from stdin into an array - * - * Usage: mapfile [-d delim] [-n count] [-O origin] [-s count] [-t] [array] - * readarray [-d delim] [-n count] [-O origin] [-s count] [-t] [array] - * - * Options: - * -d delim Use delim as line delimiter (default: newline) - * -n count Read at most count lines (0 = all) - * -O origin Start assigning at index origin (default: 0) - * -s count Skip first count lines - * -t Remove trailing delimiter from each line - * array Array name (default: MAPFILE) - */ - -import type { ExecResult } from "../../types.js"; -import { clearArray } from "../helpers/array.js"; -import { result } from "../helpers/result.js"; -import type { InterpreterContext } from "../types.js"; - -export function handleMapfile( - ctx: InterpreterContext, - args: string[], - stdin: string, -): ExecResult { - // Parse options - let delimiter = "\n"; - let maxCount = 0; // 0 = unlimited - let origin = 0; - let skipCount = 0; - let trimDelimiter = false; - let arrayName = "MAPFILE"; - - let i = 0; - while (i < args.length) { - const arg = args[i]; - if (arg === "-d" && i + 1 < args.length) { - // In bash, -d '' means use NUL byte as delimiter - delimiter = args[i + 1] === "" ? "\0" : args[i + 1] || "\n"; - i += 2; - } else if (arg === "-n" && i + 1 < args.length) { - maxCount = Number.parseInt(args[i + 1], 10) || 0; - i += 2; - } else if (arg === "-O" && i + 1 < args.length) { - origin = Number.parseInt(args[i + 1], 10) || 0; - i += 2; - } else if (arg === "-s" && i + 1 < args.length) { - skipCount = Number.parseInt(args[i + 1], 10) || 0; - i += 2; - } else if (arg === "-t") { - trimDelimiter = true; - i++; - } else if (arg === "-u" || arg === "-C" || arg === "-c") { - // Skip unsupported options that take arguments - i += 2; - } else if (!arg.startsWith("-")) { - arrayName = arg; - i++; - } else { - // Unknown option, skip - i++; - } - } - - // Use stdin from parameter, or fall back to groupStdin - let effectiveStdin = stdin; - if (!effectiveStdin && ctx.state.groupStdin !== undefined) { - effectiveStdin = ctx.state.groupStdin; - } - - // Split input by delimiter - const lines: string[] = []; - let remaining = effectiveStdin; - let lineCount = 0; - let skipped = 0; - const maxArrayElements = ctx.limits?.maxArrayElements ?? 100000; - - while (remaining.length > 0) { - const delimIndex = remaining.indexOf(delimiter); - - if (delimIndex === -1) { - // No more delimiters, add remaining content as last line (if not empty) - if (remaining.length > 0) { - if (skipped < skipCount) { - skipped++; - } else if (maxCount === 0 || lineCount < maxCount) { - // Check array element limit - if (origin + lineCount >= maxArrayElements) { - return result( - "", - `mapfile: array element limit exceeded (${maxArrayElements})\n`, - 1, - ); - } - // Bash truncates at NUL bytes - let lastLine = remaining; - const nulIdx = lastLine.indexOf("\0"); - if (nulIdx !== -1) { - lastLine = lastLine.substring(0, nulIdx); - } - lines.push(lastLine); - lineCount++; - } - } - break; - } - - // Found delimiter - let line = remaining.substring(0, delimIndex); - // Bash truncates lines at NUL bytes (unlike 'read' which ignores them) - const nulIndex = line.indexOf("\0"); - if (nulIndex !== -1) { - line = line.substring(0, nulIndex); - } - // For other delimiters, include unless -t flag is set - if (!trimDelimiter && delimiter !== "\0") { - line += delimiter; - } - - remaining = remaining.substring(delimIndex + delimiter.length); - - if (skipped < skipCount) { - skipped++; - continue; - } - - if (maxCount > 0 && lineCount >= maxCount) { - break; - } - - // Check array element limit - if (origin + lineCount >= maxArrayElements) { - return result( - "", - `mapfile: array element limit exceeded (${maxArrayElements})\n`, - 1, - ); - } - - lines.push(line); - lineCount++; - } - - // Clear existing array ONLY if not using -O (offset) option - // When using -O, we want to preserve existing elements and append starting at origin - if (origin === 0) { - clearArray(ctx, arrayName); - } - - for (let j = 0; j < lines.length; j++) { - ctx.state.env.set(`${arrayName}_${origin + j}`, lines[j]); - } - - // Set array length metadata to be the max of existing length and new end position - const existingLength = parseInt( - ctx.state.env.get(`${arrayName}__length`) || "0", - 10, - ); - const newEndIndex = origin + lines.length; - ctx.state.env.set( - `${arrayName}__length`, - String(Math.max(existingLength, newEndIndex)), - ); - - // Consume from groupStdin if we used it - if (ctx.state.groupStdin !== undefined && !stdin) { - ctx.state.groupStdin = ""; - } - - return result("", "", 0); -} diff --git a/src/interpreter/builtins/posix-fatal.test.ts b/src/interpreter/builtins/posix-fatal.test.ts deleted file mode 100644 index 82d27ed9..00000000 --- a/src/interpreter/builtins/posix-fatal.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("POSIX mode fatal errors", () => { - it("shift with too many args is fatal in POSIX mode", async () => { - const bash = new Bash(); - const result = await bash.exec(` -set -o posix -set -- a b -shift 3 -echo status=$? -`); - // Should NOT print "status=..." because shift should cause script to exit - expect(result.stdout).toBe(""); - expect(result.stderr).toContain("shift"); - expect(result.exitCode).toBe(1); - }); - - it("set with invalid option is fatal in POSIX mode", async () => { - const bash = new Bash(); - const result = await bash.exec(` -set -o posix -shopt -s invalid_ || true -echo ok -set -o invalid_ || true -echo should not get here -`); - // Should print "ok" (shopt is not special) but not "should not get here" - expect(result.stdout).toContain("ok"); - expect(result.stdout).not.toContain("should not get here"); - expect(result.exitCode).toBe(1); - }); - - it("shift works normally without POSIX mode", async () => { - const bash = new Bash(); - const result = await bash.exec(` -set -- a b -shift 3 -echo status=$? -`); - // Without POSIX mode, shift error should NOT be fatal - expect(result.stdout).toContain("status=1"); - expect(result.exitCode).toBe(0); - }); - - // Tests matching the spec-test format exactly - it("Shift is special and fails whole script (spec-test format)", async () => { - const bash = new Bash({ env: { BASH_VERSION: "5.0" } }); - // This is the actual spec test script - const result = await bash.exec(` -if test -n "$BASH_VERSION"; then - set -o posix -fi -set -- a b -shift 3 -echo status=$? -`); - // The outer script checks if the exit code is non-zero - // Expected stdout: (nothing - "echo status=$?" should not run) - // Expected exit code: non-zero - expect(result.stdout).toBe(""); - expect(result.exitCode).not.toBe(0); - }); - - it("set is special and fails whole script (spec-test format)", async () => { - const bash = new Bash({ env: { BASH_VERSION: "5.0" } }); - const result = await bash.exec(` -if test -n "$BASH_VERSION"; then - set -o posix -fi - -shopt -s invalid_ || true -echo ok -set -o invalid_ || true -echo should not get here -`); - // Expected: "ok\n" only (not "should not get here") - // Exit code: non-zero - expect(result.stdout).toBe("ok\n"); - expect(result.exitCode).not.toBe(0); - }); -}); diff --git a/src/interpreter/builtins/read.test.ts b/src/interpreter/builtins/read.test.ts deleted file mode 100644 index dc18f509..00000000 --- a/src/interpreter/builtins/read.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("read builtin", () => { - describe("basic read", () => { - it("should read from stdin into variable", async () => { - const env = new Bash(); - const result = await env.exec(` - echo "hello" | { read VAR; echo "got: $VAR"; } - `); - expect(result.stdout).toBe("got: hello\n"); - }); - - it("should read into REPLY when no variable given", async () => { - const env = new Bash(); - const result = await env.exec(` - echo "test" | { read; echo "REPLY=$REPLY"; } - `); - expect(result.stdout).toBe("REPLY=test\n"); - }); - - it("should read multiple words into multiple variables", async () => { - const env = new Bash(); - const result = await env.exec(` - echo "one two three" | { read A B C; echo "A=$A B=$B C=$C"; } - `); - expect(result.stdout).toBe("A=one B=two C=three\n"); - }); - - it("should put remaining words in last variable", async () => { - const env = new Bash(); - const result = await env.exec(` - echo "one two three four" | { read A B; echo "A=$A B=$B"; } - `); - expect(result.stdout).toBe("A=one B=two three four\n"); - }); - }); - - describe("read options", () => { - it("should support -r to disable backslash escape", async () => { - const env = new Bash(); - const result = await env.exec(` - echo 'hello\\nworld' | { read -r VAR; echo "$VAR"; } - `); - expect(result.stdout).toBe("hello\\nworld\n"); - }); - - it("should support -p for prompt (non-interactive)", async () => { - const env = new Bash(); - const result = await env.exec(` - echo "test" | { read -p "Enter: " VAR; echo "$VAR"; } - `); - expect(result.stdout).toBe("test\n"); - }); - - it("should support -a to read into array", async () => { - const env = new Bash(); - const result = await env.exec(` - echo "a b c" | { read -a ARR; echo "\${ARR[0]} \${ARR[1]} \${ARR[2]}"; } - `); - expect(result.stdout).toBe("a b c\n"); - }); - }); - - describe("read with delimiters", () => { - it("should support -d to set delimiter", async () => { - const env = new Bash(); - const result = await env.exec(` - echo -n "hello:world" | { read -d ":" VAR; echo "$VAR"; } - `); - expect(result.stdout).toBe("hello\n"); - }); - }); - - describe("read exit codes", () => { - it("should return 0 on successful read", async () => { - const env = new Bash(); - const result = await env.exec(` - echo "data" | { read VAR; echo $?; } - `); - expect(result.stdout).toBe("0\n"); - }); - - it("should return 1 on EOF", async () => { - const env = new Bash(); - const result = await env.exec(` - echo -n "" | { read VAR; echo $?; } - `); - expect(result.stdout).toBe("1\n"); - }); - }); - - describe("read in loops", () => { - it("should read multiple lines in while loop", async () => { - const env = new Bash(); - const result = await env.exec(` - echo -e "line1\\nline2\\nline3" | while read LINE; do - echo "got: $LINE" - done - `); - expect(result.stdout).toBe("got: line1\ngot: line2\ngot: line3\n"); - }); - }); - - describe("read -a with empty IFS", () => { - it("should produce empty array for empty input with empty IFS", async () => { - const env = new Bash(); - const result = await env.exec(` - IFS= - echo '' | (read -a a; echo "\${#a[@]}") - `); - // When IFS is empty and input is empty, read -a should produce an empty array (0 elements) - expect(result.stdout).toBe("0\n"); - }); - - it("should read entire non-empty input as single word with empty IFS", async () => { - const env = new Bash(); - const result = await env.exec(` - IFS= - echo 'hello world' | (read -a a; echo "\${#a[@]}"; echo "\${a[0]}") - `); - // With empty IFS, no word splitting occurs, so the entire input is one word - expect(result.stdout).toBe("1\nhello world\n"); - }); - }); -}); diff --git a/src/interpreter/builtins/read.ts b/src/interpreter/builtins/read.ts deleted file mode 100644 index 3b11cd19..00000000 --- a/src/interpreter/builtins/read.ts +++ /dev/null @@ -1,522 +0,0 @@ -/** - * read - Read a line of input builtin - */ - -import type { ExecResult } from "../../types.js"; -import { clearArray } from "../helpers/array.js"; -import { - getIfs, - splitByIfsForRead, - stripTrailingIfsWhitespace, -} from "../helpers/ifs.js"; -import { result } from "../helpers/result.js"; -import type { InterpreterContext } from "../types.js"; - -/** - * Parse the content of a read-write file descriptor. - * Format: __rw__:pathLength:path:position:content - */ -function parseRwFdContent(fdContent: string): { - path: string; - position: number; - content: string; -} | null { - if (!fdContent.startsWith("__rw__:")) { - return null; - } - const afterPrefix = fdContent.slice(7); - const firstColonIdx = afterPrefix.indexOf(":"); - if (firstColonIdx === -1) return null; - const pathLength = Number.parseInt(afterPrefix.slice(0, firstColonIdx), 10); - if (Number.isNaN(pathLength) || pathLength < 0) return null; - const pathStart = firstColonIdx + 1; - const path = afterPrefix.slice(pathStart, pathStart + pathLength); - const positionStart = pathStart + pathLength + 1; - const remaining = afterPrefix.slice(positionStart); - const posColonIdx = remaining.indexOf(":"); - if (posColonIdx === -1) return null; - const position = Number.parseInt(remaining.slice(0, posColonIdx), 10); - if (Number.isNaN(position) || position < 0) return null; - const content = remaining.slice(posColonIdx + 1); - return { path, position, content }; -} - -/** - * Encode read-write file descriptor content. - */ -function encodeRwFdContent( - path: string, - position: number, - content: string, -): string { - return `__rw__:${path.length}:${path}:${position}:${content}`; -} - -export function handleRead( - ctx: InterpreterContext, - args: string[], - stdin: string, - stdinSourceFd = -1, -): ExecResult { - // Parse options - let raw = false; - let delimiter = "\n"; - let _prompt = ""; - let nchars = -1; // -n option: number of characters to read (with IFS splitting) - let ncharsExact = -1; // -N option: read exactly N characters (no processing) - let arrayName: string | null = null; // -a option: read into array - let fileDescriptor = -1; // -u option: read from file descriptor - let timeout = -1; // -t option: timeout in seconds - const varNames: string[] = []; - - let i = 0; - let invalidNArg = false; - - // Helper to parse smooshed options like -rn1 or -rd '' - const parseOption = ( - opt: string, - argIndex: number, - ): { nextArgIndex: number } => { - let j = 1; // skip the '-' - while (j < opt.length) { - const ch = opt[j]; - if (ch === "r") { - raw = true; - j++; - } else if (ch === "s") { - // Silent - ignore in non-interactive mode - j++; - } else if (ch === "d") { - // -d requires value: either rest of this arg or next arg - if (j + 1 < opt.length) { - delimiter = opt.substring(j + 1); - return { nextArgIndex: argIndex + 1 }; - } else if (argIndex + 1 < args.length) { - delimiter = args[argIndex + 1]; - return { nextArgIndex: argIndex + 2 }; - } - return { nextArgIndex: argIndex + 1 }; - } else if (ch === "n") { - // -n requires value: either rest of this arg or next arg - if (j + 1 < opt.length) { - const numStr = opt.substring(j + 1); - nchars = Number.parseInt(numStr, 10); - if (Number.isNaN(nchars) || nchars < 0) { - invalidNArg = true; - nchars = 0; - } - return { nextArgIndex: argIndex + 1 }; - } else if (argIndex + 1 < args.length) { - nchars = Number.parseInt(args[argIndex + 1], 10); - if (Number.isNaN(nchars) || nchars < 0) { - invalidNArg = true; - nchars = 0; - } - return { nextArgIndex: argIndex + 2 }; - } - return { nextArgIndex: argIndex + 1 }; - } else if (ch === "N") { - // -N requires value: either rest of this arg or next arg - if (j + 1 < opt.length) { - const numStr = opt.substring(j + 1); - ncharsExact = Number.parseInt(numStr, 10); - if (Number.isNaN(ncharsExact) || ncharsExact < 0) { - invalidNArg = true; - ncharsExact = 0; - } - return { nextArgIndex: argIndex + 1 }; - } else if (argIndex + 1 < args.length) { - ncharsExact = Number.parseInt(args[argIndex + 1], 10); - if (Number.isNaN(ncharsExact) || ncharsExact < 0) { - invalidNArg = true; - ncharsExact = 0; - } - return { nextArgIndex: argIndex + 2 }; - } - return { nextArgIndex: argIndex + 1 }; - } else if (ch === "a") { - // -a requires value: either rest of this arg or next arg - if (j + 1 < opt.length) { - arrayName = opt.substring(j + 1); - return { nextArgIndex: argIndex + 1 }; - } else if (argIndex + 1 < args.length) { - arrayName = args[argIndex + 1]; - return { nextArgIndex: argIndex + 2 }; - } - return { nextArgIndex: argIndex + 1 }; - } else if (ch === "p") { - // -p requires value: either rest of this arg or next arg - if (j + 1 < opt.length) { - _prompt = opt.substring(j + 1); - return { nextArgIndex: argIndex + 1 }; - } else if (argIndex + 1 < args.length) { - _prompt = args[argIndex + 1]; - return { nextArgIndex: argIndex + 2 }; - } - return { nextArgIndex: argIndex + 1 }; - } else if (ch === "u") { - // -u requires value: file descriptor number - if (j + 1 < opt.length) { - const numStr = opt.substring(j + 1); - fileDescriptor = Number.parseInt(numStr, 10); - if (Number.isNaN(fileDescriptor) || fileDescriptor < 0) { - return { nextArgIndex: -2 }; // signal error (return exit code 1) - } - return { nextArgIndex: argIndex + 1 }; - } else if (argIndex + 1 < args.length) { - fileDescriptor = Number.parseInt(args[argIndex + 1], 10); - if (Number.isNaN(fileDescriptor) || fileDescriptor < 0) { - return { nextArgIndex: -2 }; // signal error (return exit code 1) - } - return { nextArgIndex: argIndex + 2 }; - } - return { nextArgIndex: argIndex + 1 }; - } else if (ch === "t") { - // -t requires value: timeout in seconds (can be float) - if (j + 1 < opt.length) { - const numStr = opt.substring(j + 1); - timeout = Number.parseFloat(numStr); - if (Number.isNaN(timeout)) { - timeout = 0; - } - return { nextArgIndex: argIndex + 1 }; - } else if (argIndex + 1 < args.length) { - timeout = Number.parseFloat(args[argIndex + 1]); - if (Number.isNaN(timeout)) { - timeout = 0; - } - return { nextArgIndex: argIndex + 2 }; - } - return { nextArgIndex: argIndex + 1 }; - } else if (ch === "e" || ch === "i" || ch === "P") { - // Interactive options - skip (with potential argument for -i) - if (ch === "i" && argIndex + 1 < args.length) { - return { nextArgIndex: argIndex + 2 }; - } - j++; - } else { - // Unknown option, skip - j++; - } - } - return { nextArgIndex: argIndex + 1 }; - }; - - while (i < args.length) { - const arg = args[i]; - if (arg.startsWith("-") && arg.length > 1 && arg !== "--") { - const parseResult = parseOption(arg, i); - if (parseResult.nextArgIndex === -1) { - // Invalid argument (e.g., unknown option) - return exit code 2 - return { stdout: "", stderr: "", exitCode: 2 }; - } - if (parseResult.nextArgIndex === -2) { - // Invalid argument value (e.g., -u with negative number) - return exit code 1 - return { stdout: "", stderr: "", exitCode: 1 }; - } - i = parseResult.nextArgIndex; - } else if (arg === "--") { - i++; - // Rest are variable names - while (i < args.length) { - varNames.push(args[i]); - i++; - } - } else { - varNames.push(arg); - i++; - } - } - - // Return error if -n had invalid argument - if (invalidNArg) { - return result("", "", 1); - } - - // Default variable is REPLY - if (varNames.length === 0 && arrayName === null) { - varNames.push("REPLY"); - } - - // Note: prompt (-p) would typically output to terminal, but we ignore it in non-interactive mode - - // Handle -t 0: check if input is available without reading - // In bash, -t 0 is a "poll" operation that always succeeds (returns 0) as long as - // stdin is valid/readable. It doesn't actually read any data. - if (timeout === 0) { - // Clear any variables to empty (read doesn't actually read anything) - if (arrayName) { - clearArray(ctx, arrayName); - } else { - for (const name of varNames) { - ctx.state.env.set(name, ""); - } - if (varNames.length === 0) { - ctx.state.env.set("REPLY", ""); - } - } - return result("", "", 0); // Always succeed - stdin is valid - } - - // Handle negative timeout - bash returns exit code 1 - if (timeout < 0 && timeout !== -1) { - return result("", "", 1); - } - - // Use stdin from parameter, or fall back to groupStdin (for piped groups/while loops) - // If -u is specified, use the file descriptor content instead - let effectiveStdin = stdin; - - if (fileDescriptor >= 0) { - // Read from specified file descriptor - if (ctx.state.fileDescriptors) { - effectiveStdin = ctx.state.fileDescriptors.get(fileDescriptor) || ""; - } else { - effectiveStdin = ""; - } - } else if (!effectiveStdin && ctx.state.groupStdin !== undefined) { - effectiveStdin = ctx.state.groupStdin; - } - - // Handle -d '' (empty delimiter) - reads until NUL byte - // Empty string delimiter means read until NUL byte (\0) - const effectiveDelimiter = delimiter === "" ? "\0" : delimiter; - - // Get input - let line = ""; - let consumed = 0; - let foundDelimiter = true; // Assume found unless no newline at end - - // Helper to consume from the appropriate source - const consumeInput = (bytesConsumed: number) => { - if (fileDescriptor >= 0 && ctx.state.fileDescriptors) { - ctx.state.fileDescriptors.set( - fileDescriptor, - effectiveStdin.substring(bytesConsumed), - ); - } else if (stdinSourceFd >= 0 && ctx.state.fileDescriptors) { - // Update the position of a read-write FD that was redirected to stdin - const fdContent = ctx.state.fileDescriptors.get(stdinSourceFd); - if (fdContent?.startsWith("__rw__:")) { - const parsed = parseRwFdContent(fdContent); - if (parsed) { - // Advance position by bytesConsumed - const newPosition = parsed.position + bytesConsumed; - ctx.state.fileDescriptors.set( - stdinSourceFd, - encodeRwFdContent(parsed.path, newPosition, parsed.content), - ); - } - } - } else if (ctx.state.groupStdin !== undefined && !stdin) { - ctx.state.groupStdin = effectiveStdin.substring(bytesConsumed); - } - }; - - if (ncharsExact >= 0) { - // -N: Read exactly N characters (ignores delimiters, no IFS splitting) - const toRead = Math.min(ncharsExact, effectiveStdin.length); - line = effectiveStdin.substring(0, toRead); - consumed = toRead; - foundDelimiter = toRead >= ncharsExact; - - // Consume from appropriate source - consumeInput(consumed); - - // With -N, assign entire content to first variable (no IFS splitting) - const varName = varNames[0] || "REPLY"; - ctx.state.env.set(varName, line); - // Set remaining variables to empty - for (let j = 1; j < varNames.length; j++) { - ctx.state.env.set(varNames[j], ""); - } - return result("", "", foundDelimiter ? 0 : 1); - } else if (nchars >= 0) { - // -n: Read at most N characters (or until delimiter/EOF), then apply IFS splitting - // In non-raw mode, backslash escapes are processed: \X counts as 1 char (the X) - let charCount = 0; - let inputPos = 0; - let hitDelimiter = false; - while (inputPos < effectiveStdin.length && charCount < nchars) { - const char = effectiveStdin[inputPos]; - if (char === effectiveDelimiter) { - consumed = inputPos + 1; - hitDelimiter = true; - break; - } - if (!raw && char === "\\" && inputPos + 1 < effectiveStdin.length) { - // Backslash escape: consume both chars, but only count as 1 char - // The escaped character is kept, backslash is removed - const nextChar = effectiveStdin[inputPos + 1]; - if (nextChar === effectiveDelimiter && effectiveDelimiter === "\n") { - // Backslash-newline is a line continuation: consume both, don't count as a char - // Continue reading from the next line - inputPos += 2; - consumed = inputPos; - continue; - } - if (nextChar === effectiveDelimiter) { - // Backslash-delimiter (non-newline): counts as one char (the escaped delimiter) - inputPos += 2; - charCount++; - line += nextChar; - consumed = inputPos; - continue; - } - line += nextChar; - inputPos += 2; - charCount++; - consumed = inputPos; - } else { - line += char; - inputPos++; - charCount++; - consumed = inputPos; - } - } - // For -n: success if we read enough characters OR if we hit the delimiter - // Failure (exit 1) only if EOF reached before nchars and before delimiter - foundDelimiter = charCount >= nchars || hitDelimiter; - // Consume from appropriate source - consumeInput(consumed); - } else { - // Read until delimiter, handling line continuation (backslash-newline) if not raw mode - // Backslash-newline continuation is handled regardless of the delimiter - it's a line continuation feature - // Backslash-delimiter escapes the delimiter, making it literal - consumed = 0; - let inputPos = 0; - - while (inputPos < effectiveStdin.length) { - const char = effectiveStdin[inputPos]; - - // Check for delimiter - if (char === effectiveDelimiter) { - consumed = inputPos + effectiveDelimiter.length; - foundDelimiter = true; - break; - } - - // In non-raw mode, handle backslash escapes - if (!raw && char === "\\" && inputPos + 1 < effectiveStdin.length) { - const nextChar = effectiveStdin[inputPos + 1]; - - if (nextChar === "\n") { - // Backslash-newline is line continuation: skip both, regardless of delimiter - inputPos += 2; - continue; - } - - if (nextChar === effectiveDelimiter) { - // Backslash-delimiter: escape the delimiter, include it literally - line += nextChar; - inputPos += 2; - continue; - } - - // Other backslash escapes: keep both for now (will be processed later) - line += char; - line += nextChar; - inputPos += 2; - continue; - } - - line += char; - inputPos++; - } - - // If we exited the loop without finding a delimiter, we consumed everything - // foundDelimiter remains at initial value (true) only if we explicitly set it in the loop - // So check if we actually found the delimiter by seeing if we broke early - if (inputPos >= effectiveStdin.length) { - // We reached end of input without finding delimiter - foundDelimiter = false; - consumed = inputPos; - // Check if we got any content - if (line.length === 0 && effectiveStdin.length === 0) { - // No input at all - return failure - for (const name of varNames) { - ctx.state.env.set(name, ""); - } - if (arrayName) { - clearArray(ctx, arrayName); - } - return result("", "", 1); - } - } - - // Consume from appropriate source - consumeInput(consumed); - } - - // Remove trailing newline if present and delimiter is newline - if (effectiveDelimiter === "\n" && line.endsWith("\n")) { - line = line.slice(0, -1); - } - - // Helper to process backslash escapes (remove backslashes, keep escaped chars) - const processBackslashEscapes = (s: string): string => { - if (raw) return s; - return s.replace(/\\(.)/g, "$1"); - }; - - // If no variable names given (only REPLY), store whole line without IFS splitting - // This preserves leading/trailing whitespace - if (varNames.length === 1 && varNames[0] === "REPLY") { - ctx.state.env.set("REPLY", processBackslashEscapes(line)); - return result("", "", foundDelimiter ? 0 : 1); - } - - // Split by IFS (default is space, tab, newline) - const ifs = getIfs(ctx.state.env); - - // Handle array assignment (-a) - if (arrayName) { - // Pass raw flag - splitting respects backslash escapes in non-raw mode - const { words } = splitByIfsForRead(line, ifs, undefined, raw); - - // Check array element limit - const maxArrayElements = ctx.limits?.maxArrayElements ?? 100000; - if (words.length > maxArrayElements) { - return result( - "", - `read: array element limit exceeded (${maxArrayElements})\n`, - 1, - ); - } - - clearArray(ctx, arrayName); - // Assign words to array elements, processing backslash escapes after splitting - for (let j = 0; j < words.length; j++) { - ctx.state.env.set(`${arrayName}_${j}`, processBackslashEscapes(words[j])); - } - return result("", "", foundDelimiter ? 0 : 1); - } - - // Use the advanced IFS splitting for read with proper whitespace/non-whitespace handling - // Pass raw flag - splitting respects backslash escapes in non-raw mode - const maxSplit = varNames.length; - const { words, wordStarts } = splitByIfsForRead(line, ifs, maxSplit, raw); - - // Assign words to variables - for (let j = 0; j < varNames.length; j++) { - const name = varNames[j]; - if (j < varNames.length - 1) { - // Assign single word, processing backslash escapes - ctx.state.env.set(name, processBackslashEscapes(words[j] ?? "")); - } else { - // Last variable gets all remaining content from original line - // This preserves original separators (tabs, etc.) but strips trailing IFS - if (j < wordStarts.length) { - // Strip trailing IFS first (respects backslash escapes), then process backslashes - let value = line.substring(wordStarts[j]); - value = stripTrailingIfsWhitespace(value, ifs, raw); - value = processBackslashEscapes(value); - ctx.state.env.set(name, value); - } else { - ctx.state.env.set(name, ""); - } - } - } - - return result("", "", foundDelimiter ? 0 : 1); -} diff --git a/src/interpreter/builtins/return.test.ts b/src/interpreter/builtins/return.test.ts deleted file mode 100644 index c30b3822..00000000 --- a/src/interpreter/builtins/return.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("return builtin", () => { - describe("basic return", () => { - it("should return from function with default exit code", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - echo before - return - echo after - } - myfunc - echo done - `); - expect(result.stdout).toBe("before\ndone\n"); - expect(result.exitCode).toBe(0); - }); - - it("should return from function with specified exit code", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - return 42 - } - myfunc - echo $? - `); - expect(result.stdout).toBe("42\n"); - }); - - it("should use last command exit code when no argument", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - false - return - } - myfunc - echo $? - `); - expect(result.stdout).toBe("1\n"); - }); - - it("should handle exit code 0", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - return 0 - } - myfunc - echo $? - `); - expect(result.stdout).toBe("0\n"); - }); - }); - - describe("exit code modulo 256", () => { - it("should wrap large exit codes", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - return 256 - } - myfunc - echo $? - `); - expect(result.stdout).toBe("0\n"); - }); - - it("should handle 257 as 1", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - return 257 - } - myfunc - echo $? - `); - expect(result.stdout).toBe("1\n"); - }); - - it("should handle negative numbers", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - return -1 - } - myfunc - echo $? - `); - expect(result.stdout).toBe("255\n"); - }); - }); - - describe("error cases", () => { - it("should error when not in function", async () => { - const env = new Bash(); - const result = await env.exec("return"); - expect(result.stderr).toContain( - "can only `return' from a function or sourced script", - ); - expect(result.exitCode).toBe(1); - }); - - it("should error on non-numeric argument", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - return abc - } - myfunc - `); - expect(result.stderr).toContain("numeric argument required"); - expect(result.exitCode).toBe(2); - }); - }); - - describe("nested functions", () => { - it("should only return from innermost function", async () => { - const env = new Bash(); - const result = await env.exec(` - outer() { - echo outer-start - inner() { - echo inner - return 5 - } - inner - echo "inner returned $?" - } - outer - echo "outer returned $?" - `); - expect(result.stdout).toBe( - "outer-start\ninner\ninner returned 5\nouter returned 0\n", - ); - }); - - it("should propagate return through control flow", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - for i in 1 2 3; do - if [ $i -eq 2 ]; then - return 42 - fi - echo $i - done - echo "never" - } - myfunc - echo $? - `); - expect(result.stdout).toBe("1\n42\n"); - }); - }); - - describe("return with output", () => { - it("should preserve stdout before return", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - echo line1 - echo line2 - return 3 - } - myfunc - echo "exit: $?" - `); - expect(result.stdout).toBe("line1\nline2\nexit: 3\n"); - }); - - it("should preserve stderr before return", async () => { - const env = new Bash(); - // Use a command that actually produces stderr (command not found) - const result = await env.exec(` - myfunc() { - nonexistent_cmd_xyz 2>/dev/null || true - return 5 - } - myfunc - `); - // The key thing is that return works and preserves the exit code - expect(result.exitCode).toBe(5); - }); - }); -}); diff --git a/src/interpreter/builtins/return.ts b/src/interpreter/builtins/return.ts deleted file mode 100644 index eb008e7c..00000000 --- a/src/interpreter/builtins/return.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * return - Return from a function with an exit code - */ - -import type { ExecResult } from "../../types.js"; -import { ReturnError } from "../errors.js"; -import { failure } from "../helpers/result.js"; -import type { InterpreterContext } from "../types.js"; - -export function handleReturn( - ctx: InterpreterContext, - args: string[], -): ExecResult { - // Check if we're in a function or sourced script - if (ctx.state.callDepth === 0 && ctx.state.sourceDepth === 0) { - return failure( - "bash: return: can only `return' from a function or sourced script\n", - ); - } - - let exitCode = ctx.state.lastExitCode; - if (args.length > 0) { - const arg = args[0]; - // Empty string or non-numeric is an error - const n = Number.parseInt(arg, 10); - if (arg === "" || Number.isNaN(n) || !/^-?\d+$/.test(arg)) { - return failure(`bash: return: ${arg}: numeric argument required\n`, 2); - } - // Bash uses modulo 256 for exit codes - exitCode = ((n % 256) + 256) % 256; - } - - throw new ReturnError(exitCode); -} diff --git a/src/interpreter/builtins/set.test.ts b/src/interpreter/builtins/set.test.ts deleted file mode 100644 index 3c02e97b..00000000 --- a/src/interpreter/builtins/set.test.ts +++ /dev/null @@ -1,524 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("set builtin", () => { - describe("set with no args (variable listing)", () => { - it("should output associative arrays in bash format", async () => { - const env = new Bash(); - const result = await env.exec(` - typeset -A __assoc - __assoc['k e y']='v a l' - __assoc[a]=b - set | grep '^__assoc=' - `); - expect(result.exitCode).toBe(0); - // Bash format: __assoc=([a]="b" ["k e y"]="v a l" ) - // Keys are sorted, so 'a' comes before 'k e y' - expect(result.stdout).toBe('__assoc=([a]="b" ["k e y"]="v a l" )\n'); - }); - - it("should not show assoc array elements as separate scalars", async () => { - const env = new Bash(); - const result = await env.exec(` - typeset -A __assoc - __assoc[a]=b - set | grep '^__assoc' | wc -l - `); - expect(result.exitCode).toBe(0); - // Should only be one line, the array output, not multiple lines - expect(result.stdout.trim()).toBe("1"); - }); - }); - - describe("set -u (nounset)", () => { - it("should error on unset variable when enabled", async () => { - const env = new Bash(); - const result = await env.exec(` - set -u - echo $UNDEFINED_VAR - `); - expect(result.stderr).toContain("UNDEFINED_VAR: unbound variable"); - expect(result.exitCode).toBe(1); - }); - - it("should not error on set variable", async () => { - const env = new Bash(); - const result = await env.exec(` - set -u - MYVAR=hello - echo $MYVAR - `); - expect(result.stdout).toBe("hello\n"); - expect(result.exitCode).toBe(0); - }); - - it("should allow empty string as valid value", async () => { - const env = new Bash(); - const result = await env.exec(` - set -u - MYVAR="" - echo "value: $MYVAR" - `); - expect(result.stdout).toBe("value: \n"); - expect(result.exitCode).toBe(0); - }); - - it("should be disabled by +u", async () => { - const env = new Bash(); - const result = await env.exec(` - set -u - set +u - echo $UNDEFINED - `); - expect(result.stdout).toBe("\n"); - expect(result.exitCode).toBe(0); - }); - - it("should work with -o nounset", async () => { - const env = new Bash(); - const result = await env.exec(` - set -o nounset - echo $UNDEFINED - `); - expect(result.stderr).toContain("UNDEFINED: unbound variable"); - expect(result.exitCode).toBe(1); - }); - - it("should be disabled with +o nounset", async () => { - const env = new Bash(); - const result = await env.exec(` - set -o nounset - set +o nounset - echo $UNDEFINED - `); - expect(result.stdout).toBe("\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("special variables with nounset", () => { - it("should not error on $? with nounset", async () => { - const env = new Bash(); - const result = await env.exec(` - set -u - echo $? - `); - expect(result.stdout).toBe("0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should not error on $$ with nounset", async () => { - const env = new Bash(); - const result = await env.exec(` - set -u - echo $$ - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toBe(""); - }); - - it("should not error on $# with nounset", async () => { - const env = new Bash(); - const result = await env.exec(` - set -u - echo $# - `); - expect(result.stdout).toBe("0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should not error on $@ with nounset when no args", async () => { - const env = new Bash(); - const result = await env.exec(` - set -u - echo "$@" - `); - expect(result.stdout).toBe("\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("positional parameters with nounset", () => { - it("should error on unset positional parameter", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - set -u - echo $1 - } - myfunc - `); - expect(result.stderr).toContain("1: unbound variable"); - expect(result.exitCode).toBe(1); - }); - - it("should not error on set positional parameter", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - set -u - echo $1 - } - myfunc hello - `); - expect(result.stdout).toBe("hello\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("default value expansion with nounset", () => { - it("should allow ${var:-default} with unset var", async () => { - const env = new Bash(); - const result = await env.exec(` - set -u - echo \${UNSET:-default} - `); - expect(result.stdout).toBe("default\n"); - expect(result.exitCode).toBe(0); - }); - - it("should allow ${var:=default} with unset var", async () => { - const env = new Bash(); - const result = await env.exec(` - set -u - echo \${UNSET:=default} - echo $UNSET - `); - expect(result.stdout).toBe("default\ndefault\n"); - expect(result.exitCode).toBe(0); - }); - - it("should allow ${var:+value} with unset var", async () => { - const env = new Bash(); - const result = await env.exec(` - set -u - echo ":\${UNSET:+alt}:" - `); - expect(result.stdout).toBe("::\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("set -e and set -u combined", () => { - it("should handle both options together", async () => { - const env = new Bash(); - const result = await env.exec(` - set -eu - VAR=hello - echo $VAR - `); - expect(result.stdout).toBe("hello\n"); - expect(result.exitCode).toBe(0); - }); - - it("should exit on unset var with -eu", async () => { - const env = new Bash(); - const result = await env.exec(` - set -eu - echo $UNDEFINED - echo "never" - `); - expect(result.stderr).toContain("UNDEFINED: unbound variable"); - expect(result.exitCode).toBe(1); - expect(result.stdout).not.toContain("never"); - }); - }); - - describe("set -e (errexit)", () => { - it("should exit immediately when command fails", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - echo before - false - echo after - `); - expect(result.stdout).toBe("before\n"); - expect(result.exitCode).toBe(1); - }); - - it("should continue execution without set -e", async () => { - const env = new Bash(); - const result = await env.exec(` - echo before - false - echo after - `); - expect(result.stdout).toBe("before\nafter\n"); - expect(result.exitCode).toBe(0); - }); - - it("should not exit if command succeeds", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - echo one - true - echo two - `); - expect(result.stdout).toBe("one\ntwo\n"); - expect(result.exitCode).toBe(0); - }); - - it("should disable errexit with set +e", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - set +e - echo before - false - echo after - `); - expect(result.stdout).toBe("before\nafter\n"); - expect(result.exitCode).toBe(0); - }); - - it("should enable errexit with set -o errexit", async () => { - const env = new Bash(); - const result = await env.exec(` - set -o errexit - echo before - false - echo after - `); - expect(result.stdout).toBe("before\n"); - expect(result.exitCode).toBe(1); - }); - - it("should disable errexit with set +o errexit", async () => { - const env = new Bash(); - const result = await env.exec(` - set -o errexit - set +o errexit - echo before - false - echo after - `); - expect(result.stdout).toBe("before\nafter\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("errexit exceptions", () => { - it("should not exit on failed command in && short-circuit", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - false && echo "not reached" - echo after - `); - expect(result.stdout).toBe("after\n"); - expect(result.exitCode).toBe(0); - }); - - it("should not exit on failed command in || short-circuit", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - false || echo "fallback" - echo after - `); - expect(result.stdout).toBe("fallback\nafter\n"); - expect(result.exitCode).toBe(0); - }); - - it("should exit if final command in && list fails", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - echo before - true && false - echo after - `); - expect(result.stdout).toBe("before\n"); - expect(result.exitCode).toBe(1); - }); - - it("should not exit on negated failed command", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - ! false - echo after - `); - expect(result.stdout).toBe("after\n"); - expect(result.exitCode).toBe(0); - }); - - it("should not exit on failed command in if condition", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - if false; then - echo "then" - else - echo "else" - fi - echo after - `); - expect(result.stdout).toBe("else\nafter\n"); - expect(result.exitCode).toBe(0); - }); - - it("should exit on failed command in if body", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - if true; then - echo "in body" - false - echo "not reached" - fi - echo after - `); - expect(result.stdout).toBe("in body\n"); - expect(result.exitCode).toBe(1); - }); - - it("should not exit on failed condition that terminates while loop", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - x=0 - while [ $x -lt 3 ]; do - echo $x - x=$((x + 1)) - done - echo after - `); - expect(result.stdout).toBe("0\n1\n2\nafter\n"); - expect(result.exitCode).toBe(0); - }); - - it("should exit on failed command in while body", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - x=0 - while [ $x -lt 3 ]; do - echo $x - false - x=$((x + 1)) - done - echo after - `); - expect(result.stdout).toBe("0\n"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("set -o pipefail", () => { - it("should return success when all commands succeed", async () => { - const env = new Bash(); - const result = await env.exec(` - set -o pipefail - echo hello | cat | cat - echo "exit: $?" - `); - expect(result.stdout).toBe("hello\nexit: 0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should return failure when first command fails", async () => { - const env = new Bash(); - const result = await env.exec(` - set -o pipefail - false | true - echo "exit: $?" - `); - expect(result.stdout).toBe("exit: 1\n"); - expect(result.exitCode).toBe(0); - }); - - it("should return failure when middle command fails", async () => { - const env = new Bash(); - const result = await env.exec(` - set -o pipefail - echo hello | false | cat - echo "exit: $?" - `); - expect(result.stdout).toBe("exit: 1\n"); - expect(result.exitCode).toBe(0); - }); - - it("should return rightmost failing exit code", async () => { - const env = new Bash(); - const result = await env.exec(` - set -o pipefail - exit 2 | exit 3 | true - echo "exit: $?" - `); - expect(result.stdout).toBe("exit: 3\n"); - expect(result.exitCode).toBe(0); - }); - - it("should return last command exit code without pipefail", async () => { - const env = new Bash(); - const result = await env.exec(` - false | true - echo "exit: $?" - `); - expect(result.stdout).toBe("exit: 0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should disable pipefail with +o pipefail", async () => { - const env = new Bash(); - const result = await env.exec(` - set -o pipefail - set +o pipefail - false | true - echo "exit: $?" - `); - expect(result.stdout).toBe("exit: 0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should trigger errexit when pipeline fails with pipefail", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - set -o pipefail - echo before - false | true - echo after - `); - expect(result.stdout).toBe("before\n"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("set error handling", () => { - it("should show help with --help", async () => { - const env = new Bash(); - const result = await env.exec("set --help"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("usage:"); - expect(result.stdout).toContain("-e"); - }); - - it("should error on unknown short option", async () => { - const env = new Bash(); - const result = await env.exec("set -z"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("-z"); - expect(result.stderr).toContain("invalid option"); - }); - - it("should error on unknown long option", async () => { - const env = new Bash(); - const result = await env.exec("set -o unknownoption"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("unknownoption"); - expect(result.stderr).toContain("invalid option name"); - }); - - it("should list options when -o has no argument", async () => { - // In bash, `set -o` without argument lists all options - const env = new Bash(); - const result = await env.exec("set -o"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("errexit"); - }); - }); -}); diff --git a/src/interpreter/builtins/set.ts b/src/interpreter/builtins/set.ts deleted file mode 100644 index d13f99e8..00000000 --- a/src/interpreter/builtins/set.ts +++ /dev/null @@ -1,485 +0,0 @@ -/** - * set - Set/unset shell options builtin - * - * In POSIX mode (set -o posix), errors from set (like invalid options) - * cause the script to exit immediately. - */ - -import type { ExecResult } from "../../types.js"; -import { PosixFatalError } from "../errors.js"; -import { getArrayIndices, getAssocArrayKeys } from "../helpers/array.js"; -import { quoteArrayValue, quoteValue } from "../helpers/quoting.js"; -import { failure, OK, success } from "../helpers/result.js"; -import { updateShellopts } from "../helpers/shellopts.js"; -import type { InterpreterContext, ShellOptions } from "../types.js"; - -const SET_USAGE = `set: usage: set [-eux] [+eux] [-o option] [+o option] -Options: - -e Exit immediately if a command exits with non-zero status - +e Disable -e - -u Treat unset variables as an error when substituting - +u Disable -u - -x Print commands and their arguments as they are executed - +x Disable -x - -o errexit Same as -e - +o errexit Disable errexit - -o nounset Same as -u - +o nounset Disable nounset - -o pipefail Return status of last failing command in pipeline - +o pipefail Disable pipefail - -o xtrace Same as -x - +o xtrace Disable xtrace -`; - -// Map short options to their corresponding shell option property -// Options not in this map are valid but no-ops -const SHORT_OPTION_MAP = new Map([ - ["e", "errexit"], - ["u", "nounset"], - ["x", "xtrace"], - ["v", "verbose"], - // Implemented options - ["f", "noglob"], - ["C", "noclobber"], - ["a", "allexport"], - ["n", "noexec"], - // No-ops (accepted for compatibility) - ["h", null], - ["b", null], - ["m", null], - ["B", null], - ["H", null], - ["P", null], - ["T", null], - ["E", null], - ["p", null], -]); - -// Map long options to their corresponding shell option property -// Options not mapped to a property are valid but no-ops -const LONG_OPTION_MAP = new Map([ - ["errexit", "errexit"], - ["pipefail", "pipefail"], - ["nounset", "nounset"], - ["xtrace", "xtrace"], - ["verbose", "verbose"], - // Implemented options - ["noclobber", "noclobber"], - ["noglob", "noglob"], - ["allexport", "allexport"], - ["noexec", "noexec"], - ["posix", "posix"], - ["vi", "vi"], - ["emacs", "emacs"], - // No-ops (accepted for compatibility) - ["notify", null], - ["monitor", null], - ["braceexpand", null], - ["histexpand", null], - ["physical", null], - ["functrace", null], - ["errtrace", null], - ["privileged", null], - ["hashall", null], - ["ignoreeof", null], - ["interactive-comments", null], - ["keyword", null], - ["onecmd", null], -]); - -// List of implemented options to display in `set -o` / `set +o` output -const DISPLAY_OPTIONS: (keyof ShellOptions)[] = [ - "errexit", - "nounset", - "pipefail", - "verbose", - "xtrace", - "posix", - "allexport", - "noclobber", - "noglob", - "noexec", - "vi", - "emacs", -]; - -// List of no-op options to display (always off, for compatibility) -const NOOP_DISPLAY_OPTIONS: string[] = [ - "braceexpand", - "errtrace", - "functrace", - "hashall", - "histexpand", - "history", - "ignoreeof", - "interactive-comments", - "keyword", - "monitor", - "nolog", - "notify", - "onecmd", - "physical", - "privileged", -]; - -/** - * Set a shell option value using the option map. - * Also updates the SHELLOPTS environment variable. - * Handles mutual exclusivity for vi/emacs options. - */ -function setShellOption( - ctx: InterpreterContext, - optionKey: keyof ShellOptions | null, - value: boolean, -): void { - if (optionKey !== null) { - // Handle mutual exclusivity of vi and emacs - if (value) { - if (optionKey === "vi") { - ctx.state.options.emacs = false; - } else if (optionKey === "emacs") { - ctx.state.options.vi = false; - } - } - ctx.state.options[optionKey] = value; - updateShellopts(ctx); - } -} - -/** - * Check if the next argument exists and is not an option flag - */ -function hasNonOptionArg(args: string[], i: number): boolean { - return ( - i + 1 < args.length && - !args[i + 1].startsWith("-") && - !args[i + 1].startsWith("+") - ); -} - -/** - * Format an array variable for set output - * Format: arr=([0]="a" [1]="b" [2]="c") - */ -function formatArrayOutput(ctx: InterpreterContext, arrayName: string): string { - const indices = getArrayIndices(ctx, arrayName); - if (indices.length === 0) { - return `${arrayName}=()`; - } - - const elements = indices.map((i) => { - const value = ctx.state.env.get(`${arrayName}_${i}`) ?? ""; - return `[${i}]=${quoteArrayValue(value)}`; - }); - - return `${arrayName}=(${elements.join(" ")})`; -} - -/** - * Quote a key for associative array output - * Keys with spaces or special characters are quoted with double quotes - */ -function quoteAssocKey(key: string): string { - // If key contains no special chars, return as-is - // Safe chars: alphanumerics, underscore - if (/^[a-zA-Z0-9_]+$/.test(key)) { - return key; - } - // Use double quotes for keys with spaces or shell metacharacters - // Escape backslashes and double quotes - const escaped = key.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - return `"${escaped}"`; -} - -/** - * Format an associative array variable for set output - * Format: arr=([key1]="val1" [key2]="val2" ) - * Note: bash adds a trailing space before the closing paren - */ -function formatAssocArrayOutput( - ctx: InterpreterContext, - arrayName: string, -): string { - const keys = getAssocArrayKeys(ctx, arrayName); - if (keys.length === 0) { - return `${arrayName}=()`; - } - - const elements = keys.map((k) => { - const value = ctx.state.env.get(`${arrayName}_${k}`) ?? ""; - return `[${quoteAssocKey(k)}]=${quoteArrayValue(value)}`; - }); - - // Note: bash has a trailing space before the closing paren for assoc arrays - return `${arrayName}=(${elements.join(" ")} )`; -} - -/** - * Get all indexed array names from the environment (excluding associative arrays) - */ -function getIndexedArrayNames(ctx: InterpreterContext): Set { - const arrayNames = new Set(); - const assocArrays = ctx.state.associativeArrays ?? new Set(); - - for (const key of ctx.state.env.keys()) { - // Match array element pattern: name_index where index is numeric - const match = key.match(/^([a-zA-Z_][a-zA-Z0-9_]*)_(\d+)$/); - if (match) { - const name = match[1]; - // Exclude associative arrays - they're handled separately - if (!assocArrays.has(name)) { - arrayNames.add(name); - } - } - } - return arrayNames; -} - -/** - * Get all associative array names from state - */ -function getAssocArrayNames(ctx: InterpreterContext): Set { - return ctx.state.associativeArrays ?? new Set(); -} - -export function handleSet(ctx: InterpreterContext, args: string[]): ExecResult { - if (args.includes("--help")) { - return success(SET_USAGE); - } - - // With no arguments, print all shell variables - if (args.length === 0) { - const indexedArrayNames = getIndexedArrayNames(ctx); - const assocArrayNames = getAssocArrayNames(ctx); - - // Helper function to check if a key is an element of any assoc array - const isAssocArrayElement = (key: string): boolean => { - for (const arrayName of assocArrayNames) { - const prefix = `${arrayName}_`; - const metadataSuffix = `${arrayName}__length`; - // Skip metadata entries - if (key === metadataSuffix) { - continue; - } - if (key.startsWith(prefix)) { - const elemKey = key.slice(prefix.length); - // Skip if the key part starts with "_length" (metadata pattern) - if (elemKey.startsWith("_length")) { - continue; - } - return true; - } - } - return false; - }; - - // Collect scalar variables (excluding array elements and internal metadata) - const scalarEntries: [string, string][] = []; - for (const [key, value] of ctx.state.env) { - // Only valid variable names - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { - continue; - } - // Skip if this is an indexed array (has array elements) - if (indexedArrayNames.has(key)) { - continue; - } - // Skip if this is an associative array - if (assocArrayNames.has(key)) { - continue; - } - // Skip indexed array element variables (name_index pattern where name is an indexed array) - const arrayElementMatch = key.match(/^([a-zA-Z_][a-zA-Z0-9_]*)_(\d+)$/); - if (arrayElementMatch && indexedArrayNames.has(arrayElementMatch[1])) { - continue; - } - // Skip indexed array metadata variables (name__length pattern) - const arrayMetadataMatch = key.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)__length$/, - ); - if (arrayMetadataMatch && indexedArrayNames.has(arrayMetadataMatch[1])) { - continue; - } - // Skip associative array element variables - if (isAssocArrayElement(key)) { - continue; - } - // Skip associative array metadata (name__length pattern for assoc arrays) - if (arrayMetadataMatch && assocArrayNames.has(arrayMetadataMatch[1])) { - continue; - } - scalarEntries.push([key, value]); - } - - // Build output: scalars first, then arrays - const lines: string[] = []; - - // Add scalar variables - for (const [key, value] of scalarEntries.sort(([a], [b]) => - a < b ? -1 : a > b ? 1 : 0, - )) { - lines.push(`${key}=${quoteValue(value)}`); - } - - // Add indexed arrays (use ASCII sort order: uppercase before lowercase) - for (const arrayName of [...indexedArrayNames].sort((a, b) => - a < b ? -1 : a > b ? 1 : 0, - )) { - lines.push(formatArrayOutput(ctx, arrayName)); - } - - // Add associative arrays - for (const arrayName of [...assocArrayNames].sort((a, b) => - a < b ? -1 : a > b ? 1 : 0, - )) { - lines.push(formatAssocArrayOutput(ctx, arrayName)); - } - - // Sort all lines together (bash uses ASCII sort order: uppercase before lowercase) - lines.sort((a, b) => { - // Extract variable name for comparison - const nameA = a.split("=")[0]; - const nameB = b.split("=")[0]; - return nameA < nameB ? -1 : nameA > nameB ? 1 : 0; - }); - - return success(lines.length > 0 ? `${lines.join("\n")}\n` : ""); - } - - let i = 0; - while (i < args.length) { - const arg = args[i]; - - // Handle -o / +o with option name - if ((arg === "-o" || arg === "+o") && hasNonOptionArg(args, i)) { - const optName = args[i + 1]; - if (!LONG_OPTION_MAP.has(optName)) { - const errorMsg = `bash: set: ${optName}: invalid option name\n${SET_USAGE}`; - // In POSIX mode, invalid option is fatal - if (ctx.state.options.posix) { - throw new PosixFatalError(1, "", errorMsg); - } - return failure(errorMsg); - } - setShellOption(ctx, LONG_OPTION_MAP.get(optName) ?? null, arg === "-o"); - i += 2; - continue; - } - - // Handle -o alone (print current settings) - if (arg === "-o") { - const implementedOutput = DISPLAY_OPTIONS.map( - (opt) => `${opt.padEnd(16)}${ctx.state.options[opt] ? "on" : "off"}`, - ); - const noopOutput = NOOP_DISPLAY_OPTIONS.map( - (opt) => `${opt.padEnd(16)}off`, - ); - const allOptions = [...implementedOutput, ...noopOutput].sort(); - return success(`${allOptions.join("\n")}\n`); - } - - // Handle +o alone (print commands to recreate settings) - if (arg === "+o") { - const implementedOutput = DISPLAY_OPTIONS.map( - (opt) => `set ${ctx.state.options[opt] ? "-o" : "+o"} ${opt}`, - ); - const noopOutput = NOOP_DISPLAY_OPTIONS.map((opt) => `set +o ${opt}`); - const allOptions = [...implementedOutput, ...noopOutput].sort(); - return success(`${allOptions.join("\n")}\n`); - } - - // Handle combined short flags like -eu or +eu - if ( - arg.length > 1 && - (arg[0] === "-" || arg[0] === "+") && - arg[1] !== "-" - ) { - const enable = arg[0] === "-"; - for (let j = 1; j < arg.length; j++) { - const flag = arg[j]; - if (!SHORT_OPTION_MAP.has(flag)) { - const errorMsg = `bash: set: ${arg[0]}${flag}: invalid option\n${SET_USAGE}`; - // In POSIX mode, invalid option is fatal - if (ctx.state.options.posix) { - throw new PosixFatalError(1, "", errorMsg); - } - return failure(errorMsg); - } - setShellOption(ctx, SHORT_OPTION_MAP.get(flag) ?? null, enable); - } - i++; - continue; - } - - // Handle -- (end of options) - if (arg === "--") { - setPositionalParameters(ctx, args.slice(i + 1)); - return OK; - } - - // Handle - (disable xtrace and verbose, end of options) - if (arg === "-") { - ctx.state.options.xtrace = false; - ctx.state.options.verbose = false; - updateShellopts(ctx); - if (i + 1 < args.length) { - setPositionalParameters(ctx, args.slice(i + 1)); - return OK; - } - i++; - continue; - } - - // Handle + (single + is ignored, continue processing options) - if (arg === "+") { - i++; - continue; - } - - // Invalid option - if (arg.startsWith("-") || arg.startsWith("+")) { - const errorMsg = `bash: set: ${arg}: invalid option\n${SET_USAGE}`; - // In POSIX mode, invalid option is fatal - if (ctx.state.options.posix) { - throw new PosixFatalError(1, "", errorMsg); - } - return failure(errorMsg); - } - - // Non-option arguments are positional parameters - setPositionalParameters(ctx, args.slice(i)); - return OK; - } - - return OK; -} - -/** - * Set positional parameters ($1, $2, etc.) and update $@, $*, $# - */ -function setPositionalParameters( - ctx: InterpreterContext, - params: string[], -): void { - // Clear existing positional parameters - let i = 1; - while (ctx.state.env.has(String(i))) { - ctx.state.env.delete(String(i)); - i++; - } - - // Set new positional parameters - for (let j = 0; j < params.length; j++) { - ctx.state.env.set(String(j + 1), params[j]); - } - - // Update $# (number of parameters) - ctx.state.env.set("#", String(params.length)); - - // Update $@ and $* (all parameters) - ctx.state.env.set("@", params.join(" ")); - ctx.state.env.set("*", params.join(" ")); - - // Note: bash does NOT reset OPTIND when positional parameters change. - // This is intentional to match bash behavior. -} diff --git a/src/interpreter/builtins/shift.test.ts b/src/interpreter/builtins/shift.test.ts deleted file mode 100644 index 1d6d57bb..00000000 --- a/src/interpreter/builtins/shift.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("shift builtin", () => { - describe("basic shift", () => { - it("should shift positional parameters by 1", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - echo "before: $1 $2 $3" - shift - echo "after: $1 $2 $3" - } - myfunc a b c - `); - expect(result.stdout).toBe("before: a b c\nafter: b c \n"); - }); - - it("should update $# after shift", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - echo "count: $#" - shift - echo "count: $#" - } - myfunc a b c - `); - expect(result.stdout).toBe("count: 3\ncount: 2\n"); - }); - - it("should update $@ after shift", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - echo "args: $@" - shift - echo "args: $@" - } - myfunc a b c - `); - expect(result.stdout).toBe("args: a b c\nargs: b c\n"); - }); - }); - - describe("shift with count", () => { - it("should shift by specified count", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - echo "before: $1 $2 $3 $4" - shift 2 - echo "after: $1 $2" - } - myfunc a b c d - `); - expect(result.stdout).toBe("before: a b c d\nafter: c d\n"); - }); - - it("should shift all parameters", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - shift 3 - echo "count: $#" - } - myfunc a b c - `); - expect(result.stdout).toBe("count: 0\n"); - }); - - it("should handle shift 0", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - shift 0 - echo "$1 $2" - } - myfunc a b - `); - expect(result.stdout).toBe("a b\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("error cases", () => { - it("should error when shift count exceeds parameters", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - shift 5 - } - myfunc a b c - `); - expect(result.stderr).toContain("shift count out of range"); - expect(result.exitCode).toBe(1); - }); - - it("should error on negative count", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - shift -1 - } - myfunc a b - `); - expect(result.stderr).toContain("numeric argument required"); - expect(result.exitCode).toBe(1); - }); - - it("should error on non-numeric argument", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - shift abc - } - myfunc a b - `); - expect(result.stderr).toContain("numeric argument required"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("multiple shifts", () => { - it("should handle consecutive shifts", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - echo $1 - shift - echo $1 - shift - echo $1 - } - myfunc a b c - `); - expect(result.stdout).toBe("a\nb\nc\n"); - }); - - it("should work in a loop", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - while [ $# -gt 0 ]; do - echo $1 - shift - done - } - myfunc x y z - `); - expect(result.stdout).toBe("x\ny\nz\n"); - }); - }); - - describe("nested functions", () => { - it("should only affect current function scope", async () => { - const env = new Bash(); - const result = await env.exec(` - outer() { - inner() { - shift - echo "inner: $1" - } - inner x y z - echo "outer: $1" - } - outer a b c - `); - expect(result.stdout).toBe("inner: y\nouter: a\n"); - }); - }); - - describe("edge cases", () => { - it("should work with no parameters", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - shift - } - myfunc - `); - expect(result.stderr).toContain("shift count out of range"); - expect(result.exitCode).toBe(1); - }); - - it("should work with single parameter", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - echo "before: $1" - shift - echo "after: $1" - } - myfunc only - `); - expect(result.stdout).toBe("before: only\nafter: \n"); - }); - }); -}); diff --git a/src/interpreter/builtins/shift.ts b/src/interpreter/builtins/shift.ts deleted file mode 100644 index 77b89dca..00000000 --- a/src/interpreter/builtins/shift.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * shift - Shift positional parameters - * - * shift [n] - * - * Shifts positional parameters to the left by n (default 1). - * $n+1 becomes $1, $n+2 becomes $2, etc. - * $# is decremented by n. - * - * In POSIX mode (set -o posix), errors from shift (like shift count - * exceeding available parameters) cause the script to exit immediately. - */ - -import type { ExecResult } from "../../types.js"; -import { PosixFatalError } from "../errors.js"; -import { failure, OK } from "../helpers/result.js"; -import type { InterpreterContext } from "../types.js"; - -export function handleShift( - ctx: InterpreterContext, - args: string[], -): ExecResult { - // Default shift count is 1 - let n = 1; - - if (args.length > 0) { - const parsed = Number.parseInt(args[0], 10); - if (Number.isNaN(parsed) || parsed < 0) { - const errorMsg = `bash: shift: ${args[0]}: numeric argument required\n`; - // In POSIX mode, this error is fatal - if (ctx.state.options.posix) { - throw new PosixFatalError(1, "", errorMsg); - } - return failure(errorMsg); - } - n = parsed; - } - - // Get current positional parameter count - const currentCount = Number.parseInt(ctx.state.env.get("#") || "0", 10); - - // Check if shift count exceeds available parameters - if (n > currentCount) { - const errorMsg = "bash: shift: shift count out of range\n"; - // In POSIX mode, this error is fatal - if (ctx.state.options.posix) { - throw new PosixFatalError(1, "", errorMsg); - } - return failure(errorMsg); - } - - // If n is 0, do nothing - if (n === 0) { - return OK; - } - - // Get current positional parameters - const params: string[] = []; - for (let i = 1; i <= currentCount; i++) { - params.push(ctx.state.env.get(String(i)) || ""); - } - - // Remove first n parameters - const newParams = params.slice(n); - - // Clear all old positional parameters - for (let i = 1; i <= currentCount; i++) { - ctx.state.env.delete(String(i)); - } - - // Set new positional parameters - for (let i = 0; i < newParams.length; i++) { - ctx.state.env.set(String(i + 1), newParams[i]); - } - - // Update $# and $@ - ctx.state.env.set("#", String(newParams.length)); - ctx.state.env.set("@", newParams.join(" ")); - - return OK; -} diff --git a/src/interpreter/builtins/shopt.ts b/src/interpreter/builtins/shopt.ts deleted file mode 100644 index 9bbfcf61..00000000 --- a/src/interpreter/builtins/shopt.ts +++ /dev/null @@ -1,410 +0,0 @@ -/** - * shopt builtin - Shell options - * Implements bash's shopt builtin for managing shell-specific options - */ - -import type { ExecResult } from "../../types.js"; -import { updateBashopts, updateShellopts } from "../helpers/shellopts.js"; -import type { InterpreterContext } from "../types.js"; - -// All supported shopt options -const SHOPT_OPTIONS = [ - "extglob", - "dotglob", - "nullglob", - "failglob", - "globstar", - "globskipdots", - "nocaseglob", - "nocasematch", - "expand_aliases", - "lastpipe", - "xpg_echo", -] as const; - -// Options that are recognized but not implemented (stubs that return current state) -const STUB_OPTIONS = [ - "autocd", - "cdable_vars", - "cdspell", - "checkhash", - "checkjobs", - "checkwinsize", - "cmdhist", - "compat31", - "compat32", - "compat40", - "compat41", - "compat42", - "compat43", - "compat44", - "complete_fullquote", - "direxpand", - "dirspell", - "execfail", - "extdebug", - "extquote", - "force_fignore", - "globasciiranges", - "gnu_errfmt", - "histappend", - "histreedit", - "histverify", - "hostcomplete", - "huponexit", - "inherit_errexit", - "interactive_comments", - "lithist", - "localvar_inherit", - "localvar_unset", - "login_shell", - "mailwarn", - "no_empty_cmd_completion", - "progcomp", - "progcomp_alias", - "promptvars", - "restricted_shell", - "shift_verbose", - "sourcepath", -] as const; - -type ShoptOption = (typeof SHOPT_OPTIONS)[number]; - -function isShoptOption(opt: string): opt is ShoptOption { - return SHOPT_OPTIONS.includes(opt as ShoptOption); -} - -function isStubOption(opt: string): boolean { - return STUB_OPTIONS.includes(opt as (typeof STUB_OPTIONS)[number]); -} - -export function handleShopt( - ctx: InterpreterContext, - args: string[], -): ExecResult { - // Parse arguments - let setFlag = false; // -s: set option - let unsetFlag = false; // -u: unset option - let printFlag = false; // -p: print in reusable form - let quietFlag = false; // -q: suppress output, only set exit code - let oFlag = false; // -o: use set -o option names - const optionNames: string[] = []; - - let i = 0; - while (i < args.length) { - const arg = args[i]; - if (arg === "--") { - i++; - break; - } - if (arg.startsWith("-") && arg.length > 1) { - for (let j = 1; j < arg.length; j++) { - const flag = arg[j]; - switch (flag) { - case "s": - setFlag = true; - break; - case "u": - unsetFlag = true; - break; - case "p": - printFlag = true; - break; - case "q": - quietFlag = true; - break; - case "o": - oFlag = true; - break; - default: - return { - exitCode: 2, - stdout: "", - stderr: `shopt: -${flag}: invalid option\n`, - }; - } - } - i++; - } else { - break; - } - } - - // Remaining args are option names - while (i < args.length) { - optionNames.push(args[i]); - i++; - } - - // -o flag: use set -o option names instead of shopt options - if (oFlag) { - return handleSetOptions( - ctx, - optionNames, - setFlag, - unsetFlag, - printFlag, - quietFlag, - ); - } - - // If -s and -u are both set, that's an error - if (setFlag && unsetFlag) { - return { - exitCode: 1, - stdout: "", - stderr: "shopt: cannot set and unset shell options simultaneously\n", - }; - } - - // No option names: print all options - if (optionNames.length === 0) { - if (setFlag || unsetFlag) { - // -s or -u without option names: print options with that state - const output: string[] = []; - for (const opt of SHOPT_OPTIONS) { - const value = ctx.state.shoptOptions[opt]; - if (setFlag && value) { - output.push(printFlag ? `shopt -s ${opt}` : `${opt}\t\ton`); - } else if (unsetFlag && !value) { - output.push(printFlag ? `shopt -u ${opt}` : `${opt}\t\toff`); - } - } - return { - exitCode: 0, - stdout: output.length > 0 ? `${output.join("\n")}\n` : "", - stderr: "", - }; - } - // No flags: print all options - const output: string[] = []; - for (const opt of SHOPT_OPTIONS) { - const value = ctx.state.shoptOptions[opt]; - output.push( - printFlag - ? `shopt ${value ? "-s" : "-u"} ${opt}` - : `${opt}\t\t${value ? "on" : "off"}`, - ); - } - return { - exitCode: 0, - stdout: `${output.join("\n")}\n`, - stderr: "", - }; - } - - // Option names provided - let hasError = false; - let stderr = ""; - const output: string[] = []; - - for (const name of optionNames) { - if (!isShoptOption(name) && !isStubOption(name)) { - stderr += `shopt: ${name}: invalid shell option name\n`; - hasError = true; - continue; - } - - if (setFlag) { - // Set the option - if (isShoptOption(name)) { - ctx.state.shoptOptions[name] = true; - updateBashopts(ctx); - } - // Stub options are silently accepted - } else if (unsetFlag) { - // Unset the option - if (isShoptOption(name)) { - ctx.state.shoptOptions[name] = false; - updateBashopts(ctx); - } - // Stub options are silently accepted - } else { - // Query the option - if (isShoptOption(name)) { - const value = ctx.state.shoptOptions[name]; - if (quietFlag) { - if (!value) { - hasError = true; - } - } else if (printFlag) { - output.push(`shopt ${value ? "-s" : "-u"} ${name}`); - // In bash, shopt -p returns exit code 1 if the option is unset - if (!value) { - hasError = true; - } - } else { - output.push(`${name}\t\t${value ? "on" : "off"}`); - // In bash, shopt without flags returns exit code 1 if the option is unset - if (!value) { - hasError = true; - } - } - } else { - // Stub options report as off - if (quietFlag) { - hasError = true; - } else if (printFlag) { - output.push(`shopt -u ${name}`); - hasError = true; // Stub options are always off - } else { - output.push(`${name}\t\toff`); - hasError = true; // Stub options are always off - } - } - } - } - - return { - exitCode: hasError ? 1 : 0, - stdout: output.length > 0 ? `${output.join("\n")}\n` : "", - stderr, - }; -} - -/** - * Handle -o flag: use set -o option names - */ -function handleSetOptions( - ctx: InterpreterContext, - optionNames: string[], - setFlag: boolean, - unsetFlag: boolean, - printFlag: boolean, - quietFlag: boolean, -): ExecResult { - // Map set -o option names to ShellOptions (implemented options) - const SET_OPTIONS = new Map([ - ["errexit", "errexit"], - ["pipefail", "pipefail"], - ["nounset", "nounset"], - ["xtrace", "xtrace"], - ["verbose", "verbose"], - ["posix", "posix"], - ["allexport", "allexport"], - ["noclobber", "noclobber"], - ["noglob", "noglob"], - ["noexec", "noexec"], - ["vi", "vi"], - ["emacs", "emacs"], - ]); - - // No-op options (recognized but always off, for compatibility with set -o) - const NOOP_OPTIONS = [ - "braceexpand", - "errtrace", - "functrace", - "hashall", - "histexpand", - "history", - "ignoreeof", - "interactive-comments", - "keyword", - "monitor", - "nolog", - "notify", - "onecmd", - "physical", - "privileged", - ]; - - const ALL_SET_OPTIONS = [...SET_OPTIONS.keys(), ...NOOP_OPTIONS].sort(); - - if (optionNames.length === 0) { - // Print all set -o options - const output: string[] = []; - for (const opt of ALL_SET_OPTIONS) { - const isNoOp = NOOP_OPTIONS.includes(opt); - const optKey = SET_OPTIONS.get(opt); - const value = isNoOp || !optKey ? false : ctx.state.options[optKey]; - if (setFlag && !value) continue; - if (unsetFlag && value) continue; - output.push( - printFlag - ? `set ${value ? "-o" : "+o"} ${opt}` - : `${opt}\t\t${value ? "on" : "off"}`, - ); - } - return { - exitCode: 0, - stdout: output.length > 0 ? `${output.join("\n")}\n` : "", - stderr: "", - }; - } - - let hasError = false; - let stderr = ""; - const output: string[] = []; - - for (const name of optionNames) { - const isImplemented = SET_OPTIONS.has(name); - const isNoOp = NOOP_OPTIONS.includes(name); - - if (!isImplemented && !isNoOp) { - stderr += `shopt: ${name}: invalid option name\n`; - hasError = true; - continue; - } - - if (isNoOp) { - // No-op options are always off and can't be changed - if (setFlag || unsetFlag) { - // Silently accept setting/unsetting no-op options (like bash) - } else { - // Query the option - if (quietFlag) { - hasError = true; // No-op options are always off - } else if (printFlag) { - output.push(`set +o ${name}`); - hasError = true; // Always off - } else { - output.push(`${name}\t\toff`); - hasError = true; // Always off - } - } - continue; - } - - const key = SET_OPTIONS.get(name); - if (!key) continue; // Should not happen since we validated above - - if (setFlag) { - // Handle mutual exclusivity of vi and emacs - if (key === "vi") { - ctx.state.options.emacs = false; - } else if (key === "emacs") { - ctx.state.options.vi = false; - } - ctx.state.options[key] = true; - updateShellopts(ctx); - } else if (unsetFlag) { - ctx.state.options[key] = false; - updateShellopts(ctx); - } else { - const value = ctx.state.options[key]; - if (quietFlag) { - if (!value) { - hasError = true; - } - } else if (printFlag) { - output.push(`set ${value ? "-o" : "+o"} ${name}`); - // In bash, shopt -o -p returns exit code 1 if the option is unset - if (!value) { - hasError = true; - } - } else { - output.push(`${name}\t\t${value ? "on" : "off"}`); - // In bash, shopt -o without flags returns exit code 1 if the option is unset - if (!value) { - hasError = true; - } - } - } - } - - return { - exitCode: hasError ? 1 : 0, - stdout: output.length > 0 ? `${output.join("\n")}\n` : "", - stderr, - }; -} diff --git a/src/interpreter/builtins/source.test.ts b/src/interpreter/builtins/source.test.ts deleted file mode 100644 index 953f245d..00000000 --- a/src/interpreter/builtins/source.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("source builtin", () => { - describe("basic source", () => { - it("should execute commands from file in current environment", async () => { - const env = new Bash(); - await env.exec('echo "x=123" > /tmp/test.sh'); - const result = await env.exec(` - source /tmp/test.sh - echo "x is: $x" - `); - expect(result.stdout).toBe("x is: 123\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support functions from sourced file", async () => { - const env = new Bash(); - await env.exec('echo "greet() { echo Hello \\$1; }" > /tmp/funcs.sh'); - const result = await env.exec(` - source /tmp/funcs.sh - greet World - `); - expect(result.stdout).toBe("Hello World\n"); - expect(result.exitCode).toBe(0); - }); - - it("should modify variables in caller environment", async () => { - const env = new Bash(); - await env.exec('echo "VAR=modified" > /tmp/modify.sh'); - const result = await env.exec(` - VAR=original - source /tmp/modify.sh - echo $VAR - `); - expect(result.stdout).toBe("modified\n"); - }); - - it("should support multiple commands in sourced file", async () => { - const env = new Bash(); - await env.exec(` - cat > /tmp/multi.sh << 'EOF' -A=1 -B=2 -C=$((A + B)) -EOF - `); - const result = await env.exec(` - source /tmp/multi.sh - echo $A $B $C - `); - expect(result.stdout).toBe("1 2 3\n"); - }); - }); - - describe(". (dot) builtin", () => { - it("should work same as source", async () => { - const env = new Bash(); - await env.exec('echo "y=456" > /tmp/test2.sh'); - const result = await env.exec(` - . /tmp/test2.sh - echo "y is: $y" - `); - expect(result.stdout).toBe("y is: 456\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle functions with . syntax", async () => { - const env = new Bash(); - await env.exec('echo "add() { echo \\$((\\$1 + \\$2)); }" > /tmp/add.sh'); - const result = await env.exec(` - . /tmp/add.sh - add 3 4 - `); - expect(result.stdout).toBe("7\n"); - }); - }); - - describe("sourced script with arguments", () => { - it("should pass arguments to sourced script", async () => { - const env = new Bash(); - await env.exec('echo "echo args: \\$1 \\$2 \\$#" > /tmp/args.sh'); - const result = await env.exec("source /tmp/args.sh foo bar"); - expect(result.stdout).toBe("args: foo bar 2\n"); - expect(result.exitCode).toBe(0); - }); - - it("should restore arguments after sourcing", async () => { - const env = new Bash(); - await env.exec('echo "echo sourced: \\$1" > /tmp/sourced.sh'); - const result = await env.exec(` - myfunc() { - echo "func: $1" - source /tmp/sourced.sh arg - echo "after: $1" - } - myfunc original - `); - expect(result.stdout).toBe( - "func: original\nsourced: arg\nafter: original\n", - ); - }); - }); - - describe("error cases", () => { - it("should error on missing file", async () => { - const env = new Bash(); - const result = await env.exec("source /nonexistent/file.sh"); - expect(result.stderr).toContain("No such file or directory"); - expect(result.exitCode).toBe(1); - }); - - it("should error with no arguments", async () => { - const env = new Bash(); - const result = await env.exec("source"); - expect(result.stderr).toContain("filename argument required"); - expect(result.exitCode).toBe(2); - }); - - it("should error with . and no arguments", async () => { - const env = new Bash(); - const result = await env.exec("."); - expect(result.stderr).toContain("filename argument required"); - expect(result.exitCode).toBe(2); - }); - }); - - describe("return in sourced script", () => { - it("should support return in sourced script", async () => { - const env = new Bash(); - await env.exec(` - cat > /tmp/early.sh << 'EOF' -echo before -return 0 -echo after -EOF - `); - const result = await env.exec(` - source /tmp/early.sh - echo done - `); - expect(result.stdout).toBe("before\ndone\n"); - }); - - it("should propagate return exit code", async () => { - const env = new Bash(); - await env.exec('echo "return 42" > /tmp/exit.sh'); - const result = await env.exec(` - source /tmp/exit.sh - echo "exit: $?" - `); - expect(result.stdout).toBe("exit: 42\n"); - }); - }); -}); diff --git a/src/interpreter/builtins/source.ts b/src/interpreter/builtins/source.ts deleted file mode 100644 index c617c490..00000000 --- a/src/interpreter/builtins/source.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * source/. - Execute commands from a file in current environment builtin - */ - -import { type ParseException, parse } from "../../parser/parser.js"; -import type { ExecResult } from "../../types.js"; -import { ExitError, ReturnError } from "../errors.js"; -import { failure, result } from "../helpers/result.js"; -import type { InterpreterContext } from "../types.js"; - -export async function handleSource( - ctx: InterpreterContext, - args: string[], -): Promise { - // Handle -- to end options (ignored like bash does) - let sourceArgs = args; - if (sourceArgs.length > 0 && sourceArgs[0] === "--") { - sourceArgs = sourceArgs.slice(1); - } - - if (sourceArgs.length === 0) { - return result("", "bash: source: filename argument required\n", 2); - } - - const filename = sourceArgs[0]; - let _resolvedPath: string | null = null; - let content: string | null = null; - - // If filename contains '/', use it directly (relative or absolute path) - if (filename.includes("/")) { - const directPath = ctx.fs.resolvePath(ctx.state.cwd, filename); - try { - content = await ctx.fs.readFile(directPath); - _resolvedPath = directPath; - } catch { - // File not found - } - } else { - // Filename doesn't contain '/' - search in PATH first, then current directory - const pathEnv = ctx.state.env.get("PATH") || ""; - const pathDirs = pathEnv.split(":").filter((d) => d); - - for (const dir of pathDirs) { - const candidate = ctx.fs.resolvePath(ctx.state.cwd, `${dir}/${filename}`); - try { - // Check if it's a regular file (not a directory) - const stat = await ctx.fs.stat(candidate); - if (stat.isDirectory) { - continue; // Skip directories - } - content = await ctx.fs.readFile(candidate); - _resolvedPath = candidate; - break; - } catch { - // File doesn't exist in this PATH directory, continue searching - } - } - - // If not found in PATH, try current directory - if (content === null) { - const directPath = ctx.fs.resolvePath(ctx.state.cwd, filename); - try { - content = await ctx.fs.readFile(directPath); - _resolvedPath = directPath; - } catch { - // File not found - } - } - } - - if (content === null) { - return failure(`bash: ${filename}: No such file or directory\n`); - } - - // Save and set positional parameters from additional args - const savedPositional = new Map(); - if (sourceArgs.length > 1) { - // Save current positional parameters - for (let i = 1; i <= 9; i++) { - savedPositional.set(String(i), ctx.state.env.get(String(i))); - } - savedPositional.set("#", ctx.state.env.get("#")); - savedPositional.set("@", ctx.state.env.get("@")); - - // Set new positional parameters - const scriptArgs = sourceArgs.slice(1); - ctx.state.env.set("#", String(scriptArgs.length)); - ctx.state.env.set("@", scriptArgs.join(" ")); - for (let i = 0; i < scriptArgs.length && i < 9; i++) { - ctx.state.env.set(String(i + 1), scriptArgs[i]); - } - // Clear any remaining positional parameters - for (let i = scriptArgs.length + 1; i <= 9; i++) { - ctx.state.env.delete(String(i)); - } - } - - // Save and restore current source context for BASH_SOURCE tracking - const savedSource = ctx.state.currentSource; - - const cleanup = (): void => { - ctx.state.sourceDepth--; - ctx.state.currentSource = savedSource; - // Restore positional parameters if we changed them - if (sourceArgs.length > 1) { - for (const [key, value] of savedPositional) { - if (value === undefined) { - ctx.state.env.delete(key); - } else { - ctx.state.env.set(key, value); - } - } - } - }; - - ctx.state.sourceDepth++; - // Set current source to the file being sourced (for function definitions) - ctx.state.currentSource = filename; - try { - const ast = parse(content); - const result = await ctx.executeScript(ast); - cleanup(); - return result; - } catch (error) { - cleanup(); - - // ExitError propagates up to exit the shell - if (error instanceof ExitError) { - throw error; - } - - // Handle return in sourced script - treat as normal exit - if (error instanceof ReturnError) { - return result(error.stdout, error.stderr, error.exitCode); - } - - if ((error as ParseException).name === "ParseException") { - return failure(`bash: ${filename}: ${(error as Error).message}\n`); - } - throw error; - } -} diff --git a/src/interpreter/builtins/unset.test.ts b/src/interpreter/builtins/unset.test.ts deleted file mode 100644 index 373f82d2..00000000 --- a/src/interpreter/builtins/unset.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("unset builtin", () => { - describe("unset variables", () => { - it("should unset a variable", async () => { - const env = new Bash({ env: { VAR: "value" } }); - const result = await env.exec(` - echo "before: $VAR" - unset VAR - echo "after: $VAR" - `); - expect(result.stdout).toBe("before: value\nafter: \n"); - }); - - it("should unset multiple variables", async () => { - const env = new Bash({ env: { A: "1", B: "2", C: "3" } }); - const result = await env.exec(` - unset A B - echo "A=$A B=$B C=$C" - `); - expect(result.stdout).toBe("A= B= C=3\n"); - }); - - it("should succeed silently for non-existent variable", async () => { - const env = new Bash(); - const result = await env.exec(` - unset NONEXISTENT - echo "done" - `); - expect(result.stdout).toBe("done\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("unset with -v flag", () => { - it("should unset variable with -v flag", async () => { - const env = new Bash({ env: { VAR: "value" } }); - const result = await env.exec(` - unset -v VAR - echo "VAR=$VAR" - `); - expect(result.stdout).toBe("VAR=\n"); - }); - }); - - describe("unset functions", () => { - it("should unset a function with -f flag", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { echo "hello"; } - myfunc - unset -f myfunc - myfunc - `); - expect(result.stdout).toBe("hello\n"); - expect(result.stderr).toContain("command not found"); - }); - - it("should succeed silently for non-existent function", async () => { - const env = new Bash(); - const result = await env.exec(` - unset -f nonexistent_func - echo "done" - `); - expect(result.stdout).toBe("done\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("unset in different scopes", () => { - it("should unset variable in function scope", async () => { - const env = new Bash({ env: { VAR: "outer" } }); - const result = await env.exec(` - myfunc() { - unset VAR - echo "in func: $VAR" - } - myfunc - echo "outside: $VAR" - `); - expect(result.stdout).toBe("in func: \noutside: \n"); - }); - - it("should unset local variable", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - local VAR=local - echo "before: $VAR" - unset VAR - echo "after: $VAR" - } - myfunc - `); - expect(result.stdout).toBe("before: local\nafter: \n"); - }); - }); - - describe("unset return value", () => { - it("should return 0 on success", async () => { - const env = new Bash({ env: { VAR: "value" } }); - const result = await env.exec(` - unset VAR - echo $? - `); - expect(result.stdout).toBe("0\n"); - }); - }); - - describe("unset special variables", () => { - it("should not unset readonly variables", async () => { - const env = new Bash(); - // Note: This tests that attempt to unset doesn't crash - // Actual readonly behavior may vary - const result = await env.exec(` - VAR=value - unset VAR - echo "done" - `); - expect(result.stdout).toBe("done\n"); - }); - }); - - describe("unset associative array elements", () => { - it("should unset associative array element with variable key", async () => { - const env = new Bash(); - const result = await env.exec(` - declare -A dict=() - key=mykey - dict["$key"]=foo - echo "before: \${dict[mykey]}" - unset -v 'dict["$key"]' - echo "after: \${dict[mykey]}" - `); - expect(result.stdout).toBe("before: foo\nafter: \n"); - expect(result.exitCode).toBe(0); - }); - - it("should unset associative array element with special characters in key", async () => { - const env = new Bash(); - const result = await env.exec(` - declare -A dict=() - key='1],a[1' - dict["$key"]=foo - echo "\${#dict[@]}" - unset -v 'dict["$key"]' - echo "\${#dict[@]}" - `); - expect(result.stdout).toBe("1\n0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should unset associative array element with single-quoted key", async () => { - const env = new Bash(); - const result = await env.exec(` - declare -A dict=() - dict['literal']=bar - echo "before: \${dict[literal]}" - unset "dict['literal']" - echo "after: \${dict[literal]}" - `); - expect(result.stdout).toBe("before: bar\nafter: \n"); - expect(result.exitCode).toBe(0); - }); - - it("should unset associative array element with plain literal key", async () => { - const env = new Bash(); - const result = await env.exec(` - declare -A dict=() - dict[plainkey]=value - echo "before: \${dict[plainkey]}" - unset 'dict[plainkey]' - echo "after: \${dict[plainkey]}" - `); - expect(result.stdout).toBe("before: value\nafter: \n"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/interpreter/builtins/unset.ts b/src/interpreter/builtins/unset.ts deleted file mode 100644 index ce5a03ce..00000000 --- a/src/interpreter/builtins/unset.ts +++ /dev/null @@ -1,566 +0,0 @@ -/** - * unset - Remove variables/functions builtin - * - * Supports: - * - unset VAR - remove variable - * - unset -v VAR - remove variable (explicit) - * - unset -f FUNC - remove function - * - unset 'a[i]' - remove array element (with arithmetic index support) - * - * Bash-specific unset scoping: - * - local-unset (same scope): value-unset - clears value but keeps local cell - * - dynamic-unset (different scope): cell-unset - removes local cell, exposes outer value - */ - -import { parseArithmeticExpression } from "../../parser/arithmetic-parser.js"; -import { Parser } from "../../parser/parser.js"; -import type { ExecResult } from "../../types.js"; -import { evaluateArithmetic } from "../arithmetic.js"; -import { isArray } from "../expansion/variable.js"; -import { expandWord, getArrayElements } from "../expansion.js"; -import { isNameref, resolveNameref } from "../helpers/nameref.js"; -import { isReadonly } from "../helpers/readonly.js"; -import { result } from "../helpers/result.js"; -import type { InterpreterContext } from "../types.js"; -import { - clearLocalVarDepth, - getLocalVarDepth, - popLocalVarStack, -} from "./variable-assignment.js"; - -/** - * Check if a name is a valid bash variable name. - * Valid names start with letter or underscore, followed by letters, digits, or underscores. - */ -function isValidVariableName(name: string): boolean { - return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name); -} - -/** - * Check if an index expression is a quoted string (single or double quotes). - * These are treated as associative array keys, not numeric indices. - */ -function isQuotedStringIndex(indexExpr: string): boolean { - // Check for single-quoted or double-quoted string - return ( - (indexExpr.startsWith("'") && indexExpr.endsWith("'")) || - (indexExpr.startsWith('"') && indexExpr.endsWith('"')) - ); -} - -/** - * Evaluate an array index expression (can be arithmetic). - * Returns the evaluated numeric index, or null if the expression is a quoted - * string that should be treated as an associative array key. - */ -async function evaluateArrayIndex( - ctx: InterpreterContext, - indexExpr: string, -): Promise { - // If the index is a quoted string, it's meant for associative arrays only - if (isQuotedStringIndex(indexExpr)) { - return null; - } - try { - const parser = new Parser(); - const arithAst = parseArithmeticExpression(parser, indexExpr); - return await evaluateArithmetic(ctx, arithAst.expression); - } catch { - // If parsing fails, try to parse as simple number - const num = parseInt(indexExpr, 10); - return Number.isNaN(num) ? 0 : num; - } -} - -/** - * Perform cell-unset for a local variable (dynamic-unset). - * This removes the local cell and exposes the outer scope's value. - * Uses the localVarStack for bash's localvar-nest behavior where multiple - * nested local declarations can each be unset independently. - * Returns true if a cell-unset was performed, false otherwise. - */ -function performCellUnset(ctx: InterpreterContext, varName: string): boolean { - // Check if this variable uses the localVarStack (for nested local declarations) - const hasStackEntry = ctx.state.localVarStack?.has(varName); - - if (hasStackEntry) { - // This variable is managed by the localVarStack - const stackEntry = popLocalVarStack(ctx, varName); - if (stackEntry) { - // Restore the value from the stack - if (stackEntry.value === undefined) { - ctx.state.env.delete(varName); - } else { - ctx.state.env.set(varName, stackEntry.value); - } - - // Check if there are more entries in the stack - const remainingStack = ctx.state.localVarStack?.get(varName); - if (!remainingStack || remainingStack.length === 0) { - // No more nested locals - clear the tracking - clearLocalVarDepth(ctx, varName); - // Also clean up the empty stack entry - ctx.state.localVarStack?.delete(varName); - // Mark this variable as "fully unset local" to prevent tempenv restoration - // Use the scope index from the last popped entry (where the variable was declared) - ctx.state.fullyUnsetLocals = ctx.state.fullyUnsetLocals || new Map(); - ctx.state.fullyUnsetLocals.set(varName, stackEntry.scopeIndex); - - // Bash 5.1 behavior: after cell-unset removes all locals, also remove tempenv - // binding to reveal the global value (not the tempenv value) - if (handleTempEnvUnset(ctx, varName)) { - // Tempenv was removed, env[varName] now has the global value - } - } else { - // Update localVarDepth to point to the now-top entry's scope - // The scope index + 1 gives us the call depth where that local was declared - const topEntry = remainingStack[remainingStack.length - 1]; - ctx.state.localVarDepth = ctx.state.localVarDepth || new Map(); - ctx.state.localVarDepth.set(varName, topEntry.scopeIndex + 1); - } - return true; - } - // Stack was empty but variable was stack-managed - just delete and clear tracking - ctx.state.env.delete(varName); - clearLocalVarDepth(ctx, varName); - ctx.state.localVarStack?.delete(varName); - // Mark as fully unset - use the outermost scope (0) since we don't know the original - ctx.state.fullyUnsetLocals = ctx.state.fullyUnsetLocals || new Map(); - ctx.state.fullyUnsetLocals.set(varName, 0); - return true; - } - - // Fall back to the old behavior for variables without stack entries - // (for backwards compatibility with existing local declarations) - for (let i = ctx.state.localScopes.length - 1; i >= 0; i--) { - const scope = ctx.state.localScopes[i]; - if (scope.has(varName)) { - // Found the scope - restore the outer value - const outerValue = scope.get(varName); - if (outerValue === undefined) { - ctx.state.env.delete(varName); - } else { - ctx.state.env.set(varName, outerValue); - } - // Remove from this scope so future lookups find the outer value - scope.delete(varName); - - // Check if there's an outer scope that also has this variable - // If so, update localVarDepth to that outer scope's depth - // Otherwise, clear the tracking - let foundOuterScope = false; - for (let j = i - 1; j >= 0; j--) { - if (ctx.state.localScopes[j].has(varName)) { - // Found an outer scope with this variable - // Scope at index j was created at callDepth j + 1 - if (ctx.state.localVarDepth) { - ctx.state.localVarDepth.set(varName, j + 1); - } - foundOuterScope = true; - break; - } - } - if (!foundOuterScope) { - clearLocalVarDepth(ctx, varName); - } - return true; - } - } - return false; -} - -/** - * Handle unsetting a variable that may have a tempEnvBinding. - * In bash, when you `unset v` where `v` was set by a prefix assignment (v=tempenv cmd), - * it reveals the underlying (global) value instead of completely deleting the variable. - * Returns true if a tempenv binding was found and handled, false otherwise. - */ -function handleTempEnvUnset(ctx: InterpreterContext, varName: string): boolean { - if (!ctx.state.tempEnvBindings || ctx.state.tempEnvBindings.length === 0) { - return false; - } - - // Search from innermost (most recent) to outermost tempEnvBinding - for (let i = ctx.state.tempEnvBindings.length - 1; i >= 0; i--) { - const bindings = ctx.state.tempEnvBindings[i]; - if (bindings.has(varName)) { - // Found a tempenv binding for this variable - // Restore the underlying value (what was saved when the tempenv was created) - const underlyingValue = bindings.get(varName); - if (underlyingValue === undefined) { - ctx.state.env.delete(varName); - } else { - ctx.state.env.set(varName, underlyingValue); - } - // Remove from this binding so future unsets will look at next layer - bindings.delete(varName); - return true; - } - } - return false; -} - -/** - * Expand the subscript expression for an associative array key. - * Handles single-quoted, double-quoted, and unquoted subscripts. - */ -async function expandAssocSubscript( - ctx: InterpreterContext, - subscriptExpr: string, -): Promise { - if (subscriptExpr.startsWith("'") && subscriptExpr.endsWith("'")) { - // Single-quoted: literal value, no expansion - return subscriptExpr.slice(1, -1); - } - if (subscriptExpr.startsWith('"') && subscriptExpr.endsWith('"')) { - // Double-quoted: expand variables inside - const inner = subscriptExpr.slice(1, -1); - const parser = new Parser(); - const wordNode = parser.parseWordFromString(inner, true, false); - return expandWord(ctx, wordNode); - } - if (subscriptExpr.includes("$")) { - // Unquoted with variable reference - const parser = new Parser(); - const wordNode = parser.parseWordFromString(subscriptExpr, false, false); - return expandWord(ctx, wordNode); - } - // Plain literal - return subscriptExpr; -} - -export async function handleUnset( - ctx: InterpreterContext, - args: string[], -): Promise { - let mode: "variable" | "function" | "both" = "both"; // Default: unset both var and func - let stderr = ""; - let exitCode = 0; - - for (const arg of args) { - // Handle flags - if (arg === "-v") { - mode = "variable"; // Explicit: only variable - continue; - } - if (arg === "-f") { - mode = "function"; - continue; - } - - if (mode === "function") { - ctx.state.functions.delete(arg); - continue; - } - - // If mode is "variable", only delete variables, not functions - if (mode === "variable") { - // Handle array element syntax: varName[index] - const arrayMatchVar = arg.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$/); - if (arrayMatchVar) { - const arrayName = arrayMatchVar[1]; - const indexExpr = arrayMatchVar[2]; - - if (indexExpr === "@" || indexExpr === "*") { - const elements = getArrayElements(ctx, arrayName); - for (const [idx] of elements) { - ctx.state.env.delete(`${arrayName}_${idx}`); - } - ctx.state.env.delete(arrayName); - continue; - } - - // Check if this is an associative array - const isAssoc = ctx.state.associativeArrays?.has(arrayName); - - if (isAssoc) { - // For associative arrays, expand variables in the subscript - const key = await expandAssocSubscript(ctx, indexExpr); - ctx.state.env.delete(`${arrayName}_${key}`); - continue; - } - - // Check if variable is an indexed array - const isIndexedArray = isArray(ctx, arrayName); - // Check if variable was explicitly declared as a scalar (not an array) - // A scalar exists when the base var name is in env (or declared but unset) but it's not an array - const isDeclaredButUnset = ctx.state.declaredVars?.has(arrayName); - const isScalar = - (ctx.state.env.has(arrayName) || isDeclaredButUnset) && - !isIndexedArray && - !isAssoc; - - if (isScalar) { - // Trying to unset array element on explicitly declared scalar variable - stderr += `bash: unset: ${arrayName}: not an array variable\n`; - exitCode = 1; - continue; - } - - // Indexed array: evaluate index as arithmetic expression - const index = await evaluateArrayIndex(ctx, indexExpr); - - // If index is null, it's a quoted string key - error for indexed arrays - // Only error if the variable is actually an indexed array - if (index === null && isIndexedArray) { - stderr += `bash: unset: ${indexExpr}: not a valid identifier\n`; - exitCode = 1; - continue; - } - - // If variable doesn't exist at all and we have a quoted string key, - // just silently succeed - if (index === null) { - continue; - } - - if (index < 0) { - const elements = getArrayElements(ctx, arrayName); - const len = elements.length; - const lineNum = ctx.state.currentLine; - if (len === 0) { - stderr += `bash: line ${lineNum}: unset: [${index}]: bad array subscript\n`; - exitCode = 1; - continue; - } - const actualPos = len + index; - if (actualPos < 0) { - stderr += `bash: line ${lineNum}: unset: [${index}]: bad array subscript\n`; - exitCode = 1; - continue; - } - const actualIndex = elements[actualPos][0]; - ctx.state.env.delete(`${arrayName}_${actualIndex}`); - continue; - } - - ctx.state.env.delete(`${arrayName}_${index}`); - continue; - } - - // Regular variable with -v: only delete variable, NOT function - // Validate variable name - if (!isValidVariableName(arg)) { - stderr += `bash: unset: \`${arg}': not a valid identifier\n`; - exitCode = 1; - continue; - } - - let targetName = arg; - if (isNameref(ctx, arg)) { - const resolved = resolveNameref(ctx, arg); - if (resolved && resolved !== arg) { - targetName = resolved; - } - } - - // Check if variable is readonly - if (isReadonly(ctx, targetName)) { - stderr += `bash: unset: ${targetName}: cannot unset: readonly variable\n`; - exitCode = 1; - continue; - } - - // Bash-specific unset scoping: check if this is a dynamic-unset - const localDepth = getLocalVarDepth(ctx, targetName); - if (localDepth !== undefined && localDepth !== ctx.state.callDepth) { - // Dynamic-unset: called from a different scope than where local was declared - // Perform cell-unset to expose outer value - performCellUnset(ctx, targetName); - } else if (ctx.state.fullyUnsetLocals?.has(targetName)) { - // This variable was a local that has been fully unset - // Don't restore from tempenv, just delete - ctx.state.env.delete(targetName); - } else if (localDepth !== undefined) { - // Local-unset: variable is local and we're in the same scope - // In bash 5.1, this is a "value-unset" for locals declared without tempenv access - // But if the tempenv was accessed (read or written) before the local declaration, - // we should pop from stack to reveal the tempenv/mutated value - const tempEnvAccessed = ctx.state.accessedTempEnvVars?.has(targetName); - const tempEnvMutated = ctx.state.mutatedTempEnvVars?.has(targetName); - if ( - (tempEnvAccessed || tempEnvMutated) && - ctx.state.localVarStack?.has(targetName) - ) { - // Tempenv was accessed before local declaration - pop from stack to reveal the value - const stackEntry = popLocalVarStack(ctx, targetName); - if (stackEntry) { - if (stackEntry.value === undefined) { - ctx.state.env.delete(targetName); - } else { - ctx.state.env.set(targetName, stackEntry.value); - } - } else { - ctx.state.env.delete(targetName); - } - } else { - // Tempenv not accessed - just value-unset (delete) - ctx.state.env.delete(targetName); - } - } else if (!handleTempEnvUnset(ctx, targetName)) { - // Not a local variable - check for tempenv binding - // If found, reveal underlying value; otherwise just delete - ctx.state.env.delete(targetName); - } - // Clear the export attribute - when variable is unset, it loses its export status - ctx.state.exportedVars?.delete(targetName); - continue; - } - - // Check for array element syntax: varName[index] - const arrayMatch = arg.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$/); - if (arrayMatch) { - const arrayName = arrayMatch[1]; - const indexExpr = arrayMatch[2]; - - // Handle [@] or [*] - unset entire array - if (indexExpr === "@" || indexExpr === "*") { - const elements = getArrayElements(ctx, arrayName); - for (const [idx] of elements) { - ctx.state.env.delete(`${arrayName}_${idx}`); - } - ctx.state.env.delete(arrayName); - continue; - } - - // Check if this is an associative array - const isAssoc = ctx.state.associativeArrays?.has(arrayName); - - if (isAssoc) { - // For associative arrays, expand variables in the subscript - const key = await expandAssocSubscript(ctx, indexExpr); - ctx.state.env.delete(`${arrayName}_${key}`); - continue; - } - - // Check if variable is an indexed array - const isIndexedArray = isArray(ctx, arrayName); - // Check if variable was explicitly declared as a scalar (not an array) - // A scalar exists when the base var name is in env but it's not an array - const isScalar = - ctx.state.env.has(arrayName) && !isIndexedArray && !isAssoc; - - if (isScalar) { - // Trying to unset array element on explicitly declared scalar variable - stderr += `bash: unset: ${arrayName}: not an array variable\n`; - exitCode = 1; - continue; - } - - // Indexed array: evaluate index as arithmetic expression - const index = await evaluateArrayIndex(ctx, indexExpr); - - // If index is null, it's a quoted string key - error for indexed arrays - // Only error if the variable is actually an indexed array - if (index === null && isIndexedArray) { - stderr += `bash: unset: ${indexExpr}: not a valid identifier\n`; - exitCode = 1; - continue; - } - - // If variable doesn't exist at all and we have a quoted string key, - // just silently succeed - if (index === null) { - continue; - } - - // Handle negative indices - if (index < 0) { - const elements = getArrayElements(ctx, arrayName); - const len = elements.length; - const lineNum = ctx.state.currentLine; - if (len === 0) { - // Empty array with negative index - error - stderr += `bash: line ${lineNum}: unset: [${index}]: bad array subscript\n`; - exitCode = 1; - continue; - } - // Convert negative index to actual position in sparse array - const actualPos = len + index; - if (actualPos < 0) { - // Out of bounds negative index - error - stderr += `bash: line ${lineNum}: unset: [${index}]: bad array subscript\n`; - exitCode = 1; - continue; - } - // Get the actual index from the sorted elements - const actualIndex = elements[actualPos][0]; - ctx.state.env.delete(`${arrayName}_${actualIndex}`); - continue; - } - - // Positive index - just delete directly - ctx.state.env.delete(`${arrayName}_${index}`); - continue; - } - - // Regular variable - check if it's a nameref and unset the target - // Validate variable name - if (!isValidVariableName(arg)) { - stderr += `bash: unset: \`${arg}': not a valid identifier\n`; - exitCode = 1; - continue; - } - - let targetName = arg; - if (isNameref(ctx, arg)) { - const resolved = resolveNameref(ctx, arg); - if (resolved && resolved !== arg) { - targetName = resolved; - } - } - - // Check if variable is readonly - if (isReadonly(ctx, targetName)) { - stderr += `bash: unset: ${targetName}: cannot unset: readonly variable\n`; - exitCode = 1; - continue; - } - - // Bash-specific unset scoping: check if this is a dynamic-unset - const localDepth = getLocalVarDepth(ctx, targetName); - if (localDepth !== undefined && localDepth !== ctx.state.callDepth) { - // Dynamic-unset: called from a different scope than where local was declared - // Perform cell-unset to expose outer value - performCellUnset(ctx, targetName); - } else if (ctx.state.fullyUnsetLocals?.has(targetName)) { - // This variable was a local that has been fully unset - // Don't restore from tempenv, just delete - ctx.state.env.delete(targetName); - } else if (localDepth !== undefined) { - // Local-unset: variable is local and we're in the same scope - // In bash 5.1, this is a "value-unset" for locals declared without tempenv access - // But if the tempenv was accessed (read or written) before the local declaration, - // we should pop from stack to reveal the tempenv/mutated value - const tempEnvAccessed = ctx.state.accessedTempEnvVars?.has(targetName); - const tempEnvMutated = ctx.state.mutatedTempEnvVars?.has(targetName); - if ( - (tempEnvAccessed || tempEnvMutated) && - ctx.state.localVarStack?.has(targetName) - ) { - // Tempenv was accessed before local declaration - pop from stack to reveal the value - const stackEntry = popLocalVarStack(ctx, targetName); - if (stackEntry) { - if (stackEntry.value === undefined) { - ctx.state.env.delete(targetName); - } else { - ctx.state.env.set(targetName, stackEntry.value); - } - } else { - ctx.state.env.delete(targetName); - } - } else { - // Tempenv not accessed - just value-unset (delete) - ctx.state.env.delete(targetName); - } - } else if (!handleTempEnvUnset(ctx, targetName)) { - // Not a local variable - check for tempenv binding - // If found, reveal underlying value; otherwise just delete - ctx.state.env.delete(targetName); - } - // Clear the export attribute - when variable is unset, it loses its export status - ctx.state.exportedVars?.delete(targetName); - ctx.state.functions.delete(arg); - } - return result("", stderr, exitCode); -} diff --git a/src/interpreter/builtins/variable-assignment.ts b/src/interpreter/builtins/variable-assignment.ts deleted file mode 100644 index 6616a0a1..00000000 --- a/src/interpreter/builtins/variable-assignment.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * Variable assignment helpers for declare, readonly, local, export builtins. - */ - -import { parseArithmeticExpression } from "../../parser/arithmetic-parser.js"; -import { Parser } from "../../parser/parser.js"; -import type { ExecResult } from "../../types.js"; -import { evaluateArithmetic } from "../arithmetic.js"; -import { checkReadonlyError, markReadonly } from "../helpers/readonly.js"; -import type { InterpreterContext } from "../types.js"; -import { parseArrayElements } from "./declare-array-parsing.js"; - -/** - * Result of parsing an assignment argument. - */ -export interface ParsedAssignment { - name: string; - isArray: boolean; - arrayElements?: string[]; - value?: string; - /** For array index assignment: a[index]=value */ - arrayIndex?: string; -} - -/** - * Parse an assignment argument like "name=value", "name=(a b c)", or "name[index]=value". - */ -export function parseAssignment(arg: string): ParsedAssignment { - // Check for array assignment: name=(...) - const arrayMatch = arg.match(/^([a-zA-Z_][a-zA-Z0-9_]*)=\((.*)\)$/s); - if (arrayMatch) { - return { - name: arrayMatch[1], - isArray: true, - arrayElements: parseArrayElements(arrayMatch[2]), - }; - } - - // Check for array index assignment: name[index]=value - // The index can be an arithmetic expression like 1*1 or 1+2 - const indexMatch = arg.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[([^\]]+)\]=(.*)$/s); - if (indexMatch) { - return { - name: indexMatch[1], - isArray: false, - arrayIndex: indexMatch[2], - value: indexMatch[3], - }; - } - - // Check for scalar assignment: name=value - if (arg.includes("=")) { - const eqIdx = arg.indexOf("="); - return { - name: arg.slice(0, eqIdx), - isArray: false, - value: arg.slice(eqIdx + 1), - }; - } - - // Just a name, no value - return { - name: arg, - isArray: false, - }; -} - -/** - * Options for setting a variable. - */ -export interface SetVariableOptions { - makeReadonly?: boolean; - checkReadonly?: boolean; -} - -/** - * Evaluate an array index expression (can be arithmetic). - */ -async function evaluateArrayIndex( - ctx: InterpreterContext, - indexExpr: string, -): Promise { - try { - const parser = new Parser(); - const arithAst = parseArithmeticExpression(parser, indexExpr); - return await evaluateArithmetic(ctx, arithAst.expression); - } catch { - // If parsing fails, try to parse as simple number - const num = parseInt(indexExpr, 10); - return Number.isNaN(num) ? 0 : num; - } -} - -/** - * Set a variable from a parsed assignment. - * Returns an error result if the variable is readonly, otherwise null. - */ -export async function setVariable( - ctx: InterpreterContext, - assignment: ParsedAssignment, - options: SetVariableOptions = {}, -): Promise { - const { name, isArray, arrayElements, value, arrayIndex } = assignment; - const { makeReadonly = false, checkReadonly = true } = options; - - // Check if variable is readonly (if checking is enabled) - if (checkReadonly) { - const error = checkReadonlyError(ctx, name); - if (error) return error; - } - - if (isArray && arrayElements) { - // Set array elements - for (let i = 0; i < arrayElements.length; i++) { - ctx.state.env.set(`${name}_${i}`, arrayElements[i]); - } - ctx.state.env.set(`${name}__length`, String(arrayElements.length)); - } else if (arrayIndex !== undefined && value !== undefined) { - // Array index assignment: a[index]=value - const index = await evaluateArrayIndex(ctx, arrayIndex); - ctx.state.env.set(`${name}_${index}`, value); - // Update array length if needed (sparse arrays may have gaps) - const currentLength = parseInt( - ctx.state.env.get(`${name}__length`) ?? "0", - 10, - ); - if (index >= currentLength) { - ctx.state.env.set(`${name}__length`, String(index + 1)); - } - } else if (value !== undefined) { - // Set scalar value - ctx.state.env.set(name, value); - } - - // Mark as readonly if requested - if (makeReadonly) { - markReadonly(ctx, name); - } - - return null; // Success -} - -/** - * Mark a variable as being declared at the current call depth. - * Used for bash-specific unset scoping behavior. - */ -export function markLocalVarDepth(ctx: InterpreterContext, name: string): void { - ctx.state.localVarDepth = ctx.state.localVarDepth || new Map(); - ctx.state.localVarDepth.set(name, ctx.state.callDepth); -} - -/** - * Get the call depth at which a local variable was declared. - * Returns undefined if the variable is not a local variable. - */ -export function getLocalVarDepth( - ctx: InterpreterContext, - name: string, -): number | undefined { - return ctx.state.localVarDepth?.get(name); -} - -/** - * Clear the local variable depth tracking for a variable. - * Called when a local variable is cell-unset (dynamic-unset). - */ -export function clearLocalVarDepth( - ctx: InterpreterContext, - name: string, -): void { - ctx.state.localVarDepth?.delete(name); -} - -/** - * Push the current value of a variable onto the local var stack. - * Used for bash's localvar-nest behavior where nested local declarations - * each create a new cell that can be unset independently. - */ -export function pushLocalVarStack( - ctx: InterpreterContext, - name: string, - currentValue: string | undefined, -): void { - ctx.state.localVarStack = ctx.state.localVarStack || new Map(); - const stack = ctx.state.localVarStack.get(name) || []; - stack.push({ - value: currentValue, - scopeIndex: ctx.state.localScopes.length - 1, - }); - ctx.state.localVarStack.set(name, stack); -} - -/** - * Pop the top entry from the local var stack for a variable. - * Returns the saved value and scope index if there was an entry, or undefined if the stack was empty. - */ -export function popLocalVarStack( - ctx: InterpreterContext, - name: string, -): { value: string | undefined; scopeIndex: number } | undefined { - const stack = ctx.state.localVarStack?.get(name); - if (!stack || stack.length === 0) { - return undefined; - } - return stack.pop(); -} - -/** - * Clear all local var stack entries for a specific scope index. - * Called when a function returns and its local scope is popped. - */ -export function clearLocalVarStackForScope( - ctx: InterpreterContext, - scopeIndex: number, -): void { - if (!ctx.state.localVarStack) return; - - for (const [name, stack] of ctx.state.localVarStack.entries()) { - // Remove entries from the top of the stack that belong to this scope - while ( - stack.length > 0 && - stack[stack.length - 1].scopeIndex === scopeIndex - ) { - stack.pop(); - } - // Clean up empty entries - if (stack.length === 0) { - ctx.state.localVarStack.delete(name); - } - } -} diff --git a/src/interpreter/command-resolution.ts b/src/interpreter/command-resolution.ts deleted file mode 100644 index f005bbf0..00000000 --- a/src/interpreter/command-resolution.ts +++ /dev/null @@ -1,224 +0,0 @@ -/** - * Command Resolution - * - * Handles PATH-based command resolution and lookup for external commands. - */ - -import type { IFileSystem } from "../fs/interface.js"; -import type { Command, CommandRegistry } from "../types.js"; -import type { InterpreterState } from "./types.js"; - -/** - * Context needed for command resolution - */ -export interface CommandResolutionContext { - fs: IFileSystem; - state: InterpreterState; - commands: CommandRegistry; -} - -/** - * Result type for command resolution - */ -export type ResolveCommandResult = - | { cmd: Command; path: string } - | { script: true; path: string } - | { error: "not_found" | "permission_denied"; path?: string } - | null; - -/** - * Resolve a command name to its implementation via PATH lookup. - * Returns the command and its resolved path, or null if not found. - * - * Resolution order: - * 1. If command contains "/", resolve as a path - * 2. Search PATH directories for the command file - * 3. Fall back to registry lookup (for non-InMemoryFs filesystems like OverlayFs) - */ -export async function resolveCommand( - ctx: CommandResolutionContext, - commandName: string, - pathOverride?: string, -): Promise { - // If command contains "/", it's a path - resolve directly - if (commandName.includes("/")) { - const resolvedPath = ctx.fs.resolvePath(ctx.state.cwd, commandName); - // Check if file exists - if (!(await ctx.fs.exists(resolvedPath))) { - return { error: "not_found", path: resolvedPath }; - } - // Extract command name from path - const cmdName = resolvedPath.split("/").pop() || commandName; - const cmd = ctx.commands.get(cmdName); - - // Check file properties - try { - const stat = await ctx.fs.stat(resolvedPath); - if (stat.isDirectory) { - // Trying to execute a directory - return { error: "permission_denied", path: resolvedPath }; - } - // For registered commands (like /bin/echo), skip execute check - // since they're our internal implementations - if (cmd) { - return { cmd, path: resolvedPath }; - } - // For non-registered commands, check if the file is executable - const isExecutable = (stat.mode & 0o111) !== 0; - if (!isExecutable) { - // File exists but is not executable - permission denied - return { error: "permission_denied", path: resolvedPath }; - } - // File exists and is executable - treat as user script - return { script: true, path: resolvedPath }; - } catch { - // If stat fails, treat as not found - return { error: "not_found", path: resolvedPath }; - } - } - - // Check hash table first (unless pathOverride is set, which bypasses cache) - if (!pathOverride && ctx.state.hashTable) { - const cachedPath = ctx.state.hashTable.get(commandName); - if (cachedPath) { - // Verify the cached path still exists - if (await ctx.fs.exists(cachedPath)) { - const cmd = ctx.commands.get(commandName); - if (cmd) { - return { cmd, path: cachedPath }; - } - // Also check if it's an executable script (not just registered commands) - try { - const stat = await ctx.fs.stat(cachedPath); - if (!stat.isDirectory && (stat.mode & 0o111) !== 0) { - return { script: true, path: cachedPath }; - } - } catch { - // If stat fails, fall through to PATH search - } - } else { - // Remove stale entry from hash table - ctx.state.hashTable.delete(commandName); - } - } - } - - // Search PATH directories (use override if provided, for command -p) - const pathEnv = pathOverride ?? ctx.state.env.get("PATH") ?? "/usr/bin:/bin"; - const pathDirs = pathEnv.split(":"); - - for (const dir of pathDirs) { - if (!dir) continue; - // Resolve relative PATH directories against cwd - const resolvedDir = dir.startsWith("/") - ? dir - : ctx.fs.resolvePath(ctx.state.cwd, dir); - const fullPath = `${resolvedDir}/${commandName}`; - if (await ctx.fs.exists(fullPath)) { - // File exists - check if it's a directory - try { - const stat = await ctx.fs.stat(fullPath); - if (stat.isDirectory) { - continue; // Skip directories - } - const isExecutable = (stat.mode & 0o111) !== 0; - // Check for registered command handler - const cmd = ctx.commands.get(commandName); - - // Determine if this is a system directory where command stubs live - const isSystemDir = dir === "/bin" || dir === "/usr/bin"; - - if (cmd && isSystemDir) { - // Registered commands in system directories work without execute bits - // (they're our internal implementations with stub files) - return { cmd, path: fullPath }; - } - - // For non-system directories (or non-registered commands), require executable - if (isExecutable) { - if (cmd && !isSystemDir) { - // User script shadows a registered command - treat as script - return { script: true, path: fullPath }; - } - if (!cmd) { - // No registered handler - treat as user script - return { script: true, path: fullPath }; - } - } - } catch { - // If stat fails, continue searching - } - } - } - - // Fallback: check registry directly only if /usr/bin doesn't exist - // This maintains backward compatibility for OverlayFs and other non-InMemoryFs - // where command stubs aren't created, while still respecting PATH for InMemoryFs - const usrBinExists = await ctx.fs.exists("/usr/bin"); - if (!usrBinExists) { - const cmd = ctx.commands.get(commandName); - if (cmd) { - return { cmd, path: `/usr/bin/${commandName}` }; - } - } - - return null; -} - -/** - * Find all paths for a command in PATH (for `which -a`). - */ -export async function findCommandInPath( - ctx: CommandResolutionContext, - commandName: string, -): Promise { - const paths: string[] = []; - - // If command contains /, it's a path - check if it exists and is executable - if (commandName.includes("/")) { - const resolvedPath = ctx.fs.resolvePath(ctx.state.cwd, commandName); - if (await ctx.fs.exists(resolvedPath)) { - try { - const stat = await ctx.fs.stat(resolvedPath); - if (!stat.isDirectory) { - // Check if file is executable (owner, group, or other execute bit set) - const isExecutable = (stat.mode & 0o111) !== 0; - if (isExecutable) { - // Return the original path format (not resolved) to match bash behavior - paths.push(commandName); - } - } - } catch { - // If stat fails, skip - } - } - return paths; - } - - const pathEnv = ctx.state.env.get("PATH") || "/usr/bin:/bin"; - const pathDirs = pathEnv.split(":"); - - for (const dir of pathDirs) { - if (!dir) continue; - // Resolve relative PATH entries relative to cwd - const resolvedDir = dir.startsWith("/") - ? dir - : ctx.fs.resolvePath(ctx.state.cwd, dir); - const fullPath = `${resolvedDir}/${commandName}`; - if (await ctx.fs.exists(fullPath)) { - // Check if it's a directory - skip directories - try { - const stat = await ctx.fs.stat(fullPath); - if (stat.isDirectory) { - continue; - } - } catch { - continue; - } - // Return the original path format (relative if relative was given) - paths.push(dir.startsWith("/") ? fullPath : `${dir}/${commandName}`); - } - } - - return paths; -} diff --git a/src/interpreter/conditionals.ts b/src/interpreter/conditionals.ts deleted file mode 100644 index 53fa54a8..00000000 --- a/src/interpreter/conditionals.ts +++ /dev/null @@ -1,1133 +0,0 @@ -/** - * Conditional Expression Evaluation - * - * Handles: - * - [[ ... ]] conditional commands - * - [ ... ] and test commands - * - File tests (-f, -d, -e, etc.) - * - String tests (-z, -n, =, !=) - * - Numeric comparisons (-eq, -ne, -lt, etc.) - * - Pattern matching (==, =~) - */ - -import type { ConditionalExpressionNode } from "../ast/types.js"; -import { parseArithmeticExpression } from "../parser/arithmetic-parser.js"; -import { Parser } from "../parser/parser.js"; -import { createUserRegex } from "../regex/index.js"; -import type { ExecResult } from "../types.js"; -import { evaluateArithmetic } from "./arithmetic.js"; -import { - escapeRegexChars, - expandWord, - expandWordForPattern, - expandWordForRegex, -} from "./expansion.js"; -import { clearArray } from "./helpers/array.js"; -import { - evaluateBinaryFileTest, - evaluateFileTest, - isBinaryFileTestOperator, - isFileTestOperator, -} from "./helpers/file-tests.js"; -import { compareNumeric, isNumericOp } from "./helpers/numeric-compare.js"; -import { result as execResult, failure, testResult } from "./helpers/result.js"; -import { compareStrings, isStringCompareOp } from "./helpers/string-compare.js"; -import { evaluateStringTest, isStringTestOp } from "./helpers/string-tests.js"; -import { evaluateVariableTest } from "./helpers/variable-tests.js"; -import type { InterpreterContext } from "./types.js"; - -export async function evaluateConditional( - ctx: InterpreterContext, - expr: ConditionalExpressionNode, -): Promise { - switch (expr.type) { - case "CondBinary": { - const left = await expandWord(ctx, expr.left); - - // Check if RHS is fully quoted (should be treated literally, not as pattern) - // For regex (=~), Escaped parts are NOT considered "quoted" because they need - // backslash preservation for the regex engine. For == and !=, Escaped parts - // should be treated as literal characters (quoted). - const isRhsQuoted = - expr.right.parts.length > 0 && - expr.right.parts.every( - (p) => - p.type === "SingleQuoted" || - p.type === "DoubleQuoted" || - // Escaped counts as quoted for pattern matching, but NOT for regex - (p.type === "Escaped" && expr.operator !== "=~"), - ); - - // For pattern comparisons (== and !=), use expandWordForPattern to preserve - // backslash escapes for pattern metacharacters like \( and \) - // This ensures *\(\) matches "foo()" by treating \( and \) as literal - // For regex (=~), use expandWordForRegex to preserve all backslash escapes - // so \[\] works as a regex to match literal [] - // When regex pattern is quoted, escape regex metacharacters for literal matching - let right: string; - if (expr.operator === "=~") { - if (isRhsQuoted) { - // Quoted regex patterns should have metacharacters escaped for literal matching - // e.g., [[ 'a b' =~ '^(a b)$' ]] should NOT match because ^ ( ) $ are literals - const expanded = await expandWord(ctx, expr.right); - right = escapeRegexChars(expanded); - } else { - right = await expandWordForRegex(ctx, expr.right); - } - } else if (isStringCompareOp(expr.operator) && !isRhsQuoted) { - right = await expandWordForPattern(ctx, expr.right); - } else { - right = await expandWord(ctx, expr.right); - } - - // String comparisons (with pattern matching support in [[ ]]) - if (isStringCompareOp(expr.operator)) { - const nocasematch = ctx.state.shoptOptions.nocasematch; - // In [[ ]], extglob patterns are always recognized regardless of shopt setting - // The extglob shopt only affects filename globbing and variable assignment syntax - return compareStrings( - expr.operator, - left, - right, - !isRhsQuoted, - nocasematch, - true, // Always enable extglob in [[ ]] pattern matching - ); - } - - // Numeric comparisons - if (isNumericOp(expr.operator)) { - return compareNumeric( - expr.operator, - await evalArithExpr(ctx, left), - await evalArithExpr(ctx, right), - ); - } - - // Binary file tests - if (isBinaryFileTestOperator(expr.operator)) { - return evaluateBinaryFileTest(ctx, expr.operator, left, right); - } - - switch (expr.operator) { - case "=~": { - try { - const nocasematch = ctx.state.shoptOptions.nocasematch; - // Convert POSIX ERE syntax to JavaScript regex syntax - const jsPattern = posixEreToJsRegex(right); - const regex = createUserRegex(jsPattern, nocasematch ? "i" : ""); - const match = regex.match(left); - // Always clear BASH_REMATCH first (bash clears it on failed match) - clearArray(ctx, "BASH_REMATCH"); - if (match) { - // Store full match at index 0, capture groups at indices 1, 2, ... - for (let i = 0; i < match.length; i++) { - ctx.state.env.set(`BASH_REMATCH_${i}`, match[i] || ""); - } - } - return match !== null; - } catch { - // Invalid regex pattern is a syntax error (exit code 2) - throw new Error("syntax error in regular expression"); - } - } - case "<": - return left < right; - case ">": - return left > right; - default: - return false; - } - } - - case "CondUnary": { - const operand = await expandWord(ctx, expr.operand); - - // Handle file test operators using shared helper - if (isFileTestOperator(expr.operator)) { - return evaluateFileTest(ctx, expr.operator, operand); - } - - if (isStringTestOp(expr.operator)) { - return evaluateStringTest(expr.operator, operand); - } - if (expr.operator === "-v") { - return await evaluateVariableTest(ctx, operand); - } - if (expr.operator === "-o") { - return evaluateShellOption(ctx, operand); - } - return false; - } - - case "CondNot": { - // When extglob is enabled and we have !( group ), it should be treated - // as an extglob pattern instead of negation. In bash, with extglob on, - // [[ !($str) ]] parses differently - the !() is a pattern, not negation. - // Since we parse before knowing extglob state, we handle this at evaluation. - // - // Check if operand is CondGroup containing CondWord - if extglob is on, - // treat the whole thing as a pattern word (which is always non-empty). - if (ctx.state.shoptOptions.extglob) { - if ( - expr.operand.type === "CondGroup" && - expr.operand.expression.type === "CondWord" - ) { - // With extglob, !($str) is an extglob pattern, not negation. - // Expand the word inside the group, construct the extglob pattern, - // and test if the pattern string is non-empty (which it always is). - const innerValue = await expandWord( - ctx, - expr.operand.expression.word, - ); - // The extglob pattern "!(value)" is always a non-empty string - const extglobPattern = `!(${innerValue})`; - return extglobPattern !== ""; - } - } - return !(await evaluateConditional(ctx, expr.operand)); - } - - case "CondAnd": { - const left = await evaluateConditional(ctx, expr.left); - if (!left) return false; - return await evaluateConditional(ctx, expr.right); - } - - case "CondOr": { - const left = await evaluateConditional(ctx, expr.left); - if (left) return true; - return await evaluateConditional(ctx, expr.right); - } - - case "CondGroup": - return await evaluateConditional(ctx, expr.expression); - - case "CondWord": { - const value = await expandWord(ctx, expr.word); - return value !== ""; - } - - default: - return false; - } -} - -export async function evaluateTestArgs( - ctx: InterpreterContext, - args: string[], -): Promise { - if (args.length === 0) { - return execResult("", "", 1); - } - - if (args.length === 1) { - return testResult(Boolean(args[0])); - } - - if (args.length === 2) { - const op = args[0]; - const operand = args[1]; - - // "(" without matching ")" is a syntax error - if (op === "(") { - return failure("test: '(' without matching ')'\n", 2); - } - - // Handle file test operators using shared helper - if (isFileTestOperator(op)) { - return testResult(await evaluateFileTest(ctx, op, operand)); - } - - if (isStringTestOp(op)) { - return testResult(evaluateStringTest(op, operand)); - } - if (op === "!") { - return testResult(!operand); - } - if (op === "-v") { - return testResult(await evaluateVariableTest(ctx, operand)); - } - if (op === "-o") { - return testResult(evaluateShellOption(ctx, operand)); - } - // If the first arg is a known binary operator but used in 2-arg context, it's an error - if ( - op === "=" || - op === "==" || - op === "!=" || - op === "<" || - op === ">" || - op === "-eq" || - op === "-ne" || - op === "-lt" || - op === "-le" || - op === "-gt" || - op === "-ge" || - op === "-nt" || - op === "-ot" || - op === "-ef" - ) { - return failure(`test: ${op}: unary operator expected\n`, 2); - } - return execResult("", "", 1); - } - - if (args.length === 3) { - const left = args[0]; - const op = args[1]; - const right = args[2]; - - // POSIX 3-argument rules: - // If $2 is a binary primary, evaluate as: $1 op $3 - // Binary primaries include: =, !=, -eq, -ne, -lt, -le, -gt, -ge, -a, -o, -nt, -ot, -ef - // Note: -a and -o as binary primaries test if both/either operand is non-empty - - // String comparisons (no pattern matching in test/[) - if (isStringCompareOp(op)) { - return testResult(compareStrings(op, left, right)); - } - - if (isNumericOp(op)) { - const leftNum = parseNumericDecimal(left); - const rightNum = parseNumericDecimal(right); - // Invalid operand returns exit code 2 - if (!leftNum.valid || !rightNum.valid) { - return execResult("", "", 2); - } - return testResult(compareNumeric(op, leftNum.value, rightNum.value)); - } - - // Binary file tests - if (isBinaryFileTestOperator(op)) { - return testResult(await evaluateBinaryFileTest(ctx, op, left, right)); - } - - switch (op) { - case "-a": - // In 3-arg context, -a is binary AND: both operands must be non-empty - return testResult(left !== "" && right !== ""); - case "-o": - // In 3-arg context, -o is binary OR: at least one operand must be non-empty - return testResult(left !== "" || right !== ""); - case ">": - // String comparison: left > right (lexicographically) - return testResult(left > right); - case "<": - // String comparison: left < right (lexicographically) - return testResult(left < right); - } - - // If $1 is '!', negate the 2-argument test - if (left === "!") { - const negResult = await evaluateTestArgs(ctx, [op, right]); - return execResult( - "", - negResult.stderr, - negResult.exitCode === 0 - ? 1 - : negResult.exitCode === 1 - ? 0 - : negResult.exitCode, - ); - } - - // If $1 is '(' and $3 is ')', evaluate $2 as single-arg test - if (left === "(" && right === ")") { - return testResult(op !== ""); - } - } - - // POSIX 4-argument rules - if (args.length === 4) { - // If $1 is '!', negate the 3-argument expression - if (args[0] === "!") { - const negResult = await evaluateTestArgs(ctx, args.slice(1)); - return execResult( - "", - negResult.stderr, - negResult.exitCode === 0 - ? 1 - : negResult.exitCode === 1 - ? 0 - : negResult.exitCode, - ); - } - - // If $1 is '(' and $4 is ')', evaluate $2 and $3 as 2-arg expression - if (args[0] === "(" && args[3] === ")") { - return evaluateTestArgs(ctx, [args[1], args[2]]); - } - } - - // Handle compound expressions with -a (AND) and -o (OR) - const exprResult = await evaluateTestExpr(ctx, args, 0); - - // Check for unconsumed tokens (extra arguments = syntax error) - if (exprResult.pos < args.length) { - return failure("test: too many arguments\n", 2); - } - - return testResult(exprResult.value); -} - -// Recursive expression evaluator for test command -async function evaluateTestExpr( - ctx: InterpreterContext, - args: string[], - pos: number, -): Promise<{ value: boolean; pos: number }> { - return evaluateTestOr(ctx, args, pos); -} - -async function evaluateTestOr( - ctx: InterpreterContext, - args: string[], - pos: number, -): Promise<{ value: boolean; pos: number }> { - let { value, pos: newPos } = await evaluateTestAnd(ctx, args, pos); - while (args[newPos] === "-o") { - const right = await evaluateTestAnd(ctx, args, newPos + 1); - value = value || right.value; - newPos = right.pos; - } - return { value, pos: newPos }; -} - -async function evaluateTestAnd( - ctx: InterpreterContext, - args: string[], - pos: number, -): Promise<{ value: boolean; pos: number }> { - let { value, pos: newPos } = await evaluateTestNot(ctx, args, pos); - while (args[newPos] === "-a") { - const right = await evaluateTestNot(ctx, args, newPos + 1); - value = value && right.value; - newPos = right.pos; - } - return { value, pos: newPos }; -} - -async function evaluateTestNot( - ctx: InterpreterContext, - args: string[], - pos: number, -): Promise<{ value: boolean; pos: number }> { - if (args[pos] === "!") { - const { value, pos: newPos } = await evaluateTestNot(ctx, args, pos + 1); - return { value: !value, pos: newPos }; - } - return evaluateTestPrimary(ctx, args, pos); -} - -async function evaluateTestPrimary( - ctx: InterpreterContext, - args: string[], - pos: number, -): Promise<{ value: boolean; pos: number }> { - const token = args[pos]; - - // Parentheses grouping - if (token === "(") { - const { value, pos: newPos } = await evaluateTestExpr(ctx, args, pos + 1); - // Skip closing ) - return { value, pos: args[newPos] === ")" ? newPos + 1 : newPos }; - } - - // IMPORTANT: Check for binary operators FIRST, before unary operators. - // This handles the ambiguous case where a flag-like string (e.g., "-o", "-z", "-f") - // is used as the left operand of a binary comparison. - // For example: test -o != foo -> should compare "-o" with "foo", not test shell option "!=" - // Similarly: test 1 -eq 1 -a -o != foo -> after -a, "-o" followed by "!=" is a comparison - const next = args[pos + 1]; - - // Check for binary string operators - // Note: [ / test uses literal string comparison, NOT pattern matching - if (isStringCompareOp(next)) { - const left = token; - const right = args[pos + 2] ?? ""; - return { value: compareStrings(next, left, right), pos: pos + 3 }; - } - - // Check for binary numeric operators - if (isNumericOp(next)) { - const leftParsed = parseNumericDecimal(token); - const rightParsed = parseNumericDecimal(args[pos + 2] ?? "0"); - // Invalid operands - return false (will cause exit code 2 at higher level) - if (!leftParsed.valid || !rightParsed.valid) { - // For now, return false which is at least consistent with "comparison failed" - return { value: false, pos: pos + 3 }; - } - const value = compareNumeric(next, leftParsed.value, rightParsed.value); - return { value, pos: pos + 3 }; - } - - // Binary file tests - if (isBinaryFileTestOperator(next)) { - const left = token; - const right = args[pos + 2] ?? ""; - const value = await evaluateBinaryFileTest(ctx, next, left, right); - return { value, pos: pos + 3 }; - } - - // Now check for unary operators (only if next token is NOT a binary operator) - - // Unary file tests - use shared helper - if (isFileTestOperator(token)) { - const operand = args[pos + 1] ?? ""; - const value = await evaluateFileTest(ctx, token, operand); - return { value, pos: pos + 2 }; - } - - // Unary string tests - use shared helper - if (isStringTestOp(token)) { - const operand = args[pos + 1] ?? ""; - return { value: evaluateStringTest(token, operand), pos: pos + 2 }; - } - - // Variable tests - if (token === "-v") { - const varName = args[pos + 1] ?? ""; - const value = await evaluateVariableTest(ctx, varName); - return { value, pos: pos + 2 }; - } - - // Shell option tests - if (token === "-o") { - const optName = args[pos + 1] ?? ""; - const value = evaluateShellOption(ctx, optName); - return { value, pos: pos + 2 }; - } - - // Single argument: true if non-empty - return { value: token !== undefined && token !== "", pos: pos + 1 }; -} - -export function matchPattern( - value: string, - pattern: string, - nocasematch = false, - extglob = false, -): boolean { - const regex = `^${patternToRegexStr(pattern, extglob)}$`; - // Use 's' flag (dotAll) so that * matches newlines in the value - // This matches bash behavior where patterns like *foo* match multiline values - const flags = nocasematch ? "is" : "s"; - return createUserRegex(regex, flags).test(value); -} - -/** - * Convert a glob pattern to a regex string (without anchors). - * Supports extglob patterns: @(...), *(...), +(...), ?(...), !(...) - */ -function patternToRegexStr(pattern: string, extglob: boolean): string { - let regex = ""; - for (let i = 0; i < pattern.length; i++) { - const char = pattern[i]; - - // Check for extglob patterns: @(...), *(...), +(...), ?(...), !(...) - if ( - extglob && - (char === "@" || - char === "*" || - char === "+" || - char === "?" || - char === "!") && - i + 1 < pattern.length && - pattern[i + 1] === "(" - ) { - // Find the matching closing paren (handle nesting) - const closeIdx = findMatchingParen(pattern, i + 1); - if (closeIdx !== -1) { - const content = pattern.slice(i + 2, closeIdx); - // Split on | but handle nested extglob patterns - const alternatives = splitExtglobAlternatives(content); - // Convert each alternative recursively - const altRegexes = alternatives.map((alt) => - patternToRegexStr(alt, extglob), - ); - const altGroup = altRegexes.length > 0 ? altRegexes.join("|") : "(?:)"; - - if (char === "@") { - // @(...) - match exactly one of the patterns - regex += `(?:${altGroup})`; - } else if (char === "*") { - // *(...) - match zero or more occurrences - regex += `(?:${altGroup})*`; - } else if (char === "+") { - // +(...) - match one or more occurrences - regex += `(?:${altGroup})+`; - } else if (char === "?") { - // ?(...) - match zero or one occurrence - regex += `(?:${altGroup})?`; - } else if (char === "!") { - // !(...) - match anything except the patterns - // When !(pattern) is followed by more pattern content, we need special handling - const hasMorePattern = closeIdx < pattern.length - 1; - if (hasMorePattern) { - // Try to compute fixed lengths for the alternatives - const lengths = alternatives.map((alt) => - computePatternLength(alt, extglob), - ); - const allSameLength = - lengths.every((l) => l !== null) && - lengths.every((l) => l === lengths[0]); - - if (allSameLength && lengths[0] !== null) { - const n = lengths[0]; - if (n === 0) { - // !(empty) followed by more - matches any non-empty string - regex += "(?:.+)"; - } else { - // Match: n chars OR exactly n chars that aren't the pattern - const parts: string[] = []; - if (n > 0) { - parts.push(`.{0,${n - 1}}`); - } - parts.push(`.{${n + 1},}`); - parts.push(`(?!(?:${altGroup})).{${n}}`); - regex += `(?:${parts.join("|")})`; - } - } else { - // Complex case: different lengths or variable-length patterns - regex += `(?:(?!(?:${altGroup})).)*?`; - } - } else { - // At end of pattern - use simple negative lookahead - regex += `(?!(?:${altGroup})$).*`; - } - } - i = closeIdx; - continue; - } - } - - // Handle backslash escapes - next char is literal - if (char === "\\") { - if (i + 1 < pattern.length) { - const next = pattern[i + 1]; - // Escape regex special chars - if (/[\\^$.|+(){}[\]*?]/.test(next)) { - regex += `\\${next}`; - } else { - regex += next; - } - i++; // Skip the escaped character - } else { - regex += "\\\\"; // Trailing backslash - } - } else if (char === "*") { - regex += ".*"; - } else if (char === "?") { - regex += "."; - } else if (char === "[") { - const closeIdx = pattern.indexOf("]", i + 1); - if (closeIdx !== -1) { - regex += pattern.slice(i, closeIdx + 1); - i = closeIdx; - } else { - regex += "\\["; - } - } else if (/[\\^$.|+(){}]/.test(char)) { - regex += `\\${char}`; - } else { - regex += char; - } - } - return regex; -} - -/** - * Find the matching closing parenthesis, handling nesting - */ -function findMatchingParen(pattern: string, openIdx: number): number { - let depth = 1; - let i = openIdx + 1; - while (i < pattern.length && depth > 0) { - const c = pattern[i]; - if (c === "\\") { - i += 2; // Skip escaped char - continue; - } - if (c === "(") { - depth++; - } else if (c === ")") { - depth--; - if (depth === 0) { - return i; - } - } - i++; - } - return -1; -} - -/** - * Split extglob pattern content on | handling nested patterns - */ -function splitExtglobAlternatives(content: string): string[] { - const alternatives: string[] = []; - let current = ""; - let depth = 0; - let i = 0; - - while (i < content.length) { - const c = content[i]; - if (c === "\\") { - // Escaped character - current += c; - if (i + 1 < content.length) { - current += content[i + 1]; - i += 2; - } else { - i++; - } - continue; - } - if (c === "(") { - depth++; - current += c; - } else if (c === ")") { - depth--; - current += c; - } else if (c === "|" && depth === 0) { - alternatives.push(current); - current = ""; - } else { - current += c; - } - i++; - } - alternatives.push(current); - return alternatives; -} - -/** - * Compute the fixed length of a pattern, if it has one. - * Returns null if the pattern has variable length (contains *, +, etc.). - * Used to optimize !() extglob patterns. - */ -function computePatternLength( - pattern: string, - extglob: boolean, -): number | null { - let length = 0; - let i = 0; - - while (i < pattern.length) { - const c = pattern[i]; - - // Check for extglob patterns - if ( - extglob && - (c === "@" || c === "*" || c === "+" || c === "?" || c === "!") && - i + 1 < pattern.length && - pattern[i + 1] === "(" - ) { - const closeIdx = findMatchingParen(pattern, i + 1); - if (closeIdx !== -1) { - if (c === "@") { - // @() matches exactly one occurrence - get length of alternatives - const content = pattern.slice(i + 2, closeIdx); - const alts = splitExtglobAlternatives(content); - const altLengths = alts.map((a) => computePatternLength(a, extglob)); - // All alternatives must have same length for fixed length - if ( - altLengths.every((l) => l !== null) && - altLengths.every((l) => l === altLengths[0]) - ) { - length += altLengths[0] as number; - i = closeIdx + 1; - continue; - } - return null; // Variable length - } - // *, +, ?, ! all have variable length - return null; - } - } - - if (c === "*") { - return null; // Variable length - } - if (c === "?") { - length += 1; - i++; - continue; - } - if (c === "[") { - // Character class matches exactly 1 char - const closeIdx = pattern.indexOf("]", i + 1); - if (closeIdx !== -1) { - length += 1; - i = closeIdx + 1; - continue; - } - // No closing bracket - treat as literal - length += 1; - i++; - continue; - } - if (c === "\\") { - // Escaped char - length += 1; - i += 2; - continue; - } - // Regular character - length += 1; - i++; - } - - return length; -} - -/** - * Evaluate -o option test (check if shell option is enabled). - * Maps option names to interpreter state flags. - */ -function evaluateShellOption(ctx: InterpreterContext, option: string): boolean { - // Map of option names to their state in ctx.state.options - // Only includes options that are actually implemented - const optionMap = new Map boolean>([ - // Implemented options (set -o) - ["errexit", () => ctx.state.options.errexit === true], - ["nounset", () => ctx.state.options.nounset === true], - ["pipefail", () => ctx.state.options.pipefail === true], - ["xtrace", () => ctx.state.options.xtrace === true], - // Single-letter aliases for implemented options - ["e", () => ctx.state.options.errexit === true], - ["u", () => ctx.state.options.nounset === true], - ["x", () => ctx.state.options.xtrace === true], - ]); - - const getter = optionMap.get(option); - if (getter) { - return getter(); - } - // Unknown or unimplemented option - return false - return false; -} - -/** - * Evaluate an arithmetic expression string for [[ ]] comparisons. - * In bash, [[ -eq ]] etc. evaluate operands as arithmetic expressions. - */ -async function evalArithExpr( - ctx: InterpreterContext, - expr: string, -): Promise { - expr = expr.trim(); - if (expr === "") return 0; - - // First try simple numeric parsing (handles octal, hex, base-N) - // If the expression is just a number, parseNumeric handles it correctly - if (/^[+-]?(\d+#[a-zA-Z0-9@_]+|0[xX][0-9a-fA-F]+|0[0-7]+|\d+)$/.test(expr)) { - return parseNumeric(expr); - } - - // Otherwise, parse and evaluate as arithmetic expression - try { - const parser = new Parser(); - const arithAst = parseArithmeticExpression(parser, expr); - return await evaluateArithmetic(ctx, arithAst.expression); - } catch { - // If parsing fails, try simple numeric - return parseNumeric(expr); - } -} - -/** - * Parse a number in base N (2-64). - * Digit values: 0-9=0-9, a-z=10-35, A-Z=36-61, @=62, _=63 - */ -function parseBaseN(digits: string, base: number): number { - let result = 0; - for (const char of digits) { - let digitValue: number; - if (char >= "0" && char <= "9") { - digitValue = char.charCodeAt(0) - 48; // '0' = 48 - } else if (char >= "a" && char <= "z") { - digitValue = char.charCodeAt(0) - 97 + 10; // 'a' = 97 - } else if (char >= "A" && char <= "Z") { - digitValue = char.charCodeAt(0) - 65 + 36; // 'A' = 65 - } else if (char === "@") { - digitValue = 62; - } else if (char === "_") { - digitValue = 63; - } else { - return Number.NaN; - } - if (digitValue >= base) { - return Number.NaN; - } - result = result * base + digitValue; - } - return result; -} - -/** - * Parse a bash numeric value, supporting: - * - Decimal: 42, -42 - * - Octal: 0777, -0123 - * - Hex: 0xff, 0xFF, -0xff - * - Base-N: 64#a, 2#1010 - * - Strings are coerced to 0 - */ -function parseNumeric(value: string): number { - value = value.trim(); - if (value === "") return 0; - - // Handle negative numbers - let negative = false; - if (value.startsWith("-")) { - negative = true; - value = value.slice(1); - } else if (value.startsWith("+")) { - value = value.slice(1); - } - - let result: number; - - // Base-N syntax: base#value - const baseMatch = value.match(/^(\d+)#([a-zA-Z0-9@_]+)$/); - if (baseMatch) { - const base = Number.parseInt(baseMatch[1], 10); - if (base >= 2 && base <= 64) { - result = parseBaseN(baseMatch[2], base); - } else { - result = 0; - } - } - // Hex: 0x or 0X - else if (/^0[xX][0-9a-fA-F]+$/.test(value)) { - result = Number.parseInt(value, 16); - } - // Octal: starts with 0 followed by digits (0-7) - else if (/^0[0-7]+$/.test(value)) { - result = Number.parseInt(value, 8); - } - // Decimal - else { - result = Number.parseInt(value, 10); - } - - // NaN becomes 0 (bash coerces invalid strings to 0) - if (Number.isNaN(result)) { - result = 0; - } - - return negative ? -result : result; -} - -/** - * Parse a number as plain decimal (for test/[ command). - * Unlike parseNumeric, this does NOT interpret octal/hex/base-N. - * Leading zeros are treated as decimal. - * Returns { value, valid } - valid is false if input is invalid. - */ -function parseNumericDecimal(value: string): { value: number; valid: boolean } { - value = value.trim(); - if (value === "") return { value: 0, valid: true }; - - // Handle negative numbers - let negative = false; - if (value.startsWith("-")) { - negative = true; - value = value.slice(1); - } else if (value.startsWith("+")) { - value = value.slice(1); - } - - // Check if it's a valid decimal number (only digits) - if (!/^\d+$/.test(value)) { - // Invalid format (hex, base-N, letters, etc.) - return { value: 0, valid: false }; - } - - // Always parse as decimal (base 10) - const result = Number.parseInt(value, 10); - - // NaN is invalid - if (Number.isNaN(result)) { - return { value: 0, valid: false }; - } - - return { value: negative ? -result : result, valid: true }; -} - -/** - * Convert a POSIX Extended Regular Expression to JavaScript RegExp syntax. - * - * Key differences handled: - * 1. `[]...]` - In POSIX, `]` is literal when first in class. In JS, need `\]` - * 2. `[^]...]` - Same with negated class - * 3. `[[:class:]]` - POSIX character classes need conversion - * - * @param pattern - POSIX ERE pattern string - * @returns JavaScript-compatible regex pattern string - */ -function posixEreToJsRegex(pattern: string): string { - let result = ""; - let i = 0; - - while (i < pattern.length) { - // Handle backslash escapes - skip the escaped character - if (pattern[i] === "\\" && i + 1 < pattern.length) { - result += pattern[i] + pattern[i + 1]; - i += 2; - } else if (pattern[i] === "[") { - // Found start of character class - const classResult = convertPosixCharClass(pattern, i); - result += classResult.converted; - i = classResult.endIndex; - } else { - result += pattern[i]; - i++; - } - } - - return result; -} - -/** - * Convert a POSIX character class starting at `startIndex` (where pattern[startIndex] === '[') - * to JavaScript regex character class syntax. - * - * Returns the converted class and the index after the closing `]`. - */ -function convertPosixCharClass( - pattern: string, - startIndex: number, -): { converted: string; endIndex: number } { - let i = startIndex + 1; - let result = "["; - - // Handle negation: [^ or [! - if (i < pattern.length && (pattern[i] === "^" || pattern[i] === "!")) { - result += "^"; - i++; - } - - // In POSIX, ] is literal when it's the first char (after optional ^) - // We need to collect it and add it later in a JS-compatible position - let hasLiteralCloseBracket = false; - if (i < pattern.length && pattern[i] === "]") { - hasLiteralCloseBracket = true; - i++; - } - - // In POSIX, [ can also be literal when first (after optional ^ and ]) - let hasLiteralOpenBracket = false; - if ( - i < pattern.length && - pattern[i] === "[" && - i + 1 < pattern.length && - pattern[i + 1] !== ":" - ) { - hasLiteralOpenBracket = true; - i++; - } - - // Collect the rest of the character class content - let classContent = ""; - let foundClose = false; - - while (i < pattern.length) { - const ch = pattern[i]; - - if (ch === "]") { - // End of character class - foundClose = true; - i++; - break; - } - - // Handle POSIX character classes like [:alpha:] - if (ch === "[" && i + 1 < pattern.length && pattern[i + 1] === ":") { - const endPos = pattern.indexOf(":]", i + 2); - if (endPos !== -1) { - const className = pattern.slice(i + 2, endPos); - classContent += posixClassToJsClass(className); - i = endPos + 2; - continue; - } - } - - // Handle collating elements [.ch.] and equivalence classes [=ch=] - // These are rarely used but we should skip them properly - if (ch === "[" && i + 1 < pattern.length) { - const next = pattern[i + 1]; - if (next === "." || next === "=") { - const endMarker = `${next}]`; - const endPos = pattern.indexOf(endMarker, i + 2); - if (endPos !== -1) { - // For now, just include the content as literal - const content = pattern.slice(i + 2, endPos); - classContent += content; - i = endPos + 2; - continue; - } - } - } - - // Handle escape sequences - if (ch === "\\" && i + 1 < pattern.length) { - classContent += ch + pattern[i + 1]; - i += 2; - continue; - } - - classContent += ch; - i++; - } - - if (!foundClose) { - // No closing bracket found - return as literal [ - return { converted: "\\[", endIndex: startIndex + 1 }; - } - - // Build the JS-compatible character class - // In JS regex, we need to escape ] and [ or put them in specific positions - // The safest approach is to escape them with backslash - - // If we had literal ] at the start, escape it - if (hasLiteralCloseBracket) { - result += "\\]"; - } - - // If we had literal [ at the start, escape it - if (hasLiteralOpenBracket) { - result += "\\["; - } - - // Add the rest of the content - result += classContent; - - result += "]"; - return { converted: result, endIndex: i }; -} - -/** - * Convert POSIX character class name to JS regex equivalent. - */ -function posixClassToJsClass(className: string): string { - const mapping = new Map([ - ["alnum", "a-zA-Z0-9"], - ["alpha", "a-zA-Z"], - ["ascii", "\\x00-\\x7F"], - ["blank", " \\t"], - ["cntrl", "\\x00-\\x1F\\x7F"], - ["digit", "0-9"], - ["graph", "!-~"], - ["lower", "a-z"], - ["print", " -~"], - ["punct", "!-/:-@\\[-`{-~"], - ["space", " \\t\\n\\r\\f\\v"], - ["upper", "A-Z"], - ["word", "a-zA-Z0-9_"], - ["xdigit", "0-9A-Fa-f"], - ]); - - return mapping.get(className) ?? ""; -} diff --git a/src/interpreter/control-flow.test.ts b/src/interpreter/control-flow.test.ts deleted file mode 100644 index baf11143..00000000 --- a/src/interpreter/control-flow.test.ts +++ /dev/null @@ -1,531 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -describe("control flow execution", () => { - describe("if/elif/else", () => { - it("should execute if branch when condition is true", async () => { - const env = new Bash(); - const result = await env.exec(` - if true; then - echo "yes" - fi - `); - expect(result.stdout).toBe("yes\n"); - expect(result.exitCode).toBe(0); - }); - - it("should skip if branch when condition is false", async () => { - const env = new Bash(); - const result = await env.exec(` - if false; then - echo "yes" - fi - echo "done" - `); - expect(result.stdout).toBe("done\n"); - expect(result.exitCode).toBe(0); - }); - - it("should execute else branch when condition is false", async () => { - const env = new Bash(); - const result = await env.exec(` - if false; then - echo "yes" - else - echo "no" - fi - `); - expect(result.stdout).toBe("no\n"); - expect(result.exitCode).toBe(0); - }); - - it("should evaluate elif chain", async () => { - const env = new Bash(); - const result = await env.exec(` - x=2 - if [ $x -eq 1 ]; then - echo "one" - elif [ $x -eq 2 ]; then - echo "two" - elif [ $x -eq 3 ]; then - echo "three" - else - echo "other" - fi - `); - expect(result.stdout).toBe("two\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle complex conditions", async () => { - const env = new Bash(); - const result = await env.exec(` - a=5 - b=10 - if [ $a -lt $b ] && [ $b -gt 5 ]; then - echo "both true" - fi - `); - expect(result.stdout).toBe("both true\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle nested if statements", async () => { - const env = new Bash(); - const result = await env.exec(` - a=1 - b=2 - if [ $a -eq 1 ]; then - if [ $b -eq 2 ]; then - echo "nested" - fi - fi - `); - expect(result.stdout).toBe("nested\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("for loops", () => { - it("should iterate over word list", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in a b c; do - echo $i - done - `); - expect(result.stdout).toBe("a\nb\nc\n"); - expect(result.exitCode).toBe(0); - }); - - it("should iterate over expanded variable", async () => { - const env = new Bash(); - const result = await env.exec(` - items="x y z" - for i in $items; do - echo $i - done - `); - expect(result.stdout).toBe("x\ny\nz\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle IFS splitting", async () => { - const env = new Bash(); - const result = await env.exec(` - IFS=: - items="a:b:c" - for i in $items; do - echo $i - done - `); - expect(result.stdout).toBe("a\nb\nc\n"); - expect(result.exitCode).toBe(0); - }); - - it("should iterate over empty list without body execution", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in; do - echo $i - done - echo "done" - `); - expect(result.stdout).toBe("done\n"); - expect(result.exitCode).toBe(0); - }); - - it("should preserve loop variable after loop", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3; do - : - done - echo $i - `); - expect(result.stdout).toBe("3\n"); - expect(result.exitCode).toBe(0); - }); - - it("should iterate over positional parameters when no list given", async () => { - const env = new Bash({ - env: { "@": "arg1 arg2 arg3" }, - }); - const result = await env.exec(` - for i; do - echo $i - done - `); - expect(result.stdout).toBe("arg1\narg2\narg3\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle brace expansion", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in {1..3}; do - echo $i - done - `); - expect(result.stdout).toBe("1\n2\n3\n"); - expect(result.exitCode).toBe(0); - }); - - it("should error on invalid variable name", async () => { - const env = new Bash(); - const result = await env.exec(` - for 123 in a b c; do - echo $i - done - `); - expect(result.stderr).toContain("not a valid identifier"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("C-style for loops", () => { - it("should execute basic C-style for", async () => { - const env = new Bash(); - const result = await env.exec(` - for ((i=0; i<3; i++)); do - echo $i - done - `); - expect(result.stdout).toBe("0\n1\n2\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle complex expressions", async () => { - const env = new Bash(); - const result = await env.exec(` - for ((i=10; i>=0; i-=3)); do - echo $i - done - `); - expect(result.stdout).toBe("10\n7\n4\n1\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle empty init", async () => { - const env = new Bash(); - const result = await env.exec(` - i=0 - for ((; i<3; i++)); do - echo $i - done - `); - expect(result.stdout).toBe("0\n1\n2\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle empty condition (infinite loop with break)", async () => { - const env = new Bash(); - const result = await env.exec(` - for ((i=0; ; i++)); do - echo $i - if [ $i -ge 2 ]; then break; fi - done - `); - expect(result.stdout).toBe("0\n1\n2\n"); - expect(result.exitCode).toBe(0); - }); - - it("should run update on continue", async () => { - const env = new Bash(); - const result = await env.exec(` - for ((i=0; i<5; i++)); do - if [ $i -eq 2 ]; then continue; fi - echo $i - done - `); - expect(result.stdout).toBe("0\n1\n3\n4\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("while loops", () => { - it("should execute while body while condition is true", async () => { - const env = new Bash(); - const result = await env.exec(` - x=0 - while [ $x -lt 3 ]; do - echo $x - x=$((x + 1)) - done - `); - expect(result.stdout).toBe("0\n1\n2\n"); - expect(result.exitCode).toBe(0); - }); - - it("should not execute body if condition is initially false", async () => { - const env = new Bash(); - const result = await env.exec(` - while false; do - echo "inside" - done - echo "done" - `); - expect(result.stdout).toBe("done\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle nested while loops", async () => { - const env = new Bash(); - const result = await env.exec(` - i=0 - while [ $i -lt 2 ]; do - j=0 - while [ $j -lt 2 ]; do - echo "$i,$j" - j=$((j + 1)) - done - i=$((i + 1)) - done - `); - expect(result.stdout).toBe("0,0\n0,1\n1,0\n1,1\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("until loops", () => { - it("should execute until body until condition is true", async () => { - const env = new Bash(); - const result = await env.exec(` - x=0 - until [ $x -ge 3 ]; do - echo $x - x=$((x + 1)) - done - `); - expect(result.stdout).toBe("0\n1\n2\n"); - expect(result.exitCode).toBe(0); - }); - - it("should not execute body if condition is initially true", async () => { - const env = new Bash(); - const result = await env.exec(` - until true; do - echo "inside" - done - echo "done" - `); - expect(result.stdout).toBe("done\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("break and continue", () => { - it("should break out of for loop", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3 4 5; do - if [ $i -eq 3 ]; then break; fi - echo $i - done - echo done - `); - expect(result.stdout).toBe("1\n2\ndone\n"); - expect(result.exitCode).toBe(0); - }); - - it("should continue to next iteration", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3 4 5; do - if [ $i -eq 3 ]; then continue; fi - echo $i - done - `); - expect(result.stdout).toBe("1\n2\n4\n5\n"); - expect(result.exitCode).toBe(0); - }); - - it("should break multiple levels", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2; do - for j in a b c; do - if [ $j = b ]; then break 2; fi - echo "$i$j" - done - done - echo done - `); - expect(result.stdout).toBe("1a\ndone\n"); - expect(result.exitCode).toBe(0); - }); - - it("should continue multiple levels", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2; do - for j in a b; do - if [ $j = a ]; then continue 2; fi - echo "$i$j" - done - done - echo done - `); - expect(result.stdout).toBe("done\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("case statements", () => { - it("should match literal pattern", async () => { - const env = new Bash(); - const result = await env.exec(` - x=hello - case $x in - hello) echo "matched hello" ;; - world) echo "matched world" ;; - esac - `); - expect(result.stdout).toBe("matched hello\n"); - expect(result.exitCode).toBe(0); - }); - - it("should match glob pattern", async () => { - const env = new Bash(); - const result = await env.exec(` - x=hello - case $x in - h*) echo "starts with h" ;; - *) echo "default" ;; - esac - `); - expect(result.stdout).toBe("starts with h\n"); - expect(result.exitCode).toBe(0); - }); - - it("should match with multiple patterns", async () => { - const env = new Bash(); - const result = await env.exec(` - x=yes - case $x in - yes|y|Y) echo "affirmative" ;; - no|n|N) echo "negative" ;; - esac - `); - expect(result.stdout).toBe("affirmative\n"); - expect(result.exitCode).toBe(0); - }); - - it("should use default pattern", async () => { - const env = new Bash(); - const result = await env.exec(` - x=unknown - case $x in - yes) echo "yes" ;; - no) echo "no" ;; - *) echo "default" ;; - esac - `); - expect(result.stdout).toBe("default\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle fall-through with ;&", async () => { - const env = new Bash(); - const result = await env.exec(` - x=a - case $x in - a) echo "a" ;& - b) echo "b" ;; - c) echo "c" ;; - esac - `); - expect(result.stdout).toBe("a\nb\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle continue-matching with ;;&", async () => { - const env = new Bash(); - const result = await env.exec(` - x=abc - case $x in - *a*) echo "has a" ;;& - *b*) echo "has b" ;;& - *c*) echo "has c" ;; - esac - `); - expect(result.stdout).toBe("has a\nhas b\nhas c\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle quoted patterns literally", async () => { - const env = new Bash(); - const result = await env.exec(` - x='*' - case $x in - '*') echo "literal star" ;; - *) echo "default" ;; - esac - `); - expect(result.stdout).toBe("literal star\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("nested control structures", () => { - it("should handle if inside for", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3; do - if [ $i -eq 2 ]; then - echo "found two" - fi - done - `); - expect(result.stdout).toBe("found two\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle for inside if", async () => { - const env = new Bash(); - const result = await env.exec(` - x=1 - if [ $x -eq 1 ]; then - for i in a b c; do - echo $i - done - fi - `); - expect(result.stdout).toBe("a\nb\nc\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle case inside for", async () => { - const env = new Bash(); - const result = await env.exec(` - for x in foo bar baz; do - case $x in - foo) echo "one" ;; - bar) echo "two" ;; - *) echo "other" ;; - esac - done - `); - expect(result.stdout).toBe("one\ntwo\nother\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle while inside case", async () => { - const env = new Bash(); - const result = await env.exec(` - action=count - case $action in - count) - i=0 - while [ $i -lt 3 ]; do - echo $i - i=$((i + 1)) - done - ;; - esac - `); - expect(result.stdout).toBe("0\n1\n2\n"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/interpreter/control-flow.ts b/src/interpreter/control-flow.ts deleted file mode 100644 index 4517e511..00000000 --- a/src/interpreter/control-flow.ts +++ /dev/null @@ -1,533 +0,0 @@ -/** - * Control Flow Execution - * - * Handles control flow constructs: - * - if/elif/else - * - for loops - * - C-style for loops - * - while loops - * - until loops - * - case statements - * - break/continue - */ - -import type { - CaseNode, - CStyleForNode, - ForNode, - HereDocNode, - IfNode, - UntilNode, - WhileNode, - WordNode, -} from "../ast/types.js"; -import type { ExecResult } from "../types.js"; -import { evaluateArithmetic } from "./arithmetic.js"; -import { matchPattern } from "./conditionals.js"; -import { BreakError, ContinueError, GlobError } from "./errors.js"; -import { - escapeGlobChars, - expandWord, - expandWordWithGlob, - isWordFullyQuoted, -} from "./expansion.js"; -import { executeCondition } from "./helpers/condition.js"; -import { handleLoopError } from "./helpers/loop.js"; -import { failure, result, throwExecutionLimit } from "./helpers/result.js"; -import { executeStatements } from "./helpers/statements.js"; -import { applyRedirections, preOpenOutputRedirects } from "./redirections.js"; -import type { InterpreterContext } from "./types.js"; - -export async function executeIf( - ctx: InterpreterContext, - node: IfNode, -): Promise { - let stdout = ""; - let stderr = ""; - - for (const clause of node.clauses) { - // Condition evaluation should not trigger errexit - const condResult = await executeCondition(ctx, clause.condition); - stdout += condResult.stdout; - stderr += condResult.stderr; - - if (condResult.exitCode === 0) { - return executeStatements(ctx, clause.body, stdout, stderr); - } - } - - if (node.elseBody) { - return executeStatements(ctx, node.elseBody, stdout, stderr); - } - - return result(stdout, stderr, 0); -} - -export async function executeFor( - ctx: InterpreterContext, - node: ForNode, -): Promise { - // Pre-open output redirects to truncate files BEFORE expanding words - // This matches bash behavior where redirect files are opened before - // any command substitutions in the word list are evaluated - const preOpenError = await preOpenOutputRedirects(ctx, node.redirections); - if (preOpenError) { - return preOpenError; - } - - let stdout = ""; - let stderr = ""; - let exitCode = 0; - let iterations = 0; - - // Validate variable name at runtime (matches bash behavior) - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(node.variable)) { - return failure(`bash: \`${node.variable}': not a valid identifier\n`); - } - - let words: string[] = []; - if (node.words === null) { - words = (ctx.state.env.get("@") || "").split(" ").filter(Boolean); - } else if (node.words.length === 0) { - words = []; - } else { - try { - for (const word of node.words) { - const expanded = await expandWordWithGlob(ctx, word); - words.push(...expanded.values); - } - } catch (e) { - if (e instanceof GlobError) { - // failglob: return error with exit code 1 - return { stdout: "", stderr: e.stderr, exitCode: 1 }; - } - throw e; - } - } - - ctx.state.loopDepth++; - try { - for (const value of words) { - iterations++; - if (iterations > ctx.limits.maxLoopIterations) { - throwExecutionLimit( - `for loop: too many iterations (${ctx.limits.maxLoopIterations}), increase executionLimits.maxLoopIterations`, - "iterations", - stdout, - stderr, - ); - } - - ctx.state.env.set(node.variable, value); - - try { - for (const stmt of node.body) { - const stmtResult = await ctx.executeStatement(stmt); - stdout += stmtResult.stdout; - stderr += stmtResult.stderr; - exitCode = stmtResult.exitCode; - } - } catch (error) { - const loopResult = handleLoopError( - error, - stdout, - stderr, - ctx.state.loopDepth, - ); - stdout = loopResult.stdout; - stderr = loopResult.stderr; - if (loopResult.action === "break") break; - if (loopResult.action === "continue") continue; - if (loopResult.action === "error") { - // Apply output redirections before returning - const bodyResult = result(stdout, stderr, loopResult.exitCode ?? 1); - return applyRedirections(ctx, bodyResult, node.redirections); - } - throw loopResult.error; - } - } - } finally { - ctx.state.loopDepth--; - } - - // Note: In bash, the loop variable persists after the loop with its last value - // Do NOT ctx.state.env.delete(node.variable) here - - // Apply output redirections - const bodyResult = result(stdout, stderr, exitCode); - return applyRedirections(ctx, bodyResult, node.redirections); -} - -export async function executeCStyleFor( - ctx: InterpreterContext, - node: CStyleForNode, -): Promise { - // Pre-open output redirects to truncate files BEFORE evaluating expressions - // This matches bash behavior where redirect files are opened before - // any command substitutions in the loop are evaluated - const preOpenError = await preOpenOutputRedirects(ctx, node.redirections); - if (preOpenError) { - return preOpenError; - } - - // Update currentLine for $LINENO - set to loop header line - const loopLine = node.line; - if (loopLine !== undefined) { - ctx.state.currentLine = loopLine; - } - - let stdout = ""; - let stderr = ""; - let exitCode = 0; - let iterations = 0; - - if (node.init) { - await evaluateArithmetic(ctx, node.init.expression); - } - - ctx.state.loopDepth++; - try { - while (true) { - iterations++; - if (iterations > ctx.limits.maxLoopIterations) { - throwExecutionLimit( - `for loop: too many iterations (${ctx.limits.maxLoopIterations}), increase executionLimits.maxLoopIterations`, - "iterations", - stdout, - stderr, - ); - } - - if (node.condition) { - // Set LINENO to loop header line for condition evaluation - if (loopLine !== undefined) { - ctx.state.currentLine = loopLine; - } - const condResult = await evaluateArithmetic( - ctx, - node.condition.expression, - ); - if (condResult === 0) break; - } - - try { - for (const stmt of node.body) { - const stmtResult = await ctx.executeStatement(stmt); - stdout += stmtResult.stdout; - stderr += stmtResult.stderr; - exitCode = stmtResult.exitCode; - } - } catch (error) { - const loopResult = handleLoopError( - error, - stdout, - stderr, - ctx.state.loopDepth, - ); - stdout = loopResult.stdout; - stderr = loopResult.stderr; - if (loopResult.action === "break") break; - if (loopResult.action === "continue") { - // Still need to run the update expression on continue - if (node.update) { - await evaluateArithmetic(ctx, node.update.expression); - } - continue; - } - if (loopResult.action === "error") { - // Apply output redirections before returning - const bodyResult = result(stdout, stderr, loopResult.exitCode ?? 1); - return applyRedirections(ctx, bodyResult, node.redirections); - } - throw loopResult.error; - } - - if (node.update) { - await evaluateArithmetic(ctx, node.update.expression); - } - } - } finally { - ctx.state.loopDepth--; - } - - // Apply output redirections - const bodyResult = result(stdout, stderr, exitCode); - return applyRedirections(ctx, bodyResult, node.redirections); -} - -export async function executeWhile( - ctx: InterpreterContext, - node: WhileNode, - stdin = "", -): Promise { - let stdout = ""; - let stderr = ""; - let exitCode = 0; - let iterations = 0; - - // Process here-doc redirections to get stdin content - let effectiveStdin = stdin; - for (const redir of node.redirections) { - if ( - (redir.operator === "<<" || redir.operator === "<<-") && - redir.target.type === "HereDoc" - ) { - const hereDoc = redir.target as HereDocNode; - let content = await expandWord(ctx, hereDoc.content); - if (hereDoc.stripTabs) { - content = content - .split("\n") - .map((line) => line.replace(/^\t+/, "")) - .join("\n"); - } - effectiveStdin = content; - } else if (redir.operator === "<<<" && redir.target.type === "Word") { - effectiveStdin = `${await expandWord(ctx, redir.target as WordNode)}\n`; - } else if (redir.operator === "<" && redir.target.type === "Word") { - try { - const target = await expandWord(ctx, redir.target as WordNode); - const filePath = ctx.fs.resolvePath(ctx.state.cwd, target); - effectiveStdin = await ctx.fs.readFile(filePath); - } catch { - const target = await expandWord(ctx, redir.target as WordNode); - return failure(`bash: ${target}: No such file or directory\n`); - } - } - } - - // Save and set groupStdin for piped while loops - const savedGroupStdin = ctx.state.groupStdin; - if (effectiveStdin) { - ctx.state.groupStdin = effectiveStdin; - } - - ctx.state.loopDepth++; - try { - while (true) { - iterations++; - if (iterations > ctx.limits.maxLoopIterations) { - throwExecutionLimit( - `while loop: too many iterations (${ctx.limits.maxLoopIterations}), increase executionLimits.maxLoopIterations`, - "iterations", - stdout, - stderr, - ); - } - - let conditionExitCode = 0; - let shouldBreak = false; - let shouldContinue = false; - - // Condition evaluation should not trigger errexit - const savedInCondition = ctx.state.inCondition; - ctx.state.inCondition = true; - try { - for (const stmt of node.condition) { - const result = await ctx.executeStatement(stmt); - stdout += result.stdout; - stderr += result.stderr; - conditionExitCode = result.exitCode; - } - } catch (error) { - // break/continue in condition should affect THIS while loop - if (error instanceof BreakError) { - stdout += error.stdout; - stderr += error.stderr; - if (error.levels > 1 && ctx.state.loopDepth > 1) { - error.levels--; - error.stdout = stdout; - error.stderr = stderr; - ctx.state.inCondition = savedInCondition; - throw error; - } - shouldBreak = true; - } else if (error instanceof ContinueError) { - stdout += error.stdout; - stderr += error.stderr; - if (error.levels > 1 && ctx.state.loopDepth > 1) { - error.levels--; - error.stdout = stdout; - error.stderr = stderr; - ctx.state.inCondition = savedInCondition; - throw error; - } - shouldContinue = true; - } else { - ctx.state.inCondition = savedInCondition; - throw error; - } - } finally { - ctx.state.inCondition = savedInCondition; - } - - if (shouldBreak) break; - if (shouldContinue) continue; - if (conditionExitCode !== 0) break; - - try { - for (const stmt of node.body) { - const stmtResult = await ctx.executeStatement(stmt); - stdout += stmtResult.stdout; - stderr += stmtResult.stderr; - exitCode = stmtResult.exitCode; - } - } catch (error) { - const loopResult = handleLoopError( - error, - stdout, - stderr, - ctx.state.loopDepth, - ); - stdout = loopResult.stdout; - stderr = loopResult.stderr; - if (loopResult.action === "break") break; - if (loopResult.action === "continue") continue; - if (loopResult.action === "error") { - return result(stdout, stderr, loopResult.exitCode ?? 1); - } - throw loopResult.error; - } - } - } finally { - ctx.state.loopDepth--; - ctx.state.groupStdin = savedGroupStdin; - } - - return result(stdout, stderr, exitCode); -} - -export async function executeUntil( - ctx: InterpreterContext, - node: UntilNode, -): Promise { - let stdout = ""; - let stderr = ""; - let exitCode = 0; - let iterations = 0; - - ctx.state.loopDepth++; - try { - while (true) { - iterations++; - if (iterations > ctx.limits.maxLoopIterations) { - throwExecutionLimit( - `until loop: too many iterations (${ctx.limits.maxLoopIterations}), increase executionLimits.maxLoopIterations`, - "iterations", - stdout, - stderr, - ); - } - - // Condition evaluation should not trigger errexit - const condResult = await executeCondition(ctx, node.condition); - stdout += condResult.stdout; - stderr += condResult.stderr; - - if (condResult.exitCode === 0) break; - - try { - for (const stmt of node.body) { - const stmtResult = await ctx.executeStatement(stmt); - stdout += stmtResult.stdout; - stderr += stmtResult.stderr; - exitCode = stmtResult.exitCode; - } - } catch (error) { - const loopResult = handleLoopError( - error, - stdout, - stderr, - ctx.state.loopDepth, - ); - stdout = loopResult.stdout; - stderr = loopResult.stderr; - if (loopResult.action === "break") break; - if (loopResult.action === "continue") continue; - if (loopResult.action === "error") { - return result(stdout, stderr, loopResult.exitCode ?? 1); - } - throw loopResult.error; - } - } - } finally { - ctx.state.loopDepth--; - } - - return result(stdout, stderr, exitCode); -} - -export async function executeCase( - ctx: InterpreterContext, - node: CaseNode, -): Promise { - // Pre-open output redirects to truncate files BEFORE expanding case word - // This matches bash behavior where redirect files are opened before - // any command substitutions in the case word are evaluated - const preOpenError = await preOpenOutputRedirects(ctx, node.redirections); - if (preOpenError) { - return preOpenError; - } - - let stdout = ""; - let stderr = ""; - let exitCode = 0; - - const value = await expandWord(ctx, node.word); - - // fallThrough tracks whether we should execute the next case body unconditionally - // This happens when the previous case ended with ;& (unconditional fall-through) - let fallThrough = false; - - for (let i = 0; i < node.items.length; i++) { - const item = node.items[i]; - let matched = fallThrough; // If falling through, automatically match - - if (!fallThrough) { - // Normal pattern matching - for (const pattern of item.patterns) { - let patternStr = await expandWord(ctx, pattern); - // If the pattern is fully quoted, escape glob characters for literal matching - if (isWordFullyQuoted(pattern)) { - patternStr = escapeGlobChars(patternStr); - } - const nocasematch = ctx.state.shoptOptions.nocasematch; - const extglob = ctx.state.shoptOptions.extglob; - if (matchPattern(value, patternStr, nocasematch, extglob)) { - matched = true; - break; - } - } - } - - if (matched) { - const bodyResult = await executeStatements( - ctx, - item.body, - stdout, - stderr, - ); - stdout = bodyResult.stdout; - stderr = bodyResult.stderr; - exitCode = bodyResult.exitCode; - - // Handle different terminators: - // ;; - stop, no fall-through - // ;& - unconditional fall-through (execute next body without pattern check) - // ;;& - continue pattern matching (check next case patterns) - if (item.terminator === ";;") { - break; - } else if (item.terminator === ";&") { - fallThrough = true; - } else { - // ;;& - reset fallThrough, continue to next iteration for pattern matching - fallThrough = false; - } - } else { - fallThrough = false; - } - } - - // Apply output redirections - const bodyResult = result(stdout, stderr, exitCode); - return applyRedirections(ctx, bodyResult, node.redirections); -} diff --git a/src/interpreter/errors.ts b/src/interpreter/errors.ts deleted file mode 100644 index 29cc7070..00000000 --- a/src/interpreter/errors.ts +++ /dev/null @@ -1,265 +0,0 @@ -/** - * Control Flow Errors - * - * Error classes used to implement shell control flow: - * - break: Exit loops - * - continue: Skip to next iteration - * - return: Exit functions - * - errexit: Exit on error (set -e) - * - nounset: Error on unset variables (set -u) - * - * All control flow errors carry stdout/stderr to accumulate output - * as they propagate through the execution stack. - */ - -/** - * Base class for all control flow errors. - * Carries stdout/stderr to preserve output during propagation. - */ -abstract class ControlFlowError extends Error { - constructor( - message: string, - public stdout: string = "", - public stderr: string = "", - ) { - super(message); - } - - /** - * Prepend output from the current context before re-throwing. - */ - prependOutput(stdout: string, stderr: string): void { - this.stdout = stdout + this.stdout; - this.stderr = stderr + this.stderr; - } -} - -/** - * Error thrown when break is called to exit loops. - */ -export class BreakError extends ControlFlowError { - readonly name = "BreakError"; - - constructor( - public levels: number = 1, - stdout: string = "", - stderr: string = "", - ) { - super("break", stdout, stderr); - } -} - -/** - * Error thrown when continue is called to skip to next iteration. - */ -export class ContinueError extends ControlFlowError { - readonly name = "ContinueError"; - - constructor( - public levels: number = 1, - stdout: string = "", - stderr: string = "", - ) { - super("continue", stdout, stderr); - } -} - -/** - * Error thrown when return is called to exit a function. - */ -export class ReturnError extends ControlFlowError { - readonly name = "ReturnError"; - - constructor( - public exitCode: number = 0, - stdout: string = "", - stderr: string = "", - ) { - super("return", stdout, stderr); - } -} - -/** - * Error thrown when set -e (errexit) is enabled and a command fails. - */ -export class ErrexitError extends ControlFlowError { - readonly name = "ErrexitError"; - - constructor( - public readonly exitCode: number, - stdout: string = "", - stderr: string = "", - ) { - super(`errexit: command exited with status ${exitCode}`, stdout, stderr); - } -} - -/** - * Error thrown when set -u (nounset) is enabled and an unset variable is referenced. - */ -export class NounsetError extends ControlFlowError { - readonly name = "NounsetError"; - - constructor( - public varName: string, - stdout: string = "", - ) { - super( - `${varName}: unbound variable`, - stdout, - `bash: ${varName}: unbound variable\n`, - ); - } -} - -/** - * Error thrown when exit builtin is called to terminate the script. - */ -export class ExitError extends ControlFlowError { - readonly name = "ExitError"; - - constructor( - public readonly exitCode: number, - stdout: string = "", - stderr: string = "", - ) { - super(`exit`, stdout, stderr); - } -} - -/** - * Error thrown for arithmetic expression errors (e.g., floating point, invalid syntax). - * Returns exit code 1 instead of 2 (syntax error). - */ -export class ArithmeticError extends ControlFlowError { - readonly name = "ArithmeticError"; - - /** - * If true, this error should abort script execution (like missing operand after binary operator). - * If false, the error is recoverable and execution can continue. - */ - public fatal: boolean; - - constructor( - message: string, - stdout: string = "", - stderr: string = "", - fatal = false, - ) { - super(message, stdout, stderr); - this.stderr = stderr || `bash: ${message}\n`; - this.fatal = fatal; - } -} - -/** - * Error thrown for bad substitution errors (e.g., ${#var:1:3}). - * Returns exit code 1. - */ -export class BadSubstitutionError extends ControlFlowError { - readonly name = "BadSubstitutionError"; - - constructor(message: string, stdout: string = "", stderr: string = "") { - super(message, stdout, stderr); - this.stderr = stderr || `bash: ${message}: bad substitution\n`; - } -} - -/** - * Error thrown when failglob is enabled and a glob pattern has no matches. - * Returns exit code 1. - */ -export class GlobError extends ControlFlowError { - readonly name = "GlobError"; - - constructor(pattern: string, stdout: string = "", stderr: string = "") { - super(`no match: ${pattern}`, stdout, stderr); - this.stderr = stderr || `bash: no match: ${pattern}\n`; - } -} - -/** - * Error thrown for invalid brace expansions (e.g., mixed case character ranges like {z..A}). - * Returns exit code 1 (matching bash behavior). - */ -export class BraceExpansionError extends ControlFlowError { - readonly name = "BraceExpansionError"; - - constructor(message: string, stdout: string = "", stderr: string = "") { - super(message, stdout, stderr); - this.stderr = stderr || `bash: ${message}\n`; - } -} - -/** - * Error thrown when execution limits are exceeded (recursion depth, command count, loop iterations). - * This should ALWAYS be thrown before JavaScript's native RangeError kicks in. - * Exit code 126 indicates a limit was exceeded. - */ -export class ExecutionLimitError extends ControlFlowError { - readonly name = "ExecutionLimitError"; - static readonly EXIT_CODE = 126; - - constructor( - message: string, - public readonly limitType: - | "recursion" - | "commands" - | "iterations" - | "string_length" - | "glob_operations" - | "substitution_depth", - stdout: string = "", - stderr: string = "", - ) { - super(message, stdout, stderr); - this.stderr = stderr || `bash: ${message}\n`; - } -} - -/** - * Error thrown when break/continue is called in a subshell that was - * spawned from within a loop context. Causes the subshell to exit cleanly. - */ -export class SubshellExitError extends ControlFlowError { - readonly name = "SubshellExitError"; - - constructor(stdout: string = "", stderr: string = "") { - super("subshell exit", stdout, stderr); - } -} - -/** - * Type guard for errors that exit the current scope (return, break, continue). - * These need special handling vs errexit/nounset which terminate execution. - */ -export function isScopeExitError( - error: unknown, -): error is BreakError | ContinueError | ReturnError { - return ( - error instanceof BreakError || - error instanceof ContinueError || - error instanceof ReturnError - ); -} - -/** - * Error thrown when a POSIX special builtin fails in POSIX mode. - * In POSIX mode (set -o posix), errors in special builtins like - * shift, set, readonly, export, etc. cause the entire script to exit. - * - * Per POSIX 2.8.1 - Consequences of Shell Errors: - * "A special built-in utility causes an interactive or non-interactive shell - * to exit when an error occurs." - */ -export class PosixFatalError extends ControlFlowError { - readonly name = "PosixFatalError"; - - constructor( - public readonly exitCode: number, - stdout: string = "", - stderr: string = "", - ) { - super("posix fatal error", stdout, stderr); - } -} diff --git a/src/interpreter/expansion.ts b/src/interpreter/expansion.ts deleted file mode 100644 index 7371bc39..00000000 --- a/src/interpreter/expansion.ts +++ /dev/null @@ -1,1069 +0,0 @@ -/** - * Word Expansion - * - * Handles shell word expansion including: - * - Variable expansion ($VAR, ${VAR}) - * - Command substitution $(...) - * - Arithmetic expansion $((...)) - * - Tilde expansion (~) - * - Brace expansion {a,b,c} - * - Glob expansion (*, ?, [...]) - */ - -import type { - ParameterExpansionPart, - WordNode, - WordPart, -} from "../ast/types.js"; -import { parseArithmeticExpression } from "../parser/arithmetic-parser.js"; -import { Parser } from "../parser/parser.js"; -import { GlobExpander } from "../shell/glob.js"; -import { evaluateArithmetic } from "./arithmetic.js"; -import { - BadSubstitutionError, - ExecutionLimitError, - ExitError, -} from "./errors.js"; - -/** - * Check if a string exceeds the maximum allowed length. - * Throws ExecutionLimitError if the limit is exceeded. - */ -function checkStringLength( - str: string, - maxLength: number, - context: string, -): void { - if (str.length > maxLength) { - throw new ExecutionLimitError( - `${context}: string length limit exceeded (${maxLength} bytes)`, - "string_length", - ); - } -} - -import { analyzeWordParts } from "./expansion/analysis.js"; -import { - expandDollarVarsInArithText, - expandSubscriptForAssocArray, -} from "./expansion/arith-text-expansion.js"; -import { expandBraceRange } from "./expansion/brace-range.js"; -import { getFileReadShorthand } from "./expansion/command-substitution.js"; -// Import from extracted modules -import { - escapeGlobChars, - escapeRegexChars, - hasGlobPattern, -} from "./expansion/glob-escape.js"; -import { - computeIsEmpty, - handleArrayKeys, - handleAssignDefault, - handleCaseModification, - handleDefaultValue, - handleErrorIfUnset, - handleIndirection, - handleLength, - handlePatternRemoval, - handlePatternReplacement, - handleSubstring, - handleTransform, - handleUseAlternative, - handleVarNamePrefix, -} from "./expansion/parameter-ops.js"; -import { - expandVariablesInPattern, - expandVariablesInPatternAsync, - patternHasCommandSubstitution, -} from "./expansion/pattern-expansion.js"; -import { applyTildeExpansion } from "./expansion/tilde.js"; -import { getVariable, isVariableSet } from "./expansion/variable.js"; -import { - expandWordWithGlobImpl, - type WordGlobExpansionDeps, -} from "./expansion/word-glob-expansion.js"; -import { smartWordSplit } from "./expansion/word-split.js"; -import { - buildIfsCharClassPattern, - getIfs, - isIfsEmpty, - splitByIfsForExpansion, -} from "./helpers/ifs.js"; -import { isNameref, resolveNameref } from "./helpers/nameref.js"; -import { getLiteralValue, isQuotedPart } from "./helpers/word-parts.js"; -import type { InterpreterContext } from "./types.js"; - -// Re-export extracted functions for use elsewhere -export { escapeGlobChars, escapeRegexChars } from "./expansion/glob-escape.js"; -// Re-export for backward compatibility -export { - getArrayElements, - getVariable, - isArray, -} from "./expansion/variable.js"; - -// Helper to fully expand word parts (including variables, arithmetic, etc.) -async function expandWordPartsAsync( - ctx: InterpreterContext, - parts: WordPart[], - inDoubleQuotes = false, -): Promise { - const results: string[] = []; - for (const part of parts) { - results.push(await expandPart(ctx, part, inDoubleQuotes)); - } - return results.join(""); -} - -/** - * Check if a word is "fully quoted" - meaning glob characters should be treated literally. - * A word is fully quoted if all its parts are either: - * - SingleQuoted - * - DoubleQuoted (entirely quoted variable expansion like "$pat") - * - Escaped characters - */ -function isPartFullyQuoted(part: WordPart): boolean { - return isQuotedPart(part); -} - -/** - * Check if an entire word is fully quoted - */ -export function isWordFullyQuoted(word: WordNode): boolean { - // Empty word is considered quoted (matches empty pattern literally) - if (word.parts.length === 0) return true; - - // Check if we have any unquoted parts with actual content - for (const part of word.parts) { - if (!isPartFullyQuoted(part)) { - return false; - } - } - return true; -} - -/** - * Handle simple part types that don't require recursion or async. - * Returns the expanded string, or null if the part type needs special handling. - * inDoubleQuotes flag suppresses tilde expansion (tilde is literal inside "...") - */ -function expandSimplePart( - ctx: InterpreterContext, - part: WordPart, - inDoubleQuotes = false, -): string | null { - // Handle literal parts (Literal, SingleQuoted, Escaped) - const literal = getLiteralValue(part); - if (literal !== null) return literal; - - switch (part.type) { - case "TildeExpansion": - // Tilde expansion doesn't happen inside double quotes - if (inDoubleQuotes) { - return part.user === null ? "~" : `~${part.user}`; - } - ctx.coverage?.hit("bash:expansion:tilde"); - if (part.user === null) { - // Use HOME if set (even if empty), otherwise fall back to /home/user - return ctx.state.env.get("HOME") ?? "/home/user"; - } - // ~username only expands if user exists - // In sandboxed environment, we can only verify 'root' exists universally - // Other unknown users stay literal (matches bash behavior) - if (part.user === "root") { - return "/root"; - } - return `~${part.user}`; - case "Glob": - // Expand variables within extglob patterns (e.g., @($var|$other)) - return expandVariablesInPattern(ctx, part.pattern); - default: - return null; // Needs special handling (DoubleQuoted, BraceExpansion, ArithmeticExpansion, CommandSubstitution) - } -} - -export async function expandWord( - ctx: InterpreterContext, - word: WordNode, -): Promise { - return expandWordAsync(ctx, word); -} - -/** - * Expand a word for use as a regex pattern (in [[ =~ ]]). - * Preserves backslash escapes so they're passed to the regex engine. - * For example, \[\] becomes \[\] in the regex (matching literal [ and ]). - */ -export async function expandWordForRegex( - ctx: InterpreterContext, - word: WordNode, -): Promise { - const parts: string[] = []; - for (const part of word.parts) { - if (part.type === "Escaped") { - // For regex patterns, preserve ALL backslash escapes - // This allows \[ \] \. \* etc. to work as regex escapes - parts.push(`\\${part.value}`); - } else if (part.type === "SingleQuoted") { - // Single-quoted content is literal in regex - parts.push(part.value); - } else if (part.type === "DoubleQuoted") { - // Double-quoted: expand contents - const expanded = await expandWordPartsAsync(ctx, part.parts); - parts.push(expanded); - } else if (part.type === "TildeExpansion") { - // Tilde expansion on RHS of =~ is treated as literal (regex chars escaped) - // This matches bash 4.x+ behavior where ~ expands but the result is - // matched literally, not as a regex pattern. - // e.g., HOME='^a$'; [[ $HOME =~ ~ ]] matches because ~ expands to '^a$' - // and then '^a$' is escaped to '\^a\$' which matches the literal string - const expanded = await expandPart(ctx, part); - parts.push(escapeRegexChars(expanded)); - } else { - // Other parts: expand normally - parts.push(await expandPart(ctx, part)); - } - } - return parts.join(""); -} - -/** - * Expand a word for use as a pattern (e.g., in [[ == ]] or case). - * Preserves backslash escapes for pattern metacharacters so they're treated literally. - * This prevents `*\(\)` from being interpreted as an extglob pattern. - */ -export async function expandWordForPattern( - ctx: InterpreterContext, - word: WordNode, -): Promise { - const parts: string[] = []; - for (const part of word.parts) { - if (part.type === "Escaped") { - // For escaped characters that are pattern metacharacters, preserve the backslash - // This includes: ( ) | * ? [ ] for glob/extglob patterns - const ch = part.value; - if ("()|*?[]".includes(ch)) { - parts.push(`\\${ch}`); - } else { - parts.push(ch); - } - } else if (part.type === "SingleQuoted") { - // Single-quoted content should be escaped for literal matching - parts.push(escapeGlobChars(part.value)); - } else if (part.type === "DoubleQuoted") { - // Double-quoted: expand contents and escape for literal matching - const expanded = await expandWordPartsAsync(ctx, part.parts); - parts.push(escapeGlobChars(expanded)); - } else { - // Other parts: expand normally - parts.push(await expandPart(ctx, part)); - } - } - return parts.join(""); -} - -/** - * Expand a word for glob matching. - * Unlike regular expansion, this escapes glob metacharacters in quoted parts - * so they are treated as literals, while preserving glob patterns from Glob parts. - * This enables patterns like '_tmp/[bc]'*.mm where [bc] is literal and * is a glob. - */ -async function expandWordForGlobbing( - ctx: InterpreterContext, - word: WordNode, -): Promise { - const parts: string[] = []; - for (const part of word.parts) { - if (part.type === "SingleQuoted") { - // Single-quoted content: escape glob metacharacters for literal matching - parts.push(escapeGlobChars(part.value)); - } else if (part.type === "Escaped") { - // Escaped character: escape if it's a glob metacharacter - const ch = part.value; - if ("*?[]\\()|".includes(ch)) { - parts.push(`\\${ch}`); - } else { - parts.push(ch); - } - } else if (part.type === "DoubleQuoted") { - // Double-quoted: expand contents and escape glob metacharacters - const expanded = await expandWordPartsAsync(ctx, part.parts); - parts.push(escapeGlobChars(expanded)); - } else if (part.type === "Glob") { - // Glob pattern: expand variables and command substitutions within extglob patterns - // e.g., @($var|$(echo foo)) needs both variable and command substitution expansion - if (patternHasCommandSubstitution(part.pattern)) { - // Use async version for command substitutions - parts.push(await expandVariablesInPatternAsync(ctx, part.pattern)); - } else { - // Use sync version for simple variable expansion - parts.push(expandVariablesInPattern(ctx, part.pattern)); - } - } else if (part.type === "Literal") { - // Literal: keep as-is (may contain glob characters that should glob) - parts.push(part.value); - } else { - // Other parts (ParameterExpansion, etc.): expand normally - parts.push(await expandPart(ctx, part)); - } - } - return parts.join(""); -} - -/** - * Check if word parts contain brace expansion - */ -function hasBraceExpansion(parts: WordPart[]): boolean { - for (const part of parts) { - if (part.type === "BraceExpansion") return true; - if (part.type === "DoubleQuoted" && hasBraceExpansion(part.parts)) - return true; - } - return false; -} - -// Maximum number of brace expansion results to prevent memory explosion -const MAX_BRACE_EXPANSION_RESULTS = 10000; -// Maximum total operations across all recursive calls -const MAX_BRACE_OPERATIONS = 100000; - -type BraceExpandedPart = string | WordPart; - -/** - * Expand brace expansion in word parts, producing multiple arrays of parts. - * Each result array represents the parts that will be joined to form one word. - * For example, "pre{a,b}post" produces [["pre", "a", "post"], ["pre", "b", "post"]] - * - * Non-brace parts are kept as WordPart objects to allow deferred expansion. - * This is necessary for bash-like behavior where side effects in expansions - * (like $((i++))) are evaluated separately for each brace alternative. - */ -async function expandBracesInPartsAsync( - ctx: InterpreterContext, - parts: WordPart[], - operationCounter: { count: number } = { count: 0 }, -): Promise { - if (operationCounter.count > MAX_BRACE_OPERATIONS) { - return [[]]; - } - - let results: BraceExpandedPart[][] = [[]]; - - for (const part of parts) { - if (part.type === "BraceExpansion") { - const braceValues: string[] = []; - let hasInvalidRange = false; - let invalidRangeLiteral = ""; - for (const item of part.items) { - if (item.type === "Range") { - const range = expandBraceRange( - item.start, - item.end, - item.step, - item.startStr, - item.endStr, - ); - if (range.expanded) { - for (const val of range.expanded) { - operationCounter.count++; - braceValues.push(val); - } - } else { - hasInvalidRange = true; - invalidRangeLiteral = range.literal; - break; - } - } else { - // Word item - expand it (recursively handle nested braces) - const expanded = await expandBracesInPartsAsync( - ctx, - item.word.parts, - operationCounter, - ); - for (const exp of expanded) { - operationCounter.count++; - // Join all parts, expanding any deferred WordParts - const joinedParts: string[] = []; - for (const p of exp) { - if (typeof p === "string") { - joinedParts.push(p); - } else { - joinedParts.push(await expandPart(ctx, p)); - } - } - braceValues.push(joinedParts.join("")); - } - } - } - - if (hasInvalidRange) { - for (const result of results) { - operationCounter.count++; - result.push(invalidRangeLiteral); - } - continue; - } - - const newSize = results.length * braceValues.length; - if ( - newSize > MAX_BRACE_EXPANSION_RESULTS || - operationCounter.count > MAX_BRACE_OPERATIONS - ) { - return results; - } - - const newResults: BraceExpandedPart[][] = []; - for (const result of results) { - for (const val of braceValues) { - operationCounter.count++; - if (operationCounter.count > MAX_BRACE_OPERATIONS) { - return newResults.length > 0 ? newResults : results; - } - newResults.push([...result, val]); - } - } - results = newResults; - } else { - // Non-brace part: keep as WordPart for deferred expansion - for (const result of results) { - operationCounter.count++; - result.push(part); - } - } - } - - return results; -} - -/** - * Async version of expandWordWithBraces - */ -async function expandWordWithBracesAsync( - ctx: InterpreterContext, - word: WordNode, -): Promise { - const parts = word.parts; - - if (!hasBraceExpansion(parts)) { - return [await expandWord(ctx, word)]; - } - - const expanded = await expandBracesInPartsAsync(ctx, parts); - - // Now expand each result, evaluating deferred parts separately for each - // This ensures side effects like $((i++)) are evaluated fresh for each brace alternative - const results: string[] = []; - for (const resultParts of expanded) { - const joinedParts: string[] = []; - for (const p of resultParts) { - if (typeof p === "string") { - joinedParts.push(p); - } else { - // Expand the deferred WordPart now (async) - joinedParts.push(await expandPart(ctx, p)); - } - } - // Apply tilde expansion to each result - this handles cases like ~{/src,root} - // where brace expansion produces ~/src and ~root, which then need tilde expansion - results.push(applyTildeExpansion(ctx, joinedParts.join(""))); - } - return results; -} - -// Create dependencies object for word-glob-expansion module -function createWordGlobDeps(): WordGlobExpansionDeps { - return { - expandWordAsync, - expandWordForGlobbing, - expandWordWithBracesAsync, - expandWordPartsAsync, - expandPart, - expandParameterAsync, - hasBraceExpansion, - evaluateArithmetic, - buildIfsCharClassPattern, - smartWordSplit, - }; -} - -export async function expandWordWithGlob( - ctx: InterpreterContext, - word: WordNode, -): Promise<{ values: string[]; quoted: boolean }> { - return expandWordWithGlobImpl(ctx, word, createWordGlobDeps()); -} - -/** - * Get textual representation of a word for error messages - */ -function getWordText(parts: WordPart[]): string { - for (const p of parts) { - if (p.type === "ParameterExpansion") { - return p.parameter; - } - if (p.type === "Literal") { - return p.value; - } - } - return ""; -} - -export function hasQuotedMultiValueAt( - ctx: InterpreterContext, - word: WordNode, -): boolean { - const numParams = Number.parseInt(ctx.state.env.get("#") || "0", 10); - // Only a problem if there are 2+ positional parameters - if (numParams < 2) return false; - - // Check for "$@" inside DoubleQuoted parts - function checkParts(parts: WordPart[]): boolean { - for (const part of parts) { - if (part.type === "DoubleQuoted") { - // Check inside the double-quoted part - for (const innerPart of part.parts) { - if ( - innerPart.type === "ParameterExpansion" && - innerPart.parameter === "@" && - !innerPart.operation // plain $@ without operations - ) { - return true; - } - } - } - } - return false; - } - - return checkParts(word.parts); -} - -/** - * Expand a redirect target with glob handling. - * - * For redirects: - * - If glob matches 0 files with failglob → error (returns { error: ... }) - * - If glob matches 0 files without failglob → use literal pattern - * - If glob matches 1 file → use that file - * - If glob matches 2+ files → "ambiguous redirect" error - * - * Returns { target: string } on success or { error: string } on failure. - */ -export async function expandRedirectTarget( - ctx: InterpreterContext, - word: WordNode, -): Promise<{ target: string } | { error: string }> { - // Check for "$@" with multiple positional params - this is an ambiguous redirect - if (hasQuotedMultiValueAt(ctx, word)) { - return { error: "bash: $@: ambiguous redirect\n" }; - } - - const wordParts = word.parts; - const { hasQuoted } = analyzeWordParts(wordParts); - - // Check for brace expansion - if it produces multiple values, it's an ambiguous redirect - // For example: echo hi > a-{one,two} should error - if (hasBraceExpansion(wordParts)) { - const braceExpanded = await expandWordWithBracesAsync(ctx, word); - if (braceExpanded.length > 1) { - // Get the original word text for the error message - const originalText = wordParts - .map((p) => { - if (p.type === "Literal") return p.value; - if (p.type === "BraceExpansion") { - // Reconstruct brace expression - const items = p.items - .map((item) => { - if (item.type === "Range") { - const step = item.step ? `..${item.step}` : ""; - return `${item.startStr ?? item.start}..${item.endStr ?? item.end}${step}`; - } - return item.word.parts - .map((wp) => (wp.type === "Literal" ? wp.value : "")) - .join(""); - }) - .join(","); - return `{${items}}`; - } - return ""; - }) - .join(""); - return { error: `bash: ${originalText}: ambiguous redirect\n` }; - } - // Single value from brace expansion - continue with normal processing - // (value will be re-expanded below, but since there's only one value it's the same) - } - - const value = await expandWordAsync(ctx, word); - - // Check for word splitting producing multiple words - this is an ambiguous redirect - // This only applies when the word has unquoted expansions (not all quoted) - const { hasParamExpansion, hasCommandSub } = analyzeWordParts(wordParts); - const hasUnquotedExpansion = - (hasParamExpansion || hasCommandSub) && !hasQuoted; - - if (hasUnquotedExpansion && !isIfsEmpty(ctx.state.env)) { - const ifsChars = getIfs(ctx.state.env); - const splitWords = splitByIfsForExpansion(value, ifsChars); - if (splitWords.length > 1) { - // Word splitting produces multiple words - ambiguous redirect - return { - error: `bash: $${getWordText(wordParts)}: ambiguous redirect\n`, - }; - } - } - - // Skip glob expansion if noglob is set (set -f) or if the word was quoted - // Check these BEFORE building glob pattern to avoid double-expanding side-effectful expressions - if (hasQuoted || ctx.state.options.noglob) { - return { target: value }; - } - - // Build glob pattern using expandWordForGlobbing which preserves escaped glob chars - // For example: two-\* becomes two-\\* (escaped * is literal, not a glob) - // But: two-$star where star='*' becomes two-* (variable expansion is subject to glob) - const globPattern = await expandWordForGlobbing(ctx, word); - - // Skip if there are no glob patterns in the pattern - if (!hasGlobPattern(globPattern, ctx.state.shoptOptions.extglob)) { - return { target: value }; - } - - // Perform glob expansion for redirect targets - const globExpander = new GlobExpander(ctx.fs, ctx.state.cwd, ctx.state.env, { - globstar: ctx.state.shoptOptions.globstar, - nullglob: ctx.state.shoptOptions.nullglob, - failglob: ctx.state.shoptOptions.failglob, - dotglob: ctx.state.shoptOptions.dotglob, - extglob: ctx.state.shoptOptions.extglob, - globskipdots: ctx.state.shoptOptions.globskipdots, - maxGlobOperations: ctx.limits.maxGlobOperations, - }); - - const matches = await globExpander.expand(globPattern); - - if (matches.length === 0) { - // No matches - if (globExpander.hasFailglob()) { - // failglob: error on no match - return { error: `bash: no match: ${value}\n` }; - } - // Without failglob, use the literal pattern (unescaped) - return { target: value }; - } - - if (matches.length === 1) { - // Exactly one match - use it - return { target: matches[0] }; - } - - // Multiple matches - ambiguous redirect error - return { error: `bash: ${value}: ambiguous redirect\n` }; -} - -// Async version of expandWord (internal) -async function expandWordAsync( - ctx: InterpreterContext, - word: WordNode, -): Promise { - const wordParts = word.parts; - const len = wordParts.length; - - if (len === 1) { - const result = await expandPart(ctx, wordParts[0]); - checkStringLength(result, ctx.limits.maxStringLength, "word expansion"); - return result; - } - - const parts: string[] = []; - for (let i = 0; i < len; i++) { - parts.push(await expandPart(ctx, wordParts[i])); - } - const result = parts.join(""); - checkStringLength(result, ctx.limits.maxStringLength, "word expansion"); - return result; -} - -async function expandPart( - ctx: InterpreterContext, - part: WordPart, - inDoubleQuotes = false, -): Promise { - // Always use async expansion for ParameterExpansion - if (part.type === "ParameterExpansion") { - return expandParameterAsync(ctx, part, inDoubleQuotes); - } - - // Try simple cases first (Literal, SingleQuoted, Escaped, TildeExpansion, Glob) - const simple = expandSimplePart(ctx, part, inDoubleQuotes); - if (simple !== null) return simple; - - // Handle cases that need recursion or async - switch (part.type) { - case "DoubleQuoted": { - const parts: string[] = []; - for (const p of part.parts) { - // Inside double quotes, suppress tilde expansion - parts.push(await expandPart(ctx, p, true)); - } - return parts.join(""); - } - - case "CommandSubstitution": { - // Check for the special $(= maxDepth) { - throw new ExecutionLimitError( - `Command substitution nesting limit exceeded (${maxDepth})`, - "substitution_depth", - ); - } - // Increment depth for nested substitutions - const savedDepth = ctx.substitutionDepth; - ctx.substitutionDepth = currentDepth + 1; - - // Command substitutions get a new BASHPID (unlike $$ which stays the same) - const savedBashPid = ctx.state.bashPid; - ctx.state.bashPid = ctx.state.nextVirtualPid++; - // Save environment - command substitutions run in a subshell and should not - // modify parent environment (e.g., aliases defined inside $() should not leak) - const savedEnv = new Map(ctx.state.env); - const savedCwd = ctx.state.cwd; - // Suppress verbose mode (set -v) inside command substitutions - // bash only prints verbose output for the main script - const savedSuppressVerbose = ctx.state.suppressVerbose; - ctx.state.suppressVerbose = true; - try { - const result = await ctx.executeScript(part.body); - // Restore environment but preserve exit code - const exitCode = result.exitCode; - ctx.state.env = savedEnv; - ctx.state.cwd = savedCwd; - ctx.state.suppressVerbose = savedSuppressVerbose; - // Store the exit code for $? - ctx.state.lastExitCode = exitCode; - ctx.state.env.set("?", String(exitCode)); - // Command substitution stderr should go to the shell's stderr at expansion time, - // NOT be affected by later redirections on the outer command - if (result.stderr) { - ctx.state.expansionStderr = - (ctx.state.expansionStderr || "") + result.stderr; - } - ctx.state.bashPid = savedBashPid; - ctx.substitutionDepth = savedDepth; - const output = result.stdout.replace(/\n+$/, ""); - // Check string length limit for command substitution output - checkStringLength( - output, - ctx.limits.maxStringLength, - "command substitution", - ); - return output; - } catch (error) { - // Restore environment on error as well - ctx.state.env = savedEnv; - ctx.state.cwd = savedCwd; - ctx.state.bashPid = savedBashPid; - ctx.substitutionDepth = savedDepth; - ctx.state.suppressVerbose = savedSuppressVerbose; - // ExecutionLimitError must always propagate - these are safety limits - if (error instanceof ExecutionLimitError) { - throw error; - } - if (error instanceof ExitError) { - // Catch exit in command substitution - return output so far - ctx.state.lastExitCode = error.exitCode; - ctx.state.env.set("?", String(error.exitCode)); - // Also forward stderr from the exit - if (error.stderr) { - ctx.state.expansionStderr = - (ctx.state.expansionStderr || "") + error.stderr; - } - const exitOutput = error.stdout.replace(/\n+$/, ""); - // Check string length limit for command substitution output - checkStringLength( - exitOutput, - ctx.limits.maxStringLength, - "command substitution", - ); - return exitOutput; - } - throw error; - } - } - - case "ArithmeticExpansion": { - // If original text is available and contains $var patterns (not ${...}), - // we need to do text substitution before parsing to maintain operator precedence. - // E.g., $(( $x * 3 )) where x='1 + 2' should expand to $(( 1 + 2 * 3 )) = 7 - // not $(( (1+2) * 3 )) = 9 - const originalText = part.expression.originalText; - const hasDollarVars = - originalText && /\$[a-zA-Z_][a-zA-Z0-9_]*(?![{[(])/.test(originalText); - if (hasDollarVars) { - // Expand $var patterns in the text - const expandedText = await expandDollarVarsInArithText( - ctx, - originalText, - ); - // Re-parse the expanded expression - const parser = new Parser(); - const newExpr = parseArithmeticExpression(parser, expandedText); - // true = expansion context, single quotes cause error - return String(await evaluateArithmetic(ctx, newExpr.expression, true)); - } - // true = expansion context, single quotes cause error - return String( - await evaluateArithmetic(ctx, part.expression.expression, true), - ); - } - - case "BraceExpansion": { - const results: string[] = []; - for (const item of part.items) { - if (item.type === "Range") { - const range = expandBraceRange( - item.start, - item.end, - item.step, - item.startStr, - item.endStr, - ); - if (range.expanded) { - results.push(...range.expanded); - } else { - return range.literal; - } - } else { - results.push(await expandWord(ctx, item.word)); - } - } - return results.join(" "); - } - - default: - return ""; - } -} - -// Async version of expandParameter for parameter expansions that contain command substitution -async function expandParameterAsync( - ctx: InterpreterContext, - part: ParameterExpansionPart, - inDoubleQuotes = false, -): Promise { - let { parameter } = part; - const { operation } = part; - - // Handle subscript expansion for array access: ${a[...]} - // We need to expand the subscript before calling getVariable - const bracketMatch = parameter.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$/); - if (bracketMatch) { - const [, arrayName, subscript] = bracketMatch; - const isAssoc = ctx.state.associativeArrays?.has(arrayName); - - // For associative arrays, expand the subscript to handle ${array[@]} and other expansions - // Also expand if subscript contains command substitution or variables - if ( - isAssoc || - subscript.includes("$(") || - subscript.includes("`") || - subscript.includes("${") - ) { - const expandedSubscript = await expandSubscriptForAssocArray( - ctx, - subscript, - ); - parameter = `${arrayName}[${expandedSubscript}]`; - } - } else if ( - // Handle nameref pointing to array subscript with command substitution: - // typeset -n ref='a[$(echo 2) + 1]'; echo $ref - /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(parameter) && - isNameref(ctx, parameter) - ) { - const target = resolveNameref(ctx, parameter); - if (target && target !== parameter) { - // Check if the resolved target is an array subscript with command substitution - const targetBracketMatch = target.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$/, - ); - if (targetBracketMatch) { - const [, targetArrayName, targetSubscript] = targetBracketMatch; - const isAssoc = ctx.state.associativeArrays?.has(targetArrayName); - if ( - isAssoc || - targetSubscript.includes("$(") || - targetSubscript.includes("`") || - targetSubscript.includes("${") - ) { - const expandedSubscript = await expandSubscriptForAssocArray( - ctx, - targetSubscript, - ); - // Replace the nameref's stored target with the expanded one for this expansion - // We need to call getVariable with the expanded target directly - parameter = `${targetArrayName}[${expandedSubscript}]`; - } - } - } - } - - // Operations that handle unset variables should not trigger nounset - const skipNounset = - operation && - (operation.type === "DefaultValue" || - operation.type === "AssignDefault" || - operation.type === "UseAlternative" || - operation.type === "ErrorIfUnset"); - - const value = await getVariable(ctx, parameter, !skipNounset); - - if (!operation) { - return value; - } - - const isUnset = !(await isVariableSet(ctx, parameter)); - // Compute isEmpty and effectiveValue using extracted helper - const { isEmpty, effectiveValue } = computeIsEmpty( - ctx, - parameter, - value, - inDoubleQuotes, - ); - const opCtx = { - value, - isUnset, - isEmpty, - effectiveValue, - inDoubleQuotes, - }; - - switch (operation.type) { - case "DefaultValue": - return handleDefaultValue(ctx, operation, opCtx, expandWordPartsAsync); - - case "AssignDefault": - return handleAssignDefault( - ctx, - parameter, - operation, - opCtx, - expandWordPartsAsync, - ); - - case "ErrorIfUnset": - return handleErrorIfUnset( - ctx, - parameter, - operation, - opCtx, - expandWordPartsAsync, - ); - - case "UseAlternative": - return handleUseAlternative(ctx, operation, opCtx, expandWordPartsAsync); - - case "PatternRemoval": - return handlePatternRemoval( - ctx, - value, - operation, - expandWordPartsAsync, - expandPart, - ); - - case "PatternReplacement": - return handlePatternReplacement( - ctx, - value, - operation, - expandWordPartsAsync, - expandPart, - ); - - case "Length": - return handleLength(ctx, parameter, value); - - case "LengthSliceError": - throw new BadSubstitutionError(parameter); - - case "BadSubstitution": - throw new BadSubstitutionError(operation.text); - - case "Substring": - return handleSubstring(ctx, parameter, value, operation); - - case "CaseModification": - return handleCaseModification( - ctx, - value, - operation, - expandWordPartsAsync, - expandParameterAsync, - ); - - case "Transform": - return handleTransform(ctx, parameter, value, isUnset, operation); - - case "Indirection": - return handleIndirection( - ctx, - parameter, - value, - isUnset, - operation, - expandParameterAsync, - inDoubleQuotes, - ); - - case "ArrayKeys": - return handleArrayKeys(ctx, operation); - - case "VarNamePrefix": - return handleVarNamePrefix(ctx, operation); - - default: - return value; - } -} diff --git a/src/interpreter/expansion/analysis.ts b/src/interpreter/expansion/analysis.ts deleted file mode 100644 index e98fb98d..00000000 --- a/src/interpreter/expansion/analysis.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Word Analysis - * - * Functions for analyzing word parts to determine what types of expansions are present. - */ - -import type { ParameterExpansionPart, WordPart } from "../../ast/types.js"; - -/** - * Check if a glob pattern string contains variable references ($var or ${var}) - * This is used to detect when IFS splitting should apply to expanded glob patterns. - */ -export function globPatternHasVarRef(pattern: string): boolean { - // Look for $varname or ${...} patterns - // Skip escaped $ (e.g., \$) - for (let i = 0; i < pattern.length; i++) { - if (pattern[i] === "\\") { - i++; // Skip next character - continue; - } - if (pattern[i] === "$") { - const next = pattern[i + 1]; - // Check for ${...} or $varname - if (next === "{" || (next && /[a-zA-Z_]/.test(next))) { - return true; - } - } - } - return false; -} - -/** - * Check if a parameter expansion has quoted parts in its operation word - * e.g., ${v:-"AxBxC"} has a quoted default value - */ -function hasQuotedOperationWord(part: ParameterExpansionPart): boolean { - if (!part.operation) return false; - - const op = part.operation; - let wordParts: WordPart[] | undefined; - - // These operation types have a 'word' property that can contain quoted parts - if ( - op.type === "DefaultValue" || - op.type === "AssignDefault" || - op.type === "UseAlternative" || - op.type === "ErrorIfUnset" - ) { - wordParts = op.word?.parts; - } - - if (!wordParts) return false; - - for (const p of wordParts) { - if (p.type === "DoubleQuoted" || p.type === "SingleQuoted") { - return true; - } - } - return false; -} - -/** - * Check if a parameter expansion's operation word is entirely quoted (all parts are quoted). - * This is different from hasQuotedOperationWord which returns true if ANY part is quoted. - * - * For word splitting purposes: - * - ${v:-"AxBxC"} - entirely quoted, should NOT be split - * - ${v:-x"AxBxC"x} - mixed quoted/unquoted, SHOULD be split (on unquoted parts) - * - ${v:-AxBxC} - entirely unquoted, SHOULD be split - */ -export function isOperationWordEntirelyQuoted( - part: ParameterExpansionPart, -): boolean { - if (!part.operation) return false; - - const op = part.operation; - let wordParts: WordPart[] | undefined; - - // These operation types have a 'word' property that can contain quoted parts - if ( - op.type === "DefaultValue" || - op.type === "AssignDefault" || - op.type === "UseAlternative" || - op.type === "ErrorIfUnset" - ) { - wordParts = op.word?.parts; - } - - if (!wordParts || wordParts.length === 0) return false; - - // Check if ALL parts are quoted (DoubleQuoted or SingleQuoted) - for (const p of wordParts) { - if (p.type !== "DoubleQuoted" && p.type !== "SingleQuoted") { - return false; // Found an unquoted part - } - } - return true; // All parts are quoted -} - -/** - * Result of analyzing word parts - */ -export interface WordPartsAnalysis { - hasQuoted: boolean; - hasCommandSub: boolean; - hasArrayVar: boolean; - hasArrayAtExpansion: boolean; - hasParamExpansion: boolean; - hasVarNamePrefixExpansion: boolean; - hasIndirection: boolean; -} - -/** - * Analyze word parts for expansion behavior - */ -export function analyzeWordParts(parts: WordPart[]): WordPartsAnalysis { - let hasQuoted = false; - let hasCommandSub = false; - let hasArrayVar = false; - let hasArrayAtExpansion = false; - let hasParamExpansion = false; - let hasVarNamePrefixExpansion = false; - let hasIndirection = false; - - for (const part of parts) { - if (part.type === "SingleQuoted" || part.type === "DoubleQuoted") { - hasQuoted = true; - // Check for "${a[@]}" inside double quotes - // BUT NOT if there's an operation like ${#a[@]} (Length) or other operations - if (part.type === "DoubleQuoted") { - for (const inner of part.parts) { - if (inner.type === "ParameterExpansion") { - // Check if it's array[@] or array[*] - const match = inner.parameter.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[[@*]\]$/, - ); - // Set hasArrayAtExpansion for: - // - No operation: ${arr[@]} - // - PatternRemoval: ${arr[@]#pattern}, ${arr[@]%pattern} - // - PatternReplacement: ${arr[@]/pattern/replacement} - if ( - match && - (!inner.operation || - inner.operation.type === "PatternRemoval" || - inner.operation.type === "PatternReplacement") - ) { - hasArrayAtExpansion = true; - } - // Check for ${!prefix@} or ${!prefix*} inside double quotes - if ( - inner.operation?.type === "VarNamePrefix" || - inner.operation?.type === "ArrayKeys" - ) { - hasVarNamePrefixExpansion = true; - } - // Check for ${!var} indirect expansion inside double quotes - if (inner.operation?.type === "Indirection") { - hasIndirection = true; - } - } - } - } - } - if (part.type === "CommandSubstitution") { - hasCommandSub = true; - } - if (part.type === "ParameterExpansion") { - hasParamExpansion = true; - if (part.parameter === "@" || part.parameter === "*") { - hasArrayVar = true; - } - // Check if the parameter expansion has quoted parts in its operation - // e.g., ${v:-"AxBxC"} - the quoted default value should prevent word splitting - if (hasQuotedOperationWord(part)) { - hasQuoted = true; - } - // Check for unquoted ${!prefix@} or ${!prefix*} - if ( - part.operation?.type === "VarNamePrefix" || - part.operation?.type === "ArrayKeys" - ) { - hasVarNamePrefixExpansion = true; - } - // Check for ${!var} indirect expansion - if (part.operation?.type === "Indirection") { - hasIndirection = true; - } - } - // Check Glob parts for variable references - patterns like +($ABC) contain - // parameter expansions that should be subject to IFS splitting - if (part.type === "Glob" && globPatternHasVarRef(part.pattern)) { - hasParamExpansion = true; - } - } - - return { - hasQuoted, - hasCommandSub, - hasArrayVar, - hasArrayAtExpansion, - hasParamExpansion, - hasVarNamePrefixExpansion, - hasIndirection, - }; -} diff --git a/src/interpreter/expansion/arith-text-expansion.ts b/src/interpreter/expansion/arith-text-expansion.ts deleted file mode 100644 index 79e26af7..00000000 --- a/src/interpreter/expansion/arith-text-expansion.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Arithmetic Text Expansion - * - * Functions for expanding variables within arithmetic expression text. - * This handles the bash behavior where $(( $x * 3 )) with x='1 + 2' should - * expand to $(( 1 + 2 * 3 )) = 7, not $(( (1+2) * 3 )) = 9. - */ - -import type { InterpreterContext } from "../types.js"; -import { getVariable } from "./variable.js"; - -/** - * Expand $var patterns in arithmetic expression text for text substitution. - * Only expands simple $var patterns, not ${...}, $(()), $(), etc. - */ -export async function expandDollarVarsInArithText( - ctx: InterpreterContext, - text: string, -): Promise { - let result = ""; - let i = 0; - while (i < text.length) { - if (text[i] === "$") { - // Check for ${...} - don't expand, keep as-is for arithmetic parser - if (text[i + 1] === "{") { - // Find matching } - let depth = 1; - let j = i + 2; - while (j < text.length && depth > 0) { - if (text[j] === "{") depth++; - else if (text[j] === "}") depth--; - j++; - } - result += text.slice(i, j); - i = j; - continue; - } - // Check for $((, $( - don't expand - if (text[i + 1] === "(") { - // Find matching ) or )) - let depth = 1; - let j = i + 2; - while (j < text.length && depth > 0) { - if (text[j] === "(") depth++; - else if (text[j] === ")") depth--; - j++; - } - result += text.slice(i, j); - i = j; - continue; - } - // Check for $var pattern - if (/[a-zA-Z_]/.test(text[i + 1] || "")) { - let j = i + 1; - while (j < text.length && /[a-zA-Z0-9_]/.test(text[j])) { - j++; - } - const varName = text.slice(i + 1, j); - const value = await getVariable(ctx, varName); - result += value; - i = j; - continue; - } - // Check for $1, $2, etc. (positional parameters) - if (/[0-9]/.test(text[i + 1] || "")) { - let j = i + 1; - while (j < text.length && /[0-9]/.test(text[j])) { - j++; - } - const varName = text.slice(i + 1, j); - const value = await getVariable(ctx, varName); - result += value; - i = j; - continue; - } - // Check for special vars: $*, $@, $#, $?, etc. - if (/[*@#?\-!$]/.test(text[i + 1] || "")) { - const varName = text[i + 1]; - const value = await getVariable(ctx, varName); - result += value; - i += 2; - continue; - } - } - // Check for double quotes - expand variables inside but keep the quotes - // (arithmetic preprocessor will strip them) - if (text[i] === '"') { - result += '"'; - i++; - while (i < text.length && text[i] !== '"') { - if (text[i] === "$" && /[a-zA-Z_]/.test(text[i + 1] || "")) { - // Expand $var inside quotes - let j = i + 1; - while (j < text.length && /[a-zA-Z0-9_]/.test(text[j])) { - j++; - } - const varName = text.slice(i + 1, j); - const value = await getVariable(ctx, varName); - result += value; - i = j; - } else if (text[i] === "\\") { - // Keep escape sequences - result += text[i]; - i++; - if (i < text.length) { - result += text[i]; - i++; - } - } else { - result += text[i]; - i++; - } - } - if (i < text.length) { - result += '"'; - i++; - } - continue; - } - result += text[i]; - i++; - } - return result; -} - -/** - * Expand variable references and command substitutions in an array subscript. - * e.g., "${array[@]}" -> "1 2 3", "$(echo 1)" -> "1" - * This is needed for associative array subscripts like assoc["${array[@]}"] - * where the subscript may contain variable or array expansions. - */ -export async function expandSubscriptForAssocArray( - ctx: InterpreterContext, - subscript: string, -): Promise { - // Remove surrounding quotes if present - let inner = subscript; - const hasDoubleQuotes = subscript.startsWith('"') && subscript.endsWith('"'); - const hasSingleQuotes = subscript.startsWith("'") && subscript.endsWith("'"); - - if (hasDoubleQuotes || hasSingleQuotes) { - inner = subscript.slice(1, -1); - } - - // For single-quoted strings, no expansion - if (hasSingleQuotes) { - return inner; - } - - // Expand $(...), ${...}, and $var references in the string - let result = ""; - let i = 0; - while (i < inner.length) { - if (inner[i] === "$") { - // Check for $(...) command substitution - if (inner[i + 1] === "(") { - // Find matching closing paren - let depth = 1; - let j = i + 2; - while (j < inner.length && depth > 0) { - if (inner[j] === "(" && inner[j - 1] === "$") { - depth++; - } else if (inner[j] === "(") { - depth++; - } else if (inner[j] === ")") { - depth--; - } - j++; - } - // Extract and execute the command - const cmdStr = inner.slice(i + 2, j - 1); - if (ctx.execFn) { - const cmdResult = await ctx.execFn(cmdStr); - // Strip trailing newlines like command substitution does - result += cmdResult.stdout.replace(/\n+$/, ""); - // Forward stderr to expansion stderr - if (cmdResult.stderr) { - ctx.state.expansionStderr = - (ctx.state.expansionStderr || "") + cmdResult.stderr; - } - } - i = j; - } else if (inner[i + 1] === "{") { - // Check for ${...} - find matching } - let depth = 1; - let j = i + 2; - while (j < inner.length && depth > 0) { - if (inner[j] === "{") depth++; - else if (inner[j] === "}") depth--; - j++; - } - const varExpr = inner.slice(i + 2, j - 1); - // Use getVariable to properly handle array expansions like array[@] and array[*] - const value = await getVariable(ctx, varExpr); - result += value; - i = j; - } else if (/[a-zA-Z_]/.test(inner[i + 1] || "")) { - // $name - find end of name - let j = i + 1; - while (j < inner.length && /[a-zA-Z0-9_]/.test(inner[j])) { - j++; - } - const varName = inner.slice(i + 1, j); - // Use getVariable for consistency - const value = await getVariable(ctx, varName); - result += value; - i = j; - } else { - result += inner[i]; - i++; - } - } else if (inner[i] === "`") { - // Legacy backtick command substitution - let j = i + 1; - while (j < inner.length && inner[j] !== "`") { - j++; - } - const cmdStr = inner.slice(i + 1, j); - if (ctx.execFn) { - const cmdResult = await ctx.execFn(cmdStr); - result += cmdResult.stdout.replace(/\n+$/, ""); - if (cmdResult.stderr) { - ctx.state.expansionStderr = - (ctx.state.expansionStderr || "") + cmdResult.stderr; - } - } - i = j + 1; - } else { - result += inner[i]; - i++; - } - } - return result; -} diff --git a/src/interpreter/expansion/array-pattern-ops.ts b/src/interpreter/expansion/array-pattern-ops.ts deleted file mode 100644 index 0ef902c9..00000000 --- a/src/interpreter/expansion/array-pattern-ops.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * Array Pattern Operations - * - * Handles pattern replacement and pattern removal for array expansions: - * - "${arr[@]/pattern/replacement}" - pattern replacement - * - "${arr[@]#pattern}" - prefix removal - * - "${arr[@]%pattern}" - suffix removal - */ - -import type { WordNode, WordPart } from "../../ast/types.js"; -import { createUserRegex } from "../../regex/index.js"; -import { getIfsSeparator } from "../helpers/ifs.js"; -import { escapeRegex } from "../helpers/regex.js"; -import type { InterpreterContext } from "../types.js"; -import type { - ArrayExpansionResult, - ExpandPartFn, - ExpandWordPartsAsyncFn, -} from "./array-word-expansion.js"; -import { patternToRegex } from "./pattern.js"; -import { applyPatternRemoval } from "./pattern-removal.js"; -import { getArrayElements } from "./variable.js"; - -/** - * Build a regex pattern from a WordNode pattern - */ -async function buildPatternRegex( - ctx: InterpreterContext, - pattern: WordNode, - expandWordPartsAsync: ExpandWordPartsAsyncFn, - expandPart: ExpandPartFn, -): Promise { - let regex = ""; - for (const part of pattern.parts) { - if (part.type === "Glob") { - regex += patternToRegex( - part.pattern, - true, - ctx.state.shoptOptions.extglob, - ); - } else if (part.type === "Literal") { - regex += patternToRegex(part.value, true, ctx.state.shoptOptions.extglob); - } else if (part.type === "SingleQuoted" || part.type === "Escaped") { - regex += escapeRegex(part.value); - } else if (part.type === "DoubleQuoted") { - const expanded = await expandWordPartsAsync(ctx, part.parts); - regex += escapeRegex(expanded); - } else if (part.type === "ParameterExpansion") { - const expanded = await expandPart(ctx, part); - regex += patternToRegex(expanded, true, ctx.state.shoptOptions.extglob); - } else { - const expanded = await expandPart(ctx, part); - regex += escapeRegex(expanded); - } - } - return regex; -} - -/** - * Handle "${arr[@]/pattern/replacement}" and "${arr[*]/pattern/replacement}" - * Returns null if this handler doesn't apply. - */ -export async function handleArrayPatternReplacement( - ctx: InterpreterContext, - wordParts: WordPart[], - expandWordPartsAsync: ExpandWordPartsAsyncFn, - expandPart: ExpandPartFn, -): Promise { - if (wordParts.length !== 1 || wordParts[0].type !== "DoubleQuoted") { - return null; - } - - const dqPart = wordParts[0]; - if ( - dqPart.parts.length !== 1 || - dqPart.parts[0].type !== "ParameterExpansion" || - dqPart.parts[0].operation?.type !== "PatternReplacement" - ) { - return null; - } - - const paramPart = dqPart.parts[0]; - const arrayMatch = paramPart.parameter.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[([@*])\]$/, - ); - if (!arrayMatch) { - return null; - } - - const arrayName = arrayMatch[1]; - const isStar = arrayMatch[2] === "*"; - const operation = paramPart.operation as { - type: "PatternReplacement"; - pattern: WordNode; - replacement: WordNode | null; - all: boolean; - anchor: "start" | "end" | null; - }; - - // Get array elements - const elements = getArrayElements(ctx, arrayName); - const values = elements.map(([, v]) => v); - - // If no elements, check for scalar (treat as single-element array) - if (elements.length === 0) { - const scalarValue = ctx.state.env.get(arrayName); - if (scalarValue !== undefined) { - values.push(scalarValue); - } - } - - if (values.length === 0) { - return { values: [], quoted: true }; - } - - // Build the replacement regex - let regex = ""; - if (operation.pattern) { - regex = await buildPatternRegex( - ctx, - operation.pattern, - expandWordPartsAsync, - expandPart, - ); - } - - const replacement = operation.replacement - ? await expandWordPartsAsync(ctx, operation.replacement.parts) - : ""; - - // Apply anchor modifiers - let regexPattern = regex; - if (operation.anchor === "start") { - regexPattern = `^${regex}`; - } else if (operation.anchor === "end") { - regexPattern = `${regex}$`; - } - - // Apply replacement to each element - const replacedValues: string[] = []; - try { - const re = createUserRegex(regexPattern, operation.all ? "g" : ""); - for (const value of values) { - replacedValues.push(re.replace(value, replacement)); - } - } catch { - // Invalid regex - return values unchanged - replacedValues.push(...values); - } - - if (isStar) { - // "${arr[*]/...}" - join all elements with IFS into one word - const ifsSep = getIfsSeparator(ctx.state.env); - return { values: [replacedValues.join(ifsSep)], quoted: true }; - } - - // "${arr[@]/...}" - each element as a separate word - return { values: replacedValues, quoted: true }; -} - -/** - * Handle "${arr[@]#pattern}" and "${arr[*]#pattern}" - array pattern removal - * Returns null if this handler doesn't apply. - */ -export async function handleArrayPatternRemoval( - ctx: InterpreterContext, - wordParts: WordPart[], - expandWordPartsAsync: ExpandWordPartsAsyncFn, - expandPart: ExpandPartFn, -): Promise { - if (wordParts.length !== 1 || wordParts[0].type !== "DoubleQuoted") { - return null; - } - - const dqPart = wordParts[0]; - if ( - dqPart.parts.length !== 1 || - dqPart.parts[0].type !== "ParameterExpansion" || - dqPart.parts[0].operation?.type !== "PatternRemoval" - ) { - return null; - } - - const paramPart = dqPart.parts[0]; - const arrayMatch = paramPart.parameter.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[([@*])\]$/, - ); - if (!arrayMatch) { - return null; - } - - const arrayName = arrayMatch[1]; - const isStar = arrayMatch[2] === "*"; - const operation = paramPart.operation as unknown as { - type: "PatternRemoval"; - pattern: WordNode; - side: "prefix" | "suffix"; - greedy: boolean; - }; - - // Get array elements - const elements = getArrayElements(ctx, arrayName); - const values = elements.map(([, v]) => v); - - // If no elements, check for scalar (treat as single-element array) - if (elements.length === 0) { - const scalarValue = ctx.state.env.get(arrayName); - if (scalarValue !== undefined) { - values.push(scalarValue); - } - } - - if (values.length === 0) { - return { values: [], quoted: true }; - } - - // Build the regex pattern string - let regexStr = ""; - const extglob = ctx.state.shoptOptions.extglob; - if (operation.pattern) { - for (const part of operation.pattern.parts) { - if (part.type === "Glob") { - regexStr += patternToRegex(part.pattern, operation.greedy, extglob); - } else if (part.type === "Literal") { - regexStr += patternToRegex(part.value, operation.greedy, extglob); - } else if (part.type === "SingleQuoted" || part.type === "Escaped") { - regexStr += escapeRegex(part.value); - } else if (part.type === "DoubleQuoted") { - const expanded = await expandWordPartsAsync(ctx, part.parts); - regexStr += escapeRegex(expanded); - } else if (part.type === "ParameterExpansion") { - const expanded = await expandPart(ctx, part); - regexStr += patternToRegex(expanded, operation.greedy, extglob); - } else { - const expanded = await expandPart(ctx, part); - regexStr += escapeRegex(expanded); - } - } - } - - // Apply pattern removal to each element - const resultValues: string[] = []; - for (const value of values) { - resultValues.push( - applyPatternRemoval(value, regexStr, operation.side, operation.greedy), - ); - } - - if (isStar) { - // "${arr[*]#...}" - join all elements with IFS into one word - const ifsSep = getIfsSeparator(ctx.state.env); - return { values: [resultValues.join(ifsSep)], quoted: true }; - } - - // "${arr[@]#...}" - each element as a separate word - return { values: resultValues, quoted: true }; -} diff --git a/src/interpreter/expansion/array-prefix-suffix.ts b/src/interpreter/expansion/array-prefix-suffix.ts deleted file mode 100644 index 68f587d4..00000000 --- a/src/interpreter/expansion/array-prefix-suffix.ts +++ /dev/null @@ -1,479 +0,0 @@ -/** - * Array Expansion with Prefix/Suffix Handlers - * - * Handles array expansions that have adjacent text in double quotes: - * - "${prefix}${arr[@]#pattern}${suffix}" - pattern removal with prefix/suffix - * - "${prefix}${arr[@]/pattern/replacement}${suffix}" - pattern replacement with prefix/suffix - * - "${prefix}${arr[@]}${suffix}" - simple array expansion with prefix/suffix - * - "${arr[@]:-${default[@]}}" - array default/alternative values - */ - -import type { - PatternRemovalOp, - PatternReplacementOp, - WordNode, - WordPart, -} from "../../ast/types.js"; -import { createUserRegex } from "../../regex/index.js"; -import { getIfsSeparator } from "../helpers/ifs.js"; -import { escapeRegex } from "../helpers/regex.js"; -import type { InterpreterContext } from "../types.js"; -import { patternToRegex } from "./pattern.js"; -import { applyPatternRemoval } from "./pattern-removal.js"; -import { getArrayElements, getVariable, isVariableSet } from "./variable.js"; - -/** - * Result type for array expansion handlers. - * `null` means the handler doesn't apply to this case. - */ -export type ArrayExpansionResult = { values: string[]; quoted: boolean } | null; - -/** - * Type for expandPart function reference - */ -export type ExpandPartFn = ( - ctx: InterpreterContext, - part: WordPart, -) => Promise; - -/** - * Type for expandWordPartsAsync function reference - */ -export type ExpandWordPartsAsyncFn = ( - ctx: InterpreterContext, - parts: WordPart[], -) => Promise; - -/** - * Handle "${arr[@]:-${default[@]}}", "${arr[@]:+${alt[@]}}", and "${arr[@]:=default}" - * Also handles "${var:-${default[@]}}" where var is a scalar variable. - * When the default value contains an array expansion, each element should become a separate word. - */ -export async function handleArrayDefaultValue( - ctx: InterpreterContext, - wordParts: WordPart[], -): Promise { - if (wordParts.length !== 1 || wordParts[0].type !== "DoubleQuoted") { - return null; - } - - const dqPart = wordParts[0]; - if ( - dqPart.parts.length !== 1 || - dqPart.parts[0].type !== "ParameterExpansion" || - (dqPart.parts[0].operation?.type !== "DefaultValue" && - dqPart.parts[0].operation?.type !== "UseAlternative" && - dqPart.parts[0].operation?.type !== "AssignDefault") - ) { - return null; - } - - const paramPart = dqPart.parts[0]; - const op = paramPart.operation as - | { type: "DefaultValue"; word?: WordNode; checkEmpty?: boolean } - | { type: "UseAlternative"; word?: WordNode; checkEmpty?: boolean } - | { type: "AssignDefault"; word?: WordNode; checkEmpty?: boolean }; - - // Check if the outer parameter is an array subscript - const arrayMatch = paramPart.parameter.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[([@*])\]$/, - ); - - // Determine if we should use the alternate/default value - let shouldUseAlternate: boolean; - let outerIsStar = false; - - if (arrayMatch) { - // Outer parameter is an array subscript like arr[@] or arr[*] - const arrayName = arrayMatch[1]; - outerIsStar = arrayMatch[2] === "*"; - - const elements = getArrayElements(ctx, arrayName); - const isSet = elements.length > 0 || ctx.state.env.has(arrayName); - const isEmpty = - elements.length === 0 || - (elements.length === 1 && elements.every(([, v]) => v === "")); - const checkEmpty = op.checkEmpty ?? false; - - if (op.type === "UseAlternative") { - shouldUseAlternate = isSet && !(checkEmpty && isEmpty); - } else { - shouldUseAlternate = !isSet || (checkEmpty && isEmpty); - } - - // If not using alternate, return the original array value - if (!shouldUseAlternate) { - if (elements.length > 0) { - const values = elements.map(([, v]) => v); - if (outerIsStar) { - const ifsSep = getIfsSeparator(ctx.state.env); - return { values: [values.join(ifsSep)], quoted: true }; - } - return { values, quoted: true }; - } - const scalarValue = ctx.state.env.get(arrayName); - if (scalarValue !== undefined) { - return { values: [scalarValue], quoted: true }; - } - return { values: [], quoted: true }; - } - } else { - // Outer parameter is a scalar variable - const varName = paramPart.parameter; - const isSet = await isVariableSet(ctx, varName); - const varValue = await getVariable(ctx, varName); - const isEmpty = varValue === ""; - const checkEmpty = op.checkEmpty ?? false; - - if (op.type === "UseAlternative") { - shouldUseAlternate = isSet && !(checkEmpty && isEmpty); - } else { - shouldUseAlternate = !isSet || (checkEmpty && isEmpty); - } - - // If not using alternate, return the scalar value - if (!shouldUseAlternate) { - return { values: [varValue], quoted: true }; - } - } - - // We should use the alternate/default value - if (shouldUseAlternate && op.word) { - // Check if the default/alternative word contains an array expansion - const opWordParts = op.word.parts; - let defaultArrayName: string | null = null; - let defaultIsStar = false; - - for (const part of opWordParts) { - if (part.type === "ParameterExpansion" && !part.operation) { - const defaultMatch = part.parameter.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[([@*])\]$/, - ); - if (defaultMatch) { - defaultArrayName = defaultMatch[1]; - defaultIsStar = defaultMatch[2] === "*"; - break; - } - } - } - - if (defaultArrayName) { - // The default word is an array expansion - return its elements - const defaultElements = getArrayElements(ctx, defaultArrayName); - if (defaultElements.length > 0) { - const values = defaultElements.map(([, v]) => v); - if (defaultIsStar || outerIsStar) { - // Join with IFS for [*] subscript - const ifsSep = getIfsSeparator(ctx.state.env); - return { values: [values.join(ifsSep)], quoted: true }; - } - // [@] - each element as a separate word - return { values, quoted: true }; - } - // Default array is empty - check for scalar - const scalarValue = ctx.state.env.get(defaultArrayName); - if (scalarValue !== undefined) { - return { values: [scalarValue], quoted: true }; - } - // Default is unset - return { values: [], quoted: true }; - } - // Default word doesn't contain an array expansion - fall through to normal expansion - } - - return null; -} - -/** - * Handle "${prefix}${arr[@]#pattern}${suffix}" and "${prefix}${arr[@]/pat/rep}${suffix}" - * Array pattern operations with adjacent text in double quotes. - * Each array element has the pattern applied, then becomes a separate word - * with prefix joined to first and suffix joined to last. - */ -export async function handleArrayPatternWithPrefixSuffix( - ctx: InterpreterContext, - wordParts: WordPart[], - hasArrayAtExpansion: boolean, - expandPart: ExpandPartFn, - expandWordPartsAsync: ExpandWordPartsAsyncFn, -): Promise { - if ( - !hasArrayAtExpansion || - wordParts.length !== 1 || - wordParts[0].type !== "DoubleQuoted" - ) { - return null; - } - - const dqPart = wordParts[0]; - // Find if there's a ${arr[@]} or ${arr[*]} with PatternRemoval or PatternReplacement - let arrayAtIndex = -1; - let arrayName = ""; - let isStar = false; - let arrayOperation: PatternRemovalOp | PatternReplacementOp | null = null; - - for (let i = 0; i < dqPart.parts.length; i++) { - const p = dqPart.parts[i]; - if ( - p.type === "ParameterExpansion" && - (p.operation?.type === "PatternRemoval" || - p.operation?.type === "PatternReplacement") - ) { - const match = p.parameter.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[([@*])\]$/); - if (match) { - arrayAtIndex = i; - arrayName = match[1]; - isStar = match[2] === "*"; - arrayOperation = p.operation as PatternRemovalOp | PatternReplacementOp; - break; - } - } - } - - // Only handle if there's prefix or suffix (pure "${arr[@]#pat}" is handled elsewhere) - if ( - arrayAtIndex === -1 || - (arrayAtIndex === 0 && arrayAtIndex === dqPart.parts.length - 1) - ) { - return null; - } - - // Expand prefix (parts before ${arr[@]}) - let prefix = ""; - for (let i = 0; i < arrayAtIndex; i++) { - prefix += await expandPart(ctx, dqPart.parts[i]); - } - - // Expand suffix (parts after ${arr[@]}) - let suffix = ""; - for (let i = arrayAtIndex + 1; i < dqPart.parts.length; i++) { - suffix += await expandPart(ctx, dqPart.parts[i]); - } - - // Get array elements - const elements = getArrayElements(ctx, arrayName); - let values = elements.map(([, v]) => v); - - // If no elements, check for scalar (treat as single-element array) - if (elements.length === 0) { - const scalarValue = ctx.state.env.get(arrayName); - if (scalarValue !== undefined) { - values = [scalarValue]; - } else { - // Variable is unset or empty array - if (isStar) { - return { values: [prefix + suffix], quoted: true }; - } - const combined = prefix + suffix; - return { values: combined ? [combined] : [], quoted: true }; - } - } - - // Apply operation to each element - if (arrayOperation?.type === "PatternRemoval") { - const op = arrayOperation as PatternRemovalOp; - // Build the regex pattern - let regexStr = ""; - const extglob = ctx.state.shoptOptions.extglob; - if (op.pattern) { - for (const part of op.pattern.parts) { - if (part.type === "Glob") { - regexStr += patternToRegex(part.pattern, op.greedy, extglob); - } else if (part.type === "Literal") { - regexStr += patternToRegex(part.value, op.greedy, extglob); - } else if (part.type === "SingleQuoted" || part.type === "Escaped") { - regexStr += escapeRegex(part.value); - } else if (part.type === "DoubleQuoted") { - const expanded = await expandWordPartsAsync(ctx, part.parts); - regexStr += escapeRegex(expanded); - } else if (part.type === "ParameterExpansion") { - const expanded = await expandPart(ctx, part); - regexStr += patternToRegex(expanded, op.greedy, extglob); - } else { - const expanded = await expandPart(ctx, part); - regexStr += escapeRegex(expanded); - } - } - } - // Apply pattern removal to each element - values = values.map((value) => - applyPatternRemoval(value, regexStr, op.side, op.greedy), - ); - } else if (arrayOperation?.type === "PatternReplacement") { - const op = arrayOperation as PatternReplacementOp; - // Build the replacement regex - let regex = ""; - if (op.pattern) { - for (const part of op.pattern.parts) { - if (part.type === "Glob") { - regex += patternToRegex( - part.pattern, - true, - ctx.state.shoptOptions.extglob, - ); - } else if (part.type === "Literal") { - regex += patternToRegex( - part.value, - true, - ctx.state.shoptOptions.extglob, - ); - } else if (part.type === "SingleQuoted" || part.type === "Escaped") { - regex += escapeRegex(part.value); - } else if (part.type === "DoubleQuoted") { - const expanded = await expandWordPartsAsync(ctx, part.parts); - regex += escapeRegex(expanded); - } else if (part.type === "ParameterExpansion") { - const expanded = await expandPart(ctx, part); - regex += patternToRegex( - expanded, - true, - ctx.state.shoptOptions.extglob, - ); - } else { - const expanded = await expandPart(ctx, part); - regex += escapeRegex(expanded); - } - } - } - - const replacement = op.replacement - ? await expandWordPartsAsync(ctx, op.replacement.parts) - : ""; - - // Apply anchor modifiers - let regexPattern = regex; - if (op.anchor === "start") { - regexPattern = `^${regex}`; - } else if (op.anchor === "end") { - regexPattern = `${regex}$`; - } - - // Apply replacement to each element - try { - const re = createUserRegex(regexPattern, op.all ? "g" : ""); - values = values.map((value) => re.replace(value, replacement)); - } catch { - // Invalid regex - leave values unchanged - } - } - - if (isStar) { - // "${arr[*]#...}" - join all elements with IFS into one word - const ifsSep = getIfsSeparator(ctx.state.env); - return { - values: [prefix + values.join(ifsSep) + suffix], - quoted: true, - }; - } - - // "${arr[@]#...}" - each element is a separate word - // Join prefix with first, suffix with last - if (values.length === 1) { - return { values: [prefix + values[0] + suffix], quoted: true }; - } - - const result = [ - prefix + values[0], - ...values.slice(1, -1), - values[values.length - 1] + suffix, - ]; - return { values: result, quoted: true }; -} - -/** - * Handle "${prefix}${arr[@]}${suffix}" - array expansion with adjacent text in double quotes. - * Each array element becomes a separate word, with prefix joined to first and suffix joined to last. - * This is similar to how "$@" works with prefix/suffix. - */ -export async function handleArrayWithPrefixSuffix( - ctx: InterpreterContext, - wordParts: WordPart[], - hasArrayAtExpansion: boolean, - expandPart: ExpandPartFn, -): Promise { - if ( - !hasArrayAtExpansion || - wordParts.length !== 1 || - wordParts[0].type !== "DoubleQuoted" - ) { - return null; - } - - const dqPart = wordParts[0]; - // Find if there's a ${arr[@]} or ${arr[*]} inside (without operations) - let arrayAtIndex = -1; - let arrayName = ""; - let isStar = false; - - for (let i = 0; i < dqPart.parts.length; i++) { - const p = dqPart.parts[i]; - if (p.type === "ParameterExpansion" && !p.operation) { - const match = p.parameter.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[([@*])\]$/); - if (match) { - arrayAtIndex = i; - arrayName = match[1]; - isStar = match[2] === "*"; - break; - } - } - } - - if (arrayAtIndex === -1) { - return null; - } - - // Expand prefix (parts before ${arr[@]}) - let prefix = ""; - for (let i = 0; i < arrayAtIndex; i++) { - prefix += await expandPart(ctx, dqPart.parts[i]); - } - - // Expand suffix (parts after ${arr[@]}) - let suffix = ""; - for (let i = arrayAtIndex + 1; i < dqPart.parts.length; i++) { - suffix += await expandPart(ctx, dqPart.parts[i]); - } - - // Get array elements - const elements = getArrayElements(ctx, arrayName); - const values = elements.map(([, v]) => v); - - // If no elements, check for scalar (treat as single-element array) - if (elements.length === 0) { - const scalarValue = ctx.state.env.get(arrayName); - if (scalarValue !== undefined) { - // Scalar treated as single-element array - return { values: [prefix + scalarValue + suffix], quoted: true }; - } - // Variable is unset or empty array - if (isStar) { - // "${arr[*]}" with empty array produces one empty word (prefix + "" + suffix) - return { values: [prefix + suffix], quoted: true }; - } - // "${arr[@]}" with empty array produces no words (unless there's prefix/suffix) - const combined = prefix + suffix; - return { values: combined ? [combined] : [], quoted: true }; - } - - if (isStar) { - // "${arr[*]}" - join all elements with IFS into one word - const ifsSep = getIfsSeparator(ctx.state.env); - return { - values: [prefix + values.join(ifsSep) + suffix], - quoted: true, - }; - } - - // "${arr[@]}" - each element is a separate word - // Join prefix with first, suffix with last - if (values.length === 1) { - return { values: [prefix + values[0] + suffix], quoted: true }; - } - - const result = [ - prefix + values[0], - ...values.slice(1, -1), - values[values.length - 1] + suffix, - ]; - return { values: result, quoted: true }; -} diff --git a/src/interpreter/expansion/array-slice-transform.ts b/src/interpreter/expansion/array-slice-transform.ts deleted file mode 100644 index dac42b5f..00000000 --- a/src/interpreter/expansion/array-slice-transform.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** - * Array Slicing and Transform Operations - * - * Handles array expansion with slicing and transform operators: - * - "${arr[@]:offset}" and "${arr[@]:offset:length}" - array slicing - * - "${arr[@]@a}", "${arr[@]@P}", "${arr[@]@Q}" - transform operations - */ - -import type { SubstringOp, WordPart } from "../../ast/types.js"; -import { ArithmeticError, ExitError } from "../errors.js"; -import { getIfsSeparator } from "../helpers/ifs.js"; -import type { InterpreterContext } from "../types.js"; -import { expandPrompt } from "./prompt.js"; -import { quoteValue } from "./quoting.js"; -import { getArrayElements } from "./variable.js"; -import { getVariableAttributes } from "./variable-attrs.js"; - -/** - * Result type for array expansion handlers. - * `null` means the handler doesn't apply to this case. - */ -export type ArrayExpansionResult = { values: string[]; quoted: boolean } | null; - -import type { ArithExpr } from "../../ast/types.js"; - -/** - * Type for evaluateArithmetic function - */ -export type EvaluateArithmeticFn = ( - ctx: InterpreterContext, - expr: ArithExpr, - isExpansionContext?: boolean, -) => Promise; - -/** - * Handle "${arr[@]:offset}" and "${arr[@]:offset:length}" - array slicing with multiple return values - * "${arr[@]:n:m}" returns m elements starting from index n as separate words - * "${arr[*]:n:m}" returns m elements starting from index n joined with IFS as one word - */ -export async function handleArraySlicing( - ctx: InterpreterContext, - wordParts: WordPart[], - evaluateArithmetic: EvaluateArithmeticFn, -): Promise { - if (wordParts.length !== 1 || wordParts[0].type !== "DoubleQuoted") { - return null; - } - - const dqPart = wordParts[0]; - if ( - dqPart.parts.length !== 1 || - dqPart.parts[0].type !== "ParameterExpansion" || - dqPart.parts[0].operation?.type !== "Substring" - ) { - return null; - } - - const paramPart = dqPart.parts[0]; - const arrayMatch = paramPart.parameter.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[([@*])\]$/, - ); - if (!arrayMatch) { - return null; - } - - const arrayName = arrayMatch[1]; - const isStar = arrayMatch[2] === "*"; - const operation = paramPart.operation as SubstringOp; - - // Slicing associative arrays doesn't make sense - error out - if (ctx.state.associativeArrays?.has(arrayName)) { - throw new ExitError( - 1, - "", - `bash: \${${arrayName}[@]: 0: 3}: bad substitution\n`, - ); - } - - // Evaluate offset and length - const offset = operation.offset - ? await evaluateArithmetic(ctx, operation.offset.expression) - : 0; - const length = operation.length - ? await evaluateArithmetic(ctx, operation.length.expression) - : undefined; - - // Get array elements (sorted by index) - const elements = getArrayElements(ctx, arrayName); - - // For sparse arrays, offset refers to index position, not element position - // Find the first element whose index >= offset (or computed index for negative offset) - let startIdx = 0; - if (offset < 0) { - // Negative offset: count from maxIndex + 1 - // e.g., -1 means elements with index >= maxIndex - if (elements.length > 0) { - const lastIdx = elements[elements.length - 1][0]; - const maxIndex = typeof lastIdx === "number" ? lastIdx : 0; - const targetIndex = maxIndex + 1 + offset; - // If target index is negative, return empty (out of bounds) - if (targetIndex < 0) { - return { values: [], quoted: true }; - } - // Find first element with index >= targetIndex - startIdx = elements.findIndex( - ([idx]) => typeof idx === "number" && idx >= targetIndex, - ); - if (startIdx < 0) startIdx = elements.length; // All elements have smaller index - } - } else { - // Positive offset: find first element with index >= offset - startIdx = elements.findIndex( - ([idx]) => typeof idx === "number" && idx >= offset, - ); - if (startIdx < 0) startIdx = elements.length; // All elements have smaller index - } - - let slicedValues: string[]; - if (length !== undefined) { - if (length < 0) { - // Negative length is an error for array slicing in bash - throw new ArithmeticError(`${arrayName}[@]: substring expression < 0`); - } - // Take 'length' elements starting from startIdx - slicedValues = elements - .slice(startIdx, startIdx + length) - .map(([, v]) => v); - } else { - // Take all elements starting from startIdx - slicedValues = elements.slice(startIdx).map(([, v]) => v); - } - - if (slicedValues.length === 0) { - return { values: [], quoted: true }; - } - - if (isStar) { - // "${arr[*]:n:m}" - join with IFS into one word - const ifsSep = getIfsSeparator(ctx.state.env); - return { values: [slicedValues.join(ifsSep)], quoted: true }; - } - - // "${arr[@]:n:m}" - each element as a separate word - return { values: slicedValues, quoted: true }; -} - -/** - * Handle "${arr[@]@a}", "${arr[@]@P}", "${arr[@]@Q}" - array Transform operations - * "${arr[@]@a}": Return attribute letter for each element (e.g., 'a' for indexed array) - * "${arr[@]@P}": Return each element's value (prompt expansion, limited implementation) - * "${arr[@]@Q}": Return each element quoted for shell reuse - * "${arr[*]@X}": Same as above but joined with IFS as one word - */ -export function handleArrayTransform( - ctx: InterpreterContext, - wordParts: WordPart[], -): ArrayExpansionResult { - if (wordParts.length !== 1 || wordParts[0].type !== "DoubleQuoted") { - return null; - } - - const dqPart = wordParts[0]; - if ( - dqPart.parts.length !== 1 || - dqPart.parts[0].type !== "ParameterExpansion" || - dqPart.parts[0].operation?.type !== "Transform" - ) { - return null; - } - - const paramPart = dqPart.parts[0]; - const arrayMatch = paramPart.parameter.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[([@*])\]$/, - ); - if (!arrayMatch) { - return null; - } - - const arrayName = arrayMatch[1]; - const isStar = arrayMatch[2] === "*"; - const operation = paramPart.operation as { - type: "Transform"; - operator: string; - }; - - // Get array elements - const elements = getArrayElements(ctx, arrayName); - - // If no elements, check for scalar (treat as single-element array) - if (elements.length === 0) { - const scalarValue = ctx.state.env.get(arrayName); - if (scalarValue !== undefined) { - // Scalar variable - return based on operator - let resultValue: string; - switch (operation.operator) { - case "a": - resultValue = ""; // Scalars have no array attribute - break; - case "P": - resultValue = expandPrompt(ctx, scalarValue); - break; - case "Q": - resultValue = quoteValue(scalarValue); - break; - default: - resultValue = scalarValue; - } - return { values: [resultValue], quoted: true }; - } - // Variable is unset - if (isStar) { - return { values: [""], quoted: true }; - } - return { values: [], quoted: true }; - } - - // Get the attribute for this array (same for all elements) - const arrayAttr = getVariableAttributes(ctx, arrayName); - - // Transform each element based on operator - let transformedValues: string[]; - switch (operation.operator) { - case "a": - // Return attribute letter for each element - // All elements of the same array have the same attribute - transformedValues = elements.map(() => arrayAttr); - break; - case "P": - // Apply prompt expansion to each element - transformedValues = elements.map(([, v]) => expandPrompt(ctx, v)); - break; - case "Q": - // Quote each element - transformedValues = elements.map(([, v]) => quoteValue(v)); - break; - case "u": - // Capitalize first character only (ucfirst) - transformedValues = elements.map( - ([, v]) => v.charAt(0).toUpperCase() + v.slice(1), - ); - break; - case "U": - // Uppercase all characters - transformedValues = elements.map(([, v]) => v.toUpperCase()); - break; - case "L": - // Lowercase all characters - transformedValues = elements.map(([, v]) => v.toLowerCase()); - break; - default: - transformedValues = elements.map(([, v]) => v); - } - - if (isStar) { - // "${arr[*]@X}" - join all values with IFS into one word - const ifsSep = getIfsSeparator(ctx.state.env); - return { values: [transformedValues.join(ifsSep)], quoted: true }; - } - - // "${arr[@]@X}" - each value as a separate word - return { values: transformedValues, quoted: true }; -} diff --git a/src/interpreter/expansion/array-word-expansion.ts b/src/interpreter/expansion/array-word-expansion.ts deleted file mode 100644 index aa4773cd..00000000 --- a/src/interpreter/expansion/array-word-expansion.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Array Word Expansion Handlers - * - * Handles complex array expansion cases in word expansion: - * - "${arr[@]}" and "${arr[*]}" - array element expansion - * - "${arr[@]:-default}" - array with defaults - * - "${arr[@]:offset:length}" - array slicing - * - "${arr[@]/pattern/replacement}" - pattern replacement - * - "${arr[@]#pattern}" - pattern removal - * - "${arr[@]@op}" - transform operations - */ - -import type { WordPart } from "../../ast/types.js"; -import { getNamerefTarget, isNameref } from "../helpers/nameref.js"; -import type { InterpreterContext } from "../types.js"; -import { getArrayElements } from "./variable.js"; - -/** - * Result type for array expansion handlers. - * `null` means the handler doesn't apply to this case. - */ -export type ArrayExpansionResult = { values: string[]; quoted: boolean } | null; - -/** - * Helper type for expandWordPartsAsync function reference - */ -export type ExpandWordPartsAsyncFn = ( - ctx: InterpreterContext, - parts: WordPart[], -) => Promise; - -/** - * Helper type for expandPart function reference - */ -export type ExpandPartFn = ( - ctx: InterpreterContext, - part: WordPart, -) => Promise; - -/** - * Handle simple "${arr[@]}" expansion without operations. - * Returns each array element as a separate word. - */ -export function handleSimpleArrayExpansion( - ctx: InterpreterContext, - wordParts: WordPart[], -): ArrayExpansionResult { - if (wordParts.length !== 1 || wordParts[0].type !== "DoubleQuoted") { - return null; - } - - const dqPart = wordParts[0]; - if ( - dqPart.parts.length !== 1 || - dqPart.parts[0].type !== "ParameterExpansion" - ) { - return null; - } - - const paramPart = dqPart.parts[0]; - // Check if it's ONLY the array expansion (like "${a[@]}") without operations - if (paramPart.operation) { - return null; - } - - const arrayMatch = paramPart.parameter.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[(@)\]$/, - ); - if (!arrayMatch) { - return null; - } - - const arrayName = arrayMatch[1]; - - // Special case: if arrayName is a nameref pointing to array[@], - // ${ref[@]} doesn't do double indirection - it returns empty - if (isNameref(ctx, arrayName)) { - const target = getNamerefTarget(ctx, arrayName); - if (target?.endsWith("[@]") || target?.endsWith("[*]")) { - // ref points to arr[@], so ${ref[@]} is invalid/empty - return { values: [], quoted: true }; - } - } - - const elements = getArrayElements(ctx, arrayName); - if (elements.length > 0) { - // Return each element as a separate word - return { values: elements.map(([, v]) => v), quoted: true }; - } - - // No array elements - check for scalar variable - // ${s[@]} where s='abc' should return 'abc' (treat scalar as single-element array) - // But NOT if the scalar value is actually from a nameref to array[@] - const scalarValue = ctx.state.env.get(arrayName); - if (scalarValue !== undefined) { - return { values: [scalarValue], quoted: true }; - } - - // Variable is unset - return empty - return { values: [], quoted: true }; -} - -/** - * Handle namerefs pointing to array[@] - "${ref}" where ref='arr[@]' - * When a nameref points to array[@], expanding "$ref" should produce multiple words - */ -export function handleNamerefArrayExpansion( - ctx: InterpreterContext, - wordParts: WordPart[], -): ArrayExpansionResult { - if (wordParts.length !== 1 || wordParts[0].type !== "DoubleQuoted") { - return null; - } - - const dqPart = wordParts[0]; - if ( - dqPart.parts.length !== 1 || - dqPart.parts[0].type !== "ParameterExpansion" || - dqPart.parts[0].operation - ) { - return null; - } - - const paramPart = dqPart.parts[0]; - const varName = paramPart.parameter; - - // Check if it's a simple variable name (not already an array subscript) - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(varName) || !isNameref(ctx, varName)) { - return null; - } - - const target = getNamerefTarget(ctx, varName); - if (!target) { - return null; - } - - // Check if resolved target is array[@] - const targetArrayMatch = target.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[(@)\]$/); - if (!targetArrayMatch) { - return null; - } - - const arrayName = targetArrayMatch[1]; - const elements = getArrayElements(ctx, arrayName); - if (elements.length > 0) { - // Return each element as a separate word - return { values: elements.map(([, v]) => v), quoted: true }; - } - - // No array elements - check for scalar variable - const scalarValue = ctx.state.env.get(arrayName); - if (scalarValue !== undefined) { - return { values: [scalarValue], quoted: true }; - } - - // Variable is unset - return empty - return { values: [], quoted: true }; -} diff --git a/src/interpreter/expansion/brace-range.ts b/src/interpreter/expansion/brace-range.ts deleted file mode 100644 index d20dafbb..00000000 --- a/src/interpreter/expansion/brace-range.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Brace Range Expansion - * - * Handles numeric {1..10} and character {a..z} range expansion. - * These are pure functions with no external dependencies. - */ - -import { BraceExpansionError } from "../errors.js"; - -// Maximum iterations for range expansion to prevent infinite loops -const MAX_SAFE_RANGE_ITERATIONS = 10000; - -/** - * Safely expand a numeric range with step, preventing infinite loops. - * Returns array of string values, or null if the range is invalid. - * - * Bash behavior: - * - When step is 0, treat it as 1 - * - When step direction is "wrong", use absolute value and go in natural direction - * - Zero-padding: use the max width of start/end for padding - */ -function safeExpandNumericRange( - start: number, - end: number, - rawStep: number | undefined, - startStr?: string, - endStr?: string, -): string[] | null { - // Step of 0 is treated as 1 in bash - let step = rawStep ?? 1; - if (step === 0) step = 1; - - // Use absolute value of step - bash ignores step sign and uses natural direction - const absStep = Math.abs(step); - - const results: string[] = []; - - // Determine zero-padding width (max width of start or end if leading zeros) - let padWidth = 0; - if (startStr?.match(/^-?0\d/)) { - padWidth = Math.max(padWidth, startStr.replace(/^-/, "").length); - } - if (endStr?.match(/^-?0\d/)) { - padWidth = Math.max(padWidth, endStr.replace(/^-/, "").length); - } - - const formatNum = (n: number): string => { - if (padWidth > 0) { - const neg = n < 0; - const absStr = String(Math.abs(n)).padStart(padWidth, "0"); - return neg ? `-${absStr}` : absStr; - } - return String(n); - }; - - if (start <= end) { - // Ascending range - for ( - let i = start, count = 0; - i <= end && count < MAX_SAFE_RANGE_ITERATIONS; - i += absStep, count++ - ) { - results.push(formatNum(i)); - } - } else { - // Descending range (start > end) - for ( - let i = start, count = 0; - i >= end && count < MAX_SAFE_RANGE_ITERATIONS; - i -= absStep, count++ - ) { - results.push(formatNum(i)); - } - } - - return results; -} - -/** - * Safely expand a character range with step, preventing infinite loops. - * Returns array of string values, or null if the range is invalid. - * Throws BraceExpansionError for mixed case ranges (e.g., {z..A}). - * - * Bash behavior: - * - When step is 0, treat it as 1 - * - When step direction is "wrong", use absolute value and go in natural direction - * - Mixed case (e.g., {z..A}) is an error - throws BraceExpansionError - */ -function safeExpandCharRange( - start: string, - end: string, - rawStep: number | undefined, -): string[] | null { - // Step of 0 is treated as 1 in bash - let step = rawStep ?? 1; - if (step === 0) step = 1; - - const startCode = start.charCodeAt(0); - const endCode = end.charCodeAt(0); - - // Use absolute value of step - bash ignores step sign and uses natural direction - const absStep = Math.abs(step); - - // Check for mixed case (upper to lower or vice versa) - invalid in bash - const startIsUpper = start >= "A" && start <= "Z"; - const startIsLower = start >= "a" && start <= "z"; - const endIsUpper = end >= "A" && end <= "Z"; - const endIsLower = end >= "a" && end <= "z"; - - if ((startIsUpper && endIsLower) || (startIsLower && endIsUpper)) { - // Mixed case is an error in bash (produces no output, exit code 1) - const stepPart = rawStep !== undefined ? `..${rawStep}` : ""; - throw new BraceExpansionError( - `{${start}..${end}${stepPart}}: invalid sequence`, - ); - } - - const results: string[] = []; - - if (startCode <= endCode) { - // Ascending range - for ( - let i = startCode, count = 0; - i <= endCode && count < MAX_SAFE_RANGE_ITERATIONS; - i += absStep, count++ - ) { - results.push(String.fromCharCode(i)); - } - } else { - // Descending range - for ( - let i = startCode, count = 0; - i >= endCode && count < MAX_SAFE_RANGE_ITERATIONS; - i -= absStep, count++ - ) { - results.push(String.fromCharCode(i)); - } - } - - return results; -} - -/** - * Result of a brace range expansion. - * Either contains expanded values or a literal fallback for invalid ranges. - */ -export interface BraceRangeResult { - expanded: string[] | null; - literal: string; -} - -/** - * Unified brace range expansion helper. - * Handles both numeric and character ranges, returning either expanded values - * or a literal string for invalid ranges. - */ -export function expandBraceRange( - start: number | string, - end: number | string, - step: number | undefined, - startStr?: string, - endStr?: string, -): BraceRangeResult { - const stepPart = step !== undefined ? `..${step}` : ""; - - if (typeof start === "number" && typeof end === "number") { - const expanded = safeExpandNumericRange(start, end, step, startStr, endStr); - return { - expanded, - literal: `{${start}..${end}${stepPart}}`, - }; - } - - if (typeof start === "string" && typeof end === "string") { - const expanded = safeExpandCharRange(start, end, step); - return { - expanded, - literal: `{${start}..${end}${stepPart}}`, - }; - } - - // Mismatched types - treat as invalid - return { - expanded: null, - literal: `{${start}..${end}${stepPart}}`, - }; -} diff --git a/src/interpreter/expansion/command-substitution.ts b/src/interpreter/expansion/command-substitution.ts deleted file mode 100644 index fd711bc8..00000000 --- a/src/interpreter/expansion/command-substitution.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Command Substitution Helpers - * - * Helper functions for handling command substitution patterns. - */ - -import type { - ScriptNode, - SimpleCommandNode, - WordNode, -} from "../../ast/types.js"; - -/** - * Check if a command substitution body matches the $( Promise; - -/** - * Type for expandWordPartsAsync function reference - */ -export type ExpandWordPartsAsyncFn = ( - ctx: InterpreterContext, - parts: WordPart[], - inDoubleQuotes?: boolean, -) => Promise; - -/** - * Handle "${!ref}" where ref='arr[@]' or ref='arr[*]' - indirect array expansion. - * This handles all the inner operation cases as well. - */ -export async function handleIndirectArrayExpansion( - ctx: InterpreterContext, - wordParts: WordPart[], - hasIndirection: boolean, - expandParameterAsync: ExpandParameterAsyncFn, - expandWordPartsAsync: ExpandWordPartsAsyncFn, -): Promise { - if ( - !hasIndirection || - wordParts.length !== 1 || - wordParts[0].type !== "DoubleQuoted" - ) { - return null; - } - - const dqPart = wordParts[0]; - if ( - dqPart.parts.length !== 1 || - dqPart.parts[0].type !== "ParameterExpansion" || - dqPart.parts[0].operation?.type !== "Indirection" - ) { - return null; - } - - const paramPart = dqPart.parts[0]; - const indirOp = paramPart.operation as { - type: "Indirection"; - innerOp?: InnerParameterOperation; - }; - - // Get the value of the reference variable (e.g., ref='arr[@]') - const refValue = await getVariable(ctx, paramPart.parameter); - - // Check if the target is an array expansion (arr[@] or arr[*]) - const arrayMatch = refValue.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[([@*])\]$/); - if (!arrayMatch) { - // Handle ${!ref} where ref='@' or ref='*' (no array) - if (!indirOp.innerOp) { - if (refValue === "@" || refValue === "*") { - const numParams = Number.parseInt(ctx.state.env.get("#") || "0", 10); - const params: string[] = []; - for (let i = 1; i <= numParams; i++) { - params.push(ctx.state.env.get(String(i)) || ""); - } - if (refValue === "*") { - // ref='*' - join with IFS into one word (like "$*") - return { - values: [params.join(getIfsSeparator(ctx.state.env))], - quoted: true, - }; - } - // ref='@' - each param as a separate word (like "$@") - return { values: params, quoted: true }; - } - } - return null; - } - - const arrayName = arrayMatch[1]; - const isStar = arrayMatch[2] === "*"; - const elements = getArrayElements(ctx, arrayName); - - if (indirOp.innerOp) { - // Handle "${!ref[@]:offset}" or "${!ref[@]:offset:length}" - array slicing via indirection - if (indirOp.innerOp.type === "Substring") { - return handleIndirectArraySlicing( - ctx, - elements, - arrayName, - isStar, - indirOp.innerOp, - ); - } - - // Handle DefaultValue, UseAlternative, AssignDefault, ErrorIfUnset - if ( - indirOp.innerOp.type === "DefaultValue" || - indirOp.innerOp.type === "UseAlternative" || - indirOp.innerOp.type === "AssignDefault" || - indirOp.innerOp.type === "ErrorIfUnset" - ) { - return handleIndirectArrayDefaultAlternative( - ctx, - elements, - arrayName, - isStar, - indirOp.innerOp, - expandWordPartsAsync, - ); - } - - // Handle Transform operations specially for @a (attributes) - if ( - indirOp.innerOp.type === "Transform" && - (indirOp.innerOp as { operator: string }).operator === "a" - ) { - const attrs = getVariableAttributes(ctx, arrayName); - const values = elements.map(() => attrs); - if (isStar) { - return { - values: [values.join(getIfsSeparator(ctx.state.env))], - quoted: true, - }; - } - return { values, quoted: true }; - } - - // Handle other innerOps (PatternRemoval, PatternReplacement, Transform, etc.) - // Apply the operation to each element - const values: string[] = []; - for (const [, elemValue] of elements) { - const syntheticPart: ParameterExpansionPart = { - type: "ParameterExpansion", - parameter: "_indirect_elem_", - operation: indirOp.innerOp, - }; - // Temporarily set the element value - const oldVal = ctx.state.env.get("_indirect_elem_"); - ctx.state.env.set("_indirect_elem_", elemValue); - try { - const result = await expandParameterAsync(ctx, syntheticPart, true); - values.push(result); - } finally { - if (oldVal !== undefined) { - ctx.state.env.set("_indirect_elem_", oldVal); - } else { - ctx.state.env.delete("_indirect_elem_"); - } - } - } - if (isStar) { - return { - values: [values.join(getIfsSeparator(ctx.state.env))], - quoted: true, - }; - } - return { values, quoted: true }; - } - - // No innerOp - return array elements directly - if (elements.length > 0) { - const values = elements.map(([, v]) => v); - if (isStar) { - // arr[*] - join with IFS into one word - return { - values: [values.join(getIfsSeparator(ctx.state.env))], - quoted: true, - }; - } - // arr[@] - each element as a separate word - return { values, quoted: true }; - } - - // No array elements - check for scalar variable - const scalarValue = ctx.state.env.get(arrayName); - if (scalarValue !== undefined) { - return { values: [scalarValue], quoted: true }; - } - - // Variable is unset - return empty - return { values: [], quoted: true }; -} - -/** - * Handle "${!ref[@]:offset}" or "${!ref[@]:offset:length}" - array slicing via indirection - */ -async function handleIndirectArraySlicing( - ctx: InterpreterContext, - elements: Array<[string | number, string]>, - arrayName: string, - isStar: boolean, - innerOp: { - offset?: { expression: ArithExpr }; - length?: { expression: ArithExpr } | null; - }, -): Promise { - const offset = innerOp.offset - ? await evaluateArithmetic(ctx, innerOp.offset.expression) - : 0; - const length = innerOp.length - ? await evaluateArithmetic(ctx, innerOp.length.expression) - : undefined; - - // For sparse arrays, offset refers to index position - let startIdx = 0; - if (offset < 0) { - if (elements.length > 0) { - const lastIdx = elements[elements.length - 1][0]; - const maxIndex = typeof lastIdx === "number" ? lastIdx : 0; - const targetIndex = maxIndex + 1 + offset; - if (targetIndex < 0) return { values: [], quoted: true }; - startIdx = elements.findIndex( - ([idx]) => typeof idx === "number" && idx >= targetIndex, - ); - if (startIdx < 0) return { values: [], quoted: true }; - } - } else { - startIdx = elements.findIndex( - ([idx]) => typeof idx === "number" && idx >= offset, - ); - if (startIdx < 0) return { values: [], quoted: true }; - } - - let slicedElements: Array<[string | number, string]>; - if (length !== undefined) { - if (length < 0) { - throw new ArithmeticError(`${arrayName}[@]: substring expression < 0`); - } - slicedElements = elements.slice(startIdx, startIdx + length); - } else { - slicedElements = elements.slice(startIdx); - } - - const values = slicedElements.map(([, v]) => v); - if (isStar) { - return { - values: [values.join(getIfsSeparator(ctx.state.env))], - quoted: true, - }; - } - return { values, quoted: true }; -} - -/** - * Handle DefaultValue, UseAlternative, AssignDefault, ErrorIfUnset for indirect array - */ -async function handleIndirectArrayDefaultAlternative( - ctx: InterpreterContext, - elements: Array<[string | number, string]>, - arrayName: string, - isStar: boolean, - innerOp: DefaultValueOp | AssignDefaultOp | ErrorIfUnsetOp | UseAlternativeOp, - expandWordPartsAsync: ExpandWordPartsAsyncFn, -): Promise { - const checkEmpty = innerOp.checkEmpty ?? false; - const values = elements.map(([, v]) => v); - // For arrays, "empty" means zero elements (not that elements are empty strings) - const isEmpty = elements.length === 0; - const isUnset = elements.length === 0; - - if (innerOp.type === "UseAlternative") { - // ${!ref[@]:+word} - return word if set and non-empty - const shouldUseAlt = !isUnset && !(checkEmpty && isEmpty); - if (shouldUseAlt && innerOp.word) { - const altValue = await expandWordPartsAsync( - ctx, - innerOp.word.parts, - true, - ); - return { values: [altValue], quoted: true }; - } - return { values: [], quoted: true }; - } - - if (innerOp.type === "DefaultValue") { - // ${!ref[@]:-word} - return word if unset or empty - const shouldUseDefault = isUnset || (checkEmpty && isEmpty); - if (shouldUseDefault && innerOp.word) { - const defValue = await expandWordPartsAsync( - ctx, - innerOp.word.parts, - true, - ); - return { values: [defValue], quoted: true }; - } - if (isStar) { - return { - values: [values.join(getIfsSeparator(ctx.state.env))], - quoted: true, - }; - } - return { values, quoted: true }; - } - - if (innerOp.type === "AssignDefault") { - // ${!ref[@]:=word} - assign and return word if unset or empty - const shouldAssign = isUnset || (checkEmpty && isEmpty); - if (shouldAssign && innerOp.word) { - const assignValue = await expandWordPartsAsync( - ctx, - innerOp.word.parts, - true, - ); - // Assign to the target array - ctx.state.env.set(`${arrayName}_0`, assignValue); - ctx.state.env.set(`${arrayName}__length`, "1"); - return { values: [assignValue], quoted: true }; - } - if (isStar) { - return { - values: [values.join(getIfsSeparator(ctx.state.env))], - quoted: true, - }; - } - return { values, quoted: true }; - } - - // ErrorIfUnset case - not common for arrays - if (isStar) { - return { - values: [values.join(getIfsSeparator(ctx.state.env))], - quoted: true, - }; - } - return { values, quoted: true }; -} - -/** - * Handle ${ref+${!ref}} or ${ref-${!ref}} - indirect in alternative/default value. - * This handles patterns like: ${hooksSlice+"${!hooksSlice}"} which should preserve element boundaries - */ -export async function handleIndirectInAlternative( - ctx: InterpreterContext, - wordParts: WordPart[], -): Promise { - if ( - wordParts.length !== 1 || - wordParts[0].type !== "ParameterExpansion" || - (wordParts[0].operation?.type !== "UseAlternative" && - wordParts[0].operation?.type !== "DefaultValue") - ) { - return null; - } - - const paramPart = wordParts[0]; - const op = paramPart.operation as - | { type: "UseAlternative"; word?: WordNode; checkEmpty?: boolean } - | { type: "DefaultValue"; word?: WordNode; checkEmpty?: boolean }; - const opWord = op?.word; - - // Check if the inner word is a quoted indirect expansion to an array - if ( - !opWord || - opWord.parts.length !== 1 || - opWord.parts[0].type !== "DoubleQuoted" - ) { - return null; - } - - const innerDq = opWord.parts[0]; - if ( - innerDq.parts.length !== 1 || - innerDq.parts[0].type !== "ParameterExpansion" || - innerDq.parts[0].operation?.type !== "Indirection" - ) { - return null; - } - - const innerParam = innerDq.parts[0]; - // Get the value of the reference variable to see if it points to an array - const refValue = await getVariable(ctx, innerParam.parameter); - const arrayMatch = refValue.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[([@*])\]$/); - - if (!arrayMatch) { - return null; - } - - // Check if we should use the alternative/default - const isSet = await isVariableSet(ctx, paramPart.parameter); - const isEmpty = (await getVariable(ctx, paramPart.parameter)) === ""; - const checkEmpty = op.checkEmpty ?? false; - - let shouldExpand: boolean; - if (op.type === "UseAlternative") { - // ${var+word} - expand if var IS set (and non-empty if :+) - shouldExpand = isSet && !(checkEmpty && isEmpty); - } else { - // ${var-word} - expand if var is NOT set (or empty if :-) - shouldExpand = !isSet || (checkEmpty && isEmpty); - } - - if (shouldExpand) { - // Expand the inner indirect array reference - const arrayName = arrayMatch[1]; - const isStar = arrayMatch[2] === "*"; - const elements = getArrayElements(ctx, arrayName); - if (elements.length > 0) { - const values = elements.map(([, v]) => v); - if (isStar) { - // arr[*] - join with IFS into one word - return { - values: [values.join(getIfsSeparator(ctx.state.env))], - quoted: true, - }; - } - // arr[@] - each element as a separate word (quoted) - return { values, quoted: true }; - } - // No array elements - check for scalar variable - const scalarValue = ctx.state.env.get(arrayName); - if (scalarValue !== undefined) { - return { values: [scalarValue], quoted: true }; - } - // Variable is unset - return empty - return { values: [], quoted: true }; - } - // Don't expand the alternative - return empty - return { values: [], quoted: false }; -} - -/** - * Handle ${!ref+${!ref}} or ${!ref-${!ref}} - indirect with innerOp in alternative/default value. - * This handles patterns like: ${!hooksSlice+"${!hooksSlice}"} which should preserve element boundaries - */ -export async function handleIndirectionWithInnerAlternative( - ctx: InterpreterContext, - wordParts: WordPart[], -): Promise { - if ( - wordParts.length !== 1 || - wordParts[0].type !== "ParameterExpansion" || - wordParts[0].operation?.type !== "Indirection" - ) { - return null; - } - - const paramPart = wordParts[0]; - const indirOp = paramPart.operation as { - type: "Indirection"; - innerOp?: { - type: string; - word?: WordNode; - checkEmpty?: boolean; - }; - }; - const innerOp = indirOp.innerOp; - - if ( - !innerOp || - (innerOp.type !== "UseAlternative" && innerOp.type !== "DefaultValue") - ) { - return null; - } - - const opWord = innerOp.word; - // Check if the inner word is a quoted indirect expansion to an array - if ( - !opWord || - opWord.parts.length !== 1 || - opWord.parts[0].type !== "DoubleQuoted" - ) { - return null; - } - - const innerDq = opWord.parts[0]; - if ( - innerDq.parts.length !== 1 || - innerDq.parts[0].type !== "ParameterExpansion" || - innerDq.parts[0].operation?.type !== "Indirection" - ) { - return null; - } - - const innerParam = innerDq.parts[0]; - // Get the value of the reference variable to see if it points to an array - const refValue = await getVariable(ctx, innerParam.parameter); - const arrayMatch = refValue.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[([@*])\]$/); - - if (!arrayMatch) { - return null; - } - - // First resolve the outer indirection - const outerRefValue = await getVariable(ctx, paramPart.parameter); - - // Check if we should use the alternative/default - const isSet = await isVariableSet(ctx, paramPart.parameter); - const isEmpty = outerRefValue === ""; - const checkEmpty = innerOp.checkEmpty ?? false; - - let shouldExpand: boolean; - if (innerOp.type === "UseAlternative") { - // ${!var+word} - expand if the indirect target IS set (and non-empty if :+) - shouldExpand = isSet && !(checkEmpty && isEmpty); - } else { - // ${!var-word} - expand if the indirect target is NOT set (or empty if :-) - shouldExpand = !isSet || (checkEmpty && isEmpty); - } - - if (shouldExpand) { - // Expand the inner indirect array reference - const arrayName = arrayMatch[1]; - const isStar = arrayMatch[2] === "*"; - const elements = getArrayElements(ctx, arrayName); - if (elements.length > 0) { - const values = elements.map(([, v]) => v); - if (isStar) { - // arr[*] - join with IFS into one word - return { - values: [values.join(getIfsSeparator(ctx.state.env))], - quoted: true, - }; - } - // arr[@] - each element as a separate word (quoted) - return { values, quoted: true }; - } - // No array elements - check for scalar variable - const scalarValue = ctx.state.env.get(arrayName); - if (scalarValue !== undefined) { - return { values: [scalarValue], quoted: true }; - } - // Variable is unset - return empty - return { values: [], quoted: true }; - } - // Don't expand the alternative - fall through to return empty or the outer value - return { values: [], quoted: false }; -} diff --git a/src/interpreter/expansion/parameter-ops.ts b/src/interpreter/expansion/parameter-ops.ts deleted file mode 100644 index 0420de59..00000000 --- a/src/interpreter/expansion/parameter-ops.ts +++ /dev/null @@ -1,787 +0,0 @@ -/** - * Parameter Operation Handlers - * - * Handles individual parameter expansion operations: - * - DefaultValue, AssignDefault, UseAlternative, ErrorIfUnset - * - PatternRemoval, PatternReplacement - * - Length, Substring - * - CaseModification, Transform - * - Indirection, ArrayKeys, VarNamePrefix - */ - -import type { - CaseModificationOp, - ErrorIfUnsetOp, - InnerParameterOperation, - ParameterExpansionPart, - PatternRemovalOp, - PatternReplacementOp, - SubstringOp, - WordNode, - WordPart, -} from "../../ast/types.js"; -import { parseArithmeticExpression } from "../../parser/arithmetic-parser.js"; -import { Parser } from "../../parser/parser.js"; -import { createUserRegex } from "../../regex/index.js"; -import { evaluateArithmetic } from "../arithmetic.js"; -import { ArithmeticError, BadSubstitutionError, ExitError } from "../errors.js"; -import { getIfsSeparator } from "../helpers/ifs.js"; -import { getNamerefTarget, isNameref } from "../helpers/nameref.js"; -import { escapeRegex } from "../helpers/regex.js"; -import type { InterpreterContext } from "../types.js"; -import { patternToRegex } from "./pattern.js"; -import { getVarNamesWithPrefix } from "./pattern-removal.js"; -import { expandPrompt } from "./prompt.js"; -import { quoteValue } from "./quoting.js"; -import { getArrayElements, getVariable, isArray } from "./variable.js"; -import { getVariableAttributes } from "./variable-attrs.js"; - -/** - * Type for expandWordPartsAsync function reference - */ -export type ExpandWordPartsAsyncFn = ( - ctx: InterpreterContext, - parts: WordPart[], - inDoubleQuotes?: boolean, -) => Promise; - -/** - * Type for expandPart function reference - */ -export type ExpandPartFn = ( - ctx: InterpreterContext, - part: WordPart, - inDoubleQuotes?: boolean, -) => Promise; - -/** - * Type for self-reference to expandParameterAsync - */ -export type ExpandParameterAsyncFn = ( - ctx: InterpreterContext, - part: ParameterExpansionPart, - inDoubleQuotes?: boolean, -) => Promise; - -/** - * Context with computed values used across multiple operation handlers - */ -export interface ParameterOpContext { - value: string; - isUnset: boolean; - isEmpty: boolean; - effectiveValue: string; - inDoubleQuotes: boolean; -} - -/** - * Handle DefaultValue operation: ${param:-word} - */ -export async function handleDefaultValue( - ctx: InterpreterContext, - operation: { word?: WordNode; checkEmpty?: boolean }, - opCtx: ParameterOpContext, - expandWordPartsAsync: ExpandWordPartsAsyncFn, -): Promise { - ctx.coverage?.hit("bash:expansion:default_value"); - const useDefault = opCtx.isUnset || (operation.checkEmpty && opCtx.isEmpty); - if (useDefault && operation.word) { - return expandWordPartsAsync( - ctx, - operation.word.parts, - opCtx.inDoubleQuotes, - ); - } - return opCtx.effectiveValue; -} - -/** - * Handle AssignDefault operation: ${param:=word} - */ -export async function handleAssignDefault( - ctx: InterpreterContext, - parameter: string, - operation: { word?: WordNode; checkEmpty?: boolean }, - opCtx: ParameterOpContext, - expandWordPartsAsync: ExpandWordPartsAsyncFn, -): Promise { - ctx.coverage?.hit("bash:expansion:assign_default"); - const useDefault = opCtx.isUnset || (operation.checkEmpty && opCtx.isEmpty); - if (useDefault && operation.word) { - const defaultValue = await expandWordPartsAsync( - ctx, - operation.word.parts, - opCtx.inDoubleQuotes, - ); - // Handle array subscript assignment (e.g., arr[0]=x) - const arrayMatch = parameter.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$/); - if (arrayMatch) { - const [, arrayName, subscriptExpr] = arrayMatch; - // Evaluate subscript as arithmetic expression - let index: number; - if (/^\d+$/.test(subscriptExpr)) { - index = Number.parseInt(subscriptExpr, 10); - } else { - try { - const parser = new Parser(); - const arithAst = parseArithmeticExpression(parser, subscriptExpr); - index = await evaluateArithmetic(ctx, arithAst.expression); - } catch { - const varValue = ctx.state.env.get(subscriptExpr); - index = varValue ? Number.parseInt(varValue, 10) : 0; - } - if (Number.isNaN(index)) index = 0; - } - // Set array element - ctx.state.env.set(`${arrayName}_${index}`, defaultValue); - // Update array length if needed - const currentLength = Number.parseInt( - ctx.state.env.get(`${arrayName}__length`) || "0", - 10, - ); - if (index >= currentLength) { - ctx.state.env.set(`${arrayName}__length`, String(index + 1)); - } - } else { - ctx.state.env.set(parameter, defaultValue); - } - return defaultValue; - } - return opCtx.effectiveValue; -} - -/** - * Handle ErrorIfUnset operation: ${param:?word} - */ -export async function handleErrorIfUnset( - ctx: InterpreterContext, - parameter: string, - operation: ErrorIfUnsetOp, - opCtx: ParameterOpContext, - expandWordPartsAsync: ExpandWordPartsAsyncFn, -): Promise { - ctx.coverage?.hit("bash:expansion:error_if_unset"); - const shouldError = opCtx.isUnset || (operation.checkEmpty && opCtx.isEmpty); - if (shouldError) { - const message = operation.word - ? await expandWordPartsAsync( - ctx, - operation.word.parts, - opCtx.inDoubleQuotes, - ) - : `${parameter}: parameter null or not set`; - throw new ExitError(1, "", `bash: ${message}\n`); - } - return opCtx.effectiveValue; -} - -/** - * Handle UseAlternative operation: ${param:+word} - */ -export async function handleUseAlternative( - ctx: InterpreterContext, - operation: { word?: WordNode; checkEmpty?: boolean }, - opCtx: ParameterOpContext, - expandWordPartsAsync: ExpandWordPartsAsyncFn, -): Promise { - ctx.coverage?.hit("bash:expansion:use_alternative"); - const useAlternative = !( - opCtx.isUnset || - (operation.checkEmpty && opCtx.isEmpty) - ); - if (useAlternative && operation.word) { - return expandWordPartsAsync( - ctx, - operation.word.parts, - opCtx.inDoubleQuotes, - ); - } - return ""; -} - -/** - * Handle PatternRemoval operation: ${param#pattern}, ${param%pattern} - */ -export async function handlePatternRemoval( - ctx: InterpreterContext, - value: string, - operation: PatternRemovalOp, - expandWordPartsAsync: ExpandWordPartsAsyncFn, - expandPart: ExpandPartFn, -): Promise { - ctx.coverage?.hit("bash:expansion:pattern_removal"); - // Build regex pattern from parts, preserving literal vs glob distinction - let regexStr = ""; - const extglob = ctx.state.shoptOptions.extglob; - if (operation.pattern) { - for (const part of operation.pattern.parts) { - if (part.type === "Glob") { - regexStr += patternToRegex(part.pattern, operation.greedy, extglob); - } else if (part.type === "Literal") { - // Unquoted literal - treat as glob pattern (may contain *, ?, [...]) - regexStr += patternToRegex(part.value, operation.greedy, extglob); - } else if (part.type === "SingleQuoted" || part.type === "Escaped") { - regexStr += escapeRegex(part.value); - } else if (part.type === "DoubleQuoted") { - const expanded = await expandWordPartsAsync(ctx, part.parts); - regexStr += escapeRegex(expanded); - } else if (part.type === "ParameterExpansion") { - const expanded = await expandPart(ctx, part); - regexStr += patternToRegex(expanded, operation.greedy, extglob); - } else { - const expanded = await expandPart(ctx, part); - regexStr += escapeRegex(expanded); - } - } - } - - // Use 's' flag (dotall) so that . matches newlines (bash ? matches any char including newline) - if (operation.side === "prefix") { - return createUserRegex(`^${regexStr}`, "s").replace(value, ""); - } - const regex = createUserRegex(`${regexStr}$`, "s"); - if (operation.greedy) { - return regex.replace(value, ""); - } - for (let i = value.length; i >= 0; i--) { - const suffix = value.slice(i); - if (regex.test(suffix)) { - return value.slice(0, i); - } - } - return value; -} - -/** - * Handle PatternReplacement operation: ${param/pattern/replacement} - */ -export async function handlePatternReplacement( - ctx: InterpreterContext, - value: string, - operation: PatternReplacementOp, - expandWordPartsAsync: ExpandWordPartsAsyncFn, - expandPart: ExpandPartFn, -): Promise { - ctx.coverage?.hit("bash:expansion:pattern_replacement"); - let regex = ""; - const extglob = ctx.state.shoptOptions.extglob; - if (operation.pattern) { - for (const part of operation.pattern.parts) { - if (part.type === "Glob") { - regex += patternToRegex(part.pattern, true, extglob); - } else if (part.type === "Literal") { - // Unquoted literal - treat as glob pattern (may contain *, ?, [...], \X) - regex += patternToRegex(part.value, true, extglob); - } else if (part.type === "SingleQuoted" || part.type === "Escaped") { - regex += escapeRegex(part.value); - } else if (part.type === "DoubleQuoted") { - const expanded = await expandWordPartsAsync(ctx, part.parts); - regex += escapeRegex(expanded); - } else if (part.type === "ParameterExpansion") { - const expanded = await expandPart(ctx, part); - regex += patternToRegex(expanded, true, extglob); - } else { - const expanded = await expandPart(ctx, part); - regex += escapeRegex(expanded); - } - } - } - - const replacement = operation.replacement - ? await expandWordPartsAsync(ctx, operation.replacement.parts) - : ""; - - // Apply anchor modifiers - if (operation.anchor === "start") { - regex = `^${regex}`; - } else if (operation.anchor === "end") { - regex = `${regex}$`; - } - - // Empty pattern (without anchor) means no replacement - return original value - // But with anchor, empty pattern is valid: ${var/#/prefix} prepends, ${var/%/suffix} appends - if (regex === "") { - return value; - } - - // Use 's' flag (dotall) so that . matches newlines (bash ? and * match any char including newline) - const flags = operation.all ? "gs" : "s"; - - try { - const re = createUserRegex(regex, flags); - if (operation.all) { - let result = ""; - let lastIndex = 0; - let match: RegExpExecArray | null = re.exec(value); - while (match !== null) { - if (match[0].length === 0 && match.index === value.length) { - break; - } - result += value.slice(lastIndex, match.index) + replacement; - lastIndex = match.index + match[0].length; - if (match[0].length === 0) { - lastIndex++; - } - match = re.exec(value); - } - result += value.slice(lastIndex); - return result; - } - return re.replace(value, replacement); - } catch { - return value; - } -} - -/** - * Handle Length operation: ${#param} - */ -export function handleLength( - ctx: InterpreterContext, - parameter: string, - value: string, -): string { - ctx.coverage?.hit("bash:expansion:length"); - // Check if this is an array length: ${#a[@]} or ${#a[*]} - const arrayMatch = parameter.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[[@*]\]$/); - if (arrayMatch) { - const arrayName = arrayMatch[1]; - const elements = getArrayElements(ctx, arrayName); - if (elements.length > 0) { - return String(elements.length); - } - // If no array elements, check if scalar variable exists - // In bash, ${#s[@]} for scalar s returns 1 - const scalarValue = ctx.state.env.get(arrayName); - if (scalarValue !== undefined) { - return "1"; - } - return "0"; - } - // Check if this is just the array name (decays to ${#a[0]}) - if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(parameter) && isArray(ctx, parameter)) { - // Special handling for FUNCNAME and BASH_LINENO - if (parameter === "FUNCNAME") { - const firstElement = ctx.state.funcNameStack?.[0] || ""; - return String([...firstElement].length); - } - if (parameter === "BASH_LINENO") { - const firstElement = ctx.state.callLineStack?.[0]; - return String( - firstElement !== undefined ? [...String(firstElement)].length : 0, - ); - } - const firstElement = ctx.state.env.get(`${parameter}_0`) || ""; - return String([...firstElement].length); - } - // Use spread to count Unicode code points, not UTF-16 code units - return String([...value].length); -} - -/** - * Handle Substring operation: ${param:offset:length} - */ -export async function handleSubstring( - ctx: InterpreterContext, - parameter: string, - value: string, - operation: SubstringOp, -): Promise { - ctx.coverage?.hit("bash:expansion:substring"); - const offset = await evaluateArithmetic(ctx, operation.offset.expression); - const length = operation.length - ? await evaluateArithmetic(ctx, operation.length.expression) - : undefined; - - // Handle special case for ${@:offset} and ${*:offset} - if (parameter === "@" || parameter === "*") { - const numParams = Number.parseInt(ctx.state.env.get("#") || "0", 10); - const params: string[] = []; - for (let i = 1; i <= numParams; i++) { - params.push(ctx.state.env.get(String(i)) || ""); - } - const shellName = ctx.state.env.get("0") || "bash"; - let allArgs: string[]; - let startIdx: number; - - if (offset <= 0) { - allArgs = [shellName, ...params]; - if (offset < 0) { - startIdx = allArgs.length + offset; - if (startIdx < 0) return ""; - } else { - startIdx = 0; - } - } else { - allArgs = params; - startIdx = offset - 1; - } - - if (startIdx < 0 || startIdx >= allArgs.length) { - return ""; - } - if (length !== undefined) { - const endIdx = length < 0 ? allArgs.length + length : startIdx + length; - return allArgs.slice(startIdx, Math.max(startIdx, endIdx)).join(" "); - } - return allArgs.slice(startIdx).join(" "); - } - - // Handle array slicing: ${arr[@]:offset} or ${arr[*]:offset} - const arrayMatchSubstr = parameter.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[[@*]\]$/, - ); - if (arrayMatchSubstr) { - const arrayName = arrayMatchSubstr[1]; - if (ctx.state.associativeArrays?.has(arrayName)) { - throw new ExitError( - 1, - "", - `bash: \${${arrayName}[@]: 0: 3}: bad substitution\n`, - ); - } - const elements = getArrayElements(ctx, arrayName); - let startIdx = 0; - if (offset < 0) { - if (elements.length > 0) { - const lastIdx = elements[elements.length - 1][0]; - const maxIndex = typeof lastIdx === "number" ? lastIdx : 0; - const targetIndex = maxIndex + 1 + offset; - if (targetIndex < 0) return ""; - startIdx = elements.findIndex( - ([idx]) => typeof idx === "number" && idx >= targetIndex, - ); - if (startIdx < 0) return ""; - } - } else { - startIdx = elements.findIndex( - ([idx]) => typeof idx === "number" && idx >= offset, - ); - if (startIdx < 0) return ""; - } - - if (length !== undefined) { - if (length < 0) { - throw new ArithmeticError( - `${arrayMatchSubstr[1]}[@]: substring expression < 0`, - ); - } - return elements - .slice(startIdx, startIdx + length) - .map(([, v]) => v) - .join(" "); - } - return elements - .slice(startIdx) - .map(([, v]) => v) - .join(" "); - } - - // String slicing with UTF-8 support - const chars = [...value]; - let start = offset; - if (start < 0) start = Math.max(0, chars.length + start); - if (length !== undefined) { - if (length < 0) { - const endPos = chars.length + length; - return chars.slice(start, Math.max(start, endPos)).join(""); - } - return chars.slice(start, start + length).join(""); - } - return chars.slice(start).join(""); -} - -/** - * Handle CaseModification operation: ${param^pattern}, ${param,pattern} - */ -export async function handleCaseModification( - ctx: InterpreterContext, - value: string, - operation: CaseModificationOp, - expandWordPartsAsync: ExpandWordPartsAsyncFn, - expandParameterAsync: ExpandParameterAsyncFn, -): Promise { - ctx.coverage?.hit("bash:expansion:case_modification"); - if (operation.pattern) { - const extglob = ctx.state.shoptOptions.extglob; - let patternRegexStr = ""; - for (const part of operation.pattern.parts) { - if (part.type === "Glob") { - patternRegexStr += patternToRegex(part.pattern, true, extglob); - } else if (part.type === "Literal") { - patternRegexStr += patternToRegex(part.value, true, extglob); - } else if (part.type === "SingleQuoted" || part.type === "Escaped") { - patternRegexStr += escapeRegex(part.value); - } else if (part.type === "DoubleQuoted") { - const expanded = await expandWordPartsAsync(ctx, part.parts); - patternRegexStr += escapeRegex(expanded); - } else if (part.type === "ParameterExpansion") { - const expanded = await expandParameterAsync(ctx, part); - patternRegexStr += patternToRegex(expanded, true, extglob); - } - } - const charPattern = createUserRegex(`^(?:${patternRegexStr})$`); - const transform = - operation.direction === "upper" - ? (c: string) => c.toUpperCase() - : (c: string) => c.toLowerCase(); - - let result = ""; - let converted = false; - for (const char of value) { - if (!operation.all && converted) { - result += char; - } else if (charPattern.test(char)) { - result += transform(char); - converted = true; - } else { - result += char; - } - } - return result; - } - - if (operation.direction === "upper") { - return operation.all - ? value.toUpperCase() - : value.charAt(0).toUpperCase() + value.slice(1); - } - return operation.all - ? value.toLowerCase() - : value.charAt(0).toLowerCase() + value.slice(1); -} - -/** - * Handle Transform operation: ${param@operator} - */ -export function handleTransform( - ctx: InterpreterContext, - parameter: string, - value: string, - isUnset: boolean, - operation: { operator: string }, -): string { - ctx.coverage?.hit("bash:expansion:transform"); - const arrayMatchTransform = parameter.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[[@*]\]$/, - ); - if (arrayMatchTransform && operation.operator === "Q") { - const elements = getArrayElements(ctx, arrayMatchTransform[1]); - const quotedElements = elements.map(([, v]) => quoteValue(v)); - return quotedElements.join(" "); - } - if (arrayMatchTransform && operation.operator === "a") { - return getVariableAttributes(ctx, arrayMatchTransform[1]); - } - - const arrayElemMatch = parameter.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[.+\]$/); - if (arrayElemMatch && operation.operator === "a") { - return getVariableAttributes(ctx, arrayElemMatch[1]); - } - - switch (operation.operator) { - case "Q": - if (isUnset) return ""; - return quoteValue(value); - case "P": - return expandPrompt(ctx, value); - case "a": - return getVariableAttributes(ctx, parameter); - case "A": - if (isUnset) return ""; - return `${parameter}=${quoteValue(value)}`; - case "E": - return value.replace(/\\([\\abefnrtv'"?])/g, (_, c) => { - switch (c) { - case "\\": - return "\\"; - case "a": - return "\x07"; - case "b": - return "\b"; - case "e": - return "\x1b"; - case "f": - return "\f"; - case "n": - return "\n"; - case "r": - return "\r"; - case "t": - return "\t"; - case "v": - return "\v"; - case "'": - return "'"; - case '"': - return '"'; - case "?": - return "?"; - default: - return c; - } - }); - case "K": - case "k": - if (isUnset) return ""; - return quoteValue(value); - case "u": - return value.charAt(0).toUpperCase() + value.slice(1); - case "U": - return value.toUpperCase(); - case "L": - return value.toLowerCase(); - default: - return value; - } -} - -/** - * Handle Indirection operation: ${!param} - */ -export async function handleIndirection( - ctx: InterpreterContext, - parameter: string, - value: string, - isUnset: boolean, - operation: { innerOp?: InnerParameterOperation }, - expandParameterAsync: ExpandParameterAsyncFn, - inDoubleQuotes = false, -): Promise { - ctx.coverage?.hit("bash:expansion:indirection"); - if (isNameref(ctx, parameter)) { - return getNamerefTarget(ctx, parameter) || ""; - } - - const isArrayExpansionPattern = /^[a-zA-Z_][a-zA-Z0-9_]*\[([@*])\]$/.test( - parameter, - ); - - if (isUnset) { - if (operation.innerOp?.type === "UseAlternative") { - return ""; - } - throw new BadSubstitutionError(`\${!${parameter}}`); - } - - const targetName = value; - - if ( - isArrayExpansionPattern && - (targetName === "" || targetName.includes(" ")) - ) { - throw new BadSubstitutionError(`\${!${parameter}}`); - } - - const arraySubscriptMatch = targetName.match( - /^[a-zA-Z_][a-zA-Z0-9_]*\[(.+)\]$/, - ); - if (arraySubscriptMatch) { - const subscript = arraySubscriptMatch[1]; - if (subscript.includes("~")) { - throw new BadSubstitutionError(`\${!${parameter}}`); - } - } - - if (operation.innerOp) { - const syntheticPart: ParameterExpansionPart = { - type: "ParameterExpansion", - parameter: targetName, - operation: operation.innerOp, - }; - return expandParameterAsync(ctx, syntheticPart, inDoubleQuotes); - } - - return await getVariable(ctx, targetName); -} - -/** - * Handle ArrayKeys operation: ${!arr[@]}, ${!arr[*]} - */ -export function handleArrayKeys( - ctx: InterpreterContext, - operation: { array: string; star: boolean }, -): string { - ctx.coverage?.hit("bash:expansion:array_keys"); - const elements = getArrayElements(ctx, operation.array); - const keys = elements.map(([k]) => String(k)); - if (operation.star) { - return keys.join(getIfsSeparator(ctx.state.env)); - } - return keys.join(" "); -} - -/** - * Handle VarNamePrefix operation: ${!prefix*}, ${!prefix@} - */ -export function handleVarNamePrefix( - ctx: InterpreterContext, - operation: { prefix: string; star: boolean }, -): string { - ctx.coverage?.hit("bash:expansion:var_name_prefix"); - const matchingVars = getVarNamesWithPrefix(ctx, operation.prefix); - if (operation.star) { - return matchingVars.join(getIfsSeparator(ctx.state.env)); - } - return matchingVars.join(" "); -} - -/** - * Compute whether the parameter value is "empty" for expansion purposes. - * This handles special cases for $*, $@, array[*], and array[@]. - */ -export function computeIsEmpty( - ctx: InterpreterContext, - parameter: string, - value: string, - inDoubleQuotes: boolean, -): { isEmpty: boolean; effectiveValue: string } { - const numParams = Number.parseInt(ctx.state.env.get("#") || "0", 10); - - // Check if this is an array expansion: varname[*] or varname[@] - const arrayExpMatch = parameter.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[([@*])\]$/); - - if (parameter === "*") { - // $* is only "empty" if no positional params exist - return { isEmpty: numParams === 0, effectiveValue: value }; - } - - if (parameter === "@") { - // $@ is "empty" if no params OR exactly one empty param - return { - isEmpty: - numParams === 0 || (numParams === 1 && ctx.state.env.get("1") === ""), - effectiveValue: value, - }; - } - - if (arrayExpMatch) { - // a[*] or a[@] - check if expansion is empty considering IFS - const [, arrayName, subscript] = arrayExpMatch; - const elements = getArrayElements(ctx, arrayName); - if (elements.length === 0) { - // Empty array - always empty - return { isEmpty: true, effectiveValue: "" }; - } - if (subscript === "*") { - // a[*] behavior depends on quoting context: - // - Quoted "${a[*]:-default}": uses default if IFS-joined result is empty - // - Unquoted ${a[*]:-default}: like $*, only "empty" if array has no elements - // (even if IFS="" makes the joined expansion an empty string) - const ifsSep = getIfsSeparator(ctx.state.env); - const joined = elements.map(([, v]) => v).join(ifsSep); - return { - isEmpty: inDoubleQuotes ? joined === "" : false, - effectiveValue: joined, // Use IFS-joined value instead of space-joined - }; - } - // a[@] - empty only if all elements are empty AND there's exactly one - // (similar to $@ behavior with single empty param) - return { - isEmpty: elements.length === 1 && elements.every(([, v]) => v === ""), - effectiveValue: elements.map(([, v]) => v).join(" "), - }; - } - - return { isEmpty: value === "", effectiveValue: value }; -} diff --git a/src/interpreter/expansion/pattern-expansion.ts b/src/interpreter/expansion/pattern-expansion.ts deleted file mode 100644 index 1baabcc3..00000000 --- a/src/interpreter/expansion/pattern-expansion.ts +++ /dev/null @@ -1,542 +0,0 @@ -/** - * Pattern Expansion - * - * Functions for expanding variables within glob/extglob patterns. - * Handles command substitution, variable expansion, and quoting within patterns. - */ - -import type { ScriptNode } from "../../ast/types.js"; -import { Parser } from "../../parser/parser.js"; -import { ExecutionLimitError, ExitError } from "../errors.js"; -import type { InterpreterContext } from "../types.js"; -import { escapeGlobChars } from "./glob-escape.js"; - -/** - * Check if a pattern string contains command substitution $(...) - */ -export function patternHasCommandSubstitution(pattern: string): boolean { - let i = 0; - while (i < pattern.length) { - const c = pattern[i]; - // Skip escaped characters - if (c === "\\" && i + 1 < pattern.length) { - i += 2; - continue; - } - // Skip single-quoted strings - if (c === "'") { - const closeIdx = pattern.indexOf("'", i + 1); - if (closeIdx !== -1) { - i = closeIdx + 1; - continue; - } - } - // Check for $( which indicates command substitution - if (c === "$" && i + 1 < pattern.length && pattern[i + 1] === "(") { - return true; - } - // Check for backtick command substitution - if (c === "`") { - return true; - } - i++; - } - return false; -} - -/** - * Find the matching closing parenthesis for a command substitution. - * Handles nested parentheses, quotes, and escapes. - * Returns the index of the closing ), or -1 if not found. - */ -function findCommandSubstitutionEnd(pattern: string, startIdx: number): number { - let depth = 1; - let i = startIdx; - let inSingleQuote = false; - let inDoubleQuote = false; - - while (i < pattern.length && depth > 0) { - const c = pattern[i]; - - // Handle escapes (only outside single quotes) - if (c === "\\" && !inSingleQuote && i + 1 < pattern.length) { - i += 2; - continue; - } - - // Handle single quotes (only outside double quotes) - if (c === "'" && !inDoubleQuote) { - inSingleQuote = !inSingleQuote; - i++; - continue; - } - - // Handle double quotes (only outside single quotes) - if (c === '"' && !inSingleQuote) { - inDoubleQuote = !inDoubleQuote; - i++; - continue; - } - - // Handle parentheses (only outside quotes) - if (!inSingleQuote && !inDoubleQuote) { - if (c === "(") { - depth++; - } else if (c === ")") { - depth--; - if (depth === 0) { - return i; - } - } - } - - i++; - } - - return -1; -} - -/** - * Execute a command substitution from a raw command string. - * Parses and executes the command, returning stdout with trailing newlines stripped. - */ -async function executeCommandSubstitutionFromString( - ctx: InterpreterContext, - commandStr: string, -): Promise { - // Parse the command - const parser = new Parser(); - let ast: ScriptNode; - try { - ast = parser.parse(commandStr); - } catch { - // Parse error - return empty string - return ""; - } - - // Execute in subshell-like context - const savedBashPid = ctx.state.bashPid; - ctx.state.bashPid = ctx.state.nextVirtualPid++; - const savedEnv = new Map(ctx.state.env); - const savedCwd = ctx.state.cwd; - const savedSuppressVerbose = ctx.state.suppressVerbose; - ctx.state.suppressVerbose = true; - - try { - const result = await ctx.executeScript(ast); - // Restore environment but preserve exit code - const exitCode = result.exitCode; - ctx.state.env = savedEnv; - ctx.state.cwd = savedCwd; - ctx.state.suppressVerbose = savedSuppressVerbose; - ctx.state.lastExitCode = exitCode; - ctx.state.env.set("?", String(exitCode)); - if (result.stderr) { - ctx.state.expansionStderr = - (ctx.state.expansionStderr || "") + result.stderr; - } - ctx.state.bashPid = savedBashPid; - return result.stdout.replace(/\n+$/, ""); - } catch (error) { - ctx.state.env = savedEnv; - ctx.state.cwd = savedCwd; - ctx.state.bashPid = savedBashPid; - ctx.state.suppressVerbose = savedSuppressVerbose; - if (error instanceof ExecutionLimitError) { - throw error; - } - if (error instanceof ExitError) { - ctx.state.lastExitCode = error.exitCode; - ctx.state.env.set("?", String(error.exitCode)); - return error.stdout?.replace(/\n+$/, "") ?? ""; - } - return ""; - } -} - -/** - * Expand variables within a glob/extglob pattern string. - * This handles patterns like @($var|$other) where variables need expansion. - * Also handles quoted strings inside patterns (e.g., @(foo|'bar'|"$baz")). - * Preserves pattern metacharacters while expanding $var and ${var} references. - */ -export function expandVariablesInPattern( - ctx: InterpreterContext, - pattern: string, -): string { - let result = ""; - let i = 0; - - while (i < pattern.length) { - const c = pattern[i]; - - // Handle single-quoted strings - content is literal, strip quotes, escape glob chars - if (c === "'") { - const closeIdx = pattern.indexOf("'", i + 1); - if (closeIdx !== -1) { - const content = pattern.slice(i + 1, closeIdx); - // Escape glob metacharacters so they match literally - result += escapeGlobChars(content); - i = closeIdx + 1; - continue; - } - } - - // Handle double-quoted strings - expand variables inside, strip quotes, escape glob chars - if (c === '"') { - // Find matching close quote, handling escapes - let closeIdx = -1; - let j = i + 1; - while (j < pattern.length) { - if (pattern[j] === "\\") { - j += 2; // Skip escaped char - continue; - } - if (pattern[j] === '"') { - closeIdx = j; - break; - } - j++; - } - if (closeIdx !== -1) { - const content = pattern.slice(i + 1, closeIdx); - // Recursively expand variables in the double-quoted content - // but without the quote handling (pass through all other chars) - const expanded = expandVariablesInDoubleQuotedPattern(ctx, content); - // Escape glob metacharacters so they match literally - result += escapeGlobChars(expanded); - i = closeIdx + 1; - continue; - } - } - - // Handle variable references: $var or ${var} - if (c === "$") { - if (i + 1 < pattern.length) { - const next = pattern[i + 1]; - if (next === "{") { - // ${var} form - find matching } - const closeIdx = pattern.indexOf("}", i + 2); - if (closeIdx !== -1) { - const varName = pattern.slice(i + 2, closeIdx); - // Simple variable expansion (no complex operations) - result += ctx.state.env.get(varName) ?? ""; - i = closeIdx + 1; - continue; - } - } else if (/[a-zA-Z_]/.test(next)) { - // $var form - read variable name - let end = i + 1; - while (end < pattern.length && /[a-zA-Z0-9_]/.test(pattern[end])) { - end++; - } - const varName = pattern.slice(i + 1, end); - result += ctx.state.env.get(varName) ?? ""; - i = end; - continue; - } - } - } - - // Handle backslash escapes - preserve them - if (c === "\\" && i + 1 < pattern.length) { - result += c + pattern[i + 1]; - i += 2; - continue; - } - - // All other characters pass through unchanged - result += c; - i++; - } - - return result; -} - -/** - * Expand variables within a double-quoted string inside a pattern. - * Handles $var and ${var} but not nested quotes. - */ -function expandVariablesInDoubleQuotedPattern( - ctx: InterpreterContext, - content: string, -): string { - let result = ""; - let i = 0; - - while (i < content.length) { - const c = content[i]; - - // Handle backslash escapes - if (c === "\\" && i + 1 < content.length) { - const next = content[i + 1]; - // In double quotes, only $, `, \, ", and newline are special after \ - if (next === "$" || next === "`" || next === "\\" || next === '"') { - result += next; - i += 2; - continue; - } - // Other escapes pass through as-is - result += c; - i++; - continue; - } - - // Handle variable references: $var or ${var} - if (c === "$") { - if (i + 1 < content.length) { - const next = content[i + 1]; - if (next === "{") { - // ${var} form - find matching } - const closeIdx = content.indexOf("}", i + 2); - if (closeIdx !== -1) { - const varName = content.slice(i + 2, closeIdx); - result += ctx.state.env.get(varName) ?? ""; - i = closeIdx + 1; - continue; - } - } else if (/[a-zA-Z_]/.test(next)) { - // $var form - read variable name - let end = i + 1; - while (end < content.length && /[a-zA-Z0-9_]/.test(content[end])) { - end++; - } - const varName = content.slice(i + 1, end); - result += ctx.state.env.get(varName) ?? ""; - i = end; - continue; - } - } - } - - // All other characters pass through unchanged - result += c; - i++; - } - - return result; -} - -/** - * Async version of expandVariablesInPattern that handles command substitutions. - * This handles patterns like @($var|$(echo foo)) where command substitutions need expansion. - */ -export async function expandVariablesInPatternAsync( - ctx: InterpreterContext, - pattern: string, -): Promise { - let result = ""; - let i = 0; - - while (i < pattern.length) { - const c = pattern[i]; - - // Handle single-quoted strings - content is literal, strip quotes, escape glob chars - if (c === "'") { - const closeIdx = pattern.indexOf("'", i + 1); - if (closeIdx !== -1) { - const content = pattern.slice(i + 1, closeIdx); - // Escape glob metacharacters so they match literally - result += escapeGlobChars(content); - i = closeIdx + 1; - continue; - } - } - - // Handle double-quoted strings - expand variables inside, strip quotes, escape glob chars - if (c === '"') { - // Find matching close quote, handling escapes - let closeIdx = -1; - let j = i + 1; - while (j < pattern.length) { - if (pattern[j] === "\\") { - j += 2; // Skip escaped char - continue; - } - if (pattern[j] === '"') { - closeIdx = j; - break; - } - j++; - } - if (closeIdx !== -1) { - const content = pattern.slice(i + 1, closeIdx); - // Recursively expand (including command substitutions) in the double-quoted content - const expanded = await expandVariablesInDoubleQuotedPatternAsync( - ctx, - content, - ); - // Escape glob metacharacters so they match literally - result += escapeGlobChars(expanded); - i = closeIdx + 1; - continue; - } - } - - // Handle command substitution: $(...) - if (c === "$" && i + 1 < pattern.length && pattern[i + 1] === "(") { - const closeIdx = findCommandSubstitutionEnd(pattern, i + 2); - if (closeIdx !== -1) { - const commandStr = pattern.slice(i + 2, closeIdx); - // Execute the command substitution - const output = await executeCommandSubstitutionFromString( - ctx, - commandStr, - ); - result += output; - i = closeIdx + 1; - continue; - } - } - - // Handle backtick command substitution: `...` - if (c === "`") { - const closeIdx = pattern.indexOf("`", i + 1); - if (closeIdx !== -1) { - const commandStr = pattern.slice(i + 1, closeIdx); - // Execute the command substitution - const output = await executeCommandSubstitutionFromString( - ctx, - commandStr, - ); - result += output; - i = closeIdx + 1; - continue; - } - } - - // Handle variable references: $var or ${var} - if (c === "$") { - if (i + 1 < pattern.length) { - const next = pattern[i + 1]; - if (next === "{") { - // ${var} form - find matching } - const closeIdx = pattern.indexOf("}", i + 2); - if (closeIdx !== -1) { - const varName = pattern.slice(i + 2, closeIdx); - // Simple variable expansion (no complex operations) - result += ctx.state.env.get(varName) ?? ""; - i = closeIdx + 1; - continue; - } - } else if (/[a-zA-Z_]/.test(next)) { - // $var form - read variable name - let end = i + 1; - while (end < pattern.length && /[a-zA-Z0-9_]/.test(pattern[end])) { - end++; - } - const varName = pattern.slice(i + 1, end); - result += ctx.state.env.get(varName) ?? ""; - i = end; - continue; - } - } - } - - // Handle backslash escapes - preserve them - if (c === "\\" && i + 1 < pattern.length) { - result += c + pattern[i + 1]; - i += 2; - continue; - } - - // All other characters pass through unchanged - result += c; - i++; - } - - return result; -} - -/** - * Async version of expandVariablesInDoubleQuotedPattern that handles command substitutions. - */ -async function expandVariablesInDoubleQuotedPatternAsync( - ctx: InterpreterContext, - content: string, -): Promise { - let result = ""; - let i = 0; - - while (i < content.length) { - const c = content[i]; - - // Handle backslash escapes - if (c === "\\" && i + 1 < content.length) { - const next = content[i + 1]; - // In double quotes, only $, `, \, ", and newline are special after \ - if (next === "$" || next === "`" || next === "\\" || next === '"') { - result += next; - i += 2; - continue; - } - // Other escapes pass through as-is - result += c; - i++; - continue; - } - - // Handle command substitution: $(...) - if (c === "$" && i + 1 < content.length && content[i + 1] === "(") { - const closeIdx = findCommandSubstitutionEnd(content, i + 2); - if (closeIdx !== -1) { - const commandStr = content.slice(i + 2, closeIdx); - const output = await executeCommandSubstitutionFromString( - ctx, - commandStr, - ); - result += output; - i = closeIdx + 1; - continue; - } - } - - // Handle backtick command substitution: `...` - if (c === "`") { - const closeIdx = content.indexOf("`", i + 1); - if (closeIdx !== -1) { - const commandStr = content.slice(i + 1, closeIdx); - const output = await executeCommandSubstitutionFromString( - ctx, - commandStr, - ); - result += output; - i = closeIdx + 1; - continue; - } - } - - // Handle variable references: $var or ${var} - if (c === "$") { - if (i + 1 < content.length) { - const next = content[i + 1]; - if (next === "{") { - // ${var} form - find matching } - const closeIdx = content.indexOf("}", i + 2); - if (closeIdx !== -1) { - const varName = content.slice(i + 2, closeIdx); - result += ctx.state.env.get(varName) ?? ""; - i = closeIdx + 1; - continue; - } - } else if (/[a-zA-Z_]/.test(next)) { - // $var form - read variable name - let end = i + 1; - while (end < content.length && /[a-zA-Z0-9_]/.test(content[end])) { - end++; - } - const varName = content.slice(i + 1, end); - result += ctx.state.env.get(varName) ?? ""; - i = end; - continue; - } - } - } - - // All other characters pass through unchanged - result += c; - i++; - } - - return result; -} diff --git a/src/interpreter/expansion/pattern-removal.ts b/src/interpreter/expansion/pattern-removal.ts deleted file mode 100644 index f8e78e30..00000000 --- a/src/interpreter/expansion/pattern-removal.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Pattern Removal Helpers - * - * Functions for ${var#pattern}, ${var%pattern}, ${!prefix*} etc. - */ - -import { createUserRegex } from "../../regex/index.js"; -import type { InterpreterContext } from "../types.js"; - -/** - * Apply pattern removal (prefix or suffix strip) to a single value. - * Used by both scalar and vectorized array operations. - */ -export function applyPatternRemoval( - value: string, - regexStr: string, - side: "prefix" | "suffix", - greedy: boolean, -): string { - // Use 's' flag (dotall) so that . matches newlines (bash ? matches any char including newline) - if (side === "prefix") { - // Prefix removal: greedy matches longest from start, non-greedy matches shortest - return createUserRegex(`^${regexStr}`, "s").replace(value, ""); - } - // Suffix removal needs special handling because we need to find - // the rightmost (shortest) or leftmost (longest) match - const regex = createUserRegex(`${regexStr}$`, "s"); - if (greedy) { - // %% - longest match: use regex directly (finds leftmost match) - return regex.replace(value, ""); - } - // % - shortest match: find rightmost position where pattern matches to end - for (let i = value.length; i >= 0; i--) { - const suffix = value.slice(i); - if (regex.test(suffix)) { - return value.slice(0, i); - } - } - return value; -} - -/** - * Get variable names that match a given prefix. - * Used for ${!prefix*} and ${!prefix@} expansions. - * Handles arrays properly - includes array base names from __length markers, - * excludes internal storage keys like arr_0, arr__length. - */ -export function getVarNamesWithPrefix( - ctx: InterpreterContext, - prefix: string, -): string[] { - const envKeys = Array.from(ctx.state.env.keys()); - const matchingVars = new Set(); - - // Get sets of array names for filtering - const assocArrays = ctx.state.associativeArrays ?? new Set(); - const indexedArrays = new Set(); - // Find indexed arrays by looking for _\d+$ patterns - for (const k of envKeys) { - const match = k.match(/^([a-zA-Z_][a-zA-Z0-9_]*)_\d+$/); - if (match) { - indexedArrays.add(match[1]); - } - const lengthMatch = k.match(/^([a-zA-Z_][a-zA-Z0-9_]*)__length$/); - if (lengthMatch) { - indexedArrays.add(lengthMatch[1]); - } - } - - // Helper to check if a key is an associative array element - const isAssocArrayElement = (key: string): boolean => { - for (const arrayName of assocArrays) { - const elemPrefix = `${arrayName}_`; - if (key.startsWith(elemPrefix) && key !== arrayName) { - return true; - } - } - return false; - }; - - for (const k of envKeys) { - if (k.startsWith(prefix)) { - // Check if this is an internal array storage key - if (k.includes("__")) { - // For __length markers, add the base array name - const lengthMatch = k.match(/^([a-zA-Z_][a-zA-Z0-9_]*)__length$/); - if (lengthMatch?.[1].startsWith(prefix)) { - matchingVars.add(lengthMatch[1]); - } - // Skip other internal markers - } else if (/_\d+$/.test(k)) { - // Skip indexed array element storage (arr_0) - // But add the base array name if it matches - const match = k.match(/^([a-zA-Z_][a-zA-Z0-9_]*)_\d+$/); - if (match?.[1].startsWith(prefix)) { - matchingVars.add(match[1]); - } - } else if (isAssocArrayElement(k)) { - // Skip associative array elements - } else { - // Regular variable - matchingVars.add(k); - } - } - } - - return [...matchingVars].sort(); -} diff --git a/src/interpreter/expansion/pattern.ts b/src/interpreter/expansion/pattern.ts deleted file mode 100644 index 6c4ba2e6..00000000 --- a/src/interpreter/expansion/pattern.ts +++ /dev/null @@ -1,357 +0,0 @@ -/** - * Pattern Matching - * - * Converts shell glob patterns to regex equivalents for pattern matching - * in parameter expansion (${var%pattern}, ${var/pattern/replacement}, etc.) - * and case statements. - * - * ## Error Handling - * - * This module follows bash's behavior for invalid patterns: - * - Invalid character ranges (e.g., `[z-a]`) result in regex compilation failure - * - Unknown POSIX classes (e.g., `[:foo:]`) produce empty match groups - * - Unclosed character classes (`[abc`) are treated as literal `[` - * - * Callers should wrap regex compilation in try/catch to handle invalid patterns. - */ - -/** - * Convert a shell glob pattern to a regex string. - * @param pattern - The glob pattern (*, ?, [...]) - * @param greedy - Whether * should be greedy (true for suffix matching, false for prefix) - * @param extglob - Whether to support extended glob patterns (@(...), *(...), +(...), ?(...), !(...)) - */ -export function patternToRegex( - pattern: string, - greedy: boolean, - extglob = false, -): string { - let regex = ""; - let i = 0; - - while (i < pattern.length) { - const char = pattern[i]; - - // Check for extglob patterns: @(...), *(...), +(...), ?(...), !(...) - if ( - extglob && - (char === "@" || - char === "*" || - char === "+" || - char === "?" || - char === "!") && - i + 1 < pattern.length && - pattern[i + 1] === "(" - ) { - // Find the matching closing paren (handle nesting) - const closeIdx = findMatchingParen(pattern, i + 1); - if (closeIdx !== -1) { - const content = pattern.slice(i + 2, closeIdx); - // Split on | but handle nested extglob patterns - const alternatives = splitExtglobAlternatives(content); - // Convert each alternative recursively - const altRegexes = alternatives.map((alt) => - patternToRegex(alt, greedy, extglob), - ); - const altGroup = altRegexes.length > 0 ? altRegexes.join("|") : "(?:)"; - - if (char === "@") { - // @(...) - match exactly one of the patterns - regex += `(?:${altGroup})`; - } else if (char === "*") { - // *(...) - match zero or more occurrences - regex += `(?:${altGroup})*`; - } else if (char === "+") { - // +(...) - match one or more occurrences - regex += `(?:${altGroup})+`; - } else if (char === "?") { - // ?(...) - match zero or one occurrence - regex += `(?:${altGroup})?`; - } else if (char === "!") { - // !(...) - match anything except the patterns - // This is tricky - we need a negative lookahead anchored to the end - regex += `(?!(?:${altGroup})$).*`; - } - i = closeIdx + 1; - continue; - } - } - - if (char === "\\") { - // Shell escape: \X means literal X - if (i + 1 < pattern.length) { - const next = pattern[i + 1]; - // Escape for regex if it's a regex special char - if (/[\\^$.|+(){}[\]*?]/.test(next)) { - regex += `\\${next}`; - } else { - regex += next; - } - i += 2; - } else { - // Trailing backslash - treat as literal - regex += "\\\\"; - i++; - } - } else if (char === "*") { - regex += greedy ? ".*" : ".*?"; - i++; - } else if (char === "?") { - regex += "."; - i++; - } else if (char === "[") { - // Character class - find the matching ] - const classEnd = findCharClassEnd(pattern, i); - if (classEnd === -1) { - // No matching ], escape the [ - regex += "\\["; - i++; - } else { - // Extract and convert the character class - const classContent = pattern.slice(i + 1, classEnd); - regex += convertCharClass(classContent); - i = classEnd + 1; - } - } else if (/[\^$.|+(){}]/.test(char)) { - // Escape regex special chars (but NOT [ and ] - handled above, and NOT \\ - handled above) - regex += `\\${char}`; - i++; - } else { - regex += char; - i++; - } - } - return regex; -} - -/** - * Find the matching closing parenthesis, handling nesting - */ -function findMatchingParen(pattern: string, openIdx: number): number { - let depth = 1; - let i = openIdx + 1; - while (i < pattern.length && depth > 0) { - const c = pattern[i]; - if (c === "\\") { - i += 2; // Skip escaped char - continue; - } - if (c === "(") { - depth++; - } else if (c === ")") { - depth--; - if (depth === 0) { - return i; - } - } - i++; - } - return -1; -} - -/** - * Split extglob pattern content on | handling nested patterns - */ -function splitExtglobAlternatives(content: string): string[] { - const alternatives: string[] = []; - let current = ""; - let depth = 0; - let i = 0; - - while (i < content.length) { - const c = content[i]; - if (c === "\\") { - // Escaped character - current += c; - if (i + 1 < content.length) { - current += content[i + 1]; - i += 2; - } else { - i++; - } - continue; - } - if (c === "(") { - depth++; - current += c; - } else if (c === ")") { - depth--; - current += c; - } else if (c === "|" && depth === 0) { - alternatives.push(current); - current = ""; - } else { - current += c; - } - i++; - } - alternatives.push(current); - return alternatives; -} - -/** - * Find the end of a character class starting at position i (where pattern[i] is '[') - */ -function findCharClassEnd(pattern: string, start: number): number { - let i = start + 1; - - // Handle negation - if (i < pattern.length && pattern[i] === "^") { - i++; - } - - // A ] immediately after [ or [^ is literal, not closing - if (i < pattern.length && pattern[i] === "]") { - i++; - } - - while (i < pattern.length) { - // Handle escape sequences - \] should not end the class - if (pattern[i] === "\\" && i + 1 < pattern.length) { - i += 2; // Skip both the backslash and the escaped character - continue; - } - - if (pattern[i] === "]") { - return i; - } - - // Handle single quotes inside character class (bash extension) - if (pattern[i] === "'") { - const closeQuote = pattern.indexOf("'", i + 1); - if (closeQuote !== -1) { - i = closeQuote + 1; - continue; - } - } - - // Handle POSIX classes [:name:] - if ( - pattern[i] === "[" && - i + 1 < pattern.length && - pattern[i + 1] === ":" - ) { - const closePos = pattern.indexOf(":]", i + 2); - if (closePos !== -1) { - i = closePos + 2; - continue; - } - } - i++; - } - return -1; -} - -/** - * Convert a shell character class content to regex equivalent. - * Input is the content inside [...], e.g., ":alpha:" for [[:alpha:]] - */ -function convertCharClass(content: string): string { - let result = "["; - let i = 0; - - // Handle negation - if (content[0] === "^" || content[0] === "!") { - result += "^"; - i++; - } - - while (i < content.length) { - // Handle single quotes inside character class (bash extension) - // '...' makes its content literal, including ] and - - if (content[i] === "'") { - const closeQuote = content.indexOf("'", i + 1); - if (closeQuote !== -1) { - // Add quoted content as literal characters - const quoted = content.slice(i + 1, closeQuote); - for (const ch of quoted) { - // Escape regex special chars inside character class - if (ch === "\\") { - result += "\\\\"; - } else if (ch === "]") { - result += "\\]"; - } else if (ch === "^" && result === "[") { - result += "\\^"; - } else { - result += ch; - } - } - i = closeQuote + 1; - continue; - } - } - - // Handle POSIX classes like [:alpha:] - if ( - content[i] === "[" && - i + 1 < content.length && - content[i + 1] === ":" - ) { - const closePos = content.indexOf(":]", i + 2); - if (closePos !== -1) { - const posixClass = content.slice(i + 2, closePos); - result += posixClassToRegex(posixClass); - i = closePos + 2; - continue; - } - } - - // Handle literal characters (escape regex special chars inside class) - const char = content[i]; - if (char === "\\") { - // Escape sequence - if (i + 1 < content.length) { - result += `\\${content[i + 1]}`; - i += 2; - } else { - result += "\\\\"; - i++; - } - } else if (char === "-" && i > 0 && i < content.length - 1) { - // Range separator - result += "-"; - i++; - } else if (char === "^" && i === 0) { - // Negation at start - result += "^"; - i++; - } else { - // Regular character - some need escaping in regex char class - if (char === "]" && i === 0) { - result += "\\]"; - } else { - result += char; - } - i++; - } - } - - result += "]"; - return result; -} - -/** Valid POSIX character class names (Map prevents prototype pollution) */ -const POSIX_CLASSES = new Map([ - ["alnum", "a-zA-Z0-9"], - ["alpha", "a-zA-Z"], - ["ascii", "\\x00-\\x7F"], - ["blank", " \\t"], - ["cntrl", "\\x00-\\x1F\\x7F"], - ["digit", "0-9"], - ["graph", "!-~"], - ["lower", "a-z"], - ["print", " -~"], - ["punct", "!-/:-@\\[-`{-~"], - ["space", " \\t\\n\\r\\f\\v"], - ["upper", "A-Z"], - ["word", "a-zA-Z0-9_"], - ["xdigit", "0-9A-Fa-f"], -]); - -/** - * Convert POSIX character class name to regex equivalent. - * Returns empty string for unknown class names (matches bash behavior). - */ -function posixClassToRegex(name: string): string { - return POSIX_CLASSES.get(name) ?? ""; -} diff --git a/src/interpreter/expansion/positional-params.ts b/src/interpreter/expansion/positional-params.ts deleted file mode 100644 index 02c84fcf..00000000 --- a/src/interpreter/expansion/positional-params.ts +++ /dev/null @@ -1,565 +0,0 @@ -/** - * Positional Parameter Expansion Handlers - * - * Handles $@ and $* expansion with various operations: - * - "${@:offset}" and "${*:offset}" - slicing - * - "${@/pattern/replacement}" - pattern replacement - * - "${@#pattern}" - pattern removal (strip) - * - "$@" and "$*" with adjacent text - */ - -import type { - ParameterExpansionPart, - SubstringOp, - WordNode, - WordPart, -} from "../../ast/types.js"; -import { createUserRegex } from "../../regex/index.js"; -import { getIfsSeparator } from "../helpers/ifs.js"; -import { escapeRegex } from "../helpers/regex.js"; -import type { InterpreterContext } from "../types.js"; -import { patternToRegex } from "./pattern.js"; -import { applyPatternRemoval } from "./pattern-removal.js"; - -/** - * Result type for positional parameter expansion handlers. - * `null` means the handler doesn't apply to this case. - */ -export type PositionalExpansionResult = { - values: string[]; - quoted: boolean; -} | null; - -import type { ArithExpr } from "../../ast/types.js"; - -/** - * Type for evaluateArithmetic function - */ -export type EvaluateArithmeticFn = ( - ctx: InterpreterContext, - expr: ArithExpr, - isExpansionContext?: boolean, -) => Promise; - -/** - * Type for expandPart function - */ -export type ExpandPartFn = ( - ctx: InterpreterContext, - part: WordPart, -) => Promise; - -/** - * Type for expandWordPartsAsync function - */ -export type ExpandWordPartsAsyncFn = ( - ctx: InterpreterContext, - parts: WordPart[], -) => Promise; - -/** - * Get positional parameters from context - */ -function getPositionalParams(ctx: InterpreterContext): string[] { - const numParams = Number.parseInt(ctx.state.env.get("#") || "0", 10); - const params: string[] = []; - for (let i = 1; i <= numParams; i++) { - params.push(ctx.state.env.get(String(i)) || ""); - } - return params; -} - -/** - * Handle "${@:offset}" and "${*:offset}" with Substring operations inside double quotes - * "${@:offset}": Each sliced positional parameter becomes a separate word - * "${*:offset}": All sliced params joined with IFS as ONE word - */ -export async function handlePositionalSlicing( - ctx: InterpreterContext, - wordParts: WordPart[], - evaluateArithmetic: EvaluateArithmeticFn, - expandPart: ExpandPartFn, -): Promise { - if (wordParts.length !== 1 || wordParts[0].type !== "DoubleQuoted") { - return null; - } - - const dqPart = wordParts[0]; - // Find if there's a ${@:offset} or ${*:offset} inside - let sliceAtIndex = -1; - let sliceIsStar = false; - for (let i = 0; i < dqPart.parts.length; i++) { - const p = dqPart.parts[i]; - if ( - p.type === "ParameterExpansion" && - (p.parameter === "@" || p.parameter === "*") && - p.operation?.type === "Substring" - ) { - sliceAtIndex = i; - sliceIsStar = p.parameter === "*"; - break; - } - } - - if (sliceAtIndex === -1) { - return null; - } - - const paramPart = dqPart.parts[sliceAtIndex] as ParameterExpansionPart; - const operation = paramPart.operation as SubstringOp; - - // Evaluate offset and length - const offset = operation.offset - ? await evaluateArithmetic(ctx, operation.offset.expression) - : 0; - const length = operation.length - ? await evaluateArithmetic(ctx, operation.length.expression) - : undefined; - - // Get positional parameters - const numParams = Number.parseInt(ctx.state.env.get("#") || "0", 10); - const allParams: string[] = []; - for (let i = 1; i <= numParams; i++) { - allParams.push(ctx.state.env.get(String(i)) || ""); - } - - const shellName = ctx.state.env.get("0") || "bash"; - - // Build sliced params array - let slicedParams: string[]; - if (offset <= 0) { - // offset 0: include $0 at position 0 - const withZero = [shellName, ...allParams]; - const computedIdx = withZero.length + offset; - // If negative offset goes beyond array bounds, return empty - if (computedIdx < 0) { - slicedParams = []; - } else { - const startIdx = offset < 0 ? computedIdx : 0; - if (length !== undefined) { - const endIdx = - length < 0 ? withZero.length + length : startIdx + length; - slicedParams = withZero.slice(startIdx, Math.max(startIdx, endIdx)); - } else { - slicedParams = withZero.slice(startIdx); - } - } - } else { - // offset > 0: start from $ - const startIdx = offset - 1; - if (startIdx >= allParams.length) { - slicedParams = []; - } else if (length !== undefined) { - const endIdx = length < 0 ? allParams.length + length : startIdx + length; - slicedParams = allParams.slice(startIdx, Math.max(startIdx, endIdx)); - } else { - slicedParams = allParams.slice(startIdx); - } - } - - // Expand prefix (parts before ${@:...}) - let prefix = ""; - for (let i = 0; i < sliceAtIndex; i++) { - prefix += await expandPart(ctx, dqPart.parts[i]); - } - - // Expand suffix (parts after ${@:...}) - let suffix = ""; - for (let i = sliceAtIndex + 1; i < dqPart.parts.length; i++) { - suffix += await expandPart(ctx, dqPart.parts[i]); - } - - if (slicedParams.length === 0) { - // No params after slicing -> prefix + suffix as one word - const combined = prefix + suffix; - return { values: combined ? [combined] : [], quoted: true }; - } - - if (sliceIsStar) { - // "${*:offset}" - join all sliced params with IFS into one word - const ifsSep = getIfsSeparator(ctx.state.env); - return { - values: [prefix + slicedParams.join(ifsSep) + suffix], - quoted: true, - }; - } - - // "${@:offset}" - each sliced param is a separate word - if (slicedParams.length === 1) { - return { - values: [prefix + slicedParams[0] + suffix], - quoted: true, - }; - } - - const result = [ - prefix + slicedParams[0], - ...slicedParams.slice(1, -1), - slicedParams[slicedParams.length - 1] + suffix, - ]; - return { values: result, quoted: true }; -} - -/** - * Handle "${@/pattern/replacement}" and "${* /pattern/replacement}" with PatternReplacement inside double quotes - * "${@/pattern/replacement}": Each positional parameter has pattern replaced, each becomes a separate word - * "${* /pattern/replacement}": All params joined with IFS, pattern replaced, becomes ONE word - */ -export async function handlePositionalPatternReplacement( - ctx: InterpreterContext, - wordParts: WordPart[], - expandPart: ExpandPartFn, - expandWordPartsAsync: ExpandWordPartsAsyncFn, -): Promise { - if (wordParts.length !== 1 || wordParts[0].type !== "DoubleQuoted") { - return null; - } - - const dqPart = wordParts[0]; - // Find if there's a ${@/...} or ${*/...} inside - let patReplAtIndex = -1; - let patReplIsStar = false; - for (let i = 0; i < dqPart.parts.length; i++) { - const p = dqPart.parts[i]; - if ( - p.type === "ParameterExpansion" && - (p.parameter === "@" || p.parameter === "*") && - p.operation?.type === "PatternReplacement" - ) { - patReplAtIndex = i; - patReplIsStar = p.parameter === "*"; - break; - } - } - - if (patReplAtIndex === -1) { - return null; - } - - const paramPart = dqPart.parts[patReplAtIndex] as ParameterExpansionPart; - const operation = paramPart.operation as { - type: "PatternReplacement"; - pattern: WordNode; - replacement: WordNode | null; - all: boolean; - anchor: "start" | "end" | null; - }; - - // Get positional parameters - const params = getPositionalParams(ctx); - - // Expand prefix (parts before ${@/...}) - let prefix = ""; - for (let i = 0; i < patReplAtIndex; i++) { - prefix += await expandPart(ctx, dqPart.parts[i]); - } - - // Expand suffix (parts after ${@/...}) - let suffix = ""; - for (let i = patReplAtIndex + 1; i < dqPart.parts.length; i++) { - suffix += await expandPart(ctx, dqPart.parts[i]); - } - - if (params.length === 0) { - const combined = prefix + suffix; - return { values: combined ? [combined] : [], quoted: true }; - } - - // Build the replacement regex - let regex = ""; - if (operation.pattern) { - for (const part of operation.pattern.parts) { - if (part.type === "Glob") { - regex += patternToRegex( - part.pattern, - true, - ctx.state.shoptOptions.extglob, - ); - } else if (part.type === "Literal") { - regex += patternToRegex( - part.value, - true, - ctx.state.shoptOptions.extglob, - ); - } else if (part.type === "SingleQuoted" || part.type === "Escaped") { - regex += escapeRegex(part.value); - } else if (part.type === "DoubleQuoted") { - const expanded = await expandWordPartsAsync(ctx, part.parts); - regex += escapeRegex(expanded); - } else if (part.type === "ParameterExpansion") { - const expanded = await expandPart(ctx, part); - regex += patternToRegex(expanded, true, ctx.state.shoptOptions.extglob); - } else { - const expanded = await expandPart(ctx, part); - regex += escapeRegex(expanded); - } - } - } - - const replacement = operation.replacement - ? await expandWordPartsAsync(ctx, operation.replacement.parts) - : ""; - - // Apply anchor modifiers - let regexPattern = regex; - if (operation.anchor === "start") { - regexPattern = `^${regex}`; - } else if (operation.anchor === "end") { - regexPattern = `${regex}$`; - } - - // Apply replacement to each param - const replacedParams: string[] = []; - try { - const re = createUserRegex(regexPattern, operation.all ? "g" : ""); - for (const param of params) { - replacedParams.push(re.replace(param, replacement)); - } - } catch { - // Invalid regex - return params unchanged - replacedParams.push(...params); - } - - if (patReplIsStar) { - // "${*/...}" - join all params with IFS into one word - const ifsSep = getIfsSeparator(ctx.state.env); - return { - values: [prefix + replacedParams.join(ifsSep) + suffix], - quoted: true, - }; - } - - // "${@/...}" - each param is a separate word - if (replacedParams.length === 1) { - return { - values: [prefix + replacedParams[0] + suffix], - quoted: true, - }; - } - - const result = [ - prefix + replacedParams[0], - ...replacedParams.slice(1, -1), - replacedParams[replacedParams.length - 1] + suffix, - ]; - return { values: result, quoted: true }; -} - -/** - * Handle "${@#pattern}" and "${*#pattern}" - positional parameter pattern removal (strip) - * "${@#pattern}": Remove shortest matching prefix from each parameter, each becomes a separate word - * "${@##pattern}": Remove longest matching prefix from each parameter - * "${@%pattern}": Remove shortest matching suffix from each parameter - * "${@%%pattern}": Remove longest matching suffix from each parameter - */ -export async function handlePositionalPatternRemoval( - ctx: InterpreterContext, - wordParts: WordPart[], - expandPart: ExpandPartFn, - expandWordPartsAsync: ExpandWordPartsAsyncFn, -): Promise { - if (wordParts.length !== 1 || wordParts[0].type !== "DoubleQuoted") { - return null; - } - - const dqPart = wordParts[0]; - // Find if there's a ${@#...} or ${*#...} inside - let patRemAtIndex = -1; - let patRemIsStar = false; - for (let i = 0; i < dqPart.parts.length; i++) { - const p = dqPart.parts[i]; - if ( - p.type === "ParameterExpansion" && - (p.parameter === "@" || p.parameter === "*") && - p.operation?.type === "PatternRemoval" - ) { - patRemAtIndex = i; - patRemIsStar = p.parameter === "*"; - break; - } - } - - if (patRemAtIndex === -1) { - return null; - } - - const paramPart = dqPart.parts[patRemAtIndex] as ParameterExpansionPart; - const operation = paramPart.operation as { - type: "PatternRemoval"; - pattern: WordNode; - side: "prefix" | "suffix"; - greedy: boolean; - }; - - // Get positional parameters - const params = getPositionalParams(ctx); - - // Expand prefix (parts before ${@#...}) - let prefix = ""; - for (let i = 0; i < patRemAtIndex; i++) { - prefix += await expandPart(ctx, dqPart.parts[i]); - } - - // Expand suffix (parts after ${@#...}) - let suffix = ""; - for (let i = patRemAtIndex + 1; i < dqPart.parts.length; i++) { - suffix += await expandPart(ctx, dqPart.parts[i]); - } - - if (params.length === 0) { - const combined = prefix + suffix; - return { values: combined ? [combined] : [], quoted: true }; - } - - // Build the regex pattern - let regexStr = ""; - const extglob = ctx.state.shoptOptions.extglob; - if (operation.pattern) { - for (const part of operation.pattern.parts) { - if (part.type === "Glob") { - regexStr += patternToRegex(part.pattern, operation.greedy, extglob); - } else if (part.type === "Literal") { - regexStr += patternToRegex(part.value, operation.greedy, extglob); - } else if (part.type === "SingleQuoted" || part.type === "Escaped") { - regexStr += escapeRegex(part.value); - } else if (part.type === "DoubleQuoted") { - const expanded = await expandWordPartsAsync(ctx, part.parts); - regexStr += escapeRegex(expanded); - } else if (part.type === "ParameterExpansion") { - const expanded = await expandPart(ctx, part); - regexStr += patternToRegex(expanded, operation.greedy, extglob); - } else { - const expanded = await expandPart(ctx, part); - regexStr += escapeRegex(expanded); - } - } - } - - // Apply pattern removal to each param - const strippedParams: string[] = []; - for (const param of params) { - strippedParams.push( - applyPatternRemoval(param, regexStr, operation.side, operation.greedy), - ); - } - - if (patRemIsStar) { - // "${*#...}" - join all params with IFS into one word - const ifsSep = getIfsSeparator(ctx.state.env); - return { - values: [prefix + strippedParams.join(ifsSep) + suffix], - quoted: true, - }; - } - - // "${@#...}" - each param is a separate word - if (strippedParams.length === 1) { - return { - values: [prefix + strippedParams[0] + suffix], - quoted: true, - }; - } - - const result = [ - prefix + strippedParams[0], - ...strippedParams.slice(1, -1), - strippedParams[strippedParams.length - 1] + suffix, - ]; - return { values: result, quoted: true }; -} - -/** - * Handle "$@" and "$*" with adjacent text inside double quotes, e.g., "-$@-" - * "$@": Each positional parameter becomes a separate word, with prefix joined to first - * and suffix joined to last. If no params, produces nothing (or just prefix+suffix if present) - * "$*": All params joined with IFS as ONE word. If no params, produces one empty word. - */ -export async function handleSimplePositionalExpansion( - ctx: InterpreterContext, - wordParts: WordPart[], - expandPart: ExpandPartFn, -): Promise { - if (wordParts.length !== 1 || wordParts[0].type !== "DoubleQuoted") { - return null; - } - - const dqPart = wordParts[0]; - // Find if there's a $@ or $* inside - let atIndex = -1; - let isStar = false; - for (let i = 0; i < dqPart.parts.length; i++) { - const p = dqPart.parts[i]; - if ( - p.type === "ParameterExpansion" && - (p.parameter === "@" || p.parameter === "*") - ) { - atIndex = i; - isStar = p.parameter === "*"; - break; - } - } - - if (atIndex === -1) { - return null; - } - - // Check if this is a simple $@ or $* without operations like ${*-default} - const paramPart = dqPart.parts[atIndex]; - if (paramPart.type === "ParameterExpansion" && paramPart.operation) { - // Has an operation - let normal expansion handle it - return null; - } - - // Get positional parameters - const numParams = Number.parseInt(ctx.state.env.get("#") || "0", 10); - - // Expand prefix (parts before $@/$*) - let prefix = ""; - for (let i = 0; i < atIndex; i++) { - prefix += await expandPart(ctx, dqPart.parts[i]); - } - - // Expand suffix (parts after $@/$*) - let suffix = ""; - for (let i = atIndex + 1; i < dqPart.parts.length; i++) { - suffix += await expandPart(ctx, dqPart.parts[i]); - } - - if (numParams === 0) { - if (isStar) { - // "$*" with no params -> one empty word (prefix + suffix) - return { values: [prefix + suffix], quoted: true }; - } - // "$@" with no params -> no words (unless there's prefix/suffix) - const combined = prefix + suffix; - return { values: combined ? [combined] : [], quoted: true }; - } - - // Get individual positional parameters - const params: string[] = []; - for (let i = 1; i <= numParams; i++) { - params.push(ctx.state.env.get(String(i)) || ""); - } - - if (isStar) { - // "$*" - join all params with IFS into one word - const ifsSep = getIfsSeparator(ctx.state.env); - return { - values: [prefix + params.join(ifsSep) + suffix], - quoted: true, - }; - } - - // "$@" - each param is a separate word - // Join prefix with first, suffix with last - if (params.length === 1) { - return { values: [prefix + params[0] + suffix], quoted: true }; - } - - const result = [ - prefix + params[0], - ...params.slice(1, -1), - params[params.length - 1] + suffix, - ]; - return { values: result, quoted: true }; -} diff --git a/src/interpreter/expansion/prompt.test.ts b/src/interpreter/expansion/prompt.test.ts deleted file mode 100644 index d2d4d3a6..00000000 --- a/src/interpreter/expansion/prompt.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("prompt expansion", () => { - describe("basic escapes", () => { - it("should expand \\n to newline", async () => { - const env = new Bash(); - const result = await env.exec( - "PS4=$'line1\\nline2\\n'; echo \"${PS4@P}\"", - ); - expect(result.stdout).toBe("line1\nline2\n\n"); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\r to carriage return", async () => { - const env = new Bash(); - const result = await env.exec("x=$'a\\rb'; echo \"${x@P}\""); - expect(result.stdout).toBe("a\rb\n"); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\\\ to backslash", async () => { - const env = new Bash(); - const result = await env.exec('x="a\\\\b"; echo "${x@P}"'); - expect(result.stdout).toBe("a\\b\n"); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\a to bell character", async () => { - const env = new Bash(); - const result = await env.exec('x="\\a"; echo "${x@P}"'); - expect(result.stdout).toBe("\x07\n"); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\e to escape character", async () => { - const env = new Bash(); - const result = await env.exec('x="\\e[1m"; echo "${x@P}"'); - expect(result.stdout).toBe("\x1b[1m\n"); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\$ to $ for regular user", async () => { - const env = new Bash(); - const result = await env.exec('x="prompt\\$ "; echo "${x@P}"'); - expect(result.stdout).toBe("prompt$ \n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("user and host escapes", () => { - it("should expand \\u to username", async () => { - const env = new Bash({ env: { USER: "testuser" } }); - const result = await env.exec('x="\\u"; echo "${x@P}"'); - expect(result.stdout).toBe("testuser\n"); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\h to short hostname", async () => { - const env = new Bash({ env: { HOSTNAME: "myhost.example.com" } }); - const result = await env.exec('x="\\h"; echo "${x@P}"'); - expect(result.stdout).toBe("myhost\n"); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\H to full hostname", async () => { - const env = new Bash({ env: { HOSTNAME: "myhost.example.com" } }); - const result = await env.exec('x="\\H"; echo "${x@P}"'); - expect(result.stdout).toBe("myhost.example.com\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("directory escapes", () => { - it("should expand \\w to current working directory", async () => { - const env = new Bash({ - env: { PWD: "/home/user/project", HOME: "/home/user" }, - }); - const result = await env.exec('x="\\w"; echo "${x@P}"'); - expect(result.stdout).toBe("~/project\n"); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\w to full path when not under HOME", async () => { - const env = new Bash({ env: { PWD: "/var/log", HOME: "/home/user" } }); - const result = await env.exec('x="\\w"; echo "${x@P}"'); - expect(result.stdout).toBe("/var/log\n"); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\W to basename of cwd", async () => { - const env = new Bash({ env: { PWD: "/home/user/project" } }); - const result = await env.exec('x="\\W"; echo "${x@P}"'); - expect(result.stdout).toBe("project\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("shell info escapes", () => { - it("should expand \\s to shell name", async () => { - const env = new Bash(); - const result = await env.exec('x="\\s"; echo "${x@P}"'); - expect(result.stdout).toBe("bash\n"); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\v to version major.minor", async () => { - const env = new Bash(); - const result = await env.exec('x="\\v"; echo "${x@P}"'); - expect(result.stdout).toBe("5.0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\V to full version", async () => { - const env = new Bash(); - const result = await env.exec('x="\\V"; echo "${x@P}"'); - expect(result.stdout).toBe("5.0.0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\j to number of jobs", async () => { - const env = new Bash(); - const result = await env.exec('x="\\j"; echo "${x@P}"'); - expect(result.stdout).toBe("0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\l to terminal name", async () => { - const env = new Bash(); - const result = await env.exec('x="\\l"; echo "${x@P}"'); - expect(result.stdout).toBe("tty\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("time escapes", () => { - it("should expand \\t to 24-hour time HH:MM:SS", async () => { - const env = new Bash(); - const result = await env.exec('x="\\t"; echo "${x@P}"'); - // Just verify format: HH:MM:SS - expect(result.stdout).toMatch(/^\d{2}:\d{2}:\d{2}\n$/); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\T to 12-hour time HH:MM:SS", async () => { - const env = new Bash(); - const result = await env.exec('x="\\T"; echo "${x@P}"'); - // Just verify format: HH:MM:SS (12-hour) - expect(result.stdout).toMatch(/^\d{2}:\d{2}:\d{2}\n$/); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\@ to 12-hour time with AM/PM", async () => { - const env = new Bash(); - const result = await env.exec('x="\\@"; echo "${x@P}"'); - // Format: HH:MM AM/PM - expect(result.stdout).toMatch(/^\d{2}:\d{2} [AP]M\n$/); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\A to 24-hour time HH:MM", async () => { - const env = new Bash(); - const result = await env.exec('x="\\A"; echo "${x@P}"'); - expect(result.stdout).toMatch(/^\d{2}:\d{2}\n$/); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\d to date", async () => { - const env = new Bash(); - const result = await env.exec('x="\\d"; echo "${x@P}"'); - // Format: Day Mon DD - expect(result.stdout).toMatch( - /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [ \d]\d\n$/, - ); - expect(result.exitCode).toBe(0); - }); - }); - - describe("strftime with \\D{format}", () => { - it("should expand \\D{%Y} to year", async () => { - const env = new Bash(); - const result = await env.exec('x="\\D{%Y}"; echo "${x@P}"'); - expect(result.stdout).toMatch(/^\d{4}\n$/); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\D{%m} to month", async () => { - const env = new Bash(); - const result = await env.exec('x="\\D{%m}"; echo "${x@P}"'); - expect(result.stdout).toMatch(/^\d{2}\n$/); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\D{%d} to day", async () => { - const env = new Bash(); - const result = await env.exec('x="\\D{%d}"; echo "${x@P}"'); - expect(result.stdout).toMatch(/^\d{2}\n$/); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\D{%H:%M:%S} to time", async () => { - const env = new Bash(); - const result = await env.exec('x="\\D{%H:%M:%S}"; echo "${x@P}"'); - expect(result.stdout).toMatch(/^\d{2}:\d{2}:\d{2}\n$/); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\D{} to default time format", async () => { - const env = new Bash(); - const result = await env.exec('x="\\D{}"; echo "${x@P}"'); - expect(result.stdout).toMatch(/^\d{2}:\d{2}:\d{2}\n$/); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\D{%a %b} to abbreviated day and month", async () => { - const env = new Bash(); - const result = await env.exec('x="\\D{%a %b}"; echo "${x@P}"'); - expect(result.stdout).toMatch( - /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\n$/, - ); - expect(result.exitCode).toBe(0); - }); - - it("should handle \\D without braces as literal", async () => { - const env = new Bash(); - const result = await env.exec('x="\\Dfoo"; echo "${x@P}"'); - expect(result.stdout).toBe("\\Dfoo\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("octal escapes", () => { - it("should expand \\NNN octal codes", async () => { - const env = new Bash(); - const result = await env.exec('x="\\101\\102\\103"; echo "${x@P}"'); - expect(result.stdout).toBe("ABC\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle wraparound for large octal values", async () => { - const env = new Bash(); - // \555 = 365 octal = 245 decimal, wraps to 109 = 'm' - const result = await env.exec('x="\\555"; echo "${x@P}"'); - expect(result.stdout.charCodeAt(0)).toBe(365 % 256); - }); - }); - - describe("command number escapes", () => { - it("should expand \\# to command number", async () => { - const env = new Bash({ env: { __COMMAND_NUMBER: "42" } }); - const result = await env.exec('x="cmd \\#"; echo "${x@P}"'); - expect(result.stdout).toBe("cmd 42\n"); - expect(result.exitCode).toBe(0); - }); - - it("should expand \\! to history number", async () => { - const env = new Bash({ env: { __COMMAND_NUMBER: "123" } }); - const result = await env.exec('x="hist \\!"; echo "${x@P}"'); - expect(result.stdout).toBe("hist 123\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("non-printing delimiters", () => { - it("should remove \\[ and \\] delimiters", async () => { - const env = new Bash(); - const result = await env.exec( - 'x="\\[\\e[1m\\]bold\\[\\e[0m\\]"; echo "${x@P}"', - ); - expect(result.stdout).toBe("\x1b[1mbold\x1b[0m\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("combined prompts", () => { - it("should expand complex PS1-like prompt", async () => { - const env = new Bash({ - env: { - USER: "alice", - HOSTNAME: "dev.local", - PWD: "/home/alice/project", - HOME: "/home/alice", - }, - }); - const result = await env.exec('x="\\u@\\h:\\w\\$ "; echo "${x@P}"'); - expect(result.stdout).toBe("alice@dev:~/project$ \n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle unknown escapes as literal", async () => { - const env = new Bash(); - const result = await env.exec('x="\\z\\q\\x"; echo "${x@P}"'); - // Unknown escapes pass through literally - expect(result.stdout).toBe("\\z\\q\\x\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle double backslash in prompt", async () => { - const env = new Bash(); - const result = await env.exec('x="a\\\\\\\\b"; echo "${x@P}"'); - // Each \\\\ becomes \\, then \\ becomes \ - expect(result.stdout).toBe("a\\b\n"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/interpreter/expansion/prompt.ts b/src/interpreter/expansion/prompt.ts deleted file mode 100644 index 0b231050..00000000 --- a/src/interpreter/expansion/prompt.ts +++ /dev/null @@ -1,380 +0,0 @@ -/** - * Prompt expansion - * - * Handles prompt escape sequences for ${var@P} transformation and PS1/PS2/PS3/PS4. - */ - -import type { InterpreterContext } from "../types.js"; - -/** - * Simple strftime implementation for prompt \D{format} - * Only supports common format specifiers - */ -function simpleStrftime(format: string, date: Date): string { - const pad = (n: number, width = 2) => String(n).padStart(width, "0"); - - // If format is empty, use locale default time format (like %X) - if (format === "") { - const h = pad(date.getHours()); - const m = pad(date.getMinutes()); - const s = pad(date.getSeconds()); - return `${h}:${m}:${s}`; - } - - let result = ""; - let i = 0; - while (i < format.length) { - if (format[i] === "%") { - if (i + 1 >= format.length) { - result += "%"; - i++; - continue; - } - const spec = format[i + 1]; - switch (spec) { - case "H": - result += pad(date.getHours()); - break; - case "M": - result += pad(date.getMinutes()); - break; - case "S": - result += pad(date.getSeconds()); - break; - case "d": - result += pad(date.getDate()); - break; - case "m": - result += pad(date.getMonth() + 1); - break; - case "Y": - result += date.getFullYear(); - break; - case "y": - result += pad(date.getFullYear() % 100); - break; - case "I": { - let h = date.getHours() % 12; - if (h === 0) h = 12; - result += pad(h); - break; - } - case "p": - result += date.getHours() < 12 ? "AM" : "PM"; - break; - case "P": - result += date.getHours() < 12 ? "am" : "pm"; - break; - case "%": - result += "%"; - break; - case "a": { - const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - result += days[date.getDay()]; - break; - } - case "b": { - const months = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ]; - result += months[date.getMonth()]; - break; - } - default: - // Unknown specifier - pass through - result += `%${spec}`; - } - i += 2; - } else { - result += format[i]; - i++; - } - } - return result; -} - -/** - * Expand prompt escape sequences (${var@P} transformation) - * Interprets backslash escapes used in PS1, PS2, PS3, PS4 prompt strings. - * - * Supported escapes: - * - \a - bell (ASCII 07) - * - \e - escape (ASCII 033) - * - \n - newline - * - \r - carriage return - * - \\ - literal backslash - * - \$ - $ for regular user, # for root (always $ here) - * - \[ and \] - non-printing sequence delimiters (removed) - * - \u - username - * - \h - short hostname (up to first .) - * - \H - full hostname - * - \w - current working directory - * - \W - basename of current working directory - * - \d - date (Weekday Month Day format) - * - \t - time HH:MM:SS (24-hour) - * - \T - time HH:MM:SS (12-hour) - * - \@ - time HH:MM AM/PM (12-hour) - * - \A - time HH:MM (24-hour) - * - \D{format} - strftime format - * - \s - shell name - * - \v - bash version (major.minor) - * - \V - bash version (major.minor.patch) - * - \j - number of jobs - * - \l - terminal device basename - * - \# - command number - * - \! - history number - * - \NNN - octal character code - */ -export function expandPrompt(ctx: InterpreterContext, value: string): string { - let result = ""; - let i = 0; - - // Get environment values for prompt escapes - const user = - ctx.state.env.get("USER") || ctx.state.env.get("LOGNAME") || "user"; - const hostname = ctx.state.env.get("HOSTNAME") || "localhost"; - const shortHost = hostname.split(".")[0]; - const pwd = ctx.state.env.get("PWD") || "/"; - const home = ctx.state.env.get("HOME") || "/"; - - // Replace $HOME with ~ in pwd for \w - const tildeExpanded = pwd.startsWith(home) - ? `~${pwd.slice(home.length)}` - : pwd; - const pwdBasename = pwd.split("/").pop() || pwd; - - // Get date/time values - const now = new Date(); - const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - const months = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ]; - - // Command number (we'll use a simple counter from the state if available) - const cmdNum = ctx.state.env.get("__COMMAND_NUMBER") || "1"; - - while (i < value.length) { - const char = value[i]; - - if (char === "\\") { - if (i + 1 >= value.length) { - // Trailing backslash - result += "\\"; - i++; - continue; - } - - const next = value[i + 1]; - - // Check for octal escape \NNN (1-3 digits) - if (next >= "0" && next <= "7") { - let octalStr = ""; - let j = i + 1; - while ( - j < value.length && - j < i + 4 && - value[j] >= "0" && - value[j] <= "7" - ) { - octalStr += value[j]; - j++; - } - // Parse octal, wrap around at 256 (e.g., \555 = 365 octal = 245 decimal, wraps to 109 = 'm') - const code = Number.parseInt(octalStr, 8) % 256; - result += String.fromCharCode(code); - i = j; - continue; - } - - switch (next) { - case "\\": - result += "\\"; - i += 2; - break; - case "a": - result += "\x07"; // Bell - i += 2; - break; - case "e": - result += "\x1b"; // Escape - i += 2; - break; - case "n": - result += "\n"; - i += 2; - break; - case "r": - result += "\r"; - i += 2; - break; - case "$": - // $ for regular user, # for root - we always use $ since we're not running as root - result += "$"; - i += 2; - break; - case "[": - case "]": - // Non-printing sequence delimiters - just remove them - i += 2; - break; - case "u": - result += user; - i += 2; - break; - case "h": - result += shortHost; - i += 2; - break; - case "H": - result += hostname; - i += 2; - break; - case "w": - result += tildeExpanded; - i += 2; - break; - case "W": - result += pwdBasename; - i += 2; - break; - case "d": { - // Date: Weekday Month Day - const dayStr = String(now.getDate()).padStart(2, " "); - result += `${weekdays[now.getDay()]} ${months[now.getMonth()]} ${dayStr}`; - i += 2; - break; - } - case "t": { - // Time: HH:MM:SS (24-hour) - const h = String(now.getHours()).padStart(2, "0"); - const m = String(now.getMinutes()).padStart(2, "0"); - const s = String(now.getSeconds()).padStart(2, "0"); - result += `${h}:${m}:${s}`; - i += 2; - break; - } - case "T": { - // Time: HH:MM:SS (12-hour) - let h = now.getHours() % 12; - if (h === 0) h = 12; - const hStr = String(h).padStart(2, "0"); - const m = String(now.getMinutes()).padStart(2, "0"); - const s = String(now.getSeconds()).padStart(2, "0"); - result += `${hStr}:${m}:${s}`; - i += 2; - break; - } - case "@": { - // Time: HH:MM AM/PM (12-hour) - let h = now.getHours() % 12; - if (h === 0) h = 12; - const hStr = String(h).padStart(2, "0"); - const m = String(now.getMinutes()).padStart(2, "0"); - const ampm = now.getHours() < 12 ? "AM" : "PM"; - result += `${hStr}:${m} ${ampm}`; - i += 2; - break; - } - case "A": { - // Time: HH:MM (24-hour) - const h = String(now.getHours()).padStart(2, "0"); - const m = String(now.getMinutes()).padStart(2, "0"); - result += `${h}:${m}`; - i += 2; - break; - } - case "D": - // strftime format: \D{format} - if (i + 2 < value.length && value[i + 2] === "{") { - const closeIdx = value.indexOf("}", i + 3); - if (closeIdx !== -1) { - const format = value.slice(i + 3, closeIdx); - // Simple strftime implementation for common formats - result += simpleStrftime(format, now); - i = closeIdx + 1; - } else { - // No closing brace - treat literally - result += "\\D"; - i += 2; - } - } else { - result += "\\D"; - i += 2; - } - break; - case "s": - // Shell name - result += "bash"; - i += 2; - break; - case "v": - // Version: major.minor - result += "5.0"; // Pretend to be bash 5.0 - i += 2; - break; - case "V": - // Version: major.minor.patch - result += "5.0.0"; // Pretend to be bash 5.0.0 - i += 2; - break; - case "j": - // Number of jobs - we don't track jobs, so return 0 - result += "0"; - i += 2; - break; - case "l": - // Terminal device basename - we're not in a real terminal - result += "tty"; - i += 2; - break; - case "#": - // Command number - result += cmdNum; - i += 2; - break; - case "!": - // History number - same as command number - result += cmdNum; - i += 2; - break; - case "x": - // \xNN hex literals are NOT supported in bash prompt expansion - // Just pass through as literal - result += "\\x"; - i += 2; - break; - default: - // Unknown escape - pass through as literal - result += `\\${next}`; - i += 2; - } - } else { - result += char; - i++; - } - } - - return result; -} diff --git a/src/interpreter/expansion/quoting.ts b/src/interpreter/expansion/quoting.ts deleted file mode 100644 index 2df22c28..00000000 --- a/src/interpreter/expansion/quoting.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Quoting helpers for word expansion - * - * Handles quoting values for shell reuse (${var@Q} transformation). - */ - -/** - * Quote a value for safe reuse as shell input (${var@Q} transformation) - * Uses single quotes with proper escaping for special characters. - * Follows bash's quoting behavior: - * - Simple strings without quotes: 'value' - * - Strings with single quotes or control characters: $'value' with \' escaping - */ -export function quoteValue(value: string): string { - // Empty string becomes '' - if (value === "") return "''"; - - // Check if we need $'...' format - for control characters OR single quotes - const needsDollarQuote = /[\n\r\t\x00-\x1f\x7f']/.test(value); - - if (needsDollarQuote) { - // Use $'...' format for strings with control characters or single quotes - let result = "$'"; - for (const char of value) { - switch (char) { - case "'": - result += "\\'"; - break; - case "\\": - result += "\\\\"; - break; - case "\n": - result += "\\n"; - break; - case "\r": - result += "\\r"; - break; - case "\t": - result += "\\t"; - break; - default: { - // Check for control characters - const code = char.charCodeAt(0); - if (code < 32 || code === 127) { - // Use octal escapes like bash does (not hex) - result += `\\${code.toString(8).padStart(3, "0")}`; - } else { - result += char; - } - } - } - } - return `${result}'`; - } - - // For simple strings without control characters or single quotes, use single quotes - return `'${value}'`; -} diff --git a/src/interpreter/expansion/tilde.ts b/src/interpreter/expansion/tilde.ts deleted file mode 100644 index b35a3a1a..00000000 --- a/src/interpreter/expansion/tilde.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Tilde Expansion - * - * Functions for handling tilde (~) expansion in word expansion. - */ - -import type { InterpreterContext } from "../types.js"; - -/** - * Apply tilde expansion to a string. - * Used after brace expansion to handle cases like ~{/src,root} -> ~/src ~root -> /home/user/src /root - * Only expands ~ at the start of the string followed by / or end of string. - */ -export function applyTildeExpansion( - ctx: InterpreterContext, - value: string, -): string { - if (!value.startsWith("~")) { - return value; - } - ctx.coverage?.hit("bash:expansion:tilde"); - - // Use HOME if set (even if empty), otherwise fall back to /home/user - const home = - ctx.state.env.get("HOME") !== undefined - ? ctx.state.env.get("HOME") - : "/home/user"; - - // ~/ or just ~ - if (value === "~" || value.startsWith("~/")) { - return home + value.slice(1); - } - - // ~username case: find where the username ends - // Username chars are alphanumeric, underscore, and hyphen - let i = 1; - while (i < value.length && /[a-zA-Z0-9_-]/.test(value[i])) { - i++; - } - const username = value.slice(1, i); - const rest = value.slice(i); - - // Only expand if followed by / or end of string - if (rest !== "" && !rest.startsWith("/")) { - return value; - } - - // Only support ~root expansion in sandboxed environment - if (username === "root") { - return `/root${rest}`; - } - - // Unknown user - keep literal - return value; -} diff --git a/src/interpreter/expansion/unquoted-expansion.ts b/src/interpreter/expansion/unquoted-expansion.ts deleted file mode 100644 index 8be7e44a..00000000 --- a/src/interpreter/expansion/unquoted-expansion.ts +++ /dev/null @@ -1,1075 +0,0 @@ -/** - * Unquoted Expansion Handlers - * - * Handles unquoted positional parameter and array expansions: - * - Unquoted $@ and $* (with and without prefix/suffix) - * - Unquoted ${arr[@]} and ${arr[*]} - * - Unquoted ${@:offset} and ${*:offset} slicing - * - Unquoted ${@#pattern} and ${*#pattern} pattern removal - * - Unquoted ${arr[@]/pattern/replacement} pattern replacement - * - Unquoted ${arr[@]#pattern} pattern removal - * - Unquoted ${!prefix@} and ${!prefix*} variable name prefix expansion - * - Unquoted ${!arr[@]} and ${!arr[*]} array keys expansion - */ - -import type { - ArithExpr, - ParameterExpansionPart, - SubstringOp, - WordNode, - WordPart, -} from "../../ast/types.js"; -import { createUserRegex } from "../../regex/index.js"; -import { GlobExpander } from "../../shell/glob.js"; -import { GlobError } from "../errors.js"; -import { - getIfs, - getIfsSeparator, - isIfsEmpty, - isIfsWhitespaceOnly, - splitByIfsForExpansion, -} from "../helpers/ifs.js"; -import { escapeRegex } from "../helpers/regex.js"; -import type { InterpreterContext } from "../types.js"; -import { hasGlobPattern } from "./glob-escape.js"; -import { patternToRegex } from "./pattern.js"; -import { - applyPatternRemoval, - getVarNamesWithPrefix, -} from "./pattern-removal.js"; -import { getArrayElements } from "./variable.js"; - -/** - * Result type for unquoted expansion handlers. - * `null` means the handler doesn't apply to this case. - */ -export type UnquotedExpansionResult = { - values: string[]; - quoted: boolean; -} | null; - -/** - * Type for expandPart function reference - */ -export type ExpandPartFn = ( - ctx: InterpreterContext, - part: WordPart, -) => Promise; - -/** - * Type for expandWordPartsAsync function reference - */ -export type ExpandWordPartsAsyncFn = ( - ctx: InterpreterContext, - parts: WordPart[], -) => Promise; - -/** - * Type for evaluateArithmetic function - */ -export type EvaluateArithmeticFn = ( - ctx: InterpreterContext, - expr: ArithExpr, - isExpansionContext?: boolean, -) => Promise; - -/** - * Helper to create a GlobExpander with the given context - */ -function createGlobExpander(ctx: InterpreterContext): GlobExpander { - return new GlobExpander(ctx.fs, ctx.state.cwd, ctx.state.env, { - globstar: ctx.state.shoptOptions.globstar, - nullglob: ctx.state.shoptOptions.nullglob, - failglob: ctx.state.shoptOptions.failglob, - dotglob: ctx.state.shoptOptions.dotglob, - extglob: ctx.state.shoptOptions.extglob, - globskipdots: ctx.state.shoptOptions.globskipdots, - maxGlobOperations: ctx.limits.maxGlobOperations, - }); -} - -/** - * Helper to apply glob expansion to a list of words - */ -async function applyGlobExpansion( - ctx: InterpreterContext, - words: string[], -): Promise { - if (ctx.state.options.noglob) { - return words; - } - - const globExpander = createGlobExpander(ctx); - const expandedValues: string[] = []; - - for (const w of words) { - if (hasGlobPattern(w, ctx.state.shoptOptions.extglob)) { - const matches = await globExpander.expand(w); - if (matches.length > 0) { - expandedValues.push(...matches); - } else if (globExpander.hasFailglob()) { - throw new GlobError(w); - } else if (globExpander.hasNullglob()) { - // skip - } else { - expandedValues.push(w); - } - } else { - expandedValues.push(w); - } - } - - return expandedValues; -} - -/** - * Handle unquoted ${array[@]/pattern/replacement} - apply to each element - * This handles ${array[@]/#/prefix} (prepend) and ${array[@]/%/suffix} (append) - */ -export async function handleUnquotedArrayPatternReplacement( - ctx: InterpreterContext, - wordParts: WordPart[], - expandWordPartsAsync: ExpandWordPartsAsyncFn, - expandPart: ExpandPartFn, -): Promise { - let unquotedArrayPatReplIdx = -1; - let unquotedArrayName = ""; - let unquotedArrayIsStar = false; - - for (let i = 0; i < wordParts.length; i++) { - const p = wordParts[i]; - if ( - p.type === "ParameterExpansion" && - p.operation?.type === "PatternReplacement" - ) { - const arrayMatch = p.parameter.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[([@*])\]$/, - ); - if (arrayMatch) { - unquotedArrayPatReplIdx = i; - unquotedArrayName = arrayMatch[1]; - unquotedArrayIsStar = arrayMatch[2] === "*"; - break; - } - } - } - - if (unquotedArrayPatReplIdx === -1) { - return null; - } - - const paramPart = wordParts[ - unquotedArrayPatReplIdx - ] as ParameterExpansionPart; - const operation = paramPart.operation as { - type: "PatternReplacement"; - pattern: WordNode; - replacement: WordNode | null; - all: boolean; - anchor: "start" | "end" | null; - }; - - // Get array elements - const elements = getArrayElements(ctx, unquotedArrayName); - let values = elements.map(([, v]) => v); - - // If no elements, check for scalar (treat as single-element array) - if (elements.length === 0) { - const scalarValue = ctx.state.env.get(unquotedArrayName); - if (scalarValue !== undefined) { - values = [scalarValue]; - } - } - - if (values.length === 0) { - return { values: [], quoted: false }; - } - - // Build the replacement regex - let regex = ""; - if (operation.pattern) { - for (const part of operation.pattern.parts) { - if (part.type === "Glob") { - regex += patternToRegex( - part.pattern, - true, - ctx.state.shoptOptions.extglob, - ); - } else if (part.type === "Literal") { - regex += patternToRegex( - part.value, - true, - ctx.state.shoptOptions.extglob, - ); - } else if (part.type === "SingleQuoted" || part.type === "Escaped") { - regex += escapeRegex(part.value); - } else if (part.type === "DoubleQuoted") { - const expanded = await expandWordPartsAsync(ctx, part.parts); - regex += escapeRegex(expanded); - } else if (part.type === "ParameterExpansion") { - const expanded = await expandPart(ctx, part); - regex += patternToRegex(expanded, true, ctx.state.shoptOptions.extglob); - } else { - const expanded = await expandPart(ctx, part); - regex += escapeRegex(expanded); - } - } - } - - const replacement = operation.replacement - ? await expandWordPartsAsync(ctx, operation.replacement.parts) - : ""; - - // Apply anchor modifiers - let regexPattern = regex; - if (operation.anchor === "start") { - regexPattern = `^${regex}`; - } else if (operation.anchor === "end") { - regexPattern = `${regex}$`; - } - - // Apply replacement to each element - const replacedValues: string[] = []; - try { - const re = createUserRegex(regexPattern, operation.all ? "g" : ""); - for (const value of values) { - replacedValues.push(re.replace(value, replacement)); - } - } catch { - // Invalid regex - return values unchanged - replacedValues.push(...values); - } - - // For unquoted, we need to IFS-split the result - const ifsChars = getIfs(ctx.state.env); - const ifsEmpty = isIfsEmpty(ctx.state.env); - - if (unquotedArrayIsStar) { - // ${arr[*]/...} unquoted - join with IFS, then split - const ifsSep = getIfsSeparator(ctx.state.env); - const joined = replacedValues.join(ifsSep); - if (ifsEmpty) { - return { values: joined ? [joined] : [], quoted: false }; - } - return { - values: splitByIfsForExpansion(joined, ifsChars), - quoted: false, - }; - } - - // ${arr[@]/...} unquoted - each element separate, then IFS-split each - if (ifsEmpty) { - return { values: replacedValues, quoted: false }; - } - - const allWords: string[] = []; - for (const val of replacedValues) { - if (val === "") { - allWords.push(""); - } else { - allWords.push(...splitByIfsForExpansion(val, ifsChars)); - } - } - return { values: allWords, quoted: false }; -} - -/** - * Handle unquoted ${array[@]#pattern} - apply pattern removal to each element - * This handles ${array[@]#pattern} (strip shortest prefix), ${array[@]##pattern} (strip longest prefix) - * ${array[@]%pattern} (strip shortest suffix), ${array[@]%%pattern} (strip longest suffix) - */ -export async function handleUnquotedArrayPatternRemoval( - ctx: InterpreterContext, - wordParts: WordPart[], - expandWordPartsAsync: ExpandWordPartsAsyncFn, - expandPart: ExpandPartFn, -): Promise { - let unquotedArrayPatRemIdx = -1; - let unquotedArrayName = ""; - let unquotedArrayIsStar = false; - - for (let i = 0; i < wordParts.length; i++) { - const p = wordParts[i]; - if ( - p.type === "ParameterExpansion" && - p.operation?.type === "PatternRemoval" - ) { - const arrayMatch = p.parameter.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[([@*])\]$/, - ); - if (arrayMatch) { - unquotedArrayPatRemIdx = i; - unquotedArrayName = arrayMatch[1]; - unquotedArrayIsStar = arrayMatch[2] === "*"; - break; - } - } - } - - if (unquotedArrayPatRemIdx === -1) { - return null; - } - - const paramPart = wordParts[unquotedArrayPatRemIdx] as ParameterExpansionPart; - const operation = paramPart.operation as { - type: "PatternRemoval"; - pattern: WordNode; - side: "prefix" | "suffix"; - greedy: boolean; - }; - - // Get array elements - const elements = getArrayElements(ctx, unquotedArrayName); - let values = elements.map(([, v]) => v); - - // If no elements, check for scalar (treat as single-element array) - if (elements.length === 0) { - const scalarValue = ctx.state.env.get(unquotedArrayName); - if (scalarValue !== undefined) { - values = [scalarValue]; - } - } - - if (values.length === 0) { - return { values: [], quoted: false }; - } - - // Build the regex pattern - let regexStr = ""; - const extglob = ctx.state.shoptOptions.extglob; - if (operation.pattern) { - for (const part of operation.pattern.parts) { - if (part.type === "Glob") { - regexStr += patternToRegex(part.pattern, operation.greedy, extglob); - } else if (part.type === "Literal") { - regexStr += patternToRegex(part.value, operation.greedy, extglob); - } else if (part.type === "SingleQuoted" || part.type === "Escaped") { - regexStr += escapeRegex(part.value); - } else if (part.type === "DoubleQuoted") { - const expanded = await expandWordPartsAsync(ctx, part.parts); - regexStr += escapeRegex(expanded); - } else if (part.type === "ParameterExpansion") { - const expanded = await expandPart(ctx, part); - regexStr += patternToRegex(expanded, operation.greedy, extglob); - } else { - const expanded = await expandPart(ctx, part); - regexStr += escapeRegex(expanded); - } - } - } - - // Apply pattern removal to each element - const strippedValues: string[] = []; - for (const value of values) { - strippedValues.push( - applyPatternRemoval(value, regexStr, operation.side, operation.greedy), - ); - } - - // For unquoted, we need to IFS-split the result - const ifsChars = getIfs(ctx.state.env); - const ifsEmpty = isIfsEmpty(ctx.state.env); - - if (unquotedArrayIsStar) { - // ${arr[*]#...} unquoted - join with IFS, then split - const ifsSep = getIfsSeparator(ctx.state.env); - const joined = strippedValues.join(ifsSep); - if (ifsEmpty) { - return { values: joined ? [joined] : [], quoted: false }; - } - return { - values: splitByIfsForExpansion(joined, ifsChars), - quoted: false, - }; - } - - // ${arr[@]#...} unquoted - each element separate, then IFS-split each - if (ifsEmpty) { - return { values: strippedValues, quoted: false }; - } - - const allWords: string[] = []; - for (const val of strippedValues) { - if (val === "") { - allWords.push(""); - } else { - allWords.push(...splitByIfsForExpansion(val, ifsChars)); - } - } - return { values: allWords, quoted: false }; -} - -/** - * Handle unquoted ${@#pattern} and ${*#pattern} - apply pattern removal to each positional parameter - * This handles ${@#pattern} (strip shortest prefix), ${@##pattern} (strip longest prefix) - * ${@%pattern} (strip shortest suffix), ${@%%pattern} (strip longest suffix) - */ -export async function handleUnquotedPositionalPatternRemoval( - ctx: InterpreterContext, - wordParts: WordPart[], - expandWordPartsAsync: ExpandWordPartsAsyncFn, - expandPart: ExpandPartFn, -): Promise { - let unquotedPosPatRemIdx = -1; - let unquotedPosPatRemIsStar = false; - - for (let i = 0; i < wordParts.length; i++) { - const p = wordParts[i]; - if ( - p.type === "ParameterExpansion" && - (p.parameter === "@" || p.parameter === "*") && - p.operation?.type === "PatternRemoval" - ) { - unquotedPosPatRemIdx = i; - unquotedPosPatRemIsStar = p.parameter === "*"; - break; - } - } - - if (unquotedPosPatRemIdx === -1) { - return null; - } - - const paramPart = wordParts[unquotedPosPatRemIdx] as ParameterExpansionPart; - const operation = paramPart.operation as { - type: "PatternRemoval"; - pattern: WordNode; - side: "prefix" | "suffix"; - greedy: boolean; - }; - - // Get positional parameters - const numParams = Number.parseInt(ctx.state.env.get("#") || "0", 10); - const params: string[] = []; - for (let i = 1; i <= numParams; i++) { - params.push(ctx.state.env.get(String(i)) || ""); - } - - if (params.length === 0) { - return { values: [], quoted: false }; - } - - // Build the regex pattern - let regexStr = ""; - const extglob = ctx.state.shoptOptions.extglob; - if (operation.pattern) { - for (const part of operation.pattern.parts) { - if (part.type === "Glob") { - regexStr += patternToRegex(part.pattern, operation.greedy, extglob); - } else if (part.type === "Literal") { - regexStr += patternToRegex(part.value, operation.greedy, extglob); - } else if (part.type === "SingleQuoted" || part.type === "Escaped") { - regexStr += escapeRegex(part.value); - } else if (part.type === "DoubleQuoted") { - const expanded = await expandWordPartsAsync(ctx, part.parts); - regexStr += escapeRegex(expanded); - } else if (part.type === "ParameterExpansion") { - const expanded = await expandPart(ctx, part); - regexStr += patternToRegex(expanded, operation.greedy, extglob); - } else { - const expanded = await expandPart(ctx, part); - regexStr += escapeRegex(expanded); - } - } - } - - // Apply pattern removal to each positional parameter - const strippedParams: string[] = []; - for (const param of params) { - strippedParams.push( - applyPatternRemoval(param, regexStr, operation.side, operation.greedy), - ); - } - - // For unquoted, we need to IFS-split the result - const ifsChars = getIfs(ctx.state.env); - const ifsEmpty = isIfsEmpty(ctx.state.env); - - if (unquotedPosPatRemIsStar) { - // ${*#...} unquoted - join with IFS, then split - const ifsSep = getIfsSeparator(ctx.state.env); - const joined = strippedParams.join(ifsSep); - if (ifsEmpty) { - return { values: joined ? [joined] : [], quoted: false }; - } - return { - values: splitByIfsForExpansion(joined, ifsChars), - quoted: false, - }; - } - - // ${@#...} unquoted - each param separate, then IFS-split each - if (ifsEmpty) { - return { values: strippedParams, quoted: false }; - } - - const allWords: string[] = []; - for (const val of strippedParams) { - if (val === "") { - allWords.push(""); - } else { - allWords.push(...splitByIfsForExpansion(val, ifsChars)); - } - } - return { values: allWords, quoted: false }; -} - -/** - * Handle unquoted ${@:offset} and ${*:offset} (with potential prefix/suffix) - */ -export async function handleUnquotedPositionalSlicing( - ctx: InterpreterContext, - wordParts: WordPart[], - evaluateArithmetic: EvaluateArithmeticFn, - expandPart: ExpandPartFn, -): Promise { - let unquotedSliceAtIndex = -1; - let unquotedSliceIsStar = false; - - for (let i = 0; i < wordParts.length; i++) { - const p = wordParts[i]; - if ( - p.type === "ParameterExpansion" && - (p.parameter === "@" || p.parameter === "*") && - p.operation?.type === "Substring" - ) { - unquotedSliceAtIndex = i; - unquotedSliceIsStar = p.parameter === "*"; - break; - } - } - - if (unquotedSliceAtIndex === -1) { - return null; - } - - const paramPart = wordParts[unquotedSliceAtIndex] as ParameterExpansionPart; - const operation = paramPart.operation as SubstringOp; - - // Evaluate offset and length - const offset = operation.offset - ? await evaluateArithmetic(ctx, operation.offset.expression) - : 0; - const length = operation.length - ? await evaluateArithmetic(ctx, operation.length.expression) - : undefined; - - // Get positional parameters - const numParams = Number.parseInt(ctx.state.env.get("#") || "0", 10); - const allParams: string[] = []; - for (let i = 1; i <= numParams; i++) { - allParams.push(ctx.state.env.get(String(i)) || ""); - } - - const shellName = ctx.state.env.get("0") || "bash"; - - // Build sliced params array - let slicedParams: string[]; - if (offset <= 0) { - // offset 0: include $0 at position 0 - const withZero = [shellName, ...allParams]; - const computedIdx = withZero.length + offset; - // If negative offset goes beyond array bounds, return empty - if (computedIdx < 0) { - slicedParams = []; - } else { - const startIdx = offset < 0 ? computedIdx : 0; - if (length !== undefined) { - const endIdx = - length < 0 ? withZero.length + length : startIdx + length; - slicedParams = withZero.slice(startIdx, Math.max(startIdx, endIdx)); - } else { - slicedParams = withZero.slice(startIdx); - } - } - } else { - // offset > 0: start from $ - const startIdx = offset - 1; - if (startIdx >= allParams.length) { - slicedParams = []; - } else if (length !== undefined) { - const endIdx = length < 0 ? allParams.length + length : startIdx + length; - slicedParams = allParams.slice(startIdx, Math.max(startIdx, endIdx)); - } else { - slicedParams = allParams.slice(startIdx); - } - } - - // Expand prefix (parts before ${@:...}) - let prefix = ""; - for (let i = 0; i < unquotedSliceAtIndex; i++) { - prefix += await expandPart(ctx, wordParts[i]); - } - - // Expand suffix (parts after ${@:...}) - let suffix = ""; - for (let i = unquotedSliceAtIndex + 1; i < wordParts.length; i++) { - suffix += await expandPart(ctx, wordParts[i]); - } - - // For unquoted, we need to IFS-split the result - const ifsChars = getIfs(ctx.state.env); - const ifsEmpty = isIfsEmpty(ctx.state.env); - - if (slicedParams.length === 0) { - // No params after slicing -> prefix + suffix as one word (may still need splitting) - const combined = prefix + suffix; - if (!combined) { - return { values: [], quoted: false }; - } - if (ifsEmpty) { - return { values: [combined], quoted: false }; - } - return { - values: splitByIfsForExpansion(combined, ifsChars), - quoted: false, - }; - } - - let allWords: string[]; - - if (unquotedSliceIsStar) { - // ${*:offset} unquoted - join all sliced params with IFS, then split result - const ifsSep = getIfsSeparator(ctx.state.env); - const joined = prefix + slicedParams.join(ifsSep) + suffix; - - if (ifsEmpty) { - allWords = joined ? [joined] : []; - } else { - allWords = splitByIfsForExpansion(joined, ifsChars); - } - } else { - // ${@:offset} unquoted - each sliced param is separate, then IFS-split each - // Prefix attaches to first, suffix attaches to last - if (ifsEmpty) { - // No splitting - just attach prefix/suffix - if (slicedParams.length === 1) { - allWords = [prefix + slicedParams[0] + suffix]; - } else { - allWords = [ - prefix + slicedParams[0], - ...slicedParams.slice(1, -1), - slicedParams[slicedParams.length - 1] + suffix, - ]; - } - } else { - // IFS-split each parameter - allWords = []; - for (let i = 0; i < slicedParams.length; i++) { - let param = slicedParams[i]; - if (i === 0) param = prefix + param; - if (i === slicedParams.length - 1) param = param + suffix; - - if (param === "") { - allWords.push(""); - } else { - const parts = splitByIfsForExpansion(param, ifsChars); - allWords.push(...parts); - } - } - } - } - - // Apply glob expansion to each word - return { values: await applyGlobExpansion(ctx, allWords), quoted: false }; -} - -/** - * Handle unquoted $@ and $* (simple, without operations) - */ -export async function handleUnquotedSimplePositional( - ctx: InterpreterContext, - wordParts: WordPart[], -): Promise { - if ( - wordParts.length !== 1 || - wordParts[0].type !== "ParameterExpansion" || - (wordParts[0].parameter !== "@" && wordParts[0].parameter !== "*") || - wordParts[0].operation - ) { - return null; - } - - const isStar = wordParts[0].parameter === "*"; - const numParams = Number.parseInt(ctx.state.env.get("#") || "0", 10); - if (numParams === 0) { - return { values: [], quoted: false }; - } - - // Get individual positional parameters - const params: string[] = []; - for (let i = 1; i <= numParams; i++) { - params.push(ctx.state.env.get(String(i)) || ""); - } - - const ifsChars = getIfs(ctx.state.env); - const ifsEmpty = isIfsEmpty(ctx.state.env); - const ifsWhitespaceOnly = isIfsWhitespaceOnly(ctx.state.env); - - let allWords: string[]; - - if (isStar) { - // $* - join params with IFS[0], then split result by IFS - // HOWEVER: When IFS is empty, bash keeps params separate (like $@) for unquoted $* - if (ifsEmpty) { - // Empty IFS - keep params separate (same as $@), filter out empty params - allWords = params.filter((p) => p !== ""); - } else { - const ifsSep = getIfsSeparator(ctx.state.env); - const joined = params.join(ifsSep); - allWords = splitByIfsForExpansion(joined, ifsChars); - } - } else { - // $@ - each param is a separate word, then each is subject to IFS splitting - if (ifsEmpty) { - // Empty IFS - no splitting, filter out empty params - allWords = params.filter((p) => p !== ""); - } else if (ifsWhitespaceOnly) { - // Whitespace-only IFS - empty params are dropped - allWords = []; - for (const param of params) { - if (param === "") { - continue; - } - const parts = splitByIfsForExpansion(param, ifsChars); - allWords.push(...parts); - } - } else { - // Non-whitespace IFS - preserve empty params EXCEPT trailing ones - allWords = []; - for (const param of params) { - if (param === "") { - allWords.push(""); - } else { - const parts = splitByIfsForExpansion(param, ifsChars); - allWords.push(...parts); - } - } - // Remove trailing empty strings - while (allWords.length > 0 && allWords[allWords.length - 1] === "") { - allWords.pop(); - } - } - } - - // Apply glob expansion to each word - return { values: await applyGlobExpansion(ctx, allWords), quoted: false }; -} - -/** - * Handle unquoted ${arr[@]} and ${arr[*]} (without operations) - */ -export async function handleUnquotedSimpleArray( - ctx: InterpreterContext, - wordParts: WordPart[], -): Promise { - if ( - wordParts.length !== 1 || - wordParts[0].type !== "ParameterExpansion" || - wordParts[0].operation - ) { - return null; - } - - const arrayMatch = wordParts[0].parameter.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[([@*])\]$/, - ); - if (!arrayMatch) { - return null; - } - - const arrayName = arrayMatch[1]; - const isStar = arrayMatch[2] === "*"; - - // Get array elements - const elements = getArrayElements(ctx, arrayName); - - // If no array elements, check for scalar (treat as single-element array) - let values: string[]; - if (elements.length === 0) { - const scalarValue = ctx.state.env.get(arrayName); - if (scalarValue !== undefined) { - values = [scalarValue]; - } else { - return { values: [], quoted: false }; - } - } else { - values = elements.map(([, v]) => v); - } - - const ifsChars = getIfs(ctx.state.env); - const ifsEmpty = isIfsEmpty(ctx.state.env); - const ifsWhitespaceOnly = isIfsWhitespaceOnly(ctx.state.env); - - let allWords: string[]; - - if (isStar) { - // ${arr[*]} unquoted - join with IFS[0], then split result by IFS - if (ifsEmpty) { - // Empty IFS - keep elements separate (same as arr[@]), filter out empty elements - allWords = values.filter((v) => v !== ""); - } else { - const ifsSep = getIfsSeparator(ctx.state.env); - const joined = values.join(ifsSep); - allWords = splitByIfsForExpansion(joined, ifsChars); - } - } else { - // ${arr[@]} unquoted - each element is a separate word, then each is subject to IFS splitting - if (ifsEmpty) { - // Empty IFS - no splitting, filter out empty elements - allWords = values.filter((v) => v !== ""); - } else if (ifsWhitespaceOnly) { - // Whitespace-only IFS - empty elements are dropped - allWords = []; - for (const val of values) { - if (val === "") { - continue; - } - const parts = splitByIfsForExpansion(val, ifsChars); - allWords.push(...parts); - } - } else { - // Non-whitespace IFS - preserve empty elements - allWords = []; - for (const val of values) { - if (val === "") { - allWords.push(""); - } else { - const parts = splitByIfsForExpansion(val, ifsChars); - allWords.push(...parts); - } - } - // Remove trailing empty strings - while (allWords.length > 0 && allWords[allWords.length - 1] === "") { - allWords.pop(); - } - } - } - - // Apply glob expansion to each word - return { values: await applyGlobExpansion(ctx, allWords), quoted: false }; -} - -/** - * Handle unquoted ${!prefix@} and ${!prefix*} (variable name prefix expansion) - */ -export function handleUnquotedVarNamePrefix( - ctx: InterpreterContext, - wordParts: WordPart[], -): UnquotedExpansionResult { - if ( - wordParts.length !== 1 || - wordParts[0].type !== "ParameterExpansion" || - wordParts[0].operation?.type !== "VarNamePrefix" - ) { - return null; - } - - const op = wordParts[0].operation as { - type: "VarNamePrefix"; - prefix: string; - star: boolean; - }; - const matchingVars = getVarNamesWithPrefix(ctx, op.prefix); - - if (matchingVars.length === 0) { - return { values: [], quoted: false }; - } - - const ifsChars = getIfs(ctx.state.env); - const ifsEmpty = isIfsEmpty(ctx.state.env); - - let allWords: string[]; - - if (op.star) { - // ${!prefix*} unquoted - join with IFS[0], then split result by IFS - if (ifsEmpty) { - // Empty IFS - keep names separate - allWords = matchingVars; - } else { - const ifsSep = getIfsSeparator(ctx.state.env); - const joined = matchingVars.join(ifsSep); - allWords = splitByIfsForExpansion(joined, ifsChars); - } - } else { - // ${!prefix@} unquoted - each name is a separate word, then each is subject to IFS splitting - if (ifsEmpty) { - // Empty IFS - no splitting - allWords = matchingVars; - } else { - allWords = []; - for (const name of matchingVars) { - const parts = splitByIfsForExpansion(name, ifsChars); - allWords.push(...parts); - } - } - } - - return { values: allWords, quoted: false }; -} - -/** - * Handle unquoted ${!arr[@]} and ${!arr[*]} (array keys/indices expansion) - */ -export function handleUnquotedArrayKeys( - ctx: InterpreterContext, - wordParts: WordPart[], -): UnquotedExpansionResult { - if ( - wordParts.length !== 1 || - wordParts[0].type !== "ParameterExpansion" || - wordParts[0].operation?.type !== "ArrayKeys" - ) { - return null; - } - - const op = wordParts[0].operation as { - type: "ArrayKeys"; - array: string; - star: boolean; - }; - const elements = getArrayElements(ctx, op.array); - const keys = elements.map(([k]) => String(k)); - - if (keys.length === 0) { - return { values: [], quoted: false }; - } - - const ifsChars = getIfs(ctx.state.env); - const ifsEmpty = isIfsEmpty(ctx.state.env); - - let allWords: string[]; - - if (op.star) { - // ${!arr[*]} unquoted - join with IFS[0], then split result by IFS - if (ifsEmpty) { - // Empty IFS - keep keys separate - allWords = keys; - } else { - const ifsSep = getIfsSeparator(ctx.state.env); - const joined = keys.join(ifsSep); - allWords = splitByIfsForExpansion(joined, ifsChars); - } - } else { - // ${!arr[@]} unquoted - each key is a separate word, then each is subject to IFS splitting - if (ifsEmpty) { - // Empty IFS - no splitting - allWords = keys; - } else { - allWords = []; - for (const key of keys) { - const parts = splitByIfsForExpansion(key, ifsChars); - allWords.push(...parts); - } - } - } - - return { values: allWords, quoted: false }; -} - -/** - * Handle unquoted $@ or $* with prefix/suffix (e.g., =$@= or =$*=) - */ -export async function handleUnquotedPositionalWithPrefixSuffix( - ctx: InterpreterContext, - wordParts: WordPart[], - expandPart: ExpandPartFn, -): Promise { - let unquotedAtStarIndex = -1; - for (let i = 0; i < wordParts.length; i++) { - const p = wordParts[i]; - if ( - p.type === "ParameterExpansion" && - (p.parameter === "@" || p.parameter === "*") && - !p.operation - ) { - unquotedAtStarIndex = i; - break; - } - } - - if (unquotedAtStarIndex === -1 || wordParts.length <= 1) { - return null; - } - - // Get positional parameters - const numParams = Number.parseInt(ctx.state.env.get("#") || "0", 10); - const params: string[] = []; - for (let i = 1; i <= numParams; i++) { - params.push(ctx.state.env.get(String(i)) || ""); - } - - // Expand prefix (parts before $@/$*) - let prefix = ""; - for (let i = 0; i < unquotedAtStarIndex; i++) { - prefix += await expandPart(ctx, wordParts[i]); - } - - // Expand suffix (parts after $@/$*) - let suffix = ""; - for (let i = unquotedAtStarIndex + 1; i < wordParts.length; i++) { - suffix += await expandPart(ctx, wordParts[i]); - } - - const ifsChars = getIfs(ctx.state.env); - const ifsEmpty = isIfsEmpty(ctx.state.env); - const ifsWhitespaceOnly = isIfsWhitespaceOnly(ctx.state.env); - - if (numParams === 0) { - // No params - just return prefix+suffix if non-empty - const combined = prefix + suffix; - return { values: combined ? [combined] : [], quoted: false }; - } - - // Build words first: prefix joins with first param, suffix joins with last - let words: string[]; - - // Both unquoted $@ and unquoted $* behave the same way: - // Each param becomes a separate word, then each is subject to IFS splitting. - { - // First, attach prefix to first param, suffix to last param - const rawWords: string[] = []; - for (let i = 0; i < params.length; i++) { - let word = params[i]; - if (i === 0) word = prefix + word; - if (i === params.length - 1) word = word + suffix; - rawWords.push(word); - } - - // Now apply IFS splitting and filtering - if (ifsEmpty) { - // Empty IFS - no splitting, filter out empty words - words = rawWords.filter((w) => w !== ""); - } else if (ifsWhitespaceOnly) { - // Whitespace-only IFS - empty words are dropped - words = []; - for (const word of rawWords) { - if (word === "") continue; - const parts = splitByIfsForExpansion(word, ifsChars); - words.push(...parts); - } - } else { - // Non-whitespace IFS - preserve empty words (except trailing) - words = []; - for (const word of rawWords) { - if (word === "") { - words.push(""); - } else { - const parts = splitByIfsForExpansion(word, ifsChars); - words.push(...parts); - } - } - // Remove trailing empty strings - while (words.length > 0 && words[words.length - 1] === "") { - words.pop(); - } - } - } - - // Apply glob expansion to each word - if (words.length === 0) { - return { values: [], quoted: false }; - } - - return { values: await applyGlobExpansion(ctx, words), quoted: false }; -} diff --git a/src/interpreter/expansion/variable-attrs.ts b/src/interpreter/expansion/variable-attrs.ts deleted file mode 100644 index ce4d8aa9..00000000 --- a/src/interpreter/expansion/variable-attrs.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Variable Attributes - * - * Functions for getting variable attributes (${var@a} transformation). - */ - -import { isNameref } from "../helpers/nameref.js"; -import { isReadonly } from "../helpers/readonly.js"; -import type { InterpreterContext } from "../types.js"; - -/** - * Get the attributes of a variable for ${var@a} transformation. - * Returns a string with attribute flags (e.g., "ar" for readonly array). - * - * Attribute flags (in order): - * - a: indexed array - * - A: associative array - * - i: integer - * - n: nameref - * - r: readonly - * - x: exported - */ -export function getVariableAttributes( - ctx: InterpreterContext, - name: string, -): string { - // Handle special variables (like ?, $, etc.) - they have no attributes - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { - return ""; - } - - let attrs = ""; - - // Check for indexed array (has numeric elements via name_0, name_1, etc. or __length marker) - const isIndexedArray = - ctx.state.env.has(`${name}__length`) || - Array.from(ctx.state.env.keys()).some( - (k) => - k.startsWith(`${name}_`) && /^[0-9]+$/.test(k.slice(name.length + 1)), - ); - - // Check for associative array - const isAssocArray = ctx.state.associativeArrays?.has(name) ?? false; - - // Add array attributes (indexed before associative) - if (isIndexedArray && !isAssocArray) { - attrs += "a"; - } - if (isAssocArray) { - attrs += "A"; - } - - // Check for integer attribute - if (ctx.state.integerVars?.has(name)) { - attrs += "i"; - } - - // Check for nameref attribute - if (isNameref(ctx, name)) { - attrs += "n"; - } - - // Check for readonly attribute - if (isReadonly(ctx, name)) { - attrs += "r"; - } - - // Check for exported attribute - if (ctx.state.exportedVars?.has(name)) { - attrs += "x"; - } - - return attrs; -} diff --git a/src/interpreter/expansion/variable.ts b/src/interpreter/expansion/variable.ts deleted file mode 100644 index 8a32e671..00000000 --- a/src/interpreter/expansion/variable.ts +++ /dev/null @@ -1,607 +0,0 @@ -/** - * Variable Access - * - * Handles variable value retrieval, including: - * - Special variables ($?, $$, $#, $@, $*, $0) - * - Array access (${arr[0]}, ${arr[@]}, ${arr[*]}) - * - Positional parameters ($1, $2, ...) - * - Regular variables - * - Nameref resolution - */ - -import { parseArithmeticExpression } from "../../parser/arithmetic-parser.js"; -import { Parser } from "../../parser/parser.js"; -import { BASH_VERSION, getProcessInfo } from "../../shell-metadata.js"; -import { evaluateArithmetic } from "../arithmetic.js"; -import { BadSubstitutionError, NounsetError } from "../errors.js"; -import { - getArrayIndices, - getAssocArrayKeys, - unquoteKey, -} from "../helpers/array.js"; -import { getIfsSeparator } from "../helpers/ifs.js"; -import { isNameref, resolveNameref } from "../helpers/nameref.js"; -import type { InterpreterContext } from "../types.js"; - -/** - * Expand simple variable references in a subscript string. - * This handles patterns like $var and ${var} but not complex expansions. - * Used to support namerefs pointing to array elements like A[$key]. - */ -function expandSimpleVarsInSubscript( - ctx: InterpreterContext, - subscript: string, -): string { - // Replace ${varname} patterns - let result = subscript.replace( - /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, - (_, name) => ctx.state.env.get(name) ?? "", - ); - // Replace $varname patterns (must be careful not to match ${}) - result = result.replace( - /\$([a-zA-Z_][a-zA-Z0-9_]*)/g, - (_, name) => ctx.state.env.get(name) ?? "", - ); - return result; -} - -/** - * Get all elements of an array stored as arrayName_0, arrayName_1, etc. - * Returns an array of [index/key, value] tuples, sorted by index/key. - * For associative arrays, uses string keys. - * Special arrays FUNCNAME, BASH_LINENO, and BASH_SOURCE are handled dynamically from call stack. - */ -export function getArrayElements( - ctx: InterpreterContext, - arrayName: string, -): Array<[number | string, string]> { - // Handle special call stack arrays - if (arrayName === "FUNCNAME") { - const stack = ctx.state.funcNameStack ?? []; - return stack.map((name, i) => [i, name]); - } - if (arrayName === "BASH_LINENO") { - const stack = ctx.state.callLineStack ?? []; - return stack.map((line, i) => [i, String(line)]); - } - if (arrayName === "BASH_SOURCE") { - const stack = ctx.state.sourceStack ?? []; - return stack.map((source, i) => [i, source]); - } - - const isAssoc = ctx.state.associativeArrays?.has(arrayName); - - if (isAssoc) { - // For associative arrays, get string keys - const keys = getAssocArrayKeys(ctx, arrayName); - return keys.map((key) => [ - key, - ctx.state.env.get(`${arrayName}_${key}`) ?? "", - ]); - } - - // For indexed arrays, get numeric indices - const indices = getArrayIndices(ctx, arrayName); - return indices.map((index) => [ - index, - ctx.state.env.get(`${arrayName}_${index}`) ?? "", - ]); -} - -/** - * Check if a variable is an array (has elements stored as name_0, name_1, etc.) - */ -export function isArray(ctx: InterpreterContext, name: string): boolean { - // Handle special call stack arrays - they're only arrays when inside functions - if (name === "FUNCNAME") { - return (ctx.state.funcNameStack?.length ?? 0) > 0; - } - if (name === "BASH_LINENO") { - return (ctx.state.callLineStack?.length ?? 0) > 0; - } - if (name === "BASH_SOURCE") { - return (ctx.state.sourceStack?.length ?? 0) > 0; - } - // Check if it's an associative array - if (ctx.state.associativeArrays?.has(name)) { - return getAssocArrayKeys(ctx, name).length > 0; - } - // Check for indexed array elements - return getArrayIndices(ctx, name).length > 0; -} - -/** - * Get the value of a variable, optionally checking nounset. - * @param ctx - The interpreter context - * @param name - The variable name - * @param checkNounset - Whether to check for nounset (default true) - */ -export async function getVariable( - ctx: InterpreterContext, - name: string, - checkNounset = true, - _insideDoubleQuotes = false, -): Promise { - // Special variables are always defined (never trigger nounset) - switch (name) { - case "?": - return String(ctx.state.lastExitCode); - case "$": - return String(process.pid); - case "#": - return ctx.state.env.get("#") || "0"; - case "@": - return ctx.state.env.get("@") || ""; - case "_": - // $_ is the last argument of the previous command - return ctx.state.lastArg; - case "-": { - // $- returns current shell option flags - // Bash always includes h (hashall) and B (braceexpand) by default - // Note: pipefail has no short flag in $- (it's only set via -o pipefail) - let flags = ""; - // h = hashall (always on in bash by default, we have hash table support) - flags += "h"; - if (ctx.state.options.errexit) flags += "e"; - if (ctx.state.options.noglob) flags += "f"; - if (ctx.state.options.nounset) flags += "u"; - if (ctx.state.options.verbose) flags += "v"; - if (ctx.state.options.xtrace) flags += "x"; - // B = braceexpand (always on in our implementation) - flags += "B"; - if (ctx.state.options.noclobber) flags += "C"; - // s = stdin reading (always on since we execute scripts passed as strings, - // which is conceptually equivalent to reading from stdin like `bash < script.sh`) - flags += "s"; - return flags; - } - case "*": { - // $* uses first character of IFS as separator when inside double quotes - // When IFS is empty string, no separator is used - // When IFS is unset, space is used (default behavior) - const numParams = Number.parseInt(ctx.state.env.get("#") || "0", 10); - if (numParams === 0) return ""; - const params: string[] = []; - for (let i = 1; i <= numParams; i++) { - params.push(ctx.state.env.get(String(i)) || ""); - } - return params.join(getIfsSeparator(ctx.state.env)); - } - case "0": - return ctx.state.env.get("0") || "bash"; - case "PWD": - // Check if PWD is in env (might have been unset) - return ctx.state.env.get("PWD") ?? ""; - case "OLDPWD": - // Check if OLDPWD is in env (might have been unset) - return ctx.state.env.get("OLDPWD") ?? ""; - case "PPID": { - // Parent process ID (from shared metadata) - const { ppid } = getProcessInfo(); - return String(ppid); - } - case "UID": { - // Real user ID (from shared metadata) - const { uid } = getProcessInfo(); - return String(uid); - } - case "EUID": - // Effective user ID (same as UID in our simulated environment) - return String(process.geteuid?.() ?? getProcessInfo().uid); - case "RANDOM": - // Random number between 0 and 32767 - return String(Math.floor(Math.random() * 32768)); - case "SECONDS": - // Seconds since shell started - return String(Math.floor((Date.now() - ctx.state.startTime) / 1000)); - case "BASH_VERSION": - // Simulated bash version (from shared metadata) - return BASH_VERSION; - case "!": - // PID of most recent background job (0 if none) - return String(ctx.state.lastBackgroundPid); - case "BASHPID": - // Current bash process ID (changes in subshells, unlike $$) - return String(ctx.state.bashPid); - case "LINENO": - // Current line number being executed - return String(ctx.state.currentLine); - case "FUNCNAME": { - // Return the first element (current function name) or handle unset - const funcName = ctx.state.funcNameStack?.[0]; - if (funcName !== undefined) { - return funcName; - } - // Outside functions, FUNCNAME is unset - check nounset - if (checkNounset && ctx.state.options.nounset) { - throw new NounsetError("FUNCNAME"); - } - return ""; - } - case "BASH_LINENO": { - // Return the first element (line where current function was called) or handle unset - const line = ctx.state.callLineStack?.[0]; - if (line !== undefined) { - return String(line); - } - // Outside functions, BASH_LINENO is unset - check nounset - if (checkNounset && ctx.state.options.nounset) { - throw new NounsetError("BASH_LINENO"); - } - return ""; - } - case "BASH_SOURCE": { - // Return the first element (source file where current function was defined) or handle unset - const source = ctx.state.sourceStack?.[0]; - if (source !== undefined) { - return source; - } - // Outside functions, BASH_SOURCE is unset - check nounset - if (checkNounset && ctx.state.options.nounset) { - throw new NounsetError("BASH_SOURCE"); - } - return ""; - } - } - - // Check for empty subscript: varName[] is invalid - if (/^[a-zA-Z_][a-zA-Z0-9_]*\[\]$/.test(name)) { - throw new BadSubstitutionError(`\${${name}}`); - } - - // Check for array subscript: varName[subscript] - const bracketMatch = name.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$/); - if (bracketMatch) { - let arrayName = bracketMatch[1]; - const subscript = bracketMatch[2]; - - // Check if arrayName is a nameref - if so, resolve it - if (isNameref(ctx, arrayName)) { - const resolved = resolveNameref(ctx, arrayName); - if (resolved && resolved !== arrayName) { - // Check if resolved target itself has array subscript - const resolvedBracket = resolved.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$/, - ); - if (resolvedBracket) { - // Nameref points to an array element like arr[2], so ref[0] is invalid - // Return empty string (bash behavior) - return ""; - } - arrayName = resolved; - } - } - - if (subscript === "@" || subscript === "*") { - // Get all array elements joined with space - const elements = getArrayElements(ctx, arrayName); - if (elements.length > 0) { - return elements.map(([, v]) => v).join(" "); - } - // If no array elements, treat scalar variable as single-element array - // ${s[@]} where s='abc' returns 'abc' - const scalarValue = ctx.state.env.get(arrayName); - if (scalarValue !== undefined) { - return scalarValue; - } - return ""; - } - - // Handle special call stack arrays with numeric subscript - if (arrayName === "FUNCNAME") { - const index = Number.parseInt(subscript, 10); - if (!Number.isNaN(index) && index >= 0) { - return ctx.state.funcNameStack?.[index] ?? ""; - } - return ""; - } - if (arrayName === "BASH_LINENO") { - const index = Number.parseInt(subscript, 10); - if (!Number.isNaN(index) && index >= 0) { - const line = ctx.state.callLineStack?.[index]; - return line !== undefined ? String(line) : ""; - } - return ""; - } - if (arrayName === "BASH_SOURCE") { - const index = Number.parseInt(subscript, 10); - if (!Number.isNaN(index) && index >= 0) { - return ctx.state.sourceStack?.[index] ?? ""; - } - return ""; - } - - const isAssoc = ctx.state.associativeArrays?.has(arrayName); - - if (isAssoc) { - // For associative arrays, use subscript as string key - // First unquote, then expand simple variable references for nameref support - let key = unquoteKey(subscript); - // Expand simple variable references like $var or ${var} - key = expandSimpleVarsInSubscript(ctx, key); - const value = ctx.state.env.get(`${arrayName}_${key}`); - if (value === undefined && checkNounset && ctx.state.options.nounset) { - throw new NounsetError(`${arrayName}[${subscript}]`); - } - return value || ""; - } - - // Evaluate subscript as arithmetic expression for indexed arrays - // This handles: a[0], a[x], a[x+1], a[a[0]], a[b=2], etc. - let index: number; - if (/^-?\d+$/.test(subscript)) { - // Simple numeric subscript - no need for full arithmetic parsing - index = Number.parseInt(subscript, 10); - } else { - // Parse and evaluate as arithmetic expression - try { - const parser = new Parser(); - const arithAst = parseArithmeticExpression(parser, subscript); - index = await evaluateArithmetic(ctx, arithAst.expression); - } catch { - // Fall back to simple variable lookup for backwards compatibility - const evalValue = ctx.state.env.get(subscript); - index = evalValue ? Number.parseInt(evalValue, 10) : 0; - if (Number.isNaN(index)) index = 0; - } - } - - // Handle negative indices - bash counts from max_index + 1 - // So a[-1] = a[max_index], a[-2] = a[max_index - 1], etc. - if (index < 0) { - const elements = getArrayElements(ctx, arrayName); - const lineNum = ctx.state.currentLine; - if (elements.length === 0) { - // Empty array with negative index - output error to stderr and return empty - ctx.state.expansionStderr = - (ctx.state.expansionStderr || "") + - `bash: line ${lineNum}: ${arrayName}: bad array subscript\n`; - return ""; - } - // Find the maximum index - const maxIndex = Math.max( - ...elements.map(([idx]) => (typeof idx === "number" ? idx : 0)), - ); - // Convert negative index to actual index - const actualIdx = maxIndex + 1 + index; - if (actualIdx < 0) { - // Out of bounds negative index - output error to stderr and return empty - ctx.state.expansionStderr = - (ctx.state.expansionStderr || "") + - `bash: line ${lineNum}: ${arrayName}: bad array subscript\n`; - return ""; - } - // Look up by actual index, not position - const value = ctx.state.env.get(`${arrayName}_${actualIdx}`); - return value || ""; - } - - const value = ctx.state.env.get(`${arrayName}_${index}`); - if (value !== undefined) { - return value; - } - // If array element doesn't exist, check if it's a scalar variable accessed as c[0] - // In bash, c[0] for scalar c returns the value of c - if (index === 0) { - const scalarValue = ctx.state.env.get(arrayName); - if (scalarValue !== undefined) { - return scalarValue; - } - } - if (checkNounset && ctx.state.options.nounset) { - throw new NounsetError(`${arrayName}[${index}]`); - } - return ""; - } - - // Positional parameters ($1, $2, etc.) - check nounset - if (/^[1-9][0-9]*$/.test(name)) { - const value = ctx.state.env.get(name); - if (value === undefined && checkNounset && ctx.state.options.nounset) { - throw new NounsetError(name); - } - return value || ""; - } - - // Check if this is a nameref - resolve and get target's value - if (isNameref(ctx, name)) { - const resolved = resolveNameref(ctx, name); - if (resolved === undefined) { - // Circular nameref - error in bash, but we return empty string - return ""; - } - if (resolved !== name) { - // Recursively get the target variable's value - // (this handles if target is also a nameref, array, etc.) - return await getVariable( - ctx, - resolved, - checkNounset, - _insideDoubleQuotes, - ); - } - // Nameref points to empty/invalid target - const value = ctx.state.env.get(name); - // Empty nameref (no target) should trigger nounset error - if ( - (value === undefined || value === "") && - checkNounset && - ctx.state.options.nounset - ) { - throw new NounsetError(name); - } - return value || ""; - } - - // Regular variables - check nounset - const value = ctx.state.env.get(name); - if (value !== undefined) { - // Track tempenv access for local-unset scoping behavior - // If this variable has a tempenv binding and we're reading it, - // mark it as "accessed" so that local-unset will reveal the tempenv value - if (ctx.state.tempEnvBindings?.some((b) => b.has(name))) { - ctx.state.accessedTempEnvVars = - ctx.state.accessedTempEnvVars || new Set(); - ctx.state.accessedTempEnvVars.add(name); - } - // Scalar value exists - return it - return value; - } - - // Check if plain variable name refers to an array (no scalar exists) - // In bash, $a where a is an array returns ${a[0]} (first element) - if (isArray(ctx, name)) { - // Return the first element (index 0) - const firstValue = ctx.state.env.get(`${name}_0`); - if (firstValue !== undefined) { - return firstValue; - } - // Array exists but no element at index 0 - return empty string - return ""; - } - - // No value found - check nounset - if (checkNounset && ctx.state.options.nounset) { - throw new NounsetError(name); - } - return ""; -} - -/** - * Check if a variable is set (exists in the environment). - * Properly handles array subscripts (e.g., arr[0] -> arr_0). - * @param ctx - The interpreter context - * @param name - The variable name (possibly with array subscript) - */ -export async function isVariableSet( - ctx: InterpreterContext, - name: string, -): Promise { - // Special variables that are always set - // These match the variables handled in getVariable's switch statement - const alwaysSetSpecialVars = new Set([ - "?", - "$", - "#", - "_", - "-", - "0", - "PPID", - "UID", - "EUID", - "RANDOM", - "SECONDS", - "BASH_VERSION", - "!", - "BASHPID", - "LINENO", - ]); - if (alwaysSetSpecialVars.has(name)) { - return true; - } - - // $@ and $* are considered "set" only if there are positional parameters - if (name === "@" || name === "*") { - const numParams = Number.parseInt(ctx.state.env.get("#") || "0", 10); - return numParams > 0; - } - - // PWD and OLDPWD are special - they are set unless explicitly unset - // We check ctx.state.env for them since they can be unset - if (name === "PWD" || name === "OLDPWD") { - return ctx.state.env.has(name); - } - - // Check for array subscript: varName[subscript] - const bracketMatch = name.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$/); - if (bracketMatch) { - let arrayName = bracketMatch[1]; - const subscript = bracketMatch[2]; - - // Check if arrayName is a nameref - if so, resolve it - if (isNameref(ctx, arrayName)) { - const resolved = resolveNameref(ctx, arrayName); - if (resolved && resolved !== arrayName) { - const resolvedBracket = resolved.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$/, - ); - if (resolvedBracket) { - // Nameref points to an array element - treat as unset - return false; - } - arrayName = resolved; - } - } - - // For @ or *, check if array has any elements - if (subscript === "@" || subscript === "*") { - const elements = getArrayElements(ctx, arrayName); - if (elements.length > 0) return true; - // Also check if scalar variable exists - return ctx.state.env.has(arrayName); - } - - const isAssoc = ctx.state.associativeArrays?.has(arrayName); - - if (isAssoc) { - // For associative arrays, use subscript as string key (remove quotes if present) - const key = unquoteKey(subscript); - return ctx.state.env.has(`${arrayName}_${key}`); - } - - // Evaluate subscript as arithmetic expression for indexed arrays - let index: number; - if (/^-?\d+$/.test(subscript)) { - index = Number.parseInt(subscript, 10); - } else { - try { - const parser = new Parser(); - const arithAst = parseArithmeticExpression(parser, subscript); - index = await evaluateArithmetic(ctx, arithAst.expression); - } catch { - const evalValue = ctx.state.env.get(subscript); - index = evalValue ? Number.parseInt(evalValue, 10) : 0; - if (Number.isNaN(index)) index = 0; - } - } - - // Handle negative indices - if (index < 0) { - const elements = getArrayElements(ctx, arrayName); - if (elements.length === 0) return false; - const maxIndex = Math.max( - ...elements.map(([idx]) => (typeof idx === "number" ? idx : 0)), - ); - const actualIdx = maxIndex + 1 + index; - if (actualIdx < 0) return false; - return ctx.state.env.has(`${arrayName}_${actualIdx}`); - } - - return ctx.state.env.has(`${arrayName}_${index}`); - } - - // Check if this is a nameref - resolve and check target - if (isNameref(ctx, name)) { - const resolved = resolveNameref(ctx, name); - if (resolved === undefined || resolved === name) { - // Circular or invalid nameref - return ctx.state.env.has(name); - } - // Recursively check the target - return isVariableSet(ctx, resolved); - } - - // Regular variable - check if scalar value exists - if (ctx.state.env.has(name)) { - return true; - } - - // Check if plain variable name refers to an array (no scalar exists) - // In bash, plain array name is "set" if array has elements - if (isArray(ctx, name)) { - // Array with elements is considered "set" - return true; - } - - return false; -} diff --git a/src/interpreter/expansion/word-glob-expansion.ts b/src/interpreter/expansion/word-glob-expansion.ts deleted file mode 100644 index 6b367740..00000000 --- a/src/interpreter/expansion/word-glob-expansion.ts +++ /dev/null @@ -1,930 +0,0 @@ -/** - * Word Expansion with Glob Handling - * - * Handles the main word expansion flow including: - * - Brace expansion - * - Array and positional parameter expansion - * - Word splitting - * - Glob/pathname expansion - */ - -import type { - ArithExpr, - ParameterExpansionPart, - WordNode, - WordPart, -} from "../../ast/types.js"; -import { GlobExpander } from "../../shell/glob.js"; -import { GlobError } from "../errors.js"; -import { - getIfs, - getIfsSeparator, - isIfsEmpty, - splitByIfsForExpansion, -} from "../helpers/ifs.js"; -import type { InterpreterContext } from "../types.js"; -import { analyzeWordParts } from "./analysis.js"; -import { - handleArrayPatternRemoval, - handleArrayPatternReplacement, -} from "./array-pattern-ops.js"; -import { - handleArrayDefaultValue, - handleArrayPatternWithPrefixSuffix, - handleArrayWithPrefixSuffix, -} from "./array-prefix-suffix.js"; -import { - handleArraySlicing, - handleArrayTransform, -} from "./array-slice-transform.js"; -import { - handleNamerefArrayExpansion, - handleSimpleArrayExpansion, -} from "./array-word-expansion.js"; -import { hasGlobPattern, unescapeGlobPattern } from "./glob-escape.js"; -import { - handleIndirectArrayExpansion, - handleIndirectInAlternative, - handleIndirectionWithInnerAlternative, -} from "./indirect-expansion.js"; -import { getVarNamesWithPrefix } from "./pattern-removal.js"; -import { - handlePositionalPatternRemoval, - handlePositionalPatternReplacement, - handlePositionalSlicing, - handleSimplePositionalExpansion, -} from "./positional-params.js"; -import { - handleUnquotedArrayKeys, - handleUnquotedArrayPatternRemoval, - handleUnquotedArrayPatternReplacement, - handleUnquotedPositionalPatternRemoval, - handleUnquotedPositionalSlicing, - handleUnquotedPositionalWithPrefixSuffix, - handleUnquotedSimpleArray, - handleUnquotedSimplePositional, - handleUnquotedVarNamePrefix, -} from "./unquoted-expansion.js"; -import { getArrayElements } from "./variable.js"; - -/** - * Dependencies injected to avoid circular imports - */ -export interface WordGlobExpansionDeps { - expandWordAsync: (ctx: InterpreterContext, word: WordNode) => Promise; - expandWordForGlobbing: ( - ctx: InterpreterContext, - word: WordNode, - ) => Promise; - expandWordWithBracesAsync: ( - ctx: InterpreterContext, - word: WordNode, - ) => Promise; - expandWordPartsAsync: ( - ctx: InterpreterContext, - parts: WordPart[], - ) => Promise; - expandPart: ( - ctx: InterpreterContext, - part: WordPart, - inDoubleQuotes?: boolean, - ) => Promise; - expandParameterAsync: ( - ctx: InterpreterContext, - part: ParameterExpansionPart, - inDoubleQuotes?: boolean, - ) => Promise; - hasBraceExpansion: (parts: WordPart[]) => boolean; - evaluateArithmetic: ( - ctx: InterpreterContext, - expr: ArithExpr, - isExpansionContext?: boolean, - ) => Promise; - buildIfsCharClassPattern: (ifsChars: string) => string; - smartWordSplit: ( - ctx: InterpreterContext, - wordParts: WordPart[], - ifsChars: string, - ifsPattern: string, - expandPart: (ctx: InterpreterContext, part: WordPart) => Promise, - ) => Promise; -} - -/** - * Main word expansion function that handles all expansion types and glob matching. - */ -export async function expandWordWithGlobImpl( - ctx: InterpreterContext, - word: WordNode, - deps: WordGlobExpansionDeps, -): Promise<{ values: string[]; quoted: boolean }> { - ctx.coverage?.hit("bash:expansion:word_glob"); - const wordParts = word.parts; - const { - hasQuoted, - hasCommandSub, - hasArrayVar, - hasArrayAtExpansion, - hasParamExpansion, - hasVarNamePrefixExpansion, - hasIndirection, - } = analyzeWordParts(wordParts); - - // Handle brace expansion first (produces multiple values) - const hasBraces = deps.hasBraceExpansion(wordParts); - const braceExpanded = hasBraces - ? await deps.expandWordWithBracesAsync(ctx, word) - : null; - - if (braceExpanded && braceExpanded.length > 1) { - return handleBraceExpansionResults(ctx, braceExpanded, hasQuoted); - } - - // Handle array expansion special cases - const arrayResult = await handleArrayExpansionCases( - ctx, - wordParts, - hasArrayAtExpansion, - hasVarNamePrefixExpansion, - hasIndirection, - deps, - ); - if (arrayResult !== null) { - return arrayResult; - } - - // Handle positional parameter expansion special cases - const positionalResult = await handlePositionalExpansionCases( - ctx, - wordParts, - deps, - ); - if (positionalResult !== null) { - return positionalResult; - } - - // Handle unquoted expansion special cases - const unquotedResult = await handleUnquotedExpansionCases( - ctx, - wordParts, - deps, - ); - if (unquotedResult !== null) { - return unquotedResult; - } - - // Handle mixed word parts with word-producing expansions - const mixedWordResult = await expandMixedWordParts( - ctx, - wordParts, - deps.expandPart, - ); - if (mixedWordResult !== null) { - return applyGlobToValues(ctx, mixedWordResult); - } - - // Word splitting based on IFS - if ( - (hasCommandSub || hasArrayVar || hasParamExpansion) && - !isIfsEmpty(ctx.state.env) - ) { - const ifsChars = getIfs(ctx.state.env); - const ifsPattern = deps.buildIfsCharClassPattern(ifsChars); - const splitResult = await deps.smartWordSplit( - ctx, - wordParts, - ifsChars, - ifsPattern, - deps.expandPart, - ); - return applyGlobToValues(ctx, splitResult); - } - - const value = await deps.expandWordAsync(ctx, word); - return handleFinalGlobExpansion( - ctx, - word, - wordParts, - value, - hasQuoted, - deps.expandWordForGlobbing, - ); -} - -/** - * Handle brace expansion results with glob expansion - */ -async function handleBraceExpansionResults( - ctx: InterpreterContext, - braceExpanded: string[], - hasQuoted: boolean, -): Promise<{ values: string[]; quoted: boolean }> { - const allValues: string[] = []; - for (const value of braceExpanded) { - if (!hasQuoted && value === "") { - continue; - } - if ( - !hasQuoted && - !ctx.state.options.noglob && - hasGlobPattern(value, ctx.state.shoptOptions.extglob) - ) { - const matches = await expandGlobPattern(ctx, value); - allValues.push(...matches); - } else { - allValues.push(value); - } - } - return { values: allValues, quoted: false }; -} - -/** - * Handle array expansion special cases - */ -async function handleArrayExpansionCases( - ctx: InterpreterContext, - wordParts: WordPart[], - hasArrayAtExpansion: boolean, - hasVarNamePrefixExpansion: boolean, - hasIndirection: boolean, - deps: WordGlobExpansionDeps, -): Promise<{ values: string[]; quoted: boolean } | null> { - // Simple array expansion "${a[@]}" - if (hasArrayAtExpansion) { - const simpleArrayResult = handleSimpleArrayExpansion(ctx, wordParts); - if (simpleArrayResult !== null) { - return simpleArrayResult; - } - } - - // Nameref pointing to array[@] - { - const namerefArrayResult = handleNamerefArrayExpansion(ctx, wordParts); - if (namerefArrayResult !== null) { - return namerefArrayResult; - } - } - - // Array default/alternative values - { - const arrayDefaultResult = await handleArrayDefaultValue(ctx, wordParts); - if (arrayDefaultResult !== null) { - return arrayDefaultResult; - } - } - - // Array pattern with prefix/suffix - { - const arrayPatternPrefixSuffixResult = - await handleArrayPatternWithPrefixSuffix( - ctx, - wordParts, - hasArrayAtExpansion, - deps.expandPart, - deps.expandWordPartsAsync, - ); - if (arrayPatternPrefixSuffixResult !== null) { - return arrayPatternPrefixSuffixResult; - } - } - - // Array with prefix/suffix - { - const arrayPrefixSuffixResult = await handleArrayWithPrefixSuffix( - ctx, - wordParts, - hasArrayAtExpansion, - deps.expandPart, - ); - if (arrayPrefixSuffixResult !== null) { - return arrayPrefixSuffixResult; - } - } - - // Array slicing - { - const arraySlicingResult = await handleArraySlicing( - ctx, - wordParts, - deps.evaluateArithmetic, - ); - if (arraySlicingResult !== null) { - return arraySlicingResult; - } - } - - // Array transform operations - { - const arrayTransformResult = handleArrayTransform(ctx, wordParts); - if (arrayTransformResult !== null) { - return arrayTransformResult; - } - } - - // Array pattern replacement - { - const arrayPatReplResult = await handleArrayPatternReplacement( - ctx, - wordParts, - deps.expandWordPartsAsync, - deps.expandPart, - ); - if (arrayPatReplResult !== null) { - return arrayPatReplResult; - } - } - - // Array pattern removal - { - const arrayPatRemResult = await handleArrayPatternRemoval( - ctx, - wordParts, - deps.expandWordPartsAsync, - deps.expandPart, - ); - if (arrayPatRemResult !== null) { - return arrayPatRemResult; - } - } - - // Variable name prefix expansion - if ( - hasVarNamePrefixExpansion && - wordParts.length === 1 && - wordParts[0].type === "DoubleQuoted" - ) { - const result = handleVarNamePrefixExpansion(ctx, wordParts); - if (result !== null) { - return result; - } - } - - // Indirect array expansion - { - const indirectArrayResult = await handleIndirectArrayExpansion( - ctx, - wordParts, - hasIndirection, - deps.expandParameterAsync, - deps.expandWordPartsAsync, - ); - if (indirectArrayResult !== null) { - return indirectArrayResult; - } - } - - // Indirect in alternative - { - const indirectInAltResult = await handleIndirectInAlternative( - ctx, - wordParts, - ); - if (indirectInAltResult !== null) { - return indirectInAltResult; - } - } - - // Indirection with inner alternative - { - const indirectionWithInnerResult = - await handleIndirectionWithInnerAlternative(ctx, wordParts); - if (indirectionWithInnerResult !== null) { - return indirectionWithInnerResult; - } - } - - return null; -} - -/** - * Handle variable name prefix expansion inside double quotes - */ -function handleVarNamePrefixExpansion( - ctx: InterpreterContext, - wordParts: WordPart[], -): { values: string[]; quoted: boolean } | null { - const dqPart = wordParts[0]; - if (dqPart.type !== "DoubleQuoted") return null; - - // Handle "${!prefix@}" and "${!prefix*}" - if ( - dqPart.parts.length === 1 && - dqPart.parts[0].type === "ParameterExpansion" && - dqPart.parts[0].operation?.type === "VarNamePrefix" - ) { - const op = dqPart.parts[0].operation; - const matchingVars = getVarNamesWithPrefix(ctx, op.prefix); - - if (op.star) { - return { - values: [matchingVars.join(getIfsSeparator(ctx.state.env))], - quoted: true, - }; - } - return { values: matchingVars, quoted: true }; - } - - // Handle "${!arr[@]}" and "${!arr[*]}" - if ( - dqPart.parts.length === 1 && - dqPart.parts[0].type === "ParameterExpansion" && - dqPart.parts[0].operation?.type === "ArrayKeys" - ) { - const op = dqPart.parts[0].operation; - const elements = getArrayElements(ctx, op.array); - const keys = elements.map(([k]) => String(k)); - - if (op.star) { - return { - values: [keys.join(getIfsSeparator(ctx.state.env))], - quoted: true, - }; - } - return { values: keys, quoted: true }; - } - - return null; -} - -/** - * Handle positional parameter expansion special cases - */ -async function handlePositionalExpansionCases( - ctx: InterpreterContext, - wordParts: WordPart[], - deps: WordGlobExpansionDeps, -): Promise<{ values: string[]; quoted: boolean } | null> { - // Positional slicing - { - const positionalSlicingResult = await handlePositionalSlicing( - ctx, - wordParts, - deps.evaluateArithmetic, - deps.expandPart, - ); - if (positionalSlicingResult !== null) { - return positionalSlicingResult; - } - } - - // Positional pattern replacement - { - const positionalPatReplResult = await handlePositionalPatternReplacement( - ctx, - wordParts, - deps.expandPart, - deps.expandWordPartsAsync, - ); - if (positionalPatReplResult !== null) { - return positionalPatReplResult; - } - } - - // Positional pattern removal - { - const positionalPatRemResult = await handlePositionalPatternRemoval( - ctx, - wordParts, - deps.expandPart, - deps.expandWordPartsAsync, - ); - if (positionalPatRemResult !== null) { - return positionalPatRemResult; - } - } - - // Simple positional expansion - { - const simplePositionalResult = await handleSimplePositionalExpansion( - ctx, - wordParts, - deps.expandPart, - ); - if (simplePositionalResult !== null) { - return simplePositionalResult; - } - } - - return null; -} - -/** - * Handle unquoted expansion special cases - */ -async function handleUnquotedExpansionCases( - ctx: InterpreterContext, - wordParts: WordPart[], - deps: WordGlobExpansionDeps, -): Promise<{ values: string[]; quoted: boolean } | null> { - // Unquoted array pattern replacement - { - const unquotedArrayPatReplResult = - await handleUnquotedArrayPatternReplacement( - ctx, - wordParts, - deps.expandWordPartsAsync, - deps.expandPart, - ); - if (unquotedArrayPatReplResult !== null) { - return unquotedArrayPatReplResult; - } - } - - // Unquoted array pattern removal - { - const unquotedArrayPatRemResult = await handleUnquotedArrayPatternRemoval( - ctx, - wordParts, - deps.expandWordPartsAsync, - deps.expandPart, - ); - if (unquotedArrayPatRemResult !== null) { - return unquotedArrayPatRemResult; - } - } - - // Unquoted positional pattern removal - { - const unquotedPosPatRemResult = - await handleUnquotedPositionalPatternRemoval( - ctx, - wordParts, - deps.expandWordPartsAsync, - deps.expandPart, - ); - if (unquotedPosPatRemResult !== null) { - return unquotedPosPatRemResult; - } - } - - // Unquoted positional slicing - { - const unquotedSliceResult = await handleUnquotedPositionalSlicing( - ctx, - wordParts, - deps.evaluateArithmetic, - deps.expandPart, - ); - if (unquotedSliceResult !== null) { - return unquotedSliceResult; - } - } - - // Unquoted simple positional - { - const unquotedSimplePositionalResult = await handleUnquotedSimplePositional( - ctx, - wordParts, - ); - if (unquotedSimplePositionalResult !== null) { - return unquotedSimplePositionalResult; - } - } - - // Unquoted simple array - { - const unquotedSimpleArrayResult = await handleUnquotedSimpleArray( - ctx, - wordParts, - ); - if (unquotedSimpleArrayResult !== null) { - return unquotedSimpleArrayResult; - } - } - - // Unquoted variable name prefix - { - const unquotedVarNamePrefixResult = handleUnquotedVarNamePrefix( - ctx, - wordParts, - ); - if (unquotedVarNamePrefixResult !== null) { - return unquotedVarNamePrefixResult; - } - } - - // Unquoted array keys - { - const unquotedArrayKeysResult = handleUnquotedArrayKeys(ctx, wordParts); - if (unquotedArrayKeysResult !== null) { - return unquotedArrayKeysResult; - } - } - - // Unquoted positional with prefix/suffix - { - const unquotedPrefixSuffixResult = - await handleUnquotedPositionalWithPrefixSuffix( - ctx, - wordParts, - deps.expandPart, - ); - if (unquotedPrefixSuffixResult !== null) { - return unquotedPrefixSuffixResult; - } - } - - return null; -} - -/** - * Find word-producing expansion in a part - */ -function findWordProducingExpansion( - part: WordPart, -): - | { type: "array"; name: string; atIndex: number; isStar: boolean } - | { type: "positional"; atIndex: number; isStar: boolean } - | null { - if (part.type !== "DoubleQuoted") return null; - - for (let i = 0; i < part.parts.length; i++) { - const inner = part.parts[i]; - if (inner.type !== "ParameterExpansion") continue; - if (inner.operation) continue; - - const arrayMatch = inner.parameter.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[([@*])\]$/, - ); - if (arrayMatch) { - return { - type: "array", - name: arrayMatch[1], - atIndex: i, - isStar: arrayMatch[2] === "*", - }; - } - - if (inner.parameter === "@" || inner.parameter === "*") { - return { - type: "positional", - atIndex: i, - isStar: inner.parameter === "*", - }; - } - } - return null; -} - -/** - * Expand a DoubleQuoted part with word-producing expansion - */ -async function expandDoubleQuotedWithWordProducing( - ctx: InterpreterContext, - part: WordPart & { type: "DoubleQuoted" }, - info: - | { type: "array"; name: string; atIndex: number; isStar: boolean } - | { type: "positional"; atIndex: number; isStar: boolean }, - expandPart: (ctx: InterpreterContext, part: WordPart) => Promise, -): Promise { - let prefix = ""; - for (let i = 0; i < info.atIndex; i++) { - prefix += await expandPart(ctx, part.parts[i]); - } - - let suffix = ""; - for (let i = info.atIndex + 1; i < part.parts.length; i++) { - suffix += await expandPart(ctx, part.parts[i]); - } - - let values: string[]; - if (info.type === "array") { - const elements = getArrayElements(ctx, info.name); - values = elements.map(([, v]) => v); - if (values.length === 0) { - const scalarValue = ctx.state.env.get(info.name); - if (scalarValue !== undefined) { - values = [scalarValue]; - } - } - } else { - const numParams = Number.parseInt(ctx.state.env.get("#") || "0", 10); - values = []; - for (let i = 1; i <= numParams; i++) { - values.push(ctx.state.env.get(String(i)) || ""); - } - } - - if (info.isStar) { - const ifsSep = getIfsSeparator(ctx.state.env); - const joined = values.join(ifsSep); - return [prefix + joined + suffix]; - } - - if (values.length === 0) { - const combined = prefix + suffix; - return combined ? [combined] : []; - } - - if (values.length === 1) { - return [prefix + values[0] + suffix]; - } - - return [ - prefix + values[0], - ...values.slice(1, -1), - values[values.length - 1] + suffix, - ]; -} - -/** - * Expand mixed word parts with word-producing expansions - */ -async function expandMixedWordParts( - ctx: InterpreterContext, - wordParts: WordPart[], - expandPart: (ctx: InterpreterContext, part: WordPart) => Promise, -): Promise { - if (wordParts.length < 2) return null; - - let hasWordProducing = false; - for (const part of wordParts) { - if (findWordProducingExpansion(part)) { - hasWordProducing = true; - break; - } - } - if (!hasWordProducing) return null; - - const ifsChars = getIfs(ctx.state.env); - const ifsEmpty = isIfsEmpty(ctx.state.env); - - const partWords: string[][] = []; - - for (const part of wordParts) { - const wpInfo = findWordProducingExpansion(part); - - if (wpInfo && part.type === "DoubleQuoted") { - const words = await expandDoubleQuotedWithWordProducing( - ctx, - part, - wpInfo, - expandPart, - ); - partWords.push(words); - } else if (part.type === "DoubleQuoted" || part.type === "SingleQuoted") { - const value = await expandPart(ctx, part); - partWords.push([value]); - } else if (part.type === "Literal") { - partWords.push([part.value]); - } else if (part.type === "ParameterExpansion") { - const value = await expandPart(ctx, part); - if (ifsEmpty) { - partWords.push(value ? [value] : []); - } else { - const split = splitByIfsForExpansion(value, ifsChars); - partWords.push(split); - } - } else { - const value = await expandPart(ctx, part); - if (ifsEmpty) { - partWords.push(value ? [value] : []); - } else { - const split = splitByIfsForExpansion(value, ifsChars); - partWords.push(split); - } - } - } - - const result: string[] = []; - - for (const words of partWords) { - if (words.length === 0) { - continue; - } - - if (result.length === 0) { - result.push(...words); - } else { - const lastIdx = result.length - 1; - result[lastIdx] = result[lastIdx] + words[0]; - for (let j = 1; j < words.length; j++) { - result.push(words[j]); - } - } - } - - return result; -} - -/** - * Apply glob expansion to values - */ -async function applyGlobToValues( - ctx: InterpreterContext, - values: string[], -): Promise<{ values: string[]; quoted: boolean }> { - if (ctx.state.options.noglob) { - return { values, quoted: false }; - } - - const expandedValues: string[] = []; - for (const v of values) { - if (hasGlobPattern(v, ctx.state.shoptOptions.extglob)) { - const matches = await expandGlobPattern(ctx, v); - expandedValues.push(...matches); - } else { - expandedValues.push(v); - } - } - return { values: expandedValues, quoted: false }; -} - -/** - * Expand a glob pattern - */ -async function expandGlobPattern( - ctx: InterpreterContext, - pattern: string, -): Promise { - const globExpander = new GlobExpander(ctx.fs, ctx.state.cwd, ctx.state.env, { - globstar: ctx.state.shoptOptions.globstar, - nullglob: ctx.state.shoptOptions.nullglob, - failglob: ctx.state.shoptOptions.failglob, - dotglob: ctx.state.shoptOptions.dotglob, - extglob: ctx.state.shoptOptions.extglob, - globskipdots: ctx.state.shoptOptions.globskipdots, - maxGlobOperations: ctx.limits.maxGlobOperations, - }); - const matches = await globExpander.expand(pattern); - if (matches.length > 0) { - return matches; - } - if (globExpander.hasFailglob()) { - throw new GlobError(pattern); - } - if (globExpander.hasNullglob()) { - return []; - } - return [pattern]; -} - -/** - * Handle final glob expansion after word expansion - */ -async function handleFinalGlobExpansion( - ctx: InterpreterContext, - word: WordNode, - wordParts: WordPart[], - value: string, - hasQuoted: boolean, - expandWordForGlobbing: ( - ctx: InterpreterContext, - word: WordNode, - ) => Promise, -): Promise<{ values: string[]; quoted: boolean }> { - const hasGlobParts = wordParts.some((p) => p.type === "Glob"); - - if (!ctx.state.options.noglob && hasGlobParts) { - const globPattern = await expandWordForGlobbing(ctx, word); - - if (hasGlobPattern(globPattern, ctx.state.shoptOptions.extglob)) { - const matches = await expandGlobPattern(ctx, globPattern); - if (matches.length > 0 && matches[0] !== globPattern) { - return { values: matches, quoted: false }; - } - if (matches.length === 0) { - return { values: [], quoted: false }; - } - } - - const unescapedValue = unescapeGlobPattern(value); - if (!isIfsEmpty(ctx.state.env)) { - const ifsChars = getIfs(ctx.state.env); - const splitValues = splitByIfsForExpansion(unescapedValue, ifsChars); - return { values: splitValues, quoted: false }; - } - return { values: [unescapedValue], quoted: false }; - } - - if ( - !hasQuoted && - !ctx.state.options.noglob && - hasGlobPattern(value, ctx.state.shoptOptions.extglob) - ) { - const globPattern = await expandWordForGlobbing(ctx, word); - - if (hasGlobPattern(globPattern, ctx.state.shoptOptions.extglob)) { - const matches = await expandGlobPattern(ctx, globPattern); - if (matches.length > 0 && matches[0] !== globPattern) { - return { values: matches, quoted: false }; - } - } - } - - if (value === "" && !hasQuoted) { - return { values: [], quoted: false }; - } - - if (hasGlobParts && !hasQuoted) { - const unescapedValue = unescapeGlobPattern(value); - if (!isIfsEmpty(ctx.state.env)) { - const ifsChars = getIfs(ctx.state.env); - const splitValues = splitByIfsForExpansion(unescapedValue, ifsChars); - return { values: splitValues, quoted: false }; - } - return { values: [unescapedValue], quoted: false }; - } - - return { values: [value], quoted: hasQuoted }; -} diff --git a/src/interpreter/expansion/word-split.ts b/src/interpreter/expansion/word-split.ts deleted file mode 100644 index 113f390d..00000000 --- a/src/interpreter/expansion/word-split.ts +++ /dev/null @@ -1,561 +0,0 @@ -/** - * Word Splitting - * - * IFS-based word splitting for unquoted expansions. - */ - -import type { ParameterExpansionPart, WordPart } from "../../ast/types.js"; -import { getVariable, isVariableSet } from "../expansion/variable.js"; -import { splitByIfsForExpansionEx } from "../helpers/ifs.js"; -import type { InterpreterContext } from "../types.js"; -import { - globPatternHasVarRef, - isOperationWordEntirelyQuoted, -} from "./analysis.js"; - -/** - * Type for the expandPart function that will be injected - */ -export type ExpandPartFn = ( - ctx: InterpreterContext, - part: WordPart, -) => Promise; - -/** - * Check if a ParameterExpansion with a default/alternative value should use that value. - * Returns the operation word parts if the value should be used, null otherwise. - */ -async function shouldUseOperationWord( - ctx: InterpreterContext, - part: ParameterExpansionPart, -): Promise { - const op = part.operation; - if (!op) return null; - - // Only handle DefaultValue, AssignDefault, and UseAlternative - if ( - op.type !== "DefaultValue" && - op.type !== "AssignDefault" && - op.type !== "UseAlternative" - ) { - return null; - } - - const word = (op as { word?: { parts: WordPart[] } }).word; - if (!word || word.parts.length === 0) return null; - - // Check if the variable is set/empty - // Pass checkNounset=false because we're inside a default/alternative value context - // where unset variables are allowed - const isSet = await isVariableSet(ctx, part.parameter); - const value = await getVariable(ctx, part.parameter, false); - const isEmpty = value === ""; - const checkEmpty = (op as { checkEmpty?: boolean }).checkEmpty ?? false; - - let shouldUse: boolean; - if (op.type === "UseAlternative") { - // ${var+word} - use word if var IS set (and non-empty if :+) - shouldUse = isSet && !(checkEmpty && isEmpty); - } else { - // ${var-word} / ${var=word} - use word if var is NOT set (or empty if :-) - shouldUse = !isSet || (checkEmpty && isEmpty); - } - - if (!shouldUse) return null; - - return word.parts; -} - -/** - * Check if a DoubleQuoted part contains only simple literals (no expansions). - * This is used to determine if special IFS handling is needed. - */ -function isSimpleQuotedLiteral(part: WordPart): boolean { - if (part.type === "SingleQuoted") { - return true; // Single quotes always contain only literals - } - if (part.type === "DoubleQuoted") { - const dqPart = part as { parts: WordPart[] }; - // Check that all parts inside the double quotes are literals - return dqPart.parts.every((p) => p.type === "Literal"); - } - return false; -} - -/** - * Check if a ParameterExpansion has a default/alternative value with mixed quoted/unquoted parts. - * These need special handling to preserve quote boundaries during IFS splitting. - * - * This function returns non-null only when: - * 1. The default value has mixed quoted and unquoted parts - * 2. The quoted parts contain only simple literals (no $@, $*, or other expansions) - * - * Cases like ${var:-"$@"x} should NOT use special handling because $@ has special - * behavior that needs to be preserved. - */ -async function hasMixedQuotedDefaultValue( - ctx: InterpreterContext, - part: WordPart, -): Promise { - if (part.type !== "ParameterExpansion") return null; - - const opWordParts = await shouldUseOperationWord(ctx, part); - if (!opWordParts || opWordParts.length <= 1) return null; - - // Check if the operation word has simple quoted parts (only literals inside) - const hasSimpleQuotedParts = opWordParts.some((p) => - isSimpleQuotedLiteral(p), - ); - const hasUnquotedParts = opWordParts.some( - (p) => - p.type === "Literal" || - p.type === "ParameterExpansion" || - p.type === "CommandSubstitution" || - p.type === "ArithmeticExpansion", - ); - - // Only apply special handling when we have simple quoted literals and unquoted parts - // This handles cases like ${var:-"2_3"x_x"4_5"} where the IFS char should only - // split at the unquoted underscore, not inside the quoted strings - if (hasSimpleQuotedParts && hasUnquotedParts) { - return opWordParts; - } - - return null; -} - -/** - * Check if a word part is splittable (subject to IFS splitting). - * Unquoted parameter expansions, command substitutions, and arithmetic expansions - * are splittable. Quoted parts (DoubleQuoted, SingleQuoted) are NOT splittable. - */ -function isPartSplittable(part: WordPart): boolean { - // Quoted parts are never splittable - if (part.type === "DoubleQuoted" || part.type === "SingleQuoted") { - return false; - } - - // Literal parts are not splittable (they join with adjacent fields) - if (part.type === "Literal") { - return false; - } - - // Glob parts are splittable only if they contain variable references - // e.g., +($ABC) where ABC contains IFS characters should be split - if (part.type === "Glob") { - return globPatternHasVarRef(part.pattern); - } - - // Check for splittable expansion types - const isSplittable = - part.type === "ParameterExpansion" || - part.type === "CommandSubstitution" || - part.type === "ArithmeticExpansion"; - - if (!isSplittable) { - return false; - } - - // Word splitting behavior depends on whether the default value is entirely quoted: - // - // - ${v:-"AxBxC"} - entirely quoted default value, should NOT be split - // The quotes protect the entire default value from word splitting. - // - // - ${v:-x"AxBxC"x} - mixed quoted/unquoted parts, SHOULD be split - // The unquoted parts (x) act as potential word boundaries when containing IFS chars. - // The quoted part "AxBxC" is protected from internal splitting. - // - // - ${v:-AxBxC} - entirely unquoted, SHOULD be split - // All IFS chars in the result cause word boundaries. - // - // - ${v:-x"$@"x} - contains $@ in quotes with surrounding literals - // bash 5.x: word splits the entire result (each space becomes a boundary) - // bash 3.2/osh: preserves $@ element boundaries but doesn't add more splits - // - // We check isOperationWordEntirelyQuoted: if true, the expansion is non-splittable. - // If false (mixed or no quotes), word splitting applies. - if ( - part.type === "ParameterExpansion" && - isOperationWordEntirelyQuoted(part) - ) { - return false; - } - - return true; -} - -/** - * Smart word splitting for words containing expansions. - * - * In bash, word splitting respects quoted parts. When you have: - * - $a"$b" where a="1 2" and b="3 4" - * - The unquoted $a gets split by IFS: "1 2" -> ["1", "2"] - * - The quoted "$b" does NOT get split, it joins with the last field from $a - * - Result: ["1", "23 4"] (the "2" joins with "3 4") - * - * This differs from pure literal words which are never IFS-split. - * - * @param ctx - Interpreter context - * @param wordParts - Word parts to expand and split - * @param ifsChars - IFS characters for proper whitespace/non-whitespace handling - * @param ifsPattern - Regex-escaped IFS pattern for checking if splitting is needed - * @param expandPartFn - Function to expand individual parts (injected to avoid circular deps) - */ -export async function smartWordSplit( - ctx: InterpreterContext, - wordParts: WordPart[], - ifsChars: string, - _ifsPattern: string, - expandPartFn: ExpandPartFn, -): Promise { - ctx.coverage?.hit("bash:expansion:word_split"); - // Check for special case: ParameterExpansion with a default value that should be used - // In this case, we need to recursively word-split the default value's parts - // to preserve quote boundaries within the default value. - if (wordParts.length === 1 && wordParts[0].type === "ParameterExpansion") { - const paramPart = wordParts[0]; - const opWordParts = await shouldUseOperationWord(ctx, paramPart); - if (opWordParts && opWordParts.length > 0) { - // Check if the operation word has mixed quoted/unquoted parts - // that would benefit from recursive word splitting - const hasMixedParts = - opWordParts.length > 1 && - opWordParts.some( - (p) => p.type === "DoubleQuoted" || p.type === "SingleQuoted", - ) && - opWordParts.some( - (p) => - p.type === "Literal" || - p.type === "ParameterExpansion" || - p.type === "CommandSubstitution" || - p.type === "ArithmeticExpansion", - ); - - if (hasMixedParts) { - // Recursively word-split the default value's parts - // But we need special handling: Literal parts from the default value - // SHOULD be split because they're in an unquoted context - return smartWordSplitWithUnquotedLiterals( - ctx, - opWordParts, - ifsChars, - _ifsPattern, - expandPartFn, - ); - } - } - } - - // Expand all parts and track if they are splittable - // Also track if they have mixed quoted default values that need special handling - type Segment = { - value: string; - isSplittable: boolean; - /** True if this is a quoted part (DoubleQuoted or SingleQuoted) - can anchor empty words */ - isQuoted: boolean; - mixedDefaultParts?: WordPart[]; - }; - const segments: Segment[] = []; - let hasAnySplittable = false; - - for (const part of wordParts) { - const splittable = isPartSplittable(part); - const isQuoted = - part.type === "DoubleQuoted" || part.type === "SingleQuoted"; - // Check if this part has a mixed quoted/unquoted default value - const mixedDefaultParts = splittable - ? await hasMixedQuotedDefaultValue(ctx, part) - : null; - const expanded = await expandPartFn(ctx, part); - segments.push({ - value: expanded, - isSplittable: splittable, - isQuoted, - mixedDefaultParts: mixedDefaultParts ?? undefined, - }); - - if (splittable) { - hasAnySplittable = true; - } - } - - // If there's no splittable expansion, return the joined value as-is - // (pure literals are not subject to IFS splitting) - if (!hasAnySplittable) { - const joined = segments.map((s) => s.value).join(""); - return joined ? [joined] : []; - } - - // Now do the smart word splitting: - // - Splittable parts get split by IFS - // - Non-splittable parts (quoted, literals) join with adjacent fields - // - // Algorithm: - // We maintain an array of words being built. The current word is built up - // by accumulating non-split content. When we split a splittable part: - // - The first fragment joins with the current word - // - Middle fragments become separate words - // - The last fragment becomes the start of a new current word - // - // Important distinction: - // - split returning [] (empty array) = nothing to add, continue building - // - split returning [""] (array with one empty string) = produces empty word - // - split returning ["x"] = produces "x" to append to current word - - const words: string[] = []; - let currentWord = ""; - // Track if we've produced any actual words (including empty ones from splits) - let hasProducedWord = false; - // Track if the previous splittable segment ended with a trailing IFS delimiter - // If true, the next non-splittable content should start a new word - let pendingWordBreak = false; - // Track if the previous segment was a quoted empty string (can anchor empty words) - let prevWasQuotedEmpty = false; - - for (const segment of segments) { - if (!segment.isSplittable) { - // Non-splittable: append to current word (no splitting) - // BUT if we have a pending word break from a previous trailing delimiter, - // push the current word first and start a new one. - // - // Special case: if this is a quoted empty segment and we have a pending word break, - // we should produce an empty word (the quoted empty "anchors" an empty word). - if (pendingWordBreak) { - if (segment.isQuoted && segment.value === "") { - // Quoted empty after trailing IFS delimiter: push current word and an empty word - if (currentWord !== "") { - words.push(currentWord); - } - // The quoted empty anchors an empty word - words.push(""); - hasProducedWord = true; - currentWord = ""; - pendingWordBreak = false; - prevWasQuotedEmpty = true; - } else if (segment.value !== "") { - // Non-empty content: push current word (if any) and start new word - if (currentWord !== "") { - words.push(currentWord); - } - currentWord = segment.value; - pendingWordBreak = false; - prevWasQuotedEmpty = false; - } else { - // Empty non-quoted segment with pending break: just append (noop) - currentWord += segment.value; - prevWasQuotedEmpty = false; - } - } else { - currentWord += segment.value; - prevWasQuotedEmpty = segment.isQuoted && segment.value === ""; - } - } else if (segment.mixedDefaultParts) { - // Special case: ParameterExpansion with mixed quoted/unquoted default value - // We need to recursively word-split the default value's parts to preserve - // quote boundaries. This handles cases like: 1${undefined:-"2_3"x_x"4_5"}6 - // where the quoted parts "2_3" and "4_5" should NOT be split by IFS. - const splitParts = await smartWordSplitWithUnquotedLiterals( - ctx, - segment.mixedDefaultParts, - ifsChars, - _ifsPattern, - expandPartFn, - ); - - if (splitParts.length === 0) { - // Empty expansion produces nothing - } else if (splitParts.length === 1) { - currentWord += splitParts[0]; - hasProducedWord = true; - } else { - // Multiple results: first joins with current, middle are separate, last starts new - currentWord += splitParts[0]; - words.push(currentWord); - hasProducedWord = true; - - for (let i = 1; i < splitParts.length - 1; i++) { - words.push(splitParts[i]); - } - - currentWord = splitParts[splitParts.length - 1]; - } - // Reset pending word break after processing mixed default parts - pendingWordBreak = false; - prevWasQuotedEmpty = false; - } else { - // Splittable: split by IFS using extended version that tracks trailing delimiters - const { - words: parts, - hadLeadingDelimiter, - hadTrailingDelimiter, - } = splitByIfsForExpansionEx(segment.value, ifsChars); - - // If the previous segment was a quoted empty and this splittable segment - // has leading IFS delimiter, the quoted empty should anchor an empty word - if (prevWasQuotedEmpty && hadLeadingDelimiter && currentWord === "") { - words.push(""); - hasProducedWord = true; - } - - if (parts.length === 0) { - // Empty expansion produces nothing - continue building current word - // This happens for empty string or all-whitespace with default IFS - // BUT if there was a trailing delimiter (e.g., " "), mark pending word break - if (hadTrailingDelimiter) { - pendingWordBreak = true; - } - } else if (parts.length === 1) { - // Single result: just append to current word - // Note: parts[0] might be empty string (e.g., IFS='_' and var='_' produces [""]) - currentWord += parts[0]; - hasProducedWord = true; - // If there was a trailing delimiter, mark pending word break for next segment - pendingWordBreak = hadTrailingDelimiter; - } else { - // Multiple results from split: - // - First part joins with current word - // - Middle parts become separate words - // - Last part starts the new current word - currentWord += parts[0]; - words.push(currentWord); - hasProducedWord = true; - - // Add middle parts as separate words - for (let i = 1; i < parts.length - 1; i++) { - words.push(parts[i]); - } - - // Last part becomes the new current word - currentWord = parts[parts.length - 1]; - // If there was a trailing delimiter, mark pending word break for next segment - pendingWordBreak = hadTrailingDelimiter; - } - prevWasQuotedEmpty = false; - } - } - - // Add the remaining current word - // We add it if: - // - currentWord is non-empty, OR - // - we haven't produced any words yet but we've had a split that produced content - // (this handles the case of IFS='_' and var='_' -> [""]) - if (currentWord !== "") { - words.push(currentWord); - } else if (words.length === 0 && hasProducedWord) { - // The only content was from a split that produced [""] (empty string) - words.push(""); - } - - return words; -} - -/** - * Check if a string starts with an IFS character - */ -function startsWithIfs(value: string, ifsChars: string): boolean { - return value.length > 0 && ifsChars.includes(value[0]); -} - -/** - * Word splitting for default value parts where Literal parts ARE splittable. - * This is used when processing ${var:-"a b" c} where the default value has - * mixed quoted and unquoted parts. The unquoted Literal parts should be split. - */ -async function smartWordSplitWithUnquotedLiterals( - ctx: InterpreterContext, - wordParts: WordPart[], - ifsChars: string, - _ifsPattern: string, - expandPartFn: ExpandPartFn, -): Promise { - // Expand all parts and track if they are splittable - // In this context, Literal parts ARE splittable - type Segment = { value: string; isSplittable: boolean }; - const segments: Segment[] = []; - - for (const part of wordParts) { - // Quoted parts are not splittable - const isQuoted = - part.type === "DoubleQuoted" || part.type === "SingleQuoted"; - // In the context of a default value, everything non-quoted is splittable - const splittable = !isQuoted; - const expanded = await expandPartFn(ctx, part); - segments.push({ value: expanded, isSplittable: splittable }); - } - - // Word splitting algorithm - // Key difference from standard smartWordSplit: - // When a splittable segment starts with an IFS character, it causes a word break - // from the previous content, even if the split produces only one word. - const words: string[] = []; - let currentWord = ""; - let hasProducedWord = false; - let pendingWordBreak = false; - - for (const segment of segments) { - if (!segment.isSplittable) { - // Non-splittable (quoted): append to current word - // BUT if we have a pending word break, push current word first - // However, don't push an empty current word - that happens when we have - // whitespace between two quoted parts, which should just separate them - // without creating an empty word in between - if (pendingWordBreak && segment.value !== "") { - if (currentWord !== "") { - words.push(currentWord); - } - currentWord = segment.value; - pendingWordBreak = false; - } else { - currentWord += segment.value; - } - } else { - // Splittable: check if it starts with IFS (causes word break) - const startsWithIfsChar = startsWithIfs(segment.value, ifsChars); - - // If the segment starts with IFS and we have accumulated content, - // finish the current word first - if (startsWithIfsChar && currentWord !== "") { - words.push(currentWord); - currentWord = ""; - hasProducedWord = true; - } - - // Split by IFS using extended version - const { words: parts, hadTrailingDelimiter } = splitByIfsForExpansionEx( - segment.value, - ifsChars, - ); - - if (parts.length === 0) { - // Empty expansion produces nothing - if (hadTrailingDelimiter) { - pendingWordBreak = true; - } - } else if (parts.length === 1) { - currentWord += parts[0]; - hasProducedWord = true; - pendingWordBreak = hadTrailingDelimiter; - } else { - // Multiple results from split - currentWord += parts[0]; - words.push(currentWord); - hasProducedWord = true; - - for (let i = 1; i < parts.length - 1; i++) { - words.push(parts[i]); - } - - currentWord = parts[parts.length - 1]; - pendingWordBreak = hadTrailingDelimiter; - } - } - } - - if (currentWord !== "") { - words.push(currentWord); - } else if (words.length === 0 && hasProducedWord) { - words.push(""); - } - - return words; -} diff --git a/src/interpreter/functions.ts b/src/interpreter/functions.ts deleted file mode 100644 index 3efac6e6..00000000 --- a/src/interpreter/functions.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * Function Handling - * - * Handles shell function definition and invocation: - * - Function definition (adding to function table) - * - Function calls (with positional parameters and local scopes) - */ - -import type { - FunctionDefNode, - HereDocNode, - RedirectionNode, - WordNode, -} from "../ast/types.js"; -import type { ExecResult } from "../types.js"; -import { clearLocalVarStackForScope } from "./builtins/variable-assignment.js"; -import { ExitError, ReturnError } from "./errors.js"; -import { expandWord } from "./expansion.js"; -import { OK, result, throwExecutionLimit } from "./helpers/result.js"; -import { POSIX_SPECIAL_BUILTINS } from "./helpers/shell-constants.js"; -import { applyRedirections, preExpandRedirectTargets } from "./redirections.js"; -import type { InterpreterContext } from "./types.js"; - -export function executeFunctionDef( - ctx: InterpreterContext, - node: FunctionDefNode, -): ExecResult { - // In POSIX mode, special built-ins cannot be redefined as functions - // This is a fatal error that exits the script - if (ctx.state.options.posix && POSIX_SPECIAL_BUILTINS.has(node.name)) { - const stderr = `bash: line ${ctx.state.currentLine}: \`${node.name}': is a special builtin\n`; - throw new ExitError(2, "", stderr); - } - // Store the source file where this function is defined (for BASH_SOURCE) - // Use currentSource from state, or the node's sourceFile, or "main" as default - const funcWithSource: FunctionDefNode = { - ...node, - sourceFile: node.sourceFile ?? ctx.state.currentSource ?? "main", - }; - ctx.state.functions.set(node.name, funcWithSource); - return OK; -} - -/** - * Process input redirections to get stdin content for function calls. - * Handles heredocs (<<, <<-), here-strings (<<<), and file input (<). - */ -async function processInputRedirections( - ctx: InterpreterContext, - redirections: RedirectionNode[], -): Promise { - let stdin = ""; - - for (const redir of redirections) { - if ( - (redir.operator === "<<" || redir.operator === "<<-") && - redir.target.type === "HereDoc" - ) { - const hereDoc = redir.target as HereDocNode; - let content = await expandWord(ctx, hereDoc.content); - // <<- strips leading tabs from each line - if (hereDoc.stripTabs) { - content = content - .split("\n") - .map((line) => line.replace(/^\t+/, "")) - .join("\n"); - } - // Only handle fd 0 (stdin) for now - const fd = redir.fd ?? 0; - if (fd === 0) { - stdin = content; - } - } else if (redir.operator === "<<<" && redir.target.type === "Word") { - stdin = `${await expandWord(ctx, redir.target as WordNode)}\n`; - } else if (redir.operator === "<" && redir.target.type === "Word") { - const target = await expandWord(ctx, redir.target as WordNode); - const filePath = ctx.fs.resolvePath(ctx.state.cwd, target); - try { - stdin = await ctx.fs.readFile(filePath); - } catch { - // File not found - stdin remains unchanged - } - } - } - - return stdin; -} - -export async function callFunction( - ctx: InterpreterContext, - func: FunctionDefNode, - args: string[], - stdin = "", - callLine?: number, -): Promise { - ctx.state.callDepth++; - if (ctx.state.callDepth > ctx.limits.maxCallDepth) { - ctx.state.callDepth--; - throwExecutionLimit( - `${func.name}: maximum recursion depth (${ctx.limits.maxCallDepth}) exceeded, increase executionLimits.maxCallDepth`, - "recursion", - ); - } - - // Track call stack for FUNCNAME, BASH_LINENO, and BASH_SOURCE - // Initialize stacks if not present - if (!ctx.state.funcNameStack) { - ctx.state.funcNameStack = []; - } - if (!ctx.state.callLineStack) { - ctx.state.callLineStack = []; - } - if (!ctx.state.sourceStack) { - ctx.state.sourceStack = []; - } - - // Push the function name and the line where it was called from - ctx.state.funcNameStack.unshift(func.name); - // Use provided callLine, or fall back to currentLine - ctx.state.callLineStack.unshift(callLine ?? ctx.state.currentLine); - // Push the source file where this function was defined (for BASH_SOURCE) - ctx.state.sourceStack.unshift(func.sourceFile ?? "main"); - - ctx.state.localScopes.push(new Map()); - - // Push a new set for tracking exports made in this scope - if (!ctx.state.localExportedVars) { - ctx.state.localExportedVars = []; - } - ctx.state.localExportedVars.push(new Set()); - - const savedPositional = new Map(); - for (let i = 0; i < args.length; i++) { - savedPositional.set(String(i + 1), ctx.state.env.get(String(i + 1))); - ctx.state.env.set(String(i + 1), args[i]); - } - savedPositional.set("@", ctx.state.env.get("@")); - savedPositional.set("#", ctx.state.env.get("#")); - ctx.state.env.set("@", args.join(" ")); - ctx.state.env.set("#", String(args.length)); - - const cleanup = (): void => { - // Get the scope index before popping (for localVarStack cleanup) - const scopeIndex = ctx.state.localScopes.length - 1; - - const localScope = ctx.state.localScopes.pop(); - if (localScope) { - for (const [varName, originalValue] of localScope) { - if (originalValue === undefined) { - ctx.state.env.delete(varName); - } else { - ctx.state.env.set(varName, originalValue); - } - } - } - - // Clear any localVarStack entries for this scope - clearLocalVarStackForScope(ctx, scopeIndex); - - // Clear fullyUnsetLocals entries for this scope only - if (ctx.state.fullyUnsetLocals) { - for (const [name, entryScope] of ctx.state.fullyUnsetLocals.entries()) { - if (entryScope === scopeIndex) { - ctx.state.fullyUnsetLocals.delete(name); - } - } - } - - // Pop local export tracking and restore export state - // If a variable was exported only in this scope, unmark it - if (ctx.state.localExportedVars && ctx.state.localExportedVars.length > 0) { - const localExports = ctx.state.localExportedVars.pop(); - if (localExports) { - for (const name of localExports) { - // Remove the export attribute since the local scope is gone - ctx.state.exportedVars?.delete(name); - } - } - } - - for (const [key, value] of savedPositional) { - if (value === undefined) { - ctx.state.env.delete(key); - } else { - ctx.state.env.set(key, value); - } - } - - // Pop from call stack tracking - ctx.state.funcNameStack?.shift(); - ctx.state.callLineStack?.shift(); - ctx.state.sourceStack?.shift(); - - ctx.state.callDepth--; - }; - - // Pre-expand redirect targets BEFORE executing the function body. - // This is critical because redirections like `fun() { echo $i; } > file$((i++))` - // must evaluate $((i++)) before the body runs, so the body sees the new value. - const { targets: preExpandedTargets, error: expandError } = - await preExpandRedirectTargets(ctx, func.redirections); - - if (expandError) { - cleanup(); - return result("", expandError, 1); - } - - try { - // Process redirections on the function definition to get stdin - // Only use redirection-based stdin if no pipeline stdin was passed - const redirectionStdin = await processInputRedirections( - ctx, - func.redirections, - ); - const effectiveStdin = stdin || redirectionStdin; - const execResult = await ctx.executeCommand(func.body, effectiveStdin); - cleanup(); - // Apply output redirections from the function definition using pre-expanded targets - // e.g., fun() { echo hi; } 1>&2 should redirect output to stderr when called - return applyRedirections( - ctx, - execResult, - func.redirections, - preExpandedTargets, - ); - } catch (error) { - cleanup(); - // Handle return statement - convert to normal exit with the specified code - if (error instanceof ReturnError) { - const returnResult = result(error.stdout, error.stderr, error.exitCode); - // Apply output redirections even when returning - return applyRedirections( - ctx, - returnResult, - func.redirections, - preExpandedTargets, - ); - } - throw error; - } -} diff --git a/src/interpreter/helpers/array.ts b/src/interpreter/helpers/array.ts deleted file mode 100644 index fc222216..00000000 --- a/src/interpreter/helpers/array.ts +++ /dev/null @@ -1,344 +0,0 @@ -/** - * Array helper functions for the interpreter. - */ - -import type { WordNode } from "../../ast/types.js"; -import type { InterpreterContext } from "../types.js"; - -/** - * Get all indices of an array, sorted in ascending order. - * Arrays are stored as `name_0`, `name_1`, etc. in the environment. - */ -export function getArrayIndices( - ctx: InterpreterContext, - arrayName: string, -): number[] { - const prefix = `${arrayName}_`; - const indices: number[] = []; - - for (const key of ctx.state.env.keys()) { - if (key.startsWith(prefix)) { - const indexStr = key.slice(prefix.length); - const index = Number.parseInt(indexStr, 10); - // Only include numeric indices (not __length or other metadata) - if (!Number.isNaN(index) && String(index) === indexStr) { - indices.push(index); - } - } - } - - return indices.sort((a, b) => a - b); -} - -/** - * Clear all elements of an array from the environment. - */ -export function clearArray(ctx: InterpreterContext, arrayName: string): void { - const prefix = `${arrayName}_`; - for (const key of ctx.state.env.keys()) { - if (key.startsWith(prefix)) { - ctx.state.env.delete(key); - } - } -} - -/** - * Get all keys of an associative array. - * For associative arrays, keys are stored as `name_key` where key is a string. - */ -export function getAssocArrayKeys( - ctx: InterpreterContext, - arrayName: string, -): string[] { - const prefix = `${arrayName}_`; - const metadataSuffix = `${arrayName}__length`; - const keys: string[] = []; - - for (const envKey of ctx.state.env.keys()) { - // Skip the metadata entry (name__length) - if (envKey === metadataSuffix) { - continue; - } - if (envKey.startsWith(prefix)) { - const key = envKey.slice(prefix.length); - // Skip if the key itself starts with underscore (would be part of metadata pattern) - // This handles edge cases like name__foo where foo could be confused with metadata - if (key.startsWith("_length")) { - continue; - } - keys.push(key); - } - } - - return keys.sort(); -} - -/** - * Remove surrounding quotes from a key string. - * Handles 'key' and "key" → key - */ -export function unquoteKey(key: string): string { - if ( - (key.startsWith("'") && key.endsWith("'")) || - (key.startsWith('"') && key.endsWith('"')) - ) { - return key.slice(1, -1); - } - return key; -} - -/** - * Parse a keyed array element from an AST WordNode like [key]=value or [key]+=value. - * Returns { key, valueParts, append } where valueParts are the AST parts for the value. - * Returns null if not a keyed element pattern. - * - * This is used to properly expand variables in the value part of keyed elements. - */ -export interface ParsedKeyedElement { - key: string; - valueParts: WordNode["parts"]; - append: boolean; -} - -export function parseKeyedElementFromWord( - word: WordNode, -): ParsedKeyedElement | null { - if (word.parts.length < 2) return null; - - const first = word.parts[0]; - const second = word.parts[1]; - - // Check for [key]= or [key]+= pattern - // First part should be a Glob with pattern like "[key]" or just "[" - // - // Special cases: - // 1. Nested brackets like [a[0]] are parsed as: - // - Glob with pattern "[a[0]" (the inner [ starts a new character class, - // which closes at the first ]) - // - Literal with value "]=..." (the outer ] and the =) - // - // 2. Double-quoted keys like ["key"]= are parsed as: - // - Glob with pattern "[" (just the opening bracket) - // - DoubleQuoted with the key - // - Literal with value "]=" or "]+=" - // - // We need to handle all these cases. - - if (first.type !== "Glob" || !first.pattern.startsWith("[")) { - return null; - } - - let key: string; - let secondPart: WordNode["parts"][0] = second; - let secondPartIndex = 1; - - // Check if this is a nested bracket case by looking at second.value - // If second starts with "]", this is nested bracket case - if (second.type === "Literal" && second.value.startsWith("]")) { - // Nested bracket case: [a[0]]= is parsed as Glob("[a[0]") + Literal("]=...") - // The key is first.pattern without leading [ - // For [a[0]]=10: first.pattern="[a[0]", second.value="]=10" - // Key should be "a[0]" - - const afterBracket = second.value.slice(1); // Remove the leading ] - - if (afterBracket.startsWith("+=") || afterBracket.startsWith("=")) { - // Good, we found the assignment operator - key = first.pattern.slice(1); - } else if (afterBracket === "") { - // The ] was the whole second part, check third part for = or += - if (word.parts.length < 3) return null; - const third = word.parts[2]; - if (third.type !== "Literal") return null; - if (!third.value.startsWith("=") && !third.value.startsWith("+=")) - return null; - key = first.pattern.slice(1); - secondPart = third; - secondPartIndex = 2; - } else { - // Not a valid keyed element pattern - return null; - } - } else if ( - first.pattern === "[" && - (second.type === "DoubleQuoted" || second.type === "SingleQuoted") - ) { - // Double/single-quoted key case: ["key"]= or ['key']= - // The key is in the second part, and third part should be ]= - if (word.parts.length < 3) return null; - const third = word.parts[2]; - if (third.type !== "Literal") return null; - if (!third.value.startsWith("]=") && !third.value.startsWith("]+=")) - return null; - - // Extract key from the quoted part - if (second.type === "SingleQuoted") { - key = second.value; - } else { - // DoubleQuoted - extract literal content from inner parts - key = ""; - for (const inner of second.parts) { - if (inner.type === "Literal") { - key += inner.value; - } else if (inner.type === "Escaped") { - key += inner.value; - } - // For now, skip variable expansions in keys (complex case) - } - } - secondPart = third; - secondPartIndex = 2; - } else if (first.pattern.endsWith("]")) { - // Normal case: [key]= where key has no nested brackets - // Second part should be a Literal starting with "=" or "+=" - if (second.type !== "Literal") return null; - if (!second.value.startsWith("=") && !second.value.startsWith("+=")) - return null; - - // Extract key from the Glob pattern (remove [ and ]) - key = first.pattern.slice(1, -1); - } else { - // Pattern doesn't end with ] and second doesn't start with ] - // This is not a valid keyed element - return null; - } - - // Remove surrounding quotes from key - key = unquoteKey(key); - - // Get the actual content after = or += from secondPart - // secondPart is a Literal that either starts with "=" or "+=" directly, - // or for nested brackets, starts with "]=" or "]+=" - let assignmentContent: string; - if (secondPart.type !== "Literal") return null; - - if (secondPart.value.startsWith("]=")) { - assignmentContent = secondPart.value.slice(1); // Remove leading ] - } else if (secondPart.value.startsWith("]+=")) { - assignmentContent = secondPart.value.slice(1); // Remove leading ] - } else { - assignmentContent = secondPart.value; - } - - // Determine if this is an append operation - const append = assignmentContent.startsWith("+="); - if (!append && !assignmentContent.startsWith("=")) return null; - - // Extract value parts: everything after the = (or +=) - // Convert BraceExpansion nodes to Literal nodes to prevent brace expansion - // in keyed element values (bash behavior: a=([k]=-{a,b}-) keeps literal braces) - const valueParts: WordNode["parts"] = []; - - // The second part may have content after the = sign - const eqLen = append ? 2 : 1; // "+=" vs "=" - const afterEq = assignmentContent.slice(eqLen); - if (afterEq) { - valueParts.push({ type: "Literal", value: afterEq }); - } - - // Add remaining parts (parts[secondPartIndex+1], etc.) - // Converting BraceExpansion to Literal - for (let i = secondPartIndex + 1; i < word.parts.length; i++) { - const part = word.parts[i]; - if (part.type === "BraceExpansion") { - // Convert brace expansion to literal string - valueParts.push({ type: "Literal", value: braceToLiteral(part) }); - } else { - valueParts.push(part); - } - } - - return { key, valueParts, append }; -} - -/** - * Convert a BraceExpansion node back to its literal form. - * e.g., {a,b,c} or {1..5} - */ -function braceToLiteral(part: { - type: "BraceExpansion"; - items: Array< - | { - type: "Range"; - start: string | number; - end: string | number; - step?: number; - startStr?: string; - endStr?: string; - } - | { type: "Word"; word: WordNode } - >; -}): string { - const items = part.items.map((item) => { - if (item.type === "Range") { - // Use startStr/endStr if available, otherwise use start/end - const startS = item.startStr ?? String(item.start); - const endS = item.endStr ?? String(item.end); - let range = `${startS}..${endS}`; - if (item.step) range += `..${item.step}`; - return range; - } - return wordToLiteralString(item.word); - }); - return `{${items.join(",")}}`; -} - -/** - * Extract literal string content from a Word node (without expansion). - * This is used for parsing associative array element syntax like [key]=value - * where the [key] part may be parsed as a Glob. - */ -export function wordToLiteralString(word: WordNode): string { - let result = ""; - for (const part of word.parts) { - switch (part.type) { - case "Literal": - result += part.value; - break; - case "Glob": - // Glob patterns in assoc array syntax are actually literal keys - result += part.pattern; - break; - case "SingleQuoted": - result += part.value; - break; - case "DoubleQuoted": - // For double-quoted parts, recursively extract literals - for (const inner of part.parts) { - if (inner.type === "Literal") { - result += inner.value; - } else if (inner.type === "Escaped") { - result += inner.value; - } - // Skip variable expansions etc. for now - } - break; - case "Escaped": - result += part.value; - break; - case "BraceExpansion": - // For brace expansions in array element context, convert to literal - // e.g., {a,b} becomes literal "{a,b}" - result += "{"; - result += part.items - .map((item) => - item.type === "Range" - ? `${item.startStr}..${item.endStr}${item.step ? `..${item.step}` : ""}` - : wordToLiteralString(item.word), - ) - .join(","); - result += "}"; - break; - case "TildeExpansion": - // Convert TildeExpansion node back to ~ or ~user literal - // The caller will handle actual tilde expansion - result += "~"; - if (part.user) { - result += part.user; - } - break; - // Skip other types (parameter expansions, command substitutions, etc.) - } - } - return result; -} diff --git a/src/interpreter/helpers/condition.ts b/src/interpreter/helpers/condition.ts deleted file mode 100644 index f7d95b8a..00000000 --- a/src/interpreter/helpers/condition.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Condition execution helper for the interpreter. - * - * Handles executing condition statements with proper inCondition state management. - * Used by if, while, and until loops. - */ - -import type { StatementNode } from "../../ast/types.js"; -import type { InterpreterContext } from "../types.js"; - -export interface ConditionResult { - stdout: string; - stderr: string; - exitCode: number; -} - -/** - * Execute condition statements with inCondition flag set. - * This prevents errexit from triggering during condition evaluation. - * - * @param ctx - Interpreter context - * @param statements - Condition statements to execute - * @returns Accumulated stdout, stderr, and final exit code - */ -export async function executeCondition( - ctx: InterpreterContext, - statements: StatementNode[], -): Promise { - const savedInCondition = ctx.state.inCondition; - ctx.state.inCondition = true; - - let stdout = ""; - let stderr = ""; - let exitCode = 0; - - try { - for (const stmt of statements) { - const result = await ctx.executeStatement(stmt); - stdout += result.stdout; - stderr += result.stderr; - exitCode = result.exitCode; - } - } finally { - ctx.state.inCondition = savedInCondition; - } - - return { stdout, stderr, exitCode }; -} diff --git a/src/interpreter/helpers/errors.ts b/src/interpreter/helpers/errors.ts deleted file mode 100644 index 45d910e7..00000000 --- a/src/interpreter/helpers/errors.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Error helper functions for the interpreter. - */ - -/** - * Extract message from an unknown error value. - * Handles both Error instances and other thrown values. - */ -export function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/src/interpreter/helpers/file-tests.ts b/src/interpreter/helpers/file-tests.ts deleted file mode 100644 index a4c31074..00000000 --- a/src/interpreter/helpers/file-tests.ts +++ /dev/null @@ -1,289 +0,0 @@ -import type { InterpreterContext } from "../types.js"; - -/** - * Resolve a path relative to the current working directory. - */ -function resolvePath(ctx: InterpreterContext, path: string): string { - return ctx.fs.resolvePath(ctx.state.cwd, path); -} - -/** - * File test operators supported by bash - * Unary operators that test file properties - */ -const FILE_TEST_OPERATORS = [ - "-e", // file exists - "-a", // file exists (deprecated synonym for -e) - "-f", // regular file - "-d", // directory - "-r", // readable - "-w", // writable - "-x", // executable - "-s", // file exists and has size > 0 - "-L", // symbolic link - "-h", // symbolic link (synonym for -L) - "-k", // sticky bit set - "-g", // setgid bit set - "-u", // setuid bit set - "-G", // owned by effective group ID - "-O", // owned by effective user ID - "-b", // block special file - "-c", // character special file - "-p", // named pipe (FIFO) - "-S", // socket - "-t", // file descriptor is open and refers to a terminal - "-N", // file has been modified since last read -] as const; - -export type FileTestOperator = (typeof FILE_TEST_OPERATORS)[number]; - -export function isFileTestOperator(op: string): op is FileTestOperator { - return FILE_TEST_OPERATORS.includes(op as FileTestOperator); -} - -/** - * Evaluates a file test operator (-e, -f, -d, etc.) against a path. - * Returns a boolean result. - * - * @param ctx - Interpreter context with filesystem access - * @param operator - The file test operator (e.g., "-f", "-d", "-e") - * @param operand - The path to test (will be resolved relative to cwd) - */ -export async function evaluateFileTest( - ctx: InterpreterContext, - operator: string, - operand: string, -): Promise { - const path = resolvePath(ctx, operand); - - switch (operator) { - case "-e": - case "-a": - // File exists - return ctx.fs.exists(path); - - case "-f": { - // Regular file - if (await ctx.fs.exists(path)) { - const stat = await ctx.fs.stat(path); - return stat.isFile; - } - return false; - } - - case "-d": { - // Directory - if (await ctx.fs.exists(path)) { - const stat = await ctx.fs.stat(path); - return stat.isDirectory; - } - return false; - } - - case "-r": { - // Readable - check read permission bits - if (await ctx.fs.exists(path)) { - const stat = await ctx.fs.stat(path); - // Check user read bit (0o400) - in our sandboxed env, we act as owner - return (stat.mode & 0o400) !== 0; - } - return false; - } - - case "-w": { - // Writable - check write permission bits - if (await ctx.fs.exists(path)) { - const stat = await ctx.fs.stat(path); - // Check user write bit (0o200) - return (stat.mode & 0o200) !== 0; - } - return false; - } - - case "-x": { - // Executable - check execute permission bits - if (await ctx.fs.exists(path)) { - const stat = await ctx.fs.stat(path); - // Check user execute bit (0o100) - return (stat.mode & 0o100) !== 0; - } - return false; - } - - case "-s": { - // File exists and has size > 0 - if (await ctx.fs.exists(path)) { - const stat = await ctx.fs.stat(path); - return stat.size > 0; - } - return false; - } - - case "-L": - case "-h": { - // Symbolic link - use lstat to check without following - try { - const stat = await ctx.fs.lstat(path); - return stat.isSymbolicLink; - } catch { - return false; - } - } - - case "-k": { - // Sticky bit set (mode & 0o1000) - if (await ctx.fs.exists(path)) { - const stat = await ctx.fs.stat(path); - return (stat.mode & 0o1000) !== 0; - } - return false; - } - - case "-g": { - // Setgid bit set (mode & 0o2000) - if (await ctx.fs.exists(path)) { - const stat = await ctx.fs.stat(path); - return (stat.mode & 0o2000) !== 0; - } - return false; - } - - case "-u": { - // Setuid bit set (mode & 0o4000) - if (await ctx.fs.exists(path)) { - const stat = await ctx.fs.stat(path); - return (stat.mode & 0o4000) !== 0; - } - return false; - } - - case "-G": - case "-O": - // Owned by effective group/user ID - // In virtual fs, assume user owns everything that exists - return ctx.fs.exists(path); - - case "-b": - // Block special file - virtual fs doesn't have these - return false; - - case "-c": { - // Character special file - // In virtual fs, recognize common character devices by path - const charDevices = [ - "/dev/null", - "/dev/zero", - "/dev/random", - "/dev/urandom", - "/dev/tty", - "/dev/stdin", - "/dev/stdout", - "/dev/stderr", - ]; - return charDevices.some((dev) => path === dev || path.endsWith(dev)); - } - - case "-p": - // Named pipe (FIFO) - virtual fs doesn't have these - return false; - - case "-S": - // Socket - virtual fs doesn't have these - return false; - - case "-t": - // File descriptor refers to terminal - // operand is fd number, not path - // We don't support terminal detection - return false; - - case "-N": { - // File has been modified since last read - // We don't track read times, so just check if file exists - return ctx.fs.exists(path); - } - - default: - return false; - } -} - -/** - * Binary file test operators for comparing two files - */ -const BINARY_FILE_TEST_OPERATORS = ["-nt", "-ot", "-ef"] as const; - -export type BinaryFileTestOperator = - (typeof BINARY_FILE_TEST_OPERATORS)[number]; - -export function isBinaryFileTestOperator( - op: string, -): op is BinaryFileTestOperator { - return BINARY_FILE_TEST_OPERATORS.includes(op as BinaryFileTestOperator); -} - -/** - * Evaluates a binary file test operator (-nt, -ot, -ef) comparing two files. - * - * @param ctx - Interpreter context with filesystem access - * @param operator - The operator (-nt, -ot, -ef) - * @param left - Left operand (file path) - * @param right - Right operand (file path) - */ -export async function evaluateBinaryFileTest( - ctx: InterpreterContext, - operator: string, - left: string, - right: string, -): Promise { - const leftPath = resolvePath(ctx, left); - const rightPath = resolvePath(ctx, right); - - switch (operator) { - case "-nt": { - // left is newer than right - try { - const leftStat = await ctx.fs.stat(leftPath); - const rightStat = await ctx.fs.stat(rightPath); - return leftStat.mtime > rightStat.mtime; - } catch { - // If either file doesn't exist, result is false - return false; - } - } - - case "-ot": { - // left is older than right - try { - const leftStat = await ctx.fs.stat(leftPath); - const rightStat = await ctx.fs.stat(rightPath); - return leftStat.mtime < rightStat.mtime; - } catch { - return false; - } - } - - case "-ef": { - // Same file (same device and inode) - // In virtual fs, compare resolved canonical paths - try { - // Both files must exist - if ( - !(await ctx.fs.exists(leftPath)) || - !(await ctx.fs.exists(rightPath)) - ) { - return false; - } - // Compare canonical paths (handles symlinks) - const leftReal = ctx.fs.resolvePath(ctx.state.cwd, leftPath); - const rightReal = ctx.fs.resolvePath(ctx.state.cwd, rightPath); - return leftReal === rightReal; - } catch { - return false; - } - } - - default: - return false; - } -} diff --git a/src/interpreter/helpers/ifs.ts b/src/interpreter/helpers/ifs.ts deleted file mode 100644 index 29da8ad0..00000000 --- a/src/interpreter/helpers/ifs.ts +++ /dev/null @@ -1,470 +0,0 @@ -/** - * IFS (Internal Field Separator) Handling - * - * Centralized utilities for IFS-based word splitting used by: - * - Word expansion (unquoted variable expansion) - * - read builtin - * - ${!prefix*} and ${!arr[*]} expansions - */ - -/** Default IFS value: space, tab, newline */ -const DEFAULT_IFS = " \t\n"; - -/** - * Get the effective IFS value from environment. - * Returns DEFAULT_IFS if IFS is undefined, or the actual value (including empty string). - */ -export function getIfs(env: Map): string { - return env.get("IFS") ?? DEFAULT_IFS; -} - -/** - * Check if IFS is set to empty string (disables word splitting). - */ -export function isIfsEmpty(env: Map): boolean { - return env.get("IFS") === ""; -} - -/** - * Check if IFS contains only whitespace characters (space, tab, newline). - * This affects how empty fields are handled in $@ and $* expansion. - * When IFS has non-whitespace chars, empty params are preserved. - * When IFS has only whitespace, empty params are dropped. - */ -export function isIfsWhitespaceOnly(env: Map): boolean { - const ifs = getIfs(env); - if (ifs === "") return true; // Empty IFS counts as "whitespace only" for this purpose - for (const ch of ifs) { - if (ch !== " " && ch !== "\t" && ch !== "\n") { - return false; - } - } - return true; -} - -/** - * Build a regex-safe pattern from IFS characters for use in character classes. - * E.g., for IFS=" \t\n", returns " \\t\\n" (escaped for [pattern] use) - */ -export function buildIfsCharClassPattern(ifs: string): string { - return ifs - .split("") - .map((c) => { - // Escape regex special chars for character class - if (/[\\^$.*+?()[\]{}|-]/.test(c)) return `\\${c}`; - if (c === "\t") return "\\t"; - if (c === "\n") return "\\n"; - return c; - }) - .join(""); -} - -/** - * Get the first character of IFS (used for joining with $* and ${!prefix*}). - * Returns space if IFS is undefined, empty string if IFS is empty. - */ -export function getIfsSeparator(env: Map): string { - const ifs = env.get("IFS"); - if (ifs === undefined) return " "; - return ifs[0] || ""; -} - -/** IFS whitespace characters */ -const IFS_WHITESPACE = " \t\n"; - -/** - * Check if a character is an IFS whitespace character. - */ -function isIfsWhitespace(ch: string): boolean { - return IFS_WHITESPACE.includes(ch); -} - -/** - * Split IFS characters into whitespace and non-whitespace sets. - */ -function categorizeIfs(ifs: string): { - whitespace: Set; - nonWhitespace: Set; -} { - const whitespace = new Set(); - const nonWhitespace = new Set(); - for (const ch of ifs) { - if (isIfsWhitespace(ch)) { - whitespace.add(ch); - } else { - nonWhitespace.add(ch); - } - } - return { whitespace, nonWhitespace }; -} - -/** - * Advanced IFS splitting for the read builtin with proper whitespace/non-whitespace handling. - * - * IFS has two types of characters: - * - Whitespace (space, tab, newline): Multiple consecutive ones are collapsed, - * leading/trailing are stripped - * - Non-whitespace (like 'x', ':'): Create empty fields when consecutive, - * trailing ones preserved (except the final delimiter) - * - * @param value - String to split - * @param ifs - IFS characters to split on - * @param maxSplit - Maximum number of splits (for read with multiple vars, the last gets the rest) - * @param raw - If true, backslash escaping is disabled (like read -r) - * @returns Object with words array and wordStarts array - */ -export function splitByIfsForRead( - value: string, - ifs: string, - maxSplit?: number, - raw?: boolean, -): { words: string[]; wordStarts: number[] } { - // Empty IFS means no splitting - if (ifs === "") { - // If value is empty, return empty array (no words) - // If value is non-empty, return the entire value as a single word - if (value === "") { - return { words: [], wordStarts: [] }; - } - return { words: [value], wordStarts: [0] }; - } - - const { whitespace, nonWhitespace } = categorizeIfs(ifs); - const words: string[] = []; - const wordStarts: number[] = []; - let pos = 0; - - // Skip leading IFS whitespace - while (pos < value.length && whitespace.has(value[pos])) { - pos++; - } - - // If we've consumed all input, return empty result - if (pos >= value.length) { - return { words: [], wordStarts: [] }; - } - - // Check for leading non-whitespace delimiter (creates empty field) - if (nonWhitespace.has(value[pos])) { - words.push(""); - wordStarts.push(pos); - pos++; - // Skip any whitespace after the delimiter - while (pos < value.length && whitespace.has(value[pos])) { - pos++; - } - } - - // Now process words - while (pos < value.length) { - // Check if we've reached maxSplit limit - if (maxSplit !== undefined && words.length >= maxSplit) { - break; - } - - const wordStart = pos; - wordStarts.push(wordStart); - - // Collect characters until we hit an IFS character - // In non-raw mode, backslash escapes the next character (protects it from being IFS) - while (pos < value.length) { - const ch = value[pos]; - // In non-raw mode, backslash escapes the next character - if (!raw && ch === "\\") { - pos++; // skip backslash - if (pos < value.length) { - pos++; // skip escaped character (it's part of the word, not IFS) - } - continue; - } - // Check if current char is IFS - if (whitespace.has(ch) || nonWhitespace.has(ch)) { - break; - } - pos++; - } - - words.push(value.substring(wordStart, pos)); - - if (pos >= value.length) { - break; - } - - // Now handle the delimiter(s) - // Skip IFS characters (whitespace before non-whitespace) - while (pos < value.length && whitespace.has(value[pos])) { - pos++; - } - - // Check for non-whitespace delimiter - if (pos < value.length && nonWhitespace.has(value[pos])) { - pos++; - - // Skip whitespace after non-whitespace delimiter - while (pos < value.length && whitespace.has(value[pos])) { - pos++; - } - - // Check for another non-whitespace delimiter (creates empty field) - while (pos < value.length && nonWhitespace.has(value[pos])) { - // Check maxSplit - if (maxSplit !== undefined && words.length >= maxSplit) { - break; - } - // Empty field for this delimiter - words.push(""); - wordStarts.push(pos); - pos++; - // Skip whitespace after - while (pos < value.length && whitespace.has(value[pos])) { - pos++; - } - } - } - - // Note: Trailing non-whitespace delimiter does NOT create an empty field. - // Empty fields are only created between consecutive non-whitespace delimiters. - // For example: "a:b:" with IFS=":" produces ['a', 'b'], not ['a', 'b', ''] - // But "a::b" with IFS=":" produces ['a', '', 'b'] (empty field between the two colons) - } - - return { words, wordStarts }; -} - -/** - * IFS splitting for word expansion (unquoted $VAR, $*, etc.). - * - * Key differences from splitByIfsForRead: - * - Trailing non-whitespace delimiter does NOT create an empty field - * - No maxSplit concept (always splits fully) - * - No backslash escape handling - * - * @param value - String to split - * @param ifs - IFS characters to split on - * @returns Array of words after splitting - */ -/** - * Result of splitByIfsForExpansionEx with leading/trailing delimiter info. - */ -export interface IfsExpansionSplitResult { - words: string[]; - /** True if the value started with an IFS whitespace delimiter (affects joining with preceding text) */ - hadLeadingDelimiter: boolean; - /** True if the value ended with an IFS delimiter (affects joining with subsequent text) */ - hadTrailingDelimiter: boolean; -} - -/** - * Extended IFS splitting that tracks trailing delimiters. - * This is needed for proper word boundary handling when literal text follows an expansion. - * For example, in `-$x-` where `x='a b c '`, the trailing space means the final `-` - * should become a separate word, not join with `c`. - */ -export function splitByIfsForExpansionEx( - value: string, - ifs: string, -): IfsExpansionSplitResult { - // Empty IFS means no splitting - if (ifs === "") { - return { - words: value ? [value] : [], - hadLeadingDelimiter: false, - hadTrailingDelimiter: false, - }; - } - - // Empty value means no words - if (value === "") { - return { - words: [], - hadLeadingDelimiter: false, - hadTrailingDelimiter: false, - }; - } - - const { whitespace, nonWhitespace } = categorizeIfs(ifs); - const words: string[] = []; - let pos = 0; - let hadLeadingDelimiter = false; - let hadTrailingDelimiter = false; - - // Skip leading IFS whitespace - const leadingStart = pos; - while (pos < value.length && whitespace.has(value[pos])) { - pos++; - } - // Track if we consumed any leading whitespace - if (pos > leadingStart) { - hadLeadingDelimiter = true; - } - - // If we've consumed all input, return empty result - if (pos >= value.length) { - // The value was all whitespace - it had both leading and trailing delimiter - return { words: [], hadLeadingDelimiter: true, hadTrailingDelimiter: true }; - } - - // Check for leading non-whitespace delimiter (creates empty field) - if (nonWhitespace.has(value[pos])) { - words.push(""); - pos++; - // Skip any whitespace after the delimiter - while (pos < value.length && whitespace.has(value[pos])) { - pos++; - } - } - - // Now process words - while (pos < value.length) { - const wordStart = pos; - - // Collect characters until we hit an IFS character - while (pos < value.length) { - const ch = value[pos]; - if (whitespace.has(ch) || nonWhitespace.has(ch)) { - break; - } - pos++; - } - - words.push(value.substring(wordStart, pos)); - - if (pos >= value.length) { - // Ended on a word, no trailing delimiter - hadTrailingDelimiter = false; - break; - } - - // Now handle the delimiter(s) - // Skip IFS whitespace - const beforeDelimiterPos = pos; - while (pos < value.length && whitespace.has(value[pos])) { - pos++; - } - - // Check for non-whitespace delimiter - if (pos < value.length && nonWhitespace.has(value[pos])) { - pos++; - - // Skip whitespace after non-whitespace delimiter - while (pos < value.length && whitespace.has(value[pos])) { - pos++; - } - - // Check for more non-whitespace delimiters (creates empty fields) - while (pos < value.length && nonWhitespace.has(value[pos])) { - // Empty field for this delimiter - words.push(""); - pos++; - // Skip whitespace after - while (pos < value.length && whitespace.has(value[pos])) { - pos++; - } - } - } - - // If we've consumed all input, we ended on a delimiter - if (pos >= value.length && pos > beforeDelimiterPos) { - hadTrailingDelimiter = true; - } - } - - return { words, hadLeadingDelimiter, hadTrailingDelimiter }; -} - -export function splitByIfsForExpansion(value: string, ifs: string): string[] { - return splitByIfsForExpansionEx(value, ifs).words; -} - -/** - * Check if string contains any non-whitespace IFS chars. - */ -function containsNonWsIfs(value: string, nonWhitespace: Set): boolean { - for (const ch of value) { - if (nonWhitespace.has(ch)) { - return true; - } - } - return false; -} - -/** - * Strip trailing IFS from the last variable in read builtin. - * - * Bash behavior: - * 1. Strip trailing IFS whitespace characters (but NOT if they're escaped by backslash) - * 2. If there's a single trailing IFS non-whitespace character, strip it ONLY IF - * there are no other non-ws IFS chars in the content (excluding the trailing one) - * - * Examples with IFS="x ": - * - "ax " -> "a" (trailing spaces stripped, then trailing single x stripped because no other x) - * - "ax" -> "a" (trailing single x stripped because no other x in remaining content) - * - "axx" -> "axx" (two trailing x's, so don't strip - there's another x) - * - "ax x" -> "ax x" (trailing x NOT stripped because there's an x earlier) - * - "bx" -> "b" (trailing x stripped, no other x) - * - "a\ " -> "a " (backslash-escaped space is NOT stripped) - * - * @param value - String to strip (raw, before backslash processing) - * @param ifs - IFS characters - * @param raw - If true, backslash escaping is disabled - */ -export function stripTrailingIfsWhitespace( - value: string, - ifs: string, - raw?: boolean, -): string { - if (ifs === "") return value; - const { whitespace, nonWhitespace } = categorizeIfs(ifs); - - // First strip trailing whitespace IFS, but stop if we hit an escaped character - let end = value.length; - while (end > 0) { - // Check if current trailing char is IFS whitespace - if (!whitespace.has(value[end - 1])) { - break; - } - // In non-raw mode, check if this char is escaped by a backslash - // A char at position i is escaped if there's a backslash at position i-1 - // But we need to count consecutive backslashes to handle \\ - if (!raw && end >= 2) { - // Count how many backslashes precede this character - let backslashCount = 0; - let pos = end - 2; - while (pos >= 0 && value[pos] === "\\") { - backslashCount++; - pos--; - } - // If odd number of backslashes, the char is escaped - stop stripping - if (backslashCount % 2 === 1) { - break; - } - } - end--; - } - const result = value.substring(0, end); - - // Check for trailing single IFS non-whitespace char - if (result.length >= 1 && nonWhitespace.has(result[result.length - 1])) { - // In non-raw mode, check if this char is escaped - if (!raw && result.length >= 2) { - let backslashCount = 0; - let pos = result.length - 2; - while (pos >= 0 && result[pos] === "\\") { - backslashCount++; - pos--; - } - // If odd number of backslashes, the char is escaped - don't strip - if (backslashCount % 2 === 1) { - return result; - } - } - - // Only strip if there are NO other non-ws IFS chars in the rest of the string - const contentWithoutTrailing = result.substring(0, result.length - 1); - if (!containsNonWsIfs(contentWithoutTrailing, nonWhitespace)) { - return contentWithoutTrailing; - } - } - - return result; -} diff --git a/src/interpreter/helpers/loop.ts b/src/interpreter/helpers/loop.ts deleted file mode 100644 index 48b00721..00000000 --- a/src/interpreter/helpers/loop.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Loop Error Handling Helpers - * - * Consolidates the repeated error handling logic used in all loop constructs - * (for, c-style for, while, until). - */ - -import { - BreakError, - ContinueError, - ErrexitError, - ExecutionLimitError, - ExitError, - ReturnError, -} from "../errors.js"; -import { getErrorMessage } from "./errors.js"; - -export type LoopAction = "break" | "continue" | "rethrow" | "error"; - -export interface LoopErrorResult { - action: LoopAction; - stdout: string; - stderr: string; - exitCode?: number; - error?: unknown; -} - -/** - * Handle errors thrown during loop body execution. - * - * @param error - The caught error - * @param stdout - Current accumulated stdout - * @param stderr - Current accumulated stderr - * @param loopDepth - Current loop nesting depth from ctx.state.loopDepth - * @returns Result indicating what action the loop should take - */ -export function handleLoopError( - error: unknown, - stdout: string, - stderr: string, - loopDepth: number, -): LoopErrorResult { - if (error instanceof BreakError) { - stdout += error.stdout; - stderr += error.stderr; - // Only propagate if levels > 1 AND we're not at the outermost loop - // Per bash docs: "If n is greater than the number of enclosing loops, - // the last enclosing loop is exited" - if (error.levels > 1 && loopDepth > 1) { - error.levels--; - error.stdout = stdout; - error.stderr = stderr; - return { action: "rethrow", stdout, stderr, error }; - } - return { action: "break", stdout, stderr }; - } - - if (error instanceof ContinueError) { - stdout += error.stdout; - stderr += error.stderr; - // Only propagate if levels > 1 AND we're not at the outermost loop - // Per bash docs: "If n is greater than the number of enclosing loops, - // the last enclosing loop is resumed" - if (error.levels > 1 && loopDepth > 1) { - error.levels--; - error.stdout = stdout; - error.stderr = stderr; - return { action: "rethrow", stdout, stderr, error }; - } - return { action: "continue", stdout, stderr }; - } - - if ( - error instanceof ReturnError || - error instanceof ErrexitError || - error instanceof ExitError || - error instanceof ExecutionLimitError - ) { - error.prependOutput(stdout, stderr); - return { action: "rethrow", stdout, stderr, error }; - } - - // Generic error - return error result - const message = getErrorMessage(error); - return { - action: "error", - stdout, - stderr: `${stderr}${message}\n`, - exitCode: 1, - }; -} diff --git a/src/interpreter/helpers/nameref.ts b/src/interpreter/helpers/nameref.ts deleted file mode 100644 index ae5a2c42..00000000 --- a/src/interpreter/helpers/nameref.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * Nameref (declare -n) support - * - * Namerefs are variables that reference other variables by name. - * When a nameref is accessed, it transparently dereferences to the target variable. - */ - -import type { InterpreterContext } from "../types.js"; - -/** - * Check if a variable is a nameref - */ -export function isNameref(ctx: InterpreterContext, name: string): boolean { - return ctx.state.namerefs?.has(name) ?? false; -} - -/** - * Mark a variable as a nameref - */ -export function markNameref(ctx: InterpreterContext, name: string): void { - ctx.state.namerefs ??= new Set(); - ctx.state.namerefs.add(name); -} - -/** - * Remove the nameref attribute from a variable - */ -export function unmarkNameref(ctx: InterpreterContext, name: string): void { - ctx.state.namerefs?.delete(name); - ctx.state.boundNamerefs?.delete(name); - ctx.state.invalidNamerefs?.delete(name); -} - -/** - * Mark a nameref as having an "invalid" target at creation time. - * Invalid namerefs always read/write their value directly, never resolving. - */ -export function markNamerefInvalid( - ctx: InterpreterContext, - name: string, -): void { - ctx.state.invalidNamerefs ??= new Set(); - ctx.state.invalidNamerefs.add(name); -} - -/** - * Check if a nameref was created with an invalid target. - */ -function isNamerefInvalid(ctx: InterpreterContext, name: string): boolean { - return ctx.state.invalidNamerefs?.has(name) ?? false; -} - -/** - * Mark a nameref as "bound" - meaning its target existed at creation time. - * This is kept for tracking purposes but is currently not used in resolution. - */ -export function markNamerefBound(ctx: InterpreterContext, name: string): void { - ctx.state.boundNamerefs ??= new Set(); - ctx.state.boundNamerefs.add(name); -} - -/** - * Check if a name refers to a valid, existing variable or array element. - * Used to determine if a nameref target is "real" or just a stored value. - */ -export function targetExists(ctx: InterpreterContext, target: string): boolean { - // Check for array subscript - const arrayMatch = target.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$/); - if (arrayMatch) { - const arrayName = arrayMatch[1]; - // Check if array exists (has any elements or is declared as assoc) - const hasElements = Array.from(ctx.state.env.keys()).some( - (k) => k.startsWith(`${arrayName}_`) && !k.includes("__"), - ); - const isAssoc = ctx.state.associativeArrays?.has(arrayName) ?? false; - return hasElements || isAssoc; - } - - // Check if it's an array (stored as target_0, target_1, etc.) - const hasArrayElements = Array.from(ctx.state.env.keys()).some( - (k) => k.startsWith(`${target}_`) && !k.includes("__"), - ); - if (hasArrayElements) { - return true; - } - - // Check if scalar variable exists - return ctx.state.env.has(target); -} - -/** - * Resolve a nameref chain to the final variable name. - * Returns the original name if it's not a nameref. - * Detects circular references and returns undefined. - * - * @param ctx - The interpreter context - * @param name - The variable name to resolve - * @param maxDepth - Maximum chain depth to prevent infinite loops (default 100) - * @returns The resolved variable name, or undefined if circular reference detected - */ -export function resolveNameref( - ctx: InterpreterContext, - name: string, - maxDepth = 100, -): string | undefined { - // If not a nameref, return as-is - if (!isNameref(ctx, name)) { - return name; - } - - // If the nameref was created with an invalid target, it should never resolve. - // It acts as a regular variable, returning its value directly. - if (isNamerefInvalid(ctx, name)) { - return name; - } - - const seen = new Set(); - let current = name; - - while (maxDepth-- > 0) { - // Detect circular reference - if (seen.has(current)) { - return undefined; - } - seen.add(current); - - // If not a nameref, we've reached the target - if (!isNameref(ctx, current)) { - return current; - } - - // Get the target name from the variable's value - const target = ctx.state.env.get(current); - if (target === undefined || target === "") { - // Empty or unset nameref - return the nameref itself - return current; - } - - // Validate target is a valid variable name (not special chars like #, @, *, etc.) - // Allow array subscripts like arr[0] or arr[@] - // Note: Numeric-only targets like '1' are NOT valid - bash doesn't resolve namerefs - // to positional parameters. The nameref keeps its literal value. - if (!/^[a-zA-Z_][a-zA-Z0-9_]*(\[.+\])?$/.test(target)) { - // Invalid nameref target - return the nameref itself (bash behavior) - return current; - } - - // Always resolve to the target for reading - // (The target may not exist, which will result in empty string on read) - current = target; - } - - // Max depth exceeded - likely circular reference - return undefined; -} - -/** - * Get the target name of a nameref (what it points to). - * Returns the variable's value if it's a nameref, undefined otherwise. - */ -export function getNamerefTarget( - ctx: InterpreterContext, - name: string, -): string | undefined { - if (!isNameref(ctx, name)) { - return undefined; - } - return ctx.state.env.get(name); -} - -/** - * Resolve a nameref for assignment purposes. - * Unlike resolveNameref, this will resolve to the target variable name - * even if the target doesn't exist yet (allowing creation). - * - * @param ctx - The interpreter context - * @param name - The variable name to resolve - * @param valueBeingAssigned - The value being assigned (needed for empty nameref handling) - * @param maxDepth - Maximum chain depth to prevent infinite loops - * @returns - * - undefined if circular reference detected - * - null if the nameref is empty and value is not an existing variable (skip assignment) - * - The resolved target name otherwise (may be the nameref itself if target is invalid) - */ -export function resolveNamerefForAssignment( - ctx: InterpreterContext, - name: string, - valueBeingAssigned?: string, - maxDepth = 100, -): string | null | undefined { - // If not a nameref, return as-is - if (!isNameref(ctx, name)) { - return name; - } - - // If the nameref was created with an invalid target, it should never resolve. - // It acts as a regular variable, so assignment goes directly to it. - if (isNamerefInvalid(ctx, name)) { - return name; - } - - const seen = new Set(); - let current = name; - - while (maxDepth-- > 0) { - // Detect circular reference - if (seen.has(current)) { - return undefined; - } - seen.add(current); - - // If not a nameref, we've reached the target - if (!isNameref(ctx, current)) { - return current; - } - - // Get the target name from the variable's value - const target = ctx.state.env.get(current); - if (target === undefined || target === "") { - // Empty or unset nameref - special handling based on value being assigned - // If the value is a valid variable name AND that variable exists, set it as target - // Otherwise, the assignment is a no-op - if (valueBeingAssigned !== undefined) { - const isValidName = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(valueBeingAssigned); - if (isValidName && targetExists(ctx, valueBeingAssigned)) { - // Value is an existing variable - set it as the target - return current; - } - // Value is not an existing variable - skip assignment (no-op) - return null; - } - // No value provided - return the nameref itself - return current; - } - - // Validate target is a valid variable name (not special chars like #, @, *, etc.) - // Allow array subscripts like arr[0] or arr[@] - if (!/^[a-zA-Z_][a-zA-Z0-9_]*(\[.+\])?$/.test(target)) { - // Invalid nameref target - assign to the nameref itself - return current; - } - - current = target; - } - - // Max depth exceeded - likely circular reference - return undefined; -} diff --git a/src/interpreter/helpers/numeric-compare.ts b/src/interpreter/helpers/numeric-compare.ts deleted file mode 100644 index 16b2e9b0..00000000 --- a/src/interpreter/helpers/numeric-compare.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Numeric comparison helper for conditionals. - * Handles -eq, -ne, -lt, -le, -gt, -ge operators. - */ - -export type NumericOp = "-eq" | "-ne" | "-lt" | "-le" | "-gt" | "-ge"; - -const NUMERIC_OPS = new Set(["-eq", "-ne", "-lt", "-le", "-gt", "-ge"]); - -/** - * Check if an operator is a numeric comparison operator. - */ -export function isNumericOp(op: string): op is NumericOp { - return NUMERIC_OPS.has(op); -} - -/** - * Compare two numbers using a numeric comparison operator. - */ -export function compareNumeric( - op: NumericOp, - left: number, - right: number, -): boolean { - switch (op) { - case "-eq": - return left === right; - case "-ne": - return left !== right; - case "-lt": - return left < right; - case "-le": - return left <= right; - case "-gt": - return left > right; - case "-ge": - return left >= right; - } -} diff --git a/src/interpreter/helpers/quoting.ts b/src/interpreter/helpers/quoting.ts deleted file mode 100644 index e838170c..00000000 --- a/src/interpreter/helpers/quoting.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Shell value quoting utilities - * - * Provides functions for quoting values in shell output format, - * used by both `set` and `declare/typeset` builtins. - */ - -/** - * Check if a character needs $'...' quoting (control characters only) - * Bash uses $'...' only for control characters (0x00-0x1F, 0x7F). - * Valid UTF-8 characters above 0x7F are output with regular single quotes. - */ -function needsDollarQuoting(value: string): boolean { - for (let i = 0; i < value.length; i++) { - const code = value.charCodeAt(i); - // Only control characters need $'...' quoting - if (code < 0x20 || code === 0x7f) { - return true; - } - } - return false; -} - -/** - * Quote a value for shell output using $'...' quoting (bash ANSI-C quoting) - * Only used for values containing control characters. - */ -function dollarQuote(value: string): string { - let result = "$'"; - for (let i = 0; i < value.length; i++) { - const char = value[i]; - const code = value.charCodeAt(i); - - if (code === 0x07) { - result += "\\a"; // bell - } else if (code === 0x08) { - result += "\\b"; // backspace - } else if (code === 0x09) { - result += "\\t"; // tab - } else if (code === 0x0a) { - result += "\\n"; // newline - } else if (code === 0x0b) { - result += "\\v"; // vertical tab - } else if (code === 0x0c) { - result += "\\f"; // form feed - } else if (code === 0x0d) { - result += "\\r"; // carriage return - } else if (code === 0x1b) { - result += "\\e"; // escape (bash extension) - } else if (code === 0x27) { - result += "\\'"; // single quote - } else if (code === 0x5c) { - result += "\\\\"; // backslash - } else if (code < 0x20 || code === 0x7f) { - // Other control characters: use octal notation (bash uses \NNN) - result += `\\${code.toString(8).padStart(3, "0")}`; - } else { - // Pass through normal characters including UTF-8 (code > 0x7f) - result += char; - } - } - result += "'"; - return result; -} - -/** - * Quote a value for shell output (used by 'set' and 'typeset' with no args) - * Matches bash's output format: - * - No quotes for simple alphanumeric values - * - Single quotes for values with spaces or shell metacharacters - * - $'...' quoting for values with control characters - */ -export function quoteValue(value: string): string { - // If value contains control characters or non-printable, use $'...' quoting - if (needsDollarQuoting(value)) { - return dollarQuote(value); - } - - // If value contains no special chars, return as-is - // Safe chars: alphanumerics, underscore, slash, dot, colon, hyphen, at, percent, plus, comma, equals - if (/^[a-zA-Z0-9_/.:\-@%+,=]*$/.test(value)) { - return value; - } - - // Use single quotes for values with spaces or shell metacharacters - // Escape embedded single quotes as '\'' - return `'${value.replace(/'/g, "'\\''")}'`; -} - -/** - * Quote a value for array element output - * Uses $'...' for control characters, double quotes otherwise - */ -export function quoteArrayValue(value: string): string { - // If value needs $'...' quoting, use it - if (needsDollarQuoting(value)) { - return dollarQuote(value); - } - // For array elements, bash always uses double quotes - // Escape backslashes and double quotes - const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - return `"${escaped}"`; -} - -/** - * Quote a value for declare -p output - * Uses $'...' for control characters, double quotes otherwise - */ -export function quoteDeclareValue(value: string): string { - // If value needs $'...' quoting, use it - if (needsDollarQuoting(value)) { - return dollarQuote(value); - } - // Otherwise use double quotes with escaping - const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - return `"${escaped}"`; -} diff --git a/src/interpreter/helpers/readonly.ts b/src/interpreter/helpers/readonly.ts deleted file mode 100644 index 03f8a8c4..00000000 --- a/src/interpreter/helpers/readonly.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Readonly and export variable helpers. - * - * Consolidates readonly and export variable logic used in declare, export, local, etc. - */ - -import type { ExecResult } from "../../types.js"; -import { ExitError } from "../errors.js"; -import type { InterpreterContext } from "../types.js"; - -/** - * Mark a variable as readonly. - */ -export function markReadonly(ctx: InterpreterContext, name: string): void { - ctx.state.readonlyVars = ctx.state.readonlyVars || new Set(); - ctx.state.readonlyVars.add(name); -} - -/** - * Check if a variable is readonly. - */ -export function isReadonly(ctx: InterpreterContext, name: string): boolean { - return ctx.state.readonlyVars?.has(name) ?? false; -} - -/** - * Check if a variable is readonly and throw an error if so. - * Returns null if the variable is not readonly (can be modified). - * - * Assigning to a readonly variable is a fatal error that stops script execution. - * This matches the behavior of dash, mksh, ash, and bash in POSIX mode. - * (Note: bash in non-POSIX mode has a bug where multi-line readonly assignment - * continues execution, but one-line still stops. We always stop.) - * - * @param ctx - Interpreter context - * @param name - Variable name - * @param command - Command name for error message (default: "bash") - * @returns null if variable is not readonly (can be modified) - * @throws ExitError if variable is readonly - */ -export function checkReadonlyError( - ctx: InterpreterContext, - name: string, - command = "bash", -): ExecResult | null { - if (isReadonly(ctx, name)) { - const stderr = `${command}: ${name}: readonly variable\n`; - // Assigning to a readonly variable is always fatal - throw new ExitError(1, "", stderr); - } - return null; -} - -/** - * Mark a variable as exported. - * - * If we're inside a local scope and the variable is local (exists in the - * current scope), track it as a locally-exported variable. When the scope - * is popped, the export attribute will be removed if it wasn't exported - * before entering the function. - */ -export function markExported(ctx: InterpreterContext, name: string): void { - const wasExported = ctx.state.exportedVars?.has(name) ?? false; - ctx.state.exportedVars = ctx.state.exportedVars || new Set(); - ctx.state.exportedVars.add(name); - - // If we're in a local scope and the variable is local, track it - if (ctx.state.localScopes.length > 0) { - const currentScope = - ctx.state.localScopes[ctx.state.localScopes.length - 1]; - // Only track if: the variable is local AND it wasn't already exported before - if (currentScope.has(name) && !wasExported) { - // Initialize localExportedVars stack if needed - if (!ctx.state.localExportedVars) { - ctx.state.localExportedVars = []; - } - // Ensure we have a set for the current scope depth - while ( - ctx.state.localExportedVars.length < ctx.state.localScopes.length - ) { - ctx.state.localExportedVars.push(new Set()); - } - // Track this variable as locally exported - ctx.state.localExportedVars[ctx.state.localExportedVars.length - 1].add( - name, - ); - } - } -} - -/** - * Remove the export attribute from a variable. - * The variable value is preserved, just no longer exported to child processes. - */ -export function unmarkExported(ctx: InterpreterContext, name: string): void { - ctx.state.exportedVars?.delete(name); -} diff --git a/src/interpreter/helpers/regex.ts b/src/interpreter/helpers/regex.ts deleted file mode 100644 index 48c7cfe9..00000000 --- a/src/interpreter/helpers/regex.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Regex helper functions for the interpreter. - */ - -/** - * Escape a string for use as a literal in a regex pattern. - * All regex special characters are escaped. - */ -export function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} diff --git a/src/interpreter/helpers/result.ts b/src/interpreter/helpers/result.ts deleted file mode 100644 index 70d1868e..00000000 --- a/src/interpreter/helpers/result.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * ExecResult factory functions for cleaner code. - * - * These helpers reduce verbosity and improve readability when - * constructing ExecResult objects throughout the interpreter. - */ - -import type { ExecResult } from "../../types.js"; -import { ExecutionLimitError } from "../errors.js"; - -/** - * A successful result with no output. - * Use this for commands that succeed silently. - */ -export const OK: ExecResult = Object.freeze({ - stdout: "", - stderr: "", - exitCode: 0, -}); - -/** - * Create a successful result with optional stdout. - * - * @param stdout - Output to include (default: "") - * @returns ExecResult with exitCode 0 - */ -export function success(stdout = ""): ExecResult { - return { stdout, stderr: "", exitCode: 0 }; -} - -/** - * Create a failure result with stderr message. - * - * @param stderr - Error message to include - * @param exitCode - Exit code (default: 1) - * @returns ExecResult with the specified exitCode - */ -export function failure(stderr: string, exitCode = 1): ExecResult { - return { stdout: "", stderr, exitCode }; -} - -/** - * Create a result with all fields specified. - * - * @param stdout - Standard output - * @param stderr - Standard error - * @param exitCode - Exit code - * @returns ExecResult with all fields - */ -export function result( - stdout: string, - stderr: string, - exitCode: number, -): ExecResult { - return { stdout, stderr, exitCode }; -} - -/** - * Convert a boolean test result to an ExecResult. - * Useful for test/conditional commands where true = exit 0, false = exit 1. - * - * @param passed - Boolean test result - * @returns ExecResult with exitCode 0 if passed, 1 otherwise - */ -export function testResult(passed: boolean): ExecResult { - return { stdout: "", stderr: "", exitCode: passed ? 0 : 1 }; -} - -/** - * Throw an ExecutionLimitError for execution limits (recursion, iterations, commands). - * - * @param message - Error message describing the limit exceeded - * @param limitType - Type of limit exceeded - * @param stdout - Accumulated stdout to include - * @param stderr - Accumulated stderr to include - * @throws ExecutionLimitError always - */ -export function throwExecutionLimit( - message: string, - limitType: "recursion" | "iterations" | "commands", - stdout = "", - stderr = "", -): never { - throw new ExecutionLimitError(message, limitType, stdout, stderr); -} diff --git a/src/interpreter/helpers/shell-constants.ts b/src/interpreter/helpers/shell-constants.ts deleted file mode 100644 index 0ae63e97..00000000 --- a/src/interpreter/helpers/shell-constants.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Shell Constants - * - * Constants for shell builtins, keywords, and POSIX special builtins. - */ - -/** - * POSIX special built-in commands. - * In POSIX mode, these have special behaviors: - * - Prefix assignments persist after the command - * - Cannot be redefined as functions - * - Errors may be fatal - */ -export const POSIX_SPECIAL_BUILTINS: Set = new Set([ - ":", - ".", - "break", - "continue", - "eval", - "exec", - "exit", - "export", - "readonly", - "return", - "set", - "shift", - "trap", - "unset", -]); - -/** - * Check if a command name is a POSIX special built-in - */ -export function isPosixSpecialBuiltin(name: string): boolean { - return POSIX_SPECIAL_BUILTINS.has(name); -} - -/** - * Shell keywords (for type, command -v, etc.) - */ -export const SHELL_KEYWORDS: Set = new Set([ - "if", - "then", - "else", - "elif", - "fi", - "case", - "esac", - "for", - "select", - "while", - "until", - "do", - "done", - "in", - "function", - "{", - "}", - "time", - "[[", - "]]", - "!", -]); - -/** - * Shell builtins (for type, command -v, builtin, etc.) - */ -export const SHELL_BUILTINS: Set = new Set([ - ":", - "true", - "false", - "cd", - "export", - "unset", - "exit", - "local", - "set", - "break", - "continue", - "return", - "eval", - "shift", - "getopts", - "compgen", - "complete", - "compopt", - "pushd", - "popd", - "dirs", - "source", - ".", - "read", - "mapfile", - "readarray", - "declare", - "typeset", - "readonly", - "let", - "command", - "shopt", - "exec", - "test", - "[", - "echo", - "printf", - "pwd", - "alias", - "unalias", - "type", - "hash", - "ulimit", - "umask", - "trap", - "times", - "wait", - "kill", - "jobs", - "fg", - "bg", - "disown", - "suspend", - "fc", - "history", - "help", - "enable", - "builtin", - "caller", -]); diff --git a/src/interpreter/helpers/shellopts.ts b/src/interpreter/helpers/shellopts.ts deleted file mode 100644 index 2ab520b2..00000000 --- a/src/interpreter/helpers/shellopts.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * SHELLOPTS and BASHOPTS variable helpers. - * - * SHELLOPTS is a colon-separated list of enabled shell options from `set -o`. - * BASHOPTS is a colon-separated list of enabled bash-specific options from `shopt`. - */ - -import type { - InterpreterContext, - ShellOptions, - ShoptOptions, -} from "../types.js"; - -/** - * List of shell option names in the order they appear in SHELLOPTS. - * This matches bash's ordering (alphabetical). - */ -const SHELLOPTS_OPTIONS: (keyof ShellOptions)[] = [ - "allexport", - "errexit", - "noglob", - "noclobber", - "noexec", - "nounset", - "pipefail", - "posix", - "verbose", - "xtrace", -]; - -/** - * Options that are always enabled in bash (no-op in our implementation but - * should appear in SHELLOPTS for compatibility). - * These are in alphabetical order. - */ -const ALWAYS_ON_OPTIONS = ["braceexpand", "hashall", "interactive-comments"]; - -/** - * Build the SHELLOPTS string from current shell options. - * Returns a colon-separated list of enabled options (alphabetically sorted). - * Includes always-on options like braceexpand, hashall, interactive-comments. - */ -export function buildShellopts(options: ShellOptions): string { - const enabled: string[] = []; - // Add always-on options and dynamic options in alphabetical order - const allOptions = [ - ...ALWAYS_ON_OPTIONS.map((opt) => ({ name: opt, enabled: true })), - ...SHELLOPTS_OPTIONS.map((opt) => ({ name: opt, enabled: options[opt] })), - ].sort((a, b) => a.name.localeCompare(b.name)); - - for (const opt of allOptions) { - if (opt.enabled) { - enabled.push(opt.name); - } - } - return enabled.join(":"); -} - -/** - * Update the SHELLOPTS environment variable to reflect current shell options. - * Should be called whenever shell options change (via set -o or shopt -o). - */ -export function updateShellopts(ctx: InterpreterContext): void { - ctx.state.env.set("SHELLOPTS", buildShellopts(ctx.state.options)); -} - -/** - * List of shopt option names in the order they appear in BASHOPTS. - * This matches bash's ordering (alphabetical). - */ -const BASHOPTS_OPTIONS: (keyof ShoptOptions)[] = [ - "dotglob", - "expand_aliases", - "extglob", - "failglob", - "globskipdots", - "globstar", - "lastpipe", - "nocaseglob", - "nocasematch", - "nullglob", - "xpg_echo", -]; - -/** - * Build the BASHOPTS string from current shopt options. - * Returns a colon-separated list of enabled options (alphabetically sorted). - */ -export function buildBashopts(shoptOptions: ShoptOptions): string { - const enabled: string[] = []; - for (const opt of BASHOPTS_OPTIONS) { - if (shoptOptions[opt]) { - enabled.push(opt); - } - } - return enabled.join(":"); -} - -/** - * Update the BASHOPTS environment variable to reflect current shopt options. - * Should be called whenever shopt options change. - */ -export function updateBashopts(ctx: InterpreterContext): void { - ctx.state.env.set("BASHOPTS", buildBashopts(ctx.state.shoptOptions)); -} diff --git a/src/interpreter/helpers/statements.ts b/src/interpreter/helpers/statements.ts deleted file mode 100644 index bcba69fb..00000000 --- a/src/interpreter/helpers/statements.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Statement execution helpers for the interpreter. - * - * Consolidates the common pattern of executing a list of statements - * and accumulating their output. - */ - -import type { StatementNode } from "../../ast/types.js"; -import type { ExecResult } from "../../types.js"; -import { - ErrexitError, - ExecutionLimitError, - ExitError, - isScopeExitError, - SubshellExitError, -} from "../errors.js"; -import type { InterpreterContext } from "../types.js"; -import { getErrorMessage } from "./errors.js"; - -/** - * Execute a list of statements and accumulate their output. - * Handles scope exit errors (break, continue, return) and errexit properly. - * - * @param ctx - Interpreter context - * @param statements - Statements to execute - * @param initialStdout - Initial stdout to prepend (default "") - * @param initialStderr - Initial stderr to prepend (default "") - * @returns Accumulated stdout, stderr, and final exit code - */ -export async function executeStatements( - ctx: InterpreterContext, - statements: StatementNode[], - initialStdout = "", - initialStderr = "", -): Promise { - let stdout = initialStdout; - let stderr = initialStderr; - let exitCode = 0; - - try { - for (const stmt of statements) { - const result = await ctx.executeStatement(stmt); - stdout += result.stdout; - stderr += result.stderr; - exitCode = result.exitCode; - } - } catch (error) { - if ( - isScopeExitError(error) || - error instanceof ErrexitError || - error instanceof ExitError || - error instanceof ExecutionLimitError || - error instanceof SubshellExitError - ) { - error.prependOutput(stdout, stderr); - throw error; - } - return { - stdout, - stderr: `${stderr}${getErrorMessage(error)}\n`, - exitCode: 1, - }; - } - - return { stdout, stderr, exitCode }; -} diff --git a/src/interpreter/helpers/string-compare.ts b/src/interpreter/helpers/string-compare.ts deleted file mode 100644 index 9a6c99e7..00000000 --- a/src/interpreter/helpers/string-compare.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * String comparison helpers for conditionals. - * - * Consolidates string comparison logic (=, ==, !=) used in: - * - [[ ]] conditional expressions (with optional pattern matching) - * - test/[ ] command (literal comparison only) - */ - -import { matchPattern } from "../conditionals.js"; - -export type StringCompareOp = "=" | "==" | "!="; - -/** - * Check if an operator is a string comparison operator. - */ -export function isStringCompareOp(op: string): op is StringCompareOp { - return op === "=" || op === "==" || op === "!="; -} - -/** - * Compare two strings using the specified operator. - * - * @param op - The comparison operator (=, ==, !=) - * @param left - Left operand - * @param right - Right operand - * @param usePattern - If true, use glob pattern matching for equality (default: false) - * @param nocasematch - If true, use case-insensitive comparison (default: false) - * @param extglob - If true, enable extended glob patterns @(), *(), +(), ?(), !() (default: false) - * @returns True if the comparison succeeds - */ -export function compareStrings( - op: StringCompareOp, - left: string, - right: string, - usePattern = false, - nocasematch = false, - extglob = false, -): boolean { - if (usePattern) { - const isEqual = matchPattern(left, right, nocasematch, extglob); - return op === "!=" ? !isEqual : isEqual; - } - if (nocasematch) { - const isEqual = left.toLowerCase() === right.toLowerCase(); - return op === "!=" ? !isEqual : isEqual; - } - const isEqual = left === right; - return op === "!=" ? !isEqual : isEqual; -} diff --git a/src/interpreter/helpers/string-tests.ts b/src/interpreter/helpers/string-tests.ts deleted file mode 100644 index fd18d373..00000000 --- a/src/interpreter/helpers/string-tests.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * String test helper for conditionals. - * Handles -z (empty) and -n (non-empty) operators. - */ - -export type StringTestOp = "-z" | "-n"; - -const STRING_TEST_OPS = new Set(["-z", "-n"]); - -/** - * Check if an operator is a string test operator. - */ -export function isStringTestOp(op: string): op is StringTestOp { - return STRING_TEST_OPS.has(op); -} - -/** - * Evaluate a string test operator. - */ -export function evaluateStringTest(op: StringTestOp, value: string): boolean { - switch (op) { - case "-z": - return value === ""; - case "-n": - return value !== ""; - } -} diff --git a/src/interpreter/helpers/tilde.ts b/src/interpreter/helpers/tilde.ts deleted file mode 100644 index 2d56a104..00000000 --- a/src/interpreter/helpers/tilde.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Tilde expansion helper functions. - * - * Handles ~ expansion in assignment contexts. - */ - -import type { InterpreterContext } from "../types.js"; - -/** - * Expand tildes in assignment values (PATH-like expansion) - * - ~ at start expands to HOME - * - ~ after : expands to HOME (for PATH-like values) - * - ~username expands to user's home (only root supported) - */ -export function expandTildesInValue( - ctx: InterpreterContext, - value: string, -): string { - const home = ctx.state.env.get("HOME") || "/home/user"; - - // Split by : to handle PATH-like values - const parts = value.split(":"); - const expanded = parts.map((part) => { - if (part === "~") { - return home; - } - if (part === "~root") { - return "/root"; - } - if (part.startsWith("~/")) { - return home + part.slice(1); - } - if (part.startsWith("~root/")) { - return `/root${part.slice(5)}`; - } - // ~otheruser stays literal (can't verify user exists) - return part; - }); - - return expanded.join(":"); -} diff --git a/src/interpreter/helpers/variable-tests.ts b/src/interpreter/helpers/variable-tests.ts deleted file mode 100644 index ccd145c1..00000000 --- a/src/interpreter/helpers/variable-tests.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { parseArithmeticExpression } from "../../parser/arithmetic-parser.js"; -import { Parser } from "../../parser/parser.js"; -import { evaluateArithmetic } from "../arithmetic.js"; -import type { InterpreterContext } from "../types.js"; -import { getArrayIndices, getAssocArrayKeys } from "./array.js"; - -/** - * Evaluates the -v (variable is set) test. - * Handles both simple variables and array element access with negative indices. - * - * @param ctx - Interpreter context with environment variables - * @param operand - The variable name to test, may include array subscript (e.g., "arr[0]", "arr[-1]") - */ -export async function evaluateVariableTest( - ctx: InterpreterContext, - operand: string, -): Promise { - // Check for array element syntax: var[index] - const arrayMatch = operand.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$/); - - if (arrayMatch) { - const arrayName = arrayMatch[1]; - const indexExpr = arrayMatch[2]; - - // Check if this is an associative array - const isAssoc = ctx.state.associativeArrays?.has(arrayName); - - if (isAssoc) { - // For associative arrays, use the key as-is (strip quotes if present) - let key = indexExpr; - // Remove surrounding quotes if present - if ( - (key.startsWith("'") && key.endsWith("'")) || - (key.startsWith('"') && key.endsWith('"')) - ) { - key = key.slice(1, -1); - } - // Expand variables in key - key = key.replace(/\$([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, varName) => { - return ctx.state.env.get(varName) || ""; - }); - return ctx.state.env.has(`${arrayName}_${key}`); - } - - // Evaluate as arithmetic expression (handles variables like zero+0) - let index: number; - try { - const parser = new Parser(); - const arithAst = parseArithmeticExpression(parser, indexExpr); - index = await evaluateArithmetic(ctx, arithAst.expression); - } catch { - // If parsing fails, try simple numeric - if (/^-?\d+$/.test(indexExpr)) { - index = Number.parseInt(indexExpr, 10); - } else { - // Last resort: try looking up as variable - const varValue = ctx.state.env.get(indexExpr); - index = varValue ? Number.parseInt(varValue, 10) : 0; - } - } - - // Handle negative indices - bash counts from max_index + 1 - if (index < 0) { - const indices = getArrayIndices(ctx, arrayName); - const lineNum = ctx.state.currentLine; - if (indices.length === 0) { - // Empty array with negative index - emit warning and return false - ctx.state.expansionStderr = - (ctx.state.expansionStderr || "") + - `bash: line ${lineNum}: ${arrayName}: bad array subscript\n`; - return false; - } - const maxIndex = Math.max(...indices); - index = maxIndex + 1 + index; - if (index < 0) { - // Out of bounds negative index - emit warning and return false - ctx.state.expansionStderr = - (ctx.state.expansionStderr || "") + - `bash: line ${lineNum}: ${arrayName}: bad array subscript\n`; - return false; - } - } - - return ctx.state.env.has(`${arrayName}_${index}`); - } - - // Check if it's a regular variable - if (ctx.state.env.has(operand)) { - return true; - } - - // Check if it's an array with elements (test -v arrayname without subscript) - // For associative arrays, check if there are any keys - if (ctx.state.associativeArrays?.has(operand)) { - return getAssocArrayKeys(ctx, operand).length > 0; - } - - // For indexed arrays, check if there are any indices - return getArrayIndices(ctx, operand).length > 0; -} diff --git a/src/interpreter/helpers/word-matching.ts b/src/interpreter/helpers/word-matching.ts deleted file mode 100644 index ad2748e9..00000000 --- a/src/interpreter/helpers/word-matching.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Interpreter Utility Functions - * - * Standalone helper functions used by the interpreter. - */ - -import type { WordNode } from "../../ast/types.js"; - -/** - * Check if a WordNode is a literal match for any of the given strings. - * Returns true only if the word is a single literal (no expansions, no quoting) - * that matches one of the target strings. - * - * This is used to detect assignment builtins at "parse time" - bash determines - * whether a command is export/declare/etc based on the literal token, not the - * runtime value after expansion. - */ -export function isWordLiteralMatch(word: WordNode, targets: string[]): boolean { - // Must be a single part - if (word.parts.length !== 1) { - return false; - } - const part = word.parts[0]; - // Must be a simple literal (not quoted, not an expansion) - if (part.type !== "Literal") { - return false; - } - return targets.includes(part.value); -} - -/** - * Parse the content of a read-write file descriptor. - * Format: __rw__:pathLength:path:position:content - * @returns The parsed components, or null if format is invalid - */ -export function parseRwFdContent(fdContent: string): { - path: string; - position: number; - content: string; -} | null { - if (!fdContent.startsWith("__rw__:")) { - return null; - } - // Parse pathLength - const afterPrefix = fdContent.slice(7); // After "__rw__:" - const firstColonIdx = afterPrefix.indexOf(":"); - if (firstColonIdx === -1) { - return null; - } - const pathLength = Number.parseInt(afterPrefix.slice(0, firstColonIdx), 10); - if (Number.isNaN(pathLength) || pathLength < 0) { - return null; - } - // Extract path using length - const pathStart = firstColonIdx + 1; - const path = afterPrefix.slice(pathStart, pathStart + pathLength); - // Parse position (after path and colon) - const positionStart = pathStart + pathLength + 1; // +1 for ":" - const remaining = afterPrefix.slice(positionStart); - const posColonIdx = remaining.indexOf(":"); - if (posColonIdx === -1) { - return null; - } - const position = Number.parseInt(remaining.slice(0, posColonIdx), 10); - if (Number.isNaN(position) || position < 0) { - return null; - } - // Extract content (after position and colon) - const content = remaining.slice(posColonIdx + 1); - return { path, position, content }; -} diff --git a/src/interpreter/helpers/word-parts.ts b/src/interpreter/helpers/word-parts.ts deleted file mode 100644 index fb382028..00000000 --- a/src/interpreter/helpers/word-parts.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Word Part Helper Functions - * - * Provides common operations on WordPart types to eliminate duplication - * across expansion.ts and word-parser.ts. - */ - -import type { WordPart } from "../../ast/types.js"; - -/** - * Get the literal string value from a word part. - * Returns the value for Literal, SingleQuoted, and Escaped parts. - * Returns null for complex parts that require expansion. - */ -export function getLiteralValue(part: WordPart): string | null { - switch (part.type) { - case "Literal": - return part.value; - case "SingleQuoted": - return part.value; - case "Escaped": - return part.value; - default: - return null; - } -} - -/** - * Check if a word part is "quoted" - meaning glob characters should be treated literally. - * A part is quoted if it is: - * - SingleQuoted - * - Escaped - * - DoubleQuoted (entirely quoted) - * - Literal with empty value (doesn't affect quoting) - */ -export function isQuotedPart(part: WordPart): boolean { - switch (part.type) { - case "SingleQuoted": - case "Escaped": - case "DoubleQuoted": - return true; - case "Literal": - // Empty literals don't affect quoting - return part.value === ""; - default: - // Unquoted expansions like $var are not quoted - return false; - } -} diff --git a/src/interpreter/helpers/xtrace.test.ts b/src/interpreter/helpers/xtrace.test.ts deleted file mode 100644 index 4b36f1a3..00000000 --- a/src/interpreter/helpers/xtrace.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../../Bash.js"; - -describe("xtrace (set -x)", () => { - describe("basic tracing", () => { - it("should trace simple commands", async () => { - const env = new Bash(); - const result = await env.exec(` - set -x - echo hello - `); - expect(result.stdout).toBe("hello\n"); - expect(result.stderr).toContain("+ echo hello"); - expect(result.exitCode).toBe(0); - }); - - it("should trace commands with arguments", async () => { - const env = new Bash(); - const result = await env.exec(` - set -x - echo one two three - `); - expect(result.stdout).toBe("one two three\n"); - expect(result.stderr).toContain("+ echo one two three"); - expect(result.exitCode).toBe(0); - }); - - it("should stop tracing with set +x", async () => { - const env = new Bash(); - const result = await env.exec(` - set -x - echo traced - set +x - echo not traced - `); - expect(result.stdout).toBe("traced\nnot traced\n"); - expect(result.stderr).toContain("+ echo traced"); - expect(result.stderr).toContain("+ set +x"); - expect(result.stderr).not.toContain("+ echo not traced"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("PS4 expansion", () => { - it("should use default PS4 prefix", async () => { - const env = new Bash(); - const result = await env.exec(` - set -x - echo test - `); - expect(result.stderr).toContain("+ echo test"); - expect(result.exitCode).toBe(0); - }); - - it("should use custom PS4 prefix", async () => { - const env = new Bash(); - const result = await env.exec(` - PS4=">>> " - set -x - echo test - `); - expect(result.stderr).toContain(">>> echo test"); - expect(result.exitCode).toBe(0); - }); - - it("should expand variables in PS4", async () => { - const env = new Bash(); - const result = await env.exec(` - MYVAR="DEBUG" - PS4='[$MYVAR] ' - set -x - echo test - `); - expect(result.stderr).toContain("[DEBUG] echo test"); - expect(result.exitCode).toBe(0); - }); - - it("should expand $LINENO in PS4", async () => { - const env = new Bash(); - const result = await env.exec(`PS4='+$LINENO: ' -set -x -echo line1`); - // Should contain line number in trace - expect(result.stderr).toMatch(/\+\d+: echo line1/); - expect(result.exitCode).toBe(0); - }); - - it("should handle empty PS4", async () => { - const env = new Bash(); - const result = await env.exec(` - PS4="" - set -x - echo test - `); - // With empty PS4, trace line has no prefix - expect(result.stderr).toContain("echo test"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("tracing with special characters", () => { - it("should quote arguments with spaces", async () => { - const env = new Bash(); - const result = await env.exec(` - set -x - echo "hello world" - `); - expect(result.stdout).toBe("hello world\n"); - // The trace should show the argument quoted - expect(result.stderr).toContain("echo"); - expect(result.stderr).toContain("hello world"); - expect(result.exitCode).toBe(0); - }); - - it("should handle empty string arguments", async () => { - const env = new Bash(); - const result = await env.exec(` - set -x - echo "" - `); - expect(result.stdout).toBe("\n"); - expect(result.stderr).toContain("echo ''"); - expect(result.exitCode).toBe(0); - }); - - it("should escape special characters in trace output", async () => { - const env = new Bash(); - const result = await env.exec(` - set -x - printf 'a\\nb' - `); - expect(result.stdout).toBe("a\nb"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("tracing assignments", () => { - it("should trace variable assignments", async () => { - const env = new Bash(); - const result = await env.exec(` - set -x - x=5 - echo $x - `); - expect(result.stdout).toBe("5\n"); - expect(result.stderr).toContain("x=5"); - expect(result.exitCode).toBe(0); - }); - - it("should trace assignments with command", async () => { - const env = new Bash(); - const result = await env.exec(` - set -x - FOO=bar echo hello - `); - expect(result.stdout).toBe("hello\n"); - expect(result.stderr).toContain("FOO=bar"); - expect(result.stderr).toContain("echo hello"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("tracing control structures", () => { - it("should trace for loop iterations", async () => { - const env = new Bash(); - const result = await env.exec(` - set -x - for i in 1 2; do - echo $i - done - `); - expect(result.stdout).toBe("1\n2\n"); - expect(result.stderr).toContain("echo 1"); - expect(result.stderr).toContain("echo 2"); - expect(result.exitCode).toBe(0); - }); - - it("should trace while loop body", async () => { - const env = new Bash(); - const result = await env.exec(` - set -x - x=0 - while [ $x -lt 2 ]; do - echo $x - x=$((x + 1)) - done - `); - expect(result.stdout).toBe("0\n1\n"); - expect(result.stderr).toContain("echo 0"); - expect(result.stderr).toContain("echo 1"); - expect(result.exitCode).toBe(0); - }); - - it("should trace if/else branches", async () => { - const env = new Bash(); - const result = await env.exec(` - set -x - if true; then - echo yes - else - echo no - fi - `); - expect(result.stdout).toBe("yes\n"); - expect(result.stderr).toContain("true"); - expect(result.stderr).toContain("echo yes"); - expect(result.stderr).not.toContain("echo no"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("tracing in subshells", () => { - it("should trace commands in subshell when xtrace is set", async () => { - const env = new Bash(); - const result = await env.exec(` - set -x - (echo subshell) - `); - expect(result.stdout).toBe("subshell\n"); - expect(result.stderr).toContain("echo subshell"); - expect(result.exitCode).toBe(0); - }); - - it("should not trace subshell when xtrace disabled inside", async () => { - const env = new Bash(); - const result = await env.exec(` - set -x - (set +x; echo subshell) - echo after - `); - expect(result.stdout).toBe("subshell\nafter\n"); - // Only after should be traced, subshell echo is not - expect(result.stderr).toContain("echo after"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("tracing pipelines", () => { - it("should trace commands in pipeline", async () => { - const env = new Bash(); - const result = await env.exec(` - set -x - echo hello | cat - `); - expect(result.stdout).toBe("hello\n"); - // At minimum the cat command should be traced - expect(result.stderr).toContain("cat"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("tracing command substitution", () => { - it("should trace commands inside command substitution", async () => { - const env = new Bash(); - const result = await env.exec(` - set -x - x=$(echo hello) - echo $x - `); - expect(result.stdout).toBe("hello\n"); - // Command substitution commands should be traced - expect(result.stderr).toContain("echo hello"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("tracing function calls", () => { - it("should trace function body execution", async () => { - const env = new Bash(); - const result = await env.exec(` - myfunc() { - echo "in func" - } - set -x - myfunc - `); - expect(result.stdout).toBe("in func\n"); - expect(result.stderr).toContain("myfunc"); - expect(result.stderr).toContain("echo"); - expect(result.exitCode).toBe(0); - }); - - it("should show function arguments in trace", async () => { - const env = new Bash(); - const result = await env.exec(` - greet() { - echo "Hello $1" - } - set -x - greet World - `); - expect(result.stdout).toBe("Hello World\n"); - expect(result.stderr).toContain("greet World"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/interpreter/helpers/xtrace.ts b/src/interpreter/helpers/xtrace.ts deleted file mode 100644 index 0e3e549a..00000000 --- a/src/interpreter/helpers/xtrace.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * xtrace (set -x) helper functions - * - * Handles trace output generation when xtrace option is enabled. - * PS4 variable controls the prefix (default "+ "). - * PS4 is expanded (variable substitution) before each trace line. - */ - -import { Parser } from "../../parser/parser.js"; -import { expandWord } from "../expansion.js"; -import type { InterpreterContext } from "../types.js"; - -/** - * Default PS4 value when not set - */ -const DEFAULT_PS4 = "+ "; - -/** - * Expand the PS4 variable and return the trace prefix. - * PS4 is expanded with variable substitution. - * If PS4 expansion fails, falls back to default "+ ". - */ -async function getXtracePrefix(ctx: InterpreterContext): Promise { - const ps4 = ctx.state.env.get("PS4"); - - // If PS4 is not set, return default - if (ps4 === undefined) { - return DEFAULT_PS4; - } - - // If PS4 is empty string (explicitly unset), bash uses no prefix - // Actually, bash outputs nothing for trace lines when PS4 is empty - if (ps4 === "") { - return ""; - } - - try { - // Parse PS4 as a word to handle variable expansion - const parser = new Parser(); - const wordNode = parser.parseWordFromString(ps4, false, false); - - // Expand the word (handles $VAR, ${VAR}, $?, $LINENO, etc.) - const expanded = await expandWord(ctx, wordNode); - - return expanded; - } catch { - // If expansion fails, print error to stderr (like bash does) and return literal PS4 - // Bash continues execution but reports the error - ctx.state.expansionStderr = `${ctx.state.expansionStderr || ""}bash: ${ps4}: bad substitution\n`; - return ps4 || DEFAULT_PS4; - } -} - -/** - * Format a trace line for output. - * Quotes arguments that need quoting for shell safety. - */ -function formatTraceLine(parts: string[]): string { - return parts.map((part) => quoteForTrace(part)).join(" "); -} - -/** - * Quote a value for trace output if needed. - * Follows bash conventions for xtrace output quoting. - */ -function quoteForTrace(value: string): string { - // Empty string needs quotes - if (value === "") { - return "''"; - } - - // Check if quoting is needed - // Need to quote if contains: whitespace, quotes, special chars, newlines - const needsQuoting = /[\s'"\\$`!*?[\]{}|&;<>()~#\n\t]/.test(value); - - if (!needsQuoting) { - return value; - } - - // Check for special characters that need $'...' quoting - const hasControlChars = /[\x00-\x1f\x7f]/.test(value); - const hasNewline = value.includes("\n"); - const hasTab = value.includes("\t"); - const hasBackslash = value.includes("\\"); - const hasSingleQuote = value.includes("'"); - - // Use $'...' quoting for control characters, newlines, tabs - if (hasControlChars || hasNewline || hasTab || hasBackslash) { - let escaped = ""; - for (const char of value) { - const code = char.charCodeAt(0); - if (char === "\n") { - escaped += "\\n"; - } else if (char === "\t") { - escaped += "\\t"; - } else if (char === "\\") { - escaped += "\\\\"; - } else if (char === "'") { - escaped += "'"; - } else if (char === '"') { - escaped += '"'; - } else if (code < 32 || code === 127) { - // Control character - use \xNN or \uNNNN - if (code < 256) { - escaped += `\\x${code.toString(16).padStart(2, "0")}`; - } else { - escaped += `\\u${code.toString(16).padStart(4, "0")}`; - } - } else { - escaped += char; - } - } - return `$'${escaped}'`; - } - - // Use single quotes if possible (no single quotes in value) - if (!hasSingleQuote) { - return `'${value}'`; - } - - // Use double quotes for values with single quotes - // Need to escape $ ` \ " in double quotes - const escaped = value.replace(/([\\$`"])/g, "\\$1"); - return `"${escaped}"`; -} - -/** - * Generate xtrace output for a simple command. - * Returns the trace line to be added to stderr. - */ -export async function traceSimpleCommand( - ctx: InterpreterContext, - commandName: string, - args: string[], -): Promise { - if (!ctx.state.options.xtrace) { - return ""; - } - - const prefix = await getXtracePrefix(ctx); - const parts = [commandName, ...args]; - const traceLine = formatTraceLine(parts); - - return `${prefix}${traceLine}\n`; -} - -/** - * Generate xtrace output for an assignment. - * Returns the trace line to be added to stderr. - */ -export async function traceAssignment( - ctx: InterpreterContext, - name: string, - value: string, -): Promise { - if (!ctx.state.options.xtrace) { - return ""; - } - - const prefix = await getXtracePrefix(ctx); - // Don't quote the assignment value - show raw name=value - return `${prefix}${name}=${value}\n`; -} diff --git a/src/interpreter/index.ts b/src/interpreter/index.ts deleted file mode 100644 index fb89b6f0..00000000 --- a/src/interpreter/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type { InterpreterOptions } from "./interpreter.js"; -export { Interpreter } from "./interpreter.js"; -export type { - InterpreterContext, - InterpreterState, - ShellOptions, -} from "./types.js"; diff --git a/src/interpreter/interpreter.ts b/src/interpreter/interpreter.ts deleted file mode 100644 index 039df82d..00000000 --- a/src/interpreter/interpreter.ts +++ /dev/null @@ -1,1228 +0,0 @@ -/** - * Interpreter - AST Execution Engine - * - * Main interpreter class that executes bash AST nodes. - * Delegates to specialized modules for: - * - Word expansion (expansion.ts) - * - Arithmetic evaluation (arithmetic.ts) - * - Conditional evaluation (conditionals.ts) - * - Built-in commands (builtins.ts) - * - Redirections (redirections.ts) - */ - -import type { - ArithmeticCommandNode, - CommandNode, - ConditionalCommandNode, - GroupNode, - HereDocNode, - PipelineNode, - ScriptNode, - SimpleCommandNode, - StatementNode, - SubshellNode, - WordNode, -} from "../ast/types.js"; -import type { IFileSystem } from "../fs/interface.js"; -import { mapToRecord } from "../helpers/env.js"; -import type { ExecutionLimits } from "../limits.js"; -import type { SecureFetch } from "../network/index.js"; -import { ParseException } from "../parser/types.js"; -import type { - CommandRegistry, - ExecResult, - FeatureCoverageWriter, - TraceCallback, -} from "../types.js"; -import { expandAlias as expandAliasHelper } from "./alias-expansion.js"; -import { evaluateArithmetic } from "./arithmetic.js"; -import { - expandLocalArrayAssignment as expandLocalArrayAssignmentHelper, - expandScalarAssignmentArg as expandScalarAssignmentArgHelper, -} from "./assignment-expansion.js"; -import { - type BuiltinDispatchContext, - dispatchBuiltin, - executeExternalCommand, -} from "./builtin-dispatch.js"; -import { findCommandInPath as findCommandInPathHelper } from "./command-resolution.js"; -import { evaluateConditional } from "./conditionals.js"; -import { - executeCase, - executeCStyleFor, - executeFor, - executeIf, - executeUntil, - executeWhile, -} from "./control-flow.js"; -import { - ArithmeticError, - BadSubstitutionError, - BraceExpansionError, - BreakError, - ContinueError, - ErrexitError, - ExecutionLimitError, - ExitError, - GlobError, - NounsetError, - PosixFatalError, - ReturnError, -} from "./errors.js"; -import { expandWord, expandWordWithGlob } from "./expansion.js"; -import { executeFunctionDef } from "./functions.js"; -import { - failure, - OK, - result, - testResult, - throwExecutionLimit, -} from "./helpers/result.js"; -import { isPosixSpecialBuiltin } from "./helpers/shell-constants.js"; -import { - isWordLiteralMatch, - parseRwFdContent, -} from "./helpers/word-matching.js"; -import { traceSimpleCommand } from "./helpers/xtrace.js"; -import { executePipeline as executePipelineHelper } from "./pipeline-execution.js"; -import { - applyRedirections, - preOpenOutputRedirects, - processFdVariableRedirections, -} from "./redirections.js"; -import { processAssignments } from "./simple-command-assignments.js"; -import { - executeGroup as executeGroupHelper, - executeSubshell as executeSubshellHelper, - executeUserScript as executeUserScriptHelper, -} from "./subshell-group.js"; -import type { InterpreterContext, InterpreterState } from "./types.js"; - -export type { InterpreterContext, InterpreterState } from "./types.js"; - -export interface InterpreterOptions { - fs: IFileSystem; - commands: CommandRegistry; - limits: Required; - exec: ( - script: string, - options?: { env?: Record; cwd?: string }, - ) => Promise; - /** Optional secure fetch function for network-enabled commands */ - fetch?: SecureFetch; - /** Optional sleep function for testing with mock clocks */ - sleep?: (ms: number) => Promise; - /** Optional trace callback for performance profiling */ - trace?: TraceCallback; - /** Optional feature coverage writer for fuzzing instrumentation */ - coverage?: FeatureCoverageWriter; -} - -export class Interpreter { - private ctx: InterpreterContext; - - constructor(options: InterpreterOptions, state: InterpreterState) { - this.ctx = { - state, - fs: options.fs, - commands: options.commands, - limits: options.limits, - execFn: options.exec, - executeScript: this.executeScript.bind(this), - executeStatement: this.executeStatement.bind(this), - executeCommand: this.executeCommand.bind(this), - fetch: options.fetch, - sleep: options.sleep, - trace: options.trace, - coverage: options.coverage, - }; - } - - /** - * Build environment record containing only exported variables. - * In bash, only exported variables are passed to child processes. - * This includes both permanently exported variables (via export/declare -x) - * and temporarily exported variables (prefix assignments like FOO=bar cmd). - */ - private buildExportedEnv(): Record { - const exportedVars = this.ctx.state.exportedVars; - const tempExportedVars = this.ctx.state.tempExportedVars; - - // Combine both exported and temp exported vars - const allExported = new Set(); - if (exportedVars) { - for (const name of exportedVars) { - allExported.add(name); - } - } - if (tempExportedVars) { - for (const name of tempExportedVars) { - allExported.add(name); - } - } - - if (allExported.size === 0) { - // No exported vars - return empty env - // This matches bash behavior where variables must be exported to be visible to children - return Object.create(null); - } - - // Use null-prototype to prevent prototype pollution via user-controlled variable names - const env: Record = Object.create(null); - for (const name of allExported) { - const value = this.ctx.state.env.get(name); - if (value !== undefined) { - env[name] = value; - } - } - return env; - } - - async executeScript(node: ScriptNode): Promise { - let stdout = ""; - let stderr = ""; - let exitCode = 0; - - for (const statement of node.statements) { - try { - const result = await this.executeStatement(statement); - stdout += result.stdout; - stderr += result.stderr; - exitCode = result.exitCode; - this.ctx.state.lastExitCode = exitCode; - this.ctx.state.env.set("?", String(exitCode)); - } catch (error) { - // ExitError always propagates up to terminate the script - // This allows 'eval exit 42' and 'source exit.sh' to exit properly - if (error instanceof ExitError) { - error.prependOutput(stdout, stderr); - throw error; - } - // PosixFatalError terminates the script in POSIX mode - // POSIX 2.8.1: special builtins cause shell to exit on error - if (error instanceof PosixFatalError) { - stdout += error.stdout; - stderr += error.stderr; - exitCode = error.exitCode; - this.ctx.state.lastExitCode = exitCode; - this.ctx.state.env.set("?", String(exitCode)); - return { - stdout, - stderr, - exitCode, - env: mapToRecord(this.ctx.state.env), - }; - } - // ExecutionLimitError must always propagate - these are safety limits - if (error instanceof ExecutionLimitError) { - throw error; - } - if (error instanceof ErrexitError) { - stdout += error.stdout; - stderr += error.stderr; - exitCode = error.exitCode; - this.ctx.state.lastExitCode = exitCode; - this.ctx.state.env.set("?", String(exitCode)); - return { - stdout, - stderr, - exitCode, - env: mapToRecord(this.ctx.state.env), - }; - } - if (error instanceof NounsetError) { - stdout += error.stdout; - stderr += error.stderr; - exitCode = 1; - this.ctx.state.lastExitCode = exitCode; - this.ctx.state.env.set("?", String(exitCode)); - return { - stdout, - stderr, - exitCode, - env: mapToRecord(this.ctx.state.env), - }; - } - if (error instanceof BadSubstitutionError) { - stdout += error.stdout; - stderr += error.stderr; - exitCode = 1; - this.ctx.state.lastExitCode = exitCode; - this.ctx.state.env.set("?", String(exitCode)); - return { - stdout, - stderr, - exitCode, - env: mapToRecord(this.ctx.state.env), - }; - } - // ArithmeticError in expansion (e.g., echo $((42x))) - the command fails - // but the script continues execution. This matches bash behavior. - if (error instanceof ArithmeticError) { - stdout += error.stdout; - stderr += error.stderr; - exitCode = 1; - this.ctx.state.lastExitCode = exitCode; - this.ctx.state.env.set("?", String(exitCode)); - // Continue to next statement instead of terminating script - continue; - } - // BraceExpansionError for invalid ranges (e.g., {z..A} mixed case) - the command fails - // but the script continues execution. This matches bash behavior. - if (error instanceof BraceExpansionError) { - stdout += error.stdout; - stderr += error.stderr; - exitCode = 1; - this.ctx.state.lastExitCode = exitCode; - this.ctx.state.env.set("?", String(exitCode)); - // Continue to next statement instead of terminating script - continue; - } - // Handle break/continue errors - if (error instanceof BreakError || error instanceof ContinueError) { - // If we're inside a loop, propagate the error up (for eval/source inside loops) - if (this.ctx.state.loopDepth > 0) { - error.prependOutput(stdout, stderr); - throw error; - } - // Outside loops (level exceeded loop depth), silently continue with next statement - stdout += error.stdout; - stderr += error.stderr; - continue; - } - // Handle return - prepend accumulated output before propagating - if (error instanceof ReturnError) { - error.prependOutput(stdout, stderr); - throw error; - } - throw error; - } - } - - return { - stdout, - stderr, - exitCode, - env: mapToRecord(this.ctx.state.env), - }; - } - - /** - * Execute a user script file found in PATH. - */ - private async executeUserScript( - scriptPath: string, - args: string[], - stdin = "", - ): Promise { - return executeUserScriptHelper(this.ctx, scriptPath, args, stdin, (ast) => - this.executeScript(ast), - ); - } - - private async executeStatement(node: StatementNode): Promise { - this.ctx.state.commandCount++; - if (this.ctx.state.commandCount > this.ctx.limits.maxCommandCount) { - throwExecutionLimit( - `too many commands executed (>${this.ctx.limits.maxCommandCount}), increase executionLimits.maxCommandCount`, - "commands", - ); - } - - // Check for deferred syntax error. This is triggered when execution reaches - // a statement that has a syntax error (like standalone `}`), but the error - // was deferred to support bash's incremental parsing behavior. - if (node.deferredError) { - throw new ParseException(node.deferredError.message, node.line ?? 1, 1); - } - - // noexec mode (set -n): parse commands but do not execute them - // This is used for syntax checking scripts without actually running them - if (this.ctx.state.options.noexec) { - return OK; - } - - // Reset errexitSafe at the start of each statement - // It will be set by inner compound command executions if needed - this.ctx.state.errexitSafe = false; - - let stdout = ""; - let stderr = ""; - - // verbose mode (set -v): print unevaluated source before execution - // Don't print verbose output inside command substitutions (suppressVerbose flag) - if ( - this.ctx.state.options.verbose && - !this.ctx.state.suppressVerbose && - node.sourceText - ) { - stderr += `${node.sourceText}\n`; - } - let exitCode = 0; - let lastExecutedIndex = -1; - let lastPipelineNegated = false; - - for (let i = 0; i < node.pipelines.length; i++) { - const pipeline = node.pipelines[i]; - const operator = i > 0 ? node.operators[i - 1] : null; - - if (operator === "&&" && exitCode !== 0) continue; - if (operator === "||" && exitCode === 0) continue; - - const result = await this.executePipeline(pipeline); - stdout += result.stdout; - stderr += result.stderr; - exitCode = result.exitCode; - lastExecutedIndex = i; - lastPipelineNegated = pipeline.negated; - - // Update $? after each pipeline so it's available for subsequent commands - this.ctx.state.lastExitCode = exitCode; - this.ctx.state.env.set("?", String(exitCode)); - } - - // Track whether this exit code is "safe" for errexit purposes - // (i.e., the failure was from a && or || chain where the final command wasn't reached, - // OR the failure came from a compound command where the inner statement was errexit-safe) - const wasShortCircuited = lastExecutedIndex < node.pipelines.length - 1; - // Preserve errexitSafe if it was set by an inner compound command - const innerWasSafe = this.ctx.state.errexitSafe; - this.ctx.state.errexitSafe = - wasShortCircuited || lastPipelineNegated || innerWasSafe; - - // Check errexit (set -e): exit if command failed - // Exceptions: - // - Command was in a && or || list and wasn't the final command (short-circuit) - // - Command was negated with ! - // - Command is part of a condition in if/while/until - // - Exit code came from a compound command where inner execution was errexit-safe - if ( - this.ctx.state.options.errexit && - exitCode !== 0 && - lastExecutedIndex === node.pipelines.length - 1 && - !lastPipelineNegated && - !this.ctx.state.inCondition && - !innerWasSafe - ) { - throw new ErrexitError(exitCode, stdout, stderr); - } - - return result(stdout, stderr, exitCode); - } - - private async executePipeline(node: PipelineNode): Promise { - return executePipelineHelper(this.ctx, node, (cmd, stdin) => - this.executeCommand(cmd, stdin), - ); - } - - private async executeCommand( - node: CommandNode, - stdin: string, - ): Promise { - this.ctx.coverage?.hit(`bash:cmd:${node.type}`); - switch (node.type) { - case "SimpleCommand": - return this.executeSimpleCommand(node, stdin); - case "If": - return executeIf(this.ctx, node); - case "For": - return executeFor(this.ctx, node); - case "CStyleFor": - return executeCStyleFor(this.ctx, node); - case "While": - return executeWhile(this.ctx, node, stdin); - case "Until": - return executeUntil(this.ctx, node); - case "Case": - return executeCase(this.ctx, node); - case "Subshell": - return this.executeSubshell(node, stdin); - case "Group": - return this.executeGroup(node, stdin); - case "FunctionDef": - return executeFunctionDef(this.ctx, node); - case "ArithmeticCommand": - return this.executeArithmeticCommand(node); - case "ConditionalCommand": - return this.executeConditionalCommand(node); - default: - return OK; - } - } - - private async executeSimpleCommand( - node: SimpleCommandNode, - stdin: string, - ): Promise { - try { - return await this.executeSimpleCommandInner(node, stdin); - } catch (error) { - if (error instanceof GlobError) { - // GlobError from failglob should return exit code 1 with error message - return failure(error.stderr); - } - // ArithmeticError in expansion (e.g., echo $((42x))) should terminate the script - // Let the error propagate - it will be caught by the top-level error handler - throw error; - } - } - - private async executeSimpleCommandInner( - node: SimpleCommandNode, - stdin: string, - ): Promise { - // Update currentLine for $LINENO - if (node.line !== undefined) { - this.ctx.state.currentLine = node.line; - } - - // Alias expansion: if expand_aliases is enabled and the command name is - // a literal unquoted word that matches an alias, substitute it. - // Keep expanding until no more alias expansion occurs (handles recursive aliases). - // The aliasExpansionStack persists across iterations to prevent infinite loops. - if (this.ctx.state.shoptOptions.expand_aliases && node.name) { - let currentNode = node; - let maxExpansions = 100; // Safety limit - while (maxExpansions > 0) { - const expandedNode = this.expandAlias(currentNode); - if (expandedNode === currentNode) { - break; // No expansion occurred - } - currentNode = expandedNode; - maxExpansions--; - } - // Clear the alias expansion stack after all expansions are done - this.aliasExpansionStack.clear(); - // Continue with the fully expanded node - if (currentNode !== node) { - node = currentNode; - } - } - - // Clear expansion stderr at the start - this.ctx.state.expansionStderr = ""; - - // Process all assignments (array, subscript, and scalar) - const assignmentResult = await processAssignments(this.ctx, node); - if (assignmentResult.error) { - return assignmentResult.error; - } - const tempAssignments = assignmentResult.tempAssignments; - const xtraceAssignmentOutput = assignmentResult.xtraceOutput; - - if (!node.name) { - // No command name - could be assignment-only or redirect-only (bare redirects) - // e.g., "x=5" (assignment-only) or "> file" (bare redirect to create empty file) - - // Handle bare redirections (no command, just redirects like "> file") - // In bash, this creates/truncates the file and returns success - if (node.redirections.length > 0) { - // Process the redirects - this creates/truncates files as needed - const redirectError = await preOpenOutputRedirects( - this.ctx, - node.redirections, - ); - if (redirectError) { - return redirectError; - } - // Apply redirections to empty result (for append, read redirects, etc.) - const baseResult = result("", xtraceAssignmentOutput, 0); - return applyRedirections(this.ctx, baseResult, node.redirections); - } - - // Assignment-only command: preserve the exit code from command substitution - // e.g., x=$(false) should set $? to 1, not 0 - // Also clear $_ - bash clears it for bare assignments - this.ctx.state.lastArg = ""; - // Include any stderr from command substitutions (e.g., FOO=$(echo foo 1>&2)) - const stderrOutput = - (this.ctx.state.expansionStderr || "") + xtraceAssignmentOutput; - this.ctx.state.expansionStderr = ""; - return result("", stderrOutput, this.ctx.state.lastExitCode); - } - - // Mark prefix assignment variables as temporarily exported for this command - // In bash, FOO=bar cmd makes FOO visible in cmd's environment - // EXCEPTION: For assignment builtins (readonly, declare, local, export, typeset), - // temp bindings should NOT be exported to command substitutions in the arguments. - // e.g., `FOO=foo readonly v=$(printenv.py FOO)` - the $(printenv.py FOO) should NOT see FOO. - // This is because assignment builtins don't actually run as external commands that receive - // an exported environment - they process their arguments in the current shell context. - const isLiteralAssignmentBuiltinForExport = - node.name && - isWordLiteralMatch(node.name, [ - "local", - "declare", - "typeset", - "export", - "readonly", - ]); - const tempExportedVars = Array.from(tempAssignments.keys()); - if (tempExportedVars.length > 0 && !isLiteralAssignmentBuiltinForExport) { - this.ctx.state.tempExportedVars = - this.ctx.state.tempExportedVars || new Set(); - for (const name of tempExportedVars) { - this.ctx.state.tempExportedVars.add(name); - } - } - - // Process FD variable redirections ({varname}>file syntax) - // This allocates FDs and sets variables before command execution - const fdVarError = await processFdVariableRedirections( - this.ctx, - node.redirections, - ); - if (fdVarError) { - for (const [name, value] of tempAssignments) { - if (value === undefined) this.ctx.state.env.delete(name); - else this.ctx.state.env.set(name, value); - } - return fdVarError; - } - - // Track source FD for stdin from read-write file descriptors - // This allows the read builtin to update the FD's position after reading - let stdinSourceFd = -1; - - for (const redir of node.redirections) { - if ( - (redir.operator === "<<" || redir.operator === "<<-") && - redir.target.type === "HereDoc" - ) { - const hereDoc = redir.target as HereDocNode; - let content = await expandWord(this.ctx, hereDoc.content); - // <<- strips leading tabs from each line - if (hereDoc.stripTabs) { - content = content - .split("\n") - .map((line) => line.replace(/^\t+/, "")) - .join("\n"); - } - // If this is a non-standard fd (not 0), store in fileDescriptors for -u option - const fd = redir.fd ?? 0; - if (fd !== 0) { - if (!this.ctx.state.fileDescriptors) { - this.ctx.state.fileDescriptors = new Map(); - } - this.ctx.state.fileDescriptors.set(fd, content); - } else { - stdin = content; - } - continue; - } - - if (redir.operator === "<<<" && redir.target.type === "Word") { - stdin = `${await expandWord(this.ctx, redir.target as WordNode)}\n`; - continue; - } - - if (redir.operator === "<" && redir.target.type === "Word") { - try { - const target = await expandWord(this.ctx, redir.target as WordNode); - const filePath = this.ctx.fs.resolvePath(this.ctx.state.cwd, target); - stdin = await this.ctx.fs.readFile(filePath); - } catch { - const target = await expandWord(this.ctx, redir.target as WordNode); - for (const [name, value] of tempAssignments) { - if (value === undefined) this.ctx.state.env.delete(name); - else this.ctx.state.env.set(name, value); - } - return failure(`bash: ${target}: No such file or directory\n`); - } - } - - // Handle <& input redirection from file descriptor - if (redir.operator === "<&" && redir.target.type === "Word") { - const target = await expandWord(this.ctx, redir.target as WordNode); - const sourceFd = Number.parseInt(target, 10); - if (!Number.isNaN(sourceFd) && this.ctx.state.fileDescriptors) { - const fdContent = this.ctx.state.fileDescriptors.get(sourceFd); - if (fdContent !== undefined) { - // Handle different FD content formats - if (fdContent.startsWith("__rw__:")) { - // Read/write mode: format is __rw__:pathLength:path:position:content - const parsed = parseRwFdContent(fdContent); - if (parsed) { - // Return content starting from current position - stdin = parsed.content.slice(parsed.position); - stdinSourceFd = sourceFd; - } - } else if ( - fdContent.startsWith("__file__:") || - fdContent.startsWith("__file_append__:") - ) { - // These are output-only, can't read from them - } else { - // Plain content (from exec N< file or here-docs) - stdin = fdContent; - } - } - } - } - } - - const commandName = await expandWord(this.ctx, node.name); - - const args: string[] = []; - const quotedArgs: boolean[] = []; - - // Handle local/declare/export/readonly arguments specially: - // - For array assignments like `local a=(1 "2 3")`, preserve quote structure - // - For scalar assignments like `local foo=$bar`, DON'T glob expand the value - // This matches bash behavior where assignment values aren't subject to word splitting/globbing - // - // IMPORTANT: This special handling only applies when the command is a LITERAL keyword, - // not when it's determined via variable expansion. For example: - // - `export var=$x` -> no word splitting (literal export keyword) - // - `e=export; $e var=$x` -> word splitting DOES occur (export via variable) - // - // This is because bash determines at parse time whether the command is an assignment builtin. - const isLiteralAssignmentBuiltin = - isWordLiteralMatch(node.name, [ - "local", - "declare", - "typeset", - "export", - "readonly", - ]) && - (commandName === "local" || - commandName === "declare" || - commandName === "typeset" || - commandName === "export" || - commandName === "readonly"); - - if (isLiteralAssignmentBuiltin) { - for (const arg of node.args) { - const arrayAssignResult = await expandLocalArrayAssignmentHelper( - this.ctx, - arg, - ); - if (arrayAssignResult) { - args.push(arrayAssignResult); - quotedArgs.push(true); - } else { - // Check if this looks like a scalar assignment (name=value) - // For assignments, we should NOT glob-expand the value part - const scalarAssignResult = await expandScalarAssignmentArgHelper( - this.ctx, - arg, - ); - if (scalarAssignResult !== null) { - args.push(scalarAssignResult); - quotedArgs.push(true); - } else { - // Not an assignment - use normal glob expansion - const expanded = await expandWordWithGlob(this.ctx, arg); - for (const value of expanded.values) { - args.push(value); - quotedArgs.push(expanded.quoted); - } - } - } - } - } else { - // Expand args even if command name is empty (they may have side effects) - for (const arg of node.args) { - const expanded = await expandWordWithGlob(this.ctx, arg); - for (const value of expanded.values) { - args.push(value); - quotedArgs.push(expanded.quoted); - } - } - } - - // Handle empty command name specially - // If the command word contains ONLY command substitutions/expansions and expands - // to empty, word-splitting removes the empty result. If there are args, the first - // arg becomes the command name. This matches bash behavior: - // - x=''; $x is a no-op (empty, no args) - // - x=''; $x Y runs command Y (empty command name, Y becomes command) - // - `true` X runs command X (since `true` outputs nothing) - // However, a literal empty string (like '') is "command not found". - if (!commandName) { - const isOnlyExpansions = node.name.parts.every( - (p) => - p.type === "CommandSubstitution" || - p.type === "ParameterExpansion" || - p.type === "ArithmeticExpansion", - ); - if (isOnlyExpansions) { - // Empty result from variable/command substitution - word split removes it - // If there are args, the first arg becomes the command name - if (args.length > 0) { - const newCommandName = args.shift() as string; - quotedArgs.shift(); - return await this.runCommand( - newCommandName, - args, - quotedArgs, - stdin, - false, - false, - stdinSourceFd, - ); - } - // No args - treat as no-op (status 0) - // Preserve lastExitCode for command subs like $(exit 42) - return result("", "", this.ctx.state.lastExitCode); - } - // Literal empty command name - command not found - return failure("bash: : command not found\n", 127); - } - - // Special handling for 'exec' with only redirections (no command to run) - // In this case, the redirections apply persistently to the shell - if (commandName === "exec" && (args.length === 0 || args[0] === "--")) { - // Process persistent FD redirections - // Note: {var}>file redirections are already handled by processFdVariableRedirections - // which sets up the FD mapping persistently. We only need to handle explicit fd redirections here. - for (const redir of node.redirections) { - if (redir.target.type === "HereDoc") continue; - - // Skip FD variable redirections - already handled by processFdVariableRedirections - if (redir.fdVariable) continue; - - const target = await expandWord(this.ctx, redir.target as WordNode); - const fd = - redir.fd ?? - (redir.operator === "<" || redir.operator === "<>" ? 0 : 1); - - if (!this.ctx.state.fileDescriptors) { - this.ctx.state.fileDescriptors = new Map(); - } - - switch (redir.operator) { - case ">": - case ">|": { - // Open file for writing (truncate) - const filePath = this.ctx.fs.resolvePath( - this.ctx.state.cwd, - target, - ); - await this.ctx.fs.writeFile(filePath, "", "utf8"); // truncate - this.ctx.state.fileDescriptors.set(fd, `__file__:${filePath}`); - break; - } - case ">>": { - // Open file for appending - const filePath = this.ctx.fs.resolvePath( - this.ctx.state.cwd, - target, - ); - this.ctx.state.fileDescriptors.set( - fd, - `__file_append__:${filePath}`, - ); - break; - } - case "<": { - // Open file for reading - store its content - const filePath = this.ctx.fs.resolvePath( - this.ctx.state.cwd, - target, - ); - try { - const content = await this.ctx.fs.readFile(filePath); - this.ctx.state.fileDescriptors.set(fd, content); - } catch { - return failure(`bash: ${target}: No such file or directory\n`); - } - break; - } - case "<>": { - // Open file for read/write - // Format: __rw__:pathLength:path:position:content - // pathLength allows parsing paths with colons - // position tracks current file offset for read/write - const filePath = this.ctx.fs.resolvePath( - this.ctx.state.cwd, - target, - ); - try { - const content = await this.ctx.fs.readFile(filePath); - this.ctx.state.fileDescriptors.set( - fd, - `__rw__:${filePath.length}:${filePath}:0:${content}`, - ); - } catch { - // File doesn't exist - create empty - await this.ctx.fs.writeFile(filePath, "", "utf8"); - this.ctx.state.fileDescriptors.set( - fd, - `__rw__:${filePath.length}:${filePath}:0:`, - ); - } - break; - } - case ">&": { - // Duplicate output FD: N>&M means N now writes to same place as M - // Move FD: N>&M- means duplicate M to N, then close M - if (target === "-") { - // Close the FD - this.ctx.state.fileDescriptors.delete(fd); - } else if (target.endsWith("-")) { - // Move operation: N>&M- duplicates M to N then closes M - const sourceFdStr = target.slice(0, -1); - const sourceFd = Number.parseInt(sourceFdStr, 10); - if (!Number.isNaN(sourceFd)) { - // First, duplicate: copy the FD content/info from source to target - const sourceInfo = this.ctx.state.fileDescriptors.get(sourceFd); - if (sourceInfo !== undefined) { - this.ctx.state.fileDescriptors.set(fd, sourceInfo); - } else { - // Source FD might be 1 (stdout) or 2 (stderr) which aren't in fileDescriptors - // In that case, store as duplication marker - this.ctx.state.fileDescriptors.set( - fd, - `__dupout__:${sourceFd}`, - ); - } - // Then close the source FD - this.ctx.state.fileDescriptors.delete(sourceFd); - } - } else { - const sourceFd = Number.parseInt(target, 10); - if (!Number.isNaN(sourceFd)) { - // Store FD duplication: fd N points to fd M - this.ctx.state.fileDescriptors.set( - fd, - `__dupout__:${sourceFd}`, - ); - } - } - break; - } - case "<&": { - // Duplicate input FD: N<&M means N now reads from same place as M - // Move FD: N<&M- means duplicate M to N, then close M - if (target === "-") { - // Close the FD - this.ctx.state.fileDescriptors.delete(fd); - } else if (target.endsWith("-")) { - // Move operation: N<&M- duplicates M to N then closes M - const sourceFdStr = target.slice(0, -1); - const sourceFd = Number.parseInt(sourceFdStr, 10); - if (!Number.isNaN(sourceFd)) { - // First, duplicate: copy the FD content/info from source to target - const sourceInfo = this.ctx.state.fileDescriptors.get(sourceFd); - if (sourceInfo !== undefined) { - this.ctx.state.fileDescriptors.set(fd, sourceInfo); - } else { - // Source FD might be 0 (stdin) which isn't in fileDescriptors - this.ctx.state.fileDescriptors.set( - fd, - `__dupin__:${sourceFd}`, - ); - } - // Then close the source FD - this.ctx.state.fileDescriptors.delete(sourceFd); - } - } else { - const sourceFd = Number.parseInt(target, 10); - if (!Number.isNaN(sourceFd)) { - // Store FD duplication for input - this.ctx.state.fileDescriptors.set(fd, `__dupin__:${sourceFd}`); - } - } - break; - } - } - } - // In bash, "exec" with only redirections does NOT persist prefix assignments - // This is the "special case of the special case" - unlike other special builtins - // (like ":"), exec without a command restores temp assignments - for (const [name, value] of tempAssignments) { - if (value === undefined) this.ctx.state.env.delete(name); - else this.ctx.state.env.set(name, value); - } - // Clear temp exported vars - if (this.ctx.state.tempExportedVars) { - for (const name of tempAssignments.keys()) { - this.ctx.state.tempExportedVars.delete(name); - } - } - return OK; - } - - // Generate xtrace output before running the command - const xtraceOutput = await traceSimpleCommand(this.ctx, commandName, args); - - // Push tempEnvBindings onto the stack so unset can see them - // This allows `unset v` to reveal the underlying global value when - // v was set by a prefix assignment like `v=tempenv cmd` - if (tempAssignments.size > 0) { - this.ctx.state.tempEnvBindings = this.ctx.state.tempEnvBindings || []; - this.ctx.state.tempEnvBindings.push(new Map(tempAssignments)); - } - - let cmdResult: ExecResult; - let controlFlowError: BreakError | ContinueError | null = null; - - try { - cmdResult = await this.runCommand( - commandName, - args, - quotedArgs, - stdin, - false, - false, - stdinSourceFd, - ); - } catch (error) { - // For break/continue, we still need to apply redirections before propagating - // This handles cases like "break > file" where the file should be created - if (error instanceof BreakError || error instanceof ContinueError) { - controlFlowError = error; - cmdResult = OK; // break/continue have exit status 0 - } else { - throw error; - } - } - - // Prepend xtrace output and any assignment warnings to stderr - const stderrPrefix = xtraceAssignmentOutput + xtraceOutput; - if (stderrPrefix) { - cmdResult = { - ...cmdResult, - stderr: stderrPrefix + cmdResult.stderr, - }; - } - - cmdResult = await applyRedirections(this.ctx, cmdResult, node.redirections); - - // If we caught a break/continue error, re-throw it after applying redirections - if (controlFlowError) { - throw controlFlowError; - } - - // Update $_ to the last argument of this command (after expansion) - // If no arguments, $_ is set to the command name - // Special case: for declare/local/typeset with array assignments like "a=(1 2)", - // bash sets $_ to just the variable name "a", not the full "a=(1 2)" - if (args.length > 0) { - let lastArg = args[args.length - 1]; - if ( - (commandName === "declare" || - commandName === "local" || - commandName === "typeset") && - /^[a-zA-Z_][a-zA-Z0-9_]*=\(/.test(lastArg) - ) { - // Extract just the variable name from array assignment - const match = lastArg.match(/^([a-zA-Z_][a-zA-Z0-9_]*)=\(/); - if (match) { - lastArg = match[1]; - } - } - this.ctx.state.lastArg = lastArg; - } else { - this.ctx.state.lastArg = commandName; - } - - // In POSIX mode, prefix assignments persist after special builtins - // e.g., `foo=bar :` leaves foo=bar in the environment - // Exception: `unset` and `eval` - bash doesn't apply POSIX temp binding persistence - // for these builtins when they modify the same variable as the temp binding - // In non-POSIX mode (bash default), temp assignments are always restored - const isPosixSpecialWithPersistence = - isPosixSpecialBuiltin(commandName) && - commandName !== "unset" && - commandName !== "eval"; - const shouldRestoreTempAssignments = - !this.ctx.state.options.posix || !isPosixSpecialWithPersistence; - - if (shouldRestoreTempAssignments) { - for (const [name, value] of tempAssignments) { - // Skip restoration if this variable was a local that was fully unset - // This implements bash's behavior where unsetting all local cells - // prevents the tempenv from being restored - if (this.ctx.state.fullyUnsetLocals?.has(name)) { - continue; - } - if (value === undefined) this.ctx.state.env.delete(name); - else this.ctx.state.env.set(name, value); - } - } - - // Clear temp exported vars after command execution - if (this.ctx.state.tempExportedVars) { - for (const name of tempAssignments.keys()) { - this.ctx.state.tempExportedVars.delete(name); - } - } - - // Pop tempEnvBindings from the stack - if (tempAssignments.size > 0 && this.ctx.state.tempEnvBindings) { - this.ctx.state.tempEnvBindings.pop(); - } - - // Include any stderr from expansion errors - if (this.ctx.state.expansionStderr) { - cmdResult = { - ...cmdResult, - stderr: this.ctx.state.expansionStderr + cmdResult.stderr, - }; - this.ctx.state.expansionStderr = ""; - } - - return cmdResult; - } - - private async runCommand( - commandName: string, - args: string[], - quotedArgs: boolean[], - stdin: string, - skipFunctions = false, - useDefaultPath = false, - stdinSourceFd = -1, - ): Promise { - const dispatchCtx: BuiltinDispatchContext = { - ctx: this.ctx, - runCommand: (name, a, qa, s, sf, udp, ssf) => - this.runCommand(name, a, qa, s, sf, udp, ssf), - buildExportedEnv: () => this.buildExportedEnv(), - executeUserScript: (path, a, s) => this.executeUserScript(path, a, s), - }; - - // Try builtin dispatch first - const builtinResult = await dispatchBuiltin( - dispatchCtx, - commandName, - args, - quotedArgs, - stdin, - skipFunctions, - useDefaultPath, - stdinSourceFd, - ); - - if (builtinResult !== null) { - return builtinResult; - } - - // Handle external command - return executeExternalCommand( - dispatchCtx, - commandName, - args, - stdin, - useDefaultPath, - ); - } - - // Alias expansion state - private aliasExpansionStack: Set = new Set(); - - private expandAlias(node: SimpleCommandNode): SimpleCommandNode { - return expandAliasHelper(this.ctx.state, node, this.aliasExpansionStack); - } - - async findCommandInPath(commandName: string): Promise { - return findCommandInPathHelper(this.ctx, commandName); - } - - private async executeSubshell( - node: SubshellNode, - stdin = "", - ): Promise { - return executeSubshellHelper(this.ctx, node, stdin, (stmt) => - this.executeStatement(stmt), - ); - } - - private async executeGroup(node: GroupNode, stdin = ""): Promise { - return executeGroupHelper(this.ctx, node, stdin, (stmt) => - this.executeStatement(stmt), - ); - } - - private async executeArithmeticCommand( - node: ArithmeticCommandNode, - ): Promise { - // Update currentLine for $LINENO - if (node.line !== undefined) { - this.ctx.state.currentLine = node.line; - } - - // Pre-open output redirects to truncate files BEFORE evaluating expression - // This matches bash behavior where redirect files are opened before - // any command substitutions in the arithmetic expression are evaluated - const preOpenError = await preOpenOutputRedirects( - this.ctx, - node.redirections, - ); - if (preOpenError) { - return preOpenError; - } - - try { - const arithResult = await evaluateArithmetic( - this.ctx, - node.expression.expression, - ); - // Apply output redirections - let bodyResult = testResult(arithResult !== 0); - // Include any stderr from expansion (e.g., command substitution stderr) - if (this.ctx.state.expansionStderr) { - bodyResult = { - ...bodyResult, - stderr: this.ctx.state.expansionStderr + bodyResult.stderr, - }; - this.ctx.state.expansionStderr = ""; - } - return applyRedirections(this.ctx, bodyResult, node.redirections); - } catch (error) { - // Apply output redirections before returning - const bodyResult = failure( - `bash: arithmetic expression: ${(error as Error).message}\n`, - ); - return applyRedirections(this.ctx, bodyResult, node.redirections); - } - } - - private async executeConditionalCommand( - node: ConditionalCommandNode, - ): Promise { - // Update currentLine for error messages - if (node.line !== undefined) { - this.ctx.state.currentLine = node.line; - } - - // Pre-open output redirects to truncate files BEFORE evaluating expression - // This matches bash behavior where redirect files are opened before - // any command substitutions in the conditional expression are evaluated - const preOpenError = await preOpenOutputRedirects( - this.ctx, - node.redirections, - ); - if (preOpenError) { - return preOpenError; - } - - try { - const condResult = await evaluateConditional(this.ctx, node.expression); - // Apply output redirections - let bodyResult = testResult(condResult); - // Include any stderr from expansion (e.g., bad array subscript warnings) - if (this.ctx.state.expansionStderr) { - bodyResult = { - ...bodyResult, - stderr: this.ctx.state.expansionStderr + bodyResult.stderr, - }; - this.ctx.state.expansionStderr = ""; - } - return applyRedirections(this.ctx, bodyResult, node.redirections); - } catch (error) { - // Apply output redirections before returning - // ArithmeticError (e.g., division by zero) returns exit code 1 - // Other errors (e.g., invalid regex) return exit code 2 - const exitCode = error instanceof ArithmeticError ? 1 : 2; - const bodyResult = failure( - `bash: conditional expression: ${(error as Error).message}\n`, - exitCode, - ); - return applyRedirections(this.ctx, bodyResult, node.redirections); - } - } -} diff --git a/src/interpreter/pipeline-execution.ts b/src/interpreter/pipeline-execution.ts deleted file mode 100644 index aa588136..00000000 --- a/src/interpreter/pipeline-execution.ts +++ /dev/null @@ -1,211 +0,0 @@ -/** - * Pipeline Execution - * - * Handles execution of command pipelines (cmd1 | cmd2 | cmd3). - */ - -import type { CommandNode, PipelineNode } from "../ast/types.js"; -import type { ExecResult } from "../types.js"; -import { BadSubstitutionError, ErrexitError, ExitError } from "./errors.js"; -import { OK } from "./helpers/result.js"; -import type { InterpreterContext } from "./types.js"; - -/** - * Type for executeCommand callback - */ -export type ExecuteCommandFn = ( - node: CommandNode, - stdin: string, -) => Promise; - -/** - * Execute a pipeline node (command or sequence of piped commands). - */ -export async function executePipeline( - ctx: InterpreterContext, - node: PipelineNode, - executeCommand: ExecuteCommandFn, -): Promise { - // Record start time for timed pipelines - const startTime = node.timed ? performance.now() : 0; - - let stdin = ""; - let lastResult: ExecResult = OK; - let pipefailExitCode = 0; // Track rightmost failing command - const pipestatusExitCodes: number[] = []; // Track all exit codes for PIPESTATUS - - // For multi-command pipelines, save parent's $_ because pipeline commands - // run in subshell-like contexts and should not affect parent's $_ - // (except the last command when lastpipe is enabled) - const isMultiCommandPipeline = node.commands.length > 1; - const savedLastArg = ctx.state.lastArg; - - for (let i = 0; i < node.commands.length; i++) { - const command = node.commands[i]; - const isLast = i === node.commands.length - 1; - - // In a multi-command pipeline, each command runs in a subshell context - // where $_ starts empty (subshells don't inherit $_ from parent in same way) - if (isMultiCommandPipeline) { - // Clear $_ for each pipeline command - they each get fresh subshell context - ctx.state.lastArg = ""; - } - - // Determine if this command runs in a subshell context - // In bash, all commands except the last run in subshells - // With lastpipe enabled, the last command runs in the current shell - const runsInSubshell = - isMultiCommandPipeline && (!isLast || !ctx.state.shoptOptions.lastpipe); - - // Save environment for commands running in subshell context - // This prevents variable assignments (e.g., ${cmd=echo}) from leaking to parent - const savedEnv = runsInSubshell ? new Map(ctx.state.env) : null; - - let result: ExecResult; - try { - result = await executeCommand(command, stdin); - } catch (error) { - // BadSubstitutionError should fail the command but not abort the script - if (error instanceof BadSubstitutionError) { - result = { - stdout: error.stdout, - stderr: error.stderr, - exitCode: 1, - }; - } - // In a MULTI-command pipeline, each command runs in a subshell context - // So exit/return/errexit only affect that segment, not the whole script - // For single commands, let these errors propagate to terminate the script - else if (error instanceof ExitError && node.commands.length > 1) { - result = { - stdout: error.stdout, - stderr: error.stderr, - exitCode: error.exitCode, - }; - } else if (error instanceof ErrexitError && node.commands.length > 1) { - // Errexit inside a pipeline segment should only fail that segment - // The pipeline's exit code comes from the last command (or pipefail) - result = { - stdout: error.stdout, - stderr: error.stderr, - exitCode: error.exitCode, - }; - } else { - // Restore environment before re-throwing - if (savedEnv) { - ctx.state.env = savedEnv; - } - throw error; - } - } - - // Restore environment for subshell commands to prevent variable assignment leakage - if (savedEnv) { - ctx.state.env = savedEnv; - } - - // Track exit code for PIPESTATUS - pipestatusExitCodes.push(result.exitCode); - - // Track the exit code of failing commands for pipefail - if (result.exitCode !== 0) { - pipefailExitCode = result.exitCode; - } - - if (!isLast) { - // Check if this pipe is |& (pipe stderr to next command's stdin too) - const pipeStderrToNext = node.pipeStderr?.[i] ?? false; - if (pipeStderrToNext) { - // |& pipes both stdout and stderr to next command's stdin - stdin = result.stderr + result.stdout; - lastResult = { - stdout: "", - stderr: "", - exitCode: result.exitCode, - }; - } else { - // Regular | only pipes stdout - stdin = result.stdout; - lastResult = { - stdout: "", - stderr: result.stderr, - exitCode: result.exitCode, - }; - } - } else { - lastResult = result; - } - } - - // Set PIPESTATUS array with exit codes from all pipeline commands - // For single-command pipelines with compound commands, don't set PIPESTATUS here - - // let inner statements set it (e.g., non-matching case statements should leave - // PIPESTATUS unchanged, matching bash behavior). - // For multi-command pipelines or simple commands, always set PIPESTATUS. - const shouldSetPipestatus = - node.commands.length > 1 || - (node.commands.length === 1 && node.commands[0].type === "SimpleCommand"); - - if (shouldSetPipestatus) { - // Clear any previous PIPESTATUS entries - for (const key of ctx.state.env.keys()) { - if (key.startsWith("PIPESTATUS_")) { - ctx.state.env.delete(key); - } - } - // Set new PIPESTATUS entries - for (let i = 0; i < pipestatusExitCodes.length; i++) { - ctx.state.env.set(`PIPESTATUS_${i}`, String(pipestatusExitCodes[i])); - } - ctx.state.env.set("PIPESTATUS__length", String(pipestatusExitCodes.length)); - } - - // If pipefail is enabled, use the rightmost failing exit code - if (ctx.state.options.pipefail && pipefailExitCode !== 0) { - lastResult = { - ...lastResult, - exitCode: pipefailExitCode, - }; - } - - if (node.negated) { - lastResult = { - ...lastResult, - exitCode: lastResult.exitCode === 0 ? 1 : 0, - }; - } - - // Output timing info for timed pipelines - if (node.timed) { - const endTime = performance.now(); - const elapsedSeconds = (endTime - startTime) / 1000; - const minutes = Math.floor(elapsedSeconds / 60); - const seconds = elapsedSeconds % 60; - - let timingOutput: string; - if (node.timePosix) { - // POSIX format (-p): decimal format without leading zeros - timingOutput = `real ${elapsedSeconds.toFixed(2)}\nuser 0.00\nsys 0.00\n`; - } else { - // Default bash format: real/user/sys with XmY.YYYs - const realStr = `${minutes}m${seconds.toFixed(3)}s`; - timingOutput = `\nreal\t${realStr}\nuser\t0m0.000s\nsys\t0m0.000s\n`; - } - - lastResult = { - ...lastResult, - stderr: lastResult.stderr + timingOutput, - }; - } - - // Handle $_ for multi-command pipelines: - // - With lastpipe enabled: $_ is set by the last command (already done above) - // - Without lastpipe: $_ should be restored to the value before the pipeline - // (since all commands ran in subshells that don't affect parent's $_) - if (isMultiCommandPipeline && !ctx.state.shoptOptions.lastpipe) { - ctx.state.lastArg = savedLastArg; - } - // With lastpipe, the last command already updated $_ in the main shell context - - return lastResult; -} diff --git a/src/interpreter/prototype-pollution.test.ts b/src/interpreter/prototype-pollution.test.ts deleted file mode 100644 index 7825d543..00000000 --- a/src/interpreter/prototype-pollution.test.ts +++ /dev/null @@ -1,963 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -/** - * Tests for bash-level prototype pollution defense. - * - * These tests ensure that JavaScript prototype-related keywords - * and constructs are handled safely as regular strings in bash, - * without triggering JavaScript prototype chain access. - */ - -// All dangerous JavaScript prototype-related keywords to test -const DANGEROUS_KEYWORDS = [ - // Core prototype keywords - "constructor", - "__proto__", - "prototype", - // Object.prototype methods - "hasOwnProperty", - "isPrototypeOf", - "propertyIsEnumerable", - "toString", - "valueOf", - "toLocaleString", - // Legacy getters/setters - "__defineGetter__", - "__defineSetter__", - "__lookupGetter__", - "__lookupSetter__", - // Other potentially dangerous - "toJSON", -]; - -describe("bash prototype pollution defense", () => { - describe("echo with prototype keywords", () => { - it("should echo 'constructor' as a literal string", async () => { - const env = new Bash(); - const result = await env.exec("echo constructor"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("constructor\n"); - }); - - it("should echo '__proto__' as a literal string", async () => { - const env = new Bash(); - const result = await env.exec("echo __proto__"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("__proto__\n"); - }); - - it("should echo 'prototype' as a literal string", async () => { - const env = new Bash(); - const result = await env.exec("echo prototype"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("prototype\n"); - }); - - it("should echo 'hasOwnProperty' as a literal string", async () => { - const env = new Bash(); - const result = await env.exec("echo hasOwnProperty"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("hasOwnProperty\n"); - }); - - it("should echo 'toString' as a literal string", async () => { - const env = new Bash(); - const result = await env.exec("echo toString"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("toString\n"); - }); - - it("should echo 'valueOf' as a literal string", async () => { - const env = new Bash(); - const result = await env.exec("echo valueOf"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("valueOf\n"); - }); - }); - - describe("variable assignment with prototype keywords", () => { - it("should allow variable named 'constructor'", async () => { - const env = new Bash(); - const result = await env.exec("constructor=test; echo $constructor"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test\n"); - }); - - it("should allow variable named '__proto__'", async () => { - const env = new Bash(); - const result = await env.exec("__proto__=test; echo $__proto__"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test\n"); - }); - - it("should allow variable named 'prototype'", async () => { - const env = new Bash(); - const result = await env.exec("prototype=test; echo $prototype"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test\n"); - }); - - it("should allow variable named 'hasOwnProperty'", async () => { - const env = new Bash(); - const result = await env.exec( - "hasOwnProperty=test; echo $hasOwnProperty", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test\n"); - }); - }); - - describe("unset prototype keyword variables", () => { - it("should return empty for unset $constructor", async () => { - const env = new Bash(); - const result = await env.exec("echo $constructor"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("\n"); - }); - - it("should return empty for unset $__proto__", async () => { - const env = new Bash(); - const result = await env.exec("echo $__proto__"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("\n"); - }); - - it("should return empty for unset $prototype", async () => { - const env = new Bash(); - const result = await env.exec("echo $prototype"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("\n"); - }); - }); - - describe("array with prototype keywords as indices", () => { - it("should handle array with prototype keyword values", async () => { - const env = new Bash(); - const result = await env.exec( - "arr=(constructor __proto__ prototype); echo ${arr[@]}", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("constructor __proto__ prototype\n"); - }); - - it("should handle associative array with prototype keyword keys", async () => { - const env = new Bash(); - const result = await env.exec( - "declare -A arr; arr[constructor]=a; arr[__proto__]=b; arr[prototype]=c; echo ${arr[constructor]} ${arr[__proto__]} ${arr[prototype]}", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a b c\n"); - }); - }); - - describe("string operations with prototype keywords", () => { - it("should handle string containing constructor", async () => { - const env = new Bash(); - const result = await env.exec('x="test constructor test"; echo $x'); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test constructor test\n"); - }); - - it("should handle string containing __proto__", async () => { - const env = new Bash(); - const result = await env.exec('x="test __proto__ test"; echo $x'); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test __proto__ test\n"); - }); - - it("should handle parameter expansion with prototype keywords", async () => { - const env = new Bash(); - const result = await env.exec("constructor=hello; echo ${constructor^^}"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("HELLO\n"); - }); - }); - - describe("function names with prototype keywords", () => { - it("should allow function named constructor", async () => { - const env = new Bash(); - const result = await env.exec( - "constructor() { echo 'func'; }; constructor", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("func\n"); - }); - - it("should allow function named __proto__", async () => { - const env = new Bash(); - const result = await env.exec("__proto__() { echo 'func'; }; __proto__"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("func\n"); - }); - }); - - describe("command substitution with prototype keywords", () => { - it("should handle command substitution returning constructor", async () => { - const env = new Bash(); - const result = await env.exec("echo $(echo constructor)"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("constructor\n"); - }); - - it("should handle command substitution returning __proto__", async () => { - const env = new Bash(); - const result = await env.exec("echo $(echo __proto__)"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("__proto__\n"); - }); - }); - - describe("arithmetic with prototype keyword variables", () => { - it("should handle arithmetic with variable named constructor", async () => { - const env = new Bash(); - const result = await env.exec("constructor=5; echo $((constructor + 3))"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("8\n"); - }); - - it("should handle arithmetic with variable named __proto__", async () => { - const env = new Bash(); - const result = await env.exec("__proto__=5; echo $((__proto__ + 3))"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("8\n"); - }); - }); - - describe("conditionals with prototype keywords", () => { - it("should compare strings containing prototype keywords", async () => { - const env = new Bash(); - const result = await env.exec( - 'if [[ "constructor" == "constructor" ]]; then echo yes; else echo no; fi', - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("yes\n"); - }); - - it("should handle -v test for prototype keyword variables", async () => { - const env = new Bash(); - const result = await env.exec( - "constructor=x; if [[ -v constructor ]]; then echo set; else echo unset; fi", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("set\n"); - }); - }); - - describe("export with prototype keywords", () => { - it("should export variable named constructor", async () => { - const env = new Bash(); - const result = await env.exec( - "export constructor=test; printenv constructor", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test\n"); - }); - - it("should export variable named __proto__", async () => { - const env = new Bash(); - const result = await env.exec( - "export __proto__=test; printenv __proto__", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test\n"); - }); - }); - - describe("read with prototype keywords", () => { - it("should read into variable named constructor", async () => { - const env = new Bash(); - const result = await env.exec( - "echo hello | read constructor; echo $constructor", - ); - // Note: read in a pipeline runs in a subshell, so this tests the variable access pattern - expect(result.exitCode).toBe(0); - }); - }); - - describe("for loop with prototype keywords", () => { - it("should iterate with variable named constructor", async () => { - const env = new Bash(); - const result = await env.exec( - "for constructor in a b c; do echo $constructor; done", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a\nb\nc\n"); - }); - - it("should iterate over prototype keyword values", async () => { - const env = new Bash(); - const result = await env.exec( - "for x in constructor __proto__ prototype; do echo $x; done", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("constructor\n__proto__\nprototype\n"); - }); - }); - - describe("case statement with prototype keywords", () => { - it("should match prototype keyword in case", async () => { - const env = new Bash(); - const result = await env.exec(` - x=constructor - case $x in - constructor) echo matched;; - *) echo nomatch;; - esac - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("matched\n"); - }); - }); - - describe("special patterns that might cause issues", () => { - it("should handle .constructor as literal", async () => { - const env = new Bash(); - const result = await env.exec("echo .constructor"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(".constructor\n"); - }); - - it("should handle [constructor] as literal", async () => { - const env = new Bash(); - const result = await env.exec("echo '[constructor]'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("[constructor]\n"); - }); - - it("should handle {constructor} as literal", async () => { - const env = new Bash(); - const result = await env.exec("echo '{constructor}'"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("{constructor}\n"); - }); - - it("should handle __proto__.test as literal", async () => { - const env = new Bash(); - const result = await env.exec("echo __proto__.test"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("__proto__.test\n"); - }); - }); - - // ========================================================================= - // EXPANDED TESTS FOR ALL DANGEROUS KEYWORDS - // ========================================================================= - - describe("all dangerous keywords as variable names", () => { - for (const keyword of DANGEROUS_KEYWORDS) { - it(`should allow variable named '${keyword}'`, async () => { - const env = new Bash(); - const result = await env.exec( - `${keyword}=test_value; echo $${keyword}`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test_value\n"); - }); - } - }); - - describe("all dangerous keywords as function names", () => { - for (const keyword of DANGEROUS_KEYWORDS) { - it(`should allow function named '${keyword}'`, async () => { - const env = new Bash(); - const result = await env.exec( - `${keyword}() { echo "called ${keyword}"; }; ${keyword}`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(`called ${keyword}\n`); - }); - } - }); - - describe("alias names with dangerous keywords", () => { - for (const keyword of DANGEROUS_KEYWORDS) { - it(`should allow alias named '${keyword}'`, async () => { - const env = new Bash(); - const result = await env.exec( - `shopt -s expand_aliases; alias ${keyword}='echo aliased'; ${keyword}`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("aliased\n"); - }); - } - }); - - describe("local variables with dangerous keywords", () => { - for (const keyword of DANGEROUS_KEYWORDS) { - it(`should allow local variable named '${keyword}'`, async () => { - const env = new Bash(); - const result = await env.exec(` - testfunc() { - local ${keyword}=local_value - echo $${keyword} - } - testfunc - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("local_value\n"); - }); - } - }); - - describe("declare with dangerous keywords", () => { - it("should handle declare -r with __proto__", async () => { - const env = new Bash(); - const result = await env.exec( - "declare -r __proto__=readonly_value; echo $__proto__", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("readonly_value\n"); - }); - - it("should handle declare -i with constructor", async () => { - const env = new Bash(); - const result = await env.exec( - "declare -i constructor=42; echo $constructor", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("42\n"); - }); - - it("should handle declare -x with prototype", async () => { - const env = new Bash(); - const result = await env.exec( - "declare -x prototype=exported; printenv prototype", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("exported\n"); - }); - - it("should handle declare -l with hasOwnProperty", async () => { - const env = new Bash(); - const result = await env.exec( - "declare -l hasOwnProperty=UPPERCASE; echo $hasOwnProperty", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("uppercase\n"); - }); - - it("should handle declare -u with toString", async () => { - const env = new Bash(); - const result = await env.exec( - "declare -u toString=lowercase; echo $toString", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("LOWERCASE\n"); - }); - }); - - describe("indexed arrays with dangerous keywords", () => { - it("should handle array containing all dangerous keywords", async () => { - const env = new Bash(); - const keywords = DANGEROUS_KEYWORDS.slice(0, 5).join(" "); - const result = await env.exec(`arr=(${keywords}); echo \${arr[@]}`); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(`${keywords}\n`); - }); - - it("should handle array named __proto__", async () => { - const env = new Bash(); - const result = await env.exec("__proto__=(a b c); echo ${__proto__[@]}"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a b c\n"); - }); - - it("should handle array named constructor", async () => { - const env = new Bash(); - const result = await env.exec( - "constructor=(1 2 3); echo ${constructor[1]}", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("2\n"); - }); - }); - - describe("associative arrays with dangerous keywords", () => { - for (const keyword of DANGEROUS_KEYWORDS.slice(0, 6)) { - it(`should handle assoc array with key '${keyword}'`, async () => { - const env = new Bash(); - const result = await env.exec( - `declare -A arr; arr[${keyword}]=value_for_${keyword}; echo \${arr[${keyword}]}`, - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(`value_for_${keyword}\n`); - }); - } - - it("should handle assoc array named __proto__", async () => { - const env = new Bash(); - const result = await env.exec( - "declare -A __proto__; __proto__[key]=val; echo ${__proto__[key]}", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("val\n"); - }); - }); - - describe("nameref variables with dangerous keywords", () => { - it("should handle nameref pointing to __proto__", async () => { - const env = new Bash(); - const result = await env.exec(` - __proto__=original - declare -n ref=__proto__ - echo $ref - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("original\n"); - }); - - it("should handle nameref named constructor", async () => { - const env = new Bash(); - const result = await env.exec(` - target=value - declare -n constructor=target - echo $constructor - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("value\n"); - }); - }); - - describe("positional parameters with dangerous keywords", () => { - it("should handle set -- with dangerous keywords", async () => { - const env = new Bash(); - const result = await env.exec( - "set -- __proto__ constructor prototype; echo $1 $2 $3", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("__proto__ constructor prototype\n"); - }); - - it("should handle shift with dangerous keyword values", async () => { - const env = new Bash(); - const result = await env.exec(` - set -- __proto__ constructor - shift - echo $1 - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("constructor\n"); - }); - }); - - describe("here documents with dangerous keywords", () => { - it("should handle heredoc with dangerous keywords", async () => { - const env = new Bash(); - const result = await env.exec(` - cat < { - const env = new Bash(); - const result = await env.exec(` - cat <<__proto__ -test content -__proto__ - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test content\n"); - }); - }); - - describe("brace expansion with dangerous keywords", () => { - it("should handle brace expansion with dangerous keywords", async () => { - const env = new Bash(); - const result = await env.exec("echo {__proto__,constructor,prototype}"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("__proto__ constructor prototype\n"); - }); - - it("should handle prefix brace expansion with dangerous keywords", async () => { - const env = new Bash(); - const result = await env.exec("echo test_{__proto__,constructor}"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test___proto__ test_constructor\n"); - }); - }); - - describe("eval with dangerous keywords", () => { - it("should handle eval setting dangerous keyword variable", async () => { - const env = new Bash(); - const result = await env.exec("eval '__proto__=evaled'; echo $__proto__"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("evaled\n"); - }); - - it("should handle eval defining function with dangerous name", async () => { - const env = new Bash(); - const result = await env.exec( - "eval 'constructor() { echo func; }'; constructor", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("func\n"); - }); - - it("should handle nested eval with dangerous keywords", async () => { - const env = new Bash(); - const result = await env.exec( - "eval 'eval \"__proto__=nested\"'; echo $__proto__", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("nested\n"); - }); - }); - - describe("environment passing with dangerous keywords", () => { - it("should export dangerous keyword var", async () => { - const env = new Bash(); - const result = await env.exec("export __proto__=passed; echo $__proto__"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("passed\n"); - }); - - it("should handle export with dangerous keywords", async () => { - const env = new Bash(); - const result = await env.exec( - "constructor=envval; export constructor; printenv constructor", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("envval\n"); - }); - }); - - describe("eval with dangerous keywords (extended)", () => { - it("should eval dangerous keyword as variable name", async () => { - const env = new Bash(); - const result = await env.exec(` - varname="__proto__" - eval "\${varname}=evaled_value" - echo $__proto__ - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("evaled_value\n"); - }); - - it("should eval array with dangerous keyword name", async () => { - const env = new Bash(); - const result = await env.exec(` - eval "__proto__=(a b c)" - echo \${__proto__[@]} - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a b c\n"); - }); - }); - - describe("trap variables with dangerous keywords", () => { - it("should allow trap command containing dangerous keyword", async () => { - const env = new Bash(); - const result = await env.exec(` - __proto__=value - echo "before: $__proto__" - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("before: value\n"); - }); - - it("should handle BASH_COMMAND with dangerous keyword", async () => { - const env = new Bash(); - const result = await env.exec(` - __proto__=test - echo $__proto__ - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("test\n"); - }); - }); - - describe("select with dangerous keywords", () => { - // Note: select requires interactive input, test variable assignment instead - it("should allow variable named REPLY with dangerous keyword value", async () => { - const env = new Bash(); - const result = await env.exec(` - REPLY=__proto__ - echo "REPLY: $REPLY" - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("REPLY: __proto__\n"); - }); - - it("should allow PS3 containing dangerous keywords", async () => { - const env = new Bash(); - const result = await env.exec(` - PS3="__proto__> " - echo "PS3: $PS3" - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("PS3: __proto__> \n"); - }); - }); - - describe("getopts with dangerous keywords", () => { - it("should handle getopts with OPTARG as dangerous keyword", async () => { - const env = new Bash(); - const result = await env.exec(` - set -- -a __proto__ - while getopts "a:" opt; do - echo "opt=$opt OPTARG=$OPTARG" - done - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("opt=a OPTARG=__proto__\n"); - }); - }); - - describe("printf with dangerous keywords", () => { - it("should handle printf format with dangerous keywords", async () => { - const env = new Bash(); - const result = await env.exec("printf '%s\\n' __proto__ constructor"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("__proto__\nconstructor\n"); - }); - - it("should handle printf -v with dangerous keyword var", async () => { - const env = new Bash(); - const result = await env.exec( - "printf -v __proto__ '%s' 'formatted'; echo $__proto__", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("formatted\n"); - }); - }); - - describe("read with dangerous keywords", () => { - it("should read into dangerous keyword variable", async () => { - const env = new Bash(); - const result = await env.exec( - "echo 'input' | { read __proto__; echo $__proto__; }", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("input\n"); - }); - - it("should read -a into dangerous keyword array", async () => { - const env = new Bash(); - const result = await env.exec( - "echo 'a b c' | { read -a __proto__; echo ${__proto__[@]}; }", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a b c\n"); - }); - - it("should read -A into dangerous keyword assoc array", async () => { - const env = new Bash(); - const result = await env.exec(` - echo 'key1 val1 key2 val2' | { - declare -A constructor - read -a pairs - constructor[\${pairs[0]}]=\${pairs[1]} - echo \${constructor[key1]} - } - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("val1\n"); - }); - }); - - describe("mapfile/readarray with dangerous keywords", () => { - it("should mapfile into dangerous keyword array", async () => { - const env = new Bash(); - const result = await env.exec(` - printf 'a\\nb\\nc\\n' | { mapfile __proto__; echo \${__proto__[@]}; } - `); - expect(result.exitCode).toBe(0); - }); - }); - - describe("unset with dangerous keywords", () => { - it("should unset dangerous keyword variable", async () => { - const env = new Bash(); - const result = await env.exec(` - __proto__=set - unset __proto__ - echo "value: '$__proto__'" - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("value: ''\n"); - }); - - it("should unset dangerous keyword function", async () => { - const env = new Bash(); - const result = await env.exec(` - constructor() { echo "func"; } - unset -f constructor - constructor 2>/dev/null || echo "function unset" - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("function unset\n"); - }); - }); - - describe("compgen with dangerous keywords", () => { - it("should complete with dangerous keyword prefix", async () => { - const env = new Bash(); - const result = await env.exec(` - __proto__=val - compgen -v __proto__ - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("__proto__\n"); - }); - }); - - describe("indirect expansion with dangerous keywords", () => { - it("should handle indirect expansion of dangerous keyword", async () => { - const env = new Bash(); - const result = await env.exec(` - __proto__=indirect_target - indirect_target=final_value - echo \${!__proto__} - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("final_value\n"); - }); - - it("should handle indirect array reference with dangerous keyword", async () => { - const env = new Bash(); - const result = await env.exec(` - __proto__=(a b c) - ref="__proto__[@]" - echo \${!ref} - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("a b c\n"); - }); - }); - - describe("parameter transformation with dangerous keywords", () => { - it("should handle ${var@Q} with dangerous keyword", async () => { - const env = new Bash(); - const result = await env.exec(` - __proto__="quoted value" - echo \${__proto__@Q} - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("'quoted value'\n"); - }); - - it("should handle ${var@A} with dangerous keyword", async () => { - const env = new Bash(); - const result = await env.exec(` - __proto__=value - echo \${__proto__@A} - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("__proto__='value'\n"); - }); - }); - - describe("substring operations with dangerous keywords", () => { - it("should handle ${var:offset} with dangerous keyword", async () => { - const env = new Bash(); - const result = await env.exec(` - constructor=hello_world - echo \${constructor:6} - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("world\n"); - }); - - it("should handle ${#var} with dangerous keyword", async () => { - const env = new Bash(); - const result = await env.exec(` - __proto__=12345 - echo \${#__proto__} - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("5\n"); - }); - }); - - describe("pattern substitution with dangerous keywords", () => { - it("should handle ${var/pattern/string} with dangerous keyword", async () => { - const env = new Bash(); - const result = await env.exec(` - __proto__="hello world" - echo \${__proto__/world/universe} - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("hello universe\n"); - }); - }); - - describe("while/until loops with dangerous keywords", () => { - it("should handle while with dangerous keyword condition var", async () => { - const env = new Bash(); - const result = await env.exec(` - __proto__=3 - while (( __proto__ > 0 )); do - echo $__proto__ - ((__proto__--)) - done - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("3\n2\n1\n"); - }); - }); - - describe("subshell with dangerous keywords", () => { - it("should handle subshell setting dangerous keyword var", async () => { - const env = new Bash(); - const result = await env.exec(` - ( __proto__=subshell; echo $__proto__ ) - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("subshell\n"); - }); - - it("should handle command substitution with dangerous keyword", async () => { - const env = new Bash(); - const result = await env.exec(` - result=$(echo __proto__) - echo "got: $result" - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("got: __proto__\n"); - }); - }); - - describe("return values in functions with dangerous keywords", () => { - it("should handle return in function with dangerous name", async () => { - const env = new Bash(); - const result = await env.exec(` - __proto__() { - return 42 - } - __proto__ - echo $? - `); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("42\n"); - }); - }); - - describe("special variable interactions", () => { - it("should not pollute Object.prototype via env", async () => { - const env = new Bash(); - await env.exec("export __proto__=polluted"); - - // Verify JavaScript Object.prototype is not affected - const testObj: Record = {}; - expect(testObj.__proto__).toBe(Object.prototype); - expect(Object.hasOwn(Object.prototype, "polluted")).toBe(false); - }); - - it("should not pollute Object.prototype via constructor var", async () => { - const env = new Bash(); - await env.exec("export constructor=polluted"); - - // Verify JavaScript Object.prototype is not affected - const testObj: Record = {}; - expect(typeof testObj.constructor).toBe("function"); - expect(testObj.constructor).toBe(Object); - }); - }); -}); diff --git a/src/interpreter/redirections.binary.test.ts b/src/interpreter/redirections.binary.test.ts deleted file mode 100644 index c150c9f1..00000000 --- a/src/interpreter/redirections.binary.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -describe("redirections with binary data", () => { - describe("basic redirection >", () => { - it("should preserve binary data when redirecting to file", async () => { - const env = new Bash({ - files: { - "/binary.bin": new Uint8Array([0x80, 0x90, 0xa0, 0xb0, 0xff]), - }, - }); - - await env.exec("cat /binary.bin > /output.bin"); - const result = await env.exec("cat /output.bin"); - - expect(result.stdout.length).toBe(5); - expect(result.stdout.charCodeAt(0)).toBe(0x80); - expect(result.stdout.charCodeAt(1)).toBe(0x90); - expect(result.stdout.charCodeAt(2)).toBe(0xa0); - expect(result.stdout.charCodeAt(3)).toBe(0xb0); - expect(result.stdout.charCodeAt(4)).toBe(0xff); - }); - - it("should preserve null bytes when redirecting to file", async () => { - const env = new Bash({ - files: { - "/nulls.bin": new Uint8Array([0x41, 0x00, 0x42, 0x00, 0x43]), - }, - }); - - await env.exec("cat /nulls.bin > /output.bin"); - const result = await env.exec("cat /output.bin"); - - expect(result.stdout).toBe("A\0B\0C"); - }); - - it("should preserve all byte values when redirecting to file", async () => { - const env = new Bash({ - files: { - "/allbytes.bin": new Uint8Array( - Array.from({ length: 256 }, (_, i) => i), - ), - }, - }); - - await env.exec("cat /allbytes.bin > /output.bin"); - const result = await env.exec("cat /output.bin"); - - expect(result.stdout.length).toBe(256); - for (let i = 0; i < 256; i++) { - expect(result.stdout.charCodeAt(i)).toBe(i); - } - }); - }); - - describe("append redirection >>", () => { - it("should preserve binary data when appending to file", async () => { - const env = new Bash({ - files: { - "/a.bin": new Uint8Array([0x80, 0x90]), - "/b.bin": new Uint8Array([0xa0, 0xb0]), - }, - }); - - await env.exec("cat /a.bin > /output.bin"); - await env.exec("cat /b.bin >> /output.bin"); - const result = await env.exec("cat /output.bin"); - - expect(result.stdout.length).toBe(4); - expect(result.stdout.charCodeAt(0)).toBe(0x80); - expect(result.stdout.charCodeAt(1)).toBe(0x90); - expect(result.stdout.charCodeAt(2)).toBe(0xa0); - expect(result.stdout.charCodeAt(3)).toBe(0xb0); - }); - }); - - describe("pipe with redirection", () => { - it("should preserve binary data through pipe and redirection", async () => { - const env = new Bash({ - files: { - "/binary.bin": new Uint8Array([0x80, 0xff, 0x90, 0xab]), - }, - }); - - await env.exec("cat /binary.bin | cat > /output.bin"); - const result = await env.exec("cat /output.bin"); - - expect(result.stdout.length).toBe(4); - expect(result.stdout.charCodeAt(0)).toBe(0x80); - expect(result.stdout.charCodeAt(1)).toBe(0xff); - expect(result.stdout.charCodeAt(2)).toBe(0x90); - expect(result.stdout.charCodeAt(3)).toBe(0xab); - }); - - it("should preserve binary data through multiple pipes", async () => { - const env = new Bash({ - files: { - "/binary.bin": new Uint8Array([0x80, 0xff, 0x00, 0x90]), - }, - }); - - await env.exec("cat /binary.bin | cat | cat > /output.bin"); - const result = await env.exec("cat /output.bin"); - - expect(result.stdout.length).toBe(4); - expect(result.stdout.charCodeAt(0)).toBe(0x80); - expect(result.stdout.charCodeAt(1)).toBe(0xff); - expect(result.stdout.charCodeAt(2)).toBe(0x00); - expect(result.stdout.charCodeAt(3)).toBe(0x90); - }); - }); - - describe("combined redirections &>", () => { - it("should preserve binary stdout when using &>", async () => { - const env = new Bash({ - files: { - "/binary.bin": new Uint8Array([0x80, 0x90, 0xa0]), - }, - }); - - await env.exec("cat /binary.bin &> /output.bin"); - const result = await env.exec("cat /output.bin"); - - expect(result.stdout.length).toBe(3); - expect(result.stdout.charCodeAt(0)).toBe(0x80); - expect(result.stdout.charCodeAt(1)).toBe(0x90); - expect(result.stdout.charCodeAt(2)).toBe(0xa0); - }); - }); - - describe("gzip/gunzip through redirections", () => { - it("should preserve binary data through gzip -c redirection", async () => { - const env = new Bash({ - files: { - "/data.txt": "test data for compression", - }, - }); - - await env.exec("gzip -c /data.txt > /compressed.gz"); - const result = await env.exec("gunzip -c /compressed.gz"); - - expect(result.stdout).toBe("test data for compression"); - }); - - it("should preserve binary data through stdin gzip pipe redirection", async () => { - const env = new Bash({ - files: { - "/data.txt": "piped compression test", - }, - }); - - await env.exec("cat /data.txt | gzip -c > /compressed.gz"); - const result = await env.exec("gunzip -c /compressed.gz"); - - expect(result.stdout).toBe("piped compression test"); - }); - - it("should handle binary file through gzip redirection", async () => { - const env = new Bash({ - files: { - "/binary.bin": new Uint8Array([0x80, 0xff, 0x00, 0x90, 0xab]), - }, - }); - - await env.exec("gzip -c /binary.bin > /binary.bin.gz"); - const result = await env.exec("gunzip -c /binary.bin.gz"); - - expect(result.stdout.length).toBe(5); - expect(result.stdout.charCodeAt(0)).toBe(0x80); - expect(result.stdout.charCodeAt(1)).toBe(0xff); - expect(result.stdout.charCodeAt(2)).toBe(0x00); - expect(result.stdout.charCodeAt(3)).toBe(0x90); - expect(result.stdout.charCodeAt(4)).toBe(0xab); - }); - }); -}); diff --git a/src/interpreter/redirections.ts b/src/interpreter/redirections.ts deleted file mode 100644 index f972a85e..00000000 --- a/src/interpreter/redirections.ts +++ /dev/null @@ -1,903 +0,0 @@ -/** - * Redirection Handling - * - * Handles output redirections: - * - > : Write stdout to file - * - >> : Append stdout to file - * - 2> : Write stderr to file - * - &> : Write both stdout and stderr to file - * - >& : Redirect fd to another fd - * - {fd}>file : Allocate FD and store in variable - */ - -import type { RedirectionNode, WordNode } from "../ast/types.js"; -import type { ExecResult } from "../types.js"; -import { - expandRedirectTarget, - expandWord, - hasQuotedMultiValueAt, -} from "./expansion.js"; -import { result as makeResult } from "./helpers/result.js"; -import type { InterpreterContext } from "./types.js"; - -/** - * Check if a redirect target is valid for output (not a directory, respects noclobber). - * Returns an error message string if invalid, null if valid. - */ -async function checkOutputRedirectTarget( - ctx: InterpreterContext, - filePath: string, - target: string, - options: { checkNoclobber?: boolean; isClobber?: boolean }, -): Promise { - try { - const stat = await ctx.fs.stat(filePath); - if (stat.isDirectory) { - return `bash: ${target}: Is a directory\n`; - } - if ( - options.checkNoclobber && - ctx.state.options.noclobber && - !options.isClobber && - target !== "/dev/null" - ) { - return `bash: ${target}: cannot overwrite existing file\n`; - } - } catch { - // File doesn't exist, that's ok - we'll create it - } - return null; -} - -/** - * Determine the encoding to use for file I/O. - * If all character codes are <= 255, use binary encoding (byte data). - * Otherwise, use UTF-8 encoding (text with Unicode characters). - * For performance, only check the first 8KB of large strings. - */ -function getFileEncoding(content: string): "binary" | "utf8" { - const SAMPLE_SIZE = 8192; // 8KB - - // For large strings, only check the first 8KB - // This is sufficient since UTF-8 files typically have Unicode chars early - const checkLength = Math.min(content.length, SAMPLE_SIZE); - - for (let i = 0; i < checkLength; i++) { - if (content.charCodeAt(i) > 255) { - return "utf8"; - } - } - return "binary"; -} - -/** - * Parse the content of a read-write file descriptor. - * Format: __rw__:pathLength:path:position:content - * @returns The parsed components, or null if format is invalid - */ -function parseRwFdContent(fdContent: string): { - path: string; - position: number; - content: string; -} | null { - if (!fdContent.startsWith("__rw__:")) { - return null; - } - const afterPrefix = fdContent.slice(7); - const firstColonIdx = afterPrefix.indexOf(":"); - if (firstColonIdx === -1) return null; - const pathLength = Number.parseInt(afterPrefix.slice(0, firstColonIdx), 10); - if (Number.isNaN(pathLength) || pathLength < 0) return null; - const pathStart = firstColonIdx + 1; - const path = afterPrefix.slice(pathStart, pathStart + pathLength); - const positionStart = pathStart + pathLength + 1; - const remaining = afterPrefix.slice(positionStart); - const posColonIdx = remaining.indexOf(":"); - if (posColonIdx === -1) return null; - const position = Number.parseInt(remaining.slice(0, posColonIdx), 10); - if (Number.isNaN(position) || position < 0) return null; - const content = remaining.slice(posColonIdx + 1); - return { path, position, content }; -} - -/** - * Pre-expanded redirect targets, keyed by index into the redirections array. - * This allows us to expand redirect targets (including side effects) before - * executing a function body, then apply the redirections after. - */ -export type ExpandedRedirectTargets = Map; - -/** - * Pre-expand redirect targets for function definitions. - * This is needed because redirections on function definitions are evaluated - * each time the function is called, and any side effects (like $((i++))) - * must occur BEFORE the function body executes. - */ -export async function preExpandRedirectTargets( - ctx: InterpreterContext, - redirections: RedirectionNode[], -): Promise<{ targets: ExpandedRedirectTargets; error?: string }> { - const targets: ExpandedRedirectTargets = new Map(); - - for (let i = 0; i < redirections.length; i++) { - const redir = redirections[i]; - if (redir.target.type === "HereDoc") { - continue; - } - - const isFdRedirect = redir.operator === ">&" || redir.operator === "<&"; - if (isFdRedirect) { - // Check for "$@" with multiple positional params - this is an ambiguous redirect - if (hasQuotedMultiValueAt(ctx, redir.target as WordNode)) { - return { targets, error: "bash: $@: ambiguous redirect\n" }; - } - targets.set(i, await expandWord(ctx, redir.target as WordNode)); - } else { - const expandResult = await expandRedirectTarget( - ctx, - redir.target as WordNode, - ); - if ("error" in expandResult) { - return { targets, error: expandResult.error }; - } - targets.set(i, expandResult.target); - } - } - - return { targets }; -} - -/** - * Allocate the next available file descriptor (starting at 10). - * Returns the allocated FD number. - */ -function allocateFd(ctx: InterpreterContext): number { - if (ctx.state.nextFd === undefined) { - ctx.state.nextFd = 10; - } - const fd = ctx.state.nextFd; - ctx.state.nextFd++; - return fd; -} - -/** - * Process FD variable redirections ({varname}>file syntax). - * This allocates FDs and sets variables before command execution. - * Returns an error result if there's an issue, or null if successful. - */ -export async function processFdVariableRedirections( - ctx: InterpreterContext, - redirections: RedirectionNode[], -): Promise { - for (const redir of redirections) { - if (!redir.fdVariable) { - continue; - } - - // Initialize fileDescriptors map if needed - if (!ctx.state.fileDescriptors) { - ctx.state.fileDescriptors = new Map(); - } - - // Handle close operation: {fd}>&- or {fd}<&- - // For close operations, we look up the existing variable value (the FD number) - // and close that FD, rather than allocating a new one. - if ( - (redir.operator === ">&" || redir.operator === "<&") && - redir.target.type === "Word" - ) { - const target = await expandWord(ctx, redir.target as WordNode); - if (target === "-") { - // Close operation - look up the FD from the variable and close it - const existingFd = ctx.state.env.get(redir.fdVariable); - if (existingFd !== undefined) { - const fdNum = Number.parseInt(existingFd, 10); - if (!Number.isNaN(fdNum)) { - ctx.state.fileDescriptors.delete(fdNum); - } - } - // Don't allocate a new FD for close operations - continue; - } - } - - // Allocate a new FD (for non-close operations) - const fd = allocateFd(ctx); - - // Set the variable to the allocated FD number - ctx.state.env.set(redir.fdVariable, String(fd)); - - // For file redirections, store the file path mapping - if (redir.target.type === "Word") { - const target = await expandWord(ctx, redir.target as WordNode); - - // Handle FD duplication: {fd}>&N or {fd}<&N - if (redir.operator === ">&" || redir.operator === "<&") { - const sourceFd = Number.parseInt(target, 10); - if (!Number.isNaN(sourceFd)) { - // Duplicate the source FD's content to the new FD - const content = ctx.state.fileDescriptors.get(sourceFd); - if (content !== undefined) { - ctx.state.fileDescriptors.set(fd, content); - } - continue; - } - } - - // For output redirections to files, we'll handle actual writing in applyRedirections - // Store the target file path associated with this FD - if ( - redir.operator === ">" || - redir.operator === ">>" || - redir.operator === ">|" || - redir.operator === "&>" || - redir.operator === "&>>" - ) { - // Mark this FD as pointing to a file (store file path for later use) - // Use a special format to distinguish from content - const filePath = ctx.fs.resolvePath(ctx.state.cwd, target); - // For truncating operators (>, >|, &>), create/truncate the file now - if ( - redir.operator === ">" || - redir.operator === ">|" || - redir.operator === "&>" - ) { - await ctx.fs.writeFile(filePath, "", "binary"); - } - ctx.state.fileDescriptors.set(fd, `__file__:${filePath}`); - } else if (redir.operator === "<<<") { - // For here-strings, store the target value plus newline as the FD content - ctx.state.fileDescriptors.set(fd, `${target}\n`); - } else if (redir.operator === "<" || redir.operator === "<>") { - // For input redirections, read the file content - try { - const filePath = ctx.fs.resolvePath(ctx.state.cwd, target); - const content = await ctx.fs.readFile(filePath); - ctx.state.fileDescriptors.set(fd, content); - } catch { - return makeResult( - "", - `bash: ${target}: No such file or directory\n`, - 1, - ); - } - } - } - } - - return null; // Success -} - -/** - * Pre-open (truncate) output redirect files before command execution. - * This is needed for compound commands (subshell, for, case, [[) where - * bash opens/truncates the redirect file BEFORE evaluating any words in - * the command body (including command substitutions). - * - * Example: `(echo \`cat FILE\`) > FILE` - * - Bash first truncates FILE (making it empty) - * - Then executes the subshell, where `cat FILE` returns empty string - * - * Returns an error result if there's an issue (like directory or noclobber), - * or null if pre-opening succeeded. - */ -export async function preOpenOutputRedirects( - ctx: InterpreterContext, - redirections: RedirectionNode[], -): Promise { - for (const redir of redirections) { - if (redir.target.type === "HereDoc") { - continue; - } - - // Only handle output truncation redirects (>, >|, &>) - // Append (>>, &>>) doesn't need pre-truncation - // >&word needs special handling - it's a file redirect only if word is not a number - const isGreaterAmpersand = redir.operator === ">&"; - if ( - redir.operator !== ">" && - redir.operator !== ">|" && - redir.operator !== "&>" && - !isGreaterAmpersand - ) { - continue; - } - - // Expand redirect target with glob handling (failglob, ambiguous redirect) - // For >&, use plain expansion first to check if it's a number - let target: string; - if (isGreaterAmpersand) { - target = await expandWord(ctx, redir.target as WordNode); - // If it's a number, -, or has explicit fd, it's an FD redirect, not a file redirect - if ( - target === "-" || - !Number.isNaN(Number.parseInt(target, 10)) || - redir.fd != null - ) { - continue; - } - // It's a file redirect - re-expand with redirect target handling - // (though we already have the expanded value, use it directly) - } else { - const expandResult = await expandRedirectTarget( - ctx, - redir.target as WordNode, - ); - if ("error" in expandResult) { - return makeResult("", expandResult.error, 1); - } - target = expandResult.target; - } - const filePath = ctx.fs.resolvePath(ctx.state.cwd, target); - const isClobber = redir.operator === ">|"; - - // Reject paths containing null bytes - these cause filesystem errors - // and are never valid in bash - if (filePath.includes("\0")) { - return makeResult("", `bash: ${target}: No such file or directory\n`, 1); - } - - // Check if target is a directory or noclobber prevents overwrite - try { - const stat = await ctx.fs.stat(filePath); - if (stat.isDirectory) { - return makeResult("", `bash: ${target}: Is a directory\n`, 1); - } - // Check noclobber: if file exists and noclobber is set, refuse to overwrite - // unless using >| (clobber operator) or writing to /dev/null - if ( - ctx.state.options.noclobber && - !isClobber && - !stat.isDirectory && - target !== "/dev/null" - ) { - return makeResult( - "", - `bash: ${target}: cannot overwrite existing file\n`, - 1, - ); - } - } catch { - // File doesn't exist, that's ok - we'll create it - } - - // Pre-truncate the file (create empty file) - // This makes the file empty before any command substitutions in the - // compound command body are evaluated - // Skip special device files that don't need pre-truncation - if ( - target !== "/dev/null" && - target !== "/dev/stdout" && - target !== "/dev/stderr" && - target !== "/dev/full" - ) { - await ctx.fs.writeFile(filePath, "", "binary"); - } - - // /dev/full always returns ENOSPC when written to - if (target === "/dev/full") { - return makeResult("", `bash: /dev/full: No space left on device\n`, 1); - } - } - - return null; // Success - no error -} - -export async function applyRedirections( - ctx: InterpreterContext, - result: ExecResult, - redirections: RedirectionNode[], - preExpandedTargets?: ExpandedRedirectTargets, -): Promise { - let { stdout, stderr, exitCode } = result; - - for (let i = 0; i < redirections.length; i++) { - const redir = redirections[i]; - if (redir.target.type === "HereDoc") { - continue; - } - - // Use pre-expanded target if available, otherwise expand now - let target: string; - const preExpanded = preExpandedTargets?.get(i); - if (preExpanded !== undefined) { - target = preExpanded; - } else { - // For FD-to-FD redirects (>&), use plain expansion without glob handling - // For file redirects, use glob expansion with failglob/ambiguous redirect handling - const isFdRedirect = redir.operator === ">&" || redir.operator === "<&"; - if (isFdRedirect) { - // Check for "$@" with multiple positional params - this is an ambiguous redirect - if (hasQuotedMultiValueAt(ctx, redir.target as WordNode)) { - stderr += "bash: $@: ambiguous redirect\n"; - exitCode = 1; - stdout = ""; - continue; - } - target = await expandWord(ctx, redir.target as WordNode); - } else { - const expandResult = await expandRedirectTarget( - ctx, - redir.target as WordNode, - ); - if ("error" in expandResult) { - stderr += expandResult.error; - exitCode = 1; - // When redirect fails, discard the output that would have been redirected - stdout = ""; - continue; - } - target = expandResult.target; - } - } - - // Skip FD variable redirections in applyRedirections - they're already handled - // by processFdVariableRedirections and don't affect stdout/stderr directly - if (redir.fdVariable) { - continue; - } - - // Reject paths containing null bytes - these cause filesystem errors - if (target.includes("\0")) { - stderr += `bash: ${target.replace(/\0/g, "")}: No such file or directory\n`; - exitCode = 1; - stdout = ""; - continue; - } - - switch (redir.operator) { - case ">": - case ">|": { - const fd = redir.fd ?? 1; - const isClobber = redir.operator === ">|"; - if (fd === 1) { - // /dev/stdout is a no-op for stdout - output stays on stdout - if (target === "/dev/stdout") { - break; - } - // /dev/stderr redirects stdout to stderr - if (target === "/dev/stderr") { - stderr += stdout; - stdout = ""; - break; - } - // /dev/full always returns ENOSPC when written to - if (target === "/dev/full") { - stderr += `bash: echo: write error: No space left on device\n`; - exitCode = 1; - stdout = ""; - break; - } - const filePath = ctx.fs.resolvePath(ctx.state.cwd, target); - const error = await checkOutputRedirectTarget(ctx, filePath, target, { - checkNoclobber: true, - isClobber, - }); - if (error) { - stderr += error; - exitCode = 1; - stdout = ""; - break; - } - // Smart encoding: binary for byte data, UTF-8 for Unicode text - await ctx.fs.writeFile(filePath, stdout, getFileEncoding(stdout)); - stdout = ""; - } else if (fd === 2) { - // /dev/stderr is a no-op for stderr - output stays on stderr - if (target === "/dev/stderr") { - break; - } - // /dev/stdout redirects stderr to stdout - if (target === "/dev/stdout") { - stdout += stderr; - stderr = ""; - break; - } - // /dev/full always returns ENOSPC when written to - if (target === "/dev/full") { - stderr += `bash: echo: write error: No space left on device\n`; - exitCode = 1; - break; - } - if (target === "/dev/null") { - stderr = ""; - } else { - const filePath = ctx.fs.resolvePath(ctx.state.cwd, target); - const error = await checkOutputRedirectTarget( - ctx, - filePath, - target, - { - checkNoclobber: true, - isClobber, - }, - ); - if (error) { - stderr += error; - exitCode = 1; - break; - } - // Smart encoding: binary for byte data, UTF-8 for Unicode text - await ctx.fs.writeFile(filePath, stderr, getFileEncoding(stderr)); - stderr = ""; - } - } - break; - } - - case ">>": { - const fd = redir.fd ?? 1; - if (fd === 1) { - // /dev/stdout is a no-op for stdout - output stays on stdout - if (target === "/dev/stdout") { - break; - } - // /dev/stderr redirects stdout to stderr - if (target === "/dev/stderr") { - stderr += stdout; - stdout = ""; - break; - } - // /dev/full always returns ENOSPC when written to - if (target === "/dev/full") { - stderr += `bash: echo: write error: No space left on device\n`; - exitCode = 1; - stdout = ""; - break; - } - const filePath = ctx.fs.resolvePath(ctx.state.cwd, target); - const error = await checkOutputRedirectTarget( - ctx, - filePath, - target, - {}, - ); - if (error) { - stderr += error; - exitCode = 1; - stdout = ""; - break; - } - // Smart encoding: binary for byte data, UTF-8 for Unicode text - await ctx.fs.appendFile(filePath, stdout, getFileEncoding(stdout)); - stdout = ""; - } else if (fd === 2) { - // /dev/stderr is a no-op for stderr - output stays on stderr - if (target === "/dev/stderr") { - break; - } - // /dev/stdout redirects stderr to stdout - if (target === "/dev/stdout") { - stdout += stderr; - stderr = ""; - break; - } - // /dev/full always returns ENOSPC when written to - if (target === "/dev/full") { - stderr += `bash: echo: write error: No space left on device\n`; - exitCode = 1; - break; - } - const filePath2 = ctx.fs.resolvePath(ctx.state.cwd, target); - const error2 = await checkOutputRedirectTarget( - ctx, - filePath2, - target, - {}, - ); - if (error2) { - stderr += error2; - exitCode = 1; - break; - } - // Smart encoding: binary for byte data, UTF-8 for Unicode text - await ctx.fs.appendFile(filePath2, stderr, getFileEncoding(stderr)); - stderr = ""; - } - break; - } - - case ">&": - case "<&": { - // In bash, <& and >& are essentially the same for FD duplication - // 1<&2 and 1>&2 both make fd 1 point to where fd 2 points - const fd = redir.fd ?? 1; // Default to stdout (fd 1) - // Handle >&- or <&- close operation - // NOTE: For command-level redirections, FD close is TEMPORARY - it only - // affects the command during its execution. By the time applyRedirections - // is called, the command has already completed, so we should NOT modify - // the persistent FD state here. The FD will be restored after this command. - // Permanent FD closes are handled by `exec N>&-` in executeSimpleCommand. - if (target === "-") { - // Don't delete the FD - command-level redirections are temporary - break; - } - // Handle FD move operation: N>&M- (duplicate M to N, then close M) - if (target.endsWith("-")) { - const sourceFdStr = target.slice(0, -1); - const sourceFd = Number.parseInt(sourceFdStr, 10); - if (!Number.isNaN(sourceFd)) { - // First, duplicate: copy the FD content/info from source to target - const sourceInfo = ctx.state.fileDescriptors?.get(sourceFd); - if (sourceInfo !== undefined) { - if (!ctx.state.fileDescriptors) { - ctx.state.fileDescriptors = new Map(); - } - ctx.state.fileDescriptors.set(fd, sourceInfo); - // Then close the source FD (only for user FDs 3+) - if (sourceFd >= 3) { - ctx.state.fileDescriptors?.delete(sourceFd); - } - } else if (sourceFd === 1 || sourceFd === 2) { - // Source FD is stdout or stderr which aren't in fileDescriptors - // Store as duplication marker - if (!ctx.state.fileDescriptors) { - ctx.state.fileDescriptors = new Map(); - } - ctx.state.fileDescriptors.set(fd, `__dupout__:${sourceFd}`); - } else if (sourceFd === 0) { - // Source FD is stdin - if (!ctx.state.fileDescriptors) { - ctx.state.fileDescriptors = new Map(); - } - ctx.state.fileDescriptors.set(fd, `__dupin__:${sourceFd}`); - } else if (sourceFd >= 3) { - // Source FD is a user FD (3+) that's not in fileDescriptors - bad file descriptor - stderr += `bash: ${sourceFd}: Bad file descriptor\n`; - exitCode = 1; - } - } - break; - } - // >&2, 1>&2, 1<&2: redirect stdout to stderr - if (target === "2" || target === "&2") { - if (fd === 1) { - stderr += stdout; - stdout = ""; - } - } - // 2>&1, 2<&1: redirect stderr to stdout - else if (target === "1" || target === "&1") { - if (fd === 2) { - stdout += stderr; - stderr = ""; - } else { - // 1>&1 is a no-op, but other fds redirect to stdout - stdout += stderr; - stderr = ""; - } - } - // Handle writing to a user-allocated FD (>&$fd) - else { - const targetFd = Number.parseInt(target, 10); - if (!Number.isNaN(targetFd)) { - // Check if this is a valid user-allocated FD - const fdInfo = ctx.state.fileDescriptors?.get(targetFd); - if (fdInfo?.startsWith("__file__:")) { - // This FD is associated with a file - write to it - // The path is already resolved when the FD was allocated - const resolvedPath = fdInfo.slice(9); // Remove "__file__:" prefix - if (fd === 1) { - await ctx.fs.appendFile( - resolvedPath, - stdout, - getFileEncoding(stdout), - ); - stdout = ""; - } else if (fd === 2) { - await ctx.fs.appendFile( - resolvedPath, - stderr, - getFileEncoding(stderr), - ); - stderr = ""; - } - } else if (fdInfo?.startsWith("__rw__:")) { - // Read/write FD - extract path using proper format parsing - // Format: __rw__:pathLength:path:position:content - const parsed = parseRwFdContent(fdInfo); - if (parsed) { - if (fd === 1) { - await ctx.fs.appendFile( - parsed.path, - stdout, - getFileEncoding(stdout), - ); - stdout = ""; - } else if (fd === 2) { - await ctx.fs.appendFile( - parsed.path, - stderr, - getFileEncoding(stderr), - ); - stderr = ""; - } - } - } else if (fdInfo?.startsWith("__dupout__:")) { - // FD is duplicated from another FD - resolve the chain - // __dupout__:N means this FD writes to the same place as FD N - const sourceFd = Number.parseInt(fdInfo.slice(11), 10); - if (sourceFd === 1) { - // Target FD duplicates stdout - output stays on stdout (no-op for 1>&N) - // stdout remains as is - } else if (sourceFd === 2) { - // Target FD duplicates stderr - redirect stdout to stderr - if (fd === 1) { - stderr += stdout; - stdout = ""; - } - } else { - // Check if sourceFd points to a file - const sourceInfo = ctx.state.fileDescriptors?.get(sourceFd); - if (sourceInfo?.startsWith("__file__:")) { - const resolvedPath = sourceInfo.slice(9); - if (fd === 1) { - await ctx.fs.appendFile( - resolvedPath, - stdout, - getFileEncoding(stdout), - ); - stdout = ""; - } else if (fd === 2) { - await ctx.fs.appendFile( - resolvedPath, - stderr, - getFileEncoding(stderr), - ); - stderr = ""; - } - } - } - } else if (fdInfo?.startsWith("__dupin__:")) { - // FD is duplicated for input - writing to it is an error - stderr += `bash: ${targetFd}: Bad file descriptor\n`; - exitCode = 1; - stdout = ""; - } else if (targetFd >= 3) { - // User FD range (3+) but FD not found - bad file descriptor - // For FDs 3-9 (manually allocated) and 10+ (auto-allocated), - // if the FD is not in fileDescriptors, it means it was closed or never opened - stderr += `bash: ${targetFd}: Bad file descriptor\n`; - exitCode = 1; - stdout = ""; - } - } else if (redir.operator === ">&") { - // In bash, N>&word where word is not a number or '-' is treated as a file redirect - // If no explicit fd (redir.fd == null), redirects BOTH stdout and stderr (equivalent to &>word) - // If explicit fd (e.g., 1>&word), redirects just that fd to the file - const filePath = ctx.fs.resolvePath(ctx.state.cwd, target); - const error = await checkOutputRedirectTarget( - ctx, - filePath, - target, - { - checkNoclobber: true, - }, - ); - if (error) { - stderr = error; - exitCode = 1; - stdout = ""; - break; - } - if (redir.fd == null) { - // >&word (no explicit fd) - write both stdout and stderr to the file - const combined = stdout + stderr; - await ctx.fs.writeFile( - filePath, - combined, - getFileEncoding(combined), - ); - stdout = ""; - stderr = ""; - } else if (fd === 1) { - // 1>&word - redirect stdout to file - await ctx.fs.writeFile(filePath, stdout, getFileEncoding(stdout)); - stdout = ""; - } else if (fd === 2) { - // 2>&word - redirect stderr to file - await ctx.fs.writeFile(filePath, stderr, getFileEncoding(stderr)); - stderr = ""; - } - } - } - break; - } - - case "&>": { - // /dev/full always returns ENOSPC when written to - if (target === "/dev/full") { - stderr = `bash: echo: write error: No space left on device\n`; - exitCode = 1; - stdout = ""; - break; - } - const filePath = ctx.fs.resolvePath(ctx.state.cwd, target); - const error = await checkOutputRedirectTarget(ctx, filePath, target, { - checkNoclobber: true, - }); - if (error) { - stderr = error; - exitCode = 1; - stdout = ""; - break; - } - // Smart encoding: binary for byte data, UTF-8 for Unicode text - const combined = stdout + stderr; - await ctx.fs.writeFile(filePath, combined, getFileEncoding(combined)); - stdout = ""; - stderr = ""; - break; - } - - case "&>>": { - // /dev/full always returns ENOSPC when written to - if (target === "/dev/full") { - stderr = `bash: echo: write error: No space left on device\n`; - exitCode = 1; - stdout = ""; - break; - } - const filePath = ctx.fs.resolvePath(ctx.state.cwd, target); - const error = await checkOutputRedirectTarget( - ctx, - filePath, - target, - {}, - ); - if (error) { - stderr = error; - exitCode = 1; - stdout = ""; - break; - } - // Smart encoding: binary for byte data, UTF-8 for Unicode text - const combined = stdout + stderr; - await ctx.fs.appendFile(filePath, combined, getFileEncoding(combined)); - stdout = ""; - stderr = ""; - break; - } - } - } - - // Apply persistent FD redirections (from exec) - // Check if fd 1 (stdout) is redirected to fd 2 (stderr) via exec 1>&2 - const fd1Info = ctx.state.fileDescriptors?.get(1); - if (fd1Info) { - if (fd1Info === "__dupout__:2") { - // fd 1 is duplicated to fd 2 - stdout goes to stderr - stderr += stdout; - stdout = ""; - } else if (fd1Info.startsWith("__file__:")) { - // fd 1 is redirected to a file - const filePath = fd1Info.slice(9); - await ctx.fs.appendFile(filePath, stdout, getFileEncoding(stdout)); - stdout = ""; - } else if (fd1Info.startsWith("__file_append__:")) { - const filePath = fd1Info.slice(16); - await ctx.fs.appendFile(filePath, stdout, getFileEncoding(stdout)); - stdout = ""; - } - } - - // Check if fd 2 (stderr) is redirected - const fd2Info = ctx.state.fileDescriptors?.get(2); - if (fd2Info) { - if (fd2Info === "__dupout__:1") { - // fd 2 is duplicated to fd 1 - stderr goes to stdout - stdout += stderr; - stderr = ""; - } else if (fd2Info.startsWith("__file__:")) { - const filePath = fd2Info.slice(9); - await ctx.fs.appendFile(filePath, stderr, getFileEncoding(stderr)); - stderr = ""; - } else if (fd2Info.startsWith("__file_append__:")) { - const filePath = fd2Info.slice(16); - await ctx.fs.appendFile(filePath, stderr, getFileEncoding(stderr)); - stderr = ""; - } - } - - return makeResult(stdout, stderr, exitCode); -} diff --git a/src/interpreter/simple-command-assignments.ts b/src/interpreter/simple-command-assignments.ts deleted file mode 100644 index 358d755c..00000000 --- a/src/interpreter/simple-command-assignments.ts +++ /dev/null @@ -1,864 +0,0 @@ -/** - * Simple Command Assignment Handling - * - * Handles variable assignments in simple commands: - * - Array assignments: VAR=(a b c) - * - Subscript assignments: VAR[idx]=value - * - Scalar assignments with nameref resolution - */ - -import type { SimpleCommandNode, WordNode } from "../ast/types.js"; -import { parseArithmeticExpression } from "../parser/arithmetic-parser.js"; -import { Parser } from "../parser/parser.js"; -import type { ExecResult } from "../types.js"; -import { evaluateArithmetic } from "./arithmetic.js"; -import { - applyCaseTransform, - getLocalVarDepth, - isInteger, -} from "./builtins/index.js"; -import { ArithmeticError, ExitError } from "./errors.js"; -import { - expandWord, - expandWordWithGlob, - getArrayElements, -} from "./expansion.js"; -import { - parseKeyedElementFromWord, - wordToLiteralString, -} from "./helpers/array.js"; -import { - getNamerefTarget, - isNameref, - resolveNameref, - resolveNamerefForAssignment, -} from "./helpers/nameref.js"; -import { checkReadonlyError, isReadonly } from "./helpers/readonly.js"; -import { result } from "./helpers/result.js"; -import { expandTildesInValue } from "./helpers/tilde.js"; -import { traceAssignment } from "./helpers/xtrace.js"; -import type { InterpreterContext } from "./types.js"; - -/** - * Result of processing assignments in a simple command - */ -export interface AssignmentResult { - /** Whether to continue to the next statement (skip command execution) */ - continueToNext: boolean; - /** Accumulated xtrace output for assignments */ - xtraceOutput: string; - /** Temporary assignments for prefix bindings (FOO=bar cmd) */ - tempAssignments: Map; - /** Error result if assignment failed */ - error?: ExecResult; -} - -/** - * Process all assignments in a simple command. - * Returns assignment results including temp bindings and any errors. - */ -export async function processAssignments( - ctx: InterpreterContext, - node: SimpleCommandNode, -): Promise { - const tempAssignments = new Map(); - let xtraceOutput = ""; - - for (const assignment of node.assignments) { - const name = assignment.name; - - // Handle array assignment: VAR=(a b c) or VAR+=(a b c) - if (assignment.array) { - const arrayResult = await processArrayAssignment( - ctx, - node, - name, - assignment.array, - assignment.append, - tempAssignments, - ); - if (arrayResult.error) { - return { - continueToNext: false, - xtraceOutput, - tempAssignments, - error: arrayResult.error, - }; - } - xtraceOutput += arrayResult.xtraceOutput; - if (arrayResult.continueToNext) { - continue; - } - } - - const value = assignment.value - ? await expandWord(ctx, assignment.value) - : ""; - - // Check for empty subscript assignment: a[]=value is invalid - const emptySubscriptMatch = name.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[\]$/); - if (emptySubscriptMatch) { - return { - continueToNext: false, - xtraceOutput, - tempAssignments, - error: result("", `bash: ${name}: bad array subscript\n`, 1), - }; - } - - // Check for array subscript assignment: a[subscript]=value - const subscriptMatch = name.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$/); - if (subscriptMatch) { - const subscriptResult = await processSubscriptAssignment( - ctx, - node, - subscriptMatch[1], - subscriptMatch[2], - value, - assignment.append, - tempAssignments, - ); - if (subscriptResult.error) { - return { - continueToNext: false, - xtraceOutput, - tempAssignments, - error: subscriptResult.error, - }; - } - if (subscriptResult.continueToNext) { - continue; - } - } - - // Handle scalar assignment - const scalarResult = await processScalarAssignment( - ctx, - node, - name, - value, - assignment.append, - tempAssignments, - ); - if (scalarResult.error) { - return { - continueToNext: false, - xtraceOutput, - tempAssignments, - error: scalarResult.error, - }; - } - xtraceOutput += scalarResult.xtraceOutput; - if (scalarResult.continueToNext) { - } - } - - return { - continueToNext: false, - xtraceOutput, - tempAssignments, - }; -} - -interface SingleAssignmentResult { - continueToNext: boolean; - xtraceOutput: string; - error?: ExecResult; -} - -/** - * Process an array assignment: VAR=(a b c) or VAR+=(a b c) - */ -async function processArrayAssignment( - ctx: InterpreterContext, - node: SimpleCommandNode, - name: string, - array: WordNode[], - append: boolean, - tempAssignments: Map, -): Promise { - let xtraceOutput = ""; - - // Check if trying to assign array to subscripted element: a[0]=(1 2) is invalid - if (/\[.+\]$/.test(name)) { - return { - continueToNext: false, - xtraceOutput: "", - error: result( - "", - `bash: ${name}: cannot assign list to array member\n`, - 1, - ), - }; - } - - // Check if name is a nameref - assigning an array to a nameref is complex - if (isNameref(ctx, name)) { - const target = getNamerefTarget(ctx, name); - if (target === undefined || target === "") { - throw new ExitError(1, "", ""); - } - const resolved = resolveNameref(ctx, name); - if (resolved && /^[a-zA-Z_][a-zA-Z0-9_]*\[@\]$/.test(resolved)) { - return { - continueToNext: false, - xtraceOutput: "", - error: result( - "", - `bash: ${name}: cannot assign list to array member\n`, - 1, - ), - }; - } - } - - // Check if array variable is readonly - if (isReadonly(ctx, name)) { - if (node.name) { - xtraceOutput += `bash: ${name}: readonly variable\n`; - return { continueToNext: true, xtraceOutput }; - } - const readonlyError = checkReadonlyError(ctx, name); - if (readonlyError) { - return { continueToNext: false, xtraceOutput: "", error: readonlyError }; - } - } - - // Check if this is an associative array - const isAssoc = ctx.state.associativeArrays?.has(name); - - // Check if elements use [key]=value or [key]+=value syntax - const hasKeyedElements = checkHasKeyedElements(array); - - // Helper to clear existing array elements - const clearExistingElements = () => { - const prefix = `${name}_`; - for (const key of ctx.state.env.keys()) { - if (key.startsWith(prefix) && !key.includes("__")) { - ctx.state.env.delete(key); - } - } - ctx.state.env.delete(name); - }; - - if (isAssoc && hasKeyedElements) { - await processAssociativeArrayAssignment( - ctx, - node, - name, - array, - append, - clearExistingElements, - (msg) => { - xtraceOutput += msg; - }, - ); - } else if (hasKeyedElements) { - await processIndexedArrayWithKeysAssignment( - ctx, - name, - array, - append, - clearExistingElements, - ); - } else { - await processSimpleArrayAssignment( - ctx, - name, - array, - append, - clearExistingElements, - ); - } - - // For prefix assignments with a command, bash stringifies the array syntax - if (node.name) { - tempAssignments.set(name, ctx.state.env.get(name)); - const elements = array.map((el) => wordToLiteralString(el)); - const stringified = `(${elements.join(" ")})`; - ctx.state.env.set(name, stringified); - } - - return { continueToNext: true, xtraceOutput }; -} - -/** - * Check if array elements use [key]=value syntax - */ -function checkHasKeyedElements(array: WordNode[]): boolean { - return array.some((element) => { - if (element.parts.length >= 2) { - const first = element.parts[0]; - const second = element.parts[1]; - if (first.type !== "Glob" || !first.pattern.startsWith("[")) { - return false; - } - if ( - first.pattern === "[" && - (second.type === "DoubleQuoted" || second.type === "SingleQuoted") - ) { - if (element.parts.length < 3) return false; - const third = element.parts[2]; - if (third.type !== "Literal") return false; - return third.value.startsWith("]=") || third.value.startsWith("]+="); - } - if (second.type !== "Literal") { - return false; - } - if (second.value.startsWith("]")) { - return second.value.startsWith("]=") || second.value.startsWith("]+="); - } - if (first.pattern.endsWith("]")) { - return second.value.startsWith("=") || second.value.startsWith("+="); - } - return false; - } - return false; - }); -} - -/** - * Process associative array assignment with [key]=value syntax - */ -async function processAssociativeArrayAssignment( - ctx: InterpreterContext, - node: SimpleCommandNode, - name: string, - array: WordNode[], - append: boolean, - clearExistingElements: () => void, - addXtraceOutput: (msg: string) => void, -): Promise { - interface PendingAssocElement { - type: "keyed"; - key: string; - value: string; - append: boolean; - } - interface PendingAssocInvalid { - type: "invalid"; - expandedValue: string; - } - const pendingElements: (PendingAssocElement | PendingAssocInvalid)[] = []; - - // First pass: Expand all values BEFORE clearing the array - for (const element of array) { - const parsed = parseKeyedElementFromWord(element); - if (parsed) { - const { key, valueParts, append: elementAppend } = parsed; - let value: string; - if (valueParts.length > 0) { - const valueWord: WordNode = { type: "Word", parts: valueParts }; - value = await expandWord(ctx, valueWord); - } else { - value = ""; - } - value = expandTildesInValue(ctx, value); - pendingElements.push({ - type: "keyed", - key, - value, - append: elementAppend, - }); - } else { - const expandedValue = await expandWord(ctx, element); - pendingElements.push({ type: "invalid", expandedValue }); - } - } - - // Clear existing elements AFTER all expansion - if (!append) { - clearExistingElements(); - } - - // Second pass: Perform all assignments - for (const pending of pendingElements) { - if (pending.type === "keyed") { - if (pending.append) { - const existing = ctx.state.env.get(`${name}_${pending.key}`) ?? ""; - ctx.state.env.set(`${name}_${pending.key}`, existing + pending.value); - } else { - ctx.state.env.set(`${name}_${pending.key}`, pending.value); - } - } else { - const lineNum = node.line ?? ctx.state.currentLine ?? 1; - addXtraceOutput( - `bash: line ${lineNum}: ${name}: ${pending.expandedValue}: must use subscript when assigning associative array\n`, - ); - } - } -} - -/** - * Process indexed array assignment with [index]=value syntax (sparse array) - */ -async function processIndexedArrayWithKeysAssignment( - ctx: InterpreterContext, - name: string, - array: WordNode[], - append: boolean, - clearExistingElements: () => void, -): Promise { - interface PendingElement { - type: "keyed"; - indexExpr: string; - value: string; - append: boolean; - } - interface PendingNonKeyed { - type: "non-keyed"; - values: string[]; - } - const pendingElements: (PendingElement | PendingNonKeyed)[] = []; - - // First pass: Expand all RHS values - for (const element of array) { - const parsed = parseKeyedElementFromWord(element); - if (parsed) { - const { key: indexExpr, valueParts, append: elementAppend } = parsed; - let value: string; - if (valueParts.length > 0) { - const valueWord: WordNode = { type: "Word", parts: valueParts }; - value = await expandWord(ctx, valueWord); - } else { - value = ""; - } - value = expandTildesInValue(ctx, value); - pendingElements.push({ - type: "keyed", - indexExpr, - value, - append: elementAppend, - }); - } else { - const expanded = await expandWordWithGlob(ctx, element); - pendingElements.push({ type: "non-keyed", values: expanded.values }); - } - } - - // Clear existing elements AFTER all RHS expansion - if (!append) { - clearExistingElements(); - } - - // Second pass: Evaluate all indices and perform assignments - let currentIndex = 0; - for (const pending of pendingElements) { - if (pending.type === "keyed") { - let index: number; - try { - const parser = new Parser(); - const arithAst = parseArithmeticExpression(parser, pending.indexExpr); - index = await evaluateArithmetic(ctx, arithAst.expression, false); - } catch { - if (/^-?\d+$/.test(pending.indexExpr)) { - index = Number.parseInt(pending.indexExpr, 10); - } else { - const varValue = ctx.state.env.get(pending.indexExpr); - index = varValue ? Number.parseInt(varValue, 10) : 0; - if (Number.isNaN(index)) index = 0; - } - } - if (pending.append) { - const existing = ctx.state.env.get(`${name}_${index}`) ?? ""; - ctx.state.env.set(`${name}_${index}`, existing + pending.value); - } else { - ctx.state.env.set(`${name}_${index}`, pending.value); - } - currentIndex = index + 1; - } else { - for (const val of pending.values) { - ctx.state.env.set(`${name}_${currentIndex++}`, val); - } - } - } -} - -/** - * Process simple array assignment without keyed elements - */ -async function processSimpleArrayAssignment( - ctx: InterpreterContext, - name: string, - array: WordNode[], - append: boolean, - clearExistingElements: () => void, -): Promise { - const allElements: string[] = []; - for (const element of array) { - const expanded = await expandWordWithGlob(ctx, element); - allElements.push(...expanded.values); - } - - let startIndex = 0; - if (append) { - const elements = getArrayElements(ctx, name); - if (elements.length > 0) { - const maxIndex = Math.max( - ...elements.map(([idx]) => (typeof idx === "number" ? idx : 0)), - ); - startIndex = maxIndex + 1; - } else { - const scalarValue = ctx.state.env.get(name); - if (scalarValue !== undefined) { - ctx.state.env.set(`${name}_0`, scalarValue); - ctx.state.env.delete(name); - startIndex = 1; - } - } - } else { - clearExistingElements(); - } - - for (let i = 0; i < allElements.length; i++) { - ctx.state.env.set(`${name}_${startIndex + i}`, allElements[i]); - } - if (!append) { - ctx.state.env.set(`${name}__length`, String(allElements.length)); - } -} - -/** - * Process a subscript assignment: VAR[idx]=value - */ -async function processSubscriptAssignment( - ctx: InterpreterContext, - node: SimpleCommandNode, - arrayName: string, - subscriptExpr: string, - value: string, - append: boolean, - tempAssignments: Map, -): Promise { - let resolvedArrayName = arrayName; - - // Check if arrayName is a nameref - if (isNameref(ctx, arrayName)) { - const resolved = resolveNameref(ctx, arrayName); - if (resolved && resolved !== arrayName) { - if (resolved.includes("[")) { - return { - continueToNext: false, - xtraceOutput: "", - error: result( - "", - `bash: \`${resolved}': not a valid identifier\n`, - 1, - ), - }; - } - resolvedArrayName = resolved; - } - } - - // Check if array variable is readonly - if (isReadonly(ctx, resolvedArrayName)) { - if (node.name) { - return { continueToNext: true, xtraceOutput: "" }; - } - const readonlyError = checkReadonlyError(ctx, resolvedArrayName); - if (readonlyError) { - return { continueToNext: false, xtraceOutput: "", error: readonlyError }; - } - } - - const isAssoc = ctx.state.associativeArrays?.has(resolvedArrayName); - let envKey: string; - - if (isAssoc) { - envKey = await computeAssocArrayEnvKey( - ctx, - resolvedArrayName, - subscriptExpr, - ); - } else { - const indexResult = await computeIndexedArrayIndex( - ctx, - resolvedArrayName, - subscriptExpr, - ); - if (indexResult.error) { - return { - continueToNext: false, - xtraceOutput: "", - error: indexResult.error, - }; - } - envKey = `${resolvedArrayName}_${indexResult.index}`; - } - - const finalValue = append ? (ctx.state.env.get(envKey) || "") + value : value; - - if (node.name) { - tempAssignments.set(envKey, ctx.state.env.get(envKey)); - ctx.state.env.set(envKey, finalValue); - } else { - const localDepth = getLocalVarDepth(ctx, resolvedArrayName); - if ( - localDepth !== undefined && - localDepth === ctx.state.callDepth && - ctx.state.localScopes.length > 0 - ) { - const currentScope = - ctx.state.localScopes[ctx.state.localScopes.length - 1]; - if (!currentScope.has(envKey)) { - currentScope.set(envKey, ctx.state.env.get(envKey)); - } - } - ctx.state.env.set(envKey, finalValue); - } - - return { continueToNext: true, xtraceOutput: "" }; -} - -/** - * Compute the env key for an associative array subscript - */ -async function computeAssocArrayEnvKey( - ctx: InterpreterContext, - arrayName: string, - subscriptExpr: string, -): Promise { - let key: string; - if (subscriptExpr.startsWith("'") && subscriptExpr.endsWith("'")) { - key = subscriptExpr.slice(1, -1); - } else if (subscriptExpr.startsWith('"') && subscriptExpr.endsWith('"')) { - const inner = subscriptExpr.slice(1, -1); - const parser = new Parser(); - const wordNode = parser.parseWordFromString(inner, true, false); - key = await expandWord(ctx, wordNode); - } else if (subscriptExpr.includes("$")) { - const parser = new Parser(); - const wordNode = parser.parseWordFromString(subscriptExpr, false, false); - key = await expandWord(ctx, wordNode); - } else { - key = subscriptExpr; - } - return `${arrayName}_${key}`; -} - -/** - * Compute the index for an indexed array subscript - */ -async function computeIndexedArrayIndex( - ctx: InterpreterContext, - arrayName: string, - subscriptExpr: string, -): Promise<{ index: number; error?: ExecResult }> { - let evalExpr = subscriptExpr; - if ( - subscriptExpr.startsWith('"') && - subscriptExpr.endsWith('"') && - subscriptExpr.length >= 2 - ) { - evalExpr = subscriptExpr.slice(1, -1); - } - - let index: number; - if (/^-?\d+$/.test(evalExpr)) { - index = Number.parseInt(evalExpr, 10); - } else { - try { - const parser = new Parser(); - const arithAst = parseArithmeticExpression(parser, evalExpr); - index = await evaluateArithmetic(ctx, arithAst.expression, false); - } catch (e) { - if (e instanceof ArithmeticError) { - const lineNum = ctx.state.currentLine; - const errorMsg = `bash: line ${lineNum}: ${subscriptExpr}: ${e.message}\n`; - if (e.fatal) { - throw new ExitError(1, "", errorMsg); - } - return { index: 0, error: result("", errorMsg, 1) }; - } - const varValue = ctx.state.env.get(subscriptExpr); - index = varValue ? Number.parseInt(varValue, 10) : 0; - } - if (Number.isNaN(index)) index = 0; - } - - // Handle negative indices - if (index < 0) { - const elements = getArrayElements(ctx, arrayName); - if (elements.length === 0) { - const lineNum = ctx.state.currentLine; - return { - index: 0, - error: result( - "", - `bash: line ${lineNum}: ${arrayName}[${subscriptExpr}]: bad array subscript\n`, - 1, - ), - }; - } - const maxIndex = Math.max( - ...elements.map(([idx]) => (typeof idx === "number" ? idx : 0)), - ); - index = maxIndex + 1 + index; - if (index < 0) { - const lineNum = ctx.state.currentLine; - return { - index: 0, - error: result( - "", - `bash: line ${lineNum}: ${arrayName}[${subscriptExpr}]: bad array subscript\n`, - 1, - ), - }; - } - } - - return { index }; -} - -/** - * Process a scalar assignment - */ -async function processScalarAssignment( - ctx: InterpreterContext, - node: SimpleCommandNode, - name: string, - value: string, - append: boolean, - tempAssignments: Map, -): Promise { - let xtraceOutput = ""; - - // Resolve nameref - let targetName = name; - let namerefArrayRef: { arrayName: string; subscriptExpr: string } | null = - null; - - if (isNameref(ctx, name)) { - const resolved = resolveNamerefForAssignment(ctx, name, value); - if (resolved === undefined) { - return { - continueToNext: false, - xtraceOutput: "", - error: result("", `bash: ${name}: circular name reference\n`, 1), - }; - } - if (resolved === null) { - return { continueToNext: true, xtraceOutput: "" }; - } - targetName = resolved; - - const arrayRefMatch = targetName.match( - /^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$/, - ); - if (arrayRefMatch) { - namerefArrayRef = { - arrayName: arrayRefMatch[1], - subscriptExpr: arrayRefMatch[2], - }; - targetName = arrayRefMatch[1]; - } - } - - // Check if variable is readonly - if (isReadonly(ctx, targetName)) { - if (node.name) { - xtraceOutput += `bash: ${targetName}: readonly variable\n`; - return { continueToNext: true, xtraceOutput }; - } - const readonlyError = checkReadonlyError(ctx, targetName); - if (readonlyError) { - return { continueToNext: false, xtraceOutput: "", error: readonlyError }; - } - } - - // Handle append mode and integer attribute - let finalValue: string; - if (isInteger(ctx, targetName)) { - try { - const parser = new Parser(); - if (append) { - const currentVal = ctx.state.env.get(targetName) || "0"; - const expr = `(${currentVal}) + (${value})`; - const arithAst = parseArithmeticExpression(parser, expr); - finalValue = String(await evaluateArithmetic(ctx, arithAst.expression)); - } else { - const arithAst = parseArithmeticExpression(parser, value); - finalValue = String(await evaluateArithmetic(ctx, arithAst.expression)); - } - } catch { - finalValue = "0"; - } - } else { - const { isArray } = await import("./expansion.js"); - const appendKey = isArray(ctx, targetName) ? `${targetName}_0` : targetName; - finalValue = append ? (ctx.state.env.get(appendKey) || "") + value : value; - } - - finalValue = applyCaseTransform(ctx, targetName, finalValue); - - xtraceOutput += await traceAssignment(ctx, targetName, finalValue); - - // Compute actual env key - let actualEnvKey = targetName; - if (namerefArrayRef) { - actualEnvKey = await computeNamerefArrayEnvKey(ctx, namerefArrayRef); - } else { - const { isArray } = await import("./expansion.js"); - if (isArray(ctx, targetName)) { - actualEnvKey = `${targetName}_0`; - } - } - - if (node.name) { - tempAssignments.set(actualEnvKey, ctx.state.env.get(actualEnvKey)); - ctx.state.env.set(actualEnvKey, finalValue); - } else { - ctx.state.env.set(actualEnvKey, finalValue); - if (ctx.state.options.allexport) { - ctx.state.exportedVars = ctx.state.exportedVars || new Set(); - ctx.state.exportedVars.add(targetName); - } - if (ctx.state.tempEnvBindings?.some((b) => b.has(targetName))) { - ctx.state.mutatedTempEnvVars = ctx.state.mutatedTempEnvVars || new Set(); - ctx.state.mutatedTempEnvVars.add(targetName); - } - } - - return { continueToNext: false, xtraceOutput }; -} - -/** - * Compute the env key for a nameref pointing to an array element - */ -async function computeNamerefArrayEnvKey( - ctx: InterpreterContext, - namerefArrayRef: { arrayName: string; subscriptExpr: string }, -): Promise { - const { arrayName, subscriptExpr } = namerefArrayRef; - const isAssoc = ctx.state.associativeArrays?.has(arrayName); - - if (isAssoc) { - return computeAssocArrayEnvKey(ctx, arrayName, subscriptExpr); - } - - let index: number; - if (/^-?\d+$/.test(subscriptExpr)) { - index = Number.parseInt(subscriptExpr, 10); - } else { - try { - const parser = new Parser(); - const arithAst = parseArithmeticExpression(parser, subscriptExpr); - index = await evaluateArithmetic(ctx, arithAst.expression, false); - } catch { - const varValue = ctx.state.env.get(subscriptExpr); - index = varValue ? Number.parseInt(varValue, 10) : 0; - } - if (Number.isNaN(index)) index = 0; - } - - if (index < 0) { - const elements = getArrayElements(ctx, arrayName); - if (elements.length > 0) { - const maxIdx = Math.max(...elements.map((e) => e[0] as number)); - index = maxIdx + 1 + index; - } - } - - return `${arrayName}_${index}`; -} diff --git a/src/interpreter/subshell-group.ts b/src/interpreter/subshell-group.ts deleted file mode 100644 index 1065c6eb..00000000 --- a/src/interpreter/subshell-group.ts +++ /dev/null @@ -1,419 +0,0 @@ -/** - * Subshell, Group, and Script Execution - * - * Handles execution of subshells (...), groups { ...; }, and user scripts - */ - -import type { - GroupNode, - HereDocNode, - ScriptNode, - StatementNode, - SubshellNode, - WordNode, -} from "../ast/types.js"; -import { Parser } from "../parser/parser.js"; -import type { ParseException } from "../parser/types.js"; -import type { ExecResult } from "../types.js"; -import { - BreakError, - ContinueError, - ErrexitError, - ExecutionLimitError, - ExitError, - isScopeExitError, - ReturnError, - SubshellExitError, -} from "./errors.js"; -import { expandWord } from "./expansion.js"; -import { getErrorMessage } from "./helpers/errors.js"; -import { failure, result } from "./helpers/result.js"; -import { - applyRedirections, - preOpenOutputRedirects, - processFdVariableRedirections, -} from "./redirections.js"; -import type { InterpreterContext } from "./types.js"; - -/** - * Type for executeStatement callback - */ -export type ExecuteStatementFn = (stmt: StatementNode) => Promise; - -/** - * Execute a subshell node (...). - * Creates an isolated execution environment that doesn't affect the parent. - */ -export async function executeSubshell( - ctx: InterpreterContext, - node: SubshellNode, - stdin: string, - executeStatement: ExecuteStatementFn, -): Promise { - // Pre-open output redirects to truncate files BEFORE executing body - // This matches bash behavior where redirect files are opened before - // any command substitutions in the subshell body are evaluated - const preOpenError = await preOpenOutputRedirects(ctx, node.redirections); - if (preOpenError) { - return preOpenError; - } - - const savedEnv = new Map(ctx.state.env); - const savedCwd = ctx.state.cwd; - // Save options so subshell changes (like set -e) don't affect parent - const savedOptions = { ...ctx.state.options }; - - // Save functions so subshell definitions don't leak to parent - // This is critical for proper subshell isolation - in real bash, function - // definitions inside (...) are isolated and don't affect the parent shell - // Note: Aliases are stored in env with BASH_ALIAS_ prefix, so they're - // already isolated via savedEnv - const savedFunctions = new Map(ctx.state.functions); - - // Save local variable scoping state for subshell isolation - // Subshell gets a copy of these, but changes don't affect parent - const savedLocalScopes = ctx.state.localScopes; - const savedLocalVarStack = ctx.state.localVarStack; - const savedLocalVarDepth = ctx.state.localVarDepth; - const savedFullyUnsetLocals = ctx.state.fullyUnsetLocals; - - // Deep copy the local scoping structures for the subshell - ctx.state.localScopes = savedLocalScopes.map((scope) => new Map(scope)); - if (savedLocalVarStack) { - ctx.state.localVarStack = new Map(); - for (const [name, stack] of savedLocalVarStack.entries()) { - ctx.state.localVarStack.set( - name, - stack.map((entry) => ({ ...entry })), - ); - } - } - if (savedLocalVarDepth) { - ctx.state.localVarDepth = new Map(savedLocalVarDepth); - } - if (savedFullyUnsetLocals) { - ctx.state.fullyUnsetLocals = new Map(savedFullyUnsetLocals); - } - - // Reset loopDepth in subshell - break/continue should not affect parent loops - const savedLoopDepth = ctx.state.loopDepth; - // Track if parent has loop context - break/continue in subshell should exit subshell - const savedParentHasLoopContext = ctx.state.parentHasLoopContext; - ctx.state.parentHasLoopContext = savedLoopDepth > 0; - ctx.state.loopDepth = 0; - - // Save $_ (last argument) - subshell execution should not affect parent's $_ - const savedLastArg = ctx.state.lastArg; - - // Subshells get a new BASHPID (unlike $$ which stays the same) - const savedBashPid = ctx.state.bashPid; - ctx.state.bashPid = ctx.state.nextVirtualPid++; - - // Save any existing groupStdin and set new one from pipeline - const savedGroupStdin = ctx.state.groupStdin; - if (stdin) { - ctx.state.groupStdin = stdin; - } - - let stdout = ""; - let stderr = ""; - let exitCode = 0; - - const restore = (): void => { - ctx.state.env = savedEnv; - ctx.state.cwd = savedCwd; - ctx.state.options = savedOptions; - ctx.state.functions = savedFunctions; - ctx.state.localScopes = savedLocalScopes; - ctx.state.localVarStack = savedLocalVarStack; - ctx.state.localVarDepth = savedLocalVarDepth; - ctx.state.fullyUnsetLocals = savedFullyUnsetLocals; - ctx.state.loopDepth = savedLoopDepth; - ctx.state.parentHasLoopContext = savedParentHasLoopContext; - ctx.state.groupStdin = savedGroupStdin; - ctx.state.bashPid = savedBashPid; - ctx.state.lastArg = savedLastArg; - }; - - try { - for (const stmt of node.body) { - const res = await executeStatement(stmt); - stdout += res.stdout; - stderr += res.stderr; - exitCode = res.exitCode; - } - } catch (error) { - restore(); - // ExecutionLimitError must always propagate - these are safety limits - if (error instanceof ExecutionLimitError) { - throw error; - } - // SubshellExitError means break/continue was called when parent had loop context - // This exits the subshell cleanly with exit code 0 - if (error instanceof SubshellExitError) { - stdout += error.stdout; - stderr += error.stderr; - // Apply output redirections before returning - const bodyResult = result(stdout, stderr, 0); - return applyRedirections(ctx, bodyResult, node.redirections); - } - // BreakError/ContinueError should NOT propagate out of subshell - // They only affect loops within the subshell - if (error instanceof BreakError || error instanceof ContinueError) { - stdout += error.stdout; - stderr += error.stderr; - // Apply output redirections before returning - const bodyResult = result(stdout, stderr, 0); - return applyRedirections(ctx, bodyResult, node.redirections); - } - // ExitError in subshell should NOT propagate - just return the exit code - // (subshells are like separate processes) - if (error instanceof ExitError) { - stdout += error.stdout; - stderr += error.stderr; - // Apply output redirections before returning - const bodyResult = result(stdout, stderr, error.exitCode); - return applyRedirections(ctx, bodyResult, node.redirections); - } - // ReturnError in subshell (e.g., f() ( return 42; )) should also just exit - // with the given code, since subshells are like separate processes - if (error instanceof ReturnError) { - stdout += error.stdout; - stderr += error.stderr; - // Apply output redirections before returning - const bodyResult = result(stdout, stderr, error.exitCode); - return applyRedirections(ctx, bodyResult, node.redirections); - } - if (error instanceof ErrexitError) { - // Apply output redirections before propagating - const bodyResult = result( - stdout + error.stdout, - stderr + error.stderr, - error.exitCode, - ); - return applyRedirections(ctx, bodyResult, node.redirections); - } - // Apply output redirections before returning - const bodyResult = result( - stdout, - `${stderr}${getErrorMessage(error)}\n`, - 1, - ); - return applyRedirections(ctx, bodyResult, node.redirections); - } - - restore(); - - // Apply output redirections - const bodyResult = result(stdout, stderr, exitCode); - return applyRedirections(ctx, bodyResult, node.redirections); -} - -/** - * Execute a group node { ...; }. - * Runs commands in the current execution environment. - */ -export async function executeGroup( - ctx: InterpreterContext, - node: GroupNode, - stdin: string, - executeStatement: ExecuteStatementFn, -): Promise { - let stdout = ""; - let stderr = ""; - let exitCode = 0; - - // Process FD variable redirections ({varname}>file syntax) - const fdVarError = await processFdVariableRedirections( - ctx, - node.redirections, - ); - if (fdVarError) { - return fdVarError; - } - - // Process heredoc and input redirections to get stdin content - let effectiveStdin = stdin; - for (const redir of node.redirections) { - if ( - (redir.operator === "<<" || redir.operator === "<<-") && - redir.target.type === "HereDoc" - ) { - const hereDoc = redir.target as HereDocNode; - let content = await expandWord(ctx, hereDoc.content); - if (hereDoc.stripTabs) { - content = content - .split("\n") - .map((line) => line.replace(/^\t+/, "")) - .join("\n"); - } - // If this is a non-standard fd (not 0), store in fileDescriptors for -u option - const fd = redir.fd ?? 0; - if (fd !== 0) { - if (!ctx.state.fileDescriptors) { - ctx.state.fileDescriptors = new Map(); - } - ctx.state.fileDescriptors.set(fd, content); - } else { - effectiveStdin = content; - } - } else if (redir.operator === "<<<" && redir.target.type === "Word") { - effectiveStdin = `${await expandWord(ctx, redir.target as WordNode)}\n`; - } else if (redir.operator === "<" && redir.target.type === "Word") { - try { - const target = await expandWord(ctx, redir.target as WordNode); - const filePath = ctx.fs.resolvePath(ctx.state.cwd, target); - effectiveStdin = await ctx.fs.readFile(filePath); - } catch { - const target = await expandWord(ctx, redir.target as WordNode); - return result("", `bash: ${target}: No such file or directory\n`, 1); - } - } - } - - // Save any existing groupStdin and set new one from pipeline - const savedGroupStdin = ctx.state.groupStdin; - if (effectiveStdin) { - ctx.state.groupStdin = effectiveStdin; - } - - try { - for (const stmt of node.body) { - const res = await executeStatement(stmt); - stdout += res.stdout; - stderr += res.stderr; - exitCode = res.exitCode; - } - } catch (error) { - // Restore groupStdin before handling error - ctx.state.groupStdin = savedGroupStdin; - // ExecutionLimitError must always propagate - these are safety limits - if (error instanceof ExecutionLimitError) { - throw error; - } - if ( - isScopeExitError(error) || - error instanceof ErrexitError || - error instanceof ExitError - ) { - error.prependOutput(stdout, stderr); - throw error; - } - return result(stdout, `${stderr}${getErrorMessage(error)}\n`, 1); - } - - // Restore groupStdin - ctx.state.groupStdin = savedGroupStdin; - - // Apply output redirections - const bodyResult = result(stdout, stderr, exitCode); - return applyRedirections(ctx, bodyResult, node.redirections); -} - -/** - * Type for executeScript callback - */ -export type ExecuteScriptFn = (node: ScriptNode) => Promise; - -/** - * Execute a user script file found in PATH. - * This handles executable files that don't have registered command handlers. - * The script runs in a subshell-like environment with its own positional parameters. - */ -export async function executeUserScript( - ctx: InterpreterContext, - scriptPath: string, - args: string[], - stdin: string, - executeScript: ExecuteScriptFn, -): Promise { - // Read the script content - let content: string; - try { - content = await ctx.fs.readFile(scriptPath); - } catch { - return failure(`bash: ${scriptPath}: No such file or directory\n`, 127); - } - - // Check for shebang and skip it if present (we'll execute as bash script) - // Note: we don't actually support different interpreters, just bash - if (content.startsWith("#!")) { - const firstNewline = content.indexOf("\n"); - if (firstNewline !== -1) { - content = content.slice(firstNewline + 1); - } - } - - // Save current state for restoration after script execution - const savedEnv = new Map(ctx.state.env); - const savedCwd = ctx.state.cwd; - const savedOptions = { ...ctx.state.options }; - const savedLoopDepth = ctx.state.loopDepth; - const savedParentHasLoopContext = ctx.state.parentHasLoopContext; - const savedLastArg = ctx.state.lastArg; - const savedBashPid = ctx.state.bashPid; - const savedGroupStdin = ctx.state.groupStdin; - const savedSource = ctx.state.currentSource; - - // Set up subshell-like environment - ctx.state.parentHasLoopContext = savedLoopDepth > 0; - ctx.state.loopDepth = 0; - ctx.state.bashPid = ctx.state.nextVirtualPid++; - if (stdin) { - ctx.state.groupStdin = stdin; - } - ctx.state.currentSource = scriptPath; - - // Set positional parameters ($1, $2, etc.) from args - // $0 should be the script path - ctx.state.env.set("0", scriptPath); - ctx.state.env.set("#", String(args.length)); - ctx.state.env.set("@", args.join(" ")); - ctx.state.env.set("*", args.join(" ")); - for (let i = 0; i < args.length && i < 9; i++) { - ctx.state.env.set(String(i + 1), args[i]); - } - // Clear any remaining positional parameters - for (let i = args.length + 1; i <= 9; i++) { - ctx.state.env.delete(String(i)); - } - - const cleanup = (): void => { - ctx.state.env = savedEnv; - ctx.state.cwd = savedCwd; - ctx.state.options = savedOptions; - ctx.state.loopDepth = savedLoopDepth; - ctx.state.parentHasLoopContext = savedParentHasLoopContext; - ctx.state.lastArg = savedLastArg; - ctx.state.bashPid = savedBashPid; - ctx.state.groupStdin = savedGroupStdin; - ctx.state.currentSource = savedSource; - }; - - try { - const parser = new Parser(); - const ast = parser.parse(content); - const execResult = await executeScript(ast); - cleanup(); - return execResult; - } catch (error) { - cleanup(); - - // ExitError propagates up (but with output from this script) - if (error instanceof ExitError) { - throw error; - } - - // ExecutionLimitError must always propagate - if (error instanceof ExecutionLimitError) { - throw error; - } - - // Handle parse errors - if ((error as ParseException).name === "ParseException") { - return failure(`bash: ${scriptPath}: ${(error as Error).message}\n`); - } - - throw error; - } -} diff --git a/src/interpreter/type-command.ts b/src/interpreter/type-command.ts deleted file mode 100644 index 6f62453a..00000000 --- a/src/interpreter/type-command.ts +++ /dev/null @@ -1,579 +0,0 @@ -/** - * Type Command Implementation - * - * Implements the `type` builtin command and related functionality: - * - type [-afptP] name... - * - command -v/-V name... - * - * Also includes helpers for function source serialization. - */ - -import type { - CommandNode, - FunctionDefNode, - GroupNode, - PipelineNode, - SimpleCommandNode, - StatementNode, - WordNode, -} from "../ast/types.js"; -import type { IFileSystem } from "../fs/interface.js"; -import type { CommandRegistry, ExecResult } from "../types.js"; -import { result } from "./helpers/result.js"; -import { SHELL_BUILTINS, SHELL_KEYWORDS } from "./helpers/shell-constants.js"; -import type { InterpreterState } from "./types.js"; - -/** - * Context needed for type command operations - */ -export interface TypeCommandContext { - state: InterpreterState; - fs: IFileSystem; - commands: CommandRegistry; -} - -/** - * Handle the `type` builtin command. - * type [-afptP] name... - */ -export async function handleType( - ctx: TypeCommandContext, - args: string[], - findFirstInPath: (name: string) => Promise, - findCommandInPath: (name: string) => Promise, -): Promise { - // Parse options - let typeOnly = false; // -t flag: print only the type word - let pathOnly = false; // -p flag: print only paths to executables (respects aliases/functions/builtins) - let forcePathSearch = false; // -P flag: force PATH search (ignores aliases/functions/builtins) - let showAll = false; // -a flag: show all definitions - let suppressFunctions = false; // -f flag: suppress function lookup - const names: string[] = []; - - for (const arg of args) { - if (arg.startsWith("-") && arg.length > 1) { - // Handle combined options like -ap, -tP, etc. - for (const char of arg.slice(1)) { - if (char === "t") { - typeOnly = true; - } else if (char === "p") { - pathOnly = true; - } else if (char === "P") { - forcePathSearch = true; - } else if (char === "a") { - showAll = true; - } else if (char === "f") { - suppressFunctions = true; - } - } - } else { - names.push(arg); - } - } - - let stdout = ""; - let stderr = ""; - let exitCode = 0; - let anyFileFound = false; // Track if any name was found as a file (for -p exit code) - let anyNotFound = false; // Track if any name wasn't found - - for (const name of names) { - let foundAny = false; - - // -P flag: force PATH search, ignoring aliases/functions/builtins - if (forcePathSearch) { - // -a -P: show all paths - if (showAll) { - const allPaths = await findCommandInPath(name); - if (allPaths.length > 0) { - for (const p of allPaths) { - stdout += `${p}\n`; - } - anyFileFound = true; - foundAny = true; - } - } else { - const pathResult = await findFirstInPath(name); - if (pathResult) { - stdout += `${pathResult}\n`; - anyFileFound = true; - foundAny = true; - } - } - if (!foundAny) { - anyNotFound = true; - } - // For -P, don't print anything if not found in PATH - continue; - } - - // Check functions first (unless -f suppresses them) - // Note: In bash, with -a, functions are checked first, then aliases, keywords, builtins, files - // But without -a, the order is: alias, keyword, function, builtin, file - // With -f, we skip function lookup entirely - - // When showing all (-a), we need to show in this order: - // 1. function (unless -f) - // 2. alias - // 3. keyword - // 4. builtin - // 5. all file paths - - // Without -a, we stop at the first match (in order: alias, keyword, function, builtin, file) - - // Check functions (unless -f suppresses them) - const hasFunction = !suppressFunctions && ctx.state.functions.has(name); - if (showAll && hasFunction) { - // -p: print nothing for functions (no path) - if (pathOnly) { - // Do nothing - functions have no path - } else if (typeOnly) { - stdout += "function\n"; - } else { - // Get the function body for display - const funcDef = ctx.state.functions.get(name); - const funcSource = funcDef - ? formatFunctionSource(name, funcDef) - : `${name} is a function\n`; - stdout += funcSource; - } - foundAny = true; - } - - // Check aliases - // Aliases are stored in env with BASH_ALIAS_ prefix - const alias = ctx.state.env.get(`BASH_ALIAS_${name}`); - const hasAlias = alias !== undefined; - if (hasAlias && (showAll || !foundAny)) { - // -p: print nothing for aliases (no path), but count as "found" - if (pathOnly) { - // Do nothing - aliases have no path - } else if (typeOnly) { - stdout += "alias\n"; - } else { - stdout += `${name} is aliased to \`${alias}'\n`; - } - foundAny = true; - if (!showAll) { - // Not showing all, continue to next name - continue; - } - } - - // Check keywords - const hasKeyword = SHELL_KEYWORDS.has(name); - if (hasKeyword && (showAll || !foundAny)) { - // -p: print nothing for keywords (no path), but count as "found" - if (pathOnly) { - // Do nothing - keywords have no path - } else if (typeOnly) { - stdout += "keyword\n"; - } else { - stdout += `${name} is a shell keyword\n`; - } - foundAny = true; - if (!showAll) { - continue; - } - } - - // Check functions (for non-showAll case, functions come before builtins) - // This matches bash behavior: alias, keyword, function, builtin, file - if (!showAll && hasFunction && !foundAny) { - // -p: print nothing for functions (no path), but count as "found" - if (pathOnly) { - // Do nothing - functions have no path - } else if (typeOnly) { - stdout += "function\n"; - } else { - const funcDef = ctx.state.functions.get(name); - const funcSource = funcDef - ? formatFunctionSource(name, funcDef) - : `${name} is a function\n`; - stdout += funcSource; - } - foundAny = true; - continue; - } - - // Check builtins - const hasBuiltin = SHELL_BUILTINS.has(name); - if (hasBuiltin && (showAll || !foundAny)) { - // -p: print nothing for builtins (no path), but count as "found" - if (pathOnly) { - // Do nothing - builtins have no path - } else if (typeOnly) { - stdout += "builtin\n"; - } else { - stdout += `${name} is a shell builtin\n`; - } - foundAny = true; - if (!showAll) { - continue; - } - } - - // Check PATH for external command(s) - if (showAll) { - // Show all file paths - const allPaths = await findCommandInPath(name); - for (const pathResult of allPaths) { - if (pathOnly) { - stdout += `${pathResult}\n`; - } else if (typeOnly) { - stdout += "file\n"; - } else { - stdout += `${name} is ${pathResult}\n`; - } - anyFileFound = true; - foundAny = true; - } - } else if (!foundAny) { - // Just find first - const pathResult = await findFirstInPath(name); - if (pathResult) { - if (pathOnly) { - stdout += `${pathResult}\n`; - } else if (typeOnly) { - stdout += "file\n"; - } else { - stdout += `${name} is ${pathResult}\n`; - } - anyFileFound = true; - foundAny = true; - } - } - - if (!foundAny) { - // Name not found anywhere - anyNotFound = true; - if (!typeOnly && !pathOnly) { - // For relative paths (containing /), if the file exists but isn't executable, - // don't print "not found" - it was found, just not as an executable command. - // Only print "not found" if the file doesn't exist at all. - let shouldPrintError = true; - if (name.includes("/")) { - const resolvedPath = ctx.fs.resolvePath(ctx.state.cwd, name); - if (await ctx.fs.exists(resolvedPath)) { - // File exists but isn't executable - don't print error - shouldPrintError = false; - } - } - if (shouldPrintError) { - stderr += `bash: type: ${name}: not found\n`; - } - } - } - } - - // Set exit code based on results - // For -p: exit 1 only if no files were found AND there was something not found - // For -P: exit 1 if any name wasn't found in PATH - // For regular type and type -t: exit 1 if any name wasn't found - if (pathOnly) { - // -p: exit 1 only if no files were found AND there was something not found - exitCode = anyNotFound && !anyFileFound ? 1 : 0; - } else if (forcePathSearch) { - // -P: exit 1 if any name wasn't found in PATH - exitCode = anyNotFound ? 1 : 0; - } else { - // Regular type or type -t: exit 1 if any name wasn't found - exitCode = anyNotFound ? 1 : 0; - } - - return result(stdout, stderr, exitCode); -} - -/** - * Format a function definition for type output. - * Produces bash-style output like: - * f is a function - * f () - * { - * echo - * } - */ -function formatFunctionSource(name: string, funcDef: FunctionDefNode): string { - // For function bodies that are Group nodes, unwrap them since we add { } ourselves - let bodyStr: string; - if (funcDef.body.type === "Group") { - const group = funcDef.body as GroupNode; - bodyStr = group.body.map((s) => serializeCompoundCommand(s)).join("; "); - } else { - bodyStr = serializeCompoundCommand(funcDef.body); - } - return `${name} is a function\n${name} () \n{ \n ${bodyStr}\n}\n`; -} - -/** - * Serialize a compound command to its source representation. - * This is a simplified serializer for function body display. - */ -function serializeCompoundCommand( - node: CommandNode | StatementNode | StatementNode[], -): string { - if (Array.isArray(node)) { - return node.map((s) => serializeCompoundCommand(s)).join("; "); - } - - if (node.type === "Statement") { - const parts: string[] = []; - for (let i = 0; i < node.pipelines.length; i++) { - const pipeline = node.pipelines[i]; - parts.push(serializePipeline(pipeline)); - if (node.operators[i]) { - parts.push(node.operators[i]); - } - } - return parts.join(" "); - } - - if (node.type === "SimpleCommand") { - const cmd = node as SimpleCommandNode; - const parts: string[] = []; - if (cmd.name) { - parts.push(serializeWord(cmd.name)); - } - for (const arg of cmd.args) { - parts.push(serializeWord(arg)); - } - return parts.join(" "); - } - - if (node.type === "Group") { - const group = node as GroupNode; - const body = group.body.map((s) => serializeCompoundCommand(s)).join("; "); - return `{ ${body}; }`; - } - - // For other compound commands, return a placeholder - return "..."; -} - -function serializePipeline(pipeline: PipelineNode): string { - const parts = pipeline.commands.map((cmd) => serializeCompoundCommand(cmd)); - return (pipeline.negated ? "! " : "") + parts.join(" | "); -} - -function serializeWord(word: WordNode): string { - // Simple serialization - just concatenate parts - let result = ""; - for (const part of word.parts) { - if (part.type === "Literal") { - result += part.value; - } else if (part.type === "DoubleQuoted") { - result += `"${part.parts.map((p) => serializeWordPart(p)).join("")}"`; - } else if (part.type === "SingleQuoted") { - result += `'${part.value}'`; - } else { - result += serializeWordPart(part); - } - } - return result; -} - -function serializeWordPart(part: unknown): string { - const p = part as { type: string; value?: string; name?: string }; - if (p.type === "Literal") { - return p.value ?? ""; - } - if (p.type === "Variable") { - return `$${p.name}`; - } - // For other part types, return empty or placeholder - return ""; -} - -/** - * Handle `command -v` and `command -V` flags - * -v: print the name or path of the command (simple output) - * -V: print a description like `type` does (verbose output) - */ -export async function handleCommandV( - ctx: TypeCommandContext, - names: string[], - _showPath: boolean, - verboseDescribe: boolean, -): Promise { - let stdout = ""; - let stderr = ""; - let exitCode = 0; - - for (const name of names) { - // Empty name is not found - if (!name) { - exitCode = 1; - continue; - } - - // Check aliases first (before other checks) - const alias = ctx.state.env.get(`BASH_ALIAS_${name}`); - if (alias !== undefined) { - if (verboseDescribe) { - stdout += `${name} is an alias for "${alias}"\n`; - } else { - stdout += `alias ${name}='${alias}'\n`; - } - } else if (SHELL_KEYWORDS.has(name)) { - if (verboseDescribe) { - stdout += `${name} is a shell keyword\n`; - } else { - stdout += `${name}\n`; - } - } else if (SHELL_BUILTINS.has(name)) { - if (verboseDescribe) { - stdout += `${name} is a shell builtin\n`; - } else { - stdout += `${name}\n`; - } - } else if (ctx.state.functions.has(name)) { - if (verboseDescribe) { - stdout += `${name} is a function\n`; - } else { - stdout += `${name}\n`; - } - } else if (name.includes("/")) { - // Path containing / - check if file exists and is executable - const resolvedPath = ctx.fs.resolvePath(ctx.state.cwd, name); - let found = false; - if (await ctx.fs.exists(resolvedPath)) { - try { - const stat = await ctx.fs.stat(resolvedPath); - if (!stat.isDirectory) { - // Check if file is executable (owner, group, or other execute bit set) - const isExecutable = (stat.mode & 0o111) !== 0; - if (isExecutable) { - if (verboseDescribe) { - stdout += `${name} is ${name}\n`; - } else { - stdout += `${name}\n`; - } - found = true; - } - } - } catch { - // If stat fails, treat as not found - } - } - if (!found) { - // Not found - for -V, print error to stderr - if (verboseDescribe) { - stderr += `${name}: not found\n`; - } - exitCode = 1; - } - } else if (ctx.commands.has(name)) { - // Search PATH for the command file (registered commands exist in both /usr/bin and /bin) - const pathEnv = ctx.state.env.get("PATH") ?? "/usr/bin:/bin"; - const pathDirs = pathEnv.split(":"); - let foundPath: string | null = null; - for (const dir of pathDirs) { - if (!dir) continue; - const cmdPath = `${dir}/${name}`; - try { - const stat = await ctx.fs.stat(cmdPath); - if (!stat.isDirectory && (stat.mode & 0o111) !== 0) { - foundPath = cmdPath; - break; - } - } catch { - // File doesn't exist in this directory, continue searching - } - } - // Fall back to /usr/bin if not found in PATH (shouldn't happen for registered commands) - if (!foundPath) { - foundPath = `/usr/bin/${name}`; - } - if (verboseDescribe) { - stdout += `${name} is ${foundPath}\n`; - } else { - stdout += `${foundPath}\n`; - } - } else { - // Not found - for -V, print error to stderr (matches test at line 237-255) - if (verboseDescribe) { - stderr += `${name}: not found\n`; - } - exitCode = 1; - } - } - - return result(stdout, stderr, exitCode); -} - -/** - * Find the first occurrence of a command in PATH. - * Returns the full path if found, null otherwise. - * Only returns executable files, not directories. - */ -export async function findFirstInPath( - ctx: TypeCommandContext, - name: string, -): Promise { - // If name contains /, it's a path - check if it exists and is executable - if (name.includes("/")) { - const resolvedPath = ctx.fs.resolvePath(ctx.state.cwd, name); - if (await ctx.fs.exists(resolvedPath)) { - // Check if it's a directory or not executable - try { - const stat = await ctx.fs.stat(resolvedPath); - if (stat.isDirectory) { - return null; - } - // Check if file is executable (owner, group, or other execute bit set) - const isExecutable = (stat.mode & 0o111) !== 0; - if (!isExecutable) { - return null; - } - } catch { - // If stat fails, assume it's not a valid path - return null; - } - // Return the original path format (not resolved) to match bash behavior - return name; - } - return null; - } - - // Search PATH directories - const pathEnv = ctx.state.env.get("PATH") ?? "/usr/bin:/bin"; - const pathDirs = pathEnv.split(":"); - - for (const dir of pathDirs) { - if (!dir) continue; - // Resolve relative PATH entries relative to cwd - const resolvedDir = dir.startsWith("/") - ? dir - : ctx.fs.resolvePath(ctx.state.cwd, dir); - const fullPath = `${resolvedDir}/${name}`; - if (await ctx.fs.exists(fullPath)) { - // Check if it's a directory - try { - const stat = await ctx.fs.stat(fullPath); - if (stat.isDirectory) { - continue; // Skip directories - } - } catch { - // If stat fails, skip this path - continue; - } - // Return the path as specified in PATH (not resolved) to match bash behavior - return `${dir}/${name}`; - } - } - - // Fallback: check if command exists in registry - // This handles virtual filesystems where commands are registered but - // not necessarily present as individual files in /usr/bin - if (ctx.commands.has(name)) { - // Return path in the first PATH directory that contains /usr/bin or /bin, or default to /usr/bin - for (const dir of pathDirs) { - if (dir === "/usr/bin" || dir === "/bin") { - return `${dir}/${name}`; - } - } - return `/usr/bin/${name}`; - } - - return null; -} diff --git a/src/interpreter/types.ts b/src/interpreter/types.ts deleted file mode 100644 index 5158944f..00000000 --- a/src/interpreter/types.ts +++ /dev/null @@ -1,415 +0,0 @@ -/** - * Interpreter Types - */ - -import type { - CommandNode, - FunctionDefNode, - ScriptNode, - StatementNode, -} from "../ast/types.js"; -import type { IFileSystem } from "../fs/interface.js"; -import type { ExecutionLimits } from "../limits.js"; -import type { SecureFetch } from "../network/index.js"; -import type { - CommandRegistry, - ExecResult, - FeatureCoverageWriter, - TraceCallback, -} from "../types.js"; - -/** - * Completion specification for a command, set by the `complete` builtin. - */ -export interface CompletionSpec { - /** Word list for -W option */ - wordlist?: string; - /** Function name for -F option */ - function?: string; - /** Command to run for -C option */ - command?: string; - /** Completion options (nospace, filenames, etc.) */ - options?: string[]; - /** Actions to perform (from -A option) */ - actions?: string[]; - /** Whether this is a default completion (-D) */ - isDefault?: boolean; -} - -export interface ShellOptions { - /** set -e: Exit immediately if a command exits with non-zero status */ - errexit: boolean; - /** set -o pipefail: Return the exit status of the last (rightmost) command in a pipeline that fails */ - pipefail: boolean; - /** set -u: Treat unset variables as an error when substituting */ - nounset: boolean; - /** set -x: Print commands and their arguments as they are executed */ - xtrace: boolean; - /** set -v: Print shell input lines as they are read (verbose) */ - verbose: boolean; - /** set -o posix: POSIX mode for stricter compliance */ - posix: boolean; - /** set -a: Export all variables */ - allexport: boolean; - /** set -C: Prevent overwriting files with redirection */ - noclobber: boolean; - /** set -f: Disable filename expansion (globbing) */ - noglob: boolean; - /** set -n: Read commands but do not execute them (syntax check mode) */ - noexec: boolean; - /** set -o vi: Use vi-style line editing (mutually exclusive with emacs) */ - vi: boolean; - /** set -o emacs: Use emacs-style line editing (mutually exclusive with vi) */ - emacs: boolean; -} - -export interface ShoptOptions { - /** shopt -s extglob: Enable extended globbing patterns @(), *(), +(), ?(), !() */ - extglob: boolean; - /** shopt -s dotglob: Include dotfiles in glob expansion */ - dotglob: boolean; - /** shopt -s nullglob: Return empty for non-matching globs instead of literal pattern */ - nullglob: boolean; - /** shopt -s failglob: Fail if glob pattern has no matches */ - failglob: boolean; - /** shopt -s globstar: Enable ** recursive glob patterns */ - globstar: boolean; - /** shopt -s globskipdots: Skip . and .. in glob patterns (default: true in bash >=5.2) */ - globskipdots: boolean; - /** shopt -s nocaseglob: Case-insensitive glob matching */ - nocaseglob: boolean; - /** shopt -s nocasematch: Case-insensitive pattern matching in [[ ]] and case */ - nocasematch: boolean; - /** shopt -s expand_aliases: Enable alias expansion */ - expand_aliases: boolean; - /** shopt -s lastpipe: Run last command of pipeline in current shell context */ - lastpipe: boolean; - /** shopt -s xpg_echo: Make echo interpret backslash escapes by default (like echo -e) */ - xpg_echo: boolean; -} - -// ============================================================================ -// Variable Attribute State -// ============================================================================ -// Tracks type attributes and special behaviors for shell variables. -// These are set via `declare`, `typeset`, `readonly`, `export`, etc. - -/** - * Tracks variable type attributes (declare -i, -l, -u, -n, -a, -A, etc.) - * and export status. These affect how variables are read, written, and expanded. - */ -export interface VariableAttributeState { - /** Set of variable names that are readonly */ - readonlyVars?: Set; - /** Set of variable names that are associative arrays */ - associativeArrays?: Set; - /** Set of variable names that are namerefs (declare -n) */ - namerefs?: Set; - /** - * Set of nameref variable names that were "bound" to valid targets at creation time. - * A bound nameref will always resolve through to its target, even if the target - * is later unset. An unbound nameref (target didn't exist at creation) acts like - * a regular variable, returning its raw value. - */ - boundNamerefs?: Set; - /** - * Set of nameref variable names that were created with an invalid target. - * Invalid namerefs always read/write their value directly, never resolving. - * For example, after `ref=1; typeset -n ref`, ref has an invalid target "1". - */ - invalidNamerefs?: Set; - /** Set of variable names that have integer attribute (declare -i) */ - integerVars?: Set; - /** Set of variable names that have lowercase attribute (declare -l) */ - lowercaseVars?: Set; - /** Set of variable names that have uppercase attribute (declare -u) */ - uppercaseVars?: Set; - /** Set of exported variable names */ - exportedVars?: Set; - /** Set of temporarily exported variable names (for prefix assignments like FOO=bar cmd) */ - tempExportedVars?: Set; - /** - * Stack of sets tracking variables exported within each local scope. - * When a function returns and a local scope is popped, if a variable was - * exported only in that scope (not before entering), the export attribute - * should be removed. This enables bash's scoped export behavior where - * `local V=x; export V` only exports the local, not the global. - */ - localExportedVars?: Set[]; - /** Set of variable names that have been declared but not assigned a value */ - declaredVars?: Set; -} - -// ============================================================================ -// Local Variable Scoping State -// ============================================================================ -// Implements bash's complex local variable scoping rules including: -// - Dynamic scoping (locals visible in called functions) -// - Nested local declarations (local inside eval inside function) -// - Unset behavior differences (local-unset vs dynamic-unset) -// - Tempenv bindings from prefix assignments (FOO=bar cmd) - -/** - * Tracks the complex local variable scoping machinery. - * Bash's local variable behavior is intricate: variables are dynamically scoped, - * can be declared multiple times in nested contexts, and have different unset - * behaviors depending on whether the unset happens in the declaring scope. - */ -export interface LocalScopingState { - /** Stack of local variable scopes (one Map per function call) */ - localScopes: Map[]; - /** - * Tracks at which call depth each local variable was declared. - * Used for bash-specific unset scoping behavior: - * - local-unset (same scope): value-unset (clears value, keeps local cell) - * - dynamic-unset (different scope): cell-unset (removes local cell, exposes outer value) - */ - localVarDepth?: Map; - /** - * Stack of saved values for each local variable, supporting bash's localvar-nest behavior. - * Each entry contains the saved (outer) value and the scope index where it was saved. - * This allows multiple nested `local` declarations of the same variable (e.g., in nested evals) - * to each have their own cell that can be unset independently. - */ - localVarStack?: Map< - string, - Array<{ value: string | undefined; scopeIndex: number }> - >; - /** - * Map of variable names to scope index where they were fully unset. - * Used to prevent tempenv restoration after all local cells are removed. - * Entries are cleared when their scope returns. - */ - fullyUnsetLocals?: Map; - /** - * Stack of temporary environment bindings from prefix assignments (e.g., FOO=bar cmd). - * Each entry maps variable names to their saved (underlying) values. - * Used for bash-specific unset behavior: when unsetting a variable that has a - * tempenv binding, the unset should reveal the underlying value, not completely - * remove the variable. - */ - tempEnvBindings?: Map[]; - /** - * Set of tempenv variable names that have been explicitly written to within - * the current function context (after the prefix assignment, before local). - * Used to distinguish between "fresh" tempenvs (local-unset = value-unset) - * and "mutated" tempenvs (local-unset reveals the mutated value). - */ - mutatedTempEnvVars?: Set; - /** - * Set of tempenv variable names that have been accessed (read or written) - * within the current function context. Used to determine if a tempenv was - * "observed" before a local declaration. - */ - accessedTempEnvVars?: Set; -} - -// ============================================================================ -// Call Stack State -// ============================================================================ -// Tracks function calls and source file nesting for: -// - FUNCNAME, BASH_LINENO, BASH_SOURCE arrays -// - Proper return behavior from functions vs sourced scripts -// - Function definition context (which file defined a function) - -/** - * Tracks the function call stack and source file nesting. - * This state powers the FUNCNAME, BASH_LINENO, and BASH_SOURCE arrays, - * and determines the behavior of `return` in different contexts. - */ -export interface CallStackState { - /** Function definitions (name -> AST node) */ - functions: Map; - /** Current function call depth (for recursion limits and local scoping) */ - callDepth: number; - /** Current source script nesting depth (for return in sourced scripts) */ - sourceDepth: number; - /** Stack of call line numbers for BASH_LINENO */ - callLineStack?: number[]; - /** Stack of function names for FUNCNAME */ - funcNameStack?: string[]; - /** Stack of source files for BASH_SOURCE (tracks where functions were defined) */ - sourceStack?: string[]; - /** Current source file context (for function definitions) */ - currentSource?: string; -} - -// ============================================================================ -// Control Flow State -// ============================================================================ -// Tracks loop nesting and condition context for: -// - break/continue with optional level argument -// - errexit (set -e) suppression in conditions -// - Subshell loop context for proper break/continue behavior - -/** - * Tracks loop nesting and condition context. - * Used to implement break/continue commands and to suppress errexit - * in condition contexts (if, while, until, ||, &&). - */ -export interface ControlFlowState { - /** True when executing condition for if/while/until (errexit doesn't apply) */ - inCondition: boolean; - /** Current loop nesting depth (for break/continue) */ - loopDepth: number; - /** True if this subshell was spawned from within a loop context (for break/continue to exit subshell) */ - parentHasLoopContext?: boolean; - /** True when the last executed statement's exit code is "safe" for errexit purposes - * (e.g., from a &&/|| chain where the failure wasn't the final command) */ - errexitSafe?: boolean; -} - -// ============================================================================ -// Process State -// ============================================================================ -// Tracks process-related information for special variables: -// - $$ (shell PID), $BASHPID (current subshell PID) -// - $! (last background PID) -// - $SECONDS (time since shell start) - -/** - * Tracks process IDs, timing, and execution counts. - * Powers special variables like $$, $BASHPID, $!, and $SECONDS. - */ -export interface ProcessState { - /** Total commands executed (for execution limits) */ - commandCount: number; - /** Time when shell started (for $SECONDS) */ - startTime: number; - /** PID of last background job (for $!) */ - lastBackgroundPid: number; - /** Current BASHPID (changes in subshells, unlike $$) */ - bashPid: number; - /** Counter for generating unique virtual PIDs for subshells */ - nextVirtualPid: number; -} - -// ============================================================================ -// I/O State -// ============================================================================ -// Tracks file descriptors and stdin for: -// - Process substitution (<() and >()) -// - Here-documents -// - Compound command stdin piping - -/** - * Tracks file descriptors and stdin content for I/O operations. - * Used for process substitution, here-documents, and compound command stdin. - */ -export interface IOState { - /** Stdin available for commands in compound commands (groups, subshells, while loops with piped input) */ - groupStdin?: string; - /** File descriptors for process substitution and here-docs */ - fileDescriptors?: Map; - /** Next available file descriptor for {varname}>file allocation (starts at 10) */ - nextFd?: number; -} - -// ============================================================================ -// Expansion State -// ============================================================================ -// Captures errors during parameter expansion that need to be reported -// after the expansion completes (arithmetic errors, etc.) - -/** - * Captures errors that occur during parameter expansion. - * Some expansion errors need to be reported after expansion completes, - * with their exit codes and stderr preserved. - */ -export interface ExpansionState { - /** Exit code from expansion errors (arithmetic, etc.) - overrides command exit code */ - expansionExitCode?: number; - /** Stderr from expansion errors */ - expansionStderr?: string; -} - -// ============================================================================ -// Interpreter State (Composed) -// ============================================================================ -// The complete interpreter state, composed from the focused interfaces above. -// This provides backward compatibility while the sub-interfaces provide -// better organization for understanding and maintaining specific features. - -/** - * Complete interpreter state for bash script execution. - * - * This interface is composed from focused sub-interfaces: - * - {@link VariableAttributeState} - Variable type attributes (readonly, integer, etc.) - * - {@link LocalScopingState} - Local variable scoping machinery - * - {@link CallStackState} - Function calls and source file tracking - * - {@link ControlFlowState} - Loop nesting and condition context - * - {@link ProcessState} - PIDs, timing, execution counts - * - {@link IOState} - File descriptors and stdin - * - {@link ExpansionState} - Expansion error capture - */ -export interface InterpreterState - extends VariableAttributeState, - LocalScopingState, - CallStackState, - ControlFlowState, - ProcessState, - IOState, - ExpansionState { - // ---- Core Environment ---- - /** Environment variables (exported to commands) - uses Map to prevent prototype pollution */ - env: Map; - /** Current working directory */ - cwd: string; - /** Previous directory (for `cd -`) */ - previousDir: string; - - // ---- Execution Tracking ---- - /** Exit code of last executed command */ - lastExitCode: number; - /** Last argument of previous command, for $_ expansion */ - lastArg: string; - /** Current line number being executed (for $LINENO) */ - currentLine: number; - - // ---- Shell Options ---- - /** Shell options (set -e, etc.) */ - options: ShellOptions; - /** Shopt options (shopt -s, etc.) */ - shoptOptions: ShoptOptions; - - // ---- Shell Features ---- - /** Completion specifications set by the `complete` builtin */ - completionSpecs?: Map; - /** Directory stack for pushd/popd/dirs */ - directoryStack?: string[]; - /** Hash table for PATH command lookup caching */ - hashTable?: Map; - - // ---- Output Control ---- - /** - * Suppress verbose mode output (set -v) when inside command substitutions. - * bash only prints verbose output for the main script, not for commands - * inside $(...) or backticks. - */ - suppressVerbose?: boolean; -} - -export interface InterpreterContext { - state: InterpreterState; - fs: IFileSystem; - commands: CommandRegistry; - /** Execution limits configuration */ - limits: Required; - execFn: ( - script: string, - options?: { env?: Record; cwd?: string }, - ) => Promise; - executeScript: (node: ScriptNode) => Promise; - executeStatement: (node: StatementNode) => Promise; - executeCommand: (node: CommandNode, stdin: string) => Promise; - /** Optional secure fetch function for network-enabled commands */ - fetch?: SecureFetch; - /** Optional sleep function for testing with mock clocks */ - sleep?: (ms: number) => Promise; - /** Optional trace callback for performance profiling */ - trace?: TraceCallback; - /** Current command substitution nesting depth (for limit enforcement) */ - substitutionDepth?: number; - /** Optional feature coverage writer for fuzzing instrumentation */ - coverage?: FeatureCoverageWriter; -} diff --git a/src/limits.ts b/src/limits.ts deleted file mode 100644 index e217a4bb..00000000 --- a/src/limits.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Execution Limits Configuration - * - * Centralized configuration for all execution limits to prevent runaway compute. - * These limits can be overridden when creating a BashEnv instance. - */ - -/** - * Configuration for execution limits. - * All limits are optional - undefined values use defaults. - */ -export interface ExecutionLimits { - /** Maximum function call/recursion depth (default: 100) */ - maxCallDepth?: number; - - /** Maximum number of commands to execute (default: 10000) */ - maxCommandCount?: number; - - /** Maximum loop iterations for bash while/for/until loops (default: 10000) */ - maxLoopIterations?: number; - - /** Maximum loop iterations for AWK while/for loops (default: 10000) */ - maxAwkIterations?: number; - - /** Maximum command iterations for SED (branch loops) (default: 10000) */ - maxSedIterations?: number; - - /** Maximum iterations for jq loops (until, while, repeat) (default: 10000) */ - maxJqIterations?: number; - - /** Maximum sqlite3 query execution time in milliseconds (default: 5000) */ - maxSqliteTimeoutMs?: number; - - /** Maximum Python execution time in milliseconds (default: 30000) */ - maxPythonTimeoutMs?: number; - - /** Maximum glob filesystem operations (default: 100000) */ - maxGlobOperations?: number; - - /** Maximum string length in bytes (default: 10MB = 10485760) */ - maxStringLength?: number; - - /** Maximum array elements (default: 100000) */ - maxArrayElements?: number; - - /** Maximum heredoc size in bytes (default: 10MB = 10485760) */ - maxHeredocSize?: number; - - /** Maximum command substitution nesting depth (default: 50) */ - maxSubstitutionDepth?: number; -} - -/** - * Default execution limits. - * These are conservative limits designed to prevent runaway execution - * while allowing reasonable scripts to complete. - */ -const DEFAULT_LIMITS: Required = { - maxCallDepth: 100, - maxCommandCount: 10000, - maxLoopIterations: 10000, - maxAwkIterations: 10000, - maxSedIterations: 10000, - maxJqIterations: 10000, - maxSqliteTimeoutMs: 5000, - maxPythonTimeoutMs: 30000, - maxGlobOperations: 100000, - maxStringLength: 10485760, // 10MB - maxArrayElements: 100000, - maxHeredocSize: 10485760, // 10MB - maxSubstitutionDepth: 50, -}; - -/** - * Resolve execution limits by merging user-provided limits with defaults. - */ -export function resolveLimits( - userLimits?: ExecutionLimits, -): Required { - if (!userLimits) { - return { ...DEFAULT_LIMITS }; - } - return { - maxCallDepth: userLimits.maxCallDepth ?? DEFAULT_LIMITS.maxCallDepth, - maxCommandCount: - userLimits.maxCommandCount ?? DEFAULT_LIMITS.maxCommandCount, - maxLoopIterations: - userLimits.maxLoopIterations ?? DEFAULT_LIMITS.maxLoopIterations, - maxAwkIterations: - userLimits.maxAwkIterations ?? DEFAULT_LIMITS.maxAwkIterations, - maxSedIterations: - userLimits.maxSedIterations ?? DEFAULT_LIMITS.maxSedIterations, - maxJqIterations: - userLimits.maxJqIterations ?? DEFAULT_LIMITS.maxJqIterations, - maxSqliteTimeoutMs: - userLimits.maxSqliteTimeoutMs ?? DEFAULT_LIMITS.maxSqliteTimeoutMs, - maxPythonTimeoutMs: - userLimits.maxPythonTimeoutMs ?? DEFAULT_LIMITS.maxPythonTimeoutMs, - maxGlobOperations: - userLimits.maxGlobOperations ?? DEFAULT_LIMITS.maxGlobOperations, - maxStringLength: - userLimits.maxStringLength ?? DEFAULT_LIMITS.maxStringLength, - maxArrayElements: - userLimits.maxArrayElements ?? DEFAULT_LIMITS.maxArrayElements, - maxHeredocSize: userLimits.maxHeredocSize ?? DEFAULT_LIMITS.maxHeredocSize, - maxSubstitutionDepth: - userLimits.maxSubstitutionDepth ?? DEFAULT_LIMITS.maxSubstitutionDepth, - }; -} diff --git a/src/network/allow-list.ts b/src/network/allow-list.ts deleted file mode 100644 index acb1811a..00000000 --- a/src/network/allow-list.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * URL allow-list matching - * - * This module provides URL allow-list matching that is enforced at the fetch layer, - * independent of any parsing or user input manipulation. - */ - -/** - * Parses a URL string into its components. - * Returns null if the URL is invalid. - */ -export function parseUrl( - urlString: string, -): { origin: string; pathname: string; href: string } | null { - try { - const url = new URL(urlString); - return { - origin: url.origin, - pathname: url.pathname, - href: url.href, - }; - } catch { - return null; - } -} - -/** - * Normalizes an allow-list entry for consistent matching. - * - Removes trailing slashes from origins without paths - * - Preserves path prefixes as-is - */ -export function normalizeAllowListEntry(entry: string): { - origin: string; - pathPrefix: string; -} | null { - const parsed = parseUrl(entry); - if (!parsed) { - return null; - } - - return { - origin: parsed.origin, - // Keep the pathname exactly as specified (including trailing slash if present) - pathPrefix: parsed.pathname, - }; -} - -/** - * Checks if a URL matches an allow-list entry. - * - * The matching rules are: - * 1. Origins must match exactly (case-sensitive for scheme and host) - * 2. The URL's path must start with the allow-list entry's path - * 3. If the allow-list entry has no path (or just "/"), all paths are allowed - * - * @param url The URL to check (as a string) - * @param allowedEntry The allow-list entry to match against - * @returns true if the URL matches the allow-list entry - */ -export function matchesAllowListEntry( - url: string, - allowedEntry: string, -): boolean { - const parsedUrl = parseUrl(url); - if (!parsedUrl) { - return false; - } - - const normalizedEntry = normalizeAllowListEntry(allowedEntry); - if (!normalizedEntry) { - return false; - } - - // Origins must match exactly - if (parsedUrl.origin !== normalizedEntry.origin) { - return false; - } - - // If the allow-list entry is just the origin (path is "/" or empty), allow all paths - if (normalizedEntry.pathPrefix === "/" || normalizedEntry.pathPrefix === "") { - return true; - } - - // The URL's path must start with the allow-list entry's path prefix - return parsedUrl.pathname.startsWith(normalizedEntry.pathPrefix); -} - -/** - * Checks if a URL is allowed by any entry in the allow-list. - * - * @param url The URL to check - * @param allowedUrlPrefixes The list of allowed URL prefixes - * @returns true if the URL is allowed - */ -export function isUrlAllowed( - url: string, - allowedUrlPrefixes: string[], -): boolean { - if (!allowedUrlPrefixes || allowedUrlPrefixes.length === 0) { - return false; - } - - return allowedUrlPrefixes.some((entry) => matchesAllowListEntry(url, entry)); -} - -/** - * Validates an allow-list configuration. - * Each entry must be a full origin (scheme + host), optionally followed by a path prefix. - * Returns an array of error messages for invalid entries. - */ -export function validateAllowList(allowedUrlPrefixes: string[]): string[] { - const errors: string[] = []; - - for (const entry of allowedUrlPrefixes) { - const parsed = parseUrl(entry); - if (!parsed) { - errors.push( - `Invalid URL in allow-list: "${entry}" - must be a valid URL with scheme and host (e.g., "https://example.com")`, - ); - continue; - } - - const url = new URL(entry); - - // Only allow http and https - if (url.protocol !== "http:" && url.protocol !== "https:") { - errors.push( - `Only http and https URLs are allowed in allow-list: "${entry}"`, - ); - continue; - } - - // Must have a valid host (not empty) - if (!url.hostname) { - errors.push(`Allow-list entry must include a hostname: "${entry}"`); - continue; - } - - // Warn about query strings and fragments (they'll be ignored) - if (url.search || url.hash) { - errors.push( - `Query strings and fragments are ignored in allow-list entries: "${entry}"`, - ); - } - } - - return errors; -} diff --git a/src/network/allow-list/bypass.test.ts b/src/network/allow-list/bypass.test.ts deleted file mode 100644 index 8b6fcdce..00000000 --- a/src/network/allow-list/bypass.test.ts +++ /dev/null @@ -1,549 +0,0 @@ -/** - * Adversarial tests attempting to bypass allow-list security - * - * These tests verify that various URL manipulation techniques - * cannot be used to access blocked URLs. - */ - -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { - createBashEnvAdapter, - createMockFetch, - expectAllowed, - expectBlocked, - MOCK_SUCCESS_BODY, - originalFetch, -} from "./shared.js"; - -describe("allow-list bypass attempts", () => { - let mockFetch: ReturnType; - - beforeAll(() => { - mockFetch = createMockFetch(); - global.fetch = mockFetch as typeof fetch; - }); - - afterAll(() => { - global.fetch = originalFetch; - vi.restoreAllMocks(); - }); - - describe("hostname confusion attacks", () => { - it("blocks evil.com disguised with allowed domain as subdomain", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - await expectBlocked(env, "https://api.example.com.evil.com/data"); - }); - - it("blocks using @ to put allowed domain in username", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // https://api.example.com@evil.com actually connects to evil.com - await expectBlocked(env, "https://api.example.com@evil.com/data"); - }); - - it("blocks using credentials with allowed domain", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - await expectBlocked(env, "https://user:pass@evil.com/data"); - }); - - it("blocks hostname with trailing dot", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // Trailing dot is technically valid DNS but should be treated carefully - // URL is not normalized, so api.example.com. != api.example.com - await expectBlocked( - env, - "https://api.example.com./data", - "https://api.example.com./data", - ); - }); - - it("blocks similar-looking domains (typosquatting)", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - await expectBlocked(env, "https://api.examp1e.com/data"); - await expectBlocked(env, "https://api.example.co/data"); - await expectBlocked(env, "https://api-example.com/data"); - await expectBlocked(env, "https://apiexample.com/data"); - }); - }); - - describe("URL encoding attacks", () => { - it("blocks URL-encoded hostname", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // %65 = 'e', trying to encode part of hostname - // URL class doesn't decode percent-encoded hostnames, so blocked as-is - await expectBlocked( - env, - "https://%65vil.com/data", - "https://%65vil.com/data", - ); - }); - - it("blocks double URL encoding", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // %25 = '%', so %2565 = %65 after first decode - // URL class doesn't decode percent-encoded hostnames, so blocked as-is - await expectBlocked( - env, - "https://evil%252ecom/data", - "https://evil%252ecom/data", - ); - }); - - it("blocks URL-encoded slashes in path", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com/v1/"] }, - }); - // %2f = '/', trying to bypass path prefix check - await expectBlocked(env, "https://api.example.com/v1%2f..%2fv2/users"); - }); - - it("handles URL-encoded allowed path correctly", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // Normal URL encoding in path should still work for allowed URLs - await expectAllowed( - env, - "https://api.example.com/data", - MOCK_SUCCESS_BODY, - ); - }); - }); - - describe("path traversal attacks", () => { - it("blocks path traversal to escape prefix", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com/v1/"] }, - }); - await expectBlocked(env, "https://api.example.com/v1/../v2/users"); - }); - - it("blocks encoded path traversal", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com/v1/"] }, - }); - await expectBlocked(env, "https://api.example.com/v1/%2e%2e/v2/users"); - }); - - it("handles double-encoded path traversal - encoded dots stay encoded", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com/v1/"] }, - }); - // %252e = %2e after single decode, which stays in path as literal %2e - // The path /v1/%252e%252e/v2/users starts with /v1/ so it's allowed - // This is correct - the encoded chars are not interpreted as traversal - const result = await env.exec( - 'curl "https://api.example.com/v1/%252e%252e/v2/users"', - ); - // Should pass allow-list (path starts with /v1/), returns 404 from mock - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("Not Found"); - expect(result.stderr).toBe(""); - }); - - it("blocks backslash path traversal", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com/v1/"] }, - }); - await expectBlocked(env, "https://api.example.com/v1/..\\v2/users"); - }); - }); - - describe("protocol attacks", () => { - it("blocks file:// protocol", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - const result = await env.exec('curl "file:///etc/passwd"'); - expect(result.exitCode).not.toBe(0); - expect(result.stdout).toBe(""); - }); - - it("blocks data: URLs", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - const result = await env.exec('curl "data:text/plain,evil"'); - expect(result.exitCode).not.toBe(0); - expect(result.stdout).toBe(""); - }); - - it("blocks ftp:// protocol - curl treats unrecognized scheme as hostname", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // curl adds https:// to URLs without recognized scheme, so ftp:// becomes - // hostname "ftp:" and the URL becomes https://ftp://api.example.com/data - // This is still blocked because the hostname doesn't match - const result = await env.exec('curl "ftp://api.example.com/data"'); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toContain("Network access denied"); - }); - - it("blocks javascript: URLs", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - const result = await env.exec('curl "javascript:alert(1)"'); - expect(result.exitCode).not.toBe(0); - expect(result.stdout).toBe(""); - }); - - it("blocks protocol-relative URLs (//)", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // Protocol-relative URLs - curl treats // as path, adds https:// - // becomes https:////evil.com/data which is blocked - const result = await env.exec('curl "//evil.com/data"'); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https:////evil.com/data\n", - ); - }); - }); - - describe("port manipulation attacks", () => { - it("blocks non-standard HTTPS port", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - await expectBlocked(env, "https://api.example.com:8443/data"); - }); - - it("allows explicit port 443 when default port is allowed", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // Port 443 is the default HTTPS port, so https://host:443 should match https://host - // The URL class normalizes this, so it should be allowed - // Mock returns 404 since it's keyed on URL without explicit port - const result = await env.exec('curl "https://api.example.com:443/data"'); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("Not Found"); - expect(result.stderr).toBe(""); - }); - - it("blocks HTTP on port 443", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - await expectBlocked(env, "http://api.example.com:443/data"); - }); - - it("blocks HTTPS on port 80", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - await expectBlocked(env, "https://api.example.com:80/data"); - }); - }); - - describe("case sensitivity attacks", () => { - it("blocks uppercase scheme - curl adds https:// prefix", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // curl adds https:// to URLs without recognized scheme - // HTTPS:// is not recognized as a scheme by curl's URL parser - // so it becomes https://HTTPS://api.example.com/data - const result = await env.exec('curl "HTTPS://api.example.com/data"'); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toContain("Network access denied"); - }); - - it("handles uppercase hostname - passes allow-list with normalized hostname", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // URL class normalizes hostname to lowercase for allow-list check - // But fetch is called with original URL, so mock doesn't match - // This passes the allow-list but returns 404 from mock - const result = await env.exec('curl "https://API.EXAMPLE.COM/data"'); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("Not Found"); - expect(result.stderr).toBe(""); - }); - - it("blocks mixed case evil domain - preserves case in error", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // Error message preserves the original URL casing - await expectBlocked( - env, - "https://EVIL.COM/data", - "https://EVIL.COM/data", - ); - }); - }); - - describe("IPv4/IPv6 attacks", () => { - it("blocks IPv4 localhost", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - await expectBlocked(env, "https://127.0.0.1/data"); - }); - - it("blocks IPv4 private ranges", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - await expectBlocked(env, "https://192.168.1.1/data"); - await expectBlocked(env, "https://10.0.0.1/data"); - await expectBlocked(env, "https://172.16.0.1/data"); - }); - - it("blocks IPv6 localhost", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - await expectBlocked(env, "https://[::1]/data"); - }); - - it("blocks IPv4-mapped IPv6", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // IPv4-mapped IPv6 address for 127.0.0.1 - await expectBlocked(env, "https://[::ffff:127.0.0.1]/data"); - }); - - it("blocks decimal IP notation", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // 2130706433 = 127.0.0.1 in decimal - await expectBlocked(env, "https://2130706433/data"); - }); - - it("blocks octal IP notation", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // 0177.0.0.1 = 127.0.0.1 in octal - await expectBlocked(env, "https://0177.0.0.1/data"); - }); - - it("blocks hex IP notation", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // 0x7f000001 = 127.0.0.1 in hex - await expectBlocked(env, "https://0x7f000001/data"); - }); - }); - - describe("special character injection", () => { - it("blocks null byte injection", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // Null byte might truncate URL processing - const result = await env.exec( - 'curl "https://api.example.com%00.evil.com/data"', - ); - expect(result.exitCode).not.toBe(0); - expect(result.stdout).toBe(""); - }); - - it("blocks CRLF injection", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - const result = await env.exec( - 'curl "https://evil.com%0d%0aHost:%20api.example.com/data"', - ); - expect(result.exitCode).not.toBe(0); - expect(result.stdout).toBe(""); - }); - - it("blocks fragment to hide path", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com/v1/"] }, - }); - // Fragment should not bypass path prefix check - await expectBlocked(env, "https://api.example.com/v2/users#/v1/"); - }); - - it("blocks query string manipulation", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com/v1/"] }, - }); - // Query string with path should not bypass - await expectBlocked(env, "https://api.example.com/v2/?path=/v1/users"); - }); - }); - - describe("Unicode/IDN attacks", () => { - it("blocks homoglyph attacks (Cyrillic 'а' vs Latin 'a')", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // Using Cyrillic 'а' (U+0430) instead of Latin 'a' (U+0061) - await expectBlocked(env, "https://аpi.example.com/data"); - }); - - it("blocks punycode bypass attempts", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // xn-- is punycode prefix - await expectBlocked(env, "https://xn--pi-7ba.example.com/data"); - }); - - it("blocks URL with BOM", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // UTF-8 BOM before URL - const result = await env.exec('curl "\ufeffhttps://evil.com/data"'); - expect(result.exitCode).not.toBe(0); - expect(result.stdout).toBe(""); - }); - - it("blocks zero-width characters in hostname", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // Zero-width space (U+200B) in hostname - await expectBlocked(env, "https://evil\u200B.com/data"); - }); - }); - - describe("whitespace and delimiter attacks", () => { - it("blocks tab in URL", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // Tab in URL makes it invalid - const result = await env.exec('curl "https://evil.com\t/data"'); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https://evil.com\t/data\n", - ); - }); - - it("blocks newline in URL", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // Newline in URL - blocked because evil.com is not allowed - const result = await env.exec('curl "https://evil.com\n/data"'); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https://evil.com\n/data\n", - ); - }); - - it("blocks space-separated URL injection", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // Space in URL creates invalid path, returns 404 from mock - // The key assertion is no evil data leaks - const result = await env.exec( - 'curl "https://api.example.com/data https://evil.com/steal"', - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("Not Found"); - expect(result.stderr).toBe(""); - }); - }); - - describe("redirect chain attacks", () => { - it("blocks open redirect via allowed domain", async () => { - // Even if allowed domain has an open redirect, we should block - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - const result = await env.exec( - 'curl "https://api.example.com/redirect-to-evil"', - ); - expect(result.exitCode).toBe(47); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (47) Redirect target not in allow-list: https://evil.com/data\n", - ); - }); - - it("verifies no data leaks on blocked redirect", async () => { - mockFetch.mockClear(); - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - - await env.exec('curl "https://api.example.com/redirect-to-evil"'); - - // Verify fetch was called for allowed URL but NOT for evil URL - const calledUrls = mockFetch.mock.calls.map((c) => c[0]); - expect(calledUrls).toContain("https://api.example.com/redirect-to-evil"); - expect(calledUrls).not.toContain("https://evil.com/data"); - }); - }); - - describe("edge case URLs", () => { - it("blocks empty hostname", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // Empty hostname is blocked - const result = await env.exec('curl "https:///data"'); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https:///data\n", - ); - }); - - it("blocks URL with only protocol", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - // URL with only protocol is blocked - const result = await env.exec('curl "https://"'); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https://\n", - ); - }); - - it("blocks extremely long hostname", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - const longHost = `${"a".repeat(1000)}.evil.com`; - await expectBlocked(env, `https://${longHost}/data`); - }); - - it("blocks URL with many subdomains", async () => { - const env = createBashEnvAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - await expectBlocked( - env, - "https://a.b.c.d.e.f.g.api.example.com.evil.com/data", - ); - }); - }); -}); diff --git a/src/network/allow-list/e2e.test.ts b/src/network/allow-list/e2e.test.ts deleted file mode 100644 index 2124cbfc..00000000 --- a/src/network/allow-list/e2e.test.ts +++ /dev/null @@ -1,464 +0,0 @@ -/** - * E2E tests for allow-list enforcement via bash execution - * - * These tests verify that the allow-list is correctly enforced when using - * curl commands through BashEnv and Sandbox.create. - */ - -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { - type AdapterFactory, - createBashEnvAdapter, - createMockFetch, - createSandboxAdapter, - MOCK_EVIL_BODY, - MOCK_FILE_BODY, - MOCK_POSTS_BODY, - MOCK_SUCCESS_BODY, - MOCK_USERS_BODY, - originalFetch, -} from "./shared.js"; - -/** - * Runs the allow-list test suite with a given adapter factory - */ -function runAllowListTests(name: string, createAdapter: AdapterFactory) { - describe(`allow-list e2e via ${name}`, () => { - let mockFetch: ReturnType; - - beforeAll(() => { - mockFetch = createMockFetch(); - global.fetch = mockFetch as typeof fetch; - }); - - afterAll(() => { - global.fetch = originalFetch; - vi.restoreAllMocks(); - }); - - describe("basic allow-list enforcement", () => { - it("allows requests to URLs in allow-list", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - const result = await env.exec("curl https://api.example.com/data"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(MOCK_SUCCESS_BODY); - expect(result.stderr).toBe(""); - }); - - it("blocks requests to URLs not in allow-list", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - const result = await env.exec("curl https://evil.com/data"); - expect(result.exitCode).toBe(7); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https://evil.com/data\n", - ); - expect(result.stdout).toBe(""); - }); - - it("returns proper exit code for blocked requests", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - const result = await env.exec("curl https://attacker.com/steal"); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https://attacker.com/steal\n", - ); - }); - }); - - describe("path prefix restrictions", () => { - it("allows URLs with matching path prefix", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com/v1/"] }, - }); - const result = await env.exec("curl https://api.example.com/v1/users"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(MOCK_USERS_BODY); - expect(result.stderr).toBe(""); - }); - - it("allows multiple paths under same prefix", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com/v1/"] }, - }); - const r1 = await env.exec("curl https://api.example.com/v1/users"); - const r2 = await env.exec("curl https://api.example.com/v1/posts"); - expect(r1.exitCode).toBe(0); - expect(r2.exitCode).toBe(0); - expect(r1.stdout).toBe(MOCK_USERS_BODY); - expect(r2.stdout).toBe(MOCK_POSTS_BODY); - expect(r1.stderr).toBe(""); - expect(r2.stderr).toBe(""); - }); - - it("blocks URLs with different path prefix", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com/v1/"] }, - }); - const result = await env.exec("curl https://api.example.com/v2/users"); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https://api.example.com/v2/users\n", - ); - }); - - it("blocks when path does not include trailing slash", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com/v1/"] }, - }); - const result = await env.exec("curl https://api.example.com/v1"); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https://api.example.com/v1\n", - ); - }); - }); - - describe("multiple allow-list entries", () => { - it("allows URLs matching any entry", async () => { - const env = await createAdapter({ - network: { - allowedUrlPrefixes: [ - "https://api.example.com", - "https://cdn.example.com", - ], - }, - }); - - const r1 = await env.exec("curl https://api.example.com/data"); - const r2 = await env.exec("curl https://cdn.example.com/file.txt"); - - expect(r1.exitCode).toBe(0); - expect(r1.stdout).toBe(MOCK_SUCCESS_BODY); - expect(r1.stderr).toBe(""); - expect(r2.exitCode).toBe(0); - expect(r2.stdout).toBe(MOCK_FILE_BODY); - expect(r2.stderr).toBe(""); - }); - - it("blocks URLs not matching any entry", async () => { - const env = await createAdapter({ - network: { - allowedUrlPrefixes: [ - "https://api.example.com", - "https://cdn.example.com", - ], - }, - }); - - const result = await env.exec("curl https://evil.com/data"); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https://evil.com/data\n", - ); - }); - }); - - describe("security scenarios via curl", () => { - it("blocks host suffix attacks", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://example.com"] }, - }); - const result = await env.exec("curl https://evilexample.com/path"); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https://evilexample.com/path\n", - ); - }); - - it("blocks subdomain attacks", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://example.com"] }, - }); - const result = await env.exec("curl https://evil.example.com/path"); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https://evil.example.com/path\n", - ); - }); - - it("blocks scheme downgrade attacks", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - const result = await env.exec("curl http://api.example.com/data"); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: http://api.example.com/data\n", - ); - }); - - it("blocks port confusion attacks", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - const result = await env.exec("curl https://api.example.com:8080/data"); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https://api.example.com:8080/data\n", - ); - }); - - it("blocks IP address access when domain allowed", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - const result = await env.exec("curl https://127.0.0.1/data"); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https://127.0.0.1/data\n", - ); - }); - - it("blocks localhost access when domain allowed", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - const result = await env.exec("curl https://localhost/data"); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https://localhost/data\n", - ); - }); - }); - - describe("redirect handling", () => { - it("blocks redirects to disallowed URLs", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - const result = await env.exec( - "curl https://api.example.com/redirect-to-evil", - ); - expect(result.exitCode).toBe(47); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (47) Redirect target not in allow-list: https://evil.com/data\n", - ); - }); - - it("allows redirects to allowed URLs", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - const result = await env.exec( - "curl https://api.example.com/redirect-to-allowed", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(MOCK_SUCCESS_BODY); - expect(result.stderr).toBe(""); - }); - - it("handles redirect chains within allow-list", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - const result = await env.exec( - "curl https://api.example.com/redirect-chain", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(MOCK_SUCCESS_BODY); - expect(result.stderr).toBe(""); - }); - }); - - describe("curl options with allow-list", () => { - it("respects allow-list with -X POST", async () => { - const env = await createAdapter({ - network: { - allowedUrlPrefixes: ["https://api.example.com"], - allowedMethods: ["GET", "POST"], - }, - }); - - const r1 = await env.exec("curl -X POST https://api.example.com/data"); - expect(r1.exitCode).toBe(0); - expect(r1.stdout).toBe(MOCK_SUCCESS_BODY); - expect(r1.stderr).toBe(""); - - const r2 = await env.exec("curl -X POST https://evil.com/data"); - expect(r2.exitCode).toBe(7); - expect(r2.stdout).toBe(""); - expect(r2.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https://evil.com/data\n", - ); - }); - - it("respects allow-list with -H headers", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - - const result = await env.exec( - 'curl -H "Authorization: Bearer token" https://evil.com/data', - ); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https://evil.com/data\n", - ); - }); - - it("silent mode hides blocked request error", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - - const result = await env.exec("curl -s https://evil.com/data"); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - }); - - it("-sS shows error for blocked requests", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - - const result = await env.exec("curl -sS https://evil.com/data"); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https://evil.com/data\n", - ); - }); - }); - - describe("dangerouslyAllowFullInternetAccess", () => { - it("allows any URL when enabled", async () => { - const env = await createAdapter({ - network: { dangerouslyAllowFullInternetAccess: true }, - }); - - const r1 = await env.exec("curl https://api.example.com/data"); - const r2 = await env.exec("curl https://evil.com/data"); - - expect(r1.exitCode).toBe(0); - expect(r1.stdout).toBe(MOCK_SUCCESS_BODY); - expect(r1.stderr).toBe(""); - expect(r2.exitCode).toBe(0); - expect(r2.stdout).toBe(MOCK_EVIL_BODY); - expect(r2.stderr).toBe(""); - }); - - it("allows redirects to any URL when enabled", async () => { - const env = await createAdapter({ - network: { dangerouslyAllowFullInternetAccess: true }, - }); - - const result = await env.exec( - "curl https://api.example.com/redirect-to-evil", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(MOCK_EVIL_BODY); - expect(result.stderr).toBe(""); - }); - }); - - describe("edge cases", () => { - it("handles URL without protocol", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - - const result = await env.exec("curl api.example.com/data"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(MOCK_SUCCESS_BODY); - expect(result.stderr).toBe(""); - }); - - it("blocks URL without protocol when not allowed", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - - const result = await env.exec("curl evil.com/data"); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https://evil.com/data\n", - ); - }); - - it("handles empty allow-list", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: [] }, - }); - - const result = await env.exec("curl https://api.example.com/data"); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "curl: (7) Network access denied: URL not in allow-list: https://api.example.com/data\n", - ); - }); - }); - - describe("piping curl output", () => { - it("pipes allowed response to other commands", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - - const result = await env.exec( - "curl https://api.example.com/data | grep success", - ); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(`${MOCK_SUCCESS_BODY}\n`); - expect(result.stderr).toBe(""); - }); - - it("blocked requests produce no output to pipe", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - - const result = await env.exec( - "curl -s https://evil.com/data | wc -c | tr -d ' '", - ); - expect(result.stdout.trim()).toBe("0"); - expect(result.stderr).toBe(""); - }); - }); - - describe("curl with file output", () => { - it("writes to file only for allowed URLs", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - - await env.exec("curl -o /output.json https://api.example.com/data"); - const content = await env.readFile("/output.json"); - expect(content).toBe(MOCK_SUCCESS_BODY); - }); - - it("does not create file for blocked URLs", async () => { - const env = await createAdapter({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - - await env.exec("curl -o /output.json https://evil.com/data"); - await expect(env.readFile("/output.json")).rejects.toThrow(); - }); - }); - }); -} - -// Run tests with both BashEnv and Sandbox adapters -runAllowListTests("BashEnv", createBashEnvAdapter); -runAllowListTests("Sandbox", createSandboxAdapter); diff --git a/src/network/allow-list/mock.test.ts b/src/network/allow-list/mock.test.ts deleted file mode 100644 index 8b4ccae1..00000000 --- a/src/network/allow-list/mock.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Tests that verify the mock is working correctly and fetch is only - * called for allowed URLs - */ - -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { Bash } from "../../Bash.js"; -import { createMockFetch, originalFetch } from "./shared.js"; - -describe("allow-list mock verification", () => { - let mockFetch: ReturnType; - - beforeAll(() => { - mockFetch = createMockFetch(); - global.fetch = mockFetch as typeof fetch; - }); - - afterAll(() => { - global.fetch = originalFetch; - vi.restoreAllMocks(); - }); - - it("never calls fetch for blocked URLs", async () => { - mockFetch.mockClear(); - - const env = new Bash({ - network: { allowedUrlPrefixes: ["https://allowed.com"] }, - }); - - await env.exec("curl https://blocked.com/data"); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("calls fetch only for allowed URLs", async () => { - mockFetch.mockClear(); - - const env = new Bash({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - - await env.exec("curl https://api.example.com/data"); - - expect(mockFetch).toHaveBeenCalledTimes(1); - expect(mockFetch.mock.calls[0][0]).toBe("https://api.example.com/data"); - }); - - it("does not call fetch for multiple blocked URLs", async () => { - mockFetch.mockClear(); - - const env = new Bash({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - - await env.exec("curl https://evil1.com/data"); - await env.exec("curl https://evil2.com/data"); - await env.exec("curl https://evil3.com/data"); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("calls fetch once per allowed request", async () => { - mockFetch.mockClear(); - - const env = new Bash({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - - await env.exec("curl https://api.example.com/data"); - await env.exec("curl https://api.example.com/v1/users"); - - expect(mockFetch).toHaveBeenCalledTimes(2); - expect(mockFetch.mock.calls[0][0]).toBe("https://api.example.com/data"); - expect(mockFetch.mock.calls[1][0]).toBe("https://api.example.com/v1/users"); - }); - - it("does not call fetch for redirect target when blocked", async () => { - mockFetch.mockClear(); - - const env = new Bash({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - - await env.exec("curl https://api.example.com/redirect-to-evil"); - - // Should call fetch for the initial URL only - const calledUrls = mockFetch.mock.calls.map((c) => c[0]); - expect(calledUrls).toContain("https://api.example.com/redirect-to-evil"); - expect(calledUrls).not.toContain("https://evil.com/data"); - }); - - it("calls fetch for both URLs in allowed redirect chain", async () => { - mockFetch.mockClear(); - - const env = new Bash({ - network: { allowedUrlPrefixes: ["https://api.example.com"] }, - }); - - await env.exec("curl https://api.example.com/redirect-to-allowed"); - - // Should call fetch for both URLs in the chain - const calledUrls = mockFetch.mock.calls.map((c) => c[0]); - expect(calledUrls).toContain("https://api.example.com/redirect-to-allowed"); - expect(calledUrls).toContain("https://api.example.com/data"); - }); -}); diff --git a/src/network/allow-list/shared.ts b/src/network/allow-list/shared.ts deleted file mode 100644 index e08b2094..00000000 --- a/src/network/allow-list/shared.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Shared utilities for allow-list e2e tests - * - * This module provides: - * - Mock fetch implementation with predefined responses - * - Environment adapters for BashEnv and Sandbox - * - Test utilities like expectBlocked - */ - -import { expect, vi } from "vitest"; -import type { BashOptions } from "../../Bash.js"; -import { Bash } from "../../Bash.js"; -import { Sandbox } from "../../sandbox/index.js"; - -// Unique markers in mock responses to verify we're not hitting real network -const MOCK_MARKER: string = "MOCK_RESPONSE_12345"; -export const MOCK_SUCCESS_BODY: string = `{"message":"success","_mock":"${MOCK_MARKER}"}`; -export const MOCK_USERS_BODY: string = `{"users":[],"_mock":"${MOCK_MARKER}"}`; -export const MOCK_POSTS_BODY: string = `{"posts":[],"_mock":"${MOCK_MARKER}"}`; -export const MOCK_FILE_BODY: string = `file contents - ${MOCK_MARKER}`; -export const MOCK_EVIL_BODY: string = `EVIL DATA - should never see this - ${MOCK_MARKER}`; - -// Mock responses for different URLs -const mockResponses: Record< - string, - { status: number; body: string; headers?: Record } -> = { - "https://api.example.com/data": { - status: 200, - body: MOCK_SUCCESS_BODY, - headers: { "content-type": "application/json" }, - }, - "https://api.example.com/v1/users": { - status: 200, - body: MOCK_USERS_BODY, - headers: { "content-type": "application/json" }, - }, - "https://api.example.com/v1/posts": { - status: 200, - body: MOCK_POSTS_BODY, - headers: { "content-type": "application/json" }, - }, - "https://api.example.com/v2/users": { - status: 200, - body: `{"v2":"users","_mock":"${MOCK_MARKER}"}`, - headers: { "content-type": "application/json" }, - }, - "https://cdn.example.com/file.txt": { - status: 200, - body: MOCK_FILE_BODY, - headers: { "content-type": "text/plain" }, - }, - "https://evil.com/data": { - status: 200, - body: MOCK_EVIL_BODY, - headers: {}, - }, - "https://attacker.com/steal": { - status: 200, - body: `attacker data - ${MOCK_MARKER}`, - headers: {}, - }, -}; - -// Store original fetch -export const originalFetch: typeof fetch = global.fetch; - -// Mock fetch implementation -export function createMockFetch(): ReturnType> { - return vi.fn( - async (url: string | URL | Request, _init?: RequestInit) => { - const urlString = typeof url === "string" ? url : url.toString(); - - // Check for redirect behavior - if (urlString === "https://api.example.com/redirect-to-evil") { - return new Response("", { - status: 302, - headers: { location: "https://evil.com/data" }, - }); - } - - if (urlString === "https://api.example.com/redirect-to-allowed") { - return new Response("", { - status: 302, - headers: { location: "https://api.example.com/data" }, - }); - } - - if (urlString === "https://api.example.com/redirect-chain") { - return new Response("", { - status: 302, - headers: { location: "https://api.example.com/redirect-to-allowed" }, - }); - } - - const mockResponse = mockResponses[urlString]; - if (mockResponse) { - const headers = new Headers(mockResponse.headers || {}); - return new Response(mockResponse.body, { - status: mockResponse.status, - statusText: mockResponse.status === 200 ? "OK" : "Error", - headers, - }); - } - - // Default: return 404 - return new Response("Not Found", { status: 404 }); - }, - ); -} - -/** - * Environment adapter interface for running tests with both BashEnv and Sandbox - */ -export interface EnvAdapter { - exec( - cmd: string, - ): Promise<{ exitCode: number; stdout: string; stderr: string }>; - readFile(path: string): Promise; -} - -/** - * Creates an adapter for BashEnv - */ -export function createBashEnvAdapter(options: BashOptions): EnvAdapter { - const env = new Bash(options); - return { - exec: (cmd) => env.exec(cmd), - readFile: (path) => env.readFile(path), - }; -} - -/** - * Creates an adapter for Sandbox - */ -export async function createSandboxAdapter( - options: BashOptions, -): Promise { - const sandbox = await Sandbox.create({ network: options.network }); - return { - exec: async (cmd) => { - const command = await sandbox.runCommand(cmd); - const finished = await command.wait(); - return { - exitCode: finished.exitCode, - stdout: await command.stdout(), - stderr: await command.stderr(), - }; - }, - readFile: (path) => sandbox.readFile(path), - }; -} - -export type AdapterFactory = ( - options: BashOptions, -) => EnvAdapter | Promise; - -/** - * Utility for testing that a URL is blocked - */ -export async function expectBlocked( - env: EnvAdapter, - url: string, - expectedUrl?: string, -): Promise { - const result = await env.exec(`curl "${url}"`); - expect(result.exitCode).toBe(7); - expect(result.stdout).toBe(""); - // Verify the URL in the error message (may be normalized) - const blockedUrl = expectedUrl ?? url; - expect(result.stderr).toBe( - `curl: (7) Network access denied: URL not in allow-list: ${blockedUrl}\n`, - ); -} - -/** - * Utility for testing that a URL succeeds with expected mock response - */ -export async function expectAllowed( - env: EnvAdapter, - url: string, - expectedBody: string, -): Promise { - const result = await env.exec(`curl "${url}"`); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(expectedBody); - expect(result.stderr).toBe(""); -} diff --git a/src/network/allow-list/unit.test.ts b/src/network/allow-list/unit.test.ts deleted file mode 100644 index 5f59495a..00000000 --- a/src/network/allow-list/unit.test.ts +++ /dev/null @@ -1,846 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - isUrlAllowed, - matchesAllowListEntry, - normalizeAllowListEntry, - parseUrl, - validateAllowList, -} from "../allow-list.js"; - -describe("parseUrl", () => { - it("parses a simple URL", () => { - const result = parseUrl("https://example.com/path"); - expect(result).toEqual({ - origin: "https://example.com", - pathname: "/path", - href: "https://example.com/path", - }); - }); - - it("parses URL with port", () => { - const result = parseUrl("https://example.com:8080/api"); - expect(result).toEqual({ - origin: "https://example.com:8080", - pathname: "/api", - href: "https://example.com:8080/api", - }); - }); - - it("parses URL with query string", () => { - const result = parseUrl("https://example.com/path?foo=bar"); - expect(result).toEqual({ - origin: "https://example.com", - pathname: "/path", - href: "https://example.com/path?foo=bar", - }); - }); - - it("returns null for invalid URL", () => { - expect(parseUrl("not-a-url")).toBeNull(); - expect(parseUrl("")).toBeNull(); - expect(parseUrl("://missing-scheme")).toBeNull(); - }); - - it("parses http URL", () => { - const result = parseUrl("http://example.com"); - expect(result).toEqual({ - origin: "http://example.com", - pathname: "/", - href: "http://example.com/", - }); - }); -}); - -describe("normalizeAllowListEntry", () => { - it("normalizes origin-only entry", () => { - expect(normalizeAllowListEntry("https://example.com")).toEqual({ - origin: "https://example.com", - pathPrefix: "/", - }); - }); - - it("normalizes origin with trailing slash", () => { - expect(normalizeAllowListEntry("https://example.com/")).toEqual({ - origin: "https://example.com", - pathPrefix: "/", - }); - }); - - it("preserves path prefix", () => { - expect(normalizeAllowListEntry("https://example.com/api/v1")).toEqual({ - origin: "https://example.com", - pathPrefix: "/api/v1", - }); - }); - - it("preserves path prefix with trailing slash", () => { - expect(normalizeAllowListEntry("https://example.com/api/v1/")).toEqual({ - origin: "https://example.com", - pathPrefix: "/api/v1/", - }); - }); - - it("returns null for invalid entry", () => { - expect(normalizeAllowListEntry("not-a-url")).toBeNull(); - }); -}); - -describe("matchesAllowListEntry", () => { - describe("origin matching", () => { - it("matches exact origin", () => { - expect( - matchesAllowListEntry( - "https://example.com/any/path", - "https://example.com", - ), - ).toBe(true); - }); - - it("does not match different origin", () => { - expect( - matchesAllowListEntry("https://other.com/path", "https://example.com"), - ).toBe(false); - }); - - it("does not match different scheme", () => { - expect( - matchesAllowListEntry("http://example.com/path", "https://example.com"), - ).toBe(false); - }); - - it("does not match different port", () => { - expect( - matchesAllowListEntry( - "https://example.com:8080/path", - "https://example.com", - ), - ).toBe(false); - }); - - it("matches same port explicitly", () => { - expect( - matchesAllowListEntry( - "https://example.com:8080/path", - "https://example.com:8080", - ), - ).toBe(true); - }); - - it("does not match subdomain", () => { - expect( - matchesAllowListEntry( - "https://api.example.com/path", - "https://example.com", - ), - ).toBe(false); - }); - - it("matches exact subdomain", () => { - expect( - matchesAllowListEntry( - "https://api.example.com/path", - "https://api.example.com", - ), - ).toBe(true); - }); - }); - - describe("path matching", () => { - it("allows any path when entry is origin only", () => { - expect( - matchesAllowListEntry("https://example.com/", "https://example.com"), - ).toBe(true); - expect( - matchesAllowListEntry("https://example.com/api", "https://example.com"), - ).toBe(true); - expect( - matchesAllowListEntry( - "https://example.com/api/v1/users", - "https://example.com", - ), - ).toBe(true); - }); - - it("matches path prefix", () => { - expect( - matchesAllowListEntry( - "https://example.com/api/v1", - "https://example.com/api", - ), - ).toBe(true); - expect( - matchesAllowListEntry( - "https://example.com/api/v1/users", - "https://example.com/api", - ), - ).toBe(true); - }); - - it("matches exact path", () => { - expect( - matchesAllowListEntry( - "https://example.com/api/v1", - "https://example.com/api/v1", - ), - ).toBe(true); - }); - - it("does not match different path", () => { - expect( - matchesAllowListEntry( - "https://example.com/other", - "https://example.com/api", - ), - ).toBe(false); - }); - - it("path prefix is strict - /api does not match /apiv2", () => { - expect( - matchesAllowListEntry( - "https://example.com/apiv2", - "https://example.com/api/", - ), - ).toBe(false); - }); - - it("trailing slash in entry enforces directory-like prefix", () => { - // With trailing slash, /api/ does not match /api - expect( - matchesAllowListEntry( - "https://example.com/api", - "https://example.com/api/", - ), - ).toBe(false); - // But matches /api/anything - expect( - matchesAllowListEntry( - "https://example.com/api/v1", - "https://example.com/api/", - ), - ).toBe(true); - }); - }); - - describe("edge cases", () => { - it("handles root path", () => { - expect( - matchesAllowListEntry("https://example.com/", "https://example.com/"), - ).toBe(true); - }); - - it("returns false for invalid URL", () => { - expect(matchesAllowListEntry("not-a-url", "https://example.com")).toBe( - false, - ); - }); - - it("returns false for invalid entry", () => { - expect(matchesAllowListEntry("https://example.com", "not-a-url")).toBe( - false, - ); - }); - - it("handles URL with query string - query is ignored in matching", () => { - expect( - matchesAllowListEntry( - "https://example.com/api?foo=bar", - "https://example.com/api", - ), - ).toBe(true); - }); - - it("handles URL with fragment - fragment is ignored in matching", () => { - expect( - matchesAllowListEntry( - "https://example.com/api#section", - "https://example.com/api", - ), - ).toBe(true); - }); - - it("handles case sensitivity in path", () => { - expect( - matchesAllowListEntry( - "https://example.com/API", - "https://example.com/api", - ), - ).toBe(false); - }); - }); -}); - -describe("isUrlAllowed", () => { - it("returns false for empty allow list", () => { - expect(isUrlAllowed("https://example.com/path", [])).toBe(false); - }); - - it("returns false for undefined allow list", () => { - expect( - isUrlAllowed( - "https://example.com/path", - undefined as unknown as string[], - ), - ).toBe(false); - }); - - it("returns true if URL matches any entry", () => { - const allowedUrlPrefixes = [ - "https://api.example.com", - "https://cdn.example.com/assets", - ]; - expect( - isUrlAllowed("https://api.example.com/v1/users", allowedUrlPrefixes), - ).toBe(true); - expect( - isUrlAllowed( - "https://cdn.example.com/assets/image.png", - allowedUrlPrefixes, - ), - ).toBe(true); - }); - - it("returns false if URL does not match any entry", () => { - const allowedUrlPrefixes = ["https://api.example.com"]; - expect(isUrlAllowed("https://other.com/path", allowedUrlPrefixes)).toBe( - false, - ); - }); - - it("handles multiple entries correctly", () => { - const allowedUrlPrefixes = [ - "https://api.example.com/v1/", - "https://api.example.com/v2/", - "https://cdn.example.com", - ]; - - // Matches v1 - expect( - isUrlAllowed("https://api.example.com/v1/users", allowedUrlPrefixes), - ).toBe(true); - // Matches v2 - expect( - isUrlAllowed("https://api.example.com/v2/data", allowedUrlPrefixes), - ).toBe(true); - // Does not match v3 - expect( - isUrlAllowed("https://api.example.com/v3/other", allowedUrlPrefixes), - ).toBe(false); - // Matches CDN - expect( - isUrlAllowed("https://cdn.example.com/anything", allowedUrlPrefixes), - ).toBe(true); - }); -}); - -describe("validateAllowList", () => { - describe("valid entries", () => { - it("returns empty array for valid entries", () => { - const errors = validateAllowList([ - "https://example.com", - "https://api.example.com/v1", - "http://localhost:3000", - ]); - expect(errors).toEqual([]); - }); - - it("accepts origin-only entries", () => { - const errors = validateAllowList([ - "https://example.com", - "http://localhost", - "https://api.example.com:8443", - ]); - expect(errors).toEqual([]); - }); - - it("accepts origin with path prefix entries", () => { - const errors = validateAllowList([ - "https://api.example.com/v1/", - "https://api.example.com/v1/users", - "http://localhost:3000/api/", - ]); - expect(errors).toEqual([]); - }); - }); - - describe("origin validation - must have scheme and host", () => { - it("rejects entries without scheme", () => { - const errors = validateAllowList(["example.com"]); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain("Invalid URL"); - expect(errors[0]).toContain("must be a valid URL with scheme and host"); - }); - - it("rejects entries with only scheme", () => { - const errors = validateAllowList(["https://"]); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain("Invalid URL"); - }); - - it("rejects relative paths", () => { - const errors = validateAllowList(["/api/v1"]); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain("Invalid URL"); - }); - - it("rejects protocol-relative URLs", () => { - const errors = validateAllowList(["//example.com/path"]); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain("Invalid URL"); - }); - - it("rejects paths without host", () => { - const errors = validateAllowList(["file:///etc/passwd"]); - expect(errors).toHaveLength(1); - // file: protocol is rejected for http/https check before hostname check - expect(errors[0]).toContain("Only http and https"); - }); - }); - - describe("protocol restrictions", () => { - it("reports non-http/https protocols", () => { - const errors = validateAllowList([ - "ftp://example.com", - "file:///etc/passwd", - ]); - expect(errors).toHaveLength(2); - expect(errors[0]).toContain("Only http and https"); - }); - - it("rejects data: URLs", () => { - const errors = validateAllowList(["data:text/html,

test

"]); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain("Only http and https"); - }); - - it("rejects javascript: URLs", () => { - const errors = validateAllowList(["javascript:alert(1)"]); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain("Only http and https"); - }); - }); - - describe("query strings and fragments", () => { - it("warns about query strings", () => { - const errors = validateAllowList(["https://example.com?api_key=123"]); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain("Query strings"); - }); - - it("warns about fragments", () => { - const errors = validateAllowList(["https://example.com#section"]); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain("fragments"); - }); - }); - - describe("edge cases", () => { - it("accepts IPv4 addresses", () => { - const errors = validateAllowList([ - "https://192.168.1.1", - "http://127.0.0.1:8080", - ]); - expect(errors).toEqual([]); - }); - - it("accepts IPv6 addresses", () => { - const errors = validateAllowList([ - "https://[::1]", - "http://[2001:db8::1]:8080", - ]); - expect(errors).toEqual([]); - }); - - it("accepts localhost", () => { - const errors = validateAllowList([ - "http://localhost", - "http://localhost:3000", - "https://localhost:8443/api", - ]); - expect(errors).toEqual([]); - }); - - it("reports multiple errors for multiple invalid entries", () => { - const errors = validateAllowList([ - "not-a-url", - "ftp://example.com", - "https://valid.com", - "/relative/path", - ]); - expect(errors).toHaveLength(3); - }); - }); -}); - -describe("security scenarios", () => { - describe("path traversal prevention", () => { - it("does not allow path traversal via encoded characters", () => { - const allowedUrlPrefixes = ["https://example.com/safe/"]; - // The URL parser will normalize this, but the check should still work - expect( - isUrlAllowed("https://example.com/safe/../unsafe", allowedUrlPrefixes), - ).toBe(false); - }); - - it("handles double-encoded characters correctly", () => { - const allowedUrlPrefixes = ["https://example.com/safe/"]; - // %252e%252e = double-encoded .. - // The URL is taken as-is after URL parsing - expect( - isUrlAllowed( - "https://example.com/safe/%252e%252e/unsafe", - allowedUrlPrefixes, - ), - ).toBe(true); // This stays under /safe/ path - }); - }); - - describe("host matching strictness", () => { - it("does not allow host suffix matching", () => { - const allowedUrlPrefixes = ["https://example.com"]; - expect( - isUrlAllowed("https://evilexample.com/path", allowedUrlPrefixes), - ).toBe(false); - }); - - it("does not allow host prefix matching", () => { - const allowedUrlPrefixes = ["https://example.com"]; - expect( - isUrlAllowed("https://example.com.evil.com/path", allowedUrlPrefixes), - ).toBe(false); - }); - - it("does not allow credential injection", () => { - const allowedUrlPrefixes = ["https://example.com"]; - // user:pass@example.com could be used for credential stuffing - expect( - isUrlAllowed("https://user:pass@example.com/path", allowedUrlPrefixes), - ).toBe(true); // URL parser strips credentials, origin matches - }); - }); - - describe("port matching", () => { - it("default ports are normalized", () => { - const allowedUrlPrefixes = ["https://example.com"]; - // Port 443 is default for https, should be normalized - expect( - isUrlAllowed("https://example.com:443/path", allowedUrlPrefixes), - ).toBe(true); - }); - - it("http default port is normalized", () => { - const allowedUrlPrefixes = ["http://example.com"]; - expect( - isUrlAllowed("http://example.com:80/path", allowedUrlPrefixes), - ).toBe(true); - }); - - it("non-default port must be explicit", () => { - const allowedUrlPrefixes = ["https://example.com"]; - expect( - isUrlAllowed("https://example.com:8443/path", allowedUrlPrefixes), - ).toBe(false); - }); - }); - - describe("real-world API patterns", () => { - it("allows GitHub API access with org restriction", () => { - const allowedUrlPrefixes = ["https://api.github.com/repos/myorg/"]; - - // Allowed: repos in myorg - expect( - isUrlAllowed( - "https://api.github.com/repos/myorg/myrepo/issues", - allowedUrlPrefixes, - ), - ).toBe(true); - - // Not allowed: other orgs - expect( - isUrlAllowed( - "https://api.github.com/repos/otherorg/repo/issues", - allowedUrlPrefixes, - ), - ).toBe(false); - - // Not allowed: other endpoints - expect( - isUrlAllowed("https://api.github.com/users/foo", allowedUrlPrefixes), - ).toBe(false); - }); - - it("allows specific API version", () => { - const allowedUrlPrefixes = ["https://api.example.com/v2/"]; - - expect( - isUrlAllowed("https://api.example.com/v2/users", allowedUrlPrefixes), - ).toBe(true); - expect( - isUrlAllowed("https://api.example.com/v1/users", allowedUrlPrefixes), - ).toBe(false); - }); - - it("allows multiple services", () => { - const allowedUrlPrefixes = [ - "https://api.service1.com", - "https://api.service2.com/webhooks/", - ]; - - expect( - isUrlAllowed("https://api.service1.com/anything", allowedUrlPrefixes), - ).toBe(true); - expect( - isUrlAllowed( - "https://api.service2.com/webhooks/hook123", - allowedUrlPrefixes, - ), - ).toBe(true); - expect( - isUrlAllowed("https://api.service2.com/other/path", allowedUrlPrefixes), - ).toBe(false); - }); - }); - - describe("adversarial URL manipulation", () => { - it("blocks URLs with @ symbol trying to confuse host parsing", () => { - const allowedUrlPrefixes = ["https://allowed.com"]; - // Attacker tries to make URL look like allowed.com but actually goes to evil.com - // https://allowed.com@evil.com would go to evil.com with allowed.com as username - expect( - isUrlAllowed("https://allowed.com@evil.com/path", allowedUrlPrefixes), - ).toBe(false); - }); - - it("blocks URLs with backslash trying to confuse path", () => { - const allowedUrlPrefixes = ["https://api.example.com/safe/"]; - // URL constructor converts backslashes to forward slashes, treating \.. as /.. - // This results in path traversal: /safe\..\\unsafe → /safe/../unsafe → /unsafe - // This is correct security behavior - the URL is blocked - expect( - isUrlAllowed( - "https://api.example.com/safe\\..\\unsafe", - allowedUrlPrefixes, - ), - ).toBe(false); - }); - - it("blocks URLs with null bytes", () => { - const allowedUrlPrefixes = ["https://api.example.com"]; - // URL constructor will reject or normalize null bytes - const result = isUrlAllowed( - "https://api.example.com/path%00.txt", - allowedUrlPrefixes, - ); - // Should either match or be rejected by URL parsing - expect(typeof result).toBe("boolean"); - }); - - it("blocks URLs with unicode homoglyphs in host", () => { - const allowedUrlPrefixes = ["https://example.com"]; - // Cyrillic 'е' looks like Latin 'e' but is different - // URL parsing should treat this as a different host - expect( - isUrlAllowed("https://еxample.com/path", allowedUrlPrefixes), // Cyrillic е - ).toBe(false); - }); - - it("blocks URLs with extra slashes", () => { - const allowedUrlPrefixes = ["https://api.example.com/safe/"]; - // Extra slashes should be normalized - expect( - isUrlAllowed("https://api.example.com//safe/path", allowedUrlPrefixes), - ).toBe(false); // //safe != /safe/ - }); - - it("blocks URLs with scheme confusion", () => { - const allowedUrlPrefixes = ["https://api.example.com"]; - // file:// URLs should never match https:// allowlist - expect(isUrlAllowed("file:///etc/passwd", allowedUrlPrefixes)).toBe( - false, - ); - expect(isUrlAllowed("javascript:alert(1)", allowedUrlPrefixes)).toBe( - false, - ); - expect(isUrlAllowed("data:text/html," -"<script>hax</script>" - -[.[]|tojson|fromjson] -["foo", 1, ["a", 1, "b", 2, {"foo":"bar"}]] -["foo",1,["a",1,"b",2,{"foo":"bar"}]] - -# -# Dictionary construction syntax -# - -{a: 1} -null -{"a":1} - -{a,b,(.d):.a,e:.b} -{"a":1, "b":2, "c":3, "d":"c"} -{"a":1, "b":2, "c":1, "e":2} - -{"a",b,"a$\(1+1)"} -{"a":1, "b":2, "c":3, "a$2":4} -{"a":1, "b":2, "a$2":4} - -%%FAIL -{(0):1} -jq: error: Cannot use number (0) as object key at , line 1, column 3: - {(0):1} - ^ - -%%FAIL -{1+2:3} -jq: error: May need parentheses around object key expression at , line 1, column 2: - {1+2:3} - ^^^ - -%%FAIL -{non_const:., (0):1} -jq: error: Cannot use number (0) as object key at , line 1, column 16: - {non_const:., (0):1} - ^ - -# -# Field access, piping -# - -.foo -{"foo": 42, "bar": 43} -42 - -.foo | .bar -{"foo": {"bar": 42}, "bar": "badvalue"} -42 - -.foo.bar -{"foo": {"bar": 42}, "bar": "badvalue"} -42 - -.foo_bar -{"foo_bar": 2} -2 - -.["foo"].bar -{"foo": {"bar": 42}, "bar": "badvalue"} -42 - -."foo"."bar" -{"foo": {"bar": 20}} -20 - -.e0, .E1, .E-1, .E+1 -{"e0": 1, "E1": 2, "E": 3} -1 -2 -2 -4 - -[.[]|.foo?] -[1,[2],{"foo":3,"bar":4},{},{"foo":5}] -[3,null,5] - -[.[]|.foo?.bar?] -[1,[2],[],{"foo":3},{"foo":{"bar":4}},{}] -[4,null] - -[..] -[1,[[2]],{ "a":[1]}] -[[1,[[2]],{"a":[1]}],1,[[2]],[2],2,{"a":[1]},[1],1] - -[.[]|.[]?] -[1,null,[],[1,[2,[[3]]]],[{}],[{"a":[1,[2]]}]] -[1,[2,[[3]]],{},{"a":[1,[2]]}] - -[.[]|.[1:3]?] -[1,null,true,false,"abcdef",{},{"a":1,"b":2},[],[1,2,3,4,5],[1,2]] -[null,"bc",[],[2,3],[2]] - -# chaining/suffix-list, with and without dot -map(try .a[] catch ., try .a.[] catch ., .a[]?, .a.[]?) -[{"a": [1,2]}, {"a": 123}] -[1,2,1,2,1,2,1,2,"Cannot iterate over number (123)","Cannot iterate over number (123)"] - -# oss-fuzz #66070: objects[] leaks if a non-last element throws an error -try ["OK", (.[] | error)] catch ["KO", .] -{"a":["b"],"c":["d"]} -["KO",["b"]] - -# -# Negative array indices -# - -try (.foo[-1] = 0) catch . -null -"Out of bounds negative array index" - -try (.foo[-2] = 0) catch . -null -"Out of bounds negative array index" - -.[-1] = 5 -[0,1,2] -[0,1,5] - -.[-2] = 5 -[0,1,2] -[0,5,2] - -try (.[999999999] = 0) catch . -null -"Array index too large" - -# -# Multiple outputs, iteration -# - -.[] -[1,2,3] -1 -2 -3 - -1,1 -[] -1 -1 - -1,. -[] -1 -[] - -[.] -[2] -[[2]] - -[[2]] -[3] -[[2]] - -[{}] -[2] -[{}] - -[.[]] -["a"] -["a"] - -[(.,1),((.,.[]),(2,3))] -["a","b"] -[["a","b"],1,["a","b"],"a","b",2,3] - -[([5,5][]),.,.[]] -[1,2,3] -[5,5,[1,2,3],1,2,3] - -{x: (1,2)},{x:3} | .x -null -1 -2 -3 - -[.[-4,-3,-2,-1,0,1,2,3]] -[1,2,3] -[null,1,2,3,1,2,3,null] - -[range(0;10)] -null -[0,1,2,3,4,5,6,7,8,9] - -[range(0,1;3,4)] -null -[0,1,2, 0,1,2,3, 1,2, 1,2,3] - -[range(0;10;3)] -null -[0,3,6,9] - -[range(0;10;-1)] -null -[] - -[range(0;-5;-1)] -null -[0,-1,-2,-3,-4] - -[range(0,1;4,5;1,2)] -null -[0,1,2,3,0,2, 0,1,2,3,4,0,2,4, 1,2,3,1,3, 1,2,3,4,1,3] - -[while(.<100; .*2)] -1 -[1,2,4,8,16,32,64] - -[(label $here | .[] | if .>1 then break $here else . end), "hi!"] -[0,1,2] -[0,1,"hi!"] - -[(label $here | .[] | if .>1 then break $here else . end), "hi!"] -[0,2,1] -[0,"hi!"] - -%%FAIL -. as $foo | break $foo -jq: error: $*label-foo is not defined at , line 1, column 13: - . as $foo | break $foo - ^^^^^^^^^^ - -[.[]|[.,1]|until(.[0] < 1; [.[0] - 1, .[1] * .[0]])|.[1]] -[1,2,3,4,5] -[1,2,6,24,120] - -[label $out | foreach .[] as $item ([3, null]; if .[0] < 1 then break $out else [.[0] -1, $item] end; .[1])] -[11,22,33,44,55,66,77,88,99] -[11,22,33] - -[foreach range(5) as $item (0; $item)] -null -[0,1,2,3,4] - -[foreach .[] as [$i, $j] (0; . + $i - $j)] -[[2,1], [5,3], [6,4]] -[1,3,5] - -[foreach .[] as {a:$a} (0; . + $a; -.)] -[{"a":1}, {"b":2}, {"a":3, "b":4}] -[-1, -1, -4] - -[-foreach -.[] as $x (0; . + $x)] -[1,2,3] -[1,3,6] - -[foreach .[] / .[] as $i (0; . + $i)] -[1,2] -[1,3,3.5,4.5] - -[foreach .[] as $x (0; . + $x) as $x | $x] -[1,2,3] -[1,3,6] - -[limit(3; .[])] -[11,22,33,44,55,66,77,88,99] -[11,22,33] - -[limit(0; error)] -"badness" -[] - -[limit(1; 1, error)] -"badness" -[1] - -try limit(-1; error) catch . -null -"limit doesn't support negative count" - -[skip(3; .[])] -[1,2,3,4,5,6,7,8,9] -[4,5,6,7,8,9] - -[skip(0,2,3,4; .[])] -[1,2,3] -[1,2,3,3] - -[skip(3; .[])] -[] -[] - -try skip(-1; error) catch . -null -"skip doesn't support negative count" - -nth(1; 0,1,error("foo")) -null -1 - -[first(range(.)), last(range(.))] -10 -[0,9] - -[first(range(.)), last(range(.))] -0 -[] - -[nth(0,5,9,10,15; range(.)), try nth(-1; range(.)) catch .] -10 -[0,5,9,"nth doesn't support negative indices"] - -# Check that first(g) does not extract more than one value from g -first(1,error("foo")) -null -1 - -# -# Check that various builtins evaluate all arguments where appropriate, -# doing cartesian products where appropriate. -# - -# Check that limit does work for each value produced by n! -[limit(5,7; range(9))] -null -[0,1,2,3,4,0,1,2,3,4,5,6] - -# Same check for nth -[nth(5,7; range(9;0;-1))] -null -[4,2] - -# Same check for range/3 -[range(0,1,2;4,3,2;2,3)] -null -[0,2,0,3,0,2,0,0,0,1,3,1,1,1,1,1,2,2,2,2] - -# Same check for range/1 -[range(3,5)] -null -[0,1,2,0,1,2,3,4] - -# Same check for index/1, rindex/1, indices/1 -[(index(",","|"), rindex(",","|")), indices(",","|")] -"a,b|c,d,e||f,g,h,|,|,i,j" -[1,3,22,19,[1,5,7,12,14,16,18,20,22],[3,9,10,17,19]] - -# Same check for join/1 -join(",","/") -["a","b","c","d"] -"a,b,c,d" -"a/b/c/d" - -[.[]|join("a")] -[[],[""],["",""],["","",""]] -["","","a","aa"] - -# Same check for flatten/1 -flatten(3,2,1) -[0, [1], [[2]], [[[3]]]] -[0,1,2,3] -[0,1,2,[3]] -[0,1,[2],[[3]]] - - -# -# Slices -# - -[.[3:2], .[-5:4], .[:-2], .[-2:], .[3:3][1:], .[10:]] -[0,1,2,3,4,5,6] -[[], [2,3], [0,1,2,3,4], [5,6], [], []] - -[.[3:2], .[-5:4], .[:-2], .[-2:], .[3:3][1:], .[10:]] -"abcdefghi" -["","","abcdefg","hi","",""] - -del(.[2:4],.[0],.[-2:]) -[0,1,2,3,4,5,6,7] -[1,4,5] - -.[2:4] = ([], ["a","b"], ["a","b","c"]) -[0,1,2,3,4,5,6,7] -[0,1,4,5,6,7] -[0,1,"a","b",4,5,6,7] -[0,1,"a","b","c",4,5,6,7] - -# Slices at large offsets (issue #1108) -# -# This is written this way because [range()] is -# significantly slower under valgrind than .[] = value. -# -# We range down rather than up so that we have just one realloc. -reduce range(65540;65536;-1) as $i ([]; .[$i] = $i)|.[65536:] -null -[null,65537,65538,65539,65540] - -# -# Variables -# - -1 as $x | 2 as $y | [$x,$y,$x] -null -[1,2,1] - -[1,2,3][] as $x | [[4,5,6,7][$x]] -null -[5] -[6] -[7] - -42 as $x | . | . | . + 432 | $x + 1 -34324 -43 - -1 + 2 as $x | -$x -null --3 - -"x" as $x | "a"+"y" as $y | $x+","+$y -null -"x,ay" - -1 as $x | [$x,$x,$x as $x | $x] -null -[1,1,1] - -[1, {c:3, d:4}] as [$a, {c:$b, b:$c}] | $a, $b, $c -null -1 -3 -null - -. as {as: $kw, "str": $str, ("e"+"x"+"p"): $exp} | [$kw, $str, $exp] -{"as": 1, "str": 2, "exp": 3} -[1, 2, 3] - -.[] as [$a, $b] | [$b, $a] -[[1], [1, 2, 3]] -[null, 1] -[2, 1] - -. as $i | . as [$i] | $i -[0] -0 - -. as [$i] | . as $i | $i -[0] -[0] - -%%FAIL -. as [] | null -jq: error: syntax error, unexpected ']', expecting BINDING or '[' or '{' at , line 1, column 7: - . as [] | null - ^ - -%%FAIL -. as {} | null -jq: error: syntax error, unexpected '}' at , line 1, column 7: - . as {} | null - ^ - -%%FAIL -. as $foo | [$foo, $bar] -jq: error: $bar is not defined at , line 1, column 20: - . as $foo | [$foo, $bar] - ^^^^ - -%%FAIL -. as {(true):$foo} | $foo -jq: error: Cannot use boolean (true) as object key at , line 1, column 8: - . as {(true):$foo} | $foo - ^^^^ - -# [.,(.[] | {x:.},.),.,.[]] - -# -# Builtin functions -# - -1+1 -null -2 - -1+1 -"wtasdf" -2.0 - -2-1 -null -1 - -2-(-1) -null -3 - -1e+0+0.001e3 -"I wonder what this will be?" -20e-1 - -.+4 -15 -19.0 - -.+null -{"a":42} -{"a":42} - -null+. -null -null - -.a+.b -{"a":42} -42 - -[1,2,3] + [.] -null -[1,2,3,null] - -{"a":1} + {"b":2} + {"c":3} -"asdfasdf" -{"a":1, "b":2, "c":3} - -"asdf" + "jkl;" + . + . + . -"some string" -"asdfjkl;some stringsome stringsome string" - -"\u0000\u0020\u0000" + . -"\u0000\u0020\u0000" -"\u0000 \u0000\u0000 \u0000" - -42 - . -11 -31 - -[1,2,3,4,1] - [.,3] -1 -[2,4] - -[-1 as $x | 1,$x] -null -[1,-1] - -[10 * 20, 20 / .] -4 -[200, 5] - -1 + 2 * 2 + 10 / 2 -null -10 - -[16 / 4 / 2, 16 / 4 * 2, 16 - 4 - 2, 16 - 4 + 2] -null -[2, 8, 10, 14] - -1e-19 + 1e-20 - 5e-21 -null -1.05e-19 - -1 / 1e-17 -null -1e+17 - -9E999999999, 9999999999E999999990, 1E-999999999, 0.000000001E-999999990 -null -9E+999999999 -9.999999999E+999999999 -1E-999999999 -1E-999999999 - -5E500000000 > 5E-5000000000, 10000E500000000 > 10000E-5000000000 -null -true -true - -# #2825 -(1e999999999, 10e999999999) > (1e-1147483646, 0.1e-1147483646) -null -true -true -true -true - -25 % 7 -null -4 - -49732 % 472 -null -172 - -[(infinite, -infinite) % (1, -1, infinite)] -null -[0,0,0,0,0,-1] - -[nan % 1, 1 % nan | isnan] -null -[true,true] - -1 + tonumber + ("10" | tonumber) -4 -15 - -map(toboolean) -["false","true",false,true] -[false,true,false,true] - -.[] | try toboolean catch . -[null,0,"tru","truee","fals","falsee",[],{}] -"null (null) cannot be parsed as a boolean" -"number (0) cannot be parsed as a boolean" -"string (\"tru\") cannot be parsed as a boolean" -"string (\"truee\") cannot be parsed as a boolean" -"string (\"fals\") cannot be parsed as a boolean" -"string (\"falsee\") cannot be parsed as a boolean" -"array ([]) cannot be parsed as a boolean" -"object ({}) cannot be parsed as a boolean" - -[{"a":42},.object,10,.num,false,true,null,"b",[1,4]] | .[] as $x | [$x == .[]] -{"object": {"a":42}, "num":10.0} -[true, true, false, false, false, false, false, false, false] -[true, true, false, false, false, false, false, false, false] -[false, false, true, true, false, false, false, false, false] -[false, false, true, true, false, false, false, false, false] -[false, false, false, false, true, false, false, false, false] -[false, false, false, false, false, true, false, false, false] -[false, false, false, false, false, false, true, false, false] -[false, false, false, false, false, false, false, true, false] -[false, false, false, false, false, false, false, false, true ] - -[.[] | length] -[[], {}, [1,2], {"a":42}, "asdf", "\u03bc"] -[0, 0, 2, 1, 4, 1] - -utf8bytelength -"asdf\u03bc" -6 - -[.[] | try utf8bytelength catch .] -[[], {}, [1,2], 55, true, false] -["array ([]) only strings have UTF-8 byte length","object ({}) only strings have UTF-8 byte length","array ([1,2]) only strings have UTF-8 byte length","number (55) only strings have UTF-8 byte length","boolean (true) only strings have UTF-8 byte length","boolean (false) only strings have UTF-8 byte length"] - - -map(keys) -[{}, {"abcd":1,"abc":2,"abcde":3}, {"x":1, "z": 3, "y":2}] -[[], ["abc","abcd","abcde"], ["x","y","z"]] - -[1,2,empty,3,empty,4] -null -[1,2,3,4] - -map(add) -[[], [1,2,3], ["a","b","c"], [[3],[4,5],[6]], [{"a":1}, {"b":2}, {"a":3}]] -[null, 6, "abc", [3,4,5,6], {"a":3, "b": 2}] - -map_values(.+1) -[0,1,2] -[1,2,3] - -[add(null), add(range(range(10))), add(empty), add(10,range(10))] -null -[null,120,null,55] - -# Real-world use case for add(empty) -.sum = add(.arr[]) -{"arr":[]} -{"arr":[],"sum":null} - -add({(.[]):1}) | keys -["a","a","b","a","d","b","d","a","d"] -["a","b","d"] - -# -# User-defined functions -# Oh god. -# - -def f: . + 1; def g: def g: . + 100; f | g | f; (f | g), g -3.0 -106.0 -105.0 - -def f: (1000,2000); f -123412345 -1000 -2000 - -def f(a;b;c;d;e;f): [a+1,b,c,d,e,f]; f(.[0];.[1];.[0];.[0];.[0];.[0]) -[1,2] -[2,2,1,1,1,1] - -def f: 1; def g: f, def f: 2; def g: 3; f, def f: g; f, g; def f: 4; [f, def f: g; def g: 5; f, g]+[f,g] -null -[4,1,2,3,3,5,4,1,2,3,3] - -# Test precedence of 'def' vs '|' -def a: 0; . | a -null -0 - -# Many arguments -def f(a;b;c;d;e;f;g;h;i;j): [j,i,h,g,f,e,d,c,b,a]; f(.[0];.[1];.[2];.[3];.[4];.[5];.[6];.[7];.[8];.[9]) -[0,1,2,3,4,5,6,7,8,9] -[9,8,7,6,5,4,3,2,1,0] - -([1,2] + [4,5]) -[1,2,3] -[1,2,4,5] - -true -[1] -true - -null,1,null -"hello" -null -1 -null - -[1,2,3] -[5,6] -[1,2,3] - -[.[]|floor] -[-1.1,1.1,1.9] -[-2, 1, 1] - -[.[]|sqrt] -[4,9] -[2,3] - -(add / length) as $m | map((. - $m) as $d | $d * $d) | add / length | sqrt -[2,4,4,4,5,5,7,9] -2 - -# Should write a test that calls the -lm function from C (or bc(1)) to -# check that they match the corresponding jq functions. However, -# there's so little template code standing between that it suffices to -# test a handful of these. The results were checked by eye against -# bc(1). -atan * 4 * 1000000|floor / 1000000 -1 -3.141592 - -[(3.141592 / 2) * (range(0;20) / 20)|cos * 1000000|floor / 1000000] -null -[1,0.996917,0.987688,0.972369,0.951056,0.923879,0.891006,0.85264,0.809017,0.760406,0.707106,0.649448,0.587785,0.522498,0.45399,0.382683,0.309017,0.233445,0.156434,0.078459] - -[(3.141592 / 2) * (range(0;20) / 20)|sin * 1000000|floor / 1000000] -null -[0,0.078459,0.156434,0.233445,0.309016,0.382683,0.45399,0.522498,0.587785,0.649447,0.707106,0.760405,0.809016,0.85264,0.891006,0.923879,0.951056,0.972369,0.987688,0.996917] - - -def f(x): x | x; f([.], . + [42]) -[1,2,3] -[[[1,2,3]]] -[[1,2,3],42] -[[1,2,3,42]] -[1,2,3,42,42] - -# test multiple function arities and redefinition -def f: .+1; def g: f; def f: .+100; def f(a):a+.+11; [(g|f(20)), f] -1 -[33,101] - -# test closures and lexical scoping -def id(x):x; 2000 as $x | def f(x):1 as $x | id([$x, x, x]); def g(x): 100 as $x | f($x,$x+x); g($x) -"more testing" -[1,100,2100.0,100,2100.0] - -# test def f($a) syntax -def x(a;b): a as $a | b as $b | $a + $b; def y($a;$b): $a + $b; def check(a;b): [x(a;b)] == [y(a;b)]; check(.[];.[]*2) -[1,2,3] -true - -# test backtracking through function calls and returns -# this test is *evil* -[[20,10][1,0] as $x | def f: (100,200) as $y | def g: [$x + $y, .]; . + $x | g; f[0] | [f][0][1] | f] -999999999 -[[110.0, 130.0], [210.0, 130.0], [110.0, 230.0], [210.0, 230.0], [120.0, 160.0], [220.0, 160.0], [120.0, 260.0], [220.0, 260.0]] - -# test recursion -def fac: if . == 1 then 1 else . * (. - 1 | fac) end; [.[] | fac] -[1,2,3,4] -[1,2,6,24] - -# test stack overflow and reallocation -# this test is disabled for now, it takes a realllllly long time. -# def f: if length > 1000 then . else .+[1]|f end; f | length -# [] -# 1001 - -reduce .[] as $x (0; . + $x) -[1,2,4] -7 - -reduce .[] as [$i, {j:$j}] (0; . + $i - $j) -[[2,{"j":1}], [5,{"j":3}], [6,{"j":4}]] -5 - -reduce [[1,2,10], [3,4,10]][] as [$i,$j] (0; . + $i * $j) -null -14 - -[-reduce -.[] as $x (0; . + $x)] -[1,2,3] -[6] - -[reduce .[] / .[] as $i (0; . + $i)] -[1,2] -[4.5] - -reduce .[] as $x (0; . + $x) as $x | $x -[1,2,3] -6 - -# This, while useless, should still compile. -reduce . as $n (.; .) -null -null - -# Destructuring -. as {$a, b: [$c, {$d}]} | [$a, $c, $d] -{"a":1, "b":[2,{"d":3}]} -[1,2,3] - -. as {$a, $b:[$c, $d]}| [$a, $b, $c, $d] -{"a":1, "b":[2,{"d":3}]} -[1,[2,{"d":3}],2,{"d":3}] - -# Destructuring with alternation -.[] | . as {$a, b: [$c, {$d}]} ?// [$a, {$b}, $e] ?// $f | [$a, $b, $c, $d, $e, $f] -[{"a":1, "b":[2,{"d":3}]}, [4, {"b":5, "c":6}, 7, 8, 9], "foo"] -[1, null, 2, 3, null, null] -[4, 5, null, null, 7, null] -[null, null, null, null, null, "foo"] - -# Destructuring DUP/POP issues -.[] | . as {a:$a} ?// {a:$a} ?// {a:$a} | $a -[[3],[4],[5],6] -# Runtime error: "jq: Cannot index array with string \"c\"" - -.[] as {a:$a} ?// {a:$a} ?// {a:$a} | $a -[[3],[4],[5],6] -# Runtime error: "jq: Cannot index array with string \"c\"" - -[[3],[4],[5],6][] | . as {a:$a} ?// {a:$a} ?// {a:$a} | $a -null -# Runtime error: "jq: Cannot index array with string \"c\"" - -[[3],[4],[5],6] | .[] as {a:$a} ?// {a:$a} ?// {a:$a} | $a -null -# Runtime error: "jq: Cannot index array with string \"c\"" - -.[] | . as {a:$a} ?// {a:$a} ?// $a | $a -[[3],[4],[5],6] -[3] -[4] -[5] -6 - -.[] as {a:$a} ?// {a:$a} ?// $a | $a -[[3],[4],[5],6] -[3] -[4] -[5] -6 - -[[3],[4],[5],6][] | . as {a:$a} ?// {a:$a} ?// $a | $a -null -[3] -[4] -[5] -6 - -[[3],[4],[5],6] | .[] as {a:$a} ?// {a:$a} ?// $a | $a -null -[3] -[4] -[5] -6 - -.[] | . as {a:$a} ?// $a ?// {a:$a} | $a -[[3],[4],[5],6] -[3] -[4] -[5] -6 - -.[] as {a:$a} ?// $a ?// {a:$a} | $a -[[3],[4],[5],6] -[3] -[4] -[5] -6 - -[[3],[4],[5],6][] | . as {a:$a} ?// $a ?// {a:$a} | $a -null -[3] -[4] -[5] -6 - -[[3],[4],[5],6] | .[] as {a:$a} ?// $a ?// {a:$a} | $a -null -[3] -[4] -[5] -6 - -.[] | . as $a ?// {a:$a} ?// {a:$a} | $a -[[3],[4],[5],6] -[3] -[4] -[5] -6 - -.[] as $a ?// {a:$a} ?// {a:$a} | $a -[[3],[4],[5],6] -[3] -[4] -[5] -6 - -[[3],[4],[5],6][] | . as $a ?// {a:$a} ?// {a:$a} | $a -null -[3] -[4] -[5] -6 - -[[3],[4],[5],6] | .[] as $a ?// {a:$a} ?// {a:$a} | $a -null -[3] -[4] -[5] -6 - -. as $dot|any($dot[];not) -[1,2,3,4,true,false,1,2,3,4,5] -true - -. as $dot|any($dot[];not) -[1,2,3,4,true] -false - -. as $dot|all($dot[];.) -[1,2,3,4,true,false,1,2,3,4,5] -false - -. as $dot|all($dot[];.) -[1,2,3,4,true] -true - -# Check short-circuiting -any(true, error; .) -"badness" -true - -all(false, error; .) -"badness" -false - -any(not) -[] -false - -all(not) -[] -true - -any(not) -[false] -true - -all(not) -[false] -true - -[any,all] -[] -[false,true] - -[any,all] -[true] -[true,true] - -[any,all] -[false] -[false,false] - -[any,all] -[true,false] -[true,false] - -[any,all] -[null,null,true] -[true,false] - -# -# Paths -# - -path(.foo[0,1]) -null -["foo", 0] -["foo", 1] - -path(.[] | select(.>3)) -[1,5,3] -[1] - -path(.) -42 -[] - -try path(.a | map(select(.b == 0))) catch . -{"a":[{"b":0}]} -"Invalid path expression with result [{\"b\":0}]" - -try path(.a | map(select(.b == 0)) | .[0]) catch . -{"a":[{"b":0}]} -"Invalid path expression near attempt to access element 0 of [{\"b\":0}]" - -try path(.a | map(select(.b == 0)) | .c) catch . -{"a":[{"b":0}]} -"Invalid path expression near attempt to access element \"c\" of [{\"b\":0}]" - -try path(.a | map(select(.b == 0)) | .[]) catch . -{"a":[{"b":0}]} -"Invalid path expression near attempt to iterate through [{\"b\":0}]" - -path(.a[path(.b)[0]]) -{"a":{"b":0}} -["a","b"] - -[paths] -[1,[[],{"a":2}]] -[[0],[1],[1,0],[1,1],[1,1,"a"]] - -["foo",1] as $p | getpath($p), setpath($p; 20), delpaths([$p]) -{"bar": 42, "foo": ["a", "b", "c", "d"]} -"b" -{"bar": 42, "foo": ["a", 20, "c", "d"]} -{"bar": 42, "foo": ["a", "c", "d"]} - -map(getpath([2])), map(setpath([2]; 42)), map(delpaths([[2]])) -[[0], [0,1], [0,1,2]] -[null, null, 2] -[[0,null,42], [0,1,42], [0,1,42]] -[[0], [0,1], [0,1]] - -map(delpaths([[0,"foo"]])) -[[{"foo":2, "x":1}], [{"bar":2}]] -[[{"x":1}], [{"bar":2}]] - -["foo",1] as $p | getpath($p), setpath($p; 20), delpaths([$p]) -{"bar":false} -null -{"bar":false, "foo": [null, 20]} -{"bar":false} - -delpaths([[-200]]) -[1,2,3] -[1,2,3] - -try delpaths(0) catch . -{} -"Paths must be specified as an array" - -del(.), del(empty), del((.foo,.bar,.baz) | .[2,3,0]), del(.foo[0], .bar[0], .foo, .baz.bar[0].x) -{"foo": [0,1,2,3,4], "bar": [0,1]} -null -{"foo": [0,1,2,3,4], "bar": [0,1]} -{"foo": [1,4], "bar": [1]} -{"bar": [1]} - -del(.[1], .[-6], .[2], .[-3:9]) -[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] -[0, 3, 5, 6, 9] - -# negative index -setpath([-1]; 1) -[0] -[1] - -pick(.a.b.c) -null -{"a":{"b":{"c":null}}} - -pick(first) -[1,2] -[1] - -pick(first|first) -[[10,20],30] -[[10]] - -# negative indices in path expressions (since last/1 is .[-1]) -try pick(last) catch . -[1,2] -"Out of bounds negative array index" - -# -# Assignment -# -.message = "goodbye" -{"message": "hello"} -{"message": "goodbye"} - -.foo = .bar -{"bar":42} -{"foo":42, "bar":42} - -.foo |= .+1 -{"foo": 42} -{"foo": 43} - -.[] += 2, .[] *= 2, .[] -= 2, .[] /= 2, .[] %=2 -[1,3,5] -[3,5,7] -[2,6,10] -[-1,1,3] -[0.5, 1.5, 2.5] -[1,1,1] - -[.[] % 7] -[-7,-6,-5,-4,-3,-2,-1,0,1,2,3,4,5,6,7] -[0,-6,-5,-4,-3,-2,-1,0,1,2,3,4,5,6,0] - -.foo += .foo -{"foo":2} -{"foo":4} - -.[0].a |= {"old":., "new":(.+1)} -[{"a":1,"b":2}] -[{"a":{"old":1, "new":2},"b":2}] - -def inc(x): x |= .+1; inc(.[].a) -[{"a":1,"b":2},{"a":2,"b":4},{"a":7,"b":8}] -[{"a":2,"b":2},{"a":3,"b":4},{"a":8,"b":8}] - -# #1358, getpath/1 should work in path expressions -.[] | try (getpath(["a",0,"b"]) |= 5) catch . -[null,{"b":0},{"a":0},{"a":null},{"a":[0,1]},{"a":{"b":1}},{"a":[{}]},{"a":[{"c":3}]}] -{"a":[{"b":5}]} -{"b":0,"a":[{"b":5}]} -"Cannot index number with number" -{"a":[{"b":5}]} -"Cannot index number with string \"b\"" -"Cannot index object with number" -{"a":[{"b":5}]} -{"a":[{"c":3,"b":5}]} - -# #2051, deletion using assigning empty against arrays -(.[] | select(. >= 2)) |= empty -[1,5,3,0,7] -[1,0] - -.[] |= select(. % 2 == 0) -[0,1,2,3,4,5] -[0,2,4] - -.foo[1,4,2,3] |= empty -{"foo":[0,1,2,3,4,5]} -{"foo":[0,5]} - -.[2][3] = 1 -[4] -[4, null, [null, null, null, 1]] - -.foo[2].bar = 1 -{"foo":[11], "bar":42} -{"foo":[11,null,{"bar":1}], "bar":42} - -try ((map(select(.a == 1))[].b) = 10) catch . -[{"a":0},{"a":1}] -"Invalid path expression near attempt to iterate through [{\"a\":1}]" - -try ((map(select(.a == 1))[].a) |= .+1) catch . -[{"a":0},{"a":1}] -"Invalid path expression near attempt to iterate through [{\"a\":1}]" - -def x: .[1,2]; x=10 -[0,1,2] -[0,10,10] - -try (def x: reverse; x=10) catch . -[0,1,2] -"Invalid path expression with result [2,1,0]" - -.[] = 1 -[1,null,Infinity,-Infinity,NaN,-NaN] -[1,1,1,1,1,1] - -# -# Conditionals -# - -[.[] | if .foo then "yep" else "nope" end] -[{"foo":0},{"foo":1},{"foo":[]},{"foo":true},{"foo":false},{"foo":null},{"foo":"foo"},{}] -["yep","yep","yep","yep","nope","nope","yep","nope"] - -[.[] | if .baz then "strange" elif .foo then "yep" else "nope" end] -[{"foo":0},{"foo":1},{"foo":[]},{"foo":true},{"foo":false},{"foo":null},{"foo":"foo"},{}] -["yep","yep","yep","yep","nope","nope","yep","nope"] - -[if 1,null,2 then 3 else 4 end] -null -[3,4,3] - -[if empty then 3 else 4 end] -null -[] - -[if 1 then 3,4 else 5 end] -null -[3,4] - -[if null then 3 else 5,6 end] -null -[5,6] - -[if true then 3 end] -7 -[3] - -[if false then 3 end] -7 -[7] - -[if false then 3 else . end] -7 -[7] - -[if false then 3 elif false then 4 end] -7 -[7] - -[if false then 3 elif false then 4 else . end] -7 -[7] - -[-if true then 1 else 2 end] -null -[-1] - -{x: if true then 1 else 2 end} -null -{"x":1} - -if true then [.] else . end [] -null -null - -[.[] | [.foo[] // .bar]] -[{"foo":[1,2], "bar": 42}, {"foo":[1], "bar": null}, {"foo":[null,false,3], "bar": 18}, {"foo":[], "bar":42}, {"foo": [null,false,null], "bar": 41}] -[[1,2], [1], [3], [42], [41]] - -.[] //= .[0] -["hello",true,false,[false],null] -["hello",true,"hello",[false],"hello"] - -.[] | [.[0] and .[1], .[0] or .[1]] -[[true,[]], [false,1], [42,null], [null,false]] -[true,true] -[false,true] -[false,true] -[false,false] - -[.[] | not] -[1,0,false,null,true,"hello"] -[false,false,true,true,false,false] - -# Check numeric comparison binops -[10 > 0, 10 > 10, 10 > 20, 10 < 0, 10 < 10, 10 < 20] -{} -[true,false,false,false,false,true] - -[10 >= 0, 10 >= 10, 10 >= 20, 10 <= 0, 10 <= 10, 10 <= 20] -{} -[true,true,false,false,true,true] - -# And some in/equality tests -[ 10 == 10, 10 != 10, 10 != 11, 10 == 11] -{} -[true,false,true,false] - -["hello" == "hello", "hello" != "hello", "hello" == "world", "hello" != "world" ] -{} -[true,false,false,true] - -[[1,2,3] == [1,2,3], [1,2,3] != [1,2,3], [1,2,3] == [4,5,6], [1,2,3] != [4,5,6]] -{} -[true,false,false,true] - -[{"foo":42} == {"foo":42},{"foo":42} != {"foo":42}, {"foo":42} != {"bar":42}, {"foo":42} == {"bar":42}] -{} -[true,false,true,false] - -# ugly complicated thing -[{"foo":[1,2,{"bar":18},"world"]} == {"foo":[1,2,{"bar":18},"world"]},{"foo":[1,2,{"bar":18},"world"]} == {"foo":[1,2,{"bar":19},"world"]}] -{} -[true,false] - -# containment operator -[("foo" | contains("foo")), ("foobar" | contains("foo")), ("foo" | contains("foobar"))] -{} -[true, true, false] - -# containment operator (embedded NULs!) -[contains(""), contains("\u0000")] -"\u0000" -[true, true] - -[contains(""), contains("a"), contains("ab"), contains("c"), contains("d")] -"ab\u0000cd" -[true, true, true, true, true] - -[contains("cd"), contains("b\u0000"), contains("ab\u0000")] -"ab\u0000cd" -[true, true, true] - -[contains("b\u0000c"), contains("b\u0000cd"), contains("b\u0000cd")] -"ab\u0000cd" -[true, true, true] - -[contains("@"), contains("\u0000@"), contains("\u0000what")] -"ab\u0000cd" -[false, false, false] - - -# Try/catch and general `?` operator -[.[]|try if . == 0 then error("foo") elif . == 1 then .a elif . == 2 then empty else . end catch .] -[0,1,2,3] -["foo","Cannot index number with string \"a\"",3] - -[.[]|(.a, .a)?] -[null,true,{"a":1}] -[null,null,1,1] - -[[.[]|[.a,.a]]?] -[null,true,{"a":1}] -[] - -[if error then 1 else 2 end?] -"foo" -[] - -try error(0) // 1 -null -1 - -1, try error(2), 3 -null -1 -3 - -1 + try 2 catch 3 + 4 -null -7 - -[-try .] -1 -[-1] - -try -.? catch . -"foo" -"string (\"foo\") cannot be negated" - -{x: try 1, y: try error catch 2, z: if true then 3 end} -null -{"x":1,"y":2,"z":3} - -{x: 1 + 2, y: false or true, z: null // 3} -null -{"x":3,"y":true,"z":3} - -.[] | try error catch . -[1,null,2] -1 -null -2 - -try error("\($__loc__)") catch . -null -"{\"file\":\"\",\"line\":1}" - -# string operations -[.[]|startswith("foo")] -["fo", "foo", "barfoo", "foobar", "barfoob"] -[false, true, false, true, false] - -[.[]|endswith("foo")] -["fo", "foo", "barfoo", "foobar", "barfoob"] -[false, true, true, false, false] - -[.[] | split(", ")] -["a,b, c, d, e,f",", a,b, c, d, e,f, "] -[["a,b","c","d","e,f"],["","a,b","c","d","e,f",""]] - -split("") -"abc" -["a","b","c"] - -[.[]|ltrimstr("foo")] -["fo", "foo", "barfoo", "foobar", "afoo"] -["fo","","barfoo","bar","afoo"] - -[.[]|rtrimstr("foo")] -["fo", "foo", "barfoo", "foobar", "foob"] -["fo","","bar","foobar","foob"] - -[.[]|trimstr("foo")] -["fo", "foo", "barfoo", "foobarfoo", "foob"] -["fo","","bar","bar","b"] - -[.[]|ltrimstr("")] -["a", "xx", ""] -["a", "xx", ""] - -[.[]|rtrimstr("")] -["a", "xx", ""] -["a", "xx", ""] - -[.[]|trimstr("")] -["a", "xx", ""] -["a", "xx", ""] - -[(index(","), rindex(",")), indices(",")] -"a,bc,def,ghij,klmno" -[1,13,[1,4,8,13]] - -[ index("aba"), rindex("aba"), indices("aba") ] -"xababababax" -[1,7,[1,3,5,7]] - -# trim -# \u000b is vertical tab (\v not supported by json) -map(trim), map(ltrim), map(rtrim) -[" \n\t\r\f\u000b", ""," ", "a", " a ", "abc", " abc ", " abc", "abc "] -["", "", "", "a", "a", "abc", "abc", "abc", "abc"] -["", "", "", "a", "a ", "abc", "abc ", "abc", "abc "] -["", "", "", "a", " a", "abc", " abc", " abc", "abc"] - -trim, ltrim, rtrim -"\u0009\u000A\u000B\u000C\u000D\u0020\u0085\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000abc\u0009\u000A\u000B\u000C\u000D\u0020\u0085\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000" -"abc" -"abc\u0009\u000A\u000B\u000C\u000D\u0020\u0085\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000" -"\u0009\u000A\u000B\u000C\u000D\u0020\u0085\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000abc" - -try trim catch ., try ltrim catch ., try rtrim catch . -123 -"trim input must be a string" -"trim input must be a string" -"trim input must be a string" - -indices(1) -[0,1,1,2,3,4,1,5] -[1,2,6] - -indices([1,2]) -[0,1,2,3,1,4,2,5,1,2,6,7] -[1,8] - -indices([1,2]) -[1] -[] - -indices(", ") -"a,b, cd,e, fgh, ijkl" -[3,9,14] - -index("!") -"здравствуй мир!" -14 - -.[:rindex("x")] -"正xyz" -"正" - -indices("o") -"🇬🇧oo" -[2,3] - -indices("o") -"ƒoo" -[1,2] - -[.[]|split(",")] -["a, bc, def, ghij, jklmn, a,b, c,d, e,f", "a,b,c,d, e,f,g,h"] -[["a"," bc"," def"," ghij"," jklmn"," a","b"," c","d"," e","f"],["a","b","c","d"," e","f","g","h"]] - -[.[]|split(", ")] -["a, bc, def, ghij, jklmn, a,b, c,d, e,f", "a,b,c,d, e,f,g,h"] -[["a","bc","def","ghij","jklmn","a,b","c,d","e,f"],["a,b,c,d","e,f,g,h"]] - -[.[] * 3] -["a", "ab", "abc"] -["aaa", "ababab", "abcabcabc"] - -[.[] * "abc"] -[-1.0, -0.5, 0.0, 0.5, 1.0, 1.5, 3.7, 10.0] -[null,null,"","","abc","abc","abcabcabc","abcabcabcabcabcabcabcabcabcabc"] - -[. * (nan,-nan)] -"abc" -[null,null] - -. * 100000 | [.[:10],.[-10:]] -"abc" -["abcabcabca","cabcabcabc"] - -. * 1000000000 -"" -"" - -try (. * 1000000000) catch . -"abc" -"Repeat string result too long" - -[.[] / ","] -["a, bc, def, ghij, jklmn, a,b, c,d, e,f", "a,b,c,d, e,f,g,h"] -[["a"," bc"," def"," ghij"," jklmn"," a","b"," c","d"," e","f"],["a","b","c","d"," e","f","g","h"]] - -[.[] / ", "] -["a, bc, def, ghij, jklmn, a,b, c,d, e,f", "a,b,c,d, e,f,g,h"] -[["a","bc","def","ghij","jklmn","a,b","c,d","e,f"],["a,b,c,d","e,f,g,h"]] - -map(.[1] as $needle | .[0] | contains($needle)) -[[[],[]], [[1,2,3], [1,2]], [[1,2,3], [3,1]], [[1,2,3], [4]], [[1,2,3], [1,4]]] -[true, true, true, false, false] - -map(.[1] as $needle | .[0] | contains($needle)) -[[["foobar", "foobaz"], ["baz", "bar"]], [["foobar", "foobaz"], ["foo"]], [["foobar", "foobaz"], ["blap"]]] -[true, true, false] - -[({foo: 12, bar:13} | contains({foo: 12})), ({foo: 12} | contains({})), ({foo: 12, bar:13} | contains({baz:14}))] -{} -[true, true, false] - -{foo: {baz: 12, blap: {bar: 13}}, bar: 14} | contains({bar: 14, foo: {blap: {}}}) -{} -true - -{foo: {baz: 12, blap: {bar: 13}}, bar: 14} | contains({bar: 14, foo: {blap: {bar: 14}}}) -{} -false - -sort -[42,[2,5,3,11],10,{"a":42,"b":2},{"a":42},true,2,[2,6],"hello",null,[2,5,6],{"a":[],"b":1},"abc","ab",[3,10],{},false,"abcd",null] -[null,null,false,true,2,10,42,"ab","abc","abcd","hello",[2,5,3,11],[2,5,6],[2,6],[3,10],{},{"a":42},{"a":42,"b":2},{"a":[],"b":1}] - -(sort_by(.b) | sort_by(.a)), sort_by(.a, .b), sort_by(.b, .c), group_by(.b), group_by(.a + .b - .c == 2) -[{"a": 1, "b": 4, "c": 14}, {"a": 4, "b": 1, "c": 3}, {"a": 1, "b": 4, "c": 3}, {"a": 0, "b": 2, "c": 43}] -[{"a": 0, "b": 2, "c": 43}, {"a": 1, "b": 4, "c": 14}, {"a": 1, "b": 4, "c": 3}, {"a": 4, "b": 1, "c": 3}] -[{"a": 0, "b": 2, "c": 43}, {"a": 1, "b": 4, "c": 14}, {"a": 1, "b": 4, "c": 3}, {"a": 4, "b": 1, "c": 3}] -[{"a": 4, "b": 1, "c": 3}, {"a": 0, "b": 2, "c": 43}, {"a": 1, "b": 4, "c": 3}, {"a": 1, "b": 4, "c": 14}] -[[{"a": 4, "b": 1, "c": 3}], [{"a": 0, "b": 2, "c": 43}], [{"a": 1, "b": 4, "c": 14}, {"a": 1, "b": 4, "c": 3}]] -[[{"a": 1, "b": 4, "c": 14}, {"a": 0, "b": 2, "c": 43}], [{"a": 4, "b": 1, "c": 3}, {"a": 1, "b": 4, "c": 3}]] - -unique -[1,2,5,3,5,3,1,3] -[1,2,3,5] - -unique -[] -[] - -[min, max, min_by(.[1]), max_by(.[1]), min_by(.[2]), max_by(.[2])] -[[4,2,"a"],[3,1,"a"],[2,4,"a"],[1,3,"a"]] -[[1,3,"a"],[4,2,"a"],[3,1,"a"],[2,4,"a"],[4,2,"a"],[1,3,"a"]] - -[min,max,min_by(.),max_by(.)] -[] -[null,null,null,null] - -.foo[.baz] -{"foo":{"bar":4},"baz":"bar"} -4 - -.[] | .error = "no, it's OK" -[{"error":true}] -{"error": "no, it's OK"} - -[{a:1}] | .[] | .a=999 -null -{"a": 999} - -to_entries -{"a": 1, "b": 2} -[{"key":"a", "value":1}, {"key":"b", "value":2}] - -from_entries -[{"key":"a", "value":1}, {"Key":"b", "Value":2}, {"name":"c", "value":3}, {"Name":"d", "Value":4}] -{"a": 1, "b": 2, "c": 3, "d": 4} - -with_entries(.key |= "KEY_" + .) -{"a": 1, "b": 2} -{"KEY_a": 1, "KEY_b": 2} - -map(has("foo")) -[{"foo": 42}, {}] -[true, false] - -map(has(2)) -[[0,1], ["a","b","c"]] -[false, true] - -has(nan) -[0,1,2] -false - -keys -[42,3,35] -[0,1,2] - -[][.] -1000000000000000000 -null - -map([1,2][0:.]) -[-1, 1, 2, 3, 1000000000000000000] -[[1], [1], [1,2], [1,2], [1,2]] - -# Test recursive object merge - -{"k": {"a": 1, "b": 2}} * . -{"k": {"a": 0,"c": 3}} -{"k": {"a": 0, "b": 2, "c": 3}} - -{"k": {"a": 1, "b": 2}, "hello": {"x": 1}} * . -{"k": {"a": 0,"c": 3}, "hello": 1} -{"k": {"a": 0, "b": 2, "c": 3}, "hello": 1} - -{"k": {"a": 1, "b": 2}, "hello": 1} * . -{"k": {"a": 0,"c": 3}, "hello": {"x": 1}} -{"k": {"a": 0, "b": 2, "c": 3}, "hello": {"x": 1}} - -{"a": {"b": 1}, "c": {"d": 2}, "e": 5} * . -{"a": {"b": 2}, "c": {"d": 3, "f": 9}} -{"a": {"b": 2}, "c": {"d": 3, "f": 9}, "e": 5} - -[.[]|arrays] -[1,2,"foo",[],[3,[]],{},true,false,null] -[[],[3,[]]] - -[.[]|objects] -[1,2,"foo",[],[3,[]],{},true,false,null] -[{}] - -[.[]|iterables] -[1,2,"foo",[],[3,[]],{},true,false,null] -[[],[3,[]],{}] - -[.[]|scalars] -[1,2,"foo",[],[3,[]],{},true,false,null] -[1,2,"foo",true,false,null] - -[.[]|values] -[1,2,"foo",[],[3,[]],{},true,false,null] -[1,2,"foo",[],[3,[]],{},true,false] - -[.[]|booleans] -[1,2,"foo",[],[3,[]],{},true,false,null] -[true,false] - -[.[]|nulls] -[1,2,"foo",[],[3,[]],{},true,false,null] -[null] - -flatten -[0, [1], [[2]], [[[3]]]] -[0, 1, 2, 3] - -flatten(0) -[0, [1], [[2]], [[[3]]]] -[0, [1], [[2]], [[[3]]]] - -flatten(2) -[0, [1], [[2]], [[[3]]]] -[0, 1, 2, [3]] - -flatten(2) -[0, [1, [2]], [1, [[3], 2]]] -[0, 1, 2, 1, [3], 2] - -try flatten(-1) catch . -[0, [1], [[2]], [[[3]]]] -"flatten depth must not be negative" - -transpose -[[1], [2,3]] -[[1,2],[null,3]] - -transpose -[] -[] - -ascii_upcase -"useful but not for é" -"USEFUL BUT NOT FOR é" - -bsearch(0,1,2,3,4) -[1,2,3] --1 -0 -1 -2 --4 - -bsearch({x:1}) -[{ "x": 0 },{ "x": 1 },{ "x": 2 }] -1 - -try ["OK", bsearch(0)] catch ["KO",.] -"aa" -["KO","string (\"aa\") cannot be searched from"] - -strftime("%Y-%m-%dT%H:%M:%SZ") -[2015,2,5,23,51,47,4,63] -"2015-03-05T23:51:47Z" - -strftime("%A, %B %d, %Y") -1435677542.822351 -"Tuesday, June 30, 2015" - -strftime("%Y-%m-%dT%H:%M:%SZ") -[2024,2,15] -"2024-03-15T00:00:00Z" - -mktime -[2024,8,21] -1726876800 - -gmtime -1425599507 -[2015,2,5,23,51,47,4,63] - -# test invalid tm input -try strftime("%Y-%m-%dT%H:%M:%SZ") catch . -["a",1,2,3,4,5,6,7] -"strftime/1 requires parsed datetime inputs" - -try strflocaltime("%Y-%m-%dT%H:%M:%SZ") catch . -["a",1,2,3,4,5,6,7] -"strflocaltime/1 requires parsed datetime inputs" - -try mktime catch . -["a",1,2,3,4,5,6,7] -"mktime requires parsed datetime inputs" - -# oss-fuzz #67403: non-string argument with number input fails assert -try ["OK", strftime([])] catch ["KO", .] -0 -["KO","strftime/1 requires a string format"] - -try ["OK", strflocaltime({})] catch ["KO", .] -0 -["KO","strflocaltime/1 requires a string format"] - -[strptime("%Y-%m-%dT%H:%M:%SZ")|(.,mktime)] -"2015-03-05T23:51:47Z" -[[2015,2,5,23,51,47,4,63],1425599507] - -# Check day-of-week and day of year computations -# (should trip an assert if this fails) -last(range(365 * 67)|("1970-03-01T01:02:03Z"|strptime("%Y-%m-%dT%H:%M:%SZ")|mktime) + (86400 * .)|strftime("%Y-%m-%dT%H:%M:%SZ")|strptime("%Y-%m-%dT%H:%M:%SZ")) -null -[2037,1,11,1,2,3,3,41] - -# module system -import "a" as foo; import "b" as bar; def fooa: foo::a; [fooa, bar::a, bar::b, foo::a] -null -["a","b","c","a"] - -import "c" as foo; [foo::a, foo::c] -null -[0,"acmehbah"] - -include "c"; [a, c] -null -[0,"acmehbah"] - -import "data" as $e; import "data" as $d; [$d[].this,$e[].that,$d::d[].this,$e::e[].that]|join(";") -null -"is a test;is too;is a test;is too" - -# Regression test for #2000 -import "data" as $a; import "data" as $b; def f: {$a, $b}; f -null -{"a":[{"this":"is a test","that":"is too"}],"b":[{"this":"is a test","that":"is too"}]} - -include "shadow1"; e -null -2 - -include "shadow1"; include "shadow2"; e -null -3 - -import "shadow1" as f; import "shadow2" as f; import "shadow1" as e; [e::e, f::e] -null -[2,3] - -%%FAIL -module (.+1); 0 -jq: error: Module metadata must be constant at , line 1, column 8: - module (.+1); 0 - ^^^^^ - -%%FAIL -module []; 0 -jq: error: Module metadata must be an object at , line 1, column 8: - module []; 0 - ^^ - -%%FAIL -include "a" (.+1); 0 -jq: error: Module metadata must be constant at , line 1, column 13: - include "a" (.+1); 0 - ^^^^^ - -%%FAIL -include "a" []; 0 -jq: error: Module metadata must be an object at , line 1, column 13: - include "a" []; 0 - ^^ - -%%FAIL -include "\ "; 0 -jq: error: Invalid escape at line 1, column 4 (while parsing '"\ "') at , line 1, column 10: - include "\ "; 0 - ^^ - -%%FAIL -include "\(a)"; 0 -jq: error: Import path must be constant at , line 1, column 9: - include "\(a)"; 0 - ^^^^^^ - -modulemeta -"c" -{"whatever":null,"deps":[{"as":"foo","is_data":false,"relpath":"a"},{"search":"./","as":"d","is_data":false,"relpath":"d"},{"search":"./","as":"d2","is_data":false,"relpath":"d"},{"search":"./../lib/jq","as":"e","is_data":false,"relpath":"e"},{"search":"./../lib/jq","as":"f","is_data":false,"relpath":"f"},{"as":"d","is_data":true,"relpath":"data"}],"defs":["a/0","c/0"]} - -modulemeta | .deps | length -"c" -6 - -modulemeta | .defs | length -"c" -2 - -%%FAIL IGNORE MSG -import "syntaxerror" as e; . -jq: error: syntax error, unexpected ';', expecting end of file at tests/modules/syntaxerror/syntaxerror.jq, line 1, column 4: - wat; - ^ - -%%FAIL -%::wat -jq: error: syntax error, unexpected '%', expecting end of file at , line 1, column 1: - %::wat - ^ - -import "test_bind_order" as check; check::check -null -true - -try -. catch . -"very-long-string" -"string (\"very-long-...) cannot be negated" - -try (.-.) catch . -"very-long-string" -"string (\"very-long-...) and string (\"very-long-...) cannot be subtracted" - -"x" * range(0; 12; 2) + "☆" * 5 | try -. catch . -null -"string (\"☆☆☆...) cannot be negated" -"string (\"xx☆☆...) cannot be negated" -"string (\"xxxx☆☆...) cannot be negated" -"string (\"xxxxxx☆...) cannot be negated" -"string (\"xxxxxxxx...) cannot be negated" -"string (\"xxxxxxxxxx...) cannot be negated" - -join(",") -["1",2,true,false,3.4] -"1,2,true,false,3.4" - -.[] | join(",") -[[], [null], [null,null], [null,null,null]] -"" -"" -"," -",," - -.[] | join(",") -[["a",null], [null,"a"]] -"a," -",a" - -try join(",") catch . -["1","2",{"a":{"b":{"c":33}}}] -"string (\"1,2,\") and object ({\"a\":{\"b\":{...) cannot be added" - -try join(",") catch . -["1","2",[3,4,5]] -"string (\"1,2,\") and array ([3,4,5]) cannot be added" - -{if:0,and:1,or:2,then:3,else:4,elif:5,end:6,as:7,def:8,reduce:9,foreach:10,try:11,catch:12,label:13,import:14,include:15,module:16} -null -{"if":0,"and":1,"or":2,"then":3,"else":4,"elif":5,"end":6,"as":7,"def":8,"reduce":9,"foreach":10,"try":11,"catch":12,"label":13,"import":14,"include":15,"module":16} - -try (1/.) catch . -0 -"number (1) and number (0) cannot be divided because the divisor is zero" - -try (1/0) catch . -0 -"number (1) and number (0) cannot be divided because the divisor is zero" - -try (0/0) catch . -0 -"number (0) and number (0) cannot be divided because the divisor is zero" - -try (1%.) catch . -0 -"number (1) and number (0) cannot be divided (remainder) because the divisor is zero" - -try (1%0) catch . -0 -"number (1) and number (0) cannot be divided (remainder) because the divisor is zero" - -# Basic numbers tests: integers, powers of two -[range(-52;52;1)] as $powers | [$powers[]|pow(2;.)|log2|round] == $powers -null -true - -[range(-99/2;99/2;1)] as $orig | [$orig[]|pow(2;.)|log2] as $back | ($orig|keys)[]|. as $k | (($orig|.[$k])-($back|.[$k]))|if . < 0 then . * -1 else . end|select(.>.00005) -null - -%%FAIL -{ -jq: error: syntax error, unexpected end of file at , line 1, column 1: - { - ^ - -%%FAIL -} -jq: error: syntax error, unexpected INVALID_CHARACTER, expecting end of file at , line 1, column 1: - } - ^ - -(.[{}] = 0)? -null - -INDEX(range(5)|[., "foo\(.)"]; .[0]) -null -{"0":[0,"foo0"],"1":[1,"foo1"],"2":[2,"foo2"],"3":[3,"foo3"],"4":[4,"foo4"]} - -JOIN({"0":[0,"abc"],"1":[1,"bcd"],"2":[2,"def"],"3":[3,"efg"],"4":[4,"fgh"]}; .[0]|tostring) -[[5,"foo"],[3,"bar"],[1,"foobar"]] -[[[5,"foo"],null],[[3,"bar"],[3,"efg"]],[[1,"foobar"],[1,"bcd"]]] - -range(5;10)|IN(range(10)) -null -true -true -true -true -true - -range(5;13)|IN(range(0;10;3)) -null -false -true -false -false -true -false -false -false - -range(10;12)|IN(range(10)) -null -false -false - -IN(range(10;20); range(10)) -null -false - -IN(range(5;20); range(10)) -null -true - -# Regression test for #1347 -(.a as $x | .b) = "b" -{"a":null,"b":null} -{"a":null,"b":"b"} - -# Regression test for #1368 -(.. | select(type == "object" and has("b") and (.b | type) == "array")|.b) |= .[0] -{"a": {"b": [1, {"b": 3}]}} -{"a": {"b": 1}} - -isempty(empty) -null -true - -isempty(range(3)) -null -false - -isempty(1,error("foo")) -null -false - -# Regression test for #1815 -index("") -"" -null - -# check that dead code removal occurs after builtin it generation -builtins|length > 10 -null -true - -"-1"|IN(builtins[] / "/"|.[1]) -null -false - -all(builtins[] / "/"; .[1]|tonumber >= 0) -null -true - -builtins|any(.[:1] == "_") -null -false - -## Test ability to use keywords (uncomment after eval is pushed) -#(.[] as $kw | "\"{\($kw)} as $\($kw) | $\($kw) | {$\($kw)} | {\($kw):.\($kw)}\""|eval|empty),null -#["as","def","module","import","include","if","then","else","elif","end","reduce","foreach","and","or","try","catch","label","break","__loc__"] -#null -# -#(.[] as $kw | "\"def f($\($kw)): $\($kw); f(.)\""|eval|empty),null -#["as","def","module","import","include","if","then","else","elif","end","reduce","foreach","and","or","try","catch","label","break","__loc__"] -#null - - -# -# Tests to cover the new toliteral number functionality -# For an example see #1652 and other linked issues -# - -# We are backward and sanity compatible - -map(. == 1) -[1, 1.0, 1.000, 100e-2, 1e+0, 0.0001e4] -[true, true, true, true, true, true] - -# When no arithmetic is involved jq should preserve the literal value - -.[0] | tostring | . == if have_decnum then "13911860366432393" else "13911860366432392" end -[13911860366432393] -true - -.x | tojson | . == if have_decnum then "13911860366432393" else "13911860366432392" end -{"x":13911860366432393} -true - -(13911860366432393 == 13911860366432392) | . == if have_decnum then false else true end -null -true - - -# Applying arithmetic to the value will truncate the result to double - -. - 10 -13911860366432393 -13911860366432382 - -.[0] - 10 -[13911860366432393] -13911860366432382 - -.x - 10 -{"x":13911860366432393} -13911860366432382 - -# Unary negation preserves numerical precision --. | tojson == if have_decnum then "-13911860366432393" else "-13911860366432392" end -13911860366432393 -true - --. | tojson == if have_decnum then "0.12345678901234567890123456789" else "0.12345678901234568" end --0.12345678901234567890123456789 -true - -[1E+1000,-1E+1000 | tojson] == if have_decnum then ["1E+1000","-1E+1000"] else ["1.7976931348623157e+308","-1.7976931348623157e+308"] end -null -true - -. |= try . catch . -1 -1 - -# decnum to double conversion -.[] as $n | $n+0 | [., tostring, . == $n] -[-9007199254740993, -9007199254740992, 9007199254740992, 9007199254740993, 13911860366432393] -[-9007199254740992,"-9007199254740992",true] -[-9007199254740992,"-9007199254740992",true] -[9007199254740992,"9007199254740992",true] -[9007199254740992,"9007199254740992",true] -[13911860366432392,"13911860366432392",true] - -# abs, fabs, length -abs -"abc" -"abc" - -map(abs) -[-0, 0, -10, -1.1] -[0,0,10,1.1] - -map(fabs) -[-0, 0, -10, -1.1] -[0,0,10,1.1] - -map(abs == length) | unique -[-10, -1.1, -1e-1, 1000000000000000002] -[true] - -# The following is NOT prescriptive: -map(abs) -[0.1,1000000000000000002] -[1e-1, 1000000000000000002] - -[1E+1000,-1E+1000 | abs | tojson] | unique == if have_decnum then ["1E+1000"] else ["1.7976931348623157e+308"] end -null -true - -[1E+1000,-1E+1000 | length | tojson] | unique == if have_decnum then ["1E+1000"] else ["1.7976931348623157e+308"] end -null -true - -# Using a keyword as variable/label name - -123 as $label | $label -null -123 - -[ label $if | range(10) | ., (select(. == 5) | break $if) ] -null -[0,1,2,3,4,5] - -reduce .[] as $then (4 as $else | $else; . as $elif | . + $then * $elif) -[1,2,3] -96 - -1 as $foreach | 2 as $and | 3 as $or | { $foreach, $and, $or, a } -{"a":4,"b":5} -{"foreach":1,"and":2,"or":3,"a":4} - -[ foreach .[] as $try (1 as $catch | $catch - 1; . + $try; .) ] -[10,9,8,7] -[10,19,27,34] - - -# Object construction - -{ a, $__loc__, c } -{"a":[1,2,3],"b":"foo","c":{"hi":"hey"}} -{"a":[1,2,3],"__loc__":{"file":"","line":1},"c":{"hi":"hey"}} - -1 as $x | "2" as $y | "3" as $z | { $x, as, $y: 4, ($z): 5, if: 6, foo: 7 } -{"as":8} -{"x":1,"as":8,"2":4,"3":5,"if":6,"foo":7} - - -# nan is parsed as a valid NaN value from JSON - -fromjson | isnan -"nan" -true - -tojson | fromjson -{"a":nan} -{"a":null} - -# NaN with payload is not parsed -.[] | try (fromjson | isnan) catch . -["NaN","-NaN","NaN1","NaN10","NaN100","NaN1000","NaN10000","NaN100000"] -true -true -"Invalid numeric literal at EOF at line 1, column 4 (while parsing 'NaN1')" -"Invalid numeric literal at EOF at line 1, column 5 (while parsing 'NaN10')" -"Invalid numeric literal at EOF at line 1, column 6 (while parsing 'NaN100')" -"Invalid numeric literal at EOF at line 1, column 7 (while parsing 'NaN1000')" -"Invalid numeric literal at EOF at line 1, column 8 (while parsing 'NaN10000')" -"Invalid numeric literal at EOF at line 1, column 9 (while parsing 'NaN100000')" - -# calling input/0, or debug/0 in a test doesn't crash jq - -try input catch . -null -"break" - -debug -1 -1 - -# try/catch catches more than it should #1859 -"foo" | try ((try . catch "caught too much") | error) catch "caught just right" -null -"caught just right" - -.[]|(try (if .=="hi" then . else error end) catch empty) | "\(.) there!" -["hi","ho"] -"hi there!" - -try (["hi","ho"]|.[]|(try . catch (if .=="ho" then "BROKEN"|error else empty end)) | if .=="ho" then error else "\(.) there!" end) catch "caught outside \(.)" -null -"hi there!" -"caught outside ho" - -.[]|(try . catch (if .=="ho" then "BROKEN"|error else empty end)) | if .=="ho" then error else "\(.) there!" end -["hi","ho"] -"hi there!" - -try (try error catch "inner catch \(.)") catch "outer catch \(.)" -"foo" -"inner catch foo" - -try ((try error catch "inner catch \(.)")|error) catch "outer catch \(.)" -"foo" -"outer catch inner catch foo" - -# Also #1859, but from #1885 -first(.?,.?) -null -null - -# Also #1859, but from #2140 -{foo: "bar"} | .foo |= .? -null -{"foo": "bar"} - -# Also #1859, but from #2220 -. |= try 2 -1 -2 - -. |= try 2 catch 3 -1 -2 - -.[] |= try tonumber -["1", "2a", "3", " 4", "5 ", "6.7", ".89", "-876", "+5.43", 21] -[1, 3, 6.7, 0.89, -876, 5.43, 21] - -# Also 1859, but from 2073 -any(keys[]|tostring?;true) -{"a":"1","b":"2","c":"3"} -true - - -# explode/implode -# test replacement character (65533) for outside codepoint range and 0xd800 (55296) - 0xdfff (57343) utf16 surrogate pair range -# 1.1 and 1.9 to test round down of non-ints -implode|explode -[-1,0,1,2,3,1114111,1114112,55295,55296,57343,57344,1.1,1.9] -[65533,0,1,2,3,1114111,65533,55295,65533,65533,57344,1,1] - -map(try implode catch .) -[123,["a"],[nan]] -["implode input must be an array","string (\"a\") can't be imploded, unicode codepoint needs to be numeric","number (null) can't be imploded, unicode codepoint needs to be numeric"] - -try 0[implode] catch . -[] -"Cannot index number with string \"\"" - -# walk -walk(.) -{"x":0} -{"x":0} - -walk(1) -{"x":0} -1 - -# The following is a regression test, not a requirement: -[walk(.,1)] -{"x":0} -[{"x":0},1] - -# Issue #2584 -walk(select(IN({}, []) | not)) -{"a":1,"b":[]} -{"a":1} - -# #2815 -[range(10)] | .[1.2:3.5] -null -[1,2,3] - -[range(10)] | .[1.5:3.5] -null -[1,2,3] - -[range(10)] | .[1.7:3.5] -null -[1,2,3] - -[range(10)] | .[1.7:4294967295] -null -[1,2,3,4,5,6,7,8,9] - -[range(10)] | .[1.7:-4294967296] -null -[] - -[[range(10)] | .[1.1,1.5,1.7]] -null -[1,1,1] - -[range(5)] | .[1.1] = 5 -null -[0,5,2,3,4] - -[range(3)] | .[nan:1] -null -[0] - -[range(3)] | .[1:nan] -null -[1,2] - -[range(3)] | .[nan] -null -null - -try ([range(3)] | .[nan] = 9) catch . -null -"Cannot set array element at NaN index" - -try ("foobar" | .[1.5:3.5] = "xyz") catch . -null -"Cannot update string slices" - -try ([range(10)] | .[1.5:3.5] = ["xyz"]) catch . -null -[0,"xyz",4,5,6,7,8,9] - -try ("foobar" | .[1.5]) catch . -null -"Cannot index string with number" - - -# setpath/2 does not leak the input after an invalid get #2970 - -try ["ok", setpath([1]; 1)] catch ["ko", .] -{"hi":"hello"} -["ko","Cannot index object with number"] - -try fromjson catch . -"{'a': 123}" -"Invalid string literal; expected \", but got ' at line 1, column 5 (while parsing '{'a': 123}')" - -# ltrimstr/1 rtrimstr/1 don't leak on invalid input #2977 - -try ltrimstr(1) catch "x", try rtrimstr(1) catch "x" | "ok" -"hi" -"ok" -"ok" - -try ltrimstr("x") catch "x", try rtrimstr("x") catch "x" | "ok" -{"hey":[]} -"ok" -"ok" - -# ltrimstr/1 and rtrimstr/1 return an error for non-strings. #2969 - -.[] as [$x, $y] | try ["ok", ($x | ltrimstr($y))] catch ["ko", .] -[["hi",1],[1,"hi"],["hi","hi"],[1,1]] -["ko","startswith() requires string inputs"] -["ko","startswith() requires string inputs"] -["ok",""] -["ko","startswith() requires string inputs"] - -.[] as [$x, $y] | try ["ok", ($x | rtrimstr($y))] catch ["ko", .] -[["hi",1],[1,"hi"],["hi","hi"],[1,1]] -["ko","endswith() requires string inputs"] -["ko","endswith() requires string inputs"] -["ok",""] -["ko","endswith() requires string inputs"] - - -# oss-fuzz #66061: setpath/2 leaks when indexing array with array - -try ["OK", setpath([[1]]; 1)] catch ["KO", .] -[] -["KO","Cannot update field at array index of array"] - -# regression test for #3227 -foreach .[] as $x (0, 1; . + $x) -[1, 2] -1 -3 -2 -4 - -# regression test for CVE-2025-49014 (use of fmt after free) -# tests with both empty string literal and empty string created by function -# as they seems to behave reference wise differently. -strflocaltime("" | ., @uri) -0 -"" -"" - -# regression tests for #3413 -# upper range bounds should be in sync with the constants defined at -# src/jv_parse.c:#define MAX_PARSING_DEPTH (N) -# src/jv_print.c:#define MAX_PRINT_DEPTH (N) -# (N-1) -reduce range(9999) as $_ ([];[.]) | tojson | fromjson | flatten -null -[] - -# (N) -reduce range(10000) as $_ ([];[.]) | tojson | try (fromjson) catch . | (contains("") | not) and contains("Exceeds depth limit for parsing") -null -true - -# (N+1) -reduce range(10001) as $_ ([];[.]) | tojson | contains("") -null -true diff --git a/src/spec-tests/jq/cases/man.test b/src/spec-tests/jq/cases/man.test deleted file mode 100644 index d815fde6..00000000 --- a/src/spec-tests/jq/cases/man.test +++ /dev/null @@ -1,994 +0,0 @@ -. -"Hello, world!" -"Hello, world!" - -. -0.12345678901234567890123456789 -0.12345678901234567890123456789 - -[., tojson] == if have_decnum then [12345678909876543212345,"12345678909876543212345"] else [12345678909876543000000,"12345678909876543000000"] end -12345678909876543212345 -true - -[1234567890987654321,-1234567890987654321 | tojson] == if have_decnum then ["1234567890987654321","-1234567890987654321"] else ["1234567890987654400","-1234567890987654400"] end -null -true - -. < 0.12345678901234567890123456788 -0.12345678901234567890123456789 -false - -map([., . == 1]) | tojson == if have_decnum then "[[1,true],[1.000,true],[1.0,true],[1.00,true]]" else "[[1,true],[1,true],[1,true],[1,true]]" end -[1, 1.000, 1.0, 100e-2] -true - -. as $big | [$big, $big + 1] | map(. > 10000000000000000000000000000000) | . == if have_decnum then [true, false] else [false, false] end -10000000000000000000000000000001 -true - -.foo -{"foo": 42, "bar": "less interesting data"} -42 - -.foo -{"notfoo": true, "alsonotfoo": false} -null - -.["foo"] -{"foo": 42} -42 - -.foo? -{"foo": 42, "bar": "less interesting data"} -42 - -.foo? -{"notfoo": true, "alsonotfoo": false} -null - -.["foo"]? -{"foo": 42} -42 - -[.foo?] -[1,2] -[] - -.[0] -[{"name":"JSON", "good":true}, {"name":"XML", "good":false}] -{"name":"JSON", "good":true} - -.[2] -[{"name":"JSON", "good":true}, {"name":"XML", "good":false}] -null - -.[-2] -[1,2,3] -2 - -.[2:4] -["a","b","c","d","e"] -["c", "d"] - -.[2:4] -"abcdefghi" -"cd" - -.[:3] -["a","b","c","d","e"] -["a", "b", "c"] - -.[-2:] -["a","b","c","d","e"] -["d", "e"] - -.[] -[{"name":"JSON", "good":true}, {"name":"XML", "good":false}] -{"name":"JSON", "good":true} -{"name":"XML", "good":false} - -.[] -[] - -.foo[] -{"foo":[1,2,3]} -1 -2 -3 - -.[] -{"a": 1, "b": 1} -1 -1 - -.foo, .bar -{"foo": 42, "bar": "something else", "baz": true} -42 -"something else" - -.user, .projects[] -{"user":"stedolan", "projects": ["jq", "wikiflow"]} -"stedolan" -"jq" -"wikiflow" - -.[4,2] -["a","b","c","d","e"] -"e" -"c" - -.[] | .name -[{"name":"JSON", "good":true}, {"name":"XML", "good":false}] -"JSON" -"XML" - -(. + 2) * 5 -1 -15 - -[.user, .projects[]] -{"user":"stedolan", "projects": ["jq", "wikiflow"]} -["stedolan", "jq", "wikiflow"] - -[ .[] | . * 2] -[1, 2, 3] -[2, 4, 6] - -{user, title: .titles[]} -{"user":"stedolan","titles":["JQ Primer", "More JQ"]} -{"user":"stedolan", "title": "JQ Primer"} -{"user":"stedolan", "title": "More JQ"} - -{(.user): .titles} -{"user":"stedolan","titles":["JQ Primer", "More JQ"]} -{"stedolan": ["JQ Primer", "More JQ"]} - -.. | .a? -[[{"a":1}]] -1 - -.a + 1 -{"a": 7} -8 - -.a + .b -{"a": [1,2], "b": [3,4]} -[1,2,3,4] - -.a + null -{"a": 1} -1 - -.a + 1 -{} -1 - -{a: 1} + {b: 2} + {c: 3} + {a: 42} -null -{"a": 42, "b": 2, "c": 3} - -4 - .a -{"a":3} -1 - -. - ["xml", "yaml"] -["xml", "yaml", "json"] -["json"] - -10 / . * 3 -5 -6 - -. / ", " -"a, b,c,d, e" -["a","b,c,d","e"] - -{"k": {"a": 1, "b": 2}} * {"k": {"a": 0,"c": 3}} -null -{"k": {"a": 0, "b": 2, "c": 3}} - -.[] | (1 / .)? -[1,0,-1] -1 --1 - -map(abs) -[-10, -1.1, -1e-1] -[10,1.1,1e-1] - -.[] | length -[[1,2], "string", {"a":2}, null, -5] -2 -6 -1 -0 -5 - -utf8bytelength -"\u03bc" -2 - -keys -{"abc": 1, "abcd": 2, "Foo": 3} -["Foo", "abc", "abcd"] - -keys -[42,3,35] -[0,1,2] - -map(has("foo")) -[{"foo": 42}, {}] -[true, false] - -map(has(2)) -[[0,1], ["a","b","c"]] -[false, true] - -.[] | in({"foo": 42}) -["foo", "bar"] -true -false - -map(in([0,1])) -[2, 0] -[false, true] - -map(.+1) -[1,2,3] -[2,3,4] - -map_values(.+1) -{"a": 1, "b": 2, "c": 3} -{"a": 2, "b": 3, "c": 4} - -map(., .) -[1,2] -[1,1,2,2] - -map_values(. // empty) -{"a": null, "b": true, "c": false} -{"b":true} - -pick(.a, .b.c, .x) -{"a": 1, "b": {"c": 2, "d": 3}, "e": 4} -{"a":1,"b":{"c":2},"x":null} - -pick(.[2], .[0], .[0]) -[1,2,3,4] -[1,null,3] - -path(.a[0].b) -null -["a",0,"b"] - -[path(..)] -{"a":[{"b":1}]} -[[],["a"],["a",0],["a",0,"b"]] - -del(.foo) -{"foo": 42, "bar": 9001, "baz": 42} -{"bar": 9001, "baz": 42} - -del(.[1, 2]) -["foo", "bar", "baz"] -["foo"] - -getpath(["a","b"]) -null -null - -[getpath(["a","b"], ["a","c"])] -{"a":{"b":0, "c":1}} -[0, 1] - -setpath(["a","b"]; 1) -null -{"a": {"b": 1}} - -setpath(["a","b"]; 1) -{"a":{"b":0}} -{"a": {"b": 1}} - -setpath([0,"a"]; 1) -null -[{"a":1}] - -delpaths([["a","b"]]) -{"a":{"b":1},"x":{"y":2}} -{"a":{},"x":{"y":2}} - -to_entries -{"a": 1, "b": 2} -[{"key":"a", "value":1}, {"key":"b", "value":2}] - -from_entries -[{"key":"a", "value":1}, {"key":"b", "value":2}] -{"a": 1, "b": 2} - -with_entries(.key |= "KEY_" + .) -{"a": 1, "b": 2} -{"KEY_a": 1, "KEY_b": 2} - -map(select(. >= 2)) -[1,5,3,0,7] -[5,3,7] - -.[] | select(.id == "second") -[{"id": "first", "val": 1}, {"id": "second", "val": 2}] -{"id": "second", "val": 2} - -.[]|numbers -[[],{},1,"foo",null,true,false] -1 - -1, empty, 2 -null -1 -2 - -[1,2,empty,3] -null -[1,2,3] - -try error catch . -"error message" -"error message" - -try error("invalid value: \(.)") catch . -42 -"invalid value: 42" - -try error("\($__loc__)") catch . -null -"{\"file\":\"\",\"line\":1}" - -[paths] -[1,[[],{"a":2}]] -[[0],[1],[1,0],[1,1],[1,1,"a"]] - -[paths(type == "number")] -[1,[[],{"a":2}]] -[[0],[1,1,"a"]] - -add -["a","b","c"] -"abc" - -add -[1, 2, 3] -6 - -add -[] -null - -add(.[].a) -[{"a":3}, {"a":5}, {"b":6}] -8 - -any -[true, false] -true - -any -[false, false] -false - -any -[] -false - -all -[true, false] -false - -all -[true, true] -true - -all -[] -true - -flatten -[1, [2], [[3]]] -[1, 2, 3] - -flatten(1) -[1, [2], [[3]]] -[1, 2, [3]] - -flatten -[[]] -[] - -flatten -[{"foo": "bar"}, [{"foo": "baz"}]] -[{"foo": "bar"}, {"foo": "baz"}] - -range(2; 4) -null -2 -3 - -[range(2; 4)] -null -[2,3] - -[range(4)] -null -[0,1,2,3] - -[range(0; 10; 3)] -null -[0,3,6,9] - -[range(0; 10; -1)] -null -[] - -[range(0; -5; -1)] -null -[0,-1,-2,-3,-4] - -floor -3.14159 -3 - -sqrt -9 -3 - -.[] | tonumber -[1, "1"] -1 -1 - -.[] | toboolean -["true", "false", true, false] -true -false -true -false - -.[] | tostring -[1, "1", [1]] -"1" -"1" -"[1]" - -map(type) -[0, false, [], {}, null, "hello"] -["number", "boolean", "array", "object", "null", "string"] - -.[] | (infinite * .) < 0 -[-1, 1] -true -false - -infinite, nan | type -null -"number" -"number" - -sort -[8,3,null,6] -[null,3,6,8] - -sort_by(.foo) -[{"foo":4, "bar":10}, {"foo":3, "bar":10}, {"foo":2, "bar":1}] -[{"foo":2, "bar":1}, {"foo":3, "bar":10}, {"foo":4, "bar":10}] - -sort_by(.foo, .bar) -[{"foo":4, "bar":10}, {"foo":3, "bar":20}, {"foo":2, "bar":1}, {"foo":3, "bar":10}] -[{"foo":2, "bar":1}, {"foo":3, "bar":10}, {"foo":3, "bar":20}, {"foo":4, "bar":10}] - -group_by(.foo) -[{"foo":1, "bar":10}, {"foo":3, "bar":100}, {"foo":1, "bar":1}] -[[{"foo":1, "bar":10}, {"foo":1, "bar":1}], [{"foo":3, "bar":100}]] - -min -[5,4,2,7] -2 - -max_by(.foo) -[{"foo":1, "bar":14}, {"foo":2, "bar":3}] -{"foo":2, "bar":3} - -unique -[1,2,5,3,5,3,1,3] -[1,2,3,5] - -unique_by(.foo) -[{"foo": 1, "bar": 2}, {"foo": 1, "bar": 3}, {"foo": 4, "bar": 5}] -[{"foo": 1, "bar": 2}, {"foo": 4, "bar": 5}] - -unique_by(length) -["chunky", "bacon", "kitten", "cicada", "asparagus"] -["bacon", "chunky", "asparagus"] - -reverse -[1,2,3,4] -[4,3,2,1] - -contains("bar") -"foobar" -true - -contains(["baz", "bar"]) -["foobar", "foobaz", "blarp"] -true - -contains(["bazzzzz", "bar"]) -["foobar", "foobaz", "blarp"] -false - -contains({foo: 12, bar: [{barp: 12}]}) -{"foo": 12, "bar":[1,2,{"barp":12, "blip":13}]} -true - -contains({foo: 12, bar: [{barp: 15}]}) -{"foo": 12, "bar":[1,2,{"barp":12, "blip":13}]} -false - -indices(", ") -"a,b, cd, efg, hijk" -[3,7,12] - -indices(1) -[0,1,2,1,3,1,4] -[1,3,5] - -indices([1,2]) -[0,1,2,3,1,4,2,5,1,2,6,7] -[1,8] - -index(", ") -"a,b, cd, efg, hijk" -3 - -index(1) -[0,1,2,1,3,1,4] -1 - -index([1,2]) -[0,1,2,3,1,4,2,5,1,2,6,7] -1 - -rindex(", ") -"a,b, cd, efg, hijk" -12 - -rindex(1) -[0,1,2,1,3,1,4] -5 - -rindex([1,2]) -[0,1,2,3,1,4,2,5,1,2,6,7] -8 - -inside("foobar") -"bar" -true - -inside(["foobar", "foobaz", "blarp"]) -["baz", "bar"] -true - -inside(["foobar", "foobaz", "blarp"]) -["bazzzzz", "bar"] -false - -inside({"foo": 12, "bar":[1,2,{"barp":12, "blip":13}]}) -{"foo": 12, "bar": [{"barp": 12}]} -true - -inside({"foo": 12, "bar":[1,2,{"barp":12, "blip":13}]}) -{"foo": 12, "bar": [{"barp": 15}]} -false - -[.[]|startswith("foo")] -["fo", "foo", "barfoo", "foobar", "barfoob"] -[false, true, false, true, false] - -[.[]|endswith("foo")] -["foobar", "barfoo"] -[false, true] - -combinations -[[1,2], [3, 4]] -[1, 3] -[1, 4] -[2, 3] -[2, 4] - -combinations(2) -[0, 1] -[0, 0] -[0, 1] -[1, 0] -[1, 1] - -[.[]|ltrimstr("foo")] -["fo", "foo", "barfoo", "foobar", "afoo"] -["fo","","barfoo","bar","afoo"] - -[.[]|rtrimstr("foo")] -["fo", "foo", "barfoo", "foobar", "foob"] -["fo","","bar","foobar","foob"] - -[.[]|trimstr("foo")] -["fo", "foo", "barfoo", "foobarfoo", "foob"] -["fo","","bar","bar","b"] - -trim, ltrim, rtrim -" abc " -"abc" -"abc " -" abc" - -explode -"foobar" -[102,111,111,98,97,114] - -implode -[65, 66, 67] -"ABC" - -join(", ") -["a","b,c,d","e"] -"a, b,c,d, e" - -join(" ") -["a",1,2.3,true,null,false] -"a 1 2.3 true false" - -ascii_upcase -"useful but not for é" -"USEFUL BUT NOT FOR é" - -[while(.<100; .*2)] -1 -[1,2,4,8,16,32,64] - -[repeat(.*2, error)?] -1 -[2] - -[.,1]|until(.[0] < 1; [.[0] - 1, .[1] * .[0]])|.[1] -4 -24 - -recurse(.foo[]) -{"foo":[{"foo": []}, {"foo":[{"foo":[]}]}]} -{"foo":[{"foo":[]},{"foo":[{"foo":[]}]}]} -{"foo":[]} -{"foo":[{"foo":[]}]} -{"foo":[]} - -recurse -{"a":0,"b":[1]} -{"a":0,"b":[1]} -0 -[1] -1 - -recurse(. * .; . < 20) -2 -2 -4 -16 - -walk(if type == "array" then sort else . end) -[[4, 1, 7], [8, 5, 2], [3, 6, 9]] -[[1,4,7],[2,5,8],[3,6,9]] - -$ENV.PAGER -null -"less" - -env.PAGER -null -"less" - -transpose -[[1], [2,3]] -[[1,2],[null,3]] - -bsearch(0) -[0,1] -0 - -bsearch(0) -[1,2,3] --1 - -bsearch(4) as $ix | if $ix < 0 then .[-(1+$ix)] = 4 else . end -[1,2,3] -[1,2,3,4] - -"The input was \(.), which is one less than \(.+1)" -42 -"The input was 42, which is one less than 43" - -[.[]|tostring] -[1, "foo", ["foo"]] -["1","foo","[\"foo\"]"] - -[.[]|tojson] -[1, "foo", ["foo"]] -["1","\"foo\"","[\"foo\"]"] - -[.[]|tojson|fromjson] -[1, "foo", ["foo"]] -[1,"foo",["foo"]] - -@html -"This works if x < y" -"This works if x < y" - -@sh "echo \(.)" -"O'Hara's Ale" -"echo 'O'\\''Hara'\\''s Ale'" - -@base64 -"This is a message" -"VGhpcyBpcyBhIG1lc3NhZ2U=" - -@base64d -"VGhpcyBpcyBhIG1lc3NhZ2U=" -"This is a message" - -fromdate -"2015-03-05T23:51:47Z" -1425599507 - -strptime("%Y-%m-%dT%H:%M:%SZ") -"2015-03-05T23:51:47Z" -[2015,2,5,23,51,47,4,63] - -strptime("%Y-%m-%dT%H:%M:%SZ")|mktime -"2015-03-05T23:51:47Z" -1425599507 - -. == false -null -false - -. == {"b": {"d": (4 + 1e-20), "c": 3}, "a":1} -{"a":1, "b": {"c": 3, "d": 4}} -true - -.[] == 1 -[1, 1.0, "1", "banana"] -true -true -false -false - -if . == 0 then "zero" elif . == 1 then "one" else "many" end -2 -"many" - -. < 5 -2 -true - -42 and "a string" -null -true - -(true, false) or false -null -true -false - -(true, true) and (true, false) -null -true -false -true -false - -[true, false | not] -null -[false, true] - -empty // 42 -null -42 - -.foo // 42 -{"foo": 19} -19 - -.foo // 42 -{} -42 - -(false, null, 1) // 42 -null -1 - -(false, null, 1) | . // 42 -null -42 -42 -1 - -try .a catch ". is not an object" -true -". is not an object" - -[.[]|try .a] -[{}, true, {"a":1}] -[null, 1] - -try error("some exception") catch . -true -"some exception" - -[.[] | .a?] -[{}, true, {"a":1}] -[null, 1] - -[.[] | tonumber?] -["1", "invalid", "3", 4] -[1, 3, 4] - -.bar as $x | .foo | . + $x -{"foo":10, "bar":200} -210 - -. as $i|[(.*2|. as $i| $i), $i] -5 -[10,5] - -. as [$a, $b, {c: $c}] | $a + $b + $c -[2, 3, {"c": 4, "d": 5}] -9 - -.[] as [$a, $b] | {a: $a, b: $b} -[[0], [0, 1], [2, 1, 0]] -{"a":0,"b":null} -{"a":0,"b":1} -{"a":2,"b":1} - -.[] as {$a, $b, c: {$d, $e}} ?// {$a, $b, c: [{$d, $e}]} | {$a, $b, $d, $e} -[{"a": 1, "b": 2, "c": {"d": 3, "e": 4}}, {"a": 1, "b": 2, "c": [{"d": 3, "e": 4}]}] -{"a":1,"b":2,"d":3,"e":4} -{"a":1,"b":2,"d":3,"e":4} - -.[] as {$a, $b, c: {$d}} ?// {$a, $b, c: [{$e}]} | {$a, $b, $d, $e} -[{"a": 1, "b": 2, "c": {"d": 3, "e": 4}}, {"a": 1, "b": 2, "c": [{"d": 3, "e": 4}]}] -{"a":1,"b":2,"d":3,"e":null} -{"a":1,"b":2,"d":null,"e":4} - -.[] as [$a] ?// [$b] | if $a != null then error("err: \($a)") else {$a,$b} end -[[3]] -{"a":null,"b":3} - -def addvalue(f): . + [f]; map(addvalue(.[0])) -[[1,2],[10,20]] -[[1,2,1], [10,20,10]] - -def addvalue(f): f as $x | map(. + $x); addvalue(.[0]) -[[1,2],[10,20]] -[[1,2,1,2], [10,20,1,2]] - -isempty(empty) -null -true - -isempty(.[]) -[] -true - -isempty(.[]) -[1,2,3] -false - -[limit(3; .[])] -[0,1,2,3,4,5,6,7,8,9] -[0,1,2] - -[skip(3; .[])] -[0,1,2,3,4,5,6,7,8,9] -[3,4,5,6,7,8,9] - -[first(range(.)), last(range(.)), nth(5; range(.))] -10 -[0,9,5] - -[first(empty), last(empty), nth(5; empty)] -null -[] - -[range(.)]|[first, last, nth(5)] -10 -[0,9,5] - -reduce .[] as $item (0; . + $item) -[1,2,3,4,5] -15 - -reduce .[] as [$i,$j] (0; . + $i * $j) -[[1,2],[3,4],[5,6]] -44 - -reduce .[] as {$x,$y} (null; .x += $x | .y += [$y]) -[{"x":"a","y":1},{"x":"b","y":2},{"x":"c","y":3}] -{"x":"abc","y":[1,2,3]} - -foreach .[] as $item (0; . + $item) -[1,2,3,4,5] -1 -3 -6 -10 -15 - -foreach .[] as $item (0; . + $item; [$item, . * 2]) -[1,2,3,4,5] -[1,2] -[2,6] -[3,12] -[4,20] -[5,30] - -foreach .[] as $item (0; . + 1; {index: ., $item}) -["foo", "bar", "baz"] -{"index":1,"item":"foo"} -{"index":2,"item":"bar"} -{"index":3,"item":"baz"} - -def range(init; upto; by): def _range: if (by > 0 and . < upto) or (by < 0 and . > upto) then ., ((.+by)|_range) else empty end; if init == upto then empty elif by == 0 then init else init|_range end; range(0; 10; 3) -null -0 -3 -6 -9 - -def while(cond; update): def _while: if cond then ., (update | _while) else empty end; _while; [while(.<100; .*2)] -1 -[1,2,4,8,16,32,64] - -truncate_stream([[0],"a"],[[1,0],"b"],[[1,0]],[[1]]) -1 -[[0],"b"] -[[0]] - -fromstream(1|truncate_stream([[0],"a"],[[1,0],"b"],[[1,0]],[[1]])) -null -["b"] - -. as $dot|fromstream($dot|tostream)|.==$dot -[0,[1,{"a":1},{"b":2}]] -true - -(..|select(type=="boolean")) |= if . then 1 else 0 end -[true,false,[5,true,[true,[false]],false]] -[1,0,[5,1,[1,[0]],0]] - -.foo += 1 -{"foo": 42} -{"foo": 43} - -.a = .b -{"a": {"b": 10}, "b": 20} -{"a":20,"b":20} - -.a |= .b -{"a": {"b": 10}, "b": 20} -{"a":10,"b":20} - -(.a, .b) = range(3) -null -{"a":0,"b":0} -{"a":1,"b":1} -{"a":2,"b":2} - -(.a, .b) |= range(3) -null -{"a":0,"b":0} - diff --git a/src/spec-tests/jq/cases/manonig.test b/src/spec-tests/jq/cases/manonig.test deleted file mode 100644 index b71c5958..00000000 --- a/src/spec-tests/jq/cases/manonig.test +++ /dev/null @@ -1,89 +0,0 @@ -split(", ") -"a, b,c,d, e, " -["a","b,c,d","e",""] - -walk( if type == "object" then with_entries( .key |= sub( "^_+"; "") ) else . end ) -[ { "_a": { "__b": 2 } } ] -[{"a":{"b":2}}] - -test("foo") -"foo" -true - -.[] | test("a b c # spaces are ignored"; "ix") -["xabcd", "ABC"] -true -true - -match("(abc)+"; "g") -"abc abc" -{"offset": 0, "length": 3, "string": "abc", "captures": [{"offset": 0, "length": 3, "string": "abc", "name": null}]} -{"offset": 4, "length": 3, "string": "abc", "captures": [{"offset": 4, "length": 3, "string": "abc", "name": null}]} - -match("foo") -"foo bar foo" -{"offset": 0, "length": 3, "string": "foo", "captures": []} - -match(["foo", "ig"]) -"foo bar FOO" -{"offset": 0, "length": 3, "string": "foo", "captures": []} -{"offset": 8, "length": 3, "string": "FOO", "captures": []} - -match("foo (?bar)? foo"; "ig") -"foo bar foo foo foo" -{"offset": 0, "length": 11, "string": "foo bar foo", "captures": [{"offset": 4, "length": 3, "string": "bar", "name": "bar123"}]} -{"offset": 12, "length": 8, "string": "foo foo", "captures": [{"offset": -1, "length": 0, "string": null, "name": "bar123"}]} - -[ match("."; "g")] | length -"abc" -3 - -capture("(?[a-z]+)-(?[0-9]+)") -"xyzzy-14" -{ "a": "xyzzy", "n": "14" } - -scan("c") -"abcdefabc" -"c" -"c" - -scan("(a+)(b+)") -"abaabbaaabbb" -["a","b"] -["aa","bb"] -["aaa","bbb"] - -split(", *"; null) -"ab,cd, ef" -["ab","cd","ef"] - -splits(", *") -"ab,cd, ef, gh" -"ab" -"cd" -"ef" -"gh" - -splits(",? *"; "n") -"ab,cd ef, gh" -"ab" -"cd" -"ef" -"gh" - -sub("[^a-z]*(?[a-z]+)"; "Z\(.x)"; "g") -"123abc456def" -"ZabcZdef" - -[sub("(?.)"; "\(.a|ascii_upcase)", "\(.a|ascii_downcase)")] -"aB" -["AB","aB"] - -gsub("(?.)[^a]*"; "+\(.x)-") -"Abcabc" -"+A-+a-" - -[gsub("p"; "a", "b")] -"p" -["a","b"] - diff --git a/src/spec-tests/jq/cases/onig.test b/src/spec-tests/jq/cases/onig.test deleted file mode 100644 index 3b189d40..00000000 --- a/src/spec-tests/jq/cases/onig.test +++ /dev/null @@ -1,211 +0,0 @@ -# match builtin -[match("( )*"; "g")] -"abc" -[{"offset":0,"length":0,"string":"","captures":[{"offset":-1,"string":null,"length":0,"name":null}]},{"offset":1,"length":0,"string":"","captures":[{"offset":-1,"string":null,"length":0,"name":null}]},{"offset":2,"length":0,"string":"","captures":[{"offset":-1,"string":null,"length":0,"name":null}]},{"offset":3,"length":0,"string":"","captures":[{"offset":-1,"string":null,"length":0,"name":null}]}] - -[match("( )*"; "gn")] -"abc" -[] - -[match(""; "g")] -"ab" -[{"offset":0,"length":0,"string":"","captures":[]},{"offset":1,"length":0,"string":"","captures":[]},{"offset":2,"length":0,"string":"","captures":[]}] - -[match("a"; "gi")] -"āáàä" -[] - -[match(["(bar)"])] -"foo bar" -[{"offset": 4, "length": 3, "string": "bar", "captures":[{"offset": 4, "length": 3, "string": "bar", "name": null}]}] - -# offsets account for combining codepoints and multi-byte UTF-8 -[match("bar")] -"ā bar with a combining codepoint U+0304" -[{"offset": 3, "length": 3, "string": "bar", "captures":[]}] - -# matches with combining codepoints still count them in their length -[match("bār")] -"a bār" -[{"offset": 2, "length": 4, "string": "bār", "captures":[]}] - -[match(".+?\\b")] -"ā two-codepoint grapheme" -[{"offset": 0, "length": 2, "string": "ā", "captures":[]}] - -[match(["foo (?bar)? foo", "ig"])] -"foo bar foo foo foo" -[{"offset": 0, "length": 11, "string": "foo bar foo", "captures":[{"offset": 4, "length": 3, "string": "bar", "name": "bar123"}]},{"offset":12, "length": 8, "string": "foo foo", "captures":[{"offset": -1, "length": 0, "string": null, "name": "bar123"}]}] - -# non-matched optional group -"a","b","c" | capture("(?a)?b?") -null -{"x":"a"} -{"x":null} -{"x":null} - -"a","b","c" | match("(?a)?b?") -null -{"offset":0,"length":1,"string":"a","captures":[{"offset":0,"length":1,"string":"a","name":"x"}]} -{"offset":0,"length":1,"string":"b","captures":[{"offset":-1,"string":null,"length":0,"name":"x"}]} -{"offset":0,"length":0,"string":"","captures":[{"offset":-1,"string":null,"length":0,"name":"x"}]} - -# same as above but allow empty match for group -"a","b","c" | capture("(?a?)?b?") -null -{"x":"a"} -{"x":""} -{"x":""} - -"a","b","c" | match("(?a?)?b?") -null -{"offset":0,"length":1,"string":"a","captures":[{"offset":0,"length":1,"string":"a","name":"x"}]} -{"offset":0,"length":1,"string":"b","captures":[{"offset":0,"string":"","length":0,"name":"x"}]} -{"offset":0,"length":0,"string":"","captures":[{"offset":0,"string":"","length":0,"name":"x"}]} - -#test builtin -[test("( )*"; "gn")] -"abc" -[false] - -[test("ā")] -"ā" -[true] - -capture("(?[a-z]+)-(?[0-9]+)") -"xyzzy-14" -{"a":"xyzzy","n":"14"} - - -# jq-coded utilities built on match: -# -# The second element in these tests' inputs tests the case where the -# fromstring matches both the head and tail of the string -[.[] | sub(", "; ":")] -["a,b, c, d, e,f", ", a,b, c, d, e,f, "] -["a,b:c, d, e,f",":a,b, c, d, e,f, "] - -sub("^(?.)"; "Head=\(.head) Tail=") -"abcdef" -"Head=a Tail=bcdef" - -[.[] | gsub(", "; ":")] -["a,b, c, d, e,f",", a,b, c, d, e,f, "] -["a,b:c:d:e,f",":a,b:c:d:e,f:"] - -gsub("(?\\d)"; ":\(.d);") -"a1b2" -"a:1;b:2;" - -gsub("a";"b") -"aaaaa" -"bbbbb" - -gsub("(.*)"; ""; "x") -"" -"" - -gsub(""; "a"; "g") -"" -"a" - -gsub("^"; ""; "g") -"a" -"a" - -gsub(""; "a"; "g") -"a" -"aaa" - -gsub("$"; "a"; "g") -"a" -"aa" - -gsub("^"; "a") -"" -"a" - -gsub("(?=u)"; "u") -"qux" -"quux" - -gsub("^.*a"; "b") -"aaa" -"b" - -gsub("^.*?a"; "b") -"aaa" -"baa" - -# The following is for regression testing and should not be construed as a requirement: -[gsub("a"; "b", "c")] -"a" -["b","c"] - -[.[] | scan(", ")] -["a,b, c, d, e,f",", a,b, c, d, e,f, "] -[", ",", ",", ",", ",", ",", ",", ",", "] - -[.[]|[[sub(", *";":")], [gsub(", *";":")], [scan(", *")]]] -["a,b, c, d, e,f",", a,b, c, d, e,f, "] -[[["a:b, c, d, e,f"],["a:b:c:d:e:f"],[",",", ",", ",", ",","]],[[":a,b, c, d, e,f, "],[":a:b:c:d:e:f:"],[", ",",",", ",", ",", ",",",", "]]] - -[.[]|[[sub(", +";":")], [gsub(", +";":")], [scan(", +")]]] -["a,b, c, d, e,f",", a,b, c, d, e,f, "] -[[["a,b:c, d, e,f"],["a,b:c:d:e,f"],[", ",", ",", "]],[[":a,b, c, d, e,f, "],[":a,b:c:d:e,f:"],[", ",", ",", ",", ",", "]]] - -[.[] | scan("b+"; "i")] -["","bBb","abcABBBCabbbc"] -["bBb","b","BBB","bbb"] - -# reference to named captures -gsub("(?.)[^a]*"; "+\(.x)-") -"Abcabc" -"+A-+a-" - -gsub("(?.)(?[0-9])"; "\(.x|ascii_downcase)\(.y)") -"A1 B2 CD" -"a1 b2 CD" - -gsub("\\b(?.)"; "\(.x|ascii_downcase)") -"ABC DEF" -"aBC dEF" - -gsub("[^a-z]*(?[a-z]*)"; "Z\(.x)") -"123foo456bar" -"ZfooZbarZ" - -# utf-8 -sub("(?.)"; "\(.x)!") -"’" -"’!" - -[sub("a"; "b", "c")] -"a" -["b","c"] - -[sub("(?.)"; "\(.a|ascii_upcase)", "\(.a|ascii_downcase)", "c")] -"aB" -["AB","aB","cB"] - -[gsub("(?.)"; "\(.a|ascii_upcase)", "\(.a|ascii_downcase)", "c")] -"aB" -["AB","ab","cc"] - -# splits -[splits("")] -"ab" -["","a","b",""] - -[splits("c")] -"ab" -["ab"] - -[splits("a+"; "i")] -"abAABBabA" -["","b","BB","b",""] - -[splits("b+"; "i")] -"abAABBabA" -["a","AA","a","A"] - diff --git a/src/spec-tests/jq/cases/optional.test b/src/spec-tests/jq/cases/optional.test deleted file mode 100644 index 77354cce..00000000 --- a/src/spec-tests/jq/cases/optional.test +++ /dev/null @@ -1,12 +0,0 @@ -# See tests/jq.test and the jq manual for more information. - -# Regression test for #3276 (fails on mingw/WIN32) -fromdate -"2038-01-19T03:14:08Z" -2147483648 - -# %e is not available on mingw/WIN32 -strftime("%A, %B %e, %Y") -1435677542.822351 -"Tuesday, June 30, 2015" - diff --git a/src/spec-tests/jq/cases/uri.test b/src/spec-tests/jq/cases/uri.test deleted file mode 100644 index de102444..00000000 --- a/src/spec-tests/jq/cases/uri.test +++ /dev/null @@ -1,38 +0,0 @@ -# Tests are groups of three lines: program, input, expected output -# Blank lines and lines starting with # are ignored - -@uri -"<>&'\"\t" -"%3C%3E%26%27%22%09" - -# decoding encoded output results in same text -(@uri|@urid) -"<>&'\"\t" -"<>&'\"\t" - -# testing variable length unicode characters -@uri -"a \u03bc \u2230 \ud83d\ude0e" -"a%20%CE%BC%20%E2%88%B0%20%F0%9F%98%8E" - -@urid -"a%20%CE%BC%20%E2%88%B0%20%F0%9F%98%8E" -"a \u03bc \u2230 \ud83d\ude0e" - -### invalid uri strings - -# unicode character should be length 4 (not 3) -. | try @urid catch . -"%F0%93%81" -"string (\"%F0%93%81\") is not a valid uri encoding" - -# invalid hex value ('FX') -. | try @urid catch . -"%FX%9F%98%8E" -"string (\"%FX%9F%98%8E\") is not a valid uri encoding" - -# trailing utf-8 octets must be formatted like 10xxxxxx -# 'C0' = 11000000 invalid -. | try @urid catch . -"%F0%C0%81%8E" -"string (\"%F0%C0%81%8E\") is not a valid uri encoding" diff --git a/src/spec-tests/jq/jq-spec.test.ts b/src/spec-tests/jq/jq-spec.test.ts deleted file mode 100644 index 824a6cff..00000000 --- a/src/spec-tests/jq/jq-spec.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Vitest runner for jq spec tests - * - * This runs the imported spec tests from the jqlang/jq project against just-bash's jq. - */ - -import * as fs from "node:fs"; -import * as path from "node:path"; -import { describe, expect, it } from "vitest"; -import { parseJqTestFile } from "./parser.js"; -import { formatError, runJqTestCase } from "./runner.js"; -import { getSkipReason, isFileSkipped } from "./skips.js"; - -const CASES_DIR = path.join(__dirname, "cases"); - -// Get all .test files -const ALL_TEST_FILES = fs - .readdirSync(CASES_DIR) - .filter((f) => f.endsWith(".test")) - .sort(); - -// Filter out completely skipped files -const TEST_FILES = ALL_TEST_FILES.filter((f) => !isFileSkipped(f)); - -/** - * Truncate program for test name display - */ -function truncateProgram(program: string, maxLen = 50): string { - const normalized = program.trim(); - if (normalized.length <= maxLen) { - return normalized; - } - return `${normalized.slice(0, maxLen - 3)}...`; -} - -describe("JQ Spec Tests", () => { - // Add a placeholder test to ensure suite is not empty when all files are skipped - if (TEST_FILES.length === 0) { - it("All test files are currently skipped", () => { - // This test passes - it's just a placeholder - }); - } - - for (const fileName of TEST_FILES) { - const filePath = path.join(CASES_DIR, fileName); - - describe(fileName, () => { - // Parse the test file - const content = fs.readFileSync(filePath, "utf-8"); - const parsed = parseJqTestFile(content, filePath); - - // Skip files with no parseable tests - if (parsed.testCases.length === 0) { - it.skip("No parseable tests", () => {}); - return; - } - - for (const testCase of parsed.testCases) { - // Check for individual test skip - // For error tests, only check exact SKIP_TESTS matches (not patterns) - // to avoid broad patterns marking error tests that actually pass - const skipReason = getSkipReason( - fileName, - testCase.name, - testCase.program, - testCase.input, - testCase.expectsError, - ); - if (skipReason) { - testCase.skip = skipReason; - } - - const programPreview = truncateProgram(testCase.program); - const testName = `[L${testCase.lineNumber}] ${programPreview}`; - - it(testName, async () => { - const result = await runJqTestCase(testCase); - - if (result.skipped) { - return; - } - - if (!result.passed) { - expect.fail(formatError(result)); - } - }); - } - }); - } -}); diff --git a/src/spec-tests/jq/parser.ts b/src/spec-tests/jq/parser.ts deleted file mode 100644 index 6540e945..00000000 --- a/src/spec-tests/jq/parser.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Parser for jq test format - * - * The jq test format is simple: - * - Tests are groups of three lines: program, input, expected output - * - Blank lines and lines starting with # are ignored - * - Multiple expected output lines mean multiple outputs - * - %%FAIL indicates an error test - */ - -export interface JqTestCase { - name: string; - /** The jq program to run */ - program: string; - /** Input JSON */ - input: string; - /** Expected outputs (may be multiple) */ - expectedOutputs: string[]; - /** If true, test expects an error */ - expectsError: boolean; - /** Expected error message (if expectsError) */ - expectedError?: string; - /** Line number in source file */ - lineNumber: number; - /** If set, test is expected to fail (value is reason) */ - skip?: string; -} - -export interface ParsedJqTestFile { - fileName: string; - filePath: string; - testCases: JqTestCase[]; -} - -/** - * Parse a jq test file - */ -export function parseJqTestFile( - content: string, - filePath: string, -): ParsedJqTestFile { - const fileName = filePath.split("/").pop() || filePath; - const lines = content.split("\n"); - const testCases: JqTestCase[] = []; - - let i = 0; - let testNumber = 0; - - while (i < lines.length) { - // Skip blank lines and comments - while ( - i < lines.length && - (lines[i].trim() === "" || lines[i].startsWith("#")) - ) { - i++; - } - - if (i >= lines.length) break; - - // Check for %%FAIL error test - if (lines[i] === "%%FAIL") { - i++; - // Skip any blank lines after %%FAIL - while (i < lines.length && lines[i].trim() === "") { - i++; - } - - if (i >= lines.length) break; - - const programLine = i; - const program = lines[i]; - i++; - - // Collect error message lines until blank line or next test - const errorLines: string[] = []; - while ( - i < lines.length && - lines[i].trim() !== "" && - !lines[i].startsWith("#") - ) { - errorLines.push(lines[i]); - i++; - } - - testNumber++; - testCases.push({ - name: `error test ${testNumber}: ${truncateProgram(program)}`, - program, - input: "null", - expectedOutputs: [], - expectsError: true, - expectedError: errorLines.join("\n"), - lineNumber: programLine + 1, - }); - continue; - } - - // Regular test: program, input, expected output(s) - const programLine = i; - const program = lines[i]; - i++; - - // Skip blank lines between program and input - while (i < lines.length && lines[i].trim() === "") { - i++; - } - - if (i >= lines.length) break; - - const input = lines[i]; - i++; - - // Collect expected output lines until blank line, comment, or %%FAIL - const expectedOutputs: string[] = []; - while ( - i < lines.length && - lines[i].trim() !== "" && - !lines[i].startsWith("#") && - lines[i] !== "%%FAIL" - ) { - expectedOutputs.push(lines[i]); - i++; - } - - // Skip if we didn't get any expected output - if (expectedOutputs.length === 0) { - continue; - } - - testNumber++; - testCases.push({ - name: `test ${testNumber}: ${truncateProgram(program)}`, - program, - input, - expectedOutputs, - expectsError: false, - lineNumber: programLine + 1, - }); - } - - return { fileName, filePath, testCases }; -} - -/** - * Truncate program for display - */ -function truncateProgram(program: string, maxLen = 50): string { - const normalized = program.trim(); - if (normalized.length <= maxLen) { - return normalized; - } - return `${normalized.slice(0, maxLen - 3)}...`; -} diff --git a/src/spec-tests/jq/runner.ts b/src/spec-tests/jq/runner.ts deleted file mode 100644 index 2b246246..00000000 --- a/src/spec-tests/jq/runner.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * JQ spec test runner - executes parsed jq tests against just-bash's jq - */ - -import { Bash } from "../../Bash.js"; -import type { JqTestCase } from "./parser.js"; - -export interface JqTestResult { - testCase: JqTestCase; - passed: boolean; - skipped: boolean; - skipReason?: string; - /** Test was expected to fail (skip) but unexpectedly passed */ - unexpectedPass?: boolean; - actualOutputs?: string[]; - actualStderr?: string; - actualStatus?: number; - expectedOutputs?: string[]; - error?: string; -} - -export interface RunOptions { - /** Custom Bash options */ - bashEnvOptions?: ConstructorParameters[0]; -} - -/** - * Run a single jq test case - */ -export async function runJqTestCase( - testCase: JqTestCase, - options: RunOptions = {}, -): Promise { - // Track if test is expected to fail (skip) - we'll still run it - const expectedToFail = !!testCase.skip; - const skipReason = testCase.skip; - - // Skip tests with no expected output (unless it's an error test) - if ( - !testCase.expectsError && - testCase.expectedOutputs.length === 0 && - testCase.skip - ) { - return { - testCase, - passed: true, - skipped: true, - skipReason: `No expected output (parser issue): ${testCase.skip}`, - }; - } - - // Create a fresh Bash for each test - const env = new Bash({ - files: { - "/tmp/_keep": "", - }, - cwd: "/tmp", - env: { - HOME: "/tmp", - }, - ...options.bashEnvOptions, - }); - - try { - // Build the jq command - // Escape single quotes in program and input - const escapedProgram = testCase.program.replace(/'/g, "'\\''"); - const escapedInput = testCase.input.replace(/'/g, "'\\''"); - - // Use -c for compact output - const script = `echo '${escapedInput}' | jq -c '${escapedProgram}'`; - - const result = await env.exec(script); - - if (testCase.expectsError) { - // For error tests, we expect non-zero exit code and error message - const gotError = result.exitCode !== 0 || result.stderr.length > 0; - - const passed = gotError; - - // Handle skip tests - if (expectedToFail) { - if (passed) { - return { - testCase, - passed: false, - skipped: false, - unexpectedPass: true, - actualOutputs: result.stdout - ? result.stdout.split("\n").filter((l) => l) - : [], - actualStderr: result.stderr, - actualStatus: result.exitCode, - expectedOutputs: testCase.expectedOutputs, - error: `UNEXPECTED PASS: This test was marked skip (${skipReason}) but now passes. Please remove the skip.`, - }; - } - return { - testCase, - passed: true, - skipped: true, - skipReason: `skip: ${skipReason}`, - actualStderr: result.stderr, - actualStatus: result.exitCode, - }; - } - - return { - testCase, - passed, - skipped: false, - actualStderr: result.stderr, - actualStatus: result.exitCode, - error: passed - ? undefined - : `Expected error but got success with output: ${result.stdout}`, - }; - } - - // For normal tests, compare outputs - const actualOutputs = normalizeOutputs(result.stdout); - const expectedOutputs = testCase.expectedOutputs.map((o) => o.trim()); - - const passed = arraysEqual(actualOutputs, expectedOutputs); - - // Handle skip tests - if (expectedToFail) { - if (passed) { - return { - testCase, - passed: false, - skipped: false, - unexpectedPass: true, - actualOutputs, - actualStderr: result.stderr, - actualStatus: result.exitCode, - expectedOutputs, - error: `UNEXPECTED PASS: This test was marked skip (${skipReason}) but now passes. Please remove the skip.`, - }; - } - return { - testCase, - passed: true, - skipped: true, - skipReason: `skip: ${skipReason}`, - actualOutputs, - actualStderr: result.stderr, - actualStatus: result.exitCode, - expectedOutputs, - }; - } - - return { - testCase, - passed, - skipped: false, - actualOutputs, - actualStderr: result.stderr, - actualStatus: result.exitCode, - expectedOutputs, - error: passed - ? undefined - : `Output mismatch:\n expected: ${JSON.stringify(expectedOutputs)}\n actual: ${JSON.stringify(actualOutputs)}`, - }; - } catch (e) { - // If test was expected to fail and threw an error, that counts as expected failure - if (expectedToFail) { - return { - testCase, - passed: true, - skipped: true, - skipReason: `skip: ${skipReason}`, - error: `Execution error (expected): ${e instanceof Error ? e.message : String(e)}`, - }; - } - return { - testCase, - passed: false, - skipped: false, - error: `Execution error: ${e instanceof Error ? e.message : String(e)}`, - }; - } -} - -/** - * Normalize outputs for comparison - */ -function normalizeOutputs(output: string): string[] { - return output - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); -} - -/** - * Normalize a JSON string to canonical form for comparison - * This handles differences in whitespace (e.g., "key": 1 vs "key":1) - */ -function normalizeJson(s: string): string { - try { - const parsed = JSON.parse(s); - return JSON.stringify(parsed); - } catch { - // If it's not valid JSON, return as-is - return s; - } -} - -/** - * Compare two arrays for equality, with JSON normalization - */ -function arraysEqual(a: string[], b: string[]): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - // Normalize both values to handle JSON formatting differences - if (normalizeJson(a[i]) !== normalizeJson(b[i])) return false; - } - return true; -} - -/** - * Format error message for debugging - */ -export function formatError(result: JqTestResult): string { - const lines: string[] = []; - - if (result.error) { - lines.push(result.error); - lines.push(""); - } - - lines.push("OUTPUT:"); - lines.push(` expected: ${JSON.stringify(result.expectedOutputs ?? [])}`); - lines.push(` actual: ${JSON.stringify(result.actualOutputs ?? [])}`); - - if (result.actualStderr) { - lines.push("STDERR:"); - lines.push(` ${JSON.stringify(result.actualStderr)}`); - } - - lines.push(""); - lines.push("PROGRAM:"); - lines.push(result.testCase.program); - - lines.push(""); - lines.push("INPUT:"); - lines.push(result.testCase.input); - - return lines.join("\n"); -} diff --git a/src/spec-tests/jq/skips.ts b/src/spec-tests/jq/skips.ts deleted file mode 100644 index 2f337b04..00000000 --- a/src/spec-tests/jq/skips.ts +++ /dev/null @@ -1,652 +0,0 @@ -/** - * Skip list for JQ spec tests - * - * Tests in this list are expected to fail. If a test passes unexpectedly, - * the test runner will report it as a failure so we know to remove it from the skip list. - */ - -/** - * Files to skip entirely - */ -const SKIP_FILES: Set = new Set([ - // Oniguruma regex library (external C dependency) - "onig.test", - "manonig.test", - - // Experimental/optional JQ features - "optional.test", -]); - -/** - * Individual test skips within files - * Format: "fileName:testName" -> skipReason - */ -const SKIP_TESTS: Map = new Map([ - // ============================================================ - // Destructuring edge cases - // ============================================================ - [ - "jq.test:. as [] | null", - "Empty array pattern should error on non-empty input", - ], - [ - "jq.test:. as {} | null", - "Empty object pattern should error on non-object input", - ], - [ - "jq.test:. as {(true):$foo} | $foo", - "Computed key with non-string expression should error", - ], - - // ============================================================ - // ltrimstr/rtrimstr type checking - // ============================================================ - [ - 'jq.test:.[] as [$x, $y] | try ["ok", ($x | ltrimstr($y))] catch ["ko", .]', - "ltrimstr should error on non-string inputs", - ], - [ - 'jq.test:.[] as [$x, $y] | try ["ok", ($x | rtrimstr($y))] catch ["ko", .]', - "rtrimstr should error on non-string inputs", - ], - - // ============================================================ - // def call-by-name semantics - // ============================================================ - [ - "jq.test:def f(x): x | x; f([.], . + [42])", - "def: requires call-by-name semantics for filter parameters", - ], - [ - "jq.test:def x(a;b): a as $a | b as $b | $a + $b; def y($a;$b): $a + $b; def check(a;b): [x(a;b)] == [y(a;b)]; check(.[];.[]*2)", - "def: requires call-by-name semantics for filter parameters", - ], - [ - "jq.test:def inc(x): x |= .+1; inc(.[].a)", - "def: update operator on parameter requires call-by-name", - ], - [ - "jq.test:def x: .[1,2]; x=10", - "def: user-defined function as path expression not supported", - ], - [ - "jq.test:try (def x: reverse; x=10) catch .", - "def: user-defined function as path expression not supported", - ], - - // ============================================================ - // Invalid escape sequences - // ============================================================ - ['jq.test:"u\\vw"', "Invalid \\v escape sequence test"], - - // ============================================================ - // Undefined variable behavior - // ============================================================ - [ - "jq.test:. as $foo | [$foo, $bar]", - "Undefined variable $bar behavior differs", - ], - - // ============================================================ - // NUL character handling - // ============================================================ - ['jq.test:"\\u0000\\u0020\\u0000" + .', "NUL character handling differs"], - [ - 'jq.test:[contains("cd"), contains("b\\u0000"), contains("b\\u0000c"), contains("d")]', - "contains with NUL character", - ], - [ - 'jq.test:[contains("b\\u0000c"), contains("b\\u0000cd"), contains("cd")]', - "contains with NUL character", - ], - [ - 'jq.test:[contains(""), contains("\\u0000")]', - "contains with NUL character edge case", - ], - - // ============================================================ - // walk edge cases - // ============================================================ - [ - "jq.test:walk(select(IN({}, []) | not))", - "walk replaces filtered values with null instead of removing", - ], - ["jq.test:[walk(.,1)]", "walk with generator argument"], - - // ============================================================ - // getpath/setpath/delpaths edge cases - // ============================================================ - [ - 'jq.test:["foo",1] as $p | getpath($p), setpath($p; 20), delpaths([[$p]])', - "getpath with string/number path", - ], - ["jq.test:delpaths([[-200]])", "delpaths with large negative index"], - - // ============================================================ - // label with keyword variable names - // ============================================================ - [ - "jq.test:[ label $if | range(10) | ., (select(. == 5) | break $if), . ]", - "label with keyword name $if", - ], - - // ============================================================ - // fromjson edge cases - // ============================================================ - [ - "jq.test:.[] | try (fromjson | isnan) catch .", - "fromjson with array iteration", - ], - - // ============================================================ - // try-catch complex nesting - // ============================================================ - [ - 'jq.test:try (["hi","ho"]|.[]|(try . catch (if .=="ho" then "BROKEN"|error else "caught: \\(.)" end))) catch .', - "Complex try-catch nesting", - ], - [ - 'jq.test:.[]|(try . catch (if .=="ho" then "BROKEN"|error else "caught: \\(.)" end))', - "Complex try-catch nesting", - ], - - // ============================================================ - // String interpolation edge cases - // ============================================================ - [ - 'jq.test:"inter\\("pol" + "ation")"', - "String interpolation with complex expression", - ], - ['jq.test:@html "\\(.)"', "String interpolation in @html"], - ['jq.test:{"a",b,"a$\\(1+1)"}', "String interpolation in object key"], - - // ============================================================ - // Float slice assignment - // ============================================================ - [ - 'jq.test:try ("foobar" | .[1.5:3.5] = "xyz") catch .', - "Float slice assignment on string", - ], - [ - 'jq.test:try ([range(10)] | .[1.5:3.5] = ["xyz"]) catch .', - "Float slice assignment on array", - ], - [ - 'jq.test:try ("foobar" | .[1.5]) catch .', - "Float index on string should error", - ], - - // ============================================================ - // path() with select/map - // ============================================================ - ["jq.test:path(.foo[0,1])", "Complex path with multiple indices"], - ["jq.test:path(.[] | select(.>3))", "path with select not supported"], - [ - "jq.test:try path(.a | map(select(.b == 0))) catch .", - "path with map/select not supported", - ], - [ - "jq.test:try path(.a | map(select(.b == 0)) | .[0]) catch .", - "path with map/select not supported", - ], - [ - "jq.test:try path(.a | map(select(.b == 0)) | .c) catch .", - "path with map/select not supported", - ], - [ - "jq.test:try path(.a | map(select(.b == 0)) | .[]) catch .", - "path with map/select not supported", - ], - ["jq.test:path(.a[path(.b)[0]])", "Nested path expressions not supported"], - - // ============================================================ - // Update with select/empty - // ============================================================ - [ - "jq.test:(.[] | select(. >= 2)) |= empty", - "Update with empty and select not implemented", - ], - ["jq.test:.[] |= select(. % 2 == 0)", "Update with select not implemented"], - ["jq.test:.foo[1,4,2,3] |= empty", "Update multiple indices with empty"], - [ - "jq.test:try ((map(select(.a == 1))[].b) = 10) catch .", - "Update through map/select not supported", - ], - [ - "jq.test:try ((map(select(.a == 1))[].a) |= .+1) catch .", - "Update through map/select not supported", - ], - [ - 'jq.test:.[] | try (getpath(["a",0,"b"]) |= 5) catch .', - "getpath update not supported", - ], - - // ============================================================ - // NaN multiplication - // ============================================================ - ["jq.test:[. * (nan,-nan)]", "NaN multiplication special handling"], - - // ============================================================ - // Iterator order difference (acceptable) - // ============================================================ - [ - "jq.test:[foreach .[] / .[] as $i (0; . + $i)]", - "Iterator order for .[] / .[] differs from jq", - ], - - // ============================================================ - // Depth limit tests - // Our depth limit (2000) is lower than jq's to ensure V8 compatibility - // ============================================================ - [ - "jq.test:reduce range(9999) as $_ ([];[.]) | tojson | fromjson | flatten", - "Our depth limit (2000) returns null before reaching 9999 levels", - ], - [ - 'jq.test:reduce range(10000) as $_ ([];[.]) | tojson | try (fromjson) catch . | (contains("") | not) and contains("Exceeds depth limit for parsing")', - "Depth limit test - different error messages", - ], - [ - 'jq.test:reduce range(10001) as $_ ([];[.]) | tojson | contains("")', - "Depth limit test - different error messages", - ], - [ - "jq.test:try (. * 1000000000) catch .", - "String multiplication overflow error message differs", - ], - - // ============================================================ - // pow/trim precision - // ============================================================ - [ - "jq.test:[range(-52;52;1)] as $powers | [$powers[]|pow(2;.)] | [.[52], .[51], .[0], .[-1], .[-2]] as $s | [$s[], $s[0]/$s[1], $s[3]/$s[4]]", - "pow with fractional exponent precision", - ], - [ - "jq.test:trim, ltrim, rtrim", - "trim doesn't handle all Unicode whitespace characters", - ], - - // ============================================================ - // man.test specific skips - // ============================================================ - [ - "man.test:[repeat(.*2, error)?]", - "repeat with error and ? operator interaction", - ], - ["man.test:env.PAGER", "env.PAGER not set in sandbox"], - ["man.test:$ENV.PAGER", "$ENV.PAGER not set in sandbox"], - [ - 'man.test:@sh "echo \\(.)"', - "@sh format with string interpolation not supported", - ], - [ - 'man.test:. == {"b": {"d": (4 + 1e-20), "c": 3}, "a":1}', - "floating point comparison with epsilon", - ], - [ - "man.test:.[] as {$a, $b, c: {$d, $e}} ?// {$a, $b, c: [{$d, $e}]} | {$a, $b, $d, $e}", - "?// alternative with complex destructuring patterns", - ], - [ - "man.test:.[] as {$a, $b, c: {$d}} ?// {$a, $b, c: [{$e}]} | {$a, $b, $d, $e}", - "?// alternative with complex destructuring patterns", - ], - [ - 'man.test:.[] as [$a] ?// [$b] | if $a != null then error("err: \\($a)") else {$a,$b} end', - "?// alternative with array destructuring and error", - ], - [ - "man.test:reduce .[] as {$x,$y} (null; .x += $x | .y += [$y])", - "reduce with object destructuring pattern", - ], - [ - "man.test:foreach .[] as $item (0; . + 1; {index: ., $item})", - "foreach with variable shorthand in object construction", - ], - [ - 'man.test:(..|select(type=="boolean")) |= if . then 1 else 0 end', - "recursive descent update with select", - ], - ["man.test:(.a, .b) = range(3)", "comma expression path assignment"], - ["man.test:(.a, .b) |= range(3)", "comma expression path update"], -]); - -/** - * Pattern-based skips for tests matching certain patterns - */ -const SKIP_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [ - // ============================================================ - // OUT OF SCOPE / INFRASTRUCTURE - // ============================================================ - - // Module system - out of scope for sandboxed jq - { pattern: /^include "/, reason: "Module system not implemented" }, - { pattern: /^import "/, reason: "Module system not implemented" }, - { pattern: /\bmodulemeta\b/, reason: "modulemeta not implemented" }, - - // Environment/stdin access - sandboxed environment - { pattern: /\binputs\b/, reason: "inputs not implemented" }, - { pattern: /\binput\s*[|)]/, reason: "input not implemented (no stdin)" }, - { pattern: /\binput\s*$/, reason: "input not implemented (no stdin)" }, - { pattern: /\binput\s+catch\b/, reason: "input not implemented (no stdin)" }, - { pattern: /[|;(]\s*input\b/, reason: "input not implemented (no stdin)" }, - - // Debug introspection - { pattern: /\$__loc__/, reason: "$__loc__ not implemented" }, - { pattern: /\$__prog__/, reason: "$__prog__ not implemented" }, - - // External dependencies - { pattern: /\bhave_decnum\b/, reason: "have_decnum not implemented" }, - - // Locale-dependent time functions - { pattern: /\bstrflocaltime\b/, reason: "strflocaltime not implemented" }, - { pattern: /\blocaltime\b/, reason: "localtime not implemented" }, - - // ============================================================ - // ERROR MESSAGE DIFFERENCES (acceptable) - // ============================================================ - - { pattern: /try join\(","\) catch \./, reason: "join error message" }, - { - pattern: /try \(\. \* 1000000000\) catch \./, - reason: "String multiply overflow", - }, - { pattern: /^try fromjson catch \.$/, reason: "fromjson error" }, - { pattern: /reduce range\(1000[01]\) as.*tojson/, reason: "depth limit" }, - { pattern: /^%%FAIL/, reason: "Error behavior test not supported" }, - { pattern: /try @base64d catch/, reason: "base64d error handling differs" }, - { pattern: /try @urid catch/, reason: "@urid error message differs" }, - - // ============================================================ - // PARSER LIMITATIONS - // ============================================================ - - // Programs starting with negative numbers - { pattern: /^-\d/, reason: "Program starting with - parsed as flag" }, - - // ============================================================ - // MISSING FUNCTIONS - // ============================================================ - - // Date functions - { pattern: /\bdateadd\b/, reason: "dateadd not implemented" }, - { pattern: /\bdatesub\b/, reason: "datesub not implemented" }, - - // Format functions - { pattern: /\bformat\(/, reason: "format() not implemented" }, - { pattern: /@base32/, reason: "@base32 not implemented" }, - { pattern: /@html /, reason: "@html format not implemented" }, - - // Math functions (obscure) - { pattern: /\bj0\b/, reason: "j0 not implemented" }, - { pattern: /\bj1\b/, reason: "j1 not implemented" }, - { pattern: /\by0\b/, reason: "y0 not implemented" }, - { pattern: /\by1\b/, reason: "y1 not implemented" }, - { pattern: /\bpow10\(/, reason: "pow10() not implemented" }, - { pattern: /\bgamma\b/, reason: "gamma not implemented" }, - { pattern: /\blgamma\b/, reason: "lgamma not implemented" }, - { pattern: /\btgamma\b/, reason: "tgamma not implemented" }, - - // Literals - { pattern: /\bInfinity\b/, reason: "Infinity literal not supported" }, - { pattern: /-Infinity\b/, reason: "-Infinity literal not supported" }, - - // ============================================================ - // IMPLEMENTATION BUGS - // ============================================================ - - // del with generator args - { - pattern: /\bdel\(\.[^)]+,\.[^)]+\)/, - reason: "Parser: generator args in del", - }, - - // path() function limitations - { pattern: /^path\(\.foo\[0,1\]\)$/, reason: "path multi-index" }, - { pattern: /path\(\.\[\] \| select/, reason: "path with select" }, - { pattern: /try path\(\.a \| map\(select/, reason: "path with map/select" }, - { pattern: /path\(\.a\[path\(/, reason: "nested path" }, - - // Update expressions with select/empty - { - pattern: /\(\.\[\] \| select.*\) \|= empty/, - reason: "update select empty", - }, - { pattern: /\.\[\] \|= select\(/, reason: "update with select" }, - { - pattern: /\.foo\[\d+,\d+,\d+,\d+\] \|= empty/, - reason: "multi-index empty", - }, - { pattern: /map\(select.*\[\]\./, reason: "update through map/select" }, - { pattern: /getpath\(\["a",0,"b"\]\) \|=/, reason: "getpath update" }, - - // Float slice assignment - { pattern: /\.\[\d+\.\d+:\d+\.\d+\] =/, reason: "Float slice assignment" }, - - // del/delpaths edge cases - { pattern: /try delpaths\(\d+\)/, reason: "delpaths type error" }, - { pattern: /del\(\.\),/, reason: "del(.) expression" }, - { pattern: /del\(empty\)/, reason: "del(empty) expression" }, - { pattern: /del\(\(\.[^)]+,\.[^)]+\)/, reason: "del with comma expressions" }, - { pattern: /del\(\.\[.*,.*\]\)/, reason: "del with multiple indices" }, - { - pattern: /delpaths\(\[\[-\d+\]\]\)/, - reason: "delpaths with large negative", - }, - - // setpath edge cases - { pattern: /setpath\(\[-\d+\]/, reason: "setpath with negative index" }, - { pattern: /setpath\(\[\[/, reason: "setpath with array key" }, - - // Auto-vivification issues - { - pattern: /\.\[\d+\]\[\d+\] = \d+/, - reason: "Nested index auto-vivification", - }, - { - pattern: /\.foo\[\d+\]\.bar = /, - reason: "Nested field/index auto-vivification", - }, - { - pattern: /\.foo = \.bar$/, - reason: "Self-referential assignment key order differs", - }, - { - pattern: /try \(\.foo\[-\d+\] = \d+\) catch/, - reason: "Negative index assignment on null", - }, - - // ============================================================ - // EDGE CASES - // ============================================================ - - // String escape sequences - { pattern: /\\v/, reason: "Parser: \\v escape not supported" }, - { pattern: /\\t/, reason: "Parser: tab escape in test input" }, - { pattern: /\\b/, reason: "Parser: backspace escape in test input" }, - { pattern: /\\f/, reason: "Parser: formfeed escape in test input" }, - { pattern: /"[^"]*\t[^"]*"/, reason: "Literal tab in test input" }, - { pattern: /"u\\vw"/, reason: "\\v escape sequence test" }, - - // NUL character handling - { pattern: /"\\u0000.*" \+ \./, reason: "NUL character string concat" }, - { pattern: /contains\("b\\u0000/, reason: "contains with NUL char" }, - - // String interpolation edge cases - { pattern: /inter\\\(/, reason: "String interpolation with backslash" }, - { - pattern: /\{"[^"]*",\w+,"[^"]*\$\\/, - reason: "Object shorthand with interpolation", - }, - - // Complex assignment - { - pattern: /\..*as \$\w+ \| [^|]+\) = /, - reason: "Assignment after variable binding", - }, - { - pattern: /\(\.\. \| select.*\) = /, - reason: "Assignment after recursive descent with select", - }, - { pattern: /\(\.\. \|.*\).*\|=/, reason: "Recursive descent assignment" }, - { - pattern: /\.\[\d+:\d+\] = \(.*,.*\)/, - reason: "Slice assignment with multiple values", - }, - - // Sorting/comparison edge cases - { pattern: /sort_by\(.*,.*\)/, reason: "sort_by with multiple keys" }, - { - pattern: /\[min, max, min_by\(\.\[1\]\)/, - reason: "min/max with complex comparison", - }, - - // Dynamic field access - { pattern: /\.foo\[\.baz\]/, reason: "Dynamic field access" }, - - // Keywords as identifiers - { pattern: /\$foreach.*\$and.*\$or/, reason: "Keywords as variables" }, - { pattern: /\{ \$x, as,/, reason: "Complex object shorthand" }, - { pattern: /\. as \{as:/, reason: "Complex destructuring" }, - { pattern: /label \$if/, reason: "label with keyword variable name" }, - - // fromjson edge cases - { pattern: /\.\[\] \| try \(fromjson/, reason: "fromjson with iteration" }, - - // try-catch edge cases - { pattern: /try \(if.*error end\) catch.*\/\//, reason: "try-catch with //" }, - { - pattern: /try.*\.\[\].*try \. catch \(if/, - reason: "Complex try-catch nesting", - }, - { - pattern: /\.\[\]\|\(try \. catch \(if/, - reason: "Complex try-catch with error propagation", - }, - { pattern: /\|= try tonumber/, reason: "Update with try" }, - - // implode edge case - { pattern: /0\[implode\]/, reason: "implode in index" }, - - // foreach edge cases - { pattern: /foreach.*as.*\(0, 1;/, reason: "foreach multiple inits" }, - - // map with try - { pattern: /map\(try \.a\[\]/, reason: "map with try" }, - - // String negation - { pattern: /\* range\(0; 12; 2\).*try -\./, reason: "String negation" }, - - // Negation of optional - { pattern: /try -\.\? catch/, reason: "Negation of optional expression" }, - { pattern: /try -\. catch \.$|try -\.\?/, reason: "Negation of optional" }, - - // null * string - { pattern: /\.\[\] \* "abc"/, reason: "null * string behavior" }, - - // NaN multiplication - { pattern: /\. \* \(nan,-nan\)/, reason: "NaN multiply" }, - - // Undefined variable - { pattern: /\[\$foo, \$bar\]/, reason: "undefined variable" }, - - // walk with generator - { pattern: /walk\(\.,\d+\)/, reason: "walk with generator argument" }, -]; - -/** - * Input patterns that should cause a test to be skipped - */ -const SKIP_INPUT_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [ - // Literal tab character in input - { pattern: /\t/, reason: "Literal tab in input" }, - // Emoji flag characters (multi-codepoint indexing issues) - { pattern: /🇬🇧/, reason: "Emoji flag codepoint indexing differs" }, - // unique sort order bug - { pattern: /^\[1,2,5,3,5,3,1,3\]$/, reason: "unique sort order differs" }, - // nan/NaN/Infinity literals in JSON input (not valid standard JSON) - { - pattern: /[:[,]nan[\],}]/, - reason: "nan literal in JSON input not supported", - }, - { - pattern: /Infinity/, - reason: "Infinity literal in JSON input not supported", - }, - { - pattern: /NaN/, - reason: "NaN literal in JSON input not supported", - }, -]; - -/** - * Get skip reason for a test - */ -export function getSkipReason( - fileName: string, - testName: string, - program?: string, - input?: string, - isErrorTest?: boolean, -): string | undefined { - // Check file-level skip first - if (SKIP_FILES.has(fileName)) { - return `File skipped: ${fileName}`; - } - - // Check individual test skip (exact match by test name) - const key = `${fileName}:${testName}`; - const exactMatch = SKIP_TESTS.get(key); - if (exactMatch) { - return exactMatch; - } - - // For error tests, also check by program name - if (program) { - const programKey = `${fileName}:${program}`; - const programMatch = SKIP_TESTS.get(programKey); - if (programMatch) { - return programMatch; - } - } - - // For error tests, only use exact SKIP_TESTS matches - if (isErrorTest) { - return undefined; - } - - // Check pattern-based skips against test name - for (const { pattern, reason } of SKIP_PATTERNS) { - if (pattern.test(testName)) { - return reason; - } - } - - // Check pattern-based skips against program content - if (program) { - for (const { pattern, reason } of SKIP_PATTERNS) { - if (pattern.test(program)) { - return reason; - } - } - } - - // Check input-based skips - if (input) { - for (const { pattern, reason } of SKIP_INPUT_PATTERNS) { - if (pattern.test(input)) { - return reason; - } - } - } - - return undefined; -} - -/** - * Check if entire file should be skipped - */ -export function isFileSkipped(fileName: string): boolean { - return SKIP_FILES.has(fileName); -} diff --git a/src/spec-tests/parser.ts b/src/spec-tests/parser.ts deleted file mode 100644 index f37464fb..00000000 --- a/src/spec-tests/parser.ts +++ /dev/null @@ -1,589 +0,0 @@ -/** - * Parser for Oils spec test format (.test.sh files) - * - * Format: - * - File headers: `## key: value` - * - Test cases start with: `#### Test Name` - * - Assertions: `## stdout:`, `## status:`, `## STDOUT: ... ## END` - * - Shell-specific: `## OK shell`, `## N-I shell`, `## BUG shell` - */ - -export interface FileHeader { - oilsFailuresAllowed?: number; - compareShells?: string[]; - tags?: string[]; -} - -export interface Assertion { - type: "stdout" | "stderr" | "status" | "stdout-json" | "stderr-json"; - value: string | number; - shells?: string[]; // If specified, only applies to these shells - variant?: "OK" | "N-I" | "BUG"; // Shell-specific variant type -} - -export interface TestCase { - name: string; - script: string; - assertions: Assertion[]; - lineNumber: number; - skip?: string; // If set, test should be skipped (value is reason) -} - -export interface ParsedSpecFile { - header: FileHeader; - testCases: TestCase[]; - filePath: string; -} - -/** - * Check if a shell name is bash-compatible (bash, bash-*, or just-bash) - */ -function isBashCompatible(s: string): boolean { - return s === "bash" || s.startsWith("bash-") || s === "just-bash"; -} - -/** - * Parse a spec test file content - */ -export function parseSpecFile( - content: string, - filePath: string, -): ParsedSpecFile { - const lines = content.split("\n"); - const header: FileHeader = {}; - const testCases: TestCase[] = []; - - let currentTest: TestCase | null = null; - let scriptLines: string[] = []; - let hasInlineCode = false; // True if ## code: directive was used - let inMultiLineBlock = false; - let multiLineType: - | "stdout" - | "stderr" - | "stdout-json" - | "stderr-json" - | null = null; - let multiLineContent: string[] = []; - let multiLineShells: string[] | undefined; - let multiLineVariant: "OK" | "N-I" | "BUG" | undefined; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const lineNumber = i + 1; - - // Inside a multi-line block - if (inMultiLineBlock) { - // Handle both "## END" and "## END:" (with trailing colon) - if (line === "## END" || line === "## END:") { - // End of multi-line block - if (currentTest && multiLineType) { - currentTest.assertions.push({ - type: multiLineType, - value: multiLineContent.join("\n"), - shells: multiLineShells, - variant: multiLineVariant, - }); - } - inMultiLineBlock = false; - multiLineType = null; - multiLineContent = []; - multiLineShells = undefined; - multiLineVariant = undefined; - continue; - } - // Check if another assertion is starting (ends current block without ## END) - if (line.startsWith("## ") && isAssertionLine(line.slice(3))) { - // End current block first - if (currentTest && multiLineType) { - currentTest.assertions.push({ - type: multiLineType, - value: multiLineContent.join("\n"), - shells: multiLineShells, - variant: multiLineVariant, - }); - } - inMultiLineBlock = false; - multiLineType = null; - multiLineContent = []; - multiLineShells = undefined; - multiLineVariant = undefined; - // Don't continue - fall through to process this line as an assertion - } else { - multiLineContent.push(line); - continue; - } - } - - // Test case header - if (line.startsWith("#### ")) { - // Save previous test case - if (currentTest) { - currentTest.script = scriptLines.join("\n").trim(); - if (currentTest.script || currentTest.assertions.length > 0) { - testCases.push(currentTest); - } - } - - // Start new test case - const name = line.slice(5).trim(); - currentTest = { - name, - script: "", - assertions: [], - lineNumber, - }; - scriptLines = []; - hasInlineCode = false; - continue; - } - - // Assertion line (starts with ##) - if (line.startsWith("## ")) { - const assertionLine = line.slice(3); - - // File headers (before first test case) - if (!currentTest) { - parseHeaderLine(assertionLine, header); - continue; - } - - // Check for shell-specific variant prefix (BUG, BUG-2, OK, OK-2, N-I, etc.) - const variantMatch = assertionLine.match( - /^(OK(?:-\d+)?|N-I|BUG(?:-\d+)?)\s+([a-z0-9/.-]+)\s+(.+)$/i, - ); - if (variantMatch) { - const variant = variantMatch[1] as "OK" | "N-I" | "BUG"; - const shells = variantMatch[2].split("/"); - const rest = variantMatch[3]; - - // Check if it's a multi-line start (allow trailing whitespace) - const multiLineMatch = rest.match(/^(STDOUT|STDERR):\s*$/); - if (multiLineMatch) { - inMultiLineBlock = true; - multiLineType = multiLineMatch[1].toLowerCase() as - | "stdout" - | "stderr"; - multiLineContent = []; - multiLineShells = shells; - multiLineVariant = variant; - continue; - } - - // Single-line shell-specific assertion - const assertion = parseSingleLineAssertion(rest); - if (assertion) { - assertion.shells = shells; - assertion.variant = variant; - currentTest.assertions.push(assertion); - } - continue; - } - - // Check for multi-line block start (allow trailing whitespace) - const multiLineStart = assertionLine.match(/^(STDOUT|STDERR):\s*$/); - if (multiLineStart) { - inMultiLineBlock = true; - multiLineType = multiLineStart[1].toLowerCase() as "stdout" | "stderr"; - multiLineContent = []; - continue; - } - - // Check for SKIP directive - // Supports both "## SKIP: reason" and "## SKIP (unimplementable): reason" formats - const skipMatch = assertionLine.match( - /^SKIP(?:\s*\([^)]+\))?(?::\s*(.*))?$/i, - ); - if (skipMatch) { - currentTest.skip = skipMatch[1] || "skipped"; - continue; - } - - // Check for code: directive (inline script) - const codeMatch = assertionLine.match(/^code:\s*(.*)$/); - if (codeMatch) { - // Override any existing script lines with the inline code - scriptLines = [codeMatch[1]]; - hasInlineCode = true; // Don't add any more script lines - continue; - } - - // Single-line assertion - const assertion = parseSingleLineAssertion(assertionLine); - if (assertion) { - currentTest.assertions.push(assertion); - } - continue; - } - - // Regular script line (only add if we're in a test case and no inline code was used) - if (currentTest && !hasInlineCode) { - scriptLines.push(line); - } - } - - // Save last test case - if (currentTest) { - currentTest.script = scriptLines.join("\n").trim(); - if (currentTest.script || currentTest.assertions.length > 0) { - testCases.push(currentTest); - } - } - - return { header, testCases, filePath }; -} - -/** - * Check if a line (without the ## prefix) is an assertion line - */ -function isAssertionLine(line: string): boolean { - // Shell-specific variant (BUG, BUG-2, OK, OK-2, N-I, etc.) - if (/^(OK(?:-\d+)?|N-I|BUG(?:-\d+)?)\s+[a-z0-9/.-]+\s+/i.test(line)) { - return true; - } - // Multi-line block start (allow trailing whitespace) - if (/^(STDOUT|STDERR):\s*$/.test(line)) { - return true; - } - // Single-line assertions - if (/^(stdout|stderr|status|stdout-json|stderr-json):/.test(line)) { - return true; - } - return false; -} - -function parseHeaderLine(line: string, header: FileHeader): void { - const colonIndex = line.indexOf(":"); - if (colonIndex === -1) return; - - const key = line.slice(0, colonIndex).trim(); - const value = line.slice(colonIndex + 1).trim(); - - switch (key) { - case "oils_failures_allowed": - header.oilsFailuresAllowed = parseInt(value, 10); - break; - case "compare_shells": - header.compareShells = value.split(/\s+/); - break; - case "tags": - header.tags = value.split(/\s+/); - break; - } -} - -function parseSingleLineAssertion(line: string): Assertion | null { - // stdout: value - const stdoutMatch = line.match(/^stdout:\s*(.*)$/); - if (stdoutMatch) { - return { type: "stdout", value: stdoutMatch[1] }; - } - - // stderr: value - const stderrMatch = line.match(/^stderr:\s*(.*)$/); - if (stderrMatch) { - return { type: "stderr", value: stderrMatch[1] }; - } - - // status: number - const statusMatch = line.match(/^status:\s*(\d+)$/); - if (statusMatch) { - return { type: "status", value: parseInt(statusMatch[1], 10) }; - } - - // stdout-json: "value" - const stdoutJsonMatch = line.match(/^stdout-json:\s*(.+)$/); - if (stdoutJsonMatch) { - try { - const parsed = JSON.parse(stdoutJsonMatch[1]); - return { type: "stdout-json", value: parsed }; - } catch { - // If JSON parse fails, use raw value - return { type: "stdout-json", value: stdoutJsonMatch[1] }; - } - } - - // stderr-json: "value" - const stderrJsonMatch = line.match(/^stderr-json:\s*(.+)$/); - if (stderrJsonMatch) { - try { - const parsed = JSON.parse(stderrJsonMatch[1]); - return { type: "stderr-json", value: parsed }; - } catch { - return { type: "stderr-json", value: stderrJsonMatch[1] }; - } - } - - return null; -} - -/** - * Get the expected stdout for a test case (considering bash-specific variants) - */ -export function getExpectedStdout(testCase: TestCase): string | null { - // First, look for default stdout (correct behavior) - just-bash prefers correctness over bug-compatibility - for (const assertion of testCase.assertions) { - if ( - (assertion.type === "stdout" || assertion.type === "stdout-json") && - !assertion.shells - ) { - return String(assertion.value); - } - } - - // Fall back to bash-specific BUG assertions (when there's no default and only BUG bash exists) - for (const assertion of testCase.assertions) { - if ( - (assertion.type === "stdout" || assertion.type === "stdout-json") && - assertion.variant === "BUG" && - assertion.shells?.some(isBashCompatible) - ) { - return String(assertion.value); - } - } - - return null; -} - -/** - * Get the expected stderr for a test case - */ -export function getExpectedStderr(testCase: TestCase): string | null { - // First, look for default stderr (correct behavior) - just-bash prefers correctness over bug-compatibility - for (const assertion of testCase.assertions) { - if ( - (assertion.type === "stderr" || assertion.type === "stderr-json") && - !assertion.shells - ) { - return String(assertion.value); - } - } - - // Fall back to bash-specific BUG assertions (when there's no default and only BUG bash exists) - for (const assertion of testCase.assertions) { - if ( - (assertion.type === "stderr" || assertion.type === "stderr-json") && - assertion.variant === "BUG" && - assertion.shells?.some(isBashCompatible) - ) { - return String(assertion.value); - } - } - - return null; -} - -/** - * Get the expected exit status for a test case - * Returns the default expected status (ignoring OK variants which are alternates) - */ -export function getExpectedStatus(testCase: TestCase): number | null { - // First, look for default status (correct behavior - just-bash prefers correctness) - for (const assertion of testCase.assertions) { - if ( - assertion.type === "status" && - !assertion.shells && - !assertion.variant - ) { - return assertion.value as number; - } - } - - // Fall back to bash-specific BUG status (when there's no default and only BUG bash exists) - for (const assertion of testCase.assertions) { - if ( - assertion.type === "status" && - assertion.variant === "BUG" && - assertion.shells?.some(isBashCompatible) - ) { - return assertion.value as number; - } - } - - return null; -} - -/** - * Get all acceptable stdout values for a test case - * This includes the default stdout and any OK variants for bash - */ -export function getAcceptableStdouts(testCase: TestCase): string[] { - const stdouts: string[] = []; - - // Add default stdout first (correct behavior - just-bash prefers correctness) - for (const assertion of testCase.assertions) { - if ( - (assertion.type === "stdout" || assertion.type === "stdout-json") && - !assertion.shells && - !assertion.variant - ) { - stdouts.push(String(assertion.value)); - break; - } - } - - // Add BUG bash stdout if present (also acceptable for bug-compatibility) - for (const assertion of testCase.assertions) { - if ( - (assertion.type === "stdout" || assertion.type === "stdout-json") && - assertion.variant === "BUG" && - assertion.shells?.some(isBashCompatible) - ) { - const value = String(assertion.value); - if (!stdouts.includes(value)) { - stdouts.push(value); - } - } - } - - // Add OK bash stdouts (these are also acceptable) - for (const assertion of testCase.assertions) { - if ( - (assertion.type === "stdout" || assertion.type === "stdout-json") && - assertion.variant === "OK" && - assertion.shells?.some(isBashCompatible) - ) { - const value = String(assertion.value); - if (!stdouts.includes(value)) { - stdouts.push(value); - } - } - } - - return stdouts; -} - -/** - * Get all acceptable stderr values for a test case - * This includes the default stderr and any OK variants for bash - */ -export function getAcceptableStderrs(testCase: TestCase): string[] { - const stderrs: string[] = []; - - // Add default stderr first (correct behavior - just-bash prefers correctness) - for (const assertion of testCase.assertions) { - if ( - (assertion.type === "stderr" || assertion.type === "stderr-json") && - !assertion.shells && - !assertion.variant - ) { - stderrs.push(String(assertion.value)); - break; - } - } - - // Add BUG bash stderr if present (also acceptable for bug-compatibility) - for (const assertion of testCase.assertions) { - if ( - (assertion.type === "stderr" || assertion.type === "stderr-json") && - assertion.variant === "BUG" && - assertion.shells?.some(isBashCompatible) - ) { - const value = String(assertion.value); - if (!stderrs.includes(value)) { - stderrs.push(value); - } - } - } - - // Add OK bash stderrs (these are also acceptable) - for (const assertion of testCase.assertions) { - if ( - (assertion.type === "stderr" || assertion.type === "stderr-json") && - assertion.variant === "OK" && - assertion.shells?.some(isBashCompatible) - ) { - const value = String(assertion.value); - if (!stderrs.includes(value)) { - stderrs.push(value); - } - } - } - - return stderrs; -} - -/** - * Get all acceptable exit statuses for a test case - * This includes the default status and any OK variants for bash - */ -export function getAcceptableStatuses(testCase: TestCase): number[] { - const statuses: number[] = []; - - // Add default status first (correct behavior - just-bash prefers correctness) - let foundDefaultStatus = false; - for (const assertion of testCase.assertions) { - if ( - assertion.type === "status" && - !assertion.shells && - !assertion.variant - ) { - statuses.push(assertion.value as number); - foundDefaultStatus = true; - break; - } - } - - // Add BUG bash status if present (also acceptable for bug-compatibility) - for (const assertion of testCase.assertions) { - if ( - assertion.type === "status" && - assertion.variant === "BUG" && - assertion.shells?.some(isBashCompatible) - ) { - const value = assertion.value as number; - if (!statuses.includes(value)) { - statuses.push(value); - } - } - } - - // Check if there are any OK or BUG bash status variants - const hasOKBashStatus = testCase.assertions.some( - (a) => - a.type === "status" && - a.variant === "OK" && - a.shells?.some(isBashCompatible), - ); - const hasBUGBashStatus = testCase.assertions.some( - (a) => - a.type === "status" && - a.variant === "BUG" && - a.shells?.some(isBashCompatible), - ); - - // If no explicit default status BUT there are OK or BUG bash status variants, - // the implicit default is 0 (success). This matters because we want to - // accept BOTH the implicit 0 AND the bash-specific status variants. - // For BUG variants: if there's a BUG bash status but no default, the implicit - // correct behavior is status 0, and we should accept that along with the buggy behavior. - if (!foundDefaultStatus && (hasOKBashStatus || hasBUGBashStatus)) { - if (!statuses.includes(0)) { - statuses.push(0); - } - } - - // Add OK bash statuses (these are also acceptable) - for (const assertion of testCase.assertions) { - if ( - assertion.type === "status" && - assertion.variant === "OK" && - assertion.shells?.some(isBashCompatible) - ) { - const value = assertion.value as number; - if (!statuses.includes(value)) { - statuses.push(value); - } - } - } - - return statuses; -} - -/** - * Check if a test case is marked as N-I (Not Implemented) for bash - */ -export function isNotImplementedForBash(testCase: TestCase): boolean { - return testCase.assertions.some( - (a) => a.variant === "N-I" && a.shells?.some(isBashCompatible), - ); -} diff --git a/src/spec-tests/runner.ts b/src/spec-tests/runner.ts deleted file mode 100644 index 292bddf2..00000000 --- a/src/spec-tests/runner.ts +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Spec test runner - executes parsed spec tests against Bash - */ - -import { Bash } from "../Bash.js"; -import { - getAcceptableStatuses, - getAcceptableStderrs, - getAcceptableStdouts, - getExpectedStatus, - getExpectedStderr, - getExpectedStdout, - isNotImplementedForBash, - type ParsedSpecFile, - type TestCase, -} from "./parser.js"; -import { testHelperCommands } from "./test-commands.js"; - -export interface TestResult { - testCase: TestCase; - passed: boolean; - skipped: boolean; - skipReason?: string; - /** Test was expected to fail (## SKIP) but unexpectedly passed */ - unexpectedPass?: boolean; - actualStdout?: string; - actualStderr?: string; - actualStatus?: number; - expectedStdout?: string | null; - expectedStderr?: string | null; - expectedStatus?: number | null; - error?: string; - /** File path for the test file (used for UNEXPECTED PASS fix commands) */ - filePath?: string; -} - -export interface RunOptions { - /** Only run tests matching this pattern */ - filter?: RegExp; - /** Custom Bash options */ - bashEnvOptions?: ConstructorParameters[0]; - /** File path for the test file */ - filePath?: string; -} - -/** - * Run a single test case - */ -export async function runTestCase( - testCase: TestCase, - options: RunOptions = {}, -): Promise { - // Track if test is expected to fail (## SKIP) - we'll still run it - const expectedToFail = !!testCase.skip; - const skipReason = testCase.skip; - - // These are true skips - we can't run these tests at all - if (isNotImplementedForBash(testCase)) { - return { - testCase, - passed: true, - skipped: true, - skipReason: "N-I (Not Implemented) for bash", - }; - } - - // Skip empty scripts - if (!testCase.script.trim()) { - return { - testCase, - passed: true, - skipped: true, - skipReason: "Empty script", - }; - } - - // Skip xtrace tests (set -x is accepted but trace output not implemented) - if (requiresXtrace(testCase)) { - return { - testCase, - passed: true, - skipped: true, - skipReason: "xtrace (set -x) trace output not implemented", - }; - } - - // Create a fresh Bash for each test - // Note: Don't use dotfiles here as they interfere with glob tests like "echo .*" - const env = new Bash({ - files: { - "/tmp/_keep": "", - // Set up /dev/zero as a character device placeholder - "/dev/zero": "", - // Set up /bin directory - "/bin/_keep": "", - }, - cwd: "/tmp", - env: { - HOME: "/tmp", - TMP: "/tmp", - TMPDIR: "/tmp", - SH: "bash", // For tests that check which shell is running - }, - customCommands: testHelperCommands, - ...options.bashEnvOptions, - }); - - // Set up /tmp with sticky bit (mode 1777) for tests that check it - await env.fs.chmod("/tmp", 0o1777); - - try { - // Use rawScript to preserve leading whitespace for here-docs - const result = await env.exec(testCase.script, { rawScript: true }); - - const expectedStdout = getExpectedStdout(testCase); - const expectedStderr = getExpectedStderr(testCase); - const expectedStatus = getExpectedStatus(testCase); - - let passed = true; - const errors: string[] = []; - - // Compare stdout - // Use getAcceptableStdouts to handle OK variants (e.g., "## OK bash stdout-json: ...") - const acceptableStdouts = getAcceptableStdouts(testCase); - if (acceptableStdouts.length > 0) { - const normalizedActual = normalizeOutput(result.stdout); - const normalizedAcceptable = acceptableStdouts.map((s) => - normalizeOutput(s), - ); - - if (!normalizedAcceptable.includes(normalizedActual)) { - passed = false; - const stdoutDesc = - normalizedAcceptable.length === 1 - ? JSON.stringify(normalizedAcceptable[0]) - : `one of [${normalizedAcceptable.map((s) => JSON.stringify(s)).join(", ")}]`; - errors.push( - `stdout mismatch:\n expected: ${stdoutDesc}\n actual: ${JSON.stringify(normalizedActual)}`, - ); - } - } - - // Compare stderr - // Use getAcceptableStderrs to handle OK variants (e.g., "## OK bash STDERR: ...") - const acceptableStderrs = getAcceptableStderrs(testCase); - if (acceptableStderrs.length > 0) { - const normalizedActual = normalizeOutput(result.stderr); - const normalizedAcceptable = acceptableStderrs.map((s) => - normalizeOutput(s), - ); - - if (!normalizedAcceptable.includes(normalizedActual)) { - passed = false; - const stderrDesc = - normalizedAcceptable.length === 1 - ? JSON.stringify(normalizedAcceptable[0]) - : `one of [${normalizedAcceptable.map((s) => JSON.stringify(s)).join(", ")}]`; - errors.push( - `stderr mismatch:\n expected: ${stderrDesc}\n actual: ${JSON.stringify(normalizedActual)}`, - ); - } - } - - // Compare exit status - // Use getAcceptableStatuses to handle OK variants (e.g., "## OK bash status: 1") - const acceptableStatuses = getAcceptableStatuses(testCase); - if (acceptableStatuses.length > 0) { - if (!acceptableStatuses.includes(result.exitCode)) { - passed = false; - const statusDesc = - acceptableStatuses.length === 1 - ? String(acceptableStatuses[0]) - : `one of [${acceptableStatuses.join(", ")}]`; - errors.push( - `status mismatch: expected ${statusDesc}, got ${result.exitCode}`, - ); - } - } - - // Handle ## SKIP tests: if expected to fail but actually passed, that's an unexpected pass - if (expectedToFail) { - if (passed) { - // Test was expected to fail but passed - report as failure so we can unskip it - // The SKIP marker is typically on the line after the test name - const skipLineNumber = testCase.lineNumber + 1; - const filePath = options.filePath || ""; - return { - testCase, - passed: false, - skipped: false, - unexpectedPass: true, - actualStdout: result.stdout, - actualStderr: result.stderr, - actualStatus: result.exitCode, - expectedStdout, - expectedStderr, - expectedStatus, - filePath, - error: `FAIL because of UNEXPECTED PASS: This test was marked ## SKIP (${skipReason}) but now passes. Remove with: sed -i '' '${skipLineNumber}d' ${filePath}`, - }; - } - // Test was expected to fail and did fail - that's fine, mark as skipped - return { - testCase, - passed: true, - skipped: true, - skipReason: `## SKIP: ${skipReason}`, - actualStdout: result.stdout, - actualStderr: result.stderr, - actualStatus: result.exitCode, - expectedStdout, - expectedStderr, - expectedStatus, - }; - } - - return { - testCase, - passed, - skipped: false, - actualStdout: result.stdout, - actualStderr: result.stderr, - actualStatus: result.exitCode, - expectedStdout, - expectedStderr, - expectedStatus, - error: errors.length > 0 ? errors.join("\n") : undefined, - }; - } catch (e) { - // If test was expected to fail and threw an error, that counts as expected failure - if (expectedToFail) { - return { - testCase, - passed: true, - skipped: true, - skipReason: `## SKIP: ${skipReason}`, - error: `Execution error (expected): ${e instanceof Error ? e.message : String(e)}`, - }; - } - return { - testCase, - passed: false, - skipped: false, - error: `Execution error: ${e instanceof Error ? e.message : String(e)}`, - }; - } -} - -/** - * Run all tests in a parsed spec file - */ -export async function runSpecFile( - specFile: ParsedSpecFile, - options: RunOptions = {}, -): Promise { - const results: TestResult[] = []; - - for (const testCase of specFile.testCases) { - if (options.filter && !options.filter.test(testCase.name)) { - continue; - } - - const result = await runTestCase(testCase, options); - results.push(result); - } - - return results; -} - -/** - * Check if a test requires xtrace (set -x) trace output - */ -function requiresXtrace(testCase: TestCase): boolean { - // Check if script uses set -x and expects trace output in stderr - if ( - /\bset\s+-x\b/.test(testCase.script) || - /\bset\s+-o\s+xtrace\b/.test(testCase.script) - ) { - // Check if test expects xtrace-style output (lines starting with +) - const expectedStderr = getExpectedStderr(testCase); - if (expectedStderr && /^\+\s/m.test(expectedStderr)) { - return true; - } - } - return false; -} - -/** - * Normalize output for comparison - * - Strip comment lines (starting with #) - these are metadata in spec test STDOUT sections - * - Trim trailing whitespace from each line - * - Ensure consistent line endings - * - Trim trailing newline - */ -function normalizeOutput(output: string): string { - return output - .split("\n") - .filter((line) => !line.startsWith("#")) // Strip comment lines - .map((line) => line.trimEnd()) - .join("\n") - .replace(/\n+$/, ""); -} - -/** - * Get summary statistics for test results - */ -export function getResultsSummary(results: TestResult[]): { - total: number; - passed: number; - failed: number; - skipped: number; -} { - return { - total: results.length, - passed: results.filter((r) => r.passed && !r.skipped).length, - failed: results.filter((r) => !r.passed).length, - skipped: results.filter((r) => r.skipped).length, - }; -} diff --git a/src/spec-tests/sed/cases/LICENSE-busybox b/src/spec-tests/sed/cases/LICENSE-busybox deleted file mode 100644 index 70a3b72f..00000000 --- a/src/spec-tests/sed/cases/LICENSE-busybox +++ /dev/null @@ -1,11 +0,0 @@ ---- A note on GPL versions - -BusyBox is distributed under version 2 of the General Public License (included -in its entirety, below). Version 2 is the only version of this license which -this version of BusyBox (or modified versions derived from this one) may be -... -This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may -consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Library General -Public License instead of this License. diff --git a/src/spec-tests/sed/cases/LICENSE-pythonsed b/src/spec-tests/sed/cases/LICENSE-pythonsed deleted file mode 100644 index a563c488..00000000 --- a/src/spec-tests/sed/cases/LICENSE-pythonsed +++ /dev/null @@ -1 +0,0 @@ -PythonSed is licensed under the BSD license diff --git a/src/spec-tests/sed/cases/busybox-sed.tests b/src/spec-tests/sed/cases/busybox-sed.tests deleted file mode 100755 index 448358b5..00000000 --- a/src/spec-tests/sed/cases/busybox-sed.tests +++ /dev/null @@ -1,424 +0,0 @@ -#!/bin/sh - -# SUSv3 compliant sed tests. -# Copyright 2005 by Rob Landley -# Licensed under GPLv2, see file LICENSE in this source tree. - -. ./testing.sh - -# testing "description" "commands" "result" "infile" "stdin" - -# Corner cases -testing "sed no files (stdin)" 'sed ""' "hello\n" "" "hello\n" -testing "sed explicit stdin" 'sed "" -' "hello\n" "" "hello\n" -testing "sed handles empty lines" "sed -e 's/\$/@/'" "@\n" "" "\n" -testing "sed stdin twice" 'sed "" - -' "hello" "" "hello" - -# Trailing EOF. -# Match $, at end of each file or all files? - -# -e corner cases -# without -e -# multiple -e -# interact with a -# -eee arg1 arg2 arg3 -# -f corner cases -# -e -f -e -# -n corner cases -# no newline at EOF? -# -r corner cases -# Just make sure it works. -# -i corner cases: -# sed -i - -# permissions -# -i on a symlink -# on a directory -# With $ last-line test -# Continue with \ -# End of script with trailing \ - -# command list -testing "sed accepts blanks before command" "sed -e '1 d'" "" "" "" -testing "sed accepts newlines in -e" "sed -e 'i\ -1 -a\ -3'" "1\n2\n3\n" "" "2\n" -testing "sed accepts multiple -e" "sed -e 'i\' -e '1' -e 'a\' -e '3'" \ - "1\n2\n3\n" "" "2\n" - -# substitutions -testing "sed -n" "sed -n -e s/foo/bar/ -e s/bar/baz/" "" "" "foo\n" -testing "sed with empty match" "sed 's/z*//g'" "string\n" "" "string\n" -testing "sed s//p" "sed -e s/foo/bar/p -e s/bar/baz/p" "bar\nbaz\nbaz\n" \ - "" "foo\n" -testing "sed -n s//p" "sed -ne s/abc/def/p" "def\n" "" "abc\n" -testing "sed s//g (exhaustive)" "sed -e 's/[[:space:]]*/,/g'" ",1,2,3,4,5,\n" \ - "" "12345\n" -testing "sed s arbitrary delimiter" "sed -e 's woo boing '" "boing\n" "" "woo\n" -testing "sed s chains" "sed -e s/foo/bar/ -e s/bar/baz/" "baz\n" "" "foo\n" -testing "sed s chains2" "sed -e s/foo/bar/ -e s/baz/nee/" "bar\n" "" "foo\n" -testing "sed s [delimiter]" "sed -e 's@[@]@@'" "onetwo" "" "one@two" -testing "sed s with \\t (GNU ext)" "sed 's/\t/ /'" "one two" "" "one\ttwo" - -# branch -testing "sed b (branch)" "sed -e 'b one;p;: one'" "foo\n" "" "foo\n" -testing "sed b (branch with no label jumps to end)" "sed -e 'b;p'" \ - "foo\n" "" "foo\n" - -# test and branch -testing "sed t (test/branch)" "sed -e 's/a/1/;t one;p;: one;p'" \ - "1\n1\nb\nb\nb\nc\nc\nc\n" "" "a\nb\nc\n" -testing "sed t (test/branch clears test bit)" "sed -e 's/a/b/;:loop;t loop'" \ - "b\nb\nc\n" "" "a\nb\nc\n" -testing "sed T (!test/branch)" "sed -e 's/a/1/;T notone;p;: notone;p'" \ - "1\n1\n1\nb\nb\nc\nc\n" "" "a\nb\nc\n" - -testing "sed n (flushes pattern space, terminates early)" "sed -e 'n;p'" \ - "a\nb\nb\nc\n" "" "a\nb\nc\n" - -# non-GNU sed: N does _not_ flush pattern space, therefore c is eaten @ script end -# GNU sed: N flushes pattern space, therefore c is printed too @ script end -testing "sed N (flushes pattern space (GNU behavior))" "sed -e 'N;p'" \ - "a\nb\na\nb\nc\n" "" "a\nb\nc\n" - -testing "sed N test2" "sed ':a;N;s/\n/ /;ta'" \ - "a b c\n" "" "a\nb\nc\n" - -testing "sed N test3" "sed 'N;s/\n/ /'" \ - "a b\nc\n" "" "a\nb\nc\n" - -testing "sed address match newline" 'sed "/b/N;/b\\nc/i woo"' \ - "a\nwoo\nb\nc\nd\n" "" "a\nb\nc\nd\n" - -# Multiple lines in pattern space -testing "sed N (stops at end of input) and P (prints to first newline only)" \ - "sed -n 'N;P;p'" "a\na\nb\n" "" "a\nb\nc\n" - -# Hold space -testing "sed G (append hold space to pattern space)" 'sed G' "a\n\nb\n\nc\n\n" \ - "" "a\nb\nc\n" -#testing "sed g/G (swap/append hold and patter space)" -#testing "sed g (swap hold/pattern space)" - -testing "sed d ends script iteration" \ - "sed -e '/ook/d;s/ook/ping/p;i woot'" "" "" "ook\n" -testing "sed d ends script iteration (2)" \ - "sed -e '/ook/d;a\' -e 'bang'" "woot\nbang\n" "" "ook\nwoot\n" - -# Multiple files, with varying newlines and NUL bytes -test x"$SKIP_KNOWN_BUGS" = x"" && { -testing "sed embedded NUL" "sed -e 's/woo/bang/'" "\0bang\0woo\0" "" \ - "\0woo\0woo\0" -} -testing "sed embedded NUL g" "sed -e 's/woo/bang/g'" "bang\0bang\0" "" \ - "woo\0woo\0" -test x"$SKIP_KNOWN_BUGS" = x"" && { -$ECHO -e "/woo/a he\0llo" > sed.commands -testing "sed NUL in command" "sed -f sed.commands" "woo\nhe\0llo\n" "" "woo" -rm sed.commands -} - -# sed has funky behavior with newlines at the end of file. Test lots of -# corner cases with the optional newline appending behavior. - -testing "sed normal newlines" "sed -e 's/woo/bang/' input -" "bang\nbang\n" \ - "woo\n" "woo\n" -testing "sed leave off trailing newline" "sed -e 's/woo/bang/' input -" \ - "bang\nbang" "woo\n" "woo" -testing "sed autoinsert newline" "sed -e 's/woo/bang/' input -" "bang\nbang" \ - "woo" "woo" -testing "sed empty file plus cat" "sed -e 's/nohit//' input -" "one\ntwo" \ - "" "one\ntwo" -testing "sed cat plus empty file" "sed -e 's/nohit//' input -" "one\ntwo" \ - "one\ntwo" "" -testing "sed append autoinserts newline" "sed -e '/woot/a woo' -" \ - "woot\nwoo\n" "" "woot" -testing "sed append autoinserts newline 2" "sed -e '/oot/a woo' - input" \ - "woot\nwoo\nboot\nwoo\n" "boot" "woot" -testing "sed append autoinserts newline 3" "sed -e '/oot/a woo' -i input && cat input" \ - "boot\nwoo\n" "boot" "" -testing "sed insert doesn't autoinsert newline" "sed -e '/woot/i woo' -" \ - "woo\nwoot" "" "woot" -testing "sed print autoinsert newlines" "sed -e 'p' -" "one\none" "" "one" -testing "sed print autoinsert newlines two files" "sed -e 'p' input -" \ - "one\none\ntwo\ntwo" "one" "two" -testing "sed noprint, no match, no newline" "sed -ne 's/woo/bang/' input" \ - "" "no\n" "" -testing "sed selective matches with one nl" "sed -ne 's/woo/bang/p' input -" \ - "a bang\nc bang\n" "a woo\nb no" "c woo\nd no" -testing "sed selective matches insert newline" \ - "sed -ne 's/woo/bang/p' input -" "a bang\nb bang\nd bang" \ - "a woo\nb woo" "c no\nd woo" -testing "sed selective matches noinsert newline" \ - "sed -ne 's/woo/bang/p' input -" "a bang\nb bang" "a woo\nb woo" \ - "c no\nd no" -testing "sed clusternewline" \ - "sed -e '/one/a 111' -e '/two/i 222' -e p input -" \ - "one\none\n111\n222\ntwo\ntwo" "one" "two" -testing "sed subst+write" \ - "sed -e 's/i/z/' -e 'woutputw' input -; $ECHO -n X; cat outputw" \ - "thzngy\nagaznXthzngy\nagazn" "thingy" "again" -rm outputw -testing "sed trailing NUL" \ - "sed 's/i/z/' input -" \ - "a\0b\0\nc" "a\0b\0" "c" -testing "sed escaped newline in command" \ - "sed 's/a/z\\ -z/' input" \ - "z\nz" "a" "" - -# Test end-of-file matching behavior - -testing "sed match EOF" "sed -e '"'$p'"'" "hello\nthere\nthere" "" \ - "hello\nthere" -testing "sed match EOF two files" "sed -e '"'$p'"' input -" \ - "one\ntwo\nthree\nfour\nfour" "one\ntwo" "three\nfour" -# sed match EOF inline: gnu sed 4.1.5 outputs this: -#00000000 6f 6e 65 0a 6f 6f 6b 0a 6f 6f 6b 0a 74 77 6f 0a |one.ook.ook.two.| -#00000010 0a 74 68 72 65 65 0a 6f 6f 6b 0a 6f 6f 6b 0a 66 |.three.ook.ook.f| -#00000020 6f 75 72 |our| -# which looks buggy to me. -$ECHO -ne "three\nfour" > input2 -testing "sed match EOF inline" \ - "sed -e '"'$i ook'"' -i input input2 && cat input input2" \ - "one\nook\ntwothree\nook\nfour" "one\ntwo" "" -rm input2 - -# Test lie-to-autoconf - -testing "sed lie-to-autoconf" "sed --version | grep -o 'GNU sed version '" \ - "GNU sed version \n" "" "" - -# Jump to nonexistent label -test x"$SKIP_KNOWN_BUGS" = x"" && { -# Incompatibility: illegal jump is not detected if input is "" -# (that is, no lines at all). GNU sed 4.1.5 complains even in this case -testing "sed nonexistent label" "sed -e 'b walrus' 2>/dev/null || echo yes" \ - "yes\n" "" "" -} - -testing "sed backref from empty s uses range regex" \ - "sed -e '/woot/s//eep \0 eep/'" "eep woot eep" "" "woot" - -testing "sed backref from empty s uses range regex with newline" \ - "sed -e '/woot/s//eep \0 eep/'" "eep woot eep\n" "" "woot\n" - -# -i with no filename - -touch ./- # Detect gnu failure mode here. -testing "sed -i with no arg [GNUFAIL]" "sed -e '' -i 2> /dev/null || echo yes" \ - "yes\n" "" "" -rm ./- # Clean up - -testing "sed s/xxx/[/" "sed -e 's/xxx/[/'" "[\n" "" "xxx\n" - -# Ponder this a bit more, why "woo not found" from gnu version? -#testing "sed doesn't substitute in deleted line" \ -# "sed -e '/ook/d;s/ook//;t woo;a bang;'" "bang" "" "ook\n" - -# This makes both seds very unhappy. Why? -#testing "sed -g (exhaustive)" "sed -e 's/[[:space:]]*/,/g'" ",1,2,3,4,5," \ -# "" "12345" - -# testing "description" "commands" "result" "infile" "stdin" - -testing "sed n command must reset 'substituted' bit" \ - "sed 's/1/x/;T;n;: next;s/3/y/;t quit;n;b next;: quit;q'" \ - "0\nx\n2\ny\n" "" "0\n1\n2\n3\n" - -testing "sed d does not break n,m matching" \ - "sed -n '1d;1,3p'" \ - "second\nthird\n" "" "first\nsecond\nthird\nfourth\n" - -testing "sed d does not break n,regex matching" \ - "sed -n '1d;1,/hir/p'" \ - "second\nthird\n" "" "first\nsecond\nthird\nfourth\n" - -testing "sed d does not break n,regex matching #2" \ - "sed -n '1,5d;1,/hir/p'" \ - "second2\nthird2\n" "" \ - "first\nsecond\nthird\nfourth\n""first2\nsecond2\nthird2\nfourth2\n" - -testing "sed 2d;2,1p (gnu compat)" \ - "sed -n '2d;2,1p'" \ - "third\n" "" \ - "first\nsecond\nthird\nfourth\n" - -# Regex means: "match / at BOL or nothing, then one or more not-slashes". -# The bug was that second slash in /usr/lib was treated as "at BOL" too. -testing "sed beginning (^) matches only once" \ - "sed 's,\(^/\|\)[^/][^/]*,>\0<,g'" \ - ">/usrlib<\n" "" \ - "/usr/lib\n" - -testing "sed c" \ - "sed 'crepl'" \ - "repl\nrepl\n" "" \ - "first\nsecond\n" - -testing "sed nested {}s" \ - "sed '/asd/ { p; /s/ { s/s/c/ }; p; q }'" \ - "qwe\nasd\nacd\nacd\n" "" \ - "qwe\nasd\nzxc\n" - -testing "sed a cmd ended by double backslash" \ - "sed -e '/| one /a \\ - | three \\\\' -e '/| one-/a \\ - | three-* \\\\'" \ -' | one \\ - | three \\ - | two \\ -' '' \ -' | one \\ - | two \\ -' - -testing "sed a cmd understands \\n,\\t,\\r" \ - "sed '/1/a\\tzero\\rone\\ntwo'" \ - "line1\n\tzero\rone\ntwo\n" "" "line1\n" - -testing "sed i cmd understands \\n,\\t,\\r" \ - "sed '/1/i\\tzero\\rone\\ntwo'" \ - "\tzero\rone\ntwo\nline1\n" "" "line1\n" - -# first three lines are deleted; 4th line is matched and printed by "2,3" and by "4" ranges -testing "sed with N skipping lines past ranges on next cmds" \ - "sed -n '1{N;N;d};1p;2,3p;3p;4p'" \ - "4\n4\n" "" "1\n2\n3\n4\n" - -testing "sed -i with address modifies all files, not only first" \ - "cp input input2; sed -i -e '1s/foo/bar/' input input2 && cat input input2; rm input2" \ - "bar\nbar\n" "foo\n" "" - -testing "sed understands \r" \ - "sed 's/r/\r/'" \ - "\rrr\n" "" "rrr\n" - -testing "sed -i finishes ranges correctly" \ - "sed '1,2d' -i input; echo \$?; cat input" \ - "0\n3\n4\n" "1\n2\n3\n4\n" "" - -testing "sed zero chars match/replace advances correctly 1" \ - "sed 's/l*/@/g'" \ - "@h@e@o@\n" "" "helllo\n" - -testing "sed zero chars match/replace advances correctly 2" \ - "sed 's [^ .]* x g'" \ - "x x.x\n" "" " a.b\n" - -testing "sed zero chars match/replace logic must not falsely trigger here 1" \ - "sed 's/a/A/g'" \ - "_AAA1AA\n" "" "_aaa1aa\n" - -testing "sed zero chars match/replace logic must not falsely trigger here 2" \ - "sed 's/ *$/_/g'" \ - "qwerty_\n" "" "qwerty\n" - -# the pattern here is interpreted as "9+", not as "9\+" -testing "sed special char as s/// delimiter, in pattern" \ - "sed 's+9\++X+'" \ - "X8=17\n" "" "9+8=17\n" - -# Matching GNU sed 4.8: -# in replacement string, "\&" remains "\&", not interpreted as "&" -testing "sed special char as s/// delimiter, in replacement 1" \ - "sed 's&9&X\&&'" \ - "X&+8=17\n" "" "9+8=17\n" -# in replacement string, "\1" is interpreted as "1" -testing "sed special char as s/// delimiter, in replacement 2" \ - "sed 's1\(9\)1X\11'" \ - "X1+8=17\n" "" "9+8=17\n" - -testing "sed /\$_in_regex/ should not match newlines, only end-of-line" \ - "sed ': testcont; /\\\\$/{ =; N; b testcont }'" \ - "\ -this is a regular line -2 -line with \\ -continuation -more regular lines -5 -line with \\ -continuation -" \ - "" "\ -this is a regular line -line with \\ -continuation -more regular lines -line with \\ -continuation -" - -testing "sed s///NUM test" \ - "sed -e 's/a/b/2; s/a/c/g'" \ - "cb\n" "" "aa\n" - -testing "sed /regex/,N{...} addresses work" \ - "sed /^2/,2{d}" \ - "1\n3\n4\n5\n" \ - "" \ - "1\n2\n3\n4\n5\n" - -testing "sed /regex/,+N{...} addresses work" \ - "sed /^2/,+2{d}" \ - "1\n5\n" \ - "" \ - "1\n2\n3\n4\n5\n" - -testing "sed /regex/,+N{...} addresses work 2" \ - "sed -n '/a/,+1 p'" \ - "a\n1\na\n2\na\n3\n" \ - "" \ - "a\n1\nc\nc\na\n2\na\n3\n" - -testing "sed /regex/,+N{...} -i works" \ - "cat - >input2; sed /^4/,+2{d} -i input input2; echo \$?; cat input input2; rm input2" \ - "0\n""1\n2\n3\n7\n8\n""1\n2\n7\n8\n" \ - "1\n2\n3\n4\n5\n6\n7\n8\n" \ - "1\n2\n4\n5\n6\n7\n8\n" \ - -# GNU sed 4.2.1 would also accept "/^4/,+{d}" with the same meaning, we don't -testing "sed /regex/,+0{...} -i works" \ - "cat - >input2; sed /^4/,+0{d} -i input input2; echo \$?; cat input input2; rm input2" \ - "0\n""1\n2\n3\n5\n6\n7\n8\n""1\n2\n5\n6\n7\n8\n" \ - "1\n2\n3\n4\n5\n6\n7\n8\n" \ - "1\n2\n4\n5\n6\n7\n8\n" \ - -# GNU sed 4.2.1 would also accept "/^4/,+d" with the same meaning, we don't -testing "sed /regex/,+0 -i works" \ - "cat - >input2; sed /^4/,+0d -i input input2; echo \$?; cat input input2; rm input2" \ - "0\n""1\n2\n3\n5\n6\n7\n8\n""1\n2\n5\n6\n7\n8\n" \ - "1\n2\n3\n4\n5\n6\n7\n8\n" \ - "1\n2\n4\n5\n6\n7\n8\n" \ - -testing "sed 's///w FILE'" \ - "sed 's/qwe/ZZZ/wz'; cat z; rm z" \ - "123\nZZZ\nasd\n""ZZZ\n" \ - "" \ - "123\nqwe\nasd\n" - -testing "sed uses previous regexp" \ - "sed '/w/p;//q'" \ - "q\nw\nw\n" \ - "" \ - "q\nw\ne\nr\n" - -testing "sed ^ OR not^" \ - "sed -e 's/^a\|b//g'" \ - "ca\n" \ - "" \ - "abca\n" - -# This only works if file name is exactly the same. -# For example, w FILE; w ./FILE won't work. -testing "sed understands duplicate file name" \ - "sed -n -e '/a/w sed.output' -e '/c/w sed.output' 2>&1 && cat sed.output && rm sed.output" \ - "a\nc\n" \ - "" \ - "a\nb\nc\n" - - -# testing "description" "commands" "result" "infile" "stdin" - -exit $FAILCOUNT diff --git a/src/spec-tests/sed/cases/pythonsed-chang.suite b/src/spec-tests/sed/cases/pythonsed-chang.suite deleted file mode 100644 index 39adb499..00000000 --- a/src/spec-tests/sed/cases/pythonsed-chang.suite +++ /dev/null @@ -1,1968 +0,0 @@ -** Scripts and test cases from Roger Chang web site -** www.rtfiber.com.tw/~changyj/sed -** -** Scripts from the following sections: -** Nth line, Patterns, Multiline Matching, N-th Match in a file - -** Some lines end with blank. Don't save with an editor removing trailing spaces... - -** Fix: - -Replace the LAST N matches of PAT in a file. -Replace all but the last N matches of PAT in a file. - "6666" should be "number 6666" - -** -** Nth line -** - ---- -Get the 4th line of a file. ---- -4!d -q ---- -Line 1 AAAA -Line 2 BBBB -Line 3 CCCC -Line 4 DDDD -Line 5 EEEE ---- -Line 4 DDDD ---- - ---- -Get the 3rd through 6th lines of a file - 1. ---- -3,6!d ---- -Line 1 AAAA -Line 2 BBBB -Line 3 CCCC -Line 4 DDDD -Line 5 EEEE -Line 6 FFFF -Line 7 GGGG ---- -Line 3 CCCC -Line 4 DDDD -Line 5 EEEE -Line 6 FFFF ---- - ---- -Get the 3rd through 6th lines of a file - 2. ---- -3,6!d; 6q ---- ---- ---- - ---- -Delete the 3rd through 6th lines of a file. ---- -3,6d ---- -Line 1 AAAA -Line 2 BBBB -Line 3 CCCC -Line 4 DDDD -Line 5 EEEE -Line 6 FFFF -Line 7 GGGG ---- -Line 1 AAAA -Line 2 BBBB -Line 7 GGGG ---- - ---- -Get the last 6 lines of a file. ---- -:loop -$q -/^\([^\n]*\n\)\{5\}/D -N -b loop ---- -Line -9 -Line -8 -Line -7 -Line -6 -Line -5 -Line -4 -Line -3 -Line -2 -Line -1 ---- -Line -6 -Line -5 -Line -4 -Line -3 -Line -2 -Line -1 ---- - ---- -Delete the last 6 lines of a file - 1. ---- -:loop -1,5{ -$d -N -b loop -} -$d -N -P -D ---- -Line 1 AAAA -Line 2 BBBB -Line 3 CCCC -Line 4 DDDD -Line 5 EEEE -Line 6 FFFF -Line 7 GGGG -Line 8 HHHH -Line 9 IIII ---- -Line 1 AAAA -Line 2 BBBB -Line 3 CCCC ---- - ---- -Delete the last 6 lines of a file - 2. ---- -:loop -$d -N -2,6b loop -P -D ---- ---- ---- - ---- -Delete the LAST N-th line through the LAST M-th line of a datafile, where N is greater than M - 1. ---- -#r -:loop -1,6{ -$b last -N -b loop -} -$!{ -N -P -D -} -: last -s/^.*\n([^\n]*(\n[^\n]*){1})$/\1/ ---- -Line 10 -Line 9 -Line 8 -Line 7 -Line 6 -Line 5 -Line 4 -Line 3 -Line 2 -Line 1 ---- -Line 10 -Line 9 -Line 8 -Line 2 -Line 1 ---- - ---- -Delete the LAST N-th line through the LAST M-th line of a datafile, where N is greater than M - 2. ---- -#r -:loop -1,6{ -$b last -N -b loop -} -$!{ -N -P -D -} -: last -s/^.*\n([^\n]*\n[^\n]*)$/\1/ ---- ---- ---- - ---- -Get every Nth line of a file - 1. ---- -#r -:loop -$!{ -N -/(\n[^\n]*){3}/!b loop -} -s/^([^\n]*\n){3}([^\n]*)/\2/p -d ---- -line 1 -line 2 -line 3 -line 4 -line 5 -line 6 -line 7 -line 8 -line 9 -line 10 -line 11 -line 12 -line 13 -line 14 -line 15 ---- -line 4 -line 8 -line 12 ---- - ---- -Get every Nth line of a file - 2. ---- -#r -:loop -$!N -s/^([^\n]*\n){3}([^\n]*)/\2/ -t -$!b loop -d ---- ---- ---- - -... -Append a line after every N-th (for example, 4th) line - 1. -... -x -s/$/#/ -/#\{4\}/{ -a\ ----------- -s/.*// -} -x -... -Line 1 AAA -Line 2 BBB -Line 3 CCC -Line 4 DDD -Line 5 EEE -Line 6 FFF -Line 7 GGG -Line 8 HHH -Line 9 III -Line 10 JJ -Line 11 KK -... -Line 1 AAA -Line 2 BBB -Line 3 CCC -Line 4 DDD ----------- -Line 5 EEE -Line 6 FFF -Line 7 GGG -Line 8 HHH ----------- -Line 9 III -Line 10 JJ -Line 11 KK -... - -... -Append a line after every N-th (for example, 4th) line - 2. -... -G -/\n\{4\}$/a\ ----------- -P -s/^[^\n]*\(\n\{4\}\)*// -h -d -... -... -... - ---- -Delete every 4th line (e.g., 4th, 8th, 12th...) of a file - 1. ---- -:loop -/^\([^\n]*\n\)\{3\}/!{ -N -b loop -} -s/\n[^\n]*$// ---- -line 1 -line 2 -line 3 -line 4 -line 5 -line 6 -line 7 -line 8 -line 9 -line 10 ---- -line 1 -line 2 -line 3 -line 5 -line 6 -line 7 -line 9 -line 10 ---- - ---- -Delete every 4th line (e.g., 4th, 8th, 12th...) of a file - 2. ---- -:loop -N -/\n[^\n]*\n/!b loop -n -d ---- ---- ---- - ---- -Delete every 4th line (e.g., 4th, 8th, 12th...) of a file - 3. -(Gilles) ---- -N;N;x;N;x ---- ---- ---- - ---- -Delete every 4th line (e.g., 4th, 8th, 12th...) of a file - 4. -(Gilles) ---- -#r -:loop -$b -N -/(\n[^\n]*){2}/!b loop -x -N -x ---- ---- ---- - ---- -Join every N lines to one - 1. ---- -#r -:loop -$!{ -N -/(\n[^\n]*){3}/!b loop -} -s/\n/ /g ---- -Line 1 -Line 2 -Line 3 -Line 4 -Line 5 -Line 6 -Line 7 -Line 8 -Line 9 -Line 10 -Line 11 -Line 12 -Line 13 ---- -Line 1 Line 2 Line 3 Line 4 -Line 5 Line 6 Line 7 Line 8 -Line 9 Line 10 Line 11 Line 12 -Line 13 ---- - ---- -Join every N lines to one - 2. -(Gilles) ---- -N;N;N;s/\n/ /g ---- ---- ---- - ---- -Join every N lines to one - 3. -(Gilles) ---- -#r -:loop -$b -N -/(\n[^\n]*){3}/!b loop -s/\n/ /g ---- ---- ---- - ---- -For each line of a file, append the previous line to the end of it. ---- -H -x -s/^\(.*\)\n\(.*\)/\2\1/ ---- -Line 1. -Line 2. -Line 3. -Line 4. -Line 5. -Line 6. -Line 7. ---- -Line 1. -Line 2.Line 1. -Line 3.Line 2. -Line 4.Line 3. -Line 5.Line 4. -Line 6.Line 5. -Line 7.Line 6. ---- - ---- -For each line of a file, append the next line to the end of it. ---- -N -s/^\(.*\)\n\(.*\)/\1\2\n\2/ -P -D ---- -Line 1. -Line 2. -Line 3. -Line 4. -Line 5. -Line 6. -Line 7. ---- -Line 1.Line 2. -Line 2.Line 3. -Line 3.Line 4. -Line 4.Line 5. -Line 5.Line 6. -Line 6.Line 7. -Line 7. ---- - -** -** Patterns -** - ---- -Get lines containing PAT1 AND PAT2 of a file. ---- -/dog/!d -/cat/!d ---- -cat chicken -bird dog apple -dog orange cat -cat juice coffee -cow milk dog ---- -dog orange cat ---- - ---- -Get lines containing PAT1 OR PAT2 of a file. ---- -/dog/b -/cat/!d ---- -pig dolphin -bird dog apple -dog orange cat -mouse juice coffee -cow milk cat ---- -bird dog apple -dog orange cat -cow milk cat ---- - ---- -Get lines containing PAT1 but no PAT2 of a file - 1. ---- -/dog/!d -/cat/d ---- -cat chicken -bird dog apple -dog orange cat -cat juice coffee -cow milk dog ---- -bird dog apple -cow milk dog ---- - ---- -Get lines containing PAT1 but no PAT2 of a file - 2. ---- -/cat/d;/dog/!d ---- ---- ---- - ---- -Insert a separating line after a line beginning with "abc" and immediately followed by a line beginning with "xyz". ---- -/^abc/!b -$!N -s/\nxyz/\n==========&/ -t -P -D ---- -ghi line 1 -abc line 2 -abc line 3 -xyz line 4 -abc line 5 -xyz line 6 -ghi line 7 -abc line 8 ---- -ghi line 1 -abc line 2 -abc line 3 -========== -xyz line 4 -abc line 5 -========== -xyz line 6 -ghi line 7 -abc line 8 ---- - ---- -Perform operations on lines starting from the fist line containing PAT1 till the last one containing PAT2. ---- -/begin/,$!b -:loop -/end/{ -s/change this/CHANGED TO THAT/g -b -} -$q -N -b loop ---- -..... keep this -..... keep this -..... keep this -begin change this -..... change this -end change this -..... change this -end change this -..... keep this -..... keep this ---- -..... keep this -..... keep this -..... keep this -begin CHANGED TO THAT -..... CHANGED TO THAT -end CHANGED TO THAT -..... CHANGED TO THAT -end CHANGED TO THAT -..... keep this -..... keep this ---- - ---- -Delete two consecutive lines if the first one contains PAT1 and the second one contains PAT2. ---- -$!N -/^\n.*this/d -P -D ---- -keep this - -delete this - keep this - keep this - -delete this - keep this - keep this ---- -keep this - keep this - keep this - keep this - keep this ---- - ---- -Remove almost identical lines. ---- -#r -$!N -/^([^\n]*).\n\1.$/d -P -D ---- -3 some text a -3 some text b -7 more text a -7 more text b -83 non matching line a -375 some more text a -375 some more text b -478 another non matching line b ---- -83 non matching line a -478 another non matching line b ---- - ---- -For consecutive "almost identical" lines, print only the first one. ---- -#r -G -h -s/ //g -/^(.*)\n\1$/{ -x -s/^.*\n// -} -/\n/{ -g -1!s/^.*\n//p -g -s/\n.*// -} -$q -h -d ---- -first record -first r e c o r d -f i r s t r e c o r d -s e c o n d record -third r e c o r d -t h i r d record -final record ---- -first record -s e c o n d record -third r e c o r d -final record ---- - ---- -Remove consecutive duplicate lines. ---- -#r -$!N -/^([^\n]*)\n\1$/D -P -D ---- -Sed is a stream editor. -Sed is a stream editor. -Sed is a stream editor. -A stream editor is used to perform -basic text transformations on an input stream -basic text transformations on an input stream -(a file or input from a pipeline). ---- -Sed is a stream editor. -A stream editor is used to perform -basic text transformations on an input stream -(a file or input from a pipeline). ---- - ---- -Retrieve the first line among consecutive lines of the same key - 1. ---- -#r -:loop -$!N -/>([^<]*)<.*\n.*>\1<.*/!{ -P -D -} -s/\n.*// -b loop ---- -Blob -Blob -Doe -Doe -Newton -Martin -Martin -Martin ---- -Blob -Doe -Newton -Martin ---- - ---- -Extract "Received:" header(s) from a mailbox. ---- -#r -/^Received:/!d -:loop -N -/\nReceived:[^\n]*$/b loop -/\n[\t ][^\n]*$/b loop -s/\n[^\n]*$/\n/ ---- -From sed-users@yahoogroups.com Sun May 9 14:52:11 2004 -Return-Path: -Received: from n11.grp.scd.yahoo.com (n11.grp.scd.yahoo.com [66.218.66.66]) - by main.rtfiber.com.tw (8.11.6/8.11.6) with SMTP id i170Lq809415 - for ; Sat, 7 Feb 2004 08:21:52 +0800 -Received: (qmail 74534 invoked from network); 7 Feb 2004 00:21:52 -0000 -Received: from unknown (HELO n17.grp.scd.yahoo.com) (66.218.66.72) - by mta2.grp.scd.yahoo.com with SMTP; 7 Feb 2004 00:21:51 -0000 -To: sed-users@yahoogroups.com -From: "john_vdv" - -Welcome to the world of Regular Expressions! - -From tester@cracker.org Sun May 9 14:52:11 2004 -Return-Path: -Received: from solomon.hq (solomon.hq [127.0.0.1]) - by solomon.hq (Postfix) with SMTP id 355263025A - for -Date: Sun, 9 May 2004 14:51:57 +0800 (CST) -From: tester@cracker.org -To: undisclosed-recipients:; - -Regular expressions are powerful! - ---- -Received: from n11.grp.scd.yahoo.com (n11.grp.scd.yahoo.com [66.218.66.66]) - by main.rtfiber.com.tw (8.11.6/8.11.6) with SMTP id i170Lq809415 - for ; Sat, 7 Feb 2004 08:21:52 +0800 -Received: (qmail 74534 invoked from network); 7 Feb 2004 00:21:52 -0000 -Received: from unknown (HELO n17.grp.scd.yahoo.com) (66.218.66.72) - by mta2.grp.scd.yahoo.com with SMTP; 7 Feb 2004 00:21:51 -0000 - -Received: from solomon.hq (solomon.hq [127.0.0.1]) - by solomon.hq (Postfix) with SMTP id 355263025A - for - ---- - -... -Add a separator after each header field of a mail. -... -#r -:loop -N -/\n[ ]+[^\n]*$/b loop -h -s/^.*\n// -x -s/\n[^\n]*$/\n---------------/p -g -/^$/!b loop -:loop1 -n -b loop1 -... -Return-Path: -Received: from main.rt.com.tw (mail.hq [10.11.1.1]) - by smb.knit.plant (Postfix) with ESMTP id D8D8F2DC24 - for ; Thu, 6 Aug 2009 08:10:06 +0800 (CST) -Received: by main.rt.com.tw (Postfix, from userid 0) - id C212627F68; Thu, 6 Aug 2009 08:10:06 +0800 (CST) -X-Original-To: mbackup@mbackup.hq -Delivered-To: mbackup@mbackup.hq -Received: from smb.knit.plant (smb.knit.plant [10.12.1.22]) - by main.rt.com.tw (Postfix) with ESMTP id 8808727F58; - Thu, 6 Aug 2009 08:07:18 +0800 (CST) -Received: from [10.3.1.70] (unknown [10.13.1.70]) - by smb.knit.plant (Postfix) with ESMTP id 4B5912DC28; - Thu, 6 Aug 2009 08:07:18 +0800 (CST) -Message-ID: <4A7A1E6D.8080702@rt.com.tw> -Date: Thu, 06 Aug 2009 08:06:05 +0800 -From: Pon -To: Celine -Subject: test mail.... - -This is a test mail.... -Mail body line 2... -Mail body line 3.... -... -Return-Path: ---------------- -Received: from main.rt.com.tw (mail.hq [10.11.1.1]) - by smb.knit.plant (Postfix) with ESMTP id D8D8F2DC24 - for ; Thu, 6 Aug 2009 08:10:06 +0800 (CST) ---------------- -Received: by main.rt.com.tw (Postfix, from userid 0) - id C212627F68; Thu, 6 Aug 2009 08:10:06 +0800 (CST) ---------------- -X-Original-To: mbackup@mbackup.hq ---------------- -Delivered-To: mbackup@mbackup.hq ---------------- -Received: from smb.knit.plant (smb.knit.plant [10.12.1.22]) - by main.rt.com.tw (Postfix) with ESMTP id 8808727F58; - Thu, 6 Aug 2009 08:07:18 +0800 (CST) ---------------- -Received: from [10.3.1.70] (unknown [10.13.1.70]) - by smb.knit.plant (Postfix) with ESMTP id 4B5912DC28; - Thu, 6 Aug 2009 08:07:18 +0800 (CST) ---------------- -Message-ID: <4A7A1E6D.8080702@rt.com.tw> ---------------- -Date: Thu, 06 Aug 2009 08:06:05 +0800 ---------------- -From: Pon ---------------- -To: Celine ---------------- -Subject: test mail.... ---------------- - -This is a test mail.... -Mail body line 2... -Mail body line 3.... -... - ---- -Extract matched headers of a mail. -(note: result from gnu sed) ---- -#r -:loop -N -/\n[ \t]+[^\n]*$/b loop -h -s/\n[^\n]*$// -/^(Received|Subject): /p -x -s/^.*\n// -/^$/q -b loop ---- -From sed-users@yahoogroups.com Sun May 9 14:52:11 2004 -Return-Path: -Received: from n11.grp.scd.yahoo.com (n11.grp.scd.yahoo.com [66.218.66.66]) - by main.rtfiber.com.tw (8.11.6/8.11.6) with SMTP id i170Lq809415 - for ; Sat, 7 Feb 2004 08:21:52 +0800 -Received: (qmail 74534 invoked from network); 7 Feb 2004 00:21:52 -0000 -To: sed-users@yahoogroups.com -Received: from unknown (HELO n17.grp.scd.yahoo.com) (66.218.66.72) - by mta2.grp.scd.yahoo.com with SMTP; 7 Feb 2004 00:21:51 -0000 -Subject: Hello! -From: "john_vdv" -Welcome to the world of Regular Expressions! ---- -Received: from n11.grp.scd.yahoo.com (n11.grp.scd.yahoo.com [66.218.66.66]) - by main.rtfiber.com.tw (8.11.6/8.11.6) with SMTP id i170Lq809415 - for ; Sat, 7 Feb 2004 08:21:52 +0800 -Received: (qmail 74534 invoked from network); 7 Feb 2004 00:21:52 -0000 -Received: from unknown (HELO n17.grp.scd.yahoo.com) (66.218.66.72) - by mta2.grp.scd.yahoo.com with SMTP; 7 Feb 2004 00:21:51 -0000 -Subject: Hello! -Welcome to the world of Regular Expressions! ---- - -** 2 scripts not included - ---- -Get every line containing PAT and the adjacent one preceding it. ---- -$!N -/PAT/P -D ---- -1 PAT print this -2 -3 --- print this -4 PAT print this -5 PAT print this -6 -7 --- print this -8 PAT print this -9 PAT print this -10 ---- -1 PAT print this -3 --- print this -4 PAT print this -5 PAT print this -7 --- print this -8 PAT print this -9 PAT print this ---- - ---- -Get every line containing PAT, the preceding, and the following ones. ---- -$!{ -N -/\n.*PAT/{ -P -D -} -} -/PAT/b -D ---- -1 -2 ====== -3 PAT -4 ====== -5 -6 ====== -7 PAT -8 PAT -9 ====== -10 ---- -2 ====== -3 PAT -4 ====== -6 ====== -7 PAT -8 PAT -9 ====== ---- - -;--- -;Get every line containing PAT, the preceding, and the following ones. -;(incorrect) -;--- -;$!N -;/\n.*PAT/!{ -;P -;D -;} -;/PAT/b -;D -;--- -;--- -;--- - ---- -Get lines containing PAT and 3 consecutive lines following each of them. ---- -/Dept=MIS/!d -N;N;N ---- -Dept=MIS -ID=00154 -Name=John -Age=23 -Dept=ACCOUNTING -ID=00312 -Name=Mary -Age=26 -Dept=SALES -No=00439 -Name=Tom -Age=30 -Dept=MIS -No=00183 -Name=Ruth -Age=25 ---- -Dept=MIS -ID=00154 -Name=John -Age=23 -Dept=MIS -No=00183 -Name=Ruth -Age=25 ---- - ---- -Get every line which is adjacent to and precedes a line containing PAT. ---- -$!N -/\n.*YES/P -D ---- -Line 1 Do not print this -Line 2 Print this -Line 3 YES Do not print this -Line 4 Print this -Line 5 YES Do not print this -Line 6 -Line 7 -Line 8 Print this -Line 9 YES Print this, because Line 10 contains YES -Line 10 YES Do not print this ---- -Line 2 Print this -Line 4 Print this -Line 8 Print this -Line 9 YES Print this, because Line 10 contains YES ---- - ---- -Get the line following a line containing PAT - Case 1 - 1. ---- -:loop -$!N -/PAT.*\n/!D -s/^.*\n//p -b loop ---- -Line 1 - This line contains PAT -Line 3 -Line 4 - PAT is here - One more line contains PAT - Two more line contains PAT -Line 8 - This has PAT, too -Line 10 ---- -Line 3 - One more line contains PAT - Two more line contains PAT -Line 8 -Line 10 ---- - ---- -Get the line following a line containing PAT - Case 1 - 2. ---- -$!N -/PAT.*\n/!D -s/^.*\n\(.*\)/\1\n\1/ -P -D ---- ---- ---- - ---- -Get the line following a line containing PAT - Case 2. ---- -/PAT/!d -$!N -/\n.*PAT/D -s/^.*\n// ---- -Line 1 - This line contains PAT -Line 3 -Line 4 - PAT is here - One more line contains PAT - Two more line contains PAT -Line 8 - This has PAT, too -Line 10 ---- -Line 3 -Line 8 -Line 10 ---- - -** -** Multiline Matching -** - ---- -Remove HTML tags (may be multi-line) of a file. ---- -///g -/ - - -
1.Line 1 Column 2 -
2.Line 2 Column 2
- ---- - -1. -Line 1 Column 2 -2. -Line 2 Column 2 - ---- - ---- -Extract every IMG elements from an HTML file. ---- -#r -//!N -/>/!b loop -} -s/\n/ /g -s/>/&\n/ -P -D ---- -This is the first image , followed -by the second and third . There may be several IMGs in one line, like -, and . ---- - - - - - -test ---- - ---- -Replace odd-numbered and even-numbered double quotes with single quotes and back quotes, respectively. ---- -#r -1{ -x -s/^/`/ -x -} -:loop -/\n/!s/^/\n/ -s/\n(([^\\"]*|\\.)*)"/\1\n"/ -/\n"/!{ -s/\n// -n -b loop -} -G -s/\n"(.*)\n(.)/\2\n\1/ -x -y/`'/'`/ -x -b loop ---- -....."test1"...."test2 -test2"..not.\"...not.\"..."test3 -test3"....."test4 -test4 -test4"...."test5". ---- -.....`test1'....`test2 -test2'..not.\"...not.\"...`test3 -test3'.....`test4 -test4 -test4'....`test5'. ---- - ---- -Replace '**' with and alternatively. ---- -:br0 -s/\*\*// -t br1 -b -:br1 -s/\*\*/<\/B>/ -t br0 -n -/^ *$/b -b br1 ---- -text01 **text02** text03 **text04 -text05** text06 -text07 **text08** **text09 - -text10 **text11** text12 ---- -text01 text02 text03 text04 -text05 text06 -text07 text08 text09 - -text10 text11 text12 ---- - ---- -Remove the start and the end tags of some `A' elements of an HTML file, but keep the contents. ---- -#r -:top -//!b -s//\n/ -/\n.*<\/a>/!{ -s/\n// -:loop -n -/<\/a>/!b loop -} -s/<\/a>([^\n]*)$/\1/ -s/\n// -b top ---- -text1 text2 - text3 -text4 -text5 text6 text7 text8 text9 text10 -text11 text12 text13 text14 -text15 ---- -text1 text2 - text3 -text4 -text5 text6 text7 text8 text9 text10 -text11 text12 text13 text14 -text15 ---- - ---- -Remove comments (/* ... */, maybe multi-line) of a C program. - 1 ---- -/\/\*/{ -:loop -s|\(.*\)/\*.*\*/|\1| -t loop -/\/\*/{ -N -b loop -} -} ---- -Data-1 Data-2 -Data-3/* comment */ Data-4/* -comment comment */ Data-5 -Data-6 Data-7 -/* comment */Data-8 -Data-9 -Data-10 /* comment */ Data-11 /* comment */ Data-12 ---- -Data-1 Data-2 -Data-3 Data-4 Data-5 -Data-6 Data-7 -Data-8 -Data-9 -Data-10 Data-11 Data-12 ---- - ---- -Remove comments (/* ... */, maybe multi-line) of a C program. - 2 ---- -:loop -s|\(.*\)/\*.*\*/|\1| -t loop -/\/\*/!b -N -b loop ---- ---- ---- - ---- -Extract (possibly multiline) contents between 'BEGIN' and the matching 'END'. ---- -/BEGIN/!d -s/BEGIN/\n/ -s/^.*\n// -/END/{ -s/END/\n/ -P -D -} -:loop -H -$d -N -s/^.*\n// -/END/!b loop -s/END/\n/ -H -x -s/^\n\(.*\)\n.*/\1/ -p -s/^.*// -x -D ---- -word-A BEGIN word-B -word-C word-D -word-E END word-F -word-G -word-H BEGIN word-I END word-J BEGIN word-K -word-L word-M -END word-N -word-O ---- - word-B -word-C word-D -word-E - word-I - word-K -word-L word-M - ---- - ---- -Find failed instances without latter successful ones. ---- -#r -:loop0 -$!{ -N -b loop0 -} -:loop1 -/^X ([^ ]+) [^ ]+ ([^\n]+)\n.*\n@ \1 [^ \n]+ \2(\n|$)/{ -s/^[^\n]+\n// -b loop1 -} -/^X/P -D ---- -X aa.sh 01:02:50 arg01 arg02 -@ bb.sh 01:02:56 arg03 -X cc.sh 01:03:05 arg04 arg05 arg06 -X dd.sh 01:04:22 arg07 -X dd.sh 02:22:45 arg08 arg09 -@ aa.sh 03:22:56 arg10 arg11 -X cc.sh 03:30:30 arg12 arg13 arg14 -@ dd.sh 03:31:28 arg07 -@ cc.sh 04:15:35 arg12 arg13 arg14 -@ aa.sh 04:22:52 arg01 arg02 ---- -X cc.sh 01:03:05 arg04 arg05 arg06 -X dd.sh 02:22:45 arg08 arg09 ---- - ---- -Change the first quote of every single-quoted string to backquote(`). - 1 ---- -#r -:find_opening -/\n/!s/^/\n/ -s/\n((\\.|[^'])*)/\1\n/ -/\n'/!{ -s/\n// -n -b find_opening -} -s/\n'/`\n/ -:find_closing -/\n/!s/^/\n/ -s/\n((\\.|[^'])*)/\1\n/ -/\n'/!{ -s/\n// -n -b find_closing -} -s/\n'/'\n/ -b find_opening ---- -first 'aaa\'AAA' second 'bbb - ccc' third 'ddd\'DE\'eee' -fourth '' fifth 'fff -ggg' sixth 'hh' seventh ' -' eight 'jjj kkk' ninth 'lll -mmm' ---- -first `aaa\'AAA' second `bbb - ccc' third `ddd\'DE\'eee' -fourth `' fifth `fff -ggg' sixth `hh' seventh ` -' eight `jjj kkk' ninth `lll -mmm' ---- - ---- -Change the first quote of every single-quoted string to backquote(`). - 2 ---- -#r -:loop -/\n/!s/^/\n/ -s/\n((\\.|[^'])*)/\1\n/ -/\n'/!{ -s/\n// -n -b loop -} -G -/\n$/s/\n'/`\n/ -/\n#$/s/\n'/'\n/ -s/\n#*$// -x -s/^/#/ -s/##// -x -b loop ---- ---- ---- - -*** -Reduces blanks, newline characters between PAT1 and PAT2. - 1 -*** -#r -:top -/PAT1/!b -:loop -$q -N -/\n *$/b loop -/\n *PAT2[^\n]*$/b matched -h -s/\n[^\n]*$//p -x -s/^.*\n// -b top -:matched -s/PAT1( *\n *)*PAT2/PAT1\nPAT2/ -P -D -*** -=== PAT1 - - - PAT2 === -PAT 1 ... -... -PAT 2 === PAT1 - PAT2 === PAT1 ... -... -... PAT2 --- PAT1 - - - -PAT2 ---- -*** -=== PAT1 -PAT2 === -PAT 1 ... -... -PAT 2 === PAT1 -PAT2 === PAT1 ... -... -... PAT2 --- PAT1 -PAT2 ---- -*** - ---- -Reduces blanks, newline characters between PAT1 and PAT2. - 2 ---- -#r -/PAT1/!b -:loop -$q -N -/\n *$/b loop -/\n *PAT2[^\n]*$/b matched -h -s/\n[^\n]*$//p -x -s/^.*\n/\n/ -D -:matched -s/PAT1( *\n *)*PAT2/PAT1\nPAT2/ -P -D ---- ---- ---- - -** -** N-th Match in a file -** - ---- -Replace the FIRST occurrence of PAT1 in a file with PAT2 ---- -/dog/{ -s/dog/DOG/ -:loop -n -b loop -} ---- -1 cat chicken -2 bird dog apple -3 dog orange cat -4 cat juice coffee -5 cow milk dog ---- -1 cat chicken -2 bird DOG apple -3 dog orange cat -4 cat juice coffee -5 cow milk dog ---- - -... -Replace the first N occurrences of PAT of a file. -... -#r -/word/!b -:0 -$!{ -N -/(word.*){4}/!b 0 -} -:1 -s/word/------/ -x -s/^/\n/ -/\n{4}/!{ -x -/word/b 1 -b 2 -} -x -:2 -n -b 2 -... -word_1 -much more data... -word_2 word_3 -much more data... -word_4 word_5 -word_6 -much more data... -word_7 final word_8 -... -------_1 -much more data... -------_2 ------_3 -much more data... -------_4 word_5 -word_6 -much more data... -word_7 final word_8 -... - ---- -Replace the FIRST N matches of PAT in a file. ---- -#r -/[0-9]/!b -:loop0 -/([0-9]+([^0-9]+|$)){5}/!{ -$!N -$!b loop0 -} -s/([0-9]+([^0-9]+|$)){1,5}/&\n/ -h -s/\n[^\n]*$// -s/[0-9]+/[&]/g -x -s/^.*\n// -G -s/^([^\n]*)\n(.*)/\2\1/ -:loop1 -n -b loop1 ---- -first 1111 -no numbers -second 222 -third 333 fourth 44444 -no numbers -fifth 5555 sixth 666666 -number 7777 -number 88888 -no numbers ---- -first [1111] -no numbers -second [222] -third [333] fourth [44444] -no numbers -fifth [5555] sixth 666666 -number 7777 -number 88888 -no numbers ---- - ---- -Replace/Remove all but the first occurrence of PAT in a file. ---- -#r -/[0-9]+/!b -s/[0-9]+/&\n/ -h -s/^[^\n]*\n// -s/[0-9]+/<&>/g -G -s/^([^\n]*)\n([^\n]*)\n.*/\2\1/ -:loop -n -s/[0-9]+/<&>/g -b loop ---- -First number 1111 Second 2222 -Third number 333 number -Fourth -44444 number Fifth number 55555 -Final number -777 ---- -First number 1111 Second <2222> -Third number <333> number -Fourth -<44444> number Fifth number <55555> -Final number -<777> ---- - -... -Replace all but the first N occurrences of PAT of a file. -... -#r -:0 -/(word.*){3}/!{ -$!N -$!b 0 -} -:1 -/(word.*){4}/{ -s/^(.*)word/\1------/ -t 1 -} -:2 -n -s/word/------/g -b 2 -... -word_1 -much more data... -word_2 word_3 word_4 -much more data... -word_5 word_6 -word_7 -much more data... -word_8 final word_9 -... -word_1 -much more data... -word_2 word_3 ------_4 -much more data... -------_5 ------_6 -------_7 -much more data... -------_8 final ------_9 -... - ---- -Replace the last occurrence of PAT of a file. ---- -#r -:0 -/word/!b -:1 -$!N -/\n.*word/{ -h -s/\n[^\n]*$//p -g -s/^.*\n// -} -$!b 1 -s/^(.*)word/\1------/ ---- -word_1 word_2 word_3 -more data... -word_4 word_5 -more data... -word_6 word_7 -word_8 -more data... -word_9 final word_10 ---- -word_1 word_2 word_3 -more data... -word_4 word_5 -more data... -word_6 word_7 -word_8 -more data... -word_9 final ------_10 ---- - -... -Replace the last N matches of PAT of a file. -... -#r -/word/!b -:0 -$!{ -N -/(.*word){3}/!b 0 -} -:1 -/\n(.*word){3}/{ -P -s/^[^\n]*\n// -} -$!{ -N -b 1 -} -:2 -s/^(.*)word/\1------/ -x -s/^/\n/ -/^\n{3}$/!{ -x -/word/b 2 -} -x -... -word_1 word_2 word_3 -much more data... -word_4 word_5 -much more data... -word_6 word_7 -word_8 -much more data... -word_9 final word_10 -... -word_1 word_2 word_3 -much more data... -word_4 word_5 -much more data... -word_6 word_7 -------_8 -much more data... -------_9 final ------_10 -... - ---- -Replace the LAST N matches of PAT in a file. ---- -#r -/[0-9]/!b -:loop -/\n.*([0-9]+([^0-9]+|$)){5}$/!{ -$b final -N -b loop -} -P -D -:final -s/([0-9]+([^0-9]+|$)){1,5}$/\n&/ -h -s/^[^\n]*\n// -s/[0-9]+/[&]/g -x -s/\n.*$// -G -s/\n// ---- -number 8888 -number 777 -number 6666 last fifth 55555 last fourth 4444 -no numbers in this line -Last third 3333 last second 2222 -Last number 111 -no numbers ---- -number 8888 -number 777 -number 6666 last fifth [55555] last fourth [4444] -no numbers in this line -Last third [3333] last second [2222] -Last number [111] -no numbers ---- - ---- -Replace all but the last N matches of PAT in a file. ---- -#r -/[0-9]/!b -:loop -/\n.*([0-9]+([^0-9]+|$)){5}$/!{ -$b final -N -b loop -} -h -s/\n.*// -s/[0-9]+/[&]/gp -g -D -:final -s/([0-9]+([^0-9]+|$)){1,5}$/\n&/ -h -s/\n.*$// -s/[0-9]+/[&]/g -G -s/\n[^\n]*\n// ---- -number 8888 -number 777 -number 6666 last fifth 55555 last fourth 4444 -no numbers in this line -Last third 3333 last second 2222 -Last number 111 -no numbers ---- -number [8888] -number [777] -number [6666] last fifth 55555 last fourth 4444 -no numbers in this line -Last third 3333 last second 2222 -Last number 111 -no numbers ---- - -... -Replace all but the last N occurrences of PAT of a file. -... -#r -/word/!b -:0 -/(word.*){4}/!{ -$!N -$!b 0 -} -:1 -s/word((.*word){3})/------\1/ -t 1 -$q -:2 -/^[^\n]*word/!{ -P -s/^[^\n]*\n// -b 2 -} -b 0 -... -word_1 -much more data... -word_2 word_3 word_4 -much more data... -word_5 -word_6 word_7 -much more data... -word_8 final word_9 -... -------_1 -much more data... -------_2 ------_3 ------_4 -much more data... -------_5 -------_6 word_7 -much more data... -word_8 final word_9 -... - ---- -Replace every N-th occurrence of PAT. ---- -#r -s/[0-9]+/\n&/g -/\n/!b -G -:loop -s/$/#/ -s/#{3}$// -/\n$/s/\n([0-9]+)/\n[\1]/ -s/\n// -/\n.*\n/b loop -P -s/^.*\n// -h -d ---- -First number 1, Second 22, Third 333, Fourth 4444, -Fifth 555, -Sixth 66, Seventh 7, Eighth 88, -Ninth 999, Tenth 1010, Eleventh 11111, Twelfth 1212, -Thirteenth 13, Fourteenth 141414, Fifteenth 15 ---- -First number 1, Second 22, Third [333], Fourth 4444, -Fifth 555, -Sixth [66], Seventh 7, Eighth 88, -Ninth [999], Tenth 1010, Eleventh 11111, Twelfth [1212], -Thirteenth 13, Fourteenth 141414, Fifteenth [15] ---- - ---- -Extract N1-th, N2-th, ... matches of PAT in a file. ---- -#r -/[0-9]+/!d -s/[0-9]+/\n&/g -G -:loop -s/$/#/ -/\n(#{3}|#{5}|#{11}|#{14})$/{ -s/^[^\n]*\n([0-9]+)/\1\n/ -P -} -s/\n// -/\n[^\n]*\n/b loop -s/^.*\n// -h -d ---- -First number 1, Second 22, Third 333, Fourth 4444, -Fifth 555, -Sixth 66, Seventh 7, Eighth 88, -Ninth 999, Tenth 1010, Eleventh 11111, Twelfth 1212, -Thirteenth 13, Fourteenth 141414, Fifteenth 15 ---- -333 -555 -11111 -141414 ---- - \ No newline at end of file diff --git a/src/spec-tests/sed/cases/pythonsed-unit.suite b/src/spec-tests/sed/cases/pythonsed-unit.suite deleted file mode 100644 index 1b8167d2..00000000 --- a/src/spec-tests/sed/cases/pythonsed-unit.suite +++ /dev/null @@ -1,1764 +0,0 @@ -** Summary of tests - -** syntax -** - terminating commands -** addresses -** - regexp separators -** - regexp flags -** regexps -** - anchors -** - or -** - quantifers -** - multiple quantifiers -** - charsets -** - backreferences -** - python extensions -** substitution -** empty regexps -** conditional branching -** a,i,c -** y -** n, N, p, P - -** r and w tests in testsuite3 - - -** -** Syntax : terminating commands except aicrw -** -** y treated apart due to its special behavior in GNU sed - ---- -syntax: terminating commands (all but y) - 1 ---- -1 { p } -2 { s/abc/&&/ } ---- -abc1 -abc2 ---- -abc1 -abc1 -abcabc2 ---- - ---- -syntax: terminating commands (all but y) - 2 ---- -1 { p ; } -2 { s/abc/&&/ ; } ---- ---- ---- - ---- -syntax: terminating commands (all but y) - 3 ---- -1 p # comment -2 s/abc/&&/ # comment ---- ---- ---- - ---- -syntax: terminating commands (y) - 1 ---- -1 { y/abc/def/ } ---- -abc ---- -def ---- - ---- -syntax: terminating commands (y) - 2 ---- -1 { y/abc/def/ ; } ---- ---- ---- - ---- -syntax: terminating commands (y) - 3 ---- -1 y/abc/def/ # comment ---- ---- ---- - ---- -syntax: terminating commands - 4 -(no space at end of line) ---- -p; p ---- -a ---- -a -a -a ---- - ---- -syntax: terminating commands - 5 -(one space at end of line) ---- -p; p ---- ---- ---- - ---- -syntax: terminating commands - 6 -(no space at end of line) ---- -p; p; ---- ---- ---- - ---- -syntax: terminating commands - 7 -(one space at end of line) ---- -p; p; ---- ---- ---- - ---- -syntax: terminating commands - 8 ---- -1 ---- -a ---- -??? ---- - ---- -syntax: terminating commands - 9 ---- -1, ---- -a ---- -??? ---- - ---- -syntax: terminating commands - 10 ---- -1,2 ---- -a ---- -??? ---- - ---- -syntax: terminating commands - aic ---- -#n -i\ -foo # no comment inside i argument -i\ -bar ; nor separator inside i argument -i\ -egg } nor end of block inside i argument -a\ -foo # no comment inside a argument -a\ -bar ; nor separator inside a argument -a\ -egg } nor end of block inside a argument -c\ -foo # no comment inside c argument -c\ -bar ; nor separator inside c argument -c\ -egg } nor end of block inside c argument ---- -x ---- -foo # no comment inside i argument -bar ; nor separator inside i argument -egg } nor end of block inside i argument -foo # no comment inside c argument -foo # no comment inside a argument -bar ; nor separator inside a argument -egg } nor end of block inside a argument ---- - -** -** regexp addresses -** - ---- -regexp address: separators ---- -#n -h -g; /a/p -g; \xaxp -g; \a\aap ---- -abc ---- -abc -abc -abc ---- - ---- -regexp address: flags ---- -#n -/abc/p -/ABC/Ip ---- -abc -ABC ---- -abc -abc -ABC ---- - ---- -regexp address: address range with flag ---- -#n -/abc/,/def/p -/abc/I,/def/p -/abc/,/def/Ip -/abc/I,/def/Ip ---- -abc -def -ABC -DEF ---- -abc -abc -abc -abc -def -def -def -def -ABC -ABC -DEF -DEF ---- - -** -** empty addresses -** - ---- -empty addresses: single address ---- -#n -/a/p;//p ---- -a -b -a -b ---- -a -a -a -a ---- - ---- -empty addresses: address range ---- -#n -/a/p;//,//p ---- ---- -a -a -b -a -a ---- - -** -** regexp anchors -** - ---- -PS ending with a line break ---- -s/.*/\n/ -s/$/X/g ---- -x ---- - -X ---- - ---- -anchors at end of regexp 1 - BRE ---- -#n -s/\(abc\)$/ABC/p ---- -abc$abc ---- -abc$ABC ---- - ---- -anchors at end of regexp 1 - ERE ---- -#nr -s/(abc)$/ABC/p ---- ---- ---- - ---- -anchors at end of regexp 2 - BRE ---- -#n -s/\(abc\)\($\)/ABC/p ---- ---- ---- - ---- -anchors at end of regexp 2 - ERE ---- -#nr -s/(abc)($)/ABC/p ---- ---- ---- - ---- -anchors at end of regexp 3 - BRE ---- -#n -s/\(abc\)\(X\|$\)/ABC/p ---- ---- ---- - ---- -anchors at end of regexp 3 - ERE ---- -#nr -s/(abc)(X|$)/ABC/p ---- ---- ---- - ---- -anchors at end of regexp 4 - BRE ---- -#n -s/\(abc\)\($\|X\)/ABC/p ---- ---- ---- - ---- -anchors at end of regexp 4 - ERE ---- -#nr -s/(abc)($|X)/ABC/p ---- ---- ---- - ---- -anchors inside regexp $ - BRE ---- -#n -s/.*\(abc\)$.*/\1/p ---- -xabc$y ---- -abc ---- - ---- -anchors inside regexp $ - ERE ---- -#nr -s/.*(abc)$.*/\1/p ---- ---- ---- - ---- -anchors at start of regexp 1 - BRE ---- -#n -s/^\(abc\)/ABC/p ---- -abc^abc ---- -ABC^abc ---- - ---- -anchors at start of regexp 1 - ERE ---- -#nr -s/^(abc)/ABC/p ---- ---- ---- - ---- -anchors at start of regexp 2 - BRE ---- -#n -s/\(^\)\(abc\)/ABC/p ---- ---- ---- - ---- -anchors at start of regexp 2 - ERE ---- -#nr -s/(^)(abc)/ABC/p ---- ---- ---- - ---- -anchors at start of regexp 3 - BRE ---- -#n -s/\(^\|X\)\(abc\)/ABC/p ---- ---- ---- - ---- -anchors at start of regexp 3 - ERE ---- -#nr -s/(^|X)(abc)/ABC/p ---- ---- ---- - ---- -anchors at start of regexp 4 - BRE ---- -#n -s/\(X\|^\)\(abc\)/ABC/p ---- ---- ---- - ---- -anchors at start of regexp 4 - ERE ---- -#nr -s/(X|^)(abc)/ABC/p ---- ---- ---- - ---- -anchors inside regexp ^ - BRE ---- -#n -s/.*\(abc\)^.*/\1/p ---- -xabc^y ---- -abc ---- - ---- -anchors inside regexp ^ - ERE ---- -#nr -s/.*(abc)^.*/\1/p ---- ---- ---- - -** -** regexp or -** - ---- -regexp or ---- -#n -/a\|b/p -/ab\|cd/p -/\(ab\)\|\(cd\)/p ---- -axy -xyb -abd -acd -ab -cd ---- -axy -xyb -abd -abd -abd -acd -acd -acd -ab -ab -ab -cd -cd ---- - ---- -regexp or (ERE) ---- -#nr -/a|b/p -/ab|cd/p -/(ab)|(cd)/p ---- ---- ---- - -** -** regexp quantifiers -** - ---- -regexp: BRE + ---- -s/ab+/cd/ ---- -ab+ ---- -cd ---- - ---- -regexp: BRE \+ ---- -s/ab\+/cd/ ---- -abbb ---- -cd ---- - ---- -regexp: ERE + ---- -#r -s/ab+/cd/ ---- -abbb ---- -cd ---- - ---- -regexp: ERE \+ ---- -#r -s/ab\+/cd/ ---- -ab+ ---- -cd ---- - ---- -regexp: BRE ? ---- -s/ab?/cd/ ---- -ab? ---- -cd ---- - ---- -regexp: BRE \? ---- -s/ab\?/cd/ ---- -a ---- -cd ---- - ---- -regexp: ERE ? ---- -#r -s/ab?/cd/ ---- -a ---- -cd ---- - ---- -regexp: ERE \? ---- -#r -s/ab\?/cd/ ---- -ab? ---- -cd ---- - ---- -regexp: BRE *? ---- -s/ab*?/cd/ ---- -abbb? ---- -cd ---- - ---- -regexp: BRE +? ---- -s/ab+?/cd/ ---- -ab+? ---- -cd ---- - ---- -regexp: BRE ?? ---- -s/ab??/cd/ ---- -ab?? ---- -cd ---- - ---- -regexp: BRE +\? ---- -s/ab+\?/cd/ ---- -ab+? ---- -cd? ---- - ---- -regexp: BRE ?\? ---- -s/ab?\?/cd/ ---- -ab?? ---- -cd? ---- - ---- -regexp: {n} ---- -#nr -/ab{1}c/p -/ab{2}c/p -/ab{3}c/p ---- -ac -abc -abbc -abbbc -abbbbc ---- -abc -abbc -abbbc ---- - ---- -regexp: {m,n} ---- -#nr -/ab{2,3}c/p ---- -ac -abc -abbc -abbbc -abbbbc ---- -abbc -abbbc ---- - ---- -regexp: {n,} ---- -#nr -/ab{1,}c/p -/ab{2,}c/p ---- -ac -abc -abbc -abbbc ---- -abc -abbc -abbc -abbbc -abbbc ---- - ---- -regexp: {,n} ---- -#nr -/ab{,1}c/p -/ab{,2}c/p ---- -ac -abc -abbc -abbbc ---- -ac -ac -abc -abc -abbc ---- - -** multiple quantifiers -launching an error for a pair of quantifiers is the expected error for sed.py - ---- -regexp: ** BRE (multiple quantifier) ---- -s/ab**/cd/ ---- -abbb ---- -??? ---- - ---- -regexp: ** ERE (multiple quantifier) ---- -#r -s/ab**/cd/ ---- -abbb ---- -??? ---- - ---- -regexp: *\? BRE (multiple quantifier) ---- -s/ab*\?/cd/ ---- -abb ---- -??? ---- - ---- -regexp: *? ERE (multiple quantifier) ---- -#r -s/ab*?/cd/ ---- -abbb ---- -??? ---- - -** -** charsets -** - ---- -regexp: closing bracket in char set ---- -#n -/a[]x]*b/p ---- -a]x]]xx]b ---- -a]x]]xx]b ---- - ---- -regexp: closing bracket in complement char set ---- -#n -/a[^]]b/p ---- -axb ---- -axb ---- - ---- -regexp: \t \n in char set ---- -#n -/a[\t]b/p -h -G -/b[\n]a/p ---- -a b ---- -a b -a b -a b ---- - -** -** regexp backreferences -** - ---- -regexp: back reference before num in address ---- -#n -/\(abc\)\10/p ---- -abcabc0 ---- -abcabc0 ---- - ---- -regexp extended: back reference before num in address ---- -#nr -/(abc)\10/p ---- -abcabc0 ---- -abcabc0 ---- - ---- -regexp extended: unmatched groups 1 ---- -#r -s/(a)|(b)/\1\2/ ---- -a ---- -a ---- - ---- -regexp extended: unmatched groups 2 ---- -#r -s/(x)?/\1/ ---- ---- ---- - -** -** avoid python extensions -** - ---- -avoid python extension - 1 ---- -s/b(\?#foo)c/xyz/ ---- -ab#foo)cd ---- -axyzd ---- - ---- -avoid python extension - 2 ---- -s/b\(\?#foo\)c/xyz/ ---- -abcd ---- -??? ---- - - -** -** Anchor repetition -** - ---- -((^|b)a){2} ---- -#r -/((^|b)a){2}/p ---- -aa ---- -aa ---- - ---- -(a(b|$)){2} ---- -#r -/(a(b|$)){2}/p ---- ---- ---- - ---- -(^a){2} ---- -#r -/(^a){2}/p ---- ---- ---- - ---- -(a$){2} ---- -#r -/(a$){2}/p ---- ---- ---- - ---- -(^){2} ---- -#r -/(^){2}/p ---- ---- -aa -aa ---- - ---- -($){2} ---- -#r -/($){2}/p ---- ---- ---- - - -** -** substitutions -** - ---- -substitution: replace first occurrence ---- -s/abc/x&y/ ---- -abcabcabc ---- -xabcyabcabc ---- - ---- -substitution: replace second occurrence ---- -s/abc/x&y/2 ---- -abcabcabc ---- -abcxabcyabc ---- - ---- -substitution: replace all occurrences ---- -s/abc/x&y/g ---- -abcabcabc ---- -xabcyxabcyxabcy ---- - ---- -substitution: replace far occurrence ---- -s/abc/x&y/12 ---- -abcabcabcabcabcabcabcabcabcabcabcabc ---- -abcabcabcabcabcabcabcabcabcabcabcxabcy ---- - ---- -substitution: occurrence not found ---- -s/abc/x&y/12 ---- -abcabcabcabc ---- -abcabcabcabc ---- - ---- -substitution: replace all occurrences with ignore case (s///i) ---- -s/ABC/x&y/gi ---- -abcabcabc ---- -xabcyxabcyxabcy ---- - ---- -substitution: replace all occurrences with ignore case (s///I) ---- -s/ABC/x&y/gI ---- -abcabcabc ---- -xabcyxabcyxabcy ---- - ---- -substitution: ignore case by default ---- -s/ABC/x&y/g ---- -abcabcabc ---- -abcabcabc ---- - ---- -substitution: back reference before num in regexp ---- -s/\(abc\)\10/\1/ ---- -abcabc0 ---- -abc ---- - ---- -substitution: back reference before num in repl ---- -s/\(abc\)/\10/ ---- -abc ---- -abc0 ---- - ---- -substitution: -r: back reference before num in regexp ---- -#r -s/(abc)\10/\1/ ---- -abcabc0 ---- -abc ---- - ---- -substitution: -r: back reference before num in repl ---- -#r -s/(abc)/\10/ ---- -abc ---- -abc0 ---- - ---- -substitution: empty back reference in regexp ---- -s/abc\(X\{0,1\}\)abc\1/&/ ---- -abcabc ---- -abcabc ---- - ---- -substitution: & in replacement ---- -#nr -h; s/.*/&/; p -g; s/.*/&&&&/; p ---- -ha ---- -ha -hahahaha ---- - ---- -substitution: new line in replacement old style ---- -s/ab/&\ -/g ---- -abcabc ---- -ab -cab -c ---- - ---- -substitution: new line in replacement new style ---- -s/ab/&\n/g ---- -abcabc ---- -ab -cab -c ---- - -** -** empty regexps -** - ---- -empty regexp ---- -# Check that the empty regex recalls the last *executed* regex, -# not the last *compiled* regex (from GNU sed test suite) -p -s/e/X/p -:x -s//Y/p -/f/bx ---- -eeefff ---- -eeefff -Xeefff -XYefff -XYeYff -XYeYYf -XYeYYY -XYeYYY ---- - ---- -empty regexp: empty cascade ---- -p -s/e/X/p -s//X/p -s//X/p -//s//X/p ---- -eeefff ---- -eeefff -Xeefff -XXefff -XXXfff -XXXfff ---- - ---- -empty regexp: case modifier propagation ---- -p -s/E/X/igp -y/X/e/ -s//X/p ---- -eeefff ---- -eeefff -XXXfff -Xeefff -Xeefff ---- - ---- -empty regexp: same empty regexp, different case status ---- -p -s/E/X/ip -:a -s//X/p -s/E/X/p -ta ---- -eeeEEE ---- -eeeEEE -XeeEEE -XXeEEE -XXeXEE -XXeXXE -XXeXXX -XXeXXX ---- - ---- -empty regexp: case modifier propagation for addresses ---- -/A/Ip -//p ---- -a ---- -a -a -a ---- - -** -** conditional branching -** - ---- -branch on subst ---- -s/abc/xy/ -ta -s/$/foo/ -:a -s/abc/xy/ -tb -s/$/bar/ -:b ---- -abc ---- -xybar ---- - ---- -branch on one successful subst ---- -s/abc/xy/ -s/abc/xy/ -ta -s/$/foo/ -:a ---- -abc ---- -xy ---- - ---- -branch or print on successful subst (not on change of PS) ---- -s/abc/abc/p -s/abc/abc/ -ta -s/$/foo/ -:a ---- -abc ---- -abc -abc ---- - -** -** a,i,c -** - ---- -Change command c ---- -2c\ -two\ -deux -4,6c\ -quatre\ -cinq\ -six -8,9{ c\ -ocho\ -nueve -} -11 { a\ -eleven second -c\ -eleven first -} -i\ -not changed: ---- -1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 ---- -not changed: -1 -two -deux -not changed: -3 -quatre -cinq -six -not changed: -7 -ocho -nueve -ocho -nueve -not changed: -10 -eleven first -eleven second -not changed: -12 ---- - ---- -a,i,c ---- -/TAG/ { -a\ -After -i\ -Before -c\ -Changed -} ---- -1 -TAG -2 ---- -1 -Before -Changed -After -2 ---- - ---- -a,i,c silent mode ---- -#n -/TAG/ { -a\ -After -i\ -Before -c\ -Changed -} ---- ---- -Before -Changed -After ---- - ---- -a,i,c one liners ---- -/TAG/ { -a After -i Before -c Changed -} ---- ---- -1 -Before -Changed -After -2 ---- - ---- -a,i,c one liners - ignore leading spaces ---- -/TAG/ { -a After -i Before -c Changed -} ---- ---- ---- - ---- -a,i,c one liners - include leading spaces with "\" ---- -/TAG/ { -a \ After -i \ Before -c \ Changed -} ---- ---- -1 - Before - Changed - After -2 ---- - ---- -a,i,c one liners - embedded \n ---- -/TAG/ { -a Aft\ner -i Bef\nore -c Ch\nang\ned -} ---- ---- -1 -Bef -ore -Ch -ang -ed -Aft -er -2 ---- - -** -** y/// -** - basic usage -** - slashes -** - separators -** - ---- -y: basic usage ---- -#n -h -g; y/a/A/; p -g; y/abc/AAA/; p -g; y/abc/bca/; p ---- -abc ---- -Abc -AAA -bca ---- - ---- -y: slashes ---- -#n -h -g; y/ABCD/xyzt/; p -g; y,ABCD,xyzt,; p -g; y/\\/X/; p -g; y/\//X/; p -g; y,\,,V,; p -g; y/\A\B\C\D/xyzt/; p ---- -A/B\C,D ---- -x/y\z,t -x/y\z,t -A/BXC,D -AXB\C,D -A/B\CVD -x/y\z,t ---- - ---- -y: more slashes: \n, \t ---- -#n -h -g; y/ /T/; p -g; y/\t/T/; p -g; y/N/\n/; p -g; y/N/\ -/; p ---- -a bNc ---- -aTbNc -aTbNc -a b -c -a b -c ---- - ---- -y: separators, including \t, space ---- -#n -h -g; ya\aaAa; p -g; y b B ; p -g; y c C ; p ---- -abcd ---- -Abcd -aBcd -abCd ---- - ---- -y: exceptions - not delimited ---- -y/ab/ab ---- -abc ---- -??? ---- - ---- -y: exceptions - unequal length ---- -y/ab/abc/ ---- -abc ---- -??? ---- - ---- -y: exceptions - additional text ---- -y/ab/ba/ and more ---- -abc ---- -??? ---- - -** -** n, N, p, P -** - ---- -n command with auto-print ---- -n; p; ---- -1 -2 -3 -4 -5 ---- -1 -2 -2 -3 -4 -4 -5 ---- - ---- -n command without auto-print ---- -#n -n; p; ---- -1 -2 -3 -4 -5 ---- -2 -4 ---- - ---- -N command with auto-print ---- -N; p; ---- -1 -2 -3 -4 -5 ---- -1 -2 -1 -2 -3 -4 -3 -4 -5 ---- - ---- -N command without auto-print ---- -#n -N; p; ---- -1 -2 -3 -4 -5 ---- -1 -2 -3 -4 ---- - ---- -p command with auto-print ---- -p ---- -1 -2 -3 ---- -1 -1 -2 -2 -3 -3 ---- - ---- -p command without auto-print ---- -#n -p ---- -1 -2 -3 ---- -1 -2 -3 ---- - ---- -P command with auto-print ---- -N; P; ---- -1 -2 -3 -4 -5 ---- -1 -1 -2 -3 -3 -4 -5 ---- - ---- -P command without auto-print ---- -#n -N; P; ---- -1 -2 -3 -4 -5 ---- -1 -3 ---- - ---- -v command earlier version ---- -v 4.5.3 ---- -test data ---- -test data ---- - ---- -v command later version ---- -v 5.0.3 ---- -test data ---- -??? ---- - ---- -v command with syntax error ---- -v 4.lo-9 ---- -test data ---- -??? ---- - ---- -F command ---- -2F ---- -1 -2 -3 ---- -1 -test-tmp-script.inp -2 -3 ---- diff --git a/src/spec-tests/sed/parser.ts b/src/spec-tests/sed/parser.ts deleted file mode 100644 index 58376246..00000000 --- a/src/spec-tests/sed/parser.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Parser for sed test formats - * - * Supports two formats: - * 1. BusyBox format: testing "description" "commands" "result" "infile" "stdin" - * 2. PythonSed .suite format: - * --- - * description - * --- - * sed script - * --- - * input - * --- - * expected output - * --- - */ - -import { - type BusyBoxTestCase, - type ParsedBusyBoxTestFile, - parseBusyBoxTests, -} from "../../test-utils/busybox-test-parser.js"; - -// Re-export types with sed-specific names for backwards compatibility -export type SedTestCase = BusyBoxTestCase; -export type ParsedSedTestFile = ParsedBusyBoxTestFile; - -/** - * Parse a sed test file (auto-detects format) - */ -export function parseSedTestFile( - content: string, - filePath: string, -): ParsedSedTestFile { - const fileName = filePath.split("/").pop() || filePath; - - // Detect format based on file extension or content - if (fileName.endsWith(".suite")) { - return parsePythonSedSuite(content, filePath); - } - - return parseBusyBoxTests(content, filePath); -} - -/** - * Parse PythonSed .suite format - * - * Format: - * --- - * description - * --- - * sed script - * --- - * input - * --- - * expected output - * --- - */ -function parsePythonSedSuite( - content: string, - filePath: string, -): ParsedSedTestFile { - const fileName = filePath.split("/").pop() || filePath; - const lines = content.split("\n"); - const testCases: SedTestCase[] = []; - - let i = 0; - - while (i < lines.length) { - // Skip lines until we find a --- delimiter - while (i < lines.length && lines[i].trim() !== "---") { - i++; - } - - if (i >= lines.length) break; - - // Found --- - const startLine = i; - i++; - - // Read description (may be multi-line) - const descriptionLines: string[] = []; - while (i < lines.length && lines[i].trim() !== "---") { - descriptionLines.push(lines[i]); - i++; - } - - if (i >= lines.length) break; - - // Skip --- - i++; - - // Read sed script (may be multi-line) - const scriptLines: string[] = []; - while (i < lines.length && lines[i].trim() !== "---") { - scriptLines.push(lines[i]); - i++; - } - - if (i >= lines.length) break; - - // Skip --- - i++; - - // Read input (may be multi-line) - const inputLines: string[] = []; - while (i < lines.length && lines[i].trim() !== "---") { - inputLines.push(lines[i]); - i++; - } - - if (i >= lines.length) break; - - // Skip --- - i++; - - // Read expected output (may be multi-line) - const outputLines: string[] = []; - while (i < lines.length && lines[i].trim() !== "---") { - outputLines.push(lines[i]); - i++; - } - - // Skip final --- - if (i < lines.length && lines[i].trim() === "---") { - i++; - } - - // Build the test case - const description = descriptionLines.join("\n").trim(); - const script = scriptLines.join("\n").trim(); - // Join input lines and add trailing newline for non-empty input - // (matching typical file behavior where files end with newline) - let input = inputLines.join("\n"); - if (input !== "") { - input += "\n"; - } - // Expected output from test file - join lines and add trailing newline - // (real sed always outputs trailing newline for each line) - let expectedOutput = outputLines.join("\n"); - // Add trailing newline for non-empty output (matches real sed behavior) - // "???" is a special marker meaning "expect error" - don't add newline - if (expectedOutput !== "" && expectedOutput !== "???") { - expectedOutput += "\n"; - } - - // Skip empty tests or comments (lines starting with **) - if (!script || description.startsWith("**")) { - continue; - } - - // Skip placeholder tests with empty input AND empty expected output - if (input.trim() === "" && expectedOutput.trim() === "") { - continue; - } - - // Build sed command from script - // The script may be multi-line, so we need to use -e for each line or escape newlines - const command = buildSedCommand(script); - - // Provide default input for tests that have empty input but expect output - // This is common in pythonsed test suites where tests reuse a default pattern - let effectiveInput = input; - if (input.trim() === "" && expectedOutput.trim() !== "") { - // Default input for a/i/c and similar tests that match /TAG/ - effectiveInput = "1\nTAG\n2\n"; - } - - testCases.push({ - name: description || `test at line ${startLine + 1}`, - command, - expectedOutput, - infile: "", - stdin: effectiveInput, - lineNumber: startLine + 1, - }); - } - - return { fileName, filePath, testCases }; -} - -/** - * Build a sed command from a script - */ -function buildSedCommand(script: string): string { - // If script has multiple lines, use multiple -e arguments - const lines = script.split("\n").filter((l) => l.trim() !== ""); - - if (lines.length === 0) { - return "sed ''"; - } - - if (lines.length === 1) { - const escapedScript = lines[0].replace(/'/g, "'\\''"); - return `sed '${escapedScript}'`; - } - - // Multiple lines - use multiple -e arguments - const args = lines.map((l) => `-e '${l.replace(/'/g, "'\\''")}'`).join(" "); - return `sed ${args}`; -} diff --git a/src/spec-tests/sed/runner.ts b/src/spec-tests/sed/runner.ts deleted file mode 100644 index 812a05c3..00000000 --- a/src/spec-tests/sed/runner.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * SED spec test runner - executes parsed sed tests against just-bash's sed - */ - -import { Bash } from "../../Bash.js"; -import type { SedTestCase } from "./parser.js"; - -export interface SedTestResult { - testCase: SedTestCase; - passed: boolean; - skipped: boolean; - skipReason?: string; - /** Test was expected to fail (skip) but unexpectedly passed */ - unexpectedPass?: boolean; - actualOutput?: string; - actualStderr?: string; - actualStatus?: number; - expectedOutput?: string; - error?: string; -} - -export interface RunOptions { - /** Custom Bash options */ - bashEnvOptions?: ConstructorParameters[0]; -} - -/** - * Run a single sed test case - */ -export async function runSedTestCase( - testCase: SedTestCase, - options: RunOptions = {}, -): Promise { - // Track if test is expected to fail (skip) - we'll still run it - const expectedToFail = !!testCase.skip; - const skipReason = testCase.skip; - - // Create files object - const files: Record = { - "/tmp/_keep": "", - }; - - // Add input file if specified (even if empty, for tests that reference it) - // Also create the file if the command references "input" (for empty file tests) - if (testCase.infile || testCase.command.includes("input")) { - files["/tmp/input"] = testCase.infile; - } - - // Create a fresh Bash for each test - const env = new Bash({ - files, - cwd: "/tmp", - env: { - HOME: "/tmp", - }, - ...options.bashEnvOptions, - }); - - try { - // Build the command - let command = testCase.command; - - // Replace "input" file reference with /tmp/input - command = command.replace(/\binput\b/g, "/tmp/input"); - - // If there's stdin, pipe it - let script: string; - if (testCase.stdin) { - const escapedStdin = testCase.stdin.replace(/'/g, "'\\''"); - // Use printf instead of echo -n for better compatibility - script = `printf '%s' '${escapedStdin}' | ${command}`; - } else { - script = command; - } - - const result = await env.exec(script); - - const actualOutput = result.stdout; - const expectedOutput = testCase.expectedOutput; - - // Handle special "???" marker meaning "expect error" - // Test passes if there's an error (non-empty stderr or non-zero exit code) - const expectError = expectedOutput === "???"; - const passed = expectError - ? result.stderr !== "" || result.exitCode !== 0 - : actualOutput === expectedOutput; - - // Handle skip tests - if (expectedToFail) { - if (passed) { - return { - testCase, - passed: false, - skipped: false, - unexpectedPass: true, - actualOutput, - actualStderr: result.stderr, - actualStatus: result.exitCode, - expectedOutput, - error: `UNEXPECTED PASS: This test was marked skip (${skipReason}) but now passes. Please remove the skip.`, - }; - } - return { - testCase, - passed: true, - skipped: true, - skipReason: `skip: ${skipReason}`, - actualOutput, - actualStderr: result.stderr, - actualStatus: result.exitCode, - expectedOutput, - }; - } - - return { - testCase, - passed, - skipped: false, - actualOutput, - actualStderr: result.stderr, - actualStatus: result.exitCode, - expectedOutput, - error: passed - ? undefined - : `Output mismatch:\n expected: ${JSON.stringify(expectedOutput)}\n actual: ${JSON.stringify(actualOutput)}`, - }; - } catch (e) { - // If test was expected to fail and threw an error, that counts as expected failure - if (expectedToFail) { - return { - testCase, - passed: true, - skipped: true, - skipReason: `skip: ${skipReason}`, - error: `Execution error (expected): ${e instanceof Error ? e.message : String(e)}`, - }; - } - return { - testCase, - passed: false, - skipped: false, - error: `Execution error: ${e instanceof Error ? e.message : String(e)}`, - }; - } -} - -/** - * Format error message for debugging - */ -export function formatError(result: SedTestResult): string { - const lines: string[] = []; - - if (result.error) { - lines.push(result.error); - lines.push(""); - } - - lines.push("OUTPUT:"); - lines.push(` expected: ${JSON.stringify(result.expectedOutput ?? "")}`); - lines.push(` actual: ${JSON.stringify(result.actualOutput ?? "")}`); - - if (result.actualStderr) { - lines.push("STDERR:"); - lines.push(` ${JSON.stringify(result.actualStderr)}`); - } - - lines.push(""); - lines.push("COMMAND:"); - lines.push(result.testCase.command); - - if (result.testCase.stdin) { - lines.push(""); - lines.push("STDIN:"); - lines.push(result.testCase.stdin); - } - - if (result.testCase.infile) { - lines.push(""); - lines.push("INFILE:"); - lines.push(result.testCase.infile); - } - - return lines.join("\n"); -} diff --git a/src/spec-tests/sed/sed-spec.test.ts b/src/spec-tests/sed/sed-spec.test.ts deleted file mode 100644 index a7cf17d1..00000000 --- a/src/spec-tests/sed/sed-spec.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Vitest runner for sed spec tests - * - * This runs the imported spec tests from BusyBox and other sources - * against just-bash's sed implementation. - */ - -import * as fs from "node:fs"; -import * as path from "node:path"; -import { describe, expect, it } from "vitest"; -import { parseSedTestFile } from "./parser.js"; -import { formatError, runSedTestCase } from "./runner.js"; -import { getSkipReason, isFileSkipped } from "./skips.js"; - -const CASES_DIR = path.join(__dirname, "cases"); - -// Get all .tests and .suite files -const ALL_TEST_FILES = fs - .readdirSync(CASES_DIR) - .filter((f) => f.endsWith(".tests") || f.endsWith(".suite")) - .sort(); - -// Filter out completely skipped files -const TEST_FILES = ALL_TEST_FILES.filter((f) => !isFileSkipped(f)); - -/** - * Truncate command for test name display - */ -function truncateCommand(command: string, maxLen = 50): string { - const normalized = command.trim().replace(/\s+/g, " "); - if (normalized.length <= maxLen) { - return normalized; - } - return `${normalized.slice(0, maxLen - 3)}...`; -} - -describe("SED Spec Tests", () => { - // Add a placeholder test to ensure suite is not empty when all files are skipped - if (TEST_FILES.length === 0) { - it("All test files are currently skipped", () => { - // This test passes - it's just a placeholder - }); - } - - for (const fileName of TEST_FILES) { - const filePath = path.join(CASES_DIR, fileName); - - describe(fileName, () => { - // Parse the test file - const content = fs.readFileSync(filePath, "utf-8"); - const parsed = parseSedTestFile(content, filePath); - - // Skip files with no parseable tests - if (parsed.testCases.length === 0) { - it.skip("No parseable tests", () => {}); - return; - } - - for (const testCase of parsed.testCases) { - // Check for individual test skip - const skipReason = getSkipReason( - fileName, - testCase.name, - testCase.command, - ); - if (skipReason) { - testCase.skip = skipReason; - } - - const commandPreview = truncateCommand(testCase.command); - const testName = `[L${testCase.lineNumber}] ${testCase.name}: ${commandPreview}`; - - it(testName, async () => { - const result = await runSedTestCase(testCase); - - if (result.skipped) { - return; - } - - if (!result.passed) { - expect.fail(formatError(result)); - } - }); - } - }); - } -}); diff --git a/src/spec-tests/sed/skips.ts b/src/spec-tests/sed/skips.ts deleted file mode 100644 index 80cf456a..00000000 --- a/src/spec-tests/sed/skips.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * Skip list for SED spec tests - * - * Tests in this list are expected to fail. If a test passes unexpectedly, - * the test runner will report it as a failure so we know to remove it from the skip list. - */ - -/** - * Files to skip entirely - */ -const SKIP_FILES: Set = new Set([]); - -/** - * Individual test skips within files - * Format: "fileName:testName" -> skipReason - */ -const SKIP_TESTS: Map = new Map([ - // ============================================================ - // BusyBox tests - // ============================================================ - [ - "busybox-sed.tests:sed with N skipping lines past ranges on next cmds", - "N command with ranges", - ], - ["busybox-sed.tests:sed NUL in command", "NUL bytes in command file"], - ["busybox-sed.tests:sed subst+write", "w command with multiple files"], - ["busybox-sed.tests:sed clusternewline", "insert/p command output ordering"], - [ - "busybox-sed.tests:sed selective matches noinsert newline", - "trailing newline with matches only in first file", - ], - [ - "busybox-sed.tests:sed match EOF inline", - "$ address with -i and multi-file", - ], - ["busybox-sed.tests:sed lie-to-autoconf", "--version output"], - [ - "busybox-sed.tests:sed a cmd ended by double backslash", - "backslash escaping in a command", - ], - [ - "busybox-sed.tests:sed special char as s/// delimiter, in replacement 2", - "special delimiter with backreference", - ], - ["busybox-sed.tests:sed 's///w FILE'", "w flag with file path syntax"], - - // ============================================================ - // PythonSed unit tests - // ============================================================ - [ - "pythonsed-unit.suite:syntax: terminating commands - aic", - "#n special comment not supported", - ], - [ - "pythonsed-unit.suite:regexp address: separators", - "custom regex delimiter \\x not supported", - ], - [ - "pythonsed-unit.suite:regexp address: flags", - "/I case-insensitive flag not supported", - ], - [ - "pythonsed-unit.suite:regexp address: address range with flag", - "/I case-insensitive flag not supported", - ], - [ - "pythonsed-unit.suite:empty addresses: address range", - "malformed test case: empty input with non-empty expected output", - ], - [ - "pythonsed-unit.suite:regexp: back reference before num in address", - "\\10 backreference in address", - ], - [ - "pythonsed-unit.suite:regexp extended: back reference before num in address", - "\\10 backreference in address with ERE", - ], - ["pythonsed-unit.suite:avoid python extension - 2", "BRE grouping edge case"], - ["pythonsed-unit.suite:(^){2}", "#r comment and ^ quantified"], - [ - "pythonsed-unit.suite:substitution: back reference before num in regexp", - "\\10 parsed as \\1 + 0", - ], - [ - "pythonsed-unit.suite:regexp: ** BRE (multiple quantifier)", - "multiple quantifier error handling", - ], - [ - "pythonsed-unit.suite:regexp: ** ERE (multiple quantifier)", - "multiple quantifier error handling", - ], - [ - "pythonsed-unit.suite:regexp: *\\\\? BRE (multiple quantifier)", - "multiple quantifier error handling", - ], - [ - "pythonsed-unit.suite:regexp: *? ERE (multiple quantifier)", - "multiple quantifier error handling", - ], - [ - "pythonsed-unit.suite:regexp: *\\? BRE (multiple quantifier)", - "BRE multiple quantifier handling", - ], - [ - "pythonsed-unit.suite:substitution: -r: back reference before num in regexp", - "\\10 parsing with extended regex", - ], - [ - "pythonsed-unit.suite:empty regexp: case modifier propagation", - "empty regex reuse and /I flag", - ], - [ - "pythonsed-unit.suite:empty regexp: same empty regexp, different case status", - "empty regex reuse and /I flag", - ], - [ - "pythonsed-unit.suite:empty regexp: case modifier propagation for addresses", - "empty regex reuse and /I flag", - ], - ["pythonsed-unit.suite:F command", "F command with stdin (no filename)"], - [ - "pythonsed-unit.suite:Change command c", - "multi-line c command with backslash continuation", - ], - - // ============================================================ - // PythonSed chang.suite - complex N/D/P scripts - // ============================================================ - [ - "pythonsed-chang.suite:Delete two consecutive lines if the first one contains PAT1 and the second one contains PAT2.", - "N/P/D commands", - ], - [ - "pythonsed-chang.suite:Get the line following a line containing PAT - Case 1 - 1.", - "N/D commands", - ], - [ - "pythonsed-chang.suite:Remove comments (/* ... */, maybe multi-line) of a C program. - 1", - "N command behavior", - ], - [ - "pythonsed-chang.suite:Extract (possibly multiline) contents between 'BEGIN' and the matching 'END'.", - "N command behavior", - ], - ["pythonsed-chang.suite:test at line 1516", "** not a valid command"], - [ - "pythonsed-chang.suite:Remove almost identical lines.", - "N/D/P commands with hold space", - ], - [ - 'pythonsed-chang.suite:For consecutive "almost identical" lines, print only the first one.', - "N/D/P commands with hold space", - ], - [ - "pythonsed-chang.suite:Remove consecutive duplicate lines.", - "N/D commands with pattern matching", - ], - [ - "pythonsed-chang.suite:Retrieve the first line among consecutive lines of the same key - 1.", - "N/D commands with complex branching", - ], - [ - "pythonsed-chang.suite:Delete the LAST N-th line through the LAST M-th line of a datafile, where N is greater than M - 1.", - "complex N/D branching", - ], - [ - "pythonsed-chang.suite:Get every Nth line of a file - 1.", - "complex N/D branching", - ], - [ - "pythonsed-chang.suite:Join every N lines to one - 1.", - "complex N/D branching", - ], - [ - 'pythonsed-chang.suite:Extract "Received:" header(s) from a mailbox.', - "complex N/D branching", - ], - [ - "pythonsed-chang.suite:Extract every IMG elements from an HTML file.", - "complex branching", - ], - [ - "pythonsed-chang.suite:Find failed instances without latter successful ones.", - "complex N/D branching", - ], - [ - "pythonsed-chang.suite:Change the first quote of every single-quoted string to backquote(`). - 1", - "complex pattern manipulation", - ], - ["pythonsed-chang.suite:1 cat chicken", "test name parsing issue"], - [ - "pythonsed-chang.suite:First number 1111 Second <2222>", - "test name parsing issue", - ], - ["pythonsed-chang.suite:word_1 word_2 word_3", "test name parsing issue"], - ["pythonsed-chang.suite:number [8888]", "test name parsing issue"], -]); - -/** - * Pattern-based skips for tests matching certain patterns - */ -const SKIP_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [ - { pattern: /1 cat chicken/, reason: "complex N-th match test" }, - { pattern: /First number 1111/, reason: "complex N-th match test" }, - { pattern: /word_1 word_2/, reason: "complex N-th match test" }, - { pattern: /number \[8888\]/, reason: "complex N-th match test" }, - { - pattern: /Extract matched headers of a mail/, - reason: "complex mail header test", - }, -]; - -/** - * Get skip reason for a test - */ -export function getSkipReason( - fileName: string, - testName: string, - command?: string, -): string | undefined { - // Check file-level skip first - if (SKIP_FILES.has(fileName)) { - return `File skipped: ${fileName}`; - } - - // Check individual test skip (exact match) - const key = `${fileName}:${testName}`; - const exactMatch = SKIP_TESTS.get(key); - if (exactMatch) { - return exactMatch; - } - - // Check pattern-based skips against test name - for (const { pattern, reason } of SKIP_PATTERNS) { - if (pattern.test(testName)) { - return reason; - } - } - - // Check pattern-based skips against command content - if (command) { - for (const { pattern, reason } of SKIP_PATTERNS) { - if (pattern.test(command)) { - return reason; - } - } - } - - return undefined; -} - -/** - * Check if entire file should be skipped - */ -export function isFileSkipped(fileName: string): boolean { - return SKIP_FILES.has(fileName); -} diff --git a/src/spec-tests/test-commands.ts b/src/spec-tests/test-commands.ts deleted file mode 100644 index 99463ea9..00000000 --- a/src/spec-tests/test-commands.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Test helper commands for spec tests - * These replace the Python scripts used in the original Oil shell tests - * - * NOTE: Standard Unix commands (tac, od, hostname) are now in src/commands/ - */ - -import { defineCommand } from "../custom-commands.js"; -import type { Command } from "../types.js"; - -// argv.py - prints arguments in Python 2 repr() format: ['arg1', "arg with '"] -// Python uses single quotes by default, double quotes when string contains single quotes -// Python 2 escapes non-printable and non-ASCII bytes as \xNN -export const argvCommand: Command = defineCommand("argv.py", async (args) => { - const formatted = args.map((arg) => { - // Convert string to Python 2 repr() format - // Process character by character, escaping as needed - let escaped = ""; - for (let i = 0; i < arg.length; i++) { - const char = arg[i]; - const code = arg.charCodeAt(i); - - if (char === "\\") { - escaped += "\\\\"; - } else if (char === "\n") { - escaped += "\\n"; - } else if (char === "\r") { - escaped += "\\r"; - } else if (char === "\t") { - escaped += "\\t"; - } else if (code < 0x20 || code === 0x7f) { - // Non-printable ASCII control characters -> \xNN - escaped += `\\x${code.toString(16).padStart(2, "0")}`; - } else if (code >= 0x80 && code <= 0xff) { - // Latin-1 range (U+0080-U+00FF): show as single \xNN - // This matches Python 2 behavior where bytes are 1:1 with codepoints - escaped += `\\x${code.toString(16).padStart(2, "0")}`; - } else if (code >= 0x100) { - // Non-Latin-1 Unicode: encode as UTF-8 bytes, then escape each byte as \xNN - // This matches Python 2 behavior with byte strings - const encoder = new TextEncoder(); - const bytes = encoder.encode(char); - for (const byte of bytes) { - escaped += `\\x${byte.toString(16).padStart(2, "0")}`; - } - } else { - // Printable ASCII - escaped += char; - } - } - - const hasSingleQuote = arg.includes("'"); - const hasDoubleQuote = arg.includes('"'); - - if (hasSingleQuote && !hasDoubleQuote) { - // Use double quotes when string contains single quotes but no double quotes - return `"${escaped}"`; - } - // Default: use single quotes (escape single quotes) - escaped = escaped.replace(/'/g, "\\'"); - return `'${escaped}'`; - }); - return { stdout: `[${formatted.join(", ")}]\n`, stderr: "", exitCode: 0 }; -}); - -// printenv.py - prints environment variable values, one per line -// Prints "None" for variables that are not set (matching Python's printenv.py) -// Uses exportedEnv (only exported variables) to match bash behavior -export const printenvCommand: Command = defineCommand( - "printenv.py", - async (args, ctx) => { - // Use exportedEnv if available (only exported vars), fall back to full env - const env = ctx.exportedEnv || ctx.env; - const output = args - .map((name) => { - const value = env instanceof Map ? env.get(name) : env[name]; - return value ?? "None"; - }) - .join("\n"); - return { - stdout: output ? `${output}\n` : "", - stderr: "", - exitCode: 0, - }; - }, -); - -// stdout_stderr.py - outputs to both stdout and stderr -// If an argument is provided, it outputs that to stdout instead of "STDOUT" -export const stdoutStderrCommand: Command = defineCommand( - "stdout_stderr.py", - async (args) => { - const stdout = args.length > 0 ? `${args[0]}\n` : "STDOUT\n"; - return { stdout, stderr: "STDERR\n", exitCode: 0 }; - }, -); - -// read_from_fd.py - reads from specified file descriptors -// Arguments are FD numbers. For each FD, outputs ": " (without trailing newline from content) -export const readFromFdCommand: Command = defineCommand( - "read_from_fd.py", - async (args, ctx) => { - const results: string[] = []; - - for (const arg of args) { - const fd = Number.parseInt(arg, 10); - if (Number.isNaN(fd)) { - continue; - } - - let content = ""; - if (fd === 0) { - // FD 0 is stdin - content = ctx.stdin || ""; - } else if (ctx.fileDescriptors) { - // Other FDs from the fileDescriptors map - content = ctx.fileDescriptors.get(fd) || ""; - } - - // Remove trailing newline from content for the output format - const trimmedContent = content.replace(/\n$/, ""); - results.push(`${fd}: ${trimmedContent}`); - } - - return { - stdout: results.length > 0 ? `${results.join("\n")}\n` : "", - stderr: "", - exitCode: 0, - }; - }, -); - -/** All test helper commands (Python script replacements) */ -export const testHelperCommands: Command[] = [ - argvCommand, - printenvCommand, - stdoutStderrCommand, - readFromFdCommand, -]; diff --git a/src/syntax/break-continue.test.ts b/src/syntax/break-continue.test.ts deleted file mode 100644 index 3ceaa032..00000000 --- a/src/syntax/break-continue.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -describe("Bash Syntax - break and continue", () => { - describe("break", () => { - it("should exit for loop early", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3 4 5; do - if [ $i -eq 3 ]; then break; fi - echo $i - done - echo done - `); - expect(result.stdout).toBe("1\n2\ndone\n"); - expect(result.exitCode).toBe(0); - }); - - it("should exit while loop early", async () => { - const env = new Bash(); - const result = await env.exec(` - x=0 - while [ $x -lt 10 ]; do - x=$((x + 1)) - if [ $x -eq 3 ]; then break; fi - echo $x - done - echo done - `); - expect(result.stdout).toBe("1\n2\ndone\n"); - expect(result.exitCode).toBe(0); - }); - - it("should exit until loop early", async () => { - const env = new Bash(); - const result = await env.exec(` - x=0 - until [ $x -ge 10 ]; do - x=$((x + 1)) - if [ $x -eq 3 ]; then break; fi - echo $x - done - echo done - `); - expect(result.stdout).toBe("1\n2\ndone\n"); - expect(result.exitCode).toBe(0); - }); - - it("should break multiple levels with break n", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2; do - for j in a b c; do - if [ $j = b ]; then break 2; fi - echo "$i$j" - done - done - echo done - `); - expect(result.stdout).toBe("1a\ndone\n"); - expect(result.exitCode).toBe(0); - }); - - it("should silently do nothing when not in loop", async () => { - const env = new Bash(); - const result = await env.exec("break"); - // In bash, break outside a loop silently does nothing - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should error on invalid argument", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3; do - break abc - done - `); - expect(result.stderr).toContain("numeric argument required"); - expect(result.exitCode).toBe(128); // bash returns 128 for invalid break args - }); - }); - - describe("continue", () => { - it("should skip to next iteration in for loop", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3 4 5; do - if [ $i -eq 3 ]; then continue; fi - echo $i - done - echo done - `); - expect(result.stdout).toBe("1\n2\n4\n5\ndone\n"); - expect(result.exitCode).toBe(0); - }); - - it("should skip to next iteration in while loop", async () => { - const env = new Bash(); - const result = await env.exec(` - x=0 - while [ $x -lt 5 ]; do - x=$((x + 1)) - if [ $x -eq 3 ]; then continue; fi - echo $x - done - echo done - `); - expect(result.stdout).toBe("1\n2\n4\n5\ndone\n"); - expect(result.exitCode).toBe(0); - }); - - it("should continue multiple levels with continue n", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2; do - for j in a b c; do - if [ $j = b ]; then continue 2; fi - echo "$i$j" - done - echo "end-$i" - done - echo done - `); - expect(result.stdout).toBe("1a\n2a\ndone\n"); - expect(result.exitCode).toBe(0); - }); - - it("should silently do nothing when not in loop", async () => { - const env = new Bash(); - const result = await env.exec("continue"); - // In bash, continue outside a loop silently does nothing - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }); - }); - - describe("nested control flow", () => { - it("should work with case statements inside loops", async () => { - const env = new Bash(); - const result = await env.exec(` - for x in a b c; do - case $x in - b) continue ;; - esac - echo $x - done - `); - expect(result.stdout).toBe("a\nc\n"); - expect(result.exitCode).toBe(0); - }); - - it("should work with subshells", async () => { - // break inside subshell should exit the subshell (no loop context) - // bash outputs: 1\n3\ndone\n (break exits subshell on i=2, no echo) - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3; do - ( - if [ $i -eq 2 ]; then break; fi - echo $i - ) - done - echo done - `); - // break inside subshell only breaks out of that iteration's subshell - // but the subshell exit code doesn't stop the outer loop - expect(result.stdout).toBe("1\n3\ndone\n"); - }); - }); -}); diff --git a/src/syntax/case-statement.test.ts b/src/syntax/case-statement.test.ts deleted file mode 100644 index ab200d84..00000000 --- a/src/syntax/case-statement.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -describe("Case Statement", () => { - it("should match exact pattern", async () => { - const env = new Bash(); - const result = await env.exec(` - case hello in - hello) echo "matched hello";; - world) echo "matched world";; - esac - `); - expect(result.stdout).toBe("matched hello\n"); - expect(result.exitCode).toBe(0); - }); - - it("should match wildcard pattern", async () => { - const env = new Bash(); - const result = await env.exec(` - case "anything" in - specific) echo "specific";; - *) echo "wildcard";; - esac - `); - expect(result.stdout).toBe("wildcard\n"); - expect(result.exitCode).toBe(0); - }); - - it("should match glob patterns", async () => { - const env = new Bash(); - const result = await env.exec(` - case "hello.txt" in - *.txt) echo "text file";; - *.md) echo "markdown file";; - *) echo "other";; - esac - `); - expect(result.stdout).toBe("text file\n"); - expect(result.exitCode).toBe(0); - }); - - it("should match multiple patterns with |", async () => { - const env = new Bash(); - const result = await env.exec(` - case "yes" in - y|yes|Y|YES) echo "confirmed";; - n|no|N|NO) echo "denied";; - *) echo "unknown";; - esac - `); - expect(result.stdout).toBe("confirmed\n"); - expect(result.exitCode).toBe(0); - }); - - it("should work with variables", async () => { - const env = new Bash({ env: { FRUIT: "apple" } }); - const result = await env.exec(` - case $FRUIT in - apple) echo "It's an apple";; - orange) echo "It's an orange";; - *) echo "Unknown fruit";; - esac - `); - expect(result.stdout).toBe("It's an apple\n"); - expect(result.exitCode).toBe(0); - }); - - it("should execute only first matching branch", async () => { - const env = new Bash(); - const result = await env.exec(` - case "test" in - test) echo "first";; - test) echo "second";; - *) echo "wildcard";; - esac - `); - expect(result.stdout).toBe("first\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle no match (empty output)", async () => { - const env = new Bash(); - const result = await env.exec(` - case "nomatch" in - a) echo "a";; - b) echo "b";; - esac - `); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should handle single-line case", async () => { - const env = new Bash(); - const result = await env.exec( - 'case "x" in x) echo "X";; y) echo "Y";; esac', - ); - expect(result.stdout).toBe("X\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle question mark wildcard", async () => { - const env = new Bash(); - const result = await env.exec(` - case "abc" in - a?c) echo "matches";; - *) echo "no match";; - esac - `); - expect(result.stdout).toBe("matches\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle character class", async () => { - const env = new Bash(); - const result = await env.exec(` - case "b" in - [abc]) echo "a, b, or c";; - [xyz]) echo "x, y, or z";; - *) echo "other";; - esac - `); - expect(result.stdout).toBe("a, b, or c\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle pattern with prefix wildcard", async () => { - const env = new Bash(); - const result = await env.exec(` - case "myfile.bak" in - *.bak) echo "backup file";; - *) echo "regular file";; - esac - `); - expect(result.stdout).toBe("backup file\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle multiple commands in branch", async () => { - const env = new Bash(); - const result = await env.exec(` - case "multi" in - multi) - echo "first" - echo "second" - ;; - *) echo "default";; - esac - `); - expect(result.stdout).toBe("first\nsecond\n"); - expect(result.exitCode).toBe(0); - }); - - it("should work with command substitution in case word", async () => { - const env = new Bash(); - const result = await env.exec(` - case $(echo test) in - test) echo "matched";; - *) echo "no match";; - esac - `); - expect(result.stdout).toBe("matched\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle last branch without ;;", async () => { - const env = new Bash(); - const result = await env.exec(` - case "default" in - a) echo "a";; - *) echo "fallback" - esac - `); - expect(result.stdout).toBe("fallback\n"); - expect(result.exitCode).toBe(0); - }); - - it("should match numbers", async () => { - const env = new Bash(); - const result = await env.exec(` - case "42" in - [0-9]) echo "single digit";; - [0-9][0-9]) echo "double digit";; - *) echo "other";; - esac - `); - expect(result.stdout).toBe("double digit\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle optional opening paren", async () => { - const env = new Bash(); - const result = await env.exec(` - case "test" in - (test) echo "with paren";; - other) echo "no match";; - esac - `); - expect(result.stdout).toBe("with paren\n"); - expect(result.exitCode).toBe(0); - }); -}); diff --git a/src/syntax/command-substitution.test.ts b/src/syntax/command-substitution.test.ts deleted file mode 100644 index cc91c4f3..00000000 --- a/src/syntax/command-substitution.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -describe("Command Substitution $(cmd)", () => { - it("should capture echo output", async () => { - const env = new Bash(); - const result = await env.exec("echo $(echo hello)"); - expect(result.stdout).toBe("hello\n"); - expect(result.exitCode).toBe(0); - }); - - it("should capture command output in variable (within same exec)", async () => { - const env = new Bash(); - const result = await env.exec("X=$(echo world); echo $X"); - expect(result.stdout).toBe("world\n"); - }); - - it("should work with cat", async () => { - const env = new Bash({ - files: { "/test.txt": "file content" }, - }); - const result = await env.exec("echo $(cat /test.txt)"); - expect(result.stdout).toBe("file content\n"); - }); - - it("should strip trailing newline from substitution", async () => { - const env = new Bash(); - const result = await env.exec("echo prefix-$(echo middle)-suffix"); - expect(result.stdout).toBe("prefix-middle-suffix\n"); - }); - - it("should handle nested command substitution", async () => { - const env = new Bash(); - const result = await env.exec("echo $(echo $(echo nested))"); - expect(result.stdout).toBe("nested\n"); - }); - - it("should work with pipes inside substitution", async () => { - const env = new Bash({ - files: { "/test.txt": "hello\nworld\n" }, - }); - const result = await env.exec("echo $(cat /test.txt | grep world)"); - expect(result.stdout).toBe("world\n"); - }); - - it("should convert newlines to spaces in unquoted command substitution", async () => { - const env = new Bash({ - files: { "/test.txt": "line1\nline2\nline3" }, - }); - const result = await env.exec("echo $(cat /test.txt)"); - // In bash, newlines become spaces when echoed unquoted - expect(result.stdout).toBe("line1 line2 line3\n"); - }); - - it("should work with wc -l", async () => { - const env = new Bash({ - files: { "/test.txt": "a\nb\nc\n" }, - }); - const result = await env.exec("echo lines: $(wc -l < /test.txt)"); - expect(result.stdout).toMatch(/lines:\s+3/); - }); - - it("should work in variable assignment (within same exec)", async () => { - const env = new Bash(); - const result = await env.exec("COUNT=$(echo 42); echo $COUNT"); - expect(result.stdout).toBe("42\n"); - }); - - it("should handle empty command output", async () => { - const env = new Bash(); - const result = await env.exec("echo prefix$(echo)suffix"); - expect(result.stdout).toBe("prefixsuffix\n"); - }); -}); - -describe("Arithmetic Expansion $((expr))", () => { - it("should evaluate simple addition", async () => { - const env = new Bash(); - const result = await env.exec("echo $((1 + 2))"); - expect(result.stdout).toBe("3\n"); - }); - - it("should evaluate subtraction", async () => { - const env = new Bash(); - const result = await env.exec("echo $((10 - 3))"); - expect(result.stdout).toBe("7\n"); - }); - - it("should evaluate multiplication", async () => { - const env = new Bash(); - const result = await env.exec("echo $((4 * 5))"); - expect(result.stdout).toBe("20\n"); - }); - - it("should evaluate division (integer)", async () => { - const env = new Bash(); - const result = await env.exec("echo $((10 / 3))"); - expect(result.stdout).toBe("3\n"); - }); - - it("should evaluate modulo", async () => { - const env = new Bash(); - const result = await env.exec("echo $((10 % 3))"); - expect(result.stdout).toBe("1\n"); - }); - - it("should evaluate power", async () => { - const env = new Bash(); - const result = await env.exec("echo $((2 ** 8))"); - expect(result.stdout).toBe("256\n"); - }); - - it("should handle parentheses", async () => { - const env = new Bash(); - const result = await env.exec("echo $(((2 + 3) * 4))"); - expect(result.stdout).toBe("20\n"); - }); - - it("should handle variables with $ prefix", async () => { - const env = new Bash({ env: { X: "5" } }); - const result = await env.exec("echo $(($X + 3))"); - expect(result.stdout).toBe("8\n"); - }); - - it("should handle variables without $ prefix", async () => { - const env = new Bash({ env: { X: "5" } }); - const result = await env.exec("echo $((X + 3))"); - expect(result.stdout).toBe("8\n"); - }); - - it("should handle negative numbers", async () => { - const env = new Bash(); - const result = await env.exec("echo $((-5 + 3))"); - expect(result.stdout).toBe("-2\n"); - }); - - it("should handle comparison operators", async () => { - const env = new Bash(); - expect((await env.exec("echo $((5 > 3))")).stdout).toBe("1\n"); - expect((await env.exec("echo $((5 < 3))")).stdout).toBe("0\n"); - expect((await env.exec("echo $((5 == 5))")).stdout).toBe("1\n"); - expect((await env.exec("echo $((5 != 5))")).stdout).toBe("0\n"); - }); - - it("should handle logical operators", async () => { - const env = new Bash(); - expect((await env.exec("echo $((1 && 1))")).stdout).toBe("1\n"); - expect((await env.exec("echo $((1 && 0))")).stdout).toBe("0\n"); - expect((await env.exec("echo $((0 || 1))")).stdout).toBe("1\n"); - expect((await env.exec("echo $((0 || 0))")).stdout).toBe("0\n"); - }); - - it("should handle bitwise operators", async () => { - const env = new Bash(); - expect((await env.exec("echo $((5 & 3))")).stdout).toBe("1\n"); // 101 & 011 = 001 - expect((await env.exec("echo $((5 | 3))")).stdout).toBe("7\n"); // 101 | 011 = 111 - expect((await env.exec("echo $((5 ^ 3))")).stdout).toBe("6\n"); // 101 ^ 011 = 110 - }); - - it("should handle shift operators", async () => { - const env = new Bash(); - expect((await env.exec("echo $((1 << 4))")).stdout).toBe("16\n"); - expect((await env.exec("echo $((16 >> 2))")).stdout).toBe("4\n"); - }); - - it("should handle complex expressions", async () => { - const env = new Bash(); - const result = await env.exec("echo $((2 + 3 * 4 - 1))"); - expect(result.stdout).toBe("13\n"); // 2 + 12 - 1 = 13 - }); - - it("should work in variable assignment (within same exec)", async () => { - const env = new Bash(); - const result = await env.exec("SUM=$((10 + 20)); echo $SUM"); - expect(result.stdout).toBe("30\n"); - }); - - it("should handle increment pattern (within same exec)", async () => { - const env = new Bash(); - const result = await env.exec("N=5; N=$((N + 1)); echo $N"); - expect(result.stdout).toBe("6\n"); - }); -}); diff --git a/src/syntax/composition.test.ts b/src/syntax/composition.test.ts deleted file mode 100644 index c49c1806..00000000 --- a/src/syntax/composition.test.ts +++ /dev/null @@ -1,509 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -describe("Syntax Feature Composition", () => { - describe("If statements with other features", () => { - it("should use command substitution in if condition", async () => { - const env = new Bash(); - const result = await env.exec(` - if [[ $(echo hello) == "hello" ]]; then - echo "matched" - fi - `); - expect(result.stdout).toBe("matched\n"); - }); - - it("should use arithmetic in if condition", async () => { - const env = new Bash(); - const result = await env.exec(` - export X=5 - if [[ $((X + 3)) -eq 8 ]]; then - echo "math works" - fi - `); - expect(result.stdout).toBe("math works\n"); - }); - - it("should use here document inside if block", async () => { - const env = new Bash(); - const result = await env.exec(` - if [[ 1 -eq 1 ]]; then - cat < { - const env = new Bash(); - const result = await env.exec(` - export VAR=apple - if [[ -n "$VAR" ]]; then - case $VAR in - apple) echo "it's an apple";; - *) echo "unknown";; - esac - fi - `); - expect(result.stdout).toBe("it's an apple\n"); - }); - - it("should use pipes inside if block", async () => { - const env = new Bash(); - const result = await env.exec(` - if [[ 1 -eq 1 ]]; then - echo -e "line1\\nline2\\nline3" | grep line2 - fi - `); - expect(result.stdout).toBe("line2\n"); - }); - }); - - describe("Here documents with pipes and commands", () => { - it("should pipe here document through multiple commands", async () => { - const env = new Bash(); - const result = await env.exec(`cat < { - const env = new Bash({ env: { PATTERN: "world" } }); - const result = await env.exec(`cat < { - const env = new Bash(); - const result = await env.exec(`cat < { - const env = new Bash(); - const result = await env.exec(`cat < { - const env = new Bash(); - const result = await env.exec(`cat < { - it("should use command substitution as case word", async () => { - const env = new Bash(); - const result = await env.exec(` - case $(echo test) in - test) echo "matched command output";; - *) echo "no match";; - esac - `); - expect(result.stdout).toBe("matched command output\n"); - }); - - it("should use arithmetic result as case word", async () => { - const env = new Bash(); - const result = await env.exec(` - case $((2 + 3)) in - 5) echo "five";; - *) echo "other";; - esac - `); - expect(result.stdout).toBe("five\n"); - }); - - it("should use pipes inside case branch", async () => { - const env = new Bash(); - const result = await env.exec(` - case "process" in - process) - echo -e "a\\nb\\nc" | wc -l - ;; - esac - `); - expect(result.stdout.trim()).toBe("3"); - }); - - it("should use here document inside case branch", async () => { - const env = new Bash(); - const result = await env.exec(` - case "heredoc" in - heredoc) - cat < { - const env = new Bash(); - const result = await env.exec(` - case "outer" in - outer) - case "inner" in - inner) echo "nested match";; - esac - ;; - esac - `); - expect(result.stdout).toBe("nested match\n"); - }); - }); - - describe("Test expressions with other features", () => { - it("should test command substitution result", async () => { - const env = new Bash(); - const result = await env.exec(` - if [[ $(echo "yes") == "yes" ]]; then - echo "command output matched" - fi - `); - expect(result.stdout).toBe("command output matched\n"); - }); - - it("should test arithmetic result", async () => { - const env = new Bash(); - const result = await env.exec(` - if [[ $((10 / 2)) -eq 5 ]]; then - echo "arithmetic correct" - fi - `); - expect(result.stdout).toBe("arithmetic correct\n"); - }); - - it("should use test expression with file created by previous command", async () => { - const env = new Bash(); - await env.exec("echo 'content' > /tmp/testfile.txt"); - const result = await env.exec(` - if [[ -f /tmp/testfile.txt ]]; then - echo "file exists" - fi - `); - expect(result.stdout).toBe("file exists\n"); - }); - - it("should combine multiple test conditions with command substitution", async () => { - const env = new Bash(); - const result = await env.exec(` - export COUNT=3 - if [[ $COUNT -gt 0 && $(echo "valid") == "valid" ]]; then - echo "both conditions met" - fi - `); - expect(result.stdout).toBe("both conditions met\n"); - }); - }); - - describe("Loops with syntax features", () => { - it("should use command substitution in for loop", async () => { - const env = new Bash(); - const result = await env.exec(` - for item in $(echo "a b c"); do - echo "item: $item" - done - `); - expect(result.stdout).toBe("item: a\nitem: b\nitem: c\n"); - }); - - it("should use arithmetic in while loop condition", async () => { - const env = new Bash(); - const result = await env.exec(` - export I=0 - while [[ $I -lt 3 ]]; do - echo "i=$I" - export I=$((I + 1)) - done - `); - expect(result.stdout).toBe("i=0\ni=1\ni=2\n"); - }); - - it("should use case statement inside loop", async () => { - const env = new Bash(); - const result = await env.exec(` - for fruit in apple banana cherry; do - case $fruit in - apple) echo "red";; - banana) echo "yellow";; - cherry) echo "red";; - esac - done - `); - expect(result.stdout).toBe("red\nyellow\nred\n"); - }); - - it("should use here document inside loop", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2; do - cat < { - const env = new Bash(); - const result = await env.exec(` - for i in 3 1 2; do - echo $i - done | sort - `); - expect(result.stdout).toBe("1\n2\n3\n"); - }); - }); - - describe("Functions with syntax features", () => { - it("should use command substitution in function", async () => { - const env = new Bash(); - const result = await env.exec(` - greet() { - local name=$(echo "World") - echo "Hello, $name!" - } - greet - `); - expect(result.stdout).toBe("Hello, World!\n"); - }); - - it("should use arithmetic in function", async () => { - const env = new Bash(); - const result = await env.exec(` - add() { - echo $(($1 + $2)) - } - add 5 3 - `); - expect(result.stdout).toBe("8\n"); - }); - - it("should use case statement in function", async () => { - const env = new Bash(); - const result = await env.exec(` - get_color() { - case $1 in - apple) echo "red";; - banana) echo "yellow";; - *) echo "unknown";; - esac - } - get_color apple - get_color banana - get_color grape - `); - expect(result.stdout).toBe("red\nyellow\nunknown\n"); - }); - - it("should use test expression in function", async () => { - const env = new Bash(); - const result = await env.exec(` - is_positive() { - if [[ $1 -gt 0 ]]; then - echo "yes" - else - echo "no" - fi - } - is_positive 5 - is_positive -3 - is_positive 0 - `); - expect(result.stdout).toBe("yes\nno\nno\n"); - }); - - it("should use here document in function", async () => { - const env = new Bash(); - const result = await env.exec(` - generate_config() { - cat < { - const env = new Bash(); - const result = await env.exec(` - double() { - echo $(($1 * 2)) - } - result=$(double 5) - echo "Result: $result" - `); - expect(result.stdout).toBe("Result: 10\n"); - }); - }); - - describe("Complex multi-feature compositions", () => { - it("should combine if, case, and command substitution", async () => { - const env = new Bash(); - const result = await env.exec(` - export TYPE=$(echo "fruit") - if [[ $TYPE == "fruit" ]]; then - case $(echo apple) in - apple) echo "it's an apple";; - *) echo "unknown fruit";; - esac - fi - `); - expect(result.stdout).toBe("it's an apple\n"); - }); - - it("should use here doc with command substitution and pipes", async () => { - const env = new Bash(); - const result = await env.exec(` - export PREFIX=">>>" - cat <>> world\n"); - }); - - it("should nest loops with conditionals and arithmetic", async () => { - const env = new Bash(); - const result = await env.exec(` - for i in 1 2 3; do - for j in 1 2; do - if [[ $((i * j)) -gt 2 ]]; then - echo "$i*$j=$((i * j))" - fi - done - done - `); - expect(result.stdout).toBe("2*2=4\n3*1=3\n3*2=6\n"); - }); - - it("should use function with loop, case, and arithmetic", async () => { - const env = new Bash(); - const result = await env.exec(` - process_numbers() { - local sum=0 - for n in $@; do - case $n in - [0-9]) sum=$((sum + n));; - *) echo "skipping $n";; - esac - done - echo "sum=$sum" - } - process_numbers 1 2 x 3 y 4 - `); - expect(result.stdout).toBe("skipping x\nskipping y\nsum=10\n"); - }); - - it("should pipe function output through multiple commands", async () => { - const env = new Bash(); - const result = await env.exec(` - generate_data() { - for i in 3 1 4 1 5 9 2 6; do - echo $i - done - } - generate_data | sort -n | uniq | head -3 - `); - expect(result.stdout).toBe("1\n2\n3\n"); - }); - - it("should combine test expression with file operations and here doc", async () => { - const env = new Bash(); - const result = await env.exec(` - cat < /tmp/data.txt -line1 -line2 -line3 -EOF - if [[ -f /tmp/data.txt ]]; then - count=$(wc -l < /tmp/data.txt) - echo "File has $count lines" - fi - `); - expect(result.stdout.trim()).toContain("File has"); - expect(result.stdout.trim()).toContain("3"); - }); - - it("should use nested command substitution", async () => { - const env = new Bash(); - const result = await env.exec(` - echo "Result: $(echo "inner: $(echo deep)")" - `); - expect(result.stdout).toBe("Result: inner: deep\n"); - }); - - it("should combine arithmetic with comparison in loop", async () => { - const env = new Bash(); - const result = await env.exec(` - export N=1 - while [[ $((N * N)) -le 10 ]]; do - echo "$N squared is $((N * N))" - export N=$((N + 1)) - done - `); - expect(result.stdout).toBe( - "1 squared is 1\n2 squared is 4\n3 squared is 9\n", - ); - }); - }); - - describe("Error handling in composed features", () => { - it("should handle failed command in command substitution", async () => { - const env = new Bash(); - const result = await env.exec(` - result=$(cat /nonexistent/file 2>/dev/null) - if [[ -z "$result" ]]; then - echo "no result" - fi - `); - expect(result.stdout).toBe("no result\n"); - }); - - it("should handle empty here document in pipe", async () => { - const env = new Bash(); - const result = await env.exec(`cat < { - const env = new Bash(); - const result = await env.exec(` - case "nomatch" in - a) echo "a";; - b) echo "b";; - esac - echo "done" - `); - expect(result.stdout).toBe("done\n"); - }); - }); -}); diff --git a/src/syntax/control-flow.test.ts b/src/syntax/control-flow.test.ts deleted file mode 100644 index 07db7013..00000000 --- a/src/syntax/control-flow.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -describe("Bash Syntax - Control Flow", () => { - describe("if statements", () => { - it("should execute then branch when condition is true", async () => { - const env = new Bash(); - const result = await env.exec("if true; then echo yes; fi"); - expect(result.stdout).toBe("yes\n"); - expect(result.exitCode).toBe(0); - }); - - it("should not execute then branch when condition is false", async () => { - const env = new Bash(); - const result = await env.exec("if false; then echo yes; fi"); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should execute else branch when condition is false", async () => { - const env = new Bash(); - const result = await env.exec( - "if false; then echo yes; else echo no; fi", - ); - expect(result.stdout).toBe("no\n"); - expect(result.exitCode).toBe(0); - }); - - it("should use command exit code as condition", async () => { - const env = new Bash({ - files: { "/test.txt": "hello world" }, - }); - const result = await env.exec( - "if grep hello /test.txt > /dev/null; then echo found; fi", - ); - expect(result.stdout).toBe("found\n"); - }); - - it("should handle elif branches", async () => { - const env = new Bash(); - const result = await env.exec( - "if false; then echo one; elif true; then echo two; else echo three; fi", - ); - expect(result.stdout).toBe("two\n"); - }); - - it("should handle multiple elif branches", async () => { - const env = new Bash(); - const result = await env.exec( - "if false; then echo 1; elif false; then echo 2; elif true; then echo 3; else echo 4; fi", - ); - expect(result.stdout).toBe("3\n"); - }); - - it("should handle commands with pipes in condition", async () => { - const env = new Bash({ - files: { "/test.txt": "hello\nworld\n" }, - }); - const result = await env.exec( - "if cat /test.txt | grep world > /dev/null; then echo found; fi", - ); - expect(result.stdout).toBe("found\n"); - }); - - it("should handle multiple commands in body", async () => { - const env = new Bash(); - const result = await env.exec( - "if true; then echo one; echo two; echo three; fi", - ); - expect(result.stdout).toBe("one\ntwo\nthree\n"); - }); - - it("should return exit code of last command in body", async () => { - const env = new Bash(); - const result = await env.exec("if true; then echo hello; false; fi"); - expect(result.stdout).toBe("hello\n"); - expect(result.exitCode).toBe(1); - }); - - it("should error on unclosed if", async () => { - const env = new Bash(); - const result = await env.exec("if true; then echo hello"); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("syntax error"); - }); - - it("should handle nested if statements", async () => { - const env = new Bash(); - const result = await env.exec( - "if true; then if true; then echo nested; fi; fi", - ); - expect(result.stdout).toBe("nested\n"); - }); - - it("should handle triple nested if statements", async () => { - const env = new Bash(); - const result = await env.exec( - "if true; then if true; then if true; then echo deep; fi; fi; fi", - ); - expect(result.stdout).toBe("deep\n"); - }); - - it("should handle if inside function body", async () => { - const env = new Bash(); - // Define and call function in same exec (each exec is a new shell) - const result = await env.exec( - "check() { if true; then echo inside; fi; }; check", - ); - expect(result.stdout).toBe("inside\n"); - }); - - it("should handle if with nested else", async () => { - const env = new Bash(); - const result = await env.exec( - "if false; then echo one; else if true; then echo two; fi; fi", - ); - expect(result.stdout).toBe("two\n"); - }); - - it("should handle if after semicolon", async () => { - const env = new Bash(); - const result = await env.exec( - "echo before; if true; then echo during; fi; echo after", - ); - expect(result.stdout).toBe("before\nduring\nafter\n"); - }); - }); - - describe("functions", () => { - // Note: Each exec is a new shell, so functions must be defined and called within the same exec - - it("should define and call a function using function keyword", async () => { - const env = new Bash(); - const result = await env.exec("function greet { echo hello; }; greet"); - expect(result.stdout).toBe("hello\n"); - }); - - it("should define and call a function using () syntax", async () => { - const env = new Bash(); - const result = await env.exec("greet() { echo hello; }; greet"); - expect(result.stdout).toBe("hello\n"); - }); - - it("should pass arguments to function as $1, $2, etc.", async () => { - const env = new Bash(); - const result = await env.exec("greet() { echo Hello $1; }; greet World"); - expect(result.stdout).toBe("Hello World\n"); - }); - - it("should support $# for argument count", async () => { - const env = new Bash(); - const result = await env.exec("count() { echo $#; }; count a b c"); - expect(result.stdout).toBe("3\n"); - }); - - it("should support $@ for all arguments", async () => { - const env = new Bash(); - const result = await env.exec("show() { echo $@; }; show one two three"); - expect(result.stdout).toBe("one two three\n"); - }); - - it("should handle functions with multiple commands", async () => { - const env = new Bash(); - const result = await env.exec( - "multi() { echo first; echo second; echo third; }; multi", - ); - expect(result.stdout).toBe("first\nsecond\nthird\n"); - }); - - it("should allow function to call other functions", async () => { - const env = new Bash(); - const result = await env.exec( - "inner() { echo inside; }; outer() { echo before; inner; echo after; }; outer", - ); - expect(result.stdout).toBe("before\ninside\nafter\n"); - }); - - it("should return exit code from last command", async () => { - const env = new Bash(); - const result = await env.exec("fail() { echo hi; false; }; fail"); - expect(result.stdout).toBe("hi\n"); - expect(result.exitCode).toBe(1); - }); - - it("should override built-in commands", async () => { - const env = new Bash(); - // Define echo function then call it - const result = await env.exec("echo() { true; }; echo hello"); - expect(result.stdout).toBe(""); - }); - - it("should work with files", async () => { - const env = new Bash({ - files: { "/data.txt": "line1\nline2\nline3\n" }, - }); - const result = await env.exec( - "countlines() { cat $1 | wc -l; }; countlines /data.txt", - ); - expect(result.stdout.trim()).toBe("3"); - }); - - it("function definitions do not persist across exec calls", async () => { - const env = new Bash(); - await env.exec("greet() { echo hello; }"); - // Each exec is a new shell - function is not defined - const result = await env.exec("greet"); - expect(result.exitCode).toBe(127); // command not found - }); - }); - - describe("local keyword", () => { - // Note: Each exec is a new shell, so functions must be defined and called within the same exec - - it("should declare local variable with value", async () => { - const env = new Bash(); - const result = await env.exec( - "test_func() { local x=hello; echo $x; }; test_func", - ); - expect(result.stdout).toBe("hello\n"); - }); - - it("should not affect outer scope", async () => { - const env = new Bash({ env: { x: "outer" } }); - const result = await env.exec( - "test_func() { local x=inner; echo $x; }; test_func; echo $x", - ); - expect(result.stdout).toBe("inner\nouter\n"); - }); - - it("should shadow outer variable", async () => { - const env = new Bash({ env: { x: "outer" } }); - const result = await env.exec( - "test_func() { local x=inner; echo $x; }; test_func", - ); - expect(result.stdout).toBe("inner\n"); - }); - - it("should restore undefined variable after function", async () => { - const env = new Bash(); - const result = await env.exec( - 'test_func() { local newvar=value; echo $newvar; }; test_func; echo "[$newvar]"', - ); - expect(result.stdout).toBe("value\n[]\n"); - }); - - it("should error when used outside function", async () => { - const env = new Bash(); - const result = await env.exec("local x=value"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("can only be used in a function"); - }); - - it("should handle multiple local declarations", async () => { - const env = new Bash(); - const result = await env.exec( - "test_func() { local a=1 b=2 c=3; echo $a $b $c; }; test_func", - ); - expect(result.stdout).toBe("1 2 3\n"); - }); - - it("should declare local without value", async () => { - const env = new Bash(); - const result = await env.exec( - "test_func() { local x; x=assigned; echo $x; }; test_func", - ); - expect(result.stdout).toBe("assigned\n"); - }); - - it("should work with nested function calls", async () => { - const env = new Bash(); - const result = await env.exec( - "inner() { local x=inner; echo $x; }; outer() { local x=outer; inner; echo $x; }; outer", - ); - expect(result.stdout).toBe("inner\nouter\n"); - }); - - it("should keep local changes within same scope", async () => { - const env = new Bash(); - const result = await env.exec( - "test_func() { local x=first; x=second; echo $x; }; test_func", - ); - expect(result.stdout).toBe("second\n"); - }); - }); - - describe("! negation operator", () => { - it("should negate exit code of true to 1", async () => { - const env = new Bash(); - const result = await env.exec("! true"); - expect(result.exitCode).toBe(1); - }); - - it("should negate exit code of false to 0", async () => { - const env = new Bash(); - const result = await env.exec("! false"); - expect(result.exitCode).toBe(0); - }); - - it("should work with && chaining", async () => { - const env = new Bash(); - const result = await env.exec("! false && echo success"); - expect(result.stdout).toBe("success\n"); - expect(result.exitCode).toBe(0); - }); - - it("should work with || chaining", async () => { - const env = new Bash(); - const result = await env.exec("! true || echo fallback"); - expect(result.stdout).toBe("fallback\n"); - expect(result.exitCode).toBe(0); - }); - - it("should negate grep failure to success", async () => { - const env = new Bash({ - files: { "/test.txt": "hello world" }, - }); - const result = await env.exec("! grep missing /test.txt"); - expect(result.exitCode).toBe(0); - }); - - it("should negate grep success to failure", async () => { - const env = new Bash({ - files: { "/test.txt": "hello world" }, - }); - const result = await env.exec("! grep hello /test.txt > /dev/null"); - expect(result.exitCode).toBe(1); - }); - - it("should work in if condition", async () => { - const env = new Bash(); - const result = await env.exec("if ! false; then echo yes; fi"); - expect(result.stdout).toBe("yes\n"); - }); - - it("should work with find -not equivalent", async () => { - const env = new Bash({ - files: { - "/project/src/app.ts": "code", - "/project/src/utils.ts": "utils", - "/project/test.json": "{}", - }, - }); - // Use -not with find (since shell ! passes to find) - const result = await env.exec( - 'find /project -name "*.ts" -not -name "utils*"', - ); - expect(result.stdout).toContain("app.ts"); - expect(result.stdout).not.toContain("utils.ts"); - }); - }); -}); diff --git a/src/syntax/execution-protection.test.ts b/src/syntax/execution-protection.test.ts deleted file mode 100644 index 230ab5c6..00000000 --- a/src/syntax/execution-protection.test.ts +++ /dev/null @@ -1,625 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; -import { ExecutionLimitError } from "../interpreter/errors.js"; - -/** - * Execution Protection Tests - * - * These tests verify that the interpreter properly limits: - * - Function call depth (maxCallDepth) - * - Command execution count (maxCommandCount) - * - Loop iterations (maxLoopIterations) - * - Brace expansion size - * - Range expansion size - * - Parser input/token limits - * - * IMPORTANT: All tests should complete quickly (<1s each). - * If any test times out, it indicates a protection gap that must be fixed. - * Tests are expected to fail with execution limit errors, not timeout or stack overflow. - */ - -// Helper to assert protection was triggered (not timeout/stack overflow) -function expectProtectionTriggered(result: { - exitCode: number; - stderr: string; -}) { - expect(result.exitCode).toBe(ExecutionLimitError.EXIT_CODE); - // Should have some error message - expect(result.stderr.length).toBeGreaterThan(0); - // Should NOT be a JS stack overflow (our limits should kick in first) - expect(result.stderr).not.toContain("Maximum call stack size exceeded"); -} - -describe("Execution Protection", () => { - describe("recursion depth protection", () => { - it("should error on simple infinite recursion", async () => { - const env = new Bash({ maxCallDepth: 50 }); - const result = await env.exec("recurse() { recurse; }; recurse"); - - expect(result.exitCode).toBe(ExecutionLimitError.EXIT_CODE); - expect(result.stderr).toContain("maximum recursion depth"); - expect(result.stderr).toContain("exceeded"); - }); - - it("should allow reasonable recursion depth", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo 5 > /count.txt; countdown() { local n=$(cat /count.txt); if [ "$n" -gt 0 ]; then echo $n; echo $((n-1)) > /count.txt; countdown; fi; }; countdown', - ); - expect(result.exitCode).toBe(0); - }); - - it("should include function name in recursion error", async () => { - const env = new Bash({ maxCallDepth: 50 }); - const result = await env.exec("myinfinite() { myinfinite; }; myinfinite"); - - expect(result.stderr).toContain("myinfinite"); - expect(result.stderr).toContain("maximum recursion depth"); - }); - - it("should protect against mutual recursion (A calls B, B calls A)", async () => { - const env = new Bash({ maxCallDepth: 50 }); - const result = await env.exec(` - ping() { pong; } - pong() { ping; } - ping - `); - - expectProtectionTriggered(result); - expect(result.stderr).toContain("maximum recursion depth"); - }); - - it("should protect against three-way mutual recursion", async () => { - const env = new Bash({ maxCallDepth: 50 }); - const result = await env.exec(` - a() { b; } - b() { c; } - c() { a; } - a - `); - - expectProtectionTriggered(result); - }); - - it("should protect against recursion through eval", async () => { - const env = new Bash({ maxCallDepth: 50 }); - const result = await env.exec(` - boom() { eval 'boom'; } - boom - `); - - expectProtectionTriggered(result); - }); - - it("should protect against recursion through command substitution", async () => { - const env = new Bash({ maxCallDepth: 50 }); - const result = await env.exec(` - boom() { echo $(boom); } - boom - `); - - expectProtectionTriggered(result); - }); - - it("should protect against recursion with local variables", async () => { - const env = new Bash({ maxCallDepth: 50 }); - const result = await env.exec(` - deep() { - local depth=$1 - echo "depth: $depth" - deep $((depth + 1)) - } - deep 0 - `); - - expectProtectionTriggered(result); - }); - - it("should protect against recursion through arithmetic expansion", async () => { - const env = new Bash({ maxCallDepth: 50 }); - const result = await env.exec(` - counter=0 - boom() { echo $((counter++)); boom; } - boom - `); - - expectProtectionTriggered(result); - }); - }); - - describe("command count protection", () => { - it("should error on too many sequential commands", async () => { - const env = new Bash({ maxLoopIterations: 100 }); - const result = await env.exec("while true; do echo x; done"); - - expect(result.exitCode).toBe(ExecutionLimitError.EXIT_CODE); - expect(result.stderr).toContain("too many iterations"); - }); - - it("should reset command count between exec calls", async () => { - const env = new Bash(); - await env.exec("echo 1; echo 2; echo 3"); - const result = await env.exec("echo done"); - expect(result.stdout).toBe("done\n"); - expect(result.exitCode).toBe(0); - }); - - it("should protect against many commands via semicolons", async () => { - const env = new Bash({ maxCommandCount: 100 }); - // Generate 200 echo commands - const commands = Array(200).fill("echo x").join("; "); - const result = await env.exec(commands); - - expectProtectionTriggered(result); - expect(result.stderr).toContain("too many commands"); - }); - - it("should protect against fork bomb pattern", async () => { - const env = new Bash({ maxCallDepth: 20, maxCommandCount: 1000 }); - // Classic fork bomb pattern (limited by our protections) - const result = await env.exec(` - bomb() { bomb | bomb & } - bomb - `); - - // Should be stopped by recursion or command limit, not hang - expectProtectionTriggered(result); - }); - }); - - describe("loop protection", () => { - it("should error on infinite for loop", async () => { - const env = new Bash({ maxLoopIterations: 100 }); - const longList = Array(200).fill("x").join(" "); - const result = await env.exec(`for i in ${longList}; do echo $i; done`); - - expect(result.exitCode).toBe(ExecutionLimitError.EXIT_CODE); - expect(result.stderr).toContain("too many iterations"); - }); - - it("should error on infinite while loop", async () => { - const env = new Bash({ maxLoopIterations: 100 }); - const result = await env.exec("while true; do echo loop; done"); - - expect(result.exitCode).toBe(ExecutionLimitError.EXIT_CODE); - expect(result.stderr).toContain("too many iterations"); - }); - - it("should error on infinite until loop", async () => { - const env = new Bash({ maxLoopIterations: 100 }); - const result = await env.exec("until false; do echo loop; done"); - - expect(result.exitCode).toBe(ExecutionLimitError.EXIT_CODE); - expect(result.stderr).toContain("too many iterations"); - }); - - it("should protect against nested infinite loops", async () => { - const env = new Bash({ maxLoopIterations: 100 }); - const result = await env.exec(` - while true; do - while true; do - echo inner - done - done - `); - - expectProtectionTriggered(result); - }); - - it("should protect against C-style infinite loop", async () => { - const env = new Bash({ maxLoopIterations: 100 }); - const result = await env.exec("for ((;;)); do echo x; done"); - - expectProtectionTriggered(result); - }); - - it("should protect against infinite loop with break that never triggers", async () => { - const env = new Bash({ maxLoopIterations: 100 }); - const result = await env.exec(` - while true; do - if false; then break; fi - echo loop - done - `); - - expectProtectionTriggered(result); - }); - - it("should protect against loop with continue abuse", async () => { - const env = new Bash({ maxLoopIterations: 100 }); - const result = await env.exec(` - i=0 - while true; do - i=$((i+1)) - continue - done - `); - - expectProtectionTriggered(result); - }); - }); - - describe("combined protection", () => { - it("should protect against recursive function with loops", async () => { - const env = new Bash({ maxCallDepth: 20, maxLoopIterations: 100 }); - const result = await env.exec( - "dangerous() { for i in 1 2 3; do dangerous; done; }; dangerous", - ); - - expectProtectionTriggered(result); - }); - - it("should protect against loop calling recursive function", async () => { - const env = new Bash({ maxCallDepth: 20, maxLoopIterations: 100 }); - const result = await env.exec(` - recurse() { recurse; } - for i in 1 2 3 4 5; do - recurse - done - `); - - expectProtectionTriggered(result); - }); - - it("should protect against eval in loop", async () => { - const env = new Bash({ maxLoopIterations: 100 }); - const result = await env.exec(` - while true; do - eval 'echo x' - done - `); - - expectProtectionTriggered(result); - }); - - it("should protect against recursive eval", async () => { - const env = new Bash({ maxCallDepth: 20 }); - const result = await env.exec(` - cmd='eval "$cmd"' - eval "$cmd" - `); - - expectProtectionTriggered(result); - }); - }); - - describe("brace expansion protection", () => { - it("should protect against massive brace expansion", async () => { - const env = new Bash(); - // {1..10000} would generate 10000 items - const result = await env.exec("echo {1..100000}"); - - // Should either truncate or error, not hang - expect(result.exitCode).toBe(0); // Brace expansion truncates, doesn't error - }); - - it("should protect against nested brace expansion explosion", async () => { - const env = new Bash(); - // {a,b}{c,d}{e,f}{g,h}{i,j}{k,l}{m,n}{o,p} = 2^8 = 256 items - // More nesting would cause exponential growth - const result = await env.exec( - "echo {a,b}{c,d}{e,f}{g,h}{i,j}{k,l}{m,n}{o,p}{q,r}{s,t}{u,v}{w,x}", - ); - - // Should complete (4096 items) or be limited - expect(result.exitCode).toBe(0); - }); - - it("should protect against deeply nested brace expansion", async () => { - const env = new Bash(); - // Many levels of nesting - const result = await env.exec( - "echo {a,b,c,d,e}{1,2,3,4,5}{a,b,c,d,e}{1,2,3,4,5}{a,b,c,d,e}", - ); - - // 5^5 = 3125 items, should be limited or complete quickly - expect(result.exitCode).toBe(0); - }); - - it("should protect against range with huge step count", async () => { - const env = new Bash(); - const result = await env.exec("echo {1..1000000..1}"); - - // Should be limited, not hang - expect(result.exitCode).toBe(0); - }); - - it("should protect against character range explosion", async () => { - const env = new Bash(); - const result = await env.exec("echo {a..z}{a..z}{a..z}{a..z}"); - - // 26^4 = 456,976 items - should be limited - expect(result.exitCode).toBe(0); - }); - }); - - describe("expansion protection", () => { - it("should protect against deeply nested command substitution", async () => { - const env = new Bash({ maxCallDepth: 50 }); - // Each level adds to call depth - const result = await env.exec( - "echo $(echo $(echo $(echo $(echo $(echo $(echo hi))))))", - ); - - // Should succeed - not too deep - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("hi"); - }); - - it("should protect against recursive command substitution via function", async () => { - const env = new Bash({ maxCallDepth: 50 }); - const result = await env.exec(` - f() { echo "$(f)"; } - f - `); - - expectProtectionTriggered(result); - }); - - it("should protect against arithmetic expansion overflow attempts", async () => { - const env = new Bash(); - // Very large numbers - const result = await env.exec( - "echo $((999999999999999999 * 999999999999999999))", - ); - - // Should handle gracefully (JavaScript handles big numbers) - expect(result.exitCode).toBe(0); - }); - - // Skip: This test hits a separate bug where function calls inside $(...) - // inside $((...)) don't work correctly. The recursion protection itself - // is working - verified by "recursive command substitution via function" test. - it.skip("should protect against recursive arithmetic in parameter expansion", async () => { - const env = new Bash({ maxCallDepth: 50 }); - const result = await env.exec(` - f() { echo $(($(f))); } - f - `); - - expectProtectionTriggered(result); - }); - }); - - describe("input size protection", () => { - it("should reject extremely long input", async () => { - const env = new Bash(); - // Create a very long command (over 1MB) - const longVar = "x".repeat(1100000); - const result = await env.exec(`echo "${longVar}"`); - - // Should be rejected by parser - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("too large"); - }); - - it("should handle many tokens gracefully", async () => { - const env = new Bash(); - // Many separate arguments - const manyArgs = Array(1000).fill("arg").join(" "); - const result = await env.exec(`echo ${manyArgs}`); - - // Should work - 1000 tokens is fine - expect(result.exitCode).toBe(0); - }); - }); - - describe("subshell protection", () => { - it("should protect against infinite subshell recursion", async () => { - const env = new Bash({ maxCallDepth: 50, maxCommandCount: 1000 }); - const result = await env.exec(` - f() { (f); } - f - `); - - expectProtectionTriggered(result); - }); - - it("should protect against nested subshells in loop", async () => { - const env = new Bash({ maxLoopIterations: 100 }); - const result = await env.exec(` - while true; do - (echo nested) - done - `); - - expectProtectionTriggered(result); - }); - }); - - describe("pipeline protection", () => { - it("should protect against infinite pipeline through function", async () => { - const env = new Bash({ maxCallDepth: 50 }); - const result = await env.exec(` - infinite_pipe() { echo x | infinite_pipe; } - infinite_pipe - `); - - expectProtectionTriggered(result); - }); - - it("should handle long pipelines gracefully", async () => { - const env = new Bash(); - // Long but finite pipeline - const pipeline = Array(50).fill("cat").join(" | "); - const result = await env.exec(`echo test | ${pipeline}`); - - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("test"); - }); - }); - - describe("special variable expansion protection", () => { - it("should handle recursive PROMPT_COMMAND safely", async () => { - const env = new Bash({ maxCallDepth: 50 }); - // PROMPT_COMMAND isn't executed in non-interactive mode - // but we should handle it safely if set - const result = await env.exec(` - PROMPT_COMMAND='echo prompt' - echo done - `); - - expect(result.exitCode).toBe(0); - }); - - it("should protect against self-referential variable", async () => { - const env = new Bash(); - // This shouldn't cause infinite loop - bash evaluates once - const result = await env.exec(` - x='$x' - echo "$x" - `); - - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("$x"); - }); - }); - - describe("configurable limits", () => { - it("should allow custom recursion depth", async () => { - const env = new Bash({ maxCallDepth: 5 }); - const result = await env.exec("recurse() { recurse; }; recurse"); - - expect(result.exitCode).toBe(ExecutionLimitError.EXIT_CODE); - expect(result.stderr).toContain("(5)"); - expect(result.stderr).toContain("maxCallDepth"); - }); - - it("should allow custom loop iterations", async () => { - const env = new Bash({ maxLoopIterations: 50 }); - const result = await env.exec("while true; do echo x; done"); - - expect(result.exitCode).toBe(ExecutionLimitError.EXIT_CODE); - expect(result.stderr).toContain("(50)"); - expect(result.stderr).toContain("maxLoopIterations"); - }); - - it("should allow custom command count", async () => { - const env = new Bash({ maxCommandCount: 50 }); - const commands = Array(100).fill("echo x").join("; "); - const result = await env.exec(commands); - - expectProtectionTriggered(result); - }); - - it("should allow higher limits when needed", async () => { - const env = new Bash({ maxLoopIterations: 200 }); - let cmd = "for i in"; - for (let i = 0; i < 150; i++) cmd += " x"; - cmd += "; do echo $i; done"; - const result = await env.exec(cmd); - - expect(result.exitCode).toBe(0); - }); - - it("should enforce very strict limits", async () => { - const env = new Bash({ - maxCallDepth: 3, - maxLoopIterations: 5, - maxCommandCount: 10, - }); - - // Even simple recursion should fail - const result = await env.exec("f() { f; }; f"); - expectProtectionTriggered(result); - }); - }); - - describe("edge cases", () => { - it("should handle empty loop body", async () => { - const env = new Bash({ maxLoopIterations: 100 }); - const result = await env.exec("while true; do :; done"); - - expectProtectionTriggered(result); - }); - - it("should handle loop with only comments", async () => { - const env = new Bash({ maxLoopIterations: 100 }); - const result = await env.exec(` - while true; do - # just a comment - : - done - `); - - expectProtectionTriggered(result); - }); - - it("should protect against infinite case recursion", async () => { - const env = new Bash({ maxCallDepth: 50 }); - const result = await env.exec(` - f() { - case x in - *) f ;; - esac - } - f - `); - - expectProtectionTriggered(result); - }); - - it("should protect against select loop (simulated)", async () => { - const env = new Bash({ maxLoopIterations: 100 }); - // select is typically interactive, simulate with while - const result = await env.exec(` - PS3='Choose: ' - i=0 - while true; do - i=$((i+1)) - echo "iteration $i" - done - `); - - expectProtectionTriggered(result); - }); - - it("should handle trap in infinite loop", async () => { - const env = new Bash({ maxLoopIterations: 100 }); - const result = await env.exec(` - trap 'echo trapped' EXIT - while true; do echo x; done - `); - - expectProtectionTriggered(result); - }); - }); - - describe("performance - all tests should be fast", () => { - it("should quickly reject obvious infinite recursion", async () => { - const env = new Bash({ maxCallDepth: 10 }); - const start = Date.now(); - await env.exec("f() { f; }; f"); - const elapsed = Date.now() - start; - - expect(elapsed).toBeLessThan(1000); // Should complete in under 1 second - }); - - it("should quickly reject infinite loop", async () => { - const env = new Bash({ maxLoopIterations: 100 }); - const start = Date.now(); - await env.exec("while true; do :; done"); - const elapsed = Date.now() - start; - - expect(elapsed).toBeLessThan(1000); - }); - - it("should quickly handle brace expansion limits", async () => { - const env = new Bash(); - const start = Date.now(); - await env.exec("echo {1..100000}"); - const elapsed = Date.now() - start; - - expect(elapsed).toBeLessThan(1000); - }); - - it("should quickly reject deep mutual recursion", async () => { - const env = new Bash({ maxCallDepth: 20 }); - const start = Date.now(); - await env.exec("a() { b; }; b() { c; }; c() { a; }; a"); - const elapsed = Date.now() - start; - - expect(elapsed).toBeLessThan(1000); - }); - }); -}); diff --git a/src/syntax/here-document.test.ts b/src/syntax/here-document.test.ts deleted file mode 100644 index eddda2e9..00000000 --- a/src/syntax/here-document.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -describe("Here Documents < { - it("should pass here document content as stdin to cat", async () => { - const env = new Bash(); - const result = await env.exec(`cat < { - const env = new Bash(); - const result = await env.exec(`cat < { - const env = new Bash({ env: { NAME: "Alice" } }); - const result = await env.exec(`cat < { - const env = new Bash({ env: { NAME: "Alice" } }); - const result = await env.exec(`cat <<'EOF' -Hello, $NAME! -EOF`); - expect(result.stdout).toBe("Hello, $NAME!\n"); - expect(result.exitCode).toBe(0); - }); - - it("should work with double-quoted delimiter", async () => { - const env = new Bash({ env: { NAME: "Alice" } }); - const result = await env.exec(`cat <<"EOF" -Hello, $NAME! -EOF`); - expect(result.stdout).toBe("Hello, $NAME!\n"); - expect(result.exitCode).toBe(0); - }); - - it("should work with wc command", async () => { - const env = new Bash(); - const result = await env.exec(`wc -l < { - const env = new Bash(); - const result = await env.exec(`grep world < { - const env = new Bash(); - const result = await env.exec(`cat < { - const env = new Bash(); - const result = await env.exec(`cat < { - const env = new Bash(); - const result = await env.exec(`cat < { - const env = new Bash(); - const result = await env.exec(`cat < { - const env = new Bash(); - const result = await env.exec(`cat < { - const env = new Bash(); - const result = await env.exec(`cat < { - it("should preserve leading spaces in here document content", async () => { - const env = new Bash(); - const result = await env.exec(`cat < { - const env = new Bash(); - const result = await env.exec(`cat < { - const env = new Bash(); - const result = await env.exec(`cat < { - const env = new Bash(); - // This tests that the script normalization doesn't strip heredoc content - const result = await env.exec(` - cat < { - const env = new Bash(); - const result = await env.exec(`cat <<'EOF' - * - * * - * * - * * - * * - * * - * * - * * - * * - * * - * * - ********************* -EOF`); - expect(result.stdout).toBe(` * - * * - * * - * * - * * - * * - * * - * * - * * - * * - * * - ********************* -`); - expect(result.exitCode).toBe(0); - }); - - it("should not treat indented delimiter as end of heredoc", async () => { - const env = new Bash(); - // A line with " EOF" (spaces before EOF) should be content, not delimiter - const result = await env.exec(`cat < { - const env = new Bash(); - const result = await env.exec(`cat < { - describe("for loops", () => { - it("should iterate over list items", async () => { - const env = new Bash(); - const result = await env.exec("for i in a b c; do echo $i; done"); - expect(result.stdout).toBe("a\nb\nc\n"); - expect(result.exitCode).toBe(0); - }); - - it("should iterate over numbers", async () => { - const env = new Bash(); - const result = await env.exec("for n in 1 2 3 4 5; do echo $n; done"); - expect(result.stdout).toBe("1\n2\n3\n4\n5\n"); - }); - - it("should handle single item", async () => { - const env = new Bash(); - const result = await env.exec("for x in hello; do echo $x; done"); - expect(result.stdout).toBe("hello\n"); - }); - - it("should handle empty list", async () => { - const env = new Bash(); - const result = await env.exec("for x in; do echo $x; done"); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should execute multiple commands in body", async () => { - const env = new Bash(); - const result = await env.exec( - "for i in 1 2; do echo start $i; echo end $i; done", - ); - expect(result.stdout).toBe("start 1\nend 1\nstart 2\nend 2\n"); - }); - - it("should work with file operations", async () => { - const env = new Bash({ - files: { - "/file1.txt": "content1", - "/file2.txt": "content2", - }, - }); - const result = await env.exec( - "for f in /file1.txt /file2.txt; do cat $f; done", - ); - expect(result.stdout).toBe("content1content2"); - }); - - it("should preserve exit code from last iteration", async () => { - const env = new Bash(); - const result = await env.exec("for i in 1 2; do false; done"); - expect(result.exitCode).toBe(1); - }); - - it("should clean up loop variable after loop", async () => { - const env = new Bash(); - await env.exec("for x in a b; do echo $x; done"); - const result = await env.exec('echo "[$x]"'); - expect(result.stdout).toBe("[]\n"); - }); - }); - - describe("while loops", () => { - it("should execute while condition is true", async () => { - const env = new Bash(); - // Use a counter file to track iterations - await env.exec("echo 0 > /count.txt"); - const result = await env.exec( - "while grep -q 0 /count.txt; do echo iteration; echo 1 > /count.txt; done", - ); - expect(result.stdout).toBe("iteration\n"); - }); - - it("should not execute when condition is initially false", async () => { - const env = new Bash(); - const result = await env.exec("while false; do echo never; done"); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should handle multiple iterations", async () => { - const env = new Bash(); - await env.exec("export count=3"); - // We can't easily decrement in this shell, so use a different approach - await env.exec('echo "aaa" > /counter.txt'); - const result = await env.exec( - 'while grep -q aaa /counter.txt; do echo loop; echo "bbb" > /counter.txt; done', - ); - expect(result.stdout).toBe("loop\n"); - }); - - it("should return exit code from last command in body", async () => { - const env = new Bash(); - await env.exec("echo start > /f.txt"); - const result = await env.exec( - "while grep -q start /f.txt; do echo done > /f.txt; true; done", - ); - expect(result.exitCode).toBe(0); - }); - }); - - describe("until loops", () => { - it("should execute until condition becomes true", async () => { - const env = new Bash(); - await env.exec("echo 0 > /flag.txt"); - const result = await env.exec( - "until grep -q 1 /flag.txt; do echo waiting; echo 1 > /flag.txt; done", - ); - expect(result.stdout).toBe("waiting\n"); - }); - - it("should not execute when condition is initially true", async () => { - const env = new Bash(); - const result = await env.exec("until true; do echo never; done"); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should execute when condition is initially false", async () => { - const env = new Bash(); - await env.exec("echo no > /check.txt"); - const result = await env.exec( - "until grep -q yes /check.txt; do echo step; echo yes > /check.txt; done", - ); - expect(result.stdout).toBe("step\n"); - }); - }); - - describe("loop protection", () => { - it("should detect infinite for loop and error", async () => { - const env = new Bash(); - // Create a list that's too long - const longList = Array(10001).fill("x").join(" "); - const result = await env.exec(`for i in ${longList}; do echo $i; done`); - // May hit either iteration limit or command count limit depending on loop body - expect(result.stderr).toMatch(/too many (iterations|commands)/); - expect(result.exitCode).toBe(ExecutionLimitError.EXIT_CODE); - }); - - it("should detect infinite while loop", async () => { - const env = new Bash(); - const result = await env.exec("while true; do echo loop; done"); - // May hit either iteration limit or command count limit depending on loop body - expect(result.stderr).toMatch(/too many (iterations|commands)/); - expect(result.exitCode).toBe(ExecutionLimitError.EXIT_CODE); - }); - - it("should detect infinite until loop", async () => { - const env = new Bash(); - const result = await env.exec("until false; do echo loop; done"); - // May hit either iteration limit or command count limit depending on loop body - expect(result.stderr).toMatch(/too many (iterations|commands)/); - expect(result.exitCode).toBe(ExecutionLimitError.EXIT_CODE); - }); - }); - - describe("nested loops", () => { - it("should handle nested for loops", async () => { - const env = new Bash(); - const result = await env.exec( - "for i in a b; do for j in 1 2; do echo $i$j; done; done", - ); - expect(result.stdout).toBe("a1\na2\nb1\nb2\n"); - }); - - it("should handle for inside while", async () => { - const env = new Bash(); - await env.exec("echo go > /run.txt"); - // Note: Nested loops with their own do/done require careful parsing - // For now, test a simpler case - const result = await env.exec( - "while grep -q go /run.txt; do echo inner; echo stop > /run.txt; done", - ); - expect(result.stdout).toBe("inner\n"); - }); - }); - - describe("loop syntax variations", () => { - it("should handle for loop without semicolon before do", async () => { - const env = new Bash(); - const result = await env.exec("for i in a b c do echo $i; done"); - expect(result.stdout).toBe("a\nb\nc\n"); - }); - - it("should handle while loop with semicolon before do", async () => { - const env = new Bash(); - await env.exec("echo x > /f.txt"); - // Note: Bash requires semicolon or newline before 'do' - const result = await env.exec( - "while grep -q x /f.txt; do echo found; echo y > /f.txt; done", - ); - expect(result.stdout).toBe("found\n"); - }); - - it("should error on malformed for loop", async () => { - const env = new Bash(); - const result = await env.exec("for i a b c; do echo $i; done"); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("syntax error"); - }); - }); -}); diff --git a/src/syntax/operators.test.ts b/src/syntax/operators.test.ts deleted file mode 100644 index 91a5e87f..00000000 --- a/src/syntax/operators.test.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -describe("Bash Syntax - Operators", () => { - describe("logical AND (&&)", () => { - it("should execute second command when first succeeds", async () => { - const env = new Bash(); - const result = await env.exec("echo first && echo second"); - expect(result.stdout).toBe("first\nsecond\n"); - expect(result.exitCode).toBe(0); - }); - - it("should not execute second command when first fails", async () => { - const env = new Bash(); - const result = await env.exec("cat /nonexistent && echo second"); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(1); - }); - - it("should chain multiple && operators", async () => { - const env = new Bash(); - const result = await env.exec("echo a && echo b && echo c && echo d"); - expect(result.stdout).toBe("a\nb\nc\nd\n"); - expect(result.exitCode).toBe(0); - }); - - it("should stop chain at first failure", async () => { - const env = new Bash(); - const result = await env.exec( - "echo a && cat /missing && echo b && echo c", - ); - expect(result.stdout).toBe("a\n"); - expect(result.exitCode).toBe(1); - }); - - it("should work with commands that modify filesystem", async () => { - const env = new Bash(); - await env.exec("mkdir /test && echo created > /test/file.txt"); - const content = await env.readFile("/test/file.txt"); - expect(content).toBe("created\n"); - }); - - it("should not modify filesystem when first command fails", async () => { - const env = new Bash({ - files: { "/important.txt": "keep this" }, - }); - await env.exec("cat /missing && rm /important.txt"); - const content = await env.readFile("/important.txt"); - expect(content).toBe("keep this"); - }); - - it("should handle && with exit codes from pipes", async () => { - const env = new Bash(); - const result = await env.exec("echo test | grep missing && echo found"); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(1); - }); - - it("should handle && after successful grep", async () => { - const env = new Bash(); - const result = await env.exec("echo test | grep test && echo found"); - expect(result.stdout).toBe("test\nfound\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("logical OR (||)", () => { - it("should execute second command when first fails", async () => { - const env = new Bash(); - const result = await env.exec("cat /nonexistent || echo fallback"); - expect(result.stdout).toBe("fallback\n"); - expect(result.exitCode).toBe(0); - }); - - it("should not execute second command when first succeeds", async () => { - const env = new Bash(); - const result = await env.exec("echo success || echo fallback"); - expect(result.stdout).toBe("success\n"); - expect(result.exitCode).toBe(0); - }); - - it("should chain multiple || operators", async () => { - const env = new Bash(); - const result = await env.exec( - "cat /a || cat /b || cat /c || echo fallback", - ); - expect(result.stdout).toBe("fallback\n"); - expect(result.exitCode).toBe(0); - }); - - it("should stop at first success in || chain", async () => { - const env = new Bash({ - files: { "/exists.txt": "found" }, - }); - const result = await env.exec( - "cat /missing || cat /exists.txt || echo fallback", - ); - expect(result.stdout).toBe("found"); - expect(result.exitCode).toBe(0); - }); - - it("should return non-zero if all commands fail", async () => { - const env = new Bash(); - const result = await env.exec("cat /a || cat /b || cat /c"); - expect(result.exitCode).toBe(1); - }); - - it("should work as error handler pattern", async () => { - const env = new Bash(); - const result = await env.exec('mkdir /dir || echo "dir already exists"'); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - // Second call should trigger the || branch - const result2 = await env.exec('mkdir /dir || echo "dir already exists"'); - expect(result2.stdout).toBe("dir already exists\n"); - }); - - it("should handle || with grep no match", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo test | grep missing || echo "not found"', - ); - expect(result.stdout).toBe("not found\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("semicolon (;) sequential execution", () => { - it("should execute both commands regardless of first result", async () => { - const env = new Bash(); - const result = await env.exec("echo first ; echo second"); - expect(result.stdout).toBe("first\nsecond\n"); - }); - - it("should execute second even when first fails", async () => { - const env = new Bash(); - const result = await env.exec("cat /missing ; echo second"); - expect(result.stdout).toBe("second\n"); - }); - - it("should chain multiple ; operators", async () => { - const env = new Bash(); - const result = await env.exec("echo a ; echo b ; echo c"); - expect(result.stdout).toBe("a\nb\nc\n"); - }); - - it("should preserve exit code from last command", async () => { - const env = new Bash(); - const result = await env.exec("echo first ; cat /missing"); - expect(result.stdout).toBe("first\n"); - expect(result.exitCode).toBe(1); - }); - - it("should return success if last command succeeds", async () => { - const env = new Bash(); - const result = await env.exec("cat /missing ; echo success"); - expect(result.stdout).toBe("success\n"); - expect(result.exitCode).toBe(0); - }); - - it("should handle ; without spaces", async () => { - const env = new Bash(); - const result = await env.exec("echo a;echo b;echo c"); - expect(result.stdout).toBe("a\nb\nc\n"); - }); - }); - - describe("mixed operators", () => { - it("should handle && followed by ||", async () => { - const env = new Bash(); - const result = await env.exec( - "cat /missing && echo success || echo failure", - ); - expect(result.stdout).toBe("failure\n"); - }); - - it("should handle || followed by &&", async () => { - const env = new Bash(); - const result = await env.exec( - "cat /missing || echo recovered && echo continued", - ); - expect(result.stdout).toBe("recovered\ncontinued\n"); - }); - - it("should handle success && success || fallback", async () => { - const env = new Bash(); - const result = await env.exec("echo a && echo b || echo c"); - expect(result.stdout).toBe("a\nb\n"); - }); - - it("should handle ; with &&", async () => { - const env = new Bash(); - const result = await env.exec("echo a ; echo b && echo c"); - expect(result.stdout).toBe("a\nb\nc\n"); - }); - - it("should handle ; with ||", async () => { - const env = new Bash(); - const result = await env.exec( - "cat /missing ; cat /missing2 || echo fallback", - ); - expect(result.stdout).toBe("fallback\n"); - }); - - it("should handle complex chain: fail && x || recover ; continue", async () => { - const env = new Bash(); - const result = await env.exec( - "cat /missing && echo success || echo recovered ; echo done", - ); - expect(result.stdout).toBe("recovered\ndone\n"); - }); - - it("should handle complex chain: success && next || x ; continue", async () => { - const env = new Bash(); - const result = await env.exec( - "echo ok && echo next || echo skip ; echo done", - ); - expect(result.stdout).toBe("ok\nnext\ndone\n"); - }); - }); - - describe("pipes (|)", () => { - it("should pipe stdout to stdin", async () => { - const env = new Bash(); - const result = await env.exec("echo hello | cat"); - expect(result.stdout).toBe("hello\n"); - }); - - it("should chain multiple pipes", async () => { - const env = new Bash(); - const result = await env.exec("echo hello | cat | cat | cat"); - expect(result.stdout).toBe("hello\n"); - }); - - it("should filter with grep in pipe", async () => { - const env = new Bash(); - const result = await env.exec('echo -e "foo\\nbar\\nbaz" | grep ba'); - expect(result.stdout).toBe("bar\nbaz\n"); - }); - - it("should count lines with wc in pipe", async () => { - const env = new Bash(); - const result = await env.exec('echo -e "a\\nb\\nc" | wc -l'); - expect(result.stdout.trim()).toBe("3"); - }); - - it("should get first n lines with head in pipe", async () => { - const env = new Bash(); - const result = await env.exec('echo -e "1\\n2\\n3\\n4\\n5" | head -n 2'); - expect(result.stdout).toBe("1\n2\n"); - }); - - it("should get last n lines with tail in pipe", async () => { - const env = new Bash(); - const result = await env.exec('echo -e "1\\n2\\n3\\n4\\n5" | tail -n 2'); - expect(result.stdout).toBe("4\n5\n"); - }); - - it("should combine head and tail in pipe", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo -e "1\\n2\\n3\\n4\\n5" | head -n 4 | tail -n 2', - ); - expect(result.stdout).toBe("3\n4\n"); - }); - - it("should pipe file contents through multiple filters", async () => { - const env = new Bash({ - files: { "/data.txt": "apple\nbanana\napricot\nblueberry\navocado\n" }, - }); - const result = await env.exec("cat /data.txt | grep a | head -n 3"); - expect(result.stdout).toBe("apple\nbanana\napricot\n"); - }); - - it("should not confuse || with pipe", async () => { - const env = new Bash(); - const result = await env.exec("cat /missing || echo fallback"); - expect(result.stdout).toBe("fallback\n"); - }); - - it("should handle pipe with && after", async () => { - const env = new Bash(); - const result = await env.exec("echo test | grep test && echo found"); - expect(result.stdout).toBe("test\nfound\n"); - }); - - it("should handle pipe with || after (no match case)", async () => { - const env = new Bash(); - const result = await env.exec( - 'echo test | grep missing || echo "not found"', - ); - expect(result.stdout).toBe("not found\n"); - }); - }); - - describe("output redirection (> and >>)", () => { - it("should redirect stdout to new file with >", async () => { - const env = new Bash(); - await env.exec("echo hello > /output.txt"); - expect(await env.readFile("/output.txt")).toBe("hello\n"); - }); - - it("should overwrite existing file with >", async () => { - const env = new Bash({ - files: { "/output.txt": "old\n" }, - }); - await env.exec("echo new > /output.txt"); - expect(await env.readFile("/output.txt")).toBe("new\n"); - }); - - it("should append to file with >>", async () => { - const env = new Bash({ - files: { "/output.txt": "line1\n" }, - }); - await env.exec("echo line2 >> /output.txt"); - expect(await env.readFile("/output.txt")).toBe("line1\nline2\n"); - }); - - it("should create file when appending to nonexistent", async () => { - const env = new Bash(); - await env.exec("echo first >> /new.txt"); - expect(await env.readFile("/new.txt")).toBe("first\n"); - }); - - it("should redirect command output", async () => { - const env = new Bash({ - files: { "/input.txt": "content\n" }, - }); - await env.exec("cat /input.txt > /output.txt"); - expect(await env.readFile("/output.txt")).toBe("content\n"); - }); - - it("should redirect pipe output", async () => { - const env = new Bash(); - await env.exec('echo -e "a\\nb\\nc" | grep b > /output.txt'); - expect(await env.readFile("/output.txt")).toBe("b\n"); - }); - - it("should handle multiple appends", async () => { - const env = new Bash(); - await env.exec("echo a >> /log.txt"); - await env.exec("echo b >> /log.txt"); - await env.exec("echo c >> /log.txt"); - expect(await env.readFile("/log.txt")).toBe("a\nb\nc\n"); - }); - - it("should handle > without spaces", async () => { - const env = new Bash(); - await env.exec("echo test>/output.txt"); - expect(await env.readFile("/output.txt")).toBe("test\n"); - }); - - it("should handle >> without spaces", async () => { - const env = new Bash({ - files: { "/output.txt": "a\n" }, - }); - await env.exec("echo b>>/output.txt"); - expect(await env.readFile("/output.txt")).toBe("a\nb\n"); - }); - }); -}); diff --git a/src/syntax/parse-errors.test.ts b/src/syntax/parse-errors.test.ts deleted file mode 100644 index 54b2d337..00000000 --- a/src/syntax/parse-errors.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -describe("Bash Syntax - Parse Errors", () => { - describe("if statement errors", () => { - it("should error on unclosed if", async () => { - const env = new Bash(); - const result = await env.exec("if true; then echo hello"); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("syntax error"); - }); - - it("should error on missing then", async () => { - const env = new Bash(); - const result = await env.exec("if true; echo hello; fi"); - expect(result.exitCode).not.toBe(0); - }); - - it("should handle elif with semicolon as condition", async () => { - const env = new Bash(); - // 'elif;' parses as elif with semicolon command which gives unexpected results - // This is edge case behavior - the semicolon gets parsed as the condition - const result = await env.exec( - "if false; then echo a; elif true; then echo b; fi", - ); - expect(result.stdout).toBe("b\n"); - }); - - it("should error on else without if", async () => { - const env = new Bash(); - const result = await env.exec("else echo hello; fi"); - expect(result.exitCode).toBe(2); // syntax error - }); - - it("should error on fi without if", async () => { - const env = new Bash(); - const result = await env.exec("fi"); - expect(result.exitCode).toBe(2); // syntax error - }); - }); - - describe("for loop errors", () => { - it("should error on missing in keyword", async () => { - const env = new Bash(); - const result = await env.exec("for x a b c; do echo $x; done"); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("syntax error"); - }); - - it("should error on missing do keyword", async () => { - const env = new Bash(); - const result = await env.exec("for x in a b c; echo $x; done"); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("syntax error"); - }); - - it("should error on missing done keyword", async () => { - const env = new Bash(); - const result = await env.exec("for x in a b c; do echo $x"); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("syntax error"); - }); - - it("should error on invalid variable name", async () => { - const env = new Bash(); - const result = await env.exec("for 123 in a b c; do echo $123; done"); - // Bash validates variable name at runtime, not parse time - // Returns exit code 1 and "not a valid identifier" error - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("not a valid identifier"); - }); - }); - - describe("while loop errors", () => { - it("should error on missing do keyword", async () => { - const env = new Bash(); - const result = await env.exec("while true; echo loop; done"); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("syntax error"); - }); - - it("should error on missing done keyword", async () => { - const env = new Bash(); - const result = await env.exec("while true; do echo loop"); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("syntax error"); - }); - - it("should error on while followed by semicolon", async () => { - const env = new Bash(); - // 'while;' is parsed as 'while' followed by semicolon, which is a syntax error - const result = await env.exec("while; do echo loop; done"); - expect(result.exitCode).toBe(2); // Syntax error - expect(result.stderr).toContain("syntax error"); - }); - }); - - describe("until loop errors", () => { - it("should error on missing do keyword", async () => { - const env = new Bash(); - const result = await env.exec("until true; echo loop; done"); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("syntax error"); - }); - - it("should error on missing done keyword", async () => { - const env = new Bash(); - const result = await env.exec("until true; do echo loop"); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("syntax error"); - }); - }); - - describe("function definition errors", () => { - it("should accept function with numeric-starting name", async () => { - const env = new Bash(); - // Bash actually allows function names starting with digits - const result = await env.exec("123func() { echo hello; }"); - expect(result.exitCode).toBe(0); - }); - - it("should error on unclosed function body", async () => { - const env = new Bash(); - const result = await env.exec("myfunc() { echo hello"); - expect(result.exitCode).toBe(2); // Syntax error (unclosed brace) - expect(result.stderr).toContain("syntax error"); - }); - }); - - describe("quote errors", () => { - it("should handle unclosed double quote gracefully", async () => { - const env = new Bash(); - // This might be parsed differently - test actual behavior - const result = await env.exec('echo "unclosed'); - // The parser should handle this somehow - expect(result).toBeDefined(); - }); - - it("should handle unclosed single quote gracefully", async () => { - const env = new Bash(); - const result = await env.exec("echo 'unclosed"); - expect(result).toBeDefined(); - }); - }); - - describe("redirection errors", () => { - it("should auto-create parent directories on redirect", async () => { - const env = new Bash(); - // VirtualFS auto-creates parent directories - const result = await env.exec("echo test > /newdir/file.txt"); - expect(result.exitCode).toBe(0); - const content = await env.readFile("/newdir/file.txt"); - expect(content).toBe("test\n"); - }); - - it("should error on redirect without target", async () => { - const env = new Bash(); - const result = await env.exec("echo test >"); - // Parser should handle missing target - expect(result).toBeDefined(); - }); - }); - - describe("command errors", () => { - it("should return 127 for unknown command", async () => { - const env = new Bash(); - const result = await env.exec("unknowncommand"); - expect(result.exitCode).toBe(127); - expect(result.stderr).toContain("command not found"); - }); - - it("should return 127 for command path not found", async () => { - const env = new Bash(); - const result = await env.exec("/nonexistent/path/command"); - expect(result.exitCode).toBe(127); - expect(result.stderr).toContain("No such file or directory"); - }); - - it("should return 1 for file not found errors", async () => { - const env = new Bash(); - const result = await env.exec("cat /nonexistent.txt"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("No such file"); - }); - }); - - describe("local keyword errors", () => { - it("should error when local used outside function", async () => { - const env = new Bash(); - const result = await env.exec("local x=1"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("can only be used in a function"); - }); - }); - - describe("pipe and operator errors", () => { - it("should handle empty command before pipe", async () => { - const env = new Bash(); - const result = await env.exec("| cat"); - // Parser should handle this gracefully - expect(result).toBeDefined(); - }); - - it("should handle empty command after pipe", async () => { - const env = new Bash(); - const result = await env.exec("echo test |"); - expect(result).toBeDefined(); - }); - - it("should handle && with no second command", async () => { - const env = new Bash(); - const result = await env.exec("true &&"); - expect(result).toBeDefined(); - }); - - it("should handle || with no second command", async () => { - const env = new Bash(); - const result = await env.exec("false ||"); - expect(result).toBeDefined(); - }); - }); -}); diff --git a/src/syntax/parser-edge-cases.test.ts b/src/syntax/parser-edge-cases.test.ts deleted file mode 100644 index 60aae565..00000000 --- a/src/syntax/parser-edge-cases.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -describe("Bash Syntax - Parser Edge Cases", () => { - describe("quoting", () => { - it("should handle nested single quotes in double quotes", async () => { - const env = new Bash(); - const result = await env.exec("echo \"hello 'world'\""); - expect(result.stdout).toBe("hello 'world'\n"); - }); - - it("should handle nested double quotes in single quotes", async () => { - const env = new Bash(); - const result = await env.exec("echo 'hello \"world\"'"); - expect(result.stdout).toBe('hello "world"\n'); - }); - - it("should handle empty double quotes", async () => { - const env = new Bash(); - const result = await env.exec('echo ""'); - expect(result.stdout).toBe("\n"); - }); - - it("should handle empty single quotes", async () => { - const env = new Bash(); - const result = await env.exec("echo ''"); - expect(result.stdout).toBe("\n"); - }); - - it("should handle adjacent quoted strings", async () => { - const env = new Bash(); - const result = await env.exec("echo 'hello'\"world\""); - expect(result.stdout).toBe("helloworld\n"); - }); - - it("should handle quotes inside arguments", async () => { - const env = new Bash(); - const result = await env.exec("echo foo'bar'baz"); - expect(result.stdout).toBe("foobarbaz\n"); - }); - - it("should preserve special chars in single quotes", async () => { - const env = new Bash(); - const result = await env.exec("echo '* ? | > < && || ;'"); - expect(result.stdout).toBe("* ? | > < && || ;\n"); - }); - }); - - describe("escape sequences", () => { - it("should handle escaped double quotes", async () => { - const env = new Bash(); - const result = await env.exec('echo "hello \\"world\\""'); - expect(result.stdout).toBe('hello "world"\n'); - }); - - it("should handle escaped backslash", async () => { - const env = new Bash(); - const result = await env.exec('echo "a\\\\b"'); - expect(result.stdout).toBe("a\\b\n"); - }); - - it("should handle escaped dollar sign", async () => { - const env = new Bash(); - const result = await env.exec('echo "\\$HOME"'); - expect(result.stdout).toBe("$HOME\n"); - }); - - it("should handle escaped space outside quotes", async () => { - const env = new Bash(); - const result = await env.exec("echo hello\\ world"); - expect(result.stdout).toBe("hello world\n"); - }); - - it("should treat backslash literally in single quotes", async () => { - const env = new Bash(); - const result = await env.exec("echo 'a\\b'"); - expect(result.stdout).toBe("a\\b\n"); - }); - - it("should escape special operators", async () => { - const env = new Bash(); - const result = await env.exec("echo a\\|b"); - expect(result.stdout).toBe("a|b\n"); - }); - }); - - describe("variable expansion", () => { - it("should handle ${VAR:-default} with set variable", async () => { - const env = new Bash({ env: { VAR: "value" } }); - const result = await env.exec('echo "${VAR:-default}"'); - expect(result.stdout).toBe("value\n"); - }); - - it("should handle ${VAR:-default} with unset variable", async () => { - const env = new Bash(); - const result = await env.exec('echo "${VAR:-default}"'); - expect(result.stdout).toBe("default\n"); - }); - - it("should handle ${VAR:-} with empty default", async () => { - const env = new Bash(); - const result = await env.exec('echo "${VAR:-}"'); - expect(result.stdout).toBe("\n"); - }); - - it("should handle $VAR with no braces", async () => { - const env = new Bash({ env: { NAME: "test" } }); - const result = await env.exec("echo $NAME"); - expect(result.stdout).toBe("test\n"); - }); - - it("should handle adjacent variables", async () => { - const env = new Bash({ env: { A: "hello", B: "world" } }); - const result = await env.exec('echo "$A$B"'); - expect(result.stdout).toBe("helloworld\n"); - }); - - it("should handle variable followed by text", async () => { - const env = new Bash({ env: { NAME: "test" } }); - const result = await env.exec('echo "${NAME}file.txt"'); - expect(result.stdout).toBe("testfile.txt\n"); - }); - - it("should handle undefined variable as empty", async () => { - const env = new Bash(); - const result = await env.exec('echo "[$UNDEFINED]"'); - expect(result.stdout).toBe("[]\n"); - }); - - it("should handle special variable $?", async () => { - // Note: $? requires prior command execution context - const env = new Bash({ env: { "?": "0" } }); - const result = await env.exec('echo "$?"'); - expect(result.stdout).toBe("0\n"); - }); - }); - - describe("whitespace handling", () => { - it("should handle multiple spaces between arguments", async () => { - const env = new Bash(); - const result = await env.exec("echo a b c"); - expect(result.stdout).toBe("a b c\n"); - }); - - it("should handle tabs between arguments", async () => { - const env = new Bash(); - const result = await env.exec("echo\ta\tb\tc"); - expect(result.stdout).toBe("a b c\n"); - }); - - it("should handle leading whitespace", async () => { - const env = new Bash(); - const result = await env.exec(" echo hello"); - expect(result.stdout).toBe("hello\n"); - }); - - it("should handle trailing whitespace", async () => { - const env = new Bash(); - const result = await env.exec("echo hello "); - expect(result.stdout).toBe("hello\n"); - }); - - it("should preserve spaces in quotes", async () => { - const env = new Bash(); - const result = await env.exec('echo " hello world "'); - expect(result.stdout).toBe(" hello world \n"); - }); - }); - - describe("redirection parsing", () => { - it("should handle > without space", async () => { - const env = new Bash(); - await env.exec("echo hello>/tmp/test.txt"); - const content = await env.readFile("/tmp/test.txt"); - expect(content).toBe("hello\n"); - }); - - it("should handle >> without space", async () => { - const env = new Bash(); - await env.exec("echo first > /tmp/test.txt"); - await env.exec("echo second>>/tmp/test.txt"); - const content = await env.readFile("/tmp/test.txt"); - expect(content).toBe("first\nsecond\n"); - }); - - it("should handle 2>/dev/null", async () => { - const env = new Bash(); - const result = await env.exec("cat /nonexistent 2>/dev/null"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(1); - }); - - it("should handle 2>&1 redirection", async () => { - const env = new Bash(); - const result = await env.exec("cat /nonexistent 2>&1"); - expect(result.stdout).toContain("No such file"); - expect(result.stderr).toBe(""); - }); - - it("should handle multiple redirections", async () => { - const env = new Bash(); - await env.exec("echo out; cat /missing 2>&1 > /tmp/out.txt"); - // Complex redirection - varies by shell - }); - }); - - describe("operator parsing", () => { - it("should parse && correctly without spaces", async () => { - const env = new Bash(); - const result = await env.exec("echo a&&echo b"); - expect(result.stdout).toBe("a\nb\n"); - }); - - it("should parse || correctly without spaces", async () => { - const env = new Bash(); - const result = await env.exec("false||echo fallback"); - expect(result.stdout).toBe("fallback\n"); - }); - - it("should parse ; correctly without spaces", async () => { - const env = new Bash(); - const result = await env.exec("echo a;echo b"); - expect(result.stdout).toBe("a\nb\n"); - }); - - it("should parse | correctly without spaces", async () => { - const env = new Bash(); - const result = await env.exec("echo hello|cat"); - expect(result.stdout).toBe("hello\n"); - }); - - it("should differentiate | from ||", async () => { - const env = new Bash(); - const result = await env.exec("echo test | grep test || echo fail"); - expect(result.stdout).toBe("test\n"); - }); - - it("should differentiate & from &&", async () => { - // & is not implemented but && should work - const env = new Bash(); - const result = await env.exec("true && echo success"); - expect(result.stdout).toBe("success\n"); - }); - }); - - describe("complex command combinations", () => { - it("should handle mixed && and || with correct precedence", async () => { - const env = new Bash(); - // In bash, && and || have equal precedence, evaluated left-to-right - const result = await env.exec("false || echo A && echo B"); - expect(result.stdout).toBe("A\nB\n"); - }); - - it("should handle semicolon with && and ||", async () => { - const env = new Bash(); - const result = await env.exec("echo a; false || echo b; echo c"); - expect(result.stdout).toBe("a\nb\nc\n"); - }); - - it("should handle pipes with semicolons", async () => { - const env = new Bash(); - const result = await env.exec("echo hello | cat; echo world | cat"); - expect(result.stdout).toBe("hello\nworld\n"); - }); - - it("should handle assignment followed by command", async () => { - const env = new Bash(); - const result = await env.exec("x=hello; echo $x"); - expect(result.stdout).toBe("hello\n"); - }); - - it("should handle command after failed assignment-like string", async () => { - const env = new Bash(); - // If = is part of an argument, not an assignment - const result = await env.exec("echo a=b"); - expect(result.stdout).toBe("a=b\n"); - }); - }); - - describe("edge cases", () => { - it("should handle empty command line", async () => { - const env = new Bash(); - const result = await env.exec(""); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should handle command with only spaces", async () => { - const env = new Bash(); - const result = await env.exec(" "); - expect(result.stdout).toBe(""); - expect(result.exitCode).toBe(0); - }); - - it("should handle semicolon only as syntax error", async () => { - const env = new Bash(); - const result = await env.exec(";"); - // Bare semicolon is a syntax error in bash - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("syntax error"); - }); - - it("should treat multiple semicolons as syntax error", async () => { - const env = new Bash(); - const result = await env.exec("echo a;;;echo b"); - // In bash, `;;` is the case terminator and is a syntax error outside case - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("syntax error"); - }); - - it("should handle very long argument", async () => { - const env = new Bash(); - const longStr = "a".repeat(10000); - const result = await env.exec(`echo ${longStr}`); - expect(result.stdout).toBe(`${longStr}\n`); - }); - - it("should handle unicode in arguments", async () => { - const env = new Bash(); - const result = await env.exec('echo "Hello 世界 🌍"'); - expect(result.stdout).toBe("Hello 世界 🌍\n"); - }); - - it("should handle newline in double quotes", async () => { - const env = new Bash(); - const result = await env.exec('echo "line1\nline2"'); - expect(result.stdout).toBe("line1\nline2\n"); - }); - }); -}); diff --git a/src/syntax/parser-protection.test.ts b/src/syntax/parser-protection.test.ts deleted file mode 100644 index 32aea173..00000000 --- a/src/syntax/parser-protection.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; -import { parse } from "../parser/parser.js"; - -/** - * Parser Protection Tests - * - * These tests verify that the parser itself cannot cause runaway compute. - * Parser operations should complete in bounded time regardless of input. - * - * IMPORTANT: All tests should complete quickly (<1s each). - * If any test times out, it indicates a parser vulnerability. - */ - -describe("Parser Protection", () => { - describe("input size limits", () => { - it("should reject extremely long input", () => { - // Parser should have a reasonable input size limit - const longInput = `echo ${"x".repeat(2_000_000)}`; - - expect(() => parse(longInput)).toThrow(); - }); - - it("should handle very long variable names gracefully", () => { - const longVar = "a".repeat(100_000); - const input = `${longVar}=value`; - - // Should either parse or throw, but not hang - const start = Date.now(); - try { - parse(input); - } catch { - // Expected to fail - } - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(1000); - }); - - it("should handle very long string literals gracefully", () => { - const longStr = "x".repeat(500_000); - const input = `echo "${longStr}"`; - - const start = Date.now(); - try { - parse(input); - } catch { - // May fail due to size limits - } - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(1000); - }); - }); - - describe("nesting depth limits", () => { - it("should handle deeply nested parentheses", () => { - const depth = 1000; - const open = "(".repeat(depth); - const close = ")".repeat(depth); - const input = `echo ${open}test${close}`; - - const start = Date.now(); - try { - parse(input); - } catch { - // Expected to fail due to nesting limits - } - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(1000); - }); - - it("should handle deeply nested braces", () => { - const depth = 1000; - const open = "{".repeat(depth); - const close = "}".repeat(depth); - const input = `echo ${open}test${close}`; - - const start = Date.now(); - try { - parse(input); - } catch { - // Expected to fail due to nesting limits - } - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(1000); - }); - - it("should handle deeply nested command substitutions", () => { - let input = "echo x"; - for (let i = 0; i < 100; i++) { - input = `echo $(${input})`; - } - - const start = Date.now(); - try { - parse(input); - } catch { - // May fail due to nesting limits - } - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(1000); - }); - - it("should handle deeply nested arithmetic", () => { - let expr = "1"; - for (let i = 0; i < 500; i++) { - expr = `(${expr}+1)`; - } - const input = `echo $((${expr}))`; - - const start = Date.now(); - try { - parse(input); - } catch { - // May fail due to nesting limits - } - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(1000); - }); - }); - - describe("token count limits", () => { - it("should handle many tokens gracefully", () => { - // Many simple tokens - const tokens = Array(50000).fill("x").join(" "); - const input = `echo ${tokens}`; - - const start = Date.now(); - try { - parse(input); - } catch { - // May fail due to token limits - } - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(2000); - }); - - it("should handle many semicolons gracefully", () => { - const commands = Array(10000).fill("echo x").join("; "); - - const start = Date.now(); - try { - parse(commands); - } catch { - // May fail due to limits - } - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(2000); - }); - - it("should handle many pipes gracefully", () => { - const pipes = Array(1000).fill("cat").join(" | "); - - const start = Date.now(); - try { - parse(pipes); - } catch { - // May fail due to limits - } - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(1000); - }); - }); - - describe("pathological patterns", () => { - it("should handle repeated brace patterns", () => { - // Brace expansion can cause exponential growth - const input = "echo {a,b}{c,d}{e,f}{g,h}{i,j}{k,l}{m,n}{o,p}"; - - const start = Date.now(); - try { - parse(input); - } catch { - // Parser should handle this - } - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(1000); - }); - - it("should handle many redirections", () => { - const redirects = Array(500).fill("> /dev/null").join(" "); - const input = `echo test ${redirects}`; - - const start = Date.now(); - try { - parse(input); - } catch { - // May fail - } - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(1000); - }); - - it("should handle alternating quotes", () => { - const pattern = `"a"'b'`.repeat(10000); - const input = `echo ${pattern}`; - - const start = Date.now(); - try { - parse(input); - } catch { - // May fail - } - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(2000); - }); - }); - - describe("execution after parsing", () => { - it("should limit brace expansion during execution", async () => { - const env = new Bash(); - // This parses fine but expansion could be exponential - const result = await env.exec( - "echo {a,b}{c,d}{e,f}{g,h}{i,j}{k,l}{m,n}{o,p}{q,r}{s,t}", - ); - - // Should complete without hanging (expansion is limited) - expect(result.exitCode).toBe(0); - }); - - it("should limit range expansion during execution", async () => { - const env = new Bash(); - const result = await env.exec("echo {1..100000}"); - - // Range expansion should be limited - expect(result.exitCode).toBe(0); - // Output should be truncated or limited - expect(result.stdout.length).toBeLessThan(1_000_000); - }); - }); -}); diff --git a/src/syntax/set-errexit.test.ts b/src/syntax/set-errexit.test.ts deleted file mode 100644 index 763d8938..00000000 --- a/src/syntax/set-errexit.test.ts +++ /dev/null @@ -1,360 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -describe("Bash Syntax - set -e (errexit)", () => { - describe("basic errexit behavior", () => { - it("should exit immediately when command fails with set -e", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - echo before - false - echo after - `); - expect(result.stdout).toBe("before\n"); - expect(result.exitCode).toBe(1); - }); - - it("should continue execution without set -e", async () => { - const env = new Bash(); - const result = await env.exec(` - echo before - false - echo after - `); - expect(result.stdout).toBe("before\nafter\n"); - expect(result.exitCode).toBe(0); - }); - - it("should not exit if command succeeds", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - echo one - true - echo two - `); - expect(result.stdout).toBe("one\ntwo\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("set +e disables errexit", () => { - it("should disable errexit with set +e", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - set +e - echo before - false - echo after - `); - expect(result.stdout).toBe("before\nafter\n"); - expect(result.exitCode).toBe(0); - }); - - it("should re-enable errexit after set +e", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - set +e - false - set -e - echo before - false - echo after - `); - expect(result.stdout).toBe("before\n"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("set -o errexit syntax", () => { - it("should enable errexit with set -o errexit", async () => { - const env = new Bash(); - const result = await env.exec(` - set -o errexit - echo before - false - echo after - `); - expect(result.stdout).toBe("before\n"); - expect(result.exitCode).toBe(1); - }); - - it("should disable errexit with set +o errexit", async () => { - const env = new Bash(); - const result = await env.exec(` - set -o errexit - set +o errexit - echo before - false - echo after - `); - expect(result.stdout).toBe("before\nafter\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("errexit exceptions - && and ||", () => { - it("should not exit on failed command in && short-circuit", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - false && echo "not reached" - echo after - `); - expect(result.stdout).toBe("after\n"); - expect(result.exitCode).toBe(0); - }); - - it("should not exit on failed command in || short-circuit", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - false || echo "fallback" - echo after - `); - expect(result.stdout).toBe("fallback\nafter\n"); - expect(result.exitCode).toBe(0); - }); - - it("should exit if final command in && list fails", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - echo before - true && false - echo after - `); - expect(result.stdout).toBe("before\n"); - expect(result.exitCode).toBe(1); - }); - - it("should not exit if || succeeds after && fails", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - false && echo "skip" || echo "fallback" - echo after - `); - expect(result.stdout).toBe("fallback\nafter\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("errexit exceptions - negated commands", () => { - it("should not exit on negated successful command", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - ! true - echo after - `); - expect(result.stdout).toBe("after\n"); - expect(result.exitCode).toBe(0); - }); - - it("should not exit on negated failed command", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - ! false - echo after - `); - expect(result.stdout).toBe("after\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("errexit exceptions - if condition", () => { - it("should not exit on failed command in if condition", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - if false; then - echo "then" - else - echo "else" - fi - echo after - `); - expect(result.stdout).toBe("else\nafter\n"); - expect(result.exitCode).toBe(0); - }); - - it("should exit on failed command in if body", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - if true; then - echo "in body" - false - echo "not reached" - fi - echo after - `); - expect(result.stdout).toBe("in body\n"); - expect(result.exitCode).toBe(1); - }); - - it("should not exit on failed command in elif condition", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - if false; then - echo one - elif false; then - echo two - else - echo three - fi - echo after - `); - expect(result.stdout).toBe("three\nafter\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("errexit exceptions - while condition", () => { - it("should not exit on failed condition that terminates loop", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - x=0 - while [ $x -lt 3 ]; do - echo $x - x=$((x + 1)) - done - echo after - `); - expect(result.stdout).toBe("0\n1\n2\nafter\n"); - expect(result.exitCode).toBe(0); - }); - - it("should exit on failed command in while body", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - x=0 - while [ $x -lt 3 ]; do - echo $x - false - x=$((x + 1)) - done - echo after - `); - expect(result.stdout).toBe("0\n"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("errexit exceptions - until condition", () => { - it("should not exit on failed condition during loop", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - x=0 - until [ $x -ge 3 ]; do - echo $x - x=$((x + 1)) - done - echo after - `); - expect(result.stdout).toBe("0\n1\n2\nafter\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("combined flags", () => { - it("should handle -ee combined flag (multiple e)", async () => { - const env = new Bash(); - const result = await env.exec(` - set -ee - echo before - false - echo after - `); - expect(result.stdout).toBe("before\n"); - expect(result.exitCode).toBe(1); - }); - - it("should error on unknown combined flag", async () => { - const env = new Bash(); - // Use -ze (z is invalid) so the error happens before errexit is enabled - const result = await env.exec("set -ze"); - expect(result.exitCode).toBe(1); // implementation returns 1 for invalid options - expect(result.stderr).toContain("-z"); - expect(result.stderr).toContain("invalid option"); - }); - - it("should trigger errexit when set -ez fails on z", async () => { - const env = new Bash(); - // With -ez, errexit is enabled first, then z fails - errexit kicks in - const result = await env.exec(` - set -ez - echo "should not reach" - `); - // Command fails with exit code 1 for invalid option - expect(result.exitCode).toBe(1); - // No echo output because script exited on the set command failure - expect(result.stdout).toBe(""); - }); - }); - - describe("preserves exit code", () => { - it("should preserve non-zero exit code", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - exit 42 - `); - expect(result.exitCode).toBe(42); - }); - }); - - describe("error handling", () => { - it("should show help with --help", async () => { - const env = new Bash(); - const result = await env.exec("set --help"); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("usage:"); - expect(result.stdout).toContain("-e"); - expect(result.stdout).toContain("errexit"); - }); - - it("should error on unknown short option", async () => { - const env = new Bash(); - const result = await env.exec("set -z"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("-z"); - expect(result.stderr).toContain("invalid option"); - }); - - it("should error on unknown long option", async () => { - const env = new Bash(); - const result = await env.exec("set -o unknownoption"); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("unknownoption"); - expect(result.stderr).toContain("invalid option name"); - }); - - it("should list options when -o has no argument", async () => { - // In bash, `set -o` without argument lists all options - const env = new Bash(); - const result = await env.exec("set -o"); - expect(result.exitCode).toBe(0); - // Should output option status (e.g., "errexit off") - expect(result.stdout).toContain("errexit"); - }); - - it("should list options when +o has no argument", async () => { - // In bash, `set +o` without argument outputs commands to recreate settings - const env = new Bash(); - const result = await env.exec("set +o"); - expect(result.exitCode).toBe(0); - // Should output set commands (e.g., "set +o errexit") - expect(result.stdout).toContain("set"); - }); - }); -}); diff --git a/src/syntax/set-pipefail.test.ts b/src/syntax/set-pipefail.test.ts deleted file mode 100644 index ac518136..00000000 --- a/src/syntax/set-pipefail.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -describe("Bash Syntax - set -o pipefail", () => { - describe("basic pipefail behavior", () => { - it("should return success when all commands succeed", async () => { - const env = new Bash(); - const result = await env.exec(` - set -o pipefail - echo hello | cat | cat - echo "exit: $?" - `); - expect(result.stdout).toBe("hello\nexit: 0\n"); - expect(result.exitCode).toBe(0); - }); - - it("should return failure when first command fails", async () => { - const env = new Bash(); - const result = await env.exec(` - set -o pipefail - false | true - echo "exit: $?" - `); - expect(result.stdout).toBe("exit: 1\n"); - expect(result.exitCode).toBe(0); - }); - - it("should return failure when middle command fails", async () => { - const env = new Bash(); - const result = await env.exec(` - set -o pipefail - echo hello | false | cat - echo "exit: $?" - `); - expect(result.stdout).toBe("exit: 1\n"); - expect(result.exitCode).toBe(0); - }); - - it("should return rightmost failing exit code", async () => { - const env = new Bash(); - const result = await env.exec(` - set -o pipefail - exit 2 | exit 3 | true - echo "exit: $?" - `); - expect(result.stdout).toBe("exit: 3\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("without pipefail", () => { - it("should return last command exit code without pipefail", async () => { - const env = new Bash(); - const result = await env.exec(` - false | true - echo "exit: $?" - `); - expect(result.stdout).toBe("exit: 0\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("disable pipefail", () => { - it("should disable pipefail with +o pipefail", async () => { - const env = new Bash(); - const result = await env.exec(` - set -o pipefail - set +o pipefail - false | true - echo "exit: $?" - `); - expect(result.stdout).toBe("exit: 0\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("pipefail with errexit", () => { - it("should trigger errexit when pipeline fails with pipefail", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - set -o pipefail - echo before - false | true - echo after - `); - expect(result.stdout).toBe("before\n"); - expect(result.exitCode).toBe(1); - }); - - it("should not trigger errexit without pipefail", async () => { - const env = new Bash(); - const result = await env.exec(` - set -e - echo before - false | true - echo after - `); - expect(result.stdout).toBe("before\nafter\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("single command", () => { - it("should work with single command pipeline", async () => { - const env = new Bash(); - const result = await env.exec(` - set -o pipefail - false - echo "exit: $?" - `); - expect(result.stdout).toBe("exit: 1\n"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/syntax/source.test.ts b/src/syntax/source.test.ts deleted file mode 100644 index 4e48f38b..00000000 --- a/src/syntax/source.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -describe("Bash Syntax - source and . builtins", () => { - describe("source builtin", () => { - it("should execute commands from file in current environment", async () => { - const env = new Bash(); - await env.exec('echo "x=123" > /tmp/test.sh'); - const result = await env.exec(` - source /tmp/test.sh - echo "x is: $x" - `); - expect(result.stdout).toBe("x is: 123\n"); - expect(result.exitCode).toBe(0); - }); - - it("should support functions from sourced file", async () => { - const env = new Bash(); - await env.exec('echo "greet() { echo Hello \\$1; }" > /tmp/funcs.sh'); - const result = await env.exec(` - source /tmp/funcs.sh - greet World - `); - expect(result.stdout).toBe("Hello World\n"); - expect(result.exitCode).toBe(0); - }); - - it("should error on missing file", async () => { - const env = new Bash(); - const result = await env.exec("source /nonexistent/file.sh"); - expect(result.stderr).toContain("No such file or directory"); - expect(result.exitCode).toBe(1); - }); - - it("should error with no arguments", async () => { - const env = new Bash(); - const result = await env.exec("source"); - expect(result.stderr).toContain("filename argument required"); - expect(result.exitCode).toBe(2); - }); - }); - - describe(". (dot) builtin", () => { - it("should work same as source", async () => { - const env = new Bash(); - await env.exec('echo "y=456" > /tmp/test2.sh'); - const result = await env.exec(` - . /tmp/test2.sh - echo "y is: $y" - `); - expect(result.stdout).toBe("y is: 456\n"); - expect(result.exitCode).toBe(0); - }); - }); - - describe("sourced script with arguments", () => { - it("should pass arguments to sourced script", async () => { - const env = new Bash(); - await env.exec('echo "echo args: \\$1 \\$2 \\$#" > /tmp/args.sh'); - const result = await env.exec("source /tmp/args.sh foo bar"); - expect(result.stdout).toBe("args: foo bar 2\n"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/syntax/subshell-args.test.ts b/src/syntax/subshell-args.test.ts deleted file mode 100644 index f0874c7f..00000000 --- a/src/syntax/subshell-args.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -describe("Positional arguments and operator precedence", () => { - describe("bash/sh positional arguments", () => { - it("should handle bash -c with positional args (single quoted)", async () => { - const env = new Bash(); - // Use single quotes so outer shell doesn't expand $1 $2 - const result = await env.exec("bash -c 'echo $1 $2' script arg1 arg2"); - expect(result.stdout).toBe("arg1 arg2\n"); - }); - - it("should handle sh -c with positional args (single quoted)", async () => { - const env = new Bash(); - const result = await env.exec("sh -c 'echo $1 $2' script arg1 arg2"); - expect(result.stdout).toBe("arg1 arg2\n"); - }); - - it("should set $0 to script name", async () => { - const env = new Bash(); - const result = await env.exec("bash -c 'echo $0' myscript"); - expect(result.stdout).toBe("myscript\n"); - }); - - it("should handle script file with positional args", async () => { - const env = new Bash({ - files: { - "/script.sh": 'echo "Args: $1 $2 $3"', - }, - }); - const result = await env.exec("bash /script.sh one two three"); - expect(result.stdout).toBe("Args: one two three\n"); - }); - - it("should set $# to argument count", async () => { - const env = new Bash(); - const result = await env.exec("bash -c 'echo $#' script a b c"); - expect(result.stdout).toBe("3\n"); - }); - - it("should set $@ to all arguments", async () => { - const env = new Bash(); - const result = await env.exec("bash -c 'echo $@' script a b c"); - expect(result.stdout).toBe("a b c\n"); - }); - }); - - describe("xargs positional arguments", () => { - it("should append args to command", async () => { - const env = new Bash(); - const result = await env.exec('echo "a b c" | xargs echo prefix'); - expect(result.stdout).toBe("prefix a b c\n"); - }); - - it("should handle -I replacement", async () => { - const env = new Bash(); - const result = await env.exec( - 'printf "one\\ntwo" | xargs -I {} echo item: {}', - ); - expect(result.stdout).toBe("item: one\nitem: two\n"); - }); - - it("should handle -n batching", async () => { - const env = new Bash(); - const result = await env.exec('echo "a b c d" | xargs -n 2 echo'); - expect(result.stdout).toBe("a b\nc d\n"); - }); - - it("should handle null-separated input with -0", async () => { - const env = new Bash(); - // Simulate find -print0 style output - const result = await env.exec( - 'printf "file1\\x00file2\\x00file3" | xargs -0 echo', - ); - expect(result.stdout).toBe("file1 file2 file3\n"); - }); - }); - - describe("Operator precedence", () => { - it("! should bind tighter than &&", async () => { - const env = new Bash(); - // ! false -> success (0), then && runs echo - const result = await env.exec("! false && echo yes"); - expect(result.stdout).toBe("yes\n"); - expect(result.exitCode).toBe(0); - }); - - it("! should bind tighter than ||", async () => { - const env = new Bash(); - // ! true -> failure (1), then || runs fallback - const result = await env.exec("! true || echo fallback"); - expect(result.stdout).toBe("fallback\n"); - expect(result.exitCode).toBe(0); - }); - - it("! should negate entire pipeline", async () => { - const env = new Bash(); - // In bash, ! negates the entire pipeline - // ! echo hello | grep missing = ! (echo hello | grep missing) - // grep fails (exit 1), negation makes it success (exit 0) - const result = await env.exec("! echo hello | grep missing"); - expect(result.exitCode).toBe(0); // grep fails (1), negated to 0 - }); - - it("! should negate successful pipeline", async () => { - const env = new Bash(); - // ! echo hello | grep hello = ! (echo hello | grep hello) - // grep succeeds (exit 0), negation makes it failure (exit 1) - const result = await env.exec("! echo hello | grep hello"); - expect(result.exitCode).toBe(1); // grep succeeds (0), negated to 1 - }); - - it("&& and || should be left-associative", async () => { - const env = new Bash(); - // true || echo no && echo yes - // Should be: (true || echo no) && echo yes - // true succeeds, || short-circuits, then && echo yes runs - const result = await env.exec("true || echo no && echo yes"); - expect(result.stdout).toBe("yes\n"); - }); - - it("; should have lowest precedence", async () => { - const env = new Bash(); - // false && echo no ; echo always - // Should be: (false && echo no) ; echo always - const result = await env.exec("false && echo no ; echo always"); - expect(result.stdout).toBe("always\n"); - }); - - it("double negation should cancel out", async () => { - const env = new Bash(); - // ! ! true = negate(negate(true)) = negate(1) = 0 - const result = await env.exec("! ! true"); - expect(result.exitCode).toBe(0); - }); - - it("double negation of false should give 1", async () => { - const env = new Bash(); - // ! ! false = negate(negate(false)) = negate(0) = 1 - const result = await env.exec("! ! false"); - expect(result.exitCode).toBe(1); - }); - - it("triple negation should negate once", async () => { - const env = new Bash(); - // ! ! ! true = negate(negate(negate(true))) = negate(0) = 1 - const result = await env.exec("! ! ! true"); - expect(result.exitCode).toBe(1); - }); - - it("triple negation of false should give 0", async () => { - const env = new Bash(); - // ! ! ! false = negate(negate(negate(false))) = negate(1) = 0 - const result = await env.exec("! ! ! false"); - expect(result.exitCode).toBe(0); - }); - - it("quadruple negation should cancel out", async () => { - const env = new Bash(); - // ! ! ! ! true = even count, no change - const result = await env.exec("! ! ! ! true"); - expect(result.exitCode).toBe(0); - }); - }); -}); diff --git a/src/syntax/variables.test.ts b/src/syntax/variables.test.ts deleted file mode 100644 index 39ca9ea2..00000000 --- a/src/syntax/variables.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Bash } from "../Bash.js"; - -describe("Bash Syntax - Variables and Quoting", () => { - describe("environment variable expansion", () => { - it("should expand $VAR", async () => { - const env = new Bash({ env: { NAME: "world" } }); - const result = await env.exec("echo hello $NAME"); - expect(result.stdout).toBe("hello world\n"); - }); - - it("should expand ${VAR}", async () => { - const env = new Bash({ env: { NAME: "world" } }); - const result = await env.exec("echo hello ${NAME}"); - expect(result.stdout).toBe("hello world\n"); - }); - - it("should expand ${VAR} adjacent to text", async () => { - const env = new Bash({ env: { PREFIX: "pre" } }); - const result = await env.exec("echo ${PREFIX}fix"); - expect(result.stdout).toBe("prefix\n"); - }); - - it("should expand multiple variables", async () => { - const env = new Bash({ env: { A: "hello", B: "world" } }); - const result = await env.exec("echo $A $B"); - expect(result.stdout).toBe("hello world\n"); - }); - - it("should handle unset variable as empty", async () => { - const env = new Bash(); - const result = await env.exec('echo "[$UNSET]"'); - expect(result.stdout).toBe("[]\n"); - }); - - it("should handle ${VAR:-default} with unset variable", async () => { - const env = new Bash(); - const result = await env.exec("echo ${MISSING:-default}"); - expect(result.stdout).toBe("default\n"); - }); - - it("should handle ${VAR:-default} with set variable", async () => { - const env = new Bash({ env: { SET: "value" } }); - const result = await env.exec("echo ${SET:-default}"); - expect(result.stdout).toBe("value\n"); - }); - - it("should expand in double quotes", async () => { - const env = new Bash({ env: { VAR: "value" } }); - const result = await env.exec('echo "the $VAR is here"'); - expect(result.stdout).toBe("the value is here\n"); - }); - - it("should not expand in single quotes", async () => { - const env = new Bash({ env: { VAR: "value" } }); - const result = await env.exec("echo 'the $VAR is here'"); - expect(result.stdout).toBe("the $VAR is here\n"); - }); - - it("should expand in file paths", async () => { - const env = new Bash({ - files: { "/home/user/file.txt": "content" }, - env: { HOME: "/home/user" }, - }); - const result = await env.exec("cat $HOME/file.txt"); - expect(result.stdout).toBe("content"); - }); - - it("should handle export command (within same exec)", async () => { - const env = new Bash(); - const result = await env.exec("export FOO=bar; echo $FOO"); - expect(result.stdout).toBe("bar\n"); - }); - - it("should handle export with multiple assignments (within same exec)", async () => { - const env = new Bash(); - const result = await env.exec("export A=1 B=2 C=3; echo $A $B $C"); - expect(result.stdout).toBe("1 2 3\n"); - }); - - it("should handle unset command (within same exec)", async () => { - const env = new Bash({ env: { FOO: "bar" } }); - const result = await env.exec('unset FOO; echo "[$FOO]"'); - expect(result.stdout).toBe("[]\n"); - }); - }); - - describe("quoting", () => { - it("should preserve spaces in double quotes", async () => { - const env = new Bash(); - const result = await env.exec('echo "hello world"'); - expect(result.stdout).toBe("hello world\n"); - }); - - it("should preserve spaces in single quotes", async () => { - const env = new Bash(); - const result = await env.exec("echo 'hello world'"); - expect(result.stdout).toBe("hello world\n"); - }); - - it("should handle single quote inside double quotes", async () => { - const env = new Bash(); - const result = await env.exec('echo "it\'s working"'); - expect(result.stdout).toBe("it's working\n"); - }); - - it("should handle escaped double quote inside double quotes", async () => { - const env = new Bash(); - const result = await env.exec('echo "say \\"hello\\""'); - expect(result.stdout).toBe('say "hello"\n'); - }); - - it("should handle empty string argument", async () => { - const env = new Bash(); - const result = await env.exec('echo ""'); - expect(result.stdout).toBe("\n"); - }); - - it("should handle adjacent quoted strings", async () => { - const env = new Bash(); - const result = await env.exec("echo \"hello\"'world'"); - expect(result.stdout).toBe("helloworld\n"); - }); - - it("should preserve special chars in single quotes", async () => { - const env = new Bash(); - const result = await env.exec("echo 'hello $VAR && test'"); - expect(result.stdout).toBe("hello $VAR && test\n"); - }); - - it("should handle newline in quoted string with $", async () => { - const env = new Bash(); - const result = await env.exec('echo "line1\nline2"'); - expect(result.stdout).toBe("line1\nline2\n"); - }); - }); - - describe("escape sequences", () => { - it("should handle \\n with echo -e", async () => { - const env = new Bash(); - const result = await env.exec('echo -e "hello\\nworld"'); - expect(result.stdout).toBe("hello\nworld\n"); - }); - - it("should handle \\t with echo -e", async () => { - const env = new Bash(); - const result = await env.exec('echo -e "col1\\tcol2"'); - expect(result.stdout).toBe("col1\tcol2\n"); - }); - - it("should handle multiple escape sequences", async () => { - const env = new Bash(); - const result = await env.exec('echo -e "a\\nb\\nc\\nd"'); - expect(result.stdout).toBe("a\nb\nc\nd\n"); - }); - - it("should handle \\\\ for literal backslash", async () => { - const env = new Bash(); - // In bash: echo -e "path\\\\to\\\\file" outputs path\to\file - // Because \\\\ in double quotes -> \\ after quote processing -> \ after echo -e - const result = await env.exec('echo -e "path\\\\\\\\to\\\\\\\\file"'); - expect(result.stdout).toBe("path\\to\\file\n"); - }); - - it("should not interpret escapes without -e", async () => { - const env = new Bash(); - const result = await env.exec('echo "hello\\nworld"'); - expect(result.stdout).toBe("hello\\nworld\n"); - }); - }); - - describe("exit command", () => { - it("should exit with code 0 by default", async () => { - const env = new Bash(); - const result = await env.exec("exit"); - expect(result.exitCode).toBe(0); - }); - - it("should exit with specified code", async () => { - const env = new Bash(); - const result = await env.exec("exit 42"); - expect(result.exitCode).toBe(42); - }); - - it("should exit with code 1", async () => { - const env = new Bash(); - const result = await env.exec("exit 1"); - expect(result.exitCode).toBe(1); - }); - }); - - describe("unknown commands", () => { - it("should return 127 for unknown command", async () => { - const env = new Bash(); - const result = await env.exec("unknowncommand"); - expect(result.exitCode).toBe(127); - expect(result.stderr).toContain("command not found"); - }); - - it("should include command name in error", async () => { - const env = new Bash(); - const result = await env.exec("foobar"); - expect(result.stderr).toContain("foobar"); - }); - }); - - describe("whitespace handling", () => { - it("should handle empty command", async () => { - const env = new Bash(); - const result = await env.exec(""); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - }); - - it("should handle whitespace-only command", async () => { - const env = new Bash(); - const result = await env.exec(" "); - expect(result.exitCode).toBe(0); - }); - - it("should trim leading/trailing whitespace", async () => { - const env = new Bash(); - const result = await env.exec(" echo hello "); - expect(result.stdout).toBe("hello\n"); - }); - - it("should collapse multiple spaces between args", async () => { - const env = new Bash(); - const result = await env.exec("echo hello world"); - expect(result.stdout).toBe("hello world\n"); - }); - - it("should handle tabs", async () => { - const env = new Bash(); - const result = await env.exec("echo\thello\tworld"); - expect(result.stdout).toBe("hello world\n"); - }); - }); - - describe("variable assignments", () => { - it("should assign value with double quotes", async () => { - const env = new Bash(); - const result = await env.exec('MYVAR="hello"; echo $MYVAR'); - expect(result.stdout).toBe("hello\n"); - }); - - it("should assign value with single quotes", async () => { - const env = new Bash(); - const result = await env.exec("MYVAR='hello'; echo $MYVAR"); - expect(result.stdout).toBe("hello\n"); - }); - - it("should assign empty string with double quotes", async () => { - const env = new Bash(); - const result = await env.exec('MYVAR=""; echo "value:$MYVAR:"'); - expect(result.stdout).toBe("value::\n"); - expect(result.stderr).toBe(""); - }); - - it("should assign empty string with single quotes", async () => { - const env = new Bash(); - const result = await env.exec("MYVAR=''; echo \"value:$MYVAR:\""); - expect(result.stdout).toBe("value::\n"); - expect(result.stderr).toBe(""); - }); - - it("should assign empty string without quotes", async () => { - const env = new Bash(); - const result = await env.exec('MYVAR=; echo "value:$MYVAR:"'); - expect(result.stdout).toBe("value::\n"); - }); - - it("should handle value with spaces in double quotes", async () => { - const env = new Bash(); - const result = await env.exec('MYVAR="hello world"; echo "$MYVAR"'); - expect(result.stdout).toBe("hello world\n"); - }); - - it("should handle export with empty double-quoted value", async () => { - const env = new Bash(); - const result = await env.exec('export MYVAR=""; echo "value:$MYVAR:"'); - expect(result.stdout).toBe("value::\n"); - expect(result.stderr).toBe(""); - }); - }); -}); diff --git a/src/test-utils/busybox-test-parser.ts b/src/test-utils/busybox-test-parser.ts deleted file mode 100644 index b40d8a4b..00000000 --- a/src/test-utils/busybox-test-parser.ts +++ /dev/null @@ -1,264 +0,0 @@ -/** - * Shared parser for BusyBox test format - * - * BusyBox format: testing "description" "commands" "result" "infile" "stdin" - * - * This parser is used by both sed-spec-tests and grep-spec-tests. - */ - -export interface BusyBoxTestCase { - name: string; - /** The shell command to run */ - command: string; - /** Expected stdout */ - expectedOutput: string; - /** Content for input file (if any) */ - infile: string; - /** Content for stdin (if any) */ - stdin: string; - /** Line number in source file */ - lineNumber: number; - /** If set, test is expected to fail (value is reason) */ - skip?: string; - /** Expected exit code (optional - if not set, only output is checked) */ - expectedExitCode?: number; -} - -export interface ParsedBusyBoxTestFile { - fileName: string; - filePath: string; - testCases: BusyBoxTestCase[]; -} - -/** - * Join multi-line test definitions, handling both shell continuations and quoted newlines - * - Shell continuation: backslash at end of line (outside quotes OR inside double quotes) -> remove backslash, join - * - Escaped backslash at end of line (\\): not a continuation, preserve newline - * - Quoted newline inside single quotes: preserve the newline (backslash is literal in single quotes) - */ -function joinTestLines( - lines: string[], - startIndex: number, -): { fullLine: string; endIndex: number } { - let result = ""; - let i = startIndex; - let inSingleQuote = false; - let inDoubleQuote = false; - - while (i < lines.length) { - const line = lines[i]; - - // Process each character to track quote state - for (let j = 0; j < line.length; j++) { - const char = line[j]; - - // Handle escape sequences (but only in double quotes for shell) - if (char === "\\" && j + 1 < line.length && inDoubleQuote) { - result += char + line[j + 1]; - j++; - continue; - } - - if (char === "'" && !inDoubleQuote) { - inSingleQuote = !inSingleQuote; - } else if (char === '"' && !inSingleQuote) { - inDoubleQuote = !inDoubleQuote; - } - - result += char; - } - - // Count trailing backslashes in the original line - // Odd count = trailing continuation backslash - // Even count = all backslashes are escaped (no continuation) - let trailingBackslashes = 0; - for (let k = line.length - 1; k >= 0 && line[k] === "\\"; k--) { - trailingBackslashes++; - } - const hasTrailingContinuation = trailingBackslashes % 2 === 1; - - // Shell continuation applies: - // - Outside quotes with trailing backslash - // - Inside double quotes with trailing backslash (not escaped) - // - NOT inside single quotes (backslash is literal there) - const isShellContinuation = hasTrailingContinuation && !inSingleQuote; - - if (isShellContinuation) { - // Remove the trailing backslash and continue to next line - result = result.slice(0, -1); - i++; - } else if (inSingleQuote || inDoubleQuote) { - // We're inside a quoted string - add newline and continue - result += "\n"; - i++; - } else { - // Line is complete (not in quotes, no continuation) - break; - } - } - - return { fullLine: result, endIndex: i }; -} - -/** - * Parse quoted arguments from a string - * Handles both single and double quoted strings - */ -function parseQuotedArgs(str: string): string[] { - const args: string[] = []; - let i = 0; - - while (i < str.length) { - // Skip whitespace - while (i < str.length && /\s/.test(str[i])) { - i++; - } - - if (i >= str.length) break; - - const quote = str[i]; - if (quote !== '"' && quote !== "'") { - // Unquoted argument - read until whitespace - let arg = ""; - while (i < str.length && !/\s/.test(str[i])) { - arg += str[i]; - i++; - } - args.push(arg); - continue; - } - - // Quoted argument - may have adjacent quotes like "a""b" which means "ab" - let arg = ""; - while (i < str.length && (str[i] === '"' || str[i] === "'")) { - const currentQuote = str[i]; - i++; // skip opening quote - while (i < str.length && str[i] !== currentQuote) { - if (str[i] === "\\" && i + 1 < str.length) { - // Handle escape sequences - arg += str[i] + str[i + 1]; - i += 2; - } else { - arg += str[i]; - i++; - } - } - i++; // skip closing quote - } - args.push(arg); - } - - return args; -} - -/** - * Unescape shell string escapes - */ -function unescapeString(str: string): string { - return str - .replace(/\\n/g, "\n") - .replace(/\\t/g, "\t") - .replace(/\\r/g, "\r") - .replace(/\\\\/g, "\\") - .replace(/\\"/g, '"') - .replace(/\\'/g, "'"); -} - -/** - * Unescape shell double-quote escapes in commands - * This mimics bash's double-quote expansion where: - * - \$ becomes $ (escaping the special meaning) - * - \\ becomes \ (escaped backslash) - * - \" becomes " - * - \` becomes ` - * - \ removes the backslash and newline (line continuation) - * - All other \X sequences are left as-is (\n, \t, etc. are NOT interpreted) - * - * Uses single-pass processing to avoid multi-level unescaping issues. - */ -function unescapeCommand(str: string): string { - let result = ""; - let i = 0; - - while (i < str.length) { - const char = str[i]; - - if (char === "\\" && i + 1 < str.length) { - const next = str[i + 1]; - // In bash double quotes, only these characters are escaped: $ ` " \ newline - if (next === "$" || next === "`" || next === '"' || next === "\\") { - result += next; - i += 2; - continue; - } - // \newline in double quotes removes both (line continuation) - if (next === "\n") { - i += 2; - continue; - } - } - - result += char; - i++; - } - - return result; -} - -/** - * Parse BusyBox test format - * - * Format: testing "description" "commands" "result" "infile" "stdin" - */ -export function parseBusyBoxTests( - content: string, - filePath: string, -): ParsedBusyBoxTestFile { - const fileName = filePath.split("/").pop() || filePath; - const lines = content.split("\n"); - const testCases: BusyBoxTestCase[] = []; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Skip comments and empty lines - if (line.trim().startsWith("#") || line.trim() === "") { - continue; - } - - // Look for testing "..." "..." "..." "..." "..." - // Handle multi-line tests with proper quote tracking - const { fullLine, endIndex } = joinTestLines(lines, i); - i = endIndex; - - const testMatch = fullLine.match(/^testing\s+"([^"]*)"\s+([\s\S]+)$/); - - if (!testMatch) { - continue; - } - - const description = testMatch[1]; - const rest = testMatch[2]; - - // Parse the remaining arguments - they're quoted strings - const args = parseQuotedArgs(rest); - - if (args.length < 4) { - continue; - } - - const [command, result, infile, stdin] = args; - - testCases.push({ - name: description, - // Unescape shell double-quote escapes (\$ -> $) but keep sed escapes (\n, \t) - command: unescapeCommand(command), - expectedOutput: unescapeString(result), - infile: unescapeString(infile), - stdin: unescapeString(stdin), - lineNumber: i + 1, - }); - } - - return { fileName, filePath, testCases }; -} diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index a98d8dfc..00000000 --- a/src/types.ts +++ /dev/null @@ -1,154 +0,0 @@ -import type { IFileSystem } from "./fs/interface.js"; -import type { ExecutionLimits } from "./limits.js"; -import type { SecureFetch } from "./network/index.js"; - -/** - * Lightweight interface for feature coverage tracking during fuzzing. - * Lives here to avoid circular dependencies between fuzzing → core modules. - */ -export interface FeatureCoverageWriter { - hit(feature: string): void; -} - -export interface ExecResult { - stdout: string; - stderr: string; - exitCode: number; - /** The final environment variables after execution (only set by BashEnv.exec) */ - env?: Record; -} - -/** Result from BashEnv.exec() - always includes env */ -export interface BashExecResult extends ExecResult { - env: Record; -} - -/** Options for exec calls within commands (internal API) */ -export interface CommandExecOptions { - /** Environment variables to merge into the exec state */ - env?: Record; - /** - * Working directory for the exec. - * Required to prevent bugs where subcommands run in the wrong directory. - * Always pass `ctx.cwd` from the calling command's context. - */ - cwd: string; -} - -/** - * Context provided to commands during execution. - * - * ## Field Availability - * - * **Always available (core fields):** - * - `fs`, `cwd`, `env`, `stdin` - * - * **Available when running via BashEnv interpreter:** - * - `exec` - For commands like `xargs`, `bash -c` that need to run subcommands - * - `getRegisteredCommands` - For the `help` command to list available commands - * - * **Conditionally available based on configuration:** - * - `fetch` - Only when `network` option is configured in BashEnv - * - `sleep` - Only when a custom sleep function is provided (e.g., for testing) - */ -/** - * Performance trace event for profiling command execution - */ -export interface TraceEvent { - /** Event category (e.g., "find", "grep") */ - category: string; - /** Event name (e.g., "readdir", "stat", "eval") */ - name: string; - /** Duration in milliseconds */ - durationMs: number; - /** Optional details (e.g., path, count) */ - details?: Record; -} - -/** - * Trace callback function for receiving performance events - */ -export type TraceCallback = (event: TraceEvent) => void; - -export interface CommandContext { - /** Virtual filesystem interface for file operations */ - fs: IFileSystem; - /** Current working directory */ - cwd: string; - /** Environment variables - uses Map to prevent prototype pollution */ - env: Map; - /** - * Exported environment variables only. - * Used by commands like printenv and env that should only show exported vars. - * In bash, only exported variables are passed to child processes. - */ - exportedEnv?: Record; - /** Standard input content */ - stdin: string; - /** - * Execution limits configuration. - * Available when running commands via BashEnv interpreter. - */ - limits?: Required; - /** - * Performance trace callback for profiling. - * If provided, commands emit timing events for analysis. - */ - trace?: TraceCallback; - /** - * Execute a subcommand (e.g., for `xargs`, `bash -c`). - * Available when running commands via BashEnv interpreter. - * - * @param command - The command string to execute - * @param options - Required options including `cwd` to prevent directory bugs - */ - exec?: (command: string, options: CommandExecOptions) => Promise; - /** - * Secure fetch function for network requests (e.g., for `curl`). - * Only available when `network` option is configured in BashEnv. - */ - fetch?: SecureFetch; - /** - * Returns names of all registered commands. - * Available when running commands via BashEnv interpreter. - * Used by the `help` command. - */ - getRegisteredCommands?: () => string[]; - /** - * Custom sleep implementation. - * If provided, used instead of real setTimeout. - * Useful for testing with mock clocks. - */ - sleep?: (ms: number) => Promise; - /** - * File descriptors map for here-docs and process substitution. - * Maps FD numbers to their content (e.g., 3 -> "content from 3<; - /** - * Whether xpg_echo shopt is enabled. - * When true, echo interprets backslash escapes by default (like echo -e). - */ - xpgEcho?: boolean; - /** - * Current command substitution nesting depth. - * Used to prevent stack exhaustion from deeply nested $(...). - */ - substitutionDepth?: number; - /** - * Feature coverage writer for fuzzing instrumentation. - * When provided, commands emit coverage hits for analysis. - */ - coverage?: FeatureCoverageWriter; -} - -export interface Command { - name: string; - execute(args: string[], ctx: CommandContext): Promise; -} - -export type CommandRegistry = Map; - -// Re-export IFileSystem for convenience -export type { IFileSystem }; diff --git a/src/utils/args.ts b/src/utils/args.ts deleted file mode 100644 index f648a505..00000000 --- a/src/utils/args.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Lightweight argument parser for command implementations. - * - * Handles common patterns: - * - Boolean flags: -n, --number - * - Combined short flags: -rn (same as -r -n) - * - Value options: -k VALUE, -kVALUE, --key=VALUE, --key VALUE - * - Positional arguments - * - Unknown option detection - */ - -import { unknownOption } from "../commands/help.js"; -import type { ExecResult } from "../types.js"; - -export type ArgType = "boolean" | "string" | "number"; - -export interface ArgDef { - /** Short form without dash, e.g., "n" for -n */ - short?: string; - /** Long form without dashes, e.g., "number" for --number */ - long?: string; - /** Type of the argument */ - type: ArgType; - /** Default value */ - default?: boolean | string | number; -} - -export interface ParsedArgs> { - /** Parsed flag/option values */ - flags: { - [K in keyof T]: T[K]["type"] extends "boolean" - ? boolean - : T[K]["default"] extends number | string - ? T[K]["type"] extends "number" - ? number - : string - : T[K]["type"] extends "number" - ? number | undefined - : string | undefined; - }; - /** Positional arguments (non-flag arguments) */ - positional: string[]; -} - -export type ParseResult> = - | { ok: true; result: ParsedArgs } - | { ok: false; error: ExecResult }; - -/** - * Parse command arguments according to the provided definitions. - * - * @param cmdName - Command name for error messages - * @param args - Arguments to parse - * @param defs - Argument definitions - * @returns Parsed arguments or error result - * - * @example - * const defs = { - * reverse: { short: "r", long: "reverse", type: "boolean" as const }, - * count: { short: "n", long: "lines", type: "number" as const, default: 10 }, - * }; - * const result = parseArgs("head", args, defs); - * if (!result.ok) return result.error; - * const { flags, positional } = result.result; - */ -export function parseArgs>( - cmdName: string, - args: string[], - defs: T, -): ParseResult { - // Build lookup maps: map short/long options to {name, type} - const shortToInfo = new Map(); - const longToInfo = new Map(); - - for (const [name, def] of Object.entries(defs)) { - const info = { name, type: def.type }; - if (def.short) shortToInfo.set(def.short, info); - if (def.long) longToInfo.set(def.long, info); - } - - // Initialize with defaults - // Boolean flags default to false, but string/number flags without - // explicit defaults remain undefined (allowing callers to detect if set) - // Use null-prototype to prevent prototype pollution - const flags: Record = - Object.create(null); - for (const [name, def] of Object.entries(defs)) { - if (def.default !== undefined) { - flags[name] = def.default; - } else if (def.type === "boolean") { - flags[name] = false; - } - // String and number types without defaults remain undefined - } - - const positional: string[] = []; - let stopParsing = false; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - if (stopParsing || !arg.startsWith("-") || arg === "-") { - positional.push(arg); - continue; - } - - if (arg === "--") { - stopParsing = true; - continue; - } - - if (arg.startsWith("--")) { - // Long option - const eqIndex = arg.indexOf("="); - let optName: string; - let optValue: string | undefined; - - if (eqIndex !== -1) { - optName = arg.slice(2, eqIndex); - optValue = arg.slice(eqIndex + 1); - } else { - optName = arg.slice(2); - } - - const info = longToInfo.get(optName); - if (!info) { - return { ok: false, error: unknownOption(cmdName, arg) }; - } - - const { name, type } = info; - if (type === "boolean") { - flags[name] = true; - } else { - // Need a value - if (optValue === undefined) { - if (i + 1 >= args.length) { - return { - ok: false, - error: { - stdout: "", - stderr: `${cmdName}: option '--${optName}' requires an argument\n`, - exitCode: 1, - }, - }; - } - optValue = args[++i]; - } - flags[name] = type === "number" ? parseInt(optValue, 10) : optValue; - } - } else { - // Short option(s) - const chars = arg.slice(1); - - for (let j = 0; j < chars.length; j++) { - const c = chars[j]; - const info = shortToInfo.get(c); - - if (!info) { - return { ok: false, error: unknownOption(cmdName, `-${c}`) }; - } - - const { name, type } = info; - if (type === "boolean") { - flags[name] = true; - } else { - // Value option - rest of string or next arg - let optValue: string; - if (j + 1 < chars.length) { - // Value is attached: -n10 - optValue = chars.slice(j + 1); - } else if (i + 1 < args.length) { - // Value is next arg: -n 10 - optValue = args[++i]; - } else { - return { - ok: false, - error: { - stdout: "", - stderr: `${cmdName}: option requires an argument -- '${c}'\n`, - exitCode: 1, - }, - }; - } - flags[name] = type === "number" ? parseInt(optValue, 10) : optValue; - break; // Rest of chars consumed as value - } - } - } - } - - return { - ok: true, - result: { - flags: flags as ParsedArgs["flags"], - positional, - }, - }; -} diff --git a/src/utils/constants.ts b/src/utils/constants.ts deleted file mode 100644 index 2033e1d5..00000000 --- a/src/utils/constants.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Shared constants for the bash environment. - */ - -/** - * Default batch size for parallel I/O operations. - * - * This value is used across multiple commands (find, ls, tree, du, etc.) to - * control how many filesystem operations are performed concurrently. A larger - * value provides more parallelism but uses more memory; a smaller value is - * more conservative. - * - * 100 is a good default that provides significant parallelism without - * overwhelming the system or using excessive memory. - */ -export const DEFAULT_BATCH_SIZE = 100; diff --git a/src/utils/file-reader.ts b/src/utils/file-reader.ts deleted file mode 100644 index ff9dd1bf..00000000 --- a/src/utils/file-reader.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * File reading utilities for command implementations. - * - * Provides common patterns for reading from files or stdin, - * including parallel batch reading for performance. - */ - -import type { CommandContext, ExecResult } from "../types.js"; -import { DEFAULT_BATCH_SIZE } from "./constants.js"; - -export interface ReadFilesOptions { - /** Command name for error messages */ - cmdName: string; - /** If true, "-" in file list means stdin */ - allowStdinMarker?: boolean; - /** If true, stop on first error. If false, collect errors and continue */ - stopOnError?: boolean; - /** Number of files to read in parallel (default: 100). Set to 1 for sequential. */ - batchSize?: number; -} - -export interface FileContent { - /** File name (or "-" for stdin, or "" if stdin with no files) */ - filename: string; - /** File content */ - content: string; -} - -export interface ReadFilesResult { - /** Successfully read files */ - files: FileContent[]; - /** Error messages (e.g., "cmd: file: No such file or directory\n") */ - stderr: string; - /** 0 if all files read successfully, 1 if any errors */ - exitCode: number; -} - -/** - * Read content from files or stdin. - * - * If files array is empty, reads from stdin. - * If files contains "-", reads stdin at that position. - * - * @example - * const result = await readFiles(ctx, files, { cmdName: "cat" }); - * if (result.exitCode !== 0 && options.stopOnError) { - * return { stdout: "", stderr: result.stderr, exitCode: result.exitCode }; - * } - * for (const { filename, content } of result.files) { - * // process content - * } - */ -export async function readFiles( - ctx: CommandContext, - files: string[], - options: ReadFilesOptions, -): Promise { - const { - cmdName, - allowStdinMarker = true, - stopOnError = false, - batchSize = DEFAULT_BATCH_SIZE, - } = options; - - // No files - read from stdin - if (files.length === 0) { - return { - files: [{ filename: "", content: ctx.stdin }], - stderr: "", - exitCode: 0, - }; - } - - const result: FileContent[] = []; - let stderr = ""; - let exitCode = 0; - - // Process files in parallel batches for better performance - for (let i = 0; i < files.length; i += batchSize) { - const batch = files.slice(i, i + batchSize); - const batchResults = await Promise.all( - batch.map(async (file) => { - if (allowStdinMarker && file === "-") { - return { filename: "-", content: ctx.stdin, error: null }; - } - try { - const filePath = ctx.fs.resolvePath(ctx.cwd, file); - // Use binary encoding to preserve all bytes (including non-UTF-8) - // This is important for piping binary data through commands like cat - const content = await ctx.fs.readFile(filePath, "binary"); - return { filename: file, content, error: null }; - } catch { - return { - filename: file, - content: "", - error: `${cmdName}: ${file}: No such file or directory\n`, - }; - } - }), - ); - - // Process results in order - for (const r of batchResults) { - if (r.error) { - stderr += r.error; - exitCode = 1; - if (stopOnError) { - return { files: result, stderr, exitCode }; - } - } else { - result.push({ filename: r.filename, content: r.content }); - } - } - } - - return { files: result, stderr, exitCode }; -} - -/** - * Read and concatenate all files into a single string. - * - * Useful for commands like sort and uniq that process all input together. - * - * @example - * const result = await readAndConcat(ctx, files, { cmdName: "sort" }); - * if (!result.ok) return result.error; - * const lines = result.content.split("\n"); - */ -export async function readAndConcat( - ctx: CommandContext, - files: string[], - options: { cmdName: string; allowStdinMarker?: boolean }, -): Promise<{ ok: true; content: string } | { ok: false; error: ExecResult }> { - const result = await readFiles(ctx, files, { - ...options, - stopOnError: true, - }); - - if (result.exitCode !== 0) { - return { - ok: false, - error: { stdout: "", stderr: result.stderr, exitCode: result.exitCode }, - }; - } - - const content = result.files.map((f) => f.content).join(""); - return { ok: true, content }; -} diff --git a/src/utils/glob.ts b/src/utils/glob.ts deleted file mode 100644 index afc18da1..00000000 --- a/src/utils/glob.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Shared glob pattern matching utilities. - * - * Used by grep, find, and other commands that need glob matching. - */ - -import { createUserRegex, type RegexLike } from "../regex/index.js"; - -// Cache compiled regexes for glob patterns (key: pattern + flags) -const globRegexCache = new Map(); - -export interface MatchGlobOptions { - /** Case-insensitive matching */ - ignoreCase?: boolean; - /** Strip surrounding quotes from pattern before matching */ - stripQuotes?: boolean; -} - -/** - * Match a filename against a glob pattern. - * - * Supports: - * - `*` matches any sequence of characters - * - `?` matches any single character - * - `[...]` character classes - * - * @param name - The filename to test - * @param pattern - The glob pattern - * @param options - Matching options - * @returns true if the name matches the pattern - */ -export function matchGlob( - name: string, - pattern: string, - options?: MatchGlobOptions | boolean, -): boolean { - // Support legacy signature: matchGlob(name, pattern, ignoreCase) - // @banned-pattern-ignore: options object with known structure (ignoreCase, stripQuotes, etc.) - const opts: MatchGlobOptions = - typeof options === "boolean" ? { ignoreCase: options } : (options ?? {}); - - let cleanPattern = pattern; - - // Strip surrounding quotes if requested - if (opts.stripQuotes) { - if ( - (cleanPattern.startsWith('"') && cleanPattern.endsWith('"')) || - (cleanPattern.startsWith("'") && cleanPattern.endsWith("'")) - ) { - cleanPattern = cleanPattern.slice(1, -1); - } - } - - // Build cache key - const cacheKey = opts.ignoreCase ? `i:${cleanPattern}` : cleanPattern; - let re = globRegexCache.get(cacheKey); - - if (!re) { - re = globToRegex(cleanPattern, opts.ignoreCase); - globRegexCache.set(cacheKey, re); - } - - return re.test(name); -} - -/** - * Convert a glob pattern to a RegExp. - */ -function globToRegex(pattern: string, ignoreCase?: boolean): RegexLike { - let regex = "^"; - - for (let i = 0; i < pattern.length; i++) { - const c = pattern[i]; - if (c === "*") { - regex += ".*"; - } else if (c === "?") { - regex += "."; - } else if (c === "[") { - // Character class - find closing bracket - let j = i + 1; - while (j < pattern.length && pattern[j] !== "]") j++; - regex += pattern.slice(i, j + 1); - i = j; - } else if ( - c === "." || - c === "+" || - c === "^" || - c === "$" || - c === "{" || - c === "}" || - c === "(" || - c === ")" || - c === "|" || - c === "\\" - ) { - regex += `\\${c}`; - } else { - regex += c; - } - } - - regex += "$"; - return createUserRegex(regex, ignoreCase ? "i" : ""); -} diff --git a/tsconfig.json b/tsconfig.json index 172a3fb4..f2228468 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,17 +1,34 @@ { "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "declaration": true, - "isolatedDeclarations": true, - "outDir": "./dist", - "rootDir": "./src", + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, "strict": true, + "noEmit": true, "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules", "app/api/agent/_agent-data"] } diff --git a/vitest.comparison.config.ts b/vitest.comparison.config.ts deleted file mode 100644 index a6cbb256..00000000 --- a/vitest.comparison.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - globals: true, - include: ["src/comparison-tests/**/*.test.ts"], - exclude: ["**/node_modules/**", "**/dist/**"], - setupFiles: ["src/comparison-tests/vitest.setup.ts"], - }, -}); diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index d5f4d01b..00000000 --- a/vitest.config.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - globals: true, - exclude: [ - "**/node_modules/**", - "**/dist/**", - "**/examples/**", - "**/.pnpm-store/**", - ], - pool: "threads", - isolate: false, - coverage: { - provider: "v8", - reporter: ["text", "html", "json-summary"], - reportsDirectory: "./coverage", - include: ["src/**/*.ts"], - exclude: [ - "src/**/*.test.ts", - "src/**/*.comparison.test.ts", - "src/spec-tests/**", - "src/comparison-tests/**", - "src/cli/**", - "src/agent-examples/**", - ], - }, - }, -}); diff --git a/vitest.unit.config.ts b/vitest.unit.config.ts deleted file mode 100644 index c54e290e..00000000 --- a/vitest.unit.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - globals: true, - include: ["src/**/*.test.ts"], - exclude: ["**/node_modules/**", "**/dist/**", "**/comparison-tests/**"], - }, -}); From 3f9cf70b172bda5b9f9598f4496cec51669d8497 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 9 Feb 2026 07:53:11 -0500 Subject: [PATCH 07/16] fix: resolve build errors from directory restructure - Fix package.json import path in terminal-content.ts (was ../../../../, now ../../) - Remove generate script that depended on legacy repo files (README.md, LICENSE, AGENTS.npm.md) - Remove pnpm generate from dev and build commands - terminal-content.ts is now a static file with baked-in content Co-Authored-By: Claude Opus 4.6 --- app/components/terminal-content.ts | 7 +- package.json | 5 +- scripts/generate-terminal-content.mjs | 95 --------------------------- 3 files changed, 4 insertions(+), 103 deletions(-) delete mode 100644 scripts/generate-terminal-content.mjs diff --git a/app/components/terminal-content.ts b/app/components/terminal-content.ts index 1f33bccc..3fc8aac3 100644 --- a/app/components/terminal-content.ts +++ b/app/components/terminal-content.ts @@ -1,12 +1,9 @@ -// Auto-generated by scripts/generate-terminal-content.mjs -// Do not edit manually - run "pnpm generate" to regenerate - -import pkg from "../../../../package.json"; +import pkg from "../../package.json"; export const version = pkg.version; // Command outputs -export const CMD_ABOUT = `just-bash v${pkg.version} +export const CMD_ABOUT = `recoup v${pkg.version} A TypeScript bash interpreter with in-memory filesystem. Designed for AI agents needing a secure, sandboxed environment. diff --git a/package.json b/package.json index b32531f0..35c54b89 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "generate": "node scripts/generate-terminal-content.mjs", "fetch-agent-data": "node scripts/fetch-agent-data.mjs", - "dev": "pnpm generate && next dev", - "build": "pnpm generate && pnpm fetch-agent-data && next build", + "dev": "next dev", + "build": "pnpm fetch-agent-data && next build", "start": "next start", "lint": "eslint" }, diff --git a/scripts/generate-terminal-content.mjs b/scripts/generate-terminal-content.mjs deleted file mode 100644 index 8fc27588..00000000 --- a/scripts/generate-terminal-content.mjs +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env node - -/** - * Generates terminal-content.ts from the main repo files. - * Run this before build to update the embedded content. - */ - -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(__dirname, "../../.."); -const outputFile = path.resolve( - __dirname, - "../app/components/terminal-content.ts" -); - -// Read repo files -const readme = fs.readFileSync(path.join(repoRoot, "README.md"), "utf-8"); -const license = fs.readFileSync(path.join(repoRoot, "LICENSE"), "utf-8"); -const packageJson = JSON.parse( - fs.readFileSync(path.join(repoRoot, "package.json"), "utf-8") -); -const agentsMd = fs.readFileSync(path.join(repoRoot, "AGENTS.npm.md"), "utf-8"); -const wtfIsThis = fs.readFileSync(path.resolve(__dirname, "../README.md"), "utf-8"); - -// Escape backticks and ${} for template literals -function escapeTemplate(str) { - return str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); -} - -// Generate simplified package.json -const simplifiedPackageJson = JSON.stringify( - { - name: packageJson.name, - version: packageJson.version, - description: packageJson.description, - repository: packageJson.repository, - homepage: packageJson.homepage, - type: packageJson.type, - main: packageJson.main, - types: packageJson.types, - author: packageJson.author, - license: packageJson.license, - }, - null, - 2 -); - -const content = `// Auto-generated by scripts/generate-terminal-content.mjs -// Do not edit manually - run "pnpm generate" to regenerate - -import pkg from "../../../../package.json"; - -export const version = pkg.version; - -// Command outputs -export const CMD_ABOUT = \`just-bash v\${pkg.version} -A TypeScript bash interpreter with in-memory filesystem. -Designed for AI agents needing a secure, sandboxed environment. - -Custom commands: - about About just-bash - install Installation instructions - github GitHub repository - -Or try any bash command: ls, cat, echo, grep, awk, jq, sed, etc. -Type 'help' for a list of all built-in commands. -\`; - -export const CMD_INSTALL = \`npm install just-bash - -Usage: - import { Bash } from "just-bash"; - const bash = new Bash(); - const result = await bash.exec("echo hello"); -\`; - -export const CMD_GITHUB = "https://github.com/vercel-labs/just-bash\\n"; - -// File contents (generated from repo) -export const FILE_README = \`${escapeTemplate(readme)}\`; - -export const FILE_LICENSE = \`${escapeTemplate(license)}\`; - -export const FILE_PACKAGE_JSON = \`${escapeTemplate(simplifiedPackageJson)}\`; - -export const FILE_AGENTS_MD = \`${escapeTemplate(agentsMd)}\`; - -export const FILE_WTF_IS_THIS = \`${escapeTemplate(wtfIsThis)}\`; -`; - -fs.writeFileSync(outputFile, content); -console.log(`Generated ${outputFile}`); From bea6e653891e2118ca6cd14cc522bad750fff2b1 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 9 Feb 2026 07:53:30 -0500 Subject: [PATCH 08/16] Remove unused pnpm-workspace.yaml Co-Authored-By: Claude Opus 4.6 --- pnpm-workspace.yaml | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 pnpm-workspace.yaml diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml deleted file mode 100644 index 581a9d5b..00000000 --- a/pnpm-workspace.yaml +++ /dev/null @@ -1,3 +0,0 @@ -ignoredBuiltDependencies: - - sharp - - unrs-resolver From 384f6259183f7bf82b822b28f05a18e1e1c3d6d4 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 9 Feb 2026 09:24:48 -0500 Subject: [PATCH 09/16] fix: defer sandbox cleanup until stream completes The `finally` block was calling `sandbox.stop()` immediately when `createAgentUIStreamResponse` returned, before the client consumed the stream. This killed the sandbox before any agent tool calls could execute, causing all bash commands to fail with "[Tool Error] Tool execution error". Now uses a TransformStream wrapper with `pipeTo().finally()` to ensure sandbox cleanup happens after streaming completes. Co-Authored-By: Claude Opus 4.6 --- app/api/agent/route.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/app/api/agent/route.ts b/app/api/agent/route.ts index 4d55f387..022fa314 100644 --- a/app/api/agent/route.ts +++ b/app/api/agent/route.ts @@ -104,12 +104,30 @@ export async function POST(req: Request) { stopWhen: stepCountIs(20), }); - return createAgentUIStreamResponse({ + const response = await createAgentUIStreamResponse({ agent, uiMessages: messages, }); - } finally { - // Don't await — let it clean up in background so response isn't delayed + + // Clean up sandbox after the stream finishes (not before). + // The original `finally` block killed the sandbox immediately when + // createAgentUIStreamResponse returned, before any tool calls ran. + const body = response.body; + if (body) { + const transform = new TransformStream(); + body.pipeTo(transform.writable).finally(() => { + sandbox.stop().catch(() => {}); + }); + return new Response(transform.readable, { + headers: response.headers, + status: response.status, + }); + } + + sandbox.stop().catch(() => {}); + return response; + } catch (error) { sandbox.stop().catch(() => {}); + throw error; } } From 2e10895daff5f8bc7c0e1112a3f59b016057c817 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Mon, 9 Feb 2026 14:28:55 -0300 Subject: [PATCH 10/16] feat: load sandbox from Recoup API snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: load sandbox from Recoup API snapshot Use snapshot-based sandbox creation when available, eliminating the file upload step. Falls back to fresh sandbox + file upload when no snapshot exists or on any failure. Co-Authored-By: Claude Opus 4.6 * refactor: extract sandbox creation into lib/sandbox/createNewSandbox.ts Move snapshot resolution, sandbox creation, and file upload fallback logic out of the route handler into a dedicated module (SRP). Co-Authored-By: Claude Opus 4.6 * feat: delegate sandbox creation to Recoup API Instead of fetching a snapshot_id and creating sandboxes directly, call POST /api/sandboxes on the Recoup API which handles snapshot resolution internally, then connect via Sandbox.get(). Co-Authored-By: Claude Opus 4.6 * fix: use env-based API URL to match Recoup-Chat pattern Switch between prod and test Recoup API based on NEXT_PUBLIC_VERCEL_ENV, matching the pattern in Recoup-Chat. Preview deployments now correctly hit test-recoup-api.vercel.app instead of production. Co-Authored-By: Claude Opus 4.6 * chore: add timing logs to identify latency bottleneck Log elapsed time for each step: POST /api/sandboxes, Sandbox.get, createBashTool, and fallback paths. Will help diagnose the 20s gap between sandbox creation and first bash execution. Co-Authored-By: Claude Opus 4.6 * perf: skip tool discovery in createBashTool with static prompt Tool discovery runs `ls /usr/bin ...` in the sandbox which takes ~34s due to sandbox warm-up after snapshot restore. Provide a static toolPrompt to skip discovery entirely — the available tools on a node22 sandbox are always the same. Co-Authored-By: Claude Opus 4.6 * fix: always upload source files regardless of sandbox source The snapshot contains the user's Recoup Sandbox content, not the just-bash/bash-tool source files. Write agent data files to the sandbox in all cases so the agent can explore the source code. Co-Authored-By: Claude Opus 4.6 * revert: skip file upload when using snapshot sandbox Only upload source files in the fallback path (fresh sandbox). Snapshot sandboxes should have the needed files pre-baked. Co-Authored-By: Claude Opus 4.6 * perf: warm up sandbox after snapshot restore Run a no-op command (true) right after Sandbox.get() to absorb the snapshot cold-start latency during setup rather than during the agent's first tool call. This eliminates the long pause users see after the first bash command appears on screen. Co-Authored-By: Claude Opus 4.6 * perf: create snapshot sandbox locally instead of via Sandbox.get Use GET /api/sandboxes to fetch the snapshot_id, then create the sandbox locally with Sandbox.create({ source: { type: "snapshot" } }) instead of POST + Sandbox.get(). This tests whether the 33s cold start was caused by cross-project Sandbox.get() vs local creation. Co-Authored-By: Claude Opus 4.6 * chore: remove dev timing logs and unused createSandbox Clean up all [timing] console.log statements and the warm-up command. Remove unused lib/recoup-api/createSandbox.ts (replaced by getSnapshotId). Co-Authored-By: Claude Opus 4.6 * feat: add /new page with fresh sandbox for performance comparison - /new page uses /api/agent/new which creates a fresh Sandbox.create() + file upload (no snapshot) for A/B comparison against snapshot path - Terminal and agent-command accept configurable agentEndpoint prop Co-Authored-By: Claude Opus 4.6 * fix: add git to static tool prompt Co-Authored-By: Claude Opus 4.6 * refactor: extract shared agent logic into lib to follow DRY Co-Authored-By: Claude Opus 4.6 * refactor: extract shared TerminalPage component for auth boilerplate Co-Authored-By: Claude Opus 4.6 * refactor: extract handleAgentRequest to DRY route boilerplate Co-Authored-By: Claude Opus 4.6 * refactor: centralize AGENT_DATA_DIR in constants Co-Authored-By: Claude Opus 4.6 * refactor: rename createNewSandbox to createSnapshotSandbox Co-Authored-By: Claude Opus 4.6 * refactor: extract readSourceFiles into its own lib file Co-Authored-By: Claude Opus 4.6 * refactor: use POST /api/sandboxes + Sandbox.get for snapshot sandbox Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- app/api/agent/new/route.ts | 7 + app/api/agent/route.ts | 136 +----------------- app/components/Terminal.tsx | 6 +- app/components/TerminalPage.tsx | 74 ++++++++++ .../terminal-parts/agent-command.ts | 3 +- app/new/page.tsx | 5 + app/page.tsx | 63 +------- lib/agent/constants.ts | 35 +++++ lib/agent/createAgentResponse.ts | 76 ++++++++++ lib/recoup-api/createSandbox.ts | 27 ++++ lib/sandbox/createFreshSandbox.ts | 13 ++ lib/sandbox/createSnapshotSandbox.ts | 20 +++ lib/sandbox/readSourceFiles.ts | 27 ++++ 13 files changed, 299 insertions(+), 193 deletions(-) create mode 100644 app/api/agent/new/route.ts create mode 100644 app/components/TerminalPage.tsx create mode 100644 app/new/page.tsx create mode 100644 lib/agent/constants.ts create mode 100644 lib/agent/createAgentResponse.ts create mode 100644 lib/recoup-api/createSandbox.ts create mode 100644 lib/sandbox/createFreshSandbox.ts create mode 100644 lib/sandbox/createSnapshotSandbox.ts create mode 100644 lib/sandbox/readSourceFiles.ts diff --git a/app/api/agent/new/route.ts b/app/api/agent/new/route.ts new file mode 100644 index 00000000..f9545cff --- /dev/null +++ b/app/api/agent/new/route.ts @@ -0,0 +1,7 @@ +import { createFreshSandbox } from "@/lib/sandbox/createFreshSandbox"; +import { handleAgentRequest } from "@/lib/agent/createAgentResponse"; +import { AGENT_DATA_DIR } from "@/lib/agent/constants"; + +export async function POST(req: Request) { + return handleAgentRequest(req, () => createFreshSandbox(AGENT_DATA_DIR)); +} diff --git a/app/api/agent/route.ts b/app/api/agent/route.ts index 022fa314..7cde8263 100644 --- a/app/api/agent/route.ts +++ b/app/api/agent/route.ts @@ -1,133 +1,9 @@ -import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from "ai"; -import { createBashTool } from "bash-tool"; -import { Sandbox } from "@vercel/sandbox"; -import { readdirSync, readFileSync } from "fs"; -import { dirname, join, relative } from "path"; -import { fileURLToPath } from "url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const AGENT_DATA_DIR = join(__dirname, "./_agent-data"); -const SANDBOX_CWD = "/vercel/sandbox"; - -const SYSTEM_INSTRUCTIONS = `You are an expert on just-bash, a TypeScript bash interpreter with an in-memory virtual filesystem. - -You have access to a real bash sandbox with the full source code of: -- just-bash/ - The main bash interpreter -- bash-tool/ - AI SDK tool for bash - -The source files are located at ${SANDBOX_CWD}. - -Refer to the README.md of the projects to answer questions about just-bash and bash-tool -themselves which is your main focus. Never talk about this demo implementation unless asked explicitly. - -Use the sandbox to explore the source code, demonstrate commands, and help users understand: -- How to use just-bash and bash-tool -- Bash scripting in general -- The implementation details of just-bash - -Key features of just-bash: -- Pure TypeScript implementation (no WASM dependencies) -- In-memory virtual filesystem -- Supports common bash commands: ls, cat, grep, awk, sed, jq, etc. -- Custom command support via defineCommand -- Network access control with URL allowlists - -Use cat to read files. Use head, tail to read parts of large files. - -Keep responses concise. You have access to a full Linux environment with standard tools.`; - -/** - * Recursively read all files from a directory, returning them in the format - * expected by Sandbox.writeFiles(). - */ -function readSourceFiles( - dir: string, - baseDir?: string -): Array<{ path: string; content: Buffer }> { - const base = baseDir ?? dir; - const files: Array<{ path: string; content: Buffer }> = []; - - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - // Skip node_modules and other large/irrelevant dirs - if (entry.name === "node_modules" || entry.name === ".git") continue; - files.push(...readSourceFiles(fullPath, base)); - } else { - const relPath = relative(base, fullPath); - files.push({ - path: join(SANDBOX_CWD, relPath), - content: readFileSync(fullPath), - }); - } - } - - return files; -} +import { createSnapshotSandbox } from "@/lib/sandbox/createSnapshotSandbox"; +import { handleAgentRequest } from "@/lib/agent/createAgentResponse"; +import { AGENT_DATA_DIR } from "@/lib/agent/constants"; export async function POST(req: Request) { - const authHeader = req.headers.get("Authorization"); - if (!authHeader?.startsWith("Bearer ")) { - return Response.json( - { error: "Unauthorized" }, - { status: 401 }, - ); - } - - const { messages } = await req.json(); - const lastUserMessage = messages - .filter((m: { role: string }) => m.role === "user") - .pop(); - console.log("Prompt:", lastUserMessage?.parts?.[0]?.text); - - const sandbox = await Sandbox.create(); - - try { - // Upload source files so the agent can explore them - const files = readSourceFiles(AGENT_DATA_DIR); - if (files.length > 0) { - await sandbox.writeFiles(files); - } - - const bashToolkit = await createBashTool({ - sandbox, - destination: SANDBOX_CWD, - }); - - // Create a fresh agent per request for proper streaming - const agent = new ToolLoopAgent({ - model: "claude-haiku-4-5", - instructions: SYSTEM_INSTRUCTIONS, - tools: { - bash: bashToolkit.tools.bash, - }, - stopWhen: stepCountIs(20), - }); - - const response = await createAgentUIStreamResponse({ - agent, - uiMessages: messages, - }); - - // Clean up sandbox after the stream finishes (not before). - // The original `finally` block killed the sandbox immediately when - // createAgentUIStreamResponse returned, before any tool calls ran. - const body = response.body; - if (body) { - const transform = new TransformStream(); - body.pipeTo(transform.writable).finally(() => { - sandbox.stop().catch(() => {}); - }); - return new Response(transform.readable, { - headers: response.headers, - status: response.status, - }); - } - - sandbox.stop().catch(() => {}); - return response; - } catch (error) { - sandbox.stop().catch(() => {}); - throw error; - } + return handleAgentRequest(req, (bearerToken) => + createSnapshotSandbox(bearerToken, AGENT_DATA_DIR), + ); } diff --git a/app/components/Terminal.tsx b/app/components/Terminal.tsx index 0eba43fb..77e7508f 100644 --- a/app/components/Terminal.tsx +++ b/app/components/Terminal.tsx @@ -32,8 +32,10 @@ function getTheme(isDark: boolean) { export default function TerminalComponent({ getAccessToken, + agentEndpoint, }: { getAccessToken: () => Promise; + agentEndpoint?: string; }) { const terminalRef = useRef(null); @@ -51,7 +53,7 @@ export default function TerminalComponent({ // Create commands const { aboutCmd, installCmd, githubCmd } = createStaticCommands(); - const agentCmd = createAgentCommand(term, getAccessToken); + const agentCmd = createAgentCommand(term, getAccessToken, agentEndpoint); // Files from DOM const files = { @@ -114,7 +116,7 @@ export default function TerminalComponent({ colorSchemeQuery.removeEventListener("change", onColorSchemeChange); term.dispose(); }; - }, [getAccessToken]); + }, [getAccessToken, agentEndpoint]); return (
{ + setMounted(true); + }, []); + + if (!mounted || !ready) { + return ( + <> + {children} + + + ); + } + + if (!authenticated) { + return ( + <> + {children} + +
+ +
+ + ); + } + + return ( + <> + {children} + + + + ); +} diff --git a/app/components/terminal-parts/agent-command.ts b/app/components/terminal-parts/agent-command.ts index b19aadcc..b829b2c3 100644 --- a/app/components/terminal-parts/agent-command.ts +++ b/app/components/terminal-parts/agent-command.ts @@ -20,6 +20,7 @@ function formatForTerminal(text: string): string { export function createAgentCommand( term: TerminalWriter, getAccessToken: () => Promise, + agentEndpoint = "/api/agent", ) { const agentMessages: UIMessage[] = []; let messageIdCounter = 0; @@ -62,7 +63,7 @@ export function createAgentCommand( }; } - const response = await fetch("/api/agent", { + const response = await fetch(agentEndpoint, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/app/new/page.tsx b/app/new/page.tsx new file mode 100644 index 00000000..16af2f34 --- /dev/null +++ b/app/new/page.tsx @@ -0,0 +1,5 @@ +import TerminalPage from "../components/TerminalPage"; + +export default function NewPage() { + return ; +} diff --git a/app/page.tsx b/app/page.tsx index cce7f4bd..2b61f47d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,9 +1,4 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { usePrivy } from "@privy-io/react-auth"; -import TerminalComponent from "./components/Terminal"; -import { TerminalData } from "./components/TerminalData"; +import TerminalPage from "./components/TerminalPage"; const NOSCRIPT_CONTENT = ` _ _ _ _ @@ -94,66 +89,14 @@ const NOSCRIPT_CONTENT = ` `; export default function Home() { - const [mounted, setMounted] = useState(false); - const { ready, authenticated, login, getAccessToken } = usePrivy(); - - useEffect(() => { - setMounted(true); - }, []); - - if (!mounted || !ready) { - return ( - <> - - - - ); - } - - if (!authenticated) { - return ( - <> - -
- -
- - ); - } - return ( - <> + - - - + ); } diff --git a/lib/agent/constants.ts b/lib/agent/constants.ts new file mode 100644 index 00000000..900abf02 --- /dev/null +++ b/lib/agent/constants.ts @@ -0,0 +1,35 @@ +import { join } from "path"; + +export const AGENT_DATA_DIR = join(process.cwd(), "app/api/agent/_agent-data"); + +export const SANDBOX_CWD = "/vercel/sandbox"; + +export const TOOL_PROMPT = + "Available tools: awk, cat, column, curl, cut, diff, find, git, grep, head, jq, join, nl, node, od, paste, printf, rev, sed, sort, split, strings, tail, tee, tr, uniq, wc, xargs, xxd, and more"; + +export const SYSTEM_INSTRUCTIONS = `You are an expert on just-bash, a TypeScript bash interpreter with an in-memory virtual filesystem. + +You have access to a real bash sandbox with the full source code of: +- just-bash/ - The main bash interpreter +- bash-tool/ - AI SDK tool for bash + +The source files are located at ${SANDBOX_CWD}. + +Refer to the README.md of the projects to answer questions about just-bash and bash-tool +themselves which is your main focus. Never talk about this demo implementation unless asked explicitly. + +Use the sandbox to explore the source code, demonstrate commands, and help users understand: +- How to use just-bash and bash-tool +- Bash scripting in general +- The implementation details of just-bash + +Key features of just-bash: +- Pure TypeScript implementation (no WASM dependencies) +- In-memory virtual filesystem +- Supports common bash commands: ls, cat, grep, awk, sed, jq, etc. +- Custom command support via defineCommand +- Network access control with URL allowlists + +Use cat to read files. Use head, tail to read parts of large files. + +Keep responses concise. You have access to a full Linux environment with standard tools.`; diff --git a/lib/agent/createAgentResponse.ts b/lib/agent/createAgentResponse.ts new file mode 100644 index 00000000..03c451e0 --- /dev/null +++ b/lib/agent/createAgentResponse.ts @@ -0,0 +1,76 @@ +import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from "ai"; +import { createBashTool } from "bash-tool"; +import { Sandbox } from "@vercel/sandbox"; +import { SANDBOX_CWD, SYSTEM_INSTRUCTIONS, TOOL_PROMPT } from "./constants"; + +type CreateSandbox = (bearerToken: string) => Promise; + +export async function handleAgentRequest( + req: Request, + createSandbox: CreateSandbox, +): Promise { + const authHeader = req.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const bearerToken = authHeader.slice("Bearer ".length); + + const { messages } = await req.json(); + const lastUserMessage = messages + .filter((m: { role: string }) => m.role === "user") + .pop(); + console.log("Prompt:", lastUserMessage?.parts?.[0]?.text); + + const sandbox = await createSandbox(bearerToken); + + return createAgentResponse(sandbox, messages); +} + +async function createAgentResponse( + sandbox: Sandbox, + messages: unknown[], +): Promise { + try { + const bashToolkit = await createBashTool({ + sandbox, + destination: SANDBOX_CWD, + promptOptions: { + toolPrompt: TOOL_PROMPT, + }, + }); + + const agent = new ToolLoopAgent({ + model: "claude-haiku-4-5", + instructions: SYSTEM_INSTRUCTIONS, + tools: { + bash: bashToolkit.tools.bash, + }, + stopWhen: stepCountIs(20), + }); + + const response = await createAgentUIStreamResponse({ + agent, + uiMessages: messages, + }); + + // Clean up sandbox after the stream finishes (not before). + const body = response.body; + if (body) { + const transform = new TransformStream(); + body.pipeTo(transform.writable).finally(() => { + sandbox.stop().catch(() => {}); + }); + return new Response(transform.readable, { + headers: response.headers, + status: response.status, + }); + } + + sandbox.stop().catch(() => {}); + return response; + } catch (error) { + sandbox.stop().catch(() => {}); + throw error; + } +} diff --git a/lib/recoup-api/createSandbox.ts b/lib/recoup-api/createSandbox.ts new file mode 100644 index 00000000..24e13208 --- /dev/null +++ b/lib/recoup-api/createSandbox.ts @@ -0,0 +1,27 @@ +const IS_PROD = process.env.NEXT_PUBLIC_VERCEL_ENV === "production"; +const RECOUP_API_URL = IS_PROD + ? "https://recoup-api.vercel.app" + : "https://test-recoup-api.vercel.app"; + +export async function createSandbox( + bearerToken: string, +): Promise { + try { + const response = await fetch(`${RECOUP_API_URL}/api/sandboxes`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${bearerToken}`, + }, + body: JSON.stringify({}), + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) return null; + + const data = await response.json(); + return data?.sandboxes?.[0]?.sandboxId ?? null; + } catch { + return null; + } +} diff --git a/lib/sandbox/createFreshSandbox.ts b/lib/sandbox/createFreshSandbox.ts new file mode 100644 index 00000000..8026447c --- /dev/null +++ b/lib/sandbox/createFreshSandbox.ts @@ -0,0 +1,13 @@ +import { Sandbox } from "@vercel/sandbox"; +import { readSourceFiles } from "./readSourceFiles"; + +export async function createFreshSandbox(agentDataDir: string): Promise { + const sandbox = await Sandbox.create(); + + const files = readSourceFiles(agentDataDir); + if (files.length > 0) { + await sandbox.writeFiles(files); + } + + return sandbox; +} diff --git a/lib/sandbox/createSnapshotSandbox.ts b/lib/sandbox/createSnapshotSandbox.ts new file mode 100644 index 00000000..935b753e --- /dev/null +++ b/lib/sandbox/createSnapshotSandbox.ts @@ -0,0 +1,20 @@ +import { Sandbox } from "@vercel/sandbox"; +import { createSandbox } from "@/lib/recoup-api/createSandbox"; +import { createFreshSandbox } from "./createFreshSandbox"; + +export async function createSnapshotSandbox( + bearerToken: string, + agentDataDir: string, +): Promise { + const sandboxId = await createSandbox(bearerToken); + + if (sandboxId) { + try { + return await Sandbox.get({ sandboxId }); + } catch (err) { + console.warn("Snapshot sandbox connection failed, falling back:", err); + } + } + + return createFreshSandbox(agentDataDir); +} diff --git a/lib/sandbox/readSourceFiles.ts b/lib/sandbox/readSourceFiles.ts new file mode 100644 index 00000000..3d0e5afc --- /dev/null +++ b/lib/sandbox/readSourceFiles.ts @@ -0,0 +1,27 @@ +import { readdirSync, readFileSync } from "fs"; +import { join, relative } from "path"; +import { SANDBOX_CWD } from "@/lib/agent/constants"; + +export function readSourceFiles( + dir: string, + baseDir?: string, +): Array<{ path: string; content: Buffer }> { + const base = baseDir ?? dir; + const files: Array<{ path: string; content: Buffer }> = []; + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === ".git") continue; + files.push(...readSourceFiles(fullPath, base)); + } else { + const relPath = relative(base, fullPath); + files.push({ + path: join(SANDBOX_CWD, relPath), + content: readFileSync(fullPath), + }); + } + } + + return files; +} From fac9d699194ac03a28d543715ea7006c4d3d8de3 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Mon, 9 Feb 2026 16:07:15 -0300 Subject: [PATCH 11/16] feat: save sandbox snapshot before stopping (#6) * feat: save sandbox snapshot before stopping after agent session Replicates the snapshot-saving pattern from Recoup-Tasks so sandbox state is preserved between sessions. After the agent stream finishes, we call sandbox.snapshot() and PATCH /api/sandboxes with the snapshotId before calling sandbox.stop(). Also adds CLAUDE.md with project-specific guidance. Co-Authored-By: Claude Opus 4.6 * refactor: extract handleAgentRequest into its own file (SRP) Co-Authored-By: Claude Opus 4.6 * fix: use next/server after() to keep function alive for snapshot save The previous fire-and-forget pattern in .finally() was racing against Vercel's serverless function shutdown. The function would terminate before sandbox.snapshot() and the PATCH call could complete, so snapshots were never persisted. Using after() tells Vercel to keep the function alive until the snapshot save finishes. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- CLAUDE.md | 67 +++++++++++++++++++++++++ app/api/agent/new/route.ts | 2 +- app/api/agent/route.ts | 2 +- lib/agent/createAgentResponse.ts | 48 ++++++++---------- lib/agent/handleAgentRequest.ts | 26 ++++++++++ lib/recoup-api/updateAccountSnapshot.ts | 28 +++++++++++ lib/sandbox/saveSnapshot.ts | 14 ++++++ 7 files changed, 157 insertions(+), 30 deletions(-) create mode 100644 CLAUDE.md create mode 100644 lib/agent/handleAgentRequest.ts create mode 100644 lib/recoup-api/updateAccountSnapshot.ts create mode 100644 lib/sandbox/saveSnapshot.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..5a93df98 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,67 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Git Workflow + +**Always commit and push changes after completing a task.** Follow these rules: + +1. After making code changes, always commit with a descriptive message +2. Push commits to the current feature branch +3. **NEVER push directly to `main`** - always use feature branches and PRs +4. Before pushing, verify the current branch is not `main` +5. **Open PRs against the `main` branch** +6. After pushing, check if a PR exists for the branch. If not, create one with `gh pr create --base main` +7. **After creating a PR, always wait for explicit user approval before merging.** Never merge PRs autonomously. + +### Starting a New Task + +Checkout main, pull latest, and create your feature branch from there: + +```bash +git checkout main && git pull origin main && git checkout -b +``` + +## Build Commands + +```bash +pnpm install # Install dependencies +pnpm dev # Start dev server +pnpm build # Fetch agent data + production build +pnpm lint # Run ESLint +``` + +## Architecture + +- **Next.js 16** with App Router, React 19 +- `app/` - Pages and API routes + - `app/api/agent/` - AI agent endpoint (Claude Haiku 4.5 via ToolLoopAgent) + - `app/api/fs/` - File serving endpoint + - `app/components/` - Terminal UI components + - `app/components/lite-terminal/` - Custom terminal emulator with ANSI support + - `app/components/terminal-parts/` - Terminal commands, input handling, markdown +- `lib/` - Core business logic: + - `lib/agent/` - AI agent configuration (system instructions, response handling) + - `lib/recoup-api/` - Recoup-API integration (sandbox creation, snapshot persistence) + - `lib/sandbox/` - Vercel Sandbox management (create, restore, snapshot) + +## Key Technologies + +- **AI**: Vercel AI SDK (`ai` package), ToolLoopAgent with Claude Haiku 4.5 +- **Terminal**: `just-bash` (TypeScript bash interpreter), custom `LiteTerminal` emulator +- **Sandbox**: `@vercel/sandbox` for isolated execution environments +- **Auth**: Privy (`@privy-io/react-auth`) +- **Styling**: Tailwind CSS 4, Geist design system + +## Code Principles + +- **SRP (Single Responsibility Principle)**: One exported function per file +- **DRY (Don't Repeat Yourself)**: Extract shared logic into reusable utilities +- **KISS (Keep It Simple)**: Prefer simple solutions over clever ones +- **YAGNI**: Don't build for hypothetical future needs +- **File Organization**: Domain-specific directories (e.g., `lib/sandbox/`, `lib/recoup-api/`) + +## Environment Variables + +- `NEXT_PUBLIC_PRIVY_APP_ID` - Privy authentication +- `NEXT_PUBLIC_VERCEL_ENV` - Environment detection (`production` vs other) for API URL routing diff --git a/app/api/agent/new/route.ts b/app/api/agent/new/route.ts index f9545cff..63f0ba43 100644 --- a/app/api/agent/new/route.ts +++ b/app/api/agent/new/route.ts @@ -1,5 +1,5 @@ import { createFreshSandbox } from "@/lib/sandbox/createFreshSandbox"; -import { handleAgentRequest } from "@/lib/agent/createAgentResponse"; +import { handleAgentRequest } from "@/lib/agent/handleAgentRequest"; import { AGENT_DATA_DIR } from "@/lib/agent/constants"; export async function POST(req: Request) { diff --git a/app/api/agent/route.ts b/app/api/agent/route.ts index 7cde8263..6aa0cb41 100644 --- a/app/api/agent/route.ts +++ b/app/api/agent/route.ts @@ -1,5 +1,5 @@ import { createSnapshotSandbox } from "@/lib/sandbox/createSnapshotSandbox"; -import { handleAgentRequest } from "@/lib/agent/createAgentResponse"; +import { handleAgentRequest } from "@/lib/agent/handleAgentRequest"; import { AGENT_DATA_DIR } from "@/lib/agent/constants"; export async function POST(req: Request) { diff --git a/lib/agent/createAgentResponse.ts b/lib/agent/createAgentResponse.ts index 03c451e0..f5e0e9a3 100644 --- a/lib/agent/createAgentResponse.ts +++ b/lib/agent/createAgentResponse.ts @@ -1,35 +1,14 @@ import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from "ai"; import { createBashTool } from "bash-tool"; import { Sandbox } from "@vercel/sandbox"; +import { after } from "next/server"; import { SANDBOX_CWD, SYSTEM_INSTRUCTIONS, TOOL_PROMPT } from "./constants"; +import { saveSnapshot } from "@/lib/sandbox/saveSnapshot"; -type CreateSandbox = (bearerToken: string) => Promise; - -export async function handleAgentRequest( - req: Request, - createSandbox: CreateSandbox, -): Promise { - const authHeader = req.headers.get("Authorization"); - if (!authHeader?.startsWith("Bearer ")) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const bearerToken = authHeader.slice("Bearer ".length); - - const { messages } = await req.json(); - const lastUserMessage = messages - .filter((m: { role: string }) => m.role === "user") - .pop(); - console.log("Prompt:", lastUserMessage?.parts?.[0]?.text); - - const sandbox = await createSandbox(bearerToken); - - return createAgentResponse(sandbox, messages); -} - -async function createAgentResponse( +export async function createAgentResponse( sandbox: Sandbox, messages: unknown[], + bearerToken: string, ): Promise { try { const bashToolkit = await createBashTool({ @@ -58,19 +37,32 @@ async function createAgentResponse( const body = response.body; if (body) { const transform = new TransformStream(); - body.pipeTo(transform.writable).finally(() => { + const pipePromise = body.pipeTo(transform.writable); + + // Use after() so Vercel keeps the function alive until + // the snapshot save completes after streaming ends. + after(async () => { + await pipePromise.catch(() => {}); + await saveSnapshot(sandbox, bearerToken); sandbox.stop().catch(() => {}); }); + return new Response(transform.readable, { headers: response.headers, status: response.status, }); } - sandbox.stop().catch(() => {}); + after(async () => { + await saveSnapshot(sandbox, bearerToken); + sandbox.stop().catch(() => {}); + }); return response; } catch (error) { - sandbox.stop().catch(() => {}); + after(async () => { + await saveSnapshot(sandbox, bearerToken); + sandbox.stop().catch(() => {}); + }); throw error; } } diff --git a/lib/agent/handleAgentRequest.ts b/lib/agent/handleAgentRequest.ts new file mode 100644 index 00000000..ea19cbe6 --- /dev/null +++ b/lib/agent/handleAgentRequest.ts @@ -0,0 +1,26 @@ +import { Sandbox } from "@vercel/sandbox"; +import { createAgentResponse } from "./createAgentResponse"; + +type CreateSandbox = (bearerToken: string) => Promise; + +export async function handleAgentRequest( + req: Request, + createSandbox: CreateSandbox, +): Promise { + const authHeader = req.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const bearerToken = authHeader.slice("Bearer ".length); + + const { messages } = await req.json(); + const lastUserMessage = messages + .filter((m: { role: string }) => m.role === "user") + .pop(); + console.log("Prompt:", lastUserMessage?.parts?.[0]?.text); + + const sandbox = await createSandbox(bearerToken); + + return createAgentResponse(sandbox, messages, bearerToken); +} diff --git a/lib/recoup-api/updateAccountSnapshot.ts b/lib/recoup-api/updateAccountSnapshot.ts new file mode 100644 index 00000000..19712984 --- /dev/null +++ b/lib/recoup-api/updateAccountSnapshot.ts @@ -0,0 +1,28 @@ +const IS_PROD = process.env.NEXT_PUBLIC_VERCEL_ENV === "production"; +const RECOUP_API_URL = IS_PROD + ? "https://recoup-api.vercel.app" + : "https://test-recoup-api.vercel.app"; + +export async function updateAccountSnapshot( + bearerToken: string, + snapshotId: string, +): Promise { + try { + const response = await fetch(`${RECOUP_API_URL}/api/sandboxes`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${bearerToken}`, + }, + body: JSON.stringify({ snapshotId }), + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.warn("Failed to update account snapshot:", response.status, errorText); + } + } catch (err) { + console.warn("Error updating account snapshot:", err); + } +} diff --git a/lib/sandbox/saveSnapshot.ts b/lib/sandbox/saveSnapshot.ts new file mode 100644 index 00000000..68eab9c1 --- /dev/null +++ b/lib/sandbox/saveSnapshot.ts @@ -0,0 +1,14 @@ +import { Sandbox } from "@vercel/sandbox"; +import { updateAccountSnapshot } from "@/lib/recoup-api/updateAccountSnapshot"; + +export async function saveSnapshot( + sandbox: Sandbox, + bearerToken: string, +): Promise { + try { + const result = await sandbox.snapshot(); + await updateAccountSnapshot(bearerToken, result.snapshotId); + } catch (err) { + console.warn("Failed to save snapshot:", err); + } +} From 3204e623534123d4b8d8bd70530b13182408d113 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Thu, 12 Feb 2026 11:44:45 -0300 Subject: [PATCH 12/16] feat: check sandbox snapshot on login, trigger setup if missing (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: check for sandbox snapshot on login and trigger setup if missing Adds useSetupSandbox hook that runs once after Privy authentication. Checks GET /api/sandboxes for existing snapshots, and if none exist, fires POST /api/sandboxes/setup in the background to provision a GitHub repo + snapshot before the user starts using the terminal. Co-Authored-By: Claude Opus 4.6 * refactor: use usePrivy directly inside useSetupSandbox hook Co-Authored-By: Claude Opus 4.6 * fix: use NEXT_PUBLIC_VERCEL_ENV for Recoup API URL Matches the existing pattern in lib/recoup-api/ — uses production URL in prod and test URL otherwise. Co-Authored-By: Claude Opus 4.6 * refactor: extract RECOUP_API_URL into lib/consts.ts Deduplicates the env-based URL logic from three files into a single shared constant. Co-Authored-By: Claude Opus 4.6 * fix: check for snapshot_id and github_repo before skipping setup Co-Authored-By: Claude Opus 4.6 * fix: read snapshot_id and github_repo from top-level response These are top-level fields on the GET /api/sandboxes response, not nested inside the sandboxes array. Co-Authored-By: Claude Opus 4.6 * refactor: extract fetch calls into getSandboxes and setupSandbox libs Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- app/components/TerminalPage.tsx | 2 ++ app/hooks/useSetupSandbox.ts | 29 +++++++++++++++++++++++++ lib/consts.ts | 5 +++++ lib/recoup-api/createSandbox.ts | 5 +---- lib/recoup-api/getSandboxes.ts | 11 ++++++++++ lib/recoup-api/setupSandbox.ts | 8 +++++++ lib/recoup-api/updateAccountSnapshot.ts | 5 +---- 7 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 app/hooks/useSetupSandbox.ts create mode 100644 lib/consts.ts create mode 100644 lib/recoup-api/getSandboxes.ts create mode 100644 lib/recoup-api/setupSandbox.ts diff --git a/app/components/TerminalPage.tsx b/app/components/TerminalPage.tsx index 1f1a426a..cbebe3e5 100644 --- a/app/components/TerminalPage.tsx +++ b/app/components/TerminalPage.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, ReactNode } from "react"; import { usePrivy } from "@privy-io/react-auth"; import TerminalComponent from "./Terminal"; import { TerminalData } from "./TerminalData"; +import { useSetupSandbox } from "../hooks/useSetupSandbox"; export default function TerminalPage({ agentEndpoint, @@ -14,6 +15,7 @@ export default function TerminalPage({ }) { const [mounted, setMounted] = useState(false); const { ready, authenticated, login, getAccessToken } = usePrivy(); + useSetupSandbox(); useEffect(() => { setMounted(true); diff --git a/app/hooks/useSetupSandbox.ts b/app/hooks/useSetupSandbox.ts new file mode 100644 index 00000000..af498b72 --- /dev/null +++ b/app/hooks/useSetupSandbox.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef } from "react"; +import { usePrivy } from "@privy-io/react-auth"; +import { getSandboxes } from "@/lib/recoup-api/getSandboxes"; +import { setupSandbox } from "@/lib/recoup-api/setupSandbox"; + +export function useSetupSandbox() { + const { authenticated, getAccessToken } = usePrivy(); + const hasRun = useRef(false); + + useEffect(() => { + if (!authenticated || hasRun.current) return; + hasRun.current = true; + + (async () => { + try { + const token = await getAccessToken(); + if (!token) return; + + const data = await getSandboxes(token); + if (!data) return; + if (data.snapshot_id && data.github_repo) return; + + setupSandbox(token); + } catch { + // Silent — background provisioning only + } + })(); + }, [authenticated, getAccessToken]); +} diff --git a/lib/consts.ts b/lib/consts.ts new file mode 100644 index 00000000..c667cf3f --- /dev/null +++ b/lib/consts.ts @@ -0,0 +1,5 @@ +const IS_PROD = process.env.NEXT_PUBLIC_VERCEL_ENV === "production"; + +export const RECOUP_API_URL = IS_PROD + ? "https://recoup-api.vercel.app" + : "https://test-recoup-api.vercel.app"; diff --git a/lib/recoup-api/createSandbox.ts b/lib/recoup-api/createSandbox.ts index 24e13208..56ec3b35 100644 --- a/lib/recoup-api/createSandbox.ts +++ b/lib/recoup-api/createSandbox.ts @@ -1,7 +1,4 @@ -const IS_PROD = process.env.NEXT_PUBLIC_VERCEL_ENV === "production"; -const RECOUP_API_URL = IS_PROD - ? "https://recoup-api.vercel.app" - : "https://test-recoup-api.vercel.app"; +import { RECOUP_API_URL } from "@/lib/consts"; export async function createSandbox( bearerToken: string, diff --git a/lib/recoup-api/getSandboxes.ts b/lib/recoup-api/getSandboxes.ts new file mode 100644 index 00000000..fe7569c7 --- /dev/null +++ b/lib/recoup-api/getSandboxes.ts @@ -0,0 +1,11 @@ +import { RECOUP_API_URL } from "@/lib/consts"; + +export async function getSandboxes(bearerToken: string) { + const response = await fetch(`${RECOUP_API_URL}/api/sandboxes`, { + headers: { Authorization: `Bearer ${bearerToken}` }, + }); + + if (!response.ok) return null; + + return response.json(); +} diff --git a/lib/recoup-api/setupSandbox.ts b/lib/recoup-api/setupSandbox.ts new file mode 100644 index 00000000..7f193eda --- /dev/null +++ b/lib/recoup-api/setupSandbox.ts @@ -0,0 +1,8 @@ +import { RECOUP_API_URL } from "@/lib/consts"; + +export function setupSandbox(bearerToken: string) { + fetch(`${RECOUP_API_URL}/api/sandboxes/setup`, { + method: "POST", + headers: { Authorization: `Bearer ${bearerToken}` }, + }); +} diff --git a/lib/recoup-api/updateAccountSnapshot.ts b/lib/recoup-api/updateAccountSnapshot.ts index 19712984..b2bb7626 100644 --- a/lib/recoup-api/updateAccountSnapshot.ts +++ b/lib/recoup-api/updateAccountSnapshot.ts @@ -1,7 +1,4 @@ -const IS_PROD = process.env.NEXT_PUBLIC_VERCEL_ENV === "production"; -const RECOUP_API_URL = IS_PROD - ? "https://recoup-api.vercel.app" - : "https://test-recoup-api.vercel.app"; +import { RECOUP_API_URL } from "@/lib/consts"; export async function updateAccountSnapshot( bearerToken: string, From 9c752be34907c8b0a7145cc98156df3de1008579 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:04:14 -0500 Subject: [PATCH 13/16] refactor: make AGENTS.md source of truth, CLAUDE.md as symlink --- AGENTS.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 68 +------------------------------------------------------ 2 files changed, 68 insertions(+), 67 deletions(-) create mode 100644 AGENTS.md mode change 100644 => 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..5a93df98 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,67 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Git Workflow + +**Always commit and push changes after completing a task.** Follow these rules: + +1. After making code changes, always commit with a descriptive message +2. Push commits to the current feature branch +3. **NEVER push directly to `main`** - always use feature branches and PRs +4. Before pushing, verify the current branch is not `main` +5. **Open PRs against the `main` branch** +6. After pushing, check if a PR exists for the branch. If not, create one with `gh pr create --base main` +7. **After creating a PR, always wait for explicit user approval before merging.** Never merge PRs autonomously. + +### Starting a New Task + +Checkout main, pull latest, and create your feature branch from there: + +```bash +git checkout main && git pull origin main && git checkout -b +``` + +## Build Commands + +```bash +pnpm install # Install dependencies +pnpm dev # Start dev server +pnpm build # Fetch agent data + production build +pnpm lint # Run ESLint +``` + +## Architecture + +- **Next.js 16** with App Router, React 19 +- `app/` - Pages and API routes + - `app/api/agent/` - AI agent endpoint (Claude Haiku 4.5 via ToolLoopAgent) + - `app/api/fs/` - File serving endpoint + - `app/components/` - Terminal UI components + - `app/components/lite-terminal/` - Custom terminal emulator with ANSI support + - `app/components/terminal-parts/` - Terminal commands, input handling, markdown +- `lib/` - Core business logic: + - `lib/agent/` - AI agent configuration (system instructions, response handling) + - `lib/recoup-api/` - Recoup-API integration (sandbox creation, snapshot persistence) + - `lib/sandbox/` - Vercel Sandbox management (create, restore, snapshot) + +## Key Technologies + +- **AI**: Vercel AI SDK (`ai` package), ToolLoopAgent with Claude Haiku 4.5 +- **Terminal**: `just-bash` (TypeScript bash interpreter), custom `LiteTerminal` emulator +- **Sandbox**: `@vercel/sandbox` for isolated execution environments +- **Auth**: Privy (`@privy-io/react-auth`) +- **Styling**: Tailwind CSS 4, Geist design system + +## Code Principles + +- **SRP (Single Responsibility Principle)**: One exported function per file +- **DRY (Don't Repeat Yourself)**: Extract shared logic into reusable utilities +- **KISS (Keep It Simple)**: Prefer simple solutions over clever ones +- **YAGNI**: Don't build for hypothetical future needs +- **File Organization**: Domain-specific directories (e.g., `lib/sandbox/`, `lib/recoup-api/`) + +## Environment Variables + +- `NEXT_PUBLIC_PRIVY_APP_ID` - Privy authentication +- `NEXT_PUBLIC_VERCEL_ENV` - Environment detection (`production` vs other) for API URL routing diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 5a93df98..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,67 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Git Workflow - -**Always commit and push changes after completing a task.** Follow these rules: - -1. After making code changes, always commit with a descriptive message -2. Push commits to the current feature branch -3. **NEVER push directly to `main`** - always use feature branches and PRs -4. Before pushing, verify the current branch is not `main` -5. **Open PRs against the `main` branch** -6. After pushing, check if a PR exists for the branch. If not, create one with `gh pr create --base main` -7. **After creating a PR, always wait for explicit user approval before merging.** Never merge PRs autonomously. - -### Starting a New Task - -Checkout main, pull latest, and create your feature branch from there: - -```bash -git checkout main && git pull origin main && git checkout -b -``` - -## Build Commands - -```bash -pnpm install # Install dependencies -pnpm dev # Start dev server -pnpm build # Fetch agent data + production build -pnpm lint # Run ESLint -``` - -## Architecture - -- **Next.js 16** with App Router, React 19 -- `app/` - Pages and API routes - - `app/api/agent/` - AI agent endpoint (Claude Haiku 4.5 via ToolLoopAgent) - - `app/api/fs/` - File serving endpoint - - `app/components/` - Terminal UI components - - `app/components/lite-terminal/` - Custom terminal emulator with ANSI support - - `app/components/terminal-parts/` - Terminal commands, input handling, markdown -- `lib/` - Core business logic: - - `lib/agent/` - AI agent configuration (system instructions, response handling) - - `lib/recoup-api/` - Recoup-API integration (sandbox creation, snapshot persistence) - - `lib/sandbox/` - Vercel Sandbox management (create, restore, snapshot) - -## Key Technologies - -- **AI**: Vercel AI SDK (`ai` package), ToolLoopAgent with Claude Haiku 4.5 -- **Terminal**: `just-bash` (TypeScript bash interpreter), custom `LiteTerminal` emulator -- **Sandbox**: `@vercel/sandbox` for isolated execution environments -- **Auth**: Privy (`@privy-io/react-auth`) -- **Styling**: Tailwind CSS 4, Geist design system - -## Code Principles - -- **SRP (Single Responsibility Principle)**: One exported function per file -- **DRY (Don't Repeat Yourself)**: Extract shared logic into reusable utilities -- **KISS (Keep It Simple)**: Prefer simple solutions over clever ones -- **YAGNI**: Don't build for hypothetical future needs -- **File Organization**: Domain-specific directories (e.g., `lib/sandbox/`, `lib/recoup-api/`) - -## Environment Variables - -- `NEXT_PUBLIC_PRIVY_APP_ID` - Privy authentication -- `NEXT_PUBLIC_VERCEL_ENV` - Environment detection (`production` vs other) for API URL routing diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From af0d4845a6616ad7e389ede432790e7c98f56176 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:12:54 -0500 Subject: [PATCH 14/16] fix: rename header to Agent Instructions --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 5a93df98..0ad1c50b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# CLAUDE.md +# Agent Instructions This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. From 4489e880d908b2430c69fdcfef1966a9cea3b4e1 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:01:26 -0500 Subject: [PATCH 15/16] fix: use inclusive agent naming in AGENTS.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 0ad1c50b..8e6b2cbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Agent Instructions -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to coding agents like Claude Code (claude.ai/code) and OpenCode when working with code in this repository. ## Git Workflow From e85f28002566b72cd5e9bdcf881bf9ffd0cde716 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Sat, 21 Mar 2026 01:59:45 +0000 Subject: [PATCH 16/16] feat: update Next.js to 16.2.1 and eslint-config-next MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrades Next.js from 16.2.0-canary.26 to 16.2.1 (stable release). No breaking changes per release notes — promotes from canary to stable. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 4 +- pnpm-lock.yaml | 174 +++++++++++++++++++++++++++++-------------------- 2 files changed, 107 insertions(+), 71 deletions(-) diff --git a/package.json b/package.json index 35c54b89..d432c678 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "bash-tool": "^1.3.13", "geist": "^1.5.1", "just-bash": "^2.9.5", - "next": "16.2.0-canary.26", + "next": "16.2.1", "react": "19.2.3", "react-dom": "19.2.3" }, @@ -27,7 +27,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.1.6", + "eslint-config-next": "16.2.1", "fast-check": "^4.5.3", "tailwindcss": "^4", "typescript": "^5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4d775c7..743ec405 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ importers: version: 3.13.1(@solana-program/system@0.10.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)))(@solana-program/token@0.9.0(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)))(@solana/kit@5.5.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(@solana/sysvars@5.5.1(typescript@5.9.3))(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.3))(@types/react@19.2.10)(bufferutil@4.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(utf-8-validate@5.0.10)(zod@4.3.6) '@vercel/analytics': specifier: ^1.6.1 - version: 1.6.1(next@16.2.0-canary.26(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 1.6.1(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) '@vercel/sandbox': specifier: ^1.4.1 version: 1.4.1 @@ -25,13 +25,13 @@ importers: version: 1.3.13(@vercel/sandbox@1.4.1)(ai@6.0.66(zod@4.3.6))(just-bash@2.9.5(bufferutil@4.1.0)(utf-8-validate@5.0.10)) geist: specifier: ^1.5.1 - version: 1.5.1(next@16.2.0-canary.26(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) + version: 1.5.1(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) just-bash: specifier: ^2.9.5 version: 2.9.5(bufferutil@4.1.0)(utf-8-validate@5.0.10) next: - specifier: 16.2.0-canary.26 - version: 16.2.0-canary.26(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: 16.2.1 + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: specifier: 19.2.3 version: 19.2.3 @@ -55,8 +55,8 @@ importers: specifier: ^9 version: 9.39.2(jiti@2.6.1) eslint-config-next: - specifier: 16.1.6 - version: 16.1.6(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + specifier: 16.2.1 + version: 16.2.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) fast-check: specifier: ^4.5.3 version: 4.5.3 @@ -355,89 +355,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -597,56 +613,60 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@next/env@16.2.0-canary.26': - resolution: {integrity: sha512-Y34nRhuzbm6lfwbr+5sUN0f322vDc1Q9zBSx7+rtFN0RJP6degxPnDk4/hjPrG4XhUPnByl4t/Jr96+FR4PWoQ==} + '@next/env@16.2.1': + resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==} - '@next/eslint-plugin-next@16.1.6': - resolution: {integrity: sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==} + '@next/eslint-plugin-next@16.2.1': + resolution: {integrity: sha512-r0epZGo24eT4g08jJlg2OEryBphXqO8aL18oajoTKLzHJ6jVr6P6FI58DLMug04MwD3j8Fj0YK0slyzneKVyzA==} - '@next/swc-darwin-arm64@16.2.0-canary.26': - resolution: {integrity: sha512-5pmf1JP0ShtVWYiyXKv7YKchAO03Cul+QWXIHGth+iGmbZLGvxe80lLx3IH+DCMcsw4nq07y9X1eSvlnn5pRSw==} + '@next/swc-darwin-arm64@16.2.1': + resolution: {integrity: sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.0-canary.26': - resolution: {integrity: sha512-a54govkfQ66tpyHOukvsTrSulClHvVBR22XU54qwK75g+phfYgKhnEbCY3XJRdsE0jLySQijqzMOIBjiP8lAeg==} + '@next/swc-darwin-x64@16.2.1': + resolution: {integrity: sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.0-canary.26': - resolution: {integrity: sha512-RZokPztn2L9ezBMylX5EhjPVTsm6XsNAnJL7TKE4dueMll6j4nRQz1nwDWCC1Wm5kkfc/551I68qYovBCiZWdg==} + '@next/swc-linux-arm64-gnu@16.2.1': + resolution: {integrity: sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.0-canary.26': - resolution: {integrity: sha512-u54kaT4emm3DORodmrm+AGpYvYs49p2yqiRtyo5wsefPX2VhLoKbg5HgAUx9cwMaLcIaRxPWxZSuyuVnih0JSw==} + '@next/swc-linux-arm64-musl@16.2.1': + resolution: {integrity: sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] - '@next/swc-linux-x64-gnu@16.2.0-canary.26': - resolution: {integrity: sha512-0QOwmYPVuQAo0S4SE2n0KEVnn9VKOMuaBWVZKMIROoE7T37vU5BGVr7US5+JgZAJ2plfBTV0fejak8/79xvCgg==} + '@next/swc-linux-x64-gnu@16.2.1': + resolution: {integrity: sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] - '@next/swc-linux-x64-musl@16.2.0-canary.26': - resolution: {integrity: sha512-HH4msGi1eRukM3ezJRnbW1hHM5YOAAA51aY/PT7dBYBv9a0j/g1GvMQCVUDPFT+HLcl4Tbs2Q0GEE+lbYaXQiQ==} + '@next/swc-linux-x64-musl@16.2.1': + resolution: {integrity: sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.0-canary.26': - resolution: {integrity: sha512-ssIPFIUmx08IU9Kq6rASTKHRvknYPDIGSblPk3FAkGjxByqBfwF9YOl8u1BXHiwwqmY6m83CqVaBlEN/rqy/Mg==} + '@next/swc-win32-arm64-msvc@16.2.1': + resolution: {integrity: sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.0-canary.26': - resolution: {integrity: sha512-GVD0tewCg/MVFB2m3Z0CR+EEEf63MCRKIlRa7MMheTOnt+/OFrKhEfICZjA84Cq+8+OLPGE3/X6k86k8tQwQ3Q==} + '@next/swc-win32-x64-msvc@16.2.1': + resolution: {integrity: sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1372,24 +1392,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -1597,41 +1621,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2449,8 +2481,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-next@16.1.6: - resolution: {integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==} + eslint-config-next@16.2.1: + resolution: {integrity: sha512-qhabwjQZ1Mk53XzXvmogf8KQ0tG0CQXF0CZ56+2/lVhmObgmaqj7x5A1DSrWdZd3kwI7GTPGUjFne+krRxYmFg==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -3202,24 +3234,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -3363,8 +3399,8 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next@16.2.0-canary.26: - resolution: {integrity: sha512-3Xw0SPdVMw/l4qIQt5HEOirTjYJr/gnfjo2TvgAZUxuX1dly0B1N054d5bouG+jRpb8BALlL7ghwG4UirmvGIw==} + next@16.2.1: + resolution: {integrity: sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -4800,7 +4836,7 @@ snapshots: idb-keyval: 6.2.1 ox: 0.6.9(typescript@5.9.3)(zod@3.25.76) preact: 10.24.2 - viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) zustand: 5.0.3(@types/react@19.2.10)(react@19.2.3)(use-sync-external-store@1.4.0(react@19.2.3)) transitivePeerDependencies: - '@types/react' @@ -4868,7 +4904,7 @@ snapshots: idb-keyval: 6.2.1 ox: 0.6.9(typescript@5.9.3)(zod@3.25.76) preact: 10.24.2 - viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) zustand: 5.0.3(@types/react@19.2.10)(react@19.2.3)(use-sync-external-store@1.4.0(react@19.2.3)) transitivePeerDependencies: - '@types/react' @@ -5381,34 +5417,34 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@16.2.0-canary.26': {} + '@next/env@16.2.1': {} - '@next/eslint-plugin-next@16.1.6': + '@next/eslint-plugin-next@16.2.1': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.2.0-canary.26': + '@next/swc-darwin-arm64@16.2.1': optional: true - '@next/swc-darwin-x64@16.2.0-canary.26': + '@next/swc-darwin-x64@16.2.1': optional: true - '@next/swc-linux-arm64-gnu@16.2.0-canary.26': + '@next/swc-linux-arm64-gnu@16.2.1': optional: true - '@next/swc-linux-arm64-musl@16.2.0-canary.26': + '@next/swc-linux-arm64-musl@16.2.1': optional: true - '@next/swc-linux-x64-gnu@16.2.0-canary.26': + '@next/swc-linux-x64-gnu@16.2.1': optional: true - '@next/swc-linux-x64-musl@16.2.0-canary.26': + '@next/swc-linux-x64-musl@16.2.1': optional: true - '@next/swc-win32-arm64-msvc@16.2.0-canary.26': + '@next/swc-win32-arm64-msvc@16.2.1': optional: true - '@next/swc-win32-x64-msvc@16.2.0-canary.26': + '@next/swc-win32-x64-msvc@16.2.1': optional: true '@noble/ciphers@1.2.1': {} @@ -5657,7 +5693,7 @@ snapshots: dependencies: big.js: 6.2.2 dayjs: 1.11.13 - viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) transitivePeerDependencies: - bufferutil - typescript @@ -5692,7 +5728,7 @@ snapshots: '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) '@walletconnect/universal-provider': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 1.13.2(@types/react@19.2.10)(react@19.2.3) - viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -5990,7 +6026,7 @@ snapshots: '@walletconnect/logger': 2.1.2 '@walletconnect/universal-provider': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) valtio: 1.13.2(@types/react@19.2.10)(react@19.2.3) - viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -6094,7 +6130,7 @@ snapshots: '@walletconnect/universal-provider': 2.21.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) bs58: 6.0.0 valtio: 1.13.2(@types/react@19.2.10)(react@19.2.3) - viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -6183,7 +6219,7 @@ snapshots: '@safe-global/safe-apps-sdk@9.1.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@safe-global/safe-gateway-typescript-sdk': 3.23.1 - viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.45.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) transitivePeerDependencies: - bufferutil - typescript @@ -6704,7 +6740,7 @@ snapshots: dependencies: '@babel/runtime': 7.28.6 '@noble/curves': 1.9.7 - '@noble/hashes': 1.4.0 + '@noble/hashes': 1.8.0 '@solana/buffer-layout': 4.0.1 '@solana/codecs-numbers': 2.3.0(typescript@5.9.3) agentkeepalive: 4.6.0 @@ -7023,9 +7059,9 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/analytics@1.6.1(next@16.2.0-canary.26(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': + '@vercel/analytics@1.6.1(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': optionalDependencies: - next: 16.2.0-canary.26(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 '@vercel/oidc@3.1.0': {} @@ -8757,9 +8793,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@16.1.6(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.2.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@next/eslint-plugin-next': 16.1.6 + '@next/eslint-plugin-next': 16.2.1 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) @@ -9023,7 +9059,7 @@ snapshots: extension-port-stream@3.0.0: dependencies: - readable-stream: 3.6.2 + readable-stream: 4.7.0 webextension-polyfill: 0.10.0 eyes@0.1.8: {} @@ -9146,9 +9182,9 @@ snapshots: functions-have-names@1.2.3: {} - geist@1.5.1(next@16.2.0-canary.26(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)): + geist@1.5.1(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)): dependencies: - next: 16.2.0-canary.26(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) generator-function@2.0.1: {} @@ -9735,9 +9771,9 @@ snapshots: natural-compare@1.4.0: {} - next@16.2.0-canary.26(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - '@next/env': 16.2.0-canary.26 + '@next/env': 16.2.1 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.9.19 caniuse-lite: 1.0.30001766 @@ -9746,14 +9782,14 @@ snapshots: react-dom: 19.2.3(react@19.2.3) styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.3) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.0-canary.26 - '@next/swc-darwin-x64': 16.2.0-canary.26 - '@next/swc-linux-arm64-gnu': 16.2.0-canary.26 - '@next/swc-linux-arm64-musl': 16.2.0-canary.26 - '@next/swc-linux-x64-gnu': 16.2.0-canary.26 - '@next/swc-linux-x64-musl': 16.2.0-canary.26 - '@next/swc-win32-arm64-msvc': 16.2.0-canary.26 - '@next/swc-win32-x64-msvc': 16.2.0-canary.26 + '@next/swc-darwin-arm64': 16.2.1 + '@next/swc-darwin-x64': 16.2.1 + '@next/swc-linux-arm64-gnu': 16.2.1 + '@next/swc-linux-arm64-musl': 16.2.1 + '@next/swc-linux-x64-gnu': 16.2.1 + '@next/swc-linux-x64-musl': 16.2.1 + '@next/swc-win32-arm64-msvc': 16.2.1 + '@next/swc-win32-x64-msvc': 16.2.1 '@opentelemetry/api': 1.9.0 sharp: 0.34.5 transitivePeerDependencies: @@ -9923,10 +9959,10 @@ snapshots: ox@0.6.7(typescript@5.9.3)(zod@3.25.76): dependencies: '@adraffy/ens-normalize': 1.11.1 - '@noble/curves': 1.8.1 - '@noble/hashes': 1.7.1 - '@scure/bip32': 1.6.2 - '@scure/bip39': 1.5.4 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 abitype: 1.0.8(typescript@5.9.3)(zod@3.25.76) eventemitter3: 5.0.1 optionalDependencies: