use crate::ast::Stylesheet;
use crate::error::Error;
use crate::scanner::Pos;
pub(crate) fn parse(src: &str) -> Result<Stylesheet, Error> {
let scss = Transpiler::new(src).run()?;
crate::parser::parse(&scss)
}
struct Raw {
line: usize,
indent: usize,
indent_str: String,
content: String,
}
struct Transpiler {
lines: Vec<Raw>,
idx: usize,
out: String,
in_css_callable: bool,
}
fn is_ident_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '-' || c == '_' || (c as u32) >= 0x80
}
impl Transpiler {
fn new(src: &str) -> Self {
let normalized = src.replace("\r\n", "\n").replace(['\r', '\u{c}'], "\n");
let mut lines = Vec::new();
for (i, physical) in normalized.split('\n').enumerate() {
let mut indent = 0usize;
let mut indent_str = String::new();
let mut rest = physical;
for (b, ch) in physical.char_indices() {
if ch == ' ' || ch == '\t' {
indent += 1;
indent_str.push(ch);
} else {
rest = &physical[b..];
break;
}
rest = &physical[b + ch.len_utf8()..];
}
lines.push(Raw {
line: i + 1,
indent,
indent_str,
content: rest.to_string(),
});
}
Transpiler {
lines,
idx: 0,
out: String::new(),
in_css_callable: false,
}
}
fn is_blank(&self, i: usize) -> bool {
self.lines
.get(i)
.map(|l| l.content.trim().is_empty())
.unwrap_or(true)
}
fn run(mut self) -> Result<String, Error> {
self.parse_block(0, 0)?;
Ok(self.out)
}
fn next_nonblank(&self, from: usize) -> Option<usize> {
let mut i = from;
while i < self.lines.len() {
if !self.is_blank(i) {
return Some(i);
}
i += 1;
}
None
}
fn parse_block(&mut self, block_indent: usize, _parent_indent: usize) -> Result<(), Error> {
loop {
let Some(i) = self.next_nonblank(self.idx) else {
self.idx = self.lines.len();
break;
};
self.idx = i;
let indent = self.lines[i].indent;
if indent < block_indent {
break;
}
if indent > block_indent {
return Err(Error::at(
"This line was indented unexpectedly.".to_string(),
Pos {
line: self.lines[i].line,
col: indent + 1,
},
));
}
self.parse_statement(block_indent)?;
}
Ok(())
}
fn parse_statement(&mut self, indent: usize) -> Result<(), Error> {
let start = self.idx;
let line_no = self.lines[start].line;
let trimmed = self.lines[start].content.trim_start().to_string();
if trimmed.starts_with("//") {
self.idx += 1;
self.consume_child_block(indent);
return Ok(());
}
if trimmed.starts_with("/*") {
return self.parse_loud_comment(indent);
}
if trimmed.starts_with("--") {
if let Some(()) = self.try_parse_custom_property_stmt(indent, line_no)? {
return Ok(());
}
}
let (mut logical, child_indent) = self.assemble_logical_line(indent)?;
if logical.starts_with('\\') {
logical.remove(0);
self.out.push_str(&logical);
if !self.parse_child_into_braces(indent)? {
self.out.push_str(" {}\n");
}
return Ok(());
}
if let Some(rest) = logical.strip_prefix('=') {
logical = format!("@mixin {}", rest.trim_start());
} else if matches!(logical.strip_prefix('+'), Some(r) if r.starts_with(|c: char| is_ident_char(c) || c == '#' || c == '\\'))
{
let rest = logical[1..].trim_start();
logical = format!("@include {rest}");
}
self.extend_directive_prelude(&mut logical, indent)?;
if directive_name(&logical).as_deref() == Some("import") {
logical = quote_import_urls(&logical);
}
self.emit_statement(&logical, child_indent, indent, line_no)
}
fn consume_child_block(&mut self, indent: usize) {
while let Some(i) = self.next_nonblank(self.idx) {
if self.lines[i].indent <= indent {
break;
}
self.idx = i + 1;
}
}
fn parse_loud_comment(&mut self, indent: usize) -> Result<(), Error> {
let start = self.idx;
let line_no = self.lines[start].line;
let mut content = self.lines[start].content.trim_start().to_string();
let mut first_line_end = start + 1;
while interp_open_anywhere(&content) && first_line_end < self.lines.len() {
content.push('\n');
content.push_str(&self.lines[first_line_end].indent_str);
content.push_str(&self.lines[first_line_end].content);
first_line_end += 1;
}
if let Some(end) = content.find("*/") {
let after = content[end + 2..].trim_start();
if !after.is_empty() && !after.starts_with("//") && !after.starts_with("/*") {
return Err(Error::at(
"expected expression.".to_string(),
Pos {
line: line_no,
col: indent + end + 3,
},
));
}
self.out.push_str(&content[..end + 2]);
self.out.push('\n');
self.idx = first_line_end;
if let Some(i) = self.next_nonblank(self.idx) {
if self.lines[i].indent > indent {
return Err(Error::at(
"This line was indented unexpectedly.".to_string(),
Pos {
line: self.lines[i].line,
col: self.lines[i].indent + 1,
},
));
}
}
return Ok(());
}
let mut content_lines: Vec<Option<(usize, String)>> = Vec::new();
let first = content.trim_start_matches("/*");
if !first.trim().is_empty() {
content_lines.push(Some((3, first.trim_start().to_string())));
}
self.idx = first_line_end;
let mut closed = false;
let mut pending_blanks = 0usize;
while self.idx < self.lines.len() {
let l = &self.lines[self.idx];
if l.content.trim().is_empty() {
pending_blanks += 1;
self.idx += 1;
continue;
}
if l.indent <= indent {
break;
}
for _ in 0..pending_blanks {
content_lines.push(None);
}
pending_blanks = 0;
let body = l.content.to_string();
content_lines.push(Some((l.indent, body.clone())));
self.idx += 1;
if body.contains("*/") {
closed = true;
break;
}
}
if content_lines.is_empty() {
content_lines.push(Some((3, String::new())));
}
if !closed {
if let Some(Some((_, last))) = content_lines.last_mut() {
if last.is_empty() {
*last = "*/".to_string();
} else {
last.push_str(" */");
}
}
}
for (i, line) in content_lines.iter().enumerate() {
match line {
Some((col, text)) => {
let pad = col.max(&3) - 2;
if i == 0 {
self.out.push_str("/*");
} else {
self.out.push_str(" *");
}
for _ in 0..pad {
self.out.push(' ');
}
self.out.push_str(text);
}
None => self.out.push_str(" *"),
}
self.out.push('\n');
}
Ok(())
}
fn assemble_logical_line(&mut self, indent: usize) -> Result<(String, usize), Error> {
let start = self.idx;
let mut logical = strip_silent_comment(self.lines[start].content.trim_start());
self.idx = start + 1;
let comma_continues =
!logical.starts_with('@') && !logical.starts_with('$') && find_decl_colon(&logical).is_none();
let op_continues =
!logical.starts_with('@') && !logical.starts_with('$') && find_decl_colon(&logical).is_some();
loop {
if continuation_pending(&logical, comma_continues)
|| (op_continues && ends_with_value_operator(&logical))
{
if self.idx >= self.lines.len() {
break;
}
let next_content = self.lines[self.idx].content.clone();
let next_indent_str = self.lines[self.idx].indent_str.clone();
let st = scan_state(&logical);
let verbatim = st.in_interp || st.in_loud_comment || st.in_string;
if next_content.trim().is_empty() {
if st.bracket_depth > 0 || verbatim {
logical.push('\n');
self.idx += 1;
continue;
}
break;
}
let joined = if verbatim {
next_content.clone()
} else {
strip_silent_comment(&next_content)
};
if logical.ends_with('\\') {
logical.pop();
if st.in_string {
logical.push_str(&next_indent_str);
logical.push_str(&joined);
} else {
logical.push(' ');
logical.push_str(joined.trim_start());
}
} else {
logical.push('\n');
logical.push_str(&next_indent_str);
logical.push_str(&joined);
}
self.idx += 1;
continue;
}
break;
}
Ok((logical, indent))
}
fn extend_directive_prelude(&mut self, logical: &mut String, _indent: usize) -> Result<(), Error> {
if !prelude_can_span(logical) {
return Ok(());
}
while prelude_incomplete(logical) {
let Some(i) = self.next_nonblank(self.idx) else {
break;
};
let piece = strip_silent_comment(self.lines[i].content.trim_start());
self.idx = i + 1;
if piece.is_empty() {
continue;
}
if !logical.is_empty() && !logical.ends_with(char::is_whitespace) {
logical.push(' ');
}
logical.push_str(&piece);
let comma_continues = matches!(directive_name(logical).as_deref(), Some("use" | "forward"));
while continuation_pending(logical, comma_continues) {
let Some(j) = self.next_nonblank(self.idx) else {
break;
};
let cont = strip_silent_comment(self.lines[j].content.trim_start());
self.idx = j + 1;
logical.push(' ');
logical.push_str(&cont);
}
}
Ok(())
}
fn parse_child_into_braces(&mut self, indent: usize) -> Result<bool, Error> {
let Some(i) = self.next_nonblank(self.idx) else {
return Ok(false);
};
let child_indent = self.lines[i].indent;
if child_indent <= indent {
return Ok(false);
}
self.out.push_str(" {\n");
self.idx = i;
self.parse_block(child_indent, indent)?;
self.out.push_str("}\n");
Ok(true)
}
fn emit_statement(
&mut self,
logical: &str,
_child_indent: usize,
indent: usize,
line_no: usize,
) -> Result<(), Error> {
let mut logical = logical.trim();
let mut explicit_semicolon = false;
if let Some(semi) = find_top_level_semicolon(logical) {
let after = trim_trailing_loud_comments(logical[semi + 1..].trim());
if !after.is_empty() {
let col = logical[..semi].chars().count();
return Err(Error::at(
"multiple statements on one line are not supported in the indented syntax.".to_string(),
Pos {
line: line_no,
col: indent + col + 1,
},
));
}
logical = logical[..semi].trim_end();
explicit_semicolon = true;
}
if let Some(name) = forbids_indented_child(logical) {
if let Some(i) = self.next_nonblank(self.idx) {
if self.lines[i].indent > indent {
return Err(Error::at(
format!("Nothing may be indented beneath a @{name} rule."),
Pos {
line: self.lines[i].line,
col: self.lines[i].indent + 1,
},
));
}
}
}
let is_decl_with_value = !logical.starts_with('@')
&& !logical.starts_with('$')
&& !logical.starts_with("--")
&& find_decl_colon(logical).is_some_and(|c| {
let after = &logical[c + 1..];
after.starts_with(char::is_whitespace) && !after.trim().is_empty()
});
if is_decl_with_value {
if let Some(i) = self.next_nonblank(self.idx) {
if self.lines[i].indent > indent {
let child_indent = self.lines[i].indent;
let mut j = i;
while j < self.lines.len() {
let l = &self.lines[j];
if l.content.trim().is_empty() {
j += 1;
continue;
}
if l.indent < child_indent {
break;
}
if l.indent == child_indent {
let t = l.content.trim_start();
if !t.starts_with("//") && !t.starts_with("/*") && find_decl_colon(t).is_none() {
return Err(Error::at(
"expected \":\".".to_string(),
Pos {
line: l.line,
col: l.content.trim_end().chars().count() + 1,
},
));
}
}
j += 1;
}
}
}
}
if self.in_css_callable && !logical.contains("#{") {
if let Some(colon) = find_decl_colon(logical) {
if logical[..colon].trim().eq_ignore_ascii_case("result") {
if let Some(i) = self.next_nonblank(self.idx) {
if self.lines[i].indent > indent {
return Err(Error::at(
"Nothing may be indented beneath a @function result.".to_string(),
Pos {
line: self.lines[i].line,
col: self.lines[i].indent + 1,
},
));
}
}
}
}
}
self.out.push_str(logical);
let css_callable = matches!(directive_name(logical).as_deref(), Some("function" | "mixin"))
&& logical
.trim_start()
.trim_start_matches('@')
.trim_start_matches(|c: char| is_ident_char(c))
.trim_start()
.starts_with("--");
let saved_callable = self.in_css_callable;
if css_callable {
self.in_css_callable = true;
}
let had_block = self.parse_child_into_braces(indent)?;
self.in_css_callable = saved_callable;
if !had_block {
let form = if explicit_semicolon {
EmptyForm::Semicolon
} else {
empty_form(logical)
};
match form {
EmptyForm::Braces => self.out.push_str(" {}\n"),
EmptyForm::Semicolon => self.out.push_str(";\n"),
}
}
Ok(())
}
fn try_parse_custom_property_stmt(
&mut self,
indent: usize,
_line_no: usize,
) -> Result<Option<()>, Error> {
let start = self.idx;
let raw = self.lines[start].content.trim_start().to_string();
let Some(colon) = find_decl_colon(&raw) else {
return Ok(None);
};
let name = raw[..colon].trim_end();
if !name.starts_with("--") || name.len() < 2 || name.contains(char::is_whitespace) {
return Ok(None);
}
self.idx = start + 1;
let mut value = raw[colon + 1..].trim_start().to_string();
while custom_value_open(&value) && self.idx < self.lines.len() {
let l = &self.lines[self.idx];
value.push('\n');
value.push_str(&l.indent_str);
value.push_str(&l.content);
self.idx += 1;
}
if let Some(i) = self.next_nonblank(self.idx) {
if self.lines[i].indent > indent {
return Err(Error::at(
"Nothing may be indented beneath a custom property.".to_string(),
Pos {
line: self.lines[i].line,
col: self.lines[i].indent + 1,
},
));
}
}
self.out.push_str(&self.lines[start].indent_str);
self.out.push_str(name);
self.out.push_str(": ");
self.out.push_str(value.trim_end());
self.out.push_str(";\n");
Ok(Some(()))
}
}
fn directive_name(logical: &str) -> Option<String> {
let t = logical.trim_start();
let rest = t.strip_prefix('@')?;
let name: String = rest.chars().take_while(|c| is_ident_char(*c)).collect();
if name.is_empty() {
None
} else {
Some(name.to_ascii_lowercase())
}
}
fn prelude_can_span(logical: &str) -> bool {
if logical.trim_start().starts_with('$') {
return true;
}
matches!(
directive_name(logical).as_deref(),
Some(
"for"
| "each"
| "if"
| "else"
| "while"
| "function"
| "mixin"
| "include"
| "return"
| "warn"
| "debug"
| "error"
| "extend"
| "use"
| "forward"
| "at-root"
| "content",
)
)
}
fn forbids_indented_child(logical: &str) -> Option<String> {
match directive_name(logical).as_deref() {
Some(
name @ ("import" | "charset" | "return" | "extend" | "error" | "warn" | "debug" | "content"
| "use" | "forward"),
) => Some(name.to_string()),
_ => None,
}
}
fn ends_with_pending_operator(t: &str) -> bool {
let t = t.trim_end();
if t.ends_with('\\') {
return true;
}
for op in ["+", "-", "*", "/", "%", "<", ">", "=", ":"] {
if t.ends_with(op) {
return true;
}
}
let last_word: String = t
.chars()
.rev()
.take_while(|c| is_ident_char(*c))
.collect::<String>()
.chars()
.rev()
.collect();
matches!(
last_word.to_ascii_lowercase().as_str(),
"from"
| "through"
| "to"
| "in"
| "and"
| "or"
| "not"
| "using"
| "as"
| "with"
| "show"
| "hide"
| "if"
)
}
fn prelude_incomplete(logical: &str) -> bool {
if bracket_depth(logical) > 0 {
return true;
}
let t = logical.trim_start();
if t.starts_with('$') {
if ends_with_pending_operator(t) {
return true;
}
return match find_decl_colon(t) {
Some(colon) => t[colon + 1..].trim().is_empty(),
None => true,
};
}
let Some(name) = directive_name(logical) else {
return false;
};
let t = logical.trim_start();
let after_at = &t[1..]; let prelude = after_at.strip_prefix(name.as_str()).unwrap_or(after_at).trim();
if ends_with_pending_operator(prelude) {
return true;
}
match name.as_str() {
"for" => {
let lower = prelude.to_ascii_lowercase();
if !lower.contains(" from ") && !lower.ends_with(" from") {
return !lower.contains("from");
}
let has_bound = lower.contains(" through ") || lower.contains(" to ");
!has_bound
}
"each" => {
let lower = prelude.to_ascii_lowercase();
!(lower.contains(" in ") || lower.ends_with(" in"))
}
"if" | "while" => prelude.is_empty(),
"else" => {
let lower = prelude.to_ascii_lowercase();
lower == "if" || lower.ends_with(" if")
}
"function" | "mixin" => prelude.is_empty(),
"return" | "warn" | "debug" | "error" | "extend" => prelude.is_empty(),
"include" => prelude.is_empty(),
"use" | "forward" if prelude.ends_with(',') => true,
"use" | "forward" | "import" => prelude.is_empty(),
_ => false,
}
}
enum EmptyForm {
Braces,
Semicolon,
}
fn empty_form(logical: &str) -> EmptyForm {
let t = logical.trim_start();
if let Some(rest) = t.strip_prefix('@') {
let name: String = rest
.chars()
.take_while(|c| is_ident_char(*c))
.collect::<String>()
.to_ascii_lowercase();
return match name.as_str() {
"function" | "mixin" | "if" | "else" | "each" | "for" | "while" | "media" | "supports"
| "at-root" | "keyframes" | "-webkit-keyframes" | "-moz-keyframes" | "-o-keyframes"
| "-ms-keyframes" => EmptyForm::Braces,
"include" if has_top_level_using(t) => EmptyForm::Braces,
"include" | "return" | "import" | "use" | "forward" | "extend" | "content" | "warn" | "debug"
| "error" | "charset" => EmptyForm::Semicolon,
_ => EmptyForm::Semicolon,
};
}
if t.starts_with('$') {
return EmptyForm::Semicolon;
}
if let Some(colon) = find_decl_colon(t) {
let value = t[colon + 1..].trim();
let after = &t[colon + 1..];
let ws_after = after.starts_with(char::is_whitespace) || after.is_empty();
if value.is_empty() {
return EmptyForm::Semicolon;
}
if ws_after {
return EmptyForm::Semicolon;
}
return EmptyForm::Braces;
}
EmptyForm::Braces
}
fn continuation_pending(s: &str, comma_continues: bool) -> bool {
let st = scan_state(s);
if st.bracket_depth > 0 || st.in_interp || st.in_loud_comment {
return true;
}
let t = trim_trailing_loud_comments(s);
if t.ends_with('\\') {
return true;
}
if t.ends_with('!') {
return true;
}
comma_continues && t.ends_with(',')
}
fn quote_import_urls(logical: &str) -> String {
let Some(rest) = logical.trim_start().strip_prefix("@import") else {
return logical.to_string();
};
let mut out = String::from("@import ");
let mut first = true;
for part in split_top_level_commas(rest.trim()) {
if !first {
out.push_str(", ");
}
first = false;
let part = part.trim();
let token_end = part.find(char::is_whitespace).unwrap_or(part.len());
let (token, modifiers) = part.split_at(token_end);
let bare = !token.is_empty()
&& !token.starts_with('"')
&& !token.starts_with('\'')
&& !token.contains("#{")
&& !token.to_ascii_lowercase().starts_with("url(");
if bare {
out.push('"');
out.push_str(token);
out.push('"');
} else {
out.push_str(token);
}
out.push_str(modifiers);
}
out
}
struct LineScanner {
cs: Vec<char>,
i: usize,
byte: usize,
ascii: bool,
}
impl LineScanner {
fn new(s: &str) -> Self {
LineScanner {
cs: s.chars().collect(),
i: 0,
byte: 0,
ascii: s.is_ascii(),
}
}
#[inline]
fn done(&self) -> bool {
self.i >= self.cs.len()
}
#[inline]
fn cur(&self) -> char {
self.cs[self.i]
}
#[inline]
fn peek(&self, k: usize) -> Option<char> {
self.cs.get(self.i + k).copied()
}
#[inline]
fn offset(&self) -> usize {
if self.ascii {
self.i
} else {
self.byte
}
}
#[inline]
fn bump(&mut self) {
if !self.ascii {
self.byte += self.cs[self.i].len_utf8();
}
self.i += 1;
}
#[inline]
fn skip_quoted(&mut self) -> bool {
let q = self.cur();
self.bump(); while self.i < self.cs.len() && self.cs[self.i] != q {
if self.cs[self.i] == '\\' && self.i + 1 < self.cs.len() {
self.bump();
}
self.bump();
}
if self.i < self.cs.len() {
self.bump(); true
} else {
false
}
}
#[inline]
fn skip_interp(&mut self) {
self.bump(); self.bump(); let mut depth = 1;
while self.i < self.cs.len() && depth > 0 {
match self.cs[self.i] {
'{' => depth += 1,
'}' => depth -= 1,
_ => {}
}
self.bump();
}
}
#[inline]
fn skip_loud_comment(&mut self) -> bool {
self.bump(); self.bump(); while self.i + 1 < self.cs.len() && !(self.cs[self.i] == '*' && self.cs[self.i + 1] == '/') {
self.bump();
}
if self.i + 1 < self.cs.len() {
self.bump(); self.bump(); true
} else {
false
}
}
}
fn split_top_level_commas(s: &str) -> Vec<String> {
let mut sc = LineScanner::new(s);
let mut parts = Vec::new();
let mut cur = String::new();
let mut depth = 0i32;
while !sc.done() {
match sc.cur() {
'"' | '\'' => {
let start = sc.i;
sc.skip_quoted();
cur.extend(&sc.cs[start..sc.i]);
continue;
}
'(' | '[' => {
depth += 1;
cur.push(sc.cur());
sc.bump();
}
')' | ']' => {
depth -= 1;
cur.push(sc.cur());
sc.bump();
}
',' if depth == 0 => {
parts.push(std::mem::take(&mut cur));
sc.bump();
}
c => {
cur.push(c);
sc.bump();
}
}
}
parts.push(cur);
parts
}
fn ends_with_value_operator(s: &str) -> bool {
let t = trim_trailing_loud_comments(s);
if t.ends_with("==") || t.ends_with("<=") || t.ends_with(">=") || t.ends_with("!=") {
return true;
}
if let Some(rest) = t.strip_suffix('-') {
let prev = rest.chars().next_back();
return !matches!(prev, Some(p) if is_ident_char(p));
}
if let Some(rest) = t.strip_suffix('%') {
let prev = rest.chars().next_back();
return matches!(prev, Some(p) if p.is_whitespace());
}
if let Some(c) = t.chars().next_back() {
if matches!(c, '+' | '*' | '/' | '<' | '>') {
return true;
}
}
let last_word: String = t
.chars()
.rev()
.take_while(|c| is_ident_char(*c))
.collect::<String>()
.chars()
.rev()
.collect();
if last_word.len() < t.trim_end().len() {
let before = &t[..t.len() - last_word.len()];
if !before.ends_with(char::is_whitespace) {
return false;
}
}
matches!(last_word.to_ascii_lowercase().as_str(), "and" | "or" | "not")
}
fn trim_trailing_loud_comments(s: &str) -> &str {
let mut t = s.trim_end();
while t.ends_with("*/") {
let Some(open) = t.rfind("/*") else {
break;
};
if !t[open..].ends_with("*/") || t[open + 2..t.len() - 2].contains("/*") {
break;
}
t = t[..open].trim_end();
}
t
}
fn strip_silent_comment(s: &str) -> String {
let mut sc = LineScanner::new(s);
while !sc.done() {
match sc.cur() {
'"' | '\'' => {
sc.skip_quoted();
}
'/' if sc.peek(1) == Some('*') => {
sc.skip_loud_comment();
}
'/' if sc.peek(1) == Some('/') => {
return s[..sc.offset()].trim_end().to_string();
}
'#' if sc.peek(1) == Some('{') => {
sc.skip_interp();
}
_ => sc.bump(),
}
}
s.trim_end().to_string()
}
fn bracket_depth(s: &str) -> i32 {
scan_state(s).bracket_depth
}
struct ScanState {
bracket_depth: i32,
in_interp: bool,
in_loud_comment: bool,
in_string: bool,
}
fn custom_value_open(s: &str) -> bool {
let mut sc = LineScanner::new(s);
let mut depth = 0i32;
while !sc.done() {
match sc.cur() {
'"' | '\'' => {
sc.skip_quoted();
}
'(' | '[' | '{' => {
depth += 1;
sc.bump();
}
')' | ']' | '}' => {
depth -= 1;
sc.bump();
}
_ => sc.bump(),
}
}
depth > 0
}
fn interp_open_anywhere(s: &str) -> bool {
let mut sc = LineScanner::new(s);
let mut interp = 0i32;
while !sc.done() {
match sc.cur() {
'"' | '\'' if interp > 0 => {
sc.skip_quoted();
}
'#' if sc.peek(1) == Some('{') => {
interp += 1;
sc.bump();
sc.bump();
}
'{' if interp > 0 => {
interp += 1;
sc.bump();
}
'}' if interp > 0 => {
interp -= 1;
sc.bump();
}
_ => sc.bump(),
}
}
interp > 0
}
fn scan_state(s: &str) -> ScanState {
let mut sc = LineScanner::new(s);
let mut depth = 0i32;
let mut interp_depth = 0i32;
while !sc.done() {
match sc.cur() {
'"' | '\'' => {
if !sc.skip_quoted() {
return ScanState {
bracket_depth: depth,
in_interp: interp_depth > 0,
in_loud_comment: false,
in_string: true,
};
}
}
'/' if sc.peek(1) == Some('/') => break,
'/' if sc.peek(1) == Some('*') => {
if !sc.skip_loud_comment() {
return ScanState {
bracket_depth: depth,
in_interp: interp_depth > 0,
in_loud_comment: true,
in_string: false,
};
}
}
'#' if sc.peek(1) == Some('{') => {
interp_depth += 1;
sc.bump();
sc.bump();
}
'{' if interp_depth > 0 => {
interp_depth += 1;
sc.bump();
}
'}' if interp_depth > 0 => {
interp_depth -= 1;
sc.bump();
}
'(' | '[' => {
depth += 1;
sc.bump();
}
')' | ']' => {
depth -= 1;
sc.bump();
}
_ => sc.bump(),
}
}
ScanState {
bracket_depth: depth,
in_interp: interp_depth > 0,
in_loud_comment: false,
in_string: false,
}
}
fn find_decl_colon(logical: &str) -> Option<usize> {
let mut sc = LineScanner::new(logical);
let mut paren = 0i32;
let mut bracket = 0i32;
while !sc.done() {
match sc.cur() {
'"' | '\'' => {
sc.skip_quoted();
}
'#' if sc.peek(1) == Some('{') => {
sc.skip_interp();
}
'(' => {
paren += 1;
sc.bump();
}
')' => {
paren -= 1;
sc.bump();
}
'[' => {
bracket += 1;
sc.bump();
}
']' => {
bracket -= 1;
sc.bump();
}
':' if paren == 0 && bracket == 0 => return Some(sc.offset()),
_ => sc.bump(),
}
}
None
}
fn has_top_level_using(logical: &str) -> bool {
let mut sc = LineScanner::new(logical);
let mut depth = 0i32;
while !sc.done() {
match sc.cur() {
'"' | '\'' => {
sc.skip_quoted();
}
'(' | '[' => {
depth += 1;
sc.bump();
}
')' | ']' => {
depth -= 1;
sc.bump();
}
'u' | 'U' if depth == 0 => {
let prev_ident = sc.i > 0 && is_ident_char(sc.cs[sc.i - 1]);
let word: String = sc.cs[sc.i..].iter().take_while(|c| is_ident_char(**c)).collect();
if !prev_ident && word.eq_ignore_ascii_case("using") {
return true;
}
for _ in 0..word.len().max(1) {
sc.bump();
}
}
_ => sc.bump(),
}
}
false
}
fn find_top_level_semicolon(logical: &str) -> Option<usize> {
let mut sc = LineScanner::new(logical);
let mut paren = 0i32;
let mut bracket = 0i32;
while !sc.done() {
match sc.cur() {
'"' | '\'' => {
sc.skip_quoted();
}
'/' if sc.peek(1) == Some('/') => break,
'/' if sc.peek(1) == Some('*') => {
sc.skip_loud_comment();
}
'#' if sc.peek(1) == Some('{') => {
sc.skip_interp();
}
'(' => {
paren += 1;
sc.bump();
}
')' => {
paren -= 1;
sc.bump();
}
'[' => {
bracket += 1;
sc.bump();
}
']' => {
bracket -= 1;
sc.bump();
}
';' if paren == 0 && bracket == 0 => return Some(sc.offset()),
_ => sc.bump(),
}
}
None
}
#[cfg(test)]
mod line_scanner_parity {
fn split_top_level_commas_ref(s: &str) -> Vec<String> {
let cs: Vec<char> = s.chars().collect();
let mut parts = Vec::new();
let mut cur = String::new();
let mut depth = 0i32;
let mut i = 0;
while i < cs.len() {
let c = cs[i];
match c {
'"' | '\'' => {
cur.push(c);
i += 1;
while i < cs.len() {
cur.push(cs[i]);
if cs[i] == '\\' && i + 1 < cs.len() {
i += 1;
cur.push(cs[i]);
} else if cs[i] == c {
break;
}
i += 1;
}
}
'(' | '[' => {
depth += 1;
cur.push(c);
}
')' | ']' => {
depth -= 1;
cur.push(c);
}
',' if depth == 0 => {
parts.push(std::mem::take(&mut cur));
}
_ => cur.push(c),
}
i += 1;
}
parts.push(cur);
parts
}
fn strip_silent_comment_ref(s: &str) -> String {
let cs: Vec<char> = s.chars().collect();
let mut i = 0;
let mut byte = 0usize;
while i < cs.len() {
let c = cs[i];
match c {
'"' | '\'' => {
let q = c;
byte += c.len_utf8();
i += 1;
while i < cs.len() && cs[i] != q {
if cs[i] == '\\' && i + 1 < cs.len() {
byte += cs[i].len_utf8();
i += 1;
}
byte += cs[i].len_utf8();
i += 1;
}
if i < cs.len() {
byte += cs[i].len_utf8();
i += 1;
}
continue;
}
'/' if cs.get(i + 1) == Some(&'*') => {
byte += 2;
i += 2;
while i + 1 < cs.len() && !(cs[i] == '*' && cs[i + 1] == '/') {
byte += cs[i].len_utf8();
i += 1;
}
if i + 1 < cs.len() {
byte += 2;
i += 2;
}
continue;
}
'/' if cs.get(i + 1) == Some(&'/') => {
return s[..byte].trim_end().to_string();
}
'#' if cs.get(i + 1) == Some(&'{') => {
byte += c.len_utf8() + '{'.len_utf8();
i += 2;
let mut d = 1;
while i < cs.len() && d > 0 {
match cs[i] {
'{' => d += 1,
'}' => d -= 1,
_ => {}
}
byte += cs[i].len_utf8();
i += 1;
}
continue;
}
_ => {}
}
byte += c.len_utf8();
i += 1;
}
s.trim_end().to_string()
}
fn custom_value_open_ref(s: &str) -> bool {
let cs: Vec<char> = s.chars().collect();
let mut depth = 0i32;
let mut i = 0;
while i < cs.len() {
match cs[i] {
'"' | '\'' => {
let q = cs[i];
i += 1;
while i < cs.len() && cs[i] != q {
if cs[i] == '\\' {
i += 1;
}
i += 1;
}
}
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth -= 1,
_ => {}
}
i += 1;
}
depth > 0
}
fn interp_open_anywhere_ref(s: &str) -> bool {
let cs: Vec<char> = s.chars().collect();
let mut interp = 0i32;
let mut i = 0;
while i < cs.len() {
match cs[i] {
'"' | '\'' if interp > 0 => {
let q = cs[i];
i += 1;
while i < cs.len() && cs[i] != q {
if cs[i] == '\\' {
i += 1;
}
i += 1;
}
}
'#' if cs.get(i + 1) == Some(&'{') => {
interp += 1;
i += 2;
continue;
}
'{' if interp > 0 => interp += 1,
'}' if interp > 0 => interp -= 1,
_ => {}
}
i += 1;
}
interp > 0
}
fn scan_state_ref(s: &str) -> (i32, bool, bool, bool) {
let cs: Vec<char> = s.chars().collect();
let mut depth = 0i32;
let mut interp_depth = 0i32;
let mut i = 0;
while i < cs.len() {
let c = cs[i];
match c {
'"' | '\'' => {
let q = c;
i += 1;
while i < cs.len() && cs[i] != q {
if cs[i] == '\\' {
i += 1;
}
i += 1;
}
if i >= cs.len() {
return (depth, interp_depth > 0, false, true);
}
}
'/' if cs.get(i + 1) == Some(&'/') => break,
'/' if cs.get(i + 1) == Some(&'*') => {
i += 2;
while i + 1 < cs.len() && !(cs[i] == '*' && cs[i + 1] == '/') {
i += 1;
}
if i + 1 >= cs.len() && !(cs.get(i) == Some(&'*') && cs.get(i + 1) == Some(&'/')) {
return (depth, interp_depth > 0, true, false);
}
i += 2;
continue;
}
'#' if cs.get(i + 1) == Some(&'{') => {
i += 2;
interp_depth += 1;
continue;
}
'{' if interp_depth > 0 => interp_depth += 1,
'}' if interp_depth > 0 => interp_depth -= 1,
'(' | '[' => depth += 1,
')' | ']' => depth -= 1,
_ => {}
}
i += 1;
}
(depth, interp_depth > 0, false, false)
}
fn find_decl_colon_ref(logical: &str) -> Option<usize> {
let cs: Vec<char> = logical.chars().collect();
let mut byte = 0usize;
let mut paren = 0i32;
let mut bracket = 0i32;
let mut i = 0;
while i < cs.len() {
let c = cs[i];
match c {
'"' | '\'' => {
let q = c;
byte += c.len_utf8();
i += 1;
while i < cs.len() && cs[i] != q {
if cs[i] == '\\' {
byte += cs[i].len_utf8();
i += 1;
}
if i < cs.len() {
byte += cs[i].len_utf8();
i += 1;
}
}
if i < cs.len() {
byte += cs[i].len_utf8();
i += 1;
}
continue;
}
'#' if cs.get(i + 1) == Some(&'{') => {
byte += c.len_utf8() + '{'.len_utf8();
i += 2;
let mut d = 1;
while i < cs.len() && d > 0 {
match cs[i] {
'{' => d += 1,
'}' => d -= 1,
_ => {}
}
byte += cs[i].len_utf8();
i += 1;
}
continue;
}
'(' => paren += 1,
')' => paren -= 1,
'[' => bracket += 1,
']' => bracket -= 1,
':' if paren == 0 && bracket == 0 => return Some(byte),
_ => {}
}
byte += c.len_utf8();
i += 1;
}
None
}
fn has_top_level_using_ref(logical: &str) -> bool {
let cs: Vec<char> = logical.chars().collect();
let mut depth = 0i32;
let mut i = 0;
while i < cs.len() {
match cs[i] {
'"' | '\'' => {
let q = cs[i];
i += 1;
while i < cs.len() && cs[i] != q {
if cs[i] == '\\' {
i += 1;
}
i += 1;
}
}
'(' | '[' => depth += 1,
')' | ']' => depth -= 1,
'u' | 'U' if depth == 0 => {
let prev_ident = i > 0 && super::is_ident_char(cs[i - 1]);
let word: String = cs[i..].iter().take_while(|c| super::is_ident_char(**c)).collect();
if !prev_ident && word.eq_ignore_ascii_case("using") {
return true;
}
i += word.len().max(1);
continue;
}
_ => {}
}
i += 1;
}
false
}
fn find_top_level_semicolon_ref(logical: &str) -> Option<usize> {
let cs: Vec<char> = logical.chars().collect();
let mut paren = 0i32;
let mut bracket = 0i32;
let mut byte = 0usize;
let mut i = 0;
while i < cs.len() {
let c = cs[i];
match c {
'"' | '\'' => {
let q = c;
byte += c.len_utf8();
i += 1;
while i < cs.len() && cs[i] != q {
if cs[i] == '\\' && i + 1 < cs.len() {
byte += cs[i].len_utf8();
i += 1;
}
byte += cs[i].len_utf8();
i += 1;
}
if i < cs.len() {
byte += cs[i].len_utf8();
i += 1;
}
continue;
}
'/' if cs.get(i + 1) == Some(&'/') => break,
'/' if cs.get(i + 1) == Some(&'*') => {
byte += 2;
i += 2;
while i + 1 < cs.len() && !(cs[i] == '*' && cs[i + 1] == '/') {
byte += cs[i].len_utf8();
i += 1;
}
if i + 1 < cs.len() {
byte += 2;
i += 2;
}
continue;
}
'#' if cs.get(i + 1) == Some(&'{') => {
byte += c.len_utf8() + '{'.len_utf8();
i += 2;
let mut d = 1;
while i < cs.len() && d > 0 {
match cs[i] {
'{' => d += 1,
'}' => d -= 1,
_ => {}
}
byte += cs[i].len_utf8();
i += 1;
}
continue;
}
'(' => paren += 1,
')' => paren -= 1,
'[' => bracket += 1,
']' => bracket -= 1,
';' if paren == 0 && bracket == 0 => return Some(byte),
_ => {}
}
byte += c.len_utf8();
i += 1;
}
None
}
fn corpus() -> Vec<String> {
let fragments = [
"",
"a, b, c",
"color: red",
"foo: bar; baz: qux",
"@include thing($a, $b) using ($c)",
"@include thing(using: 1)",
"url(a, b)",
"\"a, b\", c",
"'a; b': c",
"a: \"unterminated",
"a: 'half \\' quote'",
"a: \"esc \\\" still in\", b",
"trailing backslash \\",
"trailing backslash in str \"x\\",
"map: (a: 1, b: 2)",
"list: [1, 2, 3]",
"nested: (a: (b: c), d: [e, f])",
"interp: #{1 + 2}px",
"interp colon #{$a: b}",
"unclosed interp #{1 + 2",
"nested interp #{ #{x} }",
"interp with brace #{ {a} }",
"interp in comment /* #{x} */",
"// just a comment",
"value // trailing comment",
"value /* loud */ more",
"value /* unclosed loud",
"a: b /* c */ // d",
"/* #{x} */ : y",
"weird: a:b:c",
"深: 値; другой: значение",
"emoji 😀: 🎉, x",
"multi 字\\符 byte",
"semi inside (a; b); real",
"colon inside [a:b]: outside",
"@media screen",
"$var: value",
"url(\"x; y\")",
"a\\:escaped colon: v",
"#{} : after empty interp",
"} stray close",
") stray paren",
"using",
" using ",
"foousing: 1",
"x using y",
"\"\"",
"''",
"()",
"#{",
"/*",
"*/",
"//",
];
fragments.iter().map(|s| s.to_string()).collect()
}
#[test]
fn line_scanner_matches_reference_implementations() {
for s in corpus() {
let s = s.as_str();
assert_eq!(
super::split_top_level_commas(s),
split_top_level_commas_ref(s),
"split_top_level_commas diverged on {s:?}"
);
assert_eq!(
super::strip_silent_comment(s),
strip_silent_comment_ref(s),
"strip_silent_comment diverged on {s:?}"
);
assert_eq!(
super::custom_value_open(s),
custom_value_open_ref(s),
"custom_value_open diverged on {s:?}"
);
assert_eq!(
super::interp_open_anywhere(s),
interp_open_anywhere_ref(s),
"interp_open_anywhere diverged on {s:?}"
);
let st = super::scan_state(s);
assert_eq!(
(st.bracket_depth, st.in_interp, st.in_loud_comment, st.in_string),
scan_state_ref(s),
"scan_state diverged on {s:?}"
);
assert_eq!(
super::find_decl_colon(s),
find_decl_colon_ref(s),
"find_decl_colon diverged on {s:?}"
);
assert_eq!(
super::has_top_level_using(s),
has_top_level_using_ref(s),
"has_top_level_using diverged on {s:?}"
);
assert_eq!(
super::find_top_level_semicolon(s),
find_top_level_semicolon_ref(s),
"find_top_level_semicolon diverged on {s:?}"
);
}
}
#[test]
fn returned_byte_offsets_are_char_boundaries() {
for s in corpus() {
let s = s.as_str();
if let Some(b) = super::find_decl_colon(s) {
assert!(
s.is_char_boundary(b),
"find_decl_colon offset {b} not a boundary in {s:?}"
);
}
if let Some(b) = super::find_top_level_semicolon(s) {
assert!(
s.is_char_boundary(b),
"find_top_level_semicolon offset {b} not a boundary in {s:?}"
);
}
}
}
}