use crate::profile::parser::source::File;
use std::borrow::Cow;
use std::collections::HashMap;
use std::error::Error;
use std::fmt::{self, Display, Formatter};
pub(super) type RawProfileSet<'a> = HashMap<&'a str, HashMap<&'a str, Cow<'a, str>>>;
pub(super) const WHITESPACE: &[char] = &[' ', '\t'];
const COMMENT: &[char] = &['#', ';'];
#[derive(Clone, Debug, Eq, PartialEq)]
struct Location {
line_number: usize,
path: String,
}
#[derive(Debug, Clone)]
pub struct ProfileParseError {
location: Location,
message: String,
}
impl Display for ProfileParseError {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(
f,
"error parsing {} on line {}:\n {}",
self.location.path, self.location.line_number, self.message
)
}
}
impl Error for ProfileParseError {}
fn validate_subproperty(value: &str, location: Location) -> Result<(), ProfileParseError> {
if value.trim_matches(WHITESPACE).is_empty() {
Ok(())
} else {
parse_property_line(value)
.map_err(|err| err.into_error("sub-property", location))
.map(|_| ())
}
}
fn is_empty_line(line: &str) -> bool {
line.trim_matches(WHITESPACE).is_empty()
}
fn is_comment_line(line: &str) -> bool {
line.starts_with(COMMENT)
}
struct Parser<'a> {
data: RawProfileSet<'a>,
state: State<'a>,
location: Location,
}
enum State<'a> {
Starting,
ReadingProfile {
profile: &'a str,
property: Option<&'a str>,
is_subproperty: bool,
},
}
pub(super) fn parse_profile_file(file: &File) -> Result<RawProfileSet<'_>, ProfileParseError> {
let mut parser = Parser {
data: HashMap::new(),
state: State::Starting,
location: Location {
line_number: 0,
path: file.path.clone().unwrap_or_default(),
},
};
parser.parse_profile(&file.contents)?;
Ok(parser.data)
}
impl<'a> Parser<'a> {
fn parse_profile(&mut self, file: &'a str) -> Result<(), ProfileParseError> {
for (line_number, line) in file.lines().enumerate() {
self.location.line_number = line_number + 1; if is_empty_line(line) || is_comment_line(line) {
continue;
}
if line.starts_with('[') {
self.read_profile_line(line)?;
} else if line.starts_with(WHITESPACE) {
self.read_property_continuation(line)?;
} else {
self.read_property_line(line)?;
}
}
Ok(())
}
fn read_property_line(&mut self, line: &'a str) -> Result<(), ProfileParseError> {
let location = &self.location;
let (current_profile, name) = match &self.state {
State::Starting => return Err(self.make_error("Expected a profile definition")),
State::ReadingProfile { profile, .. } => (
self.data.get_mut(*profile).expect("profile must exist"),
profile,
),
};
let (k, v) = parse_property_line(line)
.map_err(|err| err.into_error("property", location.clone()))?;
self.state = State::ReadingProfile {
profile: name,
property: Some(k),
is_subproperty: v.is_empty(),
};
current_profile.insert(k, v.into());
Ok(())
}
fn make_error(&self, message: &str) -> ProfileParseError {
ProfileParseError {
location: self.location.clone(),
message: message.into(),
}
}
fn read_property_continuation(&mut self, line: &'a str) -> Result<(), ProfileParseError> {
let current_property = match &self.state {
State::Starting => return Err(self.make_error("Expected a profile definition")),
State::ReadingProfile {
profile,
property: Some(property),
is_subproperty,
} => {
if *is_subproperty {
validate_subproperty(line, self.location.clone())?;
}
self.data
.get_mut(*profile)
.expect("profile must exist")
.get_mut(*property)
.expect("property must exist")
}
State::ReadingProfile {
profile: _,
property: None,
..
} => return Err(self.make_error("Expected a property definition, found continuation")),
};
let line = line.trim_matches(WHITESPACE);
let current_property = current_property.to_mut();
current_property.push('\n');
current_property.push_str(line);
Ok(())
}
fn read_profile_line(&mut self, line: &'a str) -> Result<(), ProfileParseError> {
let line = prepare_line(line, false);
let profile_name = line
.strip_prefix('[')
.ok_or_else(|| self.make_error("Profile definition must start with ]"))?
.strip_suffix(']')
.ok_or_else(|| self.make_error("Profile definition must end with ']'"))?;
if !self.data.contains_key(profile_name) {
self.data.insert(profile_name, Default::default());
}
self.state = State::ReadingProfile {
profile: profile_name,
property: None,
is_subproperty: false,
};
Ok(())
}
}
#[derive(Debug, Eq, PartialEq)]
enum PropertyError {
NoEquals,
NoName,
}
impl PropertyError {
fn into_error(self, ctx: &str, location: Location) -> ProfileParseError {
let mut ctx = ctx.to_string();
match self {
PropertyError::NoName => {
ctx.get_mut(0..1).unwrap().make_ascii_uppercase();
ProfileParseError {
location,
message: format!("{} did not have a name", ctx),
}
}
PropertyError::NoEquals => ProfileParseError {
location,
message: format!("Expected an '=' sign defining a {}", ctx),
},
}
}
}
fn parse_property_line(line: &str) -> Result<(&str, &str), PropertyError> {
let line = prepare_line(line, true);
let (k, v) = line.split_once('=').ok_or(PropertyError::NoEquals)?;
let k = k.trim_matches(WHITESPACE);
let v = v.trim_matches(WHITESPACE);
if k.is_empty() {
return Err(PropertyError::NoName);
}
Ok((k, v))
}
fn prepare_line(line: &str, comments_need_whitespace: bool) -> &str {
let line = line.trim_matches(WHITESPACE);
let mut prev_char_whitespace = false;
let mut comment_idx = None;
for (idx, chr) in line.char_indices() {
if (COMMENT.contains(&chr)) && (prev_char_whitespace || !comments_need_whitespace) {
comment_idx = Some(idx);
break;
}
prev_char_whitespace = chr.is_whitespace();
}
comment_idx
.map(|idx| &line[..idx])
.unwrap_or(line)
.trim_matches(WHITESPACE)
}
#[cfg(test)]
mod test {
use super::{parse_profile_file, prepare_line, Location};
use crate::profile::parser::parse::{parse_property_line, PropertyError};
use crate::profile::parser::source::File;
use crate::profile::profile_file::ProfileFileKind;
#[test]
fn property_parsing() {
assert_eq!(parse_property_line("a = b"), Ok(("a", "b")));
assert_eq!(parse_property_line("a=b"), Ok(("a", "b")));
assert_eq!(parse_property_line("a = b "), Ok(("a", "b")));
assert_eq!(parse_property_line(" a = b "), Ok(("a", "b")));
assert_eq!(parse_property_line(" a = b 🐱 "), Ok(("a", "b 🐱")));
assert_eq!(parse_property_line("a b"), Err(PropertyError::NoEquals));
assert_eq!(parse_property_line("= b"), Err(PropertyError::NoName));
assert_eq!(parse_property_line("a = "), Ok(("a", "")));
assert_eq!(
parse_property_line("something_base64=aGVsbG8gZW50aHVzaWFzdGljIHJlYWRlcg=="),
Ok(("something_base64", "aGVsbG8gZW50aHVzaWFzdGljIHJlYWRlcg=="))
);
}
#[test]
fn prepare_line_strips_comments() {
assert_eq!(
prepare_line("name = value # Comment with # sign", true),
"name = value"
);
assert_eq!(
prepare_line("name = value#Comment # sign", true),
"name = value#Comment"
);
assert_eq!(
prepare_line("name = value#Comment # sign", false),
"name = value"
);
}
#[test]
fn error_line_numbers() {
let file = File {
kind: ProfileFileKind::Config,
path: Some("~/.aws/config".into()),
contents: "[default\nk=v".into(),
};
let err = parse_profile_file(&file).expect_err("parsing should fail");
assert_eq!(err.message, "Profile definition must end with ']'");
assert_eq!(
err.location,
Location {
path: "~/.aws/config".into(),
line_number: 1
}
)
}
}