diff --git a/CLAUDE.md b/CLAUDE.md index 7ffee29a..5e0c38ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,10 +42,19 @@ When working in a git worktree, `vendor/` and `c_bridges/*.o` must exist. Symlin ```bash ln -s /path/to/main/repo/vendor vendor +ln -s /path/to/main/repo/c_bridges/regex-bridge.o c_bridges/regex-bridge.o +# (repeat for each .o file, or run build-vendor.sh to build them fresh) ``` The `c_bridges/*.c` source files are tracked in git, but the `.o` files are built by `scripts/build-vendor.sh`. -If `.o` files are missing, either run the build script or symlink from a repo that has them built. +**Always rebuild from source** rather than symlinking to avoid stale artifacts — the symlinked `.o` +may have been compiled from an older version of the `.c` source: + +```bash +bash scripts/build-vendor.sh +``` + +`npm test` automatically runs `build-vendor.sh` to detect and rebuild stale bridges before each test run. # ChadScript Architecture Guide diff --git a/docs/why-chadscript.md b/docs/why-chadscript.md new file mode 100644 index 00000000..afe244be --- /dev/null +++ b/docs/why-chadscript.md @@ -0,0 +1,165 @@ +# Why ChadScript? + +ChadScript is a systems programming language with three goals: **as fast as C, as safe as Rust, as ergonomic as TypeScript**. + +It's not a TypeScript compiler, runtime, or transpiler. It's a new language that uses TypeScript's syntax as its surface — the same parser, the same class and interface shapes, the same `async`/`await` — but compiles to native ELF or Mach-O binaries via LLVM. No Node.js, no JVM, no runtime at all. + +## The problem it solves + +Systems languages are fast but painful. Scripting languages are ergonomic but slow. The gap between them forces you to choose between esoteric systems languages, or write user friendly interpreted languages for velocity and accept the overhead. + +ChadScript's bet: TypeScript syntax is expressive enough to be the surface of a systems language, and a smart compiler can translate it directly to native code without a runtime. + +## As fast as C + +ChadScript programs compile via LLVM — the same backend used by Clang, Rust, and Swift. The optimizer applies decades of battle-tested passes. Function calls to the standard library are direct native calls into C libraries: `sqlite3_exec`, `curl_easy_perform` — no FFI overhead, no marshalling. + +Startup time is as fast as ~2ms. + +| Runtime | Startup | +|---------|---------| +| ChadScript | ~2ms | +| Go | ~5ms | +| Bun | ~12ms | +| Node.js | ~65ms | + +[See full benchmarks →](/benchmarks) + +## As safe as Rust + +Rust's safety story is powerful but comes with the borrow checker: a new mental model, lifetime annotations, and a steep learning curve. ChadScript takes a different approach — TypeScript's type system plus a GC — and reaches a comparable safety bar for most real programs, without the complexity. + +**Null safety.** In C, `null` is a zero pointer the compiler trusts you to never dereference. In ChadScript, `string` is *never* null. If a value can be absent, its type is `string | null` and the compiler forces you to check before use. This is equivalent to Rust's `Option`, without the wrapping and unwrapping ceremony. + +```typescript +function greet(name: string | null): string { + if (name === null) return "Hello, stranger" + return `Hello, ${name}` // name is string here — compiler knows +} +``` + +**Bounds-checked array access.** Out-of-bounds array reads are a primary source of memory corruption in C. ChadScript's type system flags array access as potentially absent and requires you to handle that case — the same guarantee Rust's `.get()` provides, enforced by the compiler. + +**No dangling pointers.** Memory is managed by a garbage collector embedded in the binary. You never call `free`. There are no use-after-free bugs. + +**No undefined behavior at the language level.** Closures capture by value; mutating a variable after a closure captures it is a *compile error*, not a silent bug: + +```typescript +let x = 1 +const f = () => console.log(x) +x = 2 // error: 'x' is reassigned after being captured by a closure +``` + +**Compile-time type checking.** No `any`, no runtime type inspection. Every value has a known type at compile time. If the code compiles, the types are correct. + +**FFI is explicitly unsafe.** ChadScript can call C libraries directly via `declare function` — the same escape hatch Rust provides with `unsafe {}` blocks. Outside of that explicit boundary, the safety guarantees hold. + +## As ergonomic as TypeScript + +### Familiar syntax + +If you write TypeScript today, ChadScript is immediately readable. Classes, interfaces, `async`/`await`, arrow functions, template literals, destructuring, `Map`, `Set`, `for...of` — they all work and look exactly as you'd expect. + +```typescript +interface User { + id: number; + name: string; + email: string | null; +} + +class UserStore { + private users: Map = new Map() + + add(user: User): void { + this.users.set(user.id, user) + } + + find(id: number): User | null { + return this.users.get(id) ?? null + } +} +``` + +This compiles to a native binary. No runtime. The `Map` is a C struct under the hood. + +### IDE support + +Run `chad init` in your project to generate a `tsconfig.json` that points your editor at ChadScript's type definitions. You get full autocomplete, go-to-definition, and inline error highlighting in VS Code — the same experience as writing TypeScript. + +### Batteries included + +No `npm install`. Everything you'd reach for npm for ships with the compiler, backed by proven C libraries with zero overhead: + +| You write | Backed by | +|-----------|-----------| +| `fetch('https://...')` | libcurl | +| `sqlite.open('db.sqlite')` | SQLite | +| `crypto.sha256(data)` | OpenSSL | +| `httpServe(...)` | libwebsockets | +| `JSON.parse(str)` | yyjson | +| `fs.readFile(path)` | libc | + +No wrappers, no reflection. The compiler generates direct calls to the C API. + +### Single-binary deploy + +```bash +chad build app.ts -o app +scp app user@server:/usr/local/bin/ +``` + +That's it. No Docker required for the application itself (though you can if you want). No Node.js version mismatches. No `node_modules` to sync. One file. + +You can even embed static assets at compile time: + +```typescript +const html = ChadScript.embed('./public/index.html') // compiled in as a string constant +``` + +### LLM-friendly + +TypeScript is the language LLMs generate most fluently — it's massively overrepresented in training data. ChadScript inherits this: ask any model to write a ChadScript HTTP server or SQLite query and it will produce working code on the first try. The static type system means the model's output is more likely to be correct; the types serve as inline documentation for what each value is. + +## vs Node.js / Bun / Deno + +The JS runtimes are excellent for web applications. ChadScript targets the use cases where a runtime is a liability: + +- **CLI tools** — a 65ms startup penalty is user-visible. A 2ms startup isn't. +- **System services** — no runtime to maintain, no version to pin, no cold start in containers. +- **Single-binary distribution** — `chad build app.ts -o app` produces one file you can `scp` anywhere. +- **Resource-constrained environments** — lower memory footprint, no JIT heap. + +## vs Rust + +Rust is a great language. ChadScript is for teams who want native performance but aren't ready to invest in learning the borrow checker. If your team knows TypeScript, ChadScript has a near-zero learning curve for the syntax. The trade-off: you get a GC instead of zero-cost ownership. For most applications, GC pauses are not a problem. + +If you need zero GC pauses or are writing an OS kernel, use Rust. + +## vs Go + +Go is close in philosophy — garbage collected, fast startup, single-binary deploy. The main difference is syntax and ecosystem. ChadScript uses TypeScript syntax, which means better IDE tooling (the TS language server works on `.ts` files), better LLM code generation, and a shallower learning curve for the majority of developers who already know TypeScript. + +## vs C + +C gives you maximum control and maximum footguns. No null safety. No GC. Manual memory management. ChadScript compiles to code as fast as C (same LLVM backend) while preventing the class of bugs that C programs routinely suffer: null dereferences, use-after-free, uninitialized reads. + +--- + +## Real-world proof + +The compiler itself is the proof of concept. ChadScript is self-hosting: the ~45k-line TypeScript compiler compiles itself to a native binary. That binary can then compile the compiler again. If the language couldn't handle real programs, it couldn't compile itself. + +The [Hacker News clone](https://github.com/cs01/ChadScript/tree/main/examples/hackernews) is a practical example: SQLite database, HTTP server, embedded HTML/CSS/JS assets, JSON API — all in one TypeScript file, shipping as a single binary. + +--- + +## Get started + +```bash +curl -fsSL https://raw.githubusercontent.com/cs01/ChadScript/main/install.sh | sh +chad run examples/hello.ts +``` + +→ [Installation](/getting-started/installation) +→ [Quickstart](/getting-started/quickstart) +→ [Supported Features](/language/features) diff --git a/examples/hackernews/app.ts b/examples/hackernews/app.ts index 2c9ef700..77d43056 100644 --- a/examples/hackernews/app.ts +++ b/examples/hackernews/app.ts @@ -1,6 +1,7 @@ // Hacker News Clone - full-stack app with SQLite, embedded files, and a JSON API import { ArgumentParser } from "chadscript/argparse"; import { httpServe } from "chadscript/http"; +import { Router, Context } from "chadscript/router"; const parser = new ArgumentParser( "hackernews", @@ -11,18 +12,6 @@ parser.parse(process.argv); const port = parseInt(parser.getOption("port")); -interface HttpRequest { - method: string; - path: string; - body: string; - contentType: string; -} - -interface HttpResponse { - status: number; - body: string; -} - interface Post { id: string; title: string; @@ -134,27 +123,32 @@ for (let i = 0; i < seedPosts.length; i++) { ]); } -function handleRequest(req: HttpRequest): HttpResponse { - console.log(req.method + " " + req.path); +function getPosts(c: Context): HttpResponse { + const posts: Post[] = sqlite.query( + db, + "SELECT id, title, url, points FROM posts ORDER BY points DESC", + ); + return c.json(posts); +} - if (req.path === "/api/posts") { - const posts: Post[] = sqlite.query( - db, - "SELECT id, title, url, points FROM posts ORDER BY points DESC", - ); - return { status: 200, body: JSON.stringify(posts) }; - } +function upvotePost(c: Context): HttpResponse { + const idStr = c.req.param("id"); + sqlite.exec(db, "UPDATE posts SET points = points + 1 WHERE id = ?", [idStr]); + return c.json('{"ok":true}'); +} - if (req.method === "POST" && req.path.startsWith("/upvote/")) { - const idStr = req.path.substring(8, req.path.length); - sqlite.exec(db, "UPDATE posts SET points = points + 1 WHERE id = ?", [idStr]); - return { status: 200, body: '{"ok":true}' }; - } +const app = new Router(); +app.get("/api/posts", getPosts); +app.post("/upvote/:id", upvotePost); +function handleRequest(req: HttpRequest): HttpResponse { if (req.path === "/") { return ChadScript.serveEmbedded("index.html"); } - + const res = app.handle(req); + if (res.status !== 404) { + return res; + } return ChadScript.serveEmbedded(req.path); } @@ -165,4 +159,5 @@ console.log(` SQLite database running in-memory with ${seedPosts.length} posts` console.log(""); console.log(`Open http://localhost:${port} in your browser`); console.log(""); + httpServe(port, handleRequest); diff --git a/lib/router.ts b/lib/router.ts index 1d204382..a71addfe 100644 --- a/lib/router.ts +++ b/lib/router.ts @@ -291,7 +291,7 @@ export class Router { for (let i = 0; i < this.routes.length; i++) { const route = this.routes[i]; const outerGroup = match[route.groupOffset]; - if (outerGroup !== "") { + if (outerGroup !== undefined && outerGroup !== null && outerGroup !== "") { if (route.method === "*" || route.method === method) { const params = new Map(); if (route.paramNames !== "") { diff --git a/scripts/test.js b/scripts/test.js index 6af5dcf3..dbc50777 100644 --- a/scripts/test.js +++ b/scripts/test.js @@ -15,6 +15,13 @@ try { process.exit(1); } +console.log("Checking C bridge artifacts..."); +try { + execSync("bash scripts/build-vendor.sh", { cwd: projectRoot, stdio: "inherit" }); +} catch (error) { + console.warn("Warning: build-vendor.sh failed (bridges may be stale)"); +} + const chad = path.join(projectRoot, ".build", "chad"); if (!fs.existsSync(chad)) { console.log("Building native compiler (.build/chad)..."); diff --git a/src/codegen/expressions/method-calls.ts b/src/codegen/expressions/method-calls.ts index bb2fbf14..0e0f8ef3 100644 --- a/src/codegen/expressions/method-calls.ts +++ b/src/codegen/expressions/method-calls.ts @@ -1313,6 +1313,12 @@ export class MethodCallGenerator { } const exprObjBase = expr.object as ExprBase; + if (exprObjBase.type === "method_call") { + this.ctx.emitError( + `Method chaining on class instances is not supported. Assign the result to a variable first.`, + expr.loc, + ); + } if (exprObjBase.type === "variable") { const varName = (expr.object as VariableNode).name; const ast = this.ctx.getAst(); diff --git a/src/codegen/expressions/orchestrator.ts b/src/codegen/expressions/orchestrator.ts index c9579d97..9f9485ae 100644 --- a/src/codegen/expressions/orchestrator.ts +++ b/src/codegen/expressions/orchestrator.ts @@ -256,7 +256,9 @@ export class ExpressionGenerator { const cap = captures[i] as { name: string; llvmType: string }; const allocaReg = this.ctx.symbolTable.getAlloca(cap.name); if (!allocaReg) { - throw new Error(`Closure capture error: variable '${cap.name}' not found`); + this.ctx.emitError( + `cannot capture '${cap.name}' in closure — module-level variables are not in scope. move the variable into a function or class.`, + ); } const valueReg = this.ctx.nextTemp(); diff --git a/src/codegen/expressions/variables.ts b/src/codegen/expressions/variables.ts index 93c508c3..a6383048 100644 --- a/src/codegen/expressions/variables.ts +++ b/src/codegen/expressions/variables.ts @@ -180,10 +180,7 @@ export class VariableExpressionGenerator { for (let fi = 0; fi < funcCount; fi++) { const funcName = this.ctx.getAstFunctionNameAt(fi); if (funcName === name) { - const temp = this.ctx.nextTemp(); - this.ctx.emit(`${temp} = inttoptr i64 1 to i8*`); - this.ctx.setVariableType(temp, "i8*"); - return temp; + return "_cs_" + name; } } diff --git a/src/codegen/infrastructure/generator-context.ts b/src/codegen/infrastructure/generator-context.ts index c8d51804..3a1972ed 100644 --- a/src/codegen/infrastructure/generator-context.ts +++ b/src/codegen/infrastructure/generator-context.ts @@ -179,6 +179,7 @@ export interface IJsonGenerator { canHandle(expr: MethodCallNode): boolean; generateParse(expr: MethodCallNode, params: string[], typeParam?: string): string; generateStringify(expr: MethodCallNode, params: string[]): string; + generateStringifyExpr(arg: Expression, params: string[]): string; } export interface IDateGenerator { @@ -1895,6 +1896,8 @@ export class MockGeneratorContext implements IGeneratorContext { generateParse: (_expr: MethodCallNode, _params: string[], _typeParam?: string): string => "%mock_json_parse", generateStringify: (_expr: MethodCallNode, _params: string[]): string => "%mock_json_stringify", + generateStringifyExpr: (_arg: Expression, _params: string[]): string => + "%mock_json_stringify_expr", }; dateGen: IDateGenerator = { canHandle: (_expr: MethodCallNode): boolean => false, diff --git a/src/codegen/stdlib/json.ts b/src/codegen/stdlib/json.ts index 7af76c3b..f5396dac 100644 --- a/src/codegen/stdlib/json.ts +++ b/src/codegen/stdlib/json.ts @@ -495,24 +495,35 @@ export class JsonGenerator { return this.ctx.emitCall("i8*", "@csyyjson_stringify", `i8* ${jsonDoc}`); } + generateStringifyExpr(arg: Expression, params: string[]): string { + return this.generateStringifyArgWithSpaces(arg, params, 0); + } + generateStringify(expr: MethodCallNode, params: string[]): string { if (expr.args.length < 1) { return this.ctx.emitError("JSON.stringify() requires 1 argument", expr.loc); } + const spaces = this.getSpaces(expr); if (expr.args[0].type === "type_assertion") { - return this.generateStringifyArg( + return this.generateStringifyArgWithSpaces( (expr.args[0] as unknown as TypeAssertionNode).expression, - expr, params, + spaces, ); } - return this.generateStringifyArg(expr.args[0], expr, params); + return this.generateStringifyArgWithSpaces(expr.args[0], params, spaces); } private generateStringifyArg(arg: Expression, expr: MethodCallNode, params: string[]): string { - const spaces = this.getSpaces(expr); + return this.generateStringifyArgWithSpaces(arg, params, this.getSpaces(expr)); + } + private generateStringifyArgWithSpaces( + arg: Expression, + params: string[], + spaces: number, + ): string { if (this.ctx.isStringExpression(arg)) { return this.stringifyString(arg, params); } diff --git a/src/codegen/types/objects/class.ts b/src/codegen/types/objects/class.ts index a819bd16..ede1d103 100644 --- a/src/codegen/types/objects/class.ts +++ b/src/codegen/types/objects/class.ts @@ -1111,7 +1111,14 @@ export class ClassGenerator { } } } - const val = this.ctx.generateExpression(arg, params); + const autoSerialize = + ai === 0 && + methodName === "json" && + className === "Context" && + !this.ctx.isStringExpression(arg); + const val = autoSerialize + ? this.ctx.jsonGen.generateStringifyExpr(arg, params) + : this.ctx.generateExpression(arg, params); this.ctx.setExpectedCallbackParamType(null); let argType = "double"; diff --git a/tests/fixtures/network/context-json-any.ts b/tests/fixtures/network/context-json-any.ts new file mode 100644 index 00000000..6506a656 --- /dev/null +++ b/tests/fixtures/network/context-json-any.ts @@ -0,0 +1,42 @@ +// @test-description: c.json() accepts objects and auto-serializes them + +import { Router, Context } from "chadscript/router"; + +function test(): void { + const app = new Router(); + app.get("/obj", (c: Context) => { + return c.json({ message: "hello" }); + }); + app.get("/str", (c: Context) => { + return c.json('{"already":"json"}'); + }); + + const r1 = app.handle({ + method: "GET", + path: "/obj", + body: "", + contentType: "", + headers: "", + bodyLen: 0, + }); + if (r1.body !== '{"message":"hello"}') { + console.log("FAIL obj: " + r1.body); + process.exit(1); + } + + const r2 = app.handle({ + method: "GET", + path: "/str", + body: "", + contentType: "", + headers: "", + bodyLen: 0, + }); + if (r2.body !== '{"already":"json"}') { + console.log("FAIL str: " + r2.body); + process.exit(1); + } + + console.log("TEST_PASSED"); +} +test(); diff --git a/tests/fixtures/network/router-named-handlers.ts b/tests/fixtures/network/router-named-handlers.ts new file mode 100644 index 00000000..99e700e2 --- /dev/null +++ b/tests/fixtures/network/router-named-handlers.ts @@ -0,0 +1,46 @@ +// @test-description: Router accepts named top-level functions as route handlers + +import { Router, Context } from "chadscript/router"; + +function getHello(c: Context): HttpResponse { + return c.text("hello"); +} + +function getJson(c: Context): HttpResponse { + return c.json('{"ok":true}'); +} + +function testRouter(): void { + const app = new Router(); + app.get("/hello", getHello); + app.get("/json", getJson); + + const r1 = app.handle({ + method: "GET", + path: "/hello", + body: "", + contentType: "", + headers: "", + bodyLen: 0, + }); + if (r1.body !== "hello") { + console.log("FAIL hello: " + r1.body); + process.exit(1); + } + + const r2 = app.handle({ + method: "GET", + path: "/json", + body: "", + contentType: "", + headers: "", + bodyLen: 0, + }); + if (r2.body !== '{"ok":true}') { + console.log("FAIL json: " + r2.body); + process.exit(1); + } + + console.log("TEST_PASSED"); +} +testRouter();