mod planning;
pub mod add_rpc;
pub mod descriptors;
pub mod error;
pub mod generator;
pub mod json_schema;
pub mod language;
pub mod nexgen_config;
pub mod parser;
pub mod spec;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use descriptors::DescriptorIndex;
use error::Result;
use generator::{GenerateFilesOptions, GeneratedFiles, GeneratedOutputLayout, GenerationMode};
use language::Language;
use spec::{ApiSpecTree, CompilerPass};
pub use add_rpc::{
AddMessageRequest, AddRpcRequest, add_message_to_file, add_message_to_string, add_rpc_to_file,
add_rpc_to_string,
};
pub struct GenerateRequest {
pub config: nexgen_config::NexgenConfig,
pub language: Language,
pub input_paths: Vec<PathBuf>,
pub support_package: Option<String>,
pub descriptor_paths: Vec<PathBuf>,
pub output_path: PathBuf,
pub format: bool,
pub java_package_name: Option<String>,
pub ts_date_time_types: generator::TsDateTimeTypes,
}
pub fn generate_to_file(request: &GenerateRequest) -> Result<()> {
let _config_scope = nexgen_config::scope(request.config);
if absolute_output_path(&request.output_path)?
.file_name()
.is_none()
{
return Err(error::Error::OutputPathIsRoot {
path: request.output_path.clone(),
});
}
if parser::is_wit_input(&request.input_paths)?
&& matches!(
request.language,
Language::Dotnet | Language::Go | Language::Python | Language::TypeScript
)
&& request.support_package.as_deref().is_none_or(str::is_empty)
{
return Err(error::Error::MissingSupportPackage {
language: request.language,
});
}
let tree = parser::load_api_spec_tree_for_language_with_inputs(
request.language,
&request.input_paths,
)?;
let descriptors = DescriptorIndex::load_many(&request.descriptor_paths)?;
let options = GenerateFilesOptions {
go_output_dir_name: if request.language == Language::Go {
output_dir_name(&request.output_path)?
} else {
String::new()
},
java_package_root: if request.language == Language::Java {
Some(resolve_java_package(request)?)
} else {
None
},
ts_date_time_types: request.ts_date_time_types,
support_package: request.support_package.clone(),
};
let generated = compile_tree_to_files(request.language, tree, &descriptors, options)?;
print_warnings(&generated);
write_generated_files(&request.output_path, &generated)?;
if request.format {
format_generated_file(request.language, &request.output_path)?;
}
Ok(())
}
pub(crate) fn compile_tree_to_files(
language: Language,
authored_tree: ApiSpecTree,
descriptors: &DescriptorIndex,
options: GenerateFilesOptions,
) -> Result<GeneratedFiles> {
let mode = nexgen_config::current().mode;
let authored_tree =
planning::AuthoredValidationPass::new(descriptors, language).apply(authored_tree)?;
let selected_tree = planning::LanguageSelectionPass::new(language)
.apply(authored_tree)
.expect("language selection is infallible");
let resource_bound_tree = planning::ResourceResolutionPass::new(
descriptors,
match mode {
GenerationMode::NativeApi => planning::PlanningMode::NativeApi,
GenerationMode::DefinitionsOnly => planning::PlanningMode::DefinitionsOnly,
},
)
.apply(selected_tree)?;
let operation_bound_tree = planning::OperationBindingPass::new().apply(resource_bound_tree)?;
let operation_lowered_tree =
planning::OperationLoweringPass::new().apply(operation_bound_tree)?;
let type_planning =
planning::TypePlanningPass::new(&operation_lowered_tree, descriptors, language);
let planned_tree = type_planning.apply(operation_lowered_tree)?;
let planned_tree = planning::ReachabilityPass::new().apply(planned_tree)?;
let name_resolution = planning::EmittedNameResolutionPass::new(language, &planned_tree)?;
let generator_ready_tree = name_resolution.apply(planned_tree)?;
generator::generate_files_from_planned_tree(language, &generator_ready_tree, options)
}
fn output_dir_name(output_path: &Path) -> Result<String> {
let resolved = absolute_output_path(output_path)?;
let name = resolved
.file_name()
.ok_or_else(|| error::Error::OutputPathIsRoot {
path: output_path.to_path_buf(),
})?;
name.to_str()
.map(str::to_string)
.ok_or_else(|| error::Error::OutputPathNotUtf8 {
path: output_path.to_path_buf(),
})
}
fn absolute_output_path(output_path: &Path) -> Result<PathBuf> {
let absolute = if output_path.is_absolute() {
output_path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|source| error::Error::ReadFile {
path: PathBuf::from("."),
source,
})?
.join(output_path)
};
Ok(normalize_path(&absolute))
}
fn normalize_path(path: &Path) -> PathBuf {
let mut normalized = Vec::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir
if matches!(normalized.last(), Some(std::path::Component::Normal(_))) =>
{
normalized.pop();
}
other => normalized.push(other),
}
}
normalized.into_iter().collect()
}
fn resolve_java_package(request: &GenerateRequest) -> Result<String> {
let package_name = request
.java_package_name
.as_deref()
.ok_or(error::Error::JavaPackageNameMissing)?;
let output_dir_name = output_dir_name(&request.output_path)?;
let last_segment = package_name.rsplit('.').next().unwrap_or(package_name);
if last_segment != output_dir_name {
return Err(error::Error::JavaPackageNameMismatch {
package_name: package_name.to_string(),
last_segment: last_segment.to_string(),
output_dir_name,
});
}
Ok(package_name.to_string())
}
fn format_generated_file(language: Language, output_path: &Path) -> Result<()> {
if language == Language::Java {
return Ok(());
}
let (program, args) = formatter_command(language, output_path)?;
let command = format_formatter_command(program, &args);
let status = Command::new(program)
.args(&args)
.status()
.map_err(|source| error::Error::RunFormatter {
path: output_path.to_path_buf(),
command: command.clone(),
source,
})?;
if !status.success() {
return Err(error::Error::FormatterFailed {
path: output_path.to_path_buf(),
command,
status,
});
}
Ok(())
}
fn print_warnings(generated: &GeneratedFiles) {
for warning in &generated.warnings {
eprintln!("warning: {warning}");
}
}
fn write_generated_files(output_path: &Path, generated: &GeneratedFiles) -> Result<()> {
match generated.layout {
GeneratedOutputLayout::SingleFile => {
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent).map_err(|source| error::Error::WriteFile {
path: parent.to_path_buf(),
source,
})?;
}
fs::write(
output_path,
generated
.single_file_contents()
.expect("single-file output should contain one file"),
)
.map_err(|source| error::Error::WriteFile {
path: output_path.to_path_buf(),
source,
})?;
}
GeneratedOutputLayout::Directory => {
if output_path.is_file() {
return Err(error::Error::OutputPathExists {
path: output_path.to_path_buf(),
});
}
fs::create_dir_all(output_path).map_err(|source| error::Error::WriteFile {
path: output_path.to_path_buf(),
source,
})?;
for (relative_path, contents) in &generated.files {
let path = output_path.join(relative_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|source| error::Error::WriteFile {
path: parent.to_path_buf(),
source,
})?;
}
fs::write(&path, contents).map_err(|source| error::Error::WriteFile {
path: path.clone(),
source,
})?;
}
}
}
Ok(())
}
fn formatter_command(
language: Language,
output_path: &Path,
) -> Result<(&'static str, Vec<String>)> {
let output_path = output_path.to_string_lossy().into_owned();
match language {
Language::Go => Ok(("gofmt", vec!["-w".to_string(), output_path])),
Language::Python => Ok((
"ruff",
vec![
"format".to_string(),
"--line-length".to_string(),
"88".to_string(),
output_path,
],
)),
Language::TypeScript => Ok(("prettier", vec!["--write".to_string(), output_path])),
_ => Err(error::Error::UnsupportedLanguage { language }),
}
}
fn format_formatter_command(program: &str, args: &[String]) -> String {
std::iter::once(program)
.chain(args.iter().map(String::as_str))
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::path::PathBuf;
use super::{
GenerateRequest, format_formatter_command, formatter_command, generate_to_file,
output_dir_name, resolve_java_package,
};
use crate::language::Language;
fn java_request(output_path: &str, java_package_name: Option<&str>) -> GenerateRequest {
GenerateRequest {
config: Default::default(),
language: Language::Java,
input_paths: Vec::new(),
support_package: None,
descriptor_paths: Vec::new(),
output_path: PathBuf::from(output_path),
format: false,
java_package_name: java_package_name.map(str::to_string),
ts_date_time_types: Default::default(),
}
}
#[test]
fn wit_requires_support_package_before_reading_input() {
for language in [
Language::Dotnet,
Language::Go,
Language::Python,
Language::TypeScript,
] {
let mut request = java_request("/tmp/example", None);
request.language = language;
request.input_paths = vec![PathBuf::from("missing.wit")];
assert!(matches!(
generate_to_file(&request).unwrap_err(),
crate::error::Error::MissingSupportPackage { language: missing } if missing == language
));
}
}
#[test]
fn json_schema_does_not_require_a_support_package() {
let temp = tempfile::tempdir().unwrap();
let input =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("samples/schemas/chat.nexusrpc.yaml");
for language in [
Language::Dotnet,
Language::Go,
Language::Python,
Language::TypeScript,
] {
let mut request = java_request("/tmp/example", None);
request.language = language;
request.input_paths = vec![input.clone()];
request.output_path = temp.path().join(language.as_str());
generate_to_file(&request).unwrap();
assert!(request.output_path.exists());
}
}
#[test]
fn chooses_python_formatter_command() {
let (program, args) = formatter_command(Language::Python, Path::new("output")).unwrap();
assert_eq!(program, "ruff");
assert_eq!(args, vec!["format", "--line-length", "88", "output"]);
assert_eq!(
format_formatter_command(program, &args),
"ruff format --line-length 88 output"
);
}
#[test]
fn chooses_typescript_formatter_command() {
let (program, args) = formatter_command(Language::TypeScript, Path::new("output")).unwrap();
assert_eq!(program, "prettier");
assert_eq!(args, vec!["--write", "output"]);
assert_eq!(
format_formatter_command(program, &args),
"prettier --write output"
);
}
#[test]
fn output_dir_name_resolves_plain_absolute_path() {
assert_eq!(output_dir_name(Path::new("/tmp/foo/bar")).unwrap(), "bar");
}
#[test]
fn output_dir_name_collapses_trailing_current_dir() {
assert_eq!(output_dir_name(Path::new("/tmp/foo/bar/.")).unwrap(), "bar");
}
#[test]
fn output_dir_name_collapses_trailing_parent_dir() {
assert_eq!(
output_dir_name(Path::new("/tmp/foo/bar/..")).unwrap(),
"foo"
);
}
#[test]
fn output_dir_name_collapses_internal_parent_dir() {
assert_eq!(
output_dir_name(Path::new("/tmp/foo/../bar")).unwrap(),
"bar"
);
}
#[test]
fn output_dir_name_rejects_filesystem_root() {
assert!(matches!(
output_dir_name(Path::new("/")).unwrap_err(),
crate::error::Error::OutputPathIsRoot { path } if path == Path::new("/")
));
}
#[test]
fn resolve_java_package_accepts_matching_last_segment() {
let request = java_request("/tmp/foo/chat", Some("json_schema.definitions.chat"));
assert_eq!(
resolve_java_package(&request).unwrap(),
"json_schema.definitions.chat"
);
}
#[test]
fn resolve_java_package_accepts_single_segment_matching_directory() {
let request = java_request("/tmp/foo/chat", Some("chat"));
assert_eq!(resolve_java_package(&request).unwrap(), "chat");
}
#[test]
fn resolve_java_package_rejects_mismatched_last_segment() {
let request = java_request("/tmp/foo/chat", Some("com.example.wrong"));
assert!(matches!(
resolve_java_package(&request).unwrap_err(),
crate::error::Error::JavaPackageNameMismatch {
package_name,
last_segment,
output_dir_name,
} if package_name == "com.example.wrong"
&& last_segment == "wrong"
&& output_dir_name == "chat"
));
}
#[test]
fn resolve_java_package_requires_package_name() {
let request = java_request("/tmp/foo/chat", None);
assert!(matches!(
resolve_java_package(&request).unwrap_err(),
crate::error::Error::JavaPackageNameMissing
));
}
}