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    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}