Skip to main content

intent_codegen/
python.rs

1//! Python skeleton code generator.
2
3use intent_parser::ast;
4
5use crate::types::map_type;
6use crate::{Language, doc_text, format_ensures_item, format_expr, to_snake_case};
7
8/// Python reserved keywords that cannot be used as identifiers.
9const PYTHON_KEYWORDS: &[&str] = &[
10    "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue",
11    "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import",
12    "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while",
13    "with", "yield",
14];
15
16/// Escape a Python identifier if it collides with a reserved keyword.
17fn safe_ident(name: &str) -> String {
18    let snake = to_snake_case(name);
19    if PYTHON_KEYWORDS.contains(&snake.as_str()) {
20        format!("{snake}_")
21    } else {
22        snake
23    }
24}
25
26/// Generate Python skeleton code from a parsed intent file.
27pub fn generate(file: &ast::File) -> String {
28    let lang = Language::Python;
29    let mut out = String::new();
30
31    // Header
32    out.push_str(&format!("# Generated from {}.intent\n", file.module.name));
33    if let Some(doc) = &file.doc {
34        out.push_str(&format!("\"\"\"{}\"\"\"", doc_text(doc)));
35        out.push('\n');
36    }
37    out.push('\n');
38
39    // Imports
40    out.push_str(&generate_imports(file));
41    out.push('\n');
42
43    for item in &file.items {
44        match item {
45            ast::TopLevelItem::Entity(e) => generate_entity(&mut out, e, &lang),
46            ast::TopLevelItem::Action(a) => generate_action(&mut out, a, &lang),
47            ast::TopLevelItem::Invariant(inv) => generate_invariant(&mut out, inv),
48            ast::TopLevelItem::EdgeCases(ec) => generate_edge_cases(&mut out, ec),
49            ast::TopLevelItem::Test(_) => {}
50        }
51    }
52
53    out
54}
55
56fn generate_imports(file: &ast::File) -> String {
57    let mut out = String::from("from __future__ import annotations\n\n");
58    let source = collect_type_names(file);
59
60    out.push_str("from dataclasses import dataclass\n");
61
62    if source.contains("Decimal") {
63        out.push_str("from decimal import Decimal\n");
64    }
65    if source.contains("DateTime") {
66        out.push_str("from datetime import datetime\n");
67    }
68    if source.contains("UUID") {
69        out.push_str("import uuid\n");
70    }
71
72    // Check for union types (Literal needed)
73    let has_union = file.items.iter().any(|item| {
74        if let ast::TopLevelItem::Entity(e) = item {
75            e.fields
76                .iter()
77                .any(|f| matches!(f.ty.ty, ast::TypeKind::Union(_)))
78        } else {
79            false
80        }
81    });
82    if has_union {
83        out.push_str("from typing import Literal\n");
84    }
85
86    out.push('\n');
87    out
88}
89
90fn collect_type_names(file: &ast::File) -> String {
91    let mut names = String::new();
92    for item in &file.items {
93        match item {
94            ast::TopLevelItem::Entity(e) => {
95                for f in &e.fields {
96                    collect_type_name(&f.ty, &mut names);
97                }
98            }
99            ast::TopLevelItem::Action(a) => {
100                for p in &a.params {
101                    collect_type_name(&p.ty, &mut names);
102                }
103            }
104            _ => {}
105        }
106    }
107    names
108}
109
110fn collect_type_name(ty: &ast::TypeExpr, out: &mut String) {
111    match &ty.ty {
112        ast::TypeKind::Simple(n) => {
113            out.push_str(n);
114            out.push(' ');
115        }
116        ast::TypeKind::Parameterized { name, .. } => {
117            out.push_str(name);
118            out.push(' ');
119        }
120        ast::TypeKind::List(inner) | ast::TypeKind::Set(inner) => collect_type_name(inner, out),
121        ast::TypeKind::Map(k, v) => {
122            collect_type_name(k, out);
123            collect_type_name(v, out);
124        }
125        ast::TypeKind::Union(_) => {} // union doesn't affect stdlib imports
126    }
127}
128
129fn generate_entity(out: &mut String, entity: &ast::EntityDecl, lang: &Language) {
130    out.push_str("@dataclass\n");
131    out.push_str(&format!("class {}:\n", entity.name));
132
133    // Docstring
134    if let Some(doc) = &entity.doc {
135        out.push_str(&format!("    \"\"\"{}\"\"\"\n\n", doc_text(doc)));
136    }
137
138    for field in &entity.fields {
139        let ty = map_type(&field.ty, lang);
140        out.push_str(&format!("    {}: {}\n", safe_ident(&field.name), ty));
141    }
142
143    out.push('\n');
144    out.push('\n');
145}
146
147fn generate_action(out: &mut String, action: &ast::ActionDecl, lang: &Language) {
148    let fn_name = to_snake_case(&action.name);
149    let params: Vec<String> = action
150        .params
151        .iter()
152        .map(|p| {
153            let ty = map_type(&p.ty, lang);
154            format!("{}: {ty}", safe_ident(&p.name))
155        })
156        .collect();
157
158    out.push_str(&format!("def {fn_name}({}) -> None:\n", params.join(", ")));
159
160    // Docstring
161    let mut doc_lines = Vec::new();
162    if let Some(doc) = &action.doc {
163        doc_lines.push(doc_text(doc));
164        doc_lines.push(String::new());
165    }
166
167    if let Some(req) = &action.requires {
168        doc_lines.push("Requires:".to_string());
169        for cond in &req.conditions {
170            doc_lines.push(format!("    - {}", format_expr(cond)));
171        }
172        doc_lines.push(String::new());
173    }
174
175    if let Some(ens) = &action.ensures {
176        doc_lines.push("Ensures:".to_string());
177        for item in &ens.items {
178            doc_lines.push(format!("    - {}", format_ensures_item(item)));
179        }
180        doc_lines.push(String::new());
181    }
182
183    if let Some(props) = &action.properties {
184        doc_lines.push("Properties:".to_string());
185        for entry in &props.entries {
186            doc_lines.push(format!(
187                "    - {}: {}",
188                entry.key,
189                crate::format_prop_value(&entry.value)
190            ));
191        }
192        doc_lines.push(String::new());
193    }
194
195    if !doc_lines.is_empty() {
196        out.push_str("    \"\"\"\n");
197        for line in &doc_lines {
198            if line.is_empty() {
199                out.push('\n');
200            } else {
201                out.push_str(&format!("    {line}\n"));
202            }
203        }
204        out.push_str("    \"\"\"\n");
205    }
206
207    out.push_str(&format!(
208        "    raise NotImplementedError(\"TODO: implement {fn_name}\")\n"
209    ));
210    out.push('\n');
211    out.push('\n');
212}
213
214fn generate_invariant(out: &mut String, inv: &ast::InvariantDecl) {
215    out.push_str(&format!("# Invariant: {}\n", inv.name));
216    if let Some(doc) = &inv.doc {
217        for line in doc_text(doc).lines() {
218            out.push_str(&format!("# {line}\n"));
219        }
220    }
221    out.push_str(&format!("# {}\n\n", format_expr(&inv.body)));
222}
223
224fn generate_edge_cases(out: &mut String, ec: &ast::EdgeCasesDecl) {
225    out.push_str("# Edge cases:\n");
226    for rule in &ec.rules {
227        out.push_str(&format!(
228            "# when {} => {}()\n",
229            format_expr(&rule.condition),
230            rule.action.name,
231        ));
232    }
233    out.push('\n');
234}