})`) deliberately do NOT match: their active-parameter highlight is
+ // genuinely useful, and their opening brace is distinguishable by the token before it.
+ // Bounded token-based backward scan; string/comment tokens are ignored.
+ function _inFunctionBodyInsideArgs(editor) {
+ const cursor = editor.getCursorPos();
+ const cursorLineText = editor.document.getLine(cursor.line) || "";
+ if (cursorLineText.length > PARAM_SCAN_MAX_LINE_LENGTH) {
+ // Minified-style line: token-scanning it would freeze the UI, so don't. Suppress the
+ // popup there UNLESS the cursor sits right at a call head (just typed `foo(` / moving
+ // between a call's parens) - that much a cheap plain-text window can decide.
+ return !_atCallHeadPlainText(cursorLineText, cursor.ch);
+ }
+ const ctx = TokenUtils.getInitialContext(editor._codeMirror, cursor);
+ let parenDepth = 0,
+ braceDepth = 0,
+ unmatchedBrace = false,
+ scanned = 0,
+ first = true;
+ while (scanned++ < 2000) {
+ // The initial context token is the one directly BEFORE/at the cursor (e.g. the "(" the
+ // user just typed in `console.log(|`). It must take part in the bracket accounting -
+ // skipping it would match the wrong parens and misclassify the position.
+ if (first) {
+ first = false;
+ } else {
+ // About to cross into the previous line (mirrors movePrevToken's own condition)?
+ // Bail out BEFORE paying to tokenize it if it is minified-style huge - fail open,
+ // the hint then behaves as it did before this gate existed.
+ if (ctx.pos.ch <= 0 || ctx.token.start <= 0) {
+ if (ctx.pos.line <= 0) {
+ break;
+ }
+ const prevLineText = editor.document.getLine(ctx.pos.line - 1) || "";
+ if (prevLineText.length > PARAM_SCAN_MAX_LINE_LENGTH) {
+ return false;
+ }
+ }
+ if (!TokenUtils.movePrevToken(ctx)) {
+ break;
+ }
+ }
+ const type = ctx.token.type || "";
+ if (type.indexOf("string") !== -1 || type.indexOf("comment") !== -1) {
+ continue;
+ }
+ const text = ctx.token.string.trim();
+ if (!text) {
+ continue;
+ }
+ if (unmatchedBrace) {
+ // The token preceding an unmatched "{" tells whether it opened a function body
+ // (suppress) or a literal (keep scanning outward for the call paren).
+ if (BRACE_BODY_MARKERS.test(text)) {
+ return true;
+ }
+ unmatchedBrace = false;
+ // fall through - this token still takes part in normal bracket accounting
+ }
+ if (text === "}") {
+ braceDepth++;
+ } else if (text === "{") {
+ if (braceDepth > 0) {
+ braceDepth--;
+ } else {
+ unmatchedBrace = true;
+ }
+ } else if (text === ")") {
+ parenDepth++;
+ } else if (text === "(") {
+ if (parenDepth > 0) {
+ parenDepth--;
+ } else {
+ return false; // reached the enclosing call's "(" directly - a real arg position
+ }
+ }
+ }
+ return false;
+ }
+
async function start() {
if (registered || !canRun()) {
return;
@@ -246,10 +369,17 @@ define(function (require, exports, module) {
languages: SUPPORTED_LANGUAGES,
languageIdMap: LANGUAGE_ID_MAP,
initializationOptions: INITIALIZATION_OPTIONS,
- filterDiagnostics: filterDiagnostics
+ filterDiagnostics: filterDiagnostics,
+ // tsserver treats a whole callback argument INCLUDING its body as "inside the call" -
+ // veto signature help there so the parent call's hint doesn't show (or stick around)
+ // while coding inside a callback like `on("x", () => { | })`.
+ shouldShowParameterHints: function (editor) {
+ return !_inFunctionBodyInsideArgs(editor);
+ }
});
if (client) {
registered = true;
+ _client = client;
}
}
@@ -365,4 +495,16 @@ define(function (require, exports, module) {
}
});
});
+
+ if (Phoenix.isTestWindow) {
+ // the registered LanguageClient (null until the server has started) - lets integration
+ // tests drive the LSP providers/requests directly
+ exports._getClient = function () {
+ return _client;
+ };
+ // the parameter-hint body-gate internals, exported so tests can table-drive the
+ // classification of cursor contexts without a server round-trip per case
+ exports._inFunctionBodyInsideArgs = _inFunctionBodyInsideArgs;
+ exports._atCallHeadPlainText = _atCallHeadPlainText;
+ }
});
diff --git a/src/extensions/default/TypeScriptSupport/unittests.js b/src/extensions/default/TypeScriptSupport/unittests.js
index 26a0a7e4c8..106e82302d 100644
--- a/src/extensions/default/TypeScriptSupport/unittests.js
+++ b/src/extensions/default/TypeScriptSupport/unittests.js
@@ -99,6 +99,168 @@ define(function (require, exports, module) {
}, "TypeScript type error to be reported", 30000);
}, 45000);
+ it("should reject the parent call's parameter hints inside a callback body only", async function () {
+ // tsserver treats the whole callback argument INCLUDING its body as "inside the call",
+ // so without the body gate the parent signature popup shows (and never dismisses)
+ // while coding inside the callback. The gate REJECTS the request (instead of declining
+ // in hasParameterHints) so the manager dismisses the popup without falling through to
+ // the legacy Tern JS provider. Object-literal arguments must keep their hints.
+ const tsMain = await new Promise(function (resolve, reject) {
+ const ExtensionLoader = testWindow.brackets.test.ExtensionLoader;
+ const ctx = ExtensionLoader.getRequireContextForExtension("TypeScriptSupport");
+ ctx(["main"], resolve, reject);
+ });
+ const client = tsMain._getClient();
+ expect(client).toBeTruthy();
+ const provider = client.parameterHints;
+ await _openInProject("ts/", "type-error.ts");
+ const editor = EditorManager.getActiveEditor();
+ editor.document.setText(
+ "function withOptions(cb: (n: number) => void, options: { color: string }) { cb(1); }\n" +
+ "withOptions((n) => {\n" +
+ " console.log()\n" +
+ "}, { color: \"red\" });\n"
+ );
+
+ // inside the callback BODY -> the request is rejected outright (popup suppressed /
+ // auto-dismissed there), while the provider still owns the request
+ editor.setCursorPos(2, 4);
+ expect(provider.hasParameterHints(editor, null)).toBe(true);
+ expect(provider.getParameterHints(true, false).state()).toBe("rejected");
+
+ // inside a NESTED call's parens within the body (`console.log(|)`) -> that call's own
+ // hints must flow; the just-typed "(" is the token at the cursor and must be counted
+ const logLine = editor.document.getLine(2);
+ editor.setCursorPos(2, logLine.indexOf("(") + 1);
+ expect(provider.getParameterHints(true, false).state()).not.toBe("rejected");
+
+ // directly inside the argument list -> hints flow to the server as usual
+ editor.setCursorPos(1, 12);
+ expect(provider.getParameterHints(true, false).state()).not.toBe("rejected");
+
+ // inside an object-literal argument -> hints still flow
+ const literalLine = editor.document.getLine(3);
+ editor.setCursorPos(3, literalLine.indexOf("\"red\""));
+ expect(provider.getParameterHints(true, false).state()).not.toBe("rejected");
+
+ // minified-style lines (>2000 chars) are never token-scanned (that walk is quadratic
+ // and can freeze the UI): the popup is suppressed there, EXCEPT directly at a call
+ // head, which a cheap plain-text window decides
+ const filler = "x1=1;".repeat(500); // 2500 chars
+ const minified = "process.on(\"x\", () => {" + filler + "console.log(";
+ editor.document.setText(minified);
+ editor.setCursorPos(0, minified.length); // right after `console.log(`
+ expect(provider.getParameterHints(true, false).state()).not.toBe("rejected");
+ editor.setCursorPos(0, minified.indexOf(filler) + 1000); // deep in the filler
+ expect(provider.getParameterHints(true, false).state()).toBe("rejected");
+
+ await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE,
+ { fullPath: testFolder + "ts/type-error.ts", _forceClose: true }));
+ }, 45000);
+
+ // Parse a source snippet with a "|" cursor marker into {text, pos}.
+ function _parseCursorMarker(src) {
+ const idx = src.indexOf("|");
+ const before = src.substring(0, idx);
+ const line = before.split("\n").length - 1;
+ const ch = line === 0 ? idx : idx - before.lastIndexOf("\n") - 1;
+ return { text: src.replace("|", ""), pos: { line: line, ch: ch } };
+ }
+
+ it("should classify cursor contexts for the parameter-hint body gate", async function () {
+ // Table-driven coverage of _inFunctionBodyInsideArgs. inBody: true means the parent
+ // call's signature popup is suppressed at the "|" cursor; false means hints flow.
+ const CASES = [
+ // function bodies nested in call arguments -> suppress
+ { name: "blank arrow body", src: "on(\"x\", () => { | })", inBody: true },
+ { name: "arrow body after a statement", src: "on(\"x\", () => { log(\"hi\"); | })", inBody: true },
+ { name: "multi-line arrow body", src: "on(\"x\", (a, b) => {\n const c = a + b;\n |\n})",
+ inBody: true },
+ { name: "function-expression body", src: "arr.map(function (x) { | })", inBody: true },
+ { name: "if block inside a callback body", src: "f(x => { if (y) { | } })", inBody: true },
+ { name: "object method shorthand body", src: "f({ m() { | } })", inBody: true },
+ { name: "class method body in class-expression arg", src: "f(class { m() { | } })", inBody: true },
+ { name: "statement position after a closed nested call", src: "f(() => { g(); | })", inBody: true },
+ // also true with no call around it - harmless, servers offer no signature there
+ { name: "top-level function body", src: "function a() { | }", inBody: true },
+
+ // argument positions -> hints must flow
+ { name: "empty argument list", src: "withOptions(|)", inBody: false },
+ { name: "second argument", src: "f(a, |)", inBody: false },
+ { name: "after a closed nested call, still in args", src: "f(g(1), |)", inBody: false },
+ { name: "nested call inside a callback body", src: "f(() => { console.log(|) })", inBody: false },
+ { name: "nested call with args inside a body", src: "f(() => { log(\"a\", |) })", inBody: false },
+
+ // object/array literals in arguments -> hints must flow
+ { name: "object literal argument", src: "css({ color: | })", inBody: false },
+ { name: "nested object literal", src: "cfg({ a: { b: | } })", inBody: false },
+ { name: "object literal inside an array argument", src: "f([{ a: | }])", inBody: false },
+ { name: "parenthesized object literal", src: "f(({ a: | }))", inBody: false },
+
+ // strings and comments are invisible to the scan
+ { name: "brace inside a string argument", src: "f(\"some { text\", |)", inBody: false },
+ { name: "brace inside a template string", src: "f(`some { brace | text`)", inBody: false },
+ { name: "brace inside a block comment", src: "f(/* { */ |2)", inBody: false },
+ { name: "brace inside a line comment", src: "f(1, // { comment\n |2)", inBody: false },
+
+ // not in any call at all
+ { name: "top-level statement", src: "const x = |;", inBody: false }
+ ];
+ const tsMain = await new Promise(function (resolve, reject) {
+ const ExtensionLoader = testWindow.brackets.test.ExtensionLoader;
+ const ctx = ExtensionLoader.getRequireContextForExtension("TypeScriptSupport");
+ ctx(["main"], resolve, reject);
+ });
+ await _openInProject("ts/", "type-error.ts");
+ const editor = EditorManager.getActiveEditor();
+ const failures = [];
+ CASES.forEach(function (testCase) {
+ const parsed = _parseCursorMarker(testCase.src);
+ editor.document.setText(parsed.text);
+ editor.setCursorPos(parsed.pos.line, parsed.pos.ch);
+ const actual = tsMain._inFunctionBodyInsideArgs(editor);
+ if (actual !== testCase.inBody) {
+ failures.push(testCase.name + ": expected inBody=" + testCase.inBody + " but got " + actual);
+ }
+ });
+ expect(failures).toEqual([]);
+ await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE,
+ { fullPath: testFolder + "ts/type-error.ts", _forceClose: true }));
+ }, 45000);
+
+ it("should decide call heads on minified-style lines via the plain-text window", async function () {
+ // Table-driven coverage of _atCallHeadPlainText (used only on >2000-char lines, where
+ // token-scanning is too expensive). atHead: true means hints are allowed there.
+ const filler = "x=1;".repeat(80); // 320 chars of paren/brace-free filler
+ const CASES = [
+ { name: "right after an open paren", line: "foo(", ch: 4, atHead: true },
+ { name: "between autoclosed parens", line: "foo()", ch: 4, atHead: true },
+ { name: "after an argument", line: "foo(a, ", ch: 7, atHead: true },
+ { name: "after a closed nested call", line: "foo(bar(1), ", ch: 12, atHead: true },
+ { name: "after a matched object-literal argument", line: "foo({a:1}, ", ch: 11, atHead: true },
+ // conservative on minified lines: any enclosing brace suppresses
+ { name: "inside a body brace", line: "f(() => {" + filler, ch: 9 + 40, atHead: false },
+ { name: "inside an object-literal argument", line: "foo({ ", ch: 6, atHead: false },
+ { name: "no parens within the window", line: filler + filler, ch: 300, atHead: false },
+ { name: "call paren beyond the 200-char window", line: "foo(" + "a".repeat(250), ch: 254,
+ atHead: false },
+ { name: "line start", line: "foo", ch: 0, atHead: false }
+ ];
+ const tsMain = await new Promise(function (resolve, reject) {
+ const ExtensionLoader = testWindow.brackets.test.ExtensionLoader;
+ const ctx = ExtensionLoader.getRequireContextForExtension("TypeScriptSupport");
+ ctx(["main"], resolve, reject);
+ });
+ const failures = [];
+ CASES.forEach(function (testCase) {
+ const actual = tsMain._atCallHeadPlainText(testCase.line, testCase.ch);
+ if (actual !== testCase.atHead) {
+ failures.push(testCase.name + ": expected atHead=" + testCase.atHead + " but got " + actual);
+ }
+ });
+ expect(failures).toEqual([]);
+ }, 45000);
+
it("should report implicit-any in a JS project that opts into checkJs", async function () {
// js-checkjs has a jsconfig.json with checkJs + noImplicitAny, so the untyped parameter
// in implicit.js IS flagged - and our diagnostic filter keeps it (the project opted in).
diff --git a/src/extensionsIntegrated/JSONSupport/JsonLsp.js b/src/extensionsIntegrated/JSONSupport/JsonLsp.js
index b7b1830767..46e1436a65 100644
--- a/src/extensionsIntegrated/JSONSupport/JsonLsp.js
+++ b/src/extensionsIntegrated/JSONSupport/JsonLsp.js
@@ -235,4 +235,12 @@ define(function (require, exports, module) {
exports.canRun = canRun;
exports.SERVER_ID = SERVER_ID;
exports._setTestSchemaAssociations = _setTestSchemaAssociations;
+ if (Phoenix.isTestWindow) {
+ // Test hook - the registered LanguageClient (null until the server has started). Lets
+ // integration tests drive the LSP providers (e.g. codeHints) directly, since the hint UI
+ // needs OS window focus that the embedded test window may not have.
+ exports._getClient = function () {
+ return client;
+ };
+ }
});
diff --git a/src/htmlContent/problems-panel-table.html b/src/htmlContent/problems-panel-table.html
index 8b797f45b7..60d44d26e4 100644
--- a/src/htmlContent/problems-panel-table.html
+++ b/src/htmlContent/problems-panel-table.html
@@ -28,7 +28,7 @@
{{#fix.id}}
+ data-fixid="{{fix.id}}" title="{{fix.title}}">
{{Strings.FIX}}
{{/fix.id}}
diff --git a/src/language/CodeInspection.js b/src/language/CodeInspection.js
index 8ca27655ba..c278421c7e 100644
--- a/src/language/CodeInspection.js
+++ b/src/language/CodeInspection.js
@@ -564,10 +564,14 @@ define(function (require, exports, module) {
const fixID = `${mark.metadata}`;
let errorMessageHTML = `${_.escape(mark.message)} `;
if(documentFixes.get(fixID)){
+ // the fix's title (when the provider gave one) tells the user what clicking
+ // Fix will actually do - e.g. LSP code actions can be generators/suppressions
+ const fixTitle = _.escape(documentFixes.get(fixID).title || "");
$problemView = $(`
- ${Strings.FIX}
+ ${Strings.FIX}
${errorMessageHTML}
@@ -913,6 +917,7 @@ define(function (require, exports, module) {
* type:?Type ,
* fix: { // an optional fix, if present will show the fix button
* replaceText: "text to replace the offset given below",
+ * title: "optional tooltip describing what the fix does",
* rangeOffset: {
* start: number,
* end: number
@@ -930,6 +935,7 @@ define(function (require, exports, module) {
* @property {?Type} type - The type of the error. Defaults to `Type.WARNING` if unspecified.
* @property {?Object} fix - An optional fix object.
* @property {string} fix.replaceText - The text to replace the error with.
+ * @property {?string} fix.title - Optional tooltip on the Fix button describing what the fix does.
* @property {Object} fix.rangeOffset - The range within the text to replace.
* @property {number} fix.rangeOffset.start - The start offset of the range.
* @property {number} fix.rangeOffset.end - The end offset of the range.
diff --git a/src/languageTools/DefaultProviders.js b/src/languageTools/DefaultProviders.js
index 6eeee349e6..0b79be7e27 100644
--- a/src/languageTools/DefaultProviders.js
+++ b/src/languageTools/DefaultProviders.js
@@ -642,12 +642,19 @@ define(function (require, exports, module) {
// That start is stable as the user types forward (word/member starts don't move) and, crucially,
// for member completions it points AT the trigger "." while newText itself includes the dot
// (e.g. "console." + item ".log" -> replace from the "." -> "console.log", not "console..log").
- // We deliberately end at the CURRENT cursor rather than token.textEdit.range.end: that end is
- // stale when completions are served from cache while typing continues, which would otherwise
- // replace only part of the word (e.g. "conso"+enter -> "consolenso").
if (textEditRange && textEditRange.start.line === cursor.line &&
textEditRange.start.character <= cursor.ch) {
startCh = textEditRange.start.character;
+ // Honor a range.end BEYOND the cursor: the server wants existing text after the caret
+ // overwritten because newText includes it (e.g. JSON key completion inside autoclosed
+ // quotes `"|"` - newText `"author": {$1}` covers both quotes; keeping the closing quote
+ // would leave a dangling `"` behind the insert). A range.end at/before the cursor is
+ // clamped TO the cursor instead: that end is stale when completions are served from
+ // cache while typing continues, which would otherwise replace only part of the word
+ // (e.g. "conso"+enter -> "consolenso").
+ if (textEditRange.end.line === cursor.line && textEditRange.end.character > cursor.ch) {
+ endCh = textEditRange.end.character;
+ }
} else {
startCh = cursor.ch;
while (startCh > 0 && /[\w$]/.test(lineText.charAt(startCh - 1))) {
@@ -713,6 +720,19 @@ define(function (require, exports, module) {
docPath = editor.document.file._path,
$deferredHints = $.Deferred();
+ // A language server may VETO signature help at specific cursor positions by supplying a
+ // `shouldShowParameterHints(editor)` callback (e.g. vtsls suppresses the parent call's
+ // hint inside callback bodies). Rejecting here (rather than declining in
+ // hasParameterHints) keeps this provider the owner of the request, so the manager
+ // dismisses the popup instead of falling through to a lower-priority provider - and the
+ // cursor-activity refresh then auto-dismisses an already-visible popup when the caret
+ // moves into a vetoed position.
+ var config = this.client.config || {};
+ if (typeof config.shouldShowParameterHints === "function" &&
+ !config.shouldShowParameterHints(editor)) {
+ return $deferredHints.reject(null);
+ }
+
this.client.requestParameterHints({
filePath: docPath,
cursorPos: pos
@@ -1052,6 +1072,10 @@ define(function (require, exports, module) {
var range = edits[0].range;
return {
replaceText: edits[0].newText,
+ // the action's human title (e.g. "Generate variable `x`") - shown as the Fix
+ // button's tooltip so the user knows WHAT will be applied before clicking (servers
+ // often offer generators/suppressions, not the rename the message may suggest)
+ title: action.title,
rangeOffset: {
start: editor.indexFromPos({ line: range.start.line, ch: range.start.character }),
end: editor.indexFromPos({ line: range.end.line, ch: range.end.character })
diff --git a/src/languageTools/LSPClient.js b/src/languageTools/LSPClient.js
index e6b20d47ec..a9861fd624 100644
--- a/src/languageTools/LSPClient.js
+++ b/src/languageTools/LSPClient.js
@@ -364,12 +364,13 @@ define(function (require, exports, module) {
return { line: cursorPos.line, character: cursorPos.ch };
}
- // Build a cache key identifying the current completion "context": the file, line, the column
- // where the word under the cursor starts, and the text on the line before that word. While the
- // user types/moves within the same word, this key stays constant, so we can reuse the server's
- // (complete) result and filter client-side instead of re-querying. That avoids slow late
- // responses rebuilding the list mid-navigation.
- function _completionContextKey(filePath, pos) {
+ // Describe the completion "context" at a position:
+ // - key: identifies the word being typed - file, line, and the text on the line before the
+ // column where that word starts. Stays constant while typing/moving within one word.
+ // - query: the part of the word already typed before the cursor.
+ // - anchored: whether the word start sits directly after a trigger-ish non-space character
+ // (".", "->", "::") rather than after whitespace/line start.
+ function _completionContext(filePath, pos) {
const doc = DocumentManager.getOpenDocumentForPath(filePath);
if (!doc) {
return null;
@@ -379,7 +380,11 @@ define(function (require, exports, module) {
while (start > 0 && /[\w$]/.test(lineText.charAt(start - 1))) {
start--;
}
- return filePath + "|" + pos.line + "|" + lineText.substring(0, start);
+ return {
+ key: filePath + "|" + pos.line + "|" + lineText.substring(0, start),
+ query: lineText.substring(start, pos.ch),
+ anchored: start > 0 && /\S/.test(lineText.charAt(start - 1))
+ };
}
LanguageClient.prototype.requestHints = function (params) {
@@ -387,11 +392,23 @@ define(function (require, exports, module) {
const deferred = $.Deferred();
(async function () {
try {
- // Reuse the cached (complete) completion list while still in the same completion
- // context, so typing/cursor-moves within a word don't re-hit the server.
- const ctxKey = _completionContextKey(params.filePath, params.cursorPos);
- if (ctxKey && self._completionCache && self._completionCache.key === ctxKey) {
- deferred.resolve({ items: self._completionCache.items });
+ // Reuse the cached completion list while typing forward within one word, so every
+ // keystroke doesn't re-hit the server. Reuse is only sound when the cached list is
+ // a candidate SUPERSET of what the current query would return, so it needs ALL of:
+ // - the same context key (same file/line/word-start),
+ // - the current query extending the query the list was fetched with, and
+ // - that fetched query being non-empty OR anchored to a trigger char: a member
+ // list after "." / "->" is a closed set, but what a server answers at a BARE
+ // position is an arbitrary relevance selection, NOT a superset. intelephense
+ // answers a blank line with a grab-bag of symbols (marked complete!) which,
+ // if reused, swallows every later keystroke on that line (e.g. typing is_int
+ // showed no is_int because a stale blank-line list was being refiltered).
+ const ctx = _completionContext(params.filePath, params.cursorPos);
+ const cached = self._completionCache;
+ if (ctx && cached && cached.key === ctx.key &&
+ ctx.query.startsWith(cached.query) &&
+ (cached.query || cached.anchored)) {
+ deferred.resolve({ items: cached.items });
return;
}
await DocumentSync.flush(self, params.filePath);
@@ -406,8 +423,13 @@ define(function (require, exports, module) {
// just coerce documentation to a string for inline display.
item.documentation = _markupToString(item.documentation);
});
- // Only cache a complete list (an incomplete one must be re-queried as the user types).
- self._completionCache = (ctxKey && !isIncomplete) ? { key: ctxKey, items: items } : null;
+ // Only cache a complete, NON-EMPTY list (an incomplete one must be re-queried as
+ // the user types; an empty one has nothing to refilter - and some servers answer
+ // empty at a bare position yet answer fully once a prefix exists). The fetched
+ // query + anchoring are stored so the reuse check above can prove superset-ness.
+ self._completionCache = (ctx && !isIncomplete && items.length)
+ ? { key: ctx.key, query: ctx.query, anchored: ctx.anchored, items: items }
+ : null;
deferred.resolve({ items: items });
} catch (err) {
console.warn("[LSP] request failed:", err && (err.message || err));
@@ -704,7 +726,9 @@ define(function (require, exports, module) {
serverId: client.serverId,
command: config.command,
args: config.args || ["--stdio"],
- rootUri: rootUri
+ rootUri: rootUri,
+ workspaceConfiguration: config.workspaceConfiguration,
+ suppressStderrPattern: config.suppressStderrPattern
});
const initResult = await conn.execPeer("sendRequest", {
@@ -805,8 +829,22 @@ define(function (require, exports, module) {
* @param {boolean} [config.completionSnippetSupport] - advertise snippet support in the client
* completion capability. Only for servers that refuse to offer completion without it
* (vscode-json-language-server); snippet placeholders are stripped on insert.
+ * @param {Object} [config.workspaceConfiguration] - settings tree served node-side to the
+ * server's workspace/configuration pulls, sections resolved by dotted path (e.g.
+ * pyrefly pulls section "python" despite our capabilities saying we have none, and
+ * treats the default null answer as "all diagnostics off").
* @param {function(Array):Array} [config.filterDiagnostics] - server-specific post-filter for
* published diagnostics
+ * @param {function(Editor):boolean} [config.shouldShowParameterHints] - per-position VETO for
+ * signature help: return false to reject the request - the popup is suppressed or
+ * dismissed there and deliberately does NOT fall through to lower-priority providers
+ * (a fallback answering would undo the veto). For "this document isn't mine, let
+ * others serve it" use documentFilter instead - that preserves fall-through. E.g.
+ * vtsls vetoes the parent call's hint inside nested callback bodies.
+ * @param {string} [config.suppressStderrPattern] - regex source (string, not RegExp - it
+ * crosses the node connector); stderr lines matching it are dropped from the live
+ * console log. Opt in for servers that narrate every request on stderr (pyrefly uses
+ * "^\\s*INFO\\b"); the full stderr is still kept node-side for crash reports.
* @return {Promise} the client, or null if it could not be started
*/
async function registerLanguageServer(config) {
diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js
index 02b97ea41d..9e17a446bf 100644
--- a/src/nls/root/strings.js
+++ b/src/nls/root/strings.js
@@ -1812,6 +1812,7 @@ define({
"CODE_INTEL_PANEL_DISMISS": "Dismiss",
// PHP language support (PHPSupport)
"PHP_INSTALL_TITLE": "PHP Code Intelligence",
+ "PHP_INSTALL_DIALOG_TITLE": "Install PHP Code Intelligence",
"PHP_INSTALL_MESSAGE": "Install advanced PHP code intelligence?",
"PHP_INSTALL_ENABLE": "Install",
"PHP_INSTALL_NOT_NOW": "Not Now",
@@ -1824,12 +1825,41 @@ define({
"PHP_BENEFIT_ERRORS_SUB": "mistakes flagged as you code",
"PHP_BENEFIT_NAV": "Navigation",
"PHP_BENEFIT_NAV_SUB": "go to definition and find usages",
+ "PHP_INSTALL_DIALOG_TEXT": "Advanced completions, error checking, docs and more for PHP.",
+ "PHP_INSTALL_DIALOG_SIZE": "9 MB download",
+ "PHP_INSTALL_LATER_INFO": "You can always install later — the install bar appears whenever a PHP file is open.",
"PHP_PANEL_TEXT": "PHP code intelligence is off. Install the Intelephense language server (free, ~9 MB download) for completions, error checking, docs and go to definition.",
"PHP_INSTALLING": "Setting up PHP support — downloading the language server…",
"PHP_INSTALL_DONE": "PHP code intelligence is ready.",
"PHP_INSTALL_FAILED": "Could not set up PHP support: {0}. {APP_NAME} will retry when you open a PHP file again.",
"DESCRIPTION_PHP_CODE_INTELLIGENCE": "false to disable PHP code intelligence (Intelephense). Setting it back to true offers the language server download again.",
"DESCRIPTION_PHP_LICENSE_KEY": "Intelephense premium licence key (or an absolute path to a key file) enabling premium features like rename and code actions. Not needed if your licence is already in the standard global intelephense licence file.",
+ // Python language support (PythonSupport); LSP_INSTALL_POWERED_BY is shared with PHPSupport
+ "LSP_INSTALL_POWERED_BY": "Powered by",
+ "PYTHON_INSTALL_TITLE": "Python Code Intelligence",
+ "PYTHON_INSTALL_DIALOG_TITLE": "Install Python Code Intelligence",
+ "PYTHON_INSTALL_MESSAGE": "Install advanced Python code intelligence?",
+ "PYTHON_INSTALL_ENABLE": "Install",
+ "PYTHON_INSTALL_NOT_NOW": "Not Now",
+ "PYTHON_POWERED_BY_PYREFLY": "Powered by Pyrefly",
+ "PYTHON_BENEFIT_COMPLETIONS": "Completions",
+ "PYTHON_BENEFIT_COMPLETIONS_SUB": "smart suggestions as you type",
+ "PYTHON_BENEFIT_DOCS": "Docs on hover",
+ "PYTHON_BENEFIT_DOCS_SUB": "function help right in the editor",
+ "PYTHON_BENEFIT_ERRORS": "Error checking",
+ "PYTHON_BENEFIT_ERRORS_SUB": "mistakes flagged as you code",
+ "PYTHON_BENEFIT_NAV": "Navigation",
+ "PYTHON_BENEFIT_NAV_SUB": "go to definition and find usages",
+ "PYTHON_BENEFIT_FORMAT": "Formatting",
+ "PYTHON_BENEFIT_FORMAT_SUB": "one-command code cleanup with Ruff",
+ "PYTHON_INSTALL_DIALOG_TEXT": "Advanced completions, error checking, docs and formatting for Python.",
+ "PYTHON_INSTALL_DIALOG_SIZE": "25 MB download",
+ "PYTHON_INSTALL_LATER_INFO": "You can always install later — the install bar appears whenever a Python file is open.",
+ "PYTHON_PANEL_TEXT": "Python code intelligence is off. Install the Pyrefly language server and Ruff formatter (free, ~25 MB download) for completions, error checking, docs, formatting and go to definition.",
+ "PYTHON_INSTALLING": "Setting up Python support — downloading the language tools…",
+ "PYTHON_INSTALL_DONE": "Python code intelligence is ready.",
+ "PYTHON_INSTALL_FAILED": "Could not set up Python support: {0}. {APP_NAME} will retry when you open a Python file again.",
+ "DESCRIPTION_PYTHON_CODE_INTELLIGENCE": "false to disable Python code intelligence (Pyrefly). Setting it back to true offers the language server download again.",
// JSON / package.json intelligence (JSONSupport)
"NPM_VULN_PROVIDER_NAME": "npm Security Advisories",
"NPM_VULN_MESSAGE": "{0}@{1} is vulnerable: {2} ({3} severity)",
diff --git a/src/styles/brackets.less b/src/styles/brackets.less
index 971b525b9d..d3027d69f3 100644
--- a/src/styles/brackets.less
+++ b/src/styles/brackets.less
@@ -2303,12 +2303,13 @@ a, img {
}
}
-// PHP install prompt bar (PHPSupport) - find-bar-style banner across the editor top.
-.modal-bar .php-install-bar {
+// Language-server install prompt bar (PHPSupport, PythonSupport) - find-bar-style banner
+// across the editor top.
+.modal-bar .lsp-install-bar {
display: flex;
align-items: center;
gap: 12px;
- .php-install-bar-text {
+ .lsp-install-bar-text {
flex: none; // hug the question so the (i) sits right after it
min-width: 0;
overflow: hidden;
@@ -2316,7 +2317,7 @@ a, img {
white-space: nowrap;
}
// sits right after the question; margin-right:auto pushes everything else to the right edge
- .php-install-bar-info {
+ .lsp-install-bar-info {
flex: none;
margin-left: -4px;
margin-right: auto;
@@ -2324,7 +2325,7 @@ a, img {
cursor: default;
&:hover { opacity: 0.9; }
}
- .php-intel-powered-by {
+ .lsp-install-bar-powered-by {
flex: none;
cursor: pointer;
white-space: nowrap;
@@ -2335,6 +2336,35 @@ a, img {
}
}
+// First-time language-server install dialog (PHPSupport, PythonSupport): terse one-liner with an
+// (i) whose hover card lists the benefits, plus a muted "install later" note.
+.lsp-install-dialog-info {
+ opacity: 0.5;
+ cursor: default;
+ &:hover { opacity: 0.9; }
+}
+.lsp-install-dialog-later {
+ opacity: 0.75;
+ margin-bottom: 0;
+}
+.lsp-install-dialog-credit {
+ margin: 14px 0 0;
+ font-size: 12px;
+ span { opacity: 0.6; }
+ a { cursor: pointer; }
+}
+// download size in the footer, left of the action buttons - read at the moment of decision
+.modal-footer .lsp-install-dialog-size {
+ float: left;
+ opacity: 0.65;
+ font-size: 12px;
+ line-height: 30px; // optically centers against the .btn row
+ i {
+ font-size: 11px;
+ margin-right: 3px;
+ }
+}
+
// On bright colored toast surfaces (e.g. the PHP install prompt on INFO blue) the link-blue
// action text is invisible - use high-contrast white bold links there, both themes.
.notification-popup-container.style-info .ts-code-intel-action,
@@ -4308,6 +4338,11 @@ label input {
gap: 5px 10px;
font-size: 13px;
}
+ // Rows directly under the title (no .ph-tip-sub between): without this the title hugs the
+ // first row tighter than the rows' own visual rhythm and the card looks top-cramped.
+ .ph-tip-title + .ph-tip-rows {
+ margin-top: 6px;
+ }
.ph-tip-term {
font-weight: @font-weight-semibold;
white-space: nowrap;
diff --git a/src/utils/NodeUtils.js b/src/utils/NodeUtils.js
index 38e8c5325b..d22a788ea6 100644
--- a/src/utils/NodeUtils.js
+++ b/src/utils/NodeUtils.js
@@ -128,6 +128,110 @@ define(function (require, exports, module) {
return utilsConnector.execPeer("_npmInstallInFolder", {moduleNativeDir});
}
+ const DOWNLOAD_PROGRESS_EVENT = "downloadProgress";
+ let _downloadCounter = 0;
+
+ /**
+ * Downloads a URL to a file on disk, fully node-side (native fetch, streamed to disk).
+ * When an expected sha256 is given, a mismatch deletes the file and rejects - a resolved
+ * promise means the file holds exactly the pinned bytes.
+ * This is only available in the native app.
+ *
+ * @param {string} url - download URL (redirects followed)
+ * @param {string} destFile - platform path to write (parent directories created)
+ * @param {Object} [options]
+ * @param {string} [options.sha256] - expected hex digest of the downloaded bytes
+ * @param {function(number, number)} [options.progress] - called with (transferredBytes,
+ * totalBytes) as the download advances; totalBytes is 0 if the server sent no length
+ * @return {Promise}
+ */
+ async function downloadFile(url, destFile, options) {
+ if(!Phoenix.isNativeApp) {
+ throw new Error("downloadFile not available in browser");
+ }
+ const progress = options && options.progress;
+ _downloadCounter++;
+ const eventName = DOWNLOAD_PROGRESS_EVENT + ".dl" + _downloadCounter;
+ if(progress) {
+ utilsConnector.on(eventName, function (_evt, data) {
+ if(data && data.url === url) {
+ progress(data.transferred, data.total);
+ }
+ });
+ }
+ try {
+ return await utilsConnector.execPeer("downloadFile", {
+ url: url,
+ destFile: destFile,
+ sha256: (options && options.sha256) || undefined
+ });
+ } finally {
+ if(progress) {
+ utilsConnector.off(eventName);
+ }
+ }
+ }
+
+ /**
+ * Extracts a zip file into a directory node-side (stdlib only, no browser JSZip; creates the
+ * directory if missing, restores unix executable bits recorded in the archive). Python wheels
+ * are plain zips, so this installs those too.
+ * This is only available in the native app.
+ *
+ * @param {string} zipPath - platform path of the zip file
+ * @param {string} destDir - platform path of the directory to extract into
+ * @return {Promise}
+ */
+ async function extractZipFile(zipPath, destDir) {
+ if(!Phoenix.isNativeApp) {
+ throw new Error("extractZipFile not available in browser");
+ }
+ return utilsConnector.execPeer("extractZipFile", {zipPath, destDir});
+ }
+
+ /**
+ * Marks a file as executable (chmod 755); no-op on Windows. For binaries whose archives did
+ * not carry unix mode bits.
+ * This is only available in the native app.
+ *
+ * @param {string} filePath - platform path of the file
+ * @return {Promise}
+ */
+ async function setExecutableBits(filePath) {
+ if(!Phoenix.isNativeApp) {
+ throw new Error("setExecutableBits not available in browser");
+ }
+ return utilsConnector.execPeer("setExecutableBits", {filePath});
+ }
+
+ /**
+ * Runs an executable with the given args, feeding it text on stdin and capturing its output -
+ * a one-shot filter-style invocation (e.g. `ruff format -` for the Python beautifier). No
+ * shell is involved. Resolves with the exit code rather than rejecting on non-zero, so
+ * callers can read stderr for the reason.
+ * This is only available in the native app.
+ *
+ * @param {string} command - platform path of the executable (or a PATH command name)
+ * @param {string[]} [args]
+ * @param {Object} [options]
+ * @param {string} [options.stdinText] - written to the process's stdin, then closed
+ * @param {string} [options.cwd] - working directory
+ * @param {number} [options.timeoutMs] - kill the process and reject after this long
+ * @return {Promise<{code: number, stdout: string, stderr: string}>}
+ */
+ async function execFileWithInput(command, args, options) {
+ if(!Phoenix.isNativeApp) {
+ throw new Error("execFileWithInput not available in browser");
+ }
+ return utilsConnector.execPeer("execFileWithInput", {
+ command: command,
+ args: args || [],
+ stdinText: options && options.stdinText,
+ cwd: options && options.cwd,
+ timeoutMs: options && options.timeoutMs
+ });
+ }
+
/**
* Gets an environment variable's value
* This is only available in the native app
@@ -334,6 +438,10 @@ define(function (require, exports, module) {
// private apis
exports._loadNodeExtensionModule = _loadNodeExtensionModule;
exports._npmInstallInFolder = _npmInstallInFolder;
+ exports.downloadFile = downloadFile;
+ exports.extractZipFile = extractZipFile;
+ exports.setExecutableBits = setExecutableBits;
+ exports.execFileWithInput = execFileWithInput;
// public apis
exports.fetchURLText = fetchURLText;
diff --git a/test/spec/Extn-JSONSupport-integ-test.js b/test/spec/Extn-JSONSupport-integ-test.js
index 749fc805b3..b831d0d62a 100644
--- a/test/spec/Extn-JSONSupport-integ-test.js
+++ b/test/spec/Extn-JSONSupport-integ-test.js
@@ -88,6 +88,71 @@ define(function (require, exports, module) {
JsonLsp._setTestSchemaAssociations(null);
}, 45000);
+ it("should consume the autoclosed closing quote when inserting a key completion", async function () {
+ // Regression: typing `"` autocloses to `"|"`; the server's completion textEdit range
+ // covers BOTH quotes (its end is past the cursor) and newText is a full `"key": {$1}`
+ // snippet. insertHint used to clamp the replacement end to the cursor, leaving the
+ // closing quote dangling -> `"key": {}"` (invalid JSON).
+ const JsonLsp = testWindow.require("extensionsIntegrated/JSONSupport/JsonLsp");
+ const EditorManager = testWindow.brackets.test.EditorManager;
+ JsonLsp._setTestSchemaAssociations([{
+ fileMatch: ["appsettings.json"],
+ schema: {
+ type: "object",
+ properties: {
+ serverConfig: { type: "object" }
+ }
+ }
+ }]);
+ await _openFile("appsettings.json");
+ const editor = EditorManager.getActiveEditor();
+ editor.document.setText('{\n ""\n}');
+ editor.setCursorPos(1, 3); // between the quotes: ` "|"`
+
+ // Drive the LSP hint provider directly (the popup UI needs OS window focus that the
+ // embedded test window may not have). Poll fresh requests until the schema completion
+ // shows up - the pushed schema association may take a moment to apply server-side.
+ await awaitsFor(function () {
+ return !!JsonLsp._getClient();
+ }, "JSON language client registered", 30000);
+ const client = JsonLsp._getClient();
+ let $targetHint = null,
+ requestInFlight = false;
+ await awaitsFor(function () {
+ if ($targetHint) {
+ return true;
+ }
+ if (!requestInFlight) {
+ requestInFlight = true;
+ client._completionCache = null; // context key is stable here - don't reuse stale lists
+ client.codeHints.getHints(null).done(function (result) {
+ requestInFlight = false;
+ const hints = (result && result.hints) || [];
+ $targetHint = hints.find(function ($hint) {
+ const token = $hint.data("token");
+ return token && token.label === "serverConfig";
+ }) || null;
+ }).fail(function () {
+ requestInFlight = false;
+ });
+ }
+ return false;
+ }, "serverConfig schema completion from the JSON server", 30000);
+
+ client.codeHints.insertHint($targetHint); // what selecting the hint with Enter does
+ await awaitsFor(function () {
+ return editor.document.getLine(1).indexOf("serverConfig") !== -1;
+ }, "completion inserted into the document", 30000);
+
+ expect(editor.document.getLine(1)).toBe(' "serverConfig": {}');
+ // the whole document must stay valid JSON - no dangling quote after the insert
+ expect(function () { JSON.parse(editor.document.getText()); }).not.toThrow();
+
+ JsonLsp._setTestSchemaAssociations(null);
+ await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE,
+ { fullPath: testFolder + "/appsettings.json", _forceClose: true }));
+ }, 45000);
+
it("should squiggle vulnerable dependencies using advisory data", async function () {
const NpmRegistry = testWindow.require("extensionsIntegrated/JSONSupport/NpmRegistry");
NpmRegistry._setFetcherForTests(function (url, options) {
diff --git a/test/spec/PythonSupport-test-files/error.py b/test/spec/PythonSupport-test-files/error.py
new file mode 100644
index 0000000000..9d61b8f0d9
--- /dev/null
+++ b/test/spec/PythonSupport-test-files/error.py
@@ -0,0 +1,8 @@
+"""Fixture with deliberate type errors for the Python LSP integration tests."""
+
+count: int = "not a number"
+
+print(undefined_name_here)
+
+value = 42
+print(valu)
diff --git a/test/spec/PythonSupport-test-files/funcs.py b/test/spec/PythonSupport-test-files/funcs.py
new file mode 100644
index 0000000000..f49bada820
--- /dev/null
+++ b/test/spec/PythonSupport-test-files/funcs.py
@@ -0,0 +1,9 @@
+"""Fixture with a documented function for hover / jump-to-definition / completion tests."""
+
+
+def compute_total(prices, tax_rate):
+ """Compute the total price of all items including tax."""
+ return sum(prices) * (1 + tax_rate)
+
+
+print(compute_total([1.5, 2.5], 0.2))