#[derive(Debug, Clone)]
pub struct SourceBlock {
pub text: String,
pub md_start_line: usize,
}
#[derive(Debug, Clone)]
pub struct SourceMap {
pub source: String,
pub line_map: Vec<usize>,
}
impl SourceMap {
pub fn original_line(&self, extracted_line: usize) -> usize {
let idx = extracted_line.saturating_sub(1);
if idx < self.line_map.len() {
self.line_map[idx]
} else {
self.line_map.last().copied().unwrap_or(extracted_line)
}
}
}
pub fn extract_pht_blocks(markdown: &str) -> (Vec<SourceBlock>, SourceMap) {
let mut blocks = Vec::new();
let mut source = String::new();
let mut line_map: Vec<usize> = Vec::new();
let lines: Vec<&str> = markdown.lines().collect();
let mut i = 0;
let mut in_block = false;
let mut block_text = String::new();
let mut block_start_line: usize = 0;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim();
if !in_block {
if is_pht_fence_open(trimmed) {
in_block = true;
block_text.clear();
block_start_line = i + 2; }
} else {
if is_fence_close(trimmed) {
blocks.push(SourceBlock {
text: block_text.clone(),
md_start_line: block_start_line,
});
if !source.is_empty() {
source.push('\n');
line_map.push(i + 1);
}
source.push_str(&block_text);
in_block = false;
} else {
if !block_text.is_empty() {
block_text.push('\n');
}
block_text.push_str(line);
line_map.push(i + 1); }
}
i += 1;
}
if in_block && !block_text.is_empty() {
blocks.push(SourceBlock {
text: block_text.clone(),
md_start_line: block_start_line,
});
if !source.is_empty() {
source.push('\n');
line_map.push(block_start_line);
}
source.push_str(&block_text);
}
let map = SourceMap { source, line_map };
(blocks, map)
}
fn is_pht_fence_open(trimmed: &str) -> bool {
if let Some(rest) = trimmed.strip_prefix("```") {
return rest.trim().eq_ignore_ascii_case("pht");
}
if let Some(rest) = trimmed.strip_prefix("~~~") {
return rest.trim().eq_ignore_ascii_case("pht");
}
false
}
fn is_fence_close(trimmed: &str) -> bool {
trimmed == "```" || trimmed == "~~~"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_single_block() {
let md = r#"# Example
```pht
namespace aivolution/format/csv;
```
Some text.
"#;
let (blocks, map) = extract_pht_blocks(md);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].text, "namespace aivolution/format/csv;");
assert_eq!(blocks[0].md_start_line, 4); assert_eq!(map.source, "namespace aivolution/format/csv;");
assert_eq!(map.original_line(1), 4);
}
#[test]
fn extract_multiple_blocks() {
let md = r#"# Part 1
```pht
namespace foo/bar;
```
# Part 2
```pht
CSVFile:
header: required string,
@(header)
;
```
"#;
let (blocks, map) = extract_pht_blocks(md);
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].text, "namespace foo/bar;");
assert!(blocks[1].text.contains("CSVFile:"));
assert_eq!(map.original_line(1), 4);
}
#[test]
fn extract_no_blocks() {
let md = "# No pht here\n\nJust text.\n";
let (blocks, map) = extract_pht_blocks(md);
assert_eq!(blocks.len(), 0);
assert!(map.source.is_empty());
}
#[test]
fn extract_tilde_fence() {
let md = "~~~pht\nfoo: required string;\n~~~\n";
let (blocks, _) = extract_pht_blocks(md);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].text, "foo: required string;");
}
}