Skip to content

Commit 7d0dca2

Browse files
authored
Merge pull request #141 from AdaWorldAPI/claude/odoo-rs-transcode-lf8ya5
feat(ogar-from-ruff): typed scalar emit + reserved-word escaping (Odoo field_type)
2 parents 328ea31 + 083d73a commit 7d0dca2

3 files changed

Lines changed: 225 additions & 27 deletions

File tree

crates/ogar-from-ruff/Cargo.toml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ serde = ["dep:serde", "ogar-vocab/serde"]
1414

1515
[dependencies]
1616
ogar-vocab = { path = "../ogar-vocab" }
17-
ruff_spo_triplet = { git = "https://github.com/AdaWorldAPI/ruff", branch = "main" }
18-
ruff_spo_address = { git = "https://github.com/AdaWorldAPI/ruff", branch = "main" }
17+
# Temporarily pinned to the ruff `field_type` commit (rev) — `Field.field_type`
18+
# is consumed by `project_odoo_fields` but is not yet on ruff `main`; it lands
19+
# via the ruff PR-to-main on branch `claude/odoo-rs-transcode-lf8ya5` (this arc,
20+
# rev 4860e79 also carries the soc operator-veto fix 101928a). Flip both back to
21+
# `branch = "main"` once that ruff PR merges.
22+
ruff_spo_triplet = { git = "https://github.com/AdaWorldAPI/ruff", rev = "4860e7987aa0c9453f812bb0178962d1cba66df0" }
23+
ruff_spo_address = { git = "https://github.com/AdaWorldAPI/ruff", rev = "4860e7987aa0c9453f812bb0178962d1cba66df0" }
1924
serde = { workspace = true, optional = true }

crates/ogar-from-ruff/src/emit.rs

Lines changed: 207 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,19 @@
2222
//! **mechanical transliteration** of the same compiled spine rather than a
2323
//! re-implementation — the layer-1 / layer-2 story of substrate doc §1.6.
2424
//!
25-
//! Scalar attributes currently emit `OgScalar` uniformly: the Odoo field type
26-
//! (`Char` / `Monetary` / …) is not yet carried on the SPO `Field`, so there
27-
//! is nothing to specialise on. When the `field_type` capture lands (the ruff
28-
//! follow-up), `OgScalar` refines to the mapped concrete type with no change
29-
//! to this seam.
25+
//! Scalar attributes emit a **concrete wrapper type** mapped from the Odoo
26+
//! constructor carried on `Attribute::type_name` (the ruff `field_type`
27+
//! predicate): `char`/`text`/`html` → `OgStr`, `integer` → `OgInt`,
28+
//! `float` → `OgFloat`, `monetary` → `OgMoney` (Decimal-backed), `boolean` →
29+
//! `OgBool`, `date`/`datetime` → `OgDate`/`OgDateTime`, `binary` → `OgBytes`,
30+
//! `selection` → `OgSelection`, `json` → `OgJson`; an untyped/unknown column
31+
//! falls back to the generic `OgScalar`. See [`og_scalar_type`].
32+
//!
33+
//! Field identifiers that collide with a target-language reserved word are
34+
//! escaped (see [`escape_ident`]) so the emitted source compiles — e.g. an
35+
//! Odoo field named `type` / `ref` becomes `r#type` / `r#ref` in Rust and
36+
//! `@type` / `@ref` in C#. Python source field names cannot be keywords (the
37+
//! Odoo source would not parse), so Python emit needs no escaping.
3038
3139
use ogar_vocab::{Association, AssociationKind};
3240

@@ -47,6 +55,86 @@ fn assoc_target(assoc: &Association) -> (String, bool) {
4755
(target, is_many)
4856
}
4957

58+
/// Map an Odoo field constructor (lowercased, carried on `Attribute::type_name`
59+
/// as the ruff `field_type` predicate) to the consumer wrapper-contract scalar
60+
/// type. Shared by all three emitters — the type NAMES are identical across
61+
/// languages (§1.6). `None` (type not captured) and any unrecognised
62+
/// constructor fall back to the generic `OgScalar`, so the emit is always
63+
/// well-typed. `monetary` → `OgMoney` is Decimal-backed (the ERP money
64+
/// doctrine), never a float.
65+
fn og_scalar_type(type_name: Option<&str>) -> &'static str {
66+
match type_name {
67+
Some("char" | "text" | "html") => "OgStr",
68+
Some("integer") => "OgInt",
69+
Some("float") => "OgFloat",
70+
Some("monetary") => "OgMoney",
71+
Some("boolean") => "OgBool",
72+
Some("date") => "OgDate",
73+
Some("datetime") => "OgDateTime",
74+
Some("binary" | "image") => "OgBytes",
75+
Some("selection") => "OgSelection",
76+
Some("json") => "OgJson",
77+
_ => "OgScalar",
78+
}
79+
}
80+
81+
/// Target language for [`escape_ident`].
82+
#[derive(Clone, Copy)]
83+
enum Lang {
84+
Rust,
85+
CSharp,
86+
Python,
87+
}
88+
89+
/// Escape an emitted field/member identifier that collides with a
90+
/// target-language reserved word, so the generated source compiles.
91+
///
92+
/// Odoo field names are verbatim `snake_case`; some (`type`, `ref`, `move`, …)
93+
/// are Rust and/or C# keywords, and a raw `pub type: …` / `public … type` would
94+
/// not compile. Rust uses raw identifiers (`r#type`); the four that cannot be
95+
/// raw (`crate`/`self`/`super`/`Self`) get a trailing `_`. C# prefixes `@`
96+
/// (`@ref`). Python source field names cannot be keywords (the Odoo class body
97+
/// would not parse), so Python returns the name unchanged.
98+
fn escape_ident(name: &str, lang: Lang) -> String {
99+
match lang {
100+
Lang::Rust if matches!(name, "crate" | "self" | "super" | "Self") => format!("{name}_"),
101+
Lang::Rust if is_rust_keyword(name) => format!("r#{name}"),
102+
Lang::CSharp if is_csharp_keyword(name) => format!("@{name}"),
103+
_ => name.to_string(),
104+
}
105+
}
106+
107+
/// Rust strict + reserved keywords (2021 edition + reserved-for-future).
108+
fn is_rust_keyword(s: &str) -> bool {
109+
matches!(
110+
s,
111+
"as" | "break" | "const" | "continue" | "crate" | "dyn" | "else" | "enum" | "extern"
112+
| "false" | "fn" | "for" | "if" | "impl" | "in" | "let" | "loop" | "match" | "mod"
113+
| "move" | "mut" | "pub" | "ref" | "return" | "self" | "Self" | "static" | "struct"
114+
| "super" | "trait" | "true" | "type" | "unsafe" | "use" | "where" | "while"
115+
| "async" | "await" | "abstract" | "become" | "box" | "do" | "final" | "macro"
116+
| "override" | "priv" | "typeof" | "unsized" | "virtual" | "yield" | "try" | "gen"
117+
)
118+
}
119+
120+
/// C# reserved keywords. Contextual keywords (`type`, `record`, `var`, …) are
121+
/// legal identifiers in C#, so they are deliberately omitted.
122+
fn is_csharp_keyword(s: &str) -> bool {
123+
matches!(
124+
s,
125+
"abstract" | "as" | "base" | "bool" | "break" | "byte" | "case" | "catch" | "char"
126+
| "checked" | "class" | "const" | "continue" | "decimal" | "default" | "delegate"
127+
| "do" | "double" | "else" | "enum" | "event" | "explicit" | "extern" | "false"
128+
| "finally" | "fixed" | "float" | "for" | "foreach" | "goto" | "if" | "implicit"
129+
| "in" | "int" | "interface" | "internal" | "is" | "lock" | "long" | "namespace"
130+
| "new" | "null" | "object" | "operator" | "out" | "override" | "params" | "private"
131+
| "protected" | "public" | "readonly" | "ref" | "return" | "sbyte" | "sealed"
132+
| "short" | "sizeof" | "stackalloc" | "static" | "string" | "struct" | "switch"
133+
| "this" | "throw" | "true" | "try" | "typeof" | "uint" | "ulong" | "unchecked"
134+
| "unsafe" | "ushort" | "using" | "virtual" | "void" | "volatile" | "while"
135+
)
136+
}
137+
50138
/// Emit a [`CompiledClass`] as Rust source: a struct whose fields use the
51139
/// consumer's wrapper-contract types (`OgScalar` / `ToOne` / `ToMany`),
52140
/// prefixed by its rail `classid` const and a facet/concept doc line.
@@ -71,7 +159,11 @@ pub fn emit_rust(cc: &CompiledClass) -> String {
71159

72160
out.push_str(&format!("pub struct {ty} {{\n"));
73161
for attr in &cc.class.attributes {
74-
out.push_str(&format!(" pub {}: OgScalar,\n", attr.name));
162+
out.push_str(&format!(
163+
" pub {}: {},\n",
164+
escape_ident(&attr.name, Lang::Rust),
165+
og_scalar_type(attr.type_name.as_deref()),
166+
));
75167
}
76168
for assoc in &cc.class.associations {
77169
let (target, is_many) = assoc_target(assoc);
@@ -80,7 +172,10 @@ pub fn emit_rust(cc: &CompiledClass) -> String {
80172
} else {
81173
format!("ToOne<{target}>")
82174
};
83-
out.push_str(&format!(" pub {}: {field_ty},\n", assoc.name));
175+
out.push_str(&format!(
176+
" pub {}: {field_ty},\n",
177+
escape_ident(&assoc.name, Lang::Rust),
178+
));
84179
}
85180
out.push_str("}\n");
86181

@@ -105,10 +200,10 @@ pub fn emit_rust(cc: &CompiledClass) -> String {
105200
/// codegen mode of the C# SDK (substrate doc §1.6); a host compiles the emitted
106201
/// record into an assembly — the strongest "compiled, not parsed" form.
107202
///
108-
/// Field/member identifiers are emitted **verbatim** (Odoo `snake_case`),
109-
/// matching [`emit_rust`]'s wire fidelity; idiomatic PascalCase member casing
110-
/// is a future refinement on this same seam (cf. the `OgScalar` `field_type`
111-
/// note above).
203+
/// Field/member identifiers keep their Odoo `snake_case` spelling (matching
204+
/// [`emit_rust`]'s wire fidelity), reserved-word-escaped via [`escape_ident`]
205+
/// (`@ref`); idiomatic PascalCase member casing is a future refinement on this
206+
/// same seam.
112207
#[must_use]
113208
pub fn emit_csharp(cc: &CompiledClass) -> String {
114209
let ty = pascal_case(&cc.class.name);
@@ -127,8 +222,9 @@ pub fn emit_csharp(cc: &CompiledClass) -> String {
127222
));
128223
for attr in &cc.class.attributes {
129224
out.push_str(&format!(
130-
" public OgScalar {} {{ get; init; }}\n",
131-
attr.name
225+
" public {} {} {{ get; init; }}\n",
226+
og_scalar_type(attr.type_name.as_deref()),
227+
escape_ident(&attr.name, Lang::CSharp),
132228
));
133229
}
134230
for assoc in &cc.class.associations {
@@ -140,7 +236,7 @@ pub fn emit_csharp(cc: &CompiledClass) -> String {
140236
};
141237
out.push_str(&format!(
142238
" public {field_ty} {} {{ get; init; }}\n",
143-
assoc.name
239+
escape_ident(&assoc.name, Lang::CSharp),
144240
));
145241
}
146242
for c in &cc.class.computed_fields {
@@ -182,7 +278,11 @@ pub fn emit_python(cc: &CompiledClass) -> String {
182278
cc.facet.facet_classid(),
183279
));
184280
for attr in &cc.class.attributes {
185-
out.push_str(&format!(" {}: OgScalar\n", attr.name));
281+
out.push_str(&format!(
282+
" {}: {}\n",
283+
escape_ident(&attr.name, Lang::Python),
284+
og_scalar_type(attr.type_name.as_deref()),
285+
));
186286
}
187287
for assoc in &cc.class.associations {
188288
let (target, is_many) = assoc_target(assoc);
@@ -191,7 +291,10 @@ pub fn emit_python(cc: &CompiledClass) -> String {
191291
} else {
192292
format!("ToOne[\"{target}\"]")
193293
};
194-
out.push_str(&format!(" {}: {field_ty}\n", assoc.name));
294+
out.push_str(&format!(
295+
" {}: {field_ty}\n",
296+
escape_ident(&assoc.name, Lang::Python),
297+
));
195298
}
196299
for c in &cc.class.computed_fields {
197300
out.push_str(&format!(
@@ -240,6 +343,18 @@ mod tests {
240343
let mut m = Model::new("account_move");
241344
m.fields.push(Field {
242345
name: "name".to_string(),
346+
field_type: Some("char".to_string()),
347+
..Default::default()
348+
});
349+
// A field whose name is a Rust + C# reserved word — exercises escape_ident.
350+
m.fields.push(Field {
351+
name: "ref".to_string(),
352+
field_type: Some("char".to_string()),
353+
..Default::default()
354+
});
355+
// No field_type → the OgScalar fallback path.
356+
m.fields.push(Field {
357+
name: "narration".to_string(),
243358
..Default::default()
244359
});
245360
m.fields.push(Field {
@@ -255,8 +370,10 @@ mod tests {
255370
relation_kind: Some("one2many".to_string()),
256371
..Default::default()
257372
});
373+
// Monetary + computed → OgMoney (Decimal-backed) AND a computed doc line.
258374
m.fields.push(Field {
259375
name: "amount_total".to_string(),
376+
field_type: Some("monetary".to_string()),
260377
emitted_by: Some("_compute_amount".to_string()),
261378
depends_on: vec!["line_ids.balance".to_string()],
262379
..Default::default()
@@ -281,8 +398,14 @@ mod tests {
281398
assert!(rust.contains("pub const ACCOUNT_MOVE_CLASSID: u32 = 0x00020202;"));
282399
// The struct is PascalCase.
283400
assert!(rust.contains("pub struct AccountMove {"));
284-
// Scalar -> the wrapper's OgScalar.
285-
assert!(rust.contains("pub name: OgScalar,"));
401+
// Typed scalar: char -> OgStr.
402+
assert!(rust.contains("pub name: OgStr,"), "got:\n{rust}");
403+
// Reserved-word field name -> raw identifier r#ref (and still typed).
404+
assert!(rust.contains("pub r#ref: OgStr,"), "got:\n{rust}");
405+
// Untyped scalar -> the OgScalar fallback.
406+
assert!(rust.contains("pub narration: OgScalar,"), "got:\n{rust}");
407+
// Monetary -> OgMoney (Decimal-backed money doctrine).
408+
assert!(rust.contains("pub amount_total: OgMoney,"), "got:\n{rust}");
286409
// Many2one -> ToOne<comodel>; One2many -> ToMany<comodel>.
287410
assert!(
288411
rust.contains("pub partner_id: ToOne<ResPartner>,"),
@@ -311,9 +434,23 @@ mod tests {
311434
cs.contains("public sealed record AccountMove"),
312435
"got:\n{cs}"
313436
);
314-
// Scalar -> the wrapper's OgScalar (init-only property).
437+
// Typed scalar: char -> OgStr (init-only property).
315438
assert!(
316-
cs.contains("public OgScalar name { get; init; }"),
439+
cs.contains("public OgStr name { get; init; }"),
440+
"got:\n{cs}"
441+
);
442+
// Reserved-word field name -> @-escaped C# identifier (and still typed).
443+
assert!(
444+
cs.contains("public OgStr @ref { get; init; }"),
445+
"got:\n{cs}"
446+
);
447+
// Untyped scalar -> the OgScalar fallback; monetary -> OgMoney.
448+
assert!(
449+
cs.contains("public OgScalar narration { get; init; }"),
450+
"got:\n{cs}"
451+
);
452+
assert!(
453+
cs.contains("public OgMoney amount_total { get; init; }"),
317454
"got:\n{cs}"
318455
);
319456
// Many2one -> ToOne<comodel>; One2many -> ToMany<comodel> (shared <T> syntax).
@@ -345,8 +482,14 @@ mod tests {
345482
// A PascalCase @dataclass.
346483
assert!(py.contains("@dataclass"), "got:\n{py}");
347484
assert!(py.contains("class AccountMove:"), "got:\n{py}");
348-
// Scalar -> OgScalar annotation.
349-
assert!(py.contains(" name: OgScalar"), "got:\n{py}");
485+
// Typed scalar: char -> OgStr annotation.
486+
assert!(py.contains(" name: OgStr"), "got:\n{py}");
487+
// Reserved-word field name needs NO escaping in Python (Odoo source
488+
// field names cannot be Python keywords); still typed.
489+
assert!(py.contains(" ref: OgStr"), "got:\n{py}");
490+
// Untyped scalar -> OgScalar fallback; monetary -> OgMoney.
491+
assert!(py.contains(" narration: OgScalar"), "got:\n{py}");
492+
assert!(py.contains(" amount_total: OgMoney"), "got:\n{py}");
350493
// Relations use [T] subscripts with forward-ref comodels (not <T>).
351494
assert!(
352495
py.contains(" partner_id: ToOne[\"ResPartner\"]"),
@@ -369,6 +512,10 @@ mod tests {
369512
// syntax differs. Assert the shared vocabulary across all three.
370513
let cc = &compile_graph_python::<OdooPort>(&account_move_graph())[0];
371514
for src in [emit_rust(cc), emit_csharp(cc), emit_python(cc)] {
515+
// Typed scalar wrappers (mapped from field_type) are shared vocab.
516+
assert!(src.contains("OgStr"), "OgStr in every emitter");
517+
assert!(src.contains("OgMoney"), "OgMoney in every emitter");
518+
// The untyped fallback is shared too.
372519
assert!(src.contains("OgScalar"), "OgScalar in every emitter");
373520
assert!(src.contains("ToOne"), "ToOne in every emitter");
374521
assert!(src.contains("ToMany"), "ToMany in every emitter");
@@ -387,4 +534,42 @@ mod tests {
387534
assert_eq!(pascal_case("res.partner"), "ResPartner");
388535
assert_eq!(screaming_snake("account_move"), "ACCOUNT_MOVE");
389536
}
537+
538+
#[test]
539+
fn og_scalar_type_maps_odoo_constructors() {
540+
assert_eq!(og_scalar_type(Some("char")), "OgStr");
541+
assert_eq!(og_scalar_type(Some("text")), "OgStr");
542+
assert_eq!(og_scalar_type(Some("html")), "OgStr");
543+
assert_eq!(og_scalar_type(Some("integer")), "OgInt");
544+
assert_eq!(og_scalar_type(Some("float")), "OgFloat");
545+
assert_eq!(og_scalar_type(Some("monetary")), "OgMoney");
546+
assert_eq!(og_scalar_type(Some("boolean")), "OgBool");
547+
assert_eq!(og_scalar_type(Some("date")), "OgDate");
548+
assert_eq!(og_scalar_type(Some("datetime")), "OgDateTime");
549+
assert_eq!(og_scalar_type(Some("binary")), "OgBytes");
550+
assert_eq!(og_scalar_type(Some("selection")), "OgSelection");
551+
assert_eq!(og_scalar_type(Some("json")), "OgJson");
552+
// Unknown constructor and absent type both fall back to OgScalar.
553+
assert_eq!(og_scalar_type(Some("reference")), "OgScalar");
554+
assert_eq!(og_scalar_type(None), "OgScalar");
555+
}
556+
557+
#[test]
558+
fn escape_ident_per_language_reserved_words() {
559+
// Rust: raw identifiers for keywords; the four non-raw-able get a suffix.
560+
assert_eq!(escape_ident("ref", Lang::Rust), "r#ref");
561+
assert_eq!(escape_ident("type", Lang::Rust), "r#type");
562+
assert_eq!(escape_ident("move", Lang::Rust), "r#move");
563+
assert_eq!(escape_ident("self", Lang::Rust), "self_");
564+
assert_eq!(escape_ident("crate", Lang::Rust), "crate_");
565+
assert_eq!(escape_ident("amount", Lang::Rust), "amount");
566+
// C#: @-escape reserved words; contextual keywords (type) stay legal.
567+
assert_eq!(escape_ident("ref", Lang::CSharp), "@ref");
568+
assert_eq!(escape_ident("lock", Lang::CSharp), "@lock");
569+
assert_eq!(escape_ident("type", Lang::CSharp), "type");
570+
assert_eq!(escape_ident("amount", Lang::CSharp), "amount");
571+
// Python: no escaping (Odoo source field names cannot be keywords).
572+
assert_eq!(escape_ident("ref", Lang::Python), "ref");
573+
assert_eq!(escape_ident("type", Lang::Python), "type");
574+
}
390575
}

crates/ogar-from-ruff/src/lib.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,10 @@ fn lift_model_with_language(model: &Model, language: Language) -> Class {
205205
/// - relational field (`target` set) → [`Association`]; the kind comes from
206206
/// the field's cardinality (`relation_kind`), `class_name` is the raw
207207
/// comodel, `inverse_of` the One2many inverse.
208-
/// - non-relational field → [`Attribute`] (name only — the Odoo field type
209-
/// is not yet carried on the SPO `Field`; a follow-up).
208+
/// - non-relational field → [`Attribute`] with `type_name` set from the SPO
209+
/// `Field`'s `field_type` (the lowercased Odoo constructor — `char` /
210+
/// `integer` / `monetary` / …), so the emitters pick a concrete wrapper type
211+
/// instead of the untyped `OgScalar` fallback.
210212
/// - compute field (`emitted_by` set) → [`ComputedField`] (method +
211213
/// `@api.depends`), in addition to its Attribute / Association above.
212214
fn project_odoo_fields(class: &mut Class, model: &Model) {
@@ -218,7 +220,13 @@ fn project_odoo_fields(class: &mut Class, model: &Model) {
218220
assoc.inverse_of = field.inverse_name.clone();
219221
class.associations.push(assoc);
220222
} else {
221-
class.attributes.push(Attribute::new(&field.name));
223+
let mut attr = Attribute::new(&field.name);
224+
// Carry the Odoo constructor (field_type) so the emitters can pick a
225+
// concrete wrapper type (OgStr/OgInt/OgMoney/…) instead of the
226+
// untyped OgScalar fallback. None for a field whose type ruff did
227+
// not capture → OgScalar (the safe default).
228+
attr.type_name = field.field_type.clone();
229+
class.attributes.push(attr);
222230
}
223231
if let Some(compute_method) = &field.emitted_by {
224232
let mut computed = ComputedField::new(&field.name, compute_method);

0 commit comments

Comments
 (0)