deps_core/json_ast.rs
1//! Shared JSONC-AST helpers for recovering exact dependency source positions (#613).
2//!
3//! `deps-npm` and `deps-composer` each parse a JSON manifest's *semantic* content
4//! (dependency names, version requirement strings, `minimum-stability`, ...) via
5//! `serde_json`/[`crate::parse_json_checked`], as before — this module is never a
6//! replacement for that.
7//!
8//! What it replaces is how each crate previously recovered a dependency's *position*:
9//! substring-scanning the raw manifest text for a section's byte range, then for each
10//! name/version pattern within it (removed by #613, since it broke down for duplicate keys and
11//! same-named nested values — see `find_json_section_byte_range`'s removal in this same
12//! change). Reading positions directly off a position-preserving parse tree is correct by
13//! construction regardless of `serde_json::Map` iteration order, duplicate keys, or nested
14//! value shapes, mirroring `crates/deps-deno/src/parser.rs`'s original D6 design — generalized
15//! here for the "several named top-level sections" shape (`dependencies`/`devDependencies`,
16//! `require`/`require-dev`) that `deno.json`'s single flat `imports` map doesn't have.
17//!
18//! [`JsonSection`] keeps `jsonc-parser`'s own AST types out of every downstream crate's public
19//! (and Cargo.toml direct-dependency) surface — only `deps-core` names `jsonc_parser::ast`
20//! types directly.
21
22use jsonc_parser::ast::{Object, ObjectProp, ObjectPropName, Value};
23use jsonc_parser::{CollectOptions, ParseOptions, parse_to_ast};
24use std::collections::HashMap;
25use tower_lsp_server::ls_types::Range;
26
27use crate::lsp_helpers::LineOffsetTable;
28
29/// Finds the property named `key` among `object`'s own direct children, taking the *last* one
30/// if `key` occurs more than once.
31///
32/// This is JSON's last-key-wins semantics, which `jsonc-parser`'s `Vec<ObjectProp>` does not
33/// enforce on its own (unlike `serde_json::Map`, which dedupes during deserialization).
34///
35/// # Examples
36///
37/// ```
38/// use deps_core::json_ast::find_last_prop;
39/// use jsonc_parser::ast::Value;
40/// use jsonc_parser::{CollectOptions, ParseOptions, parse_to_ast};
41///
42/// let content = r#"{"a": 1, "a": 2}"#;
43/// let parsed = parse_to_ast(content, &CollectOptions::default(), &ParseOptions::default()).unwrap();
44/// let Some(Value::Object(root)) = parsed.value else { panic!("expected an object") };
45///
46/// let prop = find_last_prop(&root, "a").unwrap();
47/// assert!(matches!(&prop.value, Value::NumberLit(lit) if lit.value == "2"));
48/// ```
49#[must_use]
50pub fn find_last_prop<'a, 'b>(object: &'a Object<'b>, key: &str) -> Option<&'a ObjectProp<'b>> {
51 object
52 .properties
53 .iter()
54 .rev()
55 .find(|prop| prop.name.as_str() == key)
56}
57
58/// A parsed JSON/JSONC document, used only to recover a named top-level section's exact
59/// source positions — never a substitute for a caller's own `serde_json` parse of the
60/// document's semantic content.
61///
62/// # Examples
63///
64/// ```
65/// use deps_core::json_ast::JsonAst;
66///
67/// let content = r#"{"dependencies": {"express": "^4.18.2"}}"#;
68/// let ast = JsonAst::parse(content).unwrap();
69/// assert!(ast.section("dependencies").is_some());
70/// assert!(ast.section("devDependencies").is_none());
71/// ```
72pub struct JsonAst<'a> {
73 root: Object<'a>,
74}
75
76impl<'a> JsonAst<'a> {
77 /// Parses `content`. Returns `None` if it isn't valid JSONC, or its root value isn't an
78 /// object — a caller that reached this via [`crate::parse_json_checked`] should not
79 /// normally see `None` here (jsonc-parser's grammar is a strict superset of JSON), but must
80 /// still degrade gracefully (every dependency's position falling back to the default,
81 /// zero `Range`) rather than assume it.
82 ///
83 /// Relies on the caller having already bounded `content`'s nesting depth (e.g. via
84 /// [`crate::parse_json_checked`]/[`crate::check_json_nesting_depth`]) — jsonc-parser's own
85 /// internal recursion cap (512, hardcoded, not configurable) is a last resort, not this
86 /// workspace's first line of defense against a stack-overflowing payload.
87 ///
88 /// # Examples
89 ///
90 /// ```
91 /// use deps_core::json_ast::JsonAst;
92 ///
93 /// assert!(JsonAst::parse(r#"{"a": 1}"#).is_some());
94 /// assert!(JsonAst::parse("not json").is_none());
95 /// assert!(JsonAst::parse("[1, 2, 3]").is_none(), "root value must be an object");
96 /// ```
97 #[must_use]
98 pub fn parse(content: &'a str) -> Option<Self> {
99 let parsed = parse_to_ast(
100 content,
101 &CollectOptions::default(),
102 &ParseOptions::default(),
103 )
104 .ok()?;
105 match parsed.value {
106 Some(Value::Object(root)) => Some(Self { root }),
107 _ => None,
108 }
109 }
110
111 /// Indexes `key`'s top-level section by its direct properties' own names, for O(1)
112 /// per-dependency position lookup instead of an O(section length) rescan per dependency.
113 /// `None` if `key` is absent at the top level or its value isn't an object. A duplicate key
114 /// *within* the section resolves to its last occurrence too, matching `key`'s own
115 /// last-key-wins resolution (see [`find_last_prop`]) — the same rule applied one level
116 /// deeper.
117 ///
118 /// # Examples
119 ///
120 /// ```
121 /// use deps_core::json_ast::JsonAst;
122 /// use deps_core::lsp_helpers::LineOffsetTable;
123 ///
124 /// let content = r#"{"require": {"vendor/pkg": "^1.0"}}"#;
125 /// let table = LineOffsetTable::new(content);
126 /// let ast = JsonAst::parse(content).unwrap();
127 /// let section = ast.section("require").unwrap();
128 ///
129 /// let (name_range, version_range) = section.position("vendor/pkg", content, &table).unwrap();
130 /// let name_start = content.find("vendor/pkg").unwrap() as u32;
131 /// assert_eq!(name_range.start.character, name_start);
132 /// assert!(version_range.is_some());
133 /// ```
134 #[must_use]
135 pub fn section(&self, key: &str) -> Option<JsonSection<'_>> {
136 let Value::Object(section) = &find_last_prop(&self.root, key)?.value else {
137 return None;
138 };
139 let mut by_name = HashMap::with_capacity(section.properties.len());
140 for prop in §ion.properties {
141 by_name.insert(prop.name.as_str(), prop);
142 }
143 Some(JsonSection { by_name })
144 }
145}
146
147/// One top-level section's direct properties, indexed by name.
148///
149/// Opaque handle returned by [`JsonAst::section`], keeping `jsonc-parser`'s own AST types out
150/// of every downstream crate's public API and direct dependency graph.
151pub struct JsonSection<'a> {
152 by_name: HashMap<&'a str, &'a ObjectProp<'a>>,
153}
154
155impl JsonSection<'_> {
156 /// Looks up `name`'s `(name_range, version_range)` LSP position pair within this section.
157 ///
158 /// `version_range` is `Some` only when the property's value is itself a plain string
159 /// literal (an object/array/number/bool/null value has no meaningful "version span",
160 /// matching each caller's own `value.as_str()` gate on whether the entry is a
161 /// dependency declaration at all). `None` if `name` isn't a direct property of this
162 /// section.
163 ///
164 /// # Examples
165 ///
166 /// ```
167 /// use deps_core::json_ast::JsonAst;
168 /// use deps_core::lsp_helpers::LineOffsetTable;
169 ///
170 /// let content = r#"{"require": {"vendor/pkg": {"nested": true}}}"#;
171 /// let table = LineOffsetTable::new(content);
172 /// let ast = JsonAst::parse(content).unwrap();
173 /// let section = ast.section("require").unwrap();
174 ///
175 /// let (_, version_range) = section.position("vendor/pkg", content, &table).unwrap();
176 /// assert!(version_range.is_none(), "a non-string value has no version span");
177 /// assert!(section.position("missing", content, &table).is_none());
178 /// ```
179 #[must_use]
180 pub fn position(
181 &self,
182 name: &str,
183 content: &str,
184 table: &LineOffsetTable,
185 ) -> Option<(Range, Option<Range>)> {
186 let prop = *self.by_name.get(name)?;
187 Some(dependency_position(content, table, prop))
188 }
189}
190
191/// Converts one dependency's AST property into its `(name_range, version_range)` LSP position
192/// pair.
193///
194/// Trimming a literal's surrounding quotes is always a safe, single-byte, ASCII offset
195/// adjustment regardless of whether the literal's *value* required unescaping — unlike
196/// recovering a sub-span *inside* an escaped value (see `deps-deno`'s parser for that harder
197/// case), which this never needs to do since a caller only ever wants the whole version
198/// literal's span, not a piece of it.
199fn dependency_position(
200 content: &str,
201 table: &LineOffsetTable,
202 prop: &ObjectProp<'_>,
203) -> (Range, Option<Range>) {
204 let name_range = match &prop.name {
205 ObjectPropName::String(lit) => quoted_lsp_range(content, table, lit.range),
206 // Defensive only: an unquoted property name can't occur in content already accepted by
207 // `parse_json_checked`'s strict-JSON parse, but this must not mis-trim quotes that
208 // aren't there if it somehow does.
209 ObjectPropName::Word(lit) => Range::new(
210 table.byte_offset_to_position(content, lit.range.start),
211 table.byte_offset_to_position(content, lit.range.end),
212 ),
213 };
214 let version_range = match &prop.value {
215 Value::StringLit(lit) => Some(quoted_lsp_range(content, table, lit.range)),
216 _ => None,
217 };
218 (name_range, version_range)
219}
220
221/// Converts a jsonc-parser `Range` spanning a quoted literal (including its surrounding quotes)
222/// into the LSP `Range` covering just the inner, unquoted text.
223fn quoted_lsp_range(
224 content: &str,
225 table: &LineOffsetTable,
226 range: jsonc_parser::common::Range,
227) -> Range {
228 Range::new(
229 table.byte_offset_to_position(content, range.start + 1),
230 table.byte_offset_to_position(content, range.end.saturating_sub(1)),
231 )
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn test_section_indexes_direct_properties_only() {
240 let content = r#"{"require": {"a/b": {"c/d": "0.0.1"}, "c/d": "^2.0"}}"#;
241 let table = LineOffsetTable::new(content);
242 let ast = JsonAst::parse(content).unwrap();
243 let section = ast.section("require").unwrap();
244
245 let (_, version_range) = section.position("c/d", content, &table).unwrap();
246 let version_range = version_range.unwrap();
247 // The real top-level "c/d" value ("^2.0") is what must be found, not the nested one
248 // ("0.0.1") inside "a/b"'s value.
249 let expected_start = content.rfind("^2.0").unwrap();
250 assert_eq!(version_range.start.character, expected_start as u32);
251 }
252
253 #[test]
254 fn test_duplicate_top_level_key_resolves_to_last_occurrence() {
255 let content = r#"{"require": {"a": "1.0"}, "require": {"b": "2.0"}}"#;
256 let table = LineOffsetTable::new(content);
257 let ast = JsonAst::parse(content).unwrap();
258 let section = ast.section("require").unwrap();
259
260 assert!(section.position("b", content, &table).is_some());
261 assert!(section.position("a", content, &table).is_none());
262 }
263
264 /// M6(a): a duplicate key *within* a section (as opposed to a duplicate top-level section
265 /// key) must resolve to its last occurrence too — `HashMap::insert` overwrites in source
266 /// order, so the last-seen `ObjectProp` for a repeated name is the one that survives.
267 #[test]
268 fn test_duplicate_key_within_section_resolves_to_last_occurrence() {
269 let content = r#"{"dependencies": {"pkg": "1.0", "pkg": "2.0"}}"#;
270 let table = LineOffsetTable::new(content);
271 let ast = JsonAst::parse(content).unwrap();
272 let section = ast.section("dependencies").unwrap();
273
274 let (_, version_range) = section.position("pkg", content, &table).unwrap();
275 let version_range = version_range.unwrap();
276 let expected_start = content.rfind("2.0").unwrap();
277 assert_eq!(version_range.start.character, expected_start as u32);
278 }
279
280 /// M6(b): the whole double-parse design rests on `ObjectPropName::as_str()` returning the
281 /// *unescaped* key, matching `serde_json`'s own unescaped `Map` key — so a lookup by the
282 /// unescaped name must find a property declared with an escaped key in the source.
283 #[test]
284 fn test_escaped_key_resolves_through_the_section_index() {
285 let content = r#"{"require": {"vendor\/pkg": "^1.0"}}"#;
286 let table = LineOffsetTable::new(content);
287 let ast = JsonAst::parse(content).unwrap();
288 let section = ast.section("require").unwrap();
289
290 // Looked up by the unescaped name — the same string `serde_json::Map`'s key holds.
291 let (name_range, version_range) = section.position("vendor/pkg", content, &table).unwrap();
292 // The raw (still-escaped) source span is 15 bytes: "vendor\/pkg" == v-e-n-d-o-r-\-/-p-k-g.
293 assert_eq!(name_range.end.character - name_range.start.character, 11);
294 assert!(version_range.is_some());
295 }
296
297 #[test]
298 fn test_section_missing_key_is_none() {
299 let content = r#"{"require": {}}"#;
300 let ast = JsonAst::parse(content).unwrap();
301 assert!(ast.section("require-dev").is_none());
302 }
303
304 #[test]
305 fn test_section_non_object_value_is_none() {
306 let content = r#"{"require": "not-an-object"}"#;
307 let ast = JsonAst::parse(content).unwrap();
308 assert!(ast.section("require").is_none());
309 }
310
311 #[test]
312 fn test_parse_invalid_json_is_none() {
313 assert!(JsonAst::parse("{ not valid").is_none());
314 }
315
316 #[test]
317 fn test_parse_non_object_root_is_none() {
318 assert!(JsonAst::parse("[1, 2, 3]").is_none());
319 }
320
321 #[test]
322 fn test_position_trims_quotes_and_finds_version() {
323 let content = r#"{"require": {"vendor/pkg": "^1.0"}}"#;
324 let table = LineOffsetTable::new(content);
325 let ast = JsonAst::parse(content).unwrap();
326 let section = ast.section("require").unwrap();
327
328 let (name_range, version_range) = section.position("vendor/pkg", content, &table).unwrap();
329 let name_start = content.find("vendor/pkg").unwrap();
330 assert_eq!(name_range.start.character, name_start as u32);
331 assert_eq!(
332 name_range.end.character,
333 (name_start + "vendor/pkg".len()) as u32
334 );
335
336 let version_range = version_range.expect("string value must produce a version_range");
337 let version_start = content.find("^1.0").unwrap();
338 assert_eq!(version_range.start.character, version_start as u32);
339 }
340
341 #[test]
342 fn test_position_non_string_value_has_no_version_range() {
343 let content = r#"{"require": {"vendor/pkg": {"nested": true}}}"#;
344 let table = LineOffsetTable::new(content);
345 let ast = JsonAst::parse(content).unwrap();
346 let section = ast.section("require").unwrap();
347
348 let (_, version_range) = section.position("vendor/pkg", content, &table).unwrap();
349 assert!(version_range.is_none());
350 }
351
352 #[test]
353 fn test_position_missing_name_is_none() {
354 let content = r#"{"require": {"vendor/pkg": "^1.0"}}"#;
355 let table = LineOffsetTable::new(content);
356 let ast = JsonAst::parse(content).unwrap();
357 let section = ast.section("require").unwrap();
358
359 assert!(
360 section
361 .position("does-not-exist", content, &table)
362 .is_none()
363 );
364 }
365}