///|
/// The scanner.
///
/// A hand-written cursor over a `String`, following the WHATWG tokenizer
/// closely enough that a reader can check one against the other, and departing
/// from it in exactly one direction: it produces markup tokens rather than
/// driving a tree builder, so it has no insertion modes and no way to be told
/// what namespace it is in.
///
/// Indices are UTF-16 code units throughout, which is what a MoonBit `String`
/// is indexed in and what `@span.Span` measures. Every character the scanner
/// makes a decision about is ASCII, so a surrogate pair simply falls into the
/// "text" bucket a unit at a time and comes out whole.
///|
/// The replacement character, which the specification substitutes for NUL.
const REPLACEMENT : Char = '\u{FFFD}'
///|
priv struct Scanner {
src : String
len : Int
mut pos : Int
/// The raw-text run the next call has to scan, if the tag just emitted opened
/// one. This is the specification's "appropriate end tag token" check, and it
/// is the only piece of state the tokenizer needs from the document it is
/// tokenizing.
mut pending : Start
problems : Array[(@kind.ErrorKind, @span.Span)]
}
///|
/// Tokenize a whole document, ending with exactly one `Eof`.
pub fn tokenize(src : String, start? : Start = Markup) -> Scanned {
let s = { src, len: src.length(), pos: 0, pending: start, problems: [], }
let out = []
while true {
let t = s.next()
let done = t.kind == Eof
out.push(t)
if done {
break
}
}
{ tokens: out, problems: s.problems, }
}
///|
/// Tokenize, keeping only the tokens.
pub fn tokens(src : String, start? : Start = Markup) -> Array[Token] {
tokenize(src, start~).tokens
}
// ------------------------------------------------------------------- cursor
///|
/// The character at `i`, or NUL past the end.
///
/// NUL is safe as a sentinel because a real NUL in the input is replaced with
/// U+FFFD as it is read, so one can never appear as data.
fn Scanner::at(self : Scanner, i : Int) -> Char {
if i >= self.len || i < 0 {
'\u{0}'
} else {
self.src.unsafe_get(i).to_int().unsafe_to_char()
}
}
///|
fn Scanner::peek(self : Scanner) -> Char {
self.at(self.pos)
}
///|
fn Scanner::slice(self : Scanner, start : Int, end : Int) -> String {
self.src.clamped_view(start~, end~).to_owned()
}
///|
fn Scanner::tok(self : Scanner, start : Int, kind : TokenKind) -> Token {
{ kind, span: @span.Span::new(start, self.pos), }
}
///|
fn Scanner::note(
self : Scanner,
kind : @kind.ErrorKind,
span : @span.Span,
) -> Unit {
self.problems.push((kind, span))
}
///|
/// Whether the input at `i` matches `what`, ASCII-case-insensitively.
fn Scanner::matches_ci(self : Scanner, i : Int, what : String) -> Bool {
let n = what.length()
if i + n > self.len {
return false
}
let mut k = 0
while k < n {
if lower(self.at(i + k)) !=
lower(what.unsafe_get(k).to_int().unsafe_to_char()) {
return false
}
k = k + 1
}
true
}
// --------------------------------------------------------------- predicates
///|
fn lower(c : Char) -> Char {
if c >= 'A' && c <= 'Z' {
(c.to_int() + 32).unsafe_to_char()
} else {
c
}
}
///|
/// The specification's input preprocessing, applied per run rather than to the
/// whole source: a CRLF pair and a lone CR both become a single LF.
///
/// Per run because doing it to the input up front would shift every offset
/// after every CRLF, and a diagnostic that points one character to the left of
/// the problem is worse than no diagnostic. The DECODED text is normalised and
/// the source spelling is kept in `raw`, which is the same bargain a character
/// reference already makes.
fn normalize_newlines(s : String) -> String {
if !s.contains("\r") {
return s
}
let buf = StringBuilder()
let n = s.length()
let mut i = 0
while i < n {
let c = s.unsafe_get(i).to_int().unsafe_to_char()
if c == '\r' {
buf.write_char('\n')
i = i + 1
if i < n && s.unsafe_get(i).to_int() == 10 {
i = i + 1
}
continue
}
buf.write_char(c)
i = i + 1
}
buf.to_string()
}
///|
fn is_ws(c : Char) -> Bool {
c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\u{0C}'
}
///|
fn is_alpha(c : Char) -> Bool {
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}
///|
fn is_digit(c : Char) -> Bool {
c >= '0' && c <= '9'
}
///|
fn is_hex(c : Char) -> Bool {
is_digit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
}
///|
/// A character that may appear in a tag name.
///
/// Almost anything: the specification's tag-name state sends `"`, `'`, `<` and
/// `=` to its "anything else" branch, which appends them. So `` is one tag
/// whose name is `a Bool {
!is_ws(c) && c != '/' && c != '>'
}
///|
/// A character that may appear in an attribute name.
///
/// The same set less `=`, which is the one character that ends a name rather
/// than joining it. `"`, `'` and `<` are a parse error inside a name and are
/// still part of it -- `` has one attribute called `a Bool {
is_tag_name_char(c) && c != '='
}
// --------------------------------------------------------------- the stream
///|
fn Scanner::next(self : Scanner) -> Token {
if self.pos >= self.len {
return self.tok(self.pos, Eof)
}
// A raw-text or escapable-raw-text body runs to its own end tag and is
// scanned as one piece. The check is here rather than in the tag branch
// because the body follows the tag it belongs to, and `last_start` is what
// remembers which tag that was.
match self.pending {
Markup => ()
Raw(tag) => {
self.pending = Markup
return self.raw_text_body(tag)
}
Escapable(tag) => {
self.pending = Markup
return self.escapable_body(tag)
}
}
if self.peek() == '<' {
return self.markup()
}
self.text()
}
///|
/// Everything up to the next `<`, with character references resolved.
fn Scanner::text(self : Scanner) -> Token {
let start = self.pos
let buf = StringBuilder()
let mut decoded = false
let mut normalised = false
while self.pos < self.len && self.peek() != '<' {
let c = self.peek()
if c == '&' {
match self.reference(true) {
Some(text) => {
buf.write_string(text)
decoded = true
continue
}
None => ()
}
}
if c == '\u{0}' {
// The data state EMITS a NUL, it does not replace it. Every other state
// replaces it with U+FFFD, which is the sort of asymmetry only a
// conformance suite finds.
self.note(
UnexpectedNullCharacter,
@span.Span::new(self.pos, self.pos + 1),
)
}
if self.newline_at(buf) {
normalised = true
continue
}
buf.write_char(c)
self.pos = self.pos + 1
}
let raw = if decoded || normalised {
Some(self.slice(start, self.pos))
} else {
None
}
self.tok(start, Text(buf.to_string(), raw))
}
///|
/// A CR at the cursor, written as the LF the specification preprocesses it to.
///
/// Inline in the scan rather than over the finished buffer, because a resolved
/// character reference must NOT be normalised: preprocessing happens to the
/// input stream and `
` is resolved after it, so `
` really is a
/// carriage return and a literal one really is not.
fn Scanner::newline_at(self : Scanner, buf : StringBuilder) -> Bool {
if self.peek() != '\r' {
return false
}
buf.write_char('\n')
self.pos = self.pos + 1
if self.pos < self.len && self.peek() == '\n' {
self.pos = self.pos + 1
}
true
}
///|
/// A `<`, and whatever follows it.
fn Scanner::markup(self : Scanner) -> Token {
let start = self.pos
let c1 = self.at(self.pos + 1)
if c1 == '!' {
if self.matches_ci(self.pos + 2, "--") {
return self.comment()
}
if self.matches_ci(self.pos + 2, "doctype") {
return self.doctype()
}
if self.matches_ci(self.pos + 2, "[CDATA[") {
return self.cdata()
}
return self.bogus_comment(2)
}
if c1 == '/' {
if is_alpha(self.at(self.pos + 2)) {
return self.end_tag()
}
if self.pos + 2 >= self.len {
// `` at end of input: the specification emits both characters.
self.pos = self.len
return self.tok(start, Text("", None))
}
if self.at(self.pos + 2) == '>' {
// `>` is dropped entirely, per the specification.
self.pos = self.pos + 3
self.note(MissingTagName, @span.Span::new(start, self.pos))
return self.tok(start, Text("", None))
}
self.note(InvalidFirstCharacterOfTagName, @span.Span::new(start, start + 3))
return self.bogus_comment(2)
}
if is_alpha(c1) {
return self.start_tag()
}
if c1 == '?' {
// The `?` is part of the comment's data, per the specification, so that
// `` comes back as it went in.
return self.bogus_comment(1)
}
// A `<` that begins nothing: literal text, per the specification.
self.pos = self.pos + 1
self.tok(start, Text("<", None))
}
// ------------------------------------------------------------------- tags
///|
fn Scanner::start_tag(self : Scanner) -> Token {
let start = self.pos
self.pos = self.pos + 1
let name_start = self.pos
while self.pos < self.len && is_tag_name_char(self.peek()) {
self.pos = self.pos + 1
}
// NUL is a name character too, replaced with U+FFFD: `` is a tag
// called `a\u{FFFD}`, not a tag called `a`.
let name = self.nul_free(name_start, self.pos).to_lower()
let attrs : Array[RawAttr] = []
let mut self_closing = false
while true {
while self.pos < self.len && is_ws(self.peek()) {
self.pos = self.pos + 1
}
if self.pos >= self.len {
// A tag that never closed is not a tag. The specification discards it,
// and so does this -- as a `Bogus` carrying the source it covered, so
// that the printer echoes exactly what was there instead of inventing a
// `>` the input did not have.
self.note(EofInTag, @span.Span::new(start, self.pos))
return self.tok(start, Bogus(EofInTag, self.slice(start, self.pos)))
}
let c = self.peek()
if c == '>' {
self.pos = self.pos + 1
break
}
if c == '/' {
if self.at(self.pos + 1) == '>' {
self_closing = true
self.pos = self.pos + 2
break
}
self.note(UnexpectedSolidusInTag, @span.Span::new(self.pos, self.pos + 1))
self.pos = self.pos + 1
continue
}
if c == '=' {
// `` is an attribute NAMED `=x`, per the specification: the `=`
// is taken into the name rather than skipped. Skipping it silently
// renames the attribute, which is the kind of difference only a
// conforming parser notices.
self.note(
UnexpectedEqualsSignBeforeAttributeName,
@span.Span::new(self.pos, self.pos + 1),
)
match self.attribute(lead_equals=true) {
Some(a) => attrs.push(a)
None => self.pos = self.pos + 1
}
continue
}
match self.attribute() {
Some(a) => {
let mut seen = false
for existing in attrs {
if existing.name == a.name {
seen = true
}
}
if seen {
self.note(DuplicateAttribute(a.name), a.span)
} else {
attrs.push(a)
}
}
// Nothing consumed: step over the character so recovery makes progress.
None => self.pos = self.pos + 1
}
}
if !self_closing {
if @names.is_raw_text(name) {
self.pending = Raw(name)
} else if @names.is_escapable_raw_text(name) {
self.pending = Escapable(name)
}
}
self.tok(start, StartTag(name, attrs, self_closing))
}
///|
fn Scanner::end_tag(self : Scanner) -> Token {
let start = self.pos
self.pos = self.pos + 2
let name_start = self.pos
while self.pos < self.len && is_tag_name_char(self.peek()) {
self.pos = self.pos + 1
}
let name = self.nul_free(name_start, self.pos).to_lower()
// Attributes on an end tag are a parse error and are dropped, which is what
// the specification says and what makes `` harmless.
while self.pos < self.len && self.peek() != '>' {
self.pos = self.pos + 1
}
if self.pos < self.len {
self.pos = self.pos + 1
} else {
self.note(EofInTag, @span.Span::new(start, self.pos))
return self.tok(start, Bogus(EofInTag, self.slice(start, self.pos)))
}
self.tok(start, EndTag(name))
}
///|
fn Scanner::attribute(self : Scanner, lead_equals? : Bool = false) -> RawAttr? {
let start = self.pos
if lead_equals {
self.pos = self.pos + 1
}
while self.pos < self.len && is_attr_name_char(self.peek()) {
self.pos = self.pos + 1
}
if self.pos == start {
return None
}
let name = self.nul_free(start, self.pos).to_lower()
while self.pos < self.len && is_ws(self.peek()) {
self.pos = self.pos + 1
}
if self.peek() != '=' {
return Some({
name,
value: None,
raw: None,
span: @span.Span::new(start, self.pos),
})
}
self.pos = self.pos + 1
while self.pos < self.len && is_ws(self.peek()) {
self.pos = self.pos + 1
}
let (value, raw) = self.attribute_value()
Some({
name,
value: Some(value),
raw,
span: @span.Span::new(start, self.pos),
})
}
///|
fn Scanner::attribute_value(self : Scanner) -> (String, String?) {
let quote = self.peek()
let quoted = quote == '"' || quote == '\''
if quoted {
self.pos = self.pos + 1
}
let start = self.pos
let buf = StringBuilder()
let mut decoded = false
let mut normalised = false
while self.pos < self.len {
let c = self.peek()
if quoted && c == quote {
break
}
if !quoted && (is_ws(c) || c == '>') {
break
}
if !quoted && (c == '"' || c == '\'' || c == '<' || c == '=' || c == '`') {
self.note(
UnexpectedCharacterInUnquotedAttributeValue,
@span.Span::new(self.pos, self.pos + 1),
)
}
if c == '&' {
match self.reference(false) {
Some(text) => {
buf.write_string(text)
decoded = true
continue
}
None => ()
}
}
if c == '\u{0}' {
self.note(
UnexpectedNullCharacter,
@span.Span::new(self.pos, self.pos + 1),
)
buf.write_char(REPLACEMENT)
decoded = true
self.pos = self.pos + 1
continue
}
if self.newline_at(buf) {
normalised = true
continue
}
buf.write_char(c)
self.pos = self.pos + 1
}
let raw = if decoded || normalised {
Some(self.slice(start, self.pos))
} else {
None
}
if quoted && self.pos < self.len {
self.pos = self.pos + 1
}
(buf.to_string(), raw)
}
// -------------------------------------------------------------- raw bodies
///|
/// How deep inside a comment a ` -->`. Inside the
/// inner `` does not end the element. A scanner
/// that stopped at it cuts the script in half, which no browser does -- and a
/// formatter that then rewrote the file would be handing back something that
/// does not run.
priv enum ScriptMode {
/// Ordinary script data: the next `` ends the element.
Plain
/// After a `") {
mode = Plain
self.pos = self.pos + 3
continue
}
if mode == Escaped && self.opens_nested_script(self.pos) {
mode = Double
self.pos = self.pos + 7
continue
}
if mode == Double && self.is_end_tag_at(self.pos, "script") {
mode = Escaped
self.pos = self.pos + 8
continue
}
self.pos = self.pos + 1
}
}
///|
/// `