Skip to main content

panproto_parse/emit_pretty/
layout.rs

1#![allow(
2    clippy::module_name_repetitions,
3    clippy::too_many_lines,
4    clippy::too_many_arguments,
5    clippy::map_unwrap_or,
6    clippy::option_if_let_else,
7    clippy::elidable_lifetime_names,
8    clippy::items_after_statements,
9    clippy::needless_pass_by_value,
10    clippy::single_match_else,
11    clippy::manual_let_else,
12    clippy::match_same_arms,
13    clippy::missing_const_for_fn,
14    clippy::single_char_pattern,
15    clippy::naive_bytecount,
16    clippy::expect_used,
17    clippy::redundant_pub_crate,
18    clippy::used_underscore_binding,
19    clippy::redundant_field_names,
20    clippy::struct_field_names,
21    clippy::redundant_else,
22    clippy::similar_names
23)]
24
25//! `emit_pretty::layout` (Phase A decomposition).
26
27use super::{Grammar, TokenRole, is_word_like};
28
29// ═══════════════════════════════════════════════════════════════════
30
31/// Whitespace and indentation policy applied during emission.
32///
33/// The default policy inserts a single space between adjacent tokens,
34/// a newline after `;` / `}` / `{`, and tracks indent on `{` / `}`
35/// boundaries. Per-language overrides (idiomatic indent width,
36/// trailing-comma rules, blank-line conventions) can ride alongside
37/// this struct in a follow-up branch; today's defaults aim only for
38/// syntactic validity.
39#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
40pub struct FormatPolicy {
41    /// Number of spaces per indent level.
42    pub indent_width: usize,
43    /// Separator inserted between adjacent terminals that the lexer
44    /// would otherwise glue together (word ↔ word, operator ↔ operator).
45    /// Default is a single space.
46    pub separator: String,
47    /// Newline byte sequence emitted after `line_break_after` tokens
48    /// and at end-of-output. Default is `"\n"`.
49    pub newline: String,
50    /// Tokens after which the walker breaks to a new line.
51    pub line_break_after: Vec<String>,
52    /// Tokens that increase indent on emission.
53    pub indent_open: Vec<String>,
54    /// Tokens that decrease indent on emission.
55    pub indent_close: Vec<String>,
56}
57
58impl Default for FormatPolicy {
59    fn default() -> Self {
60        Self {
61            indent_width: 2,
62            separator: " ".to_owned(),
63            newline: "\n".to_owned(),
64            line_break_after: vec![";".into(), "{".into(), "}".into()],
65            indent_open: vec!["{".into()],
66            indent_close: vec!["}".into()],
67        }
68    }
69}
70
71// ═══════════════════════════════════════════════════════════════════
72// Token list output with Spacing algebra
73// ═══════════════════════════════════════════════════════════════════
74//
75// Emit produces a free monoid over `Token`. Layout (spaces, newlines,
76// indentation) is a homomorphism `Vec<Token> -> Vec<u8>` parameterised
77// by `FormatPolicy`. Separating the structural output from the layout
78// decision means each phase has one job: emit walks the grammar and
79// pushes tokens; layout is a single fold, locally driven by adjacent
80// pairs and a depth counter. Snapshot/restore is just `tokens.len()`.
81
82#[derive(Clone)]
83pub(crate) enum Token {
84    /// A user-visible terminal contributed by the grammar, annotated
85    /// with its structural role for spacing decisions.
86    Lit(String, TokenRole),
87    /// `indent_open` marker emitted when a `Lit` matched the policy's
88    /// open list. Carried as a separate token so layout can decide to
89    /// break + indent without re-scanning.
90    IndentOpen,
91    /// `indent_close` marker emitted before a closer-`Lit`.
92    IndentClose,
93    /// "Break a line here if not already at line start" — used after
94    /// statements/declarations and after open braces.
95    LineBreak,
96    /// Force a space before the next Lit even if the role-pair table
97    /// says tight. Pushed between consecutive content-producing SEQ
98    /// members (e.g. between `command_name` and `argument`) to ensure
99    /// sibling-vertex tokens are separated.
100    ForceSpace,
101    /// Suppress the next inter-Lit separator. Pushed by the REPEAT
102    /// walker when an iteration's "separator slot" (a CHOICE-with-BLANK
103    /// or OPTIONAL at SEQ position 0) emitted zero content tokens, so
104    /// the categorical reading is "no source-level separator existed
105    /// between these two sibling iterations of the body".
106    NoSpace,
107    /// Guard emitted right after a greedy unbounded negated-class
108    /// terminal (`[^...]+`, e.g. HTML's unquoted `attribute_value`). The
109    /// carried string is the negated set's inner content. If the NEXT
110    /// `Lit` begins with a character that set ADMITS, the terminal would
111    /// swallow that character on re-parse (`Ok` + `/>` lexes as the value
112    /// `Ok/>`, turning a `self_closing_tag` into a `start_tag`), so the
113    /// layout fold forces a separator. Transparent otherwise.
114    AbsorberGuard(String),
115    /// Exact source bytes replayed from the layout complement
116    /// (`reconstruct_subtree_bytes`): a whole vertex subtree whose
117    /// `interstitial-N` / `literal-value` fibre tiled its byte span exactly.
118    /// The fold writes these bytes verbatim and inserts NO role-derived
119    /// separator on either side — the replayed text already carries its own
120    /// leading and trailing whitespace, so the byte-faithful path bypasses the
121    /// role table entirely. The carried bytes may contain newlines; they are
122    /// written through without disturbing the indent counter (the replay is
123    /// self-contained, including its own indentation).
124    Verbatim(String),
125}
126
127pub(crate) struct Output<'a> {
128    pub(crate) tokens: Vec<Token>,
129    pub(crate) policy: &'a FormatPolicy,
130    pub(crate) grammar: &'a Grammar,
131    pub(crate) current_rule: Option<String>,
132    pub(crate) cassette: Option<&'a dyn crate::languages::cassettes::GrammarCassette>,
133}
134
135#[derive(Clone)]
136pub(crate) struct OutputSnapshot {
137    pub(crate) tokens_len: usize,
138    /// The active per-vertex `ptrace` budget at snapshot time. Restored with
139    /// the token stream so a REPEAT/OPTIONAL iteration that rolls back also
140    /// rolls back any separator budget it spent (a `_terminator` that picked
141    /// `;;` inside a zero-progress iteration must un-spend it).
142    ptrace_budget: std::collections::HashMap<String, usize>,
143}
144
145impl<'a> Output<'a> {
146    pub(crate) fn new(
147        policy: &'a FormatPolicy,
148        grammar: &'a Grammar,
149        cassette: Option<&'a dyn crate::languages::cassettes::GrammarCassette>,
150    ) -> Self {
151        Self {
152            tokens: Vec::new(),
153            policy,
154            grammar,
155            current_rule: None,
156            cassette,
157        }
158    }
159
160    pub(crate) fn token(&mut self, value: &str) {
161        self.token_with_role(value, None);
162    }
163
164    /// Emit a verbatim string-region leaf with NO layout side effects:
165    /// the literal is pushed with the `Terminal` role but the
166    /// `line_break_after` / `indent_open` machinery is bypassed. Tight
167    /// string content (`kind_is_tight_content`, `string_content_kinds`,
168    /// `external_content_kinds`) and the interpolation braces of a string
169    /// (`$"…{x}…"`) are part of one lexical span where a literal `{`, `}`
170    /// or `;` inside the captured text is data, not a block opener or a
171    /// statement terminator: routing them through `token_with_role` would
172    /// insert a newline / indent that the re-parse cannot absorb (the
173    /// scanner only re-lexes the interpolation when the brace abuts its
174    /// neighbours). The caller is responsible for any surrounding
175    /// [`no_space`](Self::no_space) markers.
176    pub(crate) fn tight_token(&mut self, value: &str) {
177        if value.is_empty() {
178            return;
179        }
180        // Verbatim string-region content is glued to its delimiters and is
181        // *data*, not syntax: a literal `;`/`#`/`//` inside the captured text
182        // must not be re-interpreted as a line-comment opener (which would
183        // append a newline in the layout fold). The `Immediate` role is
184        // unconditionally tight on both sides and is excluded from the
185        // line-comment-prefix newline, so it is the correct role for content.
186        self.tokens
187            .push(Token::Lit(value.to_owned(), TokenRole::Immediate));
188    }
189
190    pub(crate) fn token_with_role(&mut self, value: &str, explicit_role: Option<TokenRole>) {
191        if value.is_empty() {
192            return;
193        }
194
195        if value == "\n" || value == "\r\n" || value == "\r" {
196            self.tokens.push(Token::LineBreak);
197            return;
198        }
199
200        let trimmed = value.trim_end_matches(['\n', '\r']);
201        let trailing_newlines = value.len() - trimmed.len();
202        if trailing_newlines > 0 && !trimmed.is_empty() {
203            let role = explicit_role.unwrap_or(TokenRole::Terminal);
204            if role == TokenRole::BracketClose
205                && self.policy.indent_close.iter().any(|t| t == trimmed)
206            {
207                self.tokens.push(Token::IndentClose);
208            }
209            self.tokens.push(Token::Lit(trimmed.to_owned(), role));
210            if role == TokenRole::BracketOpen {
211                if let Some(ref rule) = self.current_rule {
212                    if self
213                        .grammar
214                        .indent_triggers
215                        .contains(&(rule.clone(), trimmed.to_owned()))
216                    {
217                        self.tokens.push(Token::IndentOpen);
218                    }
219                }
220            }
221            self.tokens.push(Token::LineBreak);
222            return;
223        }
224
225        let mut role = explicit_role.unwrap_or_else(|| self.lookup_role(value));
226        // A cassette may declare a token lexically tight in a rule (a
227        // scanner fact `grammar.json` omits, e.g. bash `VAR=1`): emit it
228        // with the always-tight Connector role (which the layout pass
229        // honours over the sibling-separation ForceSpace).
230        if let (Some(rule), Some(cassette)) = (self.current_rule.as_ref(), self.cassette) {
231            if cassette.operator_is_tight(rule, value) {
232                role = TokenRole::Connector;
233            }
234        }
235
236        if role == TokenRole::BracketClose && self.policy.indent_close.iter().any(|t| t == value) {
237            self.tokens.push(Token::IndentClose);
238        }
239
240        self.tokens.push(Token::Lit(value.to_owned(), role));
241
242        if role == TokenRole::BracketOpen {
243            let grammar_indent = self.current_rule.as_ref().is_some_and(|rule| {
244                self.grammar
245                    .indent_triggers
246                    .contains(&(rule.clone(), value.to_owned()))
247            });
248            if grammar_indent {
249                self.tokens.push(Token::IndentOpen);
250                self.tokens.push(Token::LineBreak);
251            }
252        }
253        // Line-break after tokens like `;` (statement terminator).
254        // Skip for BracketOpen/BracketClose tokens that are NOT
255        // indent-triggering (e.g. `{` in interpolation should not
256        // trigger a line break).
257        let is_non_indent_bracket = self.current_rule.is_some()
258            && (role == TokenRole::BracketOpen || role == TokenRole::BracketClose)
259            && !self.current_rule.as_ref().is_some_and(|rule| {
260                self.grammar
261                    .indent_triggers
262                    .contains(&(rule.clone(), value.to_owned()))
263            });
264        if !is_non_indent_bracket && self.policy.line_break_after.iter().any(|t| t == value) {
265            self.tokens.push(Token::LineBreak);
266        }
267    }
268
269    pub(crate) fn lookup_role(&self, value: &str) -> TokenRole {
270        if let Some(role) = self.explicit_role(value) {
271            return role;
272        }
273        if is_word_like(value) {
274            TokenRole::Keyword
275        } else {
276            TokenRole::Operator
277        }
278    }
279
280    /// The role classified for `value` in the current rule, if any.
281    /// `None` when the rule's grammar-derived `token_roles` map has no
282    /// entry, leaving the caller to choose a structural default.
283    pub(crate) fn explicit_role(&self, value: &str) -> Option<TokenRole> {
284        self.current_rule
285            .as_ref()
286            .and_then(|rule| self.grammar.token_roles.get(rule))
287            .and_then(|role_map| role_map.get(value).copied())
288    }
289
290    /// Emit a bracket-open token that triggers indentation. This is the
291    /// inline-classification counterpart to the `indent_triggers` check
292    /// in `token_with_role`: the SEQ walker computes indent-triggering
293    /// from the SEQ structure directly rather than from a precomputed map.
294    pub(crate) fn token_with_indent_open(&mut self, value: &str, role: TokenRole) {
295        if value.is_empty() {
296            return;
297        }
298        if role == TokenRole::BracketClose && self.policy.indent_close.iter().any(|t| t == value) {
299            self.tokens.push(Token::IndentClose);
300        }
301        self.tokens.push(Token::Lit(value.to_owned(), role));
302        if role == TokenRole::BracketOpen {
303            self.tokens.push(Token::IndentOpen);
304            self.tokens.push(Token::LineBreak);
305        }
306    }
307
308    pub(crate) fn newline(&mut self) {
309        self.tokens.push(Token::LineBreak);
310    }
311
312    /// Push exact replayed source bytes (see [`Token::Verbatim`]). The bytes
313    /// are written through the layout fold with no role-derived spacing on
314    /// either edge: the layout complement already encodes the verbatim
315    /// inter-token whitespace, so the byte-faithful replay path bypasses the
316    /// role table for this span.
317    pub(crate) fn verbatim(&mut self, bytes: &str) {
318        if bytes.is_empty() {
319            return;
320        }
321        self.tokens.push(Token::Verbatim(bytes.to_owned()));
322    }
323
324    /// Open an indent scope: subsequent `LineBreak`s render at the
325    /// new depth until a matching `indent_close` pops it. Used by the
326    /// external-token fallback to render indent-based grammars'
327    /// `_indent` scanner outputs.
328    pub(crate) fn indent_open(&mut self) {
329        self.tokens.push(Token::IndentOpen);
330        self.tokens.push(Token::LineBreak);
331    }
332
333    /// Close one indent scope opened by `indent_open`.
334    pub(crate) fn indent_close(&mut self) {
335        self.tokens.push(Token::IndentClose);
336    }
337
338    pub(crate) fn snapshot(&self) -> OutputSnapshot {
339        OutputSnapshot {
340            tokens_len: self.tokens.len(),
341            ptrace_budget: super::snapshot_ptrace_budget(),
342        }
343    }
344
345    pub(crate) fn restore(&mut self, snap: OutputSnapshot) {
346        self.tokens.truncate(snap.tokens_len);
347        super::restore_ptrace_budget(snap.ptrace_budget);
348    }
349
350    /// True iff at least one `Token::Lit` was pushed since `snap`.
351    /// Control-only emissions (`LineBreak`, `IndentOpen` / `IndentClose`,
352    /// `NoSpace`) do not count as content. Used by the REPEAT walker
353    /// to detect that a "separator slot" CHOICE picked its BLANK
354    /// alternative, so the next iteration's content can be marked
355    /// tight against the previous iteration's content.
356    pub(crate) fn lit_emitted_since(&self, snap: OutputSnapshot) -> bool {
357        self.tokens[snap.tokens_len..]
358            .iter()
359            .any(|t| matches!(t, Token::Lit(_, _) | Token::Verbatim(_)))
360    }
361
362    /// Push a marker that suppresses the next inter-Lit separator the
363    /// layout pass would otherwise insert. Used to encode "no source-
364    /// level separator was emitted between these two Lits" without
365    /// having to make per-grammar adjacency decisions in the layout.
366    pub(crate) fn no_space(&mut self) {
367        self.tokens.push(Token::NoSpace);
368    }
369
370    /// Push a marker that forces a separator (space) between the
371    /// surrounding Lits. Used for an external scanner token that is
372    /// required inter-token whitespace (dockerfile `_non_newline_whitespace`
373    /// between path arguments), which carries no text of its own but
374    /// must keep the neighbours apart.
375    pub(crate) fn force_space(&mut self) {
376        self.tokens.push(Token::ForceSpace);
377    }
378
379    pub(crate) fn finish(self) -> Vec<u8> {
380        layout(
381            &self.tokens,
382            self.policy,
383            &self.grammar.line_comment_prefixes,
384            &self.grammar.trailing_break_markers,
385            self.grammar.trailing_break_on_whitespace,
386            self.grammar.top_level_text_admits_newline,
387        )
388    }
389}
390
391/// Fold a token list into bytes. The algebra:
392/// * adjacent `Lit`s get a single space iff `needs_space_between(a, b)`,
393/// * `IndentOpen` / `IndentClose` adjust a depth counter,
394/// * `LineBreak` writes `\n` if not already at line start, then the
395///   next `Lit` writes `indent * indent_width` spaces of indent.
396pub(crate) fn layout(
397    tokens: &[Token],
398    policy: &FormatPolicy,
399    line_comment_prefixes: &[String],
400    trailing_break_markers: &[String],
401    trailing_break_on_whitespace: bool,
402    top_level_text_admits_newline: bool,
403) -> Vec<u8> {
404    let mut bytes = Vec::new();
405    let mut indent: usize = 0;
406    let mut at_line_start = true;
407    let mut last_role: Option<TokenRole> = None;
408    let mut last_text: String = String::new();
409    let mut suppress_next_separator = false;
410    let mut force_next_separator = false;
411    // The negated-class content of a greedy terminal that just emitted; if
412    // the next Lit's first char is admitted by it, force a separator.
413    let mut pending_absorber: Option<String> = None;
414    // True iff the most recently emitted content token was an exact-replay
415    // `Verbatim` blob. The byte-faithful replay path reproduces the source's
416    // trailing bytes verbatim (the trailing interstitial is part of the
417    // reconstructed span), so the final line-terminating newline below must not
418    // be appended after a verbatim tail: the source may legitimately have ended
419    // without a trailing newline, and a spurious `\n` can flip a
420    // newline-sensitive scanner's parse (scala `class A\n()\n()\n{}` — the
421    // trailing `\n` inserts an automatic semicolon that re-binds the empty
422    // `class_parameters`/`template_body` as top-level `unit`/`block`). Canonical
423    // (forget_layout) schemas emit no `Verbatim` tokens, so this never relaxes
424    // the conventional terminating newline on the reformatting path.
425    let mut last_content_was_verbatim = false;
426    let newline = policy.newline.as_bytes();
427    let separator = policy.separator.as_bytes();
428
429    for (tok_idx, tok) in tokens.iter().enumerate() {
430        if std::env::var("DBG_LAYOUT").is_ok() {
431            match tok {
432                Token::Lit(v, r) => eprintln!(
433                    "  TOK: Lit({v:?}, {r:?}) at_line_start={at_line_start} last_role={last_role:?}"
434                ),
435                Token::IndentOpen => eprintln!("  TOK: IndentOpen"),
436                Token::IndentClose => eprintln!("  TOK: IndentClose"),
437                Token::LineBreak => eprintln!("  TOK: LineBreak"),
438                Token::NoSpace => eprintln!("  TOK: NoSpace"),
439                Token::ForceSpace => eprintln!("  TOK: ForceSpace"),
440                Token::AbsorberGuard(s) => eprintln!("  TOK: AbsorberGuard({s:?})"),
441                Token::Verbatim(s) => eprintln!("  TOK: Verbatim({s:?})"),
442            }
443        }
444        match tok {
445            Token::IndentOpen => indent += 1,
446            Token::IndentClose => {
447                indent = indent.saturating_sub(1);
448                pending_absorber = None;
449                if !at_line_start {
450                    bytes.extend_from_slice(newline);
451                    at_line_start = true;
452                }
453            }
454            Token::LineBreak => {
455                pending_absorber = None;
456                if !at_line_start {
457                    bytes.extend_from_slice(newline);
458                    at_line_start = true;
459                }
460            }
461            Token::NoSpace => {
462                suppress_next_separator = true;
463            }
464            Token::ForceSpace => {
465                force_next_separator = true;
466            }
467            Token::AbsorberGuard(negated) => {
468                pending_absorber = Some(negated.clone());
469            }
470            Token::Verbatim(bytes_str) => {
471                // Exact replayed source: written through with NO role-derived
472                // separator on either edge. The complement already encodes the
473                // verbatim whitespace, so the byte-faithful path must not let
474                // the role table inject or suppress a space here. Any pending
475                // absorber/force/suppress markers are discharged without effect.
476                pending_absorber = None;
477                suppress_next_separator = false;
478                force_next_separator = false;
479                // Indentation only applies to the FIRST line of the blob if we
480                // were at a fresh line start; the blob carries its own internal
481                // indentation thereafter.
482                if at_line_start && !bytes_str.is_empty() {
483                    bytes.extend(std::iter::repeat_n(b' ', indent * policy.indent_width));
484                }
485                bytes.extend_from_slice(bytes_str.as_bytes());
486                // The trailing byte determines the line state for whatever
487                // follows; the role chain is reset so the next `Lit` does not
488                // role-space against a stale predecessor.
489                at_line_start = bytes_str.ends_with(['\n', '\r']);
490                last_role = None;
491                last_text.clear();
492                last_content_was_verbatim = true;
493            }
494            Token::Lit(value, role) => {
495                // A greedy negated-class terminal just emitted: if it would
496                // lexically swallow this Lit's first char on re-parse, the
497                // boundary needs a separator regardless of the role pair.
498                if let Some(negated) = pending_absorber.take() {
499                    if value
500                        .chars()
501                        .next()
502                        .is_some_and(|c| negated_class_admits(&negated, c))
503                    {
504                        force_next_separator = true;
505                    }
506                }
507                // Block-opening bracket: BracketOpen followed by IndentOpen.
508                // After a Terminal/BracketClose, this should be spaced
509                // (`}\n` not `0{`).
510                let is_block_open = *role == TokenRole::BracketOpen
511                    && tokens
512                        .get(tok_idx + 1)
513                        .is_some_and(|t| matches!(t, Token::IndentOpen));
514                if at_line_start {
515                    bytes.extend(std::iter::repeat_n(b' ', indent * policy.indent_width));
516                } else if let Some(prev_role) = last_role {
517                    // The role-spacer inserts at most ONE separator at a token
518                    // boundary, but a content leaf can carry the boundary
519                    // whitespace inside its own captured text: a marker token
520                    // whose `literal-value` ends in a space (djot
521                    // `block_quote_marker` = `"> "`, the ATX/list markers of
522                    // lightweight-markup grammars) already supplies the gap to
523                    // the following content, and a token whose text begins with
524                    // a space supplies it to the preceding one. Adding a
525                    // role-derived space on top would double it, and the doubled
526                    // space is re-absorbed into the marker's text on re-parse, so
527                    // it accretes one space per emit (`# Heading` -> `#  Heading`
528                    // -> `#   Heading` ...): the canonical fixed point is lost.
529                    // When the boundary already carries whitespace from either
530                    // side, the separator is redundant; suppress it. This is
531                    // derived purely from the emitted token text, not any
532                    // per-language table, and applies uniformly: a genuine
533                    // no-whitespace marker (Org's `* Heading`, whose literal is
534                    // bare `*`) is unaffected, since neither side carries the
535                    // space.
536                    let boundary_has_whitespace =
537                        last_text.ends_with([' ', '\t']) || value.starts_with([' ', '\t']);
538                    // An explicit NoSpace (suppress) is authoritative: it
539                    // records that the source had no separator at this
540                    // boundary (an empty REPEAT separator slot, an
541                    // IMMEDIATE_TOKEN). It overrides the sibling-separation
542                    // ForceSpace heuristic — otherwise beamed notes
543                    // (`CDEF`) re-space to `C D E F`.
544                    let want_space = !suppress_next_separator
545                        && !boundary_has_whitespace
546                        && (force_next_separator
547                            || needs_space_by_role(prev_role, &last_text, *role, value)
548                            || (is_block_open
549                                && matches!(
550                                    prev_role,
551                                    TokenRole::Terminal | TokenRole::BracketClose
552                                )));
553                    if want_space {
554                        bytes.extend_from_slice(separator);
555                    }
556                }
557                suppress_next_separator = false;
558                force_next_separator = false;
559                bytes.extend_from_slice(value.as_bytes());
560                at_line_start = false;
561                last_content_was_verbatim = false;
562                last_role = Some(*role);
563                last_text.clear();
564                last_text.push_str(value);
565                // A verbatim string-region content leaf (`Immediate` role) is
566                // data, not syntax: a `;`/`#`/`//` inside captured string text
567                // must not open a line comment.
568                if *role != TokenRole::Immediate
569                    && line_comment_prefixes
570                        .iter()
571                        .any(|p| value.starts_with(p.as_str()))
572                {
573                    bytes.extend_from_slice(newline);
574                    at_line_start = true;
575                    last_role = None;
576                }
577            }
578        }
579    }
580
581    // Append the customary end-of-output newline only when no suppressor
582    // fires: not already at line start, not directly after an exact-replay
583    // verbatim tail (scala), not on a top-level free-text repeat that admits a
584    // bare newline (liquid `{% endcomment %}` must not gain a trailing
585    // `template_content`), and not after a hard-line-break marker
586    // (markdown_inline). Each suppressor guards against the appended newline
587    // manufacturing a phantom node on re-parse.
588    if !at_line_start
589        && !last_content_was_verbatim
590        && !top_level_text_admits_newline
591        && !ends_with_trailing_break_marker(
592            &bytes,
593            trailing_break_markers,
594            trailing_break_on_whitespace,
595        )
596    {
597        bytes.extend_from_slice(newline);
598    }
599    bytes
600}
601
602/// Whether `bytes` ends with a hard-line-break marker — a bare break
603/// literal (the `\` of `markdown_inline`'s `hard_line_break`) or, when the
604/// grammar's break idiom admits it, trailing whitespace. Appending the
605/// customary end-of-output newline after such a tail would manufacture a
606/// phantom line-break node on re-parse, so the caller suppresses it.
607fn ends_with_trailing_break_marker(bytes: &[u8], markers: &[String], on_whitespace: bool) -> bool {
608    if markers.is_empty() && !on_whitespace {
609        return false;
610    }
611    if on_whitespace && bytes.last().is_some_and(|b| *b == b' ' || *b == b'\t') {
612        return true;
613    }
614    markers.iter().any(|m| bytes.ends_with(m.as_bytes()))
615}
616
617/// True when the negated character class `[^<negated>]` ADMITS `c` — i.e.
618/// `c` is not one of the excluded characters. `negated` is the inner text
619/// of the class (the part after `[^`, before `]`), with backslash escapes
620/// (`\s`, `\t`, `\n`, `\\`) and literal members. A greedy `[^...]+`
621/// terminal continues to consume any admitted character, so an admitted
622/// leading char on the following token would be swallowed on re-parse.
623fn negated_class_admits(negated: &str, c: char) -> bool {
624    let mut chars = negated.chars();
625    while let Some(ch) = chars.next() {
626        if ch == '\\' {
627            let excluded = match chars.next() {
628                Some('s') => c.is_whitespace(),
629                Some('t') => c == '\t',
630                Some('n') => c == '\n',
631                Some('r') => c == '\r',
632                Some(esc) => c == esc,
633                None => false,
634            };
635            if excluded {
636                return false;
637            }
638        } else if ch == c {
639            return false;
640        }
641    }
642    true
643}
644
645/// Effective spacing role: word-like bracket tokens (`function`, `end`,
646/// `begin`, `done`, etc.) are structurally brackets (for indentation)
647/// but space like keywords (they need whitespace on both sides).
648pub(crate) fn effective_spacing_role(role: TokenRole, text: &str) -> TokenRole {
649    match role {
650        TokenRole::BracketOpen | TokenRole::BracketClose if is_word_like(text) => {
651            TokenRole::Keyword
652        }
653        other => other,
654    }
655}
656
657/// Role-pair spacing table. Determines whether a space separator
658/// should be inserted between two adjacent tokens based on their
659/// structural roles and word-likeness.
660pub(crate) fn needs_space_by_role(
661    last: TokenRole,
662    last_text: &str,
663    next: TokenRole,
664    next_text: &str,
665) -> bool {
666    let last = effective_spacing_role(last, last_text);
667    let next = effective_spacing_role(next, next_text);
668    match (last, next) {
669        // Immediate (IMMEDIATE_TOKEN) tokens are lexically glued to
670        // their neighbours on both sides (`0.5`, not `0 . 5`).
671        (TokenRole::Immediate, _) | (_, TokenRole::Immediate) => false,
672        // Brackets: tight on the inside
673        (TokenRole::BracketOpen, _) | (_, TokenRole::BracketClose) => false,
674        // Separators: tight before, space after
675        (_, TokenRole::Separator) => false,
676        (TokenRole::Separator, _) => true,
677        // Connectors: always tight (., ::, ->, etc.)
678        (TokenRole::Connector, _) | (_, TokenRole::Connector) => false,
679        // Terminal followed by bracket-open: tight (f() not f ())
680        (TokenRole::Terminal, TokenRole::BracketOpen) => false,
681        // Close followed by open: tight
682        (TokenRole::BracketClose, TokenRole::BracketOpen) => false,
683        // Keywords always spaced
684        (TokenRole::Keyword, _) | (_, TokenRole::Keyword) => true,
685        // Terminals and operators: space between
686        (TokenRole::Terminal, TokenRole::Terminal) => true,
687        (TokenRole::Terminal, TokenRole::Operator) | (TokenRole::Operator, TokenRole::Terminal) => {
688            true
689        }
690        (TokenRole::Operator, TokenRole::Operator) => true,
691        // Close followed by non-bracket: space
692        (TokenRole::BracketClose, _) => true,
693        // Operator before open: space
694        (TokenRole::Operator, TokenRole::BracketOpen) => true,
695    }
696}