nexgen 0.2.9

Generate language-specific Nexus operation bindings from WIT
//! Validate the chosen generic surface after selection and before binding.
//! Authored descriptor checks remain in `AuthoredValidationPass`; this pass
//! consumes selected IR without cloning or selecting authored alternatives.

use std::collections::BTreeMap;
use std::path::Path;

use crate::error::{Error, Result};
use crate::spec::{
    ApiSpec, ApiSpecLeaf, CompilerPass, FunctionArgsSpec, FunctionResultSpec, SelectedFamily,
};

pub(crate) struct SelectedValidationPass;

impl CompilerPass<SelectedFamily, SelectedFamily> for SelectedValidationPass {
    type Error = Error;

    fn transform_leaf(
        &mut self,
        leaf: ApiSpecLeaf<SelectedFamily>,
    ) -> Result<ApiSpecLeaf<SelectedFamily>> {
        validate_generic_model_semantics(&leaf.spec, &leaf.source_path)?;
        Ok(leaf)
    }
}

fn validate_generic_model_semantics(spec: &ApiSpec<SelectedFamily>, path: &Path) -> Result<()> {
    let invalid = |reason: String| Error::InvalidWit {
        path: path.to_path_buf(),
        reason,
    };
    for (_, record) in spec.records() {
        let parameters = spec.record_type_parameters(&record.full_name);
        let mut generated_names = BTreeMap::<String, String>::new();
        for usage in &parameters {
            if let Some(previous) = generated_names.insert(
                usage.parameter.name.clone(),
                usage.parameter.full_name.clone(),
            ) && previous != usage.parameter.full_name
            {
                return Err(invalid(format!(
                    "type parameters `{previous}` and `{}` both generate the name `{}`",
                    usage.parameter.full_name, usage.parameter.name
                )));
            }
        }
        for (field_name, function) in record.functions() {
            let uses_parameter = match &function.result {
                FunctionResultSpec::Authored(result) => !spec.type_parameters(result).is_empty(),
                FunctionResultSpec::Annotation(_) => false,
            } || match &function.args {
                FunctionArgsSpec::Varargs { prefix, .. } | FunctionArgsSpec::Fixed(prefix) => {
                    prefix
                        .iter()
                        .any(|arg| !spec.type_parameters(&arg.field_type).is_empty())
                }
            } || function
                .alternate_type
                .as_ref()
                .is_some_and(|alternate| !spec.type_parameters(alternate).is_empty());
            if uses_parameter {
                return Err(invalid(format!(
                    "record `{}` field `{field_name}` uses a type parameter in function-signature metadata, which is not supported",
                    record.full_name
                )));
            }
        }
    }

    for (_, variant) in spec.variants() {
        let parameters = spec.variant_type_parameters(&variant.full_name);
        let mut generated_names = BTreeMap::<String, String>::new();
        for usage in parameters {
            if let Some(previous) = generated_names.insert(
                usage.parameter.name.clone(),
                usage.parameter.full_name.clone(),
            ) && previous != usage.parameter.full_name
            {
                return Err(invalid(format!(
                    "variant `{}` uses type parameters `{previous}` and `{}` that both generate the name `{}`",
                    variant.full_name, usage.parameter.full_name, usage.parameter.name
                )));
            }
        }
    }

    for service in &spec.services {
        for operation in &service.operations {
            let mut generated_names = BTreeMap::<String, String>::new();
            let operation_parameters = operation
                .input_type()
                .into_iter()
                .chain(operation.output_type())
                .flat_map(|value| spec.type_parameters(value));
            for usage in operation_parameters {
                if let Some(previous) = generated_names.insert(
                    usage.parameter.name.clone(),
                    usage.parameter.full_name.clone(),
                ) && previous != usage.parameter.full_name
                {
                    return Err(invalid(format!(
                        "operation `{}` uses type parameters `{previous}` and `{}` that both generate the name `{}`",
                        operation.name, usage.parameter.full_name, usage.parameter.name
                    )));
                }
            }
        }
        for resource in &service.resources {
            let resource_is_generic = resource
                .fields
                .iter()
                .any(|field| !spec.type_parameters(&field.field_type).is_empty())
                || resource.methods.iter().any(|method| {
                    method
                        .params
                        .iter()
                        .any(|param| !spec.type_parameters(&param.field_type).is_empty())
                        || method.result.as_ref().is_some_and(|result| {
                            !spec.type_parameters(&result.result_type).is_empty()
                        })
                });
            if resource_is_generic {
                return Err(invalid(format!(
                    "resource `{}` cannot contain generic type parameters",
                    resource.name
                )));
            }

            for method in &resource.methods {
                let Some(operation_name) = &method.operation_name else {
                    continue;
                };
                let Some(operation) = service.operation(operation_name) else {
                    continue;
                };
                let operation_is_generic = operation
                    .input_type()
                    .is_some_and(|input| !spec.type_parameters(input).is_empty())
                    || operation
                        .output_type()
                        .is_some_and(|output| !spec.type_parameters(output).is_empty());
                if operation_is_generic {
                    return Err(invalid(format!(
                        "resource-bound operation `{operation_name}` cannot use generic type parameters"
                    )));
                }
            }
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::*;
    use crate::descriptors::DescriptorIndex;
    use crate::language::Language;
    use crate::planning::{AuthoredValidationPass, LanguageSelectionPass};
    use crate::spec::ApiSpecTree;

    fn validate(language: Language, wit: &str) -> Result<()> {
        let spec = crate::parser::parse_api_spec_from_wit_for_language_with_inputs(
            language,
            wit,
            PathBuf::from("inline.wit"),
            &[PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("advanced/samples/inputs/deps")],
        )?;
        let descriptors = DescriptorIndex::load_many(&[])?;
        let authored =
            AuthoredValidationPass::new(&descriptors, language).apply(ApiSpecTree::single(spec))?;
        let selected = LanguageSelectionPass::new(language)
            .apply(authored)
            .expect("language selection is infallible");
        SelectedValidationPass.apply(selected).map(|_| ())
    }

    const GENERIC_WIT: &str = r#"
package temporal:nexus@1.0.0;
world system { export generic-service; }
interface generic-service {
  use nexus:temporal-types/model@1.0.0.{placeholder};
  /// @nexus.type-parameter
  type context-t = placeholder;
  /// @nexus.type-parameter
  type output-t = placeholder;
  record inner { value: context-t, }
  record request { nested: inner, values: list<context-t>, }
  record response { context: context-t, output: output-t, }
  complete: func(request: request) -> response;
}
"#;

    #[test]
    fn accepts_generic_models() {
        validate(Language::Dotnet, GENERIC_WIT).unwrap();
    }

    #[test]
    fn rejects_generic_resources() {
        let resource = GENERIC_WIT.replace(
            "record inner { value: context-t, }",
            "resource holder { constructor(value: context-t); }\n  record inner { value: context-t, }",
        );
        assert!(
            validate(Language::Dotnet, &resource)
                .unwrap_err()
                .to_string()
                .contains("resource")
        );
    }

    #[test]
    fn rejects_generated_type_parameter_name_collisions() {
        let base = r#"
package temporal:nexus@1.0.0;
world system { export generic-service; }
interface left {
  use nexus:temporal-types/model@1.0.0.{placeholder};
  /// @nexus.type-parameter
  type value-t = placeholder;
  record payload { value: value-t, }
}
interface right {
  use nexus:temporal-types/model@1.0.0.{placeholder};
  /// @nexus.type-parameter
  type value-t = placeholder;
  record payload { value: value-t, }
}
interface generic-service {
  use left.{payload as left-payload};
  use right.{payload as right-payload};
  record request { value: left-payload, }
  record response { value: right-payload, }
  execute: func(request: request) -> response;
}
"#;
        assert!(
            validate(Language::TypeScript, base)
                .unwrap_err()
                .to_string()
                .contains("operation `Execute`")
        );

        let variant = base.replace(
            "record request { value: left-payload, }",
            "variant collision { left(left-payload), right(right-payload), }\n  record request { value: left-payload, }",
        );
        assert!(
            validate(Language::TypeScript, &variant)
                .unwrap_err()
                .to_string()
                .contains("variant `generic-service.collision`")
        );
    }
}