onejs-sl 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,460 @@
1
+ /**
2
+ * Source to AST. A Pratt parser, because expressions are the interesting half.
3
+ *
4
+ * Phase A of `Specs/SL_TEXT.md` section 4. This file decides SHAPE only: what
5
+ * is a declaration, what is a statement, how tightly `*` binds against `+`. It
6
+ * knows nothing about types, about which names exist, or about what any of it
7
+ * lowers to, and it never touches the IR. Everything it refuses, it refuses
8
+ * because the characters cannot be read any other way.
9
+ *
10
+ * HLSL precedence, so that an expression copied out of a `.shader` file means
11
+ * here what it meant there. That is the whole promise of choosing HLSL.
12
+ */
13
+
14
+ import {
15
+ TYPE_WIDTH,
16
+ type AssignOp, type BinaryOp, type Expr, type FuncDecl, type Param, type Stmt,
17
+ type TextureDecl, type TypeName, type UniformDecl, type Unit,
18
+ } from "./ast"
19
+ import { SLParseError, tokenize, type Pos, type Token } from "./lexer"
20
+
21
+ const TYPE_NAMES = new Set(Object.keys(TYPE_WIDTH))
22
+
23
+ /**
24
+ * Type spellings that exist in HLSL or GLSL and not here, each with the reason.
25
+ *
26
+ * `Specs/SL_TEXT.md` section 2: the IR bounds what the language can say, on
27
+ * purpose. A name in this table gets the reason; a name outside it gets
28
+ * "unknown", and the difference is most of what makes the language learnable.
29
+ */
30
+ const NOT_A_TYPE: Record<string, string> = {
31
+ vec2: "use float2",
32
+ vec3: "use float3",
33
+ vec4: "use float4",
34
+ int: "there are no integers; a whole number is a float, so write float",
35
+ uint: "there are no integers; a whole number is a float, so write float",
36
+ bool: "there is no bool; a comparison is a float that is 0 or 1",
37
+ half: "use float; the IR has one precision",
38
+ double: "use float; the IR has one precision",
39
+ fixed: "use float; the IR has one precision",
40
+ float2x2: "there are no matrices",
41
+ float3x3: "there are no matrices",
42
+ float4x4: "there are no matrices",
43
+ sampler2D: "use texture2D to declare a texture slot",
44
+ void: "every function returns a value",
45
+ struct: "there are no structs",
46
+ }
47
+
48
+ /** Binding power per binary operator, HLSL's. Higher binds tighter. */
49
+ const BINDING: Record<string, number> = {
50
+ "||": 1,
51
+ "&&": 2,
52
+ "==": 3, "!=": 3,
53
+ "<": 4, "<=": 4, ">": 4, ">=": 4,
54
+ "+": 5, "-": 5,
55
+ "*": 6, "/": 6, "%": 6,
56
+ }
57
+
58
+ const ASSIGN_OPS = new Set(["=", "+=", "-=", "*=", "/=", "%="])
59
+
60
+ class Parser {
61
+ private i = 0
62
+
63
+ constructor(
64
+ private readonly tokens: Token[],
65
+ private readonly file: string,
66
+ private readonly requireMain: boolean,
67
+ ) {}
68
+
69
+ // MARK: token plumbing
70
+
71
+ private peek(n = 0): Token { return this.tokens[Math.min(this.i + n, this.tokens.length - 1)]! }
72
+ private next(): Token { return this.tokens[this.i++]! }
73
+ private at(text: string): boolean { const t = this.peek(); return t.kind !== "eof" && t.text === text }
74
+ private eat(text: string): boolean { if (this.at(text)) { this.i++; return true } return false }
75
+
76
+ private fail(message: string, at: Token = this.peek()): never {
77
+ throw new SLParseError(message, this.file, at as Pos, Math.max(1, at.text.length))
78
+ }
79
+
80
+ private expect(text: string, what: string): Token {
81
+ if (!this.at(text)) {
82
+ const got = this.peek()
83
+ this.fail(`expected "${text}" ${what}, got ${describe(got)}`, got)
84
+ }
85
+ return this.next()
86
+ }
87
+
88
+ private expectIdent(what: string): Token {
89
+ const t = this.peek()
90
+ if (t.kind !== "ident") this.fail(`expected ${what}, got ${describe(t)}`, t)
91
+ return this.next()
92
+ }
93
+
94
+ private expectType(what: string): TypeName {
95
+ const t = this.expectIdent(what)
96
+ if (TYPE_NAMES.has(t.text)) return t.text as TypeName
97
+ const why = NOT_A_TYPE[t.text]
98
+ if (why !== undefined) this.fail(`"${t.text}" is not a type here: ${why}`, t)
99
+ this.fail(`"${t.text}" is not a type; the types are float, float2, float3 and float4`, t)
100
+ }
101
+
102
+ // MARK: the file
103
+
104
+ parseUnit(): Unit {
105
+ const unit: Unit = {
106
+ file: this.file, uniforms: [], textures: [], consts: [], funcs: [], main: null,
107
+ }
108
+
109
+ while (this.peek().kind !== "eof") {
110
+ const t = this.peek()
111
+
112
+ if (t.text === "uniform") { unit.uniforms.push(this.parseUniform()); continue }
113
+ if (t.text === "texture2D") { unit.textures.push(this.parseTexture()); continue }
114
+ if (t.text === "const") {
115
+ const c = this.parseConst()
116
+ unit.consts.push(c)
117
+ continue
118
+ }
119
+ if (t.kind === "ident") {
120
+ const fn = this.parseFunction()
121
+ if (fn.name === "main") {
122
+ if (unit.main !== null) {
123
+ this.fail("this file already declares main; a .sl file is exactly one fragment function", t)
124
+ }
125
+ unit.main = fn
126
+ } else {
127
+ unit.funcs.push(fn)
128
+ }
129
+ continue
130
+ }
131
+ this.fail(
132
+ `expected a declaration (uniform, texture2D, const, or a function), got ${describe(t)}`, t,
133
+ )
134
+ }
135
+
136
+ if (unit.main === null && this.requireMain) {
137
+ this.fail(
138
+ "this file declares no main. A .sl file is one fragment function: add " +
139
+ "`float4 main() { ... }`",
140
+ this.peek(),
141
+ )
142
+ }
143
+ return unit
144
+ }
145
+
146
+ private parseUniform(): UniformDecl {
147
+ const kw = this.next()
148
+ const type = this.expectType("a type after uniform")
149
+ const name = this.expectIdent("a uniform name")
150
+ let init: Expr | null = null
151
+ if (this.eat("=")) init = this.parseExpr()
152
+ this.expect(";", "after a uniform declaration")
153
+ return { name: name.text, type, init, pos: kw }
154
+ }
155
+
156
+ private parseTexture(): TextureDecl {
157
+ const kw = this.next()
158
+ const name = this.expectIdent("a texture name")
159
+ if (this.at("=")) {
160
+ this.fail(
161
+ "a texture has no default; the host binds it by name through the textures prop",
162
+ this.peek(),
163
+ )
164
+ }
165
+ this.expect(";", "after a texture declaration")
166
+ return { name: name.text, pos: kw }
167
+ }
168
+
169
+ private parseConst(): Extract<Stmt, { k: "const" }> {
170
+ const kw = this.next()
171
+ const type = this.expectType("a type after const")
172
+ const name = this.expectIdent("a name")
173
+ this.expect("=", "after a const's name; a const has to have a value")
174
+ const init = this.parseExpr()
175
+ this.expect(";", "after a const")
176
+ return { k: "const", type, name: name.text, init, pos: kw }
177
+ }
178
+
179
+ private parseFunction(): FuncDecl {
180
+ const start = this.peek()
181
+ const ret = this.expectType("a return type")
182
+ const name = this.expectIdent("a function name")
183
+ if (!this.at("(")) {
184
+ this.fail(
185
+ `expected "(" after ${name.text}. Only uniforms, textures, consts and functions live ` +
186
+ `at the top level of a file; a value belongs inside main or a function`,
187
+ this.peek(),
188
+ )
189
+ }
190
+ this.next()
191
+ const params: Param[] = []
192
+ if (!this.at(")")) {
193
+ for (;;) {
194
+ const p = this.peek()
195
+ const ptype = this.expectType("a parameter type")
196
+ const pname = this.expectIdent("a parameter name")
197
+ params.push({ type: ptype, name: pname.text, pos: p })
198
+ if (!this.eat(",")) break
199
+ }
200
+ }
201
+ this.expect(")", "after the parameters")
202
+ const body = this.parseBlock()
203
+ return { name: name.text, ret, params, body, pos: start, prelude: false }
204
+ }
205
+
206
+ // MARK: statements
207
+
208
+ private parseBlock(): Stmt[] {
209
+ this.expect("{", "to open a body")
210
+ const out: Stmt[] = []
211
+ while (!this.at("}")) {
212
+ if (this.peek().kind === "eof") this.fail("this body is never closed", this.peek())
213
+ out.push(this.parseStmt())
214
+ }
215
+ this.next()
216
+ return out
217
+ }
218
+
219
+ /** A braced block, or the single statement HLSL lets an `if` or `for` carry. */
220
+ private parseBody(): Stmt[] {
221
+ if (this.at("{")) return this.parseBlock()
222
+ return [this.parseStmt()]
223
+ }
224
+
225
+ private parseStmt(): Stmt {
226
+ const t = this.peek()
227
+
228
+ if (t.text === "const") return this.parseConst()
229
+ if (t.text === "if") return this.parseIf()
230
+ if (t.text === "for") return this.parseFor()
231
+ if (t.text === "while" || t.text === "do") {
232
+ this.fail(
233
+ `there is no ${t.text} loop: the VM has no branches, so a loop has to unroll, and ` +
234
+ `only a for loop with constant bounds can. See a for loop`,
235
+ t,
236
+ )
237
+ }
238
+ if (t.text === "discard") {
239
+ this.fail("there is no discard; return a colour with alpha 0 instead", t)
240
+ }
241
+ if (t.text === "return") {
242
+ this.next()
243
+ const value = this.parseExpr()
244
+ this.expect(";", "after a return")
245
+ return { k: "return", value, pos: t }
246
+ }
247
+ if (t.text === "uniform" || t.text === "texture2D") {
248
+ this.fail(`a ${t.text} is declared at the top level of the file, not inside a body`, t)
249
+ }
250
+
251
+ // A declaration is a type followed by a name. Anything else starting
252
+ // with an identifier is an expression, and the only expression a
253
+ // statement may be is the left of an assignment.
254
+ if (t.kind === "ident" && this.peek(1).kind === "ident") {
255
+ const type = this.expectType("a type")
256
+ const name = this.expectIdent("a name")
257
+ if (this.at("(")) {
258
+ this.fail(
259
+ "a function is declared at the top level of the file, not inside another function",
260
+ this.peek(),
261
+ )
262
+ }
263
+ this.expect("=", `after ${name.text}; every local is declared with a value`)
264
+ const init = this.parseExpr()
265
+ this.expect(";", "after a declaration")
266
+ return { k: "var", type, name: name.text, init, pos: t }
267
+ }
268
+
269
+ const target = this.parseExpr()
270
+ const op = this.peek()
271
+ if (op.kind === "punct" && ASSIGN_OPS.has(op.text)) {
272
+ this.next()
273
+ const value = this.parseExpr()
274
+ this.expect(";", "after an assignment")
275
+ return { k: "assign", target, op: op.text as AssignOp, value, pos: t }
276
+ }
277
+ if (op.text === "++" || op.text === "--") {
278
+ this.fail(`${op.text} is only a for loop's update; write \`x = x + 1;\``, op)
279
+ }
280
+ this.fail(
281
+ "this statement has no effect. A statement is a declaration, an assignment, an if, a " +
282
+ "for or a return",
283
+ t,
284
+ )
285
+ }
286
+
287
+ private parseIf(): Stmt {
288
+ const kw = this.next()
289
+ this.expect("(", "after if")
290
+ const cond = this.parseExpr()
291
+ this.expect(")", "after an if condition")
292
+ const then = this.parseBody()
293
+ let otherwise: Stmt[] = []
294
+ if (this.at("else")) {
295
+ this.next()
296
+ otherwise = this.at("if") ? [this.parseIf()] : this.parseBody()
297
+ }
298
+ return { k: "if", cond, then, else: otherwise, pos: kw }
299
+ }
300
+
301
+ /**
302
+ * `for (int i = A; i < B; i++)`, and nothing more exotic than `i += K`.
303
+ *
304
+ * The shape is narrow because the loop UNROLLS: the counter is a compile
305
+ * time number, not a value, and every form this refuses is one where it
306
+ * could not be. Section 3.6 of the spec, and the error says so rather than
307
+ * leaving an author to guess which part offended.
308
+ */
309
+ private parseFor(): Stmt {
310
+ const kw = this.next()
311
+ this.expect("(", "after for")
312
+
313
+ const decl = this.peek()
314
+ if (decl.text !== "int" && decl.text !== "float") {
315
+ this.fail(
316
+ `a for loop starts by declaring its counter, as in \`for (int i = 0; i < 4; i++)\`, ` +
317
+ `got ${describe(decl)}`, decl,
318
+ )
319
+ }
320
+ this.next()
321
+ const counter = this.expectIdent("a counter name")
322
+ this.expect("=", "after the counter")
323
+ const from = this.parseExpr()
324
+ this.expect(";", "after the counter's start")
325
+
326
+ const lhs = this.expectIdent("the counter in the loop's condition")
327
+ if (lhs.text !== counter.text) {
328
+ this.fail(`this loop counts ${counter.text}, so its condition has to test ${counter.text}`, lhs)
329
+ }
330
+ const cmp = this.peek()
331
+ if (cmp.text !== "<" && cmp.text !== "<=") {
332
+ this.fail(
333
+ `a for loop counts up, so its condition is \`${counter.text} <\` or \`${counter.text} <=\`, ` +
334
+ `got ${describe(cmp)}`, cmp,
335
+ )
336
+ }
337
+ this.next()
338
+ const to = this.parseExpr()
339
+ this.expect(";", "after the loop's bound")
340
+
341
+ const up = this.expectIdent("the counter in the loop's update")
342
+ if (up.text !== counter.text) {
343
+ this.fail(`this loop counts ${counter.text}, so its update has to change ${counter.text}`, up)
344
+ }
345
+ let step: Expr = { k: "num", value: 1, pos: up }
346
+ if (this.eat("++")) {
347
+ // nothing: the step is 1
348
+ } else if (this.eat("+=")) {
349
+ step = this.parseExpr()
350
+ } else {
351
+ this.fail(
352
+ `a for loop's update is \`${counter.text}++\` or \`${counter.text} += n\`, got ` +
353
+ `${describe(this.peek())}`, this.peek(),
354
+ )
355
+ }
356
+ this.expect(")", "after the loop's update")
357
+ const body = this.parseBody()
358
+ return { k: "for", counter: counter.text, from, to, inclusive: cmp.text === "<=", step, body, pos: kw }
359
+ }
360
+
361
+ // MARK: expressions
362
+
363
+ parseExpr(): Expr { return this.parseBinary(0) }
364
+
365
+ private parseBinary(min: number): Expr {
366
+ let left = this.parseUnary()
367
+ for (;;) {
368
+ const t = this.peek()
369
+ if (t.kind !== "punct") break
370
+ const bp = BINDING[t.text]
371
+ if (bp === undefined || bp < min) break
372
+ this.next()
373
+ const right = this.parseBinary(bp + 1)
374
+ left = { k: "binary", op: t.text as BinaryOp, a: left, b: right, pos: t }
375
+ }
376
+ // The conditional binds looser than every binary operator and is right
377
+ // associative, so `a ? b : c ? d : e` groups to the right, as in HLSL.
378
+ if (min === 0 && this.at("?")) {
379
+ const q = this.next()
380
+ const then = this.parseExpr()
381
+ this.expect(":", "in a ?: conditional")
382
+ const otherwise = this.parseExpr()
383
+ return { k: "cond", cond: left, then, else: otherwise, pos: q }
384
+ }
385
+ return left
386
+ }
387
+
388
+ private parseUnary(): Expr {
389
+ const t = this.peek()
390
+ if (t.kind === "punct" && (t.text === "-" || t.text === "+" || t.text === "!")) {
391
+ this.next()
392
+ return { k: "unary", op: t.text, arg: this.parseUnary(), pos: t }
393
+ }
394
+ if (t.text === "++" || t.text === "--") {
395
+ this.fail(`${t.text} is only a for loop's update`, t)
396
+ }
397
+ return this.parsePostfix(this.parsePrimary())
398
+ }
399
+
400
+ private parsePostfix(expr: Expr): Expr {
401
+ for (;;) {
402
+ if (this.at(".")) {
403
+ this.next()
404
+ const name = this.expectIdent("a component or a shape name after \".\"")
405
+ expr = { k: "member", obj: expr, name: name.text, pos: name }
406
+ continue
407
+ }
408
+ if (this.at("(")) {
409
+ const open = this.next()
410
+ const args: Expr[] = []
411
+ if (!this.at(")")) {
412
+ for (;;) {
413
+ args.push(this.parseExpr())
414
+ if (!this.eat(",")) break
415
+ }
416
+ }
417
+ this.expect(")", "after the arguments")
418
+ expr = { k: "call", callee: expr, args, pos: open }
419
+ continue
420
+ }
421
+ if (this.at("[")) {
422
+ this.fail("there are no arrays; index a component with .x, .y, .z or .w", this.peek())
423
+ }
424
+ return expr
425
+ }
426
+ }
427
+
428
+ private parsePrimary(): Expr {
429
+ const t = this.next()
430
+ if (t.kind === "number") return { k: "num", value: t.value!, pos: t }
431
+ if (t.kind === "hex") return { k: "hex", hex: t.text, pos: t }
432
+ if (t.kind === "ident") return { k: "ident", name: t.text, pos: t }
433
+ if (t.text === "(") {
434
+ const inner = this.parseExpr()
435
+ this.expect(")", "to close a group")
436
+ return inner
437
+ }
438
+ this.fail(`expected a value, got ${describe(t)}`, t)
439
+ }
440
+ }
441
+
442
+ function describe(t: Token): string {
443
+ if (t.kind === "eof") return "the end of the file"
444
+ return `"${t.text}"`
445
+ }
446
+
447
+ export interface ParseOptions {
448
+ file?: string
449
+ /**
450
+ * Off for the prelude, which is a library of functions and has no main.
451
+ * Nothing else should turn it off: a `.sl` file without a main renders
452
+ * nothing, and finding that out at parse time is the point.
453
+ */
454
+ requireMain?: boolean
455
+ }
456
+
457
+ export function parseUnit(source: string, options: ParseOptions = {}): Unit {
458
+ const file = options.file ?? "program.sl"
459
+ return new Parser(tokenize(source, file), file, options.requireMain ?? true).parseUnit()
460
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The standard library, written in the language itself.
3
+ *
4
+ * `Specs/SL_TEXT.md` 3.8: the things every shader pastes from somewhere, as
5
+ * ordinary functions that inline, available in every file without an import. A
6
+ * function declared in a file shadows a prelude function of the same name.
7
+ *
8
+ * ADDING TO IT COSTS A FUNCTION AND A TEST, NOT AN OPCODE IN THREE PLACES. That
9
+ * is the whole argument for having a text language with inlining functions: a
10
+ * library entry is source, so it lands in the VM, in generated HLSL and in the
11
+ * hash by being ordinary code, and neither backend learns anything new.
12
+ *
13
+ * WHY THE SOURCE IS A STRING AND NOT A FILE. `onejs-sl` ships raw TypeScript
14
+ * and runs inside a browser worker on play.onejs.com, where
15
+ * there is no filesystem to read a `.sl` from, and the esbuild loader that
16
+ * would inline one does not exist until Phase B. A template literal parses to
17
+ * exactly the same unit, highlights the same in an editor, and needs nothing
18
+ * from the bundler.
19
+ */
20
+
21
+ import type { FuncDecl } from "./ast"
22
+ import { check } from "./check"
23
+ import { parseUnit } from "./parser"
24
+
25
+ export const PRELUDE_SOURCE = `
26
+ // Rotate a point about the origin. Translate first if you want another centre.
27
+ float2 rotate(float2 p, float angle) {
28
+ float c = cos(angle);
29
+ float s = sin(angle);
30
+ return float2(p.x * c - p.y * s, p.x * s + p.y * c);
31
+ }
32
+
33
+ // Cartesian to polar: x is the angle in radians, y is the radius.
34
+ float2 polar(float2 p) {
35
+ return float2(atan2(p.y, p.x), length(p));
36
+ }
37
+
38
+ // Signed distance to a circle at the origin. Negative inside.
39
+ float circle(float2 p, float r) {
40
+ return length(p) - r;
41
+ }
42
+
43
+ // Signed distance to an axis aligned box of the given half extents.
44
+ float box(float2 p, float2 size) {
45
+ float2 d = abs(p) - size;
46
+ return length(max(d, 0)) + min(max(d.x, d.y), 0);
47
+ }
48
+
49
+ // Repeat a unit cell n times across the input, centred on zero in each cell.
50
+ float2 tile(float2 p, float n) {
51
+ return frac(p * n) - 0.5;
52
+ }
53
+
54
+ // Inigo Quilez's cosine palette. Four float3s in, a colour out, no texture.
55
+ float3 palette(float t, float3 a, float3 b, float3 c, float3 d) {
56
+ return a + b * cos(6.28318530718 * (c * t + d));
57
+ }
58
+ `
59
+
60
+ let cached: FuncDecl[] | null = null
61
+
62
+ /**
63
+ * The prelude's functions, parsed and checked once.
64
+ *
65
+ * Checked against a unit with no uniforms and no textures of its own, which is
66
+ * what it is: a parameter here called `p` must not collide with a uniform in
67
+ * whichever file happens to use it.
68
+ */
69
+ export function preludeFunctions(): FuncDecl[] {
70
+ if (cached !== null) return cached
71
+ const unit = parseUnit(PRELUDE_SOURCE, { file: "prelude.sl", requireMain: false })
72
+ check(unit, [], { requireMain: false })
73
+ cached = unit.funcs.map((fn) => ({ ...fn, prelude: true }))
74
+ return cached
75
+ }
package/src/limits.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `onejs-sl/limits`: whether a program fits the VM, without encoding it.
3
+ *
4
+ * The VM is the one backend with limits. A compiled backend (every web build,
5
+ * an editor with a generated shader) runs any program; the VM runs one that is
6
+ * at most `MAX_INSTRUCTIONS` long and never needs more than `REGISTERS`
7
+ * values at once. OneJS refuses an over-limit program only in a build that
8
+ * still runs the VM. A host that wants to warn an author ahead of that asks
9
+ * here, and gets the same answer `encode` would give, because both run the
10
+ * same checks.
11
+ */
12
+ import { SLError, type Program } from "./ir"
13
+ import { allocate, checkBudget, forVm, reachable, wireOf } from "./encode"
14
+
15
+ export { REGISTERS, MAX_INSTRUCTIONS } from "./encode"
16
+ export { VM_UNIFORMS, VM_TEXTURES } from "./ops"
17
+
18
+ export interface VmFit {
19
+ /** Every OneJS VM at `wire` or newer runs it. */
20
+ fits: boolean
21
+ /** Operations after dead code is dropped, as the VM would run them. */
22
+ instructions: number
23
+ /** The most values live at once, when the register file held them all. */
24
+ registers?: number
25
+ /** The oldest VM encoding that can run it. */
26
+ wire: number
27
+ /** Why it does not fit, in the words `encode` throws with. */
28
+ reason?: string
29
+ }
30
+
31
+ export function vmFit(source: Program): VmFit {
32
+ const program = forVm(source)
33
+ const order = reachable(program.nodes, program.result)
34
+ const reasons: string[] = []
35
+ let registers: number | undefined
36
+ const attempt = (check: () => void) => {
37
+ try {
38
+ check()
39
+ } catch (e) {
40
+ if (!(e instanceof SLError)) throw e
41
+ reasons.push(e.message)
42
+ }
43
+ }
44
+ attempt(() => checkBudget(program, order))
45
+ attempt(() => { registers = allocate(program, order).peak })
46
+ const fit: VmFit = { fits: reasons.length === 0, instructions: order.length, wire: wireOf(source, program) }
47
+ if (registers !== undefined) fit.registers = registers
48
+ if (reasons.length > 0) fit.reason = reasons.join(" ")
49
+ return fit
50
+ }