struct Line {
text: String,
code: usize,
appendable: bool,
}
impl Line {
fn new(text: String, code: usize) -> Self {
Self {
text,
code,
appendable: true,
}
}
fn push_code(&mut self, s: &str) {
self.text.insert_str(self.code, s);
self.code += s.len();
}
}
#[derive(Clone, Copy, PartialEq)]
enum Kind {
AtRule,
MixinDef,
Include,
Decl,
Selector,
Comment,
}
struct Pending {
indent: usize,
kind: Kind,
}
struct OpenComment {
indent: usize,
last: usize,
}
struct Block {
indent: usize,
line: usize,
}
#[derive(Default)]
struct Scan {
quote: Option<u8>,
paren: i32,
in_block_comment: bool,
}
struct ScanOut {
code: usize,
complete: bool,
}
#[doc(hidden)]
pub fn to_scss(src: &str) -> String {
let lines: Vec<&str> = src.split('\n').collect();
let blank: Vec<bool> = lines.iter().map(|l| l.trim().is_empty()).collect();
let indents: Vec<usize> = lines
.iter()
.map(|l| l.len() - l.trim_start_matches([' ', '\t']).len())
.collect();
let mut next: Vec<Option<usize>> = vec![None; lines.len()];
let mut upcoming = None;
for i in (0..lines.len()).rev() {
next[i] = upcoming;
if !blank[i] {
upcoming = Some(indents[i]);
}
}
let mut out: Vec<Line> = Vec::with_capacity(lines.len());
let mut blocks: Vec<Block> = Vec::new();
let mut last_appendable: Option<usize> = None;
let mut pending: Option<Pending> = None;
let mut line_comment: Option<usize> = None;
let mut block_comment: Option<OpenComment> = None;
let mut scan = Scan::default();
for i in 0..lines.len() {
let raw = lines[i];
let indent = indents[i];
if block_comment
.as_ref()
.is_some_and(|c| !blank[i] && indent <= c.indent)
{
if let Some(c) = block_comment.take() {
let target = &mut out[c.last];
let at = target.code;
target.text.insert_str(at, " */");
target.code += 3;
}
scan.in_block_comment = false;
}
if let Some(base) = line_comment {
if !blank[i] && indent <= base {
line_comment = None;
} else {
let (text, code) = if blank[i] {
(raw.to_string(), raw.len())
} else {
(format!("{}// {}", &raw[..indent], &raw[indent..]), indent)
};
let mut line = Line::new(text, code);
line.appendable = false;
out.push(line);
continue;
}
}
if blank[i] {
out.push(Line::new(raw.to_string(), raw.len()));
last_appendable = Some(out.len() - 1);
continue;
}
if scan.in_block_comment {
let done = scan_line(raw, &mut scan);
out.push(Line {
text: raw.to_string(),
code: done.code,
appendable: false,
});
if scan.in_block_comment {
if let Some(c) = block_comment.as_mut() {
c.last = out.len() - 1;
}
} else {
block_comment = None;
}
if done.complete && pending.is_some() {
let line = out.len() - 1;
finish_statement(line, &mut out, &mut pending, &mut blocks, next[i]);
last_appendable = Some(line);
}
continue;
}
if pending.is_none() {
let kind = kind_of(&raw[indent..]);
if kind == Kind::Comment {
let done = scan_line(raw, &mut scan);
out.push(Line {
text: raw.to_string(),
code: done.code,
appendable: false,
});
if raw[indent..].starts_with("//") {
line_comment = Some(indent);
} else if scan.in_block_comment {
block_comment = Some(OpenComment {
indent,
last: out.len() - 1,
});
}
continue;
}
while blocks.last().is_some_and(|b| b.indent >= indent) {
close_block(&mut out, &mut blocks, &mut last_appendable);
}
pending = Some(Pending { indent, kind });
}
let kind = pending.as_ref().map_or(Kind::Selector, |p| p.kind);
let stmt_indent = pending.as_ref().map_or(indent, |p| p.indent);
let done = scan_line(raw, &mut scan);
let mut line = Line::new(raw.to_string(), done.code);
if indent < done.code {
let leading = &raw.as_bytes()[indent..];
match (kind, leading.first()) {
(Kind::MixinDef, Some(b'=')) => {
line.text.replace_range(indent..indent + 1, "@mixin ");
line.code += "@mixin ".len() - 1;
}
(Kind::Include, Some(b'+')) => {
line.text.replace_range(indent..indent + 1, "@include ");
line.code += "@include ".len() - 1;
}
(Kind::AtRule, Some(b'@')) => {
quote_import(&mut line, indent);
}
_ => {}
}
}
line.appendable = !scan.in_block_comment;
let appendable = line.appendable;
let code = line.code;
out.push(line);
if appendable {
last_appendable = Some(out.len() - 1);
}
if !done.complete || continues(&out[out.len() - 1].text[..code], kind, next[i], stmt_indent)
{
continue;
}
let line = out.len() - 1;
finish_statement(line, &mut out, &mut pending, &mut blocks, next[i]);
last_appendable = Some(line);
}
while !blocks.is_empty() {
close_block(&mut out, &mut blocks, &mut last_appendable);
}
let mut text = out
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
if src.ends_with('\n') && !text.ends_with('\n') {
text.push('\n');
}
text
}
#[doc(hidden)]
pub fn fold_selector_continuations(src: &str) -> String {
let lines: Vec<&str> = src.split('\n').collect();
let mut out: Vec<String> = Vec::with_capacity(lines.len());
let mut scan = Scan::default();
let mut i = 0;
while i < lines.len() {
let raw = lines[i];
let done = scan_line(raw, &mut scan);
let trimmed = raw[..done.code].trim();
let foldable = !trimmed.is_empty()
&& !trimmed.starts_with("//")
&& !trimmed.starts_with("/*")
&& !scan.in_block_comment
&& scan.paren == 0
&& trimmed.ends_with(',')
&& kind_of(trimmed) == Kind::Selector;
if !foldable {
out.push(raw.to_string());
i += 1;
continue;
}
let mut joined = raw.trim_end().to_string();
i += 1;
while let Some(j) = (i..lines.len()).find(|&j| !lines[j].trim().is_empty()) {
let next = lines[j].trim();
joined.push(' ');
joined.push_str(next);
i = j + 1;
if !next.ends_with(',') {
break;
}
}
out.push(joined);
}
out.join("\n")
}
pub(crate) fn parse_error(
src: &str,
converted: &str,
at: usize,
kind: oxc_css_parser::error::ErrorKind,
) -> (usize, String) {
if let Some(brace) = stray_brace(src) {
return (brace, "Expected newline.".to_string());
}
let index = crate::LineIndex::new(converted);
let (line, col) = index.line_col(at, converted);
let (line, col) = (line as usize, col as usize);
if let Some((off, message)) = trailing_comma_error(src, line) {
return (off, message);
}
(
source_line_end(src, line, col),
format!("SCSS parse failure: {kind}"),
)
}
fn source_line_end(src: &str, line: usize, col: usize) -> usize {
let mut start = 0;
for (i, raw) in src.split('\n').enumerate() {
if i == line {
return start + col.min(raw.trim_end().len());
}
start += raw.len() + 1;
}
src.len()
}
fn trailing_comma_error(src: &str, fail_line: usize) -> Option<(usize, String)> {
let lines: Vec<&str> = src.split('\n').collect();
let indent_of = |i: usize| lines[i].len() - lines[i].trim_start_matches([' ', '\t']).len();
let next_line = |i: usize| (i + 1..lines.len()).find(|&j| !lines[j].trim().is_empty());
let line_start = |i: usize| lines[..i].iter().map(|l| l.len() + 1).sum::<usize>();
let mut scan = Scan::default();
let mut offset = 0usize;
let mut fallback: Option<(usize, String)> = None;
for (i, raw) in lines.iter().enumerate() {
let start = offset;
offset += raw.len() + 1;
let done = scan_line(raw, &mut scan);
let code = &raw[..done.code];
let trimmed = code.trim();
if trimmed.is_empty()
|| trimmed.starts_with("//")
|| trimmed.starts_with("/*")
|| scan.in_block_comment
|| !trimmed.ends_with(',')
|| scan.paren != 0
{
continue;
}
let comma = start + code.trim_end().len();
let deeper = next_line(i).is_some_and(|j| indent_of(j) > indent_of(i));
let (at, message) = if trimmed.starts_with('$') {
let at = next_line(i).map_or(comma, |j| line_start(j) + indent_of(j));
(
at,
"Nothing may be indented beneath a variable declaration.",
)
} else if kind_of(trimmed) == Kind::AtRule || deeper {
(comma, "Expected identifier.")
} else {
(comma, "expected selector.")
};
let message = message.to_string();
if i <= fail_line {
fallback = Some((at, message));
} else {
return fallback.or(Some((at, message)));
}
}
fallback
}
pub(crate) fn stray_brace(text: &str) -> Option<usize> {
let bytes = text.as_bytes();
let mut i = 0;
let mut quote: Option<u8> = None;
let mut comment = false;
while i < bytes.len() {
let c = bytes[i];
if comment {
if c == b'*' && bytes.get(i + 1) == Some(&b'/') {
comment = false;
i += 2;
} else {
i += 1;
}
continue;
}
if let Some(q) = quote {
match c {
b'\\' => i += 2,
c if c == q => {
quote = None;
i += 1;
}
_ => i += 1,
}
continue;
}
match c {
b'\'' | b'"' => {
quote = Some(c);
i += 1;
}
b'/' if bytes.get(i + 1) == Some(&b'/') => {
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
}
b'/' if bytes.get(i + 1) == Some(&b'*') => {
comment = true;
i += 2;
}
b'#' if bytes.get(i + 1) == Some(&b'{') => i += 2,
b'{' => return Some(i),
_ => i += 1,
}
}
None
}
fn quote_import(line: &mut Line, indent: usize) {
let body = &line.text[indent..line.code];
let Some(rest) = body.strip_prefix("@import") else {
return;
};
if rest.contains(['"', '\'']) || rest.contains("url(") {
return;
}
let quoted = rest
.split(',')
.map(|part| {
let part = part.trim();
if part.is_empty() {
String::new()
} else {
format!("\"{part}\"")
}
})
.collect::<Vec<_>>()
.join(", ");
if quoted.is_empty() {
return;
}
line.text
.replace_range(indent + "@import".len()..line.code, &format!(" {quoted}"));
line.code = indent + "@import".len() + 1 + quoted.len();
}
fn finish_statement(
line: usize,
out: &mut [Line],
pending: &mut Option<Pending>,
blocks: &mut Vec<Block>,
next_indent: Option<usize>,
) {
let Some(p) = pending.take() else { return };
if next_indent.is_some_and(|n| n > p.indent) {
out[line].push_code("{");
blocks.push(Block {
indent: p.indent,
line,
});
return;
}
let head = out[line].text.get(..out[line].code).unwrap_or_default();
let needs_block =
matches!(p.kind, Kind::Selector | Kind::MixinDef) || needs_block_at_rule(head);
out[line].push_code(if needs_block { "{}" } else { ";" });
}
fn needs_block_at_rule(head: &str) -> bool {
let Some(name) = head.trim_start().strip_prefix('@') else {
return false;
};
let name = name
.split(|c: char| !c.is_ascii_alphanumeric() && c != '-')
.next()
.unwrap_or("");
matches!(
name.to_ascii_lowercase().as_str(),
"media"
| "supports"
| "if"
| "else"
| "each"
| "for"
| "while"
| "mixin"
| "function"
| "at-root"
| "layer"
| "container"
| "scope"
| "document"
| "page"
| "property"
| "starting-style"
| "keyframes"
| "font-face"
)
}
fn close_block(out: &mut [Line], blocks: &mut Vec<Block>, last_appendable: &mut Option<usize>) {
let Some(block) = blocks.pop() else { return };
let target = match *last_appendable {
Some(t) if t >= block.line => t,
_ => block.line,
};
out[target].push_code("}");
*last_appendable = Some(target);
}
fn continues(code: &str, kind: Kind, next_indent: Option<usize>, stmt_indent: usize) -> bool {
let code = code.trim_end();
let Some(&last) = code.as_bytes().last() else {
return false;
};
if kind == Kind::Selector {
return match last {
b',' => next_indent.is_some_and(|n| n >= stmt_indent),
b'>' | b'+' | b'~' => {
code.trim().len() == 1 && next_indent.is_some_and(|n| n >= stmt_indent)
}
_ => false,
};
}
if !next_indent.is_some_and(|n| n > stmt_indent) {
return false;
}
match last {
b'+' | b'*' | b'/' | b'=' | b'>' | b'<' => true,
b'-' => code
.as_bytes()
.get(code.len().wrapping_sub(2))
.is_some_and(|c| c.is_ascii_whitespace()),
_ => {
if !matches!(kind, Kind::AtRule | Kind::Decl) {
return false;
}
let word = code
.rsplit(|c: char| !c.is_ascii_alphanumeric())
.next()
.unwrap_or("");
matches!(
word,
"and" | "or" | "not" | "in" | "from" | "through" | "to"
) && code.ends_with(&format!(" {word}"))
}
}
}
fn scan_line(raw: &str, st: &mut Scan) -> ScanOut {
let bytes = raw.as_bytes();
let mut i = 0;
let mut code = raw.len();
let mut comment_at: Option<usize> = None;
while i < bytes.len() {
if st.in_block_comment {
if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') {
st.in_block_comment = false;
i += 2;
} else {
i += 1;
}
continue;
}
let c = bytes[i];
if let Some(q) = st.quote {
if c == b'\\' {
i += 2;
continue;
}
if c == q {
st.quote = None;
}
i += 1;
continue;
}
match c {
b'\'' | b'"' => {
st.quote = Some(c);
i += 1;
}
b'\\' => i += 2,
b'/' if bytes.get(i + 1) == Some(&b'/') && st.paren == 0 => {
code = raw[..i].trim_end().len();
break;
}
b'/' if bytes.get(i + 1) == Some(&b'*') => match raw[i + 2..].find("*/") {
Some(rel) => i = i + 2 + rel + 2,
None => {
st.in_block_comment = true;
comment_at.get_or_insert(raw[..i].trim_end().len());
i = bytes.len();
}
},
b'(' | b'[' => {
st.paren += 1;
i += 1;
}
b')' | b']' => {
st.paren = (st.paren - 1).max(0);
i += 1;
}
b'#' if bytes.get(i + 1) == Some(&b'{') => {
st.paren += 1;
i += 2;
}
b'}' => {
st.paren = (st.paren - 1).max(0);
i += 1;
}
_ => i += 1,
}
}
if st.in_block_comment {
if let Some(at) = comment_at {
code = at;
}
}
ScanOut {
code,
complete: st.quote.is_none() && st.paren <= 0 && !st.in_block_comment,
}
}
fn kind_of(body: &str) -> Kind {
let bytes = body.as_bytes();
match bytes.first() {
None => Kind::Selector,
Some(b'@') => Kind::AtRule,
Some(b'=') => Kind::MixinDef,
Some(b'+') if bytes.get(1).is_some_and(|c| is_name_start(*c)) => Kind::Include,
Some(b'/') if matches!(bytes.get(1), Some(b'/') | Some(b'*')) => Kind::Comment,
_ => match first_top_level_colon(body) {
Some(at) if bytes.get(at + 1).is_none_or(|c| c.is_ascii_whitespace()) => Kind::Decl,
_ => Kind::Selector,
},
}
}
fn is_name_start(c: u8) -> bool {
c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || c >= 0x80
}
fn first_top_level_colon(body: &str) -> Option<usize> {
let bytes = body.as_bytes();
let mut i = 0;
let mut paren = 0i32;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let c = bytes[i];
if let Some(q) = quote {
match c {
b'\\' => i += 2,
c if c == q => {
quote = None;
i += 1;
}
_ => i += 1,
}
continue;
}
match c {
b'\'' | b'"' => {
quote = Some(c);
i += 1;
}
b'\\' => i += 2,
b'(' | b'[' => {
paren += 1;
i += 1;
}
b')' | b']' => {
paren -= 1;
i += 1;
}
b'#' if bytes.get(i + 1) == Some(&b'{') => {
paren += 1;
i += 2;
}
b'}' => {
paren -= 1;
i += 1;
}
b':' if paren == 0 => return Some(i),
_ => i += 1,
}
}
None
}
#[cfg(test)]
mod tests {
use super::to_scss;
#[test]
fn nested_rule_and_decl() {
assert_eq!(to_scss(".a\n color: red\n"), ".a{\n color: red;\n}\n");
}
#[test]
fn mixin_shorthand() {
assert_eq!(
to_scss("=m($p: 1px)\n padding: $p\n.a\n +m(2px)\n"),
"@mixin m($p: 1px){\n padding: $p;}\n.a{\n @include m(2px);\n}\n"
);
}
#[test]
fn selector_comma_continuation_keeps_lines() {
assert_eq!(
to_scss(".a,\n.b\n color: red\n"),
".a,\n.b{\n color: red;\n}\n"
);
}
#[test]
fn declaration_without_space_is_selector() {
assert_eq!(to_scss(".a\n color:red\n"), ".a{\n color:red{}\n}\n");
}
#[test]
fn nested_property_block() {
assert_eq!(
to_scss(".a\n font:\n family: serif\n"),
".a{\n font:{\n family: serif;\n}}\n"
);
}
#[test]
fn if_else_chain() {
assert_eq!(
to_scss("@if $x\n a: 1\n@else\n a: 2\n"),
"@if $x{\n a: 1;}\n@else{\n a: 2;\n}\n"
);
}
#[test]
fn line_comment_block_swallows_deeper_lines() {
assert_eq!(
to_scss(".a\n color: red\n // note\n deeper\n.b\n color: blue\n"),
".a{\n color: red;}\n // note\n // deeper\n.b{\n color: blue;\n}\n"
);
}
#[test]
fn trailing_comment_keeps_terminator_before_it() {
assert_eq!(
to_scss(".a\n color: red // note\n background: blue\n"),
".a{\n color: red; // note\n background: blue;\n}\n"
);
}
#[test]
fn operator_continuation() {
assert_eq!(
to_scss("$x: 1 +\n 2\n.a\n width: $x\n"),
"$x: 1 +\n 2;\n.a{\n width: $x;\n}\n"
);
}
#[test]
fn complete_statement_never_continues_on_dedent() {
assert_eq!(
to_scss("@use \"a/star\" as *\n.a\n color: red\n"),
"@use \"a/star\" as *;\n.a{\n color: red;\n}\n"
);
}
#[test]
fn blank_lines_are_preserved() {
assert_eq!(
to_scss(".a\n color: red\n\n background: blue\n"),
".a{\n color: red;\n\n background: blue;\n}\n"
);
}
#[test]
fn block_comment_closed_by_dedent() {
assert_eq!(
to_scss("/* a\n b\n.c\n color: red\n"),
"/* a\n b */\n.c{\n color: red;\n}\n"
);
}
#[test]
fn keyframe_selectors_are_not_keyword_continuations() {
assert_eq!(
to_scss("@keyframes s\n from\n opacity: 0\n to\n opacity: 1\n"),
"@keyframes s{\n from{\n opacity: 0;}\n to{\n opacity: 1;\n}}\n"
);
}
#[test]
fn slashes_inside_parens_are_not_comments() {
assert_eq!(
to_scss(".a\n background: url(http://x/y.png)\n"),
".a{\n background: url(http://x/y.png);\n}\n"
);
}
#[test]
fn bodyless_statement_gets_empty_block_or_semicolon() {
assert_eq!(
to_scss(".gone\n.kept\n color: red\n"),
".gone{}\n.kept{\n color: red;\n}\n"
);
assert_eq!(
to_scss("@import \"a\"\n.b\n color: red\n"),
"@import \"a\";\n.b{\n color: red;\n}\n"
);
}
#[test]
fn declaration_value_continues_on_keyword() {
assert_eq!(
to_scss("$a: true and\n false\n.b\n c: 1\n"),
"$a: true and\n false;\n.b{\n c: 1;\n}\n"
);
}
}