lepiter-core 0.11.0

core parser and lazy index api for lepiter knowledge bases
Documentation
//! Shared scanner for inline `[[wikilink]]` and `[label](target)` syntax.
//!
//! A single byte-level walk underlies both link-target extraction (in
//! [`crate::util`]) and the export/import link rewriters in the tui crate, so
//! the grammar's edge cases live in one place instead of being re-implemented
//! per caller.
//!
//! This is distinct from the char-based styling parser in the tui crate's
//! `inline` module, which exists to render display spans and is intentionally
//! left separate.

use std::ops::Range;

/// Which inline-link syntax produced an [`InlineLink`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkKind {
    /// `[[target]]`
    Wiki,
    /// `[label](target)`
    Markdown,
}

/// A single inline link located within a text run.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InlineLink<'a> {
    pub kind: LinkKind,
    /// The display label. For [`LinkKind::Markdown`] this is the raw slice
    /// between `[` and `]`; for [`LinkKind::Wiki`] it equals `target`.
    pub label: &'a str,
    /// The link target, trimmed of surrounding whitespace. Never empty.
    pub target: &'a str,
    /// Byte range of the whole construct within the source text.
    pub range: Range<usize>,
}

/// Walks `text` and yields each `[[wikilink]]` / `[label](target)` it contains,
/// in source order.
///
/// Links whose target is empty (or whitespace-only) are skipped, and `[[wiki]]`
/// is matched before `[label](target)` at each position. All delimiters are
/// ASCII, so multi-byte UTF-8 content in labels and targets is handled
/// correctly.
///
/// A `[label](target)` target may contain parentheses as long as they balance;
/// an unbalanced `(` yields no link at that position. Backslash escapes are not
/// interpreted.
pub fn scan_inline_links(text: &str) -> InlineLinks<'_> {
    InlineLinks { text, i: 0 }
}

/// Iterator returned by [`scan_inline_links`].
pub struct InlineLinks<'a> {
    text: &'a str,
    i: usize,
}

impl<'a> Iterator for InlineLinks<'a> {
    type Item = InlineLink<'a>;

    fn next(&mut self) -> Option<Self::Item> {
        let bytes = self.text.as_bytes();
        while self.i < bytes.len() {
            let i = self.i;
            // [[wikilink]]
            if i + 1 < bytes.len()
                && bytes[i] == b'['
                && bytes[i + 1] == b'['
                && let Some(end) = find_closing_double_bracket(bytes, i + 2)
            {
                let target = self.text[i + 2..end].trim();
                if !target.is_empty() {
                    self.i = end + 2;
                    return Some(InlineLink {
                        kind: LinkKind::Wiki,
                        label: target,
                        target,
                        range: i..end + 2,
                    });
                }
            }
            // [label](target)
            if bytes[i] == b'['
                && let Some(label_end) = find_byte(bytes, b']', i + 1)
                && label_end + 1 < bytes.len()
                && bytes[label_end + 1] == b'('
                && let Some(target_end) = find_balanced_close_paren(bytes, label_end + 2)
            {
                let target = self.text[label_end + 2..target_end].trim();
                if !target.is_empty() {
                    self.i = target_end + 1;
                    return Some(InlineLink {
                        kind: LinkKind::Markdown,
                        label: &self.text[i + 1..label_end],
                        target,
                        range: i..target_end + 1,
                    });
                }
            }
            self.i += 1;
        }
        None
    }
}

/// Rewrites the inline links in `text`, leaving everything else byte-for-byte
/// intact.
///
/// For each link, `rewrite(kind, target)` may return `Some(new_target)` to
/// replace it — rendered as `[label](new_target)`, where wiki links use their
/// target as the label — or `None` to leave the original span verbatim
/// (preserving any internal whitespace).
pub fn rewrite_inline_links(
    text: &str,
    mut rewrite: impl FnMut(LinkKind, &str) -> Option<String>,
) -> String {
    let mut out = String::with_capacity(text.len());
    let mut cursor = 0;
    for link in scan_inline_links(text) {
        out.push_str(&text[cursor..link.range.start]);
        match rewrite(link.kind, link.target) {
            Some(new_target) => {
                out.push('[');
                out.push_str(link.label);
                out.push_str("](");
                out.push_str(&new_target);
                out.push(')');
            }
            None => out.push_str(&text[link.range.clone()]),
        }
        cursor = link.range.end;
    }
    out.push_str(&text[cursor..]);
    out
}

fn find_closing_double_bracket(bytes: &[u8], start: usize) -> Option<usize> {
    let mut i = start;
    while i + 1 < bytes.len() {
        if bytes[i] == b']' && bytes[i + 1] == b']' {
            return Some(i);
        }
        i += 1;
    }
    None
}

fn find_byte(bytes: &[u8], target: u8, start: usize) -> Option<usize> {
    (start..bytes.len()).find(|&i| bytes[i] == target)
}

fn find_balanced_close_paren(bytes: &[u8], start: usize) -> Option<usize> {
    let mut depth = 0usize;
    for (i, &byte) in bytes.iter().enumerate().skip(start) {
        match byte {
            b'(' => depth += 1,
            b')' if depth == 0 => return Some(i),
            b')' => depth -= 1,
            _ => {}
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    fn scan(text: &str) -> Vec<InlineLink<'_>> {
        scan_inline_links(text).collect()
    }

    #[test]
    fn scans_wiki_kind_label_target_range() {
        let links = scan("see [[My Page]] here");
        assert_eq!(links.len(), 1);
        let link = &links[0];
        assert_eq!(link.kind, LinkKind::Wiki);
        assert_eq!(link.label, "My Page");
        assert_eq!(link.target, "My Page");
        // "see " is 4 bytes; "[[My Page]]" is 11 bytes
        assert_eq!(link.range, 4..15);
        assert_eq!(&"see [[My Page]] here"[link.range.clone()], "[[My Page]]");
    }

    #[test]
    fn scans_markdown_kind_label_target_range() {
        let links = scan("a [label](page:abc) b");
        assert_eq!(links.len(), 1);
        let link = &links[0];
        assert_eq!(link.kind, LinkKind::Markdown);
        assert_eq!(link.label, "label");
        assert_eq!(link.target, "page:abc");
        assert_eq!(link.range, 2..19);
        assert_eq!(
            &"a [label](page:abc) b"[link.range.clone()],
            "[label](page:abc)"
        );
    }

    #[test]
    fn scans_mixed_in_source_order() {
        let links = scan("see [[wiki]] and [md](target) done");
        assert_eq!(links.len(), 2);
        assert_eq!(links[0].kind, LinkKind::Wiki);
        assert_eq!(links[0].target, "wiki");
        assert_eq!(links[1].kind, LinkKind::Markdown);
        assert_eq!(links[1].label, "md");
        assert_eq!(links[1].target, "target");
    }

    #[test]
    fn target_is_trimmed_but_range_covers_whitespace() {
        let links = scan("[label](  url  )");
        assert_eq!(links.len(), 1);
        assert_eq!(links[0].target, "url");
        assert_eq!(links[0].range, 0..16);
    }

    #[test]
    fn empty_targets_are_skipped() {
        assert!(scan("[]()").is_empty());
        assert!(scan("[[]]").is_empty());
        assert!(scan("[label](   )").is_empty());
        assert!(scan("[[   ]]").is_empty());
    }

    #[test]
    fn wiki_takes_precedence_over_markdown() {
        // the `[[…]]` body itself contains `](`
        let links = scan("[[x](y)]]");
        assert_eq!(links.len(), 1);
        assert_eq!(links[0].kind, LinkKind::Wiki);
        assert_eq!(links[0].target, "x](y)");
    }

    #[test]
    fn unclosed_wiki_falls_through_to_markdown() {
        // No closing `]]`, so the leading `[` opens a markdown link instead.
        let links = scan("[[text](url)");
        assert_eq!(links.len(), 1);
        assert_eq!(links[0].kind, LinkKind::Markdown);
        assert_eq!(links[0].label, "[text");
        assert_eq!(links[0].target, "url");
    }

    #[test]
    fn markdown_target_keeps_balanced_parens() {
        let text = "see [Ruby](https://en.wikipedia.org/wiki/Ruby_(programming_language)) here";
        let links = scan(text);
        assert_eq!(links.len(), 1);
        assert_eq!(links[0].label, "Ruby");
        assert_eq!(
            links[0].target,
            "https://en.wikipedia.org/wiki/Ruby_(programming_language)"
        );
        assert_eq!(
            &text[links[0].range.clone()],
            "[Ruby](https://en.wikipedia.org/wiki/Ruby_(programming_language))"
        );
    }

    #[test]
    fn markdown_target_keeps_nested_parens() {
        let text = "[x](a(b(c)d)e)";
        let links = scan(text);
        assert_eq!(links.len(), 1);
        assert_eq!(links[0].target, "a(b(c)d)e");
        assert_eq!(links[0].range, 0..text.len());
    }

    #[test]
    fn unbalanced_open_paren_yields_no_link() {
        assert!(scan("[x](a(b)").is_empty());
        assert!(scan("[x](a(b").is_empty());
    }

    #[test]
    fn unbalanced_target_does_not_swallow_a_later_link() {
        let links = scan("[a](un(closed and [b](ok)");
        assert_eq!(links.len(), 1);
        assert_eq!(links[0].label, "b");
        assert_eq!(links[0].target, "ok");
    }

    #[test]
    fn close_paren_after_a_link_is_not_part_of_the_target() {
        let links = scan("(see [a](b) too)");
        assert_eq!(links.len(), 1);
        assert_eq!(links[0].target, "b");
        assert_eq!(links[0].range, 5..11);
    }

    #[test]
    fn unicode_content_in_labels_and_targets() {
        let links = scan("[名前](ページ) [[日本語]]");
        assert_eq!(links.len(), 2);
        assert_eq!(links[0].target, "ページ");
        assert_eq!(links[1].target, "日本語");
    }

    #[test]
    fn rewrite_replaces_with_callback_target() {
        let out = rewrite_inline_links("see [a](b) and [[c]]", |kind, target| match kind {
            LinkKind::Markdown => Some(format!("md:{target}")),
            LinkKind::Wiki => Some(format!("wiki:{target}")),
        });
        assert_eq!(out, "see [a](md:b) and [c](wiki:c)");
    }

    #[test]
    fn rewrite_none_leaves_span_verbatim() {
        // Whitespace inside an untouched span is preserved exactly.
        let out = rewrite_inline_links("[a](  keep  ) and [[ keep ]]", |_, _| None);
        assert_eq!(out, "[a](  keep  ) and [[ keep ]]");
    }

    #[test]
    fn rewrite_callback_sees_kind_and_trimmed_target() {
        let mut seen = Vec::new();
        let _ = rewrite_inline_links("[a]( b ) [[ c ]]", |kind, target| {
            seen.push((kind, target.to_string()));
            None
        });
        assert_eq!(
            seen,
            vec![
                (LinkKind::Markdown, "b".to_string()),
                (LinkKind::Wiki, "c".to_string()),
            ]
        );
    }

    #[test]
    fn rewrite_replaces_a_paren_url_without_orphaning_a_tail() {
        let text = "see [Ruby](https://en.wikipedia.org/wiki/Ruby_(programming_language)) here";
        let mut seen = Vec::new();
        let out = rewrite_inline_links(text, |_, target| {
            seen.push(target.to_string());
            Some("page:abc".to_string())
        });
        assert_eq!(
            seen,
            vec!["https://en.wikipedia.org/wiki/Ruby_(programming_language)"]
        );
        assert_eq!(out, "see [Ruby](page:abc) here");
    }

    #[test]
    fn rewrite_is_identity_on_plain_text() {
        let out = rewrite_inline_links("no links here at all", |_, _| Some("x".to_string()));
        assert_eq!(out, "no links here at all");
    }
}