Skip to main content

hyperlit_extractor/
extractor.rs

1/* 📖 DR-0002 Use `syntect` to extract doc comments from code #decision #extractor
2
3Status: Approved\
4Date: 2025-06-19
5
6### Decision
7
8To extract doc comments from source code, we will use the [syntect](https://crates.io/crates/syntect) crate.
9
10### Context
11
12To extract doc comments from code, we need to find all the comments in the code, for various languages.
13
14The requirements for this extractor were:
15
161. Wide support for various programming language formats
172. Robustness against invalid code/syntax
183. Good performance
19
20### Consequences
21
22syntect is used to extract doc comments from the code.
23
24To support as many languages as possible, the [two-face](https://crates.io/crates/two-face) crate is used.
25
26### Considered Alternatives
27
28#### Custom lexer
29
30A custom lexer could be implemented to find comments.
31Due to the number of languages and the complexity of handling different syntaxes, this might not be a good idea.
32Especially handling "comment-like" syntax in strings would potentially mean having a custom lexer for each language.
33
34#### tree-sitter
35
36tree-sitter parsers could be used to extract the comments from source files.
37
38The drawback is that these parsers need to be curated, are platform-specific and are relatively heavyweight.
39
40#### inkjet
41
42[inkjet](https://crates.io/crates/inkjet) bundles ~70 tree-sitter parsers for various languages.
43
44The downside of this approach is that all these parsers need to be compiled (making the compilation much slower) and bundled in the binary (making the binary much larger)
45*/
46
47use hyperlit_base::result::HyperlitResult;
48use hyperlit_base::shared_string::SharedString;
49use hyperlit_base::{bail, err};
50use hyperlit_model::file_source::FileSource;
51use hyperlit_model::location::Location;
52use hyperlit_model::segment::Segment;
53use std::collections::HashSet;
54use std::io::{BufRead, BufReader, Read};
55use std::str::{FromStr, from_utf8};
56use syntect::easy::ScopeRegionIterator;
57use syntect::highlighting::ScopeSelectors;
58use syntect::parsing::{ParseState, ScopeStack, SyntaxSet};
59
60pub struct Extractor {
61    syntax_set: SyntaxSet,
62    doc_comment_markers: HashSet<String>,
63    root_path: String,
64}
65
66impl Extractor {
67    pub fn new(doc_comment_markers: &[&str], root_path: String) -> Self {
68        Self {
69            doc_comment_markers: doc_comment_markers.iter().map(|s| s.to_string()).collect(),
70            syntax_set: two_face::syntax::extra_newlines(),
71            root_path,
72        }
73    }
74}
75
76enum ExtractorState {
77    Code,
78    Comment,
79    DocComment,
80}
81const NEWLINE: char = '\n';
82const MAXIMUM_LINE_LENGTH: usize = 4096;
83
84#[derive(Debug)]
85struct Selectors {
86    comment: ScopeSelectors,
87}
88
89impl Default for Selectors {
90    fn default() -> Selectors {
91        Selectors {
92            comment: ScopeSelectors::from_str("comment - punctuation").unwrap(),
93        }
94    }
95}
96
97struct FileExtractor<'a> {
98    doc_comment_markers: &'a HashSet<String>,
99    source: &'a dyn FileSource,
100    parse_state: ParseState,
101    syntax_set: &'a SyntaxSet,
102    root_path: &'a str,
103}
104
105impl<'a> FileExtractor<'a> {
106    pub fn new(
107        source: &'a dyn FileSource,
108        parse_state: ParseState,
109        syntax_set: &'a SyntaxSet,
110        doc_comment_markers: &'a HashSet<String>,
111        root_path: &'a str,
112    ) -> Self {
113        Self {
114            source,
115            parse_state,
116            syntax_set,
117            doc_comment_markers,
118            root_path,
119        }
120    }
121
122    pub fn extract(&mut self) -> HyperlitResult<Vec<Segment>> {
123        let filepath = self.source.filepath()?;
124        let relative_filepath = filepath
125            .strip_prefix(self.root_path)
126            .map(SharedString::from)
127            .unwrap_or_else(|| filepath.clone());
128        let relative_filepath = relative_filepath.replace("\\", "/");
129        let relative_filepath = relative_filepath
130            .strip_prefix("/")
131            .unwrap_or(&relative_filepath);
132        let mut reader = BufReader::new(Box::new(self.source.open()?));
133        let mut segments = Vec::new();
134        let mut line_number = 0;
135        let mut read_buffer = Vec::with_capacity(MAXIMUM_LINE_LENGTH + 2);
136        let mut stack = ScopeStack::new();
137        let selectors = Selectors::default();
138        let mut state = ExtractorState::Code;
139        let mut line_complete;
140        'for_each_line: loop {
141            // Limit line length
142            let bytes_read = {
143                let mut limited_reader = reader.take(MAXIMUM_LINE_LENGTH as u64);
144                read_buffer.clear();
145                let bytes_read = limited_reader.read_until(b'\n', &mut read_buffer)?;
146                reader = limited_reader.into_inner();
147                if bytes_read == MAXIMUM_LINE_LENGTH {
148                    bail!(
149                        "{filepath}:{line_number} - Line too too long (> {MAXIMUM_LINE_LENGTH} bytes)"
150                    );
151                }
152                line_complete = from_utf8(&read_buffer[0..bytes_read])?;
153                bytes_read
154            };
155            if bytes_read == 0 {
156                break 'for_each_line;
157            }
158            line_number += 1;
159            let ops = self
160                .parse_state
161                .parse_line(line_complete, self.syntax_set)?;
162            for (text, op) in ScopeRegionIterator::new(&ops, line_complete) {
163                stack.apply(op)?;
164                if text.is_empty() {
165                    // skip empty strings
166                    continue;
167                }
168                if selectors.comment.does_match(stack.as_slice()).is_some() {
169                    // Comment
170
171                    match &mut state {
172                        ExtractorState::Code => {
173                            let Some((indicator, text_rest)) =
174                                text.trim_start().split_once(char::is_whitespace)
175                            else {
176                                // No whitespace found
177                                state = ExtractorState::Comment;
178                                continue;
179                            };
180                            if !self.doc_comment_markers.contains(indicator) {
181                                // Not a doc comment
182                                state = ExtractorState::Comment;
183                                continue;
184                            }
185                            if let Some(line_rest) = text_rest.strip_prefix("...") {
186                                // Found ellipsis -> continue previous segment
187                                let line_rest = line_rest.trim();
188                                let last_segment: &mut Segment = segments
189                                    .last_mut()
190                                    .ok_or_else(|| err!("No previous segment"))?;
191                                last_segment.text.push_str(line_rest);
192                                last_segment.text.push(NEWLINE);
193                                last_segment.text.push(NEWLINE);
194                            } else {
195                                // No ellipsis -> start a new segment
196                                let tag_extraction_result = extract_hash_tags(text_rest);
197                                segments.push(Segment::new(
198                                    0,
199                                    tag_extraction_result.text,
200                                    tag_extraction_result.tags,
201                                    "",
202                                    Location::new(relative_filepath, line_number),
203                                ));
204                            }
205                            state = ExtractorState::DocComment;
206                        }
207                        ExtractorState::DocComment => {
208                            let last_segment = segments.last_mut().unwrap();
209                            last_segment.text.push_str(text);
210                        }
211                        ExtractorState::Comment => {
212                            // ignore plain comments
213                        }
214                    }
215                } else {
216                    state = ExtractorState::Code;
217                }
218            }
219        }
220        Ok(segments)
221    }
222}
223
224impl Extractor {
225    pub fn extract(&self, source: &dyn FileSource) -> HyperlitResult<Vec<Segment>> {
226        let filepath = source.filepath()?;
227        // get extension
228        let extension = filepath
229            .rsplit_once('.')
230            .ok_or(err!("No extension found in filepath: '{filepath}'"))?
231            .1;
232        let syntax = self
233            .syntax_set
234            .find_syntax_by_extension(extension)
235            .ok_or_else(|| {
236                err!("{filepath} - No syntax definition found for extension '{extension}'")
237            })?;
238        let parse_state = ParseState::new(syntax);
239        let mut file_extractor = FileExtractor::new(
240            source,
241            parse_state,
242            &self.syntax_set,
243            &self.doc_comment_markers,
244            &self.root_path,
245        );
246        file_extractor.extract()
247    }
248}
249
250#[derive(Debug, PartialEq)]
251struct TagExtractionResult {
252    pub tags: Vec<String>,
253    pub text: String,
254}
255
256fn extract_hash_tags(input: &str) -> TagExtractionResult {
257    let mut tags = vec![];
258    let mut text = String::new();
259    let words = input.split_whitespace().collect::<Vec<_>>();
260    for word in words {
261        if let Some(tag) = word.strip_prefix("#") {
262            tags.push(tag.to_string());
263        } else {
264            if !text.is_empty() {
265                text.push(' ');
266            }
267            text.push_str(word);
268        }
269    }
270    TagExtractionResult { tags, text }
271}
272
273#[cfg(test)]
274mod tests {
275    use crate::extractor::{
276        Extractor, MAXIMUM_LINE_LENGTH, TagExtractionResult, extract_hash_tags,
277    };
278    use hyperlit_base::result::HyperlitResult;
279    use hyperlit_model::file_source::InMemoryFileSource;
280    use hyperlit_model::location::Location;
281    use hyperlit_model::segment::Segment;
282    use std::collections::HashMap;
283
284    fn create_test_extractor() -> Extractor {
285        Extractor::new(&["📖"], "root".to_string())
286    }
287
288    #[test]
289    fn test_extract_hash_tags() -> HyperlitResult<()> {
290        let testcases = HashMap::from([
291            (
292                "#tag",
293                TagExtractionResult {
294                    tags: vec!["tag".to_string()],
295                    text: "".to_string(),
296                },
297            ),
298            (
299                "#tag #tag2",
300                TagExtractionResult {
301                    tags: vec!["tag".to_string(), "tag2".to_string()],
302                    text: "".to_string(),
303                },
304            ),
305            (
306                "#TAG_FOO",
307                TagExtractionResult {
308                    tags: vec!["TAG_FOO".to_string()],
309                    text: "".to_string(),
310                },
311            ),
312            (
313                "alpha #beta gamma #delta epsilon",
314                TagExtractionResult {
315                    tags: vec!["beta".to_string(), "delta".to_string()],
316                    text: "alpha gamma epsilon".to_string(),
317                },
318            ),
319        ]);
320        for (input, expected) in testcases {
321            let result = extract_hash_tags(input);
322            assert_eq!(result, expected, "input: {}", input);
323        }
324        Ok(())
325    }
326    #[test]
327    fn extract_segment() -> HyperlitResult<()> {
328        let extractor = create_test_extractor();
329        let result = extractor.extract(&InMemoryFileSource::new(
330            "testfile.rs",
331            r#"
332        /* 📖 The #atag title #btag
333This is a test */
334        1+2
335        "#,
336        ))?;
337        assert_eq!(
338            result,
339            vec![Segment::new(
340                0,
341                "The title",
342                vec!["atag".to_string(), "btag".to_string()],
343                "This is a test ",
344                Location::new("testfile.rs", 2)
345            )]
346        );
347        Ok(())
348    }
349
350    #[test]
351    fn extract_from_line_comment() -> HyperlitResult<()> {
352        let extractor = create_test_extractor();
353        let result = extractor.extract(&InMemoryFileSource::new(
354            "testfile.rs",
355            r#"
356        // One
357        // 📖 Two
358        Three
359        // 📖 Four
360        1+2
361        code // 📖 Five
362        "#,
363        ))?;
364        assert_eq!(
365            result,
366            vec![
367                Segment::new(0, "Two", vec![], "", Location::new("testfile.rs", 3)),
368                Segment::new(0, "Four", vec![], "", Location::new("testfile.rs", 5)),
369                Segment::new(0, "Five", vec![], "", Location::new("testfile.rs", 7)),
370            ]
371        );
372        Ok(())
373    }
374
375    #[test]
376    fn extract_from_line_comment_continued() -> HyperlitResult<()> {
377        let extractor = create_test_extractor();
378        let result = extractor.extract(&InMemoryFileSource::new(
379            "testfile.rs",
380            r#"
381        // One
382        // 📖 Two
383        Three
384        // 📖 ... Four
385        1+2
386        code // 📖 ... Five
387        "#,
388        ))?;
389        assert_eq!(
390            result,
391            vec![Segment::new(
392                0,
393                "Two",
394                vec![],
395                "Four\n\nFive\n\n",
396                Location::new("testfile.rs", 3)
397            ),]
398        );
399        Ok(())
400    }
401
402    #[test]
403    fn extract_from_block_comment_continued() -> HyperlitResult<()> {
404        let extractor = create_test_extractor();
405        let result = extractor.extract(&InMemoryFileSource::new(
406            "testfile.rs",
407            r#"
408        // One
409        /* 📖 Two
410*/
411        Three
412        /* 📖 ... Four
413*/
414        1+2
415        code /* 📖 ... Five
416*/
417        "#,
418        ))?;
419        assert_eq!(
420            result,
421            vec![Segment::new(
422                0,
423                "Two",
424                vec![],
425                "Four\n\nFive\n\n",
426                Location::new("testfile.rs", 3)
427            ),]
428        );
429        Ok(())
430    }
431
432    #[test]
433    fn extract_interleaved_block_comment() -> HyperlitResult<()> {
434        let extractor = create_test_extractor();
435        let result = extractor.extract(&InMemoryFileSource::new(
436            "testfile.rs",
437            r#"
438        /* 📖 One */
439        /* Two */
440        /* 📖 Three */
441        /* 📖 Four */
442        "#,
443        ))?;
444        assert_eq!(
445            result,
446            vec![
447                Segment::new(0, "One", vec![], "", Location::new("testfile.rs", 2)),
448                Segment::new(0, "Three", vec![], "", Location::new("testfile.rs", 4)),
449                Segment::new(0, "Four", vec![], "", Location::new("testfile.rs", 5)),
450            ]
451        );
452        Ok(())
453    }
454
455    #[test]
456    fn extract_interleaved_block_comment_single_line() -> HyperlitResult<()> {
457        let extractor = create_test_extractor();
458        let result = extractor.extract(&InMemoryFileSource::new(
459            "testfile.rs",
460            r#"
461        /* 📖 One */         /* Two */        /* 📖 Three */        /* 📖 Four */   /* Five */     "#,
462        ))?;
463        assert_eq!(
464            result,
465            vec![
466                Segment::new(0, "One", vec![], "", Location::new("testfile.rs", 2)),
467                Segment::new(0, "Three", vec![], "", Location::new("testfile.rs", 2)),
468                Segment::new(0, "Four", vec![], "", Location::new("testfile.rs", 2)),
469            ]
470        );
471        Ok(())
472    }
473
474    #[test]
475    fn extract_interleaved_line_comment() -> HyperlitResult<()> {
476        let extractor = create_test_extractor();
477        let result = extractor.extract(&InMemoryFileSource::new(
478            "testfile.rs",
479            r#"
480        // 📖 One
481        // Two
482        // 📖 Three
483        // 📖 Four
484        "#,
485        ))?;
486        assert_eq!(
487            result,
488            vec![
489                Segment::new(0, "One", vec![], "", Location::new("testfile.rs", 2)),
490                Segment::new(0, "Three", vec![], "", Location::new("testfile.rs", 4)),
491                Segment::new(0, "Four", vec![], "", Location::new("testfile.rs", 5)),
492            ]
493        );
494        Ok(())
495    }
496
497    #[test]
498    fn ignore_normal_comments() -> HyperlitResult<()> {
499        let extractor = create_test_extractor();
500        let result = extractor.extract(&InMemoryFileSource::new(
501            "testfile.rs",
502            r#"
503        /* The #atag title #btag
504This is a test */
505        1+2
506        "#,
507        ))?;
508        assert_eq!(result, vec![]);
509        Ok(())
510    }
511
512    #[test]
513    fn ignore_comments_in_strings() -> HyperlitResult<()> {
514        let extractor = create_test_extractor();
515        let result = extractor.extract(&InMemoryFileSource::new(
516            "testfile.rs",
517            r#"
518        "/* 📖 The #atag title #btag
519This is a test */"
520        b"/* 📖 The #atag title #btag
521This is a test */"
522
523        "#,
524        ))?;
525        assert_eq!(result, vec![]);
526        Ok(())
527    }
528
529    #[test]
530    fn test_sass() -> HyperlitResult<()> {
531        let extractor = create_test_extractor();
532        let result = extractor.extract(&InMemoryFileSource::new(
533            "testfile.sass",
534            r#"
535        /* 📖 The #atag title #btag
536This is a test */
537
538        "#,
539        ))?;
540        assert_eq!(
541            result,
542            vec![Segment::new(
543                0,
544                "The title",
545                vec!["atag".to_string(), "btag".to_string()],
546                "This is a test ",
547                Location::new("testfile.sass", 2)
548            )]
549        );
550        Ok(())
551    }
552
553    #[test]
554    fn test_unknown_filetype() -> HyperlitResult<()> {
555        let extractor = create_test_extractor();
556        let result = extractor
557            .extract(&InMemoryFileSource::new(
558                "testfile.unknown",
559                r#"
560        /* 📖 The #atag title #btag
561This is a test */
562
563        "#,
564            ))
565            .expect_err("unknown filetype should fail");
566        assert_eq!(
567            result.to_string(),
568            "testfile.unknown - No syntax definition found for extension 'unknown'"
569        );
570        Ok(())
571    }
572
573    #[test]
574    fn bail_line_too_long() -> HyperlitResult<()> {
575        let extractor = create_test_extractor();
576        let long_line = "a".repeat(MAXIMUM_LINE_LENGTH + 1);
577        let result = extractor
578            .extract(&InMemoryFileSource::new("testfile.java", long_line))
579            .expect_err("too long line should fail");
580        assert_eq!(
581            result.to_string(),
582            "testfile.java:0 - Line too too long (> 4096 bytes)"
583        );
584        Ok(())
585    }
586
587    #[test]
588    fn bail_line_too_long_multibyte_char() -> HyperlitResult<()> {
589        let extractor = create_test_extractor();
590        let mut long_line = "a".repeat(MAXIMUM_LINE_LENGTH - 1);
591        // put a multibyte char at the maximum line length boundary so that the resulting buffer is not valid UTF-8
592        long_line += "📖";
593        let result = extractor
594            .extract(&InMemoryFileSource::new("testfile.java", long_line))
595            .expect_err("too long line should fail");
596        assert_eq!(
597            result.to_string(),
598            "testfile.java:0 - Line too too long (> 4096 bytes)"
599        );
600        Ok(())
601    }
602}