use dprint_core::formatting::PrintItems;
use dprint_core::formatting::Signal;
use lax_core::FlowClass;
use lax_core::FlowPrinter;
use lax_core::contains_directive;
use lax_core::push_comment;
use lax_core::push_text;
use std::cell::RefCell;
use super::parser::Node;
use super::tokenizer::is_raw_element;
use crate::configuration::Configuration;
use crate::format_text::ExternalFormatter;
const INLINE_ELEMENTS: &[&str] = &[
"a", "abbr", "b", "bdi", "bdo", "br", "button", "cite", "code", "data", "dfn", "em", "i", "img", "input", "kbd",
"label", "mark", "meter", "noscript", "object", "output", "progress", "q", "ruby", "s", "samp", "select", "slot",
"small", "span", "strong", "sub", "sup", "time", "u", "var", "wbr",
];
fn is_inline(name: &str) -> bool {
INLINE_ELEMENTS.iter().any(|e| e.eq_ignore_ascii_case(name))
}
struct Context<'a> {
source: &'a str,
ignore_directive: &'a str,
line_width: u32,
external: Option<&'a ExternalFormatter<'a>>,
external_error: &'a RefCell<Option<anyhow::Error>>,
}
pub fn generate(
nodes: &[Node],
source: &str,
config: &Configuration,
external: Option<&ExternalFormatter>,
external_error: &RefCell<Option<anyhow::Error>>,
) -> PrintItems {
let mut items = PrintItems::new();
let ctx = Context {
source,
ignore_directive: &config.ignore_node_comment_text,
line_width: config.line_width,
external,
external_error,
};
if can_restructure(nodes, false, true) {
gen_structural_children(nodes, &mut items, &ctx);
items.push_signal(Signal::NewLine);
} else {
push_content(&mut items, source.trim_end(), &ctx);
items.push_signal(Signal::NewLine);
}
items
}
fn is_block_node(node: &Node) -> bool {
match node {
Node::Element { name, .. } => !is_inline(name),
Node::Comment { .. } | Node::Verbatim { .. } => true,
_ => false,
}
}
fn can_restructure(children: &[Node], parent_inline: bool, root: bool) -> bool {
if children
.iter()
.any(|c| matches!(c, Node::Text { .. } | Node::RawText { .. }))
{
return false;
}
let mut prev_side_block = !parent_inline;
let mut gap_has_newline = root;
for child in children {
if let Node::Whitespace { newlines, .. } = child {
if *newlines > 0 {
gap_has_newline = true;
}
continue;
}
let gap_safe = gap_has_newline || (prev_side_block && is_block_node(child));
if !gap_safe {
return false;
}
gap_has_newline = false;
prev_side_block = is_block_node(child);
}
root || gap_has_newline || (prev_side_block && !parent_inline)
}
fn gen_structural_children(nodes: &[Node], items: &mut PrintItems, ctx: &Context) {
let mut first = true;
let mut pending_blank = false;
let mut ignore_next = false;
for node in nodes {
if let Node::Whitespace { newlines, .. } = node {
if *newlines >= 2 {
pending_blank = true;
}
continue;
}
if !first {
items.push_signal(Signal::NewLine);
}
if pending_blank && !first {
items.push_signal(Signal::NewLine);
}
pending_blank = false;
first = false;
let is_comment = matches!(node, Node::Comment { .. });
if ignore_next && !is_comment {
let (start, end) = node.span();
push_text(items, ctx.source[start..end].trim_end());
ignore_next = false;
continue;
}
if let Node::Comment { text, .. } = node
&& contains_directive(text, ctx.ignore_directive)
{
ignore_next = true;
}
gen_node(node, items, ctx);
}
}
fn gen_node(node: &Node, items: &mut PrintItems, ctx: &Context) {
match node {
Node::Comment { text, .. } => push_comment(items, ctx.source, text),
Node::Text { span } => {
push_content(items, &ctx.source[span.0..span.1], ctx);
}
Node::Verbatim { span } | Node::RawText { span } => {
push_text(items, &ctx.source[span.0..span.1]);
}
Node::Whitespace { .. } => {}
Node::Element {
name,
attrs,
self_closing,
complete,
newlines_before_close,
children,
closed,
span,
} => {
gen_open_tag(
name,
attrs,
*self_closing,
*complete,
*newlines_before_close,
items,
ctx,
);
if !*complete
|| *self_closing
|| super::parser::VOID_ELEMENTS
.iter()
.any(|v| v.eq_ignore_ascii_case(name))
{
return;
}
let parent_inline = is_inline(name);
if is_raw_element(name) {
if let Some(formatted) = format_embedded(name, attrs, children, ctx) {
if formatted.trim().is_empty() {
if *closed {
items.push_string(format!("</{}>", name));
}
return;
}
for line in formatted.trim_end().split('\n') {
items.push_signal(Signal::NewLine);
lax_core::push_text_line(items, line.trim_end_matches('\r'));
}
if *closed {
items.push_signal(Signal::NewLine);
items.push_string(format!("</{}>", name));
}
return;
}
if let Some(first) = children.first() {
let start = first.span().0;
let end = if *closed {
span.1
} else {
children.last().unwrap().span().1
};
push_text(items, &ctx.source[start..end]);
} else if *closed {
items.push_string(format!("</{}>", name));
}
return;
}
if children.iter().all(|c| matches!(c, Node::Whitespace { .. })) {
if parent_inline && !children.is_empty() {
let start = children.first().unwrap().span().0;
let end = children.last().unwrap().span().1;
push_text(items, &ctx.source[start..end]);
}
} else if can_restructure(children, parent_inline, false) {
items.push_signal(Signal::StartIndent);
items.push_signal(Signal::NewLine);
gen_structural_children(children, items, ctx);
items.push_signal(Signal::FinishIndent);
if *closed {
items.push_signal(Signal::NewLine);
}
} else {
let start = children.first().map(|c| c.span().0).unwrap();
let end = children.last().map(|c| c.span().1).unwrap();
push_content(items, &ctx.source[start..end], ctx);
}
if *closed {
items.push_string(format!("</{}>", name));
}
}
}
}
fn gen_open_tag(
name: &str,
attrs: &[super::tokenizer::Attr],
self_closing: bool,
complete: bool,
newlines_before_close: u32,
items: &mut PrintItems,
ctx: &Context,
) {
items.push_string(format!("<{}", name));
if !attrs.is_empty() {
let mut flow = FlowPrinter::new(items, false);
for attr in attrs {
flow.token(
items,
FlowClass::Whitespace {
newlines: attr.newlines_before,
},
|_| {},
);
let text = attr.text;
flow.token(items, FlowClass::Other, |items| push_text(items, text));
}
flow.finish(items);
}
let _ = ctx;
if !complete {
return;
}
if newlines_before_close > 0 {
items.push_signal(Signal::NewLine);
if self_closing {
items.push_string("/>".to_string());
} else {
items.push_string(">".to_string());
}
} else if self_closing {
items.push_string(" />".to_string());
} else {
items.push_string(">".to_string());
}
}
fn push_content(items: &mut PrintItems, text: &str, ctx: &Context) {
match format_interpolations(text, ctx) {
Some(formatted) => push_text(items, &formatted),
None => push_text(items, text),
}
}
fn format_interpolations(text: &str, ctx: &Context) -> Option<String> {
let external = ctx.external?;
if !text.contains("{{") {
return None;
}
let b = text.as_bytes();
let mut out = String::new();
let mut last = 0;
let mut i = 0;
let mut changed = false;
while i + 1 < b.len() {
let is_open = b[i] == b'{' && b[i + 1] == b'{' && b.get(i + 2) != Some(&b'{') && (i == 0 || b[i - 1] != b'{');
if is_open
&& let Some(close) = find_double_close(b, i + 2)
&& let Some(expr) = format_interpolation_expr(external, &text[i + 2..close], ctx)
{
let replacement = format!("{{{{ {} }}}}", expr);
if replacement != text[i..close + 2] {
changed = true;
}
out.push_str(&text[last..i]);
out.push_str(&replacement);
last = close + 2;
i = close + 2;
continue;
}
i += 1;
}
if !changed {
return None;
}
out.push_str(&text[last..]);
Some(out)
}
fn find_double_close(b: &[u8], from: usize) -> Option<usize> {
let mut j = from;
while j + 1 < b.len() {
if b[j] == b'}' && b[j + 1] == b'}' {
return Some(j);
}
j += 1;
}
None
}
fn format_interpolation_expr(external: &ExternalFormatter, inner: &str, ctx: &Context) -> Option<String> {
let trimmed = inner.trim();
if trimmed.is_empty() {
return None;
}
if is_mustache_sigil(trimmed.as_bytes()[0]) {
return None;
}
let formatted = match external("ts", trimmed, ctx.line_width) {
Ok(Some(formatted)) => formatted,
_ => return None,
};
let formatted = formatted.trim().trim_end_matches(';').trim_end();
if formatted.is_empty() || formatted.contains('\n') {
return None;
}
Some(formatted.to_string())
}
fn is_mustache_sigil(c: u8) -> bool {
matches!(c, b'#' | b'/' | b'^' | b'>' | b'&' | b'!' | b'=' | b'<' | b'$' | b'.')
}
fn format_embedded(name: &str, attrs: &[super::tokenizer::Attr], children: &[Node], ctx: &Context) -> Option<String> {
let external = ctx.external?;
let kind = if name.eq_ignore_ascii_case("style") {
"css"
} else if name.eq_ignore_ascii_case("script") {
"js"
} else {
return None;
};
let lang = attr_value(attrs, "lang")
.or_else(|| attr_value(attrs, "type"))
.unwrap_or(kind);
let (start, end) = match (children.first(), children.last()) {
(Some(first), Some(last)) => (first.span().0, last.span().1),
_ => return None,
};
let content = dedent(&ctx.source[start..end]);
match external(lang, &content, ctx.line_width) {
Ok(result) => result,
Err(error) => {
ctx.external_error.borrow_mut().get_or_insert(error);
None
}
}
}
fn attr_value<'a>(attrs: &[super::tokenizer::Attr<'a>], name: &str) -> Option<&'a str> {
for attr in attrs {
let text = attr.text;
let Some(eq) = text.find('=') else { continue };
if !text[..eq].trim().eq_ignore_ascii_case(name) {
continue;
}
let value = text[eq + 1..].trim();
let value = value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
.unwrap_or(value);
return Some(value);
}
None
}
fn dedent(text: &str) -> String {
let mut common: Option<&str> = None;
for line in text.split('\n') {
if line.trim().is_empty() {
continue;
}
let leading = &line[..line.len() - line.trim_start().len()];
common = Some(match common {
None => leading,
Some(prev) => {
let len = prev
.as_bytes()
.iter()
.zip(leading.as_bytes())
.take_while(|(a, b)| a == b)
.count();
&prev[..len]
}
});
}
let common = common.unwrap_or("");
if common.is_empty() {
return text.to_string();
}
text
.split('\n')
.map(|line| line.strip_prefix(common).unwrap_or(line))
.collect::<Vec<_>>()
.join("\n")
}