mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
repl: Add Jupyter kernel completion and inspection support
Integrate Jupyter's `complete_request`/`complete_reply` and `inspect_request`/`inspect_reply` protocols into Zed's editor completion system. When a REPL session is active, a composite completion provider wraps the existing LSP provider to fetch completions from both the language server and the Jupyter kernel in parallel. Selecting a kernel completion triggers an inspect request to populate documentation. Context sent to the kernel is informed by how mainstream kernels (IJulia, ipykernel/xeus-python, IRkernel) actually implement these handlers: - For completion, send the runnable chunk surrounding the cursor (the same unit `run` would execute) rather than the whole buffer. Kernels expect cell-sized input, and sending unrelated code can mislead jedi or be wasted on REPL-style completers (IJulia, IRkernel) that only look at the local syntactic context. Reusing `runnable_ranges` keeps this aligned with the user's mental model and lets future improvements to chunk detection (e.g. Tree-sitter top-level node selection) carry over automatically. - For inspect, send only the candidate symbol with the cursor at its end. Every surveyed kernel resolves documentation by extracting the token at the cursor (`get_token` in IJulia, `token_at_cursor` in ipykernel, `.guessTokenFromLine` in IRkernel, regex in xeus-cling), and ipykernel's `do_inspect` does a live-namespace lookup rather than jedi-based scope analysis, so surrounding context yields no benefit. IHaskell additionally ignores `cursor_pos` and uses the last whitespace-separated chunk, so injecting any code after the symbol would actively break inspection there. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
700b0b5de6
commit
94bf3a5b1f
4 changed files with 653 additions and 11 deletions
392
crates/repl/src/jupyter_completion_provider.rs
Normal file
392
crates/repl/src/jupyter_completion_provider.rs
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::ops::Range;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use editor::{CompletionContext, CompletionProvider, Editor};
|
||||||
|
use gpui::{Context, Entity, Task, WeakEntity, Window};
|
||||||
|
use language::{self, CodeLabel, ToOffset, ToPoint};
|
||||||
|
use project::{
|
||||||
|
Completion, CompletionDisplayOptions, CompletionResponse, CompletionSource, Project,
|
||||||
|
lsp_store::CompletionDocumentation,
|
||||||
|
};
|
||||||
|
use runtimelib::media::MediaType;
|
||||||
|
|
||||||
|
use crate::repl_editor::{CompletionChunk, completion_chunk};
|
||||||
|
use crate::session::Session;
|
||||||
|
|
||||||
|
pub(crate) struct JupyterCompletionProvider {
|
||||||
|
project: Entity<Project>,
|
||||||
|
session: WeakEntity<Session>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JupyterCompletionProvider {
|
||||||
|
pub fn new(project: Entity<Project>, session: WeakEntity<Session>) -> Self {
|
||||||
|
Self { project, session }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CompletionProvider for JupyterCompletionProvider {
|
||||||
|
fn completions(
|
||||||
|
&self,
|
||||||
|
buffer: &Entity<language::Buffer>,
|
||||||
|
buffer_position: language::Anchor,
|
||||||
|
trigger: CompletionContext,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Editor>,
|
||||||
|
) -> Task<Result<Vec<CompletionResponse>>> {
|
||||||
|
let lsp_completions =
|
||||||
|
self.project
|
||||||
|
.completions(buffer, buffer_position, trigger, window, cx);
|
||||||
|
|
||||||
|
let snapshot = buffer.read(cx).snapshot();
|
||||||
|
let cursor_point = buffer_position.to_point(&snapshot);
|
||||||
|
let chunk = completion_chunk(&snapshot, cursor_point, cx);
|
||||||
|
|
||||||
|
let jupyter_receiver = chunk.as_ref().and_then(|chunk| {
|
||||||
|
let session = self.session.upgrade()?;
|
||||||
|
session.update(cx, |session, cx| {
|
||||||
|
session.request_completions(chunk.code.clone(), chunk.cursor_pos, cx)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let timer = cx.background_executor().timer(Duration::from_secs(5));
|
||||||
|
cx.spawn(async move |_, _cx| {
|
||||||
|
let mut responses = lsp_completions.await.unwrap_or_default();
|
||||||
|
|
||||||
|
if let (Some(rx), Some(chunk)) = (jupyter_receiver, chunk) {
|
||||||
|
let reply = match futures::future::select(std::pin::pin!(rx), std::pin::pin!(timer))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
futures::future::Either::Left((Ok(reply), _)) => Some(reply),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(reply) = reply
|
||||||
|
&& reply.status == runtimelib::ReplyStatus::Ok
|
||||||
|
{
|
||||||
|
if let Some(response) = jupyter_reply_to_completion_response(
|
||||||
|
&reply,
|
||||||
|
&chunk,
|
||||||
|
&snapshot,
|
||||||
|
buffer_position,
|
||||||
|
) {
|
||||||
|
responses.push(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(responses)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_completions(
|
||||||
|
&self,
|
||||||
|
buffer: Entity<language::Buffer>,
|
||||||
|
completion_indices: Vec<usize>,
|
||||||
|
completions: Rc<RefCell<Box<[Completion]>>>,
|
||||||
|
cx: &mut Context<Editor>,
|
||||||
|
) -> Task<Result<bool>> {
|
||||||
|
let mut lsp_indices = Vec::new();
|
||||||
|
let mut jupyter_indices = Vec::new();
|
||||||
|
|
||||||
|
for &index in &completion_indices {
|
||||||
|
match &completions.borrow()[index].source {
|
||||||
|
CompletionSource::Custom => jupyter_indices.push(index),
|
||||||
|
_ => lsp_indices.push(index),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lsp_task = if lsp_indices.is_empty() {
|
||||||
|
Task::ready(Ok(false))
|
||||||
|
} else {
|
||||||
|
self.project
|
||||||
|
.resolve_completions(buffer, lsp_indices, completions.clone(), cx)
|
||||||
|
};
|
||||||
|
|
||||||
|
let jupyter_receivers: Vec<_> = jupyter_indices
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&index| {
|
||||||
|
let session = self.session.upgrade()?;
|
||||||
|
let new_text = completions.borrow()[index].new_text.clone();
|
||||||
|
let cursor_pos = new_text.chars().count();
|
||||||
|
let rx = session.update(cx, |session, cx| {
|
||||||
|
session.request_inspect(new_text, cursor_pos, cx)
|
||||||
|
})?;
|
||||||
|
Some((index, rx))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let executor = cx.background_executor().clone();
|
||||||
|
cx.spawn(async move |_, _cx| {
|
||||||
|
let lsp_resolved = lsp_task.await.unwrap_or(false);
|
||||||
|
|
||||||
|
let mut jupyter_resolved = false;
|
||||||
|
for (index, rx) in jupyter_receivers {
|
||||||
|
let timeout = executor.timer(Duration::from_secs(5));
|
||||||
|
let reply = match futures::future::select(
|
||||||
|
std::pin::pin!(rx),
|
||||||
|
std::pin::pin!(timeout),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
futures::future::Either::Left((Ok(reply), _)) => Some(reply),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(reply) = reply
|
||||||
|
&& reply.found
|
||||||
|
&& reply.status == runtimelib::ReplyStatus::Ok
|
||||||
|
{
|
||||||
|
if let Some(documentation) = inspect_reply_to_documentation(&reply) {
|
||||||
|
completions.borrow_mut()[index].documentation = Some(documentation);
|
||||||
|
jupyter_resolved = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(lsp_resolved || jupyter_resolved)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_additional_edits_for_completion(
|
||||||
|
&self,
|
||||||
|
buffer: Entity<language::Buffer>,
|
||||||
|
completions: Rc<RefCell<Box<[Completion]>>>,
|
||||||
|
completion_index: usize,
|
||||||
|
push_to_history: bool,
|
||||||
|
all_commit_ranges: Vec<Range<language::Anchor>>,
|
||||||
|
cx: &mut Context<Editor>,
|
||||||
|
) -> Task<Result<Option<language::Transaction>>> {
|
||||||
|
self.project.apply_additional_edits_for_completion(
|
||||||
|
buffer,
|
||||||
|
completions,
|
||||||
|
completion_index,
|
||||||
|
push_to_history,
|
||||||
|
all_commit_ranges,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_completion_trigger(
|
||||||
|
&self,
|
||||||
|
buffer: &Entity<language::Buffer>,
|
||||||
|
position: language::Anchor,
|
||||||
|
text: &str,
|
||||||
|
trigger_in_words: bool,
|
||||||
|
cx: &mut Context<Editor>,
|
||||||
|
) -> bool {
|
||||||
|
self.project
|
||||||
|
.is_completion_trigger(buffer, position, text, trigger_in_words, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sort_completions(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_completions(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_snippets(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn char_offset_to_byte_offset(text: &str, char_offset: usize) -> usize {
|
||||||
|
text.char_indices()
|
||||||
|
.nth(char_offset)
|
||||||
|
.map(|(byte_idx, _)| byte_idx)
|
||||||
|
.unwrap_or(text.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jupyter_reply_to_completion_response(
|
||||||
|
reply: &runtimelib::CompleteReply,
|
||||||
|
chunk: &CompletionChunk,
|
||||||
|
snapshot: &language::BufferSnapshot,
|
||||||
|
buffer_position: language::Anchor,
|
||||||
|
) -> Option<CompletionResponse> {
|
||||||
|
if reply.matches.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let start_byte = chunk.start_byte + char_offset_to_byte_offset(&chunk.code, reply.cursor_start);
|
||||||
|
let end_byte = chunk.start_byte + char_offset_to_byte_offset(&chunk.code, reply.cursor_end);
|
||||||
|
|
||||||
|
let replace_start = snapshot.anchor_after(start_byte);
|
||||||
|
let replace_end = snapshot.anchor_before(end_byte);
|
||||||
|
let replace_range = replace_start..replace_end;
|
||||||
|
|
||||||
|
let byte_offset = buffer_position.to_offset(snapshot);
|
||||||
|
let match_start_byte = start_byte.min(byte_offset);
|
||||||
|
let match_start = snapshot.anchor_after(match_start_byte);
|
||||||
|
|
||||||
|
let completions = reply
|
||||||
|
.matches
|
||||||
|
.iter()
|
||||||
|
.map(|match_text| Completion {
|
||||||
|
replace_range: replace_range.clone(),
|
||||||
|
new_text: match_text.clone(),
|
||||||
|
label: CodeLabel::filtered(
|
||||||
|
format!("{match_text} Jupyter"),
|
||||||
|
match_text.len(),
|
||||||
|
None,
|
||||||
|
vec![],
|
||||||
|
),
|
||||||
|
documentation: None,
|
||||||
|
source: CompletionSource::Custom,
|
||||||
|
icon_path: None,
|
||||||
|
match_start: Some(match_start),
|
||||||
|
snippet_deduplication_key: None,
|
||||||
|
insert_text_mode: None,
|
||||||
|
confirm: None,
|
||||||
|
group: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Some(CompletionResponse {
|
||||||
|
completions,
|
||||||
|
display_options: CompletionDisplayOptions::default(),
|
||||||
|
is_incomplete: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_reply_to_documentation(
|
||||||
|
reply: &runtimelib::InspectReply,
|
||||||
|
) -> Option<CompletionDocumentation> {
|
||||||
|
for media_type in &reply.data.content {
|
||||||
|
if let MediaType::Markdown(text) = media_type {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(CompletionDocumentation::MultiLineMarkdown(
|
||||||
|
trimmed.to_string().into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for media_type in &reply.data.content {
|
||||||
|
if let MediaType::Plain(text) = media_type {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(CompletionDocumentation::MultiLinePlainText(
|
||||||
|
trimmed.to_string().into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use gpui::{App, AppContext as _};
|
||||||
|
use language::Buffer;
|
||||||
|
|
||||||
|
fn make_chunk(code: &str, start_byte: usize) -> CompletionChunk {
|
||||||
|
CompletionChunk {
|
||||||
|
code: code.into(),
|
||||||
|
cursor_pos: code.chars().count(),
|
||||||
|
start_byte,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn test_jupyter_reply_empty_matches(cx: &mut App) {
|
||||||
|
let buffer = cx.new(|cx| Buffer::local("foo bar", cx));
|
||||||
|
let snapshot = buffer.read(cx).snapshot();
|
||||||
|
let buffer_position = snapshot.anchor_after(4);
|
||||||
|
let chunk = make_chunk("foo bar", 0);
|
||||||
|
let reply = runtimelib::CompleteReply::default();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
jupyter_reply_to_completion_response(&reply, &chunk, &snapshot, buffer_position)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn test_jupyter_reply_translates_offsets(cx: &mut App) {
|
||||||
|
// Buffer matches the chunk one-to-one; chunk starts at byte 0.
|
||||||
|
let text = "import numpy as np\nnp.ar";
|
||||||
|
let buffer = cx.new(|cx| Buffer::local(text, cx));
|
||||||
|
let snapshot = buffer.read(cx).snapshot();
|
||||||
|
let buffer_position = snapshot.anchor_after(text.len());
|
||||||
|
let chunk = make_chunk(text, 0);
|
||||||
|
let reply = runtimelib::CompleteReply {
|
||||||
|
matches: vec!["np.array".into(), "np.arange".into()],
|
||||||
|
cursor_start: 19, // start of "np.ar" (after "import numpy as np\n")
|
||||||
|
cursor_end: 24, // end of "np.ar"
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let response =
|
||||||
|
jupyter_reply_to_completion_response(&reply, &chunk, &snapshot, buffer_position)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.completions.len(), 2);
|
||||||
|
let completion = &response.completions[0];
|
||||||
|
assert_eq!(completion.replace_range.start.to_offset(&snapshot), 19);
|
||||||
|
assert_eq!(completion.replace_range.end.to_offset(&snapshot), 24);
|
||||||
|
assert_eq!(completion.new_text, "np.array");
|
||||||
|
// In the common case, `match_start` equals the replace-range start.
|
||||||
|
assert_eq!(completion.match_start.unwrap().to_offset(&snapshot), 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn test_jupyter_reply_chunk_offset_into_buffer(cx: &mut App) {
|
||||||
|
// The chunk lives at byte 12 of the buffer; replace_range must be
|
||||||
|
// shifted by chunk.start_byte to land in the right buffer span.
|
||||||
|
let prelude = "x = 1\ny = 2\n"; // 12 bytes
|
||||||
|
let chunk_text = "z.fo";
|
||||||
|
let mut text = String::from(prelude);
|
||||||
|
text.push_str(chunk_text);
|
||||||
|
let buffer = cx.new({
|
||||||
|
let text = text.clone();
|
||||||
|
|cx| Buffer::local(text, cx)
|
||||||
|
});
|
||||||
|
let snapshot = buffer.read(cx).snapshot();
|
||||||
|
let buffer_position = snapshot.anchor_after(text.len());
|
||||||
|
let chunk = make_chunk(chunk_text, 12);
|
||||||
|
let reply = runtimelib::CompleteReply {
|
||||||
|
matches: vec!["z.foo".into()],
|
||||||
|
cursor_start: 0,
|
||||||
|
cursor_end: 4,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let response =
|
||||||
|
jupyter_reply_to_completion_response(&reply, &chunk, &snapshot, buffer_position)
|
||||||
|
.unwrap();
|
||||||
|
let completion = &response.completions[0];
|
||||||
|
assert_eq!(completion.replace_range.start.to_offset(&snapshot), 12);
|
||||||
|
assert_eq!(completion.replace_range.end.to_offset(&snapshot), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn test_jupyter_reply_multibyte_offsets(cx: &mut App) {
|
||||||
|
// "π" is 2 bytes / 1 char, so char-offset and byte-offset diverge.
|
||||||
|
let text = "x = π.";
|
||||||
|
let buffer = cx.new(|cx| Buffer::local(text, cx));
|
||||||
|
let snapshot = buffer.read(cx).snapshot();
|
||||||
|
let buffer_position = snapshot.anchor_after(text.len());
|
||||||
|
let chunk = make_chunk(text, 0);
|
||||||
|
let reply = runtimelib::CompleteReply {
|
||||||
|
matches: vec!["π.bit_length".into()],
|
||||||
|
cursor_start: 4, // char offset of "π"
|
||||||
|
cursor_end: 6, // char offset after "."
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let response =
|
||||||
|
jupyter_reply_to_completion_response(&reply, &chunk, &snapshot, buffer_position)
|
||||||
|
.unwrap();
|
||||||
|
let completion = &response.completions[0];
|
||||||
|
// char offset 4 → byte 4 ("x = " = 4 bytes)
|
||||||
|
// char offset 6 → byte 7 ("x = π." = 4 + 2 + 1 = 7 bytes)
|
||||||
|
assert_eq!(completion.replace_range.start.to_offset(&snapshot), 4);
|
||||||
|
assert_eq!(completion.replace_range.end.to_offset(&snapshot), 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod components;
|
pub mod components;
|
||||||
|
mod jupyter_completion_provider;
|
||||||
mod jupyter_settings;
|
mod jupyter_settings;
|
||||||
pub mod kernels;
|
pub mod kernels;
|
||||||
pub mod notebook;
|
pub mod notebook;
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
//! REPL operations on an [`Editor`].
|
//! REPL operations on an [`Editor`].
|
||||||
|
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use editor::{Editor, MultiBufferOffset};
|
use editor::{Editor, MultiBufferOffset};
|
||||||
use gpui::{App, Entity, WeakEntity, Window, prelude::*};
|
use gpui::{App, Entity, WeakEntity, Window, prelude::*};
|
||||||
use language::{BufferSnapshot, Language, LanguageName, Point};
|
use language::{BufferSnapshot, Language, LanguageName, Point, ToOffset as _};
|
||||||
use project::{ProjectItem as _, WorktreeId};
|
use project::{ProjectItem as _, WorktreeId};
|
||||||
use workspace::{Workspace, notifications::NotificationId};
|
use workspace::{Workspace, notifications::NotificationId};
|
||||||
|
|
||||||
|
use crate::jupyter_completion_provider::JupyterCompletionProvider;
|
||||||
use crate::kernels::PythonEnvKernelSpecification;
|
use crate::kernels::PythonEnvKernelSpecification;
|
||||||
use crate::repl_store::ReplStore;
|
use crate::repl_store::ReplStore;
|
||||||
use crate::session::SessionEvent;
|
use crate::session::SessionEvent;
|
||||||
|
|
@ -51,15 +53,24 @@ pub fn assign_kernelspec(
|
||||||
cx.new(|cx| Session::new(weak_editor.clone(), fs, kernel_specification, window, cx));
|
cx.new(|cx| Session::new(weak_editor.clone(), fs, kernel_specification, window, cx));
|
||||||
|
|
||||||
weak_editor
|
weak_editor
|
||||||
.update(cx, |_editor, cx| {
|
.update(cx, |editor, cx| {
|
||||||
|
if let Some(project) = editor.project().cloned() {
|
||||||
|
let provider =
|
||||||
|
Rc::new(JupyterCompletionProvider::new(project, session.downgrade()));
|
||||||
|
editor.set_completion_provider(Some(provider));
|
||||||
|
}
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
cx.subscribe(&session, {
|
cx.subscribe(&session, {
|
||||||
let store = store.clone();
|
let store = store.clone();
|
||||||
move |_this, _session, event, cx| match event {
|
move |this, _session, event, cx| match event {
|
||||||
SessionEvent::Shutdown(shutdown_event) => {
|
SessionEvent::Shutdown(shutdown_editor) => {
|
||||||
|
if let Some(project) = this.project().cloned() {
|
||||||
|
this.set_completion_provider(Some(Rc::new(project)));
|
||||||
|
}
|
||||||
store.update(cx, |store, _cx| {
|
store.update(cx, |store, _cx| {
|
||||||
store.remove_session(shutdown_event.entity_id());
|
store.remove_session(shutdown_editor.entity_id());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -253,15 +264,24 @@ pub fn run(
|
||||||
let session =
|
let session =
|
||||||
cx.new(|cx| Session::new(weak_editor, fs, kernel_specification, window, cx));
|
cx.new(|cx| Session::new(weak_editor, fs, kernel_specification, window, cx));
|
||||||
|
|
||||||
editor.update(cx, |_editor, cx| {
|
editor.update(cx, |editor, cx| {
|
||||||
|
if let Some(project) = editor.project().cloned() {
|
||||||
|
let provider =
|
||||||
|
Rc::new(JupyterCompletionProvider::new(project, session.downgrade()));
|
||||||
|
editor.set_completion_provider(Some(provider));
|
||||||
|
}
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
cx.subscribe(&session, {
|
cx.subscribe(&session, {
|
||||||
let store = store.clone();
|
let store = store.clone();
|
||||||
move |_this, _session, event, cx| match event {
|
move |this, _session, event, cx| match event {
|
||||||
SessionEvent::Shutdown(shutdown_event) => {
|
SessionEvent::Shutdown(shutdown_editor) => {
|
||||||
|
if let Some(project) = this.project().cloned() {
|
||||||
|
this.set_completion_provider(Some(Rc::new(project)));
|
||||||
|
}
|
||||||
store.update(cx, |store, _cx| {
|
store.update(cx, |store, _cx| {
|
||||||
store.remove_session(shutdown_event.entity_id());
|
store.remove_session(shutdown_editor.entity_id());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -503,6 +523,42 @@ pub fn setup_editor_session_actions(editor: &mut Editor, editor_handle: WeakEnti
|
||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) struct CompletionChunk {
|
||||||
|
pub code: String,
|
||||||
|
/// Char offset of the cursor within `code`.
|
||||||
|
pub cursor_pos: usize,
|
||||||
|
/// Byte offset within the buffer where `code` starts.
|
||||||
|
pub start_byte: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the runnable chunk surrounding `cursor` along with the cursor's char
|
||||||
|
/// offset within that chunk. The chunk is the same unit that would be sent to
|
||||||
|
/// the kernel if the user invoked `run` at this position, so completion and
|
||||||
|
/// inspection requests share the kernel's mental model of "the current cell".
|
||||||
|
pub(crate) fn completion_chunk(
|
||||||
|
buffer: &BufferSnapshot,
|
||||||
|
cursor: Point,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Option<CompletionChunk> {
|
||||||
|
let (ranges, _) = runnable_ranges(buffer, cursor..cursor, cx);
|
||||||
|
// `runnable_ranges` may skip forward to the next cell when the cursor is on
|
||||||
|
// a blank line; that range is not useful as completion context, so only
|
||||||
|
// accept a chunk that actually contains the cursor.
|
||||||
|
let range = ranges
|
||||||
|
.into_iter()
|
||||||
|
.find(|r| r.start <= cursor && cursor <= r.end)?;
|
||||||
|
let start_byte = range.start.to_offset(buffer);
|
||||||
|
let cursor_byte = cursor.to_offset(buffer);
|
||||||
|
let code: String = buffer.text_for_range(range).collect();
|
||||||
|
let cursor_byte_in_chunk = cursor_byte.saturating_sub(start_byte).min(code.len());
|
||||||
|
let cursor_pos = code[..cursor_byte_in_chunk].chars().count();
|
||||||
|
Some(CompletionChunk {
|
||||||
|
code,
|
||||||
|
cursor_pos,
|
||||||
|
start_byte,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn cell_range(buffer: &BufferSnapshot, start_row: u32, end_row: u32) -> Range<Point> {
|
fn cell_range(buffer: &BufferSnapshot, start_row: u32, end_row: u32) -> Range<Point> {
|
||||||
let mut snippet_end_row = end_row;
|
let mut snippet_end_row = end_row;
|
||||||
while buffer.is_line_blank(snippet_end_row) && snippet_end_row > start_row {
|
while buffer.is_line_blank(snippet_end_row) && snippet_end_row > start_row {
|
||||||
|
|
@ -1081,4 +1137,131 @@ mod tests {
|
||||||
let (snippets, _) = runnable_ranges(&snapshot, Point::new(1, 0)..Point::new(1, 0), cx);
|
let (snippets, _) = runnable_ranges(&snapshot, Point::new(1, 0)..Point::new(1, 0), cx);
|
||||||
assert!(snippets.is_empty());
|
assert!(snippets.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn test_completion_chunk_basic(cx: &mut App) {
|
||||||
|
let test_language = Arc::new(Language::new(
|
||||||
|
LanguageConfig {
|
||||||
|
name: "TestLang".into(),
|
||||||
|
line_comments: vec!["# ".into()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
|
||||||
|
let buffer = cx.new(|cx| {
|
||||||
|
Buffer::local(
|
||||||
|
indoc! { r#"
|
||||||
|
print(1 + 1)
|
||||||
|
print(2 + 2)
|
||||||
|
"# },
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.with_language(test_language, cx)
|
||||||
|
});
|
||||||
|
let snapshot = buffer.read(cx).snapshot();
|
||||||
|
|
||||||
|
let chunk = completion_chunk(&snapshot, Point::new(0, 6), cx).unwrap();
|
||||||
|
assert_eq!(chunk.code, "print(1 + 1)");
|
||||||
|
assert_eq!(chunk.cursor_pos, 6);
|
||||||
|
assert_eq!(chunk.start_byte, 0);
|
||||||
|
|
||||||
|
// Second line: chunk starts after "print(1 + 1)\n" (13 bytes).
|
||||||
|
let chunk = completion_chunk(&snapshot, Point::new(1, 4), cx).unwrap();
|
||||||
|
assert_eq!(chunk.code, "print(2 + 2)");
|
||||||
|
assert_eq!(chunk.cursor_pos, 4);
|
||||||
|
assert_eq!(chunk.start_byte, 13);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn test_completion_chunk_jupytext(cx: &mut App) {
|
||||||
|
let test_language = Arc::new(Language::new(
|
||||||
|
LanguageConfig {
|
||||||
|
name: "TestLang".into(),
|
||||||
|
line_comments: vec!["# ".into()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
|
||||||
|
let buffer = cx.new(|cx| {
|
||||||
|
Buffer::local(
|
||||||
|
indoc! { r#"
|
||||||
|
# %%
|
||||||
|
x = 1
|
||||||
|
y = 2
|
||||||
|
# %%
|
||||||
|
z = 3
|
||||||
|
"# },
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.with_language(test_language, cx)
|
||||||
|
});
|
||||||
|
let snapshot = buffer.read(cx).snapshot();
|
||||||
|
|
||||||
|
// Cursor inside the first cell.
|
||||||
|
let chunk = completion_chunk(&snapshot, Point::new(2, 4), cx).unwrap();
|
||||||
|
assert_eq!(chunk.code, "# %%\nx = 1\ny = 2");
|
||||||
|
assert_eq!(chunk.cursor_pos, 15);
|
||||||
|
assert_eq!(chunk.start_byte, 0);
|
||||||
|
|
||||||
|
// Cursor inside the second cell; chunk starts at row 3 (byte 17).
|
||||||
|
let chunk = completion_chunk(&snapshot, Point::new(4, 2), cx).unwrap();
|
||||||
|
assert_eq!(chunk.code, "# %%\nz = 3");
|
||||||
|
assert_eq!(chunk.cursor_pos, 7);
|
||||||
|
assert_eq!(chunk.start_byte, 17);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn test_completion_chunk_blank_line_returns_none(cx: &mut App) {
|
||||||
|
let test_language = Arc::new(Language::new(
|
||||||
|
LanguageConfig {
|
||||||
|
name: "TestLang".into(),
|
||||||
|
line_comments: vec!["# ".into()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
|
||||||
|
let buffer = cx.new(|cx| {
|
||||||
|
Buffer::local(
|
||||||
|
indoc! { r#"
|
||||||
|
print(1 + 1)
|
||||||
|
|
||||||
|
print(2 + 2)
|
||||||
|
"# },
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.with_language(test_language, cx)
|
||||||
|
});
|
||||||
|
let snapshot = buffer.read(cx).snapshot();
|
||||||
|
|
||||||
|
// `runnable_ranges` skips forward to the next non-blank cell, but the
|
||||||
|
// returned range does not contain the cursor on row 1, so
|
||||||
|
// `completion_chunk` rejects it.
|
||||||
|
assert!(completion_chunk(&snapshot, Point::new(1, 0), cx).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn test_completion_chunk_multibyte(cx: &mut App) {
|
||||||
|
let test_language = Arc::new(Language::new(
|
||||||
|
LanguageConfig {
|
||||||
|
name: "TestLang".into(),
|
||||||
|
line_comments: vec!["# ".into()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
|
||||||
|
// "π" is 2 bytes, so byte and char counts diverge inside the chunk.
|
||||||
|
let buffer = cx.new(|cx| Buffer::local("x = π * 2", cx).with_language(test_language, cx));
|
||||||
|
let snapshot = buffer.read(cx).snapshot();
|
||||||
|
|
||||||
|
// Byte column 6 = right after "π" (which occupies bytes 4..6).
|
||||||
|
let chunk = completion_chunk(&snapshot, Point::new(0, 6), cx).unwrap();
|
||||||
|
assert_eq!(chunk.code, "x = π * 2");
|
||||||
|
// chunk.code[..6] = "x = π" → 5 chars
|
||||||
|
assert_eq!(chunk.cursor_pos, 5);
|
||||||
|
assert_eq!(chunk.start_byte, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,8 @@ use gpui::{
|
||||||
use language::Point;
|
use language::Point;
|
||||||
use project::Fs;
|
use project::Fs;
|
||||||
use runtimelib::{
|
use runtimelib::{
|
||||||
ExecuteRequest, ExecutionState, InputReply, InterruptRequest, JupyterMessage,
|
CompleteRequest, ExecuteRequest, ExecutionState, InputReply, InspectRequest, InterruptRequest,
|
||||||
JupyterMessageContent, KernelInfoRequest, ReplyStatus, ShutdownRequest,
|
JupyterMessage, JupyterMessageContent, KernelInfoRequest, ReplyStatus, ShutdownRequest,
|
||||||
};
|
};
|
||||||
use settings::Settings as _;
|
use settings::Settings as _;
|
||||||
use std::{env::temp_dir, ops::Range, sync::Arc, time::Duration};
|
use std::{env::temp_dir, ops::Range, sync::Arc, time::Duration};
|
||||||
|
|
@ -53,6 +53,10 @@ pub struct Session {
|
||||||
blocks: HashMap<String, EditorBlock>,
|
blocks: HashMap<String, EditorBlock>,
|
||||||
result_inlays: HashMap<String, (InlayId, Range<Anchor>, usize)>,
|
result_inlays: HashMap<String, (InlayId, Range<Anchor>, usize)>,
|
||||||
next_inlay_id: usize,
|
next_inlay_id: usize,
|
||||||
|
pending_completion_replies:
|
||||||
|
HashMap<String, futures::channel::oneshot::Sender<runtimelib::CompleteReply>>,
|
||||||
|
pending_inspect_replies:
|
||||||
|
HashMap<String, futures::channel::oneshot::Sender<runtimelib::InspectReply>>,
|
||||||
|
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
@ -257,6 +261,8 @@ impl Session {
|
||||||
blocks: HashMap::default(),
|
blocks: HashMap::default(),
|
||||||
result_inlays: HashMap::default(),
|
result_inlays: HashMap::default(),
|
||||||
next_inlay_id: 0,
|
next_inlay_id: 0,
|
||||||
|
pending_completion_replies: HashMap::default(),
|
||||||
|
pending_inspect_replies: HashMap::default(),
|
||||||
kernel_specification,
|
kernel_specification,
|
||||||
_subscriptions: vec![subscription],
|
_subscriptions: vec![subscription],
|
||||||
};
|
};
|
||||||
|
|
@ -486,6 +492,54 @@ impl Session {
|
||||||
anyhow::Ok(())
|
anyhow::Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn request_completions(
|
||||||
|
&mut self,
|
||||||
|
code: String,
|
||||||
|
cursor_pos: usize,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Option<futures::channel::oneshot::Receiver<runtimelib::CompleteReply>> {
|
||||||
|
if !matches!(&self.kernel, Kernel::RunningKernel(_)) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.pending_completion_replies.clear();
|
||||||
|
|
||||||
|
let request = CompleteRequest { code, cursor_pos };
|
||||||
|
let message: JupyterMessage = JupyterMessageContent::CompleteRequest(request).into();
|
||||||
|
let msg_id = message.header.msg_id.clone();
|
||||||
|
|
||||||
|
let (tx, rx) = futures::channel::oneshot::channel();
|
||||||
|
self.pending_completion_replies.insert(msg_id, tx);
|
||||||
|
self.send(message, cx).log_err();
|
||||||
|
|
||||||
|
Some(rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_inspect(
|
||||||
|
&mut self,
|
||||||
|
code: String,
|
||||||
|
cursor_pos: usize,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Option<futures::channel::oneshot::Receiver<runtimelib::InspectReply>> {
|
||||||
|
if !matches!(&self.kernel, Kernel::RunningKernel(_)) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = InspectRequest {
|
||||||
|
code,
|
||||||
|
cursor_pos,
|
||||||
|
detail_level: Some(0),
|
||||||
|
};
|
||||||
|
let message: JupyterMessage = JupyterMessageContent::InspectRequest(request).into();
|
||||||
|
let msg_id = message.header.msg_id.clone();
|
||||||
|
|
||||||
|
let (tx, rx) = futures::channel::oneshot::channel();
|
||||||
|
self.pending_inspect_replies.insert(msg_id, tx);
|
||||||
|
self.send(message, cx).log_err();
|
||||||
|
|
||||||
|
Some(rx)
|
||||||
|
}
|
||||||
|
|
||||||
fn send_stdin_reply(
|
fn send_stdin_reply(
|
||||||
&mut self,
|
&mut self,
|
||||||
value: String,
|
value: String,
|
||||||
|
|
@ -999,6 +1053,18 @@ impl KernelSession for Session {
|
||||||
self.kernel.set_kernel_info(reply);
|
self.kernel.set_kernel_info(reply);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
JupyterMessageContent::CompleteReply(reply) => {
|
||||||
|
if let Some(tx) = self.pending_completion_replies.remove(parent_message_id) {
|
||||||
|
tx.send(reply.clone()).ok();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JupyterMessageContent::InspectReply(reply) => {
|
||||||
|
if let Some(tx) = self.pending_inspect_replies.remove(parent_message_id) {
|
||||||
|
tx.send(reply.clone()).ok();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
JupyterMessageContent::UpdateDisplayData(update) => {
|
JupyterMessageContent::UpdateDisplayData(update) => {
|
||||||
let display_id = if let Some(display_id) = update.transient.display_id.clone() {
|
let display_id = if let Some(display_id) = update.transient.display_id.clone() {
|
||||||
display_id
|
display_id
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue