Add kernel detection for language support of runnable markdown cells (#29664)

Jon Gretar Borgthorsson created

Closes #27757

Release Notes:

- List of runnable markdown cells is now based on detected jupyter
kernels instead of hardcoded to Python and TypeScript

Change summary

crates/repl/src/repl_editor.rs | 90 ++++++++++++++++++++++++++++-------
crates/repl/src/repl_store.rs  | 10 ++++
2 files changed, 82 insertions(+), 18 deletions(-)

Detailed changes

crates/repl/src/repl_editor.rs 🔗

@@ -97,7 +97,7 @@ pub fn run(
     };
 
     let (runnable_ranges, next_cell_point) =
-        runnable_ranges(&buffer.read(cx).snapshot(), selected_range);
+        runnable_ranges(&buffer.read(cx).snapshot(), selected_range, cx);
 
     for runnable_range in runnable_ranges {
         let Some(language) = multibuffer.read(cx).language_at(runnable_range.start, cx) else {
@@ -215,7 +215,8 @@ pub fn session(editor: WeakEntity<Editor>, cx: &mut App) -> SessionSupport {
     match kernelspec {
         Some(kernelspec) => SessionSupport::Inactive(kernelspec),
         None => {
-            if language_supported(&language.clone()) {
+            // For language_supported, need to check available kernels for language
+            if language_supported(&language.clone(), cx) {
                 SessionSupport::RequiresSetup(language.name())
             } else {
                 SessionSupport::Unsupported
@@ -414,10 +415,11 @@ fn jupytext_cells(
 fn runnable_ranges(
     buffer: &BufferSnapshot,
     range: Range<Point>,
+    cx: &mut App,
 ) -> (Vec<Range<Point>>, Option<Point>) {
     if let Some(language) = buffer.language() {
         if language.name() == "Markdown".into() {
-            return (markdown_code_blocks(buffer, range.clone()), None);
+            return (markdown_code_blocks(buffer, range.clone(), cx), None);
         }
     }
 
@@ -442,21 +444,30 @@ fn runnable_ranges(
 
 // We allow markdown code blocks to end in a trailing newline in order to render the output
 // below the final code fence. This is different than our behavior for selections and Jupytext cells.
-fn markdown_code_blocks(buffer: &BufferSnapshot, range: Range<Point>) -> Vec<Range<Point>> {
+fn markdown_code_blocks(
+    buffer: &BufferSnapshot,
+    range: Range<Point>,
+    cx: &mut App,
+) -> Vec<Range<Point>> {
     buffer
         .injections_intersecting_range(range)
-        .filter(|(_, language)| language_supported(language))
+        .filter(|(_, language)| language_supported(language, cx))
         .map(|(content_range, _)| {
             buffer.offset_to_point(content_range.start)..buffer.offset_to_point(content_range.end)
         })
         .collect()
 }
 
-fn language_supported(language: &Arc<Language>) -> bool {
-    match language.name().as_ref() {
-        "TypeScript" | "Python" => true,
-        _ => false,
-    }
+fn language_supported(language: &Arc<Language>, cx: &mut App) -> bool {
+    let store = ReplStore::global(cx);
+    let store_read = store.read(cx);
+
+    // Since we're just checking for general language support, we only need to look at
+    // the pure Jupyter kernels - these are all the globally available ones
+    store_read.pure_jupyter_kernel_specifications().any(|spec| {
+        // Convert to lowercase for case-insensitive comparison since kernels might report "python" while our language is "Python"
+        spec.language().as_ref().to_lowercase() == language.name().as_ref().to_lowercase()
+    })
 }
 
 fn get_language(editor: WeakEntity<Editor>, cx: &mut App) -> Option<Arc<Language>> {
@@ -506,7 +517,7 @@ mod tests {
         let snapshot = buffer.read(cx).snapshot();
 
         // Single-point selection
-        let (snippets, _) = runnable_ranges(&snapshot, Point::new(0, 4)..Point::new(0, 4));
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(0, 4)..Point::new(0, 4), cx);
         let snippets = snippets
             .into_iter()
             .map(|range| snapshot.text_for_range(range).collect::<String>())
@@ -514,7 +525,7 @@ mod tests {
         assert_eq!(snippets, vec!["print(1 + 1)"]);
 
         // Multi-line selection
-        let (snippets, _) = runnable_ranges(&snapshot, Point::new(0, 5)..Point::new(2, 0));
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(0, 5)..Point::new(2, 0), cx);
         let snippets = snippets
             .into_iter()
             .map(|range| snapshot.text_for_range(range).collect::<String>())
@@ -527,7 +538,7 @@ mod tests {
         );
 
         // Trimming multiple trailing blank lines
-        let (snippets, _) = runnable_ranges(&snapshot, Point::new(0, 5)..Point::new(5, 0));
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(0, 5)..Point::new(5, 0), cx);
 
         let snippets = snippets
             .into_iter()
@@ -580,7 +591,7 @@ mod tests {
         let snapshot = buffer.read(cx).snapshot();
 
         // Jupytext snippet surrounding an empty selection
-        let (snippets, _) = runnable_ranges(&snapshot, Point::new(2, 5)..Point::new(2, 5));
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(2, 5)..Point::new(2, 5), cx);
 
         let snippets = snippets
             .into_iter()
@@ -596,7 +607,7 @@ mod tests {
         );
 
         // Jupytext snippets intersecting a non-empty selection
-        let (snippets, _) = runnable_ranges(&snapshot, Point::new(2, 5)..Point::new(6, 2));
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(2, 5)..Point::new(6, 2), cx);
         let snippets = snippets
             .into_iter()
             .map(|range| snapshot.text_for_range(range).collect::<String>())
@@ -623,6 +634,49 @@ mod tests {
 
     #[gpui::test]
     fn test_markdown_code_blocks(cx: &mut App) {
+        use crate::kernels::LocalKernelSpecification;
+        use jupyter_protocol::JupyterKernelspec;
+
+        // Initialize settings
+        settings::init(cx);
+        editor::init(cx);
+
+        // Initialize the ReplStore with a fake filesystem
+        let fs = Arc::new(project::RealFs::new(None, cx.background_executor().clone()));
+        ReplStore::init(fs, cx);
+
+        // Add mock kernel specifications for TypeScript and Python
+        let store = ReplStore::global(cx);
+        store.update(cx, |store, cx| {
+            let typescript_spec = KernelSpecification::Jupyter(LocalKernelSpecification {
+                name: "typescript".into(),
+                kernelspec: JupyterKernelspec {
+                    argv: vec![],
+                    display_name: "TypeScript".into(),
+                    language: "typescript".into(),
+                    interrupt_mode: None,
+                    metadata: None,
+                    env: None,
+                },
+                path: std::path::PathBuf::new(),
+            });
+
+            let python_spec = KernelSpecification::Jupyter(LocalKernelSpecification {
+                name: "python".into(),
+                kernelspec: JupyterKernelspec {
+                    argv: vec![],
+                    display_name: "Python".into(),
+                    language: "python".into(),
+                    interrupt_mode: None,
+                    metadata: None,
+                    env: None,
+                },
+                path: std::path::PathBuf::new(),
+            });
+
+            store.set_kernel_specs_for_testing(vec![typescript_spec, python_spec], cx);
+        });
+
         let markdown = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
         let typescript = languages::language(
             "typescript",
@@ -658,7 +712,7 @@ mod tests {
         });
         let snapshot = buffer.read(cx).snapshot();
 
-        let (snippets, _) = runnable_ranges(&snapshot, Point::new(3, 5)..Point::new(8, 5));
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(3, 5)..Point::new(8, 5), cx);
         let snippets = snippets
             .into_iter()
             .map(|range| snapshot.text_for_range(range).collect::<String>())
@@ -703,7 +757,7 @@ mod tests {
         });
         let snapshot = buffer.read(cx).snapshot();
 
-        let (snippets, _) = runnable_ranges(&snapshot, Point::new(3, 5)..Point::new(12, 5));
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(3, 5)..Point::new(12, 5), cx);
         let snippets = snippets
             .into_iter()
             .map(|range| snapshot.text_for_range(range).collect::<String>())
@@ -742,7 +796,7 @@ mod tests {
         });
         let snapshot = buffer.read(cx).snapshot();
 
-        let (snippets, _) = runnable_ranges(&snapshot, Point::new(4, 5)..Point::new(5, 5));
+        let (snippets, _) = runnable_ranges(&snapshot, Point::new(4, 5)..Point::new(5, 5), cx);
         let snippets = snippets
             .into_iter()
             .map(|range| snapshot.text_for_range(range).collect::<String>())

crates/repl/src/repl_store.rs 🔗

@@ -279,4 +279,14 @@ impl ReplStore {
     pub fn remove_session(&mut self, entity_id: EntityId) {
         self.sessions.remove(&entity_id);
     }
+
+    #[cfg(test)]
+    pub fn set_kernel_specs_for_testing(
+        &mut self,
+        specs: Vec<KernelSpecification>,
+        cx: &mut Context<Self>,
+    ) {
+        self.kernel_specifications = specs;
+        cx.notify();
+    }
 }