use miette::Diagnostic; use serde_json::Value; use std::cell::RefCell; use std::path::PathBuf; use thiserror::Error; /// A non-fatal issue surfaced by the Lexicon→MLF converter. Produced /// when the source lexicon is malformed in a way we can recover from /// (typically: missing spec-required fields we coerce to empty / /// fall-back values). Callers decide whether to print, log, or ignore. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConversionWarning { /// Namespace of the lexicon the warning came from. pub namespace: String, /// Human-readable description of what we coerced and why. pub message: String, } /// Output of [`generate_mlf_from_json`]. Carries both the rendered MLF /// and any non-fatal warnings accumulated during conversion. #[derive(Debug, Clone)] pub struct MlfGenerateOutput { pub mlf: String, pub warnings: Vec, } #[derive(Error, Debug, Diagnostic)] pub enum MlfGenerateError { #[error("Failed to read file: {path}")] #[diagnostic(code(mlf::generate::read_file))] #[allow(dead_code)] ReadFile { path: String, #[source] source: std::io::Error, }, #[error("Failed to parse JSON: {path}")] #[diagnostic(code(mlf::generate::parse_json))] #[allow(dead_code)] 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: Option, flat: bool, ) -> Result<(), MlfGenerateError> { let current_dir = std::env::current_dir().map_err(|source| MlfGenerateError::WriteOutput { path: "current directory".to_string(), source, })?; // Load mlf.toml if available let project_root = crate::config::find_project_root(¤t_dir).ok(); let config = project_root.as_ref().and_then(|root| { let config_path = root.join("mlf.toml"); crate::config::MlfConfig::load(&config_path).ok() }); // Determine output directory let output_dir = if let Some(explicit) = output_dir { explicit } else if let Some(cfg) = &config { // Find first mlf output in mlf.toml cfg.output .iter() .find(|o| o.r#type == "mlf") .map(|o| PathBuf::from(&o.directory)) .ok_or_else(|| MlfGenerateError::InvalidLexicon { message: "No mlf output configured in mlf.toml. Either add an output configuration or provide --output flag.".to_string(), })? } else { return Err(MlfGenerateError::InvalidLexicon { message: "No mlf.toml found and no --output flag provided. Either create a mlf.toml or provide --output flag.".to_string(), }); }; 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 output = match generate_mlf_from_json(&json) { Ok(output) => output, Err(e) => { errors.push((file_path.display().to_string(), format!("{:?}", e))); continue; } }; for warning in &output.warnings { eprintln!("warning ({}): {}", warning.namespace, warning.message); } let mlf_content = output.mlf; // 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(), } })?; let output_path = if flat { output_dir.join(format!("{}.mlf", namespace)) } else { // Create output path from namespace let mut path = output_dir.clone(); for segment in namespace.split('.') { path.push(segment); } if let Err(source) = std::fs::create_dir_all(&path.parent().unwrap()) { errors.push(( file_path.display().to_string(), format!("Failed to create directory: {}", source), )); continue; } path.set_extension("mlf"); path }; 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(()) } pub fn generate_mlf_from_json(json: &Value) -> Result { let mut output = String::new(); let nsid = json.get("id").and_then(|v| v.as_str()).ok_or_else(|| { MlfGenerateError::InvalidLexicon { message: "Missing 'id' field in lexicon".to_string(), } })?; let last_segment = nsid.split('.').last().unwrap_or("main"); let defs = json .get("defs") .and_then(|v| v.as_object()) .ok_or_else(|| MlfGenerateError::InvalidLexicon { message: "Missing or invalid 'defs' field".to_string(), })?; let ctx = ConversionContext { current_namespace: nsid.to_string(), local_main_name: last_segment.to_string(), warnings: RefCell::new(Vec::new()), }; // Emit a `self {}` item when the source has a top-level description // or any non-spec top-level field (`revision`, vendor `x-*`, etc.). if let Some(self_mlf) = render_self_item(json, &ctx) { output.push_str(&self_mlf); output.push('\n'); } 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), } })?; let mlf = match def_type { "record" => generate_record(name, def, &ctx)?, "query" => generate_query(name, def, &ctx)?, "procedure" => generate_procedure(name, def, &ctx)?, "subscription" => generate_subscription(name, def, &ctx)?, "token" => generate_token(name, def, &ctx)?, t if is_known_def_type(t) => generate_def_type(name, def, &ctx)?, // Unknown def type (e.g. `permission-set`): emit a // placeholder def with `@const` annotations carrying every // field, so the shape roundtrips byte-faithfully without // requiring a grammar entry for every future spec type. _ => render_unknown_def_passthrough(name, def, last_segment, &ctx), }; output.push_str(&mlf); output.push('\n'); } Ok(MlfGenerateOutput { mlf: output, warnings: ctx.warnings.into_inner(), }) } /// Spec-defined top-level fields we handle through dedicated paths. /// Anything else at the root becomes an `@const` on the emitted /// `self {}` item. const TOP_LEVEL_SPEC_FIELDS: &[&str] = &["lexicon", "id", "description", "defs", "$type"]; /// Def-kind identifiers we know how to render structurally. Anything /// else (e.g. `permission-set`) falls through to the unknown-def /// passthrough. const KNOWN_DEF_TYPES: &[&str] = &[ "record", "query", "procedure", "subscription", "token", "object", "string", "integer", "boolean", "bytes", "blob", "null", "unknown", "array", "union", "ref", "cid-link", ]; fn is_known_def_type(type_name: &str) -> bool { KNOWN_DEF_TYPES.contains(&type_name) } /// Spec-defined fields on each def kind. Any other key on the def's /// JSON object becomes an `@const` annotation on the emitted item, so /// vendor extensions (`revision`, `x-*` flags, etc.) roundtrip /// byte-faithfully. const RECORD_SPEC_FIELDS: &[&str] = &["type", "description", "key", "record"]; const QUERY_SPEC_FIELDS: &[&str] = &["type", "description", "parameters", "output", "errors"]; const PROCEDURE_SPEC_FIELDS: &[&str] = &[ "type", "description", "parameters", "input", "output", "errors", ]; const SUBSCRIPTION_SPEC_FIELDS: &[&str] = &["type", "description", "parameters", "message", "errors"]; const TOKEN_SPEC_FIELDS: &[&str] = &["type", "description"]; /// Spec-defined fields at the top of a def-type definition. Covers /// primitives (with their constraint keys), containers (array, object, /// union, ref) and unifies them all — anything outside this list on a /// def-type JSON object is treated as an extension. const DEF_TYPE_SPEC_FIELDS: &[&str] = &[ "type", "description", // Constraint keys (mirror CONSTRAINT_KEYS). "minLength", "maxLength", "minGraphemes", "maxGraphemes", "minimum", "maximum", "format", "enum", "knownValues", "accept", "maxSize", "default", "const", // Container keys. "items", "properties", "required", "nullable", "refs", "closed", "ref", ]; /// Build a `self {}` item from the top-level JSON, or `None` when /// there's nothing to emit (no description, no unknown fields). Docs /// come from top-level `description`; extension fields become `@const` /// annotations. fn render_self_item(json: &Value, ctx: &ConversionContext) -> Option { let obj = json.as_object()?; let description = obj .get("description") .and_then(|v| v.as_str()) .unwrap_or(""); let has_extension = obj .keys() .any(|k| !TOP_LEVEL_SPEC_FIELDS.contains(&k.as_str())); if description.is_empty() && !has_extension { return None; } let mut out = String::new(); for line in description.lines() { out.push_str("/// "); out.push_str(line); out.push('\n'); } for (key, value) in obj { if TOP_LEVEL_SPEC_FIELDS.contains(&key.as_str()) { continue; } warn_if_reference_shaped(ctx, key, value); out.push_str(&format!( "@const(\"{}\", {})\n", escape_string_for_mlf(key), render_json_as_mlf_literal(value) )); } out.push_str("self {}\n"); Some(out) } /// Heuristic: warn when a `@const` string value contains `#`, since that's /// the ATProto local-ref shape and the author may have intended `@reference`. /// The converter can't know intent from JSON, so it always emits `@const`; /// the warning nudges hand-review. fn warn_if_reference_shaped(ctx: &ConversionContext, key: &str, value: &Value) { let Value::String(s) = value else { return }; if !s.contains('#') { return; } ctx.warn(format!( "extension field {:?} has value {:?} which looks NSID-shaped; \ emitted as `@const` — consider `@reference` if you intend workspace \ name resolution when hand-editing the MLF", key, s )); } /// Emit a placeholder `def type X = unknown;` with `@const` annotations /// for every field — used when the def's `type` isn't in our known /// set. Keeps the lexicon's shape roundtrippable without a dedicated /// grammar entry. fn render_unknown_def_passthrough( name: &str, def: &Value, last_segment: &str, ctx: &ConversionContext, ) -> String { let obj = match def.as_object() { Some(o) => o, None => return format!("def type {} = unknown;\n", escape_name(name)), }; let mut out = String::new(); if let Some(description) = obj.get("description").and_then(|v| v.as_str()) { if !description.is_empty() { for line in description.lines() { out.push_str("/// "); out.push_str(line); out.push('\n'); } } } if name == "main" { out.push_str("@main\n"); } for (key, value) in obj { // `description` surfaced as the doc-comment block already; don't // double-emit as `@const`. Every other field — including `type` // itself — passes through as an annotation so the lexicon's // shape is preserved verbatim. if key == "description" { continue; } warn_if_reference_shaped(ctx, key, value); out.push_str(&format!( "@const(\"{}\", {})\n", escape_string_for_mlf(key), render_json_as_mlf_literal(value) )); } let def_name = if name == "main" { escape_name(last_segment) } else { escape_name(name) }; out.push_str(&format!("def type {} = unknown;\n", def_name)); out } /// Emit `@const(key, value)` annotation lines for every field on `def` /// that isn't listed in `spec_fields`. Each generator calls this after /// emitting `@main` (if applicable) and before the declaration line, /// so vendor extensions carry through in the same position the codegen /// expects to find them when emitting JSON back. fn render_extension_annotations( def: &Value, spec_fields: &[&str], ctx: &ConversionContext, ) -> String { let Some(obj) = def.as_object() else { return String::new(); }; let mut out = String::new(); for (key, value) in obj { if spec_fields.contains(&key.as_str()) { continue; } warn_if_reference_shaped(ctx, key, value); out.push_str(&format!( "@const(\"{}\", {})\n", escape_string_for_mlf(key), render_json_as_mlf_literal(value) )); } out } /// Render a JSON value as MLF source text suitable for use as an /// annotation-value literal (the second arg of `@const`). Handles every /// JSON shape; strings are quoted and escaped, objects use the /// `{ "key": value, ... }` form. fn render_json_as_mlf_literal(value: &Value) -> String { match value { Value::Null => "null".to_string(), Value::Bool(b) => b.to_string(), Value::String(s) => format!("\"{}\"", escape_string_for_mlf(s)), Value::Number(n) => { if let Some(i) = n.as_i64() { i.to_string() } else if let Some(f) = n.as_f64() { f.to_string() } else { "null".to_string() } } Value::Array(items) => { let rendered: Vec = items.iter().map(render_json_as_mlf_literal).collect(); format!("[{}]", rendered.join(", ")) } Value::Object(map) => { let rendered: Vec = map .iter() .map(|(k, v)| { format!( "\"{}\": {}", escape_string_for_mlf(k), render_json_as_mlf_literal(v) ) }) .collect(); format!("{{ {} }}", rendered.join(", ")) } } } fn escape_string_for_mlf(s: &str) -> String { s.replace('\\', "\\\\").replace('"', "\\\"") } struct ConversionContext { current_namespace: String, /// MLF-side name of this lexicon's main def. /// /// When a def is named `main` in the source JSON we rename it in /// MLF to the namespace's last segment (so `app.bsky.feed.post`'s /// `defs.main` becomes `def type post` / `record post`). That rename /// exploits MLF's implicit-main convention — a def whose name /// matches the namespace's last segment is treated as `main` /// automatically — and makes the MLF read more naturally. /// /// But sibling defs often reference the main via a local `#main` /// ref; if we naively emit those as `main` in MLF they'd point at a /// no-longer-existent def. Storing the rename here lets every /// ref-emission path rewrite `main` → `local_main_name` /// consistently, keeping the MLF internally resolvable regardless /// of what the source JSON called the main def. local_main_name: String, /// Non-fatal issues accumulated during conversion. Callers receive /// these via [`MlfGenerateOutput`] and decide what to do with them /// (print to stderr, collect for a summary, suppress). warnings: RefCell>, } impl ConversionContext { fn warn(&self, message: impl Into) { self.warnings.borrow_mut().push(ConversionWarning { namespace: self.current_namespace.clone(), message: message.into(), }); } } /// Reserved words in MLF that need to be escaped const RESERVED_WORDS: &[&str] = &[ "main", "record", "query", "procedure", "subscription", "token", "def", "type", "use", "pub", "alias", "namespace", "constrained", "error", "unit", "null", "boolean", "integer", "string", "bytes", "blob", "unknown", "array", "object", "union", "ref", ]; /// Escape a name if it's a reserved word fn escape_name(name: &str) -> String { if RESERVED_WORDS.contains(&name) { format!("`{}`", name) } else { name.to_string() } } fn generate_record( name: &str, def: &Value, ctx: &ConversionContext, ) -> 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)); } } } // Add @main annotation for "main" definitions if name == "main" { output.push_str("@main\n"); } output.push_str(&render_extension_annotations(def, RECORD_SPEC_FIELDS, ctx)); if let Some(key) = def.get("key").and_then(|v| v.as_str()) { if key != "tid" { output.push_str(&format!("@key(\"{}\")\n", escape_string_for_mlf(key))); } } // Use last segment of NSID for "main" definitions let record_name = if name == "main" { escape_name(&ctx.local_main_name) } else { escape_name(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 = string_array(record_obj, "required"); let nullable = string_array(record_obj, "nullable"); 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 required_marker = if required.contains(&field_name.as_str()) { "!" } else { "" }; let field_type = render_field_type(field_def, ctx, 1, nullable.contains(&field_name.as_str()))?; let escaped_field_name = escape_name(field_name); output.push_str(&format!( " {}{}: {},\n", escaped_field_name, required_marker, field_type )); } output.push_str("}\n"); Ok(output) } fn generate_query( name: &str, def: &Value, ctx: &ConversionContext, ) -> 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)); } } } // Add @main annotation for "main" definitions if name == "main" { output.push_str("@main\n"); } output.push_str(&render_extension_annotations(def, QUERY_SPEC_FIELDS, ctx)); let query_name = if name == "main" { escape_name(&ctx.local_main_name) } else { escape_name(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, ctx, 1) .map(Rendered::into_text) .unwrap_or_else(|_| "unknown".to_string()); let escaped_param_name = escape_name(param_name); // 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!( "{}{}: {}", escaped_param_name, required_marker, param_type )); result }) .collect(); if !param_strs.is_empty() { output.push_str(¶m_strs.join(",\n ")); } } } 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, ctx, 1)?.into_text(); output.push_str(&format!(": {}", return_type)); if let Some(errors) = def.get("errors").and_then(|v| v.as_array()) { output.push_str(" | error {\n"); for error_obj in errors { if let Some(desc) = error_obj.get("description").and_then(|v| v.as_str()) { if !desc.is_empty() { output.push_str(&format!(" /// {}\n", desc)); } } if let Some(name) = error_obj.get("name").and_then(|v| v.as_str()) { output.push_str(&format!(" {},\n", name)); } } output.push('}'); } } } output.push_str(";\n"); Ok(output) } fn generate_procedure( name: &str, def: &Value, ctx: &ConversionContext, ) -> 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)); } } } // Add @main annotation for "main" definitions if name == "main" { output.push_str("@main\n"); } output.push_str(&render_extension_annotations( def, PROCEDURE_SPEC_FIELDS, ctx, )); let procedure_name = if name == "main" { escape_name(&ctx.local_main_name) } else { escape_name(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, ctx, 1) .map(Rendered::into_text) .unwrap_or_else(|_| "unknown".to_string()); let escaped_param_name = escape_name(param_name); // 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!( "{}{}: {}", escaped_param_name, required_marker, param_type )); result }) .collect(); if !param_strs.is_empty() { output.push_str(¶m_strs.join(",\n ")); } } } } 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, ctx, 1)?.into_text(); output.push_str(&format!(": {}", return_type)); if let Some(errors) = def.get("errors").and_then(|v| v.as_array()) { output.push_str(" | error {\n"); for error_obj in errors { if let Some(desc) = error_obj.get("description").and_then(|v| v.as_str()) { if !desc.is_empty() { output.push_str(&format!(" /// {}\n", desc)); } } if let Some(name) = error_obj.get("name").and_then(|v| v.as_str()) { output.push_str(&format!(" {},\n", name)); } } output.push('}'); } } } output.push_str(";\n"); Ok(output) } fn generate_subscription( name: &str, def: &Value, ctx: &ConversionContext, ) -> 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)); } } } // Add @main annotation for "main" definitions if name == "main" { output.push_str("@main\n"); } output.push_str(&render_extension_annotations( def, SUBSCRIPTION_SPEC_FIELDS, ctx, )); let subscription_name = if name == "main" { escape_name(&ctx.local_main_name) } else { escape_name(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, ctx, 1) .map(Rendered::into_text) .unwrap_or_else(|_| "unknown".to_string()); let escaped_param_name = escape_name(param_name); format!("{}{}: {}", escaped_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, ctx, 1)?.into_text(); output.push_str(&format!(": {}", message_type)); } } output.push_str(";\n"); Ok(output) } fn generate_token( name: &str, def: &Value, ctx: &ConversionContext, ) -> 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(&render_extension_annotations(def, TOKEN_SPEC_FIELDS, ctx)); let escaped_name = escape_name(name); output.push_str(&format!("token {};\n", escaped_name)); Ok(output) } fn generate_def_type( name: &str, def: &Value, ctx: &ConversionContext, ) -> 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)); } } } // Add @main annotation for "main" definitions if name == "main" { output.push_str("@main\n"); } output.push_str(&render_extension_annotations( def, DEF_TYPE_SPEC_FIELDS, ctx, )); // Use last segment of NSID for "main" definitions // Keywords are now allowed by the parser, so just escape with backticks let def_name = if name == "main" { escape_name(&ctx.local_main_name) } else { escape_name(name) }; output.push_str(&format!("def type {} = ", def_name)); let type_str = generate_type(def, ctx, 0)?.into_text(); output.push_str(&type_str); output.push_str(";\n"); Ok(output) } /// Keys whose presence on a type definition triggers a `constrained { ... }` /// block. Single source of truth for both the "has any constraint" predicate /// and the actual rendering — keeping them in sync is a correctness /// requirement (see issue #3). const CONSTRAINT_KEYS: &[&str] = &[ "minLength", "maxLength", "minGraphemes", "maxGraphemes", "minimum", "maximum", "format", "enum", "knownValues", "accept", "maxSize", "default", "const", ]; /// Render a single `key: value` constraint line. Returns `None` when the /// JSON value doesn't match the expected shape for that key. fn render_constraint(key: &str, value: &Value) -> Option { match key { "minLength" | "maxLength" | "minGraphemes" | "maxGraphemes" | "minimum" | "maximum" | "maxSize" => value.as_i64().map(|n| format!("{}: {}", key, n)), "format" => value.as_str().map(|s| format!("format: \"{}\"", s)), "enum" | "knownValues" | "accept" => value.as_array().map(|arr| { let vals: Vec = arr .iter() .filter_map(|v| v.as_str()) .map(|s| format!("\"{}\"", s)) .collect(); format!("{}: [{}]", key, vals.join(", ")) }), "default" | "const" => { let formatted = match value { Value::String(s) => format!("\"{}\"", s), Value::Number(n) => n.to_string(), Value::Bool(b) => b.to_string(), _ => "null".to_string(), }; Some(format!("{}: {}", key, formatted)) } _ => None, } } /// Render the `constrained { ... }` suffix attached to a base type, or /// `None` when the type has no constraints. `indent_level` is the depth of /// the line on which the base type begins; the opening `constrained {` sits /// on that line and the body is indented one further level. fn render_constraints_block(type_def: &Value, indent_level: usize) -> Option { let obj = type_def.as_object()?; let lines: Vec = CONSTRAINT_KEYS .iter() .filter_map(|k| obj.get(*k).and_then(|v| render_constraint(k, v))) .collect(); if lines.is_empty() { return None; } let body_indent = " ".repeat(indent_level + 1); let close_indent = " ".repeat(indent_level); let mut out = String::from(" constrained {\n"); for line in &lines { out.push_str(&body_indent); out.push_str(line); out.push_str(",\n"); } out.push_str(&close_indent); out.push('}'); Some(out) } /// The syntactic shape of a rendered type's top-level form. Used to decide /// whether parenthesization is required when the type is reused as the /// base of a postfix operator (currently: the element type of `[]`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Shape { /// A primary/atomic form. Safe in any context. Atom, /// A union `a | b [ | !]`. Must be parenthesized as an array element. Union, /// Ends with `constrained { ... }`. Must be parenthesized as an array /// element so that `[]` binds to the whole thing rather than forming an /// illegal `constrained {...}[]`. Constrained, } /// A rendered type, paired with its syntactic shape so the caller can /// parenthesize it correctly when composing. struct Rendered { text: String, shape: Shape, } impl Rendered { fn atom(text: impl Into) -> Self { Self { text: text.into(), shape: Shape::Atom, } } /// Render `base` plus any constraints from `type_def`. If no constraints /// are present the result is atomic; otherwise it is `Constrained`. fn with_constraints(base: &str, type_def: &Value, indent_level: usize) -> Self { match render_constraints_block(type_def, indent_level) { Some(suffix) => Self { text: format!("{}{}", base, suffix), shape: Shape::Constrained, }, None => Self::atom(base.to_string()), } } fn into_text(self) -> String { self.text } /// Consume into text suitable as the base of an array suffix `[]`, /// wrapping in parentheses when the shape would otherwise bind /// incorrectly. fn into_array_base(self) -> String { match self.shape { Shape::Atom => self.text, Shape::Union | Shape::Constrained => format!("({})", self.text), } } } /// Map a Lexicon string `format` to its MLF prelude type, if any. fn prelude_type_for_format(format: &str) -> Option<&'static str> { match format { "did" => Some("Did"), "at-uri" => Some("AtUri"), "at-identifier" => Some("AtIdentifier"), "handle" => Some("Handle"), "datetime" => Some("Datetime"), "uri" => Some("Uri"), "cid" => Some("Cid"), "nsid" => Some("Nsid"), "tid" => Some("Tid"), "record-key" => Some("RecordKey"), "language" => Some("Language"), _ => None, } } /// Convert a Lexicon type definition into an MLF type expression. /// /// `indent_level` is the indentation of the line on which the type's first /// character appears; multi-line constructs (constraint blocks, inline /// objects) use it to compute proper body/close indentation. fn generate_type( type_def: &Value, ctx: &ConversionContext, indent_level: usize, ) -> Result { let type_name = type_def.get("type").and_then(|v| v.as_str()); // Primitive types that the ATProto spec defines with constraint fields // are routed through `with_constraints`; it returns an atom when no // constraint keys are present, so it's equally valid for inputs // written without any. The spec permits: // boolean — default, const // integer — minimum, maximum, enum, default, const // string — format, min/maxLength, min/maxGraphemes, knownValues, // enum, default, const (via `render_string`) // bytes — minLength, maxLength // blob — accept, maxSize // `null` and `unknown` have no defined constraint fields and stay // atomic. match type_name { Some("null") => Ok(Rendered::atom("null")), Some("boolean") => Ok(Rendered::with_constraints( "boolean", type_def, indent_level, )), Some("integer") => Ok(Rendered::with_constraints( "integer", type_def, indent_level, )), Some("string") => Ok(render_string(type_def, indent_level)), Some("bytes") => Ok(Rendered::with_constraints("bytes", type_def, indent_level)), Some("blob") => Ok(Rendered::with_constraints("blob", type_def, indent_level)), Some("unknown") => Ok(Rendered::atom("unknown")), Some("array") => render_array(type_def, ctx, indent_level), Some("object") => render_object_inline(type_def, ctx, indent_level), Some("union") => render_union(type_def, ctx, indent_level), Some("ref") => render_ref(type_def, ctx).map(Rendered::atom), _ => Ok(Rendered::atom("unknown")), } } fn render_string(type_def: &Value, indent_level: usize) -> Rendered { // A `string` with only a recognised `format` collapses to a prelude alias // (e.g. `Did`, `Handle`). Any additional constraint forces the long form. if let Some(format) = type_def.get("format").and_then(|v| v.as_str()) { if let Some(prelude) = prelude_type_for_format(format) { let has_other = CONSTRAINT_KEYS .iter() .filter(|k| **k != "format") .any(|k| type_def.get(*k).is_some()); if !has_other { return Rendered::atom(prelude); } } } Rendered::with_constraints("string", type_def, indent_level) } fn render_array( type_def: &Value, ctx: &ConversionContext, indent_level: usize, ) -> Result { // `items` is spec-required on `array` types. Handle a missing field // leniently — fall back to `unknown` as the item type, warn — to // match the lenient handling of `object` without `properties` and // empty-refs unions. Malformed-but-publishable lexicons stay // convertible instead of blocking the whole authority. let fallback_items = Value::Object(serde_json::Map::from_iter([( "type".to_string(), Value::String("unknown".to_string()), )])); let items = match type_def.get("items") { Some(v) => v, None => { ctx.warn( "array type is missing `items` field; \ treating item type as `unknown` (ATProto spec lists `items` as required)", ); &fallback_items } }; let item = generate_type(items, ctx, indent_level)?; let base = format!("{}[]", item.into_array_base()); Ok(Rendered::with_constraints(&base, type_def, indent_level)) } fn render_object_inline( type_def: &Value, ctx: &ConversionContext, indent_level: usize, ) -> Result { let obj = type_def .as_object() .ok_or_else(|| MlfGenerateError::InvalidLexicon { message: "Object type definition is not a JSON object".to_string(), })?; // The spec lists `properties` as required on object types, but real- // world lexicons (e.g. blog.pckt.richtext.facet marker defs) publish // empty objects with no `properties` field at all. Accept that // leniently — semantically missing `properties` is equivalent to // `properties: {}` — but surface a warning so authors know their // lexicon isn't strictly spec-compliant. let empty_map = serde_json::Map::new(); let properties = match obj.get("properties") { Some(v) => v .as_object() .ok_or_else(|| MlfGenerateError::InvalidLexicon { message: "`properties` in object type must be a JSON object".to_string(), })?, None => { ctx.warn( "object type is missing `properties` field; \ treating as empty (ATProto spec lists `properties` as required)", ); &empty_map } }; let required = string_array(obj, "required"); let nullable = string_array(obj, "nullable"); let field_indent = " ".repeat(indent_level + 1); let close_indent = " ".repeat(indent_level); let mut out = String::from("{\n"); for (field_name, field_def) in properties { if let Some(desc) = field_def.get("description").and_then(|v| v.as_str()) { if !desc.is_empty() { for line in desc.lines() { out.push_str(&field_indent); out.push_str("/// "); out.push_str(line); out.push('\n'); } } } let marker = if required.contains(&field_name.as_str()) { "!" } else { "" }; let field_type = render_field_type( field_def, ctx, indent_level + 1, nullable.contains(&field_name.as_str()), )?; out.push_str(&field_indent); out.push_str(&escape_name(field_name)); out.push_str(marker); out.push_str(": "); out.push_str(&field_type); out.push_str(",\n"); } out.push_str(&close_indent); out.push('}'); Ok(Rendered::atom(out)) } /// Read a JSON string-array property (e.g. `"required": ["a", "b"]`) as a /// slice of `&str` values, defaulting to empty when absent or malformed. fn string_array<'a>(obj: &'a serde_json::Map, key: &str) -> Vec<&'a str> { obj.get(key) .and_then(|v| v.as_array()) .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) .unwrap_or_default() } /// Render a field's MLF type, appending `| null` when the field is listed /// in the enclosing object's `nullable` array. Lexicon encodes nullability /// as a sibling property of `properties`; MLF has no field-level nullable /// marker, so we express it as a union with `null`. fn render_field_type( field_def: &Value, ctx: &ConversionContext, indent_level: usize, nullable: bool, ) -> Result { let rendered = generate_type(field_def, ctx, indent_level)?; if nullable { Ok(format!("{} | null", rendered.into_text())) } else { Ok(rendered.into_text()) } } fn render_union( type_def: &Value, ctx: &ConversionContext, _indent_level: usize, ) -> Result { 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 closed = type_def .get("closed") .and_then(|v| v.as_bool()) .unwrap_or(false); // An open union with zero refs is malformed per the ATProto spec — it // names no valid types at all. Real-world lexicons (e.g. // blog.pckt.content) publish this shape anyway; rather than refusing // to convert we fall back to `unknown` with a warning, matching the // lenient handling of `object` types without `properties`. if refs.is_empty() && !closed { ctx.warn( "open union has no `refs`; emitting `unknown` as a placeholder \ (ATProto spec lists `refs` as required on union types)", ); return Ok(Rendered::atom("unknown")); } // Each entry in `refs` is a string (local `#defName` or external // `namespace#defName`), not a nested type object. let parts: Vec = refs .iter() .map(|r| { r.as_str() .map(|s| resolve_ref_string(s, ctx)) .unwrap_or_else(|| "unknown".to_string()) }) .collect(); let mut text = parts.join(" | "); if closed { text.push_str(" | !"); } // A single-member open union renders as just that member — still an atom. // Anything with a visible `|` becomes Union for postfix purposes. let shape = if parts.len() >= 2 || closed { Shape::Union } else { Shape::Atom }; Ok(Rendered { text, shape }) } fn render_ref(type_def: &Value, ctx: &ConversionContext) -> Result { let ref_str = type_def .get("ref") .and_then(|v| v.as_str()) .ok_or_else(|| MlfGenerateError::InvalidLexicon { message: "Missing 'ref' in ref type".to_string(), })?; Ok(resolve_ref_string(ref_str, ctx)) } /// Resolve a Lexicon ref string (e.g. `#defName`, `namespace#defName`, /// or a bare NSID) into the MLF type name to emit in source. /// /// Local refs to this lexicon's `main` def get rewritten to the /// renamed def name held in [`ConversionContext::local_main_name`] — /// see that field's docs for the full rationale. fn resolve_ref_string(ref_str: &str, ctx: &ConversionContext) -> String { if let Some(stripped) = ref_str.strip_prefix('#') { return rewrite_local_main(stripped, ctx); } if let Some((namespace, def_name)) = ref_str.split_once('#') { if namespace == ctx.current_namespace { return rewrite_local_main(def_name, ctx); } return format!("{}.{}", namespace, def_name); } // Bare NSID — implicit main of the named lexicon. If it names the // current lexicon (rare but possible), rewrite to the local main // name; otherwise leave as-is. if ref_str == ctx.current_namespace { return ctx.local_main_name.clone(); } ref_str.to_string() } /// Rewrite a bare def name to account for the lexicon's main-def /// rename. A def name of `"main"` in a lexicon whose main has been /// renamed to its namespace's last segment becomes that segment /// instead; any other name passes through unchanged. fn rewrite_local_main(def_name: &str, ctx: &ConversionContext) -> String { if def_name == "main" { ctx.local_main_name.clone() } else { def_name.to_string() } }