tinymist_query/analysis/
link_expr.rs

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