@marimo-team/islands 0.23.13-dev0 → 0.23.13-dev2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36001,7 +36001,7 @@ ${d}`,
36001
36001
  return Logger.warn("Failed to get version from mount config"), null;
36002
36002
  }
36003
36003
  }
36004
- marimoVersionAtom = atom(getVersionFromMountConfig() || "0.23.13-dev0");
36004
+ marimoVersionAtom = atom(getVersionFromMountConfig() || "0.23.13-dev2");
36005
36005
  showCodeInRunModeAtom = atom(true);
36006
36006
  atom(null);
36007
36007
  var import_compiler_runtime = require_compiler_runtime();
package/dist/main.js CHANGED
@@ -26,7 +26,7 @@ import { $ as reducer, B as safeExtractSetUIElementMessageBuffers, Bn as CircleA
26
26
  import { __tla as __tla_1 } from "./chunk-5FQGJX7Z-BbqSm5gU.js";
27
27
  import { o as useSize, s as Root$1, u as createLucideIcon } from "./dist--2Bqjvs0.js";
28
28
  import { A as SquareFunction, C as DEFAULT_COLOR_SCHEME, D as SCALE_TYPE_DESCRIPTIONS, E as EMPTY_VALUE$1, O as TIME_UNIT_DESCRIPTIONS, S as DEFAULT_AGGREGATION, T as DEFAULT_TIME_UNIT, _ as AGGREGATION_TYPE_DESCRIPTIONS, a as AGGREGATION_FNS$1, b as COLOR_SCHEMES, c as COLOR_BY_FIELDS, d as NONE_VALUE, f as SELECTABLE_DATA_TYPES, g as TIME_UNITS, h as STRING_AGGREGATION_FNS, i as convertDataTypeToSelectable, j as ChartColumn, k as escapeFieldName, l as COMBINED_TIME_UNITS, m as SORT_TYPES, n as createSpecWithoutData, o as BIN_AGGREGATION, p as SINGLE_TIME_UNITS, r as isFieldSet, s as CHART_TYPES, t as augmentSpecWithData, u as ChartType, v as AGGREGATION_TYPE_ICON, w as DEFAULT_MAX_BINS_FACET, x as COUNT_FIELD, y as CHART_TYPE_ICON } from "./spec-Bv-XlYiv.js";
29
- import { $ as contextAwarePanelOpen, $t as EmotionCacheProvider, A as prettifyRowCount, At as SELECT_COLUMN_ID, B as DatePicker, Bt as dateToLocalISOTime, C as downloadSizeLimitAtom, Ct as DelayMount, D as ErrorState, Dt as loadTableAndRawData, E as EmptyState, Et as getPageIndexForRow, F as ContextMenuSeparator, Ft as isRecord, G as CommandEmpty, Gt as ChartErrorState, H as Combobox, Ht as TabsContent, I as ContextMenuTrigger, It as isNullishFilter, J as CommandList, Jt as LazyVegaEmbed, K as CommandInput, Kt as ChartInfoState, L as useInternalStateWithSync, Lt as Maps, M as ContextMenu, Mt as toFieldTypes, N as ContextMenuContent, Nt as getMimeValues, O as LoadingState, Ot as loadTableData, P as ContextMenuItem, Pt as hasFunctionProperty, Q as PANEL_TYPES, Qt as HtmlOutput, R as useSelectList, Rt as dateToLocalISODate, S as Filenames, St as ColumnChartSpecModel, T as ColumnPreviewContainer, Tt as usePrevious$1, U as ComboboxItem, Ut as TabsList, V as DateRangePicker, Vt as Tabs, W as Command, Wt as TabsTrigger, X as smartMatch, Xt as RenderTextWithLinks, Y as CommandSeparator, Yt as useOverflowDetection, Z as ContextAwarePanelItem, Zt as Kbd, _ as ADD_PRINTING_CLASS, _t as NAMELESS_COLUMN_PREFIX, an as EyeOff, at as Toggle, b as downloadHTMLAsImage, bt as renderCellValue, c as Slide, cn as Download, d as RadioGroupItem, dn as ChevronsRight, dt as Table, en as $fae977aafc393c5c$export$588937bcd60ade55, et as contextAwarePanelOwner, f as JsonOutput, fn as ChevronsLeft, ft as TableBody, g as InstallPackageButton, gt as TableRow, h as DataTable, hn as ArrowDownWideNarrow, ht as TableHeader, in as Funnel, it as slotsController, j as getColumnCountForDisplay, jt as TOO_MANY_ROWS, k as prettifyRowColumnCount, kt as INDEX_COLUMN_NAME, l as Switch, ln as Code, lt as Fill, m as OutputRenderer, mn as ChevronLeft, mt as TableHead, n as marimoVersionAtom, nn as TextWrap, nt as isCellAwareAtom, o as SLIDE_TYPE_OPTIONS_BY_VALUE, p as OutputArea, pn as ChevronsDownUp, pt as TableCell, q as CommandItem, qt as ChartLoadingState, r as showCodeInRunModeAtom, rn as GripHorizontal, rt as SlotNames, sn as Ellipsis, t as useNotebookCodeAvailable, tn as $fae977aafc393c5c$export$6b862160d295c8e, tt as contextAwarePanelType, u as RadioGroup, un as ChevronsUpDown, ut as Provider$1, v as downloadBlob, vt as generateColumns, w as ColumnName, wt as useIntersectionObserver, x as Progress, xt as ColumnChartContext, y as downloadByURL, yt as inferFieldTypes, z as CompactChipRow, zt as dateToLocalISODateTime, __tla as __tla_2 } from "./code-visibility-D9IipVFG.js";
29
+ import { $ as contextAwarePanelOpen, $t as EmotionCacheProvider, A as prettifyRowCount, At as SELECT_COLUMN_ID, B as DatePicker, Bt as dateToLocalISOTime, C as downloadSizeLimitAtom, Ct as DelayMount, D as ErrorState, Dt as loadTableAndRawData, E as EmptyState, Et as getPageIndexForRow, F as ContextMenuSeparator, Ft as isRecord, G as CommandEmpty, Gt as ChartErrorState, H as Combobox, Ht as TabsContent, I as ContextMenuTrigger, It as isNullishFilter, J as CommandList, Jt as LazyVegaEmbed, K as CommandInput, Kt as ChartInfoState, L as useInternalStateWithSync, Lt as Maps, M as ContextMenu, Mt as toFieldTypes, N as ContextMenuContent, Nt as getMimeValues, O as LoadingState, Ot as loadTableData, P as ContextMenuItem, Pt as hasFunctionProperty, Q as PANEL_TYPES, Qt as HtmlOutput, R as useSelectList, Rt as dateToLocalISODate, S as Filenames, St as ColumnChartSpecModel, T as ColumnPreviewContainer, Tt as usePrevious$1, U as ComboboxItem, Ut as TabsList, V as DateRangePicker, Vt as Tabs, W as Command, Wt as TabsTrigger, X as smartMatch, Xt as RenderTextWithLinks, Y as CommandSeparator, Yt as useOverflowDetection, Z as ContextAwarePanelItem, Zt as Kbd, _ as ADD_PRINTING_CLASS, _t as NAMELESS_COLUMN_PREFIX, an as EyeOff, at as Toggle, b as downloadHTMLAsImage, bt as renderCellValue, c as Slide, cn as Download, d as RadioGroupItem, dn as ChevronsRight, dt as Table, en as $fae977aafc393c5c$export$588937bcd60ade55, et as contextAwarePanelOwner, f as JsonOutput, fn as ChevronsLeft, ft as TableBody, g as InstallPackageButton, gt as TableRow, h as DataTable, hn as ArrowDownWideNarrow, ht as TableHeader, in as Funnel, it as slotsController, j as getColumnCountForDisplay, jt as TOO_MANY_ROWS, k as prettifyRowColumnCount, kt as INDEX_COLUMN_NAME, l as Switch, ln as Code, lt as Fill, m as OutputRenderer, mn as ChevronLeft, mt as TableHead, n as marimoVersionAtom, nn as TextWrap, nt as isCellAwareAtom, o as SLIDE_TYPE_OPTIONS_BY_VALUE, p as OutputArea, pn as ChevronsDownUp, pt as TableCell, q as CommandItem, qt as ChartLoadingState, r as showCodeInRunModeAtom, rn as GripHorizontal, rt as SlotNames, sn as Ellipsis, t as useNotebookCodeAvailable, tn as $fae977aafc393c5c$export$6b862160d295c8e, tt as contextAwarePanelType, u as RadioGroup, un as ChevronsUpDown, ut as Provider$1, v as downloadBlob, vt as generateColumns, w as ColumnName, wt as useIntersectionObserver, x as Progress, xt as ColumnChartContext, y as downloadByURL, yt as inferFieldTypes, z as CompactChipRow, zt as dateToLocalISODateTime, __tla as __tla_2 } from "./code-visibility-BK2rfFb5.js";
30
30
  import { c as Calendar, i as createReducerAndAtoms, n as useOnUnmount, o as ToggleLeft, t as useOnMount } from "./useLifecycle-AHlswLw-.js";
31
31
  import { t as Check } from "./check-C9OoNtR4.js";
32
32
  import { A as Icon, C as logNever, D as $18f2051aff69b9bf$export$a54013f0d02a8f82, E as $18f2051aff69b9bf$export$43bb16f9c6d9e3f7, F as createCollection, I as X, M as clamp$2, N as usePrevious$2, P as useDirection, R as ChevronDown, S as assertNever, a as SelectGroup, c as SelectSeparator, d as NativeSelect, i as SelectContent, j as Trigger$1, l as SelectTrigger, n as capitalize, o as SelectItem, r as Select, s as SelectLabel, t as Strings, u as SelectValue, w as $a916eb452884faea$export$b7a616150fdb9f44 } from "./strings-Dq_j3Rxw.js";
@@ -36235,7 +36235,7 @@ ${c}
36235
36235
  function _temp2$2(e) {
36236
36236
  e.target === e.currentTarget && e.key === "Enter" && (e.preventDefault(), e.stopPropagation(), e.currentTarget.click());
36237
36237
  }
36238
- var LazySlidesComponent = import_react.lazy(() => import("./reveal-component-Dk32fyu2.js"));
36238
+ var LazySlidesComponent = import_react.lazy(() => import("./reveal-component-DSl81Cxa.js"));
36239
36239
  const SlidesLayoutRenderer = ({ layout: e, setLayout: r, cells: c, mode: l }) => {
36240
36240
  var _a3;
36241
36241
  let u = useAtomValue(kioskModeAtom), d = l === "read" || u, f = useAtomValue(numColumnsAtom) > 1, [p, m] = (0, import_react.useState)(null), { slideCells: h, skippedIds: g, noOutputIds: _, slideTypes: v, startCellIndex: y } = (0, import_react.useMemo)(() => computeSlideCellsInfo(c, e), [
@@ -9,7 +9,7 @@ import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
9
9
  import { lt as kioskModeAtom } from "./html-to-image-DXwLcQ6l.js";
10
10
  import "./chunk-5FQGJX7Z-BbqSm5gU.js";
11
11
  import { u as createLucideIcon } from "./dist--2Bqjvs0.js";
12
- import { a as DEFAULT_SLIDE_TYPE, an as EyeOff, c as Slide, ct as PanelResizeHandle, i as DEFAULT_DECK_TRANSITION, ln as Code, on as Expand, ot as Panel, s as SlideSidebar, st as PanelGroup, t as useNotebookCodeAvailable } from "./code-visibility-D9IipVFG.js";
12
+ import { a as DEFAULT_SLIDE_TYPE, an as EyeOff, c as Slide, ct as PanelResizeHandle, i as DEFAULT_DECK_TRANSITION, ln as Code, on as Expand, ot as Panel, s as SlideSidebar, st as PanelGroup, t as useNotebookCodeAvailable } from "./code-visibility-BK2rfFb5.js";
13
13
  import { X as useDebouncedCallback } from "./input-CbEz_aj_.js";
14
14
  import "./toDate-D-l5s8nn.js";
15
15
  import "./react-dom-BTJzcVJ9.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.23.13-dev0",
3
+ "version": "0.23.13-dev2",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -10,9 +10,11 @@ import { Button } from "@/components/ui/button";
10
10
  import { DelayMount } from "@/components/utils/delay-mount";
11
11
  import { aiCompletionCellAtom } from "@/core/ai/state";
12
12
  import { maybeAddMarimoImport } from "@/core/cells/add-missing-import";
13
- import { useCellActions } from "@/core/cells/cells";
13
+ import { getNotebook, useCellActions } from "@/core/cells/cells";
14
+ import { SETUP_CELL_ID } from "@/core/cells/ids";
14
15
  import { usePendingDeleteService } from "@/core/cells/pending-delete-service";
15
16
  import type { CellData, CellRuntimeState } from "@/core/cells/types";
17
+ import { notebookCellEditorViews } from "@/core/cells/utils";
16
18
  import { setupCodeMirror } from "@/core/codemirror/cm";
17
19
  import { acceptCompletionOnEnterAtom } from "@/core/codemirror/completion/accept-on-enter-atom";
18
20
  import { editorMountScheduler } from "@/core/codemirror/editor-mount-scheduler";
@@ -22,6 +24,10 @@ import {
22
24
  reconfigureLanguageEffect,
23
25
  switchLanguage,
24
26
  } from "@/core/codemirror/language/extension";
27
+ import {
28
+ getEditorCodeAsPython,
29
+ updateEditorCodeFromPython,
30
+ } from "@/core/codemirror/language/utils";
25
31
  import { MARKDOWN_INITIAL_HIDE_CODE } from "@/core/codemirror/language/languages/markdown";
26
32
  import type { LanguageAdapterType } from "@/core/codemirror/language/types";
27
33
  import {
@@ -214,6 +220,32 @@ const CellEditorInternal = ({
214
220
  });
215
221
  }
216
222
  },
223
+ addOrAppendSetupCell: (code) => {
224
+ const notebook = getNotebook();
225
+ // No setup cell yet: create one with this code at the top.
226
+ if (!notebook.cellIds.setupCellExists()) {
227
+ cellActions.addSetupCellIfDoesntExist({ code });
228
+ return;
229
+ }
230
+ // Setup cell exists: append the pasted code to it. Prefer updating
231
+ // the mounted editor (keeps the view and state in sync, like
232
+ // formatting does); fall back to a plain state update otherwise.
233
+ const view = notebookCellEditorViews(notebook)[SETUP_CELL_ID];
234
+ const existing = view
235
+ ? getEditorCodeAsPython(view)
236
+ : notebook.cellData[SETUP_CELL_ID].code;
237
+ const merged = existing.trim()
238
+ ? `${existing.trimEnd()}\n\n${code}`
239
+ : code;
240
+ if (view) {
241
+ updateEditorCodeFromPython(view, merged);
242
+ }
243
+ cellActions.updateCellCode({
244
+ cellId: SETUP_CELL_ID,
245
+ code: merged,
246
+ formattingChange: false,
247
+ });
248
+ },
217
249
  splitCell,
218
250
  toggleHideCode,
219
251
  aiCellCompletion: () => {
@@ -8,6 +8,11 @@ export interface CodemirrorCellActions extends CellActions {
8
8
  toggleHideCode: () => boolean;
9
9
  aiCellCompletion: () => boolean;
10
10
  createManyBelow: (content: string[]) => void;
11
+ /**
12
+ * Create the setup cell with the given code, or append the code to the
13
+ * existing setup cell if one already exists.
14
+ */
15
+ addOrAppendSetupCell: (code: string) => void;
11
16
  onRun: () => void;
12
17
  deleteCell: () => void;
13
18
  afterToggleMarkdown: () => void;
@@ -7,7 +7,7 @@ import {
7
7
  type CodemirrorCellActions,
8
8
  cellActionsState,
9
9
  } from "../../cells/state";
10
- import { extractCells, pasteBundle } from "../paste";
10
+ import { extractCells, extractMarimoApp, pasteBundle } from "../paste";
11
11
 
12
12
  describe("extractCells", () => {
13
13
  it("returns empty array for non-marimo text", () => {
@@ -158,6 +158,154 @@ def _(mo, px):
158
158
  ]);
159
159
  });
160
160
 
161
+ it("preserves decorators on nested functions", () => {
162
+ const input = `
163
+ @app.cell
164
+ def _():
165
+ @functools.cache
166
+ def fib(n):
167
+ return n
168
+ return fib
169
+ `;
170
+ expect(extractCells(input)).toEqual([
171
+ "@functools.cache\ndef fib(n):\n return n",
172
+ ]);
173
+ });
174
+
175
+ it("preserves decorated methods inside a class", () => {
176
+ const input = `
177
+ @app.cell
178
+ def _():
179
+ class A:
180
+ @property
181
+ def x(self):
182
+ return self._x
183
+ return A
184
+ `;
185
+ expect(extractCells(input)).toEqual([
186
+ "class A:\n @property\n def x(self):\n return self._x",
187
+ ]);
188
+ });
189
+
190
+ it("handles async cells with multi-line args", () => {
191
+ const input = `
192
+ @app.cell
193
+ async def _(
194
+ a,
195
+ b,
196
+ ):
197
+ x = await foo(a, b)
198
+ return x
199
+ `;
200
+ expect(extractCells(input)).toEqual(["x = await foo(a, b)"]);
201
+ });
202
+
203
+ it("strips multi-line returns using brackets", () => {
204
+ const input = `
205
+ @app.cell
206
+ def _():
207
+ a = 1
208
+ b = 2
209
+ return [
210
+ a,
211
+ b,
212
+ ]
213
+ `;
214
+ expect(extractCells(input)).toEqual(["a = 1\nb = 2"]);
215
+ });
216
+
217
+ it("strips multi-line returns using braces", () => {
218
+ const input = `
219
+ @app.cell
220
+ def _():
221
+ a = 1
222
+ return {
223
+ "a": a,
224
+ }
225
+ `;
226
+ expect(extractCells(input)).toEqual(["a = 1"]);
227
+ });
228
+
229
+ it("does not corrupt the following cell after a bracketed return", () => {
230
+ const input = `
231
+ @app.cell
232
+ def _():
233
+ a = 1
234
+ return [
235
+ a,
236
+ ]
237
+
238
+ @app.cell
239
+ def _():
240
+ b = 2
241
+ return b
242
+ `;
243
+ expect(extractCells(input)).toEqual(["a = 1", "b = 2"]);
244
+ });
245
+
246
+ it("preserves comments in the cell body", () => {
247
+ const input = `
248
+ @app.cell
249
+ def _():
250
+ # leading comment
251
+ x = 1
252
+ return x
253
+ `;
254
+ expect(extractCells(input)).toEqual(["# leading comment\nx = 1"]);
255
+ });
256
+
257
+ it("extracts the setup block separately from cells", () => {
258
+ const input = `
259
+ import marimo
260
+ app = marimo.App()
261
+
262
+ with app.setup(hide_code=True):
263
+ import marimo as mo
264
+ import numpy as np
265
+ from numpy.linalg import eigh
266
+ `;
267
+ expect(extractMarimoApp(input)).toEqual({
268
+ setup:
269
+ "import marimo as mo\nimport numpy as np\nfrom numpy.linalg import eigh",
270
+ cells: [],
271
+ });
272
+ // The convenience wrapper excludes the setup block.
273
+ expect(extractCells(input)).toEqual([]);
274
+ });
275
+
276
+ it("extracts @app.function definitions, keeping their return", () => {
277
+ const input = `
278
+ @app.function
279
+ def laplacian_matrix(adjacency_matrix):
280
+ degree_matrix = np.diag(np.sum(adjacency_matrix, axis=1))
281
+ L = degree_matrix - adjacency_matrix
282
+ return L
283
+ `;
284
+ expect(extractCells(input)).toEqual([
285
+ "def laplacian_matrix(adjacency_matrix):\n degree_matrix = np.diag(np.sum(adjacency_matrix, axis=1))\n L = degree_matrix - adjacency_matrix\n return L",
286
+ ]);
287
+ });
288
+
289
+ it("extracts setup separately while keeping cells and functions in order", () => {
290
+ const input = `
291
+ with app.setup:
292
+ import numpy as np
293
+
294
+ @app.cell
295
+ def _():
296
+ a = 1
297
+ return (a,)
298
+
299
+ @app.function
300
+ def double(x):
301
+ return x * 2
302
+ `;
303
+ expect(extractMarimoApp(input)).toEqual({
304
+ setup: "import numpy as np",
305
+ cells: ["a = 1", "def double(x):\n return x * 2"],
306
+ });
307
+ });
308
+
161
309
  it("handles cells with config", () => {
162
310
  const input = `
163
311
  @app.cell(hide_code=True, column=2)
@@ -254,4 +402,45 @@ def _():
254
402
 
255
403
  expect(createManyBelow).not.toHaveBeenCalled();
256
404
  });
405
+
406
+ it("routes the setup block to the setup cell, not a normal cell", () => {
407
+ const createManyBelow = vi.fn();
408
+ const addOrAppendSetupCell = vi.fn();
409
+ const extension = pasteBundle();
410
+ const view = new EditorView({
411
+ state: EditorState.create({
412
+ doc: "",
413
+ extensions: [
414
+ extension,
415
+ cellActionsState.of({
416
+ createManyBelow,
417
+ addOrAppendSetupCell,
418
+ } as never as CodemirrorCellActions),
419
+ ],
420
+ }),
421
+ });
422
+
423
+ const clipboardData = new MockDataTransfer();
424
+ clipboardData.setData(
425
+ "text/plain",
426
+ `
427
+ with app.setup:
428
+ import numpy as np
429
+
430
+ @app.cell
431
+ def _():
432
+ x = 1
433
+ return x
434
+ `,
435
+ );
436
+ const event = new MockClipboardEvent("paste", { clipboardData });
437
+
438
+ // HACK: manually add the paste event listener
439
+ // @ts-expect-error extension not typed
440
+ view.dom.onpaste = (evt) => extension[0].domEventHandlers.paste(evt, view);
441
+ view.dom.dispatchEvent(event);
442
+
443
+ expect(addOrAppendSetupCell).toHaveBeenCalledWith("import numpy as np");
444
+ expect(createManyBelow).toHaveBeenCalledWith(["x = 1"]);
445
+ });
257
446
  });
@@ -1,6 +1,8 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import type { Extension } from "@codemirror/state";
3
3
  import { EditorView } from "@codemirror/view";
4
+ import type { SyntaxNode } from "@lezer/common";
5
+ import { parser } from "@lezer/python";
4
6
  import { cellActionsState } from "../cells/state";
5
7
 
6
8
  export function pasteBundle(): Extension[] {
@@ -8,152 +10,166 @@ export function pasteBundle(): Extension[] {
8
10
  EditorView.domEventHandlers({
9
11
  paste: (event: ClipboardEvent, view: EditorView) => {
10
12
  const text = event.clipboardData?.getData("text/plain");
11
- if (!text?.includes("@app.cell")) {
13
+ if (!text || !looksLikeMarimoApp(text)) {
12
14
  return false;
13
15
  }
14
16
 
15
- const cells = extractCells(text);
16
- if (cells.length === 0) {
17
+ const { setup, cells } = extractMarimoApp(text);
18
+ if (setup === null && cells.length === 0) {
17
19
  return false;
18
20
  }
19
21
 
20
22
  const actions = view.state.facet(cellActionsState);
21
- actions.createManyBelow(cells);
23
+ // The `with app.setup` block belongs in the special setup cell, not in
24
+ // an ordinary cell.
25
+ if (setup !== null) {
26
+ actions.addOrAppendSetupCell(setup);
27
+ }
28
+ if (cells.length > 0) {
29
+ actions.createManyBelow(cells);
30
+ }
22
31
  return true;
23
32
  },
24
33
  }),
25
34
  ];
26
35
  }
27
36
 
37
+ /** Whether the pasted text looks like marimo app source worth parsing. */
38
+ function looksLikeMarimoApp(text: string): boolean {
39
+ return /@app\.cell|@app\.function|\bapp\.setup\b/.test(text);
40
+ }
41
+
42
+ export interface ExtractedApp {
43
+ /** The `with app.setup` block's body, if present. Belongs in the setup cell. */
44
+ setup: string | null;
45
+ /** Ordinary cells, in source order. */
46
+ cells: string[];
47
+ }
48
+
28
49
  /**
29
- * Extract the cells from a marimo app.
50
+ * Extract the cells of a marimo app from its source.
51
+ *
52
+ * Parses the pasted source with the Python grammar (the same one that powers
53
+ * the editor) and turns each marimo construct into a cell:
54
+ * - `with app.setup(...)` contributes the setup block's body, returned
55
+ * separately as `setup` because it belongs in the dedicated setup cell.
56
+ * - `@app.cell` functions contribute their body, minus the trailing
57
+ * auto-generated `return`.
58
+ * - `@app.function` definitions contribute the whole function (the decorator
59
+ * is dropped, but the function — including its `return` — is kept).
60
+ *
61
+ * Using the real syntax tree means decorators, `async def`, multi-line
62
+ * signatures, nested functions, and multi-line returns of any bracket type are
63
+ * all handled structurally rather than by line-based heuristics.
30
64
  */
31
- export function extractCells(text: string): string[] {
32
- // Quick check if this looks like a marimo app
33
- if (!text.includes("@app.cell")) {
34
- return [];
65
+ export function extractMarimoApp(text: string): ExtractedApp {
66
+ if (!looksLikeMarimoApp(text)) {
67
+ return { setup: null, cells: [] };
35
68
  }
36
69
 
37
70
  const cells: string[] = [];
38
- const lines = text.split("\n");
39
- let currentCell: string[] = [];
40
- let inCell = false;
41
- let skipLines = 0;
42
- let inMultilineArgs = false;
43
- let inMultilineReturn = false;
44
- let parenCount = 0;
45
- let cellBaseIndent: number | null = null;
46
-
47
- // Pre-compile regex patterns
48
- const leadingParenRegex = /\(/g;
49
- const trailingParenRegex = /\)/g;
50
- const cellEndMarkers = new Set(["@"]);
51
-
52
- function countParens(line: string): number {
53
- return (
54
- (line.match(leadingParenRegex) || []).length -
55
- (line.match(trailingParenRegex) || []).length
56
- );
57
- }
58
-
59
- function getIndent(line: string): number {
60
- const match = line.match(/^\s*/);
61
- return match ? match[0].length : 0;
62
- }
63
-
64
- function finalizeCellIfNeeded() {
65
- if (currentCell.length === 0) {
66
- return;
71
+ const setupBlocks: string[] = [];
72
+ const tree = parser.parse(text);
73
+
74
+ // A node's `from` starts after its line's leading indentation; extend back to
75
+ // the start of the line so `dedent` sees consistent indentation, then dedent.
76
+ const blockText = (from: number, to: number): string => {
77
+ const lineStart = text.lastIndexOf("\n", from - 1) + 1;
78
+ return dedent(text.slice(lineStart, to));
79
+ };
80
+
81
+ const pushCell = (from: number, to: number) => {
82
+ const cell = blockText(from, to);
83
+ if (cell.trim()) {
84
+ cells.push(cell);
67
85
  }
68
-
69
- // Only add non-empty cells
70
- if (currentCell.some((l) => l.trim() !== "")) {
71
- cells.push(dedent(currentCell.join("\n")));
86
+ };
87
+
88
+ // The text of a block's body (the statements after its `:`), optionally
89
+ // dropping a trailing auto-generated `return`. Returns null if empty.
90
+ const bodyText = (
91
+ body: SyntaxNode,
92
+ stripTrailingReturn: boolean,
93
+ ): string | null => {
94
+ const children: SyntaxNode[] = [];
95
+ for (let child = body.firstChild; child; child = child.nextSibling) {
96
+ if (child.name !== ":") {
97
+ children.push(child);
98
+ }
72
99
  }
73
- currentCell = [];
74
- }
75
-
76
- for (const line of lines) {
77
- const trimmed = line.trim();
78
-
79
- // Skip empty lines between cells
80
- if (!trimmed && !inCell) {
81
- continue;
100
+ if (stripTrailingReturn && children.at(-1)?.name === "ReturnStatement") {
101
+ children.pop();
82
102
  }
83
-
84
- // Start of a new cell
85
- if (trimmed.startsWith("@app.cell")) {
86
- finalizeCellIfNeeded();
87
- inCell = true;
88
- skipLines = 1; // Skip the def line
89
- cellBaseIndent = null;
90
- continue;
103
+ const first = children[0];
104
+ const last = children.at(-1);
105
+ if (!first || !last) {
106
+ return null;
91
107
  }
92
-
93
- // Handle function definition and args
94
- if (skipLines > 0) {
95
- if (
96
- trimmed.startsWith("def") &&
97
- trimmed.includes("(") &&
98
- !trimmed.includes("):")
99
- ) {
100
- inMultilineArgs = true;
101
- parenCount = countParens(trimmed);
108
+ const cell = blockText(first.from, last.to);
109
+ return cell.trim() ? cell : null;
110
+ };
111
+
112
+ tree.iterate({
113
+ enter: (node) => {
114
+ // Setup cell: `with app.setup:` / `with app.setup(...):`
115
+ if (node.name === "WithStatement") {
116
+ const body = node.node.getChild("Body");
117
+ if (body && text.slice(node.from, body.from).includes("app.setup")) {
118
+ const setup = bodyText(body, false);
119
+ if (setup !== null) {
120
+ setupBlocks.push(setup);
121
+ }
122
+ }
123
+ return false;
102
124
  }
103
- skipLines--;
104
- continue;
105
- }
106
125
 
107
- // Track multi-line arguments
108
- if (inMultilineArgs) {
109
- parenCount += countParens(trimmed);
110
- if (parenCount === 0) {
111
- inMultilineArgs = false;
126
+ if (node.name !== "DecoratedStatement") {
127
+ // Keep descending until we reach the decorated definitions.
128
+ return true;
112
129
  }
113
- continue;
114
- }
115
-
116
- if (!inCell) {
117
- continue;
118
- }
119
-
120
- // Handle cell content
121
- if (cellEndMarkers.has(trimmed[0]) || trimmed.startsWith("if __name__")) {
122
- finalizeCellIfNeeded();
123
- inCell = trimmed.startsWith("@");
124
- continue;
125
- }
126
-
127
- // Detect base indentation of cell body from first content line
128
- if (cellBaseIndent === null && trimmed) {
129
- cellBaseIndent = getIndent(line);
130
- }
131
130
 
132
- // Handle return statements — only strip cell-level returns
133
- if (trimmed.startsWith("return") && getIndent(line) === cellBaseIndent) {
134
- if (trimmed.includes("(") && !trimmed.endsWith(")")) {
135
- inMultilineReturn = true;
136
- parenCount = countParens(trimmed);
131
+ const decorator = node.node.getChild("Decorator");
132
+ const fn = node.node.getChild("FunctionDefinition");
133
+ if (!decorator || !fn) {
134
+ return false;
135
+ }
136
+ const decoratorText = text.slice(decorator.from, decorator.to).trim();
137
+
138
+ // `@app.cell` / `@app.cell(...)`: the body becomes a cell, minus the
139
+ // trailing auto-generated return.
140
+ if (decoratorText.startsWith("@app.cell")) {
141
+ const body = fn.getChild("Body");
142
+ if (body) {
143
+ const cell = bodyText(body, true);
144
+ if (cell !== null) {
145
+ cells.push(cell);
146
+ }
147
+ }
148
+ return false;
137
149
  }
138
- continue;
139
- }
140
150
 
141
- if (inMultilineReturn) {
142
- parenCount += countParens(trimmed);
143
- if (parenCount === 0) {
144
- inMultilineReturn = false;
151
+ // `@app.function`: the whole function definition is the cell (the
152
+ // decorator is dropped, but the function — including its return — stays).
153
+ if (decoratorText.startsWith("@app.function")) {
154
+ pushCell(fn.from, fn.to);
145
155
  }
146
- continue;
147
- }
148
156
 
149
- // Add line to current cell
150
- currentCell.push(line);
151
- }
157
+ return false;
158
+ },
159
+ });
152
160
 
153
- // Handle last cell
154
- finalizeCellIfNeeded();
161
+ return {
162
+ setup: setupBlocks.length > 0 ? setupBlocks.join("\n\n") : null,
163
+ cells,
164
+ };
165
+ }
155
166
 
156
- return cells;
167
+ /**
168
+ * Convenience wrapper returning only the ordinary cells (excluding the setup
169
+ * block). Prefer {@link extractMarimoApp} when the setup block matters.
170
+ */
171
+ export function extractCells(text: string): string[] {
172
+ return extractMarimoApp(text).cells;
157
173
  }
158
174
 
159
175
  function dedent(text: string): string {