Skip to content

Commit 8f59086

Browse files
mowtwocodex
andcommitted
feat: rewrite router in tu
Co-authored-by: Codex <codex@openai.com>
1 parent dff9c6b commit 8f59086

16 files changed

Lines changed: 452 additions & 267 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
node_modules/
22
dist/
33
lib/
4+
.tu-build/
5+
.tu-check/
46
.turbo/
57
coverage/
68
*.log

packages/compiler/src/ast.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,8 @@ export interface Param extends Ranged {
271271
name: string
272272
/** Type annotation as a raw identifier ('string', 'number'…) — full type AST lands later. */
273273
type?: string
274+
/** True when the parameter was written with a `?` marker, e.g. `opts?: T`. */
275+
optional?: boolean
274276
/** Source byte range of the type expression, when a `: T` annotation exists. */
275277
typeStart?: number
276278
typeEnd?: number

packages/compiler/src/codegen.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2510,6 +2510,7 @@ class Codegen {
25102510
// TS's implicit `any`. Forces narrow / cast at use sites; pairs
25112511
// with the M8 `type.as` helper for typed runtime casts.
25122512
if (this.tsMode) {
2513+
if (p.optional) this.write('?')
25132514
this.write(': ')
25142515
if (p.type !== undefined && p.typeStart !== undefined && p.typeEnd !== undefined) {
25152516
this.mark(p.typeStart, p.typeEnd, () => this.write(p.type!))
@@ -2873,6 +2874,7 @@ class Codegen {
28732874
// with single-file first-call inference for omitted annotations.
28742875
if (this.tsMode) {
28752876
const inferred = this.inferredParamTypes.get(node)?.get(i)
2877+
if (p.optional) this.write('?')
28762878
this.write(': ')
28772879
if (p.type !== undefined && p.typeStart !== undefined && p.typeEnd !== undefined) {
28782880
this.mark(p.typeStart, p.typeEnd, () => this.write(p.type!))

packages/compiler/src/parser.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2023,11 +2023,9 @@ export class Parser {
20232023
let typeStart: number | undefined
20242024
let typeEnd: number | undefined
20252025
let endOffset = nameTok.end
2026-
// TS-style optional param: `(name?: T)`. Tu mirrors the syntax — the
2027-
// `?` is folded into the emitted TS type span so tsserver sees a
2028-
// proper optional. We append ` | undefined` rather than rewriting
2029-
// the param name to `name?` since codegen emits the param name in
2030-
// a JS context where `?` would be a syntax error.
2026+
// TS-style optional param: `(name?: T)`. Tu mirrors the syntax and
2027+
// preserves the optional marker for TS emit / declaration generation.
2028+
// JS emit still writes a plain parameter name.
20312029
let optional = false
20322030
if (this.peek().kind === TokenKind.Question) {
20332031
optional = true
@@ -2037,14 +2035,13 @@ export class Parser {
20372035
if (this.peek().kind === TokenKind.Colon) {
20382036
this.pos++
20392037
const span = this.parseRawTypeUntilParamBoundary()
2040-
type = optional ? `(${span.text}) | undefined` : span.text
2038+
type = span.text
20412039
typeStart = span.start
20422040
typeEnd = span.end
20432041
endOffset = span.end
20442042
} else if (optional) {
2045-
// `name?` with no colon = implicit `unknown | undefined`. Pass the
2046-
// narrower `undefined` through; tsserver will widen as needed.
2047-
type = 'undefined'
2043+
// `name?` with no colon = optional `unknown`.
2044+
type = 'unknown'
20482045
}
20492046
return type === undefined
20502047
? {
@@ -2056,6 +2053,7 @@ export class Parser {
20562053
}
20572054
: {
20582055
name: nameTok.text,
2056+
optional,
20592057
type,
20602058
typeStart,
20612059
typeEnd,

packages/compiler/tests/codegen.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,9 @@ describe('codegen', () => {
182182
expect(js).toContain('((p.a || p.b) && p.c)')
183183
})
184184

185-
it('TS-style optional param `name?: T` lowers to `(T) | undefined` in TS-emit', () => {
185+
it('TS-style optional param `name?: T` stays optional in TS-emit', () => {
186186
const ts = compileToTS('export let f = (x: string, y?: boolean) => true')
187-
expect(ts).toContain('y: (boolean) | undefined')
187+
expect(ts).toContain('y?: boolean')
188188
})
189189

190190
it('flattens block-bodied lambdas to expression form when single child', () => {

packages/compiler/tests/parser.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,17 @@ describe('parser', () => {
118118
])
119119
})
120120

121+
it('parses optional typed parameters', () => {
122+
const tree = ast('let f = (name: string, active?: boolean) => "ok"')
123+
const lambda = (tree.body[0] as { value: unknown }).value as {
124+
params: { name: string; type?: string; optional?: boolean }[]
125+
}
126+
expect(lambda.params).toMatchObject([
127+
{ name: 'name', type: 'string' },
128+
{ name: 'active', type: 'boolean', optional: true },
129+
])
130+
})
131+
121132
it('parses tag-call with props and nested children', () => {
122133
const tree = ast(`
123134
let App = () => {

packages/router/package.json

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,17 @@
3636
"README.md"
3737
],
3838
"scripts": {
39-
"build": "tsc -p tsconfig.json",
40-
"test": "vitest run",
41-
"check": "tsc --noEmit -p tsconfig.json",
42-
"clean": "rm -rf dist *.tsbuildinfo"
39+
"build": "node scripts/build.mjs",
40+
"test": "pnpm run build && vitest run",
41+
"check": "node scripts/check.mjs",
42+
"clean": "rm -rf dist .tu-build .tu-check *.tsbuildinfo"
4343
},
4444
"dependencies": {
45-
"@tu-lang/runtime": "workspace:*"
45+
"@tu-lang/runtime": "workspace:*",
46+
"@tu-lang/std": "workspace:*"
47+
},
48+
"devDependencies": {
49+
"@tu-lang/compiler": "workspace:*"
4650
},
4751
"publishConfig": {
4852
"access": "public"

packages/router/scripts/build.mjs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
2+
import { dirname, join } from 'node:path'
3+
import { fileURLToPath } from 'node:url'
4+
import { spawn } from 'node:child_process'
5+
import { compileToTSWithMap, compileWithMap } from '@tu-lang/compiler'
6+
7+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
8+
const sourcePath = join(root, 'src', 'index.tu')
9+
const distDir = join(root, 'dist')
10+
const buildDir = join(root, '.tu-build')
11+
const source = await readFile(sourcePath, 'utf8')
12+
const filename = 'src/index.tu'
13+
14+
await rm(distDir, { recursive: true, force: true })
15+
await rm(buildDir, { recursive: true, force: true })
16+
await mkdir(distDir, { recursive: true })
17+
await mkdir(buildDir, { recursive: true })
18+
19+
const stripInlineMap = (code) => code.replace(/\n?\/\/# sourceMappingURL=data:application\/json;charset=utf-8;base64,[A-Za-z0-9+/=]+\n?$/, '')
20+
21+
const js = compileWithMap(source, { filename })
22+
await writeFile(join(distDir, 'index.js'), `${stripInlineMap(js.code)}\n//# sourceMappingURL=index.js.map\n`)
23+
await writeFile(join(distDir, 'index.js.map'), JSON.stringify({
24+
...js.map,
25+
file: 'index.js',
26+
sources: ['../src/index.tu'],
27+
}))
28+
29+
const ts = compileToTSWithMap(source, { filename })
30+
await writeFile(join(buildDir, 'index.ts'), stripInlineMap(ts.code))
31+
32+
await new Promise((resolve, reject) => {
33+
const child = spawn(
34+
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
35+
['exec', 'tsc', '-p', 'tsconfig.dts.json'],
36+
{ cwd: root, stdio: 'inherit' }
37+
)
38+
child.on('error', reject)
39+
child.on('exit', (code) => {
40+
if (code === 0) resolve()
41+
else reject(new Error(`tsc exited with code ${code}`))
42+
})
43+
})

packages/router/scripts/check.mjs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
2+
import { dirname, join } from 'node:path'
3+
import { fileURLToPath } from 'node:url'
4+
import { spawn } from 'node:child_process'
5+
import { compileToTSWithMap } from '@tu-lang/compiler'
6+
7+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
8+
const checkDir = join(root, '.tu-check')
9+
const source = await readFile(join(root, 'src', 'index.tu'), 'utf8')
10+
const stripInlineMap = (code) => code.replace(/\n?\/\/# sourceMappingURL=data:application\/json;charset=utf-8;base64,[A-Za-z0-9+/=]+\n?$/, '')
11+
12+
await rm(checkDir, { recursive: true, force: true })
13+
await mkdir(checkDir, { recursive: true })
14+
15+
const ts = compileToTSWithMap(source, { filename: 'src/index.tu' })
16+
await writeFile(join(checkDir, 'index.ts'), stripInlineMap(ts.code))
17+
18+
await new Promise((resolve, reject) => {
19+
const child = spawn(
20+
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
21+
['exec', 'tsc', '-p', 'tsconfig.check.json'],
22+
{ cwd: root, stdio: 'inherit' }
23+
)
24+
child.on('error', reject)
25+
child.on('exit', (code) => {
26+
if (code === 0) resolve()
27+
else reject(new Error(`tsc exited with code ${code}`))
28+
})
29+
})

packages/router/src/index.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
joinRoutePaths,
77
renderRoute,
88
renderRouteToString,
9-
} from './index.js'
9+
} from '../dist/index.js'
1010

1111
describe('@tu-lang/router', () => {
1212
it('matches static routes and renders route bodies', async () => {

0 commit comments

Comments
 (0)