calepin 0.0.27

A Rust CLI for preprocessing Typst documents with executable code chunks
use anyhow::{Context, Result};
use serde::Serialize;
use std::collections::HashMap;
use std::ops::Range;
use std::path::{Path, PathBuf};

use crate::typst::introspect::page_anchors;
use crate::typst::io::write_if_changed;
use crate::typst::markdown_fence::{is_closing_fence, leading_backtick_count};
use crate::typst::model::{ChunkSpec, LayoutPaths};
use crate::typst::paths::slash_path;

const PAGE_SYNC_SCHEMA_VERSION: u8 = 1;

#[derive(Serialize)]
struct PageSyncDocument {
    schema: u8,
    input: String,
    entries: Vec<PageSyncEntry>,
}

#[derive(Debug, PartialEq, Eq, Serialize)]
struct PageSyncEntry {
    label: String,
    file: String,
    line: usize,
    page: usize,
}

pub fn page_sync_path(layout: &LayoutPaths) -> PathBuf {
    layout.artifact_path("pages.json")
}

pub fn write_page_sync(typst: &Path, layout: &LayoutPaths, chunks: &[ChunkSpec]) -> Result<()> {
    let pages = page_anchors(typst, layout)?;
    let lines = source_lines_for_chunks(layout, chunks)?;
    let input = slash_path(&layout.input_rel);
    let entries = page_sync_entries(chunks, &pages, &lines, &input);

    write_page_sync_document(
        &page_sync_path(layout),
        &PageSyncDocument {
            schema: PAGE_SYNC_SCHEMA_VERSION,
            input,
            entries,
        },
    )
}

fn page_sync_entries(
    chunks: &[ChunkSpec],
    pages: &HashMap<String, usize>,
    lines: &HashMap<String, usize>,
    file: &str,
) -> Vec<PageSyncEntry> {
    let mut entries = Vec::new();

    for chunk in chunks {
        let Some(page) = pages.get(&chunk.label).copied() else {
            continue;
        };
        let Some(line) = lines.get(&chunk.label).copied() else {
            continue;
        };
        entries.push(PageSyncEntry {
            label: chunk.label.clone(),
            file: file.to_string(),
            line,
            page,
        });
    }

    entries
}

fn source_lines_for_chunks(
    layout: &LayoutPaths,
    chunks: &[ChunkSpec],
) -> Result<HashMap<String, usize>> {
    let source = std::fs::read_to_string(&layout.input)
        .with_context(|| format!("failed to read {}", layout.input.display()))?;
    let code_ranges = source_code_ranges(&source);
    let mut lines = HashMap::new();
    let mut search_start = 0;

    for chunk in chunks {
        if chunk.code.is_empty() {
            continue;
        }
        let index = find_chunk_code_index(&source, &code_ranges, &chunk.code, search_start);
        let Some(index) = index else {
            continue;
        };
        lines.insert(chunk.label.clone(), byte_index_to_line(&source, index));
        search_start = index + chunk.code.len();
    }

    Ok(lines)
}

fn find_chunk_code_index(
    source: &str,
    code_ranges: &[Range<usize>],
    code: &str,
    search_start: usize,
) -> Option<usize> {
    for range in code_ranges {
        if range.end <= search_start {
            continue;
        }
        let start = range.start.max(search_start);
        if start > range.end {
            continue;
        }
        if let Some(offset) = source[start..range.end].find(code) {
            return Some(start + offset);
        }
    }

    source
        .get(search_start..)?
        .find(code)
        .map(|offset| search_start + offset)
}

fn source_code_ranges(source: &str) -> Vec<Range<usize>> {
    let mut ranges = Vec::new();
    let mut open_fence: Option<(usize, usize)> = None;
    let mut offset = 0;

    for segment in source.split_inclusive('\n') {
        let (line, newline) = segment
            .strip_suffix('\n')
            .map(|line| (line, 1))
            .unwrap_or((segment, 0));
        let line_start = offset;
        let line_end = line_start + line.len();
        let segment_end = line_end + newline;
        let trimmed = line.trim_start();

        if let Some((fence_len, code_start)) = open_fence {
            if is_closing_fence(trimmed, fence_len) {
                ranges.push(code_start..line_start);
                open_fence = None;
            }
        } else {
            let fence_len = leading_backtick_count(trimmed);
            if fence_len >= 3 {
                open_fence = Some((fence_len, segment_end));
            } else {
                collect_inline_code_ranges(line, line_start, &mut ranges);
            }
        }

        offset = segment_end;
    }

    if let Some((_, code_start)) = open_fence {
        ranges.push(code_start..source.len());
    }

    ranges
}

fn collect_inline_code_ranges(line: &str, line_start: usize, ranges: &mut Vec<Range<usize>>) {
    let bytes = line.as_bytes();
    let mut index = 0;
    let mut open = None;

    while index < bytes.len() {
        if bytes[index] != b'`' {
            index += 1;
            continue;
        }

        let mut end = index;
        while end < bytes.len() && bytes[end] == b'`' {
            end += 1;
        }
        let tick_count = end - index;
        if tick_count < 3 {
            if let Some((open_tick_count, start)) = open {
                if tick_count != open_tick_count {
                    index = end;
                    continue;
                }
                ranges.push(start..line_start + index);
                open = None;
            } else {
                open = Some((tick_count, line_start + end));
            }
        }
        index = end;
    }
}

fn byte_index_to_line(source: &str, index: usize) -> usize {
    source[..index]
        .bytes()
        .filter(|byte| *byte == b'\n')
        .count()
        + 1
}

fn write_page_sync_document(path: &Path, document: &PageSyncDocument) -> Result<()> {
    let json = serde_json::to_string_pretty(document)?;
    let json = format!("{}\n", json);
    write_if_changed(path, json)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::typst::model::ResultsMode;
    use crate::typst::testfixtures;

    #[test]
    fn maps_chunks_to_source_lines_in_document_order() {
        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("paper.typ");
        std::fs::write(
            &input,
            r#"#calepin.inline("python")[`print(1)`]

#calepin.chunk("python")[
```
print(2)
```
]
"#,
        )
        .unwrap();
        let mut layout = testfixtures::layout(dir.path());
        layout.input = input;
        let chunks = vec![
            test_chunk("inline-1", "print(1)"),
            test_chunk("chunk-1", "print(2)"),
        ];

        let lines = source_lines_for_chunks(&layout, &chunks).unwrap();

        assert_eq!(lines.get("inline-1"), Some(&1));
        assert_eq!(lines.get("chunk-1"), Some(&5));
    }

    #[test]
    fn maps_chunks_to_code_regions_before_matching_prose() {
        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("paper.typ");
        std::fs::write(
            &input,
            r#"The code text print(2) appears in prose first.

#calepin.chunk("python")[
```
print(2)
```
]
"#,
        )
        .unwrap();
        let mut layout = testfixtures::layout(dir.path());
        layout.input = input;
        let chunks = vec![test_chunk("chunk-1", "print(2)")];

        let lines = source_lines_for_chunks(&layout, &chunks).unwrap();

        assert_eq!(lines.get("chunk-1"), Some(&5));
    }

    #[test]
    fn leaves_later_unmatched_duplicate_code_unmapped() {
        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("paper.typ");
        std::fs::write(
            &input,
            r#"#calepin.chunk("python")[
```
print(1)
```
]
"#,
        )
        .unwrap();
        let mut layout = testfixtures::layout(dir.path());
        layout.input = input;
        let chunks = vec![
            test_chunk("chunk-1", "print(1)"),
            test_chunk("generated-1", "print(1)"),
        ];

        let lines = source_lines_for_chunks(&layout, &chunks).unwrap();

        assert_eq!(lines.get("chunk-1"), Some(&3));
        assert_eq!(lines.get("generated-1"), None);
    }

    #[test]
    fn maps_code_after_backtick_prefixed_raw_block_line() {
        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("paper.typ");
        std::fs::write(
            &input,
            r#"The code text print(1) appears in prose first.

#calepin.chunk("python")[
```
```not a closing fence
print(1)
```
]
"#,
        )
        .unwrap();
        let mut layout = testfixtures::layout(dir.path());
        layout.input = input;
        let chunks = vec![test_chunk("chunk-1", "print(1)")];

        let lines = source_lines_for_chunks(&layout, &chunks).unwrap();

        assert_eq!(lines.get("chunk-1"), Some(&6));
    }

    #[test]
    fn ignores_mismatched_inline_raw_delimiters() {
        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("paper.typ");
        std::fs::write(
            &input,
            r#"The code text print(1) appears in prose first.
Mismatched inline `print(1)`` should not count.
#calepin.inline("python")[`print(1)`]
"#,
        )
        .unwrap();
        let mut layout = testfixtures::layout(dir.path());
        layout.input = input;
        let chunks = vec![test_chunk("inline-1", "print(1)")];

        let lines = source_lines_for_chunks(&layout, &chunks).unwrap();

        assert_eq!(lines.get("inline-1"), Some(&3));
    }

    #[test]
    fn page_sync_entries_join_chunks_pages_and_lines_in_chunk_order() {
        let chunks = vec![
            test_chunk("chunk-1", "print(1)"),
            test_chunk("chunk-2", "print(2)"),
            test_chunk("chunk-3", "print(3)"),
        ];
        let pages = HashMap::from([
            ("chunk-1".to_string(), 3usize),
            ("chunk-2".to_string(), 4usize),
            ("chunk-3".to_string(), 5usize),
        ]);
        let lines = HashMap::from([
            ("chunk-1".to_string(), 10usize),
            ("chunk-3".to_string(), 30usize),
        ]);

        let entries = page_sync_entries(&chunks, &pages, &lines, "paper.typ");

        assert_eq!(
            entries,
            vec![
                PageSyncEntry {
                    label: "chunk-1".to_string(),
                    file: "paper.typ".to_string(),
                    line: 10,
                    page: 3,
                },
                PageSyncEntry {
                    label: "chunk-3".to_string(),
                    file: "paper.typ".to_string(),
                    line: 30,
                    page: 5,
                },
            ]
        );
    }

    fn test_chunk(label: &str, code: &str) -> ChunkSpec {
        testfixtures::chunk(label, code, ResultsMode::Verbatim)
    }
}