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::{MAX_ITERATION_COUNT, 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 workspaces_vec: Vec<String> = workspace_patterns
97                .iter()
98                .take(MAX_ITERATION_COUNT)
99                .filter_map(|v| v.as_str())
100                .map(|s| truncate_field(s.to_string()))
101                .collect();
102
103            PackageData {
104                package_type: Some(NpmWorkspaceParser::PACKAGE_TYPE),
105                extra_data: if workspaces_vec.is_empty() {
106                    None
107                } else {
108                    let mut extra = std::collections::HashMap::new();
109                    extra.insert(
110                        "datasource_id".to_string(),
111                        serde_json::Value::String("pnpm_workspace_yaml".to_string()),
112                    );
113                    extra.insert(
114                        "workspaces".to_string(),
115                        serde_json::Value::Array(
116                            workspaces_vec
117                                .into_iter()
118                                .map(serde_json::Value::String)
119                                .collect(),
120                        ),
121                    );
122                    Some(extra)
123                },
124                ..default_package_data()
125            }
126        }
127        None => {
128            // No workspaces found, return basic package data
129            PackageData {
130                package_type: Some(NpmWorkspaceParser::PACKAGE_TYPE),
131                extra_data: {
132                    let mut extra = std::collections::HashMap::new();
133                    extra.insert(
134                        "datasource_id".to_string(),
135                        serde_json::Value::String("pnpm_workspace_yaml".to_string()),
136                    );
137                    Some(extra)
138                },
139                ..default_package_data()
140            }
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_is_match() {
151        assert!(NpmWorkspaceParser::is_match(Path::new(
152            "pnpm-workspace.yaml"
153        )));
154        assert!(!NpmWorkspaceParser::is_match(Path::new("package.json")));
155        assert!(!NpmWorkspaceParser::is_match(Path::new("pnpm-lock.yaml")));
156        assert!(!NpmWorkspaceParser::is_match(Path::new("README.md")));
157    }
158
159    #[test]
160    fn test_parse_workspace_with_single_package() {
161        let yaml_content = r#"
162packages:
163  - "packages/*"
164"#;
165
166        let workspace_data: Value = yaml_serde::from_str(yaml_content).unwrap();
167        let result = parse_workspace_file(&workspace_data);
168
169        assert_eq!(result.package_type, Some(PackageType::Npm));
170
171        let extra_data = result.extra_data.unwrap();
172        assert_eq!(
173            extra_data.get("datasource_id").unwrap().as_str().unwrap(),
174            "pnpm_workspace_yaml"
175        );
176        let workspaces = extra_data.get("workspaces").unwrap().as_array().unwrap();
177        assert_eq!(workspaces.len(), 1);
178        assert_eq!(workspaces[0], "packages/*");
179    }
180
181    #[test]
182    fn test_parse_workspace_with_multiple_packages() {
183        let yaml_content = r#"
184packages:
185  - "packages/*"
186  - "apps/*"
187  - "tools/*"
188"#;
189
190        let workspace_data: Value = yaml_serde::from_str(yaml_content).unwrap();
191        let result = parse_workspace_file(&workspace_data);
192
193        let extra_data = result.extra_data.unwrap();
194        let workspaces = extra_data.get("workspaces").unwrap().as_array().unwrap();
195        assert_eq!(workspaces.len(), 3);
196        assert_eq!(workspaces[0], "packages/*");
197        assert_eq!(workspaces[1], "apps/*");
198        assert_eq!(workspaces[2], "tools/*");
199    }
200
201    #[test]
202    fn test_parse_workspace_with_wildcard_pattern() {
203        let yaml_content = r#"
204packages:
205  - "*"
206"#;
207
208        let workspace_data: Value = yaml_serde::from_str(yaml_content).unwrap();
209        let result = parse_workspace_file(&workspace_data);
210
211        let extra_data = result.extra_data.unwrap();
212        let workspaces = extra_data.get("workspaces").unwrap().as_array().unwrap();
213        assert_eq!(workspaces.len(), 1);
214        assert_eq!(workspaces[0], "*");
215    }
216
217    #[test]
218    fn test_parse_workspace_with_negated_pattern() {
219        let yaml_content = r#"
220packages:
221  - "packages/*"
222  - "!packages/dont-scan-me"
223"#;
224
225        let workspace_data: Value = yaml_serde::from_str(yaml_content).unwrap();
226        let result = parse_workspace_file(&workspace_data);
227
228        let extra_data = result.extra_data.unwrap();
229        let workspaces = extra_data.get("workspaces").unwrap().as_array().unwrap();
230        assert_eq!(workspaces.len(), 2);
231        assert_eq!(workspaces[0], "packages/*");
232        assert_eq!(workspaces[1], "!packages/dont-scan-me");
233    }
234
235    #[test]
236    fn test_parse_workspace_with_depth_pattern() {
237        let yaml_content = r#"
238packages:
239  - "**/components/*"
240"#;
241
242        let workspace_data: Value = yaml_serde::from_str(yaml_content).unwrap();
243        let result = parse_workspace_file(&workspace_data);
244
245        let extra_data = result.extra_data.unwrap();
246        let workspaces = extra_data.get("workspaces").unwrap().as_array().unwrap();
247        assert_eq!(workspaces.len(), 1);
248        assert_eq!(workspaces[0], "**/components/*");
249    }
250
251    #[test]
252    fn test_parse_workspace_with_no_packages() {
253        let yaml_content = r#"
254name: my-workspace
255"#;
256
257        let workspace_data: Value = yaml_serde::from_str(yaml_content).unwrap();
258        let result = parse_workspace_file(&workspace_data);
259
260        assert_eq!(result.package_type, Some(PackageType::Npm));
261        assert!(result.extra_data.is_some());
262        let extra_data = result.extra_data.unwrap();
263        assert_eq!(
264            extra_data.get("datasource_id").unwrap().as_str().unwrap(),
265            "pnpm_workspace_yaml"
266        );
267        assert!(!extra_data.contains_key("workspaces"));
268    }
269
270    #[test]
271    fn test_parse_workspace_with_empty_packages_array() {
272        let yaml_content = r#"
273packages: []
274"#;
275
276        let workspace_data: Value = yaml_serde::from_str(yaml_content).unwrap();
277        let result = parse_workspace_file(&workspace_data);
278
279        assert_eq!(result.package_type, Some(PackageType::Npm));
280        assert!(
281            result.extra_data.is_none() || !result.extra_data.unwrap().contains_key("workspaces")
282        );
283    }
284
285    #[test]
286    fn test_default_package_data() {
287        let result = default_package_data();
288
289        assert_eq!(result.package_type, Some(PackageType::Npm));
290        assert!(result.name.is_none());
291        assert!(result.version.is_none());
292        assert!(result.extra_data.is_none());
293    }
294}