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#[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 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 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 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 if let Some(paren_start) = node_text_str.rfind('(') {
130 let after_paren = &node_text_str[paren_start + 1..];
132 if let Some(url_in_parens) = after_paren.find(url) {
133 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 = "";
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 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 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 let location = &replace_fix.location;
315
316 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 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 let markdown = "";
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 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 let location = &replace_fix.location;
390
391 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}