From 7ffe04771327c62eb6b7376fa9140c151d1bce01 Mon Sep 17 00:00:00 2001 From: Lionel Henry Date: Fri, 5 Dec 2025 11:03:22 +0100 Subject: [PATCH 1/6] Add `code_location` field to execute requests --- Cargo.lock | 279 +++++++++++++++--- crates/amalthea/Cargo.toml | 1 + .../amalthea/src/fixtures/dummy_frontend.rs | 1 + crates/amalthea/src/wire/execute_request.rs | 90 ++++++ crates/ark/src/interface.rs | 21 +- 5 files changed, 346 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f1f2eb94c..f7d658969 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -321,6 +321,7 @@ dependencies = [ "strum 0.24.1", "strum_macros 0.24.3", "tracing", + "url", "uuid", "zmq", ] @@ -1014,6 +1015,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "dissimilar" version = "1.0.10" @@ -1141,7 +1153,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1180,9 +1192,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "form_urlencoded" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -1612,6 +1624,87 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -1620,12 +1713,23 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "0.4.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "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]] @@ -1713,7 +1817,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi 0.5.2", "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1853,6 +1957,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "local-channel" version = "0.1.4" @@ -2170,9 +2280,9 @@ checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" [[package]] name = "percent-encoding" -version = "2.3.0" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phf" @@ -2304,6 +2414,15 @@ version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2722,7 +2841,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2984,9 +3103,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.10.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" @@ -3141,6 +3260,17 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "system-deps" version = "6.1.0" @@ -3276,20 +3406,15 @@ dependencies = [ ] [[package]] -name = "tinyvec" -version = "1.6.0" +name = "tinystr" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ - "tinyvec_macros", + "displaydoc", + "zerovec", ] -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.28.1" @@ -3586,12 +3711,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "unicode-bidi" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" - [[package]] name = "unicode-bom" version = "2.0.3" @@ -3604,15 +3723,6 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" -[[package]] -name = "unicode-normalization" -version = "0.1.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" -dependencies = [ - "tinyvec", -] - [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -3627,9 +3737,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "url" -version = "2.4.1" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", @@ -3643,6 +3753,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.3.3" @@ -4217,6 +4333,12 @@ version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + [[package]] name = "xdg" version = "2.5.2" @@ -4232,6 +4354,29 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.7.35" @@ -4252,6 +4397,27 @@ dependencies = [ "syn 2.0.111", ] +[[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 2.0.111", + "synstructure", +] + [[package]] name = "zeromq-src" version = "0.2.6+4.3.4" @@ -4262,6 +4428,39 @@ dependencies = [ "dircpy", ] +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "zmq" version = "0.10.0" diff --git a/crates/amalthea/Cargo.toml b/crates/amalthea/Cargo.toml index a0b0b3374..59ca28230 100644 --- a/crates/amalthea/Cargo.toml +++ b/crates/amalthea/Cargo.toml @@ -34,6 +34,7 @@ serde_with = "3.0.0" serde_repr = "0.1.17" tracing = "0.1.40" assert_matches = "1.5.0" +url = "2.5.7" [dev-dependencies] env_logger = "0.10.0" diff --git a/crates/amalthea/src/fixtures/dummy_frontend.rs b/crates/amalthea/src/fixtures/dummy_frontend.rs index 04255b5e6..fc4264199 100644 --- a/crates/amalthea/src/fixtures/dummy_frontend.rs +++ b/crates/amalthea/src/fixtures/dummy_frontend.rs @@ -233,6 +233,7 @@ impl DummyFrontend { user_expressions: serde_json::Value::Null, allow_stdin: options.allow_stdin, stop_on_error: false, + positron: None, }) } diff --git a/crates/amalthea/src/wire/execute_request.rs b/crates/amalthea/src/wire/execute_request.rs index 87c21435c..7db552edd 100644 --- a/crates/amalthea/src/wire/execute_request.rs +++ b/crates/amalthea/src/wire/execute_request.rs @@ -5,9 +5,11 @@ * */ +use anyhow::Context; use serde::Deserialize; use serde::Serialize; use serde_json::Value; +use url::Url; use crate::wire::jupyter_message::MessageType; @@ -33,6 +35,94 @@ pub struct ExecuteRequest { /// Whether the kernel should discard the execution queue if evaluating the /// code results in an error pub stop_on_error: bool, + + /// Posit extension + pub positron: Option, +} + +#[serde_with::skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExecuteRequestPositron { + pub code_location: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct JupyterPositronLocation { + pub uri: String, + pub range: JupyterPositronRange, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct JupyterPositronRange { + pub start: JupyterPositronPosition, + pub end: JupyterPositronPosition, +} + +/// See https://jupyter-client.readthedocs.io/en/stable/messaging.html#cursor-pos-unicode-note +/// regarding choice of offset in unicode points +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct JupyterPositronPosition { + pub line: u32, + /// Column offset in unicode points + pub character: u32, +} + +/// Code location with `character` in UTF-8 offset +#[derive(Debug, Clone)] +pub struct CodeLocation { + pub uri: Url, + pub line: u32, + pub character: usize, +} + +impl ExecuteRequest { + pub fn extract_code_location(&self) -> anyhow::Result> { + let Some(positron) = &self.positron else { + return Ok(None); + }; + + let Some(location) = &positron.code_location else { + return Ok(None); + }; + + let uri = Url::parse(&location.uri).context("Failed to parse URI from code location")?; + + let character = unicode_char_to_utf8_offset( + &self.code, + location.range.start.line, + location.range.start.character, + )?; + + Ok(Some(CodeLocation { + uri, + line: location.range.start.line, + character, + })) + } +} + +/// Converts a character position in unicode scalar values to a UTF-8 byte +/// offset within the specified line. +fn unicode_char_to_utf8_offset(text: &str, line: u32, character: u32) -> anyhow::Result { + let target_line = text + .lines() + .nth(line as usize) + .ok_or_else(|| anyhow::anyhow!("Line {line} not found in text"))?; + + let line_chars = target_line.chars().count(); + if character as usize > line_chars { + return Err(anyhow::anyhow!( + "Character position {character} exceeds line {line} length ({line_chars})" + )); + } + + let byte_offset = target_line + .char_indices() + .nth(character as usize) + .map(|(byte_idx, _)| byte_idx) + .unwrap_or(target_line.len()); + + Ok(byte_offset) } impl MessageType for ExecuteRequest { diff --git a/crates/ark/src/interface.rs b/crates/ark/src/interface.rs index b72894eae..62bcf2fc4 100644 --- a/crates/ark/src/interface.rs +++ b/crates/ark/src/interface.rs @@ -38,6 +38,7 @@ use amalthea::wire::exception::Exception; use amalthea::wire::execute_error::ExecuteError; use amalthea::wire::execute_input::ExecuteInput; use amalthea::wire::execute_reply::ExecuteReply; +use amalthea::wire::execute_request::CodeLocation; use amalthea::wire::execute_request::ExecuteRequest; use amalthea::wire::execute_result::ExecuteResult; use amalthea::wire::input_reply::InputReply; @@ -332,7 +333,10 @@ enum ParseResult { } impl PendingInputs { - pub(crate) fn read(input: &str) -> anyhow::Result> { + pub(crate) fn read( + input: &str, + _loc: Option, + ) -> anyhow::Result> { let mut _srcfile = None; let input = if harp::get_option_bool("keep.source") { @@ -478,7 +482,7 @@ pub struct PromptInfo { pub enum ConsoleInput { EOF, - Input(String), + Input(String, Option), } #[derive(Debug)] @@ -894,8 +898,13 @@ impl RMain { } } + let loc = req.extract_code_location().log_err().flatten(); + // Return the code to the R console to be evaluated and the corresponding exec count - (ConsoleInput::Input(req.code.clone()), self.execution_count) + ( + ConsoleInput::Input(req.code.clone(), loc), + self.execution_count, + ) } /// Invoked by R to read console input from the user. @@ -1318,14 +1327,14 @@ impl RMain { // Translate requests from the debugger frontend to actual inputs for // the debug interpreter - ConsoleInput::Input(debug_request_command(cmd)) + ConsoleInput::Input(debug_request_command(cmd), None) }, }; match input { - ConsoleInput::Input(code) => { + ConsoleInput::Input(code, loc) => { // Parse input into pending expressions - match PendingInputs::read(&code) { + match PendingInputs::read(&code, loc) { Ok(ParseResult::Success(inputs)) => { self.pending_inputs = inputs; }, From 652a87d9f2983ef142166fb15f51f556cd56d83c Mon Sep 17 00:00:00 2001 From: Lionel Henry Date: Fri, 5 Dec 2025 15:58:25 +0100 Subject: [PATCH 2/6] Parse with line directive --- Cargo.lock | 1 + Cargo.toml | 1 + crates/amalthea/src/wire/execute_request.rs | 6 +-- crates/ark/Cargo.toml | 1 + crates/ark/src/interface.rs | 60 +++++++++++++++++++-- 5 files changed, 59 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f7d658969..9ad64b599 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -355,6 +355,7 @@ dependencies = [ "async-trait", "base64 0.21.0", "biome_line_index", + "biome_rowan", "bus", "cc", "cfg-if", diff --git a/Cargo.toml b/Cargo.toml index bed8fd92b..2d7f7d19a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ authors = ["Posit Software, PBC"] [workspace.dependencies] biome_line_index = { git = "https://github.com/biomejs/biome", rev = "c13fc60726883781e4530a4437724273b560c8e0" } +biome_rowan = { git = "https://github.com/biomejs/biome", rev = "c13fc60726883781e4530a4437724273b560c8e0" } aether_lsp_utils = { git = "https://github.com/posit-dev/air", rev = "f959e32eee91" } aether_parser = { git = "https://github.com/posit-dev/air", package = "air_r_parser", rev = "f959e32eee91" } aether_syntax = { git = "https://github.com/posit-dev/air", package = "air_r_syntax", rev = "f959e32eee91" } diff --git a/crates/amalthea/src/wire/execute_request.rs b/crates/amalthea/src/wire/execute_request.rs index 7db552edd..89d783d9e 100644 --- a/crates/amalthea/src/wire/execute_request.rs +++ b/crates/amalthea/src/wire/execute_request.rs @@ -87,11 +87,7 @@ impl ExecuteRequest { let uri = Url::parse(&location.uri).context("Failed to parse URI from code location")?; - let character = unicode_char_to_utf8_offset( - &self.code, - location.range.start.line, - location.range.start.character, - )?; + let character = unicode_char_to_utf8_offset(&self.code, 0, location.range.start.character)?; Ok(Some(CodeLocation { uri, diff --git a/crates/ark/Cargo.toml b/crates/ark/Cargo.toml index 42d049977..0d59666fb 100644 --- a/crates/ark/Cargo.toml +++ b/crates/ark/Cargo.toml @@ -17,6 +17,7 @@ anyhow = "1.0.80" async-trait = "0.1.66" base64 = "0.21.0" biome_line_index.workspace = true +biome_rowan.workspace = true bus = "2.3.0" cfg-if = "1.0.0" crossbeam = { version = "0.8.2", features = ["crossbeam-channel"] } diff --git a/crates/ark/src/interface.rs b/crates/ark/src/interface.rs index 62bcf2fc4..51b06d72e 100644 --- a/crates/ark/src/interface.rs +++ b/crates/ark/src/interface.rs @@ -52,6 +52,7 @@ use amalthea::wire::stream::Stream; use amalthea::wire::stream::StreamOutput; use amalthea::Error; use anyhow::*; +use biome_rowan::AstNode; use bus::Bus; use crossbeam::channel::bounded; use crossbeam::channel::Receiver; @@ -334,16 +335,20 @@ enum ParseResult { impl PendingInputs { pub(crate) fn read( - input: &str, - _loc: Option, + code: &str, + location: Option, ) -> anyhow::Result> { let mut _srcfile = None; - let input = if harp::get_option_bool("keep.source") { - _srcfile = Some(SrcFile::new_virtual_empty_filename(input.into())); + let input = if let Some(location) = location { + let annotated_code = Self::annotate(code, location); + _srcfile = Some(SrcFile::new_virtual_empty_filename(annotated_code.into())); + harp::ParseInput::SrcFile(&_srcfile.unwrap()) + } else if harp::get_option_bool("keep.source") { + _srcfile = Some(SrcFile::new_virtual_empty_filename(code.into())); harp::ParseInput::SrcFile(&_srcfile.unwrap()) } else { - harp::ParseInput::Text(input) + harp::ParseInput::Text(code) }; let status = match harp::parse_status(&input) { @@ -387,6 +392,51 @@ impl PendingInputs { }))) } + fn annotate(code: &str, location: CodeLocation) -> String { + let node = aether_parser::parse(code, Default::default()).tree(); + let Some(first_token) = node.syntax().first_token() else { + return code.into(); + }; + + let line_directive = format!( + "#line {line} \"{uri}\"", + line = location.line + 1, + uri = location.uri + ); + + // Collect existing leading trivia as (kind, text) tuples + let existing_trivia: Vec<_> = first_token + .leading_trivia() + .pieces() + .map(|piece| (piece.kind(), piece.text().to_string())) + .collect(); + + // Create new trivia with line directive prepended + let new_trivia: Vec<_> = vec![ + ( + biome_rowan::TriviaPieceKind::SingleLineComment, + line_directive.to_string(), + ), + (biome_rowan::TriviaPieceKind::Newline, "\n".to_string()), + ] + .into_iter() + .chain(existing_trivia.into_iter()) + .collect(); + + let new_first_token = + first_token.with_leading_trivia(new_trivia.iter().map(|(k, t)| (*k, t.as_str()))); + + let Some(new_node) = node + .syntax() + .clone() + .replace_child(first_token.into(), new_first_token.into()) + else { + return code.into(); + }; + + new_node.to_string() + } + pub(crate) fn is_empty(&self) -> bool { self.index >= self.len } From 195113e5487db02cf72b24e36ee27217b46ad1f1 Mon Sep 17 00:00:00 2001 From: Lionel Henry Date: Mon, 8 Dec 2025 10:48:17 +0100 Subject: [PATCH 3/6] Add execute request test for code locations --- .../amalthea/src/fixtures/dummy_frontend.rs | 43 +++++++++++++++++-- crates/ark/src/modules/positron/srcref.R | 8 ++++ crates/ark/tests/kernel-debugger.rs | 5 ++- crates/ark/tests/kernel-notebook.rs | 15 +++++-- crates/ark/tests/kernel-srcref.rs | 42 +++++++++++++++++- crates/ark/tests/kernel-stdin.rs | 25 ++++++++--- 6 files changed, 125 insertions(+), 13 deletions(-) diff --git a/crates/amalthea/src/fixtures/dummy_frontend.rs b/crates/amalthea/src/fixtures/dummy_frontend.rs index fc4264199..7cb2cdace 100644 --- a/crates/amalthea/src/fixtures/dummy_frontend.rs +++ b/crates/amalthea/src/fixtures/dummy_frontend.rs @@ -15,6 +15,8 @@ use crate::session::Session; use crate::socket::socket::Socket; use crate::wire::execute_input::ExecuteInput; use crate::wire::execute_request::ExecuteRequest; +use crate::wire::execute_request::ExecuteRequestPositron; +use crate::wire::execute_request::JupyterPositronLocation; use crate::wire::handshake_reply::HandshakeReply; use crate::wire::input_reply::InputReply; use crate::wire::jupyter_message::JupyterMessage; @@ -48,6 +50,7 @@ pub struct DummyFrontend { pub struct ExecuteRequestOptions { pub allow_stdin: bool, + pub positron: Option, } impl DummyConnection { @@ -233,7 +236,7 @@ impl DummyFrontend { user_expressions: serde_json::Value::Null, allow_stdin: options.allow_stdin, stop_on_error: false, - positron: None, + positron: options.positron, }) } @@ -265,7 +268,38 @@ impl DummyFrontend { where F: FnOnce(String), { - self.send_execute_request(code, ExecuteRequestOptions::default()); + self.execute_request_with_options(code, result_check, Default::default()) + } + + #[track_caller] + pub fn execute_request_with_location( + &self, + code: &str, + result_check: F, + code_location: JupyterPositronLocation, + ) -> u32 + where + F: FnOnce(String), + { + self.execute_request_with_options(code, result_check, ExecuteRequestOptions { + positron: Some(ExecuteRequestPositron { + code_location: Some(code_location), + }), + ..Default::default() + }) + } + + #[track_caller] + pub fn execute_request_with_options( + &self, + code: &str, + result_check: F, + options: ExecuteRequestOptions, + ) -> u32 + where + F: FnOnce(String), + { + self.send_execute_request(code, options); self.recv_iopub_busy(); let input = self.recv_iopub_execute_input(); @@ -664,6 +698,9 @@ impl DummyFrontend { impl Default for ExecuteRequestOptions { fn default() -> Self { - Self { allow_stdin: false } + Self { + allow_stdin: false, + positron: None, + } } } diff --git a/crates/ark/src/modules/positron/srcref.R b/crates/ark/src/modules/positron/srcref.R index 947c1ac08..6ee67c89b 100644 --- a/crates/ark/src/modules/positron/srcref.R +++ b/crates/ark/src/modules/positron/srcref.R @@ -167,3 +167,11 @@ srcref_to_range <- function(x) { end_column = x[[loc_end_column]] ) } + +get_srcref_range <- function(x) { + srcref <- attr(x, 'srcref') + list( + start = c(line = srcref[[1]], character = srcref[[5]]), + end = c(line = srcref[[3]], character = srcref[[6]]) + ) +} diff --git a/crates/ark/tests/kernel-debugger.rs b/crates/ark/tests/kernel-debugger.rs index 03046db6f..7df93efd2 100644 --- a/crates/ark/tests/kernel-debugger.rs +++ b/crates/ark/tests/kernel-debugger.rs @@ -198,7 +198,10 @@ fn test_execute_request_browser_stdin() { assert!(result.contains("Called from: top level")); }); - let options = ExecuteRequestOptions { allow_stdin: true }; + let options = ExecuteRequestOptions { + allow_stdin: true, + ..Default::default() + }; let code = "readline('prompt>')"; frontend.send_execute_request(code, options); frontend.recv_iopub_busy(); diff --git a/crates/ark/tests/kernel-notebook.rs b/crates/ark/tests/kernel-notebook.rs index 965003805..e232f25ea 100644 --- a/crates/ark/tests/kernel-notebook.rs +++ b/crates/ark/tests/kernel-notebook.rs @@ -109,7 +109,10 @@ fn test_notebook_execute_request_incomplete_multiple_lines() { fn test_notebook_stdin_basic_prompt() { let frontend = DummyArkFrontendNotebook::lock(); - let options = ExecuteRequestOptions { allow_stdin: true }; + let options = ExecuteRequestOptions { + allow_stdin: true, + ..Default::default() + }; let code = "readline('prompt>')"; frontend.send_execute_request(code, options); @@ -134,7 +137,10 @@ fn test_notebook_stdin_basic_prompt() { fn test_notebook_stdin_followed_by_an_expression_on_the_same_line() { let frontend = DummyArkFrontendNotebook::lock(); - let options = ExecuteRequestOptions { allow_stdin: true }; + let options = ExecuteRequestOptions { + allow_stdin: true, + ..Default::default() + }; let code = "val <- readline('prompt>'); paste0(val,'-there')"; frontend.send_execute_request(code, options); @@ -159,7 +165,10 @@ fn test_notebook_stdin_followed_by_an_expression_on_the_same_line() { fn test_notebook_stdin_followed_by_an_expression_on_the_next_line() { let frontend = DummyArkFrontendNotebook::lock(); - let options = ExecuteRequestOptions { allow_stdin: true }; + let options = ExecuteRequestOptions { + allow_stdin: true, + ..Default::default() + }; // Note, `1` is an intermediate output and is not emitted in notebooks let code = "1\nval <- readline('prompt>')\npaste0(val,'-there')"; diff --git a/crates/ark/tests/kernel-srcref.rs b/crates/ark/tests/kernel-srcref.rs index 9efbf7b89..35b3a7f04 100644 --- a/crates/ark/tests/kernel-srcref.rs +++ b/crates/ark/tests/kernel-srcref.rs @@ -1,7 +1,10 @@ +use amalthea::wire::execute_request::JupyterPositronLocation; +use amalthea::wire::execute_request::JupyterPositronPosition; +use amalthea::wire::execute_request::JupyterPositronRange; use ark::fixtures::DummyArkFrontend; #[test] -fn test_execute_request_source_references() { +fn test_execute_request_srcref() { let frontend = DummyArkFrontend::lock(); // Test that our parser attaches source references when global option is set @@ -33,3 +36,40 @@ fn test_execute_request_source_references() { }, ); } + +#[test] +fn test_execute_request_srcref_location_line_shift() { + let frontend = DummyArkFrontend::lock(); + + // Starting at line 3, column 0 + let code_location = JupyterPositronLocation { + uri: "file:///path/to/file.R".to_owned(), + range: JupyterPositronRange { + start: JupyterPositronPosition { + line: 2, + character: 0, + }, + end: JupyterPositronPosition { + line: 2, + character: 18, + }, + }, + }; + frontend.execute_request_with_location("fn <- function() {}; NULL", |_| (), code_location); + + // `function` starts at column 7, the body ends at 19 (right-boundary position) + // Lines are 1-based so incremented by 1. + frontend.execute_request(".ps.internal(get_srcref_range(fn))", |result| { + assert_eq!( + result, + "$start + line character\u{20} + 3 7\u{20} + +$end + line character\u{20} + 3 19\u{20} +" + ); + }); +} diff --git a/crates/ark/tests/kernel-stdin.rs b/crates/ark/tests/kernel-stdin.rs index 3d937499c..2d3f17200 100644 --- a/crates/ark/tests/kernel-stdin.rs +++ b/crates/ark/tests/kernel-stdin.rs @@ -5,7 +5,10 @@ use ark::fixtures::DummyArkFrontend; fn test_stdin_basic_prompt() { let frontend = DummyArkFrontend::lock(); - let options = ExecuteRequestOptions { allow_stdin: true }; + let options = ExecuteRequestOptions { + allow_stdin: true, + ..Default::default() + }; let code = "readline('prompt>')"; frontend.send_execute_request(code, options); @@ -30,7 +33,10 @@ fn test_stdin_basic_prompt() { fn test_stdin_followed_by_an_expression_on_the_same_line() { let frontend = DummyArkFrontend::lock(); - let options = ExecuteRequestOptions { allow_stdin: true }; + let options = ExecuteRequestOptions { + allow_stdin: true, + ..Default::default() + }; let code = "val <- readline('prompt>'); paste0(val,'-there')"; frontend.send_execute_request(code, options); @@ -55,7 +61,10 @@ fn test_stdin_followed_by_an_expression_on_the_same_line() { fn test_stdin_followed_by_an_expression_on_the_next_line() { let frontend = DummyArkFrontend::lock(); - let options = ExecuteRequestOptions { allow_stdin: true }; + let options = ExecuteRequestOptions { + allow_stdin: true, + ..Default::default() + }; let code = "1\nval <- readline('prompt>')\npaste0(val,'-there')"; frontend.send_execute_request(code, options); @@ -82,7 +91,10 @@ fn test_stdin_followed_by_an_expression_on_the_next_line() { fn test_stdin_single_line_buffer_overflow() { let frontend = DummyArkFrontend::lock(); - let options = ExecuteRequestOptions { allow_stdin: true }; + let options = ExecuteRequestOptions { + allow_stdin: true, + ..Default::default() + }; let code = "1\nreadline('prompt>')"; frontend.send_execute_request(code, options); @@ -116,7 +128,10 @@ fn test_stdin_single_line_buffer_overflow() { fn test_stdin_from_menu() { let frontend = DummyArkFrontend::lock(); - let options = ExecuteRequestOptions { allow_stdin: true }; + let options = ExecuteRequestOptions { + allow_stdin: true, + ..Default::default() + }; let code = "menu(c('a', 'b'))\n3"; frontend.send_execute_request(code, options); From 597512b81d2974be4298aff69c9db96f0ba54500 Mon Sep 17 00:00:00 2001 From: Lionel Henry Date: Mon, 8 Dec 2025 11:04:45 +0100 Subject: [PATCH 4/6] Add leading padding whitespace --- crates/ark/src/interface.rs | 8 +++++++ crates/ark/tests/kernel-srcref.rs | 37 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/crates/ark/src/interface.rs b/crates/ark/src/interface.rs index 51b06d72e..7ab3c39c7 100644 --- a/crates/ark/src/interface.rs +++ b/crates/ark/src/interface.rs @@ -404,6 +404,10 @@ impl PendingInputs { uri = location.uri ); + // Leading whitespace to ensure that R starts parsing expressions from + // the expected `character` offset. + let leading_padding = " ".repeat(location.character); + // Collect existing leading trivia as (kind, text) tuples let existing_trivia: Vec<_> = first_token .leading_trivia() @@ -418,6 +422,10 @@ impl PendingInputs { line_directive.to_string(), ), (biome_rowan::TriviaPieceKind::Newline, "\n".to_string()), + ( + biome_rowan::TriviaPieceKind::Whitespace, + leading_padding.to_string(), + ), ] .into_iter() .chain(existing_trivia.into_iter()) diff --git a/crates/ark/tests/kernel-srcref.rs b/crates/ark/tests/kernel-srcref.rs index 35b3a7f04..450611b1f 100644 --- a/crates/ark/tests/kernel-srcref.rs +++ b/crates/ark/tests/kernel-srcref.rs @@ -73,3 +73,40 @@ $end ); }); } + +#[test] +fn test_execute_request_srcref_location_line_and_column_shift() { + let frontend = DummyArkFrontend::lock(); + + // Starting at line 3, column 3 + let code_location = JupyterPositronLocation { + uri: "file:///path/to/file.R".to_owned(), + range: JupyterPositronRange { + start: JupyterPositronPosition { + line: 2, + character: 3, + }, + end: JupyterPositronPosition { + line: 2, + character: 22, + }, + }, + }; + frontend.execute_request_with_location("fn <- function() {}; NULL", |_| (), code_location); + + // `function` starts at column 7, the body ends at 19 (right-boundary position) + // Lines are 1-based so incremented by 1. + frontend.execute_request(".ps.internal(get_srcref_range(fn))", |result| { + assert_eq!( + result, + "$start + line character\u{20} + 3 10\u{20} + +$end + line character\u{20} + 3 22\u{20} +" + ); + }); +} From d700eed242abedce08891bd2dae5dbd1e174f589 Mon Sep 17 00:00:00 2001 From: Lionel Henry Date: Mon, 8 Dec 2025 11:40:05 +0100 Subject: [PATCH 5/6] Check that location extents match supplied code --- crates/amalthea/src/wire/execute_request.rs | 57 ++++++++++++++++++--- crates/ark/src/interface.rs | 6 +-- crates/ark/tests/kernel-srcref.rs | 4 +- 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/crates/amalthea/src/wire/execute_request.rs b/crates/amalthea/src/wire/execute_request.rs index 89d783d9e..f7b5cfc88 100644 --- a/crates/amalthea/src/wire/execute_request.rs +++ b/crates/amalthea/src/wire/execute_request.rs @@ -71,12 +71,19 @@ pub struct JupyterPositronPosition { #[derive(Debug, Clone)] pub struct CodeLocation { pub uri: Url, + pub start: Position, + pub end: Position, +} + +/// `character` in UTF-8 offset +#[derive(Debug, Clone)] +pub struct Position { pub line: u32, pub character: usize, } impl ExecuteRequest { - pub fn extract_code_location(&self) -> anyhow::Result> { + pub fn code_location(&self) -> anyhow::Result> { let Some(positron) = &self.positron else { return Ok(None); }; @@ -87,13 +94,51 @@ impl ExecuteRequest { let uri = Url::parse(&location.uri).context("Failed to parse URI from code location")?; - let character = unicode_char_to_utf8_offset(&self.code, 0, location.range.start.character)?; + let character_start = + unicode_char_to_utf8_offset(&self.code, 0, location.range.start.character)?; + let character_end = + unicode_char_to_utf8_offset(&self.code, 0, location.range.end.character)?; - Ok(Some(CodeLocation { - uri, + let start = Position { line: location.range.start.line, - character, - })) + character: character_start, + }; + let end = Position { + line: location.range.end.line, + character: character_end, + }; + + // Sanity check: `code` conforms exactly to expected number of lines + let line_count_code = self.code.lines().count() - 1; + let line_count_message = (end.line - start.line) as usize; + if line_count_code != line_count_message { + return Err(anyhow::anyhow!( + "Line information does not match code line count (expected {}, got {})", + line_count_code, + line_count_message + )); + } + + // Sanity check: the last line has exactly the expected number of UTF-8 bytes + let last_line = self + .code + .split('\n') + .last() + .ok_or_else(|| anyhow::anyhow!("Unreachable"))?; + + // `code` might have Windows line endings + let last_line = last_line.strip_suffix('\r').unwrap_or(last_line); + + if end.character != last_line.len() { + return Err(anyhow::anyhow!( + "Expected line {line} to have length {expected}, got {actual}", + line = end.line, + expected = end.character, + actual = last_line.len() + )); + } + + Ok(Some(CodeLocation { uri, start, end })) } } diff --git a/crates/ark/src/interface.rs b/crates/ark/src/interface.rs index 7ab3c39c7..37dc7c760 100644 --- a/crates/ark/src/interface.rs +++ b/crates/ark/src/interface.rs @@ -400,13 +400,13 @@ impl PendingInputs { let line_directive = format!( "#line {line} \"{uri}\"", - line = location.line + 1, + line = location.start.line + 1, uri = location.uri ); // Leading whitespace to ensure that R starts parsing expressions from // the expected `character` offset. - let leading_padding = " ".repeat(location.character); + let leading_padding = " ".repeat(location.start.character); // Collect existing leading trivia as (kind, text) tuples let existing_trivia: Vec<_> = first_token @@ -956,7 +956,7 @@ impl RMain { } } - let loc = req.extract_code_location().log_err().flatten(); + let loc = req.code_location().log_err().flatten(); // Return the code to the R console to be evaluated and the corresponding exec count ( diff --git a/crates/ark/tests/kernel-srcref.rs b/crates/ark/tests/kernel-srcref.rs index 450611b1f..95b1fcc21 100644 --- a/crates/ark/tests/kernel-srcref.rs +++ b/crates/ark/tests/kernel-srcref.rs @@ -51,7 +51,7 @@ fn test_execute_request_srcref_location_line_shift() { }, end: JupyterPositronPosition { line: 2, - character: 18, + character: 25, }, }, }; @@ -88,7 +88,7 @@ fn test_execute_request_srcref_location_line_and_column_shift() { }, end: JupyterPositronPosition { line: 2, - character: 22, + character: 25, }, }, }; From 0e19bb169535fe9bea58cd56fa1dc80e929d8e2c Mon Sep 17 00:00:00 2001 From: Lionel Henry Date: Mon, 8 Dec 2025 15:38:11 +0100 Subject: [PATCH 6/6] Properly check span of code location --- crates/amalthea/src/wire/execute_request.rs | 92 ++++++----- crates/ark/tests/kernel-srcref.rs | 160 +++++++++++++++++++- 2 files changed, 216 insertions(+), 36 deletions(-) diff --git a/crates/amalthea/src/wire/execute_request.rs b/crates/amalthea/src/wire/execute_request.rs index f7b5cfc88..b1941582a 100644 --- a/crates/amalthea/src/wire/execute_request.rs +++ b/crates/amalthea/src/wire/execute_request.rs @@ -94,50 +94,67 @@ impl ExecuteRequest { let uri = Url::parse(&location.uri).context("Failed to parse URI from code location")?; - let character_start = - unicode_char_to_utf8_offset(&self.code, 0, location.range.start.character)?; - let character_end = - unicode_char_to_utf8_offset(&self.code, 0, location.range.end.character)?; - - let start = Position { - line: location.range.start.line, - character: character_start, - }; - let end = Position { - line: location.range.end.line, - character: character_end, + // The location maps `self.code` to a range in the document. We'll first + // do a sanity check that the span dimensions (end - start) match the + // code extents. + let span_lines = location.range.end.line - location.range.start.line; + + // For multiline code, the last line's expected length is just `end.character`. + // For single-line code, the expected length is `end.character - start.character`. + let expected_last_line_chars = if span_lines == 0 { + location.range.end.character - location.range.start.character + } else { + location.range.end.character }; - // Sanity check: `code` conforms exactly to expected number of lines - let line_count_code = self.code.lines().count() - 1; - let line_count_message = (end.line - start.line) as usize; - if line_count_code != line_count_message { + let code_lines: Vec<&str> = self.code.lines().collect(); + let code_line_count = code_lines.len().saturating_sub(1); + + // Sanity check: `code` conforms exactly to expected number of lines in the span + if code_line_count != span_lines as usize { return Err(anyhow::anyhow!( "Line information does not match code line count (expected {}, got {})", - line_count_code, - line_count_message + code_line_count, + span_lines )); } - // Sanity check: the last line has exactly the expected number of UTF-8 bytes - let last_line = self - .code - .split('\n') - .last() - .ok_or_else(|| anyhow::anyhow!("Unreachable"))?; - - // `code` might have Windows line endings + let last_line_idx = code_lines.len().saturating_sub(1); + let last_line = code_lines.get(last_line_idx).unwrap_or(&""); let last_line = last_line.strip_suffix('\r').unwrap_or(last_line); + let last_line_chars = last_line.chars().count() as u32; - if end.character != last_line.len() { + // Sanity check: the last line has exactly the expected number of characters + if last_line_chars != expected_last_line_chars { return Err(anyhow::anyhow!( - "Expected line {line} to have length {expected}, got {actual}", - line = end.line, - expected = end.character, - actual = last_line.len() + "Expected last line to have {expected} characters, got {actual}", + expected = expected_last_line_chars, + actual = last_line_chars )); } + // Convert start character from unicode code points to UTF-8 bytes + let character_start = + unicode_char_to_utf8_offset(&self.code, 0, location.range.start.character)?; + + // End character is start + last line byte length (for single line) + // or just last line byte length (for multiline, since it's on a new line) + let last_line_bytes = last_line.len(); + let character_end = if span_lines == 0 { + character_start + last_line_bytes + } else { + last_line_bytes + }; + + let start = Position { + line: location.range.start.line, + character: character_start, + }; + let end = Position { + line: location.range.end.line, + character: character_end, + }; + Ok(Some(CodeLocation { uri, start, end })) } } @@ -150,18 +167,23 @@ fn unicode_char_to_utf8_offset(text: &str, line: u32, character: u32) -> anyhow: .nth(line as usize) .ok_or_else(|| anyhow::anyhow!("Line {line} not found in text"))?; - let line_chars = target_line.chars().count(); + unicode_char_to_utf8_offset_in_line(target_line, character) +} + +/// Converts a character count in unicode scalar values to a UTF-8 byte count. +fn unicode_char_to_utf8_offset_in_line(line: &str, character: u32) -> anyhow::Result { + let line_chars = line.chars().count(); if character as usize > line_chars { return Err(anyhow::anyhow!( - "Character position {character} exceeds line {line} length ({line_chars})" + "Character position {character} exceeds line length ({line_chars})" )); } - let byte_offset = target_line + let byte_offset = line .char_indices() .nth(character as usize) .map(|(byte_idx, _)| byte_idx) - .unwrap_or(target_line.len()); + .unwrap_or(line.len()); Ok(byte_offset) } diff --git a/crates/ark/tests/kernel-srcref.rs b/crates/ark/tests/kernel-srcref.rs index 95b1fcc21..7db9824e9 100644 --- a/crates/ark/tests/kernel-srcref.rs +++ b/crates/ark/tests/kernel-srcref.rs @@ -88,7 +88,7 @@ fn test_execute_request_srcref_location_line_and_column_shift() { }, end: JupyterPositronPosition { line: 2, - character: 25, + character: 25 + 3, }, }, }; @@ -110,3 +110,161 @@ $end ); }); } + +#[test] +fn test_execute_request_srcref_location_multiline() { + let frontend = DummyArkFrontend::lock(); + + // Code spans lines 3-5 in the document, starting at column 4 + let code_location = JupyterPositronLocation { + uri: "file:///path/to/file.R".to_owned(), + range: JupyterPositronRange { + start: JupyterPositronPosition { + line: 2, + character: 4, + }, + end: JupyterPositronPosition { + line: 4, + character: 7, + }, + }, + }; + + // Multiline function definition + let code = "fn <- function() { + 1 +}; NULL"; + frontend.execute_request_with_location(code, |_| (), code_location); + + // `function` starts at column 7 on line 1, with start.character=4 offset -> line 3, col 11 + // The closing brace is at column 1 on line 3 of code (line 5 in document) + frontend.execute_request(".ps.internal(get_srcref_range(fn))", |result| { + assert_eq!( + result, + "$start + line character\u{20} + 3 11\u{20} + +$end + line character\u{20} + 5 1\u{20} +" + ); + }); +} + +#[test] +fn test_execute_request_srcref_location_with_emoji_utf8_shift() { + let frontend = DummyArkFrontend::lock(); + + // Starting at line 3, column 3 (these input positions are in Unicode code points) + let code_location = JupyterPositronLocation { + uri: "file:///path/to/file.R".to_owned(), + range: JupyterPositronRange { + start: JupyterPositronPosition { + line: 2, + character: 3, + }, + end: JupyterPositronPosition { + line: 2, + character: 26 + 3, + }, + }, + }; + + // The function body contains a single emoji character. The input character positions above + // are specified as Unicode code points. The srcref we receive reports UTF-8 byte positions, + // so the presence of the multibyte emoji shifts the end position by the emoji's extra bytes. + frontend.execute_request_with_location("fn <- function() \"🙂\"; NULL", |_| (), code_location); + + // `function` starts at column 7 (code point counting), so with a start.character of 3 we get 10. + // The function body `"🙂"` ends at UTF-8 byte 20 locally (the closing quote), so with + // start.character=3 the reported end becomes 23. + frontend.execute_request(".ps.internal(get_srcref_range(fn))", |result| { + assert_eq!( + result, + "$start + line character\u{20} + 3 10\u{20} + +$end + line character\u{20} + 3 23\u{20} +" + ); + }); +} + +#[test] +fn test_execute_request_srcref_location_invalid_end_line() { + let frontend = DummyArkFrontend::lock(); + + // Invalid location: end line exceeds the number of lines in the input + let code_location = JupyterPositronLocation { + uri: "file:///path/to/file.R".to_owned(), + range: JupyterPositronRange { + start: JupyterPositronPosition { + line: 2, + character: 3, + }, + end: JupyterPositronPosition { + line: 10, + character: 25, + }, + }, + }; + frontend.execute_request_with_location("fn <- function() {}; NULL", |_| (), code_location); + + // With invalid location, fallback behavior uses start of file (line 1, column 1). + // `function` starts at column 7, the body ends at 19 (right-boundary position). + frontend.execute_request(".ps.internal(get_srcref_range(fn))", |result| { + assert_eq!( + result, + "$start + line character\u{20} + 1 7\u{20} + +$end + line character\u{20} + 1 19\u{20} +" + ); + }); +} + +#[test] +fn test_execute_request_srcref_location_invalid_end_character() { + let frontend = DummyArkFrontend::lock(); + + // Invalid location: end character exceeds the number of characters in the last line + let code_location = JupyterPositronLocation { + uri: "file:///path/to/file.R".to_owned(), + range: JupyterPositronRange { + start: JupyterPositronPosition { + line: 2, + character: 3, + }, + end: JupyterPositronPosition { + line: 2, + character: 1000, + }, + }, + }; + frontend.execute_request_with_location("fn <- function() {}; NULL", |_| (), code_location); + + // With invalid location, fallback behavior uses start of file (line 1, column 1). + // `function` starts at column 7, the body ends at 19 (right-boundary position). + frontend.execute_request(".ps.internal(get_srcref_range(fn))", |result| { + assert_eq!( + result, + "$start + line character\u{20} + 1 7\u{20} + +$end + line character\u{20} + 1 19\u{20} +" + ); + }); +}