use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::Instant;
use sasso::{compile, compile_with_source_map, FsImporter, Options, OutputStyle, Syntax};
#[global_allocator]
static GLOBAL: sasso::ScopedAlloc = sasso::ScopedAlloc;
const USAGE: &str = "\
sasso — a pure-Rust SCSS to CSS compiler
USAGE:
sasso [options] <input.scss>
sasso --stdin [options] < input.scss
OPTIONS:
-s, --style <expanded|compressed> output style (default: expanded)
-I, --load-path <dir> add an @import search path (repeatable)
-o, --output <file> write CSS to <file> instead of stdout
--source-map also write <output>.map and append a
sourceMappingURL footer (requires -o)
--embed-sources embed full source text in the map's
sourcesContent
--source-map-urls <relative|absolute>
how the map references its sources
(default: relative)
--stdin read SCSS from standard input
--indented parse the indented .sass syntax
--no-unicode ASCII-only diagnostics (no box glyphs)
--loop <N> recompile in-process N times (throughput)
-q, --quiet suppress CSS on stdout (timing only)
--version print version and exit
-h, --help print this help and exit
NOTE: --embed-source-map (inline data: URI) is not yet supported.
";
struct Cli {
inputs: Vec<PathBuf>,
use_stdin: bool,
style: OutputStyle,
load_paths: Vec<PathBuf>,
indented: bool,
quiet: bool,
loop_n: Option<u32>,
no_unicode: bool,
output: Option<PathBuf>,
source_map: bool,
embed_sources: bool,
source_map_urls: SourceMapUrls,
}
#[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::FAILURE
}
}
}
enum Action {
Run(Cli),
Help,
Version,
}
fn parse_args(args: &[String]) -> Result<Action, String> {
let mut cli = Cli {
inputs: Vec::new(),
use_stdin: false,
style: OutputStyle::Expanded,
load_paths: Vec::new(),
indented: false,
quiet: false,
loop_n: None,
no_unicode: false,
output: None,
source_map: false,
embed_sources: false,
source_map_urls: SourceMapUrls::Relative,
};
let mut i = 0;
while i < args.len() {
let a = &args[i];
match a.as_str() {
"-h" | "--help" => return Ok(Action::Help),
"--version" => return Ok(Action::Version),
"--stdin" => cli.use_stdin = true,
"--indented" => cli.indented = true,
"--no-unicode" => cli.no_unicode = true,
"--source-map" => cli.source_map = true,
"--embed-sources" => cli.embed_sources = true,
"-q" | "--quiet" => cli.quiet = 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 = 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)?);
}
"-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("--output=") {
cli.output = Some(PathBuf::from(v));
} else if let Some(v) = other.strip_prefix("--source-map-urls=") {
cli.source_map_urls = parse_source_map_urls(v)?;
} else if other.starts_with('-') && other != "-" {
return Err(format!("unknown option {other}"));
} else {
cli.inputs.push(PathBuf::from(other));
}
}
}
i += 1;
}
if cli.source_map && cli.output.is_none() {
return Err("--source-map requires --output".to_string());
}
if cli.output.is_some() && cli.inputs.len() + usize::from(cli.use_stdin) > 1 {
return Err("--output requires a single input".to_string());
}
Ok(Action::Run(cli))
}
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_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)"
)),
}
}
fn run(cli: Cli) -> ExitCode {
let mut units: Vec<(String, Syntax, String)> = Vec::new();
if cli.use_stdin {
let syntax = if cli.indented { Syntax::Sass } else { Syntax::Scss };
match read_stdin() {
Ok(s) => units.push((s, syntax, "-".to_string())),
Err(e) => {
if is_invalid_utf8(&e) {
eprintln!("Error: Invalid UTF-8.");
return ExitCode::from(65);
}
eprintln!("error: failed to read stdin: {e}");
return ExitCode::FAILURE;
}
}
} else {
if cli.inputs.is_empty() {
eprintln!("error: no input file (pass a path or --stdin)\n");
eprint!("{USAGE}");
return ExitCode::FAILURE;
}
for path in &cli.inputs {
let ext_is_sass = path
.extension()
.map(|e| e.eq_ignore_ascii_case("sass"))
.unwrap_or(false);
let syntax = if cli.indented || ext_is_sass {
Syntax::Sass
} else {
Syntax::Scss
};
match std::fs::read_to_string(path) {
Ok(s) => units.push((s, syntax, path.to_string_lossy().into_owned())),
Err(e) => {
if is_invalid_utf8(&e) {
eprintln!("Error: Invalid UTF-8.");
return ExitCode::from(65);
}
eprintln!("error: cannot read {}: {e}", path.display());
return ExitCode::FAILURE;
}
}
}
}
let importer = FsImporter::new(cli.load_paths);
let style = cli.style;
let unicode = !cli.no_unicode;
fn opts_for<'a>(
style: OutputStyle,
importer: &'a FsImporter,
unicode: bool,
syntax: Syntax,
url: &'a str,
) -> Options<'a> {
Options::default()
.with_style(style)
.with_syntax(syntax)
.with_importer(importer)
.with_url(url)
.with_unicode(unicode)
}
if let Some(output) = &cli.output {
let (source, syntax, url) = &units[0];
let opts = opts_for(style, &importer, unicode, *syntax, url)
.with_source_map_include_sources(cli.embed_sources);
return write_output(
&opts,
source,
output,
url,
cli.source_map,
cli.style,
cli.source_map_urls,
);
}
if let Some(n) = cli.loop_n {
for (source, syntax, url) in &units {
if let Err(e) = compile(source, &opts_for(style, &importer, unicode, *syntax, url)) {
eprintln!("{e}");
return ExitCode::from(65);
}
}
let mut last = String::new();
let start = Instant::now();
for _ in 0..n {
for (source, syntax, url) in &units {
match compile(source, &opts_for(style, &importer, unicode, *syntax, url)) {
Ok(css) => last = css,
Err(e) => {
eprintln!("{e}");
return ExitCode::from(65);
}
}
}
}
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 !cli.quiet && !last.is_empty() {
println!("{last}");
}
return ExitCode::SUCCESS;
}
let mut out = String::new();
for (source, syntax, url) in &units {
match compile(source, &opts_for(style, &importer, unicode, *syntax, url)) {
Ok(css) => {
if !css.is_empty() {
out.push_str(&css);
out.push('\n');
}
}
Err(e) => {
eprintln!("{e}");
return ExitCode::from(65);
}
}
}
if !cli.quiet {
print!("{out}");
}
ExitCode::SUCCESS
}
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_output(
opts: &Options<'_>,
source: &str,
output: &Path,
input_url: &str,
source_map: bool,
style: OutputStyle,
source_map_urls: SourceMapUrls,
) -> ExitCode {
if source_map {
let result = match compile_with_source_map(source, opts) {
Ok(r) => r,
Err(e) => {
eprintln!("{e}");
return ExitCode::from(65);
}
};
let map_path = append_ext(output, "map");
let map_url = path_basename(&map_path);
let css = append_source_map_footer(&result.css, &map_url, style);
let file = path_basename(output);
let sources = adjust_sources(&result.source_map.sources, input_url, &map_path, source_map_urls);
let map_json = dart_map_json(
&file,
&sources,
result.source_map.sources_content.as_deref(),
&result.source_map.mappings,
);
if let Err(e) = std::fs::write(output, css.as_bytes()) {
eprintln!("error: cannot write {}: {e}", output.display());
return ExitCode::FAILURE;
}
if let Err(e) = std::fs::write(&map_path, map_json.as_bytes()) {
eprintln!("error: cannot write {}: {e}", map_path.display());
return ExitCode::FAILURE;
}
} else {
let mut css = match compile(source, opts) {
Ok(css) => css,
Err(e) => {
eprintln!("{e}");
return ExitCode::from(65);
}
};
if !css.is_empty() {
css.push('\n');
}
if let Err(e) = std::fs::write(output, css.as_bytes()) {
eprintln!("error: cannot write {}: {e}", output.display());
return ExitCode::FAILURE;
}
}
ExitCode::SUCCESS
}
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 dart_map_json(file: &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);
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, map_path: &Path, mode: SourceMapUrls) -> Vec<String> {
let cwd = std::env::current_dir().unwrap_or_default();
sources
.iter()
.map(|src| {
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()
}
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(_) => {}
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,
}
}