relay-knowledge 1.1.5

Graph-database-based knowledge graph project.
Documentation
use crate::domain::CodeImportRecord;

use super::super::import_resolution::{
    ImportContext, ImportResolution, ModuleFileResolution, normalize_join, parent_dir,
};

pub(in crate::code::identity) fn resolve_import(
    import: &CodeImportRecord,
    context: &ImportContext<'_>,
) -> Option<(ImportResolution, Option<String>)> {
    let target = import
        .module
        .lines()
        .find_map(shellcheck_source_target)
        .or_else(|| import.module.lines().find_map(bash_source_target))?;
    let candidates = shell_source_candidates(&import.path, &target);
    Some(resolve_module_candidates(context, &candidates))
}

fn shellcheck_source_target(line: &str) -> Option<String> {
    let statement = line.trim();
    if !statement.starts_with('#') {
        return None;
    }
    let target = statement.split_whitespace().find_map(|part| {
        part.strip_prefix("source=")
            .map(|value| trim_shell_quotes(value).to_owned())
    })?;

    (!target.is_empty()).then_some(target)
}

fn bash_source_target(line: &str) -> Option<String> {
    let statement = line.trim();
    let rest = statement
        .strip_prefix("source")
        .filter(|rest| rest.starts_with(char::is_whitespace))
        .or_else(|| {
            statement
                .strip_prefix('.')
                .filter(|rest| rest.starts_with(char::is_whitespace))
        })?;
    let target = first_shell_word(rest.trim())?;

    (!target.contains('$')).then(|| target.to_owned())
}

fn shell_source_candidates(import_path: &str, target: &str) -> Vec<String> {
    let mut candidates = Vec::new();
    if let Some(relative) = normalize_join(parent_dir(import_path), target) {
        push_module_file_candidates(&mut candidates, &relative, &["sh", "bash"]);
    }
    push_module_file_candidates(&mut candidates, target, &["sh", "bash"]);

    candidates
}

fn first_shell_word(value: &str) -> Option<&str> {
    let first = value.chars().next()?;
    if matches!(first, '"' | '\'') {
        let rest = value.get(first.len_utf8()..)?;
        let quoted_end = rest.find(first)?;
        return Some(&rest[..quoted_end]);
    }

    value.split_whitespace().next()
}

fn trim_shell_quotes(value: &str) -> &str {
    let Some(first) = value
        .chars()
        .next()
        .filter(|quote| matches!(quote, '"' | '\''))
    else {
        return value;
    };
    value
        .get(first.len_utf8()..)
        .and_then(|rest| rest.strip_suffix(first))
        .unwrap_or(value)
}

fn push_module_file_candidates(candidates: &mut Vec<String>, base_path: &str, extensions: &[&str]) {
    push_unique(candidates, base_path.to_owned());
    if base_path.rsplit_once('.').is_some() {
        return;
    }
    for extension in extensions {
        push_unique(candidates, format!("{base_path}.{extension}"));
    }
}

fn resolve_module_candidates(
    context: &ImportContext<'_>,
    candidates: &[String],
) -> (ImportResolution, Option<String>) {
    match context.resolve_first_module_file(candidates, true) {
        ModuleFileResolution::Resolved(target_hint) => {
            (ImportResolution::Resolved, Some(target_hint))
        }
        ModuleFileResolution::Ambiguous => (ImportResolution::Ambiguous, None),
        ModuleFileResolution::Unresolved => (ImportResolution::Unresolved, None),
    }
}

fn push_unique(values: &mut Vec<String>, value: String) {
    if !values.contains(&value) {
        values.push(value);
    }
}