chore: add code
This commit is contained in:
parent
050b9b8512
commit
38a7885504
5 changed files with 2990 additions and 0 deletions
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…
Add table
Reference in a new issue