use jsonc_parser::ast::{Object, ObjectProp, ObjectPropName, Value};
use jsonc_parser::{CollectOptions, ParseOptions, parse_to_ast};
use std::collections::HashMap;
use crate::lsp_helpers::{LineOffsetTable, byte_span_to_range};
use crate::position::Range;
#[must_use]
pub fn find_last_prop<'a, 'b>(object: &'a Object<'b>, key: &str) -> Option<&'a ObjectProp<'b>> {
object
.properties
.iter()
.rev()
.find(|prop| prop.name.as_str() == key)
}
pub struct JsonAst<'a> {
root: Object<'a>,
}
impl<'a> JsonAst<'a> {
#[must_use]
pub fn parse(content: &'a str) -> Option<Self> {
let parsed = parse_to_ast(
content,
&CollectOptions::default(),
&ParseOptions::default(),
)
.ok()?;
match parsed.value {
Some(Value::Object(root)) => Some(Self { root }),
_ => None,
}
}
#[must_use]
pub fn section(&self, key: &str) -> Option<JsonSection<'_>> {
let Value::Object(section) = &find_last_prop(&self.root, key)?.value else {
return None;
};
let mut by_name = HashMap::with_capacity(section.properties.len());
for prop in §ion.properties {
by_name.insert(prop.name.as_str(), prop);
}
Some(JsonSection { by_name })
}
#[must_use]
pub fn root_property_position(
&self,
key: &str,
content: &str,
table: &LineOffsetTable,
) -> Option<(Range, Option<Range>)> {
let prop = find_last_prop(&self.root, key)?;
Some(dependency_position(content, table, prop))
}
}
pub struct JsonSection<'a> {
by_name: HashMap<&'a str, &'a ObjectProp<'a>>,
}
impl JsonSection<'_> {
#[must_use]
pub fn position(
&self,
name: &str,
content: &str,
table: &LineOffsetTable,
) -> Option<(Range, Option<Range>)> {
let prop = *self.by_name.get(name)?;
Some(dependency_position(content, table, prop))
}
}
fn dependency_position(
content: &str,
table: &LineOffsetTable,
prop: &ObjectProp<'_>,
) -> (Range, Option<Range>) {
let name_range = match &prop.name {
ObjectPropName::String(lit) => quoted_lsp_range(content, table, lit.range),
ObjectPropName::Word(lit) => {
byte_span_to_range(content, table, lit.range.start, lit.range.end)
}
};
let version_range = match &prop.value {
Value::StringLit(lit) => Some(quoted_lsp_range(content, table, lit.range)),
_ => None,
};
(name_range, version_range)
}
fn quoted_lsp_range(
content: &str,
table: &LineOffsetTable,
range: jsonc_parser::common::Range,
) -> Range {
byte_span_to_range(content, table, range.start + 1, range.end.saturating_sub(1))
}
#[cfg(test)]
#[expect(
clippy::cast_possible_truncation,
reason = "#673: fixed test-fixture lengths cast to u32 for Position/Range assertions never \
approach truncation range"
)]
mod tests {
use super::*;
#[test]
fn test_section_indexes_direct_properties_only() {
let content = r#"{"require": {"a/b": {"c/d": "0.0.1"}, "c/d": "^2.0"}}"#;
let table = LineOffsetTable::new(content);
let ast = JsonAst::parse(content).unwrap();
let section = ast.section("require").unwrap();
let (_, version_range) = section.position("c/d", content, &table).unwrap();
let version_range = version_range.unwrap();
let expected_start = content.rfind("^2.0").unwrap();
assert_eq!(version_range.start.character, expected_start as u32);
}
#[test]
fn test_duplicate_top_level_key_resolves_to_last_occurrence() {
let content = r#"{"require": {"a": "1.0"}, "require": {"b": "2.0"}}"#;
let table = LineOffsetTable::new(content);
let ast = JsonAst::parse(content).unwrap();
let section = ast.section("require").unwrap();
assert!(section.position("b", content, &table).is_some());
assert!(section.position("a", content, &table).is_none());
}
#[test]
fn test_duplicate_key_within_section_resolves_to_last_occurrence() {
let content = r#"{"dependencies": {"pkg": "1.0", "pkg": "2.0"}}"#;
let table = LineOffsetTable::new(content);
let ast = JsonAst::parse(content).unwrap();
let section = ast.section("dependencies").unwrap();
let (_, version_range) = section.position("pkg", content, &table).unwrap();
let version_range = version_range.unwrap();
let expected_start = content.rfind("2.0").unwrap();
assert_eq!(version_range.start.character, expected_start as u32);
}
#[test]
fn test_escaped_key_resolves_through_the_section_index() {
let content = r#"{"require": {"vendor\/pkg": "^1.0"}}"#;
let table = LineOffsetTable::new(content);
let ast = JsonAst::parse(content).unwrap();
let section = ast.section("require").unwrap();
let (name_range, version_range) = section.position("vendor/pkg", content, &table).unwrap();
assert_eq!(name_range.end.character - name_range.start.character, 11);
assert!(version_range.is_some());
}
#[test]
fn test_section_missing_key_is_none() {
let content = r#"{"require": {}}"#;
let ast = JsonAst::parse(content).unwrap();
assert!(ast.section("require-dev").is_none());
}
#[test]
fn test_section_non_object_value_is_none() {
let content = r#"{"require": "not-an-object"}"#;
let ast = JsonAst::parse(content).unwrap();
assert!(ast.section("require").is_none());
}
#[test]
fn test_parse_invalid_json_is_none() {
assert!(JsonAst::parse("{ not valid").is_none());
}
#[test]
fn test_parse_non_object_root_is_none() {
assert!(JsonAst::parse("[1, 2, 3]").is_none());
}
#[test]
fn test_position_trims_quotes_and_finds_version() {
let content = r#"{"require": {"vendor/pkg": "^1.0"}}"#;
let table = LineOffsetTable::new(content);
let ast = JsonAst::parse(content).unwrap();
let section = ast.section("require").unwrap();
let (name_range, version_range) = section.position("vendor/pkg", content, &table).unwrap();
let name_start = content.find("vendor/pkg").unwrap();
assert_eq!(name_range.start.character, name_start as u32);
assert_eq!(
name_range.end.character,
(name_start + "vendor/pkg".len()) as u32
);
let version_range = version_range.expect("string value must produce a version_range");
let version_start = content.find("^1.0").unwrap();
assert_eq!(version_range.start.character, version_start as u32);
}
#[test]
fn test_position_non_string_value_has_no_version_range() {
let content = r#"{"require": {"vendor/pkg": {"nested": true}}}"#;
let table = LineOffsetTable::new(content);
let ast = JsonAst::parse(content).unwrap();
let section = ast.section("require").unwrap();
let (_, version_range) = section.position("vendor/pkg", content, &table).unwrap();
assert!(version_range.is_none());
}
#[test]
fn test_position_missing_name_is_none() {
let content = r#"{"require": {"vendor/pkg": "^1.0"}}"#;
let table = LineOffsetTable::new(content);
let ast = JsonAst::parse(content).unwrap();
let section = ast.section("require").unwrap();
assert!(
section
.position("does-not-exist", content, &table)
.is_none()
);
}
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn parse_and_position_never_panic(
pairs in proptest::collection::vec(
("[a-zA-Z0-9_.-]{1,20}", "[a-zA-Z0-9_.-]{1,20}"),
0..8,
)
) {
let mut deps = serde_json::Map::new();
for (name, version) in &pairs {
deps.insert(name.clone(), serde_json::Value::String(version.clone()));
}
let mut root = serde_json::Map::new();
root.insert("dependencies".to_string(), serde_json::Value::Object(deps));
let content = serde_json::to_string_pretty(&serde_json::Value::Object(root))
.expect("serde_json::Value serialization is infallible");
let Some(ast) = JsonAst::parse(&content) else {
return Ok(());
};
let table = LineOffsetTable::new(&content);
let Some(section) = ast.section("dependencies") else {
return Ok(());
};
for (name, _) in &pairs {
if let Some((name_range, version_range)) =
section.position(name, &content, &table)
{
prop_assert!(table.line_start(name_range.start.line as usize).is_some());
if let Some(version_range) = version_range {
prop_assert!(
table.line_start(version_range.start.line as usize).is_some()
);
}
}
}
}
}
}
}