Initial commit

This commit is contained in:
Corey Johnson 2025-09-25 20:17:27 -07:00
commit 695d7f22bd
23 changed files with 1125 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

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

34
.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

107
CLAUDE.md Normal file
View File

@ -0,0 +1,107 @@
I am using the lezer grammar [System Guide](https://lezer.codemirror.net/docs/guide/) [api](https://lezer.codemirror.net/docs/ref/).
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`.

21
README.md Normal file
View File

@ -0,0 +1,21 @@
# bun-react-tailwind-template
To install dependencies:
```bash
bun install
```
To start a development server:
```bash
bun dev
```
To run for production:
```bash
bun start
```
This project was created using `bun init` in bun v1.2.22. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

149
build.ts Normal file
View File

@ -0,0 +1,149 @@
#!/usr/bin/env bun
import plugin from "bun-plugin-tailwind";
import { existsSync } from "fs";
import { rm } from "fs/promises";
import path from "path";
if (process.argv.includes("--help") || process.argv.includes("-h")) {
console.log(`
🏗 Bun Build Script
Usage: bun run build.ts [options]
Common Options:
--outdir <path> Output directory (default: "dist")
--minify Enable minification (or --minify.whitespace, --minify.syntax, etc)
--sourcemap <type> Sourcemap type: none|linked|inline|external
--target <target> Build target: browser|bun|node
--format <format> Output format: esm|cjs|iife
--splitting Enable code splitting
--packages <type> Package handling: bundle|external
--public-path <path> Public path for assets
--env <mode> Environment handling: inline|disable|prefix*
--conditions <list> Package.json export conditions (comma separated)
--external <list> External packages (comma separated)
--banner <text> Add banner text to output
--footer <text> Add footer text to output
--define <obj> Define global constants (e.g. --define.VERSION=1.0.0)
--help, -h Show this help message
Example:
bun run build.ts --outdir=dist --minify --sourcemap=linked --external=react,react-dom
`);
process.exit(0);
}
const toCamelCase = (str: string): string => str.replace(/-([a-z])/g, g => g[1].toUpperCase());
const parseValue = (value: string): any => {
if (value === "true") return true;
if (value === "false") return false;
if (/^\d+$/.test(value)) return parseInt(value, 10);
if (/^\d*\.\d+$/.test(value)) return parseFloat(value);
if (value.includes(",")) return value.split(",").map(v => v.trim());
return value;
};
function parseArgs(): Partial<Bun.BuildConfig> {
const config: Partial<Bun.BuildConfig> = {};
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === undefined) continue;
if (!arg.startsWith("--")) continue;
if (arg.startsWith("--no-")) {
const key = toCamelCase(arg.slice(5));
config[key] = false;
continue;
}
if (!arg.includes("=") && (i === args.length - 1 || args[i + 1]?.startsWith("--"))) {
const key = toCamelCase(arg.slice(2));
config[key] = true;
continue;
}
let key: string;
let value: string;
if (arg.includes("=")) {
[key, value] = arg.slice(2).split("=", 2) as [string, string];
} else {
key = arg.slice(2);
value = args[++i] ?? "";
}
key = toCamelCase(key);
if (key.includes(".")) {
const [parentKey, childKey] = key.split(".");
config[parentKey] = config[parentKey] || {};
config[parentKey][childKey] = parseValue(value);
} else {
config[key] = parseValue(value);
}
}
return config;
}
const formatFileSize = (bytes: number): string => {
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
console.log("\n🚀 Starting build process...\n");
const cliConfig = parseArgs();
const outdir = cliConfig.outdir || path.join(process.cwd(), "dist");
if (existsSync(outdir)) {
console.log(`🗑️ Cleaning previous build at ${outdir}`);
await rm(outdir, { recursive: true, force: true });
}
const start = performance.now();
const entrypoints = [...new Bun.Glob("**.html").scanSync("src")]
.map(a => path.resolve("src", a))
.filter(dir => !dir.includes("node_modules"));
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`);
const result = await Bun.build({
entrypoints,
outdir,
plugins: [plugin],
minify: true,
target: "browser",
sourcemap: "linked",
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
...cliConfig,
});
const end = performance.now();
const outputTable = result.outputs.map(output => ({
File: path.relative(process.cwd(), output.path),
Type: output.kind,
Size: formatFileSize(output.size),
}));
console.table(outputTable);
const buildTime = (end - start).toFixed(2);
console.log(`\n✅ Build completed in ${buildTime}ms\n`);

17
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;
}

57
bun.lock Normal file
View File

@ -0,0 +1,57 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "bun-react-template",
"dependencies": {
"@lezer/generator": "^1.8.0",
"bun-plugin-tailwind": "^0.0.15",
"react": "^19",
"react-dom": "^19",
"tailwindcss": "^4.1.11",
},
"devDependencies": {
"@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.2",
"@types/bun": "latest",
"@types/react": "^19",
"@types/react-dom": "^19",
},
},
},
"packages": {
"@lezer/common": ["@lezer/common@1.2.3", "", {}, "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA=="],
"@lezer/generator": ["@lezer/generator@1.8.0", "", { "dependencies": { "@lezer/common": "^1.1.0", "@lezer/lr": "^1.3.0" }, "bin": { "lezer-generator": "src/lezer-generator.cjs" } }, "sha512-/SF4EDWowPqV1jOgoGSGTIFsE7Ezdr7ZYxyihl5eMKVO5tlnpIhFcDavgm1hHY5GEonoOAEnJ0CU0x+tvuAuUg=="],
"@lezer/highlight": ["@lezer/highlight@1.2.1", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA=="],
"@lezer/lr": ["@lezer/lr@1.4.2", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA=="],
"@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="],
"@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="],
"@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="],
"@types/react-dom": ["@types/react-dom@19.1.9", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ=="],
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.0.15", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-qtAXMNGG4R0UGGI8zWrqm2B7BdXqx48vunJXBPzfDOHPA5WkRUZdTSbE7TFwO4jLhYqSE23YMWsM9NhE6ovobw=="],
"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=="],
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
"react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="],
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
"tailwindcss": ["tailwindcss@4.1.13", "", {}, "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w=="],
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="],
}
}

4
bunfig.toml Normal file
View File

@ -0,0 +1,4 @@
[serve.static]
plugins = ["bun-plugin-tailwind"]
env = "BUN_PUBLIC_*"

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "bun-react-template",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot src/server.tsx",
"generate-parser": "lezer-generator src/parser/shrimp.grammar --typeScript -o src/parser/shrimp.ts"
},
"dependencies": {
"bun-plugin-tailwind": "^0.0.15",
"react": "^19",
"react-dom": "^19",
"tailwindcss": "^4.1.11",
"@lezer/generator": "^1.8.0"
},
"devDependencies": {
"@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.2",
"@types/bun": "latest",
"@types/react": "^19",
"@types/react-dom": "^19"
},
"prettier": {
"semi": false,
"singleQuote": true,
"printWidth": 100
}
}

7
src/app.tsx Normal file
View File

@ -0,0 +1,7 @@
export const App = () => {
return (
<div className="max-w-7xl mx-auto p-8 text-center relative z-10">
HI!
</div>
);
}

35
src/bin/shrimp Executable file
View File

@ -0,0 +1,35 @@
#! /usr/bin/env bun
import { parser } from '../parser/shrimp.js'
import { evaluate } from '../evaluator/evaluator.js'
const log = (...args: any[]) => console.log(...args)
log.error = (...args: any[]) => console.error(...args)
const repl = async () => {
log()
log('\033[38;5;219m»……¬¯\033[0m Shrimp REPL \033[38;5;219m¯¬……«\033[0m')
log()
process.stdout.write('> ')
const context = new Map<string, any>()
for await (const chunk of Bun.stdin.stream()) {
const input = new TextDecoder().decode(chunk).trim()
try {
const tree = parser.parse(input)
const output = evaluate(input, tree, context)
log(output)
} catch (error) {
log.error(`${error.message}`)
}
process.stdout.write('> ')
}
}
try {
repl()
} catch (error) {
log.error(`Fatal Error: ${error.message}`)
}

120
src/evaluator/evaluator.ts Normal file
View File

@ -0,0 +1,120 @@
import { nodeToString } from '@/evaluator/treeHelper'
import { Tree, type SyntaxNode } from '@lezer/common'
type Context = Map<string, any>
function getChildren(node: SyntaxNode): SyntaxNode[] {
const children = []
let child = node.firstChild
while (child) {
children.push(child)
child = child.nextSibling
}
return children
}
export const evaluate = (input: string, tree: Tree, context: Context) => {
// Just evaluate the top-level children, don't use iterate()
let result = undefined
let child = tree.topNode.firstChild
while (child) {
result = evaluateNode(child, input, context)
child = child.nextSibling
}
return result
}
const evaluateNode = (node: SyntaxNode, input: string, context: Context): any => {
const value = input.slice(node.from, node.to)
try {
switch (node.name) {
case 'Number': {
return parseFloat(value)
}
case 'Identifier': {
if (!context.has(value)) {
throw new Error(`Undefined identifier: ${value}`)
}
return context.get(value)
}
case 'BinOp': {
let [left, op, right] = getChildren(node)
left = assertNode(left, 'LeftOperand')
op = assertNode(op, 'Operator')
right = assertNode(right, 'RightOperand')
const leftValue = evaluateNode(left, input, context)
const opValue = input.slice(op.from, op.to)
const rightValue = evaluateNode(right, input, context)
switch (opValue) {
case '+':
return leftValue + rightValue
case '-':
return leftValue - rightValue
case '*':
return leftValue * rightValue
case '/':
return leftValue / rightValue
default:
throw new Error(`Unsupported operator: ${opValue}`)
}
}
case 'Assignment': {
const [identifier, expr] = getChildren(node)
const identifierNode = assertNode(identifier, 'Identifier')
const exprNode = assertNode(expr, 'Expression')
const name = input.slice(identifierNode.from, identifierNode.to)
const value = evaluateNode(exprNode, input, context)
context.set(name, value)
return value
}
case 'Function': {
const [params, body] = getChildren(node)
const paramNodes = getChildren(assertNode(params, 'Parameters'))
const bodyNode = assertNode(body, 'Body')
const paramNames = paramNodes.map((param) => {
const paramNode = assertNode(param, 'Identifier')
return input.slice(paramNode.from, paramNode.to)
})
return (...args: any[]) => {
if (args.length !== paramNames.length) {
throw new Error(`Expected ${paramNames.length} arguments, but got ${args.length}`)
}
const localContext = new Map(context)
paramNames.forEach((param, index) => {
localContext.set(param, args[index])
})
return evaluateNode(bodyNode, input, localContext)
}
}
default:
throw new Error(`Unsupported node type: ${node.name}`)
}
} catch (error) {
throw new Error(`Error evaluating node "${value}"\n${error.message}`)
}
}
const assertNode = (node: any, expectedName: string): SyntaxNode => {
if (!node) {
throw new Error(`Expected "${expectedName}", but got undefined`)
}
return node
}

View File

@ -0,0 +1,36 @@
import { type SyntaxNodeRef } from '@lezer/common'
export const nodeToString = (nodeRef: SyntaxNodeRef, input: string, maxDepth = 10) => {
const lines: string[] = []
function addNode(currentNodeRef: SyntaxNodeRef, depth = 0) {
if (depth > maxDepth) {
lines.push(' '.repeat(depth) + '...')
return
}
const indent = ' '.repeat(depth)
const text = input.slice(currentNodeRef.from, currentNodeRef.to)
const singleTokens = ['+', '-', '*', '/', '->', 'fn', '=', 'equals']
let child = currentNodeRef.node.firstChild
if (child) {
lines.push(`${indent}${currentNodeRef.name}`)
while (child) {
addNode(child, depth + 1)
child = child.nextSibling
}
} else {
const cleanText = currentNodeRef.name === 'String' ? text.slice(1, -1) : text
if (singleTokens.includes(currentNodeRef.name)) {
lines.push(`${indent}${currentNodeRef.name}`)
} else {
lines.push(`${indent}${currentNodeRef.name} ${cleanText}`)
}
}
}
addNode(nodeRef)
return lines.join('\n')
}

50
src/index.css Normal file
View File

@ -0,0 +1,50 @@
@import "tailwindcss";
@layer base {
:root {
@apply text-[rgba(255,255,255,0.87)] bg-[#242424] font-sans;
}
body {
@apply grid place-items-center min-w-[320px] min-h-screen relative m-0;
}
}
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
opacity: 0.05;
background: url("./logo.svg");
background-size: 256px;
transform: rotate(-12deg) scale(1.35);
animation: slide 30s linear infinite;
pointer-events: none;
}
@keyframes slide {
from {
background-position: 0 0;
}
to {
background-position: 256px 224px;
}
}
@keyframes spin {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion) {
*,
::before,
::after {
animation: none !important;
}
}

12
src/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shrimp</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>

7
src/parser/highlight.js Normal file
View File

@ -0,0 +1,7 @@
import { styleTags, tags } from '@lezer/highlight'
export const highlighting = styleTags({
Identifier: tags.name,
Number: tags.number,
String: tags.string,
})

52
src/parser/shrimp.grammar Normal file
View File

@ -0,0 +1,52 @@
@external propSource highlighting from "./highlight.js"
@top Program { expr* }
@skip { space }
@tokens {
@precedence { fn Boolean Identifier }
space { @whitespace+ }
Number { $[0-9]+ ('.' $[0-9]+)? }
Boolean { "true" | "false" }
String { '"' !["]* '"' }
Identifier { $[A-Za-z_]$[A-Za-z_0-9-]* }
fn { "fn" }
arrow { "->" }
equals { "=" }
"+"
"-"
"*"
"/"
leftParen { "(" }
rightParen { ")" }
}
@precedence {
multiplicative @left,
additive @left,
function @right
assignment @right
}
expr {
Assignment |
Function |
BinOp |
atom
}
BinOp {
expr !multiplicative "*" expr |
expr !multiplicative "/" expr |
expr !additive "+" expr |
expr !additive "-" expr
}
Params { Identifier* }
Function { !function fn Params arrow expr }
atom { Identifier | Number | String | Boolean | leftParen expr rightParen }
Assignment { Identifier !assignment equals expr }

View File

@ -0,0 +1,11 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Program = 1,
Assignment = 2,
Identifier = 3,
Function = 4,
Params = 5,
BinOp = 6,
Number = 11,
String = 12,
Boolean = 13

208
src/parser/shrimp.test.ts Normal file
View File

@ -0,0 +1,208 @@
import { expectTree, regenerateParser } from '@/parser/test-helper'
import { beforeAll, describe, test } from 'bun:test'
describe('BinOp', () => {
beforeAll(() => regenerateParser())
test('addition tests', () => {
expectTree('2 + 3').toMatch(`
BinOp
Number 2
+
Number 3
`)
})
test('subtraction tests', () => {
expectTree('5 - 2').toMatch(`
BinOp
Number 5
-
Number 2
`)
})
test('multiplication tests', () => {
expectTree('4 * 3').toMatch(`
BinOp
Number 4
*
Number 3
`)
})
test('division tests', () => {
expectTree('8 / 2').toMatch(`
BinOp
Number 8
/
Number 2
`)
})
test('mixed operations with precedence', () => {
expectTree('2 + 3 * 4 - 5 / 1').toMatch(`
BinOp
BinOp
Number 2
+
BinOp
Number 3
*
Number 4
-
BinOp
Number 5
/
Number 1
`)
})
})
describe('Fn', () => {
beforeAll(() => regenerateParser())
test('parses function with single parameter', () => {
expectTree('fn x -> x + 1').toMatch(`
Function
Params
Identifier x
BinOp
Identifier x
+
Number 1`)
})
test('parses function with multiple parameters', () => {
expectTree('fn x y -> x * y').toMatch(`
Function
Params
Identifier x
Identifier y
BinOp
Identifier x
*
Identifier y`)
})
test('parses nested functions', () => {
expectTree('fn x -> fn y -> x + y').toMatch(`
Function
Params
Identifier x
Function
Params
Identifier y
BinOp
Identifier x
+
Identifier y`)
})
})
describe('Identifier', () => {
beforeAll(() => regenerateParser())
test('parses hyphenated identifiers correctly', () => {
expectTree('my-var - another-var').toMatch(`
BinOp
Identifier my-var
-
Identifier another-var`)
expectTree('double--trouble - another-var').toMatch(`
BinOp
Identifier double--trouble
-
Identifier another-var`)
expectTree('tail-- - another-var').toMatch(`
BinOp
Identifier tail--
-
Identifier another-var`)
})
})
describe('Assignment', () => {
beforeAll(() => regenerateParser())
test('parses assignment with addition', () => {
expectTree('x = 5 + 3').toMatch(`
Assignment
Identifier x
BinOp
Number 5
+
Number 3`)
})
test('parses assignment with functions', () => {
expectTree('add = fn a b -> a + b').toMatch(`
Assignment
Identifier add
Function
Params
Identifier a
Identifier b
BinOp
Identifier a
+
Identifier b`)
})
})
describe('Parentheses', () => {
beforeAll(() => regenerateParser())
test('parses expressions with parentheses correctly', () => {
expectTree('(2 + 3) * 4').toMatch(`
BinOp
BinOp
Number 2
+
Number 3
*
Number 4`)
})
test('parses nested parentheses correctly', () => {
expectTree('((1 + 2) * (3 - 4)) / 5').toMatch(`
BinOp
BinOp
BinOp
Number 1
+
Number 2
*
BinOp
Number 3
-
Number 4
/
Number 5`)
})
})
describe('multiline', () => {
beforeAll(() => regenerateParser())
test('parses multiline expressions', () => {
expectTree(`
5 + 4
fn x -> x - 1
`).toMatch(`
BinOp
Number 5
+
Number 4
Function
Params
Identifier x
BinOp
Identifier x
-
Number 1
`)
})
})

18
src/parser/shrimp.ts Normal file
View File

@ -0,0 +1,18 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {highlighting} from "./highlight.js"
export const parser = LRParser.deserialize({
version: 14,
states: "$OQVQPOOOkQPO'#CsO!fQQO'#C`O!nQPO'#CjOOQO'#Cs'#CsOVQPO'#CsOOQO'#Co'#CoQVQPOOOVQPO,58xOOQO'#Ck'#CkO#cQQO'#CaO#kQQO,58zOVQPO,58|OVQPO,58|O#pQPO,59_OOQO-E6h-E6hO$RQPO1G.dOOQO-E6i-E6iOVQPO1G.fOOQO1G.h1G.hO$yQPO1G.hOOQO1G.y1G.yO%qQPO7+$Q",
stateData: "&n~ObOS~ORPOZSO[SO]SOeQOhTO~OdWORgXVgXWgXXgXYgXZgX[gX]gX`gXegXhgXigX~ORXOfTP~OV[OW[OX]OY]OR^XZ^X[^X]^X`^Xe^Xh^X~ORXOfTX~OfbO~OV[OW[OX]OY]OieO~OV[OW[OX]OY]ORQiZQi[Qi]Qi`QieQihQiiQi~OV[OW[ORUiXUiYUiZUi[Ui]Ui`UieUihUiiUi~OV[OW[OX]OY]ORSqZSq[Sq]Sq`SqeSqhSqiSq~Oe]R]~",
goto: "!fhPPiPiriPPPPPPPu{PPP!RPPPi_UOTVW[]bRZQQVOR_VQYQRaYSROVQ^TQ`WQc[Qd]Rfb",
nodeNames: "⚠ Program Assignment Identifier Function Params BinOp * / + - Number String Boolean",
maxTerm: 25,
propSources: [highlighting],
skippedNodes: [0],
repeatNodeCount: 2,
tokenData: "*f~RjX^!spq!srs#hxy$Vyz$[z{$a{|$f}!O$k!P!Q$x!Q![$}!_!`%h!c!}%m#R#S%m#T#Y%m#Y#Z&R#Z#h%m#h#i)`#i#o%m#y#z!s$f$g!s#BY#BZ!s$IS$I_!s$I|$JO!s$JT$JU!s$KV$KW!s&FU&FV!s~!xYb~X^!spq!s#y#z!s$f$g!s#BY#BZ!s$IS$I_!s$I|$JO!s$JT$JU!s$KV$KW!s&FU&FV!s~#kTOr#hrs#zs;'S#h;'S;=`$P<%lO#h~$PO[~~$SP;=`<%l#h~$[Oh~~$aOi~~$fOV~~$kOX~R$pPYP!`!a$sQ$xOfQ~$}OW~~%SQZ~!O!P%Y!Q![$}~%]P!Q![%`~%ePZ~!Q![%`~%mOd~~%rTR~}!O%m!Q![%m!c!}%m#R#S%m#T#o%m~&WWR~}!O%m!Q![%m!c!}%m#R#S%m#T#U&p#U#b%m#b#c(x#c#o%m~&uVR~}!O%m!Q![%m!c!}%m#R#S%m#T#`%m#`#a'[#a#o%m~'aVR~}!O%m!Q![%m!c!}%m#R#S%m#T#g%m#g#h'v#h#o%m~'{VR~}!O%m!Q![%m!c!}%m#R#S%m#T#X%m#X#Y(b#Y#o%m~(iT]~R~}!O%m!Q![%m!c!}%m#R#S%m#T#o%m~)PTe~R~}!O%m!Q![%m!c!}%m#R#S%m#T#o%m~)eVR~}!O%m!Q![%m!c!}%m#R#S%m#T#f%m#f#g)z#g#o%m~*PVR~}!O%m!Q![%m!c!}%m#R#S%m#T#i%m#i#j'v#j#o%m",
tokenizers: [0, 1],
topRules: {"Program":[0,1]},
tokenPrec: 255
})

83
src/parser/test-helper.ts Normal file
View File

@ -0,0 +1,83 @@
import { beforeAll, expect } from 'bun:test'
import { Tree, TreeCursor } from '@lezer/common'
import grammarFile from './shrimp.grammar'
import { parser } from './shrimp.ts'
import { $ } from 'bun'
// Regenerate the parser if the grammar file is newer than the generated parser
// This makes --watch work without needing to manually regenerate the parser
export const regenerateParser = async () => {
const grammarStat = await Bun.file('src/parser/shrimp.grammar').stat()
const jsStat = await Bun.file('src/parser/shrimp.ts').stat()
if (grammarStat.mtime <= jsStat.mtime) return
console.log(`Regenerating parser from ${grammarFile}...`)
await $`bun generate-parser `
}
export const expectTree = (input: string) => {
const tree = parser.parse(input)
return {
toMatch: (expected: string) => {
expect(treeToString(tree, input)).toEqual(trimWhitespace(expected))
},
}
}
const treeToString = (tree: Tree, input: string): string => {
const lines: string[] = []
const addNode = (cursor: TreeCursor, depth: number) => {
if (!cursor.name) return
const indent = ' '.repeat(depth)
const text = input.slice(cursor.from, cursor.to)
const nodeName = cursor.name // Save the node name before moving cursor
if (cursor.firstChild()) {
lines.push(`${indent}${nodeName}`)
do {
addNode(cursor, depth + 1)
} while (cursor.nextSibling())
cursor.parent()
} else {
const cleanText = nodeName === 'String' ? text.slice(1, -1) : text
// Node names that should be displayed as single tokens (operators, keywords)
const singleTokens = ['+', '-', '*', '/', '->']
if (singleTokens.includes(nodeName)) {
lines.push(`${indent}${nodeName}`)
} else {
lines.push(`${indent}${nodeName} ${cleanText}`)
}
}
}
const cursor = tree.cursor()
if (cursor.firstChild()) {
do {
addNode(cursor, 0)
} while (cursor.nextSibling())
}
return lines.join('\n')
}
const trimWhitespace = (str: string): string => {
const lines = str.split('\n').filter((line) => line.trim().length > 0)
const firstLine = lines[0]
if (!firstLine) return ''
const leadingWhitespace = firstLine.match(/^(\s*)/)?.[1] || ''
return lines
.map((line) => {
if (!line.startsWith(leadingWhitespace)) {
let foundWhitespace = line.match(/^(\s*)/)?.[1] || ''
throw new Error(
`Line has inconsistent leading whitespace: "${line}" (found "${foundWhitespace}", expected "${leadingWhitespace}")`
)
}
return line.slice(leadingWhitespace.length)
})
.join('\n')
}

30
src/server.tsx Normal file
View File

@ -0,0 +1,30 @@
import { serve } from "bun";
import index from "./index.html";
const server = serve({
routes: {
"/*": index,
"/api/hello": {
async GET(req) {
return Response.json({
message: "Hello, world!",
method: "GET",
});
},
async PUT(req) {
return Response.json({
message: "Hello, world!",
method: "PUT",
});
},
},
},
development: process.env.NODE_ENV !== "production" && {
hmr: true,
console: true,
},
});
console.log(`🚀 Server running at ${server.url}`);

36
tsconfig.json Normal file
View File

@ -0,0 +1,36 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-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": ".",
"paths": {
"@/*": ["./src/*"]
},
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"exclude": ["dist", "node_modules"]
}