use-tsconfig 0.0.1

Partial tsconfig metadata primitives for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::fmt;
use std::{collections::BTreeMap, error::Error};
use use_ts::{TsModuleResolution, TsStrictness, TsTarget};

/// Partial `tsconfig.json` metadata.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct TsConfig {
    extends: Option<TsConfigExtends>,
    compiler_options: CompilerOptions,
    include: Vec<TsConfigInclude>,
    exclude: Vec<TsConfigExclude>,
}

impl TsConfig {
    /// Creates empty `tsconfig` metadata.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Sets the `extends` metadata.
    #[must_use]
    pub fn with_extends(mut self, extends: TsConfigExtends) -> Self {
        self.extends = Some(extends);
        self
    }

    /// Sets compiler options metadata.
    #[must_use]
    pub fn with_compiler_options(mut self, compiler_options: CompilerOptions) -> Self {
        self.compiler_options = compiler_options;
        self
    }

    /// Adds an include pattern.
    #[must_use]
    pub fn with_include(mut self, include: TsConfigInclude) -> Self {
        self.include.push(include);
        self
    }

    /// Adds an exclude pattern.
    #[must_use]
    pub fn with_exclude(mut self, exclude: TsConfigExclude) -> Self {
        self.exclude.push(exclude);
        self
    }

    /// Returns `extends` metadata.
    #[must_use]
    pub const fn extends(&self) -> Option<&TsConfigExtends> {
        self.extends.as_ref()
    }

    /// Returns compiler options metadata.
    #[must_use]
    pub const fn compiler_options(&self) -> &CompilerOptions {
        &self.compiler_options
    }

    /// Returns include patterns.
    #[must_use]
    pub fn include(&self) -> &[TsConfigInclude] {
        &self.include
    }

    /// Returns exclude patterns.
    #[must_use]
    pub fn exclude(&self) -> &[TsConfigExclude] {
        &self.exclude
    }
}

/// Partial compiler options metadata.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct CompilerOptions {
    target: Option<TsTarget>,
    module: Option<String>,
    module_resolution: Option<TsModuleResolution>,
    strict: Option<bool>,
    jsx: Option<String>,
    base_url: Option<String>,
    paths: BTreeMap<String, Vec<String>>,
}

impl CompilerOptions {
    /// Creates empty compiler options metadata.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Sets the target option.
    #[must_use]
    pub const fn with_target(mut self, target: TsTarget) -> Self {
        self.target = Some(target);
        self
    }

    /// Sets the module option label.
    #[must_use]
    pub fn with_module(mut self, module: &str) -> Self {
        self.module = non_empty(module);
        self
    }

    /// Sets the module resolution option.
    #[must_use]
    pub const fn with_module_resolution(mut self, module_resolution: TsModuleResolution) -> Self {
        self.module_resolution = Some(module_resolution);
        self
    }

    /// Sets strictness metadata.
    #[must_use]
    pub const fn with_strictness(mut self, strictness: TsStrictness) -> Self {
        self.strict = Some(matches!(strictness, TsStrictness::Strict));
        self
    }

    /// Sets the JSX option label.
    #[must_use]
    pub fn with_jsx(mut self, jsx: &str) -> Self {
        self.jsx = non_empty(jsx);
        self
    }

    /// Sets the base URL option.
    #[must_use]
    pub fn with_base_url(mut self, base_url: &str) -> Self {
        self.base_url = non_empty(base_url);
        self
    }

    /// Adds a paths mapping.
    #[must_use]
    pub fn with_path_mapping(mut self, key: &str, values: Vec<String>) -> Self {
        if let Some(key) = non_empty(key) {
            self.paths.insert(key, values);
        }
        self
    }

    /// Returns the target option.
    #[must_use]
    pub const fn target(&self) -> Option<TsTarget> {
        self.target
    }

    /// Returns the module option label.
    #[must_use]
    pub fn module(&self) -> Option<&str> {
        self.module.as_deref()
    }

    /// Returns the module resolution option.
    #[must_use]
    pub const fn module_resolution(&self) -> Option<TsModuleResolution> {
        self.module_resolution
    }

    /// Returns the strict option value.
    #[must_use]
    pub const fn strict(&self) -> Option<bool> {
        self.strict
    }

    /// Returns the JSX option label.
    #[must_use]
    pub fn jsx(&self) -> Option<&str> {
        self.jsx.as_deref()
    }

    /// Returns the base URL option.
    #[must_use]
    pub fn base_url(&self) -> Option<&str> {
        self.base_url.as_deref()
    }

    /// Returns path mappings.
    #[must_use]
    pub const fn paths(&self) -> &BTreeMap<String, Vec<String>> {
        &self.paths
    }
}

macro_rules! string_newtype {
    ($name:ident) => {
        #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
        pub struct $name(String);

        impl $name {
            /// Creates non-empty tsconfig metadata text.
            ///
            /// # Errors
            ///
            /// Returns [`TsConfigTextError::Empty`] when `input` is empty after trimming.
            pub fn new(input: &str) -> Result<Self, TsConfigTextError> {
                let trimmed = input.trim();
                if trimmed.is_empty() {
                    Err(TsConfigTextError::Empty)
                } else {
                    Ok(Self(trimmed.to_string()))
                }
            }

            /// Returns the stored text.
            #[must_use]
            pub fn as_str(&self) -> &str {
                &self.0
            }
        }

        impl fmt::Display for $name {
            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
                formatter.write_str(self.as_str())
            }
        }
    };
}

string_newtype!(TsConfigExtends);
string_newtype!(TsConfigInclude);
string_newtype!(TsConfigExclude);

/// Error returned when tsconfig text metadata is empty.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TsConfigTextError {
    Empty,
}

impl fmt::Display for TsConfigTextError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("tsconfig metadata text cannot be empty")
    }
}

impl Error for TsConfigTextError {}

fn non_empty(input: &str) -> Option<String> {
    let trimmed = input.trim();
    (!trimmed.is_empty()).then(|| trimmed.to_string())
}

#[cfg(test)]
mod tests {
    use super::{CompilerOptions, TsConfig, TsConfigInclude, TsConfigTextError};
    use use_ts::{TsModuleResolution, TsStrictness, TsTarget};

    #[test]
    fn stores_partial_compiler_options() -> Result<(), Box<dyn std::error::Error>> {
        let options = CompilerOptions::new()
            .with_target("es2024".parse::<TsTarget>()?)
            .with_module("esnext")
            .with_module_resolution(TsModuleResolution::Bundler)
            .with_strictness(TsStrictness::Strict)
            .with_jsx("react-jsx")
            .with_base_url(".")
            .with_path_mapping("@/*", vec![String::from("src/*")]);

        assert_eq!(options.module(), Some("esnext"));
        assert_eq!(options.strict(), Some(true));
        assert_eq!(options.paths().len(), 1);
        Ok(())
    }

    #[test]
    fn stores_config_patterns() -> Result<(), TsConfigTextError> {
        let config = TsConfig::new().with_include(TsConfigInclude::new("src")?);
        assert_eq!(config.include()[0].as_str(), "src");
        assert_eq!(TsConfigInclude::new(" "), Err(TsConfigTextError::Empty));
        Ok(())
    }
}