use std::sync::Arc;
use rhai::{
serde::{from_dynamic, to_dynamic},
Array, Dynamic, Engine, AST,
};
use crate::file_analysis::{HashKeyOwner, InferredType, Span};
use tree_sitter::Point;
use super::{
CallContext, CompletionQueryContext, EmitAction, FrameworkPlugin, PluginCompletionAnswer,
PluginSigHelpAnswer, SigHelpQueryContext, Trigger, TypeOverride, UseContext,
};
pub fn make_engine() -> Engine {
let mut engine = Engine::new();
engine.set_max_expr_depths(64, 64);
let max_ops: u64 = std::env::var("PERL_LSP_RHAI_MAX_OPS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1_000_000);
engine.set_max_operations(max_ops);
engine.register_fn("owner_sub", |package: String, name: String| {
let pkg = if package.is_empty() { None } else { Some(package) };
let owner = HashKeyOwner::Sub { package: pkg, name };
to_dynamic(owner).unwrap_or(Dynamic::UNIT)
});
engine.register_fn("owner_sub_unscoped", |name: String| {
let owner = HashKeyOwner::Sub { package: None, name };
to_dynamic(owner).unwrap_or(Dynamic::UNIT)
});
engine.register_fn("owner_class", |class: String| {
let owner = HashKeyOwner::Class(class);
to_dynamic(owner).unwrap_or(Dynamic::UNIT)
});
engine.register_fn("type_string", || to_dynamic(InferredType::String).unwrap());
engine.register_fn("type_numeric", || to_dynamic(InferredType::Numeric).unwrap());
engine.register_fn("type_hashref", || to_dynamic(InferredType::HashRef).unwrap());
engine.register_fn("type_arrayref", || to_dynamic(InferredType::ArrayRef).unwrap());
engine.register_fn("type_coderef", || {
to_dynamic(InferredType::CodeRef { return_edge: None }).unwrap()
});
engine.register_fn("type_regexp", || to_dynamic(InferredType::Regexp).unwrap());
engine.register_fn("type_class", |class: String| {
to_dynamic(InferredType::ClassName(class)).unwrap_or(Dynamic::UNIT)
});
engine.register_fn("as_invocant_params", |list: Array| -> Array {
let mut out = list;
if let Some(first) = out.get_mut(0) {
if let Ok(mut m) = first.as_map_mut() {
m.insert("is_invocant".into(), Dynamic::from(true));
}
}
out
});
engine.register_fn(
"subspan_cols",
|base: Dynamic, col_start_delta: i64, col_end_delta: i64| -> Dynamic {
let Ok(span) = from_dynamic::<Span>(&base) else { return Dynamic::UNIT; };
let start = Point::new(
span.start.row,
(span.start.column as i64 + col_start_delta).max(0) as usize,
);
let end = Point::new(
span.start.row,
(span.start.column as i64 + col_end_delta).max(0) as usize,
);
to_dynamic(Span { start, end }).unwrap_or(Dynamic::UNIT)
},
);
engine
}
pub struct RhaiPlugin {
id: String,
triggers: Vec<Trigger>,
overrides: Vec<TypeOverride>,
engine: Arc<Engine>,
ast: Arc<AST>,
has_on_function_call: bool,
has_on_method_call: bool,
has_on_use: bool,
has_on_signature_help: bool,
has_on_completion: bool,
}
impl RhaiPlugin {
pub fn from_source(
source: &str,
engine: Arc<Engine>,
) -> Result<Self, String> {
let ast = engine
.compile(source)
.map_err(|e| format!("rhai compile: {}", e))?;
let id: String = engine
.call_fn(&mut rhai::Scope::new(), &ast, "id", ())
.map_err(|e| format!("rhai `id()`: {}", e))?;
let trig_dyn: Array = engine
.call_fn(&mut rhai::Scope::new(), &ast, "triggers", ())
.map_err(|e| format!("rhai `triggers()`: {}", e))?;
let mut triggers = Vec::with_capacity(trig_dyn.len());
for d in trig_dyn {
let t: Trigger = from_dynamic(&d)
.map_err(|e| format!("bad trigger from `{}`: {}", id, e))?;
triggers.push(t);
}
let signatures: Vec<String> = ast
.iter_functions()
.map(|f| f.name.to_string())
.collect();
let mut overrides: Vec<TypeOverride> = Vec::new();
if signatures.iter().any(|n| n == "overrides") {
match engine.call_fn::<Array>(&mut rhai::Scope::new(), &ast, "overrides", ()) {
Ok(arr) => {
for d in arr {
match from_dynamic::<TypeOverride>(&d) {
Ok(o) => overrides.push(o),
Err(e) => log::error!(
"plugin `{}` overrides() bad entry: {}",
id,
e
),
}
}
}
Err(e) => log::error!("plugin `{}` overrides() failed: {}", id, e),
}
}
Ok(Self {
has_on_function_call: signatures.iter().any(|n| n == "on_function_call"),
has_on_method_call: signatures.iter().any(|n| n == "on_method_call"),
has_on_use: signatures.iter().any(|n| n == "on_use"),
has_on_signature_help: signatures.iter().any(|n| n == "on_signature_help"),
has_on_completion: signatures.iter().any(|n| n == "on_completion"),
id,
triggers,
overrides,
engine,
ast: Arc::new(ast),
})
}
fn call_opt_map<T: serde::de::DeserializeOwned>(&self, fn_name: &str, arg: Dynamic) -> Option<T> {
let out: Result<Dynamic, _> =
self.engine.call_fn(&mut rhai::Scope::new(), &self.ast, fn_name, (arg,));
let v = match out {
Ok(v) => v,
Err(e) => {
log::error!("plugin `{}`::{} failed: {}", self.id, fn_name, e);
return None;
}
};
if v.is_unit() { return None; }
match from_dynamic::<T>(&v) {
Ok(parsed) => Some(parsed),
Err(e) => {
log::error!("plugin `{}`::{} bad return: {}", self.id, fn_name, e);
None
}
}
}
fn dispatch(&self, fn_name: &str, arg: Dynamic) -> Vec<EmitAction> {
let out: Result<Array, _> =
self.engine.call_fn(&mut rhai::Scope::new(), &self.ast, fn_name, (arg,));
let arr = match out {
Ok(a) => a,
Err(e) => {
log::error!("plugin `{}`::{} failed: {}", self.id, fn_name, e);
return Vec::new();
}
};
arr.into_iter()
.filter_map(|d| {
from_dynamic::<EmitAction>(&d)
.map_err(|e| {
log::error!(
"plugin `{}`::{} bad emission: {}",
self.id,
fn_name,
e
)
})
.ok()
})
.collect()
}
}
impl FrameworkPlugin for RhaiPlugin {
fn id(&self) -> &str {
&self.id
}
fn triggers(&self) -> &[Trigger] {
&self.triggers
}
fn overrides(&self) -> &[TypeOverride] {
&self.overrides
}
fn on_function_call(&self, ctx: &CallContext) -> Vec<EmitAction> {
if !self.has_on_function_call {
return Vec::new();
}
match to_dynamic(ctx) {
Ok(d) => self.dispatch("on_function_call", d),
Err(e) => {
log::warn!("plugin `{}`: ctx serialize: {}", self.id, e);
Vec::new()
}
}
}
fn on_method_call(&self, ctx: &CallContext) -> Vec<EmitAction> {
if !self.has_on_method_call {
return Vec::new();
}
match to_dynamic(ctx) {
Ok(d) => self.dispatch("on_method_call", d),
Err(e) => {
log::warn!("plugin `{}`: ctx serialize: {}", self.id, e);
Vec::new()
}
}
}
fn on_signature_help(&self, ctx: &SigHelpQueryContext) -> Option<PluginSigHelpAnswer> {
if !self.has_on_signature_help { return None; }
let d = to_dynamic(ctx).ok()?;
self.call_opt_map("on_signature_help", d)
}
fn on_completion(&self, ctx: &CompletionQueryContext) -> Option<PluginCompletionAnswer> {
if !self.has_on_completion { return None; }
let d = to_dynamic(ctx).ok()?;
self.call_opt_map("on_completion", d)
}
fn on_use(&self, ctx: &UseContext) -> Vec<EmitAction> {
if !self.has_on_use {
return Vec::new();
}
match to_dynamic(ctx) {
Ok(d) => self.dispatch("on_use", d),
Err(e) => {
log::warn!("plugin `{}`: use ctx serialize: {}", self.id, e);
Vec::new()
}
}
}
}
const BUNDLED: &[(&str, &str)] = &[
("mojo-events", include_str!("../../frameworks/mojo-events.rhai")),
("mojo-helpers", include_str!("../../frameworks/mojo-helpers.rhai")),
("mojo-routes", include_str!("../../frameworks/mojo-routes.rhai")),
("mojo-lite", include_str!("../../frameworks/mojo-lite.rhai")),
("minion", include_str!("../../frameworks/minion.rhai")),
("data-printer", include_str!("../../frameworks/data-printer.rhai")),
];
pub fn load_bundled(engine: Arc<Engine>) -> Vec<Box<dyn FrameworkPlugin>> {
let mut out: Vec<Box<dyn FrameworkPlugin>> = Vec::new();
for (id, src) in BUNDLED {
match RhaiPlugin::from_source(src, engine.clone()) {
Ok(p) => {
log::info!("loaded bundled plugin `{}`", id);
out.push(Box::new(p));
}
Err(e) => {
log::warn!("bundled plugin `{}` failed to load: {}", id, e);
}
}
}
out
}
pub fn plugin_fingerprint() -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
for (id, src) in BUNDLED {
id.hash(&mut hasher);
src.hash(&mut hasher);
}
if let Ok(dir) = std::env::var("PERL_LSP_PLUGIN_DIR") {
let path = std::path::PathBuf::from(&dir);
let mut entries: Vec<std::path::PathBuf> = match std::fs::read_dir(&path) {
Ok(read) => read
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("rhai"))
.collect(),
Err(_) => Vec::new(),
};
entries.sort();
for p in entries {
p.to_string_lossy().hash(&mut hasher);
if let Ok(src) = std::fs::read_to_string(&p) {
src.hash(&mut hasher);
}
}
}
format!("{:016x}", hasher.finish())
}
pub fn load_plugin_dir(
dir: &std::path::Path,
engine: Arc<Engine>,
) -> Vec<Box<dyn FrameworkPlugin>> {
let mut out: Vec<Box<dyn FrameworkPlugin>> = Vec::new();
let read = match std::fs::read_dir(dir) {
Ok(r) => r,
Err(_) => return out,
};
for entry in read.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("rhai") {
continue;
}
let source = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
log::warn!("plugin {}: read: {}", path.display(), e);
continue;
}
};
match RhaiPlugin::from_source(&source, engine.clone()) {
Ok(p) => {
log::info!("loaded plugin {} from {}", p.id(), path.display());
out.push(Box::new(p));
}
Err(e) => log::warn!("plugin {}: {}", path.display(), e),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::file_analysis::Span;
use tree_sitter::Point;
fn sp(r1: usize, c1: usize, r2: usize, c2: usize) -> Span {
Span { start: Point::new(r1, c1), end: Point::new(r2, c2) }
}
#[test]
fn minimal_plugin_loads_and_dispatches() {
let src = r#"
fn id() { "demo" }
fn triggers() { [ #{ UsesModule: "Demo" } ] }
fn on_function_call(ctx) {
if ctx.function_name == "greet" {
return [
#{
Method: #{
name: "hello",
span: ctx.call_span,
selection_span: ctx.selection_span,
params: [],
is_method: true,
return_type: (),
doc: (),
}
}
];
}
[]
}
"#;
let engine = Arc::new(make_engine());
let plugin = RhaiPlugin::from_source(src, engine).expect("compiles");
assert_eq!(plugin.id(), "demo");
assert_eq!(plugin.triggers().len(), 1);
let ctx = CallContext {
call_kind: super::super::CallKind::Function,
function_name: Some("greet".into()),
method_name: None,
receiver_text: None,
receiver_type: None,
args: vec![],
call_span: sp(0, 0, 0, 5),
selection_span: sp(0, 0, 0, 5),
current_package: Some("Demo::App".into()),
current_package_parents: vec![],
current_package_uses: vec!["Demo".into()],
};
let emissions = plugin.on_function_call(&ctx);
assert_eq!(emissions.len(), 1);
match &emissions[0] {
EmitAction::Method { name, is_method, .. } => {
assert_eq!(name, "hello");
assert!(*is_method);
}
other => panic!("unexpected emission: {:?}", other),
}
}
#[test]
fn plugin_fingerprint_invariants() {
let dir = std::env::temp_dir().join(format!(
"perl-lsp-fp-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
std::fs::create_dir_all(&dir).unwrap();
let plugin_path = dir.join("test.rhai");
let saved = std::env::var("PERL_LSP_PLUGIN_DIR").ok();
std::env::set_var("PERL_LSP_PLUGIN_DIR", &dir);
std::fs::write(&plugin_path, r#"fn id() { "v1" } fn triggers() { [] }"#).unwrap();
let v1a = plugin_fingerprint();
let v1b = plugin_fingerprint();
std::fs::write(&plugin_path, r#"fn id() { "v2" } fn triggers() { [] }"#).unwrap();
let v2 = plugin_fingerprint();
match saved {
Some(v) => std::env::set_var("PERL_LSP_PLUGIN_DIR", v),
None => std::env::remove_var("PERL_LSP_PLUGIN_DIR"),
}
let _ = std::fs::remove_file(&plugin_path);
let _ = std::fs::remove_dir(&dir);
assert!(!v1a.is_empty(), "fingerprint should never be empty");
assert_eq!(v1a, v1b, "fingerprint must be deterministic");
assert_ne!(v1a, v2, "fingerprint must change when a user plugin's source changes");
}
#[test]
fn bundled_script_compiles() {
let engine = Arc::new(make_engine());
for (id, src) in [
("mojo-events", include_str!("../../frameworks/mojo-events.rhai")),
("mojo-helpers", include_str!("../../frameworks/mojo-helpers.rhai")),
("mojo-routes", include_str!("../../frameworks/mojo-routes.rhai")),
("mojo-lite", include_str!("../../frameworks/mojo-lite.rhai")),
("minion", include_str!("../../frameworks/minion.rhai")),
("data-printer", include_str!("../../frameworks/data-printer.rhai")),
] {
RhaiPlugin::from_source(src, engine.clone())
.unwrap_or_else(|e| panic!("{}.rhai failed to compile: {e}", id));
}
}
#[test]
fn bundled_mojo_events_loads_and_emits() {
use crate::plugin::{ArgInfo, CallKind};
let engine = Arc::new(make_engine());
let bundled = load_bundled(engine);
let plugin = bundled
.into_iter()
.find(|p| p.id() == "mojo-events")
.expect("mojo-events is bundled");
let evt_span = sp(3, 15, 3, 23);
let cb_span = sp(3, 25, 3, 40);
let ctx = CallContext {
call_kind: CallKind::Method,
function_name: None,
method_name: Some("on".into()),
receiver_text: Some("$self".into()),
receiver_type: Some(InferredType::ClassName("My::Emitter".into())),
args: vec![
ArgInfo {
text: "'connect'".into(),
string_value: Some("connect".into()),
span: evt_span,
content_span: None,
inferred_type: Some(InferredType::String), sub_params: vec![], callable_return_edge: None,
},
ArgInfo {
text: "sub { ... }".into(),
string_value: None,
span: cb_span,
content_span: None,
inferred_type: Some(InferredType::CodeRef { return_edge: None }), sub_params: vec![], callable_return_edge: None,
},
],
call_span: sp(3, 4, 3, 45),
selection_span: sp(3, 10, 3, 12),
current_package: Some("My::Emitter".into()),
current_package_parents: vec!["Mojo::EventEmitter".into()],
current_package_uses: vec![],
};
let emissions = plugin.on_method_call(&ctx);
assert_eq!(emissions.len(), 3,
"dispatch call + handler + namespace; got: {:?}", emissions);
let has_dispatch = emissions.iter().any(|e| {
matches!(e, EmitAction::DispatchCall { name, dispatcher, .. }
if name == "connect" && dispatcher == "on")
});
assert!(has_dispatch, "missing DispatchCall for 'connect' via ->on");
let has_handler = emissions.iter().any(|e| {
matches!(e, EmitAction::Handler { name, .. } if name == "connect")
});
assert!(has_handler, "missing Handler symbol for 'connect'");
let has_namespace = emissions.iter().any(|e| {
matches!(e, EmitAction::PluginNamespace { id, kind, entity_names, .. }
if id == "mojo-events:My::Emitter"
&& kind == "events"
&& entity_names.iter().any(|n| n == "connect"))
});
assert!(has_namespace,
"missing PluginNamespace for My::Emitter events; got: {:?}", emissions);
}
#[test]
fn mojo_events_skips_dynamic_event_name() {
use crate::plugin::{ArgInfo, CallKind};
let engine = Arc::new(make_engine());
let bundled = load_bundled(engine);
let plugin = bundled
.into_iter()
.find(|p| p.id() == "mojo-events")
.unwrap();
let ctx = CallContext {
call_kind: CallKind::Method,
function_name: None,
method_name: Some("on".into()),
receiver_text: Some("$self".into()),
receiver_type: Some(InferredType::ClassName("Foo".into())),
args: vec![
ArgInfo {
text: "$name".into(),
string_value: None,
span: sp(0, 0, 0, 5),
content_span: None,
inferred_type: None, sub_params: vec![], callable_return_edge: None,
},
ArgInfo {
text: "sub {}".into(),
string_value: None,
span: sp(0, 6, 0, 12),
content_span: None,
inferred_type: Some(InferredType::CodeRef { return_edge: None }), sub_params: vec![], callable_return_edge: None,
},
],
call_span: sp(0, 0, 0, 15),
selection_span: sp(0, 0, 0, 2),
current_package: Some("Foo".into()),
current_package_parents: vec!["Mojo::EventEmitter".into()],
current_package_uses: vec![],
};
let emissions = plugin.on_method_call(&ctx);
assert!(emissions.is_empty(), "dynamic name must not emit");
}
#[test]
fn rhai_overrides_function_is_read_at_compile_time() {
let src = r#"
fn id() { "demo-overrides" }
fn triggers() { [] }
fn overrides() {
[
#{
target: #{ Method: #{ class: "Foo", name: "bar" } },
return_type: #{ ClassName: "Foo" },
reason: "test",
}
]
}
"#;
let engine = Arc::new(make_engine());
let plugin = RhaiPlugin::from_source(src, engine).expect("compiles");
let ovs = plugin.overrides();
assert_eq!(ovs.len(), 1);
match &ovs[0].target {
crate::plugin::OverrideTarget::Method { class, name } => {
assert_eq!(class, "Foo");
assert_eq!(name, "bar");
}
other => panic!("expected Method target, got {:?}", other),
}
assert_eq!(
ovs[0].return_type,
InferredType::ClassName("Foo".into())
);
assert_eq!(ovs[0].reason, "test");
}
#[test]
fn rhai_overrides_missing_function_yields_empty() {
let src = r#"
fn id() { "no-overrides" }
fn triggers() { [] }
"#;
let engine = Arc::new(make_engine());
let plugin = RhaiPlugin::from_source(src, engine).expect("compiles");
assert!(plugin.overrides().is_empty());
}
#[test]
fn non_matching_function_returns_empty() {
let src = r#"
fn id() { "demo2" }
fn triggers() { [ #{ Always: () } ] }
fn on_function_call(ctx) {
if ctx.function_name == "wanted" { return [#{ FrameworkImport: #{ keyword: "ok" } }]; }
[]
}
"#;
let engine = Arc::new(make_engine());
let plugin = RhaiPlugin::from_source(src, engine).unwrap();
let ctx = CallContext {
call_kind: super::super::CallKind::Function,
function_name: Some("unrelated".into()),
method_name: None,
receiver_text: None,
receiver_type: None,
args: vec![],
call_span: sp(0, 0, 0, 0),
selection_span: sp(0, 0, 0, 0),
current_package: None,
current_package_parents: vec![],
current_package_uses: vec![],
};
assert!(plugin.on_function_call(&ctx).is_empty());
}
}