tinymist_query/analysis/
link_expr.rs

1//! Analyze link expressions in a source file.
2
3use std::borrow::Cow;
4use std::str::FromStr;
5
6use lsp_types::Url;
7use tinymist_world::package::PackageSpec;
8use tinymist_world::vfs::PathResolution;
9
10use super::prelude::*;
11
12/// Get link expressions from a source.
13#[typst_macros::time(span = src.root().span())]
14#[comemo::memoize]
15pub fn get_link_exprs(src: &Source) -> Arc<LinkInfo> {
16    let root = LinkedNode::new(src.root());
17    Arc::new(get_link_exprs_in(&root))
18}
19
20/// Get link expressions in a source node.
21pub fn get_link_exprs_in(node: &LinkedNode) -> LinkInfo {
22    let mut worker = LinkStrWorker {
23        info: LinkInfo::default(),
24    };
25    worker.collect_links(node);
26    worker.info
27}
28
29/// Link information in a source file.
30#[derive(Debug, Default)]
31pub struct LinkInfo {
32    /// The link objects in a source file.
33    pub objects: Vec<LinkObject>,
34}
35
36/// A link object in a source file.
37#[derive(Debug)]
38pub struct LinkObject {
39    /// The range of the link expression.
40    pub range: Range<usize>,
41    /// The span of the link expression.
42    pub span: Span,
43    /// The target of the link.
44    pub target: LinkTarget,
45}
46
47/// A valid link target.
48#[derive(Debug)]
49pub enum LinkTarget {
50    /// A package specification.
51    Package(Box<PackageSpec>),
52    /// A URL.
53    Url(Box<Url>),
54    /// A file path reference with its associated Typst file identifier and path
55    /// string.
56    ///
57    /// # Fields
58    /// * `TypstFileId` - The unique identifier for the Typst file emits the
59    ///   link
60    /// * `EcoString` - An string representation of the target file path
61    Path(TypstFileId, EcoString),
62}
63
64impl LinkTarget {
65    pub(crate) fn resolve(&self, ctx: &mut LocalContext) -> Option<Url> {
66        match self {
67            LinkTarget::Package(..) => None,
68            LinkTarget::Url(url) => Some(url.as_ref().clone()),
69            LinkTarget::Path(id, path) => {
70                let resolved = resolve_path_from_id(*id, path.as_str()).ok()?;
71                let path = match ctx.world().vfs().resolve_root(*id).ok()? {
72                    Some(root) => PathResolution::Resolved(resolved.vpath().realize(&root).ok()?),
73                    None => PathResolution::Rootless(Cow::Owned(resolved.vpath().clone())),
74                };
75                crate::path_res_to_url(path).ok()
76            }
77        }
78    }
79}
80
81struct LinkStrWorker {
82    info: LinkInfo,
83}
84
85impl LinkStrWorker {
86    fn collect_links(&mut self, node: &LinkedNode) {
87        match node.kind() {
88            // SyntaxKind::Link => { }
89            SyntaxKind::FuncCall => {
90                let fc = self.analyze_call(node);
91                if fc.is_some() {
92                    return;
93                }
94            }
95            SyntaxKind::ModuleInclude => {
96                let inc = node.cast::<ast::ModuleInclude>().expect("checked cast");
97                let path = inc.source();
98                self.analyze_path_expr(node, path);
99            }
100            // early exit
101            kind if kind.is_trivia() || kind.is_keyword() || kind.is_error() => return,
102            _ => {}
103        };
104
105        for child in node.children() {
106            self.collect_links(&child);
107        }
108    }
109
110    fn analyze_call(&mut self, node: &LinkedNode) -> Option<()> {
111        let call = node.cast::<ast::FuncCall>()?;
112        let mut callee = call.callee();
113        'check_link_fn: loop {
114            match callee {
115                ast::Expr::FieldAccess(fa) => {
116                    let target = fa.target();
117                    let ast::Expr::Ident(ident) = target else {
118                        return None;
119                    };
120                    if ident.get().as_str() != "std" {
121                        return None;
122                    }
123                    callee = ast::Expr::Ident(fa.field());
124                    continue 'check_link_fn;
125                }
126                ast::Expr::Ident(ident) => match ident.get().as_str() {
127                    "raw" => {
128                        self.analyze_reader(node, call, "theme", false);
129                        self.analyze_reader(node, call, "syntaxes", false);
130                    }
131                    "bibliography" => {
132                        self.analyze_reader(node, call, "cite", false);
133                        self.analyze_bibliography_style(node, call);
134                        self.analyze_reader(node, call, "path", true);
135                    }
136                    "cbor" | "csv" | "image" | "read" | "json" | "yaml" | "xml" => {
137                        self.analyze_reader(node, call, "path", true);
138                    }
139                    _ => return None,
140                },
141                _ => return None,
142            }
143            return None;
144        }
145    }
146
147    fn analyze_bibliography_style(&mut self, node: &LinkedNode, call: ast::FuncCall) -> Option<()> {
148        for item in call.args().items() {
149            match item {
150                ast::Arg::Named(named) if named.name().get().as_str() == "style" => {
151                    if let ast::Expr::Str(style) = named.expr()
152                        && hayagriva::archive::ArchivedStyle::by_name(&style.get()).is_some()
153                    {
154                        return Some(());
155                    }
156                    self.analyze_path_expr(node, named.expr());
157                    return Some(());
158                }
159                _ => {}
160            }
161        }
162        Some(())
163    }
164
165    fn analyze_reader(
166        &mut self,
167        node: &LinkedNode,
168        call: ast::FuncCall,
169        key: &str,
170        pos: bool,
171    ) -> Option<()> {
172        let arg = call.args().items().next()?;
173        match arg {
174            ast::Arg::Pos(s) if pos => {
175                self.analyze_path_expr(node, s);
176            }
177            _ => {}
178        }
179        for item in call.args().items() {
180            match item {
181                ast::Arg::Named(named) if named.name().get().as_str() == key => {
182                    self.analyze_path_expr(node, named.expr());
183                }
184                _ => {}
185            }
186        }
187        Some(())
188    }
189
190    fn analyze_path_expr(&mut self, node: &LinkedNode, path_expr: ast::Expr) -> Option<()> {
191        match path_expr {
192            ast::Expr::Str(s) => self.analyze_path_str(node, s),
193            ast::Expr::Array(a) => {
194                for item in a.items() {
195                    if let ast::ArrayItem::Pos(ast::Expr::Str(s)) = item {
196                        self.analyze_path_str(node, s);
197                    }
198                }
199                Some(())
200            }
201            _ => None,
202        }
203    }
204
205    fn analyze_path_str(&mut self, node: &LinkedNode, s: ast::Str<'_>) -> Option<()> {
206        let str_node = node.find(s.span())?;
207        let str_range = str_node.range();
208        let range = str_range.start + 1..str_range.end - 1;
209        if range.is_empty() {
210            return None;
211        }
212
213        let content = s.get();
214        if content.starts_with('@') {
215            let pkg_spec = PackageSpec::from_str(&content).ok()?;
216            self.info.objects.push(LinkObject {
217                range,
218                span: s.span(),
219                target: LinkTarget::Package(Box::new(pkg_spec)),
220            });
221            return Some(());
222        }
223
224        let id = node.span().id()?;
225        self.info.objects.push(LinkObject {
226            range,
227            span: s.span(),
228            target: LinkTarget::Path(id, content),
229        });
230        Some(())
231    }
232}