diff --git a/mlf-cli/src/generate/mlf.rs b/mlf-cli/src/generate/mlf.rs new file mode 100644 index 0000000..6465b33 --- /dev/null +++ b/mlf-cli/src/generate/mlf.rs @@ -0,0 +1,820 @@ +use miette::Diagnostic; +use serde_json::Value; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +#[derive(Error, Debug, Diagnostic)] +pub enum MlfGenerateError { + #[error("Failed to read file: {path}")] + #[diagnostic(code(mlf::generate::read_file))] + ReadFile { + path: String, + #[source] + source: std::io::Error, + }, + + #[error("Failed to parse JSON: {path}")] + #[diagnostic(code(mlf::generate::parse_json))] + ParseJson { + path: String, + #[source] + source: serde_json::Error, + }, + + #[error("Failed to write output: {path}")] + #[diagnostic(code(mlf::generate::write_output))] + WriteOutput { + path: String, + #[source] + source: std::io::Error, + }, + + #[error("Invalid lexicon format: {message}")] + #[diagnostic(code(mlf::generate::invalid_lexicon))] + InvalidLexicon { message: String }, + + #[error("Failed to expand glob pattern")] + #[diagnostic(code(mlf::generate::glob_error))] + GlobError { + #[source] + source: glob::GlobError, + }, + + #[error("Invalid glob pattern: {pattern}")] + #[diagnostic(code(mlf::generate::invalid_glob))] + InvalidGlob { + pattern: String, + #[source] + source: glob::PatternError, + }, +} + +pub fn run(input_patterns: Vec, output_dir: PathBuf) -> Result<(), MlfGenerateError> { + let mut file_paths = Vec::new(); + + for pattern in input_patterns { + if pattern.contains('*') || pattern.contains('?') { + for entry in glob::glob(&pattern).map_err(|source| MlfGenerateError::InvalidGlob { + pattern: pattern.clone(), + source, + })? { + let path = entry.map_err(|source| MlfGenerateError::GlobError { source })?; + file_paths.push(path); + } + } else { + file_paths.push(PathBuf::from(pattern)); + } + } + + std::fs::create_dir_all(&output_dir).map_err(|source| MlfGenerateError::WriteOutput { + path: output_dir.display().to_string(), + source, + })?; + + let mut errors = Vec::new(); + let mut success_count = 0; + + for file_path in file_paths { + let source = match std::fs::read_to_string(&file_path) { + Ok(s) => s, + Err(source) => { + errors.push(( + file_path.display().to_string(), + format!("Failed to read file: {}", source), + )); + continue; + } + }; + + let json: Value = match serde_json::from_str(&source) { + Ok(j) => j, + Err(source) => { + errors.push(( + file_path.display().to_string(), + format!("Failed to parse JSON: {}", source), + )); + continue; + } + }; + + let mlf_content = match generate_mlf_from_json(&json) { + Ok(content) => content, + Err(e) => { + errors.push((file_path.display().to_string(), format!("{:?}", e))); + continue; + } + }; + + // Extract namespace from JSON "id" field + let namespace = json + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| MlfGenerateError::InvalidLexicon { + message: "Missing 'id' field in lexicon".to_string(), + })?; + + // Create output path from namespace + let mut output_path = output_dir.clone(); + for segment in namespace.split('.') { + output_path.push(segment); + } + if let Err(source) = std::fs::create_dir_all(&output_path.parent().unwrap()) { + errors.push(( + file_path.display().to_string(), + format!("Failed to create directory: {}", source), + )); + continue; + } + output_path.set_extension("mlf"); + + if let Err(source) = std::fs::write(&output_path, mlf_content) { + errors.push(( + output_path.display().to_string(), + format!("Failed to write file: {}", source), + )); + continue; + } + + println!("Generated: {}", output_path.display()); + success_count += 1; + } + + if !errors.is_empty() { + eprintln!( + "\n{} file(s) generated successfully, {} error(s) encountered:\n", + success_count, + errors.len() + ); + for (path, error) in &errors { + eprintln!(" {} - {}", path, error); + } + eprintln!(); + return Err(MlfGenerateError::InvalidLexicon { + message: format!("{} errors total", errors.len()), + }); + } + + println!("\nSuccessfully generated {} file(s)", success_count); + Ok(()) +} + +fn generate_mlf_from_json(json: &Value) -> Result { + let mut output = String::new(); + + let defs = json.get("defs").and_then(|v| v.as_object()).ok_or_else(|| { + MlfGenerateError::InvalidLexicon { + message: "Missing or invalid 'defs' field".to_string(), + } + })?; + + // Process all definitions + for (name, def) in defs { + let def_type = def.get("type").and_then(|v| v.as_str()).ok_or_else(|| { + MlfGenerateError::InvalidLexicon { + message: format!("Missing 'type' field for definition '{}'", name), + } + })?; + + match def_type { + "record" => { + let mlf = generate_record(name, def)?; + output.push_str(&mlf); + output.push('\n'); + } + "query" => { + let mlf = generate_query(name, def)?; + output.push_str(&mlf); + output.push('\n'); + } + "procedure" => { + let mlf = generate_procedure(name, def)?; + output.push_str(&mlf); + output.push('\n'); + } + "subscription" => { + let mlf = generate_subscription(name, def)?; + output.push_str(&mlf); + output.push('\n'); + } + "token" => { + let mlf = generate_token(name, def)?; + output.push_str(&mlf); + output.push('\n'); + } + "object" => { + let mlf = generate_def_type(name, def)?; + output.push_str(&mlf); + output.push('\n'); + } + _ => { + // Unknown type, skip + } + } + } + + Ok(output) +} + +fn generate_record(name: &str, def: &Value) -> Result { + let mut output = String::new(); + + // Add doc comment if present + if let Some(desc) = def.get("description").and_then(|v| v.as_str()) { + if !desc.is_empty() { + for line in desc.lines() { + output.push_str(&format!("/// {}\n", line)); + } + } + } + + // Use "main" name if present, otherwise use the definition name + let record_name = if name == "main" { + // Try to extract the last segment from the namespace ID + // This is a heuristic - we could make it better + "main" + } else { + name + }; + + output.push_str(&format!("record {} {{\n", record_name)); + + // Get the record object + let record_obj = def.get("record").and_then(|v| v.as_object()).ok_or_else(|| { + MlfGenerateError::InvalidLexicon { + message: format!("Missing 'record' field in record definition '{}'", name), + } + })?; + + let properties = record_obj + .get("properties") + .and_then(|v| v.as_object()) + .ok_or_else(|| MlfGenerateError::InvalidLexicon { + message: format!("Missing 'properties' in record '{}'", name), + })?; + + let required = record_obj + .get("required") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + }) + .unwrap_or_default(); + + for (field_name, field_def) in properties { + // Add field doc comment + if let Some(desc) = field_def.get("description").and_then(|v| v.as_str()) { + if !desc.is_empty() { + for line in desc.lines() { + output.push_str(&format!(" /// {}\n", line)); + } + } + } + + let is_required = required.contains(&field_name.as_str()); + let required_marker = if is_required { "!" } else { "" }; + + let field_type = generate_type(field_def)?; + output.push_str(&format!( + " {}{}: {},\n", + field_name, required_marker, field_type + )); + } + + output.push_str("};\n"); + Ok(output) +} + +fn generate_query(name: &str, def: &Value) -> Result { + let mut output = String::new(); + + // Add doc comment + if let Some(desc) = def.get("description").and_then(|v| v.as_str()) { + if !desc.is_empty() { + for line in desc.lines() { + output.push_str(&format!("/// {}\n", line)); + } + } + } + + let query_name = if name == "main" { "query" } else { name }; + output.push_str(&format!("query {}", query_name)); + + // Parameters + output.push('('); + if let Some(params) = def.get("parameters").and_then(|v| v.as_object()) { + let properties = params.get("properties").and_then(|v| v.as_object()); + let required = params + .get("required") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + }) + .unwrap_or_default(); + + if let Some(props) = properties { + let param_strs: Vec = props + .iter() + .map(|(param_name, param_def)| { + let is_required = required.contains(¶m_name.as_str()); + let required_marker = if is_required { "!" } else { "" }; + let param_type = generate_type(param_def).unwrap_or_else(|_| "unknown".to_string()); + + // Add doc comment inline if present + let mut result = String::new(); + if let Some(desc) = param_def.get("description").and_then(|v| v.as_str()) { + if !desc.is_empty() { + result.push_str(&format!("\n /// {}\n ", desc)); + } + } + result.push_str(&format!("{}{}: {}", param_name, required_marker, param_type)); + result + }) + .collect(); + + if !param_strs.is_empty() { + output.push_str(¶m_strs.join(",")); + } + } + } + output.push(')'); + + // Output type + if let Some(output_obj) = def.get("output").and_then(|v| v.as_object()) { + if let Some(schema) = output_obj.get("schema") { + let return_type = generate_type(schema)?; + output.push_str(&format!(": {}", return_type)); + + // Check for errors + if let Some(errors) = output_obj.get("errors").and_then(|v| v.as_object()) { + output.push_str(" | error {\n"); + for (error_name, error_def) in errors { + if let Some(desc) = error_def.get("description").and_then(|v| v.as_str()) { + if !desc.is_empty() { + output.push_str(&format!(" /// {}\n", desc)); + } + } + output.push_str(&format!(" {},\n", error_name)); + } + output.push('}'); + } + } + } + + output.push_str(";\n"); + Ok(output) +} + +fn generate_procedure(name: &str, def: &Value) -> Result { + let mut output = String::new(); + + // Add doc comment + if let Some(desc) = def.get("description").and_then(|v| v.as_str()) { + if !desc.is_empty() { + for line in desc.lines() { + output.push_str(&format!("/// {}\n", line)); + } + } + } + + let procedure_name = if name == "main" { "procedure" } else { name }; + output.push_str(&format!("procedure {}", procedure_name)); + + // Input parameters + output.push('('); + if let Some(input) = def.get("input").and_then(|v| v.as_object()) { + if let Some(schema) = input.get("schema").and_then(|v| v.as_object()) { + let properties = schema.get("properties").and_then(|v| v.as_object()); + let required = schema + .get("required") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + }) + .unwrap_or_default(); + + if let Some(props) = properties { + let param_strs: Vec = props + .iter() + .map(|(param_name, param_def)| { + let is_required = required.contains(¶m_name.as_str()); + let required_marker = if is_required { "!" } else { "" }; + let param_type = + generate_type(param_def).unwrap_or_else(|_| "unknown".to_string()); + + // Add doc comment inline if present + let mut result = String::new(); + if let Some(desc) = param_def.get("description").and_then(|v| v.as_str()) { + if !desc.is_empty() { + result.push_str(&format!("\n /// {}\n ", desc)); + } + } + result.push_str(&format!( + "{}{}: {}", + param_name, required_marker, param_type + )); + result + }) + .collect(); + + if !param_strs.is_empty() { + output.push_str(¶m_strs.join(",")); + } + } + } + } + output.push(')'); + + // Output type + if let Some(output_obj) = def.get("output").and_then(|v| v.as_object()) { + if let Some(schema) = output_obj.get("schema") { + let return_type = generate_type(schema)?; + output.push_str(&format!(": {}", return_type)); + + // Check for errors + if let Some(errors) = output_obj.get("errors").and_then(|v| v.as_object()) { + output.push_str(" | error {\n"); + for (error_name, error_def) in errors { + if let Some(desc) = error_def.get("description").and_then(|v| v.as_str()) { + if !desc.is_empty() { + output.push_str(&format!(" /// {}\n", desc)); + } + } + output.push_str(&format!(" {},\n", error_name)); + } + output.push('}'); + } + } + } + + output.push_str(";\n"); + Ok(output) +} + +fn generate_subscription(name: &str, def: &Value) -> Result { + let mut output = String::new(); + + // Add doc comment + if let Some(desc) = def.get("description").and_then(|v| v.as_str()) { + if !desc.is_empty() { + for line in desc.lines() { + output.push_str(&format!("/// {}\n", line)); + } + } + } + + let subscription_name = if name == "main" { + "subscription" + } else { + name + }; + output.push_str(&format!("subscription {}", subscription_name)); + + // Parameters + output.push('('); + if let Some(params) = def.get("parameters").and_then(|v| v.as_object()) { + let properties = params.get("properties").and_then(|v| v.as_object()); + let required = params + .get("required") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + }) + .unwrap_or_default(); + + if let Some(props) = properties { + let param_strs: Vec = props + .iter() + .map(|(param_name, param_def)| { + let is_required = required.contains(¶m_name.as_str()); + let required_marker = if is_required { "!" } else { "" }; + let param_type = generate_type(param_def).unwrap_or_else(|_| "unknown".to_string()); + + format!("{}{}: {}", param_name, required_marker, param_type) + }) + .collect(); + + if !param_strs.is_empty() { + output.push_str(¶m_strs.join(", ")); + } + } + } + output.push(')'); + + // Message types + if let Some(message) = def.get("message").and_then(|v| v.as_object()) { + if let Some(schema) = message.get("schema") { + let message_type = generate_type(schema)?; + output.push_str(&format!(": {}", message_type)); + } + } + + output.push_str(";\n"); + Ok(output) +} + +fn generate_token(name: &str, def: &Value) -> Result { + let mut output = String::new(); + + // Add doc comment + if let Some(desc) = def.get("description").and_then(|v| v.as_str()) { + if !desc.is_empty() { + for line in desc.lines() { + output.push_str(&format!("/// {}\n", line)); + } + } + } + + output.push_str(&format!("token {};\n", name)); + Ok(output) +} + +fn generate_def_type(name: &str, def: &Value) -> Result { + let mut output = String::new(); + + output.push_str(&format!("def type {} = ", name)); + let type_str = generate_type_with_indent(def, 0)?; + output.push_str(&type_str); + output.push_str(";\n"); + + Ok(output) +} + +fn generate_type_with_indent(type_def: &Value, indent_level: usize) -> Result { + let type_name = type_def.get("type").and_then(|v| v.as_str()); + + match type_name { + Some("object") => { + let indent = " ".repeat(indent_level); + let field_indent = " ".repeat(indent_level + 1); + + let mut output = String::from("{\n"); + let properties = type_def + .get("properties") + .and_then(|v| v.as_object()) + .ok_or_else(|| MlfGenerateError::InvalidLexicon { + message: "Missing 'properties' in object type".to_string(), + })?; + + let required = type_def + .get("required") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + }) + .unwrap_or_default(); + + for (field_name, field_def) in properties { + // Add field doc comment + if let Some(desc) = field_def.get("description").and_then(|v| v.as_str()) { + if !desc.is_empty() { + for line in desc.lines() { + output.push_str(&format!("{}/// {}\n", field_indent, line)); + } + } + } + + let is_required = required.contains(&field_name.as_str()); + let required_marker = if is_required { "!" } else { "" }; + let field_type = generate_type_with_indent(field_def, indent_level + 1)?; + output.push_str(&format!( + "{}{}{}: {},\n", + field_indent, field_name, required_marker, field_type + )); + } + + output.push_str(&format!("{}}}", indent)); + Ok(output) + } + _ => generate_type(type_def), + } +} + +fn generate_type(type_def: &Value) -> Result { + let type_name = type_def.get("type").and_then(|v| v.as_str()); + + match type_name { + Some("null") => Ok("null".to_string()), + Some("boolean") => Ok("boolean".to_string()), + Some("integer") => { + let mut result = "integer".to_string(); + result = apply_constraints(result, type_def); + Ok(result) + } + Some("string") => { + // Check if this is a format string that maps to a prelude type + if let Some(format) = type_def.get("format").and_then(|v| v.as_str()) { + let prelude_type = match format { + "did" => "Did", + "at-uri" => "AtUri", + "at-identifier" => "AtIdentifier", + "handle" => "Handle", + "datetime" => "Datetime", + "uri" => "Uri", + "cid" => "Cid", + "nsid" => "Nsid", + "tid" => "Tid", + "record-key" => "RecordKey", + "language" => "Language", + _ => { + // Unknown format, fall through to normal string with constraints + let mut result = "string".to_string(); + result = apply_constraints(result, type_def); + return Ok(result); + } + }; + // If it's a known prelude type with only the format constraint, use the prelude type directly + // Check if there are other constraints besides format + let has_other_constraints = type_def.get("minLength").is_some() + || type_def.get("maxLength").is_some() + || type_def.get("minGraphemes").is_some() + || type_def.get("maxGraphemes").is_some() + || type_def.get("enum").is_some() + || type_def.get("knownValues").is_some() + || type_def.get("default").is_some(); + + if !has_other_constraints { + return Ok(prelude_type.to_string()); + } + } + + let mut result = "string".to_string(); + result = apply_constraints(result, type_def); + Ok(result) + } + Some("bytes") => Ok("bytes".to_string()), + Some("blob") => { + let mut result = "blob".to_string(); + result = apply_constraints(result, type_def); + Ok(result) + } + Some("unknown") => Ok("unknown".to_string()), + Some("array") => { + let items = type_def.get("items").ok_or_else(|| { + MlfGenerateError::InvalidLexicon { + message: "Missing 'items' in array type".to_string(), + } + })?; + let item_type = generate_type(items)?; + let mut result = format!("{}[]", item_type); + result = apply_constraints(result, type_def); + Ok(result) + } + Some("object") => { + let mut output = String::from("{\n"); + let properties = type_def + .get("properties") + .and_then(|v| v.as_object()) + .ok_or_else(|| MlfGenerateError::InvalidLexicon { + message: "Missing 'properties' in object type".to_string(), + })?; + + let required = type_def + .get("required") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + }) + .unwrap_or_default(); + + for (field_name, field_def) in properties { + // Add field doc comment + if let Some(desc) = field_def.get("description").and_then(|v| v.as_str()) { + if !desc.is_empty() { + for line in desc.lines() { + output.push_str(&format!(" /// {}\n", line)); + } + } + } + + let is_required = required.contains(&field_name.as_str()); + let required_marker = if is_required { "!" } else { "" }; + let field_type = generate_type(field_def)?; + output.push_str(&format!( + " {}{}: {},\n", + field_name, required_marker, field_type + )); + } + + output.push_str(" }"); + Ok(output) + } + Some("union") => { + let refs = type_def.get("refs").and_then(|v| v.as_array()).ok_or_else(|| { + MlfGenerateError::InvalidLexicon { + message: "Missing 'refs' in union type".to_string(), + } + })?; + + let type_strs: Vec = refs + .iter() + .map(|r| generate_type(r).unwrap_or_else(|_| "unknown".to_string())) + .collect(); + + let mut result = type_strs.join(" | "); + + // Check if closed + if type_def.get("closed").and_then(|v| v.as_bool()).unwrap_or(false) { + result.push_str(" | !"); + } + + Ok(result) + } + Some("ref") => { + if let Some(ref_str) = type_def.get("ref").and_then(|v| v.as_str()) { + // Convert ref format from namespace#name to namespace.name + // Also strip leading # for local refs + let clean_ref = ref_str.trim_start_matches('#').replace('#', "."); + Ok(clean_ref) + } else { + Err(MlfGenerateError::InvalidLexicon { + message: "Missing 'ref' in ref type".to_string(), + }) + } + } + _ => Ok("unknown".to_string()), + } +} + +fn apply_constraints(mut type_str: String, type_def: &Value) -> String { + let mut constraints = Vec::new(); + + if let Some(min_length) = type_def.get("minLength").and_then(|v| v.as_i64()) { + constraints.push(format!("minLength: {}", min_length)); + } + if let Some(max_length) = type_def.get("maxLength").and_then(|v| v.as_i64()) { + constraints.push(format!("maxLength: {}", max_length)); + } + if let Some(min_graphemes) = type_def.get("minGraphemes").and_then(|v| v.as_i64()) { + constraints.push(format!("minGraphemes: {}", min_graphemes)); + } + if let Some(max_graphemes) = type_def.get("maxGraphemes").and_then(|v| v.as_i64()) { + constraints.push(format!("maxGraphemes: {}", max_graphemes)); + } + if let Some(minimum) = type_def.get("minimum").and_then(|v| v.as_i64()) { + constraints.push(format!("minimum: {}", minimum)); + } + if let Some(maximum) = type_def.get("maximum").and_then(|v| v.as_i64()) { + constraints.push(format!("maximum: {}", maximum)); + } + if let Some(format) = type_def.get("format").and_then(|v| v.as_str()) { + constraints.push(format!("format: \"{}\"", format)); + } + if let Some(enum_vals) = type_def.get("enum").and_then(|v| v.as_array()) { + let vals: Vec = enum_vals + .iter() + .filter_map(|v| v.as_str()) + .map(|s| format!("\"{}\"", s)) + .collect(); + constraints.push(format!("enum: [{}]", vals.join(", "))); + } + if let Some(known_vals) = type_def.get("knownValues").and_then(|v| v.as_array()) { + let vals: Vec = known_vals + .iter() + .filter_map(|v| v.as_str()) + .map(|s| format!("\"{}\"", s)) + .collect(); + constraints.push(format!("knownValues: [{}]", vals.join(", "))); + } + if let Some(accept) = type_def.get("accept").and_then(|v| v.as_array()) { + let mimes: Vec = accept + .iter() + .filter_map(|v| v.as_str()) + .map(|s| format!("\"{}\"", s)) + .collect(); + constraints.push(format!("accept: [{}]", mimes.join(", "))); + } + if let Some(max_size) = type_def.get("maxSize").and_then(|v| v.as_i64()) { + constraints.push(format!("maxSize: {}", max_size)); + } + if let Some(default) = type_def.get("default") { + let default_str = match default { + Value::String(s) => format!("\"{}\"", s), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + _ => "null".to_string(), + }; + constraints.push(format!("default: {}", default_str)); + } + + if !constraints.is_empty() { + type_str.push_str(" constrained {\n"); + for constraint in &constraints { + type_str.push_str(&format!(" {},\n", constraint)); + } + type_str.push_str(" }"); + } + + type_str +} diff --git a/mlf-cli/src/generate/mod.rs b/mlf-cli/src/generate/mod.rs index 1873bc9..08c24dd 100644 --- a/mlf-cli/src/generate/mod.rs +++ b/mlf-cli/src/generate/mod.rs @@ -1,2 +1,3 @@ pub mod code; pub mod lexicon; +pub mod mlf; diff --git a/mlf-cli/src/main.rs b/mlf-cli/src/main.rs index bfe4c25..24ce20b 100644 --- a/mlf-cli/src/main.rs +++ b/mlf-cli/src/main.rs @@ -71,6 +71,13 @@ enum GenerateCommands { #[arg(long, help = "Use flat file structure (e.g., app.bsky.post.ts)")] flat: bool, }, + Mlf { + #[arg(short, long, help = "Input JSON lexicon files (glob patterns supported)")] + input: Vec, + + #[arg(short, long, help = "Output directory")] + output: PathBuf, + }, } fn main() { @@ -90,6 +97,9 @@ fn main() { GenerateCommands::Code { generator, input, output, flat } => { generate::code::run(generator, input, output, flat).into_diagnostic() } + GenerateCommands::Mlf { input, output } => { + generate::mlf::run(input, output).into_diagnostic() + } }, };