Files
pyranota 11dd4118ce feat: add ruby support (#5939)
* implement single line pin

* make panic-safe

* use pin even if multiple modules imported withing single statement

* add repins and make imports respect pins

* keep all pins

* Allow multiple pins

* add comments + handle stuff more safely

* fix fully qualified imports

* remove ignore

* sort nested

* apply unique to output requirements list

* fix typo

* remove mut

* update sqlx

* sort imports

* sort imports

* fix formatter and format

* feat: add ruby support

* fix

* update sqlx

* add ruby to flake.nix

Signed-off-by: pyranota <pyra@duck.com>

* upgrade par_install_language_dependencies helper

rename fields + allow passing custom payload

Signed-off-by: pyranota <pyra@duck.com>

* change migrations

Signed-off-by: pyranota <pyra@duck.com>

* make full pipeline work!

Signed-off-by: pyranota <pyra@duck.com>

* do a bit fixing

* stage

Signed-off-by: pyranota <pyra@duck.com>

* add ruby in dockerfiles

Signed-off-by: pyranota <pyra@duck.com>

* implement auto-require + 'windmill/inline'

Signed-off-by: pyranota <pyra@duck.com>

* create 'windmill/inline' only if does not exist

Signed-off-by: pyranota <pyra@duck.com>

* add nsjail

Signed-off-by: pyranota <pyra@duck.com>

* update global settings

Signed-off-by: pyranota <pyra@duck.com>

* add ruby icon

* generate lockfile on deploy (but broken)

Signed-off-by: pyranota <pyra@duck.com>

* fix(frontend): display deployed script lockfile and other info

Signed-off-by: pyranota <pyra@duck.com>

* feat: add mini-wmill client

Methods:
- get_resource
- get_variable

Additionally add shortcuts on frontend

Signed-off-by: pyranota <pyra@duck.com>

* add nit warning

Signed-off-by: pyranota <pyra@duck.com>

* support private repos credentials

Signed-off-by: pyranota <pyra@duck.com>

* fix compilation error

Signed-off-by: pyranota <pyra@duck.com>

* found rustc bug + refactor universal installer

Signed-off-by: pyranota <pyra@duck.com>

* more refactor + fixes

Signed-off-by: pyranota <pyra@duck.com>

* bug fixing + polishing

Signed-off-by: pyranota <pyra@duck.com>

* cleanup

Signed-off-by: pyranota <pyra@duck.com>

* change 'windmill/inline'

Signed-off-by: pyranota <pyra@duck.com>

* delegate env_clear to universal installer

Signed-off-by: pyranota <pyra@duck.com>

* update init script

Signed-off-by: pyranota <pyra@duck.com>

* add cli support

Signed-off-by: pyranota <pyra@duck.com>

* unhardcode frontend

Signed-off-by: pyranota <pyra@duck.com>

* fix cli

Signed-off-by: pyranota <pyra@duck.com>

* fix init script

Signed-off-by: pyranota <pyra@duck.com>

* make it work on ms windows

Signed-off-by: pyranota <pyra@duck.com>

* do not clean env in the end

Signed-off-by: pyranota <pyra@duck.com>

* fix windows (again)

Signed-off-by: pyranota <pyra@duck.com>

* include PROXY_ENVS (untested)

Signed-off-by: pyranota <pyra@duck.com>

* feat: self-signed certs + proxy support (tested)

Signed-off-by: pyranota <pyra@duck.com>

* flake: pin ruby to 3.4

* merge follow-ups

Signed-off-by: pyranota <pyra@duck.com>

* remove obsolete libc crate

Signed-off-by: pyranota <pyra@duck.com>

* cleanup parser

Signed-off-by: pyranota <pyra@duck.com>

* fix java ci

Signed-off-by: pyranota <pyra@duck.com>

* cleanup

Signed-off-by: pyranota <pyra@duck.com>

* fix nits

Signed-off-by: pyranota <pyra@duck.com>

* Update backend/windmill-worker/src/universal_pkg_installer.rs

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update backend/windmill-worker/src/ruby_executor.rs

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* add nsjail for lock stage

Signed-off-by: pyranota <pyra@duck.com>

* fix merge

Signed-off-by: pyranota <pyra@duck.com>

* Update common.rs

* regen windmill-api-client

Signed-off-by: pyranota <pyra@duck.com>

* fix tests

Signed-off-by: pyranota <pyra@duck.com>

* fix tests again

Signed-off-by: pyranota <pyra@duck.com>

* reference published ruby parser

Signed-off-by: pyranota <pyra@duck.com>

---------

Signed-off-by: pyranota <pyra@duck.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-08-15 13:03:32 +00:00

411 lines
14 KiB
Rust

#![cfg_attr(target_arch = "wasm32", feature(c_variadic))]
#[cfg(target_arch = "wasm32")]
pub mod wasm_libc;
use anyhow::anyhow;
use anyhow::bail;
use regex::Regex;
use serde_json::Value;
use tree_sitter::Node;
use tree_sitter::Range;
use windmill_parser::json_to_typ;
use windmill_parser::Arg;
use windmill_parser::MainArgSignature;
pub fn parse_ruby_sig_meta(code: &str) -> anyhow::Result<MainArgSignature> {
let mut parser = tree_sitter::Parser::new();
let language = tree_sitter_ruby::LANGUAGE;
parser
.set_language(&language.into())
.map_err(|e| anyhow!("Error setting Ruby as language: {e}"))?;
// Parse code
let tree = parser
.parse(code, None)
.ok_or(anyhow!("Failed to parse code"))?;
let root_node = tree.root_node();
root_node.clone().to_string();
// Traverse the AST to find the Main method signature
let args = find_main_signature(root_node, code)?;
let no_main_func = Some(args.is_none());
let main_sig = MainArgSignature {
star_args: false,
star_kwargs: false,
args: args.unwrap_or_default(),
has_preprocessor: None,
no_main_func,
};
Ok(main_sig)
}
pub fn parse_ruby_signature(code: &str) -> anyhow::Result<MainArgSignature> {
Ok(parse_ruby_sig_meta(code)?)
}
pub fn parse_ruby_requirements(code: &str) -> anyhow::Result<String> {
let mut parser = tree_sitter::Parser::new();
let language = tree_sitter_ruby::LANGUAGE;
parser
.set_language(&language.into())
.map_err(|e| anyhow!("Error setting Ruby as language: {e}"))?;
// Parse code
let tree = parser
.parse(code, None)
.ok_or(anyhow!("Failed to parse code"))?;
let root_node = tree.root_node();
root_node.clone().to_string();
let mut cursor = root_node.walk();
'top_level: for x in root_node.children(&mut cursor) {
if x.kind() == "call" {
for (i, n) in x.children(&mut x.walk()).enumerate() {
if i == 0
&& n.kind() == "identifier"
&& !n
.utf8_text(code.as_bytes())
.map(|ident| ident == "gemfile")
.unwrap_or_default()
{
continue 'top_level;
} else if n.kind() == "do_block" {
let req = n.utf8_text(code.as_bytes())?.to_owned();
lazy_static::lazy_static! {
static ref WINDMILL_RE: Regex = Regex::new(r"(?m)^\s*require\s*'windmill/inline'").unwrap();
}
if WINDMILL_RE.find(&code).is_none() {
return Err(anyhow!(
"`require 'windmill/inline'` is not detected - please add `require 'windmill/inline'` in order to use inline gemfile.
Your Gemfile syntax will be compatible with bundler/inline."
)
.into());
}
return req
// gemfile do_block comes with 'do' and 'end'
// we want to omit these by taking slice
.get(2..(req.len() - 3))
.map(str::to_owned)
.ok_or(anyhow!("Invalid gemfile do block"));
}
}
}
}
Ok(String::new())
}
// Function to find the Main method's signature
fn find_main_signature<'a>(root_node: Node<'a>, code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
let mut cursor = root_node.walk();
'top_level: for x in root_node.children(&mut cursor) {
if x.kind() == "method" {
let mut args = vec![];
for (i, m) in x.children(&mut x.walk()).skip(1).enumerate() {
if i == 0
&& m.kind() == "identifier"
&& !m
.utf8_text(code.as_bytes())
.map(|ident| ident == "main")
.unwrap_or_default()
{
continue 'top_level;
} else if m.kind() == "method_parameters" {
for a in m.children(&mut m.walk()) {
if a.kind() == "identifier" {
a.utf8_text(code.as_bytes()).inspect(|name| {
args.push(Arg { name: (*name).to_owned(), ..Default::default() })
})?;
}
if a.kind() == "optional_parameter" {
let mut walk = a.walk();
let mut it = a.children(&mut walk).into_iter();
match (it.next().and_then(|n| n.utf8_text(code.as_bytes()).ok()), {
// Skip `=`
it.next();
it.next().map(|n| n.range())
}) {
(Some(ident), Some(Range { start_byte, end_byte, .. })) => {
let unparsed =
&code[start_byte..end_byte].replace("nil", "null");
let default: Value = serde_json::from_str(unparsed).map_err(
|e|
anyhow!("Cannot convert default value to json\n\tvalue: {unparsed}\n\terror: {e}{}",
if e.to_string().contains("key must be a string") {
"\n\nNOTE: If you are trying to declare default hash, use following syntax:\n { \"<key>\": <value> }"
} else {
""
}
))?;
args.push(Arg {
name: ident.to_owned(),
typ: json_to_typ(&default, true),
default: Some(default),
has_default: true,
..Default::default()
});
}
_ => {
let Range { start_byte, end_byte, .. } = a.range();
bail!(
"Cannot parse optional parameter: {}",
&code.get(start_byte..end_byte).unwrap_or("CANNOT DISPLAY")
)
}
}
}
let kind = a.kind();
if matches!(
kind,
"keyword_parameter" | "splat_parameter" | "hash_splat_parameter"
) {
let Range { start_byte, end_byte, .. } = a.range();
bail!(
" - {}\n{}s are not supported",
&code.get(start_byte..end_byte).unwrap_or("CANNOT DISPLAY"),
kind.replace("_", " ")
);
}
}
}
}
return Ok(Some(args));
}
}
Ok(None)
}
#[cfg(test)]
mod test {
use serde_json::json;
use windmill_parser::{ObjectProperty, ObjectType, Typ};
use super::parse_ruby_sig_meta as parse;
use super::*;
#[test]
fn test_parse_ruby_no_main() {
let code = r#"
def not_main end
def private_fn end
"#;
let sig = parse(code).unwrap();
assert_eq!(
sig,
MainArgSignature { no_main_func: Some(true), ..Default::default() }
);
}
#[test]
fn test_parse_ruby_no_args() {
let code = r#"
def main
end
"#;
let sig = parse(code).unwrap();
assert_eq!(
sig,
MainArgSignature { no_main_func: Some(false), ..Default::default() }
);
}
#[test]
fn test_parse_ruby_positional_args() {
let sig = {
let code = r#"def main(a, b, c) end"#;
parse(code).unwrap()
};
let sig2 = {
let code = r#"
def main a, b, c
end"#;
parse(code).unwrap()
};
assert_eq!(
sig,
MainArgSignature {
args: vec![
Arg { name: "a".into(), ..Default::default() },
Arg { name: "b".into(), ..Default::default() },
Arg { name: "c".into(), ..Default::default() }
],
no_main_func: Some(false),
..Default::default()
}
);
assert_eq!(sig2, sig);
}
#[test]
fn test_parse_ruby_lists() {
let sig = {
let code = r#"def main(a = [ 1, 2, 3 ], b = [ 1, false, nil, "test"]) end"#;
parse(code).unwrap()
};
assert_eq!(
sig,
MainArgSignature {
args: vec![
Arg {
name: "a".into(),
has_default: true,
typ: Typ::List(Box::new(Typ::Int)),
default: Some(json!([1, 2, 3])),
..Default::default()
},
Arg {
name: "b".into(),
has_default: true,
typ: Typ::List(Box::new(Typ::Unknown)),
default: Some(json!([1, false, null, "test"])),
..Default::default()
},
],
no_main_func: Some(false),
..Default::default()
}
);
}
#[test]
fn test_parse_ruby_hashes() {
let sig = {
let code = r#"def main(a = { "1": 4, "2": [ 1, 2, 3] }) end"#;
parse(code).unwrap()
};
assert_eq!(
sig,
MainArgSignature {
args: vec![Arg {
name: "a".into(),
has_default: true,
typ: Typ::Object(ObjectType::new(
None,
Some(vec![
ObjectProperty { key: "1".into(), typ: Box::new(Typ::Int) },
ObjectProperty {
key: "2".into(),
typ: Box::new(Typ::List(Box::new(Typ::Int)))
}
],)
)),
default: Some(json!({"1": 4, "2": [ 1, 2, 3 ]})),
..Default::default()
},],
no_main_func: Some(false),
..Default::default()
}
);
}
#[test]
fn test_parse_ruby_default_args() {
let sig = {
let code =
r#"def main(a = 10, b = "hey", c = false, d = [ 1, 2, 3 ], e = { "a": 43 }) end"#;
parse(code).unwrap()
};
assert_eq!(
sig,
MainArgSignature {
args: vec![
Arg {
name: "a".into(),
has_default: true,
typ: Typ::Int,
default: Some(json!(10)),
..Default::default()
},
Arg {
name: "b".into(),
has_default: true,
typ: Typ::Str(None),
default: Some(json!("hey")),
..Default::default()
},
Arg {
name: "c".into(),
has_default: true,
typ: Typ::Bool,
default: Some(json!(false)),
..Default::default()
},
Arg {
name: "d".into(),
has_default: true,
typ: Typ::List(Box::new(Typ::Int)),
default: Some(json!([1, 2, 3])),
..Default::default()
},
Arg {
name: "e".into(),
has_default: true,
typ: Typ::Object(ObjectType::new(
None,
Some(vec![ObjectProperty {
key: "a".into(),
typ: Box::new(Typ::Int)
}])
)),
default: Some(json!({"a": 43})),
..Default::default()
},
],
no_main_func: Some(false),
..Default::default()
}
);
}
#[test]
fn test_parse_ruby_unsupported() {
assert_eq!(
&parse("def main( a: ) end").unwrap_err().to_string(),
" - a:\nkeyword parameters are not supported"
);
assert_eq!(
&parse("def main( *a ) end").unwrap_err().to_string(),
" - *a\nsplat parameters are not supported"
);
assert_eq!(
&parse("def main( **a ) end").unwrap_err().to_string(),
" - **a\nhash splat parameters are not supported"
);
assert!(&parse("def main( a = Time.now ) end").is_err());
assert!(&parse("def main( a = :symbol ) end").is_err());
assert!(&parse("def main( a = { b: 2 } ) end").is_err());
assert!(&parse("def main( a = { b => 2 } ) end").is_err());
assert!(&parse(r#"def main( a = { "b" => 2 } ) end"#).is_err());
}
// #[test]
// fn test_parse_ruby_requirements() {
// assert_eq!(
// parse_ruby_requirements(
// "
// require 'dep1'
// require 'dep2'
// require 'dep2/submod'
// require 'u/username/script'
// require 'f/folder/script'
// "
// )
// .unwrap(),
// vec![
// "dep1".to_owned(),
// "dep2".into(),
// "dep2".into(),
// "u/username/script".into(),
// "f/folder/script".into(),
// ]
// );
// }
}