diff --git a/Cargo.lock b/Cargo.lock index 835d10c..d54b314 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1071,6 +1071,7 @@ dependencies = [ "qsys_ir", "qsys_model", "qsys_parser", + "qsys_runtime", "qsys_sema", "serde", "serde_json", @@ -1115,6 +1116,14 @@ dependencies = [ "ruff_text_size", ] +[[package]] +name = "qsys_runtime" +version = "0.1.0" +dependencies = [ + "rustc-hash", + "serde", +] + [[package]] name = "qsys_sema" version = "0.1.0" @@ -1123,6 +1132,7 @@ dependencies = [ "qsys_ir", "qsys_model", "qsys_parser", + "qsys_runtime", "rustc-hash", "serde", "strsim", diff --git a/Cargo.toml b/Cargo.toml index ee65d85..c372155 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/qsys_ir", "crates/qsys_model", "crates/qsys_parser", + "crates/qsys_runtime", "crates/qsys_sema", "crates/qsys_wasm", "crates/qsyspyc", @@ -24,4 +25,5 @@ qsys_diagnostics = { path = "crates/qsys_diagnostics" } qsys_ir = { path = "crates/qsys_ir" } qsys_model = { path = "crates/qsys_model" } qsys_parser = { path = "crates/qsys_parser" } +qsys_runtime = { path = "crates/qsys_runtime" } qsys_sema = { path = "crates/qsys_sema" } diff --git a/README.md b/README.md index fee9438..54c9818 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ crates/ ├── qsys_model/ # Authoritative Q-SYS domain model (Controls, Properties, Pin styles, Layout) ├── qsys_diagnostics/ # Diagnostic catalog (E0001–E0205), span tracking, Monaco/LSP markers ├── qsys_parser/ # AST lowering from ruff_python_parser; strict syntax validation +├── qsys_runtime/ # Runtime plugins (PythonCore, LuaStdlib, QSysRuntime): globals, native types, removed builtins ├── qsys_sema/ # Scope management, symbol resolution, stable IDs, type inference ├── qsys_ir/ # Target-agnostic Intermediate Representation (Typed IR) ├── qsys_codegen_lua/ # Q-SYS Lua 5.3 code generator (PluginInfo, GetControls, runtime guard) diff --git a/crates/qsys_codegen_lua/src/plugin.rs b/crates/qsys_codegen_lua/src/plugin.rs index 623382f..06649aa 100644 --- a/crates/qsys_codegen_lua/src/plugin.rs +++ b/crates/qsys_codegen_lua/src/plugin.rs @@ -1,26 +1,9 @@ use qsys_ir::*; -use qsys_model::{ControlKind, PropertyKind}; +use qsys_model::{ControlKind, PropertyKind, is_design_time_method}; use crate::context::CodegenContext; use crate::emitter::{LuaEmitter, escape_lua_string, format_lua_float}; -// Helper to identify lifecycle methods -fn is_lifecycle_method(name: &str) -> bool { - matches!( - name, - "get_properties" - | "get_controls" - | "get_control_layout" - | "get_pages" - | "get_color" - | "get_pretty_name" - | "rectify_properties" - | "get_components" - | "get_pins" - | "get_wiring" - ) -} - pub fn emit_plugin(plugin: &IRPlugin, ctx: &mut CodegenContext) -> String { // Register controls and properties for ctl in &plugin.controls { @@ -114,7 +97,7 @@ pub fn emit_plugin(plugin: &IRPlugin, ctx: &mut CodegenContext) -> String { // Emit runtime methods (excluding design-time lifecycle methods) for method in &plugin.methods { - if is_lifecycle_method(&method.name) { + if is_design_time_method(&method.name) { continue; } emit_method(&mut emitter, &plugin.class_name, method); diff --git a/crates/qsys_codegen_lua/tests/codegen_tests.rs b/crates/qsys_codegen_lua/tests/codegen_tests.rs index 3033752..d48077c 100644 --- a/crates/qsys_codegen_lua/tests/codegen_tests.rs +++ b/crates/qsys_codegen_lua/tests/codegen_tests.rs @@ -272,3 +272,91 @@ class MatrixMixer(QSysPlugin): "Subclass super().show() must lower to Foo.show(self):\n{lua}" ); } + +#[test] +fn test_runtime_env_call_styles() { + let src = r#" +t = Timer.New() +t.Start(0.5) +sock = UdpSocket.New() +sock.Send("255.255.255.255", 9, "hi") +n = math.floor(2.5) +qsys.lua("print('raw')") +"#; + let lua = compile_to_lua(src); + + assert!( + lua.contains("Timer.New()"), + "Factories use dot calls:\n{lua}" + ); + assert!( + lua.contains("t:Start(0.5)"), + "Handle methods use colon calls:\n{lua}" + ); + assert!( + lua.contains("UdpSocket.New()"), + "Factories use dot calls:\n{lua}" + ); + assert!( + lua.contains("sock:Send(\"255.255.255.255\", 9, \"hi\")"), + "Handle methods use colon calls:\n{lua}" + ); + assert!( + lua.contains("math.floor(2.5)"), + "Library functions use dot calls:\n{lua}" + ); + assert!( + lua.contains("print('raw')"), + "Escape hatch emits raw Lua:\n{lua}" + ); +} + +#[test] +fn test_runtime_env_builtin_lowering() { + let src = r#" +items = [1, 2, 3] +n = len(items) +s = str(n) +m = abs(-3) +c = chr(65) +"#; + let lua = compile_to_lua(src); + + assert!( + lua.contains("#items"), + "len() lowers to the # operator:\n{lua}" + ); + assert!( + lua.contains("tostring(n)"), + "str() lowers to tostring():\n{lua}" + ); + assert!( + lua.contains("math.abs("), + "abs() lowers to math.abs():\n{lua}" + ); + assert!( + lua.contains("string.char(65)"), + "chr() lowers to string.char():\n{lua}" + ); +} + +#[test] +fn test_user_functions_shadow_builtin_lowering() { + let src = r#" +def len(x): + return 42 + +items = [1, 2, 3] +n = len(items) +"#; + let lua = compile_to_lua(src); + + assert!( + !lua.contains("#items"), + "A user-defined len() must not be lowered to the # operator:\n{lua}" + ); + assert!( + lua.contains("len(items)"), + "User len() is called as written:\n{lua}" + ); +} diff --git a/crates/qsys_compiler/Cargo.toml b/crates/qsys_compiler/Cargo.toml index 19a26db..a308299 100644 --- a/crates/qsys_compiler/Cargo.toml +++ b/crates/qsys_compiler/Cargo.toml @@ -9,6 +9,7 @@ qsys_diagnostics.workspace = true qsys_ir.workspace = true qsys_model.workspace = true qsys_parser.workspace = true +qsys_runtime.workspace = true qsys_sema.workspace = true qsys_codegen_lua = { path = "../qsys_codegen_lua" } diff --git a/crates/qsys_compiler/src/analysis.rs b/crates/qsys_compiler/src/analysis.rs index 42de0be..89348e1 100644 --- a/crates/qsys_compiler/src/analysis.rs +++ b/crates/qsys_compiler/src/analysis.rs @@ -5,11 +5,18 @@ use serde::{Deserialize, Serialize}; use qsys_diagnostics::{FileId, SourceFile}; use qsys_model::{ControlKind, ControlMember, PropertyKind}; use qsys_parser::{ParsedModule, ast, parse_module_lossy}; +use qsys_runtime::{ + Availability, CallStyle, Function, Global, GlobalKind, Lowering, Member, ObjectType, Returns, + RuntimeEnv, +}; use qsys_sema::{ DeclaredControl, DeclaredProperty, ResolvedMethod, SemanticModel, TargetKind, Type, analyze_lossy, }; +/// Characters stripped from the front of a dotted receiver expression (`(sock.` -> `sock`). +const RECEIVER_TRIM: [char; 10] = ['(', '[', '{', ',', '=', ':', '+', '-', '*', '/']; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LspHover { pub contents: Vec, @@ -277,8 +284,8 @@ impl Analyzer { } } - // 5. Built-in Q-SYS symbols & methods - if let Some((sig, doc)) = builtin_symbol_doc(word) { + // 5. QSysPy declaration DSL symbols + if let Some((sig, doc)) = dsl_symbol_doc(word) { return Some(LspHover { contents: vec![sig.to_string(), doc.to_string()], start_line, @@ -288,6 +295,19 @@ impl Analyzer { }); } + // 5b. Runtime environment globals and native object members + if let Some(contents) = runtime_hover( + word, word_start, &file.src, controls, properties, &parsed, &model, + ) { + return Some(LspHover { + contents, + start_line, + start_col, + end_line, + end_col, + }); + } + // 6. Search TypeMap for the narrowest inferred expression/variable type at cursor let mut best_span: Option<(qsys_diagnostics::Span, &Type)> = None; for (&span, ty) in model.type_map.types() { @@ -390,8 +410,7 @@ impl Analyzer { // B. Dotted member access: `.` if let Some((receiver, partial)) = prefix.rsplit_once('.') { - let receiver = - receiver.trim_start_matches(['(', '[', '{', ',', '=', ':', '+', '-', '*', '/']); + let receiver = receiver.trim_start_matches(RECEIVER_TRIM); // B1. `self.` -> Controls, Properties, Methods, Metadata if receiver == "self" { @@ -488,63 +507,28 @@ impl Analyzer { return items; } - // B4. `UdpSocket.`, `TcpSocket.`, `Timer.`, `qsys.` - if matches!(receiver, "UdpSocket" | "TcpSocket" | "Timer") { - if matches_prefix("New", partial) { - items.push(LspCompletion { - label: "New".to_string(), - kind: "Method".to_string(), - detail: format!("{receiver}.New() -> {receiver}Handle"), - documentation: format!( - "Allocates a new Q-SYS `{receiver}` runtime instance." - ), - insert_text: "New()".to_string(), - is_snippet: false, - }); - } - return items; - } - - if receiver == "qsys" { - if matches_prefix("lua", partial) { - items.push(LspCompletion { - label: "lua".to_string(), - kind: "Function".to_string(), - detail: "qsys.lua(code: str) -> None".to_string(), - documentation: - "Injects raw, uncompiled Lua 5.3 code directly into the output." - .to_string(), - insert_text: "lua(\"${1:print('hello from lua')}\")".to_string(), - is_snippet: true, - }); - } + // B4. Runtime globals bound to object types (`Timer.`, `UdpSocket.`, `math.`, `qsys.`) + if !is_local_symbol(&model, receiver) + && let Some(ty) = model.env.object_type_of_global(receiver) + { + items.extend( + object_type_completions(ty) + .into_iter() + .filter(|item| matches_prefix(&item.label, partial)), + ); return items; } // B5. Check receiver type (`self..`, `ctl.`, `sock.`, `timer.`) let receiver_ty = infer_receiver_type(receiver, controls, properties, &parsed, &model); match receiver_ty { - Some(Type::UdpSocketHandle) => { - for item in udp_socket_completions() { - if matches_prefix(&item.label, partial) { - items.push(item); - } - } - return items; - } - Some(Type::TcpSocketHandle) => { - for item in tcp_socket_completions() { - if matches_prefix(&item.label, partial) { - items.push(item); - } - } - return items; - } - Some(Type::TimerHandle) => { - for item in timer_completions() { - if matches_prefix(&item.label, partial) { - items.push(item); - } + Some(Type::Native { ref name }) => { + if let Some(ty) = model.env.object_type(name) { + items.extend( + object_type_completions(ty) + .into_iter() + .filter(|item| matches_prefix(&item.label, partial)), + ); } return items; } @@ -671,8 +655,7 @@ impl Analyzer { } // C. Top-level / statement / identifier completions - let ident_partial = - prefix.trim_start_matches(['(', '[', '{', ',', '=', ':', '+', '-', '*', '/']); + let ident_partial = prefix.trim_start_matches(RECEIVER_TRIM); // Include custom classes and module-level variables from the AST for stmt in &parsed.module.statements { @@ -720,7 +703,7 @@ impl Analyzer { } } - for item in top_level_completions(controls, properties, methods) { + for item in top_level_completions(controls, properties, methods, &model.env) { if matches_prefix(&item.label, ident_partial) { items.push(item); } @@ -770,8 +753,8 @@ impl Analyzer { let cursor_offset = file.offset(line, col); let (callee_name, active_parameter) = find_enclosing_call(&file.src, cursor_offset)?; - let (_, model) = parse_and_analyze_for_ide(source, line); - let (_, _, methods) = extract_target_members(&model); + let (parsed, model) = parse_and_analyze_for_ide(source, line); + let (controls, properties, methods) = extract_target_members(&model); if let Some(method_name) = callee_name.strip_prefix("self.") { for m in methods { @@ -799,7 +782,16 @@ impl Analyzer { } } - builtin_signature_help(&callee_name, active_parameter) + dsl_signature_help(&callee_name, active_parameter).or_else(|| { + runtime_signature_help( + &callee_name, + active_parameter, + controls, + properties, + &parsed, + &model, + ) + }) } } @@ -872,9 +864,7 @@ fn collect_stmt_inlay_hints( && let Some(ty) = model.type_map.get_type(target.span) && (matches!( ty, - Type::UdpSocketHandle - | Type::TcpSocketHandle - | Type::TimerHandle + Type::Native { .. } | Type::ControlHandle | Type::PropertyHandle | Type::UserInstance { .. } @@ -943,10 +933,14 @@ fn infer_receiver_type( } // Fallback: inspect AST assignments `receiver = UdpSocket.New()` or `receiver = Foo()` - find_assigned_type_in_module(&parsed.module.statements, receiver) + find_assigned_type_in_module(&parsed.module.statements, receiver, &model.env) } -fn find_assigned_type_in_module(stmts: &[ast::Stmt], var_name: &str) -> Option { +fn find_assigned_type_in_module( + stmts: &[ast::Stmt], + var_name: &str, + env: &RuntimeEnv, +) -> Option { for stmt in stmts { match &stmt.kind { ast::StmtKind::Assign { target, value } => { @@ -955,14 +949,20 @@ fn find_assigned_type_in_module(stmts: &[ast::Stmt], var_name: &str) -> Option return Some(Type::UdpSocketHandle), - "TcpSocket" => return Some(Type::TcpSocketHandle), - "Timer" => return Some(Type::TimerHandle), - _ => {} + // `X.factory()` on a runtime global, e.g. `UdpSocket.New()` + if let Some(Function { + returns: Returns::Type(ret), + .. + }) = env + .object_type_of_global(global_name) + .and_then(|ty| ty.get_method(attr)) + { + let ty = Type::from_ref(ret); + if ty != Type::Any { + return Some(ty); + } } } else if let ast::ExprKind::Ident(cls_name) = &callee.kind && cls_name.chars().next().is_some_and(char::is_uppercase) @@ -974,7 +974,7 @@ fn find_assigned_type_in_module(stmts: &[ast::Stmt], var_name: &str) -> Option { - if let Some(ty) = find_assigned_type_in_module(&cls.body, var_name) { + if let Some(ty) = find_assigned_type_in_module(&cls.body, var_name, env) { return Some(ty); } } @@ -987,7 +987,7 @@ fn find_assigned_type_in_module(stmts: &[ast::Stmt], var_name: &str) -> Option Option { match ty { Type::ControlHandle => Some("ControlHandle".to_string()), Type::PropertyHandle => Some("PropertyHandle".to_string()), - Type::UdpSocketHandle => Some("UdpSocketHandle".to_string()), - Type::TcpSocketHandle => Some("TcpSocketHandle".to_string()), - Type::TimerHandle => Some("TimerHandle".to_string()), - Type::UserInstance { name } => Some(name.clone()), + Type::Native { name } | Type::UserInstance { name } => Some(name.clone()), Type::Float => Some("float".to_string()), Type::Int => Some("int".to_string()), Type::String => Some("str".to_string()), @@ -1158,142 +1155,291 @@ fn property_factory_completions() -> Vec { ] } -fn udp_socket_completions() -> Vec { - vec![ - LspCompletion { - label: "Open".to_string(), - kind: "Method".to_string(), - detail: "sock.Open(ip?: str, port?: int) -> None".to_string(), - documentation: "Opens the UDP socket (compiled to `sock:Open(...)`).".to_string(), - insert_text: "Open(${0})".to_string(), - is_snippet: true, - }, - LspCompletion { - label: "Send".to_string(), - kind: "Method".to_string(), - detail: "sock.Send(ip: str, port: int, data: str) -> None".to_string(), - documentation: - "Sends a UDP datagram to `(ip, port)` (compiled to `sock:Send(ip, port, data)`)." - .to_string(), - insert_text: "Send(${1:self.IPAddress.String}, ${2:9}, ${3:packet})".to_string(), - is_snippet: true, - }, - LspCompletion { - label: "Close".to_string(), - kind: "Method".to_string(), - detail: "sock.Close() -> None".to_string(), - documentation: "Closes the UDP socket (compiled to `sock:Close()`).".to_string(), - insert_text: "Close()".to_string(), - is_snippet: false, - }, - LspCompletion { - label: "JoinMulticast".to_string(), - kind: "Method".to_string(), - detail: "sock.JoinMulticast(address: str, local_ip?: str) -> None".to_string(), - documentation: "Joins a multicast group address.".to_string(), - insert_text: "JoinMulticast(${1:\"224.0.0.1\"})".to_string(), - is_snippet: true, - }, - LspCompletion { - label: "Data".to_string(), - kind: "Event".to_string(), - detail: "sock.Data: Callable[[UdpSocketHandle, UdpPacket], None]".to_string(), - documentation: "Callback assigned to receive incoming UDP packets.".to_string(), - insert_text: "Data".to_string(), - is_snippet: false, - }, - ] +/// Completion items for every method and field of a runtime object type. +fn object_type_completions(ty: &ObjectType) -> Vec { + let methods = ty + .methods + .iter() + .map(|m| member_completion(&ty.name, Member::Method(m))); + let fields = ty + .fields + .iter() + .map(|f| member_completion(&ty.name, Member::Field(f))); + methods.chain(fields).collect() } -fn tcp_socket_completions() -> Vec { - vec![ - LspCompletion { - label: "Connect".to_string(), - kind: "Method".to_string(), - detail: "sock.Connect(ip: str, port: int) -> None".to_string(), - documentation: "Initiates a TCP connection (compiled to `sock:Connect(ip, port)`)." - .to_string(), - insert_text: "Connect(${1:ip}, ${2:port})".to_string(), - is_snippet: true, - }, - LspCompletion { - label: "Write".to_string(), - kind: "Method".to_string(), - detail: "sock.Write(data: str) -> None".to_string(), - documentation: "Writes data to the TCP socket (compiled to `sock:Write(data)`)." - .to_string(), - insert_text: "Write(${1:data})".to_string(), - is_snippet: true, - }, - LspCompletion { - label: "Read".to_string(), - kind: "Method".to_string(), - detail: "sock.Read(length: int) -> str".to_string(), - documentation: "Reads up to `length` bytes from the TCP receive buffer.".to_string(), - insert_text: "Read(${1:1024})".to_string(), - is_snippet: true, - }, - LspCompletion { - label: "ReadLine".to_string(), +fn member_completion(type_name: &str, member: Member<'_>) -> LspCompletion { + match member { + Member::Method(method) => LspCompletion { + label: method.name.clone(), kind: "Method".to_string(), - detail: "sock.ReadLine(eol: int) -> str | None".to_string(), - documentation: "Reads a single line from the TCP receive buffer.".to_string(), - insert_text: "ReadLine(${1:TcpSocket.EOL.Any})".to_string(), - is_snippet: true, + detail: format!( + "{type_name}.{}({}) -> {}", + method.name, + method.params_label(), + method.return_annotation() + ), + documentation: method_documentation(method), + insert_text: call_insert_text(method), + is_snippet: method.snippet.is_some() || !method.params.is_empty() || method.variadic, }, - LspCompletion { - label: "Disconnect".to_string(), - kind: "Method".to_string(), - detail: "sock.Disconnect() -> None".to_string(), - documentation: "Closes the active TCP connection.".to_string(), - insert_text: "Disconnect()".to_string(), - is_snippet: false, - }, - LspCompletion { - label: "IsConnected".to_string(), - kind: "Property".to_string(), - detail: "(property) sock.IsConnected: bool".to_string(), - documentation: "True when the TCP socket is currently connected.".to_string(), - insert_text: "IsConnected".to_string(), + Member::Field(field) => LspCompletion { + label: field.name.clone(), + kind: if field.is_event() { + "Event" + } else { + "Property" + } + .to_string(), + detail: format!( + "({}) {type_name}.{}: {}", + if field.is_event() { + "event" + } else { + "property" + }, + field.name, + field.ty.python_annotation() + ), + documentation: field.doc.clone(), + insert_text: field.name.clone(), is_snippet: false, }, - ] + } } -fn timer_completions() -> Vec { - vec![ - LspCompletion { - label: "Start".to_string(), - kind: "Method".to_string(), - detail: "timer.Start(period_seconds: float) -> None".to_string(), - documentation: "Starts the Q-SYS timer (compiled to `timer:Start(period)`)." - .to_string(), - insert_text: "Start(${1:1.0})".to_string(), - is_snippet: true, - }, - LspCompletion { - label: "Stop".to_string(), - kind: "Method".to_string(), - detail: "timer.Stop() -> None".to_string(), - documentation: "Stops the Q-SYS timer (compiled to `timer:Stop()`).".to_string(), - insert_text: "Stop()".to_string(), - is_snippet: false, - }, - LspCompletion { - label: "EventHandler".to_string(), - kind: "Event".to_string(), - detail: "timer.EventHandler: Callable[[TimerHandle], None]".to_string(), - documentation: "Callback invoked every time the timer period elapses.".to_string(), - insert_text: "EventHandler".to_string(), - is_snippet: false, - }, - ] +fn global_completion(global: &Global, env: &RuntimeEnv) -> LspCompletion { + let (kind, detail, insert_text, is_snippet) = match &global.kind { + GlobalKind::Function(function) => ( + "Function", + format!( + "{}({}) -> {}", + function.name, + function.params_label(), + function.return_annotation() + ), + call_insert_text(function), + function.snippet.is_some() || !function.params.is_empty() || function.variadic, + ), + GlobalKind::Value(ty) => { + let is_class = global.object_type_name() == Some(global.name.as_str()) + && env.object_type(&global.name).is_some(); + if is_class { + ( + "Class", + format!("class {}", global.name), + global.name.clone(), + false, + ) + } else { + ( + "Variable", + format!("{}: {}", global.name, ty.python_annotation()), + global.name.clone(), + false, + ) + } + } + }; + + LspCompletion { + label: global.name.clone(), + kind: kind.to_string(), + detail, + documentation: global_documentation(global), + insert_text, + is_snippet, + } +} + +fn call_insert_text(function: &Function) -> String { + if let Some(snippet) = &function.snippet { + snippet.clone() + } else if function.params.is_empty() && !function.variadic { + format!("{}()", function.name) + } else { + format!("{}(${{0}})", function.name) + } +} + +fn method_documentation(method: &Function) -> String { + match method.call_style { + CallStyle::Colon => format!("{} (compiled to `obj:{}(...)`)", method.doc, method.name), + CallStyle::Dot => method.doc.clone(), + } +} + +fn global_documentation(global: &Global) -> String { + let mut doc = global.doc.clone(); + if let Some(function) = global.as_function() { + let lowered = match &function.lowering { + Lowering::Direct => None, + Lowering::Rename(target) => Some(format!("`{target}(...)`")), + Lowering::Qualified { module, name } => Some(format!("`{module}.{name}(...)`")), + Lowering::LengthOperator => Some("the `#` length operator".to_string()), + }; + if let Some(lowered) = lowered { + doc.push_str(&format!("\n\nCompiles to {lowered} in Lua.")); + } + } + if global.availability != Availability::Always { + doc.push_str(&format!( + "\n\n_Only available {}._", + global.availability.describe() + )); + } + doc +} + +/// Whether `name` is bound to a user-defined local/parameter (which shadows runtime globals). +fn is_local_symbol(model: &SemanticModel, name: &str) -> bool { + model.type_map.symbol_names().values().any(|n| n == name) +} + +/// Resolves the runtime object type of a dotted receiver (`Timer`, `sock`, `self.timer`). +fn resolve_receiver_object_type( + receiver: &str, + controls: &[DeclaredControl], + properties: &[DeclaredProperty], + parsed: &ParsedModule, + model: &SemanticModel, +) -> Option { + if !is_local_symbol(model, receiver) + && let Some(ty) = model.env.object_type_of_global(receiver) + { + return Some(ty.name.clone()); + } + match infer_receiver_type(receiver, controls, properties, parsed, model)? { + Type::Native { name } => Some(name), + _ => None, + } +} + +/// Hover contents for runtime globals (`Timer`, `print`) and native members (`sock.Send`). +fn runtime_hover( + word: &str, + word_start: usize, + src: &str, + controls: &[DeclaredControl], + properties: &[DeclaredProperty], + parsed: &ParsedModule, + model: &SemanticModel, +) -> Option> { + let env = &model.env; + let before = &src[..word_start.min(src.len())]; + + if let Some(receiver_text) = before.strip_suffix('.') { + let receiver = get_prefix_before_cursor(receiver_text, receiver_text.len()) + .trim_start_matches(RECEIVER_TRIM); + let resolved = resolve_receiver_object_type(receiver, controls, properties, parsed, model) + .and_then(|type_name| { + env.member(&type_name, word) + .map(|member| member_hover(&type_name, member)) + }); + // Fall back to the first runtime type declaring this member when the receiver's type is unknown. + return resolved.or_else(|| { + env.object_types() + .iter() + .find_map(|ty| ty.member(word).map(|member| member_hover(&ty.name, member))) + }); + } + + if is_local_symbol(model, word) { + return None; + } + let global = env.global(word)?; + let signature = match &global.kind { + GlobalKind::Function(function) => format!( + "*(function)* {}({}) -> {}", + function.name, + function.params_label(), + function.return_annotation() + ), + GlobalKind::Value(ty) => { + if global.object_type_name() == Some(global.name.as_str()) { + format!("*(class)* {}", global.name) + } else { + format!("*(variable)* {}: {}", global.name, ty.python_annotation()) + } + } + }; + Some(vec![signature, global_documentation(global)]) +} + +fn member_hover(type_name: &str, member: Member<'_>) -> Vec { + match member { + Member::Method(method) => vec![ + format!( + "*(method)* {type_name}.{}({}) -> {}", + method.name, + method.params_label(), + method.return_annotation() + ), + method_documentation(method), + ], + Member::Field(field) => vec![ + format!( + "*({})* {type_name}.{}: {}", + if field.is_event() { + "event" + } else { + "property" + }, + field.name, + field.ty.python_annotation() + ), + field.doc.clone(), + ], + } +} + +/// Signature help for runtime functions (`print(`) and native methods (`sock.Send(`). +fn runtime_signature_help( + callee: &str, + active_parameter: usize, + controls: &[DeclaredControl], + properties: &[DeclaredProperty], + parsed: &ParsedModule, + model: &SemanticModel, +) -> Option { + let env = &model.env; + let function = if let Some((receiver, member)) = callee.rsplit_once('.') { + resolve_receiver_object_type(receiver, controls, properties, parsed, model) + .and_then(|type_name| env.object_type(&type_name)) + .and_then(|ty| ty.get_method(member)) + .or_else(|| { + env.object_types() + .iter() + .find_map(|ty| ty.get_method(member)) + }) + } else if is_local_symbol(model, callee) { + None + } else { + env.global(callee).and_then(Global::as_function) + }?; + + let parameters: Vec = function + .params + .iter() + .map(|p| LspParameterInfo { + label: p.label(), + documentation: p.doc.clone(), + }) + .collect(); + + Some(LspSignatureHelp { + label: format!("{callee}({})", function.params_label()), + documentation: function.doc.clone(), + active_parameter: active_parameter.min(parameters.len().saturating_sub(1)), + parameters, + }) } fn top_level_completions( controls: &[DeclaredControl], properties: &[DeclaredProperty], methods: &[ResolvedMethod], + env: &RuntimeEnv, ) -> Vec { let mut items = vec![ LspCompletion { @@ -1328,30 +1474,6 @@ fn top_level_completions( insert_text: "Property".to_string(), is_snippet: false, }, - LspCompletion { - label: "UdpSocket".to_string(), - kind: "Class".to_string(), - detail: "Q-SYS UdpSocket runtime API".to_string(), - documentation: "Create UDP sockets via `UdpSocket.New()`.".to_string(), - insert_text: "UdpSocket".to_string(), - is_snippet: false, - }, - LspCompletion { - label: "TcpSocket".to_string(), - kind: "Class".to_string(), - detail: "Q-SYS TcpSocket runtime API".to_string(), - documentation: "Create TCP client sockets via `TcpSocket.New()`.".to_string(), - insert_text: "TcpSocket".to_string(), - is_snippet: false, - }, - LspCompletion { - label: "Timer".to_string(), - kind: "Class".to_string(), - detail: "Q-SYS Timer runtime API".to_string(), - documentation: "Create periodic or one-shot timers via `Timer.New()`.".to_string(), - insert_text: "Timer".to_string(), - is_snippet: false, - }, LspCompletion { label: "self".to_string(), kind: "Variable".to_string(), @@ -1411,6 +1533,10 @@ fn top_level_completions( }); } + for global in env.globals() { + items.push(global_completion(global, env)); + } + items } @@ -1446,7 +1572,9 @@ fn control_member_doc(name: &str) -> (&'static str, &'static str) { } } -fn builtin_symbol_doc(word: &str) -> Option<(&'static str, &'static str)> { +/// Hover documentation for names owned by the QSysPy declaration DSL. +/// Runtime globals and native members are documented by the `RuntimeEnv` instead. +fn dsl_symbol_doc(word: &str) -> Option<(&'static str, &'static str)> { match word { "QSysPlugin" => Some(( "*(class)* QSysPlugin", @@ -1468,51 +1596,11 @@ fn builtin_symbol_doc(word: &str) -> Option<(&'static str, &'static str)> { "*(class)* ControlHandle", "Runtime handle to a Q-SYS `Controls[...]` object providing `.Value`, `.String`, `.Boolean`, `.Legend`, `.Color`, `.IsDisabled`, `.IsInvisible`, and `.RampTime`.", )), - "UdpSocket" => Some(( - "*(class)* UdpSocket", - "Q-SYS native UDP networking class. Instantiate via `UdpSocket.New()`.", - )), - "TcpSocket" => Some(( - "*(class)* TcpSocket", - "Q-SYS native TCP client networking class. Instantiate via `TcpSocket.New()`.", - )), - "Timer" => Some(( - "*(class)* Timer", - "Q-SYS native event loop timer. Instantiate via `Timer.New()`.", - )), - "Open" => Some(( - "*(method)* UdpSocketHandle.Open(ip?: str, port?: int) -> None", - "Binds and opens the UDP socket (`sock:Open(...)` in Lua).", - )), - "Send" => Some(( - "*(method)* UdpSocketHandle.Send(ip: str, port: int, data: str) -> None", - "Sends a UDP packet to the target IP and port (`sock:Send(ip, port, data)` in Lua).", - )), - "Close" => Some(( - "*(method)* UdpSocketHandle.Close() -> None", - "Closes the UDP socket (`sock:Close()` in Lua).", - )), - "Connect" => Some(( - "*(method)* TcpSocketHandle.Connect(ip: str, port: int) -> None", - "Connects the TCP socket to the target host and port (`sock:Connect(ip, port)` in Lua).", - )), - "Write" => Some(( - "*(method)* TcpSocketHandle.Write(data: str) -> None", - "Writes string or binary payload over the TCP socket (`sock:Write(data)` in Lua).", - )), - "Start" => Some(( - "*(method)* TimerHandle.Start(seconds: float) -> None", - "Starts or restarts the Q-SYS timer with the given interval in seconds (`timer:Start(seconds)` in Lua).", - )), - "Stop" => Some(( - "*(method)* TimerHandle.Stop() -> None", - "Stops the Q-SYS timer (`timer:Stop()` in Lua).", - )), _ => None, } } -fn builtin_signature_help(callee: &str, active_parameter: usize) -> Option { +fn dsl_signature_help(callee: &str, active_parameter: usize) -> Option { let (label, doc, params): (&str, &str, &[(&str, &str)]) = match callee { "Control.Float" | "Control.Knob" | "Control.Fader" => ( "Control.Float(name, min=-100.0, max=20.0, default=0.0, unit=\"Float\", pins=\"Both\", count=1)", @@ -1630,46 +1718,6 @@ fn builtin_signature_help(callee: &str, active_parameter: usize) -> Option ( - "qsys.lua(code: str)", - "Injects raw Lua 5.3 source directly into the compiled output.", - &[("code: str", "Raw Lua 5.3 string literal.")], - ), - s if s.ends_with(".Send") => ( - "sock.Send(ip: str, port: int, data: str)", - "Transmits a UDP datagram over the Q-SYS UdpSocket (`sock:Send(ip, port, data)`).", - &[ - ("ip: str", "Destination IPv4/IPv6 or broadcast address."), - ("port: int", "Destination UDP port number (1-65535)."), - ("data: str", "Binary or text payload to send."), - ], - ), - s if s.ends_with(".Open") => ( - "sock.Open(ip?: str, port?: int)", - "Binds and opens the UDP socket (`sock:Open(ip, port)`).", - &[ - ("ip?: str", "Optional local interface IP to bind."), - ("port?: int", "Optional local UDP port to bind."), - ], - ), - s if s.ends_with(".Connect") => ( - "sock.Connect(ip: str, port: int)", - "Opens a TCP connection to `(ip, port)` (`sock:Connect(ip, port)`).", - &[ - ("ip: str", "Remote host IP or hostname."), - ("port: int", "Remote TCP port."), - ], - ), - s if s.ends_with(".Write") => ( - "sock.Write(data: str)", - "Writes data to the connected TCP socket (`sock:Write(data)`).", - &[("data: str", "String or binary payload to transmit.")], - ), - s if s.ends_with(".Start") => ( - "timer.Start(seconds: float)", - "Starts the Q-SYS timer with the specified interval in seconds (`timer:Start(seconds)`).", - &[("seconds: float", "Timer interval in seconds.")], - ), _ => return None, }; diff --git a/crates/qsys_compiler/src/driver.rs b/crates/qsys_compiler/src/driver.rs index 99e319c..d7e4cad 100644 --- a/crates/qsys_compiler/src/driver.rs +++ b/crates/qsys_compiler/src/driver.rs @@ -4,7 +4,8 @@ use qsys_codegen_lua::LuaBackend; use qsys_diagnostics::{DiagnosticKind, Diagnostics, FileId, SourceFile, Span}; use qsys_ir::{Backend, CodegenConfig}; use qsys_parser::parse_module; -use qsys_sema::{TargetKind, analyze}; +use qsys_runtime::RuntimeEnv; +use qsys_sema::{TargetKind, analyze_with_env}; use crate::options::{CheckResult, CompiledArtifact, CompilerOptions, ProgramKind}; @@ -31,7 +32,7 @@ impl Compiler { let parsed = parse_module(file.clone())?; // Semantic Analysis (AST -> Semantic Model with stable IDs) - let model = analyze(&parsed)?; + let model = analyze_with_env(&parsed, options.env.clone())?; // Extract metadata for reports & CLI summaries let (program_kind, plugin_info, control_count, property_count) = match &model.target { @@ -93,10 +94,18 @@ impl Compiler { Self::check_file(file) } - /// Checks an in-memory `SourceFile` without performing code generation. + /// Checks an in-memory `SourceFile` against the default Q-SYS runtime environment. pub fn check_file(file: Arc) -> Result { + Self::check_file_with_env(file, RuntimeEnv::qsys()) + } + + /// Checks an in-memory `SourceFile` against a custom runtime environment. + pub fn check_file_with_env( + file: Arc, + env: Arc, + ) -> Result { let parsed = parse_module(file.clone())?; - let model = analyze(&parsed)?; + let model = analyze_with_env(&parsed, env)?; let (program_kind, plugin_info, control_count, property_count) = match &model.target { TargetKind::Plugin(p) => ( diff --git a/crates/qsys_compiler/src/options.rs b/crates/qsys_compiler/src/options.rs index fee6b46..482e52d 100644 --- a/crates/qsys_compiler/src/options.rs +++ b/crates/qsys_compiler/src/options.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use qsys_diagnostics::{Diagnostics, EditorMarker, SourceFile}; use qsys_model::PluginInfo; +use qsys_runtime::RuntimeEnv; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProgramKind { @@ -26,6 +27,8 @@ pub struct CompilerOptions { pub emit_source_maps: bool, pub minify: bool, pub filename: String, + /// Runtime environment (globals, native types, removed builtins) to compile against. + pub env: Arc, } impl Default for CompilerOptions { @@ -35,6 +38,7 @@ impl Default for CompilerOptions { emit_source_maps: false, minify: false, filename: "main.py".to_string(), + env: RuntimeEnv::qsys(), } } } diff --git a/crates/qsys_compiler/src/stubs.rs b/crates/qsys_compiler/src/stubs.rs index d5ce4ce..5e27f60 100644 --- a/crates/qsys_compiler/src/stubs.rs +++ b/crates/qsys_compiler/src/stubs.rs @@ -1,9 +1,11 @@ use std::fs; use std::path::Path; -/// The authoritative PEP 484/561 Python type stub definition for QSysPy. -pub const QSYS_PYI: &str = r#""" -# Python type stubs for QSysPy (Q-SYS Core Compiler) +use qsys_runtime::{QSysRuntime, RuntimeEnv, pyi}; + +/// Hand-written stubs for the QSysPy declaration DSL (`QSysPlugin`, `Control.*`, `Property.*`). +/// Runtime globals and native object types are generated from the `RuntimeEnv` (see [`generate_pyi`]). +const DSL_PYI: &str = r#"# Python type stubs for QSysPy (Q-SYS Core Compiler) # Compatible with Pyright, Pylance, mypy, and PyCharm from typing import Any, Callable, Literal, TypeVar, overload @@ -229,123 +231,48 @@ class TextController: """Access bound pin controls on self.""" ... -class UdpPacket: - """Incoming UDP packet received by a UdpSocket.""" - Address: str - Port: int - Data: str - -class UdpSocketHandle: - """Active UDP socket handle.""" - Data: Callable[[UdpSocketHandle, UdpPacket], None] - Closed: Callable[[UdpSocketHandle], None] - - def Open(self, port: int = ..., ip: str = ...) -> None: - """Binds the socket to a port and optional local IP.""" - ... - def Close(self) -> None: - """Closes the socket.""" - ... - def Send(self, ip: str, port: int, data: str | bytes) -> None: - """Transmits a datagram to the specified destination.""" - ... - def JoinMulticast(self, address: str) -> None: - """Joins an IP multicast group.""" - ... - def LeaveMulticast(self, address: str) -> None: - """Leaves an IP multicast group.""" - ... - -class UdpSocket: - """Factory for Q-SYS UDP sockets.""" - @staticmethod - def New() -> UdpSocketHandle: - """Creates a new UDP socket.""" - ... - -class TcpSocketHandle: - """Active TCP socket handle.""" - IsConnected: bool - Connected: Callable[[TcpSocketHandle], None] - Data: Callable[[TcpSocketHandle], None] - Closed: Callable[[TcpSocketHandle], None] - Error: Callable[[TcpSocketHandle, str], None] - Timeout: Callable[[TcpSocketHandle], None] - - def Connect(self, ip: str, port: int) -> None: - """Opens a TCP connection to the host and port.""" - ... - def Disconnect(self) -> None: - """Disconnects an active TCP connection.""" - ... - def Write(self, data: str | bytes) -> None: - """Transmits raw data across the TCP socket.""" - ... - def Read(self, length: int = ...) -> str: - """Reads a specified number of bytes from the buffer.""" - ... - def ReadLine(self, eol: str = ...) -> str: - """Reads from the buffer until the EOL delimiter.""" - ... - -class TcpSocket: - """Factory for Q-SYS TCP sockets.""" - @staticmethod - def New() -> TcpSocketHandle: - """Creates a new TCP socket.""" - ... - -class TimerHandle: - """Active Q-SYS event timer.""" - EventHandler: Callable[[TimerHandle], None] - - def Start(self, seconds: float) -> None: - """Starts or restarts the timer with the specified interval in seconds.""" - ... - def Stop(self) -> None: - """Stops the timer.""" - ... - def Reset(self) -> None: - """Resets the timer counter.""" - ... - -class Timer: - """Factory for Q-SYS Timers.""" - @staticmethod - def New() -> TimerHandle: - """Creates a new timer instance.""" - ... - -class _QSysNamespace: - """Built-in compiler escape hatches and runtime utilities.""" - - @staticmethod - def lua(code: str) -> None: - """ - Escape hatch: Injects raw, uncompiled Lua 5.3 code directly into the compiled output. - - Example: - qsys.lua("UdpSocket.New():Send('255.255.255.255', 9, packet)") - """ - ... - -qsys: _QSysNamespace -Controls: dict[str, ControlHandle] -Properties: dict[str, PropertyHandle] "#; +/// Builds the complete `qsys.pyi` source: the DSL stubs followed by every type and global +/// registered by the [`QSysRuntime`] plugin in `env`. +pub fn generate_pyi(env: &RuntimeEnv) -> String { + let runtime = pyi::render_pyi(env, |origin| origin == QSysRuntime::NAME); + format!( + "{DSL_PYI}# --- Q-SYS runtime (generated from the `{}` runtime plugin) ---\n\n{runtime}", + QSysRuntime::NAME + ) +} + /// Generates standard PEP 561 stub files (`qsys/__init__.pyi`, `qsys/py.typed`, `qsys.pyi`). pub fn write_stubs_to_dir(out_dir: &Path) -> std::io::Result<()> { + let pyi_source = generate_pyi(&RuntimeEnv::qsys()); + // 1. Write standalone qsys.pyi (for single-file imports) fs::create_dir_all(out_dir)?; let single_pyi = out_dir.join("qsys.pyi"); - fs::write(single_pyi, QSYS_PYI)?; + fs::write(single_pyi, &pyi_source)?; // 2. Write package layout: qsys/__init__.pyi and qsys/py.typed (PEP 561) let pkg_dir = out_dir.join("qsys"); fs::create_dir_all(&pkg_dir)?; - fs::write(pkg_dir.join("__init__.pyi"), QSYS_PYI)?; + fs::write(pkg_dir.join("__init__.pyi"), &pyi_source)?; fs::write(pkg_dir.join("py.typed"), "")?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_stubs_cover_dsl_and_runtime() { + let pyi = generate_pyi(&RuntimeEnv::qsys()); + assert!(pyi.starts_with("# Python type stubs for QSysPy")); + assert!(pyi.contains("class QSysPlugin:")); + assert!(pyi.contains("class UdpSocketHandle:")); + assert!(pyi.contains(" def Send(self, ip: str, port: int, data: str) -> None:")); + assert!(pyi.contains("class UdpSocket:\n")); + assert!(pyi.contains("Properties: dict[str, PropertyHandle]")); + } +} diff --git a/crates/qsys_compiler/tests/driver_tests.rs b/crates/qsys_compiler/tests/driver_tests.rs index 9be7e9b..54fac03 100644 --- a/crates/qsys_compiler/tests/driver_tests.rs +++ b/crates/qsys_compiler/tests/driver_tests.rs @@ -191,3 +191,61 @@ class Wol(QSysPlugin): .any(|h| h.line == 7 && h.col == 13 && h.label == ": UdpSocketHandle") ); } + +#[test] +fn test_runtime_env_powers_ide_features() { + // Completions on a runtime global come from its object type in the RuntimeEnv + let timer_src = "t = Timer.\n"; + let timer_items = Analyzer::completions(timer_src, 1, 11); + assert!(timer_items.iter().any(|c| c.label == "New")); + assert!(timer_items.iter().any(|c| c.label == "CallAfter")); + + // Members of a handle returned by a factory method + let handle_src = "t = Timer.New()\nt.\n"; + let handle_items = Analyzer::completions(handle_src, 2, 3); + assert!( + handle_items + .iter() + .any(|c| c.label == "Start" && c.kind == "Method") + ); + assert!( + handle_items + .iter() + .any(|c| c.label == "EventHandler" && c.kind == "Event") + ); + + // Library tables registered by LuaStdlib + let math_items = Analyzer::completions("x = math.fl\n", 1, 12); + assert!(math_items.iter().any(|c| c.label == "floor")); + assert!(!math_items.iter().any(|c| c.label == "ceil")); + + // Top-level completions include runtime globals + let top_items = Analyzer::completions("Tcp\n", 1, 4); + assert!( + top_items + .iter() + .any(|c| c.label == "TcpSocket" && c.kind == "Class") + ); + + // Hover on a runtime global and on a runtime-only global + let hover = Analyzer::hover("t = Timer.New()\n", 1, 6).expect("Hover for Timer"); + assert_eq!(hover.contents[0], "*(class)* Timer"); + let hover = Analyzer::hover( + "class P(QSysPlugin):\n def runtime(self):\n Controls[\"x\"].Value = 1\n", + 3, + 10, + ) + .expect("Hover for Controls"); + assert!(hover.contents[0].contains("Controls: dict[str, ControlHandle]")); + assert!(hover.contents[1].contains("Only available at runtime")); + + // Hover on a native member resolves through the receiver type + let hover = Analyzer::hover("t = Timer.New()\nt.Start(1.0)\n", 2, 4).expect("Hover for Start"); + assert!(hover.contents[0].contains("TimerHandle.Start(seconds: float) -> None")); + + // Signature help for a library function and for a Python builtin + let sig = Analyzer::signature_help("x = math.floor(", 1, 16).expect("Signature for math.floor"); + assert_eq!(sig.label, "math.floor(x: float)"); + let sig = Analyzer::signature_help("x = len(", 1, 9).expect("Signature for len"); + assert_eq!(sig.label, "len(obj: Any)"); +} diff --git a/crates/qsys_diagnostics/src/kind.rs b/crates/qsys_diagnostics/src/kind.rs index 5adce82..a218a00 100644 --- a/crates/qsys_diagnostics/src/kind.rs +++ b/crates/qsys_diagnostics/src/kind.rs @@ -67,6 +67,15 @@ pub enum DiagnosticKind { member: String, candidates: Vec, }, + UnavailableInContext { + name: String, + available: String, + context: String, + }, + RemovedBuiltin { + name: String, + reason: String, + }, } impl DiagnosticKind { @@ -112,6 +121,8 @@ impl DiagnosticKind { Self::InvalidEscapeHatchCall { .. } => "E0205", Self::UndefinedVariable { .. } => "E0206", Self::UnknownClassMember { .. } => "E0207", + Self::UnavailableInContext { .. } => "E0208", + Self::RemovedBuiltin { .. } => "E0209", } } @@ -194,6 +205,16 @@ impl DiagnosticKind { }; format!("Class '{class_name}' has no control or property '{member}'{hint}") } + Self::UnavailableInContext { + name, + available, + context, + } => { + format!("'{name}' is only available {available}, but is used {context}") + } + Self::RemovedBuiltin { name, reason } => { + format!("'{name}' is not available on this target: {reason}") + } } } @@ -225,6 +246,9 @@ impl DiagnosticKind { Self::UnknownClassMember { .. } => Some( "Controls and properties must be declared at the class level: 'name = Control.()'.".to_string(), ), + Self::UnavailableInContext { available, .. } => Some(format!( + "Move this usage into code that runs {available}, such as a runtime method or event handler." + )), _ => None, } } diff --git a/crates/qsys_model/src/lib.rs b/crates/qsys_model/src/lib.rs index f91cd7f..755dd4d 100644 --- a/crates/qsys_model/src/lib.rs +++ b/crates/qsys_model/src/lib.rs @@ -13,6 +13,25 @@ pub use layout::{ pub use plugin_info::PluginInfo; pub use properties::{PropertyDef, PropertyKind}; +/// Plugin methods that compile to Q-SYS design-time lifecycle functions +/// (`GetControls`, `GetControlLayout`, ...) instead of runtime code. +pub const DESIGN_TIME_METHODS: &[&str] = &[ + "get_properties", + "get_controls", + "get_control_layout", + "get_pages", + "get_color", + "get_pretty_name", + "rectify_properties", + "get_components", + "get_pins", + "get_wiring", +]; + +pub fn is_design_time_method(name: &str) -> bool { + DESIGN_TIME_METHODS.contains(&name) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/qsys_runtime/Cargo.toml b/crates/qsys_runtime/Cargo.toml new file mode 100644 index 0000000..f4b854e --- /dev/null +++ b/crates/qsys_runtime/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "qsys_runtime" +version = "0.1.0" +edition = "2024" +description = "Declarative runtime environment (globals, native object types, removed builtins) and the plugins that build it for QSysPy" + +[dependencies] +rustc-hash = { workspace = true } +serde = { workspace = true, features = ["derive"] } diff --git a/crates/qsys_runtime/src/defs.rs b/crates/qsys_runtime/src/defs.rs new file mode 100644 index 0000000..6cc5a03 --- /dev/null +++ b/crates/qsys_runtime/src/defs.rs @@ -0,0 +1,494 @@ +//! Declarative building blocks used by [`RuntimePlugin`](crate::RuntimePlugin)s to describe +//! the globals, native object types and builtin lowerings available on a target runtime. + +use serde::{Deserialize, Serialize}; + +/// A target-agnostic reference to a type, as declared by a runtime plugin. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum TypeRef { + Any, + None, + Bool, + Int, + Float, + Str, + List(Box), + Dict(Box, Box), + /// A native object type registered with [`EnvBuilder::add_type`](crate::EnvBuilder::add_type), + /// or one of the DSL-owned handle types (`ControlHandle`, `PropertyHandle`). + Object(String), + /// A callback signature (used for event fields such as `TimerHandle.EventHandler`). + Callable(Vec), +} + +impl TypeRef { + pub fn object(name: impl Into) -> Self { + Self::Object(name.into()) + } + + pub fn list(elem: TypeRef) -> Self { + Self::List(Box::new(elem)) + } + + pub fn dict(key: TypeRef, value: TypeRef) -> Self { + Self::Dict(Box::new(key), Box::new(value)) + } + + pub fn callable(args: impl IntoIterator) -> Self { + Self::Callable(args.into_iter().collect()) + } + + /// Renders the type as a Python annotation (used by stubs, hover and signature help). + pub fn python_annotation(&self) -> String { + match self { + Self::Any => "Any".to_string(), + Self::None => "None".to_string(), + Self::Bool => "bool".to_string(), + Self::Int => "int".to_string(), + Self::Float => "float".to_string(), + Self::Str => "str".to_string(), + Self::List(elem) => format!("list[{}]", elem.python_annotation()), + Self::Dict(k, v) => { + format!("dict[{}, {}]", k.python_annotation(), v.python_annotation()) + } + Self::Object(name) => name.clone(), + Self::Callable(args) => format!( + "Callable[[{}], None]", + args.iter() + .map(TypeRef::python_annotation) + .collect::>() + .join(", ") + ), + } + } +} + +/// Where a global may legally be referenced. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum Availability { + #[default] + Always, + Runtime, + DesignTime, +} + +impl Availability { + pub fn permits(self, context: ExecutionContext) -> bool { + matches!( + (self, context), + (Self::Always, _) + | (_, ExecutionContext::Unknown) + | (Self::Runtime, ExecutionContext::Runtime) + | (Self::DesignTime, ExecutionContext::DesignTime) + ) + } + + pub fn describe(self) -> &'static str { + match self { + Self::Always => "everywhere", + Self::Runtime => "at runtime", + Self::DesignTime => "at design time", + } + } +} + +/// The phase in which a piece of analyzed code executes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum ExecutionContext { + Runtime, + DesignTime, + /// Code that executes in both phases (e.g. module-level statements of a plugin). + Both, + /// Code whose execution phase cannot be determined statically (e.g. helper function bodies). + #[default] + Unknown, +} + +impl ExecutionContext { + pub fn describe(self) -> &'static str { + match self { + Self::Runtime => "in runtime code", + Self::DesignTime => "in design-time code", + Self::Both => "in module-level code that runs at both design time and runtime", + Self::Unknown => "here", + } + } +} + +/// How a method is invoked in the emitted Lua. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum CallStyle { + /// `obj:method(...)` - instance methods on native handles. + Colon, + /// `obj.method(...)` - library functions and static constructors. + #[default] + Dot, +} + +/// How a builtin function call is rewritten when lowering Python to Lua. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum Lowering { + /// Emitted unchanged: `print(x)` -> `print(x)`. + #[default] + Direct, + /// Calls a differently named global: `str(x)` -> `tostring(x)`. + Rename(String), + /// Calls a function inside a library table: `abs(x)` -> `math.abs(x)`. + Qualified { module: String, name: String }, + /// Lowers a single-argument call to Lua's length operator: `len(x)` -> `#x`. + LengthOperator, +} + +/// How a function's return type is computed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Returns { + Type(TypeRef), + /// `int` when every argument is an `int`, otherwise `float` (e.g. `abs`, `min`, `max`). + NumericPromotion, +} + +impl Default for Returns { + fn default() -> Self { + Self::Type(TypeRef::None) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Param { + pub name: String, + pub ty: TypeRef, + pub optional: bool, + pub doc: String, +} + +impl Param { + pub fn new(name: impl Into, ty: TypeRef) -> Self { + Self { + name: name.into(), + ty, + optional: false, + doc: String::new(), + } + } + + pub fn optional(mut self) -> Self { + self.optional = true; + self + } + + pub fn doc(mut self, doc: impl Into) -> Self { + self.doc = doc.into(); + self + } + + /// `name: type` or `name?: type` for optional parameters. + pub fn label(&self) -> String { + let marker = if self.optional { "?" } else { "" }; + format!("{}{marker}: {}", self.name, self.ty.python_annotation()) + } +} + +/// A callable: either a global function or a method on an [`ObjectType`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Function { + pub name: String, + pub doc: String, + pub params: Vec, + /// Accepts any number of trailing positional arguments (`print(*values)`). + pub variadic: bool, + pub returns: Returns, + pub call_style: CallStyle, + pub lowering: Lowering, + /// Optional IDE snippet inserted on completion (Monaco/LSP snippet syntax). + pub snippet: Option, +} + +impl Function { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + doc: String::new(), + params: Vec::new(), + variadic: false, + returns: Returns::default(), + call_style: CallStyle::default(), + lowering: Lowering::default(), + snippet: None, + } + } + + pub fn doc(mut self, doc: impl Into) -> Self { + self.doc = doc.into(); + self + } + + pub fn param(mut self, name: impl Into, ty: TypeRef) -> Self { + self.params.push(Param::new(name, ty)); + self + } + + pub fn optional_param(mut self, name: impl Into, ty: TypeRef) -> Self { + self.params.push(Param::new(name, ty).optional()); + self + } + + pub fn arg(mut self, param: Param) -> Self { + self.params.push(param); + self + } + + pub fn variadic(mut self) -> Self { + self.variadic = true; + self + } + + pub fn returns(mut self, ty: TypeRef) -> Self { + self.returns = Returns::Type(ty); + self + } + + pub fn returns_numeric_promotion(mut self) -> Self { + self.returns = Returns::NumericPromotion; + self + } + + pub fn colon(mut self) -> Self { + self.call_style = CallStyle::Colon; + self + } + + pub fn dot(mut self) -> Self { + self.call_style = CallStyle::Dot; + self + } + + pub fn lower_to(mut self, lowering: Lowering) -> Self { + self.lowering = lowering; + self + } + + pub fn snippet(mut self, snippet: impl Into) -> Self { + self.snippet = Some(snippet.into()); + self + } + + /// The return type rendered as a Python annotation. + pub fn return_annotation(&self) -> String { + match &self.returns { + Returns::Type(ty) => ty.python_annotation(), + Returns::NumericPromotion => "int | float".to_string(), + } + } + + /// Comma separated parameter labels (`ip: str, port: int`). + pub fn params_label(&self) -> String { + let mut parts: Vec = self.params.iter().map(Param::label).collect(); + if self.variadic { + parts.push("*args: Any".to_string()); + } + parts.join(", ") + } +} + +/// A data member on an [`ObjectType`]. Callable-typed fields are rendered as events. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Field { + pub name: String, + pub ty: TypeRef, + pub doc: String, +} + +impl Field { + pub fn new(name: impl Into, ty: TypeRef) -> Self { + Self { + name: name.into(), + ty, + doc: String::new(), + } + } + + pub fn doc(mut self, doc: impl Into) -> Self { + self.doc = doc.into(); + self + } + + pub fn is_event(&self) -> bool { + matches!(self.ty, TypeRef::Callable(_)) + } +} + +/// A native runtime object (library table, class factory or handle). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ObjectType { + pub name: String, + pub doc: String, + pub fields: Vec, + pub methods: Vec, + /// Call style used for members that are not explicitly declared. + pub default_call_style: CallStyle, + /// Name of the plugin that registered this type. + pub origin: String, +} + +impl ObjectType { + /// A library table or class factory whose members are invoked with `.` (e.g. `Timer`, `math`). + pub fn library(name: impl Into) -> Self { + Self { + name: name.into(), + doc: String::new(), + fields: Vec::new(), + methods: Vec::new(), + default_call_style: CallStyle::Dot, + origin: String::new(), + } + } + + /// A runtime handle whose methods are invoked with `:` (e.g. `TimerHandle`). + pub fn handle(name: impl Into) -> Self { + Self { + default_call_style: CallStyle::Colon, + ..Self::library(name) + } + } + + pub fn doc(mut self, doc: impl Into) -> Self { + self.doc = doc.into(); + self + } + + pub fn field(mut self, field: Field) -> Self { + self.fields.push(field); + self + } + + /// Adds a method. Methods on handle types default to colon calls unless built with `.dot()`. + pub fn method(mut self, mut method: Function) -> Self { + if self.default_call_style == CallStyle::Colon && method.call_style == CallStyle::Dot { + method.call_style = CallStyle::Colon; + } + self.methods.push(method); + self + } + + /// Adds a method that is always invoked with `.` (e.g. static constructors). + pub fn static_method(mut self, method: Function) -> Self { + self.methods.push(method.dot()); + self + } + + pub fn get_field(&self, name: &str) -> Option<&Field> { + self.fields.iter().find(|f| f.name == name) + } + + pub fn get_method(&self, name: &str) -> Option<&Function> { + self.methods.iter().find(|m| m.name == name) + } + + pub fn member(&self, name: &str) -> Option> { + self.get_method(name) + .map(Member::Method) + .or_else(|| self.get_field(name).map(Member::Field)) + } + + pub fn member_names(&self) -> Vec<&str> { + self.methods + .iter() + .map(|m| m.name.as_str()) + .chain(self.fields.iter().map(|f| f.name.as_str())) + .collect() + } +} + +/// A borrowed view of an [`ObjectType`] member. +#[derive(Debug, Clone, Copy)] +pub enum Member<'a> { + Field(&'a Field), + Method(&'a Function), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum GlobalKind { + Function(Function), + /// A value of the given type: library tables, class factories, `Controls`, ... + Value(TypeRef), +} + +/// A name injected into the global scope of compiled programs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Global { + pub name: String, + pub doc: String, + pub availability: Availability, + pub kind: GlobalKind, + /// Name of the plugin that registered this global. + pub origin: String, +} + +impl Global { + pub fn function(function: Function) -> Self { + Self { + name: function.name.clone(), + doc: function.doc.clone(), + availability: Availability::Always, + kind: GlobalKind::Function(function), + origin: String::new(), + } + } + + pub fn value(name: impl Into, ty: TypeRef) -> Self { + Self { + name: name.into(), + doc: String::new(), + availability: Availability::Always, + kind: GlobalKind::Value(ty), + origin: String::new(), + } + } + + /// A global bound to an object type of the same name (`Timer`, `math`, ...). + pub fn object(name: impl Into) -> Self { + let name = name.into(); + Self::value(name.clone(), TypeRef::Object(name)) + } + + pub fn doc(mut self, doc: impl Into) -> Self { + self.doc = doc.into(); + self + } + + pub fn available(mut self, availability: Availability) -> Self { + self.availability = availability; + self + } + + pub fn runtime_only(self) -> Self { + self.available(Availability::Runtime) + } + + pub fn design_time_only(self) -> Self { + self.available(Availability::DesignTime) + } + + pub fn as_function(&self) -> Option<&Function> { + match &self.kind { + GlobalKind::Function(f) => Some(f), + GlobalKind::Value(_) => None, + } + } + + /// The object type name this global is bound to, if any. + pub fn object_type_name(&self) -> Option<&str> { + match &self.kind { + GlobalKind::Value(TypeRef::Object(name)) => Some(name), + _ => None, + } + } +} + +/// A name explicitly removed from the environment, with a user-facing reason. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Removed { + pub name: String, + pub reason: String, + pub origin: String, +} diff --git a/crates/qsys_runtime/src/env.rs b/crates/qsys_runtime/src/env.rs new file mode 100644 index 0000000..8cd5f63 --- /dev/null +++ b/crates/qsys_runtime/src/env.rs @@ -0,0 +1,224 @@ +use std::sync::{Arc, OnceLock}; + +use rustc_hash::FxHashMap; + +use crate::defs::{CallStyle, Global, Member, ObjectType, Removed}; +use crate::plugins::{LuaStdlib, PythonCore, QSysRuntime}; + +/// A bundle of runtime knowledge that can be installed into an [`EnvBuilder`]. +/// +/// ``` +/// use qsys_runtime::{EnvBuilder, Function, Global, RuntimeEnv, RuntimePlugin, TypeRef}; +/// +/// struct Diagnostics; +/// +/// impl RuntimePlugin for Diagnostics { +/// fn name(&self) -> &'static str { +/// "diagnostics" +/// } +/// +/// fn install(&self, env: &mut EnvBuilder) { +/// env.add_global(Global::function( +/// Function::new("trace").param("message", TypeRef::Str), +/// )); +/// env.remove("print", "use trace() instead"); +/// } +/// } +/// +/// let env = RuntimeEnv::builder().install(&Diagnostics).build(); +/// assert!(env.global("trace").is_some()); +/// assert_eq!(env.removed_reason("print"), Some("use trace() instead")); +/// ``` +pub trait RuntimePlugin { + fn name(&self) -> &'static str; + fn install(&self, env: &mut EnvBuilder); +} + +/// Accumulates globals, object types and removals from a sequence of plugins. +/// +/// Plugins are applied in order: a later plugin can override or [`remove`](Self::remove) +/// anything an earlier plugin registered. +#[derive(Debug, Default)] +pub struct EnvBuilder { + globals: Vec, + types: Vec, + removed: Vec, + plugins: Vec, + current_origin: String, +} + +impl EnvBuilder { + pub fn new() -> Self { + Self::default() + } + + /// Applies a plugin. Chainable for top-level environment construction. + pub fn install(mut self, plugin: &dyn RuntimePlugin) -> Self { + self.install_mut(plugin); + self + } + + /// Applies a plugin through a mutable reference (useful when composing plugins). + pub fn install_mut(&mut self, plugin: &dyn RuntimePlugin) -> &mut Self { + let previous = std::mem::replace(&mut self.current_origin, plugin.name().to_string()); + plugin.install(self); + self.current_origin = previous; + self.plugins.push(plugin.name().to_string()); + self + } + + /// Registers (or replaces) a global. Re-adding a removed name makes it available again. + pub fn add_global(&mut self, mut global: Global) -> &mut Self { + global.origin.clone_from(&self.current_origin); + self.removed.retain(|r| r.name != global.name); + if let Some(existing) = self.globals.iter_mut().find(|g| g.name == global.name) { + *existing = global; + } else { + self.globals.push(global); + } + self + } + + /// Registers (or replaces) a native object type. + pub fn add_type(&mut self, mut ty: ObjectType) -> &mut Self { + ty.origin.clone_from(&self.current_origin); + if let Some(existing) = self.types.iter_mut().find(|t| t.name == ty.name) { + *existing = ty; + } else { + self.types.push(ty); + } + self + } + + /// Removes a global and records why, so its use yields a targeted diagnostic. + /// The name does not need to have been registered (e.g. unsupported Python builtins). + pub fn remove(&mut self, name: impl Into, reason: impl Into) -> &mut Self { + let name = name.into(); + self.globals.retain(|g| g.name != name); + self.removed.retain(|r| r.name != name); + self.removed.push(Removed { + name, + reason: reason.into(), + origin: self.current_origin.clone(), + }); + self + } + + pub fn build(self) -> RuntimeEnv { + let global_index = self + .globals + .iter() + .enumerate() + .map(|(i, g)| (g.name.clone(), i)) + .collect(); + let type_index = self + .types + .iter() + .enumerate() + .map(|(i, t)| (t.name.clone(), i)) + .collect(); + let removed = self + .removed + .iter() + .map(|r| (r.name.clone(), r.clone())) + .collect(); + + RuntimeEnv { + globals: self.globals, + global_index, + types: self.types, + type_index, + removed, + plugins: self.plugins, + } + } +} + +/// The immutable, queryable result of installing runtime plugins. +#[derive(Debug, Clone, Default)] +pub struct RuntimeEnv { + globals: Vec, + global_index: FxHashMap, + types: Vec, + type_index: FxHashMap, + removed: FxHashMap, + plugins: Vec, +} + +impl RuntimeEnv { + pub fn builder() -> EnvBuilder { + EnvBuilder::new() + } + + /// The default Q-SYS environment: [`PythonCore`] + [`LuaStdlib`] + [`QSysRuntime`]. + /// Built once and shared. + pub fn qsys() -> Arc { + static ENV: OnceLock> = OnceLock::new(); + ENV.get_or_init(|| { + Arc::new( + Self::builder() + .install(&PythonCore) + .install(&LuaStdlib) + .install(&QSysRuntime) + .build(), + ) + }) + .clone() + } + + pub fn global(&self, name: &str) -> Option<&Global> { + self.global_index.get(name).map(|&i| &self.globals[i]) + } + + /// All globals in registration order. + pub fn globals(&self) -> &[Global] { + &self.globals + } + + pub fn global_names(&self) -> impl Iterator { + self.globals.iter().map(|g| g.name.as_str()) + } + + pub fn object_type(&self, name: &str) -> Option<&ObjectType> { + self.type_index.get(name).map(|&i| &self.types[i]) + } + + /// All object types in registration order. + pub fn object_types(&self) -> &[ObjectType] { + &self.types + } + + /// The object type a global is bound to (`Timer` -> `Timer`, `qsys` -> `QSysNamespace`). + pub fn object_type_of_global(&self, name: &str) -> Option<&ObjectType> { + self.global(name) + .and_then(Global::object_type_name) + .and_then(|t| self.object_type(t)) + } + + pub fn member(&self, type_name: &str, member: &str) -> Option> { + self.object_type(type_name).and_then(|t| t.member(member)) + } + + /// How `obj.member(...)` should be emitted for an object of `type_name`. + pub fn call_style(&self, type_name: &str, member: &str) -> CallStyle { + match self.object_type(type_name) { + Some(ty) => ty + .get_method(member) + .map_or(ty.default_call_style, |m| m.call_style), + None => CallStyle::Dot, + } + } + + pub fn removed_reason(&self, name: &str) -> Option<&str> { + self.removed.get(name).map(|r| r.reason.as_str()) + } + + pub fn removed(&self) -> impl Iterator { + self.removed.values() + } + + /// Names of the plugins installed, in order. + pub fn plugins(&self) -> &[String] { + &self.plugins + } +} diff --git a/crates/qsys_runtime/src/lib.rs b/crates/qsys_runtime/src/lib.rs new file mode 100644 index 0000000..289c769 --- /dev/null +++ b/crates/qsys_runtime/src/lib.rs @@ -0,0 +1,138 @@ +//! Declarative description of the runtime a QSysPy program is compiled against. +//! +//! A [`RuntimeEnv`] is assembled from [`RuntimePlugin`]s. Each plugin can add globals +//! (functions and values), native object types (fields, methods, events) and remove names +//! with a user-facing reason. Semantic analysis, Lua lowering, IDE features and `.pyi` +//! stub generation all read from the same environment, so they cannot drift apart. +//! +//! The default Q-SYS environment is [`RuntimeEnv::qsys`]: +//! [`PythonCore`] + [`LuaStdlib`] + [`QSysRuntime`]. + +mod defs; +mod env; +pub mod plugins; +pub mod pyi; + +pub use crate::defs::{ + Availability, CallStyle, ExecutionContext, Field, Function, Global, GlobalKind, Lowering, + Member, ObjectType, Param, Removed, Returns, TypeRef, +}; +pub use crate::env::{EnvBuilder, RuntimeEnv, RuntimePlugin}; +pub use crate::plugins::{LuaStdlib, PythonCore, QSysRuntime}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_env_contains_all_plugins() { + let env = RuntimeEnv::qsys(); + assert_eq!( + env.plugins(), + [PythonCore::NAME, LuaStdlib::NAME, QSysRuntime::NAME] + ); + assert!(env.global("print").is_some()); + assert!(env.global("math").is_some()); + assert!(env.global("Timer").is_some()); + } + + #[test] + fn controls_and_properties_are_runtime_only() { + let env = RuntimeEnv::qsys(); + for name in ["Controls", "Properties"] { + let global = env.global(name).unwrap(); + assert_eq!(global.availability, Availability::Runtime); + assert!(!global.availability.permits(ExecutionContext::DesignTime)); + assert!(!global.availability.permits(ExecutionContext::Both)); + assert!(global.availability.permits(ExecutionContext::Runtime)); + } + assert!( + env.global("Timer") + .unwrap() + .availability + .permits(ExecutionContext::DesignTime) + ); + } + + #[test] + fn handle_methods_use_colon_and_factories_use_dot() { + let env = RuntimeEnv::qsys(); + assert_eq!(env.call_style("TimerHandle", "Start"), CallStyle::Colon); + assert_eq!( + env.call_style("TimerHandle", "Undeclared"), + CallStyle::Colon + ); + assert_eq!(env.call_style("Timer", "New"), CallStyle::Dot); + assert_eq!(env.call_style("math", "floor"), CallStyle::Dot); + } + + #[test] + fn later_plugins_override_and_remove() { + struct Base; + impl RuntimePlugin for Base { + fn name(&self) -> &'static str { + "base" + } + fn install(&self, env: &mut EnvBuilder) { + env.add_global(Global::function(Function::new("helper"))); + env.add_global(Global::function(Function::new("legacy"))); + } + } + + struct Restrict; + impl RuntimePlugin for Restrict { + fn name(&self) -> &'static str { + "restrict" + } + fn install(&self, env: &mut EnvBuilder) { + env.remove("legacy", "removed on this target"); + env.add_global( + Global::function(Function::new("helper").returns(TypeRef::Int)).runtime_only(), + ); + } + } + + let env = RuntimeEnv::builder() + .install(&Base) + .install(&Restrict) + .build(); + assert!(env.global("legacy").is_none()); + assert_eq!(env.removed_reason("legacy"), Some("removed on this target")); + + let helper = env.global("helper").unwrap(); + assert_eq!(helper.origin, "restrict"); + assert_eq!(helper.availability, Availability::Runtime); + } + + #[test] + fn re_adding_a_removed_name_restores_it() { + let mut builder = RuntimeEnv::builder(); + builder.remove("print", "no console"); + builder.add_global(Global::function(Function::new("print"))); + let env = builder.build(); + assert!(env.global("print").is_some()); + assert!(env.removed_reason("print").is_none()); + } + + #[test] + fn pyi_renders_qsys_types_only() { + let env = RuntimeEnv::qsys(); + let pyi = pyi::render_pyi(&env, |origin| origin == QSysRuntime::NAME); + + assert!(pyi.contains("class TimerHandle:")); + assert!(pyi.contains(" def Start(self, seconds: float) -> None:")); + assert!(pyi.contains("class Timer:\n")); + assert!(pyi.contains(" @staticmethod\n def New() -> TimerHandle:")); + assert!(pyi.contains(" EventHandler: Callable[[TimerHandle], None]")); + assert!(pyi.contains("Controls: dict[str, ControlHandle]")); + assert!(pyi.contains("qsys: QSysNamespace")); + assert!( + !pyi.contains("class math:"), + "Lua stdlib must be filtered out" + ); + assert!( + !pyi.contains("def print("), + "Python builtins must be filtered out" + ); + } +} diff --git a/crates/qsys_runtime/src/plugins/lua_stdlib.rs b/crates/qsys_runtime/src/plugins/lua_stdlib.rs new file mode 100644 index 0000000..f8b5d8c --- /dev/null +++ b/crates/qsys_runtime/src/plugins/lua_stdlib.rs @@ -0,0 +1,283 @@ +use crate::defs::{Field, Function, Global, ObjectType, TypeRef}; +use crate::env::{EnvBuilder, RuntimePlugin}; + +/// The Lua 5.3 base library and standard tables available to compiled code. +pub struct LuaStdlib; + +impl LuaStdlib { + pub const NAME: &'static str = "lua-stdlib"; +} + +fn unary_float(name: &str, doc: &str) -> Function { + Function::new(name) + .param("x", TypeRef::Float) + .returns(TypeRef::Float) + .doc(doc) +} + +fn binary_int(name: &str, doc: &str) -> Function { + Function::new(name) + .param("a", TypeRef::Int) + .param("b", TypeRef::Int) + .returns(TypeRef::Int) + .doc(doc) +} + +impl RuntimePlugin for LuaStdlib { + fn name(&self) -> &'static str { + Self::NAME + } + + fn install(&self, env: &mut EnvBuilder) { + env.add_type( + ObjectType::library("math") + .doc("Lua 5.3 `math` library.") + .field(Field::new("pi", TypeRef::Float).doc("The value of π.")) + .field(Field::new("huge", TypeRef::Float).doc("Positive infinity.")) + .method( + Function::new("floor") + .param("x", TypeRef::Float) + .returns(TypeRef::Int) + .doc("Largest integer less than or equal to `x`."), + ) + .method( + Function::new("ceil") + .param("x", TypeRef::Float) + .returns(TypeRef::Int) + .doc("Smallest integer greater than or equal to `x`."), + ) + .method(unary_float("sqrt", "Square root of `x`.")) + .method(unary_float("abs", "Absolute value of `x`.")) + .method(unary_float("exp", "e raised to the power `x`.")) + .method(unary_float("log", "Natural logarithm of `x`.")) + .method(unary_float("sin", "Sine of `x` (radians).")) + .method(unary_float("cos", "Cosine of `x` (radians).")) + .method(unary_float("tan", "Tangent of `x` (radians).")) + .method( + Function::new("fmod") + .param("x", TypeRef::Float) + .param("y", TypeRef::Float) + .returns(TypeRef::Float) + .doc("Remainder of `x / y` rounding towards zero."), + ) + .method( + Function::new("min") + .variadic() + .returns_numeric_promotion() + .doc("Smallest argument."), + ) + .method( + Function::new("max") + .variadic() + .returns_numeric_promotion() + .doc("Largest argument."), + ) + .method( + Function::new("random") + .optional_param("m", TypeRef::Int) + .optional_param("n", TypeRef::Int) + .returns(TypeRef::Float) + .doc("Pseudo-random number (float in [0,1) or integer in [m, n])."), + ) + .method( + Function::new("randomseed") + .param("seed", TypeRef::Int) + .doc("Seeds the pseudo-random generator."), + ), + ); + + env.add_type( + ObjectType::library("string") + .doc("Lua 5.3 `string` library.") + .method( + Function::new("char") + .variadic() + .returns(TypeRef::Str) + .doc("String made of the given byte values."), + ) + .method( + Function::new("byte") + .param("s", TypeRef::Str) + .optional_param("i", TypeRef::Int) + .returns(TypeRef::Int) + .doc("Byte value of `s[i]`."), + ) + .method( + Function::new("len") + .param("s", TypeRef::Str) + .returns(TypeRef::Int) + .doc("Length of `s` in bytes."), + ) + .method( + Function::new("rep") + .param("s", TypeRef::Str) + .param("n", TypeRef::Int) + .returns(TypeRef::Str) + .doc("`s` repeated `n` times."), + ) + .method( + Function::new("format") + .param("fmt", TypeRef::Str) + .variadic() + .returns(TypeRef::Str) + .doc("printf-style formatting."), + ) + .method( + Function::new("sub") + .param("s", TypeRef::Str) + .param("i", TypeRef::Int) + .optional_param("j", TypeRef::Int) + .returns(TypeRef::Str) + .doc("Substring from `i` to `j` (1-based, inclusive)."), + ) + .method( + Function::new("upper") + .param("s", TypeRef::Str) + .returns(TypeRef::Str) + .doc("Upper-cased copy of `s`."), + ) + .method( + Function::new("lower") + .param("s", TypeRef::Str) + .returns(TypeRef::Str) + .doc("Lower-cased copy of `s`."), + ) + .method( + Function::new("find") + .param("s", TypeRef::Str) + .param("pattern", TypeRef::Str) + .returns(TypeRef::Any) + .doc("Start index of the first match of `pattern`, or `nil`."), + ) + .method( + Function::new("gsub") + .param("s", TypeRef::Str) + .param("pattern", TypeRef::Str) + .param("repl", TypeRef::Any) + .returns(TypeRef::Str) + .doc("Copy of `s` with every match of `pattern` replaced."), + ) + .method( + Function::new("match") + .param("s", TypeRef::Str) + .param("pattern", TypeRef::Str) + .returns(TypeRef::Any) + .doc("First capture of `pattern` in `s`, or `nil`."), + ) + .method( + Function::new("reverse") + .param("s", TypeRef::Str) + .returns(TypeRef::Str) + .doc("`s` reversed."), + ), + ); + + env.add_type( + ObjectType::library("table") + .doc("Lua 5.3 `table` library.") + .method( + Function::new("insert") + .param("t", TypeRef::Any) + .param("value", TypeRef::Any) + .doc("Appends `value` (or inserts at a position when given 3 arguments)."), + ) + .method( + Function::new("remove") + .param("t", TypeRef::Any) + .optional_param("pos", TypeRef::Int) + .returns(TypeRef::Any) + .doc("Removes and returns the element at `pos` (default: last)."), + ) + .method( + Function::new("concat") + .param("t", TypeRef::Any) + .optional_param("sep", TypeRef::Str) + .returns(TypeRef::Str) + .doc("Joins the elements of `t` with `sep`."), + ) + .method( + Function::new("sort") + .param("t", TypeRef::Any) + .optional_param("comp", TypeRef::Any) + .doc("Sorts `t` in place."), + ) + .method( + Function::new("unpack") + .param("t", TypeRef::Any) + .returns(TypeRef::Any) + .doc("Returns the elements of `t` as multiple values."), + ), + ); + + env.add_type( + ObjectType::library("bit32") + .doc("Bitwise operations library.") + .method(binary_int("band", "Bitwise AND.")) + .method(binary_int("bor", "Bitwise OR.")) + .method(binary_int("bxor", "Bitwise XOR.")) + .method(binary_int("lshift", "Logical left shift.")) + .method(binary_int("rshift", "Logical right shift.")) + .method( + Function::new("bnot") + .param("a", TypeRef::Int) + .returns(TypeRef::Int) + .doc("Bitwise NOT."), + ), + ); + + for library in ["math", "string", "table", "bit32"] { + env.add_global(Global::object(library).doc(format!("Lua `{library}` library."))); + } + + let functions = [ + Function::new("tonumber") + .param("value", TypeRef::Any) + .optional_param("base", TypeRef::Int) + .returns(TypeRef::Float) + .doc("Converts `value` to a number, or `nil`."), + Function::new("tostring") + .param("value", TypeRef::Any) + .returns(TypeRef::Str) + .doc("String representation of `value`."), + Function::new("type") + .param("value", TypeRef::Any) + .returns(TypeRef::Str) + .doc("Lua type name of `value`."), + Function::new("ipairs") + .param("t", TypeRef::Any) + .returns(TypeRef::Any) + .doc("Iterator over the array part of `t`."), + Function::new("pairs") + .param("t", TypeRef::Any) + .returns(TypeRef::Any) + .doc("Iterator over all key/value pairs of `t`."), + Function::new("next") + .param("t", TypeRef::Any) + .optional_param("key", TypeRef::Any) + .returns(TypeRef::Any) + .doc("Next key/value pair of `t` after `key`."), + Function::new("unpack") + .param("t", TypeRef::Any) + .returns(TypeRef::Any) + .doc("Returns the elements of `t` as multiple values."), + Function::new("pcall") + .param("f", TypeRef::Any) + .variadic() + .returns(TypeRef::Any) + .doc("Calls `f` in protected mode, returning a success flag."), + Function::new("error") + .param("message", TypeRef::Any) + .optional_param("level", TypeRef::Int) + .doc("Raises an error."), + Function::new("assert") + .param("value", TypeRef::Any) + .optional_param("message", TypeRef::Str) + .returns(TypeRef::Any) + .doc("Raises an error if `value` is false or nil."), + ]; + + for function in functions { + env.add_global(Global::function(function)); + } + } +} diff --git a/crates/qsys_runtime/src/plugins/mod.rs b/crates/qsys_runtime/src/plugins/mod.rs new file mode 100644 index 0000000..1a575ca --- /dev/null +++ b/crates/qsys_runtime/src/plugins/mod.rs @@ -0,0 +1,7 @@ +mod lua_stdlib; +mod python_core; +mod qsys; + +pub use lua_stdlib::LuaStdlib; +pub use python_core::PythonCore; +pub use qsys::QSysRuntime; diff --git a/crates/qsys_runtime/src/plugins/python_core.rs b/crates/qsys_runtime/src/plugins/python_core.rs new file mode 100644 index 0000000..247c036 --- /dev/null +++ b/crates/qsys_runtime/src/plugins/python_core.rs @@ -0,0 +1,107 @@ +use crate::defs::{Function, Global, Lowering, TypeRef}; +use crate::env::{EnvBuilder, RuntimePlugin}; + +/// The Python builtins QSysPy understands, and how each one lowers to Lua. +pub struct PythonCore; + +impl PythonCore { + pub const NAME: &'static str = "python-core"; +} + +fn qualified(module: &str, name: &str) -> Lowering { + Lowering::Qualified { + module: module.to_string(), + name: name.to_string(), + } +} + +impl RuntimePlugin for PythonCore { + fn name(&self) -> &'static str { + Self::NAME + } + + fn install(&self, env: &mut EnvBuilder) { + let builtins = [ + Function::new("print") + .variadic() + .doc("Writes values to the debug output (Lua `print`)."), + Function::new("len") + .param("obj", TypeRef::Any) + .returns(TypeRef::Int) + .lower_to(Lowering::LengthOperator) + .doc("Length of a string, list or table (Lua `#obj`)."), + Function::new("str") + .param("value", TypeRef::Any) + .returns(TypeRef::Str) + .lower_to(Lowering::Rename("tostring".to_string())) + .doc("String representation of a value (Lua `tostring`)."), + Function::new("int") + .param("value", TypeRef::Any) + .returns(TypeRef::Int) + .lower_to(Lowering::Rename("tonumber".to_string())) + .doc("Converts a value to a number (Lua `tonumber`)."), + Function::new("float") + .param("value", TypeRef::Any) + .returns(TypeRef::Float) + .lower_to(Lowering::Rename("tonumber".to_string())) + .doc("Converts a value to a number (Lua `tonumber`)."), + Function::new("bool") + .param("value", TypeRef::Any) + .returns(TypeRef::Bool) + .doc("Converts a value to a boolean."), + Function::new("range") + .param("start_or_stop", TypeRef::Int) + .optional_param("stop", TypeRef::Int) + .optional_param("step", TypeRef::Int) + .returns(TypeRef::list(TypeRef::Int)) + .doc("Sequence of integers, used in `for` loops."), + Function::new("abs") + .param("x", TypeRef::Float) + .returns_numeric_promotion() + .lower_to(qualified("math", "abs")) + .doc("Absolute value (Lua `math.abs`)."), + Function::new("min") + .variadic() + .returns_numeric_promotion() + .lower_to(qualified("math", "min")) + .doc("Smallest of the arguments (Lua `math.min`)."), + Function::new("max") + .variadic() + .returns_numeric_promotion() + .lower_to(qualified("math", "max")) + .doc("Largest of the arguments (Lua `math.max`)."), + Function::new("chr") + .param("code", TypeRef::Int) + .returns(TypeRef::Str) + .lower_to(qualified("string", "char")) + .doc("Character for a byte value (Lua `string.char`)."), + Function::new("ord") + .param("char", TypeRef::Str) + .returns(TypeRef::Int) + .lower_to(qualified("string", "byte")) + .doc("Byte value of a character (Lua `string.byte`)."), + Function::new("super") + .returns(TypeRef::Any) + .doc("Proxy to the base class, for `super().method(...)` calls."), + ]; + + for builtin in builtins { + env.add_global(Global::function(builtin)); + } + + let dynamic = "dynamic code execution cannot be compiled to Lua ahead of time"; + env.remove("eval", dynamic); + env.remove("exec", dynamic); + env.remove("compile", dynamic); + + let introspection = "runtime scope introspection is not supported"; + env.remove("globals", introspection); + env.remove("locals", introspection); + env.remove("vars", introspection); + + env.remove( + "__import__", + "dynamic imports are not supported; use a top-level `import` statement", + ); + } +} diff --git a/crates/qsys_runtime/src/plugins/qsys.rs b/crates/qsys_runtime/src/plugins/qsys.rs new file mode 100644 index 0000000..f6e7f74 --- /dev/null +++ b/crates/qsys_runtime/src/plugins/qsys.rs @@ -0,0 +1,300 @@ +use crate::defs::{Field, Function, Global, ObjectType, Param, TypeRef}; +use crate::env::{EnvBuilder, RuntimePlugin}; + +/// The Q-SYS Core runtime: `Controls`, `Properties`, timers, sockets and Q-SYS libraries. +pub struct QSysRuntime; + +impl QSysRuntime { + pub const NAME: &'static str = "qsys"; +} + +/// Q-SYS libraries that are exposed as globals but not (yet) modelled member by member. +/// Their members are accepted without diagnostics and invoked with `.`. +const OPAQUE_LIBRARIES: &[(&str, &str)] = &[ + ("SerialPort", "Q-SYS serial port API."), + ("HttpClient", "Q-SYS HTTP/HTTPS client library."), + ( + "NamedControl", + "Access to named controls in the Q-SYS design.", + ), + ( + "Notifications", + "Q-SYS publish/subscribe notification library.", + ), + ( + "Component", + "Access to named components in the Q-SYS design.", + ), + ("Log", "Q-SYS Core event log."), + ("System", "Q-SYS Core system information."), + ("Crypto", "Q-SYS cryptography and encoding library."), + ( + "rapidjson", + "JSON encoding/decoding library bundled with Q-SYS.", + ), + ("json", "JSON encoding/decoding library bundled with Q-SYS."), +]; + +impl RuntimePlugin for QSysRuntime { + fn name(&self) -> &'static str { + Self::NAME + } + + fn install(&self, env: &mut EnvBuilder) { + install_design_access(env); + install_timer(env); + install_udp(env); + install_tcp(env); + + for (name, doc) in OPAQUE_LIBRARIES { + env.add_type(ObjectType::library(*name).doc(*doc)); + env.add_global(Global::object(*name).doc(*doc)); + } + + env.remove("input", "Q-SYS scripts have no standard input"); + env.remove( + "open", + "Python file objects are not supported on the Q-SYS target", + ); + } +} + +fn install_design_access(env: &mut EnvBuilder) { + env.add_global( + Global::value( + "Controls", + TypeRef::dict(TypeRef::Str, TypeRef::object("ControlHandle")), + ) + .doc("Table of the component's controls, indexed by control name (`Controls[\"Gain\"]`).") + .runtime_only(), + ); + env.add_global( + Global::value( + "Properties", + TypeRef::dict(TypeRef::Str, TypeRef::object("PropertyHandle")), + ) + .doc("Table of the component's properties, indexed by property name (`Properties[\"Port\"].Value`).") + .runtime_only(), + ); + + env.add_type( + ObjectType::library("QSysNamespace") + .doc("Built-in compiler escape hatches and runtime utilities.") + .method( + Function::new("lua") + .arg(Param::new("code", TypeRef::Str).doc("Raw Lua 5.3 string literal.")) + .doc("Escape hatch: injects raw, uncompiled Lua 5.3 code directly into the compiled output.") + .snippet("lua(\"${1:print('hello from lua')}\")"), + ), + ); + env.add_global( + Global::value("qsys", TypeRef::object("QSysNamespace")) + .doc("Compiler escape hatches (`qsys.lua(\"...\")`)."), + ); +} + +fn install_timer(env: &mut EnvBuilder) { + env.add_type( + ObjectType::handle("TimerHandle") + .doc("Active Q-SYS event timer.") + .field( + Field::new( + "EventHandler", + TypeRef::callable([TypeRef::object("TimerHandle")]), + ) + .doc("Callback invoked every time the timer period elapses."), + ) + .method( + Function::new("Start") + .arg(Param::new("seconds", TypeRef::Float).doc("Timer interval in seconds.")) + .doc("Starts or restarts the timer with the given interval in seconds.") + .snippet("Start(${1:1.0})"), + ) + .method(Function::new("Stop").doc("Stops the timer.")) + .method(Function::new("Reset").doc("Resets the timer counter.")), + ); + env.add_type( + ObjectType::library("Timer") + .doc("Q-SYS native event loop timer. Instantiate via `Timer.New()`.") + .static_method( + Function::new("New") + .returns(TypeRef::object("TimerHandle")) + .doc("Creates a new timer instance."), + ) + .static_method( + Function::new("CallAfter") + .arg(Param::new("callback", TypeRef::callable([])).doc("Function to call.")) + .arg(Param::new("delay", TypeRef::Float).doc("Delay in seconds.")) + .doc("Calls `callback` once after `delay` seconds."), + ), + ); + env.add_global( + Global::object("Timer") + .doc("Q-SYS native event loop timer. Instantiate via `Timer.New()`."), + ); +} + +fn install_udp(env: &mut EnvBuilder) { + env.add_type( + ObjectType::library("UdpPacket") + .doc("Incoming UDP packet received by a UdpSocket.") + .field(Field::new("Address", TypeRef::Str).doc("Sender IP address.")) + .field(Field::new("Port", TypeRef::Int).doc("Sender UDP port.")) + .field(Field::new("Data", TypeRef::Str).doc("Packet payload.")), + ); + env.add_type( + ObjectType::handle("UdpSocketHandle") + .doc("Active UDP socket handle.") + .field( + Field::new( + "Data", + TypeRef::callable([ + TypeRef::object("UdpSocketHandle"), + TypeRef::object("UdpPacket"), + ]), + ) + .doc("Callback assigned to receive incoming UDP packets."), + ) + .field( + Field::new( + "Closed", + TypeRef::callable([TypeRef::object("UdpSocketHandle")]), + ) + .doc("Callback invoked when the socket closes."), + ) + .method( + Function::new("Open") + .arg( + Param::new("ip", TypeRef::Str) + .optional() + .doc("Optional local interface IP to bind."), + ) + .arg( + Param::new("port", TypeRef::Int) + .optional() + .doc("Optional local UDP port to bind."), + ) + .doc("Binds and opens the UDP socket."), + ) + .method(Function::new("Close").doc("Closes the UDP socket.")) + .method( + Function::new("Send") + .arg( + Param::new("ip", TypeRef::Str) + .doc("Destination IPv4/IPv6 or broadcast address."), + ) + .arg( + Param::new("port", TypeRef::Int) + .doc("Destination UDP port number (1-65535)."), + ) + .arg(Param::new("data", TypeRef::Str).doc("Binary or text payload to send.")) + .doc("Transmits a UDP datagram to the specified destination.") + .snippet("Send(${1:self.IPAddress.String}, ${2:9}, ${3:packet})"), + ) + .method( + Function::new("JoinMulticast") + .arg(Param::new("address", TypeRef::Str).doc("Multicast group address.")) + .doc("Joins an IP multicast group.") + .snippet("JoinMulticast(${1:\"224.0.0.1\"})"), + ) + .method( + Function::new("LeaveMulticast") + .arg(Param::new("address", TypeRef::Str).doc("Multicast group address.")) + .doc("Leaves an IP multicast group."), + ), + ); + env.add_type( + ObjectType::library("UdpSocket") + .doc("Q-SYS native UDP networking class. Instantiate via `UdpSocket.New()`.") + .static_method( + Function::new("New") + .returns(TypeRef::object("UdpSocketHandle")) + .doc("Creates a new UDP socket."), + ), + ); + env.add_global( + Global::object("UdpSocket") + .doc("Q-SYS native UDP networking class. Instantiate via `UdpSocket.New()`."), + ); +} + +fn install_tcp(env: &mut EnvBuilder) { + let this = || TypeRef::object("TcpSocketHandle"); + env.add_type( + ObjectType::handle("TcpSocketHandle") + .doc("Active TCP socket handle.") + .field( + Field::new("IsConnected", TypeRef::Bool) + .doc("True when the TCP socket is currently connected."), + ) + .field( + Field::new("Connected", TypeRef::callable([this()])) + .doc("Callback invoked when the connection is established."), + ) + .field( + Field::new("Data", TypeRef::callable([this()])) + .doc("Callback invoked when data arrives."), + ) + .field( + Field::new("Closed", TypeRef::callable([this()])) + .doc("Callback invoked when the remote end closes the connection."), + ) + .field( + Field::new("Error", TypeRef::callable([this(), TypeRef::Str])) + .doc("Callback invoked on socket errors."), + ) + .field( + Field::new("Timeout", TypeRef::callable([this()])) + .doc("Callback invoked when a read or write times out."), + ) + .method( + Function::new("Connect") + .arg(Param::new("ip", TypeRef::Str).doc("Remote host IP or hostname.")) + .arg(Param::new("port", TypeRef::Int).doc("Remote TCP port.")) + .doc("Opens a TCP connection to the host and port.") + .snippet("Connect(${1:ip}, ${2:port})"), + ) + .method(Function::new("Disconnect").doc("Closes the active TCP connection.")) + .method( + Function::new("Write") + .arg(Param::new("data", TypeRef::Str).doc("String or binary payload to transmit.")) + .doc("Writes data to the connected TCP socket.") + .snippet("Write(${1:data})"), + ) + .method( + Function::new("Read") + .arg( + Param::new("length", TypeRef::Int) + .optional() + .doc("Maximum number of bytes to read."), + ) + .returns(TypeRef::Str) + .doc("Reads up to `length` bytes from the receive buffer.") + .snippet("Read(${1:1024})"), + ) + .method( + Function::new("ReadLine") + .arg( + Param::new("eol", TypeRef::Any) + .optional() + .doc("End-of-line mode, e.g. `TcpSocket.EOL.Any`."), + ) + .returns(TypeRef::Any) + .doc("Reads a single line from the receive buffer, or `nil` if no full line is available.") + .snippet("ReadLine(${1:TcpSocket.EOL.Any})"), + ), + ); + env.add_type( + ObjectType::library("TcpSocket") + .doc("Q-SYS native TCP client networking class. Instantiate via `TcpSocket.New()`.") + .static_method( + Function::new("New") + .returns(this()) + .doc("Creates a new TCP socket."), + ), + ); + env.add_global( + Global::object("TcpSocket") + .doc("Q-SYS native TCP client networking class. Instantiate via `TcpSocket.New()`."), + ); +} diff --git a/crates/qsys_runtime/src/pyi.rs b/crates/qsys_runtime/src/pyi.rs new file mode 100644 index 0000000..36a820c --- /dev/null +++ b/crates/qsys_runtime/src/pyi.rs @@ -0,0 +1,109 @@ +//! Renders a [`RuntimeEnv`] as PEP 484 stub (`.pyi`) source, so editor tooling always +//! matches what the compiler accepts. + +use std::fmt::Write as _; + +use crate::defs::{CallStyle, Function, GlobalKind, ObjectType, TypeRef}; +use crate::env::RuntimeEnv; + +/// Renders the object types and globals whose `origin` plugin satisfies `include`. +/// +/// The output references `Any` and `Callable`; the caller is responsible for importing them +/// from `typing`. +pub fn render_pyi(env: &RuntimeEnv, include: impl Fn(&str) -> bool) -> String { + let mut out = String::new(); + + for ty in env.object_types().iter().filter(|t| include(&t.origin)) { + render_class(&mut out, ty); + } + + for global in env.globals().iter().filter(|g| include(&g.origin)) { + match &global.kind { + GlobalKind::Value(TypeRef::Object(type_name)) + if *type_name == global.name && env.object_type(type_name).is_some() => + { + // The class rendered above doubles as the global. + } + GlobalKind::Value(ty) => { + let _ = writeln!(out, "{}: {}", global.name, ty.python_annotation()); + write_attribute_doc(&mut out, "", &global.doc); + } + GlobalKind::Function(function) => { + render_function(&mut out, "", function, false); + out.push('\n'); + } + } + } + + out +} + +fn render_class(out: &mut String, ty: &ObjectType) { + let _ = writeln!(out, "class {}:", ty.name); + let mut has_body = false; + + if !ty.doc.is_empty() { + let _ = writeln!(out, " \"\"\"{}\"\"\"", escape_doc(&ty.doc)); + has_body = true; + } + + for field in &ty.fields { + let _ = writeln!(out, " {}: {}", field.name, field.ty.python_annotation()); + write_attribute_doc(out, " ", &field.doc); + has_body = true; + } + + for method in &ty.methods { + let is_static = method.call_style == CallStyle::Dot; + if is_static { + out.push_str(" @staticmethod\n"); + } + render_function(out, " ", method, !is_static); + has_body = true; + } + + if !has_body { + out.push_str(" ...\n"); + } + out.push('\n'); +} + +fn render_function(out: &mut String, indent: &str, function: &Function, with_self: bool) { + let mut params: Vec = Vec::new(); + if with_self { + params.push("self".to_string()); + } + for param in &function.params { + let default = if param.optional { " = ..." } else { "" }; + params.push(format!( + "{}: {}{default}", + param.name, + param.ty.python_annotation() + )); + } + if function.variadic { + params.push("*args: Any".to_string()); + } + + let _ = writeln!( + out, + "{indent}def {}({}) -> {}:", + function.name, + params.join(", "), + function.return_annotation() + ); + if !function.doc.is_empty() { + let _ = writeln!(out, "{indent} \"\"\"{}\"\"\"", escape_doc(&function.doc)); + } + let _ = writeln!(out, "{indent} ..."); +} + +fn write_attribute_doc(out: &mut String, indent: &str, doc: &str) { + if !doc.is_empty() { + let _ = writeln!(out, "{indent}\"\"\"{}\"\"\"", escape_doc(doc)); + } +} + +fn escape_doc(doc: &str) -> String { + doc.replace('\\', "\\\\").replace("\"\"\"", "\\\"\\\"\\\"") +} diff --git a/crates/qsys_sema/Cargo.toml b/crates/qsys_sema/Cargo.toml index 4da6758..d61414c 100644 --- a/crates/qsys_sema/Cargo.toml +++ b/crates/qsys_sema/Cargo.toml @@ -9,6 +9,7 @@ qsys_model.workspace = true qsys_diagnostics.workspace = true qsys_parser.workspace = true qsys_ir.workspace = true +qsys_runtime.workspace = true strsim = { version = "0.11.1" } diff --git a/crates/qsys_sema/src/analyzer.rs b/crates/qsys_sema/src/analyzer.rs index 77a9ea9..f9f521e 100644 --- a/crates/qsys_sema/src/analyzer.rs +++ b/crates/qsys_sema/src/analyzer.rs @@ -3,60 +3,15 @@ use strsim::levenshtein; use qsys_diagnostics::{DiagnosticKind, Diagnostics}; use qsys_model::{ControlMember, PropertyKind}; use qsys_parser::ast; +use qsys_runtime::{ExecutionContext, GlobalKind, Member, Returns, RuntimeEnv}; use crate::binding::find_closest_member; use crate::symbols::{ScopeKind, ScopeStack, Symbol, SymbolKind}; use crate::types::{ResolvedTarget, Type, TypeMap}; -const KNOWN_GLOBALS: &[&str] = &[ - "print", - "qsys", - "Controls", - "Properties", - "Timer", - "TcpSocket", - "UdpSocket", - "SerialPort", - "HttpClient", - "NamedControl", - "Notifications", - "Component", - "Log", - "System", - "Crypto", - "rapidjson", - "json", - "math", - "string", - "table", - "bit32", - "tonumber", - "tostring", - "type", - "ipairs", - "pairs", - "next", - "unpack", - "pcall", - "error", - "assert", - "range", - "len", - "str", - "int", - "float", - "bool", - "abs", - "min", - "max", - "chr", - "ord", - "Control", - "Property", - "QSysPlugin", - "TextController", - "super", -]; +/// Names owned by the QSysPy declaration DSL (`class X(QSysPlugin)`, `Control.Float(...)`). +/// Everything else in the global scope comes from the `RuntimeEnv`. +const DSL_GLOBALS: &[&str] = &["Control", "Property", "QSysPlugin", "TextController"]; fn find_closest_match(target: &str, candidates: &[String]) -> Option { let mut best_match = None; @@ -85,6 +40,9 @@ pub struct ExprAnalyzer<'a> { pub scope_stack: &'a mut ScopeStack, pub type_map: &'a mut TypeMap, pub diagnostics: &'a mut Diagnostics, + pub env: &'a RuntimeEnv, + /// Execution phase of the code being analyzed, used to enforce global availability. + pub context: ExecutionContext, } impl<'a> ExprAnalyzer<'a> { @@ -92,14 +50,22 @@ impl<'a> ExprAnalyzer<'a> { scope_stack: &'a mut ScopeStack, type_map: &'a mut TypeMap, diagnostics: &'a mut Diagnostics, + env: &'a RuntimeEnv, ) -> Self { Self { scope_stack, type_map, diagnostics, + env, + context: ExecutionContext::Unknown, } } + pub fn with_context(mut self, context: ExecutionContext) -> Self { + self.context = context; + self + } + pub fn analyze_statements(&mut self, stmts: &[ast::Stmt]) { for stmt in stmts { self.analyze_stmt(stmt); @@ -280,7 +246,7 @@ impl<'a> ExprAnalyzer<'a> { Type::Any } } else { - crate::type_from_annotation(param.type_annotation.as_deref()) + crate::type_from_annotation(param.type_annotation.as_deref(), self.env) }; self.type_map .insert_symbol_name(param_sym_id, param.name.clone()); @@ -299,10 +265,13 @@ impl<'a> ExprAnalyzer<'a> { )); } + // A nested function body only runs when called, so its execution phase is unknown. + let outer_context = std::mem::replace(&mut self.context, ExecutionContext::Unknown); self.analyze_statements(&f.body); + self.context = outer_context; self.scope_stack.pop_scope(); - let mut ret_ty = crate::type_from_annotation(f.return_type.as_deref()); + let mut ret_ty = crate::type_from_annotation(f.return_type.as_deref(), self.env); if ret_ty == Type::Any { for body_stmt in &f.body { if let ast::StmtKind::Return(Some(ret_expr)) = &body_stmt.kind @@ -350,7 +319,8 @@ impl<'a> ExprAnalyzer<'a> { // Pre-register methods on the class so intra-class `self.method()` calls resolve cleanly for body_stmt in &c.body { if let ast::StmtKind::FunctionDef(f) = &body_stmt.kind { - let annotated_ret = crate::type_from_annotation(f.return_type.as_deref()); + let annotated_ret = + crate::type_from_annotation(f.return_type.as_deref(), self.env); self.type_map.insert_class_member_type( c.name.clone(), f.name.clone(), @@ -405,27 +375,40 @@ impl<'a> ExprAnalyzer<'a> { Type::UserClass { name: name.clone() } } } - } else if name == "UdpSocket" { - self.type_map - .insert_resolution(expr.span, ResolvedTarget::Global(name.clone())); - Type::UdpSocketClass - } else if name == "TcpSocket" { - self.type_map - .insert_resolution(expr.span, ResolvedTarget::Global(name.clone())); - Type::TcpSocketClass - } else if name == "Timer" { + } else if let Some(global) = self.env.global(name) { + if !global.availability.permits(self.context) { + self.diagnostics.report( + DiagnosticKind::UnavailableInContext { + name: name.clone(), + available: global.availability.describe().to_string(), + context: self.context.describe().to_string(), + }, + expr.span, + ); + } self.type_map .insert_resolution(expr.span, ResolvedTarget::Global(name.clone())); - Type::TimerClass - } else if KNOWN_GLOBALS.contains(&name.as_str()) { + match &global.kind { + GlobalKind::Value(ty) => Type::from_ref(ty), + GlobalKind::Function(_) => Type::Any, + } + } else if DSL_GLOBALS.contains(&name.as_str()) { self.type_map .insert_resolution(expr.span, ResolvedTarget::Global(name.clone())); Type::Any + } else if let Some(reason) = self.env.removed_reason(name) { + self.diagnostics.report( + DiagnosticKind::RemovedBuiltin { + name: name.clone(), + reason: reason.to_string(), + }, + expr.span, + ); + Type::Any } else { let mut visible = self.scope_stack.all_visible_names(); - for g in KNOWN_GLOBALS { - visible.push((*g).to_string()); - } + visible.extend(self.env.global_names().map(str::to_string)); + visible.extend(DSL_GLOBALS.iter().map(|g| (*g).to_string())); let candidates = find_closest_match(name, &visible) .map(|c| vec![c]) .unwrap_or_default(); @@ -600,6 +583,10 @@ impl<'a> ExprAnalyzer<'a> { Type::Any } } + Type::Native { ref name } => match self.env.member(name, attr) { + Some(Member::Field(field)) => Type::from_ref(&field.ty), + Some(Member::Method(_)) | None => Type::Any, + }, _ => Type::Any, } } @@ -607,26 +594,11 @@ impl<'a> ExprAnalyzer<'a> { ast::ExprKind::Subscript { value, index } => { let val_ty = self.infer_expr(value); let _ = self.infer_expr(index); - if let ast::ExprKind::Ident(ref name) = value.kind { - if name == "Controls" { - Type::ControlHandle - } else if name == "Properties" { - Type::PropertyHandle - } else { - match val_ty { - Type::List(elem) => *elem, - Type::Dict(_, val) => *val, - Type::String => Type::String, - _ => Type::Any, - } - } - } else { - match val_ty { - Type::List(elem) => *elem, - Type::Dict(_, val) => *val, - Type::String => Type::String, - _ => Type::Any, - } + match val_ty { + Type::List(elem) => *elem, + Type::Dict(_, val) => *val, + Type::String => Type::String, + _ => Type::Any, } } @@ -759,12 +731,12 @@ impl<'a> ExprAnalyzer<'a> { Type::UserInstance { name } } else if let ast::ExprKind::Attribute { value, attr } = &callee.kind { let receiver_ty = self.type_map.get_type(value.span).cloned(); - if attr == "New" { - match receiver_ty { - Some(Type::UdpSocketClass) => Type::UdpSocketHandle, - Some(Type::TcpSocketClass) => Type::TcpSocketHandle, - Some(Type::TimerClass) => Type::TimerHandle, - _ => Type::Any, + if let Some(Type::Native { ref name }) = receiver_ty { + match self.env.member(name, attr) { + Some(Member::Method(method)) => { + self.return_type_of(&method.returns, &arg_types) + } + Some(Member::Field(_)) | None => Type::Any, } } else if let Some( Type::UserInstance { ref name } | Type::ClassInstance { ref name }, @@ -781,51 +753,42 @@ impl<'a> ExprAnalyzer<'a> { "find" | "byte" => Type::Int, _ => Type::Any, } - } else if attr == "char" || attr == "rep" || attr == "format" || attr == "sub" { - Type::String - } else if attr == "byte" || attr == "len" { - Type::Int } else { Type::Any } } else if let ast::ExprKind::Ident(fn_name) = &callee.kind { - match fn_name.as_str() { - "str" | "tostring" | "chr" => Type::String, - "int" | "len" | "ord" => Type::Int, - "float" | "tonumber" => Type::Float, - "bool" => Type::Bool, - "range" => Type::List(Box::new(Type::Int)), - "abs" | "min" | "max" => { - if !arg_types.is_empty() && arg_types.iter().all(|t| *t == Type::Int) { - Type::Int - } else { - Type::Float + let is_runtime_global = matches!( + self.type_map.get_resolution(callee.span), + Some(ResolvedTarget::Global(_)) + ); + if is_runtime_global && fn_name == "super" { + if let Some(sym) = self.scope_stack.lookup("self") + && let SymbolKind::Variable { + ty: Type::UserInstance { ref name }, + .. } - } - "super" => { - if let Some(sym) = self.scope_stack.lookup("self") - && let SymbolKind::Variable { - ty: Type::UserInstance { ref name }, - .. - } - | SymbolKind::Parameter { - ty: Type::UserInstance { ref name }, - .. - } = sym.kind - && let Some(base_name) = self.type_map.get_class_base(name) - { - Type::UserInstance { - name: base_name.to_string(), - } - } else { - Type::Any + | SymbolKind::Parameter { + ty: Type::UserInstance { ref name }, + .. + } = sym.kind + && let Some(base_name) = self.type_map.get_class_base(name) + { + Type::UserInstance { + name: base_name.to_string(), } + } else { + Type::Any } - _ => self - .type_map + } else if is_runtime_global + && let Some(function) = + self.env.global(fn_name).and_then(|g| g.as_function()) + { + self.return_type_of(&function.returns, &arg_types) + } else { + self.type_map .get_function_return_type(fn_name) .cloned() - .unwrap_or(Type::Any), + .unwrap_or(Type::Any) } } else { Type::Any @@ -838,6 +801,19 @@ impl<'a> ExprAnalyzer<'a> { self.type_map.insert_type(expr.span, ty.clone()); ty } + + fn return_type_of(&self, returns: &Returns, arg_types: &[Type]) -> Type { + match returns { + Returns::Type(ty) => Type::from_ref(ty), + Returns::NumericPromotion => { + if !arg_types.is_empty() && arg_types.iter().all(|t| *t == Type::Int) { + Type::Int + } else { + Type::Float + } + } + } + } } fn is_qsys_lua_call(callee: &ast::Expr) -> bool { diff --git a/crates/qsys_sema/src/lib.rs b/crates/qsys_sema/src/lib.rs index 2eeb3cd..7535ae6 100644 --- a/crates/qsys_sema/src/lib.rs +++ b/crates/qsys_sema/src/lib.rs @@ -12,8 +12,9 @@ use rustc_hash::FxHashMap; use qsys_diagnostics::{Diagnostics, SourceFile, Span}; use qsys_ir::{FunctionId, SymbolId}; -use qsys_model::PluginInfo; +use qsys_model::{PluginInfo, is_design_time_method}; use qsys_parser::{ParsedModule, ast}; +use qsys_runtime::{ExecutionContext, RuntimeEnv}; use crate::analyzer::ExprAnalyzer; pub use crate::binding::{ResolvedBinding, resolve_event_decorator}; @@ -44,6 +45,8 @@ pub struct SemanticModel { pub file: Arc, pub target: TargetKind, pub type_map: TypeMap, + /// The runtime environment the program was analyzed against. + pub env: Arc, } #[derive(Debug, Clone)] @@ -90,7 +93,7 @@ pub struct ResolvedMethod { pub span: Span, } -pub(crate) fn type_from_annotation(ann: Option<&str>) -> Type { +pub(crate) fn type_from_annotation(ann: Option<&str>, env: &RuntimeEnv) -> Type { match ann { Some("str") | Some("String") => Type::String, Some("int") | Some("Integer") => Type::Int, @@ -99,6 +102,9 @@ pub(crate) fn type_from_annotation(ann: Option<&str>) -> Type { Some("None") => Type::None, Some("ControlHandle") => Type::ControlHandle, Some("PropertyHandle") => Type::PropertyHandle, + Some(name) if env.object_type(name).is_some() => Type::Native { + name: name.to_string(), + }, _ => Type::Any, } } @@ -106,7 +112,17 @@ pub(crate) fn type_from_annotation(ann: Option<&str>) -> Type { /// Fault-tolerant semantic analysis that always returns a `SemanticModel` alongside any diagnostics. /// Used by IDE / LSP features (`Analyzer`) so completions, hover, and inlay hints continue to work /// even when the user is mid-edit and diagnostics are present. +/// +/// Analyzes against the default Q-SYS runtime environment ([`RuntimeEnv::qsys`]). pub fn analyze_lossy(parsed: &ParsedModule) -> (SemanticModel, Diagnostics) { + analyze_lossy_with_env(parsed, RuntimeEnv::qsys()) +} + +/// Fault-tolerant semantic analysis against a custom runtime environment. +pub fn analyze_lossy_with_env( + parsed: &ParsedModule, + env: Arc, +) -> (SemanticModel, Diagnostics) { let mut diagnostics = Diagnostics::new(); // 1. Collect helper classes @@ -144,6 +160,7 @@ pub fn analyze_lossy(parsed: &ParsedModule) -> (SemanticModel, Diagnostics) { file: parsed.file.clone(), target: TargetKind::ProceduralScript(ScriptSemantic::default()), type_map, + env, }, diagnostics, ); @@ -155,8 +172,16 @@ pub fn analyze_lossy(parsed: &ParsedModule) -> (SemanticModel, Diagnostics) { classification, TargetClassification::Plugin { .. } | TargetClassification::TextController { .. } ) { + // A plugin's module-level statements are emitted before `PluginInfo`, so they execute + // in both the design-time and runtime phases. Text controllers only run at runtime. + let module_context = if matches!(classification, TargetClassification::Plugin { .. }) { + ExecutionContext::Both + } else { + ExecutionContext::Runtime + }; let mut module_analyzer = - ExprAnalyzer::new(&mut scope_stack, &mut type_map, &mut diagnostics); + ExprAnalyzer::new(&mut scope_stack, &mut type_map, &mut diagnostics, &env) + .with_context(module_context); for stmt in &parsed.module.statements { if let ast::StmtKind::ClassDef(cls) = &stmt.kind && cls @@ -276,7 +301,7 @@ pub fn analyze_lossy(parsed: &ParsedModule) -> (SemanticModel, Diagnostics) { let param_ty = if is_event_handler && idx == 1 { Type::ControlHandle } else { - type_from_annotation(param.type_annotation.as_deref()) + type_from_annotation(param.type_annotation.as_deref(), &env) }; let param_sym = scope_stack.alloc_symbol_id(); @@ -297,8 +322,18 @@ pub fn analyze_lossy(parsed: &ParsedModule) -> (SemanticModel, Diagnostics) { resolved_params.push((param_sym, param.name.clone())); } - let mut analyzer = - ExprAnalyzer::new(&mut scope_stack, &mut type_map, &mut diagnostics); + let method_context = if is_design_time_method(&f.name) { + ExecutionContext::DesignTime + } else { + ExecutionContext::Runtime + }; + let mut analyzer = ExprAnalyzer::new( + &mut scope_stack, + &mut type_map, + &mut diagnostics, + &env, + ) + .with_context(method_context); analyzer.analyze_statements(&f.body); scope_stack.pop_scope(); @@ -334,7 +369,8 @@ pub fn analyze_lossy(parsed: &ParsedModule) -> (SemanticModel, Diagnostics) { class.span, )); let mut analyzer = - ExprAnalyzer::new(&mut scope_stack, &mut type_map, &mut diagnostics); + ExprAnalyzer::new(&mut scope_stack, &mut type_map, &mut diagnostics, &env) + .with_context(ExecutionContext::Runtime); analyzer.analyze_statements(&init_statements); } @@ -442,7 +478,7 @@ pub fn analyze_lossy(parsed: &ParsedModule) -> (SemanticModel, Diagnostics) { let param_ty = if is_event_handler && idx == 1 { Type::ControlHandle } else { - type_from_annotation(param.type_annotation.as_deref()) + type_from_annotation(param.type_annotation.as_deref(), &env) }; let param_sym = scope_stack.alloc_symbol_id(); @@ -463,8 +499,13 @@ pub fn analyze_lossy(parsed: &ParsedModule) -> (SemanticModel, Diagnostics) { resolved_params.push((param_sym, param.name.clone())); } - let mut analyzer = - ExprAnalyzer::new(&mut scope_stack, &mut type_map, &mut diagnostics); + let mut analyzer = ExprAnalyzer::new( + &mut scope_stack, + &mut type_map, + &mut diagnostics, + &env, + ) + .with_context(ExecutionContext::Runtime); analyzer.analyze_statements(&f.body); scope_stack.pop_scope(); @@ -500,7 +541,8 @@ pub fn analyze_lossy(parsed: &ParsedModule) -> (SemanticModel, Diagnostics) { class.span, )); let mut analyzer = - ExprAnalyzer::new(&mut scope_stack, &mut type_map, &mut diagnostics); + ExprAnalyzer::new(&mut scope_stack, &mut type_map, &mut diagnostics, &env) + .with_context(ExecutionContext::Runtime); analyzer.analyze_statements(&init_statements); } @@ -554,7 +596,8 @@ pub fn analyze_lossy(parsed: &ParsedModule) -> (SemanticModel, Diagnostics) { let mut resolved_params = Vec::new(); for (idx, param) in f.params.iter().enumerate() { - let param_ty = type_from_annotation(param.type_annotation.as_deref()); + let param_ty = + type_from_annotation(param.type_annotation.as_deref(), &env); let param_sym = scope_stack.alloc_symbol_id(); type_map.insert_symbol_name(param_sym, param.name.clone()); scope_stack.define(Symbol::new( @@ -573,13 +616,18 @@ pub fn analyze_lossy(parsed: &ParsedModule) -> (SemanticModel, Diagnostics) { resolved_params.push((param_sym, param.name.clone())); } - let mut analyzer = - ExprAnalyzer::new(&mut scope_stack, &mut type_map, &mut diagnostics); + let mut analyzer = ExprAnalyzer::new( + &mut scope_stack, + &mut type_map, + &mut diagnostics, + &env, + ) + .with_context(ExecutionContext::Runtime); analyzer.analyze_statements(&f.body); scope_stack.pop_scope(); - let mut ret_ty = type_from_annotation(f.return_type.as_deref()); + let mut ret_ty = type_from_annotation(f.return_type.as_deref(), &env); if ret_ty == Type::Any { for body_stmt in &f.body { if let ast::StmtKind::Return(Some(ret_expr)) = &body_stmt.kind @@ -602,15 +650,25 @@ pub fn analyze_lossy(parsed: &ParsedModule) -> (SemanticModel, Diagnostics) { }); } ast::StmtKind::ClassDef(_) => { - let mut analyzer = - ExprAnalyzer::new(&mut scope_stack, &mut type_map, &mut diagnostics); + let mut analyzer = ExprAnalyzer::new( + &mut scope_stack, + &mut type_map, + &mut diagnostics, + &env, + ) + .with_context(ExecutionContext::Runtime); analyzer.analyze_stmt(stmt); top_level_statements.push(stmt.clone()); } ast::StmtKind::Import { .. } | ast::StmtKind::ImportFrom { .. } => {} _ => { - let mut analyzer = - ExprAnalyzer::new(&mut scope_stack, &mut type_map, &mut diagnostics); + let mut analyzer = ExprAnalyzer::new( + &mut scope_stack, + &mut type_map, + &mut diagnostics, + &env, + ) + .with_context(ExecutionContext::Runtime); analyzer.analyze_stmt(stmt); top_level_statements.push(stmt.clone()); } @@ -632,13 +690,23 @@ pub fn analyze_lossy(parsed: &ParsedModule) -> (SemanticModel, Diagnostics) { file: parsed.file.clone(), target, type_map, + env, }, diagnostics, ) } +/// Strict semantic analysis against the default Q-SYS runtime environment. pub fn analyze(parsed: &ParsedModule) -> Result { - let (model, diagnostics) = analyze_lossy(parsed); + analyze_with_env(parsed, RuntimeEnv::qsys()) +} + +/// Strict semantic analysis against a custom runtime environment. +pub fn analyze_with_env( + parsed: &ParsedModule, + env: Arc, +) -> Result { + let (model, diagnostics) = analyze_lossy_with_env(parsed, env); if diagnostics.has_errors() { Err(diagnostics) } else { diff --git a/crates/qsys_sema/src/lower.rs b/crates/qsys_sema/src/lower.rs index 61fe288..751ea6d 100644 --- a/crates/qsys_sema/src/lower.rs +++ b/crates/qsys_sema/src/lower.rs @@ -1,5 +1,6 @@ use qsys_ir::*; use qsys_parser::ast; +use qsys_runtime::{CallStyle, Lowering, RuntimeEnv}; use crate::types::{ResolvedTarget, Type}; use crate::{SemanticModel, TargetKind, TruthinessStrategy, TypeMap}; @@ -7,7 +8,7 @@ use crate::{SemanticModel, TargetKind, TruthinessStrategy, TypeMap}; pub fn lower_to_ir(model: &SemanticModel) -> IRProgram { match &model.target { TargetKind::Plugin(p) => { - let mut lowerer = StmtLowerer::new(&model.type_map, true, &p.helper_classes); + let mut lowerer = StmtLowerer::new(model, true, &p.helper_classes); let classes = p .helper_classes @@ -74,7 +75,7 @@ pub fn lower_to_ir(model: &SemanticModel) -> IRProgram { } TargetKind::TextController(c) => { - let mut lowerer = StmtLowerer::new(&model.type_map, false, &c.helper_classes); + let mut lowerer = StmtLowerer::new(model, false, &c.helper_classes); let classes = c .helper_classes @@ -130,7 +131,7 @@ pub fn lower_to_ir(model: &SemanticModel) -> IRProgram { } TargetKind::ProceduralScript(s) => { - let mut lowerer = StmtLowerer::new(&model.type_map, false, &s.classes); + let mut lowerer = StmtLowerer::new(model, false, &s.classes); let classes = s .classes @@ -163,15 +164,17 @@ pub fn lower_to_ir(model: &SemanticModel) -> IRProgram { struct StmtLowerer<'a> { type_map: &'a TypeMap, + env: &'a RuntimeEnv, is_plugin: bool, custom_classes: &'a [ast::ClassDef], current_class_base: Option, } impl<'a> StmtLowerer<'a> { - fn new(type_map: &'a TypeMap, is_plugin: bool, custom_classes: &'a [ast::ClassDef]) -> Self { + fn new(model: &'a SemanticModel, is_plugin: bool, custom_classes: &'a [ast::ClassDef]) -> Self { Self { - type_map, + type_map: &model.type_map, + env: &model.env, is_plugin, custom_classes, current_class_base: None, @@ -548,61 +551,45 @@ impl<'a> StmtLowerer<'a> { }; } - // Python built-in function lowering - if let ast::ExprKind::Ident(fn_name) = &callee.kind { - match fn_name.as_str() { - "len" if args_ir.len() == 1 && kwargs_ir.is_empty() => { + // Builtin lowering declared by the runtime env (e.g. `len(x)` -> `#x`). + // Only applies when the name resolved to the runtime global, so user + // definitions that shadow a builtin are called as written. + if let ast::ExprKind::Ident(fn_name) = &callee.kind + && matches!( + self.type_map.get_resolution(callee.span), + Some(ResolvedTarget::Global(_)) + ) + && let Some(function) = + self.env.global(fn_name).and_then(|g| g.as_function()) + { + match &function.lowering { + Lowering::LengthOperator + if args_ir.len() == 1 && kwargs_ir.is_empty() => + { return IRExpr::Unary { op: IRUnaryOp::Len, operand: Box::new(args_ir.remove(0)), truthiness: IRTruthiness::NumericNotZero, }; } - "str" => { - return IRExpr::Call { - callee: Box::new(IRExpr::Global("tostring".to_string())), - args: args_ir, - kwargs: kwargs_ir, - }; - } - "int" | "float" => { + Lowering::Rename(target) => { return IRExpr::Call { - callee: Box::new(IRExpr::Global("tonumber".to_string())), + callee: Box::new(IRExpr::Global(target.clone())), args: args_ir, kwargs: kwargs_ir, }; } - "abs" | "min" | "max" => { + Lowering::Qualified { module, name } => { return IRExpr::Call { callee: Box::new(IRExpr::Attribute { - object: Box::new(IRExpr::Global("math".to_string())), - attr: fn_name.clone(), + object: Box::new(IRExpr::Global(module.clone())), + attr: name.clone(), }), args: args_ir, kwargs: kwargs_ir, }; } - "chr" => { - return IRExpr::Call { - callee: Box::new(IRExpr::Attribute { - object: Box::new(IRExpr::Global("string".to_string())), - attr: "char".to_string(), - }), - args: args_ir, - kwargs: kwargs_ir, - }; - } - "ord" => { - return IRExpr::Call { - callee: Box::new(IRExpr::Attribute { - object: Box::new(IRExpr::Global("string".to_string())), - attr: "byte".to_string(), - }), - args: args_ir, - kwargs: kwargs_ir, - }; - } - _ => {} + Lowering::LengthOperator | Lowering::Direct => {} } } @@ -688,16 +675,17 @@ impl<'a> StmtLowerer<'a> { let obj_ty = self.type_map.get_type(value.span); - // Socket, Timer, or custom class instance method call -> `obj:method(...)` - if matches!( - obj_ty, - Some( - Type::UdpSocketHandle - | Type::TcpSocketHandle - | Type::TimerHandle - | Type::UserInstance { .. } - ) - ) { + // Native runtime objects use the call style declared by the runtime env + // (`timer:Start(1)` for handles, `Timer.New()` for factories); custom class + // instances always dispatch with `obj:method(...)`. + let uses_colon = match obj_ty { + Some(Type::Native { name }) => { + self.env.call_style(name, attr) == CallStyle::Colon + } + Some(Type::UserInstance { .. }) => true, + _ => false, + }; + if uses_colon { return IRExpr::MethodCall { object: Box::new(self.lower_expr(value)), method: attr.clone(), diff --git a/crates/qsys_sema/src/types.rs b/crates/qsys_sema/src/types.rs index 929cb19..6adfa6e 100644 --- a/crates/qsys_sema/src/types.rs +++ b/crates/qsys_sema/src/types.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use qsys_diagnostics::Span; use qsys_ir::{ControlId, FunctionId, PropertyId, SymbolId}; +use qsys_runtime::TypeRef; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ResolvedTarget { @@ -27,17 +28,20 @@ pub enum Type { ControlHandle, PropertyHandle, - // Q-SYS Native Runtime Objects - UdpSocketClass, - UdpSocketHandle, - TcpSocketClass, - TcpSocketHandle, - TimerClass, - TimerHandle, - - ClassInstance { name: String }, // QSysPlugin instance (`self`) - UserClass { name: String }, // Custom class constructor (e.g. `Device`) - UserInstance { name: String }, // Custom class instance object (e.g. `dev = Device(...)`) + /// A native runtime object described by the `RuntimeEnv` (e.g. `Timer`, `TimerHandle`, `math`). + Native { + name: String, + }, + + ClassInstance { + name: String, + }, // QSysPlugin instance (`self`) + UserClass { + name: String, + }, // Custom class constructor (e.g. `Device`) + UserInstance { + name: String, + }, // Custom class instance object (e.g. `dev = Device(...)`) Any, } @@ -47,13 +51,32 @@ impl Type { matches!(self, Self::Int | Self::Float) } + /// Converts a runtime plugin type reference into a semantic type. + pub fn from_ref(ty: &TypeRef) -> Self { + match ty { + TypeRef::Any | TypeRef::Callable(_) => Self::Any, + TypeRef::None => Self::None, + TypeRef::Bool => Self::Bool, + TypeRef::Int => Self::Int, + TypeRef::Float => Self::Float, + TypeRef::Str => Self::String, + TypeRef::List(elem) => Self::List(Box::new(Self::from_ref(elem))), + TypeRef::Dict(k, v) => { + Self::Dict(Box::new(Self::from_ref(k)), Box::new(Self::from_ref(v))) + } + TypeRef::Object(name) => match name.as_str() { + "ControlHandle" => Self::ControlHandle, + "PropertyHandle" => Self::PropertyHandle, + _ => Self::Native { name: name.clone() }, + }, + } + } + pub fn truthiness_strategy(&self) -> TruthinessStrategy { match self { Self::Bool | Self::ControlHandle - | Self::UdpSocketHandle - | Self::TcpSocketHandle - | Self::TimerHandle + | Self::Native { .. } | Self::ClassInstance { .. } | Self::UserInstance { .. } => TruthinessStrategy::Direct, @@ -61,12 +84,9 @@ impl Type { Self::String => TruthinessStrategy::StringNonEmpty, Self::List(_) | Self::Dict(_, _) => TruthinessStrategy::CollectionNonEmpty, Self::None => TruthinessStrategy::AlwaysFalse, - Self::Any - | Self::PropertyHandle - | Self::UdpSocketClass - | Self::TcpSocketClass - | Self::TimerClass - | Self::UserClass { .. } => TruthinessStrategy::Dynamic, + Self::Any | Self::PropertyHandle | Self::UserClass { .. } => { + TruthinessStrategy::Dynamic + } } } } diff --git a/crates/qsys_sema/tests/runtime_env_tests.rs b/crates/qsys_sema/tests/runtime_env_tests.rs new file mode 100644 index 0000000..3f39b8d --- /dev/null +++ b/crates/qsys_sema/tests/runtime_env_tests.rs @@ -0,0 +1,177 @@ +use std::sync::Arc; + +use qsys_diagnostics::{Diagnostics, FileId, SourceFile}; +use qsys_parser::parse_module; +use qsys_runtime::{ + EnvBuilder, Function, Global, LuaStdlib, PythonCore, QSysRuntime, RuntimeEnv, RuntimePlugin, + TypeRef, +}; +use qsys_sema::{SemanticModel, Type, analyze, analyze_with_env}; + +fn analyze_src(src: &str) -> Result { + let file = SourceFile::new(FileId(1), "test.py", src); + analyze(&parse_module(file).expect("Should parse")) +} + +fn analyze_src_with(src: &str, env: Arc) -> Result { + let file = SourceFile::new(FileId(1), "test.py", src); + analyze_with_env(&parse_module(file).expect("Should parse"), env) +} + +#[test] +fn controls_are_rejected_in_design_time_methods() { + let src = r#" +class Mixer(QSysPlugin): + def get_control_layout(self, props): + x = Controls["Gain"] + return {} +"#; + let diags = analyze_src(src).expect_err("Controls is runtime-only"); + assert_eq!(diags.items.len(), 1); + assert_eq!(diags.items[0].code(), "E0208"); + assert_eq!( + diags.items[0].message(), + "'Controls' is only available at runtime, but is used in design-time code" + ); +} + +#[test] +fn controls_are_rejected_in_plugin_module_level_code() { + let src = r#" +gain = Controls["Gain"] + +class Mixer(QSysPlugin): + pass +"#; + let diags = analyze_src(src).expect_err("Module-level plugin code also runs at design time"); + assert_eq!(diags.items[0].code(), "E0208"); + assert!( + diags.items[0] + .message() + .contains("both design time and runtime") + ); +} + +#[test] +fn controls_are_allowed_in_runtime_code_and_deferred_functions() { + let src = r#" +def read_gain(): + return Controls["Gain"].Value + +class Helper: + def poll(self): + return Properties["Port"].Value + +class Mixer(QSysPlugin): + def runtime(self): + Controls["Gain"].Value = 0.0 + t = Timer.New() +"#; + analyze_src(src).expect("Runtime methods and function bodies may use runtime-only globals"); +} + +#[test] +fn controls_are_allowed_in_text_controller_module_code() { + let src = r#" +gain = Controls["Gain"] + +class Room(TextController): + pass +"#; + analyze_src(src).expect("Text controllers only execute at runtime"); +} + +#[test] +fn removed_builtins_report_the_plugin_reason() { + let diags = analyze_src("x = eval(\"1 + 1\")\n").expect_err("eval is removed by PythonCore"); + assert_eq!(diags.items[0].code(), "E0209"); + assert_eq!( + diags.items[0].message(), + "'eval' is not available on this target: dynamic code execution cannot be compiled to Lua ahead of time" + ); + + let diags = analyze_src("name = input()\n").expect_err("input is removed by QSysRuntime"); + assert_eq!(diags.items[0].code(), "E0209"); + assert!(diags.items[0].message().contains("no standard input")); +} + +#[test] +fn user_definitions_shadow_removed_builtins() { + let src = r#" +def eval(x): + return x + +y = eval(1) +"#; + analyze_src(src).expect("A user-defined `eval` is not the removed builtin"); +} + +#[test] +fn native_types_flow_from_the_runtime_env() { + let src = r#" +t = Timer.New() +n = math.floor(2.5) +"#; + let model = analyze_src(src).unwrap(); + let types: Vec<&Type> = model.type_map.types().values().collect(); + assert!(types.contains(&&Type::Native { + name: "TimerHandle".to_string() + })); + assert!(types.contains(&&Type::Native { + name: "Timer".to_string() + })); + assert!(types.contains(&&Type::Int), "math.floor returns int"); +} + +#[test] +fn annotations_naming_native_types_are_understood() { + let src = r#" +def restart(t: TimerHandle): + t.Start(1.0) +"#; + let model = analyze_src(src).unwrap(); + assert!(model.type_map.types().values().any(|t| *t + == Type::Native { + name: "TimerHandle".to_string() + })); +} + +struct Telemetry; + +impl RuntimePlugin for Telemetry { + fn name(&self) -> &'static str { + "telemetry" + } + + fn install(&self, env: &mut EnvBuilder) { + env.add_global(Global::function( + Function::new("trace") + .param("message", TypeRef::Str) + .returns(TypeRef::Bool), + )); + env.remove("Timer", "timers are disabled on this target"); + } +} + +#[test] +fn custom_plugins_can_add_and_remove_globals() { + let env = Arc::new( + RuntimeEnv::builder() + .install(&PythonCore) + .install(&LuaStdlib) + .install(&QSysRuntime) + .install(&Telemetry) + .build(), + ); + + let model = analyze_src_with("ok = trace(\"hello\")\n", env.clone()) + .expect("trace() is provided by the Telemetry plugin"); + assert!(model.type_map.types().values().any(|t| *t == Type::Bool)); + + let diags = analyze_src_with("t = Timer.New()\n", env).expect_err("Timer was removed"); + assert_eq!(diags.items[0].code(), "E0209"); + assert!(diags.items[0].message().contains("timers are disabled")); + + let diags = analyze_src("ok = trace(\"hello\")\n").expect_err("Not in the default env"); + assert_eq!(diags.items[0].code(), "E0206"); +}