lex_core/lex/ast/
links.rs

1//! Document link extraction for LSP support
2//!
3//! This module provides APIs for extracting clickable links from Lex documents,
4//! enabling the LSP "document links" feature that makes URLs and file references
5//! clickable in editors.
6//!
7//! ## Problem
8//!
9//! The LSP document links feature needs to find all clickable links:
10//! - URLs in text (`[https://example.com]`)
11//! - File references (`[./file.txt]`)
12//! - Verbatim block `src` parameters (images, includes)
13//!
14//! While `ReferenceType::Url` and `ReferenceType::File` exist, there's no API to
15//! extract all links from a document.
16//!
17//! ## Solution
18//!
19//! This module provides:
20//! - `DocumentLink` struct representing a link with its location and type
21//! - `find_all_links()` methods on Document and Session
22//! - `src_parameter()` method on Verbatim to access src parameters
23//!
24//! ## Link Types
25//!
26//! 1. **URL links**: `[https://example.com]` - HTTP/HTTPS URLs
27//! 2. **File links**: `[./file.txt]`, `[../path/to/file.md]` - File references
28//! 3. **Verbatim src**: `:: image src=./image.png ::` - External resource references
29
30use super::elements::Verbatim;
31use super::range::Range;
32use super::{Document, Session};
33use crate::lex::inlines::{InlineNode, ReferenceType};
34use std::fmt;
35
36/// Represents a document link with its location and type
37#[derive(Debug, Clone, PartialEq)]
38pub struct DocumentLink {
39    pub range: Range,
40    pub target: String,
41    pub link_type: LinkType,
42}
43
44impl DocumentLink {
45    pub fn new(range: Range, target: String, link_type: LinkType) -> Self {
46        Self {
47            range,
48            target,
49            link_type,
50        }
51    }
52}
53
54impl fmt::Display for DocumentLink {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(
57            f,
58            "{:?} link: {} at {}",
59            self.link_type, self.target, self.range.start
60        )
61    }
62}
63
64/// Type of document link
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum LinkType {
67    /// HTTP/HTTPS URL
68    Url,
69    /// File reference (relative or absolute path)
70    File,
71    /// Verbatim block src parameter
72    VerbatimSrc,
73}
74
75impl Verbatim {
76    /// Get the src parameter value if present
77    ///
78    /// The src parameter is commonly used for:
79    /// - Image sources: `:: image src=./diagram.png ::`
80    /// - File includes: `:: include src=./code.rs ::`
81    /// - External resources: `:: data src=./data.csv ::`
82    ///
83    /// # Returns
84    /// The value of the `src` parameter, or None if not present
85    ///
86    /// # Example
87    /// ```rust,ignore
88    /// if let Some(src) = verbatim.src_parameter() {
89    ///     // Make src clickable in editor
90    ///     println!("Link to: {}", src);
91    /// }
92    /// ```
93    pub fn src_parameter(&self) -> Option<&str> {
94        self.closing_data
95            .parameters
96            .iter()
97            .find(|p| p.key == "src")
98            .map(|p| p.value.as_str())
99    }
100}
101
102impl Session {
103    /// Find all links at any depth in this session
104    ///
105    /// This searches recursively through all content to find:
106    /// - URL references: `[https://example.com]`
107    /// - File references: `[./path/to/file.txt]`
108    /// - Verbatim src parameters: `src=./image.png`
109    ///
110    /// # Returns
111    /// Vector of all links found in this session and its descendants
112    ///
113    /// # Example
114    /// ```rust,ignore
115    /// let links = session.find_all_links();
116    /// for link in links {
117    ///     println!("Found {} link: {}", link.link_type, link.target);
118    /// }
119    /// ```
120    pub fn find_all_links(&self) -> Vec<DocumentLink> {
121        use super::elements::content_item::ContentItem;
122        use super::traits::AstNode;
123
124        let mut links = Vec::new();
125
126        // Check for links in the session title
127        if let Some(inlines) = self.title.inlines() {
128            for inline in inlines {
129                if let InlineNode::Reference { data, .. } = inline {
130                    match &data.reference_type {
131                        ReferenceType::Url { target } => {
132                            // Use header location if available, otherwise session location
133                            let range = self.header_location().unwrap_or(&self.location).clone();
134                            let link = DocumentLink::new(range, target.clone(), LinkType::Url);
135                            links.push(link);
136                        }
137                        ReferenceType::File { target } => {
138                            let range = self.header_location().unwrap_or(&self.location).clone();
139                            let link = DocumentLink::new(range, target.clone(), LinkType::File);
140                            links.push(link);
141                        }
142                        _ => {}
143                    }
144                }
145            }
146        }
147
148        // Use existing iter_all_references() API to find URL and File references
149        for paragraph in self.iter_paragraphs_recursive() {
150            for line_item in &paragraph.lines {
151                if let ContentItem::TextLine(line) = line_item {
152                    // Use inlines() method which returns the parsed inlines without requiring mutable access
153                    if let Some(inlines) = line.content.inlines() {
154                        for inline in inlines {
155                            if let InlineNode::Reference { data, .. } = inline {
156                                match &data.reference_type {
157                                    ReferenceType::Url { target } => {
158                                        // Use paragraph's range since we don't have inline-level ranges yet
159                                        let link = DocumentLink::new(
160                                            paragraph.range().clone(),
161                                            target.clone(),
162                                            LinkType::Url,
163                                        );
164                                        links.push(link);
165                                    }
166                                    ReferenceType::File { target } => {
167                                        let link = DocumentLink::new(
168                                            paragraph.range().clone(),
169                                            target.clone(),
170                                            LinkType::File,
171                                        );
172                                        links.push(link);
173                                    }
174                                    _ => {
175                                        // Other reference types are not clickable links
176                                    }
177                                }
178                            }
179                        }
180                    }
181                }
182            }
183        }
184
185        // Iterate all verbatim blocks to find src parameters
186        for (item, _depth) in self.iter_all_nodes_with_depth() {
187            if let ContentItem::VerbatimBlock(verbatim) = item {
188                if let Some(src) = verbatim.src_parameter() {
189                    let link = DocumentLink::new(
190                        verbatim.range().clone(),
191                        src.to_string(),
192                        LinkType::VerbatimSrc,
193                    );
194                    links.push(link);
195                }
196            }
197        }
198
199        links
200    }
201}
202
203impl Document {
204    /// Find all links in the entire document
205    ///
206    /// This searches the entire document tree to find all clickable links:
207    /// - URL references in text
208    /// - File references in text
209    /// - Verbatim block src parameters
210    ///
211    /// # Returns
212    /// Vector of all links found in the document
213    ///
214    /// # Example
215    /// ```rust,ignore
216    /// let doc = parse_document(source)?;
217    /// let links = doc.find_all_links();
218    /// for link in links {
219    ///     // Make link clickable in LSP
220    ///     send_document_link(link.range, link.target);
221    /// }
222    /// ```
223    pub fn find_all_links(&self) -> Vec<DocumentLink> {
224        self.root.find_all_links()
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::lex::parsing::parse_document;
232
233    #[test]
234    fn test_url_link_extraction() {
235        let source = "Check out [https://example.com] for more info.\n\n";
236        let doc = parse_document(source).unwrap();
237
238        let links = doc.find_all_links();
239
240        assert_eq!(links.len(), 1);
241        assert_eq!(links[0].link_type, LinkType::Url);
242        assert_eq!(links[0].target, "https://example.com");
243    }
244
245    #[test]
246    fn test_file_link_extraction() {
247        let source = "See [./README.md] for details.\n\n";
248        let doc = parse_document(source).unwrap();
249
250        let links = doc.find_all_links();
251
252        assert_eq!(links.len(), 1);
253        assert_eq!(links[0].link_type, LinkType::File);
254        assert_eq!(links[0].target, "./README.md");
255    }
256
257    #[test]
258    fn test_multiple_links() {
259        let source = "Visit [https://example.com] and check [./docs.md].\n\n";
260        let doc = parse_document(source).unwrap();
261
262        let links = doc.find_all_links();
263
264        assert_eq!(links.len(), 2);
265        assert!(links.iter().any(|l| l.link_type == LinkType::Url));
266        assert!(links.iter().any(|l| l.link_type == LinkType::File));
267    }
268
269    #[test]
270    fn test_verbatim_src_parameter() {
271        let source =
272            "Sunset Photo:\n    As the sun sets over the ocean.\n:: image src=./diagram.png\n\n";
273        let doc = parse_document(source).unwrap();
274
275        let links = doc.find_all_links();
276
277        // Find verbatim src link
278        let src_links: Vec<_> = links
279            .iter()
280            .filter(|l| l.link_type == LinkType::VerbatimSrc)
281            .collect();
282        assert_eq!(
283            src_links.len(),
284            1,
285            "Expected 1 verbatim src link, found {}. All links: {:?}",
286            src_links.len(),
287            links
288        );
289        assert_eq!(src_links[0].target, "./diagram.png");
290    }
291
292    #[test]
293    fn test_verbatim_src_parameter_method() {
294        use super::super::elements::{Data, Label, Parameter};
295
296        let verbatim = Verbatim::with_subject(
297            "Test".to_string(),
298            Data::new(
299                Label::new("image".to_string()),
300                vec![Parameter::new("src".to_string(), "./test.png".to_string())],
301            ),
302        );
303
304        assert_eq!(verbatim.src_parameter(), Some("./test.png"));
305
306        // Test verbatim without src parameter
307        let verbatim_no_src = Verbatim::with_subject(
308            "Test".to_string(),
309            Data::new(Label::new("code".to_string()), vec![]),
310        );
311
312        assert_eq!(verbatim_no_src.src_parameter(), None);
313    }
314
315    #[test]
316    fn test_no_links() {
317        let source = "Just plain text with no links.\n\n";
318        let doc = parse_document(source).unwrap();
319
320        let links = doc.find_all_links();
321
322        assert_eq!(links.len(), 0);
323    }
324
325    #[test]
326    fn test_footnote_not_a_link() {
327        let source = "Text with footnote [42].\n\n";
328        let doc = parse_document(source).unwrap();
329
330        let links = doc.find_all_links();
331
332        // Footnote references are not clickable links
333        assert_eq!(links.len(), 0);
334    }
335
336    #[test]
337    fn test_nested_session_links() {
338        let source = "Outer Session\n\n    Inner session with [https://example.com].\n\n";
339        let doc = parse_document(source).unwrap();
340
341        let links = doc.find_all_links();
342
343        // Should find link in nested session
344        assert_eq!(links.len(), 1);
345        assert_eq!(links[0].target, "https://example.com");
346    }
347}