feat(types): schema en types.yaml — fields, principal_field, parser+writer

Issue 0005:
- types_registry.h: enum FieldKind {string,int,float,bool,date,url,enum},
  struct FieldSpec {name,kind,required,enum_values}. EntitySpec gana
  principal_field, fields[], icon_name (para round-trip exacto).
- Parser: tolerante con yaml antiguo (sin fields). Soporta sub-key
  'fields:' como lista multilinea de inline-maps con bracket-aware
  split (respeta [a,b,c] sin partir). Soporta 'principal_field:'.
  Tipos desconocidos → FK_STRING con warning.
- Writer types_save_yaml: emite formato compacto (un dict por entity,
  fields como inline-maps, color en #RRGGBB[AA], icon por nombre).
  Round-trip estable: load→save→load produce ParsedTypes identico.

apply_types_yaml sigue ignorando fields (eso lo consumen 0007/0008).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 00:00:44 +02:00
parent 396a974686
commit 8a36ad068a
2 changed files with 311 additions and 37 deletions
+271 -35
View File
@@ -101,6 +101,74 @@ uint8_t parse_style(const std::string& v) {
return EDGE_USE_TYPE; return EDGE_USE_TYPE;
} }
// ----------------------------------------------------------------------------
// Inline-map parser: { key: value, key: [a, b, c], key: value }
// ----------------------------------------------------------------------------
// Split a string by ',' respecting [...] brackets. Devuelve los segmentos
// trimeados.
std::vector<std::string> split_top_level_commas(const std::string& s) {
std::vector<std::string> out;
int depth = 0;
std::string cur;
for (char c : s) {
if (c == '[' || c == '{') ++depth;
else if (c == ']' || c == '}') --depth;
if (c == ',' && depth == 0) {
out.push_back(trim(cur));
cur.clear();
} else {
cur.push_back(c);
}
}
std::string t = trim(cur);
if (!t.empty()) out.push_back(t);
return out;
}
// Parse "[a, b, c]" → {"a","b","c"}. Soporta strings sin/con comillas.
std::vector<std::string> parse_inline_array(const std::string& s) {
std::vector<std::string> out;
std::string body = trim(s);
if (body.size() < 2 || body.front() != '[' || body.back() != ']') return out;
body = body.substr(1, body.size() - 2);
for (auto& tok : split_top_level_commas(body)) {
std::string v = strip_quotes(trim(tok));
if (!v.empty()) out.push_back(v);
}
return out;
}
// Parse "{ name: x, type: y, required: true, values: [a,b,c] }" → FieldSpec.
// Devuelve true si parseo algo (al menos un name no vacio).
bool parse_field_inline_map(const std::string& raw, FieldSpec* out) {
if (!out) return false;
std::string body = trim(raw);
if (body.size() < 2 || body.front() != '{' || body.back() != '}') return false;
body = body.substr(1, body.size() - 2);
*out = FieldSpec{};
for (auto& pair : split_top_level_commas(body)) {
auto colon = pair.find(':');
if (colon == std::string::npos) continue;
std::string k = trim(pair.substr(0, colon));
std::string v = trim(pair.substr(colon + 1));
if (k == "name") {
out->name = strip_quotes(v);
} else if (k == "type") {
out->kind = field_kind_from_name(strip_quotes(v).c_str());
} else if (k == "required") {
std::string lv = strip_quotes(v);
std::transform(lv.begin(), lv.end(), lv.begin(),
[](unsigned char c) { return std::tolower(c); });
out->required = (lv == "true" || lv == "1" || lv == "yes");
} else if (k == "values") {
out->enum_values = parse_inline_array(v);
}
}
return !out->name.empty();
}
// Cuenta el numero de espacios al principio de una linea. Tabs cuentan como // Cuenta el numero de espacios al principio de una linea. Tabs cuentan como
// 4 espacios para que el archivo siga siendo legible si alguien usa tabs. // 4 espacios para que el archivo siga siendo legible si alguien usa tabs.
int leading_indent(const std::string& line) { int leading_indent(const std::string& line) {
@@ -120,6 +188,36 @@ bool starts_with(const std::string& s, const char* p) {
} // namespace } // namespace
const char* field_kind_name(FieldKind k) {
switch (k) {
case FK_STRING: return "string";
case FK_INT: return "int";
case FK_FLOAT: return "float";
case FK_BOOL: return "bool";
case FK_DATE: return "date";
case FK_URL: return "url";
case FK_ENUM: return "enum";
}
return "string";
}
FieldKind field_kind_from_name(const char* s) {
if (!s || !*s) return FK_STRING;
std::string v = s;
std::transform(v.begin(), v.end(), v.begin(),
[](unsigned char c) { return std::tolower(c); });
if (v == "string" || v == "str" || v == "text") return FK_STRING;
if (v == "int" || v == "integer") return FK_INT;
if (v == "float" || v == "double") return FK_FLOAT;
if (v == "bool" || v == "boolean") return FK_BOOL;
if (v == "date") return FK_DATE;
if (v == "url" || v == "uri") return FK_URL;
if (v == "enum") return FK_ENUM;
std::fprintf(stderr,
"[types.yaml] tipo de campo desconocido '%s' → tratado como string\n", s);
return FK_STRING;
}
bool types_load_yaml(const char* path, ParsedTypes* out, std::string* error_msg) { bool types_load_yaml(const char* path, ParsedTypes* out, std::string* error_msg) {
if (!out) return false; if (!out) return false;
std::ifstream f(path); std::ifstream f(path);
@@ -133,7 +231,9 @@ bool types_load_yaml(const char* path, ParsedTypes* out, std::string* error_msg)
EntitySpec cur_entity; EntitySpec cur_entity;
RelationSpec cur_rel; RelationSpec cur_rel;
bool have_item = false; bool have_item = false;
int item_indent = -1; // indent de "- " que abre el item
bool in_fields = false; // dentro de `fields:` del entity actual
auto flush = [&]() { auto flush = [&]() {
if (!have_item) return; if (!have_item) return;
@@ -145,6 +245,46 @@ bool types_load_yaml(const char* path, ParsedTypes* out, std::string* error_msg)
cur_entity = EntitySpec{}; cur_entity = EntitySpec{};
cur_rel = RelationSpec{}; cur_rel = RelationSpec{};
have_item = false; have_item = false;
in_fields = false;
item_indent = -1;
};
// Aplica un sub-key "key: value" al item actual. Si es `fields` con
// valor vacio, activa modo fields.
auto apply_subkey = [&](const std::string& k, const std::string& v) {
if (k == "name") {
if (section == SEC_ENTITIES) cur_entity.name = strip_quotes(v);
else cur_rel.name = strip_quotes(v);
} else if (k == "color") {
uint32_t c = parse_color_hex(v);
if (section == SEC_ENTITIES) cur_entity.color = c;
else cur_rel.color = c;
} else if (k == "shape" && section == SEC_ENTITIES) {
cur_entity.shape = parse_shape(v);
} else if (k == "icon" && section == SEC_ENTITIES) {
std::string nm = strip_quotes(v);
cur_entity.icon_name = nm;
cur_entity.icon_cp = tabler_codepoint_by_name(nm.c_str());
} else if (k == "principal_field" && section == SEC_ENTITIES) {
cur_entity.principal_field = strip_quotes(v);
} else if (k == "fields" && section == SEC_ENTITIES) {
// Sub-seccion: si el valor es vacio entramos en modo fields y
// las siguientes lineas "- { ... }" se parsean como FieldSpec.
// Si trae lista inline ("[...]") la parseamos aqui (raro pero
// soportado).
std::string vv = trim(v);
if (vv.empty()) {
in_fields = true;
} else if (!vv.empty() && vv.front() == '[') {
// No soportado en v1: lista inline de inline-maps en una
// misma linea. Se ignora con warning.
std::fprintf(stderr,
"[types.yaml] fields: en linea con [...] no soportado;"
" usar lista multilinea con `- { ... }`\n");
}
} else if (k == "style" && section == SEC_RELATIONS) {
cur_rel.style = parse_style(v);
}
}; };
std::string line; std::string line;
@@ -186,32 +326,37 @@ bool types_load_yaml(const char* path, ParsedTypes* out, std::string* error_msg)
if (section == SEC_NONE) continue; if (section == SEC_NONE) continue;
// Inicio de item: "- name: Foo" o "-" // Item nuevo a nivel del seccion (indent <= item_indent o sin item):
if (starts_with(trimmed, "- ")) { // disambigua "- " entre apertura de item y entrada de fields.
flush(); bool is_dash = starts_with(trimmed, "- ");
have_item = true; bool is_field_line = in_fields && is_dash && indent > item_indent;
bool is_new_item = is_dash && !is_field_line;
if (is_field_line) {
// Parse "- { ... }" como FieldSpec
std::string body = trim(trimmed.substr(2));
FieldSpec fs;
if (parse_field_inline_map(body, &fs)) {
cur_entity.fields.push_back(std::move(fs));
} else {
std::fprintf(stderr,
"[types.yaml] field invalido (esperado inline map "
"{ name: ..., type: ... }): %s\n", body.c_str());
}
continue;
}
if (is_new_item) {
flush();
have_item = true;
item_indent = indent;
in_fields = false;
std::string body = trim(trimmed.substr(2)); std::string body = trim(trimmed.substr(2));
// Si la linea trae un par key:value tras el guion, parsearlo aqui
auto colon = body.find(':'); auto colon = body.find(':');
if (colon != std::string::npos) { if (colon != std::string::npos) {
std::string k = trim(body.substr(0, colon)); std::string k = trim(body.substr(0, colon));
std::string v = trim(body.substr(colon + 1)); std::string v = trim(body.substr(colon + 1));
if (k == "name") { apply_subkey(k, v);
if (section == SEC_ENTITIES) cur_entity.name = strip_quotes(v);
else cur_rel.name = strip_quotes(v);
}
// Cualquier otra key inline se reasigna mas abajo si hay sub-lineas
else if (k == "color") {
uint32_t c = parse_color_hex(v);
if (section == SEC_ENTITIES) cur_entity.color = c;
else cur_rel.color = c;
} else if (k == "shape" && section == SEC_ENTITIES) {
cur_entity.shape = parse_shape(v);
} else if (k == "icon" && section == SEC_ENTITIES) {
cur_entity.icon_cp = tabler_codepoint_by_name(strip_quotes(v).c_str());
} else if (k == "style" && section == SEC_RELATIONS) {
cur_rel.style = parse_style(v);
}
} }
continue; continue;
} }
@@ -222,26 +367,117 @@ bool types_load_yaml(const char* path, ParsedTypes* out, std::string* error_msg)
if (colon == std::string::npos) continue; if (colon == std::string::npos) continue;
std::string k = trim(trimmed.substr(0, colon)); std::string k = trim(trimmed.substr(0, colon));
std::string v = trim(trimmed.substr(colon + 1)); std::string v = trim(trimmed.substr(colon + 1));
if (k == "name") { // Sub-key del entity con indent menor o igual al item: significa
if (section == SEC_ENTITIES) cur_entity.name = strip_quotes(v); // que salimos de in_fields (el indent de fields_items es mayor).
else cur_rel.name = strip_quotes(v); if (in_fields && indent <= item_indent + 2) {
} else if (k == "color") { in_fields = false;
uint32_t c = parse_color_hex(v);
if (section == SEC_ENTITIES) cur_entity.color = c;
else cur_rel.color = c;
} else if (k == "shape" && section == SEC_ENTITIES) {
cur_entity.shape = parse_shape(v);
} else if (k == "icon" && section == SEC_ENTITIES) {
cur_entity.icon_cp = tabler_codepoint_by_name(strip_quotes(v).c_str());
} else if (k == "style" && section == SEC_RELATIONS) {
cur_rel.style = parse_style(v);
} }
apply_subkey(k, v);
} }
} }
flush(); flush();
return true; return true;
} }
// ----------------------------------------------------------------------------
// Writer: ParsedTypes -> types.yaml
// ----------------------------------------------------------------------------
namespace {
// ABGR -> "#RRGGBB" (o "#RRGGBBAA" si alpha != 0xFF). Devuelve "" si color es 0.
std::string color_to_hex(uint32_t c) {
if (c == 0) return "";
uint8_t r = (uint8_t)( c & 0xFF);
uint8_t g = (uint8_t)((c >> 8) & 0xFF);
uint8_t b = (uint8_t)((c >> 16) & 0xFF);
uint8_t a = (uint8_t)((c >> 24) & 0xFF);
char buf[16];
if (a == 0xFF) {
std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", r, g, b);
} else {
std::snprintf(buf, sizeof(buf), "#%02X%02X%02X%02X", r, g, b, a);
}
return buf;
}
const char* style_name(uint8_t s) {
switch (s) {
case EDGE_SOLID: return "solid";
case EDGE_DASHED: return "dashed";
case EDGE_DOTTED: return "dotted";
default: return "";
}
}
// Serializa un FieldSpec como inline-map: "{ name: X, type: Y, required: true, values: [a,b,c] }".
std::string serialize_field(const FieldSpec& f) {
std::string out = "{ name: ";
out += f.name;
out += ", type: ";
out += field_kind_name(f.kind);
if (f.required) out += ", required: true";
if (f.kind == FK_ENUM && !f.enum_values.empty()) {
out += ", values: [";
for (size_t i = 0; i < f.enum_values.size(); ++i) {
if (i) out += ", ";
out += f.enum_values[i];
}
out += "]";
}
out += " }";
return out;
}
} // namespace
bool types_save_yaml(const char* path, const ParsedTypes& types,
std::string* error_msg) {
if (!path || !*path) {
if (error_msg) *error_msg = "path vacio";
return false;
}
std::ofstream f(path);
if (!f.good()) {
if (error_msg) *error_msg = std::string("cannot open ") + path;
return false;
}
f << "# types.yaml — autogenerado por graph_explorer Type Editor.\n";
f << "# Editable a mano. Round-trip con types_load_yaml/types_save_yaml.\n";
f << "# Todos los nodos son circulo, salvo \"Table\" que es cuadrado.\n";
f << "# Tipos de campo: string | int | float | bool | date | url | enum.\n";
f << "\n";
f << "entities:\n";
for (const auto& e : types.entities) {
f << " - name: " << e.name << "\n";
std::string col = color_to_hex(e.color);
if (!col.empty()) f << " color: \"" << col << "\"\n";
if (!e.icon_name.empty()) f << " icon: " << e.icon_name << "\n";
if (!e.principal_field.empty())
f << " principal_field: " << e.principal_field << "\n";
if (!e.fields.empty()) {
f << " fields:\n";
for (const auto& fs : e.fields) {
f << " - " << serialize_field(fs) << "\n";
}
}
f << "\n";
}
f << "relations:\n";
for (const auto& r : types.relations) {
f << " - name: " << r.name << "\n";
std::string col = color_to_hex(r.color);
if (!col.empty()) f << " color: \"" << col << "\"\n";
const char* st = style_name(r.style);
if (st && *st) f << " style: " << st << "\n";
f << "\n";
}
return true;
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Mapeo nombre Tabler -> codepoint // Mapeo nombre Tabler -> codepoint
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
+40 -2
View File
@@ -9,13 +9,18 @@
namespace ge { namespace ge {
// Representacion en memoria de un `types.yaml` minimo: // Representacion en memoria de un `types.yaml`:
// //
// entities: // entities:
// - name: Person // - name: Person
// color: "#5B8DEF" // color: "#5B8DEF"
// shape: circle
// icon: ti-user // icon: ti-user
// principal_field: name # opcional, default = "name"
// fields:
// - { name: name, type: string, required: true }
// - { name: first_name, type: string }
// - { name: age, type: int }
// - { name: gender, type: enum, values: [male, female, other] }
// relations: // relations:
// - name: owns // - name: owns
// color: "#888888" // color: "#888888"
@@ -24,11 +29,36 @@ namespace ge {
// Campos no presentes quedan con sentinel (`0` para color, `SHAPE_USE_TYPE` // Campos no presentes quedan con sentinel (`0` para color, `SHAPE_USE_TYPE`
// para shape, `EDGE_USE_TYPE` para style, codepoint = 0 para icon). // para shape, `EDGE_USE_TYPE` para style, codepoint = 0 para icon).
// Tipos de campo soportados en el schema. v1 — ampliable. Tipos desconocidos
// se mapean a FK_STRING con warning del parser.
enum FieldKind : uint8_t {
FK_STRING = 0,
FK_INT,
FK_FLOAT,
FK_BOOL,
FK_DATE, // YYYY-MM-DD (validacion blanda en el inspector)
FK_URL,
FK_ENUM, // requiere `values: [...]`
};
const char* field_kind_name(FieldKind k);
FieldKind field_kind_from_name(const char* s); // case-insensitive; default FK_STRING
struct FieldSpec {
std::string name;
FieldKind kind = FK_STRING;
bool required = false;
std::vector<std::string> enum_values; // solo si kind == FK_ENUM
};
struct EntitySpec { struct EntitySpec {
std::string name; std::string name;
uint32_t color = 0; // ABGR; 0 = no override uint32_t color = 0; // ABGR; 0 = no override
uint8_t shape = SHAPE_USE_TYPE; // 0 = no override uint8_t shape = SHAPE_USE_TYPE; // 0 = no override
uint16_t icon_cp = 0; // codepoint Tabler; 0 = sin icono uint16_t icon_cp = 0; // codepoint Tabler; 0 = sin icono
std::string icon_name; // nombre original ti-* (para round-trip)
std::string principal_field; // vacio = usar "name"
std::vector<FieldSpec> fields; // schema; vacio = sin schema
}; };
struct RelationSpec { struct RelationSpec {
@@ -47,6 +77,14 @@ struct ParsedTypes {
// no es reconocido se ignora silenciosamente. // no es reconocido se ignora silenciosamente.
bool types_load_yaml(const char* path, ParsedTypes* out, std::string* error_msg); bool types_load_yaml(const char* path, ParsedTypes* out, std::string* error_msg);
// Writer de `types.yaml`. Escribe el formato compacto de `examples/types.yaml`
// (un dict por entity/relation, sub-keys indentadas, fields como inline-maps).
// Round-trip estable: load → save → load produce el mismo ParsedTypes.
//
// Devuelve true en exito. En fallo, `error_msg` describe el motivo.
bool types_save_yaml(const char* path, const ParsedTypes& types,
std::string* error_msg);
// Resuelve un nombre tipo `ti-user` -> codepoint Tabler. Devuelve 0 si no // Resuelve un nombre tipo `ti-user` -> codepoint Tabler. Devuelve 0 si no
// existe. La tabla de mapeo vive dentro de la implementacion y solo cubre // existe. La tabla de mapeo vive dentro de la implementacion y solo cubre
// los iconos comunes que el ejemplo OSINT usa; ampliable a demanda. // los iconos comunes que el ejemplo OSINT usa; ampliable a demanda.