Skip to main content

package_json_lsp/
parser.rs

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}