1use std::collections::HashMap;
15use std::fs;
16
17#[derive(Debug, Clone, PartialEq)]
19pub enum TextSegment {
20 Normal(String),
22 Bold(String),
24 Italic(String),
26 Code(String),
28 Link { text: String, target: String },
30}
31
32impl TextSegment {
33 pub fn text(&self) -> &str {
35 match self {
36 TextSegment::Normal(s) => s,
37 TextSegment::Bold(s) => s,
38 TextSegment::Italic(s) => s,
39 TextSegment::Code(s) => s,
40 TextSegment::Link { text, .. } => text,
41 }
42 }
43
44 pub fn width(&self) -> usize {
49 display_width(self.text())
50 }
51
52 pub fn len(&self) -> usize {
55 self.width()
56 }
57
58 pub fn is_empty(&self) -> bool {
60 self.text().is_empty()
61 }
62}
63
64pub(crate) fn display_width(s: &str) -> usize {
67 use unicode_width::UnicodeWidthChar;
68 s.chars().map(|c| c.width().unwrap_or(0)).sum()
69}
70
71const SEE_ALSO_PREFIX: &str = " \u{2192} "; fn ref_len(width: usize) -> u8 {
76 width.min(u8::MAX as usize) as u8
77}
78
79#[derive(Debug, Clone)]
82pub struct CrossRef {
83 pub line: i16,
85 pub offset: i16,
87 pub length: u8,
89 pub target: String,
91}
92
93impl CrossRef {
94 pub fn new(line: i16, offset: i16, length: u8, target: String) -> Self {
96 Self {
97 line,
98 offset,
99 length,
100 target,
101 }
102 }
103}
104
105#[derive(Debug, Clone)]
107pub struct HelpTopic {
108 pub id: String,
110 pub title: String,
112 pub content: Vec<String>,
114 pub links: Vec<String>,
116 pub link_titles: HashMap<String, String>,
120}
121
122impl HelpTopic {
123 pub fn new(id: String, title: String) -> Self {
125 Self {
126 id,
127 title,
128 content: Vec::new(),
129 links: Vec::new(),
130 link_titles: HashMap::new(),
131 }
132 }
133
134 pub fn add_line(&mut self, line: String) {
136 self.content.push(line);
137 }
138
139 pub fn add_link(&mut self, topic_id: String) {
141 if !self.links.contains(&topic_id) {
142 self.links.push(topic_id);
143 }
144 }
145
146 pub fn link_label<'a>(&'a self, id: &'a str) -> &'a str {
149 self.link_titles.get(id).map_or(id, String::as_str)
150 }
151
152 pub fn get_formatted_content(&self) -> Vec<String> {
154 let mut lines = vec![format!("═══ {} ═══", self.title), String::new()];
155 lines.extend(self.content.clone());
156
157 if !self.links.is_empty() {
158 lines.push(String::new());
159 lines.push("See also:".to_string());
160 for link in &self.links {
161 lines.push(format!("{SEE_ALSO_PREFIX}{}", self.link_label(link)));
162 }
163 }
164
165 lines
166 }
167
168 pub fn get_content_with_refs(&self) -> (Vec<String>, Vec<CrossRef>) {
172 let mut lines = Vec::new();
173 let mut refs = Vec::new();
174
175 lines.push(format!("═══ {} ═══", self.title));
177 lines.push(String::new());
178
179 for content_line in &self.content {
181 let (processed_line, line_refs) =
182 Self::process_line_links(content_line, (lines.len() + 1) as i16);
183 lines.push(processed_line);
184 refs.extend(line_refs);
185 }
186
187 if !self.links.is_empty() {
189 lines.push(String::new());
190 lines.push("See also:".to_string());
191 let offset = display_width(SEE_ALSO_PREFIX) as i16;
192 for link in &self.links {
193 let line_num = (lines.len() + 1) as i16;
194 let label = self.link_label(link);
195 refs.push(CrossRef::new(
196 line_num,
197 offset,
198 ref_len(display_width(label)),
199 link.clone(),
200 ));
201 lines.push(format!("{SEE_ALSO_PREFIX}{label}"));
202 }
203 }
204
205 (lines, refs)
206 }
207
208 fn process_line_links(line: &str, line_num: i16) -> (String, Vec<CrossRef>) {
211 let mut result = String::new();
212 let mut refs = Vec::new();
213 let mut remaining = line;
214
215 while let Some(link_start) = remaining.find('[') {
216 result.push_str(&remaining[..link_start]);
218
219 let after_bracket = &remaining[link_start + 1..];
221 if let Some(text_end) = after_bracket.find("](#") {
222 let link_text = &after_bracket[..text_end];
223 let after_target_start = &after_bracket[text_end + 3..];
224
225 if let Some(target_end) = after_target_start.find(')') {
226 let target = &after_target_start[..target_end];
227
228 let offset = display_width(&result) as i16;
230 refs.push(CrossRef::new(
231 line_num,
232 offset,
233 ref_len(display_width(link_text)),
234 target.to_string(),
235 ));
236
237 result.push_str(link_text);
239
240 remaining = &after_target_start[target_end + 1..];
242 continue;
243 }
244 }
245
246 result.push('[');
248 remaining = after_bracket;
249 }
250
251 result.push_str(remaining);
253
254 (result, refs)
255 }
256
257 pub fn num_cross_refs(&self) -> usize {
259 let (_, refs) = self.get_content_with_refs();
260 refs.len()
261 }
262
263 pub fn parse_line_segments(line: &str) -> Vec<TextSegment> {
266 let mut segments = Vec::new();
267 let mut remaining = line;
268 let mut current_text = String::new();
269
270 while !remaining.is_empty() {
271 if remaining.starts_with("**") {
273 if !current_text.is_empty() {
275 segments.push(TextSegment::Normal(std::mem::take(&mut current_text)));
276 }
277
278 if let Some(end) = remaining[2..].find("**") {
279 let bold_text = &remaining[2..2 + end];
280 segments.push(TextSegment::Bold(bold_text.to_string()));
281 remaining = &remaining[2 + end + 2..];
282 continue;
283 }
284 }
285
286 if remaining.starts_with('`') {
288 if !current_text.is_empty() {
290 segments.push(TextSegment::Normal(std::mem::take(&mut current_text)));
291 }
292
293 if let Some(end) = remaining[1..].find('`') {
294 let code_text = &remaining[1..=end];
295 segments.push(TextSegment::Code(code_text.to_string()));
296 remaining = &remaining[1 + end + 1..];
297 continue;
298 }
299 }
300
301 if remaining.starts_with('[') {
303 if !current_text.is_empty() {
305 segments.push(TextSegment::Normal(std::mem::take(&mut current_text)));
306 }
307
308 if let Some(text_end) = remaining[1..].find("](#") {
309 let link_text = &remaining[1..=text_end];
310 let after_target = &remaining[1 + text_end + 3..];
311
312 if let Some(target_end) = after_target.find(')') {
313 let target = &after_target[..target_end];
314 segments.push(TextSegment::Link {
315 text: link_text.to_string(),
316 target: target.to_string(),
317 });
318 remaining = &after_target[target_end + 1..];
319 continue;
320 }
321 }
322 }
323
324 if remaining.starts_with('*') && !remaining.starts_with("**") {
326 if !current_text.is_empty() {
328 segments.push(TextSegment::Normal(std::mem::take(&mut current_text)));
329 }
330
331 let search = &remaining[1..];
333 let mut found_end = None;
334 let mut pos = 0;
335 while pos < search.len() {
336 if search[pos..].starts_with('*') && !search[pos..].starts_with("**") {
337 found_end = Some(pos);
338 break;
339 }
340 pos += 1;
341 }
342
343 if let Some(end) = found_end {
344 let italic_text = &remaining[1..=end];
345 segments.push(TextSegment::Italic(italic_text.to_string()));
346 remaining = &remaining[1 + end + 1..];
347 continue;
348 }
349 }
350
351 current_text.push(remaining.chars().next().unwrap());
353 remaining = &remaining[remaining.chars().next().unwrap().len_utf8()..];
354 }
355
356 if !current_text.is_empty() {
358 segments.push(TextSegment::Normal(current_text));
359 }
360
361 if segments.is_empty() {
363 segments.push(TextSegment::Normal(String::new()));
364 }
365
366 segments
367 }
368
369 pub fn get_styled_content(&self) -> (Vec<Vec<TextSegment>>, Vec<CrossRef>) {
372 let mut all_segments = Vec::new();
373 let mut refs = Vec::new();
374
375 all_segments.push(vec![TextSegment::Normal(format!("═══ {} ═══", self.title))]);
377 all_segments.push(vec![TextSegment::Normal(String::new())]);
378
379 for content_line in &self.content {
381 let segments = Self::parse_line_segments(content_line);
382
383 let line_num = (all_segments.len() + 1) as i16;
385 let mut offset = 0i16;
386 for seg in &segments {
387 if let TextSegment::Link { text, target } = seg {
388 refs.push(CrossRef::new(
389 line_num,
390 offset,
391 ref_len(display_width(text)),
392 target.clone(),
393 ));
394 }
395 offset += seg.width() as i16;
396 }
397
398 all_segments.push(segments);
399 }
400
401 if !self.links.is_empty() {
403 all_segments.push(vec![TextSegment::Normal(String::new())]);
404 all_segments.push(vec![TextSegment::Normal("See also:".to_string())]);
405 let offset = display_width(SEE_ALSO_PREFIX) as i16;
407 for link in &self.links {
408 let line_num = (all_segments.len() + 1) as i16;
409 let label = self.link_label(link).to_string();
410 refs.push(CrossRef::new(
411 line_num,
412 offset,
413 ref_len(display_width(&label)),
414 link.clone(),
415 ));
416 all_segments.push(vec![
417 TextSegment::Normal(SEE_ALSO_PREFIX.to_string()),
418 TextSegment::Link {
419 text: label,
420 target: link.clone(),
421 },
422 ]);
423 }
424 }
425
426 (all_segments, refs)
427 }
428}
429
430pub struct HelpFile {
434 path: String,
436 topics: HashMap<String, HelpTopic>,
438 default_topic: Option<String>,
440}
441
442impl HelpFile {
443 pub fn new(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
445 let path_ref = path.as_ref();
446 let mut help_file = Self {
447 path: path_ref.to_string_lossy().to_string(),
448 topics: HashMap::new(),
449 default_topic: None,
450 };
451
452 help_file.load()?;
453 Ok(help_file)
454 }
455
456 pub fn from_content(content: &str) -> Self {
458 let mut help_file = Self {
459 path: String::new(),
460 topics: HashMap::new(),
461 default_topic: None,
462 };
463 help_file.parse_markdown(content);
464 help_file
465 }
466
467 fn load(&mut self) -> std::io::Result<()> {
469 let content = fs::read_to_string(&self.path)?;
470 self.parse_markdown(&content);
471 Ok(())
472 }
473
474 fn parse_markdown(&mut self, content: &str) {
476 let mut current_topic: Option<HelpTopic> = None;
477
478 for line in content.lines() {
479 if let Some(topic) = Self::parse_topic_header(line) {
481 if let Some(topic) = current_topic.take() {
483 if self.default_topic.is_none() {
484 self.default_topic = Some(topic.id.clone());
485 }
486 self.topics.insert(topic.id.clone(), topic);
487 }
488 current_topic = Some(topic);
489 } else if let Some(ref mut topic) = current_topic {
490 for link_id in Self::parse_links(line) {
492 topic.add_link(link_id);
493 }
494
495 if !topic.content.is_empty() || !line.trim().is_empty() {
497 topic.add_line(line.to_string());
498 }
499 }
500 }
501
502 if let Some(topic) = current_topic {
504 if self.default_topic.is_none() {
505 self.default_topic = Some(topic.id.clone());
506 }
507 self.topics.insert(topic.id.clone(), topic);
508 }
509
510 self.resolve_link_titles();
511 }
512
513 fn resolve_link_titles(&mut self) {
516 let titles: HashMap<String, String> = self
517 .topics
518 .values()
519 .map(|t| (t.id.clone(), t.title.clone()))
520 .collect();
521 for topic in self.topics.values_mut() {
522 topic.link_titles = topic
523 .links
524 .iter()
525 .filter_map(|id| titles.get(id).map(|title| (id.clone(), title.clone())))
526 .collect();
527 }
528 }
529
530 fn parse_topic_header(line: &str) -> Option<HelpTopic> {
532 let trimmed = line.trim();
533 if !trimmed.starts_with('#') {
534 return None;
535 }
536
537 if let Some(start) = trimmed.find("{#") {
539 if let Some(end) = trimmed[start..].find('}') {
540 let id = trimmed[start + 2..start + end].to_string();
541 let title = trimmed[1..start].trim().to_string();
542 return Some(HelpTopic::new(id, title));
543 }
544 }
545
546 None
547 }
548
549 fn parse_links(line: &str) -> impl Iterator<Item = String> {
554 HelpTopic::process_line_links(line, 0)
555 .1
556 .into_iter()
557 .map(|r| r.target)
558 }
559
560 pub fn get_topic(&self, id: &str) -> Option<&HelpTopic> {
562 self.topics.get(id)
563 }
564
565 pub fn get_default_topic(&self) -> Option<&HelpTopic> {
567 if let Some(ref id) = self.default_topic {
568 self.get_topic(id)
569 } else {
570 None
571 }
572 }
573
574 pub fn get_topic_ids(&self) -> Vec<String> {
576 let mut ids: Vec<String> = self.topics.keys().cloned().collect();
577 ids.sort();
578 ids
579 }
580
581 pub fn has_topic(&self, id: &str) -> bool {
583 self.topics.contains_key(id)
584 }
585
586 pub fn path(&self) -> &str {
588 &self.path
589 }
590
591 pub fn reload(&mut self) -> std::io::Result<()> {
593 self.topics.clear();
594 self.default_topic = None;
595 self.load()
596 }
597}
598
599pub struct HelpFileBuilder {
601 path: Option<String>,
602}
603
604impl HelpFileBuilder {
605 pub fn new() -> Self {
606 Self { path: None }
607 }
608
609 #[must_use]
610 pub fn path(mut self, path: impl Into<String>) -> Self {
611 self.path = Some(path.into());
612 self
613 }
614
615 pub fn build(self) -> std::io::Result<HelpFile> {
616 let path = self.path.expect("HelpFile path must be set");
617 HelpFile::new(&path)
618 }
619
620 pub fn build_rc(self) -> std::io::Result<std::rc::Rc<std::cell::RefCell<HelpFile>>> {
621 Ok(std::rc::Rc::new(std::cell::RefCell::new(self.build()?)))
622 }
623}
624
625impl Default for HelpFileBuilder {
626 fn default() -> Self {
627 Self::new()
628 }
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634 use std::io::Write;
635 use tempfile::NamedTempFile;
636
637 fn create_test_help_file() -> NamedTempFile {
638 let mut file = NamedTempFile::new().unwrap();
639 writeln!(file, "# Introduction {{#intro}}").unwrap();
640 writeln!(file).unwrap();
641 writeln!(file, "Welcome to the help system!").unwrap();
642 writeln!(file).unwrap();
643 writeln!(file, "For more information, see [File Menu](#file-menu).").unwrap();
644 writeln!(file).unwrap();
645 writeln!(file, "# File Menu {{#file-menu}}").unwrap();
646 writeln!(file).unwrap();
647 writeln!(file, "The File menu contains:").unwrap();
648 writeln!(file, "- Open: Open a file").unwrap();
649 writeln!(file, "- Save: Save the file").unwrap();
650 writeln!(file).unwrap();
651 writeln!(file, "See also [Edit Menu](#edit-menu).").unwrap();
652 writeln!(file).unwrap();
653 writeln!(file, "# Edit Menu {{#edit-menu}}").unwrap();
654 writeln!(file).unwrap();
655 writeln!(file, "The Edit menu contains:").unwrap();
656 writeln!(file, "- Copy: Copy text").unwrap();
657 writeln!(file, "- Paste: Paste text").unwrap();
658 file.flush().unwrap();
659 file
660 }
661
662 #[test]
663 fn every_link_on_a_line_is_recorded() {
664 let help = HelpFile::from_content(
665 "# Two {#two}\n\nSee [A](#a) and [B](#b).\n\n\
666 # Brackets {#brackets}\n\nNotes ([C](#c)) and [D](#d)], then [C](#c) again.\n",
667 );
668 assert_eq!(help.get_topic("two").unwrap().links, ["a", "b"]);
669 assert_eq!(help.get_topic("brackets").unwrap().links, ["c", "d"]);
672 }
673
674 #[test]
675 fn test_help_file_load() {
676 let file = create_test_help_file();
677 let help = HelpFile::new(file.path().to_str().unwrap()).unwrap();
678
679 assert_eq!(help.get_topic_ids().len(), 3);
680 assert!(help.has_topic("intro"));
681 assert!(help.has_topic("file-menu"));
682 assert!(help.has_topic("edit-menu"));
683 }
684
685 #[test]
686 fn test_help_topic_content() {
687 let file = create_test_help_file();
688 let help = HelpFile::new(file.path().to_str().unwrap()).unwrap();
689
690 let topic = help.get_topic("intro").unwrap();
691 assert_eq!(topic.title, "Introduction");
692 assert!(!topic.content.is_empty());
693 assert_eq!(topic.links.len(), 1);
694 assert_eq!(topic.links[0], "file-menu");
695 }
696
697 #[test]
698 fn test_default_topic() {
699 let file = create_test_help_file();
700 let help = HelpFile::new(file.path().to_str().unwrap()).unwrap();
701
702 let default = help.get_default_topic().unwrap();
703 assert_eq!(default.id, "intro");
704 }
705
706 #[test]
707 fn test_formatted_content() {
708 let file = create_test_help_file();
709 let help = HelpFile::new(file.path().to_str().unwrap()).unwrap();
710
711 let topic = help.get_topic("file-menu").unwrap();
712 let formatted = topic.get_formatted_content();
713
714 assert!(formatted[0].contains("File Menu"));
715 assert!(formatted.iter().any(|line| line.contains("See also:")));
716 }
717
718 #[test]
719 fn test_cross_references() {
720 let file = create_test_help_file();
721 let help = HelpFile::new(file.path().to_str().unwrap()).unwrap();
722
723 let file_menu = help.get_topic("file-menu").unwrap();
724 assert_eq!(file_menu.links.len(), 1);
725 assert_eq!(file_menu.links[0], "edit-menu");
726 }
727
728 #[test]
729 fn test_get_content_with_refs() {
730 let file = create_test_help_file();
731 let help = HelpFile::new(file.path().to_str().unwrap()).unwrap();
732
733 let intro = help.get_topic("intro").unwrap();
734 let (lines, refs) = intro.get_content_with_refs();
735
736 assert!(lines.len() >= 4);
738 assert!(lines[0].contains("Introduction"));
739
740 assert!(!refs.is_empty());
742
743 let has_inline_ref = refs.iter().any(|r| r.target == "file-menu");
746 assert!(has_inline_ref, "Should have file-menu cross-ref");
747
748 let file_menu_ref = refs.iter().find(|r| r.target == "file-menu").unwrap();
750 assert!(file_menu_ref.line > 0, "Line should be positive (1-based)");
751 assert!(file_menu_ref.length > 0, "Length should be positive");
752 }
753
754 #[test]
755 fn test_process_line_links() {
756 let line = "See [File Menu](#file-menu) and [Edit](#edit) for details.";
758 let (result, refs) = HelpTopic::process_line_links(line, 5);
759
760 assert_eq!(result, "See File Menu and Edit for details.");
762
763 assert_eq!(refs.len(), 2);
765
766 assert_eq!(refs[0].target, "file-menu");
768 assert_eq!(refs[0].offset, 4);
769 assert_eq!(refs[0].length, 9); assert_eq!(refs[0].line, 5);
771
772 assert_eq!(refs[1].target, "edit");
774 assert_eq!(refs[1].offset, 18);
775 assert_eq!(refs[1].length, 4); }
777
778 #[test]
779 fn test_reload() {
780 let file = create_test_help_file();
781 let path = file.path().to_str().unwrap().to_string();
782 let mut help = HelpFile::new(&path).unwrap();
783
784 assert_eq!(help.get_topic_ids().len(), 3);
785
786 help.reload().unwrap();
788 assert_eq!(help.get_topic_ids().len(), 3);
789 }
790
791 #[test]
792 fn test_parse_line_segments_bold() {
793 let segments = HelpTopic::parse_line_segments("Press **F1** for help");
794 assert_eq!(segments.len(), 3);
795 assert_eq!(segments[0], TextSegment::Normal("Press ".to_string()));
796 assert_eq!(segments[1], TextSegment::Bold("F1".to_string()));
797 assert_eq!(segments[2], TextSegment::Normal(" for help".to_string()));
798 }
799
800 #[test]
801 fn test_parse_line_segments_italic() {
802 let segments = HelpTopic::parse_line_segments("Choose *File > Open* from menu");
803 assert_eq!(segments.len(), 3);
804 assert_eq!(segments[0], TextSegment::Normal("Choose ".to_string()));
805 assert_eq!(segments[1], TextSegment::Italic("File > Open".to_string()));
806 assert_eq!(segments[2], TextSegment::Normal(" from menu".to_string()));
807 }
808
809 #[test]
810 fn test_parse_line_segments_code() {
811 let segments = HelpTopic::parse_line_segments("Use `Ctrl+C` to copy");
812 assert_eq!(segments.len(), 3);
813 assert_eq!(segments[0], TextSegment::Normal("Use ".to_string()));
814 assert_eq!(segments[1], TextSegment::Code("Ctrl+C".to_string()));
815 assert_eq!(segments[2], TextSegment::Normal(" to copy".to_string()));
816 }
817
818 #[test]
819 fn test_parse_line_segments_link() {
820 let segments = HelpTopic::parse_line_segments("See [File Menu](#file-menu) for details");
821 assert_eq!(segments.len(), 3);
822 assert_eq!(segments[0], TextSegment::Normal("See ".to_string()));
823 assert_eq!(
824 segments[1],
825 TextSegment::Link {
826 text: "File Menu".to_string(),
827 target: "file-menu".to_string(),
828 }
829 );
830 assert_eq!(segments[2], TextSegment::Normal(" for details".to_string()));
831 }
832
833 #[test]
834 fn test_parse_line_segments_mixed() {
835 let segments = HelpTopic::parse_line_segments("Press **F1** or `?` for [Help](#help)");
836 assert_eq!(segments.len(), 6);
837 assert_eq!(segments[0], TextSegment::Normal("Press ".to_string()));
838 assert_eq!(segments[1], TextSegment::Bold("F1".to_string()));
839 assert_eq!(segments[2], TextSegment::Normal(" or ".to_string()));
840 assert_eq!(segments[3], TextSegment::Code("?".to_string()));
841 assert_eq!(segments[4], TextSegment::Normal(" for ".to_string()));
842 assert_eq!(
843 segments[5],
844 TextSegment::Link {
845 text: "Help".to_string(),
846 target: "help".to_string(),
847 }
848 );
849 }
850
851 #[test]
852 fn test_parse_line_segments_plain() {
853 let segments = HelpTopic::parse_line_segments("Just plain text");
854 assert_eq!(segments.len(), 1);
855 assert_eq!(
856 segments[0],
857 TextSegment::Normal("Just plain text".to_string())
858 );
859 }
860
861 #[test]
862 fn see_also_shows_the_target_title_and_falls_back_to_the_id() {
863 let help = HelpFile::from_content(
864 "# Intro {#intro}\n\nSee [Edit](#edit).\nAnd [Gone](#missing).\n\n# Edit Menu {#edit}\n\nText.\n",
865 );
866 let intro = help.get_topic("intro").unwrap();
867 assert_eq!(intro.links, vec!["edit".to_string(), "missing".to_string()]);
868 let (lines, refs) = intro.get_styled_content();
869 let n = lines.len();
870 assert_eq!(
871 lines[n - 2][1],
872 TextSegment::Link {
873 text: "Edit Menu".to_string(),
874 target: "edit".to_string(),
875 },
876 "the linked topic's title"
877 );
878 assert_eq!(
879 lines[n - 1][1],
880 TextSegment::Link {
881 text: "missing".to_string(),
882 target: "missing".to_string(),
883 },
884 "an unknown topic shows its id"
885 );
886 let last = refs.last().unwrap();
887 assert_eq!((last.target.as_str(), last.length), ("missing", 7));
888 let edit = &refs[refs.len() - 2];
889 assert_eq!((edit.target.as_str(), edit.length), ("edit", 9));
890 }
891
892 #[test]
893 fn test_parse_line_segments_empty() {
894 let segments = HelpTopic::parse_line_segments("");
895 assert_eq!(segments.len(), 1);
896 assert_eq!(segments[0], TextSegment::Normal(String::new()));
897 }
898}