Skip to main content

use_ts/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6use use_ecmascript::{EcmaScriptParseError, EcmaScriptTarget};
7
8/// TypeScript semantic version metadata.
9#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
10#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
11pub struct TypeScriptVersion {
12    major: u16,
13    minor: Option<u16>,
14    patch: Option<u16>,
15}
16
17impl TypeScriptVersion {
18    /// Creates TypeScript version metadata.
19    ///
20    /// # Errors
21    ///
22    /// Returns [`TypeScriptVersionParseError::InvalidVersion`] when the major version is zero
23    /// or a patch is provided without a minor version.
24    pub const fn new(
25        major: u16,
26        minor: Option<u16>,
27        patch: Option<u16>,
28    ) -> Result<Self, TypeScriptVersionParseError> {
29        if major == 0 || (minor.is_none() && patch.is_some()) {
30            Err(TypeScriptVersionParseError::InvalidVersion)
31        } else {
32            Ok(Self {
33                major,
34                minor,
35                patch,
36            })
37        }
38    }
39
40    /// Returns the major version.
41    #[must_use]
42    pub const fn major(self) -> u16 {
43        self.major
44    }
45
46    /// Returns the optional minor version.
47    #[must_use]
48    pub const fn minor(self) -> Option<u16> {
49        self.minor
50    }
51
52    /// Returns the optional patch version.
53    #[must_use]
54    pub const fn patch(self) -> Option<u16> {
55        self.patch
56    }
57}
58
59impl fmt::Display for TypeScriptVersion {
60    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match (self.minor, self.patch) {
62            (Some(minor), Some(patch)) => write!(formatter, "{}.{}.{}", self.major, minor, patch),
63            (Some(minor), None) => write!(formatter, "{}.{}", self.major, minor),
64            (None, _) => write!(formatter, "{}", self.major),
65        }
66    }
67}
68
69impl FromStr for TypeScriptVersion {
70    type Err = TypeScriptVersionParseError;
71
72    fn from_str(input: &str) -> Result<Self, Self::Err> {
73        let trimmed = input.trim().trim_start_matches('v');
74        if trimmed.is_empty() {
75            return Err(TypeScriptVersionParseError::Empty);
76        }
77
78        let parts = trimmed.split('.').collect::<Vec<_>>();
79        if parts.len() > 3 || parts.iter().any(|part| part.is_empty()) {
80            return Err(TypeScriptVersionParseError::InvalidVersion);
81        }
82
83        let major = parse_version_part(parts[0])?;
84        let minor = parse_optional_version_part(parts.get(1).copied())?;
85        let patch = parse_optional_version_part(parts.get(2).copied())?;
86        Self::new(major, minor, patch)
87    }
88}
89
90/// Error returned while parsing a TypeScript version.
91#[derive(Clone, Copy, Debug, Eq, PartialEq)]
92pub enum TypeScriptVersionParseError {
93    Empty,
94    InvalidVersion,
95}
96
97impl fmt::Display for TypeScriptVersionParseError {
98    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99        match self {
100            Self::Empty => formatter.write_str("TypeScript version cannot be empty"),
101            Self::InvalidVersion => formatter.write_str("invalid TypeScript version"),
102        }
103    }
104}
105
106impl Error for TypeScriptVersionParseError {}
107
108/// TypeScript module resolution labels.
109#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
110#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
111pub enum TsModuleResolution {
112    Classic,
113    Node,
114    Node10,
115    Node16,
116    NodeNext,
117    Bundler,
118}
119
120impl fmt::Display for TsModuleResolution {
121    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122        formatter.write_str(match self {
123            Self::Classic => "classic",
124            Self::Node => "node",
125            Self::Node10 => "node10",
126            Self::Node16 => "node16",
127            Self::NodeNext => "nodenext",
128            Self::Bundler => "bundler",
129        })
130    }
131}
132
133impl FromStr for TsModuleResolution {
134    type Err = TsOptionParseError;
135
136    fn from_str(input: &str) -> Result<Self, Self::Err> {
137        let trimmed = input.trim();
138        if trimmed.is_empty() {
139            return Err(TsOptionParseError::Empty);
140        }
141
142        match trimmed.to_ascii_lowercase().as_str() {
143            "classic" => Ok(Self::Classic),
144            "node" | "nodejs" => Ok(Self::Node),
145            "node10" => Ok(Self::Node10),
146            "node16" => Ok(Self::Node16),
147            "nodenext" | "node_next" | "node-next" => Ok(Self::NodeNext),
148            "bundler" => Ok(Self::Bundler),
149            _ => Err(TsOptionParseError::Unknown),
150        }
151    }
152}
153
154/// TypeScript target metadata.
155#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
156#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
157pub enum TsTarget {
158    EcmaScript(EcmaScriptTarget),
159    Latest,
160}
161
162impl fmt::Display for TsTarget {
163    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
164        match self {
165            Self::EcmaScript(target) => fmt::Display::fmt(target, formatter),
166            Self::Latest => formatter.write_str("latest"),
167        }
168    }
169}
170
171impl From<EcmaScriptTarget> for TsTarget {
172    fn from(value: EcmaScriptTarget) -> Self {
173        Self::EcmaScript(value)
174    }
175}
176
177impl FromStr for TsTarget {
178    type Err = TsTargetParseError;
179
180    fn from_str(input: &str) -> Result<Self, Self::Err> {
181        let trimmed = input.trim();
182        if trimmed.is_empty() {
183            return Err(TsTargetParseError::Empty);
184        }
185        if trimmed.eq_ignore_ascii_case("latest") {
186            return Ok(Self::Latest);
187        }
188        trimmed
189            .parse::<EcmaScriptTarget>()
190            .map(Self::EcmaScript)
191            .map_err(TsTargetParseError::EcmaScript)
192    }
193}
194
195/// TypeScript strictness metadata.
196#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
197#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
198pub enum TsStrictness {
199    Loose,
200    Strict,
201}
202
203impl fmt::Display for TsStrictness {
204    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
205        formatter.write_str(match self {
206            Self::Loose => "loose",
207            Self::Strict => "strict",
208        })
209    }
210}
211
212impl FromStr for TsStrictness {
213    type Err = TsOptionParseError;
214
215    fn from_str(input: &str) -> Result<Self, Self::Err> {
216        let trimmed = input.trim();
217        if trimmed.is_empty() {
218            return Err(TsOptionParseError::Empty);
219        }
220
221        match trimmed.to_ascii_lowercase().as_str() {
222            "loose" | "false" | "off" => Ok(Self::Loose),
223            "strict" | "true" | "on" => Ok(Self::Strict),
224            _ => Err(TsOptionParseError::Unknown),
225        }
226    }
227}
228
229/// Error returned while parsing TypeScript option labels.
230#[derive(Clone, Copy, Debug, Eq, PartialEq)]
231pub enum TsOptionParseError {
232    Empty,
233    Unknown,
234}
235
236impl fmt::Display for TsOptionParseError {
237    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
238        match self {
239            Self::Empty => formatter.write_str("TypeScript option cannot be empty"),
240            Self::Unknown => formatter.write_str("unknown TypeScript option"),
241        }
242    }
243}
244
245impl Error for TsOptionParseError {}
246
247/// Error returned while parsing TypeScript targets.
248#[derive(Clone, Copy, Debug, Eq, PartialEq)]
249pub enum TsTargetParseError {
250    Empty,
251    EcmaScript(EcmaScriptParseError),
252}
253
254impl fmt::Display for TsTargetParseError {
255    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
256        match self {
257            Self::Empty => formatter.write_str("TypeScript target cannot be empty"),
258            Self::EcmaScript(error) => write!(formatter, "invalid ECMAScript target: {error}"),
259        }
260    }
261}
262
263impl Error for TsTargetParseError {
264    fn source(&self) -> Option<&(dyn Error + 'static)> {
265        match self {
266            Self::Empty => None,
267            Self::EcmaScript(error) => Some(error),
268        }
269    }
270}
271
272fn parse_version_part(input: &str) -> Result<u16, TypeScriptVersionParseError> {
273    input
274        .parse::<u16>()
275        .map_err(|_error| TypeScriptVersionParseError::InvalidVersion)
276}
277
278fn parse_optional_version_part(
279    input: Option<&str>,
280) -> Result<Option<u16>, TypeScriptVersionParseError> {
281    input.map(parse_version_part).transpose()
282}
283
284#[cfg(test)]
285mod tests {
286    use super::{TsModuleResolution, TsStrictness, TsTarget, TypeScriptVersion};
287
288    #[test]
289    fn parses_versions() -> Result<(), Box<dyn std::error::Error>> {
290        let version: TypeScriptVersion = "v5.4.2".parse()?;
291        assert_eq!(version.major(), 5);
292        assert_eq!(version.minor(), Some(4));
293        assert_eq!(version.patch(), Some(2));
294        assert_eq!(version.to_string(), "5.4.2");
295        Ok(())
296    }
297
298    #[test]
299    fn parses_options() -> Result<(), Box<dyn std::error::Error>> {
300        assert_eq!(
301            "nodenext".parse::<TsModuleResolution>()?,
302            TsModuleResolution::NodeNext
303        );
304        assert_eq!("es2022".parse::<TsTarget>()?.to_string(), "ES2022");
305        assert_eq!("strict".parse::<TsStrictness>()?, TsStrictness::Strict);
306        Ok(())
307    }
308}