diff --git a/Sources/DocumentationLanguageService/DocumentationLanguageService.swift b/Sources/DocumentationLanguageService/DocumentationLanguageService.swift index 4386e6bdb..106a539b6 100644 --- a/Sources/DocumentationLanguageService/DocumentationLanguageService.swift +++ b/Sources/DocumentationLanguageService/DocumentationLanguageService.swift @@ -18,6 +18,8 @@ package import SourceKitLSP import SwiftExtensions package import SwiftSyntax package import ToolchainRegistry +import SwiftDocC +import BuildServerIntegration package actor DocumentationLanguageService: LanguageService, Sendable { /// The ``SourceKitLSPServer`` instance that created this `DocumentationLanguageService`. @@ -107,4 +109,128 @@ package actor DocumentationLanguageService: LanguageService, Sendable { ) async { // The DocumentationLanguageService does not do anything with document events } + + package func hover(_ req: HoverRequest) async throws -> HoverResponse? { + guard let sourceKitLSPServer else { + throw ResponseError.unknown("Language server is shutting down") + } + + guard let snapshot = try? documentManager.latestSnapshot(req.textDocument.uri), + snapshot.language == .swift, + let workspace = await sourceKitLSPServer.workspaceForDocument(uri: req.textDocument.uri) else { + return nil + } + + guard let (symbolGraph, symbolUSR, overrideDocComments) = try? await sourceKitLSPServer.primaryLanguageService( + for: snapshot.uri, + snapshot.language, + in: workspace + ).symbolGraph(for: snapshot, at: req.position) else { + return nil + } + + var moduleName: String? = nil + var catalogURL: URL? = nil + if let target = await workspace.buildServerManager.canonicalTarget(for: req.textDocument.uri) { + moduleName = await workspace.buildServerManager.moduleName(for: target) + catalogURL = await workspace.buildServerManager.doccCatalog(for: target) + } + + guard let doccResponse = try? await documentationManager.renderDocCDocumentation( + symbolUSR: symbolUSR, + symbolGraph: symbolGraph, + overrideDocComments: overrideDocComments, + markupFile: nil, + moduleName: moduleName, + catalogURL: catalogURL + ) else { + return nil + } + + guard let renderNodeData = try? Data(doccResponse.renderNode.utf8), + let renderNode = try? JSONDecoder().decode(RenderNode.self, from: renderNodeData), + let markdown = renderNode.markdown else { + return nil + } + + return HoverResponse(contents: .markupContent(MarkupContent(kind: .markdown, value: markdown)), range: nil) + } } + +extension RenderNode { + fileprivate var markdown: String? { + var result = "" + + let sections = primaryContentSections + for section in sections { + if let declSection = section as? DeclarationsRenderSection { + for declaration in declSection.declarations { + let sourceText = declaration.tokens.map(\.text).joined() + result += "```swift\n\(sourceText)\n```\n" + } + } + } + + if let abstract = abstract { + let abstractMarkdown = abstract.map { $0.markdown }.joined() + if !abstractMarkdown.isEmpty { + result += "\(abstractMarkdown)\n\n" + } + } + + for section in sections { + if let contentSection = section as? ContentRenderSection { + for contentBlock in contentSection.content { + result += contentBlock.markdown + "\n" + } + } else if let parametersSection = section as? ParametersRenderSection { + result += "## Parameters\n" + for param in parametersSection.parameters { + result += "- `\(param.name)`: " + let paramContent = param.content.compactMap { $0.markdown.trimmingCharacters(in: .whitespacesAndNewlines) } + result += paramContent.joined(separator: " ") + "\n" + } + result += "\n" + } + } + + let finalResult = result.trimmingCharacters(in: .whitespacesAndNewlines) + return finalResult.isEmpty ? nil : finalResult + } +} + +extension RenderInlineContent { + fileprivate var markdown: String { + switch self { + case .text(let text): return text + case .codeVoice(let code): return "`\(code)`" + case .strong(let inline): return "**\(inline.map(\.markdown).joined())**" + case .emphasis(let inline): return "*\(inline.map(\.markdown).joined())*" + case .reference(_, _, let overridingTitle, let overridingTitleInlineContent): + if let titleContent = overridingTitleInlineContent { + return titleContent.map(\.markdown).joined() + } else if let title = overridingTitle { + return "`\(title)`" + } else { + return "" + } + default: return "" + } + } +} + +extension RenderBlockContent { + fileprivate var markdown: String { + switch self { + case .paragraph(let p): + return p.inlineContent.map(\.markdown).joined() + "\n" + case .codeListing(_): + return "" + case .heading(let h): + return "\(String(repeating: "#", count: h.level)) \(h.text)\n" + default: + return "" + } + } +} + diff --git a/Sources/SwiftLanguageService/SwiftLanguageService.swift b/Sources/SwiftLanguageService/SwiftLanguageService.swift index 7bd0d5409..2d532fcc3 100644 --- a/Sources/SwiftLanguageService/SwiftLanguageService.swift +++ b/Sources/SwiftLanguageService/SwiftLanguageService.swift @@ -743,22 +743,43 @@ extension SwiftLanguageService { package func hover(_ req: HoverRequest) async throws -> HoverResponse? { let uri = req.textDocument.uri let position = req.position + + var tokenRange: Range? + if let snapshot = try? await latestSnapshot(for: uri) { + let tree = await syntaxTreeManager.syntaxTree(for: snapshot) + if let token = tree.token(at: snapshot.absolutePosition(of: position)) { + tokenRange = snapshot.absolutePositionRange(of: token.trimmedRange) + } + } + + if let sourceKitLSPServer, let workspace = await sourceKitLSPServer.workspaceForDocument(uri: uri) { + let languageServices = await sourceKitLSPServer.languageServices(for: uri, .swift, in: workspace) + for languageService in languageServices where languageService !== self { + if var response = try? await languageService.hover(req) { + if response.range == nil { + response.range = tokenRange + } + return response + } + } + } + let cursorInfoResults = try await cursorInfo(uri, position.. String? in if let documentation = cursorInfo.documentation { var result = "" - if let annotatedDeclaration = cursorInfo.annotatedDeclaration { + if let annotated = cursorInfo.annotatedDeclaration { let markdownDecl = orLog("Convert XML declaration to Markdown") { - try xmlDocumentationToMarkdown(annotatedDeclaration) - } ?? annotatedDeclaration + try xmlDocumentationToMarkdown(annotated) + } ?? annotated result += "\(markdownDecl)\n" } result += documentation return result - } else if let annotated: String = cursorInfo.annotatedDeclaration { + } else if let annotated = cursorInfo.annotatedDeclaration { return """ \(orLog("Convert XML to Markdown") { try xmlDocumentationToMarkdown(annotated) } ?? annotated) """ @@ -793,15 +814,6 @@ extension SwiftLanguageService { """ } - var tokenRange: Range? - - if let snapshot = try? await latestSnapshot(for: uri) { - let tree = await syntaxTreeManager.syntaxTree(for: snapshot) - if let token = tree.token(at: snapshot.absolutePosition(of: position)) { - tokenRange = snapshot.absolutePositionRange(of: token.trimmedRange) - } - } - return HoverResponse( contents: .markupContent(MarkupContent(kind: .markdown, value: joinedDocumentation)), range: tokenRange diff --git a/Tests/SourceKitLSPTests/HoverTests.swift b/Tests/SourceKitLSPTests/HoverTests.swift index 0872ccb69..cfcd9765c 100644 --- a/Tests/SourceKitLSPTests/HoverTests.swift +++ b/Tests/SourceKitLSPTests/HoverTests.swift @@ -192,12 +192,51 @@ final class HoverTests: SourceKitLSPTestCase { expectedRange: Position(line: 2, utf16index: 18).., + expectedRange: Range? = nil, + expectedRangeMarker: (String, String)? = nil, file: StaticString = #filePath, line: UInt = #line ) async throws { @@ -211,7 +250,11 @@ private func assertHover( ) let hover = try XCTUnwrap(response, file: file, line: line) - XCTAssertEqual(hover.range, expectedRange, file: file, line: line) + if let expectedRangeMarker { + XCTAssertEqual(hover.range, positions[expectedRangeMarker.0] ..< positions[expectedRangeMarker.1], file: file, line: line) + } else if let expectedRange { + XCTAssertEqual(hover.range, expectedRange, file: file, line: line) + } guard case let .markupContent(content) = hover.contents else { XCTFail("hover.contents is not .markupContents", file: file, line: line) return