tinymist_query/docs/
package.rs

1use core::fmt::Write;
2use std::collections::{HashMap, HashSet};
3use std::ops::Range;
4use std::path::{Path, PathBuf};
5
6use ecow::{EcoString, EcoVec};
7use indexmap::IndexSet;
8use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
9use serde::{Deserialize, Serialize};
10use tinymist_analysis::docs::tidy::remove_list_annotations;
11use tinymist_std::path::unix_slash;
12use tinymist_world::package::PackageSpec;
13use typst::diag::{StrResult, eco_format};
14use typst::syntax::package::PackageManifest;
15use typst::syntax::{FileId, Source, Span, SyntaxNode, VirtualRoot};
16use typst_shim::syntax::{RootedPathExt, source_range};
17
18use crate::LocalContext;
19use crate::docs::{DefDocs, PackageDefInfo, SourceQuery, file_id_repr, module_docs};
20use crate::index::{ScipPublicApi, ScipPublicModule};
21use crate::package::{PackageInfo, get_manifest_id, package_entrypoint_id};
22use crate::prelude::Definition;
23use crate::syntax::DefKind;
24
25/// Documentation Information about a package.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct PackageDoc {
28    meta: PackageMeta,
29    packages: Vec<PackageMeta>,
30    files: Vec<FileMeta>,
31    modules: Vec<(EcoString, crate::docs::DefInfo, ModuleInfo)>,
32}
33
34/// Documentation Information about a package module.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36struct ModuleInfo {
37    prefix: EcoString,
38    name: EcoString,
39    parent_ident: EcoString,
40    aka: EcoVec<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    path: Option<PathBuf>,
43    #[serde(skip)]
44    source: Option<EcoString>,
45}
46
47/// A generated Typst source file for bundle-mode package docs.
48#[derive(Debug, Clone)]
49pub struct PackageDocTypFile {
50    /// The path of the generated file, relative to the bundle source root.
51    pub path: PathBuf,
52    /// The Typst source content.
53    pub content: String,
54}
55
56struct BundleModulePath {
57    module_source: PathBuf,
58    module_source_import: String,
59    module_common_import: String,
60    module_output: String,
61    module_func: String,
62    symbol_paths: Vec<BundleSymbolPath>,
63    source_source: Option<PathBuf>,
64    source_source_import: Option<String>,
65    source_common_import: Option<String>,
66    source_output: Option<String>,
67    source_func: String,
68    source_path: Option<String>,
69    source_text: Option<EcoString>,
70}
71
72struct BundleSymbolPath {
73    section: &'static str,
74    symbol_index: usize,
75    source: PathBuf,
76    source_import: String,
77    common_import: String,
78    output: String,
79    func: String,
80}
81
82#[derive(Debug, Clone, Copy)]
83enum BundleSection {
84    Constants,
85    Functions,
86}
87
88impl BundleSection {
89    const ALL: [Self; 2] = [Self::Constants, Self::Functions];
90
91    fn id(self) -> &'static str {
92        match self {
93            Self::Constants => "constants",
94            Self::Functions => "functions",
95        }
96    }
97
98    fn accepts(self, child: &crate::docs::DefInfo) -> bool {
99        match self {
100            Self::Constants => {
101                matches!(child.kind, DefKind::Constant | DefKind::Variable)
102                    && !child.name.as_str().starts_with('_')
103            }
104            Self::Functions => {
105                matches!(child.kind, DefKind::Function) && !child.name.as_str().starts_with('_')
106            }
107        }
108    }
109}
110
111#[derive(Default)]
112struct PackageDocSpanIndex {
113    by_file: HashMap<FileId, SourceSpanIndex>,
114}
115
116struct SourceSpanIndex {
117    source: Source,
118    ranges: HashMap<Span, Range<usize>>,
119}
120
121impl PackageDocSpanIndex {
122    fn preload(ctx: &LocalContext, fids: &[FileId]) -> Self {
123        let shared = ctx.shared().clone();
124        let by_file = fids
125            .par_iter()
126            .filter_map(|fid| {
127                let source = shared.source_by_id(*fid).ok()?;
128                Some((*fid, SourceSpanIndex::new(source)))
129            })
130            .collect();
131        Self { by_file }
132    }
133
134    fn source(&mut self, ctx: &LocalContext, fid: FileId) -> Option<Source> {
135        Some(self.entry(ctx, fid)?.source.clone())
136    }
137
138    fn source_range(&mut self, ctx: &LocalContext, span: Span) -> Option<(Source, Range<usize>)> {
139        let fid = span.id()?;
140        let entry = self.entry(ctx, fid)?;
141        let range = entry
142            .ranges
143            .get(&span)
144            .cloned()
145            .or_else(|| source_range(&entry.source, span))?;
146        Some((entry.source.clone(), range))
147    }
148
149    fn entry(&mut self, ctx: &LocalContext, fid: FileId) -> Option<&SourceSpanIndex> {
150        if let std::collections::hash_map::Entry::Vacant(entry) = self.by_file.entry(fid) {
151            let source = ctx.source_by_id(fid).ok()?;
152            entry.insert(SourceSpanIndex::new(source));
153        }
154
155        self.by_file.get(&fid)
156    }
157}
158
159impl SourceSpanIndex {
160    fn new(source: Source) -> Self {
161        let mut ranges = HashMap::new();
162        collect_source_spans(source.root(), 0, &mut ranges);
163        Self { source, ranges }
164    }
165}
166
167fn collect_source_spans(
168    node: &SyntaxNode,
169    offset: usize,
170    ranges: &mut HashMap<Span, Range<usize>>,
171) {
172    let span = node.span();
173    if span.id().is_some() {
174        ranges.entry(span).or_insert(offset..offset + node.len());
175    }
176
177    let mut child_offset = offset;
178    for child in node.children() {
179        collect_source_spans(child, child_offset, ranges);
180        child_offset += child.len();
181    }
182}
183
184/// Generate full documents in markdown format
185pub fn package_docs(ctx: &mut LocalContext, spec: &PackageInfo) -> StrResult<PackageDoc> {
186    log::info!("generate_md_docs {spec:?}");
187
188    let toml_id = get_manifest_id(spec)?;
189    let manifest = ctx.get_manifest(toml_id)?;
190
191    let for_spec = toml_id
192        .package_compat()
193        .expect("package manifest must be in a package");
194    let entry_point = package_entrypoint_id(toml_id, &manifest.package.entrypoint);
195
196    let depended: Vec<_> = ctx.depended_source_files().into_iter().collect();
197    ctx.preload_expr_stages(depended.iter().copied());
198    let mut span_index = PackageDocSpanIndex::preload(ctx, &depended);
199
200    let PackageDefInfo { root, module_uses } = module_docs(ctx, entry_point)?;
201
202    crate::log_debug_ct!("module_uses: {module_uses:#?}");
203
204    let manifest = ctx.get_manifest(toml_id)?;
205
206    let meta = PackageMeta {
207        namespace: spec.namespace.clone(),
208        name: spec.name.clone(),
209        version: spec.version.to_string(),
210        manifest: Some(manifest),
211    };
212
213    let mut modules_to_generate = vec![(root.name.clone(), root)];
214    let mut generated_modules = HashSet::new();
215    let mut file_ids: IndexSet<FileId> = IndexSet::new();
216
217    // let aka = module_uses[&file_id_repr(fid.unwrap())].clone();
218    // let primary = &aka[0];
219    let mut primary_aka_cache = HashMap::<FileId, EcoVec<String>>::new();
220    let mut akas = |fid: FileId| {
221        primary_aka_cache
222            .entry(fid)
223            .or_insert_with(|| {
224                module_uses
225                    .get(&file_id_repr(fid))
226                    .unwrap_or_else(|| panic!("no module uses for {}", file_id_repr(fid)))
227                    .clone()
228            })
229            .clone()
230    };
231
232    let mut modules = vec![];
233
234    while !modules_to_generate.is_empty() {
235        for (parent_ident, mut def) in std::mem::take(&mut modules_to_generate) {
236            // parent_ident, symbols
237
238            set_scip_symbol(ctx, &mut span_index, &mut def);
239            let module_val = def.decl.as_ref().unwrap();
240            let fid = module_val.file_id();
241            let aka = fid.map(&mut akas).unwrap_or_default();
242
243            // It is (primary) known to safe as a part of HTML string, so we don't have to
244            // do sanitization here.
245            let primary = aka.first().cloned().unwrap_or_default();
246
247            if let Some(fid) = fid {
248                file_ids.insert_full(fid);
249            }
250
251            let module_info = ModuleInfo {
252                prefix: primary.as_str().into(),
253                name: def.name.clone(),
254                parent_ident: parent_ident.clone(),
255                aka,
256                path: fid.map(|fid| fid.vpath().get_without_slash().to_owned().into()),
257                source: fid
258                    .and_then(|fid| span_index.source(ctx, fid).map(|src| src.text().into())),
259            };
260
261            for child in def.children.iter_mut() {
262                set_scip_symbol(ctx, &mut span_index, child);
263                let span = child.decl.as_ref().map(|decl| decl.span());
264                let fid_range = span.and_then(|v| {
265                    let fid = v.id()?;
266                    let allocated = file_ids.insert_full(fid).0;
267                    let (src, rng) = span_index.source_range(ctx, v)?;
268                    let start = ctx.to_lsp_range(rng.clone(), &src).start;
269                    child.source = Some(SourceQuery {
270                        file: allocated,
271                        position: start,
272                    });
273                    Some((allocated, rng.start, rng.end))
274                });
275                let child_fid = child.decl.as_ref().and_then(|decl| decl.file_id());
276                let child_fid = child_fid.or_else(|| span.and_then(Span::id)).or(fid);
277                let span = fid_range.or_else(|| {
278                    let fid = child_fid?;
279                    Some((file_ids.insert_full(fid).0, 0, 0))
280                });
281                child.loc = span;
282
283                if child.parsed_docs.is_some() {
284                    child.docs = None;
285                }
286
287                let ident = if !primary.is_empty() {
288                    eco_format!("symbol-{}-{primary}.{}", child.kind, child.name)
289                } else {
290                    eco_format!("symbol-{}-{}", child.kind, child.name)
291                };
292
293                if child.is_external
294                    && let Some(fid) = child_fid
295                {
296                    let lnk = if matches!(fid.root(), VirtualRoot::Package(spec) if spec == for_spec)
297                    {
298                        let sub_aka = akas(fid);
299                        let sub_primary = sub_aka.first().cloned().unwrap_or_default();
300                        child.external_link = Some(format!(
301                            "#symbol-{}-{sub_primary}.{}",
302                            child.kind, child.name
303                        ));
304                        if matches!(child.kind, DefKind::Module) {
305                            module_heading_anchor(&sub_primary)
306                        } else {
307                            format!("#{}-{}-in-{sub_primary}", child.kind, child.name)
308                                .replace(".", "")
309                        }
310                    } else if let VirtualRoot::Package(spec) = fid.root() {
311                        let lnk = format!(
312                            "https://typst.app/universe/package/{}/{}",
313                            spec.name, spec.version
314                        );
315                        child.external_link = Some(lnk.clone());
316                        lnk
317                    } else {
318                        let lnk: String = "https://typst.app/docs".into();
319                        child.external_link = Some(lnk.clone());
320                        lnk
321                    };
322                    child.symbol_link = Some(lnk);
323                }
324
325                let child_children = std::mem::take(&mut child.children);
326                if !child_children.is_empty() {
327                    crate::log_debug_ct!("sub_fid: {child_fid:?}");
328                    let lnk = match child_fid {
329                        Some(fid) => {
330                            let aka = akas(fid);
331                            let primary = aka.first().cloned().unwrap_or_default();
332
333                            if generated_modules.insert(fid) {
334                                let mut child = child.clone();
335                                child.children = child_children;
336                                modules_to_generate.push((ident.clone(), child));
337                            }
338
339                            module_heading_anchor(&primary)
340                        }
341                        None => "builtin".to_owned(),
342                    };
343
344                    child.module_link = Some(lnk);
345                }
346
347                child.id = ident;
348            }
349
350            modules.push((parent_ident, def, module_info));
351        }
352    }
353
354    let mut bundle_links = HashMap::new();
355    for (idx, (parent_ident, _, info)) in modules.iter().enumerate() {
356        let path = module_output_path(idx, info);
357        bundle_links.insert(parent_ident.clone(), path.clone());
358        for aka in &info.aka {
359            bundle_links.insert(eco_format!("symbol-module-{aka}"), path.clone());
360        }
361    }
362    for (idx, (_, def, info)) in modules.iter_mut().enumerate() {
363        apply_bundle_links(def, &bundle_links);
364        apply_symbol_bundle_links(idx, def, info);
365    }
366
367    let mut packages = IndexSet::new();
368
369    let files = file_ids
370        .into_iter()
371        .map(|fid| {
372            let pkg = fid
373                .package_compat()
374                .map(|spec| packages.insert_full(spec.clone()).0);
375
376            FileMeta {
377                package: pkg,
378                path: fid.vpath().get_without_slash().to_owned().into(),
379                uri: ctx.uri_for_id(fid).ok().map(|uri| uri.to_string()),
380            }
381        })
382        .collect();
383
384    let packages = packages
385        .into_iter()
386        .map(|spec| PackageMeta {
387            namespace: spec.namespace.clone(),
388            name: spec.name.clone(),
389            version: spec.version.to_string(),
390            manifest: None,
391        })
392        .collect();
393
394    let doc = PackageDoc {
395        meta,
396        packages,
397        files,
398        modules,
399    };
400
401    Ok(doc)
402}
403
404impl PackageDoc {
405    /// Gets the public API overlay for SCIP encoding.
406    pub fn scip_public_api(&self) -> ScipPublicApi {
407        ScipPublicApi {
408            modules: self
409                .modules
410                .iter()
411                .map(|(_, def, info)| ScipPublicModule {
412                    file_path: info.path.as_ref().map(|path| unix_slash(path)),
413                    module_symbol: def.symbol.clone(),
414                    public_symbols: def
415                        .children
416                        .iter()
417                        .filter(|child| is_public_api_symbol(child))
418                        .filter_map(|child| child.symbol.clone())
419                        .collect(),
420                })
421                .collect(),
422        }
423    }
424}
425
426fn is_public_api_symbol(def: &crate::docs::DefInfo) -> bool {
427    !def.name.as_ref().starts_with('_')
428}
429
430fn set_scip_symbol(
431    ctx: &LocalContext,
432    span_index: &mut PackageDocSpanIndex,
433    def: &mut crate::docs::DefInfo,
434) {
435    let Some(decl) = def.decl.as_ref() else {
436        return;
437    };
438    let definition = Definition::new(decl.clone(), None);
439    let disambiguator = span_index
440        .source_range(ctx, decl.span())
441        .map(|(source, range)| {
442            let start = ctx.to_lsp_range(range, &source).start;
443            format!("L{}_C{}", start.line, start.character)
444        })
445        .unwrap_or_else(|| format!("{:x}", decl.span().into_raw()));
446    def.symbol =
447        crate::index::scip::scip_symbol_with_disambiguator(&definition, disambiguator).ok();
448}
449
450/// Generate full documents in markdown format
451pub fn package_docs_typ(doc: &PackageDoc) -> StrResult<String> {
452    let mut out = String::new();
453
454    let _ = writeln!(out, "{}", include_str!("package-doc.typ"));
455
456    let pi = &doc.meta;
457    let _ = writeln!(
458        out,
459        "#package-doc(bytes(read(\"{}-{}-{}.json\")), scip: read(\"{}-{}-{}.scip\", encoding: none))",
460        pi.namespace, pi.name, pi.version, pi.namespace, pi.name, pi.version,
461    );
462
463    Ok(out)
464}
465
466/// Generate Typst source files for bundle-mode package docs.
467pub fn package_docs_bundle_typ(doc: &PackageDoc) -> StrResult<Vec<PackageDocTypFile>> {
468    let pi = &doc.meta;
469    let base = format!("{}-{}-{}", pi.namespace, pi.name, pi.version);
470
471    let module_paths = doc
472        .modules
473        .iter()
474        .enumerate()
475        .map(|(idx, (_, def, info))| {
476            let module_source = module_source_path(info);
477            let source_source = info.source.as_ref().map(|_| module_source_page_path(info));
478            let symbol_paths = BundleSection::ALL
479                .into_iter()
480                .flat_map(|section| {
481                    def.children
482                        .iter()
483                        .filter(move |child| section.accepts(child))
484                        .enumerate()
485                        .map(move |(symbol_index, child)| {
486                            let source = module_symbol_source_path(
487                                idx,
488                                info,
489                                section.id(),
490                                child.name.as_str(),
491                            );
492                            BundleSymbolPath {
493                                section: section.id(),
494                                symbol_index,
495                                source_import: unix_slash(&source),
496                                common_import: relative_import_to_common(&source),
497                                output: module_symbol_output_path(
498                                    idx,
499                                    info,
500                                    section.id(),
501                                    child.name.as_str(),
502                                ),
503                                func: format!(
504                                    "render-module-{idx}-{}-{symbol_index}",
505                                    section.id()
506                                ),
507                                source,
508                            }
509                        })
510                })
511                .collect();
512            BundleModulePath {
513                module_source_import: unix_slash(&module_source),
514                module_common_import: relative_import_to_common(&module_source),
515                module_output: module_output_path(idx, info),
516                module_func: format!("render-module-{idx}"),
517                symbol_paths,
518                source_source_import: source_source.as_ref().map(|path| unix_slash(path)),
519                source_common_import: source_source
520                    .as_ref()
521                    .map(|path| relative_import_to_common(path)),
522                source_output: info
523                    .source
524                    .as_ref()
525                    .map(|_| module_source_output_path(info)),
526                source_func: format!("render-source-{idx}"),
527                source_path: info.path.as_ref().map(|path| unix_slash(path)),
528                source_text: info.source.clone(),
529                source_source,
530                module_source,
531            }
532        })
533        .collect::<Vec<_>>();
534
535    let mut files = vec![];
536    files.push(PackageDocTypFile {
537        path: PathBuf::from("common.typ"),
538        content: include_str!("package-doc.typ").to_owned(),
539    });
540
541    let mut entry = String::new();
542    let _ = writeln!(
543        entry,
544        "#import \"/typ/packages/tinymist-index/lib.typ\": create_index"
545    );
546    for path in &module_paths {
547        let _ = writeln!(
548            entry,
549            "#import {}: {}",
550            typst_string(&path.module_source_import),
551            path.module_func
552        );
553        for symbol in &path.symbol_paths {
554            let _ = writeln!(
555                entry,
556                "#import {}: {}",
557                typst_string(&symbol.source_import),
558                symbol.func
559            );
560        }
561        if let Some(source_import) = &path.source_source_import {
562            let _ = writeln!(
563                entry,
564                "#import {}: {}",
565                typst_string(source_import),
566                path.source_func
567            );
568        }
569    }
570    let _ = writeln!(
571        entry,
572        "#let package-info = json(bytes(read(\"../{base}.json\")))"
573    );
574    let _ = writeln!(
575        entry,
576        "#let package-index = create_index(read(\"../{base}.scip\", encoding: none))"
577    );
578    for path in &module_paths {
579        let _ = writeln!(entry, "#{}(package-info, package-index)", path.module_func);
580        for symbol in &path.symbol_paths {
581            let _ = writeln!(entry, "#{}(package-info, package-index)", symbol.func);
582        }
583    }
584    for path in &module_paths {
585        if path.source_text.is_some() {
586            let _ = writeln!(entry, "#{}(package-info, package-index)", path.source_func);
587        }
588    }
589    files.push(PackageDocTypFile {
590        path: PathBuf::from("index.typ"),
591        content: entry,
592    });
593
594    for (idx, path) in module_paths.into_iter().enumerate() {
595        let mut content = String::new();
596        let _ = writeln!(
597            content,
598            "#import {}: package-module-document",
599            typst_string(&path.module_common_import)
600        );
601        let _ = writeln!(
602            content,
603            "#let {func}(package-info, package-index) = package-module-document(package-info, package-index, module-index: {idx}, path: {})",
604            typst_string(&path.module_output),
605            func = path.module_func,
606        );
607        files.push(PackageDocTypFile {
608            path: path.module_source,
609            content,
610        });
611
612        for symbol in path.symbol_paths {
613            let mut content = String::new();
614            let _ = writeln!(
615                content,
616                "#import {}: package-module-symbol-document",
617                typst_string(&symbol.common_import)
618            );
619            let _ = writeln!(
620                content,
621                "#let {func}(package-info, package-index) = package-module-symbol-document(package-info, package-index, module-index: {idx}, section: {}, symbol-index: {}, path: {})",
622                typst_string(symbol.section),
623                symbol.symbol_index,
624                typst_string(&symbol.output),
625                func = symbol.func,
626            );
627            files.push(PackageDocTypFile {
628                path: symbol.source,
629                content,
630            });
631        }
632
633        if let (
634            Some(source_source),
635            Some(source_common_import),
636            Some(source_output),
637            Some(source_path),
638            Some(source_text),
639        ) = (
640            path.source_source,
641            path.source_common_import,
642            path.source_output,
643            path.source_path,
644            path.source_text,
645        ) {
646            let mut content = String::new();
647            let _ = writeln!(
648                content,
649                "#import {}: package-source-document",
650                typst_string(&source_common_import)
651            );
652            let _ = writeln!(
653                content,
654                "#let {func}(package-info, package-index) = package-source-document(package-info, package-index, module-index: {idx}, path: {}, source-path: {}, source: {})",
655                typst_string(&source_output),
656                typst_string(&source_path),
657                typst_string(source_text.as_str()),
658                func = path.source_func,
659            );
660            files.push(PackageDocTypFile {
661                path: source_source,
662                content,
663            });
664        }
665    }
666
667    Ok(files)
668}
669
670fn module_source_path(info: &ModuleInfo) -> PathBuf {
671    PathBuf::from("modules").join(module_package_path(info))
672}
673
674fn module_source_page_path(info: &ModuleInfo) -> PathBuf {
675    PathBuf::from("sources").join(module_package_path(info))
676}
677
678fn module_symbol_source_path(
679    idx: usize,
680    info: &ModuleInfo,
681    section: &str,
682    symbol: &str,
683) -> PathBuf {
684    let mut path = PathBuf::from("symbols");
685    path.push(module_symbol_path(idx, info, section, symbol, "typ"));
686    path
687}
688
689fn module_package_path(info: &ModuleInfo) -> PathBuf {
690    info.path
691        .clone()
692        .unwrap_or_else(|| PathBuf::from(module_fallback_file_name(info)))
693}
694
695fn module_fallback_file_name(info: &ModuleInfo) -> String {
696    let raw = if !info.parent_ident.is_empty() {
697        info.parent_ident.as_str()
698    } else if !info.prefix.is_empty() {
699        info.prefix.as_str()
700    } else {
701        info.name.as_str()
702    };
703    let mut stem = String::new();
704    let mut prev_dash = false;
705    for ch in raw.chars().flat_map(char::to_lowercase) {
706        if ch.is_ascii_alphanumeric() {
707            stem.push(ch);
708            prev_dash = false;
709        } else if !prev_dash {
710            stem.push('-');
711            prev_dash = true;
712        }
713    }
714
715    let stem = stem.trim_matches('-');
716    if stem.is_empty() {
717        "module.typ".to_owned()
718    } else {
719        format!("{stem}.typ")
720    }
721}
722
723fn module_output_path(idx: usize, info: &ModuleInfo) -> String {
724    if idx == 0 {
725        "index.html".to_owned()
726    } else {
727        let mut output = module_package_path(info);
728        output.set_extension("html");
729        unix_slash(&output)
730    }
731}
732
733fn module_source_output_path(info: &ModuleInfo) -> String {
734    format!("{}.html", unix_slash(&module_package_path(info)))
735}
736
737fn module_symbol_output_path(idx: usize, info: &ModuleInfo, section: &str, symbol: &str) -> String {
738    unix_slash(&module_symbol_path(idx, info, section, symbol, "html"))
739}
740
741fn module_symbol_path(
742    idx: usize,
743    info: &ModuleInfo,
744    section: &str,
745    symbol: &str,
746    extension: &str,
747) -> PathBuf {
748    let file_name = format!("{}.{}", symbol_file_stem(symbol), extension);
749    if idx == 0 {
750        return PathBuf::from(section).join(file_name);
751    }
752
753    let path = module_package_path(info);
754    let stem = path
755        .file_stem()
756        .map(|stem| stem.to_owned())
757        .unwrap_or_else(|| info.name.as_str().into());
758    let mut output = path.parent().map(Path::to_owned).unwrap_or_default();
759    output.push(stem);
760    output.push(section);
761    output.push(file_name);
762    output
763}
764
765fn symbol_file_stem(raw: &str) -> String {
766    let mut stem = String::new();
767    let mut prev_dash = false;
768    for ch in raw.chars() {
769        if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
770            stem.push(ch);
771            prev_dash = false;
772        } else if !prev_dash {
773            stem.push('-');
774            prev_dash = true;
775        }
776    }
777
778    let stem = stem.trim_matches('-');
779    if stem.is_empty() {
780        "symbol".to_owned()
781    } else {
782        stem.to_owned()
783    }
784}
785
786fn relative_import_to_common(source: &Path) -> String {
787    let mut path = PathBuf::new();
788    let depth = source
789        .parent()
790        .map(|parent| parent.components().count())
791        .unwrap_or(0);
792    for _ in 0..depth {
793        path.push("..");
794    }
795    path.push("common.typ");
796    unix_slash(&path)
797}
798
799fn typst_string(value: &str) -> String {
800    serde_json::to_string(value).expect("Typst string serialization must succeed")
801}
802
803fn module_heading_anchor(primary: &str) -> String {
804    let mut anchor = String::from("#");
805    let mut prev_dash = false;
806
807    for ch in format!("Module: {primary}").chars() {
808        if ch.is_whitespace() || matches!(ch, ':' | '-') {
809            if !prev_dash {
810                anchor.push('-');
811                prev_dash = true;
812            }
813        } else if matches!(ch, '.' | '(' | ')') {
814            continue;
815        } else {
816            if ch == 'M' {
817                anchor.push('m');
818            } else {
819                anchor.push(ch);
820            }
821            prev_dash = false;
822        }
823    }
824
825    anchor
826}
827
828fn apply_bundle_links(def: &mut crate::docs::DefInfo, links: &HashMap<EcoString, String>) {
829    for child in &mut def.children {
830        if matches!(child.kind, DefKind::Module)
831            && let Some(link) = links.get(&child.id)
832        {
833            child.bundle_link = Some(link.clone());
834        }
835    }
836}
837
838fn apply_symbol_bundle_links(idx: usize, def: &mut crate::docs::DefInfo, info: &ModuleInfo) {
839    for child in &mut def.children {
840        for section in BundleSection::ALL {
841            if section.accepts(child) {
842                child.bundle_link = Some(module_symbol_output_path(
843                    idx,
844                    info,
845                    section.id(),
846                    child.name.as_str(),
847                ));
848                break;
849            }
850        }
851    }
852}
853
854/// Generate full documents in markdown format
855pub fn package_docs_md(doc: &PackageDoc) -> StrResult<String> {
856    let mut out = String::new();
857
858    let title = doc.meta.spec().to_string();
859
860    writeln!(out, "# {title}").unwrap();
861    out.push('\n');
862    writeln!(out, "This documentation is generated locally. Please submit issues to [tinymist](https://github.com/Myriad-Dreamin/tinymist/issues) if you see **incorrect** information in it.").unwrap();
863    out.push('\n');
864    out.push('\n');
865
866    let package_meta = jbase64(&doc.meta);
867    let _ = writeln!(out, "<!-- begin:package {package_meta} -->");
868
869    let mut errors = vec![];
870    for (parent_ident, def, module_info) in &doc.modules {
871        // parent_ident, symbols
872        let primary = &module_info.prefix;
873        if !module_info.prefix.is_empty() {
874            let _ = writeln!(out, "---\n## Module: {primary}");
875        }
876
877        crate::log_debug_ct!("module: {primary} -- {parent_ident}");
878        let module_info = jbase64(&module_info);
879        let _ = writeln!(out, "<!-- begin:module {primary} {module_info} -->");
880
881        for child in &def.children {
882            let convert_err = None::<EcoString>;
883
884            let ident = if !primary.is_empty() {
885                eco_format!("symbol-{}-{primary}.{}", child.kind, child.name)
886            } else {
887                eco_format!("symbol-{}-{}", child.kind, child.name)
888            };
889            let _ = writeln!(out, "### {}: {} in {primary}", child.kind, child.name);
890
891            if let Some(lnk) = &child.symbol_link {
892                let _ = writeln!(out, "[Symbol Docs]({lnk})\n");
893            }
894
895            let head = jbase64(&child);
896            let _ = writeln!(out, "<!-- begin:symbol {ident} {head} -->");
897
898            if let Some(DefDocs::Function(sig)) = &child.parsed_docs {
899                let _ = writeln!(out, "<!-- begin:sig -->");
900                let _ = writeln!(out, "```typc");
901                let _ = write!(out, "let {}", child.name);
902                let _ = sig.print(&mut out);
903                let _ = writeln!(out, ";");
904                let _ = writeln!(out, "```");
905                let _ = writeln!(out, "<!-- end:sig -->");
906            }
907
908            let mut printed_docs = false;
909            match (&child.parsed_docs, convert_err) {
910                (_, Some(err)) => {
911                    let err = format!("failed to convert docs in {title}: {err}").replace(
912                        "-->", "—>", // avoid markdown comment
913                    );
914                    let _ = writeln!(out, "<!-- convert-error: {err} -->");
915                    errors.push(err);
916                }
917                (Some(docs), _) if !child.is_external => {
918                    let _ = writeln!(out, "{}", remove_list_annotations(docs.docs()));
919                    printed_docs = true;
920                    if let DefDocs::Function(docs) = docs {
921                        for param in docs
922                            .pos
923                            .iter()
924                            .chain(docs.named.values())
925                            .chain(docs.rest.as_ref())
926                        {
927                            let _ = writeln!(out, "<!-- begin:param {} -->", param.name);
928                            let ty = match &param.cano_type {
929                                Some((short, _, _)) => short,
930                                None => "unknown",
931                            };
932                            let _ = writeln!(
933                                out,
934                                "#### {} ({ty:?})\n<!-- begin:param-doc {} -->\n{}\n<!-- end:param-doc {} -->",
935                                param.name, param.name, param.docs, param.name
936                            );
937                            let _ = writeln!(out, "<!-- end:param -->");
938                        }
939                    }
940                }
941                (_, None) => {}
942            }
943
944            if !printed_docs {
945                let plain_docs = child.docs.as_deref();
946                let plain_docs = plain_docs.or(child.oneliner.as_deref());
947
948                if let Some(docs) = plain_docs {
949                    let contains_code = docs.contains("```");
950                    if contains_code {
951                        let _ = writeln!(out, "`````typ");
952                    }
953                    let _ = writeln!(out, "{docs}");
954                    if contains_code {
955                        let _ = writeln!(out, "`````");
956                    }
957                }
958            }
959
960            if let Some(lnk) = &child.module_link {
961                match lnk.as_str() {
962                    "builtin" => {
963                        let _ = writeln!(out, "A Builtin Module");
964                    }
965                    lnk => {
966                        let _ = writeln!(out, "[Module Docs]({lnk})\n");
967                    }
968                }
969            }
970
971            let _ = writeln!(out, "<!-- end:symbol {ident} -->");
972        }
973
974        let _ = writeln!(out, "<!-- end:module {primary} -->");
975    }
976
977    let res = ConvertResult { errors };
978    let err = jbase64(&res);
979    let _ = writeln!(out, "<!-- begin:errors {err} -->");
980    let _ = writeln!(out, "## Errors");
981    for errs in res.errors {
982        let _ = writeln!(out, "- {errs}");
983    }
984    let _ = writeln!(out, "<!-- end:errors -->");
985
986    let meta = PackageMetaEnd {
987        packages: doc.packages.clone(),
988        files: doc.files.clone(),
989    };
990    let package_meta = jbase64(&meta);
991    let _ = writeln!(out, "<!-- end:package {package_meta} -->");
992
993    Ok(out)
994}
995
996fn jbase64<T: Serialize>(s: &T) -> String {
997    use base64::Engine;
998    let content = serde_json::to_string(s).unwrap();
999    base64::engine::general_purpose::STANDARD.encode(content)
1000}
1001
1002/// Information about a package.
1003#[derive(Debug, Clone, Serialize, Deserialize)]
1004pub struct PackageMeta {
1005    /// The namespace the package lives in.
1006    pub namespace: EcoString,
1007    /// The name of the package within its namespace.
1008    pub name: EcoString,
1009    /// The package's version.
1010    pub version: String,
1011    /// The package's manifest information.
1012    pub manifest: Option<PackageManifest>,
1013}
1014
1015impl PackageMeta {
1016    /// Returns the package's full name, including namespace and version.
1017    pub fn spec(&self) -> PackageSpec {
1018        PackageSpec {
1019            namespace: self.namespace.clone(),
1020            name: self.name.clone(),
1021            version: self.version.parse().expect("Invalid version format"),
1022        }
1023    }
1024}
1025
1026/// Information about a package.
1027#[derive(Debug, Serialize, Deserialize)]
1028pub struct PackageMetaEnd {
1029    packages: Vec<PackageMeta>,
1030    files: Vec<FileMeta>,
1031}
1032
1033/// Information about a package.
1034#[derive(Debug, Clone, Serialize, Deserialize)]
1035pub struct FileMeta {
1036    package: Option<usize>,
1037    path: PathBuf,
1038    #[serde(skip_serializing_if = "Option::is_none")]
1039    uri: Option<String>,
1040}
1041
1042#[derive(Serialize, Deserialize)]
1043struct ConvertResult {
1044    errors: Vec<String>,
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049    use std::sync::{Arc, Mutex};
1050
1051    use tinymist_world::package::{PackageRegistry, PackageSpec, registry::PREVIEW_NS};
1052
1053    use super::{
1054        PackageInfo, package_docs, package_docs_bundle_typ, package_docs_md, package_docs_typ,
1055    };
1056    use crate::analysis::Analysis;
1057    use crate::tests::*;
1058
1059    fn test(pkg: PackageSpec) {
1060        run_with_sources("", |verse: &mut LspUniverse, path| {
1061            let pkg_root = verse.registry.resolve(&pkg).unwrap();
1062            let pi = PackageInfo {
1063                path: pkg_root.as_ref().to_owned(),
1064                namespace: pkg.namespace,
1065                name: pkg.name,
1066                version: pkg.version.to_string(),
1067            };
1068            let public_api = Arc::new(Mutex::new(None));
1069            let public_api_docs = public_api.clone();
1070            run_with_ctx(verse, path, &|a, _p| {
1071                let docs = package_docs(a, &pi).unwrap();
1072                *public_api_docs.lock().unwrap() = Some(docs.scip_public_api());
1073                let dest = format!(
1074                    "../../target/{}-{}-{}.json",
1075                    pi.namespace, pi.name, pi.version
1076                );
1077                std::fs::write(dest, serde_json::to_string_pretty(&docs).unwrap()).unwrap();
1078                let typ = package_docs_typ(&docs).unwrap();
1079                let dest = format!(
1080                    "../../target/{}-{}-{}.typ",
1081                    pi.namespace, pi.name, pi.version
1082                );
1083                std::fs::write(dest, typ).unwrap();
1084                let md = package_docs_md(&docs).unwrap();
1085                let dest = format!(
1086                    "../../target/{}-{}-{}.md",
1087                    pi.namespace, pi.name, pi.version
1088                );
1089                std::fs::write(dest, md).unwrap();
1090                let bundle = package_docs_bundle_typ(&docs).unwrap();
1091                let dest = std::path::PathBuf::from(format!(
1092                    "../../target/{}-{}-{}.bundle",
1093                    pi.namespace, pi.name, pi.version
1094                ));
1095                let _ = std::fs::remove_dir_all(&dest);
1096                for file in bundle {
1097                    let path = dest.join(file.path);
1098                    if let Some(parent) = path.parent() {
1099                        std::fs::create_dir_all(parent).unwrap();
1100                    }
1101                    std::fs::write(path, file.content).unwrap();
1102                }
1103            });
1104            let analysis = Arc::new(Analysis::default());
1105            let public_api_index = public_api.clone();
1106            let scip = analysis
1107                .query_snapshot(verse.computation(), None)
1108                .run_within_package(&pi, |a| {
1109                    let knowledge = crate::index::knowledge(a).unwrap();
1110                    let public_api = public_api_index.lock().unwrap();
1111                    let public_api = public_api
1112                        .as_ref()
1113                        .expect("package docs must build public API before SCIP");
1114                    knowledge
1115                        .bind(a.shared())
1116                        .to_scip_bytes_with_public_api(public_api)
1117                })
1118                .unwrap();
1119            let dest = format!(
1120                "../../target/{}-{}-{}.scip",
1121                pi.namespace, pi.name, pi.version
1122            );
1123            std::fs::write(dest, scip).unwrap();
1124        })
1125    }
1126
1127    #[test]
1128    fn tidy() {
1129        test(PackageSpec {
1130            namespace: PREVIEW_NS.into(),
1131            name: "tidy".into(),
1132            version: "0.3.0".parse().unwrap(),
1133        });
1134    }
1135
1136    #[test]
1137    fn touying() {
1138        test(PackageSpec {
1139            namespace: PREVIEW_NS.into(),
1140            name: "touying".into(),
1141            version: "0.6.0".parse().unwrap(),
1142        });
1143    }
1144
1145    #[test]
1146    fn fletcher() {
1147        test(PackageSpec {
1148            namespace: PREVIEW_NS.into(),
1149            name: "fletcher".into(),
1150            version: "0.5.8".parse().unwrap(),
1151        });
1152    }
1153
1154    #[test]
1155    fn cetz() {
1156        test(PackageSpec {
1157            namespace: PREVIEW_NS.into(),
1158            name: "cetz".into(),
1159            version: "0.2.2".parse().unwrap(),
1160        });
1161    }
1162}