///|
/// Shrubbery notation to a CSS syntax tree.
///
/// The surface syntax has one rule -- write the thing's name, not a symbol for
/// it -- and this is where that rule is read back. A call is a thing, a `~name`
/// keyword is a relation, and everything else is what shrubbery already made of
/// it.
///
/// The lowering is a pure function of the shrubbery tree. It reads `Node.span`,
/// for diagnostics, and never `Node.meta`. That matters more than it looks:
/// because nothing depends on how the source was laid out, the lowering is
/// total over hand-built trees as well as parsed ones, which is what lets the
/// round-trip properties generate trees rather than text.
///
/// It never fails in the tolerant mode. Anything unreadable becomes a `Bogus`
/// carrying the reason, so a file with one mistake still lowers to a stylesheet
/// with everything else intact.
///|
/// A lowering: the tree, and everything noticed on the way.
pub struct Lowered {
sheet : @ast.Stylesheet
diagnostics : Array[@err.Diagnostic]
}
///|
pub fn Lowered::sheet(self : Lowered) -> @ast.Stylesheet {
self.sheet
}
///|
pub fn Lowered::diagnostics(self : Lowered) -> Array[@err.Diagnostic] {
self.diagnostics
}
///|
pub fn Lowered::has_errors(self : Lowered) -> Bool {
for d in self.diagnostics {
if d.is_error() {
return true
}
}
false
}
///|
priv struct Lowerer {
diags : Array[@err.Diagnostic]
strict : Bool
}
///|
/// Where a group sits, which decides what it may be.
///
/// The distinction is load-bearing and is the first level of the rule/
/// declaration cascade: at the top of a file a `Group` with a block is always a
/// rule, because CSS has no declarations outside rules; inside `font_face()` it
/// is always a declaration, because that at-rule has no nested rules. Most of a
/// real file is settled here, before any head-shape guessing happens at all.
priv enum Ctx {
/// The top of the file.
Top
/// The body of a style rule: declarations and nested rules interleave.
Nested
/// `font_face()`, `page()`, `property()`, a keyframe: declarations only.
DeclOnly
}
///|
/// Lower a parsed shrubbery tree.
pub fn lower(
root : @sast.Node,
strict? : Bool = false,
) -> Lowered raise @err.ShrubCssError {
let l = { diags: [], strict, }
let items : Array[@ast.TopItem] = []
let groups = match root.it {
Multi(gs) => gs
// A bare group, which is what a fragment parses to.
Group(_) => [root]
_ => []
}
for g in groups {
match as_comment(g) {
Some(c) => {
items.push(Comment(c))
continue
}
None => ()
}
match l.statement(g, Top) {
Some(r) => items.push(Rule(r))
None => ()
}
}
{ sheet: { items, span: cspan(root.span), }, diagnostics: l.diags, }
}
///|
/// A lowering of a rule BODY: the items, and everything noticed on the way.
pub struct LoweredBlock {
items : Array[@ast.BlockItem]
diagnostics : Array[@err.Diagnostic]
}
///|
pub fn LoweredBlock::items(self : LoweredBlock) -> Array[@ast.BlockItem] {
self.items
}
///|
pub fn LoweredBlock::diagnostics(self : LoweredBlock) -> Array[@err.Diagnostic] {
self.diagnostics
}
///|
pub fn LoweredBlock::has_errors(self : LoweredBlock) -> Bool {
for d in self.diagnostics {
if d.is_error() {
return true
}
}
false
}
///|
/// Lower a parsed shrubbery tree as the BODY of a rule.
///
/// `lower` starts at the top of a file, where CSS has no declarations at all --
/// so a consumer holding a SCOPED fragment cannot use it. Everything that goes
/// inside one `[data-cid="3"] { ... }` is made of exactly the declarations that
/// are refused up there, and correctly refused: the context is the first level
/// of the rule-versus-declaration cascade, and at the top of a file the answer
/// really is "rule".
///
/// This is the same lowering in `Nested`, the context that admits both. A
/// declaration is a declaration, a nested rule is a nested rule, and a
/// top-level-only at-rule is refused the way it would be inside any other rule
/// body.
pub fn lower_block(
root : @sast.Node,
strict? : Bool = false,
) -> LoweredBlock raise @err.ShrubCssError {
let l = { diags: [], strict, }
let groups = match root.it {
Multi(gs) => gs
Block(gs) => gs
// A bare group, which is what a one-line fragment parses to.
Group(_) => [root]
_ => []
}
{ items: l.block_items_of(groups, Nested), diagnostics: l.diags, }
}
///|
/// Parse a scoped shrubbery-CSS fragment and lower it as a rule body.
pub fn lower_block_source(
src : String,
strict? : Bool = false,
) -> LoweredBlock raise @err.ShrubCssError {
let parsed = @shrub.parse(src, recover=true) catch {
e => {
let d = shrub_diagnostic(e.diagnostic(), src)
if strict {
d.raise_()
}
return { items: [], diagnostics: [d], }
}
}
let pre : Array[@err.Diagnostic] = []
for d in parsed.diagnostics() {
pre.push(shrub_diagnostic(d, src))
}
if strict && pre.length() > 0 {
pre[0].raise_()
}
let out = lower_block(parsed.root(), strict~)
let all : Array[@err.Diagnostic] = []
for d in pre {
all.push(d)
}
for d in out.diagnostics {
all.push(d)
}
{ items: out.items, diagnostics: all, }
}
///|
/// Parse shrubbery source and lower it in one step.
///
/// Shrubbery's own diagnostics come first and are converted, so a caller sees
/// one list rather than having to consult two. A source that does not parse
/// lowers to an empty stylesheet: there is no tree to lower, and inventing one
/// would put the bridge's guesses where the notation's answer should be.
pub fn lower_source(
src : String,
strict? : Bool = false,
) -> Lowered raise @err.ShrubCssError {
let parsed = @shrub.parse(src, recover=true) catch {
e => {
let d = shrub_diagnostic(e.diagnostic(), src)
if strict {
d.raise_()
}
return {
sheet: { items: [], span: @cspan.Span::new(0, src.length()), },
diagnostics: [d],
}
}
}
let pre : Array[@err.Diagnostic] = []
for d in parsed.diagnostics() {
pre.push(shrub_diagnostic(d, src))
}
if strict && pre.length() > 0 {
pre[0].raise_()
}
let out = lower(parsed.root(), strict~)
let all : Array[@err.Diagnostic] = []
for d in pre {
all.push(d)
}
for d in out.diagnostics {
all.push(d)
}
{ sheet: out.sheet, diagnostics: all, }
}
// -------------------------------------------------------------- statements
///|
/// One group, in a context that says what it may be.
fn Lowerer::statement(
self : Lowerer,
g : @sast.Node,
ctx : Ctx,
) -> @ast.CssRule? raise @err.ShrubCssError {
let items = match g.it {
Group(xs) => xs
_ => return Some(self.bogus_rule(BadSelector, g))
}
let (head, block, has_alts) = split_block(items[:])
if has_alts {
return Some(self.bogus_rule(AltsUnsupported, g))
}
if head.length() == 0 {
return Some(self.bogus_rule(HeadlessBlock, g))
}
// An at-rule is recognised only in statement position, only as `name(...)`,
// and only for a name in the table -- so a type selector called `media` is
// merely unspellable rather than silently reinterpreted. `tag(media)` is the
// way out.
match as_call(head) {
Some((name, args, rest)) =>
if rest.length() == 0 && @names.is_at_rule(@names.unkebab(name)) {
return self.at_rule(g, @names.unkebab(name), args, block, ctx)
}
None => ()
}
match block {
None => Some(self.bogus_rule(ExpectedBlock, g))
Some(b) =>
if is_declaration(head, b, ctx) {
// A declaration is not a rule; the caller that wanted one gets nothing
// and reads it as a declaration itself. `Top` cannot arrive here --
// `is_declaration` says so -- and the case it used to claim to handle
// is the one below.
None
} else {
match ctx {
DeclOnly => Some(self.bogus_rule(RuleInDeclarationContext, g))
Top =>
// Level one settled what this IS: at the top of a file a group with
// a block is a rule, because CSS has no declarations outside one.
// It did not settle what to SAY about it. A group shaped like a
// declaration here is a mistake with a name, and reading it as a
// rule prints `color{ red}` -- which looks like valid CSS, is not,
// and is worse than a refusal.
if declaration_shape(head, b) && !colon_pseudo_shape(b) {
Some(self.bogus_rule(DeclarationAtTopLevel, g))
} else {
let selectors = self.selector_list(head, g.span)
let body = self.block_items(b, Nested)
Some(Style({ selectors, body, span: cspan(g.span), }))
}
Nested => {
let selectors = self.selector_list(head, g.span)
let body = self.block_items(b, Nested)
Some(Style({ selectors, body, span: cspan(g.span), }))
}
}
}
}
}
///|
/// The rule-versus-declaration cascade, level two and three.
///
/// Level one was the context, already applied by the caller. What is left:
///
/// * head shape -- a declaration head is a lone identifier, a `--custom`
/// property, or the `ident("literal")` escape, and nothing else;
/// * block shape -- for a lone identifier only, a value is one group with no
/// block of its own, while a rule body always contains a group that has
/// one.
///
/// The residue is exactly `a: hover`: a lone identifier over a block of one
/// bare word. It is read as a declaration, which is what CSS would say, and
/// `colon_pseudo` fires so the reader is told rather than surprised.
fn is_declaration(
head : ArrayView[@sast.Node],
block : @sast.Node,
ctx : Ctx,
) -> Bool {
match ctx {
Top => false
// Not a bare `true`. `font_face()` holds declarations only, and saying so
// by fiat made `rule_in_declaration_context` unreachable in exactly the way
// `declaration_at_top_level` was: a nested rule in there was read as a
// declaration and printed as `@font-face{a:color : red}`. The context still
// decides -- a lone identifier over a value is a declaration here and would
// be a rule at the top of a file -- it just no longer decides alone.
DeclOnly => declaration_shape(head, block)
Nested => declaration_shape(head, block)
}
}
///|
/// Levels two and three alone: does this GROUP look like a declaration?
///
/// Split out from `is_declaration` because the two questions are different and
/// were once the same function. "Is it a declaration here" is settled by the
/// context first, and at the top of a file the answer is always no. "Does it
/// look like one" has no context in it, which is what lets the top-level case
/// report `declaration_at_top_level` instead of printing `color{ red}`.
fn declaration_shape(head : ArrayView[@sast.Node], block : @sast.Node) -> Bool {
if as_dashed(head) is Some(_) {
return true
}
match as_call(head) {
Some((name, args, rest)) =>
if name == "ident" &&
rest.length() == 0 &&
literal_string(args) is Some(_) {
return true
}
None => ()
}
match head {
[{ it: Id(_), .. }] => block_is_value(block)
_ => false
}
}
///|
/// Is this block a single bare word naming a pseudo-class or pseudo-element?
///
/// The `a: hover` mistake, seen from the top of a file. It is declaration-
/// shaped, so `declaration_at_top_level` would fire and be right in the letter
/// and wrong in the spirit: the author meant `a:hover`, a selector, and
/// `colon_pseudo` is the diagnostic that says so. Letting it through to the
/// rule path is what puts it in front of the guard in `block_items_of`, which
/// is the one that already knows how to name it.
fn colon_pseudo_shape(block : @sast.Node) -> Bool {
let groups = match block.it {
Block(gs) => gs
_ => return false
}
if groups.length() != 1 {
return false
}
match groups[0].it {
Group([{ it: Id(v), .. }]) => {
let css_v = @names.unkebab(v)
@names.is_simple_pseudo(css_v) || @names.is_pseudo_element(css_v)
}
_ => false
}
}
///|
/// An at-rule written as a call, in a position where one is being looked for.
fn is_at_rule_call(head : ArrayView[@sast.Node]) -> Bool {
match as_call(head) {
Some((name, _, rest)) =>
rest.length() == 0 && @names.is_at_rule(@names.unkebab(name))
None => false
}
}
///|
/// `comment("...")`: a CSS comment, carried as data.
///
/// It has to be data because shrubbery comments are trivia, and trivia does not
/// reach the tree. See the emitter for the whole argument.
fn as_comment(g : @sast.Node) -> @ast.Comment? {
let items = match g.it {
Group(xs) => xs[:]
_ => return None
}
match as_call(items) {
Some((name, args, rest)) =>
if name == "comment" && rest.length() == 0 {
match literal_string(args) {
Some(text) => Some({ text, span: cspan(g.span), })
None => None
}
} else {
None
}
None => None
}
}
///|
/// Whether a block holds one value rather than a rule body.
fn block_is_value(block : @sast.Node) -> Bool {
match block.it {
Block(gs) => gs.length() == 1 && !group_has_block(gs[0])
_ => false
}
}
///|
fn group_has_block(g : @sast.Node) -> Bool {
match g.it {
Group(xs) => {
for x in xs {
match x.it {
Block(_) | Alts(_) => return true
_ => ()
}
}
false
}
_ => false
}
}
///|
/// The contents of a block, as declarations and nested rules in source order.
fn Lowerer::block_items(
self : Lowerer,
block : @sast.Node,
ctx : Ctx,
) -> Array[@ast.BlockItem] raise @err.ShrubCssError {
match block.it {
Block(gs) => self.block_items_of(gs, ctx)
_ => []
}
}
///|
/// The same, over a group sequence that is not wrapped in a `Block`.
///
/// This is what `lower_block` needs: a scoped fragment parses to a `Multi`, and
/// its groups are a rule body without any rule around them.
fn Lowerer::block_items_of(
self : Lowerer,
groups : Array[@sast.Node],
ctx : Ctx,
) -> Array[@ast.BlockItem] raise @err.ShrubCssError {
let out : Array[@ast.BlockItem] = []
for g in groups {
match as_comment(g) {
Some(c) => {
out.push(Comment(c))
continue
}
None => ()
}
let items = match g.it {
Group(xs) => xs
_ => {
out.push(Bogus(self.bogus(BadSelector, g)))
continue
}
}
let (head, blk, has_alts) = split_block(items[:])
if has_alts {
out.push(Bogus(self.bogus(AltsUnsupported, g)))
continue
}
match blk {
None => {
// A blockless at-rule -- `charset("utf-8")`, `import("x.css")` -- has
// no block, so without this it would be reported as a bare word. It is
// not a bare word, it is an at-rule in a place that has none, and
// `statement` is what knows how to say so.
if is_at_rule_call(head) {
match self.statement(g, ctx) {
Some(r) => out.push(Rule(r))
None => ()
}
continue
}
// A bare term: not a declaration, not a rule. When it names a
// pseudo-class it is the `a: hover` mistake wearing its top-level
// spelling, so it gets that diagnostic rather than a vaguer one.
let what = describe_head(head)
let css_what = @names.unkebab(what)
let kind : @kind.ErrorKind = if @names.is_simple_pseudo(css_what) ||
@names.is_pseudo_element(css_what) {
ColonPseudo("", css_what)
} else {
BareTermInBlock(what)
}
self.error(kind, g.span)
out.push(Bogus(bogus_node(kind, g)))
}
Some(b) =>
if is_declaration(head, b, ctx) {
match self.declaration(g, head, b) {
Some(d) => out.push(Decl(d))
None => ()
}
} else {
match self.statement(g, ctx) {
Some(r) => out.push(Rule(r))
None => ()
}
}
}
}
out
}
///|
/// A declaration block: the same, but a nested rule is an error rather than a
/// possibility.
fn Lowerer::decl_block(
self : Lowerer,
block : @sast.Node,
) -> @ast.DeclBlock raise @err.ShrubCssError {
{ decls: self.block_items(block, DeclOnly), span: cspan(block.span), }
}
// ------------------------------------------------------------ declarations
///|
fn Lowerer::declaration(
self : Lowerer,
g : @sast.Node,
head : ArrayView[@sast.Node],
block : @sast.Node,
) -> @ast.Declaration? raise @err.ShrubCssError {
let property : @ast.PropertyName = match as_dashed(head) {
Some(name) => Custom(name)
None =>
match as_call(head) {
Some((name, args, _)) if name == "ident" =>
match literal_string(args) {
Some(s) => Ident(s)
None => Ident("")
}
_ =>
match head {
[{ it: Id(n), .. }] => {
// The one place a CSS habit produces a wrong tree rather than an
// error: `a: hover` is structurally a declaration and reads as
// one. Saying so is the whole point of the diagnostic.
self.warn_colon_pseudo(n, block, g)
Ident(@names.unkebab(n))
}
_ => Ident("")
}
}
}
let groups = match block.it {
Block(gs) => gs
_ => []
}
if groups.length() > 1 {
// `;` after a value puts the next declaration INSIDE this one's block.
self.error(SemicolonDeclarations, groups[1].span)
}
if groups.length() == 0 {
return None
}
let (value, important) = self.value_of(groups[0])
Some({ property, value, important, span: cspan(g.span), })
}
///|
/// `a: hover`, guarded so that `cursor: default` stays quiet.
///
/// The guard is what makes the check usable: `default`, `link`, `first` and
/// `left` are all pseudo-class names AND ordinary CSS values, so firing on the
/// value alone would cry wolf on every third stylesheet. Requiring the property
/// to be one CSS does not have is what narrows it to the real mistake.
fn Lowerer::warn_colon_pseudo(
self : Lowerer,
property : String,
block : @sast.Node,
g : @sast.Node,
) -> Unit {
if is_known_property(@names.unkebab(property)) {
return
}
match block.it {
Block([{ it: Group([{ it: Id(v), .. }]), .. }]) => {
let css_v = @names.unkebab(v)
if @names.is_simple_pseudo(css_v) || @names.is_pseudo_element(css_v) {
self.diags.push(
@err.Diagnostic::new(ColonPseudo(property, css_v), g.span),
)
}
}
_ => ()
}
}
// --------------------------------------------------------------- mechanics
///|
fn Lowerer::error(
self : Lowerer,
kind : @kind.ErrorKind,
span : @basic.Span,
) -> Unit raise @err.ShrubCssError {
let d = @err.Diagnostic::new(kind, span)
self.diags.push(d)
if self.strict {
d.raise_()
}
}
///|
/// A `Bogus` node, with its diagnostic recorded.
fn Lowerer::bogus(
self : Lowerer,
kind : @kind.ErrorKind,
n : @sast.Node,
) -> @ast.Bogus raise @err.ShrubCssError {
self.error(kind, n.span)
bogus_node(kind, n)
}
///|
/// A `Bogus` node whose diagnostic the caller has already recorded.
fn bogus_node(kind : @kind.ErrorKind, n : @sast.Node) -> @ast.Bogus {
// The CSS layer's kinds and this one's are different sets, so the reason is
// carried across as text. What the printer echoes is the shrubbery that was
// written, PRINTED FLAT rather than reproduced from the node's raw metadata.
//
// Two reasons, and both are load-bearing. Reproducing the source carries its
// newlines and its indentation, so a `Minified` stylesheet came back with a
// literal line break inside it. And `to_source` reads `Node.meta`, which this
// lowering promises never to do -- a hand-built tree has no metadata, so the
// echo was empty for exactly the trees the property tests generate.
@ast.Bogus::new(
@csskind.ErrorKind::Unexpected(kind.code()),
cspan(n.span),
text=flat(n),
)
}
///|
/// A shrubbery node on one line, for echoing inside a diagnostic or a `Bogus`.
///
/// `Style::Flat` is not it: flat writes a block as `« ... »`, which is legal
/// shrubbery and unreadable inside a CSS error. `Pretty` at a width nothing
/// reaches gives the same one line without the armouring, and the collapse
/// below makes that a guarantee rather than a hope -- a run of whitespace that
/// contains a newline becomes one space, and a run that does not is left alone,
/// so the spaces inside a string literal survive.
fn flat(n : @sast.Node) -> String {
let s = @swrite.write(n, style=Pretty, width=Some(1_000_000))
let out = StringBuilder()
let run = StringBuilder()
let mut broke = false
let mut started = false
fn flush() {
let ws = run.to_string()
if ws != "" {
if broke {
if started {
out.write_char(' ')
}
} else {
out.write_string(ws)
}
}
run.reset()
broke = false
}
for c in s {
if c == '\n' || c == '\r' || c == '\t' || c == ' ' {
if c == '\n' || c == '\r' {
broke = true
}
run.write_char(c)
} else {
flush()
out.write_char(c)
started = true
}
}
// A trailing run is dropped: it is the newline the printer ends on.
out.to_string()
}
///|
fn Lowerer::bogus_rule(
self : Lowerer,
kind : @kind.ErrorKind,
n : @sast.Node,
) -> @ast.CssRule raise @err.ShrubCssError {
Bogus(self.bogus(kind, n))
}
///|
/// A shrubbery span as a CSS one. Both are UTF-16 code unit offsets, so this
/// reads a field rather than converting anything.
fn cspan(s : @basic.Span) -> @cspan.Span {
@cspan.Span::new(s.start.idx, s.end.idx)
}
///|
/// A shrubbery diagnostic, carried across with CSS-specific help attached.
///
/// A CSS author's first mistakes are all lexical -- `10px`, `#fff`, `@media` --
/// and shrubbery rejects each of them before this layer sees a tree at all. So
/// the enrichment happens here, on the way past: the original kind and span are
/// kept, and what is added is the sentence naming the thing to type instead.
fn shrub_diagnostic(d : @shrub_err.Diagnostic, src : String) -> @err.Diagnostic {
let text = slice(src, d.span.start.idx, d.span.end.idx)
@err.Diagnostic::new(lexical_hint(text), d.span)
}
///|
/// What a rejected run of source most likely meant.
fn lexical_hint(text : String) -> @kind.ErrorKind {
if text.has_prefix("#") {
SigilSelector("#")
} else if text.has_prefix("@") {
SigilSelector("@")
} else if text == "~" {
OperatorCombinator("~")
} else if looks_dimension(text) {
Unsupported("`" + text + "`: a CSS dimension is a call, like `px(10)`")
} else {
Unsupported("`" + text + "`")
}
}
///|
/// `10px`, `2n` -- a number run into letters, which shrubbery rejects.
fn looks_dimension(text : String) -> Bool {
if text.length() == 0 {
return false
}
let mut seen_digit = false
let mut seen_alpha = false
for c in text {
if c >= '0' && c <= '9' {
if seen_alpha {
return false
}
seen_digit = true
} else if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '%' {
if !seen_digit {
return false
}
seen_alpha = true
} else if c != '.' {
return false
}
}
seen_digit && seen_alpha
}
///|
fn slice(src : String, from : Int, to : Int) -> String {
let a = if from < 0 { 0 } else { from }
let b = if to < a { a } else { to }
src.clamped_view(start=a, end=b).to_owned()
}
///|
/// A short name for what a head looks like, for a diagnostic to quote.
fn describe_head(head : ArrayView[@sast.Node]) -> String {
match head {
[{ it: Id(n), .. }, ..] => n
[{ it: Kw(k), .. }, ..] => "~" + k
[{ it: Op(o), .. }, ..] => o
_ => "this"
}
}
///|
/// Whether CSS has a property by this name.
///
/// Advisory, and short on purpose: it exists only to keep `colon_pseudo` from
/// firing on `cursor: default`, so it needs the properties whose values collide
/// with pseudo-class names, not all of them. Missing a property makes a
/// diagnostic slightly more eager, never wrong.
fn is_known_property(name : String) -> Bool {
match name {
"cursor" | "display" | "position" | "float" | "clear" | "color" => true
"content" | "visibility" | "overflow" | "direction" | "resize" => true
"all" | "appearance" | "user-select" | "pointer-events" => true
"text-align" | "vertical-align" | "white-space" | "word-break" => true
_ => name.has_prefix("--")
}
}