arg completer
This commit is contained in:
parent
881ed132c0
commit
329550f44a
2
packages/arg-completer/.gitattributes
vendored
Normal file
2
packages/arg-completer/.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
34
packages/arg-completer/.gitignore
vendored
Normal file
34
packages/arg-completer/.gitignore
vendored
Normal 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
|
||||
106
packages/arg-completer/CLAUDE.md
Normal file
106
packages/arg-completer/CLAUDE.md
Normal 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
17
packages/arg-completer/bun-env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
29
packages/arg-completer/bun.lock
Normal file
29
packages/arg-completer/bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
2
packages/arg-completer/bunfig.toml
Normal file
2
packages/arg-completer/bunfig.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[serve.static]
|
||||
env = "BUN_PUBLIC_*"
|
||||
19
packages/arg-completer/package.json
Normal file
19
packages/arg-completer/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
packages/arg-completer/public/img/cow.jpeg
Normal file
BIN
packages/arg-completer/public/img/cow.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
BIN
packages/arg-completer/public/vendor/C64_Pro-STYLE.woff2
vendored
Normal file
BIN
packages/arg-completer/public/vendor/C64_Pro-STYLE.woff2
vendored
Normal file
Binary file not shown.
BIN
packages/arg-completer/public/vendor/C64_Pro_Mono-STYLE.woff2
vendored
Normal file
BIN
packages/arg-completer/public/vendor/C64_Pro_Mono-STYLE.woff2
vendored
Normal file
Binary file not shown.
4
packages/arg-completer/public/vendor/pico.fuchsia.css
vendored
Normal file
4
packages/arg-completer/public/vendor/pico.fuchsia.css
vendored
Normal file
File diff suppressed because one or more lines are too long
30
packages/arg-completer/src/components/prompt.tsx
Normal file
30
packages/arg-completer/src/components/prompt.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
166
packages/arg-completer/src/css/index.css
Normal file
166
packages/arg-completer/src/css/index.css
Normal 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;
|
||||
} */
|
||||
116
packages/arg-completer/src/js/command.test.ts
Normal file
116
packages/arg-completer/src/js/command.test.ts
Normal 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.
|
||||
*/
|
||||
171
packages/arg-completer/src/js/command.ts
Normal file
171
packages/arg-completer/src/js/command.ts
Normal 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 }
|
||||
449
packages/arg-completer/src/js/commands.ts
Normal file
449
packages/arg-completer/src/js/commands.ts
Normal 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))
|
||||
12
packages/arg-completer/src/js/dom.ts
Normal file
12
packages/arg-completer/src/js/dom.ts
Normal 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
|
||||
5
packages/arg-completer/src/js/errors.ts
Normal file
5
packages/arg-completer/src/js/errors.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export class ParseError extends Error {
|
||||
constructor(message: string, public start: number, public end: number) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
133
packages/arg-completer/src/js/index.tsx
Normal file
133
packages/arg-completer/src/js/index.tsx
Normal 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
|
||||
}
|
||||
105
packages/arg-completer/src/js/tokenizer.test.ts
Normal file
105
packages/arg-completer/src/js/tokenizer.test.ts
Normal 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()
|
||||
})
|
||||
122
packages/arg-completer/src/js/tokenizer.ts
Normal file
122
packages/arg-completer/src/js/tokenizer.ts
Normal 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
|
||||
}
|
||||
17
packages/arg-completer/src/layout.tsx
Normal file
17
packages/arg-completer/src/layout.tsx
Normal 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>`
|
||||
32
packages/arg-completer/src/server.tsx
Normal file
32
packages/arg-completer/src/server.tsx
Normal 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
|
||||
58
packages/arg-completer/src/utils.tsx
Normal file
58
packages/arg-completer/src/utils.tsx
Normal 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
|
||||
}
|
||||
34
packages/arg-completer/tsconfig.json
Normal file
34
packages/arg-completer/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user