jsonschema_annotator/annotator/
yaml.rs

1use super::{Annotator, AnnotatorConfig, ExistingCommentBehavior};
2use crate::error::{AnnotatorError, AnnotatorErrorKind, Error};
3use crate::schema::{Annotation, AnnotationMap};
4
5/// YAML document annotator using string-based line injection
6///
7/// Since yaml-edit doesn't support comment injection, we use a string-based
8/// approach that tracks indentation to map lines to paths.
9pub 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    /// Build a map of line numbers to (path, indent, has_existing_comment) for YAML content
51    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            // Skip empty lines and comments
58            if line.trim().is_empty() || line.trim().starts_with('#') {
59                continue;
60            }
61
62            // Calculate indentation
63            let indent = line.len() - line.trim_start().len();
64
65            // Pop path components that are at same or deeper indentation
66            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            // Extract key from line (handle "key:" and "key: value" formats)
75            if let Some(key) = extract_yaml_key(line) {
76                // Build current path
77                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                // Check if there's an existing comment immediately before this line
85                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                // Check if this line starts a nested object (ends with ":" or has nested content)
90                if line.trim().ends_with(':') || is_mapping_start(line) {
91                    path_stack.push((key, indent));
92                }
93            }
94        }
95
96        result
97    }
98
99    /// Check if there's a comment line immediately preceding the given line
100    /// that belongs to this key (at the same or appropriate indentation)
101    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        // Look at the line immediately before
107        let prev_line = lines[line_num - 1];
108        let prev_trimmed = prev_line.trim();
109
110        // If it's a comment, check if it's at the same indentation level
111        if prev_trimmed.starts_with('#') {
112            let prev_indent = prev_line.len() - prev_line.trim_start().len();
113            // Comment belongs to this key if it's at the same indentation
114            return prev_indent == key_indent;
115        }
116
117        false
118    }
119}
120
121/// Extract the key from a YAML line like "key: value" or "key:"
122fn extract_yaml_key(line: &str) -> Option<String> {
123    let trimmed = line.trim();
124
125    // Skip list items for now (lines starting with -)
126    if trimmed.starts_with('-') {
127        return None;
128    }
129
130    // Find the colon
131    let colon_pos = trimmed.find(':')?;
132    let key = trimmed[..colon_pos].trim();
133
134    // Skip if key is empty or quoted (complex keys)
135    if key.is_empty() {
136        return None;
137    }
138
139    Some(key.to_string())
140}
141
142/// Check if a line is a mapping start (key with no inline value)
143fn 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
153/// Represents an operation to perform on the YAML lines
154enum YamlOperation {
155    /// Insert comment lines before the target line
156    Insert { line_num: usize, comment: String },
157    /// Replace the existing comment line with a new one
158    Replace { line_num: usize, comment: String },
159    /// Insert comment lines after an existing comment (before the key)
160    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        // Validate YAML syntax by attempting to parse
170        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        // Collect operations
176        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                            // Prepend (default) or no existing comment
197                            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        // Sort by line number descending to process from bottom up
212        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        // Apply operations
227        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                    // Find and count existing comment lines before this key
239                    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                    // Remove old comments
244                    for _ in start_line..line_num {
245                        lines.remove(start_line);
246                    }
247                    // Insert new comments at the start position
248                    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                    // Insert after existing comments (right before the key)
255                    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        // Preserve trailing newline if original had one
264        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        // port should keep its existing comment, host should get the annotation
422        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        // Default config has include_default = false
489        let annotator = YamlAnnotator::new(AnnotatorConfig::default());
490        let result = annotator.annotate(content, &map).unwrap();
491
492        assert_snapshot!(result);
493    }
494}