diff --git a/dsc/locales/en-us.toml b/dsc/locales/en-us.toml index 49e9b50dd..ca6ca852d 100644 --- a/dsc/locales/en-us.toml +++ b/dsc/locales/en-us.toml @@ -34,6 +34,8 @@ getAll = "Get all instances of the resource" resource = "The name of the resource to invoke" functionAbout = "Operations on DSC functions" listFunctionAbout = "List or find functions" +functionCategory = "Category to filter functions on; specify multiple times to require all categories" +functionDescription = "Description to search for in the function description, accepts wildcards" version = "The version of the resource to invoke in semver format" serverAbout = "Use DSC as a server over JSON-RPC (useful as MCP server)" bicepAbout = "Use DSC as a Bicep server over gRPC" @@ -152,6 +154,7 @@ tableHeader_functionName = "Function" tableHeader_functionCategory = "Category" tableHeader_syntax = "Syntax" invalidFunctionFilter = "Invalid function filter" +invalidFunctionDescriptionFilter = "Invalid function description filter" maxInt = "maxInt" invalidManifest = "Error in manifest for" jsonArrayNotSupported = "JSON array output format is only supported for `--all'" diff --git a/dsc/src/args.rs b/dsc/src/args.rs index 4d4622287..3108c6317 100644 --- a/dsc/src/args.rs +++ b/dsc/src/args.rs @@ -4,6 +4,7 @@ use clap::{Parser, Subcommand, ValueEnum}; use clap_complete::Shell; use dsc_lib::dscresources::command_resource::TraceLevel; +use dsc_lib::functions::FunctionCategory; use dsc_lib::progress::ProgressFormat; use dsc_lib::types::{FullyQualifiedTypeName, ResourceVersionReq, TypeNameFilter}; use rust_i18n::t; @@ -193,6 +194,10 @@ pub enum FunctionSubCommand { List { /// Optional function name to filter the list function_name: Option, + #[clap(short = 'c', long = "category", help = t!("args.functionCategory").to_string())] + category: Vec, + #[clap(short, long, help = t!("args.functionDescription").to_string())] + description: Option, #[clap(short = 'o', long, help = t!("args.outputFormat").to_string())] output_format: Option, }, diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs index 1c86d9ae5..180346e9a 100644 --- a/dsc/src/subcommand.rs +++ b/dsc/src/subcommand.rs @@ -28,7 +28,7 @@ use dsc_lib::{ }, dscresources::dscresource::{Capability, ImplementedAs, validate_json, validate_properties}, extensions::dscextension::Capability as ExtensionCapability, - functions::FunctionDispatcher, + functions::{FunctionCategory, FunctionDispatcher}, progress::ProgressFormat, util::convert_wildcard_to_regex, }; @@ -541,8 +541,8 @@ pub fn extension(subcommand: &ExtensionSubCommand, progress_format: ProgressForm pub fn function(subcommand: &FunctionSubCommand) { let functions = FunctionDispatcher::new(); match subcommand { - FunctionSubCommand::List { function_name, output_format } => { - list_functions(&functions, function_name.as_ref(), output_format.as_ref()); + FunctionSubCommand::List { function_name, category, description, output_format } => { + list_functions(&functions, function_name.as_ref(), category, description.as_ref(), output_format.as_ref()); }, } } @@ -689,7 +689,7 @@ fn list_extensions(dsc: &mut DscManager, extension_name: &TypeNameFilter, format } } -fn list_functions(functions: &FunctionDispatcher, function_name: Option<&String>, output_format: Option<&ListOutputFormat>) { +fn list_functions(functions: &FunctionDispatcher, function_name: Option<&String>, category: &[FunctionCategory], description: Option<&String>, output_format: Option<&ListOutputFormat>) { let write_table = should_write_table(output_format); let mut table = Table::new(&[ t!("subcommand.tableHeader_functionCategory").to_string().as_ref(), @@ -709,6 +709,19 @@ fn list_functions(functions: &FunctionDispatcher, function_name: Option<&String> exit(EXIT_INVALID_ARGS); }; + let description_regex = description.map(|description| { + let regex_str = convert_wildcard_to_regex(description); + // strip the `^` and `$` anchors so the filter searches within the description text + let regex_str = ®ex_str[1..regex_str.len() - 1]; + let mut regex_builder = RegexBuilder::new(regex_str); + regex_builder.case_insensitive(true); + let Ok(regex) = regex_builder.build() else { + error!("{}: {}", t!("subcommand.invalidFunctionDescriptionFilter"), regex_str); + exit(EXIT_INVALID_ARGS); + }; + regex + }); + let mut functions_list = functions.list(); functions_list.sort(); for function in functions_list { @@ -716,6 +729,16 @@ fn list_functions(functions: &FunctionDispatcher, function_name: Option<&String> continue; } + // all specified categories must be present on the function (AND filter) + if !category.iter().all(|c| function.category.contains(c)) { + continue; + } + + if let Some(ref description_regex) = description_regex + && !description_regex.is_match(&function.description) { + continue; + } + if write_table { table.add_row(vec![ function.category.iter().map(std::string::ToString::to_string).collect::>().join(", "), diff --git a/dsc/tests/dsc_function_list.tests.ps1 b/dsc/tests/dsc_function_list.tests.ps1 index f3b8b9ae7..43bf1d7c1 100644 --- a/dsc/tests/dsc_function_list.tests.ps1 +++ b/dsc/tests/dsc_function_list.tests.ps1 @@ -42,4 +42,75 @@ Describe 'Tests for function list subcommand' { } $foundWideLine | Should -BeTrue } + + Context 'Category filter' { + It 'Should filter by a single category' { + $out = dsc function list --category resource | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out | Should -Not -BeNullOrEmpty + foreach ($function in $out) { + $function.category | Should -Contain 'resource' + } + $out.name | Should -Contain 'resourceId' + } + + It 'Should treat multiple categories as an AND filter' { + $out = dsc function list --category array --category object | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out | Should -Not -BeNullOrEmpty + foreach ($function in $out) { + $function.category | Should -Contain 'array' + $function.category | Should -Contain 'object' + } + $out.name | Should -Contain 'tryGet' + } + + It 'Should match category case-insensitively' { + $out = dsc function list --category Array | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out | Should -Not -BeNullOrEmpty + foreach ($function in $out) { + $function.category | Should -Contain 'array' + } + } + + It 'Should error on an invalid category' { + $out = dsc function list --category notARealCategory 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + ($out | Out-String) | Should -Match 'Invalid function category' + } + } + + Context 'Description filter' { + It 'Should filter on description text' { + $out = dsc function list --description base64 | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out | Should -Not -BeNullOrEmpty + foreach ($function in $out) { + $function.description | Should -Match 'base64' + } + $out.name | Should -Contain 'base64' + $out.name | Should -Contain 'base64ToString' + } + + It 'Should support wildcards in the description filter' { + $out = dsc function list --description 'encodes*base64' | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.name | Should -Contain 'base64' + } + + It 'Should return nothing when the description does not match' { + $out = dsc function list --description 'zzzNoSuchDescriptionzzz' | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out | Should -BeNullOrEmpty + } + } + + Context 'Combined filters' { + It 'Should combine name, category, and description filters' { + $out = dsc function list 'try*' --category array --category object --description 'retrieve' | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.name | Should -BeExactly 'tryGet' + } + } } diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 968c2c674..0bd57fe9e 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -284,6 +284,7 @@ noObjectArgs = "Function '%{name}' does not accept object arguments, accepted ty noStringArgs = "Function '%{name}' does not accept string arguments, accepted types are: %{accepted_args_string}" lambdaNotFound = "Function '%{name}' could not find lambda with ID '%{id}'" lambdaTooManyParams = "Function '%{name}' requires lambda with 1 or 2 parameters (element and optional index)" +invalidCategory = "Invalid function category '%{category}', valid categories are: %{valid_categories}" [functions.add] description = "Adds two or more numbers together" diff --git a/lib/dsc-lib/src/functions/mod.rs b/lib/dsc-lib/src/functions/mod.rs index 344c421e2..37c8f8e5a 100644 --- a/lib/dsc-lib/src/functions/mod.rs +++ b/lib/dsc-lib/src/functions/mod.rs @@ -428,3 +428,46 @@ impl Display for FunctionCategory { } } } + +impl FunctionCategory { + /// All defined function categories. + pub const ALL: [FunctionCategory; 12] = [ + FunctionCategory::Array, + FunctionCategory::Cidr, + FunctionCategory::Comparison, + FunctionCategory::Date, + FunctionCategory::Deployment, + FunctionCategory::Lambda, + FunctionCategory::Logical, + FunctionCategory::Numeric, + FunctionCategory::Object, + FunctionCategory::Resource, + FunctionCategory::String, + FunctionCategory::System, + ]; +} + +impl std::str::FromStr for FunctionCategory { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "array" => Ok(FunctionCategory::Array), + "cidr" => Ok(FunctionCategory::Cidr), + "comparison" => Ok(FunctionCategory::Comparison), + "date" => Ok(FunctionCategory::Date), + "deployment" => Ok(FunctionCategory::Deployment), + "lambda" => Ok(FunctionCategory::Lambda), + "logical" => Ok(FunctionCategory::Logical), + "numeric" => Ok(FunctionCategory::Numeric), + "object" => Ok(FunctionCategory::Object), + "resource" => Ok(FunctionCategory::Resource), + "string" => Ok(FunctionCategory::String), + "system" => Ok(FunctionCategory::System), + _ => { + let valid = Self::ALL.iter().map(std::string::ToString::to_string).collect::>().join(", "); + Err(t!("functions.invalidCategory", category = s, valid_categories = valid).to_string()) + }, + } + } +} diff --git a/lib/dsc-lib/src/util.rs b/lib/dsc-lib/src/util.rs index ba68e031c..56807111e 100644 --- a/lib/dsc-lib/src/util.rs +++ b/lib/dsc-lib/src/util.rs @@ -72,8 +72,15 @@ pub fn parse_input_to_json(value: &str) -> Result { /// A string that holds the regex pattern. #[must_use] pub fn convert_wildcard_to_regex(wildcard: &str) -> String { - let mut regex = wildcard.to_string().replace('.', "\\.").replace('?', ".").replace('*', ".*?"); - regex.insert(0, '^'); + let mut regex = String::with_capacity(wildcard.len() + 2); + regex.push('^'); + for c in wildcard.chars() { + match c { + '*' => regex.push_str(".*?"), + '?' => regex.push('.'), + _ => regex.push_str(®ex::escape(&c.to_string())), + } + } regex.push('$'); regex } @@ -95,6 +102,10 @@ mod tests { let wildcard = "r*"; let regex = convert_wildcard_to_regex(wildcard); assert_eq!(regex, "^r.*?$"); + + let wildcard = "a+b(c)[d]^e$f|g\\h"; + let regex = convert_wildcard_to_regex(wildcard); + assert_eq!(regex, r"^a\+b\(c\)\[d\]\^e\$f\|g\\h$"); } }