devscout-rs 0.10.0

Fast code index for C# and TypeScript codebases -- map, find, refs, impact from the CLI.
//! Reading the values a project's settings files state for a configuration
//! key, the way the .NET JSON configuration provider reads them: comments
//! and trailing commas are allowed, and key parts match without regard to
//! ASCII case.

use std::fs;
use std::path::Path;

use super::catalogue::Val;
use super::fact_types::{ConfigValue, ValueObj};
use super::site_types::Parsed;
use super::values::classify;

const UNREADABLE: &str = "unreadable-configuration";

#[derive(Debug, PartialEq)]
enum Json {
    Str(String),
    Object(Vec<(String, Json)>),
    Other,
}

enum Found {
    Missing,
    Text(String),
    Unreadable,
}

struct Reader<'a> {
    chars: std::iter::Peekable<std::str::Chars<'a>>,
}

impl Reader<'_> {
    fn skip(&mut self) -> Option<()> {
        loop {
            match self.chars.peek() {
                Some(c) if c.is_whitespace() => {
                    self.chars.next();
                }
                Some('/') => {
                    self.chars.next();
                    match self.chars.next()? {
                        '/' => while self.chars.next().is_some_and(|c| c != '\n') {},
                        '*' => self.block_comment()?,
                        _ => return None,
                    }
                }
                _ => return Some(()),
            }
        }
    }

    fn block_comment(&mut self) -> Option<()> {
        let mut star = false;
        loop {
            let c = self.chars.next()?;
            if star && c == '/' {
                return Some(());
            }
            star = c == '*';
        }
    }

    fn value(&mut self) -> Option<Json> {
        self.skip()?;
        match *self.chars.peek()? {
            '{' => self.object(),
            '[' => self.array(),
            '"' => self.string().map(Json::Str),
            _ => self.scalar(),
        }
    }

    fn object(&mut self) -> Option<Json> {
        self.chars.next();
        let mut entries = Vec::new();
        loop {
            self.skip()?;
            if self.chars.peek() == Some(&'}') {
                self.chars.next();
                return Some(Json::Object(entries));
            }
            let key = self.string()?;
            self.skip()?;
            if self.chars.next()? != ':' {
                return None;
            }
            entries.push((key, self.value()?));
            self.skip()?;
            match self.chars.next()? {
                ',' => {}
                '}' => return Some(Json::Object(entries)),
                _ => return None,
            }
        }
    }

    fn array(&mut self) -> Option<Json> {
        self.chars.next();
        loop {
            self.skip()?;
            if self.chars.peek() == Some(&']') {
                self.chars.next();
                return Some(Json::Other);
            }
            self.value()?;
            self.skip()?;
            match self.chars.next()? {
                ',' => {}
                ']' => return Some(Json::Other),
                _ => return None,
            }
        }
    }

    fn string(&mut self) -> Option<String> {
        if self.chars.next()? != '"' {
            return None;
        }
        let mut out = String::new();
        loop {
            match self.chars.next()? {
                '"' => return Some(out),
                '\\' => out.push(self.escape()?),
                c if c < ' ' => return None,
                c => out.push(c),
            }
        }
    }

    fn escape(&mut self) -> Option<char> {
        Some(match self.chars.next()? {
            'n' => '\n',
            't' => '\t',
            'r' => '\r',
            'b' => '\u{8}',
            'f' => '\u{c}',
            c @ ('"' | '\\' | '/') => c,
            'u' => {
                let unit = self.hex4()?;
                if !(0xD800..0xDC00).contains(&unit) {
                    return char::from_u32(unit);
                }
                if self.chars.next()? != '\\' || self.chars.next()? != 'u' {
                    return None;
                }
                let low = self.hex4()?;
                if !(0xDC00..0xE000).contains(&low) {
                    return None;
                }
                char::from_u32(0x10000 + ((unit - 0xD800) << 10) + (low - 0xDC00))?
            }
            _ => return None,
        })
    }

    fn hex4(&mut self) -> Option<u32> {
        let mut unit = 0;
        for _ in 0..4 {
            unit = unit * 16 + self.chars.next()?.to_digit(16)?;
        }
        Some(unit)
    }

    // `true`, `false`, `null` or a number, which the JSON grammar alone
    // allows outside quotes; any other token makes the file unreadable.
    fn scalar(&mut self) -> Option<Json> {
        let mut token = String::new();
        while let Some(c) = self
            .chars
            .peek()
            .copied()
            .filter(|c| c.is_ascii_alphanumeric() || "+-.".contains(*c))
        {
            token.push(c);
            self.chars.next();
        }
        (matches!(token.as_str(), "true" | "false" | "null") || is_number(&token))
            .then_some(Json::Other)
    }
}

// `-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?`
fn is_number(token: &str) -> bool {
    fn digits(text: &str) -> (usize, &str) {
        let n = text.bytes().take_while(u8::is_ascii_digit).count();
        (n, &text[n..])
    }
    let rest = token.strip_prefix('-').unwrap_or(token);
    let (n, rest) = digits(rest);
    if n == 0 || (n > 1 && token.trim_start_matches('-').starts_with('0')) {
        return false;
    }
    let rest = match rest.strip_prefix('.') {
        Some(fraction) => match digits(fraction) {
            (0, _) => return false,
            (_, rest) => rest,
        },
        None => rest,
    };
    let rest = match rest.strip_prefix(['e', 'E']) {
        Some(exponent) => {
            let exponent = exponent.strip_prefix(['+', '-']).unwrap_or(exponent);
            match digits(exponent) {
                (0, _) => return false,
                (_, rest) => rest,
            }
        }
        None => rest,
    };
    rest.is_empty()
}

fn parse(text: &str) -> Option<Json> {
    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
    let mut reader = Reader {
        chars: text.chars().peekable(),
    };
    let value = reader.value()?;
    reader.skip()?;
    reader.chars.peek().is_none().then_some(value)
}

fn lookup(json: &Json, key: &str) -> Found {
    let mut node = json;
    for part in key.split(':') {
        let Json::Object(entries) = node else {
            return Found::Missing;
        };
        let mut matching = entries.iter().filter(|(k, _)| k.eq_ignore_ascii_case(part));
        match (matching.next(), matching.next()) {
            (None, _) => return Found::Missing,
            (Some(_), Some(_)) => return Found::Unreadable,
            (Some((_, value)), None) => node = value,
        }
    }
    match node {
        Json::Str(s) => Found::Text(s.clone()),
        _ => Found::Unreadable,
    }
}

// The one part of a parsed value a property of kind `val` holds.
fn part(parsed: Parsed, val: Val) -> Option<String> {
    match val {
        Val::HostName => parsed.host,
        Val::VirtualHost => parsed.virtual_host,
        Val::Region => parsed.region,
        _ => parsed.value,
    }
}

fn unknown(reason: &str, key: &str) -> ValueObj {
    ValueObj::Unknown {
        reason: reason.to_string(),
        key: Some(key.to_string()),
    }
}

/// The value `files` state for `key`, read as kind `val`. A file that does
/// not parse, a key two entries match, a value that is no string, and a
/// value outside its kind's grammar make the whole value unknown.
pub(crate) fn configured_value(root: &Path, files: &[String], key: &str, val: Val) -> ValueObj {
    let mut values = Vec::new();
    for file in files {
        let Some(json) = fs::read_to_string(root.join(file))
            .ok()
            .as_deref()
            .and_then(parse)
        else {
            return unknown(UNREADABLE, key);
        };
        let text = match lookup(&json, key) {
            Found::Missing => continue,
            Found::Unreadable => return unknown(UNREADABLE, key),
            Found::Text(text) => text,
        };
        match classify(&text, val).map(|p| part(p, val)) {
            Ok(Some(value)) => values.push(ConfigValue {
                file: file.clone(),
                value,
            }),
            Ok(None) => return unknown(super::values::UNREAD, key),
            Err(reason) => return unknown(reason, key),
        }
    }
    ValueObj::Configuration {
        key: key.to_string(),
        values,
    }
}

#[cfg(test)]
#[path = "configuration_tests.rs"]
mod tests;