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::Deserialize;

use crate::graph::OrderedMap;

use super::modes::RefusedConfig;
use super::{dir_of, join_rel};

#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(untagged)]
pub(super) enum OrderedValue {
    Null,
    Bool(bool),
    Number(serde_json::Number),
    String(String),
    Array(Vec<OrderedValue>),
    Object(OrderedMap<OrderedValue>),
}

impl OrderedValue {
    pub fn as_str(&self) -> Option<&str> {
        if let Self::String(value) = self {
            Some(value)
        } else {
            None
        }
    }

    pub fn as_object(&self) -> Option<&OrderedMap<Self>> {
        if let Self::Object(value) = self {
            Some(value)
        } else {
            None
        }
    }
}

#[derive(Clone, Debug)]
pub(super) struct Package {
    pub dir: String,
    pub name: Option<String>,
    pub module_type: Option<String>,
    pub main: Option<String>,
    pub exports: Option<OrderedValue>,
    pub imports: Option<OrderedValue>,
    pub workspaces: Option<Vec<String>>,
}

#[derive(Default)]
pub(super) struct Packages {
    pub by_dir: HashMap<String, Package>,
    pub refused: Vec<RefusedConfig>,
    pub refused_dirs: HashSet<String>,
}

impl Packages {
    pub fn nearest(&self, file: &str) -> Option<&Package> {
        let mut dir = dir_of(file);
        loop {
            if let Some(package) = self.by_dir.get(dir) {
                return Some(package);
            }
            if dir.is_empty() {
                return None;
            }
            dir = dir_of(dir);
        }
    }

    pub fn nearest_refused(&self, file: &str) -> bool {
        let mut dir = dir_of(file);
        loop {
            if self.refused_dirs.contains(dir) {
                return true;
            }
            if self.by_dir.contains_key(dir) || dir.is_empty() {
                return false;
            }
            dir = dir_of(dir);
        }
    }
}

pub(super) fn read_packages(root: &Path) -> Packages {
    let mut packages = Packages::default();
    let mut dirs = vec![String::new()];
    while let Some(dir) = dirs.pop() {
        let full = root.join(&dir);
        let Ok(entries) = fs::read_dir(&full) else {
            continue;
        };
        for entry in entries.flatten() {
            let Ok(kind) = entry.file_type() else {
                continue;
            };
            if kind.is_symlink() {
                continue;
            }
            let name = entry.file_name().to_string_lossy().into_owned();
            if kind.is_dir() {
                if !matches!(name.as_str(), ".git" | "node_modules" | "target") {
                    if let Some(child) = join_rel(&dir, &name) {
                        dirs.push(child)
                    }
                }
            } else if kind.is_file() && name == "package.json" {
                let rel = join_rel(&dir, &name).unwrap_or(name);
                let parsed = fs::read_to_string(entry.path())
                    .map_err(|_| "document")
                    .and_then(|text| parse_package(&dir, &text));
                match parsed {
                    Ok(package) => {
                        packages.by_dir.insert(dir.clone(), package);
                    }
                    Err(field) => {
                        packages.refused.push(RefusedConfig {
                            file: rel,
                            field: field.into(),
                        });
                        packages.refused_dirs.insert(dir.clone());
                    }
                }
            }
        }
    }
    packages.refused.sort_by(|a, b| a.file.cmp(&b.file));
    packages
}

fn parse_package(dir: &str, text: &str) -> Result<Package, &'static str> {
    let object: OrderedMap<OrderedValue> = serde_json::from_str(text).map_err(|_| "document")?;
    let name = field_string(&object, "name")?;
    if name.as_deref() == Some("") {
        return Err("name");
    }
    let module_type = field_string(&object, "type")?;
    if module_type
        .as_deref()
        .is_some_and(|value| !matches!(value, "module" | "commonjs"))
    {
        return Err("type");
    }
    let main = field_string(&object, "main")?;
    let exports = object.get("exports").cloned();
    if exports.as_ref().is_some_and(|value| !valid_map(value))
        || exports.as_ref().is_some_and(mixed_exports)
    {
        return Err("exports");
    }
    let imports = object.get("imports").cloned();
    if imports.as_ref().is_some_and(|value| !valid_map(value)) {
        return Err("imports");
    }
    let workspaces = match object.get("workspaces") {
        None => None,
        Some(OrderedValue::Array(items)) => Some(strings(items).ok_or("workspaces")?),
        Some(OrderedValue::Object(fields)) => match fields.get("packages") {
            Some(OrderedValue::Array(items)) => Some(strings(items).ok_or("workspaces")?),
            _ => return Err("workspaces"),
        },
        _ => return Err("workspaces"),
    };
    Ok(Package {
        dir: dir.into(),
        name,
        module_type,
        main,
        exports,
        imports,
        workspaces,
    })
}

fn field_string(
    object: &OrderedMap<OrderedValue>,
    name: &'static str,
) -> Result<Option<String>, &'static str> {
    object.get(name).map_or(Ok(None), |value| {
        value.as_str().map(|v| Some(v.to_string())).ok_or(name)
    })
}

fn strings(items: &[OrderedValue]) -> Option<Vec<String>> {
    items
        .iter()
        .map(|item| item.as_str().map(str::to_string))
        .collect()
}

fn valid_map(value: &OrderedValue) -> bool {
    match value {
        OrderedValue::Null | OrderedValue::String(_) => true,
        OrderedValue::Array(items) => items.iter().all(valid_map),
        OrderedValue::Object(object) => object.iter().all(|(_, value)| valid_map(value)),
        OrderedValue::Bool(_) | OrderedValue::Number(_) => false,
    }
}

fn mixed_exports(value: &OrderedValue) -> bool {
    let Some(object) = value.as_object() else {
        return false;
    };
    let subpaths = object.iter().any(|(key, _)| key.starts_with('.'));
    let conditions = object.iter().any(|(key, _)| !key.starts_with('.'));
    subpaths && conditions
}

#[cfg(test)]
mod tests;