1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
//! Typst parse-time diagnostics. Phase 1 of the typst-as-library
//! plan (1.2.5+).
//!
//! Pulls in `typst-syntax` only — no eval, no layout, no render,
//! no fonts, no package resolver. Gives us "is this even valid
//! Typst?" at the source level so the editor can surface a parse
//! error at the line where it lives, without spawning a child
//! `typst compile` process.
//!
//! The eventual Phase 4 swap (in-process compile + PDF emit gated
//! behind `typst.engine = "inprocess"`) lives separately; this
//! module is intentionally the smallest possible step on that
//! path.
use typst_syntax::Source;
/// One parse-time diagnostic, anchored at a specific position in
/// the source buffer.
///
/// `line` and `col` are **1-based** so they match how the editor
/// pane and human-facing status messages talk about positions
/// elsewhere in inkhaven. `byte_start` / `byte_end` are 0-based
/// byte offsets in the original source (useful if a future
/// caller wants to highlight the exact span).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypstDiagnostic {
pub line: usize,
pub col: usize,
pub byte_start: usize,
pub byte_end: usize,
pub message: String,
pub hints: Vec<String>,
}
impl TypstDiagnostic {
/// One-line, human-readable summary. Used for status-bar
/// messages and the `inkhaven` log output.
pub fn summary(&self) -> String {
format!("typst: line {}:{} — {}", self.line, self.col, self.message)
}
}
/// Parse `source` and return every syntactic error the typst
/// parser found. An empty vec means the buffer parses cleanly —
/// no statement about whether the document would actually
/// *compile* (no eval / layout / typst-stdlib lookup is run);
/// it just says the grammar is satisfied.
///
/// `source` is passed by reference but `Source::detached` takes
/// ownership of a `String`, so we copy. Buffers are typically
/// a few KB to a few hundred KB; the cost is dominated by the
/// parser itself, not the clone.
pub fn check(source: &str) -> Vec<TypstDiagnostic> {
let source = Source::detached(source.to_owned());
let root = source.root();
let errors = root.errors();
if errors.is_empty() {
return Vec::new();
}
let lines = source.lines();
let mut out = Vec::with_capacity(errors.len());
for err in errors {
let range = match source.range(err.span) {
Some(r) => r,
None => continue, // detached / synthetic span — skip
};
let (line0, col0) = lines
.byte_to_line_column(range.start)
.unwrap_or((0, 0));
out.push(TypstDiagnostic {
line: line0 + 1,
col: col0 + 1,
byte_start: range.start,
byte_end: range.end,
message: err.message.to_string(),
hints: err.hints.iter().map(|h| h.to_string()).collect(),
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_buffer_is_clean() {
assert!(check("").is_empty());
}
#[test]
fn plain_prose_is_clean() {
let src = "The storm came up at three.\n\nThe sea kept rising.\n";
assert!(check(src).is_empty(), "got: {:?}", check(src));
}
#[test]
fn well_formed_heading_is_clean() {
let src = "= Chapter one\n\nThe storm came up at three.\n";
assert!(check(src).is_empty(), "got: {:?}", check(src));
}
#[test]
fn unterminated_string_is_an_error() {
// Code-mode string literal that never closes — the parser
// should emit an error at the opening quote.
let src = r#"#let x = "hello
broken
"#;
let diags = check(src);
assert!(!diags.is_empty(), "expected at least one diagnostic");
let first = &diags[0];
assert!(first.line >= 1);
assert!(first.col >= 1);
// Sanity: message should be non-empty.
assert!(!first.message.is_empty());
}
#[test]
fn unbalanced_brace_reports_a_position() {
// Open brace in code mode, no close.
let src = "#let f() = {\n 1 + 1\n";
let diags = check(src);
assert!(!diags.is_empty());
// Every diagnostic must have a valid (line, col) pair.
for d in &diags {
assert!(d.line >= 1, "line was {}", d.line);
assert!(d.col >= 1, "col was {}", d.col);
assert!(
d.byte_end >= d.byte_start,
"byte range must be non-negative",
);
}
}
#[test]
fn summary_contains_line_and_message() {
let d = TypstDiagnostic {
line: 12,
col: 5,
byte_start: 100,
byte_end: 110,
message: "unexpected token".to_owned(),
hints: vec![],
};
let s = d.summary();
assert!(s.contains("line 12:5"));
assert!(s.contains("unexpected token"));
}
use proptest::prelude::*;
proptest! {
/// 1.3.36 hardening — `check` parses arbitrary editor source
/// (the user's prose + Typst markup). It must return a
/// diagnostics Vec and never panic, including on lone
/// surrogates-free Unicode, unbalanced delimiters, and the
/// byte-offset → line/col mapping over multibyte input.
#[test]
fn check_never_panics(src in "\\PC{0,400}") {
let _ = check(&src);
}
/// Token-salad of Typst markup delimiters + prose — exercises
/// the parser's bracket/brace/dollar paths past what random
/// printable strings reach.
#[test]
fn check_never_panics_on_markup_salad(
toks in proptest::collection::vec(
proptest::sample::select(vec![
"$", "#", "[", "]", "{", "}", "(", ")", "*", "_", "=",
"\\", "/*", "*/", "let", "x", " ", "\n", "café", "—",
]),
0..200,
),
) {
let _ = check(&toks.concat());
}
}
}