knave-expand 0.14.21

The reading, scoping and expansion stages of knave
Documentation
use std::path::PathBuf;

use super::fields::{FieldScanner, Fields};
use super::name::ProjectName;
use super::problems::ProjectProblems;
use super::reader::SectionTable;
use super::{CrateDependency, LibraryDependency, LibrarySource};
use crate::diagnostic::Diagnostics;
use crate::modules::ModuleSources;
use crate::reader::{Form, FormKind};
use crate::source::Span;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Section {
    Libraries,
    Dependencies,
    DevDependencies,
}

pub struct DependencyReader<'p> {
    section: Section,
    problems: &'p mut Diagnostics,
    names: Vec<(String, Span)>,
}

struct EntryParts<'f> {
    name: &'f str,
    name_span: Span,
    version: Option<Version<'f>>,
    fields: Fields<'f>,
}

struct Version<'f> {
    text: &'f str,
    span: Span,
}

impl Section {
    pub const ALL: [Section; 3] = [
        Section::Libraries,
        Section::Dependencies,
        Section::DevDependencies,
    ];
    const LIBRARY_KEYS: &[&str] = &["path", "git", "rev"];
    const CRATE_KEYS: &[&str] = &["features", "path"];

    pub fn named(name: &str) -> Option<Self> {
        Self::ALL.into_iter().find(|section| section.name() == name)
    }

    pub fn name(self) -> &'static str {
        match self {
            Section::Libraries => "libraries",
            Section::Dependencies => "dependencies",
            Section::DevDependencies => "dev-dependencies",
        }
    }

    fn keys(self) -> &'static [&'static str] {
        match self {
            Section::Libraries => Self::LIBRARY_KEYS,
            Section::Dependencies | Section::DevDependencies => Self::CRATE_KEYS,
        }
    }
}

impl<'p> DependencyReader<'p> {
    const OPTION_PREFIX: char = '-';

    pub fn new(section: Section, problems: &'p mut Diagnostics) -> Self {
        Self {
            section,
            problems,
            names: Vec::new(),
        }
    }

    pub fn bare_name(form: &Form) -> Option<&str> {
        let name = form.as_symbol()?.name();
        FieldScanner::key_of(form).is_none().then_some(name)
    }

    pub fn read(mut self, entries: &[Form], table: &mut SectionTable) {
        for entry in entries {
            if let Some(parts) = self.entry_parts(entry) {
                self.add_entry(&parts, table);
            }
        }
    }

    fn entry_parts<'f>(&mut self, entry: &'f Form) -> Option<EntryParts<'f>> {
        let Some((name, rest)) = Self::named_entry(entry) else {
            self.problems
                .push(ProjectProblems::malformed_dependency(entry.span()));
            return None;
        };
        let (version, keyed) = Self::split_version(rest);
        let scanned = FieldScanner::new(self.section.keys(), self.problems).scan(keyed);
        for other in scanned.others {
            self.problems
                .push(ProjectProblems::unexpected_entry_form(other.span()));
        }
        Some(EntryParts {
            name: Self::bare_name(name)?,
            name_span: name.span(),
            version,
            fields: scanned.fields,
        })
    }

    fn named_entry(entry: &Form) -> Option<(&Form, &[Form])> {
        let (name, rest) = entry.as_list()?.split_first()?;
        Self::bare_name(name)?;
        Some((name, rest))
    }

    fn split_version(forms: &[Form]) -> (Option<Version<'_>>, &[Form]) {
        if let Some((first, rest)) = forms.split_first()
            && let FormKind::String(text) = first.kind()
        {
            return (
                Some(Version {
                    text,
                    span: first.span(),
                }),
                rest,
            );
        }
        (None, forms)
    }

    fn add_entry(&mut self, parts: &EntryParts<'_>, table: &mut SectionTable) {
        if let Some((_, first)) = self.names.iter().find(|(name, _)| name == parts.name) {
            return self.problems.push(ProjectProblems::duplicate_entry(
                parts.name,
                parts.name_span,
                *first,
            ));
        }
        self.names.push((parts.name.to_string(), parts.name_span));
        match self.section {
            Section::Libraries => table.libraries.extend(self.library(parts)),
            Section::Dependencies => table.dependencies.push(self.crate_dependency(parts)),
            Section::DevDependencies => table.dev_dependencies.push(self.crate_dependency(parts)),
        }
    }

    fn library(&mut self, parts: &EntryParts<'_>) -> Option<LibraryDependency> {
        if let Some(version) = &parts.version {
            self.problems
                .push(ProjectProblems::versioned_library(parts.name, version.span));
        }
        self.check_library_name(parts);
        Some(LibraryDependency {
            name: parts.name.to_string(),
            name_span: parts.name_span,
            source: self.library_source(parts)?,
        })
    }

    fn check_library_name(&mut self, parts: &EntryParts<'_>) {
        if ModuleSources::is_reserved_library(parts.name) {
            return self.problems.push(ProjectProblems::reserved_library_name(
                parts.name,
                parts.name_span,
            ));
        }
        if !ProjectName::is_kebab_case(parts.name) {
            self.problems.push(ProjectProblems::invalid_library_name(
                parts.name,
                parts.name_span,
            ));
        }
    }

    fn library_source(&mut self, parts: &EntryParts<'_>) -> Option<LibrarySource> {
        let fields = &parts.fields;
        let path = fields.get("path");
        let repository = fields.get("git").or_else(|| fields.get("rev"));
        match (path, repository) {
            (Some(path), Some(repository)) => {
                self.problems.push(ProjectProblems::conflicting_sources(
                    parts.name,
                    repository.keyword.span(),
                    path.keyword.span(),
                ));
                None
            }
            (Some(_), None) => fields
                .string("path", self.problems)
                .map(|path| LibrarySource::Path(PathBuf::from(path))),
            (None, Some(_)) => self.git_source(parts),
            (None, None) => {
                self.problems.push(ProjectProblems::library_without_source(
                    parts.name,
                    parts.name_span,
                ));
                None
            }
        }
    }

    fn git_source(&mut self, parts: &EntryParts<'_>) -> Option<LibrarySource> {
        let Some(git) = parts.fields.get("git") else {
            self.problems.push(ProjectProblems::library_without_source(
                parts.name,
                parts.name_span,
            ));
            return None;
        };
        if parts.fields.get("rev").is_none() {
            self.problems.push(ProjectProblems::git_without_revision(
                parts.name,
                git.keyword.span(),
            ));
            return None;
        }
        let url = self.repository_url(&parts.fields);
        let commit = self.full_commit(&parts.fields);
        Some(LibrarySource::Git {
            url: url?,
            commit: commit?,
        })
    }

    fn repository_url(&mut self, fields: &Fields<'_>) -> Option<String> {
        let url = fields.string("git", self.problems)?;
        if !url.starts_with(Self::OPTION_PREFIX) {
            return Some(url);
        }
        let span = fields.get("git")?.value.span();
        self.problems
            .push(ProjectProblems::repository_like_an_option(span));
        None
    }

    fn full_commit(&mut self, fields: &Fields<'_>) -> Option<String> {
        let commit = fields.string("rev", self.problems)?;
        if Self::is_full_commit(&commit) {
            return Some(commit.to_ascii_lowercase());
        }
        let span = fields.get("rev")?.value.span();
        self.problems.push(ProjectProblems::short_revision(span));
        None
    }

    fn is_full_commit(commit: &str) -> bool {
        commit.len() == LibrarySource::FULL_COMMIT_LENGTH
            && commit
                .chars()
                .all(|character| character.is_ascii_hexdigit())
    }

    fn crate_dependency(&mut self, parts: &EntryParts<'_>) -> CrateDependency {
        if parts.version.is_none() && parts.fields.get("path").is_none() {
            self.problems.push(ProjectProblems::unversioned_crate(
                parts.name,
                parts.name_span,
            ));
        }
        CrateDependency {
            name: parts.name.to_string(),
            name_span: parts.name_span,
            version: parts
                .version
                .as_ref()
                .map(|version| version.text.to_string()),
            features: parts.fields.strings("features", self.problems),
            path: parts
                .fields
                .string("path", self.problems)
                .map(PathBuf::from),
        }
    }
}