Skip to content

Commit 04a3fcf

Browse files
committed
Improve UX around educational notes
The Swift compiler contains educational notes to further describe diagnostics [1]. These educational notes are documented in markdown files that are contained within the toolchain. Sourcekit LSP includes a link to the local markdown file when returning diagnostics that have an associated educational note (as part of the diagnostic code). The default behaviour in VSCode is to present these as a link in the diagnostic hover, and open the editor to the markdown file when the link is clicked. This PR updates the behaviour for educational notes to instead open the link using the markdown preview, which shows nicely rendered content. It also updates the link in the hover to show "More Information" instead of the code. Issue: swiftlang#1395 [1] https://github.com/swiftlang/swift/tree/main/userdocs/diagnostics
1 parent ff3ca28 commit 04a3fcf

File tree

5 files changed

+162
-0
lines changed

5 files changed

+162
-0
lines changed

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@
111111
"title": "Create New Project...",
112112
"category": "Swift"
113113
},
114+
{
115+
"command": "swift.openEducationalNote",
116+
"title": "Open Educational Note...",
117+
"category": "Swift"
118+
},
114119
{
115120
"command": "swift.newFile",
116121
"title": "Create New Swift File...",

src/DiagnosticsManager.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,24 @@ export class DiagnosticsManager implements vscode.Disposable {
142142
d1 => isSwiftc(d1) && !!removedDiagnostics.find(d2 => isEqual(d1, d2))
143143
);
144144
}
145+
146+
for (const diagnostic of newDiagnostics) {
147+
if (
148+
diagnostic.code &&
149+
typeof diagnostic.code !== "string" &&
150+
typeof diagnostic.code !== "number"
151+
) {
152+
if (diagnostic.code.target.fsPath.endsWith(".md")) {
153+
diagnostic.code = {
154+
target: vscode.Uri.parse(
155+
`command:swift.openEducationalNote?${encodeURIComponent(JSON.stringify(diagnostic.code.target))}`
156+
),
157+
value: "More Information...",
158+
};
159+
}
160+
}
161+
}
162+
145163
// Append the new diagnostics we just received
146164
allDiagnostics.push(...newDiagnostics);
147165
this.allDiagnostics.set(uri.fsPath, allDiagnostics);

src/commands.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { openInExternalEditor } from "./commands/openInExternalEditor";
3131
import { switchPlatform } from "./commands/switchPlatform";
3232
import { insertFunctionComment } from "./commands/insertFunctionComment";
3333
import { createNewProject } from "./commands/createNewProject";
34+
import { openEducationalNote } from "./commands/openEducationalNote";
3435
import { openPackage } from "./commands/openPackage";
3536
import { resolveDependencies } from "./commands/dependencies/resolve";
3637
import { resetPackage } from "./commands/resetPackage";
@@ -199,6 +200,9 @@ export function register(ctx: WorkspaceContext): vscode.Disposable[] {
199200
vscode.commands.registerCommand(Commands.SHOW_NESTED_DEPENDENCIES_LIST, () =>
200201
updateDependenciesViewList(ctx, false)
201202
),
203+
vscode.commands.registerCommand("swift.openEducationalNote", uri =>
204+
openEducationalNote(uri)
205+
),
202206
];
203207
}
204208

src/commands/openEducationalNote.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the VS Code Swift open source project
4+
//
5+
// Copyright (c) 2021-2025 the VS Code Swift project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of VS Code Swift project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import * as vscode from "vscode";
16+
17+
/**
18+
* Handle the user requesting to show an educational note.
19+
*
20+
* The default behaviour is to open it in a markdown preview to the side.
21+
*/
22+
export async function openEducationalNote(markdownFile: vscode.Uri | undefined): Promise<void> {
23+
await vscode.commands.executeCommand("markdown.showPreviewToSide", markdownFile);
24+
}

test/integration-tests/DiagnosticsManager.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { FolderContext } from "../../src/FolderContext";
2424
import { Version } from "../../src/utilities/version";
2525
import { Workbench } from "../../src/utilities/commands";
2626
import { activateExtensionForSuite, folderInRootWorkspace } from "./utilities/testutilities";
27+
import { expect } from "chai";
2728

2829
const isEqual = (d1: vscode.Diagnostic, d2: vscode.Diagnostic) => {
2930
return (
@@ -555,6 +556,116 @@ suite("DiagnosticsManager Test Suite", async function () {
555556
await swiftConfig.update("diagnosticsCollection", undefined);
556557
});
557558

559+
suite("markdownLinks", () => {
560+
let diagnostic: vscode.Diagnostic;
561+
562+
setup(async () => {
563+
workspaceContext.diagnostics.clear();
564+
diagnostic = new vscode.Diagnostic(
565+
new vscode.Range(new vscode.Position(1, 8), new vscode.Position(1, 8)), // Note swiftc provides empty range
566+
"Cannot assign to value: 'bar' is a 'let' constant",
567+
vscode.DiagnosticSeverity.Error
568+
);
569+
diagnostic.source = "SourceKit";
570+
});
571+
572+
test("ignore strings", async () => {
573+
diagnostic.code = "string";
574+
575+
// Now provide identical SourceKit diagnostic
576+
workspaceContext.diagnostics.handleDiagnostics(
577+
mainUri,
578+
DiagnosticsManager.isSourcekit,
579+
[diagnostic]
580+
);
581+
582+
// check diagnostic hasn't changed
583+
assertHasDiagnostic(mainUri, diagnostic);
584+
585+
const diagnostics = vscode.languages.getDiagnostics(mainUri);
586+
const matchingDiagnostic = diagnostics.find(findDiagnostic(diagnostic));
587+
588+
expect(matchingDiagnostic).to.have.property("code", "string");
589+
});
590+
591+
test("ignore numbers", async () => {
592+
diagnostic.code = 1;
593+
594+
// Now provide identical SourceKit diagnostic
595+
workspaceContext.diagnostics.handleDiagnostics(
596+
mainUri,
597+
DiagnosticsManager.isSourcekit,
598+
[diagnostic]
599+
);
600+
601+
// check diagnostic hasn't changed
602+
assertHasDiagnostic(mainUri, diagnostic);
603+
604+
const diagnostics = vscode.languages.getDiagnostics(mainUri);
605+
const matchingDiagnostic = diagnostics.find(findDiagnostic(diagnostic));
606+
607+
expect(matchingDiagnostic).to.have.property("code", 1);
608+
});
609+
610+
test("target without markdown link", async () => {
611+
const diagnosticCode = {
612+
value: "string",
613+
target: vscode.Uri.file("/some/path/md/readme.txt"),
614+
};
615+
diagnostic.code = diagnosticCode;
616+
617+
// Now provide identical SourceKit diagnostic
618+
workspaceContext.diagnostics.handleDiagnostics(
619+
mainUri,
620+
DiagnosticsManager.isSourcekit,
621+
[diagnostic]
622+
);
623+
624+
// check diagnostic hasn't changed
625+
assertHasDiagnostic(mainUri, diagnostic);
626+
627+
const diagnostics = vscode.languages.getDiagnostics(mainUri);
628+
const matchingDiagnostic = diagnostics.find(findDiagnostic(diagnostic));
629+
630+
expect(matchingDiagnostic).to.have.property("code", diagnostic.code);
631+
});
632+
633+
test("target with markdown link", async () => {
634+
const pathToMd = "/some/path/md/readme.md";
635+
diagnostic.code = {
636+
value: "string",
637+
target: vscode.Uri.file(pathToMd),
638+
};
639+
640+
workspaceContext.diagnostics.handleDiagnostics(
641+
mainUri,
642+
DiagnosticsManager.isSourcekit,
643+
[diagnostic]
644+
);
645+
646+
const diagnostics = vscode.languages.getDiagnostics(mainUri);
647+
const matchingDiagnostic = diagnostics.find(findDiagnostic(diagnostic));
648+
649+
expect(matchingDiagnostic).to.have.property("code");
650+
expect(matchingDiagnostic?.code).to.have.property("value", "More Information...");
651+
652+
if (
653+
matchingDiagnostic &&
654+
matchingDiagnostic.code &&
655+
typeof matchingDiagnostic.code !== "string" &&
656+
typeof matchingDiagnostic.code !== "number"
657+
) {
658+
expect(matchingDiagnostic.code.target.scheme).to.equal("command");
659+
expect(matchingDiagnostic.code.target.path).to.equal(
660+
"swift.openEducationalNote"
661+
);
662+
expect(matchingDiagnostic.code.target.query).to.contain(pathToMd);
663+
} else {
664+
assert.fail("Diagnostic target not replaced with markdown command");
665+
}
666+
});
667+
});
668+
558669
suite("keepAll", () => {
559670
setup(async () => {
560671
await swiftConfig.update("diagnosticsCollection", "keepAll");

0 commit comments

Comments
 (0)