deps: drop regex, toml, indexmap - std-only minirx, toml_lite, inferred IndexMap
This commit is contained in:
parent
59637e50dc
commit
d19e4f784d
19 changed files with 1171 additions and 96 deletions
|
|
@ -10,7 +10,6 @@ license.workspace = true
|
|||
noxis-classfile = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[lints]
|
||||
|
|
|
|||
172
crates/noxis-core/src/config/from_toml/mod.rs
Normal file
172
crates/noxis-core/src/config/from_toml/mod.rs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
//! Builds [`Config`](super::Config) from a parsed [`toml_lite::Table`].
|
||||
//!
|
||||
//! Deliberately *not* going through `serde::Deserialize`: `Config` has
|
||||
//! only a handful of fields and three small kebab-case enums, so a tiny
|
||||
//! hand-written visitor is less machinery (and fewer edge cases to worry
|
||||
//! about) than writing a generic `serde::Deserializer` over our `Value`
|
||||
//! tree. This keeps the TOML replacement fully decoupled from serde -
|
||||
//! the `Serialize`/`Deserialize` derives left on `Config` and friends are
|
||||
//! unrelated to this path and can be dropped independently later.
|
||||
|
||||
mod sections;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use super::toml_lite::{Table, Value};
|
||||
use super::{Config, PassId, Profile};
|
||||
|
||||
type Res<T> = Result<T, String>;
|
||||
|
||||
fn unknown_key(section: &str, key: &str) -> String {
|
||||
format!("неизвестный ключ `{key}` в секции `{section}`")
|
||||
}
|
||||
|
||||
fn expect_str<'a>(v: &'a Value, path: &str) -> Res<&'a str> {
|
||||
match v {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Err(format!("`{path}` должен быть строкой")),
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_bool(v: &Value, path: &str) -> Res<bool> {
|
||||
match v {
|
||||
Value::Bool(b) => Ok(*b),
|
||||
_ => Err(format!("`{path}` должен быть bool")),
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_usize(v: &Value, path: &str) -> Res<usize> {
|
||||
match v {
|
||||
Value::Integer(i) if *i >= 0 => Ok(*i as usize),
|
||||
_ => Err(format!("`{path}` должен быть неотрицательным целым")),
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_u64(v: &Value, path: &str) -> Res<u64> {
|
||||
match v {
|
||||
Value::Integer(i) if *i >= 0 => Ok(*i as u64),
|
||||
_ => Err(format!("`{path}` должен быть неотрицательным целым")),
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_string_vec(v: &Value, path: &str) -> Res<Vec<String>> {
|
||||
match v {
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.map(|item| expect_str(item, path).map(str::to_string))
|
||||
.collect(),
|
||||
_ => Err(format!("`{path}` должен быть массивом строк")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_profile(s: &str) -> Res<Profile> {
|
||||
match s {
|
||||
"light" => Ok(Profile::Light),
|
||||
"standard" => Ok(Profile::Standard),
|
||||
"paranoid" => Ok(Profile::Paranoid),
|
||||
"manual" => Ok(Profile::Manual),
|
||||
other => Err(format!("неизвестный профиль `{other}`")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_pass_id(s: &str) -> Res<PassId> {
|
||||
[
|
||||
PassId::Rename,
|
||||
PassId::Strings,
|
||||
PassId::Constants,
|
||||
PassId::Flow,
|
||||
PassId::Noise,
|
||||
PassId::DebugInfo,
|
||||
PassId::Resources,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|id| id.as_str() == s)
|
||||
.ok_or_else(|| format!("неизвестный пасс `{s}`"))
|
||||
}
|
||||
|
||||
fn parse_pass_id_vec(v: &Value, path: &str) -> Res<Vec<PassId>> {
|
||||
expect_string_vec(v, path)?
|
||||
.iter()
|
||||
.map(|s| parse_pass_id(s))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reject any key in `table` that isn't in `known` (mirrors serde's
|
||||
/// `deny_unknown_fields`).
|
||||
fn check_known_keys(table: &Table, known: &[&str], section: &str) -> Res<()> {
|
||||
for key in table.keys() {
|
||||
if !known.contains(&key.as_str()) {
|
||||
return Err(unknown_key(section, key));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sub_table<'a>(table: &'a Table, key: &str) -> Res<Option<&'a Table>> {
|
||||
match table.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Value::Table(t)) => Ok(Some(t)),
|
||||
Some(_) => Err(format!("`{key}` должен быть секцией `[{key}]`")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a full [`Config`] from a parsed root table.
|
||||
pub(super) fn build(root: &Table) -> Res<Config> {
|
||||
check_known_keys(
|
||||
root,
|
||||
&[
|
||||
"profile",
|
||||
"seed",
|
||||
"enable",
|
||||
"disable",
|
||||
"keep",
|
||||
"keep_resources",
|
||||
"rename",
|
||||
"strings",
|
||||
"constants",
|
||||
"flow",
|
||||
"noise",
|
||||
"debug",
|
||||
],
|
||||
"<root>",
|
||||
)?;
|
||||
|
||||
let mut cfg = Config::default();
|
||||
if let Some(v) = root.get("profile") {
|
||||
cfg.profile = parse_profile(expect_str(v, "profile")?)?;
|
||||
}
|
||||
if let Some(v) = root.get("seed") {
|
||||
cfg.seed = Some(expect_u64(v, "seed")?);
|
||||
}
|
||||
if let Some(v) = root.get("enable") {
|
||||
cfg.enable = parse_pass_id_vec(v, "enable")?;
|
||||
}
|
||||
if let Some(v) = root.get("disable") {
|
||||
cfg.disable = parse_pass_id_vec(v, "disable")?;
|
||||
}
|
||||
if let Some(v) = root.get("keep") {
|
||||
cfg.keep = expect_string_vec(v, "keep")?;
|
||||
}
|
||||
if let Some(v) = root.get("keep_resources") {
|
||||
cfg.keep_resources = expect_string_vec(v, "keep_resources")?;
|
||||
}
|
||||
if let Some(t) = sub_table(root, "rename")? {
|
||||
cfg.rename = sections::build_rename(t)?;
|
||||
}
|
||||
if let Some(t) = sub_table(root, "strings")? {
|
||||
cfg.strings = sections::build_strings(t)?;
|
||||
}
|
||||
if let Some(t) = sub_table(root, "constants")? {
|
||||
cfg.constants = sections::build_constants(t)?;
|
||||
}
|
||||
if let Some(t) = sub_table(root, "flow")? {
|
||||
cfg.flow = sections::build_flow(t)?;
|
||||
}
|
||||
if let Some(t) = sub_table(root, "noise")? {
|
||||
cfg.noise = sections::build_noise(t)?;
|
||||
}
|
||||
if let Some(t) = sub_table(root, "debug")? {
|
||||
cfg.debug = sections::build_debug(t)?;
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
184
crates/noxis-core/src/config/from_toml/sections.rs
Normal file
184
crates/noxis-core/src/config/from_toml/sections.rs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
//! Per-section struct population, split out of `from_toml::build` purely to
|
||||
//! stay under the per-file line budget (see `docs/README.md`).
|
||||
|
||||
use super::super::sections::{
|
||||
ConstantsConfig, DebugConfig, FlowConfig, NameStyle, NoiseConfig, RenameConfig, StringsConfig,
|
||||
};
|
||||
use super::super::toml_lite::Table;
|
||||
use super::{Res, check_known_keys, expect_bool, expect_str, expect_string_vec, expect_usize};
|
||||
|
||||
fn parse_name_style(s: &str) -> Res<NameStyle> {
|
||||
match s {
|
||||
"short-latin" => Ok(NameStyle::ShortLatin),
|
||||
"il-o0" => Ok(NameStyle::IlO0),
|
||||
"unicode" => Ok(NameStyle::Unicode),
|
||||
"mixed-latin" => Ok(NameStyle::MixedLatin),
|
||||
"decoy-words" => Ok(NameStyle::DecoyWords),
|
||||
"plausible" => Ok(NameStyle::Plausible),
|
||||
other => Err(format!("неизвестный стиль имён `{other}`")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_rename(t: &Table) -> Res<RenameConfig> {
|
||||
check_known_keys(
|
||||
t,
|
||||
&[
|
||||
"spread_packages",
|
||||
"package_pool",
|
||||
"class_style",
|
||||
"member_style",
|
||||
"local_style",
|
||||
"widen_access",
|
||||
"rename_enum_constants",
|
||||
"rename_locals",
|
||||
"rename_public_members",
|
||||
],
|
||||
"rename",
|
||||
)?;
|
||||
let mut c = RenameConfig::default();
|
||||
if let Some(v) = t.get("spread_packages") {
|
||||
c.spread_packages = expect_bool(v, "rename.spread_packages")?;
|
||||
}
|
||||
if let Some(v) = t.get("package_pool") {
|
||||
c.package_pool = expect_usize(v, "rename.package_pool")?;
|
||||
}
|
||||
if let Some(v) = t.get("class_style") {
|
||||
c.class_style = parse_name_style(expect_str(v, "rename.class_style")?)?;
|
||||
}
|
||||
if let Some(v) = t.get("member_style") {
|
||||
c.member_style = parse_name_style(expect_str(v, "rename.member_style")?)?;
|
||||
}
|
||||
if let Some(v) = t.get("local_style") {
|
||||
c.local_style = parse_name_style(expect_str(v, "rename.local_style")?)?;
|
||||
}
|
||||
if let Some(v) = t.get("widen_access") {
|
||||
c.widen_access = expect_bool(v, "rename.widen_access")?;
|
||||
}
|
||||
if let Some(v) = t.get("rename_enum_constants") {
|
||||
c.rename_enum_constants = expect_bool(v, "rename.rename_enum_constants")?;
|
||||
}
|
||||
if let Some(v) = t.get("rename_locals") {
|
||||
c.rename_locals = expect_bool(v, "rename.rename_locals")?;
|
||||
}
|
||||
if let Some(v) = t.get("rename_public_members") {
|
||||
c.rename_public_members = expect_bool(v, "rename.rename_public_members")?;
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
pub(super) fn build_strings(t: &Table) -> Res<StringsConfig> {
|
||||
check_known_keys(
|
||||
t,
|
||||
&["schemes", "min_len", "split_above", "skip", "derive_key"],
|
||||
"strings",
|
||||
)?;
|
||||
let mut c = StringsConfig::default();
|
||||
if let Some(v) = t.get("schemes") {
|
||||
c.schemes = expect_string_vec(v, "strings.schemes")?;
|
||||
}
|
||||
if let Some(v) = t.get("min_len") {
|
||||
c.min_len = expect_usize(v, "strings.min_len")?;
|
||||
}
|
||||
if let Some(v) = t.get("split_above") {
|
||||
c.split_above = expect_usize(v, "strings.split_above")?;
|
||||
}
|
||||
if let Some(v) = t.get("skip") {
|
||||
c.skip = expect_string_vec(v, "strings.skip")?;
|
||||
}
|
||||
if let Some(v) = t.get("derive_key") {
|
||||
c.derive_key = expect_bool(v, "strings.derive_key")?;
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
pub(super) fn build_constants(t: &Table) -> Res<ConstantsConfig> {
|
||||
check_known_keys(t, &["schemes", "max_predicates", "skip"], "constants")?;
|
||||
let mut c = ConstantsConfig::default();
|
||||
if let Some(v) = t.get("schemes") {
|
||||
c.schemes = expect_string_vec(v, "constants.schemes")?;
|
||||
}
|
||||
if let Some(v) = t.get("max_predicates") {
|
||||
c.max_predicates = expect_usize(v, "constants.max_predicates")?;
|
||||
}
|
||||
if let Some(v) = t.get("skip") {
|
||||
c.skip = expect_string_vec(v, "constants.skip")?;
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
pub(super) fn build_flow(t: &Table) -> Res<FlowConfig> {
|
||||
check_known_keys(t, &["schemes", "max_insns", "encrypt_block_bodies"], "flow")?;
|
||||
let mut c = FlowConfig::default();
|
||||
if let Some(v) = t.get("schemes") {
|
||||
c.schemes = expect_string_vec(v, "flow.schemes")?;
|
||||
}
|
||||
if let Some(v) = t.get("max_insns") {
|
||||
c.max_insns = expect_usize(v, "flow.max_insns")?;
|
||||
}
|
||||
if let Some(v) = t.get("encrypt_block_bodies") {
|
||||
c.encrypt_block_bodies = expect_bool(v, "flow.encrypt_block_bodies")?;
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
pub(super) fn build_noise(t: &Table) -> Res<NoiseConfig> {
|
||||
check_known_keys(
|
||||
t,
|
||||
&[
|
||||
"junk_ratio",
|
||||
"junk_methods_per_class",
|
||||
"junk_annotations",
|
||||
"weave_reachable",
|
||||
],
|
||||
"noise",
|
||||
)?;
|
||||
let mut c = NoiseConfig::default();
|
||||
if let Some(v) = t.get("junk_ratio") {
|
||||
c.junk_ratio = expect_usize(v, "noise.junk_ratio")?;
|
||||
}
|
||||
if let Some(v) = t.get("junk_methods_per_class") {
|
||||
c.junk_methods_per_class = expect_usize(v, "noise.junk_methods_per_class")?;
|
||||
}
|
||||
if let Some(v) = t.get("junk_annotations") {
|
||||
c.junk_annotations = expect_usize(v, "noise.junk_annotations")?;
|
||||
}
|
||||
if let Some(v) = t.get("weave_reachable") {
|
||||
c.weave_reachable = expect_bool(v, "noise.weave_reachable")?;
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
pub(super) fn build_debug(t: &Table) -> Res<DebugConfig> {
|
||||
check_known_keys(
|
||||
t,
|
||||
&[
|
||||
"strip_line_numbers",
|
||||
"fake_line_numbers",
|
||||
"strip_local_variables",
|
||||
"spoof_source_file",
|
||||
"strip_source_file",
|
||||
"strip_signatures",
|
||||
],
|
||||
"debug",
|
||||
)?;
|
||||
let mut c = DebugConfig::default();
|
||||
if let Some(v) = t.get("strip_line_numbers") {
|
||||
c.strip_line_numbers = expect_bool(v, "debug.strip_line_numbers")?;
|
||||
}
|
||||
if let Some(v) = t.get("fake_line_numbers") {
|
||||
c.fake_line_numbers = expect_bool(v, "debug.fake_line_numbers")?;
|
||||
}
|
||||
if let Some(v) = t.get("strip_local_variables") {
|
||||
c.strip_local_variables = expect_bool(v, "debug.strip_local_variables")?;
|
||||
}
|
||||
if let Some(v) = t.get("spoof_source_file") {
|
||||
c.spoof_source_file = expect_bool(v, "debug.spoof_source_file")?;
|
||||
}
|
||||
if let Some(v) = t.get("strip_source_file") {
|
||||
c.strip_source_file = expect_bool(v, "debug.strip_source_file")?;
|
||||
}
|
||||
if let Some(v) = t.get("strip_signatures") {
|
||||
c.strip_signatures = expect_bool(v, "debug.strip_signatures")?;
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
64
crates/noxis-core/src/config/from_toml/tests.rs
Normal file
64
crates/noxis-core/src/config/from_toml/tests.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
//! Tests for TOML-subset → `Config` struct population.
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use super::*;
|
||||
use crate::config::sections::NameStyle;
|
||||
use crate::config::toml_lite;
|
||||
use crate::config::{PassId, Profile};
|
||||
|
||||
fn build_from(text: &str) -> Res<Config> {
|
||||
let table = toml_lite::parse(text).map_err(|e| e.to_string())?;
|
||||
build(&table)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_defaults_from_empty_input() {
|
||||
let cfg = build_from("").unwrap();
|
||||
assert_eq!(cfg, Config::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overrides_top_level_and_section_fields() {
|
||||
let cfg = build_from(
|
||||
r#"
|
||||
profile = "paranoid"
|
||||
seed = 123
|
||||
keep = ["com/example/Main"]
|
||||
[rename]
|
||||
package_pool = 4
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cfg.profile, Profile::Paranoid);
|
||||
assert_eq!(cfg.seed, Some(123));
|
||||
assert_eq!(cfg.keep, vec!["com/example/Main".to_string()]);
|
||||
assert_eq!(cfg.rename.package_pool, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_top_level_key() {
|
||||
assert!(build_from("totally_unknown = 1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_section_key() {
|
||||
assert!(build_from("[rename]\nbogus = 1\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_enable_disable_pass_lists() {
|
||||
let cfg = build_from(
|
||||
r#"enable = ["flow"]
|
||||
disable = ["strings"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cfg.enable, vec![PassId::Flow]);
|
||||
assert_eq!(cfg.disable, vec![PassId::Strings]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_name_style_in_rename_section() {
|
||||
let cfg = build_from("[rename]\nclass_style = \"il-o0\"\n").unwrap();
|
||||
assert_eq!(cfg.rename.class_style, NameStyle::IlO0);
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
//! Конфигурация обфускации: профили, список пассов, keep-правила.
|
||||
|
||||
mod from_toml;
|
||||
pub mod sections;
|
||||
mod toml_lite;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -146,9 +148,10 @@ impl Config {
|
|||
self.profile.baseline().contains(&id)
|
||||
}
|
||||
|
||||
/// Загрузить конфиг из TOML.
|
||||
/// Загрузить конфиг из TOML (наш подмножественный парсер, см. `toml_lite`).
|
||||
pub fn from_toml(text: &str) -> Result<Self, CoreError> {
|
||||
toml::from_str(text).map_err(|e| CoreError::Config(e.to_string()))
|
||||
let table = toml_lite::parse(text).map_err(|e| CoreError::Config(e.to_string()))?;
|
||||
from_toml::build(&table).map_err(CoreError::Config)
|
||||
}
|
||||
|
||||
/// Нормализовать значения, которые легко задать нулем/пустым списком.
|
||||
|
|
|
|||
218
crates/noxis-core/src/config/toml_lite/mod.rs
Normal file
218
crates/noxis-core/src/config/toml_lite/mod.rs
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
//! Hand-rolled parser for the small TOML subset `Config` actually uses.
|
||||
//!
|
||||
//! We don't need full TOML (dates, multi-line strings, inline tables,
|
||||
//! arrays-of-tables): every fixture and hand-written config in this repo
|
||||
//! is flat `key = value` pairs grouped by `[section]` / `[section.sub]`
|
||||
//! headers, with scalars limited to strings, integers, bools and arrays
|
||||
//! of those. Replacing the `toml` crate with the full spec would be far
|
||||
//! more code than this project needs; this covers exactly the syntax
|
||||
//! `Config::from_toml` has ever been asked to parse (see
|
||||
//! `crates/noxis-core/src/config/from_toml.rs` for the struct-population
|
||||
//! side), erroring out loudly on anything else instead of guessing.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
/// A parsed scalar or compound TOML-subset value.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Value {
|
||||
String(String),
|
||||
Integer(i64),
|
||||
Bool(bool),
|
||||
Array(Vec<Value>),
|
||||
Table(Table),
|
||||
}
|
||||
|
||||
/// A `[section]` body: key → value, in the order keys were written.
|
||||
pub type Table = BTreeMap<String, Value>;
|
||||
|
||||
/// A parse failure, with a human-readable message (no line/col tracking -
|
||||
/// configs here are short enough that the offending text is enough).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParseError(pub String);
|
||||
|
||||
impl fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn err(msg: impl Into<String>) -> ParseError {
|
||||
ParseError(msg.into())
|
||||
}
|
||||
|
||||
/// Parse TOML-subset source text into a root [`Table`].
|
||||
pub fn parse(text: &str) -> Result<Table, ParseError> {
|
||||
let mut root: Table = Table::new();
|
||||
let mut path: Vec<String> = Vec::new();
|
||||
|
||||
for raw_line in text.lines() {
|
||||
let line = strip_comment(raw_line).trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(header) = line.strip_prefix('[') {
|
||||
let header = header
|
||||
.strip_suffix(']')
|
||||
.ok_or_else(|| err(format!("незакрытый заголовок секции: `{raw_line}`")))?;
|
||||
path = header
|
||||
.split('.')
|
||||
.map(|seg| seg.trim().to_string())
|
||||
.collect();
|
||||
if path.iter().any(|seg| seg.is_empty()) {
|
||||
return Err(err(format!("пустой сегмент в заголовке: `{raw_line}`")));
|
||||
}
|
||||
ensure_table_path(&mut root, &path)?;
|
||||
continue;
|
||||
}
|
||||
let eq = find_top_level_eq(line)
|
||||
.ok_or_else(|| err(format!("ожидалось `key = value`: `{raw_line}`")))?;
|
||||
let key = line[..eq].trim();
|
||||
if key.is_empty() {
|
||||
return Err(err(format!("пустой ключ: `{raw_line}`")));
|
||||
}
|
||||
let value = parse_value(line[eq + 1..].trim())?;
|
||||
let table = table_at_path(&mut root, &path)?;
|
||||
table.insert(key.to_string(), value);
|
||||
}
|
||||
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
/// Drop a `#`-started comment, unless the `#` is inside a quoted string.
|
||||
fn strip_comment(line: &str) -> &str {
|
||||
let mut in_string = false;
|
||||
for (i, c) in line.char_indices() {
|
||||
match c {
|
||||
'"' => in_string = !in_string,
|
||||
'#' if !in_string => return &line[..i],
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
/// Find the first `=` that isn't inside a quoted string.
|
||||
fn find_top_level_eq(line: &str) -> Option<usize> {
|
||||
let mut in_string = false;
|
||||
for (i, c) in line.char_indices() {
|
||||
match c {
|
||||
'"' => in_string = !in_string,
|
||||
'=' if !in_string => return Some(i),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn ensure_table_path(root: &mut Table, path: &[String]) -> Result<(), ParseError> {
|
||||
let mut cur = root;
|
||||
for seg in path {
|
||||
let entry = cur
|
||||
.entry(seg.clone())
|
||||
.or_insert_with(|| Value::Table(Table::new()));
|
||||
match entry {
|
||||
Value::Table(t) => cur = t,
|
||||
_ => return Err(err(format!("`{seg}` уже используется как не-секция"))),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn table_at_path<'a>(root: &'a mut Table, path: &[String]) -> Result<&'a mut Table, ParseError> {
|
||||
let mut cur = root;
|
||||
for seg in path {
|
||||
match cur.get_mut(seg.as_str()) {
|
||||
Some(Value::Table(t)) => cur = t,
|
||||
_ => return Err(err(format!("секция `{seg}` не найдена"))),
|
||||
}
|
||||
}
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
fn parse_value(s: &str) -> Result<Value, ParseError> {
|
||||
let s = s.trim();
|
||||
if let Some(inner) = s.strip_prefix('"') {
|
||||
let inner = inner
|
||||
.strip_suffix('"')
|
||||
.ok_or_else(|| err(format!("незакрытая строка: `{s}`")))?;
|
||||
return Ok(Value::String(unescape(inner)?));
|
||||
}
|
||||
if let Some(inner) = s.strip_prefix('[') {
|
||||
let inner = inner
|
||||
.strip_suffix(']')
|
||||
.ok_or_else(|| err(format!("незакрытый массив: `{s}`")))?;
|
||||
return Ok(Value::Array(parse_array_elements(inner)?));
|
||||
}
|
||||
match s {
|
||||
"true" => return Ok(Value::Bool(true)),
|
||||
"false" => return Ok(Value::Bool(false)),
|
||||
_ => {}
|
||||
}
|
||||
s.parse::<i64>()
|
||||
.map(Value::Integer)
|
||||
.map_err(|_| err(format!("неподдерживаемое значение: `{s}`")))
|
||||
}
|
||||
|
||||
fn parse_array_elements(inner: &str) -> Result<Vec<Value>, ParseError> {
|
||||
let mut out = Vec::new();
|
||||
let mut depth = 0i32;
|
||||
let mut in_string = false;
|
||||
let mut start = 0usize;
|
||||
let bytes = inner.as_bytes();
|
||||
let push = |out: &mut Vec<Value>, chunk: &str| -> Result<(), ParseError> {
|
||||
let chunk = chunk.trim();
|
||||
if chunk.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
out.push(parse_value(chunk)?);
|
||||
Ok(())
|
||||
};
|
||||
for (i, c) in inner.char_indices() {
|
||||
match c {
|
||||
'"' => in_string = !in_string,
|
||||
'[' if !in_string => depth += 1,
|
||||
']' if !in_string => depth -= 1,
|
||||
',' if !in_string && depth == 0 => {
|
||||
push(&mut out, &inner[start..i])?;
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if start < bytes.len() {
|
||||
push(&mut out, &inner[start..])?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Basic escapes actually seen in configs: `\"`, `\\`, `\n`, `\t`.
|
||||
fn unescape(s: &str) -> Result<String, ParseError> {
|
||||
if !s.contains('\\') {
|
||||
return Ok(s.to_string());
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('"') => out.push('"'),
|
||||
Some('\\') => out.push('\\'),
|
||||
Some('n') => out.push('\n'),
|
||||
Some('t') => out.push('\t'),
|
||||
other => {
|
||||
return Err(err(format!(
|
||||
"неподдерживаемый escape-символ: `\\{}`",
|
||||
other.unwrap_or(' ')
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
83
crates/noxis-core/src/config/toml_lite/tests.rs
Normal file
83
crates/noxis-core/src/config/toml_lite/tests.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
//! Tests for the TOML-subset parser.
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_flat_pairs() {
|
||||
let t = parse("name = \"x\"\ncount = 4\nflag = true\n").unwrap();
|
||||
assert_eq!(t.get("name"), Some(&Value::String("x".into())));
|
||||
assert_eq!(t.get("count"), Some(&Value::Integer(4)));
|
||||
assert_eq!(t.get("flag"), Some(&Value::Bool(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_negative_integer() {
|
||||
let t = parse("x = -12").unwrap();
|
||||
assert_eq!(t.get("x"), Some(&Value::Integer(-12)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_section_header() {
|
||||
let t = parse("[rename]\npackage_pool = 4\n").unwrap();
|
||||
let Some(Value::Table(section)) = t.get("rename") else {
|
||||
panic!("expected table");
|
||||
};
|
||||
assert_eq!(section.get("package_pool"), Some(&Value::Integer(4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_nested_section_header() {
|
||||
let t = parse("[a.b]\nx = 1\n").unwrap();
|
||||
let Some(Value::Table(a)) = t.get("a") else {
|
||||
panic!("expected table a")
|
||||
};
|
||||
let Some(Value::Table(b)) = a.get("b") else {
|
||||
panic!("expected table b")
|
||||
};
|
||||
assert_eq!(b.get("x"), Some(&Value::Integer(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_string_array() {
|
||||
let t = parse(r#"keep = ["com/example/Main", "*Service"]"#).unwrap();
|
||||
assert_eq!(
|
||||
t.get("keep"),
|
||||
Some(&Value::Array(vec![
|
||||
Value::String("com/example/Main".into()),
|
||||
Value::String("*Service".into()),
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_empty_array() {
|
||||
let t = parse("keep = []").unwrap();
|
||||
assert_eq!(t.get("keep"), Some(&Value::Array(vec![])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_comments_and_blank_lines() {
|
||||
let t = parse("# comment\n\nx = 1 # trailing\n").unwrap();
|
||||
assert_eq!(t.get("x"), Some(&Value::Integer(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_inside_string_is_not_a_comment() {
|
||||
let t = parse(r#"x = "a#b""#).unwrap();
|
||||
assert_eq!(t.get("x"), Some(&Value::String("a#b".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_float() {
|
||||
assert!(parse("x = 1.5").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage_line() {
|
||||
assert!(parse("not a key value line").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unterminated_string() {
|
||||
assert!(parse("x = \"unterminated").is_err());
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue