Skip to content

Commit 1d7bb2a

Browse files
committed
Add diagnostics for DocC Symbol Links in DocumentationLanguageService
1 parent c1fa8c8 commit 1d7bb2a

3 files changed

Lines changed: 296 additions & 45 deletions

File tree

Sources/DocumentationLanguageService/DocumentationLanguageService.swift

Lines changed: 241 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ package actor DocumentationLanguageService: LanguageService, Sendable {
3939

4040
let workspace: Workspace
4141

42+
/// In-flight debounce tasks for `publishSymbolLinkDiagnostics`, keyed by document.
43+
/// Cancel-and-replace on every edit so we don't run diagnostics on every keystroke, and so an
44+
/// older, now-stale run can never race a newer one and publish outdated results.
45+
private var inFlightPublishDiagnosticsTasks: [DocumentURI: Task<Void, Never>] = [:]
46+
4247
package static var experimentalCapabilities: [String: LSPAny] {
4348
return [
4449
DoccDocumentationRequest.method: ["version": 1],
@@ -76,11 +81,13 @@ package actor DocumentationLanguageService: LanguageService, Sendable {
7681
_ notification: DidOpenTextDocumentNotification,
7782
snapshot: DocumentSnapshot
7883
) async {
79-
// The DocumentationLanguageService does not do anything with document events
84+
schedulePublishSymbolLinkDiagnostics(for: snapshot.uri)
8085
}
8186

8287
package func closeDocument(_ notification: DidCloseTextDocumentNotification) async {
83-
// The DocumentationLanguageService does not do anything with document events
88+
cancelInFlightPublishDiagnosticsTask(for: notification.textDocument.uri)
89+
inFlightPublishDiagnosticsTasks[notification.textDocument.uri] = nil
90+
await sourceKitLSPServer?.clearDiagnostics(for: notification.textDocument.uri, from: .documentation)
8491
}
8592

8693
package func reopenDocument(_ notification: ReopenTextDocumentNotification) async {
@@ -105,7 +112,7 @@ package actor DocumentationLanguageService: LanguageService, Sendable {
105112
postEditSnapshot: DocumentSnapshot,
106113
edits: [SwiftSyntax.SourceEdit]
107114
) async {
108-
// The DocumentationLanguageService does not do anything with document events
115+
schedulePublishSymbolLinkDiagnostics(for: postEditSnapshot.uri)
109116
}
110117

111118
package func definition(_ req: DefinitionRequest) async throws -> LocationsOrLocationLinksResponse? {
@@ -136,8 +143,7 @@ package actor DocumentationLanguageService: LanguageService, Sendable {
136143
return .locations([targetLocation])
137144
}
138145

139-
/// Walks the Markdown/DocC AST looking for symbol link
140-
/// that contains a given source position.
146+
/// Walks the Markdown/DocC AST looking for a symbol link that contains a given source position.
141147
private struct SymbolLocator: MarkupWalker {
142148
let target: Markdown.SourceLocation
143149
var found: String?
@@ -151,6 +157,10 @@ package actor DocumentationLanguageService: LanguageService, Sendable {
151157
return range.lowerBound <= target && target < range.upperBound
152158
}
153159

160+
/// Resolves a click inside a multi-component link like ``Sloth/energy`` to just the component under the cursor and
161+
/// everything before it: clicking `Sloth` goes to `Sloth`;
162+
/// clicking `energy` goes to `Sloth/energy`. Without this, any click inside the link — including on
163+
/// the first component — would always navigate to the full, most-nested destination.
154164
mutating func visitSymbolLink(_ symbolLink: SymbolLink) {
155165
guard
156166
found == nil,
@@ -160,6 +170,7 @@ package actor DocumentationLanguageService: LanguageService, Sendable {
160170
else {
161171
return
162172
}
173+
163174
let relativeColumn = target.column - range.lowerBound.column - 3
164175
let components = destination.split(separator: "/")
165176
var currentLength = 0
@@ -223,6 +234,20 @@ package actor DocumentationLanguageService: LanguageService, Sendable {
223234
return line.utf8.distance(from: line.startIndex, to: stringIndex)
224235
}
225236

237+
/// The inverse of `utf8Offset(inLine:forUTF16Offset:)`. Diagnostics need this because we go the
238+
/// other direction from go-to-definition: swift-markdown hands us a UTF-8 column for a symbol
239+
/// link it found, and we need to turn that into the UTF-16 column LSP `Position` expects.
240+
private func utf16Offset(inLine line: String, forUTF8Offset utf8Offset: Int) -> Int {
241+
let utf8View = line.utf8
242+
guard
243+
let utf8Index = utf8View.index(utf8View.startIndex, offsetBy: utf8Offset, limitedBy: utf8View.endIndex),
244+
let stringIndex = utf8Index.samePosition(in: line)
245+
else {
246+
return line.utf16.count
247+
}
248+
return line.utf16.distance(from: line.startIndex, to: stringIndex)
249+
}
250+
226251
private func docCommentGroups(
227252
in trivia: Trivia,
228253
tokenStart: AbsolutePosition,
@@ -266,8 +291,6 @@ package actor DocumentationLanguageService: LanguageService, Sendable {
266291
}
267292

268293
/// Strips `///` and any leading whitespace from each line comment piece.
269-
/// Returns each line alongside how many UTF-16 units were stripped off its front,
270-
/// so cursor columns can be remapped correctly.
271294
private func stripLineCommentDelimiters(
272295
_ lines: [(text: String, startLine: Int, startColumn: Int)]
273296
) -> [(text: String, strippedPrefixCount: Int)] {
@@ -287,8 +310,6 @@ package actor DocumentationLanguageService: LanguageService, Sendable {
287310
}
288311
}
289312
/// Strips `/**`, `*/`, and per-line leading `*`/whitespace from a block comment.
290-
/// Returns each line alongside how many UTF-16 units were stripped off its front,
291-
/// so that cursor columns can be remapped correctly.
292313
private func stripBlockCommentDelimiters(_ text: String) -> [(text: String, strippedPrefixCount: Int)] {
293314
let lines = text.components(separatedBy: "\n")
294315
return lines.enumerated().map { index, rawLine in
@@ -406,10 +427,20 @@ package actor DocumentationLanguageService: LanguageService, Sendable {
406427
for symbolPath: String,
407428
currentDocumentURI: DocumentURI
408429
) async -> Location? {
409-
// Split the full path into components e.g. "Sloth/energy" -> ["Sloth", "energy"]
410-
let pathComponents = symbolPath.components(separatedBy: "/")
411-
let symbolName = pathComponents.last ?? symbolPath
430+
guard let symbolGraph = await loadSymbolGraph(for: currentDocumentURI),
431+
let symbol = matchingSymbol(for: symbolPath, in: symbolGraph),
432+
let location = symbol.location,
433+
let targetURI = try? DocumentURI(string: location.uri)
434+
else {
435+
return nil
436+
}
412437

438+
let destinationPosition = Position(line: location.position.line, utf16index: location.position.character)
439+
return Location(uri: targetURI, range: destinationPosition..<destinationPosition)
440+
}
441+
442+
/// Loads and decodes the symbol graph for `currentDocumentURI`'s module.
443+
private func loadSymbolGraph(for currentDocumentURI: DocumentURI) async -> SymbolGraph? {
413444
guard let targetID = await self.workspace.buildServerManager.targets(for: currentDocumentURI).first,
414445
let moduleName = await self.workspace.buildServerManager.moduleName(for: targetID)
415446
else {
@@ -422,39 +453,215 @@ package actor DocumentationLanguageService: LanguageService, Sendable {
422453
.appendingPathComponent(".build/symbol-graphs")
423454
.appendingPathComponent("\(moduleName).symbols.json")
424455

425-
guard let data = try? Data(contentsOf: targetGraphURL),
426-
let symbolGraph = try? JSONDecoder().decode(SymbolGraph.self, from: data)
427-
else {
456+
guard let data = try? Data(contentsOf: targetGraphURL) else {
428457
return nil
429458
}
459+
return try? JSONDecoder().decode(SymbolGraph.self, from: data)
460+
}
430461

431-
for symbol in symbolGraph.symbols {
432-
guard symbol.names.title == symbolName else {
433-
continue
462+
/// Finds the symbol graph entry matching `symbolPath` (e.g. "Sloth/energy"), if any.
463+
private func matchingSymbol(for symbolPath: String, in symbolGraph: SymbolGraph) -> SymbolGraph.Symbol? {
464+
let pathComponents = symbolPath.components(separatedBy: "/")
465+
let symbolName = pathComponents.last ?? symbolPath
466+
467+
return symbolGraph.symbols.first { symbol in
468+
guard symbol.names.title == symbolName else { return false }
469+
guard pathComponents.count > 1 else { return true }
470+
guard let symbolPathComponents = symbol.pathComponents else { return false }
471+
// The reference path must match the tail of the symbol's pathComponents, so any-depth
472+
return Array(symbolPathComponents.suffix(pathComponents.count)) == pathComponents
473+
}
474+
}
475+
476+
// MARK: - Symbol link diagnostics
477+
478+
private struct SymbolLinkReference {
479+
let symbol: String
480+
let range: Markdown.SourceRange
481+
}
482+
483+
/// Walks a Markdown/DocC AST collecting every symbol link and its range.
484+
private struct SymbolLinkCollector: MarkupWalker {
485+
var found: [SymbolLinkReference] = []
486+
487+
mutating func visitSymbolLink(_ symbolLink: SymbolLink) {
488+
if let range = symbolLink.range, let destination = symbolLink.destination {
489+
found.append(SymbolLinkReference(symbol: destination, range: range))
434490
}
491+
}
492+
}
435493

436-
// Validate the full path using pathComponents from the symbol graph
437-
if pathComponents.count > 1 {
438-
guard let symbolPathComponents = symbol.pathComponents else {
439-
continue
440-
}
441-
// The reference path must match the tail of the symbol's pathComponents
442-
let tail = symbolPathComponents.suffix(pathComponents.count)
443-
guard Array(tail) == pathComponents else {
444-
continue
445-
}
494+
/// Maps a Markdown source location to an LSP position in the original source file.
495+
private struct MappedLine {
496+
let text: String
497+
let absoluteLine: Int
498+
let absoluteColumnBase: Int
499+
let strippedPrefixCount: Int
500+
}
501+
502+
private func mappedLines(for group: DocTriviaGroup) -> [MappedLine] {
503+
switch group {
504+
case .lines(let lines):
505+
let strippedLines = stripLineCommentDelimiters(lines)
506+
return zip(lines, strippedLines).map { raw, stripped in
507+
MappedLine(
508+
text: stripped.text,
509+
absoluteLine: raw.startLine,
510+
absoluteColumnBase: raw.startColumn,
511+
strippedPrefixCount: stripped.strippedPrefixCount
512+
)
513+
}
514+
515+
case .block(let text, let startLine, let startColumn):
516+
let strippedLines = stripBlockCommentDelimiters(text)
517+
return strippedLines.enumerated().map { index, stripped in
518+
MappedLine(
519+
text: stripped.text,
520+
absoluteLine: startLine + index,
521+
absoluteColumnBase: index == 0 ? startColumn : 0,
522+
strippedPrefixCount: stripped.strippedPrefixCount
523+
)
446524
}
525+
}
526+
}
527+
528+
private func position(for location: Markdown.SourceLocation, in lines: [MappedLine]) -> Position? {
529+
let index = location.line - 1
530+
guard lines.indices.contains(index) else { return nil }
531+
let line = lines[index]
532+
let utf16OffsetInStripped = utf16Offset(inLine: line.text, forUTF8Offset: location.column - 1)
533+
let absoluteUTF16 = line.absoluteColumnBase + line.strippedPrefixCount + utf16OffsetInStripped
534+
return Position(line: line.absoluteLine, utf16index: absoluteUTF16)
535+
}
447536

448-
guard let location = symbol.location,
449-
let targetURI = try? DocumentURI(string: location.uri)
537+
/// Collects every symbol link inside one doc-comment trivia group
538+
private func symbolLinks(in group: DocTriviaGroup) -> [(symbol: String, range: Range<Position>)] {
539+
let lines = mappedLines(for: group)
540+
let combinedText = lines.map(\.text).joined(separator: "\n")
541+
542+
let document = Markdown.Document(parsing: combinedText, options: [.parseSymbolLinks])
543+
var collector = SymbolLinkCollector()
544+
collector.visit(document)
545+
546+
return collector.found.compactMap { link in
547+
guard
548+
let start = position(for: link.range.lowerBound, in: lines),
549+
let end = position(for: link.range.upperBound, in: lines)
450550
else {
451551
return nil
452552
}
553+
return (link.symbol, start..<end)
554+
}
555+
}
556+
557+
private func collectSymbolLinksInSwiftDocComments(
558+
_ snapshot: DocumentSnapshot
559+
) -> [(symbol: String, range: Range<Position>)] {
560+
let sourceFile = SwiftParser.Parser.parse(source: snapshot.text)
561+
var results: [(symbol: String, range: Range<Position>)] = []
453562

454-
let destinationPosition = Position(line: location.position.line, utf16index: location.position.character)
455-
let destinationRange = Range(uncheckedBounds: (lower: destinationPosition, upper: destinationPosition))
456-
return Location(uri: targetURI, range: destinationRange)
563+
for token in sourceFile.tokens(viewMode: .sourceAccurate) {
564+
let groups = docCommentGroups(in: token.leadingTrivia, tokenStart: token.position, snapshot: snapshot)
565+
for group in groups {
566+
results.append(contentsOf: symbolLinks(in: group))
567+
}
457568
}
458-
return nil
569+
return results
570+
}
571+
572+
private func collectSymbolLinksInMarkdown(_ text: String) -> [(symbol: String, range: Range<Position>)] {
573+
let lines = text.components(separatedBy: "\n")
574+
let document = Markdown.Document(parsing: text, options: [.parseSymbolLinks, .parseBlockDirectives])
575+
var collector = SymbolLinkCollector()
576+
collector.visit(document)
577+
578+
func position(for location: Markdown.SourceLocation) -> Position? {
579+
let lineIndex = location.line - 1
580+
guard lines.indices.contains(lineIndex) else { return nil }
581+
let utf16Col = utf16Offset(inLine: lines[lineIndex], forUTF8Offset: location.column - 1)
582+
return Position(line: lineIndex, utf16index: utf16Col)
583+
}
584+
585+
return collector.found.compactMap { link in
586+
guard let start = position(for: link.range.lowerBound), let end = position(for: link.range.upperBound) else {
587+
return nil
588+
}
589+
return (link.symbol, start..<end)
590+
}
591+
}
592+
593+
private func collectSymbolLinks(in snapshot: DocumentSnapshot) -> [(symbol: String, range: Range<Position>)] {
594+
switch snapshot.language {
595+
case .swift:
596+
return collectSymbolLinksInSwiftDocComments(snapshot)
597+
case .markdown, .tutorial:
598+
return collectSymbolLinksInMarkdown(snapshot.text)
599+
default:
600+
return []
601+
}
602+
}
603+
604+
private func cancelInFlightPublishDiagnosticsTask(for uri: DocumentURI) {
605+
inFlightPublishDiagnosticsTasks[uri]?.cancel()
606+
}
607+
608+
private func schedulePublishSymbolLinkDiagnostics(for uri: DocumentURI) {
609+
cancelInFlightPublishDiagnosticsTask(for: uri)
610+
inFlightPublishDiagnosticsTasks[uri] = Task(priority: .medium) { [weak self] in
611+
do {
612+
try await Task.sleep(for: .milliseconds(500))
613+
} catch {
614+
return // cancelled by a newer edit
615+
}
616+
await self?.publishSymbolLinkDiagnostics(for: uri)
617+
}
618+
}
619+
620+
private func symbolLinkDiagnostics(for snapshot: DocumentSnapshot) async -> [Diagnostic]? {
621+
let symbolLinks = collectSymbolLinks(in: snapshot)
622+
guard let symbolGraph = await loadSymbolGraph(for: snapshot.uri) else {
623+
return nil
624+
}
625+
return symbolLinks.compactMap { link in
626+
guard matchingSymbol(for: link.symbol, in: symbolGraph) == nil else { return nil }
627+
return Diagnostic(
628+
range: link.range,
629+
severity: .error,
630+
source: "DocC",
631+
message: "No symbol link resolved for '\(link.symbol)'"
632+
)
633+
}
634+
}
635+
636+
private func publishSymbolLinkDiagnostics(for uri: DocumentURI) async {
637+
guard let sourceKitLSPServer else { return }
638+
guard let snapshot = try? self.documentManager.latestSnapshot(uri) else { return }
639+
640+
// Pull diagnostics are the first preference; only push if the client can't pull diagnostics for this document's language
641+
let clientSupportsPull =
642+
await sourceKitLSPServer.capabilityRegistry?.clientSupportsPullDiagnostics(for: snapshot.language) ?? false
643+
guard !clientSupportsPull else { return }
644+
645+
guard !Task.isCancelled else { return }
646+
guard let diagnostics = await symbolLinkDiagnostics(for: snapshot) else { return }
647+
guard !Task.isCancelled else { return }
648+
649+
await sourceKitLSPServer.publishDiagnostics(diagnostics, for: uri, from: .documentation)
650+
}
651+
652+
package func documentDiagnostic(_ req: DocumentDiagnosticsRequest) async throws -> DocumentDiagnosticReport {
653+
switch try? ReferenceDocumentURL(from: req.textDocument.uri) {
654+
case .generatedInterface:
655+
// Generated interfaces don't have diagnostics associated with them.
656+
return .full(RelatedFullDocumentDiagnosticReport(items: []))
657+
case .macroExpansion, nil: break
658+
}
659+
660+
guard let snapshot = try? self.documentManager.latestSnapshot(req.textDocument.uri) else {
661+
return .full(RelatedFullDocumentDiagnosticReport(items: []))
662+
}
663+
664+
let diagnostics = await symbolLinkDiagnostics(for: snapshot) ?? []
665+
return .full(RelatedFullDocumentDiagnosticReport(items: diagnostics))
459666
}
460667
}

0 commit comments

Comments
 (0)