Repository: marimo-team/marimo
Pull request: feat: go-to-definition with cmd+click
# Pull Request Template

## 📝 Summary

<!-- 
Provide a brief summary of what this pull request is addressing. Describe the changes made and the main issue fixed or feature implemented. Keep it concise and informative.
-->

This adds go-to-definition with cmd+click

## 🔍 Description of Changes

<!-- 
Detail the specific changes made in this pull request. Explain the problem addressed and how it was resolved. If applicable, provide before and after comparisons or any relevant details to help reviewers understand the changes easily.
-->

Uses a codemirror decorator with underline and builds off some existing go-to-definition logic we have. Most of this work is just the codemirror plugin and some tests. 

## 🛠️ Additional Information

<!-- 
Include any additional information or context that might be helpful for reviewers. This could include additional dependencies, screenshots, performance improvements, or any other relevant information.
-->

## 📋 Checklist Before Submitting

- [x] I have read the [contributor guidelines](../CONTRIBUTING.md) and the Pull Request section.
- [x] This PR fixes an issue (If applicable, specify issue number #).
- [x] This change was discussed or approved through an issue or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable).
- [x] I have added necessary tests for the changes made.
- [x] I have run the code and verified that it works as expected.

## 🧩 Related Issues

<!-- 
If this PR fixes any issues, list them here by number (e.g., Fixes #123). 
-->

Further fixes #1126

## 📜 Who Can Review?

<!-- 
Tag potential reviewers from the community or maintainers who might be interested in reviewing this pull request. This helps expedite the review process and ensures that the PR receives timely attention.
-->

<!-- Your PR will be reviewed more quickly if you can figure out the right person to tag with @ -->
@akshayka


Problem statement:
Go to cell defining something
### Description

Quickly go to the cell defining a variable or a function.

### Suggested solution

- An item in the right click menu.
- A keyboard shortcut.

### Alternative

_No response_

### Additional context

_No response_

Hints from issue discussion:
Could this be a simple matter to code now that #1365 is fixed?
I didn't factor https://github.com/marimo-team/marimo/pull/1365 in when making these changes.

Here's the plan I used to make the change:
https://glide.agenticlabs.com/sharing?userId=65bae76b7fbfb1c20ac1f3b8&taskId=ghM1mqX

Do we think this needs to be updated based on the new functionality of `goToDefinition`? It looked like from my testing that `goToDefinition` just focused on the first instance of the variable within the cell, not across cells?
I thought thanks to marimo's design we already know the cell defining a
variable, so the remainder is going to the (single) definition in that cell.
Thanks for introducing me to glide, but I think I don't have the time and
understanding to contribute right now!

On Sun, May 19, 2024 at 3:15 PM Robert McKnight ***@***.***>
wrote:

> I didn't factor #1365 <https://github.com/marimo-team/marimo/pull/1365>
> in when making these changes.
>
> Here's the plan I used to make the change:
>
> https://glide.agenticlabs.com/sharing?userId=65bae76b7fbfb1c20ac1f3b8&taskId=ghM1mqX
>
> Do we think this needs to be updated based on the new functionality of
> goToDefinition? It looked like from my testing that goToDefinition just
> focused on the first instance of the variable within the cell, not across
> cells?
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/marimo-team/marimo/issues/1126#issuecomment-2119214990>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AYWFZPD2VSJJH6W7IPOVXMTZDCJU7AVCNFSM6AAAAABGEPXL76VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDCMJZGIYTIOJZGA>
> .
> You are receiving this because you commented.Message ID:
> ***@***.***>
>


-- 
Alon Levy
Software Developer
Greenvibe
054-2395317

Commit message: feat: go-to-definition with cmd+click

Patch to review:
diff --git a/frontend/src/components/dependency-graph/panels.tsx b/frontend/src/components/dependency-graph/panels.tsx
index 61010ed8ead..7560679c435 100644
--- a/frontend/src/components/dependency-graph/panels.tsx
+++ b/frontend/src/components/dependency-graph/panels.tsx
@@ -1,6 +1,6 @@
 /* Copyright 2024 Marimo. All rights reserved. */
 import React, { memo } from "react";
-import { Edge, Panel } from "reactflow";
+import { type Edge, Panel } from "reactflow";
 import { Button } from "../ui/button";
 import {
   Rows3Icon,
@@ -13,18 +13,18 @@ import {
   SettingsIcon,
   MoreVerticalIcon,
 } from "lucide-react";
-import { GraphLayoutView, GraphSelection, GraphSettings } from "./types";
+import type { GraphLayoutView, GraphSelection, GraphSettings } from "./types";
 import { CellLink } from "../editor/links/cell-link";
 import { CellLinkList } from "../editor/links/cell-link-list";
 import { VariableName } from "../variables/common";
-import { Variable, Variables } from "@/core/variables/types";
+import type { Variable, Variables } from "@/core/variables/types";
 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
 import { Checkbox } from "../ui/checkbox";
 import { Label } from "../ui/label";
 import { ConnectionCellActionsDropdown } from "../editor/cell/cell-actions";
 import { getCellEditorView } from "@/core/cells/cells";
-import { CellId } from "@/core/cells/ids";
-import { goToDefinition } from "@/core/codemirror/find-replace/search-highlight";
+import type { CellId } from "@/core/cells/ids";
+import { goToVariableDefinition } from "@/core/codemirror/go-to-definition/commands";
 
 interface Props {
   view: GraphLayoutView;
@@ -120,7 +120,7 @@ export const GraphSelectionPanel: React.FC<{
   const highlightInCell = (cellId: CellId, variableName: string) => {
     const editorView = getCellEditorView(cellId);
     if (editorView) {
-      goToDefinition(editorView, variableName);
+      goToVariableDefinition(editorView, variableName);
     }
   };
 
diff --git a/frontend/src/components/editor/cell/cell-context-menu.tsx b/frontend/src/components/editor/cell/cell-context-menu.tsx
index 48063c18d1f..8b670458430 100644
--- a/frontend/src/components/editor/cell/cell-context-menu.tsx
+++ b/frontend/src/components/editor/cell/cell-context-menu.tsx
@@ -1,7 +1,7 @@
 /* Copyright 2024 Marimo. All rights reserved. */
 import React, { Fragment } from "react";
 import {
-  CellActionButtonProps,
+  type CellActionButtonProps,
   useCellActionButtons,
 } from "../actions/useCellActionButton";
 import {
@@ -12,7 +12,7 @@ import {
   ContextMenuSeparator,
 } from "@/components/ui/context-menu";
 import { renderMinimalShortcut } from "@/components/shortcuts/renderShortcut";
-import { ActionButton } from "../actions/types";
+import type { ActionButton } from "../actions/types";
 import {
   ClipboardPasteIcon,
   CopyIcon,
@@ -20,7 +20,7 @@ import {
   ScissorsIcon,
   SearchIcon,
 } from "lucide-react";
-import { goToDefinition } from "@/core/codemirror/go-to-definition";
+import { goToDefinitionAtCursorPosition } from "@/core/codemirror/go-to-definition/utils";
 
 interface Props extends CellActionButtonProps {
   children: React.ReactNode;
@@ -90,7 +90,7 @@ export const CellActionsContextMenu = ({ children, ...props }: Props) => {
         const { getEditorView } = props;
         const editorView = getEditorView();
         if (editorView) {
-          goToDefinition(editorView);
+          goToDefinitionAtCursorPosition(editorView);
         }
       },
     },
diff --git a/frontend/src/components/variables/variables-table.tsx b/frontend/src/components/variables/variables-table.tsx
index a0b695d1512..203cb669f4a 100644
--- a/frontend/src/components/variables/variables-table.tsx
+++ b/frontend/src/components/variables/variables-table.tsx
@@ -8,8 +8,8 @@ import {
   TableCell,
   Table,
 } from "../ui/table";
-import { Variable, Variables } from "@/core/variables/types";
-import { CellId } from "@/core/cells/ids";
+import type { Variable, Variables } from "@/core/variables/types";
+import type { CellId } from "@/core/cells/ids";
 import { CellLink } from "@/components/editor/links/cell-link";
 import { cn } from "@/utils/cn";
 import { SquareEqualIcon, WorkflowIcon } from "lucide-react";
@@ -17,16 +17,16 @@ import {
   useReactTable,
   getCoreRowModel,
   flexRender,
-  ColumnDef,
-  SortingState,
+  type ColumnDef,
+  type SortingState,
   getSortedRowModel,
-  ColumnSort,
+  type ColumnSort,
   getFilteredRowModel,
 } from "@tanstack/react-table";
 import { DataTableColumnHeader } from "../data-table/column-header";
 import { sortBy } from "lodash-es";
 import { getCellEditorView } from "@/core/cells/cells";
-import { goToDefinition } from "@/core/codemirror/find-replace/search-highlight";
+import { goToVariableDefinition } from "@/core/codemirror/go-to-definition/commands";
 import { SearchInput } from "../ui/input";
 import { CellLinkList } from "../editor/links/cell-link-list";
 import { VariableName } from "./common";
@@ -122,7 +122,7 @@ const COLUMNS = [
       const highlightInCell = (cellId: CellId) => {
         const editorView = getCellEditorView(cellId);
         if (editorView) {
-          goToDefinition(editorView, name);
+          goToVariableDefinition(editorView, name);
         }
       };
 
diff --git a/frontend/src/core/cells/cells.ts b/frontend/src/core/cells/cells.ts
index da947abc453..66193561181 100644
--- a/frontend/src/core/cells/cells.ts
+++ b/frontend/src/core/cells/cells.ts
@@ -34,7 +34,7 @@ import { clamp } from "@/utils/math";
 import type { LayoutState } from "../layout/layout";
 import { notebookIsRunning } from "./utils";
 import {
-  getEditorCodeAsPython,
+  splitEditor,
   updateEditorCodeFromPython,
 } from "../codemirror/language/utils";
 
@@ -627,38 +627,18 @@ const {
       cellLogs: [],
     };
   },
-  splitCell: (state, action: { cellId: CellId; cursorPos: number }) => {
-    const { cellId, cursorPos } = action;
+  splitCell: (state, action: { cellId: CellId }) => {
+    const { cellId } = action;
     const index = state.cellIds.indexOf(cellId);
     const cell = state.cellData[cellId];
     const cellHandle = state.cellHandles[cellId].current;
 
     if (cellHandle?.editorView == null) {
-      // TODO: Because of this we can't do  the reducer tests like for the other functions
       return state;
     }
 
-    // Figure out if we're at the start or end of a line to adjust the cursor positions
-    const isCursorAtLineStart =
-      cell.code.length > 0 && cell.code[cursorPos - 1] === "\n";
-    const isCursorAtLineEnd =
-      cell.code.length > 0 && cell.code[cursorPos] === "\n";
-
-    const beforeAdjustedCursorPos = isCursorAtLineStart
-      ? cursorPos - 1
-      : cursorPos;
-    const afterAdjustedCursorPos = isCursorAtLineEnd
-      ? cursorPos + 1
-      : cursorPos;
-
-    const beforeCursorCode = getEditorCodeAsPython(
-      cellHandle.editorView,
-      0,
-      beforeAdjustedCursorPos,
-    );
-    const afterCursorCode = getEditorCodeAsPython(
+    const { beforeCursorCode, afterCursorCode } = splitEditor(
       cellHandle.editorView,
-      afterAdjustedCursorPos,
     );
 
     updateEditorCodeFromPython(cellHandle.editorView, beforeCursorCode);
@@ -673,7 +653,9 @@ const {
         [cellId]: {
           ...cell,
           code: beforeCursorCode,
-          edited: beforeCursorCode.trim() !== cell.lastCodeRun,
+          edited:
+            Boolean(beforeCursorCode) &&
+            beforeCursorCode.trim() !== cell.lastCodeRun?.trim(),
         },
         [newCellId]: createCell({
           id: newCellId,
diff --git a/frontend/src/core/cells/scrollCellIntoView.ts b/frontend/src/core/cells/scrollCellIntoView.ts
index 4ebc3f2ceee..756ffa5da70 100644
--- a/frontend/src/core/cells/scrollCellIntoView.ts
+++ b/frontend/src/core/cells/scrollCellIntoView.ts
@@ -1,10 +1,10 @@
 /* Copyright 2024 Marimo. All rights reserved. */
-import { RefObject } from "react";
+import type { RefObject } from "react";
 import { Logger } from "../../utils/Logger";
-import { CellId, HTMLCellId } from "./ids";
-import { CellHandle } from "@/components/editor/Cell";
-import { CellConfig } from "./types";
-import { goToDefinition } from "../codemirror/find-replace/search-highlight";
+import { type CellId, HTMLCellId } from "./ids";
+import type { CellHandle } from "@/components/editor/Cell";
+import type { CellConfig } from "./types";
+import { goToVariableDefinition } from "../codemirror/go-to-definition/commands";
 
 export function focusAndScrollCellIntoView({
   cellId,
@@ -57,7 +57,7 @@ export function focusAndScrollCellIntoView({
         },
       });
     } else if (variableName) {
-      goToDefinition(editor, variableName);
+      goToVariableDefinition(editor, variableName);
     }
   }
 
diff --git a/frontend/src/core/codemirror/cells/extensions.ts b/frontend/src/core/codemirror/cells/extensions.ts
index 550d14fb1f9..daeb01e5e69 100644
--- a/frontend/src/core/codemirror/cells/extensions.ts
+++ b/frontend/src/core/codemirror/cells/extensions.ts
@@ -9,7 +9,7 @@ import { getEditorCodeAsPython } from "../language/utils";
 import { formattingChangeEffect } from "../format";
 import { closeCompletion, completionStatus } from "@codemirror/autocomplete";
 import { isAtEndOfEditor, isAtStartOfEditor } from "../utils";
-import { goToDefinition } from "../go-to-definition";
+import { goToDefinitionAtCursorPosition } from "../go-to-definition/utils";
 
 export interface MovementCallbacks
   extends Pick<
@@ -246,7 +246,7 @@ export function cellMovementBundle(
       preventDefault: true,
       stopPropagation: true,
       run: (ev) => {
-        goToDefinition(ev);
+        goToDefinitionAtCursorPosition(ev);
         return true;
       },
     },
@@ -255,8 +255,7 @@ export function cellMovementBundle(
       preventDefault: true,
       stopPropagation: true,
       run: (ev) => {
-        const cursorPos = ev.state.selection.main.head;
-        splitCell({ cellId, cursorPos });
+        splitCell({ cellId });
         requestAnimationFrame(() => {
           ev.contentDOM.blur();
           moveToNextCell({ cellId, before: false }); // focus new cell
diff --git a/frontend/src/core/codemirror/cm.ts b/frontend/src/core/codemirror/cm.ts
index bc676fe9316..b48ada3bae5 100644
--- a/frontend/src/core/codemirror/cm.ts
+++ b/frontend/src/core/codemirror/cm.ts
@@ -34,20 +34,20 @@ import {
   EditorView,
 } from "@codemirror/view";
 
-import { EditorState, Extension, Prec } from "@codemirror/state";
+import { EditorState, type Extension, Prec } from "@codemirror/state";
 import { oneDark } from "@codemirror/theme-one-dark";
 
-import { CompletionConfig, KeymapConfig } from "../config/config-schema";
-import { Theme } from "../../theme/useTheme";
+import type { CompletionConfig, KeymapConfig } from "../config/config-schema";
+import type { Theme } from "../../theme/useTheme";
 
 import { findReplaceBundle } from "./find-replace/extension";
 import {
-  CodeCallbacks,
-  MovementCallbacks,
+  type CodeCallbacks,
+  type MovementCallbacks,
   cellCodeEditingBundle,
   cellMovementBundle,
 } from "./cells/extensions";
-import { CellId } from "../cells/ids";
+import type { CellId } from "../cells/ids";
 import { keymapBundle } from "./keymaps/keymaps";
 import { scrollActiveLineIntoView } from "./extensions";
 import { copilotBundle } from "./copilot/extension";
@@ -58,6 +58,7 @@ import {
   clickablePlaceholderExtension,
   smartPlaceholderExtension,
 } from "./placeholder/extensions";
+import { goToDefinitionBundle } from "./go-to-definition/extension";
 
 export interface CodeMirrorSetupOpts {
   cellId: CellId;
@@ -91,6 +92,8 @@ export const setupCodeMirror = ({
     cellCodeEditingBundle(cellId, cellCodeCallbacks),
     // Comes last so that it can be overridden
     basicBundle(completionConfig, theme),
+    // Underline cmd+clickable placeholder
+    goToDefinitionBundle(),
     showPlaceholder
       ? Prec.highest(smartPlaceholderExtension("import marimo as mo"))
       : enableAI
diff --git a/frontend/src/core/codemirror/find-replace/search-highlight.ts b/frontend/src/core/codemirror/find-replace/search-highlight.ts
index 8f31fb1bf2f..e7d2044a048 100644
--- a/frontend/src/core/codemirror/find-replace/search-highlight.ts
+++ b/frontend/src/core/codemirror/find-replace/search-highlight.ts
@@ -4,15 +4,14 @@ import { RangeSetBuilder, StateEffect, StateField } from "@codemirror/state";
 import {
   Decoration,
   ViewPlugin,
-  DecorationSet,
+  type DecorationSet,
   EditorView,
-  ViewUpdate,
+  type ViewUpdate,
 } from "@codemirror/view";
-import { QueryType, asQueryCreator } from "./query";
+import { type QueryType, asQueryCreator } from "./query";
 import { store } from "@/core/state/jotai";
 import { findReplaceAtom } from "./state";
 import { getAllEditorViews } from "@/core/cells/cells";
-import { syntaxTree } from "@codemirror/language";
 
 const setSearchQuery = StateEffect.define<SearchQuery>();
 
@@ -147,57 +146,3 @@ export const highlightTheme = EditorView.baseTheme({
     backgroundColor: "#6199ff88 !important",
   },
 });
-
-/**
- * This function will select the first occurrence of the given variable name.
- */
-export function goToDefinition(
-  view: EditorView,
-  variableName: string,
-): boolean {
-  const state = view.state;
-  const tree = syntaxTree(state);
-
-  let found = false;
-  let from = 0;
-
-  tree.iterate({
-    enter: (node) => {
-      if (found) {
-        return false;
-      } // Stop traversal if found
-
-      // Check if the node is an identifier and matches the variable name
-      if (
-        node.name === "VariableName" &&
-        state.doc.sliceString(node.from, node.to) === variableName
-      ) {
-        from = node.from;
-        found = true;
-        return false; // Stop traversal
-      }
-
-      // Skip comments and strings
-      if (node.name === "Comment" || node.name === "String") {
-        return false;
-      }
-    },
-  });
-
-  if (found) {
-    view.focus();
-    view.dispatch({
-      selection: {
-        anchor: from,
-        head: from,
-      },
-      // Unfortunately, EditorView.scrollIntoView does
-      // not support smooth scrolling.
-      effects: EditorView.scrollIntoView(from, {
-        y: "center",
-      }),
-    });
-    return true;
-  }
-  return false;
-}
diff --git a/frontend/src/core/codemirror/go-to-definition.ts b/frontend/src/core/codemirror/go-to-definition.ts
deleted file mode 100644
index cfbb4186f2d..00000000000
--- a/frontend/src/core/codemirror/go-to-definition.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/* Copyright 2024 Marimo. All rights reserved. */
-import { EditorState } from "@codemirror/state";
-import { EditorView } from "@codemirror/view";
-import { getPositionAtWordBounds } from "../codemirror/completion/hints";
-import { VariableName, Variables } from "../variables/types";
-import { focusAndScrollCellIntoView } from "../cells/scrollCellIntoView";
-import { store } from "../state/jotai";
-import { notebookAtom } from "../cells/cells";
-import { variablesAtom } from "../variables/state";
-
-const getWordUnderCursor = (state: EditorState) => {
-  const { from, to } = state.selection.main;
-  let variableName: string;
-
-  if (from === to) {
-    const { startToken, endToken } = getPositionAtWordBounds(state.doc, from);
-    variableName = state.doc.sliceString(startToken, endToken);
-  } else {
-    variableName = state.doc.sliceString(from, to);
-  }
-
-  return variableName;
-};
-
-const getCellIdOfDefinition = (variables: Variables, variableName: string) => {
-  const variable = variables[variableName as VariableName];
-  if (!variable || variable.declaredBy.length === 0) {
-    return null;
-  }
-  const focusCellId = variable.declaredBy[0];
-  return focusCellId;
-};
-
-export function goToDefinition(view: EditorView) {
-  const state = view.state;
-  const variables = store.get(variablesAtom);
-  const variableName = getWordUnderCursor(state);
-  const focusCellId = getCellIdOfDefinition(variables, variableName);
-
-  if (focusCellId) {
-    const notebookState = store.get(notebookAtom);
-    focusAndScrollCellIntoView({
-      cellId: focusCellId,
-      cell: notebookState.cellHandles[focusCellId],
-      config: notebookState.cellData[focusCellId].config,
-      codeFocus: undefined,
-      variableName,
-    });
-  }
-}
diff --git a/frontend/src/core/codemirror/go-to-definition/__tests__/commands.test.ts b/frontend/src/core/codemirror/go-to-definition/__tests__/commands.test.ts
new file mode 100644
index 00000000000..b30926e4f62
--- /dev/null
+++ b/frontend/src/core/codemirror/go-to-definition/__tests__/commands.test.ts
@@ -0,0 +1,62 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import { EditorState } from "@codemirror/state";
+import { EditorView } from "@codemirror/view";
+import { describe, afterEach, test, expect } from "vitest";
+import { goToVariableDefinition } from "../commands";
+import { python } from "@codemirror/lang-python";
+import { invariant } from "@/utils/invariant";
+
+function createEditor(content: string) {
+  const state = EditorState.create({
+    doc: content,
+    extensions: [python()],
+  });
+
+  const view = new EditorView({
+    state,
+    parent: document.body,
+  });
+
+  return view;
+}
+
+let view: EditorView | null = null;
+
+afterEach(() => {
+  if (view) {
+    view.destroy();
+    view = null;
+  }
+});
+
+describe("goToVariableDefinition", () => {
+  test("selects the variable when it exists", () => {
+    view = createEditor("#comment\nmyVar = 10\nprint(myVar)");
+    const result = goToVariableDefinition(view, "myVar");
+
+    expect(result).toBe(true);
+    requestAnimationFrame(() => {
+      invariant(view, "view should be defined");
+      expect(view.state.selection.main.from).toBe(12);
+      expect(view.state.selection.main.to).toBe(17);
+    });
+  });
+
+  test("does not select the variable when it does not exist", () => {
+    view = createEditor("#comment\nmyVar = 10;\nprint(myVar)");
+    const result = goToVariableDefinition(view, "nonExistentVar");
+
+    expect(result).toBe(false);
+    expect(view.state.selection.main.from).toBe(0);
+    expect(view.state.selection.main.to).toBe(0);
+  });
+
+  test("does not select the variable when it exists in a comment or string", () => {
+    view = createEditor("#comment\n# myVar\nprint('myVar')");
+    const result = goToVariableDefinition(view, "myVar");
+
+    expect(result).toBe(false);
+    expect(view.state.selection.main.from).toBe(0);
+    expect(view.state.selection.main.to).toBe(0);
+  });
+});
diff --git a/frontend/src/core/codemirror/go-to-definition/commands.ts b/frontend/src/core/codemirror/go-to-definition/commands.ts
new file mode 100644
index 00000000000..1e62b02eafc
--- /dev/null
+++ b/frontend/src/core/codemirror/go-to-definition/commands.ts
@@ -0,0 +1,64 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import { EditorView } from "@codemirror/view";
+import { syntaxTree } from "@codemirror/language";
+
+/**
+ * This function will select the first occurrence of the given variable name,
+ * for a given editor view.
+ * @param view The editor view which contains the variable name.
+ * @param variableName The name of the variable to select, if found in the editor.
+ */
+export function goToVariableDefinition(
+  view: EditorView,
+  variableName: string,
+): boolean {
+  const state = view.state;
+  const tree = syntaxTree(state);
+
+  let found = false;
+  let from = 0;
+
+  tree.iterate({
+    enter: (node) => {
+      if (found) {
+        return false;
+      } // Stop traversal if found
+
+      // Check if the node is an identifier and matches the variable name
+      if (
+        node.name === "VariableName" &&
+        state.doc.sliceString(node.from, node.to) === variableName
+      ) {
+        from = node.from;
+        found = true;
+        return false; // Stop traversal
+      }
+
+      // Skip comments and strings
+      if (node.name === "Comment" || node.name === "String") {
+        return false;
+      }
+    },
+  });
+
+  if (found) {
+    view.focus();
+    // Wait for the next frame, otherwise codemirror will
+    // add a cursor from a pointer click.
+    requestAnimationFrame(() => {
+      view.dispatch({
+        selection: {
+          anchor: from,
+          head: from,
+        },
+        // Unfortunately, EditorView.scrollIntoView does
+        // not support smooth scrolling.
+        effects: EditorView.scrollIntoView(from, {
+          y: "center",
+        }),
+      });
+    });
+    return true;
+  }
+  return false;
+}
diff --git a/frontend/src/core/codemirror/go-to-definition/extension.ts b/frontend/src/core/codemirror/go-to-definition/extension.ts
new file mode 100644
index 00000000000..5724a88a04a
--- /dev/null
+++ b/frontend/src/core/codemirror/go-to-definition/extension.ts
@@ -0,0 +1,23 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import { EditorView } from "@codemirror/view";
+import { underlineField, createUnderlinePlugin } from "./underline";
+import { goToDefinition } from "./utils";
+
+/**
+ * Create a go-to-definition extension.
+ */
+export function goToDefinitionBundle() {
+  return [
+    underlineField,
+    createUnderlinePlugin((view, variableName) => {
+      goToDefinition(view, variableName);
+    }),
+    EditorView.baseTheme({
+      ".underline": {
+        textDecoration: "underline",
+        cursor: "pointer",
+        color: "hsl(var(--link))",
+      },
+    }),
+  ];
+}
diff --git a/frontend/src/core/codemirror/go-to-definition/underline.ts b/frontend/src/core/codemirror/go-to-definition/underline.ts
new file mode 100644
index 00000000000..ee04ff44fa0
--- /dev/null
+++ b/frontend/src/core/codemirror/go-to-definition/underline.ts
@@ -0,0 +1,157 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import {
+  EditorView,
+  Decoration,
+  type DecorationSet,
+  ViewPlugin,
+  type ViewUpdate,
+} from "@codemirror/view";
+import { StateField, StateEffect } from "@codemirror/state";
+import { syntaxTree } from "@codemirror/language";
+import type { TreeCursor } from "@lezer/common";
+
+// Decoration
+const underlineDecoration = Decoration.mark({ class: "underline" });
+
+// State Effects
+const addUnderline = StateEffect.define<{ from: number; to: number }>();
+const removeUnderline = StateEffect.define<{ from: number; to: number }>();
+
+// Underline Field
+export const underlineField = StateField.define<DecorationSet>({
+  create() {
+    return Decoration.none;
+  },
+  update(underlines, tr) {
+    let newUnderlines = underlines.map(tr.changes);
+    for (const effect of tr.effects) {
+      if (effect.is(addUnderline)) {
+        newUnderlines = underlines.update({
+          add: [underlineDecoration.range(effect.value.from, effect.value.to)],
+        });
+      } else if (effect.is(removeUnderline)) {
+        newUnderlines = underlines.update({
+          filter: (from, to) =>
+            from !== effect.value.from || to !== effect.value.to,
+        });
+      }
+    }
+    return newUnderlines;
+  },
+  provide: (f) => EditorView.decorations.from(f),
+});
+
+// When meta is pressed, underline the variable name under the cursor
+class MetaUnderlineVariablePlugin {
+  private view: EditorView;
+  private commandKey: boolean;
+  private hoveredRange: { from: number; to: number; position: number } | null;
+  private onClick: (view: EditorView, variableName: string) => void;
+
+  constructor(
+    view: EditorView,
+    onClick: (view: EditorView, variableName: string) => void,
+  ) {
+    this.view = view;
+    this.commandKey = false;
+    this.hoveredRange = null;
+    this.onClick = onClick;
+
+    window.addEventListener("mousemove", this.mousemove);
+    window.addEventListener("keydown", this.keydown);
+    window.addEventListener("keyup", this.keyup);
+    this.view.dom.addEventListener("click", this.click);
+  }
+
+  update(update: ViewUpdate) {
+    if (update.docChanged || update.viewportChanged) {
+      this.clearUnderline();
+    }
+  }
+
+  destroy() {
+    window.removeEventListener("mousemove", this.mousemove);
+    window.removeEventListener("keydown", this.keydown);
+    window.removeEventListener("keyup", this.keyup);
+  }
+
+  private mousemove = (event: MouseEvent) => {
+    if (!this.commandKey) {
+      this.clearUnderline();
+      return;
+    }
+
+    const pos = this.view.posAtCoords({
+      x: event.clientX,
+      y: event.clientY,
+    });
+    if (pos == null) {
+      this.clearUnderline();
+      return;
+    }
+
+    const tree = syntaxTree(this.view.state);
+    const cursor: TreeCursor = tree.cursorAt(pos);
+
+    if (cursor.name === "VariableName") {
+      const from = cursor.from;
+      const to = cursor.to;
+      if (
+        this.hoveredRange &&
+        this.hoveredRange.from === from &&
+        this.hoveredRange.to === to
+      ) {
+        return;
+      }
+      this.clearUnderline();
+      this.hoveredRange = { from, to, position: pos };
+      this.view.dispatch({ effects: addUnderline.of(this.hoveredRange) });
+    } else {
+      this.clearUnderline();
+    }
+  };
+
+  private keydown = (event: KeyboardEvent) => {
+    if (event.key === "Meta") {
+      this.commandKey = true;
+    }
+  };
+
+  private click = (event: MouseEvent) => {
+    if (this.hoveredRange) {
+      const variableName = this.view.state.doc.sliceString(
+        this.hoveredRange.from,
+        this.hoveredRange.to,
+      );
+      event.preventDefault();
+      event.stopPropagation();
+      this.onClick(this.view, variableName);
+      // Move the cursor to the clicked position
+      this.view.dispatch({
+        selection: {
+          head: this.hoveredRange.position,
+          anchor: this.hoveredRange.position,
+        },
+      });
+    }
+  };
+
+  private keyup = (event: KeyboardEvent) => {
+    if (event.key === "Meta") {
+      this.commandKey = false;
+      this.clearUnderline();
+    }
+  };
+
+  private clearUnderline() {
+    if (this.hoveredRange) {
+      this.view.dispatch({ effects: removeUnderline.of(this.hoveredRange) });
+      this.hoveredRange = null;
+    }
+  }
+}
+
+export const createUnderlinePlugin = (
+  onClick: (view: EditorView, variableName: string) => void,
+) =>
+  ViewPlugin.define((view) => new MetaUnderlineVariablePlugin(view, onClick));
diff --git a/frontend/src/core/codemirror/go-to-definition/utils.ts b/frontend/src/core/codemirror/go-to-definition/utils.ts
new file mode 100644
index 00000000000..e624be91bfb
--- /dev/null
+++ b/frontend/src/core/codemirror/go-to-definition/utils.ts
@@ -0,0 +1,104 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import type { EditorState } from "@codemirror/state";
+import { closeHoverTooltips, type EditorView } from "@codemirror/view";
+import { getPositionAtWordBounds } from "../completion/hints";
+import type { VariableName, Variables } from "../../variables/types";
+import { store } from "../../state/jotai";
+import { notebookAtom } from "../../cells/cells";
+import { variablesAtom } from "../../variables/state";
+import type { CellId } from "@/core/cells/ids";
+import { goToVariableDefinition } from "./commands";
+import { closeCompletion } from "@codemirror/autocomplete";
+
+/**
+ * Get the word under the cursor.
+ */
+function getWordUnderCursor(state: EditorState) {
+  const { from, to } = state.selection.main;
+  if (from === to) {
+    const { startToken, endToken } = getPositionAtWordBounds(state.doc, from);
+    return state.doc.sliceString(startToken, endToken);
+  }
+
+  return state.doc.sliceString(from, to);
+}
+
+/**
+ * Get the cell id of the definition of the given variable.
+ */
+function getCellIdOfDefinition(
+  variables: Variables,
+  variableName: string,
+): CellId | null {
+  if (!variableName) {
+    return null;
+  }
+  const variable = variables[variableName as VariableName];
+  if (!variable || variable.declaredBy.length === 0) {
+    return null;
+  }
+  const focusCellId = variable.declaredBy[0];
+  return focusCellId;
+}
+
+function isPrivateVariable(variableName: string) {
+  return variableName.startsWith("_");
+}
+
+/**
+ * Go to the definition of the variable under the cursor.
+ * @param view The editor view at which the command was invoked.
+ */
+export function goToDefinitionAtCursorPosition(view: EditorView): boolean {
+  const state = view.state;
+  const variableName = getWordUnderCursor(state);
+  if (!variableName) {
+    return false;
+  }
+  // Close popovers/tooltips
+  closeCompletion(view);
+  view.dispatch({ effects: closeHoverTooltips });
+
+  return goToDefinition(view, variableName);
+}
+
+/**
+ * Go to the definition of the variable under the cursor.
+ * @param view The editor view at which the command was invoked.
+ */
+export function goToDefinition(
+  view: EditorView,
+  variableName: string,
+): boolean {
+  // The variable may exist in another cell
+  const editorWithVariable = getEditorForVariable(view, variableName);
+  if (!editorWithVariable) {
+    return false;
+  }
+  return goToVariableDefinition(editorWithVariable, variableName);
+}
+
+/**
+ * @param editor The editor view at which the command was invoked.
+ * @param variableName  The name of the variable to go to.
+ */
+function getEditorForVariable(
+  editor: EditorView,
+  variableName: string,
+): EditorView | null {
+  // If it's a private variable, we only want to go to the
+  // definition if it's in the same cell
+  if (isPrivateVariable(variableName)) {
+    return editor;
+  }
+
+  const variables = store.get(variablesAtom);
+
+  const cellId = getCellIdOfDefinition(variables, variableName);
+  if (cellId) {
+    const notebookState = store.get(notebookAtom);
+    return notebookState.cellHandles[cellId].current?.editorView ?? null;
+  }
+
+  return null;
+}
diff --git a/frontend/src/core/codemirror/language/__tests__/utils.test.ts b/frontend/src/core/codemirror/language/__tests__/utils.test.ts
new file mode 100644
index 00000000000..2e26a27f80b
--- /dev/null
+++ b/frontend/src/core/codemirror/language/__tests__/utils.test.ts
@@ -0,0 +1,133 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import {
+  getEditorCodeAsPython,
+  updateEditorCodeFromPython,
+  splitEditor,
+} from "../utils";
+import { EditorState } from "@codemirror/state";
+import { EditorView } from "@codemirror/view";
+import { describe, it, expect } from "vitest";
+import { adaptiveLanguageConfiguration, switchLanguage } from "../extension";
+
+function createEditor(doc: string) {
+  return new EditorView({
+    state: EditorState.create({
+      doc,
+      extensions: [
+        adaptiveLanguageConfiguration({
+          activate_on_typing: true,
+          copilot: false,
+        }),
+      ],
+    }),
+  });
+}
+
+describe("getEditorCodeAsPython", () => {
+  it("should return the entire editor text when no positions are specified", () => {
+    const mockEditor = createEditor("print('Hello, World!')");
+    const result = getEditorCodeAsPython(mockEditor);
+    expect(result).toEqual("print('Hello, World!')");
+  });
+
+  it("should return a slice of the editor text when positions are specified", () => {
+    const mockEditor = createEditor("print('Hello, World!')");
+    const result = getEditorCodeAsPython(mockEditor, 0, 5);
+    expect(result).toEqual("print");
+  });
+});
+
+describe("updateEditorCodeFromPython", () => {
+  it("should update the editor code with the provided Python code", () => {
+    const mockEditor = createEditor("");
+    const pythonCode = "print('Hello, World!')";
+    const result = updateEditorCodeFromPython(mockEditor, pythonCode);
+    expect(result).toEqual(pythonCode);
+    expect(mockEditor.state.doc.toString()).toEqual(pythonCode);
+  });
+});
+
+describe("splitEditor", () => {
+  it("should handle the cursor being at the start of the line", () => {
+    const mockEditor = createEditor("print('Hello')\nprint('Goodbye')");
+    mockEditor.dispatch({
+      selection: {
+        anchor: "print('Hello')\n".length,
+      },
+    });
+    const result = splitEditor(mockEditor);
+    expect(result.beforeCursorCode).toEqual("print('Hello')");
+    expect(result.afterCursorCode).toEqual("print('Goodbye')");
+  });
+
+  it("should handle the cursor being at the end of the line", () => {
+    const mockEditor = createEditor("print('Hello')\nprint('Goodbye')");
+    mockEditor.dispatch({
+      selection: {
+        anchor: "print('Hello')".length,
+      },
+    });
+    const result = splitEditor(mockEditor);
+    expect(result.beforeCursorCode).toEqual("print('Hello')");
+    expect(result.afterCursorCode).toEqual("print('Goodbye')");
+  });
+
+  it("should split the editor code into two parts at the cursor position", () => {
+    const mockEditor = createEditor("print('Hello')\nprint('Goodbye')");
+    mockEditor.dispatch({
+      selection: {
+        anchor: 2,
+      },
+    });
+    const result = splitEditor(mockEditor);
+    expect(result.beforeCursorCode).toEqual("pr");
+    expect(result.afterCursorCode).toEqual("int('Hello')\nprint('Goodbye')");
+  });
+
+  it("handle start and end of the docs", () => {
+    let mockEditor = createEditor("print('Hello')\nprint('Goodbye')");
+    mockEditor.dispatch({
+      selection: { anchor: 0 },
+    });
+    const result = splitEditor(mockEditor);
+    expect(result.beforeCursorCode).toEqual("");
+    expect(result.afterCursorCode).toEqual("print('Hello')\nprint('Goodbye')");
+
+    mockEditor = createEditor("print('Hello')\nprint('Goodbye')");
+    mockEditor.dispatch({
+      selection: { anchor: mockEditor.state.doc.length },
+    });
+
+    const result2 = splitEditor(mockEditor);
+    expect(result2.beforeCursorCode).toEqual(
+      "print('Hello')\nprint('Goodbye')",
+    );
+    expect(result2.afterCursorCode).toEqual("");
+  });
+
+  it("handles markdown", () => {
+    const mockEditor = createEditor("mo.md('Hello, World!')");
+    // Set to markdown
+    switchLanguage(mockEditor, "markdown");
+    // Set cursor position
+    mockEditor.dispatch({
+      selection: { anchor: "Hello,".length },
+    });
+    const result = splitEditor(mockEditor);
+    expect(result.beforeCursorCode).toEqual('mo.md("Hello,")');
+    expect(result.afterCursorCode).toEqual('mo.md(" World!")');
+  });
+
+  it("handles markdown with variables", () => {
+    const mockEditor = createEditor('mo.md(f"""{a}\n{b}!""")');
+    // Set to markdown
+    switchLanguage(mockEditor, "markdown");
+    // Set cursor position
+    mockEditor.dispatch({
+      selection: { anchor: "{a}\n".length },
+    });
+    const result = splitEditor(mockEditor);
+    expect(result.beforeCursorCode).toEqual('mo.md(f"{a}")');
+    expect(result.afterCursorCode).toEqual('mo.md(f"{b}!")');
+  });
+});
diff --git a/frontend/src/core/codemirror/language/utils.ts b/frontend/src/core/codemirror/language/utils.ts
index 14a36365b42..b3161657792 100644
--- a/frontend/src/core/codemirror/language/utils.ts
+++ b/frontend/src/core/codemirror/language/utils.ts
@@ -1,7 +1,7 @@
 /* Copyright 2024 Marimo. All rights reserved. */
-import { EditorView } from "@codemirror/view";
+import type { EditorView } from "@codemirror/view";
 import { languageAdapterState } from "./extension";
-import { EditorState } from "@codemirror/state";
+import type { EditorState } from "@codemirror/state";
 
 /**
  * Get the editor code as Python
@@ -20,6 +20,10 @@ export function getEditorCodeAsPython(
   return languageAdapter.transformOut(editorText)[0];
 }
 
+/**
+ * Update the editor code from Python code
+ * Handles when the editor is showing a different language (e.g. markdown)
+ */
 export function updateEditorCodeFromPython(
   editor: EditorView,
   pythonCode: string,
@@ -35,3 +39,33 @@ export function updateEditorCodeFromPython(
   });
   return code;
 }
+
+/**
+ * Split the editor code into two parts at the cursor position
+ */
+export function splitEditor(editor: EditorView) {
+  const cursorPos = editor.state.selection.main.head;
+  const editorCode = editor.state.doc.toString();
+
+  const isCursorAtLineStart =
+    editorCode.length > 0 && editorCode[cursorPos - 1] === "\n";
+  const isCursorAtLineEnd =
+    editorCode.length > 0 && editorCode[cursorPos] === "\n";
+
+  const beforeAdjustedCursorPos = isCursorAtLineStart
+    ? cursorPos - 1
+    : cursorPos;
+  const afterAdjustedCursorPos = isCursorAtLineEnd ? cursorPos + 1 : cursorPos;
+
+  const beforeCursorCode = getEditorCodeAsPython(
+    editor,
+    0,
+    beforeAdjustedCursorPos,
+  );
+  const afterCursorCode = getEditorCodeAsPython(editor, afterAdjustedCursorPos);
+
+  return {
+    beforeCursorCode,
+    afterCursorCode,
+  };
+}


Code review comments:
diff_hunk: @@ -0,0 +1,64 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import { EditorView } from "@codemirror/view";
+import { syntaxTree } from "@codemirror/language";
+
+/**
+ * This function will select the first occurrence of the given variable name,
+ * for a given editor view.
+ * @param view The editor view which contains the variable name.
+ * @param variableName The name of the variable to select, if found in the editor.
+ */
+export function goToVariableDefinition(
+  view: EditorView,
+  variableName: string,
+): boolean {
+  const state = view.state;
path: frontend/src/core/codemirror/go-to-definition/commands.ts
text: @user1:
**suggestion (code-quality):** Prefer object destructuring when accessing and using properties. ([`use-object-destructuring`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/TypeScript/Default-Rules/use-object-destructuring))

```suggestion
  const {state} = view;
```

<br/><details><summary>Explanation</summary>Object destructuring can often remove an unnecessary temporary reference, as well as making your code more succinct.

From the [Airbnb Javascript Style Guide](https://airbnb.io/javascript/#destructuring--object)
</details>

diff_hunk: @@ -0,0 +1,157 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import {
+  EditorView,
+  Decoration,
+  type DecorationSet,
+  ViewPlugin,
+  type ViewUpdate,
+} from "@codemirror/view";
+import { StateField, StateEffect } from "@codemirror/state";
+import { syntaxTree } from "@codemirror/language";
+import type { TreeCursor } from "@lezer/common";
+
+// Decoration
+const underlineDecoration = Decoration.mark({ class: "underline" });
+
+// State Effects
+const addUnderline = StateEffect.define<{ from: number; to: number }>();
+const removeUnderline = StateEffect.define<{ from: number; to: number }>();
+
+// Underline Field
+export const underlineField = StateField.define<DecorationSet>({
+  create() {
+    return Decoration.none;
+  },
+  update(underlines, tr) {
+    let newUnderlines = underlines.map(tr.changes);
+    for (const effect of tr.effects) {
+      if (effect.is(addUnderline)) {
+        newUnderlines = underlines.update({
+          add: [underlineDecoration.range(effect.value.from, effect.value.to)],
+        });
+      } else if (effect.is(removeUnderline)) {
+        newUnderlines = underlines.update({
+          filter: (from, to) =>
+            from !== effect.value.from || to !== effect.value.to,
+        });
+      }
+    }
+    return newUnderlines;
+  },
+  provide: (f) => EditorView.decorations.from(f),
+});
+
+// When meta is pressed, underline the variable name under the cursor
+class MetaUnderlineVariablePlugin {
+  private view: EditorView;
+  private commandKey: boolean;
+  private hoveredRange: { from: number; to: number; position: number } | null;
+  private onClick: (view: EditorView, variableName: string) => void;
+
+  constructor(
+    view: EditorView,
+    onClick: (view: EditorView, variableName: string) => void,
+  ) {
+    this.view = view;
+    this.commandKey = false;
+    this.hoveredRange = null;
+    this.onClick = onClick;
+
+    window.addEventListener("mousemove", this.mousemove);
+    window.addEventListener("keydown", this.keydown);
+    window.addEventListener("keyup", this.keyup);
+    this.view.dom.addEventListener("click", this.click);
+  }
+
+  update(update: ViewUpdate) {
+    if (update.docChanged || update.viewportChanged) {
+      this.clearUnderline();
+    }
+  }
+
+  destroy() {
+    window.removeEventListener("mousemove", this.mousemove);
+    window.removeEventListener("keydown", this.keydown);
+    window.removeEventListener("keyup", this.keyup);
+  }
+
+  private mousemove = (event: MouseEvent) => {
+    if (!this.commandKey) {
+      this.clearUnderline();
+      return;
+    }
+
+    const pos = this.view.posAtCoords({
+      x: event.clientX,
+      y: event.clientY,
+    });
+    if (pos == null) {
+      this.clearUnderline();
+      return;
+    }
+
+    const tree = syntaxTree(this.view.state);
+    const cursor: TreeCursor = tree.cursorAt(pos);
+
+    if (cursor.name === "VariableName") {
+      const from = cursor.from;
path: frontend/src/core/codemirror/go-to-definition/underline.ts
text: @user1:
**suggestion (code-quality):** Prefer object destructuring when accessing and using properties. ([`use-object-destructuring`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/TypeScript/Default-Rules/use-object-destructuring))

```suggestion
      const {from} = cursor;
```

<br/><details><summary>Explanation</summary>Object destructuring can often remove an unnecessary temporary reference, as well as making your code more succinct.

From the [Airbnb Javascript Style Guide](https://airbnb.io/javascript/#destructuring--object)
</details>

diff_hunk: @@ -0,0 +1,157 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import {
+  EditorView,
+  Decoration,
+  type DecorationSet,
+  ViewPlugin,
+  type ViewUpdate,
+} from "@codemirror/view";
+import { StateField, StateEffect } from "@codemirror/state";
+import { syntaxTree } from "@codemirror/language";
+import type { TreeCursor } from "@lezer/common";
+
+// Decoration
+const underlineDecoration = Decoration.mark({ class: "underline" });
+
+// State Effects
+const addUnderline = StateEffect.define<{ from: number; to: number }>();
+const removeUnderline = StateEffect.define<{ from: number; to: number }>();
+
+// Underline Field
+export const underlineField = StateField.define<DecorationSet>({
+  create() {
+    return Decoration.none;
+  },
+  update(underlines, tr) {
+    let newUnderlines = underlines.map(tr.changes);
+    for (const effect of tr.effects) {
+      if (effect.is(addUnderline)) {
+        newUnderlines = underlines.update({
+          add: [underlineDecoration.range(effect.value.from, effect.value.to)],
+        });
+      } else if (effect.is(removeUnderline)) {
+        newUnderlines = underlines.update({
+          filter: (from, to) =>
+            from !== effect.value.from || to !== effect.value.to,
+        });
+      }
+    }
+    return newUnderlines;
+  },
+  provide: (f) => EditorView.decorations.from(f),
+});
+
+// When meta is pressed, underline the variable name under the cursor
+class MetaUnderlineVariablePlugin {
+  private view: EditorView;
+  private commandKey: boolean;
+  private hoveredRange: { from: number; to: number; position: number } | null;
+  private onClick: (view: EditorView, variableName: string) => void;
+
+  constructor(
+    view: EditorView,
+    onClick: (view: EditorView, variableName: string) => void,
+  ) {
+    this.view = view;
+    this.commandKey = false;
+    this.hoveredRange = null;
+    this.onClick = onClick;
+
+    window.addEventListener("mousemove", this.mousemove);
+    window.addEventListener("keydown", this.keydown);
+    window.addEventListener("keyup", this.keyup);
+    this.view.dom.addEventListener("click", this.click);
+  }
+
+  update(update: ViewUpdate) {
+    if (update.docChanged || update.viewportChanged) {
+      this.clearUnderline();
+    }
+  }
+
+  destroy() {
+    window.removeEventListener("mousemove", this.mousemove);
+    window.removeEventListener("keydown", this.keydown);
+    window.removeEventListener("keyup", this.keyup);
+  }
+
+  private mousemove = (event: MouseEvent) => {
+    if (!this.commandKey) {
+      this.clearUnderline();
+      return;
+    }
+
+    const pos = this.view.posAtCoords({
+      x: event.clientX,
+      y: event.clientY,
+    });
+    if (pos == null) {
+      this.clearUnderline();
+      return;
+    }
+
+    const tree = syntaxTree(this.view.state);
+    const cursor: TreeCursor = tree.cursorAt(pos);
+
+    if (cursor.name === "VariableName") {
+      const from = cursor.from;
+      const to = cursor.to;
path: frontend/src/core/codemirror/go-to-definition/underline.ts
text: @user1:
**suggestion (code-quality):** Prefer object destructuring when accessing and using properties. ([`use-object-destructuring`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/TypeScript/Default-Rules/use-object-destructuring))

```suggestion
      const {to} = cursor;
```

<br/><details><summary>Explanation</summary>Object destructuring can often remove an unnecessary temporary reference, as well as making your code more succinct.

From the [Airbnb Javascript Style Guide](https://airbnb.io/javascript/#destructuring--object)
</details>

diff_hunk: @@ -0,0 +1,104 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import type { EditorState } from "@codemirror/state";
+import { closeHoverTooltips, type EditorView } from "@codemirror/view";
+import { getPositionAtWordBounds } from "../completion/hints";
+import type { VariableName, Variables } from "../../variables/types";
+import { store } from "../../state/jotai";
+import { notebookAtom } from "../../cells/cells";
+import { variablesAtom } from "../../variables/state";
+import type { CellId } from "@/core/cells/ids";
+import { goToVariableDefinition } from "./commands";
+import { closeCompletion } from "@codemirror/autocomplete";
+
+/**
+ * Get the word under the cursor.
+ */
+function getWordUnderCursor(state: EditorState) {
+  const { from, to } = state.selection.main;
+  if (from === to) {
+    const { startToken, endToken } = getPositionAtWordBounds(state.doc, from);
+    return state.doc.sliceString(startToken, endToken);
+  }
+
+  return state.doc.sliceString(from, to);
+}
+
+/**
+ * Get the cell id of the definition of the given variable.
+ */
+function getCellIdOfDefinition(
+  variables: Variables,
+  variableName: string,
+): CellId | null {
+  if (!variableName) {
+    return null;
+  }
+  const variable = variables[variableName as VariableName];
+  if (!variable || variable.declaredBy.length === 0) {
+    return null;
+  }
+  const focusCellId = variable.declaredBy[0];
+  return focusCellId;
path: frontend/src/core/codemirror/go-to-definition/utils.ts
text: @user1:
**suggestion (code-quality):** Inline variable that is immediately returned ([`inline-immediately-returned-variable`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/TypeScript/Default-Rules/inline-immediately-returned-variable))

```suggestion
  return variable.declaredBy[0];

```

<br/><details><summary>Explanation</summary>Something that we often see in people's code is assigning to a result variable
and then immediately returning it.

Returning the result directly shortens the code and removes an unnecessary
variable, reducing the mental load of reading the function.

Where intermediate variables can be useful is if they then get used as a
parameter or a condition, and the name can act like a comment on what the
variable represents. In the case where you're returning it from a function, the
function name is there to tell you what the result is, so the variable name
is unnecessary.
</details>

diff_hunk: @@ -0,0 +1,104 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import type { EditorState } from "@codemirror/state";
+import { closeHoverTooltips, type EditorView } from "@codemirror/view";
+import { getPositionAtWordBounds } from "../completion/hints";
+import type { VariableName, Variables } from "../../variables/types";
+import { store } from "../../state/jotai";
+import { notebookAtom } from "../../cells/cells";
+import { variablesAtom } from "../../variables/state";
+import type { CellId } from "@/core/cells/ids";
+import { goToVariableDefinition } from "./commands";
+import { closeCompletion } from "@codemirror/autocomplete";
+
+/**
+ * Get the word under the cursor.
+ */
+function getWordUnderCursor(state: EditorState) {
+  const { from, to } = state.selection.main;
+  if (from === to) {
+    const { startToken, endToken } = getPositionAtWordBounds(state.doc, from);
+    return state.doc.sliceString(startToken, endToken);
+  }
+
+  return state.doc.sliceString(from, to);
+}
+
+/**
+ * Get the cell id of the definition of the given variable.
+ */
+function getCellIdOfDefinition(
+  variables: Variables,
+  variableName: string,
+): CellId | null {
+  if (!variableName) {
+    return null;
+  }
+  const variable = variables[variableName as VariableName];
+  if (!variable || variable.declaredBy.length === 0) {
+    return null;
+  }
+  const focusCellId = variable.declaredBy[0];
+  return focusCellId;
+}
+
+function isPrivateVariable(variableName: string) {
+  return variableName.startsWith("_");
+}
+
+/**
+ * Go to the definition of the variable under the cursor.
+ * @param view The editor view at which the command was invoked.
+ */
+export function goToDefinitionAtCursorPosition(view: EditorView): boolean {
+  const state = view.state;
path: frontend/src/core/codemirror/go-to-definition/utils.ts
text: @user1:
**suggestion (code-quality):** Prefer object destructuring when accessing and using properties. ([`use-object-destructuring`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/TypeScript/Default-Rules/use-object-destructuring))

```suggestion
  const {state} = view;
```

<br/><details><summary>Explanation</summary>Object destructuring can often remove an unnecessary temporary reference, as well as making your code more succinct.

From the [Airbnb Javascript Style Guide](https://airbnb.io/javascript/#destructuring--object)
</details>