Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
165 changes: 165 additions & 0 deletions docs/why-chadscript.md
Original file line number Diff line number Diff line change
@@ -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<T>`, 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<number, User> = 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<T>(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)
49 changes: 22 additions & 27 deletions examples/hackernews/app.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}

Expand All @@ -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);
2 changes: 1 addition & 1 deletion lib/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
if (route.paramNames !== "") {
Expand Down
7 changes: 7 additions & 0 deletions scripts/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)...");
Expand Down
6 changes: 6 additions & 0 deletions src/codegen/expressions/method-calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion src/codegen/expressions/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 1 addition & 4 deletions src/codegen/expressions/variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/codegen/infrastructure/generator-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 15 additions & 4 deletions src/codegen/stdlib/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
9 changes: 8 additions & 1 deletion src/codegen/types/objects/class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading