lex_core/lex/ast/elements/annotation.rs
1//! Annotation
2//!
3//! Annotations are a core element in lex, but not the document's content , rather it's metadata one.
4//! They provide not only a way for authors and collaborators to register non content related
5//! information, but the right hooks for tooling to build on top of lex (e.g., comments, review
6//! metadata, publishing hints).
7//!
8//! As such they provide : -
9//! - labels: a way to identify the annotation
10//! - parameters (optional): a way to provide structured data
11//! - Optional content, like all other elements:
12//! - Nestable containter that can host any element but sessions
13//! - Shorthand for for single or no content annotations.
14//!
15//!
16//! Syntax:
17//! Short Hand Form:
18//! <lex-marker> <label> <parameters>? <lex-marker>
19//! Long Hand Form:
20//! <lex-marker> <label> <parameters>? <lex-marker>
21//! <indent> <content> ... any number of content elements
22//! <dedent> <lex-marker>
23//!
24//! Parsing Structure:
25//!
26//! | Element | Prec. Blank | Head | Blank | Content | Tail |
27//! |------------|-------------|---------------------|-------|---------|---------------|
28//! | Annotation | Optional | DataMarkerLine | Yes | Yes | dedent |
29//!
30//! Special Case: Short form annotations are one-liners without content or dedent.
31//!
32//! Examples:
33//! Label only:
34//! :: image ::
35//! Label and parameters:
36//! :: note severity=high :: Check this carefully
37//! Marker form (no content):
38//! :: debug ::
39//! Parameters augmenting the label:
40//! :: meta type=python :: (parameters need an accompanying label)
41//! Long Form:
42//! :: label ::
43//! John has reviewed this paragraph. Hence we're only lacking:
44//! - Janest's approval
45//! - OK from legal
46//! Learn More:
47//! - The annotation spec: specs/v1/elements/annotation.lex
48//! - The annotation sample: specs/v1/samples/element-based/annotations/annotations.simple.lex
49//! - Labels: specs/v1/elements/label.lex
50//! - Parameters: specs/v1/elements/parameter.lex
51
52use super::super::range::{Position, Range};
53use super::super::traits::{AstNode, Container, Visitor, VisualStructure};
54use super::container::GeneralContainer;
55use super::content_item::ContentItem;
56use super::data::Data;
57use super::label::Label;
58use super::parameter::Parameter;
59use super::typed_content::ContentElement;
60use std::fmt;
61
62/// An annotation represents some metadata about an AST element.
63///
64/// # Reserved label namespace
65///
66/// Labels starting with `lex.` (the `lex.*` namespace, [`Annotation::RESERVED_NAMESPACE_PREFIX`])
67/// are reserved for core-defined semantics. Third-party tooling must not author labels in
68/// this namespace; the core may add new `lex.*` labels without a coordinating versioning
69/// concern. Non-reserved labels remain freely available for extensions
70/// (`mycompany.include`, `docs.embed`, etc.).
71///
72/// The current set of reserved labels:
73/// - [`Annotation::INCLUDE_LABEL`] (`"lex.include"`) — see `comms/specs/proposals/includes.lex`.
74#[derive(Debug, Clone, PartialEq)]
75pub struct Annotation {
76 pub data: Data,
77 pub children: GeneralContainer,
78 pub location: Range,
79}
80
81impl Annotation {
82 /// Reserved label prefix for core-defined annotation semantics.
83 ///
84 /// Any annotation whose label starts with this prefix is owned by the Lex
85 /// core and may carry behavior in the resolver / analysis layers. External
86 /// authors should pick a different namespace.
87 pub const RESERVED_NAMESPACE_PREFIX: &'static str = "lex.";
88
89 /// Reserved label for the include directive.
90 ///
91 /// An annotation with this label and a `src=` parameter is interpreted by
92 /// `lex_core::includes` as a request to splice another Lex file's content
93 /// into the parent container at the annotation's position. See
94 /// `comms/specs/proposals/includes.lex` for the full design.
95 pub const INCLUDE_LABEL: &'static str = "lex.include";
96
97 fn default_location() -> Range {
98 Range::new(0..0, Position::new(0, 0), Position::new(0, 0))
99 }
100 pub fn new(label: Label, parameters: Vec<Parameter>, children: Vec<ContentElement>) -> Self {
101 let data = Data::new(label, parameters);
102 Self::from_data(data, children)
103 }
104 pub fn marker(label: Label) -> Self {
105 Self::from_data(Data::new(label, Vec::new()), Vec::new())
106 }
107 pub fn with_parameters(label: Label, parameters: Vec<Parameter>) -> Self {
108 Self::from_data(Data::new(label, parameters), Vec::new())
109 }
110 pub fn from_data(data: Data, children: Vec<ContentElement>) -> Self {
111 Self {
112 data,
113 children: GeneralContainer::from_typed(children),
114 location: Self::default_location(),
115 }
116 }
117
118 /// Preferred builder
119 pub fn at(mut self, location: Range) -> Self {
120 self.location = location;
121 self
122 }
123
124 /// Range covering only the annotation header (label + parameters).
125 pub fn header_location(&self) -> &Range {
126 &self.data.location
127 }
128
129 /// Bounding range covering only the annotation's children.
130 pub fn body_location(&self) -> Option<Range> {
131 Range::bounding_box(self.children.iter().map(|item| item.range()))
132 }
133
134 /// Whether this annotation's label is in the reserved `lex.*` namespace.
135 pub fn is_reserved(&self) -> bool {
136 self.data
137 .label
138 .value
139 .starts_with(Self::RESERVED_NAMESPACE_PREFIX)
140 }
141
142 /// Whether this annotation is the include directive (label `lex.include`).
143 ///
144 /// Hides the string-match on the reserved label so callers don't sprinkle
145 /// `annotation.label == "lex.include"` throughout the codebase. Also serves
146 /// as the migration boundary if a future version models includes as a
147 /// distinct AST node type.
148 pub fn is_include(&self) -> bool {
149 self.data.label.value == Self::INCLUDE_LABEL
150 }
151
152 /// The `src=` parameter value with surrounding quotes stripped and
153 /// escape sequences resolved, if present.
154 ///
155 /// Useful on its own for any annotation that uses a `src` parameter
156 /// (verbatim-via-annotation, future `lex.*` directives, etc.). For
157 /// the include-specific case, callers typically pair this with
158 /// [`Annotation::is_include`].
159 ///
160 /// Returns an owned `String` rather than `&str` because escape
161 /// resolution may need to allocate. For quote-free values (the common
162 /// case) the returned string is the same as `parameter.value` minus
163 /// the leading/trailing `"`.
164 pub fn include_src(&self) -> Option<String> {
165 self.data
166 .parameters
167 .iter()
168 .find(|p| p.key == "src")
169 .map(|p| p.unquoted_value())
170 }
171}
172
173impl AstNode for Annotation {
174 fn node_type(&self) -> &'static str {
175 "Annotation"
176 }
177 fn display_label(&self) -> String {
178 if self.data.parameters.is_empty() {
179 self.data.label.value.clone()
180 } else {
181 format!(
182 "{} ({} params)",
183 self.data.label.value,
184 self.data.parameters.len()
185 )
186 }
187 }
188 fn range(&self) -> &Range {
189 &self.location
190 }
191
192 fn accept(&self, visitor: &mut dyn Visitor) {
193 visitor.visit_annotation(self);
194 super::super::traits::visit_children(visitor, &self.children);
195 visitor.leave_annotation(self);
196 }
197}
198
199impl VisualStructure for Annotation {
200 fn is_source_line_node(&self) -> bool {
201 true
202 }
203
204 fn has_visual_header(&self) -> bool {
205 true
206 }
207}
208
209impl Container for Annotation {
210 fn label(&self) -> &str {
211 &self.data.label.value
212 }
213 fn children(&self) -> &[ContentItem] {
214 &self.children
215 }
216 fn children_mut(&mut self) -> &mut Vec<ContentItem> {
217 self.children.as_mut_vec()
218 }
219}
220
221impl fmt::Display for Annotation {
222 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223 write!(
224 f,
225 "Annotation('{}', {} params, {} items)",
226 self.data.label.value,
227 self.data.parameters.len(),
228 self.children.len()
229 )
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use crate::lex::ast::elements::paragraph::Paragraph;
237 use crate::lex::ast::elements::typed_content::ContentElement;
238
239 #[test]
240 fn test_annotation_header_and_body_locations() {
241 let header_range = Range::new(0..4, Position::new(0, 0), Position::new(0, 4));
242 let child_range = Range::new(10..20, Position::new(1, 0), Position::new(2, 0));
243 let label = Label::new("note".to_string()).at(header_range.clone());
244 let data = Data::new(label, Vec::new()).at(header_range.clone());
245 let child = ContentElement::Paragraph(
246 Paragraph::from_line("body".to_string()).at(child_range.clone()),
247 );
248
249 let annotation = Annotation::from_data(data, vec![child]).at(Range::new(
250 0..25,
251 Position::new(0, 0),
252 Position::new(2, 0),
253 ));
254
255 assert_eq!(annotation.header_location().span, header_range.span);
256 assert_eq!(annotation.body_location().unwrap().span, child_range.span);
257 }
258
259 fn ann(label: &str, params: Vec<(&str, &str)>) -> Annotation {
260 let parameters = params
261 .into_iter()
262 .map(|(k, v)| Parameter::new(k.to_string(), v.to_string()))
263 .collect();
264 Annotation::with_parameters(Label::new(label.to_string()), parameters)
265 }
266
267 #[test]
268 fn test_is_reserved() {
269 assert!(ann("lex.include", vec![]).is_reserved());
270 assert!(ann("lex.foo.bar", vec![]).is_reserved());
271 // Boundary: a label that starts with "lex" but not "lex." is NOT reserved.
272 assert!(!ann("lexicon", vec![]).is_reserved());
273 assert!(!ann("review", vec![]).is_reserved());
274 assert!(!ann("mycompany.include", vec![]).is_reserved());
275 }
276
277 #[test]
278 fn test_is_include() {
279 assert!(ann("lex.include", vec![("src", "x.lex")]).is_include());
280 // Other lex.* labels are reserved but not includes.
281 assert!(!ann("lex.something_else", vec![]).is_include());
282 // Same trailing label without the lex. prefix is not an include.
283 assert!(!ann("include", vec![("src", "x.lex")]).is_include());
284 }
285
286 #[test]
287 fn test_include_src() {
288 let with_src = ann("lex.include", vec![("src", "chapters/01.lex")]);
289 assert_eq!(with_src.include_src().as_deref(), Some("chapters/01.lex"));
290
291 // The accessor is independent of the label — works for any annotation
292 // that happens to carry a `src` parameter (verbatim-via-annotation, etc.)
293 let other_with_src = ann("image", vec![("src", "diagram.png")]);
294 assert_eq!(other_with_src.include_src().as_deref(), Some("diagram.png"));
295
296 // No src parameter → None.
297 assert_eq!(ann("lex.include", vec![]).include_src(), None);
298 assert_eq!(
299 ann("lex.include", vec![("title", "Chapter 1")]).include_src(),
300 None
301 );
302 }
303
304 #[test]
305 fn test_include_src_strips_quotes_from_parsed_value() {
306 // When the parser stores a quoted string, include_src should return
307 // the unquoted form so callers (the resolver) can use it as a path.
308 let with_quoted = ann("lex.include", vec![("src", "\"chapters/01.lex\"")]);
309 assert_eq!(
310 with_quoted.include_src().as_deref(),
311 Some("chapters/01.lex")
312 );
313 }
314
315 #[test]
316 fn test_constants_match_documented_values() {
317 assert_eq!(Annotation::RESERVED_NAMESPACE_PREFIX, "lex.");
318 assert_eq!(Annotation::INCLUDE_LABEL, "lex.include");
319 // Sanity: the include label is itself reserved.
320 assert!(Annotation::INCLUDE_LABEL.starts_with(Annotation::RESERVED_NAMESPACE_PREFIX));
321 }
322}