devscout-rs 0.10.0

Fast code index for C# and TypeScript codebases -- map, find, refs, impact from the CLI.
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;

use serde_json::Value;

use super::{dir_of, join_rel, strip_jsonc, TSCONFIG_CHAIN_LIMIT, TSCONFIG_ROOTS};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum Mode {
    Node10,
    Node16,
    NodeNext,
    Bundler,
    Classic,
}

#[derive(Clone, Debug)]
pub(super) struct ModeScope {
    pub config: String,
    pub mode: Mode,
    pub custom_conditions: Vec<String>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct RefusedConfig {
    pub file: String,
    pub field: String,
}

#[derive(Default)]
pub(super) struct ModeScopes {
    pub by_file: HashMap<String, ModeScope>,
    pub refused: Vec<RefusedConfig>,
}

impl ModeScopes {
    pub fn get(&self, file: &str) -> Option<&ModeScope> {
        self.by_file.get(file)
    }

    pub fn inert(&self) -> bool {
        self.by_file.is_empty()
    }
}

pub(super) fn read_modes<'a>(root: &Path, files: impl IntoIterator<Item = &'a str>) -> ModeScopes {
    let mut refused = Vec::new();
    let mut cache: HashMap<String, Option<ModeScope>> = HashMap::new();
    let mut by_file = HashMap::new();
    for file in files {
        let mut dir = dir_of(file);
        let mut owner = None;
        while !dir.is_empty() {
            let candidate = format!("{dir}/tsconfig.json");
            let scope = cache
                .entry(candidate.clone())
                .or_insert_with(|| read_mode_chain(root, &candidate, &mut refused));
            if scope.is_some() {
                owner = scope.clone();
                break;
            }
            dir = dir_of(dir);
        }
        if owner.is_none() {
            for &candidate in TSCONFIG_ROOTS {
                let scope = cache
                    .entry(candidate.to_string())
                    .or_insert_with(|| read_mode_chain(root, candidate, &mut refused));
                if scope.is_some() {
                    owner = scope.clone();
                    break;
                }
            }
        }
        if let Some(scope) = owner {
            by_file.insert(file.to_string(), scope);
        }
    }
    refused.sort_by(|a, b| a.file.cmp(&b.file).then_with(|| a.field.cmp(&b.field)));
    refused.dedup_by(|a, b| a.file == b.file);
    ModeScopes { by_file, refused }
}

fn read_mode_chain(
    root: &Path,
    start: &str,
    refused: &mut Vec<RefusedConfig>,
) -> Option<ModeScope> {
    let mut rel = start.to_string();
    let mut seen = HashSet::new();
    let mut mode = None;
    let mut module = None;
    let mut custom_conditions = None;
    for _ in 0..TSCONFIG_CHAIN_LIMIT {
        if !seen.insert(rel.clone()) {
            break;
        }
        let path = root.join(&rel);
        if !path.is_file() {
            break;
        }
        let Ok(text) = fs::read_to_string(&path) else {
            refuse(refused, &rel, "document");
            return None;
        };
        let Ok(value) = serde_json::from_str::<Value>(&strip_jsonc(&text)) else {
            refuse(refused, &rel, "document");
            return None;
        };
        if let Some(options) = value.get("compilerOptions").and_then(Value::as_object) {
            if let Some(field) = invalid_field(options) {
                refuse(refused, &rel, field);
                return None;
            }
            if mode.is_none() {
                mode = options
                    .get("moduleResolution")
                    .and_then(Value::as_str)
                    .and_then(parse_mode);
            }
            if module.is_none() {
                module = options
                    .get("module")
                    .and_then(Value::as_str)
                    .map(str::to_ascii_lowercase);
            }
            if custom_conditions.is_none() {
                custom_conditions = options
                    .get("customConditions")
                    .and_then(Value::as_array)
                    .map(|a| {
                        a.iter()
                            .filter_map(Value::as_str)
                            .map(str::to_string)
                            .collect()
                    });
            }
        }
        let next = value
            .get("extends")
            .and_then(Value::as_str)
            .filter(|v| v.starts_with('.'));
        let Some(next) = next else { break };
        let spelled = if next.ends_with(".json") {
            next.to_string()
        } else {
            format!("{next}.json")
        };
        let Some(joined) = join_rel(dir_of(&rel), &spelled) else {
            break;
        };
        rel = joined;
    }
    let mode = mode.or_else(|| implied_mode(module.as_deref()))?;
    Some(ModeScope {
        config: start.to_string(),
        mode,
        custom_conditions: custom_conditions.unwrap_or_default(),
    })
}

fn refuse(out: &mut Vec<RefusedConfig>, file: &str, field: &str) {
    out.push(RefusedConfig {
        file: file.to_string(),
        field: field.to_string(),
    });
}

fn invalid_field(options: &serde_json::Map<String, Value>) -> Option<&'static str> {
    let resolution = options
        .get("moduleResolution")
        .is_none_or(|value| value.as_str().and_then(parse_mode).is_some());
    if !resolution {
        return Some("compilerOptions.moduleResolution");
    }
    let module = options
        .get("module")
        .is_none_or(|value| value.as_str().is_some_and(valid_module));
    if !module {
        return Some("compilerOptions.module");
    }
    let conditions = options.get("customConditions").is_none_or(|value| {
        value
            .as_array()
            .is_some_and(|items| items.iter().all(Value::is_string))
    });
    if !conditions {
        return Some("compilerOptions.customConditions");
    }
    for (key, field) in [
        ("outDir", "compilerOptions.outDir"),
        ("rootDir", "compilerOptions.rootDir"),
    ] {
        if options
            .get(key)
            .is_some_and(|value| !value.as_str().is_some_and(|v| !v.is_empty()))
        {
            return Some(field);
        }
    }
    if options
        .get("composite")
        .is_some_and(|value| !value.is_boolean())
    {
        return Some("compilerOptions.composite");
    }
    None
}

fn parse_mode(value: &str) -> Option<Mode> {
    match value.to_ascii_lowercase().as_str() {
        "node" | "node10" => Some(Mode::Node10),
        "node16" => Some(Mode::Node16),
        "nodenext" => Some(Mode::NodeNext),
        "bundler" => Some(Mode::Bundler),
        "classic" => Some(Mode::Classic),
        _ => None,
    }
}

fn valid_module(value: &str) -> bool {
    matches!(
        value.to_ascii_lowercase().as_str(),
        "none"
            | "commonjs"
            | "amd"
            | "system"
            | "umd"
            | "es6"
            | "es2015"
            | "es2020"
            | "es2022"
            | "esnext"
            | "node16"
            | "node18"
            | "node20"
            | "nodenext"
            | "preserve"
    )
}

fn implied_mode(module: Option<&str>) -> Option<Mode> {
    match module? {
        "node16" | "node18" | "node20" => Some(Mode::Node16),
        "nodenext" => Some(Mode::NodeNext),
        "preserve" => Some(Mode::Bundler),
        _ => None,
    }
}

#[cfg(test)]
mod tests;