atproto blogging
1//! Syntax span tracking for conditional visibility.
2//!
3//! Tracks markdown syntax characters (like `**`, `#`, `>`) so they can be
4//! shown/hidden based on cursor position (Obsidian-style editing).
5
6use std::ops::Range;
7
8use smol_str::SmolStr;
9
10/// Classification of markdown syntax characters.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum SyntaxType {
13 /// Inline formatting: **, *, ~~, `, $, [, ], (, )
14 Inline,
15 /// Block formatting: #, >, -, *, 1., ```, ---
16 Block,
17}
18
19/// Information about a syntax span for conditional visibility.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct SyntaxSpanInfo {
22 /// Unique identifier for this syntax span (e.g., "s0", "s1")
23 pub syn_id: SmolStr,
24 /// Source char range this syntax covers (just this marker)
25 pub char_range: Range<usize>,
26 /// Whether this is inline or block-level syntax
27 pub syntax_type: SyntaxType,
28 /// For paired inline syntax (**, *, etc), the full formatted region
29 /// from opening marker through content to closing marker.
30 /// When cursor is anywhere in this range, the syntax is visible.
31 pub formatted_range: Option<Range<usize>>,
32}
33
34impl SyntaxSpanInfo {
35 /// Adjust all position fields by a character delta.
36 ///
37 /// This adjusts both `char_range` and `formatted_range` (if present) together,
38 /// ensuring they stay in sync. Use this instead of manually adjusting fields
39 /// to avoid forgetting one.
40 pub fn adjust_positions(&mut self, char_delta: isize) {
41 self.char_range.start = (self.char_range.start as isize + char_delta) as usize;
42 self.char_range.end = (self.char_range.end as isize + char_delta) as usize;
43 if let Some(ref mut fr) = self.formatted_range {
44 fr.start = (fr.start as isize + char_delta) as usize;
45 fr.end = (fr.end as isize + char_delta) as usize;
46 }
47 }
48
49 /// Check if cursor is within the visibility range for this syntax.
50 ///
51 /// Returns true if the cursor should cause this syntax to be visible.
52 /// Uses `formatted_range` if present (for paired syntax like **bold**),
53 /// otherwise uses `char_range` (for standalone syntax like # heading).
54 pub fn cursor_in_range(&self, cursor_pos: usize) -> bool {
55 let range = self.formatted_range.as_ref().unwrap_or(&self.char_range);
56 cursor_pos >= range.start && cursor_pos <= range.end
57 }
58}
59
60/// Classify syntax text as inline or block level.
61pub fn classify_syntax(text: &str) -> SyntaxType {
62 let trimmed = text.trim_start();
63
64 // Check for block-level markers
65 if trimmed.starts_with('#')
66 || trimmed.starts_with('>')
67 || trimmed.starts_with("```")
68 || trimmed.starts_with("---")
69 || (trimmed.starts_with('-')
70 && trimmed
71 .chars()
72 .nth(1)
73 .map(|c| c.is_whitespace())
74 .unwrap_or(false))
75 || (trimmed.starts_with('*')
76 && trimmed
77 .chars()
78 .nth(1)
79 .map(|c| c.is_whitespace())
80 .unwrap_or(false))
81 || trimmed
82 .chars()
83 .next()
84 .map(|c| c.is_ascii_digit())
85 .unwrap_or(false)
86 && trimmed.contains('.')
87 {
88 SyntaxType::Block
89 } else {
90 SyntaxType::Inline
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use smol_str::ToSmolStr;
97
98 use super::*;
99
100 #[test]
101 fn test_classify_block_syntax() {
102 assert_eq!(classify_syntax("# "), SyntaxType::Block);
103 assert_eq!(classify_syntax("## "), SyntaxType::Block);
104 assert_eq!(classify_syntax("> "), SyntaxType::Block);
105 assert_eq!(classify_syntax("- "), SyntaxType::Block);
106 assert_eq!(classify_syntax("* "), SyntaxType::Block);
107 assert_eq!(classify_syntax("1. "), SyntaxType::Block);
108 assert_eq!(classify_syntax("```"), SyntaxType::Block);
109 assert_eq!(classify_syntax("---"), SyntaxType::Block);
110 }
111
112 #[test]
113 fn test_classify_inline_syntax() {
114 assert_eq!(classify_syntax("**"), SyntaxType::Inline);
115 assert_eq!(classify_syntax("*"), SyntaxType::Inline);
116 assert_eq!(classify_syntax("`"), SyntaxType::Inline);
117 assert_eq!(classify_syntax("~~"), SyntaxType::Inline);
118 assert_eq!(classify_syntax("["), SyntaxType::Inline);
119 assert_eq!(classify_syntax("]("), SyntaxType::Inline);
120 }
121
122 #[test]
123 fn test_adjust_positions() {
124 let mut span = SyntaxSpanInfo {
125 syn_id: "s0".to_smolstr(),
126 char_range: 10..15,
127 syntax_type: SyntaxType::Inline,
128 formatted_range: Some(10..25),
129 };
130
131 span.adjust_positions(5);
132 assert_eq!(span.char_range, 15..20);
133 assert_eq!(span.formatted_range, Some(15..30));
134
135 span.adjust_positions(-3);
136 assert_eq!(span.char_range, 12..17);
137 assert_eq!(span.formatted_range, Some(12..27));
138 }
139
140 #[test]
141 fn test_cursor_in_range() {
142 // Paired syntax with formatted_range
143 let span = SyntaxSpanInfo {
144 syn_id: "s0".to_smolstr(),
145 char_range: 0..2, // **
146 syntax_type: SyntaxType::Inline,
147 formatted_range: Some(0..10), // **content**
148 };
149
150 assert!(span.cursor_in_range(0));
151 assert!(span.cursor_in_range(5));
152 assert!(span.cursor_in_range(10));
153 assert!(!span.cursor_in_range(11));
154
155 // Unpaired syntax without formatted_range
156 let span = SyntaxSpanInfo {
157 syn_id: "s1".to_smolstr(),
158 char_range: 0..2, // ##
159 syntax_type: SyntaxType::Block,
160 formatted_range: None,
161 };
162
163 assert!(span.cursor_in_range(0));
164 assert!(span.cursor_in_range(1));
165 assert!(span.cursor_in_range(2));
166 assert!(!span.cursor_in_range(3));
167 }
168}