code wiki / (root) / nx_css_parse.nx

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}