commit 695d7f22bdb1c9a028d39cc799eeee78d8015ddf Author: Corey Johnson Date: Thu Sep 25 20:17:27 2025 -0700 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b5a267e --- /dev/null +++ b/CLAUDE.md @@ -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 ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +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

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8afc0f2 --- /dev/null +++ b/README.md @@ -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. diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..f3c5cd4 --- /dev/null +++ b/build.ts @@ -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 Output directory (default: "dist") + --minify Enable minification (or --minify.whitespace, --minify.syntax, etc) + --sourcemap Sourcemap type: none|linked|inline|external + --target Build target: browser|bun|node + --format Output format: esm|cjs|iife + --splitting Enable code splitting + --packages Package handling: bundle|external + --public-path Public path for assets + --env Environment handling: inline|disable|prefix* + --conditions Package.json export conditions (comma separated) + --external External packages (comma separated) + --banner Add banner text to output + --footer Add footer text to output + --define 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 { + const config: Partial = {}; + 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`); diff --git a/bun-env.d.ts b/bun-env.d.ts new file mode 100644 index 0000000..72f1c26 --- /dev/null +++ b/bun-env.d.ts @@ -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; +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..e62fb33 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..8877354 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,4 @@ + +[serve.static] +plugins = ["bun-plugin-tailwind"] +env = "BUN_PUBLIC_*" \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..79a3263 --- /dev/null +++ b/package.json @@ -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 + } +} \ No newline at end of file diff --git a/src/app.tsx b/src/app.tsx new file mode 100644 index 0000000..b76db42 --- /dev/null +++ b/src/app.tsx @@ -0,0 +1,7 @@ +export const App = () => { + return ( +
+ HI! +
+ ); +} \ No newline at end of file diff --git a/src/bin/shrimp b/src/bin/shrimp new file mode 100755 index 0000000..e4a8397 --- /dev/null +++ b/src/bin/shrimp @@ -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() + 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}`) +} diff --git a/src/evaluator/evaluator.ts b/src/evaluator/evaluator.ts new file mode 100644 index 0000000..f6b7797 --- /dev/null +++ b/src/evaluator/evaluator.ts @@ -0,0 +1,120 @@ +import { nodeToString } from '@/evaluator/treeHelper' +import { Tree, type SyntaxNode } from '@lezer/common' + +type Context = Map + +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 +} diff --git a/src/evaluator/treeHelper.ts b/src/evaluator/treeHelper.ts new file mode 100644 index 0000000..7420cc5 --- /dev/null +++ b/src/evaluator/treeHelper.ts @@ -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') +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..9addc76 --- /dev/null +++ b/src/index.css @@ -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; + } +} diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..2460d2d --- /dev/null +++ b/src/index.html @@ -0,0 +1,12 @@ + + + + + + Shrimp + + +
+ + + diff --git a/src/parser/highlight.js b/src/parser/highlight.js new file mode 100644 index 0000000..5adeb55 --- /dev/null +++ b/src/parser/highlight.js @@ -0,0 +1,7 @@ +import { styleTags, tags } from '@lezer/highlight' + +export const highlighting = styleTags({ + Identifier: tags.name, + Number: tags.number, + String: tags.string, +}) diff --git a/src/parser/shrimp.grammar b/src/parser/shrimp.grammar new file mode 100644 index 0000000..1c25312 --- /dev/null +++ b/src/parser/shrimp.grammar @@ -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 } \ No newline at end of file diff --git a/src/parser/shrimp.terms.ts b/src/parser/shrimp.terms.ts new file mode 100644 index 0000000..0d856a9 --- /dev/null +++ b/src/parser/shrimp.terms.ts @@ -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 diff --git a/src/parser/shrimp.test.ts b/src/parser/shrimp.test.ts new file mode 100644 index 0000000..f64ecbe --- /dev/null +++ b/src/parser/shrimp.test.ts @@ -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 + `) + }) +}) diff --git a/src/parser/shrimp.ts b/src/parser/shrimp.ts new file mode 100644 index 0000000..f798f00 --- /dev/null +++ b/src/parser/shrimp.ts @@ -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 +}) diff --git a/src/parser/test-helper.ts b/src/parser/test-helper.ts new file mode 100644 index 0000000..11e0d51 --- /dev/null +++ b/src/parser/test-helper.ts @@ -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') +} diff --git a/src/server.tsx b/src/server.tsx new file mode 100644 index 0000000..3fd46e7 --- /dev/null +++ b/src/server.tsx @@ -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}`); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..632a36f --- /dev/null +++ b/tsconfig.json @@ -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"] +}