wgctl/core/framework/flag.sh
Nuno Duque Nunes a559b73e8e feat: new flag::define syntax, flag::set_constraint
- flag::define: variadic constraint args (key:value) instead of bracket string
- flag::_parse_constraints_from_args: replaces flag::_parse_and_cache
- flag::set_constraint: Option B syntax for post-definition constraints
- choices separator: comma (choices:split,full) — no quoting needed
- guard against empty _CURRENT_COMMAND in exclusive groups lookup
- migrate all commands to new constraint syntax
- add helpful error for unknown constraint args
2026-05-31 00:16:55 +00:00

399 lines
No EOL
14 KiB
Bash

#!/usr/bin/env bash
# core/framework/flag.sh
# Optimized: pre-parsed constraints + per-command flag index
# ── Storage ────────────────────────────────────────────────────────────────
declare -gA _FLAG_REGISTRY=() # "ctx:--flag" → "type|description"
declare -gA _FLAG_INDEX=() # "ctx" → " --flag1 --flag2 ..." (space-separated)
# Pre-parsed constraints
declare -gA _FLAG_C_DEFAULT=()
declare -gA _FLAG_C_TYPE=()
declare -gA _FLAG_C_MIN=()
declare -gA _FLAG_C_MAX=()
declare -gA _FLAG_C_CHOICES=()
declare -gA _FLAG_C_REQUIRED=()
declare -gA _FLAG_C_LABEL=()
declare -gA _FLAG_C_SECTION=()
# Runtime values
declare -gA _FLAG_VALUES=()
declare -gA _FLAG_ARRAYS=()
declare -gA _FLAG_SET=()
declare -ga _FLAG_ARGS=()
# Completion registry
declare -gA _FLAG_COMPLETION=()
# ── Pure bash constraint extractor ────────────────────────────────────────
function flag::_extract_constraint() {
local constraints="$1" lookup="$2"
constraints="${constraints#[}"; constraints="${constraints%]}"
local rest="$constraints"
while [[ -n "$rest" ]]; do
local pair="${rest%%,*}"
pair="${pair# }"; pair="${pair% }"
local k="${pair%%=*}"; local v="${pair#*=}"
k="${k% }"; v="${v# }"; v="${v//\"/}"
if [[ "$k" == "$lookup" ]]; then
printf '%s' "$v"
return 0
fi
[[ "$rest" == *,* ]] && rest="${rest#*,}" || break
done
}
function flag::_parse_and_cache() {
local key="$1" constraints="$2"
[[ -z "$constraints" ]] && return 0
# log::debug "parse_and_cache: key=$key"
local v
v=$(flag::_extract_constraint "$constraints" "default")
[[ -n "$v" ]] && _FLAG_C_DEFAULT["$key"]="$v"
v=$(flag::_extract_constraint "$constraints" "type")
[[ -n "$v" ]] && _FLAG_C_TYPE["$key"]="$v"
v=$(flag::_extract_constraint "$constraints" "min")
[[ -n "$v" ]] && _FLAG_C_MIN["$key"]="$v"
v=$(flag::_extract_constraint "$constraints" "max")
[[ -n "$v" ]] && _FLAG_C_MAX["$key"]="$v"
v=$(flag::_extract_constraint "$constraints" "choices")
# log::debug "parse_and_cache: choices='$v'"
[[ -n "$v" ]] && _FLAG_C_CHOICES["$key"]="$v"
v=$(flag::_extract_constraint "$constraints" "required")
[[ "$v" == "true" ]] && _FLAG_C_REQUIRED["$key"]="true"
v=$(flag::_extract_constraint "$constraints" "label")
[[ -n "$v" ]] && _FLAG_C_LABEL["$key"]="$v"
v=$(flag::_extract_constraint "$constraints" "section")
[[ -n "$v" ]] && _FLAG_C_SECTION["$key"]="$v"
}
# ── Constraints ────────────────────────────────────────
# flag::set_constraint <flag> <key> [value]
# Set a constraint on an already-defined flag (Option B syntax)
# Examples:
# flag::set_constraint --mode choices "split|full"
# flag::set_constraint --mode required
# flag::set_constraint --mode section Options
function flag::set_constraint() {
local flag="$1" k="$2" v="${3:-}"
local ctx="${_CURRENT_COMMAND:-__global__}"
local key="${ctx}:${flag}"
[[ -z "${_FLAG_REGISTRY[$key]+x}" ]] && \
log::error "flag::set_constraint: flag not defined: ${flag}" && return 1
case "$k" in
label) _FLAG_C_LABEL["$key"]="$v" ;;
default) _FLAG_C_DEFAULT["$key"]="$v" ;;
type) _FLAG_C_TYPE["$key"]="$v" ;;
min) _FLAG_C_MIN["$key"]="$v" ;;
max) _FLAG_C_MAX["$key"]="$v" ;;
choices) _FLAG_C_CHOICES["$key"]="$v" ;;
section)
_FLAG_C_SECTION["$key"]="$v"
declare -f help::_assign_flag_from_cache &>/dev/null && \
help::_assign_flag_from_cache "$flag" "$v"
;;
required) _FLAG_C_REQUIRED["$key"]="true" ;;
esac
}
# ── Context ───────────────────────────────────────────────────────────────
function flag::_context() { printf '%s' "${_CURRENT_COMMAND:-__global__}"; }
function flag::_key() { printf '%s:%s' "${_CURRENT_COMMAND:-__global__}" "$1"; }
# ── Registration ──────────────────────────────────────────────────────────
# flag::define <flag> [bool|value] "description" [constraints...]
# flag::define <flag[]> "description" [constraints...]
#
# Constraints (positional, after description):
# label:name Display label in help
# default:50 Default value
# type:int Value type (int, string)
# min:1 Minimum value (int)
# max:100 Maximum value (int)
# choices:a|b|c Allowed values (pipe-separated)
# section:Filters Help section
# required Mark as required (no value needed)
#
# Examples:
# flag::define --fw bool "Firewall only" section:Filters
# flag::define --limit value "Max results" default:50 type:int min:1 section:Output
# flag::define --mode value "Tunnel mode" required choices:split|full section:Options
# flag::define --svc[] "Exclude service" label:service section:Filters
function flag::define() {
local raw_flag="$1"
local is_array=false
if [[ "$raw_flag" == *"[]" ]]; then
is_array=true
raw_flag="${raw_flag%[]}"
fi
local type description
local -a constraints=()
if $is_array; then
type="array"
description="${2:-}"
shift 2
constraints=("$@")
else
type="${2:-bool}"
description="${3:-}"
shift 3
constraints=("$@")
fi
local ctx="${_CURRENT_COMMAND:-__global__}"
local key="${ctx}:${raw_flag}"
_FLAG_REGISTRY["$key"]="${type}|${description}"
_FLAG_INDEX["$ctx"]+=" ${raw_flag}"
# Parse constraints
flag::_parse_constraints_from_args "$key" "${constraints[@]:-}"
# Bool default
if [[ "$type" == "bool" && -z "${_FLAG_C_DEFAULT[$key]:-}" ]]; then
_FLAG_C_DEFAULT["$key"]="false"
fi
# Help section — constraint takes precedence, then active section
local section="${_FLAG_C_SECTION[$key]:-${_CURRENT_HELP_SECTION:-}}"
if [[ -n "$section" ]]; then
_FLAG_C_SECTION["$key"]="$section"
declare -f help::_assign_flag_from_cache &>/dev/null && \
help::_assign_flag_from_cache "$raw_flag" "$section"
fi
_FLAG_COMPLETION["$raw_flag"]=1
}
function flag::register() { _FLAG_COMPLETION["${1:-}"]=1; }
# ── Parsing ───────────────────────────────────────────────────────────────
function flag::parse() {
local ctx="${_CURRENT_COMMAND:-__global__}"
# log::debug "ctx=$ctx index='${_FLAG_INDEX[$ctx]:-EMPTY}'" >&2
# Reset runtime
_FLAG_VALUES=(); _FLAG_ARRAYS=(); _FLAG_SET=(); _FLAG_ARGS=()
# Check for --help/-h first (fast path)
local arg
for arg in "$@"; do
if [[ "$arg" == "--help" || "$arg" == "-h" ]]; then
local cmd="${_CURRENT_COMMAND%%::*}"
declare -f hook::fire &>/dev/null && \
hook::fire "command:help:${cmd}" "$cmd" "${_CURRENT_COMMAND##*::}"
return 1
fi
done
# Initialize from per-command index (fast — no full registry scan)
local flag key type default_val
for flag in ${_FLAG_INDEX[$ctx]:-}; do
key="${ctx}:${flag}"
[[ -z "${_FLAG_REGISTRY[$key]+x}" ]] && continue
type="${_FLAG_REGISTRY[$key]%%|*}"
default_val="${_FLAG_C_DEFAULT[$key]:-}"
case "$type" in
bool) _FLAG_VALUES["$flag"]="${default_val:-false}" ;;
value) [[ -n "$default_val" ]] && _FLAG_VALUES["$flag"]="$default_val" ;;
array) _FLAG_ARRAYS["$flag"]="" ;;
esac
done
# Parse args
while [[ $# -gt 0 ]]; do
arg="$1"
if [[ "$arg" == "--" ]]; then
shift; _FLAG_ARGS+=("$@"); break
fi
if [[ "$arg" != --* ]]; then
_FLAG_ARGS+=("$arg"); shift; continue
fi
key="${ctx}:${arg}"
if [[ -z "${_FLAG_REGISTRY[$key]+x}" ]]; then
log::error "Unknown flag: ${arg}"; return 1
fi
type="${_FLAG_REGISTRY[$key]%%|*}"
case "$type" in
bool)
_FLAG_VALUES["$arg"]="true"
_FLAG_SET["$arg"]="1"
shift
;;
value)
if [[ $# -lt 2 || "$2" == --* ]]; then
log::error "Flag ${arg} requires a value"; return 1
fi
local val="$2"
local vtype="${_FLAG_C_TYPE[$key]:-}"
if [[ "$vtype" == "int" ]]; then
if ! [[ "$val" =~ ^[0-9]+$ ]]; then
log::error "Flag ${arg} requires an integer, got: ${val}"; return 1
fi
local min="${_FLAG_C_MIN[$key]:-}" max="${_FLAG_C_MAX[$key]:-}"
[[ -n "$min" && "$val" -lt "$min" ]] && \
log::error "Flag ${arg} minimum is ${min}, got: ${val}" && return 1
[[ -n "$max" && "$val" -gt "$max" ]] && \
log::error "Flag ${arg} maximum is ${max}, got: ${val}" && return 1
fi
local choices="${_FLAG_C_CHOICES[$key]:-}"
# log::debug "choices check: flag=$arg val=$val choices='${_FLAG_C_CHOICES[$key]:-EMPTY}'"
if [[ -n "$choices" ]]; then
local valid=false
local choice
local saved_ifs="$IFS"
IFS=',' read -ra choice_list <<< "$choices"
IFS="$saved_ifs"
for choice in "${choice_list[@]}"; do
[[ "$val" == "$choice" ]] && valid=true && break
done
if ! $valid; then
log::error "Flag ${arg} must be one of: ${choices//|/, }, got: ${val}"
return 1
fi
fi
_FLAG_VALUES["$arg"]="$val"
_FLAG_SET["$arg"]="1"
shift 2
;;
array)
if [[ $# -lt 2 || "$2" == --* ]]; then
log::error "Flag ${arg} requires a value"; return 1
fi
if [[ -n "${_FLAG_ARRAYS[$arg]:-}" ]]; then
_FLAG_ARRAYS["$arg"]+=$'\n'"$2"
else
_FLAG_ARRAYS["$arg"]="$2"
fi
_FLAG_SET["$arg"]="1"
shift 2
;;
esac
done
# Validate required
for flag in ${_FLAG_INDEX[$ctx]:-}; do
key="${ctx}:${flag}"
[[ -z "${_FLAG_C_REQUIRED[$key]:-}" ]] && continue
if [[ -z "${_FLAG_SET[$flag]+x}" && -z "${_FLAG_VALUES[$flag]:-}" ]]; then
log::error "Flag ${flag} is required"; return 1
fi
done
# Validate exclusive groups
local cmd="${_CURRENT_COMMAND%%::*}"
local groups=""
[[ -n "$cmd" ]] && groups="${_FLAG_EXCLUSIVE_GROUPS[$cmd]:-}"
if [[ -n "$groups" ]]; then
local group
while IFS= read -r group; do
[[ -z "$group" ]] && continue
local -a members=()
local OLD_IFS="$IFS"; IFS=','; read -ra members <<< "$group"; IFS="$OLD_IFS"
local found_count=0 found_flags="" member
for member in "${members[@]}"; do
flag::set "$member" && (( found_count++ )) && found_flags+=" $member"
done
if [[ $found_count -gt 1 ]]; then
log::error "Flags${found_flags} are mutually exclusive"; return 1
fi
done < <(printf '%s' "$groups" | tr '|' '\n')
fi
return 0
}
# flag::_parse_constraints_from_args <key> <constraint_args...>
# Parse variadic constraint args: label:x required choices:a|b section:x
function flag::_parse_constraints_from_args() {
local key="$1"
shift
local arg k v
for arg in "$@"; do
# Handle bare keywords (no colon) — e.g. "required"
if [[ "$arg" != *:* ]]; then
case "$arg" in
required) _FLAG_C_REQUIRED["$key"]="true" ;;
optional) ;; # default, no-op
esac
continue
fi
k="${arg%%:*}"
v="${arg#*:}"
case "$k" in
label) _FLAG_C_LABEL["$key"]="$v" ;;
default) _FLAG_C_DEFAULT["$key"]="$v" ;;
type) _FLAG_C_TYPE["$key"]="$v" ;;
min) _FLAG_C_MIN["$key"]="$v" ;;
max) _FLAG_C_MAX["$key"]="$v" ;;
choices) _FLAG_C_CHOICES["$key"]="$v" ;;
section) _FLAG_C_SECTION["$key"]="$v" ;;
required) _FLAG_C_REQUIRED["$key"]="$v" ;;
esac
done
}
# ── Accessors ─────────────────────────────────────────────────────────────
function flag::bool() { [[ "${_FLAG_VALUES[$1]:-false}" == "true" ]]; }
function flag::value() { printf '%s' "${_FLAG_VALUES[$1]:-}"; }
function flag::array() { printf '%s' "${_FLAG_ARRAYS[$1]:-}"; }
function flag::set() { [[ -n "${_FLAG_SET[$1]+x}" ]]; }
function flag::args() { printf '%s ' "${_FLAG_ARGS[@]:-}"; }
function flag::args_array() {
local -n _arr_ref="$1"
_arr_ref=("${_FLAG_ARGS[@]:-}")
}
# ── Reset ─────────────────────────────────────────────────────────────────
function flag::_reset_runtime() {
_FLAG_VALUES=(); _FLAG_ARRAYS=(); _FLAG_SET=(); _FLAG_ARGS=()
}
function flag::_reset_registry() {
local ctx="${1:-$(flag::_context)}"
local key
for key in "${!_FLAG_REGISTRY[@]}"; do
[[ "$key" == "${ctx}:"* ]] || continue
unset "_FLAG_REGISTRY[$key]" "_FLAG_C_DEFAULT[$key]" "_FLAG_C_TYPE[$key]"
unset "_FLAG_C_MIN[$key]" "_FLAG_C_MAX[$key]" "_FLAG_C_CHOICES[$key]"
unset "_FLAG_C_REQUIRED[$key]" "_FLAG_C_LABEL[$key]" "_FLAG_C_SECTION[$key]"
done
unset "_FLAG_INDEX[$ctx]"
}
# ── Help bridge ───────────────────────────────────────────────────────────
function help::_assign_flag_from_cache() {
local flag="$1" section="$2"
local ctx="${_CURRENT_COMMAND:-__global__}"
[[ -z "$section" ]] && section="${_CURRENT_HELP_SECTION:-}"
[[ -z "$section" ]] && return 0
local skey="${ctx}:${section}"
if [[ -z "${_HELP_SECTIONS[$skey]+x}" ]]; then
_HELP_SECTIONS["$skey"]=$(( _HELP_SECTION_COUNT++ ))
fi
_HELP_FLAG_SECTION["${ctx}:${flag}"]="$section"
}