1use crate::code_block_popup::CodeBlock;
2use anyhow::Result;
3use std::fs::File;
4use std::io::Write;
5use std::path::PathBuf;
6use tui_textarea::TextArea;
7
8#[derive(Debug, Default, Clone, PartialEq)]
12pub struct ParsedNotes {
13 pub preamble_lines: Vec<String>,
14 pub blocks: Vec<(String, Vec<String>)>,
15}
16
17fn unescape_line(line: &str) -> String {
18 match line.strip_prefix("\\#") {
19 Some(rest) => format!("#{}", rest),
20 None => line.to_string(),
21 }
22}
23
24fn escape_line(line: &str, in_code_block: bool) -> String {
25 if in_code_block || !line.starts_with('#') {
26 line.to_string()
27 } else {
28 format!("\\{}", line)
29 }
30}
31
32pub fn parse_notes(content: &str) -> ParsedNotes {
35 let mut parsed = ParsedNotes::default();
36 let mut current_title = String::new();
37 let mut current_lines: Vec<String> = Vec::new();
38 let mut in_code_block = false;
39 let mut seen_title = false;
40
41 for line in content.lines() {
42 if line.trim().starts_with("```") {
43 in_code_block = !in_code_block;
44 }
45 if !in_code_block && line.starts_with("# ") {
46 if seen_title && !current_title.is_empty() {
47 parsed
48 .blocks
49 .push((current_title.clone(), std::mem::take(&mut current_lines)));
50 }
51 seen_title = true;
52 current_title = line[2..].to_string();
53 } else if seen_title {
54 current_lines.push(unescape_line(line));
55 } else {
56 parsed.preamble_lines.push(line.to_string());
57 }
58 }
59
60 if seen_title && !current_title.is_empty() {
61 parsed.blocks.push((current_title, current_lines));
62 }
63
64 parsed
65}
66
67pub fn write_notes<W: Write>(writer: &mut W, parsed: &ParsedNotes) -> Result<()> {
70 for line in &parsed.preamble_lines {
71 writeln!(writer, "{}", line)?;
72 }
73 let mut in_code_block = false;
74 for (title, lines) in &parsed.blocks {
75 writeln!(writer, "# {}", title)?;
76 for line in lines {
77 writeln!(writer, "{}", escape_line(line, in_code_block))?;
78 if line.trim().starts_with("```") {
79 in_code_block = !in_code_block;
80 }
81 }
82 }
83 Ok(())
84}
85
86pub fn extract_code_blocks(content: &str) -> Vec<CodeBlock> {
87 let mut code_blocks = Vec::new();
88 let mut in_code_block = false;
89 let mut current_block = String::new();
90 let mut current_language = String::new();
91 let mut start_line = 0;
92
93 for (i, line) in content.lines().enumerate() {
94 if line.trim().starts_with("```") {
95 if in_code_block {
96 code_blocks.push(CodeBlock::new(
98 current_block.trim_end().to_string(),
99 current_language.clone(),
100 start_line,
101 i,
102 ));
103 current_block.clear();
104 current_language.clear();
105 in_code_block = false;
106 } else {
107 in_code_block = true;
109 start_line = i;
110
111 let lang_part = line.trim_start_matches('`').trim();
112 current_language = lang_part.to_string();
113 }
114 } else if in_code_block {
115 current_block.push_str(line);
116 current_block.push('\n');
117 }
118 }
119
120 if in_code_block && !current_block.is_empty() {
121 code_blocks.push(CodeBlock::new(
122 current_block.trim_end().to_string(),
123 current_language,
124 start_line,
125 content.lines().count() - 1,
126 ));
127 }
128
129 code_blocks
130}
131
132pub fn save_textareas(textareas: &[TextArea], titles: &[String], file_path: PathBuf) -> Result<()> {
133 let parsed = ParsedNotes {
134 preamble_lines: Vec::new(),
135 blocks: textareas
136 .iter()
137 .zip(titles.iter())
138 .map(|(textarea, title)| (title.clone(), textarea.lines().to_vec()))
139 .collect(),
140 };
141 let mut file = File::create(file_path)?;
142 write_notes(&mut file, &parsed)
143}
144
145pub fn load_textareas(file_path: PathBuf) -> Result<(Vec<TextArea<'static>>, Vec<String>)> {
146 let content = std::fs::read_to_string(file_path)?;
147 let parsed = parse_notes(&content);
148 let mut textareas = Vec::with_capacity(parsed.blocks.len().max(1));
149 let mut titles = Vec::with_capacity(parsed.blocks.len().max(1));
150
151 if !parsed.preamble_lines.is_empty() {
154 let mut preamble_textarea = TextArea::default();
155 for line in &parsed.preamble_lines {
156 preamble_textarea.insert_str(line);
157 preamble_textarea.insert_newline();
158 }
159 textareas.push(preamble_textarea);
160 titles.push(String::from("Untitled"));
161 }
162
163 for (title, lines) in parsed.blocks {
164 let mut textarea = TextArea::default();
165 for line in &lines {
166 textarea.insert_str(line);
167 textarea.insert_newline();
168 }
169 textareas.push(textarea);
170 titles.push(title);
171 }
172
173 Ok((textareas, titles))
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn test_extract_code_blocks() {
182 let content = r#"# Test Document
183
184```rust
185fn main() {
186 println!("Hello, world!");
187}
188```
189
190Some text here
191
192```python
193def hello():
194 print("Hello")
195```"#;
196
197 let blocks = extract_code_blocks(content);
198 assert_eq!(blocks.len(), 2);
199 assert_eq!(blocks[0].language, "rust");
200 assert_eq!(
201 blocks[0].content,
202 "fn main() {\n println!(\"Hello, world!\");\n}"
203 );
204 assert_eq!(blocks[1].language, "python");
205 assert_eq!(blocks[1].content, "def hello():\n print(\"Hello\")");
206 }
207
208 #[test]
209 fn test_extract_empty_code_blocks() {
210 let content = "```rust\n```";
211 let blocks = extract_code_blocks(content);
212 assert_eq!(blocks.len(), 1);
213 assert_eq!(blocks[0].language, "rust");
214 assert_eq!(blocks[0].content, "");
215 }
216
217 #[test]
218 fn test_unclosed_code_block() {
219 let content = "```js\nlet x = 1;";
220 let blocks = extract_code_blocks(content);
221 assert_eq!(blocks.len(), 1);
222 assert_eq!(blocks[0].language, "js");
223 assert_eq!(blocks[0].content, "let x = 1;");
224 }
225
226 #[test]
227 fn test_parse_notes_multi_block() {
228 let content = "# First\nline one\n# Second\nline two\nline three";
229 let parsed = parse_notes(content);
230 assert_eq!(parsed.preamble_lines.len(), 0);
231 assert_eq!(parsed.blocks.len(), 2);
232 assert_eq!(
233 parsed.blocks[0],
234 ("First".to_string(), vec!["line one".to_string()])
235 );
236 assert_eq!(
237 parsed.blocks[1],
238 (
239 "Second".to_string(),
240 vec!["line two".to_string(), "line three".to_string()]
241 )
242 );
243 }
244
245 #[test]
246 fn test_write_roundtrip_with_escapes() {
247 let content = "# Notes\nplain text\n\\# not a header\n```rust\n# this is code\n```";
248 let parsed = parse_notes(content);
249 assert_eq!(parsed.blocks.len(), 1);
250 let (title, lines) = &parsed.blocks[0];
251 assert_eq!(title, "Notes");
252 assert_eq!(
253 lines,
254 &vec![
255 "plain text".to_string(),
256 "# not a header".to_string(),
257 "```rust".to_string(),
258 "# this is code".to_string(),
259 "```".to_string(),
260 ]
261 );
262
263 let mut buffer = Vec::new();
264 write_notes(&mut buffer, &parsed).unwrap();
265 assert_eq!(String::from_utf8(buffer).unwrap(), format!("{}\n", content));
267 }
268
269 #[test]
270 fn test_preamble_preserved() {
271 let content = "some preamble text\n# Only block\ncontent\n";
272 let parsed = parse_notes(content);
273 assert_eq!(
274 parsed.preamble_lines,
275 vec!["some preamble text".to_string()]
276 );
277 assert_eq!(parsed.blocks.len(), 1);
278
279 let mut buffer = Vec::new();
280 write_notes(&mut buffer, &parsed).unwrap();
281 assert_eq!(String::from_utf8(buffer).unwrap(), content);
282 }
283
284 #[test]
285 fn test_headings_inside_code_blocks_are_content() {
286 let content = "# Block\n```md\n# Header inside code\n```";
287 let parsed = parse_notes(content);
288 assert_eq!(parsed.blocks.len(), 1);
289 assert_eq!(parsed.blocks[0].1.len(), 3);
290 }
291}