diff --git a/Cargo.lock b/Cargo.lock index e6bf7d9..ae7b60d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.9.4" @@ -107,6 +113,32 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "errno" version = "0.3.13" @@ -117,6 +149,122 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -147,12 +295,44 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + [[package]] name = "log" version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "lsp-server" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6ada348dbc2703cbe7637b2dda05cff84d3da2819c24abcb305dd613e0ba2e" +dependencies = [ + "crossbeam-channel", + "log", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "lsp-types" +version = "0.95.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e34d33a8e9b006cd3fc4fe69a921affa097bae4bb65f76271f4644f9a334365" +dependencies = [ + "bitflags 1.3.2", + "serde", + "serde_json", + "serde_repr", + "url", +] + [[package]] name = "matchers" version = "0.2.0" @@ -195,12 +375,27 @@ version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.101" @@ -254,7 +449,7 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys", @@ -299,6 +494,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -314,6 +520,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "strsim" version = "0.11.1" @@ -331,14 +543,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "technique" -version = "0.4.2" +version = "0.4.3" dependencies = [ "clap", + "lsp-server", + "lsp-types", "owo-colors", "regex", "serde", + "serde_json", "tinytemplate", "tracing", "tracing-subscriber", @@ -363,6 +589,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinytemplate" version = "1.2.1" @@ -440,6 +676,24 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -604,3 +858,87 @@ name = "windows_x86_64_msvc" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 41df1e4..309d028 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "technique" -version = "0.4.2" +version = "0.4.3" edition = "2021" description = "A domain specific language for procedures." authors = [ "Andrew Cowie" ] @@ -9,10 +9,14 @@ license = "MIT" [dependencies] clap = { version = "4.5.16", features = [ "wrap_help" ] } +lsp-server = "0.7.9" +lsp-types = "0.95" owo-colors = "4" regex = "1.11.1" serde = { version = "1.0.209", features = [ "derive" ] } +serde_json = "1.0" tinytemplate = "1.2.1" + tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = [ "env-filter" ] } diff --git a/src/editor/mod.rs b/src/editor/mod.rs new file mode 100644 index 0000000..c15e7d5 --- /dev/null +++ b/src/editor/mod.rs @@ -0,0 +1,37 @@ +use lsp_server::Connection; +use lsp_types::{ + InitializeParams, OneOf, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, +}; +use tracing::{debug, info}; + +mod server; + +pub(crate) fn run_language_server() { + debug!("Starting Technique Language Server"); + + let (connection, threads) = Connection::stdio(); + + let capabilities = serde_json::to_value(ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)), + document_formatting_provider: Some(OneOf::Left(true)), + ..Default::default() + }) + .unwrap(); + + // extract any initialization parameters passed from the editor. + if let Ok(params) = connection.initialize(capabilities) { + let _params = serde_json::from_value::(params).unwrap(); + + info!("Technique Language Server starting on stdin"); + + let server = server::TechniqueLanguageServer::new(); + + if let Err(e) = server.run(connection) { + eprintln!("Server error: {}", e); + } + } + + threads + .join() + .unwrap(); +} diff --git a/src/editor/server.rs b/src/editor/server.rs new file mode 100644 index 0000000..5781e0c --- /dev/null +++ b/src/editor/server.rs @@ -0,0 +1,650 @@ +use std::collections::HashMap; +use std::path::Path; + +use lsp_server::{Connection, Message, Notification, Request, Response}; +use lsp_types::{ + Diagnostic, DiagnosticSeverity, DidChangeTextDocumentParams, DidCloseTextDocumentParams, + DidOpenTextDocumentParams, DidSaveTextDocumentParams, DocumentFormattingParams, + InitializeParams, InitializeResult, InitializedParams, Position, PublishDiagnosticsParams, + Range, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, Url, +}; +use serde_json::{from_value, to_value, Value}; +use technique::formatting::Identity; +use tracing::{debug, error, info, warn}; + +use crate::formatting; +use crate::parsing; +use crate::parsing::ParsingError; +use crate::problem::{calculate_column_number, calculate_line_number}; + +pub struct TechniqueLanguageServer { + /// Map from URI to document content + documents: HashMap, +} + +impl TechniqueLanguageServer { + pub fn new() -> Self { + Self { + documents: HashMap::new(), + } + } + + /// Main server loop that handles incoming LSP messages + pub fn run( + mut self, + connection: Connection, + ) -> Result<(), Box> { + info!("Starting Language Server main loop"); + + for message in &connection.receiver { + match message { + Message::Request(request) => { + if let Err(err) = self.handle_request(request, &|msg| { + connection + .sender + .send(msg) + }) { + error!("Error handling request: {}", err); + } + } + Message::Notification(notification) => { + if notification.method == "exit" { + break; + } + + if let Err(error) = self.handle_notification(notification, &|message| { + connection + .sender + .send(message) + }) { + error!("Error handling notification: {}", error); + } + } + Message::Response(_resp) => { + // We don't expect responses as a server + warn!("Received unexpected response message"); + } + } + } + + Ok(()) + } + + fn handle_request( + &mut self, + req: Request, + sender: &dyn Fn(Message) -> Result<(), E>, + ) -> Result<(), Box> + where + E: std::error::Error + Send + Sync + 'static, + { + match req + .method + .as_str() + { + "initialize" => { + let params: InitializeParams = from_value(req.params)?; + let result = self.handle_initialize(params)?; + let response = Response::new_ok(req.id, result); + sender(Message::Response(response))?; + } + "textDocument/formatting" => { + let params: DocumentFormattingParams = from_value(req.params)?; + match self.handle_document_formatting(params) { + Ok(result) => { + let response = Response::new_ok(req.id, result); + sender(Message::Response(response))?; + } + Err(err) => { + let response = Response::new_err( + req.id, + lsp_server::ErrorCode::ParseError as i32, + err.to_string(), + ); + sender(Message::Response(response))?; + } + } + } + "shutdown" => { + info!("Language Server received shutdown request"); + let response = Response::new_ok(req.id, Value::Null); + sender(Message::Response(response))?; + } + _ => { + warn!("Unhandled request method: {}", req.method); + let response = Response::new_err( + req.id, + lsp_server::ErrorCode::MethodNotFound as i32, + format!("Method not found: {}", req.method), + ); + sender(Message::Response(response))?; + } + } + Ok(()) + } + + fn handle_notification( + &mut self, + notification: Notification, + sender: &dyn Fn(Message) -> Result<(), E>, + ) -> Result<(), Box> + where + E: std::error::Error + Send + Sync + 'static, + { + match notification + .method + .as_str() + { + "initialized" => { + let _params: InitializedParams = from_value(notification.params)?; + self.handle_initialized()?; + } + "textDocument/didOpen" => { + let params: DidOpenTextDocumentParams = from_value(notification.params)?; + self.handle_did_open(params, sender)?; + } + "textDocument/didChange" => { + let params: DidChangeTextDocumentParams = from_value(notification.params)?; + self.handle_did_change(params, sender)?; + } + "textDocument/didSave" => { + let params: DidSaveTextDocumentParams = from_value(notification.params)?; + self.handle_did_save(params, sender)?; + } + "textDocument/didClose" => { + let params: DidCloseTextDocumentParams = from_value(notification.params)?; + self.handle_did_close(params, sender)?; + } + _ => { + debug!("Unhandled notification method: {}", notification.method); + } + } + Ok(()) + } + + fn handle_initialize( + &self, + _params: InitializeParams, + ) -> Result> { + info!("Language Server initializing"); + + Ok(InitializeResult { + server_info: None, + capabilities: ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::FULL, + )), + document_formatting_provider: Some(lsp_types::OneOf::Left(true)), + ..Default::default() + }, + }) + } + + fn handle_initialized(&self) -> Result<(), Box> { + info!("Technique Language Server initialized"); + Ok(()) + } + + fn handle_did_open( + &mut self, + params: DidOpenTextDocumentParams, + sender: &dyn Fn(Message) -> Result<(), E>, + ) -> Result<(), Box> + where + E: std::error::Error + Send + Sync + 'static, + { + let uri = params + .text_document + .uri; + let content = params + .text_document + .text; + + debug!("Document opened: {}", uri); + + self.documents + .insert(uri.clone(), content.clone()); + + self.parse_and_report(uri, content, sender)?; + Ok(()) + } + + fn handle_did_change( + &mut self, + params: DidChangeTextDocumentParams, + sender: &dyn Fn(Message) -> Result<(), E>, + ) -> Result<(), Box> + where + E: std::error::Error + Send + Sync + 'static, + { + let uri = params + .text_document + .uri; + + if let Some(change) = params + .content_changes + .into_iter() + .next() + { + let content = change.text; + + debug!("Document changed: {}", uri); + + self.documents + .insert(uri.clone(), content.clone()); + + self.parse_and_report(uri, content, sender)?; + } + Ok(()) + } + + fn handle_did_save( + &mut self, + params: DidSaveTextDocumentParams, + sender: &dyn Fn(Message) -> Result<(), E>, + ) -> Result<(), Box> + where + E: std::error::Error + Send + Sync + 'static, + { + let uri = params + .text_document + .uri; + debug!("Document saved: {}", uri); + + let content = self + .documents + .get(&uri) + .cloned(); + + if let Some(content) = content { + self.parse_and_report(uri, content, sender)?; + } + Ok(()) + } + + fn handle_did_close( + &mut self, + params: DidCloseTextDocumentParams, + sender: &dyn Fn(Message) -> Result<(), E>, + ) -> Result<(), Box> + where + E: std::error::Error + Send + Sync + 'static, + { + let uri = params + .text_document + .uri; + debug!("Document closed: {}", uri); + + self.documents + .remove(&uri); + + // Clear diagnostics for closed document + self.publish_diagnostics(uri, vec![], sender)?; + Ok(()) + } + + fn handle_document_formatting( + &self, + params: DocumentFormattingParams, + ) -> Result>, Box> { + let uri = params + .text_document + .uri; + + debug!("Format request: {}", uri); + + // Get content from our documents map + let content = match self + .documents + .get(&uri) + { + Some(content) => content.clone(), + None => { + return Err("Document not open".into()); + } + }; + + let path = match uri.to_file_path() { + Ok(buf) => buf, + Err(_) => Path::new("-").to_path_buf(), + }; + + let document = match parsing::parse_with_recovery(&path, &content) { + Ok(document) => document, + Err(_) => { + return Err("Document must be free of parse errors before formatting".into()); + } + }; + + let result = formatting::render(&Identity, &document, 78); + + // convert to LSP type for return to editor. + let edit = TextEdit { + range: Range { + start: Position { + line: 0, + character: 0, + }, + end: Position { + line: u32::MAX, + character: 0, + }, + }, + new_text: result, + }; + + Ok(Some(vec![edit])) + } + + /// Parse document and convert errors to diagnostics + fn parse_and_report( + &self, + uri: Url, + content: String, + sender: &dyn Fn(Message) -> Result<(), E>, + ) -> Result<(), Box> + where + E: std::error::Error + Send + Sync + 'static, + { + let path = uri + .to_file_path() + .unwrap_or_else(|_| Path::new("-").to_path_buf()); + + match parsing::parse_with_recovery(&path, &content) { + Ok(_document) => { + self.publish_diagnostics(uri, vec![], sender)?; + } + Err(errors) => { + let diagnostics = self.convert_parsing_errors(&uri, &content, errors); + self.publish_diagnostics(uri, diagnostics, sender)?; + } + } + Ok(()) + } + + fn publish_diagnostics( + &self, + uri: Url, + diagnostics: Vec, + sender: &dyn Fn(Message) -> Result<(), E>, + ) -> Result<(), Box> + where + E: std::error::Error + Send + Sync + 'static, + { + let params = PublishDiagnosticsParams { + uri, + diagnostics, + version: None, + }; + + let notification = Notification::new( + "textDocument/publishDiagnostics".to_string(), + to_value(params).unwrap(), + ); + + sender(Message::Notification(notification))?; + Ok(()) + } + + fn convert_parsing_errors( + &self, + _uri: &Url, + content: &str, + errors: Vec, + ) -> Vec { + let mut diagnostics = Vec::new(); + + for error in errors { + let offset = error.offset(); + let width = error.width(); + let start_position = offset_to_position(content, offset); + let end_position = if width > 0 { + offset_to_position(content, offset + width) + } else { + start_position // Fallback to single character if width is unknown + }; + let range = Range { + start: start_position, + end: end_position, + }; + + let (message, severity) = match &error { + ParsingError::IllegalParserState(_, _) => ( + "Internal parser error".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::Unimplemented(_, _) => ( + "Unimplemented feature".to_string(), + DiagnosticSeverity::WARNING, + ), + ParsingError::Unrecognized(_, _) => { + ("Unrecognized syntax".to_string(), DiagnosticSeverity::ERROR) + } + ParsingError::UnexpectedEndOfInput(_, _) => ( + "Unexpected end of input".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::Expected(_, _, expected) => { + (format!("Expected {}", expected), DiagnosticSeverity::ERROR) + } + ParsingError::ExpectedMatchingChar(_, _, subject, start, end) => ( + format!("Expected matching '{}' for '{}' in {}", end, start, subject), + DiagnosticSeverity::ERROR, + ), + ParsingError::MissingParenthesis(_, _) => ( + "Require parenthesis around multiple parameters in binding".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidCharacter(_, _, ch) => ( + format!("Invalid character '{}'", ch), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidHeader(_, _) => { + ("Invalid header line".to_string(), DiagnosticSeverity::ERROR) + } + ParsingError::InvalidIdentifier(_, _, id) => ( + format!("Invalid identifier '{}'", id), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidForma(_, _) => ( + "Invalid forma in signature".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidGenus(_, _) => ( + "Invalid genus in signature".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidSignature(_, _) => ( + "Invalid signature in procedure declaration".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidParameters(_, _) => ( + "Malformed parameters in procedure declaration".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidDeclaration(_, _) => ( + "Invalid procedure declaration".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidSection(_, _) => ( + "Invalid section heading".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidInvocation(_, _) => ( + "Invalid procedure Invocation".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidFunction(_, _) => ( + "Invalid function call".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidCodeBlock(_, _) => { + ("Invalid code block".to_string(), DiagnosticSeverity::ERROR) + } + ParsingError::InvalidStep(_, _) => { + ("Invalid step".to_string(), DiagnosticSeverity::ERROR) + } + ParsingError::InvalidSubstep(_, _) => { + ("Invalid substep".to_string(), DiagnosticSeverity::ERROR) + } + ParsingError::InvalidResponse(_, _) => { + ("Invalid response".to_string(), DiagnosticSeverity::ERROR) + } + ParsingError::InvalidMultiline(_, _) => ( + "Invalid multiline content".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidForeach(_, _) => ( + "Invalid foreach expression".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidIntegral(_, _) => ( + "Invalid integral number".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidQuantity(_, _) => { + ("Invalid quantity".to_string(), DiagnosticSeverity::ERROR) + } + ParsingError::InvalidQuantityDecimal(_, _) => ( + "Invalid quantity decimal".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidQuantityUncertainty(_, _) => ( + "Invalid quantity uncertainty".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidQuantityMagnitude(_, _) => ( + "Invalid quantity magnitude".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::InvalidQuantitySymbol(_, _) => ( + "Invalid quantity symbol".to_string(), + DiagnosticSeverity::ERROR, + ), + ParsingError::UnclosedInterpolation(_, _) => ( + "Unclosed interpolation".to_string(), + DiagnosticSeverity::ERROR, + ), + }; + + let diagnostic = Diagnostic { + range, + severity: Some(severity), + code: None, + code_description: None, + source: Some("technique".to_string()), + message, + related_information: None, + tags: None, + data: None, + }; + + diagnostics.push(diagnostic); + } + + diagnostics + } +} + +/// Convert byte offset to LSP Position +fn offset_to_position(text: &str, offset: usize) -> Position { + let line = calculate_line_number(text, offset) as u32; + let character = calculate_column_number(text, offset) as u32; + Position { line, character } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_offset_to_position() { + let text = "line 1\nline 2\nline 3"; + + // Test beginning + assert_eq!( + offset_to_position(text, 0), + Position { + line: 0, + character: 0 + } + ); + + // Test end of first line + assert_eq!( + offset_to_position(text, 6), + Position { + line: 0, + character: 6 + } + ); + + // Test beginning of second line + assert_eq!( + offset_to_position(text, 7), + Position { + line: 1, + character: 0 + } + ); + + // Test middle of second line + assert_eq!( + offset_to_position(text, 10), + Position { + line: 1, + character: 3 + } + ); + } + + #[test] + fn test_parsing_error_types() { + // Test that all error types can be converted to messages without panicking + let test_errors = vec![ + ParsingError::IllegalParserState(0, 0), + ParsingError::Unimplemented(0, 0), + ParsingError::Unrecognized(0, 0), + ParsingError::UnexpectedEndOfInput(0, 0), + ParsingError::Expected(0, 0, "test"), + ParsingError::ExpectedMatchingChar(0, 0, "test", '(', ')'), + ParsingError::MissingParenthesis(0, 0), + ParsingError::InvalidCharacter(0, 0, 'x'), + ParsingError::InvalidHeader(0, 0), + ParsingError::InvalidIdentifier(0, 0, "test".to_string()), + ParsingError::InvalidDeclaration(0, 0), + ]; + + // This shouldn't panic - just test that all enum variants are handled + for error in test_errors { + let offset = error.offset(); + assert_eq!(offset, 0); // All test errors are at offset 0 + + // Test message generation (this was formerly in convert_parsing_errors) + match &error { + ParsingError::IllegalParserState(_, _) => { + assert_eq!("Internal parser error", "Internal parser error") + } + ParsingError::Unimplemented(_, _) => { + assert_eq!("Unimplemented feature", "Unimplemented feature") + } + ParsingError::Unrecognized(_, _) => { + assert_eq!("Unrecognized syntax", "Unrecognized syntax") + } + ParsingError::UnexpectedEndOfInput(_, _) => { + assert_eq!("Unexpected end of input", "Unexpected end of input") + } + ParsingError::Expected(_, _, expected) => assert_eq!(*expected, "test"), + ParsingError::ExpectedMatchingChar(_, _, subject, start, end) => { + assert_eq!(*subject, "test"); + assert_eq!(*start, '('); + assert_eq!(*end, ')'); + } + ParsingError::InvalidDeclaration(_, _) => { + assert_eq!("Invalid declaration", "Invalid declaration") + } + _ => {} // Other variants tested implicitly + } + } + } +} diff --git a/src/formatting/formatter.rs b/src/formatting/formatter.rs index 6acbe7a..497392c 100644 --- a/src/formatting/formatter.rs +++ b/src/formatting/formatter.rs @@ -623,7 +623,7 @@ impl<'i> Formatter<'i> { } // This is a helper for rendering a single descriptives in error messages. - // The real method is append_decriptives() below; this method simply + // The real method is append_descriptives() below; this method simply // creates a single element slice that can be passed to it. fn append_descriptive(&mut self, descriptive: &'i Descriptive) { use std::slice; diff --git a/src/main.rs b/src/main.rs index 928ef46..8ba376b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ use technique::formatting::{self}; use technique::parsing; +mod editor; mod problem; mod rendering; @@ -75,7 +76,7 @@ fn main() { .arg( Arg::new("filename") .required(true) - .help("The file containing the code for the Technique you want to type-check."), + .help("The file containing the code for the Technique you want to parse and type check, or - to read from standard input."), ), ) .subcommand( @@ -100,7 +101,7 @@ fn main() { .arg( Arg::new("filename") .required(true) - .help("The file containing the code for the Technique you want to format."), + .help("The file containing the code for the Technique you want to format, or - to read from standard input."), ), ) .subcommand( @@ -125,6 +126,14 @@ fn main() { .help("The file containing the code for the Technique you want to print."), ), ) + .subcommand( + Command::new("language") + .about("Language Server Protocol integration for editors and IDEs.") + .long_about("Run a Language Server Protocol (LSP) service \ + for Technique documents. This accepts commands and code \ + input via stdin and returns compilation errors and other \ + diagnostics.") + ) .get_matches(); match matches.subcommand() { @@ -315,6 +324,11 @@ fn main() { rendering::via_typst(&filename, &result); } + Some(("language", _)) => { + debug!("Starting Language Server"); + + editor::run_language_server(); + } Some(_) => { println!("No valid subcommand was used") } diff --git a/src/parsing/checks/errors.rs b/src/parsing/checks/errors.rs index dba2b87..d852aff 100644 --- a/src/parsing/checks/errors.rs +++ b/src/parsing/checks/errors.rs @@ -1,7 +1,7 @@ use super::*; use std::path::Path; -/// Helper function to check if parsing produces the expected error type +/// Helper function to check if parsing produces the expected error fn expect_error(content: &str, expected: ParsingError) { let result = parse_with_recovery(Path::new("test.tq"), content); match result { @@ -10,14 +10,12 @@ fn expect_error(content: &str, expected: ParsingError) { content ), Err(errors) => { - // Check if any error matches the expected type - let found_expected = errors - .iter() - .any(|error| std::mem::discriminant(error) == std::mem::discriminant(&expected)); + // Check if any error exactly matches the expected error + let found_expected = errors.contains(&expected); if !found_expected { panic!( - "Expected error type like {:?} but got: {:?} for input '{}'", + "Expected error {:?} but got: {:?} for input '{}'", expected, errors, content ); } @@ -32,7 +30,7 @@ fn invalid_identifier_uppercase_start() { Making_Coffee : Ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidIdentifier(0, "".to_string()), + ParsingError::InvalidIdentifier(0, 13, "Making_Coffee".to_string()), ); } @@ -43,7 +41,7 @@ fn invalid_identifier_mixed_case() { makeCoffee : Ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidIdentifier(0, "".to_string()), + ParsingError::InvalidIdentifier(0, 10, "makeCoffee".to_string()), ); } @@ -54,7 +52,7 @@ fn invalid_identifier_with_dashes() { make-coffee : Ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidIdentifier(0, "".to_string()), + ParsingError::InvalidIdentifier(0, 11, "make-coffee".to_string()), ); } @@ -65,7 +63,7 @@ fn invalid_identifier_with_spaces() { make coffee : Ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidParameters(0), + ParsingError::InvalidParameters(5, 6), ); } @@ -76,7 +74,7 @@ fn invalid_signature_wrong_arrow() { making_coffee : Ingredients => Coffee "# .trim_ascii(), - ParsingError::InvalidSignature(0), + ParsingError::InvalidSignature(28, 0), ); } @@ -87,7 +85,7 @@ fn invalid_genus_lowercase_forma() { making_coffee : ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidGenus(16), + ParsingError::InvalidGenus(16, 11), ); } @@ -98,7 +96,7 @@ fn invalid_genus_both_lowercase() { making_coffee : ingredients -> coffee "# .trim_ascii(), - ParsingError::InvalidGenus(16), + ParsingError::InvalidGenus(16, 11), ); } @@ -109,7 +107,7 @@ fn invalid_signature_missing_arrow() { making_coffee : Ingredients Coffee "# .trim_ascii(), - ParsingError::InvalidSignature(16), + ParsingError::InvalidSignature(28, 0), ); } @@ -120,7 +118,7 @@ fn invalid_declaration_missing_colon() { making_coffee Ingredients -> Coffee "# .trim_ascii(), - ParsingError::Unrecognized(0), + ParsingError::Unrecognized(0, 0), ); } @@ -131,7 +129,7 @@ fn invalid_identifier_in_parameters() { making_coffee(BadParam) : Ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidIdentifier(14, "".to_string()), + ParsingError::InvalidIdentifier(0, 8, "BadParam".to_string()), ); } @@ -142,7 +140,7 @@ fn invalid_identifier_empty() { : Ingredients -> Coffee "# .trim_ascii(), - ParsingError::InvalidDeclaration(0), + ParsingError::InvalidDeclaration(0, 0), ); } @@ -155,7 +153,7 @@ making_coffee : A. First step (should be lowercase 'a.') "# .trim_ascii(), - ParsingError::InvalidStep(21), + ParsingError::InvalidStep(21, 0), ); } @@ -169,7 +167,7 @@ making_coffee : "Yes" | "No" "# .trim_ascii(), - ParsingError::InvalidResponse(52), + ParsingError::InvalidResponse(52, 0), ); } @@ -183,7 +181,7 @@ making_coffee : This is missing closing backticks "# .trim_ascii(), - ParsingError::InvalidMultiline(41), + ParsingError::InvalidMultiline(24, 0), ); } @@ -196,7 +194,7 @@ making_coffee : 1. Do something { exec("command" "# .trim_ascii(), - ParsingError::ExpectedMatchingChar(38, "a code block", '{', '}'), + ParsingError::ExpectedMatchingChar(37, 0, "a code block", '{', '}'), ); } @@ -209,7 +207,7 @@ making_coffee : i. Wrong case section "# .trim_ascii(), - ParsingError::InvalidStep(21), + ParsingError::InvalidStep(21, 0), ); } @@ -222,7 +220,7 @@ making_coffee : 1. Do '), + ParsingError::ExpectedMatchingChar(27, 0, "an invocation", '<', '>'), ); } @@ -235,7 +233,33 @@ making_coffee : 1. Do something { exec("command" } "# .trim_ascii(), - ParsingError::ExpectedMatchingChar(43, "a function call", '(', ')'), + ParsingError::ExpectedMatchingChar(43, 0, "parameters for a function", '(', ')'), + ); +} + +#[test] +fn invalid_function_with_space_in_name() { + expect_error( + r#" +making_coffee : + + 1. Do something { re peat() } + "# + .trim_ascii(), + ParsingError::InvalidFunction(39, 7), + ); +} + +#[test] +fn invalid_function_with_space_and_invocation() { + expect_error( + r#" +making_coffee : + + 1. Do something { re peat () } + "# + .trim_ascii(), + ParsingError::InvalidFunction(39, 15), ); } @@ -248,7 +272,7 @@ making_coffee : 1. { repeat '), + ParsingError::ExpectedMatchingChar(33, 0, "an invocation", '<', '>'), ); } @@ -262,6 +286,19 @@ making_coffee : A. This should be lowercase "# .trim_ascii(), - ParsingError::InvalidSubstep(37), + ParsingError::InvalidSubstep(43, 0), + ); +} + +#[test] +fn invalid_code_block_with_leftover_content() { + expect_error( + r#" +robot : + +Your plastic pal who's fun to be with! { re peat } + "# + .trim_ascii(), + ParsingError::InvalidCodeBlock(50, 7), ); } diff --git a/src/parsing/checks/parser.rs b/src/parsing/checks/parser.rs index 08fe4ba..1a4253f 100644 --- a/src/parsing/checks/parser.rs +++ b/src/parsing/checks/parser.rs @@ -608,7 +608,7 @@ fn read_toplevel_steps() { // Test invalid step input.initialize("Not a step"); let result = input.read_step_dependent(); - assert_eq!(result, Err(ParsingError::InvalidStep(0))); + assert_eq!(result, Err(ParsingError::InvalidStep(0, 0))); } #[test] @@ -1587,8 +1587,8 @@ fn test_foreach_keyword_boundary() { input.initialize("{ foreachitem in items }"); let result = input.read_code_block(); - // Should parse as identifier, not foreach - assert_eq!(result, Ok(Expression::Variable(Identifier("foreachitem")))); + // Should fail because "foreachitem" is parsed but "in items" is leftover content + assert_eq!(result, Err(ParsingError::InvalidCodeBlock(2, 11))); } #[test] @@ -1660,7 +1660,7 @@ fn splitting_by() { // different split character input.initialize("'Yes'|'No'|'Maybe'"); let result = input.take_split_by('|', |inner| { - validate_response(inner.source).ok_or(ParsingError::IllegalParserState(inner.offset)) + validate_response(inner.source).ok_or(ParsingError::IllegalParserState(inner.offset, 0)) }); assert_eq!( result, @@ -2022,7 +2022,7 @@ fn parse_collecting_errors_basic() { assert!(errors.len() > 0); assert!(errors .iter() - .any(|e| matches!(e, ParsingError::InvalidHeader(_)))); + .any(|e| matches!(e, ParsingError::InvalidHeader(_, _)))); } } @@ -2119,7 +2119,7 @@ fn test_redundant_error_removal_unclosed_interpolation() { // Should get the specific UnclosedInterpolation error, not a generic // one match result { - Err(ParsingError::UnclosedInterpolation(_)) => { + Err(ParsingError::UnclosedInterpolation(_, _)) => { // Good - we got the specific error } Err(other) => { diff --git a/src/parsing/mod.rs b/src/parsing/mod.rs index 06e0588..71a4fe5 100644 --- a/src/parsing/mod.rs +++ b/src/parsing/mod.rs @@ -1,5 +1,6 @@ //! parser for the Technique language +use std::io::Read; use std::path::Path; use tracing::debug; @@ -15,6 +16,21 @@ pub use parser::{parse_with_recovery, Parser, ParsingError}; /// main function so that the Technique object created by parse() below can /// have the same lifetime. pub fn load(filename: &Path) -> Result> { + if filename.to_str() == Some("-") { + let mut buffer = String::new(); + match std::io::stdin().read_to_string(&mut buffer) { + Ok(_) => return Ok(buffer), + Err(error) => { + debug!(?error); + return Err(LoadingError { + problem: "Failed reading from stdin".to_string(), + details: error.to_string(), + filename, + }); + } + } + } + match std::fs::read_to_string(filename) { Ok(content) => Ok(content), Err(error) => { diff --git a/src/parsing/parser.rs b/src/parsing/parser.rs index c263389..7487095 100644 --- a/src/parsing/parser.rs +++ b/src/parsing/parser.rs @@ -26,75 +26,111 @@ pub fn parse_with_recovery<'i>( #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum ParsingError { // lowest priority - IllegalParserState(usize), - Unimplemented(usize), - Unrecognized(usize), // improve this - UnexpectedEndOfInput(usize), - Expected(usize, &'static str), - ExpectedMatchingChar(usize, &'static str, char, char), - MissingParenthesis(usize), + IllegalParserState(usize, usize), // offset, width (0 = unknown) + Unimplemented(usize, usize), + Unrecognized(usize, usize), // improve this + UnexpectedEndOfInput(usize, usize), + Expected(usize, usize, &'static str), // offset, width, expected + ExpectedMatchingChar(usize, usize, &'static str, char, char), + MissingParenthesis(usize, usize), // more specific errors - InvalidCharacter(usize, char), - InvalidHeader(usize), - InvalidIdentifier(usize, String), - InvalidForma(usize), - InvalidGenus(usize), - InvalidSignature(usize), - InvalidParameters(usize), - InvalidDeclaration(usize), - InvalidSection(usize), - InvalidInvocation(usize), - InvalidFunction(usize), - InvalidCodeBlock(usize), - InvalidStep(usize), - InvalidSubstep(usize), - InvalidResponse(usize), - InvalidMultiline(usize), - InvalidForeach(usize), - InvalidIntegral(usize), - InvalidQuantity(usize), - InvalidQuantityDecimal(usize), - InvalidQuantityUncertainty(usize), - InvalidQuantityMagnitude(usize), - InvalidQuantitySymbol(usize), + InvalidCharacter(usize, usize, char), + InvalidHeader(usize, usize), + InvalidIdentifier(usize, usize, String), + InvalidForma(usize, usize), + InvalidGenus(usize, usize), + InvalidSignature(usize, usize), + InvalidParameters(usize, usize), + InvalidDeclaration(usize, usize), + InvalidSection(usize, usize), + InvalidInvocation(usize, usize), + InvalidFunction(usize, usize), + InvalidCodeBlock(usize, usize), + InvalidStep(usize, usize), + InvalidSubstep(usize, usize), + InvalidResponse(usize, usize), + InvalidMultiline(usize, usize), + InvalidForeach(usize, usize), + InvalidIntegral(usize, usize), + InvalidQuantity(usize, usize), + InvalidQuantityDecimal(usize, usize), + InvalidQuantityUncertainty(usize, usize), + InvalidQuantityMagnitude(usize, usize), + InvalidQuantitySymbol(usize, usize), // highest priority - UnclosedInterpolation(usize), + UnclosedInterpolation(usize, usize), } impl ParsingError { pub fn offset(&self) -> usize { match self { - ParsingError::IllegalParserState(offset) => *offset, - ParsingError::Unimplemented(offset) => *offset, - ParsingError::Unrecognized(offset) => *offset, - ParsingError::Expected(offset, _) => *offset, - ParsingError::ExpectedMatchingChar(offset, _, _, _) => *offset, - ParsingError::MissingParenthesis(offset) => *offset, - ParsingError::UnclosedInterpolation(offset) => *offset, - ParsingError::InvalidHeader(offset) => *offset, - ParsingError::InvalidCharacter(offset, _) => *offset, - ParsingError::UnexpectedEndOfInput(offset) => *offset, - ParsingError::InvalidIdentifier(offset, _) => *offset, - ParsingError::InvalidForma(offset) => *offset, - ParsingError::InvalidGenus(offset) => *offset, - ParsingError::InvalidSignature(offset) => *offset, - ParsingError::InvalidDeclaration(offset) => *offset, - ParsingError::InvalidParameters(offset) => *offset, - ParsingError::InvalidSection(offset) => *offset, - ParsingError::InvalidInvocation(offset) => *offset, - ParsingError::InvalidFunction(offset) => *offset, - ParsingError::InvalidCodeBlock(offset) => *offset, - ParsingError::InvalidMultiline(offset) => *offset, - ParsingError::InvalidStep(offset) => *offset, - ParsingError::InvalidSubstep(offset) => *offset, - ParsingError::InvalidForeach(offset) => *offset, - ParsingError::InvalidResponse(offset) => *offset, - ParsingError::InvalidIntegral(offset) => *offset, - ParsingError::InvalidQuantity(offset) => *offset, - ParsingError::InvalidQuantityDecimal(offset) => *offset, - ParsingError::InvalidQuantityUncertainty(offset) => *offset, - ParsingError::InvalidQuantityMagnitude(offset) => *offset, - ParsingError::InvalidQuantitySymbol(offset) => *offset, + ParsingError::IllegalParserState(offset, _) => *offset, + ParsingError::Unimplemented(offset, _) => *offset, + ParsingError::Unrecognized(offset, _) => *offset, + ParsingError::Expected(offset, _, _) => *offset, + ParsingError::ExpectedMatchingChar(offset, _, _, _, _) => *offset, + ParsingError::MissingParenthesis(offset, _) => *offset, + ParsingError::UnclosedInterpolation(offset, _) => *offset, + ParsingError::InvalidHeader(offset, _) => *offset, + ParsingError::InvalidCharacter(offset, _, _) => *offset, + ParsingError::UnexpectedEndOfInput(offset, _) => *offset, + ParsingError::InvalidIdentifier(offset, _, _) => *offset, + ParsingError::InvalidForma(offset, _) => *offset, + ParsingError::InvalidGenus(offset, _) => *offset, + ParsingError::InvalidSignature(offset, _) => *offset, + ParsingError::InvalidDeclaration(offset, _) => *offset, + ParsingError::InvalidParameters(offset, _) => *offset, + ParsingError::InvalidSection(offset, _) => *offset, + ParsingError::InvalidInvocation(offset, _) => *offset, + ParsingError::InvalidFunction(offset, _) => *offset, + ParsingError::InvalidCodeBlock(offset, _) => *offset, + ParsingError::InvalidMultiline(offset, _) => *offset, + ParsingError::InvalidStep(offset, _) => *offset, + ParsingError::InvalidSubstep(offset, _) => *offset, + ParsingError::InvalidForeach(offset, _) => *offset, + ParsingError::InvalidResponse(offset, _) => *offset, + ParsingError::InvalidIntegral(offset, _) => *offset, + ParsingError::InvalidQuantity(offset, _) => *offset, + ParsingError::InvalidQuantityDecimal(offset, _) => *offset, + ParsingError::InvalidQuantityUncertainty(offset, _) => *offset, + ParsingError::InvalidQuantityMagnitude(offset, _) => *offset, + ParsingError::InvalidQuantitySymbol(offset, _) => *offset, + } + } + + pub fn width(&self) -> usize { + match self { + ParsingError::IllegalParserState(_, width) => *width, + ParsingError::Unimplemented(_, width) => *width, + ParsingError::Unrecognized(_, width) => *width, + ParsingError::Expected(_, width, _) => *width, + ParsingError::ExpectedMatchingChar(_, width, _, _, _) => *width, + ParsingError::MissingParenthesis(_, width) => *width, + ParsingError::UnclosedInterpolation(_, width) => *width, + ParsingError::InvalidHeader(_, width) => *width, + ParsingError::InvalidCharacter(_, width, _) => *width, + ParsingError::UnexpectedEndOfInput(_, width) => *width, + ParsingError::InvalidIdentifier(_, width, _) => *width, + ParsingError::InvalidForma(_, width) => *width, + ParsingError::InvalidGenus(_, width) => *width, + ParsingError::InvalidSignature(_, width) => *width, + ParsingError::InvalidDeclaration(_, width) => *width, + ParsingError::InvalidParameters(_, width) => *width, + ParsingError::InvalidSection(_, width) => *width, + ParsingError::InvalidInvocation(_, width) => *width, + ParsingError::InvalidFunction(_, width) => *width, + ParsingError::InvalidCodeBlock(_, width) => *width, + ParsingError::InvalidMultiline(_, width) => *width, + ParsingError::InvalidStep(_, width) => *width, + ParsingError::InvalidSubstep(_, width) => *width, + ParsingError::InvalidForeach(_, width) => *width, + ParsingError::InvalidResponse(_, width) => *width, + ParsingError::InvalidIntegral(_, width) => *width, + ParsingError::InvalidQuantity(_, width) => *width, + ParsingError::InvalidQuantityDecimal(_, width) => *width, + ParsingError::InvalidQuantityUncertainty(_, width) => *width, + ParsingError::InvalidQuantityMagnitude(_, width) => *width, + ParsingError::InvalidQuantitySymbol(_, width) => *width, } } } @@ -232,7 +268,7 @@ impl<'i> Parser<'i> { } } else { self.problems - .push(ParsingError::Unrecognized(self.offset)); + .push(ParsingError::Unrecognized(self.offset, 0)); self.skip_to_next_line(); } } @@ -304,7 +340,7 @@ impl<'i> Parser<'i> { } } else { self.problems - .push(ParsingError::Unrecognized(self.offset)); + .push(ParsingError::Unrecognized(self.offset, 0)); self.skip_to_next_line(); } } @@ -458,11 +494,16 @@ impl<'i> Parser<'i> { } if !begun { - return Err(ParsingError::Expected(self.offset, "the start character")); + return Err(ParsingError::Expected( + self.offset, + 0, + "the start character", + )); } if l == 0 { return Err(ParsingError::ExpectedMatchingChar( self.offset, + 0, subject, start_char, end_char, @@ -500,7 +541,11 @@ impl<'i> Parser<'i> { let start = self .source .find(delimiter) - .ok_or(ParsingError::Expected(self.offset, "a starting delimiter"))?; + .ok_or(ParsingError::Expected( + self.offset, + 0, + "a starting delimiter", + ))?; // Look for the end delimiter after correcting for the starting one let start = start + width; @@ -508,6 +553,7 @@ impl<'i> Parser<'i> { .find(delimiter) .ok_or(ParsingError::Expected( self.offset, + 0, "the corresponding end delimiter", ))?; @@ -570,6 +616,7 @@ impl<'i> Parser<'i> { if trimmed.is_empty() { return Err(ParsingError::Expected( self.offset, + 0, "non-empty content between delimiters", )); } @@ -649,7 +696,7 @@ impl<'i> Parser<'i> { } else if c.is_ascii_whitespace() { continue; } else { - return Err(ParsingError::InvalidCharacter(self.offset, c)); + return Err(ParsingError::InvalidCharacter(self.offset, 0, c)); } } @@ -673,7 +720,7 @@ impl<'i> Parser<'i> { Ok(1) } else { let error_offset = analyze_magic_line(inner.source); - Err(ParsingError::InvalidHeader(inner.offset + error_offset)) + Err(ParsingError::InvalidHeader(inner.offset + error_offset, 0)) } }) } @@ -686,7 +733,7 @@ impl<'i> Parser<'i> { let cap = re .captures(inner.source) - .ok_or(ParsingError::InvalidHeader(inner.offset))?; + .ok_or(ParsingError::InvalidHeader(inner.offset, 0))?; // Now to extracting the values we need. We get the license code from // the first capture. It must be present otherwise we don't have a @@ -695,10 +742,10 @@ impl<'i> Parser<'i> { let one = cap .get(1) - .ok_or(ParsingError::Expected(inner.offset, "the license name"))?; + .ok_or(ParsingError::Expected(inner.offset, 0, "the license name"))?; - let result = - validate_license(one.as_str()).ok_or(ParsingError::InvalidHeader(inner.offset))?; + let result = validate_license(one.as_str()) + .ok_or(ParsingError::InvalidHeader(inner.offset, 0))?; let license = Some(result); // Now dig out the copyright, if present: @@ -706,7 +753,7 @@ impl<'i> Parser<'i> { let copyright = match cap.get(2) { Some(two) => { let result = validate_copyright(two.as_str()) - .ok_or(ParsingError::InvalidHeader(inner.offset))?; + .ok_or(ParsingError::InvalidHeader(inner.offset, 0))?; Some(result) } None => None, @@ -722,14 +769,14 @@ impl<'i> Parser<'i> { let cap = re .captures(inner.source) - .ok_or(ParsingError::InvalidHeader(inner.offset))?; + .ok_or(ParsingError::InvalidHeader(inner.offset, 0))?; let one = cap .get(1) - .ok_or(ParsingError::Expected(inner.offset, "a template name"))?; + .ok_or(ParsingError::Expected(inner.offset, 0, "a template name"))?; - let result = - validate_template(one.as_str()).ok_or(ParsingError::InvalidHeader(inner.offset))?; + let result = validate_template(one.as_str()) + .ok_or(ParsingError::InvalidHeader(inner.offset, 0))?; Ok(Some(result)) }) } @@ -741,7 +788,7 @@ impl<'i> Parser<'i> { self.require_newline()?; result } else { - Err(ParsingError::Expected(0, "The % symbol"))? + Err(ParsingError::Expected(0, 0, "The % symbol"))? }; // Process SPDX line @@ -777,7 +824,10 @@ impl<'i> Parser<'i> { Some(c) => c, None => { let arrow_offset = analyze_malformed_signature(self.source); - return Err(ParsingError::InvalidSignature(self.offset + arrow_offset)); + return Err(ParsingError::InvalidSignature( + self.offset + arrow_offset, + 0, + )); } }; @@ -785,15 +835,26 @@ impl<'i> Parser<'i> { .get(1) .ok_or(ParsingError::Expected( self.offset, + 0, "a Genus for the domain", ))?; let two = cap .get(2) - .ok_or(ParsingError::Expected(self.offset, "a Genus for the range"))?; + .ok_or(ParsingError::Expected( + self.offset, + 0, + "a Genus for the range", + ))?; - let domain = validate_genus(one.as_str()).ok_or(ParsingError::InvalidGenus(self.offset))?; - let range = validate_genus(two.as_str()).ok_or(ParsingError::InvalidGenus(self.offset))?; + let domain = validate_genus(one.as_str()).ok_or(ParsingError::InvalidGenus( + self.offset + one.start(), + one.len(), + ))?; + let range = validate_genus(two.as_str()).ok_or(ParsingError::InvalidGenus( + self.offset + two.start(), + two.len(), + ))?; Ok(Signature { domain, range }) } @@ -815,12 +876,13 @@ impl<'i> Parser<'i> { let cap = re .captures(self.source) - .ok_or(ParsingError::InvalidDeclaration(self.offset))?; + .ok_or(ParsingError::InvalidDeclaration(self.offset, 0))?; let one = cap .get(1) .ok_or(ParsingError::Expected( self.offset, + 0, "an Identifier for the procedure declaration", ))?; @@ -829,12 +891,13 @@ impl<'i> Parser<'i> { let before = before.trim(); let name = validate_identifier(before).ok_or(ParsingError::InvalidIdentifier( self.offset, + before.len(), before.to_string(), ))?; // Extract parameters from parentheses if !list.ends_with(')') { - return Err(ParsingError::InvalidDeclaration(self.offset)); + return Err(ParsingError::InvalidDeclaration(self.offset, 0)); } let list = &list[..list.len() - 1].trim_ascii(); @@ -846,6 +909,8 @@ impl<'i> Parser<'i> { let param = validate_identifier(item.trim_ascii()).ok_or( ParsingError::InvalidIdentifier( self.offset, + item.trim_ascii() + .len(), item.trim_ascii() .to_string(), ), @@ -873,11 +938,13 @@ impl<'i> Parser<'i> { .as_ptr() as isize - text.as_ptr() as isize; let error_offset = self.offset + one.start() + first_param_pos as usize; - return Err(ParsingError::InvalidParameters(error_offset)); + let param_width = text.len() - first_param_pos as usize; + return Err(ParsingError::InvalidParameters(error_offset, param_width)); } let name = validate_identifier(text).ok_or(ParsingError::InvalidIdentifier( self.offset, + text.len(), text.to_string(), ))?; (name, None) @@ -904,7 +971,7 @@ impl<'i> Parser<'i> { Ok(title) } else { // we shouldn't have invoked this unless we have a title to parse! - Err(ParsingError::IllegalParserState(self.offset)) + Err(ParsingError::IllegalParserState(self.offset, 0)) } } @@ -1048,7 +1115,7 @@ impl<'i> Parser<'i> { } else if malformed_step_pattern(content) { // Store error but continue parsing self.problems - .push(ParsingError::InvalidStep(parser.offset)); + .push(ParsingError::InvalidStep(parser.offset, 0)); parser.skip_to_next_line(); } else { match parser.take_block_lines( @@ -1246,11 +1313,11 @@ impl<'i> Parser<'i> { let re = regex!(r"^\s*([IVX]+)\.\s*(.*)$"); let cap = re .captures(line) - .ok_or(ParsingError::InvalidSection(self.offset))?; + .ok_or(ParsingError::InvalidSection(self.offset, 0))?; let numeral = match cap.get(1) { Some(one) => one.as_str(), - None => return Err(ParsingError::Expected(self.offset, "section header")), + None => return Err(ParsingError::Expected(self.offset, 0, "section header")), }; // Though section text appear as titles, they are in fact steps and so @@ -1269,7 +1336,7 @@ impl<'i> Parser<'i> { let paragraphs = parser.read_descriptive()?; if paragraphs.len() != 1 { - return Err(ParsingError::InvalidSection(self.offset)); + return Err(ParsingError::InvalidSection(self.offset, 0)); } let paragraph = paragraphs .into_iter() @@ -1286,8 +1353,56 @@ impl<'i> Parser<'i> { } fn read_code_block(&mut self) -> Result, ParsingError> { - self.take_block_chars("a code block", '{', '}', true, |outer| { - outer.read_expression() + self.take_block_chars("a code block", '{', '}', true, |inner| { + // Save the start position (accounting for leading whitespace that read_expression will trim) + inner.trim_whitespace(); + let start = inner.offset; + + let expression = inner.read_expression()?; + + // Check if there's leftover content + let offset_before_trim = inner.offset; + inner.trim_whitespace(); + if !inner + .source + .is_empty() + { + let mut width = offset_before_trim - start; // Width of what we parsed + + // Check if leftover looks like continuation of identifier + let leftover = inner + .source + .chars() + .next() + .map(|ch| { + ch.is_ascii_lowercase() + && !inner + .source + .starts_with("in ") + }) + .unwrap_or(false); + + if leftover { + // Include the space(s) between parts + width = inner.offset - start; + + // Add identifier-like characters from leftover + for ch in inner + .source + .chars() + { + if ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' { + width += ch.len_utf8(); + } else { + break; + } + } + } + + return Err(ParsingError::InvalidCodeBlock(start, width)); + } + + Ok(expression) }) } @@ -1307,14 +1422,14 @@ impl<'i> Parser<'i> { self.advance(tilde_pos + 1); // Move past ~ self.trim_whitespace(); } - return Err(ParsingError::MissingParenthesis(self.offset)); + return Err(ParsingError::MissingParenthesis(self.offset, 0)); } else if is_repeat_keyword(content) { self.read_repeat_expression() } else if is_foreach_keyword(content) { self.read_foreach_expression() } else if content.starts_with("foreach ") { // Malformed foreach expression - return Err(ParsingError::InvalidForeach(self.offset)); + return Err(ParsingError::InvalidForeach(self.offset, 0)); } else if content.starts_with('[') { self.read_tablet_expression() } else if is_numeric(content) { @@ -1329,7 +1444,19 @@ impl<'i> Parser<'i> { let invocation = self.read_invocation()?; Ok(Expression::Application(invocation)) } else if is_function(content) { - let target = self.read_identifier()?; + // Extract the entire text before the opening parenthesis + self.trim_whitespace(); + let content = self.source; + let paren = content + .find('(') + .unwrap(); // is_function() already checked + let text = &content[0..paren]; + + // Validate that the entire text is a valid identifier + let target = validate_identifier(text) + .ok_or(ParsingError::InvalidFunction(self.offset, text.len()))?; + + self.advance(text.len()); let parameters = self.read_parameters()?; let function = Function { target, parameters }; @@ -1360,7 +1487,7 @@ impl<'i> Parser<'i> { .unwrap() .is_ascii_whitespace() { - return Err(ParsingError::InvalidForeach(self.offset)); + return Err(ParsingError::InvalidForeach(self.offset, 0)); } self.trim_whitespace(); @@ -1404,7 +1531,7 @@ impl<'i> Parser<'i> { } if identifiers.is_empty() { - return Err(ParsingError::InvalidForeach(outer.offset)); + return Err(ParsingError::InvalidForeach(outer.offset, 0)); } Ok(identifiers) @@ -1463,6 +1590,7 @@ impl<'i> Parser<'i> { { return Err(ParsingError::Expected( outer.offset, + 0, "a string label for the field, in double-quotes", )); } @@ -1478,6 +1606,7 @@ impl<'i> Parser<'i> { { return Err(ParsingError::Expected( outer.offset, + 0, "a '=' after the field name to indicate what value is to be assigned to it", )); } @@ -1490,7 +1619,7 @@ impl<'i> Parser<'i> { let content = inner.source; if content.is_empty() { - return Err(ParsingError::Expected(inner.offset, "value expression")); + return Err(ParsingError::Expected(inner.offset, 0, "value expression")); }; inner.read_expression() @@ -1557,6 +1686,7 @@ impl<'i> Parser<'i> { // Unmatched brace - point to the opening brace position return Err(ParsingError::UnclosedInterpolation( self.offset + absolute_brace_start, + 0, )); } } @@ -1587,6 +1717,7 @@ impl<'i> Parser<'i> { let identifier = validate_identifier(possible).ok_or(ParsingError::InvalidIdentifier( self.offset, + possible.len(), possible.to_string(), ))?; @@ -1606,7 +1737,7 @@ impl<'i> Parser<'i> { } else if is_numeric_quantity(content) { self.read_numeric_quantity() } else { - Err(ParsingError::InvalidQuantity(self.offset)) + Err(ParsingError::InvalidQuantity(self.offset, 0)) } } @@ -1621,7 +1752,7 @@ impl<'i> Parser<'i> { self.advance(content.len()); Ok(Numeric::Integral(amount)) } else { - Err(ParsingError::InvalidIntegral(self.offset)) + Err(ParsingError::InvalidIntegral(self.offset, 0)) } } @@ -1680,7 +1811,7 @@ impl<'i> Parser<'i> { .source .starts_with("10") { - return Err(ParsingError::InvalidQuantityMagnitude(self.offset)); + return Err(ParsingError::InvalidQuantityMagnitude(self.offset, 0)); } self.advance(2); // Skip "10" @@ -1693,7 +1824,7 @@ impl<'i> Parser<'i> { } else if let Some(exp) = self.read_exponent_superscript() { Some(exp) } else { - return Err(ParsingError::InvalidQuantityMagnitude(self.offset)); + return Err(ParsingError::InvalidQuantityMagnitude(self.offset, 0)); } } else { None @@ -1723,10 +1854,10 @@ impl<'i> Parser<'i> { self.advance(decimal_str.len()); Ok(decimal) } else { - Err(ParsingError::InvalidQuantityDecimal(self.offset)) + Err(ParsingError::InvalidQuantityDecimal(self.offset, 0)) } } else { - Err(ParsingError::InvalidQuantityDecimal(self.offset)) + Err(ParsingError::InvalidQuantityDecimal(self.offset, 0)) } } @@ -1740,10 +1871,10 @@ impl<'i> Parser<'i> { self.advance(decimal_str.len()); Ok(decimal) } else { - Err(ParsingError::InvalidQuantityUncertainty(self.offset)) + Err(ParsingError::InvalidQuantityUncertainty(self.offset, 0)) } } else { - Err(ParsingError::InvalidQuantityUncertainty(self.offset)) + Err(ParsingError::InvalidQuantityUncertainty(self.offset, 0)) } } @@ -1757,10 +1888,10 @@ impl<'i> Parser<'i> { self.advance(exp_str.len()); Ok(exp) } else { - Err(ParsingError::InvalidQuantityMagnitude(self.offset)) + Err(ParsingError::InvalidQuantityMagnitude(self.offset, 0)) } } else { - Err(ParsingError::InvalidQuantityMagnitude(self.offset)) + Err(ParsingError::InvalidQuantityMagnitude(self.offset, 0)) } } @@ -1793,19 +1924,25 @@ impl<'i> Parser<'i> { if ch.is_whitespace() || ch == ',' || ch == ')' { // Stop at whitespace, comma, or closing parameter boundary break; - } else if ch.is_ascii_alphabetic() || ch == '°' || ch == '/' || ch == 'μ' { + } else if ch.is_ascii_alphabetic() + || ch == '°' + || ch == '/' + || ch == 'μ' + || "⁰¹²³⁴⁵⁶⁷⁸⁹".contains(ch) + { // Valid character valid_end = byte_offset + ch.len_utf8(); } else { // Invalid character found - point directly at it return Err(ParsingError::InvalidQuantitySymbol( self.offset + byte_offset, + ch.len_utf8(), )); } } if valid_end == 0 { - return Err(ParsingError::InvalidQuantitySymbol(self.offset)); + return Err(ParsingError::InvalidQuantitySymbol(self.offset, 1)); } let symbol = &self.source[..valid_end]; @@ -1815,12 +1952,17 @@ impl<'i> Parser<'i> { /// Parse a target like or fn read_target(&mut self) -> Result, ParsingError> { + let start_offset = self.offset; self.take_block_chars("an invocation", '<', '>', true, |inner| { - let content = inner.source; + let content = inner + .source + .trim(); if content.starts_with("https://") { Ok(Target::Remote(External(content))) } else { - let identifier = inner.read_identifier()?; + let identifier = validate_identifier(content).ok_or_else(|| { + ParsingError::InvalidInvocation(start_offset + 1, content.len()) + })?; Ok(Target::Local(identifier)) } }) @@ -1846,12 +1988,13 @@ impl<'i> Parser<'i> { let re = regex!(r"^\s*(\d+)\.\s+"); let cap = re .captures(outer.source) - .ok_or(ParsingError::InvalidStep(outer.offset))?; + .ok_or(ParsingError::InvalidStep(outer.offset, 0))?; let number = cap .get(1) .ok_or(ParsingError::Expected( outer.offset, + 0, "the ordinal Step number", ))? .as_str(); @@ -1886,7 +2029,7 @@ impl<'i> Parser<'i> { .source .starts_with('-') { - return Err(ParsingError::IllegalParserState(outer.offset)); + return Err(ParsingError::IllegalParserState(outer.offset, 0)); } outer.advance(1); // skip over '-' outer.trim_whitespace(); @@ -1914,12 +2057,13 @@ impl<'i> Parser<'i> { let re = regex!(r"^\s*([a-hj-uw-z])\.\s+"); let cap = re .captures(content) - .ok_or(ParsingError::InvalidStep(outer.offset))?; + .ok_or(ParsingError::InvalidStep(outer.offset, 0))?; let letter = cap .get(1) .ok_or(ParsingError::Expected( outer.offset, + 0, "the ordinal Sub-Step letter", ))? .as_str(); @@ -1956,7 +2100,7 @@ impl<'i> Parser<'i> { let re = regex!(r"^\s*-\s+"); let zero = re .find(outer.source) - .ok_or(ParsingError::InvalidStep(outer.offset))?; + .ok_or(ParsingError::InvalidStep(outer.offset, 0))?; // Skip past the dash and space let l = zero.len(); @@ -2026,7 +2170,7 @@ impl<'i> Parser<'i> { .starts_with("```") { // Multiline blocks are not allowed in descriptive text - return Err(ParsingError::InvalidMultiline(parser.offset)); + return Err(ParsingError::InvalidMultiline(parser.offset, 0)); } else if c == '<' { let invocation = parser.read_invocation()?; parser.trim_whitespace(); @@ -2043,7 +2187,7 @@ impl<'i> Parser<'i> { .starts_with(',') { return Err(ParsingError::MissingParenthesis( - start_pos, + start_pos, 0, )); } @@ -2064,6 +2208,7 @@ impl<'i> Parser<'i> { if content.contains("```") { return Err(ParsingError::InvalidMultiline( inner.offset, + 0, )); } Ok(content) @@ -2082,7 +2227,7 @@ impl<'i> Parser<'i> { .starts_with(',') { return Err(ParsingError::MissingParenthesis( - start_pos, + start_pos, 0, )); } @@ -2113,7 +2258,7 @@ impl<'i> Parser<'i> { /// Parse enum responses like 'Yes' | 'No' | 'Not Applicable' fn read_responses(&mut self) -> Result>, ParsingError> { self.take_split_by('|', |inner| { - validate_response(inner.source).ok_or(ParsingError::InvalidResponse(inner.offset)) + validate_response(inner.source).ok_or(ParsingError::InvalidResponse(inner.offset, 0)) }) } @@ -2156,7 +2301,7 @@ impl<'i> Parser<'i> { .trim_ascii() .is_empty() { - return Err(ParsingError::InvalidMultiline(self.offset)); + return Err(ParsingError::InvalidMultiline(self.offset, 0)); } result.push(after) @@ -2200,9 +2345,11 @@ impl<'i> Parser<'i> { let (lang, lines) = outer .take_block_delimited("```", |inner| inner.parse_multiline_content()) .map_err(|err| match err { - ParsingError::Expected(offset, "the corresponding end delimiter") => { - ParsingError::InvalidMultiline(offset) - } + ParsingError::Expected( + offset, + _, + "the corresponding end delimiter", + ) => ParsingError::InvalidMultiline(offset, 0), _ => err, })?; params.push(Expression::Multiline(lang, lines)); @@ -2288,25 +2435,35 @@ impl<'i> Parser<'i> { else if let Some(captures) = regex!(r"^@([a-z][a-z0-9_]*)$").captures(trimmed) { let role_name = captures .get(1) - .ok_or(ParsingError::Expected(inner.offset, "role name after @"))? + .ok_or(ParsingError::Expected(inner.offset, 0, "role name after @"))? .as_str(); - let identifier = validate_identifier(role_name).ok_or( - ParsingError::InvalidIdentifier(inner.offset, role_name.to_string()), - )?; + let identifier = + validate_identifier(role_name).ok_or(ParsingError::InvalidIdentifier( + inner.offset, + role_name.len(), + role_name.to_string(), + ))?; attributes.push(Attribute::Role(identifier)); } // Check if it's a place '^' else if let Some(captures) = regex!(r"^\^([a-z][a-z0-9_]*)$").captures(trimmed) { let place_name = captures .get(1) - .ok_or(ParsingError::Expected(inner.offset, "place name after ^"))? + .ok_or(ParsingError::Expected( + inner.offset, + 0, + "place name after ^", + ))? .as_str(); - let identifier = validate_identifier(place_name).ok_or( - ParsingError::InvalidIdentifier(inner.offset, place_name.to_string()), - )?; + let identifier = + validate_identifier(place_name).ok_or(ParsingError::InvalidIdentifier( + inner.offset, + place_name.len(), + place_name.to_string(), + ))?; attributes.push(Attribute::Place(identifier)); } else { - return Err(ParsingError::InvalidStep(inner.offset)); + return Err(ParsingError::InvalidStep(inner.offset, 0)); } } @@ -2346,9 +2503,9 @@ impl<'i> Parser<'i> { let block = self.read_code_scope()?; scopes.push(block); } else if malformed_step_pattern(content) { - return Err(ParsingError::InvalidSubstep(self.offset)); + return Err(ParsingError::InvalidSubstep(self.offset, 0)); } else if malformed_response_pattern(content) { - return Err(ParsingError::InvalidResponse(self.offset)); + return Err(ParsingError::InvalidResponse(self.offset, 0)); } else if is_enum_response(content) { let responses = self.read_responses()?; scopes.push(Scope::ResponseBlock { responses }); diff --git a/src/problem/format.rs b/src/problem/format.rs index 92ea071..cfb55d9 100644 --- a/src/problem/format.rs +++ b/src/problem/format.rs @@ -11,7 +11,9 @@ pub fn full_parsing_error<'i>( renderer: &impl Render, ) -> String { let (problem, details) = generate_error_message(error, renderer); + let input = generate_filename(filename); let offset = error.offset(); + let width = error.width(); let i = calculate_line_number(source, offset); let j = calculate_column_number(source, offset); @@ -22,23 +24,28 @@ pub fn full_parsing_error<'i>( .unwrap_or("?"); let line = i + 1; let column = j + 1; - let width = 3.max( + let indent = 3.max( line.to_string() .len(), ); + // Create underline string based on error width + let spacer = " ".repeat(j); + let width = if width > 0 { width } else { 1 }; + let underline = "^".repeat(width); + format!( r#" {}: {}:{}:{} {} -{:width$} {} -{:width$} {} {} -{:width$} {} {:>column$} +{:indent$} {} +{:indent$} {} {} +{:indent$} {} {}{} {} "#, "error".bright_red(), - filename.to_string_lossy(), + input, line, column, problem.bold(), @@ -49,7 +56,8 @@ pub fn full_parsing_error<'i>( code, ' ', '|'.bright_blue(), - '^'.bright_red(), + spacer, + underline.bright_red(), details ) .trim_ascii() @@ -64,6 +72,7 @@ pub fn concise_parsing_error<'i>( renderer: &impl Render, ) -> String { let (problem, _) = generate_error_message(error, renderer); + let input = generate_filename(filename); let offset = error.offset(); let i = calculate_line_number(source, offset); let j = calculate_column_number(source, offset); @@ -73,7 +82,7 @@ pub fn concise_parsing_error<'i>( format!( "{}: {}:{}:{} {}", "error".bright_red(), - filename.to_string_lossy(), + input, line, column, problem.bold(), @@ -94,15 +103,25 @@ pub fn concise_loading_error<'i>(error: &LoadingError<'i>) -> String { ) } +fn generate_filename(filename: &Path) -> String { + if filename.to_str() == Some("-") { + "".to_string() + } else { + filename + .display() + .to_string() + } +} + // Helper functions for line/column calculation -fn calculate_line_number(content: &str, offset: usize) -> usize { +pub fn calculate_line_number(content: &str, offset: usize) -> usize { content[..offset] .bytes() .filter(|&b| b == b'\n') .count() } -fn calculate_column_number(content: &str, offset: usize) -> usize { +pub fn calculate_column_number(content: &str, offset: usize) -> usize { let before = &content[..offset]; match before.rfind('\n') { Some(start) => content[start + 1..offset] diff --git a/src/problem/messages.rs b/src/problem/messages.rs index f46226e..6c9bed5 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -4,26 +4,26 @@ use technique::{formatting::Render, language::*, parsing::ParsingError}; /// Generate problem and detail messages for parsing errors using AST construction pub fn generate_error_message<'i>(error: &ParsingError, renderer: &dyn Render) -> (String, String) { match error { - ParsingError::IllegalParserState(_) => ( + ParsingError::IllegalParserState(_, _) => ( "Illegal parser state".to_string(), "Internal parser error. This should not have happened! Sorry.".to_string(), ), - ParsingError::Unimplemented(_) => ( + ParsingError::Unimplemented(_, _) => ( "Feature not yet implemented".to_string(), "This feature is planned but not yet available.".to_string(), ), - ParsingError::Unrecognized(_) => ( + ParsingError::Unrecognized(_, _) => ( "Unrecognized input".to_string(), "The parser encountered unexpected content".to_string(), ), - ParsingError::Expected(_, value) => ( + ParsingError::Expected(_, _, value) => ( format!("Expected {}", value), format!( "The parser was looking for {} but found something else.", value ), ), - ParsingError::ExpectedMatchingChar(_, subject, start, end) => ( + ParsingError::ExpectedMatchingChar(_, _, subject, start, end) => ( format!("Expected matching character '{}'", end), format!( r#" @@ -35,7 +35,7 @@ there was no more input remaining in the current scope. .trim_ascii() .to_string(), ), - ParsingError::MissingParenthesis(_) => { + ParsingError::MissingParenthesis(_, _) => { let examples = vec![Descriptive::Binding( Box::new(Descriptive::Application(Invocation { target: Target::Local(Identifier("mix_pangalactic_gargle_blaster")), @@ -59,7 +59,7 @@ enclose those names in parenthesis. For example: .to_string(), ) } - ParsingError::UnclosedInterpolation(_) => ( + ParsingError::UnclosedInterpolation(_, _) => ( "Unclosed string interpolation".to_string(), r#" Every '{' that starts an interpolation within a string must have a @@ -69,7 +69,7 @@ literal resumes. .trim_ascii() .to_string(), ), - ParsingError::InvalidHeader(_) => { + ParsingError::InvalidHeader(_, _) => { // Format the sample metadata using the same code as the formatter let mut formatted_example = String::new(); formatted_example @@ -115,15 +115,15 @@ Technique. Common templates include {}, {}, and ), ) } - ParsingError::InvalidCharacter(_, c) => ( + ParsingError::InvalidCharacter(_, _, c) => ( format!("Invalid character '{}'", c), "This character is not allowed here.".to_string(), ), - ParsingError::UnexpectedEndOfInput(_) => ( + ParsingError::UnexpectedEndOfInput(_, _) => ( "Unexpected end of input".to_string(), "The file ended before the parser expected it to".to_string(), ), - ParsingError::InvalidIdentifier(_, _) => { + ParsingError::InvalidIdentifier(_, _, _) => { let examples = vec![ Procedure { name: Identifier("make_coffee"), @@ -172,7 +172,7 @@ letters, numbers, and underscores. Valid examples include: .to_string(), ) } - ParsingError::InvalidForma(_) => { + ParsingError::InvalidForma(_, _) => { let examples = vec![ Forma("Coffee"), Forma("Ingredients"), @@ -198,7 +198,7 @@ For example: .to_string(), ) } - ParsingError::InvalidGenus(_) => { + ParsingError::InvalidGenus(_, _) => { let examples = vec![ Genus::Single(Forma("Coffee")), Genus::Tuple(vec![Forma("Beans"), Forma("Water")]), @@ -234,7 +234,7 @@ doesn't have an input or result, per se. .to_string(), ) } - ParsingError::InvalidSignature(_) => { + ParsingError::InvalidSignature(_, _) => { let examples = vec![ Signature { domain: Genus::Single(Forma("A")), @@ -272,7 +272,7 @@ this form. .to_string(), ) } - ParsingError::InvalidDeclaration(_) => { + ParsingError::InvalidDeclaration(_, _) => { let examples = vec![ Procedure { name: Identifier("f"), @@ -374,7 +374,7 @@ Finally, variables can be assigned for the names of the input parameters: .to_string(), ) } - ParsingError::InvalidParameters(_) => { + ParsingError::InvalidParameters(_, _) => { let examples = vec![ Procedure { name: Identifier("create_bypass"), @@ -433,7 +433,7 @@ declarations (and in fact the same): .to_string(), ) } - ParsingError::InvalidSection(_) => { + ParsingError::InvalidSection(_, _) => { // Roman numeral sections don't have AST representation ( "Invalid section heading".to_string(), @@ -453,7 +453,7 @@ author of the Technique. .to_string(), ) } - ParsingError::InvalidInvocation(_) => { + ParsingError::InvalidInvocation(_, _) => { let examples = vec![ Invocation { target: Target::Local(Identifier("make_coffee")), @@ -484,7 +484,7 @@ If the procedure takes parameters they can be specified in parenthesis: .to_string(), ) } - ParsingError::InvalidFunction(_) => { + ParsingError::InvalidFunction(_, _) => { let examples = vec![ Function { target: Identifier("exec"), @@ -523,7 +523,7 @@ expressions as parameters as required: .to_string(), ) } - ParsingError::InvalidCodeBlock(_) => { + ParsingError::InvalidCodeBlock(_, _) => { let examples = vec![ Expression::Execution(Function { target: Identifier("exec"), @@ -554,7 +554,7 @@ Inline code blocks are enclosed in braces: .to_string(), ) } - ParsingError::InvalidMultiline(_) => ( + ParsingError::InvalidMultiline(_, _) => ( "Invalid multi-line string".to_string(), r#" Multi-line strings can be written by surrounding the content in triple @@ -584,7 +584,7 @@ it may be used by output templates when rendering the procedure. .trim_ascii() .to_string(), ), - ParsingError::InvalidStep(_) => ( + ParsingError::InvalidStep(_, _) => ( "Invalid step format".to_string(), r#" Steps must start with a number or lower-case letter (in the case of dependent @@ -604,7 +604,7 @@ dash. They can be done in either order, or concurrently: .trim_ascii() .to_string(), ), - ParsingError::InvalidSubstep(_) => ( + ParsingError::InvalidSubstep(_, _) => ( "Invalid substep format".to_string(), r#" Substeps can be nested below top-level dependent steps or top-level parallel @@ -630,7 +630,7 @@ parallel steps, but again this is not compulsory. .trim_ascii() .to_string(), ), - ParsingError::InvalidForeach(_) => { + ParsingError::InvalidForeach(_, _) => { let examples = vec![ Expression::Foreach( vec![Identifier("patient")], @@ -661,7 +661,7 @@ a list of tuples. .to_string(), ) } - ParsingError::InvalidResponse(_) => { + ParsingError::InvalidResponse(_, _) => { let examples = vec![ vec![ Response { @@ -723,7 +723,7 @@ By convention the response values are Proper Case. .to_string(), ) } - ParsingError::InvalidIntegral(_) => { + ParsingError::InvalidIntegral(_, _) => { let examples = vec![ Numeric::Integral(42), Numeric::Integral(-123), @@ -751,7 +751,7 @@ Integers cannot contain decimal points or units."#, .to_string(), ) } - ParsingError::InvalidQuantity(_) => { + ParsingError::InvalidQuantity(_, _) => { let examples = vec![ Numeric::Scientific(Quantity { mantissa: Decimal { @@ -816,7 +816,7 @@ a magnitude: .to_string(), ) } - ParsingError::InvalidQuantityDecimal(_) => ( + ParsingError::InvalidQuantityDecimal(_, _) => ( "Invalid number in quantity".to_string(), r#" The numeric part of a quantity may be positive or negative, and may have a @@ -829,7 +829,7 @@ Values less than 1 must have a leading '0' before the decimal."# .trim_ascii() .to_string(), ), - ParsingError::InvalidQuantityUncertainty(_) => ( + ParsingError::InvalidQuantityUncertainty(_, _) => ( "Invalid uncertainty in quantity".to_string(), r#" Uncertainty values must be positive numbers: @@ -840,7 +840,7 @@ You can use '±' or `+/-`, followed by a decimal."# .trim_ascii() .to_string(), ), - ParsingError::InvalidQuantityMagnitude(_) => ( + ParsingError::InvalidQuantityMagnitude(_, _) => ( "Invalid magnitude format".to_string(), r#" The magnitude of a quantity can be expressed in the usual scientific format @@ -855,7 +855,7 @@ The base must be 10, and the exponent must be an integer."# .trim_ascii() .to_string(), ), - ParsingError::InvalidQuantitySymbol(_) => { + ParsingError::InvalidQuantitySymbol(_, _) => { let examples = vec![ Numeric::Scientific(Quantity { mantissa: Decimal { diff --git a/src/rendering/mod.rs b/src/rendering/mod.rs index 0d6ad12..f2d9250 100644 --- a/src/rendering/mod.rs +++ b/src/rendering/mod.rs @@ -1,3 +1,4 @@ +use owo_colors::OwoColorize; use serde::Serialize; use std::io::Write; use std::path::Path; @@ -25,6 +26,13 @@ pub(crate) fn via_typst(filename: &Path, markup: &str) { info!("Printing file: {}", filename.display()); // Verify that the file actually exists + if filename.to_str() == Some("-") { + eprintln!( + "{}: Unable to render to PDF from standard input.", + "error".bright_red() + ); + std::process::exit(1); + } if !filename.exists() { panic!( "Supplied procedure file does not exist: {}", diff --git a/tests/broken/ScrapCode.tq b/tests/broken/ScrapCode.tq new file mode 100644 index 0000000..6b95a96 --- /dev/null +++ b/tests/broken/ScrapCode.tq @@ -0,0 +1,3 @@ +robot : + +Your plastic pal who's fun to be with! { re peat } diff --git a/tests/parsing/mod.rs b/tests/parsing/mod.rs index 25fc4d6..1609ecb 100644 --- a/tests/parsing/mod.rs +++ b/tests/parsing/mod.rs @@ -1,2 +1,2 @@ -mod samples; mod broken; +mod samples;