Skip to content

Commit 6365445

Browse files
dmealingclaude
andcommitted
feat(codegen-spring): FR-010 OutputFormatSpecEmitter (VO+template -> OutputFormatSpec source)
Pure helper that turns a value-object MetaObject + its template.output node into a Java source literal for an OutputFormatSpec (the artifact-1 prompt descriptor). Mirrors RecoverSchemaEmitter's field-walk, FieldKind mapping, and Map.ofEntries pattern; adds @PromptStyle→PromptStyle mapping, @example/@Instruction reads, and a javaStringLiteral() escaping helper for free-text attrs. 14 new unit tests, full module green (64/64). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0bb4271 commit 6365445

3 files changed

Lines changed: 725 additions & 0 deletions

File tree

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
package com.metaobjects.generator.spring;
2+
3+
import com.metaobjects.field.BooleanField;
4+
import com.metaobjects.field.DoubleField;
5+
import com.metaobjects.field.EnumField;
6+
import com.metaobjects.field.IntegerField;
7+
import com.metaobjects.field.LongField;
8+
import com.metaobjects.field.MetaField;
9+
import com.metaobjects.field.ObjectField;
10+
import com.metaobjects.field.StringField;
11+
import com.metaobjects.object.MetaObject;
12+
import com.metaobjects.template.MetaTemplate;
13+
import com.metaobjects.template.TemplateConstants;
14+
15+
import java.util.ArrayList;
16+
import java.util.List;
17+
import java.util.Properties;
18+
19+
/**
20+
* Pure helper that turns a value-object (VO) {@link MetaObject} + its
21+
* {@code template.output} node into a Java source literal for an
22+
* {@code OutputFormatSpec} — the artifact-1 prompt descriptor used by the
23+
* FR-010 prompt-fragment codegen.
24+
*
25+
* <p>The public entry point is
26+
* {@link #specLiteral(MetaObject, MetaTemplate, String)}, which emits a
27+
* {@code new OutputFormatSpec(Format.X, "<rootName>", PromptStyle.<S>,
28+
* java.util.List.of(<PromptField...>))} source snippet ready for embedding
29+
* in a generated Java class.
30+
*
31+
* <p>Field-kind mapping (mirrors {@link SpringTypeMapper} / {@link RecoverSchemaEmitter}):
32+
* <ul>
33+
* <li>{@link EnumField} → {@code FieldKind.ENUM}</li>
34+
* <li>{@link StringField} → {@code FieldKind.STRING}</li>
35+
* <li>{@link IntegerField} → {@code FieldKind.INT}</li>
36+
* <li>{@link LongField} → {@code FieldKind.LONG}</li>
37+
* <li>{@link DoubleField} → {@code FieldKind.DOUBLE}</li>
38+
* <li>{@link BooleanField} → {@code FieldKind.BOOLEAN}</li>
39+
* <li>Nested {@link ObjectField} → {@code FieldKind.OBJECT}, nested arg {@code null}
40+
* (Plan 3.1 deferral — FR-010: nested prompt deferred).</li>
41+
* </ul>
42+
*
43+
* <p>This class is package-private; the Spring prompt-fragment generator will delegate here.
44+
* Isolating this logic makes it unit-testable without running the full generator pipeline.
45+
*/
46+
final class OutputFormatSpecEmitter {
47+
48+
private OutputFormatSpecEmitter() { /* no instances */ }
49+
50+
// -------------------------------------------------------------------------
51+
// Public API
52+
// -------------------------------------------------------------------------
53+
54+
/**
55+
* Emit a {@code new OutputFormatSpec(Format.X, "<rootName>", PromptStyle.<S>,
56+
* java.util.List.of(...))} literal for all fields on {@code vo}.
57+
*
58+
* <p>{@code @format} from the template: {@code "xml"} → {@code Format.XML};
59+
* anything else → {@code Format.JSON}.</p>
60+
*
61+
* <p>{@code @promptStyle}: {@code "guide"} → {@code PromptStyle.GUIDE} (default),
62+
* {@code "inline"} → {@code PromptStyle.INLINE},
63+
* {@code "exampleOnly"} → {@code PromptStyle.EXAMPLE_ONLY}.</p>
64+
*
65+
* @param vo the payload value-object whose fields drive the spec
66+
* @param template the {@code template.output} node carrying {@code @format} and
67+
* {@code @promptStyle}
68+
* @param rootName the logical root name embedded in the spec
69+
* @return Java source snippet, e.g.
70+
* {@code new OutputFormatSpec(Format.JSON, "Foo", PromptStyle.GUIDE, java.util.List.of(...))}
71+
*/
72+
static String specLiteral(MetaObject vo, MetaTemplate template, String rootName) {
73+
String formatEnum = resolveFormatEnum(template);
74+
String promptStyleEnum = resolvePromptStyleEnum(template);
75+
76+
List<String> fieldLiterals = new ArrayList<>();
77+
for (MetaField<?> field : vo.getMetaFields()) {
78+
fieldLiterals.add(promptFieldLiteral(field));
79+
}
80+
81+
return "new OutputFormatSpec(" + formatEnum + ", \""
82+
+ rootName + "\", " + promptStyleEnum + ", java.util.List.of("
83+
+ String.join(", ", fieldLiterals) + "))";
84+
}
85+
86+
// -------------------------------------------------------------------------
87+
// Private helpers — template attr resolution
88+
// -------------------------------------------------------------------------
89+
90+
/** Resolve {@code @format} to a {@code Format.*} enum literal. */
91+
private static String resolveFormatEnum(MetaTemplate template) {
92+
return "xml".equalsIgnoreCase(template.getFormat()) ? "Format.XML" : "Format.JSON";
93+
}
94+
95+
/**
96+
* Resolve {@code @promptStyle} to a {@code PromptStyle.*} enum literal.
97+
* Default (absent or unrecognized) → {@code PromptStyle.GUIDE}.
98+
*/
99+
private static String resolvePromptStyleEnum(MetaTemplate template) {
100+
String raw = null;
101+
if (template.hasMetaAttr(TemplateConstants.ATTR_PROMPT_STYLE, false)) {
102+
raw = template.getMetaAttr(TemplateConstants.ATTR_PROMPT_STYLE, false).getValueAsString();
103+
}
104+
if (raw == null) return "PromptStyle.GUIDE";
105+
return switch (raw) {
106+
case TemplateConstants.PROMPT_STYLE_INLINE -> "PromptStyle.INLINE";
107+
case TemplateConstants.PROMPT_STYLE_EXAMPLE_ONLY -> "PromptStyle.EXAMPLE_ONLY";
108+
default -> "PromptStyle.GUIDE";
109+
};
110+
}
111+
112+
// -------------------------------------------------------------------------
113+
// Private helpers — field literal
114+
// -------------------------------------------------------------------------
115+
116+
/**
117+
* Build a {@code new PromptField(...)} call for a single field.
118+
*
119+
* <p>Constructor shape:
120+
* {@code new PromptField(name, FieldKind, required, array,
121+
* enumValues-or-null, enumDoc-map-or-null, example-or-null,
122+
* instruction-or-null, nested-or-null)}
123+
*/
124+
@SuppressWarnings("rawtypes")
125+
private static String promptFieldLiteral(MetaField<?> field) {
126+
String name = field.getName();
127+
boolean required = isRequired(field);
128+
boolean array = field.isArray();
129+
130+
// Nested object — bounded deferral (Plan 3.1).
131+
if (field instanceof ObjectField) {
132+
return "new PromptField(\"" + name + "\", FieldKind.OBJECT, "
133+
+ required + ", " + array
134+
+ ", null, null, null, null, null) /* FR-010: nested prompt deferred */";
135+
}
136+
137+
// Enum field — include values + optional enumDoc.
138+
if (field instanceof EnumField ef) {
139+
return enumPromptFieldLiteral(name, required, array, ef);
140+
}
141+
142+
// Scalar — resolve @example and @instruction.
143+
String kindName = scalarKind(field);
144+
if (kindName == null) {
145+
kindName = "STRING"; // Unknown type: fall back to STRING.
146+
}
147+
String exampleLit = optStringAttr(field, MetaField.ATTR_EXAMPLE);
148+
String instructionLit = optStringAttr(field, MetaField.ATTR_INSTRUCTION);
149+
150+
return "new PromptField(\"" + name + "\", FieldKind." + kindName + ", "
151+
+ required + ", " + array
152+
+ ", null, null, " + exampleLit + ", " + instructionLit + ", null)";
153+
}
154+
155+
/**
156+
* Build a {@code new PromptField(...)} call for an enum field, including
157+
* the values list and optional enumDoc map.
158+
*/
159+
private static String enumPromptFieldLiteral(
160+
String name, boolean required, boolean array, EnumField field) {
161+
162+
// @values — guaranteed non-null by the loader's ValidationPhase.
163+
@SuppressWarnings("unchecked")
164+
List<String> values = (List<String>) field.getMetaAttr(EnumField.ATTR_VALUES).getValue();
165+
List<String> quoted = new ArrayList<>(values.size());
166+
for (String v : values) quoted.add("\"" + v + "\"");
167+
String valuesLit = "java.util.List.of(" + String.join(", ", quoted) + ")";
168+
169+
// @enumDoc — optional; null when absent or empty.
170+
Properties enumDoc = null;
171+
if (field.hasMetaAttr(EnumField.ATTR_ENUM_DOC, false)) {
172+
Object v = field.getMetaAttr(EnumField.ATTR_ENUM_DOC, false).getValue();
173+
if (v instanceof Properties p && !p.isEmpty()) {
174+
enumDoc = p;
175+
}
176+
}
177+
String enumDocLit = enumDoc != null ? buildMapOfEntriesLiteral(enumDoc) : "null";
178+
179+
String exampleLit = optStringAttr(field, MetaField.ATTR_EXAMPLE);
180+
String instructionLit = optStringAttr(field, MetaField.ATTR_INSTRUCTION);
181+
182+
return "new PromptField(\"" + name + "\", FieldKind.ENUM, "
183+
+ required + ", " + array
184+
+ ", " + valuesLit + ", " + enumDocLit
185+
+ ", " + exampleLit + ", " + instructionLit + ", null)";
186+
}
187+
188+
// -------------------------------------------------------------------------
189+
// Private helpers — attribute reads
190+
// -------------------------------------------------------------------------
191+
192+
/**
193+
* Return a Java string literal for an optional String attribute, or {@code "null"}.
194+
* The value is escaped via {@link #javaStringLiteral(String)} so that
195+
* example/instruction free-text containing quotes or newlines embeds safely
196+
* in Java source.
197+
*/
198+
private static String optStringAttr(MetaField<?> field, String attrName) {
199+
if (!field.hasMetaAttr(attrName, false)) return "null";
200+
String v = field.getMetaAttr(attrName, false).getValueAsString();
201+
return v == null ? "null" : "\"" + javaStringLiteral(v) + "\"";
202+
}
203+
204+
/**
205+
* Returns {@code true} when the field carries {@code @required: true}.
206+
* Mirrors {@link RecoverSchemaEmitter#isRequired}.
207+
*/
208+
private static boolean isRequired(MetaField<?> field) {
209+
return field.hasMetaAttr(MetaField.ATTR_REQUIRED)
210+
&& "true".equalsIgnoreCase(
211+
field.getMetaAttr(MetaField.ATTR_REQUIRED).getValueAsString());
212+
}
213+
214+
/**
215+
* Return the {@code FieldKind} enum member name for a scalar field, or
216+
* {@code null} when the field type is not a known scalar.
217+
* Mirrors the {@code instanceof} order in {@link SpringTypeMapper#javaTypeName}.
218+
*/
219+
@SuppressWarnings("rawtypes")
220+
private static String scalarKind(MetaField<?> field) {
221+
if (field instanceof StringField) return "STRING";
222+
if (field instanceof IntegerField) return "INT";
223+
if (field instanceof LongField) return "LONG";
224+
if (field instanceof DoubleField) return "DOUBLE";
225+
if (field instanceof BooleanField) return "BOOLEAN";
226+
return null;
227+
}
228+
229+
// -------------------------------------------------------------------------
230+
// Private helpers — source literal builders
231+
// -------------------------------------------------------------------------
232+
233+
/**
234+
* Emit a {@code java.util.Map.ofEntries(java.util.Map.entry(k, v), ...)} literal
235+
* from a {@link Properties}. Entries are sorted by key for deterministic output.
236+
* Values are escaped via {@link #javaStringLiteral(String)}.
237+
*
238+
* <p>{@code java.util.Map.of} only has overloads up to 10 key-value pairs; using
239+
* {@code Map.ofEntries} removes that arity cap and allows any number of entries.
240+
* Mirrors {@link RecoverSchemaEmitter#buildMapOfLiteral}.</p>
241+
*/
242+
private static String buildMapOfEntriesLiteral(Properties props) {
243+
List<String> keys = new ArrayList<>();
244+
for (Object k : props.keySet()) keys.add(k.toString());
245+
keys.sort(String::compareTo);
246+
247+
StringBuilder sb = new StringBuilder("java.util.Map.ofEntries(");
248+
for (int i = 0; i < keys.size(); i++) {
249+
if (i > 0) sb.append(", ");
250+
String k = keys.get(i);
251+
String v = props.getProperty(k);
252+
sb.append("java.util.Map.entry(\"")
253+
.append(javaStringLiteral(k)).append("\", \"")
254+
.append(javaStringLiteral(v)).append("\")");
255+
}
256+
sb.append(')');
257+
return sb.toString();
258+
}
259+
260+
/**
261+
* Escape a string value for safe embedding as a Java string literal body
262+
* (i.e. between double-quote delimiters). Escapes backslashes, double quotes,
263+
* and common control characters (tab, newline, carriage-return).
264+
*
265+
* <p>RecoverSchemaEmitter left equivalent escaping as a TODO for alias keys
266+
* (enum member symbols are identifier-safe, so the risk was low). Here,
267+
* {@code @example} and {@code @instruction} are free-text authored by
268+
* developers and are significantly more likely to contain quotes or
269+
* newlines, so escaping is applied eagerly.</p>
270+
*/
271+
static String javaStringLiteral(String value) {
272+
if (value == null) return "";
273+
StringBuilder sb = new StringBuilder(value.length() + 4);
274+
for (int i = 0; i < value.length(); i++) {
275+
char c = value.charAt(i);
276+
switch (c) {
277+
case '\\' -> sb.append("\\\\");
278+
case '"' -> sb.append("\\\"");
279+
case '\t' -> sb.append("\\t");
280+
case '\n' -> sb.append("\\n");
281+
case '\r' -> sb.append("\\r");
282+
default -> sb.append(c);
283+
}
284+
}
285+
return sb.toString();
286+
}
287+
}

0 commit comments

Comments
 (0)