use std::collections::HashMap;
use std::fmt;
use toml_edit::{Document, Item, Table};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FieldProvenance {
UserToml { line: usize },
LocalToml { line: usize },
ProcessorDefault,
ScanDefault,
OutputDirDefault,
SerdeDefault,
CliOverride,
}
impl fmt::Display for FieldProvenance {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UserToml { line } => write!(f, "from rsconstruct.toml:{line}"),
Self::LocalToml { line } => write!(f, "from rsconstruct.local.toml:{line}"),
Self::ProcessorDefault => write!(f, "processor default"),
Self::ScanDefault => write!(f, "scan default"),
Self::OutputDirDefault => write!(f, "output dir default"),
Self::SerdeDefault => write!(f, "serde default"),
Self::CliOverride => write!(f, "CLI override"),
}
}
}
pub type ProvenanceMap = HashMap<String, FieldProvenance>;
pub fn record_if_absent(
map: &mut ProvenanceMap,
field: &str,
source: FieldProvenance,
) {
if !map.contains_key(field) {
map.insert(field.to_string(), source);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Section {
Processor,
Analyzer,
}
pub type SpanMap = HashMap<(Section, String, String), usize>;
pub type GlobalSpanMap = HashMap<String, HashMap<String, usize>>;
pub fn build_span_maps(source: &str) -> (SpanMap, GlobalSpanMap) {
match Document::parse(source) {
Ok(doc) => (span_map_of(&doc, source), global_span_map_of(&doc, source)),
Err(_) => (SpanMap::new(), GlobalSpanMap::new()),
}
}
fn span_map_of(doc: &Document<&str>, source: &str) -> SpanMap {
let mut map = SpanMap::new();
let root = doc.as_table();
if let Some(item) = root.get("processor")
&& let Some(table) = item.as_table()
{
walk_instance_section(table, Section::Processor, source, &mut map);
}
if let Some(item) = root.get("analyzer")
&& let Some(table) = item.as_table()
{
walk_instance_section(table, Section::Analyzer, source, &mut map);
}
map
}
fn walk_instance_section(
table: &Table,
section: Section,
source: &str,
map: &mut SpanMap,
) {
for (type_name, item) in table {
let Some(sub) = item.as_table() else { continue };
let all_children_are_tables = !sub.is_empty()
&& sub.iter().all(|(_, v)| v.is_table());
if all_children_are_tables {
for (inst_suffix, inst_item) in sub {
if let Some(inst_table) = inst_item.as_table() {
let instance_name = format!("{type_name}.{inst_suffix}");
record_field_lines(inst_table, section, &instance_name, source, map);
}
}
} else {
record_field_lines(sub, section, type_name, source, map);
}
}
}
fn record_field_lines(
table: &Table,
section: Section,
instance_name: &str,
source: &str,
map: &mut SpanMap,
) {
for (key, item) in table {
let span = key_span(table, key).or_else(|| item_span(item));
if let Some(range) = span {
let line = byte_offset_to_line(source, range.start);
map.insert((section, instance_name.to_string(), key.to_string()), line);
}
}
}
fn key_span(table: &Table, key: &str) -> Option<std::ops::Range<usize>> {
table.key(key).and_then(toml_edit::Key::span)
}
fn item_span(item: &Item) -> Option<std::ops::Range<usize>> {
item.span()
}
fn global_span_map_of(doc: &Document<&str>, source: &str) -> GlobalSpanMap {
let mut map = GlobalSpanMap::new();
for (section_name, item) in doc.as_table() {
if section_name == "processor" || section_name == "analyzer" {
continue;
}
let Some(table) = item.as_table() else { continue };
let mut fields = HashMap::new();
for (key, field_item) in table {
let span = key_span(table, key).or_else(|| item_span(field_item));
if let Some(range) = span {
fields.insert(key.to_string(), byte_offset_to_line(source, range.start));
}
}
if !fields.is_empty() {
map.insert(section_name.to_string(), fields);
}
}
map
}
fn byte_offset_to_line(source: &str, offset: usize) -> usize {
let clamped = offset.min(source.len());
let mut line = 1usize;
for b in &source.as_bytes()[..clamped] {
if *b == b'\n' {
line += 1;
}
}
line
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spans_capture_instance_fields() {
let src = r#"[build]
output_dir = "build"
parallel = 4
[processor.ruff]
src_dirs = ["src"]
args = ["--fix"]
[processor.pylint.core]
src_dirs = ["src/core"]
"#;
let (spans, _) = build_span_maps(src);
let key = (Section::Processor, "ruff".to_string(), "args".to_string());
let line = spans.get(&key).copied().unwrap_or(0);
assert!(line > 0, "expected a real line for ruff.args, got {line} (span map: {spans:?})");
}
#[test]
fn global_spans_capture_build_fields() {
let src = r#"[build]
output_dir = "build"
parallel = 4
"#;
let (_, spans) = build_span_maps(src);
let build = spans.get("build").expect("expected [build] section in span map");
assert_eq!(build.get("parallel"), Some(&3), "expected parallel on line 3");
}
}