Skip to content

Commit d0db089

Browse files
committed
fix(ts-lsp): suppress parent call's parameter hints inside callback bodies
tsserver treats a whole callback argument INCLUDING its body as "inside the call", so the parent signature popup showed (and, via the cursor-activity refresh, never dismissed) while coding inside a callback like on("x", () => { | }). The gate rejects in getParameterHints so the LSP provider stays the request owner (no fall-through to the legacy Tern provider) and the popup auto-dismisses when the caret enters the body. A bounded backward token scan classifies the cursor context: unmatched "{" preceded by "=>", ")" or a block keyword means function body (suppress); object/array literals, strings, comments and nested calls keep their hints. The token at the cursor takes part in the accounting (a just-typed "(" must count). Minified-style lines (>2000 chars) are never token-scanned - that walk is quadratic and can freeze the UI - hints are suppressed there except directly at a call head, decided by a cheap 200-char plain-text window. Measured: 0.076ms typical, 11ms one-off worst case, 4us on minified lines. vtsls-specific policy, localized to this extension (intelephense and pyrefly already answer null inside nested bodies). Adds table-driven specs (33 cases) for both scanner functions plus popup-level assertions.
1 parent 9fbea91 commit d0db089

2 files changed

Lines changed: 320 additions & 0 deletions

File tree

src/extensions/default/TypeScriptSupport/main.js

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ define(function (require, exports, module) {
3636
EditorManager = brackets.getModule("editor/EditorManager"),
3737
FileSystem = brackets.getModule("filesystem/FileSystem"),
3838
NodeConnector = brackets.getModule("NodeConnector"),
39+
TokenUtils = brackets.getModule("utils/TokenUtils"),
3940
CodeIntelligence = require("./CodeIntelligence"),
4041
ConfigPanel = require("./ConfigPanel");
4142

@@ -178,6 +179,7 @@ define(function (require, exports, module) {
178179
}
179180

180181
let registered = false;
182+
let _client = null;
181183
let lspClientPromise = null;
182184

183185
/**
@@ -228,6 +230,148 @@ define(function (require, exports, module) {
228230
});
229231
}
230232

233+
// A body-opening "{" is preceded by "=>" (arrow body), ")" (function/if/for/switch body) or a
234+
// bare block keyword; an object-literal "{" follows "(", ",", ":", "=", "return", "[", ...
235+
const BRACE_BODY_MARKERS = /^(=>|\)|do|else|try|finally)$/;
236+
237+
// Lines longer than this are minified-style code. The backward token walk re-tokenizes a
238+
// line's prefix on every step (O(length^2) per line), which on a single huge line can freeze
239+
// the UI for seconds - so such lines are never token-scanned.
240+
const PARAM_SCAN_MAX_LINE_LENGTH = 2000;
241+
242+
// Plain-character backward scan over a small window - used only on minified-style lines where
243+
// tokenizing is too expensive. True when the cursor sits directly inside a call's argument
244+
// parens (an unmatched "(" appears before any unmatched "{"), e.g. right after typing `foo(`.
245+
// Quotes/comments are not understood - best effort, bounded blast radius on minified code.
246+
function _atCallHeadPlainText(lineText, ch) {
247+
const windowStart = Math.max(0, ch - 200);
248+
let parenDepth = 0,
249+
braceDepth = 0;
250+
for (let i = ch - 1; i >= windowStart; i--) {
251+
const c = lineText.charAt(i);
252+
if (c === ")") {
253+
parenDepth++;
254+
} else if (c === "(") {
255+
if (parenDepth > 0) {
256+
parenDepth--;
257+
} else {
258+
return true; // directly inside a call's parens
259+
}
260+
} else if (c === "}") {
261+
braceDepth++;
262+
} else if (c === "{") {
263+
if (braceDepth > 0) {
264+
braceDepth--;
265+
} else {
266+
return false; // some brace scope encloses the cursor first - not at a call head
267+
}
268+
}
269+
}
270+
return false; // cannot tell within the window
271+
}
272+
273+
// True when the cursor sits inside a `{...}` FUNCTION BODY nested within the surrounding
274+
// call's argument list - e.g. `on("x", () => { <cursor> })`. Object-literal arguments
275+
// (`css({ color: <cursor> })`) deliberately do NOT match: their active-parameter highlight is
276+
// genuinely useful, and their opening brace is distinguishable by the token before it.
277+
// Bounded token-based backward scan; string/comment tokens are ignored.
278+
function _inFunctionBodyInsideArgs(editor) {
279+
const cursor = editor.getCursorPos();
280+
const cursorLineText = editor.document.getLine(cursor.line) || "";
281+
if (cursorLineText.length > PARAM_SCAN_MAX_LINE_LENGTH) {
282+
// Minified-style line: token-scanning it would freeze the UI, so don't. Suppress the
283+
// popup there UNLESS the cursor sits right at a call head (just typed `foo(` / moving
284+
// between a call's parens) - that much a cheap plain-text window can decide.
285+
return !_atCallHeadPlainText(cursorLineText, cursor.ch);
286+
}
287+
const ctx = TokenUtils.getInitialContext(editor._codeMirror, cursor);
288+
let parenDepth = 0,
289+
braceDepth = 0,
290+
unmatchedBrace = false,
291+
scanned = 0,
292+
first = true;
293+
while (scanned++ < 2000) {
294+
// The initial context token is the one directly BEFORE/at the cursor (e.g. the "(" the
295+
// user just typed in `console.log(|`). It must take part in the bracket accounting -
296+
// skipping it would match the wrong parens and misclassify the position.
297+
if (first) {
298+
first = false;
299+
} else {
300+
// About to cross into the previous line (mirrors movePrevToken's own condition)?
301+
// Bail out BEFORE paying to tokenize it if it is minified-style huge - fail open,
302+
// the hint then behaves as it did before this gate existed.
303+
if (ctx.pos.ch <= 0 || ctx.token.start <= 0) {
304+
if (ctx.pos.line <= 0) {
305+
break;
306+
}
307+
const prevLineText = editor.document.getLine(ctx.pos.line - 1) || "";
308+
if (prevLineText.length > PARAM_SCAN_MAX_LINE_LENGTH) {
309+
return false;
310+
}
311+
}
312+
if (!TokenUtils.movePrevToken(ctx)) {
313+
break;
314+
}
315+
}
316+
const type = ctx.token.type || "";
317+
if (type.indexOf("string") !== -1 || type.indexOf("comment") !== -1) {
318+
continue;
319+
}
320+
const text = ctx.token.string.trim();
321+
if (!text) {
322+
continue;
323+
}
324+
if (unmatchedBrace) {
325+
// The token preceding an unmatched "{" tells whether it opened a function body
326+
// (suppress) or a literal (keep scanning outward for the call paren).
327+
if (BRACE_BODY_MARKERS.test(text)) {
328+
return true;
329+
}
330+
unmatchedBrace = false;
331+
// fall through - this token still takes part in normal bracket accounting
332+
}
333+
if (text === "}") {
334+
braceDepth++;
335+
} else if (text === "{") {
336+
if (braceDepth > 0) {
337+
braceDepth--;
338+
} else {
339+
unmatchedBrace = true;
340+
}
341+
} else if (text === ")") {
342+
parenDepth++;
343+
} else if (text === "(") {
344+
if (parenDepth > 0) {
345+
parenDepth--;
346+
} else {
347+
return false; // reached the enclosing call's "(" directly - a real arg position
348+
}
349+
}
350+
}
351+
return false;
352+
}
353+
354+
// tsserver treats a whole callback argument INCLUDING its body as "inside the call", so the
355+
// parent call's signature would pop up (and, via the cursor-activity refresh, never dismiss)
356+
// while coding inside a callback. Rejecting in getParameterHints covers every trigger path -
357+
// Ctrl-Space, typed "("/",", and the cursor-activity re-show, which then auto-dismisses an
358+
// already-visible popup when the caret enters the body. Rejecting (rather than declining in
359+
// hasParameterHints) keeps this provider the owner of the request, so the manager dismisses
360+
// the popup instead of falling through to the legacy Tern JS provider. This is vtsls-specific
361+
// policy, so it wraps OUR provider here instead of living in the shared LSP framework
362+
// (intelephense and pyrefly already answer null inside nested bodies on their own).
363+
function _installParameterHintBodyGate(client) {
364+
const provider = client.parameterHints;
365+
const baseGetParameterHints = provider.getParameterHints.bind(provider);
366+
provider.getParameterHints = function (explicit, onCursorActivity) {
367+
const editor = EditorManager.getActiveEditor();
368+
if (editor && _inFunctionBodyInsideArgs(editor)) {
369+
return $.Deferred().reject(null);
370+
}
371+
return baseGetParameterHints(explicit, onCursorActivity);
372+
};
373+
}
374+
231375
async function start() {
232376
if (registered || !canRun()) {
233377
return;
@@ -250,6 +394,8 @@ define(function (require, exports, module) {
250394
});
251395
if (client) {
252396
registered = true;
397+
_client = client;
398+
_installParameterHintBodyGate(client);
253399
}
254400
}
255401

@@ -365,4 +511,16 @@ define(function (require, exports, module) {
365511
}
366512
});
367513
});
514+
515+
if (Phoenix.isTestWindow) {
516+
// the registered LanguageClient (null until the server has started) - lets integration
517+
// tests drive the LSP providers/requests directly
518+
exports._getClient = function () {
519+
return _client;
520+
};
521+
// the parameter-hint body-gate internals, exported so tests can table-drive the
522+
// classification of cursor contexts without a server round-trip per case
523+
exports._inFunctionBodyInsideArgs = _inFunctionBodyInsideArgs;
524+
exports._atCallHeadPlainText = _atCallHeadPlainText;
525+
}
368526
});

src/extensions/default/TypeScriptSupport/unittests.js

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,168 @@ define(function (require, exports, module) {
9999
}, "TypeScript type error to be reported", 30000);
100100
}, 45000);
101101

102+
it("should reject the parent call's parameter hints inside a callback body only", async function () {
103+
// tsserver treats the whole callback argument INCLUDING its body as "inside the call",
104+
// so without the body gate the parent signature popup shows (and never dismisses)
105+
// while coding inside the callback. The gate REJECTS the request (instead of declining
106+
// in hasParameterHints) so the manager dismisses the popup without falling through to
107+
// the legacy Tern JS provider. Object-literal arguments must keep their hints.
108+
const tsMain = await new Promise(function (resolve, reject) {
109+
const ExtensionLoader = testWindow.brackets.test.ExtensionLoader;
110+
const ctx = ExtensionLoader.getRequireContextForExtension("TypeScriptSupport");
111+
ctx(["main"], resolve, reject);
112+
});
113+
const client = tsMain._getClient();
114+
expect(client).toBeTruthy();
115+
const provider = client.parameterHints;
116+
await _openInProject("ts/", "type-error.ts");
117+
const editor = EditorManager.getActiveEditor();
118+
editor.document.setText(
119+
"function withOptions(cb: (n: number) => void, options: { color: string }) { cb(1); }\n" +
120+
"withOptions((n) => {\n" +
121+
" console.log()\n" +
122+
"}, { color: \"red\" });\n"
123+
);
124+
125+
// inside the callback BODY -> the request is rejected outright (popup suppressed /
126+
// auto-dismissed there), while the provider still owns the request
127+
editor.setCursorPos(2, 4);
128+
expect(provider.hasParameterHints(editor, null)).toBe(true);
129+
expect(provider.getParameterHints(true, false).state()).toBe("rejected");
130+
131+
// inside a NESTED call's parens within the body (`console.log(|)`) -> that call's own
132+
// hints must flow; the just-typed "(" is the token at the cursor and must be counted
133+
const logLine = editor.document.getLine(2);
134+
editor.setCursorPos(2, logLine.indexOf("(") + 1);
135+
expect(provider.getParameterHints(true, false).state()).not.toBe("rejected");
136+
137+
// directly inside the argument list -> hints flow to the server as usual
138+
editor.setCursorPos(1, 12);
139+
expect(provider.getParameterHints(true, false).state()).not.toBe("rejected");
140+
141+
// inside an object-literal argument -> hints still flow
142+
const literalLine = editor.document.getLine(3);
143+
editor.setCursorPos(3, literalLine.indexOf("\"red\""));
144+
expect(provider.getParameterHints(true, false).state()).not.toBe("rejected");
145+
146+
// minified-style lines (>2000 chars) are never token-scanned (that walk is quadratic
147+
// and can freeze the UI): the popup is suppressed there, EXCEPT directly at a call
148+
// head, which a cheap plain-text window decides
149+
const filler = "x1=1;".repeat(500); // 2500 chars
150+
const minified = "process.on(\"x\", () => {" + filler + "console.log(";
151+
editor.document.setText(minified);
152+
editor.setCursorPos(0, minified.length); // right after `console.log(`
153+
expect(provider.getParameterHints(true, false).state()).not.toBe("rejected");
154+
editor.setCursorPos(0, minified.indexOf(filler) + 1000); // deep in the filler
155+
expect(provider.getParameterHints(true, false).state()).toBe("rejected");
156+
157+
await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE,
158+
{ fullPath: testFolder + "ts/type-error.ts", _forceClose: true }));
159+
}, 45000);
160+
161+
// Parse a source snippet with a "|" cursor marker into {text, pos}.
162+
function _parseCursorMarker(src) {
163+
const idx = src.indexOf("|");
164+
const before = src.substring(0, idx);
165+
const line = before.split("\n").length - 1;
166+
const ch = line === 0 ? idx : idx - before.lastIndexOf("\n") - 1;
167+
return { text: src.replace("|", ""), pos: { line: line, ch: ch } };
168+
}
169+
170+
it("should classify cursor contexts for the parameter-hint body gate", async function () {
171+
// Table-driven coverage of _inFunctionBodyInsideArgs. inBody: true means the parent
172+
// call's signature popup is suppressed at the "|" cursor; false means hints flow.
173+
const CASES = [
174+
// function bodies nested in call arguments -> suppress
175+
{ name: "blank arrow body", src: "on(\"x\", () => { | })", inBody: true },
176+
{ name: "arrow body after a statement", src: "on(\"x\", () => { log(\"hi\"); | })", inBody: true },
177+
{ name: "multi-line arrow body", src: "on(\"x\", (a, b) => {\n const c = a + b;\n |\n})",
178+
inBody: true },
179+
{ name: "function-expression body", src: "arr.map(function (x) { | })", inBody: true },
180+
{ name: "if block inside a callback body", src: "f(x => { if (y) { | } })", inBody: true },
181+
{ name: "object method shorthand body", src: "f({ m() { | } })", inBody: true },
182+
{ name: "class method body in class-expression arg", src: "f(class { m() { | } })", inBody: true },
183+
{ name: "statement position after a closed nested call", src: "f(() => { g(); | })", inBody: true },
184+
// also true with no call around it - harmless, servers offer no signature there
185+
{ name: "top-level function body", src: "function a() { | }", inBody: true },
186+
187+
// argument positions -> hints must flow
188+
{ name: "empty argument list", src: "withOptions(|)", inBody: false },
189+
{ name: "second argument", src: "f(a, |)", inBody: false },
190+
{ name: "after a closed nested call, still in args", src: "f(g(1), |)", inBody: false },
191+
{ name: "nested call inside a callback body", src: "f(() => { console.log(|) })", inBody: false },
192+
{ name: "nested call with args inside a body", src: "f(() => { log(\"a\", |) })", inBody: false },
193+
194+
// object/array literals in arguments -> hints must flow
195+
{ name: "object literal argument", src: "css({ color: | })", inBody: false },
196+
{ name: "nested object literal", src: "cfg({ a: { b: | } })", inBody: false },
197+
{ name: "object literal inside an array argument", src: "f([{ a: | }])", inBody: false },
198+
{ name: "parenthesized object literal", src: "f(({ a: | }))", inBody: false },
199+
200+
// strings and comments are invisible to the scan
201+
{ name: "brace inside a string argument", src: "f(\"some { text\", |)", inBody: false },
202+
{ name: "brace inside a template string", src: "f(`some { brace | text`)", inBody: false },
203+
{ name: "brace inside a block comment", src: "f(/* { */ |2)", inBody: false },
204+
{ name: "brace inside a line comment", src: "f(1, // { comment\n |2)", inBody: false },
205+
206+
// not in any call at all
207+
{ name: "top-level statement", src: "const x = |;", inBody: false }
208+
];
209+
const tsMain = await new Promise(function (resolve, reject) {
210+
const ExtensionLoader = testWindow.brackets.test.ExtensionLoader;
211+
const ctx = ExtensionLoader.getRequireContextForExtension("TypeScriptSupport");
212+
ctx(["main"], resolve, reject);
213+
});
214+
await _openInProject("ts/", "type-error.ts");
215+
const editor = EditorManager.getActiveEditor();
216+
const failures = [];
217+
CASES.forEach(function (testCase) {
218+
const parsed = _parseCursorMarker(testCase.src);
219+
editor.document.setText(parsed.text);
220+
editor.setCursorPos(parsed.pos.line, parsed.pos.ch);
221+
const actual = tsMain._inFunctionBodyInsideArgs(editor);
222+
if (actual !== testCase.inBody) {
223+
failures.push(testCase.name + ": expected inBody=" + testCase.inBody + " but got " + actual);
224+
}
225+
});
226+
expect(failures).toEqual([]);
227+
await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE,
228+
{ fullPath: testFolder + "ts/type-error.ts", _forceClose: true }));
229+
}, 45000);
230+
231+
it("should decide call heads on minified-style lines via the plain-text window", async function () {
232+
// Table-driven coverage of _atCallHeadPlainText (used only on >2000-char lines, where
233+
// token-scanning is too expensive). atHead: true means hints are allowed there.
234+
const filler = "x=1;".repeat(80); // 320 chars of paren/brace-free filler
235+
const CASES = [
236+
{ name: "right after an open paren", line: "foo(", ch: 4, atHead: true },
237+
{ name: "between autoclosed parens", line: "foo()", ch: 4, atHead: true },
238+
{ name: "after an argument", line: "foo(a, ", ch: 7, atHead: true },
239+
{ name: "after a closed nested call", line: "foo(bar(1), ", ch: 12, atHead: true },
240+
{ name: "after a matched object-literal argument", line: "foo({a:1}, ", ch: 11, atHead: true },
241+
// conservative on minified lines: any enclosing brace suppresses
242+
{ name: "inside a body brace", line: "f(() => {" + filler, ch: 9 + 40, atHead: false },
243+
{ name: "inside an object-literal argument", line: "foo({ ", ch: 6, atHead: false },
244+
{ name: "no parens within the window", line: filler + filler, ch: 300, atHead: false },
245+
{ name: "call paren beyond the 200-char window", line: "foo(" + "a".repeat(250), ch: 254,
246+
atHead: false },
247+
{ name: "line start", line: "foo", ch: 0, atHead: false }
248+
];
249+
const tsMain = await new Promise(function (resolve, reject) {
250+
const ExtensionLoader = testWindow.brackets.test.ExtensionLoader;
251+
const ctx = ExtensionLoader.getRequireContextForExtension("TypeScriptSupport");
252+
ctx(["main"], resolve, reject);
253+
});
254+
const failures = [];
255+
CASES.forEach(function (testCase) {
256+
const actual = tsMain._atCallHeadPlainText(testCase.line, testCase.ch);
257+
if (actual !== testCase.atHead) {
258+
failures.push(testCase.name + ": expected atHead=" + testCase.atHead + " but got " + actual);
259+
}
260+
});
261+
expect(failures).toEqual([]);
262+
}, 45000);
263+
102264
it("should report implicit-any in a JS project that opts into checkJs", async function () {
103265
// js-checkjs has a jsconfig.json with checkJs + noImplicitAny, so the untyped parameter
104266
// in implicit.js IS flagged - and our diagnostic filter keeps it (the project opted in).

0 commit comments

Comments
 (0)