From 87d93033d1318be3674f322932e3c12e8c9680c2 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 24 Jul 2024 11:53:58 +0200 Subject: [PATCH] Support Jupytext-style line comments for REPL evaluation ranges (#15073) This adds support for detecting line comments in the [Jupytext](https://jupytext.readthedocs.io/) format. When line comments such as `# %%` is present, invoking `repl: run` will evaluate the code between these line comments as a unit. /cc @rgbkrk ```py # %% # This is my first block print(1) print(2) # %% # This is my second block print(3) ``` Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra Co-authored-by: Antonio Co-authored-by: Thorsten Co-authored-by: Thorsten Ball --- Cargo.lock | 1 + Cargo.toml | 1 + crates/editor/Cargo.toml | 2 +- crates/project/Cargo.toml | 2 +- crates/repl/Cargo.toml | 1 + crates/repl/src/repl_editor.rs | 360 ++++++++++++++++++++++++++------- crates/repl/src/session.rs | 9 +- 7 files changed, 295 insertions(+), 81 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 996f3b44a7..a71a04ca94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8665,6 +8665,7 @@ dependencies = [ "gpui", "http_client", "image", + "indoc", "language", "log", "multi_buffer", diff --git a/Cargo.toml b/Cargo.toml index 5028bf85a8..96c7219404 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -161,6 +161,7 @@ resolver = "2" [workspace.dependencies] activity_indicator = { path = "crates/activity_indicator" } +aho-corasick = "1.1" ai = { path = "crates/ai" } anthropic = { path = "crates/anthropic" } assets = { path = "crates/assets" } diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index ab0a77c1de..8fb4e3be32 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -28,7 +28,7 @@ test-support = [ ] [dependencies] -aho-corasick = "1.1" +aho-corasick.workspace = true anyhow.workspace = true assets.workspace = true chrono.workspace = true diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index ff5fd693f2..1c3c6a05dc 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -24,7 +24,7 @@ test-support = [ ] [dependencies] -aho-corasick = "1.1" +aho-corasick.workspace = true anyhow.workspace = true async-trait.workspace = true client.workspace = true diff --git a/crates/repl/Cargo.toml b/crates/repl/Cargo.toml index 0c98ddc2a7..eba647a3ef 100644 --- a/crates/repl/Cargo.toml +++ b/crates/repl/Cargo.toml @@ -45,6 +45,7 @@ editor = { workspace = true, features = ["test-support"] } env_logger.workspace = true gpui = { workspace = true, features = ["test-support"] } http_client = { workspace = true, features = ["test-support"] } +indoc.workspace = true language = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } diff --git a/crates/repl/src/repl_editor.rs b/crates/repl/src/repl_editor.rs index e0c64cef01..b1b79daf89 100644 --- a/crates/repl/src/repl_editor.rs +++ b/crates/repl/src/repl_editor.rs @@ -4,10 +4,9 @@ use std::ops::Range; use std::sync::Arc; use anyhow::{Context, Result}; -use editor::{Anchor, Editor, RangeToAnchorExt}; -use gpui::{prelude::*, AppContext, View, WeakView, WindowContext}; -use language::{Language, Point}; -use multi_buffer::MultiBufferRow; +use editor::Editor; +use gpui::{prelude::*, AppContext, Entity, View, WeakView, WindowContext}; +use language::{BufferSnapshot, Language, Point}; use crate::repl_store::ReplStore; use crate::session::SessionEvent; @@ -19,50 +18,69 @@ pub fn run(editor: WeakView, cx: &mut WindowContext) -> Result<()> { return Ok(()); } - let Some((selected_text, language, anchor_range)) = snippet(editor.clone(), cx) else { + let editor = editor.upgrade().context("editor was dropped")?; + let selected_range = editor + .update(cx, |editor, cx| editor.selections.newest_adjusted(cx)) + .range(); + let multibuffer = editor.read(cx).buffer().clone(); + let Some(buffer) = multibuffer.read(cx).as_singleton() else { return Ok(()); }; - let entity_id = editor.entity_id(); + for range in snippet_ranges(&buffer.read(cx).snapshot(), selected_range) { + let Some(language) = multibuffer.read(cx).language_at(range.start, cx) else { + continue; + }; - let kernel_specification = store.update(cx, |store, cx| { - store - .kernelspec(&language, cx) - .with_context(|| format!("No kernel found for language: {}", language.name())) - })?; - - let fs = store.read(cx).fs().clone(); - let session = if let Some(session) = store.read(cx).get_session(entity_id).cloned() { - session - } else { - let session = cx.new_view(|cx| Session::new(editor.clone(), fs, kernel_specification, cx)); - - editor.update(cx, |_editor, cx| { - cx.notify(); - - cx.subscribe(&session, { - let store = store.clone(); - move |_this, _session, event, cx| match event { - SessionEvent::Shutdown(shutdown_event) => { - store.update(cx, |store, _cx| { - store.remove_session(shutdown_event.entity_id()); - }); - } - } - }) - .detach(); + let kernel_specification = store.update(cx, |store, cx| { + store + .kernelspec(&language, cx) + .with_context(|| format!("No kernel found for language: {}", language.name())) })?; - store.update(cx, |store, _cx| { - store.insert_session(entity_id, session.clone()); + let fs = store.read(cx).fs().clone(); + let session = if let Some(session) = store.read(cx).get_session(editor.entity_id()).cloned() + { + session + } else { + let weak_editor = editor.downgrade(); + let session = cx.new_view(|cx| Session::new(weak_editor, fs, kernel_specification, cx)); + + editor.update(cx, |_editor, cx| { + cx.notify(); + + cx.subscribe(&session, { + let store = store.clone(); + move |_this, _session, event, cx| match event { + SessionEvent::Shutdown(shutdown_event) => { + store.update(cx, |store, _cx| { + store.remove_session(shutdown_event.entity_id()); + }); + } + } + }) + .detach(); + }); + + store.update(cx, |store, _cx| { + store.insert_session(editor.entity_id(), session.clone()); + }); + + session + }; + + let selected_text; + let anchor_range; + { + let snapshot = multibuffer.read(cx).read(cx); + selected_text = snapshot.text_for_range(range.clone()).collect::(); + anchor_range = snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end); + } + + session.update(cx, |session, cx| { + session.execute(selected_text, anchor_range, cx); }); - - session - }; - - session.update(cx, |session, cx| { - session.execute(&selected_text, anchor_range, cx); - }); + } anyhow::Ok(()) } @@ -134,48 +152,91 @@ pub fn shutdown(editor: WeakView, cx: &mut WindowContext) { }); } -fn snippet( - editor: WeakView, - cx: &mut WindowContext, -) -> Option<(String, Arc, Range)> { - let selection = editor - .update(cx, |editor, cx| editor.selections.newest_adjusted(cx)) - .ok()?; +fn snippet_range(buffer: &BufferSnapshot, start_row: u32, end_row: u32) -> Range { + let mut snippet_end_row = end_row; + while buffer.is_line_blank(snippet_end_row) && snippet_end_row > start_row { + snippet_end_row -= 1; + } + Point::new(start_row, 0)..Point::new(snippet_end_row, buffer.line_len(snippet_end_row)) +} - let editor = editor.upgrade()?; - let editor = editor.read(cx); +fn jupytext_snippets(buffer: &BufferSnapshot, range: Range) -> Vec> { + let mut current_row = range.start.row; - let buffer = editor.buffer().read(cx).snapshot(cx); - let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx); - - let range = if selection.is_empty() { - Point::new(selection.start.row, 0) - ..Point::new( - selection.start.row, - multi_buffer_snapshot.line_len(MultiBufferRow(selection.start.row)), - ) - } else { - let mut range = selection.range(); - if range.end.column == 0 { - range.end.row -= 1; - range.end.column = multi_buffer_snapshot.line_len(MultiBufferRow(range.end.row)); - } - range + let Some(language) = buffer.language() else { + return Vec::new(); }; - let anchor_range = range.to_anchors(&multi_buffer_snapshot); - - let selected_text = buffer - .text_for_range(anchor_range.clone()) - .collect::(); - - let start_language = buffer.language_at(anchor_range.start)?; - let end_language = buffer.language_at(anchor_range.end)?; - if start_language != end_language { - return None; + let default_scope = language.default_scope(); + let comment_prefixes = default_scope.line_comment_prefixes(); + if comment_prefixes.is_empty() { + return Vec::new(); } - Some((selected_text, start_language.clone(), anchor_range)) + let jupytext_prefixes = comment_prefixes + .iter() + .map(|comment_prefix| format!("{comment_prefix}%%")) + .collect::>(); + + let mut snippet_start_row = None; + loop { + if jupytext_prefixes + .iter() + .any(|prefix| buffer.contains_str_at(Point::new(current_row, 0), prefix)) + { + snippet_start_row = Some(current_row); + break; + } else if current_row > 0 { + current_row -= 1; + } else { + break; + } + } + + let mut snippets = Vec::new(); + if let Some(mut snippet_start_row) = snippet_start_row { + for current_row in range.start.row + 1..=buffer.max_point().row { + if jupytext_prefixes + .iter() + .any(|prefix| buffer.contains_str_at(Point::new(current_row, 0), prefix)) + { + snippets.push(snippet_range(buffer, snippet_start_row, current_row - 1)); + + if current_row <= range.end.row { + snippet_start_row = current_row; + } else { + return snippets; + } + } + } + + snippets.push(snippet_range( + buffer, + snippet_start_row, + buffer.max_point().row, + )); + } + + snippets +} + +fn snippet_ranges(buffer: &BufferSnapshot, range: Range) -> Vec> { + let jupytext_snippets = jupytext_snippets(buffer, range.clone()); + if !jupytext_snippets.is_empty() { + return jupytext_snippets; + } + + let snippet_range = snippet_range(buffer, range.start.row, range.end.row); + let start_language = buffer.language_at(snippet_range.start); + let end_language = buffer.language_at(snippet_range.end); + + if let Some((start, end)) = start_language.zip(end_language) { + if start == end { + return vec![snippet_range]; + } + } + + Vec::new() } fn get_language(editor: WeakView, cx: &mut AppContext) -> Option> { @@ -184,3 +245,148 @@ fn get_language(editor: WeakView, cx: &mut AppContext) -> Option()) + .collect::>(); + assert_eq!(snippets, vec!["print(1 + 1)"]); + + // Multi-line selection + let snippets = snippet_ranges(&snapshot, Point::new(0, 5)..Point::new(2, 0)) + .into_iter() + .map(|range| snapshot.text_for_range(range).collect::()) + .collect::>(); + assert_eq!( + snippets, + vec![indoc! { r#" + print(1 + 1) + print(2 + 2)"# }] + ); + + // Trimming multiple trailing blank lines + let snippets = snippet_ranges(&snapshot, Point::new(0, 5)..Point::new(5, 0)) + .into_iter() + .map(|range| snapshot.text_for_range(range).collect::()) + .collect::>(); + assert_eq!( + snippets, + vec![indoc! { r#" + print(1 + 1) + print(2 + 2) + + print(4 + 4)"# }] + ); + } + + #[gpui::test] + fn test_jupytext_snippet_ranges(cx: &mut AppContext) { + // Create a test language + let test_language = Arc::new(Language::new( + LanguageConfig { + name: "TestLang".into(), + line_comments: vec!["# ".into()], + ..Default::default() + }, + None, + )); + + let buffer = cx.new_model(|cx| { + Buffer::local( + indoc! { r#" + # Hello! + # %% [markdown] + # This is some arithmetic + print(1 + 1) + print(2 + 2) + + # %% + print(3 + 3) + print(4 + 4) + + print(5 + 5) + + + + "# }, + cx, + ) + .with_language(test_language, cx) + }); + let snapshot = buffer.read(cx).snapshot(); + + // Jupytext snippet surrounding an empty selection + let snippets = snippet_ranges(&snapshot, Point::new(2, 5)..Point::new(2, 5)) + .into_iter() + .map(|range| snapshot.text_for_range(range).collect::()) + .collect::>(); + assert_eq!( + snippets, + vec![indoc! { r#" + # %% [markdown] + # This is some arithmetic + print(1 + 1) + print(2 + 2)"# }] + ); + + // Jupytext snippets intersecting a non-empty selection + let snippets = snippet_ranges(&snapshot, Point::new(2, 5)..Point::new(6, 2)) + .into_iter() + .map(|range| snapshot.text_for_range(range).collect::()) + .collect::>(); + assert_eq!( + snippets, + vec![ + indoc! { r#" + # %% [markdown] + # This is some arithmetic + print(1 + 1) + print(2 + 2)"# + }, + indoc! { r#" + # %% + print(3 + 3) + print(4 + 4) + + print(5 + 5)"# + } + ] + ); + } +} diff --git a/crates/repl/src/session.rs b/crates/repl/src/session.rs index dfe1c78b48..3eee410f98 100644 --- a/crates/repl/src/session.rs +++ b/crates/repl/src/session.rs @@ -377,7 +377,12 @@ impl Session { self.blocks.clear(); } - pub fn execute(&mut self, code: &str, anchor_range: Range, cx: &mut ViewContext) { + pub fn execute( + &mut self, + code: String, + anchor_range: Range, + cx: &mut ViewContext, + ) { let Some(editor) = self.editor.upgrade() else { return; }; @@ -387,7 +392,7 @@ impl Session { } let execute_request = ExecuteRequest { - code: code.to_string(), + code, ..ExecuteRequest::default() };