///|
/// Shrubbery notation to a markup tree.
///
/// The surface syntax has three laws and this is where they are read back:
///
/// 1. An element is a call; its children are its block.
/// 2. Its attributes are its arguments; an attribute is a declaration.
/// 3. Text is a string literal. Whitespace only ever exists inside one.
///
/// The third is the one that earns the design. Shrubbery's tree does not record
/// whitespace, so in this notation a space cannot mean anything unless it is
/// inside a string -- which means the indentation is free, and the whole of
/// HTML's whitespace-sensitivity problem does not arise. `"Read "` carries its
/// own trailing space; reindent the source however you like and the rendered
/// page is unchanged.
///
/// 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 document
/// with everything else intact.
///|
/// A lowering: the tree, and everything noticed on the way.
pub struct Lowered {
document : @ast.Document
diagnostics : Array[@err.Diagnostic]
}
///|
pub fn Lowered::document(self : Lowered) -> @ast.Document {
self.document
}
///|
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
}
///|
/// What to do with the block of a raw-text element.
///
/// The obvious next thought after `style(): @{ ... }` is to let that body hold
/// shrubbery CSS -- and acting on it would make `marianoguerra/shrubbery-css` a
/// dependency of this module, which every consumer would then fetch whether
/// they wanted CSS or not. So the hook is a parameter instead: a caller that
/// already depends on both supplies one and gets the composition; everyone else
/// pays nothing.
///
/// The function is handed the element's name and the groups in its block, and
/// returns the raw text to put inside it, or `None` to fall back to the
/// ordinary reading.
pub type RawTextHook = (String, Array[@sast.Node]) -> String?
///|
priv struct Lowerer {
diags : Array[@err.Diagnostic]
strict : Bool
hook : RawTextHook?
}
///|
/// Lower a parsed shrubbery tree.
pub fn lower(
root : @sast.Node,
strict? : Bool = false,
hook? : RawTextHook? = None,
) -> Lowered raise @err.ShrubHtmlError {
let l = { diags: [], strict, hook, }
let groups = match root.it {
Multi(gs) => gs
// A bare group, which is what a fragment parses to.
Group(_) => [root]
_ => []
}
let children : Array[@ast.Node] = []
for g in groups {
for n in l.group(g, Html) {
children.push(n)
}
}
{ document: { children, span: hspan(root.span), }, diagnostics: l.diags, }
}
///|
/// 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 document: 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,
hook? : RawTextHook? = None,
) -> Lowered raise @err.ShrubHtmlError {
let parsed = @shrub.parse(src, recover=true) catch {
e => {
let d = shrub_diagnostic(e.diagnostic(), src)
if strict {
d.raise_()
}
return {
document: { children: [], span: @hspan.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~, hook~)
let all : Array[@err.Diagnostic] = []
for d in pre {
all.push(d)
}
for d in out.diagnostics {
all.push(d)
}
{ document: out.document, diagnostics: all, }
}
// --------------------------------------------------------------- reporting
///|
fn Lowerer::report(
self : Lowerer,
kind : @kind.ErrorKind,
n : @sast.Node,
) -> Unit raise @err.ShrubHtmlError {
let d = @err.Diagnostic::of_kind(kind, n.span)
if self.strict && d.is_error() {
d.raise_()
}
self.diags.push(d)
}
///|
/// A node that could not be read, kept with the source it covered.
///
/// The markup 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 source,
/// which is the useful half anyway -- and echoing it is what keeps the tree
/// covering the whole file.
fn Lowerer::bogus(
self : Lowerer,
kind : @kind.ErrorKind,
n : @sast.Node,
) -> @ast.Node raise @err.ShrubHtmlError {
self.report(kind, n)
Bogus(
@ast.Bogus::new(
@hkind.ErrorKind::Unexpected(kind.code()),
hspan(n.span),
text=n.to_source(),
),
)
}
///|
/// The same, for a call, whose source is two nodes rather than one.
fn Lowerer::bogus_call(
self : Lowerer,
kind : @kind.ErrorKind,
span : @basic.Span,
source : String,
) -> @ast.Node raise @err.ShrubHtmlError {
let d = @err.Diagnostic::of_kind(kind, span)
if self.strict && d.is_error() {
d.raise_()
}
self.diags.push(d)
Bogus(
@ast.Bogus::new(
@hkind.ErrorKind::Unexpected(kind.code()),
hspan(span),
text=source,
),
)
}
// ------------------------------------------------------------------ groups
///|
/// One group, as a run of children.
///
/// A group is a RUN, not a single node: `"a" br() "b"` is three children on one
/// line. The trailing block, if there is one, belongs to the LAST element in
/// the run -- which is what makes an element with children have to come last,
/// and what `TrailingRunAfterBlock` warns about when it does not.
fn Lowerer::group(
self : Lowerer,
g : @sast.Node,
ns : @ast.Namespace,
) -> Array[@ast.Node] raise @err.ShrubHtmlError {
let items = match g.it {
Group(xs) => xs
// A block's groups are groups; anything else here is a lone term, which
// the run reader handles on its own.
_ => [g]
}
let (head, block, has_alts) = split_block(items[:])
if has_alts {
return [self.bogus(AltsUnsupported, g)]
}
self.run(head, block, ns, g)
}
///|
/// A run of items, with an optional block for the last of them.
fn Lowerer::run(
self : Lowerer,
items : ArrayView[@sast.Node],
block : @sast.Node?,
ns : @ast.Namespace,
whole : @sast.Node,
) -> Array[@ast.Node] raise @err.ShrubHtmlError {
let out : Array[@ast.Node] = []
let mut i = 0
while i < items.length() {
let rest = items[i:]
match as_call(rest) {
Some((name, args, after)) => {
// The block belongs to the LAST call in the run, because shrubbery
// puts a block at the end of its group and nowhere else. That is the
// whole of the rule that an element with children has to come last.
let mine = if after.length() == 0 { block } else { None }
match mine {
Some(b) =>
if i > 0 &&
rest[0].span.start.line == b.span.start.line &&
block_run_length(b) >= 2 {
// The trap. `p(): "Hello " strong(): "world" "!"` parses cleanly
// and puts the `"!"` INSIDE the `strong`, because a block runs to
// the end of its line. Nothing in the tree distinguishes that from
// someone meaning it, so this is a warning rather than an error --
// and it is reported at all only because the shape is so specific:
// a same-line block, holding one group, holding a run of more than
// one child, on a call that already had a sibling before it.
self.report(TrailingRunAfterBlock, whole)
}
None => ()
}
out.push(self.call(name, args, mine, ns, rest[0], rest[1]))
i = i + 2
continue
}
None => ()
}
let n = items[i]
match n.it {
Lit(Str(s)) =>
out.push(Text({ text: s, raw: None, span: hspan(n.span), }))
Lit(Int_(v)) =>
out.push(Text({ text: v.to_string(), raw: None, span: hspan(n.span), }))
Lit(Flo(v)) =>
out.push(Text({ text: v.to_string(), raw: None, span: hspan(n.span), }))
Id(name) => out.push(self.bogus(BareTermInBlock(name), n))
Op(o) => out.push(self.bogus(SigilTag(o), n))
Braces(_) => out.push(self.bogus(BracesUnsupported, n))
Quotes(_) => out.push(self.bogus(BracesUnsupported, n))
Block(_) => out.push(self.bogus(BlockOnNonElement, n))
Kw(k) => out.push(self.bogus(Unsupported("`~" + k + "`"), n))
_ => out.push(self.bogus(Unsupported("this"), n))
}
i = i + 1
}
// A block with nothing before it has nothing to be the children of.
if out.length() == 0 && block is Some(b) {
out.push(self.bogus(BlockOnNonElement, b))
}
out
}
// ------------------------------------------------------------------- calls
///|
/// A `name(...)` call, with the block that follows it if it has one.
fn Lowerer::call(
self : Lowerer,
raw_name : String,
args : ArrayView[@sast.Node],
block : @sast.Node?,
ns : @ast.Namespace,
at : @sast.Node,
parens : @sast.Node,
) -> @ast.Node raise @err.ShrubHtmlError {
// A failed escape keeps the whole call, not just its name. Echoing `element`
// where the source said `element("text", x: "10")` would lose the arguments
// and leave a bare word in the output.
let whole = at.span.merge(parens.span)
let source = at.to_source() + parens.to_source()
match raw_name {
"text" =>
match literal_string(args) {
Some(s) => return Text({ text: s, raw: None, span: hspan(at.span), })
None => return self.bogus_call(BadEscapeCall("text"), whole, source)
}
"raw" =>
match literal_string(args) {
Some(s) => return RawText({ text: s, span: hspan(at.span), })
None => return self.bogus_call(BadEscapeCall("raw"), whole, source)
}
"cdata" =>
match literal_string(args) {
Some(s) => return Cdata(s, hspan(at.span))
None => return self.bogus_call(BadEscapeCall("cdata"), whole, source)
}
"comment" =>
match literal_string(args) {
Some(s) => return Comment({ text: s, span: hspan(at.span), })
None => return self.bogus_call(BadEscapeCall("comment"), whole, source)
}
"doctype" => return self.doctype(args, at, whole, source)
// `element("text")`, and `element("text", x: "10")`: the escape takes the
// name and then everything an ordinary call would take, because an element
// that needs the escape still has attributes.
"element" =>
if args.length() >= 1 {
match group_string(args[0]) {
Some(s) => return self.element(s, args[1:], block, ns, at)
None =>
return self.bogus_call(BadEscapeCall("element"), whole, source)
}
} else {
return self.bogus_call(BadEscapeCall("element"), whole, source)
}
"attr" | "frag" | "bogus" =>
return self.bogus_call(ReservedCall(raw_name), whole, source)
_ => ()
}
self.element(@names.unkebab(raw_name), args, block, ns, at)
}
///|
/// `doctype()`, and the legacy form with its identifiers.
fn Lowerer::doctype(
self : Lowerer,
args : ArrayView[@sast.Node],
at : @sast.Node,
whole : @basic.Span,
source : String,
) -> @ast.Node raise @err.ShrubHtmlError {
if args.length() == 0 {
return Doctype({
name: "html",
public_id: None,
system_id: None,
span: hspan(at.span),
})
}
match literal_string(args) {
Some(name) =>
Doctype({ name, public_id: None, system_id: None, span: hspan(at.span), })
None =>
match literal_pair(args) {
Some((public_id, system_id)) =>
Doctype({
name: "html",
public_id: Some(public_id),
system_id: Some(system_id),
span: hspan(at.span),
})
None => self.bogus_call(BadEscapeCall("doctype"), whole, source)
}
}
}
///|
/// An element, its attributes, and its children.
fn Lowerer::element(
self : Lowerer,
name : String,
args : ArrayView[@sast.Node],
block : @sast.Node?,
parent_ns : @ast.Namespace,
at : @sast.Node,
) -> @ast.Node raise @err.ShrubHtmlError {
let ns = namespace_of(name, parent_ns)
let tag : @ast.TagName = { ns, name: spelled(name, ns), }
let attrs = self.attributes(args, ns)
let span = hspan(at.span)
let is_void = ns == Html && @hnames.is_void(tag.name)
match block {
None => {
let closing : @ast.Closing = if is_void {
Void
} else if ns == Html {
Explicit
} else {
// In foreign content an element with no children closes itself, which
// is what `` means and what makes it round-trip.
SelfClosing
}
Element({
name: tag,
attrs,
children: [],
closing,
span,
name_span: span,
})
}
Some(b) => {
if is_void {
self.report(ChildrenOnVoid(tag.name), b)
return Element({
name: tag,
attrs,
children: [],
closing: Void,
span,
name_span: span,
})
}
let children = self.children(tag, b, child_namespace(tag))
Element({
name: tag,
attrs,
children,
closing: Explicit,
span,
name_span: span,
})
}
}
}
///|
/// The contents of an element's block.
///
/// Raw text is decided here, by the element's name, exactly as the markup
/// tokenizer decides it: inside a `script` or a `style` the body is text and
/// not markup, so an `@{...}` becomes one `RawText` node rather than a run of
/// children.
fn Lowerer::children(
self : Lowerer,
tag : @ast.TagName,
block : @sast.Node,
ns : @ast.Namespace,
) -> Array[@ast.Node] raise @err.ShrubHtmlError {
let groups = block_groups(block)
let raw = tag.ns == Html && @hnames.is_raw_text(tag.name)
if raw {
match self.hook {
Some(f) =>
match f(tag.name, groups) {
Some(text) => return [RawText({ text, span: hspan(block.span), })]
None => ()
}
None => ()
}
}
let out : Array[@ast.Node] = []
for g in groups {
// `@{...}` is one text block, whatever else the group holds.
match at_text_of(g) {
Some(text) => {
if raw {
out.push(RawText({ text, span: hspan(g.span), }))
} else {
out.push(Text({ text, raw: None, span: hspan(g.span), }))
}
continue
}
None => ()
}
for n in self.group(g, ns) {
if raw {
// Everything inside a raw-text element is text, so a nested call is
// not an element -- it is a name someone wrote inside a script.
match n {
Text(t) => out.push(RawText({ text: t.text, span: t.span, }))
other => out.push(other)
}
} else {
out.push(n)
}
}
}
out
}
///|
/// How many children the single group in a block would produce.
///
/// A shrubbery-level count rather than a lowered one, because the answer is
/// needed before the block is lowered: a call is two nodes and everything else
/// is one. Zero for a block that is not exactly one group, which is the shape
/// this question is about.
fn block_run_length(b : @sast.Node) -> Int {
let gs = block_groups(b)
if gs.length() != 1 {
return 0
}
let items = match gs[0].it {
Group(xs) => xs
_ => return 0
}
let (head, _, _) = split_block(items[:])
let mut count = 0
let mut i = 0
while i < head.length() {
if as_call(head[i:]) is Some(_) {
i = i + 2
} else {
i = i + 1
}
count = count + 1
}
count
}
///|
/// A group that is nothing but an `@{...}` body.
fn at_text_of(g : @sast.Node) -> String? {
let items = match g.it {
Group(xs) => xs
_ => return None
}
if items.length() != 1 {
return None
}
as_at_text(items[0])
}
// -------------------------------------------------------------- attributes
///|
/// A call's arguments, as attributes.
///
/// An attribute is a declaration -- `class: "card"` -- which is the same
/// shrubbery shape a CSS declaration already uses, and a bare name is a
/// valueless attribute, which is the same thing HTML writes.
fn Lowerer::attributes(
self : Lowerer,
args : ArrayView[@sast.Node],
ns : @ast.Namespace,
) -> Array[@ast.Attribute] raise @err.ShrubHtmlError {
let out : Array[@ast.Attribute] = []
for arg in args {
let items = match arg.it {
Group(xs) => xs
_ => {
self.report(AttrNotDeclaration, arg)
continue
}
}
let (head, block, _) = split_block(items[:])
// `attr("xlink:href", "#a")`, the escape for a name with no bare spelling.
match as_call(head) {
Some(("attr", inner, after)) =>
if after.length() == 0 && block is None {
match literal_pair(inner) {
Some((name, value)) => {
out.push({
name: split_prefix(name),
value: Value(value, None),
span: hspan(arg.span),
})
continue
}
None =>
match literal_string(inner) {
Some(name) => {
out.push({
name: split_prefix(name),
value: Empty,
span: hspan(arg.span),
})
continue
}
None => {
self.report(BadEscapeCall("attr"), arg)
continue
}
}
}
}
_ => ()
}
if head.length() != 1 {
self.report(AttrNotDeclaration, arg)
continue
}
let name = match head[0].it {
Id(n) => spelled_attr(@names.unkebab(n), ns)
_ => {
self.report(AttrNotDeclaration, arg)
continue
}
}
match block {
None =>
out.push({
name: split_prefix(name),
value: Empty,
span: hspan(arg.span),
})
Some(b) =>
match self.attr_value(b) {
Some(v) =>
out.push({
name: split_prefix(name),
value: Value(v, None),
span: hspan(arg.span),
})
None => {
self.report(BadAttrValue, b)
out.push({
name: split_prefix(name),
value: Value("", None),
span: hspan(arg.span),
})
}
}
}
}
out
}
///|
/// The text of an attribute's value.
///
/// A string literal is itself; a number is its digits; a bare name is its own
/// text, so that `type: submit` and `type: "submit"` agree. Anything else is a
/// diagnostic rather than a guess: an attribute value is a string in the source
/// and there is nothing for a compound expression to mean.
fn Lowerer::attr_value(self : Lowerer, block : @sast.Node) -> String? {
ignore(self)
let groups = block_groups(block)
if groups.length() != 1 {
return None
}
let items = match groups[0].it {
Group(xs) => xs
_ => return None
}
if items.length() != 1 {
return None
}
match items[0].it {
Lit(Str(s)) => Some(s)
Lit(Int_(v)) => Some(v.to_string())
Lit(Flo(v)) => Some(v.to_string())
Id(n) => Some(@names.unkebab(n))
_ => None
}
}
// -------------------------------------------------------------- namespaces
///|
/// Which vocabulary an element's name is drawn from.
///
/// The namespace follows the element, exactly as it does in HTML: `svg()` and
/// `math()` enter one, and an HTML integration point leaves it. So the notation
/// needs no keyword for it at all -- writing the element is writing the
/// namespace.
fn namespace_of(name : String, parent : @ast.Namespace) -> @ast.Namespace {
match parent {
Html =>
if name == "svg" {
Svg
} else if name == "math" {
MathMl
} else {
Html
}
// Inside foreign content every name is foreign, INCLUDING an integration
// point: `foreignObject` is an SVG element. What is HTML again is its
// CHILDREN, which is a question `child_namespace` answers.
other => other
}
}
///|
/// The namespace an element's children belong to.
///
/// The one place the integration points matter, and getting it wrong is silent:
/// a `
` inside a `` would stop being void and start closing
/// itself, and nothing but the printed output would say so.
fn child_namespace(tag : @ast.TagName) -> @ast.Namespace {
match tag.ns {
Html => Html
other =>
if @hnames.is_integration_point(tag.name.to_lower()) {
Html
} else {
other
}
}
}
///|
/// A name as its vocabulary spells it.
fn spelled(name : String, ns : @ast.Namespace) -> String {
match ns {
Svg =>
match @hnames.svg_tag(name.to_lower()) {
Some(cased) => cased
None => name
}
_ => name
}
}
///|
/// An attribute name as its vocabulary spells it.
fn spelled_attr(name : String, ns : @ast.Namespace) -> String {
match ns {
Html => name
_ =>
match @hnames.foreign_attr(name.to_lower()) {
Some(cased) => cased
None => name
}
}
}
///|
/// `xlink:href` as a prefix and a name.
fn split_prefix(name : String) -> @ast.AttrName {
let n = name.length()
let mut i = 0
while i < n {
if name.unsafe_get(i).to_int() == 58 {
if i > 0 && i + 1 < n {
return {
prefix: Some(name.clamped_view(start=0, end=i).to_owned()),
name: name.clamped_view(start=i + 1, end=n).to_owned(),
}
}
break
}
i = i + 1
}
{ prefix: None, name, }
}
// ------------------------------------------------------------------ spans
///|
fn hspan(s : @basic.Span) -> @hspan.Span {
@hspan.Span::new(s.start.idx, s.end.idx)
}
///|
/// A shrubbery diagnostic, carried across with markup-specific help attached.
///
/// An HTML author's first mistake is to write HTML, and shrubbery rejects `<`
/// before this layer sees a tree at all. So the enrichment happens here, on the
/// way past: the original span is 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)
}
///|
fn lexical_hint(text : String) -> @kind.ErrorKind {
if text.has_prefix("") {
SigilTag("")
} else if text.has_prefix("<") {
SigilTag("<")
} else if text.has_prefix("&") {
SigilTag("&")
} else {
Unsupported("`" + text + "`")
}
}
///|
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()
}