1use deps_core::error::{DepsError, Result};
20use deps_core::lockfile::{
21 LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource,
22 locate_lockfile_for_manifest, read_lockfile_content,
23};
24use serde::Deserialize;
25use std::collections::HashMap;
26use std::path::{Path, PathBuf};
27use tower_lsp_server::ls_types::Uri;
28
29const MAX_WORKSPACE_DEPTH: usize = 5;
33
34pub struct NuGetLockParser;
35
36impl NuGetLockParser {
37 const LOCKFILE_NAMES: &'static [&'static str] = &["packages.lock.json"];
38}
39
40fn locate_multi_project_lockfile(manifest_uri: &Uri) -> Option<PathBuf> {
51 let manifest_path = manifest_uri.to_file_path()?;
52 let project_name = manifest_path.file_stem()?.to_str()?;
53 if project_name.is_empty() {
54 return None;
55 }
56 let lock_filename = format!("packages.{project_name}.lock.json");
57 let manifest_dir = manifest_path.parent()?;
58
59 let mut lock_path = manifest_dir.to_path_buf();
60 lock_path.push(&lock_filename);
61 if lock_path.is_file() {
62 return Some(lock_path);
63 }
64
65 let mut current_dir = manifest_dir.parent()?;
66 for _ in 0..MAX_WORKSPACE_DEPTH {
67 lock_path = current_dir.join(&lock_filename);
68 if lock_path.is_file() {
69 return Some(lock_path);
70 }
71 current_dir = current_dir.parent()?;
72 }
73 None
74}
75
76#[derive(Deserialize)]
77struct PackagesLock {
78 #[serde(default)]
82 dependencies: HashMap<String, HashMap<String, LockEntry>>,
83}
84
85#[derive(Deserialize)]
86struct LockEntry {
87 #[serde(default)]
88 resolved: Option<String>,
89 #[serde(default, rename = "contentHash")]
90 content_hash: Option<String>,
91}
92
93impl LockFileProvider for NuGetLockParser {
94 fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf> {
95 locate_lockfile_for_manifest(manifest_uri, Self::LOCKFILE_NAMES)
96 .or_else(|| locate_multi_project_lockfile(manifest_uri))
97 }
98
99 fn parse_lockfile<'a>(
100 &'a self,
101 lockfile_path: &'a Path,
102 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>>
103 {
104 Box::pin(async move {
105 tracing::debug!("Parsing packages.lock.json: {}", lockfile_path.display());
106
107 let content = read_lockfile_content(lockfile_path, "packages.lock.json").await?;
108
109 let lock_data: PackagesLock = deps_core::parse_json_checked(content.as_bytes())
110 .map_err(|e| DepsError::ParseError {
111 file_type: "packages.lock.json".into(),
112 source: Box::new(e),
113 })?;
114
115 let mut candidates: HashMap<String, Vec<(String, Option<String>)>> = HashMap::new();
122 for packages in lock_data.dependencies.into_values() {
123 for (name, entry) in packages {
124 if let Some(resolved) = entry.resolved {
127 candidates
128 .entry(name)
129 .or_default()
130 .push((resolved, entry.content_hash));
131 }
132 }
133 }
134
135 let mut packages = ResolvedPackages::new();
136 for (name, versions) in candidates {
137 let best = versions
138 .into_iter()
139 .max_by(|a, b| crate::version::compare_versions(&a.0, &b.0));
140 if let Some((version, content_hash)) = best {
141 packages.insert(ResolvedPackage {
142 name,
143 version,
144 source: ResolvedSource::Registry {
145 url: crate::registry::NUGET_ORG_INDEX_URL.into(),
151 checksum: content_hash.unwrap_or_default(),
152 },
153 dependencies: vec![],
154 });
155 }
156 }
157
158 tracing::info!(
159 "Parsed packages.lock.json: {} packages from {}",
160 packages.len(),
161 lockfile_path.display()
162 );
163
164 Ok(packages)
165 })
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[tokio::test]
174 async fn test_parse_single_tfm() {
175 let content = r#"{
176 "version": 1,
177 "dependencies": {
178 "net8.0": {
179 "Newtonsoft.Json": {
180 "type": "Direct",
181 "requested": "[13.0.3, )",
182 "resolved": "13.0.3",
183 "contentHash": "abc123"
184 }
185 }
186 }
187}"#;
188 let tmp = tempfile::tempdir().unwrap();
189 let path = tmp.path().join("packages.lock.json");
190 tokio::fs::write(&path, content).await.unwrap();
191
192 let parser = NuGetLockParser;
193 let resolved = parser.parse_lockfile(&path).await.unwrap();
194 assert_eq!(resolved.len(), 1);
195 assert_eq!(resolved.get_version("Newtonsoft.Json"), Some("13.0.3"));
196 }
197
198 #[tokio::test]
199 async fn test_project_reference_entry_skipped() {
200 let content = r#"{
201 "version": 2,
202 "dependencies": {
203 "net8.0": {
204 "MyCompany.Shared": { "type": "Project" },
205 "Newtonsoft.Json": { "type": "Direct", "resolved": "13.0.3" }
206 }
207 }
208}"#;
209 let tmp = tempfile::tempdir().unwrap();
210 let path = tmp.path().join("packages.lock.json");
211 tokio::fs::write(&path, content).await.unwrap();
212
213 let parser = NuGetLockParser;
214 let resolved = parser.parse_lockfile(&path).await.unwrap();
215 assert_eq!(resolved.len(), 1);
216 assert!(resolved.get("MyCompany.Shared").is_none());
217 assert_eq!(resolved.get_version("Newtonsoft.Json"), Some("13.0.3"));
218 }
219
220 #[tokio::test]
221 async fn test_multi_tfm_tie_break_uses_nuget_comparator() {
222 let content = r#"{
225 "version": 1,
226 "dependencies": {
227 "net472": {
228 "Foo": { "type": "Direct", "resolved": "1.9.0.0" }
229 },
230 "net8.0": {
231 "Foo": { "type": "Direct", "resolved": "1.10.0.0" }
232 }
233 }
234}"#;
235 let tmp = tempfile::tempdir().unwrap();
236 let path = tmp.path().join("packages.lock.json");
237 tokio::fs::write(&path, content).await.unwrap();
238
239 let parser = NuGetLockParser;
240 let resolved = parser.parse_lockfile(&path).await.unwrap();
241 assert_eq!(resolved.get_version("Foo"), Some("1.10.0.0"));
242 }
243
244 #[tokio::test]
245 async fn test_missing_optional_fields() {
246 let content = r#"{
247 "dependencies": {
248 "net8.0": {
249 "Bare": { "resolved": "1.0.0" }
250 }
251 }
252}"#;
253 let tmp = tempfile::tempdir().unwrap();
254 let path = tmp.path().join("packages.lock.json");
255 tokio::fs::write(&path, content).await.unwrap();
256
257 let parser = NuGetLockParser;
258 let resolved = parser.parse_lockfile(&path).await.unwrap();
259 assert_eq!(resolved.get_version("Bare"), Some("1.0.0"));
260 }
261
262 #[tokio::test]
263 async fn test_invalid_json_returns_error() {
264 let tmp = tempfile::tempdir().unwrap();
265 let path = tmp.path().join("packages.lock.json");
266 tokio::fs::write(&path, b"not valid json").await.unwrap();
267
268 let parser = NuGetLockParser;
269 let result = parser.parse_lockfile(&path).await;
270 assert!(result.is_err());
271 }
272
273 #[tokio::test]
274 async fn test_nesting_at_max_depth_accepted() {
275 let depth = deps_core::MAX_JSON_NESTING_DEPTH;
276 let content = format!(
277 r#"{{"dependencies": {{}}, "extra": {}1{}}}"#,
278 "[".repeat(depth - 1),
279 "]".repeat(depth - 1)
280 );
281 let tmp = tempfile::tempdir().unwrap();
282 let path = tmp.path().join("packages.lock.json");
283 tokio::fs::write(&path, &content).await.unwrap();
284
285 let parser = NuGetLockParser;
286 assert!(parser.parse_lockfile(&path).await.is_ok());
287 }
288
289 #[tokio::test]
290 async fn test_nesting_over_max_depth_rejected() {
291 let depth = deps_core::MAX_JSON_NESTING_DEPTH + 1;
292 let content = format!(
293 r#"{{"dependencies": {{}}, "extra": {}1{}}}"#,
294 "[".repeat(depth),
295 "]".repeat(depth)
296 );
297 let tmp = tempfile::tempdir().unwrap();
298 let path = tmp.path().join("packages.lock.json");
299 tokio::fs::write(&path, &content).await.unwrap();
300
301 let parser = NuGetLockParser;
302 assert!(parser.parse_lockfile(&path).await.is_err());
303 }
304
305 #[tokio::test]
306 async fn test_empty_dependencies_returns_empty() {
307 let content = r#"{"version": 1, "dependencies": {}}"#;
308 let tmp = tempfile::tempdir().unwrap();
309 let path = tmp.path().join("packages.lock.json");
310 tokio::fs::write(&path, content).await.unwrap();
311
312 let parser = NuGetLockParser;
313 let resolved = parser.parse_lockfile(&path).await.unwrap();
314 assert_eq!(resolved.len(), 0);
315 }
316
317 #[test]
318 fn test_locate_lockfile() {
319 let temp_dir = tempfile::tempdir().unwrap();
320 let manifest_path = temp_dir.path().join("App.csproj");
321 let lock_path = temp_dir.path().join("packages.lock.json");
322 std::fs::write(&manifest_path, "<Project></Project>").unwrap();
323 std::fs::write(&lock_path, "{}").unwrap();
324
325 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
326 let parser = NuGetLockParser;
327 let located = parser.locate_lockfile(&manifest_uri);
328 assert_eq!(located, Some(lock_path));
329 }
330
331 #[test]
338 fn test_locate_lockfile_multi_project_matches_own_project_not_first_found() {
339 let temp_dir = tempfile::tempdir().unwrap();
340 let app1_manifest = temp_dir.path().join("App1.csproj");
341 let app2_manifest = temp_dir.path().join("App2.csproj");
342 let app1_lock = temp_dir.path().join("packages.App1.lock.json");
343 let app2_lock = temp_dir.path().join("packages.App2.lock.json");
344 std::fs::write(&app1_manifest, "<Project></Project>").unwrap();
345 std::fs::write(&app2_manifest, "<Project></Project>").unwrap();
346 std::fs::write(&app1_lock, "{}").unwrap();
347 std::fs::write(&app2_lock, "{}").unwrap();
348
349 let parser = NuGetLockParser;
350 assert_eq!(
351 parser.locate_lockfile(&Uri::from_file_path(&app1_manifest).unwrap()),
352 Some(app1_lock)
353 );
354 assert_eq!(
355 parser.locate_lockfile(&Uri::from_file_path(&app2_manifest).unwrap()),
356 Some(app2_lock)
357 );
358 }
359
360 #[test]
363 fn test_locate_lockfile_multi_project_does_not_match_other_projects_lock_file() {
364 let temp_dir = tempfile::tempdir().unwrap();
365 let app1_manifest = temp_dir.path().join("App1.csproj");
366 let app2_lock = temp_dir.path().join("packages.App2.lock.json");
367 std::fs::write(&app1_manifest, "<Project></Project>").unwrap();
368 std::fs::write(&app2_lock, "{}").unwrap();
369
370 let manifest_uri = Uri::from_file_path(&app1_manifest).unwrap();
371 let parser = NuGetLockParser;
372 assert_eq!(parser.locate_lockfile(&manifest_uri), None);
373 }
374
375 #[test]
376 fn test_locate_lockfile_finds_multi_project_name_in_manifest_dir() {
377 let temp_dir = tempfile::tempdir().unwrap();
378 let manifest_path = temp_dir.path().join("MyApp.csproj");
379 let lock_path = temp_dir.path().join("packages.MyApp.lock.json");
380 std::fs::write(&manifest_path, "<Project></Project>").unwrap();
381 std::fs::write(&lock_path, "{}").unwrap();
382
383 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
384 let parser = NuGetLockParser;
385 assert_eq!(parser.locate_lockfile(&manifest_uri), Some(lock_path));
386 }
387
388 #[test]
389 fn test_locate_lockfile_prefers_exact_name_over_multi_project() {
390 let temp_dir = tempfile::tempdir().unwrap();
391 let manifest_path = temp_dir.path().join("MyApp.csproj");
392 let exact_lock_path = temp_dir.path().join("packages.lock.json");
393 let multi_lock_path = temp_dir.path().join("packages.MyApp.lock.json");
394 std::fs::write(&manifest_path, "<Project></Project>").unwrap();
395 std::fs::write(&exact_lock_path, "{}").unwrap();
396 std::fs::write(&multi_lock_path, "{}").unwrap();
397
398 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
399 let parser = NuGetLockParser;
400 assert_eq!(parser.locate_lockfile(&manifest_uri), Some(exact_lock_path));
401 }
402
403 #[test]
404 fn test_locate_lockfile_finds_multi_project_name_in_workspace_parent() {
405 let temp_dir = tempfile::tempdir().unwrap();
406 let project_dir = temp_dir.path().join("src").join("MyApp");
407 std::fs::create_dir_all(&project_dir).unwrap();
408 let manifest_path = project_dir.join("MyApp.csproj");
409 let lock_path = temp_dir.path().join("packages.MyApp.lock.json");
410 std::fs::write(&manifest_path, "<Project></Project>").unwrap();
411 std::fs::write(&lock_path, "{}").unwrap();
412
413 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
414 let parser = NuGetLockParser;
415 assert_eq!(parser.locate_lockfile(&manifest_uri), Some(lock_path));
416 }
417
418 #[test]
419 fn test_locate_lockfile_no_match_returns_none() {
420 let temp_dir = tempfile::tempdir().unwrap();
421 let manifest_path = temp_dir.path().join("MyApp.csproj");
422 std::fs::write(&manifest_path, "<Project></Project>").unwrap();
423
424 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
425 let parser = NuGetLockParser;
426 assert_eq!(parser.locate_lockfile(&manifest_uri), None);
427 }
428
429 #[test]
430 fn test_locate_lockfile_ignores_unrelated_files_in_dir() {
431 let temp_dir = tempfile::tempdir().unwrap();
432 let manifest_path = temp_dir.path().join("MyApp.csproj");
433 std::fs::write(&manifest_path, "<Project></Project>").unwrap();
434 std::fs::write(temp_dir.path().join("packages.json"), "{}").unwrap();
435 std::fs::write(temp_dir.path().join("packages..lock.json"), "{}").unwrap();
436
437 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
438 let parser = NuGetLockParser;
439 assert_eq!(parser.locate_lockfile(&manifest_uri), None);
440 }
441}