From 3300586f455e316171d0d0f88884979800a212f0 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 4 Jan 2026 12:24:02 +0100 Subject: [PATCH 1/2] feat(parser): add index term support with type-safe `IndexTermKind` Add support for `AsciiDoc` index terms (issue #260): - Flow terms (visible): ((term)) or indexterm2:[term] - Concealed terms (hidden): (((term,secondary,tertiary))) or indexterm:[...] Introduce `IndexTermKind` enum that makes invalid states unrepresentable: - Flow variant can only hold a single term (no hierarchy) - Concealed variant supports primary/secondary/tertiary terms Closes #260. --- acdc-parser/fixtures/tests/index_terms.adoc | 9 + acdc-parser/fixtures/tests/index_terms.json | 281 ++++++++++++++++++ acdc-parser/src/grammar/document.rs | 189 ++++++++++++- acdc-parser/src/grammar/location_mapping.rs | 2 + acdc-parser/src/lib.rs | 14 +- acdc-parser/src/model/inlines/converter.rs | 7 +- acdc-parser/src/model/inlines/macros.rs | 74 +++++ acdc-parser/src/model/inlines/mod.rs | 299 +++++++++++++------- acdc-parser/src/proptests/invariants.rs | 7 + 9 files changed, 770 insertions(+), 112 deletions(-) create mode 100644 acdc-parser/fixtures/tests/index_terms.adoc create mode 100644 acdc-parser/fixtures/tests/index_terms.json diff --git a/acdc-parser/fixtures/tests/index_terms.adoc b/acdc-parser/fixtures/tests/index_terms.adoc new file mode 100644 index 00000000..31e14f81 --- /dev/null +++ b/acdc-parser/fixtures/tests/index_terms.adoc @@ -0,0 +1,9 @@ += Index Terms Test + +This is about ((Arthur)) the king. + +(((Sword, Broadsword)))A concealed term for swords. + +Using macro style: indexterm2:[Excalibur]. + +And concealed macro: indexterm:[Knights, Round Table]. diff --git a/acdc-parser/fixtures/tests/index_terms.json b/acdc-parser/fixtures/tests/index_terms.json new file mode 100644 index 00000000..4af02c4e --- /dev/null +++ b/acdc-parser/fixtures/tests/index_terms.json @@ -0,0 +1,281 @@ +{ + "name": "document", + "type": "block", + "header": { + "title": [ + { + "name": "text", + "type": "string", + "value": "Index Terms Test", + "location": [ + { + "line": 1, + "col": 3 + }, + { + "line": 1, + "col": 18 + } + ] + } + ], + "location": [ + { + "line": 1, + "col": 1 + }, + { + "line": 1, + "col": 18 + } + ] + }, + "attributes": {}, + "blocks": [ + { + "name": "paragraph", + "type": "block", + "inlines": [ + { + "name": "text", + "type": "string", + "value": "This is about ", + "location": [ + { + "line": 3, + "col": 1 + }, + { + "line": 3, + "col": 14 + } + ] + }, + { + "name": "indexterm", + "type": "inline", + "term": "Arthur", + "visible": true, + "location": [ + { + "line": 3, + "col": 15 + }, + { + "line": 3, + "col": 24 + } + ] + }, + { + "name": "text", + "type": "string", + "value": " the king.", + "location": [ + { + "line": 3, + "col": 25 + }, + { + "line": 3, + "col": 34 + } + ] + } + ], + "location": [ + { + "line": 3, + "col": 1 + }, + { + "line": 3, + "col": 34 + } + ] + }, + { + "name": "paragraph", + "type": "block", + "inlines": [ + { + "name": "indexterm", + "type": "inline", + "term": "Sword", + "secondary": "Broadsword", + "visible": false, + "location": [ + { + "line": 5, + "col": 1 + }, + { + "line": 5, + "col": 23 + } + ] + }, + { + "name": "text", + "type": "string", + "value": "A concealed term for swords.", + "location": [ + { + "line": 5, + "col": 24 + }, + { + "line": 5, + "col": 51 + } + ] + } + ], + "location": [ + { + "line": 5, + "col": 1 + }, + { + "line": 5, + "col": 51 + } + ] + }, + { + "name": "paragraph", + "type": "block", + "inlines": [ + { + "name": "text", + "type": "string", + "value": "Using macro style: ", + "location": [ + { + "line": 7, + "col": 1 + }, + { + "line": 7, + "col": 19 + } + ] + }, + { + "name": "indexterm", + "type": "inline", + "term": "Excalibur", + "visible": true, + "location": [ + { + "line": 7, + "col": 20 + }, + { + "line": 7, + "col": 41 + } + ] + }, + { + "name": "text", + "type": "string", + "value": ".", + "location": [ + { + "line": 7, + "col": 42 + }, + { + "line": 7, + "col": 42 + } + ] + } + ], + "location": [ + { + "line": 7, + "col": 1 + }, + { + "line": 7, + "col": 42 + } + ] + }, + { + "name": "paragraph", + "type": "block", + "inlines": [ + { + "name": "text", + "type": "string", + "value": "And concealed macro: ", + "location": [ + { + "line": 9, + "col": 1 + }, + { + "line": 9, + "col": 21 + } + ] + }, + { + "name": "indexterm", + "type": "inline", + "term": "Knights", + "secondary": "Round Table", + "visible": false, + "location": [ + { + "line": 9, + "col": 22 + }, + { + "line": 9, + "col": 53 + } + ] + }, + { + "name": "text", + "type": "string", + "value": ".", + "location": [ + { + "line": 9, + "col": 54 + }, + { + "line": 9, + "col": 54 + } + ] + } + ], + "location": [ + { + "line": 9, + "col": 1 + }, + { + "line": 9, + "col": 54 + } + ] + } + ], + "location": [ + { + "line": 1, + "col": 1 + }, + { + "line": 9, + "col": 54 + } + ] +} \ No newline at end of file diff --git a/acdc-parser/src/grammar/document.rs b/acdc-parser/src/grammar/document.rs index a5c9f321..3dffe17b 100644 --- a/acdc-parser/src/grammar/document.rs +++ b/acdc-parser/src/grammar/document.rs @@ -4,10 +4,10 @@ use crate::{ BlockMetadata, Bold, Button, CalloutList, CalloutListItem, CalloutRef, Comment, CurvedApostrophe, CurvedQuotation, DelimitedBlock, DelimitedBlockType, DescriptionList, DescriptionListItem, DiscreteHeader, Document, DocumentAttribute, Error, Footnote, Form, - Header, Highlight, ICON_SIZES, Icon, Image, InlineMacro, InlineNode, Italic, Keyboard, - LineBreak, Link, ListItem, ListItemCheckedStatus, Location, Mailto, Menu, Monospace, - OrderedList, PageBreak, Paragraph, Pass, PassthroughKind, Plain, Raw, Section, Source, - SourceLocation, StandaloneCurvedApostrophe, Stem, StemContent, StemNotation, Subscript, + Header, Highlight, ICON_SIZES, Icon, Image, IndexTerm, IndexTermKind, InlineMacro, InlineNode, + Italic, Keyboard, LineBreak, Link, ListItem, ListItemCheckedStatus, Location, Mailto, Menu, + Monospace, OrderedList, PageBreak, Paragraph, Pass, PassthroughKind, Plain, Raw, Section, + Source, SourceLocation, StandaloneCurvedApostrophe, Stem, StemContent, StemNotation, Subscript, Substitution, Subtitle, Superscript, Table, TableOfContents, TableRow, ThematicBreak, Title, UnorderedList, Url, Verbatim, Video, grammar::{ @@ -2924,6 +2924,11 @@ peg::parser! { = inline:( // Escaped syntax must come first - backslash prevents any following syntax from being parsed escaped_syntax:escaped_syntax(offset) { escaped_syntax } + // Index terms: concealed (triple parens) must come before flow (double parens) + / index_term:index_term_concealed(offset) { index_term } + / index_term:index_term_flow(offset) { index_term } + / indexterm:indexterm_macro(offset) { indexterm } + / indexterm2:indexterm2_macro(offset) { indexterm2 } / inline_anchor:inline_anchor(offset) { inline_anchor } / cross_reference_shorthand:cross_reference_shorthand(offset) { cross_reference_shorthand } / cross_reference_macro:cross_reference_macro(offset) { cross_reference_macro } @@ -3003,7 +3008,7 @@ peg::parser! { // Curly braces (attributes): {...} / "{" inner:$([^'}']*) "}" { format!("{{{inner}}}") } // Double parens (index terms): ((...)) - / "((" inner:$((!")))" [_])*) "))" { format!("(({inner}))") } + / "((" inner:$((!"))" [_])*) "))" { format!("(({inner}))") } // Unconstrained formatting: match entire span including content and closing marker // \**not bold** -> **not bold** / "**" inner:$((!"**" [_])*) "**" { format!("**{inner}**") } @@ -3033,7 +3038,7 @@ peg::parser! { / "[[" (!"]]" [_])* "]]" / [^('[' | ' ' | '\t' | '\n' | '\\')]+ "[" [^']']* "]" / "{" [^'}']* "}" - / "((" (!")))" [_])* "))" + / "((" (!"))" [_])* "))" // Unconstrained formatting: match entire span / "**" (!"**" [_])* "**" / "__" (!("__" !['_']) [_])* "__" @@ -3146,6 +3151,110 @@ peg::parser! { })) } + /// Concealed index term: (((primary, secondary, tertiary))) + /// Only appears in the index, not in the text. + /// Supports hierarchical entries with up to three levels. + rule index_term_concealed(offset: usize) -> InlineNode + = start:position!() + "(((" + terms:index_term_list() + ")))" + end:position!() + { + let mut iter = terms.into_iter(); + let term = iter.next().unwrap_or_default(); + let secondary = iter.next(); + let tertiary = iter.next(); + tracing::info!(%term, ?secondary, ?tertiary, "Found concealed index term"); + InlineNode::Macro(InlineMacro::IndexTerm(IndexTerm { + kind: IndexTermKind::Concealed { term, secondary, tertiary }, + location: state.create_block_location(start, end, offset), + })) + } + + /// Flow index term: ((term)) + /// Appears both in the text and in the index. + /// Only supports a primary term. + rule index_term_flow(offset: usize) -> InlineNode + = start:position!() + "((" + !("(") // Ensure this is not the start of a concealed term + term:$((!"))" [_])+) + "))" + end:position!() + { + let term = term.trim().to_string(); + tracing::info!(%term, "Found flow index term"); + InlineNode::Macro(InlineMacro::IndexTerm(IndexTerm { + kind: IndexTermKind::Flow(term), + location: state.create_block_location(start, end, offset), + })) + } + + /// indexterm macro: indexterm:[primary, secondary, tertiary] + /// Concealed (hidden) form - same as (((primary, secondary, tertiary))) + rule indexterm_macro(offset: usize) -> InlineNode + = start:position!() + "indexterm:[" + terms:index_term_list() + "]" + end:position!() + { + let mut iter = terms.into_iter(); + let term = iter.next().unwrap_or_default(); + let secondary = iter.next(); + let tertiary = iter.next(); + tracing::info!(%term, ?secondary, ?tertiary, "Found indexterm macro"); + InlineNode::Macro(InlineMacro::IndexTerm(IndexTerm { + kind: IndexTermKind::Concealed { term, secondary, tertiary }, + location: state.create_block_location(start, end, offset), + })) + } + + /// indexterm2 macro: indexterm2:[term] + /// Flow (visible) form - same as ((term)) + rule indexterm2_macro(offset: usize) -> InlineNode + = start:position!() + "indexterm2:[" + term:$([^']']+) + "]" + end:position!() + { + let term = term.trim().to_string(); + tracing::info!(%term, "Found indexterm2 macro"); + InlineNode::Macro(InlineMacro::IndexTerm(IndexTerm { + kind: IndexTermKind::Flow(term), + location: state.create_block_location(start, end, offset), + })) + } + + /// Parse comma-separated index term list with support for quoted segments + /// e.g., "knight, Knight of the Round Table, Lancelot" + /// or "knight, \"Arthur, King\"" (quoted segment with embedded comma) + rule index_term_list() -> Vec + = terms:(index_term_segment() ** ",") { + terms.into_iter().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect() + } + + /// Parse a single index term segment, either quoted or unquoted + rule index_term_segment() -> String + = whitespace()? segment:(index_term_quoted() / index_term_unquoted()) whitespace()? { segment } + + /// Quoted segment: "term with, comma" + rule index_term_quoted() -> String + = "\"" content:$([^'"']*) "\"" { content.to_string() } + + /// Unquoted segment: term without comma + rule index_term_unquoted() -> String + = content:$([^('"' | ',' | ')' | ']')]+) { content.to_string() } + + /// Match index term patterns without consuming (for negative lookahead in plain_text) + rule index_term_match() -> () + = "(((" (!"))" [_])* ")))" // Concealed: (((term))) + / "((" !("(") (!"))" [_])+ "))" // Flow: ((term)) + / "indexterm:[" [^']']* "]" // indexterm:[term] + / "indexterm2:[" [^']']* "]" // indexterm2:[term] + rule inline_menu(offset: usize) -> InlineNode = start:position!() "menu:" @@ -4060,7 +4169,7 @@ peg::parser! { = start_pos:position!() content:$(( "\\" ['^' | '~'] // Escape sequences for superscript/subscript markers - / (!(eol()*<2,> / ![_] / escaped_syntax_match() / inline_anchor_match() / cross_reference_shorthand_match() / cross_reference_macro_match() / hard_wrap(offset) / footnote_match(offset, block_metadata) / inline_image(start_pos, block_metadata) / inline_icon(start_pos, block_metadata) / inline_stem(start_pos) / inline_keyboard(start_pos) / inline_button(start_pos) / inline_menu(start_pos) / mailto_macro(start_pos, block_metadata) / url_macro(start_pos, block_metadata) / inline_pass(start_pos) / link_macro(start_pos) / inline_autolink(start_pos) / inline_line_break(start_pos) / bold_text_unconstrained(start_pos, block_metadata) / bold_text_constrained_match() / italic_text_unconstrained(start_pos, block_metadata) / italic_text_constrained_match() / monospace_text_unconstrained(start_pos, block_metadata) / monospace_text_constrained_match() / highlight_text_unconstrained(start_pos, block_metadata) / highlight_text_constrained_match() / superscript_text(start_pos, block_metadata) / subscript_text(start_pos, block_metadata) / curved_quotation_text(start_pos, block_metadata) / curved_apostrophe_text(start_pos, block_metadata) / standalone_curved_apostrophe(start_pos, block_metadata)) [_]) + / (!(eol()*<2,> / ![_] / escaped_syntax_match() / index_term_match() / inline_anchor_match() / cross_reference_shorthand_match() / cross_reference_macro_match() / hard_wrap(offset) / footnote_match(offset, block_metadata) / inline_image(start_pos, block_metadata) / inline_icon(start_pos, block_metadata) / inline_stem(start_pos) / inline_keyboard(start_pos) / inline_button(start_pos) / inline_menu(start_pos) / mailto_macro(start_pos, block_metadata) / url_macro(start_pos, block_metadata) / inline_pass(start_pos) / link_macro(start_pos) / inline_autolink(start_pos) / inline_line_break(start_pos) / bold_text_unconstrained(start_pos, block_metadata) / bold_text_constrained_match() / italic_text_unconstrained(start_pos, block_metadata) / italic_text_constrained_match() / monospace_text_unconstrained(start_pos, block_metadata) / monospace_text_constrained_match() / highlight_text_unconstrained(start_pos, block_metadata) / highlight_text_constrained_match() / superscript_text(start_pos, block_metadata) / subscript_text(start_pos, block_metadata) / curved_quotation_text(start_pos, block_metadata) / curved_apostrophe_text(start_pos, block_metadata) / standalone_curved_apostrophe(start_pos, block_metadata)) [_]) )+) end:position!() { @@ -5818,4 +5927,70 @@ Content C. Ok(()) } + + #[test] + #[tracing_test::traced_test] + fn test_index_term_flow() -> Result<(), Error> { + let input = "= Test\n\nThis is about ((Arthur)) the king.\n"; + let mut state = ParserState::new(input); + let result = document_parser::document(input, &mut state)??; + + // Find the paragraph + let paragraph = result + .blocks + .iter() + .find_map(|b| { + if let Block::Paragraph(p) = b { + Some(p) + } else { + None + } + }) + .expect("paragraph exists"); + + // Check that the index term was parsed + let has_index_term = paragraph.content.iter().any(|inline| { + matches!(inline, InlineNode::Macro(InlineMacro::IndexTerm(it)) if it.is_visible() && it.term() == "Arthur") + }); + + assert!( + has_index_term, + "Expected to find visible index term 'Arthur', but found: {:?}", + paragraph.content + ); + Ok(()) + } + + #[test] + #[tracing_test::traced_test] + fn test_index_term_concealed() -> Result<(), Error> { + let input = "= Test\n\n(((Sword, Broadsword)))This is a concealed index term.\n"; + let mut state = ParserState::new(input); + let result = document_parser::document(input, &mut state)??; + + // Find the paragraph + let paragraph = result + .blocks + .iter() + .find_map(|b| { + if let Block::Paragraph(p) = b { + Some(p) + } else { + None + } + }) + .expect("paragraph exists"); + + // Check that the concealed index term was parsed + let has_concealed_term = paragraph.content.iter().any(|inline| { + matches!(inline, InlineNode::Macro(InlineMacro::IndexTerm(it)) if !it.is_visible() && it.term() == "Sword") + }); + + assert!( + has_concealed_term, + "Expected to find concealed index term 'Sword', but found: {:?}", + paragraph.content + ); + Ok(()) + } } diff --git a/acdc-parser/src/grammar/location_mapping.rs b/acdc-parser/src/grammar/location_mapping.rs index e8996742..5a903572 100644 --- a/acdc-parser/src/grammar/location_mapping.rs +++ b/acdc-parser/src/grammar/location_mapping.rs @@ -133,6 +133,7 @@ pub(crate) fn clamp_inline_node_locations(node: &mut InlineNode, input: &str) { } crate::InlineMacro::Pass(p) => clamp_location_bounds(&mut p.location, input), crate::InlineMacro::Stem(s) => clamp_location_bounds(&mut s.location, input), + crate::InlineMacro::IndexTerm(it) => clamp_location_bounds(&mut it.location, input), }, } } @@ -591,6 +592,7 @@ fn map_inline_macro( InlineMacro::Autolink(autolink) => autolink.location = map_loc(&autolink.location)?, InlineMacro::Stem(stem) => stem.location = map_loc(&stem.location)?, InlineMacro::Pass(pass) => pass.location = map_loc(&pass.location)?, + InlineMacro::IndexTerm(index_term) => index_term.location = map_loc(&index_term.location)?, } Ok(InlineNode::Macro(mapped_macro)) } diff --git a/acdc-parser/src/lib.rs b/acdc-parser/src/lib.rs index c2292744..ab191c3a 100644 --- a/acdc-parser/src/lib.rs +++ b/acdc-parser/src/lib.rs @@ -55,13 +55,13 @@ pub use model::{ ColumnFormat, ColumnStyle, ColumnWidth, Comment, CrossReference, CurvedApostrophe, CurvedQuotation, DelimitedBlock, DelimitedBlockType, DescriptionList, DescriptionListItem, DiscreteHeader, Document, DocumentAttribute, DocumentAttributes, ElementAttributes, Footnote, - Form, Header, Highlight, HorizontalAlignment, ICON_SIZES, Icon, Image, InlineMacro, InlineNode, - Italic, Keyboard, LineBreak, Link, ListItem, ListItemCheckedStatus, Location, Mailto, Menu, - Monospace, OrderedList, PageBreak, Paragraph, Pass, PassthroughKind, Plain, Position, Raw, - Role, Section, Source, StandaloneCurvedApostrophe, Stem, StemContent, StemNotation, Subscript, - Substitution, Subtitle, Superscript, Table, TableColumn, TableOfContents, TableRow, - ThematicBreak, Title, TocEntry, UnorderedList, Url, Verbatim, VerticalAlignment, Video, - inlines_to_string, + Form, Header, Highlight, HorizontalAlignment, ICON_SIZES, Icon, Image, IndexTerm, + IndexTermKind, InlineMacro, InlineNode, Italic, Keyboard, LineBreak, Link, ListItem, + ListItemCheckedStatus, Location, Mailto, Menu, Monospace, OrderedList, PageBreak, Paragraph, + Pass, PassthroughKind, Plain, Position, Raw, Role, Section, Source, StandaloneCurvedApostrophe, + Stem, StemContent, StemNotation, Subscript, Substitution, Subtitle, Superscript, Table, + TableColumn, TableOfContents, TableRow, ThematicBreak, Title, TocEntry, UnorderedList, Url, + Verbatim, VerticalAlignment, Video, inlines_to_string, }; pub use options::{Options, OptionsBuilder, SafeMode}; diff --git a/acdc-parser/src/model/inlines/converter.rs b/acdc-parser/src/model/inlines/converter.rs index 6cc0f76b..38b8e757 100644 --- a/acdc-parser/src/model/inlines/converter.rs +++ b/acdc-parser/src/model/inlines/converter.rs @@ -49,6 +49,10 @@ pub fn inlines_to_string(inlines: &[InlineNode]) -> String { InlineMacro::CrossReference(xref) => { xref.text.clone().unwrap_or_else(|| xref.target.clone()) } + // For visible index terms, return the term text; hidden ones return empty + InlineMacro::IndexTerm(index_term) if index_term.is_visible() => { + index_term.term().to_string() + } // Skip other macro types (images, footnotes, buttons, icons, etc.) InlineMacro::Image(_) | InlineMacro::Footnote(_) @@ -57,7 +61,8 @@ pub fn inlines_to_string(inlines: &[InlineNode]) -> String { | InlineMacro::Keyboard(_) | InlineMacro::Menu(_) | InlineMacro::Stem(_) - | InlineMacro::Icon(_) => String::new(), + | InlineMacro::Icon(_) + | InlineMacro::IndexTerm(_) => String::new(), }, // Callout references are rendered as their number in plain text contexts InlineNode::CalloutRef(callout) => format!("<{}>", callout.number), diff --git a/acdc-parser/src/model/inlines/macros.rs b/acdc-parser/src/model/inlines/macros.rs index 9d4df737..4e3de6d3 100644 --- a/acdc-parser/src/model/inlines/macros.rs +++ b/acdc-parser/src/model/inlines/macros.rs @@ -202,3 +202,77 @@ pub struct Stem { pub notation: StemNotation, pub location: Location, } + +/// The kind of index term, encoding both visibility and structure. +/// +/// This enum makes invalid states unrepresentable: flow terms can only have +/// a single term (no hierarchy), while concealed terms support up to three +/// hierarchical levels. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum IndexTermKind { + /// Visible in output, single term only. + /// + /// Created by `((term))` or `indexterm2:[term]`. + Flow(String), + /// Hidden from output, supports hierarchical entries. + /// + /// Created by `(((term,secondary,tertiary)))` or `indexterm:[term,secondary,tertiary]`. + Concealed { + term: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + secondary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + tertiary: Option, + }, +} + +/// An `IndexTerm` represents an index term in a document. +/// +/// Index terms can be either: +/// - **Flow terms** (visible): `((term))` or `indexterm2:[term]` - the term appears in the text +/// - **Concealed terms** (hidden): `(((term,secondary,tertiary)))` or `indexterm:[term,secondary,tertiary]` +/// - only appears in the index +/// +/// Concealed terms support hierarchical entries with primary, secondary, and tertiary levels. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct IndexTerm { + /// The kind and content of this index term. + pub kind: IndexTermKind, + pub location: Location, +} + +impl IndexTerm { + /// Returns the primary term. + #[must_use] + pub fn term(&self) -> &str { + match &self.kind { + IndexTermKind::Flow(term) | IndexTermKind::Concealed { term, .. } => term, + } + } + + /// Returns the secondary term, if any. + #[must_use] + pub fn secondary(&self) -> Option<&str> { + match &self.kind { + IndexTermKind::Flow(_) => None, + IndexTermKind::Concealed { secondary, .. } => secondary.as_deref(), + } + } + + /// Returns the tertiary term, if any. + #[must_use] + pub fn tertiary(&self) -> Option<&str> { + match &self.kind { + IndexTermKind::Flow(_) => None, + IndexTermKind::Concealed { tertiary, .. } => tertiary.as_deref(), + } + } + + /// Returns whether this term is visible in the output. + #[must_use] + pub fn is_visible(&self) -> bool { + matches!(self.kind, IndexTermKind::Flow(_)) + } +} diff --git a/acdc-parser/src/model/inlines/mod.rs b/acdc-parser/src/model/inlines/mod.rs index 115f649c..2544da68 100644 --- a/acdc-parser/src/model/inlines/mod.rs +++ b/acdc-parser/src/model/inlines/mod.rs @@ -67,6 +67,7 @@ pub enum InlineNode { /// | `Menu` | `menu:File[Save]` | Menu navigation path | /// | `Pass` | `pass:[content]` | Passthrough (no processing) | /// | `Stem` | `stem:[formula]` | Math notation | +/// | `IndexTerm` | `((term))` or `(((term)))` | Index term (visible or hidden) | /// /// # Example /// @@ -110,6 +111,8 @@ pub enum InlineMacro { Pass(Pass), /// Inline math: `stem:[formula]` or `latexmath:[...]` / `asciimath:[...]` Stem(Stem), + /// Index term: `((term))` (visible) or `(((term)))` (hidden) + IndexTerm(IndexTerm), } /// Macro to serialize inline format types (Bold, Italic, Monospace, etc.) @@ -226,104 +229,179 @@ where S: Serializer, { match macro_node { - InlineMacro::Footnote(footnote) => { - map.serialize_entry("name", "footnote")?; - map.serialize_entry("type", "inline")?; - map.serialize_entry("id", &footnote.id)?; - map.serialize_entry("inlines", &footnote.content)?; - map.serialize_entry("location", &footnote.location)?; - } - InlineMacro::Icon(icon) => { - map.serialize_entry("name", "icon")?; - map.serialize_entry("type", "inline")?; - map.serialize_entry("target", &icon.target)?; - if !icon.attributes.is_empty() { - map.serialize_entry("attributes", &icon.attributes)?; - } - map.serialize_entry("location", &icon.location)?; - } - InlineMacro::Image(image) => { - map.serialize_entry("name", "image")?; - map.serialize_entry("type", "inline")?; - map.serialize_entry("title", &image.title)?; - map.serialize_entry("target", &image.source)?; - map.serialize_entry("location", &image.location)?; - } - InlineMacro::Keyboard(keyboard) => { - map.serialize_entry("name", "keyboard")?; - map.serialize_entry("type", "inline")?; - map.serialize_entry("keys", &keyboard.keys)?; - map.serialize_entry("location", &keyboard.location)?; - } - InlineMacro::Button(button) => { - map.serialize_entry("name", "button")?; - map.serialize_entry("type", "inline")?; - map.serialize_entry("label", &button.label)?; - map.serialize_entry("location", &button.location)?; - } - InlineMacro::Menu(menu) => { - map.serialize_entry("name", "menu")?; - map.serialize_entry("type", "inline")?; - map.serialize_entry("target", &menu.target)?; - if !menu.items.is_empty() { - map.serialize_entry("items", &menu.items)?; - } - map.serialize_entry("location", &menu.location)?; - } - InlineMacro::Url(url) => { - map.serialize_entry("name", "ref")?; - map.serialize_entry("type", "inline")?; - map.serialize_entry("variant", "link")?; - map.serialize_entry("target", &url.target)?; - map.serialize_entry("location", &url.location)?; - map.serialize_entry("attributes", &url.attributes)?; - } - InlineMacro::Mailto(mailto) => { - map.serialize_entry("name", "ref")?; - map.serialize_entry("type", "inline")?; - map.serialize_entry("variant", "mailto")?; - map.serialize_entry("target", &mailto.target)?; - map.serialize_entry("location", &mailto.location)?; - map.serialize_entry("attributes", &mailto.attributes)?; - } - InlineMacro::Link(link) => { - map.serialize_entry("name", "ref")?; - map.serialize_entry("type", "inline")?; - map.serialize_entry("variant", "link")?; - map.serialize_entry("target", &link.target)?; - map.serialize_entry("location", &link.location)?; - map.serialize_entry("attributes", &link.attributes)?; - } - InlineMacro::Autolink(autolink) => { - map.serialize_entry("name", "ref")?; - map.serialize_entry("type", "inline")?; - map.serialize_entry("variant", "autolink")?; - map.serialize_entry("target", &autolink.url)?; - map.serialize_entry("location", &autolink.location)?; - } - InlineMacro::CrossReference(xref) => { - map.serialize_entry("name", "xref")?; - map.serialize_entry("type", "inline")?; - map.serialize_entry("target", &xref.target)?; - if let Some(text) = &xref.text { - map.serialize_entry("text", text)?; - } - map.serialize_entry("location", &xref.location)?; - } - InlineMacro::Pass(_) => { - return Err(S::Error::custom( - "inline passthrough macros are not part of the ASG specification and cannot be serialized", - )); - } - InlineMacro::Stem(stem) => { - map.serialize_entry("name", "stem")?; - map.serialize_entry("type", "inline")?; - map.serialize_entry("content", &stem.content)?; - map.serialize_entry("notation", &stem.notation)?; - map.serialize_entry("location", &stem.location)?; - } + InlineMacro::Footnote(f) => serialize_footnote::(f, map), + InlineMacro::Icon(i) => serialize_icon::(i, map), + InlineMacro::Image(i) => serialize_image::(i, map), + InlineMacro::Keyboard(k) => serialize_keyboard::(k, map), + InlineMacro::Button(b) => serialize_button::(b, map), + InlineMacro::Menu(m) => serialize_menu::(m, map), + InlineMacro::Url(u) => serialize_url::(u, map), + InlineMacro::Mailto(m) => serialize_mailto::(m, map), + InlineMacro::Link(l) => serialize_link::(l, map), + InlineMacro::Autolink(a) => serialize_autolink::(a, map), + InlineMacro::CrossReference(x) => serialize_xref::(x, map), + InlineMacro::Stem(s) => serialize_stem::(s, map), + InlineMacro::IndexTerm(i) => serialize_indexterm::(i, map), + InlineMacro::Pass(_) => Err(S::Error::custom( + "inline passthrough macros are not part of the ASG specification and cannot be serialized", + )), + } +} + +fn serialize_footnote(f: &Footnote, map: &mut S::SerializeMap) -> Result<(), S::Error> +where + S: Serializer, +{ + map.serialize_entry("name", "footnote")?; + map.serialize_entry("type", "inline")?; + map.serialize_entry("id", &f.id)?; + map.serialize_entry("inlines", &f.content)?; + map.serialize_entry("location", &f.location) +} + +fn serialize_icon(i: &Icon, map: &mut S::SerializeMap) -> Result<(), S::Error> +where + S: Serializer, +{ + map.serialize_entry("name", "icon")?; + map.serialize_entry("type", "inline")?; + map.serialize_entry("target", &i.target)?; + if !i.attributes.is_empty() { + map.serialize_entry("attributes", &i.attributes)?; + } + map.serialize_entry("location", &i.location) +} + +fn serialize_image(i: &Image, map: &mut S::SerializeMap) -> Result<(), S::Error> +where + S: Serializer, +{ + map.serialize_entry("name", "image")?; + map.serialize_entry("type", "inline")?; + map.serialize_entry("title", &i.title)?; + map.serialize_entry("target", &i.source)?; + map.serialize_entry("location", &i.location) +} + +fn serialize_keyboard(k: &Keyboard, map: &mut S::SerializeMap) -> Result<(), S::Error> +where + S: Serializer, +{ + map.serialize_entry("name", "keyboard")?; + map.serialize_entry("type", "inline")?; + map.serialize_entry("keys", &k.keys)?; + map.serialize_entry("location", &k.location) +} + +fn serialize_button(b: &Button, map: &mut S::SerializeMap) -> Result<(), S::Error> +where + S: Serializer, +{ + map.serialize_entry("name", "button")?; + map.serialize_entry("type", "inline")?; + map.serialize_entry("label", &b.label)?; + map.serialize_entry("location", &b.location) +} + +fn serialize_menu(m: &Menu, map: &mut S::SerializeMap) -> Result<(), S::Error> +where + S: Serializer, +{ + map.serialize_entry("name", "menu")?; + map.serialize_entry("type", "inline")?; + map.serialize_entry("target", &m.target)?; + if !m.items.is_empty() { + map.serialize_entry("items", &m.items)?; } - Ok(()) + map.serialize_entry("location", &m.location) +} + +fn serialize_url(u: &Url, map: &mut S::SerializeMap) -> Result<(), S::Error> +where + S: Serializer, +{ + map.serialize_entry("name", "ref")?; + map.serialize_entry("type", "inline")?; + map.serialize_entry("variant", "link")?; + map.serialize_entry("target", &u.target)?; + map.serialize_entry("location", &u.location)?; + map.serialize_entry("attributes", &u.attributes) +} + +fn serialize_mailto(m: &Mailto, map: &mut S::SerializeMap) -> Result<(), S::Error> +where + S: Serializer, +{ + map.serialize_entry("name", "ref")?; + map.serialize_entry("type", "inline")?; + map.serialize_entry("variant", "mailto")?; + map.serialize_entry("target", &m.target)?; + map.serialize_entry("location", &m.location)?; + map.serialize_entry("attributes", &m.attributes) +} + +fn serialize_link(l: &Link, map: &mut S::SerializeMap) -> Result<(), S::Error> +where + S: Serializer, +{ + map.serialize_entry("name", "ref")?; + map.serialize_entry("type", "inline")?; + map.serialize_entry("variant", "link")?; + map.serialize_entry("target", &l.target)?; + map.serialize_entry("location", &l.location)?; + map.serialize_entry("attributes", &l.attributes) +} + +fn serialize_autolink(a: &Autolink, map: &mut S::SerializeMap) -> Result<(), S::Error> +where + S: Serializer, +{ + map.serialize_entry("name", "ref")?; + map.serialize_entry("type", "inline")?; + map.serialize_entry("variant", "autolink")?; + map.serialize_entry("target", &a.url)?; + map.serialize_entry("location", &a.location) +} + +fn serialize_xref(x: &CrossReference, map: &mut S::SerializeMap) -> Result<(), S::Error> +where + S: Serializer, +{ + map.serialize_entry("name", "xref")?; + map.serialize_entry("type", "inline")?; + map.serialize_entry("target", &x.target)?; + if let Some(text) = &x.text { + map.serialize_entry("text", text)?; + } + map.serialize_entry("location", &x.location) +} + +fn serialize_stem(s: &Stem, map: &mut S::SerializeMap) -> Result<(), S::Error> +where + S: Serializer, +{ + map.serialize_entry("name", "stem")?; + map.serialize_entry("type", "inline")?; + map.serialize_entry("content", &s.content)?; + map.serialize_entry("notation", &s.notation)?; + map.serialize_entry("location", &s.location) +} + +fn serialize_indexterm(i: &IndexTerm, map: &mut S::SerializeMap) -> Result<(), S::Error> +where + S: Serializer, +{ + map.serialize_entry("name", "indexterm")?; + map.serialize_entry("type", "inline")?; + map.serialize_entry("term", i.term())?; + if let Some(secondary) = i.secondary() { + map.serialize_entry("secondary", secondary)?; + } + if let Some(tertiary) = i.tertiary() { + map.serialize_entry("tertiary", tertiary)?; + } + map.serialize_entry("visible", &i.is_visible())?; + map.serialize_entry("location", &i.location) } // ============================================================================= @@ -356,6 +434,11 @@ struct RawInlineFields { xreflabel: Option, bracketed: Option, number: Option, + // Index term fields + term: Option, + secondary: Option, + tertiary: Option, + visible: Option, } // ----------------------------------------------------------------------------- @@ -471,6 +554,27 @@ fn construct_stem(raw: RawInlineFields) -> Result { }))) } +fn construct_indexterm(raw: RawInlineFields) -> Result { + let term = raw.term.ok_or_else(|| E::missing_field("term"))?; + let visible = raw.visible.ok_or_else(|| E::missing_field("visible"))?; + let location = raw.location.ok_or_else(|| E::missing_field("location"))?; + + let kind = if visible { + IndexTermKind::Flow(term) + } else { + IndexTermKind::Concealed { + term, + secondary: raw.secondary, + tertiary: raw.tertiary, + } + }; + + Ok(InlineNode::Macro(InlineMacro::IndexTerm(IndexTerm { + kind, + location, + }))) +} + fn construct_xref(raw: RawInlineFields) -> Result { let target_val = raw.target.ok_or_else(|| E::missing_field("target"))?; let target: String = serde_json::from_value(target_val).map_err(E::custom)?; @@ -637,6 +741,7 @@ fn dispatch_inline(raw: RawInlineFields) -> Result ("btn" | "button", "inline") => construct_button(raw), ("menu", "inline") => construct_menu(raw), ("stem", "inline") => construct_stem(raw), + ("indexterm", "inline") => construct_indexterm(raw), ("xref", "inline") => construct_xref(raw), ("ref", "inline") => construct_ref(raw), ("span", "inline") => construct_span(raw), diff --git a/acdc-parser/src/proptests/invariants.rs b/acdc-parser/src/proptests/invariants.rs index a5b034f8..a9e7616a 100644 --- a/acdc-parser/src/proptests/invariants.rs +++ b/acdc-parser/src/proptests/invariants.rs @@ -337,6 +337,9 @@ fn verify_inline_locations_bounded(inline: &InlineNode, input_len: usize) { crate::InlineMacro::Stem(s) => { verify_location_bounded(&s.location, input_len, "stem"); } + crate::InlineMacro::IndexTerm(i) => { + verify_location_bounded(&i.location, input_len, "index term"); + } } } InlineNode::CalloutRef(callout) => { @@ -580,6 +583,9 @@ fn verify_inline_utf8_boundaries(inline: &InlineNode, input: &str) { crate::InlineMacro::Stem(s) => { verify_location_utf8(&s.location, input, "stem"); } + crate::InlineMacro::IndexTerm(i) => { + verify_location_utf8(&i.location, input, "index term"); + } }, InlineNode::CalloutRef(callout) => { verify_location_utf8(&callout.location, input, "callout ref"); @@ -718,6 +724,7 @@ fn get_inline_location(inline: &InlineNode) -> &Location { crate::InlineMacro::CrossReference(x) => &x.location, crate::InlineMacro::Pass(p) => &p.location, crate::InlineMacro::Stem(s) => &s.location, + crate::InlineMacro::IndexTerm(i) => &i.location, }, InlineNode::CalloutRef(c) => &c.location, } From d9d01fe1f4bfff542904d0d66b3152423eb5cf37 Mon Sep 17 00:00:00 2001 From: Norberto Lopes Date: Sun, 4 Jan 2026 13:25:01 +0100 Subject: [PATCH 2/2] feat(html,manpage,terminal): add visible index terms --- converters/html/src/inlines.rs | 11 +++++++++++ converters/manpage/src/inlines.rs | 10 ++++++++++ converters/terminal/src/inlines.rs | 8 ++++++++ 3 files changed, 29 insertions(+) diff --git a/converters/html/src/inlines.rs b/converters/html/src/inlines.rs index 9c291dfb..1538274e 100644 --- a/converters/html/src/inlines.rs +++ b/converters/html/src/inlines.rs @@ -486,6 +486,17 @@ fn render_inline_macro + ?Sized>( write!(w, "")?; } } + InlineMacro::IndexTerm(it) => { + // Flow terms (visible): output the term text + // Concealed terms (hidden): output nothing + // Index terms are stored for later index generation but may not appear in output + if it.is_visible() { + // Flow term: display the term in the text + let text = substitution_text(it.term(), options); + write!(w, "{text}")?; + } + // Concealed terms produce no output - they're only for index generation + } _ => { return Err(io::Error::new( io::ErrorKind::Unsupported, diff --git a/converters/manpage/src/inlines.rs b/converters/manpage/src/inlines.rs index 69564ce2..57c20754 100644 --- a/converters/manpage/src/inlines.rs +++ b/converters/manpage/src/inlines.rs @@ -330,6 +330,16 @@ fn visit_inline_macro( write!(w, "{}", stem.content)?; } + InlineMacro::IndexTerm(it) => { + // Flow terms (visible): output the term text + // Concealed terms (hidden): output nothing + if it.is_visible() { + let w = visitor.writer_mut(); + write!(w, "{}", manify(it.term(), EscapeMode::Normalize))?; + } + // Concealed terms produce no output - they're only for index generation + } + // Handle any future variants - skip unknown macros _ => {} } diff --git a/converters/terminal/src/inlines.rs b/converters/terminal/src/inlines.rs index e1c81afe..e1658009 100644 --- a/converters/terminal/src/inlines.rs +++ b/converters/terminal/src/inlines.rs @@ -344,6 +344,14 @@ fn render_inline_macro_to_writer( // Show stem content as-is (terminal can't render math) write!(w, "[{}]", stem.content)?; } + InlineMacro::IndexTerm(it) => { + // Flow terms (visible): output the term text + // Concealed terms (hidden): output nothing + if it.is_visible() { + write!(w, "{}", it.term())?; + } + // Concealed terms produce no output - they're only for index generation + } _ => { return Err(std::io::Error::new( std::io::ErrorKind::Unsupported,