chore: add api_nu_plugin_kcl

This commit is contained in:
Jesús Pérex 2025-06-27 02:32:49 +01:00
parent 4acf51fbdc
commit 7aafe5523f
6 changed files with 6963 additions and 0 deletions

2
api_nu_plugin_kcl/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
.DS_Store

6457
api_nu_plugin_kcl/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
[package]
name = "nu_plugin_kcl"
version = "0.1.0"
authors = ["Jesús Pérez <jpl@jesusperez.com>"]
edition = "2024"
description = "a nushell plugin called kcl"
repository = "https://github.com/jesusperez/nu_plugin_kcl"
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"] }
kcl-lang = { git = "https://github.com/kcl-lang/lib" }
anyhow = "1.0"
toml = "0.8"
serde = { version = "1.0", features = ["derive"] }
tempfile = "3"
[dev-dependencies]
nu-plugin-test-support = { path = "../nushell/crates/nu-plugin-test-support" }
#nu-plugin-test-support = { version = "0.104.0" }

21
api_nu_plugin_kcl/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 - 2022 The Nushell Project Developers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,23 @@
# nu_plugin_kcl
This is a [Nushell](https://nushell.sh/) plugin called "kcl".
## Installing
```nushell
> cargo install --path .
```
## Usage
FIXME: This reflects the demo functionality generated with the template. Update this documentation
once you have implemented the actual plugin functionality.
```nushell
> plugin add ~/.cargo/bin/nu_plugin_kcl
> plugin use kcl
> kcl-exec Ellie
Hello, Ellie. How are you today?
> kcl-exec --shout Ellie
HELLO, ELLIE. HOW ARE YOU TODAY?
```

View File

@ -0,0 +1,433 @@
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());
}
}