badness_parser/semantic/tikz.rs
1//! The TikZ/pgf statement *unit model*: which gaps inside a picture-body
2//! `STATEMENT` are unit-internal and must never become width-break
3//! opportunities.
4//!
5//! The parser's `STATEMENT` node owns a statement's *extent* (everything up to
6//! the top-level `;`), which is all the boundary layout and the continuation
7//! hang need. What extent cannot give is good breaks *inside* a statement: the
8//! interior is an undifferentiated atom stream, so a width fill would happily
9//! break a coordinate from its operation (`\draw (6,6)` / `circle (3);`) or an
10//! `at` from its coordinate. These are vocabulary-dependent relationships, so
11//! they belong in the semantic layer rather than the grammar. A missed or
12//! incorrect relationship can affect line breaking, but never the syntax tree.
13//!
14//! The model is deliberately a **glue map, not a grammar**: for each authored
15//! gap between a statement's top-level elements, one verdict — unit-internal
16//! (render as a single space, never break) or neutral (an ordinary break
17//! opportunity). Everything unrecognized is neutral. All
18//! reads are non-trivia token text, so the verdicts are Tier 1 by
19//! construction: no trivia predicate is consulted, and a width wrap re-derives
20//! the same units on every pass.
21
22use crate::syntax::{SyntaxElement, SyntaxKind};
23
24/// Path operators: a break lands *before* one (the idiomatic continuation
25/// lead), and the operator binds **forward** to whatever it connects.
26fn is_path_operator(text: &str) -> bool {
27 matches!(text, "--" | "|-" | "-|" | "..")
28}
29
30/// Path operation and connective keywords that bind **forward** to their
31/// argument (`circle (1)`, `node {A}`, `to [out=90]`, `controls (a)`), and
32/// which a preceding coordinate binds **to** (`(6,6) circle` never splits).
33///
34/// Curated, and deliberately small: these are pgf's core path vocabulary, the
35/// words whose split from their neighbors reads as broken TikZ. A library verb
36/// not listed here degrades to a neutral gap — today's layout — which is the
37/// admission bargain of keeping the model semantic-side.
38fn is_operation_keyword(text: &str) -> bool {
39 matches!(
40 text,
41 "circle"
42 | "rectangle"
43 | "ellipse"
44 | "arc"
45 | "grid"
46 | "parabola"
47 | "sin"
48 | "cos"
49 | "plot"
50 | "coordinate"
51 | "node"
52 | "pic"
53 | "edge"
54 | "to"
55 | "controls"
56 | "and"
57 )
58}
59
60/// A coordinate-shaped word: `(0,0)`, `(a.north)`, `(3);` (the terminator rides
61/// the word), the relative forms `+(1,0)` / `++(1,0)`, or a coordinate's
62/// closing tail — a word ending in `)`, which is how a multi-token coordinate
63/// (`(\point)`, where `\point` lexes as its own `CONTROL_WORD`) presents its
64/// last element. A cheap shape test, not a coordinate parser — it exists only
65/// to decide whether a following operation keyword belongs to this word, so
66/// only the *preceding* side of a gap ever consults it.
67fn is_coordinate_shaped(text: &str) -> bool {
68 let bare = text
69 .strip_prefix("++")
70 .or_else(|| text.strip_prefix('+'))
71 .unwrap_or(text);
72 bare.starts_with('(') || bare.ends_with(')')
73}
74
75/// What the glue rules need to know about one non-trivia element.
76#[derive(Clone, Copy, PartialEq, Eq)]
77enum UnitPart {
78 /// `--`, `|-`, `-|`, `..` — binds forward.
79 Operator,
80 /// `at` — binds both sides (`\node (D) at (0,0)` never splits around it).
81 At,
82 /// A curated operation/connective keyword — binds forward, and a
83 /// coordinate before it binds to it.
84 Operation,
85 /// A coordinate-shaped `WORD`.
86 Coordinate,
87 /// A `%` comment: never glued on either side — a comment must end its
88 /// line, so a glue verdict across one would be a promise the layout
89 /// cannot keep.
90 Comment,
91 /// Everything else: commands, groups, brackets, ordinary words.
92 Other,
93}
94
95fn classify(element: &SyntaxElement) -> UnitPart {
96 match element {
97 SyntaxElement::Token(token) => match token.kind() {
98 SyntaxKind::COMMENT => UnitPart::Comment,
99 SyntaxKind::WORD => {
100 let text = token.text();
101 if is_path_operator(text) {
102 UnitPart::Operator
103 } else if text == "at" {
104 UnitPart::At
105 } else if is_operation_keyword(text) {
106 UnitPart::Operation
107 } else if is_coordinate_shaped(text) {
108 UnitPart::Coordinate
109 } else {
110 UnitPart::Other
111 }
112 }
113 _ => UnitPart::Other,
114 },
115 SyntaxElement::Node(_) => UnitPart::Other,
116 }
117}
118
119/// Per-element glue verdicts for a `STATEMENT`'s top-level element stream.
120///
121/// `glue_before[i]` is `true` when the authored gap immediately before
122/// `elements[i]` is unit-internal: the formatter renders it as a single space
123/// and never breaks there. Entries for trivia elements, and for elements with
124/// no gap before them (glued in the source — adjacency already forms one
125/// atom), are `false` and meaningless.
126///
127/// The rules, each backed by the corpus survey:
128///
129/// - a path **operator binds forward** (`-- (1,1)` is one unit; the break
130/// point is *before* the operator);
131/// - **`at` binds both sides** (`at` is essentially never split from its
132/// coordinate in the wild);
133/// - an **operation keyword binds forward** to its argument
134/// (`circle (1)`, `node {A}`, `controls (a)`);
135/// - a **coordinate binds to a following operation keyword**
136/// (`(6,6) circle (3)` — the split the model exists to forbid);
137/// - inside a **loose bracket run** (a statement-level `[`…`]`, an options
138/// list) a gap glues unless it follows a comma: `edge [loop above]` never
139/// splits an option mid-phrase, while a long keyval run still breaks at its
140/// entry boundaries — the comma convention every keyval layout here uses.
141///
142/// A comment on either side of a gap suppresses every rule.
143pub fn statement_glue(elements: &[SyntaxElement]) -> Vec<bool> {
144 let mut glue = vec![false; elements.len()];
145 let mut prev: Option<UnitPart> = None;
146 let mut prev_ends_comma = false;
147 let mut saw_gap = false;
148 let mut bracket_depth = 0usize;
149 for (idx, element) in elements.iter().enumerate() {
150 if matches!(element.kind(), SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE) {
151 saw_gap = true;
152 continue;
153 }
154 let part = classify(element);
155 if let Some(prev) = prev
156 && saw_gap
157 && prev != UnitPart::Comment
158 && part != UnitPart::Comment
159 {
160 glue[idx] = (bracket_depth > 0 && !prev_ends_comma)
161 || matches!(
162 prev,
163 UnitPart::Operator | UnitPart::At | UnitPart::Operation
164 )
165 || part == UnitPart::At
166 || (prev == UnitPart::Coordinate && part == UnitPart::Operation);
167 }
168 match element.kind() {
169 SyntaxKind::L_BRACKET => bracket_depth += 1,
170 SyntaxKind::R_BRACKET => bracket_depth = bracket_depth.saturating_sub(1),
171 _ => {}
172 }
173 prev_ends_comma = element
174 .as_token()
175 .is_some_and(|token| token.text().ends_with(','));
176 prev = Some(part);
177 saw_gap = false;
178 }
179 glue
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use crate::parser::parse;
186 use crate::syntax::SyntaxNode;
187
188 /// The statement's element stream, with each glued gap rendered as `·`
189 /// and each neutral gap as `|` — a direct projection of the verdicts.
190 fn units(picture_body: &str) -> String {
191 let input = format!("\\begin{{tikzpicture}}\n{picture_body}\n\\end{{tikzpicture}}\n");
192 let parsed = parse(&input);
193 assert_eq!(parsed.syntax().to_string(), input, "losslessness");
194 let stmt: SyntaxNode = parsed
195 .syntax()
196 .descendants()
197 .find(|n| n.kind() == SyntaxKind::STATEMENT)
198 .expect("a STATEMENT node");
199 let elements: Vec<SyntaxElement> = stmt.children_with_tokens().collect();
200 let glue = statement_glue(&elements);
201 let mut out = String::new();
202 let mut pending_gap = false;
203 for (idx, element) in elements.iter().enumerate() {
204 if matches!(element.kind(), SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE) {
205 pending_gap = true;
206 continue;
207 }
208 if pending_gap {
209 out.push(if glue[idx] { '·' } else { '|' });
210 pending_gap = false;
211 }
212 out.push_str(&element.to_string().replace('\n', ""));
213 }
214 out
215 }
216
217 #[test]
218 fn an_operator_binds_forward_and_a_break_lands_before_it() {
219 assert_eq!(
220 units(r"\draw (0,0) -- (1,1) -- cycle;"),
221 r"\draw|(0,0)|--·(1,1)|--·cycle;"
222 );
223 }
224
225 #[test]
226 fn a_coordinate_binds_its_operation_and_the_operation_its_argument() {
227 assert_eq!(
228 units(r"\draw (6,6) circle (3);"),
229 r"\draw|(6,6)·circle·(3);"
230 );
231 }
232
233 #[test]
234 fn at_binds_both_sides() {
235 assert_eq!(
236 units(r"\node (D) at (0,0) {A};"),
237 r"\node|(D)·at·(0,0)|{A};"
238 );
239 }
240
241 #[test]
242 fn a_mid_path_node_and_its_label_are_one_unit() {
243 assert_eq!(
244 units(r"\draw (0,0) -- (2,2) node {above};"),
245 r"\draw|(0,0)|--·(2,2)·node·{above};"
246 );
247 }
248
249 #[test]
250 fn a_controls_clause_chains_and_the_break_stays_before_the_operator() {
251 assert_eq!(
252 units(r"\draw (0,0) .. controls (1,1) and (2,0) .. (3,0);"),
253 r"\draw|(0,0)|..·controls·(1,1)·and·(2,0)|..·(3,0);"
254 );
255 }
256
257 #[test]
258 fn relative_coordinates_are_coordinate_shaped() {
259 assert_eq!(
260 units(r"\draw (0,0) -- ++(1,0) circle (2pt);"),
261 r"\draw|(0,0)|--·++(1,0)·circle·(2pt);"
262 );
263 }
264
265 #[test]
266 fn a_comment_suppresses_glue_on_both_sides() {
267 // A comment must end its line, so no unit may claim to span one.
268 assert_eq!(
269 units("\\draw (0,0) -- % note\n(1,1);"),
270 r"\draw|(0,0)|--|% note|(1,1);"
271 );
272 }
273
274 #[test]
275 fn unrecognized_vocabulary_stays_neutral() {
276 // An axis-body statement with prose-ish words: no rule fires except
277 // the curated `and`, and a wrong guess is only a bigger atom.
278 assert_eq!(
279 units(r"\legend{a} extra words here;"),
280 r"\legend{a}|extra|words|here;"
281 );
282 }
283
284 #[test]
285 fn an_options_bracket_run_is_one_unit() {
286 // `[loop above]` is an options list: a break inside it splits an
287 // option mid-phrase (vassar.tex's automaton `edge [loop above]`).
288 assert_eq!(
289 units(r"\path (A) edge [loop above, red] node {x} (B);"),
290 r"\path|(A)·edge·[loop·above,|red]|node·{x}|(B);"
291 );
292 }
293
294 #[test]
295 fn a_multi_token_coordinate_tail_still_binds_its_operation() {
296 // `(\point)` lexes as three tokens; the closing `)` word is the
297 // coordinate's tail, and `circle` still belongs to it (Euclid's
298 // `\fill [black] (\point) circle [radius=2pt];`).
299 assert_eq!(
300 units(r"\fill (\point) circle [radius=2pt];"),
301 r"\fill|(\point)·circle·[radius=2pt];"
302 );
303 }
304
305 #[test]
306 fn a_source_glued_pair_needs_no_verdict() {
307 // `(0,0)--(1,1)` lexes as one WORD: no gap, nothing to decide.
308 assert_eq!(units(r"\draw (0,0)--(1,1);"), r"\draw|(0,0)--(1,1);");
309 }
310}