///|
priv enum Statement {
Leaf(String)
Block(String, Array[Statement])
}
///|
priv struct Scope {
vars : Map[String, SassValue]
mixins : Map[String, Mixin]
functions : Map[String, Mixin]
standard_modules : Map[String, String]
module_scopes : Map[String, Scope]
star_scopes : Array[Scope]
forwarded_vars : Map[String, (Scope, String)]
forwarded_mixins : Map[String, Mixin]
forwarded_functions : Map[String, Mixin]
configured : Map[String, Bool]
configuration : Map[String, SassValue]
path : String
evaluation : Evaluation
returned : Ref[SassValue?]?
flow : Bool
parent : Scope?
}
///|
priv struct Mixin {
name : String
params : Array[(String, String?)]
rest : String?
body : Array[Statement]
scope : Scope
}
///|
priv struct Content {
body : Array[Statement]
scope : Scope
outer : Content?
}
///|
fn Scope::new(parent : Scope?) -> Scope {
{
vars: Map([]),
mixins: Map([]),
functions: Map([]),
standard_modules: Map([]),
module_scopes: Map([]),
star_scopes: [],
forwarded_vars: Map([]),
forwarded_mixins: Map([]),
forwarded_functions: Map([]),
configured: Map([]),
configuration: Map([]),
path: match parent {
Some(p) => p.path
None => ""
},
evaluation: match parent {
Some(p) => p.evaluation
None => Evaluation::new()
},
returned: match parent {
Some(p) => p.returned
None => None
},
flow: false,
parent,
}
}
///|
fn identifier(name : String) -> String {
name.replace_all(old="_", new="-")
}
///|
/// Module namespaces are case-sensitive and keep underscores distinct.
fn reference_name(name : String) -> String {
let parts = name.split(".").to_array()
if parts.length() == 2 {
parts[0].to_owned() + "." + identifier(parts[1].to_owned())
} else {
identifier(name)
}
}
///|
fn Scope::get(self : Scope, name : String) -> SassValue? raise ParseError {
if name.contains(".") {
return self.qualified_variable(name)
}
match self.vars.get(identifier(name)) {
Some(v) => Some(v)
None =>
match self.parent {
Some(p) => p.get(name)
None =>
match self.star_variable(identifier(name)) {
Some((owner, key)) => owner.vars.get(key)
None => None
}
}
}
}
///|
fn Scope::lookup_mixin(self : Scope, name : String) -> Mixin? raise ParseError {
if name.contains(".") {
return self.qualified_callable(name, false)
}
match self.mixins.get(identifier(name)) {
Some(v) => Some(v)
None =>
match self.parent {
Some(p) => p.lookup_mixin(name)
None => self.star_callable(identifier(name), false)
}
}
}
///|
fn Scope::global(self : Scope) -> Scope {
match self.parent {
Some(p) => p.global()
None => self
}
}
///|
fn Scope::expand(
self : Scope,
text : String,
variables? : Bool = true,
) -> String raise ParseError {
let cs = text.to_array()
let out = StringBuilder()
let mut size = 0
let mut i = 0
let mut quote = '\u0000'
while i < cs.length() {
let c = cs[i]
if c == '\\' {
out.write_char(c)
i += 1
size += 1
if i < cs.length() {
out.write_char(cs[i])
i += 1
size += 2
}
continue
}
if c == '#' && i + 1 < cs.length() && cs[i + 1] == '{' {
let parser = ExpressionParser::{ chars: cs, pos: i, depth: 0, }
let expression = parser.interpolation()
i = parser.pos
let computed = self.eval_ast(expression, true, 0)
let value = if computed is List([], _, _) || computed is Null {
""
} else {
computed.css(unquote=true)
}
out.write_string(value)
size += value.length()
} else if variables && c == '$' && quote == '\u0000' {
i += 1
let start = i
while i < cs.length() && (word(cs[i]) || cs[i] == '-') {
i += 1
}
let key = String::from_array(cs[start:i])
let value = match self.get(key) {
Some(v) => v.css()
None => raise Invalid("undefined variable " + key)
}
out.write_string(value)
size += value.length()
} else {
if quote == '\u0000' && (c == '\'' || c == '"') {
quote = c
} else if c == quote {
quote = '\u0000'
}
out.write_char(c)
i += 1
size += c.utf16_len()
}
if size > 1000000 {
raise Invalid("expanded value limit")
}
}
if size > 1000000 {
raise Invalid("expanded value limit")
}
out.to_string()
}
///|
fn Source::statements(
self : Source,
closing : Bool,
depth : Int,
) -> Array[Statement] raise ParseError {
if depth > 32 {
raise Invalid("nesting limit")
}
let nodes = []
while true {
let (text, end) = self.segment()
if end == '{' {
nodes.push(Block(text, self.statements(true, depth + 1)))
} else if !text.is_empty() {
nodes.push(Leaf(text))
}
if end == '}' {
if !closing {
raise Invalid("unexpected closing brace")
}
break
}
if end == '\u0000' {
if closing {
raise Invalid("missing closing brace")
}
break
}
}
nodes
}
///|
fn invocation(text : String) -> (String, Array[String]) raise ParseError {
let cs = text.trim().to_owned().to_array()
let mut at = 0
while at < cs.length() && cs[at] != '(' {
at += 1
}
let name = String::from_array(cs[:at]).trim().to_owned()
if name.is_empty() ||
!name.to_array().iter().all(c => name_char(c) || c == '.') {
raise Invalid("invalid mixin name")
}
if at == cs.length() {
return (reference_name(name), [])
}
if cs.last() != Some(')') {
raise Invalid("invalid mixin arguments")
}
let inner = String::from_array(cs[at + 1:cs.length() - 1]).trim().to_owned()
(
reference_name(name),
if inner.is_empty() {
[]
} else {
split_top(inner, ',')
},
)
}
///|
priv struct Emitter {
output : StringBuilder
mut size : Int
mut budget : Int
mut pending_size : Int
mut pending : Array[String]
mut parents : Array[String]
}
///|
fn Emitter::write(self : Emitter, text : String) -> Unit raise ParseError {
self.size += text.length()
if self.size > 1000000 {
raise Invalid("CSS output limit")
}
self.output.write_string(text)
}
///|
fn Emitter::flush(self : Emitter) -> Unit raise ParseError {
if self.pending.is_empty() {
return
}
if !self.parents.is_empty() {
self.write(self.parents.join(", ") + " {\n")
}
self.write(self.pending.join(""))
if !self.parents.is_empty() {
self.write("}\n")
}
self.pending = []
self.pending_size = 0
}
///|
fn Emitter::property(
self : Emitter,
parents : Array[String],
key : String,
value : String,
) -> Unit raise ParseError {
if self.parents != parents {
self.flush()
self.parents = parents
}
let text = " " +
key +
(if key.has_prefix("--") { ":" } else { ": " }) +
value +
";\n"
if text.length() > 1000000 {
raise Invalid("property limit")
}
self.pending_size += text.length()
if self.pending_size > 1000000 - self.size {
raise Invalid("CSS output limit")
}
self.pending.push(text)
}
///|
fn include_mixin(
header : String,
body : Content?,
parents : Array[String],
caller : Scope,
emitter : Emitter,
depth : Int,
raw : Bool,
prefix : String,
) -> Unit raise ParseError {
let (name, args) = invocation(header)
let definition = match caller.lookup_mixin(name) {
Some(m) => m
None => raise Invalid("undefined mixin " + name)
}
let scope = Scope::new(Some(definition.scope))
let keywords : Map[String, SassValue] = Map([])
let positional = []
let mut named = false
for arg in args {
let parts = split_top(arg, ':')
if parts.length() > 1 && parts[0].has_prefix("$") {
named = true
let key = identifier(parts[0][1:].to_owned())
if keywords.contains(key) {
raise Invalid("duplicate keyword argument")
}
keywords[key] = caller.evaluate(parts[1:].to_owned().join(":"))
} else {
if named {
raise Invalid("positional argument after keyword")
}
positional.push(caller.evaluate(arg))
}
}
for i in 0.. {
keywords.remove(key)
v
}
None =>
match default {
Some(v) => scope.evaluate(v)
None => raise Invalid("missing mixin argument " + key)
}
}
}
scope.vars[key] = value
}
if !keywords.is_empty() {
raise Invalid("unknown keyword argument")
}
match definition.rest {
Some(key) =>
scope.vars[key] = if positional.length() > definition.params.length() {
List(positional[definition.params.length():].to_owned(), ",", false)
} else {
List([], ",", false)
}
None =>
if positional.length() > definition.params.length() {
raise Invalid("too many mixin arguments")
}
}
render(definition.body, parents, scope, emitter, depth + 1, body, raw, prefix)
}
///|
fn render(
nodes : Array[Statement],
parents : Array[String],
scope : Scope,
emitter : Emitter,
depth : Int,
content : Content?,
raw : Bool,
prefix : String,
) -> Unit raise ParseError {
if depth > 64 {
raise Invalid("evaluation nesting limit")
}
let mut conditional : Bool? = None
for node in nodes {
if scope.returned is Some(cell) && cell.val is Some(_) {
return
}
scope.charge()
emitter.budget -= 1
if emitter.budget < 0 {
raise Invalid("evaluation budget")
}
if node is Block(header, body) {
if header.has_prefix("@if ") {
let selected = scope.evaluate(header[4:].to_owned()).truth()
conditional = Some(selected)
if selected {
render(
body,
parents,
scope.flow_scope(),
emitter,
depth + 1,
content,
raw,
prefix,
)
}
continue
}
if header == "@else" || header.has_prefix("@else if ") {
guard conditional is Some(previous) else {
raise Invalid("else without adjacent if")
}
let selected = !previous &&
(header == "@else" || scope.evaluate(header[9:].to_owned()).truth())
conditional = if header == "@else" {
None
} else {
Some(previous || selected)
}
if selected {
render(
body,
parents,
scope.flow_scope(),
emitter,
depth + 1,
content,
raw,
prefix,
)
}
continue
}
if render_loop(
header, body, parents, scope, emitter, depth, content, raw, prefix,
) {
conditional = None
continue
}
}
conditional = None
match node {
Block(header, body) =>
if header.has_prefix("@mixin ") || header.has_prefix("@function ") {
let function = header.has_prefix("@function ")
if function {
validate_function(body)
} else {
validate_mixin_body(body)
}
let (name, args) = invocation(
header[if function { 10 } else { 7 }:].to_owned(),
)
let params = []
let mut rest : String? = None
let seen : Map[String, Bool] = Map([])
for i in 0.. 1 {
Some(pieces[1:].to_owned().join(":"))
} else {
None
},
),
)
}
}
if function {
scope.functions[name] = { name, params, rest, body, scope, }
} else {
scope.mixins[name] = { name, params, rest, body, scope, }
}
} else if header.has_prefix("@include ") {
include_mixin(
header[9:].to_owned(),
Some({ body, scope, outer: content, }),
parents,
scope,
emitter,
depth,
raw,
prefix,
)
} else if header.has_prefix("@media ") ||
header.has_prefix("@supports (") ||
header.has_prefix("@layer ") ||
header == "@font-face" {
emitter.flush()
emitter.write(scope.expand(header) + " {\n")
render(
body,
if header == "@font-face" {
[]
} else {
parents
},
Scope::new(Some(scope)),
emitter,
depth + 1,
content,
header == "@font-face",
prefix,
)
emitter.flush()
emitter.write("}\n")
} else if header.has_suffix(":") {
render(
body,
parents,
Scope::new(Some(scope)),
emitter,
depth + 1,
content,
raw,
prefix + scope.expand(header[:header.length() - 1].to_owned()) + "-",
)
} else {
emitter.flush()
render(
body,
selectors(parents, scope.expand(header)),
Scope::new(Some(scope)),
emitter,
depth + 1,
content,
false,
"",
)
emitter.flush()
}
Leaf(part) =>
if part.has_prefix("@return ") {
guard scope.returned is Some(cell) else {
raise Invalid("return outside function")
}
cell.val = Some(scope.evaluate(part[8:].to_owned()))
return
} else if part.has_prefix("@error ") {
raise Invalid(
scope.evaluate(part[7:].to_owned()).css(unquote=true, inspect=true),
)
} else if part.has_prefix("@use ") {
scope.load_directive(part[5:].to_owned(), false)
} else if part.has_prefix("@forward ") {
scope.load_directive(part[9:].to_owned(), true)
} else if part.has_prefix("@warn ") || part.has_prefix("@debug ") {
let debug = part.has_prefix("@debug ")
scope.evaluation.diagnostics.push(
(if debug { "debug: " } else { "warn: " }) +
scope
.evaluate(part[if debug { 7 } else { 6 }:].to_owned())
.css(unquote=true, inspect=true),
)
} else if part.has_prefix("@include ") {
include_mixin(
part[9:].to_owned(),
None,
parents,
scope,
emitter,
depth,
raw,
prefix,
)
} else if part == "@content" {
match content {
Some(c) =>
render(
c.body,
parents,
c.scope,
emitter,
depth + 1,
c.outer,
raw,
prefix,
)
None => ()
}
} else {
let pieces = split_top(part, ':')
if pieces.length() < 2 {
raise Invalid("declaration requires colon")
}
let key = pieces[0]
let mut value = pieces[1:].to_owned().join(":")
if key.has_prefix("$") || key.contains(".$") {
let mut global = false
let mut default = false
while true {
if value.has_suffix("!global") {
global = true
value = value[:value.length() - 7].trim().to_owned()
} else if value.has_suffix("!default") {
default = true
value = value[:value.length() - 8].trim().to_owned()
} else {
break
}
}
let name = reference_name(
if key.has_prefix("$") {
key[1:].to_owned()
} else {
key.replace_all(old=".$", new=".")
},
)
if default &&
scope.configured.contains(name) &&
scope.configuration.get(name) is Some(supplied) {
scope.configured.remove(name)
if !(supplied is Null) {
scope.assign(name, supplied)
}
}
if !default ||
scope.get(name) is None ||
scope.get(name) is Some(Null) {
let computed = scope.evaluate(value)
if global {
scope.global().assign(name, computed)
} else {
scope.assign(name, computed)
}
}
} else {
if parents.is_empty() && !raw {
raise Invalid("property outside rule")
}
let key = prefix + scope.expand(key)
if key.has_prefix("--") {
let chars = part.to_array()
let mut at = 0
while at < chars.length() && chars[at] != ':' {
at += 1
}
value = scope.expand(
String::from_array(chars[at + 1:]),
variables=false,
)
} else {
let computed = scope.evaluate(value, division=false)
value = if computed is Null { "" } else { computed.css() }
}
if key.has_prefix("@") || key.contains(" ") {
raise Invalid("unsupported declaration")
}
if value == "()" {
raise Invalid("empty list is not a CSS value")
}
if !value.is_empty() {
emitter.property(parents, key, value)
}
}
}
}
}
}
///|
pub fn compile(source : String) -> String raise ParseError {
compile_files("input.scss", Map([("input.scss", source)])).css
}
///|
fn validate_mixin_body(nodes : Array[Statement]) -> Unit raise ParseError {
for node in nodes {
if node is Block(header, body) {
if header.has_prefix("@mixin ") {
raise Invalid("mixin definitions cannot be nested inside mixins")
}
validate_mixin_body(body)
}
}
}
///|
fn validate_content(
nodes : Array[Statement],
inside : Bool,
) -> Unit raise ParseError {
for node in nodes {
match node {
Leaf(text) =>
if text == "@content" && !inside {
raise Invalid("content outside mixin")
}
Block(header, body) =>
validate_content(body, inside || header.has_prefix("@mixin "))
}
}
}