diff --git a/mlf-codegen/src/lib.rs b/mlf-codegen/src/lib.rs index 9b2f68c..a3e0465 100644 --- a/mlf-codegen/src/lib.rs +++ b/mlf-codegen/src/lib.rs @@ -114,127 +114,41 @@ fn get_encoding_annotation(annotations: &[Annotation], param_name: &str) -> Opti pub fn generate_lexicon(namespace: &str, lexicon: &Lexicon, workspace: &Workspace) -> Value { let usage_counts = analyze_type_usage(lexicon); - - // Extract the last segment of the namespace to determine main - let namespace_parts: Vec<&str> = namespace.split('.').collect(); - let expected_main_name = namespace_parts.last().copied().unwrap_or(""); - let is_defs_namespace = expected_main_name == "defs"; - - // Count main-eligible items (records, queries, procedures, subscriptions, def types) without @main - let main_eligible_items: Vec<&Item> = lexicon.items.iter() - .filter(|item| { - matches!(item, Item::Record(_) | Item::Query(_) | Item::Procedure(_) | Item::Subscription(_) | Item::DefType(_)) - }) - .collect(); - - let main_eligible_count = main_eligible_items.len(); - - // Check if any item has @main annotation - let has_explicit_main = main_eligible_items.iter().any(|item| { - match item { - Item::Record(r) => has_main_annotation(&r.annotations), - Item::Query(q) => has_main_annotation(&q.annotations), - Item::Procedure(p) => has_main_annotation(&p.annotations), - Item::Subscription(s) => has_main_annotation(&s.annotations), - Item::DefType(d) => has_main_annotation(&d.annotations), - _ => false, - } - }); + let eligibility = MainEligibility::for_lexicon(namespace, lexicon); let mut defs = Map::new(); for item in &lexicon.items { match item { Item::Record(record) => { - let record_json = generate_record_json(record, &usage_counts, workspace, namespace); - - // Check if this should be main - let is_main = if has_explicit_main { - // If @main is used explicitly, only that item is main - has_main_annotation(&record.annotations) - } else { - // Otherwise use heuristics: single item or name matches namespace - main_eligible_count == 1 || (!is_defs_namespace && record.name.name == expected_main_name) - }; - - if is_main { - defs.insert("main".to_string(), record_json); - } else { - defs.insert(record.name.name.clone(), record_json); - } + let value = generate_record_json(record, &usage_counts, workspace, namespace); + insert_def(&mut defs, &record.name.name, eligibility.is_main(&record.name.name, &record.annotations), value); } Item::Query(query) => { - let query_json = generate_query_json(query, &usage_counts, workspace, namespace); - - let is_main = if has_explicit_main { - has_main_annotation(&query.annotations) - } else { - main_eligible_count == 1 || (!is_defs_namespace && query.name.name == expected_main_name) - }; - - if is_main { - defs.insert("main".to_string(), query_json); - } else { - defs.insert(query.name.name.clone(), query_json); - } + let value = generate_query_json(query, &usage_counts, workspace, namespace); + insert_def(&mut defs, &query.name.name, eligibility.is_main(&query.name.name, &query.annotations), value); } Item::Procedure(procedure) => { - let procedure_json = generate_procedure_json(procedure, &usage_counts, workspace, namespace); - - let is_main = if has_explicit_main { - has_main_annotation(&procedure.annotations) - } else { - main_eligible_count == 1 || (!is_defs_namespace && procedure.name.name == expected_main_name) - }; - - if is_main { - defs.insert("main".to_string(), procedure_json); - } else { - defs.insert(procedure.name.name.clone(), procedure_json); - } + let value = generate_procedure_json(procedure, &usage_counts, workspace, namespace); + insert_def(&mut defs, &procedure.name.name, eligibility.is_main(&procedure.name.name, &procedure.annotations), value); } Item::Subscription(subscription) => { - let subscription_json = generate_subscription_json(subscription, &usage_counts, workspace, namespace); - - let is_main = if has_explicit_main { - has_main_annotation(&subscription.annotations) - } else { - main_eligible_count == 1 || (!is_defs_namespace && subscription.name.name == expected_main_name) - }; - - if is_main { - defs.insert("main".to_string(), subscription_json); - } else { - defs.insert(subscription.name.name.clone(), subscription_json); - } + let value = generate_subscription_json(subscription, &usage_counts, workspace, namespace); + insert_def(&mut defs, &subscription.name.name, eligibility.is_main(&subscription.name.name, &subscription.annotations), value); } Item::DefType(def_type) => { - let def_type_json = generate_def_type_json(def_type, &usage_counts, workspace, namespace); - - // Check if this should be main - let is_main = if has_explicit_main { - has_main_annotation(&def_type.annotations) - } else { - main_eligible_count == 1 || (!is_defs_namespace && def_type.name.name == expected_main_name) - }; - - if is_main { - defs.insert("main".to_string(), def_type_json); - } else { - defs.insert(def_type.name.name.clone(), def_type_json); - } - } - Item::InlineType(_) => { - // Inline types are never added to defs - they expand at point of use - // TODO: inline expansion will be handled by workspace/cross-file resolution + let value = generate_def_type_json(def_type, &usage_counts, workspace, namespace); + insert_def(&mut defs, &def_type.name.name, eligibility.is_main(&def_type.name.name, &def_type.annotations), value); } Item::Token(token) => { - let token_json = json!({ - "type": "token", - "description": extract_docs(&token.docs) - }); - defs.insert(token.name.name.clone(), token_json); + let mut token_obj = Map::new(); + token_obj.insert("type".to_string(), json!("token")); + insert_opt_str(&mut token_obj, "description", &extract_docs(&token.docs)); + defs.insert(token.name.name.clone(), Value::Object(token_obj)); } + // Inline types never appear in `defs` — they expand at their point + // of use. Other item kinds (use statements, namespace blocks) are + // structural and not emitted into the lexicon output. _ => {} } } @@ -247,6 +161,81 @@ pub fn generate_lexicon(namespace: &str, lexicon: &Lexicon, workspace: &Workspac Value::Object(root) } +/// Decides whether a given def should be emitted under the key `main` or +/// under its own name. The rules are lexicon-level — they depend on +/// whether the namespace is a `.defs` container, whether any item carries +/// an explicit `@main`, and how many main-eligible items exist — so we +/// compute the context once and consult it per item. +struct MainEligibility { + /// True when at least one item has `@main`; in that case only + /// annotated items are main, and all heuristics are skipped. + has_explicit_main: bool, + /// Total count of items eligible to be main. + eligible_count: usize, + /// The last segment of the namespace (e.g. `profile` for + /// `app.bsky.actor.profile`). Used for the "name matches namespace" + /// heuristic. + expected_main_name: String, + /// True when the namespace ends in `.defs`. By convention these + /// lexicons are containers for named defs and never have a main + /// entry; heuristic promotion is suppressed here to preserve that + /// convention on roundtrip. + is_defs_namespace: bool, +} + +impl MainEligibility { + fn for_lexicon(namespace: &str, lexicon: &Lexicon) -> Self { + let expected_main_name = namespace.rsplit('.').next().unwrap_or("").to_string(); + let is_defs_namespace = expected_main_name == "defs"; + + let headers: Vec<_> = lexicon.items.iter().filter_map(item_header).collect(); + let eligible_count = headers.len(); + let has_explicit_main = headers.iter().any(|(_, ann)| has_main_annotation(ann)); + + Self { + has_explicit_main, + eligible_count, + expected_main_name, + is_defs_namespace, + } + } + + fn is_main(&self, name: &str, annotations: &[Annotation]) -> bool { + if self.has_explicit_main { + return has_main_annotation(annotations); + } + // `.defs` lexicons are pure containers — never promote a def to + // `main`, not even when it's the only one in the file. + if self.is_defs_namespace { + return false; + } + self.eligible_count == 1 || name == self.expected_main_name + } +} + +/// Unified accessor for the main-eligible item kinds. Returns the def's +/// declared name and annotations — the only facts the main-promotion +/// decision needs from any given item. Returns `None` for item kinds +/// that never participate in `main` eligibility (e.g. inline types, +/// tokens, use statements). +fn item_header(item: &Item) -> Option<(&str, &[Annotation])> { + match item { + Item::Record(r) => Some((&r.name.name, &r.annotations)), + Item::Query(q) => Some((&q.name.name, &q.annotations)), + Item::Procedure(p) => Some((&p.name.name, &p.annotations)), + Item::Subscription(s) => Some((&s.name.name, &s.annotations)), + Item::DefType(d) => Some((&d.name.name, &d.annotations)), + _ => None, + } +} + +/// Insert a def into the lexicon's `defs` map under the canonical key — +/// `"main"` for the main def, otherwise the def's own name. +fn insert_def(defs: &mut Map, name: &str, is_main: bool, value: Value) { + let key = if is_main { "main".to_string() } else { name.to_string() }; + defs.insert(key, value); +} + fn analyze_type_usage(lexicon: &Lexicon) -> HashMap { let mut usage_counts = HashMap::new();