valve-keyvalue 1.0.3

A parser and serializer for the Valve KeyValue format (VMT/VDF).
Documentation
/*
   Copyright 2026 Gerg0Vagyok

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.
*/

use crate::{ValveKeyValue, ValveKeyValueType};
use crate::error::{Error, Result};

#[derive(Clone, Debug)]
pub struct TrackedChars<'a> {
	inner: std::iter::Peekable<std::str::Chars<'a>>,
	line: usize,
	col: usize,
}

impl<'a> TrackedChars<'a> {
	pub fn new(input: &'a str) -> Self {
		Self { inner: input.chars().peekable(), line: 0, col: 0 }
	}

	pub fn get_pos(&self) -> (usize, usize) {
		(self.line, self.col)
	}

	#[allow(clippy::should_implement_trait)]
	pub fn next(&mut self) -> Option<char> {
		let ch = self.inner.next();
		if let Some(c) = ch {
			match c {
				'\n' => {
					self.line += 1;
					self.col = 0;
				},
				'\r' => self.col = 0,
				_ => self.col += 1
			}
		}
		ch
	}

	pub fn peek(&mut self) -> Option<&char> {
		self.inner.peek()
	}

	pub fn peek_2(&mut self) -> Option<char> {
		let mut next = self.inner.clone();
		next.next()?;
		next.next()
	}
}

// getting serde vibes with the iterators

pub fn parse_string( // If it starts with " it has to end with a ", if it doesnt start with a " it
					 // ends at the next whitespace
	input: &mut TrackedChars,
	use_escape_sequences: bool
) -> Result<String> {
	let mut output_string = String::new();

	let is_quote = Some(&'"') == input.peek();
	if is_quote {
		input.next();
	}

	let is_end = |inp: char| -> bool {
		if is_quote {
			inp == '"'
		} else {
			inp.is_whitespace() || inp == '{' || inp == '}' || inp == '"'
		}
	};

	while let Some(ch) = input.peek() {
		if use_escape_sequences {
			match ch {
				'\\' => match input.peek_2() {
					Some(ch) => {
						match ch {
							'\\' => {
								output_string.push('\\');
								input.next();
								input.next();
							},
							'"' => {
								output_string.push('"');
								input.next();
								input.next();
							},
							'n' => {
								output_string.push('\n');
								input.next();
								input.next();
							},
							't' => {
								output_string.push('\t');
								input.next();
								input.next();
							},
							_ => {input.next();}
						}
					},
					None => return Err(Error::UnexpectedEndOfFile)
				},
				_ => if is_end(*ch) {
					if *ch == '"' && is_quote {input.next();}
					return Ok(output_string);
				} else {
					output_string.push(*ch);
					input.next();
				}
			}
		} else {
			if is_end(*ch) {
				if *ch == '"' && is_quote {input.next();}
				return Ok(output_string);
			} else {
				output_string.push(*ch);
				input.next();
			}
		}
	}

	if is_quote {
		Err(Error::UnexpectedEndOfFile)
	} else {
		Ok(output_string)
	}
}

pub fn skip_comments(input: &mut TrackedChars) {
	while let Some(ch) = input.peek() {
		if ch == &'\n' {
			return;
		} else {
			input.next();
		}
	}
}

pub fn parse_object(
	input: &mut TrackedChars,
	use_escape_sequences: bool,
	depth: usize
) -> Result<Vec<ValveKeyValue>> {
	let mut keypairs: Vec<ValveKeyValue> = Vec::new();
	let mut current_key: Option<String> = None;

	while let Some(ch) = input.peek() {
		if ch == &'{' {
			let pos = input.get_pos();
			input.next();
			let parsed_object = parse_object(input, use_escape_sequences, depth + 1)?;
			if let Some(curr_key) = &current_key {
				keypairs.push(ValveKeyValue::new(curr_key.clone(), ValveKeyValueType::Object(parsed_object)));
				current_key = None;
			} else {
				return Err(Error::UnexpectedObjectAsKey { line: pos.0, col: pos.1 });
			}
		} else if ch == &'}' {
			let pos = input.get_pos();
			if depth == 0 {
				return Err(Error::UnexpectedEndOfObject { line: pos.0, col: pos.1 });
			}
			if current_key.is_some() {
				return Err(Error::MissingValue { line: pos.0, col: pos.1 });
			}
			input.next();
			return Ok(keypairs);
		} else if ch.is_whitespace() {
			input.next();
		} else if ch == &'/' && input.peek_2() == Some('/') {
			input.next();
			skip_comments(input);
		} else {
			let parsed_string = parse_string(input, use_escape_sequences)?;
			if let Some(curr_key) = &current_key {
				keypairs.push(ValveKeyValue::new(curr_key.clone(), ValveKeyValueType::String(parsed_string)));
				current_key = None;
			} else {
				current_key = Some(parsed_string);
			}
		}
	}

	if current_key.is_some() {
		let pos = input.get_pos();
		Err(Error::MissingValue { line: pos.0, col: pos.1 })
	} else if depth == 0 {
		Ok(keypairs)
	} else {
		Err(Error::UnexpectedEndOfFile)
	}
}


// This is more of a wrapper
pub fn parse(input: String, use_escape_sequences: bool) -> Result<Vec<ValveKeyValue>> {
	parse_object(&mut TrackedChars::new(&input), use_escape_sequences, 0)
}