feat(brahman-cards): Nickel reader + templates con merge nativo (V2)
El brazo ahora acepta `.ncl`: evalúa via nickel-lang 2.0, exporta a JSON, dispatcha por los readers JSON estándar. Templates funcionan con import + & merge nativos de Nickel — el brazo no inventa mecánica paralela. - Dep nickel-lang = "2.0.0" (interfaz estable). - Nuevo módulo nickel_eval con eval_nickel_file(path) -> Value y errores tipados (Io/Eval/Export/JsonReparse). Mensaje de Nickel como texto plano sin ANSI. - load_card_with añade arm "ncl" simétrico al "json". - CardLoadError::Nickel propaga el error limpio. - Imports resueltos: parent dir del input + env BRAHMAN_CARDS_TEMPLATES_DIR (registry global, opcional). - Convención obligatoria documentada: fields override-ables del template usan `| default` (sin eso Nickel rechaza el merge). 9 tests nuevos: eval directo, dispatch a UiModule/Ente, template merge con id+label override, registry via env, error wrapping, contract violation en eval-time (`id | String = 42`), shape desconocida. 22 tests totales en brahman-cards (13 JSON V1 + 9 Nickel V2). Workspace build verde. NO hace: migrar consumers, set canonical de templates, KCL→Nickel — todos para commits siguientes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -157,6 +157,9 @@ pub enum CardLoadError {
|
||||
ext: String,
|
||||
supported: Vec<&'static str>,
|
||||
},
|
||||
|
||||
#[error("evaluación Nickel: {0}")]
|
||||
Nickel(#[from] NickelEvalError),
|
||||
}
|
||||
|
||||
/// Trait de reader. Cada formato implementa una instancia.
|
||||
@@ -180,7 +183,9 @@ pub trait CardReader: Send + Sync {
|
||||
fn read(&self, input: Value) -> Result<Card, CardLoadError>;
|
||||
}
|
||||
|
||||
mod nickel_eval;
|
||||
mod readers;
|
||||
pub use nickel_eval::{eval_nickel_file, NickelEvalError, BRAHMAN_CARDS_TEMPLATES_ENV};
|
||||
pub use readers::{EnteJsonReader, MonadJsonReader, UiModuleJsonReader};
|
||||
|
||||
/// Construye el set default de readers para inputs JSON. El orden
|
||||
@@ -222,9 +227,18 @@ pub fn load_card_with(
|
||||
let value: Value = serde_json::from_slice(&bytes)?;
|
||||
dispatch_to_reader(value, readers)
|
||||
}
|
||||
"ncl" => {
|
||||
// Nickel pipeline: leer archivo → evaluar deeply → exportar
|
||||
// a JSON → parsear como Value → dispatch a los readers JSON
|
||||
// estándar. Templates funcionan via los `import` nativos de
|
||||
// Nickel; el evaluator resuelve relativo al input y al
|
||||
// `BRAHMAN_CARDS_TEMPLATES_DIR` env (si está set).
|
||||
let value = eval_nickel_file(path)?;
|
||||
dispatch_to_reader(value, readers)
|
||||
}
|
||||
other => Err(CardLoadError::UnsupportedExtension {
|
||||
ext: other.to_string(),
|
||||
supported: vec!["json"],
|
||||
supported: vec!["json", "ncl"],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Evaluador Nickel para inputs `.ncl`.
|
||||
//!
|
||||
//! El brazo de Cards lee Nickel como **fuente** y produce JSON como
|
||||
//! **representación intermedia** que después dispatcha por los readers
|
||||
//! estándar. Esto significa que un `.ncl` puede producir cualquier
|
||||
//! variant del [`super::CardBody`] siempre que evalúe a una shape JSON
|
||||
//! que alguno de los readers reconozca.
|
||||
//!
|
||||
//! # Templates
|
||||
//!
|
||||
//! Nickel soporta `import "..."` y el operador `&` de merge nativo. Un
|
||||
//! Card "concreto" puede ser un template + override:
|
||||
//!
|
||||
//! ```nickel
|
||||
//! let base = import "ente_basic.ncl" in
|
||||
//! base & { id = "01ARZ...", label = "mi-ente" }
|
||||
//! ```
|
||||
//!
|
||||
//! **Convención obligatoria del template**: las fields que el usuario
|
||||
//! va a sobrescribir tienen que estar marcadas `| default` (o
|
||||
//! `| optional`). Nickel rechaza el merge de dos strings/numbers
|
||||
//! distintos con la misma prioridad — el `| default` baja la prioridad
|
||||
//! del template y deja que el override del user gane:
|
||||
//!
|
||||
//! ```nickel
|
||||
//! # template ui_module_basic.ncl
|
||||
//! {
|
||||
//! id | String | default = "TEMPLATE_ID",
|
||||
//! label | String | default = "TEMPLATE_LABEL",
|
||||
//! # ...
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Resolución de imports (en orden):
|
||||
//! 1. Relativo al directorio del archivo input (default de Nickel).
|
||||
//! 2. `BRAHMAN_CARDS_TEMPLATES_DIR` (env). Permite tener un
|
||||
//! registry global de templates accesible por nombre desnudo:
|
||||
//! `import "ui_module_basic.ncl"`.
|
||||
//!
|
||||
//! No agregamos magic resolución por kind — el autor decide qué
|
||||
//! template importa explícitamente.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Variable de entorno opcional. Si está set, su path se agrega al
|
||||
/// search path de imports de Nickel después del parent dir del input,
|
||||
/// permitiendo `import "<nombre>.ncl"` desde cualquier ubicación.
|
||||
pub const BRAHMAN_CARDS_TEMPLATES_ENV: &str = "BRAHMAN_CARDS_TEMPLATES_DIR";
|
||||
|
||||
/// Errores específicos del pipeline Nickel. Wrap del error de Nickel
|
||||
/// formateado como texto plano (sin ANSI) + el path del input para
|
||||
/// contexto.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum NickelEvalError {
|
||||
#[error("io leyendo {path}: {source}")]
|
||||
Io {
|
||||
path: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("evaluación de '{path}' falló:\n{message}")]
|
||||
Eval { path: String, message: String },
|
||||
|
||||
#[error("export a JSON de '{path}' falló:\n{message}")]
|
||||
Export { path: String, message: String },
|
||||
|
||||
#[error("JSON exportado por Nickel no parsea de vuelta: {source}")]
|
||||
JsonReparse {
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Lee `path` (debe ser un `.ncl` válido), lo evalúa profundamente vía
|
||||
/// `nickel-lang` y devuelve el resultado como `serde_json::Value`
|
||||
/// listo para dispatch a un reader JSON.
|
||||
///
|
||||
/// El parent dir del input se agrega como import path para que
|
||||
/// imports relativos tipo `import "./template.ncl"` funcionen sin
|
||||
/// configuración extra. Si `BRAHMAN_CARDS_TEMPLATES_DIR` está set,
|
||||
/// también se agrega.
|
||||
pub fn eval_nickel_file(path: &Path) -> Result<Value, NickelEvalError> {
|
||||
let path_display = path.display().to_string();
|
||||
let source = std::fs::read_to_string(path).map_err(|e| NickelEvalError::Io {
|
||||
path: path_display.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let mut import_paths: Vec<OsString> = Vec::new();
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
import_paths.push(parent.into());
|
||||
}
|
||||
}
|
||||
if let Ok(reg) = std::env::var(BRAHMAN_CARDS_TEMPLATES_ENV) {
|
||||
if !reg.is_empty() {
|
||||
import_paths.push(reg.into());
|
||||
}
|
||||
}
|
||||
|
||||
let mut ctx = nickel_lang::Context::new()
|
||||
.with_added_import_paths(import_paths)
|
||||
.with_source_name(path_display.clone());
|
||||
|
||||
let expr = ctx
|
||||
.eval_deep_for_export(&source)
|
||||
.map_err(|e| NickelEvalError::Eval {
|
||||
path: path_display.clone(),
|
||||
message: format_nickel_error(&e),
|
||||
})?;
|
||||
|
||||
let json_str = ctx
|
||||
.expr_to_json(&expr)
|
||||
.map_err(|e| NickelEvalError::Export {
|
||||
path: path_display.clone(),
|
||||
message: format_nickel_error(&e),
|
||||
})?;
|
||||
|
||||
serde_json::from_str(&json_str).map_err(|e| NickelEvalError::JsonReparse { source: e })
|
||||
}
|
||||
|
||||
/// Formatea un error de Nickel como texto plano. Usa `ErrorFormat::Text`
|
||||
/// (sin ANSI) para que sea legible en logs y mensajes de UI sin
|
||||
/// escape sequences.
|
||||
fn format_nickel_error(err: &nickel_lang::Error) -> String {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
if err
|
||||
.format(&mut buf, nickel_lang::ErrorFormat::Text)
|
||||
.is_err()
|
||||
{
|
||||
// Si la propia formateación falla, devolvemos el Debug —
|
||||
// peor mensaje que el normal pero no perdemos info.
|
||||
return format!("{err:?}");
|
||||
}
|
||||
String::from_utf8(buf).unwrap_or_else(|_| format!("{err:?}"))
|
||||
}
|
||||
Reference in New Issue
Block a user