Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,7 @@ jobs:
run: uv sync --project targets/ptfkit-py --frozen --all-groups

- name: Check generated files
run: |
cargo run --manifest-path codegen/Cargo.toml generate
git diff --exit-code
test -z "$(git status --porcelain)"
run: cargo run --manifest-path codegen/Cargo.toml check-generated

- name: Check codegen formatting
id: codegen-format
Expand Down
5 changes: 5 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ mod rust 'targets/ptfkit-rs'
@generate:
cargo run generate

# Regenerate all codegen-owned files and fail if any output drifts.
[working-directory: 'codegen']
@check-generated:
cargo run check-generated

# Set the package version and refresh dependent lockfiles.
@version value:
cargo run --manifest-path codegen/Cargo.toml -- version {{quote(value)}}
Expand Down
32 changes: 32 additions & 0 deletions codegen/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Code Generator Architecture

`ptfkit-codegen` validates the YAML specifications, compiles their formulas
into a shared semantic representation, and renders every committed target.
The generated files, after their target formatter runs, are the compatibility
contract; internal generator APIs are not.

## Pipeline

1. `specs` loads source specifications and `validate` checks their contracts.
2. `compile` resolves formulas and golden cases into `CompiledFunction` values.
3. `documentation` provides borrowed source/function facts without target
markup. `render` contains shared text, Markdown, and C-family expression
rendering support.
4. `targets::{catalog, reference, native, python, rust}` render concrete
generated products and return `GeneratedFile` artifacts.
5. `output` owns layouts, staging, formatter execution, cleanup, snapshots,
and atomic replacement.

`check-generated` snapshots all marker-owned files, runs the same pipeline,
and fails if the generated tree changes.

## Extension points

Add target-local syntax and escaping beside its renderer. Reusable C-family
expression precedence belongs in `render::c`; reusable Markdown file/block
composition belongs in `render::markdown`. Use `render::Writer` only where
indentation-aware text composition is natural; Rust remains token-based with
`proc_macro2` and `quote`. Add an `output::Layout` only when a new output group
needs a distinct root, cleanup root, marker, or formatter. Do not introduce a
cross-language syntax AST or let renderers own filesystem, staging, formatting,
or cleanup policy.
File renamed without changes.
201 changes: 201 additions & 0 deletions codegen/src/documentation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
//! Semantic documentation assembled from validated specifications.
//!
//! Targets choose their own section ordering and markup. This module only
//! describes the information they have available to render.

use crate::model::{Function, Outputs, Parameter, Scope, Source};

#[derive(Clone, Copy, Debug)]
pub(crate) struct SourceDocument<'a> {
pub(crate) summary: &'a str,
pub(crate) reference: Reference<'a>,
pub(crate) territory: Option<&'a str>,
pub(crate) dataset: Option<&'a str>,
}

#[derive(Clone, Copy, Debug)]
pub(crate) struct Reference<'a> {
pub(crate) citation: &'a str,
pub(crate) doi: Option<Doi<'a>>,
}

#[derive(Clone, Copy, Debug)]
pub(crate) struct Doi<'a> {
pub(crate) identifier: &'a str,
pub(crate) url: &'a str,
}

#[derive(Clone, Copy, Debug)]
pub(crate) struct FunctionDocument<'a> {
pub(crate) summary: &'a str,
pub(crate) parameters: &'a [Parameter],
pub(crate) returns: Returns<'a>,
pub(crate) territory: Option<&'a str>,
pub(crate) models: Models<'a>,
pub(crate) remarks: Remarks<'a>,
pub(crate) notes: &'a [String],
pub(crate) warnings: &'a [String],
}

#[derive(Clone, Copy, Debug)]
pub(crate) enum Returns<'a> {
Scalar(&'a Parameter),
Record {
name: &'a str,
fields: &'a [Parameter],
},
}

#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct Models<'a> {
pub(crate) h_theta: Option<&'a str>,
pub(crate) k_h: Option<&'a str>,
}

#[derive(Clone, Copy, Debug)]
pub(crate) struct Remarks<'a> {
pub(crate) prediction_target: &'a str,
}

pub(crate) fn for_source<'a>(source: &'a Source, scope: &'a Scope) -> SourceDocument<'a> {
SourceDocument {
summary: &source.summary,
reference: Reference {
citation: &source.citation_apa,
doi: source.doi.as_ref().map(|doi| Doi {
identifier: &doi.identifier,
url: &doi.url,
}),
},
territory: scope.territory.as_deref(),
dataset: scope.dataset.as_deref(),
}
}

pub(crate) fn for_function(function: &Function) -> FunctionDocument<'_> {
FunctionDocument {
summary: &function.public_api.summary,
parameters: &function.inputs,
returns: match &function.outputs {
Outputs::Scalar { field } => Returns::Scalar(field),
Outputs::Record { name, fields } => Returns::Record { name, fields },
},
territory: function.scope.territory.as_deref(),
models: Models {
h_theta: function.scope.models.h_theta.as_deref(),
k_h: function.scope.models.k_h.as_deref(),
},
remarks: Remarks {
prediction_target: &function.scope.prediction_target,
},
notes: &function.documentation.notes,
warnings: &function.documentation.warnings,
}
}

pub(crate) fn parameter_details(parameter: &Parameter) -> String {
format!("{} ({})", parameter.description, parameter.unit)
}

pub(crate) fn parameter_documentation(parameter: &Parameter) -> String {
format!("{}: {}", parameter.name, parameter_details(parameter))
}

#[cfg(test)]
mod tests {
use crate::model::{
Documentation, Function, FunctionScope, Models, Outputs, Parameter, PublicApi,
};

use super::{Returns, for_function};

fn parameter(name: &str) -> Parameter {
Parameter {
name: name.into(),
unit: "cm^3/cm^3".into(),
domain: None,
description: format!("{name} description."),
}
}

#[test]
fn preserves_empty_optional_documentation_sections() {
let function = Function {
name: "calc_ptf_test".into(),
status: "draft".into(),
public_api: PublicApi {
name: "calc_ptf_test".into(),
result_class: None,
summary: "Estimate a test property.".into(),
},
scope: FunctionScope {
territory: None,
prediction_target: "Test property.".into(),
models: Models::default(),
},
inputs: Vec::new(),
outputs: Outputs::Scalar {
field: parameter("result"),
},
documentation: Documentation::default(),
implementation: None,
golden_tests: Vec::new(),
};

let document = for_function(&function);
assert!(document.territory.is_none());
assert!(document.models.h_theta.is_none());
assert!(document.models.k_h.is_none());
assert!(document.notes.is_empty());
assert!(document.warnings.is_empty());
assert!(matches!(document.returns, Returns::Scalar(_)));
}

#[test]
fn retains_each_record_output_field() {
let function = Function {
name: "calc_ptf_test".into(),
status: "draft".into(),
public_api: PublicApi {
name: "calc_ptf_test".into(),
result_class: Some("TestResult".into()),
summary: "Estimate test properties.".into(),
},
scope: FunctionScope {
territory: Some("Test territory.".into()),
prediction_target: "Test properties.".into(),
models: Models {
h_theta: Some("Retention model.".into()),
k_h: Some("Conductivity model.".into()),
},
},
inputs: vec![parameter("sand")],
outputs: Outputs::Record {
name: "TestResult".into(),
fields: vec![parameter("theta_33"), parameter("theta_1500")],
},
documentation: Documentation {
notes: vec!["A note.".into()],
warnings: vec!["A warning.".into()],
},
implementation: None,
golden_tests: Vec::new(),
};

let document = for_function(&function);
let Returns::Record { name, fields } = document.returns else {
panic!("record outputs must retain their shape");
};
assert_eq!(name, "TestResult");
assert_eq!(
fields
.iter()
.map(|field| field.name.as_str())
.collect::<Vec<_>>(),
["theta_33", "theta_1500"]
);
assert_eq!(document.models.h_theta, Some("Retention model."));
assert_eq!(document.models.k_h, Some("Conductivity model."));
assert_eq!(document.remarks.prediction_target, "Test properties.");
}
}
9 changes: 9 additions & 0 deletions codegen/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ use std::{path::Path, process::ExitCode};
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};

mod compile;
mod documentation;
mod formula;
mod model;
mod output;
mod render;
mod semantic;
mod specs;
mod targets;
Expand All @@ -24,6 +28,7 @@ pub(crate) struct Cli {
enum Command {
Validate,
Generate,
CheckGenerated,
Version { version: String },
}

Expand All @@ -50,6 +55,10 @@ impl Cli {
let entries = load_validated_specifications(root)?;
targets::run(root, entries)
}
Command::CheckGenerated => {
let entries = load_validated_specifications(root)?;
targets::check_generated(root, entries)
}
Command::Version { version } => version::run(root, &version),
}
}
Expand Down
Loading