zdot Implementation Guide
June 22, 2026 · View on GitHub
This document provides technical implementation details for the zdot system. It covers the internal architecture, data structures, algorithms, and design decisions.
Audience: Developers working on the zdot core system or advanced users who want to understand internals.
For users: See the README for setup and the Module Writer's Guide for module creation.
Table of Contents
- Architecture Overview
- Core Components
- Data Structures
- Hook Lifecycle
- Dependency Resolution
- Context System
- Module Loading
- Logging System
- Debugging Tools
- Design Decisions
- Extension Points
Architecture Overview
System Flow
┌─────────────────────────────────────────────────────────────┐
│ 1. System Initialization (zdot.zsh) │
│ - Set up autoload paths │
│ - Source core modules │
│ - Initialize global data structures │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 2. Module Loading (zdot_load_module) │
│ - User calls zdot_load_module for each desired module │
│ - Searches configured module path (user dirs, then modules/) │
│ - Source each module file │
│ - Modules register hooks via zdot_register_hook() │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 3. Execution Planning (zdot_build_execution_plan) │
│ - Analyze hook dependencies │
│ - Perform topological sort │
│ - Build ordered execution plan │
│ - Store in _ZDOT_EXECUTION_PLAN array │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 4. Hook Execution (zdot_execute_all) │
│ - Iterate through execution plan │
│ - Check shell context matches │
│ - Execute hook function │
│ - Mark phase as provided if successful │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 5. Deferred Hook Dispatch (_zdot_run_deferred_phase_check) │
│ - Called once after zdot_execute_all completes │
│ - Checks each deferred hook's required phases │
│ - Dispatches hooks whose dependencies are now satisfied │
│ - Re-scans after each deferred hook completes (chain) │
└─────────────────────────────────────────────────────────────┘
Design Philosophy
- Declarative over Imperative: Modules declare what they need and provide, system figures out execution order
- Fail Gracefully: Optional hooks don't break the system if dependencies are missing
- Context Aware: Different behavior for different shell types
- Debuggable: Rich introspection tools for troubleshooting
- Extensible: Easy to add new modules without modifying core
Core Components
File Structure
zdot/
├── zdot.zsh # Entry point
├── core/
│ ├── core.zsh # Core bootstrap (sources other core modules)
│ ├── cache.zsh # Cache invalidation & compilation
│ ├── completions.zsh # Completion helpers (compdump management)
│ ├── functions.zsh # Function autoloading setup
│ ├── hooks.zsh # Hook system
│ ├── logging.zsh # Logging functions
│ ├── modules.zsh # Module loading pipeline (zdot_load_module, _zdot_build_module_search_path, _zdot_load_module_file)
│ ├── plugins.zsh # Plugin loading (antidote + zsh-defer) + sugar functions (zdot_define_module, zdot_simple_hook)
│ ├── utils.zsh # Utility functions (zdot_interactive, zdot_login, …)
│ ├── functions/ # Autoloaded functions (zdot, zdot_hooks_list, …)
│ │ ├── zdot # CLI dispatcher
│ │ ├── _zdot # Tab-completion for zdot CLI
│ │ └── ...
│ └── plugin-bundles/
│ └── omz.zsh # OMZ plugin bundle (compdef stub queue)
└── modules/ # User modules
├── xdg/
│ └── xdg.zsh
├── brew/
│ └── brew.zsh
└── ...
Core Modules
zdot.zsh (Entry Point)
Responsibilities:
- Set up autoload paths for functions
- Source core modules (hooks, logging, utils)
- Initialize function autoloading
- Set the stage for module loading
Key Code:
# Function autoloading — skips completion functions prefixed with _
if [[ -d "${ZDOTDIR}/core/functions" ]]; then
fpath=("${ZDOTDIR}/core/functions" $fpath)
for func_file in "${ZDOTDIR}"/core/functions/*; do
[[ -f "$func_file" ]] || continue
[[ "${func_file:t}" == _* ]] && continue # skip _zdot (completion function)
autoload -Uz "${func_file:t}"
done
fi
Design Note: Kept intentionally minimal. All complex logic is in sourced modules.
core/hooks.zsh (Hook System Core)
Responsibilities:
- Global data structure initialization
- Hook registration (
zdot_register_hook) - Execution order constraints (
zdot_defer_order) - Dependency resolution (
zdot_build_execution_plan) - Hook execution (
zdot_execute_all) - Deferred hook dispatch (
_zdot_run_deferred_phase_check) - Phase management (
zdot_allow_defer) - Module loading (
zdot_load_module)
Key Functions:
zdot_register_hook()
Registers a hook with the system.
Algorithm:
- Parse arguments (function name, contexts, flags)
- Generate unique hook ID:
hook_N(sequential integer, e.g.hook_1,hook_2) - Store metadata in global associative arrays
- If
--provides, create reverse mapping in_ZDOT_PHASE_PROVIDERS_BY_CONTEXT(key:"context:phase")
Validation:
- Checks for duplicate hook IDs
- Validates context values
- Ensures at least one context is specified
zdot_build_execution_plan()
Builds ordered execution plan via topological sort.
Algorithm (Kahn's BFS topological sort):
- Build a dependency graph: for each registered hook, add an edge from each dependency hook to it
- Compute in-degree for every hook (count of dependencies not yet satisfied)
- Seed a queue with all hooks whose in-degree is zero (no unmet dependencies)
- While the queue is non-empty:
- Dequeue a hook; add it to
_ZDOT_EXECUTION_PLAN(filtering by current context) - For each hook that depended on the dequeued hook, decrement its in-degree; if it reaches zero, enqueue it
- Dequeue a hook; add it to
- If any hooks remain with non-zero in-degree, a cycle exists (circular dependency error)
Dependency Resolution:
- Handled inline during graph traversal — no separate recursive function
- Missing optional dependencies: hook is skipped gracefully
- Missing required dependencies: warning is issued, hook excluded
Edge Cases:
- Circular dependency detection via recursion tracking
- Missing optional dependencies (hooks skipped gracefully)
- Missing required dependencies (warnings issued)
zdot_execute_all()
Executes hooks in planned order.
Algorithm:
- Check execution plan exists
- For each hook ID in plan:
- Look up function name
- Execute function
- If successful, mark phase as provided
- If failure, log error
- Mark hook as executed (prevents re-execution)
Execution Context:
- Each hook runs in current shell context
- Hook return value determines success (0 = success)
- Provided phases are immediately available for downstream hooks
core/logging.zsh (Logging System)
Responsibilities:
- Consistent log formatting
- Log level management
- Color and icon support
Functions:
zdot_info(): Informational messages (blue info icon)zdot_success(): Success messages (green checkmark)zdot_warn(): Warning messages (yellow warning icon)zdot_error(): Error messages (red X icon)zdot_verbose(): Debug messages (only withZDOT_VERBOSE=1)
Implementation Details:
- Uses ANSI color codes for formatting
- Icons: Unicode symbols (ℹ ✓ ⚠ ✗)
- Verbose mode controlled by
ZDOT_VERBOSEenvironment variable - All output goes to stderr (doesn't pollute stdout)
Design Note: Never replace echo statements that are function return values!
core/utils.zsh (Utility Functions)
Responsibilities:
- Debug functions
- Helper utilities
- System introspection
Key Functions:
zdot debug / zdot info
Comprehensive debug output available via the CLI.
Output Sections:
- Loaded modules list
- Registered hooks (via
zdot hook list) - Completion system status
Design Note: Entry point for troubleshooting configuration issues.
zdot_interactive(), zdot_login(), zdot_has_tty() (core/utils.zsh)
Shell context detection helpers.
Implementation:
zdot_interactive(): Checks$_ZDOT_IS_INTERACTIVE -eq 1(flag set once at startup bycore.zsh)zdot_login(): Checks$_ZDOT_IS_LOGIN -eq 1(flag set once at startup bycore.zsh)zdot_has_tty(): Checks[[ -t 1 ]]— distinct from interactive;zsh -i -c ...is interactive but has no PTY
Return Values:
- 0 = true (condition holds)
- 1 = false (condition does not hold)
Usage:
zdot_interactive || return 0 # skip in non-interactive shells
zdot_login || return 0 # skip in non-login shells
zdot_has_tty || return 0 # skip when no terminal I/O available
core/modules.zsh (Module Loading System)
Responsibilities:
- Module search path management (
_zdot_build_module_search_path) - Module loading with first-match search (
zdot_load_module) - Deduplication and existence checking (
_zdot_load_module_file) - Module path resolution across the search path (
zdot_module_path) - Loaded-module status check (
zdot_module_loaded) - Module listing with provenance (
zdot_module_list)
Key Functions:
_zdot_build_module_search_path() (private)
Populates _ZDOT_MODULE_SEARCH_PATH on first call. Reads the zstyle ':zdot:modules' search-path array, tilde-expands each entry, then appends _ZDOT_MODULE_DIR as the final built-in fallback. Subsequent calls are no-ops (idempotent guard).
zdot_load_module() (public)
Public API for loading a named module. Builds the search path if needed, then walks each directory looking for <name>/<name>.zsh. The first match is loaded via _zdot_load_module_file. User-supplied directories shadow built-in modules of the same name by appearing earlier in the path. Errors if no match is found.
_zdot_load_module_file() (private)
Internal entry point for loading any module file. Handles deduplication (returns immediately if _ZDOT_MODULES_LOADED[$module] is set), existence checking, and records the module in both _ZDOT_MODULES_LOADED and _ZDOT_MODULE_SOURCE_DIR.
zdot_module_path() (public)
Searches the path for <name>/<name>.zsh and sets REPLY to the first match. Returns 1 if the module is not found anywhere in the path.
zdot_module_loaded() (public)
Returns 0 if _ZDOT_MODULES_LOADED[$name] is set, 1 otherwise. Intended for use inside configure/init hooks where module authors want to vary behaviour based on which sibling modules the user has enabled. Because all zdot_load_module calls run synchronously at .zshrc time (before any hook fires), the check reflects the complete user selection regardless of load order.
zdot_module_list() (public)
Lists all loaded modules from _ZDOT_MODULES_LOADED, annotating each with its source directory. Modules loaded from .modules/. are shown as (modules); others show their full directory path.
Design Note: zdot_module_dir() is also defined here — it allows module authors to retrieve their own directory at load time via the _ZDOT_CURRENT_MODULE_DIR context variable set by _zdot_source_module.
core/functions/zdot_hooks_list (Hook Inspection)
Responsibilities:
- Display all registered hooks
- Categorize by phase, unplanned, or error
- Validate hook requirements
- Show context filtering
Algorithm:
- Detect current shell context
- Parse
--allflag for showing all contexts - Categorize each hook:
- Hooks by Phase: Have
--providesset - Unplanned Hooks: Have satisfiable deps but no
--provides - Error Hooks: No
--provides, have unsatisfiable requirements
- Hooks by Phase: Have
- Group phase hooks by provided phase
- Collect unplanned hooks
- Detect error hooks via requirement validation
Requirement Validation: A requirement is satisfiable if ANY of:
- Provided by a hook (
_ZDOT_PHASE_PROVIDERS_BY_CONTEXT[$ctx:$req]for each active context)
If any requirement is unsatisfiable, hook is flagged as error.
Display Format:
Hooks by Phase:
Phase: brew-ready
• _brew_init (interactive noninteractive) [optional]
Phase: xdg-configured
• _xdg_init (interactive noninteractive)
Unplanned Hooks:
• _xdg_cleanup (interactive noninteractive) [optional]
⚠️ Hooks with Missing Requirements:
• _broken_hook (interactive noninteractive)
✗ Missing requirement: nonexistent-phase
Data Structures
Global Associative Arrays
Hook Metadata
# Hook ID → Function name
typeset -gA _ZDOT_HOOKS
# Example: _ZDOT_HOOKS["hook_1"]="_brew_init"
# Hook ID → Context string (space-separated)
typeset -gA _ZDOT_HOOK_CONTEXTS
# Example: _ZDOT_HOOK_CONTEXTS["hook_1"]="interactive noninteractive"
# Hook ID → Required phases (space-separated)
typeset -gA _ZDOT_HOOK_REQUIRES
# Example: _ZDOT_HOOK_REQUIRES["hook_1"]="xdg-configured"
# Hook ID → Provided phase (single value)
typeset -gA _ZDOT_HOOK_PROVIDES
# Example: _ZDOT_HOOK_PROVIDES["hook_1"]="brew-ready"
# Hook ID → 1 if optional
typeset -gA _ZDOT_HOOK_OPTIONAL
# Example: _ZDOT_HOOK_OPTIONAL["hook_1"]=1
# Hook ID → variant include list (space-separated; empty = matches all variants)
typeset -gA _ZDOT_HOOK_VARIANTS
# Example: _ZDOT_HOOK_VARIANTS["hook_1"]="work contractor"
# Hook ID → variant exclude list (space-separated)
typeset -gA _ZDOT_HOOK_VARIANT_EXCLUDES
# Example: _ZDOT_HOOK_VARIANT_EXCLUDES["hook_1"]="small"
Phase Tracking
# "context:phase" → Hook ID (reverse lookup for providers, scoped per context)
typeset -gA _ZDOT_PHASE_PROVIDERS_BY_CONTEXT
# Example: _ZDOT_PHASE_PROVIDERS_BY_CONTEXT["interactive:brew-ready"]="hook_1"
# Variant-filtered view of _ZDOT_PHASE_PROVIDERS_BY_CONTEXT (built at plan time)
typeset -gA _ZDOT_PHASE_PROVIDERS_ACTIVE
# Same key format; only contains entries whose hook passes _zdot_variant_match
# Phase name → 1 if actually provided at runtime
typeset -gA _ZDOT_PHASES_PROVIDED
# Example: _ZDOT_PHASES_PROVIDED["brew-ready"]=1
Runtime State
# Hook ID → 1 when executed
typeset -gA _ZDOT_HOOKS_EXECUTED
# Example: _ZDOT_HOOKS_EXECUTED["hook_2"]=1
# All loaded module names → 1 (both built-in and user modules; used for dedup)
typeset -gA _ZDOT_MODULES_LOADED
# Example: _ZDOT_MODULES_LOADED["xdg"]=1
# module_name → absolute directory the module was loaded from (the module's own dir)
typeset -gA _ZDOT_MODULE_SOURCE_DIR
# Example: _ZDOT_MODULE_SOURCE_DIR["xdg"]="/Users/user/.config/zdot/modules/xdg"
# Ordered list of directories to search when resolving a module by name.
# Populated once by _zdot_build_module_search_path; modules/ is always last.
typeset -ga _ZDOT_MODULE_SEARCH_PATH
# Example: _ZDOT_MODULE_SEARCH_PATH=("/Users/user/.config/zsh/modules" "/Users/user/.config/zdot/modules")
# Transient: set during _zdot_source_module, unset immediately after sourcing
typeset -g _ZDOT_CURRENT_MODULE_NAME # e.g. "xdg"
typeset -g _ZDOT_CURRENT_MODULE_DIR # e.g. "/Users/user/.config/zdot/modules/xdg"
Global Arrays
# Ordered list of hook IDs to execute
typeset -ga _ZDOT_EXECUTION_PLAN
# Example: _ZDOT_EXECUTION_PLAN=("hook_1" "hook_2" ...)
Data Structure Design Decisions
Why Associative Arrays?
- O(1) lookup for hooks, phases, and metadata
- Natural key-value mapping
- Built-in existence checking via
${array[$key]:-}
Why Sequential Hook IDs?
- Hook IDs are assigned sequentially:
hook_1,hook_2, etc. - IDs are stable references used as keys across all hook metadata arrays
- Function names and contexts are stored separately in
_ZDOT_HOOK_FUNCSand_ZDOT_HOOK_CONTEXTS
Why Separate Arrays vs Nested Structures?
- Zsh doesn't have native nested data structures
- Separate arrays are simpler and faster
- Easier to iterate and query
Why Space-Separated Strings for Lists?
- Native Zsh word splitting:
${(z)string} - Simple to parse and iterate
- Compact storage
Hook Lifecycle
Registration Phase
Module Loaded
↓
zdot_register_hook() called
↓
Assign sequential hook_id = "hook_N"
↓
Validate arguments
↓
Store in _ZDOT_HOOKS[hook_id]
↓
Store metadata (contexts, requires, provides, optional, on-demand)
↓
Update reverse mappings (_ZDOT_PHASE_PROVIDERS_BY_CONTEXT, _ZDOT_ON_DEMAND_PHASES)
↓
Registration complete
Planning Phase
zdot_build_execution_plan() called
↓
Initialize empty plan and tracking sets
↓
For each registered hook:
↓
Check if contexts match current shell
↓
Compute in-degree for each hook (count of unsatisfied required phases)
↓
All dependencies satisfied? → Add to plan
↓
Missing optional dependency? → Skip gracefully
↓
Missing required dependency? → Issue warning, skip
↓
Execution plan built
↓
Stored in _ZDOT_EXECUTION_PLAN array
Execution Phase
zdot_execute_all() called
↓
Iterate _ZDOT_EXECUTION_PLAN
↓
For each hook_id:
↓
Look up function name in _ZDOT_HOOKS
↓
Execute function
↓
Success (return 0)?
↓
Mark hook as executed (_ZDOT_HOOKS_EXECUTED)
↓
Mark phase as provided (_ZDOT_PHASES_PROVIDED)
↓
Failure (return != 0)?
↓
Log error
↓
Continue to next hook
↓
All hooks executed
Dependency Resolution
Algorithm: Topological Sort (Kahn's BFS)
The dependency resolution uses Kahn's algorithm — a queue-based breadth-first topological sort — implemented in zdot_build_execution_plan (core/hooks.zsh).
Overview:
- Compute an in-degree for each hook: the number of its required phases that are not yet provided and have a known provider registered in the current context.
- Enqueue all hooks with in-degree 0 (no unsatisfied dependencies).
- Dequeue a hook, append it to the execution plan, mark its provided phases as satisfied, then decrement the in-degree of every hook that required one of those phases.
- Any hook whose in-degree reaches 0 is enqueued.
- Repeat until the queue is empty.
If hooks remain unprocessed after the queue drains, they either depend on unprovided phases (warning issued) or are part of a circular dependency (error issued).
Pseudocode:
# Build in-degree map
for each hook_id in registered hooks:
if contexts don't match current shell: skip
for each phase in _ZDOT_HOOK_REQUIRES[hook_id]:
if phase already in _ZDOT_PHASES_PROVIDED: continue
provider = _ZDOT_PHASE_PROVIDERS_BY_CONTEXT[ctx:phase]
if provider exists: in_degree[hook_id]++
# Enqueue zero-degree hooks
queue = [hook_id for hook_id if in_degree[hook_id] == 0]
# Process queue (Kahn's BFS)
while queue not empty:
hook_id = dequeue(queue)
execution_plan += hook_id
for each phase in _ZDOT_HOOK_PROVIDES[hook_id]:
_ZDOT_PHASES_PROVIDED += phase
for each dependent in hooks requiring phase:
in_degree[dependent]--
if in_degree[dependent] == 0:
enqueue(queue, dependent)
# Remaining hooks: missing or circular deps
for each hook_id not in execution_plan:
if optional: VERBOSE skip
else: WARNING required phase unavailable
Key Properties:
- No Recursion: BFS queue eliminates recursion depth limits
- Deterministic Order: Hooks at the same depth are processed in registration order
- Finally Group: The reserved
finallygroup is synthesized as an ordinary group (begin/member/end barriers) whose begin barrier requires every other in-plan hook, so it sorts last; it is force-deferred (and drains last) whenever any deferred hooks exist, otherwise it runs last in the eager pass - Optional Handling: Hooks with unresolvable optional deps are silently skipped
- Circular Detection: Hooks still in the unprocessed set after BFS drains indicate a cycle
Dependency Edge Cases
Circular Dependencies
Example:
zdot_register_hook _hook_a interactive --requires phase-b --provides phase-a
zdot_register_hook _hook_b interactive --requires phase-a --provides phase-b
Detection:
- After Kahn's BFS drains, any hook not in the execution plan has an unsatisfied in-degree
- If all unprocessed hooks have missing required (non-optional) dependencies and none of those dependencies can ever be provided by another unprocessed hook, a circular dependency is reported
- Error message issued; affected hooks are skipped
Output:
✗ Circular dependency detected involving hook_1 (_hook_a), hook_2 (_hook_b)
Missing Optional Dependencies
Example:
zdot_register_hook _hook_a interactive --requires nonexistent-phase --optional
Behavior:
- Dependency resolution fails
- Hook is skipped silently (verbose log only)
- No error issued
- Other hooks continue normally
Output (with ZDOT_VERBOSE=1):
ℹ Skipping optional hook _hook_a: required phase nonexistent-phase not available
Missing Required Dependencies
Example:
zdot_register_hook _hook_a interactive --requires nonexistent-phase
Behavior:
- Dependency resolution fails
- Warning issued
- Hook is skipped
- Other hooks continue
Output:
⚠ Hook _hook_a requires phase nonexistent-phase, which is not available
Finally Group Hooks
Example:
# In module: register a cleanup hook that runs after all other hooks complete
zdot_register_hook _cleanup interactive --group finally
Behavior:
--group finallymakes the hook a member of the reservedfinallygroup (use--group, not--requires-group— the latter is for hooks that must run after finally completes, which is rare)finallyis synthesized as an ordinary group: begin/member/end barriers, and members participate in the topological sort like any other group- The group's begin barrier is given a real
--requireson every other in-plan hook (each prior hook H provides_group_member_finally_<H>, which the begin barrier requires), so the whole subgraph sorts after everything else - Because the begin barrier requires every hook — deferred ones included — it is
force-deferred whenever any deferred hooks exist, cascading the subgraph
into the deferred set; the drain then releases it last. The cascade is
intentional, so the subgraph is pre-accepted and the force-defer pass stays
silent. If nothing is deferred,
finallysimply runs last in the eager pass. (Barrier pre-acceptance — begin/end barriers staying silent when force-deferred — applies to every group, since a barrier defers only because its members did; pre-accepting the members is the part unique tofinally.)
Use Case: Cleanup tasks, post-init bookkeeping that should run after all other setup completes
See the Module Guide → Predefined groups for the full design and the companion pre-defer group.
Predefined Group Scheduling: pre-defer and finally
The pre-defer and finally groups use the same begin/member/end barrier
synthesis as every other group; what's special is only how each group's begin
barrier is pushed last. The two mechanisms differ deliberately:
pre-defermust stay eager. Its begin barrier is ordered after every other eager hook with synthetic Kahn-graph edges only (via each predecessor's_defer_order_<hid>bridge phase), never real--requires. This is deliberate: the eager/deferred split isn't known until force-deferral runs after the sort, so a real dependency on a hook that later promotes to deferred would dragpre-deferinto the deferred set too. Synthetic edges are invisible to force-deferral, so they can't promote it. (For introspection, the real, now-known-eager dependencies are recorded separately after force-deferral — they document the order inzdot hook graph; they don't enforce it.)finallymust run after everything, deferred work included, so its begin barrier carries a real--requireson every other in-plan hook (each prior hook H is made to provide_group_member_finally_<H>, and the begin barrier requires it — the same member-phase mechanism a normal group's end barrier uses, applied to the begin barrier here).finallyis therefore deferred only if deferred hooks exist: requiring a deferred hook's phase force-defers the begin barrier, cascading the whole subgraph into the deferred set, and the drain releases it last. If nothing is deferred,finallysimply stays eager and runs last in the eager pass (likepre-defer, one step later). When the cascade does happen it is the whole point, not an accident, so the entirefinallysubgraph is pre-accepted (zdot_allow_defer-style) and the force-defer pass stays silent for it.
Context System
Shell Context Detection
Zsh provides context information via the $options associative array.
Available Contexts:
interactive: User is interacting with shell (terminal)noninteractive: Running scripts or subshellslogin: First shell after authenticationnonlogin: Subsequent shells (new terminal tabs/windows)
Detection Code (core/utils.zsh):
zdot_interactive() {
[[ $_ZDOT_IS_INTERACTIVE -eq 1 ]]
}
zdot_login() {
[[ $_ZDOT_IS_LOGIN -eq 1 ]]
}
zdot_has_tty() {
[[ -t 1 ]]
}
$_ZDOT_IS_INTERACTIVE and $_ZDOT_IS_LOGIN are set once at startup (before any plugin sourcing) based on zsh option flags, ensuring consistent context detection throughout the session regardless of subshell state.
zdot_has_tty() checks for a connected terminal (stdout is a TTY) — distinct from interactive mode and useful for guarding output that would break pipe usage.
Usage in Hook Registration:
# Interactive shells only
zdot_register_hook _prompt_init interactive
# Both interactive and non-interactive
zdot_register_hook _env_init interactive noninteractive
# All contexts (interactive, noninteractive, login, nonlogin)
zdot_register_hook _universal_init interactive noninteractive login nonlogin
Variant System
The variant is an optional third context dimension — a user-defined label orthogonal to interactivity and login status. At most one variant is active per shell session; an empty variant (the default) means no filtering is applied.
Detection (core/ctx.zsh, zdot_resolve_variant): called once from zdot_build_context before plan building. Priority order:
$ZDOT_VARIANTenvironment variablezstyle ':zdot:variant' name <value>- User-defined
zdot_detect_variant()function — must setREPLY - Empty string (default — no filtering)
Global state (core/core.zsh):
typeset -g _ZDOT_VARIANT="" # active variant (empty = default)
typeset -g _ZDOT_VARIANT_DETECTED=0 # 1 once zdot_resolve_variant has run
typeset -g _ZDOT_VARIANT_INDEX_BUILT=0 # 1 once _zdot_build_variant_provider_index has run
Per-hook storage (core/hooks.zsh):
typeset -gA _ZDOT_HOOK_VARIANTS # hook_id -> "v1 v2 ..." (include list; empty = all)
typeset -gA _ZDOT_HOOK_VARIANT_EXCLUDES # hook_id -> "v1 v2 ..." (exclude list)
Matching (_zdot_variant_match): exclude list takes priority; empty include list matches all variants; non-empty include list requires the active variant to appear in it.
Provider index (_zdot_build_variant_provider_index): called at plan-build time after zdot_resolve_variant. Populates _ZDOT_PHASE_PROVIDERS_ACTIVE — a variant-filtered view of _ZDOT_PHASE_PROVIDERS_BY_CONTEXT used during dependency resolution so that phases provided only by variant-excluded hooks are not counted as satisfiable.
Public API:
zdot_variant # print active variant string (may be empty)
zdot_is_variant work # returns 0 if active variant is 'work'
Group barrier inheritance: synthetic begin/end barrier hooks created by _zdot_init_resolve_groups inherit variant constraints derived from their members — include list = union of member include lists (any open member keeps the barrier open); exclude list = intersection of member exclude lists (all members must exclude a variant for the barrier to exclude it). This ensures that when all members of a group are variant-filtered out, the barriers are also filtered, keeping --requires-group dependency resolution correct.
Skipped optional members and the end barrier: an --optional member that is skipped at plan time (one of its own active requires has no provider — e.g. a tool-gated hook whose tool isn't installed) must not stall its group's end barrier. The member is variant/context-present, so it still has a registered provider for its synthetic _group_member_<G>_<id> phase — but that provider never runs, so a naive end barrier would keep a dead in-edge, never reach in-degree 0, and the plan would abort as a false circular dependency. zdot_build_execution_plan handles this in two steps:
- Optional-skip pre-pass (before in-degree counting): a fixed-point loop computes the set of
--optionalhooks that will be skipped — a hook is skipped when an active, non-_group_member_*require has no provider that will actually run (the provider is absent, or is itself a skipped optional hook, so skips cascade)._group_member_*requires are excluded as skip triggers, so a barrier is never skipped merely because one of its members is. Only--optionalhooks can enter the set; a non-optional hook with a missing provider remains a hard error. - Per-edge drop (during in-degree counting): a
_group_member_*edge whose provider is in the skip set is dropped rather than counted — exactly mirroring how_zdot_require_active_in_ctxdrops context-absent member edges. The end barrier therefore counts only the members that will run, fires once they complete, and--requires-groupconsumers proceed. If every member is skipped the barrier has no in-edges and fires immediately, identical to an empty group (vacuously satisfied).
The net effect: optional group members compose safely — adding a tool-gated --optional hook to a producer group never risks deadlocking a non-optional --requires-group consumer on machines where the tool is absent. (Helper: _zdot_provider_hook_in_contexts returns the provider hook-id, which the pre-pass and the per-edge drop both consult.)
Context Matching Algorithm
Function: zdot_build_execution_plan() (core/hooks.zsh)
Algorithm:
# Determine current context
local -a current_contexts
if [[ $_ZDOT_IS_INTERACTIVE -eq 1 ]]; then
current_contexts+=(interactive)
else
current_contexts+=(noninteractive)
fi
if [[ $_ZDOT_IS_LOGIN -eq 1 ]]; then
current_contexts+=(login)
else
current_contexts+=(nonlogin)
fi
# For each hook, apply context then variant filter
for hook_id in ${(k)_ZDOT_HOOKS}; do
# Skip hooks not in current context
if ! _zdot_context_match "$hook_id" "${current_contexts[@]}"; then
continue
fi
# Skip hooks that don't match the active variant
if ! _zdot_variant_match "$hook_id"; then
continue
fi
# Proceed with dependency resolution...
done
Logic:
- Hook must declare at least one context that matches current shell
- If hook declares
interactiveand shell isinteractive→ match - If hook declares
interactive noninteractive→ always matches (all interactivity levels) - If hook declares
loginand shell islogin→ match - Variant filter is applied after context filter; hooks with no
--variant/--variant-excludealways pass
Context Design Decisions
Why separate login/nonlogin from interactive/noninteractive?
- Login shells need special setup (environment variables, authentication)
- Interactive shells need UI configuration (prompt, keybindings)
- These concerns are orthogonal
Why allow multiple contexts per hook?
- Avoids duplication when hook applies to multiple contexts
- Single registration can cover all cases
Why context matching is inclusive (OR), not exclusive (AND)?
- Hook runs if ANY declared context matches
- More flexible and intuitive
- Allows "run in interactive OR noninteractive"
Module Loading
Module Loading Pipeline
The framework loads named modules through a three-layer call chain:
zdot_load_module <name> # public — search path walk, first match wins
│
│ _zdot_build_module_search_path (lazy, one-time)
│ walk _ZDOT_MODULE_SEARCH_PATH for <name>/<name>.zsh
▼
_zdot_load_module_file <name> <file> # private — dedup + existence check + load
│
▼
_zdot_source_module <name> <file> # private — compile if stale, set context vars, source
│
▼
zdot_cache_compile_file <file> # compile .zsh → .zwc if needed (core/cache.zsh)
source <compiled-or-original-file>
_zdot_load_module_file (core/modules.zsh)
Central private helper. zdot_load_module delegates to it after locating the module file via the search path.
_zdot_load_module_file() {
local module="\$1" module_file="\$2"
[[ -n "${_ZDOT_MODULES_LOADED[$module]}" ]] && return 0
if [[ ! -f "$module_file" ]]; then
zdot_error "_zdot_load_module_file: module file not found: $module_file"
return 1
fi
_zdot_source_module "$module" "$module_file"
_ZDOT_MODULES_LOADED[$module]=1
}
Key properties:
- Dedup: returns immediately if
_ZDOT_MODULES_LOADED[$module]is set; safe to call multiple times - Existence check: errors with a descriptive message if the file is missing
- Shared registry: built-in and user modules share
_ZDOT_MODULES_LOADED, preventing name collisions
_zdot_source_module (core/cache.zsh)
Private loader called by _zdot_load_module_file. Sets per-module context variables so module authors can reference their own directory via zdot_module_source.
_zdot_source_module() {
local module="\$1"
local module_file="\$2"
# compile if stale/missing...
_ZDOT_CURRENT_MODULE_DIR="${module_file:h}"
_ZDOT_CURRENT_MODULE_NAME="$module"
source "$module_file"
unset _ZDOT_CURRENT_MODULE_DIR
unset _ZDOT_CURRENT_MODULE_NAME
return 0
}
Note: Do not confuse
_zdot_source_module(framework-private loader) withzdot_module_source(public helper incore/utils.zshfor module authors to source sub-files relative to their module directory).
Module Search Path
zdot_load_module resolves module names against an ordered list of directories
(_ZDOT_MODULE_SEARCH_PATH). The first directory that contains <name>/<name>.zsh
wins. This allows user-supplied directories to shadow built-in modules.
Configuration
# In your .zshrc, before zdot_load_module calls:
zstyle ':zdot:modules' search-path \
"${XDG_CONFIG_HOME}/zsh/modules" \
"${HOME}/.dotfiles/zsh-extra"
modules/ ($_ZDOT_MODULE_DIR) is always appended as the final entry, so built-in modules
are available without any configuration. The path is built once on first use and
cached in _ZDOT_MODULE_SEARCH_PATH.
Module Structure
Identical to built-in modules — one directory per module, main file named the same as the directory:
<user-dir>/
└── <name>/
└── <name>.zsh # entry point (same convention as built-in modules)
Loading a Module
zdot_load_module my-custom
zdot_load_module walks the search path, loads from the first matching directory,
and records the source in _ZDOT_MODULE_SOURCE_DIR[$module]. Both built-in and
user-supplied modules go through the same pipeline.
Deduplication
All modules share _ZDOT_MODULES_LOADED. Loading the same name twice is a no-op
regardless of which directory it came from.
Cloning a Module
The clone CLI command copies any module (by name) to the first user-supplied
directory in the search path, as a starting point for customisation:
zdot module clone xdg
# finds xdg in the search path (modules/ by default)
# copies it to <first-user-dir>/xdg/
# fails if destination already exists
Public API
| Function | Description |
|---|---|
zdot_load_module <name> | Load a module (search path, deduped) |
zdot_module_path <name> | Return the path to a module's main file (REPLY) |
zdot_module_loaded <name> | Return 0 if the named module has been loaded |
zdot_module_list | Print all loaded modules with source directory |
CLI Reference
| Command | Description |
|---|---|
zdot module list | List all loaded modules with their source directory |
zdot module clone <name> | Clone a module into the first user directory |
No Automatic Module Discovery
Modules are loaded explicitly with zdot_load_module <name> in .zshrc. The
framework does not auto-discover modules from a directory scan — a removed
early design (zdot_load_modules, plural) that made the loaded set implicit
and order-dependent on the filesystem.
Module Isolation
Double-Load Prevention:
Every module should include this guard:
[[ -n "${_MYMODULE_LOADED:-}" ]] && return 0
_MYMODULE_LOADED=1
Why?
- Prevents double-loading if module is sourced multiple times
- Avoids duplicate hook registrations
- Prevents re-initialization side effects
Naming Convention:
- Variable name:
_<MODULENAME>_LOADED(uppercase, with leading underscore) - Leading underscore indicates internal/private variable
Module Namespacing
Best Practices:
- Prefix all module functions with
_<modulename>_ - Prefix all module variables with
_<MODULENAME>_or<MODULENAME>_ - Use
localfor temporary variables in functions - Avoid polluting global namespace
Example:
# Good: Namespaced
_mymodule_init() { ... }
_MYMODULE_LOADED=1
MYMODULE_CONFIG_DIR="..."
# Bad: Global namespace pollution
init() { ... }
LOADED=1
CONFIG_DIR="..."
Logging System
Implementation Details
File: core/logging.zsh
Color Codes:
local -r BLUE='\033[0;34m'
local -r GREEN='\033[0;32m'
local -r YELLOW='\033[1;33m'
local -r RED='\033[0;31m'
local -r RESET='\033[0m'
Icons:
local -r INFO_ICON="ℹ"
local -r SUCCESS_ICON="✓"
local -r WARN_ICON="⚠"
local -r ERROR_ICON="✗"
Output Target:
All logging functions output to stderr (>&2), not stdout.
Why stderr?
- Keeps stdout clean for function return values
- Allows piping/redirection of script output without capturing logs
- Standard convention for diagnostic messages
Log Levels
zdot_verbose():
- Only shown when
ZDOT_VERBOSE=1 - For debug/trace information
- Not shown by default
zdot_info():
- Always shown
- Informational messages
- Blue color, info icon (ℹ)
zdot_success():
- Always shown
- Success confirmations
- Green color, checkmark icon (✓)
zdot_warn():
- Always shown
- Warning messages (non-fatal)
- Yellow color, warning icon (⚠)
zdot_error():
- Always shown
- Error messages (may be fatal)
- Red color, X icon (✗)
Logging Best Practices
When to use each level:
# Verbose: Debug details
zdot_verbose "Checking for tool: $tool_name"
zdot_verbose "Found $count configuration files"
# Info: Normal informational messages
zdot_info "Initializing module: mymodule"
zdot_info "Configuring environment variables"
# Success: Confirmation of successful operations
zdot_success "Module initialized successfully"
zdot_success "Configuration loaded"
# Warn: Problems that don't prevent execution
zdot_warn "Configuration file not found, using defaults"
zdot_warn "Tool not installed, some features unavailable"
# Error: Problems that prevent execution
zdot_error "Required dependency not found: $dep"
zdot_error "Failed to initialize: $error_message"
Do's and Don'ts:
✅ DO:
- Use logging functions for all user-visible messages
- Include context in messages (module name, what's happening)
- Make error messages actionable (tell user what to do)
❌ DON'T:
- Replace
echoin functions that return values - Replace
echoincore/logging.zshitself - Use
echofor debug/informational output - Use
printorprintfinstead of logging functions
Debugging Tools
zdot info / zdot debug
Purpose: Show comprehensive system state for troubleshooting.
Available via: zdot info (summary), zdot debug (verbose).
Output Sections:
-
Loaded Modules:
- Lists all modules loaded via
zdot_load_module - Helps verify which modules are loaded
- Lists all modules loaded via
-
Registered Hooks:
- Shows hook organization (via
zdot hook list) - Shows phases, unplanned hooks, and errors
- Shows hook organization (via
-
Completion Status:
- Shows completion commands to be generated
- Shows live completion functions
- Helps debug completion issues
Usage:
zdot info
zdot debug
When to use:
- Configuration not working as expected
- Hooks not executing
- Understanding execution order
- Verifying module loading
zdot_hooks_list()
Purpose: Display registered hooks organized by category.
Location: core/functions/zdot_hooks_list
Arguments:
--all: Show hooks for all contexts (default: only active context)
Output Sections:
-
Hooks by Phase:
- Groups hooks by the phase they provide
- Shows contexts and flags (
[optional]) - Standard hooks that provide phases
-
Unplanned Hooks:
- Hooks without
--providesbut with satisfiable deps - Not errors; simply have no phase to provide
- Hooks without
-
Hooks with Missing Requirements:
- Hooks with unsatisfiable dependencies
- Shows which specific phases are missing
- True configuration errors
- Uses warning/error colors
Usage:
zdot_hooks_list # Active context only
zdot_hooks_list --all # All contexts
When to use:
- Understanding hook organization
- Debugging dependency issues
- Finding configuration errors
- Verifying module registration
Verbose Mode
Enable:
export ZDOT_VERBOSE=1
source ~/.zshrc
What it shows:
- Module loading progress
- Hook registration details
- Dependency resolution steps
- Phase provisions
- Skipped hooks and reasons
Example output:
ℹ Loading modules from: /Users/user/.config/zsh/zdot/lib
ℹ Loading module: xdg
ℹ Loading module: brew
ℹ Registering hook: _xdg_init (interactive noninteractive) provides xdg-configured
ℹ Registering hook: _brew_init (interactive noninteractive) provides brew-ready
ℹ Building execution plan
ℹ Resolving dependencies for: _brew_init@interactive noninteractive
ℹ Required phase xdg-configured provided by _xdg_init@interactive noninteractive
ℹ Adding hook to plan: _xdg_init@interactive noninteractive
ℹ Adding hook to plan: _brew_init@interactive noninteractive
ℹ Executing hook: _xdg_init
✓ xdg initialized
ℹ Phase provided: xdg-configured
ℹ Executing hook: _brew_init
✓ brew initialized
ℹ Phase provided: brew-ready
Manual Inspection
Global Arrays:
# Show execution plan
print -l "${_ZDOT_EXECUTION_PLAN[@]}"
# Show all registered hooks
print -l "${(k)_ZDOT_HOOKS[@]}"
# Show provided phases
print -l "${(k)_ZDOT_PHASES_PROVIDED[@]}"
# Show executed hooks
print -l "${(k)_ZDOT_HOOKS_EXECUTED[@]}"
# Show phase providers (key format: "context:phase")
for key in "${(k)_ZDOT_PHASE_PROVIDERS_BY_CONTEXT[@]}"; do
echo "$key -> ${_ZDOT_PHASE_PROVIDERS_BY_CONTEXT[$key]}"
done
Hook Metadata:
# Show specific hook details (hook IDs are sequential: hook_1, hook_2, ...)
hook_id="hook_1"
echo "Function: ${_ZDOT_HOOKS[$hook_id]}"
echo "Contexts: ${_ZDOT_HOOK_CONTEXTS[$hook_id]}"
echo "Requires: ${_ZDOT_HOOK_REQUIRES[$hook_id]}"
echo "Provides: ${_ZDOT_HOOK_PROVIDES[$hook_id]}"
echo "Optional: ${_ZDOT_HOOK_OPTIONAL[$hook_id]:-0}"
Design Decisions
Why Hook-Based Architecture?
Problem: Traditional zsh configs become monolithic and hard to maintain.
Solution: Hook-based system with dependency resolution.
Benefits:
- Modularity: Each concern in separate module
- Reusability: Modules can be shared across configs
- Correct Ordering: System determines execution order automatically
- Graceful Degradation: Optional hooks don't break system
- Testability: Modules can be tested independently
Trade-offs:
- More complex than simple sourcing
- Requires understanding dependency model
- Upfront setup cost
Why Topological Sort for Dependencies?
Problem: Need to execute hooks in dependency order.
Alternatives Considered:
- Manual ordering: User specifies order (brittle, error-prone)
- Priority numbers: User assigns priorities (doesn't express relationships)
- Dependency resolution: System figures out order (chosen)
Why chosen:
- Expresses actual relationships, not arbitrary order
- Automatically handles complex dependency graphs
- Catches circular dependencies
- More maintainable as modules are added/removed
Why Associative Arrays vs. Structs?
Problem: Need to store hook metadata.
Zsh Limitation: No native nested data structures or structs.
Alternatives Considered:
- Separate arrays per metadata field: Chosen
- Serialized strings:
"field1=value1;field2=value2"(parsing overhead) - Namespaced variables:
_ZDOT_HOOK_${hook_id}_PROVIDES(dynamic variable names, messy)
Why chosen:
- Clean separation of concerns
- O(1) lookup
- Easy to query and iterate
- No parsing overhead
Why Sequential Hook IDs?
Format: hook_N (e.g., hook_1, hook_2, hook_3)
Problem: Hooks need a stable, unique identity that is independent of the registering function's name or context list.
Alternatives Considered:
- Function name only:
_brew_init— breaks if the same function is registered twice, or renamed (rejected) - Composite keys:
<function>@<contexts>— encodes metadata in the key, fragile if contexts change, hard to use as an array key (rejected) - Sequential IDs: Chosen
Why chosen:
- Stable: ID is assigned at registration time and never changes
- Simple: trivial to generate (
(( _ZDOT_HOOK_COUNTER++ ))) - Decoupled: function name and contexts are stored separately in metadata arrays, not baked into the key
- Safe as array keys: no special characters or spaces
Why Optional Flag?
Problem: Some hooks depend on external tools (homebrew, docker, etc.) that might not be installed.
Alternatives Considered:
- Always fail if deps missing: Breaks system for missing tools (rejected)
- Ignore all missing deps: Hides real errors (rejected)
- Explicit optional flag: Chosen
Why chosen:
- Explicit intent (module author decides)
- Graceful degradation for optional features
- Errors shown for truly broken configs
Why Finally Group?
Problem: Some hooks need to run after all deferred initialization completes — e.g. cleanup or post-init bookkeeping. Previously this used --on-demand with a manually-triggered finalize phase.
Alternatives Considered:
- Manual trigger (
zdot_run_until finalize): Requires user to call explicitly, easy to forget (rejected) - Special
finalizephase: Treated as a regular phase but never provided by an eager hook — caused plan errors (rejected) - Finally as an ordinary group, ordered last by real deps: Chosen
Why chosen:
- Fully automatic: no user action required to trigger cleanup hooks
- Reuses the standard group machinery (begin/member/end barriers) — no special
case in the executor; intra-group
--requiresand introspection just work - Ordered last by giving the begin barrier a real
--requireson every other in-plan hook, so it falls out of the normal topological sort - Deferred only when needed: force-deferral promotes it past the deferred drain iff deferred hooks exist, otherwise it stays eager and runs last in the eager pass
- Members simply declare
--group finallyat registration time
Why Autoloading for Functions?
Problem: Sourcing all utility functions upfront is slow.
Solution: Zsh autoloading - functions loaded on first use.
Benefits:
- Faster startup (only load what's used)
- Cleaner namespace (functions not defined until needed)
- Better organization (one function per file)
Setup (zdot.zsh):
fpath=("${ZDOTDIR}/core/functions" $fpath)
for func_file in "${ZDOTDIR}"/core/functions/*; do
autoload -Uz "${func_file:t}"
done
Trade-offs:
- Requires proper
fpathsetup - Functions must be in separate files
- Slight delay on first use (negligible)
Extension Points
Adding New Core Functions
Location: core/functions/
Steps:
- Create file with function name:
core/functions/my_function - Write function (file contains only function definition)
- Function is automatically autoloaded (no changes to zdot.zsh needed)
Example (core/functions/my_function):
# Description of what this function does
my_function() {
local arg1="\$1"
# Function implementation
return 0
}
Conventions:
- One function per file
- Filename matches function name
- Include docstring comment
- Return 0 on success, non-zero on failure
Adding New Global Arrays
Location: core/hooks.zsh
Steps:
- Declare array with
typeset -gA(associative) ortypeset -ga(regular) - Document purpose in comment
- Initialize in same location as other arrays
Example:
# My new tracking array: key -> value
typeset -gA _ZDOT_MY_NEW_ARRAY
Naming Convention:
- Always start with
_ZDOT_ - All caps for arrays
- Descriptive name
Adding New Flags to zdot_register_hook()
Location: core/hooks.zsh
Steps:
- Add flag to usage documentation:
# Usage: zdot_register_hook <function> [contexts...] [--my-flag]
- Initialize local variable:
local my_flag=0
- Add case in argument parsing loop:
--my-flag)
my_flag=1
;;
- Store in global array (after line 92):
[[ $my_flag -eq 1 ]] && _ZDOT_HOOK_MY_FLAG[$hook_id]=1
- Declare global array at top of file:
typeset -gA _ZDOT_HOOK_MY_FLAG
- Update
zdot_hooks_listto display flag (core/functions/zdot_hooks_list)
Adding New Log Levels
Location: core/logging.zsh
Steps:
- Define color and icon:
local -r CYAN='\033[0;36m'
local -r NOTICE_ICON="➜"
- Create logging function:
zdot_notice() {
echo -e "${CYAN}${NOTICE_ICON}${RESET} $*" >&2
}
- Document in README.md
Pattern: All logging functions follow same structure:
- Color + Icon + Message
- Output to stderr (
>&2) - Use
echo -efor ANSI codes
Adding New Debug Commands
Location: core/functions/ (new file) or core/utils.zsh
Steps:
- Create function:
zdot_debug_phases() {
echo "=== Phase Status ==="
echo "Provided phases:"
for phase in "${(k)_ZDOT_PHASES_PROVIDED[@]}"; do
echo " ✓ $phase"
done
}
- If in new file, place in
core/functions/for autoloading - If in
core/utils.zsh, it's immediately available - Document in README.md
Modifying Dependency Resolution
Location: core/hooks.zsh, inside zdot_build_execution_plan() (the dependency graph building and Kahn's BFS topological sort loop)
Caution: This is core logic. Changes can break system.
Common modifications:
- Change skip behavior: Modify how optional hooks are handled in the in-degree computation loop
- Add new phase types: Extend the logic that classifies phase providers and group membership
- Improve cycle detection: Enhance the cycle-detected error path in
zdot_build_execution_plan()
Testing: After modifications, test with:
- Circular dependencies
- Missing optional dependencies
- Missing required dependencies
- Complex dependency graphs (3+ levels deep)
Using the Finally Group for Cleanup Hooks
Use Case: Run cleanup or post-init hooks automatically after all deferred initialization completes.
Implementation:
In module, register hook as a member of the finally group with --group finally:
zdot_register_hook _mymodule_cleanup interactive noninteractive \
--group finally
Result: _mymodule_cleanup runs last of all — after every eager and deferred hook —
because the synthesized finally begin barrier requires every other in-plan hook. No manual
triggering required. (Use --requires-group finally only for the rare hook that must run
after the finally group itself completes.)
Performance Considerations
Startup Time
Critical Path:
- Source zdot.zsh (~28 lines, fast)
- Source core modules (~650 total lines, fast)
- Load modules from modules/ (varies, usually <1000 lines)
- Build execution plan (O(n²) in worst case, n=number of hooks)
- Execute hooks (depends on hook implementations)
Bottlenecks:
- Module initialization: External commands (brew, eval, etc.)
- Completion loading: Can be slow for large completion systems
Optimizations:
- Lazy loading: Use autoloaded functions
- Conditional execution: Skip hooks in noninteractive shells
- Caching: Store expensive computation results
- Defer loading: Use plugins like
romkatv/zsh-deferfor non-critical features
Memory Usage
Global Arrays: Each hook adds ~5-7 entries to global arrays. With 20 hooks:
- ~100-140 array entries
- ~10KB memory overhead
- Negligible impact
Execution Plan: Stored as simple array, minimal memory.
Phase Tracking: Associative arrays, O(1) lookup, minimal overhead.
Overall: zdot's memory footprint is negligible (<50KB).
Caching System
zdot uses Zsh's native bytecode compilation (zcompile) to improve startup performance. The caching system creates .zwc (Zsh Word Code) bytecode files that are co-located with source files, allowing Zsh to use pre-compiled bytecode transparently.
How Zsh Bytecode Works
Zsh has built-in support for bytecode compilation that works automatically:
- Compilation: The
zcompilecommand converts.zshfiles to.zwcbytecode files - Co-location:
.zwcfiles MUST be in the same directory as the source file - Automatic usage: When you
source file.zsh, Zsh automatically looks forfile.zsh.zwc - Transparent loading: If
.zwcexists and is newer than.zsh, Zsh uses the bytecode - No code changes: Module loading code just sources
.zshfiles normally
This is standard Zsh behavior - zdot doesn't need any special logic to use bytecode files.
Architecture
zdot implements two types of caching:
1. Module Caching
Each module file (*.zsh) gets a co-located bytecode file:
~/.config/zsh/zdot/core/core.zsh → core.zsh.zwc (co-located)
~/.config/zsh/zdot/modules/git/git.zsh → git.zsh.zwc (co-located)
Implementation:
# Create bytecode file next to source file
zcompile module.zsh # Creates module.zsh.zwc
# Load module (Zsh automatically uses .zwc if available)
source module.zsh # Zsh uses module.zsh.zwc transparently
2. Function Caching
Each function file gets its own co-located .zwc file:
~/.config/zsh/zdot/modules/git/functions/
├── git-status
├── git-status.zwc # Per-file bytecode (co-located)
├── git-branch
└── git-branch.zwc # Per-file bytecode (co-located)
Implementation:
# Compile each function file individually (co-located .zwc)
zcompile git-status.zwc git-status
zcompile git-branch.zwc git-branch
# Add directory to fpath (Zsh finds .zwc automatically)
fpath=(~/zdot/modules/git/functions $fpath)
autoload -Uz git-status git-branch
3. Execution Plan Caching (Separate System)
The execution plan is cached separately in ~/.cache/zdot/plans/:
~/.cache/zdot/plans/
├── execution_plan_interactive_nonlogin_default.zsh # no variant set
├── execution_plan_interactive_nonlogin_default.zsh.zwc
├── execution_plan_interactive_nonlogin_work.zsh # ZDOT_VARIANT=work
└── execution_plan_interactive_nonlogin_work.zsh.zwc
This is a distinct caching mechanism from module/function caching.
Implementation Details
Cache Creation
The zdot_cache_compile_file() function handles bytecode compilation:
zdot_cache_compile_file() {
local source_file="\$1"
# Co-locate .zwc file next to source file
local output_file="${source_file}.zwc"
# Check if recompilation needed
if [[ -f "$output_file" ]] && ! zdot_is_newer_or_missing "$source_file" "$output_file"; then
return 0
fi
if ! zcompile "$output_file" "$source_file" 2>/dev/null; then
zdot_error "zdot_cache_compile_file: compilation failed for: $source_file"
return 1
fi
return 0
}
Location: ~/.config/zsh/zdot/core/cache.zsh:98
Module Loading
Modules are loaded through zdot_module_source():
zdot_module_source() {
local rel_path="\$1"
local module_dir=$(zdot_module_dir)
local source_file="${module_dir}/${rel_path}"
# Compile if caching enabled and .zwc is stale or missing
if zdot_cache_is_enabled; then
local compiled_path="${source_file}.zwc"
if zdot_is_newer_or_missing "$source_file" "$compiled_path"; then
zdot_cache_compile_file "$source_file"
fi
fi
# Source the .zsh file (Zsh uses .zwc automatically)
source "$source_file"
}
Location: ~/.config/zsh/zdot/core/utils.zsh:44
Function Loading
Functions are compiled and loaded via zdot_module_autoload_funcs():
zdot_module_autoload_funcs() {
local module_dir=$(zdot_module_dir)
local func_dir="${module_dir}/functions"
[[ -d "$func_dir" ]] || return 0
# Compile each function file to a co-located .zwc if caching enabled
if zdot_cache_is_enabled; then
zdot_cache_compile_functions "$func_dir"
fi
# Add directory to fpath (Zsh picks up co-located .zwc automatically)
fpath=("$func_dir" $fpath)
# Autoload all function files found in the directory
for func_file in "$func_dir"/*; do
[[ -f "$func_file" ]] || continue
local func_name="${func_file:t}"
autoload -Uz "$func_name"
done
}
Location: ~/.config/zsh/zdot/core/functions.zsh:127
Configuration
Enabling/Disabling
Control caching with zstyle:
# Enable caching (default in .zshrc)
zstyle ':zdot:cache' enabled yes
# Disable caching
zstyle ':zdot:cache' enabled no
Check cache status:
zdot_cache_is_enabled && echo "Caching enabled" || echo "Caching disabled"
Cache Invalidation
Remove all bytecode files to force recompilation:
# Remove all .zwc files
zdot_cache_invalidate
# Restart shell to recompile
exec zsh
This deletes:
- All
*.zsh.zwcfiles in~/.config/zsh/zdot/ - All
*.zwcfiles in function directories - The execution plan cache in
~/.cache/zdot/plans/
Cache File Locations
With zdot installed, bytecode files are co-located with source files:
~/.config/zsh/zdot/ (symlink to .dotfiles)
├── core/
│ ├── core.zsh
│ ├── core.zsh.zwc ← Co-located bytecode
│ ├── cache.zsh
│ ├── cache.zsh.zwc ← Co-located bytecode
│ └── ...
├── modules/
│ ├── git/
│ │ ├── git.zsh
│ │ ├── git.zsh.zwc ← Co-located bytecode
│ │ └── functions/
│ │ ├── git-status
│ │ ├── git-status.zwc ← Per-file bytecode
│ │ ├── git-branch
│ │ ├── git-branch.zwc ← Per-file bytecode
│ │ └── ...
│ └── ...
└── ...
~/.cache/zdot/plans/ (separate plan cache)
├── execution_plan_interactive_nonlogin.zsh
└── execution_plan_interactive_nonlogin.zsh.zwc
Total: ~56 .zwc files co-located with source files:
- 8 core module cache files
- 22 library module cache files
- 26 function cache files
Performance Impact
Bytecode compilation provides significant performance improvements:
- Startup time: ~0.40-0.42 seconds with caching enabled
- Parsing speed: ~10x faster with pre-compiled bytecode
- Cache overhead: Minimal (~1-2ms to check timestamps)
- Disk usage: ~200-300KB for all
.zwcfiles
The performance gain is most noticeable during shell startup, where dozens of modules and functions are loaded.
Why Co-location?
The co-location strategy (.zwc next to .zsh) is required by Zsh's design:
- Built-in behavior: When
source file.zshis called, Zsh automatically looks forfile.zsh.zwcin the same directory - Automatic usage: If found and newer, Zsh uses bytecode transparently - no code changes needed
- Function compatibility: Functions in
fpathwork correctly with co-located.zwcfiles - Simplicity: No special loading logic required - just compile and source normally
Alternative approaches (separate cache directory) don't work because:
- Zsh won't find
.zwcfiles in different directories - Sourcing
.zwcfiles directly causes parse errors - Function autoloading fails with non-co-located bytecode
Troubleshooting
Cache not being used
If performance doesn't improve:
# Check if caching is enabled
zdot_cache_is_enabled && echo "Caching enabled" || echo "Caching disabled"
# Verify .zwc files exist
ls -la ~/.config/zsh/zdot/core/*.zwc
ls -la ~/.config/zsh/zdot/modules/*/*.zwc
# Invalidate and regenerate cache
zdot_cache_invalidate
exec zsh
Stale bytecode
If code changes aren't reflected:
# .zwc files are automatically updated if source is newer
# Force regeneration:
zdot_cache_invalidate
exec zsh
Debug cache operations
Enable debugging to see cache operations:
# In .zshrc, before zdot loads
zstyle ':zdot:debug' enabled yes
zstyle ':zdot:debug' verbose yes
Contributing Guidelines
Code Style
Indentation: 4 spaces (use spaces, not tabs)
Whitespace:
- Strip trailing whitespace
- Blank lines have no indent
- One blank line between functions
Naming:
- Functions:
snake_casewith prefix (zdot_or_modulename_) - Variables:
snake_case(local),UPPER_CASE(global) - Arrays:
_ZDOT_UPPER_CASE(global)
Comments:
- Document non-obvious logic
- Use docstrings for functions
- Explain "why", not "what"
Testing Changes
Before submitting changes:
- Test all contexts:
# Interactive
zsh -i -c 'zdot info'
# Non-interactive
zsh -c 'zdot info'
# Login
zsh -l -c 'zdot info'
- Test edge cases:
- Circular dependencies
- Missing dependencies
- Optional vs required
- Finally group hooks
- Test with verbose logging:
ZDOT_VERBOSE=1 zsh -c 'source ~/.zshrc'
- Verify output:
zdot hook list --all
zdot info
Documentation
When adding features:
- Update README.md (user-facing documentation)
- Update IMPLEMENTATION.md (technical details)
- Add examples
- Document new functions/flags
- Update design decisions section if relevant
Troubleshooting Guide
Hook Not Executing
Symptoms:
- Hook registered but not running
- Phase not provided
Debug Steps:
-
Check if hook is in execution plan:
print -l "${_ZDOT_EXECUTION_PLAN[@]}" | grep hook_name -
Check contexts match:
zdot_hooks_list --all # Look for your hook -
Enable verbose logging:
ZDOT_VERBOSE=1 source ~/.zshrc -
Check for dependency issues:
zdot_hooks_list # Look in error section
Common Causes:
- Context mismatch (hook declares
login, shell isnonlogin) - Missing dependency (check
--optionalflag) - Hook returns non-zero (check function implementation)
- Circular dependency (check verbose output)
Phase Not Available
Symptoms:
- Hook requires phase that doesn't exist
- Shows up in error section of
zdot_hooks_list
Debug Steps:
- Check if phase is provided by any hook:
# Key format: "context:phase-name" (e.g., "interactive:brew-ready") echo "${_ZDOT_PHASE_PROVIDERS_BY_CONTEXT[interactive:phase-name]}"
Solutions:
- Fix phase name typo
- Add module that provides the phase
Circular Dependency
Symptoms:
- Error message about circular dependency
- Hooks not executing
Debug Steps:
-
Look at error message:
✗ Circular dependency detected: _hook_a → _hook_b → _hook_a -
Trace dependency chain:
- _hook_a requires phase-b
- _hook_b provides phase-b but requires phase-a
- _hook_a provides phase-a
- Cycle: a → b → a
Solutions:
- Remove circular dependency by breaking chain
- Combine hooks into single hook
- Use intermediate phase to break cycle
Module Not Loading
Symptoms:
- Module file exists but not showing in
zdot info - Hooks not registered
Debug Steps:
-
Check file location:
ls -la ~/.dotfiles/.config/zsh/zdot/modules/mymodule/mymodule.zsh -
Check file is sourced:
ZDOT_VERBOSE=1 source ~/.zshrc | grep "Loading module: mymodule" -
Check for syntax errors:
zsh -n ~/.dotfiles/.config/zsh/zdot/modules/mymodule/mymodule.zsh
Common Causes:
- File not named
*.zsh - Syntax error in module file
- File is in wrong directory
- File has incorrect permissions
- Double-load guard returning early
Hook Naming: --name Flag and Name Registry
Overview
Hooks can be assigned human-readable name labels at registration time using the
--name flag on zdot_register_hook. Names are stored in a bidirectional
registry and are used by zdot_defer_order to express ordering constraints
without coupling to internal hook IDs.
Global Arrays
_ZDOT_HOOK_NAMES (associative): hook_id → name label
_ZDOT_HOOK_BY_NAME (associative): name label → hook_id
Both arrays are declared in core/hooks.zsh. They are populated during
zdot_register_hook and are read-only after the execution plan is built.
Registration
zdot_register_hook --name my-plugin my_plugin_init env network
--name <label>is extracted in a pre-pass before positional argument parsing. It does not affect the hook's function name or context list.- If
--nameis omitted, the function name is used as the label fallback forzdot_defer_orderlookups. - Duplicate name labels generate a warning and the second registration wins.
Internal Storage
Hook IDs are sequential integers (hook_1, hook_2, ...). The name registry
maps between these opaque IDs and the stable label strings used in ordering
declarations:
_ZDOT_HOOK_NAMES[hook_3]="my-plugin"
_ZDOT_HOOK_BY_NAME[my-plugin]="hook_3"
Deferred Hooks: --deferred Flag
Overview
The --deferred flag on zdot_register_hook marks a hook as explicitly
deferred. Deferred hooks are excluded from the main synchronous execution plan
and are instead run after shell startup completes, triggered asynchronously.
Global Array
_ZDOT_DEFERRED_HOOKS (array): hook_ids explicitly marked --deferred
Declared in core/hooks.zsh. Populated during zdot_register_hook.
Registration
zdot_register_hook --deferred my_slow_tool_init env
- The
--deferredflag is extracted in the same pre-pass as--name. - The hook_id is appended to
_ZDOT_DEFERRED_HOOKS. - The hook is not added to the main execution plan array
(
_ZDOT_EXECUTION_PLAN). It is tracked separately in_ZDOT_EXECUTION_PLAN_DEFERRED.
Interaction with Phase Providers
If a deferred hook is the sole provider of a phase that another (non-deferred) hook requires, that dependent hook is force-deferred via the fixed-point propagation mechanism described in the Force-Deferral section below.
Hook Ordering: zdot_defer_order
Overview
zdot_defer_order declares that a set of hooks must execute in a specific
order relative to one another within the deferred execution chain. It records
ordering constraints by name label; the actual DAG edges are injected when
zdot_build_execution_plan runs.
Global Arrays
_ZDOT_DEFER_ORDER_DEPENDENCIES (array): flat list of triplets [ctx from to ctx from to ...]
stride-3: (context_spec, from_name, to_name)
_ZDOT_DEFER_ORDER_WARNINGS (array): warnings generated during edge injection
Both declared in core/hooks.zsh.
Usage
zdot_defer_order name-A name-B name-C
zdot_defer_order --context interactive name-A name-B name-C
Records all pairwise (i < j) ordering constraints: A→B, A→C, B→C. This means A must complete before B, and B before C.
- Arguments are name labels (as assigned via
--name, or function names as fallback). - The optional
--context <ctx>flag restricts the ordering constraint to the given context (e.g.,interactive). When omitted, the constraint applies in all contexts. - Triplets are stored in
_ZDOT_DEFER_ORDER_DEPENDENCIESas flat stride-3 elements:(context_spec, from_name, to_name). When--contextis omitted,context_specis the empty string"". - No validation is done at call time; validation and edge injection occur inside
zdot_build_execution_plan.
Edge Injection (inside zdot_build_execution_plan)
During plan construction:
- Each triplet from
_ZDOT_DEFER_ORDER_DEPENDENCIESis read as(context_spec, from_name, to_name). - Context filtering (early skip): If
context_specis non-empty and does not intersect withcurrent_contexts, the constraint is silently skipped — it simply doesn't apply in this execution context. - Each name is resolved to a hook_id via
_ZDOT_HOOK_BY_NAME.- If a name is not found in
_ZDOT_HOOK_BY_NAMEat all, the edge is skipped with a warning (genuine error — unknown hook name). - If a name resolves to a hook_id but the hook is not active in the
current context (not in
in_degree), the edge is silently skipped (the hook exists but isn't relevant here — no warning).
- If a name is not found in
- A synthetic DAG edge is added to the deferred dependency graph.
- A cycle check is run on the synthetic-edge-only subgraph (
_doo_adj) using DFS. If adding the edge would create a cycle, the edge is skipped and a warning is appended to_ZDOT_DEFER_ORDER_WARNINGS. - Contradictory edges (A→B when B→A already exists) are also rejected.
The technique used is a bridge-phase injection: a synthetic intermediate phase is created to carry the ordering constraint through the existing topological sort machinery without altering real phase semantics.
Suppressing Force-Deferral Warnings: zdot_allow_defer
Overview
When a hook is force-deferred (because its required phase is only provided by a
deferred hook), the system emits a warning. zdot_allow_defer silences
these warnings for specific function+phase combinations where force-deferral is
expected and intentional.
Global Array
_ZDOT_ACCEPTED_DEFERRED (associative): func_name → "all" | "phase1 phase2 ..."
Declared in core/hooks.zsh. Populated by zdot_allow_defer at module
load time, before the execution plan is built.
Usage
# Accept force-deferral for all phases of a function:
zdot_allow_defer my_plugin_init
# Accept force-deferral for specific phases only:
zdot_allow_defer my_plugin_init network tools
- With no phase arguments: sets
_ZDOT_ACCEPTED_DEFERRED[func]="all". - With phase arguments: appends each phase name to the space-separated value for that function key. Multiple calls accumulate phases.
Suppression Logic
During force-deferral propagation, after a hook is force-deferred, the system
checks _ZDOT_ACCEPTED_DEFERRED:
- If value is
"all": warning is suppressed entirely for that hook. - If value contains the specific phase that triggered force-deferral: warning is suppressed for that phase.
- Otherwise: the warning is appended to
_ZDOT_FORCED_DEFERRED_WARNINGSand printed at startup.
Force-Deferral Propagation
Overview
When zdot_build_execution_plan separates deferred hooks from the main plan,
it must also identify any non-deferred hooks that cannot run synchronously
because a phase they require is only provided by a deferred hook. These hooks
are force-deferred via a fixed-point propagation loop.
Global Arrays
_ZDOT_FORCED_DEFERRED_WARNINGS (array): warning strings for unexpected force-deferrals
Declared in core/hooks.zsh.
Algorithm: Fixed-Point Propagation
After the initial deferred set is established:
- Scan all remaining (non-deferred) hooks.
- For each hook, check whether every required phase has at least one provider in the non-deferred set.
- If a required phase is only provided by a deferred hook → mark this hook as
force-deferred (
reason="forced"). - Set
changed=1and restart the scan from step 1. - Repeat until a full pass completes with
changed=0(fixed point reached).
This handles transitive chains: if hook C requires a phase provided only by hook B, and hook B gets force-deferred because it requires a phase provided only by explicit-deferred hook A, then hook C is also force-deferred on the next iteration.
Reason Classification
Each deferred hook carries a reason tag:
| Reason | Meaning |
|---|---|
"explicit" | Registered with --deferred |
"forced" | Force-deferred due to phase provider being deferred |
Tool-dependency force-deferral (via --requires-tool) is applied silently
without generating a warning entry, regardless of zdot_allow_defer.
Warnings
For each force-deferred hook where the phase+function combo is not accepted via
zdot_allow_defer, a warning string is appended to
_ZDOT_FORCED_DEFERRED_WARNINGS and printed during startup to alert the module
author that an implicit deferral occurred.
Deferred Chain Re-scanning: _zdot_run_deferred_phase_check
Overview
_zdot_run_deferred_phase_check is an internal function in core/hooks.zsh
that drives the deferred execution chain. It scans the list of outstanding
deferred hooks and executes any whose required phases have now been satisfied.
When It Is Called
| Trigger | Location |
|---|---|
After zdot_execute_all completes | End of main synchronous sequence |
| After each deferred hook completes | Inside the deferred dispatch loop |
The repeated call after each deferred hook completion allows cascading satisfaction: if hook A provides a phase that hook B requires, B becomes eligible immediately after A finishes, without waiting for a separate scan interval.
Algorithm
- Iterate
_ZDOT_EXECUTION_PLAN_DEFERRED. - For each hook that has not yet executed, check whether all required phases
are now in
_ZDOT_PROVIDED_PHASES. - If satisfied → execute the hook; mark it as done; set
changed=1. - After one full pass with
changed=1, recurse / restart to catch newly satisfied dependents. - Stop when a full pass completes with no newly-satisfied hooks.
This is the deferred-chain equivalent of the main plan's topological sort execution: it re-evaluates readiness after each completion rather than pre-computing a fixed ordering.
Deferred Queue Display: zdot_show_defer_queue
Overview
zdot_show_defer_queue is an autoloaded function (defined in
core/functions/zdot_show_defer_queue) that prints a human-readable summary
of all commands, hooks, and delays that have been recorded in the deferred
dispatch log.
Global Arrays (declared in core/plugins.zsh)
_ZDOT_DEFER_CMDS (array): command strings recorded for each deferred entry
_ZDOT_DEFER_HOOKS (array): hook_id or label for each entry
_ZDOT_DEFER_DELAYS(array): delay value (ms or descriptor) for each entry
_ZDOT_DEFER_SPECS (array): full spec string for each entry
All four arrays are parallel and index-aligned: element [i] across all
four arrays describes the same deferred dispatch event.
Recording: _zdot_defer_record
_zdot_defer_record <cmd> <delay> <spec>
Called internally whenever a deferred command or hook is scheduled. Appends one element to each of the four parallel arrays. The hook label/name is determined from context at call time.
If _ZDOT_DEFER_SKIP_RECORD=1 is set, recording is suppressed (used during
internal re-execution paths to avoid double-logging).
Display
zdot_show_defer_queue iterates the parallel arrays and formats each entry
as a table row. It is intended for diagnostic use (e.g. called from
zdot_debug or interactively) to inspect what was deferred and in what order.
For user-focused documentation and examples, see README.md.