use jsonc_parser::ast::{Object, Value};
use jsonc_parser::{CollectOptions, ParseOptions, parse_to_ast};
use serde_json::Value as JsonValue;
use tower_lsp::lsp_types::Range;
use crate::document::Document;
use crate::{CATALOG_PREFIX, DEPENDENCY_PROPERTIES, WORKSPACE_PREFIX};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DependencyItem {
pub package_name: String,
pub version_string: String,
pub property_range: Range,
pub value_range: Range,
pub catalog: Option<String>,
pub is_workspace_ref: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct WorkspaceData {
pub packages: Vec<String>,
pub catalog: std::collections::HashMap<String, String>,
pub catalogs: std::collections::HashMap<String, std::collections::HashMap<String, String>>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct WorkspacePositions {
pub catalog: std::collections::HashMap<String, Range>,
pub catalogs: std::collections::HashMap<String, std::collections::HashMap<String, Range>>,
}
pub fn parse_package_dependencies(document: &Document) -> Vec<DependencyItem> {
let Some(root) = parse_jsonc_object(document.text()) else {
return Vec::new();
};
let mut items = Vec::new();
for prop in &root.properties {
if !DEPENDENCY_PROPERTIES.contains(&prop.name.as_str()) {
continue;
}
if let Value::Object(object) = &prop.value {
collect_dependency_items(document, object, &mut items);
}
}
items
}
pub fn parse_json_workspace_data(text: &str) -> WorkspaceData {
let Some(value) = parse_jsonc_value(text) else {
return WorkspaceData::default();
};
let workspaces = value.get("workspaces").unwrap_or(&value);
workspace_data_from_json(workspaces)
}
pub fn parse_yaml_workspace_data(text: &str) -> WorkspaceData {
let value = serde_yaml_ng::from_str::<serde_yaml_ng::Value>(text).ok();
let Some(value) = value else {
return WorkspaceData::default();
};
workspace_data_from_yaml(&value)
}
pub fn parse_json_workspace_positions(document: &Document) -> WorkspacePositions {
let Some(root) = parse_jsonc_object(document.text()) else {
return WorkspacePositions::default();
};
let workspace = root
.get("workspaces")
.and_then(|prop| match &prop.value {
Value::Object(object) => Some(object),
_ => None,
})
.unwrap_or(&root);
positions_from_json_object(document, workspace)
}
pub fn parse_yaml_workspace_positions(document: &Document) -> WorkspacePositions {
let _tree_sitter_language = tree_sitter_yaml::LANGUAGE;
let mut positions = WorkspacePositions::default();
let lines: Vec<&str> = document.text().lines().collect();
let mut index = 0;
while index < lines.len() {
let line = strip_comment(lines[index]);
if let Some((indent, key, rest)) = yaml_key_value(line)
&& indent == 0
&& key == "catalog"
&& rest.is_empty()
{
index += 1;
while index < lines.len() {
let entry = strip_comment(lines[index]);
let Some((entry_indent, pkg, value)) = yaml_key_value(entry) else {
index += 1;
continue;
};
if entry_indent <= indent {
break;
}
if !value.is_empty() {
positions.catalog.insert(
unquote(pkg).to_string(),
yaml_value_range(document, index, lines[index], value),
);
}
index += 1;
}
continue;
}
if let Some((indent, key, rest)) = yaml_key_value(line)
&& indent == 0
&& key == "catalogs"
&& rest.is_empty()
{
index += 1;
while index < lines.len() {
let catalog_line = strip_comment(lines[index]);
let Some((catalog_indent, catalog_name, catalog_rest)) =
yaml_key_value(catalog_line)
else {
index += 1;
continue;
};
if catalog_indent <= indent {
break;
}
if !catalog_rest.is_empty() {
index += 1;
continue;
}
let catalog_name = unquote(catalog_name).to_string();
index += 1;
while index < lines.len() {
let entry = strip_comment(lines[index]);
let Some((entry_indent, pkg, value)) = yaml_key_value(entry) else {
index += 1;
continue;
};
if entry_indent <= catalog_indent {
break;
}
if !value.is_empty() {
positions
.catalogs
.entry(catalog_name.clone())
.or_default()
.insert(
unquote(pkg).to_string(),
yaml_value_range(document, index, lines[index], value),
);
}
index += 1;
}
}
continue;
}
index += 1;
}
positions
}
fn collect_dependency_items(
document: &Document,
object: &Object<'_>,
items: &mut Vec<DependencyItem>,
) {
for prop in &object.properties {
match &prop.value {
Value::StringLit(value) => {
let version_string = value.value.to_string();
let catalog = version_string.strip_prefix(CATALOG_PREFIX).map(|name| {
let name = name.trim();
if name.is_empty() {
"default".to_string()
} else {
name.to_string()
}
});
items.push(DependencyItem {
package_name: prop.name.as_str().to_string(),
version_string: version_string.clone(),
property_range: document
.range_from_byte_range(prop.range.start, prop.range.end),
value_range: string_content_range(document, value.range.start, value.range.end),
catalog,
is_workspace_ref: version_string.starts_with(WORKSPACE_PREFIX),
});
}
Value::Object(object) => collect_dependency_items(document, object, items),
_ => {}
}
}
}
fn parse_jsonc_object(text: &str) -> Option<Object<'_>> {
match parse_to_ast(text, &CollectOptions::default(), &ParseOptions::default())
.ok()?
.value?
{
Value::Object(object) => Some(object),
_ => None,
}
}
fn parse_jsonc_value(text: &str) -> Option<JsonValue> {
jsonc_parser::parse_to_serde_value(text, &ParseOptions::default()).ok()?
}
fn workspace_data_from_json(value: &JsonValue) -> WorkspaceData {
let mut data = WorkspaceData::default();
if let Some(packages) = value.get("packages").and_then(JsonValue::as_array) {
data.packages = packages
.iter()
.filter_map(JsonValue::as_str)
.map(ToOwned::to_owned)
.collect();
}
if let Some(catalog) = value.get("catalog").and_then(JsonValue::as_object) {
data.catalog = catalog
.iter()
.filter_map(|(key, value)| Some((key.clone(), value.as_str()?.to_string())))
.collect();
}
if let Some(catalogs) = value.get("catalogs").and_then(JsonValue::as_object) {
for (name, catalog) in catalogs {
if let Some(catalog) = catalog.as_object() {
data.catalogs.insert(
name.clone(),
catalog
.iter()
.filter_map(|(key, value)| Some((key.clone(), value.as_str()?.to_string())))
.collect(),
);
}
}
}
data
}
fn workspace_data_from_yaml(value: &serde_yaml_ng::Value) -> WorkspaceData {
let json = serde_json::to_value(value).unwrap_or(JsonValue::Null);
workspace_data_from_json(&json)
}
fn positions_from_json_object(document: &Document, object: &Object<'_>) -> WorkspacePositions {
let mut positions = WorkspacePositions::default();
if let Some(catalog) = object.get("catalog")
&& let Value::Object(catalog) = &catalog.value
{
collect_json_string_positions(document, catalog, &mut positions.catalog);
}
if let Some(catalogs) = object.get("catalogs")
&& let Value::Object(catalogs) = &catalogs.value
{
for catalog in &catalogs.properties {
if let Value::Object(object) = &catalog.value {
let mut map = std::collections::HashMap::new();
collect_json_string_positions(document, object, &mut map);
positions
.catalogs
.insert(catalog.name.as_str().to_string(), map);
}
}
}
positions
}
fn collect_json_string_positions(
document: &Document,
object: &Object<'_>,
target: &mut std::collections::HashMap<String, Range>,
) {
for prop in &object.properties {
if let Value::StringLit(value) = &prop.value {
target.insert(
prop.name.as_str().to_string(),
string_content_range(document, value.range.start, value.range.end),
);
}
}
}
fn string_content_range(document: &Document, start: usize, end: usize) -> Range {
if end > start + 1 {
document.range_from_byte_range(start + 1, end - 1)
} else {
document.range_from_byte_range(start, end)
}
}
fn strip_comment(line: &str) -> &str {
line.split_once('#').map_or(line, |(before, _)| before)
}
fn yaml_key_value(line: &str) -> Option<(usize, &str, &str)> {
if line.trim().is_empty() {
return None;
}
let indent = line.len() - line.trim_start().len();
let trimmed = line.trim_start();
let (key, value) = trimmed.split_once(':')?;
Some((indent, key.trim(), value.trim()))
}
fn yaml_value_range(
_document: &Document,
line_index: usize,
full_line: &str,
value: &str,
) -> Range {
let column = full_line.find(value).unwrap_or(full_line.len());
let mut start_column = column;
let mut end_column = column + value.len();
if (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''))
{
start_column += 1;
end_column = end_column.saturating_sub(1);
}
Range {
start: tower_lsp::lsp_types::Position {
line: line_index as u32,
character: start_column as u32,
},
end: tower_lsp::lsp_types::Position {
line: line_index as u32,
character: end_column as u32,
},
}
}
fn unquote(value: &str) -> &str {
value
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.or_else(|| {
value
.strip_prefix('\'')
.and_then(|value| value.strip_suffix('\''))
})
.unwrap_or(value)
}
#[cfg(test)]
mod tests {
use tower_lsp::lsp_types::Url;
use super::*;
fn doc(text: &str) -> Document {
Document::new(
Url::parse("file:///tmp/package.json").unwrap(),
1,
text.to_string(),
)
}
#[test]
fn extracts_dependency_catalogs_and_ranges() {
let document = doc(r#"{
"dependencies": {
"react": "catalog:",
"vite": "catalog:build",
"local": "workspace:*"
}
}"#);
let deps = parse_package_dependencies(&document);
assert_eq!(deps.len(), 3);
assert_eq!(deps[0].package_name, "react");
assert_eq!(deps[0].catalog.as_deref(), Some("default"));
assert_eq!(deps[1].catalog.as_deref(), Some("build"));
assert!(deps[2].is_workspace_ref);
assert_eq!(deps[0].value_range.start.line, 2);
assert_eq!(deps[0].value_range.start.character, 14);
}
#[test]
fn parses_workspace_data_for_package_json() {
let data = parse_json_workspace_data(
r#"{
"workspaces": {
"packages": ["packages/*"],
"catalog": { "react": "^19.0.0" },
"catalogs": { "build": { "vite": "^7.0.0" } }
}
}"#,
);
assert_eq!(data.packages, vec!["packages/*"]);
assert_eq!(data.catalog["react"], "^19.0.0");
assert_eq!(data.catalogs["build"]["vite"], "^7.0.0");
}
#[test]
fn parses_yaml_workspace_data_and_positions() {
let document = doc(r#"packages:
- packages/*
catalog:
react: ^19.0.0
catalogs:
build:
vite: "^7.0.0"
"#);
let data = parse_yaml_workspace_data(document.text());
let positions = parse_yaml_workspace_positions(&document);
assert_eq!(data.packages, vec!["packages/*"]);
assert_eq!(data.catalog["react"], "^19.0.0");
assert_eq!(data.catalogs["build"]["vite"], "^7.0.0");
assert_eq!(positions.catalog["react"].start.line, 3);
assert_eq!(positions.catalogs["build"]["vite"].start.character, 11);
}
}