From 206ef3a16c7119b23a8250da90260b4a60af9084 Mon Sep 17 00:00:00 2001 From: nerodesu017 <46645625+nerodesu017@users.noreply.github.com> Date: Tue, 26 Aug 2025 18:32:07 +0300 Subject: [PATCH] auto param extractor mvp --- .github/workflows/test.yml | 21 + Cargo.toml | 7 +- src/commands/bundle.rs | 70 ++ src/commands/bundle/param_extractor.rs | 899 ++++++++++++++++++++++ src/lib.rs | 5 + tests/mod.rs | 1 + tests/params_extraction/mod.rs | 10 + tests/params_extraction/t1/flow.luau | 4 + tests/params_extraction/t1/mod.rs | 28 + tests/params_extraction/t10/flow.luau | 19 + tests/params_extraction/t10/mod.rs | 34 + tests/params_extraction/t2/flow.luau | 15 + tests/params_extraction/t2/mod.rs | 28 + tests/params_extraction/t3/flow.luau | 12 + tests/params_extraction/t3/mod.rs | 28 + tests/params_extraction/t3/typestuff.luau | 6 + tests/params_extraction/t4/flow.luau | 9 + tests/params_extraction/t4/mod.rs | 22 + tests/params_extraction/t5/flow.luau | 14 + tests/params_extraction/t5/mod.rs | 29 + tests/params_extraction/t6/flow.luau | 6 + tests/params_extraction/t6/mod.rs | 22 + tests/params_extraction/t7/flow.luau | 6 + tests/params_extraction/t7/mod.rs | 28 + tests/params_extraction/t7/typestuff.luau | 10 + tests/params_extraction/t8/flow.luau | 21 + tests/params_extraction/t8/mod.rs | 88 +++ tests/params_extraction/t9/flow.luau | 19 + tests/params_extraction/t9/mod.rs | 34 + 29 files changed, 1494 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 src/commands/bundle/param_extractor.rs create mode 100644 src/lib.rs create mode 100644 tests/mod.rs create mode 100644 tests/params_extraction/mod.rs create mode 100644 tests/params_extraction/t1/flow.luau create mode 100644 tests/params_extraction/t1/mod.rs create mode 100644 tests/params_extraction/t10/flow.luau create mode 100644 tests/params_extraction/t10/mod.rs create mode 100644 tests/params_extraction/t2/flow.luau create mode 100644 tests/params_extraction/t2/mod.rs create mode 100644 tests/params_extraction/t3/flow.luau create mode 100644 tests/params_extraction/t3/mod.rs create mode 100644 tests/params_extraction/t3/typestuff.luau create mode 100644 tests/params_extraction/t4/flow.luau create mode 100644 tests/params_extraction/t4/mod.rs create mode 100644 tests/params_extraction/t5/flow.luau create mode 100644 tests/params_extraction/t5/mod.rs create mode 100644 tests/params_extraction/t6/flow.luau create mode 100644 tests/params_extraction/t6/mod.rs create mode 100644 tests/params_extraction/t7/flow.luau create mode 100644 tests/params_extraction/t7/mod.rs create mode 100644 tests/params_extraction/t7/typestuff.luau create mode 100644 tests/params_extraction/t8/flow.luau create mode 100644 tests/params_extraction/t8/mod.rs create mode 100644 tests/params_extraction/t9/flow.luau create mode 100644 tests/params_extraction/t9/mod.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..be8b61b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,21 @@ +name: Tests + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Run tests + run: cargo test diff --git a/Cargo.toml b/Cargo.toml index abee90a..a9df601 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,13 +5,17 @@ edition = "2021" authors = ["OpacityLabs"] description = "A CLI tool for bundling and analyzing Luau files" +[lib] +name = "opacity_cli" +path = "src/lib.rs" + [dependencies] anyhow = "1.0.95" clap = { version = "4.5.1", features = ["derive", "cargo"] } serde = { version = "1.0", features = ["derive"] } toml = "0.8.20" which = "7.0.2" -darklua = "0.16.0" +darklua = "0.17.1" serde_derive = "1.0.217" clap_complete = "4.5.46" axum = "0.8.3" @@ -25,3 +29,4 @@ chrono = "0.4.40" tracing-subscriber = "0.3.19" notify = "6.1.1" sha2 = "0.10.9" +toml_edit = "0.23.4" diff --git a/src/commands/bundle.rs b/src/commands/bundle.rs index 6814dc8..aff2d7a 100644 --- a/src/commands/bundle.rs +++ b/src/commands/bundle.rs @@ -11,6 +11,8 @@ use std::path::PathBuf; use std::time::Instant; use tracing::info; +pub mod param_extractor; + fn get_global_inject_rules(platform: &Platform, flow: &Flow) -> Vec> { let mut rules: Vec> = vec![ Box::new(InjectGlobalValue::string("FLOW_NAME", flow.name.clone())), @@ -72,10 +74,43 @@ fn compute_hashes(file_paths: &mut Vec) -> Result Ok(hashes) } +fn serialize_flows_with_param_variants_to_toml( + flows_with_params: &Vec<(String, Vec)>, +) -> String { + use toml_edit::*; + let mut doc = DocumentMut::new(); + + let flows_with_params = flows_with_params + .iter() + .map(|(alias, param_variants)| { + let mut table = Table::new(); + table.insert("alias", alias.clone().into()); + + let params = param_variants + .iter() + .map(|param_variant| { + param_variant + .iter() + .map(|param| param.to_toml_table().into_inline_table()) + .collect::() + }) + .collect::(); + + table.insert("params", params.into()); + table + }) + .collect::(); + + doc.insert("flows_with_params", flows_with_params.into()); + doc.to_string() +} + pub fn bundle(config_path: &str, is_rebundle: bool) -> Result<()> { let config = config::Config::from_file(config_path)?; let resources = Resources::from_file_system(); + let mut flows_with_params: Vec<(String, Vec)> = Vec::new(); + std::fs::create_dir_all(&config.settings.output_directory)?; let mut file_paths: Vec = Vec::new(); @@ -110,6 +145,25 @@ pub fn bundle(config_path: &str, is_rebundle: bool) -> Result<()> { .with_configuration(config); process_bundle(&resources, options)?; + + let parent_path_as_cwd = std::fs::canonicalize(config_path) + .ok() + .and_then(|abs_path| { + abs_path + .parent() + .map(|p| p.to_str().map(|s| s.to_owned())) + .flatten() + }) + .map(|s| s.to_string()); + + flows_with_params.push(( + flow.alias.clone(), + param_extractor::extract_params( + &std::fs::read_to_string(&flow.path)?, + &flow.path, + parent_path_as_cwd, + )?, + )); } } @@ -126,6 +180,22 @@ pub fn bundle(config_path: &str, is_rebundle: bool) -> Result<()> { .join("\n"), )?; + std::fs::write( + config_path_dir_buf.join("flows_with_params.toml"), + serialize_flows_with_param_variants_to_toml( + &flows_with_params + .iter() + .filter_map(|(alias, param_variants)| { + if param_variants.is_empty() { + None + } else { + Some((alias.clone(), param_variants.clone())) + } + }) + .collect::)>>(), + ), + )?; + if is_rebundle { info!("Rebundled all flows successfully"); } else { diff --git a/src/commands/bundle/param_extractor.rs b/src/commands/bundle/param_extractor.rs new file mode 100644 index 0000000..63ca8eb --- /dev/null +++ b/src/commands/bundle/param_extractor.rs @@ -0,0 +1,899 @@ +use std::{ + collections::HashMap, + env, + path::{Path, PathBuf}, +}; + +use darklua_core::{ + nodes::{ + Block, FunctionStatement, Statement, TableEntryType, TableType, + TriviaKind, Type, TypeDeclarationStatement, + }, + process::NodeProcessor, + Parser, +}; +use serde::{Deserialize, Serialize}; + +type ParamType = String; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct Param { + pub name: String, + pub description: String, + // we keep it as a String for now + // maybe we move to a String | Table later :) + pub ty: ParamType, + pub required: bool, +} + +impl Param { + pub fn to_toml_table(&self) -> toml_edit::Table { + let mut table = toml_edit::Table::new(); + table.insert("name", self.name.clone().into()); + table.insert("description", self.description.clone().into()); + table.insert("ty", self.ty.clone().into()); + table.insert("required", self.required.into()); + table + } +} + +pub type ParamVariant = Vec; + +#[derive(Debug, PartialEq, Eq)] +pub struct Module { + pub local_types: HashMap, + pub source_code: String, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ModuleEnum { + Resolved(Module), + NotYetResolved, +} + +type ModulePath = String; + +#[derive(Default, Debug, PartialEq, Eq)] +pub struct Context { + cwd: Option, + are_we_in_main_block: bool, + errors: Vec, + params: Vec, + main_function: Option, + /// HashMap that keeps track of the file's path to the module enum + modules: HashMap, + name_to_module_path: HashMap, + main_module_types: HashMap, + main_module_path: String, + /// Funny thing: this is "" (empty) for the main module + current_module_path: String, + main_module_source_code: String, +} + +pub struct ParamExtractorVisitor(pub Context); + +impl ParamExtractorVisitor { + pub fn new(cwd: Option) -> Self { + Self(Context { + are_we_in_main_block: true, + cwd, + ..Default::default() + }) + } + + fn get_string_comment(&self, token: &darklua_core::nodes::Token) -> String { + token + .iter_leading_trivia() + .filter(|t| matches!(t.kind(), TriviaKind::Comment)) + .map(|t| { + fn is_multiline_comment(comment: &str) -> bool { + comment.starts_with("--[[") && comment.ends_with("]]") + } + + let read_value = t.read(self.get_current_file_source_code()).to_owned(); + + match is_multiline_comment(&read_value) { + true => read_value[4..read_value.len() - 2] + .split("\n") + .map(|line| line.trim()) + .collect::>() + .join("\n"), + false => read_value[2..].trim().to_owned(), + } + }) + .collect::>() + .join("\n") + } + + fn get_current_file_source_code(&self) -> &str { + if self.0.current_module_path.is_empty() { + return self.0.main_module_source_code.as_str(); + } + + let module = self.0.modules.get(&self.0.current_module_path).unwrap(); + match module { + ModuleEnum::Resolved(module) => module.source_code.as_str(), + ModuleEnum::NotYetResolved => unreachable!(), + } + } + + /// Function used to get the type declaration for a name depending on the current module path (which should be passed just to be extra sure) + fn get_type_decl_for_name( + &mut self, + curr_module_path: String, + name: String, + ) -> Result { + match curr_module_path.as_str() { + "" => { + // we are in the main module + Ok(self + .0 + .main_module_types + .get(name.as_str()) + .ok_or(anyhow::anyhow!( + "Type `{}` not found in main module types", + name + ))? + .clone()) + } + module_path => { + let module = self + .0 + .modules + .get(module_path) + .ok_or(anyhow::anyhow!("Module not found: {}", module_path))?; + match module { + ModuleEnum::Resolved(module) => Ok(module + .local_types + .get(name.as_str()) + .ok_or(anyhow::anyhow!("Type `{}` not found in module types", name))? + .clone()), + _ => unreachable!(), + } + } + } + } + + /// This is used for fields of our table + /// We only accept simple types, no user defined types + /// We return the mapped type from lua to our types + fn resolve_simple_type(&mut self, ty: &Type) -> Result { + Ok(match ty { + Type::False(_) => "false".to_string(), + Type::Name(name) => { + // primitive types or user defined types + // for now only accept simple types, no user defined types + match name.get_type_name().get_name().to_string().as_str() { + "string" => "string".to_string(), + "number" => "number".to_string(), + "boolean" => "boolean".to_string(), + // TODO: add support for other primitive types + _ => { + if !Self::is_user_defined_type( + name.get_type_name().get_name().to_string().as_str(), + ) { + Err(anyhow::anyhow!( + "Unsupported type: '{}'. Only string, number and boolean are supported as primitive types", + name.get_type_name().get_name().to_string() + ))? + } + + let name_str = name.get_type_name().get_name(); + + let type_decl = self.get_type_decl_for_name( + self.0.current_module_path.to_owned(), + name_str.to_owned(), + )?; + + // for now, if you are going to hide the actual field type behind a user-defined type, + // you have to make sure that that is a union of string literals, it's the only accepted type + + let mut tys = Vec::new(); + match type_decl.get_type() { + Type::Union(union) => { + for ty in union.iter_types() { + match ty { + Type::String(string) => { + tys.push(format!( + "\"{}\"", + String::from_utf8_lossy(string.get_value()) + )); + } + _ => Err(anyhow::anyhow!( + "Unsupported type declaration: {:?}", + type_decl.get_type() + ))?, + } + } + tys.join(" | ") + } + _ => Err(anyhow::anyhow!( + "Unsupported type declaration: {:?}", + type_decl.get_type() + ))?, + } + } + } + } + Type::String(string) => format!("\"{}\"", String::from_utf8_lossy(string.get_value())), + Type::True(_) => "true".to_string(), + Type::Optional(optional) => self.resolve_simple_type(optional.get_inner_type())?, + _ => Err(anyhow::anyhow!("Unsupported simple type: {:?}", ty))?, + }) + } + + /// This is used for simple union types like `ActionType = "start" | "status" | "download"` + /// We only accept the string simple type for now + fn resolve_simple_union_type(&self, ty: &Type) -> Result { + Ok(match ty { + Type::Union(union) => { + let mut tys: Vec = Vec::new(); + for ty in union.iter_types() { + match ty { + Type::String(string) => { + tys.push(format!( + "\"{}\"", + String::from_utf8_lossy(string.get_value()) + )); + } + _ => Err(anyhow::anyhow!("Unsupported simple union type: {:?}", ty))?, + } + } + + tys.join(" | ") + } + _ => Err(anyhow::anyhow!("Expected a union type, got a {:?}", ty))?, + }) + } + + // this resolves a union type that is the type of a field in a type table + fn resolve_union_type(&self, ty: &Type) -> Result { + Ok(match ty { + Type::Union(union) => { + let mut tys = Vec::new(); + + for ty in union.iter_types() { + // in + match ty { + Type::String(string) => { + tys.push(format!( + "\"{}\"", + String::from_utf8_lossy(string.get_value()) + )); + } + Type::Field(_) => Err(anyhow::anyhow!( + "Field types are not supported yet inside union types" + ))?, + Type::Name(_) => Err(anyhow::anyhow!( + "Name types are not supported yet inside union types" + ))?, + Type::Optional(optional) => { + // we do accept "A"? | "C"? + // TODO: maybe we also have to set the param type here as optional? + match optional.get_inner_type() { + Type::String(string) => { + tys.push(format!( + "\"{}\"", + String::from_utf8_lossy(string.get_value()) + )); + } + _ => Err(anyhow::anyhow!( + "Unsupported optional type inside union types: {:?}", + optional.get_inner_type() + ))?, + } + } + Type::Nil(_) => { + todo!() + } + _ => Err(anyhow::anyhow!("Unsupported union type: {:?}", ty))?, + } + } + + tys.join(" | ") + } + _ => Err(anyhow::anyhow!("Expected a union type, got a {:?}", ty))?, + }) + } + + /// If we have something like: + /// + /// ```luau + /// type Params = {a: string?, b: number?} + /// ``` + /// + /// or even + /// + /// ```luau + /// type State = {a: string?, b: number?} + /// type Params = State + /// ``` + /// + /// it resolves the actual table + fn resolve_type_table( + &mut self, + type_table: &TableType, + ) -> Result { + let mut params = ParamVariant::new(); + + /* + We currently accept fields like this: + field1: string?, + field2: string, + field999: "A", + field3: "A" | "B" | "C", + field4: number?, + field5: number, + field6: boolean?, + field7: boolean, + field8: false, + field9: true, + */ + for entry in type_table.iter_entries() { + let mut curr_param = Param { + name: String::new(), + description: String::new(), + ty: String::new(), + required: true, + }; + + let value = match entry { + TableEntryType::Property(prop) => { + curr_param.name = prop.get_identifier().get_name().to_string(); + let comment = match prop.get_identifier().get_token() { + Some(token) => self.get_string_comment(token), + None => String::new(), + }; + curr_param.description = comment; + prop.get_type() + }, + // Some property names are NOT valid, for example "end", which is a reserved keyword + // You might want to have both "start" and "end" as simple properties, but you can't + // The solution is to have a literal property, which is a string literal + TableEntryType::Literal(literal) => { + curr_param.name = String::from_utf8_lossy(literal.get_string().get_value()).to_string(); + let comment = match literal.get_string().get_token() { + Some(token) => self.get_string_comment(token), + None => String::new(), + }; + curr_param.description = comment; + literal.get_type() + } + _ => return Err(anyhow::anyhow!("Expected a property, got a {:?}", entry)), + }; + + curr_param.ty = match value { + Type::Array(array) => { + // this can either be a simple primitive type or a union of strings + let ty = match array.get_element_type() { + Type::False(_) + | Type::Name(_) + | Type::String(_) + | Type::True(_) => self.resolve_simple_type(value)?, + Type::Union(_) => self.resolve_simple_union_type(value)?, + _ => Err(anyhow::anyhow!( + "Unsupported array type: {:?}", + array.get_element_type() + ))?, + }; + + format!("Vec<{ty}>") + } + Type::False(_) | Type::Name(_) | Type::String(_) | Type::True(_) => { + self.resolve_simple_type(value)? + } + Type::Field(_) => { + Err(anyhow::anyhow!("Field types are not supported yet"))? + } + Type::Function(_) => { + Err(anyhow::anyhow!("Function types are not supported"))? + } + Type::Intersection(_) => { + Err(anyhow::anyhow!("Intersection types are not supported"))? + } + Type::Nil(_) => Err(anyhow::anyhow!("Nil types are not supported yet"))?, + Type::Optional(optional) => { + curr_param.required = false; + // this should be like the above, simple types + // what it can also be is a paranthese type, ughh + match optional.get_inner_type() { + Type::False(_) + | Type::Name(_) + | Type::String(_) + | Type::True(_) => self.resolve_simple_type(value)?, + Type::Union(_) => self.resolve_union_type(value)?, + Type::Parenthese(parenthese) => { + let value = parenthese.get_inner_type(); + // TODO: maybe wrap final value in parentheses? maybe not? + match value { + Type::Union(_) => self.resolve_union_type(value)?, + _ => Err(anyhow::anyhow!( + "Unsupported optional type inside paranthese types: {:?}", + parenthese.get_inner_type() + ))?, + } + } + _ => Err(anyhow::anyhow!( + "Unsupported optional type: {:?}", + optional.get_inner_type() + ))?, + } + } + Type::Parenthese(parenthese) => { + // this should be like the above, simple types + let ty = match parenthese.get_inner_type() { + Type::False(_) + | Type::Name(_) + | Type::String(_) + | Type::True(_) => self.resolve_simple_type(value)?, + Type::Union(_) => self.resolve_union_type(value)?, + _ => Err(anyhow::anyhow!( + "Unsupported paranthese type: {:?}", + parenthese + ))?, + }; + + format!("({ty})") + } + Type::Table(_) => { + Err(anyhow::anyhow!("Table types are not supported yet"))? + } + Type::TypeOf(_) => Err(anyhow::anyhow!("Type of types are not supported"))?, + Type::Union(_) => self.resolve_union_type(value)?, + }; + + params.push(curr_param); + } + + Ok(params) + } + + /// this is the resolver that the [[Self::resolve_params_type_decl]] uses + /// because we might have a union `type Param = State1 | State2`, and we'd want to delegate + fn resolve_type_decl_for_param_variant( + &mut self, + type_decl: &Type, + ) -> Result { + match type_decl { + Type::Table(table) => self.resolve_type_table(table), + Type::Field(_) => Err(anyhow::anyhow!( + "External field types are not supported yet, please have both the main Params type and the types for your fields in the same file" + ))?, + Type::Name(name) => { + let name_str = name.get_type_name().get_name().to_string(); + let type_decl = self.get_type_decl_for_name( + self.0.current_module_path.to_owned(), + name_str.to_owned(), + )?; + + match type_decl.get_type() { + Type::Table(table) => self.resolve_type_table(table), + _ => Err(anyhow::anyhow!( + "Unsupported type declaration: {:?}", + type_decl.get_type() + ))?, + } + } + _ => Err(anyhow::anyhow!( + "Unsupported type declaration: {:?}", + type_decl + ))?, + } + } + + /// this is the resolver for the main `type Params = ...` + fn resolve_params_type_decl( + &mut self, + type_decl: &TypeDeclarationStatement, + ) -> Result, anyhow::Error> { + let mut param_variants: Vec = Vec::new(); + + match type_decl.get_type() { + // [[Type::Field(_)]] is like an external type requiredModule.TypeInsideIt + Type::Table(_) | Type::Name(_) => { + param_variants.push(self.resolve_type_decl_for_param_variant(type_decl.get_type())?) + } + Type::Field(field) => { + let prop_name = field.get_type_name().get_type_name().get_name().to_string(); + + let type_decl = self.get_type_decl_for_name( + self.0.current_module_path.to_owned(), + prop_name.to_owned(), + )?; + + match type_decl.get_type() { + Type::Union(union) => { + for ty in union.iter_types() { + param_variants.push(self.resolve_type_decl_for_param_variant(ty)?) + } + } + _ => param_variants + .push(self.resolve_type_decl_for_param_variant(type_decl.get_type())?), + } + } + + Type::Union(union) => { + // we accept unions here, but the union types need to be either tables, fields or names + for ty in union.iter_types() { + match ty { + Type::Table(_) | Type::Field(_) => { + param_variants.push(self.resolve_type_decl_for_param_variant(ty)?) + } + Type::Name(name) => { + let name_str = name.get_type_name().get_name().to_string(); + let type_decl = self.get_type_decl_for_name( + self.0.current_module_path.to_owned(), + name_str.to_owned(), + )?; + + let ty = + self.resolve_type_decl_for_param_variant(type_decl.get_type())?; + param_variants.push(ty); + } + _ => Err(anyhow::anyhow!("Unsupported union type: {:?}", ty))?, + } + } + } + + _ => Err(anyhow::anyhow!( + "Unsupported type declaration: {:?}", + type_decl.get_type() + ))?, + } + + Ok(param_variants) + } + + /// This function is used when we actually import the type from another file + /// The way we extract type information is navigating through the main blocks of the ast until we find a + /// 'export type PARAMS_NAME_WE_NEED' statement + /// After that we can delegate to one of the functions that we already have + fn resolve_type_from_extern_file( + &mut self, + module_name: String, + type_name: String, + ) -> Result, anyhow::Error> { + match self.0.name_to_module_path.get(&module_name) { + None => Err(anyhow::anyhow!("Module not found: {}", module_name)), + Some(module_path) => { + match self.0.modules.get(module_path) { + // unreachable for the moment + Some(ModuleEnum::Resolved(_)) => unreachable!(), + Some(ModuleEnum::NotYetResolved) => { + self.0.current_module_path = module_path.to_owned(); + let mut module = Module { + local_types: HashMap::new(), + source_code: String::new(), + }; + // load the module + let abs_module_path = self + .0 + .name_to_module_path + .get(&module_name) + .ok_or(anyhow::anyhow!("Module not found: {}", module_name))?; + + let module_file = std::fs::read_to_string(abs_module_path).unwrap(); + module.source_code = module_file.clone(); + let module_ast = Parser::default().parse(&module_file).unwrap(); + + // first traverse the top-level statements and collect the local types so we can make use of them when needed + for statement in module_ast.iter_statements() { + use Statement::*; + if let TypeDeclaration(type_decl) = statement { + module.local_types.insert( + type_decl.get_name().get_name().to_string(), + type_decl.clone(), + ); + } + } + + self.0 + .modules + .insert(module_path.to_owned(), ModuleEnum::Resolved(module)); + + let type_decl: &TypeDeclarationStatement = module_ast + .iter_statements() + .find_map(|statement| { + use Statement::*; + match statement { + TypeDeclaration(type_decl) => { + if type_decl.get_name().get_name() == &type_name { + Some(type_decl) + } else { + None + } + } + _ => None, + } + }) + .ok_or(anyhow::anyhow!("Type declaration not found: {}", type_name))?; + + let res = self.resolve_params_type_decl(type_decl); + self.0.current_module_path = "".to_owned(); + res + } + None => Err(anyhow::anyhow!( + "Module `{}` found in `name_to_module_path` but not found in `modules` map. Did you forget to add it in code?", + module_name + )), + } + } + } + } + + fn is_user_defined_type(type_name: &str) -> bool { + !matches!( + type_name, + "any" + | "boolean" + | "buffer" + | "never" + | "nil" + | "number" + | "string" + | "thread" + | "unknown" + | "vector" + ) + } +} + +/// Given a relative path, compute its absolute path using the current working directory. +fn absolute_from_cwd>(relative: P, cwd: Option) -> std::io::Result { + let cwd = cwd.map(PathBuf::from).unwrap_or(env::current_dir()?); + cwd.join(relative).canonicalize() +} + +/// Given a full path and a relative path, compute the resolved full path if the relative path +/// is relative to the full path's parent directory. +fn resolve_relative_to_full, R: AsRef>( + full: P, + relative: R, +) -> std::io::Result { + let full = full.as_ref(); + let base = full.parent().unwrap_or_else(|| Path::new("/")); + let joined = base.join(relative); + joined.canonicalize() +} + +impl NodeProcessor for ParamExtractorVisitor { + fn process_block(&mut self, block: &mut Block) { + if self.0.main_function.is_some() { + return; + } + for statement in block.iter_statements() { + use Statement::*; + match statement { + // we care about local types = require("./type.luau") + LocalAssign(local_assign) => { + // we look only for local assign statements that have 1 variable and 1 value + // we then check to see if the value is a function call to `require` + // if it is, we then check to make sure the call has 1 arg and that it is a string + // we then try to get that string and resolve the module + + // oh, by the way, we don't allow types with a depth more than 2 (the type for the Params + // can be found only in a required module, not in a require of a required module or so on) + if local_assign.variables_len() != 1 || local_assign.values_len() != 1 { + continue; + } + + // we know for sure we have 1 variable and 1 value + let variable = local_assign.iter_variables().next().unwrap(); + let value = local_assign.iter_values().next().unwrap(); + + use darklua_core::nodes::Expression; + let require_file: Option = match value { + Expression::Call(call) => { + use darklua_core::nodes::Prefix::*; + let prefix_is_okay = match call.get_prefix() { + Identifier(identifier) => identifier.get_name() == "require", + _ => false, + }; + + use darklua_core::nodes::Arguments; + + let (args_are_okay, arg) = match call.get_arguments() { + // Fun fact, Arguments::String is calling the function like so: `require "path"` ?!?!?!? + // Arguments::String(string) => ( + // true, + // String::from_utf8_lossy(string.get_value()).to_string(), + // ), + Arguments::Tuple(tuple) => match tuple.len() { + 1 => { + let arg = tuple.iter_values().next().unwrap(); + match arg { + Expression::String(string) => ( + true, + String::from_utf8_lossy(string.get_value()) + .to_string(), + ), + _ => (false, String::new()), + } + } + _ => (false, String::new()), + }, + _ => (false, String::new()), + }; + + match (prefix_is_okay, args_are_okay) { + (true, true) => Some(arg), + _ => None, + } + } + _ => None, + }; + + // this is @Lib stuff, we don't care about it atm, SKIP FILE + if require_file.is_none() || require_file.as_ref().unwrap().starts_with("@") { + continue; + } + + // resolve the full path of the module + // this means that we have to make use of the current module path (self.0.main_module_path) + // and the require file (require_file) + + // so, we have to get the FULL path of the main module + // THEN, we have to compute the full path of the require file, which we only have relative for the moment + let full_path_of_main_module = + absolute_from_cwd(self.0.main_module_path.clone(), self.0.cwd.clone()) + .unwrap(); + + match require_file.clone() { + Some(file) => { + // append .luau to the file if it doesn't have it + let file = if file.ends_with(".luau") { + file + } else { + file + ".luau" + }; + let full_path_of_require_file = + resolve_relative_to_full(full_path_of_main_module, file).unwrap(); + + // now, we have to check if the full path of the require file is a file or a directory + // if it is a file, we can just use it + // if it is a directory, we have to check if it contains a file called "index.luau" + // if it does, we can use it, otherwise we have to error out + self.0.modules.insert( + full_path_of_require_file.to_str().unwrap().to_string(), + ModuleEnum::NotYetResolved, + ); + self.0.name_to_module_path.insert( + variable.get_name().to_string(), + full_path_of_require_file.to_str().unwrap().to_string(), + ); + } + None => {} + } + } + // we care about type Params = ... + TypeDeclaration(type_decl) => { + // delegate the resolution of the type decl so you can also do it for require'd modules + // let types: Result, _> = self.resolve_type_decl(type_decl); + + self.0.main_module_types.insert( + type_decl.get_name().get_name().to_string(), + type_decl.clone(), + ); + } + Function(func) => { + if func.get_name().get_name().get_name() == "main" { + self.0.main_function = Some(*func.clone()); + } + } + _ => {} + } + } + + // after collecting the main function, check if we have a parameter with a type + let main_function = match self.0.main_function.take() { + None => { + self.0.errors.push("No main function found".to_string()); + return; + } + Some(func) => func, + }; + + let params = main_function.get_parameters(); + + // the flow does not require any parameters, we can just return + if params.len() == 0 { + return; + } + + // the main function should have just 1 parameter which is the Params table + if params.len() > 1 { + self.0 + .errors + .push(format!("Expected 0-1 parameters, got {}", params.len())); + } + + let param = params.first().unwrap(); + + let param_type = match param.get_type() { + Some(ty) => ty, + None => { + self.0.errors.push("No parameter type found".to_string()); + return; + } + }; + + match param_type { + Type::Table(table) => match self.resolve_type_table(table).map(|val| vec![val]) { + Ok(val) => self.0.params = val, + Err(e) => { + self.0 + .errors + .push(format!("Error resolving type table: {e}")); + return; + } + }, + Type::Field(field) => { + let module_name = field.get_namespace().get_name().to_string(); + let type_name = field.get_type_name().get_type_name().get_name().to_string(); + + // we have to process loaded files, we currently allow only one level of indirection + match self.resolve_type_from_extern_file(module_name, type_name) { + Ok(val) => self.0.params = val, + Err(e) => { + self.0.errors.push(format!("Error resolving module: {e}")); + return; + } + } + } + Type::Name(name) => { + let name_str = name.get_type_name().get_name().to_string(); + if !Self::is_user_defined_type(name_str.as_str()) { + self.0.errors.push(format!( + "User defined type `{name_str}` is not supported yet" + )); + return; + } + + let type_decl = match self.0.main_module_types.get(name_str.as_str()) { + Some(type_decl) => type_decl.clone(), + None => { + self.0 + .errors + .push(format!("Type `{name_str}` not found in main module types")); + return; + } + }; + + match self.resolve_params_type_decl(&type_decl) { + Ok(val) => self.0.params = val, + Err(e) => { + self.0 + .errors + .push(format!("Error resolving type declaration: {e}")); + return; + } + } + } + _ => { + self.0 + .errors + .push(format!("Unsupported parameter type: {param_type:?}")); + return; + } + }; + } +} + +pub fn extract_params( + file: &str, + file_path: &str, + cwd: Option, +) -> Result, anyhow::Error> { + let parser = Parser::default().preserve_tokens(); + let mut ast = parser.parse(file).unwrap(); + let mut visitor = ParamExtractorVisitor::new(cwd); + visitor.0.main_module_source_code = file.to_string(); + visitor.0.main_module_path = file_path.to_string(); + visitor.process_block(&mut ast); + + if !visitor.0.errors.is_empty() { + return Err(anyhow::anyhow!("Errors found: {:?}", visitor.0.errors)); + } + + Ok(visitor.0.params) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..bea36fc --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,5 @@ +pub mod commands { + pub mod bundle { + pub mod param_extractor; + } +} \ No newline at end of file diff --git a/tests/mod.rs b/tests/mod.rs new file mode 100644 index 0000000..b352d25 --- /dev/null +++ b/tests/mod.rs @@ -0,0 +1 @@ +mod params_extraction; \ No newline at end of file diff --git a/tests/params_extraction/mod.rs b/tests/params_extraction/mod.rs new file mode 100644 index 0000000..4915f23 --- /dev/null +++ b/tests/params_extraction/mod.rs @@ -0,0 +1,10 @@ +mod t1; +mod t10; +mod t2; +mod t3; +mod t4; +mod t5; +mod t6; +mod t7; +mod t8; +mod t9; diff --git a/tests/params_extraction/t1/flow.luau b/tests/params_extraction/t1/flow.luau new file mode 100644 index 0000000..6b8edf5 --- /dev/null +++ b/tests/params_extraction/t1/flow.luau @@ -0,0 +1,4 @@ +--!nolint FunctionUnused +function main(params: {a: number, b: number}) + return params.a + params.b +end \ No newline at end of file diff --git a/tests/params_extraction/t1/mod.rs b/tests/params_extraction/t1/mod.rs new file mode 100644 index 0000000..3469d25 --- /dev/null +++ b/tests/params_extraction/t1/mod.rs @@ -0,0 +1,28 @@ +use opacity_cli::commands::bundle::param_extractor::{self, Param}; + +#[test] +pub fn test_t1() { + let file_path = std::path::Path::new(std::env::current_dir().unwrap().as_os_str()) + .join("tests/params_extraction//t1/flow.luau"); + let file = std::fs::read_to_string(file_path).unwrap(); + + let params = param_extractor::extract_params(&file, "flow.luau", None).unwrap(); + + assert_eq!( + params, + vec![vec![ + Param { + name: "a".to_string(), + description: "".to_string(), + ty: "number".to_string(), + required: true, + }, + Param { + name: "b".to_string(), + description: "".to_string(), + ty: "number".to_string(), + required: true, + }, + ]] + ) +} diff --git a/tests/params_extraction/t10/flow.luau b/tests/params_extraction/t10/flow.luau new file mode 100644 index 0000000..815cae8 --- /dev/null +++ b/tests/params_extraction/t10/flow.luau @@ -0,0 +1,19 @@ +--!nolint FunctionUnused + +--[[ + This is a multiline comment for the type Params +]] +type Params = { + -- this is a comment about the field a + a: number, + -- this is a single comment about the field b + -- this is a secondary single comment about the field b + b: number, + --[[ This is a multiline comment + about the field c ]] + ["c"]: number +} + +function main(params: Params) + +end diff --git a/tests/params_extraction/t10/mod.rs b/tests/params_extraction/t10/mod.rs new file mode 100644 index 0000000..6f0ad89 --- /dev/null +++ b/tests/params_extraction/t10/mod.rs @@ -0,0 +1,34 @@ +use opacity_cli::commands::bundle::param_extractor::{self, Param}; + +#[test] +pub fn test_t9() { + let file_path = std::path::Path::new(std::env::current_dir().unwrap().as_os_str()) + .join("tests/params_extraction//t9/flow.luau"); + let file = std::fs::read_to_string(file_path).unwrap(); + + let params = param_extractor::extract_params(&file, "flow.luau", None).unwrap(); + + assert_eq!( + params, + vec![vec![ + Param { + name: "a".to_string(), + description: "this is a comment about the field a".to_string(), + ty: "number".to_string(), + required: true, + }, + Param { + name: "b".to_string(), + description: "this is a single comment about the field b\nthis is a secondary single comment about the field b".to_string(), + ty: "number".to_string(), + required: true, + }, + Param { + name: "c".to_string(), + description: "This is a multiline comment\nabout the field c".to_string(), + ty: "number".to_string(), + required: true, + }, + ]] + ) +} diff --git a/tests/params_extraction/t2/flow.luau b/tests/params_extraction/t2/flow.luau new file mode 100644 index 0000000..47e04dd --- /dev/null +++ b/tests/params_extraction/t2/flow.luau @@ -0,0 +1,15 @@ +--!nolint FunctionUnused +type Param = { + a: number, + b: number +} + +function main(params: Param) + return params.a + params.b +end + + + + + + diff --git a/tests/params_extraction/t2/mod.rs b/tests/params_extraction/t2/mod.rs new file mode 100644 index 0000000..b3aa3d0 --- /dev/null +++ b/tests/params_extraction/t2/mod.rs @@ -0,0 +1,28 @@ +use opacity_cli::commands::bundle::param_extractor::{self, Param}; + +#[test] +pub fn test_t2() { + let file_path = std::path::Path::new(std::env::current_dir().unwrap().as_os_str()) + .join("tests/params_extraction/t2/flow.luau"); + let file = std::fs::read_to_string(file_path).unwrap(); + + let params = param_extractor::extract_params(&file, "flow.luau", None).unwrap(); + + assert_eq!( + params, + vec![vec![ + Param { + name: "a".to_string(), + description: "".to_string(), + ty: "number".to_string(), + required: true, + }, + Param { + name: "b".to_string(), + description: "".to_string(), + ty: "number".to_string(), + required: true, + }, + ]] + ) +} diff --git a/tests/params_extraction/t3/flow.luau b/tests/params_extraction/t3/flow.luau new file mode 100644 index 0000000..1e1b342 --- /dev/null +++ b/tests/params_extraction/t3/flow.luau @@ -0,0 +1,12 @@ +--!nolint FunctionUnused +local typestuff = require("./typestuff.luau") + +function main(params: typestuff.Params) + return params.a + params.b +end + + + + + + diff --git a/tests/params_extraction/t3/mod.rs b/tests/params_extraction/t3/mod.rs new file mode 100644 index 0000000..4d0794c --- /dev/null +++ b/tests/params_extraction/t3/mod.rs @@ -0,0 +1,28 @@ +use opacity_cli::commands::bundle::param_extractor::{self, Param}; + +#[test] +pub fn test_t3() { + let file_path = std::path::Path::new(std::env::current_dir().unwrap().as_os_str()) + .join("tests/params_extraction//t3/flow.luau"); + let file = std::fs::read_to_string(file_path).unwrap(); + + let params = param_extractor::extract_params(&file, "flow.luau", Some("tests/params_extraction//t3".to_string())).unwrap(); + + assert_eq!( + params, + vec![vec![ + Param { + name: "a".to_string(), + description: "".to_string(), + ty: "number".to_string(), + required: true, + }, + Param { + name: "b".to_string(), + description: "".to_string(), + ty: "number".to_string(), + required: true, + }, + ]] + ) +} \ No newline at end of file diff --git a/tests/params_extraction/t3/typestuff.luau b/tests/params_extraction/t3/typestuff.luau new file mode 100644 index 0000000..c3bdc5e --- /dev/null +++ b/tests/params_extraction/t3/typestuff.luau @@ -0,0 +1,6 @@ +export type Params = { + a: number, + b: number +} + +return {} \ No newline at end of file diff --git a/tests/params_extraction/t4/flow.luau b/tests/params_extraction/t4/flow.luau new file mode 100644 index 0000000..4462bfc --- /dev/null +++ b/tests/params_extraction/t4/flow.luau @@ -0,0 +1,9 @@ +--!nolint FunctionUnused + +type Params = { + a: "start" | "stop"?, +} + +function main(params: Params) + +end \ No newline at end of file diff --git a/tests/params_extraction/t4/mod.rs b/tests/params_extraction/t4/mod.rs new file mode 100644 index 0000000..ff5c93c --- /dev/null +++ b/tests/params_extraction/t4/mod.rs @@ -0,0 +1,22 @@ +use opacity_cli::commands::bundle::param_extractor::{self, Param}; + +#[test] +pub fn test_t4() { + let file_path = std::path::Path::new(std::env::current_dir().unwrap().as_os_str()) + .join("tests/params_extraction//t4/flow.luau"); + let file = std::fs::read_to_string(file_path).unwrap(); + + let params = param_extractor::extract_params(&file, "flow.luau", None).unwrap(); + + assert_eq!( + params, + vec![vec![ + Param { + name: "a".to_string(), + description: "".to_string(), + ty: "\"start\" | \"stop\"".to_string(), + required: true, + }, + ]] + ) +} diff --git a/tests/params_extraction/t5/flow.luau b/tests/params_extraction/t5/flow.luau new file mode 100644 index 0000000..15aee01 --- /dev/null +++ b/tests/params_extraction/t5/flow.luau @@ -0,0 +1,14 @@ +--!nolint FunctionUnused +type StartParams = { + action: "start", +} + +type StopParams = { + action: "stop", +} + +type Params = StartParams | StopParams + +function main(params: Params) + +end \ No newline at end of file diff --git a/tests/params_extraction/t5/mod.rs b/tests/params_extraction/t5/mod.rs new file mode 100644 index 0000000..7ab896d --- /dev/null +++ b/tests/params_extraction/t5/mod.rs @@ -0,0 +1,29 @@ +use opacity_cli::commands::bundle::param_extractor::{self, Param}; + +#[test] +pub fn test_t5() { + let file_path = std::path::Path::new(std::env::current_dir().unwrap().as_os_str()) + .join("tests/params_extraction//t5/flow.luau"); + let file = std::fs::read_to_string(file_path).unwrap(); + + let params = param_extractor::extract_params(&file, "flow.luau", None).unwrap(); + + assert_eq!( + params, + vec![vec![ + Param { + name: "action".to_string(), + description: "".to_string(), + ty: "\"start\"".to_string(), + required: true, + }, + ], vec![ + Param { + name: "action".to_string(), + description: "".to_string(), + ty: "\"stop\"".to_string(), + required: true, + }, + ]] + ) +} diff --git a/tests/params_extraction/t6/flow.luau b/tests/params_extraction/t6/flow.luau new file mode 100644 index 0000000..d242702 --- /dev/null +++ b/tests/params_extraction/t6/flow.luau @@ -0,0 +1,6 @@ +--!nolint FunctionUnused +type ActionType = "start" | "status" | "download" + +function main(params: { action: ActionType }) + +end \ No newline at end of file diff --git a/tests/params_extraction/t6/mod.rs b/tests/params_extraction/t6/mod.rs new file mode 100644 index 0000000..9790ad5 --- /dev/null +++ b/tests/params_extraction/t6/mod.rs @@ -0,0 +1,22 @@ +use opacity_cli::commands::bundle::param_extractor::{self, Param}; + +#[test] +pub fn test_t6() { + let file_path = std::path::Path::new(std::env::current_dir().unwrap().as_os_str()) + .join("tests/params_extraction//t6/flow.luau"); + let file = std::fs::read_to_string(file_path).unwrap(); + + let params = param_extractor::extract_params(&file, "flow.luau", None).unwrap(); + + assert_eq!( + params, + vec![vec![ + Param { + name: "action".to_string(), + description: "".to_string(), + ty: "\"start\" | \"status\" | \"download\"".to_string(), + required: true, + }, + ]] + ) +} diff --git a/tests/params_extraction/t7/flow.luau b/tests/params_extraction/t7/flow.luau new file mode 100644 index 0000000..9da1fc3 --- /dev/null +++ b/tests/params_extraction/t7/flow.luau @@ -0,0 +1,6 @@ +--!nolint FunctionUnused +local typestuff = require("./typestuff.luau") + +function main(params: typestuff.Params) -- this should be a union of string literals + +end \ No newline at end of file diff --git a/tests/params_extraction/t7/mod.rs b/tests/params_extraction/t7/mod.rs new file mode 100644 index 0000000..5562571 --- /dev/null +++ b/tests/params_extraction/t7/mod.rs @@ -0,0 +1,28 @@ +use opacity_cli::commands::bundle::param_extractor::{self, Param}; + +#[test] +pub fn test_t7() { + let file_path = std::path::Path::new(std::env::current_dir().unwrap().as_os_str()) + .join("tests/params_extraction//t7/flow.luau"); + let file = std::fs::read_to_string(file_path).unwrap(); + + let params = param_extractor::extract_params(&file, "flow.luau", Some("tests/params_extraction//t7/".to_string())).unwrap(); + + assert_eq!( + params, + vec![ + vec![Param { + name: "action".to_string(), + description: "".to_string(), + ty: "\"action1\"".to_string(), + required: true, + },], + vec![Param { + name: "action".to_string(), + description: "".to_string(), + ty: "\"action2\"".to_string(), + required: true, + },] + ] + ) +} diff --git a/tests/params_extraction/t7/typestuff.luau b/tests/params_extraction/t7/typestuff.luau new file mode 100644 index 0000000..0dc83f3 --- /dev/null +++ b/tests/params_extraction/t7/typestuff.luau @@ -0,0 +1,10 @@ +type Action1 = { + action: "action1" +} +type Action2 = { + action: "action2" +} + +export type Params = Action1 | Action2 + +return {} \ No newline at end of file diff --git a/tests/params_extraction/t8/flow.luau b/tests/params_extraction/t8/flow.luau new file mode 100644 index 0000000..83b2483 --- /dev/null +++ b/tests/params_extraction/t8/flow.luau @@ -0,0 +1,21 @@ +--!nolint FunctionUnused +type UnionType = "A" | "B" | "C" + +type Params = { + field1: string?, + field2: string, + field999: "A", + field998: "A" | "B" | "C", + field3: ("A" | "B" | "C")?, -- if you wish to have a nullable union type, you can wrap it in parentheses and use the ? operator + field4: number?, + field5: number, + field6: boolean?, + field7: boolean, + field8: false, + field9: true, + field10: UnionType? +} + +function main(params: Params) + +end diff --git a/tests/params_extraction/t8/mod.rs b/tests/params_extraction/t8/mod.rs new file mode 100644 index 0000000..393a587 --- /dev/null +++ b/tests/params_extraction/t8/mod.rs @@ -0,0 +1,88 @@ +use opacity_cli::commands::bundle::param_extractor::{self, Param}; + +#[test] +pub fn test_t8() { + let file_path = std::path::Path::new(std::env::current_dir().unwrap().as_os_str()) + .join("tests/params_extraction//t8/flow.luau"); + let file = std::fs::read_to_string(file_path).unwrap(); + + let params = param_extractor::extract_params(&file, "flow.luau", None).unwrap(); + + assert_eq!( + params, + vec![vec![ + Param { + name: "field1".to_string(), + description: "".to_string(), + ty: "string".to_string(), + required: false, + }, + Param { + name: "field2".to_string(), + description: "".to_string(), + ty: "string".to_string(), + required: true, + }, + Param { + name: "field999".to_string(), + description: "".to_string(), + ty: "\"A\"".to_string(), + required: true, + }, + Param { + name: "field998".to_string(), + description: "".to_string(), + ty: "\"A\" | \"B\" | \"C\"".to_string(), + required: true, + }, + Param { + name: "field3".to_string(), + description: "".to_string(), + ty: "\"A\" | \"B\" | \"C\"".to_string(), + required: false, + }, + Param { + name: "field4".to_string(), + description: "".to_string(), + ty: "number".to_string(), + required: false, + }, + Param { + name: "field5".to_string(), + description: "".to_string(), + ty: "number".to_string(), + required: true, + }, + Param { + name: "field6".to_string(), + description: "".to_string(), + ty: "boolean".to_string(), + required: false, + }, + Param { + name: "field7".to_string(), + description: "".to_string(), + ty: "boolean".to_string(), + required: true, + }, + Param { + name: "field8".to_string(), + description: "".to_string(), + ty: "false".to_string(), + required: true, + }, + Param { + name: "field9".to_string(), + description: "".to_string(), + ty: "true".to_string(), + required: true, + }, + Param { + name: "field10".to_string(), + description: "".to_string(), + ty: "\"A\" | \"B\" | \"C\"".to_string(), + required: false, + }, + ]] + ) +} diff --git a/tests/params_extraction/t9/flow.luau b/tests/params_extraction/t9/flow.luau new file mode 100644 index 0000000..cc03f08 --- /dev/null +++ b/tests/params_extraction/t9/flow.luau @@ -0,0 +1,19 @@ +--!nolint FunctionUnused + +--[[ + This is a multiline comment for the type Params +]] +type Params = { + -- this is a comment about the field a + a: number, + -- this is a single comment about the field b + -- this is a secondary single comment about the field b + b: number, + --[[ This is a multiline comment + about the field c ]] + c: number +} + +function main(params: Params) + +end diff --git a/tests/params_extraction/t9/mod.rs b/tests/params_extraction/t9/mod.rs new file mode 100644 index 0000000..6f0ad89 --- /dev/null +++ b/tests/params_extraction/t9/mod.rs @@ -0,0 +1,34 @@ +use opacity_cli::commands::bundle::param_extractor::{self, Param}; + +#[test] +pub fn test_t9() { + let file_path = std::path::Path::new(std::env::current_dir().unwrap().as_os_str()) + .join("tests/params_extraction//t9/flow.luau"); + let file = std::fs::read_to_string(file_path).unwrap(); + + let params = param_extractor::extract_params(&file, "flow.luau", None).unwrap(); + + assert_eq!( + params, + vec![vec![ + Param { + name: "a".to_string(), + description: "this is a comment about the field a".to_string(), + ty: "number".to_string(), + required: true, + }, + Param { + name: "b".to_string(), + description: "this is a single comment about the field b\nthis is a secondary single comment about the field b".to_string(), + ty: "number".to_string(), + required: true, + }, + Param { + name: "c".to_string(), + description: "This is a multiline comment\nabout the field c".to_string(), + ty: "number".to_string(), + required: true, + }, + ]] + ) +}