@@ -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