use std::borrow::Cow;
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Mutex;
use std::time::Instant;
use sasso::{
compile, compile_with_source_map, FsImporter, Options, OutputStyle, SourceMap, Syntax, WarnEvent,
WarnHandler,
};
#[global_allocator]
static GLOBAL: sasso::ScopedAlloc = sasso::ScopedAlloc;
const USAGE: &str = "\
sasso — a pure-Rust SCSS to CSS compiler
USAGE:
sasso [options] <input.scss> [<output.css>] CSS to stdout, or to a file
sasso [options] <in.scss>:<out.css>... one output file per input
sasso [options] <in-dir/>:<out-dir/> compile a whole tree
sasso --stdin [options] [<output.css>] < input.scss
INPUT AND OUTPUT:
-s, --style <expanded|compressed> output style (default: expanded)
-I, --load-path <dir> add an @import/@use search path (repeatable)
-o, --output <file> write CSS to <file> (same as a second
positional argument)
--stdin read SCSS from standard input
--indented parse the indented .sass syntax
--[no-]charset emit @charset/BOM for non-ASCII CSS
(default: on)
--[no-]error-css on a compile error, write a stylesheet
describing it (default: on when
compiling to a file)
SOURCE MAPS:
--[no-]source-map generate source maps (default: on when
compiling to a file, off for stdout)
--source-map-urls <relative|absolute>
how the map references its sources
(default: relative)
--[no-]embed-sources embed the source text in the map's
sourcesContent
--[no-]embed-source-map inline the map into the CSS as a
data: URI instead of a .map file
WARNINGS:
-q, --[no-]quiet don't print warnings
--[no-]quiet-deps don't print compiler warnings from
dependencies (stylesheets reached
through load paths)
OTHER:
-j, --jobs <N> compile at most N files at once
(default: one per CPU)
--[no-]stop-on-error don't start more files once one fails
-c, --[no-]color accepted for dart-sass compatibility
(no-op: sasso never colors output)
--[no-]unicode Unicode box glyphs in diagnostics
(default: on)
--loop <N> recompile in-process N times and report
throughput (stdout inputs only)
--no-css compile but discard the CSS (timing or
lint runs)
--version print version and exit
-h, --help print this help and exit
-- end of options: what follows are inputs
and outputs, even if they start with -
";
const EXIT_USAGE: u8 = 64;
const EXIT_COMPILE: u8 = 65;
const EXIT_IO: u8 = 66;
struct Cli {
positionals: Vec<String>,
pairs: Vec<(PathBuf, PathBuf)>,
stdin_flag: bool,
entry: Option<Entry>,
style: OutputStyle,
load_paths: Vec<PathBuf>,
indented: bool,
quiet: bool,
quiet_deps: bool,
no_css: bool,
loop_n: Option<u32>,
no_unicode: bool,
output: Option<PathBuf>,
source_map: Option<bool>,
embed_sources: bool,
embed_source_map: bool,
source_map_urls: Option<SourceMapUrls>,
error_css: Option<bool>,
stop_on_error: bool,
charset: bool,
jobs: Option<usize>,
}
enum Entry {
Stdin,
File(PathBuf),
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum SourceMapUrls {
Relative,
Absolute,
}
fn main() -> ExitCode {
{
use std::io::Write;
let _ = std::io::stdout().lock().flush();
let _ = std::io::stderr().lock().flush();
}
let args: Vec<String> = std::env::args().skip(1).collect();
match parse_args(&args) {
Ok(Action::Run(cli)) => run(cli),
Ok(Action::Help) => {
print!("{USAGE}");
ExitCode::SUCCESS
}
Ok(Action::Version) => {
println!("sasso {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
Err(msg) => {
eprintln!("error: {msg}\n");
eprint!("{USAGE}");
ExitCode::from(EXIT_USAGE)
}
}
}
enum Action {
Run(Cli),
Help,
Version,
}
fn parse_args(args: &[String]) -> Result<Action, String> {
let mut cli = Cli {
positionals: Vec::new(),
pairs: Vec::new(),
stdin_flag: false,
entry: None,
style: OutputStyle::Expanded,
load_paths: Vec::new(),
indented: false,
quiet: false,
quiet_deps: false,
no_css: false,
loop_n: None,
no_unicode: false,
output: None,
source_map: None,
embed_sources: false,
embed_source_map: false,
source_map_urls: None,
error_css: None,
stop_on_error: false,
charset: true,
jobs: None,
};
let mut i = 0;
let mut only_operands = false;
while i < args.len() {
let a = &args[i];
if only_operands {
push_operand(&mut cli, a)?;
i += 1;
continue;
}
match a.as_str() {
"--" => only_operands = true,
"-h" | "--help" => return Ok(Action::Help),
"--version" => return Ok(Action::Version),
"--stdin" => cli.stdin_flag = true,
"--no-stdin" => cli.stdin_flag = false,
"--indented" => cli.indented = true,
"--no-indented" => cli.indented = false,
"--unicode" => cli.no_unicode = false,
"--no-unicode" => cli.no_unicode = true,
"--source-map" => cli.source_map = Some(true),
"--no-source-map" => cli.source_map = Some(false),
"--embed-sources" => cli.embed_sources = true,
"--no-embed-sources" => cli.embed_sources = false,
"--embed-source-map" => cli.embed_source_map = true,
"--no-embed-source-map" => cli.embed_source_map = false,
"--error-css" => cli.error_css = Some(true),
"--no-error-css" => cli.error_css = Some(false),
"--charset" => cli.charset = true,
"--no-charset" => cli.charset = false,
"-q" | "--quiet" => cli.quiet = true,
"--no-quiet" => cli.quiet = false,
"--quiet-deps" => cli.quiet_deps = true,
"--no-quiet-deps" => cli.quiet_deps = false,
"--stop-on-error" => cli.stop_on_error = true,
"--no-stop-on-error" => cli.stop_on_error = false,
"-c" | "--color" | "--no-color" => {}
"--no-css" => cli.no_css = true,
"-o" | "--output" => {
i += 1;
let v = args.get(i).ok_or("--output requires a value")?;
cli.output = Some(PathBuf::from(v));
}
"--source-map-urls" => {
i += 1;
let v = args.get(i).ok_or("--source-map-urls requires a value")?;
cli.source_map_urls = Some(parse_source_map_urls(v)?);
}
"--loop" => {
i += 1;
let v = args.get(i).ok_or("--loop requires a value")?;
cli.loop_n = Some(parse_loop(v)?);
}
"-j" | "--jobs" => {
i += 1;
let v = args.get(i).ok_or("--jobs requires a value")?;
cli.jobs = Some(parse_jobs(v)?);
}
"-s" | "--style" => {
i += 1;
let v = args.get(i).ok_or("--style requires a value")?;
cli.style = parse_style(v)?;
}
"-I" | "--load-path" => {
i += 1;
let v = args.get(i).ok_or("--load-path requires a value")?;
cli.load_paths.push(PathBuf::from(v));
}
other => {
if let Some(v) = other.strip_prefix("--style=") {
cli.style = parse_style(v)?;
} else if let Some(v) = other.strip_prefix("--load-path=") {
cli.load_paths.push(PathBuf::from(v));
} else if let Some(v) = other.strip_prefix("--loop=") {
cli.loop_n = Some(parse_loop(v)?);
} else if let Some(v) = other.strip_prefix("--jobs=") {
cli.jobs = Some(parse_jobs(v)?);
} else if let Some(v) = other.strip_prefix("--output=") {
cli.output = Some(PathBuf::from(v));
} else if let Some(v) = other.strip_prefix("--source-map-urls=") {
cli.source_map_urls = Some(parse_source_map_urls(v)?);
} else if other.starts_with('-') && other != "-" && !other.starts_with("-:") {
return Err(format!("unknown option {other}"));
} else {
push_operand(&mut cli, other)?;
}
}
}
i += 1;
}
if cli.embed_source_map && cli.source_map == Some(false) {
return Err("--embed-source-map isn't allowed with --no-source-map.".to_string());
}
if cli.embed_sources && cli.source_map == Some(false) {
return Err("--embed-sources isn't allowed with --no-source-map.".to_string());
}
if cli.source_map_urls.is_some() && cli.source_map == Some(false) {
return Err("--source-map-urls isn't allowed with --no-source-map.".to_string());
}
if !cli.pairs.is_empty() {
if !cli.positionals.is_empty() {
return Err("Positional and \":\" arguments may not both be used.".to_string());
}
if cli.stdin_flag {
return Err("--stdin may not be used with \":\" arguments.".to_string());
}
if cli.output.is_some() {
return Err("--output may not be used with \":\" arguments.".to_string());
}
if cli.loop_n.is_some() {
return Err("--loop compiles to stdout only (no \":\" arguments or --output).".to_string());
}
let mut seen: std::collections::HashSet<&Path> = std::collections::HashSet::new();
for (src, _) in &cli.pairs {
if !seen.insert(src.as_path()) {
return Err(format!("Duplicate source {:?}.", src.to_string_lossy()));
}
}
let cwd = std::env::current_dir().unwrap_or_default();
let mut keys: Vec<PathBuf> = Vec::new();
let mut kept: Vec<(PathBuf, PathBuf)> = Vec::new();
for (src, dest) in std::mem::take(&mut cli.pairs) {
let key = if src == Path::new("-") {
src.clone()
} else {
normalize_path(&cwd.join(&src))
};
match keys.iter().position(|k| *k == key) {
Some(i) => kept[i].1 = dest,
None => {
keys.push(key);
kept.push((src, dest));
}
}
}
cli.pairs = kept;
} else {
let max = if cli.stdin_flag { 1 } else { 2 };
if cli.positionals.len() > max {
return Err(if cli.stdin_flag {
"Only one argument is allowed with --stdin.".to_string()
} else {
"Only two positional args may be passed.".to_string()
});
}
let mut positionals = cli.positionals.iter();
cli.entry = if cli.stdin_flag {
Some(Entry::Stdin)
} else {
positionals.next().map(|p| {
if p == "-" {
Entry::Stdin
} else {
Entry::File(PathBuf::from(p))
}
})
};
if let Some(out) = positionals.next() {
if cli.output.is_some() {
return Err("--output requires a single input".to_string());
}
cli.output = Some(PathBuf::from(out));
}
if cli.output.is_some() && cli.loop_n.is_some() {
return Err("--loop compiles to stdout only (no \":\" arguments or --output).".to_string());
}
}
let entry_is_dir = matches!(&cli.entry, Some(Entry::File(p)) if p.is_dir());
if cli.loop_n.is_some() && entry_is_dir {
return Err(
"--loop compiles to stdout only (no directories, \":\" arguments or --output).".to_string(),
);
}
if cli.loop_n.is_some() && (cli.source_map == Some(true) || cli.embed_source_map || cli.embed_sources) {
return Err(
"--loop does not generate source maps (drop --source-map, --embed-source-map and --embed-sources)."
.to_string(),
);
}
let to_stdout = cli.pairs.is_empty() && cli.output.is_none() && !entry_is_dir;
if to_stdout {
if cli.source_map_urls == Some(SourceMapUrls::Relative) {
return Err("--source-map-urls=relative isn't allowed when printing to stdout.".to_string());
}
if !cli.embed_source_map {
if cli.source_map == Some(true) {
return Err("When printing to stdout, --source-map requires --embed-source-map.".to_string());
}
if cli.embed_sources {
return Err(
"When printing to stdout, --embed-sources requires --embed-source-map.".to_string(),
);
}
if cli.source_map_urls.is_some() {
return Err(
"When printing to stdout, --source-map-urls requires --embed-source-map.".to_string(),
);
}
}
}
Ok(Action::Run(cli))
}
fn push_operand(cli: &mut Cli, arg: &str) -> Result<(), String> {
match split_pair(arg)? {
Some(pair) => cli.pairs.push(pair),
None => cli.positionals.push(arg.to_string()),
}
Ok(())
}
fn split_pair(arg: &str) -> Result<Option<(PathBuf, PathBuf)>, String> {
let mut from = 0;
while let Some(off) = arg[from..].find(':') {
let idx = from + off;
let is_drive_colon = cfg!(windows) && idx == 1 && arg.as_bytes()[0].is_ascii_alphabetic();
if is_drive_colon {
from = idx + 1;
continue;
}
let (src, dest) = (&arg[..idx], &arg[idx + 1..]);
if src.is_empty() || dest.is_empty() {
return Err(format!("expected <source>:<destination>, got {arg:?}"));
}
let dest_drive_colon = cfg!(windows)
&& dest.len() > 1
&& dest.as_bytes()[1] == b':'
&& dest.as_bytes()[0].is_ascii_alphabetic();
let extra = if dest_drive_colon {
dest[2..].contains(':')
} else {
dest.contains(':')
};
if extra {
return Err(format!("{arg:?} may only contain one \":\"."));
}
return Ok(Some((PathBuf::from(src), PathBuf::from(dest))));
}
Ok(None)
}
fn usage_error(msg: &str) -> ExitCode {
eprintln!("error: {msg}\n");
eprint!("{USAGE}");
ExitCode::from(EXIT_USAGE)
}
fn parse_source_map_urls(s: &str) -> Result<SourceMapUrls, String> {
match s {
"relative" => Ok(SourceMapUrls::Relative),
"absolute" => Ok(SourceMapUrls::Absolute),
other => Err(format!(
"unknown --source-map-urls {other:?} (expected relative or absolute)"
)),
}
}
fn parse_loop(s: &str) -> Result<u32, String> {
match s.parse::<u32>() {
Ok(n) if n >= 1 => Ok(n),
_ => Err(format!("--loop expects a positive integer (got {s:?})")),
}
}
fn parse_jobs(s: &str) -> Result<usize, String> {
match s.parse::<usize>() {
Ok(n) if n >= 1 => Ok(n),
_ => Err(format!("--jobs expects a positive integer (got {s:?})")),
}
}
fn parse_style(s: &str) -> Result<OutputStyle, String> {
match s {
"expanded" => Ok(OutputStyle::Expanded),
"compressed" => Ok(OutputStyle::Compressed),
other => Err(format!(
"unknown style {other:?} (expected expanded or compressed)"
)),
}
}
#[derive(Clone)]
enum Source {
Text(String),
File(PathBuf),
InvalidUtf8,
}
enum Target {
Stdout,
File(PathBuf),
}
struct Unit {
source: Source,
url: String,
syntax: Syntax,
target: Target,
}
struct Shared {
load_paths: Vec<PathBuf>,
style: OutputStyle,
unicode: bool,
charset: bool,
quiet: bool,
quiet_deps: bool,
no_css: bool,
embed_sources: bool,
embed_source_map: bool,
source_map_urls: SourceMapUrls,
file_source_map: bool,
file_error_css: bool,
stdout_error_css: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Status {
Ok,
CompileError,
IoError,
}
struct Outcome {
stderr: String,
stdout: String,
status: Status,
}
impl Outcome {
fn failed(status: Status, stderr: String) -> Self {
Outcome {
stderr,
stdout: String::new(),
status,
}
}
}
fn syntax_for(path: &Path, indented: bool) -> Syntax {
if indented {
return Syntax::Sass;
}
match path.extension().and_then(|e| e.to_str()) {
Some("sass") => Syntax::Sass,
Some("css") => Syntax::Css,
_ => Syntax::Scss,
}
}
fn is_compilable(path: &Path) -> bool {
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => return false,
};
!name.starts_with('_')
&& matches!(
path.extension().and_then(|e| e.to_str()),
Some("scss" | "sass" | "css")
)
}
fn expand_dir(src: &Path, dest: &Path, indented: bool, units: &mut Vec<Unit>) -> Result<(), String> {
let mut files = Vec::new();
let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
seen.insert(std::fs::canonicalize(src).unwrap_or_else(|_| src.to_path_buf()));
let mut stack = vec![src.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries = std::fs::read_dir(&dir)
.map_err(|_| format!("Error reading {}: Cannot open file.", dir.display()))?;
let mut paths: Vec<PathBuf> = Vec::new();
for entry in entries {
let entry = entry.map_err(|_| format!("Error reading {}: Cannot open file.", dir.display()))?;
paths.push(entry.path());
}
paths.sort();
for path in paths {
if path.is_dir() {
let identity = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
if seen.insert(identity) {
stack.push(path);
}
} else if is_compilable(&path) {
files.push(path);
}
}
}
files.sort();
let cwd = std::env::current_dir().unwrap_or_default();
let src_abs = path_key(&normalize_path(&cwd.join(src)));
let dest_abs = path_key(&normalize_path(&cwd.join(dest)));
let nested = dest_abs != src_abs && dest_abs.starts_with(&src_abs);
for path in files {
let rel = path.strip_prefix(src).unwrap_or(&path).with_extension("css");
let out = dest.join(rel);
let path_abs = path_key(&normalize_path(&cwd.join(&path)));
if nested && path_abs.starts_with(&dest_abs) {
continue;
}
if path_key(&normalize_path(&cwd.join(&out))) == path_abs {
continue;
}
units.push(Unit {
url: path.to_string_lossy().into_owned(),
syntax: syntax_for(&path, indented),
source: Source::File(path),
target: Target::File(out),
});
}
Ok(())
}
fn run(cli: Cli) -> ExitCode {
let mut units: Vec<Unit> = Vec::new();
let mut stdin_cache: Option<Source> = None;
let stdin_syntax = if cli.indented { Syntax::Sass } else { Syntax::Scss };
let mut dir_entry = false;
if let Some(out) = &cli.output {
if out.is_dir() {
return usage_error(&format!(
"Directory {:?} may not be a positional arg.",
out.to_string_lossy()
));
}
}
match &cli.entry {
Some(Entry::Stdin) => match stdin_source(&mut stdin_cache) {
Ok(source) => units.push(Unit {
source,
url: "-".to_string(),
syntax: stdin_syntax,
target: Target::Stdout,
}),
Err(code) => return code,
},
Some(Entry::File(path)) if path.is_dir() => {
if cli.output.is_some() {
return usage_error(&format!(
"Directory {:?} may not be a positional arg.",
path.to_string_lossy()
));
}
if let Err(msg) = expand_dir(path, path, cli.indented, &mut units) {
eprintln!("{msg}");
return ExitCode::from(EXIT_IO);
}
dir_entry = true;
}
Some(Entry::File(path)) => units.push(Unit {
url: path.to_string_lossy().into_owned(),
syntax: syntax_for(path, cli.indented),
source: Source::File(path.clone()),
target: Target::Stdout,
}),
None => {}
}
for (src, dest) in &cli.pairs {
if src == Path::new("-") {
match stdin_source(&mut stdin_cache) {
Ok(source) => units.push(Unit {
source,
url: "-".to_string(),
syntax: stdin_syntax,
target: Target::File(dest.clone()),
}),
Err(code) => return code,
}
} else if src.is_dir() {
if let Err(msg) = expand_dir(src, dest, cli.indented, &mut units) {
eprintln!("{msg}");
return ExitCode::from(EXIT_IO);
}
} else {
units.push(Unit {
url: src.to_string_lossy().into_owned(),
syntax: syntax_for(src, cli.indented),
source: Source::File(src.clone()),
target: Target::File(dest.clone()),
});
}
}
let units = {
let cwd = std::env::current_dir().unwrap_or_default();
let mut keys: Vec<PathBuf> = Vec::new();
let mut kept: Vec<Unit> = Vec::new();
for unit in units {
let key = match &unit.source {
Source::File(path) => path_key(&normalize_path(&cwd.join(path))),
Source::Text(_) | Source::InvalidUtf8 => PathBuf::from("-"),
};
match keys.iter().position(|k| *k == key) {
Some(i) => kept[i].target = unit.target,
None => {
keys.push(key);
kept.push(unit);
}
}
}
kept
};
let mut units = units;
if let Some(output) = &cli.output {
if let Some(unit) = units.first_mut() {
unit.target = Target::File(output.clone());
}
}
if units.is_empty() {
if cli.pairs.is_empty() && !dir_entry {
return usage_error("no input file (pass a path, an <in>:<out> pair, or --stdin)");
}
return ExitCode::SUCCESS;
}
let shared = Shared {
load_paths: cli.load_paths.clone(),
style: cli.style,
unicode: !cli.no_unicode,
charset: cli.charset,
quiet: cli.quiet,
quiet_deps: cli.quiet_deps,
no_css: cli.no_css,
embed_sources: cli.embed_sources,
embed_source_map: cli.embed_source_map,
source_map_urls: cli.source_map_urls.unwrap_or(SourceMapUrls::Relative),
file_source_map: cli.source_map.unwrap_or(true) || cli.embed_source_map,
file_error_css: cli.error_css.unwrap_or(true),
stdout_error_css: cli.error_css == Some(true),
};
if let Some(n) = cli.loop_n {
return run_loop(&units, &shared, n);
}
let jobs = cli
.jobs
.unwrap_or_else(|| std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1));
let outcomes = compile_all(&units, &shared, jobs, cli.stop_on_error);
let mut worst = Status::Ok;
{
use std::io::Write;
let mut stdout = std::io::stdout().lock();
let mut stderr = std::io::stderr().lock();
let mut stderr_ends_blank = true;
let mut stdout_ok = true;
for outcome in outcomes.into_iter().flatten() {
worst = worse(worst, outcome.status);
if !outcome.stderr.is_empty() {
if !stderr_ends_blank {
let _ = stderr.write_all(b"\n");
}
let _ = stderr.write_all(outcome.stderr.as_bytes());
stderr_ends_blank = outcome.stderr.ends_with("\n\n");
}
if stdout_ok && !outcome.stdout.is_empty() {
if let Err(e) = stdout.write_all(outcome.stdout.as_bytes()) {
stdout_ok = false;
if e.kind() != std::io::ErrorKind::BrokenPipe {
let _ = writeln!(stderr, "error: cannot write to stdout: {e}");
worst = Status::IoError;
}
}
}
}
if stdout_ok {
if let Err(e) = stdout.flush() {
if e.kind() != std::io::ErrorKind::BrokenPipe {
let _ = writeln!(stderr, "error: cannot write to stdout: {e}");
worst = Status::IoError;
}
}
}
}
match worst {
Status::Ok => ExitCode::SUCCESS,
Status::CompileError => ExitCode::from(EXIT_COMPILE),
Status::IoError => ExitCode::from(EXIT_IO),
}
}
fn worse(a: Status, b: Status) -> Status {
match (a, b) {
(Status::IoError, _) | (_, Status::IoError) => Status::IoError,
(Status::CompileError, _) | (_, Status::CompileError) => Status::CompileError,
_ => Status::Ok,
}
}
fn compile_all(units: &[Unit], shared: &Shared, jobs: usize, stop_on_error: bool) -> Vec<Option<Outcome>> {
let n = units.len();
if jobs <= 1 || n <= 1 {
let mut results = Vec::with_capacity(n);
for unit in units {
let outcome = compile_unit(unit, shared);
let failed = outcome.status != Status::Ok;
results.push(Some(outcome));
if failed && stop_on_error {
break;
}
}
results.resize_with(n, || None);
return results;
}
let next = AtomicUsize::new(0);
let failed = AtomicBool::new(false);
let slots: Vec<Mutex<Option<Outcome>>> = (0..n).map(|_| Mutex::new(None)).collect();
std::thread::scope(|scope| {
for _ in 0..jobs.min(n) {
scope.spawn(|| loop {
if stop_on_error && failed.load(Ordering::Relaxed) {
break;
}
let i = next.fetch_add(1, Ordering::Relaxed);
if i >= n {
break;
}
if stop_on_error && failed.load(Ordering::Relaxed) {
break;
}
let outcome = compile_unit(&units[i], shared);
if outcome.status != Status::Ok {
failed.store(true, Ordering::Relaxed);
}
*slots[i].lock().unwrap_or_else(|p| p.into_inner()) = Some(outcome);
});
}
});
slots
.into_iter()
.map(|slot| slot.into_inner().unwrap_or_else(|p| p.into_inner()))
.collect()
}
fn read_source<'u>(unit: &'u Unit) -> Result<Cow<'u, str>, Outcome> {
match &unit.source {
Source::Text(s) => Ok(Cow::Borrowed(s.as_str())),
Source::InvalidUtf8 => Err(Outcome::failed(
Status::CompileError,
"Error: Invalid UTF-8.\n".to_string(),
)),
Source::File(path) => match std::fs::read_to_string(path) {
Ok(s) => Ok(Cow::Owned(s)),
Err(e) if is_invalid_utf8(&e) => Err(Outcome::failed(
Status::CompileError,
"Error: Invalid UTF-8.\n".to_string(),
)),
Err(_) => Err(Outcome::failed(
Status::IoError,
format!("Error reading {}: Cannot open file.\n", path.display()),
)),
},
}
}
fn options_for<'a>(
unit: &'a Unit,
shared: &'a Shared,
importer: &'a FsImporter,
unicode: bool,
warn: WarnHandler,
) -> Options<'a> {
let opts = Options::default()
.with_style(shared.style)
.with_syntax(unit.syntax)
.with_importer(importer)
.with_url(&unit.url)
.with_unicode(unicode)
.with_charset(shared.charset)
.with_source_map_include_sources(shared.embed_sources)
.with_warn_handler(warn);
if shared.quiet_deps {
opts.with_quiet_deps(importer.dependencies())
} else {
opts
}
}
fn buffered_warn_handler(shared: &Shared, buf: Rc<RefCell<String>>) -> WarnHandler {
let quiet = shared.quiet;
Rc::new(move |ev: &WarnEvent<'_>| {
if quiet {
return;
}
let mut b = buf.borrow_mut();
b.push_str(ev.formatted);
b.push('\n');
})
}
fn silent_warn_handler() -> WarnHandler {
Rc::new(|_: &WarnEvent<'_>| {})
}
fn compile_unit(unit: &Unit, shared: &Shared) -> Outcome {
match read_source(unit) {
Ok(source) => compile_source(unit, &source, shared),
Err(mut outcome) => {
if outcome.status == Status::CompileError {
let message = outcome.stderr.trim_end_matches('\n').to_string();
finish_compile_error(unit, shared, &message, &message, &mut outcome);
}
outcome
}
}
}
fn finish_compile_error(unit: &Unit, shared: &Shared, rendered: &str, ascii: &str, outcome: &mut Outcome) {
if shared.no_css {
return;
}
match &unit.target {
Target::Stdout => {
if shared.stdout_error_css {
outcome.stdout = error_css(rendered, ascii);
}
}
Target::File(output) => {
if shared.file_error_css {
if let Err(msg) = write_file(output, error_css(rendered, ascii).as_bytes()) {
outcome.stderr.push_str(&msg);
outcome.stderr.push('\n');
outcome.status = Status::IoError;
}
} else if let Err(e) = std::fs::remove_file(output) {
if e.kind() != std::io::ErrorKind::NotFound {
outcome
.stderr
.push_str(&format!("error: cannot remove {}: {e}\n", output.display()));
outcome.status = Status::IoError;
}
}
}
}
}
fn compile_source(unit: &Unit, source: &str, shared: &Shared) -> Outcome {
let importer = FsImporter::new(shared.load_paths.clone());
let warnings = Rc::new(RefCell::new(String::new()));
let opts = options_for(
unit,
shared,
&importer,
shared.unicode,
buffered_warn_handler(shared, Rc::clone(&warnings)),
);
let want_map = !shared.no_css
&& match &unit.target {
Target::File(_) => shared.file_source_map,
Target::Stdout => shared.embed_source_map,
};
let compiled: Result<(String, Option<SourceMap>), sasso::Error> = if want_map {
compile_with_source_map(source, &opts).map(|r| (r.css, Some(r.source_map)))
} else {
compile(source, &opts).map(|css| (css, None))
};
let stdin_text = match &unit.source {
Source::Text(text) => Some(text.as_str()),
Source::File(_) | Source::InvalidUtf8 => None,
};
let mut outcome = Outcome {
stderr: std::mem::take(&mut *warnings.borrow_mut()),
stdout: String::new(),
status: Status::Ok,
};
match compiled {
Ok(_) if shared.no_css => {}
Ok((css, map)) => match &unit.target {
Target::Stdout => {
outcome.stdout = match map {
Some(map) => {
let sources = adjust_sources(
&map.sources,
&unit.url,
stdin_text,
Path::new(""),
SourceMapUrls::Absolute,
);
let json =
dart_map_json(None, &sources, map.sources_content.as_deref(), &map.mappings);
append_source_map_footer(&css, &data_uri(&json), shared.style)
}
None if css.is_empty() => css,
None => format!("{css}\n"),
};
}
Target::File(output) => {
if let Err(msg) = write_css_file(output, &css, map.as_ref(), &unit.url, stdin_text, shared) {
outcome.stderr.push_str(&msg);
outcome.stderr.push('\n');
outcome.status = Status::IoError;
}
}
},
Err(err) => {
let rendered = err.to_string();
outcome.stderr.push_str(&rendered);
outcome.stderr.push('\n');
outcome.status = Status::CompileError;
let want_error_css = match &unit.target {
Target::File(_) => shared.file_error_css,
Target::Stdout => shared.stdout_error_css,
};
let ascii = if want_error_css && shared.unicode {
let ascii_opts = options_for(unit, shared, &importer, false, silent_warn_handler());
compile(source, &ascii_opts)
.err()
.map(|e| e.to_string())
.unwrap_or_else(|| rendered.clone())
} else {
rendered.clone()
};
finish_compile_error(unit, shared, &rendered, &ascii, &mut outcome);
}
}
outcome
}
fn run_loop(units: &[Unit], shared: &Shared, n: u32) -> ExitCode {
let mut sources = Vec::with_capacity(units.len());
for unit in units {
let source = match read_source(unit) {
Ok(s) => s.into_owned(),
Err(outcome) => {
eprint!("{}", outcome.stderr);
return ExitCode::from(if outcome.status == Status::IoError {
EXIT_IO
} else {
EXIT_COMPILE
});
}
};
let outcome = compile_source(unit, &source, shared);
eprint!("{}", outcome.stderr);
if outcome.status != Status::Ok {
return ExitCode::from(if outcome.status == Status::IoError {
EXIT_IO
} else {
EXIT_COMPILE
});
}
sources.push(source);
}
let importer = FsImporter::new(shared.load_paths.clone());
let mut last = String::new();
let start = Instant::now();
for _ in 0..n {
for (unit, source) in units.iter().zip(&sources) {
let opts = options_for(unit, shared, &importer, shared.unicode, silent_warn_handler());
match compile(source, &opts) {
Ok(css) => last = css,
Err(e) => {
eprintln!("{e}");
return ExitCode::from(EXIT_COMPILE);
}
}
}
}
let elapsed = start.elapsed();
let per = elapsed.as_secs_f64() * 1000.0 / f64::from(n);
let per_sec = if per > 0.0 { 1000.0 / per } else { f64::INFINITY };
eprintln!("sasso: {n} compiles in {elapsed:.3?} => {per:.3} ms/compile, {per_sec:.1} compiles/sec");
if !shared.no_css && !last.is_empty() {
println!("{last}");
}
ExitCode::SUCCESS
}
fn stdin_source(cache: &mut Option<Source>) -> Result<Source, ExitCode> {
if let Some(source) = cache {
return Ok(source.clone());
}
let source = match read_stdin() {
Ok(text) => Source::Text(text),
Err(e) if is_invalid_utf8(&e) => Source::InvalidUtf8,
Err(e) => {
eprintln!("error: failed to read stdin: {e}");
return Err(ExitCode::from(EXIT_IO));
}
};
*cache = Some(source.clone());
Ok(source)
}
fn read_stdin() -> std::io::Result<String> {
use std::io::Read as _;
let mut s = String::new();
std::io::stdin().read_to_string(&mut s)?;
Ok(s)
}
fn is_invalid_utf8(e: &std::io::Error) -> bool {
e.kind() == std::io::ErrorKind::InvalidData
}
fn write_file(path: &Path, bytes: &[u8]) -> Result<(), String> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("error: cannot create {}: {e}", parent.display()))?;
}
}
std::fs::write(path, bytes).map_err(|e| format!("error: cannot write {}: {e}", path.display()))
}
fn write_css_file(
output: &Path,
css: &str,
map: Option<&SourceMap>,
input_url: &str,
stdin_text: Option<&str>,
shared: &Shared,
) -> Result<(), String> {
match map {
Some(map) => {
let map_path = append_ext(output, "map");
let file = encode_url_segment(&path_basename(output));
let sources = adjust_sources(
&map.sources,
input_url,
stdin_text,
&map_path,
shared.source_map_urls,
);
let map_json = dart_map_json(
Some(&file),
&sources,
map.sources_content.as_deref(),
&map.mappings,
);
if shared.embed_source_map {
let css = append_source_map_footer(css, &data_uri(&map_json), shared.style);
write_file(output, css.as_bytes())
} else {
let map_url = encode_url_segment(&path_basename(&map_path));
let css = append_source_map_footer(css, &map_url, shared.style);
write_file(&map_path, map_json.as_bytes())?;
write_file(output, css.as_bytes())
}
}
None => {
let css = format!("{css}\n");
write_file(output, css.as_bytes())
}
}
}
fn append_source_map_footer(css: &str, url: &str, style: OutputStyle) -> String {
let url = url.replace("*/", "%2A/");
let mut out = String::with_capacity(css.len() + url.len() + 32);
out.push_str(css);
match style {
OutputStyle::Expanded => out.push_str(&format!("\n\n/*# sourceMappingURL={url} */\n")),
OutputStyle::Compressed => out.push_str(&format!("/*# sourceMappingURL={url} */\n")),
}
out
}
fn error_css(rendered: &str, ascii: &str) -> String {
let ascii = ascii.trim_end_matches('\n');
let rendered = rendered.trim_end_matches('\n');
let comment = ascii.replace("*/", "*\u{2215}").replace('\n', "\n * ");
let mut content = String::with_capacity(rendered.len() + 32);
for c in rendered.chars() {
match c {
'"' => content.push_str("\\\""),
'\\' => content.push_str("\\\\"),
'\n' => content.push_str("\\a "),
c if !c.is_ascii() => content.push_str(&format!("\\{:x} ", c as u32)),
c => content.push(c),
}
}
format!(
"/* {comment} */\n\n\
body::before {{\n \
font-family: \"Source Code Pro\", \"SF Mono\", Monaco, Inconsolata, \"Fira Mono\",\n \
\"Droid Sans Mono\", monospace, monospace;\n \
white-space: pre;\n \
display: block;\n \
padding: 1em;\n \
margin-bottom: 1em;\n \
border-bottom: 2px solid black;\n \
content: \"{content}\";\n\
}}\n"
)
}
fn data_uri(json: &str) -> String {
format!("data:application/json;charset=utf-8,{}", uric_encode(json))
}
fn uric_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
let keep = b.is_ascii_alphanumeric()
|| matches!(
b,
b'-' | b'.'
| b'_'
| b'~'
| b'!'
| b'*'
| b'\''
| b'('
| b')'
| b';'
| b'/'
| b'?'
| b':'
| b'@'
| b'&'
| b'='
| b'+'
| b'$'
| b','
);
if keep {
out.push(b as char);
} else {
out.push('%');
out.push(hex_upper(b >> 4));
out.push(hex_upper(b & 0xf));
}
}
out
}
fn dart_map_json(
file: Option<&str>,
sources: &[String],
contents: Option<&[String]>,
mappings: &str,
) -> String {
let mut s = String::from("{\"version\":3,\"sourceRoot\":\"\",\"sources\":[");
for (i, src) in sources.iter().enumerate() {
if i > 0 {
s.push(',');
}
json_str(src, &mut s);
}
s.push_str("],\"names\":[],\"mappings\":");
json_str(mappings, &mut s);
if let Some(file) = file {
s.push_str(",\"file\":");
json_str(file, &mut s);
}
if let Some(contents) = contents {
s.push_str(",\"sourcesContent\":[");
for (i, c) in contents.iter().enumerate() {
if i > 0 {
s.push(',');
}
json_str(c, &mut s);
}
s.push(']');
}
s.push('}');
s
}
fn json_str(value: &str, out: &mut String) {
out.push('"');
for c in value.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\u{08}' => out.push_str("\\b"),
'\u{0c}' => out.push_str("\\f"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
}
fn path_basename(p: &Path) -> String {
p.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| p.to_string_lossy().into_owned())
}
fn append_ext(p: &Path, ext: &str) -> PathBuf {
let mut s = p.as_os_str().to_os_string();
s.push(".");
s.push(ext);
PathBuf::from(s)
}
fn adjust_sources(
sources: &[String],
input_url: &str,
stdin_text: Option<&str>,
map_path: &Path,
mode: SourceMapUrls,
) -> Vec<String> {
let cwd = std::env::current_dir().unwrap_or_default();
sources
.iter()
.map(|src| {
let is_entry = src == "stdin" || src == input_url;
if let (true, Some(text)) = (is_entry, stdin_text) {
return format!("data:;charset=utf-8,{}", uric_encode(text));
}
let raw: &str = if src == "stdin" { input_url } else { src.as_str() };
let abs = normalize_path(&cwd.join(raw));
match mode {
SourceMapUrls::Absolute => file_url(&abs),
SourceMapUrls::Relative => {
let map_dir = normalize_path(&cwd.join(map_path.parent().unwrap_or(Path::new(""))));
let rel = relative_path(&map_dir, &abs);
encode_url_path(&rel)
}
}
})
.collect()
}
#[cfg(windows)]
fn path_key(p: &Path) -> PathBuf {
PathBuf::from(p.to_string_lossy().to_lowercase())
}
#[cfg(not(windows))]
fn path_key(p: &Path) -> PathBuf {
p.to_path_buf()
}
fn normalize_path(p: &Path) -> PathBuf {
use std::path::Component;
let mut out: Vec<Component<'_>> = Vec::new();
for comp in p.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
if matches!(out.last(), Some(Component::Normal(_))) {
out.pop();
} else if !matches!(out.last(), Some(Component::RootDir | Component::Prefix(_))) {
out.push(comp);
}
}
c => out.push(c),
}
}
out.iter().collect()
}
fn relative_path(base: &Path, target: &Path) -> String {
use std::path::Component;
let base: Vec<Component<'_>> = base.components().collect();
let target: Vec<Component<'_>> = target.components().collect();
let common = base.iter().zip(target.iter()).take_while(|(a, b)| a == b).count();
let mut parts: Vec<String> = Vec::new();
for _ in common..base.len() {
parts.push("..".to_string());
}
for c in &target[common..] {
parts.push(c.as_os_str().to_string_lossy().into_owned());
}
parts.join("/")
}
fn file_url(abs: &Path) -> String {
use std::path::Component;
let mut s = String::from("file://");
for comp in abs.components() {
match comp {
Component::RootDir => {}
Component::Prefix(prefix) => {
use std::path::Prefix;
match prefix.kind() {
Prefix::Disk(letter) | Prefix::VerbatimDisk(letter) => {
s.push('/');
s.push(letter as char);
s.push(':');
}
Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
s.push_str(&encode_url_segment(&server.to_string_lossy()));
s.push('/');
s.push_str(&encode_url_segment(&share.to_string_lossy()));
}
Prefix::Verbatim(rest) | Prefix::DeviceNS(rest) => {
s.push('/');
s.push_str(&encode_url_segment(&rest.to_string_lossy()));
}
}
}
c => {
s.push('/');
s.push_str(&encode_url_segment(&c.as_os_str().to_string_lossy()));
}
}
}
s
}
fn encode_url_path(path: &str) -> String {
path.split('/')
.map(encode_url_segment)
.collect::<Vec<_>>()
.join("/")
}
fn encode_url_segment(seg: &str) -> String {
let mut out = String::with_capacity(seg.len());
for b in seg.bytes() {
let keep = b.is_ascii_alphanumeric()
|| matches!(
b,
b'-' | b'.'
| b'_'
| b'~'
| b'!'
| b'$'
| b'&'
| b'\''
| b'('
| b')'
| b'*'
| b'+'
| b','
| b';'
| b'='
| b'@'
);
if keep {
out.push(b as char);
} else {
out.push('%');
out.push(hex_upper(b >> 4));
out.push(hex_upper(b & 0xf));
}
}
out
}
fn hex_upper(nibble: u8) -> char {
match nibble {
0..=9 => (b'0' + nibble) as char,
_ => (b'A' + (nibble - 10)) as char,
}
}