jsonschema_annotator/annotator/
yaml.rs1use super::{Annotator, AnnotatorConfig, ExistingCommentBehavior};
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 self.config.include_default {
38 if let Some(default) = &annotation.default {
39 lines.push(format!("{}# Default: {}", indent_str, default));
40 }
41 }
42
43 if lines.is_empty() {
44 None
45 } else {
46 Some(lines.join("\n"))
47 }
48 }
49
50 fn build_line_path_map(&self, content: &str) -> Vec<(usize, String, usize, bool)> {
52 let mut result = Vec::new();
53 let mut path_stack: Vec<(String, usize)> = Vec::new();
54 let lines: Vec<&str> = content.lines().collect();
55
56 for (line_num, line) in lines.iter().enumerate() {
57 if line.trim().is_empty() || line.trim().starts_with('#') {
59 continue;
60 }
61
62 let indent = line.len() - line.trim_start().len();
64
65 while let Some((_, prev_indent)) = path_stack.last() {
67 if indent <= *prev_indent {
68 path_stack.pop();
69 } else {
70 break;
71 }
72 }
73
74 if let Some(key) = extract_yaml_key(line) {
76 let path = if path_stack.is_empty() {
78 key.clone()
79 } else {
80 let parent_path: Vec<_> = path_stack.iter().map(|(k, _)| k.as_str()).collect();
81 format!("{}.{}", parent_path.join("."), key)
82 };
83
84 let has_existing_comment = self.has_preceding_comment(&lines, line_num, indent);
86
87 result.push((line_num, path.clone(), indent, has_existing_comment));
88
89 if line.trim().ends_with(':') || is_mapping_start(line) {
91 path_stack.push((key, indent));
92 }
93 }
94 }
95
96 result
97 }
98
99 fn has_preceding_comment(&self, lines: &[&str], line_num: usize, key_indent: usize) -> bool {
102 if line_num == 0 {
103 return false;
104 }
105
106 let prev_line = lines[line_num - 1];
108 let prev_trimmed = prev_line.trim();
109
110 if prev_trimmed.starts_with('#') {
112 let prev_indent = prev_line.len() - prev_line.trim_start().len();
113 return prev_indent == key_indent;
115 }
116
117 false
118 }
119}
120
121fn extract_yaml_key(line: &str) -> Option<String> {
123 let trimmed = line.trim();
124
125 if trimmed.starts_with('-') {
127 return None;
128 }
129
130 let colon_pos = trimmed.find(':')?;
132 let key = trimmed[..colon_pos].trim();
133
134 if key.is_empty() {
136 return None;
137 }
138
139 Some(key.to_string())
140}
141
142fn is_mapping_start(line: &str) -> bool {
144 let trimmed = line.trim();
145 if let Some(colon_pos) = trimmed.find(':') {
146 let after_colon = trimmed[colon_pos + 1..].trim();
147 after_colon.is_empty() || after_colon.starts_with('#')
148 } else {
149 false
150 }
151}
152
153enum YamlOperation {
155 Insert { line_num: usize, comment: String },
157 Replace { line_num: usize, comment: String },
159 Append { line_num: usize, comment: String },
161}
162
163impl Annotator for YamlAnnotator {
164 fn annotate(
165 &self,
166 content: &str,
167 annotations: &AnnotationMap,
168 ) -> Result<String, AnnotatorError> {
169 let _: serde_yaml::Value = serde_yaml::from_str(content)
171 .map_err(|e| Error::new(AnnotatorErrorKind::Parse).with_source(e))?;
172
173 let line_paths = self.build_line_path_map(content);
174
175 let mut operations: Vec<YamlOperation> = Vec::new();
177
178 for (line_num, path, indent, has_existing_comment) in &line_paths {
179 if let Some(ann) = annotations.get(path) {
180 if let Some(comment) = self.format_comment(ann, *indent) {
181 let op = match (self.config.existing_comments, *has_existing_comment) {
182 (ExistingCommentBehavior::Skip, true) => None,
183 (ExistingCommentBehavior::Replace, true) => {
184 Some(YamlOperation::Replace {
185 line_num: *line_num,
186 comment,
187 })
188 }
189 (ExistingCommentBehavior::Append, true) => {
190 Some(YamlOperation::Append {
191 line_num: *line_num,
192 comment,
193 })
194 }
195 _ => {
196 Some(YamlOperation::Insert {
198 line_num: *line_num,
199 comment,
200 })
201 }
202 };
203
204 if let Some(operation) = op {
205 operations.push(operation);
206 }
207 }
208 }
209 }
210
211 operations.sort_by(|a, b| {
213 let line_a = match a {
214 YamlOperation::Insert { line_num, .. }
215 | YamlOperation::Replace { line_num, .. }
216 | YamlOperation::Append { line_num, .. } => *line_num,
217 };
218 let line_b = match b {
219 YamlOperation::Insert { line_num, .. }
220 | YamlOperation::Replace { line_num, .. }
221 | YamlOperation::Append { line_num, .. } => *line_num,
222 };
223 line_b.cmp(&line_a)
224 });
225
226 let mut lines: Vec<String> = content.lines().map(String::from).collect();
228
229 for op in operations {
230 match op {
231 YamlOperation::Insert { line_num, comment } => {
232 let comment_lines: Vec<String> = comment.lines().map(String::from).collect();
233 for (i, comment_line) in comment_lines.into_iter().enumerate() {
234 lines.insert(line_num + i, comment_line);
235 }
236 }
237 YamlOperation::Replace { line_num, comment } => {
238 let mut start_line = line_num - 1;
240 while start_line > 0 && lines[start_line - 1].trim().starts_with('#') {
241 start_line -= 1;
242 }
243 for _ in start_line..line_num {
245 lines.remove(start_line);
246 }
247 let comment_lines: Vec<String> = comment.lines().map(String::from).collect();
249 for (i, comment_line) in comment_lines.into_iter().enumerate() {
250 lines.insert(start_line + i, comment_line);
251 }
252 }
253 YamlOperation::Append { line_num, comment } => {
254 let comment_lines: Vec<String> = comment.lines().map(String::from).collect();
256 for (i, comment_line) in comment_lines.into_iter().enumerate() {
257 lines.insert(line_num + i, comment_line);
258 }
259 }
260 }
261 }
262
263 let mut result = lines.join("\n");
265 if content.ends_with('\n') {
266 result.push('\n');
267 }
268
269 Ok(result)
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use crate::schema::Annotation;
277 use insta::assert_snapshot;
278
279 fn make_annotations(items: &[(&str, Option<&str>, Option<&str>)]) -> AnnotationMap {
280 let mut map = AnnotationMap::new();
281 for (path, title, desc) in items {
282 let mut ann = Annotation::new(*path);
283 if let Some(t) = title {
284 ann = ann.with_title(*t);
285 }
286 if let Some(d) = desc {
287 ann = ann.with_description(*d);
288 }
289 map.insert(ann);
290 }
291 map
292 }
293
294 #[test]
295 fn test_simple_annotation() {
296 let content = "port: 8080\n";
297 let annotations = make_annotations(&[("port", Some("Port"), Some("Server port number"))]);
298
299 let annotator = YamlAnnotator::new(AnnotatorConfig::default());
300 let result = annotator.annotate(content, &annotations).unwrap();
301
302 assert_snapshot!(result);
303 }
304
305 #[test]
306 fn test_nested_mapping() {
307 let content = r#"server:
308 port: 8080
309 host: localhost
310"#;
311 let annotations = make_annotations(&[
312 ("server", Some("Server Config"), None),
313 ("server.port", Some("Port"), Some("The port to listen on")),
314 ("server.host", Some("Host"), None),
315 ]);
316
317 let annotator = YamlAnnotator::new(AnnotatorConfig::default());
318 let result = annotator.annotate(content, &annotations).unwrap();
319
320 assert_snapshot!(result);
321 }
322
323 #[test]
324 fn test_title_only() {
325 let content = "name: test\n";
326 let annotations = make_annotations(&[("name", Some("Name"), Some("Full description"))]);
327
328 let annotator = YamlAnnotator::new(AnnotatorConfig::titles_only());
329 let result = annotator.annotate(content, &annotations).unwrap();
330
331 assert_snapshot!(result);
332 }
333
334 #[test]
335 fn test_description_only() {
336 let content = "name: test\n";
337 let annotations = make_annotations(&[("name", Some("Name"), Some("Full description"))]);
338
339 let annotator = YamlAnnotator::new(AnnotatorConfig::descriptions_only());
340 let result = annotator.annotate(content, &annotations).unwrap();
341
342 assert_snapshot!(result);
343 }
344
345 #[test]
346 fn test_preserve_existing_comments() {
347 let content = "# Existing comment\nport: 8080\n";
348 let annotations = make_annotations(&[("port", Some("Port"), None)]);
349
350 let annotator = YamlAnnotator::new(AnnotatorConfig::default());
351 let result = annotator.annotate(content, &annotations).unwrap();
352
353 assert_snapshot!(result);
354 }
355
356 #[test]
357 fn test_no_matching_annotations() {
358 let content = "name: test\nage: 30\n";
359 let annotations = make_annotations(&[("other", Some("Other"), None)]);
360
361 let annotator = YamlAnnotator::new(AnnotatorConfig::default());
362 let result = annotator.annotate(content, &annotations).unwrap();
363
364 assert_snapshot!(result);
365 }
366
367 #[test]
368 fn test_deeply_nested() {
369 let content = r#"database:
370 connection:
371 host: localhost
372 port: 5432
373"#;
374 let annotations = make_annotations(&[
375 ("database", Some("Database"), None),
376 ("database.connection", Some("Connection Settings"), None),
377 ("database.connection.host", Some("Host"), Some("Database server hostname")),
378 ("database.connection.port", Some("Port"), None),
379 ]);
380
381 let annotator = YamlAnnotator::new(AnnotatorConfig::default());
382 let result = annotator.annotate(content, &annotations).unwrap();
383
384 assert_snapshot!(result);
385 }
386
387 #[test]
388 fn test_inline_values() {
389 let content = r#"server:
390 host: localhost
391 port: 8080
392 enabled: true
393"#;
394 let annotations = make_annotations(&[
395 ("server.host", Some("Hostname"), None),
396 ("server.port", Some("Port Number"), None),
397 ("server.enabled", Some("Enabled"), Some("Whether the server is enabled")),
398 ]);
399
400 let annotator = YamlAnnotator::new(AnnotatorConfig::default());
401 let result = annotator.annotate(content, &annotations).unwrap();
402
403 assert_snapshot!(result);
404 }
405
406 #[test]
407 fn test_skip_existing_comments() {
408 let content = "# Existing comment\nport: 8080\nhost: localhost\n";
409 let annotations = make_annotations(&[
410 ("port", Some("Port"), None),
411 ("host", Some("Host"), None),
412 ]);
413
414 let config = AnnotatorConfig {
415 existing_comments: ExistingCommentBehavior::Skip,
416 ..Default::default()
417 };
418 let annotator = YamlAnnotator::new(config);
419 let result = annotator.annotate(content, &annotations).unwrap();
420
421 assert_snapshot!(result);
423 }
424
425 #[test]
426 fn test_append_to_existing_comments() {
427 let content = "# Existing comment\nport: 8080\n";
428 let annotations = make_annotations(&[("port", Some("Port"), None)]);
429
430 let config = AnnotatorConfig {
431 existing_comments: ExistingCommentBehavior::Append,
432 ..Default::default()
433 };
434 let annotator = YamlAnnotator::new(config);
435 let result = annotator.annotate(content, &annotations).unwrap();
436
437 assert_snapshot!(result);
438 }
439
440 #[test]
441 fn test_replace_existing_comments() {
442 let content = "# Existing comment\nport: 8080\n";
443 let annotations = make_annotations(&[("port", Some("Port"), None)]);
444
445 let config = AnnotatorConfig {
446 existing_comments: ExistingCommentBehavior::Replace,
447 ..Default::default()
448 };
449 let annotator = YamlAnnotator::new(config);
450 let result = annotator.annotate(content, &annotations).unwrap();
451
452 assert_snapshot!(result);
453 }
454
455 #[test]
456 fn test_include_default_value() {
457 let content = "port: 8080\n";
458
459 let mut map = AnnotationMap::new();
460 map.insert(
461 Annotation::new("port")
462 .with_title("Port")
463 .with_description("The port number")
464 .with_default("3000"),
465 );
466
467 let config = AnnotatorConfig {
468 include_default: true,
469 ..Default::default()
470 };
471 let annotator = YamlAnnotator::new(config);
472 let result = annotator.annotate(content, &map).unwrap();
473
474 assert_snapshot!(result);
475 }
476
477 #[test]
478 fn test_default_value_disabled_by_default() {
479 let content = "port: 8080\n";
480
481 let mut map = AnnotationMap::new();
482 map.insert(
483 Annotation::new("port")
484 .with_title("Port")
485 .with_default("3000"),
486 );
487
488 let annotator = YamlAnnotator::new(AnnotatorConfig::default());
490 let result = annotator.annotate(content, &map).unwrap();
491
492 assert_snapshot!(result);
493 }
494}