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 fn units(picture_body: &str) -> String {
189 let input = format!("\\begin{{tikzpicture}}\n{picture_body}\n\\end{{tikzpicture}}\n");
190 let parsed = parse(&input);
191 assert_eq!(parsed.syntax().to_string(), input, "losslessness");
192 let stmt: SyntaxNode = parsed
193 .syntax()
194 .descendants()
195 .find(|n| n.kind() == SyntaxKind::STATEMENT)
196 .expect("a STATEMENT node");
197 let elements: Vec<SyntaxElement> = stmt.children_with_tokens().collect();
198 let glue = statement_glue(&elements);
199 let mut out = String::new();
200 let mut pending_gap = false;
201 for (idx, element) in elements.iter().enumerate() {
202 if matches!(element.kind(), SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE) {
203 pending_gap = true;
204 continue;
205 }
206 if pending_gap {
207 out.push(if glue[idx] { '·' } else { '|' });
208 pending_gap = false;
209 }
210 out.push_str(&element.to_string().replace('\n', ""));
211 }
212 out
213 }
214
215 #[test]
216 fn an_operator_binds_forward_and_a_break_lands_before_it() {
217 assert_eq!(
218 units(r"\draw (0,0) -- (1,1) -- cycle;"),
219 r"\draw|(0,0)|--·(1,1)|--·cycle;"
220 );
221 }
222
223 #[test]
224 fn a_coordinate_binds_its_operation_and_the_operation_its_argument() {
225 assert_eq!(
226 units(r"\draw (6,6) circle (3);"),
227 r"\draw|(6,6)·circle·(3);"
228 );
229 }
230
231 #[test]
232 fn at_binds_both_sides() {
233 assert_eq!(
234 units(r"\node (D) at (0,0) {A};"),
235 r"\node|(D)·at·(0,0)|{A};"
236 );
237 }
238
239 #[test]
240 fn a_mid_path_node_and_its_label_are_one_unit() {
241 assert_eq!(
242 units(r"\draw (0,0) -- (2,2) node {above};"),
243 r"\draw|(0,0)|--·(2,2)·node·{above};"
244 );
245 }
246
247 #[test]
248 fn a_controls_clause_chains_and_the_break_stays_before_the_operator() {
249 assert_eq!(
250 units(r"\draw (0,0) .. controls (1,1) and (2,0) .. (3,0);"),
251 r"\draw|(0,0)|..·controls·(1,1)·and·(2,0)|..·(3,0);"
252 );
253 }
254
255 #[test]
256 fn relative_coordinates_are_coordinate_shaped() {
257 assert_eq!(
258 units(r"\draw (0,0) -- ++(1,0) circle (2pt);"),
259 r"\draw|(0,0)|--·++(1,0)·circle·(2pt);"
260 );
261 }
262
263 #[test]
264 fn a_comment_suppresses_glue_on_both_sides() {
265 assert_eq!(
266 units("\\draw (0,0) -- % note\n(1,1);"),
267 r"\draw|(0,0)|--|% note|(1,1);"
268 );
269 }
270
271 #[test]
272 fn unrecognized_vocabulary_stays_neutral() {
273 assert_eq!(
274 units(r"\legend{a} extra words here;"),
275 r"\legend{a}|extra|words|here;"
276 );
277 }
278
279 #[test]
280 fn an_options_bracket_run_is_one_unit() {
281 assert_eq!(
282 units(r"\path (A) edge [loop above, red] node {x} (B);"),
283 r"\path|(A)·edge·[loop·above,|red]|node·{x}|(B);"
284 );
285 }
286
287 #[test]
288 fn a_multi_token_coordinate_tail_still_binds_its_operation() {
289 assert_eq!(
290 units(r"\fill (\point) circle [radius=2pt];"),
291 r"\fill|(\point)·circle·[radius=2pt];"
292 );
293 }
294
295 #[test]
296 fn a_source_glued_pair_needs_no_verdict() {
297 assert_eq!(units(r"\draw (0,0)--(1,1);"), r"\draw|(0,0)--(1,1);");
298 }
299}