Skip to content
Closed
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
15 changes: 9 additions & 6 deletions crates/ogar-render-askama/src/artifact_kinds/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,19 @@
//! let source = emitter.emit(&spec)?;
//! ```
//!
//! Proof-of-shape phase: [`RustStruct`](rust_struct::RustStructEmitter) has
//! a real askama template + emitter; the other four kinds use [`Stub`] —
//! placeholder code that compiles and emits a marker comment so callers can
//! exercise the full pipeline (lookup + dispatch + return) without waiting
//! for every template to land. Concrete emitters arrive per-kind in
//! follow-on PRs (T2–T5 in the integration plan).
//! Real emitters (so far): [`RustStruct`](rust_struct::RustStructEmitter)
//! (T1, from PR #78) and [`TsInterface`](ts_interface::TsInterfaceEmitter)
//! (T2, this PR). Remaining kinds use [`Stub`] — placeholder code that
//! compiles and emits a marker comment so callers can exercise the full
//! pipeline (lookup + dispatch + return) without waiting for every
//! template to land. Concrete emitters arrive per-kind in follow-on PRs
//! T3–T5 from the Northstar plan §3.

use crate::spec::{ArtifactKind, ArtifactSpec};

pub mod rust_struct;
pub mod stub;
pub mod ts_interface;

/// Contract every kind's emitter implements.
pub trait ArtifactEmitter {
Expand All @@ -34,6 +36,7 @@ pub trait ArtifactEmitter {
pub fn for_kind(kind: ArtifactKind) -> Box<dyn ArtifactEmitter> {
match kind {
ArtifactKind::RustStruct => Box::new(rust_struct::RustStructEmitter),
ArtifactKind::TsInterface => Box::new(ts_interface::TsInterfaceEmitter),
other => Box::new(stub::Stub { kind: other }),
}
}
181 changes: 181 additions & 0 deletions crates/ogar-render-askama/src/artifact_kinds/ts_interface.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
//! `TsInterface` emitter — T2 from the Northstar plan §3.
//!
//! Lifts an [`ogar_vocab::Class`] into a TypeScript `interface` declaration
//! with a `CLASS_ID` const (the OGAR codebook id, `as const` so consumers
//! get the literal type). Mirrors the shape of
//! [`crate::artifact_kinds::rust_struct`] — different template, different
//! binding-struct field names, same canonical input.
//!
//! Per Northstar §1.6 (mass-mail templates): the template names variables,
//! the binding struct supplies them. The Rust ↔ TS difference lives in the
//! type mapping (`rails_to_ts_type`) and the identifier escape rules
//! (TypeScript allows almost anything as a property name, but a small set
//! of JS keywords is safest quoted) — the rest is the same shape.

use askama::Template;

use super::ArtifactEmitter;
use crate::spec::ArtifactSpec;
use ogar_vocab::{canonical_concept_id, AssociationKind};

#[derive(Template)]
#[template(path = "dispatch/ts_interface.askama", escape = "none")]
struct TsInterfaceCtx {
name: String,
concept_fn: String,
canonical_concept: String,
class_id_hex: String,
attributes: Vec<TsAttr>,
associations: Vec<TsEdge>,
}

struct TsAttr {
name: String,
/// The property name as it appears in the emitted interface. Either
/// a bare identifier or a quoted string for safety on JS reserved
/// words. See [`escape_ts_property`].
ts_name: String,
ts_type: String,
type_name: String,
}

struct TsEdge {
name: String,
ts_name: String,
ts_type: String,
kind_label: String,
target: String,
}

/// The concrete emitter for
/// [`ArtifactKind::TsInterface`](crate::ArtifactKind::TsInterface).
pub struct TsInterfaceEmitter;

impl ArtifactEmitter for TsInterfaceEmitter {
fn emit(&self, spec: &ArtifactSpec<'_>) -> Result<String, askama::Error> {
let class = spec.class;
let concept = class.canonical_concept.as_deref().unwrap_or("");
let class_id_hex = canonical_concept_id(concept)
.map(|id| format!("0x{id:04X}"))
.unwrap_or_default();

let attributes = class
.attributes
.iter()
.map(|a| TsAttr {
name: a.name.clone(),
ts_name: escape_ts_property(&a.name),
ts_type: rails_to_ts_type(a.type_name.as_deref()),
type_name: a.type_name.clone().unwrap_or_default(),
})
.collect();

let associations = class
.associations
.iter()
.map(|a| TsEdge {
name: a.name.clone(),
ts_name: escape_ts_property(&a.name),
ts_type: edge_ts_type(a),
kind_label: assoc_label(a.kind),
target: a.class_name.clone().unwrap_or_default(),
})
.collect();

TsInterfaceCtx {
name: class.name.clone(),
concept_fn: concept.to_string(),
canonical_concept: concept.to_string(),
class_id_hex,
attributes,
associations,
}
.render()
}
}

/// Quote JS reserved words / non-identifier names so the emitted TS
/// compiles. TypeScript accepts any string-literal property name in an
/// interface body (`"type": string;`), so escaping by quoting is the
/// cleanest safe move. Identifiers that are unambiguously bare (snake_case
/// without reserved-word collisions, no embedded dots / spaces) pass
/// through unchanged.
///
/// Conservative list: the union of strict JS reserved words plus a few
/// contextual ones that consumers sometimes trip over (`type` in TS
/// declaration contexts; safe here because we're in an interface body,
/// but quoting costs nothing).
fn escape_ts_property(name: &str) -> String {
// Property names containing characters that can't appear in a bare
// identifier must be quoted (e.g. Odoo-style `"account.move.line"`).
let ident_safe = !name.is_empty()
&& name
.chars()
.enumerate()
.all(|(i, c)| c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit()));
const QUOTED: &[&str] = &[
// Strict JS reserved words (subset that field names sometimes hit).
"break", "case", "catch", "class", "const", "continue", "debugger",
"default", "delete", "do", "else", "enum", "export", "extends",
"false", "finally", "for", "function", "if", "import", "in",
"instanceof", "new", "null", "return", "super", "switch", "this",
"throw", "true", "try", "typeof", "var", "void", "while", "with",
"yield",
// ES strict-mode reserved.
"let", "static", "implements", "interface", "package", "private",
"protected", "public",
// TypeScript contextual keywords — bare in property position is
// technically accepted, but consumers' linters / downstream
// codegen often choke. Conservative quote: zero downside, removes
// a class of "compiles here, breaks at consumer" footguns. This
// is the TS-side companion of Rust's `r#type` escape (codex P1
// on #78).
"type", "async", "await", "as", "from", "of", "is", "infer",
"keyof", "namespace", "satisfies", "readonly",
];
if !ident_safe || QUOTED.contains(&name) {
format!("\"{name}\"")
} else {
name.to_string()
}
}

/// Map a producer-side Rails type name onto a TypeScript type. Coarse —
/// downstream consumers can specialise (e.g. `Decimal` strings vs
/// `number`); the canonical contract round-trips on the structural shape.
fn rails_to_ts_type(t: Option<&str>) -> String {
match t {
Some("string") | Some("text") => "string".into(),
Some("integer") | Some("big_integer") | Some("bigint") => "number".into(),
Some("float") | Some("double") => "number".into(),
Some("decimal") | Some("monetary") => "number".into(),
Some("boolean") | Some("bool") => "boolean".into(),
// ISO-8601 string at the canonical layer; consumers may parse to
// Date downstream.
Some("date") | Some("datetime") | Some("timestamp") => "string".into(),
Some("json") | Some("jsonb") => "unknown".into(),
Some(_) | None => "string".into(),
}
}

fn edge_ts_type(a: &ogar_vocab::Association) -> String {
// Same coarse shape as the Rust emitter: belongs_to / has_one → fk id
// (nullable); has_many / habtm → array of fk ids. Concrete consumers
// can swap for typed object references downstream.
match a.kind {
AssociationKind::HasMany | AssociationKind::HasAndBelongsToMany => {
"ReadonlyArray<number>".into()
}
_ => "number | null".into(),
}
}

fn assoc_label(k: AssociationKind) -> String {
match k {
AssociationKind::BelongsTo => "belongs_to".into(),
AssociationKind::HasOne => "has_one".into(),
AssociationKind::HasMany => "has_many".into(),
AssociationKind::HasAndBelongsToMany => "has_and_belongs_to_many".into(),
_ => format!("{k:?}").to_ascii_lowercase(),
}
}
117 changes: 113 additions & 4 deletions crates/ogar-render-askama/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,11 @@ mod tests {

#[test]
fn stub_emits_marker_for_unimplemented_kinds() {
// The four stub kinds compile + emit a marker comment naming the
// kind + class. This is what lets the kit be wired end-to-end
// before every template has landed.
// The remaining stub kinds compile + emit a marker comment naming
// the kind + class. T1 (RustStruct) + T2 (TsInterface) now have
// real emitters; T3 / T4 / T5 are still stubbed (per Northstar §3).
let class = project();
for kind in [
ArtifactKind::TsInterface,
ArtifactKind::SurrealqlTable,
ArtifactKind::OpenapiSchema,
ArtifactKind::NodeGuidRoutingArm,
Expand All @@ -185,6 +184,116 @@ mod tests {
}
}

#[test]
fn ts_interface_emits_class_id_and_canonical_concept() {
// T2 proof of shape: render project_work_item — verify the emitted
// .ts source declares the right interface name + CLASS_ID const
// (`as const` for literal-type) + canonical concept const.
let class = project_work_item();
let src = render(&class, ArtifactKind::TsInterface).unwrap();
assert!(
src.contains("export interface ProjectWorkItem"),
"ts_interface should declare `export interface ProjectWorkItem`:\n{src}"
);
assert!(
src.contains("export const CLASS_ID = 0x0102 as const;"),
"expected CLASS_ID = 0x0102 (as const) in:\n{src}"
);
assert!(
src.contains("export const CANONICAL_CONCEPT = \"project_work_item\" as const;"),
"{src}"
);
}

#[test]
fn ts_interface_maps_rails_types_to_ts() {
// Coarse Rails→TS type mapping: integer → number, string → string,
// boolean → boolean. Pinned against project_role (carries `name`,
// `position` int, `permissions` text).
let class = project_role();
let src = render(&class, ArtifactKind::TsInterface).unwrap();
// `name: string` (Rails "string")
assert!(src.contains("name: string;"), "expected `name: string;` in:\n{src}");
// `position: number` (Rails "integer")
assert!(
src.contains("position: number;"),
"expected `position: number;` in:\n{src}"
);
// `permissions: string` (Rails "text")
assert!(
src.contains("permissions: string;"),
"expected `permissions: string;` in:\n{src}"
);
}

#[test]
fn ts_interface_emits_family_edges_as_arrays_and_nullable_ids() {
// belongs_to / has_one → `number | null` (nullable FK id)
// has_many / habtm → `ReadonlyArray<number>`
let class = billable_work_entry();
let src = render(&class, ArtifactKind::TsInterface).unwrap();
// Every family edge name should appear as a TS property.
for edge in &class.associations {
assert!(
src.contains(&format!("{}: ", edge.name)),
"ts_interface missing family edge `{}`:\n{src}",
edge.name
);
}
// At least one of each shape:
assert!(
src.contains("number | null"),
"expected at least one belongs_to/has_one as `number | null`:\n{src}"
);
// (billable_work_entry's edges are all belongs_to today, so
// `ReadonlyArray<number>` may not appear — assert via project_actor
// which has the `groups` / `users` has_many shape.)
let actor_src = render(&project_actor(), ArtifactKind::TsInterface).unwrap();
assert!(
actor_src.contains("ReadonlyArray<number>"),
"expected at least one has_many edge as `ReadonlyArray<number>`:\n{actor_src}"
);
}

#[test]
fn ts_interface_handles_keyword_property_names_safely() {
// project_actor declares an attribute named `type` (the same hazard
// that bit Rust on PR #78). TypeScript accepts `type` as a property
// name in interface bodies, but quoting is the conservative move
// for any JS reserved word. Per `escape_ts_property`, `type` is in
// the conservative quote list -> emitted as `"type": string;`.
let class = project_actor();
let src = render(&class, ArtifactKind::TsInterface).unwrap();
assert!(
src.contains("\"type\": string;"),
"expected `\"type\": string;` (quoted JS-reserved name) in:\n{src}"
);
assert!(
!src.contains("\n type: "),
"unquoted `type:` is a hazard; emitter must quote it:\n{src}"
);
}

#[test]
fn ts_interface_quotes_dotted_property_names() {
// Odoo-style identifiers (e.g. `account.move.line`) aren't valid
// bare TS property names — must be quoted. This is the same logic
// catching `escape_ts_property`'s non-identifier branch.
// Use a fabricated class with a dotted attribute name.
use ogar_vocab::{Attribute, Class as VocabClass, Language};
let mut c = VocabClass::new("Synth");
c.canonical_concept = Some("synth".to_string());
c.language = Language::Unknown;
let mut a = Attribute::new("account.move.line");
a.type_name = Some("string".to_string());
c.attributes = vec![a];
let src = render(&c, ArtifactKind::TsInterface).unwrap();
assert!(
src.contains("\"account.move.line\": string;"),
"dotted property name must be quoted:\n{src}"
);
}

#[test]
fn rust_struct_escapes_keyword_attribute_names() {
// Codex P1 on #78: `project_actor()` declares an attribute named
Expand Down
31 changes: 31 additions & 0 deletions crates/ogar-render-askama/templates/dispatch/ts_interface.askama
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{# Render one canonical concept as a TypeScript interface + class-id constant. #}
{# Mass-mail simple: bag-of-variables substitution. Per the Northstar §1.6, #}
{# the template just names what it wants; the binding struct supplies it. #}
//
// {{ name }} — canonical class generated from `ogar_vocab::{{ concept_fn }}()`.
// DO NOT EDIT BY HAND. Re-render via `ogar-render-askama`.
//

/** Canonical OGAR codebook id for [`{{ name }}`]. */
{% if class_id_hex.len() > 0 -%}
export const CLASS_ID = {{ class_id_hex }} as const;
{% else -%}
// (no codebook id — concept not yet promoted)
{% endif %}

/** Canonical concept name as in the OGAR codebook. */
export const CANONICAL_CONCEPT = "{{ canonical_concept }}" as const;

export interface {{ name }} {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Sanitize interface names before emitting

When rendering a supported Class whose name is not a TypeScript identifier, this splices the raw source name into the declaration and produces invalid TS such as export interface account.move { (the Class::name docs explicitly preserve dotted ORM names in crates/ogar-vocab/src/lib.rs:89-91, and aliases like account.analytic.line are part of the codebook flow). Since this emitter accepts arbitrary ogar_vocab::Class values, derive or sanitize a valid TS interface identifier instead of using the source name directly.

Useful? React with 👍 / 👎.

{%- for attr in attributes %}
/** `{{ attr.name }}`{% if attr.type_name.len() > 0 %} (curator type: `{{ attr.type_name }}`){% endif %} */
{{ attr.ts_name }}: {{ attr.ts_type }};
{%- endfor %}
{%- if associations.len() > 0 %}
// Family edges (canonical relations):
{%- for edge in associations %}
/** {{ edge.kind_label }} `{{ edge.name }}`{% if edge.target.len() > 0 %} → `{{ edge.target }}`{% endif %} */
{{ edge.ts_name }}: {{ edge.ts_type }};
{%- endfor %}
{%- endif %}
}
Loading