use super::{KeySpan, lines};
fn strip_comment(line: &str) -> &str {
super::strip_comment(line, &['#'], true)
}
pub(crate) fn keys(text: &str) -> Vec<KeySpan> {
let mut stack: Vec<(usize, String)> = Vec::new();
let mut spans = Vec::new();
for (offset, line) in lines(text) {
let content = strip_comment(line);
let trimmed = content.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "-" {
continue;
}
if trimmed.starts_with("---") || trimmed.starts_with("...") {
stack.clear();
continue;
}
let indent = content.len() - trimmed.len();
let entry = trimmed.strip_prefix("- ").map_or(trimmed, str::trim_start);
let column = indent + (trimmed.len() - entry.len());
while stack.last().is_some_and(|(depth, _)| *depth >= column) {
stack.pop();
}
let Some(separator) = key_separator(entry) else {
if !stack.is_empty() {
spans.push(KeySpan {
start: offset + column,
end: offset + content.len(),
path: path_of(&stack),
});
}
continue;
};
let key = entry[..separator].trim().trim_matches(['"', '\'']);
stack.push((column, key.to_string()));
let value_at = column + separator + 1;
if content[value_at.min(content.len())..].trim().is_empty() {
continue; }
spans.push(KeySpan {
start: offset + value_at,
end: offset + content.len(),
path: path_of(&stack),
});
}
spans
}
fn path_of(stack: &[(usize, String)]) -> String {
stack
.iter()
.map(|(_, key)| key.as_str())
.collect::<Vec<&str>>()
.join(".")
}
fn key_separator(entry: &str) -> Option<usize> {
let bytes = entry.as_bytes();
(0..bytes.len())
.find(|index| bytes[*index] == b':' && bytes.get(index + 1).is_none_or(|n| *n == b' '))
}
#[cfg(test)]
mod tests {
use super::*;
fn at(text: &str, needle: &str) -> Option<String> {
let at = text.find(needle).expect("the needle is in the text");
keys(text)
.into_iter()
.find(|span| span.start <= at && at < span.end)
.map(|span| span.path)
}
#[test]
fn a_nested_key_becomes_a_dotted_path() {
let text = "server:\n bind: 10.0.0.5\n";
assert_eq!(at(text, "10.0.0.5").as_deref(), Some("server.bind"));
}
#[test]
fn a_top_level_key_stands_alone() {
assert_eq!(at("bind: 10.0.0.5\n", "10.0.0.5").as_deref(), Some("bind"));
}
#[test]
fn an_ipv6_value_is_not_cut_in_half_by_its_own_colons() {
let text = "dns:\n upstream: 2001:db8::1\n";
assert_eq!(at(text, "2001:db8::1").as_deref(), Some("dns.upstream"));
}
#[test]
fn dedenting_closes_the_keys_it_left() {
let text = "a:\n b:\n c: 10.0.0.1\nd: 10.0.0.2\n";
assert_eq!(at(text, "10.0.0.1").as_deref(), Some("a.b.c"));
assert_eq!(at(text, "10.0.0.2").as_deref(), Some("d"));
}
#[test]
fn a_sequence_entry_belongs_to_the_key_above_it() {
let text = "allow:\n - 10.0.0.0/8\n - 192.168.0.0/16\n";
assert_eq!(at(text, "10.0.0.0/8").as_deref(), Some("allow"));
assert_eq!(at(text, "192.168.0.0/16").as_deref(), Some("allow"));
}
#[test]
fn a_mapping_inside_a_sequence_keeps_its_own_key() {
let text = "peers:\n - addr: 10.0.0.2\n gateway: 10.0.0.1\n";
assert_eq!(at(text, "10.0.0.2").as_deref(), Some("peers.addr"));
assert_eq!(at(text, "10.0.0.1").as_deref(), Some("peers.gateway"));
}
#[test]
fn a_document_separator_resets_the_stack() {
let text = "a:\n b: 10.0.0.1\n---\nc: 10.0.0.2\n";
assert_eq!(at(text, "10.0.0.2").as_deref(), Some("c"));
}
#[test]
fn a_comment_is_not_a_value() {
assert!(keys("# bind: 10.0.0.5\n").is_empty());
assert_eq!(strip_comment("a: b # c"), "a: b ");
assert_eq!(strip_comment("a: http://x#y"), "a: http://x#y");
}
#[test]
fn a_key_with_no_value_names_no_span() {
assert!(keys("server:\n").is_empty());
}
#[test]
fn a_sequence_entry_with_no_key_above_it_names_nothing() {
assert!(keys("- 10.0.0.1\n- 10.0.0.2\n").is_empty());
assert!(keys("just some prose 10.0.0.1\n").is_empty());
}
}