Skip to main content

deps_deno/
parser.rs

1//! `deno.json` / `deno.jsonc` parser using `jsonc-parser`'s AST (D6).
2//!
3//! Plain JSON is a strict subset of JSONC, so both `deno.json` and `deno.jsonc` share this
4//! single code path. The AST (rather than `deps-npm`'s `serde_json` + text-search pattern)
5//! is required because Deno import maps routinely point several aliases at the identical
6//! specifier value — a find-first-occurrence text search would hand every such alias the
7//! same source position, corrupting hover/inlay-hint/code-action targeting.
8
9use crate::specifier::parse_specifier;
10use crate::types::{DenoDependency, DenoDependencySection};
11use deps_core::lsp_helpers::LineOffsetTable;
12use deps_core::{DepsError, PackageName, Result, VersionReq};
13use jsonc_parser::ast::{Object, ObjectProp, StringLit, Value};
14use jsonc_parser::{CollectOptions, ParseOptions, parse_to_ast};
15use std::borrow::Cow;
16use std::collections::HashSet;
17use tower_lsp_server::ls_types::{Range, Uri};
18
19/// Result of parsing a `deno.json`/`deno.jsonc` file.
20#[derive(Debug)]
21pub struct DenoParseResult {
22    /// All dependencies found in the `imports` map.
23    pub dependencies: Vec<DenoDependency>,
24    /// Document URI.
25    pub uri: Uri,
26}
27
28deps_core::impl_parse_result!(
29    DenoParseResult,
30    DenoDependency {
31        dependencies: dependencies,
32        uri: uri,
33    }
34);
35
36/// Parses a `deno.json`/`deno.jsonc` file and extracts all `imports` entries with
37/// positions.
38///
39/// # Errors
40///
41/// Returns an error if the content is not valid JSON/JSONC (malformed syntax, unclosed
42/// block comment, stray brace) — the file is then not recognized as a Deno manifest and
43/// degrades gracefully (spec §6), matching every other ecosystem's parse-failure behavior.
44///
45/// # Examples
46///
47/// ```no_run
48/// use deps_deno::parser::parse_deno_json;
49/// use tower_lsp_server::ls_types::Uri;
50///
51/// let json = r#"{
52///   "imports": {
53///     "@std/fs": "jsr:@std/fs@^1.0"
54///   }
55/// }"#;
56/// let uri = Uri::from_file_path("/project/deno.json").unwrap();
57///
58/// let result = parse_deno_json(json, &uri).unwrap();
59/// assert_eq!(result.dependencies.len(), 1);
60/// assert_eq!(result.dependencies[0].name, "jsr:@std/fs");
61/// ```
62pub fn parse_deno_json(content: &str, uri: &Uri) -> Result<DenoParseResult> {
63    let ast = parse_to_ast(
64        content,
65        &CollectOptions::default(),
66        &ParseOptions::default(),
67    )
68    .map_err(|e| DepsError::ParseError {
69        file_type: "deno.json".into(),
70        source: e.to_string().into(),
71    })?;
72
73    let line_table = LineOffsetTable::new(content);
74    let mut dependencies = Vec::new();
75
76    if let Some(Value::Object(root)) = ast.value
77        && let Some(imports_prop) = find_last_prop(&root, "imports")
78        && let Value::Object(imports) = &imports_prop.value
79    {
80        collect_imports(imports, content, &line_table, &mut dependencies);
81    }
82
83    Ok(DenoParseResult {
84        dependencies,
85        uri: uri.clone(),
86    })
87}
88
89/// Finds the property named `key` in `object`, taking the *last* one if `key` occurs more
90/// than once — JSON's last-key-wins semantics (spec §6), which `jsonc-parser`'s
91/// `Vec<ObjectProp>` does not enforce on its own (unlike `serde_json::Map`, which dedupes
92/// during parsing).
93fn find_last_prop<'a, 'b>(object: &'a Object<'b>, key: &str) -> Option<&'a ObjectProp<'b>> {
94    object
95        .properties
96        .iter()
97        .rev()
98        .find(|prop| prop.name.as_str() == key)
99}
100
101/// Builds a [`DenoDependency`] for every entry in the `imports` object, applying
102/// last-alias-wins deduplication (S6) so a manifest with two entries for the same import
103/// alias produces exactly one dependency — positioned at the *last* occurrence, matching
104/// where its value actually lives once JSON parsing collapses the duplicate.
105fn collect_imports(
106    imports: &Object,
107    content: &str,
108    line_table: &LineOffsetTable,
109    out: &mut Vec<DenoDependency>,
110) {
111    let mut seen_aliases = HashSet::new();
112    let mut collected = Vec::new();
113
114    // Walk in reverse (last occurrence first) so the first alias we see for a given key
115    // is the one that wins; earlier (source-order) duplicates are then skipped.
116    for prop in imports.properties.iter().rev() {
117        let alias = prop.name.as_str();
118        if !seen_aliases.insert(alias.to_string()) {
119            continue;
120        }
121        let Value::StringLit(value_lit) = &prop.value else {
122            continue;
123        };
124        if let Some(dep) = build_dependency(value_lit, content, line_table) {
125            collected.push(dep);
126        }
127    }
128
129    // Restore source order for the surviving (deduplicated) entries.
130    collected.reverse();
131    out.extend(collected);
132}
133
134/// Builds a [`DenoDependency`] from one `imports` value's string literal, or `None` if the
135/// value is neither a recognized `jsr:`/`npm:` specifier nor a syntactically incomplete,
136/// still-being-typed one (D7 — e.g. `http://`, `file:`, a bare alias — silently skipped,
137/// same as an unparseable entry in any other ecosystem).
138fn build_dependency(
139    value_lit: &StringLit,
140    content: &str,
141    line_table: &LineOffsetTable,
142) -> Option<DenoDependency> {
143    let raw_value = value_lit.value.as_ref();
144
145    // S5 escape guard: `value_lit.range` covers the whole literal *including* its
146    // surrounding quotes. Byte-arithmetic from that range into offsets relative to the
147    // *unescaped* value is only sound when no unescaping happened — exactly when
148    // `value_lit.value` is `Cow::Borrowed` (a direct slice of the source). Deno specifiers
149    // never legitimately need escapes, so this is a correctness fail-safe: an escaped
150    // value only degrades to the inner-span fallback (no `version_range`) when it fully
151    // parses; a partial/in-progress escaped value (#310) is skipped entirely rather than
152    // guessing at an unsound byte range for it.
153    if let Cow::Owned(_) = &value_lit.value {
154        let parsed = parse_specifier(raw_value)?;
155        tracing::debug!(
156            "deno.json: import value contains escape sequences, skipping precise position tracking"
157        );
158        let inner_start = value_lit.range.start + 1;
159        let inner_end = value_lit.range.end.saturating_sub(1);
160        return Some(DenoDependency {
161            name: PackageName::new(parsed.name),
162            name_range: byte_range_to_lsp(content, line_table, inner_start, inner_end),
163            version_req: parsed.version_req.map(VersionReq::new),
164            version_range: None,
165            section: DenoDependencySection::Imports,
166        });
167    }
168
169    // The literal's inner (unquoted) text starts right after its opening quote.
170    let value_start = value_lit.range.start + 1;
171
172    if let Some(parsed) = parse_specifier(raw_value) {
173        let name_range = byte_range_to_lsp(
174            content,
175            line_table,
176            value_start + parsed.name_range.start,
177            value_start + parsed.name_range.end,
178        );
179        let version_range = parsed.version_range.map(|r| {
180            byte_range_to_lsp(
181                content,
182                line_table,
183                value_start + r.start,
184                value_start + r.end,
185            )
186        });
187
188        return Some(DenoDependency {
189            name: PackageName::new(parsed.name),
190            name_range,
191            version_req: parsed.version_req.map(VersionReq::new),
192            version_range,
193            section: DenoDependencySection::Imports,
194        });
195    }
196
197    // #310: a syntactically incomplete but still in-progress jsr:/npm: specifier ("jsr:",
198    // "jsr:@", "jsr:@std", "jsr:@std/", ...) — `parse_specifier` correctly rejects these
199    // (not yet a valid, routable name), but a `Dependency` must still exist here for
200    // `detect_completion_context` to have a range to fire `PackageName` completion
201    // against. This mirrors `deps-npm`'s parser, which always builds a `Dependency` from a
202    // `dependencies` object key regardless of scope completeness (`deps-npm/src/parser.rs`)
203    // — validity is deferred to `DenoFormatter::validate_package_name`'s diagnostic, not
204    // gated at parse time.
205    let partial_range = crate::specifier::partial_name_range(raw_value)?;
206    let name_range = byte_range_to_lsp(
207        content,
208        line_table,
209        value_start + partial_range.start,
210        value_start + partial_range.end,
211    );
212
213    Some(DenoDependency {
214        name: PackageName::new(&raw_value[partial_range]),
215        name_range,
216        version_req: None,
217        version_range: None,
218        section: DenoDependencySection::Imports,
219    })
220}
221
222/// Converts a `[start, end)` byte range in `content` to an LSP `Range`.
223fn byte_range_to_lsp(content: &str, table: &LineOffsetTable, start: usize, end: usize) -> Range {
224    Range::new(
225        table.byte_offset_to_position(content, start),
226        table.byte_offset_to_position(content, end),
227    )
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    use std::assert_matches;
235
236    fn test_uri() -> Uri {
237        deps_core::test_util::test_uri("/test/deno.json")
238    }
239
240    #[test]
241    fn test_parse_simple_jsr_import() {
242        let json = r#"{
243  "imports": {
244    "@std/fs": "jsr:@std/fs@^1.0"
245  }
246}"#;
247
248        let result = parse_deno_json(json, &test_uri()).unwrap();
249        assert_eq!(result.dependencies.len(), 1);
250
251        let dep = &result.dependencies[0];
252        assert_eq!(dep.name, "jsr:@std/fs");
253        assert_eq!(dep.version_req, Some("^1.0".into()));
254        assert_matches!(dep.section, DenoDependencySection::Imports);
255    }
256
257    #[test]
258    fn test_parse_npm_import() {
259        let json = r#"{
260  "imports": {
261    "react": "npm:react@^18"
262  }
263}"#;
264
265        let result = parse_deno_json(json, &test_uri()).unwrap();
266        assert_eq!(result.dependencies.len(), 1);
267        assert_eq!(result.dependencies[0].name, "npm:react");
268        assert_eq!(result.dependencies[0].version_req, Some("^18".into()));
269    }
270
271    #[test]
272    fn test_parse_mixed_jsr_and_npm() {
273        let json = r#"{
274  "imports": {
275    "@std/fs": "jsr:@std/fs@^1.0",
276    "react": "npm:react@^18",
277    "preact": "npm:preact@^10"
278  }
279}"#;
280
281        let result = parse_deno_json(json, &test_uri()).unwrap();
282        assert_eq!(result.dependencies.len(), 3);
283        assert!(result.dependencies.iter().any(|d| d.name == "jsr:@std/fs"));
284        assert!(result.dependencies.iter().any(|d| d.name == "npm:react"));
285        assert!(result.dependencies.iter().any(|d| d.name == "npm:preact"));
286    }
287
288    #[test]
289    fn test_parse_empty_imports() {
290        let json = r#"{"imports": {}}"#;
291        let result = parse_deno_json(json, &test_uri()).unwrap();
292        assert!(result.dependencies.is_empty());
293    }
294
295    #[test]
296    fn test_parse_no_imports_key() {
297        let json = r#"{"name": "my-app"}"#;
298        let result = parse_deno_json(json, &test_uri()).unwrap();
299        assert!(result.dependencies.is_empty());
300    }
301
302    #[test]
303    fn test_parse_unsupported_specifier_silently_skipped() {
304        let json = r#"{
305  "imports": {
306    "@std/fs": "jsr:@std/fs@^1.0",
307    "legacy": "https://deno.land/x/legacy/mod.ts",
308    "local": "./local.ts"
309  }
310}"#;
311
312        let result = parse_deno_json(json, &test_uri()).unwrap();
313        assert_eq!(result.dependencies.len(), 1);
314        assert_eq!(result.dependencies[0].name, "jsr:@std/fs");
315    }
316
317    #[test]
318    fn test_parse_invalid_json_errors() {
319        let json = "{ imports: not valid json !!!";
320        let result = parse_deno_json(json, &test_uri());
321        assert_matches!(
322            result,
323            Err(DepsError::ParseError { file_type, .. }) if file_type == "deno.json"
324        );
325    }
326
327    #[test]
328    fn test_parse_jsonc_comments_tolerated() {
329        let jsonc = r#"{
330  // line comment
331  "imports": {
332    /* block comment */
333    "@std/fs": "jsr:@std/fs@^1.0"
334  }
335}"#;
336
337        let result = parse_deno_json(jsonc, &test_uri()).unwrap();
338        assert_eq!(result.dependencies.len(), 1);
339        assert_eq!(result.dependencies[0].name, "jsr:@std/fs");
340    }
341
342    #[test]
343    fn test_duplicate_alias_keys_last_wins() {
344        // S6: jsonc-parser's `Vec<ObjectProp>` does not dedupe like serde_json::Map does.
345        let json = r#"{
346  "imports": {
347    "@std/fs": "jsr:@std/fs@^1.0",
348    "@std/fs": "jsr:@std/fs@^2.0"
349  }
350}"#;
351
352        let result = parse_deno_json(json, &test_uri()).unwrap();
353        assert_eq!(result.dependencies.len(), 1);
354        assert_eq!(result.dependencies[0].version_req, Some("^2.0".into()));
355        // The surviving dependency's position must be the *last* occurrence's line.
356        assert_eq!(result.dependencies[0].name_range.start.line, 3);
357    }
358
359    #[test]
360    fn test_two_aliases_same_specifier_get_distinct_positions() {
361        // The core reason D6 uses the AST rather than deps-npm's text-search pattern:
362        // two different aliases mapping to the identical specifier value must not collapse
363        // onto the same source position.
364        let json = r#"{
365  "imports": {
366    "@std/fs": "jsr:@std/fs@^1.0",
367    "@std/fs/": "jsr:@std/fs@^1.0"
368  }
369}"#;
370
371        let result = parse_deno_json(json, &test_uri()).unwrap();
372        assert_eq!(result.dependencies.len(), 2);
373        let lines: Vec<u32> = result
374            .dependencies
375            .iter()
376            .map(|d| d.name_range.start.line)
377            .collect();
378        assert_ne!(lines[0], lines[1]);
379    }
380
381    #[test]
382    fn test_empty_version_after_at_produces_a_version_range() {
383        // S7, exercised through the full parser: the completion path relies on
384        // `version_range` being `Some` (an empty span) right after the user types '@'.
385        let json = r#"{"imports": {"@std/fs": "jsr:@std/fs@"}}"#;
386        let result = parse_deno_json(json, &test_uri()).unwrap();
387        assert_eq!(result.dependencies.len(), 1);
388        let dep = &result.dependencies[0];
389        assert_eq!(dep.version_req, Some(String::new().into()));
390        let range = dep.version_range.expect("version_range must be Some");
391        assert_eq!(range.start, range.end);
392    }
393
394    #[test]
395    fn test_scoped_npm_import() {
396        let json = r#"{"imports": {"node-types": "npm:@types/node@^20"}}"#;
397        let result = parse_deno_json(json, &test_uri()).unwrap();
398        assert_eq!(result.dependencies.len(), 1);
399        assert_eq!(result.dependencies[0].name, "npm:@types/node");
400        assert_eq!(result.dependencies[0].version_req, Some("^20".into()));
401    }
402
403    #[test]
404    fn test_escaped_specifier_falls_back_to_inner_span_with_no_version_range() {
405        // S5 escape-guard fail-safe: a JSON `\uXXXX` escape inside the value (unescaping
406        // to the character '1' here) means `value_lit.value` is `Cow::Owned`, so
407        // byte-offset arithmetic into the raw *source* text is unsound for anything
408        // narrower than the whole literal. `name`/`version_req` still come from the
409        // already-unescaped value (jsonc-parser did that work); only position tracking
410        // degrades to the inner-span fallback with no `version_range`.
411        let json_escape = "\\u0031"; // a JSON \uXXXX escape unescaping to the character '1'
412        let raw_value = format!("jsr:@std/fs@{json_escape}.0"); // raw source text, escape included
413        let json = format!(r#"{{"imports": {{"@std/fs": "{raw_value}"}}}}"#);
414        let result = parse_deno_json(&json, &test_uri()).unwrap();
415        assert_eq!(result.dependencies.len(), 1);
416
417        let dep = &result.dependencies[0];
418        assert_eq!(dep.name, "jsr:@std/fs");
419        assert_eq!(dep.version_req, Some("1.0".into()));
420        assert!(dep.version_range.is_none());
421
422        // name_range widens to the raw literal's inner span (excluding quotes) — the
423        // whole escaped value text, not just the name portion, and never the whole
424        // literal *including* quotes (which would let a later edit delete them).
425        let value_start_byte = json.find(&format!("\"{raw_value}\"")).unwrap() + 1;
426        let value_end_byte = value_start_byte + raw_value.len();
427
428        assert_eq!(dep.name_range.start.line, 0);
429        assert_eq!(dep.name_range.start.character, value_start_byte as u32);
430        assert_eq!(dep.name_range.end.character, value_end_byte as u32);
431    }
432
433    // --- #310: partial jsr:/npm: specifiers still produce a completion-eligible Dependency ---
434
435    #[test]
436    fn test_partial_jsr_bare_scheme_produces_dependency_with_no_version() {
437        let json = r#"{"imports": {"@std/fs": "jsr:"}}"#;
438        let result = parse_deno_json(json, &test_uri()).unwrap();
439        assert_eq!(result.dependencies.len(), 1);
440        let dep = &result.dependencies[0];
441        assert_eq!(dep.name, "jsr:");
442        assert_eq!(dep.version_req, None);
443        assert_eq!(dep.version_range, None);
444    }
445
446    #[test]
447    fn test_partial_jsr_scope_started_produces_dependency() {
448        let json = r#"{"imports": {"@std/fs": "jsr:@"}}"#;
449        let result = parse_deno_json(json, &test_uri()).unwrap();
450        assert_eq!(result.dependencies.len(), 1);
451        assert_eq!(result.dependencies[0].name, "jsr:@");
452    }
453
454    #[test]
455    fn test_partial_jsr_scope_in_progress_produces_dependency() {
456        let json = r#"{"imports": {"@std/fs": "jsr:@std"}}"#;
457        let result = parse_deno_json(json, &test_uri()).unwrap();
458        assert_eq!(result.dependencies.len(), 1);
459        assert_eq!(result.dependencies[0].name, "jsr:@std");
460    }
461
462    #[test]
463    fn test_partial_jsr_trailing_slash_produces_dependency() {
464        let json = r#"{"imports": {"@std/fs": "jsr:@std/"}}"#;
465        let result = parse_deno_json(json, &test_uri()).unwrap();
466        assert_eq!(result.dependencies.len(), 1);
467        assert_eq!(result.dependencies[0].name, "jsr:@std/");
468    }
469
470    #[test]
471    fn test_partial_specifier_name_range_covers_the_typed_text() {
472        // The completion path relies on `name_range` spanning exactly the typed text (no
473        // quotes) so `detect_completion_context` can extract the right prefix.
474        let json = r#"{"imports": {"@std/fs": "jsr:@std/"}}"#;
475        let result = parse_deno_json(json, &test_uri()).unwrap();
476        let dep = &result.dependencies[0];
477        let value_start = json.find(r#""jsr:@std/""#).unwrap() + 1;
478        assert_eq!(dep.name_range.start.character, value_start as u32);
479        assert_eq!(
480            dep.name_range.end.character,
481            (value_start + "jsr:@std/".len()) as u32
482        );
483    }
484
485    #[test]
486    fn test_permanently_malformed_empty_scope_still_skipped() {
487        // "jsr:@/pkg" is not a partial state (no scope prefix to complete against) --
488        // must remain silently skipped, same as before #310.
489        let json = r#"{"imports": {"@std/fs": "jsr:@/pkg"}}"#;
490        let result = parse_deno_json(json, &test_uri()).unwrap();
491        assert!(result.dependencies.is_empty());
492    }
493
494    #[test]
495    fn test_unsupported_scheme_still_skipped_not_treated_as_partial() {
496        let json = r#"{"imports": {"legacy": "https://deno.land/x/legacy/mod.ts"}}"#;
497        let result = parse_deno_json(json, &test_uri()).unwrap();
498        assert!(result.dependencies.is_empty());
499    }
500
501    #[test]
502    fn test_partial_specifier_end_to_end_completion_context_fires() {
503        use deps_core::completion::{CompletionContext, detect_completion_context};
504
505        for value in ["jsr:", "jsr:@", "jsr:@std", "jsr:@std/"] {
506            let content = format!(r#"{{"imports": {{"@std/fs": "{value}"}}}}"#);
507            let result = parse_deno_json(&content, &test_uri()).unwrap();
508            assert_eq!(result.dependencies.len(), 1, "value: {value}");
509
510            let name_range = result.dependencies[0].name_range;
511            let cursor = name_range.end; // cursor right after the last typed character
512            let context = detect_completion_context(&result, cursor, &content);
513
514            match context {
515                CompletionContext::PackageName { prefix, .. } => {
516                    assert_eq!(prefix, value, "value: {value}");
517                }
518                other => panic!("value {value}: expected PackageName context, got {other:?}"),
519            }
520        }
521    }
522
523    #[test]
524    fn test_unclosed_block_comment_is_a_parse_error() {
525        // deps-lsp testing gap: the only prior invalid-JSONC test used generic garbage
526        // text, not the JSONC-specific failure mode the parser's own docs name.
527        let jsonc = r#"{
528  /* unclosed comment
529  "imports": {
530    "@std/fs": "jsr:@std/fs@^1.0"
531  }
532}"#;
533        let result = parse_deno_json(jsonc, &test_uri());
534        assert!(result.is_err());
535    }
536}