#![forbid(unsafe_code)]
#![warn(missing_docs)]
use std::fmt;
use std::ops::Range;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Quote {
None,
Single,
Double,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
key: String,
value: String,
quote: Quote,
exported: bool,
line: usize,
line_span: Range<usize>,
key_span: Range<usize>,
value_span: Range<usize>,
}
impl Entry {
#[must_use]
pub fn key(&self) -> &str {
&self.key
}
#[must_use]
pub fn value(&self) -> &str {
&self.value
}
#[must_use]
pub fn quote(&self) -> Quote {
self.quote
}
#[must_use]
pub fn exported(&self) -> bool {
self.exported
}
#[must_use]
pub fn line(&self) -> usize {
self.line
}
#[must_use]
pub fn line_span(&self) -> Range<usize> {
self.line_span.clone()
}
#[must_use]
pub fn key_span(&self) -> Range<usize> {
self.key_span.clone()
}
#[must_use]
pub fn value_span(&self) -> Range<usize> {
self.value_span.clone()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Document {
entries: Vec<Entry>,
bom: bool,
}
impl Document {
#[must_use]
pub fn entries(&self) -> &[Entry] {
&self.entries
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&str> {
self.entries
.iter()
.rev()
.find(|e| e.key == key)
.map(Entry::value)
}
pub fn entries_for<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'a Entry> + 'a {
self.entries.iter().filter(move |e| e.key == key)
}
pub fn unique(&self, key: &str) -> Result<&Entry, EditError> {
let mut found = self.entries.iter().filter(|e| e.key == key);
let first = found.next().ok_or(EditError::Missing)?;
match found.count() {
0 => Ok(first),
more => Err(EditError::Duplicate(more + 1)),
}
}
#[must_use]
pub fn has_bom(&self) -> bool {
self.bom
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError {
line: usize,
message: String,
}
impl ParseError {
#[must_use]
pub fn line(&self) -> usize {
self.line
}
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "line {}: {}", self.line, self.message)
}
}
impl std::error::Error for ParseError {}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum EditError {
Parse(ParseError),
Missing,
Duplicate(usize),
Unencodable,
WouldChangeMeaning,
}
impl fmt::Display for EditError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Parse(e) => write!(f, "{e}"),
Self::Missing => f.write_str("key is not assigned"),
Self::Duplicate(n) => write!(f, "key is assigned {n} times"),
Self::Unencodable => f.write_str("value cannot be encoded exactly in this context"),
Self::WouldChangeMeaning => {
f.write_str("edit would change the meaning of the remaining text")
}
}
}
}
impl std::error::Error for EditError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Parse(e) => Some(e),
_ => None,
}
}
}
impl From<ParseError> for EditError {
fn from(e: ParseError) -> Self {
Self::Parse(e)
}
}
const BOM: char = '\u{feff}';
pub fn parse(text: &str) -> Result<Document, ParseError> {
let (body, base) = match text.strip_prefix(BOM) {
Some(rest) => (rest, BOM.len_utf8()),
None => (text, 0),
};
let mut entries = Vec::new();
let mut start = base;
for (i, chunk) in body.split_inclusive('\n').enumerate() {
let line_span = start..start + chunk.len();
start += chunk.len();
let content = chunk
.strip_suffix('\n')
.map_or(chunk, |c| c.strip_suffix('\r').unwrap_or(c));
if let Some(entry) = parse_line(content, i + 1, line_span)? {
entries.push(entry);
}
}
Ok(Document {
entries,
bom: base > 0,
})
}
fn parse_line(
content: &str,
line: usize,
line_span: Range<usize>,
) -> Result<Option<Entry>, ParseError> {
let err = |message: String| ParseError { line, message };
let at = |r: Range<usize>| line_span.start + r.start..line_span.start + r.end;
let trimmed = content.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
return Ok(None);
}
let mut pos = content.len() - trimmed.len();
let exported = trimmed.starts_with("export ");
if exported {
let rest = &content[pos + "export ".len()..];
pos = content.len() - rest.trim_start().len();
}
let assignment = &content[pos..];
let Some(eq) = assignment.find('=') else {
return Err(err(format!(
"expected `KEY=VALUE`, found `{}`",
assignment.trim_end()
)));
};
let key = assignment[..eq].trim_end();
if key.is_empty() {
return Err(err("empty key before `=`".to_string()));
}
if key.contains(char::is_whitespace) {
return Err(err(format!("key `{key}` contains whitespace")));
}
let raw_start = pos + eq + 1;
let (value, quote, region) =
parse_value(&content[raw_start..]).map_err(|m| err(m.to_string()))?;
Ok(Some(Entry {
key: key.to_string(),
value,
quote,
exported,
line,
key_span: at(pos..pos + key.len()),
value_span: at(raw_start + region.start..raw_start + region.end),
line_span,
}))
}
fn parse_value(raw: &str) -> Result<(String, Quote, Range<usize>), &'static str> {
let v = raw.trim_start();
let lead = raw.len() - v.len();
if let Some(rest) = v.strip_prefix('\'') {
let end = rest.find('\'').ok_or("unterminated single-quoted value")?;
return Ok((rest[..end].to_string(), Quote::Single, lead..lead + end + 2));
}
if let Some(rest) = v.strip_prefix('"') {
let mut out = String::new();
let mut chars = rest.char_indices();
loop {
match chars.next() {
None => return Err("unterminated double-quoted value"),
Some((i, '"')) => return Ok((out, Quote::Double, lead..lead + i + 2)),
Some((_, '\\')) => match chars.next() {
Some((_, 'n')) => out.push('\n'),
Some((_, 'r')) => out.push('\r'),
Some((_, 't')) => out.push('\t'),
Some((_, '\\')) => out.push('\\'),
Some((_, '"')) => out.push('"'),
Some((_, other)) => {
out.push('\\');
out.push(other);
}
None => return Err("trailing backslash in value"),
},
Some((_, c)) => out.push(c),
}
}
}
let region = &raw[..raw.find(" #").unwrap_or(raw.len())];
let token = region.trim();
if token.is_empty() {
return Ok((String::new(), Quote::None, 0..0));
}
let start = region.len() - region.trim_start().len();
Ok((token.to_string(), Quote::None, start..start + token.len()))
}
#[must_use]
pub fn encode_value(value: &str) -> Option<String> {
if value
.chars()
.any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
{
return None;
}
let bare_ok = value == value.trim()
&& !value.contains(" #")
&& !value.starts_with(['\'', '"', '#'])
&& !value.contains(['\n', '\r', '\t']);
if bare_ok {
return Some(value.to_string());
}
Some(double_quoted(value))
}
fn double_quoted(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for c in value.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
other => out.push(other),
}
}
out.push('"');
out
}
pub fn set(text: &str, key: &str, value: &str) -> Result<String, EditError> {
let doc = parse(text)?;
let span = doc.unique(key)?.value_span();
let encoded = encode_value(value).ok_or(EditError::Unencodable)?;
let out = splice(text, span.clone(), &encoded);
if parse(&out).is_ok_and(|d| d.get(key) == Some(value)) {
return Ok(out);
}
if !encoded.starts_with('"') {
let out = splice(text, span, &double_quoted(value));
if parse(&out).is_ok_and(|d| d.get(key) == Some(value)) {
return Ok(out);
}
}
Err(EditError::Unencodable)
}
pub fn remove(text: &str, key: &str) -> Result<String, EditError> {
let doc = parse(text)?;
let span = doc.unique(key)?.line_span();
let out = splice(text, span, "");
let after = parse(&out).map_err(|_| EditError::WouldChangeMeaning)?;
let remaining = doc
.entries()
.iter()
.filter(|e| e.key() != key)
.map(|e| (e.key(), e.value(), e.quote(), e.exported()));
let reparsed = after
.entries()
.iter()
.map(|e| (e.key(), e.value(), e.quote(), e.exported()));
if doc.has_bom() != after.has_bom() || !remaining.eq(reparsed) {
return Err(EditError::WouldChangeMeaning);
}
Ok(out)
}
fn splice(text: &str, span: Range<usize>, with: &str) -> String {
let mut out = String::with_capacity(text.len() - span.len() + with.len());
out.push_str(&text[..span.start]);
out.push_str(with);
out.push_str(&text[span.end..]);
out
}
#[cfg(test)]
mod tests;
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
pub struct ReadmeDoctests;