shuck-semantic 0.0.43

Semantic analysis model for shell scripts with scopes, bindings, and dataflow
Documentation
//! zdot module request discovery.
//!
//! zdot resolves logical module names through `zdot_load_module`, then sources
//! `modules/<name>/<name>.zsh` from its module search path. This adapter only
//! records static module loads; dynamic cache and hook execution stays in the
//! generic zsh/runtime layer.

use super::super::*;
use super::*;

pub(super) struct ZdotPluginManager;

impl ZshPluginManager for ZdotPluginManager {
    fn collect_plugin_requests(&self, context: &PluginManagerContext<'_>) -> Vec<PluginRequest> {
        collect_zdot_plugin_requests(context)
    }
}

impl ZshPluginFramework for ZdotPluginManager {
    fn framework(&self) -> PluginFramework {
        PluginFramework::Zdot
    }

    fn root_keys(&self) -> &'static [&'static str] {
        &["zdot"]
    }

    fn resolve_plugin_entrypoint(&self, root: &Path, name: &str) -> Option<PathBuf> {
        Some(root.join("modules").join(name).join(format!("{name}.zsh")))
    }

    fn resolve_theme_entrypoint(&self, _root: &Path, _name: &str) -> Option<PathBuf> {
        None
    }

    fn resolve_source_suffix(
        &self,
        root: &Path,
        source_path: &Path,
        candidate: &str,
    ) -> Option<PathBuf> {
        let normalized = candidate.replace('\\', "/");
        if normalized == "zdot.zsh" || normalized.ends_with("/zdot.zsh") {
            return Some(PathBuf::from("zdot.zsh"));
        }

        let source_in_framework = source_path.starts_with(root);
        let candidate_has_framework_anchor =
            path_text_has_zdot_anchor(&normalized) || path_text_starts_with_path(&normalized, root);
        if !source_in_framework && !candidate_has_framework_anchor {
            return None;
        }

        suffix_after_last_marker(&normalized, ["/core/", "/modules/"])
    }
}

fn path_text_has_zdot_anchor(path: &str) -> bool {
    path.contains("/.zdot/") || path.contains("/zdot/")
}

fn collect_zdot_plugin_requests(context: &PluginManagerContext<'_>) -> Vec<PluginRequest> {
    let mut requests = Vec::new();
    for stmt in &context.file.body.stmts {
        let Command::Simple(command) = &stmt.command else {
            continue;
        };
        let Some(command_name) = static_word_text(&command.name, context.source) else {
            continue;
        };
        let args = static_command_args(command, context.source);
        let Some(args) = args.as_deref() else {
            continue;
        };
        match command_name.as_ref() {
            "zdot_load_module" => {
                let modules = static_plugin_names(
                    args.iter()
                        .filter(|arg| !arg.starts_with('-'))
                        .map(String::as_str),
                );
                requests.extend(modules.into_iter().map(|module| PluginRequest {
                    framework: PluginFramework::Zdot,
                    kind: PluginRequestKind::Plugin,
                    name: module,
                    span: stmt.span,
                    explicit: false,
                    root_hint: None,
                }));
            }
            "zdot_use_plugin" => {
                if let Some(request) = zdot_plugin_request_from_spec(args, stmt.span) {
                    requests.push(request);
                }
            }
            _ => {}
        }
    }
    requests
}

fn zdot_plugin_request_from_spec(args: &[String], span: Span) -> Option<PluginRequest> {
    let spec = args.iter().find(|arg| !arg.starts_with('-'))?;
    if let Some(name) = spec
        .strip_prefix("omz:plugins/")
        .filter(|name| !name.contains('/'))
    {
        return Some(PluginRequest {
            framework: PluginFramework::OhMyZsh,
            kind: PluginRequestKind::Plugin,
            name: name.to_owned(),
            span,
            explicit: false,
            root_hint: None,
        });
    }
    let plugin = spec.rsplit('/').next()?.trim();
    if plugin.is_empty() {
        return None;
    }
    Some(PluginRequest {
        framework: PluginFramework::Other(plugin.to_owned()),
        kind: PluginRequestKind::Plugin,
        name: plugin.to_owned(),
        span,
        explicit: false,
        root_hint: None,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn root_keys_match_config_name() {
        assert_eq!(ZdotPluginManager.root_keys(), &["zdot"]);
    }

    #[test]
    fn resolves_module_entrypoint() {
        let root = Path::new("/workspace/zdot");

        assert_eq!(
            ZdotPluginManager.resolve_plugin_entrypoint(root, "fzf"),
            Some(PathBuf::from("/workspace/zdot/modules/fzf/fzf.zsh"))
        );
        assert_eq!(
            ZdotPluginManager.resolve_entrypoint(root, PluginRequestKind::Plugin, "tmux"),
            Some(PathBuf::from("/workspace/zdot/modules/tmux/tmux.zsh"))
        );
    }

    #[test]
    fn does_not_resolve_themes() {
        let root = Path::new("/workspace/zdot");

        assert_eq!(
            ZdotPluginManager.resolve_theme_entrypoint(root, "prompt"),
            None
        );
        assert_eq!(
            ZdotPluginManager.resolve_entrypoint(root, PluginRequestKind::Theme, "prompt"),
            None
        );
    }

    #[test]
    fn resolves_bootstrap_and_framework_relative_source_suffixes() {
        let root = Path::new("/workspace/zdot");

        assert_eq!(
            ZdotPluginManager.resolve_source_suffix(
                root,
                Path::new("/workspace/app/.zshrc"),
                "/not-installed/zdot/zdot.zsh",
            ),
            Some(PathBuf::from("zdot.zsh"))
        );
        assert_eq!(
            ZdotPluginManager.resolve_source_suffix(
                root,
                Path::new("/workspace/zdot/zdot.zsh"),
                "/opt/app/core/hooks.zsh",
            ),
            Some(PathBuf::from("core/hooks.zsh"))
        );
        assert_eq!(
            ZdotPluginManager.resolve_source_suffix(
                root,
                Path::new("/workspace/zdot/zdot.zsh"),
                "/opt/app/modules/fzf/fzf.zsh",
            ),
            Some(PathBuf::from("modules/fzf/fzf.zsh"))
        );
    }

    #[test]
    fn rejects_unanchored_source_suffixes_outside_framework() {
        assert_eq!(
            ZdotPluginManager.resolve_source_suffix(
                Path::new("/workspace/zdot"),
                Path::new("/workspace/app/.zshrc"),
                "/opt/app/core/hooks.zsh",
            ),
            None
        );
    }

    #[test]
    fn use_plugin_omz_specs_resolve_to_oh_my_zsh_plugins() {
        let args = vec!["omz:plugins/zoxide".to_owned(), "defer".to_owned()];

        assert_eq!(
            zdot_plugin_request_from_spec(&args, Span::new()),
            Some(PluginRequest {
                framework: PluginFramework::OhMyZsh,
                kind: PluginRequestKind::Plugin,
                name: "zoxide".to_owned(),
                span: Span::new(),
                explicit: false,
                root_hint: None,
            })
        );
    }

    #[test]
    fn use_plugin_repo_specs_resolve_to_standalone_plugin_roots() {
        let args = vec![
            "zsh-users/zsh-autosuggestions".to_owned(),
            "defer".to_owned(),
        ];

        assert_eq!(
            zdot_plugin_request_from_spec(&args, Span::new()),
            Some(PluginRequest {
                framework: PluginFramework::Other("zsh-autosuggestions".to_owned()),
                kind: PluginRequestKind::Plugin,
                name: "zsh-autosuggestions".to_owned(),
                span: Span::new(),
                explicit: false,
                root_hint: None,
            })
        );
    }
}