easy_configuration_format/
lib.rs

1//! # Easy Configuration Format
2//! 
3//! ### A settings format that strikes a great balance between usage simplicity and parsing simplicity, with aspects like:
4//! - Support for strings, ints, float, bools, and comments
5//! - Elegant error handling, an invalid line in the middle won't ruin everything afterwards and loading then saving a file will always result in a valid ecf file (to see this in action, just run `cargo run --example main`)
6//! - 'Setting updater' functions have built-in support and encouragement
7//! - Almost no code (~500 sloc) and no dependencies (other than std)
8//! 
9//! <br>
10//! 
11//! ## Example settings file:
12//! 
13//! ```txt
14//! format 1
15//! # This first line defines the version number of your settings file. If you want to update
16//! # your program's settings, this will allow you to update users' settings file to your
17//! # newer version
18//! 
19//! example key: "example value"
20//! 
21//! example blank: empty
22//! example string: "not empty"
23//! example int: 3
24//! example float: 3.5
25//! example bool: true
26//! example multiline: "
27//! "first line (#0)
28//! "also, because of how strings are stored, you can have " characters inside a string with
29//! "no escape codes needed
30//! "last line (#3)
31//! example string 2: "you can also put " chars in single-line strings"
32//! 
33//! example namespace.example key: "example value 2"
34//! # "namespaces" are entirely made up, they're just fancy names but it's still the
35//! # recommended way to structure settings
36//! 
37//! # example comment
38//! 
39//! ##
40//! example multiline comment
41//! just like strings, you can have extra # chars anywhere you want (as long as you don't 
42//! want one of the lines in a comment to just be "##")
43//! ##
44//! 
45//! example array.0: "value 0"
46//! example array.1: "value 1"
47//! example array.2: "value 2"
48//! example array.3: "value 3"
49//! 
50//! example nested array.0.name: "person 0"
51//! example nested array.0.age: "age 0"
52//! example nested array.0.friends.0: "person 1"
53//! example nested array.0.friends.1: "person 2"
54//! 
55//! example nested array.1.name: "person 1"
56//! example nested array.1.age: "age 1"
57//! example nested array.1.friends.0: "person 0"
58//! example nested array.1.friends.1: "person 2"
59//! 
60//! 
61//! 
62//! # examples for error handling:
63//! 
64//! example duplicate key: "this key will be kept"
65//! example duplicate key: "this key will be commented"
66//! 
67//! invalid key "doesn't have any colon"
68//! invalid value 1: "missing an ending quote
69//! invalid value 2: missing a starting quote"
70//! invalid value 3: missing both quotes
71//! # empty multiline strings aren't allowed:
72//! invalid value 4: "
73//! 
74//! invalid value 6: .3
75//! 
76//! invalid entry: empty # inline comments aren't allowed
77//! 
78//! ##
79//! invalid multiline comment, only these two lines will be commented because of this
80//! 
81//! # single-line comments cannot be invalid!
82//! 
83//! working key: "and even after all that, it can still keep parsing settings!"
84//! 
85//! ```
86//! 
87//! ### See the specification [Here](specification.txt)
88//! 
89//! <br>
90//! <br>
91//! <br>
92//! 
93//! A settings file is intended to be represented in code using two main values: the layout vec and the values hashmap. The layout vec describes the layout of the settings file according to how it was when it was parsed, and modifying it at runtime isn't recommended (because there should no need to do so). The values hashmap simply stores the key-value (String, ecf::Value) pairs, and this is what your code will interact with.
94//! 
95//! Also, I strongly recommend using an automatic format upgrading system like what's shown in the [example](https://github.com/What42Pizza/Easy-Configuration-Format/blob/main/examples/main.rs).
96//! 
97//! <br>
98//! <br>
99
100
101
102#![warn(missing_docs)]
103
104
105
106/// All the data types used by this crate
107pub mod data;
108pub use data::*;
109/// Utility functions for easy value management
110pub mod utils;
111pub use utils::*;
112
113
114
115use std::collections::{HashMap, HashSet};
116
117
118
119
120
121/// Converts a settings file into a layout + values, opposite of `format_settings()`
122/// 
123/// The generic `T` is for passing generic data to the updater functions
124pub fn parse_settings<T>(contents: impl AsRef<str>, updater_fns: &[fn(&mut HashMap<String, Value>, &T)], args: &T) -> (File, Vec<ParseEntryError>) {
125	let mut layout = vec!();
126	let mut values = HashMap::new();
127	let mut errors = vec!();
128	
129	let lines = contents.as_ref().split('\n').collect::<Vec<_>>();
130	let version = get_file_version(lines[0].trim());
131	let mut line_i = 1;
132	loop {
133		let result = parse_line(&lines, &mut line_i, &mut layout, &mut values);
134		if let Err(err) = result {
135			layout.push(LayoutEntry::Comment (lines[line_i].to_string()));
136			errors.push(err);
137		}
138		line_i += 1;
139		if line_i >= lines.len() {break;}
140	}
141	
142	if let Some(version) = version {
143		for updater_fn in &updater_fns[version - 1 ..] {
144			(updater_fn)(&mut values, args);
145		}
146	} else {
147		errors.push(ParseEntryError::new(0, "Could not find version, assuming version is latest"));
148	}
149	
150	(File {
151		values,
152		layout,
153		version: updater_fns.len() + 1,
154	}, errors)
155}
156
157
158
159fn get_file_version(first_line: &str) -> Option<usize> {
160	let Some(format_str) = first_line.strip_prefix("format ") else {return None;};
161	format_str.parse::<usize>().ok()
162}
163
164
165
166fn parse_line(
167	lines: &[&str],
168	line_i: &mut usize,
169	layout: &mut Vec<LayoutEntry>,
170	values: &mut HashMap<String, Value>,
171) -> Result<(), ParseEntryError> {
172	
173	let line_trimmed = lines[*line_i].trim();
174	if line_trimmed.is_empty() {
175		layout.push(LayoutEntry::Empty);
176		return Ok(());
177	}
178	
179	if line_trimmed == "##" {
180		layout.push(parse_multiline_comment(lines, line_i)?);
181		return Ok(());
182	}
183	if let Some(comment) = line_trimmed.strip_prefix("#") {
184		layout.push(LayoutEntry::Comment (comment.to_string()));
185		return Ok(());
186	}
187	
188	let colon_index = line_trimmed.find(':');
189	let Some(colon_index) = colon_index else {return Err(ParseEntryError::new(*line_i, "No colon (':') was found, either add a colon after the key or mark this as a comment."));};
190	if colon_index == 0 {return Err(ParseEntryError::new(*line_i, "Lines cannot start with a colon."));}
191	let key = &line_trimmed[..colon_index];
192	if values.contains_key(key) {return Err(ParseEntryError::new(*line_i, format!("Key \"{key}\" is already defined.")));}
193	let value = parse_value(lines, line_i, colon_index)?;
194	layout.push(LayoutEntry::Key (key.to_string()));
195	values.insert(key.to_string(), value);
196	
197	Ok(())
198}
199
200
201
202fn parse_multiline_comment(
203	lines: &[&str],
204	line_i: &mut usize,
205) -> Result<LayoutEntry, ParseEntryError> {
206	
207	let start_line_i = *line_i;
208	let mut output = String::new();
209	*line_i += 1;
210	while lines[*line_i].trim() != "##" {
211		output += lines[*line_i];
212		output.push('\n');
213		*line_i += 1;
214		if *line_i == lines.len() {
215			*line_i = start_line_i;
216			return Err(ParseEntryError::new(start_line_i, "Could not find an end of this multiline comment. To end a multiline comment, its last line should be nothing but '##'."));
217		}
218	}
219	output.pop();
220	Ok(LayoutEntry::Comment (output))
221}
222
223
224
225fn parse_value(lines: &[&str], line_i: &mut usize, colon_index: usize) -> Result<Value, ParseEntryError> {
226	let line_trimmed = lines[*line_i].trim();
227	
228	let value_start_i =
229		line_trimmed.char_indices()
230		.skip(colon_index + 1)
231		.find(|(_i, c)| !c.is_whitespace());
232	let Some((value_start_i, _c)) = value_start_i else {return Err(ParseEntryError::new(*line_i, "No value was found for this key (if this is meant to be empty, please set the value as 'empty')."));};
233	
234	let value = &line_trimmed[value_start_i..];
235	match &*value.to_lowercase() {
236		"empty" => return Ok(Value::Empty),
237		"true" => return Ok(Value::Bool (true)),
238		"false" => return Ok(Value::Bool (false)),
239		"\"" => return parse_multiline_string(lines, line_i),
240		_ => {}
241	}
242	let first_char = value.chars().next().unwrap(); // safety: value cannot be empty because it has to have non-whitespace char(s)
243	if first_char.is_ascii_digit() {
244		if let Ok(i64_value) = value.parse::<i64>() {return Ok(Value::I64 (i64_value));}
245		if let Ok(f64_value) = value.parse::<f64>() {return Ok(Value::F64 (f64_value));}
246	}
247	if first_char == '"' {
248		let last_char = value.chars().last().unwrap(); // safety: value is already assumed to have a first char, therefore it also has a last char
249		if last_char != '"' {return Err(ParseEntryError::new(*line_i, "Invalid string, no ending quote found. If this is a single-line string, no characters are allowed after the final quotation mark. If this is meant to be a multi-line string, no characters are allowed after the first quotation mark."))}
250		return Ok(Value::String (value[1 .. value.len()-1].to_string()));
251	}
252	
253	Err(ParseEntryError::new(*line_i, "Invalid value, must be 'empty', 'true', 'false', a valid integer, a valid decimal number, a string enclosed in quotes, or a multiline quote starting with a single '\"' character."))
254}
255
256
257
258fn parse_multiline_string(lines: &[&str], line_i: &mut usize) -> Result<Value, ParseEntryError> {
259	let mut output = String::new();
260	let start_i = *line_i;
261	*line_i += 1;
262	let mut curr_line = lines[*line_i].trim_start();
263	while curr_line.starts_with('"') {
264		output += &curr_line[1..];
265		output.push('\n');
266		*line_i += 1;
267		if *line_i == lines.len() {break;}
268		curr_line = lines[*line_i].trim_start();
269	}
270	*line_i -= 1;
271	output.pop();
272	if *line_i == start_i {
273		return Err(ParseEntryError::new(start_i, String::from("Invalid value, multiline strings cannot be empty")));
274	}
275	Ok(Value::String (output))
276}
277
278
279
280
281
282/// Converts a layout plus values into a formatted settings file, opposite of `parse_settings()`
283pub fn format_settings(file: &File) -> (String, Vec<FormatEntryError>) {
284	let mut output = format!("format {}\n", file.version);
285	if file.layout.is_empty() {return (output, vec!());}
286	let mut errors = vec!();
287	let mut printed_keys = HashSet::new();
288	for entry in &file.layout {
289		match entry {
290			LayoutEntry::Empty => {}
291			LayoutEntry::Comment (comment) => {
292				if comment.contains('\n') {
293					output += "##\n";
294					output += comment;
295					output += "\n##";
296				} else {
297					output.push('#');
298					output += comment;
299				}
300			}
301			LayoutEntry::Key (key) => {
302				output += key;
303				output += ": ";
304				let value = file.get(key);
305				if let Some(value) = value {
306					output += &value.format();
307				} else {
308					errors.push(FormatEntryError::new(key));
309					continue;
310				};
311				printed_keys.insert(key.to_string());
312			}
313		}
314		output.push('\n');
315	}
316	for (key, value) in &file.values {
317		if printed_keys.contains(key) {continue;}
318		output += key;
319		output += ": ";
320		output += &value.format();
321		output.push('\n');
322	}
323	output.pop();
324	(output, errors)
325}
326
327
328
329
330
331/// Automatically merge new setting values with existing setting values
332pub fn merge_values(existing_values: &mut HashMap<String, Value>, new_values: &HashMap<String, Value>, merge_options: MergeOptions) {
333	match merge_options {
334		MergeOptions::UpdateOnly => {
335			for (key, value) in new_values {
336				if existing_values.contains_key(key) {
337					existing_values.insert(key.clone(), value.clone());
338				}
339			}
340		}
341		MergeOptions::UpdateAndAdd => {
342			for (key, value) in new_values {
343				existing_values.insert(key.clone(), value.clone());
344			}
345		}
346		MergeOptions::AddOnly => {
347			for (key, value) in new_values {
348				if !existing_values.contains_key(key) {
349					existing_values.insert(key.clone(), value.clone());
350				}
351			}
352		}
353		MergeOptions::FullyReplace => {
354			*existing_values = new_values.clone();
355		}
356	}
357}
358
359/// Used with `merge_values()`
360pub enum MergeOptions {
361	/// Only Update the values that already exist in the hashmap
362	UpdateOnly,
363	/// Update the values that already exist in the hashmap, and add any new key-value pairs that didn't exist
364	UpdateAndAdd,
365	/// Only add key-value pairs that didn't exist in the hashmap
366	AddOnly,
367	/// Simple replace the existing hashmap with the new hashmap
368	FullyReplace,
369}