|
| 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