1use crate::env_config::source::File;
16use std::borrow::Cow;
17use std::collections::HashMap;
18use std::error::Error;
19use std::fmt::{self, Display, Formatter};
20
21pub(super) type RawProfileSet<'a> = HashMap<&'a str, HashMap<Cow<'a, str>, Cow<'a, str>>>;
23
24pub(crate) const WHITESPACE: &[char] = &[' ', '\t'];
29const COMMENT: &[char] = &['#', ';'];
30
31#[derive(Clone, Debug, Eq, PartialEq)]
33struct Location {
34 line_number: usize,
35 path: String,
36}
37
38#[derive(Debug, Clone)]
40pub struct EnvConfigParseError {
41 location: Location,
43
44 message: String,
46}
47
48impl Display for EnvConfigParseError {
49 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
50 write!(
51 f,
52 "error parsing {} on line {}:\n {}",
53 self.location.path, self.location.line_number, self.message
54 )
55 }
56}
57
58impl Error for EnvConfigParseError {}
59
60fn validate_subproperty(value: &str, location: Location) -> Result<(), EnvConfigParseError> {
66 if value.trim_matches(WHITESPACE).is_empty() {
67 Ok(())
68 } else {
69 parse_property_line(value)
70 .map_err(|err| err.into_error("sub-property", location))
71 .map(|_| ())
72 }
73}
74
75fn is_empty_line(line: &str) -> bool {
76 line.trim_matches(WHITESPACE).is_empty()
77}
78
79fn is_comment_line(line: &str) -> bool {
80 line.starts_with(COMMENT)
81}
82
83struct Parser<'a> {
85 data: RawProfileSet<'a>,
87
88 state: State<'a>,
90
91 location: Location,
95}
96
97enum State<'a> {
98 Starting,
99 ReadingProfile {
100 profile: &'a str,
101 property: Option<Cow<'a, str>>,
102 is_subproperty: bool,
103 },
104}
105
106pub(super) fn parse_profile_file(file: &File) -> Result<RawProfileSet<'_>, EnvConfigParseError> {
108 let mut parser = Parser {
109 data: HashMap::new(),
110 state: State::Starting,
111 location: Location {
112 line_number: 0,
113 path: file.path.clone().unwrap_or_default(),
114 },
115 };
116 parser.parse_profile(&file.contents)?;
117 Ok(parser.data)
118}
119
120impl<'a> Parser<'a> {
121 fn parse_profile(&mut self, file: &'a str) -> Result<(), EnvConfigParseError> {
123 for (line_number, line) in file.lines().enumerate() {
124 self.location.line_number = line_number + 1; if is_empty_line(line) || is_comment_line(line) {
126 continue;
127 }
128 if line.starts_with('[') {
129 self.read_profile_line(line)?;
130 } else if line.starts_with(WHITESPACE) {
131 self.read_property_continuation(line)?;
132 } else {
133 self.read_property_line(line)?;
134 }
135 }
136 Ok(())
137 }
138
139 fn read_property_line(&mut self, line: &'a str) -> Result<(), EnvConfigParseError> {
143 let location = &self.location;
144 let (current_profile, name) = match &self.state {
145 State::Starting => return Err(self.make_error("Expected a profile definition")),
146 State::ReadingProfile { profile, .. } => (
147 self.data.get_mut(*profile).expect("profile must exist"),
148 profile,
149 ),
150 };
151 let (k, v) = parse_property_line(line)
152 .map_err(|err| err.into_error("property", location.clone()))?;
153 self.state = State::ReadingProfile {
154 profile: name,
155 property: Some(k.clone()),
156 is_subproperty: v.is_empty(),
157 };
158 current_profile.insert(k, v.into());
159 Ok(())
160 }
161
162 fn make_error(&self, message: &str) -> EnvConfigParseError {
164 EnvConfigParseError {
165 location: self.location.clone(),
166 message: message.into(),
167 }
168 }
169
170 fn read_property_continuation(&mut self, line: &'a str) -> Result<(), EnvConfigParseError> {
174 let current_property = match &self.state {
175 State::Starting => return Err(self.make_error("Expected a profile definition")),
176 State::ReadingProfile {
177 profile,
178 property: Some(property),
179 is_subproperty,
180 } => {
181 if *is_subproperty {
182 validate_subproperty(line, self.location.clone())?;
183 }
184 self.data
185 .get_mut(*profile)
186 .expect("profile must exist")
187 .get_mut(property.as_ref())
188 .expect("property must exist")
189 }
190 State::ReadingProfile {
191 profile: _,
192 property: None,
193 ..
194 } => return Err(self.make_error("Expected a property definition, found continuation")),
195 };
196 let line = line.trim_matches(WHITESPACE);
197 let current_property = current_property.to_mut();
198 current_property.push('\n');
199 current_property.push_str(line);
200 Ok(())
201 }
202
203 fn read_profile_line(&mut self, line: &'a str) -> Result<(), EnvConfigParseError> {
204 let line = prepare_line(line, false);
205 let profile_name = line
206 .strip_prefix('[')
207 .ok_or_else(|| self.make_error("Profile definition must start with '['"))?
208 .strip_suffix(']')
209 .ok_or_else(|| self.make_error("Profile definition must end with ']'"))?;
210 if !self.data.contains_key(profile_name) {
211 self.data.insert(profile_name, Default::default());
212 }
213 self.state = State::ReadingProfile {
214 profile: profile_name,
215 property: None,
216 is_subproperty: false,
217 };
218 Ok(())
219 }
220}
221
222#[derive(Debug, Eq, PartialEq)]
224enum PropertyError {
225 NoEquals,
226 NoName,
227}
228
229impl PropertyError {
230 fn into_error(self, ctx: &str, location: Location) -> EnvConfigParseError {
231 let mut ctx = ctx.to_string();
232 match self {
233 PropertyError::NoName => {
234 ctx.get_mut(0..1).unwrap().make_ascii_uppercase();
235 EnvConfigParseError {
236 location,
237 message: format!("{} did not have a name", ctx),
238 }
239 }
240 PropertyError::NoEquals => EnvConfigParseError {
241 location,
242 message: format!("Expected an '=' sign defining a {}", ctx),
243 },
244 }
245 }
246}
247
248fn parse_property_line(line: &str) -> Result<(Cow<'_, str>, &str), PropertyError> {
250 let line = prepare_line(line, true);
251 let (k, v) = line.split_once('=').ok_or(PropertyError::NoEquals)?;
252 let k = k.trim_matches(WHITESPACE);
253 let v = v.trim_matches(WHITESPACE);
254 if k.is_empty() {
255 return Err(PropertyError::NoName);
256 }
257 Ok((to_ascii_lowercase(k), v))
262}
263
264pub(crate) fn to_ascii_lowercase(s: &str) -> Cow<'_, str> {
265 if s.bytes().any(|b| b.is_ascii_uppercase()) {
266 Cow::Owned(s.to_ascii_lowercase())
267 } else {
268 Cow::Borrowed(s)
269 }
270}
271
272fn prepare_line(line: &str, comments_need_whitespace: bool) -> &str {
283 let line = line.trim_matches(WHITESPACE);
284 let mut prev_char_whitespace = false;
285 let mut comment_idx = None;
286 for (idx, chr) in line.char_indices() {
287 if (COMMENT.contains(&chr)) && (prev_char_whitespace || !comments_need_whitespace) {
288 comment_idx = Some(idx);
289 break;
290 }
291 prev_char_whitespace = chr.is_whitespace();
292 }
293 comment_idx
294 .map(|idx| &line[..idx])
295 .unwrap_or(line)
296 .trim_matches(WHITESPACE)
298}
299
300#[cfg(test)]
301mod test {
302 use super::{parse_profile_file, prepare_line, Location};
303 use crate::env_config::file::EnvConfigFileKind;
304 use crate::env_config::parse::{parse_property_line, PropertyError};
305 use crate::env_config::source::File;
306 use std::borrow::Cow;
307
308 #[test]
311 fn property_parsing() {
312 fn ok<'a>(key: &'a str, value: &'a str) -> Result<(Cow<'a, str>, &'a str), PropertyError> {
313 Ok((Cow::Borrowed(key), value))
314 }
315
316 assert_eq!(parse_property_line("a = b"), ok("a", "b"));
317 assert_eq!(parse_property_line("a=b"), ok("a", "b"));
318 assert_eq!(parse_property_line("a = b "), ok("a", "b"));
319 assert_eq!(parse_property_line(" a = b "), ok("a", "b"));
320 assert_eq!(parse_property_line(" a = b 🐱 "), ok("a", "b 🐱"));
321 assert_eq!(parse_property_line("a b"), Err(PropertyError::NoEquals));
322 assert_eq!(parse_property_line("= b"), Err(PropertyError::NoName));
323 assert_eq!(parse_property_line("a = "), ok("a", ""));
324 assert_eq!(
325 parse_property_line("something_base64=aGVsbG8gZW50aHVzaWFzdGljIHJlYWRlcg=="),
326 ok("something_base64", "aGVsbG8gZW50aHVzaWFzdGljIHJlYWRlcg==")
327 );
328
329 assert_eq!(parse_property_line("ABc = DEF"), ok("abc", "DEF"));
330 }
331
332 #[test]
333 fn prepare_line_strips_comments() {
334 assert_eq!(
335 prepare_line("name = value # Comment with # sign", true),
336 "name = value"
337 );
338
339 assert_eq!(
340 prepare_line("name = value#Comment # sign", true),
341 "name = value#Comment"
342 );
343
344 assert_eq!(
345 prepare_line("name = value#Comment # sign", false),
346 "name = value"
347 );
348 }
349
350 #[test]
351 fn error_line_numbers() {
352 let file = File {
353 kind: EnvConfigFileKind::Config,
354 path: Some("~/.aws/config".into()),
355 contents: "[default\nk=v".into(),
356 };
357 let err = parse_profile_file(&file).expect_err("parsing should fail");
358 assert_eq!(err.message, "Profile definition must end with ']'");
359 assert_eq!(
360 err.location,
361 Location {
362 path: "~/.aws/config".into(),
363 line_number: 1
364 }
365 )
366 }
367}