@marimo-team/islands 0.23.7-dev50 → 0.23.7-dev52

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.
@@ -1,7 +1,31 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { syntaxTree } from "@codemirror/language";
4
+ import type { EditorState } from "@codemirror/state";
4
5
  import { EditorView } from "@codemirror/view";
6
+ import type { SyntaxNode, Tree, TreeCursor } from "@lezer/common";
7
+
8
+ const SCOPE_CREATING_NODES = new Set([
9
+ "FunctionDefinition",
10
+ "LambdaExpression",
11
+ "ArrayComprehensionExpression",
12
+ "SetComprehensionExpression",
13
+ "DictionaryComprehensionExpression",
14
+ "ComprehensionExpression",
15
+ "ClassDefinition",
16
+ ]);
17
+
18
+ const POSITION_SENSITIVE_SCOPES = new Set(["ClassDefinition"]);
19
+
20
+ interface ScopeContext {
21
+ id: number;
22
+ type: string;
23
+ }
24
+
25
+ interface VariableDeclaration {
26
+ from: number;
27
+ scopeId: number;
28
+ }
5
29
 
6
30
  function goToPosition(view: EditorView, from: number): void {
7
31
  view.focus();
@@ -22,50 +46,386 @@ function goToPosition(view: EditorView, from: number): void {
22
46
  });
23
47
  }
24
48
 
25
- /**
26
- * This function will select the first occurrence of the given variable name,
27
- * for a given editor view.
28
- * @param view The editor view which contains the variable name.
29
- * @param variableName The name of the variable to select, if found in the editor.
30
- */
31
- export function goToVariableDefinition(
32
- view: EditorView,
49
+ function findFirstMatchingVariable(
50
+ state: EditorState,
33
51
  variableName: string,
34
- ): boolean {
35
- const { state } = view;
52
+ ): number | null {
36
53
  const tree = syntaxTree(state);
37
54
 
38
- let found = false;
39
- let from = 0;
55
+ let from: number | null = null;
40
56
 
41
57
  tree.iterate({
42
58
  enter: (node) => {
43
- if (found) {
59
+ if (from !== null) {
44
60
  return false;
45
- } // Stop traversal if found
61
+ }
46
62
 
47
- // Check if the node is an identifier and matches the variable name
48
63
  if (
49
64
  node.name === "VariableName" &&
50
65
  state.doc.sliceString(node.from, node.to) === variableName
51
66
  ) {
52
67
  from = node.from;
53
- found = true;
54
- return false; // Stop traversal
68
+ return false;
55
69
  }
56
70
 
57
- // Skip comments and strings
58
71
  if (node.name === "Comment" || node.name === "String") {
59
72
  return false;
60
73
  }
74
+
75
+ return undefined;
61
76
  },
62
77
  });
63
78
 
64
- if (found) {
65
- goToPosition(view, from);
66
- return true;
79
+ return from;
80
+ }
81
+
82
+ function getScopeChain(tree: Tree, usagePosition: number): ScopeContext[] {
83
+ const scopeChain: ScopeContext[] = [];
84
+ let currentNode: SyntaxNode | null = tree.resolveInner(usagePosition, 0);
85
+
86
+ while (currentNode) {
87
+ if (SCOPE_CREATING_NODES.has(currentNode.name)) {
88
+ // Skip ClassDefinition if we've already seen a function/lambda.
89
+ const inFunctionLikeScope = scopeChain.some(
90
+ (scope) =>
91
+ scope.type === "FunctionDefinition" ||
92
+ scope.type === "LambdaExpression",
93
+ );
94
+ if (!(inFunctionLikeScope && currentNode.name === "ClassDefinition")) {
95
+ scopeChain.push({
96
+ id: currentNode.from,
97
+ type: currentNode.name,
98
+ });
99
+ }
100
+ }
101
+ currentNode = currentNode.parent;
102
+ }
103
+
104
+ scopeChain.push({ id: -1, type: "global" });
105
+ return scopeChain;
106
+ }
107
+
108
+ function addDeclaration(
109
+ declarations: VariableDeclaration[],
110
+ scopeId: number,
111
+ from: number,
112
+ ) {
113
+ declarations.push({ scopeId, from });
114
+ }
115
+
116
+ function traverseChildren(
117
+ cursor: TreeCursor,
118
+ callback: (node: SyntaxNode) => void,
119
+ ) {
120
+ if (cursor.firstChild()) {
121
+ do {
122
+ callback(cursor.node);
123
+ } while (cursor.nextSibling());
124
+ }
125
+ }
126
+
127
+ function collectMatchingTargets(
128
+ cursor: TreeCursor,
129
+ state: EditorState,
130
+ variableName: string,
131
+ scopeId: number,
132
+ declarations: VariableDeclaration[],
133
+ ) {
134
+ switch (cursor.name) {
135
+ case "VariableName":
136
+ if (state.doc.sliceString(cursor.from, cursor.to) === variableName) {
137
+ addDeclaration(declarations, scopeId, cursor.from);
138
+ }
139
+ break;
140
+
141
+ case "TupleExpression":
142
+ case "ArrayExpression": {
143
+ const childCursor = cursor.node.cursor();
144
+ childCursor.firstChild();
145
+ do {
146
+ collectMatchingTargets(
147
+ childCursor,
148
+ state,
149
+ variableName,
150
+ scopeId,
151
+ declarations,
152
+ );
153
+ } while (childCursor.nextSibling());
154
+ break;
155
+ }
156
+ default:
157
+ break;
67
158
  }
68
- return false;
159
+ }
160
+
161
+ function collectFunctionParameters(
162
+ node: SyntaxNode | Tree,
163
+ state: EditorState,
164
+ variableName: string,
165
+ scopeId: number,
166
+ declarations: VariableDeclaration[],
167
+ ) {
168
+ const cursor = node.cursor();
169
+ cursor.firstChild();
170
+ do {
171
+ if (cursor.name !== "ParamList") {
172
+ continue;
173
+ }
174
+
175
+ const paramCursor = cursor.node.cursor();
176
+ paramCursor.firstChild();
177
+ do {
178
+ if (
179
+ paramCursor.name === "VariableName" &&
180
+ state.doc.sliceString(paramCursor.from, paramCursor.to) === variableName
181
+ ) {
182
+ addDeclaration(declarations, scopeId, paramCursor.from);
183
+ }
184
+ } while (paramCursor.nextSibling());
185
+ } while (cursor.nextSibling());
186
+ }
187
+
188
+ function collectForTargets(
189
+ node: SyntaxNode | Tree,
190
+ state: EditorState,
191
+ variableName: string,
192
+ scopeId: number,
193
+ declarations: VariableDeclaration[],
194
+ ) {
195
+ const cursor = node.cursor();
196
+ cursor.firstChild();
197
+ let foundFor = false;
198
+ do {
199
+ if (cursor.name === "for") {
200
+ foundFor = true;
201
+ } else if (foundFor && cursor.name === "in") {
202
+ break;
203
+ } else if (foundFor) {
204
+ collectMatchingTargets(
205
+ cursor,
206
+ state,
207
+ variableName,
208
+ scopeId,
209
+ declarations,
210
+ );
211
+ }
212
+ } while (cursor.nextSibling());
213
+ }
214
+
215
+ function collectMatchingDeclarations(
216
+ node: SyntaxNode | Tree,
217
+ state: EditorState,
218
+ variableName: string,
219
+ scopeStack: number[],
220
+ declarations: VariableDeclaration[],
221
+ ) {
222
+ const cursor = node.cursor();
223
+ const nodeName = cursor.name;
224
+ const nodeStart = cursor.from;
225
+
226
+ const isNewScope = SCOPE_CREATING_NODES.has(nodeName);
227
+ const currentScopeStack = isNewScope
228
+ ? [...scopeStack, nodeStart]
229
+ : scopeStack;
230
+ const currentScope = currentScopeStack[currentScopeStack.length - 1] ?? -1;
231
+
232
+ switch (nodeName) {
233
+ case "FunctionDefinition":
234
+ case "ClassDefinition": {
235
+ const subCursor = node.cursor();
236
+ subCursor.firstChild();
237
+ do {
238
+ if (
239
+ subCursor.name === "VariableName" &&
240
+ state.doc.sliceString(subCursor.from, subCursor.to) === variableName
241
+ ) {
242
+ const parentScope = scopeStack[scopeStack.length - 1] ?? -1;
243
+ addDeclaration(declarations, parentScope, subCursor.from);
244
+ break;
245
+ }
246
+ } while (subCursor.nextSibling());
247
+
248
+ if (nodeName === "FunctionDefinition") {
249
+ collectFunctionParameters(
250
+ node,
251
+ state,
252
+ variableName,
253
+ nodeStart,
254
+ declarations,
255
+ );
256
+ }
257
+ break;
258
+ }
259
+ case "LambdaExpression":
260
+ collectFunctionParameters(
261
+ node,
262
+ state,
263
+ variableName,
264
+ nodeStart,
265
+ declarations,
266
+ );
267
+ break;
268
+
269
+ case "ArrayComprehensionExpression":
270
+ case "DictionaryComprehensionExpression":
271
+ case "SetComprehensionExpression":
272
+ case "ComprehensionExpression":
273
+ case "ForStatement":
274
+ collectForTargets(node, state, variableName, currentScope, declarations);
275
+ break;
276
+
277
+ case "AssignStatement": {
278
+ const assignOpPositions: number[] = [];
279
+ const subCursor = node.cursor();
280
+ subCursor.firstChild();
281
+ do {
282
+ if (subCursor.name === "AssignOp") {
283
+ assignOpPositions.push(subCursor.from);
284
+ }
285
+ } while (subCursor.nextSibling());
286
+
287
+ const lastAssignOpPosition =
288
+ assignOpPositions[assignOpPositions.length - 1];
289
+ if (lastAssignOpPosition === undefined) {
290
+ break;
291
+ }
292
+
293
+ const targetCursor = node.cursor();
294
+ targetCursor.firstChild();
295
+ do {
296
+ if (targetCursor.from < lastAssignOpPosition) {
297
+ collectMatchingTargets(
298
+ targetCursor,
299
+ state,
300
+ variableName,
301
+ currentScope,
302
+ declarations,
303
+ );
304
+ }
305
+ } while (targetCursor.nextSibling());
306
+ break;
307
+ }
308
+ case "ImportStatement": {
309
+ const subCursor = node.cursor();
310
+ subCursor.firstChild();
311
+ do {
312
+ if (
313
+ subCursor.name === "VariableName" &&
314
+ state.doc.sliceString(subCursor.from, subCursor.to) === variableName
315
+ ) {
316
+ addDeclaration(declarations, currentScope, subCursor.from);
317
+ }
318
+ } while (subCursor.nextSibling());
319
+ break;
320
+ }
321
+ case "ImportFromStatement": {
322
+ const subCursor = node.cursor();
323
+ subCursor.firstChild();
324
+ let foundImport = false;
325
+ do {
326
+ if (subCursor.name === "import") {
327
+ foundImport = true;
328
+ } else if (
329
+ foundImport &&
330
+ subCursor.name === "VariableName" &&
331
+ state.doc.sliceString(subCursor.from, subCursor.to) === variableName
332
+ ) {
333
+ addDeclaration(declarations, currentScope, subCursor.from);
334
+ }
335
+ } while (subCursor.nextSibling());
336
+ break;
337
+ }
338
+ case "TryStatement":
339
+ case "WithStatement": {
340
+ const subCursor = node.cursor();
341
+ subCursor.firstChild();
342
+ let foundAs = false;
343
+ do {
344
+ if (subCursor.name === "as") {
345
+ foundAs = true;
346
+ } else if (
347
+ foundAs &&
348
+ subCursor.name === "VariableName" &&
349
+ state.doc.sliceString(subCursor.from, subCursor.to) === variableName
350
+ ) {
351
+ addDeclaration(declarations, currentScope, subCursor.from);
352
+ foundAs = false;
353
+ }
354
+ } while (subCursor.nextSibling());
355
+ break;
356
+ }
357
+ default:
358
+ break;
359
+ }
360
+
361
+ traverseChildren(cursor, (childNode) => {
362
+ collectMatchingDeclarations(
363
+ childNode,
364
+ state,
365
+ variableName,
366
+ currentScopeStack,
367
+ declarations,
368
+ );
369
+ });
370
+ }
371
+
372
+ function findScopedDefinitionPosition(
373
+ state: EditorState,
374
+ variableName: string,
375
+ usagePosition: number,
376
+ ): number | null {
377
+ const tree = syntaxTree(state);
378
+ const declarations: VariableDeclaration[] = [];
379
+
380
+ collectMatchingDeclarations(tree, state, variableName, [], declarations);
381
+
382
+ const clampedUsagePosition = Math.max(
383
+ 0,
384
+ Math.min(usagePosition, state.doc.length),
385
+ );
386
+
387
+ for (const scope of getScopeChain(tree, clampedUsagePosition)) {
388
+ const match = declarations
389
+ .filter((declaration) => declaration.scopeId === scope.id)
390
+ .filter((declaration) => {
391
+ return POSITION_SENSITIVE_SCOPES.has(scope.type)
392
+ ? declaration.from <= clampedUsagePosition
393
+ : true;
394
+ })
395
+ .toSorted((left, right) => left.from - right.from)[0];
396
+
397
+ if (match) {
398
+ return match.from;
399
+ }
400
+ }
401
+
402
+ return null;
403
+ }
404
+
405
+ /**
406
+ * This function will select the first occurrence of the given variable name,
407
+ * for a given editor view.
408
+ * @param view The editor view which contains the variable name.
409
+ * @param variableName The name of the variable to select, if found in the editor.
410
+ * @param usagePosition The position of the variable usage, if available.
411
+ */
412
+ export function goToVariableDefinition(
413
+ view: EditorView,
414
+ variableName: string,
415
+ usagePosition?: number,
416
+ ): boolean {
417
+ const { state } = view;
418
+ const from =
419
+ (usagePosition !== undefined
420
+ ? findScopedDefinitionPosition(state, variableName, usagePosition)
421
+ : null) ?? findFirstMatchingVariable(state, variableName);
422
+
423
+ if (from === null) {
424
+ return false;
425
+ }
426
+
427
+ goToPosition(view, from);
428
+ return true;
69
429
  }
70
430
 
71
431
  /**
@@ -18,10 +18,16 @@ function getWordUnderCursor(state: EditorState) {
18
18
  const { from, to } = state.selection.main;
19
19
  if (from === to) {
20
20
  const { startToken, endToken } = getPositionAtWordBounds(state.doc, from);
21
- return state.doc.sliceString(startToken, endToken);
21
+ return {
22
+ position: startToken,
23
+ word: state.doc.sliceString(startToken, endToken),
24
+ };
22
25
  }
23
26
 
24
- return state.doc.sliceString(from, to);
27
+ return {
28
+ position: from,
29
+ word: state.doc.sliceString(from, to),
30
+ };
25
31
  }
26
32
 
27
33
  /**
@@ -51,15 +57,15 @@ function isPrivateVariable(variableName: string) {
51
57
  */
52
58
  export function goToDefinitionAtCursorPosition(view: EditorView): boolean {
53
59
  const { state } = view;
54
- const variableName = getWordUnderCursor(state);
55
- if (!variableName) {
60
+ const { position, word } = getWordUnderCursor(state);
61
+ if (!word) {
56
62
  return false;
57
63
  }
58
64
  // Close popovers/tooltips
59
65
  closeCompletion(view);
60
66
  view.dispatch({ effects: closeHoverTooltips });
61
67
 
62
- return goToDefinition(view, variableName);
68
+ return goToDefinition(view, word, position);
63
69
  }
64
70
 
65
71
  /**
@@ -69,7 +75,19 @@ export function goToDefinitionAtCursorPosition(view: EditorView): boolean {
69
75
  export function goToDefinition(
70
76
  view: EditorView,
71
77
  variableName: string,
78
+ usagePosition?: number,
72
79
  ): boolean {
80
+ if (usagePosition !== undefined) {
81
+ const foundLocally = goToVariableDefinition(
82
+ view,
83
+ variableName,
84
+ usagePosition,
85
+ );
86
+ if (foundLocally) {
87
+ return true;
88
+ }
89
+ }
90
+
73
91
  // The variable may exist in another cell
74
92
  const editorWithVariable = getEditorForVariable(view, variableName);
75
93
  if (!editorWithVariable) {