use oxc_css_parser::pos::Span;
use crate::Options;
use crate::merge::Merged;
use crate::print::{Printer, SourceSegment};
pub(crate) struct Style {
pub(crate) blank_line_after_rule: bool,
pub(crate) indent_selector_continuation: bool,
pub(crate) charset: bool,
pub(crate) trailing_newline: bool,
pub(crate) sources_content: bool,
pub(crate) verbatim_comments: bool,
}
impl Style {
pub(crate) const SASS: Style = Style {
blank_line_after_rule: true,
indent_selector_continuation: false,
charset: true,
trailing_newline: false,
sources_content: true,
verbatim_comments: false,
};
pub(crate) const LESS: Style = Style {
blank_line_after_rule: false,
indent_selector_continuation: true,
charset: false,
trailing_newline: true,
sources_content: false,
verbatim_comments: true,
};
}
pub(crate) type SelectorLines = Vec<(String, Span)>;
pub(crate) fn selector_lines(text: &str, span: Span) -> SelectorLines {
text.split('\n')
.map(|line| (line.to_string(), span))
.collect()
}
#[derive(Clone)]
pub(crate) struct Rule {
pub(crate) selector: SelectorLines,
pub(crate) body: Body,
pub(crate) group_end: bool,
pub(crate) extend_id: Option<usize>,
pub(crate) placeholder: bool,
}
#[derive(Clone)]
pub(crate) struct Keyframe {
pub(crate) selector: SelectorLines,
pub(crate) body: Body,
}
#[derive(Clone)]
pub(crate) struct AtRule {
pub(crate) name: String,
pub(crate) name_span: Span,
pub(crate) prelude: String,
pub(crate) body: Option<Body>,
pub(crate) group_end: bool,
pub(crate) hoist: bool,
}
#[derive(Clone)]
pub(crate) struct Comment {
pub(crate) text: String,
pub(crate) span: Span,
pub(crate) column: usize,
}
#[derive(Clone)]
pub(crate) struct Decl {
pub(crate) name: String,
pub(crate) name_span: Span,
pub(crate) value: String,
pub(crate) value_span: Span,
pub(crate) custom: bool,
pub(crate) custom_strip: Option<CustomStrip>,
}
#[derive(Clone, Copy)]
pub(crate) enum CustomStrip {
Trailing,
Indent(usize),
}
#[derive(Clone)]
pub(crate) enum Item {
Rule(Rule),
Keyframe(Keyframe),
AtRule(AtRule),
Comment(Comment),
Raw(Raw),
}
#[derive(Clone)]
pub(crate) struct Raw {
pub(crate) text: String,
pub(crate) marks: Vec<(usize, Span)>,
}
impl From<Raw> for Item {
fn from(raw: Raw) -> Self {
Item::Raw(raw)
}
}
#[derive(Clone)]
pub(crate) enum Body {
Rules(Vec<Item>),
Decls(Vec<DeclItem>),
}
#[derive(Clone)]
pub(crate) enum DeclItem {
Decl(Decl),
Comment(Comment),
AtRule(AtRule),
}
impl Item {
pub(crate) fn is_hoisted(&self) -> bool {
matches!(self, Item::AtRule(at) if at.hoist)
}
pub(crate) fn clear_hoist(&mut self) {
if let Item::AtRule(at) = self {
at.hoist = false;
at.group_end = false;
}
}
fn is_group_end(&self) -> bool {
match self {
Item::Rule(rule) => rule.group_end,
Item::AtRule(at) => at.group_end,
Item::Keyframe(_) | Item::Comment(_) | Item::Raw(_) => false,
}
}
pub(crate) fn is_ascii(&self) -> bool {
match self {
Item::Rule(r) => {
r.selector.iter().all(|(text, _)| text.is_ascii()) && r.body.is_ascii()
}
Item::Keyframe(k) => {
k.selector.iter().all(|(text, _)| text.is_ascii()) && k.body.is_ascii()
}
Item::AtRule(a) => {
a.name.is_ascii()
&& a.prelude.is_ascii()
&& a.body.as_ref().is_none_or(Body::is_ascii)
}
Item::Comment(c) => c.text.is_ascii(),
Item::Raw(r) => r.text.is_ascii(),
}
}
}
impl Body {
pub(crate) fn is_ascii(&self) -> bool {
match self {
Body::Rules(items) => items.iter().all(Item::is_ascii),
Body::Decls(items) => items.iter().all(|item| match item {
DeclItem::Decl(d) => d.name.is_ascii() && d.value.is_ascii(),
DeclItem::Comment(c) => c.text.is_ascii(),
DeclItem::AtRule(a) => a.name.is_ascii() && a.prelude.is_ascii(),
}),
}
}
pub(crate) fn is_empty(&self) -> bool {
match self {
Body::Rules(items) => items.is_empty(),
Body::Decls(items) => items.is_empty(),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Ctx {
Rules,
Keyframes,
}
pub(crate) fn print(
items: &[Item],
style: &Style,
entry: &str,
merged: &Merged,
opts: &Options,
) -> (String, Option<String>) {
let entry_text = merged.files.first().map_or("", |f| f.text.as_str());
let mut printer = Printer::new(entry, entry_text, opts.source_map);
for file in merged.files.iter().skip(1) {
printer.add_file(&file.path.to_string_lossy(), &file.text);
}
if style.sources_content {
for (i, file) in merged.files.iter().enumerate() {
printer.set_content(i as u32, &file.original);
}
}
if merged.files.len() > 1 {
printer.set_segments(
merged
.segs
.iter()
.map(|s| SourceSegment {
start: s.start,
end: s.end,
file: s.file,
orig_start: s.orig,
})
.collect(),
);
}
if style.charset && !items.iter().all(Item::is_ascii) {
printer.raw("@charset \"UTF-8\";\n");
}
emit_items(items, style, Ctx::Rules, 0, &mut printer);
if style.trailing_newline && !items.is_empty() {
printer.newline();
}
printer.finish(opts)
}
fn emit_items(items: &[Item], style: &Style, ctx: Ctx, indent: usize, printer: &mut Printer) {
for (i, item) in items.iter().enumerate() {
if i > 0 {
printer.newline();
if style.blank_line_after_rule && ctx == Ctx::Rules && items[i - 1].is_group_end() {
printer.newline();
}
}
emit_item(item, style, indent, printer);
}
}
fn emit_item(item: &Item, style: &Style, indent: usize, printer: &mut Printer) {
match item {
Item::Rule(rule) => {
emit_selector(&rule.selector, style, indent, printer);
printer.raw(" {");
emit_body(&rule.body, style, indent + 1, Ctx::Rules, printer);
printer.raw("}");
}
Item::Keyframe(keyframe) => {
emit_selector(&keyframe.selector, style, indent, printer);
printer.raw(" {");
emit_body(&keyframe.body, style, indent + 1, Ctx::Keyframes, printer);
printer.raw("}");
}
Item::AtRule(at) => emit_at_rule(at, style, indent, printer),
Item::Comment(comment) => emit_comment(comment, style, indent, printer),
Item::Raw(raw) => emit_raw(raw, indent, printer),
}
}
fn emit_selector(selector: &SelectorLines, style: &Style, indent: usize, printer: &mut Printer) {
for (i, (text, span)) in selector.iter().enumerate() {
if i == 0 {
printer.indent(indent);
} else {
printer.newline();
if style.indent_selector_continuation {
printer.indent(indent);
}
}
printer.token(text, *span);
}
}
fn emit_raw(raw: &Raw, indent: usize, printer: &mut Printer) {
printer.indent(indent);
let text = raw.text.as_str();
let mut cursor = 0;
for (i, &(offset, span)) in raw.marks.iter().enumerate() {
if offset > cursor {
printer.raw(&text[cursor..offset]);
}
let end = raw.marks.get(i + 1).map_or(text.len(), |&(next, _)| next);
printer.token(&text[offset..end], span);
cursor = end;
}
if cursor < text.len() {
printer.raw(&text[cursor..]);
}
}
fn emit_comment(comment: &Comment, style: &Style, indent: usize, printer: &mut Printer) {
let text = format!("/*{}*/", comment.text);
printer.indent(indent);
if style.verbatim_comments {
printer.raw(&text);
return;
}
match minimum_indentation(&text) {
None => printer.token(&text, comment.span),
Some(min) => {
let strip = min.min(comment.column);
printer.token(
text.split_once('\n').map_or(&text, |(first, _)| first),
comment.span,
);
let rest = write_with_indent(&text, strip, indent);
for line in rest.split('\n').skip(1) {
printer.newline();
if !line.is_empty() {
printer.token(line, comment.span);
}
}
}
}
}
fn minimum_indentation(text: &str) -> Option<usize> {
let mut lines = text.split('\n');
lines.next()?;
lines
.filter_map(|line| {
let lead = line.len() - line.trim_start_matches([' ', '\t']).len();
(!line.trim().is_empty()).then_some(lead)
})
.min()
}
fn write_with_indent(text: &str, strip: usize, indent: usize) -> String {
let pad = " ".repeat(indent);
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len() + pad.len());
let mut i = 0;
while i < chars.len() && chars[i] != '\n' {
i += 1;
}
while i < chars.len() {
let mut newlines = 0usize;
let mut line_start = i;
loop {
if i >= chars.len() {
out.push(' ');
return out;
}
let ch = chars[i];
i += 1;
match ch {
' ' | '\t' => continue,
'\n' => {
newlines += 1;
line_start = i;
}
_ => {
i -= 1;
break;
}
}
}
for _ in 0..newlines {
out.push('\n');
}
out.push_str(&pad);
let skip = (line_start + strip).min(i);
out.extend(chars[skip..].iter().take_while(|&&ch| ch != '\n'));
while i < chars.len() && chars[i] != '\n' {
i += 1;
}
}
out
}
fn emit_at_rule(at: &AtRule, style: &Style, indent: usize, printer: &mut Printer) {
printer.indent(indent);
printer.token(
&format!("@{}", at.name),
Span {
start: at.name_span.start.saturating_sub(1),
end: at.name_span.end,
},
);
if !at.prelude.is_empty() {
printer.raw(" ");
printer.raw(&at.prelude);
}
match &at.body {
None => printer.raw(";"),
Some(body) if body.is_empty() => printer.raw(" {}"),
Some(body) => {
let ctx = if at.name.to_ascii_lowercase().ends_with("keyframes") {
Ctx::Keyframes
} else {
Ctx::Rules
};
printer.raw(" {");
emit_body(body, style, indent + 1, ctx, printer);
printer.raw("}");
}
}
}
fn emit_body(body: &Body, style: &Style, indent: usize, ctx: Ctx, printer: &mut Printer) {
if body.is_empty() {
return;
}
printer.newline();
match body {
Body::Rules(items) => emit_items(items, style, ctx, indent, printer),
Body::Decls(items) => {
for (i, item) in items.iter().enumerate() {
if i > 0 {
printer.newline();
}
match item {
DeclItem::Decl(decl) => {
printer.indent(indent);
printer.token(&decl.name, decl.name_span);
if decl.custom {
printer.raw(":");
let tail = match decl.custom_strip {
None => std::borrow::Cow::Borrowed(decl.value.as_str()),
Some(CustomStrip::Trailing) => {
std::borrow::Cow::Owned(format!("{} ", decl.value.trim_end()))
}
Some(CustomStrip::Indent(strip)) => {
let first = decl
.value
.split_once('\n')
.map_or(decl.value.as_str(), |(first, _)| first);
printer.raw(first);
std::borrow::Cow::Owned(write_with_indent(
&decl.value,
strip,
indent,
))
}
};
printer.raw(&tail);
} else {
printer.raw(": ");
printer.token(&decl.value, decl.value_span);
}
printer.raw(";");
}
DeclItem::Comment(comment) => emit_comment(comment, style, indent, printer),
DeclItem::AtRule(at) => emit_at_rule(at, style, indent, printer),
}
}
}
}
printer.newline();
printer.indent(indent.saturating_sub(1));
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
fn render(src: &str) -> String {
crate::scss::compile(std::path::Path::new("t.scss"), src, &Options::default())
.unwrap()
.css
}
#[test]
fn raw_is_printed_and_mapped() {
let source = "中文 raw";
let mut merged = Merged::new();
merged.add_file(Path::new("t.scss"), source.to_string(), source.to_string());
let items = vec![Item::from(Raw {
text: "中文\n.x".to_string(),
marks: vec![
(0, Span { start: 0, end: 0 }),
(7, Span { start: 3, end: 5 }),
],
})];
assert!(!items.iter().all(Item::is_ascii), "非 ASCII 要能被扫到");
let opts = Options {
source_map: true,
..Default::default()
};
let (css, map) = print(&items, &Style::SASS, "t.scss", &merged, &opts);
assert_eq!(css, "@charset \"UTF-8\";\n中文\n.x");
let map: serde_json::Value = serde_json::from_str(&map.expect("map")).unwrap();
let mapped_lines = map["mappings"]
.as_str()
.unwrap()
.split(';')
.filter(|line| !line.is_empty())
.count();
assert_eq!(mapped_lines, 2);
}
#[test]
fn blank_line_after_style_rule() {
assert_eq!(
render(".a{color:red}.b{color:blue}"),
".a {\n color: red;\n}\n\n.b {\n color: blue;\n}"
);
assert_eq!(
render(".a{color:red}@media screen{.b{color:blue}}"),
".a {\n color: red;\n}\n\n@media screen {\n .b {\n color: blue;\n }\n}"
);
assert_eq!(
render("@media screen{.a{color:red}}.b{color:blue}"),
"@media screen {\n .a {\n color: red;\n }\n}\n.b {\n color: blue;\n}"
);
assert_eq!(
render("@media screen{.a{color:red}.b{color:blue}}"),
"@media screen {\n .a {\n color: red;\n }\n\n .b {\n color: blue;\n }\n}"
);
}
#[test]
fn comments_inside_declarations_are_dropped() {
assert_eq!(render(".a{width:1px /* t */ }"), ".a {\n width: 1px;\n}");
assert_eq!(
render(".a{width:1px /* t */; color:red}"),
".a {\n width: 1px;\n color: red;\n}"
);
assert_eq!(render(".a{width: /* t */ 1px }"), ".a {\n width: 1px;\n}");
assert_eq!(
render(".a{width:1px /* t */ !important}"),
".a {\n width: 1px !important;\n}"
);
assert_eq!(
render(".a{color:red; /* t */}"),
".a {\n color: red;\n /* t */\n}"
);
assert_eq!(
render(".a{ /* c1 */ color:red; /* c2 */ }"),
".a {\n /* c1 */\n color: red;\n /* c2 */\n}"
);
}
#[test]
fn anb_selector_normalization() {
assert_eq!(
render(":nth-child(2n + 1){x:1}"),
":nth-child(2n+1) {\n x: 1;\n}"
);
assert_eq!(
render(":nth-child( -n + 3 ){x:1}"),
":nth-child(-n+3) {\n x: 1;\n}"
);
assert_eq!(
render(":nth-child(3n- 1){x:1}"),
":nth-child(3n-1) {\n x: 1;\n}"
);
assert_eq!(
render(":nth-child(odd){x:1}"),
":nth-child(odd) {\n x: 1;\n}"
);
assert_eq!(
render(":nth-child(2n + 1 of .a, .b){x:1}"),
":nth-child(2n+1 of .a, .b) {\n x: 1;\n}"
);
assert_eq!(
render(":nth-of-type( -n + 3 ){x:1}"),
":nth-of-type(-n + 3) {\n x: 1;\n}"
);
assert_eq!(
render(":nth-of-type(2N+1){x:1}"),
":nth-of-type(2N+1) {\n x: 1;\n}"
);
assert_eq!(render(":not(.a + .b){x:1}"), ":not(.a + .b) {\n x: 1;\n}");
}
#[test]
fn root_imports_are_hoisted() {
assert_eq!(
render("@namespace svg url(http://x);@import url(x.css);.a{v:1}"),
"@import url(x.css);\n@namespace svg url(http://x);\n.a {\n v: 1;\n}"
);
assert_eq!(
render("@layer a;@import url(x.css);.a{v:1}"),
"@import url(x.css);\n@layer a;\n.a {\n v: 1;\n}"
);
assert_eq!(
render("@page{margin:1cm}@import url(x.css);@namespace svg url(http://x);"),
"@import url(x.css);\n@page {\n margin: 1cm;\n}\n@namespace svg url(http://x);"
);
assert_eq!(
render("/* lead */@import url(a.css);.a{v:1}"),
"/* lead */\n@import url(a.css);\n.a {\n v: 1;\n}"
);
assert_eq!(
render(".a{v:1}/* c */@import url(x.css);"),
"@import url(x.css);\n.a {\n v: 1;\n}\n\n/* c */"
);
assert_eq!(
render("@media screen{@import url(a.css);}.a{v:1}"),
"@media screen {\n @import url(a.css);\n}\n.a {\n v: 1;\n}"
);
}
#[test]
fn comment_rules() {
assert_eq!(
render("/*! keep */\n.a{color:red}"),
"/*! keep */\n.a {\n color: red;\n}"
);
assert_eq!(render("// drop\n.a{color:red}"), ".a {\n color: red;\n}");
assert_eq!(
render(".a{color:red}/* c */.b{color:blue}"),
".a {\n color: red;\n}\n\n/* c */\n.b {\n color: blue;\n}"
);
assert_eq!(render(".a{/* c */}"), ".a {\n /* c */\n}");
}
#[test]
fn empty_rules_removed() {
assert_eq!(render(".a{}\n.b{color:red}"), ".b {\n color: red;\n}");
assert_eq!(render("@media screen{}"), "");
assert_eq!(render("@font-face{}"), "@font-face {}");
assert_eq!(render("@foo bar{}"), "@foo bar {}");
}
#[test]
fn charset_and_no_trailing_newline() {
assert_eq!(render(".a{color:red}"), ".a {\n color: red;\n}");
assert_eq!(
render(".a{content:\"中\"}"),
"@charset \"UTF-8\";\n.a {\n content: \"中\";\n}"
);
assert!(!render(".a{color:red}").ends_with('\n'));
}
#[test]
fn declarations_and_custom_properties() {
assert_eq!(
render(".a{color:red!important;--x: 1px 2px}"),
".a {\n color: red !important;\n --x: 1px 2px;\n}"
);
assert_eq!(
render(".a{background:url(\"a b.png\") no-repeat}"),
".a {\n background: url(\"a b.png\") no-repeat;\n}"
);
}
#[test]
fn attribute_selector_unquotes_identifiers() {
assert_eq!(
render("input[type=\"button\"], [class*=\"col-\"], [href^=\"javascript:\"]{color:red}"),
"input[type=button], [class*=col-], [href^=\"javascript:\"] {\n color: red;\n}"
);
assert_eq!(
render("[type = \"button\"], [type=\"--x\"], [type=\"1\"]{color:red}"),
"[type=button], [type=\"--x\"], [type=\"1\"] {\n color: red;\n}"
);
}
#[test]
fn custom_property_whitespace() {
assert_eq!(
render(".a{--x: 1px 2px;--y:a,b }"),
".a {\n --x: 1px 2px;\n --y:a,b ;\n}"
);
assert_eq!(
render(".a{--x: a !important}"),
".a {\n --x: a !important;\n}"
);
assert_eq!(render(".a{--x: a\n b}"), ".a {\n --x: a\n b;\n}");
}
#[test]
fn keyframes_have_no_blank_lines_between_blocks() {
assert_eq!(
render("@keyframes x{from{opacity:0}to{opacity:1}}"),
"@keyframes x {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n}"
);
}
#[test]
fn empty_at_rule_blocks() {
assert_eq!(render("@media screen{}"), "");
assert_eq!(render("@supports (a:b){}"), "");
assert_eq!(render("@layer base{}"), "@layer base {}");
}
#[test]
fn raw_function_arguments() {
assert_eq!(
render(
".a{filter:alpha(opacity = 65);x:progid:DXImageTransform.Microsoft.gradient(enabled = false)}"
),
".a {\n filter: alpha(opacity=65);\n x: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n}"
);
}
#[test]
fn source_map_records_declarations() {
let opts = Options {
source_map: true,
..Default::default()
};
let out =
crate::scss::compile(std::path::Path::new("t.scss"), ".a{color:red}", &opts).unwrap();
let map: serde_json::Value = serde_json::from_str(&out.map.unwrap()).unwrap();
assert_eq!(map["version"], 3);
assert_eq!(map["sources"][0], "t.scss");
assert!(!map["mappings"].as_str().unwrap().is_empty());
}
#[test]
fn selector_normalization() {
assert_eq!(
render(".a>.b,.c .d{color:red}"),
".a > .b, .c .d {\n color: red;\n}"
);
assert_eq!(render(".a,\n.b{color:red}"), ".a,\n.b {\n color: red;\n}");
assert_eq!(
render(":not( .a , .b ){color:red}"),
":not(.a, .b) {\n color: red;\n}"
);
}
}