Skip to content

Commit 5de4691

Browse files
committed
Add units, regex, numbers
1 parent 897f706 commit 5de4691

9 files changed

Lines changed: 364 additions & 44 deletions

File tree

README.md

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ fn() // 1
7777
+ `{ a; b }` — block scope
7878
+ `let x`, `const x = 1`
7979
+ `break`, `continue`, `return x`
80+
+ `` `a ${x} b` `` — template literals
81+
+ `/pattern/flags` — regex literals
82+
+ `5px`, `10rem` — unit suffixes
8083

8184
```js
8285
import subscript from 'subscript/justin'
@@ -124,21 +127,41 @@ const fn = compile(['+', ['*', 'min', [,60]], [,'sec']])
124127
fn({min: 5}) // min*60 + "sec" == "300sec"
125128

126129
// node kinds
127-
['+', a]; // unary operator `+a`
128-
['+', a, b]; // binary operator `a + b`
129-
['+', a, b, c]; // n-ary operator `a + b + c`
130-
['()', a]; // group operator `(a)`
131-
['()', a, b]; // access operator `a(b)`
132-
[, 'a']; // literal value `'a'`
133-
'a'; // variable (from scope)
134-
null|empty; // placeholder
135-
136-
// eg.
137-
['()', 'a'] // (a)
138-
['()', 'a', null] // a()
139-
['()', 'a', 'b'] // a(b)
140-
['++', 'a'] // ++a
141-
['++','a', null] // a++
130+
'a' // identifier — variable from scope
131+
[, value] // literal — [0] empty distinguishes from operator
132+
[op, a] // unary — prefix operator
133+
[op, a, null] // unary — postfix operator (null marks postfix)
134+
[op, a, b] // binary
135+
[op, a, b, c] // n-ary / ternary
136+
137+
// operators
138+
['+', a, b] // a + b
139+
['.', a, 'b'] // a.b — property access
140+
['[]', a, b] // a[b] — bracket access
141+
['()', a] // (a) — grouping
142+
['()', a, b] // a(b) — function call
143+
['()', a, null] // a() — call with no args
144+
145+
// literals & structures
146+
[, 1] // 1
147+
[, 'hello'] // "hello"
148+
['[]', [',', ...]] // [a, b] — array literal
149+
['{}', [':', ...]] // {a: b} — object literal
150+
151+
// justin extensions
152+
['?', a, b, c] // a ? b : c — ternary
153+
['=>', params, x] // (a) => x — arrow function
154+
['...', a] // ...a — spread
155+
156+
// control flow (extra)
157+
['if', cond, then, else]
158+
['while', cond, body]
159+
['for', init, cond, step, body]
160+
161+
// postfix example
162+
['++', 'a'] // ++a
163+
['++', 'a', null] // a++
164+
['px', [,5]] // 5px (unit suffix)
142165
```
143166

144167
### Stringify

feature/number.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
1-
import { lookup, next, err, skip } from "../src/parse.js"
2-
import { cur, idx } from "../src/parse.js"
1+
import { lookup, next, err, skip, cur, idx } from "../src/parse.js"
32
import { PERIOD, _0, _E, _e, _9 } from "../src/const.js"
43

54
// Char codes for prefixes
65
const _b = 98, _B = 66, _o = 111, _O = 79, _x = 120, _X = 88
76
const _a = 97, _f = 102, _A = 65, _F = 70
7+
const PLUS = 43, MINUS = 45
8+
9+
// Check if char at offset is digit or sign (valid after 'e')
10+
const isExpFollow = off => {
11+
const c = cur.charCodeAt(idx + off)
12+
return (c >= _0 && c <= _9) || c === PLUS || c === MINUS
13+
}
814

915
// parse decimal number (with optional exponent)
16+
// Only consume 'e' if followed by digit or +/-
1017
const num = (a, _) => [, (
11-
a = +next(c => (c === PERIOD) || (c >= _0 && c <= _9) || (c === _E || c === _e ? 2 : 0))
18+
a = +next(c => (c === PERIOD) || (c >= _0 && c <= _9) || ((c === _E || c === _e) && isExpFollow(1) ? 2 : 0))
1219
) != a ? err() : a]
1320

1421
// .1

feature/regex.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Regex literals: /pattern/flags
3+
*
4+
* AST:
5+
* /abc/gi → [, /abc/gi]
6+
*
7+
* Note: Disambiguates from division by context:
8+
* - `/` after value = division
9+
* - `/` at start or after operator = regex
10+
*/
11+
import * as P from '../src/parse.js'
12+
13+
const { lookup, skip, err, next } = P
14+
const SLASH = 47, BSLASH = 92
15+
16+
const regexFlags = c => c === 103 || c === 105 || c === 109 || c === 115 || c === 117 || c === 121 // g i m s u y
17+
18+
// Store original division handler
19+
const divHandler = lookup[SLASH]
20+
21+
// Override / to detect regex vs division
22+
lookup[SLASH] = (a, prec) => {
23+
// If there's a left operand, it's division
24+
if (a) return divHandler?.(a, prec)
25+
26+
// No left operand = regex literal
27+
skip() // consume opening /
28+
29+
let pattern = '', c
30+
while ((c = P.cur.charCodeAt(P.idx)) && c !== SLASH) {
31+
if (c === BSLASH) {
32+
// Escape sequence - include both chars
33+
pattern += P.cur[P.idx]
34+
skip()
35+
if (!P.cur[P.idx]) err('Unterminated regex')
36+
pattern += P.cur[P.idx]
37+
skip()
38+
} else {
39+
pattern += P.cur[P.idx]
40+
skip()
41+
}
42+
}
43+
44+
if (!P.cur[P.idx]) err('Unterminated regex')
45+
skip() // consume closing /
46+
47+
// Parse flags
48+
const flags = next(regexFlags)
49+
50+
try {
51+
return [, new RegExp(pattern, flags)]
52+
} catch (e) {
53+
err('Invalid regex: ' + e.message)
54+
}
55+
}

feature/template.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* Template string interpolation: `a ${expr} b`
3+
*
4+
* AST:
5+
* `a ${x} b` → ['`', [,'a '], 'x', [,' b']]
6+
*/
7+
import * as P from '../src/parse.js'
8+
import { operator, compile } from '../src/compile.js'
9+
10+
const { lookup, skip, err, next, expr } = P
11+
const BACKTICK = 96, DOLLAR = 36, OBRACE = 123, CBRACE = 125, BSLASH = 92
12+
13+
const escape = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v' }
14+
15+
// Parse template literal
16+
lookup[BACKTICK] = a => {
17+
a && err('Unexpected template')
18+
skip() // consume opening `
19+
20+
const parts = []
21+
let str = ''
22+
23+
while (P.cur.charCodeAt(P.idx) !== BACKTICK) {
24+
const c = P.cur.charCodeAt(P.idx)
25+
if (!c) err('Unterminated template')
26+
27+
// Escape sequence
28+
if (c === BSLASH) {
29+
skip()
30+
const ec = P.cur[P.idx]
31+
str += escape[ec] || ec
32+
skip()
33+
}
34+
// Interpolation ${...}
35+
else if (c === DOLLAR && P.cur.charCodeAt(P.idx + 1) === OBRACE) {
36+
if (str) parts.push([, str])
37+
str = ''
38+
skip(); skip() // consume ${
39+
parts.push(expr(0, CBRACE))
40+
}
41+
// Regular character
42+
else {
43+
str += P.cur[P.idx]
44+
skip()
45+
}
46+
}
47+
48+
skip() // consume closing `
49+
if (str) parts.push([, str])
50+
51+
// Optimize: if no interpolations, return plain string
52+
if (parts.length === 0) return [, '']
53+
if (parts.length === 1 && parts[0][0] === undefined) return parts[0]
54+
55+
return ['`', ...parts]
56+
}
57+
58+
// Compile template: concatenate parts
59+
operator('`', (...parts) => {
60+
parts = parts.map(p => compile(p))
61+
return ctx => parts.map(p => p(ctx)).join('')
62+
})

feature/unit.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* Unit suffixes: 5px, 10rem, 2s, 500ms
3+
*
4+
* AST:
5+
* 5px → ['px', [,5]]
6+
* 2.5s → ['s', [,2.5]]
7+
*
8+
* Units are postfix operators — idiomatic to subscript's design.
9+
* Inspired by piezo: https://github.com/dy/piezo
10+
*
11+
* Usage:
12+
* import { unit } from 'subscript/feature/unit.js'
13+
* unit('px', 'em', 'rem', 's', 'ms')
14+
*/
15+
import * as P from '../src/parse.js'
16+
import { operator, compile } from '../src/compile.js'
17+
18+
const { lookup, next, parse } = P
19+
20+
// Unit registry
21+
const units = new Set
22+
23+
// Register units with default evaluator
24+
export const unit = (...names) => names.forEach(name => {
25+
units.add(name)
26+
// Default: return { value, unit } object
27+
operator(name, val => (val = compile(val), ctx => ({ value: val(ctx), unit: name })))
28+
})
29+
30+
// Wrap number handler to check for unit suffix
31+
const wrapHandler = (charCode) => {
32+
const original = lookup[charCode]
33+
if (!original) return
34+
35+
lookup[charCode] = (a, prec) => {
36+
const result = original(a, prec)
37+
if (!result) return result
38+
39+
// Only numeric literals (not identifiers)
40+
if (!Array.isArray(result) || result[0] !== undefined) return result
41+
42+
// Try to consume unit suffix
43+
const startIdx = P.idx
44+
const u = next(c => parse.id(c) && !(c >= 48 && c <= 57))
45+
46+
if (u && units.has(u)) return [u, result]
47+
48+
// Not a unit - backtrack
49+
if (u) P.idx = startIdx
50+
51+
return result
52+
}
53+
}
54+
55+
// Wrap all number entry points (0-9 and .)
56+
const PERIOD = 46, _0 = 48, _9 = 57
57+
wrapHandler(PERIOD)
58+
for (let i = _0; i <= _9; i++) wrapHandler(i)

test/regex.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import test, { is, throws } from 'tst'
2+
import { parse, compile } from '../subscript.js'
3+
import '../feature/regex.js'
4+
5+
const run = (code, ctx = {}) => compile(parse(code))(ctx)
6+
7+
test('regex: basic', t => {
8+
const ast = parse('/abc/')
9+
is(ast[0], undefined)
10+
is(ast[1] instanceof RegExp, true)
11+
is(ast[1].source, 'abc')
12+
})
13+
14+
test('regex: flags', t => {
15+
is(parse('/abc/gi')[1].flags, 'gi')
16+
is(parse('/test/m')[1].multiline, true)
17+
is(parse('/test/i')[1].ignoreCase, true)
18+
})
19+
20+
test('regex: escapes', t => {
21+
is(parse('/a\\/b/')[1].source, 'a\\/b')
22+
is(parse('/a\\nb/')[1].source, 'a\\nb')
23+
})
24+
25+
test('regex: eval', t => {
26+
is(run('/abc/.test("abc")'), true)
27+
is(run('/abc/.test("def")'), false)
28+
is(run('"hello world".match(/\\w+/g)'), ['hello', 'world'])
29+
})
30+
31+
test('regex: division disambiguation', t => {
32+
// Division when left operand exists
33+
is(run('4 / 2'), 2)
34+
is(run('a / b', { a: 10, b: 2 }), 5)
35+
36+
// Regex when no left operand
37+
is(run('/a/.test("a")'), true)
38+
})
39+
40+
test('regex: in expressions', t => {
41+
is(run('x.match(/\\d+/)', { x: 'abc123def' })[0], '123')
42+
is(run('x.replace(/a/g, "b")', { x: 'aaa' }), 'bbb')
43+
})

test/template.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import test, { is, throws } from 'tst'
2+
import { parse, compile } from '../subscript.js'
3+
import '../feature/ternary.js'
4+
import '../feature/template.js'
5+
6+
const run = (code, ctx = {}) => compile(parse(code))(ctx)
7+
8+
test('template: basic', t => {
9+
is(parse('`hello`'), [, 'hello'])
10+
is(run('`hello`'), 'hello')
11+
})
12+
13+
test('template: interpolation', t => {
14+
is(parse('`a ${x} b`'), ['`', [, 'a '], 'x', [, ' b']])
15+
is(run('`hello ${name}!`', { name: 'world' }), 'hello world!')
16+
is(run('`${a} + ${b} = ${a + b}`', { a: 2, b: 3 }), '2 + 3 = 5')
17+
})
18+
19+
test('template: expressions', t => {
20+
is(run('`result: ${x * 2}`', { x: 5 }), 'result: 10')
21+
is(run('`${a > b ? "yes" : "no"}`', { a: 5, b: 3 }), 'yes')
22+
})
23+
24+
test('template: escapes', t => {
25+
is(run('`a\\nb`'), 'a\nb')
26+
is(run('`a\\tb`'), 'a\tb')
27+
is(run('`\\${x}`'), '${x}')
28+
})
29+
30+
test('template: empty', t => {
31+
is(parse('``'), [, ''])
32+
is(run('``'), '')
33+
})
34+
35+
test('template: nested', t => {
36+
is(run('`outer ${`inner ${x}`}`', { x: 1 }), 'outer inner 1')
37+
})

test/unit.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import test, { is, throws } from 'tst'
2+
import { parse, compile } from '../subscript.js'
3+
import { unit } from '../feature/unit.js'
4+
5+
// Register units for tests
6+
unit('px', 'em', 'rem', 'vh', 'vw', 'pt', 's', 'ms', 'deg', 'rad')
7+
8+
const run = (code, ctx = {}) => compile(parse(code))(ctx)
9+
10+
test('unit: basic', t => {
11+
is(parse('5px'), ['px', [, 5]])
12+
is(parse('10rem'), ['rem', [, 10]])
13+
is(parse('2.5s'), ['s', [, 2.5]])
14+
})
15+
16+
test('unit: eval', t => {
17+
is(run('5px'), { value: 5, unit: 'px' })
18+
is(run('100vh'), { value: 100, unit: 'vh' })
19+
is(run('500ms'), { value: 500, unit: 'ms' })
20+
})
21+
22+
test('unit: CSS lengths', t => {
23+
is(run('10em').unit, 'em')
24+
is(run('50vw').unit, 'vw')
25+
is(run('12pt').unit, 'pt')
26+
})
27+
28+
test('unit: time', t => {
29+
is(run('2s'), { value: 2, unit: 's' })
30+
is(run('300ms'), { value: 300, unit: 'ms' })
31+
})
32+
33+
test('unit: angles', t => {
34+
is(run('90deg'), { value: 90, unit: 'deg' })
35+
is(run('3.14rad'), { value: 3.14, unit: 'rad' })
36+
})
37+
38+
test('unit: in expressions', t => {
39+
// Units should not affect normal operations when not applied
40+
is(run('5 + 3'), 8)
41+
is(run('px', { px: 10 }), 10) // px as identifier
42+
})

0 commit comments

Comments
 (0)