Skip to main content

wyvern/extensions/
match_logic.rs

1//! Argv match kinds and help-prefix matching for CLI extensions.
2
3use std::path::Path;
4
5use super::{ExtensionDef, ExtensionRegistry, MatchSpec, MatchToken};
6
7/// Successful argv match against one extension.
8#[derive(Debug, Clone)]
9pub enum ExtensionMatch<'a> {
10    /// Single positional suffix or exact filename.
11    Suffix {
12        /// Matched extension.
13        ext: &'a ExtensionDef,
14        /// Matched file path token.
15        path: &'a str,
16    },
17    /// Prefix-only (for example `compose render --root …`).
18    Prefix {
19        /// Matched extension.
20        ext: &'a ExtensionDef,
21        /// Tokens after the prefix.
22        args_after_prefix: &'a [String],
23    },
24    /// Prefix plus a suffix-matching path token.
25    PrefixSuffix {
26        /// Matched extension.
27        ext: &'a ExtensionDef,
28        /// Matched file path token.
29        path: &'a str,
30        /// Tokens after the prefix (includes the path).
31        args_after_prefix: &'a [String],
32    },
33}
34
35impl<'a> ExtensionMatch<'a> {
36    /// Extension that matched.
37    #[must_use]
38    pub fn extension(&self) -> &'a ExtensionDef {
39        match self {
40            Self::Suffix { ext, .. }
41            | Self::Prefix { ext, .. }
42            | Self::PrefixSuffix { ext, .. } => ext,
43        }
44    }
45
46    /// Matched file path, if this match kind has one.
47    #[must_use]
48    pub fn path(&self) -> Option<&'a str> {
49        match self {
50            Self::Suffix { path, .. } | Self::PrefixSuffix { path, .. } => Some(*path),
51            Self::Prefix { .. } => None,
52        }
53    }
54
55    /// Tokens after an argv prefix (empty for suffix-only matches).
56    #[must_use]
57    pub fn args_after_prefix(&self) -> &'a [String] {
58        match self {
59            Self::Prefix {
60                args_after_prefix, ..
61            }
62            | Self::PrefixSuffix {
63                args_after_prefix, ..
64            } => args_after_prefix,
65            Self::Suffix { .. } => &[],
66        }
67    }
68}
69
70impl ExtensionDef {
71    pub(super) fn match_spec_argv<'a>(&'a self, argv: &'a [String]) -> Option<ExtensionMatch<'a>> {
72        let spec = &self.match_spec;
73        if let Some(prefix) = &spec.argv_prefix {
74            if argv.len() < prefix.len()
75                || !prefix
76                    .iter()
77                    .zip(argv.iter())
78                    .all(|(expected, got)| expected.as_str() == got)
79            {
80                return None;
81            }
82            let rest = &argv[prefix.len()..];
83            if let Some(suffix) = &spec.arg_suffix {
84                let path = rest
85                    .iter()
86                    .find(|token| ends_with_suffix(token, suffix.as_str()))?;
87                return Some(ExtensionMatch::PrefixSuffix {
88                    ext: self,
89                    path: path.as_str(),
90                    args_after_prefix: rest,
91                });
92            }
93            return Some(ExtensionMatch::Prefix {
94                ext: self,
95                args_after_prefix: rest,
96            });
97        }
98        if argv.len() != 1 {
99            return None;
100        }
101        let token = argv[0].as_str();
102        if let Some(filename) = &spec.filename {
103            let base = Path::new(token).file_name()?.to_str()?;
104            if base == filename.as_str() {
105                return Some(ExtensionMatch::Suffix {
106                    ext: self,
107                    path: token,
108                });
109            }
110            return None;
111        }
112        if let Some(suffix) = &spec.positional_suffix {
113            if ends_with_suffix(token, suffix.as_str()) {
114                return Some(ExtensionMatch::Suffix {
115                    ext: self,
116                    path: token,
117                });
118            }
119        }
120        None
121    }
122}
123
124pub(crate) fn ends_with_suffix(token: &str, suffix: &str) -> bool {
125    let Some(start) = token.len().checked_sub(suffix.len()) else {
126        return false;
127    };
128    // `str::get` is `None` when `start` is not a UTF-8 char boundary, so a
129    // multi-byte token cannot panic the way a raw byte slice would.
130    token
131        .get(start..)
132        .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix))
133}
134
135/// Match an extension whose remaining tokens are only `--help` / `-h`.
136///
137/// Prefix skills match at the argv prefix (ignores `requires` and `arg_suffix`).
138/// Suffix / filename skills match `path --help` so agents are not told the
139/// path is unknown input. Longest prefix wins when two prefixes match.
140#[must_use]
141pub fn match_extension_help<'a>(
142    registry: &'a ExtensionRegistry,
143    argv: &'a [String],
144) -> Option<&'a ExtensionDef> {
145    if let Some(ext) = match_prefix_help(registry, argv) {
146        return Some(ext);
147    }
148    match_suffix_help(registry, argv)
149}
150
151fn match_prefix_help<'a>(
152    registry: &'a ExtensionRegistry,
153    argv: &'a [String],
154) -> Option<&'a ExtensionDef> {
155    let mut best: Option<(&'a ExtensionDef, usize)> = None;
156    for ext in registry.extensions() {
157        let Some(prefix) = &ext.match_spec.argv_prefix else {
158            continue;
159        };
160        if prefix.is_empty() || argv.len() < prefix.len() {
161            continue;
162        }
163        if !prefix
164            .iter()
165            .zip(argv.iter())
166            .all(|(expected, got)| expected.as_str() == got)
167        {
168            continue;
169        }
170        if !is_help_only_tokens(&argv[prefix.len()..]) {
171            continue;
172        }
173        let len = prefix.len();
174        if best.is_none_or(|(_, best_len)| len > best_len) {
175            best = Some((ext, len));
176        }
177    }
178    best.map(|(ext, _)| ext)
179}
180
181fn match_suffix_help<'a>(
182    registry: &'a ExtensionRegistry,
183    argv: &'a [String],
184) -> Option<&'a ExtensionDef> {
185    if argv.len() < 2 || !is_help_only_tokens(&argv[1..]) {
186        return None;
187    }
188    let path = argv[0].as_str();
189    registry
190        .extensions()
191        .iter()
192        .find(|ext| ext.match_spec.argv_prefix.is_none() && matches_suffix_or_filename(ext, path))
193}
194
195fn matches_suffix_or_filename(ext: &ExtensionDef, token: &str) -> bool {
196    let spec = &ext.match_spec;
197    if let Some(filename) = &spec.filename {
198        return Path::new(token)
199            .file_name()
200            .and_then(|name| name.to_str())
201            .is_some_and(|base| base == filename.as_str());
202    }
203    spec.positional_suffix
204        .as_ref()
205        .is_some_and(|suffix| ends_with_suffix(token, suffix.as_str()))
206}
207
208/// Returns whether every token is `--help` or `-h` (and at least one is present).
209#[must_use]
210pub fn is_help_only_tokens(tokens: &[String]) -> bool {
211    !tokens.is_empty()
212        && tokens
213            .iter()
214            .all(|token| token == "--help" || token == "-h")
215}
216
217#[must_use]
218pub fn match_kind_summary(spec: &MatchSpec) -> String {
219    if let Some(prefix) = &spec.argv_prefix {
220        let prefix_s = prefix
221            .iter()
222            .map(MatchToken::as_str)
223            .collect::<Vec<_>>()
224            .join(" ");
225        if let Some(suffix) = &spec.arg_suffix {
226            return format!("prefix+suffix: {prefix_s} {suffix}");
227        }
228        return format!("prefix: {prefix_s}");
229    }
230    if let Some(filename) = &spec.filename {
231        return format!("filename: {filename}");
232    }
233    if let Some(suffix) = &spec.positional_suffix {
234        return format!("suffix: {suffix}");
235    }
236    "match: (none)".to_string()
237}