Skip to main content

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 &section.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}