commit 23260fd60a915975f82a25a3fe44e9fb4ec37763 Author: eater <=@eater.me> Date: Tue Dec 24 16:44:55 2019 +0100 Initial commit (config parsing) diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..73fd40a --- /dev/null +++ b/.drone.yml @@ -0,0 +1,9 @@ +name: default +type: docker +kind: pipeline + +steps: + - name: test + image: rust + commands: + - cargo test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53eaa21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +**/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9a15ea2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,6 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +[[package]] +name = "systemf" +version = "0.1.0" + diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ea14742 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "systemf" +version = "0.1.0" +authors = ["eater <=@eater.me>"] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..5154829 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,216 @@ +use std::collections::HashMap; +use crate::config::parser::parse_time_span; + +pub mod parser; + +#[derive(Clone, Debug, Default)] +struct Config { + name: String, + file_pointer: usize, + files: HashMap, + sections: HashMap, +} + +impl Config { + pub(crate) fn add_file(&mut self, file_name: String) -> usize { + let id = self.file_pointer; + self.file_pointer += 1; + self.files.insert(id, file_name); + + id + } +} + + +#[derive(Clone, Debug, Default)] +struct Section { + name: String, + entries: HashMap, +} + +impl Section { + pub fn new(name: String) -> Section { + Section { + name, + entries: HashMap::new(), + } + } + + pub fn entry(&self, name: &str) -> Option<&Entry> { + self.entries.get(name) + } + + fn get_string(&self, name: &str) -> Option<&str> { + self.entries.get(name).map(|x| x.get_string()) + } + + fn get_boolean(&self, name: &str) -> Option { + self.entries.get(name).and_then(|x| x.get_boolean()) + } + + fn get_list(&self, name: &str) -> Option> { + self.entries.get(name).map(|x| x.get_list()) + } + + fn get_time_span(&self, name: &str) -> Option { + self.entries.get(name).and_then(|x| x.get_time_span()) + } +} + +#[derive(Clone, Debug, Default)] +struct Entry { + name: String, + values: Vec, +} + +impl Entry { + pub fn new(name: String) -> Entry { + Entry { + name, + values: Vec::new(), + } + } + + pub fn add(&mut self, value: &str, file_id: usize) { + self.values.push(EntryValue { value: value.to_string(), file_id }) + } + + pub fn get_string(&self) -> &str { + self.last_value().unwrap_or("") + } + + pub fn get_list(&self) -> Vec<&str> { + self.values.iter() + .map(|x| x.value.as_str()) + .fold(Vec::new(), |mut acc, val| { + if val == "" { + acc.clear() + } else { + acc.push(val) + } + + acc + }) + } + + fn last_value(&self) -> Option<&str> { + self.values.last().map(|x| x.value.as_str()) + } + + pub fn get_boolean(&self) -> Option { + self.last_value().and_then(|value| { + match value { + "yes" | "true" | "1" | "on" => { + Some(true) + } + + "no" | "false" | "0" | "off" => { + Some(false) + } + + _ => None + } + }) + } + + pub fn get_time_span(&self) -> Option { + self.last_value().and_then(parse_time_span) + } +} + +#[derive(Default, Debug, Copy, Clone, Hash, Ord, PartialOrd, Eq, PartialEq)] +pub struct TimeSpan { + pub years: usize, + pub months: usize, + pub weeks: usize, + pub days: usize, + pub hours: usize, + pub minutes: usize, + pub seconds: usize, + pub milliseconds: usize, + pub microseconds: usize, + pub nanoseconds: usize, +} + +impl TimeSpan { + pub fn with_years(&self, years: usize) -> TimeSpan { + let mut new = self.clone(); + new.years = years; + new + } + + pub fn with_months(&self, months: usize) -> TimeSpan { + let mut new = self.clone(); + new.months = months; + new + } + + pub fn with_weeks(&self, weeks: usize) -> TimeSpan { + let mut new = self.clone(); + new.weeks = weeks; + new + } + + pub fn with_days(&self, days: usize) -> TimeSpan { + let mut new = self.clone(); + new.days = days; + new + } + + pub fn with_hours(&self, hours: usize) -> TimeSpan { + let mut new = self.clone(); + new.hours = hours; + new + } + + pub fn with_minutes(&self, minutes: usize) -> TimeSpan { + let mut new = self.clone(); + new.minutes = minutes; + new + } + + pub fn with_seconds(&self, seconds: usize) -> TimeSpan { + let mut new = self.clone(); + new.seconds = seconds; + new + } + + pub fn with_milliseconds(&self, milliseconds: usize) -> TimeSpan { + let mut new = self.clone(); + new.milliseconds = milliseconds; + new + } + + pub fn with_microseconds(&self, microseconds: usize) -> TimeSpan { + let mut new = self.clone(); + new.microseconds = microseconds; + new + } + + pub fn with_nanoseconds(&self, nanoseconds: usize) -> TimeSpan { + let mut new = self.clone(); + new.nanoseconds = nanoseconds; + new + } + + pub fn new() -> TimeSpan { + TimeSpan { + years: 0, + months: 0, + weeks: 0, + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + milliseconds: 0, + microseconds: 0, + nanoseconds: 0, + } + } +} + +#[derive(Clone, Debug, Default)] +struct EntryValue { + value: String, + file_id: usize, +} \ No newline at end of file diff --git a/src/config/parser.rs b/src/config/parser.rs new file mode 100644 index 0000000..e98f20a --- /dev/null +++ b/src/config/parser.rs @@ -0,0 +1,271 @@ +use crate::config::{Config, Section, Entry, TimeSpan}; +use std::error::Error; +use std::fmt::{Display, Formatter, Write}; +use std::borrow::{Borrow, BorrowMut}; + +#[derive(Debug)] +struct ParserError { + filename: String, + line_no: usize, + description: String, +} + +impl Error for ParserError {} + +impl Display for ParserError { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + f.write_str(&format!("Config didn't start with section header in file {} at line {}", self.filename, self.line_no)) + } +} + +pub fn parse_time_span(val: &str) -> Option { + let parse = val.replace(" ", ""); + let mut time_span = TimeSpan::default(); + let mut current_value = String::new(); + let mut current_num = 0usize; + + let finish = &mut |name: &str, amount: usize| { + match name { + "nsec" | "ns" => { + time_span.nanoseconds += amount; + } + + "usec" | "us" | "µs" => { + time_span.microseconds += amount; + } + + "msec" | "ms" => { + time_span.milliseconds += amount; + } + + "" | "s" | "sec" | "second" | "seconds" => { + time_span.seconds += amount; + } + + "minutes" | "minute" | "min" | "m" => { + time_span.minutes += amount; + } + + "hours" | "hour" | "hr" | "h" => { + time_span.hours += amount; + } + + "days" | "day" | "d" => { + time_span.days += amount; + } + + "weeks" | "week" | "w" => { + time_span.weeks += amount; + } + + "M" | "months" | "month" => { + time_span.months += amount; + } + + "years" | "year" | "y" => { + time_span.years += amount; + } + + _ => return false + } + + true + }; + + let mut last_is_nr = false; + for chxr in parse.chars() { + match chxr.to_ascii_lowercase() { + '0'..='9' => { + if !last_is_nr { + if !finish(current_value.as_str(), current_num) { + return None; + } + current_value = String::new(); + current_num = 0; + last_is_nr = true + } + + current_num *= 10; + current_num += chxr.to_digit(10).unwrap_or(0) as usize + } + + 'a'..='z' => { + current_value += &chxr.to_string(); + last_is_nr = false; + } + + _ => { + return None; + } + } + } + + if !finish(current_value.as_str(), current_num) { + None + } else { + Some(time_span) + } +} + +fn parse_config(config: &mut Config, filename: String, contents: String) -> Result<(), ParserError> { + let file_id = config.add_file(filename.clone()); + let mut current_entry: Option<(&str, String)> = None; + let mut current_section: Option<&mut Section> = None; + let mut line_no = 0; + for line in contents.lines() { + line_no += 1; + + let line_trimmed = line.trim(); + + // We can ignore comments or empty lines + if line_trimmed == "" || line_trimmed.starts_with("#") || line_trimmed.starts_with(";") { + continue; + } + + // Start of group + if line_trimmed.starts_with('[') { + if let Some(x) = line_trimmed.rfind(']') { + let name = &line_trimmed[1..x]; + current_section = Some( + config + .sections + .entry(name.to_string()) + .or_insert_with(|| Section::new(name.to_string())) + ); + } + + continue; + } + + if let Some(section) = &mut current_section { + let (key, value) = if let Some((key, value)) = ¤t_entry { + (*key, value.clone() + line_trimmed) + } else { + let split: Vec<&str> = line_trimmed.splitn(2, "=").collect(); + if split.len() < 2 { + return Err(ParserError { + filename, + line_no, + description: "Line didn't have Key=Value syntax".to_string(), + }); + } + + (split.get(0).unwrap().trim(), split.get(1).unwrap().trim().to_string()) + }; + + if value.ends_with("\\") { + let value = value[..value.len() - 1].to_string(); + current_entry = Some((key, value + " ")) + } else { + section + .entries + .entry(key.to_string()) + .or_insert_with(|| Entry::new(key.to_string())) + .add(&value, file_id) + } + } else { + return Err(ParserError { + filename, + line_no, + description: "Config should start with section header".to_string(), + }); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::TimeSpan; + + #[test] + fn test_parse() { + let mut config = Config::default(); + let res = parse_config(&mut config, "test.unit".to_string(), r" +[Unit] +Hello=no +Hello= +Hello=Hell +Hello=World +Jump=Oh no\ +1336".to_string()); + + assert!(res.is_ok()); + assert!(config.sections.get("Unit").is_some()); + + let section = config.sections.get("Unit").unwrap(); + let hello_entry = section.entry("Hello").unwrap(); + assert_eq!(hello_entry.get_string(), "World"); + assert_eq!(hello_entry.get_list(), vec!["Hell", "World"]); + assert_eq!(section.entry("Jump").unwrap().get_string(), "Oh no 1336") + } + + #[test] + fn test_config_without_section() { + let mut config = Config::default(); + let err = parse_config(&mut config, "test.unit".to_string(), r"Hello=World".to_string()); + + assert!(err.is_err()); + + let err = err.unwrap_err(); + assert_eq!(err.line_no, 1); + assert_eq!(err.description, "Config should start with section header") + } + + #[test] + fn test_booleans() { + let mut config = Config::default(); + let res = parse_config(&mut config, "test.unit".to_string(), r" +[Unit] +1=no +2=off +3=0 +4=false +5=yes +6=on +7=1 +8=true +9=x".to_string()); + + assert!(res.is_ok()); + assert!(config.sections.get("Unit").is_some()); + + let unit = config.sections.get("Unit").unwrap(); + assert_eq!(unit.get_boolean("1"), Some(false)); + assert_eq!(unit.get_boolean("2"), Some(false)); + assert_eq!(unit.get_boolean("3"), Some(false)); + assert_eq!(unit.get_boolean("4"), Some(false)); + assert_eq!(unit.get_boolean("5"), Some(true)); + assert_eq!(unit.get_boolean("6"), Some(true)); + assert_eq!(unit.get_boolean("7"), Some(true)); + assert_eq!(unit.get_boolean("8"), Some(true)); + assert_eq!(unit.get_boolean("9"), None); + } + + fn test_time_span() { + let mut config = Config::default(); + let res = parse_config(&mut config, "test.unit".to_string(), r" +[Unit] +1=3 hours +2=1h +3=3 M +4=3m +5=1M1m +6=blaat + ".to_string()); + + assert!(res.is_ok()); + assert!(config.sections.get("Unit").is_some()); + + let unit = config.sections.get("Unit").unwrap(); + + assert_eq!(unit.get_time_span("1"), Some(TimeSpan::new().with_hours(3))); + assert_eq!(unit.get_time_span("2"), Some(TimeSpan::new().with_hours(1))); + assert_eq!(unit.get_time_span("3"), Some(TimeSpan::new().with_months(3))); + assert_eq!(unit.get_time_span("4"), Some(TimeSpan::new().with_minutes(3))); + assert_eq!(unit.get_time_span("5"), Some(TimeSpan::new().with_months(1).with_minutes(1))); + assert_eq!(unit.get_time_span("6"), None); + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b5d1bf8 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,6 @@ +pub mod config; + + +fn main() { + println!("Hello, world!"); +}