use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use crate::utils::normalize_to_kebab_or_snake_case;
use serde_json::Value;
pub mod abstract_collection;
pub mod collection;
pub mod collection_factory;
pub mod custom_collection;
pub mod nest_collection;
pub mod schematic_option;
pub const NESTRS_COLLECTION_NAME: &str = "@nestrs/schematics";
pub const NESTJS_COLLECTION_NAME: &str = NESTRS_COLLECTION_NAME;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Collection {
Nestjs,
Custom(String),
}
impl Collection {
pub fn from_name(name: impl Into<String>) -> Self {
let name = name.into();
if name == NESTRS_COLLECTION_NAME {
Self::Nestjs
} else {
Self::Custom(name)
}
}
pub fn as_str(&self) -> &str {
match self {
Self::Nestjs => NESTRS_COLLECTION_NAME,
Self::Custom(name) => name,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SchematicOptionValue {
Bool(bool),
String(String),
}
impl From<bool> for SchematicOptionValue {
fn from(value: bool) -> Self {
Self::Bool(value)
}
}
impl From<&str> for SchematicOptionValue {
fn from(value: &str) -> Self {
Self::String(value.to_string())
}
}
impl From<String> for SchematicOptionValue {
fn from(value: String) -> Self {
Self::String(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchematicOption {
pub name: String,
pub value: SchematicOptionValue,
}
impl SchematicOption {
pub fn new(name: impl Into<String>, value: impl Into<SchematicOptionValue>) -> Self {
Self {
name: name.into(),
value: value.into(),
}
}
pub fn normalized_name(&self) -> String {
normalize_to_kebab_or_snake_case(&self.name)
}
pub fn to_command_string(&self) -> String {
let normalized_name = self.normalized_name();
match &self.value {
SchematicOptionValue::Bool(true) => format!("--{normalized_name}"),
SchematicOptionValue::Bool(false) => format!("--no-{normalized_name}"),
SchematicOptionValue::String(value) if self.name == "name" => {
format!("--{normalized_name}={}", format_name_value(value))
}
SchematicOptionValue::String(value)
if self.name == "version" || self.name == "path" =>
{
format!("--{normalized_name}={value}")
}
SchematicOptionValue::String(value) => format!("--{normalized_name}=\"{value}\""),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Schematic {
pub name: String,
pub alias: String,
pub description: String,
}
impl Schematic {
pub fn new(
name: impl Into<String>,
alias: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
name: name.into(),
alias: alias.into(),
description: description.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CollectionSchematic {
pub schema: Option<String>,
pub description: String,
pub aliases: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CollectionDescription {
pub path: Option<PathBuf>,
pub extends: Vec<String>,
pub schematics: Vec<(String, CollectionSchematic)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AbstractCollection {
collection: String,
}
impl AbstractCollection {
pub fn new(collection: impl Into<String>) -> Self {
Self {
collection: collection.into(),
}
}
pub fn collection(&self) -> &str {
&self.collection
}
pub fn build_command_line(&self, name: &str, options: &[SchematicOption]) -> String {
format!("{}:{name}{}", self.collection, Self::build_options(options))
}
pub fn build_command_line_with_extra_flags(
&self,
name: &str,
options: &[SchematicOption],
extra_flags: Option<&str>,
) -> String {
let mut command = self.build_command_line(name, options);
if let Some(extra_flags) = extra_flags.filter(|flags| !flags.is_empty()) {
command.push(' ');
command.push_str(extra_flags);
}
command
}
fn build_options(options: &[SchematicOption]) -> String {
options.iter().fold(String::new(), |mut line, option| {
line.push(' ');
line.push_str(&option.to_command_string());
line
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NestCollection {
base: AbstractCollection,
}
impl NestCollection {
pub fn new() -> Self {
Self {
base: AbstractCollection::new(NESTJS_COLLECTION_NAME),
}
}
pub fn execute_command(
&self,
name: &str,
options: &[SchematicOption],
) -> Result<String, String> {
let schematic = self.validate(name)?;
Ok(self.base.build_command_line(&schematic, options))
}
pub fn get_schematics(&self) -> Vec<Schematic> {
nest_schematics()
.into_iter()
.filter(|schematic| schematic.name != "angular-app")
.collect()
}
pub fn validate(&self, name: &str) -> Result<String, String> {
nest_schematics()
.into_iter()
.find(|schematic| schematic.name == name || schematic.alias == name)
.map(|schematic| schematic.name)
.ok_or_else(|| {
format!(
"Invalid schematic \"{name}\". Please, ensure that \"{name}\" exists in this collection."
)
})
}
}
impl Default for NestCollection {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomCollection {
base: AbstractCollection,
descriptions: Vec<CollectionDescription>,
}
impl CustomCollection {
pub fn new(collection: impl Into<String>, descriptions: Vec<CollectionDescription>) -> Self {
Self {
base: AbstractCollection::new(collection),
descriptions,
}
}
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, String> {
let path = path.as_ref();
let descriptions = CollectionDiscovery::discover_collection_descriptions(path)?;
Ok(Self::new(path.to_string_lossy().into_owned(), descriptions))
}
pub fn from_package(
package_name: &str,
module_paths: impl IntoIterator<Item = impl AsRef<Path>>,
) -> Result<Self, String> {
let path =
CollectionDiscovery::resolve_package_collection_path(package_name, module_paths)?;
let descriptions = CollectionDiscovery::discover_collection_descriptions(&path)?;
Ok(Self::new(package_name, descriptions))
}
pub fn execute_command(&self, name: &str, options: &[SchematicOption]) -> String {
self.base.build_command_line(name, options)
}
pub fn execute_command_with_extra_flags(
&self,
name: &str,
options: &[SchematicOption],
extra_flags: Option<&str>,
) -> String {
self.base
.build_command_line_with_extra_flags(name, options, extra_flags)
}
pub fn get_schematics(&self) -> Vec<Schematic> {
flatten_collection_descriptions(&self.descriptions)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CollectionInstance {
Nest(NestCollection),
Custom(CustomCollection),
}
impl CollectionInstance {
pub fn get_schematics(&self) -> Vec<Schematic> {
match self {
Self::Nest(collection) => collection.get_schematics(),
Self::Custom(collection) => collection.get_schematics(),
}
}
pub fn execute_command(
&self,
name: &str,
options: &[SchematicOption],
) -> Result<String, String> {
match self {
Self::Nest(collection) => collection.execute_command(name, options),
Self::Custom(collection) => Ok(collection.execute_command(name, options)),
}
}
}
pub struct CollectionFactory;
impl CollectionFactory {
pub fn create(collection: impl Into<String>) -> CollectionInstance {
match Collection::from_name(collection) {
Collection::Nestjs => CollectionInstance::Nest(NestCollection::new()),
Collection::Custom(name) => CollectionInstance::Custom(
CustomCollection::from_name_or_empty(&name, default_module_paths())
.unwrap_or_else(|_| CustomCollection::new(name, Vec::new())),
),
}
}
}
pub struct CollectionDiscovery;
impl CollectionDiscovery {
pub fn discover_collection_descriptions(
path: impl AsRef<Path>,
) -> Result<Vec<CollectionDescription>, String> {
let mut visited = BTreeSet::new();
discover_collection_descriptions(path.as_ref(), &mut visited)
}
pub fn resolve_package_collection_path(
package_name: &str,
module_paths: impl IntoIterator<Item = impl AsRef<Path>>,
) -> Result<PathBuf, String> {
for module_path in module_paths {
let package_path = module_path.as_ref().join(package_name);
let package_json_path = package_path.join("package.json");
if !package_json_path.is_file() {
continue;
}
let content = fs::read_to_string(&package_json_path).map_err(|error| {
format!("Failed to read {}: {error}", package_json_path.display())
})?;
let schematics = extract_string_field(&content, "schematics")
.ok_or_else(|| format!("Package \"{package_name}\" does not declare schematics"))?;
return Ok(package_path.join(schematics));
}
Err(format!("Package \"{package_name}\" could not be resolved"))
}
}
fn discover_collection_descriptions(
path: &Path,
visited: &mut BTreeSet<PathBuf>,
) -> Result<Vec<CollectionDescription>, String> {
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if !visited.insert(canonical) {
return Ok(Vec::new());
}
let content = fs::read_to_string(path)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
let mut description = parse_collection_description(&content)?;
description.path = Some(path.to_path_buf());
let base_dir = path.parent().unwrap_or_else(|| Path::new(""));
let mut descriptions = vec![description.clone()];
for base in &description.extends {
let base_path = base_dir.join(base);
descriptions.extend(discover_collection_descriptions(&base_path, visited)?);
}
Ok(descriptions)
}
fn parse_collection_description(content: &str) -> Result<CollectionDescription, String> {
let value = serde_json::from_str::<Value>(content)
.map_err(|error| format!("Invalid collection JSON: {error}"))?;
let extends = string_array_value(value.get("extends"));
let schematics_value = value
.get("schematics")
.and_then(Value::as_object)
.ok_or_else(|| "Collection does not declare schematics".to_string())?;
let mut schematics = Vec::new();
for (name, body) in schematics_value {
let description = body
.get("description")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let aliases = string_array_value(body.get("aliases"));
let schema = body
.get("schema")
.and_then(Value::as_str)
.map(ToString::to_string);
schematics.push((
name.clone(),
CollectionSchematic {
schema,
description,
aliases,
},
));
}
Ok(CollectionDescription {
path: None,
extends,
schematics,
})
}
impl CustomCollection {
pub fn from_name_or_empty(
name: &str,
module_paths: impl IntoIterator<Item = impl AsRef<Path>>,
) -> Result<Self, String> {
let path = Path::new(name);
if path.is_file() {
return Self::from_path(path);
}
Self::from_package(name, module_paths)
}
}
fn default_module_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(cwd) = std::env::current_dir() {
for directory in cwd.ancestors() {
paths.push(directory.join("node_modules"));
}
}
paths
}
fn string_array_value(value: Option<&Value>) -> Vec<String> {
match value {
Some(Value::String(value)) => vec![value.clone()],
Some(Value::Array(values)) => values
.iter()
.filter_map(Value::as_str)
.map(ToString::to_string)
.collect(),
_ => Vec::new(),
}
}
fn flatten_collection_descriptions(descriptions: &[CollectionDescription]) -> Vec<Schematic> {
let mut used_names = BTreeSet::new();
let mut schematics = Vec::new();
for description in descriptions {
for (name, collection_schematic) in &description.schematics {
if used_names.contains(name) {
continue;
}
used_names.insert(name.clone());
let alias = collection_schematic
.aliases
.iter()
.find(|alias| !used_names.contains(*alias))
.cloned()
.unwrap_or_else(|| name.clone());
for alias in &collection_schematic.aliases {
used_names.insert(alias.clone());
}
schematics.push(Schematic::new(
name,
alias,
&collection_schematic.description,
));
}
}
schematics.sort_by(|left, right| left.name.cmp(&right.name));
schematics
}
fn nest_schematics() -> Vec<Schematic> {
[
(
"application",
"application",
"Generate a new application workspace",
),
("angular-app", "ng-app", ""),
("class", "cl", "Generate a new class"),
(
"configuration",
"config",
"Generate a CLI configuration file",
),
("controller", "co", "Generate a controller declaration"),
("decorator", "d", "Generate a custom decorator"),
("filter", "f", "Generate a filter declaration"),
("gateway", "ga", "Generate a gateway declaration"),
("guard", "gu", "Generate a guard declaration"),
("interceptor", "itc", "Generate an interceptor declaration"),
("interface", "itf", "Generate an interface"),
("library", "lib", "Generate a new library within a monorepo"),
("middleware", "mi", "Generate a middleware declaration"),
("module", "mo", "Generate a module declaration"),
("pipe", "pi", "Generate a pipe declaration"),
("provider", "pr", "Generate a provider declaration"),
("resolver", "r", "Generate a GraphQL resolver declaration"),
("resource", "res", "Generate a new CRUD resource"),
("service", "s", "Generate a service declaration"),
(
"sub-app",
"app",
"Generate a new application within a monorepo",
),
]
.into_iter()
.map(|(name, alias, description)| Schematic::new(name, alias, description))
.collect()
}
fn format_name_value(value: &str) -> String {
normalize_to_kebab_or_snake_case(value)
.chars()
.fold(String::new(), |mut output, character| {
if matches!(character, '(' | ')' | '[' | ']') {
output.push('\\');
}
output.push(character);
output
})
}
fn extract_string_field(content: &str, key: &str) -> Option<String> {
let index = find_json_key(content, key)?;
let after_colon = content[index..].split_once(':')?.1.trim_start();
parse_json_string(after_colon).map(|(value, _)| value)
}
fn find_json_key(content: &str, key: &str) -> Option<usize> {
content.find(&format!("\"{key}\""))
}
fn parse_json_string(content: &str) -> Option<(String, usize)> {
let mut chars = content.char_indices();
if chars.next()?.1 != '"' {
return None;
}
let mut escaped = false;
let mut value = String::new();
for (index, character) in chars {
if escaped {
value.push(character);
escaped = false;
continue;
}
if character == '\\' {
escaped = true;
continue;
}
if character == '"' {
return Some((value, index + character.len_utf8()));
}
value.push(character);
}
None
}