tsv_ts 0.8.0

TypeScript/JS formatter, type stripper and acorn-compatible parser — part of tsv
Documentation
//! The ECMAScript parse goal symbol (`Script` vs `Module`).
//!
//! The goal is a parse-time input — literally which symbol the grammar starts
//! from (`ParseScript` vs `ParseModule` in the spec). It governs *syntactic
//! availability* of a handful of constructs and nothing else.
//!
//! It is orthogonal to **strictness**, which is a property of the source text
//! rather than of the goal, with one coupling: Module code is strict by
//! definition, while Script code is strict only once a `"use strict"` directive
//! prologue says so. So `Goal` itself toggles the goal-specific grammar below;
//! what strict code disallows is decided separately, by the parser's `strict`
//! state.

use std::fmt;
use std::path::Path;
use std::str::FromStr;

/// The syntactic goal symbol a parse runs against.
///
/// Defaults to [`Goal::Module`], which is correct for Svelte `<script>` blocks
/// (always modules) and essentially all real-world TypeScript. [`Goal::Script`]
/// exists for standalone scripts and parser-conformance grading, where the
/// goal-specific constructs differ.
///
/// The only axis this enum moves is the goal symbol. The four constructs that
/// differ between the goals:
///
/// | construct | `Module` | `Script` |
/// | --- | --- | --- |
/// | `await` as an identifier / binding / label / class name | reserved | allowed (`[~Await]`) |
/// | top-level `await` — an *expression*, or a `for await` loop | allowed | syntax error |
/// | `import.meta` | allowed | syntax error |
/// | top-level `import` / `export` *declarations* | allowed | syntax error |
///
/// Dynamic `import(...)` is a call expression valid under both goals.
///
/// A goal may be added (a CommonJS-style one, say, where a top-level `return` is legal), so a
/// match outside this crate carries a wildcard arm.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum Goal {
    /// `ParseModule` — the default. Top-level `import`/`export`, `import.meta`,
    /// and top-level `await` expressions are available; `await` is reserved as
    /// an identifier. Mirrors acorn's `sourceType: 'module'`.
    #[default]
    Module,
    /// `ParseScript` — `await` is an ordinary identifier (the top level is
    /// `[~Await]`); `import`/`export` declarations, `import.meta`, and top-level
    /// `await` expressions and `for await` loops are syntax errors. Script code is **sloppy** unless its
    /// directive prologue holds a `"use strict"`, so the strict-mode rules the parser
    /// enforces (a `with` statement, a leading-zero numeric literal, a legacy
    /// string escape) parse where a module rejects them. The Annex B
    /// web-compatibility grammar is out of scope at both goals.
    Script,
}

impl Goal {
    /// The acorn `sourceType` string this goal serializes to in the public AST
    /// (`Program.sourceType`) — `"module"` or `"script"`, the vocabulary its
    /// [`FromStr`] reads back and its [`Display`](fmt::Display) writes. Hidden: `Display`
    /// and `FromStr` are the public spelling; this is the `const` form the wire writer and
    /// tsv's tooling read.
    #[doc(hidden)]
    #[must_use]
    pub const fn source_type(self) -> &'static str {
        match self {
            Goal::Module => "module",
            Goal::Script => "script",
        }
    }

    /// The goal a file's **extension** settles, or `None` when it settles nothing.
    ///
    /// Two of the extensions tsv formats carry a goal in the name rather than in
    /// the file: `.mjs` and `.mts` are ES modules whatever any config says — Node
    /// loads a `.mjs` as ESM unconditionally, and TypeScript maps `.mts`/`.mjs` to
    /// `ModuleKind.ESNext` with the extension overriding `module` (the implied-format
    /// switch in the TypeScript repository's `src/compiler/program.ts`). Their `.c*`
    /// counterparts are the CommonJS half of that same switch, but CommonJS is not the `Script`
    /// goal in any useful sense here — a `.cjs` is script *code* yet nothing in tsv's
    /// output turns on it — so they stay unsettled with `.js`/`.ts`.
    ///
    /// A caller that formats a path reads this instead of naming nothing at all: the
    /// module-then-script fallback exists to reach a **legacy sloppy script**, and a
    /// file whose own extension forbids being one has nothing to fall back to. Every
    /// source the module grammar accepts formats identically either way — the retry
    /// fires only on a module parse *failure* — so this narrows the fallback to the
    /// files it was written for and never changes an output.
    ///
    /// `None` for every other extension (including a Svelte or CSS path, whose parser
    /// has no goal axis at all), leaving the caller's own default in force.
    ///
    /// The extension is `Path::extension`'s, read without regard to ASCII case
    /// (`legacy.MJS`), as every extension dispatch in the CLIs reads one.
    ///
    /// ```
    /// use std::path::Path;
    /// use tsv_ts::Goal;
    ///
    /// assert_eq!(Goal::from_extension("src/legacy.MJS"), Some(Goal::Module));
    /// assert_eq!(Goal::from_extension(Path::new("a.mjs")), Some(Goal::Module));
    /// assert_eq!(Goal::from_extension("a.mts"), Some(Goal::Module));
    /// // `.js`, `.ts` and the CommonJS names settle nothing
    /// assert_eq!(Goal::from_extension("a.cjs"), None);
    /// assert_eq!(Goal::from_extension("a.ts"), None);
    ///
    /// // a path-driven caller takes the exact tier where the name settles the goal and the
    /// // bare one where it does not
    /// # let source = "let x: number = 1;";
    /// let stripped = match Goal::from_extension("a.mts") {
    ///     Some(goal) => tsv_ts::strip_with_goal(source, goal),
    ///     None => tsv_ts::strip(source),
    /// };
    /// assert_eq!(stripped.unwrap().code, "let x         = 1;");
    /// ```
    #[must_use]
    pub fn from_extension(path: impl AsRef<Path>) -> Option<Goal> {
        path.as_ref()
            .extension()
            .is_some_and(|ext| ext.eq_ignore_ascii_case("mjs") || ext.eq_ignore_ascii_case("mts"))
            .then_some(Goal::Module)
    }
}

/// Reads a goal from the string its [`Display`](fmt::Display) writes (`"module"` /
/// `"script"`), case-sensitively — the vocabulary acorn's
/// `sourceType` and the `tsv` CLI's `--source-type` use.
///
/// ```
/// use tsv_ts::Goal;
///
/// assert_eq!("script".parse::<Goal>(), Ok(Goal::Script));
/// let error = "Module".parse::<Goal>().unwrap_err();
/// assert_eq!(error.to_string(), "expected 'script' or 'module'");
/// ```
// The single source of truth for the goal vocabulary, shared by the CLI `--source-type`
// flag (`tsv_cli`), the bindings (`tsv_wasm`, `tsv_napi`), and the fixture goal-marker
// reader (`tsv_debug`); callers layer their own default and error formatting on top.
impl FromStr for Goal {
    type Err = ParseGoalError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "module" => Ok(Goal::Module),
            "script" => Ok(Goal::Script),
            _ => Err(ParseGoalError),
        }
    }
}

/// Writes the goal's acorn `sourceType` string: `"module"` or `"script"`.
impl fmt::Display for Goal {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.source_type())
    }
}

/// The error [`Goal`]'s [`FromStr`] returns for a string that names no goal — anything but
/// `"module"` or `"script"`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct ParseGoalError;

impl fmt::Display for ParseGoalError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("expected 'script' or 'module'")
    }
}

impl std::error::Error for ParseGoalError {}

#[cfg(test)]
mod tests {
    use super::Goal;

    #[test]
    fn source_type_round_trips() {
        for goal in [Goal::Module, Goal::Script] {
            assert_eq!(goal.source_type().parse::<Goal>(), Ok(goal));
            assert_eq!(goal.to_string(), goal.source_type());
        }
    }

    #[test]
    fn from_extension_settles_the_module_names_in_any_case() {
        for module in ["a.mjs", "a.mts", "A.MJS", "dir/x.Mts", "a.svelte.mjs"] {
            assert_eq!(Goal::from_extension(module), Some(Goal::Module), "{module}");
        }
        for unsettled in [
            "a.js", "a.ts", "a.cjs", "a.cts", "mjs", ".mjs", "a.mjsx", "a.svelte",
        ] {
            assert_eq!(Goal::from_extension(unsettled), None, "{unsettled}");
        }
    }

    #[test]
    fn from_str_rejects_unknown() {
        assert!("sloppy".parse::<Goal>().is_err());
        assert!("Module".parse::<Goal>().is_err()); // case-sensitive
        assert!("".parse::<Goal>().is_err());
    }

    #[test]
    fn default_is_module() {
        assert_eq!(Goal::default(), Goal::Module);
    }

    #[test]
    fn only_the_module_only_extensions_settle_a_goal() {
        for path in ["a.mjs", "a.mts", "deep/dir/a.mjs", "a.config.mjs"] {
            assert_eq!(
                Goal::from_extension(path),
                Some(Goal::Module),
                "{path} is an ES module by its own extension"
            );
        }
        for path in [
            "a.js", "a.mjsx", "a.ts", "a.cjs", "a.cts", "a.svelte", "a.css", "amjs", "a", "",
        ] {
            assert_eq!(
                Goal::from_extension(path),
                None,
                "{path} settles no goal — the caller's default stands"
            );
        }
    }
}