use super::format::Reader;
use super::{csv, dotenv, ini, json, toml, yaml};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct KeySpan {
pub(crate) start: usize,
pub(crate) end: usize,
pub(crate) path: String,
}
pub(crate) fn key_spans(text: &str, reader: Reader) -> Vec<KeySpan> {
match reader {
Reader::Json => json::key_spans(text),
Reader::Yaml => yaml::key_spans(text),
Reader::Toml => toml::key_spans(text),
Reader::Ini => ini::key_spans(text),
Reader::Dotenv => dotenv::key_spans(text),
Reader::Csv => csv::key_spans(text, csv::COMMA),
Reader::Tsv => csv::key_spans(text, csv::TAB),
Reader::Text => Vec::new(),
}
}
pub(crate) fn key_at(spans: &[KeySpan], offset: usize) -> Option<&str> {
let after = spans.partition_point(|span| span.start <= offset);
let span = spans.get(after.checked_sub(1)?)?;
(offset < span.end).then_some(span.path.as_str())
}
pub(crate) fn lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
let mut offset = 0;
text.split_inclusive('\n').map(move |line| {
let start = offset;
offset += line.len();
(start, line.trim_end_matches(['\n', '\r']))
})
}
pub(crate) fn value_length(raw: &str, comments: &[u8]) -> usize {
let bytes = raw.as_bytes();
let mut quote: Option<u8> = None;
let mut after_space = false;
let mut at = 0;
while at < bytes.len() {
let byte = bytes[at];
at += 1;
if quote == Some(b'"') && byte == b'\\' {
at += 1;
continue;
}
if quote == Some(byte) {
quote = None;
after_space = false;
continue;
}
if quote.is_some() {
after_space = false;
continue;
}
if byte == b'"' || byte == b'\'' {
quote = Some(byte);
after_space = false;
continue;
}
if after_space && comments.contains(&byte) {
return raw[..at - 1].trim_end().len();
}
after_space = byte.is_ascii_whitespace();
}
raw.trim_end().len()
}
pub(crate) fn join<'a>(segments: impl IntoIterator<Item = &'a str>) -> String {
segments
.into_iter()
.filter(|segment| !segment.is_empty())
.collect::<Vec<&str>>()
.join(".")
}
#[cfg(test)]
mod tests {
use super::*;
fn spans() -> Vec<KeySpan> {
vec![
KeySpan {
start: 5,
end: 10,
path: "a".to_string(),
},
KeySpan {
start: 20,
end: 30,
path: "b.c".to_string(),
},
]
}
#[test]
fn an_offset_inside_a_span_finds_its_key() {
assert_eq!(key_at(&spans(), 5), Some("a"));
assert_eq!(key_at(&spans(), 9), Some("a"));
assert_eq!(key_at(&spans(), 25), Some("b.c"));
}
#[test]
fn an_offset_outside_every_span_finds_nothing() {
assert_eq!(key_at(&spans(), 0), None);
assert_eq!(key_at(&spans(), 10), None);
assert_eq!(key_at(&spans(), 15), None);
assert_eq!(key_at(&spans(), 30), None);
assert_eq!(key_at(&[], 0), None);
}
#[test]
fn a_plain_text_document_has_no_spans() {
assert!(key_spans("id = 1\n", Reader::Text).is_empty());
}
#[test]
fn every_line_carries_the_offset_it_starts_at() {
let text = "one\ntwo\nthree";
let found: Vec<(usize, &str)> = lines(text).collect();
assert_eq!(found, [(0, "one"), (4, "two"), (8, "three")]);
}
#[test]
fn a_carriage_return_is_not_part_of_a_line() {
let found: Vec<(usize, &str)> = lines("one\r\ntwo\r\n").collect();
assert_eq!(found, [(0, "one"), (5, "two")]);
}
#[test]
fn a_trailing_newline_does_not_add_an_empty_line() {
assert_eq!(lines("one\n").count(), 1);
assert_eq!(lines("").count(), 0);
}
const HASH: &[u8] = b"#";
#[test]
fn a_comment_ends_the_value() {
assert_eq!(value_length(" 1 # a comment", HASH), 2);
assert_eq!(value_length(" 1\t# a comment", HASH), 2);
assert_eq!(value_length(" 1", HASH), 2);
assert_eq!(value_length("", HASH), 0);
}
#[test]
fn a_comment_character_inside_a_string_is_part_of_the_value() {
assert_eq!(value_length(r#" "a # b""#, HASH), 8);
assert_eq!(value_length(" 'a # b'", HASH), 8);
assert_eq!(value_length(r#" "a # b" # gone"#, HASH), 8);
assert_eq!(value_length(r#" "a\" # b""#, HASH), 10);
}
#[test]
fn a_comment_character_with_no_space_before_it_is_part_of_the_value() {
assert_eq!(value_length(" b#c", HASH), 4);
assert_eq!(value_length("#ff0000", HASH), 7);
}
#[test]
fn a_reader_chooses_which_characters_open_a_comment() {
assert_eq!(value_length(" 1 ; two", b";#"), 2);
assert_eq!(value_length(" 1 ; two", HASH), 8, "not this dialect's");
}
#[test]
fn a_value_that_is_only_a_comment_has_no_length() {
assert_eq!(value_length(" # all of it", HASH), 0);
}
#[test]
fn a_path_skips_the_segments_a_root_leaves_empty() {
assert_eq!(join(["a", "b"]), "a.b");
assert_eq!(join(["", "b"]), "b");
assert_eq!(join([""; 0]), "");
}
}