434 lines
13 KiB
Rust
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());
|
||
|
}
|
||
|
}
|