use std::borrow::Cow;
use super::Printer;
use super::boundary_ws::closer_pos;
use super::selectors::first_anchor;
use super::value_normalization;
use super::value_normalization::ValueReader;
use crate::ast;
use crate::whitespace::Edge;
use tsv_lang::Span;
use tsv_lang::doc::{DocBuf, DocContext, arena::DocId};
use tsv_lang::printing::visual_width;
use tsv_lang::{PRINT_WIDTH, TAB_WIDTH};
fn is_media_connector(atom: &str) -> bool {
atom.eq_ignore_ascii_case("and") || atom.eq_ignore_ascii_case("or")
}
struct MediaQueryList<'a> {
text: &'a str,
entries: Vec<&'a str>,
closing_comma: bool,
}
fn media_query_list(content: &str) -> MediaQueryList<'_> {
let parts = value_normalization::split_args_by_comma(content);
let ends_with_comma = value_normalization::has_closing_comma(content, &parts);
let entries: Vec<&str> = parts.into_iter().map(crate::escapes::trim_css).collect();
let closing_comma = ends_with_comma && entries.last().is_some_and(|entry| entry.is_empty());
let text = if ends_with_comma && !closing_comma {
crate::escapes::trim_end_preserving_escape(&content[..content.len() - 1])
} else {
content
};
MediaQueryList {
text,
entries,
closing_comma,
}
}
fn first_line_width(text: &str) -> usize {
visual_width(text.split('\n').next().unwrap_or(text), TAB_WIDTH)
}
fn prelude_region_end(atrule: &ast::CssAtrule<'_>) -> u32 {
atrule
.block
.as_ref()
.map_or_else(|| closer_pos(atrule.span), |block| block.span.start)
}
enum GapSide {
Claimed(String),
Comments(String),
}
#[derive(Clone, Copy)]
enum ConditionKind<'a> {
Supports,
Container { name: Option<&'a str> },
}
impl<'a> ConditionKind<'a> {
fn normalizes(self) -> bool {
matches!(self, ConditionKind::Supports)
}
fn name(self) -> Option<&'a str> {
match self {
ConditionKind::Container { name } => name,
ConditionKind::Supports => None,
}
}
}
fn prelude_fill_context(reserve: usize) -> DocContext {
DocContext::reserving(reserve).with_glued_lead(true)
}
impl<'a> Printer<'a> {
pub(super) fn print_css_atrule(&mut self, atrule: &ast::CssAtrule<'_>) {
self.write_head_boundary_ws(atrule.span.start);
self.write("@");
let name_src = atrule.name_span.extract(self.source);
self.write(&value_normalization::lowercase_at_rule_name(name_src));
match &atrule.prelude {
ast::PreludeValue::Values { values, span } if !values.is_empty() => {
self.write(" ");
let is_import = atrule.name.eq_ignore_ascii_case("import");
let mut prev_end = span.start;
for (i, value) in values.iter().enumerate() {
let glue_next =
self.source.as_bytes().get(value.span().start as usize) == Some(&b',');
self.write_import_gap_comments(prev_end, value.span().start, i > 0, glue_next);
if is_import
&& i == values.len() - 1
&& let ast::CssValue::Identifier {
span: value_span, ..
} = value
{
let normalized = value_normalization::normalize_css_whitespace(
value_span.extract(self.source),
);
self.print_import_media_query(&normalized);
prev_end = value.span().end;
continue;
}
let reserve = usize::from(i == values.len() - 1 && atrule.block.is_none());
let doc = self.build_css_value_doc(value);
self.write_arena_doc_reserving(doc, reserve);
prev_end = value.span().end;
}
self.write_prelude_gap(prev_end, prelude_region_end(atrule), Edge::Flush);
}
ast::PreludeValue::Raw { content, span } if !content.is_empty() => {
#[cfg(feature = "_internal_comment_check")]
tsv_lang::comment_ledger::record_verbatim_range(self.source, span.start, span.end);
#[cfg(not(feature = "_internal_comment_check"))]
let _ = span;
self.write(" ");
self.write(content);
}
ast::PreludeValue::Supports { condition, span } => {
self.write_condition_prelude(ConditionKind::Supports, condition, *span, atrule);
}
ast::PreludeValue::Container {
name,
condition,
span,
} => {
self.write_condition_prelude(
ConditionKind::Container {
name: name.as_deref(),
},
condition,
*span,
atrule,
);
}
ast::PreludeValue::Media { content, .. } if !content.is_empty() => {
self.write(" ");
self.print_media_prelude(content, atrule.block.is_some());
}
ast::PreludeValue::CustomSelector { name, list, span } => {
self.write_custom_selector_prelude(*name, list, *span, atrule);
}
ast::PreludeValue::Selectors { root, limit, .. } => {
self.write_scope_prelude(root.as_ref(), limit.as_ref(), atrule);
}
_ => {}
}
if let Some(block) = &atrule.block {
self.write_block_open();
self.indent_level += 1;
let was_in_keyframes = self.in_keyframes;
self.in_keyframes = crate::parser::is_keyframes_atrule(atrule.name);
let was_in_utility = self.value_scope.in_utility_at_rule;
self.value_scope.in_utility_at_rule =
was_in_utility || atrule.name.eq_ignore_ascii_case("utility");
self.print_css_block_children(block.children, 0);
self.value_scope.in_utility_at_rule = was_in_utility;
self.in_keyframes = was_in_keyframes;
self.indent_level -= 1;
self.write_indent();
self.write_block_tail_boundary_ws(block.children, block.span);
self.write("}");
} else {
self.write(";");
}
}
fn print_media_prelude(&mut self, content: &str, has_block: bool) {
let reserve = if has_block { " {".len() } else { 0 };
let doc = self.build_media_prelude_doc(content, reserve);
self.write_arena_doc_reserving(doc, reserve);
}
fn build_media_prelude_doc(&self, content: &str, reserve: usize) -> DocId {
let d = self.d();
let content = value_normalization::normalize_value_text(content, ValueReader::MediaQuery);
let content = match value_normalization::lowercase_media_feature_names(&content) {
Cow::Borrowed(_) => content,
Cow::Owned(s) => s,
};
let MediaQueryList {
text,
entries: queries,
closing_comma,
} = media_query_list(&content);
if queries.len() > 1 {
let mut parts = DocBuf::new();
for (i, query) in queries.iter().enumerate() {
if i > 0 {
parts.push(d.text(","));
parts.push(d.line());
}
parts.push(d.text_pooled(query));
}
if closing_comma {
parts.push(d.text(","));
}
return d.group(d.indent(d.concat(&parts)));
}
self.build_and_or_wrap_doc(text, reserve)
}
fn build_and_or_wrap_doc(&self, query: &str, reserve: usize) -> DocId {
let d = self.d();
let atoms = value_normalization::split_by_space_preserving_parens(query);
let mut fill_parts = DocBuf::new();
let mut segment = d.pool_writer();
let mut has_connector = false;
for (i, atom) in atoms.iter().copied().enumerate() {
if is_media_connector(atom) && !segment.is_empty() && i + 1 < atoms.len() {
has_connector = true;
fill_parts.push(segment.finish_text());
segment = d.pool_writer();
fill_parts.push(d.concat(&[d.text(" "), d.text_pooled(atom), d.line()]));
} else {
if !segment.is_empty() {
segment.push(' ');
}
segment.push_str(atom);
}
}
if !segment.is_empty() {
fill_parts.push(segment.finish_text());
}
if !has_connector {
return d.text_pooled(query);
}
let fill = d.fill(&fill_parts);
let fill = d.with_context(fill, prelude_fill_context(reserve));
d.indent(fill)
}
fn write_condition_prelude(
&mut self,
kind: ConditionKind<'_>,
condition: &ast::ConditionQuery<'_>,
span: Span,
atrule: &ast::CssAtrule<'_>,
) {
if condition.parts.is_empty() {
let region_end = atrule
.block
.as_ref()
.map_or(atrule.span.end, |b| b.span.start);
let comments = self.comment_blocks_in_range(atrule.name_span.end, region_end);
let pieces = [
kind.name(),
(!comments.is_empty()).then_some(comments.as_str()),
condition.trailing_operators,
];
for piece in pieces.into_iter().flatten() {
self.write(" ");
self.write(piece);
}
return;
}
self.write(" ");
self.print_condition_query(kind, condition, atrule, span);
}
fn print_condition_query(
&mut self,
kind: ConditionKind<'_>,
condition: &ast::ConditionQuery<'_>,
atrule: &ast::CssAtrule<'_>,
prelude_span: Span,
) {
debug_assert!(
!condition.parts.is_empty(),
"a partless condition prelude is write_condition_prelude's to print"
);
let name_end_pos = if let Some(n) = kind.name() {
let kept = self.spell_gap_items(
atrule.name_span.end,
prelude_span.start,
Edge::Flush,
Edge::Presence,
);
if !kept.is_empty() {
self.write(&kept);
}
self.write(n);
self.write(" ");
prelude_span.start + n.len() as u32
} else {
atrule.name_span.end
};
let region_end = prelude_region_end(atrule);
let reserve = if atrule.block.is_some() {
" {".len()
} else {
0
};
let doc = self.build_condition_query_doc(
kind,
condition,
name_end_pos,
(prelude_span, region_end),
reserve,
);
self.write_arena_doc_reserving(doc, reserve);
}
pub(super) fn build_supports_condition_doc(
&self,
name: &str,
condition: &ast::ConditionQuery<'_>,
) -> DocId {
let d = self.d();
let Some(part) = condition.parts.first() else {
return d.text_pooled(name);
};
let last = part.segments.len().saturating_sub(1);
let mut inner = DocBuf::with_capacity(part.segments.len());
for (i, segment) in part.segments.iter().enumerate() {
let peeled = match segment {
ast::ConditionSegment::Text(text) => {
let mut text: &str = text;
if i == 0 {
text = text.strip_prefix('(').unwrap_or(text);
}
if i == last {
text = text.strip_suffix(')').unwrap_or(text);
}
ast::ConditionSegment::Text(text)
}
selectors => *selectors,
};
inner.push(self.build_condition_segment_doc(ConditionKind::Supports, &peeled));
}
let inner = d.concat(&inner);
d.group(d.concat(&[
d.text_pooled(name),
d.text("("),
d.indent(d.concat(&[d.softline(), inner])),
d.softline(),
d.text(")"),
]))
}
fn build_condition_segment_doc(
&self,
kind: ConditionKind<'_>,
segment: &ast::ConditionSegment<'_>,
) -> DocId {
let d = self.d();
match segment {
ast::ConditionSegment::Text(text) => {
if kind.normalizes() {
d.text_pooled(&value_normalization::normalize_value_text(
text,
ValueReader::Value,
))
} else {
d.text_pooled(text)
}
}
ast::ConditionSegment::Selectors { list, paren } => {
d.remove_lines(self.build_paren_selector_list_inner(list, *paren))
}
}
}
fn build_condition_query_doc(
&self,
kind: ConditionKind<'_>,
condition: &ast::ConditionQuery<'_>,
name_end_pos: u32,
(prelude_span, region_end): (Span, u32),
reserve: usize,
) -> DocId {
let d = self.d();
let parts = condition.parts;
let content_doc = |part: &ast::ConditionPart<'_>| {
let mut segments = DocBuf::with_capacity(part.segments.len());
for segment in part.segments {
segments.push(self.build_condition_segment_doc(kind, segment));
}
d.concat(&segments)
};
let leading_first = |part: &ast::ConditionPart<'_>| -> DocId {
let mut head = DocBuf::new();
match part.connector {
None => self.push_condition_gap_ahead(&mut head, name_end_pos, part.span.start),
Some(connector) => {
self.push_condition_gap_ahead(&mut head, name_end_pos, connector.span.start);
head.push(d.text_pooled(connector.text));
head.push(d.text(" "));
self.push_condition_gap_ahead(&mut head, connector.span.end, part.span.start);
}
}
head.push(content_doc(part));
d.concat(&head)
};
if parts.len() <= 1 {
let Some(first) = parts.first() else {
return d.text("");
};
let head = leading_first(first);
return match self.condition_tail_doc(first, prelude_span, region_end, condition) {
Some(tail) => d.concat(&[head, tail]),
None => head,
};
}
let mut fill_parts = DocBuf::new();
for (i, part) in parts.iter().enumerate() {
if i == 0 {
fill_parts.push(leading_first(part));
continue;
}
let gap_start = parts[i - 1].span.end;
let before_end = part.connector.map_or(part.span.start, |c| c.span.start);
let mut sep = DocBuf::new();
let mut chunk = DocBuf::new();
let before =
self.condition_gap_side(gap_start, before_end, Edge::Presence, Edge::Presence);
if let Some(connector) = part.connector {
match before {
GapSide::Claimed(kept) => sep.push(d.text_pooled(&kept)),
GapSide::Comments(comments) if comments.is_empty() => sep.push(d.text(" ")),
GapSide::Comments(comments) => {
sep.push(d.text(" "));
sep.push(d.text_pooled(&comments));
sep.push(d.text(" "));
}
}
sep.push(d.text_pooled(connector.text));
sep.push(d.line());
} else {
match before {
GapSide::Claimed(kept) => {
if let Some(spaced) = kept.strip_suffix(' ') {
sep.push(d.text_pooled(spaced));
sep.push(d.line());
} else if let Some(glued) = kept.strip_prefix(' ') {
sep.push(d.line());
chunk.push(d.text_pooled(glued));
} else {
if let Some(previous) = fill_parts.pop() {
fill_parts.push(d.concat(&[
previous,
d.text_pooled(&kept),
content_doc(part),
]));
continue;
}
sep.push(d.text_pooled(&kept));
}
}
GapSide::Comments(comments) if comments.is_empty() => sep.push(d.line()),
GapSide::Comments(comments) => {
sep.push(d.text(" "));
sep.push(d.text_pooled(&comments));
sep.push(d.line());
}
}
}
fill_parts.push(d.concat(&sep));
if let Some(connector) = part.connector {
self.push_condition_gap_ahead(&mut chunk, connector.span.end, part.span.start);
}
chunk.push(content_doc(part));
fill_parts.push(d.concat(&chunk));
}
if let Some(tail) = parts
.last()
.and_then(|last| self.condition_tail_doc(last, prelude_span, region_end, condition))
&& let Some(last_chunk) = fill_parts.pop()
{
fill_parts.push(d.concat(&[last_chunk, tail]));
}
let fill = d.fill(&fill_parts);
let fill = d.with_context(fill, prelude_fill_context(reserve));
d.indent(fill)
}
fn condition_gap_side(&self, from: u32, to: u32, lead: Edge, trail: Edge) -> GapSide {
if self.gap_holds_member(from, to) {
GapSide::Claimed(self.spell_gap_items(from, to, lead, trail))
} else {
GapSide::Comments(self.comment_blocks_in_range(from, to))
}
}
fn push_condition_gap_ahead(&self, docs: &mut DocBuf, from: u32, to: u32) {
let d = self.d();
match self.condition_gap_side(from, to, Edge::Flush, Edge::Presence) {
GapSide::Claimed(kept) => docs.push(d.text_pooled(&kept)),
GapSide::Comments(comments) if comments.is_empty() => {}
GapSide::Comments(comments) => {
docs.push(d.text_pooled(&comments));
docs.push(d.text(" "));
}
}
}
fn condition_tail_doc(
&self,
last: &ast::ConditionPart<'_>,
prelude_span: Span,
region_end: u32,
condition: &ast::ConditionQuery<'_>,
) -> Option<DocId> {
let d = self.d();
let mut tail = DocBuf::new();
let gap_start = if condition.trailing_operators.is_some() {
prelude_span.end.max(last.span.end)
} else {
last.span.end
};
if let Some(operators) = self.trailing_operators_doc(condition) {
tail.push(operators);
}
match self.condition_gap_side(gap_start, region_end, Edge::Presence, Edge::Flush) {
GapSide::Claimed(kept) => tail.push(d.text_pooled(&kept)),
GapSide::Comments(comments) if comments.is_empty() => {}
GapSide::Comments(comments) => {
tail.push(d.text(" "));
tail.push(d.text_pooled(&comments));
}
}
(!tail.is_empty()).then(|| d.concat(&tail))
}
fn trailing_operators_doc(&self, condition: &ast::ConditionQuery<'_>) -> Option<DocId> {
let trailing_operators = condition.trailing_operators?;
let d = self.d();
Some(d.concat(&[d.text(" "), d.text_pooled(trailing_operators)]))
}
fn write_import_gap_comments(
&mut self,
start: u32,
end: u32,
needs_separator: bool,
glue_next: bool,
) {
if self.gap_holds_member(start, end) {
let lead = if needs_separator {
Edge::Presence
} else {
Edge::Flush
};
let trail = if glue_next {
Edge::Flush
} else {
Edge::Presence
};
let kept = self.spell_gap_items(start, end, lead, trail);
self.write(&kept);
return;
}
let comments: Vec<_> = self.comments_to_emit_between(start, end).collect();
if comments.is_empty() {
if needs_separator && !glue_next {
self.write(" ");
}
return;
}
if needs_separator {
self.write(" ");
}
for (i, comment) in comments.iter().enumerate() {
if i > 0 {
self.write(" ");
}
self.print_css_comment(comment);
}
if !glue_next {
self.write(" ");
}
}
fn write_scope_prelude(
&mut self,
root: Option<&ast::ScopeClause<'_>>,
limit: Option<&ast::ScopeLimit<'_>>,
atrule: &ast::CssAtrule<'_>,
) {
let block_start = prelude_region_end(atrule);
let before_token = Edge::Presence;
let before_end = Edge::Flush;
let first_start = root
.map(|r| r.paren.start)
.or_else(|| limit.map(|l| l.to_span.start))
.unwrap_or(block_start);
let lead_edge = if first_start == block_start {
before_end
} else {
before_token
};
let claimed = self.write_prelude_gap(atrule.name_span.end, first_start, lead_edge);
let terminator_width = if atrule.block.is_some() { 2 } else { 1 };
let last_end = limit
.map(|l| l.clause.paren.end)
.or_else(|| root.map(|r| r.paren.end));
let mut tail = String::from(")");
if let Some(last_end) = last_end {
tail.push_str(&self.prelude_gap_text(last_end, block_start, before_end).0);
}
let tail_width = first_line_width(&tail)
+ if tail.contains('\n') {
0
} else {
terminator_width
};
match (root, limit) {
(Some(root), Some(limit)) => {
let mut bridge = String::from(")");
let (gap, gap_claimed) =
self.prelude_gap_text(root.paren.end, limit.to_span.start, before_token);
bridge.push_str(&gap);
bridge.push_str(if gap_claimed { "to" } else { " to" });
let (gap, gap_claimed) = self.prelude_gap_text(
limit.to_span.end,
limit.clause.paren.start,
before_token,
);
bridge.push_str(&gap);
bridge.push_str(if gap_claimed { "(" } else { " (" });
let limit_paren = limit.clause.paren.extract(self.source);
let limit_is_empty = limit_paren[1..limit_paren.len() - 1]
.bytes()
.all(|b| b.is_ascii_whitespace());
let root_suffix =
first_line_width(&bridge) + if limit_is_empty { tail_width } else { 0 };
self.write(if claimed { "(" } else { " (" });
self.print_selector_list_nested(&root.list, root.paren, root_suffix);
self.write(&bridge);
self.print_selector_list_nested(&limit.clause.list, limit.clause.paren, tail_width);
}
(Some(clause), None) => {
self.write(if claimed { "(" } else { " (" });
self.print_selector_list_nested(&clause.list, clause.paren, tail_width);
}
(None, Some(limit)) => {
self.write(if claimed { "to" } else { " to" });
let claimed = self.write_prelude_gap(
limit.to_span.end,
limit.clause.paren.start,
before_token,
);
self.write(if claimed { "(" } else { " (" });
self.print_selector_list_nested(&limit.clause.list, limit.clause.paren, tail_width);
}
(None, None) => return,
}
self.write(&tail);
}
fn write_custom_selector_prelude(
&mut self,
name: Span,
list: &ast::SelectorList<'_>,
span: Span,
atrule: &ast::CssAtrule<'_>,
) {
let claimed = self.write_prelude_gap(atrule.name_span.end, name.start, Edge::Presence);
let d = self.d();
let mut parts = DocBuf::new();
parts.push(d.text_pooled(name.extract(self.source)));
let anchor = first_anchor(list);
self.claimed_lead.set(Some(anchor));
let kept = self.spell_gap_items(name.end, anchor, Edge::Presence, Edge::Presence);
if kept.is_empty() {
let gap = self.comment_blocks_in_range(name.end, list.span.start);
if !gap.is_empty() {
parts.push(d.text(" "));
parts.push(d.text_pooled(&gap));
}
parts.push(d.line());
} else if let Some(before_list) = kept.strip_suffix(' ') {
parts.push(d.text_pooled(before_list));
parts.push(d.line());
} else if let Some(after_name) = kept.strip_prefix(' ') {
parts.push(d.line());
parts.push(d.text_pooled(after_name));
} else {
parts.push(d.text_pooled(&kept));
}
parts.push(self.build_comma_list_doc(list.selectors, true));
let doc = d.group(d.indent(d.concat(&parts)));
let reserve = if atrule.block.is_some() { 2 } else { 1 };
if !claimed {
self.write(" ");
}
self.write_arena_doc_reserving(doc, reserve);
self.write_prelude_gap(list.span.end, span.end, Edge::Flush);
}
fn write_prelude_gap(&mut self, start: u32, end: u32, trail: Edge) -> bool {
let (text, claimed) = self.prelude_gap_text(start, end, trail);
self.write(&text);
claimed
}
fn prelude_gap_text(&self, start: u32, end: u32, trail: Edge) -> (String, bool) {
if self.gap_holds_member(start, end) {
return (
self.spell_gap_items(start, end, Edge::Presence, trail),
true,
);
}
let mut text = self.comment_blocks_in_range(start, end);
if !text.is_empty() {
text.insert(0, ' ');
}
(text, false)
}
fn print_import_media_query(&mut self, content: &str) {
let normalized = value_normalization::normalize_value_text(content, ValueReader::Value);
let MediaQueryList {
text,
entries: queries,
closing_comma,
} = media_query_list(&normalized);
if text.contains("/*") || queries.len() <= 1 {
let doc = self.build_and_or_wrap_doc(text, 1);
self.write_arena_doc_reserving(doc, 1);
} else {
self.print_import_media_query_fill(&queries, closing_comma);
}
}
fn print_import_media_query_fill(&mut self, queries: &[&str], closing_comma: bool) {
let base = self.indent_level;
let indent1 = (base + 1) * TAB_WIDTH; let indent2 = (base + 2) * TAB_WIDTH;
let mut col = self.current_column();
let n = queries.len();
for (qi, query) in queries.iter().enumerate() {
let is_last = qi == n - 1;
let query_start = col;
col = self.emit_import_query(query, query_start, indent2, PRINT_WIDTH);
if is_last {
if closing_comma {
self.write(",");
}
continue;
}
self.write(",");
col += 1;
let next = queries[qi + 1];
let next_comma = usize::from(qi + 1 != n - 1);
if query_start + query.len() + next.len() + 2 + next_comma <= PRINT_WIDTH {
self.write(" ");
col += 1;
} else {
self.write("\n");
self.write_indent_extra(1);
col = indent1;
}
}
}
fn emit_import_query(
&mut self,
query: &str,
start_col: usize,
indent2: usize,
width: usize,
) -> usize {
let atoms = value_normalization::split_by_space_preserving_parens(query);
let last = atoms.len().saturating_sub(1);
let mut col = start_col;
for (ai, &atom) in atoms.iter().enumerate() {
if ai == 0 {
self.write(atom);
col += atom.len();
continue;
}
let reserve = usize::from(ai == last);
if col + 1 + atom.len() + reserve <= width {
self.write(" ");
self.write(atom);
col += 1 + atom.len();
} else {
self.write("\n");
self.write_indent_extra(2);
self.write(atom);
col = indent2 + atom.len();
}
}
col
}
}