supa_mdx_lint/rules/
rule006_no_absolute_urls.rs

1use markdown::mdast::{Image, Link, Node};
2use supa_mdx_macros::RuleName;
3
4use crate::{
5    context::Context,
6    errors::{LintError, LintLevel},
7    fix::LintCorrectionReplace,
8    location::{AdjustedRange, DenormalizedLocation},
9};
10
11use super::{Rule, RuleName, RuleSettings};
12
13/// Links and images should use relative URLs instead of absolute URLs that match the configured base URL.
14///
15/// ## Examples
16///
17/// ### Valid
18///
19/// ```markdown
20/// [Documentation](/docs/auth)
21/// ![Logo](/images/logo.png)
22/// ```
23///
24/// ### Invalid (assuming base_url is `https://supabase.com`)
25///
26/// ```markdown
27/// [Documentation](https://supabase.com/docs/auth)
28/// ![Logo](https://supabase.com/images/logo.png)
29/// ```
30///
31/// ## Configuration
32///
33/// Configure the base URL via the `base_url` setting in your configuration file:
34///
35/// ```toml
36/// [rule006_no_absolute_urls]
37/// base_url = "https://supabase.com"
38/// ```
39#[derive(Debug, Default, RuleName)]
40pub struct Rule006NoAbsoluteUrls {
41    base_url: Option<String>,
42}
43
44impl Rule for Rule006NoAbsoluteUrls {
45    fn default_level(&self) -> LintLevel {
46        LintLevel::Error
47    }
48
49    fn setup(&mut self, settings: Option<&mut RuleSettings>) {
50        if let Some(settings) = settings {
51            if let Some(toml::Value::String(base_url)) = settings.0.get("base_url") {
52                let base_url = base_url.trim_end_matches('/').to_string();
53                self.base_url = Some(base_url);
54            }
55        }
56    }
57
58    fn check(&self, ast: &Node, context: &Context, level: LintLevel) -> Option<Vec<LintError>> {
59        let url = match ast {
60            Node::Link(link) => &link.url,
61            Node::Image(image) => &image.url,
62            _ => return None,
63        };
64
65        // Skip if no base URL is configured
66        let base_url = self.base_url.as_ref()?;
67
68        if url.starts_with(base_url) {
69            let relative_path = &url[base_url.len()..];
70
71            let relative_path = if relative_path.starts_with('/') {
72                relative_path
73            } else {
74                // This shouldn't happen, but fail gracefully
75                return None;
76            };
77
78            if let Some(url_location) = self.find_url_location(ast, context) {
79                let correction = LintCorrectionReplace {
80                    location: url_location,
81                    text: relative_path.to_string(),
82                };
83
84                let error = LintError::from_node()
85                    .node(ast)
86                    .context(context)
87                    .rule(self.name())
88                    .level(level)
89                    .message(&self.message(url, relative_path))
90                    .fix(vec![crate::fix::LintCorrection::Replace(correction)])
91                    .call();
92
93                return error.map(|err| vec![err]);
94            }
95        }
96
97        None
98    }
99}
100
101impl Rule006NoAbsoluteUrls {
102    fn message(&self, absolute_url: &str, relative_url: &str) -> String {
103        format!(
104            "Use relative URL '{}' instead of absolute URL '{}'",
105            relative_url, absolute_url
106        )
107    }
108
109    /// Find the exact location of the URL within the markdown text
110    /// This method specifically looks for the URL within the parentheses portion
111    /// to avoid matching URLs that might appear in the display text.
112    fn find_url_location(&self, ast: &Node, context: &Context) -> Option<DenormalizedLocation> {
113        let (url, node_position) = match ast {
114            Node::Link(Link { url, position, .. }) => (url, position.as_ref()?),
115            Node::Image(Image { url, position, .. }) => (url, position.as_ref()?),
116            _ => return None,
117        };
118
119        let node_range = AdjustedRange::from_unadjusted_position(node_position, context);
120        let node_start_offset: usize = node_range.start.into();
121        let node_text = context
122            .rope()
123            .byte_slice(Into::<std::ops::Range<usize>>::into(node_range));
124        let node_text_str = node_text.to_string();
125
126        // Find the URL specifically within the parentheses portion
127        // For links: [text](URL) - look for the last opening paren, then find URL after it
128        // For images: ![alt](URL) - look for the last opening paren, then find URL after it
129        if let Some(paren_start) = node_text_str.rfind('(') {
130            // Look for the URL after the opening parenthesis
131            let after_paren = &node_text_str[paren_start + 1..];
132            if let Some(url_in_parens) = after_paren.find(url) {
133                // Make sure this is at the start of the parentheses content (accounting for whitespace)
134                let before_url = &after_paren[..url_in_parens];
135                if before_url.trim().is_empty() {
136                    let url_start_in_text = paren_start + 1 + url_in_parens;
137                    let url_start_offset = node_start_offset + url_start_in_text;
138                    let url_end_offset = url_start_offset + url.len();
139
140                    let url_range =
141                        AdjustedRange::new(url_start_offset.into(), url_end_offset.into());
142                    return Some(DenormalizedLocation::from_offset_range(url_range, context));
143                }
144            }
145        }
146
147        None
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::{context::Context, parser::parse};
155
156    fn find_link_node(node: &Node) -> Option<&Node> {
157        match node {
158            Node::Link(_) => Some(node),
159            Node::Image(_) => Some(node),
160            _ => {
161                if let Some(children) = node.children() {
162                    for child in children {
163                        if let Some(found) = find_link_node(child) {
164                            return Some(found);
165                        }
166                    }
167                }
168                None
169            }
170        }
171    }
172
173    #[test]
174    fn test_absolute_link_with_matching_base_url() {
175        let mut rule = Rule006NoAbsoluteUrls::default();
176        let mut settings = super::super::RuleSettings::from_key_value(
177            "base_url",
178            toml::Value::String("https://supabase.com".to_string()),
179        );
180        rule.setup(Some(&mut settings));
181
182        let markdown = "[Documentation](https://supabase.com/docs/auth)";
183        let parse_result = parse(markdown).unwrap();
184        let context = Context::builder()
185            .parse_result(&parse_result)
186            .build()
187            .unwrap();
188
189        let link_node = find_link_node(parse_result.ast()).expect("Should find a link node");
190        let errors = rule.check(link_node, &context, LintLevel::Error);
191        assert!(errors.is_some());
192
193        let errors = errors.unwrap();
194        assert_eq!(errors.len(), 1);
195        assert!(errors[0].message.contains("/docs/auth"));
196    }
197
198    #[test]
199    fn test_absolute_link_with_non_matching_base_url() {
200        let mut rule = Rule006NoAbsoluteUrls::default();
201        let mut settings = super::super::RuleSettings::from_key_value(
202            "base_url",
203            toml::Value::String("https://supabase.com".to_string()),
204        );
205        rule.setup(Some(&mut settings));
206
207        let markdown = "[External](https://example.com/docs)";
208        let parse_result = parse(markdown).unwrap();
209        let context = Context::builder()
210            .parse_result(&parse_result)
211            .build()
212            .unwrap();
213
214        let link_node = find_link_node(parse_result.ast()).expect("Should find a link node");
215        let errors = rule.check(link_node, &context, LintLevel::Error);
216        assert!(errors.is_none());
217    }
218
219    #[test]
220    fn test_relative_link() {
221        let mut rule = Rule006NoAbsoluteUrls::default();
222        let mut settings = super::super::RuleSettings::from_key_value(
223            "base_url",
224            toml::Value::String("https://supabase.com".to_string()),
225        );
226        rule.setup(Some(&mut settings));
227
228        let markdown = "[Documentation](/docs/auth)";
229        let parse_result = parse(markdown).unwrap();
230        let context = Context::builder()
231            .parse_result(&parse_result)
232            .build()
233            .unwrap();
234
235        let link_node = find_link_node(parse_result.ast()).expect("Should find a link node");
236        let errors = rule.check(link_node, &context, LintLevel::Error);
237        assert!(errors.is_none());
238    }
239
240    #[test]
241    fn test_image_with_absolute_url() {
242        let mut rule = Rule006NoAbsoluteUrls::default();
243        let mut settings = super::super::RuleSettings::from_key_value(
244            "base_url",
245            toml::Value::String("https://supabase.com".to_string()),
246        );
247        rule.setup(Some(&mut settings));
248
249        let markdown = "![Logo](https://supabase.com/images/logo.png)";
250        let parse_result = parse(markdown).unwrap();
251        let context = Context::builder()
252            .parse_result(&parse_result)
253            .build()
254            .unwrap();
255
256        let image_node = find_link_node(parse_result.ast()).expect("Should find an image node");
257        let errors = rule.check(image_node, &context, LintLevel::Error);
258        assert!(errors.is_some());
259
260        let errors = errors.unwrap();
261        assert_eq!(errors.len(), 1);
262        assert!(errors[0].message.contains("/images/logo.png"));
263    }
264
265    #[test]
266    fn test_no_base_url_configured() {
267        let rule = Rule006NoAbsoluteUrls::default();
268
269        let markdown = "[Documentation](https://supabase.com/docs/auth)";
270        let parse_result = parse(markdown).unwrap();
271        let context = Context::builder()
272            .parse_result(&parse_result)
273            .build()
274            .unwrap();
275
276        let link_node = find_link_node(parse_result.ast()).expect("Should find a link node");
277        let errors = rule.check(link_node, &context, LintLevel::Error);
278        assert!(errors.is_none());
279    }
280
281    #[test]
282    fn test_url_in_display_text_and_href() {
283        let mut rule = Rule006NoAbsoluteUrls::default();
284        let mut settings = super::super::RuleSettings::from_key_value(
285            "base_url",
286            toml::Value::String("https://supabase.com".to_string()),
287        );
288        rule.setup(Some(&mut settings));
289
290        // URL appears in both display text and href - should only fix the href
291        let markdown = "[https://supabase.com](https://supabase.com/docs/auth)";
292        let parse_result = parse(markdown).unwrap();
293        let context = Context::builder()
294            .parse_result(&parse_result)
295            .build()
296            .unwrap();
297
298        let link_node = find_link_node(parse_result.ast()).expect("Should find a link node");
299        let errors = rule.check(link_node, &context, LintLevel::Error);
300        assert!(errors.is_some());
301
302        let errors = errors.unwrap();
303        assert_eq!(errors.len(), 1);
304        assert!(errors[0].message.contains("/docs/auth"));
305
306        // Verify the fix would only replace the href part  
307        assert!(errors[0].fix.is_some(), "Expected fix to be present");
308        let fixes = errors[0].fix.as_ref().unwrap();
309        assert_eq!(fixes.len(), 1);
310        if let crate::fix::LintCorrection::Replace(replace_fix) = &fixes[0] {
311            assert_eq!(replace_fix.text(), "/docs/auth");
312            
313            // Verify the location is correct - should target only the URL in parentheses
314            let location = &replace_fix.location;
315            
316            // The original text is "[https://supabase.com](https://supabase.com/docs/auth)"  
317            // Position of the URL in parentheses starts at index 23 and ends at 53
318            // [https://supabase.com](https://supabase.com/docs/auth)
319            // 012345678901234567890123456789012345678901234567890123456789
320            //                        ^                             ^  
321            //                        23                            53
322            let expected_start = 23_usize;
323            let expected_end = 53_usize;
324            
325            let actual_start: usize = location.offset_range.start.into();
326            let actual_end: usize = location.offset_range.end.into();
327            assert_eq!(actual_start, expected_start);
328            assert_eq!(actual_end, expected_end);
329        } else {
330            panic!("Expected Replace correction");
331        }
332    }
333
334    #[test]
335    fn test_url_only_in_display_text() {
336        let mut rule = Rule006NoAbsoluteUrls::default();
337        let mut settings = super::super::RuleSettings::from_key_value(
338            "base_url",
339            toml::Value::String("https://supabase.com".to_string()),
340        );
341        rule.setup(Some(&mut settings));
342
343        // URL only in display text, href is different - should not trigger
344        let markdown = "[https://supabase.com](https://example.com/docs)";
345        let parse_result = parse(markdown).unwrap();
346        let context = Context::builder()
347            .parse_result(&parse_result)
348            .build()
349            .unwrap();
350
351        let link_node = find_link_node(parse_result.ast()).expect("Should find a link node");
352        let errors = rule.check(link_node, &context, LintLevel::Error);
353        assert!(errors.is_none());
354    }
355
356    #[test]
357    fn test_image_url_in_alt_text_and_src() {
358        let mut rule = Rule006NoAbsoluteUrls::default();
359        let mut settings = super::super::RuleSettings::from_key_value(
360            "base_url",
361            toml::Value::String("https://supabase.com".to_string()),
362        );
363        rule.setup(Some(&mut settings));
364
365        // URL appears in both alt text and src - should only fix the src
366        let markdown = "![https://supabase.com](https://supabase.com/logo.png)";
367        let parse_result = parse(markdown).unwrap();
368        let context = Context::builder()
369            .parse_result(&parse_result)
370            .build()
371            .unwrap();
372
373        let image_node = find_link_node(parse_result.ast()).expect("Should find an image node");
374        let errors = rule.check(image_node, &context, LintLevel::Error);
375        assert!(errors.is_some());
376
377        let errors = errors.unwrap();
378        assert_eq!(errors.len(), 1);
379        assert!(errors[0].message.contains("/logo.png"));
380        
381        // Verify the fix would only replace the src part
382        assert!(errors[0].fix.is_some(), "Expected fix to be present");
383        let fixes = errors[0].fix.as_ref().unwrap();
384        assert_eq!(fixes.len(), 1);
385        if let crate::fix::LintCorrection::Replace(replace_fix) = &fixes[0] {
386            assert_eq!(replace_fix.text(), "/logo.png");
387            
388            // Verify the location is correct - should target only the URL in parentheses
389            let location = &replace_fix.location;
390            
391            // The original text is "![https://supabase.com](https://supabase.com/logo.png)"
392            // Position of the URL in parentheses starts at index 24 and ends at 53
393            // ![https://supabase.com](https://supabase.com/logo.png)
394            // 012345678901234567890123456789012345678901234567890123456789
395            //                         ^                            ^
396            //                         24                           53
397            let expected_start = 24_usize;
398            let expected_end = 53_usize;
399            
400            let actual_start: usize = location.offset_range.start.into();
401            let actual_end: usize = location.offset_range.end.into();
402            assert_eq!(actual_start, expected_start);
403            assert_eq!(actual_end, expected_end);
404        } else {
405            panic!("Expected Replace correction");
406        }
407    }
408}