use super::syntax::{Kind, Piece};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Flavour {
Xml,
Html,
}
impl Flavour {
pub fn label(self) -> &'static str {
match self {
Flavour::Xml => "xml",
Flavour::Html => "html",
}
}
}
const VOID: &[&str] = &[
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
"track", "wbr",
];
const RAW_TEXT: &[&str] = &["script", "style"];
const INDENT: usize = 2;
const MAX_DEPTH: usize = 64;
pub fn reindent(value: &str) -> Option<(Vec<Vec<Piece>>, Flavour)> {
let mut parser = Parser {
source: value.as_bytes(),
at: 0,
html: false,
depth: 0,
};
let nodes = parser.nodes(None)?;
if parser.at != value.len() || !nodes.iter().any(|node| matches!(node, Node::Element(_))) {
return None;
}
let mut out = Vec::new();
render(&nodes, 0, &mut out);
Some((
out,
match parser.html {
true => Flavour::Html,
false => Flavour::Xml,
},
))
}
enum Node {
Element(Element),
Text(String),
Aside(String),
}
struct Element {
open: Vec<Piece>,
children: Vec<Node>,
close: Option<Vec<Piece>>,
}
fn breaks(children: &[Node]) -> bool {
children
.iter()
.any(|child| matches!(child, Node::Element(_)))
&& !has_prose(children)
}
fn has_prose(nodes: &[Node]) -> bool {
nodes
.iter()
.any(|node| matches!(node, Node::Text(text) if !text.trim().is_empty()))
}
fn render(nodes: &[Node], indent: usize, out: &mut Vec<Vec<Piece>>) {
if has_prose(nodes) {
let mut line = opening(indent);
for node in nodes {
inline(node, &mut line);
}
out.push(line);
return;
}
for node in nodes {
if matches!(node, Node::Text(text) if text.trim().is_empty()) {
continue;
}
block(node, indent, out);
}
}
fn block(node: &Node, indent: usize, out: &mut Vec<Vec<Piece>>) {
let mut line = opening(indent);
match node {
Node::Text(text) => line.push(Piece::new(Kind::Text, text.clone())),
Node::Aside(text) => line.push(Piece::new(Kind::Punct, text.clone())),
Node::Element(element) if breaks(&element.children) => {
line.extend(element.open.iter().cloned());
out.push(line);
render(&element.children, indent + 1, out);
if let Some(close) = &element.close {
let mut line = opening(indent);
line.extend(close.iter().cloned());
out.push(line);
}
return;
}
Node::Element(element) => {
line.extend(element.open.iter().cloned());
for child in &element.children {
inline(child, &mut line);
}
if let Some(close) = &element.close {
line.extend(close.iter().cloned());
}
}
}
out.push(line);
}
fn inline(node: &Node, line: &mut Vec<Piece>) {
match node {
Node::Text(text) => line.push(Piece::new(Kind::Text, text.clone())),
Node::Aside(text) => line.push(Piece::new(Kind::Punct, text.clone())),
Node::Element(element) => {
line.extend(element.open.iter().cloned());
for child in &element.children {
inline(child, line);
}
if let Some(close) = &element.close {
line.extend(close.iter().cloned());
}
}
}
}
fn opening(indent: usize) -> Vec<Piece> {
match indent {
0 => Vec::new(),
_ => vec![Piece::new(Kind::Punct, " ".repeat(indent * INDENT))],
}
}
struct Parser<'a> {
source: &'a [u8],
at: usize,
html: bool,
depth: usize,
}
impl Parser<'_> {
fn peek(&self) -> Option<u8> {
self.source.get(self.at).copied()
}
fn starts_with(&self, text: &str) -> bool {
self.source[self.at..].starts_with(text.as_bytes())
}
fn text(&self, span: std::ops::Range<usize>) -> String {
String::from_utf8_lossy(&self.source[span]).into_owned()
}
fn nodes(&mut self, until: Option<&str>) -> Option<Vec<Node>> {
let mut nodes = Vec::new();
loop {
if self.at >= self.source.len() {
return until.is_none().then_some(nodes);
}
if self.starts_with("</") {
return until.is_some().then_some(nodes);
}
if self.starts_with("<!--") {
nodes.push(self.until("-->")?);
} else if self.starts_with("<![CDATA[") {
nodes.push(self.until("]]>")?);
} else if self.starts_with("<!") {
if self.source[self.at..].len() > 9
&& self.source[self.at + 2..self.at + 9].eq_ignore_ascii_case(b"DOCTYPE")
{
self.html = true;
}
nodes.push(self.until(">")?);
} else if self.starts_with("<?") {
nodes.push(self.until("?>")?);
} else if self.peek() == Some(b'<') {
nodes.push(self.element()?);
} else {
nodes.push(self.prose()?);
}
}
}
fn until(&mut self, end: &str) -> Option<Node> {
let start = self.at;
let at = self.source[start..]
.windows(end.len())
.position(|window| window == end.as_bytes())?;
self.at = start + at + end.len();
Some(Node::Aside(self.text(start..self.at)))
}
fn prose(&mut self) -> Option<Node> {
let start = self.at;
while self.peek().is_some_and(|byte| byte != b'<') {
self.at += 1;
}
(self.at > start).then(|| Node::Text(self.text(start..self.at)))
}
fn element(&mut self) -> Option<Node> {
if self.depth >= MAX_DEPTH {
return None;
}
let mut open = vec![Piece::new(Kind::Punct, "<")];
self.at += 1;
let name = self.name()?;
open.push(Piece::new(Kind::Name, name.clone()));
self.attributes(&mut open)?;
if self.starts_with("/>") {
self.at += 2;
open.push(Piece::new(Kind::Punct, "/>"));
return Some(Node::Element(Element {
open,
children: Vec::new(),
close: None,
}));
}
if self.peek()? != b'>' {
return None;
}
self.at += 1;
open.push(Piece::new(Kind::Punct, ">"));
let lower = name.to_ascii_lowercase();
if VOID.contains(&lower.as_str()) {
self.html = true;
return Some(Node::Element(Element {
open,
children: Vec::new(),
close: None,
}));
}
let children = if RAW_TEXT.contains(&lower.as_str()) {
self.html = true;
let start = self.at;
let end = self.find_close(&name)?;
self.at = end;
match end > start {
true => vec![Node::Text(self.text(start..end))],
false => Vec::new(),
}
} else {
self.depth += 1;
let children = self.nodes(Some(&name))?;
self.depth -= 1;
children
};
if !self.starts_with("</") {
return None;
}
self.at += 2;
let closing = self.name()?;
if !closing.eq_ignore_ascii_case(&name) {
return None;
}
self.space();
if self.peek()? != b'>' {
return None;
}
self.at += 1;
let close = vec![
Piece::new(Kind::Punct, "</"),
Piece::new(Kind::Name, closing),
Piece::new(Kind::Punct, ">"),
];
Some(Node::Element(Element {
open,
children,
close: Some(close),
}))
}
fn find_close(&self, name: &str) -> Option<usize> {
let needle = format!("</{name}");
let bytes = needle.as_bytes();
self.source[self.at..]
.windows(bytes.len())
.position(|window| window.eq_ignore_ascii_case(bytes))
.map(|at| self.at + at)
}
fn name(&mut self) -> Option<String> {
let start = self.at;
if !self
.peek()
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
{
return None;
}
while self.peek().is_some_and(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':' | b'.')
}) {
self.at += 1;
}
Some(self.text(start..self.at))
}
fn space(&mut self) {
while self
.peek()
.is_some_and(|byte| matches!(byte, b' ' | b'\t' | b'\n' | b'\r'))
{
self.at += 1;
}
}
fn attributes(&mut self, open: &mut Vec<Piece>) -> Option<()> {
loop {
let before = self.at;
self.space();
if matches!(self.peek()?, b'>' | b'/') {
return Some(());
}
if self.at == before {
return None;
}
open.push(Piece::new(Kind::Punct, " "));
let name = self.name()?;
open.push(Piece::new(Kind::Attr, name));
let before = self.at;
self.space();
if self.peek()? != b'=' {
self.at = before;
self.html = true;
continue;
}
self.at += 1;
open.push(Piece::new(Kind::Punct, "="));
self.space();
let value = self.value()?;
open.push(Piece::new(Kind::Str, value));
}
}
fn value(&mut self) -> Option<String> {
let start = self.at;
match self.peek()? {
quote @ (b'"' | b'\'') => {
self.at += 1;
while self.peek()? != quote {
self.at += 1;
}
self.at += 1;
}
_ => {
self.html = true;
while self
.peek()
.is_some_and(|byte| !byte.is_ascii_whitespace() && !matches!(byte, b'>' | b'/'))
{
self.at += 1;
}
if self.at == start {
return None;
}
}
}
Some(self.text(start..self.at))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn shown(value: &str) -> Option<Vec<String>> {
let (document, _) = reindent(value)?;
Some(
document
.iter()
.map(|line| line.iter().map(|piece| piece.text.as_str()).collect())
.collect(),
)
}
fn flavour(value: &str) -> Option<Flavour> {
reindent(value).map(|(_, flavour)| flavour)
}
#[test]
fn elements_are_broken_out_one_to_a_line() {
assert_eq!(
shown("<a><b><c/></b></a>").unwrap(),
["<a>", " <b>", " <c/>", " </b>", "</a>"]
);
}
#[test]
fn an_element_holding_text_stays_on_one_line() {
assert_eq!(
shown("<div><p>Hi <b>there</b></p></div>").unwrap(),
["<div>", " <p>Hi <b>there</b></p>", "</div>"]
);
}
#[test]
fn attributes_are_kept_exactly_as_written() {
assert_eq!(
shown(r#"<a href='/x?a=1&b=2' data-n="3"><b/></a>"#).unwrap(),
[r#"<a href='/x?a=1&b=2' data-n="3">"#, " <b/>", "</a>"],
"quoting style and entities are the document's own"
);
}
#[test]
fn a_void_element_needs_no_close_and_says_this_is_html() {
assert_eq!(
shown("<div><br><img src=x></div>").unwrap(),
["<div>", " <br>", " <img src=x>", "</div>"]
);
assert_eq!(flavour("<div><br></div>"), Some(Flavour::Html));
assert_eq!(flavour("<div><br/></div>"), Some(Flavour::Xml));
}
#[test]
fn what_needed_htmls_rules_is_html_and_what_did_not_is_xml() {
assert_eq!(flavour("<note><to>x</to></note>"), Some(Flavour::Xml));
assert_eq!(flavour("<input disabled>"), Some(Flavour::Html));
assert_eq!(flavour("<a href=x>y</a>"), Some(Flavour::Html), "unquoted");
assert_eq!(flavour("<!DOCTYPE html><html></html>"), Some(Flavour::Html));
}
#[test]
fn a_less_than_sign_in_prose_is_not_a_tag() {
for value in [
"a < b and b > c",
"1 < 2",
"not markup at all",
"<p>unclosed",
"</p>",
"<p>a</div>",
"<a><b></a></b>",
"<p>x</p> trailing <",
"",
] {
assert!(reindent(value).is_none(), "claimed {value:?}");
}
}
#[test]
fn text_alone_is_not_a_document() {
assert!(reindent("<!-- just a comment -->").is_none());
assert!(reindent("plain words").is_none());
}
#[test]
fn a_script_holds_text_and_not_markup() {
assert_eq!(
shown("<div><script>if (a<b) {}</script></div>").unwrap(),
["<div>", " <script>if (a<b) {}</script>", "</div>"]
);
}
#[test]
fn comments_and_doctypes_are_kept_whole() {
assert_eq!(
shown("<a><!-- note --><b/></a>").unwrap(),
["<a>", " <!-- note -->", " <b/>", "</a>"]
);
}
#[test]
fn the_pieces_say_what_each_run_is() {
let (document, _) = reindent(r#"<a href="x">hi</a>"#).unwrap();
let kinds: Vec<Kind> = document[0].iter().map(|piece| piece.kind).collect();
assert_eq!(
kinds,
[
Kind::Punct, Kind::Name, Kind::Punct, Kind::Attr, Kind::Punct, Kind::Str, Kind::Punct, Kind::Text, Kind::Punct, Kind::Name, Kind::Punct, ]
);
}
#[test]
fn nesting_past_the_cap_is_refused_rather_than_recursed_into() {
let deep = "<a>".repeat(MAX_DEPTH + 2) + &"</a>".repeat(MAX_DEPTH + 2);
assert!(reindent(&deep).is_none());
}
#[test]
fn whitespace_between_elements_is_the_formatters_to_decide() {
assert_eq!(
shown("<a>\n <b/>\n</a>").unwrap(),
["<a>", " <b/>", "</a>"],
"an already-indented document comes back indented once"
);
}
}