nx_css_parse.nx source
↩ module page · 459 lines · 19429 B
1// nx_css_parse.nx -- CSS parser for the Nishi browser. Phase 2 (CSS
2// subset) of docs/NISHI_BROWSER_ROADMAP.md, second primitive after
3// nx_css_tokenize.nx.
4//
5// Consumes the CssToken stream produced by nx_css_next_token and
6// emits a CssRule AST suitable for cascade + apply by nx_css_apply
7// (next primitive, queued).
8//
9// Subset:
10// - one simple selector per rule (TAG / CLASS / ID)
11// - one or more declarations per rule, separated by ';'
12// - each declaration's value is ONE of:
13// NUMBER + UNIT (e.g., "16px" -- emitted as NUMBER then IDENT
14// by the tokenizer; parser pairs them)
15// HEXCOLOR (e.g., "#ff0000")
16// IDENT (e.g., "red" or "sans-serif")
17// STRING (e.g., "Arial")
18// - rules are separated by whitespace (no comma-separated selector
19// lists in Phase 2 -- one selector per rule, repeated for groups)
20//
21// What it does NOT handle yet (named Phase 2b improvements):
22// - selector lists with commas (group as multiple rules)
23// - compound selectors (h1.foo#bar) (one type/class/id per rule)
24// - descendant chains (header h1) (single simple selector only)
25// - multi-token values (margin: 8px 16px;)
26// - !important
27// - shorthand expansion (border, font, margin)
28// - @-rules (silently skipped to next rule)
29//
30// Subset is sufficient to drive nx_css_apply on andelinwest.com-class
31// hand-written CSS. Per cardinal feedback-honest-perf-verdict: gap
32// list is EXACT.
33//
34// genealogy_id: w3c_css_syntax_module_level_3_parser_section +
35// substrate_browser_phase_2_second_primitive
36// lineage_id: nishi_browser_css_parse_v1
37//
38// nx_safety_envelope:
39// intended_use: "CSS parser -- browser front-end style apply"
40// sil_target: SIL1
41// evidence: [W3C_CSS_syntax_canonical_basis,
42// sealed_selector_kind_enum,
43// sealed_value_kind_enum,
44// bounded_state_machine,
45// no_allocation_caller_owned_memory]
46// verdict: NOT_YET_EVALUATED
47
48import "nx_syscalls.nx"
49import "nx_css_tokenize.nx"
50const NX_MAGIC_8192: i64 = 8192
51const NX_MAGIC_65536: i64 = 65536
52
53// Sealed selector kind enum.
54const NX_CSS_SEL_UNKNOWN: i64 = 0
55const NX_CSS_SEL_TAG: i64 = 1 // body, div, h1
56const NX_CSS_SEL_CLASS: i64 = 2 // .head, .menu-item
57const NX_CSS_SEL_ID: i64 = 3 // #main
58const NX_CSS_SEL_N: i64 = 4
59
60// Sealed declaration value kind enum. Mirrors the token shapes the
61// tokenizer can emit at value position.
62const NX_CSS_VAL_UNKNOWN: i64 = 0
63const NX_CSS_VAL_NUMBER: i64 = 1 // bare number, no unit
64const NX_CSS_VAL_DIMENSION: i64 = 2 // number + unit ident (16px, 1.5em)
65const NX_CSS_VAL_HEXCOLOR: i64 = 3 // #ff0000 / #fff
66const NX_CSS_VAL_IDENT: i64 = 4 // red, sans-serif, auto
67const NX_CSS_VAL_STRING: i64 = 5 // "Arial"
68const NX_CSS_VAL_N: i64 = 6
69
70// One declaration. Property name + a single typed value with
71// optional dimension unit.
72struct CssDeclaration {
73 prop_off: i64,
74 prop_len: i64,
75 val_kind: i64,
76 val_off: i64,
77 val_len: i64,
78 unit_off: i64, // populated only for VAL_DIMENSION
79 unit_len: i64
80}
81
82const NX_CSS_DECLARATION_BYTES: i64 = 56
83
84// One rule. Single simple selector + range into the declarations
85// array (caller owns).
86struct CssRule {
87 sel_kind: i64,
88 sel_off: i64,
89 sel_len: i64,
90 sel_full_off: i64, // FULL selector text range (for complex matching: compound/descendant/
91 sel_full_len: i64, // child/comma-list). sel_off/len above = just the first simple selector.
92 decl_first: i64, // index into declarations array
93 decl_count: i64
94}
95
96const NX_CSS_RULE_BYTES: i64 = 56
97
98// Parse-state bundle. Threaded through helpers so signatures stay
99// under the 16-arg cap per the nxc2 parser convention. Caller owns
100// every pointer.
101struct CssParseState {
102 cur: *CssCursor,
103 tok: *CssToken, // scratch token slot, caller-supplied
104 rules: *CssRule,
105 max_rules: i64,
106 rule_count: i64,
107 decls: *CssDeclaration,
108 max_decls: i64,
109 decl_count: i64,
110 last_tok_kind: i64 // saved kind of the most-recent advance
111}
112
113const NX_CSS_PARSE_STATE_BYTES: i64 = 72
114
115// ---- helpers (no forward refs; defined before callers) ----
116
117// Advance to the next non-whitespace token; updates last_tok_kind.
118// Returns the kind read; -1 on EOF.
119func _css_advance_skip_ws(st: *CssParseState) -> i64 {
120 var keep: i64 = 1
121 var k: i64 = -1
122 while keep == 1 {
123 let rc: i64 = nx_css_next_token(st.cur, st.tok)
124 if rc == 0 {
125 k = NX_CSS_TOK_EOF
126 keep = 0
127 } else {
128 if st.tok.kind == NX_CSS_TOK_WHITESPACE { }
129 else {
130 k = st.tok.kind
131 keep = 0
132 }
133 }
134 }
135 st.last_tok_kind = k
136 return k
137}
138
139// Advance ONE token (whitespace included). Returns kind; sets state.
140func _css_advance_one(st: *CssParseState) -> i64 {
141 let rc: i64 = nx_css_next_token(st.cur, st.tok)
142 if rc == 0 {
143 st.last_tok_kind = NX_CSS_TOK_EOF
144 return NX_CSS_TOK_EOF
145 }
146 st.last_tok_kind = st.tok.kind
147 return st.tok.kind
148}
149
150// Parse a single simple selector at the current position. Fills the
151// rule at index st.rule_count with sel_kind/sel_off/sel_len. Returns
152// 1 on success, 0 on parse error.
153//
154// Pre: current token already read into st.tok by _css_advance_skip_ws.
155// Post: cursor positioned after the selector; LBRACE not yet consumed.
156func _css_parse_selector(st: *CssParseState) -> i64 {
157 let rule_idx: i64 = st.rule_count
158 let r: *CssRule = (st.rules as *u8 + (rule_idx as nx_size) * (NX_CSS_RULE_BYTES as nx_size)) as *CssRule
159
160 let k: i64 = st.last_tok_kind
161
162 if k == NX_CSS_TOK_IDENT {
163 // TAG selector: the IDENT itself is the selector.
164 r.sel_kind = NX_CSS_SEL_TAG
165 r.sel_off = st.tok.body_off
166 r.sel_len = st.tok.body_len
167 return 1
168 }
169
170 if k == NX_CSS_TOK_DOT {
171 // CLASS selector: '.' then IDENT.
172 let k2: i64 = _css_advance_one(st)
173 if k2 != NX_CSS_TOK_IDENT { return 0 }
174 r.sel_kind = NX_CSS_SEL_CLASS
175 r.sel_off = st.tok.body_off
176 r.sel_len = st.tok.body_len
177 return 1
178 }
179
180 if k == NX_CSS_TOK_HASH {
181 // ID selector: '#' then IDENT.
182 let k3: i64 = _css_advance_one(st)
183 if k3 != NX_CSS_TOK_IDENT { return 0 }
184 r.sel_kind = NX_CSS_SEL_ID
185 r.sel_off = st.tok.body_off
186 r.sel_len = st.tok.body_len
187 return 1
188 }
189
190 return 0
191}
192
193// Consume the REST of a multi-token value: the cursor is already at the first TRAILING token (st.tok),
194// known to be a non-terminator. Advances to the terminating ';' or '}' (or EOF), extending d.val_len to
195// span from d.val_off to the end of the last value token -- so values like `grid-template-columns:1fr 1fr
196// 1fr`, `repeat(3,1fr)`, `margin:8px 16px` are CAPTURED as a whole-value span instead of rejected. SINGLE
197// -token values never reach here (they hit the terminator directly), so existing typed-value behavior is
198// byte-for-byte unchanged. Leaves st.last_tok_kind = the terminator so the caller finishes the decl.
199func _css_consume_value_rest(st: *CssParseState, d: *CssDeclaration) -> i64 {
200 var val_end: i64 = d.val_off + d.val_len
201 var k: i64 = st.last_tok_kind
202 var guard: i64 = 0
203 var stop: i64 = 0
204 while stop == 0 {
205 if guard >= NX_MAGIC_8192 { stop = 1 }
206 else {
207 guard = guard + 1
208 if k == NX_CSS_TOK_SEMI { stop = 1 }
209 else { if k == NX_CSS_TOK_RBRACE { stop = 1 }
210 else { if k == NX_CSS_TOK_EOF { stop = 1 }
211 else {
212 if k != NX_CSS_TOK_WHITESPACE {
213 let te: i64 = st.tok.body_off + st.tok.body_len
214 if te > val_end { val_end = te }
215 }
216 k = _css_advance_one(st)
217 } } }
218 }
219 }
220 d.val_len = val_end - d.val_off
221 st.last_tok_kind = k
222 return 1
223}
224
225// Parse one property:value; declaration. Appends to st.decls.
226// Returns 1 on success, 0 on parse error or no more declarations.
227//
228// Pre: caller has read the next non-whitespace token into st.tok.
229// If that token is RBRACE, returns 0 (block end) without erroring.
230// Post: cursor positioned after the trailing SEMI (or just before
231// RBRACE if no trailing SEMI).
232func _css_parse_declaration(st: *CssParseState) -> i64 {
233 let k_prop: i64 = st.last_tok_kind
234
235 if k_prop == NX_CSS_TOK_RBRACE { return 0 }
236 if k_prop != NX_CSS_TOK_IDENT { return 0 }
237
238 if st.decl_count >= st.max_decls { return 0 }
239
240 let d_idx: i64 = st.decl_count
241 let d: *CssDeclaration = (st.decls as *u8 + (d_idx as nx_size) * (NX_CSS_DECLARATION_BYTES as nx_size)) as *CssDeclaration
242
243 d.prop_off = st.tok.body_off
244 d.prop_len = st.tok.body_len
245 d.val_kind = NX_CSS_VAL_UNKNOWN
246 d.val_off = 0
247 d.val_len = 0
248 d.unit_off = 0
249 d.unit_len = 0
250
251 // Expect colon.
252 let k_colon: i64 = _css_advance_skip_ws(st)
253 if k_colon != NX_CSS_TOK_COLON { return 0 }
254
255 // Read value.
256 let k_val: i64 = _css_advance_skip_ws(st)
257
258 if k_val == NX_CSS_TOK_HEXCOLOR {
259 d.val_kind = NX_CSS_VAL_HEXCOLOR
260 d.val_off = st.tok.body_off
261 d.val_len = st.tok.body_len
262 } else {
263 if k_val == NX_CSS_TOK_NUMBER {
264 d.val_kind = NX_CSS_VAL_NUMBER
265 d.val_off = st.tok.body_off
266 d.val_len = st.tok.body_len
267 // Peek-via-advance for optional unit. CSS doesn't permit
268 // whitespace between number and unit, so we use the
269 // raw _css_advance_one and only treat IDENT immediately
270 // following as a unit.
271 let k_unit: i64 = _css_advance_one(st)
272 if k_unit == NX_CSS_TOK_IDENT {
273 d.val_kind = NX_CSS_VAL_DIMENSION
274 d.unit_off = st.tok.body_off
275 d.unit_len = st.tok.body_len
276 } else {
277 // The token we read is the terminator (no unit followed the bare number). nx's tokenizer
278 // has no rewind, so the declaration is finalized HERE -- the dimension/ident/hex paths reach
279 // the shared end-check below, but the number path already consumed its terminator token.
280 // BUGFIX 2026-06-20: these terminator branches must INCREMENT st.decl_count -- previously
281 // they `return 1` WITHOUT counting, so EVERY unitless-number value (flex-grow, flex, opacity,
282 // z-index, order, unitless line-height, font-weight:400) was parsed into the slot then
283 // silently dropped (decl_count never advanced -> the next decl overwrote it). The dimension
284 // path (e.g. width:200px) worked only because it falls through to the counting end-check.
285 st.last_tok_kind = k_unit
286 if k_unit == NX_CSS_TOK_SEMI { st.decl_count = st.decl_count + 1; return 1 }
287 if k_unit == NX_CSS_TOK_WHITESPACE {
288 let k_after: i64 = _css_advance_skip_ws(st)
289 if k_after == NX_CSS_TOK_SEMI { st.decl_count = st.decl_count + 1; return 1 }
290 if k_after == NX_CSS_TOK_RBRACE { st.decl_count = st.decl_count + 1; return 1 }
291 return 0
292 }
293 if k_unit == NX_CSS_TOK_RBRACE { st.decl_count = st.decl_count + 1; return 1 }
294 return 0
295 }
296 } else {
297 if k_val == NX_CSS_TOK_IDENT {
298 d.val_kind = NX_CSS_VAL_IDENT
299 d.val_off = st.tok.body_off
300 d.val_len = st.tok.body_len
301 } else {
302 if k_val == NX_CSS_TOK_STRING {
303 d.val_kind = NX_CSS_VAL_STRING
304 d.val_off = st.tok.body_off
305 d.val_len = st.tok.body_len
306 } else {
307 return 0
308 }
309 }
310 }
311 }
312
313 // Expect SEMI or peek RBRACE. Skip optional whitespace.
314 let k_end: i64 = _css_advance_skip_ws(st)
315 if k_end == NX_CSS_TOK_SEMI {
316 st.decl_count = st.decl_count + 1
317 return 1
318 }
319 if k_end == NX_CSS_TOK_RBRACE {
320 // Last declaration without trailing ';' is legal CSS.
321 st.decl_count = st.decl_count + 1
322 // Mark last_tok_kind = RBRACE so caller exits the block loop.
323 st.last_tok_kind = NX_CSS_TOK_RBRACE
324 return 1
325 }
326
327 // Trailing token(s) after the first value token -> MULTI-TOKEN value (grid-template-columns:1fr 1fr
328 // 1fr, repeat(...), border/margin shorthands): consume to the terminator, spanning val_len over the
329 // whole value, instead of rejecting the declaration. st.tok is currently the first trailing token.
330 _css_consume_value_rest(st, d)
331 if st.last_tok_kind == NX_CSS_TOK_SEMI {
332 st.decl_count = st.decl_count + 1
333 return 1
334 }
335 if st.last_tok_kind == NX_CSS_TOK_RBRACE {
336 st.decl_count = st.decl_count + 1
337 st.last_tok_kind = NX_CSS_TOK_RBRACE
338 return 1
339 }
340 return 0
341}
342
343// ---- public API ----
344
345// Init a parse state over a tokenizer cursor + caller-supplied output
346// arrays. The token scratch slot must be writable for the parser's
347// lifetime.
348func nx_css_parse_state_init(st: *CssParseState,
349 cur: *CssCursor,
350 tok: *CssToken,
351 rules: *CssRule,
352 max_rules: i64,
353 decls: *CssDeclaration,
354 max_decls: i64) -> i64 {
355 st.cur = cur
356 st.tok = tok
357 st.rules = rules
358 st.max_rules = max_rules
359 st.rule_count = 0
360 st.decls = decls
361 st.max_decls = max_decls
362 st.decl_count = 0
363 st.last_tok_kind = NX_CSS_TOK_UNKNOWN
364 return 0
365}
366
367// Parse the entire stylesheet bytes the cursor was initialized over.
368// Returns 1 on success (st.rule_count rules + st.decl_count
369// declarations filled). Returns 0 on parse error -- st.rule_count and
370// st.decl_count reflect rules successfully parsed prior to failure.
371func nx_css_parse(st: *CssParseState) -> i64 {
372 var keep: i64 = 1
373 while keep == 1 {
374 // Skip leading whitespace; check for EOF.
375 let k_start: i64 = _css_advance_skip_ws(st)
376 if k_start == NX_CSS_TOK_EOF { keep = 0 }
377 else {
378 if st.rule_count >= st.max_rules {
379 // No more rule slots available.
380 keep = 0
381 } else {
382 let r_idx: i64 = st.rule_count
383 let r: *CssRule = (st.rules as *u8 + (r_idx as nx_size) * (NX_CSS_RULE_BYTES as nx_size)) as *CssRule
384 r.sel_kind = NX_CSS_SEL_UNKNOWN
385 r.sel_off = 0
386 r.sel_len = 0
387 r.sel_full_off = st.tok.src_off // selector begins at the current (first) token.
388 // NOTE: src_off (set for EVERY token kind incl. '.', '#', '{') -- NOT body_off, which is
389 // only populated for IDENT/STRING/NUMBER/HEXCOLOR. Using body_off gave a stale 0 at the
390 // LBRACE -> negative sel_full_len -> the complex cascade matched NOTHING (ncomp=0).
391 r.sel_full_len = 0
392 r.decl_first = st.decl_count
393 r.decl_count = 0
394
395 // Parse the FIRST simple selector (keeps sel_kind/off/len for the simple matcher path).
396 let sel_ok: i64 = _css_parse_selector(st)
397 // ROBUST: a selector the SIMPLE parser can't handle (#hex-ish-id tokenized as HEXCOLOR,
398 // `*`, `[attr]`, `:pseudo`, leading combinator) must NOT abort the WHOLE stylesheet --
399 // that silently truncated every page's CSS at its first such rule. Leave sel_kind=UNKNOWN
400 // (the simple matcher skips it) and fall through to the skip-to-LBRACE below; rh_cascade
401 // matches via sel_full (the raw selector text) with the complex matcher.
402 if sel_ok != 1 { r.sel_kind = NX_CSS_SEL_UNKNOWN }
403
404 // Skip any REMAINING selector parts (compound .a.b / descendant .a .b / child .a>.b /
405 // comma-lists / pseudo / attribute) up to LBRACE. ROBUST: capture the full selector
406 // text in sel_full for complex matching instead of ERRORING (which dropped the entire
407 // rest of the stylesheet at the first complex selector -- why page CSS barely applied).
408 var k_brace: i64 = _css_advance_skip_ws(st)
409 var skipg: i64 = 0
410 while k_brace != NX_CSS_TOK_LBRACE {
411 if k_brace == NX_CSS_TOK_EOF { return 0 }
412 if skipg >= NX_MAGIC_8192 { return 0 }
413 skipg = skipg + 1
414 k_brace = _css_advance_skip_ws(st)
415 }
416 r.sel_full_len = st.tok.src_off - r.sel_full_off // LBRACE src_off - selector start
417
418 // Parse declarations until RBRACE.
419 let decls_before: i64 = st.decl_count
420 var d_keep: i64 = 1
421 while d_keep == 1 {
422 let k_next: i64 = _css_advance_skip_ws(st)
423 if k_next == NX_CSS_TOK_RBRACE { d_keep = 0 }
424 else {
425 if k_next == NX_CSS_TOK_EOF { return 0 }
426 else {
427 let d_ok: i64 = _css_parse_declaration(st)
428 if d_ok != 1 {
429 // ROBUST: skip a malformed/unsupported declaration (multi-value like
430 // `1px solid black`, calc()/var()/!important) to the next ';' or '}'
431 // instead of aborting the WHOLE sheet. _css_parse_declaration returns 0
432 // AT/BEFORE the offending token (never past the block close), so this
433 // recovery can't desync. Previously the FIRST such page declaration
434 // killed the rest of the stylesheet (multi-value decls are EVERYWHERE).
435 var rk: i64 = st.last_tok_kind
436 var rg: i64 = 0
437 var rdone: i64 = 0
438 while rdone == 0 {
439 if rk == NX_CSS_TOK_SEMI { rdone = 1 }
440 else { if rk == NX_CSS_TOK_RBRACE { d_keep = 0; rdone = 1 }
441 else { if rk == NX_CSS_TOK_EOF { d_keep = 0; rdone = 1 }
442 else { if rg >= NX_MAGIC_65536 { d_keep = 0; rdone = 1 }
443 else { rg = rg + 1; rk = _css_advance_skip_ws(st) } } } }
444 }
445 } else {
446 // If declaration ended at RBRACE, we are done with this block.
447 if st.last_tok_kind == NX_CSS_TOK_RBRACE { d_keep = 0 }
448 }
449 }
450 }
451 }
452
453 r.decl_count = st.decl_count - decls_before
454 st.rule_count = st.rule_count + 1
455 }
456 }
457 }
458 return 1
459}