chore: add code
This commit is contained in:
parent
050b9b8512
commit
38a7885504
2615
Cargo.lock
generated
Normal file
2615
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
Cargo.toml
Normal file
24
Cargo.toml
Normal file
@ -0,0 +1,24 @@
|
||||
[package]
|
||||
|
||||
name = "nu_plugin_tera"
|
||||
version = "0.1.0"
|
||||
authors = ["Jesús Pérex <jpl@jesusperez.com>"]
|
||||
edition = "2024"
|
||||
description = "a nushell plugin called tera"
|
||||
repository = "https://github.com/JesusPerez/nu_plugin_tera"
|
||||
license = "MIT"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
# for local development, you can use a path dependency
|
||||
nu-plugin = { path = "../nushell/crates/nu-plugin" }
|
||||
nu-protocol = { path = "../nushell/crates/nu-protocol", features = ["plugin"] }
|
||||
#nu-plugin = "0.104.0"
|
||||
#nu-protocol = { version = "0.104.0", features = ["plugin"] }
|
||||
tera = "1.3"
|
||||
serde_json = "1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
nu-plugin-test-support = { path = "../nushell/crates/nu-plugin-test-support" }
|
||||
#nu-plugin-test-support = { version = "0.104.0" }
|
65
src/helpers.rs
Normal file
65
src/helpers.rs
Normal file
@ -0,0 +1,65 @@
|
||||
use nu_protocol::{LabeledError, Value};
|
||||
|
||||
/// Convert a Nushell Value to a serde_json::Value suitable for Tera context.
|
||||
///
|
||||
/// - Records are converted to JSON objects.
|
||||
/// - Lists, strings, ints, and bools are wrapped in an object with key "value".
|
||||
/// - Other types return an error.
|
||||
pub fn value_to_serde_json(value: Value) -> Result<serde_json::Value, LabeledError> {
|
||||
match value {
|
||||
Value::Record { val, .. } => {
|
||||
let record = &*val;
|
||||
let mut map = serde_json::Map::new();
|
||||
for (col, val) in record.columns().zip(record.values()) {
|
||||
map.insert(col.clone(), value_to_serde_json(val.clone())?);
|
||||
}
|
||||
Ok(serde_json::Value::Object(map))
|
||||
}
|
||||
Value::List { vals, .. } => {
|
||||
let vec = vals
|
||||
.into_iter()
|
||||
.map(value_to_serde_json)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(serde_json::Value::Array(vec))
|
||||
}
|
||||
Value::String { val, .. } => Ok(serde_json::Value::String(val)),
|
||||
Value::Int { val, .. } => Ok(serde_json::Value::Number(val.into())),
|
||||
Value::Bool { val, .. } => Ok(serde_json::Value::Bool(val)),
|
||||
_ => Err(LabeledError::new("Type not supported")
|
||||
.with_label("Use records, lists or primitives", value.span())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the top-level 'value' key if it is the only key in the object, and always returns an object (wraps non-objects as { "value": ... }).
|
||||
pub fn unwrap_value_key(json: serde_json::Value) -> serde_json::Value {
|
||||
let unwrapped = if let serde_json::Value::Object(mut map) = json {
|
||||
if map.len() == 1 {
|
||||
if let Some(inner) = map.remove("value") {
|
||||
return unwrap_value_key(inner);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(map)
|
||||
} else {
|
||||
json
|
||||
};
|
||||
match unwrapped {
|
||||
serde_json::Value::Object(_) => unwrapped,
|
||||
other => {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("value".to_string(), other);
|
||||
serde_json::Value::Object(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps the top-level value if it is not an object.
|
||||
pub fn wrap_top_level_if_needed(json: serde_json::Value) -> serde_json::Value {
|
||||
match json {
|
||||
serde_json::Value::Object(_) => json,
|
||||
other => {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("value".to_string(), other);
|
||||
serde_json::Value::Object(map)
|
||||
}
|
||||
}
|
||||
}
|
183
src/main.rs
Normal file
183
src/main.rs
Normal file
@ -0,0 +1,183 @@
|
||||
use nu_plugin::{
|
||||
EngineInterface, EvaluatedCall, MsgPackSerializer, Plugin, PluginCommand, SimplePluginCommand,
|
||||
serve_plugin,
|
||||
};
|
||||
use nu_protocol::{Category, Example, LabeledError, Signature, SyntaxShape, Type, Value};
|
||||
use std::fs;
|
||||
use tera::Tera;
|
||||
//use serde_json::json;
|
||||
|
||||
mod helpers;
|
||||
|
||||
use crate::helpers::{unwrap_value_key, value_to_serde_json, wrap_top_level_if_needed};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// Nushell plugin for rendering Tera templates with structured data.
|
||||
pub struct TeraPlugin;
|
||||
|
||||
impl Plugin for TeraPlugin {
|
||||
/// Returns the plugin version from Cargo.toml.
|
||||
fn version(&self) -> String {
|
||||
// This automatically uses the version of your package from Cargo.toml as the plugin version
|
||||
// sent to Nushell
|
||||
env!("CARGO_PKG_VERSION").into()
|
||||
}
|
||||
|
||||
/// Returns the list of commands provided by this plugin.
|
||||
fn commands(&self) -> Vec<Box<dyn PluginCommand<Plugin = Self>>> {
|
||||
vec![
|
||||
// Commands should be added here
|
||||
Box::new(Render),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// The main render command for the Tera plugin.
|
||||
pub struct Render;
|
||||
|
||||
impl SimplePluginCommand for Render {
|
||||
type Plugin = TeraPlugin;
|
||||
|
||||
/// The name of the command as used in Nushell.
|
||||
fn name(&self) -> &str {
|
||||
"tera-render"
|
||||
}
|
||||
|
||||
/// The Nushell signature for the command, describing its parameters and usage.
|
||||
fn signature(&self) -> Signature {
|
||||
Signature::build(PluginCommand::name(self))
|
||||
.input_output_type(Type::Any, Type::String)
|
||||
.required("template", SyntaxShape::Filepath, "Ruta al archivo .tera")
|
||||
// .switch("shout", "(FIXME) Yell it instead", None)
|
||||
.optional(
|
||||
"context",
|
||||
SyntaxShape::Any,
|
||||
"Datos de contexto (record o JSON path)",
|
||||
)
|
||||
.category(Category::Experimental)
|
||||
}
|
||||
|
||||
/// A short description of the command for Nushell help.
|
||||
fn description(&self) -> &str {
|
||||
"(FIXME) help text for render"
|
||||
}
|
||||
|
||||
/// Example usages of the command for Nushell help and testing.
|
||||
fn examples(&self) -> Vec<Example> {
|
||||
vec![Example {
|
||||
example: "{ name: 'Akasha', projects: [ {'name': 'TheProject' , 'status': 'active' }] } | tera-render example/template.tera",
|
||||
description: "Render template.tera with a record as context from the pipeline.\n\n\
|
||||
template.tera:\n\
|
||||
Hello, {{ name }}!Projects:\n\
|
||||
{% for project in projects -%}\n\
|
||||
- {{ project.name }} ({{ project.status }})\n\
|
||||
{% endfor %}\n\n\
|
||||
Other options:\n\
|
||||
open data.json | wrap value | tera-render template.tera\n\
|
||||
open data.json | tera-render template.tera\n\
|
||||
",
|
||||
result: Some(Value::test_string(
|
||||
"Hello, Akasha!\nProjects:\n- TheProject (active)\n\n",
|
||||
)),
|
||||
}]
|
||||
}
|
||||
|
||||
/// The main entry point for the command. Handles reading the template, context, and rendering.
|
||||
fn run(
|
||||
&self,
|
||||
_plugin: &TeraPlugin,
|
||||
_engine: &EngineInterface,
|
||||
call: &EvaluatedCall,
|
||||
input: &Value,
|
||||
) -> Result<Value, LabeledError> {
|
||||
let template_path: String = call.req(0)?;
|
||||
let context_arg: Option<Value> = call.opt(1)?;
|
||||
// if call.has_flag("shout")? {
|
||||
|
||||
// Read template
|
||||
let template_content = fs::read_to_string(&template_path)
|
||||
.map_err(|e| LabeledError::new("Read error").with_label(e.to_string(), call.head))?;
|
||||
|
||||
// Get data context (input pipeline or argument)
|
||||
let context = match context_arg {
|
||||
Some(val) => {
|
||||
if let Value::String { val: ref s, .. } = val {
|
||||
if s.ends_with(".json") {
|
||||
let file_content = std::fs::read_to_string(s).map_err(|e| {
|
||||
LabeledError::new("Failed to read JSON file")
|
||||
.with_label(e.to_string(), val.span())
|
||||
})?;
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(&file_content).map_err(|e| {
|
||||
LabeledError::new("Failed to parse JSON file")
|
||||
.with_label(e.to_string(), val.span())
|
||||
})?;
|
||||
let context_json = unwrap_value_key(wrap_top_level_if_needed(json));
|
||||
// println!("DEBUG context: {}", context_json);
|
||||
let mut tera = Tera::default();
|
||||
tera.add_raw_template(&template_path, &template_content)
|
||||
.map_err(|e| {
|
||||
LabeledError::new("Template error")
|
||||
.with_label(e.to_string(), call.head)
|
||||
})?;
|
||||
let context = tera::Context::from_serialize(context_json).map_err(|e| {
|
||||
LabeledError::new("Tera context error")
|
||||
.with_label(e.to_string(), val.span())
|
||||
})?;
|
||||
let output = tera.render(&template_path, &context).map_err(|e| {
|
||||
LabeledError::new("Render error").with_label(e.to_string(), call.head)
|
||||
})?;
|
||||
return Ok(Value::string(output, call.head));
|
||||
} else if s.ends_with(".yaml")
|
||||
|| s.ends_with(".yml")
|
||||
|| s.ends_with(".toml")
|
||||
|| s.ends_with(".csv")
|
||||
|| std::path::Path::new(s).exists()
|
||||
{
|
||||
return Err(LabeledError::new("Context is a file path, not data")
|
||||
.with_label(
|
||||
format!("You passed a file path ('{}') as context. Use 'open' to read the file: open {} | tera-render ...", s, s),
|
||||
val.span()
|
||||
));
|
||||
}
|
||||
}
|
||||
// Default context handling if not a file path string
|
||||
let context_json =
|
||||
unwrap_value_key(wrap_top_level_if_needed(value_to_serde_json(val.clone())?));
|
||||
// println!("DEBUG context: {}", context_json);
|
||||
tera::Context::from_serialize(context_json).map_err(|e| {
|
||||
LabeledError::new("Tera context error").with_label(e.to_string(), val.span())
|
||||
})?
|
||||
}
|
||||
None => {
|
||||
let context_json = unwrap_value_key(wrap_top_level_if_needed(value_to_serde_json(
|
||||
input.clone(),
|
||||
)?));
|
||||
//println!("DEBUG context: {}", context_json);
|
||||
tera::Context::from_serialize(context_json).map_err(|e| {
|
||||
LabeledError::new("Tera context error").with_label(e.to_string(), input.span())
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
// Render with Tera
|
||||
let mut tera = Tera::default();
|
||||
tera.add_raw_template(&template_path, &template_content)
|
||||
.map_err(|e| {
|
||||
LabeledError::new("Template error").with_label(e.to_string(), call.head)
|
||||
})?;
|
||||
|
||||
let output = tera
|
||||
.render(&template_path, &context)
|
||||
.map_err(|e| LabeledError::new("Render error").with_label(e.to_string(), call.head))?;
|
||||
|
||||
Ok(Value::string(output, call.head))
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry point for the plugin binary.
|
||||
fn main() {
|
||||
serve_plugin(&TeraPlugin, MsgPackSerializer);
|
||||
}
|
103
src/tests.rs
Normal file
103
src/tests.rs
Normal file
@ -0,0 +1,103 @@
|
||||
// use super::*;
|
||||
use nu_protocol::{Record, Span, Value};
|
||||
use tera::Tera;
|
||||
use crate::helpers::{unwrap_value_key, value_to_serde_json, wrap_top_level_if_needed};
|
||||
use crate::{Render, TeraPlugin};
|
||||
|
||||
|
||||
/// Runs the plugin test examples using nu_plugin_test_support.
|
||||
#[test]
|
||||
fn test_examples() -> Result<(), nu_protocol::ShellError> {
|
||||
use nu_plugin_test_support::PluginTest;
|
||||
|
||||
// This will automatically run the examples specified in your command and compare their actual
|
||||
// output against what was specified in the example. You can remove this test if the examples
|
||||
// can't be tested this way, but we recommend including it if possible.
|
||||
|
||||
PluginTest::new("tera", TeraPlugin.into())?.test_command_examples(&Render)
|
||||
}
|
||||
#[test]
|
||||
fn test_value_to_serde_json_record() {
|
||||
let record = Record::from_raw_cols_vals(
|
||||
vec!["name".to_string(), "age".to_string()],
|
||||
vec![
|
||||
Value::string("Akasha", Span::test_data()),
|
||||
Value::int(42, Span::test_data()),
|
||||
],
|
||||
Span::test_data(),
|
||||
Span::test_data(),
|
||||
)
|
||||
.expect("failed to create test record");
|
||||
let val = Value::record(record, Span::test_data());
|
||||
let json = value_to_serde_json(val).unwrap();
|
||||
assert_eq!(json["name"], "Akasha");
|
||||
assert_eq!(json["age"], 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_value_to_serde_json_list() {
|
||||
let val = Value::list(
|
||||
vec![
|
||||
Value::int(1, Span::test_data()),
|
||||
Value::int(2, Span::test_data()),
|
||||
],
|
||||
Span::test_data(),
|
||||
);
|
||||
let json = value_to_serde_json(val).unwrap();
|
||||
assert_eq!(json, serde_json::json!([1, 2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_value_to_serde_json_string() {
|
||||
let val = Value::string("hello", Span::test_data());
|
||||
let json = value_to_serde_json(val).unwrap();
|
||||
assert_eq!(json, serde_json::json!("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unwrap_value_key_simple() {
|
||||
let json = serde_json::json!({"value": {"name": "Akasha"}});
|
||||
let unwrapped = unwrap_value_key(json);
|
||||
assert_eq!(unwrapped["name"], "Akasha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unwrap_value_key_nested() {
|
||||
let json = serde_json::json!({"value": {"value": {"name": "Akasha"}}});
|
||||
let unwrapped = unwrap_value_key(json);
|
||||
assert_eq!(unwrapped["name"], "Akasha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unwrap_value_key_non_object() {
|
||||
let json = serde_json::json!(42);
|
||||
let unwrapped = unwrap_value_key(json);
|
||||
assert_eq!(unwrapped["value"], 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unwrap_value_key_object() {
|
||||
let json = serde_json::json!({"name": "Akasha"});
|
||||
let unwrapped = unwrap_value_key(json);
|
||||
assert_eq!(unwrapped["name"], "Akasha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_pipeline() {
|
||||
let template = "Hello, {{ name }}!";
|
||||
let mut tera = Tera::default();
|
||||
tera.add_raw_template("test", template).unwrap();
|
||||
let record = Record::from_raw_cols_vals(
|
||||
vec!["name".to_string()],
|
||||
vec![Value::string("Akasha", Span::test_data())],
|
||||
Span::test_data(),
|
||||
Span::test_data(),
|
||||
)
|
||||
.expect("failed to create test record");
|
||||
let val = Value::record(record, Span::test_data());
|
||||
let context_json =
|
||||
unwrap_value_key(wrap_top_level_if_needed(value_to_serde_json(val).unwrap()));
|
||||
let context = tera::Context::from_serialize(context_json).unwrap();
|
||||
let output = tera.render("test", &context).unwrap();
|
||||
assert_eq!(output, "Hello, Akasha!");
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user