arg completer

This commit is contained in:
Corey Johnson 2025-09-19 17:32:19 -07:00
parent 881ed132c0
commit 329550f44a
25 changed files with 1663 additions and 0 deletions

2
packages/arg-completer/.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

34
packages/arg-completer/.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

View File

@ -0,0 +1,106 @@
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
// import .css files directly and it works
import './index.css';
import { createRoot } from "react-dom/client";
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.

17
packages/arg-completer/bun-env.d.ts vendored Normal file
View File

@ -0,0 +1,17 @@
// Generated by `bun init`
declare module "*.svg" {
/**
* A path to the SVG file
*/
const path: `${string}.svg`;
export = path;
}
declare module "*.module.css" {
/**
* A record of class names to their corresponding CSS module classes
*/
const classes: { readonly [key: string]: string };
export = classes;
}

View File

@ -0,0 +1,29 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "bun-react-template",
"dependencies": {
"hono": "^4.9.7",
},
"devDependencies": {
"@types/bun": "latest",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="],
"@types/node": ["@types/node@24.5.0", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-y1dMvuvJspJiPSDZUQ+WMBvF7dpnEqN4x9DDC9ie5Fs/HUZJA3wFp7EhHoVaKX/iI0cRoECV8X2jL8zi0xrHCg=="],
"@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="],
"bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"hono": ["hono@4.9.7", "", {}, "sha512-t4Te6ERzIaC48W3x4hJmBwgNlLhmiEdEE5ViYb02ffw4ignHNHa5IBtPjmbKstmtKa8X6C35iWwK4HaqvrzG9w=="],
"undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="],
}
}

View File

@ -0,0 +1,2 @@
[serve.static]
env = "BUN_PUBLIC_*"

View File

@ -0,0 +1,19 @@
{
"name": "complate",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot src/server.tsx"
},
"dependencies": {
"hono": "^4.9.7"
},
"devDependencies": {
"@types/bun": "latest"
},
"prettier": {
"semi": false,
"printWidth": 100
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,30 @@
import type { FC } from "hono/jsx"
export const Prompt: FC = async () => {
return (
<>
<h1>Completion demo!</h1>
<div id="command-line">
<textarea
id="command-prompt"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck={false}
rows={1}
autofocus
></textarea>
<div id="command-error">{/* tail follow=<span class="error">fsd</span> */}</div>
<textarea
id="command-hint"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck={false}
readonly
></textarea>
<div id="command-suggestion-list"></div>
</div>
</>
)
}

View File

@ -0,0 +1,166 @@
@font-face {
font-family: 'C64ProMono';
src: url('/vendor/C64_Pro_Mono-STYLE.woff2') format('woff2');
font-weight: normal;
font-style: normal;
}
:root {
--font-family: 'C64ProMono', monospace;
--black: #000000;
--white: #E0E0E0;
--grey: #aaa;
--dark-grey: #666;
--cyan: #00A8C8;
--red: #C62828;
--green: green;
--yellow: #C4A000;
--purple: #7C3AED;
--blue: #1565C0;
--magenta: #ff66cc;
}
.black {
color: var(--black);
}
.white {
color: var(--white);
}
.cyan {
color: var(--cyan);
}
.red {
color: var(--red);
}
.green {
color: var(--green);
}
.yellow {
color: var(--yellow);
}
.purple {
color: var(--purple);
}
.blue {
color: var(--blue);
}
.magenta {
color: var(--magenta);
}
body,
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-family);
color: var(--pico-h3-color);
}
body {
margin: 10px;
}
#command-line {
display: flex;
flex-direction: column;
position: relative;
width: 95%;
}
#command-line textarea {
width: 100%;
min-height: 2em;
resize: none;
background: transparent;
overflow: hidden;
padding: var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);
box-sizing: border-box;
display: block;
}
#command-prompt {
position: relative;
z-index: 2;
}
#command-error {
position: absolute;
top: 1px;
left: 1px;
right: 0;
z-index: 0;
color: white;
padding: var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);
}
#command-error .error {
border-bottom: 3px groove red;
}
#command-hint {
position: absolute;
top: 1px;
left: 1px;
right: 0;
bottom: 0;
z-index: 1;
color: #666;
border: none;
}
.command-suggestion {
display: flex;
padding: 0.25em 0.5em;
justify-content: space-between;
}
.command-suggestion:nth-child(odd) {
background-color: var(--white);
}
/* #command-promp {
position: relative;
z-index: 2;
background: transparent;
padding: var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);
font: inherit;
letter-spacing: inherit;
line-height: inherit;
height: auto;
min-height: 1.2em;
resize: none;
overflow: hidden;
}
#command-hin {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
border: var(--pico-border-width) solid transparent;
pointer-events: none;
padding: var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);
font: inherit;
letter-spacing: inherit;
line-height: inherit;
color: #666;
background: transparent;
resize: none;
overflow: hidden;
min-height: 1.2em;
height: auto;
} */

View File

@ -0,0 +1,116 @@
import { type CommandShape, Command } from "./command"
import { ParseError } from "./errors"
import { expect, test } from "bun:test"
const fetchShape: CommandShape = {
command: "fetch",
description: "Fetch a URL and display the response",
args: [
{ name: "url", type: "string" },
{ name: "raw", type: "boolean", named: true, default: false },
{ name: "timeout", type: "number", named: true, default: 30 },
],
}
test("parseArgs with valid args", () => {
const cmd = new Command(fetchShape)
const { args, error } = cmd.parse("fetch https://example.com raw=true timeout=10")
expect(error).toBeUndefined()
expect(args).toMatchObject([
{ name: "url", value: "https://example.com" },
{ name: "raw", value: true },
{ name: "timeout", value: 10 },
])
})
test("parseArgs without some named args", () => {
const cmd = new Command(fetchShape)
const { args, error } = cmd.parse("fetch https://example.com raw=true")
expect(error).toBeUndefined()
expect(args).toEqual([
{ name: "url", value: "https://example.com" },
{ name: "raw", value: true },
])
})
test("parseArgs with too many named args", () => {
const cmd = new Command(fetchShape)
const { error } = cmd.parse("fetch https://example.com meow")
expect(error).toBeDefined()
expect(error?.message).toEqual("Expected 1 positional argument(s) but got 2.")
expect(error?.start).toEqual(30)
expect(error?.end).toEqual(30)
})
test("parseArgs with not enough positional args", () => {
const cmd = new Command(fetchShape)
const { error } = cmd.parse("fetch")
expect(error).toBeDefined()
expect(error?.message).toEqual("Expected 1 positional argument(s) but got 0.")
expect(error?.start).toEqual(5)
expect(error?.end).toEqual(5)
})
const typeShape: CommandShape = {
command: "type",
description: "Fetch a URL and display the response",
args: [
{ name: "theBoolean", type: "boolean", named: true, default: false },
{ name: "theNumber", type: "number", named: true, default: 30 },
],
}
test("parseArgs incorrect number type", () => {
const cmd = new Command(typeShape)
const { error } = cmd.parse("type theNumber=notanumber")
expect(error).toBeDefined()
expect(error?.message).toEqual("Expected a number but got 'notanumber'.")
expect(error?.start).toEqual(5)
expect(error?.end).toEqual(25)
})
test("parseArgs incorrect boolean type", () => {
const cmd = new Command(typeShape)
const { error } = cmd.parse("type theBoolean=notaboolean")
expect(error).toBeDefined()
expect(error?.message).toEqual("Expected a boolean but got 'notaboolean'.")
expect(error?.start).toEqual(5)
expect(error?.end).toEqual(27)
})
test("argSuggestions for positional and named args", () => {
const cmd = new Command(fetchShape)
const expectNames = (input: string) => {
const names = cmd.getSuggestions(input).suggestions.map((a) => a.name + (a.named ? "=" : ""))
return expect(names, `from input "${input}"`)
}
expectNames("fetch ").toEqual(["url"])
expectNames("fetch path r").toEqual(["raw="])
expectNames("fetch path raw").toEqual(["raw="])
expectNames("fetch path raw=true ").toEqual(["timeout="])
expectNames("fetch path raw=true t").toEqual(["timeout="])
})
test("argSuggestions throw errors for unmatched patterns", () => {
const cmd = new Command(fetchShape)
const expectError = (input: string) => {
const error = cmd.getSuggestions(input).error
return expect(error, `from input "${input}"`)
}
expectError("fetch path f").toBeInstanceOf(ParseError)
expectError("fetch path raw=asdf ").toBeInstanceOf(ParseError)
expectError("fetch path raw=true").toBeUndefined()
})
/*
/*
# Ignored problems
what if a positional arg looks like a named arg? For example echo verbose=32. If we want to echo verbose=32 it is ambiguous.
*/

View File

@ -0,0 +1,171 @@
import { Tokenizer, type Token } from "./tokenizer"
import { ParseError } from "./errors"
export class Command {
shape: CommandShape
constructor(shape: CommandShape) {
this.shape = shape
}
getSuggestions(input: string): { suggestions: ArgShape[]; token?: Token } {
const tokens = Tokenizer.tokens(input)
if (tokens[0]?.value !== this.shape.command) {
return { suggestions: [] }
}
const argTokens = tokens.slice(1)
const hasTrailingSpace = input.endsWith(" ")
const lastToken = argTokens.at(-1)
const isCompletingPartialArg = !hasTrailingSpace && lastToken
const completedTokens = isCompletingPartialArg ? argTokens.slice(0, -1) : argTokens
const usedArgs = new Set<string>()
completedTokens.forEach((token, index) => {
const parsedArg = this.#convertTokenToArg(token, index, completedTokens)
if (usedArgs.has(parsedArg.name)) {
const message = `Argument '${parsedArg.name}' was provided more than once.`
throw new ParseError(message, token.start, token.end)
}
usedArgs.add(parsedArg.name)
})
const availableArgs = this.shape.args.filter((arg) => !usedArgs.has(arg.name))
if (isCompletingPartialArg) {
const positionalShapes = this.shape.args.filter((a) => !a.named)
const parsingPositionalArgs = completedTokens.length < positionalShapes.length
if (parsingPositionalArgs)
return { suggestions: [positionalShapes[completedTokens.length]!], token: lastToken }
const prefix = lastToken.type === "named" ? lastToken.name : lastToken.value
const suggestions = availableArgs.filter((arg) => arg.named && arg.name.startsWith(prefix))
if (suggestions.length > 0) {
return { suggestions, token: lastToken }
} else {
throw new ParseError(`No matches for '${prefix}'`, lastToken.start, lastToken.end)
}
}
// If we still need positional args, only suggest the next positional arg
const positionalShapes = this.shape.args.filter((a) => !a.named)
const positionalCount = completedTokens.filter((t) => t.type === "positional").length
if (positionalCount < positionalShapes.length) {
const nextPositionalArg = positionalShapes[positionalCount]!
return { suggestions: [nextPositionalArg], token: lastToken }
}
// Otherwise suggest available named args
return { suggestions: availableArgs.filter((arg) => arg.named), token: lastToken }
}
parse(input: string): ParsedArg[] {
const parsedArgs: ParsedArg[] = []
const tokens = Tokenizer.tokens(input)
const [command, ...argTokens] = tokens
if (!command) {
throw new ParseError(`Expected command "${this.shape.command}" but got nothing.`, 0, 0)
} else if (command.value !== this.shape.command) {
const message = `Expected command "${this.shape.command}" but got "${command.value}."`
throw new ParseError(message, command.start, command.end)
}
// Convert each token to a parsed arg
const usedArgs = new Set<string>()
argTokens.forEach((token, index) => {
const parsedArg = this.#convertTokenToArg(token, index, argTokens)
if (usedArgs.has(parsedArg.name)) {
const message = `Argument '${parsedArg.name}' was provided more than once.`
throw new ParseError(message, token.start, token.end)
}
usedArgs.add(parsedArg.name)
parsedArgs.push(parsedArg)
})
// Check that we have the right number of positional args
const positionalShapes = this.shape.args.filter((a) => !a.named)
const positionalCount = argTokens.filter((t) => t.type === "positional").length
if (positionalCount !== positionalShapes.length) {
const message = `Expected ${positionalShapes.length} positional argument(s) but got ${positionalCount}.`
const lastToken = argTokens.at(-1)
const end = lastToken ? lastToken.end : command.end
throw new ParseError(message, end, end)
}
return parsedArgs
}
#convertTokenToArg(token: Token, tokenIndex: number, allTokens: Token[]): ParsedArg {
if (token.type === "positional") {
const positionalShapes = this.shape.args.filter((a) => !a.named)
// If we're past the positional args, this is out of order
const shape = positionalShapes[tokenIndex]
if (!shape) {
const message = `Positional arguments must come before named arguments`
throw new ParseError(message, token.start, token.end)
}
return { name: shape.name, value: this.#castArgValue(token, shape.type), token }
} else if (token.type === "named") {
const namedShapes = this.shape.args.filter((a) => a.named)
const shape = namedShapes.find((a) => a.name === token.name)
if (!shape) {
throw new ParseError(`Unknown named argument '${token.name}'`, token.start, token.end)
}
return { name: token.name, value: this.#castArgValue(token, shape.type), token }
}
throw new ParseError(`Unknown token type`, token.start, token.end)
}
#castArgValue<T extends ArgShape["type"]>(token: Token, type: T): ArgTypeMap[T] {
if (type === "string") {
return token.value as ArgTypeMap[T]
} else if (type === "number") {
const number = Number(token.value)
if (isNaN(number))
throw new ParseError(`Expected a number but got '${token.value}'.`, token.start, token.end)
return number as ArgTypeMap[T]
} else if (type === "boolean") {
if (token.value.match(/^(true|1|t)$/i)) return true as ArgTypeMap[T]
if (token.value.match(/^(false|0|f)$/i)) return false as ArgTypeMap[T]
throw new ParseError(`Expected a boolean but got '${token.value}'.`, token.start, token.end)
}
throw new ParseError(`Unknown arg type: ${type}.`, token.start, token.end)
}
}
type ArgTypeMap = {
string: string
number: number
boolean: boolean
}
type ArgShape<T extends keyof ArgTypeMap = keyof ArgTypeMap> =
| {
name: string
type: T
description?: string
named?: false
}
| {
name: string
type: T
description?: string
named: true
default: ArgTypeMap[T]
}
export type CommandShape = {
command: string
description: string
args: ArgShape[]
}
type ParsedArg = { name: string; value: ArgTypeMap[keyof ArgTypeMap]; token: Token }

View File

@ -0,0 +1,449 @@
import { Command, type CommandShape } from "./command"
export const commandsShapes = {
ls: {
command: "ls",
description: "List the contents of a directory",
args: [
{ name: "path", type: "string", description: "The path to list", named: false },
{
name: "all",
type: "boolean",
description: "Show hidden files",
named: true,
default: false,
},
{
name: "long",
type: "boolean",
description: "List in long format",
named: true,
default: false,
},
{
name: "short-names",
type: "boolean",
description: "Only print file names",
named: true,
default: false,
},
{
name: "full-paths",
type: "boolean",
description: "Display full paths",
named: true,
default: false,
},
],
},
cd: {
command: "cd",
description: "Change the current working directory",
args: [{ name: "path", type: "string", description: "The path to change to", named: false }],
},
cp: {
command: "cp",
description: "Copy files or directories",
args: [
{ name: "source", type: "string", description: "Source file or directory" },
{ name: "destination", type: "string", description: "Destination path" },
{
name: "recursive",
type: "boolean",
description: "Copy recursively",
named: true,
default: false,
},
{
name: "verbose",
type: "boolean",
description: "Verbose output",
named: true,
default: false,
},
],
},
mv: {
command: "mv",
description: "Move files or directories",
args: [
{ name: "source", type: "string", description: "Source file or directory" },
{ name: "destination", type: "string", description: "Destination path" },
{
name: "verbose",
type: "boolean",
description: "Verbose output",
named: true,
default: false,
},
],
},
rm: {
command: "rm",
description: "Remove files or directories",
args: [
{ name: "path", type: "string", description: "Path to remove" },
{
name: "recursive",
type: "boolean",
description: "Remove recursively",
named: true,
default: false,
},
{ name: "force", type: "boolean", description: "Force removal", named: true, default: false },
{
name: "verbose",
type: "boolean",
description: "Verbose output",
named: true,
default: false,
},
],
},
mkdir: {
command: "mkdir",
description: "Create directories",
args: [
{ name: "path", type: "string", description: "Directory path to create" },
{
name: "verbose",
type: "boolean",
description: "Verbose output",
named: true,
default: false,
},
],
},
touch: {
command: "touch",
description: "Create empty files or update timestamps",
args: [
{ name: "path", type: "string", description: "File path to touch" },
{
name: "access",
type: "boolean",
description: "Update access time only",
named: true,
default: false,
},
{
name: "modified",
type: "boolean",
description: "Update modified time only",
named: true,
default: false,
},
],
},
// Text operations
echo: {
command: "echo",
description: "Display a string",
args: [
{ name: "text", type: "string", description: "Text to display" },
{
name: "no-newline",
type: "boolean",
description: "Don't append newline",
named: true,
default: false,
},
],
},
cat: {
command: "cat",
description: "Display file contents",
args: [
{ name: "path", type: "string", description: "File to display" },
{
name: "numbered",
type: "boolean",
description: "Show line numbers",
named: true,
default: false,
},
],
},
head: {
command: "head",
description: "Show first lines of input",
args: [
{ name: "path", type: "string", description: "File to read from", named: false },
{ name: "lines", type: "number", description: "Number of lines", named: true, default: 10 },
],
},
tail: {
command: "tail",
description: "Show last lines of input",
args: [
{ name: "path", type: "string", description: "File to read from", named: false },
{ name: "lines", type: "number", description: "Number of lines", named: true, default: 10 },
{
name: "follow",
type: "boolean",
description: "Follow file changes",
named: true,
default: false,
},
],
},
grep: {
command: "grep",
description: "Search for patterns in text",
args: [
{ name: "pattern", type: "string", description: "Pattern to search for" },
{
name: "ignore-case",
type: "boolean",
description: "Case insensitive search",
named: true,
default: false,
},
{
name: "invert-match",
type: "boolean",
description: "Invert match",
named: true,
default: false,
},
{
name: "line-number",
type: "boolean",
description: "Show line numbers",
named: true,
default: false,
},
],
},
sort: {
command: "sort",
description: "Sort input",
args: [
{
name: "reverse",
type: "boolean",
description: "Sort in reverse order",
named: true,
default: false,
},
{
name: "ignore-case",
type: "boolean",
description: "Case insensitive sort",
named: true,
default: false,
},
{
name: "numeric",
type: "boolean",
description: "Numeric sort",
named: true,
default: false,
},
],
},
uniq: {
command: "uniq",
description: "Filter out repeated lines",
args: [
{
name: "count",
type: "boolean",
description: "Show count of occurrences",
named: true,
default: false,
},
{
name: "repeated",
type: "boolean",
description: "Show only repeated lines",
named: true,
default: false,
},
{
name: "unique",
type: "boolean",
description: "Show only unique lines",
named: true,
default: false,
},
],
},
// Data manipulation
select: {
command: "select",
description: "Select specific columns from data",
args: [{ name: "columns", type: "string", description: "Columns to select" }],
},
where: {
command: "where",
description: "Filter data based on conditions",
args: [{ name: "condition", type: "string", description: "Filter condition" }],
},
group_by: {
command: "group-by",
description: "Group data by column values",
args: [{ name: "column", type: "string", description: "Column to group by" }],
},
// // Network operations
// http_get: {
// command: "http get",
// description: "Fetch data from a URL via GET request",
// args: [
// { name: "url", type: "string", description: "URL to fetch" },
// { name: "headers", type: "string", description: "HTTP headers", named: true, default: "" },
// {
// name: "raw",
// type: "boolean",
// description: "Return raw response",
// named: true,
// default: false,
// },
// {
// name: "insecure",
// type: "boolean",
// description: "Allow insecure connections",
// named: true,
// default: false,
// },
// ],
// },
// http_post: {
// command: "http post",
// description: "Send data via POST request",
// args: [
// { name: "url", type: "string", description: "URL to post to" },
// { name: "data", type: "string", description: "Data to send", named: false },
// {
// name: "content-type",
// type: "string",
// description: "Content type",
// named: true,
// default: "application/json",
// },
// { name: "headers", type: "string", description: "HTTP headers", named: true, default: "" },
// ],
// },
// System operations
ps: {
command: "ps",
description: "List running processes",
args: [
{
name: "long",
type: "boolean",
description: "Show detailed information",
named: true,
default: false,
},
],
},
sys: {
command: "sys",
description: "Show system information",
args: [],
},
which: {
command: "which",
description: "Find the location of a command",
args: [
{ name: "command", type: "string", description: "Command to locate" },
{
name: "all",
type: "boolean",
description: "Show all matches",
named: true,
default: false,
},
],
},
// // Conversion operations
// to_json: {
// command: "to json",
// description: "Convert data to JSON",
// args: [
// { name: "indent", type: "number", description: "JSON indentation", named: true, default: 2 },
// { name: "raw", type: "boolean", description: "Output raw JSON", named: true, default: false },
// ],
// },
// from_json: {
// command: "from json",
// description: "Parse JSON data",
// args: [
// {
// name: "objects",
// type: "boolean",
// description: "Parse multiple objects",
// named: true,
// default: false,
// },
// ],
// },
// to_csv: {
// command: "to csv",
// description: "Convert data to CSV",
// args: [
// {
// name: "separator",
// type: "string",
// description: "Field separator",
// named: true,
// default: ",",
// },
// {
// name: "no-headers",
// type: "boolean",
// description: "Don't include headers",
// named: true,
// default: false,
// },
// ],
// },
// from_csv: {
// command: "from csv",
// description: "Parse CSV data",
// args: [
// {
// name: "separator",
// type: "string",
// description: "Field separator",
// named: true,
// default: ",",
// },
// {
// name: "no-headers",
// type: "boolean",
// description: "No header row",
// named: true,
// default: false,
// },
// ],
// },
} as const satisfies Record<string, CommandShape>
export const commands: Command[] = Object.values(commandsShapes).map((shape) => new Command(shape))

View File

@ -0,0 +1,12 @@
export const $ = (id: string): HTMLElement | null => document.getElementById(id)
export const $$ = (tag: string, html = ""): HTMLElement => {
const el = document.createElement(tag)
el.innerHTML = html
return el
}
export const cmdPrompt = $("command-prompt") as HTMLTextAreaElement
export const cmdHint = $("command-hint") as HTMLTextAreaElement
export const cmdSuggestionList = $("command-suggestion-list") as HTMLUListElement
export const cmdError = $("command-error") as HTMLDivElement

View File

@ -0,0 +1,5 @@
export class ParseError extends Error {
constructor(message: string, public start: number, public end: number) {
super(message)
}
}

View File

@ -0,0 +1,133 @@
import { commands } from "./commands"
import { cmdPrompt, cmdHint, cmdSuggestionList, cmdError } from "./dom"
import { ParseError } from "./errors"
let tabCompletion = ""
cmdPrompt.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
console.log("ENTER")
e.preventDefault
} else if (e.key === "Tab") {
e.preventDefault()
cmdPrompt.value = tabCompletion
updateSuggestionList(cmdPrompt.value)
}
})
cmdPrompt.addEventListener("input", (e) => {
updateSuggestionList(cmdPrompt.value)
})
// Corey hates this
const updateSuggestionList = (input: string) => {
if (input.trim() == "") return
cmdSuggestionList.innerHTML = ""
cmdError.innerHTML = ""
tabCompletion = ""
const { commandSuggestions, argSuggestions, error } = getSuggestions(input)
if (error) {
const beforeError = input.slice(0, error.start)
const errorText = input.slice(error.start, error.end)
const afterError = input.slice(error.end)
cmdError.innerHTML = `${beforeError}<span class='error'>${errorText}</span>${afterError}`
}
// Show command suggestions
commandSuggestions.forEach((cmdShape) => {
const div = createSuggestionElement(cmdShape.command, cmdShape.description)
cmdSuggestionList.appendChild(div)
})
// Show arg suggestions
argSuggestions?.forEach((arg) => {
let name = arg.name
if (arg.named) {
name = `${name}=${arg.default}`
}
const description = `<${arg.type}>` + (arg.description ? ` - ${arg.description}` : "")
const div = createSuggestionElement(name, description)
cmdSuggestionList.appendChild(div)
})
cmdHint.value = ""
tabCompletion = ""
// Tab completion
if (commandSuggestions.length > 0) {
cmdHint.value = commandSuggestions[0]!.command
tabCompletion = cmdHint.value + " "
} else if (argSuggestions?.length > 0) {
const suggestion = argSuggestions[0]!
const lastArg = input.trimStart().split(" ").pop() ?? ""
if (suggestion?.named) {
if (lastArg.match(/^[\w-]+=/)) {
const afterEquals = lastArg.split("=")[1] ?? ""
if (!afterEquals) {
const suggestionText = `<${suggestion.type}>`
tabCompletion = input + suggestionText
cmdHint.value = tabCompletion
}
} else {
const suggestionText = suggestion.name.slice(lastArg.length) + "="
tabCompletion = input + suggestionText
cmdHint.value = tabCompletion
}
} else if (!lastArg) {
tabCompletion = ""
cmdHint.value = input + suggestion?.name + " "
}
}
}
const getSuggestions = (input: string) => {
try {
const commandInput = input.trimStart().split(" ")[0] ?? ""
const matchingCommands = commands.filter((cmd) => cmd.shape.command.startsWith(commandInput))
const hasCommand = input.trimStart().match(/\w+\s+/)
if (!hasCommand) {
return {
commandSuggestions: matchingCommands.map((cmd) => cmd.shape),
argSuggestions: [],
command: null,
}
} else if (matchingCommands.length === 1) {
const command = matchingCommands[0]!
const { suggestions } = command.getSuggestions(input)
return {
commandSuggestions: [],
argSuggestions: suggestions,
}
} else {
return {
commandSuggestions: [],
argSuggestions: [],
error: new ParseError(`Unknown command: "${commandInput}."`, 0, commandInput.length),
}
}
} catch (error) {
if (error instanceof ParseError) {
return { commandSuggestions: [], argSuggestions: [], error }
}
throw error
}
}
const createSuggestionElement = (title: string, description: string) => {
const div = document.createElement("div")
div.className = "command-suggestion"
const commandDiv = document.createElement("div")
commandDiv.textContent = title
commandDiv.className = "command-name"
const descriptionDiv = document.createElement("div")
descriptionDiv.textContent = description
descriptionDiv.className = "command-description"
div.appendChild(commandDiv)
div.appendChild(descriptionDiv)
return div
}

View File

@ -0,0 +1,105 @@
import { Tokenizer } from "./tokenizer.js"
import { expect, test } from "bun:test"
test("parse 'fetch url' into command and positional tokens", () => {
const { tokens, error } = Tokenizer.tokens("fetch https://example.com/path true")
expect(tokens).toEqual([
{ type: "command", value: "fetch", start: 0, end: 5 },
{ type: "positional", value: "https://example.com/path", start: 6, end: 30 },
{ type: "positional", value: "true", start: 31, end: 35 },
])
})
test("parse 'fetch \"quoted url\"' and strip quotes from value", () => {
const { tokens, error } = Tokenizer.tokens('fetch "https://example.com/path"')
expect(tokens).toEqual([
{ type: "command", value: "fetch", start: 0, end: 5 },
{ type: "positional", value: "https://example.com/path", start: 6, end: 32 },
])
})
test("parse 'fetch url timeout=30' with named argument", () => {
const { tokens, error } = Tokenizer.tokens("fetch https://example.com/path timeout=30")
expect(tokens).toEqual([
{ type: "command", value: "fetch", start: 0, end: 5 },
{ type: "positional", value: "https://example.com/path", start: 6, end: 30 },
{ type: "named", name: "timeout", value: "30", start: 31, end: 41 },
])
})
test("parse mixed args 'fetch url timeout=30 raw=true'", () => {
const { tokens, error } = Tokenizer.tokens("fetch https://example.com/path timeout=30 raw=true")
expect(tokens).toEqual([
{ type: "command", value: "fetch", start: 0, end: 5 },
{ type: "positional", value: "https://example.com/path", start: 6, end: 30 },
{ type: "named", name: "timeout", value: "30", start: 31, end: 41 },
{ type: "named", name: "raw", value: "true", start: 42, end: 50 },
])
})
test("return error for unclosed quote in 'fetch \"unterminated'", () => {
const { tokens, error } = Tokenizer.tokens('fetch "https://example.com/path')
expect(tokens).toEqual([])
expect(error).toBeDefined()
expect(error?.message).toBe("Unclosed quote")
expect(error?.start).toBe(6)
expect(error?.end).toBe(31)
})
test("return error for escape char at end", () => {
const { tokens, error } = Tokenizer.tokens("fetch https://example.com/path\\")
expect(tokens).toEqual([])
expect(error).toBeDefined()
expect(error?.message).toBe("Trailing backslash")
expect(error?.start).toBe(30)
expect(error?.end).toBe(31)
})
test('handle escaped quotes in \'fetch "url with \\"quotes\\""\'', () => {
const { tokens, error } = Tokenizer.tokens('fetch "url with \\"quotes\\""')
expect(tokens).toEqual([
{ type: "command", value: "fetch", start: 0, end: 5 },
{ type: "positional", value: 'url with "quotes"', start: 6, end: 27 },
])
expect(error).toBeUndefined()
})
test("handle escaped quotes in in named arg", () => {
const { tokens, error } = Tokenizer.tokens('fetch url timeout="30 seconds" raw=true')
expect(tokens).toEqual([
{ type: "command", value: "fetch", start: 0, end: 5 },
{ type: "positional", value: "url", start: 6, end: 9 },
{ type: "named", name: "timeout", value: "30 seconds", start: 10, end: 30 },
{ type: "named", name: "raw", value: "true", start: 31, end: 39 },
])
expect(error).toBeUndefined()
})
test("ignore multiple spaces between arguments", () => {
const { tokens, error } = Tokenizer.tokens(
"fetch https://example.com/path timeout=30 raw=true"
)
expect(tokens).toEqual([
{ type: "command", value: "fetch", start: 0, end: 5 },
{ type: "positional", value: "https://example.com/path", start: 9, end: 33 },
{ type: "named", name: "timeout", value: "30", start: 36, end: 46 },
{ type: "named", name: "raw", value: "true", start: 49, end: 57 },
])
expect(error).toBeUndefined()
})
test("return empty array for empty string input", () => {
const { tokens, error } = Tokenizer.tokens("")
expect(tokens).toEqual([])
expect(error).toBeUndefined()
})
test("handle quote immediately followed by text 'fetch \"url\"more'", () => {
const { tokens, error } = Tokenizer.tokens('fetch "url"more timeout=30')
expect(tokens).toEqual([
{ type: "command", value: "fetch", start: 0, end: 5 },
{ type: "positional", value: "urlmore", start: 6, end: 15 },
{ type: "named", name: "timeout", value: "30", start: 16, end: 26 },
])
expect(error).toBeUndefined()
})

View File

@ -0,0 +1,122 @@
import { ParseError } from "./errors.js"
export class Tokenizer {
pos: number
tokens: Token[]
input: string
constructor(input: string) {
this.pos = 0
this.tokens = []
this.input = input
}
static tokens(input: string): Token[] {
const tokenizer = new Tokenizer(input)
return tokenizer.tokenize()
}
tokenize(): Token[] {
while (this.pos < this.input.length) {
if (this.#skipWhitespace(this.input)) continue
const rawTokenStart = this.pos
const rawToken = this.#readRawToken()
const token = this.#analyzeToken(rawToken, rawTokenStart, this.pos)
this.tokens.push(token)
}
return this.tokens
}
#analyzeToken(value: string, start: number, end: number): Token {
if (!value) throw new ParseError("Unexpected empty argument", start, end)
if (this.tokens.length === 0) {
return { type: "command", value, start, end }
}
const namedMatch = value.match(/^([\w-]+)=(.*)$/)
if (namedMatch) {
const [, name, argValue] = namedMatch
if (!name) {
throw new ParseError("Named argument missing name", start, end)
}
return { type: "named", name, value: argValue, start, end }
} else {
return { type: "positional", value, start, end }
}
}
#readRawToken(): string {
let token = ""
let inQuotes = false
let escapeNext = false
while (!this.#eol()) {
const char = this.input[this.pos]
if (escapeNext) {
if (char === '"') {
token += char // unescape the quote
} else {
token += "\\" + char // keep the backslash for non-quotes
}
escapeNext = false
} else if (char === "\\") {
escapeNext = true
} else if (char === '"') {
inQuotes = !inQuotes
} else if (char === " " && !inQuotes) {
break
} else {
token += char
}
this.pos++
}
if (inQuotes) {
throw new ParseError("Unclosed quote", this.pos - token.length - 1, this.pos)
} else if (escapeNext) {
throw new ParseError("Trailing backslash", this.pos - 1, this.pos)
}
return token
}
#skipWhitespace(input: string): boolean {
const match = input.slice(this.pos).match(/^\s+/)
if (!match) return false
const offset = match[0].length
this.pos += offset
return true
}
#eol(): boolean {
return this.pos >= this.input.length
}
}
export type Token =
| {
type: "command"
value: string
start: number
end: number
}
| {
type: "positional"
value: string
start: number
end: number
}
| {
type: "named"
name: string
value: string
start: number
end: number
}

View File

@ -0,0 +1,17 @@
import { html } from "hono/html"
export const Layout = (children: any) => html`<!DOCTYPE html>
<html lang="en">
<head>
<title>Completion test</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<link rel="stylesheet" href="/vendor/pico.fuchsia.css" />
<link rel="stylesheet" href="/css/index.css" />
<script src="/js/index.js" type="module"></script>
</head>
<body>
${children}
</body>
</html>`

View File

@ -0,0 +1,32 @@
import { Hono } from "hono"
import { Prompt } from "./components/prompt"
import { serveStatic } from "hono/bun"
import { Layout } from "./layout"
import { isFile, transpile } from "./utils"
const app = new Hono()
app.get("/", (c) => c.html(Layout(<Prompt />)))
app.use("/vendor/*", serveStatic({ root: "./public" }))
app.use("/css/*", serveStatic({ root: "./src" }))
app.get("/js/:path{.+}", async (c) => {
const path = "./src/js/" + c.req.param("path")
const ts = path.endsWith(".js") ? path.replace(".js", ".ts") : path + ".ts"
const tsx = path.endsWith(".js") ? path.replace(".js", ".tsx") : path + ".tsx"
let javascript = ""
if (isFile(ts)) {
javascript = await transpile(ts)
} else if (isFile(tsx)) {
javascript = await transpile(tsx)
} else if (isFile(path)) {
javascript = await Bun.file(path).text()
} else {
return c.text("File not found", 404)
}
return new Response(javascript, { headers: { "Content-Type": "text/javascript" } })
})
export default app

View File

@ -0,0 +1,58 @@
import { statSync } from "node:fs"
import { stat } from "node:fs/promises"
const transpiler = new Bun.Transpiler({ loader: 'tsx' })
export function css(strings: TemplateStringsArray, ...values: any[]) {
return <style dangerouslySetInnerHTML={{
__html: strings.reduce((result, str, i) => {
return result + str + (values[i] || '')
}, '')
}} />
}
export function js(strings: TemplateStringsArray, ...values: any[]) {
return <script dangerouslySetInnerHTML={{
__html: strings.reduce((result, str, i) => {
return transpiler.transformSync(result + str + (values[i] || ''))
}, '')
}} />
}
export function isFile(path: string): boolean {
try {
const stats = statSync(path)
return stats.isFile()
} catch {
return false
}
}
export function isDir(path: string): boolean {
try {
const stats = statSync(path)
return stats.isDirectory()
} catch {
return false
}
}
export function randomID(): string {
return Math.random().toString(36).slice(2)
}
const transpileCache: Record<string, string> = {}
export async function transpile(path: string): Promise<string> {
const code = await Bun.file(path).text()
const { mtime } = await stat(path)
const key = `${path}?${mtime}`
let cached = transpileCache[key]
if (!cached) {
cached = transpiler.transformSync(code)
transpileCache[key] = cached
}
return cached
}

View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"baseUrl": ".",
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"exclude": ["dist", "node_modules"]
}