Skip to main content

provenant/parsers/
npm_workspace.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! Parser for npm/pnpm workspace configuration files.
7//!
8//! Extracts workspace package patterns and monorepo structure from workspace
9//! configuration files used by npm, yarn, and pnpm to define workspaces.
10//!
11//! # Supported Formats
12//! - pnpm-workspace.yaml (YAML workspace configuration)
13//!
14//! # Key Features
15//! - Workspace package pattern extraction (glob patterns for package locations)
16//! - Monorepo structure detection and documentation
17//! - Package discovery from workspace configurations
18//!
19//! # Implementation Notes
20//! - Parses YAML format for workspace field
21//! - Package patterns are glob expressions (e.g., `packages/*`, `@scoped/**`)
22//! - Returns package data representing the workspace configuration itself
23
24use crate::models::PackageData;
25use crate::models::{DatasourceId, PackageType};
26use crate::parser_warn as warn;
27use crate::parsers::utils::{capped_iteration_limit, read_file_to_string, truncate_field};
28use std::path::Path;
29use yaml_serde::Value;
30
31use super::PackageParser;
32use super::metadata::ParserMetadata;
33
34/// npm workspace parser for pnpm-workspace.yaml files.
35///
36/// Extracts workspace package patterns for monorepo configurations.
37pub struct NpmWorkspaceParser;
38
39impl PackageParser for NpmWorkspaceParser {
40    const PACKAGE_TYPE: PackageType = PackageType::Npm;
41
42    fn metadata() -> Vec<ParserMetadata> {
43        vec![ParserMetadata {
44            description: "pnpm workspace yaml file",
45            file_patterns: &["**/pnpm-workspace.yaml"],
46            package_type: "npm",
47            primary_language: "JavaScript",
48            documentation_url: Some("https://pnpm.io/pnpm-workspace_yaml"),
49        }]
50    }
51
52    fn is_match(path: &Path) -> bool {
53        path.file_name()
54            .and_then(|name| name.to_str())
55            .map(|name| name == "pnpm-workspace.yaml")
56            .unwrap_or(false)
57    }
58
59    fn extract_packages(path: &Path) -> Vec<PackageData> {
60        let content = match read_file_to_string(path, None) {
61            Ok(content) => content,
62            Err(e) => {
63                warn!("Failed to read npm workspace file at {:?}: {}", path, e);
64                return vec![default_package_data()];
65            }
66        };
67
68        let workspace_data: Value = match yaml_serde::from_str(&content) {
69            Ok(data) => data,
70            Err(e) => {
71                crate::parser_warn!("Failed to parse npm workspace file at {:?}: {}", path, e);
72                return vec![default_package_data()];
73            }
74        };
75
76        vec![parse_workspace_file(&workspace_data)]
77    }
78}
79
80/// Returns a default empty PackageData for error cases
81fn default_package_data() -> PackageData {
82    PackageData {
83        package_type: Some(NpmWorkspaceParser::PACKAGE_TYPE),
84        datasource_id: Some(DatasourceId::PnpmWorkspaceYaml),
85        ..Default::default()
86    }
87}
88
89/// Parse a pnpm-workspace.yaml file and extract workspace configuration
90fn parse_workspace_file(workspace_data: &Value) -> PackageData {
91    // Extract the `packages` field which contains workspace patterns
92    let workspaces = workspace_data.get("packages").and_then(|v| v.as_sequence());
93
94    match workspaces {
95        Some(workspace_patterns) => {
96            let limit = capped_iteration_limit(
97                workspace_patterns.len(),
98                "pnpm workspace: packages patterns",
99            );
100            let workspaces_vec: Vec<String> = workspace_patterns
101                .iter()
102                .take(limit)
103                .filter_map(|v| v.as_str())
104                .map(|s| truncate_field(s.to_string()))
105                .collect();
106
107            PackageData {
108                package_type: Some(NpmWorkspaceParser::PACKAGE_TYPE),
109                extra_data: if workspaces_vec.is_empty() {
110                    None
111                } else {
112                    let mut extra = std::collections::HashMap::new();
113                    extra.insert(
114                        "datasource_id".to_string(),
115                        serde_json::Value::String("pnpm_workspace_yaml".to_string()),
116                    );
117                    extra.insert(
118                        "workspaces".to_string(),
119                        serde_json::Value::Array(
120                            workspaces_vec
121                                .into_iter()
122                                .map(serde_json::Value::String)
123                                .collect(),
124                        ),
125                    );
126                    Some(extra)
127                },
128                ..default_package_data()
129            }
130        }
131        None => {
132            // No workspaces found, return basic package data
133            PackageData {
134                package_type: Some(NpmWorkspaceParser::PACKAGE_TYPE),
135                extra_data: {
136                    let mut extra = std::collections::HashMap::new();
137                    extra.insert(
138                        "datasource_id".to_string(),
139                        serde_json::Value::String("pnpm_workspace_yaml".to_string()),
140                    );
141                    Some(extra)
142                },
143                ..default_package_data()
144            }
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn test_is_match() {
155        assert!(NpmWorkspaceParser::is_match(Path::new(
156            "pnpm-workspace.yaml"
157        )));
158        assert!(!NpmWorkspaceParser::is_match(Path::new("package.json")));
159        assert!(!NpmWorkspaceParser::is_match(Path::new("pnpm-lock.yaml")));
160        assert!(!NpmWorkspaceParser::is_match(Path::new("README.md")));
161    }
162
163    #[test]
164    fn test_parse_workspace_with_single_package() {
165        let yaml_content = r#"
166packages:
167  - "packages/*"
168"#;
169
170        let workspace_data: Value = yaml_serde::from_str(yaml_content).unwrap();
171        let result = parse_workspace_file(&workspace_data);
172
173        assert_eq!(result.package_type, Some(PackageType::Npm));
174
175        let extra_data = result.extra_data.unwrap();
176        assert_eq!(
177            extra_data.get("datasource_id").unwrap().as_str().unwrap(),
178            "pnpm_workspace_yaml"
179        );
180        let workspaces = extra_data.get("workspaces").unwrap().as_array().unwrap();
181        assert_eq!(workspaces.len(), 1);
182        assert_eq!(workspaces[0], "packages/*");
183    }
184
185    #[test]
186    fn test_parse_workspace_with_multiple_packages() {
187        let yaml_content = r#"
188packages:
189  - "packages/*"
190  - "apps/*"
191  - "tools/*"
192"#;
193
194        let workspace_data: Value = yaml_serde::from_str(yaml_content).unwrap();
195        let result = parse_workspace_file(&workspace_data);
196
197        let extra_data = result.extra_data.unwrap();
198        let workspaces = extra_data.get("workspaces").unwrap().as_array().unwrap();
199        assert_eq!(workspaces.len(), 3);
200        assert_eq!(workspaces[0], "packages/*");
201        assert_eq!(workspaces[1], "apps/*");
202        assert_eq!(workspaces[2], "tools/*");
203    }
204
205    #[test]
206    fn test_parse_workspace_with_wildcard_pattern() {
207        let yaml_content = r#"
208packages:
209  - "*"
210"#;
211
212        let workspace_data: Value = yaml_serde::from_str(yaml_content).unwrap();
213        let result = parse_workspace_file(&workspace_data);
214
215        let extra_data = result.extra_data.unwrap();
216        let workspaces = extra_data.get("workspaces").unwrap().as_array().unwrap();
217        assert_eq!(workspaces.len(), 1);
218        assert_eq!(workspaces[0], "*");
219    }
220
221    #[test]
222    fn test_parse_workspace_with_negated_pattern() {
223        let yaml_content = r#"
224packages:
225  - "packages/*"
226  - "!packages/dont-scan-me"
227"#;
228
229        let workspace_data: Value = yaml_serde::from_str(yaml_content).unwrap();
230        let result = parse_workspace_file(&workspace_data);
231
232        let extra_data = result.extra_data.unwrap();
233        let workspaces = extra_data.get("workspaces").unwrap().as_array().unwrap();
234        assert_eq!(workspaces.len(), 2);
235        assert_eq!(workspaces[0], "packages/*");
236        assert_eq!(workspaces[1], "!packages/dont-scan-me");
237    }
238
239    #[test]
240    fn test_parse_workspace_with_depth_pattern() {
241        let yaml_content = r#"
242packages:
243  - "**/components/*"
244"#;
245
246        let workspace_data: Value = yaml_serde::from_str(yaml_content).unwrap();
247        let result = parse_workspace_file(&workspace_data);
248
249        let extra_data = result.extra_data.unwrap();
250        let workspaces = extra_data.get("workspaces").unwrap().as_array().unwrap();
251        assert_eq!(workspaces.len(), 1);
252        assert_eq!(workspaces[0], "**/components/*");
253    }
254
255    #[test]
256    fn test_parse_workspace_with_no_packages() {
257        let yaml_content = r#"
258name: my-workspace
259"#;
260
261        let workspace_data: Value = yaml_serde::from_str(yaml_content).unwrap();
262        let result = parse_workspace_file(&workspace_data);
263
264        assert_eq!(result.package_type, Some(PackageType::Npm));
265        assert!(result.extra_data.is_some());
266        let extra_data = result.extra_data.unwrap();
267        assert_eq!(
268            extra_data.get("datasource_id").unwrap().as_str().unwrap(),
269            "pnpm_workspace_yaml"
270        );
271        assert!(!extra_data.contains_key("workspaces"));
272    }
273
274    #[test]
275    fn test_parse_workspace_with_empty_packages_array() {
276        let yaml_content = r#"
277packages: []
278"#;
279
280        let workspace_data: Value = yaml_serde::from_str(yaml_content).unwrap();
281        let result = parse_workspace_file(&workspace_data);
282
283        assert_eq!(result.package_type, Some(PackageType::Npm));
284        assert!(
285            result.extra_data.is_none() || !result.extra_data.unwrap().contains_key("workspaces")
286        );
287    }
288
289    #[test]
290    fn test_default_package_data() {
291        let result = default_package_data();
292
293        assert_eq!(result.package_type, Some(PackageType::Npm));
294        assert!(result.name.is_none());
295        assert!(result.version.is_none());
296        assert!(result.extra_data.is_none());
297    }
298}