1use 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 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 continue;
167 }
168 if selectors.comment.does_match(stack.as_slice()).is_some() {
169 match &mut state {
172 ExtractorState::Code => {
173 let Some((indicator, text_rest)) =
174 text.trim_start().split_once(char::is_whitespace)
175 else {
176 state = ExtractorState::Comment;
178 continue;
179 };
180 if !self.doc_comment_markers.contains(indicator) {
181 state = ExtractorState::Comment;
183 continue;
184 }
185 if let Some(line_rest) = text_rest.strip_prefix("...") {
186 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 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 }
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 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 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}