diff --git a/AGENTS.md b/AGENTS.md index c2804610..35e6f896 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -235,16 +235,17 @@ Input -> Output`. The signature part `Input -> Output` is optional. The `:` `@waiter + ^milliways`); the special `@*` resets the scope. Attributes are effectively parallel steps and create scopes within which parallel or dependent steps can be nested. -- This is invalid: +- This is well-formed but semantically incorrect: ```technique invalid : - Top level parallel step - nested parallel substep ``` - -because there is no way for the parser to differentiate between the two. - + the author's intent was to nest parallel steps but this is not possible to + articulate in Technique because; there is no way for the parser to + differentiate between the two. Unfortunately we cannot generate an error for + this because of being whitespace agnostic. - The free form descriptive text can be escaped to code using an inline code block, delimited with braces `{ ... }` - Within code blocks there are basic control flow: `repeat`, `foreach` loops. - Within code blocks there are function calls to builtin functions: `exec()` diff --git a/Cargo.lock b/Cargo.lock index b6b4faae..6daddeab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -337,9 +337,9 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lsp-server" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad8be6fe0ca81b8298bfbbe8a77e9fcd8895ad6c84cd7794d5ebadcbb09ae43" +checksum = "62e55c013e58520c3471c904b6a4b95367acb73f20e718e1a0026d4297c9fc8e" dependencies = [ "crossbeam-channel", "log", @@ -686,7 +686,7 @@ dependencies = [ [[package]] name = "technique" -version = "0.6.5" +version = "0.6.6" dependencies = [ "clap", "crossterm", @@ -696,10 +696,8 @@ dependencies = [ "nix", "owo-colors", "regex", - "serde", "serde_json", "time", - "tinytemplate", "tracing", "tracing-subscriber", ] @@ -734,7 +732,6 @@ dependencies = [ "powerfmt", "serde_core", "time-core", - "time-macros", ] [[package]] @@ -743,26 +740,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" -[[package]] -name = "time-macros" -version = "0.2.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "tracing" version = "0.1.44" diff --git a/Cargo.toml b/Cargo.toml index 73e0d85e..f6527037 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "technique" -version = "0.6.5" +version = "0.6.6" edition = "2024" description = "A domain specific language for procedures." authors = [ "Andrew Cowie" ] @@ -11,14 +11,12 @@ license = "MIT" clap = { version = "4.5.16", features = [ "wrap_help" ] } crossterm = "0.29" ignore = "0.4" -lsp-server = "0.8.0" +lsp-server = "0.9.0" lsp-types = "0.97" owo-colors = "4" regex = "1.11.1" -serde = { version = "1.0.209", features = [ "derive" ] } serde_json = "1.0" -time = { version = "0.3", features = [ "formatting" ] } -tinytemplate = "1.2.1" +time = "0.3" tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = [ "env-filter" ] } diff --git a/src/editor/server.rs b/src/editor/server.rs index 2d5bca4b..a5a088cc 100644 --- a/src/editor/server.rs +++ b/src/editor/server.rs @@ -880,8 +880,176 @@ fn offset_to_position(text: &str, offset: usize) -> Position { #[cfg(test)] mod tests { use super::*; + use lsp_server::{ErrorCode, RequestId, ResponseKind}; + use lsp_types::TextDocumentItem; + use std::cell::RefCell; + use std::convert::Infallible; use technique::language::Span; + fn collecting_sender( + messages: &RefCell>, + ) -> impl Fn(Message) -> Result<(), Infallible> + '_ { + move |msg| { + messages + .borrow_mut() + .push(msg); + Ok(()) + } + } + + #[test] + fn test_handle_request_unknown_method_returns_error() { + let mut server = TechniqueLanguageServer::new(InitializeParams::default()); + let messages = RefCell::new(Vec::new()); + let sender = collecting_sender(&messages); + + let request = Request::new( + RequestId::from(1), + "textDocument/nonsense".to_string(), + Value::Null, + ); + server + .handle_request(request, &sender) + .unwrap(); + drop(sender); + + let messages = messages.into_inner(); + assert_eq!(messages.len(), 1); + match &messages[0] { + Message::Response(response) => { + assert_eq!(response.id, RequestId::from(1)); + match &response.response_kind { + ResponseKind::Err { error } => { + assert_eq!(error.code, ErrorCode::MethodNotFound as i32); + } + ResponseKind::Ok { .. } => panic!("expected an error response"), + } + } + _ => panic!("expected a Response message"), + } + } + + #[test] + fn test_handle_request_shutdown_returns_ok() { + let mut server = TechniqueLanguageServer::new(InitializeParams::default()); + let messages = RefCell::new(Vec::new()); + let sender = collecting_sender(&messages); + + let request = Request::new(RequestId::from(2), "shutdown".to_string(), Value::Null); + server + .handle_request(request, &sender) + .unwrap(); + drop(sender); + + let messages = messages.into_inner(); + assert_eq!(messages.len(), 1); + match &messages[0] { + Message::Response(response) => { + assert_eq!(response.id, RequestId::from(2)); + match &response.response_kind { + ResponseKind::Ok { result } => assert_eq!(*result, Value::Null), + ResponseKind::Err { .. } => panic!("expected an ok response"), + } + } + _ => panic!("expected a Response message"), + } + } + + #[test] + fn test_handle_notification_did_open_publishes_diagnostics() { + let mut server = TechniqueLanguageServer::new(InitializeParams::default()); + let messages = RefCell::new(Vec::new()); + let sender = collecting_sender(&messages); + + let uri: Uri = "file:///Broken.tq" + .parse() + .unwrap(); + let text = "making_coffee : Ingredients Coffee\n\nmakingCoffee :\n".to_string(); + let params = DidOpenTextDocumentParams { + text_document: TextDocumentItem::new(uri, "technique".to_string(), 1, text), + }; + let notification = Notification::new( + "textDocument/didOpen".to_string(), + to_value(params).unwrap(), + ); + server + .handle_notification(notification, &sender) + .unwrap(); + drop(sender); + + let messages = messages.into_inner(); + assert_eq!(messages.len(), 1); + match &messages[0] { + Message::Notification(notification) => { + assert_eq!(notification.method, "textDocument/publishDiagnostics"); + let params: PublishDiagnosticsParams = from_value( + notification + .params + .clone(), + ) + .unwrap(); + assert!( + !params + .diagnostics + .is_empty() + ); + } + _ => panic!("expected a Notification message"), + } + } + + #[test] + fn test_workspace_symbol_finds_procedure_indexed_from_workspace_folder() { + let uri: Uri = format!("file://{}/examples/minimal", env!("CARGO_MANIFEST_DIR")) + .parse() + .unwrap(); + let params = InitializeParams { + workspace_folders: Some(vec![WorkspaceFolder { + uri, + name: "minimal".to_string(), + }]), + ..Default::default() + }; + let mut server = TechniqueLanguageServer::new(params); + let messages = RefCell::new(Vec::new()); + let sender = collecting_sender(&messages); + + let request = Request::new( + RequestId::from(3), + "workspace/symbol".to_string(), + to_value(WorkspaceSymbolParams { + query: "making_coffee".to_string(), + ..Default::default() + }) + .unwrap(), + ); + server + .handle_request(request, &sender) + .unwrap(); + drop(sender); + + let messages = messages.into_inner(); + assert_eq!(messages.len(), 1); + match &messages[0] { + Message::Response(response) => match &response.response_kind { + ResponseKind::Ok { result } => { + let symbols: Option> = + from_value(result.clone()).unwrap(); + let symbols = symbols.unwrap(); + assert!( + symbols + .iter() + .any(|symbol| symbol + .name + .starts_with("making_coffee")) + ); + } + ResponseKind::Err { .. } => panic!("expected an ok response"), + }, + _ => panic!("expected a Response message"), + } + } + #[test] fn test_calculate_str_offsets() { let parent = "hello world, this is a test"; diff --git a/src/parsing/checks/parser.rs b/src/parsing/checks/parser.rs index 493d9df0..f92b324e 100644 --- a/src/parsing/checks/parser.rs +++ b/src/parsing/checks/parser.rs @@ -2280,6 +2280,32 @@ fn tuple_binding_expression() { ); } +#[test] +fn binding_followed_by_use_in_same_code_block() { + let mut input = Parser::new(); + input.initialize("{ now() ~ t ; t }"); + + let result = input.read_code_block(); + assert_eq!( + result, + Ok(vec![ + Expression::Binding( + Box::new(Expression::Execution( + Function { + target: Identifier::new("now"), + parameters: vec![] + }, + Span::default() + )), + vec![Identifier::new("t")], + Span::default() + ), + Expression::Separator, + Expression::Variable(Identifier::new("t"), Span::default()) + ]) + ); +} + #[test] fn test_repeat_expression() { let mut input = Parser::new(); diff --git a/src/parsing/parser.rs b/src/parsing/parser.rs index a2f3800f..b4065418 100644 --- a/src/parsing/parser.rs +++ b/src/parsing/parser.rs @@ -3320,7 +3320,8 @@ fn is_function(content: &str) -> bool { } fn is_binding(content: &str) -> bool { - let re = regex!(r"~\s+([a-z][a-z0-9_]*|\([a-z][a-z0-9_]*(?:\s*,\s*[a-z][a-z0-9_]*)*\))\s*$"); + let re = + regex!(r"~\s+([a-z][a-z0-9_]*|\([a-z][a-z0-9_]*(?:\s*,\s*[a-z][a-z0-9_]*)*\))\s*(;|$)"); re.is_match(content) } diff --git a/src/runner/context.rs b/src/runner/context.rs index 9409130f..25e98756 100644 --- a/src/runner/context.rs +++ b/src/runner/context.rs @@ -93,7 +93,6 @@ impl Context { /// Pass a complete, already known, text string through to the user. This /// is just a convenience over `write()` for whole messages such as status /// lines or announcements. - #[allow(dead_code)] pub fn emit(&self, message: &str) -> io::Result<()> { self.write(message.as_bytes()) } diff --git a/src/runner/driver.rs b/src/runner/driver.rs index 619eb532..5719d656 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -66,7 +66,7 @@ pub enum Kind { Prose, // descriptive text; unit value Computable, // a value will be computed with no external side effect // (invoke, bind, literal, Pure builtin) - System, // an external command the host runs, e.g exec() + System, // something the host runs, e.g. exec(), now() Action, // an act a human performs e.g. click()) Choice, // a response the user must select } diff --git a/src/runner/library.rs b/src/runner/library.rs index cace66ee..f369ba30 100644 --- a/src/runner/library.rs +++ b/src/runner/library.rs @@ -22,15 +22,16 @@ use crate::value::{Numeric, Value}; /// it. pub type Native = fn(&Context, &[Value]) -> Result; -/// How a builtin is presented to the user. `Pure` computes a value and just -/// runs. `Command` is executed by the host environment; the user vets it on an -/// editable prompt before it runs (`exec`). `Action` is a physical interaction -/// the user performs themselves (`click`, `select`): shown read-only to -/// confirm, never edited. +/// How a builtin is presented to the user. `Pure` just runs. `Command` (e.g. +/// `exec()`) is host-run and vetted on an editable prompt. `Instant` +/// (`now()`) is host-run too but has nothing to vet and can't fail, so it +/// runs unvetted while still being traced like `Command`. `Action`s (such as +/// `click()`, `select()`) are a physical step the user confirms read-only. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Nature { Pure, Command, + Instant, Action, } @@ -117,7 +118,7 @@ impl Library { name: "now", display: None, arity: 0, - nature: Nature::Pure, + nature: Nature::Instant, function: now, }, ] @@ -176,8 +177,8 @@ impl Library { self.functions[id.0].display } - /// How the function at `id` is presented: `Pure`, host `Command`, or - /// physical `Action`. + /// How the function at `id` is presented: `Pure`, host `Command` or + /// `Instant`, or physical `Action`. pub fn nature(&self, id: ExecutableId) -> Nature { self.functions[id.0].nature } @@ -461,10 +462,14 @@ fn tee( Ok(()) } -/// `now()` — the current wall-clock time as an ISO 8601 string. A read of -/// external state, hence part of the system layer rather than `core`. -fn now(_context: &Context, _args: &[Value]) -> Result { - Ok(Value::Literali(super::runner::now_iso8601())) +/// `now()` — the current wall-clock time as an ISO 8601 string, emitted +/// through Context the same way `exec` tees its output. +fn now(context: &Context, _args: &[Value]) -> Result { + let text = super::runner::now_iso8601(); + context + .emit(&format!("{}\n", text)) + .map_err(RunnerError::ExecError)?; + Ok(Value::Literali(text)) } /// A browser-library action: the user performs the UI manipulation when the @@ -549,7 +554,7 @@ impl Library { name: "now", display: None, arity: 0, - nature: Nature::Pure, + nature: Nature::Instant, function: unit, }, Builtin { diff --git a/src/runner/runner.rs b/src/runner/runner.rs index ca73e7a4..0307a794 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -363,13 +363,13 @@ impl<'i, D: Driver> Runner<'i, D> { let run_id = self .appender .run_id(); - // A `Command` builtin (e.g. `exec`) is executed by the host; - // the user vets it: show the editable script and run it only on - // their say-so. An `Action` (e.g. `click`) is a physical - // interaction the user performs themselves: show the call - // read-only to confirm. Either way Skip or Fail declines and - // records the step; Quit stops. `Pure` builtins just announce - // and run. + // `Command` (e.g. `exec()`) and `Instant` (e.g. `now()`) + // builtins run on the host; `Command` is vetted on an + // editable prompt, `Instant` runs unvetted (see below). + // `Action` is a physical step the user confirms read-only. + // Either way Skip or Fail declines and records the step; Quit + // stops. `Pure` builtins just announce and run. + let nature = self.executable_nature(executable); let kind = self.execute_kind(executable); // Pure builtins record nothing; only effectful calls are traced. let effectful = if let Kind::Computable = kind { @@ -389,6 +389,18 @@ impl<'i, D: Driver> Runner<'i, D> { })?; } let outcome = match kind { + // Nothing to vet, and cannot fail, so we skip the command + // prompt. + Kind::System if nature == Nature::Instant => { + let value = super::evaluator::dispatch( + &self.library, + &self.context, + env, + executable, + None, + )?; + Ok(Conclusion::Completed(Outcome::Done(value))) + } Kind::System => { let script = self.script_text(env, executable)?; match self @@ -1771,18 +1783,22 @@ impl<'i, D: Driver> Runner<'i, D> { Ok(Conclusion::Stopping) } - /// Classify an `Execute` by its builtin's `Nature`, resolving the target as - /// the dispatch does. - fn execute_kind(&self, exec: &Executable) -> Kind { - let nature = match &exec.target { + /// The Nature of an Executable's resolved target; `Pure` if unresolved. + fn executable_nature(&self, exec: &Executable) -> Nature { + match &exec.target { ExecutableRef::Resolved(id) => self .library .nature(*id), _ => Nature::Pure, - }; - match nature { + } + } + + /// Classify an `Execute` by its builtin's `Nature`, resolving the target as + /// the dispatch does. + fn execute_kind(&self, exec: &Executable) -> Kind { + match self.executable_nature(exec) { Nature::Pure => Kind::Computable, - Nature::Command => Kind::System, + Nature::Command | Nature::Instant => Kind::System, Nature::Action => Kind::Action, } } diff --git a/tests/golden/runner/Century.pfftt b/tests/golden/runner/Century.pfftt new file mode 100644 index 00000000..985c260f --- /dev/null +++ b/tests/golden/runner/Century.pfftt @@ -0,0 +1,8 @@ +2026-07-15T09:14:13.639Z 000114 / Start file:///home/andrew/src/technique-lang/technique/tests/golden/runner/Century.tq?library=system +2026-07-15T09:14:13.640Z 000114 /twenty_first_century: Begin +2026-07-15T09:14:13.640Z 000114 /twenty_first_century:/1 Begin +2026-07-15T09:14:13.640Z 000114 /twenty_first_century:/1 Execute exec() +2026-07-15T09:14:13.652Z 000114 /twenty_first_century:/1 Return "20" +2026-07-15T09:14:13.652Z 000114 /twenty_first_century:/1 Done "20" +2026-07-15T09:14:13.652Z 000114 /twenty_first_century: Done "20" +2026-07-15T09:14:13.652Z 000114 / Finish diff --git a/tests/golden/runner/Century.tq b/tests/golden/runner/Century.tq new file mode 100644 index 00000000..fc79b060 --- /dev/null +++ b/tests/golden/runner/Century.tq @@ -0,0 +1,7 @@ +% technique v1 + +twenty_first_century : + +Unfortunately this test will fail in the Twenty-Second century. + + 1. This should output "20" { exec("date -u +%C") ~ t ; t }