Skip to main content

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}