use codemap::SpanLoc;
use js_sys::{Array, Function, JsString, Map, Object, Reflect};
use wasm_bindgen::prelude::*;
use crate::{
Error as SassError, ErrorKind, Fs, InputSyntax, Logger, MemoryFs, Options, OutputStyle,
from_path, from_string, from_string_with_file_name,
};
#[wasm_bindgen(typescript_custom_section)]
const TYPES: &'static str = r#"
/** A stylesheet tree the compiler resolves imports against. Paths are virtual
* and normalized: "a/b.scss" and "./a/b.scss" name the same file. */
export type SassFiles = Record<string, string> | Map<string, string>;
/** An `@warn` or `@debug` reported while compiling. */
export interface SassLogEvent {
type: "warn" | "debug";
message: string;
file: string;
/** 1-based. */
line: number;
/** 1-based. */
column: number;
}
export interface CompileOptions {
/** Output style. Defaults to "expanded". */
style?: "expanded" | "compressed";
/** Syntax of the entry point only; imported files always infer their own.
* Defaults to the extension of `url`, else "scss". */
syntax?: "scss" | "indented" | "sass" | "css";
/** Paths searched when a relative import does not resolve. */
loadPaths?: string[];
/** The stylesheet tree to resolve imports against. */
files?: SassFiles;
/** Virtual path of the source, which relative imports resolve against.
* Defaults to "stdin". `compile()` ignores it and uses its path argument. */
url?: string;
/** Emit `@charset` or a byte-order mark for non-ASCII output. Defaults to true. */
charset?: boolean;
/** Restrict error messages to ASCII. Defaults to false. */
alertAscii?: boolean;
/** Silence `@warn`, `@debug` and deprecation warnings. Defaults to false. */
quiet?: boolean;
/** Called for each `@warn` and `@debug`. Exceptions it throws are ignored. */
logger?: (event: SassLogEvent) => void;
}
export interface CompileResult {
css: string;
/** The files the compile actually read, in the order it read them. */
loadedUrls: string[];
}
export interface SassException extends Error {
/** The message with no span or source context. */
message: string;
/** The full block the command-line compiler prints. */
formatted: string;
file: string;
/** 1-based. */
line: number;
/** 1-based. */
column: number;
}
"#;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(typescript_type = "CompileOptions")]
pub type CompileOptions;
#[wasm_bindgen(typescript_type = "CompileResult")]
pub type CompileResult;
}
#[wasm_bindgen(js_name = compileString)]
pub fn compile_string(
source: String,
options: Option<CompileOptions>,
) -> Result<CompileResult, JsValue> {
let config = Config::parse(options.as_ref())?;
let logger = JsLogger::new(config.logger);
let url = config.url.unwrap_or_else(|| "stdin".to_owned());
let built = config
.base
.build(&config.fs, &logger, &config.load_paths, config.syntax);
finish(from_string_with_file_name(source, &url, &built), &config.fs)
}
#[wasm_bindgen(js_name = compile)]
pub fn compile(path: String, options: Option<CompileOptions>) -> Result<CompileResult, JsValue> {
let config = Config::parse(options.as_ref())?;
let logger = JsLogger::new(config.logger);
let built = config
.base
.build(&config.fs, &logger, &config.load_paths, config.syntax);
finish(from_path(&path, &built), &config.fs)
}
#[wasm_bindgen(js_name = from_string)]
pub fn from_string_js(input: String) -> Result<String, String> {
from_string(input, &Options::default()).map_err(|e| e.to_string())
}
fn finish(result: crate::Result<String>, fs: &MemoryFs) -> Result<CompileResult, JsValue> {
match result {
Ok(css) => {
let loaded = fs
.loaded_paths()
.iter()
.map(|p| JsValue::from_str(&p.to_string_lossy()))
.collect::<Array>();
let out = Object::new();
Reflect::set(&out, &JsValue::from_str("css"), &JsValue::from_str(&css))?;
Reflect::set(&out, &JsValue::from_str("loadedUrls"), &loaded)?;
Ok(CompileResult::from(JsValue::from(out)))
}
Err(e) => Err(to_js_error(*e)),
}
}
fn to_js_error(error: SassError) -> JsValue {
let formatted = error.to_string();
let (message, file, line, column) = match error.kind() {
ErrorKind::ParseError { message, loc, .. } => {
let (file, line, column) = span_parts(&loc);
(message, file, line, column)
}
ErrorKind::IoError(io) => (io.to_string(), String::new(), 0, 0),
ErrorKind::FromUtf8Error(message) => (message, String::new(), 0, 0),
};
let js = js_sys::Error::new(&message);
let value = JsValue::from(js);
let _ = Reflect::set(
&value,
&JsValue::from_str("formatted"),
&JsValue::from_str(&formatted),
);
let _ = Reflect::set(
&value,
&JsValue::from_str("file"),
&JsValue::from_str(&file),
);
let _ = Reflect::set(&value, &JsValue::from_str("line"), &JsValue::from(line));
let _ = Reflect::set(&value, &JsValue::from_str("column"), &JsValue::from(column));
value
}
fn span_parts(loc: &SpanLoc) -> (String, u32, u32) {
(
loc.file.name().to_owned(),
loc.begin.line as u32 + 1,
loc.begin.column as u32 + 1,
)
}
struct Config {
base: BaseOptions,
fs: MemoryFs,
load_paths: Vec<String>,
syntax: Option<InputSyntax>,
url: Option<String>,
logger: Option<Function>,
}
struct BaseOptions {
style: OutputStyle,
charset: bool,
unicode_error_messages: bool,
quiet: bool,
}
impl BaseOptions {
fn build<'a>(
&self,
fs: &'a dyn Fs,
logger: &'a dyn Logger,
load_paths: &[String],
syntax: Option<InputSyntax>,
) -> Options<'a> {
let mut options = Options::default()
.fs(fs)
.logger(logger)
.style(self.style)
.allows_charset(self.charset)
.unicode_error_messages(self.unicode_error_messages)
.quiet(self.quiet)
.load_paths(load_paths);
if let Some(syntax) = syntax {
options = options.input_syntax(syntax);
}
options
}
}
impl Config {
fn parse(options: Option<&CompileOptions>) -> Result<Self, JsValue> {
let Some(options) = options.map(|value| -> &JsValue { value.as_ref() }) else {
return Ok(Self::defaults());
};
if options.is_undefined() || options.is_null() {
return Ok(Self::defaults());
}
if !options.is_object() {
return Err(type_error("options must be an object"));
}
let mut config = Self::defaults();
if let Some(value) = get(options, "style")? {
config.base.style = match require_string(&value, "style")?.as_str() {
"expanded" => OutputStyle::Expanded,
"compressed" => OutputStyle::Compressed,
other => {
return Err(type_error(&format!(
"style must be \"expanded\" or \"compressed\", got {other:?}"
)));
}
};
}
if let Some(value) = get(options, "syntax")? {
config.syntax = Some(match require_string(&value, "syntax")?.as_str() {
"scss" => InputSyntax::Scss,
"indented" | "sass" => InputSyntax::Sass,
"css" => InputSyntax::Css,
other => {
return Err(type_error(&format!(
"syntax must be \"scss\", \"indented\" or \"css\", got {other:?}"
)));
}
});
}
if let Some(value) = get(options, "loadPaths")? {
let array: Array = value
.dyn_into()
.map_err(|_| type_error("loadPaths must be an array of strings"))?;
for entry in array.iter() {
config
.load_paths
.push(require_string(&entry, "loadPaths entry")?);
}
}
if let Some(value) = get(options, "files")? {
read_files(&value, &mut config.fs)?;
}
if let Some(value) = get(options, "url")? {
config.url = Some(require_string(&value, "url")?);
}
if let Some(value) = get(options, "charset")? {
config.base.charset = require_bool(&value, "charset")?;
}
if let Some(value) = get(options, "alertAscii")? {
config.base.unicode_error_messages = !require_bool(&value, "alertAscii")?;
}
if let Some(value) = get(options, "quiet")? {
config.base.quiet = require_bool(&value, "quiet")?;
}
if let Some(value) = get(options, "logger")? {
config.logger = Some(
value
.dyn_into()
.map_err(|_| type_error("logger must be a function"))?,
);
}
Ok(config)
}
fn defaults() -> Self {
Self {
base: BaseOptions {
style: OutputStyle::Expanded,
charset: true,
unicode_error_messages: true,
quiet: false,
},
fs: MemoryFs::new(),
load_paths: Vec::new(),
syntax: None,
url: None,
logger: None,
}
}
}
fn read_files(value: &JsValue, fs: &mut MemoryFs) -> Result<(), JsValue> {
if let Some(map) = value.dyn_ref::<Map>() {
let entries = map.entries();
let iterator = js_sys::try_iter(&entries)?
.ok_or_else(|| type_error("files must be a Map or an object"))?;
for entry in iterator {
let pair: Array = entry?
.dyn_into()
.map_err(|_| type_error("files entries must be [path, source] pairs"))?;
insert_file(fs, &pair.get(0), &pair.get(1))?;
}
return Ok(());
}
if Array::is_array(value) || value.is_function() {
return Err(type_error("files must be a Map or an object"));
}
if value.is_object() {
for entry in Object::entries(&Object::from(value.clone())).iter() {
let pair: Array = entry
.dyn_into()
.map_err(|_| type_error("files entries must be [path, source] pairs"))?;
insert_file(fs, &pair.get(0), &pair.get(1))?;
}
return Ok(());
}
Err(type_error("files must be a Map or an object"))
}
fn insert_file(fs: &mut MemoryFs, path: &JsValue, source: &JsValue) -> Result<(), JsValue> {
let path = require_string(path, "files key")?;
let source = require_string(source, "files value")?;
fs.insert(path, source);
Ok(())
}
fn get(object: &JsValue, key: &str) -> Result<Option<JsValue>, JsValue> {
let value = Reflect::get(object, &JsValue::from_str(key))?;
if value.is_undefined() || value.is_null() {
return Ok(None);
}
Ok(Some(value))
}
fn require_string(value: &JsValue, field: &str) -> Result<String, JsValue> {
value
.dyn_ref::<JsString>()
.map(String::from)
.ok_or_else(|| type_error(&format!("{field} must be a string")))
}
fn require_bool(value: &JsValue, field: &str) -> Result<bool, JsValue> {
value
.as_bool()
.ok_or_else(|| type_error(&format!("{field} must be a boolean")))
}
fn type_error(message: &str) -> JsValue {
JsValue::from(js_sys::TypeError::new(message))
}
struct JsLogger {
callback: Option<Function>,
}
impl JsLogger {
const fn new(callback: Option<Function>) -> Self {
Self { callback }
}
fn emit(&self, kind: &str, location: &SpanLoc, message: &str) {
let Some(callback) = &self.callback else {
return;
};
let (file, line, column) = span_parts(location);
let event = Object::new();
let _ = Reflect::set(&event, &JsValue::from_str("type"), &JsValue::from_str(kind));
let _ = Reflect::set(
&event,
&JsValue::from_str("message"),
&JsValue::from_str(message),
);
let _ = Reflect::set(
&event,
&JsValue::from_str("file"),
&JsValue::from_str(&file),
);
let _ = Reflect::set(&event, &JsValue::from_str("line"), &JsValue::from(line));
let _ = Reflect::set(&event, &JsValue::from_str("column"), &JsValue::from(column));
let _ = callback.call1(&JsValue::NULL, &event);
}
}
impl Logger for JsLogger {
fn debug(&self, location: SpanLoc, message: &str) {
self.emit("debug", &location, message);
}
fn warn(&self, location: SpanLoc, message: &str) {
self.emit("warn", &location, message);
}
}
impl std::fmt::Debug for JsLogger {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JsLogger")
.field("callback", &self.callback.is_some())
.finish()
}
}