Skip to main content

gram_data/
markdown.rs

1use crate::analyze;
2use crate::lint::LintOptions;
3use gram_diagnostics::Diagnostic;
4
5pub struct Snippet {
6    /// The extracted gram source text (fence delimiters excluded).
7    pub source: String,
8    /// 0-based line number of the opening fence (` ```gram `) in the host document.
9    pub fence_start_line: usize,
10}
11
12/// Extract all ` ```gram ` fenced blocks from a Markdown document.
13pub fn extract_snippets(doc_source: &str) -> Vec<Snippet> {
14    let mut snippets = Vec::new();
15    let mut in_fence = false;
16    let mut fence_start = 0usize;
17    let mut buf = String::new();
18
19    for (line_no, line) in doc_source.lines().enumerate() {
20        let trimmed = line.trim();
21        if !in_fence {
22            // Opening fence: ```gram or ```gram followed by optional whitespace
23            if is_gram_fence_open(trimmed) {
24                in_fence = true;
25                fence_start = line_no;
26                buf.clear();
27            }
28        } else {
29            // Closing fence: a line of only backticks (3+)
30            if is_fence_close(trimmed) {
31                snippets.push(Snippet { source: buf.clone(), fence_start_line: fence_start });
32                in_fence = false;
33            } else {
34                buf.push_str(line);
35                buf.push('\n');
36            }
37        }
38    }
39    snippets
40}
41
42/// Lint all ` ```gram ` snippets in a Markdown document.
43///
44/// Returns one `(Snippet, diagnostics)` pair per fenced block. Diagnostic ranges
45/// are relative to the snippet source. To map back to host-document line numbers
46/// add `snippet.fence_start_line + 1` to each diagnostic's line values.
47pub fn lint_markdown(doc_source: &str, _opts: &LintOptions) -> Vec<(Snippet, Vec<Diagnostic>)> {
48    extract_snippets(doc_source)
49        .into_iter()
50        .map(|snippet| {
51            let (_, raw) = analyze::analyze_source(&snippet.source);
52            let diags = raw.iter().map(|d| analyze::to_public(&snippet.source, d)).collect();
53            (snippet, diags)
54        })
55        .collect()
56}
57
58fn is_gram_fence_open(trimmed: &str) -> bool {
59    // Match ```gram or ~~~gram (with optional trailing whitespace)
60    for prefix in ["```", "~~~"] {
61        if let Some(rest) = trimmed.strip_prefix(prefix) {
62            let lang = rest.trim();
63            if lang.eq_ignore_ascii_case("gram") {
64                return true;
65            }
66        }
67    }
68    false
69}
70
71fn is_fence_close(trimmed: &str) -> bool {
72    (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
73        && trimmed.chars().all(|c| c == '`' || c == '~')
74        && trimmed.len() >= 3
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn extracts_gram_fence() {
83        let md = "# Hello\n\n```gram\n(a)-[:KNOWS]->(b)\n```\n";
84        let snippets = extract_snippets(md);
85        assert_eq!(snippets.len(), 1);
86        assert_eq!(snippets[0].source.trim(), "(a)-[:KNOWS]->(b)");
87        assert_eq!(snippets[0].fence_start_line, 2);
88    }
89
90    #[test]
91    fn ignores_non_gram_fences() {
92        let md = "```cypher\nMATCH (n) RETURN n\n```\n\n```gram\n(a)\n```\n";
93        let snippets = extract_snippets(md);
94        assert_eq!(snippets.len(), 1);
95        assert_eq!(snippets[0].source.trim(), "(a)");
96    }
97
98    #[test]
99    fn multiple_fences() {
100        let md = "```gram\n(a)\n```\n\nSome text.\n\n```gram\n(b)\n```\n";
101        let snippets = extract_snippets(md);
102        assert_eq!(snippets.len(), 2);
103        assert_eq!(snippets[0].source.trim(), "(a)");
104        assert_eq!(snippets[1].source.trim(), "(b)");
105    }
106
107    #[test]
108    fn lint_valid_snippet_no_diagnostics() {
109        let md = "```gram\n(alice)-[:KNOWS]->(bob)\n```\n";
110        let results = lint_markdown(md, &LintOptions { strict: false });
111        assert_eq!(results.len(), 1);
112        assert!(results[0].1.is_empty());
113    }
114
115    #[test]
116    fn lint_invalid_snippet_has_diagnostics() {
117        let md = "```gram\n(((\n```\n";
118        let results = lint_markdown(md, &LintOptions { strict: false });
119        assert_eq!(results.len(), 1);
120        assert!(!results[0].1.is_empty());
121    }
122}