const NG_PACKAGE_DEFAULT_ENTRY_FILE: &str = "src/public_api.ts";
use std::path::Path;
use super::config_parser;
use super::{Plugin, PluginResult};
define_plugin!(
struct AngularPlugin => "angular",
enablers: &["@angular/core", "ng-packagr"],
entry_patterns: &[
"src/main.ts",
"src/app*.component.ts",
"src/app*.module.ts",
"src/app*.service.ts",
"src/app*.guard.ts",
"src/app*.pipe.ts",
"src/app*.directive.ts",
"src/app*.resolver.ts",
"src/app*.interceptor.ts",
"**/src/main.ts",
"**/src/app*.component.ts",
"**/src/app*.module.ts",
"**/src/app*.service.ts",
"**/src/app*.guard.ts",
"**/src/app*.pipe.ts",
"**/src/app*.directive.ts",
"**/src/app*.resolver.ts",
"**/src/app*.interceptor.ts",
],
config_patterns: &[
"angular.json",
".angular.json",
"ng-package.json",
"ng-package.prod.json",
],
always_used: &[
"angular.json",
".angular.json",
"src/polyfills.ts",
"src/environments*.ts",
"src/app/app.config.ts",
"src/app/app.config.server.ts",
],
tooling_dependencies: &[
"@angular/cli",
"@angular-devkit/build-angular",
"@angular/compiler-cli",
"@angular/compiler",
"@angular/build",
"ng-packagr",
"zone.js",
"tslib",
"rxjs",
"@angular/common",
"@angular/platform-browser",
"@angular/platform-browser-dynamic",
],
resolve_config(config_path, source, _root) {
let mut result = PluginResult::default();
if is_ng_package_config(config_path) {
for entry in resolve_ng_package_entries(config_path, source, _root) {
result.push_entry_pattern(entry);
}
return result;
}
let styles = config_parser::extract_config_object_nested_string_or_array(
source,
config_path,
&["projects"],
&["architect", "build", "options", "styles"],
);
for style in &styles {
let path = style.trim_start_matches("./");
result.push_entry_pattern(path.to_string());
}
let scripts = config_parser::extract_config_object_nested_string_or_array(
source,
config_path,
&["projects"],
&["architect", "build", "options", "scripts"],
);
for script in &scripts {
let path = script.trim_start_matches("./");
result.push_entry_pattern(path.to_string());
}
for field in &["main", "browser"] {
let mains = config_parser::extract_config_object_nested_strings(
source,
config_path,
&["projects"],
&["architect", "build", "options", field],
);
for main in &mains {
let path = main.trim_start_matches("./");
result.push_entry_pattern(path.to_string());
}
}
let polyfills = config_parser::extract_config_object_nested_string_or_array(
source,
config_path,
&["projects"],
&["architect", "build", "options", "polyfills"],
);
for polyfill in &polyfills {
let trimmed = polyfill.trim_start_matches("./");
if trimmed.contains('/') {
result.push_entry_pattern(trimmed.to_string());
}
}
let test_mains = config_parser::extract_config_object_nested_strings(
source,
config_path,
&["projects"],
&["architect", "test", "options", "main"],
);
for main in &test_mains {
let path = main.trim_start_matches("./");
result.push_entry_pattern(path.to_string());
}
let include_paths = config_parser::extract_config_object_nested_string_or_array(
source,
config_path,
&["projects"],
&[
"architect",
"build",
"options",
"stylePreprocessorOptions",
"includePaths",
],
);
result
.scss_include_paths
.extend(resolve_scss_include_paths(&include_paths, _root));
result
},
);
fn resolve_scss_include_paths(entries: &[String], root: &Path) -> Vec<std::path::PathBuf> {
entries
.iter()
.map(|entry| root.join(entry.trim_start_matches("./")))
.filter(|path| path.is_dir())
.collect()
}
const NG_PACKAGE_SCAN_MAX_DEPTH: usize = 6;
const NG_PACKAGE_SCAN_SKIP_DIRS: &[&str] = &["node_modules", "dist", "out", "tmp", "coverage"];
fn is_ng_package_file_name(name: &str) -> bool {
name == "ng-package.json" || name == "ng-package.prod.json"
}
fn is_ng_package_config(config_path: &Path) -> bool {
config_path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(is_ng_package_file_name)
}
fn resolve_ng_package_entries(config_path: &Path, source: &str, root: &Path) -> Vec<String> {
let mut entries = Vec::new();
if let Some(entry) = resolve_ng_package_entry_from_source(config_path, source, root) {
entries.push(entry);
}
if let Some(base) = config_path.parent() {
let mut nested = Vec::new();
collect_nested_ng_package_configs(base, 0, &mut nested);
for nested_path in nested {
let Ok(nested_source) = std::fs::read_to_string(&nested_path) else {
continue;
};
if let Some(entry) =
resolve_ng_package_entry_from_source(&nested_path, &nested_source, root)
{
entries.push(entry);
}
}
}
entries.sort();
entries.dedup();
entries
}
fn resolve_ng_package_entry_from_source(
config_path: &Path,
source: &str,
root: &Path,
) -> Option<String> {
let entry_file =
config_parser::extract_config_string(source, config_path, &["lib", "entryFile"])
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| NG_PACKAGE_DEFAULT_ENTRY_FILE.to_string());
config_parser::normalize_config_path(&entry_file, config_path, root)
}
fn collect_nested_ng_package_configs(
dir: &Path,
depth: usize,
found: &mut Vec<std::path::PathBuf>,
) {
if depth > NG_PACKAGE_SCAN_MAX_DEPTH {
return;
}
let Ok(read_dir) = std::fs::read_dir(dir) else {
return;
};
for entry in read_dir.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
let path = entry.path();
if file_type.is_dir() {
let skip = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
name.starts_with('.') || NG_PACKAGE_SCAN_SKIP_DIRS.contains(&name)
});
if !skip {
collect_nested_ng_package_configs(&path, depth + 1, found);
}
} else if depth >= 1
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(is_ng_package_file_name)
{
found.push(path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn has_entry_pattern(result: &PluginResult, pattern: &str) -> bool {
result
.entry_patterns
.iter()
.any(|entry_pattern| entry_pattern.pattern == pattern)
}
#[test]
fn resolve_config_extracts_styles() {
let source = r#"{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"styles": ["src/styles.css", "src/theme.scss"]
}
}
}
}
}
}"#;
let plugin = AngularPlugin;
let result =
plugin.resolve_config(Path::new("angular.json"), source, Path::new("/project"));
assert!(has_entry_pattern(&result, "src/styles.css"));
assert!(has_entry_pattern(&result, "src/theme.scss"));
}
#[test]
fn resolve_config_extracts_styles_object_form() {
let source = r#"{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"styles": [
"src/styles.scss",
{ "input": "src/theme.scss", "bundleName": "theme", "inject": false }
]
}
}
}
}
}
}"#;
let plugin = AngularPlugin;
let result =
plugin.resolve_config(Path::new("angular.json"), source, Path::new("/project"));
assert!(has_entry_pattern(&result, "src/styles.scss"));
assert!(
has_entry_pattern(&result, "src/theme.scss"),
"object-form entry `input` must be extracted as entry pattern"
);
}
#[test]
fn resolve_config_extracts_main() {
let source = r#"{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"main": "src/main.ts"
}
}
}
}
}
}"#;
let plugin = AngularPlugin;
let result =
plugin.resolve_config(Path::new("angular.json"), source, Path::new("/project"));
assert!(has_entry_pattern(&result, "src/main.ts"));
}
#[test]
fn resolve_config_extracts_scripts() {
let source = r#"{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"scripts": ["node_modules/some-lib/dist/script.js"]
}
}
}
}
}
}"#;
let plugin = AngularPlugin;
let result =
plugin.resolve_config(Path::new("angular.json"), source, Path::new("/project"));
assert!(has_entry_pattern(
&result,
"node_modules/some-lib/dist/script.js"
));
}
#[test]
fn resolve_config_multiple_projects() {
let source = r#"{
"projects": {
"app-one": {
"architect": {
"build": {
"options": {
"styles": ["apps/one/src/styles.css"],
"main": "apps/one/src/main.ts"
}
}
}
},
"app-two": {
"architect": {
"build": {
"options": {
"styles": ["apps/two/src/styles.css"],
"main": "apps/two/src/main.ts"
}
}
}
}
}
}"#;
let plugin = AngularPlugin;
let result =
plugin.resolve_config(Path::new("angular.json"), source, Path::new("/project"));
assert!(has_entry_pattern(&result, "apps/one/src/styles.css"));
assert!(has_entry_pattern(&result, "apps/two/src/styles.css"));
assert!(has_entry_pattern(&result, "apps/one/src/main.ts"));
assert!(has_entry_pattern(&result, "apps/two/src/main.ts"));
}
#[test]
fn resolve_config_extracts_scss_include_paths() {
let tmp = tempfile::tempdir().expect("create temp dir");
let root = tmp.path();
std::fs::create_dir_all(root.join("src/styles")).unwrap();
std::fs::create_dir_all(root.join("libs/shared/scss")).unwrap();
let source = r#"{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"stylePreprocessorOptions": {
"includePaths": ["src/styles", "./libs/shared/scss"]
}
}
}
}
}
}
}"#;
let plugin = AngularPlugin;
let result = plugin.resolve_config(Path::new("angular.json"), source, root);
assert_eq!(result.scss_include_paths.len(), 2);
assert!(result.scss_include_paths.contains(&root.join("src/styles")));
assert!(
result
.scss_include_paths
.contains(&root.join("libs/shared/scss"))
);
}
#[test]
fn resolve_config_scss_include_paths_skips_missing_dirs() {
let tmp = tempfile::tempdir().expect("create temp dir");
let root = tmp.path();
std::fs::create_dir_all(root.join("src/styles")).unwrap();
let source = r#"{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"stylePreprocessorOptions": {
"includePaths": ["src/styles", "missing/dir"]
}
}
}
}
}
}
}"#;
let plugin = AngularPlugin;
let result = plugin.resolve_config(Path::new("angular.json"), source, root);
assert_eq!(result.scss_include_paths.len(), 1);
assert_eq!(result.scss_include_paths[0], root.join("src/styles"));
}
#[test]
fn resolve_config_ng_package_entry_file() {
let source = r#"{
"$schema": "./node_modules/ng-packagr/ng-package.schema.json",
"dest": "./dist",
"lib": {
"entryFile": "src/public-api.ts"
}
}"#;
let plugin = AngularPlugin;
let result = plugin.resolve_config(
Path::new("/project/ng-package.json"),
source,
Path::new("/project"),
);
assert!(has_entry_pattern(&result, "src/public-api.ts"));
}
#[test]
fn resolve_config_ng_package_entry_file_nested_dir() {
let source = r#"{
"lib": { "entryFile": "src/public-api.ts" }
}"#;
let plugin = AngularPlugin;
let result = plugin.resolve_config(
Path::new("/repo/packages/angular/ng-package.json"),
source,
Path::new("/repo"),
);
assert!(has_entry_pattern(
&result,
"packages/angular/src/public-api.ts"
));
}
#[test]
fn resolve_config_ng_package_entry_file_default_when_omitted() {
let source = r#"{ "dest": "./dist", "lib": {} }"#;
let plugin = AngularPlugin;
let result = plugin.resolve_config(
Path::new("/project/ng-package.json"),
source,
Path::new("/project"),
);
assert!(has_entry_pattern(&result, "src/public_api.ts"));
}
#[test]
fn resolve_config_ng_package_default_when_lib_absent() {
let source = r#"{ "$schema": "x", "dest": "./dist" }"#;
let plugin = AngularPlugin;
let result = plugin.resolve_config(
Path::new("/project/ng-package.json"),
source,
Path::new("/project"),
);
assert!(has_entry_pattern(&result, "src/public_api.ts"));
}
#[test]
fn resolve_config_ng_package_prod_variant() {
let source = r#"{ "lib": { "entryFile": "src/prod-api.ts" } }"#;
let plugin = AngularPlugin;
let result = plugin.resolve_config(
Path::new("/project/ng-package.prod.json"),
source,
Path::new("/project"),
);
assert!(has_entry_pattern(&result, "src/prod-api.ts"));
}
#[test]
fn resolve_config_ng_package_empty_entry_file_uses_default() {
let source = r#"{ "lib": { "entryFile": "" } }"#;
let plugin = AngularPlugin;
let result = plugin.resolve_config(
Path::new("/project/ng-package.json"),
source,
Path::new("/project"),
);
assert!(has_entry_pattern(&result, "src/public_api.ts"));
}
#[test]
fn resolve_config_ng_package_malformed_does_not_panic() {
let source = "{ this is not valid json";
let plugin = AngularPlugin;
let result = plugin.resolve_config(
Path::new("/project/ng-package.json"),
source,
Path::new("/project"),
);
assert!(has_entry_pattern(&result, "src/public_api.ts"));
}
#[test]
fn resolve_config_ng_package_collects_nested_secondary_entries() {
let tmp = tempfile::tempdir().expect("temp dir");
let root = tmp.path();
std::fs::create_dir_all(root.join("client")).unwrap();
std::fs::create_dir_all(root.join("server")).unwrap();
std::fs::write(
root.join("ng-package.json"),
r#"{ "lib": { "entryFile": "src/public-api.ts" } }"#,
)
.unwrap();
std::fs::write(
root.join("client/ng-package.json"),
r#"{ "lib": { "entryFile": "src/public_api.ts" } }"#,
)
.unwrap();
std::fs::write(root.join("server/ng-package.json"), r"{}").unwrap();
let source = std::fs::read_to_string(root.join("ng-package.json")).unwrap();
let plugin = AngularPlugin;
let result = plugin.resolve_config(&root.join("ng-package.json"), &source, root);
assert!(has_entry_pattern(&result, "src/public-api.ts"));
assert!(has_entry_pattern(&result, "client/src/public_api.ts"));
assert!(has_entry_pattern(&result, "server/src/public_api.ts"));
}
#[test]
fn resolve_config_ng_package_skips_node_modules_nested_configs() {
let tmp = tempfile::tempdir().expect("temp dir");
let root = tmp.path();
std::fs::create_dir_all(root.join("node_modules/some-lib")).unwrap();
std::fs::write(
root.join("ng-package.json"),
r#"{ "lib": { "entryFile": "src/public-api.ts" } }"#,
)
.unwrap();
std::fs::write(
root.join("node_modules/some-lib/ng-package.json"),
r#"{ "lib": { "entryFile": "src/leaked.ts" } }"#,
)
.unwrap();
let source = std::fs::read_to_string(root.join("ng-package.json")).unwrap();
let plugin = AngularPlugin;
let result = plugin.resolve_config(&root.join("ng-package.json"), &source, root);
assert!(has_entry_pattern(&result, "src/public-api.ts"));
assert!(
!has_entry_pattern(&result, "node_modules/some-lib/src/leaked.ts"),
"node_modules configs must not be collected: {:?}",
result.entry_patterns
);
}
#[test]
fn resolve_config_ng_package_same_dir_sibling_left_to_discovery() {
let tmp = tempfile::tempdir().expect("temp dir");
let root = tmp.path();
std::fs::write(
root.join("ng-package.json"),
r#"{ "lib": { "entryFile": "src/public-api.ts" } }"#,
)
.unwrap();
std::fs::write(
root.join("ng-package.prod.json"),
r#"{ "lib": { "entryFile": "src/prod-api.ts" } }"#,
)
.unwrap();
let source = std::fs::read_to_string(root.join("ng-package.json")).unwrap();
let plugin = AngularPlugin;
let result = plugin.resolve_config(&root.join("ng-package.json"), &source, root);
assert_eq!(result.entry_patterns.len(), 1);
assert!(has_entry_pattern(&result, "src/public-api.ts"));
}
#[test]
fn resolve_config_ng_package_does_not_run_angular_json_extractors() {
let source = r#"{ "lib": { "entryFile": "src/public-api.ts" } }"#;
let plugin = AngularPlugin;
let result = plugin.resolve_config(
Path::new("/project/ng-package.json"),
source,
Path::new("/project"),
);
assert_eq!(result.entry_patterns.len(), 1);
assert!(has_entry_pattern(&result, "src/public-api.ts"));
}
#[test]
fn resolve_config_polyfills_skips_packages() {
let source = r#"{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"polyfills": ["zone.js", "src/polyfills.ts"]
}
}
}
}
}
}"#;
let plugin = AngularPlugin;
let result =
plugin.resolve_config(Path::new("angular.json"), source, Path::new("/project"));
assert!(!has_entry_pattern(&result, "zone.js"));
assert!(has_entry_pattern(&result, "src/polyfills.ts"));
}
#[test]
fn ng_packagr_is_enabler_and_tooling_dependency() {
let plugin = AngularPlugin;
assert!(plugin.enablers().contains(&"ng-packagr"));
assert!(plugin.tooling_dependencies().contains(&"ng-packagr"));
}
}