#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
use use_js_identifier::{JsIdentifier, JsIdentifierError};
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum AngularVersionFamily {
Angular2,
Angular3,
Angular4,
Angular5,
Angular6,
Angular7,
Angular8,
Angular9,
Angular10,
Angular11,
Angular12,
Angular13,
Angular14,
Angular15,
Angular16,
Angular17,
Angular18,
Angular19,
Angular20,
}
impl AngularVersionFamily {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Angular2 => "angular2",
Self::Angular3 => "angular3",
Self::Angular4 => "angular4",
Self::Angular5 => "angular5",
Self::Angular6 => "angular6",
Self::Angular7 => "angular7",
Self::Angular8 => "angular8",
Self::Angular9 => "angular9",
Self::Angular10 => "angular10",
Self::Angular11 => "angular11",
Self::Angular12 => "angular12",
Self::Angular13 => "angular13",
Self::Angular14 => "angular14",
Self::Angular15 => "angular15",
Self::Angular16 => "angular16",
Self::Angular17 => "angular17",
Self::Angular18 => "angular18",
Self::Angular19 => "angular19",
Self::Angular20 => "angular20",
}
}
}
impl fmt::Display for AngularVersionFamily {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for AngularVersionFamily {
type Err = AngularNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"angular2" | "2" => Ok(Self::Angular2),
"angular3" | "3" => Ok(Self::Angular3),
"angular4" | "4" => Ok(Self::Angular4),
"angular5" | "5" => Ok(Self::Angular5),
"angular6" | "6" => Ok(Self::Angular6),
"angular7" | "7" => Ok(Self::Angular7),
"angular8" | "8" => Ok(Self::Angular8),
"angular9" | "9" => Ok(Self::Angular9),
"angular10" | "10" => Ok(Self::Angular10),
"angular11" | "11" => Ok(Self::Angular11),
"angular12" | "12" => Ok(Self::Angular12),
"angular13" | "13" => Ok(Self::Angular13),
"angular14" | "14" => Ok(Self::Angular14),
"angular15" | "15" => Ok(Self::Angular15),
"angular16" | "16" => Ok(Self::Angular16),
"angular17" | "17" => Ok(Self::Angular17),
"angular18" | "18" => Ok(Self::Angular18),
"angular19" | "19" => Ok(Self::Angular19),
"angular20" | "20" => Ok(Self::Angular20),
_ => Err(AngularNameError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum AngularFileKind {
Component,
Template,
Stylesheet,
Spec,
Service,
Module,
RoutingModule,
Config,
}
impl AngularFileKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Component => "component",
Self::Template => "template",
Self::Stylesheet => "stylesheet",
Self::Spec => "spec",
Self::Service => "service",
Self::Module => "module",
Self::RoutingModule => "routing-module",
Self::Config => "config",
}
}
}
impl fmt::Display for AngularFileKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for AngularFileKind {
type Err = AngularNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"component" => Ok(Self::Component),
"template" => Ok(Self::Template),
"stylesheet" | "style" | "styles" => Ok(Self::Stylesheet),
"spec" | "test" => Ok(Self::Spec),
"service" => Ok(Self::Service),
"module" => Ok(Self::Module),
"routingmodule" | "routing" => Ok(Self::RoutingModule),
"config" => Ok(Self::Config),
_ => Err(AngularNameError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum AngularArtifactKind {
Component,
Directive,
Pipe,
Service,
Module,
Guard,
Resolver,
Interceptor,
}
impl AngularArtifactKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Component => "component",
Self::Directive => "directive",
Self::Pipe => "pipe",
Self::Service => "service",
Self::Module => "module",
Self::Guard => "guard",
Self::Resolver => "resolver",
Self::Interceptor => "interceptor",
}
}
}
impl fmt::Display for AngularArtifactKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for AngularArtifactKind {
type Err = AngularNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"component" => Ok(Self::Component),
"directive" => Ok(Self::Directive),
"pipe" => Ok(Self::Pipe),
"service" => Ok(Self::Service),
"module" => Ok(Self::Module),
"guard" => Ok(Self::Guard),
"resolver" => Ok(Self::Resolver),
"interceptor" => Ok(Self::Interceptor),
_ => Err(AngularNameError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum AngularStandaloneMode {
Standalone,
NgModuleBased,
}
impl AngularStandaloneMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Standalone => "standalone",
Self::NgModuleBased => "ng-module-based",
}
}
}
impl fmt::Display for AngularStandaloneMode {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for AngularStandaloneMode {
type Err = AngularNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"standalone" => Ok(Self::Standalone),
"ngmodulebased" | "ngmodule" | "modulebased" => Ok(Self::NgModuleBased),
_ => Err(AngularNameError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum AngularConfigFile {
AngularJson,
TsConfigAppJson,
TsConfigSpecJson,
}
impl AngularConfigFile {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::AngularJson => "angular.json",
Self::TsConfigAppJson => "tsconfig.app.json",
Self::TsConfigSpecJson => "tsconfig.spec.json",
}
}
}
impl fmt::Display for AngularConfigFile {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for AngularConfigFile {
type Err = AngularNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"angularjson" | "angular.json" => Ok(Self::AngularJson),
"tsconfigappjson" | "tsconfig.app.json" => Ok(Self::TsConfigAppJson),
"tsconfigspecjson" | "tsconfig.spec.json" => Ok(Self::TsConfigSpecJson),
_ => Err(AngularNameError::UnknownLabel),
}
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct AngularDirectiveName(String);
impl AngularDirectiveName {
pub fn new(input: &str) -> Result<Self, AngularNameError> {
validate_pascal_case(input).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for AngularDirectiveName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for AngularDirectiveName {
type Err = AngularNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
impl TryFrom<&str> for AngularDirectiveName {
type Error = AngularNameError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct AngularSelector(String);
impl AngularSelector {
pub fn new(input: &str) -> Result<Self, AngularNameError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(AngularNameError::Empty);
}
if trimmed.chars().any(char::is_whitespace) {
return Err(AngularNameError::ContainsWhitespace);
}
if !is_selector_shape(trimmed) {
return Err(AngularNameError::InvalidSelector);
}
Ok(Self(trimmed.to_string()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for AngularSelector {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for AngularSelector {
type Err = AngularNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
impl TryFrom<&str> for AngularSelector {
type Error = AngularNameError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct AngularModuleName(String);
impl AngularModuleName {
pub fn new(input: &str) -> Result<Self, AngularNameError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(AngularNameError::Empty);
}
if trimmed.chars().any(char::is_whitespace) {
return Err(AngularNameError::ContainsWhitespace);
}
Ok(Self(trimmed.to_string()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for AngularModuleName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for AngularModuleName {
type Err = AngularNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
impl TryFrom<&str> for AngularModuleName {
type Error = AngularNameError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AngularNameError {
Empty,
ContainsWhitespace,
Identifier(JsIdentifierError),
NotPascalCase,
InvalidSelector,
UnknownLabel,
}
impl fmt::Display for AngularNameError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("Angular metadata text cannot be empty"),
Self::ContainsWhitespace => {
formatter.write_str("Angular metadata text cannot contain whitespace")
}
Self::Identifier(error) => write!(formatter, "{error}"),
Self::NotPascalCase => {
formatter.write_str("Angular artifact name must be `PascalCase`-shaped")
}
Self::InvalidSelector => formatter.write_str("invalid Angular selector"),
Self::UnknownLabel => formatter.write_str("unknown Angular metadata label"),
}
}
}
impl Error for AngularNameError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Identifier(error) => Some(error),
Self::Empty
| Self::ContainsWhitespace
| Self::NotPascalCase
| Self::InvalidSelector
| Self::UnknownLabel => None,
}
}
}
fn validate_pascal_case(input: &str) -> Result<String, AngularNameError> {
let identifier = JsIdentifier::new(input).map_err(AngularNameError::Identifier)?;
if !identifier
.as_str()
.chars()
.next()
.is_some_and(|character| character.is_ascii_uppercase())
{
return Err(AngularNameError::NotPascalCase);
}
Ok(identifier.as_str().to_string())
}
fn is_selector_shape(input: &str) -> bool {
if let Some(inner) = input
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
{
return !inner.is_empty() && inner.chars().all(is_selector_character);
}
if let Some(class_selector) = input.strip_prefix('.') {
return !class_selector.is_empty() && class_selector.chars().all(is_selector_character);
}
!input.is_empty() && input.chars().all(is_selector_character)
}
const fn is_selector_character(character: char) -> bool {
character.is_ascii_alphanumeric() || matches!(character, '-' | '_')
}
fn normalized_label(input: &str) -> Result<String, AngularNameError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(AngularNameError::Empty);
}
Ok(trimmed
.chars()
.filter(|character| !matches!(character, '-' | '_' | ' '))
.flat_map(char::to_lowercase)
.collect())
}
#[cfg(test)]
mod tests {
use super::{
AngularArtifactKind, AngularConfigFile, AngularDirectiveName, AngularFileKind,
AngularModuleName, AngularNameError, AngularSelector, AngularStandaloneMode,
AngularVersionFamily,
};
use use_js_identifier::JsIdentifierError;
#[test]
fn validates_selectors() -> Result<(), AngularNameError> {
assert_eq!(AngularSelector::new("app-root")?.as_str(), "app-root");
assert_eq!(
AngularSelector::new("[appHighlight]")?.as_str(),
"[appHighlight]"
);
assert_eq!(AngularSelector::new(".app-card")?.as_str(), ".app-card");
assert_eq!(AngularSelector::new(""), Err(AngularNameError::Empty));
assert_eq!(
AngularSelector::new("app root"),
Err(AngularNameError::ContainsWhitespace)
);
assert_eq!(
AngularSelector::new("#app"),
Err(AngularNameError::InvalidSelector)
);
Ok(())
}
#[test]
fn validates_names() -> Result<(), AngularNameError> {
assert_eq!(
AngularDirectiveName::new("AppHighlight")?.as_str(),
"AppHighlight"
);
assert_eq!(
AngularModuleName::new("FeatureModule")?.as_str(),
"FeatureModule"
);
assert_eq!(
AngularDirectiveName::new("appHighlight"),
Err(AngularNameError::NotPascalCase)
);
assert_eq!(
AngularDirectiveName::new("1App"),
Err(AngularNameError::Identifier(
JsIdentifierError::InvalidStart { character: '1' }
))
);
assert_eq!(
AngularModuleName::new("Feature Module"),
Err(AngularNameError::ContainsWhitespace)
);
Ok(())
}
#[test]
fn parses_labels() -> Result<(), AngularNameError> {
assert_eq!(
"angular17".parse::<AngularVersionFamily>()?,
AngularVersionFamily::Angular17
);
assert_eq!(
"routing-module".parse::<AngularFileKind>()?,
AngularFileKind::RoutingModule
);
assert_eq!(
"component".parse::<AngularArtifactKind>()?,
AngularArtifactKind::Component
);
assert_eq!(
"ng-module".parse::<AngularStandaloneMode>()?,
AngularStandaloneMode::NgModuleBased
);
assert_eq!(
"angular.json".parse::<AngularConfigFile>()?,
AngularConfigFile::AngularJson
);
assert_eq!(AngularArtifactKind::Service.to_string(), "service");
Ok(())
}
}