use crate::syntax::{SyntaxElement, SyntaxKind};
fn is_path_operator(text: &str) -> bool {
matches!(text, "--" | "|-" | "-|" | "..")
}
fn is_operation_keyword(text: &str) -> bool {
matches!(
text,
"circle"
| "rectangle"
| "ellipse"
| "arc"
| "grid"
| "parabola"
| "sin"
| "cos"
| "plot"
| "coordinate"
| "node"
| "pic"
| "edge"
| "to"
| "controls"
| "and"
)
}
fn is_coordinate_shaped(text: &str) -> bool {
let bare = text
.strip_prefix("++")
.or_else(|| text.strip_prefix('+'))
.unwrap_or(text);
bare.starts_with('(') || bare.ends_with(')')
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum UnitPart {
Operator,
At,
Operation,
Coordinate,
Comment,
Other,
}
fn classify(element: &SyntaxElement) -> UnitPart {
match element {
SyntaxElement::Token(token) => match token.kind() {
SyntaxKind::COMMENT => UnitPart::Comment,
SyntaxKind::WORD => {
let text = token.text();
if is_path_operator(text) {
UnitPart::Operator
} else if text == "at" {
UnitPart::At
} else if is_operation_keyword(text) {
UnitPart::Operation
} else if is_coordinate_shaped(text) {
UnitPart::Coordinate
} else {
UnitPart::Other
}
}
_ => UnitPart::Other,
},
SyntaxElement::Node(_) => UnitPart::Other,
}
}
pub fn statement_glue(elements: &[SyntaxElement]) -> Vec<bool> {
let mut glue = vec![false; elements.len()];
let mut prev: Option<UnitPart> = None;
let mut prev_ends_comma = false;
let mut saw_gap = false;
let mut bracket_depth = 0usize;
for (idx, element) in elements.iter().enumerate() {
if matches!(element.kind(), SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE) {
saw_gap = true;
continue;
}
let part = classify(element);
if let Some(prev) = prev
&& saw_gap
&& prev != UnitPart::Comment
&& part != UnitPart::Comment
{
glue[idx] = (bracket_depth > 0 && !prev_ends_comma)
|| matches!(
prev,
UnitPart::Operator | UnitPart::At | UnitPart::Operation
)
|| part == UnitPart::At
|| (prev == UnitPart::Coordinate && part == UnitPart::Operation);
}
match element.kind() {
SyntaxKind::L_BRACKET => bracket_depth += 1,
SyntaxKind::R_BRACKET => bracket_depth = bracket_depth.saturating_sub(1),
_ => {}
}
prev_ends_comma = element
.as_token()
.is_some_and(|token| token.text().ends_with(','));
prev = Some(part);
saw_gap = false;
}
glue
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
use crate::syntax::SyntaxNode;
fn units(picture_body: &str) -> String {
let input = format!("\\begin{{tikzpicture}}\n{picture_body}\n\\end{{tikzpicture}}\n");
let parsed = parse(&input);
assert_eq!(parsed.syntax().to_string(), input, "losslessness");
let stmt: SyntaxNode = parsed
.syntax()
.descendants()
.find(|n| n.kind() == SyntaxKind::STATEMENT)
.expect("a STATEMENT node");
let elements: Vec<SyntaxElement> = stmt.children_with_tokens().collect();
let glue = statement_glue(&elements);
let mut out = String::new();
let mut pending_gap = false;
for (idx, element) in elements.iter().enumerate() {
if matches!(element.kind(), SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE) {
pending_gap = true;
continue;
}
if pending_gap {
out.push(if glue[idx] { '·' } else { '|' });
pending_gap = false;
}
out.push_str(&element.to_string().replace('\n', ""));
}
out
}
#[test]
fn an_operator_binds_forward_and_a_break_lands_before_it() {
assert_eq!(
units(r"\draw (0,0) -- (1,1) -- cycle;"),
r"\draw|(0,0)|--·(1,1)|--·cycle;"
);
}
#[test]
fn a_coordinate_binds_its_operation_and_the_operation_its_argument() {
assert_eq!(
units(r"\draw (6,6) circle (3);"),
r"\draw|(6,6)·circle·(3);"
);
}
#[test]
fn at_binds_both_sides() {
assert_eq!(
units(r"\node (D) at (0,0) {A};"),
r"\node|(D)·at·(0,0)|{A};"
);
}
#[test]
fn a_mid_path_node_and_its_label_are_one_unit() {
assert_eq!(
units(r"\draw (0,0) -- (2,2) node {above};"),
r"\draw|(0,0)|--·(2,2)·node·{above};"
);
}
#[test]
fn a_controls_clause_chains_and_the_break_stays_before_the_operator() {
assert_eq!(
units(r"\draw (0,0) .. controls (1,1) and (2,0) .. (3,0);"),
r"\draw|(0,0)|..·controls·(1,1)·and·(2,0)|..·(3,0);"
);
}
#[test]
fn relative_coordinates_are_coordinate_shaped() {
assert_eq!(
units(r"\draw (0,0) -- ++(1,0) circle (2pt);"),
r"\draw|(0,0)|--·++(1,0)·circle·(2pt);"
);
}
#[test]
fn a_comment_suppresses_glue_on_both_sides() {
assert_eq!(
units("\\draw (0,0) -- % note\n(1,1);"),
r"\draw|(0,0)|--|% note|(1,1);"
);
}
#[test]
fn unrecognized_vocabulary_stays_neutral() {
assert_eq!(
units(r"\legend{a} extra words here;"),
r"\legend{a}|extra|words|here;"
);
}
#[test]
fn an_options_bracket_run_is_one_unit() {
assert_eq!(
units(r"\path (A) edge [loop above, red] node {x} (B);"),
r"\path|(A)·edge·[loop·above,|red]|node·{x}|(B);"
);
}
#[test]
fn a_multi_token_coordinate_tail_still_binds_its_operation() {
assert_eq!(
units(r"\fill (\point) circle [radius=2pt];"),
r"\fill|(\point)·circle·[radius=2pt];"
);
}
#[test]
fn a_source_glued_pair_needs_no_verdict() {
assert_eq!(units(r"\draw (0,0)--(1,1);"), r"\draw|(0,0)--(1,1);");
}
}