Skip to main content

deno_doc/
lib.rs

1// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
2
3#![recursion_limit = "256"]
4#![deny(clippy::disallowed_methods)]
5#![deny(clippy::disallowed_types)]
6#![deny(clippy::print_stderr)]
7#![deny(clippy::print_stdout)]
8
9#[macro_use]
10extern crate cfg_if;
11#[macro_use]
12extern crate lazy_static;
13#[cfg(test)]
14#[macro_use]
15extern crate serde_json;
16
17pub mod class;
18mod decorators;
19mod diagnostics;
20pub mod diff;
21mod display;
22pub mod r#enum;
23pub mod function;
24pub mod html;
25pub mod interface;
26pub mod js_doc;
27pub mod node;
28mod params;
29mod parser;
30pub mod ts_type;
31pub mod ts_type_param;
32pub mod type_alias;
33mod util;
34pub mod variable;
35mod visibility;
36
37pub use node::Declaration;
38pub use node::DeclarationDef;
39pub use node::Document;
40pub use node::Location;
41pub use node::Symbol;
42
43use params::ParamDef;
44
45cfg_if! {
46  if #[cfg(feature = "rust")] {
47    mod printer;
48    pub use diagnostics::DocDiagnostic;
49    pub use diagnostics::DocDiagnosticKind;
50    pub use printer::DocPrinter;
51  }
52}
53
54pub use parser::DocError;
55pub use parser::DocParser;
56pub use parser::DocParserOptions;
57pub use parser::ParseOutput;
58
59#[cfg(test)]
60mod tests;
61
62#[cfg(feature = "rust")]
63pub fn find_nodes_by_name_recursively(
64  symbols: Vec<std::sync::Arc<Symbol>>,
65  name: &str,
66) -> Vec<Symbol> {
67  let mut parts = name.splitn(2, '.');
68  let name = parts.next();
69  let leftover = parts.next();
70  if name.is_none() {
71    return symbols
72      .into_iter()
73      .map(std::sync::Arc::unwrap_or_clone)
74      .collect();
75  }
76
77  let name = name.unwrap();
78  let symbol = symbols.into_iter().find(|symbol| &*symbol.name == name);
79
80  let mut found: Vec<Symbol> = vec![];
81
82  if let Some(symbol) = symbol {
83    let symbol = std::sync::Arc::unwrap_or_clone(symbol);
84    match leftover {
85      Some(leftover) => {
86        let children = get_children_of_node(symbol);
87        found.extend(find_nodes_by_name_recursively(children, leftover));
88      }
89      None => found.push(symbol),
90    }
91  }
92
93  found
94}
95
96#[cfg(feature = "rust")]
97fn get_children_of_node(node: Symbol) -> Vec<std::sync::Arc<Symbol>> {
98  use node::DeclarationDef;
99
100  let mut doc_nodes: Vec<std::sync::Arc<Symbol>> = vec![];
101  for decl in node.declarations {
102    match decl.def {
103      DeclarationDef::Namespace(namespace_def) => {
104        doc_nodes.extend(namespace_def.elements);
105      }
106      DeclarationDef::Interface(interface_def) => {
107        for method in interface_def.methods {
108          doc_nodes.push(std::sync::Arc::new(method.into()));
109        }
110        for property in interface_def.properties {
111          doc_nodes.push(std::sync::Arc::new(property.into()));
112        }
113      }
114      DeclarationDef::Class(class_def) => {
115        for method in class_def.methods.into_vec().into_iter() {
116          doc_nodes.push(std::sync::Arc::new(method.into()));
117        }
118        for property in class_def.properties.into_vec().into_iter() {
119          doc_nodes.push(std::sync::Arc::new(property.into()));
120        }
121      }
122      _ => {}
123    }
124  }
125  doc_nodes
126}
127
128pub fn docnodes_v1_to_v2(value: serde_json::Value) -> Document {
129  let serde_json::Value::Array(arr) = value else {
130    return Document::default();
131  };
132
133  // v1 format: flat array where each entry has "name", "kind", "location",
134  // "declarationKind", "jsDoc", and def fields all at the top level.
135  // v2 format: Document { module_doc, imports, symbols }
136  // where symbols are Symbol { name, isDefault, declarations: [...] }.
137  let mut module_doc = js_doc::JsDoc::default();
138  let mut imports = Vec::new();
139  let mut symbols: indexmap::IndexMap<Box<str>, Symbol> =
140    indexmap::IndexMap::new();
141
142  for item in arr {
143    let serde_json::Value::Object(mut obj) = item else {
144      continue;
145    };
146
147    let kind = obj
148      .get("kind")
149      .and_then(|v| v.as_str())
150      .map(|s| s.to_string());
151
152    // v1 moduleDoc nodes become Document.module_doc
153    if kind.as_deref() == Some("moduleDoc") {
154      if let Some(mut js_doc_val) = obj.remove("jsDoc") {
155        migrate_js_doc_tags(&mut js_doc_val);
156        if let Ok(js_doc) = serde_json::from_value::<js_doc::JsDoc>(js_doc_val)
157        {
158          module_doc = js_doc;
159        }
160      }
161      continue;
162    }
163
164    // v1 import nodes become Document.imports
165    if kind.as_deref() == Some("import") {
166      let imported_name: Box<str> = obj
167        .remove("name")
168        .and_then(|v| v.as_str().map(|s| s.into()))
169        .unwrap_or_else(|| "".into());
170      let import_def = obj.remove("importDef").unwrap_or_default();
171      let src = import_def
172        .get("src")
173        .and_then(|v| v.as_str())
174        .unwrap_or("")
175        .to_string();
176      let original_name = import_def
177        .get("imported")
178        .and_then(|v| v.as_str())
179        .map(|s| s.to_string());
180      let js_doc = obj
181        .remove("jsDoc")
182        .and_then(|mut v| {
183          migrate_js_doc_tags(&mut v);
184          serde_json::from_value::<js_doc::JsDoc>(v).ok()
185        })
186        .unwrap_or_default();
187      imports.push(node::Import {
188        imported_name,
189        original_name,
190        src,
191        js_doc,
192      });
193      continue;
194    }
195
196    let name: Box<str> = obj
197      .remove("name")
198      .and_then(|v| v.as_str().map(|s| s.into()))
199      .unwrap_or_else(|| "".into());
200
201    let is_default = obj
202      .remove("isDefault")
203      .and_then(|v| v.as_bool())
204      .unwrap_or(false);
205
206    let mut declaration = serde_json::Value::Object(obj);
207    migrate_declaration(&mut declaration);
208
209    let symbol = symbols.entry(name.clone()).or_insert_with(|| Symbol {
210      name,
211      is_default,
212      declarations: vec![],
213    });
214    // If any entry is marked default, the symbol is default
215    if is_default {
216      symbol.is_default = true;
217    }
218    if let Ok(decl) = serde_json::from_value::<Declaration>(declaration) {
219      symbol.declarations.push(decl);
220    }
221  }
222
223  Document {
224    module_doc,
225    imports,
226    symbols: symbols.into_values().map(std::sync::Arc::new).collect(),
227  }
228}
229
230/// The v1 def field names. In v1, these appear as top-level keys on a doc node
231/// (e.g. `"functionDef": {...}`). In v2, the content is moved to a `"def"` key
232/// and tagged via `"kind"`.
233const V1_DEF_FIELDS: &[&str] = &[
234  "functionDef",
235  "variableDef",
236  "enumDef",
237  "classDef",
238  "typeAliasDef",
239  "namespaceDef",
240  "interfaceDef",
241  "referenceDef",
242  "reference_def",
243];
244
245/// Migrate a single v1 doc node (JSON object) into a v2 Declaration-shaped
246/// object in-place.
247///
248/// This renames the kind-specific def field (e.g. `"functionDef"`) to `"def"`,
249/// converts namespace elements from flat v1 doc node arrays into v2
250/// `Symbol` arrays (grouped by name), and migrates TsTypeDef / JsDocTag
251/// formats.
252fn migrate_declaration(value: &mut serde_json::Value) {
253  let serde_json::Value::Object(obj) = value else {
254    return;
255  };
256
257  // v1 had kind-specific def fields (e.g. "functionDef", "variableDef")
258  // at the top level. v2 uses adjacently-tagged "def" for all kinds.
259  for field in V1_DEF_FIELDS {
260    if let Some(val) = obj.remove(*field) {
261      obj.insert("def".to_string(), val);
262      break;
263    }
264  }
265
266  // Namespace elements in v1 are flat doc node arrays (same shape as
267  // top-level nodes). In v2 they are `Vec<Symbol>` where each Symbol
268  // groups declarations by name — the same structure as the top-level
269  // Document.symbols. Convert them here.
270  if let Some(serde_json::Value::Object(def)) = obj.get_mut("def")
271    && let Some(serde_json::Value::Array(elements)) = def.remove("elements")
272  {
273    let symbols = v1_nodes_to_symbols(elements);
274    def.insert("elements".to_string(), symbols);
275  }
276
277  // v1 TsTypeDef used variant-name content keys (e.g. "keyword": "string"),
278  // v2 uses "value" as a uniform content key.
279  migrate_ts_type_defs(value);
280  // v1 JsDocTag used "type": "<string>" for type refs,
281  // v2 uses "tsType": { TsTypeDef object }.
282  migrate_js_doc_tags(value);
283}
284
285/// Convert a flat array of v1 doc nodes into a v2 symbols JSON array,
286/// grouping declarations by name (just like the top-level conversion).
287fn v1_nodes_to_symbols(nodes: Vec<serde_json::Value>) -> serde_json::Value {
288  let mut symbols: indexmap::IndexMap<String, serde_json::Value> =
289    indexmap::IndexMap::new();
290
291  for node in nodes {
292    let serde_json::Value::Object(mut obj) = node else {
293      continue;
294    };
295
296    // Skip import nodes inside namespaces
297    if obj.get("kind").and_then(|v| v.as_str()) == Some("import") {
298      continue;
299    }
300
301    let name = obj
302      .remove("name")
303      .and_then(|v| v.as_str().map(|s| s.to_string()))
304      .unwrap_or_default();
305
306    let is_default = obj
307      .remove("isDefault")
308      .and_then(|v| v.as_bool())
309      .unwrap_or(false);
310
311    // Migrate the node into a declaration-shaped object
312    let mut decl = serde_json::Value::Object(obj);
313    migrate_declaration(&mut decl);
314
315    let symbol = symbols.entry(name.clone()).or_insert_with(|| {
316      serde_json::json!({
317        "name": name,
318        "declarations": []
319      })
320    });
321
322    if is_default {
323      symbol["isDefault"] = serde_json::Value::Bool(true);
324    }
325
326    symbol["declarations"].as_array_mut().unwrap().push(decl);
327  }
328
329  serde_json::Value::Array(symbols.into_values().collect())
330}
331
332/// v1 TsTypeDef content key overrides: in v1, some variants used a content
333/// key name that differs from their kind name.
334const V1_TS_TYPE_CONTENT_KEY_OVERRIDES: &[(&str, &str)] =
335  &[("conditional", "conditionalType"), ("mapped", "mappedType")];
336
337/// Recursively walk JSON and convert v1 TsTypeDef objects (which use
338/// variant-name content keys like `"keyword": "string"`) to v2 format
339/// (which uses `"value"` as the content key).
340fn migrate_ts_type_defs(value: &mut serde_json::Value) {
341  match value {
342    serde_json::Value::Object(obj) => {
343      // If this looks like a v1 TsTypeDef (has "repr" and "kind"), rename
344      // the content key to "value".
345      if obj.contains_key("repr")
346        && let Some(kind_str) = obj
347          .get("kind")
348          .and_then(|k| k.as_str())
349          .map(|s| s.to_string())
350      {
351        // Unit variants (like "this") have a boolean content key in v1
352        // (e.g. "this": true) that should simply be removed — v2 expects
353        // no content for unit variants.
354        if kind_str == "this" || kind_str == "unsupported" {
355          obj.remove(&kind_str);
356        } else {
357          // Find the content key: either an override or the kind name itself
358          let content_key = V1_TS_TYPE_CONTENT_KEY_OVERRIDES
359            .iter()
360            .find(|(k, _)| *k == kind_str)
361            .map(|(_, v)| *v)
362            .unwrap_or(&kind_str);
363
364          if let Some(content) = obj.remove(content_key) {
365            obj.insert("value".to_string(), content);
366          }
367        }
368      }
369
370      for val in obj.values_mut() {
371        migrate_ts_type_defs(val);
372      }
373    }
374    serde_json::Value::Array(arr) => {
375      for val in arr.iter_mut() {
376        migrate_ts_type_defs(val);
377      }
378    }
379    _ => {}
380  }
381}
382
383/// Recursively walk JSON and convert v1 JsDocTag objects that had
384/// `"type": "<string>"` (the old `type_ref` field) to v2 format
385/// with `"tsType": { "repr": "<string>", "kind": "unsupported" }`.
386fn migrate_js_doc_tags(value: &mut serde_json::Value) {
387  match value {
388    serde_json::Value::Object(obj) => {
389      // Check if this looks like a v1 JsDocTag with a "type" field that
390      // should be migrated to "tsType". We distinguish from TsTypeDef
391      // objects (which also have "kind") by checking that "repr" is absent.
392      if !obj.contains_key("repr") {
393        let is_type_bearing_tag =
394          obj.get("kind").and_then(|k| k.as_str()).is_some_and(|s| {
395            matches!(
396              s,
397              "enum"
398                | "extends"
399                | "param"
400                | "property"
401                | "return"
402                | "this"
403                | "throws"
404                | "typedef"
405                | "type"
406            )
407          });
408
409        if is_type_bearing_tag && let Some(type_val) = obj.remove("type") {
410          match type_val {
411            serde_json::Value::String(s) => {
412              obj.insert(
413                "tsType".to_string(),
414                serde_json::json!({
415                  "repr": s,
416                  "kind": "unsupported"
417                }),
418              );
419            }
420            serde_json::Value::Null => {
421              // Optional type that was null - leave tsType absent
422            }
423            _ => {
424              // Non-string, non-null "type" values can't be migrated;
425              // drop them so deserialization doesn't fail on an
426              // unexpected "type" key.
427            }
428          }
429        }
430      }
431
432      for val in obj.values_mut() {
433        migrate_js_doc_tags(val);
434      }
435    }
436    serde_json::Value::Array(arr) => {
437      for val in arr.iter_mut() {
438        migrate_js_doc_tags(val);
439      }
440    }
441    _ => {}
442  }
443}
444
445#[cfg(test)]
446mod v1_to_v2_tests {
447  use super::*;
448  use serde_json::json;
449
450  #[test]
451  fn non_array_returns_default() {
452    let doc = docnodes_v1_to_v2(json!({}));
453    assert!(doc.symbols.is_empty());
454    assert!(doc.imports.is_empty());
455    assert!(doc.module_doc.is_empty());
456  }
457
458  #[test]
459  fn empty_array() {
460    let doc = docnodes_v1_to_v2(json!([]));
461    assert!(doc.symbols.is_empty());
462  }
463
464  #[test]
465  fn module_doc() {
466    let doc = docnodes_v1_to_v2(json!([
467      {
468        "kind": "moduleDoc",
469        "jsDoc": {
470          "doc": "Module documentation"
471        }
472      }
473    ]));
474    assert_eq!(doc.module_doc.doc.as_deref(), Some("Module documentation"));
475    assert!(doc.symbols.is_empty());
476  }
477
478  #[test]
479  fn import_node() {
480    let doc = docnodes_v1_to_v2(json!([
481      {
482        "kind": "import",
483        "name": "Foo",
484        "importDef": {
485          "src": "./foo.ts",
486          "imported": "Foo"
487        }
488      }
489    ]));
490    assert_eq!(doc.imports.len(), 1);
491    assert_eq!(&*doc.imports[0].imported_name, "Foo");
492    assert_eq!(doc.imports[0].src, "./foo.ts");
493    assert_eq!(doc.imports[0].original_name.as_deref(), Some("Foo"));
494  }
495
496  #[test]
497  fn variable_declaration() {
498    let doc = docnodes_v1_to_v2(json!([
499      {
500        "name": "myVar",
501        "kind": "variable",
502        "location": { "filename": "test.ts", "line": 1, "col": 0, "byteIndex": 0 },
503        "declarationKind": "export",
504        "variableDef": {
505          "kind": "const"
506        }
507      }
508    ]));
509    assert_eq!(doc.symbols.len(), 1);
510    assert_eq!(&*doc.symbols[0].name, "myVar");
511    assert_eq!(doc.symbols[0].declarations.len(), 1);
512    assert!(matches!(
513      doc.symbols[0].declarations[0].def,
514      node::DeclarationDef::Variable(_)
515    ));
516  }
517
518  #[test]
519  fn function_declaration() {
520    let doc = docnodes_v1_to_v2(json!([
521      {
522        "name": "myFunc",
523        "kind": "function",
524        "location": { "filename": "test.ts", "line": 1, "col": 0, "byteIndex": 0 },
525        "declarationKind": "export",
526        "functionDef": {
527          "params": [],
528          "returnType": {
529            "repr": "string",
530            "kind": "keyword",
531            "keyword": "string"
532          }
533        }
534      }
535    ]));
536    assert_eq!(doc.symbols.len(), 1);
537    assert_eq!(&*doc.symbols[0].name, "myFunc");
538    let decl = &doc.symbols[0].declarations[0];
539    match &decl.def {
540      node::DeclarationDef::Function(f) => {
541        let rt = f.return_type.as_ref().unwrap();
542        assert_eq!(rt.repr, "string");
543      }
544      other => panic!("expected Function, got {:?}", other),
545    }
546  }
547
548  #[test]
549  fn ts_type_def_migration() {
550    // v1 uses kind-named content key, v2 uses "value"
551    let doc = docnodes_v1_to_v2(json!([
552      {
553        "name": "x",
554        "kind": "variable",
555        "location": { "filename": "test.ts", "line": 1, "col": 0, "byteIndex": 0 },
556        "declarationKind": "export",
557        "variableDef": {
558          "kind": "const",
559          "tsType": {
560            "repr": "string",
561            "kind": "keyword",
562            "keyword": "string"
563          }
564        }
565      }
566    ]));
567    let decl = &doc.symbols[0].declarations[0];
568    match &decl.def {
569      node::DeclarationDef::Variable(v) => {
570        let ts_type = v.ts_type.as_ref().unwrap();
571        assert_eq!(ts_type.repr, "string");
572      }
573      other => panic!("expected Variable, got {:?}", other),
574    }
575  }
576
577  #[test]
578  fn is_default_flag() {
579    let doc = docnodes_v1_to_v2(json!([
580      {
581        "name": "default",
582        "isDefault": true,
583        "kind": "variable",
584        "location": { "filename": "test.ts", "line": 1, "col": 0, "byteIndex": 0 },
585        "declarationKind": "export",
586        "variableDef": { "kind": "const" }
587      }
588    ]));
589    assert!(doc.symbols[0].is_default);
590  }
591
592  #[test]
593  fn multiple_declarations_same_name() {
594    let doc = docnodes_v1_to_v2(json!([
595      {
596        "name": "myFunc",
597        "kind": "function",
598        "location": { "filename": "test.ts", "line": 1, "col": 0, "byteIndex": 0 },
599        "declarationKind": "export",
600        "functionDef": { "params": [] }
601      },
602      {
603        "name": "myFunc",
604        "kind": "function",
605        "location": { "filename": "test.ts", "line": 3, "col": 0, "byteIndex": 20 },
606        "declarationKind": "export",
607        "functionDef": { "params": [] }
608      }
609    ]));
610    assert_eq!(doc.symbols.len(), 1);
611    assert_eq!(doc.symbols[0].declarations.len(), 2);
612  }
613
614  #[test]
615  fn jsdoc_tag_type_to_ts_type_param() {
616    let doc = docnodes_v1_to_v2(json!([
617      {
618        "name": "myFunc",
619        "kind": "function",
620        "location": { "filename": "test.ts", "line": 1, "col": 0, "byteIndex": 0 },
621        "declarationKind": "export",
622        "jsDoc": {
623          "tags": [
624            { "kind": "param", "name": "x", "type": "string", "doc": "a param" }
625          ]
626        },
627        "functionDef": { "params": [] }
628      }
629    ]));
630    let decl = &doc.symbols[0].declarations[0];
631    let tag = &decl.js_doc.tags[0];
632    match tag {
633      js_doc::JsDocTag::Param { ts_type, name, .. } => {
634        assert_eq!(&**name, "x");
635        let ts = ts_type.as_ref().unwrap();
636        assert_eq!(ts.repr, "string");
637      }
638      other => panic!("expected Param, got {:?}", other),
639    }
640  }
641
642  #[test]
643  fn jsdoc_tag_type_to_ts_type_return() {
644    let doc = docnodes_v1_to_v2(json!([
645      {
646        "name": "myFunc",
647        "kind": "function",
648        "location": { "filename": "test.ts", "line": 1, "col": 0, "byteIndex": 0 },
649        "declarationKind": "export",
650        "jsDoc": {
651          "tags": [
652            { "kind": "return", "type": "number" }
653          ]
654        },
655        "functionDef": { "params": [] }
656      }
657    ]));
658    let tag = &doc.symbols[0].declarations[0].js_doc.tags[0];
659    match tag {
660      js_doc::JsDocTag::Return { ts_type, .. } => {
661        let ts = ts_type.as_ref().unwrap();
662        assert_eq!(ts.repr, "number");
663      }
664      other => panic!("expected Return, got {:?}", other),
665    }
666  }
667
668  #[test]
669  fn jsdoc_tag_type_to_ts_type_enum() {
670    let doc = docnodes_v1_to_v2(json!([
671      {
672        "name": "x",
673        "kind": "variable",
674        "location": { "filename": "test.ts", "line": 1, "col": 0, "byteIndex": 0 },
675        "declarationKind": "export",
676        "jsDoc": {
677          "tags": [
678            { "kind": "enum", "type": "number" }
679          ]
680        },
681        "variableDef": { "kind": "const" }
682      }
683    ]));
684    let tag = &doc.symbols[0].declarations[0].js_doc.tags[0];
685    match tag {
686      js_doc::JsDocTag::Enum { ts_type, .. } => {
687        assert_eq!(ts_type.repr, "number");
688      }
689      other => panic!("expected Enum, got {:?}", other),
690    }
691  }
692
693  #[test]
694  fn jsdoc_tag_type_to_ts_type_extends() {
695    let doc = docnodes_v1_to_v2(json!([
696      {
697        "name": "x",
698        "kind": "variable",
699        "location": { "filename": "test.ts", "line": 1, "col": 0, "byteIndex": 0 },
700        "declarationKind": "export",
701        "jsDoc": {
702          "tags": [
703            { "kind": "extends", "type": "Foo" }
704          ]
705        },
706        "variableDef": { "kind": "const" }
707      }
708    ]));
709    let tag = &doc.symbols[0].declarations[0].js_doc.tags[0];
710    match tag {
711      js_doc::JsDocTag::Extends { ts_type, .. } => {
712        assert_eq!(ts_type.repr, "Foo");
713      }
714      other => panic!("expected Extends, got {:?}", other),
715    }
716  }
717
718  #[test]
719  fn jsdoc_tag_type_to_ts_type_this() {
720    let doc = docnodes_v1_to_v2(json!([
721      {
722        "name": "x",
723        "kind": "variable",
724        "location": { "filename": "test.ts", "line": 1, "col": 0, "byteIndex": 0 },
725        "declarationKind": "export",
726        "jsDoc": {
727          "tags": [
728            { "kind": "this", "type": "Window" }
729          ]
730        },
731        "variableDef": { "kind": "const" }
732      }
733    ]));
734    let tag = &doc.symbols[0].declarations[0].js_doc.tags[0];
735    match tag {
736      js_doc::JsDocTag::This { ts_type, .. } => {
737        assert_eq!(ts_type.repr, "Window");
738      }
739      other => panic!("expected This, got {:?}", other),
740    }
741  }
742
743  #[test]
744  fn jsdoc_tag_type_to_ts_type_throws() {
745    let doc = docnodes_v1_to_v2(json!([
746      {
747        "name": "x",
748        "kind": "function",
749        "location": { "filename": "test.ts", "line": 1, "col": 0, "byteIndex": 0 },
750        "declarationKind": "export",
751        "jsDoc": {
752          "tags": [
753            { "kind": "throws", "type": "Error" }
754          ]
755        },
756        "functionDef": { "params": [] }
757      }
758    ]));
759    let tag = &doc.symbols[0].declarations[0].js_doc.tags[0];
760    match tag {
761      js_doc::JsDocTag::Throws { ts_type, .. } => {
762        let ts = ts_type.as_ref().unwrap();
763        assert_eq!(ts.repr, "Error");
764      }
765      other => panic!("expected Throws, got {:?}", other),
766    }
767  }
768
769  #[test]
770  fn jsdoc_tag_type_to_ts_type_typedef() {
771    let doc = docnodes_v1_to_v2(json!([
772      {
773        "name": "x",
774        "kind": "variable",
775        "location": { "filename": "test.ts", "line": 1, "col": 0, "byteIndex": 0 },
776        "declarationKind": "export",
777        "jsDoc": {
778          "tags": [
779            { "kind": "typedef", "name": "MyType", "type": "object" }
780          ]
781        },
782        "variableDef": { "kind": "const" }
783      }
784    ]));
785    let tag = &doc.symbols[0].declarations[0].js_doc.tags[0];
786    match tag {
787      js_doc::JsDocTag::TypeDef { ts_type, name, .. } => {
788        assert_eq!(&**name, "MyType");
789        assert_eq!(ts_type.repr, "object");
790      }
791      other => panic!("expected TypeDef, got {:?}", other),
792    }
793  }
794
795  #[test]
796  fn jsdoc_tag_type_to_ts_type_typeref() {
797    let doc = docnodes_v1_to_v2(json!([
798      {
799        "name": "x",
800        "kind": "variable",
801        "location": { "filename": "test.ts", "line": 1, "col": 0, "byteIndex": 0 },
802        "declarationKind": "export",
803        "jsDoc": {
804          "tags": [
805            { "kind": "type", "type": "Record<string, unknown>" }
806          ]
807        },
808        "variableDef": { "kind": "const" }
809      }
810    ]));
811    let tag = &doc.symbols[0].declarations[0].js_doc.tags[0];
812    match tag {
813      js_doc::JsDocTag::TypeRef { ts_type, .. } => {
814        assert_eq!(ts_type.repr, "Record<string, unknown>");
815      }
816      other => panic!("expected TypeRef, got {:?}", other),
817    }
818  }
819
820  #[test]
821  fn jsdoc_tag_type_to_ts_type_property() {
822    let doc = docnodes_v1_to_v2(json!([
823      {
824        "name": "x",
825        "kind": "variable",
826        "location": { "filename": "test.ts", "line": 1, "col": 0, "byteIndex": 0 },
827        "declarationKind": "export",
828        "jsDoc": {
829          "tags": [
830            { "kind": "property", "name": "foo", "type": "string" }
831          ]
832        },
833        "variableDef": { "kind": "const" }
834      }
835    ]));
836    let tag = &doc.symbols[0].declarations[0].js_doc.tags[0];
837    match tag {
838      js_doc::JsDocTag::Property { ts_type, name, .. } => {
839        assert_eq!(&**name, "foo");
840        assert_eq!(ts_type.repr, "string");
841      }
842      other => panic!("expected Property, got {:?}", other),
843    }
844  }
845
846  #[test]
847  fn jsdoc_tag_null_type_becomes_none() {
848    let doc = docnodes_v1_to_v2(json!([
849      {
850        "name": "myFunc",
851        "kind": "function",
852        "location": { "filename": "test.ts", "line": 1, "col": 0, "byteIndex": 0 },
853        "declarationKind": "export",
854        "jsDoc": {
855          "tags": [
856            { "kind": "return", "type": null }
857          ]
858        },
859        "functionDef": { "params": [] }
860      }
861    ]));
862    let tag = &doc.symbols[0].declarations[0].js_doc.tags[0];
863    match tag {
864      js_doc::JsDocTag::Return { ts_type, .. } => {
865        assert!(ts_type.is_none());
866      }
867      other => panic!("expected Return, got {:?}", other),
868    }
869  }
870
871  #[test]
872  fn module_doc_jsdoc_tags_migrated() {
873    let doc = docnodes_v1_to_v2(json!([
874      {
875        "kind": "moduleDoc",
876        "jsDoc": {
877          "doc": "Module docs",
878          "tags": [
879            { "kind": "type", "type": "module" }
880          ]
881        }
882      }
883    ]));
884    let tag = &doc.module_doc.tags[0];
885    match tag {
886      js_doc::JsDocTag::TypeRef { ts_type, .. } => {
887        assert_eq!(ts_type.repr, "module");
888      }
889      other => panic!("expected TypeRef, got {:?}", other),
890    }
891  }
892
893  #[test]
894  fn import_jsdoc_tags_migrated() {
895    let doc = docnodes_v1_to_v2(json!([
896      {
897        "kind": "import",
898        "name": "Foo",
899        "importDef": { "src": "./foo.ts" },
900        "jsDoc": {
901          "tags": [
902            { "kind": "type", "type": "Foo" }
903          ]
904        }
905      }
906    ]));
907    let tag = &doc.imports[0].js_doc.tags[0];
908    match tag {
909      js_doc::JsDocTag::TypeRef { ts_type, .. } => {
910        assert_eq!(ts_type.repr, "Foo");
911      }
912      other => panic!("expected TypeRef, got {:?}", other),
913    }
914  }
915
916  #[test]
917  fn migrate_ts_type_defs_nested() {
918    // Ensure nested TsTypeDef objects in v1 format are migrated
919    let doc = docnodes_v1_to_v2(json!([
920      {
921        "name": "myFunc",
922        "kind": "function",
923        "location": { "filename": "test.ts", "line": 1, "col": 0, "byteIndex": 0 },
924        "declarationKind": "export",
925        "functionDef": {
926          "params": [],
927          "returnType": {
928            "repr": "string[]",
929            "kind": "array",
930            "array": {
931              "repr": "string",
932              "kind": "keyword",
933              "keyword": "string"
934            }
935          }
936        }
937      }
938    ]));
939    let decl = &doc.symbols[0].declarations[0];
940    match &decl.def {
941      node::DeclarationDef::Function(f) => {
942        let rt = f.return_type.as_ref().unwrap();
943        assert_eq!(rt.repr, "string[]");
944      }
945      other => panic!("expected Function, got {:?}", other),
946    }
947  }
948
949  #[test]
950  fn zod_v1_fixture() {
951    let raw = std::fs::read_to_string(
952      std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
953        .join("tests/testdata/@zod_zod_4.3.6_raw.json"),
954    )
955    .unwrap();
956    let fixture: serde_json::Map<String, serde_json::Value> =
957      serde_json::from_str(&raw).unwrap();
958
959    for (url, v1_nodes) in fixture {
960      let doc = docnodes_v1_to_v2(v1_nodes);
961      assert!(
962        !doc.symbols.is_empty(),
963        "{url}: expected at least one symbol"
964      );
965      for symbol in &doc.symbols {
966        assert!(
967          !symbol.declarations.is_empty(),
968          "{url}: symbol '{}' has no declarations",
969          symbol.name
970        );
971      }
972      // Round-trip: serializing and deserializing should not lose data
973      let json = serde_json::to_value(&doc).unwrap();
974      let _: Document = serde_json::from_value(json).unwrap();
975    }
976  }
977}