From 7863f682a18b791fc3b3670b074e0297ec4bb0b8 Mon Sep 17 00:00:00 2001 From: Josh Arnold Date: Sun, 14 Sep 2025 14:23:51 -0400 Subject: [PATCH] Support custom preparationBatchSize defined via SourceKit's options --- Documentation/Configuration File.md | 4 + .../Sources/ConfigSchemaGen/JSONSchema.swift | 63 +++++++++++-- .../ConfigSchemaGen/OptionDocument.swift | 50 +++++++++-- .../ConfigSchemaGen/OptionSchema.swift | 90 +++++++++++++++++-- .../PreparationBatchingStrategy.swift | 54 +++++++++++ Sources/SKOptions/SourceKitLSPOptions.swift | 7 ++ .../PreparationTaskDescription.swift | 14 +-- .../SemanticIndex/SemanticIndexManager.swift | 22 ++++- Sources/SourceKitLSP/Workspace.swift | 1 + .../BackgroundIndexingTests.swift | 87 ++++++++++++++++++ config.schema.json | 25 ++++++ 11 files changed, 387 insertions(+), 30 deletions(-) create mode 100644 Sources/SKOptions/PreparationBatchingStrategy.swift diff --git a/Documentation/Configuration File.md b/Documentation/Configuration File.md index 562f190ec..c04a777b0 100644 --- a/Documentation/Configuration File.md +++ b/Documentation/Configuration File.md @@ -62,3 +62,7 @@ The structure of the file is currently not guaranteed to be stable. Options may - `sourcekitdRequestTimeout: number`: The maximum duration that a sourcekitd request should be allowed to execute before being declared as timed out. In general, editors should cancel requests that they are no longer interested in, but in case editors don't cancel requests, this ensures that a long-running non-cancelled request is not blocking sourcekitd and thus most semantic functionality. In particular, VS Code does not cancel the semantic tokens request, which can cause a long-running AST build that blocks sourcekitd. - `semanticServiceRestartTimeout: number`: If a request to sourcekitd or clangd exceeds this timeout, we assume that the semantic service provider is hanging for some reason and won't recover. To restore semantic functionality, we terminate and restart it. - `buildServerWorkspaceRequestsTimeout: number`: Duration how long to wait for responses to `workspace/buildTargets` or `buildTarget/sources` request by the build server before defaulting to an empty response. +- `preparationBatchingStrategy: object`: Defines the batch size for target preparation. If nil, defaults to preparing 1 target at a time. + - This is a tagged union discriminated by the `strategy` field. Each case has the following structure: + - `strategy: "fixedTargetBatchSize"`: Prepare a fixed number of targets in a single batch. `batchSize`: The number of targets to prepare in each batch. + - `batchSize: integer`: The number of targets to prepare in each batch. diff --git a/SourceKitLSPDevUtils/Sources/ConfigSchemaGen/JSONSchema.swift b/SourceKitLSPDevUtils/Sources/ConfigSchemaGen/JSONSchema.swift index 55f578633..85fa92275 100644 --- a/SourceKitLSPDevUtils/Sources/ConfigSchemaGen/JSONSchema.swift +++ b/SourceKitLSPDevUtils/Sources/ConfigSchemaGen/JSONSchema.swift @@ -38,6 +38,8 @@ struct JSONSchema: Encodable { case additionalProperties case markdownDescription case markdownEnumDescriptions + case oneOf + case const } var _schema: String? var id: String? @@ -60,6 +62,9 @@ struct JSONSchema: Encodable { /// https://github.com/microsoft/vscode-wiki/blob/main/Setting-Descriptions.md var markdownEnumDescriptions: [String]? + var oneOf: [JSONSchema]? + var const: String? + func encode(to encoder: any Encoder) throws { // Manually implement encoding to use `encodeIfPresent` for HeapBox-ed fields var container = encoder.container(keyedBy: CodingKeys.self) @@ -82,6 +87,10 @@ struct JSONSchema: Encodable { if let markdownEnumDescriptions { try container.encode(markdownEnumDescriptions, forKey: .markdownEnumDescriptions) } + if let oneOf, !oneOf.isEmpty { + try container.encode(oneOf, forKey: .oneOf) + } + try container.encodeIfPresent(const, forKey: .const) } } @@ -126,13 +135,53 @@ struct JSONSchemaBuilder { schema.properties = properties schema.required = required case .enum(let enumInfo): - schema.type = "string" - schema.enum = enumInfo.cases.map(\.name) - // Set `markdownEnumDescriptions` for better rendering in VSCode rich hover - // Unlike `description`, `enumDescriptions` field is not a part of JSON Schema spec, - // so we only set `markdownEnumDescriptions` here. - if enumInfo.cases.contains(where: { $0.description != nil }) { - schema.markdownEnumDescriptions = enumInfo.cases.map { $0.description ?? "" } + let hasAssociatedTypes = enumInfo.cases.contains { !($0.associatedProperties?.isEmpty ?? true) } + + if hasAssociatedTypes { + let discriminatorFieldName = enumInfo.discriminatorFieldName ?? "type" + var oneOfSchemas: [JSONSchema] = [] + + for caseInfo in enumInfo.cases { + var caseSchema = JSONSchema() + caseSchema.type = "object" + caseSchema.description = caseInfo.description + caseSchema.markdownDescription = caseInfo.description + + var caseProperties: [String: JSONSchema] = [:] + var caseRequired: [String] = [discriminatorFieldName] + + var discriminatorSchema = JSONSchema() + discriminatorSchema.const = caseInfo.name + caseProperties[discriminatorFieldName] = discriminatorSchema + + if let associatedProperties = caseInfo.associatedProperties { + for property in associatedProperties { + let propertyType = property.type + var propertySchema = try buildJSONSchema(from: propertyType) + propertySchema.description = property.description + propertySchema.markdownDescription = property.description + caseProperties[property.name] = propertySchema + if !propertyType.isOptional { + caseRequired.append(property.name) + } + } + } + + caseSchema.properties = caseProperties + caseSchema.required = caseRequired + oneOfSchemas.append(caseSchema) + } + + schema.oneOf = oneOfSchemas + } else { + schema.type = "string" + schema.enum = enumInfo.cases.map(\.name) + // Set `markdownEnumDescriptions` for better rendering in VSCode rich hover + // Unlike `description`, `enumDescriptions` field is not a part of JSON Schema spec, + // so we only set `markdownEnumDescriptions` here. + if enumInfo.cases.contains(where: { $0.description != nil }) { + schema.markdownEnumDescriptions = enumInfo.cases.map { $0.description ?? "" } + } } } return schema diff --git a/SourceKitLSPDevUtils/Sources/ConfigSchemaGen/OptionDocument.swift b/SourceKitLSPDevUtils/Sources/ConfigSchemaGen/OptionDocument.swift index b6a1d6a1b..c1c5c4002 100644 --- a/SourceKitLSPDevUtils/Sources/ConfigSchemaGen/OptionDocument.swift +++ b/SourceKitLSPDevUtils/Sources/ConfigSchemaGen/OptionDocument.swift @@ -68,14 +68,39 @@ struct OptionDocumentBuilder { try appendProperty(property, indentLevel: indentLevel + 1) } case .enum(let schema): - for caseInfo in schema.cases { - // Add detailed description for each case if available - guard let description = caseInfo.description else { - continue + let hasAssociatedTypes = schema.cases.contains { + $0.associatedProperties != nil && !$0.associatedProperties!.isEmpty + } + + if hasAssociatedTypes { + let discriminatorFieldName = schema.discriminatorFieldName ?? "type" + doc += + "\(indent) - This is a tagged union discriminated by the `\(discriminatorFieldName)` field. Each case has the following structure:\n" + + for caseInfo in schema.cases { + doc += """ + \(indent) - `\(discriminatorFieldName): "\(caseInfo.name)"` + """ + if let description = caseInfo.description { + doc += ": " + description.split(separator: "\n").joined(separator: "\n\(indent) ") + } + doc += "\n" + + if let associatedProperties = caseInfo.associatedProperties { + for assocProp in associatedProperties { + try appendProperty(assocProp, indentLevel: indentLevel + 2) + } + } + } + } else { + for caseInfo in schema.cases { + guard let description = caseInfo.description else { + continue + } + doc += "\(indent) - `\(caseInfo.name)`" + doc += ": " + description.split(separator: "\n").joined(separator: "\n\(indent) ") + doc += "\n" } - doc += "\(indent) - `\(caseInfo.name)`" - doc += ": " + description.split(separator: "\n").joined(separator: "\n\(indent) ") - doc += "\n" } default: break } @@ -102,8 +127,15 @@ struct OptionDocumentBuilder { case .struct(let structInfo): return structInfo.name case .enum(let enumInfo): - let cases = enumInfo.cases.map { "\"\($0.name)\"" }.joined(separator: "|") - return shouldWrap ? "(\(cases))" : cases + let hasAssociatedTypes = enumInfo.cases.contains { + $0.associatedProperties != nil && !$0.associatedProperties!.isEmpty + } + if hasAssociatedTypes { + return "object" + } else { + let cases = enumInfo.cases.map { "\"\($0.name)\"" }.joined(separator: "|") + return shouldWrap ? "(\(cases))" : cases + } } } } diff --git a/SourceKitLSPDevUtils/Sources/ConfigSchemaGen/OptionSchema.swift b/SourceKitLSPDevUtils/Sources/ConfigSchemaGen/OptionSchema.swift index 64876c1db..8486c1cff 100644 --- a/SourceKitLSPDevUtils/Sources/ConfigSchemaGen/OptionSchema.swift +++ b/SourceKitLSPDevUtils/Sources/ConfigSchemaGen/OptionSchema.swift @@ -31,11 +31,13 @@ struct OptionTypeSchama { struct Case { var name: String var description: String? + var associatedProperties: [Property]? } struct Enum { var name: String var cases: [Case] + var discriminatorFieldName: String? } enum Kind { @@ -146,14 +148,13 @@ struct OptionSchemaContext { } private func buildEnumCases(_ node: EnumDeclSyntax) throws -> OptionTypeSchama.Enum { + let discriminatorFieldName = Self.extractDiscriminatorFieldName(node.leadingTrivia) + let cases = try node.memberBlock.members.flatMap { member -> [OptionTypeSchama.Case] in guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else { return [] } return try caseDecl.elements.compactMap { - guard $0.parameterClause == nil else { - throw ConfigSchemaGenError("Associated values in enum cases are not supported: \(caseDecl)") - } let name: String if let rawValue = $0.rawValue?.value { if let stringLiteral = rawValue.as(StringLiteralExprSyntax.self), @@ -172,11 +173,45 @@ struct OptionSchemaContext { if description?.contains("- Note: Internal option") ?? false { return nil } - return OptionTypeSchama.Case(name: name, description: description) + + var associatedProperties: [OptionTypeSchama.Property]? = nil + if let parameterClause = $0.parameterClause { + let caseDescription = description + associatedProperties = try parameterClause.parameters.map { param in + let propertyName: String + if let firstName = param.firstName, firstName.tokenKind != .wildcard { + propertyName = firstName.text + } else if let secondName = param.secondName { + propertyName = secondName.text + } else { + propertyName = name + } + + let propertyType = try resolveType(param.type) + let propertyDescription = + Self.extractParameterDescription( + from: caseDescription, + parameterName: propertyName + ) ?? Self.extractDocComment(param.leadingTrivia) + + return OptionTypeSchama.Property( + name: propertyName, + type: propertyType, + description: propertyDescription, + defaultValue: nil + ) + } + } + + return OptionTypeSchama.Case( + name: name, + description: description, + associatedProperties: associatedProperties + ) } } let typeName = node.name.text - return .init(name: typeName, cases: cases) + return .init(name: typeName, cases: cases, discriminatorFieldName: discriminatorFieldName) } private func buildStructProperties(_ node: StructDeclSyntax) throws -> OptionTypeSchama.Struct { @@ -234,4 +269,49 @@ struct OptionSchemaContext { } return docLines.joined(separator: " ") } + + private static func extractDiscriminatorFieldName(_ trivia: Trivia) -> String? { + let docLines = trivia.flatMap { piece -> [Substring] in + switch piece { + case .docBlockComment(let text): + assert(text.hasPrefix("/**") && text.hasSuffix("*/"), "Unexpected doc block comment format: \(text)") + return text.dropFirst(3).dropLast(2).split { $0.isNewline } + case .docLineComment(let text): + assert(text.hasPrefix("///"), "Unexpected doc line comment format: \(text)") + let text = text.dropFirst(3) + return [text] + default: + return [] + } + } + + for line in docLines { + let trimmed = line.drop(while: \.isWhitespace) + if trimmed.hasPrefix("- discriminator:") { + let fieldName = trimmed.dropFirst("- discriminator:".count).trimmingCharacters(in: .whitespaces) + return fieldName.isEmpty ? nil : fieldName + } + } + return nil + } + + private static func extractParameterDescription(from docComment: String?, parameterName: String) -> String? { + guard let docComment = docComment else { + return nil + } + + let pattern = "`\(parameterName)`:" + guard let range = docComment.range(of: pattern) else { + return nil + } + + let afterPattern = docComment[range.upperBound...] + let lines = afterPattern.split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false) + guard let firstLine = lines.first else { + return nil + } + + let description = firstLine.trimmingCharacters(in: .whitespaces) + return description.isEmpty ? nil : description + } } diff --git a/Sources/SKOptions/PreparationBatchingStrategy.swift b/Sources/SKOptions/PreparationBatchingStrategy.swift new file mode 100644 index 000000000..b5c187c83 --- /dev/null +++ b/Sources/SKOptions/PreparationBatchingStrategy.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Defines the batch size for target preparation. +/// +/// If nil, SourceKit-LSP will default to preparing 1 target at a time. +/// +/// - discriminator: strategy +public enum PreparationBatchingStrategy: Sendable, Equatable { + /// Prepare a fixed number of targets in a single batch. + /// + /// `batchSize`: The number of targets to prepare in each batch. + case fixedTargetBatchSize(batchSize: Int) +} + +extension PreparationBatchingStrategy: Codable { + private enum CodingKeys: String, CodingKey { + case strategy + case batchSize + } + + private enum StrategyValue: String, Codable { + case fixedTargetBatchSize + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let strategy = try container.decode(StrategyValue.self, forKey: .strategy) + + switch strategy { + case .fixedTargetBatchSize: + let batchSize = try container.decode(Int.self, forKey: .batchSize) + self = .fixedTargetBatchSize(batchSize: batchSize) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .fixedTargetBatchSize(let batchSize): + try container.encode(StrategyValue.fixedTargetBatchSize, forKey: .strategy) + try container.encode(batchSize, forKey: .batchSize) + } + } +} diff --git a/Sources/SKOptions/SourceKitLSPOptions.swift b/Sources/SKOptions/SourceKitLSPOptions.swift index 78bd02ddd..347dccaf9 100644 --- a/Sources/SKOptions/SourceKitLSPOptions.swift +++ b/Sources/SKOptions/SourceKitLSPOptions.swift @@ -449,6 +449,10 @@ public struct SourceKitLSPOptions: Sendable, Codable, Equatable { return .seconds(15) } + /// Defines the batch size for target preparation. + /// If nil, defaults to preparing 1 target at a time. + public var preparationBatchingStrategy: PreparationBatchingStrategy? + public init( swiftPM: SwiftPMOptions? = .init(), fallbackBuildSystem: FallbackBuildSystemOptions? = .init(), @@ -462,6 +466,7 @@ public struct SourceKitLSPOptions: Sendable, Codable, Equatable { generatedFilesPath: String? = nil, backgroundIndexing: Bool? = nil, backgroundPreparationMode: BackgroundPreparationMode? = nil, + preparationBatchingStrategy: PreparationBatchingStrategy? = nil, cancelTextDocumentRequestsOnEditAndClose: Bool? = nil, experimentalFeatures: Set? = nil, swiftPublishDiagnosticsDebounceDuration: Double? = nil, @@ -482,6 +487,7 @@ public struct SourceKitLSPOptions: Sendable, Codable, Equatable { self.defaultWorkspaceType = defaultWorkspaceType self.backgroundIndexing = backgroundIndexing self.backgroundPreparationMode = backgroundPreparationMode + self.preparationBatchingStrategy = preparationBatchingStrategy self.cancelTextDocumentRequestsOnEditAndClose = cancelTextDocumentRequestsOnEditAndClose self.experimentalFeatures = experimentalFeatures self.swiftPublishDiagnosticsDebounceDuration = swiftPublishDiagnosticsDebounceDuration @@ -545,6 +551,7 @@ public struct SourceKitLSPOptions: Sendable, Codable, Equatable { generatedFilesPath: override?.generatedFilesPath ?? base.generatedFilesPath, backgroundIndexing: override?.backgroundIndexing ?? base.backgroundIndexing, backgroundPreparationMode: override?.backgroundPreparationMode ?? base.backgroundPreparationMode, + preparationBatchingStrategy: override?.preparationBatchingStrategy ?? base.preparationBatchingStrategy, cancelTextDocumentRequestsOnEditAndClose: override?.cancelTextDocumentRequestsOnEditAndClose ?? base.cancelTextDocumentRequestsOnEditAndClose, experimentalFeatures: override?.experimentalFeatures ?? base.experimentalFeatures, diff --git a/Sources/SemanticIndex/PreparationTaskDescription.swift b/Sources/SemanticIndex/PreparationTaskDescription.swift index 664f0abe0..324c60e28 100644 --- a/Sources/SemanticIndex/PreparationTaskDescription.swift +++ b/Sources/SemanticIndex/PreparationTaskDescription.swift @@ -49,6 +49,8 @@ package struct PreparationTaskDescription: IndexTaskDescription { /// Hooks that should be called when the preparation task finishes. private let hooks: IndexHooks + private let purpose: TargetPreparationPurpose + /// The task is idempotent because preparing the same target twice produces the same result as preparing it once. package var isIdempotent: Bool { true } @@ -70,13 +72,15 @@ package struct PreparationTaskDescription: IndexTaskDescription { @escaping @Sendable ( _ message: String, _ type: WindowMessageType, _ structure: LanguageServerProtocol.StructuredLogKind ) -> Void, - hooks: IndexHooks + hooks: IndexHooks, + purpose: TargetPreparationPurpose ) { self.targetsToPrepare = targetsToPrepare self.buildServerManager = buildServerManager self.preparationUpToDateTracker = preparationUpToDateTracker self.logMessageToIndexLog = logMessageToIndexLog self.hooks = hooks + self.purpose = purpose } package func execute() async { @@ -122,11 +126,9 @@ package struct PreparationTaskDescription: IndexTaskDescription { to currentlyExecutingTasks: [PreparationTaskDescription] ) -> [TaskDependencyAction] { return currentlyExecutingTasks.compactMap { (other) -> TaskDependencyAction? in - if other.targetsToPrepare.count > self.targetsToPrepare.count { - // If there is an prepare operation with more targets already running, suspend it. - // The most common use case for this is if we prepare all targets simultaneously during the initial preparation - // when a project is opened and need a single target indexed for user interaction. We should suspend the - // workspace-wide preparation and just prepare the currently needed target. + if other.purpose == .forIndexing && self.purpose == .forEditorFunctionality { + // If we're running a background indexing operation but need a target indexed for user interaction, + // we should prioritize the latter. return .cancelAndRescheduleDependency(other) } return .waitAndElevatePriorityOfDependency(other) diff --git a/Sources/SemanticIndex/SemanticIndexManager.swift b/Sources/SemanticIndex/SemanticIndexManager.swift index c0202e6cf..7eb385058 100644 --- a/Sources/SemanticIndex/SemanticIndexManager.swift +++ b/Sources/SemanticIndex/SemanticIndexManager.swift @@ -16,6 +16,7 @@ import Foundation @_spi(SourceKitLSP) package import LanguageServerProtocol @_spi(SourceKitLSP) import LanguageServerProtocolExtensions @_spi(SourceKitLSP) import SKLogging +package import SKOptions import SwiftExtensions @_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions @@ -155,7 +156,7 @@ private struct InProgressPrepareForEditorTask { } /// The reason why a target is being prepared. This is used to determine the `IndexProgressStatus`. -private enum TargetPreparationPurpose: Comparable { +package enum TargetPreparationPurpose: Comparable { /// We are preparing the target so we can index files in it. case forIndexing @@ -233,6 +234,9 @@ package final actor SemanticIndexManager { /// The parameter is the number of files that were scheduled to be indexed. private let indexTasksWereScheduled: @Sendable (_ numberOfFileScheduled: Int) -> Void + /// The size of the batches in which the `SemanticIndexManager` should dispatch preparation tasks. + private let preparationBatchingStrategy: PreparationBatchingStrategy? + /// Callback that is called when `progressStatus` might have changed. private let indexProgressStatusDidChange: @Sendable () -> Void @@ -272,6 +276,7 @@ package final actor SemanticIndexManager { updateIndexStoreTimeout: Duration, hooks: IndexHooks, indexTaskScheduler: TaskScheduler, + preparationBatchingStrategy: PreparationBatchingStrategy?, logMessageToIndexLog: @escaping @Sendable ( _ message: String, _ type: WindowMessageType, _ structure: LanguageServerProtocol.StructuredLogKind @@ -284,6 +289,7 @@ package final actor SemanticIndexManager { self.updateIndexStoreTimeout = updateIndexStoreTimeout self.hooks = hooks self.indexTaskScheduler = indexTaskScheduler + self.preparationBatchingStrategy = preparationBatchingStrategy self.logMessageToIndexLog = logMessageToIndexLog self.indexTasksWereScheduled = indexTasksWereScheduled self.indexProgressStatusDidChange = indexProgressStatusDidChange @@ -666,7 +672,8 @@ package final actor SemanticIndexManager { buildServerManager: self.buildServerManager, preparationUpToDateTracker: preparationUpToDateTracker, logMessageToIndexLog: logMessageToIndexLog, - hooks: hooks + hooks: hooks, + purpose: purpose ) ) if Task.isCancelled { @@ -920,7 +927,16 @@ package final actor SemanticIndexManager { // TODO: When we can index multiple targets concurrently in SwiftPM, increase the batch size to half the // processor count, so we can get parallelism during preparation. // (https://github.com/swiftlang/sourcekit-lsp/issues/1262) - for targetsBatch in sortedTargets.partition(intoBatchesOfSize: 1) { + let batchSize: Int + switch preparationBatchingStrategy { + case .fixedTargetBatchSize(let size): + batchSize = max(size, 1) + case nil: + batchSize = 1 // Default: prepare 1 target at a time + } + let partitionedTargets = sortedTargets.partition(intoBatchesOfSize: batchSize) + + for targetsBatch in partitionedTargets { let preparationTaskID = UUID() let filesToIndex = targetsBatch.flatMap { (target) -> [FileIndexInfo] in guard let files = filesByTarget[target] else { diff --git a/Sources/SourceKitLSP/Workspace.swift b/Sources/SourceKitLSP/Workspace.swift index 760a6356d..d550ef873 100644 --- a/Sources/SourceKitLSP/Workspace.swift +++ b/Sources/SourceKitLSP/Workspace.swift @@ -241,6 +241,7 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { updateIndexStoreTimeout: options.indexOrDefault.updateIndexStoreTimeoutOrDefault, hooks: hooks.indexHooks, indexTaskScheduler: indexTaskScheduler, + preparationBatchingStrategy: options.preparationBatchingStrategy, logMessageToIndexLog: { [weak sourceKitLSPServer] in sourceKitLSPServer?.logMessageToIndexLog(message: $0, type: $1, structure: $2) }, diff --git a/Tests/SourceKitLSPTests/BackgroundIndexingTests.swift b/Tests/SourceKitLSPTests/BackgroundIndexingTests.swift index f6c871c38..33b68af30 100644 --- a/Tests/SourceKitLSPTests/BackgroundIndexingTests.swift +++ b/Tests/SourceKitLSPTests/BackgroundIndexingTests.swift @@ -2983,6 +2983,93 @@ final class BackgroundIndexingTests: SourceKitLSPTestCase { ] ) } + + func testBuildServerUsesCustomTaskBatchSize() async throws { + actor BuildServer: CustomBuildServer { + let inProgressRequestsTracker = CustomBuildServerInProgressRequestTracker() + private let projectRoot: URL + private var testFileURL: URL { projectRoot.appendingPathComponent("test.swift").standardized } + private var preparedTargetBatches = [[BuildTargetIdentifier]]() + + init(projectRoot: URL, connectionToSourceKitLSP _: any LanguageServerProtocol.Connection) { + self.projectRoot = projectRoot + } + + func initializeBuildRequest(_: InitializeBuildRequest) async throws -> InitializeBuildResponse { + return try initializationResponseSupportingBackgroundIndexing( + projectRoot: projectRoot, + outputPathsProvider: false, + ) + } + + func buildTargetSourcesRequest(_: BuildTargetSourcesRequest) async throws -> BuildTargetSourcesResponse { + var dummyTargets = [BuildTargetIdentifier]() + for i in 0..<10 { + dummyTargets.append(BuildTargetIdentifier(uri: try! URI(string: "dummy://dummy-\(i)"))) + } + return BuildTargetSourcesResponse( + items: dummyTargets.map { + SourcesItem(target: $0, sources: [SourceItem(uri: URI(testFileURL), kind: .file, generated: false)]) + } + ) + } + + func textDocumentSourceKitOptionsRequest( + _ request: TextDocumentSourceKitOptionsRequest + ) async throws -> TextDocumentSourceKitOptionsResponse? { + return TextDocumentSourceKitOptionsResponse(compilerArguments: [request.textDocument.uri.pseudoPath]) + } + + func prepareTarget(_ request: BuildTargetPrepareRequest) async throws -> VoidResponse { + preparedTargetBatches.append(request.targets.sorted { $0.uri.stringValue < $1.uri.stringValue }) + return VoidResponse() + } + + fileprivate func getPreparedBatches() async -> [[BuildTargetIdentifier]] { + return preparedTargetBatches.sorted { $0[0].uri.stringValue < $1[0].uri.stringValue } + } + } + + let project = try await CustomBuildServerTestProject( + files: [ + "test.swift": """ + func testFunction() {} + """ + ], + buildServer: BuildServer.self, + options: SourceKitLSPOptions(preparationBatchingStrategy: .fixedTargetBatchSize(batchSize: 3)), + enableBackgroundIndexing: true, + ) + + try await project.testClient.send(SynchronizeRequest(index: true)) + + let buildServer = try project.buildServer() + let preparedBatches = await buildServer.getPreparedBatches() + + XCTAssertEqual( + preparedBatches, + [ + [ + BuildTargetIdentifier(uri: try! URI(string: "dummy://dummy-0")), + BuildTargetIdentifier(uri: try! URI(string: "dummy://dummy-1")), + BuildTargetIdentifier(uri: try! URI(string: "dummy://dummy-2")), + ], + [ + BuildTargetIdentifier(uri: try! URI(string: "dummy://dummy-3")), + BuildTargetIdentifier(uri: try! URI(string: "dummy://dummy-4")), + BuildTargetIdentifier(uri: try! URI(string: "dummy://dummy-5")), + ], + [ + BuildTargetIdentifier(uri: try! URI(string: "dummy://dummy-6")), + BuildTargetIdentifier(uri: try! URI(string: "dummy://dummy-7")), + BuildTargetIdentifier(uri: try! URI(string: "dummy://dummy-8")), + ], + [ + BuildTargetIdentifier(uri: try! URI(string: "dummy://dummy-9")) + ], + ] + ) + } } extension HoverResponseContents { diff --git a/config.schema.json b/config.schema.json index b286aa0c2..4f0351f6c 100644 --- a/config.schema.json +++ b/config.schema.json @@ -186,6 +186,31 @@ }, "type" : "object" }, + "preparationBatchingStrategy" : { + "description" : "Defines the batch size for target preparation. If nil, defaults to preparing 1 target at a time.", + "markdownDescription" : "Defines the batch size for target preparation. If nil, defaults to preparing 1 target at a time.", + "oneOf" : [ + { + "description" : "Prepare a fixed number of targets in a single batch. `batchSize`: The number of targets to prepare in each batch.", + "markdownDescription" : "Prepare a fixed number of targets in a single batch. `batchSize`: The number of targets to prepare in each batch.", + "properties" : { + "batchSize" : { + "description" : "The number of targets to prepare in each batch.", + "markdownDescription" : "The number of targets to prepare in each batch.", + "type" : "integer" + }, + "strategy" : { + "const" : "fixedTargetBatchSize" + } + }, + "required" : [ + "strategy", + "batchSize" + ], + "type" : "object" + } + ] + }, "semanticServiceRestartTimeout" : { "description" : "If a request to sourcekitd or clangd exceeds this timeout, we assume that the semantic service provider is hanging for some reason and won't recover. To restore semantic functionality, we terminate and restart it.", "markdownDescription" : "If a request to sourcekitd or clangd exceeds this timeout, we assume that the semantic service provider is hanging for some reason and won't recover. To restore semantic functionality, we terminate and restart it.",