openusd_build/lib.rs
1//! Generates Rust schema views from OpenUSD `schema.usda` files, with the
2//! metadata fields and kinds a `plugInfo.json` declares beside them.
3//!
4//! This is the Rust answer to `usdGenSchema`, shaped as a build dependency: a
5//! consumer describes its schema libraries in `build.rs`, and the generated
6//! views join the crate through
7//! [`openusd::include_schema!`](openusd::include_schema).
8//!
9//! ```no_run
10//! // build.rs
11//! openusd_build::configure()
12//! .search_path("schemas")
13//! .schema("schemas/usdGeom/schema.usda")
14//! .generate()?;
15//! # Ok::<(), openusd_build::Error>(())
16//! ```
17//!
18//! ```ignore
19//! // src/lib.rs
20//! pub mod geom {
21//! openusd::include_schema!("usdGeom");
22//! }
23//! ```
24//!
25//! A library's name comes from the `libraryName` its `schema.usda` declares,
26//! not from the path, so the two spellings above name one library.
27//!
28//! [`Builder::plug_info`] adds what a library's plugin declares, and
29//! [`Builder::declaration_family`] generates a plugin that has declarations
30//! and no `schema.usda` at all.
31//!
32//! [`Builder::generate`] reads and checks the schemas it is given and writes
33//! one file per library, carrying both the views and the schema data behind
34//! them. The data is written as declarations a registry takes directly, so
35//! nothing is serialized on the way out and nothing is parsed on the way back
36//! in: what a consumer registers is a `const` its compiler has already checked.
37//!
38//! A registry is handed the whole of it through the `SCHEMAS` each generated
39//! library exposes:
40//!
41//! ```ignore
42//! use openusd::usd::SchemaRegistry;
43//!
44//! let registry = SchemaRegistry::builder().register(geom::SCHEMAS).build()?;
45//! ```
46//!
47//! Fenced `ignore`, as the `include_schema!` example above is: this crate has
48//! no build script, so there is no `OUT_DIR` here for the module to come from.
49
50mod error;
51
52mod decl;
53mod emit;
54mod load;
55mod plug_info;
56mod resolve;
57mod shader_defs;
58mod tokens;
59mod validate;
60
61mod doc;
62mod model;
63mod names;
64mod types;
65
66use std::collections::{BTreeMap, BTreeSet};
67use std::env;
68use std::fs;
69use std::path::{Path, PathBuf};
70use std::process;
71
72use openusd::{tf, usd};
73
74use crate::model::Library;
75
76/// Where a library's generated views live, for a base this run does not
77/// generate: the library name a schema declares, mapped to the Rust path its
78/// views are reachable at.
79pub(crate) type Externs = BTreeMap<String, String>;
80
81pub use error::{Error, TokenEnumError};
82pub use validate::Violation;
83
84/// What generating one library produced: a schema library, or the family of a
85/// plugin that declares no schema.
86///
87/// The declarations are the contract: a registry built from them answers what
88/// a registry built from upstream's own schema data answers. They reach a
89/// consumer as the `SCHEMAS` table in [`rust`](Self::rust), and this crate's
90/// own callers through [`with_family`](Self::with_family).
91#[derive(Debug)]
92pub struct Output {
93 /// The generated file: this library's tokens and schema data, and its views
94 /// unless they were left out.
95 pub rust: String,
96 /// Whether [`rust`](Self::rust) carries the views: [`Views::Skip`] was
97 /// asked for, or the schema declared `skipCodeGeneration`.
98 pub views: bool,
99 /// What a schema author should know, though the output is still correct.
100 pub warnings: Vec<String>,
101 /// The model everything above was generated from, and what
102 /// [`with_family`](Self::with_family) lends its declarations out of.
103 library: model::Library,
104 /// The file it was generated from: the schema as configured, or the
105 /// `plugInfo.json` of a plugin with no schema.
106 source: PathBuf,
107}
108
109impl Output {
110 /// The library's name: the `libraryName` its schema declared, or the name
111 /// of the plugin a declaration family was generated from.
112 pub fn library_name(&self) -> &str {
113 &self.library.name
114 }
115
116 /// Where every layer the schema resolved to was found, which is what a
117 /// build script watches. A layer reached through a
118 /// [`search_path`](Builder::search_path) is here by the location it was
119 /// found at, not the bare relative path the schema asked for.
120 pub fn layers(&self) -> &[PathBuf] {
121 &self.library.source_layers
122 }
123
124 /// Calls `f` with this library's schemas as a declared family — the schema
125 /// data itself, and the same declarations the generated table carries.
126 ///
127 /// A [`usd::SchemaFamily`] is a view over its parts, so it arrives through
128 /// a closure rather than as a value: what it borrows lives for the call.
129 /// [`usd::SchemaRegistryBuilder::register`] takes it, and
130 /// [`usd::SchemaFamily::to_data`] turns it into the class prims a
131 /// `generatedSchema.usda` would carry.
132 ///
133 /// ```no_run
134 /// # fn main() -> Result<(), openusd_build::Error> {
135 /// let output = openusd_build::configure()
136 /// .build_library("schemas/usdGeom/schema.usda", openusd_build::Views::Generate)?;
137 /// let data = output.with_family(|family| family.to_data());
138 /// # let _ = data;
139 /// # Ok(())
140 /// # }
141 /// ```
142 pub fn with_family<R>(&self, f: impl FnOnce(&usd::SchemaFamily<'_>) -> R) -> R {
143 decl::with_family(&self.library, f)
144 }
145}
146
147/// What generating one shader-node library produced.
148#[derive(Debug)]
149pub struct NodeOutput {
150 /// The generated file: the node views and the tokens naming their ids,
151 /// inputs and outputs.
152 pub rust: String,
153 /// Where every layer the definitions composed from was found.
154 layers: Vec<PathBuf>,
155}
156
157impl NodeOutput {
158 /// Where every layer the definitions composed from was found, which is
159 /// what a build script watches.
160 pub fn layers(&self) -> &[PathBuf] {
161 &self.layers
162 }
163}
164
165/// Whether a library's views are generated beside its schema data.
166///
167/// The data is not optional either way: it is what a registry is built from, so
168/// a library declining an API still ships what registers it.
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum Views {
171 /// Generate the trait and view per schema.
172 Generate,
173 /// Generate the tokens and the schema data alone, for a library whose API
174 /// is written by hand. A schema declaring `skipCodeGeneration` is treated
175 /// this way however it was asked for.
176 Skip,
177}
178
179/// A Rust enum over one token attribute's `allowedTokens`.
180///
181/// Generation is opt-in and explicit, because a token set is not an identity:
182/// two properties can admit the same tokens and mean different things, and two
183/// properties meaning the same thing can declare different fallbacks. So the
184/// enum is named here and sourced from the one property that defines it.
185/// Nothing else is associated with it — a property admitting the same tokens is
186/// free to use it or not, and the generated accessors are unchanged either
187/// way.
188///
189/// ```no_run
190/// # fn main() -> Result<(), openusd_build::Error> {
191/// use openusd_build::TokenEnum;
192///
193/// openusd_build::configure()
194/// .schema("schemas/usdGeom/schema.usda")
195/// .token_enum(TokenEnum::new("Purpose", "usdGeom", "Imageable.purpose").with_default())
196/// .generate()?;
197/// # Ok(())
198/// # }
199/// ```
200#[derive(Debug, Clone)]
201pub struct TokenEnum {
202 /// The Rust type to generate.
203 pub(crate) name: String,
204 /// The `libraryName` of the schema declaring the source property.
205 pub(crate) library: String,
206 /// `Class.property`, naming the declaration the variants come from.
207 source: String,
208 /// Whether to derive `Default` from the source property's fallback.
209 pub(crate) default: bool,
210 /// Variant names the caller spelled, by the token each stands for.
211 pub(crate) variants: BTreeMap<String, String>,
212}
213
214impl TokenEnum {
215 /// An enum called `name` over the `allowedTokens` of `source`, a property
216 /// of `library` named `Class.property`.
217 ///
218 /// `Class` is the schema's registered identifier, as the class prim spells
219 /// it, not the Rust name its `className` may ask for.
220 pub fn new(name: impl Into<String>, library: impl Into<String>, source: impl Into<String>) -> Self {
221 TokenEnum {
222 name: name.into(),
223 library: library.into(),
224 source: source.into(),
225 default: false,
226 variants: BTreeMap::new(),
227 }
228 }
229
230 /// Derives `Default` from the source property's fallback.
231 ///
232 /// Off unless asked for: a token set carries no default of its own, and the
233 /// corpus shows why — `guideVisibility` falls back to `invisible` where
234 /// `proxyVisibility` and `renderVisibility` fall back to `inherited`, all
235 /// three admitting the same tokens. So the default is the source
236 /// property's, said explicitly, and a source with no token fallback is an
237 /// error rather than a guess.
238 #[must_use]
239 pub fn with_default(mut self) -> Self {
240 self.default = true;
241 self
242 }
243
244 /// Spells the variant for `token` rather than casing it.
245 ///
246 /// For a token Rust has no name for — an empty spelling, one opening with a
247 /// digit — and for two tokens that would otherwise reach one name. Naming a
248 /// token the source property does not admit is an error, so an override
249 /// cannot quietly do nothing.
250 #[must_use]
251 pub fn variant(mut self, token: impl Into<String>, variant: impl Into<String>) -> Self {
252 self.variants.insert(token.into(), variant.into());
253 self
254 }
255
256 /// The class and property its variants come from, or the spelling that is
257 /// neither.
258 pub(crate) fn source(&self) -> Result<(&str, &str), TokenEnumError> {
259 self.source
260 .split_once('.')
261 .filter(|(class, property)| !class.is_empty() && !property.is_empty())
262 .ok_or_else(|| TokenEnumError::MalformedSource {
263 spelling: self.source.clone(),
264 })
265 }
266}
267
268/// Starts describing what to generate. See [`Builder`].
269pub fn configure() -> Builder {
270 Builder {
271 out_dir: None,
272 search_paths: Vec::new(),
273 extern_libraries: BTreeMap::new(),
274 schemas: Vec::new(),
275 token_enums: Vec::new(),
276 plug_infos: Vec::new(),
277 declaration_families: Vec::new(),
278 shader_defs: Vec::new(),
279 }
280}
281
282/// What to generate, and where.
283///
284/// Every method takes and returns the builder, so a `build.rs` reads as one
285/// expression.
286#[derive(Debug)]
287pub struct Builder {
288 out_dir: Option<PathBuf>,
289 search_paths: Vec<PathBuf>,
290 extern_libraries: Externs,
291 /// Each schema to generate, and whether its views are wanted with it.
292 schemas: Vec<(PathBuf, Views)>,
293 /// The enums to generate over token properties, in the order declared.
294 token_enums: Vec<TokenEnum>,
295 /// The `plugInfo.json` files declaring libraries' metadata fields and
296 /// kinds.
297 plug_infos: Vec<PathBuf>,
298 /// The plugins to generate as families of their own, with no schema
299 /// library behind them.
300 declaration_families: Vec<String>,
301 /// The shader-definition layers to generate node views of, each with the
302 /// library name its file is written under.
303 shader_defs: Vec<(String, PathBuf)>,
304}
305
306impl Builder {
307 /// Writes generated files to `dir` instead of `OUT_DIR`.
308 ///
309 /// A build script needs this only when it writes somewhere it also reads
310 /// from, such as a checked-in copy; `OUT_DIR` is the ordinary destination,
311 /// and where [`openusd::include_schema!`](openusd::include_schema) looks.
312 #[must_use]
313 pub fn out_dir(mut self, dir: impl Into<PathBuf>) -> Self {
314 self.out_dir = Some(dir.into());
315 self
316 }
317
318 /// Reads what a `plugInfo.json` declares beside its schemas, the way C++
319 /// registers it: the metadata fields of each plugin's `SdfMetadata` block
320 /// and the kinds of its `Kinds` block.
321 ///
322 /// Each plugin's declarations belong to the library whose `libraryName`
323 /// is the plugin's `Name`. They are registered with that library's
324 /// schemas; a field is also named in its `tokens`, and read and written
325 /// through extension traits on the handles it applies to
326 /// (`AttributeMetadata` and its siblings). A plugin with no schema library
327 /// is named with [`declaration_family`](Self::declaration_family), and one
328 /// file may hold plugins of both sorts. A plugin that is neither is an
329 /// error when [`generate`](Self::generate) runs.
330 #[must_use]
331 pub fn plug_info(mut self, path: impl Into<PathBuf>) -> Self {
332 self.plug_infos.push(path.into());
333 self
334 }
335
336 /// Generates the plugin called `name` as a family of its own, for a
337 /// plugin that declares kinds or metadata fields and has no schema
338 /// library. Repeatable.
339 ///
340 /// The plugin is read from a file [`plug_info`](Self::plug_info) names.
341 /// Its file is written as `<name>.rs` and included with
342 /// [`openusd::include_schema!`](openusd::include_schema) like a schema
343 /// library's, and its `SCHEMAS` registers the declarations and no schema.
344 /// A name no configured `plugInfo.json` declares is an error when
345 /// [`generate`](Self::generate) runs.
346 #[must_use]
347 pub fn declaration_family(mut self, name: impl Into<String>) -> Self {
348 self.declaration_families.push(name.into());
349 self
350 }
351
352 /// Generates a view of each shader node the layer at `path` defines, as
353 /// `usdShaders`'s `shaderDefs.usda` defines `UsdPreviewSurface` and its
354 /// companions.
355 ///
356 /// Each `def Shader` prim authoring an `info:id` is a node, and its view
357 /// wraps the `usdShade` library's `Shader` view, so that library must be
358 /// placed with [`extern_library`](Self::extern_library). The file is
359 /// written as `<library>.rs` and included with
360 /// [`openusd::include_schema!`](openusd::include_schema) like a schema
361 /// library's.
362 #[must_use]
363 pub fn shader_defs(mut self, library: impl Into<String>, path: impl Into<PathBuf>) -> Self {
364 self.shader_defs.push((library.into(), path.into()));
365 self
366 }
367
368 /// Adds a directory that a `schema.usda`'s sublayers resolve through, the
369 /// way [`ar::DefaultResolver::with_search_paths`](openusd::ar::DefaultResolver::with_search_paths)
370 /// takes them.
371 ///
372 /// Upstream schemas sublayer each other by bare path
373 /// (`subLayers = [@usd/schema.usda@]`), which is what a search path
374 /// resolves. Repeatable, and searched in the order given.
375 #[must_use]
376 pub fn search_path(mut self, dir: impl Into<PathBuf>) -> Self {
377 self.search_paths.push(dir.into());
378 self
379 }
380
381 /// Declares where a library this run does not generate already lives, so
382 /// the generated code can name types from it.
383 ///
384 /// `library` is the `libraryName` that library's `schema.usda` declares and
385 /// `rust_path` the module path its views are reachable at, as in
386 /// `("usdGeom", "openusd_schemas::geom")`. A class inheriting from, or
387 /// reflecting, a schema of a library this run does not generate needs it
388 /// declared here; a class of a declared library that a schema's prose names
389 /// is linked to where it lives, and one of an undeclared library is left as
390 /// prose. The path is to the library's views, so a library generated for
391 /// its schema data alone has nothing to declare. Keyed by library name, so
392 /// declaring one twice keeps the last.
393 #[must_use]
394 pub fn extern_library(mut self, library: impl Into<String>, rust_path: impl Into<String>) -> Self {
395 self.extern_libraries.insert(library.into(), rust_path.into());
396 self
397 }
398
399 /// Adds a `schema.usda` to generate a library from. Repeatable.
400 #[must_use]
401 pub fn schema(mut self, path: impl Into<PathBuf>) -> Self {
402 self.schemas.push((path.into(), Views::Generate));
403 self
404 }
405
406 /// Adds a `schema.usda` to generate schema data from, without its views.
407 ///
408 /// The generated file still carries the tokens and the declaration table a
409 /// registry is built from; what it leaves out is the trait and view per
410 /// schema. That is what a crate whose API for these schemas is written by
411 /// hand wants: the declarations a registry needs, without a generated set
412 /// of views beside its own to collide with.
413 ///
414 /// A schema that declares `skipCodeGeneration` is treated this way whether
415 /// or not it is named here.
416 #[must_use]
417 pub fn schema_data(mut self, path: impl Into<PathBuf>) -> Self {
418 self.schemas.push((path.into(), Views::Skip));
419 self
420 }
421
422 /// Adds a Rust enum over a token property's `allowedTokens`. Repeatable.
423 ///
424 /// See [`TokenEnum`] for what it takes and why it is named rather than
425 /// derived. The enum joins the generated file of the library its source
426 /// property belongs to, beside that library's token constants; a library no
427 /// configured enum names generates exactly what it did before.
428 ///
429 /// An enum whose library this run does not build generates nothing, so one
430 /// list of enums describes families that are each behind their own
431 /// feature. Naming a library this build neither generates nor declares
432 /// with [`extern_library`](Self::extern_library) is an error.
433 #[must_use]
434 pub fn token_enum(mut self, declared: TokenEnum) -> Self {
435 self.token_enums.push(declared);
436 self
437 }
438
439 /// Generates every configured library.
440 ///
441 /// Configuring no schemas, shader definitions or declaration families is
442 /// not an error and writes nothing: a consumer whose families are
443 /// feature-gated generates none of them when its features are off, and its
444 /// `build.rs` should not have to know that.
445 ///
446 /// Otherwise the output directory must be known, from
447 /// [`out_dir`](Self::out_dir) or from the `OUT_DIR` a build script runs
448 /// with, and is created if it does not exist.
449 ///
450 /// Each library writes one file, `<library>.rs`, named after the
451 /// `libraryName` its `/GLOBAL` declares and included by
452 /// [`openusd::include_schema!`](openusd::include_schema). It carries both
453 /// the views and the schema data, the latter as the declaration table
454 /// [`usd::SchemaRegistryBuilder::register`] takes. A library that asked for
455 /// `skipCodeGeneration` writes that file too, carrying its tokens and its
456 /// schema data without the views — the data is what a registry is built
457 /// from, so a library declining an API still has to ship it.
458 ///
459 /// Two schemas declaring the same `libraryName` would write one file, and
460 /// are refused before anything is written. A file already holding what
461 /// this run generates is left untouched, its modification time with it.
462 pub fn generate(self) -> Result<(), Error> {
463 if self.schemas.is_empty() && self.shader_defs.is_empty() && self.declaration_families.is_empty() {
464 return Ok(());
465 }
466
467 let out_dir = match &self.out_dir {
468 Some(dir) => dir.clone(),
469 None => env::var_os("OUT_DIR").map(PathBuf::from).ok_or(Error::NoOutDir)?,
470 };
471 fs::create_dir_all(&out_dir).map_err(|source| Error::Io {
472 path: out_dir.clone(),
473 source,
474 })?;
475
476 // Every library is generated before any of them is written, so a
477 // schema this run refuses leaves the last run's output alone rather
478 // than half of it replaced. Each file is then replaced whole (`write`),
479 // so a run failing part-way leaves every library as one run or the
480 // other wrote it.
481 //
482 // TODO(rayon): a library is an independent stage open, composition and
483 // emission over `&self`, so the families of `openusd-schemas` could
484 // build at once. `Output` is what stands in the way: its model holds
485 // `sdf::Value`s read from a stage that is neither `Send` nor `Sync`, so
486 // a parallel version has to finish with each library inside the worker
487 // and return the generated text alone.
488 let plugins = self.plugins()?;
489 let mut outputs = self
490 .schemas
491 .iter()
492 .map(|(schema, views)| self.build(schema, *views, &plugins))
493 .collect::<Result<Vec<_>, Error>>()?;
494 for family in &self.declaration_families {
495 outputs.push(self.declare(family, &plugins)?);
496 }
497
498 // A library this run builds, or one declared so a view can name it: an
499 // enum naming anything else matches nothing, ever. A family behind
500 // a feature that is off is still declared, so this does not catch
501 // one that is simply switched off.
502 let known: BTreeSet<&str> = outputs
503 .iter()
504 .map(Output::library_name)
505 .chain(self.extern_libraries.keys().map(String::as_str))
506 .collect();
507 if let Some(declared) = self
508 .token_enums
509 .iter()
510 .find(|declared| !known.contains(declared.library.as_str()))
511 {
512 return Err(Error::TokenEnum {
513 name: declared.name.clone(),
514 cause: TokenEnumError::UnknownLibrary {
515 library: declared.library.clone(),
516 },
517 });
518 }
519
520 // Every plugin a `plugInfo.json` declares is one of the libraries or
521 // declaration families built here.
522 for plugin in &plugins {
523 if !outputs.iter().any(|output| output.library_name() == plugin.name) {
524 return Err(Error::UnknownPlugin {
525 plugin: plugin.name.clone(),
526 path: plugin.path.clone(),
527 });
528 }
529 }
530
531 let nodes = self
532 .shader_defs
533 .iter()
534 .map(|(library, path)| Ok((library.as_str(), path.as_path(), self.build_shader_defs(library, path)?)))
535 .collect::<Result<Vec<_>, Error>>()?;
536
537 // One file per library name, so two libraries of one name would write
538 // the same file.
539 let mut destinations: BTreeMap<&str, &Path> = BTreeMap::new();
540 let named = outputs
541 .iter()
542 .map(|output| (output.library_name(), output.source.as_path()))
543 .chain(nodes.iter().map(|(library, path, _)| (*library, *path)));
544 for (library, source) in named {
545 if let Some(first) = destinations.insert(library, source) {
546 return Err(Error::DuplicateLibrary {
547 library: library.to_owned(),
548 first: first.to_path_buf(),
549 second: source.to_path_buf(),
550 });
551 }
552 }
553
554 // A field or a kind is declared once. Two plugins of one name are one
555 // library, so a repeat between them names that library twice.
556 let fields = declared_twice(&outputs, |library| {
557 library.metadata.iter().map(|field| field.name.as_str()).collect()
558 });
559 if let Some((field, first, second)) = fields {
560 return Err(Error::DuplicateMetadataField { field, first, second });
561 }
562 let kinds = declared_twice(&outputs, |library| {
563 library.kinds.iter().map(|kind| kind.name.as_str()).collect()
564 });
565 if let Some((kind, first, second)) = kinds {
566 return Err(Error::DuplicateKind { kind, first, second });
567 }
568 for (library, _, output) in &nodes {
569 write(&out_dir.join(format!("{library}.rs")), &output.rust)?;
570 for layer in output.layers() {
571 println!("cargo:rerun-if-changed={}", layer.display());
572 }
573 }
574
575 for output in &outputs {
576 for warning in &output.warnings {
577 println!("cargo:warning={warning}");
578 }
579
580 write(&out_dir.join(format!("{}.rs", output.library_name())), &output.rust)?;
581
582 // Every layer the schema composed from, so an edit to a sublayer
583 // regenerates as surely as an edit to the file named here. Naming
584 // any of them switches off cargo's own "rerun if the package
585 // changed" default, which is why the whole stack is named and not
586 // the configured paths alone.
587 for layer in output.layers() {
588 println!("cargo:rerun-if-changed={}", layer.display());
589 }
590 }
591 Ok(())
592 }
593
594 /// Generates one schema library, writing nothing.
595 ///
596 /// The whole pipeline, for a caller that wants the result rather than the
597 /// files: [`generate`](Self::generate) runs this over every schema
598 /// [`schema`](Self::schema) named and writes what comes back. The schema is
599 /// named here instead, so that list has no bearing on this call.
600 /// A schema asking for `skipCodeGeneration` gets no views whatever `views`
601 /// says.
602 pub fn build_library(&self, schema: impl AsRef<Path>, views: Views) -> Result<Output, Error> {
603 self.build(schema.as_ref(), views, &self.plugins()?)
604 }
605
606 /// Generates the declaration-only family of the plugin called `family`,
607 /// writing nothing: the file [`generate`](Self::generate) writes for a
608 /// name [`declaration_family`](Self::declaration_family) gave.
609 pub fn build_declarations(&self, family: &str) -> Result<Output, Error> {
610 self.declare(family, &self.plugins()?)
611 }
612
613 /// Generates the family of what the plugins called `family` declare, with
614 /// no schema library behind it.
615 fn declare(&self, family: &str, plugins: &[plug_info::Plugin]) -> Result<Output, Error> {
616 // The name is the generated file's, and what `include_schema!` is
617 // given, so it has to be one a path and a Rust string both take whole.
618 if !tf::is_valid_identifier(family) {
619 return Err(Error::InvalidDeclarationFamily {
620 family: family.to_owned(),
621 });
622 }
623 let Some(plugin) = plugins.iter().find(|plugin| plugin.name == family) else {
624 return Err(Error::UnknownDeclarationFamily {
625 family: family.to_owned(),
626 });
627 };
628 let mut library = Library::declarations(family);
629 library.take_declarations(plugins);
630 let warnings = validate::check(&library)?;
631 // The traits a metadata field is read through are emitted with the
632 // views, so the views are asked for.
633 self.emit(library, warnings, &plugin.path, Views::Generate)
634 }
635
636 /// Generates the file of a library read from `source`.
637 fn emit(&self, library: Library, warnings: Vec<String>, source: &Path, views: Views) -> Result<Output, Error> {
638 // Named by file rather than by path: the header reaches whatever the
639 // consumer generates into, and an absolute path would differ on every
640 // machine that built it.
641 let named = source.file_name().unwrap_or(source.as_os_str());
642 let rust = emit::emit(
643 &library,
644 &self.extern_libraries,
645 &named.to_string_lossy(),
646 views,
647 &self.token_enums,
648 )?;
649 Ok(Output {
650 rust,
651 views: views == Views::Generate,
652 warnings,
653 library,
654 source: source.to_path_buf(),
655 })
656 }
657
658 /// Every plugin the configured `plugInfo.json` files declare, each file
659 /// read once.
660 fn plugins(&self) -> Result<Vec<plug_info::Plugin>, Error> {
661 let mut plugins = Vec::new();
662 for path in &self.plug_infos {
663 plugins.extend(plug_info::read(path)?);
664 }
665 Ok(plugins)
666 }
667
668 /// Generates the views of the shader nodes the layer at `path` defines,
669 /// writing nothing: the file [`generate`](Self::generate) writes for a
670 /// library [`shader_defs`](Self::shader_defs) named.
671 pub fn build_shader_defs(&self, library: &str, path: impl AsRef<Path>) -> Result<NodeOutput, Error> {
672 let path = path.as_ref();
673 let shade = self.extern_libraries.get("usdShade").ok_or_else(|| Error::ShaderDefs {
674 path: path.to_path_buf(),
675 cause: "a node's view wraps `usdShade`'s `Shader`, and no extern_library places usdShade".to_owned(),
676 })?;
677 let shade: syn::Path = syn::parse_str(shade).map_err(|_| Error::ShaderDefs {
678 path: path.to_path_buf(),
679 cause: format!("usdShade is placed at `{shade}`, which is not a Rust path"),
680 })?;
681 let library = shader_defs::read(self, library, path)?;
682 let rust = emit::nodes(&library, &shade)?;
683 Ok(NodeOutput {
684 rust,
685 layers: library.layers,
686 })
687 }
688
689 /// Reads, checks and generates one library.
690 fn build(&self, schema: &Path, views: Views, plugins: &[plug_info::Plugin]) -> Result<Output, Error> {
691 let (library, warnings) = self.read(schema, plugins)?;
692 let views = match library.skip_code_generation {
693 true => Views::Skip,
694 false => views,
695 };
696 self.emit(library, warnings, schema, views)
697 }
698
699 /// Reads one schema into the model every output is generated from, with
700 /// whatever a schema author should know about it.
701 ///
702 /// The three stages behind it stay separate: [`load`] extracts what the
703 /// layers declare, [`resolve`] consults composition once and keeps the
704 /// answers, and [`validate`] checks the rules. What validation finds that
705 /// does not make the output wrong travels back beside the model, so a
706 /// caller reading the result in memory sees it as well as a build log does.
707 fn read(&self, schema: &Path, plugins: &[plug_info::Plugin]) -> Result<(Library, Vec<String>), Error> {
708 let source = load::open(self, schema)?;
709 let mut library = resolve::library(&source)?;
710
711 library.take_declarations(plugins);
712
713 // Prim indices are built on demand, so a diagnostic a class's own
714 // composition raises exists only once resolve has read that class.
715 // Asking again here is what catches those.
716 Error::composition(schema, &source.stage).map_or(Ok(()), Err)?;
717
718 let warnings = validate::check(&library)?;
719 Ok((library, warnings))
720 }
721}
722
723/// The first name two of `outputs` both declare, with the library declaring
724/// it first and the one declaring it again. `names` lists what one library
725/// declares.
726fn declared_twice<'a>(
727 outputs: &'a [Output],
728 names: impl Fn(&'a Library) -> Vec<&'a str>,
729) -> Option<(String, String, String)> {
730 let mut declared: BTreeMap<&str, &str> = BTreeMap::new();
731 for output in outputs {
732 for name in names(&output.library) {
733 if let Some(first) = declared.insert(name, output.library_name()) {
734 return Some((name.to_owned(), first.to_owned(), output.library_name().to_owned()));
735 }
736 }
737 }
738 None
739}
740
741/// Writes one generated file, naming the path in anything that goes wrong.
742///
743/// A file already holding `contents` is left untouched, so whatever watches
744/// its modification time — rustc's incremental cache, an editor — sees
745/// nothing change when the same text is regenerated. Anything else is written
746/// beside the destination and renamed onto it, which replaces the file whole:
747/// an interrupted run leaves the previous file or the new one, never a
748/// truncated one.
749fn write(path: &Path, contents: &str) -> Result<(), Error> {
750 if fs::read(path).is_ok_and(|current| current == contents.as_bytes()) {
751 return Ok(());
752 }
753
754 let mut staged = path.as_os_str().to_owned();
755 staged.push(format!(".tmp-{}", process::id()));
756 let staged = PathBuf::from(staged);
757 let written = fs::write(&staged, contents).map_err(|source| Error::Io {
758 path: staged.clone(),
759 source,
760 });
761 let replaced = written.and_then(|()| {
762 fs::rename(&staged, path).map_err(|source| Error::Io {
763 path: path.to_path_buf(),
764 source,
765 })
766 });
767 if replaced.is_err() {
768 // Nothing to report beyond the failure itself, which is the one a
769 // contributor needs; a stale staging file only costs disk space.
770 let _ = fs::remove_file(&staged);
771 }
772 replaced
773}
774
775#[cfg(test)]
776mod tests {
777
778 use super::*;
779
780 use std::time::{Duration, SystemTime};
781
782 use openusd::sdf;
783
784 /// Resolves one file of the vendored upstream corpus, which every module's
785 /// tests read.
786 pub(crate) fn read_fixture(name: &str) -> Result<Library, Error> {
787 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/testUsdGenSchema");
788 configure()
789 .search_path(&dir)
790 .read(&dir.join(name), &[])
791 .map(|(library, _)| library)
792 }
793
794 /// Resolves a schema a test writes for itself, for a case the vendored
795 /// corpus does not reach.
796 pub(crate) fn read_source(dir: &Path, source: &str) -> Result<Library, Error> {
797 fs::write(dir.join("schema.usda"), source).expect("writes the schema");
798 configure()
799 .read(&dir.join("schema.usda"), &[])
800 .map(|(library, _)| library)
801 }
802
803 /// A schema library called `library`: the roots, and whatever `classes`
804 /// declare.
805 pub(crate) fn schema(library: &str, classes: &str) -> String {
806 format!(
807 r#"#usda 1.0
808
809def "GLOBAL" (
810 customData = {{
811 string libraryName = "{library}"
812 }}
813)
814{{
815}}
816
817class "Typed" {{}}
818
819class "APISchemaBase" {{}}
820
821{classes}
822"#
823 )
824 }
825
826 /// A single-apply API schema for a class to reflect, declaring `dup` among
827 /// its properties for a second reflected schema to collide with.
828 pub(crate) const TAG_API: &str = r#"class "TagAPI" (
829 inherits = </APISchemaBase>
830 customData = { token apiSchemaType = "singleApply" }
831) {
832 string tag = ""
833 int dup = 0
834}"#;
835
836 /// An API schema whose name breaks the suffix convention, which is a
837 /// warning rather than a rule: the registry never reads the spelling.
838 const MISSING_SUFFIX: &str = r#"#usda 1.0
839
840def "GLOBAL" (
841 customData = {
842 string libraryName = "testWarn"
843 }
844)
845{
846}
847
848class "APISchemaBase"
849{
850}
851
852class "MissingSuffix" (
853 inherits = </APISchemaBase>
854 customData = {
855 token apiSchemaType = "singleApply"
856 }
857)
858{
859}
860"#;
861
862 /// A convention warning reaches the caller that asked for the library, not
863 /// only the build log a `generate` run prints to.
864 #[test]
865 fn build_library_reports_warnings() {
866 let dir = tempfile::tempdir().expect("tempdir");
867 let path = dir.path().join("schema.usda");
868 fs::write(&path, MISSING_SUFFIX).expect("writes the schema");
869
870 let output = configure().build_library(&path, Views::Generate).expect("builds");
871 let warnings = &output.warnings;
872 assert_eq!(warnings.len(), 1, "{warnings:?}");
873 assert!(warnings[0].contains("conventionally ends in `API`"), "{warnings:?}");
874 }
875
876 /// A run with nothing configured writes nothing and needs no destination,
877 /// which is the state a consumer's build script is in when every schema
878 /// family it can generate is behind a feature that is off.
879 #[test]
880 fn no_schemas_writes_nothing() {
881 let dir = tempfile::tempdir().expect("tempdir");
882 let out = dir.path().join("generated");
883
884 configure().out_dir(&out).generate().expect("nothing to do");
885
886 assert!(!out.exists(), "a run with no schemas creates no output directory");
887 }
888
889 /// The destination is created on demand, since `OUT_DIR` exists but a
890 /// caller's own path need not.
891 #[test]
892 fn out_dir_created() {
893 let dir = tempfile::tempdir().expect("tempdir");
894 let out = dir.path().join("nested/generated");
895
896 // The corpus reflects a schema of its `usd` sublayer library, whose
897 // views the generated code has to be able to name.
898 let schema = Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/testUsdGenSchema/schema.usda");
899 configure()
900 .out_dir(&out)
901 .search_path(schema.parent().expect("a parent"))
902 .extern_library("usd", "::openusd::usd")
903 .schema(&schema)
904 .generate()
905 .expect("generates");
906
907 assert!(out.is_dir(), "the output directory is created if it is missing");
908 }
909
910 /// `MISSING_SUFFIX` written to `dir`, and the library it declares.
911 fn missing_suffix(dir: &Path) -> PathBuf {
912 let path = dir.join("schema.usda");
913 fs::write(&path, MISSING_SUFFIX).expect("writes the schema");
914 path
915 }
916
917 /// What `generate` writes is what `build_library` hands back, with no
918 /// staging file left beside it.
919 #[test]
920 fn generated_file_written() {
921 let dir = tempfile::tempdir().expect("tempdir");
922 let schema = missing_suffix(dir.path());
923 let out = dir.path().join("generated");
924
925 configure().out_dir(&out).schema(&schema).generate().expect("generates");
926
927 let expected = configure()
928 .build_library(&schema, Views::Generate)
929 .expect("builds")
930 .rust;
931 let written = fs::read_to_string(out.join("testWarn.rs")).expect("reads the output");
932 assert_eq!(written, expected);
933 let names: Vec<_> = fs::read_dir(&out)
934 .expect("lists the output")
935 .map(|entry| entry.expect("an entry").file_name())
936 .collect();
937 assert_eq!(names, vec!["testWarn.rs"], "nothing else is left behind");
938 }
939
940 /// Regenerating the same text leaves the file as it was, modification
941 /// time included; different text replaces it.
942 #[test]
943 fn unchanged_file_untouched() {
944 let dir = tempfile::tempdir().expect("tempdir");
945 let schema = missing_suffix(dir.path());
946 let out = dir.path().join("generated");
947 let file = out.join("testWarn.rs");
948 let generate = || configure().out_dir(&out).schema(&schema).generate();
949
950 generate().expect("generates");
951 let long_ago = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
952 fs::File::options()
953 .write(true)
954 .open(&file)
955 .and_then(|written| written.set_modified(long_ago))
956 .expect("backdates the output");
957
958 generate().expect("regenerates");
959 let modified = || {
960 fs::metadata(&file)
961 .and_then(|meta| meta.modified())
962 .expect("reads mtime")
963 };
964 assert_eq!(modified(), long_ago, "the same text is not written again");
965
966 let changed = MISSING_SUFFIX.replace(
967 "class \"MissingSuffix\" (",
968 "class \"MissingSuffix\" (\n doc = \"Changed.\"",
969 );
970 fs::write(&schema, changed).expect("edits the schema");
971 generate().expect("regenerates");
972 assert_ne!(modified(), long_ago, "different text replaces the file");
973 assert!(fs::read_to_string(&file).expect("reads").contains("Changed."));
974 }
975
976 /// Two schemas declaring one `libraryName` would write one file, so the
977 /// run refuses before writing either.
978 #[test]
979 fn duplicate_library_refused() {
980 let dir = tempfile::tempdir().expect("tempdir");
981 let [first, second] = ["a", "b"].map(|name| {
982 let own = dir.path().join(name);
983 fs::create_dir(&own).expect("creates a schema directory");
984 missing_suffix(&own)
985 });
986 let out = dir.path().join("generated");
987
988 let error = configure()
989 .out_dir(&out)
990 .schema(&first)
991 .schema(&second)
992 .generate()
993 .expect_err("both declare testWarn");
994
995 match error {
996 Error::DuplicateLibrary {
997 library,
998 first: named_first,
999 second: named_second,
1000 } => {
1001 assert_eq!(library, "testWarn");
1002 assert_eq!((named_first, named_second), (first, second));
1003 }
1004 other => panic!("{other}"),
1005 }
1006 assert!(!out.join("testWarn.rs").exists(), "nothing is written");
1007 }
1008
1009 /// A `plugInfo.json` declaring `field` for the plugin `plugin`, written
1010 /// to `dir`.
1011 fn plug_info(dir: &Path, plugin: &str, field: &str) -> PathBuf {
1012 let path = dir.join(format!("{plugin}-{field}.json"));
1013 let text = format!(
1014 r#"{{"Plugins": [{{"Name": "{plugin}", "Info": {{"SdfMetadata": {{"{field}": {{"type": "token"}}}}}}}}]}}"#
1015 );
1016 fs::write(&path, text).expect("writes the plugInfo");
1017 path
1018 }
1019
1020 /// A `plugInfo.json` holding the schema library `testWarn`'s plugin and
1021 /// the plugin `siteKinds`, which declares kinds and has no schema library.
1022 fn mixed_plug_info(dir: &Path) -> PathBuf {
1023 let path = dir.join("mixed.json");
1024 fs::write(
1025 &path,
1026 r#"{"Plugins": [
1027 {"Name": "testWarn", "Info": {
1028 "SdfMetadata": {"role": {"type": "token"}},
1029 "Kinds": {"prop": {"baseKind": "component"}}
1030 }},
1031 {"Name": "siteKinds", "Info": {"Kinds": {
1032 "chargroup": {"baseKind": "assembly"},
1033 "site_root": {}
1034 }}}
1035 ]}"#,
1036 )
1037 .expect("writes the plugInfo");
1038 path
1039 }
1040
1041 /// The kinds `output`'s family declares, each with its base.
1042 fn declared_kinds(output: &Output) -> Vec<(String, Option<String>)> {
1043 output.with_family(|family| {
1044 family
1045 .declared_kinds()
1046 .iter()
1047 .map(|kind| (kind.name().to_owned(), kind.declared_base().map(str::to_owned)))
1048 .collect()
1049 })
1050 }
1051
1052 /// One file holds a schema library's plugin and a declaration-only one,
1053 /// and each reaches its own generated file.
1054 #[test]
1055 fn mixed_plugins_generate() {
1056 let dir = tempfile::tempdir().expect("tempdir");
1057 let schema = missing_suffix(dir.path());
1058 let info = mixed_plug_info(dir.path());
1059 let out = dir.path().join("generated");
1060
1061 configure()
1062 .out_dir(&out)
1063 .schema(&schema)
1064 .plug_info(&info)
1065 .declaration_family("siteKinds")
1066 .generate()
1067 .expect("generates both");
1068
1069 let library = fs::read_to_string(out.join("testWarn.rs")).expect("the schema library is written");
1070 assert!(
1071 library.contains(r#"::openusd::kind::Decl::new("prop").base("component")"#),
1072 "{library}"
1073 );
1074 let family = fs::read_to_string(out.join("siteKinds.rs")).expect("the declaration family is written");
1075 assert!(
1076 family.contains(r#"::openusd::kind::Decl::new("chargroup").base("assembly")"#),
1077 "{family}"
1078 );
1079 assert!(
1080 family.contains(r#"::openusd::kind::Decl::new("site_root")"#),
1081 "{family}"
1082 );
1083 assert!(!family.contains("prop"), "{family}");
1084 }
1085
1086 /// A run with no schema at all still writes its declaration families.
1087 #[test]
1088 fn declarations_alone_generate() {
1089 let dir = tempfile::tempdir().expect("tempdir");
1090 let info = mixed_plug_info(dir.path());
1091 let out = dir.path().join("generated");
1092
1093 // Naming the file alone asks for nothing, as configuring no schema does.
1094 configure()
1095 .out_dir(&out)
1096 .plug_info(&info)
1097 .generate()
1098 .expect("nothing to do");
1099 assert!(!out.exists(), "nothing is written");
1100
1101 let error = configure()
1102 .out_dir(&out)
1103 .plug_info(&info)
1104 .declaration_family("siteKinds")
1105 .generate()
1106 .expect_err("testWarn has no schema library here");
1107 assert!(
1108 matches!(&error, Error::UnknownPlugin { plugin, .. } if plugin == "testWarn"),
1109 "{error}"
1110 );
1111
1112 let kinds = dir.path().join("kinds.json");
1113 fs::write(
1114 &kinds,
1115 r#"{"Plugins": [{"Name": "siteKinds", "Info": {"Kinds": {"chargroup": {"baseKind": "assembly"}}}}]}"#,
1116 )
1117 .expect("writes the plugInfo");
1118 configure()
1119 .out_dir(&out)
1120 .plug_info(&kinds)
1121 .declaration_family("siteKinds")
1122 .generate()
1123 .expect("generates the family");
1124 assert!(out.join("siteKinds.rs").exists());
1125 }
1126
1127 /// A declaration family carries its plugin's kinds and watches its file.
1128 #[test]
1129 fn declaration_family_kinds() {
1130 let dir = tempfile::tempdir().expect("tempdir");
1131 let info = mixed_plug_info(dir.path());
1132
1133 let output = configure()
1134 .plug_info(&info)
1135 .build_declarations("siteKinds")
1136 .expect("builds");
1137 assert_eq!(output.library_name(), "siteKinds");
1138 assert_eq!(output.layers(), [info]);
1139 assert_eq!(
1140 declared_kinds(&output),
1141 [
1142 ("chargroup".to_owned(), Some("assembly".to_owned())),
1143 ("site_root".to_owned(), None)
1144 ]
1145 );
1146 output.with_family(|family| assert!(family.schemas().is_empty()));
1147 }
1148
1149 /// A declaration family's name is its file's, so one that is no
1150 /// identifier is refused before anything is written.
1151 #[test]
1152 fn declaration_family_name_checked() {
1153 let dir = tempfile::tempdir().expect("tempdir");
1154 let out = dir.path().join("generated");
1155 for name in ["../kinds", "site/kinds", "my-kinds", ""] {
1156 let error = configure()
1157 .out_dir(&out)
1158 .plug_info(mixed_plug_info(dir.path()))
1159 .declaration_family(name)
1160 .generate()
1161 .expect_err("not an identifier");
1162 assert!(
1163 matches!(&error, Error::InvalidDeclarationFamily { family } if family == name),
1164 "{error}"
1165 );
1166 }
1167 let written = fs::read_dir(&out).expect("the output directory").count();
1168 assert_eq!(written, 0, "nothing is written");
1169 assert!(!dir.path().join("kinds.rs").exists(), "nor beside it");
1170 }
1171
1172 /// A declaration family no configured file declares a plugin for would
1173 /// write an empty file, so generation stops.
1174 #[test]
1175 fn unknown_declaration_family_refused() {
1176 let dir = tempfile::tempdir().expect("tempdir");
1177 let schema = missing_suffix(dir.path());
1178
1179 let error = configure()
1180 .out_dir(dir.path().join("generated"))
1181 .schema(&schema)
1182 .plug_info(mixed_plug_info(dir.path()))
1183 .declaration_family("siteKindz")
1184 .generate()
1185 .expect_err("no plugin is called siteKindz");
1186 assert!(
1187 matches!(&error, Error::UnknownDeclarationFamily { family } if family == "siteKindz"),
1188 "{error}"
1189 );
1190 }
1191
1192 /// One kind declared by two plugins would be refused by the registry, so
1193 /// generation refuses it first.
1194 #[test]
1195 fn duplicate_kind_refused() {
1196 let dir = tempfile::tempdir().expect("tempdir");
1197 let schema = missing_suffix(dir.path());
1198 let info = dir.path().join("twice.json");
1199 fs::write(
1200 &info,
1201 r#"{"Plugins": [
1202 {"Name": "testWarn", "Info": {"Kinds": {"prop": {"baseKind": "component"}}}},
1203 {"Name": "siteKinds", "Info": {"Kinds": {"prop": {}}}}
1204 ]}"#,
1205 )
1206 .expect("writes the plugInfo");
1207
1208 let error = configure()
1209 .out_dir(dir.path().join("generated"))
1210 .schema(&schema)
1211 .plug_info(&info)
1212 .declaration_family("siteKinds")
1213 .generate()
1214 .expect_err("both declare prop");
1215 assert!(
1216 matches!(&error, Error::DuplicateKind { kind, .. } if kind == "prop"),
1217 "{error}"
1218 );
1219 }
1220
1221 /// A plugin named after no library this run builds declares fields that
1222 /// would reach nothing, so generation stops.
1223 #[test]
1224 fn unknown_plugin_refused() {
1225 let dir = tempfile::tempdir().expect("tempdir");
1226 let schema = missing_suffix(dir.path());
1227 let info = plug_info(dir.path(), "testElsewhere", "role");
1228
1229 let error = configure()
1230 .out_dir(dir.path().join("generated"))
1231 .schema(&schema)
1232 .plug_info(&info)
1233 .generate()
1234 .expect_err("no library is called testElsewhere");
1235 assert!(
1236 matches!(&error, Error::UnknownPlugin { plugin, path } if plugin == "testElsewhere" && path == &info),
1237 "{error}"
1238 );
1239 }
1240
1241 /// A plugin's fields reach the library named after it: its schema data
1242 /// registers them, and the file is watched with the layers it read.
1243 #[test]
1244 fn plugin_fields_reach_library() {
1245 let dir = tempfile::tempdir().expect("tempdir");
1246 let schema = missing_suffix(dir.path());
1247 let info = plug_info(dir.path(), "testWarn", "role");
1248
1249 let output = configure()
1250 .plug_info(&info)
1251 .build_library(&schema, Views::Generate)
1252 .expect("builds");
1253 assert!(output.layers().contains(&info), "{:?}", output.layers());
1254 let names: Vec<String> = output.with_family(|family| {
1255 family
1256 .declared_metadata()
1257 .iter()
1258 .map(|field| field.name().to_owned())
1259 .collect()
1260 });
1261 assert_eq!(names, vec!["role"]);
1262 }
1263
1264 /// A list default is written into the declaration table as the value it
1265 /// decodes to, array and tuple alike.
1266 #[test]
1267 fn list_defaults_emitted() {
1268 let dir = tempfile::tempdir().expect("tempdir");
1269 let schema = missing_suffix(dir.path());
1270 let info = dir.path().join("plugInfo.json");
1271 fs::write(
1272 &info,
1273 r#"{"Plugins": [{"Name": "testWarn", "Info": {"SdfMetadata": {
1274 "weights": {"type": "double[]", "default": [0.0, 1.0], "appliesTo": "layers"},
1275 "up": {"type": "float3", "default": [0, 1, 0], "appliesTo": "layers"}
1276 }}}]}"#,
1277 )
1278 .expect("writes the plugInfo");
1279
1280 let output = configure()
1281 .plug_info(&info)
1282 .build_library(&schema, Views::Generate)
1283 .expect("builds");
1284 let fallbacks: Vec<Option<sdf::Value>> = output.with_family(|family| {
1285 family
1286 .declared_metadata()
1287 .iter()
1288 .map(usd::MetadataDecl::declared_fallback)
1289 .collect()
1290 });
1291 assert!(
1292 fallbacks.contains(&Some(sdf::Value::DoubleVec(vec![0.0, 1.0]))),
1293 "{fallbacks:?}"
1294 );
1295 assert!(output.rust.contains(".fallback(||"), "{}", output.rust);
1296 }
1297
1298 /// One field declared by two libraries would be refused by the registry,
1299 /// so generation refuses it first.
1300 #[test]
1301 fn duplicate_field_refused() {
1302 let dir = tempfile::tempdir().expect("tempdir");
1303 let first = missing_suffix(dir.path());
1304 let second_dir = dir.path().join("other");
1305 fs::create_dir(&second_dir).expect("creates a schema directory");
1306 let second = second_dir.join("schema.usda");
1307 fs::write(&second, MISSING_SUFFIX.replace("testWarn", "testOther")).expect("writes the schema");
1308
1309 let error = configure()
1310 .out_dir(dir.path().join("generated"))
1311 .schema(&first)
1312 .schema(&second)
1313 .plug_info(plug_info(dir.path(), "testWarn", "role"))
1314 .plug_info(plug_info(dir.path(), "testOther", "role"))
1315 .generate()
1316 .expect_err("both declare role");
1317 assert!(
1318 matches!(&error, Error::DuplicateMetadataField { field, .. } if field == "role"),
1319 "{error}"
1320 );
1321 }
1322
1323 /// Every layer a node library composes from is watched, a sublayer
1324 /// included, so an edit to any of them regenerates.
1325 #[test]
1326 fn shader_def_sublayers_watched() {
1327 let dir = tempfile::tempdir().expect("tempdir");
1328 let base = dir.path().join("base.usda");
1329 fs::write(
1330 &base,
1331 "#usda 1.0\n\ndef Shader \"UsdBase\"\n{\n uniform token info:id = \"UsdBase\"\n}\n",
1332 )
1333 .expect("writes the sublayer");
1334 let root = dir.path().join("shaderDefs.usda");
1335 fs::write(&root, "#usda 1.0\n(\n subLayers = [@./base.usda@]\n)\n").expect("writes the root");
1336
1337 let output = configure()
1338 .extern_library("usdShade", "crate::shade")
1339 .build_shader_defs("testNodes", &root)
1340 .expect("generates");
1341 let watched: Vec<_> = output
1342 .layers()
1343 .iter()
1344 .map(|layer| layer.file_name().expect("a file").to_owned())
1345 .collect();
1346 assert_eq!(watched, vec!["shaderDefs.usda", "base.usda"]);
1347 assert!(output.rust.contains("pub struct Base("), "{}", output.rust);
1348 }
1349
1350 /// A node taking its ports from a referenced layer watches that layer
1351 /// too, since the generated interface is read from it.
1352 #[test]
1353 fn shader_def_references_watched() {
1354 let dir = tempfile::tempdir().expect("tempdir");
1355 fs::write(
1356 dir.path().join("base.usda"),
1357 "#usda 1.0\n\ndef Shader \"Base\"\n{\n float inputs:gain = 1\n}\n",
1358 )
1359 .expect("writes the referenced layer");
1360 let root = dir.path().join("shaderDefs.usda");
1361 fs::write(
1362 &root,
1363 "#usda 1.0\n\ndef Shader \"UsdGain\" (\n references = @./base.usda@</Base>\n)\n{\n uniform token info:id = \"UsdGain\"\n}\n",
1364 )
1365 .expect("writes the root");
1366
1367 let output = configure()
1368 .extern_library("usdShade", "crate::shade")
1369 .build_shader_defs("testNodes", &root)
1370 .expect("generates");
1371 assert!(output.rust.contains("pub fn gain_input("), "{}", output.rust);
1372 let watched: Vec<_> = output
1373 .layers()
1374 .iter()
1375 .map(|layer| layer.file_name().expect("a file").to_owned())
1376 .collect();
1377 assert_eq!(watched, vec!["shaderDefs.usda", "base.usda"]);
1378 }
1379
1380 /// A node referencing a layer that is not there fails generation rather
1381 /// than emitting the interface without what the layer would have given it.
1382 #[test]
1383 fn shader_def_missing_reference_refused() {
1384 let dir = tempfile::tempdir().expect("tempdir");
1385 let root = dir.path().join("shaderDefs.usda");
1386 fs::write(
1387 &root,
1388 "#usda 1.0\n\ndef Shader \"UsdGain\" (\n references = @./missing.usda@</Base>\n)\n{\n uniform token info:id = \"UsdGain\"\n}\n",
1389 )
1390 .expect("writes the root");
1391
1392 let error = configure()
1393 .extern_library("usdShade", "crate::shade")
1394 .build_shader_defs("testNodes", &root)
1395 .expect_err("the reference resolves to nothing");
1396 assert!(matches!(error, Error::Composition { .. }), "{error}");
1397 }
1398
1399 /// A run configuring only shader definitions still generates them.
1400 #[test]
1401 fn shader_defs_alone_generated() {
1402 let dir = tempfile::tempdir().expect("tempdir");
1403 let out = dir.path().join("generated");
1404 let defs = Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/nodes/shaderDefs.usda");
1405
1406 configure()
1407 .out_dir(&out)
1408 .extern_library("usdShade", "crate::shade")
1409 .shader_defs("tinyNodes", &defs)
1410 .generate()
1411 .expect("generates");
1412 assert!(out.join("tinyNodes.rs").is_file(), "the node library is written");
1413 }
1414
1415 /// With schemas to build and nowhere to put them, generation stops rather
1416 /// than guessing.
1417 #[test]
1418 fn missing_out_dir_reported() {
1419 let error = configure()
1420 .schema("schemas/usdGeom/schema.usda")
1421 .generate()
1422 .expect_err("this crate has no build script, so its tests run without OUT_DIR set");
1423
1424 assert!(matches!(error, Error::NoOutDir), "{error}");
1425 }
1426
1427 /// A repeated option accumulates, except an extern library, which is keyed
1428 /// by library name and keeps the last declaration.
1429 ///
1430 /// Nothing reads either yet; they are what the generator will be given.
1431 #[test]
1432 fn repeated_options() {
1433 let builder = configure()
1434 .schema("schemas/usdGeom/schema.usda")
1435 .schema("schemas/usdLux/schema.usda")
1436 .extern_library("usdGeom", "crate::geom")
1437 .extern_library("usdGeom", "openusd_schemas::geom");
1438
1439 assert_eq!(builder.schemas.len(), 2);
1440 assert_eq!(
1441 builder.extern_libraries.get("usdGeom").map(String::as_str),
1442 Some("openusd_schemas::geom")
1443 );
1444 }
1445}