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}