dssl / native /spec /02-syntax.md
EHS-Studio's picture
Claude Sonnet 5.5
v0.16.3: native DSSL spec, and the seven bugs writing it found
9da4a9b
|
Raw History Blame Contribute Delete
10.7 kB

2. Syntax

This chapter turns source text into a syntax tree. It needs no tokens, no document and no vocabulary except the lists in the appendices.

2.1 Source text

  • SYN-1. Source is a sequence of Unicode characters. Line numbers start at 1 and count \n characters. A parser that reads bytes MUST decode UTF-8 first. \r is not special: a \r\n file leaves a \r at the end of each line, and values are trimmed, so it does no harm.
  • SYN-2. Every diagnostic carries the file name and a 1-based line number. For a construct that spans several lines, the line is the one where the construct started.

2.2 Comments

There are two comment forms: // to the end of the line and /* a block */.

  • SYN-3. Comments are removed before anything else is parsed, in one pass over the whole source, left to right.
  • SYN-4. A quote character (" or ') that is not inside a comment starts a string. A string ends at the next occurrence of the same quote character that is not directly preceded by a backslash. Comment markers inside a string are plain text.
  • SYN-5. A line comment runs to, but not including, the next \n. A block comment runs to the first */, or to the end of the source if there is none. Inside a block comment, every character except \n is replaced by a space and every \n is kept. Removing a comment therefore never changes the line number of anything after it.
  • SYN-6. An unquoted url( ... ) is one token for the comment pass. When the letters url( (any case) are not directly preceded by a letter, digit, _ or -, and the first character after the ( and any white space is not a quote, the text through the closing ) is kept exactly as written, newlines included, so a // inside it (url(https://x.com/a.png)) is not a comment. A url( with no closing ) is not special. A quoted url("...") is covered by SYN-4. (Before 0.16.3 the engine treated that // as a comment, see D-1.)

Comments are removed from everything else, including the body of raw { }.

// a line comment
.card {
  padding: 16   /* a block comment, even in the middle of a line */
}

2.3 Logical lines

The comment-free source is split on \n into physical lines. Physical lines are joined into logical chunks, and each chunk is processed by 2.4.

  • SYN-7. A blank physical line is skipped. (Inside a raw { } capture it is kept. See 2.7.)
  • SYN-8. A chunk is incomplete when any of these holds:
    1. its parentheses or square brackets are unbalanced (more opening than closing, counting outside quoted strings), or
    2. after trimming trailing white space, it ends in ,, or
    3. after trimming trailing white space, it ends in :.
  • SYN-9. While the buffer is incomplete, the next non-blank physical line is trimmed and appended to the buffer with one space between them. When the buffer becomes complete, it is processed as one chunk, with the line number of the first physical line. A chunk that is incomplete at the end of the file is processed as it stands.

Why: this is how multi-line values, multi-line selector lists and prop: followed by the value on the next line all work with no semicolons.

.card,
.panel {
  background: linear-gradient(
    to bottom, red, blue
  )
  color:
    color.ink
}

2.4 Processing a chunk

A chunk is a string. The parser keeps a stack of open blocks. The root is always at the bottom of the stack. It repeats the following on the chunk until nothing is left:

  1. Trim the chunk. If it is empty, stop.
  2. Find the first top-level { and the first top-level }. "Top-level" means not inside a quoted string and not inside parentheses or square brackets.
  3. If there is a } and either there is no { or the } comes first:
    • treat the text before it as declarations (2.6) if it is not empty,
    • if the stack holds only the root, report unexpected "}" (SYN-10); otherwise pop one block,
    • continue with the text after the }.
  4. Else if there is a {: the text before it is the header text.
    • SYN-11. If the header text contains a top-level ;, everything before the last such ; is treated as declarations (2.6) and the header is the text after it. This is what lets .card { background: red; hover { background: blue } } work on one line. If the remaining header is empty, report block is missing a header before "{" and skip the {.
    • If the header text is empty, report the same error and skip the {.
    • Otherwise classify the header (2.5), append a node to the block on top of the stack, and run the block-open rule (2.7). If the block is not closed on this line, push it.
  5. Else the whole chunk is declarations (2.6). Stop.

A node always carries the line number of its chunk.

2.5 Classifying a header

The header is trimmed. The first row that matches wins.

Header Node kind Notes
exactly tokens tokens 4.1
exactly pin pin the must-win layer, 5.2
exactly raw or @css raw verbatim CSS, 2.7
exactly fonts fonts 8.7
starts with behavior behavior, name = rest, trimmed 6.9
starts with motion motion, name = rest, trimmed chapter 7
starts with @layer layer, name = rest, trimmed 5.2
starts with @media media, query = rest, trimmed 5.5
starts with @container container, query = rest after the 10 characters, trimmed 5.6
starts with @signal signal, expr = rest, trimmed 5.9
starts with any other @ bogus error unknown at-rule "<first word>", the whole block is skipped (2.7)
starts with :: pseudo, name = text after :: up to the first space or ( 5.8. If the name is not a pseudo-element name and not a vendor pseudo, error unknown pseudo-element "::name" and the block is skipped
exactly one of the state words state, name = the word 5.7
anything else selector, sel = the header a CSS selector list

The state words, the pseudo-element names and the vendor-pseudo pattern are in appendix A.

  • SYN-12. Matching of tokens, pin, raw, @css and fonts is on the whole trimmed header. tokens foo { is a selector. @media(width>1) (no space) is an unknown at-rule. @container is the one at-rule that does not require a space.

Unknown at-rules are skipped whole, so nothing inside them leaks out. For verbatim CSS the author writes raw { }.

raw {
  @supports (display: grid) {
    .box { display: grid; }
  }
}

2.6 Declarations

A declaration text is split on top-level ;. Inside a tokens block (the block on top of the stack, or any block below it is a tokens block), each piece is further split by the token-pair rule (2.8). Each non-empty trimmed piece is one declaration:

  • SYN-13. Find the first top-level : in the piece.
  • SYN-14. If there is none:
    • use <name> where <name> matches [A-Za-z][\w-]* is a declaration with property use and value <name>.
    • A bare word matching [a-z][a-z0-9-]* (case-insensitive) is a declaration with that word lowercased as the property and an empty value. (This is how truncate and visually-hidden are written.)
    • Anything else is the error expected ":" in "<first 40 characters>" with the hint declarations look like prop: value.
  • SYN-15. The property is the text before the colon, trimmed and lowercased. The value is the text after it, trimmed, with its case kept. An empty property is the error declaration is missing a property name.
  • SYN-16. A property that is all digits with an optional fraction (9, 2.5) is legal only when some block on the stack is a tokens block. Elsewhere it is the error "<prop>" is not a valid property name, with a hint about token keys.
  • SYN-17. Any other property MUST match ^(-{1,2}[a-z][a-z0-9.-]*|[a-z][a-z0-9-]*)$ (case-insensitive), otherwise the error "<prop>" is not a valid property name is reported and the declaration is dropped. Dotted names are legal only after a leading -- (--color.accent).

A valid declaration becomes a decl node { prop, value, line } appended to the block on top of the stack.

2.7 Block-open rule

After a header is classified and its node is appended:

  • raw and fonts.
    • SYN-18. They are legal only at the top level (the stack holds only the root). Otherwise report raw { } must be top-level (or fonts { } ...) with the hint move it out of the enclosing block, and skip the balanced block.
    • SYN-19. The body is captured verbatim into node.css. If the matching } is on the same line as the header, the body is the text between the braces. Otherwise capture continues on following physical lines: each one is appended followed by \n, blank lines included, until the braces balance. The first line contributes only the text after the {. Brace matching ignores quoted strings. Text after the closing } is processed as a new chunk. If the file ends first, report the raw { } block was never closed with "}" and the CSS captured so far is discarded.
  • bogus.
    • SYN-20. The block is skipped: the parser finds the matching } (quote-aware) and continues after it. If there is none, report the "<first 30 characters>" block was never closed with "}" and discard the rest of the chunk.
  • Everything else. The node is pushed on the stack.

At the end of the file, if more than the root is on the stack, report <n> block(s) were never closed with "}".

2.8 Token pairs

  • SYN-21. Inside a tokens block, pill: 999 card: 22 on one line is two declarations. A piece is split at a space when all of these hold: a top-level : (not part of ::) has already been seen in the piece, the space is outside strings, parentheses and brackets, and the text after the space looks like name: where name matches [A-Za-z0-9][\w.-]* followed by optional spaces and a : that is not ::.

2.9 The syntax tree

root        { kind: 'root', children: [...] }
node        { kind, line, children: [...], ...extra }
decl        { kind: 'decl', prop, value, line }

Extra fields by kind: behavior and motion have name, layer has name, media and container have query, signal has expr, selector has sel, state and pseudo have name, raw and fonts have css. Child order is source order. The tree is data: a Level 1 parser is tested by comparing its tree and its errors with the reference.