svelte-compiler 0.1.4

Core compiler API for the Rust Svelte toolchain
Documentation
use std::fs;
use std::io;
use std::process::{Command, Stdio};

use camino::{Utf8Path, Utf8PathBuf};
use serde::de::DeserializeOwned;

const IGNORED_CHILDREN: [&str; 2] = ["_output", "_actual.json"];

#[derive(Debug, Clone)]
pub struct FixtureCase {
    pub name: String,
    pub path: Utf8PathBuf,
}

impl FixtureCase {
    pub fn read_required_text(&self, relative_path: &str) -> io::Result<String> {
        fs::read_to_string(self.path.join(relative_path))
    }

    pub fn read_optional_text(&self, relative_path: &str) -> io::Result<Option<String>> {
        let path = self.path.join(relative_path);
        if path.exists() {
            return fs::read_to_string(path).map(Some);
        }
        Ok(None)
    }

    #[must_use]
    pub fn has_file(&self, relative_path: &str) -> bool {
        self.path.join(relative_path).exists()
    }
}

pub fn discover_suite_cases(
    repo_root: &Utf8Path,
    suite_name: &str,
) -> io::Result<Vec<FixtureCase>> {
    discover_suite_cases_by_name(repo_root, suite_name)
}

pub fn discover_suite_cases_by_name(
    repo_root: &Utf8Path,
    suite_name: &str,
) -> io::Result<Vec<FixtureCase>> {
    let suite_samples = repo_root
        .join("packages")
        .join("svelte")
        .join("tests")
        .join(suite_name)
        .join("samples");

    let mut cases = Vec::new();
    for entry in fs::read_dir(&suite_samples)? {
        let entry = entry?;
        if !entry.file_type()?.is_dir() {
            continue;
        }

        let Ok(path) = Utf8PathBuf::from_path_buf(entry.path()) else {
            continue;
        };

        let name = entry.file_name().to_string_lossy().to_string();
        if name.starts_with('.') || has_only_ignored_children(&path)? {
            continue;
        }

        cases.push(FixtureCase { name, path });
    }

    cases.sort_unstable_by(|a, b| a.name.cmp(&b.name));
    Ok(cases)
}

pub fn load_test_config<T: DeserializeOwned>(case: &FixtureCase) -> io::Result<Option<T>> {
    let path = case.path.join("_config.js");
    if !path.exists() {
        return Ok(None);
    }

    let source = fs::read_to_string(&path)?;
    let transformed = transform_config_source(&source);
    evaluate_config_source(&transformed, case)
}

fn has_only_ignored_children(path: &Utf8Path) -> io::Result<bool> {
    let mut found_any = false;
    for entry in fs::read_dir(path)? {
        let entry = entry?;
        found_any = true;
        let child = entry.file_name().to_string_lossy().to_string();
        if !IGNORED_CHILDREN.contains(&child.as_str()) {
            return Ok(false);
        }
    }
    Ok(found_any)
}

fn transform_config_source(source: &str) -> String {
    let mut imported_names = Vec::<String>::new();
    let mut body = String::new();

    for line in source.lines() {
        let trimmed = line.trim_start();
        if trimmed.starts_with("import ") {
            imported_names.extend(parse_import_names(trimmed));
            continue;
        }
        body.push_str(line);
        body.push('\n');
    }

    let mut output = String::new();
    output.push_str(
        "const __dummy = new Proxy(function () { return __dummy; }, { get: () => __dummy, apply: () => __dummy, construct: () => __dummy });\n",
    );
    output.push_str("const test = (value) => value;\n");

    imported_names.sort();
    imported_names.dedup();
    for name in imported_names {
        if name == "test" || name.is_empty() {
            continue;
        }
        output.push_str("const ");
        output.push_str(&name);
        output.push_str(" = __dummy;\n");
    }

    let mut replaced = body.replacen("export default", "globalThis.__export_default =", 1);
    for (from, to) in [
        ("export const", "const"),
        ("export let", "let"),
        ("export var", "var"),
        ("export function", "function"),
        ("export class", "class"),
    ] {
        replaced = replaced.replace(from, to);
    }
    output.push_str(&replaced);
    output
}

#[derive(serde::Deserialize)]
struct ConfigEvalResult<T> {
    ok: bool,
    value: Option<T>,
    error: Option<String>,
}

fn evaluate_config_source<T: DeserializeOwned>(
    source: &str,
    case: &FixtureCase,
) -> io::Result<Option<T>> {
    let script = r"
import fs from 'node:fs';

function normalize(value, seen = new WeakSet()) {
  if (value === null) return null;

  const t = typeof value;
  if (t === 'string' || t === 'number' || t === 'boolean') return value;
  if (t === 'undefined') return null;
  if (t === 'bigint') return { __kind: 'bigint', value: value.toString() };
  if (t === 'function') {
    if (value.length === 0) {
      try {
        return normalize(value(), seen);
      } catch {}
    }
    return { __kind: 'function', source: value.toString() };
  }
  if (t === 'symbol') return { __kind: 'symbol', value: value.toString() };

  if (value instanceof RegExp) {
    return { __kind: 'regexp', source: value.source, flags: value.flags };
  }

  if (Array.isArray(value)) {
    return value.map((entry) => normalize(entry, seen));
  }

  if (t === 'object') {
    if (seen.has(value)) {
      return { __kind: 'circular' };
    }
    seen.add(value);
    const out = {};
    for (const key of Object.keys(value)) {
      const descriptor = Object.getOwnPropertyDescriptor(value, key);
      if (!descriptor) continue;
      if (Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
        out[key] = normalize(descriptor.value, seen);
      } else {
        out[key] = { __kind: 'accessor' };
      }
    }
    seen.delete(value);
    return out;
  }

  return { __kind: 'unknown', value: String(value) };
}

const source = fs.readFileSync(0, 'utf8');

try {
  eval(source);
  process.stdout.write(JSON.stringify({ ok: true, value: normalize(globalThis.__export_default) }));
} catch (error) {
  process.stdout.write(JSON.stringify({ ok: false, error: String(error) }));
  process.exit(1);
}
";

    let mut child = Command::new("node")
        .arg("--input-type=module")
        .arg("-e")
        .arg(script)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()?;

    if let Some(stdin) = &mut child.stdin {
        use std::io::Write;
        stdin.write_all(source.as_bytes())?;
    }

    let output = child.wait_with_output()?;
    if !output.status.success() {
        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(io::Error::other(format!(
            "failed to evaluate _config.js for {}: status={} stdout={} stderr={}",
            case.name,
            output.status,
            stdout.trim(),
            stderr.trim()
        )));
    }

    let result: ConfigEvalResult<T> = serde_json::from_slice(&output.stdout)
        .map_err(|error| io::Error::other(format!("invalid config JSON output: {error}")))?;

    if !result.ok {
        let message = result
            .error
            .as_deref()
            .unwrap_or("unknown config evaluation error");
        return Err(io::Error::other(format!(
            "failed to evaluate _config.js for {}: {message}",
            case.name
        )));
    }

    Ok(result.value)
}

fn parse_import_names(import_line: &str) -> Vec<String> {
    let line = import_line.trim().trim_end_matches(';');
    if !line.starts_with("import ") || line.starts_with("import '") || line.starts_with("import \"")
    {
        return Vec::new();
    }

    let Some((left, _)) = line[7..].split_once(" from ") else {
        return Vec::new();
    };
    parse_import_clause(left)
}

fn parse_import_clause(clause: &str) -> Vec<String> {
    let clause = clause.trim();
    if clause.is_empty() {
        return Vec::new();
    }
    if let Some(rest) = clause.strip_prefix("*")
        && let Some(alias) = rest.trim().strip_prefix("as ")
    {
        return vec![sanitize_ident(alias)];
    }
    if clause.starts_with('{') {
        return parse_named_imports(clause);
    }
    if let Some((default_name, named)) = clause.split_once(',') {
        let mut names = vec![sanitize_ident(default_name)];
        names.extend(parse_named_imports(named));
        return names;
    }
    vec![sanitize_ident(clause)]
}

fn parse_named_imports(clause: &str) -> Vec<String> {
    let trimmed = clause.trim();
    let inner = trimmed
        .strip_prefix('{')
        .and_then(|s| s.strip_suffix('}'))
        .unwrap_or(trimmed);

    inner
        .split(',')
        .map(str::trim)
        .filter(|entry| !entry.is_empty())
        .map(|entry| {
            if let Some((_, alias)) = entry.split_once(" as ") {
                sanitize_ident(alias)
            } else {
                sanitize_ident(entry)
            }
        })
        .collect()
}

fn sanitize_ident(raw: &str) -> String {
    raw.trim()
        .chars()
        .filter(|ch| ch.is_alphanumeric() || *ch == '_' || *ch == '$')
        .collect()
}