1use crate::errors::{Result, SyntaxError};
4use crate::types::{FilesystemItem, NavigationGuide, NavigationGuideLine};
5use regex::Regex;
6
7pub struct Parser {
9 list_item_regex: Regex,
11 path_comment_regex: Regex,
13}
14
15impl Parser {
16 pub fn new() -> Self {
18 Self {
19 list_item_regex: Regex::new(r"^(\s*)-\s+(.+)$").unwrap(),
20 path_comment_regex: Regex::new(r"^([^#]+?)(?:\s*#\s*(.*))?$").unwrap(),
21 }
22 }
23
24 pub fn parse(&self, content: &str) -> Result<NavigationGuide> {
26 let (prologue, guide_content, epilogue, line_offset, ignore) =
28 self.extract_guide_block(content)?;
29
30 let items = self.parse_guide_content(&guide_content, line_offset)?;
32
33 Ok(NavigationGuide {
34 items,
35 prologue,
36 epilogue,
37 ignore,
38 })
39 }
40
41 #[allow(clippy::type_complexity)]
43 fn extract_guide_block(
44 &self,
45 content: &str,
46 ) -> Result<(Option<String>, String, Option<String>, usize, bool)> {
47 let lines: Vec<&str> = content.lines().collect();
48 let mut start_idx = None;
49 let mut end_idx = None;
50 let mut ignore = false;
51
52 for (idx, line) in lines.iter().enumerate() {
54 let trimmed = line.trim();
55
56 if trimmed.starts_with("<agentic-navigation-guide") && trimmed.ends_with(">") {
58 if start_idx.is_some() {
59 return Err(SyntaxError::MultipleGuideBlocks { line: idx + 1 }.into());
60 }
61 start_idx = Some(idx);
62
63 ignore = self.parse_ignore_attribute(trimmed);
65 } else if trimmed == "</agentic-navigation-guide>" {
66 end_idx = Some(idx);
67 break;
68 }
69 }
70
71 let start = start_idx.ok_or(SyntaxError::MissingOpeningMarker { line: 1 })?;
73 let end = end_idx.ok_or(SyntaxError::MissingClosingMarker { line: lines.len() })?;
74
75 let prologue = if start > 0 {
77 Some(lines[..start].join("\n"))
78 } else {
79 None
80 };
81
82 let guide_content = lines[start + 1..end].join("\n");
83
84 let epilogue = if end + 1 < lines.len() {
85 Some(lines[end + 1..].join("\n"))
86 } else {
87 None
88 };
89
90 let line_offset = start + 1;
92
93 Ok((prologue, guide_content, epilogue, line_offset, ignore))
94 }
95
96 fn parse_ignore_attribute(&self, tag: &str) -> bool {
99 if tag.contains("ignore=true") || tag.contains("ignore=\"true\"") {
101 return true;
102 }
103 false
104 }
105
106 fn parse_guide_content(
108 &self,
109 content: &str,
110 line_offset: usize,
111 ) -> Result<Vec<NavigationGuideLine>> {
112 if content.trim().is_empty() {
113 return Err(SyntaxError::EmptyGuideBlock.into());
114 }
115
116 let mut items = Vec::new();
117 let mut indent_size = None;
118 let lines: Vec<&str> = content.lines().collect();
119
120 for (idx, line) in lines.iter().enumerate() {
121 let line_number = idx + 1 + line_offset;
123
124 if line.trim().is_empty() {
126 return Err(SyntaxError::BlankLineInGuide { line: line_number }.into());
127 }
128
129 if let Some(captures) = self.list_item_regex.captures(line) {
131 let indent = captures.get(1).unwrap().as_str().len();
132 let content = captures.get(2).unwrap().as_str();
133
134 if indent > 0 && indent_size.is_none() {
136 indent_size = Some(indent);
137 }
138
139 let indent_level = if indent == 0 {
141 0
142 } else if let Some(size) = indent_size {
143 if indent % size != 0 {
144 return Err(
145 SyntaxError::InvalidIndentationLevel { line: line_number }.into()
146 );
147 }
148 indent / size
149 } else {
150 1
152 };
153
154 let (path, comment) = self.parse_path_comment(content, line_number)?;
156 let expanded_paths = Self::expand_wildcard_path(&path, line_number)?;
157
158 for expanded in expanded_paths {
159 let item = if expanded == "..." {
161 FilesystemItem::Placeholder {
162 comment: comment.clone(),
163 }
164 } else if expanded.ends_with('/') {
165 FilesystemItem::Directory {
166 path: expanded.trim_end_matches('/').to_string(),
167 comment: comment.clone(),
168 children: Vec::new(),
169 }
170 } else {
171 FilesystemItem::File {
173 path: expanded,
174 comment: comment.clone(),
175 }
176 };
177
178 items.push(NavigationGuideLine {
179 line_number,
180 indent_level,
181 item,
182 });
183 }
184 } else {
185 return Err(SyntaxError::InvalidListFormat { line: line_number }.into());
186 }
187 }
188
189 let hierarchical_items = self.build_hierarchy(items)?;
191
192 Ok(hierarchical_items)
193 }
194
195 fn parse_path_comment(
197 &self,
198 content: &str,
199 line_number: usize,
200 ) -> Result<(String, Option<String>)> {
201 if let Some(captures) = self.path_comment_regex.captures(content) {
202 let path = captures.get(1).unwrap().as_str().trim().to_string();
203 let comment = captures.get(2).map(|m| m.as_str().trim().to_string());
204
205 if path.is_empty() {
207 return Err(SyntaxError::InvalidPathFormat {
208 line: line_number,
209 path: String::new(),
210 }
211 .into());
212 }
213
214 if path == "..." {
216 } else if path == "." || path == ".." || path == "./" || path == "../" {
218 return Err(SyntaxError::InvalidSpecialDirectory {
219 line: line_number,
220 path,
221 }
222 .into());
223 }
224
225 Ok((path, comment))
226 } else {
227 Err(SyntaxError::InvalidPathFormat {
228 line: line_number,
229 path: content.to_string(),
230 }
231 .into())
232 }
233 }
234
235 fn process_escapes(s: &str) -> String {
250 let mut result = String::new();
251 let mut chars = s.chars().peekable();
252
253 while let Some(ch) = chars.next() {
254 if ch == '\\' {
255 if let Some(&next) = chars.peek() {
256 chars.next();
258 result.push(next);
259 } else {
260 result.push(ch);
262 }
263 } else {
264 result.push(ch);
265 }
266 }
267
268 result
269 }
270
271 fn expand_wildcard_path(path: &str, line_number: usize) -> Result<Vec<String>> {
311 let mut prefix = String::new();
312 let mut suffix = String::new();
313 let mut block_content = String::new();
314
315 let mut in_block = false;
316 let mut block_found = false;
317 let mut in_quotes = false;
318 let mut iter = path.chars().peekable();
319
320 while let Some(ch) = iter.next() {
321 match ch {
322 '\\' => {
323 let next = iter
324 .next()
325 .ok_or_else(|| SyntaxError::InvalidWildcardSyntax {
326 line: line_number,
327 path: path.to_string(),
328 message: "incomplete escape sequence".to_string(),
329 })?;
330
331 if in_block {
333 block_content.push('\\');
334 block_content.push(next);
335 } else if block_found {
336 suffix.push('\\');
337 suffix.push(next);
338 } else {
339 prefix.push('\\');
340 prefix.push(next);
341 }
342 }
343 '[' if !in_block => {
344 if block_found {
345 return Err(SyntaxError::InvalidWildcardSyntax {
346 line: line_number,
347 path: path.to_string(),
348 message: "multiple wildcard choice blocks are not supported"
349 .to_string(),
350 }
351 .into());
352 }
353 block_found = true;
354 in_block = true;
355 in_quotes = false;
356 }
357 ']' if in_block && !in_quotes => {
358 in_block = false;
359 in_quotes = false;
360 }
361 ']' if in_block => {
362 block_content.push(ch);
363 }
364 '"' if in_block => {
365 in_quotes = !in_quotes;
366 block_content.push(ch);
367 }
368 _ => {
369 if in_block {
370 block_content.push(ch);
371 } else if block_found {
372 suffix.push(ch);
373 } else {
374 prefix.push(ch);
375 }
376 }
377 }
378 }
379
380 if in_block {
381 return Err(SyntaxError::InvalidWildcardSyntax {
382 line: line_number,
383 path: path.to_string(),
384 message: "unterminated wildcard choice block".to_string(),
385 }
386 .into());
387 }
388
389 if !block_found {
390 return Ok(vec![Self::process_escapes(&prefix)]);
392 }
393
394 let choices = Self::parse_choice_block(&block_content, path, line_number)?;
395 let mut results = Vec::with_capacity(choices.len());
396
397 let processed_prefix = Self::process_escapes(&prefix);
399 let processed_suffix = Self::process_escapes(&suffix);
400
401 for choice in choices {
402 let processed_choice = Self::process_escapes(&choice);
404 let mut expanded = processed_prefix.clone();
405 expanded.push_str(&processed_choice);
406 expanded.push_str(&processed_suffix);
407 results.push(expanded);
408 }
409
410 Ok(results)
411 }
412
413 fn parse_choice_block(content: &str, path: &str, line_number: usize) -> Result<Vec<String>> {
448 let mut choices = Vec::new();
449 let mut current = String::new();
450 let mut chars = content.chars().peekable();
451 let mut in_quotes = false;
452
453 while let Some(ch) = chars.next() {
454 match ch {
455 '\\' => {
456 let next = chars
457 .next()
458 .ok_or_else(|| SyntaxError::InvalidWildcardSyntax {
459 line: line_number,
460 path: path.to_string(),
461 message: "incomplete escape sequence".to_string(),
462 })?;
463 current.push('\\');
465 current.push(next);
466 }
467 '"' => {
468 in_quotes = !in_quotes;
469 }
470 ',' if !in_quotes => {
471 choices.push(current.trim().to_string());
472 current.clear();
473 }
474 ch if ch.is_whitespace() && !in_quotes => {
475 }
477 _ => {
478 current.push(ch);
479 }
480 }
481 }
482
483 if in_quotes {
484 return Err(SyntaxError::InvalidWildcardSyntax {
485 line: line_number,
486 path: path.to_string(),
487 message: "unterminated quoted string in wildcard choices".to_string(),
488 }
489 .into());
490 }
491
492 choices.push(current.trim().to_string());
493
494 if choices.is_empty() || choices.iter().all(|c| c.is_empty()) {
496 return Err(SyntaxError::InvalidWildcardSyntax {
497 line: line_number,
498 path: path.to_string(),
499 message: "choice block cannot be empty".to_string(),
500 }
501 .into());
502 }
503
504 Ok(choices)
505 }
506
507 fn build_hierarchy(&self, items: Vec<NavigationGuideLine>) -> Result<Vec<NavigationGuideLine>> {
509 if items.is_empty() {
510 return Ok(Vec::new());
511 }
512
513 let mut result: Vec<NavigationGuideLine> = Vec::new();
515 let mut parent_indices: Vec<Option<usize>> = vec![None; items.len()];
516
517 for i in 0..items.len() {
519 let current_level = items[i].indent_level;
520
521 if current_level == 0 {
522 parent_indices[i] = None; } else {
524 let mut parent_found = false;
526 for j in (0..i).rev() {
527 if items[j].indent_level == current_level - 1 && items[j].is_directory() {
528 parent_indices[i] = Some(j);
529 parent_found = true;
530 break;
531 } else if items[j].indent_level < current_level - 1 {
532 break;
534 }
535 }
536
537 if !parent_found {
538 return Err(SyntaxError::InvalidIndentationLevel {
539 line: items[i].line_number,
540 }
541 .into());
542 }
543 }
544 }
545
546 let mut processed_items: Vec<Option<NavigationGuideLine>> =
549 items.into_iter().map(Some).collect();
550
551 for i in (0..processed_items.len()).rev() {
553 if let Some(item) = processed_items[i].take() {
554 if let Some(parent_idx) = parent_indices[i] {
555 if let Some(ref mut parent) = processed_items[parent_idx] {
557 match &mut parent.item {
558 FilesystemItem::Directory { children, .. } => {
559 children.insert(0, item);
561 }
562 _ => {
563 return Err(SyntaxError::InvalidIndentationLevel {
564 line: item.line_number,
565 }
566 .into());
567 }
568 }
569 }
570 } else {
571 result.insert(0, item);
573 }
574 }
575 }
576
577 Ok(result)
578 }
579}
580
581impl Default for Parser {
582 fn default() -> Self {
583 Self::new()
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 #[test]
592 fn test_parse_minimal_guide() {
593 let content = r#"<agentic-navigation-guide>
594- src/
595 - main.rs
596- Cargo.toml
597</agentic-navigation-guide>"#;
598
599 let parser = Parser::new();
600 let guide = parser.parse(content).unwrap();
601 assert_eq!(guide.items.len(), 2); let src_item = &guide.items[0];
605 assert!(src_item.is_directory());
606 assert_eq!(src_item.path(), "src");
607
608 if let Some(children) = src_item.children() {
609 assert_eq!(children.len(), 1);
610 assert_eq!(children[0].path(), "main.rs");
611 } else {
612 panic!("src/ should have children");
613 }
614 }
615
616 #[test]
617 fn test_missing_opening_marker() {
618 let content = r#"- src/
619</agentic-navigation-guide>"#;
620
621 let parser = Parser::new();
622 let result = parser.parse(content);
623 assert!(matches!(
624 result,
625 Err(crate::errors::AppError::Syntax(
626 SyntaxError::MissingOpeningMarker { .. }
627 ))
628 ));
629 }
630
631 #[test]
632 fn test_parse_with_comments() {
633 let content = r#"<agentic-navigation-guide>
634- src/ # source code
635- Cargo.toml # project manifest
636</agentic-navigation-guide>"#;
637
638 let parser = Parser::new();
639 let guide = parser.parse(content).unwrap();
640 assert_eq!(guide.items.len(), 2);
641 assert_eq!(guide.items[0].comment(), Some("source code"));
642 assert_eq!(guide.items[1].comment(), Some("project manifest"));
643 }
644
645 #[test]
646 fn test_trailing_whitespace_allowed() {
647 let content = r#"<agentic-navigation-guide>
648- foo.rs
649- bar.rs
650- baz/
651 - qux.rs
652</agentic-navigation-guide>"#;
653
654 let parser = Parser::new();
655 let guide = parser.parse(content).unwrap();
656 assert_eq!(guide.items.len(), 3);
657 assert_eq!(guide.items[0].path(), "foo.rs");
658 assert_eq!(guide.items[1].path(), "bar.rs");
659 assert_eq!(guide.items[2].path(), "baz");
660
661 if let Some(children) = guide.items[2].children() {
662 assert_eq!(children.len(), 1);
663 assert_eq!(children[0].path(), "qux.rs");
664 } else {
665 panic!("baz/ should have children");
666 }
667 }
668
669 #[test]
670 fn test_parse_placeholder() {
671 let content = r#"<agentic-navigation-guide>
672- src/
673 - main.rs
674 - ... # other source files
675- docs/
676 - README.md
677 - ...
678</agentic-navigation-guide>"#;
679
680 let parser = Parser::new();
681 let guide = parser.parse(content).unwrap();
682 assert_eq!(guide.items.len(), 2); let src_item = &guide.items[0];
686 if let Some(children) = src_item.children() {
687 assert_eq!(children.len(), 2);
688 assert_eq!(children[0].path(), "main.rs");
689 assert!(children[1].is_placeholder());
690 assert_eq!(children[1].comment(), Some("other source files"));
691 } else {
692 panic!("src/ should have children");
693 }
694
695 let docs_item = &guide.items[1];
697 if let Some(children) = docs_item.children() {
698 assert_eq!(children.len(), 2);
699 assert_eq!(children[0].path(), "README.md");
700 assert!(children[1].is_placeholder());
701 assert_eq!(children[1].comment(), None);
702 } else {
703 panic!("docs/ should have children");
704 }
705 }
706
707 #[test]
708 fn test_parse_ignore_attribute_unquoted() {
709 let content = r#"<agentic-navigation-guide ignore=true>
710- src/
711 - main.rs
712- Cargo.toml
713</agentic-navigation-guide>"#;
714
715 let parser = Parser::new();
716 let guide = parser.parse(content).unwrap();
717 assert!(guide.ignore);
718 assert_eq!(guide.items.len(), 2);
719 }
720
721 #[test]
722 fn test_parse_ignore_attribute_quoted() {
723 let content = r#"<agentic-navigation-guide ignore="true">
724- src/
725 - main.rs
726- Cargo.toml
727</agentic-navigation-guide>"#;
728
729 let parser = Parser::new();
730 let guide = parser.parse(content).unwrap();
731 assert!(guide.ignore);
732 assert_eq!(guide.items.len(), 2);
733 }
734
735 #[test]
736 fn test_parse_without_ignore_attribute() {
737 let content = r#"<agentic-navigation-guide>
738- src/
739 - main.rs
740- Cargo.toml
741</agentic-navigation-guide>"#;
742
743 let parser = Parser::new();
744 let guide = parser.parse(content).unwrap();
745 assert!(!guide.ignore);
746 assert_eq!(guide.items.len(), 2);
747 }
748
749 #[test]
750 fn test_parse_ignore_attribute_with_spaces() {
751 let content = r#"<agentic-navigation-guide ignore=true >
752- src/
753 - main.rs
754</agentic-navigation-guide>"#;
755
756 let parser = Parser::new();
757 let guide = parser.parse(content).unwrap();
758 assert!(guide.ignore);
759 assert_eq!(guide.items.len(), 1);
760 }
761
762 #[test]
763 fn test_parse_wildcard_expands_multiple_files() {
764 let content = r#"<agentic-navigation-guide>
765- FooCoordinator[.h, .cpp] # Coordinates foo interactions
766</agentic-navigation-guide>"#;
767
768 let parser = Parser::new();
769 let guide = parser.parse(content).unwrap();
770
771 assert_eq!(guide.items.len(), 2);
772 assert_eq!(guide.items[0].path(), "FooCoordinator.h");
773 assert_eq!(guide.items[1].path(), "FooCoordinator.cpp");
774 assert_eq!(
775 guide.items[0].comment(),
776 Some("Coordinates foo interactions")
777 );
778 assert_eq!(
779 guide.items[1].comment(),
780 Some("Coordinates foo interactions")
781 );
782 }
783
784 #[test]
785 fn test_parse_wildcard_with_empty_choice_and_whitespace() {
786 let content = r#"<agentic-navigation-guide>
787- Config[, .local].json
788</agentic-navigation-guide>"#;
789
790 let parser = Parser::new();
791 let guide = parser.parse(content).unwrap();
792
793 assert_eq!(guide.items.len(), 2);
794 assert_eq!(guide.items[0].path(), "Config.json");
795 assert_eq!(guide.items[1].path(), "Config.local.json");
796 }
797
798 #[test]
799 fn test_parse_wildcard_with_escapes_and_quotes() {
800 let content = r#"<agentic-navigation-guide>
801- data["with , comma", \,space, "literal []"] # variations
802</agentic-navigation-guide>"#;
803
804 let parser = Parser::new();
805 let guide = parser.parse(content).unwrap();
806
807 assert_eq!(guide.items.len(), 3);
808 assert_eq!(guide.items[0].path(), "datawith , comma");
814 assert_eq!(guide.items[1].path(), "data,space");
815 assert_eq!(guide.items[2].path(), "dataliteral []");
816 }
817
818 #[test]
819 fn test_parse_wildcard_literal_brackets_without_expansion() {
820 let content = r#"<agentic-navigation-guide>
821- Foo\[bar\].txt
822</agentic-navigation-guide>"#;
823
824 let parser = Parser::new();
825 let guide = parser.parse(content).unwrap();
826
827 assert_eq!(guide.items.len(), 1);
828 assert_eq!(guide.items[0].path(), "Foo[bar].txt");
829 }
830
831 #[test]
832 fn test_parse_wildcard_multiple_blocks_error() {
833 let content = r#"<agentic-navigation-guide>
834- Foo[.h][.cpp]
835</agentic-navigation-guide>"#;
836
837 let parser = Parser::new();
838 let result = parser.parse(content);
839
840 assert!(matches!(
841 result,
842 Err(crate::errors::AppError::Syntax(
843 SyntaxError::InvalidWildcardSyntax { .. }
844 ))
845 ));
846 }
847
848 #[test]
849 fn test_parse_choice_block_with_quotes() {
850 let parsed =
851 Parser::parse_choice_block("\"with , comma\", \\,space, \"literal []\"", "path", 1)
852 .unwrap();
853
854 assert_eq!(parsed, vec!["with , comma", "\\,space", "literal []"]);
857 }
858
859 #[test]
860 fn test_expand_wildcard_with_escapes_and_quotes() {
861 let expanded =
862 Parser::expand_wildcard_path("data[\"with , comma\", \\,space, \"literal []\"]", 1)
863 .unwrap();
864
865 assert_eq!(
866 expanded,
867 vec![
868 "datawith , comma".to_string(),
869 "data,space".to_string(),
870 "dataliteral []".to_string(),
871 ]
872 );
873 }
874
875 #[test]
876 fn test_parse_wildcard_with_escaped_quotes_in_quoted_strings() {
877 let content = r#"<agentic-navigation-guide>
878- file[\"test\\\"quote\"].txt
879</agentic-navigation-guide>"#;
880
881 let parser = Parser::new();
882 let guide = parser.parse(content).unwrap();
883
884 assert_eq!(guide.items.len(), 1);
885 assert_eq!(guide.items[0].path(), r#"file"test\"quote".txt"#);
886 }
887
888 #[test]
889 fn test_parse_wildcard_empty_choice_block_error() {
890 let content = r#"<agentic-navigation-guide>
891- Foo[]
892</agentic-navigation-guide>"#;
893
894 let parser = Parser::new();
895 let result = parser.parse(content);
896
897 assert!(matches!(
898 result,
899 Err(crate::errors::AppError::Syntax(
900 SyntaxError::InvalidWildcardSyntax { .. }
901 ))
902 ));
903
904 if let Err(crate::errors::AppError::Syntax(SyntaxError::InvalidWildcardSyntax {
905 message,
906 ..
907 })) = result
908 {
909 assert_eq!(message, "choice block cannot be empty");
910 }
911 }
912
913 #[test]
914 fn test_parse_wildcard_whitespace_only_choice_block_error() {
915 let content = r#"<agentic-navigation-guide>
916- Foo[ , , ]
917</agentic-navigation-guide>"#;
918
919 let parser = Parser::new();
920 let result = parser.parse(content);
921
922 assert!(matches!(
923 result,
924 Err(crate::errors::AppError::Syntax(
925 SyntaxError::InvalidWildcardSyntax { .. }
926 ))
927 ));
928
929 if let Err(crate::errors::AppError::Syntax(SyntaxError::InvalidWildcardSyntax {
930 message,
931 ..
932 })) = result
933 {
934 assert_eq!(message, "choice block cannot be empty");
935 }
936 }
937
938 #[test]
939 fn test_parse_wildcard_complex_nested_escapes() {
940 let content = r#"<agentic-navigation-guide>
942- file["a \"b\" c"].txt
943</agentic-navigation-guide>"#;
944
945 let parser = Parser::new();
946 let guide = parser.parse(content).unwrap();
947
948 assert_eq!(guide.items.len(), 1);
949 assert_eq!(guide.items[0].path(), r#"filea "b" c.txt"#);
951 }
952}