Skip to main content

use_yaml/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4/// A discovered YAML mapping entry.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct YamlKeyValue {
7    pub key: String,
8    pub value: String,
9    pub line: usize,
10    pub indent: usize,
11}
12
13/// Returns `true` when the input contains YAML-like markers, list items, or mappings.
14pub fn looks_like_yaml(input: &str) -> bool {
15    input.lines().any(|line| {
16        let trimmed = line.trim();
17        !trimmed.is_empty()
18            && !trimmed.starts_with('#')
19            && (is_yaml_document_start(trimmed)
20                || is_yaml_document_end(trimmed)
21                || is_yaml_list_item(line)
22                || split_yaml_key_value(line).is_some())
23    })
24}
25
26/// Returns `true` when a line is a YAML document start marker.
27pub fn is_yaml_document_start(line: &str) -> bool {
28    line.trim() == "---"
29}
30
31/// Returns `true` when a line is a YAML document end marker.
32pub fn is_yaml_document_end(line: &str) -> bool {
33    line.trim() == "..."
34}
35
36/// Strips leading and trailing YAML document markers when they are present.
37pub fn strip_yaml_document_markers(input: &str) -> &str {
38    let mut start = 0;
39
40    if !input.is_empty() {
41        let first_line_end = input.find('\n').unwrap_or(input.len());
42        let first_line = input[..first_line_end].trim_end_matches('\r');
43        if is_yaml_document_start(first_line) {
44            start = first_line_end.min(input.len());
45            if start < input.len() && input.as_bytes()[start] == b'\n' {
46                start += 1;
47            }
48        }
49    }
50
51    let remainder = &input[start..];
52    let mut body_end = remainder.len();
53
54    while body_end > 0 && matches!(remainder.as_bytes()[body_end - 1], b'\n' | b'\r') {
55        body_end -= 1;
56    }
57
58    if body_end == 0 {
59        return "";
60    }
61
62    let last_line_start = remainder[..body_end]
63        .rfind('\n')
64        .map_or(0, |index| index + 1);
65    let last_line = remainder[last_line_start..body_end].trim_end_matches('\r');
66
67    let end = if is_yaml_document_end(last_line) {
68        let mut marker_start = last_line_start;
69        if marker_start > 0 && remainder.as_bytes()[marker_start - 1] == b'\n' {
70            marker_start -= 1;
71        }
72        if marker_start > 0 && remainder.as_bytes()[marker_start - 1] == b'\r' {
73            marker_start -= 1;
74        }
75        marker_start
76    } else {
77        remainder.len()
78    };
79
80    &remainder[..end]
81}
82
83/// Returns `true` when a line is a YAML list item.
84pub fn is_yaml_list_item(line: &str) -> bool {
85    let trimmed = line.trim_start();
86    trimmed == "-" || trimmed.starts_with("- ")
87}
88
89/// Counts leading space indentation on a YAML line.
90pub fn yaml_indent(line: &str) -> usize {
91    line.chars().take_while(|ch| *ch == ' ').count()
92}
93
94/// Splits a simple YAML mapping line on the first `:` outside quotes.
95pub fn split_yaml_key_value(line: &str) -> Option<(String, String)> {
96    let content = strip_yaml_comment(line).trim();
97    if content.is_empty() || is_yaml_document_start(content) || is_yaml_document_end(content) {
98        return None;
99    }
100
101    if is_yaml_list_item(content) {
102        return None;
103    }
104
105    let mut in_single = false;
106    let mut in_double = false;
107    let mut escaped = false;
108    let mut chars = content.char_indices().peekable();
109
110    while let Some((index, ch)) = chars.next() {
111        if in_single {
112            if ch == '\'' {
113                in_single = false;
114            }
115            continue;
116        }
117
118        if in_double {
119            if escaped {
120                escaped = false;
121            } else if ch == '\\' {
122                escaped = true;
123            } else if ch == '"' {
124                in_double = false;
125            }
126            continue;
127        }
128
129        match ch {
130            '\'' => in_single = true,
131            '"' => in_double = true,
132            ':' => {
133                let next_is_value = chars
134                    .peek()
135                    .map(|(_, next)| next.is_whitespace())
136                    .unwrap_or(true);
137                if !next_is_value {
138                    continue;
139                }
140
141                let key = content[..index].trim();
142                if key.is_empty() {
143                    return None;
144                }
145
146                let value = content[index + 1..].trim().to_string();
147                return Some((key.to_string(), value));
148            }
149            _ => {}
150        }
151    }
152
153    None
154}
155
156/// Extracts simple YAML mapping entries from the input.
157pub fn extract_yaml_key_values(input: &str) -> Vec<YamlKeyValue> {
158    let mut pairs = Vec::new();
159
160    for (line_index, line) in input.lines().enumerate() {
161        if let Some((key, value)) = split_yaml_key_value(line) {
162            pairs.push(YamlKeyValue {
163                key,
164                value,
165                line: line_index + 1,
166                indent: yaml_indent(line),
167            });
168        }
169    }
170
171    pairs
172}
173
174/// Quotes a string as a single-quoted YAML scalar.
175pub fn quote_yaml_string(input: &str) -> String {
176    format!("'{}'", input.replace('\'', "''"))
177}
178
179fn strip_yaml_comment(line: &str) -> &str {
180    let mut in_single = false;
181    let mut in_double = false;
182    let mut escaped = false;
183
184    for (index, ch) in line.char_indices() {
185        if in_single {
186            if ch == '\'' {
187                in_single = false;
188            }
189            continue;
190        }
191
192        if in_double {
193            if escaped {
194                escaped = false;
195            } else if ch == '\\' {
196                escaped = true;
197            } else if ch == '"' {
198                in_double = false;
199            }
200            continue;
201        }
202
203        match ch {
204            '\'' => in_single = true,
205            '"' => in_double = true,
206            '#' => return &line[..index],
207            _ => {}
208        }
209    }
210
211    line
212}