1use std::fmt;
2use std::ops::Range;
3
4use cairo_lang_debug::DebugWithDb;
5use cairo_lang_filesystem::span::{TextOffset, TextSpan, TextWidth};
6use itertools::Itertools;
7use pulldown_cmark::{
8 Alignment, BrokenLink, CodeBlockKind, Event, HeadingLevel, LinkType, Options,
9 Parser as MarkdownParser, Tag, TagEnd,
10};
11
12use crate::db::DocGroup;
13
14#[derive(PartialEq, Eq, Hash, Debug, Clone)]
15pub struct MarkdownLink {
16 pub link_span: TextSpan,
18 pub dest_span: Option<TextSpan>,
20 pub dest_text: Option<String>,
22}
23
24#[derive(Debug, PartialEq, Clone, Eq, salsa::Update)]
26pub struct CommentLinkToken {
27 pub label: String,
29 pub path: Option<String>,
31 pub md_link: MarkdownLink,
33}
34
35#[derive(Debug, PartialEq, Clone, Eq, salsa::Update)]
38pub enum DocumentationCommentToken {
39 Content(String),
41 Link(CommentLinkToken),
43}
44
45impl DocumentationCommentToken {
46 pub fn ends_with_newline(self) -> bool {
48 match self {
49 DocumentationCommentToken::Content(content) => content.ends_with('\n'),
50 DocumentationCommentToken::Link(link_token) => link_token.label.ends_with('\n'),
51 }
52 }
53}
54
55struct DocCommentListItem {
57 delimiter: Option<u64>,
59 is_ordered_list: bool,
61}
62
63struct PendingLink {
64 label: String,
65 path: Option<String>,
66 link_start: usize,
67 link_type: LinkType,
68 destination: String,
69 label_range: Option<Range<usize>>,
70}
71
72pub fn parse_documentation_comment(documentation_comment: &str) -> Vec<DocumentationCommentToken> {
78 let mut tokens = Vec::new();
79 let mut current_link: Option<PendingLink> = None;
80 let mut is_indented_code_block = false;
81 let mut replacer = |broken_link: BrokenLink<'_>| {
82 if matches!(broken_link.link_type, LinkType::ShortcutUnknown | LinkType::Shortcut) {
83 return Some((broken_link.reference.to_string().into(), "".into()));
84 }
85 None
86 };
87
88 let mut options = Options::empty();
89 options.insert(Options::ENABLE_TABLES);
90 let parser = MarkdownParser::new_with_broken_link_callback(
91 documentation_comment,
92 options,
93 Some(&mut replacer),
94 );
95
96 let mut list_nesting: Vec<DocCommentListItem> = Vec::new();
97 let write_list_item_prefix =
98 |list_nesting: &mut Vec<DocCommentListItem>,
99 tokens: &mut Vec<DocumentationCommentToken>| {
100 if !list_nesting.is_empty() {
101 let indent = " ".repeat(list_nesting.len() - 1);
102 let list_nesting = list_nesting.last_mut().unwrap();
103
104 tokens.push(DocumentationCommentToken::Content(if list_nesting.is_ordered_list {
105 let delimiter = list_nesting.delimiter.unwrap_or(0);
106 list_nesting.delimiter = Some(delimiter + 1);
107 format!("{indent}{delimiter}. ",)
108 } else {
109 format!("{indent}- ")
110 }));
111 }
112 };
113 let mut prefix_list_item = false;
114 let mut last_two_events = [None, None];
115 let mut table_alignment: Vec<Alignment> = Vec::new();
116
117 for (event, range) in parser.into_offset_iter() {
118 match &event {
119 Event::Text(text) => {
120 if prefix_list_item {
121 write_list_item_prefix(&mut list_nesting, &mut tokens);
122 prefix_list_item = false;
123 }
124 if let Some(link) = current_link.as_mut() {
125 link.label.push_str(text.as_ref());
126 link.label_range = Some(range.clone());
127 } else {
128 let text = {
129 if is_indented_code_block {
130 format!(" {text}")
131 } else {
132 text.to_string()
133 }
134 };
135 tokens.push(DocumentationCommentToken::Content(text));
136 }
137 }
138 Event::Code(code) => {
139 if prefix_list_item {
140 write_list_item_prefix(&mut list_nesting, &mut tokens);
141 prefix_list_item = false;
142 }
143 let complete_code = format!("`{code}`");
144 if let Some(link) = current_link.as_mut() {
145 link.label.push_str(&complete_code);
146 link.label_range = Some(range.clone());
147 } else {
148 tokens.push(DocumentationCommentToken::Content(complete_code));
149 }
150 }
151 Event::Start(tag_start) => match tag_start {
152 Tag::Heading { level, .. } => {
153 if let Some(last_token) = tokens.last_mut()
154 && !last_token.clone().ends_with_newline()
155 {
156 tokens.push(DocumentationCommentToken::Content("\n".to_string()));
157 }
158 tokens.push(DocumentationCommentToken::Content(format!(
159 "{} ",
160 heading_level_to_markdown(*level)
161 )));
162 }
163 Tag::List(list_type) => {
164 if !list_nesting.is_empty() {
165 tokens.push(DocumentationCommentToken::Content("\n".to_string()));
166 }
167 list_nesting.push(DocCommentListItem {
168 delimiter: *list_type,
169 is_ordered_list: list_type.is_some(),
170 });
171 }
172 Tag::CodeBlock(kind) => match kind {
173 CodeBlockKind::Fenced(language) => {
174 if language.trim().is_empty() {
175 tokens.push(DocumentationCommentToken::Content(String::from(
176 "```cairo\n",
177 )));
178 } else {
179 tokens.push(DocumentationCommentToken::Content(format!(
180 "```{language}\n"
181 )));
182 }
183 }
184 CodeBlockKind::Indented => {
185 tokens.push(DocumentationCommentToken::Content("\n".to_string()));
186 is_indented_code_block = true;
187 }
188 },
189 Tag::Link { link_type, dest_url, .. } => {
190 let path = match *link_type {
191 LinkType::ShortcutUnknown | LinkType::Shortcut => None,
192 _ => Some(dest_url.clone().into_string()),
193 };
194 current_link = Some(PendingLink {
195 label: String::new(),
196 path,
197 link_start: range.start,
198 link_type: *link_type,
199 destination: dest_url.clone().into_string(),
200 label_range: None,
201 });
202 }
203 Tag::Paragraph | Tag::TableRow => {
204 tokens.push(DocumentationCommentToken::Content("\n".to_string()));
205 }
206 Tag::Item => {
207 prefix_list_item = true;
208 }
209 Tag::Table(alignment) => {
210 table_alignment = alignment.clone();
211 tokens.push(DocumentationCommentToken::Content("\n".to_string()));
212 }
213 Tag::TableCell => {
214 tokens.push(DocumentationCommentToken::Content("|".to_string()));
215 }
216 Tag::Strong => {
217 tokens.push(DocumentationCommentToken::Content("**".to_string()));
218 }
219 Tag::Emphasis => {
220 tokens.push(DocumentationCommentToken::Content("_".to_string()));
221 }
222 _ => {}
223 },
224 Event::End(tag_end) => match tag_end {
225 TagEnd::Heading(_) | TagEnd::Table => {
226 tokens.push(DocumentationCommentToken::Content("\n".to_string()));
227 }
228 TagEnd::List(_) => {
229 list_nesting.pop();
230 }
231 TagEnd::Item
232 if !matches!(last_two_events[0], Some(Event::End(_)))
233 | !matches!(last_two_events[1], Some(Event::End(_))) =>
234 {
235 tokens.push(DocumentationCommentToken::Content("\n".to_string()));
236 }
237 TagEnd::TableHead => {
238 tokens.push(DocumentationCommentToken::Content(format!(
239 "|\n|{}|",
240 table_alignment
241 .iter()
242 .map(|a| {
243 let (left, right) = get_alignment_markers(a);
244 format!("{left}---{right}")
245 })
246 .join("|")
247 )));
248 table_alignment.clear();
249 }
250 TagEnd::CodeBlock => {
251 if !is_indented_code_block {
252 tokens.push(DocumentationCommentToken::Content("```\n".to_string()));
253 }
254 is_indented_code_block = false;
255 }
256 TagEnd::Link => {
257 if let Some(link) = current_link {
258 let link_span = span_from_relative_range(
259 documentation_comment,
260 link.link_start..range.end,
261 );
262 let (dest_span, dest_text) = link
263 .label_range
264 .as_ref()
265 .and_then(|label_range| {
266 location_from_link_fields(
267 documentation_comment,
268 link.link_type,
269 &link.destination,
270 label_range,
271 )
272 })
273 .map(|(dest_range, dest_text)| {
274 (
275 Some(span_from_relative_range(
276 documentation_comment,
277 dest_range,
278 )),
279 Some(dest_text),
280 )
281 })
282 .unwrap_or((None, None));
283 let md_link = MarkdownLink { link_span, dest_span, dest_text };
284 tokens.push(DocumentationCommentToken::Link(CommentLinkToken {
285 label: link.label,
286 path: link.path,
287 md_link,
288 }));
289 }
290 current_link = None;
291 }
292 TagEnd::TableRow => {
293 tokens.push(DocumentationCommentToken::Content("|".to_string()));
294 }
295 TagEnd::Strong => {
296 tokens.push(DocumentationCommentToken::Content("**".to_string()));
297 }
298 TagEnd::Emphasis => {
299 tokens.push(DocumentationCommentToken::Content("_".to_string()));
300 }
301 TagEnd::Paragraph => {
302 tokens.push(DocumentationCommentToken::Content("\n".to_string()));
303 }
304 _ => {}
305 },
306 Event::SoftBreak => {
307 tokens.push(DocumentationCommentToken::Content("\n".to_string()));
308 }
309 Event::Rule => {
310 tokens.push(DocumentationCommentToken::Content("___\n".to_string()));
311 }
312 _ => {}
313 }
314 last_two_events = [last_two_events[1].clone(), Some(event)];
315 }
316
317 if let Some(DocumentationCommentToken::Content(token)) = tokens.first()
318 && token == "\n"
319 {
320 tokens.remove(0);
321 }
322 if let Some(DocumentationCommentToken::Content(token)) = tokens.last_mut() {
323 *token = token.trim_end().to_string();
324 if token.is_empty() {
325 tokens.pop();
326 }
327 }
328
329 tokens
330}
331
332impl fmt::Display for CommentLinkToken {
333 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334 match self.path.clone() {
335 Some(path) => write!(f, "[{}]({})", self.label, path),
336 None => write!(f, "[{}]", self.label),
337 }
338 }
339}
340
341impl fmt::Display for DocumentationCommentToken {
342 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
343 match self {
344 DocumentationCommentToken::Content(content) => {
345 write!(f, "{content}")
346 }
347 DocumentationCommentToken::Link(link_token) => {
348 write!(f, "{link_token}")
349 }
350 }
351 }
352}
353
354impl<'db> DebugWithDb<'db> for CommentLinkToken {
355 type Db = dyn DocGroup;
356 fn fmt(&self, f: &mut fmt::Formatter<'_>, _db: &Self::Db) -> fmt::Result {
357 f.debug_struct("CommentLinkToken")
358 .field("label", &self.label)
359 .field("path", &self.path)
360 .field("md_link", &self.md_link)
361 .finish()
362 }
363}
364
365fn span_from_relative_range(content: &str, range: Range<usize>) -> TextSpan {
367 let start = TextOffset::START.add_width(TextWidth::at(content, range.start));
368 let end = TextOffset::START.add_width(TextWidth::at(content, range.end));
369 TextSpan::new(start, end)
370}
371
372fn location_from_link_fields(
374 content: &str,
375 link_type: LinkType,
376 destination: &str,
377 label_range: &Range<usize>,
378) -> Option<(Range<usize>, String)> {
379 let (destination_normalized, backticked) = normalize_location_text(destination)?;
380
381 let range = match link_type {
382 LinkType::Inline => find_inline_destination_range(content, label_range.end, destination),
383 LinkType::Collapsed
384 | LinkType::CollapsedUnknown
385 | LinkType::Shortcut
386 | LinkType::ShortcutUnknown => label_range.clone(),
387 _ => return None,
388 };
389 Some((trim_backtick_range(range, backticked), destination_normalized))
390}
391
392fn is_location_string(value: &str) -> bool {
394 !value.is_empty() && value.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':')
395}
396
397fn normalize_location_text(value: &str) -> Option<(String, bool)> {
399 let (value, backticked) = strip_backticks(value);
400 is_location_string(value).then(|| (value.to_string(), backticked))
401}
402
403fn strip_backticks(value: &str) -> (&str, bool) {
405 let value = value.trim();
406 if let Some(stripped) = value.strip_prefix('`').and_then(|rest| rest.strip_suffix('`')) {
407 (stripped, true)
408 } else {
409 (value, false)
410 }
411}
412
413fn trim_backtick_range(range: Range<usize>, backticked: bool) -> Range<usize> {
415 if backticked { (range.start + 1)..(range.end - 1) } else { range }
416}
417
418fn find_inline_destination_range(
420 content: &str,
421 label_last_end: usize,
422 destination: &str,
423) -> Range<usize> {
424 let destination_start =
428 label_last_end + content[label_last_end..].find("](").unwrap_or_default() + 2;
429 destination_start..(destination_start + destination.len())
430}
431
432fn heading_level_to_markdown(heading_level: HeadingLevel) -> String {
434 let heading_char: String = String::from("#");
435 match heading_level {
436 HeadingLevel::H1 => heading_char,
437 HeadingLevel::H2 => heading_char.repeat(2),
438 HeadingLevel::H3 => heading_char.repeat(3),
439 HeadingLevel::H4 => heading_char.repeat(4),
440 HeadingLevel::H5 => heading_char.repeat(5),
441 HeadingLevel::H6 => heading_char.repeat(6),
442 }
443}
444
445fn get_alignment_markers(alignment: &Alignment) -> (String, String) {
447 let (left, right) = match alignment {
448 Alignment::None => ("", ""),
449 Alignment::Left => (":", ""),
450 Alignment::Right => ("", ":"),
451 Alignment::Center => (":", ":"),
452 };
453 (left.to_string(), right.to_string())
454}