jsonschema_annotator/annotator/
yaml.rs1use super::{Annotator, AnnotatorConfig};
2use crate::error::{AnnotatorError, AnnotatorErrorKind, Error};
3use crate::schema::{Annotation, AnnotationMap};
4
5pub struct YamlAnnotator {
10 config: AnnotatorConfig,
11}
12
13impl YamlAnnotator {
14 pub fn new(config: AnnotatorConfig) -> Self {
15 Self { config }
16 }
17
18 fn format_comment(&self, annotation: &Annotation, indent: usize) -> Option<String> {
19 let mut lines = Vec::new();
20 let indent_str = " ".repeat(indent);
21
22 if self.config.include_title {
23 if let Some(title) = &annotation.title {
24 lines.push(format!("{}# {}", indent_str, title));
25 }
26 }
27
28 if self.config.include_description {
29 if let Some(desc) = &annotation.description {
30 let width = self.config.max_line_width.unwrap_or(78).saturating_sub(indent + 2);
31 for line in textwrap::wrap(desc, width) {
32 lines.push(format!("{}# {}", indent_str, line));
33 }
34 }
35 }
36
37 if lines.is_empty() {
38 None
39 } else {
40 Some(lines.join("\n"))
41 }
42 }
43
44 fn build_line_path_map(&self, content: &str) -> Vec<(usize, String, usize)> {
46 let mut result = Vec::new();
47 let mut path_stack: Vec<(String, usize)> = Vec::new();
48
49 for (line_num, line) in content.lines().enumerate() {
50 if line.trim().is_empty() || line.trim().starts_with('#') {
52 continue;
53 }
54
55 let indent = line.len() - line.trim_start().len();
57
58 while let Some((_, prev_indent)) = path_stack.last() {
60 if indent <= *prev_indent {
61 path_stack.pop();
62 } else {
63 break;
64 }
65 }
66
67 if let Some(key) = extract_yaml_key(line) {
69 let path = if path_stack.is_empty() {
71 key.clone()
72 } else {
73 let parent_path: Vec<_> = path_stack.iter().map(|(k, _)| k.as_str()).collect();
74 format!("{}.{}", parent_path.join("."), key)
75 };
76
77 result.push((line_num, path.clone(), indent));
78
79 if line.trim().ends_with(':') || is_mapping_start(line) {
81 path_stack.push((key, indent));
82 }
83 }
84 }
85
86 result
87 }
88}
89
90fn extract_yaml_key(line: &str) -> Option<String> {
92 let trimmed = line.trim();
93
94 if trimmed.starts_with('-') {
96 return None;
97 }
98
99 let colon_pos = trimmed.find(':')?;
101 let key = trimmed[..colon_pos].trim();
102
103 if key.is_empty() {
105 return None;
106 }
107
108 Some(key.to_string())
109}
110
111fn is_mapping_start(line: &str) -> bool {
113 let trimmed = line.trim();
114 if let Some(colon_pos) = trimmed.find(':') {
115 let after_colon = trimmed[colon_pos + 1..].trim();
116 after_colon.is_empty() || after_colon.starts_with('#')
117 } else {
118 false
119 }
120}
121
122impl Annotator for YamlAnnotator {
123 fn annotate(
124 &self,
125 content: &str,
126 annotations: &AnnotationMap,
127 ) -> Result<String, AnnotatorError> {
128 let _: serde_yaml::Value = serde_yaml::from_str(content)
130 .map_err(|e| Error::new(AnnotatorErrorKind::Parse).with_source(e))?;
131
132 let line_paths = self.build_line_path_map(content);
133
134 let mut insertions: Vec<(usize, String, usize)> = Vec::new();
136
137 for (line_num, path, indent) in &line_paths {
138 if let Some(ann) = annotations.get(path) {
139 if let Some(comment) = self.format_comment(ann, *indent) {
140 insertions.push((*line_num, comment, *indent));
141 }
142 }
143 }
144
145 insertions.sort_by(|a, b| b.0.cmp(&a.0));
147
148 let mut lines: Vec<String> = content.lines().map(String::from).collect();
150
151 for (line_num, comment, _indent) in insertions {
152 let comment_lines: Vec<String> = comment.lines().map(String::from).collect();
154 for (i, comment_line) in comment_lines.into_iter().enumerate() {
155 lines.insert(line_num + i, comment_line);
156 }
157 }
158
159 let mut result = lines.join("\n");
161 if content.ends_with('\n') {
162 result.push('\n');
163 }
164
165 Ok(result)
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::schema::Annotation;
173 use insta::assert_snapshot;
174
175 fn make_annotations(items: &[(&str, Option<&str>, Option<&str>)]) -> AnnotationMap {
176 let mut map = AnnotationMap::new();
177 for (path, title, desc) in items {
178 let mut ann = Annotation::new(*path);
179 if let Some(t) = title {
180 ann = ann.with_title(*t);
181 }
182 if let Some(d) = desc {
183 ann = ann.with_description(*d);
184 }
185 map.insert(ann);
186 }
187 map
188 }
189
190 #[test]
191 fn test_simple_annotation() {
192 let content = "port: 8080\n";
193 let annotations = make_annotations(&[("port", Some("Port"), Some("Server port number"))]);
194
195 let annotator = YamlAnnotator::new(AnnotatorConfig::default());
196 let result = annotator.annotate(content, &annotations).unwrap();
197
198 assert_snapshot!(result);
199 }
200
201 #[test]
202 fn test_nested_mapping() {
203 let content = r#"server:
204 port: 8080
205 host: localhost
206"#;
207 let annotations = make_annotations(&[
208 ("server", Some("Server Config"), None),
209 ("server.port", Some("Port"), Some("The port to listen on")),
210 ("server.host", Some("Host"), None),
211 ]);
212
213 let annotator = YamlAnnotator::new(AnnotatorConfig::default());
214 let result = annotator.annotate(content, &annotations).unwrap();
215
216 assert_snapshot!(result);
217 }
218
219 #[test]
220 fn test_title_only() {
221 let content = "name: test\n";
222 let annotations = make_annotations(&[("name", Some("Name"), Some("Full description"))]);
223
224 let annotator = YamlAnnotator::new(AnnotatorConfig::titles_only());
225 let result = annotator.annotate(content, &annotations).unwrap();
226
227 assert_snapshot!(result);
228 }
229
230 #[test]
231 fn test_description_only() {
232 let content = "name: test\n";
233 let annotations = make_annotations(&[("name", Some("Name"), Some("Full description"))]);
234
235 let annotator = YamlAnnotator::new(AnnotatorConfig::descriptions_only());
236 let result = annotator.annotate(content, &annotations).unwrap();
237
238 assert_snapshot!(result);
239 }
240
241 #[test]
242 fn test_preserve_existing_comments() {
243 let content = "# Existing comment\nport: 8080\n";
244 let annotations = make_annotations(&[("port", Some("Port"), None)]);
245
246 let annotator = YamlAnnotator::new(AnnotatorConfig::default());
247 let result = annotator.annotate(content, &annotations).unwrap();
248
249 assert_snapshot!(result);
250 }
251
252 #[test]
253 fn test_no_matching_annotations() {
254 let content = "name: test\nage: 30\n";
255 let annotations = make_annotations(&[("other", Some("Other"), None)]);
256
257 let annotator = YamlAnnotator::new(AnnotatorConfig::default());
258 let result = annotator.annotate(content, &annotations).unwrap();
259
260 assert_snapshot!(result);
261 }
262
263 #[test]
264 fn test_deeply_nested() {
265 let content = r#"database:
266 connection:
267 host: localhost
268 port: 5432
269"#;
270 let annotations = make_annotations(&[
271 ("database", Some("Database"), None),
272 ("database.connection", Some("Connection Settings"), None),
273 ("database.connection.host", Some("Host"), Some("Database server hostname")),
274 ("database.connection.port", Some("Port"), None),
275 ]);
276
277 let annotator = YamlAnnotator::new(AnnotatorConfig::default());
278 let result = annotator.annotate(content, &annotations).unwrap();
279
280 assert_snapshot!(result);
281 }
282
283 #[test]
284 fn test_inline_values() {
285 let content = r#"server:
286 host: localhost
287 port: 8080
288 enabled: true
289"#;
290 let annotations = make_annotations(&[
291 ("server.host", Some("Hostname"), None),
292 ("server.port", Some("Port Number"), None),
293 ("server.enabled", Some("Enabled"), Some("Whether the server is enabled")),
294 ]);
295
296 let annotator = YamlAnnotator::new(AnnotatorConfig::default());
297 let result = annotator.annotate(content, &annotations).unwrap();
298
299 assert_snapshot!(result);
300 }
301}