badness_parser/bib/ast/nodes.rs
1//! Typed [`AstNode`](super::AstNode) wrappers over the BibTeX CST, with syntactic
2//! accessors. The bib analog of [`crate::ast::nodes`]. Purely syntactic: they know
3//! nothing about what a field or entry *means* (AGENTS.md decisions #2, #10).
4
5use rowan::{NodeOrToken, TextRange};
6
7use super::{AstNode, child, children};
8use crate::bib::syntax::{SyntaxKind, SyntaxNode};
9
10/// Declares a newtype wrapper over a `SyntaxNode` of exactly one bib `SyntaxKind`,
11/// implementing [`AstNode`]. Only the identity is generated; accessors are
12/// hand-written. The bib analog of `crate::ast::nodes::ast_node!`.
13macro_rules! ast_node {
14 ($(#[$meta:meta])* $name:ident, $kind:ident) => {
15 $(#[$meta])*
16 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
17 pub struct $name {
18 syntax: SyntaxNode,
19 }
20
21 impl AstNode for $name {
22 fn can_cast(kind: SyntaxKind) -> bool {
23 kind == SyntaxKind::$kind
24 }
25
26 fn cast(syntax: SyntaxNode) -> Option<Self> {
27 Self::can_cast(syntax.kind()).then_some(Self { syntax })
28 }
29
30 fn syntax(&self) -> &SyntaxNode {
31 &self.syntax
32 }
33 }
34 };
35}
36
37ast_node!(
38 /// A regular bibliographic entry: `@type{ key, fields }`.
39 Entry, ENTRY
40);
41ast_node!(
42 /// An `@string{ name = value }` macro definition.
43 StringEntry, STRING_ENTRY
44);
45ast_node!(
46 /// A `name = value` field.
47 Field, FIELD
48);
49ast_node!(
50 /// The right-hand side of a field's `=`; pieces separated by `#`.
51 Value, VALUE
52);
53ast_node!(
54 /// The type word following `@` (`article`, `string`, …).
55 EntryType, ENTRY_TYPE
56);
57ast_node!(
58 /// A cite key.
59 Key, KEY
60);
61ast_node!(
62 /// A field / macro name left of `=`.
63 FieldName, FIELD_NAME
64);
65
66/// Concatenated text of a node's direct `WORD`/`NUMBER` token children. Reassembles
67/// a name or key the lexer kept as one word run anyway (`westfahl:space` lexes as a
68/// single `WORD`, since `:`/`-`/`.` are word characters), and tolerates the rare run
69/// split across `WORD`+`NUMBER`. Structural punctuation and trivia are skipped. Kept
70/// free so both the typed accessors and the free-function shims share it.
71pub(crate) fn joined_words(node: &SyntaxNode) -> String {
72 let mut text = String::new();
73 for element in node.children_with_tokens() {
74 if let NodeOrToken::Token(token) = element
75 && matches!(token.kind(), SyntaxKind::WORD | SyntaxKind::NUMBER)
76 {
77 text.push_str(token.text());
78 }
79 }
80 text
81}
82
83/// [`joined_words`], but `None` for an empty run — the shape every name/key accessor
84/// wants.
85fn nonempty_words(node: &SyntaxNode) -> Option<String> {
86 let text = joined_words(node);
87 (!text.is_empty()).then_some(text)
88}
89
90impl EntryType {
91 /// The type word, or `None` when empty. Case is preserved; callers normalize.
92 pub fn text(&self) -> Option<String> {
93 nonempty_words(&self.syntax)
94 }
95}
96
97impl Key {
98 /// The cite-key text, or `None` when empty.
99 pub fn text(&self) -> Option<String> {
100 nonempty_words(&self.syntax)
101 }
102}
103
104impl FieldName {
105 /// The field / macro name, or `None` when empty.
106 pub fn text(&self) -> Option<String> {
107 nonempty_words(&self.syntax)
108 }
109}
110
111impl Entry {
112 /// The entry type — the word following `@` (`article`, …), or `None` for a
113 /// malformed entry with no `ENTRY_TYPE` child.
114 pub fn entry_type(&self) -> Option<String> {
115 child::<EntryType>(&self.syntax)?.text()
116 }
117
118 /// The cite key and the byte range of its `KEY` node, or `None` when the entry has
119 /// no key (a recovery case) or the key is empty.
120 pub fn cite_key(&self) -> Option<(String, TextRange)> {
121 let key = child::<Key>(&self.syntax)?;
122 key.text().map(|text| (text, key.syntax.text_range()))
123 }
124
125 /// The `FIELD` children, in source order.
126 pub fn fields(&self) -> impl Iterator<Item = Field> {
127 children::<Field>(&self.syntax)
128 }
129}
130
131impl StringEntry {
132 /// The macro name defined by `@string{ name = value }` and the byte range of its
133 /// `FIELD_NAME` node, or `None` for a malformed `@string` with no `name = …` field.
134 pub fn def_name(&self) -> Option<(String, TextRange)> {
135 let name = child::<Field>(&self.syntax)?.name_node()?;
136 name.text().map(|text| (text, name.syntax.text_range()))
137 }
138}
139
140impl Field {
141 /// The field's `FIELD_NAME` node, if present.
142 pub fn name_node(&self) -> Option<FieldName> {
143 child::<FieldName>(&self.syntax)
144 }
145
146 /// The field name (the text of its `FIELD_NAME`), or `None` if absent.
147 pub fn name(&self) -> Option<String> {
148 self.name_node()?.text()
149 }
150
151 /// The `VALUE` node (the right-hand side of `=`), or `None` if absent.
152 pub fn value(&self) -> Option<Value> {
153 child::<Value>(&self.syntax)
154 }
155}
156
157impl Value {
158 /// The bare-macro *uses* inside this value: each `LITERAL` piece whose single
159 /// token is a `WORD` (an unquoted, unbraced name) is an `@string` reference. A
160 /// `LITERAL` wrapping a `NUMBER` is a literal number, not a macro use, and is
161 /// skipped. Yields `(name, range)` with the range of the `LITERAL` piece.
162 pub fn macro_uses(&self) -> impl Iterator<Item = (String, TextRange)> {
163 macro_uses_of(&self.syntax)
164 }
165}
166
167/// The shared body of [`Value::macro_uses`], kept kind-agnostic so the free-function
168/// shim can call it on any node.
169pub(crate) fn macro_uses_of(node: &SyntaxNode) -> impl Iterator<Item = (String, TextRange)> {
170 node.children()
171 .filter(|n| n.kind() == SyntaxKind::LITERAL)
172 .filter_map(|literal| {
173 let token = literal
174 .children_with_tokens()
175 .filter_map(|e| e.into_token())
176 .find(|t| matches!(t.kind(), SyntaxKind::WORD | SyntaxKind::NUMBER))?;
177 (token.kind() == SyntaxKind::WORD)
178 .then(|| (token.text().to_string(), literal.text_range()))
179 })
180}