Skip to main content

arity_parser/parser/
roxygen.rs

1//! Roxygen2 doc-comment recognition, sub-tokenization, and block structure.
2//!
3//! A roxygen line is a comment whose text matches `^#+'` (one-or-more `#`
4//! followed by a single `'`). Such lines are sub-tokenized—rather than emitted
5//! as one `COMMENT` token—so their structure (marker, tags, arguments, prose)
6//! lives directly in the lossless CST. The sub-tokens' texts tile the line's
7//! bytes exactly, preserving the round-trip invariant.
8//!
9//! The work is split into three phases, one module each, plus this parent which
10//! owns the macro-classification layer (the `\macro` arity/verbatim tables that
11//! both the lexer and the structure builder consult) and the shared
12//! balanced-delimiter scan:
13//!
14//! * [`lex`] — sub-lexing: block-mode resolution + the per-line tokenizer
15//!   (text → `Vec<Token>`).
16//! * [`group`] — block grouping: wrapping a run of lines in a `ROXYGEN_BLOCK`
17//!   and laying out its section/paragraph skeleton (`Vec<Token>` → `Vec<Event>`).
18//! * [`build`] — structure building: the block-level Rd-macro and markdown
19//!   constructs (`\itemize{…}`, `\describe{…}`, markdown lists) dispatched from
20//!   the grouper.
21
22mod build;
23mod group;
24mod inline;
25mod lex;
26
27pub(crate) use group::emit_roxygen_block;
28pub use lex::{is_md_standalone_html_tag, md_fence_run_closes, starts_md_html_block};
29pub(crate) use lex::{
30    is_roxygen_comment, lex_roxygen_line, rd_backslash_is_escaped, resolve_roxygen_block,
31    roxygen_line_tag, scan_rd_macro, tag_body_skips_markdown, tag_folds_prose_continuation,
32};
33
34/// Inline Rd macros whose `{…}` content is **verbatim** (`VERB` in
35/// `tools::parse_Rd`): the body is raw text and nested `\macro` markup is *not*
36/// parsed. Confirmed against `parse_Rd` (see the projector's `rd_macros` work).
37/// Latexlike macros (`\code`, `\emph`, `\strong`, `\link`, …) are everything
38/// else --- their content is sub-parsed, so nested macros become child nodes.
39/// `\eqn`/`\deqn` (LaTeX math) and `\out` (raw output) are verbatim too:
40/// `parse_Rd` yields `(\eqn (VERB "x \code{q} y"))`, the nested markup literal.
41const VERBATIM_RD_MACROS: &[&str] = &[
42    "url", "verb", "samp", "env", "kbd", "option", "eqn", "deqn", "out",
43];
44
45/// Whether the macro named `name` (without the leading `\`) takes verbatim
46/// `{…}` content. Used both when building the CST (don't recurse into a verbatim
47/// body) and when projecting it (emit `VERB`, not coalesced `TEXT`) — the
48/// projector's block-form verbatim arm keys on this set, so it is `pub` like its
49/// [`rd_macro_arity`] sibling (semver-loose, see the crate docs).
50pub fn is_verbatim_rd_macro(name: &str) -> bool {
51    VERBATIM_RD_MACROS.contains(&name)
52}
53
54/// Whether argument group `index` (0-based) of the macro named `name` takes
55/// **verbatim** `{…}` content (`VERB` in `parse_Rd`: raw text, no nested markup).
56/// A fully-verbatim macro (`\url`/`\verb`/…) is verbatim in its only argument;
57/// `\href{url}{text}` is verbatim in its *first* argument (the URL) but latexlike
58/// in its *second* (the link text, which is sub-parsed). Drives both the tree
59/// builder (don't recurse into a verbatim arg) and, via the emitted `VERB` leaf,
60/// the projector. Confirmed against `parse_Rd`: `\href`'s first arg is `VERB`.
61pub(crate) fn is_verbatim_rd_arg(name: &str, index: usize) -> bool {
62    is_verbatim_rd_macro(name) || (name == "href" && index == 0) || name == "figure"
63}
64
65/// The Rd macros whose adjacent-`{…}`-group count is **not one**, with that
66/// count the way `tools::parse_Rd` counts it. Every name absent here takes
67/// exactly one group, so a trailing `\code{x}{y}`'s `{y}` stays literal --- the
68/// arity is per macro, and a group past the count is likewise literal
69/// (`\ifelse{a}{b}{c}{d}` → `(\ifelse …)` plus a sibling `(LIST …)`). Probed
70/// exhaustively against R 4.6 (every [`KNOWN_RD_MACROS`] name written
71/// `\name{A}{B}{C}` in a `\details` section).
72///
73/// A trailing group is *optional*: `\eqn{x^2}` alone is a complete call, and the
74/// lexer's consumption is present-and-adjacent-only, so the optionality costs
75/// nothing. parse_Rd requires same-line adjacency --- `\eqn{a} {b}` and a
76/// next-line `{b}` leave the group a literal brace `LIST`. A brace-less `\item`
77/// (under `\itemize`/`\enumerate`) never reaches here: it has no `{`, so it is
78/// not a macro token at all.
79///
80/// **Arity `0` is a complete call by name alone**: parse_Rd consumes no group at
81/// all, so a written `{}`/`{x}` is a *following sibling* brace list, never an
82/// argument (`\dots{}` → `(\dots)` plus `(LIST)`). See [`is_zero_arg_rd_macro`].
83///
84/// The trailing entries are **not** parse_Rd built-ins but R's *system user
85/// macros* (`share/Rd/macros/system.Rd`, always loaded before every Rd file);
86/// their arity is the largest `#N` in the definition --- zero when it has none
87/// --- and parse_Rd consumes that many groups before expanding. The definitions
88/// themselves live in the projector's `usermacro` module --- only the group
89/// count is a parsing fact.
90///
91/// The arity-above-one names are also the macros whose `{…}` arguments
92/// `parse_Rd` models as *list* wrappers (so a multi-atom argument projects to a
93/// `(GRP …)`), as opposed to latexlike macros (`\code`, `\emph`, …) whose single
94/// argument's content is inlined directly. The projector keys its GRP rule on
95/// [`is_multi_arg_rd_macro`].
96const RD_MACRO_ARITY: &[(&str, usize)] = &[
97    ("if", 2),
98    ("ifelse", 3),
99    ("item", 2),
100    ("tabular", 2),
101    ("href", 2),
102    ("figure", 2),
103    ("eqn", 2),
104    ("deqn", 2),
105    ("enc", 2),
106    ("method", 2),
107    ("S3method", 2),
108    ("S4method", 2),
109    ("subsection", 2),
110    // Zero-argument built-ins: `\cr`, `\dots`, … are complete by name alone.
111    ("cr", 0),
112    ("tab", 0),
113    ("dots", 0),
114    ("ldots", 0),
115    ("R", 0),
116    // R system user macros (see above).
117    ("manual", 2),
118    ("bibinfo", 3),
119    ("sspace", 0),
120    ("LaTeX", 0),
121];
122
123/// How many `{…}` argument groups the macro named `name` (without the leading
124/// `\`) takes; `1` for every name outside [`RD_MACRO_ARITY`]. Drives the lexer
125/// (consume that many groups into one token), the tree builder (emit them as
126/// children), and the projector (each group of a multi-argument macro is a list
127/// argument --- a multi-atom one becomes a `(GRP …)`).
128pub fn rd_macro_arity(name: &str) -> usize {
129    RD_MACRO_ARITY
130        .iter()
131        .find(|(n, _)| *n == name)
132        .map_or(1, |(_, arity)| *arity)
133}
134
135/// Whether the macro named `name` (without the leading `\`) takes more than one
136/// `{…}` argument group --- parse_Rd's *structural* macros, whose arguments are
137/// list wrappers. See [`rd_macro_arity`].
138pub fn is_multi_arg_rd_macro(name: &str) -> bool {
139    rd_macro_arity(name) > 1
140}
141
142/// R's **system user macros** (`share/Rd/macros/system.Rd`, loaded before every
143/// Rd file). They are not parse_Rd built-ins --- they are `\newcommand`
144/// definitions --- so they are absent from [`KNOWN_RD_MACROS`]; but parse_Rd
145/// *does* consume [`rd_macro_arity`] groups for each before expanding it, which
146/// makes their names a parsing fact. Without this list the lexer could not tell
147/// `\doi{10.1/x}` (one consumed argument) from a genuinely unrecognized
148/// `\zzz{x}` (no argument at all --- see [`is_argument_taking_rd_macro`]).
149///
150/// The **definitions** live in the projector's `usermacro` module, which pins
151/// this list against them; only the names and group counts are parsing facts.
152const SYSTEM_RD_USER_MACROS: &[&str] = &[
153    "CRANpkg",
154    "PR",
155    "doi",
156    "I",
157    "packageTitle",
158    "packageDescription",
159    "packageAuthor",
160    "packageMaintainer",
161    "packageDESCRIPTION",
162    "packageIndices",
163    "bibcitep",
164    "bibcitet",
165    "bibshow",
166    "proglang",
167    "sspace",
168    "LaTeX",
169    "manual",
170    "bibinfo",
171];
172
173/// Every name in [`SYSTEM_RD_USER_MACROS`], for the projector's drift check
174/// against its own definition table.
175pub fn system_rd_user_macro_names() -> &'static [&'static str] {
176    SYSTEM_RD_USER_MACROS
177}
178
179/// Whether `parse_Rd` consumes `{…}` argument groups for `\name` --- a built-in
180/// ([`is_known_rd_macro`]) or a system user macro ([`SYSTEM_RD_USER_MACROS`]).
181/// Everything else is an unrecognized `\word`: parse_Rd tags it `UNKNOWN` and
182/// consumes **nothing**, so a written group is a following sibling brace list
183/// (`\zzz{x}` → `(UNKNOWN "\zzz") (LIST (TEXT "x"))`), exactly like a zero-arity
184/// call's. Arity still applies on top --- a zero-arity name in either set takes
185/// no group either.
186pub fn is_argument_taking_rd_macro(name: &str) -> bool {
187    is_known_rd_macro(name) || SYSTEM_RD_USER_MACROS.contains(&name)
188}
189
190/// Split a GFM table row into its cells, honoring backslash-escaped pipes. One
191/// optional leading and one optional trailing **unescaped** `|` are stripped (the
192/// GFM leading/trailing pipe), then the remainder is split on each unescaped `|`.
193/// Cells are returned untrimmed (callers trim). An escaped `\|` stays inside its
194/// cell. Shared by the recognition gate (cell counting) and the projector (cell
195/// rendering) so the two never disagree on where a cell begins.
196///
197/// GFM counts pipes **without** honoring code spans — a `|` inside `` `…` `` still
198/// splits a cell — so this deliberately does not track backticks. That is what
199/// makes `| ` + "`a|b`" + ` | y |` fail the header/delimiter cell-count match and
200/// stay prose (verified against roxygen2).
201pub fn split_table_row_cells(line: &str) -> Vec<&str> {
202    let trimmed = line.trim();
203    let bytes = trimmed.as_bytes();
204    let start = usize::from(bytes.first() == Some(&b'|'));
205    let end = if bytes.len() > start
206        && bytes[bytes.len() - 1] == b'|'
207        && !pipe_is_escaped(bytes, bytes.len() - 1)
208    {
209        bytes.len() - 1
210    } else {
211        bytes.len()
212    };
213    let inner = &trimmed[start..end];
214    let ib = inner.as_bytes();
215    let mut cells = Vec::new();
216    let mut cell_start = 0;
217    let mut i = 0;
218    while i < ib.len() {
219        match ib[i] {
220            b'\\' => i += 2, // skip the escaped byte (an escaped `\|` stays in-cell)
221            b'|' => {
222                cells.push(&inner[cell_start..i]);
223                i += 1;
224                cell_start = i;
225            }
226            _ => i += 1,
227        }
228    }
229    cells.push(&inner[cell_start.min(inner.len())..]);
230    cells
231}
232
233/// The number of cells in a GFM table row (see [`split_table_row_cells`]). The
234/// header row and the delimiter row form a table only when these are equal.
235pub(crate) fn count_table_cells(line: &str) -> usize {
236    split_table_row_cells(line).len()
237}
238
239/// Whether `line` is a GFM table **delimiter row**: it contains at least one
240/// unescaped `|` (so a bare `---`, which is a setext underline, is *not* a
241/// single-column table) and every cell (trimmed) is `:?-+:?` (optional leading
242/// colon, one or more hyphens, optional trailing colon). The pipe requirement
243/// mirrors cmark-gfm, which treats a pipeless dash run as a setext heading.
244pub fn is_table_delim_row(line: &str) -> bool {
245    let trimmed = line.trim();
246    if !line_has_unescaped_pipe(trimmed) {
247        return false;
248    }
249    let cells = split_table_row_cells(trimmed);
250    !cells.is_empty() && cells.iter().all(|c| is_table_delim_cell(c.trim()))
251}
252
253/// Whether the byte at `idx` (a `|`) is backslash-escaped: preceded by an odd
254/// run of `\`.
255fn pipe_is_escaped(bytes: &[u8], idx: usize) -> bool {
256    let mut k = idx;
257    let mut count = 0;
258    while k > 0 && bytes[k - 1] == b'\\' {
259        count += 1;
260        k -= 1;
261    }
262    count % 2 == 1
263}
264
265/// Whether `line` contains an unescaped `|`.
266fn line_has_unescaped_pipe(line: &str) -> bool {
267    let bytes = line.as_bytes();
268    let mut i = 0;
269    while i < bytes.len() {
270        match bytes[i] {
271            b'\\' => i += 2,
272            b'|' => return true,
273            _ => i += 1,
274        }
275    }
276    false
277}
278
279/// Whether `cell` (already trimmed) is a valid delimiter cell: `:?-+:?`.
280fn is_table_delim_cell(cell: &str) -> bool {
281    let b = cell.as_bytes();
282    let mut i = usize::from(b.first() == Some(&b':'));
283    let dash_start = i;
284    while i < b.len() && b[i] == b'-' {
285        i += 1;
286    }
287    if i == dash_start {
288        return false; // a delimiter cell needs at least one hyphen
289    }
290    if b.get(i) == Some(&b':') {
291        i += 1;
292    }
293    i == b.len()
294}
295
296/// Scan a balanced delimited run starting at `bytes[i] == open`, tracking nesting
297/// and skipping Rd backslash escapes (`\}` etc.). Returns the index past the
298/// matching `close`, or `None` if it is unbalanced before end of input.
299pub(crate) fn scan_balanced(bytes: &[u8], i: usize, open: u8, close: u8) -> Option<usize> {
300    debug_assert_eq!(bytes[i], open);
301    let mut depth = 0usize;
302    let mut j = i;
303    while j < bytes.len() {
304        let b = bytes[j];
305        if b == b'\\' {
306            j += 2; // skip the escaped byte
307        } else if b == open {
308            depth += 1;
309            j += 1;
310        } else if b == close {
311            depth -= 1;
312            j += 1;
313            if depth == 0 {
314                return Some(j);
315            }
316        } else {
317            j += 1;
318        }
319    }
320    None
321}
322
323/// Advance a markdown value column across one whitespace character: a tab
324/// expands to the next 4-column tab stop (CommonMark's rule — roxygen2 hands
325/// cmark the tag value with tabs intact), any other character is one column.
326/// Columns are zero-based **value** coordinates: column 0 is the first
327/// character after the `#'` sigil plus the one whitespace character roxygen2's
328/// tokenizer strips (`consumeWhitespace(1)`, parser2.cpp — a space *or* a tab).
329/// The single source of column arithmetic for the block builder's indent gauge
330/// and the projector's column stripping.
331pub fn advance_md_col(col: usize, c: char) -> usize {
332    if c == '\t' {
333        col - col % 4 + 4
334    } else {
335        col + 1
336    }
337}
338
339/// The one-based indent **gauge** of a marker→content whitespace run: the first
340/// character (space or tab alike) is the one roxygen2 strips and maps to
341/// gauge 1 (value column 0); the rest expand via [`advance_md_col`], so the
342/// gauge is the content's value column plus one. An empty run gauges 0. For an
343/// all-space run this equals the character count — the pre-tab behavior.
344pub fn md_ws_gauge<'a>(texts: impl IntoIterator<Item = &'a str>) -> usize {
345    let mut col = 0usize;
346    let mut first = true;
347    for text in texts {
348        for c in text.chars() {
349            if first {
350                first = false;
351                continue;
352            }
353            col = advance_md_col(col, c);
354        }
355    }
356    if first { 0 } else { col + 1 }
357}
358
359/// The end index of an Rd macro name starting at `bytes[start]` (the byte *after*
360/// the leading `\`). An Rd command name is `[A-Za-z][A-Za-z0-9]*`: a leading
361/// letter then any letters or digits (e.g. `\linkS4class`). Returns `start` when
362/// no valid name begins there (`\\`, `\{`, `\4`, end of input). The single source
363/// of truth for where a `\name` ends, shared by the lexer and the tree builder.
364pub fn rd_macro_name_end(bytes: &[u8], start: usize) -> usize {
365    let mut k = start;
366    if bytes.get(k).is_some_and(u8::is_ascii_alphabetic) {
367        k += 1;
368        while k < bytes.len() && bytes[k].is_ascii_alphanumeric() {
369            k += 1;
370        }
371    }
372    k
373}
374
375/// The built-in Rd macro names `tools::parse_Rd` recognizes (without the leading
376/// `\`). A `\word` *not* in this set is an **unknown** macro: `parse_Rd` tags it
377/// `UNKNOWN` (warning "unknown macro '\word'"), even brace-less. Used to gate
378/// brace-less macro recognition in the lexer (only an unknown name is carved as a
379/// token; a known name brace-less stays literal prose --- its name-only/expanded
380/// rendering is backlog) and the projector's name-only classification (a known
381/// list child like `\item`/`\cr` → `(\name)`, an unknown one → `(UNKNOWN …)`).
382///
383/// The set is parse_Rd's static keyword table, verified against R 4.5; it
384/// deliberately excludes package/user-defined macros (`\CRANpkg`, `\doi`, …),
385/// which `parse_Rd` *expands* rather than parses (out of scope for a static
386/// projector --- they surface as faithful divergences).
387const KNOWN_RD_MACROS: &[&str] = &[
388    // Sectioning / structural commands.
389    "name",
390    "alias",
391    "title",
392    "description",
393    "usage",
394    "arguments",
395    "value",
396    "details",
397    "references",
398    "note",
399    "author",
400    "seealso",
401    "examples",
402    "keyword",
403    "concept",
404    "section",
405    "subsection",
406    "docType",
407    "encoding",
408    "Rdversion",
409    "format",
410    "source",
411    "synopsis",
412    "figure",
413    "item",
414    "describe",
415    "itemize",
416    "enumerate",
417    "tabular",
418    "method",
419    "S3method",
420    "S4method",
421    "newcommand",
422    "renewcommand",
423    "Sexpr",
424    "RdOpts",
425    "if",
426    "ifelse",
427    "out",
428    "enc",
429    "href",
430    // Inline text / cross-reference / math macros.
431    "emph",
432    "strong",
433    "bold",
434    "code",
435    "preformatted",
436    "kbd",
437    "samp",
438    "pkg",
439    "file",
440    "email",
441    "url",
442    "var",
443    "env",
444    "option",
445    "command",
446    "dfn",
447    "cite",
448    "acronym",
449    "dQuote",
450    "sQuote",
451    "verb",
452    "link",
453    "linkS4class",
454    "eqn",
455    "deqn",
456    // Zero-argument / escape / examples-only commands.
457    "cr",
458    "tab",
459    "dots",
460    "ldots",
461    "R",
462    "dontrun",
463    "donttest",
464    "dontshow",
465    "testonly",
466];
467
468/// Whether `name` (without the leading `\`) is a built-in Rd macro `parse_Rd`
469/// recognizes. The single source of truth for the known/unknown split, shared by
470/// the lexer (gate brace-less recognition) and the projector (name-only → `(\name)`
471/// vs `(UNKNOWN …)`). See [`KNOWN_RD_MACROS`].
472pub fn is_known_rd_macro(name: &str) -> bool {
473    KNOWN_RD_MACROS.contains(&name)
474}
475
476/// Whether the macro named `name` (without the leading `\`) takes **no**
477/// argument group --- `parse_Rd`'s zero-argument built-ins (`\dots`, `\cr`, …)
478/// and the zero-placeholder system user macros (`\sspace`, `\LaTeX`). Such an
479/// occurrence is a *complete* call by its name alone, so the lexer carves it
480/// name-only even when a `{` follows (the group is then a sibling brace list) —
481/// unlike other known names, whose brace-less misuse triggers parse_Rd's
482/// drop-recovery. One source of truth with the rest of the group count:
483/// [`RD_MACRO_ARITY`].
484pub(crate) fn is_zero_arg_rd_macro(name: &str) -> bool {
485    rd_macro_arity(name) == 0
486}
487
488/// Built-in Rd macros whose brace-less misuse does **not** recover as a clean
489/// text drop: by the time parse_Rd's "expecting `{`" error fires, its lexer has
490/// already switched to the macro's argument mode, and the recovery leaves that
491/// state in place — everything to *section end* (crossing paragraph breaks)
492/// becomes per-line `RCODE`/`VERB` atoms (`\code z` → `(RCODE " z after\n") …`).
493/// `\item` is special the other way: out of list context parse_Rd tags it an
494/// `(UNKNOWN "\item")` node and the text continues. Probed exhaustively against
495/// R 4.5 (every [`KNOWN_RD_MACROS`] name as `before \name z after`); every name
496/// *not* here (and not zero-arg) drops cleanly instead — see
497/// [`is_rd_braceless_drop_macro`].
498///
499/// This list covers the **built-ins** only; the argument-taking *system user
500/// macros* are sticky too, and are folded in by
501/// [`is_sticky_braceless_rd_macro`] rather than repeated here.
502const STICKY_BRACELESS_RD_MACROS: &[&str] = &[
503    // RCODE-argument macros: recovery leaves parse_Rd in R-code mode.
504    "code",
505    "donttest",
506    "dontshow",
507    "testonly",
508    // VERB-argument macros: recovery leaves parse_Rd in verbatim mode.
509    "verb",
510    "url",
511    "samp",
512    "env",
513    "kbd",
514    "option",
515    "out",
516    "eqn",
517    "deqn",
518    "href",
519    "figure",
520    "preformatted",
521    "dontrun",
522    "newcommand",
523    "renewcommand",
524    // Out-of-list `\item` becomes an `(UNKNOWN …)` node, not a drop.
525    "item",
526];
527
528/// Whether the brace-less misuse of `\name` leaves parse_Rd in its argument
529/// mode, swallowing the rest of the section — the built-ins of
530/// [`STICKY_BRACELESS_RD_MACROS`] plus every **argument-taking system user
531/// macro** ([`SYSTEM_RD_USER_MACROS`]).
532///
533/// A system user macro is sticky for the same reason a built-in is: parse_Rd
534/// consumes its groups ([`is_argument_taking_rd_macro`]) *before* expanding it,
535/// so the "expecting `{`" recovery fires with the lexer already switched — and
536/// it is always the **verbatim** mode, never R-code (probed against R 4.6:
537/// every name in the list as `before \name b after`). The zero-arity ones
538/// (`\sspace`, `\LaTeX`) are excluded: they need no group, so they simply expand
539/// on their name and the prose continues.
540pub(crate) fn is_sticky_braceless_rd_macro(name: &str) -> bool {
541    STICKY_BRACELESS_RD_MACROS.contains(&name)
542        || (SYSTEM_RD_USER_MACROS.contains(&name) && !is_zero_arg_rd_macro(name))
543}
544
545/// Whether a brace-less `\name` in prose is dropped by parse_Rd's recovery with
546/// the surrounding text continuing as plain `TEXT` (the "unexpected TEXT/section
547/// header, expecting `{`" recovery): an argument-taking, non-zero-arg macro
548/// outside [`is_sticky_braceless_rd_macro`]. Zero-arg names are complete calls
549/// (carved by the lexer), unknown names are `(UNKNOWN …)` nodes (also carved),
550/// and the sticky set leaves parse_Rd in a code/verbatim mode. Of that set,
551/// brace-less `\item` is projected as an `(UNKNOWN "\item")` node
552/// (`split_braceless_items` in the projector) and the code/verbatim mode-flip
553/// names swallow the section (`split_sticky_braceless_swallow`).
554/// Mode-independent: the `@md` pipeline is a net no-op on a backslash run before
555/// a letter, so parse_Rd sees the same brace-less macro either way.
556pub fn is_rd_braceless_drop_macro(name: &str) -> bool {
557    is_argument_taking_rd_macro(name)
558        && !is_zero_arg_rd_macro(name)
559        && !is_sticky_braceless_rd_macro(name)
560}
561
562/// For a brace-less sticky Rd macro name (`\code`/`\verb`/`\doi`/…, see
563/// [`is_sticky_braceless_rd_macro`]), which parse_Rd lexer state its "expecting
564/// `{`" recovery leaves in place: `Some(true)` for the **R-code** macros (the
565/// recovery stays in R-code mode → per-line `RCODE` atoms) and `Some(false)` for
566/// the **verbatim** macros (verbatim mode → per-line `VERB` atoms). `None` for a
567/// non-sticky name and for `item`, whose out-of-list misuse is instead an
568/// `(UNKNOWN "\item")` node (handled by the projector's `split_braceless_items`).
569/// The two built-in sets are exactly [`STICKY_BRACELESS_RD_MACROS`] minus `item`,
570/// probed against R 4.5 (see that list's doc); the argument-taking system user
571/// macros are uniformly verbatim, probed against R 4.6.
572pub fn sticky_braceless_code_mode(name: &str) -> Option<bool> {
573    match name {
574        "code" | "donttest" | "dontshow" | "testonly" => Some(true),
575        "verb" | "url" | "samp" | "env" | "kbd" | "option" | "out" | "eqn" | "deqn" | "href"
576        | "figure" | "preformatted" | "dontrun" | "newcommand" | "renewcommand" => Some(false),
577        // `\item` is the one sticky name with no argument mode to leave behind.
578        "item" => None,
579        _ => {
580            (SYSTEM_RD_USER_MACROS.contains(&name) && !is_zero_arg_rd_macro(name)).then_some(false)
581        }
582    }
583}
584
585/// Rd macros whose `{…}` content roxygen2 **protects** from the markdown parser
586/// (`escaped_for_md` in roxygen2's `R/markdown-escaping.R`): under `@md`,
587/// `escape_rd_for_md` swaps the whole `\tag{…}` span out for a placeholder before
588/// running cmark, so markdown inside such a macro stays literal Rd
589/// (`\code{*x*}` → `\code{*x*}`, not `\code{\emph{x}}`). Every *other* macro keeps
590/// only its backslash-word as literal text while its argument **is** markdown-
591/// processed (`\emph{*x*}` → `\emph{\emph{x}}`), so the projector resolves the arg
592/// of a non-fragile, known, single-argument macro as a markdown inline run.
593const FRAGILE_FOR_MD_RD_MACROS: &[&str] = &[
594    "acronym",
595    "code",
596    "command",
597    "CRANpkg",
598    "deqn",
599    "doi",
600    "dontrun",
601    "dontshow",
602    "donttest",
603    "email",
604    "env",
605    "eqn",
606    "figure",
607    "file",
608    "if",
609    "ifelse",
610    "kbd",
611    "link",
612    "linkS4class",
613    "method",
614    "mjeqn",
615    "mjdeqn",
616    "mjseqn",
617    "mjsdeqn",
618    "mjteqn",
619    "mjtdeqn",
620    "newcommand",
621    "option",
622    "out",
623    "packageAuthor",
624    "packageDescription",
625    "packageDESCRIPTION",
626    "packageIndices",
627    "packageMaintainer",
628    "packageTitle",
629    "pkg",
630    "PR",
631    "preformatted",
632    "renewcommand",
633    "S3method",
634    "S4method",
635    "samp",
636    "special",
637    "testonly",
638    "url",
639    "var",
640    "verb",
641];
642
643/// Whether the macro named `name` (without the leading `\`) has its `{…}` content
644/// **protected** from markdown under `@md` (roxygen2's `escaped_for_md`). A fragile
645/// macro keeps its argument literal; a non-fragile one has it markdown-processed.
646/// See [`FRAGILE_FOR_MD_RD_MACROS`].
647pub fn is_fragile_for_md(name: &str) -> bool {
648    FRAGILE_FOR_MD_RD_MACROS.contains(&name)
649}
650
651/// Resolve a bare prose `content` string as a `@md` markdown **inline run** and
652/// return the resulting `ROXYGEN_PARAGRAPH` node, whose children are the resolved
653/// inline elements (text, emphasis/strong nodes, links, code spans, nested Rd
654/// macros). Drives the projector's translation of a non-fragile Rd macro's argument
655/// under `@md` (`\emph{*x*}` → `\emph{\emph{x}}`): the projector slices out the raw
656/// argument text and feeds it here, reusing the **real** inline pass (the
657/// delimiter-stack arena) rather than a second markdown scanner — so nesting,
658/// links, and code spans resolve exactly as in ordinary `@md` prose. A nested
659/// fragile macro stays an opaque `ROXYGEN_RD_MACRO` token here; the projector keeps
660/// its argument literal by recursing with the same fragility check.
661pub fn resolve_md_inline(content: &str) -> crate::syntax::SyntaxNode {
662    let mut tokens = Vec::new();
663    lex::lex_roxygen_prose_fragment(&mut tokens, content, true);
664    resolve_md_inline_tokens(tokens, true)
665}
666
667/// Non-markdown twin of [`resolve_md_inline`]: lex `content` as a plain **Rd**
668/// prose fragment (no markdown recognizers; `\name{…}` macros still carve, gated
669/// by the backslash-pairing parity) and return the `ROXYGEN_PARAGRAPH` node.
670/// Drives the projector's re-parse of a *demoted* markdown span's rendered body,
671/// which `tools::parse_Rd` reads as ordinary Rd text once the generating macro's
672/// backslash has been paired away.
673pub fn resolve_rd_inline(content: &str) -> crate::syntax::SyntaxNode {
674    let mut tokens = Vec::new();
675    lex::lex_roxygen_prose_fragment(&mut tokens, content, false);
676    resolve_md_inline_tokens(tokens, false)
677}
678
679/// One piece of a **structural** Rd-macro argument fed to
680/// [`resolve_md_inline_pieces`]: either raw prose `Text` (markdown-lexed) or a
681/// pre-parsed nested `Macro` (its raw `\name{…}`/`\name` source, kept opaque).
682pub enum MdArgPiece {
683    /// Raw prose text, lexed as a markdown inline fragment.
684    Text(String),
685    /// A nested Rd macro's raw source (`\strong{y}`, `\tab`, `\cr`, …), emitted as
686    /// one opaque `RoxygenRdMacro` token so emphasis/links span across it.
687    Macro(String),
688}
689
690/// Resolve a **structural** Rd-macro argument (`\item`/`\tabular`/`\href` under
691/// `@md`) as a single markdown inline run from its already-carved `pieces`,
692/// returning the resolved `ROXYGEN_PARAGRAPH` node (same shape as
693/// [`resolve_md_inline`]).
694///
695/// roxygen2 markdown-processes a structural argument as **one** cmark run: a nested
696/// Rd macro is opaque text to cmark (reconstituted afterward), so an emphasis or
697/// link span crosses it. Re-lexing the raw argument string cannot reproduce this
698/// faithfully — the prose fragment lexer leaves a *brace-less* known macro
699/// (`\tab`/`\cr`, the table separators) literal. Instead each pre-parsed macro
700/// child (carved by the block-macro grouper) is emitted as one opaque
701/// `RoxygenRdMacro` token, which [`build_rd_macro`](crate::parser::tree_builder)
702/// re-expands into a faithful node (a brace-less `\tab` → a name-only `\tab` node).
703/// The prose pieces between them lex as ordinary markdown fragments, so the
704/// delimiter-stack arena spans emphasis across the macros exactly as cmark does.
705pub fn resolve_md_inline_pieces(pieces: &[MdArgPiece]) -> crate::syntax::SyntaxNode {
706    use crate::parser::lexer::{TokKind, Token};
707
708    let mut tokens = Vec::new();
709    for piece in pieces {
710        match piece {
711            MdArgPiece::Text(t) => lex::lex_roxygen_prose_fragment(&mut tokens, t, true),
712            // Offsets are unused by the inline pass and the tree builder (which key
713            // off `kind`/`text` only), so a synthetic macro token needs no real span.
714            MdArgPiece::Macro(m) => tokens.push(Token {
715                kind: TokKind::RoxygenRdMacro,
716                text: m.clone(),
717                start: 0,
718                end: 0,
719            }),
720        }
721    }
722    resolve_md_inline_tokens(tokens, true)
723}
724
725/// Wrap `tokens` in a paragraph, run the emphasis/inline pass, and return the
726/// resolved `ROXYGEN_PARAGRAPH` node. Shared by [`resolve_md_inline`],
727/// [`resolve_rd_inline`], and [`resolve_md_inline_pieces`]. `md` is the mode the
728/// tokens were lexed under; a non-md fragment carries no markdown delimiters, so
729/// the inline pass only threads its tokens through.
730fn resolve_md_inline_tokens(
731    tokens: Vec<crate::parser::lexer::Token>,
732    md: bool,
733) -> crate::syntax::SyntaxNode {
734    use crate::parser::events::Event;
735    use crate::syntax::SyntaxKind;
736
737    let mut events = Vec::with_capacity(tokens.len() + 2);
738    events.push(Event::Start(SyntaxKind::ROXYGEN_PARAGRAPH));
739    events.extend((0..tokens.len()).map(Event::Tok));
740    events.push(Event::Finish);
741    // Fragments carry no soft breaks, so the multi-line HTML resolution is
742    // inert here.
743    inline::resolve_emphasis(&tokens, &mut events, md);
744    let root = crate::parser::tree_builder::build_tree(&tokens, &events);
745    root.first_child()
746        .expect("build_tree always wraps the paragraph in ROOT")
747}
748
749/// Length in bytes of the UTF-8 char whose leading byte is `b`.
750fn utf8_len(b: u8) -> usize {
751    match b {
752        0x00..=0x7F => 1,
753        0xC0..=0xDF => 2,
754        0xE0..=0xEF => 3,
755        _ => 4,
756    }
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762
763    /// The brace-less classification is a three-way split, and every
764    /// argument-taking name must land in exactly one arm: sticky (parse_Rd's
765    /// recovery leaves an argument mode behind), clean drop, or — for the
766    /// zero-arity and unknown names — not a misuse at all.
767    #[test]
768    fn braceless_classification_is_a_partition() {
769        for name in KNOWN_RD_MACROS.iter().chain(SYSTEM_RD_USER_MACROS) {
770            if is_zero_arg_rd_macro(name) {
771                assert!(
772                    !is_sticky_braceless_rd_macro(name),
773                    "{name} zero-arg sticky"
774                );
775                assert!(!is_rd_braceless_drop_macro(name), "{name} zero-arg drop");
776                continue;
777            }
778            assert_ne!(
779                is_sticky_braceless_rd_macro(name),
780                is_rd_braceless_drop_macro(name),
781                "{name} is both or neither sticky and drop",
782            );
783        }
784        // An unrecognized name is an `(UNKNOWN …)` node, neither of the two.
785        assert!(!is_sticky_braceless_rd_macro("zzz"));
786        assert!(!is_rd_braceless_drop_macro("zzz"));
787    }
788
789    /// Every argument-taking system user macro is sticky, and uniformly in the
790    /// **verbatim** mode — probed against R 4.6 (`before \name b after` for each).
791    /// The zero-arity ones expand on their name, so they are not sticky at all.
792    #[test]
793    fn argument_taking_system_macros_are_verbatim_sticky() {
794        for name in SYSTEM_RD_USER_MACROS {
795            if is_zero_arg_rd_macro(name) {
796                assert_eq!(sticky_braceless_code_mode(name), None, "{name}");
797            } else {
798                assert_eq!(sticky_braceless_code_mode(name), Some(false), "{name}");
799            }
800        }
801        // The built-ins keep both modes, and `\item` neither.
802        assert_eq!(sticky_braceless_code_mode("code"), Some(true));
803        assert_eq!(sticky_braceless_code_mode("verb"), Some(false));
804        assert_eq!(sticky_braceless_code_mode("item"), None);
805    }
806
807    /// `sticky_braceless_code_mode` answers for exactly the sticky names minus
808    /// `item`, so the mode table and the set cannot drift apart.
809    #[test]
810    fn sticky_set_and_mode_table_agree() {
811        for name in KNOWN_RD_MACROS.iter().chain(SYSTEM_RD_USER_MACROS) {
812            let expected = is_sticky_braceless_rd_macro(name) && *name != "item";
813            assert_eq!(
814                sticky_braceless_code_mode(name).is_some(),
815                expected,
816                "{name}",
817            );
818        }
819    }
820}