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 ¶graph.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}