2025-06-27 02:32:49 +01:00

434 lines
13 KiB
Rust

use kcl_lang::{API, ExecProgramArgs, ExternalPkg};
use nu_plugin::{EngineInterface, EvaluatedCall, SimplePluginCommand};
use nu_plugin::{MsgPackSerializer, Plugin, PluginCommand, serve_plugin};
use nu_protocol::IntoPipelineData;
use nu_protocol::{
Category, Example, LabeledError, PipelineData, Signature, SyntaxShape, Type, Value,
};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// Nushell plugin for executing KCL files and inline KCL code.
///
/// This plugin integrates [KCL](https://kcl-lang.io/) (Kusion Configuration Language) with [Nushell](https://www.nushell.sh/), allowing you to execute KCL files or inline KCL code directly from your Nushell pipeline.
///
/// # Provided Commands
///
/// - `kcl-exec`: Execute one or more KCL files and return the result as YAML. Accepts an array of file paths and an optional `--work_dir` flag to specify the working directory.
/// - `kcl-run`: Execute inline KCL code from the pipeline and return the result as YAML.
///
/// # Usage
///
/// ## kcl-exec
///
/// Run multiple KCL files in the current directory:
///
/// ```shell
/// kcl-exec [settings.k defs/aws_defaults.k]
/// ```
///
/// Run multiple KCL files from any directory, specifying the project root:
///
/// ```shell
/// kcl-exec [settings.k defs/aws_defaults.k] --work_dir /path/to/project
/// ```
///
/// Run a single KCL file:
///
/// ```shell
/// kcl-exec [settings.k] --work_dir /path/to/project
/// ```
///
/// ## kcl-run
///
/// Run inline KCL code:
///
/// ```shell
/// 'a = 1' | kcl-run
/// ```
///
/// Run a KCL file from the pipeline:
///
/// ```shell
/// open settings.k | kcl-run
/// ```
///
/// # Limitations
///
/// - The `kcl-exec` command requires the specified files to exist and be accessible.
/// - The `kcl-run` command expects a string of KCL code from the pipeline input.
/// - Example-based tests that require actual files or pipeline input may not run in all test environments.
///
/// # Resources
///
/// - [KCL Language Documentation](https://kcl-lang.io/docs/)
/// - [Nushell Plugin Development](https://www.nushell.sh/book/plugins.html)
pub struct KclPlugin;
impl Plugin for KclPlugin {
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()
}
fn commands(&self) -> Vec<Box<dyn PluginCommand<Plugin = Self>>> {
vec![
// Commands should be added here
Box::new(KclExec),
Box::new(KclValidate),
]
}
}
/// Command to execute KCL files from a file path.
///
/// This command takes a path to a KCL file and executes it, returning the result as YAML.
///
/// # Examples
///
/// ```shell
/// kcl-exec ./src/myfile.k
/// ```
///
/// # Arguments
///
/// - `file`: Path to the KCL file to execute (required).
/// - `work_dir`: Optional working directory for KCL execution.
///
/// # Limitations
///
/// The file must exist and be readable by the plugin process.
/// - If `--work_dir` is not provided, the plugin will automatically search for `kcl.mod` upwards from the first file in the files array and use its directory as the working directory.
pub struct KclExec;
impl SimplePluginCommand for KclExec {
type Plugin = KclPlugin;
fn name(&self) -> &str {
"kcl-exec"
}
fn signature(&self) -> Signature {
Signature::build(PluginCommand::name(self))
.input_output_type(Type::Any, Type::String)
.required(
"files",
SyntaxShape::List(Box::new(SyntaxShape::Filepath)),
"KCL files to execute (array)",
)
.named(
"work_dir",
SyntaxShape::Directory,
"Work directory",
Some('w'),
)
.category(Category::Experimental)
}
fn description(&self) -> &str {
"Exec KCL files and return result in YAML"
}
fn examples(&self) -> Vec<Example> {
let examples: Vec<Example> = vec![Example {
example: "kcl-exec [settings.k defs/aws_defaults.k] --work_dir .",
description: "Execute multiple KCL files in the current directory",
result: None,
}];
examples
}
fn run(
&self,
_plugin: &KclPlugin,
_engine: &EngineInterface,
call: &EvaluatedCall,
_input: &Value,
) -> Result<Value, LabeledError> {
let files: Vec<String> = call.req(0)?;
let work_dir: Option<String> = call.get_flag("work_dir")?;
match exec_kcl_file(&files, work_dir.as_deref()) {
Ok(yaml) => Ok(Value::string(yaml, call.head)),
Err(e) => Err(LabeledError::new("KCL code execution error").with_label(e, call.head)),
}
}
}
/// Command to execute inline KCL code from the Nushell pipeline.
///
/// This command takes a string of KCL code from the pipeline and executes it, returning the result as YAML.
///
/// # Example
///
/// ```shell
/// open myfile.k | kcl-run
/// ```
///
/// # Arguments
///
/// - `filename`: Optional virtual filename for the KCL code (default: `main.k`).
///
/// # Limitations
///
/// The input must be a string containing valid KCL code.
struct KclValidate;
impl PluginCommand for KclValidate {
type Plugin = KclPlugin;
fn name(&self) -> &str {
"kcl-run"
}
fn description(&self) -> &str {
"Exec KCL inline code from pipeline"
}
fn signature(&self) -> Signature {
Signature::build(PluginCommand::name(self))
.input_output_type(Type::String, Type::String)
.optional(
"filename",
SyntaxShape::String,
"Nombre virtual del archivo",
)
.category(Category::Experimental)
}
fn run(
&self,
_plugin: &KclPlugin,
_engine: &EngineInterface,
call: &EvaluatedCall,
input: PipelineData,
) -> Result<PipelineData, LabeledError> {
let filename = call
.opt::<String>(0)?
.unwrap_or_else(|| "main.k".to_string());
let value = input.into_value(call.head);
let code = match value {
Ok(Value::String { val, .. }) => val,
Ok(_) => {
return Err(LabeledError::new("KCL code expected as string")
.with_label("Use a string as KCL code", call.head));
}
Err(e) => {
return Err(LabeledError::new("Failed to extract input value")
.with_label(e.to_string(), call.head));
}
};
match exec_kcl_inline(&code, &filename) {
Ok(yaml) => Ok(Value::string(yaml, call.head).into_pipeline_data()),
Err(e) => Err(LabeledError::new("KCL code execution error").with_label(e, call.head)),
}
}
fn examples(&self) -> Vec<Example> {
vec![Example {
example: "'a = 1' | kcl-run",
description: "Run inline KCL code from the pipeline",
result: None,
}]
}
}
fn main() {
serve_plugin(&KclPlugin, MsgPackSerializer);
}
fn exec_kcl_file(file_paths: &[String], work_dir: Option<&str>) -> Result<String, String> {
let work_dir = match work_dir {
Some(dir) => dir.to_string(),
None => {
// Try to find kcl.mod upwards from the first file
if let Some(first_file) = file_paths.get(0) {
match find_kcl_mod_dir(first_file) {
Some(dir) => dir,
None => return Err("No kcl.mod found in parent directories of the first file, and --work_dir not provided".to_string()),
}
} else {
return Err("No files provided to execute".to_string());
}
}
};
let external_pkgs = resolve_dependencies_toml(&work_dir)?;
// Debug: mostrar dependencias resueltas
for pkg in &external_pkgs {
println!("Dependencies: {} -> {}", pkg.pkg_name, pkg.pkg_path);
println!("Existe: {}", Path::new(&pkg.pkg_path).exists());
}
let api = API::default();
let args = ExecProgramArgs {
k_filename_list: file_paths.to_vec(),
work_dir,
..Default::default()
};
match api.exec_program(&args) {
Ok(result) => Ok(result.yaml_result),
Err(e) => Err(e.to_string()),
}
}
/// Search upwards from the given file for a kcl.mod file and return its directory as a String.
fn find_kcl_mod_dir<P: AsRef<Path>>(start_file: P) -> Option<String> {
let mut dir = PathBuf::from(start_file.as_ref()).parent()?.to_path_buf();
loop {
let kcl_mod = dir.join("kcl.mod");
if kcl_mod.exists() {
return Some(dir.to_string_lossy().to_string());
}
if !dir.pop() {
break;
}
}
None
}
fn exec_kcl_inline(code: &str, filename: &str) -> Result<String, String> {
let api = API::default();
let args = ExecProgramArgs {
k_filename_list: vec![filename.to_string()],
k_code_list: vec![code.to_string()],
..Default::default()
};
match api.exec_program(&args) {
Ok(result) => Ok(result.yaml_result),
Err(e) => Err(e.to_string()),
}
}
#[allow(dead_code)]
#[derive(Deserialize)]
struct KclMod {
package: Package,
dependencies: Option<HashMap<String, Dependency>>,
}
#[allow(dead_code)]
#[derive(Deserialize)]
struct Package {
name: String,
edition: String,
version: String,
}
#[allow(dead_code)]
#[derive(Deserialize)]
struct Dependency {
path: Option<String>,
version: String,
}
fn resolve_dependencies_toml(work_dir: &str) -> Result<Vec<ExternalPkg>, String> {
let kcl_mod_path = Path::new(work_dir).join("kcl.mod");
if !kcl_mod_path.exists() {
return Ok(vec![]);
}
let content = std::fs::read_to_string(&kcl_mod_path)
.map_err(|e| format!("Error reading kcl.mod: {}", e))?;
let kcl_mod: KclMod =
toml::from_str(&content).map_err(|e| format!("Error parsing kcl.mod: {}", e))?;
let mut external_pkgs = Vec::new();
if let Some(dependencies) = kcl_mod.dependencies {
for (pkg_name, dep) in dependencies {
if let Some(relative_path) = dep.path {
let absolute_path = Path::new(work_dir)
.join(relative_path)
.canonicalize()
.map_err(|e| format!("Error resolving path for {}: {}", pkg_name, e))?
.to_string_lossy()
.to_string();
external_pkgs.push(ExternalPkg {
pkg_name,
pkg_path: absolute_path,
});
}
}
}
dbg!(&external_pkgs);
Ok(external_pkgs)
}
/// Test all examples for the `kcl-exec` command.
///
/// This test runs the examples provided in the `examples` method of `KclExec`.
/// Note: Examples that require actual files may not pass unless the files exist.
#[test]
fn test_examples() -> Result<(), nu_protocol::ShellError> {
use nu_plugin_test_support::PluginTest;
PluginTest::new("kcl", KclPlugin.into())?.test_command_examples(&KclExec)
}
/// Test all examples for the `kcl-run` command.
///
/// This test runs the examples provided in the `examples` method of `KclValidate`.
/// Note: Examples that require pipeline input may not be fully testable in all environments.
#[test]
fn test_kcl_validate_examples() -> Result<(), nu_protocol::ShellError> {
use nu_plugin_test_support::PluginTest;
PluginTest::new("kcl", KclPlugin.into())?.test_command_examples(&KclValidate)
}
/// Entry point for the KCL Nushell plugin.
///
/// This function starts the plugin and serves it to Nushell using message pack serialization.
#[cfg(test)]
mod unit_tests {
use super::*;
use std::fs;
use std::io::Write;
extern crate tempfile;
use tempfile::tempdir;
#[test]
fn test_exec_kcl_file_nonexistent() {
let result = exec_kcl_file(&vec!["/tmp/does_not_exist.k".to_string()], None);
assert!(result.is_err());
}
#[test]
fn test_exec_kcl_inline_invalid_code() {
let result = exec_kcl_inline("not valid kcl", "main.k");
assert!(result.is_err());
}
#[test]
fn test_resolve_dependencies_toml_success() {
let dir = tempdir().unwrap();
let dep_dir = dir.path().join("dep");
fs::create_dir_all(&dep_dir).unwrap();
let kcl_mod_content = r#"
[package]
name = "testpkg"
edition = "v0.9.0"
version = "0.1.0"
[dependencies]
foo = { path = "dep" , version = "0.1.0" }
"#;
let kcl_mod_path = dir.path().join("kcl.mod");
let mut file = fs::File::create(&kcl_mod_path).unwrap();
file.write_all(kcl_mod_content.as_bytes()).unwrap();
let pkgs = resolve_dependencies_toml(dir.path().to_str().unwrap()).unwrap();
assert_eq!(pkgs.len(), 1);
assert_eq!(pkgs[0].pkg_name, "foo");
assert!(pkgs[0].pkg_path.ends_with("dep"));
}
#[test]
fn test_resolve_dependencies_toml_no_kcl_mod() {
let dir = tempdir().unwrap();
let pkgs = resolve_dependencies_toml(dir.path().to_str().unwrap()).unwrap();
assert!(pkgs.is_empty());
}
}