Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-purgatory/src/kconfig/tree.rs

//! **Kconfig as the dependency graph it already is** — the [`crate::KconfigSource`]
//! over a real kernel tree.
//!
//! `mimir-v2.md` §2: *"what does `CONFIG_X` pull in? — Kconfig itself.
//! `select` / `depends on` / `default` IS a declarative dependency graph, in the
//! tree, machine-readable. No C scan."* This module reads it: from the root
//! `Kconfig`, following every `source` (with `$(SRCARCH)` substituted), through
//! `if`/`menu`/`choice` scopes whose conditions become dependencies of what they
//! enclose, past help blocks (prose, never attributes), into a symbol table with
//! a reverse `select` index.
//!
//! Three questions are answered from it and nothing else:
//!
//! * **what does enabling `X` force on?** — [`KconfigTree::select_closure`]
//! * **what does policy imply?** — [`KconfigTree::policy_closure`]: a fragment
//!   that says `SECCOMP_FILTER=y` has also said `SECCOMP` and `NET`, because
//!   `olddefconfig` would drop the first without the others. Invariant 2 must
//!   therefore protect the closure, not just the named set.
//! * **why is this here?** — [`KconfigTree::explain_arrival`]: the enabled
//!   symbol that `select`ed an `ADDED` one, with the file it is written in. The
//!   2026-09-07 `BLK_DEV_IO_TRACE select DEBUG_FS` finding, made routine.
//!
//! It is a reader, not a solver. It does not evaluate expressions, does not know
//! a symbol's value, and never predicts an effective set — that is the produced
//! `.config`'s job and [`super::effective`]'s honesty rule.
//!
//! Zero dependencies: a hand-rolled tokenizer over `std::fs`. A kernel tree is
//! ~1 900 Kconfig files and reads in well under a second.

use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
use std::fmt;
use std::path::{Path, PathBuf};

/// A symbol's type, from `bool`/`tristate`/`def_bool`/…
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
    Bool,
    Tristate,
    Int,
    Hex,
    Str,
    /// Declared without a type line (a bare `config X` seen only via `select`).
    Unknown,
}

/// `select TARGET [if COND]` (also `imply`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Select {
    pub target: String,
    pub cond: Option<String>,
}

/// `default VALUE [if COND]`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DefaultValue {
    pub value: String,
    pub cond: Option<String>,
}

/// One `config`/`menuconfig` entry, with every block that named it merged.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KSymbol {
    pub name: String,
    pub kind: Kind,
    /// The prompt text, if the symbol is ever prompted. `None` means the user
    /// can never toggle it — only `select`/`default` can.
    pub prompt: Option<String>,
    /// `bool "…" if EXPERT` — the shape `hardened.config:28-36` explains: not
    /// prompted on a non-EXPERT defconfig, so `olddefconfig` restores the default.
    pub prompt_cond: Option<String>,
    /// The symbol's own `depends on` lines, verbatim expressions.
    pub depends: Vec<String>,
    /// Conditions of the enclosing `if`/`menu`/`choice` scopes — dependencies
    /// Kconfig applies without the symbol saying so.
    pub scope_depends: Vec<String>,
    pub selects: Vec<Select>,
    pub implies: Vec<Select>,
    pub defaults: Vec<DefaultValue>,
    /// Tree-relative file and line of the FIRST block that declared it.
    pub file: String,
    pub line: usize,
}

impl KSymbol {
    fn new(name: &str, file: &str, line: usize) -> KSymbol {
        KSymbol {
            name: name.to_string(),
            kind: Kind::Unknown,
            prompt: None,
            prompt_cond: None,
            depends: Vec::new(),
            scope_depends: Vec::new(),
            selects: Vec::new(),
            implies: Vec::new(),
            defaults: Vec::new(),
            file: file.to_string(),
            line,
        }
    }
}

/// Who `select`s a symbol, and where that is written.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Selector {
    pub by: String,
    pub cond: Option<String>,
    pub file: String,
    pub line: usize,
}

/// How a symbol is mentioned in an expression.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Polarity {
    /// `X`, `X=y`, `X=m`, `X!=n`, `X >= 3` — the dependant needs `X` on.
    Positive,
    /// `!X`, `X=n`, `X!=y` — the dependant needs `X` off; subtracting `X` cannot hurt it.
    Negative,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeError {
    pub why: String,
}
impl fmt::Display for TreeError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.why)
    }
}
impl std::error::Error for TreeError {}

/// The graph.
#[derive(Debug, Clone, Default)]
pub struct KconfigTree {
    root: Option<PathBuf>,
    release: Option<String>,
    symbols: BTreeMap<String, KSymbol>,
    selected_by: BTreeMap<String, Vec<Selector>>,
    files_read: usize,
    missing_sources: Vec<String>,
}

impl KconfigTree {
    /// Read a kernel tree from disk: `<root>/Kconfig` and everything it sources,
    /// with `$(SRCARCH)` = `arch`. A missing root is an error; a missing SOURCED
    /// file is recorded in [`KconfigTree::missing_sources`] and reading goes on.
    pub fn read(root: &Path, arch: &str) -> Result<KconfigTree, TreeError> {
        if !root.join("Kconfig").is_file() {
            return Err(TreeError {
                why: format!("{} has no Kconfig — not a kernel tree", root.display()),
            });
        }
        let mut tree = KconfigTree {
            root: Some(root.to_path_buf()),
            ..KconfigTree::default()
        };
        tree.release = read_release(&root.join("Makefile"));
        let reader = |rel: &str| std::fs::read_to_string(root.join(rel)).ok();
        Parser::new(&mut tree, arch, &reader).parse("Kconfig");
        Ok(tree)
    }

    /// Build from in-memory `(tree-relative path, text)` pairs — for fixtures.
    /// The first pair is the root. A `source` naming a path not in the list is
    /// recorded as missing, exactly as on disk.
    #[must_use]
    pub fn from_texts<'a, I>(arch: &str, files: I) -> KconfigTree
    where
        I: IntoIterator<Item = (&'a str, &'a str)>,
    {
        let map: BTreeMap<String, String> = files
            .into_iter()
            .map(|(p, t)| (p.to_string(), t.to_string()))
            .collect();
        let mut tree = KconfigTree::default();
        let Some(first) = map.keys().next().cloned() else {
            return tree;
        };
        let reader = |rel: &str| map.get(rel).cloned();
        Parser::new(&mut tree, arch, &reader).parse(&first);
        tree
    }

    #[must_use]
    pub fn root(&self) -> Option<&Path> {
        self.root.as_deref()
    }
    /// `VERSION.PATCHLEVEL.SUBLEVEL` from the root Makefile, e.g. `6.12.104`.
    #[must_use]
    pub fn release(&self) -> Option<&str> {
        self.release.as_deref()
    }
    #[must_use]
    pub fn len(&self) -> usize {
        self.symbols.len()
    }
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.symbols.is_empty()
    }
    #[must_use]
    pub fn files_read(&self) -> usize {
        self.files_read
    }
    /// `source` directives that resolved to nothing — a finding, never silence.
    #[must_use]
    pub fn missing_sources(&self) -> &[String] {
        &self.missing_sources
    }
    #[must_use]
    pub fn symbol(&self, name: &str) -> Option<&KSymbol> {
        self.symbols.get(name)
    }
    pub fn symbols(&self) -> impl Iterator<Item = &KSymbol> {
        self.symbols.values()
    }
    /// Every `select` that names `name`, wherever it is written.
    #[must_use]
    pub fn selected_by(&self, name: &str) -> &[Selector] {
        self.selected_by.get(name).map_or(&[], Vec::as_slice)
    }

    /// Positive symbols in a symbol's own and scope `depends on`, in order, deduplicated.
    #[must_use]
    pub fn positive_depends(&self, name: &str) -> Vec<String> {
        let Some(s) = self.symbols.get(name) else {
            return Vec::new();
        };
        let mut out = Vec::new();
        for expr in s.depends.iter().chain(s.scope_depends.iter()) {
            for (sym, pol) in symbols_in_expr(expr) {
                if pol == Polarity::Positive && !out.contains(&sym) {
                    out.push(sym);
                }
            }
        }
        out
    }

    /// **What enabling `name` forces on**, transitively, through `select` alone.
    /// `depends on` is not included: a dependency is required, not pulled.
    #[must_use]
    pub fn select_closure(&self, name: &str) -> BTreeSet<String> {
        let mut seen = BTreeSet::new();
        let mut queue: VecDeque<String> = VecDeque::from([name.to_string()]);
        while let Some(cur) = queue.pop_front() {
            if let Some(s) = self.symbols.get(&cur) {
                for sel in &s.selects {
                    if seen.insert(sel.target.clone()) {
                        queue.push_back(sel.target.clone());
                    }
                }
            }
        }
        seen.remove(name);
        seen
    }

    /// **What `name` needs on**, transitively, through positive `depends on`.
    #[must_use]
    pub fn depends_closure(&self, name: &str) -> BTreeSet<String> {
        let mut seen = BTreeSet::new();
        let mut queue: VecDeque<String> = VecDeque::from([name.to_string()]);
        while let Some(cur) = queue.pop_front() {
            for d in self.positive_depends(&cur) {
                if seen.insert(d.clone()) {
                    queue.push_back(d);
                }
            }
        }
        seen.remove(name);
        seen
    }

    /// **What policy implies is policy.** The seeds plus everything they
    /// `select` and everything they positively `depend on`, transitively. This
    /// is the set invariant 2 must protect: subtract a dependency and
    /// `olddefconfig` silently drops the hardening symbol that needed it.
    #[must_use]
    pub fn policy_closure(&self, seeds: &BTreeSet<String>) -> BTreeSet<String> {
        let mut seen: BTreeSet<String> = seeds.clone();
        let mut queue: VecDeque<String> = seeds.iter().cloned().collect();
        while let Some(cur) = queue.pop_front() {
            let mut next: Vec<String> = self.positive_depends(&cur);
            if let Some(s) = self.symbols.get(&cur) {
                next.extend(s.selects.iter().map(|sel| sel.target.clone()));
            }
            for n in next {
                if seen.insert(n.clone()) {
                    queue.push_back(n);
                }
            }
        }
        seen
    }

    /// **Why is `added` in the kernel?** The enabled symbols that `select` it.
    /// One hop, on purpose: the answer names a symbol somebody can look at, and
    /// that symbol's own arrival is the next question.
    #[must_use]
    pub fn explain_arrival(&self, added: &str, enabled: &BTreeSet<&str>) -> Vec<Selector> {
        self.selected_by(added)
            .iter()
            .filter(|s| enabled.contains(s.by.as_str()))
            .cloned()
            .collect()
    }
}

impl crate::KconfigSource for KconfigTree {
    fn selects(&self, symbol: &str) -> Vec<String> {
        self.symbols
            .get(symbol)
            .map(|s| s.selects.iter().map(|sel| sel.target.clone()).collect())
            .unwrap_or_default()
    }
    fn depends(&self, symbol: &str) -> Vec<String> {
        self.positive_depends(symbol)
    }
}

/// `VERSION = 6` / `PATCHLEVEL = 12` / `SUBLEVEL = 104` from a kernel Makefile.
fn read_release(makefile: &Path) -> Option<String> {
    let text = std::fs::read_to_string(makefile).ok()?;
    let mut v = [None::<String>, None, None];
    for line in text.lines().take(20) {
        let Some((k, val)) = line.split_once('=') else {
            continue;
        };
        let idx = match k.trim() {
            "VERSION" => 0,
            "PATCHLEVEL" => 1,
            "SUBLEVEL" => 2,
            _ => continue,
        };
        v[idx] = Some(val.trim().to_string());
    }
    match v {
        [Some(a), Some(b), Some(c)] => Some(format!("{a}.{b}.{c}")),
        _ => None,
    }
}

// ── the expression tokenizer ─────────────────────────────────────────────────

/// Every symbol an expression mentions, with how it is mentioned.
///
/// Not an evaluator: `depends on` conditions are read for the symbols they
/// NAME, because that is all the closure needs. Constants (`y`/`m`/`n`),
/// numbers, quoted strings and `$(macro …)` calls are not symbols. `!` and a
/// negated group flip polarity; `=n` / `!=y` / `!=m` are negative mentions.
#[must_use]
pub fn symbols_in_expr(expr: &str) -> Vec<(String, Polarity)> {
    let chars: Vec<char> = expr.chars().collect();
    let mut out = Vec::new();
    let mut i = 0;
    let mut groups: Vec<bool> = Vec::new(); // negation state of each open paren
    let mut pending_not = false;
    let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
    while i < chars.len() {
        let c = chars[i];
        let current_neg = groups.last().copied().unwrap_or(false);
        if c == '"' {
            i += 1;
            while i < chars.len() && chars[i] != '"' {
                i += 1;
            }
            i += 1;
            pending_not = false;
        } else if c == '$' && chars.get(i + 1) == Some(&'(') {
            let mut depth = 0;
            i += 1;
            while i < chars.len() {
                match chars[i] {
                    '(' => depth += 1,
                    ')' => {
                        depth -= 1;
                        if depth == 0 {
                            i += 1;
                            break;
                        }
                    }
                    _ => {}
                }
                i += 1;
            }
            pending_not = false;
        } else if c == '!' && chars.get(i + 1) != Some(&'=') {
            pending_not = !pending_not;
            i += 1;
        } else if c == '(' {
            groups.push(current_neg ^ pending_not);
            pending_not = false;
            i += 1;
        } else if c == ')' {
            groups.pop();
            i += 1;
        } else if is_ident(c) {
            let start = i;
            while i < chars.len() && is_ident(chars[i]) {
                i += 1;
            }
            let word: String = chars[start..i].iter().collect();
            let neg = current_neg ^ pending_not;
            pending_not = false;
            // A comparison operator after the word?
            let mut j = i;
            while j < chars.len() && chars[j] == ' ' {
                j += 1;
            }
            let op: Option<&str> = if chars[j..].starts_with(&['!', '=']) {
                Some("!=")
            } else if chars[j..].starts_with(&['>', '=']) || chars[j..].starts_with(&['<', '=']) {
                Some("cmp")
            } else if chars.get(j) == Some(&'=') {
                Some("=")
            } else if matches!(chars.get(j), Some('<' | '>')) {
                Some("cmp")
            } else {
                None
            };
            if let Some(op) = op {
                let rhs: String;
                j += op.len().min(2);
                if op == "cmp" && j < chars.len() && chars[j] == '=' {
                    j += 1;
                }
                while j < chars.len() && chars[j] == ' ' {
                    j += 1;
                }
                if j < chars.len() && chars[j] == '"' {
                    let mut k = j + 1;
                    while k < chars.len() && chars[k] != '"' {
                        k += 1;
                    }
                    rhs = chars[j + 1..k.min(chars.len())].iter().collect();
                    i = (k + 1).min(chars.len());
                } else if j < chars.len() && chars[j] == '$' {
                    // `X = $(macro)`: skip the macro, no RHS symbol.
                    i = j;
                    rhs = String::new();
                } else {
                    let mut k = j;
                    while k < chars.len() && is_ident(chars[k]) {
                        k += 1;
                    }
                    rhs = chars[j..k].iter().collect();
                    i = k;
                }
                let r = rhs.as_str();
                if is_symbol_name(&word) {
                    let pol = match (op, r) {
                        ("=", "n") | ("!=", "y") | ("!=", "m") => Polarity::Negative,
                        _ => Polarity::Positive,
                    };
                    out.push((word.clone(), flip(pol, neg)));
                }
                if is_symbol_name(r) {
                    out.push((r.to_string(), flip(Polarity::Positive, neg)));
                }
                continue;
            }
            if is_symbol_name(&word) {
                out.push((word, flip(Polarity::Positive, neg)));
            }
        } else {
            i += 1;
        }
    }
    out
}

fn flip(p: Polarity, neg: bool) -> Polarity {
    match (p, neg) {
        (Polarity::Positive, true) => Polarity::Negative,
        (Polarity::Negative, true) => Polarity::Positive,
        (p, false) => p,
    }
}

/// A Kconfig symbol has at least one upper-case ASCII letter and nothing but
/// `[A-Za-z0-9_]` — `y`, `n`, `m`, `if`, `140000` are not symbols.
fn is_symbol_name(s: &str) -> bool {
    !s.is_empty()
        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
        && s.chars().any(|c| c.is_ascii_uppercase())
}

// ── the parser ───────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ScopeKind {
    If,
    Menu,
    Choice,
}

struct Scope {
    kind: ScopeKind,
    exprs: Vec<String>,
}

/// Where attribute lines go.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Entity {
    None,
    Config(String),
    /// A `menu` or `choice` header: its `depends on` become the scope's.
    TopScope,
    Comment,
}

struct Parser<'a> {
    tree: &'a mut KconfigTree,
    arch: String,
    read: &'a dyn Fn(&str) -> Option<String>,
    scopes: Vec<Scope>,
    visited: HashSet<String>,
}

impl<'a> Parser<'a> {
    fn new(
        tree: &'a mut KconfigTree,
        arch: &str,
        read: &'a dyn Fn(&str) -> Option<String>,
    ) -> Parser<'a> {
        Parser {
            tree,
            arch: arch.to_string(),
            read,
            scopes: Vec::new(),
            visited: HashSet::new(),
        }
    }

    fn parse(&mut self, rel: &str) {
        if !self.visited.insert(rel.to_string()) {
            return;
        }
        let Some(text) = (self.read)(rel) else {
            self.tree.missing_sources.push(rel.to_string());
            return;
        };
        self.tree.files_read += 1;
        self.parse_text(rel, &text);
    }

    fn scope_exprs(&self) -> Vec<String> {
        self.scopes
            .iter()
            .flat_map(|s| s.exprs.iter().cloned())
            .collect()
    }

    fn pop_until(&mut self, kind: ScopeKind) {
        while let Some(s) = self.scopes.pop() {
            if s.kind == kind {
                break;
            }
        }
    }

    fn parse_text(&mut self, file: &str, text: &str) {
        let mut entity = Entity::None;
        // Help handling: `help_pending` is the indent of the `help` keyword;
        // `help_indent` the indent of its first line once seen.
        let mut help_pending: Option<usize> = None;
        let mut help_indent: Option<usize> = None;

        for (idx, raw) in text.lines().enumerate() {
            let lineno = idx + 1;
            let indent = indent_of(raw);
            let blank = raw.trim().is_empty();
            if let Some(hi) = help_indent {
                if blank || indent >= hi {
                    continue;
                }
                help_indent = None;
            } else if let Some(kw) = help_pending {
                if blank {
                    continue;
                }
                help_pending = None;
                if indent > kw {
                    help_indent = Some(indent);
                    continue;
                }
            }
            let line = strip_comment(raw).trim().to_string();
            if line.is_empty() {
                continue;
            }
            let (kw, rest) = match line.split_once(char::is_whitespace) {
                Some((k, r)) => (k, r.trim()),
                None => (line.as_str(), ""),
            };
            match kw {
                "config" | "menuconfig" => {
                    let name = rest.split_whitespace().next().unwrap_or("").to_string();
                    if name.is_empty() {
                        entity = Entity::None;
                        continue;
                    }
                    let scope = self.scope_exprs();
                    let sym = self
                        .tree
                        .symbols
                        .entry(name.clone())
                        .or_insert_with(|| KSymbol::new(&name, file, lineno));
                    for e in scope {
                        if !sym.scope_depends.contains(&e) {
                            sym.scope_depends.push(e);
                        }
                    }
                    entity = Entity::Config(name);
                }
                "menu" => {
                    self.scopes.push(Scope {
                        kind: ScopeKind::Menu,
                        exprs: Vec::new(),
                    });
                    entity = Entity::TopScope;
                }
                "endmenu" => {
                    self.pop_until(ScopeKind::Menu);
                    entity = Entity::None;
                }
                "choice" => {
                    self.scopes.push(Scope {
                        kind: ScopeKind::Choice,
                        exprs: Vec::new(),
                    });
                    entity = Entity::TopScope;
                }
                "endchoice" => {
                    self.pop_until(ScopeKind::Choice);
                    entity = Entity::None;
                }
                "if" => {
                    self.scopes.push(Scope {
                        kind: ScopeKind::If,
                        exprs: vec![rest.to_string()],
                    });
                    entity = Entity::None;
                }
                "endif" => {
                    self.pop_until(ScopeKind::If);
                    entity = Entity::None;
                }
                "source" => {
                    let path = unquote(rest);
                    let path = path
                        .replace("$(SRCARCH)", &self.arch)
                        .replace("$(HEADER_ARCH)", &self.arch);
                    if path.contains("$(") {
                        self.tree.missing_sources.push(path);
                    } else {
                        self.parse(&path);
                    }
                    entity = Entity::None;
                }
                "mainmenu" => entity = Entity::None,
                "comment" => entity = Entity::Comment,
                "help" | "---help---" => help_pending = Some(indent),
                "depends" => {
                    let expr = rest
                        .strip_prefix("on")
                        .map(str::trim)
                        .unwrap_or(rest)
                        .to_string();
                    match &entity {
                        Entity::Config(n) => {
                            if let Some(s) = self.tree.symbols.get_mut(n) {
                                s.depends.push(expr);
                            }
                        }
                        Entity::TopScope => {
                            if let Some(sc) = self.scopes.last_mut() {
                                sc.exprs.push(expr);
                            }
                        }
                        _ => {}
                    }
                }
                "select" | "imply" => {
                    if let Entity::Config(n) = &entity {
                        let (target, cond) = split_if(rest);
                        let target = target.split_whitespace().next().unwrap_or("").to_string();
                        if target.is_empty() {
                            continue;
                        }
                        let sel = Select {
                            target: target.clone(),
                            cond: cond.clone(),
                        };
                        if let Some(s) = self.tree.symbols.get_mut(n) {
                            if kw == "select" {
                                s.selects.push(sel);
                            } else {
                                s.implies.push(sel);
                            }
                        }
                        if kw == "select" {
                            self.tree
                                .selected_by
                                .entry(target)
                                .or_default()
                                .push(Selector {
                                    by: n.clone(),
                                    cond,
                                    file: file.to_string(),
                                    line: lineno,
                                });
                        }
                    }
                }
                "default" | "def_bool" | "def_tristate" => {
                    if let Entity::Config(n) = &entity {
                        let (value, cond) = split_if(rest);
                        if let Some(s) = self.tree.symbols.get_mut(n) {
                            s.defaults.push(DefaultValue {
                                value: value.trim().to_string(),
                                cond,
                            });
                            if kw == "def_bool" {
                                s.kind = Kind::Bool;
                            } else if kw == "def_tristate" {
                                s.kind = Kind::Tristate;
                            }
                        }
                    }
                }
                "bool" | "tristate" | "string" | "hex" | "int" | "prompt" => {
                    if let Entity::Config(n) = &entity {
                        if let Some(s) = self.tree.symbols.get_mut(n) {
                            match kw {
                                "bool" => s.kind = Kind::Bool,
                                "tristate" => s.kind = Kind::Tristate,
                                "string" => s.kind = Kind::Str,
                                "hex" => s.kind = Kind::Hex,
                                "int" => s.kind = Kind::Int,
                                _ => {}
                            }
                            if rest.starts_with('"') {
                                let (prompt, after) = take_quoted(rest);
                                s.prompt = Some(prompt);
                                let after = after.trim();
                                if let Some(c) = after.strip_prefix("if ") {
                                    s.prompt_cond = Some(c.trim().to_string());
                                }
                            }
                        }
                    }
                }
                // `range`, `option`, `modules`, `visible if`, and the macro
                // assignments in scripts/Kconfig.include: not graph.
                _ => {}
            }
        }
    }
}

fn indent_of(line: &str) -> usize {
    let mut n = 0;
    for c in line.chars() {
        match c {
            '\t' => n += 8,
            ' ' => n += 1,
            _ => break,
        }
    }
    n
}

/// Drop a `# comment` that is not inside double quotes.
fn strip_comment(line: &str) -> &str {
    let mut in_q = false;
    for (i, c) in line.char_indices() {
        match c {
            '"' => in_q = !in_q,
            '#' if !in_q => return &line[..i],
            _ => {}
        }
    }
    line
}

fn unquote(s: &str) -> String {
    let s = s.trim();
    s.strip_prefix('"')
        .and_then(|s| s.strip_suffix('"'))
        .unwrap_or(s)
        .to_string()
}

/// `"text" rest` → (`text`, `rest`).
fn take_quoted(s: &str) -> (String, &str) {
    let Some(body) = s.strip_prefix('"') else {
        return (String::new(), s);
    };
    match body.find('"') {
        Some(end) => (body[..end].to_string(), &body[end + 1..]),
        None => (body.to_string(), ""),
    }
}

/// Split `VALUE if COND` at the LAST ` if ` that is outside quotes and parens.
fn split_if(s: &str) -> (String, Option<String>) {
    let chars: Vec<char> = s.chars().collect();
    let mut in_q = false;
    let mut depth = 0i32;
    let mut cut: Option<usize> = None;
    let mut i = 0;
    while i < chars.len() {
        match chars[i] {
            '"' => in_q = !in_q,
            '(' if !in_q => depth += 1,
            ')' if !in_q => depth -= 1,
            ' ' if !in_q && depth == 0 && chars[i + 1..].starts_with(&['i', 'f', ' ']) => {
                cut = Some(i)
            }
            _ => {}
        }
        i += 1;
    }
    match cut {
        Some(c) => {
            let value: String = chars[..c].iter().collect();
            let cond: String = chars[c + 4..].iter().collect();
            (value.trim().to_string(), Some(cond.trim().to_string()))
        }
        None => (s.trim().to_string(), None),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn split_if_finds_the_top_level_if_only() {
        assert_eq!(split_if("DEBUG_FS"), ("DEBUG_FS".into(), None));
        assert_eq!(
            split_if("HAVE_ARCH_HUGE_VMAP if X86_64 || X86_PAE"),
            (
                "HAVE_ARCH_HUGE_VMAP".into(),
                Some("X86_64 || X86_PAE".into())
            )
        );
        assert_eq!(
            split_if("\"a string with if inside\" if EXPERT"),
            ("\"a string with if inside\"".into(), Some("EXPERT".into()))
        );
        assert_eq!(
            split_if("$(if-success,x,y,n)"),
            ("$(if-success,x,y,n)".into(), None)
        );
    }

    #[test]
    fn comments_outside_quotes_are_stripped() {
        assert_eq!(strip_comment("endif # HYPERVISOR_GUEST").trim(), "endif");
        assert_eq!(
            strip_comment("bool \"a # in a prompt\""),
            "bool \"a # in a prompt\""
        );
        assert_eq!(strip_comment("# whole line"), "");
    }

    #[test]
    fn a_help_block_ends_where_the_indent_falls_back() {
        let t = KconfigTree::from_texts(
            "x86",
            [(
                "Kconfig",
                "config A\n\tbool \"a\"\n\thelp\n\t  prose\n\n\t  select B is prose too\n\nconfig B\n\tbool\n\tselect C\n\nconfig C\n\tbool\n",
            )],
        );
        assert!(t.symbol("A").unwrap().selects.is_empty());
        assert_eq!(t.symbol("B").unwrap().selects[0].target, "C");
        assert_eq!(t.len(), 3);
    }

    #[test]
    fn multiple_blocks_for_one_symbol_merge_and_keep_the_first_site() {
        let t = KconfigTree::from_texts(
            "x86",
            [
                (
                    "Kconfig",
                    "config A\n\tbool\n\tselect B\nsource \"b/Kconfig\"\n",
                ),
                ("b/Kconfig", "config A\n\tselect C\n\tdepends on D\n"),
            ],
        );
        let a = t.symbol("A").unwrap();
        assert_eq!(a.file, "Kconfig");
        assert_eq!(a.selects.len(), 2);
        assert_eq!(a.depends, ["D"]);
        assert_eq!(t.files_read(), 2);
    }
}