pub(crate) mod builtins;
mod color_space;
pub(crate) mod eval;
pub(crate) mod extend;
pub(crate) mod imports;
#[doc(hidden)]
pub mod indented;
pub(crate) mod modules;
pub(crate) mod value;
mod decls;
mod emit;
mod flow;
mod module_use;
mod prelude;
mod selector;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use oxc_css_parser::ast::*;
use oxc_css_parser::pos::Span;
use oxc_css_parser::{Allocator, ParserBuilder, Syntax};
use crate::doc::{
AtRule as OutAtRule, Body, Comment, CustomStrip, Decl, DeclItem, Item, Keyframe, Rule, Style,
print, selector_lines,
};
use crate::merge::Merged;
use crate::{CompileError, Options, Output};
use eval::{EvalError, Evaluator, Invocation, RestCheck};
use imports::{Loaded, Loader, ModuleInfo};
use self::decls::*;
use self::emit::*;
use self::prelude::*;
use self::selector::*;
const MAX_MIXIN_DEPTH: usize = 256;
pub(crate) fn compile(
path: &Path,
source: &str,
opts: &Options,
) -> Result<Output, Box<CompileError>> {
let source = source.strip_prefix('\u{feff}').unwrap_or(source);
let Loaded {
merged,
deps,
modules,
scopes,
scope_of,
} = Loader::new(path, source, opts).run()?;
let src = merged.text.as_str();
let module_file: HashMap<usize, u32> = modules
.values()
.map(|info| (info.scope, info.file))
.chain(std::iter::once((0, 0)))
.collect();
let alloc = Allocator::default();
let mut parser = ParserBuilder::new(&alloc, src)
.syntax(Syntax::Scss)
.comments()
.build();
let ast = parser
.parse::<Stylesheet>()
.map_err(|e| merged.error_at(e.span.start, format!("SCSS 解析失败: {}", e.kind)))?;
validate_structure(&merged, &ast.statements)?;
let comments: Vec<CommentRec> = parser
.comments()
.iter()
.filter(|c| c.kind == oxc_css_parser::token::CommentKind::Block)
.map(|c| CommentRec {
text: c.content.to_string(),
span: c.span,
})
.collect();
let mut eval = Evaluator::new(scopes);
eval.set_source(&merged.text);
let mut converter = Converter {
empty_tail: false,
had_child: false,
pending_hoist: Vec::new(),
merged: &merged,
opts,
eval,
modules: &modules,
scope_of: &scope_of,
module_file: &module_file,
comments,
cursor: 0,
mixins: (0..scopes).map(|_| vec![HashMap::new()]).collect(),
content: Vec::new(),
content_blocks: Vec::new(),
mixin_depth: 0,
control_depth: 0,
in_mixin: false,
decl_context: false,
decl_prefix: None,
block_depth: 0,
media_stack: Vec::new(),
extensions: extend::Engine::new(),
};
let mut items = hoist_imports(converter.stylesheet(&ast.statements)?);
if let Some(err) = converter.check_forward_conflicts() {
return Err(err);
}
if let Some(at) = converter.eval.unresolved_config() {
return Err(converter
.merged
.error_at(
at,
"This variable was not declared with !default in the @used module.",
)
.into());
}
converter.finish_extensions(&mut items)?;
drop(converter);
let (css, map) = print(&items, &Style::SASS, &path.to_string_lossy(), &merged, opts);
Ok(Output { css, map, deps })
}
fn hoist_imports(items: Vec<Item>) -> Vec<Item> {
let is_import = |item: &Item| matches!(item, Item::AtRule(at) if at.body.is_none() && at.name.eq_ignore_ascii_case("import"));
let prefix = items
.iter()
.take_while(|item| matches!(item, Item::Comment(_)) || is_import(item))
.count();
let mut keep = Vec::with_capacity(items.len());
let mut hoisted = Vec::new();
for (i, item) in items.into_iter().enumerate() {
if i >= prefix && is_import(&item) {
hoisted.push(item);
} else {
keep.push(item);
}
}
keep.splice(prefix..prefix, hoisted);
keep
}
struct SelectorSet {
parts: Vec<Sel>,
}
struct Sel {
text: String,
line_break: bool,
}
impl SelectorSet {
fn is_empty(&self) -> bool {
self.parts.is_empty()
}
fn render(&self, keep: impl Fn(&Sel) -> bool) -> String {
let mut out = String::new();
for part in self.parts.iter().filter(|p| keep(p)) {
if !out.is_empty() {
out.push(',');
out.push(if part.line_break { '\n' } else { ' ' });
}
out.push_str(&part.text);
}
out
}
}
struct CommentRec {
text: String,
span: Span,
}
type Param<'a> = eval::Param<'a>;
#[derive(Clone)]
struct MixinDef<'a> {
params: Vec<Param<'a>>,
rest: Option<&'a str>,
body: &'a [Statement<'a>],
body_span: Span,
scope: usize,
depth: usize,
has_content: bool,
}
#[derive(Clone)]
struct ContentBlock<'a> {
params: Vec<Param<'a>>,
rest: Option<&'a str>,
stmts: &'a [Statement<'a>],
span: Span,
scope: usize,
head_len: usize,
tail: Vec<eval::Frame<'a>>,
mixins_tail: Vec<HashMap<String, MixinDef<'a>>>,
cursor: usize,
outer: Option<usize>,
}
struct Converter<'a> {
merged: &'a Merged,
opts: &'a Options,
eval: Evaluator<'a>,
modules: &'a HashMap<PathBuf, ModuleInfo>,
scope_of: &'a HashMap<u32, usize>,
module_file: &'a HashMap<usize, u32>,
comments: Vec<CommentRec>,
cursor: usize,
mixins: Vec<Vec<HashMap<String, MixinDef<'a>>>>,
content: Vec<Option<usize>>,
content_blocks: Vec<ContentBlock<'a>>,
mixin_depth: usize,
control_depth: usize,
in_mixin: bool,
decl_context: bool,
decl_prefix: Option<String>,
block_depth: usize,
media_stack: Vec<String>,
extensions: extend::Engine,
empty_tail: bool,
had_child: bool,
pending_hoist: Vec<Vec<Item>>,
}
impl<'a> Converter<'a> {
fn src(&self) -> &'a str {
&self.merged.text
}
fn scope_at(&self, at: usize) -> usize {
*self.scope_of.get(&self.file_at(at)).unwrap_or(&0)
}
fn file_at(&self, at: usize) -> u32 {
self.merged.locate_exact(at).map_or_else(
|| self.merged.segs.last().map_or(0, |seg| seg.file),
|(file, _)| file,
)
}
fn err(&self, at: usize, message: impl Into<String>) -> Box<CompileError> {
self.merged.error_at(at, message).into()
}
fn eval_err(&self, e: EvalError) -> Box<CompileError> {
self.merged.error_at(e.at, e.message).into()
}
fn at_root_rule(
&mut self,
at: &'a oxc_css_parser::ast::AtRule<'a>,
parent: Option<&SelectorSet>,
out: &mut Vec<Item>,
) -> Result<(), Box<CompileError>> {
let (selectors, span) = match &at.prelude {
Some(AtRulePrelude::SassAtRoot(root)) => match &root.kind {
SassAtRootKind::Selector(list) => {
let resolved = self.selectors(list, None)?;
(Some(resolved.0), resolved.1)
}
_ => {
return Err(self.err(
at.span.start,
"SCSS 特性尚未实现(S3): @at-root 的 query 形式",
));
}
},
None => (None, at.span),
_ => return Err(self.err(at.span.start, "SCSS 解析失败: @at-root")),
};
let Some(block) = &at.block else {
return Ok(());
};
if selectors.as_ref().is_some_and(|s| s.is_empty()) {
return Ok(());
}
let saved_parent = parent.map(|p| p.render(|_| true));
let mut items = Vec::new();
let scope = self.push_scope(false);
self.eval.set_parent_selector(match &selectors {
Some(sels) => Some(sels.render(|_| true)),
None => saved_parent.clone(),
});
let saved_empty = self.empty_tail;
let saved_child = self.had_child;
self.empty_tail = false;
self.had_child = false;
let result = self.block(
&block.statements,
block.span.end,
selectors.as_ref(),
span,
&mut items,
);
let tail_empty = if self.had_child {
self.empty_tail
} else {
items.is_empty()
};
self.empty_tail = saved_empty;
self.had_child = saved_child;
self.eval.set_parent_selector(saved_parent);
self.pop_scope(scope);
result?;
if !tail_empty {
if let Some(item) = items.last_mut() {
mark_group_end(item);
}
}
out.extend(items);
self.empty_tail = tail_empty;
self.had_child = true;
Ok(())
}
fn extend_rule(
&mut self,
at: &'a oxc_css_parser::ast::AtRule<'a>,
parent: Option<&SelectorSet>,
) -> Result<(), Box<CompileError>> {
let Some(AtRulePrelude::SassExtend(extend)) = &at.prelude else {
return Err(self.err(at.span.start, "SCSS 解析失败: @extend"));
};
let Some(parent) = parent else {
return Err(self.err(at.span.start, "Extend may only be used within style rules."));
};
let extenders = parent
.parts
.iter()
.filter_map(|part| extend::parse_sel(&part.text, part.line_break))
.collect::<Vec<_>>();
let raw = self
.src()
.get(extend.selectors.span.start..extend.selectors.span.end)
.unwrap_or("")
.to_string();
let targets = self
.eval
.interpolate(&raw, extend.selectors.span.start)
.map_err(|e| self.eval_err(e))?;
let optional = extend.optional.is_some();
for target in split_top_level_commas(&targets) {
let target = target.trim();
if target.is_empty() {
continue;
}
let Some(compound) = extend::target_compound(target) else {
return Err(self.err(
at.span.start,
format!("Can't extend complex selector {}.", target),
));
};
self.extensions.add_extension(extend::Extension {
extenders: extenders.clone(),
target: compound,
optional,
span: at.span.start,
});
}
Ok(())
}
fn finish_extensions(&mut self, items: &mut Vec<Item>) -> Result<(), Box<CompileError>> {
self.extensions.resolve();
if std::env::var("EXT_DEBUG").is_ok() {
self.extensions.debug_lists();
}
if !self.extensions.is_empty() {
extend::apply(items, &self.extensions.lists, &self.extensions.dirty);
}
match self.extensions.unmatched() {
Some(span) => Err(self.err(span, "The target selector was not found.")),
None => Ok(()),
}
}
fn stylesheet(&mut self, stmts: &'a [Statement<'a>]) -> Result<Vec<Item>, Box<CompileError>> {
let mut out = Vec::new();
self.block(
stmts,
self.src().len(),
None,
Span { start: 0, end: 0 },
&mut out,
)?;
Ok(out)
}
fn block(
&mut self,
stmts: &'a [Statement<'a>],
end: usize,
parent: Option<&SelectorSet>,
parent_span: Span,
out: &mut Vec<Item>,
) -> Result<(), Box<CompileError>> {
let mut decls: Vec<DeclItem> = Vec::new();
let mut nested: Vec<Item> = Vec::new();
self.pending_hoist.push(Vec::new());
self.stmts(stmts, end, parent, parent_span, &mut decls, &mut nested)?;
self.flush_pending_hoist(&mut decls, parent, parent_span, &mut nested)?;
self.flush_rule(&mut decls, parent, parent_span, &mut nested)?;
if let Some(pending) = self.pending_hoist.pop() {
nested.extend(pending);
}
out.extend(nested);
Ok(())
}
fn flush_pending_hoist(
&mut self,
decls: &mut Vec<DeclItem>,
parent: Option<&SelectorSet>,
parent_span: Span,
nested: &mut Vec<Item>,
) -> Result<(), Box<CompileError>> {
if self.pending_hoist.last().is_none_or(Vec::is_empty) {
return Ok(());
}
self.flush_rule(decls, parent, parent_span, nested)?;
if let Some(pending) = self.pending_hoist.last_mut() {
nested.append(pending);
}
Ok(())
}
fn flush_rule(
&mut self,
decls: &mut Vec<DeclItem>,
parent: Option<&SelectorSet>,
parent_span: Span,
nested: &mut Vec<Item>,
) -> Result<(), Box<CompileError>> {
if decls.is_empty() {
return Ok(());
}
let items = std::mem::take(decls);
let Some(selectors) = parent else {
return Err(self.err(
parent_span.start,
"Declarations may only be used within style rules.",
));
};
let selector = selectors.render(|_| true);
if !selector.is_empty() {
let parsed = selectors
.parts
.iter()
.map(|part| extend::parse_sel(&part.text, part.line_break))
.collect::<Option<Vec<_>>>();
let extend_id = parsed.map(|list| self.extensions.add_rule(list));
nested.push(Item::Rule(Rule {
selector: selector_lines(&selector, parent_span),
body: Body::Decls(items),
group_end: false,
extend_id,
placeholder: false,
}));
}
Ok(())
}
fn stmts(
&mut self,
stmts: &'a [Statement<'a>],
end: usize,
parent: Option<&SelectorSet>,
parent_span: Span,
decls: &mut Vec<DeclItem>,
nested: &mut Vec<Item>,
) -> Result<(), Box<CompileError>> {
self.block_depth += 1;
let result = self.stmts_loop(stmts, end, parent, parent_span, decls, nested);
self.block_depth -= 1;
result
}
fn stmts_loop(
&mut self,
stmts: &'a [Statement<'a>],
end: usize,
parent: Option<&SelectorSet>,
parent_span: Span,
decls: &mut Vec<DeclItem>,
nested: &mut Vec<Item>,
) -> Result<(), Box<CompileError>> {
for st in stmts {
self.eval.enter(self.scope_at(st.span().start));
let decl_context = self.decl_context;
self.flush_comments(st.span().start, &mut |c| {
if parent.is_some() || decl_context {
decls.push(DeclItem::Comment(c));
} else {
nested.push(Item::Comment(c));
}
})?;
if !matches!(st, Statement::Declaration(_)) {
self.flush_pending_hoist(decls, parent, parent_span, nested)?;
}
self.statement(st, parent, parent_span, decls, nested)?;
let region_end = match st {
Statement::Declaration(decl) => self.decl_region_end(decl.span.end).end,
other => other.span().end,
};
self.skip_comments(region_end);
}
let decl_context = self.decl_context;
self.flush_comments(end, &mut |c| {
if parent.is_some() || decl_context {
decls.push(DeclItem::Comment(c));
} else {
nested.push(Item::Comment(c));
}
})?;
Ok(())
}
fn statement(
&mut self,
st: &'a Statement<'a>,
parent: Option<&SelectorSet>,
parent_span: Span,
decls: &mut Vec<DeclItem>,
nested: &mut Vec<Item>,
) -> Result<(), Box<CompileError>> {
match st {
Statement::Declaration(decl) => match self.nesting_cut(decl) {
Some(cut) => self.nested_decl(decl, cut, parent, parent_span, decls, nested),
None => match self.decl(decl)? {
Some(decl) => {
if parent.is_none() && !self.decl_context {
Err(self.err(
decl.name_span.start,
"Declarations may only be used within style rules.",
))
} else {
decls.push(DeclItem::Decl(decl));
Ok(())
}
}
None => Ok(()),
},
},
Statement::SassVariableDeclaration(var) => self.var_decl(var),
Statement::QualifiedRule(rule) => {
self.flush_rule(decls, parent, parent_span, nested)?;
self.nested_rule(rule, parent, nested)
}
Statement::KeyframeBlock(_) => {
Err(self.err(st.span().start, "SCSS 特性尚未实现(S1): 裸 keyframe 块"))
}
Statement::SassIfAtRule(rule) => self.if_rule(rule, parent, parent_span, decls, nested),
Statement::UnknownSassAtRule(at) => Err(self.err(
at.span.start,
format!(
"SCSS 特性尚未实现(S2): @{}",
ident_name(&at.name).unwrap_or("?")
),
)),
Statement::AtRule(at) => self.sass_at_rule(at, parent, parent_span, decls, nested),
other => Err(self.err(
st.span().start,
format!(
"SCSS 特性尚未实现(S1): {}",
snippet(self.src(), other.span().start, other.span().end)
),
)),
}
}
fn sass_at_rule(
&mut self,
at: &'a oxc_css_parser::ast::AtRule<'a>,
parent: Option<&SelectorSet>,
parent_span: Span,
decls: &mut Vec<DeclItem>,
nested: &mut Vec<Item>,
) -> Result<(), Box<CompileError>> {
let lower = at.name.name.to_ascii_lowercase();
if self.decl_prefix.is_some()
&& !matches!(
lower.as_str(),
"include"
| "content"
| "each"
| "for"
| "while"
| "if"
| "debug"
| "warn"
| "error"
)
{
return Err(self.err(at.span.start, "This at-rule is not allowed here."));
}
match lower.as_str() {
"mixin" => return self.define_mixin(at),
"function" => return self.define_function(at),
"include" => return self.include_rule(at, parent, parent_span, decls, nested),
"content" => return self.content_rule(at, parent, parent_span, decls, nested),
"each" => return self.each_rule(at, parent, parent_span, decls, nested),
"for" => return self.for_rule(at, parent, parent_span, decls, nested),
"while" => return self.while_rule(at, parent, parent_span, decls, nested),
"use" => return self.use_rule(at),
"forward" => return self.forward_rule(at),
_ => {}
}
if parent.is_some() && at.block.is_none() && at.name.name.eq_ignore_ascii_case("import") {
let raw = self.prelude_raw(at);
let prelude = self
.eval
.interpolate(&raw, at.name.span.end)
.map_err(|e| self.eval_err(e))?;
decls.push(DeclItem::AtRule(OutAtRule {
name: at.name.name.to_string(),
name_span: at.name.span,
prelude: normalize_import_urls(prelude.trim()),
body: None,
group_end: false,
hoist: false,
}));
return Ok(());
}
let mut items = Vec::new();
self.at_rule(at, parent, parent_span, &mut items)?;
if items.is_empty() {
return Ok(());
}
if parent.is_some() && items.iter().any(Item::is_hoisted) {
if let Some(pending) = self.pending_hoist.last_mut() {
pending.extend(items);
}
return Ok(());
}
self.flush_rule(decls, parent, parent_span, nested)?;
nested.extend(items);
Ok(())
}
fn push_scope(&mut self, semi_global: bool) -> usize {
let scope = self.eval.active_scope();
self.eval.push_frame(semi_global);
self.mixins[scope].push(HashMap::new());
scope
}
fn pop_scope(&mut self, scope: usize) {
let saved = self.eval.active_scope();
self.eval.enter(scope);
if self.mixins[scope].len() > 1 {
self.mixins[scope].pop();
}
self.eval.pop_frame();
self.eval.enter(saved);
}
fn comment_cursor(&self, at: usize) -> usize {
self.comments.partition_point(|c| c.span.start < at)
}
fn nested_rule(
&mut self,
rule: &'a QualifiedRule<'a>,
parent: Option<&SelectorSet>,
out: &mut Vec<Item>,
) -> Result<(), Box<CompileError>> {
self.skip_comments(rule.block.span.start);
let (selectors, span) = self.selectors(&rule.selector, parent)?;
if selectors.is_empty() {
return Ok(());
}
let parsed = selectors
.parts
.iter()
.filter_map(|part| extend::parse_sel(&part.text, part.line_break))
.collect::<Vec<_>>();
self.extensions.note_selectors(&parsed);
let mut items = Vec::new();
let scope = self.push_scope(false);
let saved_parent = self.eval.parent_selector_text();
self.eval
.set_parent_selector(Some(selectors.render(|_| true)));
let saved_empty = self.empty_tail;
let saved_child = self.had_child;
self.empty_tail = false;
self.had_child = false;
let result = self.block(
&rule.block.statements,
rule.block.span.end,
Some(&selectors),
span,
&mut items,
);
self.eval.set_parent_selector(saved_parent);
self.pop_scope(scope);
result?;
let root_rule = parent.is_none();
let tail_empty = if self.had_child {
self.empty_tail
} else {
items.is_empty()
};
self.empty_tail = saved_empty;
self.had_child = saved_child;
let this_empty = items.is_empty();
let before = out.len();
out.extend(items);
let _ = this_empty;
self.empty_tail = tail_empty;
self.had_child = true;
if root_rule && !tail_empty {
if let Some(item) = out[before..].last_mut() {
mark_group_end(item);
}
}
Ok(())
}
fn keyframe(
&mut self,
block: &'a KeyframeBlock<'a>,
) -> Result<Option<Item>, Box<CompileError>> {
self.skip_comments(block.block.span.start);
let selector = self.keyframe_selectors(block)?;
let body = Body::Decls(self.decl_list(&block.block.statements, block.block.span.end)?);
if body.is_empty() {
return Ok(None);
}
Ok(Some(Item::Keyframe(Keyframe {
selector: selector_lines(
&selector,
Span {
start: block
.selectors
.first()
.map_or(block.span.start, |s| self.keyframe_span(s).start),
end: block.block.span.start,
},
),
body,
})))
}
fn at_rule(
&mut self,
at: &'a oxc_css_parser::ast::AtRule<'a>,
parent: Option<&SelectorSet>,
parent_span: Span,
out: &mut Vec<Item>,
) -> Result<(), Box<CompileError>> {
let name = at.name.name.to_string();
let lower = name.to_ascii_lowercase();
if matches!(&at.prelude, Some(AtRulePrelude::SassAtRoot(_))) || lower == "at-root" {
return self.at_root_rule(at, parent, out);
}
match lower.as_str() {
"charset" => return Ok(()),
"use" => {
if self.block_depth > 1 {
return Err(self.err(at.span.start, "This at-rule is not allowed here."));
}
return self.use_rule(at);
}
"debug" => {
if let Some(AtRulePrelude::SassExpr(expr)) = &at.prelude {
let value = self
.eval
.eval_component(self.src(), expr)
.map_err(|e| self.eval_err(e))?;
let (path, line, _) = self.merged.position(at.span.start);
let message = match &value {
value::Value::Str(s) if s.quoted => s.text.clone(),
value => value.inspect(),
};
eprintln!("{path}:{line} DEBUG: {message}");
}
return Ok(());
}
"warn" => {
if let Some(AtRulePrelude::SassExpr(expr)) = &at.prelude {
let value = self
.eval
.eval_component(self.src(), expr)
.map_err(|e| self.eval_err(e))?;
let message = match &value {
value::Value::Str(s) if s.quoted => Ok(s.text.clone()),
value => value.to_css(),
}
.map_err(|message| self.err(at.span.start, message))?;
let (path, line, column) = self.merged.position(at.span.start);
eprintln!(
"WARNING: {message}\n {path} {line}:{} root stylesheet\n",
column + 1
);
}
return Ok(());
}
"error" => {
let message = match &at.prelude {
Some(AtRulePrelude::SassExpr(expr)) => self
.eval
.eval_component(self.src(), expr)
.map_err(|e| self.eval_err(e))?
.inspect(),
_ => String::new(),
};
return Err(self.err(at.span.start, message));
}
"return" => return Err(self.err(at.span.start, "This at-rule is not allowed here.")),
"extend" => return self.extend_rule(at, parent),
"forward" => {
if self.block_depth > 1 {
return Err(self.err(at.span.start, "This at-rule is not allowed here."));
}
return self.forward_rule(at);
}
"mixin" | "include" | "content" => {
return Err(self.err(at.span.start, format!("SCSS 解析失败: @{lower}")));
}
_ => {}
}
let mut prelude = {
let raw = self.prelude_raw(at);
let base = at.name.span.end;
let interpolated = self
.eval
.interpolate(&raw, base)
.map_err(|e| self.eval_err(e))?;
if lower == "supports" {
match supports_prelude(&interpolated, &mut self.eval, base) {
Ok(prelude) => prelude,
Err((offset, message)) => {
return Err(self.err(base + offset, message));
}
}
} else if lower == "media" {
let text = self.substitute_vars(&interpolated, base)?;
normalize_prelude(&text)
} else if lower == "import" {
normalize_import_urls(&normalize_prelude(&interpolated))
} else {
normalize_prelude(&interpolated)
}
};
let mut merged = false;
if lower == "media" {
if let Some(outer) = self.media_stack.last().cloned() {
match merge_media(&outer, &prelude) {
Some(queries) if queries.is_empty() => return Ok(()),
Some(queries) => {
prelude = queries;
merged = true;
}
None => {}
}
}
}
let Some(block) = &at.block else {
out.push(Item::AtRule(OutAtRule {
name,
name_span: at.name.span,
prelude,
body: None,
group_end: false,
hoist: false,
}));
return Ok(());
};
self.skip_comments(block.span.start);
let pushed = if lower == "media" {
self.media_stack.push(prelude.clone());
true
} else {
false
};
let body = if lower.ends_with("keyframes") {
let mut items = Vec::new();
for st in &block.statements {
self.flush_comments(st.span().start, &mut |c| items.push(Item::Comment(c)))?;
if let Statement::KeyframeBlock(kb) = st {
if let Some(item) = self.keyframe(kb)? {
items.push(item);
}
} else if !matches!(st, Statement::AtRule(_)) {
return Err(self.err(
st.span().start,
"SCSS 特性尚未实现(S1): @keyframes 内的非常规语句",
));
}
self.skip_comments(st.span().end);
}
self.flush_comments(block.span.end, &mut |c| items.push(Item::Comment(c)))?;
Body::Rules(items)
} else if parent.is_some()
|| has_rule_statements(&block.statements) && !is_decl_at_rule(&lower)
{
let mut items = Vec::new();
let scope = self.push_scope(false);
let result = self.block(
&block.statements,
block.span.end,
parent,
parent_span,
&mut items,
);
self.pop_scope(scope);
result?;
Body::Rules(items)
} else {
Body::Decls(self.decl_list(&block.statements, block.span.end)?)
};
if pushed {
self.media_stack.pop();
}
if body.is_empty() && is_conditional(&lower) {
return Ok(());
}
let merged_away = merged;
let rule = OutAtRule {
name,
name_span: at.name.span,
prelude,
body: Some(body),
group_end: false,
hoist: merged_away,
};
if let Some(Body::Rules(items)) = rule.body.clone() {
if items.iter().any(Item::is_hoisted) {
let mut current: Vec<Item> = Vec::new();
let mut segments: Vec<Item> = Vec::new();
for mut item in items {
if item.is_hoisted() {
item.clear_hoist();
if !current.is_empty() {
segments.push(wrap_media(&rule, std::mem::take(&mut current)));
}
segments.push(item);
} else {
current.push(item);
}
}
if !current.is_empty() {
segments.push(wrap_media(&rule, current));
}
out.extend(segments);
return Ok(());
}
}
out.push(Item::AtRule(rule));
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unsupported_sass_syntax_reports_position() {
let err = compile(
Path::new("t.scss"),
"// header\n@forward \"sass:math\";\n",
&Options::default(),
)
.unwrap_err();
assert!(err.message.contains("尚未实现"), "{}", err.message);
assert_eq!(err.line, Some(2));
assert_eq!(err.path.as_deref(), Some("t.scss"));
}
#[test]
fn prelude_normalizes_colon() {
assert_eq!(normalize_prelude("(min-width:100px)"), "(min-width: 100px)");
assert_eq!(
normalize_prelude(" screen and (min-width:100px)"),
"screen and (min-width: 100px)"
);
assert_eq!(
normalize_prelude(" (400px <= width <= 700px)"),
"(400px <= width <= 700px)"
);
assert_eq!(
normalize_prelude(" url(\"x.css\") screen"),
"url(\"x.css\") screen"
);
assert_eq!(normalize_prelude(" :first"), ":first");
assert_eq!(normalize_prelude(" selector(:has(a))"), "selector(:has(a))");
}
#[test]
fn attribute_selector_quote_rules() {
assert_eq!(
normalize_selector("[id=\"modal\"] .bd-example .btn"),
"[id=modal] .bd-example .btn"
);
assert_eq!(normalize_selector(".a[x=\"1\"] .b"), ".a[x=\"1\"] .b");
assert_eq!(normalize_selector("[type=\"button\"]"), "[type=button]");
assert_eq!(normalize_selector("[class*=\"col-\"]"), "[class*=col-]");
assert_eq!(
normalize_selector("[data-x=\"a b\"] .y"),
"[data-x=\"a b\"] .y"
);
}
#[test]
fn supports_condition_parens() {
fn supports(prelude: &str) -> String {
let mut eval = Evaluator::new(1);
supports_prelude(prelude, &mut eval, 0).expect("prelude 应能打印")
}
assert_eq!(supports("((a:b))"), "(a: b)");
assert_eq!(supports("(((a:b)))"), "(a: b)");
assert_eq!(supports("((a:b) and (c:d))"), "(a: b) and (c: d)");
assert_eq!(supports("(a:b) and ((c:d))"), "(a: b) and (c: d)");
assert_eq!(supports("not ((a:b))"), "not (a: b)");
assert_eq!(supports("(display: grid)"), "(display: grid)");
assert_eq!(
supports("((a:b) or (c:d)) and (e:f)"),
"((a: b) or (c: d)) and (e: f)"
);
assert_eq!(
supports("(a:b) and ((c:d) or (e:f))"),
"(a: b) and ((c: d) or (e: f))"
);
assert_eq!(supports("not ((a:b) and (c:d))"), "not ((a: b) and (c: d))");
assert_eq!(
supports("(a:b) and (c:d) and (e:f)"),
"(a: b) and (c: d) and (e: f)"
);
assert_eq!(supports("selector(:has(a))"), "selector(:has(a))");
assert_eq!(supports("foo(bar)"), "foo(bar)");
let mut eval = Evaluator::new(1);
let err = supports_prelude("(a:1) and (b:2) or (c:3)", &mut eval, 0).unwrap_err();
assert_eq!(err.1, "Expected \"and\".");
}
#[test]
fn pseudo_parent_selector_uses_whole_parent_list() {
let parents = [
Sel {
text: ".a".into(),
line_break: false,
},
Sel {
text: ".b".into(),
line_break: false,
},
];
let one = |child: &str| {
resolve_selector(child, &parents, false)
.into_iter()
.map(|s| s.text)
.collect::<Vec<_>>()
};
assert_eq!(one(":not(&)"), [":not(.a, .b)"]);
assert_eq!(one(":has(> &)"), [":has(> .a, > .b)"]);
assert_eq!(one(":nth-child(2n of &)"), [":nth-child(2n of .a, .b)"]);
assert_eq!(one(":not(&, .x)"), [":not(.a, .x, .b)"]);
assert_eq!(
one(":not(& + &)"),
[":not(.a + .a, .a + .b, .b + .a, .b + .b)"]
);
assert_eq!(one(":not(:is(&))"), [":not(:is(.a, .b))"]);
assert_eq!(one("&:not(&)"), [".a:not(.a, .b)", ".b:not(.a, .b)"]);
assert_eq!(one(":not(&) &"), [":not(.a, .b) .a", ":not(.a, .b) .b"]);
assert_eq!(
one(".c & :not(&)"),
[".c .a :not(.a, .b)", ".c .b :not(.a, .b)"]
);
}
#[test]
fn compiled_css_never_contains_parent_selector() {
let source = r#"
.a { :not(&) { x: 1 } }
.a, .b { :has(> &) { x: 2 } &:not(&) { x: 3 } }
.a, .b { :is(&) & { x: 4 } }
.a { .b { :not(&) :is(&) { x: 5 } } }
.a, .b { @media screen { :not(&) { x: 6 } } }
"#;
let out = compile(Path::new("t.scss"), source, &Options::default()).expect("compile");
assert!(!out.css.contains('&'), "产物残留 `&`:\n{}", out.css);
}
#[test]
fn builtin_modules() {
let css = |source: &str| {
compile(Path::new("t.scss"), source, &Options::default())
.expect("compile")
.css
};
assert_eq!(
css("@use \"sass:math\";\na { b: math.div(1, 2) }"),
"a {\n b: 0.5;\n}"
);
assert_eq!(
css("@use \"sass:math\" as m;\na { b: m.$pi }"),
"a {\n b: 3.1415926536;\n}"
);
assert_eq!(
css("@use \"sass:string\" as *;\na { b: index(\"abc\", \"b\"); c: length(\"abc\") }"),
"a {\n b: 2;\n c: 3;\n}"
);
assert!(
compile(
Path::new("t.scss"),
"@use \"sass:math\";\n@use \"sass:math\";\na { b: 1 }",
&Options::default()
)
.is_err()
);
}
#[test]
fn builtin_module_errors() {
let err = |source: &str| {
compile(Path::new("t.scss"), source, &Options::default())
.unwrap_err()
.message
};
assert_eq!(
err("@use \"sass:math\";\na { b: math.nope(1) }"),
"Undefined function."
);
assert_eq!(
err("@use \"sass:color\";\na { b: color.lighten(#123, 10%) }"),
"The function lighten() isn't in the sass:color module."
);
assert_eq!(
err("@use \"sass:map\";\na { b: map.keys(1) }"),
"$map: 1 is not a map."
);
assert_eq!(
err("@use \"sass:string\";\na { b: string.quote(a b) }"),
"$string: a b is not a string."
);
}
#[test]
fn builtin_module_quirks() {
let css = |source: &str| {
compile(Path::new("t.scss"), source, &Options::default())
.expect("compile")
.css
};
assert_eq!(
css("@use \"sass:map\" as m;\na { b: inspect(m.deep-merge((a: 1, b: 2), (c: 3))) }"),
"a {\n b: (c: 3, a: 1, b: 2);\n}"
);
assert_eq!(
css("@use \"sass:list\" as l;\na { b: l.slash(1, 2); c: 1/2 }"),
"a {\n b: 1 / 2;\n c: 1/2;\n}"
);
}
#[test]
fn builtin_module_modern_members() {
let css = |source: &str| {
compile(Path::new("t.scss"), source, &Options::default())
.expect("compile")
.css
};
assert_eq!(
css(
"@use \"sass:color\" as c;\na { b: c.space(#036); c: c.space(oklch(0.5 0.1 20)); d: c.is-legacy(#036); e: c.is-legacy(oklch(0.5 0.1 20)) }"
),
"a {\n b: rgb;\n c: oklch;\n d: true;\n e: false;\n}"
);
assert_eq!(
css(
"@use \"sass:color\" as c;\na { b: c.channel(#123456, \"red\"); c: c.channel(hsl(120, 50%, 50%), \"hue\"); d: c.channel(oklch(0.5 0.1 20), \"chroma\") }"
),
"a {\n b: 18;\n c: 120deg;\n d: 0.1;\n}"
);
assert_eq!(
css(
"@use \"sass:color\" as c;\na { b: c.to-space(#036, oklch); c: c.to-gamut(oklch(0.7 0.4 30), $method: clip) }"
),
"a {\n b: oklch(32.333781389% 0.1025438139 253.8851819964deg);\n c: oklch(70% 0.4 30deg);\n}"
);
assert_eq!(
css(
"@use \"sass:color\" as c;\na { b: c.is-in-gamut(oklch(0.7 0.4 30), $space: rgb); c: c.is-powerless(hsl(120, 0%, 50%), \"hue\") }"
),
"a {\n b: false;\n c: true;\n}"
);
assert_eq!(
css(
"@use \"sass:meta\" as m;\n$x: calc(1px + 2%);\na { b: m.calc-name($x); c: m.calc-args($x); d: m.calc-args(min(1px, 2%)) }"
),
"a {\n b: \"calc\";\n c: 1px + 2%;\n d: 1px, 2%;\n}"
);
assert_eq!(
css(
"@use \"sass:string\" as s;\na { b: s.split(\"a-b-c\", \"-\", 2); c: s.split(\"abc\", \"\") }"
),
"a {\n b: [\"a\", \"b\", \"c\"];\n c: [\"a\", \"b\", \"c\"];\n}"
);
assert_eq!(
css("@use \"sass:math\" as m;\na { b: m.$epsilon; c: m.$max-safe-integer }"),
"a {\n b: 0;\n c: 9007199254740991;\n}"
);
}
#[test]
fn prelude_escapes() {
assert_eq!(
normalize_prelude("screen and (min-width: 0\\0 )"),
"screen and (min-width: 0\\0 )"
);
assert_eq!(
normalize_prelude("screen and (min-width: 0\\0)"),
"screen and (min-width: 0\\0 )"
);
assert_eq!(
normalize_prelude("(min-width: 4\\25 )"),
"(min-width: 4\\%)"
);
assert_eq!(normalize_prelude("(a\\61 b)"), "(aab)");
assert_eq!(normalize_prelude("(a\\61 b)"), "(aa b)");
assert_eq!(normalize_prelude("(x\\.y)"), "(x\\.y)");
assert_eq!(normalize_prelude("(x\\9)"), "(x\\9 )");
}
}