Skip to main content

provenant/parsers/
npm_workspace.rs

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