Skip to content

Commit ea3c57c

Browse files
authored
Tighten import handling and fix SetComprehension typo (#9607)
1 parent 4a621fa commit ea3c57c

6 files changed

Lines changed: 259 additions & 66 deletions

File tree

frontend/src/core/codemirror/go-to-definition/__tests__/commands.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,73 @@ a = 10`;
253253
`);
254254
});
255255

256+
test("from-import alias is the binding, not the imported name", async () => {
257+
const code = `\
258+
from math import sin as my_sin
259+
print(my_sin)`;
260+
view = createEditor(code);
261+
const usagePosition = code.lastIndexOf("my_sin");
262+
const result = goToVariableDefinition(view, "my_sin", usagePosition);
263+
264+
expect(result).toBe(true);
265+
await tick();
266+
// The alias `my_sin` (after `as`) is the real binding.
267+
expect(renderEditorView(view)).toMatchInlineSnapshot(`
268+
"
269+
from math import sin as my_sin
270+
^
271+
print(my_sin)
272+
"
273+
`);
274+
});
275+
276+
test("module path in from-import is not a local definition", async () => {
277+
const code = `\
278+
from math import sin
279+
print(math)`;
280+
view = createEditor(code);
281+
const usagePosition = code.lastIndexOf("math");
282+
// `math` is a module reference in the from-clause, not a binding in this
283+
// cell, so the scoped resolver should return false and let the caller fall
284+
// through to cross-cell resolution.
285+
const result = goToVariableDefinition(view, "math", usagePosition);
286+
287+
expect(result).toBe(false);
288+
expect(view.state.selection.main.head).toBe(0);
289+
});
290+
291+
test("imported name without `as` is a local definition", async () => {
292+
const code = `\
293+
from math import sin
294+
print(sin)`;
295+
view = createEditor(code);
296+
const usagePosition = code.lastIndexOf("sin");
297+
const result = goToVariableDefinition(view, "sin", usagePosition);
298+
299+
expect(result).toBe(true);
300+
await tick();
301+
expect(renderEditorView(view)).toMatchInlineSnapshot(`
302+
"
303+
from math import sin
304+
^
305+
print(sin)
306+
"
307+
`);
308+
});
309+
310+
test("imported name shadowed by `as` is not a binding", async () => {
311+
const code = `\
312+
from math import sin as my_sin
313+
print(sin)`;
314+
view = createEditor(code);
315+
const usagePosition = code.lastIndexOf("sin");
316+
// `sin` here refers to nothing in this cell (it was renamed to `my_sin`),
317+
// so the scoped resolver should return false.
318+
const result = goToVariableDefinition(view, "sin", usagePosition);
319+
320+
expect(result).toBe(false);
321+
});
322+
256323
test("selects outer-scope function declaration", async () => {
257324
view = createEditor(`\
258325
def x():

frontend/src/core/codemirror/go-to-definition/__tests__/utils.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,51 @@ output = _x + 10`;
133133
await tick();
134134
expect(view.state.selection.main.head).toBe(code.indexOf("_x = 10"));
135135
});
136+
137+
test("falls through to cross-cell when in-cell occurrence is only a module path in a from-import", async () => {
138+
// Regression: ImportStatement used to register every VariableName child
139+
// (the module path and pre-`as` names) as in-cell declarations, so the
140+
// local-first short-circuit would steal F12 from cross-cell resolution.
141+
const moduleCell = cellId("module-cell");
142+
const usageCell = cellId("usage-cell");
143+
const moduleCode = `mymodule = 100`;
144+
const usageCode = `\
145+
from mymodule import something
146+
print(mymodule)`;
147+
148+
const moduleView = createEditor(moduleCode, moduleCode.length);
149+
const usageView = createEditor(
150+
usageCode,
151+
usageCode.lastIndexOf("mymodule"),
152+
);
153+
views.push(moduleView, usageView);
154+
155+
const notebook = initialNotebookState();
156+
notebook.cellHandles[moduleCell] = {
157+
current: { editorView: moduleView, editorViewOrNull: moduleView },
158+
} as never;
159+
notebook.cellHandles[usageCell] = {
160+
current: { editorView: usageView, editorViewOrNull: usageView },
161+
} as never;
162+
163+
store.set(notebookAtom, notebook);
164+
store.set(variablesAtom, {
165+
[variableName("mymodule")]: {
166+
dataType: "int",
167+
declaredBy: [moduleCell],
168+
name: variableName("mymodule"),
169+
usedBy: [usageCell],
170+
value: "100",
171+
},
172+
});
173+
174+
const result = goToDefinitionAtCursorPosition(usageView);
175+
176+
expect(result).toBe(true);
177+
await tick();
178+
// Cross-cell jump: moduleView's cursor should land on `mymodule = 100`.
179+
expect(moduleView.state.selection.main.head).toBe(
180+
moduleCode.indexOf("mymodule"),
181+
);
182+
});
136183
});

frontend/src/core/codemirror/go-to-definition/commands.ts

Lines changed: 47 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -307,33 +307,52 @@ function collectMatchingDeclarations(
307307
break;
308308
}
309309
case "ImportStatement": {
310+
// The grammar emits one ImportStatement for both `import x [as y]` and
311+
// `from m import x [as y], ...`. Direct children include the keywords
312+
// (`from`/`import`/`as`), commas, dots, and every VariableName from the
313+
// module path AND the import list. We only want the names that actually
314+
// bind in the current scope: the post-`as` alias if present, otherwise
315+
// the imported name itself. Names before `import` (the from-path) and
316+
// the original name when an alias follows it are NOT bindings.
310317
const subCursor = node.cursor();
311318
subCursor.firstChild();
312-
do {
313-
if (
314-
subCursor.name === "VariableName" &&
315-
state.doc.sliceString(subCursor.from, subCursor.to) === variableName
316-
) {
317-
addDeclaration(declarations, currentScope, subCursor.from);
319+
let pastImport = false;
320+
// Buffer the most recent post-`import` VariableName so we can defer
321+
// committing it until we know whether `as` follows.
322+
let pending: { from: number; matches: boolean } | null = null;
323+
const commit = () => {
324+
if (pending?.matches) {
325+
addDeclaration(declarations, currentScope, pending.from);
318326
}
319-
} while (subCursor.nextSibling());
320-
break;
321-
}
322-
case "ImportFromStatement": {
323-
const subCursor = node.cursor();
324-
subCursor.firstChild();
325-
let foundImport = false;
327+
pending = null;
328+
};
326329
do {
327330
if (subCursor.name === "import") {
328-
foundImport = true;
329-
} else if (
330-
foundImport &&
331-
subCursor.name === "VariableName" &&
332-
state.doc.sliceString(subCursor.from, subCursor.to) === variableName
333-
) {
334-
addDeclaration(declarations, currentScope, subCursor.from);
331+
pastImport = true;
332+
continue;
333+
}
334+
if (!pastImport) {
335+
continue;
336+
}
337+
if (subCursor.name === "as") {
338+
// Next VariableName is the alias and replaces `pending`.
339+
pending = null;
340+
continue;
341+
}
342+
if (subCursor.name === "VariableName") {
343+
// Flush any previous pending name (no `as` followed it).
344+
commit();
345+
pending = {
346+
from: subCursor.from,
347+
matches:
348+
state.doc.sliceString(subCursor.from, subCursor.to) ===
349+
variableName,
350+
};
351+
} else if (subCursor.name === ",") {
352+
commit();
335353
}
336354
} while (subCursor.nextSibling());
355+
commit();
337356
break;
338357
}
339358
case "TryStatement":
@@ -410,23 +429,21 @@ function findScopedDefinitionPosition(
410429
* @param view The editor view which contains the variable name.
411430
* @param variableName The name of the variable to select, if found in the editor.
412431
* @param usagePosition The position of the variable usage, if available.
413-
* @param fallbackToFirstMatch Whether to fall back to the first matching
414-
* variable name when no scoped definition is found. Defaults to true.
415432
*/
416433
export function goToVariableDefinition(
417434
view: EditorView,
418435
variableName: string,
419436
usagePosition?: number,
420-
fallbackToFirstMatch = true,
421437
): boolean {
422438
const { state } = view;
423-
let from: number | null = null;
424-
if (usagePosition !== undefined) {
425-
from = findScopedDefinitionPosition(state, variableName, usagePosition);
426-
}
427-
if (from === null && fallbackToFirstMatch) {
428-
from = findFirstMatchingVariable(state, variableName);
429-
}
439+
// When the caller knows the usage position, trust the scoped lookup. Falling
440+
// back to first-match would defeat the local-vs-cross-cell decision in
441+
// goToDefinition: if the symbol only appears as a module path in an import,
442+
// scoped resolution returns null and we want the caller to try other cells.
443+
const from =
444+
usagePosition !== undefined
445+
? findScopedDefinitionPosition(state, variableName, usagePosition)
446+
: findFirstMatchingVariable(state, variableName);
430447

431448
if (from === null) {
432449
return false;

frontend/src/core/codemirror/go-to-definition/utils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ export function goToDefinition(
8282
view,
8383
variableName,
8484
usagePosition,
85-
false,
8685
);
8786
if (foundLocally) {
8887
return true;

frontend/src/core/codemirror/reactive-references/__tests__/analyzer.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,60 @@ def run(polars):
640640
`);
641641
});
642642

643+
test("set comprehension target shadows outer global", () => {
644+
// Regression: SCOPE_CREATING_NODES used "SetComprehension" instead of the
645+
// grammar's "SetComprehensionExpression", so set comprehensions never
646+
// created a scope and their for-target was treated as reactive.
647+
expect(runHighlight(["x"], "result = {x for x in range(5)}"))
648+
.toMatchInlineSnapshot(`
649+
"
650+
result = {x for x in range(5)}
651+
"
652+
`);
653+
});
654+
655+
test("from-import module path stays reactive", () => {
656+
// Regression: ImportStatement collected every VariableName child, so the
657+
// module name in `from m import y` was wrongly treated as a local binding.
658+
expect(
659+
runHighlight(
660+
["math"],
661+
`
662+
def f():
663+
from math import sin as my_sin
664+
return math + my_sin(1)`,
665+
),
666+
).toMatchInlineSnapshot(`
667+
"
668+
def f():
669+
from math import sin as my_sin
670+
return math + my_sin(1)
671+
^^^^
672+
"
673+
`);
674+
});
675+
676+
test("from-import: aliased imported name is not a binding", () => {
677+
// Regression: `sin` in `from math import sin as my_sin` was incorrectly
678+
// registered as a local binding, hiding genuine reactive uses of `sin`.
679+
expect(
680+
runHighlight(
681+
["sin"],
682+
`
683+
def f():
684+
from math import sin as my_sin
685+
return sin + my_sin(1)`,
686+
),
687+
).toMatchInlineSnapshot(`
688+
"
689+
def f():
690+
from math import sin as my_sin
691+
return sin + my_sin(1)
692+
^^^
693+
"
694+
`);
695+
});
696+
643697
test("lambda inside function with outer global", () => {
644698
expect(
645699
runHighlight(

0 commit comments

Comments
 (0)