1use jsonc_parser::ast::{Object, Value};
2use jsonc_parser::{CollectOptions, ParseOptions, parse_to_ast};
3use serde_json::Value as JsonValue;
4use tower_lsp::lsp_types::Range;
5
6use crate::document::Document;
7use crate::{CATALOG_PREFIX, DEPENDENCY_PROPERTIES, WORKSPACE_PREFIX};
8
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct DependencyItem {
11 pub package_name: String,
12 pub version_string: String,
13 pub property_range: Range,
14 pub value_range: Range,
15 pub catalog: Option<String>,
16 pub is_workspace_ref: bool,
17}
18
19#[derive(Clone, Debug, Default, PartialEq, Eq)]
20pub struct WorkspaceData {
21 pub packages: Vec<String>,
22 pub catalog: std::collections::HashMap<String, String>,
23 pub catalogs: std::collections::HashMap<String, std::collections::HashMap<String, String>>,
24}
25
26#[derive(Clone, Debug, Default, PartialEq, Eq)]
27pub struct WorkspacePositions {
28 pub catalog: std::collections::HashMap<String, Range>,
29 pub catalogs: std::collections::HashMap<String, std::collections::HashMap<String, Range>>,
30}
31
32pub fn parse_package_dependencies(document: &Document) -> Vec<DependencyItem> {
33 let Some(root) = parse_jsonc_object(document.text()) else {
34 return Vec::new();
35 };
36
37 let mut items = Vec::new();
38 for prop in &root.properties {
39 if !DEPENDENCY_PROPERTIES.contains(&prop.name.as_str()) {
40 continue;
41 }
42 if let Value::Object(object) = &prop.value {
43 collect_dependency_items(document, object, &mut items);
44 }
45 }
46 items
47}
48
49pub fn parse_json_workspace_data(text: &str) -> WorkspaceData {
50 let Some(value) = parse_jsonc_value(text) else {
51 return WorkspaceData::default();
52 };
53
54 let workspaces = value.get("workspaces").unwrap_or(&value);
55 workspace_data_from_json(workspaces)
56}
57
58pub fn parse_yaml_workspace_data(text: &str) -> WorkspaceData {
59 let value = serde_yaml_ng::from_str::<serde_yaml_ng::Value>(text).ok();
60 let Some(value) = value else {
61 return WorkspaceData::default();
62 };
63 workspace_data_from_yaml(&value)
64}
65
66pub fn parse_json_workspace_positions(document: &Document) -> WorkspacePositions {
67 let Some(root) = parse_jsonc_object(document.text()) else {
68 return WorkspacePositions::default();
69 };
70
71 let workspace = root
72 .get("workspaces")
73 .and_then(|prop| match &prop.value {
74 Value::Object(object) => Some(object),
75 _ => None,
76 })
77 .unwrap_or(&root);
78
79 positions_from_json_object(document, workspace)
80}
81
82pub fn parse_yaml_workspace_positions(document: &Document) -> WorkspacePositions {
83 let _tree_sitter_language = tree_sitter_yaml::LANGUAGE;
84
85 let mut positions = WorkspacePositions::default();
86 let lines: Vec<&str> = document.text().lines().collect();
87
88 let mut index = 0;
89 while index < lines.len() {
90 let line = strip_comment(lines[index]);
91 if let Some((indent, key, rest)) = yaml_key_value(line)
92 && indent == 0
93 && key == "catalog"
94 && rest.is_empty()
95 {
96 index += 1;
97 while index < lines.len() {
98 let entry = strip_comment(lines[index]);
99 let Some((entry_indent, pkg, value)) = yaml_key_value(entry) else {
100 index += 1;
101 continue;
102 };
103 if entry_indent <= indent {
104 break;
105 }
106 if !value.is_empty() {
107 positions.catalog.insert(
108 unquote(pkg).to_string(),
109 yaml_value_range(document, index, lines[index], value),
110 );
111 }
112 index += 1;
113 }
114 continue;
115 }
116
117 if let Some((indent, key, rest)) = yaml_key_value(line)
118 && indent == 0
119 && key == "catalogs"
120 && rest.is_empty()
121 {
122 index += 1;
123 while index < lines.len() {
124 let catalog_line = strip_comment(lines[index]);
125 let Some((catalog_indent, catalog_name, catalog_rest)) =
126 yaml_key_value(catalog_line)
127 else {
128 index += 1;
129 continue;
130 };
131 if catalog_indent <= indent {
132 break;
133 }
134 if !catalog_rest.is_empty() {
135 index += 1;
136 continue;
137 }
138 let catalog_name = unquote(catalog_name).to_string();
139 index += 1;
140 while index < lines.len() {
141 let entry = strip_comment(lines[index]);
142 let Some((entry_indent, pkg, value)) = yaml_key_value(entry) else {
143 index += 1;
144 continue;
145 };
146 if entry_indent <= catalog_indent {
147 break;
148 }
149 if !value.is_empty() {
150 positions
151 .catalogs
152 .entry(catalog_name.clone())
153 .or_default()
154 .insert(
155 unquote(pkg).to_string(),
156 yaml_value_range(document, index, lines[index], value),
157 );
158 }
159 index += 1;
160 }
161 }
162 continue;
163 }
164
165 index += 1;
166 }
167
168 positions
169}
170
171fn collect_dependency_items(
172 document: &Document,
173 object: &Object<'_>,
174 items: &mut Vec<DependencyItem>,
175) {
176 for prop in &object.properties {
177 match &prop.value {
178 Value::StringLit(value) => {
179 let version_string = value.value.to_string();
180 let catalog = version_string.strip_prefix(CATALOG_PREFIX).map(|name| {
181 let name = name.trim();
182 if name.is_empty() {
183 "default".to_string()
184 } else {
185 name.to_string()
186 }
187 });
188 items.push(DependencyItem {
189 package_name: prop.name.as_str().to_string(),
190 version_string: version_string.clone(),
191 property_range: document
192 .range_from_byte_range(prop.range.start, prop.range.end),
193 value_range: string_content_range(document, value.range.start, value.range.end),
194 catalog,
195 is_workspace_ref: version_string.starts_with(WORKSPACE_PREFIX),
196 });
197 }
198 Value::Object(object) => collect_dependency_items(document, object, items),
199 _ => {}
200 }
201 }
202}
203
204fn parse_jsonc_object(text: &str) -> Option<Object<'_>> {
205 match parse_to_ast(text, &CollectOptions::default(), &ParseOptions::default())
206 .ok()?
207 .value?
208 {
209 Value::Object(object) => Some(object),
210 _ => None,
211 }
212}
213
214fn parse_jsonc_value(text: &str) -> Option<JsonValue> {
215 jsonc_parser::parse_to_serde_value(text, &ParseOptions::default()).ok()?
216}
217
218fn workspace_data_from_json(value: &JsonValue) -> WorkspaceData {
219 let mut data = WorkspaceData::default();
220
221 if let Some(packages) = value.get("packages").and_then(JsonValue::as_array) {
222 data.packages = packages
223 .iter()
224 .filter_map(JsonValue::as_str)
225 .map(ToOwned::to_owned)
226 .collect();
227 }
228
229 if let Some(catalog) = value.get("catalog").and_then(JsonValue::as_object) {
230 data.catalog = catalog
231 .iter()
232 .filter_map(|(key, value)| Some((key.clone(), value.as_str()?.to_string())))
233 .collect();
234 }
235
236 if let Some(catalogs) = value.get("catalogs").and_then(JsonValue::as_object) {
237 for (name, catalog) in catalogs {
238 if let Some(catalog) = catalog.as_object() {
239 data.catalogs.insert(
240 name.clone(),
241 catalog
242 .iter()
243 .filter_map(|(key, value)| Some((key.clone(), value.as_str()?.to_string())))
244 .collect(),
245 );
246 }
247 }
248 }
249
250 data
251}
252
253fn workspace_data_from_yaml(value: &serde_yaml_ng::Value) -> WorkspaceData {
254 let json = serde_json::to_value(value).unwrap_or(JsonValue::Null);
255 workspace_data_from_json(&json)
256}
257
258fn positions_from_json_object(document: &Document, object: &Object<'_>) -> WorkspacePositions {
259 let mut positions = WorkspacePositions::default();
260
261 if let Some(catalog) = object.get("catalog")
262 && let Value::Object(catalog) = &catalog.value
263 {
264 collect_json_string_positions(document, catalog, &mut positions.catalog);
265 }
266
267 if let Some(catalogs) = object.get("catalogs")
268 && let Value::Object(catalogs) = &catalogs.value
269 {
270 for catalog in &catalogs.properties {
271 if let Value::Object(object) = &catalog.value {
272 let mut map = std::collections::HashMap::new();
273 collect_json_string_positions(document, object, &mut map);
274 positions
275 .catalogs
276 .insert(catalog.name.as_str().to_string(), map);
277 }
278 }
279 }
280
281 positions
282}
283
284fn collect_json_string_positions(
285 document: &Document,
286 object: &Object<'_>,
287 target: &mut std::collections::HashMap<String, Range>,
288) {
289 for prop in &object.properties {
290 if let Value::StringLit(value) = &prop.value {
291 target.insert(
292 prop.name.as_str().to_string(),
293 string_content_range(document, value.range.start, value.range.end),
294 );
295 }
296 }
297}
298
299fn string_content_range(document: &Document, start: usize, end: usize) -> Range {
300 if end > start + 1 {
301 document.range_from_byte_range(start + 1, end - 1)
302 } else {
303 document.range_from_byte_range(start, end)
304 }
305}
306
307fn strip_comment(line: &str) -> &str {
308 line.split_once('#').map_or(line, |(before, _)| before)
309}
310
311fn yaml_key_value(line: &str) -> Option<(usize, &str, &str)> {
312 if line.trim().is_empty() {
313 return None;
314 }
315 let indent = line.len() - line.trim_start().len();
316 let trimmed = line.trim_start();
317 let (key, value) = trimmed.split_once(':')?;
318 Some((indent, key.trim(), value.trim()))
319}
320
321fn yaml_value_range(
322 _document: &Document,
323 line_index: usize,
324 full_line: &str,
325 value: &str,
326) -> Range {
327 let column = full_line.find(value).unwrap_or(full_line.len());
328 let mut start_column = column;
329 let mut end_column = column + value.len();
330
331 if (value.starts_with('"') && value.ends_with('"'))
332 || (value.starts_with('\'') && value.ends_with('\''))
333 {
334 start_column += 1;
335 end_column = end_column.saturating_sub(1);
336 }
337
338 Range {
339 start: tower_lsp::lsp_types::Position {
340 line: line_index as u32,
341 character: start_column as u32,
342 },
343 end: tower_lsp::lsp_types::Position {
344 line: line_index as u32,
345 character: end_column as u32,
346 },
347 }
348}
349
350fn unquote(value: &str) -> &str {
351 value
352 .strip_prefix('"')
353 .and_then(|value| value.strip_suffix('"'))
354 .or_else(|| {
355 value
356 .strip_prefix('\'')
357 .and_then(|value| value.strip_suffix('\''))
358 })
359 .unwrap_or(value)
360}
361
362#[cfg(test)]
363mod tests {
364 use tower_lsp::lsp_types::Url;
365
366 use super::*;
367
368 fn doc(text: &str) -> Document {
369 Document::new(
370 Url::parse("file:///tmp/package.json").unwrap(),
371 1,
372 text.to_string(),
373 )
374 }
375
376 #[test]
377 fn extracts_dependency_catalogs_and_ranges() {
378 let document = doc(r#"{
379 "dependencies": {
380 "react": "catalog:",
381 "vite": "catalog:build",
382 "local": "workspace:*"
383 }
384}"#);
385
386 let deps = parse_package_dependencies(&document);
387
388 assert_eq!(deps.len(), 3);
389 assert_eq!(deps[0].package_name, "react");
390 assert_eq!(deps[0].catalog.as_deref(), Some("default"));
391 assert_eq!(deps[1].catalog.as_deref(), Some("build"));
392 assert!(deps[2].is_workspace_ref);
393 assert_eq!(deps[0].value_range.start.line, 2);
394 assert_eq!(deps[0].value_range.start.character, 14);
395 }
396
397 #[test]
398 fn parses_workspace_data_for_package_json() {
399 let data = parse_json_workspace_data(
400 r#"{
401 "workspaces": {
402 "packages": ["packages/*"],
403 "catalog": { "react": "^19.0.0" },
404 "catalogs": { "build": { "vite": "^7.0.0" } }
405 }
406}"#,
407 );
408
409 assert_eq!(data.packages, vec!["packages/*"]);
410 assert_eq!(data.catalog["react"], "^19.0.0");
411 assert_eq!(data.catalogs["build"]["vite"], "^7.0.0");
412 }
413
414 #[test]
415 fn parses_yaml_workspace_data_and_positions() {
416 let document = doc(r#"packages:
417 - packages/*
418catalog:
419 react: ^19.0.0
420catalogs:
421 build:
422 vite: "^7.0.0"
423"#);
424
425 let data = parse_yaml_workspace_data(document.text());
426 let positions = parse_yaml_workspace_positions(&document);
427
428 assert_eq!(data.packages, vec!["packages/*"]);
429 assert_eq!(data.catalog["react"], "^19.0.0");
430 assert_eq!(data.catalogs["build"]["vite"], "^7.0.0");
431 assert_eq!(positions.catalog["react"].start.line, 3);
432 assert_eq!(positions.catalogs["build"]["vite"].start.character, 11);
433 }
434}