From d916b2ea0cf8660091c69f80bac069c4620ed7e8 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Wed, 7 May 2025 22:06:14 +0200 Subject: [PATCH 01/66] list files within workspace recursively --- .../MultiFileContextManager.swift | 30 +++++++++++++++ .../MultiFileContext/WorkspaceProvider.swift | 30 +++++++++++++++ .../MultiFileContextManagerTests.swift | 38 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift create mode 100644 Core/Sources/Service/MultiFileContext/WorkspaceProvider.swift create mode 100644 Core/Tests/ServiceTests/MultiFileContextManagerTests.swift diff --git a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift new file mode 100644 index 00000000..2047c231 --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift @@ -0,0 +1,30 @@ +import Foundation +import Workspace +import XcodeInspector + +class MultiFileContextManager { + let workspaceProvider: WorkspaceProvider + + init(workspaceProvider: WorkspaceProvider) { + self.workspaceProvider = workspaceProvider + } + + /// List files within workspace recursively + /// Retrieved from: https://stackoverflow.com/a/57640445 + func listFilesInWorkspace() async -> [String] { + guard let workspace: Workspace = try? await workspaceProvider.workspace() + else { return [] } + var files = [String]() + if let enumerator = FileManager.default.enumerator(at: workspace.projectRootURL, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) { + for case let fileURL as URL in enumerator { + do { + let fileAttributes = try fileURL.resourceValues(forKeys: [.isRegularFileKey]) + if fileAttributes.isRegularFile! { + files.append(fileURL.absoluteString) + } + } catch { print(error, fileURL) } + } + } + return files + } +} diff --git a/Core/Sources/Service/MultiFileContext/WorkspaceProvider.swift b/Core/Sources/Service/MultiFileContext/WorkspaceProvider.swift new file mode 100644 index 00000000..5e4efb89 --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/WorkspaceProvider.swift @@ -0,0 +1,30 @@ +import Foundation +import Workspace +import XcodeInspector + +protocol WorkspaceProvider { + func workspace() async throws -> Workspace? +} + +class XcodeInspectorWorkspaceProvider: WorkspaceProvider { + private func getFileURL() async -> URL? { + await XcodeInspector.shared.safe.realtimeActiveDocumentURL + } + + @WorkspaceActor + private func getFilespace() async -> Filespace? { + guard + let fileURL = await getFileURL(), + let (_, filespace) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + else { return nil } + return filespace + } + + func workspace() async throws -> Workspace? { + guard let filespace = await getFilespace() else { return nil } + let workspacePool: WorkspacePool = await Service.shared.workspacePool + let tuple: (workspace: Workspace, _: Filespace)? = try await workspacePool.fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) + return tuple?.workspace + } +} diff --git a/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift new file mode 100644 index 00000000..cb7869fa --- /dev/null +++ b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift @@ -0,0 +1,38 @@ +import XCTest + +@testable import Service +@testable import Workspace + +class MultiFileContextManagerTests: XCTestCase { + + var sut: MultiFileContextManager { + MultiFileContextManager( + workspaceProvider: WorkspaceProviderMock() + ) + } + + func testListingFiles() async { + let sut = sut + let files = await sut.listFilesInWorkspace() + + XCTAssertNotEqual(files.count, 0) + } +} + + +class WorkspaceProviderMock: WorkspaceProvider { + func workspace() async throws -> Workspace? { + let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/CopilotForXcode-Fork/Copilot for Xcode.xcworkspace") + return Workspace(workspaceURL: workspaceURL) + } +} + +//private func mockFilespace() -> Filespace { +// let fileURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/CopilotForXcode-Fork/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift") +// return Filespace(fileURL: fileURL) { filespace in +// return +// } onClose: { url in +// return +// } +// +//} From b6b1cb825c9c6d0b7a1ded7badf027a5517ca113 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Tue, 13 May 2025 11:11:20 +0200 Subject: [PATCH 02/66] read file contents and provide them in new data type --- .../MultiFileContextManager.swift | 19 +++++++++++++++++++ .../MultiFileContextManagerTests.swift | 10 +++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift index 2047c231..b52b9116 100644 --- a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift +++ b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift @@ -27,4 +27,23 @@ class MultiFileContextManager { } return files } + + func readFileContents() async -> [FileContent] { + let fileURLs = await listFilesInWorkspace() + return fileURLs.compactMap { fileURLString in + guard let fileURL = URL(string: fileURLString) else { return nil } + do { + let content = try String(contentsOf: fileURL, encoding: .utf8) + return FileContent(fileURL: fileURLString, content: content) + } catch { + print("Failed to read \(fileURL):", error) + return nil + } + } + } +} + +struct FileContent { + let fileURL: String + let content: String } diff --git a/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift index cb7869fa..b65906f6 100644 --- a/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift +++ b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift @@ -17,12 +17,20 @@ class MultiFileContextManagerTests: XCTestCase { XCTAssertNotEqual(files.count, 0) } + + func testRetrievingFileContent() async { + let sut = sut + let files = await sut.readFileContents() + + XCTAssertNotEqual(files.count, 0) + } } class WorkspaceProviderMock: WorkspaceProvider { func workspace() async throws -> Workspace? { - let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/CopilotForXcode-Fork/Copilot for Xcode.xcworkspace") +// let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/CopilotForXcode-Fork/Copilot for Xcode.xcworkspace") + let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/clean-architecture-swiftui-fork") return Workspace(workspaceURL: workspaceURL) } } From bfb812790c5513ae9fc416f38551080e5ee23688 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Wed, 21 May 2025 12:44:42 +0200 Subject: [PATCH 03/66] add swift-syntax library to project which already existed in xcworkspace --- .../xcshareddata/swiftpm/Package.resolved | 73 +++++++++++-------- Core/Package.swift | 6 +- 2 files changed, 45 insertions(+), 34 deletions(-) diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3a57f6e9..60d6595b 100644 --- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", - "version" : "1.0.0" + "revision" : "5928286acce13def418ec36d05a001a9641086f2", + "version" : "1.0.3" } }, { @@ -167,8 +167,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "8d712376c99fc0267aa0e41fea732babe365270a", - "version" : "1.3.3" + "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25", + "version" : "1.7.0" } }, { @@ -176,8 +176,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33", - "version" : "1.0.2" + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" } }, { @@ -194,8 +194,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "revision" : "433a23118f739078644ebeb4009e23d307af694a", - "version" : "1.10.4" + "revision" : "b1d27a82b498b8e697acabd8cc01b585d26aab19", + "version" : "1.19.1" } }, { @@ -203,8 +203,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", - "version" : "1.1.0" + "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", + "version" : "1.3.1" } }, { @@ -212,8 +212,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c", - "version" : "1.3.0" + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" } }, { @@ -221,8 +221,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "350e1e119babe8525f9bd155b76640a5de270184", - "version" : "1.3.0" + "revision" : "4c90d6b2b9bf0911af87b103bb40f41771891596", + "version" : "1.9.2" } }, { @@ -230,8 +230,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-identified-collections", "state" : { - "revision" : "d533cd18b0b456b106694a9899f917ee595f2666", - "version" : "1.0.2" + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" } }, { @@ -243,6 +243,15 @@ "version" : "2.4.0" } }, + { + "identity" : "swift-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-navigation", + "state" : { + "revision" : "db6bc9dbfed001f21e6728fd36413d9342c235b4", + "version" : "2.3.0" + } + }, { "identity" : "swift-parsing", "kind" : "remoteSourceControl", @@ -257,35 +266,35 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "64f7f6c28c6a4d3c4b9da2ba02383e29ab48a8cf", - "version" : "1.2.2" + "revision" : "d924c62a70fca5f43872f286dbd7cef0957f1c01", + "version" : "1.6.0" } }, { - "identity" : "swift-syntax", + "identity" : "swift-sharing", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", + "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", - "version" : "509.0.2" + "revision" : "75e846ee3159dc75b3a29bfc24b6ce5a557ddca9", + "version" : "2.5.2" } }, { - "identity" : "swiftui-flow-layout", + "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/globulus/swiftui-flow-layout", + "location" : "https://github.com/apple/swift-syntax", "state" : { - "revision" : "de7da3440c3b87ba94adfa98c698828d7746a76d", - "version" : "1.0.5" + "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", + "version" : "510.0.2" } }, { - "identity" : "swiftui-navigation", + "identity" : "swiftui-flow-layout", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swiftui-navigation", + "location" : "https://github.com/globulus/swiftui-flow-layout", "state" : { - "revision" : "7ab04c6e2e6a73d34d5a762970ef88bf0aedb084", - "version" : "1.4.0" + "revision" : "de7da3440c3b87ba94adfa98c698828d7746a76d", + "version" : "1.0.5" } }, { @@ -293,8 +302,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "6f30bdba373bbd7fbfe241dddd732651f2fbd1e2", - "version" : "1.1.2" + "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", + "version" : "1.5.2" } } ], diff --git a/Core/Package.swift b/Core/Package.swift index 3de53aeb..f326fdaa 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -53,8 +53,8 @@ let package = Package( .package(url: "https://github.com/devm33/KeyboardShortcuts", branch: "main"), .package(url: "https://github.com/devm33/CGEventOverride", branch: "devm33/fix-stale-AXIsProcessTrusted"), .package(url: "https://github.com/devm33/Highlightr", branch: "master"), - .package(url: "https://github.com/globulus/swiftui-flow-layout", - from: "1.0.5") + .package(url: "https://github.com/globulus/swiftui-flow-layout", from: "1.0.5"), + .package(url: "https://github.com/apple/swift-syntax", exact: "510.0.2"), ], targets: [ // MARK: - Main @@ -98,6 +98,8 @@ let package = Package( .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"), + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), ]), .testTarget( name: "ServiceTests", From 4ce5708cd20155ef826f0098bad0c5ab0d195aaf Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Wed, 21 May 2025 12:50:48 +0200 Subject: [PATCH 04/66] fix project from not building after adding swift-syntax library decorating methods and inits with @MainActor fixed the issues --- Core/Sources/ConversationTab/ConversationTab.swift | 1 + Tool/Sources/ChatTab/ChatTab.swift | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Core/Sources/ConversationTab/ConversationTab.swift b/Core/Sources/ConversationTab/ConversationTab.swift index 5d6d5014..1b698ffb 100644 --- a/Core/Sources/ConversationTab/ConversationTab.swift +++ b/Core/Sources/ConversationTab/ConversationTab.swift @@ -156,6 +156,7 @@ public class ConversationTab: ChatTab { self.isRestored = true } + @MainActor public func start() { observer = .init() cancellable = [] diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index 54bc5781..e99905a5 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -108,6 +108,7 @@ open class BaseChatTab { private var didStart = false private let storeObserver = NSObject() + @MainActor public init(store: StoreOf) { chatTabStore = store @@ -232,7 +233,7 @@ public class EmptyChatTab: ChatTab { struct Builder: ChatTabBuilder { let title: String func build(store: StoreOf) async -> (any ChatTab)? { - EmptyChatTab(store: store) + await EmptyChatTab(store: store) } } @@ -274,6 +275,7 @@ public class EmptyChatTab: ChatTab { return Builder(title: "Empty") } + @MainActor public convenience init(id: String) { self.init(store: .init( initialState: .init(id: id, title: "Empty-\(id)", workspacePath: "", username: ""), From bf1694c1246774513151406f68b742ebcdd62eae Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Wed, 21 May 2025 12:51:37 +0200 Subject: [PATCH 05/66] classify contents within files by suing swift-syntax library --- .../MultiFileContextManager.swift | 160 +++++++++++++++++- .../MultiFileContextManagerTests.swift | 11 +- 2 files changed, 169 insertions(+), 2 deletions(-) diff --git a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift index b52b9116..57b6cc2f 100644 --- a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift +++ b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift @@ -1,9 +1,14 @@ import Foundation import Workspace import XcodeInspector +import SwiftSyntax +import SwiftParser class MultiFileContextManager { - let workspaceProvider: WorkspaceProvider + private let workspaceProvider: WorkspaceProvider + +// private static let classificationKeywords: [String] = ["class", "struct", "enum", "actor", "protocol", "func", "var", "let"] +// private static let classificationKeywordsWithSpecialCases: [String] = ["extension", "typealias"] init(workspaceProvider: WorkspaceProvider) { self.workspaceProvider = workspaceProvider @@ -41,9 +46,162 @@ class MultiFileContextManager { } } } + + func classifyContentWithinFile() async -> [String: SymbolContent] { + let fileContents = await readFileContents() + var result: [String: SymbolContent] = [:] + + for file in fileContents { + guard let fileURL = URL(string: file.fileURL) else { continue } + do { + let sourceFile = Parser.parse(source: file.content) + let converter = SourceLocationConverter(fileName: file.fileURL, tree: sourceFile) + let collector = DeclarationCollector(sourceLocationConverter: converter, sourceText: file.content) + collector.walk(sourceFile) + result[file.fileName] = file.mapToSymbolContent(symbols: collector.symbols) + } catch { + print("SwiftSyntax parse failed for \(file.fileURL):", error) + } + } + + return result + } + } struct FileContent { let fileURL: String let content: String + + var fileName: String { + let fileNameWithExtension = String(fileURL.split(separator: "/").last ?? "") + let fileName: String = fileNameWithExtension.replacingOccurrences(of: ".swift", with: "") + return fileName + } +} + +extension FileContent { + func mapToSymbolContent(symbols: [SymbolInfo]) -> SymbolContent { + SymbolContent(fileURL: fileURL, content: content, symbols: symbols) + } +} + +struct SymbolContent { + let fileURL: String + let content: String + let symbols: [SymbolInfo] +} + +enum ClassificationKeywords: String { + case classWord = "class" + case structWord = "struct" + case enumWord = "enum" + case actorWord = "actor" + case protocolWord = "protocol" + case funcWord = "func" + case varWord = "var" + case letWord = "let" + case extensionWord = "extension" + case typealiasWord = "typealias" +} + +struct SymbolInfo { + let name: String + let kind: String + let startLine: Int + let endLine: Int + let content: String +} + +import SwiftSyntax +//import SwiftSyntaxParser + +class DeclarationCollector: SyntaxVisitor { + var symbols: [SymbolInfo] = [] + let sourceLocationConverter: SourceLocationConverter + let sourceText: String + + init(sourceLocationConverter: SourceLocationConverter, sourceText: String) { + self.sourceLocationConverter = sourceLocationConverter + self.sourceText = sourceText + super.init(viewMode: .all) + } + +// override func visit(_ node: ImportDeclSyntax) -> SyntaxVisitorContinueKind { +// recordSymbol(name: node.name.text, kind: "import", node: node) +// return .skipChildren +// } + + override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + recordSymbol(name: node.name.text, kind: ClassificationKeywords.classWord.rawValue, node: node) + return .skipChildren + } + + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + recordSymbol(name: node.name.text, kind: ClassificationKeywords.structWord.rawValue, node: node) + return .skipChildren + } + + override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { + recordSymbol(name: node.name.text, kind: ClassificationKeywords.enumWord.rawValue, node: node) + return .skipChildren + } + + override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { + recordSymbol(name: node.name.text, kind: ClassificationKeywords.protocolWord.rawValue, node: node) + return .skipChildren + } + + override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { + recordSymbol(name: node.name.text, kind: ClassificationKeywords.actorWord.rawValue, node: node) + return .skipChildren + } + + override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { + recordSymbol(name: node.name.text, kind: ClassificationKeywords.funcWord.rawValue, node: node) + return .skipChildren + } + + override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { + guard let binding = node.bindings.first, + let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else { + return .skipChildren + } + + let keyword = node.bindingSpecifier.text // "let" or "var" + recordSymbol(name: pattern.identifier.text, kind: keyword, node: node) + return .skipChildren + } + + override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind { + let name = node.extendedType.trimmedDescription + recordSymbol(name: name, kind: ClassificationKeywords.extensionWord.rawValue, node: node) + return .skipChildren + } + + override func visit(_ node: TypeAliasDeclSyntax) -> SyntaxVisitorContinueKind { + recordSymbol(name: node.name.text, kind: ClassificationKeywords.typealiasWord.rawValue, node: node) + return .skipChildren + } + + private func recordSymbol(name: String, kind: String, node: SyntaxProtocol) { + let startLoc = sourceLocationConverter.location(for: node.positionAfterSkippingLeadingTrivia) + let endLoc = sourceLocationConverter.location(for: node.endPositionBeforeTrailingTrivia) + let startLineIndex = startLoc.line - 1 + let endLineIndex = endLoc.line - 1 + + let lines = sourceText.split(separator: "\n", omittingEmptySubsequences: false) + + let contentLines = lines[startLineIndex...min(endLineIndex, lines.count - 1)] + let content = contentLines.joined(separator: "\n") + + let symbol = SymbolInfo( + name: name, + kind: kind, + startLine: startLoc.line, + endLine: endLoc.line, + content: content + ) + symbols.append(symbol) + } } diff --git a/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift index b65906f6..19bcbec0 100644 --- a/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift +++ b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift @@ -24,13 +24,22 @@ class MultiFileContextManagerTests: XCTestCase { XCTAssertNotEqual(files.count, 0) } + + func testClassifyingCode() async { + let sut = sut + let classifiedFiles = await sut.classifyContentWithinFile() + // symbols + XCTAssertNotEqual(classifiedFiles.count, 0) + } } class WorkspaceProviderMock: WorkspaceProvider { func workspace() async throws -> Workspace? { // let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/CopilotForXcode-Fork/Copilot for Xcode.xcworkspace") - let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/clean-architecture-swiftui-fork") +// let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/clean-architecture-swiftui-fork") +// let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/ios-minttv") + let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/TwitchEndpoint.swift") return Workspace(workspaceURL: workspaceURL) } } From b10f39dc392f1ecd522ae44411cc75d6c2e52c11 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Wed, 21 May 2025 14:44:34 +0200 Subject: [PATCH 06/66] use workspaceURL for better testing --- .../Service/MultiFileContext/MultiFileContextManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift index 57b6cc2f..2b25c4a7 100644 --- a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift +++ b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift @@ -20,7 +20,7 @@ class MultiFileContextManager { guard let workspace: Workspace = try? await workspaceProvider.workspace() else { return [] } var files = [String]() - if let enumerator = FileManager.default.enumerator(at: workspace.projectRootURL, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) { + if let enumerator = FileManager.default.enumerator(at: workspace.workspaceURL, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) { for case let fileURL as URL in enumerator { do { let fileAttributes = try fileURL.resourceValues(forKeys: [.isRegularFileKey]) From 3b21e6846bb95933bb406c8c1f2aae7a06864da5 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Wed, 21 May 2025 15:58:18 +0200 Subject: [PATCH 07/66] classify symbols, not files and add extension symbols to base declaration --- .../MultiFileContextManager.swift | 76 ++++++++++++------- .../MultiFileContextManagerTests.swift | 8 +- 2 files changed, 52 insertions(+), 32 deletions(-) diff --git a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift index 2b25c4a7..9fe8e2a8 100644 --- a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift +++ b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift @@ -52,21 +52,45 @@ class MultiFileContextManager { var result: [String: SymbolContent] = [:] for file in fileContents { - guard let fileURL = URL(string: file.fileURL) else { continue } - do { - let sourceFile = Parser.parse(source: file.content) - let converter = SourceLocationConverter(fileName: file.fileURL, tree: sourceFile) - let collector = DeclarationCollector(sourceLocationConverter: converter, sourceText: file.content) - collector.walk(sourceFile) - result[file.fileName] = file.mapToSymbolContent(symbols: collector.symbols) - } catch { - print("SwiftSyntax parse failed for \(file.fileURL):", error) + let sourceFile = Parser.parse(source: file.content) + let converter = SourceLocationConverter(fileName: file.fileURL, tree: sourceFile) + let collector = DeclarationCollector(sourceLocationConverter: converter, sourceText: file.content) + collector.walk(sourceFile) + var symbols: [SymbolContent] = collector.symbols.map { symbol in + SymbolContent(fileURL: file.fileURL, content: file.content, symbol: symbol) + } + mergeExtensionsIntoBaseDeclarations(&symbols) + for symbol in symbols { + result[symbol.symbol.name] = symbol } } return result } + private func mergeExtensionsIntoBaseDeclarations(_ symbols: inout [SymbolContent]) { + var indexesToRemove: [Int] = [] + + for (index, symbol) in symbols.enumerated() { + guard symbol.symbol.kind == .extensionWord else { continue } + + if let targetIndex = symbols.firstIndex(where: { + $0.symbol.name == symbol.symbol.name && + $0.symbol.kind != .extensionWord + }) { + var target = symbols[targetIndex] + target.symbol.extensions.append(symbol) + symbols[targetIndex] = target + + indexesToRemove.append(index) + } + } + + for index in indexesToRemove.sorted(by: >) { + symbols.remove(at: index) + } + } + } struct FileContent { @@ -80,16 +104,10 @@ struct FileContent { } } -extension FileContent { - func mapToSymbolContent(symbols: [SymbolInfo]) -> SymbolContent { - SymbolContent(fileURL: fileURL, content: content, symbols: symbols) - } -} - struct SymbolContent { let fileURL: String let content: String - let symbols: [SymbolInfo] + var symbol: SymbolInfo } enum ClassificationKeywords: String { @@ -107,10 +125,11 @@ enum ClassificationKeywords: String { struct SymbolInfo { let name: String - let kind: String + let kind: ClassificationKeywords let startLine: Int let endLine: Int let content: String + var extensions: [SymbolContent] = [] } import SwiftSyntax @@ -133,58 +152,57 @@ class DeclarationCollector: SyntaxVisitor { // } override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { - recordSymbol(name: node.name.text, kind: ClassificationKeywords.classWord.rawValue, node: node) + recordSymbol(name: node.name.text, kind: ClassificationKeywords.classWord, node: node) return .skipChildren } override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { - recordSymbol(name: node.name.text, kind: ClassificationKeywords.structWord.rawValue, node: node) + recordSymbol(name: node.name.text, kind: ClassificationKeywords.structWord, node: node) return .skipChildren } override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { - recordSymbol(name: node.name.text, kind: ClassificationKeywords.enumWord.rawValue, node: node) + recordSymbol(name: node.name.text, kind: ClassificationKeywords.enumWord, node: node) return .skipChildren } override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { - recordSymbol(name: node.name.text, kind: ClassificationKeywords.protocolWord.rawValue, node: node) + recordSymbol(name: node.name.text, kind: ClassificationKeywords.protocolWord, node: node) return .skipChildren } override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { - recordSymbol(name: node.name.text, kind: ClassificationKeywords.actorWord.rawValue, node: node) + recordSymbol(name: node.name.text, kind: ClassificationKeywords.actorWord, node: node) return .skipChildren } override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { - recordSymbol(name: node.name.text, kind: ClassificationKeywords.funcWord.rawValue, node: node) + recordSymbol(name: node.name.text, kind: ClassificationKeywords.funcWord, node: node) return .skipChildren } override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { guard let binding = node.bindings.first, - let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else { + let pattern = binding.pattern.as(IdentifierPatternSyntax.self), + let keyword: ClassificationKeywords = ClassificationKeywords(rawValue: node.bindingSpecifier.text) else { return .skipChildren } - - let keyword = node.bindingSpecifier.text // "let" or "var" recordSymbol(name: pattern.identifier.text, kind: keyword, node: node) return .skipChildren } override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind { let name = node.extendedType.trimmedDescription - recordSymbol(name: name, kind: ClassificationKeywords.extensionWord.rawValue, node: node) + recordSymbol(name: name, kind: ClassificationKeywords.extensionWord, node: node) return .skipChildren } override func visit(_ node: TypeAliasDeclSyntax) -> SyntaxVisitorContinueKind { - recordSymbol(name: node.name.text, kind: ClassificationKeywords.typealiasWord.rawValue, node: node) + recordSymbol(name: node.name.text, kind: ClassificationKeywords.typealiasWord, node: node) return .skipChildren } - private func recordSymbol(name: String, kind: String, node: SyntaxProtocol) { + private func recordSymbol(name: String, kind: ClassificationKeywords, node: SyntaxProtocol) { let startLoc = sourceLocationConverter.location(for: node.positionAfterSkippingLeadingTrivia) let endLoc = sourceLocationConverter.location(for: node.endPositionBeforeTrailingTrivia) let startLineIndex = startLoc.line - 1 diff --git a/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift index 19bcbec0..43d25a15 100644 --- a/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift +++ b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift @@ -27,9 +27,9 @@ class MultiFileContextManagerTests: XCTestCase { func testClassifyingCode() async { let sut = sut - let classifiedFiles = await sut.classifyContentWithinFile() + let classifiedSymbols = await sut.classifyContentWithinFile() // symbols - XCTAssertNotEqual(classifiedFiles.count, 0) + XCTAssertNotEqual(classifiedSymbols.count, 0) } } @@ -39,7 +39,9 @@ class WorkspaceProviderMock: WorkspaceProvider { // let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/CopilotForXcode-Fork/Copilot for Xcode.xcworkspace") // let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/clean-architecture-swiftui-fork") // let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/ios-minttv") - let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/TwitchEndpoint.swift") +// let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/TwitchEndpoint.swift") +// let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/Chat/IRC") + let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/Chat/Presentation/View") return Workspace(workspaceURL: workspaceURL) } } From 455a859aea990a425b9f90988a397297bf4b5a17 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Thu, 22 May 2025 18:41:44 +0200 Subject: [PATCH 08/66] refactor code and remove unneeded imports from MultiFileContextManager --- .../Entity/ClassificationKeywords.swift | 12 ++ .../MultiFileContext/Entity/FileContent.swift | 4 + .../Entity/SymbolContent.swift | 5 + .../MultiFileContext/Entity/SymbolInfo.swift | 8 + .../MultiFileContextManager.swift | 149 +----------------- .../ProgrammingLanguageSyntaxParser.swift | 18 +++ .../SwiftDeclarationCollector.swift | 90 +++++++++++ .../MultiFileContextManagerTests.swift | 3 +- 8 files changed, 143 insertions(+), 146 deletions(-) create mode 100644 Core/Sources/Service/MultiFileContext/Entity/ClassificationKeywords.swift create mode 100644 Core/Sources/Service/MultiFileContext/Entity/FileContent.swift create mode 100644 Core/Sources/Service/MultiFileContext/Entity/SymbolContent.swift create mode 100644 Core/Sources/Service/MultiFileContext/Entity/SymbolInfo.swift create mode 100644 Core/Sources/Service/MultiFileContext/ProgrammingLanguageSyntaxParser.swift create mode 100644 Core/Sources/Service/MultiFileContext/SwiftDeclarationCollector.swift diff --git a/Core/Sources/Service/MultiFileContext/Entity/ClassificationKeywords.swift b/Core/Sources/Service/MultiFileContext/Entity/ClassificationKeywords.swift new file mode 100644 index 00000000..6372bce5 --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/Entity/ClassificationKeywords.swift @@ -0,0 +1,12 @@ +enum ClassificationKeywords: String { + case classWord = "class" + case structWord = "struct" + case enumWord = "enum" + case actorWord = "actor" + case protocolWord = "protocol" + case funcWord = "func" + case varWord = "var" + case letWord = "let" + case extensionWord = "extension" + case typealiasWord = "typealias" +} diff --git a/Core/Sources/Service/MultiFileContext/Entity/FileContent.swift b/Core/Sources/Service/MultiFileContext/Entity/FileContent.swift new file mode 100644 index 00000000..102493f9 --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/Entity/FileContent.swift @@ -0,0 +1,4 @@ +struct FileContent { + let fileURL: String + let content: String +} diff --git a/Core/Sources/Service/MultiFileContext/Entity/SymbolContent.swift b/Core/Sources/Service/MultiFileContext/Entity/SymbolContent.swift new file mode 100644 index 00000000..9369d940 --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/Entity/SymbolContent.swift @@ -0,0 +1,5 @@ +struct SymbolContent { + let fileURL: String + let content: String + var symbol: SymbolInfo +} diff --git a/Core/Sources/Service/MultiFileContext/Entity/SymbolInfo.swift b/Core/Sources/Service/MultiFileContext/Entity/SymbolInfo.swift new file mode 100644 index 00000000..72b37501 --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/Entity/SymbolInfo.swift @@ -0,0 +1,8 @@ +struct SymbolInfo { + let name: String + let kind: ClassificationKeywords + let startLine: Int + let endLine: Int + let content: String + var extensions: [SymbolContent] = [] +} diff --git a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift index 9fe8e2a8..15d9154e 100644 --- a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift +++ b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift @@ -1,17 +1,13 @@ import Foundation import Workspace -import XcodeInspector -import SwiftSyntax -import SwiftParser class MultiFileContextManager { private let workspaceProvider: WorkspaceProvider + private let parser: ProgrammingLanguageSyntaxParser -// private static let classificationKeywords: [String] = ["class", "struct", "enum", "actor", "protocol", "func", "var", "let"] -// private static let classificationKeywordsWithSpecialCases: [String] = ["extension", "typealias"] - - init(workspaceProvider: WorkspaceProvider) { + init(workspaceProvider: WorkspaceProvider, parser: ProgrammingLanguageSyntaxParser) { self.workspaceProvider = workspaceProvider + self.parser = parser } /// List files within workspace recursively @@ -52,13 +48,7 @@ class MultiFileContextManager { var result: [String: SymbolContent] = [:] for file in fileContents { - let sourceFile = Parser.parse(source: file.content) - let converter = SourceLocationConverter(fileName: file.fileURL, tree: sourceFile) - let collector = DeclarationCollector(sourceLocationConverter: converter, sourceText: file.content) - collector.walk(sourceFile) - var symbols: [SymbolContent] = collector.symbols.map { symbol in - SymbolContent(fileURL: file.fileURL, content: file.content, symbol: symbol) - } + var symbols = parser.parse(file: file) mergeExtensionsIntoBaseDeclarations(&symbols) for symbol in symbols { result[symbol.symbol.name] = symbol @@ -92,134 +82,3 @@ class MultiFileContextManager { } } - -struct FileContent { - let fileURL: String - let content: String - - var fileName: String { - let fileNameWithExtension = String(fileURL.split(separator: "/").last ?? "") - let fileName: String = fileNameWithExtension.replacingOccurrences(of: ".swift", with: "") - return fileName - } -} - -struct SymbolContent { - let fileURL: String - let content: String - var symbol: SymbolInfo -} - -enum ClassificationKeywords: String { - case classWord = "class" - case structWord = "struct" - case enumWord = "enum" - case actorWord = "actor" - case protocolWord = "protocol" - case funcWord = "func" - case varWord = "var" - case letWord = "let" - case extensionWord = "extension" - case typealiasWord = "typealias" -} - -struct SymbolInfo { - let name: String - let kind: ClassificationKeywords - let startLine: Int - let endLine: Int - let content: String - var extensions: [SymbolContent] = [] -} - -import SwiftSyntax -//import SwiftSyntaxParser - -class DeclarationCollector: SyntaxVisitor { - var symbols: [SymbolInfo] = [] - let sourceLocationConverter: SourceLocationConverter - let sourceText: String - - init(sourceLocationConverter: SourceLocationConverter, sourceText: String) { - self.sourceLocationConverter = sourceLocationConverter - self.sourceText = sourceText - super.init(viewMode: .all) - } - -// override func visit(_ node: ImportDeclSyntax) -> SyntaxVisitorContinueKind { -// recordSymbol(name: node.name.text, kind: "import", node: node) -// return .skipChildren -// } - - override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { - recordSymbol(name: node.name.text, kind: ClassificationKeywords.classWord, node: node) - return .skipChildren - } - - override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { - recordSymbol(name: node.name.text, kind: ClassificationKeywords.structWord, node: node) - return .skipChildren - } - - override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { - recordSymbol(name: node.name.text, kind: ClassificationKeywords.enumWord, node: node) - return .skipChildren - } - - override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { - recordSymbol(name: node.name.text, kind: ClassificationKeywords.protocolWord, node: node) - return .skipChildren - } - - override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { - recordSymbol(name: node.name.text, kind: ClassificationKeywords.actorWord, node: node) - return .skipChildren - } - - override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { - recordSymbol(name: node.name.text, kind: ClassificationKeywords.funcWord, node: node) - return .skipChildren - } - - override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { - guard let binding = node.bindings.first, - let pattern = binding.pattern.as(IdentifierPatternSyntax.self), - let keyword: ClassificationKeywords = ClassificationKeywords(rawValue: node.bindingSpecifier.text) else { - return .skipChildren - } - recordSymbol(name: pattern.identifier.text, kind: keyword, node: node) - return .skipChildren - } - - override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind { - let name = node.extendedType.trimmedDescription - recordSymbol(name: name, kind: ClassificationKeywords.extensionWord, node: node) - return .skipChildren - } - - override func visit(_ node: TypeAliasDeclSyntax) -> SyntaxVisitorContinueKind { - recordSymbol(name: node.name.text, kind: ClassificationKeywords.typealiasWord, node: node) - return .skipChildren - } - - private func recordSymbol(name: String, kind: ClassificationKeywords, node: SyntaxProtocol) { - let startLoc = sourceLocationConverter.location(for: node.positionAfterSkippingLeadingTrivia) - let endLoc = sourceLocationConverter.location(for: node.endPositionBeforeTrailingTrivia) - let startLineIndex = startLoc.line - 1 - let endLineIndex = endLoc.line - 1 - - let lines = sourceText.split(separator: "\n", omittingEmptySubsequences: false) - - let contentLines = lines[startLineIndex...min(endLineIndex, lines.count - 1)] - let content = contentLines.joined(separator: "\n") - - let symbol = SymbolInfo( - name: name, - kind: kind, - startLine: startLoc.line, - endLine: endLoc.line, - content: content - ) - symbols.append(symbol) - } -} diff --git a/Core/Sources/Service/MultiFileContext/ProgrammingLanguageSyntaxParser.swift b/Core/Sources/Service/MultiFileContext/ProgrammingLanguageSyntaxParser.swift new file mode 100644 index 00000000..79ac0b19 --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/ProgrammingLanguageSyntaxParser.swift @@ -0,0 +1,18 @@ +import SwiftSyntax +import SwiftParser + +protocol ProgrammingLanguageSyntaxParser { + func parse(file: FileContent) -> [SymbolContent] +} + +class SwiftProgrammingLanguageSyntaxParser: ProgrammingLanguageSyntaxParser { + func parse(file: FileContent) -> [SymbolContent] { + let sourceFile = Parser.parse(source: file.content) + let converter = SourceLocationConverter(fileName: file.fileURL, tree: sourceFile) + let collector = SwiftDeclarationCollector(sourceLocationConverter: converter, sourceText: file.content) + collector.walk(sourceFile) + return collector.symbols.map { symbol in + SymbolContent(fileURL: file.fileURL, content: file.content, symbol: symbol) + } + } +} diff --git a/Core/Sources/Service/MultiFileContext/SwiftDeclarationCollector.swift b/Core/Sources/Service/MultiFileContext/SwiftDeclarationCollector.swift new file mode 100644 index 00000000..3b8d9c29 --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/SwiftDeclarationCollector.swift @@ -0,0 +1,90 @@ +import SwiftSyntax + +class SwiftDeclarationCollector: SyntaxVisitor { + var symbols: [SymbolInfo] = [] + let sourceLocationConverter: SourceLocationConverter + let sourceText: String + + init(sourceLocationConverter: SourceLocationConverter, sourceText: String) { + self.sourceLocationConverter = sourceLocationConverter + self.sourceText = sourceText + super.init(viewMode: .all) + } + +// override func visit(_ node: ImportDeclSyntax) -> SyntaxVisitorContinueKind { +// recordSymbol(name: node.name.text, kind: "import", node: node) +// return .skipChildren +// } + + override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + recordSymbol(name: node.name.text, kind: ClassificationKeywords.classWord, node: node) + return .skipChildren + } + + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + recordSymbol(name: node.name.text, kind: ClassificationKeywords.structWord, node: node) + return .skipChildren + } + + override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { + recordSymbol(name: node.name.text, kind: ClassificationKeywords.enumWord, node: node) + return .skipChildren + } + + override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { + recordSymbol(name: node.name.text, kind: ClassificationKeywords.protocolWord, node: node) + return .skipChildren + } + + override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { + recordSymbol(name: node.name.text, kind: ClassificationKeywords.actorWord, node: node) + return .skipChildren + } + + override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { + recordSymbol(name: node.name.text, kind: ClassificationKeywords.funcWord, node: node) + return .skipChildren + } + + override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { + guard let binding = node.bindings.first, + let pattern = binding.pattern.as(IdentifierPatternSyntax.self), + let keyword: ClassificationKeywords = ClassificationKeywords(rawValue: node.bindingSpecifier.text) else { + return .skipChildren + } + recordSymbol(name: pattern.identifier.text, kind: keyword, node: node) + return .skipChildren + } + + override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind { + let name = node.extendedType.trimmedDescription + recordSymbol(name: name, kind: ClassificationKeywords.extensionWord, node: node) + return .skipChildren + } + + override func visit(_ node: TypeAliasDeclSyntax) -> SyntaxVisitorContinueKind { + recordSymbol(name: node.name.text, kind: ClassificationKeywords.typealiasWord, node: node) + return .skipChildren + } + + private func recordSymbol(name: String, kind: ClassificationKeywords, node: SyntaxProtocol) { + let startLoc = sourceLocationConverter.location(for: node.positionAfterSkippingLeadingTrivia) + let endLoc = sourceLocationConverter.location(for: node.endPositionBeforeTrailingTrivia) + let startLineIndex = startLoc.line - 1 + let endLineIndex = endLoc.line - 1 + + let lines = sourceText.split(separator: "\n", omittingEmptySubsequences: false) + + let contentLines = lines[startLineIndex...min(endLineIndex, lines.count - 1)] + let content = contentLines.joined(separator: "\n") + + let symbol = SymbolInfo( + name: name, + kind: kind, + startLine: startLoc.line, + endLine: endLoc.line, + content: content + ) + symbols.append(symbol) + } +} diff --git a/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift index 43d25a15..1c855808 100644 --- a/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift +++ b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift @@ -7,7 +7,8 @@ class MultiFileContextManagerTests: XCTestCase { var sut: MultiFileContextManager { MultiFileContextManager( - workspaceProvider: WorkspaceProviderMock() + workspaceProvider: WorkspaceProviderMock(), + parser: SwiftProgrammingLanguageSyntaxParser() ) } From 1bbe3f62a29e3f7075350000ef6d122809c0cfed Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Thu, 22 May 2025 18:42:17 +0200 Subject: [PATCH 09/66] update dependency url --- .../xcshareddata/swiftpm/Package.resolved | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index 60d6595b..1ac803ab 100644 --- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -282,7 +282,7 @@ { "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax", + "location" : "https://github.com/swiftlang/swift-syntax", "state" : { "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", "version" : "510.0.2" From c4d84f063e317ad4dfff2c4f95288a3f48ad69dd Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Fri, 23 May 2025 15:21:37 +0200 Subject: [PATCH 10/66] fix crashes by only scanning swift files as intended --- .../Service/MultiFileContext/MultiFileContextManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift index 15d9154e..f02a4edb 100644 --- a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift +++ b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift @@ -20,7 +20,7 @@ class MultiFileContextManager { for case let fileURL as URL in enumerator { do { let fileAttributes = try fileURL.resourceValues(forKeys: [.isRegularFileKey]) - if fileAttributes.isRegularFile! { + if fileAttributes.isRegularFile ?? false, fileURL.pathExtension.lowercased() == "swift" { files.append(fileURL.absoluteString) } } catch { print(error, fileURL) } From 60f9985502788998590a7daed1b33f6b7bc4fe46 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Fri, 23 May 2025 15:41:14 +0200 Subject: [PATCH 11/66] move workspace url retrieval to own component as no other attribute is needed from Workspace type --- .../MultiFileContext/MultiFileContextManager.swift | 9 ++++++--- .../Service/MultiFileContext/WorkspaceProvider.swift | 10 ++++++++++ .../ServiceTests/MultiFileContextManagerTests.swift | 4 ++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift index f02a4edb..b5f50001 100644 --- a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift +++ b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift @@ -1,5 +1,4 @@ import Foundation -import Workspace class MultiFileContextManager { private let workspaceProvider: WorkspaceProvider @@ -13,10 +12,14 @@ class MultiFileContextManager { /// List files within workspace recursively /// Retrieved from: https://stackoverflow.com/a/57640445 func listFilesInWorkspace() async -> [String] { - guard let workspace: Workspace = try? await workspaceProvider.workspace() + guard let workspaceURL = try? await workspaceProvider.getProjectRootURL() else { return [] } var files = [String]() - if let enumerator = FileManager.default.enumerator(at: workspace.workspaceURL, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) { + if let enumerator = FileManager.default.enumerator( + at: workspaceURL, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) { for case let fileURL as URL in enumerator { do { let fileAttributes = try fileURL.resourceValues(forKeys: [.isRegularFileKey]) diff --git a/Core/Sources/Service/MultiFileContext/WorkspaceProvider.swift b/Core/Sources/Service/MultiFileContext/WorkspaceProvider.swift index 5e4efb89..bc2a5e75 100644 --- a/Core/Sources/Service/MultiFileContext/WorkspaceProvider.swift +++ b/Core/Sources/Service/MultiFileContext/WorkspaceProvider.swift @@ -4,6 +4,7 @@ import XcodeInspector protocol WorkspaceProvider { func workspace() async throws -> Workspace? + func getProjectRootURL() async throws -> URL } class XcodeInspectorWorkspaceProvider: WorkspaceProvider { @@ -27,4 +28,13 @@ class XcodeInspectorWorkspaceProvider: WorkspaceProvider { let tuple: (workspace: Workspace, _: Filespace)? = try await workspacePool.fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) return tuple?.workspace } + + func getProjectRootURL() async throws -> URL { + guard let workspace = try await workspace() else { throw WorkspaceError.errorGettingWorkspace } + return workspace.projectRootURL + } +} + +enum WorkspaceError: Error { + case errorGettingWorkspace } diff --git a/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift index 1c855808..49dcc584 100644 --- a/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift +++ b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift @@ -45,6 +45,10 @@ class WorkspaceProviderMock: WorkspaceProvider { let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/Chat/Presentation/View") return Workspace(workspaceURL: workspaceURL) } + + func getProjectRootURL() async throws -> URL { + URL(fileURLWithPath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/Chat/Presentation/View") + } } //private func mockFilespace() -> Filespace { From 0b46b15611a3a1760d9440e22d8e4610ac4bba9a Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Fri, 23 May 2025 15:45:19 +0200 Subject: [PATCH 12/66] replace deprecated initializer --- .../MultiFileContextManagerTests.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift index 49dcc584..5cf21ea0 100644 --- a/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift +++ b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift @@ -37,22 +37,22 @@ class MultiFileContextManagerTests: XCTestCase { class WorkspaceProviderMock: WorkspaceProvider { func workspace() async throws -> Workspace? { -// let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/CopilotForXcode-Fork/Copilot for Xcode.xcworkspace") -// let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/clean-architecture-swiftui-fork") -// let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/ios-minttv") -// let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/TwitchEndpoint.swift") -// let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/Chat/IRC") - let workspaceURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/Chat/Presentation/View") +// let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/CopilotForXcode-Fork/Copilot for Xcode.xcworkspace") +// let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/clean-architecture-swiftui-fork") +// let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/ios-minttv") +// let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/TwitchEndpoint.swift") +// let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/Chat/IRC") + let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/Chat/Presentation/View") return Workspace(workspaceURL: workspaceURL) } func getProjectRootURL() async throws -> URL { - URL(fileURLWithPath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/Chat/Presentation/View") + URL(filePath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/Chat/Presentation/View") } } //private func mockFilespace() -> Filespace { -// let fileURL = URL(fileURLWithPath: "/Users/christopherknapp/repos/CopilotForXcode-Fork/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift") +// let fileURL = URL(filePath: "/Users/christopherknapp/repos/CopilotForXcode-Fork/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift") // return Filespace(fileURL: fileURL) { filespace in // return // } onClose: { url in From a9df21874f3cdcd14e098a19c531d0e930f9aaa8 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Wed, 28 May 2025 00:45:55 +0200 Subject: [PATCH 13/66] add benchmark user interface within settings --- .../Benchmark/DI/BenchmarkModule.swift | 45 ++++++++++ .../Data/Entity/BenchmarkDirectoryDTO.swift | 6 ++ .../LocalBenchmarkSettingsRepository.swift | 83 +++++++++++++++++++ .../Domain/Entity/BenchmarkDirectory.swift | 6 ++ .../BenchmarkSettingsRepository.swift | 9 ++ .../Data/Entity/LocalStorageError.swift | 5 ++ .../UserDefaultsLocalStorageManager.swift | 30 +++++++ .../Domain/Manager/LocalStorageManager.swift | 5 ++ .../View/AddDirectoryButtonView.swift | 22 +++++ .../Presentation/View/AddDirectoryView.swift | 44 ++++++++++ .../Presentation/View/BenchmarkView.swift | 57 +++++++++++++ .../Presentation/View/DirectoryNameView.swift | 24 ++++++ .../View/OutputConfigurationButtonView.swift | 22 +++++ .../View/OutputConfigurationView.swift | 37 +++++++++ .../ViewModel/AddDirectoryViewModel.swift | 25 ++++++ .../ViewModel/BenchmarkViewModel.swift | 35 ++++++++ .../OutputConfigurationViewModel.swift | 27 ++++++ Core/Sources/HostApp/TabContainer.swift | 8 ++ 18 files changed, 490 insertions(+) create mode 100644 Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift create mode 100644 Core/Sources/HostApp/Benchmark/Data/Entity/BenchmarkDirectoryDTO.swift create mode 100644 Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift create mode 100644 Core/Sources/HostApp/Benchmark/Domain/Entity/BenchmarkDirectory.swift create mode 100644 Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift create mode 100644 Core/Sources/HostApp/Benchmark/LocalStorage/Data/Entity/LocalStorageError.swift create mode 100644 Core/Sources/HostApp/Benchmark/LocalStorage/Data/Manager/UserDefaultsLocalStorageManager.swift create mode 100644 Core/Sources/HostApp/Benchmark/LocalStorage/Domain/Manager/LocalStorageManager.swift create mode 100644 Core/Sources/HostApp/Benchmark/Presentation/View/AddDirectoryButtonView.swift create mode 100644 Core/Sources/HostApp/Benchmark/Presentation/View/AddDirectoryView.swift create mode 100644 Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift create mode 100644 Core/Sources/HostApp/Benchmark/Presentation/View/DirectoryNameView.swift create mode 100644 Core/Sources/HostApp/Benchmark/Presentation/View/OutputConfigurationButtonView.swift create mode 100644 Core/Sources/HostApp/Benchmark/Presentation/View/OutputConfigurationView.swift create mode 100644 Core/Sources/HostApp/Benchmark/Presentation/ViewModel/AddDirectoryViewModel.swift create mode 100644 Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift create mode 100644 Core/Sources/HostApp/Benchmark/Presentation/ViewModel/OutputConfigurationViewModel.swift diff --git a/Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift b/Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift new file mode 100644 index 00000000..b59edef0 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift @@ -0,0 +1,45 @@ +protocol BenchmarkModuleType { + func provide() -> BenchmarkViewModel + func provide() -> OutputConfigurationViewModel + func provide() -> AddDirectoryViewModel +} + +class BenchmarkModule: BenchmarkModuleType { + private var benchmarkSettingsRepository: BenchmarkSettingsRepository? + + static let shared: BenchmarkModuleType = BenchmarkModule() + + private func component() -> LocalStorageManager { + UserDefaultsLocalStorageManager() + } + + private func component() -> BenchmarkSettingsRepository { + if let repository = benchmarkSettingsRepository { + return repository + } else { + let repository = LocalBenchmarkSettingsRepository( + localStorageManager: component() + ) + benchmarkSettingsRepository = repository + return repository + } + } + + func provide() -> BenchmarkViewModel { + BenchmarkViewModel( + benchmarkSettingsRepository: component() + ) + } + + func provide() -> OutputConfigurationViewModel { + OutputConfigurationViewModel( + benchmarkSettingsRepository: component() + ) + } + + func provide() -> AddDirectoryViewModel { + AddDirectoryViewModel( + benchmarkSettingsRepository: component() + ) + } +} diff --git a/Core/Sources/HostApp/Benchmark/Data/Entity/BenchmarkDirectoryDTO.swift b/Core/Sources/HostApp/Benchmark/Data/Entity/BenchmarkDirectoryDTO.swift new file mode 100644 index 00000000..9538e9c6 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Data/Entity/BenchmarkDirectoryDTO.swift @@ -0,0 +1,6 @@ +import Foundation + +struct BenchmarkDirectoryDTO: Codable { + let name: String + let url: URL +} diff --git a/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift b/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift new file mode 100644 index 00000000..f7ee06cb --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift @@ -0,0 +1,83 @@ +import Combine + +class LocalBenchmarkSettingsRepository: BenchmarkSettingsRepository { + private let localStorageManager: LocalStorageManager + private let benchmarkDirectoriesKey = "benchmarkDirectories" + private let benchmarkOutputDirectoryKey = "benchmarkOutputDirectory" + private let defaultOutputDirectory = "~Desktop/benchmark_output" + + private let currentBenchmarkDirectories: CurrentValueSubject<[BenchmarkDirectory], Never> = CurrentValueSubject([]) + var benchmarkDirectories: AnyPublisher<[BenchmarkDirectory], Never> { + currentBenchmarkDirectories.eraseToAnyPublisher() + } + + private let benchmarkOutputDirectory: CurrentValueSubject = CurrentValueSubject("") + var outputDirectory: AnyPublisher { + benchmarkOutputDirectory.eraseToAnyPublisher() + } + + init(localStorageManager: LocalStorageManager) { + self.localStorageManager = localStorageManager + if let benchmarkDirectories = try? retrieveBenchmarkDirectories() { + currentBenchmarkDirectories.send(benchmarkDirectories) + } + if let outputDirectory = try? loadBenchmarkOutputDirectory() { + benchmarkOutputDirectory.send(outputDirectory) + } + } + + func saveBenchmarkDirectory(_ directory: BenchmarkDirectory) throws { + let currentEntries = currentBenchmarkDirectories.value + let updatedEntries = currentEntries + [directory] + let data = updatedEntries.mapToDTOs() + try localStorageManager.save(codable: data, key: benchmarkDirectoriesKey) + currentBenchmarkDirectories.send(updatedEntries) + } + + func deleteBenchmarkDirectory(_ directory: BenchmarkDirectory) throws { + var currentEntries = currentBenchmarkDirectories.value + currentEntries.removeAll { $0 == directory } + let data = currentEntries.mapToDTOs() + try localStorageManager.save(codable: data, key: benchmarkDirectoriesKey) + currentBenchmarkDirectories.send(currentEntries) + } + + private func retrieveBenchmarkDirectories() throws -> [BenchmarkDirectory] { + let data: [BenchmarkDirectoryDTO] = try localStorageManager.load(key: benchmarkDirectoriesKey) + return data.map { $0.mapToDomain() } + } + + func saveBenchmarkOutputDirectory(_ directory: String) throws { + try localStorageManager.save(codable: directory, key: benchmarkOutputDirectoryKey) + benchmarkOutputDirectory.send(directory) + } + + private func loadBenchmarkOutputDirectory() throws -> String { + do { + return try localStorageManager.load(key: benchmarkOutputDirectoryKey) + } catch LocalStorageError.noDataForKey { + try saveBenchmarkOutputDirectory(defaultOutputDirectory) + return defaultOutputDirectory + } + + } + +} + +extension Array where Element == BenchmarkDirectory { + func mapToDTOs() -> [BenchmarkDirectoryDTO] { + return self.map { $0.mapToDTO() } + } +} + +extension BenchmarkDirectory { + func mapToDTO() -> BenchmarkDirectoryDTO { + return BenchmarkDirectoryDTO(name: name, url: url) + } +} + +extension BenchmarkDirectoryDTO { + func mapToDomain() -> BenchmarkDirectory { + return BenchmarkDirectory(name: name, url: url) + } +} diff --git a/Core/Sources/HostApp/Benchmark/Domain/Entity/BenchmarkDirectory.swift b/Core/Sources/HostApp/Benchmark/Domain/Entity/BenchmarkDirectory.swift new file mode 100644 index 00000000..16de0d01 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Domain/Entity/BenchmarkDirectory.swift @@ -0,0 +1,6 @@ +import Foundation + +struct BenchmarkDirectory: Hashable { + let name: String + let url: URL +} diff --git a/Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift b/Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift new file mode 100644 index 00000000..00631216 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift @@ -0,0 +1,9 @@ +import Combine + +protocol BenchmarkSettingsRepository { + var benchmarkDirectories: AnyPublisher<[BenchmarkDirectory], Never> { get } + var outputDirectory: AnyPublisher { get } + func saveBenchmarkDirectory(_ directory: BenchmarkDirectory) throws + func deleteBenchmarkDirectory(_ directory: BenchmarkDirectory) throws + func saveBenchmarkOutputDirectory(_ directory: String) throws +} diff --git a/Core/Sources/HostApp/Benchmark/LocalStorage/Data/Entity/LocalStorageError.swift b/Core/Sources/HostApp/Benchmark/LocalStorage/Data/Entity/LocalStorageError.swift new file mode 100644 index 00000000..e6b646eb --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/LocalStorage/Data/Entity/LocalStorageError.swift @@ -0,0 +1,5 @@ +enum LocalStorageError: Error { + case encodingError + case noDataForKey + case decodingError +} diff --git a/Core/Sources/HostApp/Benchmark/LocalStorage/Data/Manager/UserDefaultsLocalStorageManager.swift b/Core/Sources/HostApp/Benchmark/LocalStorage/Data/Manager/UserDefaultsLocalStorageManager.swift new file mode 100644 index 00000000..c689a4de --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/LocalStorage/Data/Manager/UserDefaultsLocalStorageManager.swift @@ -0,0 +1,30 @@ +import Foundation + +class UserDefaultsLocalStorageManager: LocalStorageManager { + + private let userDefaults: UserDefaults = UserDefaults() + + func save(codable: T, key: String) throws { + let encoder = JSONEncoder() + guard let encoded = try? encoder.encode(codable) else { + throw LocalStorageError.encodingError + } + userDefaults.set(encoded, forKey: key) + } + + func load(key: String) throws -> T { + guard let data = userDefaults.data(forKey: key) else { + throw LocalStorageError.noDataForKey + } + let decoder = JSONDecoder() + guard let decoded = try? decoder.decode(T.self, from: data) else { + throw LocalStorageError.decodingError + } + return decoded + } + + func delete(key: String) { + userDefaults.removeObject(forKey: key) + } + +} diff --git a/Core/Sources/HostApp/Benchmark/LocalStorage/Domain/Manager/LocalStorageManager.swift b/Core/Sources/HostApp/Benchmark/LocalStorage/Domain/Manager/LocalStorageManager.swift new file mode 100644 index 00000000..47e4c64d --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/LocalStorage/Domain/Manager/LocalStorageManager.swift @@ -0,0 +1,5 @@ +protocol LocalStorageManager { + func save(codable: T, key: String) throws + func load(key: String) throws -> T + func delete(key: String) +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/AddDirectoryButtonView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/AddDirectoryButtonView.swift new file mode 100644 index 00000000..7799d57d --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/AddDirectoryButtonView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct AddDirectoryButtonView: View { + private let module: BenchmarkModuleType + @State private var isShowingSheet = false + + init(module: BenchmarkModuleType) { + self.module = module + } + + var body: some View { + Button { + isShowingSheet.toggle() + } label : { + Label("add directory", systemImage: "plus") + .foregroundStyle(.foreground) // to remove green accent color from system image + } + .sheet(isPresented: $isShowingSheet) { + AddDirectoryView(module: module) + } + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/AddDirectoryView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/AddDirectoryView.swift new file mode 100644 index 00000000..77b34296 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/AddDirectoryView.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct AddDirectoryView: View { + @State private var currentDirectoryName: String = "" + @State private var currentDirectory: String = "" + @StateObject var viewModel: AddDirectoryViewModel + @Environment(\.dismiss) private var dismiss + + private let labelWidth: CGFloat = 100 + + init(module: BenchmarkModuleType) { + _viewModel = StateObject(wrappedValue: module.provide()) + } + + var body: some View { + VStack { + HStack { + Text("Name") + .frame(width: labelWidth, alignment: .leading) + TextField("Name", text: $currentDirectoryName, prompt: Text("Project X")) + .textFieldStyle(PlainTextFieldStyle()) + } + HStack { + Text("Directory") + .frame(width: labelWidth, alignment: .leading) + TextField("Directory", text: $currentDirectory, prompt: Text("/path/to/directory")) + .textFieldStyle(PlainTextFieldStyle()) + } + + } + .padding() + + HStack { + Button("Cancel") { + dismiss() + } + Button("Save") { + viewModel.saveNewDirectory(name: currentDirectoryName, directory: currentDirectory) + dismiss() + } + } + .padding([.horizontal, .bottom]) + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift new file mode 100644 index 00000000..5b1ac28a --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct BenchmarkView: View { + private let module: BenchmarkModuleType + @StateObject private var viewModel: BenchmarkViewModel + + init(module: BenchmarkModuleType) { + self.module = module + _viewModel = StateObject(wrappedValue: module.provide()) + } + + var body: some View { + ScrollView { + VStack { + HStack { + Text("Run Benchmark") + .font(.title) + .frame(maxWidth: .infinity, alignment: .leading) + OutputConfigurationButtonView(module: module) + } + .padding(.vertical) + VStack { + ForEach(viewModel.benchmarkDirectories, id: \.self) { directory in + HStack { + DirectoryNameView(directory: directory) + .frame(maxWidth: .infinity, alignment: .leading) + Button { + viewModel.runBenchmark(for: directory) + } label: { + Image(systemName: "play") + } + Button { + NSWorkspace.shared.open(directory.url) + } label: { + Image(systemName: "folder") + } + Button { + viewModel.deleteDirectory(directory) + } label: { + Image(systemName: "trash") + .foregroundColor(.red) + } + } + } + } + AddDirectoryButtonView(module: module) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding() + } + .onAppear { + viewModel.loadBenchmarkDirectories() + } + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/DirectoryNameView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/DirectoryNameView.swift new file mode 100644 index 00000000..9e9d576a --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/DirectoryNameView.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct DirectoryNameView: View { + let directory: BenchmarkDirectory + @State private var isPressed = false + + var body: some View { + Text(isPressed ? directory.url.path : directory.name) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged({ _ in + withAnimation { + isPressed = true + } + }) + .onEnded({ _ in + withAnimation { + isPressed = false + } + }) + ) + .animation(.easeInOut(duration: 0.2), value: isPressed) + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/OutputConfigurationButtonView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/OutputConfigurationButtonView.swift new file mode 100644 index 00000000..7ca5e3f2 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/OutputConfigurationButtonView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct OutputConfigurationButtonView: View { + private let module: BenchmarkModuleType + @State private var isShowingSheet = false + + init(module: BenchmarkModuleType) { + self.module = module + } + + var body: some View { + Button { + isShowingSheet.toggle() + } label : { + Label("Configure Output", systemImage: "gear") + } + .sheet(isPresented: $isShowingSheet) { + OutputConfigurationView(module: module) + } + } +} + diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/OutputConfigurationView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/OutputConfigurationView.swift new file mode 100644 index 00000000..4d4b47d9 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/OutputConfigurationView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct OutputConfigurationView: View { + @State private var currentOutputDirectory: String = "" + @StateObject var viewModel: OutputConfigurationViewModel + @Environment(\.dismiss) private var dismiss + + private let labelWidth: CGFloat = 100 + + init(module: BenchmarkModuleType) { + self._viewModel = StateObject(wrappedValue: module.provide()) + } + + var body: some View { + HStack { + Text("Output Directory:") + .frame(width: labelWidth, alignment: .leading) + TextField("Output Directory", text: $currentOutputDirectory, prompt: Text("Directory")) + .textFieldStyle(PlainTextFieldStyle()) + + } + .padding() + HStack { + Button("Cancel") { + dismiss() + } + Button("Save") { + viewModel.saveOutputDirectory(currentOutputDirectory) + dismiss() + } + } + .padding([.horizontal, .bottom]) + .onAppear { + currentOutputDirectory = viewModel.outputDirectory + } + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/AddDirectoryViewModel.swift b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/AddDirectoryViewModel.swift new file mode 100644 index 00000000..6dc9a8f5 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/AddDirectoryViewModel.swift @@ -0,0 +1,25 @@ +import Foundation +import Toast + +class AddDirectoryViewModel: ObservableObject { + private let benchmarkSettingsRepository: BenchmarkSettingsRepository + private var toast: ToastController { ToastControllerDependencyKey.liveValue } + + init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { + self.benchmarkSettingsRepository = benchmarkSettingsRepository + } + + func saveNewDirectory(name: String, directory: String) { + do { + try benchmarkSettingsRepository.saveBenchmarkDirectory( + BenchmarkDirectory( + name: name, + url: URL(fileURLWithPath: directory) + ) + ) + toast.toast(content: "Directory added successfully.", level: .info) + } catch { + toast.toast(content: "Failed adding directory.", level: .error) + } + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift new file mode 100644 index 00000000..2586af79 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift @@ -0,0 +1,35 @@ +import Combine +import Toast + +class BenchmarkViewModel: ObservableObject { + @Published var benchmarkDirectories: [BenchmarkDirectory] = [] + + private let benchmarkSettingsRepository: BenchmarkSettingsRepository + private var cancellables = Set() + private var toast: ToastController { ToastControllerDependencyKey.liveValue } + + init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { + self.benchmarkSettingsRepository = benchmarkSettingsRepository + } + + func loadBenchmarkDirectories() { + benchmarkSettingsRepository.benchmarkDirectories + .assign(to: \.benchmarkDirectories, on: self) + .store(in: &cancellables) + } + + func runBenchmark(for directory: BenchmarkDirectory) { + // TODO: Implement the logic to run the benchmark for the given directory + print("Running benchmark for \(directory.url.path)") + } + + + func deleteDirectory(_ directory: BenchmarkDirectory) { + do { + try benchmarkSettingsRepository.deleteBenchmarkDirectory(directory) + toast.toast(content: "Directory deleted successfully.", level: .info) + } catch { + toast.toast(content: "Failed deleting directory.", level: .error) + } + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/OutputConfigurationViewModel.swift b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/OutputConfigurationViewModel.swift new file mode 100644 index 00000000..8bbe400d --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/OutputConfigurationViewModel.swift @@ -0,0 +1,27 @@ +import Combine +import Toast + +class OutputConfigurationViewModel: ObservableObject { + @Published var outputDirectory: String = "" + private var cancellables = Set() + private var toast: ToastController { ToastControllerDependencyKey.liveValue } + + private let benchmarkSettingsRepository: BenchmarkSettingsRepository + + init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { + self.benchmarkSettingsRepository = benchmarkSettingsRepository + benchmarkSettingsRepository.outputDirectory + .assign(to: \.outputDirectory, on: self) + .store(in: &cancellables) + } + + func saveOutputDirectory(_ directory: String) { + do { + try benchmarkSettingsRepository.saveBenchmarkOutputDirectory(directory) + toast.toast(content: "Output Directory changed.", level: .info) + } catch { + toast.toast(content: "Failed changing output directory.", level: .error) + } + } +} + diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 3a4bb494..d068eb6f 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -14,6 +14,9 @@ public struct TabContainer: View { @ObservedObject var toastController: ToastController @State private var tabBarItems = [TabBarItem]() @Binding var tag: Int + /// currently needs to be a Singleton as the containing code appears to have a bug, what might also + /// be the reason why `hostAppStore` is declared on script level + private let benchmarkModule: BenchmarkModuleType = BenchmarkModule.shared public init() { toastController = ToastControllerDependencyKey.liveValue @@ -56,6 +59,11 @@ public struct TabContainer: View { title: "MCP", image: "wrench.and.screwdriver.fill" ) + BenchmarkView(module: benchmarkModule).tabBarItem( + tag: 3, + title: "Benchmark", + image: "testtube.2" + ) } .environment(\.tabBarTabTag, tag) .frame(minHeight: 400) From e92298fda195b52d20d37558704383d12f16e510 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Wed, 28 May 2025 00:46:15 +0200 Subject: [PATCH 14/66] update swift syntax lib location --- .../xcshareddata/swiftpm/Package.resolved | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1ac803ab..60d6595b 100644 --- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -282,7 +282,7 @@ { "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax", + "location" : "https://github.com/apple/swift-syntax", "state" : { "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", "version" : "510.0.2" From 165a5fb43b0b59e82362795a0202fd65c462783c Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Wed, 28 May 2025 16:22:23 +0200 Subject: [PATCH 15/66] set text color of path to gray --- .../HostApp/Benchmark/Presentation/View/DirectoryNameView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/DirectoryNameView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/DirectoryNameView.swift index 9e9d576a..035bb830 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/View/DirectoryNameView.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/DirectoryNameView.swift @@ -3,9 +3,11 @@ import SwiftUI struct DirectoryNameView: View { let directory: BenchmarkDirectory @State private var isPressed = false + @Environment(\.colorScheme) var colorScheme var body: some View { Text(isPressed ? directory.url.path : directory.name) + .foregroundStyle(isPressed ? Color.gray : colorScheme == .dark ? Color.white : Color.black) .gesture( DragGesture(minimumDistance: 0) .onChanged({ _ in From db5ab06d4a82b3711b18e49c6170c4a5f7e7227f Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Mon, 2 Jun 2025 16:31:40 +0200 Subject: [PATCH 16/66] fix default directory --- .../Data/Repository/LocalBenchmarkSettingsRepository.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift b/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift index f7ee06cb..797382b0 100644 --- a/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift +++ b/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift @@ -4,7 +4,7 @@ class LocalBenchmarkSettingsRepository: BenchmarkSettingsRepository { private let localStorageManager: LocalStorageManager private let benchmarkDirectoriesKey = "benchmarkDirectories" private let benchmarkOutputDirectoryKey = "benchmarkOutputDirectory" - private let defaultOutputDirectory = "~Desktop/benchmark_output" + private let defaultOutputDirectory = "~/Desktop/benchmark_output" private let currentBenchmarkDirectories: CurrentValueSubject<[BenchmarkDirectory], Never> = CurrentValueSubject([]) var benchmarkDirectories: AnyPublisher<[BenchmarkDirectory], Never> { From 89edd1181c2072804710293fb98fefbf5fef6233 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Mon, 2 Jun 2025 17:22:32 +0200 Subject: [PATCH 17/66] use safe home directory through FileManager --- .../LocalBenchmarkSettingsRepository.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift b/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift index 797382b0..cc682dbb 100644 --- a/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift +++ b/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift @@ -1,11 +1,16 @@ +import Foundation import Combine class LocalBenchmarkSettingsRepository: BenchmarkSettingsRepository { private let localStorageManager: LocalStorageManager private let benchmarkDirectoriesKey = "benchmarkDirectories" private let benchmarkOutputDirectoryKey = "benchmarkOutputDirectory" - private let defaultOutputDirectory = "~/Desktop/benchmark_output" + private var defaultOutputDirectory: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Desktop/benchmark_output") + } + private let currentBenchmarkDirectories: CurrentValueSubject<[BenchmarkDirectory], Never> = CurrentValueSubject([]) var benchmarkDirectories: AnyPublisher<[BenchmarkDirectory], Never> { currentBenchmarkDirectories.eraseToAnyPublisher() @@ -56,12 +61,11 @@ class LocalBenchmarkSettingsRepository: BenchmarkSettingsRepository { do { return try localStorageManager.load(key: benchmarkOutputDirectoryKey) } catch LocalStorageError.noDataForKey { - try saveBenchmarkOutputDirectory(defaultOutputDirectory) - return defaultOutputDirectory + let defaultPath = defaultOutputDirectory.path + try saveBenchmarkOutputDirectory(defaultPath) + return defaultPath } - } - } extension Array where Element == BenchmarkDirectory { From fc1b0f9299ad8045061c464ae22a36925c20cc60 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Mon, 2 Jun 2025 17:23:22 +0200 Subject: [PATCH 18/66] add directory picker for convenient choosing --- .../View/OutputConfigurationView.swift | 55 +++++++++++++------ 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/OutputConfigurationView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/OutputConfigurationView.swift index 4d4b47d9..295be135 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/View/OutputConfigurationView.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/OutputConfigurationView.swift @@ -5,33 +5,56 @@ struct OutputConfigurationView: View { @StateObject var viewModel: OutputConfigurationViewModel @Environment(\.dismiss) private var dismiss - private let labelWidth: CGFloat = 100 + private let labelWidth: CGFloat = 120 init(module: BenchmarkModuleType) { self._viewModel = StateObject(wrappedValue: module.provide()) } var body: some View { - HStack { - Text("Output Directory:") - .frame(width: labelWidth, alignment: .leading) - TextField("Output Directory", text: $currentOutputDirectory, prompt: Text("Directory")) - .textFieldStyle(PlainTextFieldStyle()) - - } - .padding() - HStack { - Button("Cancel") { - dismiss() + VStack { + HStack(alignment: .top) { + Text("Output Directory:") + .frame(width: labelWidth, alignment: .leading) + VStack { + TextField("Output Directory", text: $currentOutputDirectory, prompt: Text("Directory")) + .textFieldStyle(PlainTextFieldStyle()) + Button { + selectDirectory() + } label: { + Label("Select...", systemImage: "folder") + } + .padding() + } } - Button("Save") { - viewModel.saveOutputDirectory(currentOutputDirectory) - dismiss() + .padding() + HStack { + Button("Cancel") { + dismiss() + } + Button("Save") { + viewModel.saveOutputDirectory(currentOutputDirectory) + dismiss() + } } + .padding([.horizontal, .bottom]) } - .padding([.horizontal, .bottom]) .onAppear { currentOutputDirectory = viewModel.outputDirectory } } + + private func selectDirectory() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: currentOutputDirectory) + + if panel.runModal() == .OK { + if let url = panel.url { + currentOutputDirectory = url.path + } + } + } } From 3c58d63f6d101818f2184e8f5ca22cbed7c3af37 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Sun, 13 Jul 2025 20:37:46 +0200 Subject: [PATCH 19/66] WIP get code suggestions programmatically --- Core/Package.swift | 227 +++++++++--------- .../LocalBenchmarkSettingsRepository.swift | 4 + .../Domain/Manager/BenchmarkManager.swift | 188 +++++++++++++++ .../BenchmarkSettingsRepository.swift | 1 + .../Presentation/View/BenchmarkView.swift | 4 +- .../ViewModel/BenchmarkViewModel.swift | 5 +- .../PseudoCommandHandler.swift | 54 ++++- .../LanguageServer/GitHubCopilotService.swift | 2 +- Tool/Sources/Workspace/Workspace.swift | 2 +- 9 files changed, 362 insertions(+), 125 deletions(-) create mode 100644 Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift diff --git a/Core/Package.swift b/Core/Package.swift index f326fdaa..c37b0a6d 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -23,7 +23,7 @@ let package = Package( .library( name: "Client", targets: [ - "Client", + "Client" ] ), .library( @@ -50,15 +50,17 @@ let package = Package( ), // quick hack to support custom UserDefaults // https://github.com/sindresorhus/KeyboardShortcuts - .package(url: "https://github.com/devm33/KeyboardShortcuts", branch: "main"), - .package(url: "https://github.com/devm33/CGEventOverride", branch: "devm33/fix-stale-AXIsProcessTrusted"), + .package(url: "https://github.com/devm33/KeyboardShortcuts", branch: "main"), + .package( + url: "https://github.com/devm33/CGEventOverride", + branch: "devm33/fix-stale-AXIsProcessTrusted"), .package(url: "https://github.com/devm33/Highlightr", branch: "master"), .package(url: "https://github.com/globulus/swiftui-flow-layout", from: "1.0.5"), .package(url: "https://github.com/apple/swift-syntax", exact: "510.0.2"), ], targets: [ // MARK: - Main - + .target( name: "Client", dependencies: [ @@ -79,6 +81,7 @@ let package = Package( "ConversationTab", "KeyBindingManager", "XcodeThemeController", + "SuggestionInjector", .product(name: "TelemetryService", package: "Tool"), .product(name: "XPCShared", package: "Tool"), .product(name: "SuggestionProvider", package: "Tool"), @@ -114,39 +117,40 @@ let package = Package( .product(name: "ConversationServiceProvider", package: "Tool"), ] ), - + // MARK: - Host App - - .target( - name: "HostApp", - dependencies: [ - "Client", - "LaunchAgentManager", - "GitHubCopilotViewModel", - .product(name: "SuggestionProvider", package: "Tool"), - .product(name: "Toast", package: "Tool"), - .product(name: "SharedUIComponents", package: "Tool"), - .product(name: "SuggestionBasic", package: "Tool"), - .product(name: "MarkdownUI", package: "swift-markdown-ui"), - .product(name: "ChatAPIService", package: "Tool"), - .product(name: "Preferences", package: "Tool"), - .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"), - .product(name: "GitHubCopilotService", package: "Tool"), - ]), - + + .target( + name: "HostApp", + dependencies: [ + "Service", + "Client", + "LaunchAgentManager", + "GitHubCopilotViewModel", + .product(name: "SuggestionProvider", package: "Tool"), + .product(name: "Toast", package: "Tool"), + .product(name: "SharedUIComponents", package: "Tool"), + .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "MarkdownUI", package: "swift-markdown-ui"), + .product(name: "ChatAPIService", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"), + .product(name: "GitHubCopilotService", package: "Tool"), + ]), + // MARK: - Suggestion Service - - .target( - name: "SuggestionService", - dependencies: [ - .product(name: "UserDefaultsObserver", package: "Tool"), - .product(name: "Preferences", package: "Tool"), - .product(name: "SuggestionBasic", package: "Tool"), - .product(name: "SuggestionProvider", package: "Tool"), - .product(name: "BuiltinExtension", package: "Tool"), - .product(name: "GitHubCopilotService", package: "Tool"), - ]), + + .target( + name: "SuggestionService", + dependencies: [ + .product(name: "UserDefaultsObserver", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "SuggestionProvider", package: "Tool"), + .product(name: "BuiltinExtension", package: "Tool"), + .product(name: "GitHubCopilotService", package: "Tool"), + ]), .target( name: "SuggestionInjector", dependencies: [.product(name: "SuggestionBasic", package: "Tool")] @@ -155,89 +159,89 @@ let package = Package( name: "SuggestionInjectorTests", dependencies: ["SuggestionInjector"] ), - + // MARK: - Prompt To Code - - .target( - name: "PromptToCodeService", - dependencies: [ - .product(name: "SuggestionBasic", package: "Tool"), - .product(name: "ChatAPIService", package: "Tool"), - .product(name: "AppMonitoring", package: "Tool"), - .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - ]), - + + .target( + name: "PromptToCodeService", + dependencies: [ + .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "ChatAPIService", package: "Tool"), + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ]), + // MARK: - Chat - - .target( - name: "ChatService", - dependencies: [ - "PersistMiddleware", - .product(name: "AppMonitoring", package: "Tool"), - .product(name: "Parsing", package: "swift-parsing"), - .product(name: "ChatAPIService", package: "Tool"), - .product(name: "Preferences", package: "Tool"), - .product(name: "AXHelper", package: "Tool"), - .product(name: "ConversationServiceProvider", package: "Tool"), - .product(name: "GitHubCopilotService", package: "Tool"), - .product(name: "Workspace", package: "Tool"), - .product(name: "Terminal", package: "Tool") - ]), - .testTarget( - name: "ChatServiceTests", - dependencies: ["ChatService"] - ), - .target( - name: "ConversationTab", - dependencies: [ - "ChatService", - .product(name: "SharedUIComponents", package: "Tool"), - .product(name: "ChatAPIService", package: "Tool"), - .product(name: "Logger", package: "Tool"), - .product(name: "ChatTab", package: "Tool"), - .product(name: "Terminal", package: "Tool"), - .product(name: "Cache", package: "Tool"), - .product(name: "MarkdownUI", package: "swift-markdown-ui"), - .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - .product(name: "SwiftUIFlowLayout", package: "swiftui-flow-layout"), - .product(name: "Persist", package: "Tool"), - .product(name: "Terminal", package: "Tool") - ] - ), - + .target( + name: "ChatService", + dependencies: [ + "PersistMiddleware", + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "Parsing", package: "swift-parsing"), + .product(name: "ChatAPIService", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "AXHelper", package: "Tool"), + .product(name: "ConversationServiceProvider", package: "Tool"), + .product(name: "GitHubCopilotService", package: "Tool"), + .product(name: "Workspace", package: "Tool"), + .product(name: "Terminal", package: "Tool"), + ]), + .testTarget( + name: "ChatServiceTests", + dependencies: ["ChatService"] + ), + + .target( + name: "ConversationTab", + dependencies: [ + "ChatService", + .product(name: "SharedUIComponents", package: "Tool"), + .product(name: "ChatAPIService", package: "Tool"), + .product(name: "Logger", package: "Tool"), + .product(name: "ChatTab", package: "Tool"), + .product(name: "Terminal", package: "Tool"), + .product(name: "Cache", package: "Tool"), + .product(name: "MarkdownUI", package: "swift-markdown-ui"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "SwiftUIFlowLayout", package: "swiftui-flow-layout"), + .product(name: "Persist", package: "Tool"), + .product(name: "Terminal", package: "Tool"), + ] + ), + // MARK: - UI - - .target( - name: "SuggestionWidget", - dependencies: [ - "PromptToCodeService", - "ConversationTab", - "GitHubCopilotViewModel", - "PersistMiddleware", - .product(name: "GitHubCopilotService", package: "Tool"), - .product(name: "Toast", package: "Tool"), - .product(name: "UserDefaultsObserver", package: "Tool"), - .product(name: "SharedUIComponents", package: "Tool"), - .product(name: "AppMonitoring", package: "Tool"), - .product(name: "ChatTab", package: "Tool"), - .product(name: "Logger", package: "Tool"), - .product(name: "CustomAsyncAlgorithms", package: "Tool"), - .product(name: "HostAppActivator", package: "Tool"), - .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), - .product(name: "MarkdownUI", package: "swift-markdown-ui"), - .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - ] - ), + + .target( + name: "SuggestionWidget", + dependencies: [ + "PromptToCodeService", + "ConversationTab", + "GitHubCopilotViewModel", + "PersistMiddleware", + .product(name: "GitHubCopilotService", package: "Tool"), + .product(name: "Toast", package: "Tool"), + .product(name: "UserDefaultsObserver", package: "Tool"), + .product(name: "SharedUIComponents", package: "Tool"), + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "ChatTab", package: "Tool"), + .product(name: "Logger", package: "Tool"), + .product(name: "CustomAsyncAlgorithms", package: "Tool"), + .product(name: "HostAppActivator", package: "Tool"), + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + .product(name: "MarkdownUI", package: "swift-markdown-ui"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), .testTarget(name: "SuggestionWidgetTests", dependencies: ["SuggestionWidget"]), - + // MARK: - Helpers - + .target(name: "FileChangeChecker"), .target( name: "LaunchAgentManager", dependencies: [ - .product(name: "Logger", package: "Tool"), + .product(name: "Logger", package: "Tool") ] ), .target( @@ -286,7 +290,7 @@ let package = Package( .product(name: "Highlightr", package: "Highlightr"), ] ), - + // MARK: Persist Middleware .target( name: "PersistMiddleware", @@ -294,9 +298,8 @@ let package = Package( .product(name: "Persist", package: "Tool"), .product(name: "ChatTab", package: "Tool"), .product(name: "ChatAPIService", package: "Tool"), - .product(name: "ConversationServiceProvider", package: "Tool") + .product(name: "ConversationServiceProvider", package: "Tool"), ] - ) + ), ] ) - diff --git a/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift b/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift index cc682dbb..db5457ae 100644 --- a/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift +++ b/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift @@ -5,6 +5,7 @@ class LocalBenchmarkSettingsRepository: BenchmarkSettingsRepository { private let localStorageManager: LocalStorageManager private let benchmarkDirectoriesKey = "benchmarkDirectories" private let benchmarkOutputDirectoryKey = "benchmarkOutputDirectory" + let projectRootURL: String = "/Users/christopherknapp/repos/ModernCleanArchitectureSwiftUI/" private var defaultOutputDirectory: URL { FileManager.default.homeDirectoryForCurrentUser @@ -66,6 +67,9 @@ class LocalBenchmarkSettingsRepository: BenchmarkSettingsRepository { return defaultPath } } + + private func deleteBenchmarkOutputDirectory() { + } } extension Array where Element == BenchmarkDirectory { diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift new file mode 100644 index 00000000..e951cab6 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -0,0 +1,188 @@ +import Foundation +import AppKit +import Dependencies +import Workspace +import Service +import XcodeInspector +import PromptToCodeService +import SuggestionBasic +import SuggestionProvider +import SuggestionService +import WorkspaceSuggestionService +import CopilotForXcodeKit + +import BuiltinExtension +import GitHubCopilotService + +protocol BenchmarkManager { + +} + +class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { + private let benchmarkSettingsRepository: BenchmarkSettingsRepository + @WorkspaceActor + private let workspacePool: WorkspacePool + + init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { + @Dependency(\.workspacePool) var workspacePool +// workspacePool.registerPlugin { +// SuggestionServiceWorkspacePlugin(workspace: $0) { SuggestionService.service() } +// } + BuiltinExtensionManager.shared.setupExtensions([ + GitHubCopilotExtension(workspacePool: workspacePool) + ]) +// scheduledCleaner = .init() + workspacePool.registerPlugin { + SuggestionServiceWorkspacePlugin(workspace: $0) { SuggestionService.service() } + } + workspacePool.registerPlugin { + GitHubCopilotWorkspacePlugin(workspace: $0) + } + workspacePool.registerPlugin { + BuiltinExtensionWorkspacePlugin(workspace: $0) + } + self.workspacePool = workspacePool + self.benchmarkSettingsRepository = benchmarkSettingsRepository + } + + func getCodeSuggestions(at benchmarkDirectory: BenchmarkDirectory) async throws { + let taskPaths: [URL] = getTaskFolders(in: benchmarkDirectory.url) + for taskPath in taskPaths { + let suggestion = await getCodeSuggestionFromService(at: taskPath, from: benchmarkDirectory.url) + } + } + + func getCodeSuggestionFromService(at directory: URL, from benchmarkDirectory: URL) async -> SuggestionBasic.CodeSuggestion? { + guard let metadata: MetadataDTO = readMetadata(at: directory) else { return nil } + let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkSettingsRepository.projectRootURL) + let tuple: (workspace: Workspace, _: Filespace)? = try? await workspacePool.fetchOrCreateWorkspaceAndFilespace(fileURL: entrypoint.fileURL) + guard let workspace = tuple?.workspace, + let filespace = try? await workspace.createFilespaceIfNeeded(fileURL: entrypoint.fileURL) + else { + return nil + } + await workspace.didOpenFilespace(filespace) + await filespace.bumpVersion() + let content: String = (try? String(contentsOf: entrypoint.fileURL, encoding: .utf8)) ?? "" + let suggestionRequest = SuggestionProvider.SuggestionRequest( + fileURL: entrypoint.fileURL, + relativePath: entrypoint.fileURL.path.replacingOccurrences(of: benchmarkDirectory.path, with: ""), + content: content, + originalContent: content, + lines: content.linesWithNewlineSuffix, + cursorPosition: entrypoint.cursor, + cursorOffset: content.cursorOffset(line: entrypoint.cursor.line, character: entrypoint.cursor.character) ?? 0, + tabSize: 4, + indentSize: 4, + usesTabsForIndentation: false, + relevantCodeSnippets: [] + ) + // TODO: use the broader one + let workspaceInfo = WorkspaceInfo(workspaceURL: benchmarkDirectory, projectURL: benchmarkDirectory) + do { + // only works when setting document version GitHubCopilotService to 0 + let suggestions = try await workspace.suggestionService?.getSuggestions(suggestionRequest, workspaceInfo: workspaceInfo) + return suggestions?.first + } catch { + print("CK \(error)") + return nil + } +// return nil + } + + func readMetadata(at taskFolder: URL) -> MetadataDTO? { + let metadataURL = taskFolder.appendingPathComponent("metadata.json") + do { + let data = try Data(contentsOf: metadataURL) + let metadata = try JSONDecoder().decode(MetadataDTO.self, from: data) + return metadata + } catch { + print("Failed to read metadata at \(metadataURL):", error) + return nil + } + } + + func getTaskFolders(in rootDirectory: URL) -> [URL] { + let fileManager = FileManager.default + var taskFolders: [URL] = [] + + let regex = try! NSRegularExpression(pattern: #"Task-\d+"#) + + let enumerator = fileManager.enumerator(at: rootDirectory, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) + + while let fileURL = enumerator?.nextObject() as? URL { + do { + let resourceValues = try fileURL.resourceValues(forKeys: [.isDirectoryKey]) + if resourceValues.isDirectory == true { + let folderName = fileURL.lastPathComponent + let range = NSRange(location: 0, length: folderName.utf16.count) + if regex.firstMatch(in: folderName, options: [], range: range) != nil { + taskFolders.append(fileURL) + } + } + } catch { + print("Failed to read directory attributes for \(fileURL):", error) + } + } + + return taskFolders.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) + } + + +} + +struct MetadataDTO: Codable { + let taskId: Int + let entrypoint: EntrypointDTO + let files: [String] + + private enum CodingKeys: String, CodingKey { + case taskId = "task_id" + case entrypoint + case files + } + + struct EntrypointDTO: Codable { + let filename: String + let cursor: CursorPositionDTO + } + + struct CursorPositionDTO: Codable { + let line: Int + let character: Int + } +} + +extension MetadataDTO { + func mapToEntrypoint(prefixing baseUrl: String) -> EntryPoint { + EntryPoint( + cursor: CursorPosition( + line: entrypoint.cursor.line, + character: entrypoint.cursor.character + ), + fileURL: URL(fileURLWithPath: baseUrl+entrypoint.filename) + ) + } +} + +extension String { + /// Splits the string by line and appends `\n` to each non-empty line + var linesWithNewlineSuffix: [String] { + self.split(separator: "\n", omittingEmptySubsequences: false) + .map { String($0) + "\n" } + } + + /// Calculates the character offset in the string from a line number and character index within that line + func cursorOffset(line: Int, character: Int) -> Int? { + let lines = self.split(separator: "\n", omittingEmptySubsequences: false) + guard line < lines.count else { return nil } + + // Sum up the lengths of all previous lines (+1 for each '\n') + let offsetBeforeLine = lines.prefix(line).reduce(0) { $0 + $1.count + 1 } + + // Ensure the character index doesn't exceed the current line length + guard character <= lines[line].count else { return nil } + + return offsetBeforeLine + character + } +} diff --git a/Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift b/Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift index 00631216..e9ab897f 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift @@ -6,4 +6,5 @@ protocol BenchmarkSettingsRepository { func saveBenchmarkDirectory(_ directory: BenchmarkDirectory) throws func deleteBenchmarkDirectory(_ directory: BenchmarkDirectory) throws func saveBenchmarkOutputDirectory(_ directory: String) throws + var projectRootURL: String { get } } diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift index 5b1ac28a..ffa6817a 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift @@ -25,7 +25,9 @@ struct BenchmarkView: View { DirectoryNameView(directory: directory) .frame(maxWidth: .infinity, alignment: .leading) Button { - viewModel.runBenchmark(for: directory) + Task { + try? await viewModel.runBenchmark(for: directory) + } } label: { Image(systemName: "play") } diff --git a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift index 2586af79..65e8f2ec 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift @@ -5,11 +5,13 @@ class BenchmarkViewModel: ObservableObject { @Published var benchmarkDirectories: [BenchmarkDirectory] = [] private let benchmarkSettingsRepository: BenchmarkSettingsRepository + private let benchmarkManager: RealtimeSuggestionControllerBenchmarkManager private var cancellables = Set() private var toast: ToastController { ToastControllerDependencyKey.liveValue } init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { self.benchmarkSettingsRepository = benchmarkSettingsRepository + self.benchmarkManager = RealtimeSuggestionControllerBenchmarkManager(benchmarkSettingsRepository: benchmarkSettingsRepository) } func loadBenchmarkDirectories() { @@ -18,9 +20,10 @@ class BenchmarkViewModel: ObservableObject { .store(in: &cancellables) } - func runBenchmark(for directory: BenchmarkDirectory) { + func runBenchmark(for directory: BenchmarkDirectory) async throws { // TODO: Implement the logic to run the benchmark for the given directory print("Running benchmark for \(directory.url.path)") + try await benchmarkManager.getCodeSuggestions(at: directory) } diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index 2ad3e765..225f11e2 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -14,11 +14,19 @@ import AXHelper /// It's used to run some commands without really triggering the menu bar item. /// /// For example, we can use it to generate real-time suggestions without Apple Scripts. -struct PseudoCommandHandler { +public struct PseudoCommandHandler { static var lastTimeCommandFailedToTriggerWithAccessibilityAPI = Date(timeIntervalSince1970: 0) static var lastBundleNotFoundTime = Date(timeIntervalSince1970: 0) static var lastBundleDisabledTime = Date(timeIntervalSince1970: 0) private var toast: ToastController { ToastControllerDependencyKey.liveValue } + + public init() { } + + private func workspace(for filespace: Filespace) async -> Workspace? { + let workspacePool: WorkspacePool = await Service.shared.workspacePool + let tuple: (workspace: Workspace, _: Filespace)? = try? await workspacePool.fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) + return tuple?.workspace + } func presentPreviousSuggestion() async { let handler = WindowBaseCommandHandler() @@ -51,15 +59,16 @@ struct PseudoCommandHandler { } @WorkspaceActor - func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async { - guard let filespace = await getFilespace(), + public func generateRealtimeSuggestions(sourceEditor: SourceEditor?, entrypoint: EntryPoint? = nil) async { + // Copilot Suggestions + guard let filespace = await getFilespace(for: entrypoint?.fileURL), let (workspace, _) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) else { return } if Task.isCancelled { return } // Can't use handler if content is not available. - guard let editor = await getEditorContent(sourceEditor: sourceEditor) + guard let editor = await getEditorContent(sourceEditor: sourceEditor, entrypoint: entrypoint) else { return } let fileURL = filespace.fileURL @@ -399,9 +408,15 @@ extension PseudoCommandHandler { } @WorkspaceActor - func getFilespace() async -> Filespace? { + func getFilespace(for file: URL? = nil) async -> Filespace? { guard - let fileURL = await getFileURL(), + let fileURL = await { + if let file { + return file + } else { + return await getFileURL() + } + }(), let (_, filespace) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return nil } @@ -409,19 +424,31 @@ extension PseudoCommandHandler { } @WorkspaceActor - func getEditorContent(sourceEditor: SourceEditor?) async -> EditorContent? { - guard let filespace = await getFilespace(), + public func getEditorContent(sourceEditor: SourceEditor?, entrypoint: EntryPoint? = nil) async -> EditorContent? { + guard let filespace = await getFilespace(for: entrypoint?.fileURL), let sourceEditor = await { if let sourceEditor { sourceEditor } else { await XcodeInspector.shared.safe.focusedEditor } }() else { return nil } + print("CK filespace \(filespace)") + print("CK sourceEditor \(sourceEditor )") if Task.isCancelled { return nil } - let content = sourceEditor.getContent() + var content = sourceEditor.getContent() let uti = filespace.codeMetadata.uti ?? "" let tabSize = filespace.codeMetadata.tabSize ?? 4 let indentSize = filespace.codeMetadata.indentSize ?? 4 let usesTabsForIndentation = filespace.codeMetadata.usesTabsForIndentation ?? false + let multiFileContextManager = MultiFileContextManager( + workspaceProvider: XcodeInspectorWorkspaceProvider(), + parser: SwiftProgrammingLanguageSyntaxParser() + ) + let classifiedSymbols = await multiFileContextManager.classifyContentWithinFile() + if let entrypoint, + let fileContent = try? String(contentsOf: entrypoint.fileURL, encoding: .utf8) { + content.content = fileContent + content.cursorPosition = entrypoint.cursor + } return .init( content: content.content, lines: content.lines, @@ -438,3 +465,12 @@ extension PseudoCommandHandler { } } +public struct EntryPoint { + public let cursor: CursorPosition + public let fileURL: URL + + public init(cursor: CursorPosition, fileURL: URL) { + self.cursor = cursor + self.fileURL = fileURL + } +} diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 310ab959..1af0cc58 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -442,7 +442,7 @@ public final class GitHubCopilotService: do { let completions = try await self .sendRequest(GitHubCopilotRequest.InlineCompletion(doc: .init( - textDocument: .init(uri: fileURL.absoluteString, version: 1), + textDocument: .init(uri: fileURL.absoluteString, version: 0), position: cursorPosition, formattingOptions: .init( tabSize: tabSize, diff --git a/Tool/Sources/Workspace/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift index 82248822..bb88344e 100644 --- a/Tool/Sources/Workspace/Workspace.swift +++ b/Tool/Sources/Workspace/Workspace.swift @@ -189,7 +189,7 @@ public final class Workspace { } @WorkspaceActor - func didOpenFilespace(_ filespace: Filespace) { + public func didOpenFilespace(_ filespace: Filespace) { refreshUpdateTime() openedFileRecoverableStorage.openFile(fileURL: filespace.fileURL) for plugin in plugins.values { From bdf8ac9725885a10ba25e10bcf6407d900d0ab04 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Fri, 25 Jul 2025 16:00:56 +0200 Subject: [PATCH 20/66] apply example code suggestion --- .../Domain/Manager/BenchmarkManager.swift | 134 ++++++++++++++++-- Core/Sources/Service/ScheduledCleaner.swift | 6 +- .../Workspace+Cleanup.swift | 4 +- 3 files changed, 131 insertions(+), 13 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index e951cab6..d6af1ee2 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -22,6 +22,7 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { private let benchmarkSettingsRepository: BenchmarkSettingsRepository @WorkspaceActor private let workspacePool: WorkspacePool + private let scheduledCleaner: ScheduledCleaner init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { @Dependency(\.workspacePool) var workspacePool @@ -43,26 +44,34 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { } self.workspacePool = workspacePool self.benchmarkSettingsRepository = benchmarkSettingsRepository + self.scheduledCleaner = .init() } func getCodeSuggestions(at benchmarkDirectory: BenchmarkDirectory) async throws { let taskPaths: [URL] = getTaskFolders(in: benchmarkDirectory.url) - for taskPath in taskPaths { - let suggestion = await getCodeSuggestionFromService(at: taskPath, from: benchmarkDirectory.url) + for taskPath in taskPaths.prefix(1) { + if let suggestion = await getCodeSuggestionFromService(at: taskPath, from: benchmarkDirectory.url) { + await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) + } + } } - func getCodeSuggestionFromService(at directory: URL, from benchmarkDirectory: URL) async -> SuggestionBasic.CodeSuggestion? { + func getCodeSuggestionFromService(at directory: URL, from benchmarkDirectory: URL) async -> SuggestionResponse? { guard let metadata: MetadataDTO = readMetadata(at: directory) else { return nil } let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkSettingsRepository.projectRootURL) - let tuple: (workspace: Workspace, _: Filespace)? = try? await workspacePool.fetchOrCreateWorkspaceAndFilespace(fileURL: entrypoint.fileURL) + let tuple: (workspace: Workspace, filespace: Filespace)? = try? await workspacePool.fetchOrCreateWorkspaceAndFilespace(fileURL: entrypoint.fileURL) guard let workspace = tuple?.workspace, - let filespace = try? await workspace.createFilespaceIfNeeded(fileURL: entrypoint.fileURL) +// let filespace = try? await workspace.createFilespaceIfNeeded(fileURL: entrypoint.fileURL) + let filespace = tuple?.filespace else { return nil } +// workspace. + await filespace.reset() + await cleanUp() await workspace.didOpenFilespace(filespace) - await filespace.bumpVersion() +// await filespace.bumpVersion() let content: String = (try? String(contentsOf: entrypoint.fileURL, encoding: .utf8)) ?? "" let suggestionRequest = SuggestionProvider.SuggestionRequest( fileURL: entrypoint.fileURL, @@ -81,8 +90,14 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { let workspaceInfo = WorkspaceInfo(workspaceURL: benchmarkDirectory, projectURL: benchmarkDirectory) do { // only works when setting document version GitHubCopilotService to 0 - let suggestions = try await workspace.suggestionService?.getSuggestions(suggestionRequest, workspaceInfo: workspaceInfo) - return suggestions?.first +// let suggestions = try await workspace.suggestionService?.getSuggestions(suggestionRequest, workspaceInfo: workspaceInfo) + let suggestions: [SuggestionBasic.CodeSuggestion]? = [exampleSuggestion] + await workspace.closeFilespace(fileURL: URL(fileURLWithPath: metadata.entrypoint.filename)) + guard let suggestions, let firstSuggestion = suggestions.first else { return nil } + return .init( + suggestion: firstSuggestion, + fileURL: entrypoint.fileURL + ) } catch { print("CK \(error)") return nil @@ -90,10 +105,89 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { // return nil } + func applyCodeSuggestion(suggestion: SuggestionBasic.CodeSuggestion, at fileURL: URL) async { + do { + var content = try String(contentsOf: fileURL, encoding: .utf8) + var lines = content.components(separatedBy: .newlines) + + let start = suggestion.range.start + let end = suggestion.range.end + + // Check range bounds + guard start.line < lines.count, end.line < lines.count else { + print("Invalid range for suggestion in file: \(fileURL)") + return + } + + // Modify the content between start and end + if start.line == end.line { + var line = lines[start.line] + let startIndex = line.index(line.startIndex, offsetBy: start.character) + let endIndex = line.index(line.startIndex, offsetBy: end.character) + line.replaceSubrange(startIndex.. MetadataDTO? { let metadataURL = taskFolder.appendingPathComponent("metadata.json") do { let data = try Data(contentsOf: metadataURL) + let metadata = try JSONDecoder().decode(MetadataDTO.self, from: data) return metadata } catch { @@ -186,3 +280,27 @@ extension String { return offsetBeforeLine + character } } + +let exampleSuggestion = SuggestionBasic.CodeSuggestion( + id: "452c2f9a-c0e3-4a9e-8ae2-bf8babf5c634", + text: " func fetch() async { \n do {\n let page = try await useCase.fetch(request: request, page: 1)\n movies = page.results\n } catch {\n errorToast.show()\n }", + position: .init( + line: 27, + character: 24 + ), + range: .init( + start: .init( + line: 27, + character: 0 + ), + end: .init( + line: 27, + character: 24 + ) + ) +) + +struct SuggestionResponse { + let suggestion: SuggestionBasic.CodeSuggestion + let fileURL: URL +} diff --git a/Core/Sources/Service/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift index 2178ba50..4a0a3d0c 100644 --- a/Core/Sources/Service/ScheduledCleaner.swift +++ b/Core/Sources/Service/ScheduledCleaner.swift @@ -10,9 +10,9 @@ import XcodeInspector public final class ScheduledCleaner { weak var service: Service? - init() {} + public init() {} - func start() { + public func start() { Task { @ServiceActor in while !Task.isCancelled { try await Task.sleep(nanoseconds: 10 * 60 * 1_000_000_000) @@ -31,7 +31,7 @@ public final class ScheduledCleaner { } @ServiceActor - func cleanUp() async { + public func cleanUp() async { guard let service else { return } let workspaceInfos = XcodeInspector.shared.xcodes.reduce( diff --git a/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift b/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift index d154aade..2f0bb047 100644 --- a/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift +++ b/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift @@ -5,7 +5,7 @@ import WorkspaceSuggestionService extension Workspace { @WorkspaceActor - func cleanUp(availableTabs: Set) { + public func cleanUp(availableTabs: Set) { for (fileURL, _) in filespaces { if isFilespaceExpired(fileURL: fileURL, availableTabs: availableTabs) { openedFileRecoverableStorage.closeFile(fileURL: fileURL) @@ -14,7 +14,7 @@ extension Workspace { } } - func isFilespaceExpired(fileURL: URL, availableTabs: Set) -> Bool { + public func isFilespaceExpired(fileURL: URL, availableTabs: Set) -> Bool { let filename = fileURL.lastPathComponent if availableTabs.contains(filename) { return false } guard let filespace = filespaces[fileURL] else { return true } From 0c9b3d8b677bd42b8f40756fd8e7564769d717c9 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Fri, 25 Jul 2025 19:26:16 +0200 Subject: [PATCH 21/66] fix suggestion service from mostly returning empty array --- .../Domain/Manager/BenchmarkManager.swift | 75 +++++-------------- 1 file changed, 19 insertions(+), 56 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index d6af1ee2..d4f96b5a 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -22,17 +22,12 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { private let benchmarkSettingsRepository: BenchmarkSettingsRepository @WorkspaceActor private let workspacePool: WorkspacePool - private let scheduledCleaner: ScheduledCleaner init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { @Dependency(\.workspacePool) var workspacePool -// workspacePool.registerPlugin { -// SuggestionServiceWorkspacePlugin(workspace: $0) { SuggestionService.service() } -// } BuiltinExtensionManager.shared.setupExtensions([ GitHubCopilotExtension(workspacePool: workspacePool) ]) -// scheduledCleaner = .init() workspacePool.registerPlugin { SuggestionServiceWorkspacePlugin(workspace: $0) { SuggestionService.service() } } @@ -44,7 +39,6 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { } self.workspacePool = workspacePool self.benchmarkSettingsRepository = benchmarkSettingsRepository - self.scheduledCleaner = .init() } func getCodeSuggestions(at benchmarkDirectory: BenchmarkDirectory) async throws { @@ -58,20 +52,11 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { } func getCodeSuggestionFromService(at directory: URL, from benchmarkDirectory: URL) async -> SuggestionResponse? { - guard let metadata: MetadataDTO = readMetadata(at: directory) else { return nil } + guard let metadata: MetadataDTO = readMetadata(at: directory), + let xcodeWorkspaceFileURL = findXcodeWorkspace(in: benchmarkDirectory), + let workspace = try? await workspacePool.fetchOrCreateWorkspace(workspaceURL: xcodeWorkspaceFileURL) + else { return nil } let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkSettingsRepository.projectRootURL) - let tuple: (workspace: Workspace, filespace: Filespace)? = try? await workspacePool.fetchOrCreateWorkspaceAndFilespace(fileURL: entrypoint.fileURL) - guard let workspace = tuple?.workspace, -// let filespace = try? await workspace.createFilespaceIfNeeded(fileURL: entrypoint.fileURL) - let filespace = tuple?.filespace - else { - return nil - } -// workspace. - await filespace.reset() - await cleanUp() - await workspace.didOpenFilespace(filespace) -// await filespace.bumpVersion() let content: String = (try? String(contentsOf: entrypoint.fileURL, encoding: .utf8)) ?? "" let suggestionRequest = SuggestionProvider.SuggestionRequest( fileURL: entrypoint.fileURL, @@ -86,12 +71,11 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { usesTabsForIndentation: false, relevantCodeSnippets: [] ) - // TODO: use the broader one - let workspaceInfo = WorkspaceInfo(workspaceURL: benchmarkDirectory, projectURL: benchmarkDirectory) + let workspaceInfo = WorkspaceInfo(workspaceURL: xcodeWorkspaceFileURL, projectURL: benchmarkDirectory) do { // only works when setting document version GitHubCopilotService to 0 -// let suggestions = try await workspace.suggestionService?.getSuggestions(suggestionRequest, workspaceInfo: workspaceInfo) - let suggestions: [SuggestionBasic.CodeSuggestion]? = [exampleSuggestion] + let suggestions = try await workspace.suggestionService?.getSuggestions(suggestionRequest, workspaceInfo: workspaceInfo) +// let suggestions: [SuggestionBasic.CodeSuggestion]? = [exampleSuggestion] await workspace.closeFilespace(fileURL: URL(fileURLWithPath: metadata.entrypoint.filename)) guard let suggestions, let firstSuggestion = suggestions.first else { return nil } return .init( @@ -151,38 +135,6 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { } } - public func cleanUp() async { -// guard let service else { return } - - let workspaceInfos = XcodeInspector.shared.xcodes.reduce( - into: [ - XcodeAppInstanceInspector.WorkspaceIdentifier: - XcodeAppInstanceInspector.WorkspaceInfo - ]() - ) { result, xcode in - let infos = xcode.realtimeWorkspaces - for (id, info) in infos { - if let existed = result[id] { - result[id] = existed.combined(with: info) - } else { - result[id] = info - } - } - } - for (url, workspace) in await workspacePool.workspaces { - if workspace.isExpired, workspaceInfos[.url(url)] == nil { -// Logger.service.info("Remove idle workspace") - await workspace.cleanUp(availableTabs: []) - await workspacePool.removeWorkspace(url: url) - } else { - let tabs = (workspaceInfos[.url(url)]?.tabs ?? []) - .union(workspaceInfos[.unknown]?.tabs ?? []) - // cleanup workspace - await workspace.cleanUp(availableTabs: tabs) - } - } - } - func readMetadata(at taskFolder: URL) -> MetadataDTO? { let metadataURL = taskFolder.appendingPathComponent("metadata.json") do { @@ -222,7 +174,18 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { return taskFolders.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) } - + func findXcodeWorkspace(in directory: URL) -> URL? { + let fileManager = FileManager.default + let enumerator = fileManager.enumerator(at: directory, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) + + while let fileURL = enumerator?.nextObject() as? URL { + if fileURL.pathExtension == "xcworkspace" { + return fileURL + } + } + + return nil + } } struct MetadataDTO: Codable { From 38154b8b7c86729c623e6c730fe3c13e1b5abfab Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Sat, 26 Jul 2025 15:32:30 +0200 Subject: [PATCH 22/66] remove hardcoded test url --- .../Repository/LocalBenchmarkSettingsRepository.swift | 1 - .../Benchmark/Domain/Manager/BenchmarkManager.swift | 11 ++++++++--- .../Repository/BenchmarkSettingsRepository.swift | 1 - 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift b/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift index db5457ae..a9bb6852 100644 --- a/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift +++ b/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift @@ -5,7 +5,6 @@ class LocalBenchmarkSettingsRepository: BenchmarkSettingsRepository { private let localStorageManager: LocalStorageManager private let benchmarkDirectoriesKey = "benchmarkDirectories" private let benchmarkOutputDirectoryKey = "benchmarkOutputDirectory" - let projectRootURL: String = "/Users/christopherknapp/repos/ModernCleanArchitectureSwiftUI/" private var defaultOutputDirectory: URL { FileManager.default.homeDirectoryForCurrentUser diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index d4f96b5a..914987bf 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -56,7 +56,7 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { let xcodeWorkspaceFileURL = findXcodeWorkspace(in: benchmarkDirectory), let workspace = try? await workspacePool.fetchOrCreateWorkspace(workspaceURL: xcodeWorkspaceFileURL) else { return nil } - let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkSettingsRepository.projectRootURL) + let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkDirectory.path) let content: String = (try? String(contentsOf: entrypoint.fileURL, encoding: .utf8)) ?? "" let suggestionRequest = SuggestionProvider.SuggestionRequest( fileURL: entrypoint.fileURL, @@ -212,12 +212,17 @@ struct MetadataDTO: Codable { extension MetadataDTO { func mapToEntrypoint(prefixing baseUrl: String) -> EntryPoint { - EntryPoint( + // Ensure there's exactly one "/" between the base and the filename + let cleanedBase = baseUrl.hasSuffix("/") ? baseUrl : baseUrl + "/" + let cleanedFilename = entrypoint.filename.hasPrefix("/") ? String(entrypoint.filename.dropFirst()) : entrypoint.filename + let fullPath = cleanedBase + cleanedFilename + + return EntryPoint( cursor: CursorPosition( line: entrypoint.cursor.line, character: entrypoint.cursor.character ), - fileURL: URL(fileURLWithPath: baseUrl+entrypoint.filename) + fileURL: URL(fileURLWithPath: fullPath) ) } } diff --git a/Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift b/Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift index e9ab897f..00631216 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift @@ -6,5 +6,4 @@ protocol BenchmarkSettingsRepository { func saveBenchmarkDirectory(_ directory: BenchmarkDirectory) throws func deleteBenchmarkDirectory(_ directory: BenchmarkDirectory) throws func saveBenchmarkOutputDirectory(_ directory: String) throws - var projectRootURL: String { get } } From 670cd6904d8ef7bb7a7750d1ad53d32f30ec7e44 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Sat, 26 Jul 2025 16:45:45 +0200 Subject: [PATCH 23/66] store suggestion info in output folder --- .../Domain/Manager/BenchmarkManager.swift | 99 ++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index 914987bf..ef6cd94c 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -14,6 +14,9 @@ import CopilotForXcodeKit import BuiltinExtension import GitHubCopilotService +import Combine + + protocol BenchmarkManager { } @@ -43,9 +46,10 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { func getCodeSuggestions(at benchmarkDirectory: BenchmarkDirectory) async throws { let taskPaths: [URL] = getTaskFolders(in: benchmarkDirectory.url) - for taskPath in taskPaths.prefix(1) { + for (index, taskPath) in taskPaths.prefix(1).enumerated() { if let suggestion = await getCodeSuggestionFromService(at: taskPath, from: benchmarkDirectory.url) { await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) + await storeContentInOutputDirectory(suggestion, for: index+1) } } @@ -135,6 +139,40 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { } } + func storeContentInOutputDirectory(_ suggestion: SuggestionResponse, for taskNumber: Int) async { + guard let outputDirPath = await benchmarkSettingsRepository.outputDirectory.firstValue() else { + print("Could not retrieve output directory.") + return + } + let fileManager = FileManager.default + + let resolvedOutputDirPath = outputDirPath.replacingOccurrences(of: "~", with: fileManager.homeDirectoryForCurrentUser.path) + let outputDir = URL(fileURLWithPath: resolvedOutputDirPath) + + var isDirectory: ObjCBool = false + if !fileManager.fileExists(atPath: outputDir.path, isDirectory: &isDirectory) || !isDirectory.boolValue { + do { + try fileManager.createDirectory(at: outputDir, withIntermediateDirectories: true, attributes: nil) + } catch { + print("Failed to create output directory:", error) + return + } + } + + let timestamp = ISO8601DateFormatter().string(from: Date()) + let reformattedTimestamp = timestamp.replacingOccurrences(of: ":", with: "-") + let outputFileURL = outputDir.appendingPathComponent("Task-\(taskNumber)-\(reformattedTimestamp).json") + + do { + let dto = suggestion.toStoredDTO(timestamp: timestamp) + let data = try JSONEncoder().encode(dto) + try data.write(to: outputFileURL) + print("Stored suggestion at: \(outputFileURL.path)") + } catch { + print("Failed to write suggestion file:", error) + } + } + func readMetadata(at taskFolder: URL) -> MetadataDTO? { let metadataURL = taskFolder.appendingPathComponent("metadata.json") do { @@ -272,3 +310,62 @@ struct SuggestionResponse { let suggestion: SuggestionBasic.CodeSuggestion let fileURL: URL } + + +extension AnyPublisher where Failure == Never { + /// Awaits the first emitted value of the publisher (only for Failure == Never) + func firstValue() async -> Output? { + await withCheckedContinuation { continuation in + var cancellable: AnyCancellable? + cancellable = self.first().sink { value in + continuation.resume(returning: value) + cancellable?.cancel() + } + } + } +} + + +struct StoredSuggestionDTO: Codable { + let fileURL: String + let id: String + let suggestionText: String + let position: CursorPositionDTO + let range: CursorRangeDTO + let createdAt: String + + struct CursorPositionDTO: Codable { + let line: Int + let character: Int + } + + struct CursorRangeDTO: Codable { + let start: CursorPositionDTO + let end: CursorPositionDTO + } +} + +extension SuggestionResponse { + func toStoredDTO(timestamp: String) -> StoredSuggestionDTO { + StoredSuggestionDTO( + fileURL: fileURL.path, + id: suggestion.id, + suggestionText: suggestion.text, + position: .init( + line: suggestion.position.line, + character: suggestion.position.character + ), + range: .init( + start: .init( + line: suggestion.range.start.line, + character: suggestion.range.start.character + ), + end: .init( + line: suggestion.range.end.line, + character: suggestion.range.end.character + ) + ), + createdAt: timestamp + ) + } +} From 8559c73e9d876810be74f9f057a12ee9729b0f2f Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Sun, 27 Jul 2025 16:28:38 +0200 Subject: [PATCH 24/66] add named benchmark directory --- .../Domain/Manager/BenchmarkManager.swift | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index ef6cd94c..f4e23a93 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -49,7 +49,7 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { for (index, taskPath) in taskPaths.prefix(1).enumerated() { if let suggestion = await getCodeSuggestionFromService(at: taskPath, from: benchmarkDirectory.url) { await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) - await storeContentInOutputDirectory(suggestion, for: index+1) + await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) } } @@ -139,7 +139,11 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { } } - func storeContentInOutputDirectory(_ suggestion: SuggestionResponse, for taskNumber: Int) async { + func storeContentInOutputDirectory( + _ suggestion: SuggestionResponse, + for taskNumber: Int, + in benchmarkDirectory: BenchmarkDirectory + ) async { guard let outputDirPath = await benchmarkSettingsRepository.outputDirectory.firstValue() else { print("Could not retrieve output directory.") return @@ -159,9 +163,20 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { } } + var isBenchmarkDirADirectory: ObjCBool = false + let outputBenchmarkDir = outputDir.appendingPathComponent(benchmarkDirectory.name, isDirectory: true) + if !fileManager.fileExists(atPath: outputBenchmarkDir.path, isDirectory: &isBenchmarkDirADirectory) || !isBenchmarkDirADirectory.boolValue { + do { + try fileManager.createDirectory(at: outputBenchmarkDir, withIntermediateDirectories: true, attributes: nil) + } catch { + print("Failed to create output directory:", error) + return + } + } + let timestamp = ISO8601DateFormatter().string(from: Date()) let reformattedTimestamp = timestamp.replacingOccurrences(of: ":", with: "-") - let outputFileURL = outputDir.appendingPathComponent("Task-\(taskNumber)-\(reformattedTimestamp).json") + let outputFileURL = outputBenchmarkDir.appendingPathComponent("Task-\(taskNumber)-\(reformattedTimestamp).json") do { let dto = suggestion.toStoredDTO(timestamp: timestamp) From 6ff7579fe275d8c53b55ce3f1987873cb148b6d4 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Sun, 27 Jul 2025 18:34:08 +0200 Subject: [PATCH 25/66] add multi file context to benchmark --- .../Domain/Manager/BenchmarkManager.swift | 37 ++++++++++++++++++- .../Entity/ClassificationKeywords.swift | 2 +- .../MultiFileContext/Entity/FileContent.swift | 2 +- .../Entity/SymbolContent.swift | 8 ++-- .../MultiFileContext/Entity/SymbolInfo.swift | 14 +++---- .../MultiFileContextManager.swift | 23 +++++++++--- .../ProgrammingLanguageSyntaxParser.swift | 9 +++-- .../MultiFileContext/WorkspaceProvider.swift | 18 ++++++++- .../PseudoCommandHandler.swift | 10 ----- .../MultiFileContextManagerTests.swift | 2 +- 10 files changed, 90 insertions(+), 35 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index f4e23a93..6465f282 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -62,6 +62,13 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { else { return nil } let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkDirectory.path) let content: String = (try? String(contentsOf: entrypoint.fileURL, encoding: .utf8)) ?? "" + + let multiFileContextManager = MultiFileContextManager( + workspaceProvider: ManualWorkspaceProvider(workspace: workspace), + parser: SwiftProgrammingLanguageSyntaxParser() + ) + let relevantSymbols = await multiFileContextManager.retrieveRelevantSymbolsForFileContent(content: content) + let suggestionRequest = SuggestionProvider.SuggestionRequest( fileURL: entrypoint.fileURL, relativePath: entrypoint.fileURL.path.replacingOccurrences(of: benchmarkDirectory.path, with: ""), @@ -84,7 +91,8 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { guard let suggestions, let firstSuggestion = suggestions.first else { return nil } return .init( suggestion: firstSuggestion, - fileURL: entrypoint.fileURL + fileURL: entrypoint.fileURL, + relevantSymbolsFromRequest: Array(relevantSymbols.values) ) } catch { print("CK \(error)") @@ -324,6 +332,7 @@ let exampleSuggestion = SuggestionBasic.CodeSuggestion( struct SuggestionResponse { let suggestion: SuggestionBasic.CodeSuggestion let fileURL: URL + let relevantSymbolsFromRequest: [SymbolContent] } @@ -348,6 +357,7 @@ struct StoredSuggestionDTO: Codable { let position: CursorPositionDTO let range: CursorRangeDTO let createdAt: String + let relevantSymbols: [RelevantSymbolsDTO] struct CursorPositionDTO: Codable { let line: Int @@ -358,6 +368,15 @@ struct StoredSuggestionDTO: Codable { let start: CursorPositionDTO let end: CursorPositionDTO } + + struct RelevantSymbolsDTO: Codable { + let fileURL: String + let name: String + let content: String + let startLine: Int + let endLine: Int + let kind: String + } } extension SuggestionResponse { @@ -380,7 +399,21 @@ extension SuggestionResponse { character: suggestion.range.end.character ) ), - createdAt: timestamp + createdAt: timestamp, + relevantSymbols: relevantSymbolsFromRequest.map { $0.toStoredDTO() } + ) + } +} + +extension SymbolContent { + func toStoredDTO() -> StoredSuggestionDTO.RelevantSymbolsDTO { + .init( + fileURL: fileURL, + name: symbol.name, + content: content, + startLine: symbol.startLine, + endLine: symbol.endLine, + kind: symbol.kind.rawValue ) } } diff --git a/Core/Sources/Service/MultiFileContext/Entity/ClassificationKeywords.swift b/Core/Sources/Service/MultiFileContext/Entity/ClassificationKeywords.swift index 6372bce5..8da1aa93 100644 --- a/Core/Sources/Service/MultiFileContext/Entity/ClassificationKeywords.swift +++ b/Core/Sources/Service/MultiFileContext/Entity/ClassificationKeywords.swift @@ -1,4 +1,4 @@ -enum ClassificationKeywords: String { +public enum ClassificationKeywords: String { case classWord = "class" case structWord = "struct" case enumWord = "enum" diff --git a/Core/Sources/Service/MultiFileContext/Entity/FileContent.swift b/Core/Sources/Service/MultiFileContext/Entity/FileContent.swift index 102493f9..1e63d6e9 100644 --- a/Core/Sources/Service/MultiFileContext/Entity/FileContent.swift +++ b/Core/Sources/Service/MultiFileContext/Entity/FileContent.swift @@ -1,4 +1,4 @@ -struct FileContent { +public struct FileContent { let fileURL: String let content: String } diff --git a/Core/Sources/Service/MultiFileContext/Entity/SymbolContent.swift b/Core/Sources/Service/MultiFileContext/Entity/SymbolContent.swift index 9369d940..bf1e3d6b 100644 --- a/Core/Sources/Service/MultiFileContext/Entity/SymbolContent.swift +++ b/Core/Sources/Service/MultiFileContext/Entity/SymbolContent.swift @@ -1,5 +1,5 @@ -struct SymbolContent { - let fileURL: String - let content: String - var symbol: SymbolInfo +public struct SymbolContent { + public let fileURL: String + public let content: String + public internal(set) var symbol: SymbolInfo } diff --git a/Core/Sources/Service/MultiFileContext/Entity/SymbolInfo.swift b/Core/Sources/Service/MultiFileContext/Entity/SymbolInfo.swift index 72b37501..0917dd7f 100644 --- a/Core/Sources/Service/MultiFileContext/Entity/SymbolInfo.swift +++ b/Core/Sources/Service/MultiFileContext/Entity/SymbolInfo.swift @@ -1,8 +1,8 @@ -struct SymbolInfo { - let name: String - let kind: ClassificationKeywords - let startLine: Int - let endLine: Int - let content: String - var extensions: [SymbolContent] = [] +public struct SymbolInfo { + public let name: String + public let kind: ClassificationKeywords + public let startLine: Int + public let endLine: Int + public let content: String + public internal(set) var extensions: [SymbolContent] = [] } diff --git a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift index b5f50001..0d1f0c9b 100644 --- a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift +++ b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift @@ -1,17 +1,17 @@ import Foundation -class MultiFileContextManager { +public class MultiFileContextManager { private let workspaceProvider: WorkspaceProvider private let parser: ProgrammingLanguageSyntaxParser - init(workspaceProvider: WorkspaceProvider, parser: ProgrammingLanguageSyntaxParser) { + public init(workspaceProvider: WorkspaceProvider, parser: ProgrammingLanguageSyntaxParser) { self.workspaceProvider = workspaceProvider self.parser = parser } /// List files within workspace recursively /// Retrieved from: https://stackoverflow.com/a/57640445 - func listFilesInWorkspace() async -> [String] { + public func listFilesInWorkspace() async -> [String] { guard let workspaceURL = try? await workspaceProvider.getProjectRootURL() else { return [] } var files = [String]() @@ -32,7 +32,7 @@ class MultiFileContextManager { return files } - func readFileContents() async -> [FileContent] { + public func readFileContents() async -> [FileContent] { let fileURLs = await listFilesInWorkspace() return fileURLs.compactMap { fileURLString in guard let fileURL = URL(string: fileURLString) else { return nil } @@ -46,7 +46,7 @@ class MultiFileContextManager { } } - func classifyContentWithinFile() async -> [String: SymbolContent] { + public func classifyContentWithinFiles() async -> [String: SymbolContent] { let fileContents = await readFileContents() var result: [String: SymbolContent] = [:] @@ -61,6 +61,19 @@ class MultiFileContextManager { return result } + public func retrieveRelevantSymbolsForFileContent(content: String) async -> [String: SymbolContent] { + let allSymbols = await classifyContentWithinFiles() + var relevantSymbols: [String: SymbolContent] = [:] + + for (symbolName, symbolContent) in allSymbols { + if content.contains(symbolName) { + relevantSymbols[symbolName] = symbolContent + } + } + + return relevantSymbols + } + private func mergeExtensionsIntoBaseDeclarations(_ symbols: inout [SymbolContent]) { var indexesToRemove: [Int] = [] diff --git a/Core/Sources/Service/MultiFileContext/ProgrammingLanguageSyntaxParser.swift b/Core/Sources/Service/MultiFileContext/ProgrammingLanguageSyntaxParser.swift index 79ac0b19..3df10d1a 100644 --- a/Core/Sources/Service/MultiFileContext/ProgrammingLanguageSyntaxParser.swift +++ b/Core/Sources/Service/MultiFileContext/ProgrammingLanguageSyntaxParser.swift @@ -1,12 +1,15 @@ import SwiftSyntax import SwiftParser -protocol ProgrammingLanguageSyntaxParser { +public protocol ProgrammingLanguageSyntaxParser { func parse(file: FileContent) -> [SymbolContent] } -class SwiftProgrammingLanguageSyntaxParser: ProgrammingLanguageSyntaxParser { - func parse(file: FileContent) -> [SymbolContent] { +public class SwiftProgrammingLanguageSyntaxParser: ProgrammingLanguageSyntaxParser { + + public init() { } + + public func parse(file: FileContent) -> [SymbolContent] { let sourceFile = Parser.parse(source: file.content) let converter = SourceLocationConverter(fileName: file.fileURL, tree: sourceFile) let collector = SwiftDeclarationCollector(sourceLocationConverter: converter, sourceText: file.content) diff --git a/Core/Sources/Service/MultiFileContext/WorkspaceProvider.swift b/Core/Sources/Service/MultiFileContext/WorkspaceProvider.swift index bc2a5e75..2f1b0346 100644 --- a/Core/Sources/Service/MultiFileContext/WorkspaceProvider.swift +++ b/Core/Sources/Service/MultiFileContext/WorkspaceProvider.swift @@ -2,7 +2,7 @@ import Foundation import Workspace import XcodeInspector -protocol WorkspaceProvider { +public protocol WorkspaceProvider { func workspace() async throws -> Workspace? func getProjectRootURL() async throws -> URL } @@ -38,3 +38,19 @@ class XcodeInspectorWorkspaceProvider: WorkspaceProvider { enum WorkspaceError: Error { case errorGettingWorkspace } + +public class ManualWorkspaceProvider: WorkspaceProvider { + private let workspace: Workspace + + public init(workspace: Workspace) { + self.workspace = workspace + } + + public func workspace() async throws -> Workspace? { + workspace + } + + public func getProjectRootURL() async throws -> URL { + workspace.projectRootURL + } +} diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index 225f11e2..a374e9e6 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -439,16 +439,6 @@ extension PseudoCommandHandler { let tabSize = filespace.codeMetadata.tabSize ?? 4 let indentSize = filespace.codeMetadata.indentSize ?? 4 let usesTabsForIndentation = filespace.codeMetadata.usesTabsForIndentation ?? false - let multiFileContextManager = MultiFileContextManager( - workspaceProvider: XcodeInspectorWorkspaceProvider(), - parser: SwiftProgrammingLanguageSyntaxParser() - ) - let classifiedSymbols = await multiFileContextManager.classifyContentWithinFile() - if let entrypoint, - let fileContent = try? String(contentsOf: entrypoint.fileURL, encoding: .utf8) { - content.content = fileContent - content.cursorPosition = entrypoint.cursor - } return .init( content: content.content, lines: content.lines, diff --git a/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift index 5cf21ea0..d68d09cf 100644 --- a/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift +++ b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift @@ -28,7 +28,7 @@ class MultiFileContextManagerTests: XCTestCase { func testClassifyingCode() async { let sut = sut - let classifiedSymbols = await sut.classifyContentWithinFile() + let classifiedSymbols = await sut.classifyContentWithinFiles() // symbols XCTAssertNotEqual(classifiedSymbols.count, 0) } From 572b76cfed8c3b12f67cb0000016ade9a51acb50 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Sun, 27 Jul 2025 22:49:07 +0200 Subject: [PATCH 26/66] print output pretty --- .../HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index 6465f282..f475394c 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -188,7 +188,9 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { do { let dto = suggestion.toStoredDTO(timestamp: timestamp) - let data = try JSONEncoder().encode(dto) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted] + let data = try encoder.encode(dto) try data.write(to: outputFileURL) print("Stored suggestion at: \(outputFileURL.path)") } catch { From c68859cd0a258b9a8235a0e9cb2261a4348ac796 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Mon, 28 Jul 2025 18:27:26 +0200 Subject: [PATCH 27/66] add toggle to run with or without multi file context --- .../Domain/Manager/BenchmarkManager.swift | 48 +++++++++++++++++-- .../Presentation/View/BenchmarkView.swift | 3 +- .../ViewModel/BenchmarkViewModel.swift | 9 ++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index f475394c..96e0d9d3 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -26,6 +26,11 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { @WorkspaceActor private let workspacePool: WorkspacePool + private let isMultiFileEnabledSubject = CurrentValueSubject(true) + var isMultiFileEnabled: AnyPublisher { + isMultiFileEnabledSubject.eraseToAnyPublisher() + } + init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { @Dependency(\.workspacePool) var workspacePool BuiltinExtensionManager.shared.setupExtensions([ @@ -67,7 +72,14 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { workspaceProvider: ManualWorkspaceProvider(workspace: workspace), parser: SwiftProgrammingLanguageSyntaxParser() ) - let relevantSymbols = await multiFileContextManager.retrieveRelevantSymbolsForFileContent(content: content) + let relevantSymbols: [SymbolContent] = await { + if isMultiFileEnabledSubject.value { + let symbols = await multiFileContextManager.retrieveRelevantSymbolsForFileContent(content: content) + return Array(symbols.values) + } else { + return [] + } + }() let suggestionRequest = SuggestionProvider.SuggestionRequest( fileURL: entrypoint.fileURL, @@ -80,7 +92,7 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { tabSize: 4, indentSize: 4, usesTabsForIndentation: false, - relevantCodeSnippets: [] + relevantCodeSnippets: relevantSymbols.mapToRelevantCodeSnippets() ) let workspaceInfo = WorkspaceInfo(workspaceURL: xcodeWorkspaceFileURL, projectURL: benchmarkDirectory) do { @@ -92,7 +104,7 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { return .init( suggestion: firstSuggestion, fileURL: entrypoint.fileURL, - relevantSymbolsFromRequest: Array(relevantSymbols.values) + relevantSymbolsFromRequest: relevantSymbols ) } catch { print("CK \(error)") @@ -182,9 +194,20 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { } } + let contextFolderName = isMultiFileEnabledSubject.value ? "with context" : "without context" + let contextFolder = outputBenchmarkDir.appendingPathComponent(contextFolderName, isDirectory: true) + if !fileManager.fileExists(atPath: contextFolder.path, isDirectory: &isDirectory) || !isDirectory.boolValue { + do { + try fileManager.createDirectory(at: contextFolder, withIntermediateDirectories: true, attributes: nil) + } catch { + print("Failed to create context directory:", error) + return + } + } + let timestamp = ISO8601DateFormatter().string(from: Date()) let reformattedTimestamp = timestamp.replacingOccurrences(of: ":", with: "-") - let outputFileURL = outputBenchmarkDir.appendingPathComponent("Task-\(taskNumber)-\(reformattedTimestamp).json") + let outputFileURL = contextFolder.appendingPathComponent("Task-\(taskNumber)-\(reformattedTimestamp).json") do { let dto = suggestion.toStoredDTO(timestamp: timestamp) @@ -249,6 +272,11 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { return nil } + + @MainActor + func updateMultiFileContextState(_ newValue: Bool) { + isMultiFileEnabledSubject.send(newValue) + } } struct MetadataDTO: Codable { @@ -419,3 +447,15 @@ extension SymbolContent { ) } } + +extension Array where Element == SymbolContent { + func mapToRelevantCodeSnippets() -> [SuggestionProvider.RelevantCodeSnippet] { + map { symbol in + .init( + content: symbol.content, + priority: 0, + filePath: symbol.fileURL + ) + } + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift index ffa6817a..51550e3c 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift @@ -12,10 +12,11 @@ struct BenchmarkView: View { var body: some View { ScrollView { VStack { - HStack { + HStack(spacing: 20) { Text("Run Benchmark") .font(.title) .frame(maxWidth: .infinity, alignment: .leading) + Toggle("Multi File Context Enabled", isOn: $viewModel.isMultiFileContextEnabled) OutputConfigurationButtonView(module: module) } .padding(.vertical) diff --git a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift index 65e8f2ec..ed61dcfc 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift @@ -3,6 +3,12 @@ import Toast class BenchmarkViewModel: ObservableObject { @Published var benchmarkDirectories: [BenchmarkDirectory] = [] + @Published var isMultiFileContextEnabled: Bool = true { + didSet { + guard oldValue != isMultiFileContextEnabled else { return } + Task { await benchmarkManager.updateMultiFileContextState(isMultiFileContextEnabled) } + } + } private let benchmarkSettingsRepository: BenchmarkSettingsRepository private let benchmarkManager: RealtimeSuggestionControllerBenchmarkManager @@ -12,6 +18,9 @@ class BenchmarkViewModel: ObservableObject { init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { self.benchmarkSettingsRepository = benchmarkSettingsRepository self.benchmarkManager = RealtimeSuggestionControllerBenchmarkManager(benchmarkSettingsRepository: benchmarkSettingsRepository) + benchmarkManager.isMultiFileEnabled + .assign(to: \.isMultiFileContextEnabled, on: self) + .store(in: &cancellables) } func loadBenchmarkDirectories() { From 6eeba6b554910ab2b008fd9e34ff3db5fbd0d706 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Mon, 28 Jul 2025 19:03:18 +0200 Subject: [PATCH 28/66] cleanup after each task to fix document version mismatch --- .../Benchmark/Domain/Manager/BenchmarkManager.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index 96e0d9d3..b2be34f9 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -56,7 +56,7 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) } - + await cleanUp() } } @@ -99,7 +99,6 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { // only works when setting document version GitHubCopilotService to 0 let suggestions = try await workspace.suggestionService?.getSuggestions(suggestionRequest, workspaceInfo: workspaceInfo) // let suggestions: [SuggestionBasic.CodeSuggestion]? = [exampleSuggestion] - await workspace.closeFilespace(fileURL: URL(fileURLWithPath: metadata.entrypoint.filename)) guard let suggestions, let firstSuggestion = suggestions.first else { return nil } return .init( suggestion: firstSuggestion, @@ -221,6 +220,12 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { } } + func cleanUp() async { + for (url, _) in await workspacePool.workspaces { + await workspacePool.removeWorkspace(url: url) + } + } + func readMetadata(at taskFolder: URL) -> MetadataDTO? { let metadataURL = taskFolder.appendingPathComponent("metadata.json") do { From 7d75ce870f753d81cbe484ef2fd2e4a10b0def54 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Wed, 30 Jul 2025 16:21:47 +0200 Subject: [PATCH 29/66] add task status to BenchmarkView --- .../Domain/Manager/BenchmarkManager.swift | 29 +++++ .../Presentation/View/BenchmarkView.swift | 116 +++++++++++++----- .../ViewModel/BenchmarkViewModel.swift | 2 + 3 files changed, 114 insertions(+), 33 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index b2be34f9..2d970d86 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -30,6 +30,10 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { var isMultiFileEnabled: AnyPublisher { isMultiFileEnabledSubject.eraseToAnyPublisher() } + private let taskStatesSubject = CurrentValueSubject<[TaskStatus], Never>([]) + var taskStates: AnyPublisher<[TaskStatus], Never> { + taskStatesSubject.eraseToAnyPublisher() + } init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { @Dependency(\.workspacePool) var workspacePool @@ -51,10 +55,16 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { func getCodeSuggestions(at benchmarkDirectory: BenchmarkDirectory) async throws { let taskPaths: [URL] = getTaskFolders(in: benchmarkDirectory.url) + let initialTaskStates = taskPaths.map { _ in TaskStatus.notStarted } + await updateTaskStates(initialTaskStates) for (index, taskPath) in taskPaths.prefix(1).enumerated() { + await updateTaskStatus(.running, at: index) if let suggestion = await getCodeSuggestionFromService(at: taskPath, from: benchmarkDirectory.url) { await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) + await updateTaskStatus(.success, at: index) + } else { + await updateTaskStatus(.failure, at: index) } await cleanUp() } @@ -282,6 +292,18 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { func updateMultiFileContextState(_ newValue: Bool) { isMultiFileEnabledSubject.send(newValue) } + + @MainActor + private func updateTaskStates(_ newValue: [TaskStatus]) { + taskStatesSubject.send(newValue) + } + + @MainActor + private func updateTaskStatus(_ newValue: TaskStatus, at index: Int) { + var updatedValue = taskStatesSubject.value + updatedValue[index] = newValue + taskStatesSubject.send(updatedValue) + } } struct MetadataDTO: Codable { @@ -464,3 +486,10 @@ extension Array where Element == SymbolContent { } } } + +enum TaskStatus { + case success + case failure + case notStarted + case running +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift index 51550e3c..d7f37bc0 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift @@ -4,54 +4,104 @@ struct BenchmarkView: View { private let module: BenchmarkModuleType @StateObject private var viewModel: BenchmarkViewModel + private let taskStatusInfoHeight: CGFloat = 150.0 + init(module: BenchmarkModuleType) { self.module = module _viewModel = StateObject(wrappedValue: module.provide()) } var body: some View { - ScrollView { - VStack { - HStack(spacing: 20) { - Text("Run Benchmark") - .font(.title) - .frame(maxWidth: .infinity, alignment: .leading) - Toggle("Multi File Context Enabled", isOn: $viewModel.isMultiFileContextEnabled) - OutputConfigurationButtonView(module: module) - } - .padding(.vertical) + VStack { + ScrollView { VStack { - ForEach(viewModel.benchmarkDirectories, id: \.self) { directory in - HStack { - DirectoryNameView(directory: directory) - .frame(maxWidth: .infinity, alignment: .leading) - Button { - Task { - try? await viewModel.runBenchmark(for: directory) + HStack(spacing: 20) { + Text("Run Benchmark") + .font(.title) + .frame(maxWidth: .infinity, alignment: .leading) + Toggle("Multi File Context Enabled", isOn: $viewModel.isMultiFileContextEnabled) + OutputConfigurationButtonView(module: module) + } + .padding(.vertical) + VStack { + ForEach(viewModel.benchmarkDirectories, id: \.self) { directory in + HStack { + DirectoryNameView(directory: directory) + .frame(maxWidth: .infinity, alignment: .leading) + Button { + Task { + try? await viewModel.runBenchmark(for: directory) + } + } label: { + Image(systemName: "play") + } + Button { + NSWorkspace.shared.open(directory.url) + } label: { + Image(systemName: "folder") + } + Button { + viewModel.deleteDirectory(directory) + } label: { + Image(systemName: "trash") + .foregroundColor(.red) } - } label: { - Image(systemName: "play") - } - Button { - NSWorkspace.shared.open(directory.url) - } label: { - Image(systemName: "folder") } - Button { - viewModel.deleteDirectory(directory) - } label: { - Image(systemName: "trash") - .foregroundColor(.red) + } + } + AddDirectoryButtonView(module: module) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding() + } + VStack(alignment: .leading, spacing: 12) { + if viewModel.taskStates.isEmpty { + Text("💤 No benchmarks running right now.") + .foregroundColor(.secondary) + } else { + VStack(alignment: .leading, spacing: 8) { + Text("📋 Task Status") + .font(.headline) + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(Array(viewModel.taskStates.enumerated()), id: \.offset) { index, taskStatus in + HStack { + Text("Task \(index + 1)") + .frame(maxWidth: .infinity, alignment: .leading) + + Group { + switch taskStatus { + case .notStarted: + Text("🕒") + .accessibilityLabel("Not started") + case .running: + ProgressView() + .scaleEffect(0.6) + .accessibilityLabel("Running") + case .success: + Text("✅") + .accessibilityLabel("Success") + case .failure: + Text("❌") + .accessibilityLabel("Failed") + } + } + .frame(width: 24, height: 24, alignment: .center) + } + .padding(.vertical, 2) + } } } } + .frame(height: taskStatusInfoHeight) } - AddDirectoryButtonView(module: module) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .center) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .frame(maxWidth: .infinity, minHeight: taskStatusInfoHeight, alignment: .leading) .padding() + .background(Color(.windowBackgroundColor)) + .cornerRadius(8) } .onAppear { viewModel.loadBenchmarkDirectories() diff --git a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift index ed61dcfc..3a78cde9 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift @@ -9,6 +9,7 @@ class BenchmarkViewModel: ObservableObject { Task { await benchmarkManager.updateMultiFileContextState(isMultiFileContextEnabled) } } } + @Published private(set) var taskStates: [TaskStatus] = [] private let benchmarkSettingsRepository: BenchmarkSettingsRepository private let benchmarkManager: RealtimeSuggestionControllerBenchmarkManager @@ -21,6 +22,7 @@ class BenchmarkViewModel: ObservableObject { benchmarkManager.isMultiFileEnabled .assign(to: \.isMultiFileContextEnabled, on: self) .store(in: &cancellables) + benchmarkManager.taskStates.assign(to: \.taskStates, on: self).store(in: &cancellables) } func loadBenchmarkDirectories() { From 6fdc41174ec64011dde61febdefc908d47a26e99 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Tue, 12 Aug 2025 20:41:16 +0200 Subject: [PATCH 30/66] ignore paths from /Tests/ path --- .../HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index 2d970d86..f7169cf7 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -264,6 +264,7 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { let folderName = fileURL.lastPathComponent let range = NSRange(location: 0, length: folderName.utf16.count) if regex.firstMatch(in: folderName, options: [], range: range) != nil { + guard !fileURL.path.contains("/Tests/") else { continue } taskFolders.append(fileURL) } } From 5c96b8bd3d2b63aa4d4a4e19bb6ad612bb91152e Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Tue, 12 Aug 2025 20:42:54 +0200 Subject: [PATCH 31/66] sort tasks by task number --- .../Domain/Manager/BenchmarkManager.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index f7169cf7..da460cca 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -273,7 +273,21 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { } } - return taskFolders.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) + return taskFolders.sorted { lhs, rhs in + let lhsNumber = extractTaskNumber(from: lhs.lastPathComponent) ?? 0 + let rhsNumber = extractTaskNumber(from: rhs.lastPathComponent) ?? 0 + return lhsNumber < rhsNumber + } + } + + private func extractTaskNumber(from name: String) -> Int? { + let regex = try! NSRegularExpression(pattern: #"Task-(\d+)"#) + let range = NSRange(location: 0, length: name.utf16.count) + if let match = regex.firstMatch(in: name, options: [], range: range), + let numberRange = Range(match.range(at: 1), in: name) { + return Int(name[numberRange]) + } + return nil } func findXcodeWorkspace(in directory: URL) -> URL? { From 850123a289bbd9442fba89e7781d217b2c12e817 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Tue, 12 Aug 2025 21:09:04 +0200 Subject: [PATCH 32/66] set version to 1 to match LSP version --- .../LanguageServer/GitHubCopilotService.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 1af0cc58..a4e2dab8 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -442,7 +442,7 @@ public final class GitHubCopilotService: do { let completions = try await self .sendRequest(GitHubCopilotRequest.InlineCompletion(doc: .init( - textDocument: .init(uri: fileURL.absoluteString, version: 0), + textDocument: .init(uri: fileURL.absoluteString, version: 1), position: cursorPosition, formattingOptions: .init( tabSize: tabSize, @@ -730,7 +730,7 @@ public final class GitHubCopilotService: textDocument: .init( uri: uri, languageId: languageId.rawValue, - version: 0, + version: 1, text: content ) ) From 9c5c9a68541cc1fb528617496e69e9fc05f85eee Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Wed, 13 Aug 2025 18:06:26 +0200 Subject: [PATCH 33/66] fix wrong version number - unfortunately with changes on production --- .../Domain/Manager/BenchmarkManager.swift | 33 ++++++++++++++++ .../BuiltinExtension/BuiltinExtension.swift | 8 ++++ .../BuiltinExtensionWorkspacePlugin.swift | 18 +++++++++ .../GitHubCopilotExtension.swift | 38 +++++++++++++++++++ Tool/Sources/Workspace/Workspace.swift | 9 +++-- 5 files changed, 102 insertions(+), 4 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index da460cca..4f305928 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -63,6 +63,7 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) await updateTaskStatus(.success, at: index) + try await Task.sleep(nanoseconds: 3_000_000_000) } else { await updateTaskStatus(.failure, at: index) } @@ -105,9 +106,13 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { relevantCodeSnippets: relevantSymbols.mapToRelevantCodeSnippets() ) let workspaceInfo = WorkspaceInfo(workspaceURL: xcodeWorkspaceFileURL, projectURL: benchmarkDirectory) + await openFilespaces(entrypoint: entrypoint, relevantSymbols: relevantSymbols, in: workspace) + await simulateInitialEditorChange(entrypoint: entrypoint, content: content, in: workspace) do { // only works when setting document version GitHubCopilotService to 0 let suggestions = try await workspace.suggestionService?.getSuggestions(suggestionRequest, workspaceInfo: workspaceInfo) + await saveFilespace(entrypoint: entrypoint, in: workspace) + await closeFilespaces(entrypoint: entrypoint, relevantSymbols: relevantSymbols, in: workspace) // let suggestions: [SuggestionBasic.CodeSuggestion]? = [exampleSuggestion] guard let suggestions, let firstSuggestion = suggestions.first else { return nil } return .init( @@ -122,6 +127,34 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { // return nil } + private func openFilespaces(entrypoint: EntryPoint, relevantSymbols: [SymbolContent], in workspace: Workspace) async { + for symbol in relevantSymbols { + if let filespace = try? await workspace.createFilespaceIfNeeded(fileURL: URL(fileURLWithPath: symbol.fileURL)) { + await workspace.didOpenFilespace(filespace) + } + } + if let filespace = try? await workspace.createFilespaceIfNeeded(fileURL: entrypoint.fileURL) { + await workspace.didOpenFilespace(filespace) + } + } + + private func closeFilespaces(entrypoint: EntryPoint, relevantSymbols: [SymbolContent], in workspace: Workspace) async { + for symbol in relevantSymbols { + await workspace.didCloseFilespace(URL(fileURLWithPath: symbol.fileURL)) + } + await workspace.didCloseFilespace(entrypoint.fileURL) + } + + private func saveFilespace(entrypoint: EntryPoint, in workspace: Workspace) async { + if let filespace = try? await workspace.createFilespaceIfNeeded(fileURL: entrypoint.fileURL) { + await workspace.didSaveFilespace(filespace) + } + } + + private func simulateInitialEditorChange(entrypoint: EntryPoint, content: String, in workspace: Workspace) async { + await workspace.didUpdateFilespace(fileURL: entrypoint.fileURL, content: content, version: 1) + } + func applyCodeSuggestion(suggestion: SuggestionBasic.CodeSuggestion, at fileURL: URL) async { do { var content = try String(contentsOf: fileURL, encoding: .utf8) diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift index 525b5c54..7db6f126 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift @@ -21,5 +21,13 @@ public protocol BuiltinExtension: CopilotForXcodeCapability { /// It's usually called when the app is about to quit, /// you should clean up all the resources here. func terminate() + + /// To manually set the document version to 1 in case service is not connected to editor + func workspace( + _ workspace: WorkspaceInfo, + didUpdateDocumentAt documentURL: URL, + content: String?, + version: Int + ) } diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift index a03c34d1..d9a47a19 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift @@ -20,6 +20,10 @@ public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { override public func didUpdateFilespace(_ filespace: Filespace, content: String) { notifyUpdateFile(filespace: filespace, content: content) } + + override public func didUpdateFilespace(_ filespace: Filespace, content: String, version: Int) { + notifyUpdateFile(filespace: filespace, content: content, version: version) + } override public func didCloseFilespace(_ fileURL: URL) { Task { @@ -56,6 +60,20 @@ public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { } } } + + public func notifyUpdateFile(filespace: Filespace, content: String, version: Int) { + Task { + guard filespace.isTextReadable else { return } + for ext in extensionManager.extensions { + ext.workspace( + .init(workspaceURL: workspaceURL, projectURL: projectRootURL), + didUpdateDocumentAt: filespace.fileURL, + content: content, + version: version + ) + } + } + } public func notifySaveFile(filespace: Filespace) { Task { diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index 119278ee..cd59b285 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -129,6 +129,44 @@ public final class GitHubCopilotExtension: BuiltinExtension { } } } + + public func workspace( + _ workspace: WorkspaceInfo, + didUpdateDocumentAt documentURL: URL, + content: String?, + version: Int + ) { + guard isLanguageServerInUse else { return } + // check if file size is larger than 15MB, if so, return immediately + if let attrs = try? FileManager.default + .attributesOfItem(atPath: documentURL.path), + let fileSize = attrs[FileAttributeKey.size] as? UInt64, + fileSize > 15 * 1024 * 1024 + { return } + + Task { + guard let content else { return } + guard let service = await serviceLocator.getService(from: workspace) else { return } + do { + try await service.notifyChangeTextDocument( + fileURL: documentURL, + content: content, + version: version + ) + } catch let error as ServerError { + switch error { + case .serverError(-32602, _, _): // parameter incorrect + Logger.gitHubCopilot.error(error.localizedDescription) + // Reopen document if it's not found in the language server + self.workspace(workspace, didOpenDocumentAt: documentURL) + default: + Logger.gitHubCopilot.info(error.localizedDescription) + } + } catch { + Logger.gitHubCopilot.info(error.localizedDescription) + } + } + } public func extensionUsageDidChange(_ usage: ExtensionUsage) { extensionUsage = usage diff --git a/Tool/Sources/Workspace/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift index bb88344e..1db2a755 100644 --- a/Tool/Sources/Workspace/Workspace.swift +++ b/Tool/Sources/Workspace/Workspace.swift @@ -46,6 +46,7 @@ open class WorkspacePlugin { open func didOpenFilespace(_: Filespace) {} open func didSaveFilespace(_: Filespace) {} open func didUpdateFilespace(_: Filespace, content: String) {} + open func didUpdateFilespace(_: Filespace, content: String, version: Int) {} open func didCloseFilespace(_: URL) {} } @@ -178,13 +179,13 @@ public final class Workspace { } @WorkspaceActor - public func didUpdateFilespace(fileURL: URL, content: String) { + public func didUpdateFilespace(fileURL: URL, content: String, version: Int = 0) { refreshUpdateTime() guard let filespace = filespaces[fileURL] else { return } filespace.bumpVersion() filespace.refreshUpdateTime() for plugin in plugins.values { - plugin.didUpdateFilespace(filespace, content: content) + plugin.didUpdateFilespace(filespace, content: content, version: version) } } @@ -198,14 +199,14 @@ public final class Workspace { } @WorkspaceActor - func didCloseFilespace(_ fileURL: URL) { + public func didCloseFilespace(_ fileURL: URL) { for plugin in self.plugins.values { plugin.didCloseFilespace(fileURL) } } @WorkspaceActor - func didSaveFilespace(_ filespace: Filespace) { + public func didSaveFilespace(_ filespace: Filespace) { refreshUpdateTime() filespace.refreshUpdateTime() for plugin in plugins.values { From 30c14c5ea6a97af3771fc630cf0b4ef16d9a1b38 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Thu, 14 Aug 2025 17:32:25 +0200 Subject: [PATCH 34/66] WIP add ChatGPT integration --- .../Domain/Manager/BenchmarkManager.swift | 596 ++++++++++++++++++ 1 file changed, 596 insertions(+) diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index 4f305928..28a7511b 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -60,6 +60,7 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { for (index, taskPath) in taskPaths.prefix(1).enumerated() { await updateTaskStatus(.running, at: index) if let suggestion = await getCodeSuggestionFromService(at: taskPath, from: benchmarkDirectory.url) { +// if let suggestion = await getCodeSuggestionFromOpenAI(at: taskPath, from: benchmarkDirectory.url) { await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) await updateTaskStatus(.success, at: index) @@ -155,6 +156,62 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { await workspace.didUpdateFilespace(fileURL: entrypoint.fileURL, content: content, version: 1) } + func getCodeSuggestionFromOpenAI(at directory: URL, from benchmarkDirectory: URL) async -> SuggestionResponse? { + guard let metadata: MetadataDTO = readMetadata(at: directory), + let xcodeWorkspaceFileURL = findXcodeWorkspace(in: benchmarkDirectory), + let workspace = try? await workspacePool.fetchOrCreateWorkspace(workspaceURL: xcodeWorkspaceFileURL) + else { return nil } + let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkDirectory.path) + let content: String = (try? String(contentsOf: entrypoint.fileURL, encoding: .utf8)) ?? "" + + let multiFileContextManager = MultiFileContextManager( + workspaceProvider: ManualWorkspaceProvider(workspace: workspace), + parser: SwiftProgrammingLanguageSyntaxParser() + ) + let relevantSymbols: [SymbolContent] = await { + if isMultiFileEnabledSubject.value { + let symbols = await multiFileContextManager.retrieveRelevantSymbolsForFileContent(content: content) + return Array(symbols.values) + } else { + return [] + } + }() + + let suggestionRequest = SuggestionRequest( + fileURL: entrypoint.fileURL, + relativePath: entrypoint.fileURL.path.replacingOccurrences(of: benchmarkDirectory.path, with: ""), + content: content, + originalContent: content, + lines: content.linesWithNewlineSuffix, + cursorPosition: .init(line: entrypoint.cursor.line, character: entrypoint.cursor.character), + cursorOffset: content.cursorOffset(line: entrypoint.cursor.line, character: entrypoint.cursor.character) ?? 0, + tabSize: 4, + indentSize: 4, + usesTabsForIndentation: false, + relevantCodeSnippets: relevantSymbols.mapToRelevantCodeSnippets() + ) + let workspaceInfo = WorkspaceInfo(workspaceURL: xcodeWorkspaceFileURL, projectURL: benchmarkDirectory) + let repository = OpenAICompletionRepository(config: .init()) + do { + // only works when setting document version GitHubCopilotService to 0 + let suggestion = try await repository.structuredEdit(for: suggestionRequest) +// let suggestions: [SuggestionBasic.CodeSuggestion]? = [exampleSuggestion] + return .init( + suggestion: suggestion.mapToCodeSuggestion( + requestPosition: SuggestionRequest.CursorPosition( + line: entrypoint.cursor.line, + character: entrypoint.cursor.character + ) + ), + fileURL: entrypoint.fileURL, + relevantSymbolsFromRequest: relevantSymbols + ) + } catch { + print("CK \(error)") + return nil + } + } + func applyCodeSuggestion(suggestion: SuggestionBasic.CodeSuggestion, at fileURL: URL) async { do { var content = try String(contentsOf: fileURL, encoding: .utf8) @@ -541,3 +598,542 @@ enum TaskStatus { case notStarted case running } + + + +// MARK: - ChatGPT + +import Foundation + +// MARK: - Input models from your side + +public struct SuggestionRequest: Sendable { + + public struct CursorPosition: Codable, Sendable { + public var line: Int + public var character: Int + } + + public struct RelevantCodeSnippet: Codable, Sendable { + public var path: String + public var language: String? + public var code: String + } + + public var fileURL: URL + public var relativePath: String + public var content: String + public var originalContent: String + public var lines: [String] + public var cursorPosition: CursorPosition + public var cursorOffset: Int + public var tabSize: Int + public var indentSize: Int + public var usesTabsForIndentation: Bool + public var relevantCodeSnippets: [RelevantCodeSnippet] +} + +// MARK: - Output + +public struct CompletionSuggestion: Sendable { + /// The raw text to insert at the cursor. + public let insertText: String + /// Optional reason the model stopped (e.g. "length", "stop"). + public let finishReason: String? +} + +// MARK: - Protocol + +public protocol CodeCompletionRepository: Sendable { + /// Non-streaming variant that returns a single suggestion string. +// func suggestion( +// for request: SuggestionRequest +// ) async throws -> CompletionSuggestion + + func structuredEdit(for request: SuggestionRequest) async throws -> CodeEdit +} + +// MARK: - OpenAI implementation + +public struct OpenAICompletionRepository: CodeCompletionRepository, Sendable { + + public enum OpenAIError: Error { + case badResponse(status: Int, body: String) + case decoding + case emptyChoice + } + + public struct Config: Sendable { + public var apiKey: String + /// e.g. "gpt-4o-mini" + public var model: String + /// Optional org header + public var organization: String? + /// API base, keep default unless using a proxy + public var baseURL: URL + + public init( + apiKey: String = "", + model: String = "gpt-4o-mini", + organization: String? = nil, + baseURL: URL = URL(string: "https://api.openai.com/v1")! + ) { + self.apiKey = apiKey + self.model = model + self.organization = organization + self.baseURL = baseURL + } + } + + private let config: Config + private let urlSession: URLSession + + public init(config: Config, session: URLSession = .shared) { + self.config = config + self.urlSession = session + } + + // MARK: Public API + +// public func suggestion(for request: SuggestionRequest) async throws -> CompletionSuggestion { +// let url = config.baseURL.appendingPathComponent("chat/completions") +// var req = URLRequest(url: url) +// req.httpMethod = "POST" +// req.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") +// if let org = config.organization { req.setValue(org, forHTTPHeaderField: "OpenAI-Organization") } +// req.setValue("application/json", forHTTPHeaderField: "Content-Type") +// +// let payload = ChatPayload( +// model: config.model, +// messages: buildMessages(from: request), +// temperature: 0, +// stream: false, +// response_format: nil // No structured output requested +// ) +// req.httpBody = try JSONEncoder().encode(payload) +// +// let (data, resp) = try await urlSession.data(for: req) +// guard let http = resp as? HTTPURLResponse else { throw OpenAIError.decoding } +// guard 200..<300 ~= http.statusCode else { +// let body = String(data: data, encoding: .utf8) ?? "" +// throw OpenAIError.badResponse(status: http.statusCode, body: body) +// } +// +// let decoded = try JSONDecoder().decode(ChatResponse.self, from: data) +// guard let choice = decoded.choices.first else { throw OpenAIError.emptyChoice } +// return CompletionSuggestion( +// insertText: choice.message.content ?? "", +// finishReason: choice.finishReason +// ) +// } + + // MARK: - Prompt construction + + /// Builds a strict prompt that returns only the code to insert at . + private func buildMessages(from req: SuggestionRequest) -> [ChatMessage] { + let indentDescriptor: String = { + if req.usesTabsForIndentation { return "tabs=\(req.tabSize)" } + return "spaces=\(req.indentSize)" + }() + + // Surrounding context (prefix/suffix) based on cursorOffset + let before = String(req.content.prefix(req.cursorOffset)) + let after = String(req.content.suffix(max(0, req.content.count - req.cursorOffset))) + + // Include some relevant separate snippets (e.g., related files / symbols) + let related = req.relevantCodeSnippets.map { s in + """ + PATH: \(s.path) + LANG: \(s.language ?? "unknown") + ---- + \(s.code) + """ + }.joined(separator: "\n\n========\n\n") + + let system = ChatMessage( + role: "system", + content: +""" +You are a **code completion engine** integrated into Xcode IDE. +- The user sends source code with a marker or with BEFORE/AFTER sections. +- Return **only** the code to insert at the cursor. **No prose, no fences, no echo**. +- Respect indentation (\(indentDescriptor)). +- Prefer short, compilable, context-aware continuations. +- Do not duplicate text already present in AFTER. +""" + ) + + let user = ChatMessage( + role: "user", + content: +""" +FILE: \(req.relativePath) + +BEFORE: +««« +\(before) +»»» + +< CURSOR > + +AFTER: +««« +\(after) +»»» + +RELEVANT CONTEXT (optional sections across the workspace): +««« +\(related) +»»» + +Constraints: +- Output must be only the inserted code (no explanations). +- No changes can be made in RELEVANT CONTEXT. +- Do not repeat characters from AFTER. +- Keep indentation/style consistent with BEFORE. +""" + ) + + return [system, user] + } + + // MARK: - SSE parsing + + private func parseDeltaChunk(jsonLine: String) throws -> String? { + // Matches OpenAI stream schema for chat.completions: + // { "id": "...","choices":[{"delta":{"content":"..."},"finish_reason":null,...}], ... } + struct StreamEnvelope: Decodable { + struct Choice: Decodable { + struct Delta: Decodable { let content: String? } + let delta: Delta + } + let choices: [Choice] + } + let data = Data(jsonLine.utf8) + let env = try JSONDecoder().decode(StreamEnvelope.self, from: data) + return env.choices.first?.delta.content + } +} + +public extension OpenAICompletionRepository { + + struct JSONOnly: Encodable { let type: String = "json_object" } + + public func structuredEdit(for request: SuggestionRequest) async throws -> CodeEdit { + var urlRequest = URLRequest(url: config.baseURL.appendingPathComponent("chat/completions")) + urlRequest.httpMethod = "POST" + urlRequest.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") + if let org = config.organization { urlRequest.setValue(org, forHTTPHeaderField: "OpenAI-Organization") } + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let payload = ChatPayload( + model: config.model, + messages: buildMessagesForStructuredEdit(from: request), + temperature: 0, + stream: false, + response_format: JSONOnly() // ask for a single JSON object + ) + urlRequest.httpBody = try JSONEncoder().encode(payload) + + let (data, resp) = try await urlSession.data(for: urlRequest) + guard let http = resp as? HTTPURLResponse, 200..<300 ~= http.statusCode else { + let body = String(data: data, encoding: .utf8) ?? "" + throw OpenAIError.badResponse(status: (resp as? HTTPURLResponse)?.statusCode ?? -1, body: body) + } + + let decoded = try JSONDecoder().decode(ChatResponse.self, from: data) + guard let content = decoded.choices.first?.message.content, !content.isEmpty else { + throw OpenAIError.emptyChoice + } + + // The model's entire reply is a JSON object. Decode it. + let edit = try JSONDecoder().decode(CodeEdit.self, from: Data(content.utf8)) + let editWithCorrectedRange = calculateStartAndEndRangeFromSuggestion( + request: request, + suggestion: edit.text + ) + return editWithCorrectedRange + } + + private func calculateStartAndEndRangeFromSuggestion( + request: SuggestionRequest, + suggestion: String + ) -> CodeEdit { + let start = CodeEdit.Position( + line: request.cursorPosition.line, + character: 0 + ) + let suggestionLines = suggestion.components(separatedBy: "\n") + let endLine = request.cursorPosition.line + suggestionLines.count + let endCharacter = suggestionLines.last?.count ?? 0 + let end = CodeEdit.Position( + line: endLine, + character: endCharacter + ) + return CodeEdit( + text: suggestion, + range: CodeEdit.TextRange( + start: start, + end: end + ) + ) + } + + + private func detectLanguage(from path: String) -> String { + switch (path as NSString).pathExtension.lowercased() { + case "swift": return "Swift" + default: return "Unknown" + } + } + + private func buildMessagesForStructuredEdit(from req: SuggestionRequest) -> [ChatMessage] { + let language = detectLanguage(from: req.relativePath) + + let before = String(req.content.prefix(req.cursorOffset)) + let after = String(req.content.suffix(max(0, req.content.count - req.cursorOffset))) + + // Defensive: if the line index is in range, grab the full original line text + let lineIndex = max(0, min(req.cursorPosition.line, max(0, req.lines.count - 1))) + let currentLine = lineIndex < req.lines.count ? req.lines[lineIndex] : "" + + let indentDescriptor: String = req.usesTabsForIndentation + ? "tabs=\(req.tabSize)" + : "spaces=\(req.indentSize)" + + let related = req.relevantCodeSnippets.map { s in + """ + PATH: \(s.path) + LANG: \(s.language ?? "unknown") + ---- + \(s.code) + """ + }.joined(separator: "\n\n========\n\n") + + let system = ChatMessage(role: "system", content: """ + You are a \(language) inline code completion engine. + + Complete the body of only the current function. Produce meaningful, compilable code using identifiers in scope. No placeholders (e.g., "TODO", "// Implementation goes here"). + Return ONE compact JSON object and nothing else (no code fences, no prose). + + Semantics: + - Ranges use 0-based LSP-style coordinates and are half-open: (start, end). + - Coordinates are relative to the current (pre-edit) buffer. + - The replacement range MUST cover the entire CURRENT LINE (from character 0). + - The provided range MUST cover the entire last line UNTIL THE LAST CHARACTER. + - You SHOULD extend the range into following lines to complete the construct. + - The text BEFORE and AFTER is kept as-is; do not repeat it. + - The output text will be inserted at the cursor position, keeping BEFORE and AFTER as-is. + - As can be seen in AFTER, the closing brace `}` of the function to complete is already existing. The output text must NOT contain it. Otherwise it will be duplicated and the code won't compile. + + Output requirements: + - The "text" must include the full updated CURRENT LINE (where the cursor is) including the function definition and any additional lines needed to complete the implementation. + - Do NOT begin the text with the first bytes of AFTER. + - No placeholders or comments like "TODO" or "// Implementation goes here". + - Respect indentation \(indentDescriptor) and file style. + - Tabs are represented as spaces in the input; preserve that. + - Preserve newlines; avoid trailing whitespace. + + Respond only with JSON of shape: + { + "text": "STRING", + "range": { + "start": { "line": INT, "character": INT }, + "end": { "line": INT, "character": INT } + } + } + """) + + // Few-shot example teaches the model that we want a body, not just "}" + let exampleUser = ChatMessage(role: "user", content: + """ + EXAMPLE ONLY — DO NOT SOLVE THIS: + FILE: Example.swift + LANGUAGE_HINT: Swift + CURRENT_LINE_INDEX: 1 + CURRENT_LINE_TEXT: + ««« + func greet() {\n + »»» + CURSOR_CHARACTER: 18 + BEFORE: + ««« + struct Greeter {\n func greet() { + »»» + + AFTER: + ««« + \n }\n} + »»» + Respond with JSON: + { + "text": "func greet() {\n print(\"Hello\")\n", + "range": { "start": { "line": 1, "character": 0 }, "end": { "line": 3, "character": 5 } } + } + """ + ) + + // Instruct exact JSON shape + let user = ChatMessage(role: "user", content: + """ + FILE: \(req.relativePath) + LANGUAGE_HINT: \(language) + + CURRENT_LINE_INDEX: \(lineIndex) + CURRENT_LINE_TEXT: + ««« + \(currentLine) + »»» + CURSOR_CHARACTER: \(req.cursorPosition.character) + + BEFORE: + ««« + \(before) + »»» + + + + AFTER: + ««« + \(after) + »»» + + OPTIONAL RELEVANT CONTEXT: + ««« + \(related) + »»» + + Respond with **only** a compact JSON object of this shape (no code fences, no trailing text): + { + "text": "STRING — the replacement text (must include the full updated CURRENT LINE as it should appear after the edit; may include multiple lines if needed)", + "range": { + "start": { "line": INT, "character": INT }, + "end": { "line": INT, "character": INT } + } + } + Rules: + - Choose a minimal range that replaces from the start you need to change up to the end you need to change. + - The range MUST at least span the entire CURRENT LINE (i.e., covers from (CURRENT_LINE_INDEX, 0) to some end on the same or later line), so that the returned `text` fully defines that line after the edit. + - Do not include explanations or formatting fences. + """ + ) + + return [system, exampleUser, user] + } +} + +// MARK: - OpenAI Shapes + +private struct ChatPayload: Encodable { + let model: String + let messages: [ChatMessage] + let temperature: Double + let stream: Bool + let response_format: RF? + init(model: String, messages: [ChatMessage], temperature: Double, stream: Bool, response_format: RF? = nil) { + self.model = model + self.messages = messages + self.temperature = temperature + self.stream = stream + self.response_format = response_format + } +} + +private struct ChatMessage: Codable { + let role: String + let content: String +} + +private struct ChatResponse: Decodable { + struct Choice: Decodable { + struct Message: Decodable { let role: String; let content: String? } + let index: Int + let message: Message + let finishReason: String? + + private enum CodingKeys: String, CodingKey { + case index, message + case finishReason = "finish_reason" + } + } + let id: String + let choices: [Choice] +} + + +public struct CodeEdit: Codable, Sendable { + public struct Position: Codable, Sendable { + public let line: Int + public let character: Int + } + public struct TextRange: Codable, Sendable { + public let start: Position + public let end: Position + } + + /// Replacement text that MUST include the full (updated) content of the cursor's line. + public let text: String + public let range: TextRange +} + +extension CodeEdit { + func mapToSuggestionResponse(fileURL: URL, relevantSymbolsFromRequest: [SymbolContent]) -> SuggestionResponse { + let start = range.start + let end = range.end + let range = SuggestionBasic.CursorRange( + start: .init(line: start.line, character: start.character), + end: .init(line: end.line, character: end.character) + ) + let position = SuggestionBasic.CursorPosition( + line: start.line, + character: start.character + ) + return SuggestionResponse( + suggestion: SuggestionBasic.CodeSuggestion( + id: UUID().uuidString, + text: text, + position: position, + range: range + ), + fileURL: fileURL, + relevantSymbolsFromRequest: relevantSymbolsFromRequest + ) + } +} + +extension Array where Element == SymbolContent { + func mapToRelevantCodeSnippets() -> [SuggestionRequest.RelevantCodeSnippet] { + map { symbol in + .init( + path: symbol.fileURL, + language: "Swift", + code: symbol.content + ) + } + } +} + +extension CodeEdit { + func mapToCodeSuggestion(requestPosition: SuggestionRequest.CursorPosition) -> SuggestionBasic.CodeSuggestion { + let start = range.start + let end = range.end + return SuggestionBasic.CodeSuggestion( + id: UUID().uuidString, + text: text, + position: SuggestionBasic.CursorPosition( + line: requestPosition.line, + character: requestPosition.character + ), + range: SuggestionBasic.CursorRange( + start: .init(line: start.line, character: start.character), + end: .init(line: end.line, character: end.character) + ) + ) + } +} + +struct Greeter { + func greet() { + print("Hello") + } +} From a51d8e5d70d4266ee937867ac7db1c09e0569150 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Thu, 14 Aug 2025 19:59:00 +0200 Subject: [PATCH 35/66] improve prompt and remove closing brace if needed --- .../Domain/Manager/BenchmarkManager.swift | 55 +++++++------------ 1 file changed, 21 insertions(+), 34 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index 28a7511b..8e88ed7e 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -848,37 +848,25 @@ public extension OpenAICompletionRepository { // The model's entire reply is a JSON object. Decode it. let edit = try JSONDecoder().decode(CodeEdit.self, from: Data(content.utf8)) - let editWithCorrectedRange = calculateStartAndEndRangeFromSuggestion( - request: request, - suggestion: edit.text + let fixedSuggestionText = removeClosingBraceIfNeeded( + suggestion: edit.text, + promptCode: request.content, + line: request.cursorPosition.line ) - return editWithCorrectedRange + let fixedEdit = CodeEdit(text: fixedSuggestionText, range: edit.range) + return fixedEdit } - private func calculateStartAndEndRangeFromSuggestion( - request: SuggestionRequest, - suggestion: String - ) -> CodeEdit { - let start = CodeEdit.Position( - line: request.cursorPosition.line, - character: 0 - ) - let suggestionLines = suggestion.components(separatedBy: "\n") - let endLine = request.cursorPosition.line + suggestionLines.count - let endCharacter = suggestionLines.last?.count ?? 0 - let end = CodeEdit.Position( - line: endLine, - character: endCharacter - ) - return CodeEdit( - text: suggestion, - range: CodeEdit.TextRange( - start: start, - end: end - ) - ) + private func removeClosingBraceIfNeeded(suggestion: String, promptCode: String, line: Int) -> String { + let lines = suggestion.components(separatedBy: "\n") + let promptCodeLines = promptCode.components(separatedBy: "\n") + let lineAfterCursor = promptCodeLines[line+1] + if lineAfterCursor == lines.last { + return lines.dropLast().joined(separator: "\n") + } else { + return suggestion + } } - private func detectLanguage(from path: String) -> String { switch (path as NSString).pathExtension.lowercased() { @@ -917,17 +905,16 @@ public extension OpenAICompletionRepository { Return ONE compact JSON object and nothing else (no code fences, no prose). Semantics: - - Ranges use 0-based LSP-style coordinates and are half-open: (start, end). + - Range describes from what line and character to what line and character the output text should be replaced + - Ranges use 0-based LSP-style coordinates: (start, end). - Coordinates are relative to the current (pre-edit) buffer. - The replacement range MUST cover the entire CURRENT LINE (from character 0). - - The provided range MUST cover the entire last line UNTIL THE LAST CHARACTER. - - You SHOULD extend the range into following lines to complete the construct. - - The text BEFORE and AFTER is kept as-is; do not repeat it. - - The output text will be inserted at the cursor position, keeping BEFORE and AFTER as-is. - - As can be seen in AFTER, the closing brace `}` of the function to complete is already existing. The output text must NOT contain it. Otherwise it will be duplicated and the code won't compile. + - You can extend the range into following lines to complete the construct. + - The output text will be inserted in the given range. Output requirements: - The "text" must include the full updated CURRENT LINE (where the cursor is) including the function definition and any additional lines needed to complete the implementation. + - IMPORTANT: As can be seen in AFTER, the closing brace `}` of the function to complete is already existing. It MUST not be included in output text. Otherwise a duplicated brace will lead to a compilation error. - Do NOT begin the text with the first bytes of AFTER. - No placeholders or comments like "TODO" or "// Implementation goes here". - Respect indentation \(indentDescriptor) and file style. @@ -968,7 +955,7 @@ public extension OpenAICompletionRepository { Respond with JSON: { "text": "func greet() {\n print(\"Hello\")\n", - "range": { "start": { "line": 1, "character": 0 }, "end": { "line": 3, "character": 5 } } + "range": { "start": { "line": 1, "character": 0 }, "end": { "line": 1, "character": 18 } } } """ ) From 192e32c04c09b8357434e35a93060907f20e5fa9 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Thu, 14 Aug 2025 20:11:13 +0200 Subject: [PATCH 36/66] remove unused code --- .../Domain/Manager/BenchmarkManager.swift | 138 +----------------- 1 file changed, 2 insertions(+), 136 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index 8e88ed7e..4e510a5a 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -645,11 +645,6 @@ public struct CompletionSuggestion: Sendable { // MARK: - Protocol public protocol CodeCompletionRepository: Sendable { - /// Non-streaming variant that returns a single suggestion string. -// func suggestion( -// for request: SuggestionRequest -// ) async throws -> CompletionSuggestion - func structuredEdit(for request: SuggestionRequest) async throws -> CodeEdit } @@ -685,6 +680,8 @@ public struct OpenAICompletionRepository: CodeCompletionRepository, Sendable { } } + struct JSONOnly: Encodable { let type: String = "json_object" } + private let config: Config private let urlSession: URLSession @@ -693,131 +690,6 @@ public struct OpenAICompletionRepository: CodeCompletionRepository, Sendable { self.urlSession = session } - // MARK: Public API - -// public func suggestion(for request: SuggestionRequest) async throws -> CompletionSuggestion { -// let url = config.baseURL.appendingPathComponent("chat/completions") -// var req = URLRequest(url: url) -// req.httpMethod = "POST" -// req.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") -// if let org = config.organization { req.setValue(org, forHTTPHeaderField: "OpenAI-Organization") } -// req.setValue("application/json", forHTTPHeaderField: "Content-Type") -// -// let payload = ChatPayload( -// model: config.model, -// messages: buildMessages(from: request), -// temperature: 0, -// stream: false, -// response_format: nil // No structured output requested -// ) -// req.httpBody = try JSONEncoder().encode(payload) -// -// let (data, resp) = try await urlSession.data(for: req) -// guard let http = resp as? HTTPURLResponse else { throw OpenAIError.decoding } -// guard 200..<300 ~= http.statusCode else { -// let body = String(data: data, encoding: .utf8) ?? "" -// throw OpenAIError.badResponse(status: http.statusCode, body: body) -// } -// -// let decoded = try JSONDecoder().decode(ChatResponse.self, from: data) -// guard let choice = decoded.choices.first else { throw OpenAIError.emptyChoice } -// return CompletionSuggestion( -// insertText: choice.message.content ?? "", -// finishReason: choice.finishReason -// ) -// } - - // MARK: - Prompt construction - - /// Builds a strict prompt that returns only the code to insert at . - private func buildMessages(from req: SuggestionRequest) -> [ChatMessage] { - let indentDescriptor: String = { - if req.usesTabsForIndentation { return "tabs=\(req.tabSize)" } - return "spaces=\(req.indentSize)" - }() - - // Surrounding context (prefix/suffix) based on cursorOffset - let before = String(req.content.prefix(req.cursorOffset)) - let after = String(req.content.suffix(max(0, req.content.count - req.cursorOffset))) - - // Include some relevant separate snippets (e.g., related files / symbols) - let related = req.relevantCodeSnippets.map { s in - """ - PATH: \(s.path) - LANG: \(s.language ?? "unknown") - ---- - \(s.code) - """ - }.joined(separator: "\n\n========\n\n") - - let system = ChatMessage( - role: "system", - content: -""" -You are a **code completion engine** integrated into Xcode IDE. -- The user sends source code with a marker or with BEFORE/AFTER sections. -- Return **only** the code to insert at the cursor. **No prose, no fences, no echo**. -- Respect indentation (\(indentDescriptor)). -- Prefer short, compilable, context-aware continuations. -- Do not duplicate text already present in AFTER. -""" - ) - - let user = ChatMessage( - role: "user", - content: -""" -FILE: \(req.relativePath) - -BEFORE: -««« -\(before) -»»» - -< CURSOR > - -AFTER: -««« -\(after) -»»» - -RELEVANT CONTEXT (optional sections across the workspace): -««« -\(related) -»»» - -Constraints: -- Output must be only the inserted code (no explanations). -- No changes can be made in RELEVANT CONTEXT. -- Do not repeat characters from AFTER. -- Keep indentation/style consistent with BEFORE. -""" - ) - - return [system, user] - } - - // MARK: - SSE parsing - - private func parseDeltaChunk(jsonLine: String) throws -> String? { - // Matches OpenAI stream schema for chat.completions: - // { "id": "...","choices":[{"delta":{"content":"..."},"finish_reason":null,...}], ... } - struct StreamEnvelope: Decodable { - struct Choice: Decodable { - struct Delta: Decodable { let content: String? } - let delta: Delta - } - let choices: [Choice] - } - let data = Data(jsonLine.utf8) - let env = try JSONDecoder().decode(StreamEnvelope.self, from: data) - return env.choices.first?.delta.content - } -} - -public extension OpenAICompletionRepository { - - struct JSONOnly: Encodable { let type: String = "json_object" } public func structuredEdit(for request: SuggestionRequest) async throws -> CodeEdit { var urlRequest = URLRequest(url: config.baseURL.appendingPathComponent("chat/completions")) @@ -1118,9 +990,3 @@ extension CodeEdit { ) } } - -struct Greeter { - func greet() { - print("Hello") - } -} From dea04781b27227d0bb7c71d4ec6e7da55d51d1ec Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Thu, 14 Aug 2025 22:05:17 +0200 Subject: [PATCH 37/66] add TextField to store OpenAI key --- .../Benchmark/DI/BenchmarkModule.swift | 6 +- .../LocalBenchmarkSettingsRepository.swift | 18 +++ .../BenchmarkSettingsRepository.swift | 2 + .../Presentation/View/BenchmarkView.swift | 136 +++++++++--------- ...ew.swift => ConfigurationButtonView.swift} | 6 +- ...tionView.swift => ConfigurationView.swift} | 14 +- ...del.swift => ConfigurationViewModel.swift} | 15 +- 7 files changed, 122 insertions(+), 75 deletions(-) rename Core/Sources/HostApp/Benchmark/Presentation/View/{OutputConfigurationButtonView.swift => ConfigurationButtonView.swift} (70%) rename Core/Sources/HostApp/Benchmark/Presentation/View/{OutputConfigurationView.swift => ConfigurationView.swift} (75%) rename Core/Sources/HostApp/Benchmark/Presentation/ViewModel/{OutputConfigurationViewModel.swift => ConfigurationViewModel.swift} (64%) diff --git a/Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift b/Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift index b59edef0..4ea79a0e 100644 --- a/Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift +++ b/Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift @@ -1,6 +1,6 @@ protocol BenchmarkModuleType { func provide() -> BenchmarkViewModel - func provide() -> OutputConfigurationViewModel + func provide() -> ConfigurationViewModel func provide() -> AddDirectoryViewModel } @@ -31,8 +31,8 @@ class BenchmarkModule: BenchmarkModuleType { ) } - func provide() -> OutputConfigurationViewModel { - OutputConfigurationViewModel( + func provide() -> ConfigurationViewModel { + ConfigurationViewModel( benchmarkSettingsRepository: component() ) } diff --git a/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift b/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift index a9bb6852..ba5e73d7 100644 --- a/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift +++ b/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift @@ -5,6 +5,7 @@ class LocalBenchmarkSettingsRepository: BenchmarkSettingsRepository { private let localStorageManager: LocalStorageManager private let benchmarkDirectoriesKey = "benchmarkDirectories" private let benchmarkOutputDirectoryKey = "benchmarkOutputDirectory" + private let openAIKeyDefaultsKey = "openAIKey" private var defaultOutputDirectory: URL { FileManager.default.homeDirectoryForCurrentUser @@ -21,6 +22,11 @@ class LocalBenchmarkSettingsRepository: BenchmarkSettingsRepository { benchmarkOutputDirectory.eraseToAnyPublisher() } + private let currentOpenAIKey: CurrentValueSubject = CurrentValueSubject("") + var openAIKey: AnyPublisher { + currentOpenAIKey.eraseToAnyPublisher() + } + init(localStorageManager: LocalStorageManager) { self.localStorageManager = localStorageManager if let benchmarkDirectories = try? retrieveBenchmarkDirectories() { @@ -29,6 +35,9 @@ class LocalBenchmarkSettingsRepository: BenchmarkSettingsRepository { if let outputDirectory = try? loadBenchmarkOutputDirectory() { benchmarkOutputDirectory.send(outputDirectory) } + if let openAIKey = try? loadOpenAIKey() { + currentOpenAIKey.send("") + } } func saveBenchmarkDirectory(_ directory: BenchmarkDirectory) throws { @@ -69,6 +78,15 @@ class LocalBenchmarkSettingsRepository: BenchmarkSettingsRepository { private func deleteBenchmarkOutputDirectory() { } + + private func loadOpenAIKey() throws -> String? { + return try? localStorageManager.load(key: openAIKeyDefaultsKey) + } + + func saveOpenAIKey(_ key: String) throws { + try localStorageManager.save(codable: key, key: openAIKeyDefaultsKey) + currentOpenAIKey.send(key) + } } extension Array where Element == BenchmarkDirectory { diff --git a/Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift b/Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift index 00631216..50d35efc 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift @@ -6,4 +6,6 @@ protocol BenchmarkSettingsRepository { func saveBenchmarkDirectory(_ directory: BenchmarkDirectory) throws func deleteBenchmarkDirectory(_ directory: BenchmarkDirectory) throws func saveBenchmarkOutputDirectory(_ directory: String) throws + var openAIKey: AnyPublisher { get } + func saveOpenAIKey(_ key: String) throws } diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift index d7f37bc0..763ff643 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift @@ -20,34 +20,37 @@ struct BenchmarkView: View { .font(.title) .frame(maxWidth: .infinity, alignment: .leading) Toggle("Multi File Context Enabled", isOn: $viewModel.isMultiFileContextEnabled) - OutputConfigurationButtonView(module: module) + ConfigurationButtonView(module: module) } .padding(.vertical) - VStack { - ForEach(viewModel.benchmarkDirectories, id: \.self) { directory in - HStack { - DirectoryNameView(directory: directory) - .frame(maxWidth: .infinity, alignment: .leading) - Button { - Task { - try? await viewModel.runBenchmark(for: directory) + ForEach(viewModel.benchmarkDirectories, id: \.self) { directory in + VStack { + VStack(alignment: .leading, spacing: 10) { + HStack { + DirectoryNameView(directory: directory) + .frame(maxWidth: .infinity, alignment: .leading) + Button { + Task { + try? await viewModel.runBenchmark(for: directory) + } + } label: { + Image(systemName: "play") + } + Button { + NSWorkspace.shared.open(directory.url) + } label: { + Image(systemName: "folder") + } + Button { + viewModel.deleteDirectory(directory) + } label: { + Image(systemName: "trash") + .foregroundColor(.red) } - } label: { - Image(systemName: "play") - } - Button { - NSWorkspace.shared.open(directory.url) - } label: { - Image(systemName: "folder") - } - Button { - viewModel.deleteDirectory(directory) - } label: { - Image(systemName: "trash") - .foregroundColor(.red) } } } + taskList(directory: directory) } AddDirectoryButtonView(module: module) .padding(.vertical, 10) @@ -56,55 +59,56 @@ struct BenchmarkView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding() } - VStack(alignment: .leading, spacing: 12) { - if viewModel.taskStates.isEmpty { - Text("💤 No benchmarks running right now.") - .foregroundColor(.secondary) - } else { - VStack(alignment: .leading, spacing: 8) { - Text("📋 Task Status") - .font(.headline) - ScrollView { - LazyVStack(alignment: .leading, spacing: 8) { - ForEach(Array(viewModel.taskStates.enumerated()), id: \.offset) { index, taskStatus in - HStack { - Text("Task \(index + 1)") - .frame(maxWidth: .infinity, alignment: .leading) - - Group { - switch taskStatus { - case .notStarted: - Text("🕒") - .accessibilityLabel("Not started") - case .running: - ProgressView() - .scaleEffect(0.6) - .accessibilityLabel("Running") - case .success: - Text("✅") - .accessibilityLabel("Success") - case .failure: - Text("❌") - .accessibilityLabel("Failed") - } - } - .frame(width: 24, height: 24, alignment: .center) - } - .padding(.vertical, 2) - } + } + .onAppear { + viewModel.loadBenchmarkDirectories() + } + } + + func taskList(directory: BenchmarkDirectory) -> some View { + VStack(alignment: .leading, spacing: 8) { +// Text("📋 Task Status") +// .font(.headline) + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(Array(viewModel.taskStates.enumerated()), id: \.offset) { index, taskStatus in + HStack { + Button { + Task { + await viewModel.runTask(index: index, in: directory) + } + } label: { + Image(systemName: "play") + } + + Text("Task \(index + 1)") + .frame(maxWidth: .infinity, alignment: .leading) + + Group { + switch taskStatus { + case .notStarted: + Text("") + .accessibilityLabel("Not started") + case .scheduled: + Text("🕒") + .accessibilityLabel("Scheduled") + case .running: + ProgressView() + .scaleEffect(0.6) + .accessibilityLabel("Running") + case .success: + Text("✅") + .accessibilityLabel("Success") + case .failure: + Text("❌") + .accessibilityLabel("Failed") } } + .frame(width: 24, height: 24, alignment: .center) } - .frame(height: taskStatusInfoHeight) + .padding(.vertical, 2) } } - .frame(maxWidth: .infinity, minHeight: taskStatusInfoHeight, alignment: .leading) - .padding() - .background(Color(.windowBackgroundColor)) - .cornerRadius(8) - } - .onAppear { - viewModel.loadBenchmarkDirectories() } + .padding(.leading, 20) } } diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/OutputConfigurationButtonView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/ConfigurationButtonView.swift similarity index 70% rename from Core/Sources/HostApp/Benchmark/Presentation/View/OutputConfigurationButtonView.swift rename to Core/Sources/HostApp/Benchmark/Presentation/View/ConfigurationButtonView.swift index 7ca5e3f2..ff0dcd68 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/View/OutputConfigurationButtonView.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/ConfigurationButtonView.swift @@ -1,6 +1,6 @@ import SwiftUI -struct OutputConfigurationButtonView: View { +struct ConfigurationButtonView: View { private let module: BenchmarkModuleType @State private var isShowingSheet = false @@ -12,10 +12,10 @@ struct OutputConfigurationButtonView: View { Button { isShowingSheet.toggle() } label : { - Label("Configure Output", systemImage: "gear") + Label("Configure", systemImage: "gear") } .sheet(isPresented: $isShowingSheet) { - OutputConfigurationView(module: module) + ConfigurationView(module: module) } } } diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/OutputConfigurationView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/ConfigurationView.swift similarity index 75% rename from Core/Sources/HostApp/Benchmark/Presentation/View/OutputConfigurationView.swift rename to Core/Sources/HostApp/Benchmark/Presentation/View/ConfigurationView.swift index 295be135..a1516fc8 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/View/OutputConfigurationView.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/ConfigurationView.swift @@ -1,8 +1,9 @@ import SwiftUI -struct OutputConfigurationView: View { +struct ConfigurationView: View { @State private var currentOutputDirectory: String = "" - @StateObject var viewModel: OutputConfigurationViewModel + @State private var currentOpenAIKey: String = "" + @StateObject var viewModel: ConfigurationViewModel @Environment(\.dismiss) private var dismiss private let labelWidth: CGFloat = 120 @@ -13,6 +14,13 @@ struct OutputConfigurationView: View { var body: some View { VStack { + HStack(alignment: .top) { + Text("OpenAI API Key:") + .frame(width: labelWidth, alignment: .leading) + TextField("Key", text: $currentOpenAIKey, prompt: Text("Key")) + .textFieldStyle(PlainTextFieldStyle()) + } + .padding([.top, .horizontal]) HStack(alignment: .top) { Text("Output Directory:") .frame(width: labelWidth, alignment: .leading) @@ -34,6 +42,7 @@ struct OutputConfigurationView: View { } Button("Save") { viewModel.saveOutputDirectory(currentOutputDirectory) + viewModel.saveOpenAIKey(currentOpenAIKey) dismiss() } } @@ -41,6 +50,7 @@ struct OutputConfigurationView: View { } .onAppear { currentOutputDirectory = viewModel.outputDirectory + currentOpenAIKey = viewModel.openAIKey } } diff --git a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/OutputConfigurationViewModel.swift b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/ConfigurationViewModel.swift similarity index 64% rename from Core/Sources/HostApp/Benchmark/Presentation/ViewModel/OutputConfigurationViewModel.swift rename to Core/Sources/HostApp/Benchmark/Presentation/ViewModel/ConfigurationViewModel.swift index 8bbe400d..c9657eee 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/OutputConfigurationViewModel.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/ConfigurationViewModel.swift @@ -1,8 +1,9 @@ import Combine import Toast -class OutputConfigurationViewModel: ObservableObject { +class ConfigurationViewModel: ObservableObject { @Published var outputDirectory: String = "" + @Published var openAIKey: String = "" private var cancellables = Set() private var toast: ToastController { ToastControllerDependencyKey.liveValue } @@ -13,6 +14,9 @@ class OutputConfigurationViewModel: ObservableObject { benchmarkSettingsRepository.outputDirectory .assign(to: \.outputDirectory, on: self) .store(in: &cancellables) + benchmarkSettingsRepository.openAIKey + .assign(to: \.openAIKey, on: self) + .store(in: &cancellables) } func saveOutputDirectory(_ directory: String) { @@ -23,5 +27,14 @@ class OutputConfigurationViewModel: ObservableObject { toast.toast(content: "Failed changing output directory.", level: .error) } } + + func saveOpenAIKey(_ key: String) { + do { + try benchmarkSettingsRepository.saveOpenAIKey(key) + toast.toast(content: "OpenAI Key changed.", level: .info) + } catch { + toast.toast(content: "Failed changing OpenAI Key.", level: .error) + } + } } From 13a9abe661005ea191e186949ff21a443fe22038 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Thu, 14 Aug 2025 22:17:35 +0200 Subject: [PATCH 38/66] add option to run single tasks and connect OpenAI key from settings --- .../Domain/Manager/BenchmarkManager.swift | 55 +++++++++++++------ .../ViewModel/BenchmarkViewModel.swift | 4 ++ 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index 4e510a5a..ee21b7fa 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -34,6 +34,8 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { var taskStates: AnyPublisher<[TaskStatus], Never> { taskStatesSubject.eraseToAnyPublisher() } + private var openAIKey: String? + private var cancellables = Set() init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { @Dependency(\.workspacePool) var workspacePool @@ -51,24 +53,29 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { } self.workspacePool = workspacePool self.benchmarkSettingsRepository = benchmarkSettingsRepository + + benchmarkSettingsRepository.benchmarkDirectories.sink { [weak self] benchmarkDirectories in + for directory in benchmarkDirectories { + guard let taskPaths: [URL] = self?.getTaskFolders(in: directory.url) else { + return + } + let initialTaskStates = taskPaths.map { _ in TaskStatus.notStarted } + Task { await self?.updateTaskStates(initialTaskStates) } + } + }.store(in: &cancellables) + + benchmarkSettingsRepository.openAIKey.sink { [weak self] key in + self?.openAIKey = key + }.store(in: &cancellables) } func getCodeSuggestions(at benchmarkDirectory: BenchmarkDirectory) async throws { let taskPaths: [URL] = getTaskFolders(in: benchmarkDirectory.url) - let initialTaskStates = taskPaths.map { _ in TaskStatus.notStarted } + let initialTaskStates = taskPaths.map { _ in TaskStatus.scheduled } await updateTaskStates(initialTaskStates) - for (index, taskPath) in taskPaths.prefix(1).enumerated() { - await updateTaskStatus(.running, at: index) - if let suggestion = await getCodeSuggestionFromService(at: taskPath, from: benchmarkDirectory.url) { -// if let suggestion = await getCodeSuggestionFromOpenAI(at: taskPath, from: benchmarkDirectory.url) { - await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) - await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) - await updateTaskStatus(.success, at: index) - try await Task.sleep(nanoseconds: 3_000_000_000) - } else { - await updateTaskStatus(.failure, at: index) - } - await cleanUp() + for (index, _) in taskPaths.prefix(1).enumerated() { + await runTask(at: index, in: benchmarkDirectory) + try await Task.sleep(nanoseconds: 3_000_000_000) } } @@ -156,7 +163,22 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { await workspace.didUpdateFilespace(fileURL: entrypoint.fileURL, content: content, version: 1) } - func getCodeSuggestionFromOpenAI(at directory: URL, from benchmarkDirectory: URL) async -> SuggestionResponse? { + func runTask(at index: Int, in benchmarkDirectory: BenchmarkDirectory) async { + let taskPath = getTaskFolders(in: benchmarkDirectory.url)[index] + await updateTaskStatus(.running, at: index) +// if let suggestion = await getCodeSuggestionFromService(at: taskPath, from: benchmarkDirectory.url) { + if let openAIKey = openAIKey, + let suggestion = await getCodeSuggestionFromOpenAI(at: taskPath, from: benchmarkDirectory.url, key: openAIKey) { + await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) + await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) + await updateTaskStatus(.success, at: index) + } else { + await updateTaskStatus(.failure, at: index) + } + await cleanUp() + } + + func getCodeSuggestionFromOpenAI(at directory: URL, from benchmarkDirectory: URL, key: String) async -> SuggestionResponse? { guard let metadata: MetadataDTO = readMetadata(at: directory), let xcodeWorkspaceFileURL = findXcodeWorkspace(in: benchmarkDirectory), let workspace = try? await workspacePool.fetchOrCreateWorkspace(workspaceURL: xcodeWorkspaceFileURL) @@ -191,7 +213,7 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { relevantCodeSnippets: relevantSymbols.mapToRelevantCodeSnippets() ) let workspaceInfo = WorkspaceInfo(workspaceURL: xcodeWorkspaceFileURL, projectURL: benchmarkDirectory) - let repository = OpenAICompletionRepository(config: .init()) + let repository = OpenAICompletionRepository(config: .init(apiKey: key)) do { // only works when setting document version GitHubCopilotService to 0 let suggestion = try await repository.structuredEdit(for: suggestionRequest) @@ -597,6 +619,7 @@ enum TaskStatus { case failure case notStarted case running + case scheduled } @@ -668,7 +691,7 @@ public struct OpenAICompletionRepository: CodeCompletionRepository, Sendable { public var baseURL: URL public init( - apiKey: String = "", + apiKey: String, model: String = "gpt-4o-mini", organization: String? = nil, baseURL: URL = URL(string: "https://api.openai.com/v1")! diff --git a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift index 3a78cde9..9b3e6b50 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift @@ -46,4 +46,8 @@ class BenchmarkViewModel: ObservableObject { toast.toast(content: "Failed deleting directory.", level: .error) } } + + func runTask(index: Int, in directory: BenchmarkDirectory) async { + await benchmarkManager.runTask(at: index, in: directory) + } } From e05fda0b61f495d0212bf3d095535978bdd56896 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Thu, 14 Aug 2025 22:54:27 +0200 Subject: [PATCH 39/66] add picker to choose language model --- .../Domain/Manager/BenchmarkManager.swift | 70 +++++++++++++++++-- .../Presentation/View/BenchmarkView.swift | 5 ++ .../ViewModel/BenchmarkViewModel.swift | 9 +++ 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index ee21b7fa..44fc869c 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -35,6 +35,10 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { taskStatesSubject.eraseToAnyPublisher() } private var openAIKey: String? + private let selectedGenAIModelSubject = CurrentValueSubject(.defaultModel) + var selectedGenAIModel: AnyPublisher { + selectedGenAIModelSubject.eraseToAnyPublisher() + } private var cancellables = Set() init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { @@ -166,9 +170,19 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { func runTask(at index: Int, in benchmarkDirectory: BenchmarkDirectory) async { let taskPath = getTaskFolders(in: benchmarkDirectory.url)[index] await updateTaskStatus(.running, at: index) -// if let suggestion = await getCodeSuggestionFromService(at: taskPath, from: benchmarkDirectory.url) { - if let openAIKey = openAIKey, - let suggestion = await getCodeSuggestionFromOpenAI(at: taskPath, from: benchmarkDirectory.url, key: openAIKey) { + if case .githubCopilot = selectedGenAIModelSubject.value, + let suggestion = await getCodeSuggestionFromService(at: taskPath, from: benchmarkDirectory.url) { + await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) + await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) + await updateTaskStatus(.success, at: index) + } else if let openAIKey = openAIKey, + case let .openAI(model) = selectedGenAIModelSubject.value, + let suggestion = await getCodeSuggestionFromOpenAI( + at: taskPath, + from: benchmarkDirectory.url, + key: openAIKey, + model: model.rawValue + ) { await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) await updateTaskStatus(.success, at: index) @@ -178,7 +192,7 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { await cleanUp() } - func getCodeSuggestionFromOpenAI(at directory: URL, from benchmarkDirectory: URL, key: String) async -> SuggestionResponse? { + func getCodeSuggestionFromOpenAI(at directory: URL, from benchmarkDirectory: URL, key: String, model: String) async -> SuggestionResponse? { guard let metadata: MetadataDTO = readMetadata(at: directory), let xcodeWorkspaceFileURL = findXcodeWorkspace(in: benchmarkDirectory), let workspace = try? await workspacePool.fetchOrCreateWorkspace(workspaceURL: xcodeWorkspaceFileURL) @@ -213,7 +227,7 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { relevantCodeSnippets: relevantSymbols.mapToRelevantCodeSnippets() ) let workspaceInfo = WorkspaceInfo(workspaceURL: xcodeWorkspaceFileURL, projectURL: benchmarkDirectory) - let repository = OpenAICompletionRepository(config: .init(apiKey: key)) + let repository = OpenAICompletionRepository(config: .init(apiKey: key, model: model)) do { // only works when setting document version GitHubCopilotService to 0 let suggestion = try await repository.structuredEdit(for: suggestionRequest) @@ -415,6 +429,11 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { return nil } + @MainActor + func changeGenAIModel(to newModel: GenAILanguageModel) { + selectedGenAIModelSubject.send(newModel) + } + @MainActor func updateMultiFileContextState(_ newValue: Bool) { isMultiFileEnabledSubject.send(newValue) @@ -692,7 +711,7 @@ public struct OpenAICompletionRepository: CodeCompletionRepository, Sendable { public init( apiKey: String, - model: String = "gpt-4o-mini", + model: String, organization: String? = nil, baseURL: URL = URL(string: "https://api.openai.com/v1")! ) { @@ -1013,3 +1032,42 @@ extension CodeEdit { ) } } + +enum GenAILanguageModel: Hashable, CaseIterable, Identifiable { + case githubCopilot + case openAI(OpenAIModel) + + static var allCases: [GenAILanguageModel] { + [.githubCopilot] + OpenAIModel.allCases.map { .openAI($0) } + } + + var id: String { + switch self { + case .githubCopilot: return "githubCopilot" + case .openAI(let m): return "openAI:\(m.rawValue)" + } + } + + var name: String { + switch self { + case .githubCopilot: return "GitHub Copilot" + case .openAI(let m): return m.displayName + } + } + + static let defaultModel: GenAILanguageModel = .githubCopilot +} + +enum OpenAIModel: String, CaseIterable, Hashable { + case gpt4oMini = "gpt-4o-mini" + case gpt4o = "gpt-4o" + case gpt5 = "gpt-5" + + var displayName: String { + switch self { + case .gpt4oMini: return "GPT-4o mini" + case .gpt4o: return "GPT-4o" + case .gpt5: return "GPT-5" + } + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift index 763ff643..1b9ac181 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift @@ -20,6 +20,11 @@ struct BenchmarkView: View { .font(.title) .frame(maxWidth: .infinity, alignment: .leading) Toggle("Multi File Context Enabled", isOn: $viewModel.isMultiFileContextEnabled) + Picker("Language Model: ", selection: $viewModel.selectedLanguageModel) { + ForEach(GenAILanguageModel.allCases, id: \.self) { model in + Text(model.name).tag(model) + } + } ConfigurationButtonView(module: module) } .padding(.vertical) diff --git a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift index 9b3e6b50..7a76a595 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift @@ -10,6 +10,12 @@ class BenchmarkViewModel: ObservableObject { } } @Published private(set) var taskStates: [TaskStatus] = [] + @Published var selectedLanguageModel: GenAILanguageModel = .defaultModel { + didSet { + guard oldValue != selectedLanguageModel else { return } + Task { await benchmarkManager.changeGenAIModel(to: selectedLanguageModel) } + } + } private let benchmarkSettingsRepository: BenchmarkSettingsRepository private let benchmarkManager: RealtimeSuggestionControllerBenchmarkManager @@ -23,6 +29,9 @@ class BenchmarkViewModel: ObservableObject { .assign(to: \.isMultiFileContextEnabled, on: self) .store(in: &cancellables) benchmarkManager.taskStates.assign(to: \.taskStates, on: self).store(in: &cancellables) + benchmarkManager.selectedGenAIModel + .assign(to: \.selectedLanguageModel, on: self) + .store(in: &cancellables) } func loadBenchmarkDirectories() { From b4b658bd5f5cce00b57102b3d8c2dd4678d61562 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Thu, 14 Aug 2025 23:16:04 +0200 Subject: [PATCH 40/66] increase spacing --- .../HostApp/Benchmark/Presentation/View/BenchmarkView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift index 1b9ac181..08f83213 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift @@ -76,7 +76,7 @@ struct BenchmarkView: View { // .font(.headline) LazyVStack(alignment: .leading, spacing: 8) { ForEach(Array(viewModel.taskStates.enumerated()), id: \.offset) { index, taskStatus in - HStack { + HStack(spacing: 20) { Button { Task { await viewModel.runTask(index: index, in: directory) From 8a1502ad86480ee2136733a9ad72fffd2c49d9cf Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Thu, 14 Aug 2025 23:34:41 +0200 Subject: [PATCH 41/66] add model info to output log --- .../Domain/Manager/BenchmarkManager.swift | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index 44fc869c..dc321593 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -130,7 +130,8 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { return .init( suggestion: firstSuggestion, fileURL: entrypoint.fileURL, - relevantSymbolsFromRequest: relevantSymbols + relevantSymbolsFromRequest: relevantSymbols, + model: selectedGenAIModelSubject.value ) } catch { print("CK \(error)") @@ -240,7 +241,8 @@ class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { ) ), fileURL: entrypoint.fileURL, - relevantSymbolsFromRequest: relevantSymbols + relevantSymbolsFromRequest: relevantSymbols, + model: selectedGenAIModelSubject.value ) } catch { print("CK \(error)") @@ -536,6 +538,7 @@ struct SuggestionResponse { let suggestion: SuggestionBasic.CodeSuggestion let fileURL: URL let relevantSymbolsFromRequest: [SymbolContent] + let model: GenAILanguageModel } @@ -561,6 +564,7 @@ struct StoredSuggestionDTO: Codable { let range: CursorRangeDTO let createdAt: String let relevantSymbols: [RelevantSymbolsDTO] + let model: String struct CursorPositionDTO: Codable { let line: Int @@ -603,7 +607,8 @@ extension SuggestionResponse { ) ), createdAt: timestamp, - relevantSymbols: relevantSymbolsFromRequest.map { $0.toStoredDTO() } + relevantSymbols: relevantSymbolsFromRequest.map { $0.toStoredDTO() }, + model: model.id ) } } @@ -978,7 +983,11 @@ public struct CodeEdit: Codable, Sendable { } extension CodeEdit { - func mapToSuggestionResponse(fileURL: URL, relevantSymbolsFromRequest: [SymbolContent]) -> SuggestionResponse { + func mapToSuggestionResponse( + fileURL: URL, + relevantSymbolsFromRequest: [SymbolContent], + model: GenAILanguageModel + ) -> SuggestionResponse { let start = range.start let end = range.end let range = SuggestionBasic.CursorRange( @@ -997,7 +1006,8 @@ extension CodeEdit { range: range ), fileURL: fileURL, - relevantSymbolsFromRequest: relevantSymbolsFromRequest + relevantSymbolsFromRequest: relevantSymbolsFromRequest, + model: model ) } } From 474183aabfe6ed107213bdec61b653f0132b00d3 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Fri, 15 Aug 2025 00:27:08 +0200 Subject: [PATCH 42/66] restructure manager and cleanup --- .../Benchmark/DI/BenchmarkModule.swift | 9 +- .../Benchmark/Data/Entity/MetadataDTO.swift | 21 + .../Data/Entity/StoredSuggestionDTO.swift | 29 + .../MultiFileContextBenchmarkManager.swift | 575 +++++++++ .../Domain/Entity/SuggestionRequest.swift | 27 + .../Domain/Entity/SuggestionResponse.swift | 10 + .../Benchmark/Domain/Entity/TaskStatus.swift | 7 + .../Domain/Manager/BenchmarkManager.swift | 1084 +---------------- .../Data/Entity/ChatPayload.swift | 44 + .../Data/Entity/CodeEdit.swift | 14 + .../OpenAICompletionRepository.swift | 231 ++++ .../Domain/Entity/GenAILanguageModel.swift | 38 + .../Repository/CodeCompletionRepository.swift | 3 + .../ViewModel/BenchmarkViewModel.swift | 9 +- 14 files changed, 1020 insertions(+), 1081 deletions(-) create mode 100644 Core/Sources/HostApp/Benchmark/Data/Entity/MetadataDTO.swift create mode 100644 Core/Sources/HostApp/Benchmark/Data/Entity/StoredSuggestionDTO.swift create mode 100644 Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift create mode 100644 Core/Sources/HostApp/Benchmark/Domain/Entity/SuggestionRequest.swift create mode 100644 Core/Sources/HostApp/Benchmark/Domain/Entity/SuggestionResponse.swift create mode 100644 Core/Sources/HostApp/Benchmark/Domain/Entity/TaskStatus.swift create mode 100644 Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Entity/ChatPayload.swift create mode 100644 Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Entity/CodeEdit.swift create mode 100644 Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift create mode 100644 Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Domain/Entity/GenAILanguageModel.swift create mode 100644 Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Domain/Repository/CodeCompletionRepository.swift diff --git a/Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift b/Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift index 4ea79a0e..3419e0af 100644 --- a/Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift +++ b/Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift @@ -25,9 +25,16 @@ class BenchmarkModule: BenchmarkModuleType { } } + private func component() -> BenchmarkManager { + MultiFileContextBenchmarkManager( + benchmarkSettingsRepository: component() + ) + } + func provide() -> BenchmarkViewModel { BenchmarkViewModel( - benchmarkSettingsRepository: component() + benchmarkSettingsRepository: component(), + benchmarkManager: component() ) } diff --git a/Core/Sources/HostApp/Benchmark/Data/Entity/MetadataDTO.swift b/Core/Sources/HostApp/Benchmark/Data/Entity/MetadataDTO.swift new file mode 100644 index 00000000..07211271 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Data/Entity/MetadataDTO.swift @@ -0,0 +1,21 @@ +struct MetadataDTO: Codable { + let taskId: Int + let entrypoint: EntrypointDTO + let files: [String] + + private enum CodingKeys: String, CodingKey { + case taskId = "task_id" + case entrypoint + case files + } + + struct EntrypointDTO: Codable { + let filename: String + let cursor: CursorPositionDTO + } + + struct CursorPositionDTO: Codable { + let line: Int + let character: Int + } +} diff --git a/Core/Sources/HostApp/Benchmark/Data/Entity/StoredSuggestionDTO.swift b/Core/Sources/HostApp/Benchmark/Data/Entity/StoredSuggestionDTO.swift new file mode 100644 index 00000000..4b5731ca --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Data/Entity/StoredSuggestionDTO.swift @@ -0,0 +1,29 @@ +struct StoredSuggestionDTO: Codable { + let fileURL: String + let id: String + let suggestionText: String + let position: CursorPositionDTO + let range: CursorRangeDTO + let createdAt: String + let relevantSymbols: [RelevantSymbolsDTO] + let model: String + + struct CursorPositionDTO: Codable { + let line: Int + let character: Int + } + + struct CursorRangeDTO: Codable { + let start: CursorPositionDTO + let end: CursorPositionDTO + } + + struct RelevantSymbolsDTO: Codable { + let fileURL: String + let name: String + let content: String + let startLine: Int + let endLine: Int + let kind: String + } +} diff --git a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift new file mode 100644 index 00000000..a2de146e --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift @@ -0,0 +1,575 @@ +import Foundation +import Combine +import Dependencies +import Workspace +import Service +import SuggestionBasic +import SuggestionProvider +import SuggestionService +import WorkspaceSuggestionService +import CopilotForXcodeKit +import BuiltinExtension +import GitHubCopilotService + +class MultiFileContextBenchmarkManager: BenchmarkManager { + private let benchmarkSettingsRepository: BenchmarkSettingsRepository + @WorkspaceActor + private let workspacePool: WorkspacePool + + private let isMultiFileEnabledSubject = CurrentValueSubject(true) + var isMultiFileEnabled: AnyPublisher { + isMultiFileEnabledSubject.eraseToAnyPublisher() + } + private let taskStatesSubject = CurrentValueSubject<[TaskStatus], Never>([]) + var taskStates: AnyPublisher<[TaskStatus], Never> { + taskStatesSubject.eraseToAnyPublisher() + } + private var openAIKey: String? + private let selectedGenAIModelSubject = CurrentValueSubject(.defaultModel) + var selectedGenAIModel: AnyPublisher { + selectedGenAIModelSubject.eraseToAnyPublisher() + } + private var cancellables = Set() + + init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { + @Dependency(\.workspacePool) var workspacePool + BuiltinExtensionManager.shared.setupExtensions([ + GitHubCopilotExtension(workspacePool: workspacePool) + ]) + workspacePool.registerPlugin { + SuggestionServiceWorkspacePlugin(workspace: $0) { SuggestionService.service() } + } + workspacePool.registerPlugin { + GitHubCopilotWorkspacePlugin(workspace: $0) + } + workspacePool.registerPlugin { + BuiltinExtensionWorkspacePlugin(workspace: $0) + } + self.workspacePool = workspacePool + self.benchmarkSettingsRepository = benchmarkSettingsRepository + + benchmarkSettingsRepository.benchmarkDirectories.sink { [weak self] benchmarkDirectories in + for directory in benchmarkDirectories { + guard let taskPaths: [URL] = self?.getTaskFolders(in: directory.url) else { + return + } + let initialTaskStates = taskPaths.map { _ in TaskStatus.notStarted } + Task { await self?.updateTaskStates(initialTaskStates) } + } + }.store(in: &cancellables) + + benchmarkSettingsRepository.openAIKey.sink { [weak self] key in + self?.openAIKey = key + }.store(in: &cancellables) + } + + func getCodeSuggestions(at benchmarkDirectory: BenchmarkDirectory) async throws { + let taskPaths: [URL] = getTaskFolders(in: benchmarkDirectory.url) + let initialTaskStates = taskPaths.map { _ in TaskStatus.scheduled } + await updateTaskStates(initialTaskStates) + for (index, _) in taskPaths.prefix(1).enumerated() { + await runTask(at: index, in: benchmarkDirectory) + try await Task.sleep(nanoseconds: 3_000_000_000) + } + } + + private func getCodeSuggestionFromService(at directory: URL, from benchmarkDirectory: URL) async -> SuggestionResponse? { + guard let metadata: MetadataDTO = readMetadata(at: directory), + let xcodeWorkspaceFileURL = findXcodeWorkspace(in: benchmarkDirectory), + let workspace = try? await workspacePool.fetchOrCreateWorkspace(workspaceURL: xcodeWorkspaceFileURL) + else { return nil } + let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkDirectory.path) + let content: String = (try? String(contentsOf: entrypoint.fileURL, encoding: .utf8)) ?? "" + + let multiFileContextManager = MultiFileContextManager( + workspaceProvider: ManualWorkspaceProvider(workspace: workspace), + parser: SwiftProgrammingLanguageSyntaxParser() + ) + let relevantSymbols: [SymbolContent] = await { + if isMultiFileEnabledSubject.value { + let symbols = await multiFileContextManager.retrieveRelevantSymbolsForFileContent(content: content) + return Array(symbols.values) + } else { + return [] + } + }() + + let suggestionRequest = SuggestionProvider.SuggestionRequest( + fileURL: entrypoint.fileURL, + relativePath: entrypoint.fileURL.path.replacingOccurrences(of: benchmarkDirectory.path, with: ""), + content: content, + originalContent: content, + lines: content.linesWithNewlineSuffix, + cursorPosition: entrypoint.cursor, + cursorOffset: content.cursorOffset(line: entrypoint.cursor.line, character: entrypoint.cursor.character) ?? 0, + tabSize: 4, + indentSize: 4, + usesTabsForIndentation: false, + relevantCodeSnippets: relevantSymbols.mapToRelevantCodeSnippets() + ) + let workspaceInfo = WorkspaceInfo(workspaceURL: xcodeWorkspaceFileURL, projectURL: benchmarkDirectory) + await openFilespaces(entrypoint: entrypoint, relevantSymbols: relevantSymbols, in: workspace) + await simulateInitialEditorChange(entrypoint: entrypoint, content: content, in: workspace) + do { + let suggestions = try await workspace.suggestionService?.getSuggestions(suggestionRequest, workspaceInfo: workspaceInfo) + await saveFilespace(entrypoint: entrypoint, in: workspace) + await closeFilespaces(entrypoint: entrypoint, relevantSymbols: relevantSymbols, in: workspace) + guard let suggestions, let firstSuggestion = suggestions.first else { return nil } + return .init( + suggestion: firstSuggestion, + fileURL: entrypoint.fileURL, + relevantSymbolsFromRequest: relevantSymbols, + model: selectedGenAIModelSubject.value + ) + } catch { + return nil + } + } + + private func openFilespaces(entrypoint: EntryPoint, relevantSymbols: [SymbolContent], in workspace: Workspace) async { + for symbol in relevantSymbols { + if let filespace = try? await workspace.createFilespaceIfNeeded(fileURL: URL(fileURLWithPath: symbol.fileURL)) { + await workspace.didOpenFilespace(filespace) + } + } + if let filespace = try? await workspace.createFilespaceIfNeeded(fileURL: entrypoint.fileURL) { + await workspace.didOpenFilespace(filespace) + } + } + + private func closeFilespaces(entrypoint: EntryPoint, relevantSymbols: [SymbolContent], in workspace: Workspace) async { + for symbol in relevantSymbols { + await workspace.didCloseFilespace(URL(fileURLWithPath: symbol.fileURL)) + } + await workspace.didCloseFilespace(entrypoint.fileURL) + } + + private func saveFilespace(entrypoint: EntryPoint, in workspace: Workspace) async { + if let filespace = try? await workspace.createFilespaceIfNeeded(fileURL: entrypoint.fileURL) { + await workspace.didSaveFilespace(filespace) + } + } + + private func simulateInitialEditorChange(entrypoint: EntryPoint, content: String, in workspace: Workspace) async { + await workspace.didUpdateFilespace(fileURL: entrypoint.fileURL, content: content, version: 1) + } + + func runTask(at index: Int, in benchmarkDirectory: BenchmarkDirectory) async { + let taskPath = getTaskFolders(in: benchmarkDirectory.url)[index] + await updateTaskStatus(.running, at: index) + if case .githubCopilot = selectedGenAIModelSubject.value, + let suggestion = await getCodeSuggestionFromService(at: taskPath, from: benchmarkDirectory.url) { + await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) + await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) + await updateTaskStatus(.success, at: index) + } else if let openAIKey = openAIKey, + case let .openAI(model) = selectedGenAIModelSubject.value, + let suggestion = await getCodeSuggestionFromOpenAI( + at: taskPath, + from: benchmarkDirectory.url, + key: openAIKey, + model: model.rawValue + ) { + await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) + await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) + await updateTaskStatus(.success, at: index) + } else { + await updateTaskStatus(.failure, at: index) + } + await cleanUp() + } + + private func getCodeSuggestionFromOpenAI(at directory: URL, from benchmarkDirectory: URL, key: String, model: String) async -> SuggestionResponse? { + guard let metadata: MetadataDTO = readMetadata(at: directory), + let xcodeWorkspaceFileURL = findXcodeWorkspace(in: benchmarkDirectory), + let workspace = try? await workspacePool.fetchOrCreateWorkspace(workspaceURL: xcodeWorkspaceFileURL) + else { return nil } + let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkDirectory.path) + let content: String = (try? String(contentsOf: entrypoint.fileURL, encoding: .utf8)) ?? "" + + let multiFileContextManager = MultiFileContextManager( + workspaceProvider: ManualWorkspaceProvider(workspace: workspace), + parser: SwiftProgrammingLanguageSyntaxParser() + ) + let relevantSymbols: [SymbolContent] = await { + if isMultiFileEnabledSubject.value { + let symbols = await multiFileContextManager.retrieveRelevantSymbolsForFileContent(content: content) + return Array(symbols.values) + } else { + return [] + } + }() + + let suggestionRequest = SuggestionRequest( + fileURL: entrypoint.fileURL, + relativePath: entrypoint.fileURL.path.replacingOccurrences(of: benchmarkDirectory.path, with: ""), + content: content, + originalContent: content, + lines: content.linesWithNewlineSuffix, + cursorPosition: .init(line: entrypoint.cursor.line, character: entrypoint.cursor.character), + cursorOffset: content.cursorOffset(line: entrypoint.cursor.line, character: entrypoint.cursor.character) ?? 0, + tabSize: 4, + indentSize: 4, + usesTabsForIndentation: false, + relevantCodeSnippets: relevantSymbols.mapToRelevantCodeSnippets() + ) + let repository = OpenAICompletionRepository(config: .init(apiKey: key, model: model)) + do { + // only works when setting document version GitHubCopilotService to 0 + let suggestion = try await repository.structuredEdit(for: suggestionRequest) +// let suggestions: [SuggestionBasic.CodeSuggestion]? = [exampleSuggestion] + return .init( + suggestion: suggestion.mapToCodeSuggestion( + requestPosition: SuggestionRequest.CursorPosition( + line: entrypoint.cursor.line, + character: entrypoint.cursor.character + ) + ), + fileURL: entrypoint.fileURL, + relevantSymbolsFromRequest: relevantSymbols, + model: selectedGenAIModelSubject.value + ) + } catch { + print("CK \(error)") + return nil + } + } + + private func applyCodeSuggestion(suggestion: SuggestionBasic.CodeSuggestion, at fileURL: URL) async { + do { + let content = try String(contentsOf: fileURL, encoding: .utf8) + var lines = content.components(separatedBy: .newlines) + + let start = suggestion.range.start + let end = suggestion.range.end + + // Check range bounds + guard start.line < lines.count, end.line < lines.count else { + print("Invalid range for suggestion in file: \(fileURL)") + return + } + + // Modify the content between start and end + if start.line == end.line { + var line = lines[start.line] + let startIndex = line.index(line.startIndex, offsetBy: start.character) + let endIndex = line.index(line.startIndex, offsetBy: end.character) + line.replaceSubrange(startIndex.. MetadataDTO? { + let metadataURL = taskFolder.appendingPathComponent("metadata.json") + do { + let data = try Data(contentsOf: metadataURL) + + let metadata = try JSONDecoder().decode(MetadataDTO.self, from: data) + return metadata + } catch { + print("Failed to read metadata at \(metadataURL):", error) + return nil + } + } + + private func getTaskFolders(in rootDirectory: URL) -> [URL] { + let fileManager = FileManager.default + var taskFolders: [URL] = [] + + let regex = try! NSRegularExpression(pattern: #"Task-\d+"#) + + let enumerator = fileManager.enumerator(at: rootDirectory, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) + + while let fileURL = enumerator?.nextObject() as? URL { + do { + let resourceValues = try fileURL.resourceValues(forKeys: [.isDirectoryKey]) + if resourceValues.isDirectory == true { + let folderName = fileURL.lastPathComponent + let range = NSRange(location: 0, length: folderName.utf16.count) + if regex.firstMatch(in: folderName, options: [], range: range) != nil { + guard !fileURL.path.contains("/Tests/") else { continue } + taskFolders.append(fileURL) + } + } + } catch { + print("Failed to read directory attributes for \(fileURL):", error) + } + } + + return taskFolders.sorted { lhs, rhs in + let lhsNumber = extractTaskNumber(from: lhs.lastPathComponent) ?? 0 + let rhsNumber = extractTaskNumber(from: rhs.lastPathComponent) ?? 0 + return lhsNumber < rhsNumber + } + } + + private func extractTaskNumber(from name: String) -> Int? { + let regex = try! NSRegularExpression(pattern: #"Task-(\d+)"#) + let range = NSRange(location: 0, length: name.utf16.count) + if let match = regex.firstMatch(in: name, options: [], range: range), + let numberRange = Range(match.range(at: 1), in: name) { + return Int(name[numberRange]) + } + return nil + } + + private func findXcodeWorkspace(in directory: URL) -> URL? { + let fileManager = FileManager.default + let enumerator = fileManager.enumerator(at: directory, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) + + while let fileURL = enumerator?.nextObject() as? URL { + if fileURL.pathExtension == "xcworkspace" { + return fileURL + } + } + + return nil + } + + @MainActor + func changeGenAIModel(to newModel: GenAILanguageModel) { + selectedGenAIModelSubject.send(newModel) + } + + @MainActor + func updateMultiFileContextState(_ newValue: Bool) { + isMultiFileEnabledSubject.send(newValue) + } + + @MainActor + private func updateTaskStates(_ newValue: [TaskStatus]) { + taskStatesSubject.send(newValue) + } + + @MainActor + private func updateTaskStatus(_ newValue: TaskStatus, at index: Int) { + var updatedValue = taskStatesSubject.value + updatedValue[index] = newValue + taskStatesSubject.send(updatedValue) + } +} + +extension MetadataDTO { + func mapToEntrypoint(prefixing baseUrl: String) -> EntryPoint { + // Ensure there's exactly one "/" between the base and the filename + let cleanedBase = baseUrl.hasSuffix("/") ? baseUrl : baseUrl + "/" + let cleanedFilename = entrypoint.filename.hasPrefix("/") ? String(entrypoint.filename.dropFirst()) : entrypoint.filename + let fullPath = cleanedBase + cleanedFilename + + return EntryPoint( + cursor: CursorPosition( + line: entrypoint.cursor.line, + character: entrypoint.cursor.character + ), + fileURL: URL(fileURLWithPath: fullPath) + ) + } +} + +extension String { + /// Splits the string by line and appends `\n` to each non-empty line + var linesWithNewlineSuffix: [String] { + self.split(separator: "\n", omittingEmptySubsequences: false) + .map { String($0) + "\n" } + } + + /// Calculates the character offset in the string from a line number and character index within that line + func cursorOffset(line: Int, character: Int) -> Int? { + let lines = self.split(separator: "\n", omittingEmptySubsequences: false) + guard line < lines.count else { return nil } + + // Sum up the lengths of all previous lines (+1 for each '\n') + let offsetBeforeLine = lines.prefix(line).reduce(0) { $0 + $1.count + 1 } + + // Ensure the character index doesn't exceed the current line length + guard character <= lines[line].count else { return nil } + + return offsetBeforeLine + character + } +} + +extension AnyPublisher where Failure == Never { + /// Awaits the first emitted value of the publisher (only for Failure == Never) + func firstValue() async -> Output? { + await withCheckedContinuation { continuation in + var cancellable: AnyCancellable? + cancellable = self.first().sink { value in + continuation.resume(returning: value) + cancellable?.cancel() + } + } + } +} + +extension SuggestionResponse { + func toStoredDTO(timestamp: String) -> StoredSuggestionDTO { + StoredSuggestionDTO( + fileURL: fileURL.path, + id: suggestion.id, + suggestionText: suggestion.text, + position: .init( + line: suggestion.position.line, + character: suggestion.position.character + ), + range: .init( + start: .init( + line: suggestion.range.start.line, + character: suggestion.range.start.character + ), + end: .init( + line: suggestion.range.end.line, + character: suggestion.range.end.character + ) + ), + createdAt: timestamp, + relevantSymbols: relevantSymbolsFromRequest.map { $0.toStoredDTO() }, + model: model.id + ) + } +} + +extension SymbolContent { + func toStoredDTO() -> StoredSuggestionDTO.RelevantSymbolsDTO { + .init( + fileURL: fileURL, + name: symbol.name, + content: content, + startLine: symbol.startLine, + endLine: symbol.endLine, + kind: symbol.kind.rawValue + ) + } +} + +extension Array where Element == SymbolContent { + func mapToRelevantCodeSnippets() -> [SuggestionProvider.RelevantCodeSnippet] { + map { symbol in + .init( + content: symbol.content, + priority: 0, + filePath: symbol.fileURL + ) + } + } +} + +extension Array where Element == SymbolContent { + func mapToRelevantCodeSnippets() -> [SuggestionRequest.RelevantCodeSnippet] { + map { symbol in + .init( + path: symbol.fileURL, + language: "Swift", + code: symbol.content + ) + } + } +} + +extension CodeEdit { + func mapToCodeSuggestion(requestPosition: SuggestionRequest.CursorPosition) -> SuggestionBasic.CodeSuggestion { + let start = range.start + let end = range.end + return SuggestionBasic.CodeSuggestion( + id: UUID().uuidString, + text: text, + position: SuggestionBasic.CursorPosition( + line: requestPosition.line, + character: requestPosition.character + ), + range: SuggestionBasic.CursorRange( + start: .init(line: start.line, character: start.character), + end: .init(line: end.line, character: end.character) + ) + ) + } +} diff --git a/Core/Sources/HostApp/Benchmark/Domain/Entity/SuggestionRequest.swift b/Core/Sources/HostApp/Benchmark/Domain/Entity/SuggestionRequest.swift new file mode 100644 index 00000000..c5a96f76 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Domain/Entity/SuggestionRequest.swift @@ -0,0 +1,27 @@ +import Foundation + +struct SuggestionRequest { + + struct CursorPosition { + var line: Int + var character: Int + } + + struct RelevantCodeSnippet { + var path: String + var language: String? + var code: String + } + + var fileURL: URL + var relativePath: String + var content: String + var originalContent: String + var lines: [String] + var cursorPosition: CursorPosition + var cursorOffset: Int + var tabSize: Int + var indentSize: Int + var usesTabsForIndentation: Bool + var relevantCodeSnippets: [RelevantCodeSnippet] +} diff --git a/Core/Sources/HostApp/Benchmark/Domain/Entity/SuggestionResponse.swift b/Core/Sources/HostApp/Benchmark/Domain/Entity/SuggestionResponse.swift new file mode 100644 index 00000000..93841cd3 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Domain/Entity/SuggestionResponse.swift @@ -0,0 +1,10 @@ +import Foundation +import SuggestionBasic +import Service + +struct SuggestionResponse { + let suggestion: SuggestionBasic.CodeSuggestion + let fileURL: URL + let relevantSymbolsFromRequest: [SymbolContent] + let model: GenAILanguageModel +} diff --git a/Core/Sources/HostApp/Benchmark/Domain/Entity/TaskStatus.swift b/Core/Sources/HostApp/Benchmark/Domain/Entity/TaskStatus.swift new file mode 100644 index 00000000..82969163 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Domain/Entity/TaskStatus.swift @@ -0,0 +1,7 @@ +enum TaskStatus { + case success + case failure + case notStarted + case running + case scheduled +} diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index dc321593..59af9f63 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -1,1083 +1,13 @@ -import Foundation -import AppKit -import Dependencies -import Workspace -import Service -import XcodeInspector -import PromptToCodeService -import SuggestionBasic -import SuggestionProvider -import SuggestionService -import WorkspaceSuggestionService -import CopilotForXcodeKit - -import BuiltinExtension -import GitHubCopilotService - import Combine - protocol BenchmarkManager { - -} - -class RealtimeSuggestionControllerBenchmarkManager: BenchmarkManager { - private let benchmarkSettingsRepository: BenchmarkSettingsRepository - @WorkspaceActor - private let workspacePool: WorkspacePool - - private let isMultiFileEnabledSubject = CurrentValueSubject(true) - var isMultiFileEnabled: AnyPublisher { - isMultiFileEnabledSubject.eraseToAnyPublisher() - } - private let taskStatesSubject = CurrentValueSubject<[TaskStatus], Never>([]) - var taskStates: AnyPublisher<[TaskStatus], Never> { - taskStatesSubject.eraseToAnyPublisher() - } - private var openAIKey: String? - private let selectedGenAIModelSubject = CurrentValueSubject(.defaultModel) - var selectedGenAIModel: AnyPublisher { - selectedGenAIModelSubject.eraseToAnyPublisher() - } - private var cancellables = Set() - - init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { - @Dependency(\.workspacePool) var workspacePool - BuiltinExtensionManager.shared.setupExtensions([ - GitHubCopilotExtension(workspacePool: workspacePool) - ]) - workspacePool.registerPlugin { - SuggestionServiceWorkspacePlugin(workspace: $0) { SuggestionService.service() } - } - workspacePool.registerPlugin { - GitHubCopilotWorkspacePlugin(workspace: $0) - } - workspacePool.registerPlugin { - BuiltinExtensionWorkspacePlugin(workspace: $0) - } - self.workspacePool = workspacePool - self.benchmarkSettingsRepository = benchmarkSettingsRepository - - benchmarkSettingsRepository.benchmarkDirectories.sink { [weak self] benchmarkDirectories in - for directory in benchmarkDirectories { - guard let taskPaths: [URL] = self?.getTaskFolders(in: directory.url) else { - return - } - let initialTaskStates = taskPaths.map { _ in TaskStatus.notStarted } - Task { await self?.updateTaskStates(initialTaskStates) } - } - }.store(in: &cancellables) - - benchmarkSettingsRepository.openAIKey.sink { [weak self] key in - self?.openAIKey = key - }.store(in: &cancellables) - } - - func getCodeSuggestions(at benchmarkDirectory: BenchmarkDirectory) async throws { - let taskPaths: [URL] = getTaskFolders(in: benchmarkDirectory.url) - let initialTaskStates = taskPaths.map { _ in TaskStatus.scheduled } - await updateTaskStates(initialTaskStates) - for (index, _) in taskPaths.prefix(1).enumerated() { - await runTask(at: index, in: benchmarkDirectory) - try await Task.sleep(nanoseconds: 3_000_000_000) - } - } - - func getCodeSuggestionFromService(at directory: URL, from benchmarkDirectory: URL) async -> SuggestionResponse? { - guard let metadata: MetadataDTO = readMetadata(at: directory), - let xcodeWorkspaceFileURL = findXcodeWorkspace(in: benchmarkDirectory), - let workspace = try? await workspacePool.fetchOrCreateWorkspace(workspaceURL: xcodeWorkspaceFileURL) - else { return nil } - let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkDirectory.path) - let content: String = (try? String(contentsOf: entrypoint.fileURL, encoding: .utf8)) ?? "" - - let multiFileContextManager = MultiFileContextManager( - workspaceProvider: ManualWorkspaceProvider(workspace: workspace), - parser: SwiftProgrammingLanguageSyntaxParser() - ) - let relevantSymbols: [SymbolContent] = await { - if isMultiFileEnabledSubject.value { - let symbols = await multiFileContextManager.retrieveRelevantSymbolsForFileContent(content: content) - return Array(symbols.values) - } else { - return [] - } - }() - - let suggestionRequest = SuggestionProvider.SuggestionRequest( - fileURL: entrypoint.fileURL, - relativePath: entrypoint.fileURL.path.replacingOccurrences(of: benchmarkDirectory.path, with: ""), - content: content, - originalContent: content, - lines: content.linesWithNewlineSuffix, - cursorPosition: entrypoint.cursor, - cursorOffset: content.cursorOffset(line: entrypoint.cursor.line, character: entrypoint.cursor.character) ?? 0, - tabSize: 4, - indentSize: 4, - usesTabsForIndentation: false, - relevantCodeSnippets: relevantSymbols.mapToRelevantCodeSnippets() - ) - let workspaceInfo = WorkspaceInfo(workspaceURL: xcodeWorkspaceFileURL, projectURL: benchmarkDirectory) - await openFilespaces(entrypoint: entrypoint, relevantSymbols: relevantSymbols, in: workspace) - await simulateInitialEditorChange(entrypoint: entrypoint, content: content, in: workspace) - do { - // only works when setting document version GitHubCopilotService to 0 - let suggestions = try await workspace.suggestionService?.getSuggestions(suggestionRequest, workspaceInfo: workspaceInfo) - await saveFilespace(entrypoint: entrypoint, in: workspace) - await closeFilespaces(entrypoint: entrypoint, relevantSymbols: relevantSymbols, in: workspace) -// let suggestions: [SuggestionBasic.CodeSuggestion]? = [exampleSuggestion] - guard let suggestions, let firstSuggestion = suggestions.first else { return nil } - return .init( - suggestion: firstSuggestion, - fileURL: entrypoint.fileURL, - relevantSymbolsFromRequest: relevantSymbols, - model: selectedGenAIModelSubject.value - ) - } catch { - print("CK \(error)") - return nil - } -// return nil - } - - private func openFilespaces(entrypoint: EntryPoint, relevantSymbols: [SymbolContent], in workspace: Workspace) async { - for symbol in relevantSymbols { - if let filespace = try? await workspace.createFilespaceIfNeeded(fileURL: URL(fileURLWithPath: symbol.fileURL)) { - await workspace.didOpenFilespace(filespace) - } - } - if let filespace = try? await workspace.createFilespaceIfNeeded(fileURL: entrypoint.fileURL) { - await workspace.didOpenFilespace(filespace) - } - } - - private func closeFilespaces(entrypoint: EntryPoint, relevantSymbols: [SymbolContent], in workspace: Workspace) async { - for symbol in relevantSymbols { - await workspace.didCloseFilespace(URL(fileURLWithPath: symbol.fileURL)) - } - await workspace.didCloseFilespace(entrypoint.fileURL) - } - - private func saveFilespace(entrypoint: EntryPoint, in workspace: Workspace) async { - if let filespace = try? await workspace.createFilespaceIfNeeded(fileURL: entrypoint.fileURL) { - await workspace.didSaveFilespace(filespace) - } - } - - private func simulateInitialEditorChange(entrypoint: EntryPoint, content: String, in workspace: Workspace) async { - await workspace.didUpdateFilespace(fileURL: entrypoint.fileURL, content: content, version: 1) - } - - func runTask(at index: Int, in benchmarkDirectory: BenchmarkDirectory) async { - let taskPath = getTaskFolders(in: benchmarkDirectory.url)[index] - await updateTaskStatus(.running, at: index) - if case .githubCopilot = selectedGenAIModelSubject.value, - let suggestion = await getCodeSuggestionFromService(at: taskPath, from: benchmarkDirectory.url) { - await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) - await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) - await updateTaskStatus(.success, at: index) - } else if let openAIKey = openAIKey, - case let .openAI(model) = selectedGenAIModelSubject.value, - let suggestion = await getCodeSuggestionFromOpenAI( - at: taskPath, - from: benchmarkDirectory.url, - key: openAIKey, - model: model.rawValue - ) { - await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) - await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) - await updateTaskStatus(.success, at: index) - } else { - await updateTaskStatus(.failure, at: index) - } - await cleanUp() - } - - func getCodeSuggestionFromOpenAI(at directory: URL, from benchmarkDirectory: URL, key: String, model: String) async -> SuggestionResponse? { - guard let metadata: MetadataDTO = readMetadata(at: directory), - let xcodeWorkspaceFileURL = findXcodeWorkspace(in: benchmarkDirectory), - let workspace = try? await workspacePool.fetchOrCreateWorkspace(workspaceURL: xcodeWorkspaceFileURL) - else { return nil } - let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkDirectory.path) - let content: String = (try? String(contentsOf: entrypoint.fileURL, encoding: .utf8)) ?? "" - - let multiFileContextManager = MultiFileContextManager( - workspaceProvider: ManualWorkspaceProvider(workspace: workspace), - parser: SwiftProgrammingLanguageSyntaxParser() - ) - let relevantSymbols: [SymbolContent] = await { - if isMultiFileEnabledSubject.value { - let symbols = await multiFileContextManager.retrieveRelevantSymbolsForFileContent(content: content) - return Array(symbols.values) - } else { - return [] - } - }() - - let suggestionRequest = SuggestionRequest( - fileURL: entrypoint.fileURL, - relativePath: entrypoint.fileURL.path.replacingOccurrences(of: benchmarkDirectory.path, with: ""), - content: content, - originalContent: content, - lines: content.linesWithNewlineSuffix, - cursorPosition: .init(line: entrypoint.cursor.line, character: entrypoint.cursor.character), - cursorOffset: content.cursorOffset(line: entrypoint.cursor.line, character: entrypoint.cursor.character) ?? 0, - tabSize: 4, - indentSize: 4, - usesTabsForIndentation: false, - relevantCodeSnippets: relevantSymbols.mapToRelevantCodeSnippets() - ) - let workspaceInfo = WorkspaceInfo(workspaceURL: xcodeWorkspaceFileURL, projectURL: benchmarkDirectory) - let repository = OpenAICompletionRepository(config: .init(apiKey: key, model: model)) - do { - // only works when setting document version GitHubCopilotService to 0 - let suggestion = try await repository.structuredEdit(for: suggestionRequest) -// let suggestions: [SuggestionBasic.CodeSuggestion]? = [exampleSuggestion] - return .init( - suggestion: suggestion.mapToCodeSuggestion( - requestPosition: SuggestionRequest.CursorPosition( - line: entrypoint.cursor.line, - character: entrypoint.cursor.character - ) - ), - fileURL: entrypoint.fileURL, - relevantSymbolsFromRequest: relevantSymbols, - model: selectedGenAIModelSubject.value - ) - } catch { - print("CK \(error)") - return nil - } - } - - func applyCodeSuggestion(suggestion: SuggestionBasic.CodeSuggestion, at fileURL: URL) async { - do { - var content = try String(contentsOf: fileURL, encoding: .utf8) - var lines = content.components(separatedBy: .newlines) - - let start = suggestion.range.start - let end = suggestion.range.end - - // Check range bounds - guard start.line < lines.count, end.line < lines.count else { - print("Invalid range for suggestion in file: \(fileURL)") - return - } - - // Modify the content between start and end - if start.line == end.line { - var line = lines[start.line] - let startIndex = line.index(line.startIndex, offsetBy: start.character) - let endIndex = line.index(line.startIndex, offsetBy: end.character) - line.replaceSubrange(startIndex.. MetadataDTO? { - let metadataURL = taskFolder.appendingPathComponent("metadata.json") - do { - let data = try Data(contentsOf: metadataURL) - - let metadata = try JSONDecoder().decode(MetadataDTO.self, from: data) - return metadata - } catch { - print("Failed to read metadata at \(metadataURL):", error) - return nil - } - } - - func getTaskFolders(in rootDirectory: URL) -> [URL] { - let fileManager = FileManager.default - var taskFolders: [URL] = [] - - let regex = try! NSRegularExpression(pattern: #"Task-\d+"#) - - let enumerator = fileManager.enumerator(at: rootDirectory, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) - - while let fileURL = enumerator?.nextObject() as? URL { - do { - let resourceValues = try fileURL.resourceValues(forKeys: [.isDirectoryKey]) - if resourceValues.isDirectory == true { - let folderName = fileURL.lastPathComponent - let range = NSRange(location: 0, length: folderName.utf16.count) - if regex.firstMatch(in: folderName, options: [], range: range) != nil { - guard !fileURL.path.contains("/Tests/") else { continue } - taskFolders.append(fileURL) - } - } - } catch { - print("Failed to read directory attributes for \(fileURL):", error) - } - } - - return taskFolders.sorted { lhs, rhs in - let lhsNumber = extractTaskNumber(from: lhs.lastPathComponent) ?? 0 - let rhsNumber = extractTaskNumber(from: rhs.lastPathComponent) ?? 0 - return lhsNumber < rhsNumber - } - } - - private func extractTaskNumber(from name: String) -> Int? { - let regex = try! NSRegularExpression(pattern: #"Task-(\d+)"#) - let range = NSRange(location: 0, length: name.utf16.count) - if let match = regex.firstMatch(in: name, options: [], range: range), - let numberRange = Range(match.range(at: 1), in: name) { - return Int(name[numberRange]) - } - return nil - } - - func findXcodeWorkspace(in directory: URL) -> URL? { - let fileManager = FileManager.default - let enumerator = fileManager.enumerator(at: directory, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) - - while let fileURL = enumerator?.nextObject() as? URL { - if fileURL.pathExtension == "xcworkspace" { - return fileURL - } - } - - return nil - } - - @MainActor - func changeGenAIModel(to newModel: GenAILanguageModel) { - selectedGenAIModelSubject.send(newModel) - } - + var isMultiFileEnabled: AnyPublisher { get } + var taskStates: AnyPublisher<[TaskStatus], Never> { get } + var selectedGenAIModel: AnyPublisher { get } + func getCodeSuggestions(at benchmarkDirectory: BenchmarkDirectory) async throws + func runTask(at index: Int, in benchmarkDirectory: BenchmarkDirectory) async @MainActor - func updateMultiFileContextState(_ newValue: Bool) { - isMultiFileEnabledSubject.send(newValue) - } - + func changeGenAIModel(to newModel: GenAILanguageModel) @MainActor - private func updateTaskStates(_ newValue: [TaskStatus]) { - taskStatesSubject.send(newValue) - } - - @MainActor - private func updateTaskStatus(_ newValue: TaskStatus, at index: Int) { - var updatedValue = taskStatesSubject.value - updatedValue[index] = newValue - taskStatesSubject.send(updatedValue) - } -} - -struct MetadataDTO: Codable { - let taskId: Int - let entrypoint: EntrypointDTO - let files: [String] - - private enum CodingKeys: String, CodingKey { - case taskId = "task_id" - case entrypoint - case files - } - - struct EntrypointDTO: Codable { - let filename: String - let cursor: CursorPositionDTO - } - - struct CursorPositionDTO: Codable { - let line: Int - let character: Int - } -} - -extension MetadataDTO { - func mapToEntrypoint(prefixing baseUrl: String) -> EntryPoint { - // Ensure there's exactly one "/" between the base and the filename - let cleanedBase = baseUrl.hasSuffix("/") ? baseUrl : baseUrl + "/" - let cleanedFilename = entrypoint.filename.hasPrefix("/") ? String(entrypoint.filename.dropFirst()) : entrypoint.filename - let fullPath = cleanedBase + cleanedFilename - - return EntryPoint( - cursor: CursorPosition( - line: entrypoint.cursor.line, - character: entrypoint.cursor.character - ), - fileURL: URL(fileURLWithPath: fullPath) - ) - } -} - -extension String { - /// Splits the string by line and appends `\n` to each non-empty line - var linesWithNewlineSuffix: [String] { - self.split(separator: "\n", omittingEmptySubsequences: false) - .map { String($0) + "\n" } - } - - /// Calculates the character offset in the string from a line number and character index within that line - func cursorOffset(line: Int, character: Int) -> Int? { - let lines = self.split(separator: "\n", omittingEmptySubsequences: false) - guard line < lines.count else { return nil } - - // Sum up the lengths of all previous lines (+1 for each '\n') - let offsetBeforeLine = lines.prefix(line).reduce(0) { $0 + $1.count + 1 } - - // Ensure the character index doesn't exceed the current line length - guard character <= lines[line].count else { return nil } - - return offsetBeforeLine + character - } -} - -let exampleSuggestion = SuggestionBasic.CodeSuggestion( - id: "452c2f9a-c0e3-4a9e-8ae2-bf8babf5c634", - text: " func fetch() async { \n do {\n let page = try await useCase.fetch(request: request, page: 1)\n movies = page.results\n } catch {\n errorToast.show()\n }", - position: .init( - line: 27, - character: 24 - ), - range: .init( - start: .init( - line: 27, - character: 0 - ), - end: .init( - line: 27, - character: 24 - ) - ) -) - -struct SuggestionResponse { - let suggestion: SuggestionBasic.CodeSuggestion - let fileURL: URL - let relevantSymbolsFromRequest: [SymbolContent] - let model: GenAILanguageModel -} - - -extension AnyPublisher where Failure == Never { - /// Awaits the first emitted value of the publisher (only for Failure == Never) - func firstValue() async -> Output? { - await withCheckedContinuation { continuation in - var cancellable: AnyCancellable? - cancellable = self.first().sink { value in - continuation.resume(returning: value) - cancellable?.cancel() - } - } - } -} - - -struct StoredSuggestionDTO: Codable { - let fileURL: String - let id: String - let suggestionText: String - let position: CursorPositionDTO - let range: CursorRangeDTO - let createdAt: String - let relevantSymbols: [RelevantSymbolsDTO] - let model: String - - struct CursorPositionDTO: Codable { - let line: Int - let character: Int - } - - struct CursorRangeDTO: Codable { - let start: CursorPositionDTO - let end: CursorPositionDTO - } - - struct RelevantSymbolsDTO: Codable { - let fileURL: String - let name: String - let content: String - let startLine: Int - let endLine: Int - let kind: String - } -} - -extension SuggestionResponse { - func toStoredDTO(timestamp: String) -> StoredSuggestionDTO { - StoredSuggestionDTO( - fileURL: fileURL.path, - id: suggestion.id, - suggestionText: suggestion.text, - position: .init( - line: suggestion.position.line, - character: suggestion.position.character - ), - range: .init( - start: .init( - line: suggestion.range.start.line, - character: suggestion.range.start.character - ), - end: .init( - line: suggestion.range.end.line, - character: suggestion.range.end.character - ) - ), - createdAt: timestamp, - relevantSymbols: relevantSymbolsFromRequest.map { $0.toStoredDTO() }, - model: model.id - ) - } -} - -extension SymbolContent { - func toStoredDTO() -> StoredSuggestionDTO.RelevantSymbolsDTO { - .init( - fileURL: fileURL, - name: symbol.name, - content: content, - startLine: symbol.startLine, - endLine: symbol.endLine, - kind: symbol.kind.rawValue - ) - } -} - -extension Array where Element == SymbolContent { - func mapToRelevantCodeSnippets() -> [SuggestionProvider.RelevantCodeSnippet] { - map { symbol in - .init( - content: symbol.content, - priority: 0, - filePath: symbol.fileURL - ) - } - } -} - -enum TaskStatus { - case success - case failure - case notStarted - case running - case scheduled -} - - - -// MARK: - ChatGPT - -import Foundation - -// MARK: - Input models from your side - -public struct SuggestionRequest: Sendable { - - public struct CursorPosition: Codable, Sendable { - public var line: Int - public var character: Int - } - - public struct RelevantCodeSnippet: Codable, Sendable { - public var path: String - public var language: String? - public var code: String - } - - public var fileURL: URL - public var relativePath: String - public var content: String - public var originalContent: String - public var lines: [String] - public var cursorPosition: CursorPosition - public var cursorOffset: Int - public var tabSize: Int - public var indentSize: Int - public var usesTabsForIndentation: Bool - public var relevantCodeSnippets: [RelevantCodeSnippet] -} - -// MARK: - Output - -public struct CompletionSuggestion: Sendable { - /// The raw text to insert at the cursor. - public let insertText: String - /// Optional reason the model stopped (e.g. "length", "stop"). - public let finishReason: String? -} - -// MARK: - Protocol - -public protocol CodeCompletionRepository: Sendable { - func structuredEdit(for request: SuggestionRequest) async throws -> CodeEdit -} - -// MARK: - OpenAI implementation - -public struct OpenAICompletionRepository: CodeCompletionRepository, Sendable { - - public enum OpenAIError: Error { - case badResponse(status: Int, body: String) - case decoding - case emptyChoice - } - - public struct Config: Sendable { - public var apiKey: String - /// e.g. "gpt-4o-mini" - public var model: String - /// Optional org header - public var organization: String? - /// API base, keep default unless using a proxy - public var baseURL: URL - - public init( - apiKey: String, - model: String, - organization: String? = nil, - baseURL: URL = URL(string: "https://api.openai.com/v1")! - ) { - self.apiKey = apiKey - self.model = model - self.organization = organization - self.baseURL = baseURL - } - } - - struct JSONOnly: Encodable { let type: String = "json_object" } - - private let config: Config - private let urlSession: URLSession - - public init(config: Config, session: URLSession = .shared) { - self.config = config - self.urlSession = session - } - - - public func structuredEdit(for request: SuggestionRequest) async throws -> CodeEdit { - var urlRequest = URLRequest(url: config.baseURL.appendingPathComponent("chat/completions")) - urlRequest.httpMethod = "POST" - urlRequest.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") - if let org = config.organization { urlRequest.setValue(org, forHTTPHeaderField: "OpenAI-Organization") } - urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let payload = ChatPayload( - model: config.model, - messages: buildMessagesForStructuredEdit(from: request), - temperature: 0, - stream: false, - response_format: JSONOnly() // ask for a single JSON object - ) - urlRequest.httpBody = try JSONEncoder().encode(payload) - - let (data, resp) = try await urlSession.data(for: urlRequest) - guard let http = resp as? HTTPURLResponse, 200..<300 ~= http.statusCode else { - let body = String(data: data, encoding: .utf8) ?? "" - throw OpenAIError.badResponse(status: (resp as? HTTPURLResponse)?.statusCode ?? -1, body: body) - } - - let decoded = try JSONDecoder().decode(ChatResponse.self, from: data) - guard let content = decoded.choices.first?.message.content, !content.isEmpty else { - throw OpenAIError.emptyChoice - } - - // The model's entire reply is a JSON object. Decode it. - let edit = try JSONDecoder().decode(CodeEdit.self, from: Data(content.utf8)) - let fixedSuggestionText = removeClosingBraceIfNeeded( - suggestion: edit.text, - promptCode: request.content, - line: request.cursorPosition.line - ) - let fixedEdit = CodeEdit(text: fixedSuggestionText, range: edit.range) - return fixedEdit - } - - private func removeClosingBraceIfNeeded(suggestion: String, promptCode: String, line: Int) -> String { - let lines = suggestion.components(separatedBy: "\n") - let promptCodeLines = promptCode.components(separatedBy: "\n") - let lineAfterCursor = promptCodeLines[line+1] - if lineAfterCursor == lines.last { - return lines.dropLast().joined(separator: "\n") - } else { - return suggestion - } - } - - private func detectLanguage(from path: String) -> String { - switch (path as NSString).pathExtension.lowercased() { - case "swift": return "Swift" - default: return "Unknown" - } - } - - private func buildMessagesForStructuredEdit(from req: SuggestionRequest) -> [ChatMessage] { - let language = detectLanguage(from: req.relativePath) - - let before = String(req.content.prefix(req.cursorOffset)) - let after = String(req.content.suffix(max(0, req.content.count - req.cursorOffset))) - - // Defensive: if the line index is in range, grab the full original line text - let lineIndex = max(0, min(req.cursorPosition.line, max(0, req.lines.count - 1))) - let currentLine = lineIndex < req.lines.count ? req.lines[lineIndex] : "" - - let indentDescriptor: String = req.usesTabsForIndentation - ? "tabs=\(req.tabSize)" - : "spaces=\(req.indentSize)" - - let related = req.relevantCodeSnippets.map { s in - """ - PATH: \(s.path) - LANG: \(s.language ?? "unknown") - ---- - \(s.code) - """ - }.joined(separator: "\n\n========\n\n") - - let system = ChatMessage(role: "system", content: """ - You are a \(language) inline code completion engine. - - Complete the body of only the current function. Produce meaningful, compilable code using identifiers in scope. No placeholders (e.g., "TODO", "// Implementation goes here"). - Return ONE compact JSON object and nothing else (no code fences, no prose). - - Semantics: - - Range describes from what line and character to what line and character the output text should be replaced - - Ranges use 0-based LSP-style coordinates: (start, end). - - Coordinates are relative to the current (pre-edit) buffer. - - The replacement range MUST cover the entire CURRENT LINE (from character 0). - - You can extend the range into following lines to complete the construct. - - The output text will be inserted in the given range. - - Output requirements: - - The "text" must include the full updated CURRENT LINE (where the cursor is) including the function definition and any additional lines needed to complete the implementation. - - IMPORTANT: As can be seen in AFTER, the closing brace `}` of the function to complete is already existing. It MUST not be included in output text. Otherwise a duplicated brace will lead to a compilation error. - - Do NOT begin the text with the first bytes of AFTER. - - No placeholders or comments like "TODO" or "// Implementation goes here". - - Respect indentation \(indentDescriptor) and file style. - - Tabs are represented as spaces in the input; preserve that. - - Preserve newlines; avoid trailing whitespace. - - Respond only with JSON of shape: - { - "text": "STRING", - "range": { - "start": { "line": INT, "character": INT }, - "end": { "line": INT, "character": INT } - } - } - """) - - // Few-shot example teaches the model that we want a body, not just "}" - let exampleUser = ChatMessage(role: "user", content: - """ - EXAMPLE ONLY — DO NOT SOLVE THIS: - FILE: Example.swift - LANGUAGE_HINT: Swift - CURRENT_LINE_INDEX: 1 - CURRENT_LINE_TEXT: - ««« - func greet() {\n - »»» - CURSOR_CHARACTER: 18 - BEFORE: - ««« - struct Greeter {\n func greet() { - »»» - - AFTER: - ««« - \n }\n} - »»» - Respond with JSON: - { - "text": "func greet() {\n print(\"Hello\")\n", - "range": { "start": { "line": 1, "character": 0 }, "end": { "line": 1, "character": 18 } } - } - """ - ) - - // Instruct exact JSON shape - let user = ChatMessage(role: "user", content: - """ - FILE: \(req.relativePath) - LANGUAGE_HINT: \(language) - - CURRENT_LINE_INDEX: \(lineIndex) - CURRENT_LINE_TEXT: - ««« - \(currentLine) - »»» - CURSOR_CHARACTER: \(req.cursorPosition.character) - - BEFORE: - ««« - \(before) - »»» - - - - AFTER: - ««« - \(after) - »»» - - OPTIONAL RELEVANT CONTEXT: - ««« - \(related) - »»» - - Respond with **only** a compact JSON object of this shape (no code fences, no trailing text): - { - "text": "STRING — the replacement text (must include the full updated CURRENT LINE as it should appear after the edit; may include multiple lines if needed)", - "range": { - "start": { "line": INT, "character": INT }, - "end": { "line": INT, "character": INT } - } - } - Rules: - - Choose a minimal range that replaces from the start you need to change up to the end you need to change. - - The range MUST at least span the entire CURRENT LINE (i.e., covers from (CURRENT_LINE_INDEX, 0) to some end on the same or later line), so that the returned `text` fully defines that line after the edit. - - Do not include explanations or formatting fences. - """ - ) - - return [system, exampleUser, user] - } -} - -// MARK: - OpenAI Shapes - -private struct ChatPayload: Encodable { - let model: String - let messages: [ChatMessage] - let temperature: Double - let stream: Bool - let response_format: RF? - init(model: String, messages: [ChatMessage], temperature: Double, stream: Bool, response_format: RF? = nil) { - self.model = model - self.messages = messages - self.temperature = temperature - self.stream = stream - self.response_format = response_format - } -} - -private struct ChatMessage: Codable { - let role: String - let content: String -} - -private struct ChatResponse: Decodable { - struct Choice: Decodable { - struct Message: Decodable { let role: String; let content: String? } - let index: Int - let message: Message - let finishReason: String? - - private enum CodingKeys: String, CodingKey { - case index, message - case finishReason = "finish_reason" - } - } - let id: String - let choices: [Choice] -} - - -public struct CodeEdit: Codable, Sendable { - public struct Position: Codable, Sendable { - public let line: Int - public let character: Int - } - public struct TextRange: Codable, Sendable { - public let start: Position - public let end: Position - } - - /// Replacement text that MUST include the full (updated) content of the cursor's line. - public let text: String - public let range: TextRange -} - -extension CodeEdit { - func mapToSuggestionResponse( - fileURL: URL, - relevantSymbolsFromRequest: [SymbolContent], - model: GenAILanguageModel - ) -> SuggestionResponse { - let start = range.start - let end = range.end - let range = SuggestionBasic.CursorRange( - start: .init(line: start.line, character: start.character), - end: .init(line: end.line, character: end.character) - ) - let position = SuggestionBasic.CursorPosition( - line: start.line, - character: start.character - ) - return SuggestionResponse( - suggestion: SuggestionBasic.CodeSuggestion( - id: UUID().uuidString, - text: text, - position: position, - range: range - ), - fileURL: fileURL, - relevantSymbolsFromRequest: relevantSymbolsFromRequest, - model: model - ) - } -} - -extension Array where Element == SymbolContent { - func mapToRelevantCodeSnippets() -> [SuggestionRequest.RelevantCodeSnippet] { - map { symbol in - .init( - path: symbol.fileURL, - language: "Swift", - code: symbol.content - ) - } - } -} - -extension CodeEdit { - func mapToCodeSuggestion(requestPosition: SuggestionRequest.CursorPosition) -> SuggestionBasic.CodeSuggestion { - let start = range.start - let end = range.end - return SuggestionBasic.CodeSuggestion( - id: UUID().uuidString, - text: text, - position: SuggestionBasic.CursorPosition( - line: requestPosition.line, - character: requestPosition.character - ), - range: SuggestionBasic.CursorRange( - start: .init(line: start.line, character: start.character), - end: .init(line: end.line, character: end.character) - ) - ) - } -} - -enum GenAILanguageModel: Hashable, CaseIterable, Identifiable { - case githubCopilot - case openAI(OpenAIModel) - - static var allCases: [GenAILanguageModel] { - [.githubCopilot] + OpenAIModel.allCases.map { .openAI($0) } - } - - var id: String { - switch self { - case .githubCopilot: return "githubCopilot" - case .openAI(let m): return "openAI:\(m.rawValue)" - } - } - - var name: String { - switch self { - case .githubCopilot: return "GitHub Copilot" - case .openAI(let m): return m.displayName - } - } - - static let defaultModel: GenAILanguageModel = .githubCopilot -} - -enum OpenAIModel: String, CaseIterable, Hashable { - case gpt4oMini = "gpt-4o-mini" - case gpt4o = "gpt-4o" - case gpt5 = "gpt-5" - - var displayName: String { - switch self { - case .gpt4oMini: return "GPT-4o mini" - case .gpt4o: return "GPT-4o" - case .gpt5: return "GPT-5" - } - } + func updateMultiFileContextState(_ newValue: Bool) } diff --git a/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Entity/ChatPayload.swift b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Entity/ChatPayload.swift new file mode 100644 index 00000000..4613974c --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Entity/ChatPayload.swift @@ -0,0 +1,44 @@ +struct ChatPayload: Encodable { + let model: String + let messages: [ChatMessage] + let temperature: Double + let stream: Bool + let response_format: RF? + init( + model: String, + messages: [ChatMessage], + temperature: Double, + stream: Bool, + response_format: RF? = nil + ) { + self.model = model + self.messages = messages + self.temperature = temperature + self.stream = stream + self.response_format = response_format + } +} + +struct ChatMessage: Codable { + let role: String + let content: String +} + +struct ChatResponse: Decodable { + struct Choice: Decodable { + struct Message: Decodable { + let role: String + let content: String? + } + let index: Int + let message: Message + let finishReason: String? + + private enum CodingKeys: String, CodingKey { + case index, message + case finishReason = "finish_reason" + } + } + let id: String + let choices: [Choice] +} diff --git a/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Entity/CodeEdit.swift b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Entity/CodeEdit.swift new file mode 100644 index 00000000..1c81fd8e --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Entity/CodeEdit.swift @@ -0,0 +1,14 @@ +struct CodeEdit: Codable { + struct Position: Codable { + let line: Int + let character: Int + } + struct TextRange: Codable { + let start: Position + let end: Position + } + + /// Replacement text that MUST include the full (updated) content of the cursor's line. + let text: String + let range: TextRange +} diff --git a/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift new file mode 100644 index 00000000..8a755749 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift @@ -0,0 +1,231 @@ +import Foundation + +struct OpenAICompletionRepository: CodeCompletionRepository { + + enum OpenAIError: Error { + case badResponse(status: Int, body: String) + case decoding + case emptyChoice + } + + struct Config { + var apiKey: String + /// e.g. "gpt-4o-mini" + var model: String + /// Optional org header + var organization: String? + /// API base, keep default unless using a proxy + var baseURL: URL + + init( + apiKey: String, + model: String, + organization: String? = nil, + baseURL: URL = URL(string: "https://api.openai.com/v1")! + ) { + self.apiKey = apiKey + self.model = model + self.organization = organization + self.baseURL = baseURL + } + } + + struct JSONOnly: Encodable { let type: String = "json_object" } + + private let config: Config + private let urlSession: URLSession + + init(config: Config, session: URLSession = .shared) { + self.config = config + self.urlSession = session + } + + func structuredEdit(for request: SuggestionRequest) async throws -> CodeEdit { + var urlRequest = URLRequest(url: config.baseURL.appendingPathComponent("chat/completions")) + urlRequest.httpMethod = "POST" + urlRequest.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") + if let org = config.organization { urlRequest.setValue(org, forHTTPHeaderField: "OpenAI-Organization") } + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let payload = ChatPayload( + model: config.model, + messages: buildMessagesForStructuredEdit(from: request), + temperature: 0, + stream: false, + response_format: JSONOnly() // ask for a single JSON object + ) + urlRequest.httpBody = try JSONEncoder().encode(payload) + + let (data, resp) = try await urlSession.data(for: urlRequest) + guard let http = resp as? HTTPURLResponse, 200..<300 ~= http.statusCode else { + let body = String(data: data, encoding: .utf8) ?? "" + throw OpenAIError.badResponse(status: (resp as? HTTPURLResponse)?.statusCode ?? -1, body: body) + } + + let decoded = try JSONDecoder().decode(ChatResponse.self, from: data) + guard let content = decoded.choices.first?.message.content, !content.isEmpty else { + throw OpenAIError.emptyChoice + } + + // The model's entire reply is a JSON object. Decode it. + let edit = try JSONDecoder().decode(CodeEdit.self, from: Data(content.utf8)) + let fixedSuggestionText = removeClosingBraceIfNeeded( + suggestion: edit.text, + promptCode: request.content, + line: request.cursorPosition.line + ) + let fixedEdit = CodeEdit(text: fixedSuggestionText, range: edit.range) + return fixedEdit + } + + private func removeClosingBraceIfNeeded(suggestion: String, promptCode: String, line: Int) -> String { + let lines = suggestion.components(separatedBy: "\n") + let promptCodeLines = promptCode.components(separatedBy: "\n") + let lineAfterCursor = promptCodeLines[line+1] + if lineAfterCursor == lines.last { + return lines.dropLast().joined(separator: "\n") + } else { + return suggestion + } + } + + private func detectLanguage(from path: String) -> String { + switch (path as NSString).pathExtension.lowercased() { + case "swift": return "Swift" + default: return "Unknown" + } + } + + private func buildMessagesForStructuredEdit(from req: SuggestionRequest) -> [ChatMessage] { + let language = detectLanguage(from: req.relativePath) + + let before = String(req.content.prefix(req.cursorOffset)) + let after = String(req.content.suffix(max(0, req.content.count - req.cursorOffset))) + + // Defensive: if the line index is in range, grab the full original line text + let lineIndex = max(0, min(req.cursorPosition.line, max(0, req.lines.count - 1))) + let currentLine = lineIndex < req.lines.count ? req.lines[lineIndex] : "" + + let indentDescriptor: String = req.usesTabsForIndentation + ? "tabs=\(req.tabSize)" + : "spaces=\(req.indentSize)" + + let related = req.relevantCodeSnippets.map { s in + """ + PATH: \(s.path) + LANG: \(s.language ?? "unknown") + ---- + \(s.code) + """ + }.joined(separator: "\n\n========\n\n") + + let system = ChatMessage(role: "system", content: """ + You are a \(language) inline code completion engine. + + Complete the body of only the current function. Produce meaningful, compilable code using identifiers in scope. No placeholders (e.g., "TODO", "// Implementation goes here"). + Return ONE compact JSON object and nothing else (no code fences, no prose). + + Semantics: + - Range describes from what line and character to what line and character the output text should be replaced + - Ranges use 0-based LSP-style coordinates: (start, end). + - Coordinates are relative to the current (pre-edit) buffer. + - The replacement range MUST cover the entire CURRENT LINE (from character 0). + - You can extend the range into following lines to complete the construct. + - The output text will be inserted in the given range. + + Output requirements: + - The "text" must include the full updated CURRENT LINE (where the cursor is) including the function definition and any additional lines needed to complete the implementation. + - IMPORTANT: As can be seen in AFTER, the closing brace `}` of the function to complete is already existing. It MUST not be included in output text. Otherwise a duplicated brace will lead to a compilation error. + - Do NOT begin the text with the first bytes of AFTER. + - No placeholders or comments like "TODO" or "// Implementation goes here". + - Respect indentation \(indentDescriptor) and file style. + - Tabs are represented as spaces in the input; preserve that. + - Preserve newlines; avoid trailing whitespace. + + Respond only with JSON of shape: + { + "text": "STRING", + "range": { + "start": { "line": INT, "character": INT }, + "end": { "line": INT, "character": INT } + } + } + """) + + // Few-shot example teaches the model that we want a body, not just "}" + let exampleUser = ChatMessage(role: "user", content: + """ + EXAMPLE ONLY — DO NOT SOLVE THIS: + FILE: Example.swift + LANGUAGE_HINT: Swift + CURRENT_LINE_INDEX: 1 + CURRENT_LINE_TEXT: + ««« + func greet() {\n + »»» + CURSOR_CHARACTER: 18 + BEFORE: + ««« + struct Greeter {\n func greet() { + »»» + + AFTER: + ««« + \n }\n} + »»» + Respond with JSON: + { + "text": "func greet() {\n print(\"Hello\")\n", + "range": { "start": { "line": 1, "character": 0 }, "end": { "line": 1, "character": 18 } } + } + """ + ) + + // Instruct exact JSON shape + let user = ChatMessage(role: "user", content: + """ + FILE: \(req.relativePath) + LANGUAGE_HINT: \(language) + + CURRENT_LINE_INDEX: \(lineIndex) + CURRENT_LINE_TEXT: + ««« + \(currentLine) + »»» + CURSOR_CHARACTER: \(req.cursorPosition.character) + + BEFORE: + ««« + \(before) + »»» + + + + AFTER: + ««« + \(after) + »»» + + OPTIONAL RELEVANT CONTEXT: + ««« + \(related) + »»» + + Respond with **only** a compact JSON object of this shape (no code fences, no trailing text): + { + "text": "STRING — the replacement text (must include the full updated CURRENT LINE as it should appear after the edit; may include multiple lines if needed)", + "range": { + "start": { "line": INT, "character": INT }, + "end": { "line": INT, "character": INT } + } + } + Rules: + - Choose a minimal range that replaces from the start you need to change up to the end you need to change. + - The range MUST at least span the entire CURRENT LINE (i.e., covers from (CURRENT_LINE_INDEX, 0) to some end on the same or later line), so that the returned `text` fully defines that line after the edit. + - Do not include explanations or formatting fences. + """ + ) + + return [system, exampleUser, user] + } +} diff --git a/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Domain/Entity/GenAILanguageModel.swift b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Domain/Entity/GenAILanguageModel.swift new file mode 100644 index 00000000..bde8ad49 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Domain/Entity/GenAILanguageModel.swift @@ -0,0 +1,38 @@ +enum GenAILanguageModel: Hashable, CaseIterable, Identifiable { + case githubCopilot + case openAI(OpenAIModel) + + static var allCases: [GenAILanguageModel] { + [.githubCopilot] + OpenAIModel.allCases.map { .openAI($0) } + } + + var id: String { + switch self { + case .githubCopilot: return "githubCopilot" + case .openAI(let m): return "openAI:\(m.rawValue)" + } + } + + var name: String { + switch self { + case .githubCopilot: return "GitHub Copilot" + case .openAI(let m): return m.displayName + } + } + + static let defaultModel: GenAILanguageModel = .githubCopilot +} + +enum OpenAIModel: String, CaseIterable, Hashable { + case gpt4oMini = "gpt-4o-mini" + case gpt4o = "gpt-4o" + case gpt5 = "gpt-5" + + var displayName: String { + switch self { + case .gpt4oMini: return "GPT-4o mini" + case .gpt4o: return "GPT-4o" + case .gpt5: return "GPT-5" + } + } +} diff --git a/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Domain/Repository/CodeCompletionRepository.swift b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Domain/Repository/CodeCompletionRepository.swift new file mode 100644 index 00000000..25d6935a --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Domain/Repository/CodeCompletionRepository.swift @@ -0,0 +1,3 @@ +protocol CodeCompletionRepository { + func structuredEdit(for request: SuggestionRequest) async throws -> CodeEdit +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift index 7a76a595..26779660 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift @@ -18,13 +18,16 @@ class BenchmarkViewModel: ObservableObject { } private let benchmarkSettingsRepository: BenchmarkSettingsRepository - private let benchmarkManager: RealtimeSuggestionControllerBenchmarkManager + private let benchmarkManager: BenchmarkManager private var cancellables = Set() private var toast: ToastController { ToastControllerDependencyKey.liveValue } - init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { + init( + benchmarkSettingsRepository: BenchmarkSettingsRepository, + benchmarkManager: BenchmarkManager + ) { self.benchmarkSettingsRepository = benchmarkSettingsRepository - self.benchmarkManager = RealtimeSuggestionControllerBenchmarkManager(benchmarkSettingsRepository: benchmarkSettingsRepository) + self.benchmarkManager = benchmarkManager benchmarkManager.isMultiFileEnabled .assign(to: \.isMultiFileContextEnabled, on: self) .store(in: &cancellables) From 0fdc86f4d0f23516a88aa2627979c496c04fad27 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Fri, 15 Aug 2025 16:14:03 +0200 Subject: [PATCH 43/66] fix OpenAI key from not being loaded properly --- .../Data/Repository/LocalBenchmarkSettingsRepository.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift b/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift index ba5e73d7..7b6e1aa6 100644 --- a/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift +++ b/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift @@ -36,7 +36,7 @@ class LocalBenchmarkSettingsRepository: BenchmarkSettingsRepository { benchmarkOutputDirectory.send(outputDirectory) } if let openAIKey = try? loadOpenAIKey() { - currentOpenAIKey.send("") + currentOpenAIKey.send(openAIKey) } } From f164406de1b112e18c61e32892391aa4fd676c8f Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Sat, 16 Aug 2025 18:37:29 +0200 Subject: [PATCH 44/66] add run all label for clarity --- .../HostApp/Benchmark/Presentation/View/BenchmarkView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift index 08f83213..6d2a6509 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift @@ -39,7 +39,7 @@ struct BenchmarkView: View { try? await viewModel.runBenchmark(for: directory) } } label: { - Image(systemName: "play") + Label("Run all Tasks", systemImage: "play") } Button { NSWorkspace.shared.open(directory.url) From c20ae305e18fd3055f2d1033e7b8a23850af2bc3 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Sat, 16 Aug 2025 18:37:49 +0200 Subject: [PATCH 45/66] always show directory instead of only on tap --- .../Presentation/View/DirectoryNameView.swift | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/DirectoryNameView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/DirectoryNameView.swift index 035bb830..0236cbbe 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/View/DirectoryNameView.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/DirectoryNameView.swift @@ -6,21 +6,12 @@ struct DirectoryNameView: View { @Environment(\.colorScheme) var colorScheme var body: some View { - Text(isPressed ? directory.url.path : directory.name) - .foregroundStyle(isPressed ? Color.gray : colorScheme == .dark ? Color.white : Color.black) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged({ _ in - withAnimation { - isPressed = true - } - }) - .onEnded({ _ in - withAnimation { - isPressed = false - } - }) - ) - .animation(.easeInOut(duration: 0.2), value: isPressed) + VStack(alignment: .leading, spacing: 5) { + Text(directory.name) + + Text(directory.url.path) + .font(.subheadline) + .foregroundStyle(Color.gray) + } } } From f886e4301f691606f34a29afa19455c81f4fab3e Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Sat, 16 Aug 2025 21:21:50 +0200 Subject: [PATCH 46/66] make tasks collapsable and fix tasks for different directories --- .../Benchmark/DI/BenchmarkModule.swift | 22 ++++- .../MultiFileContextBenchmarkManager.swift | 32 +++--- .../Domain/Manager/BenchmarkManager.swift | 2 +- .../View/BenchmarkDirectoryEntryView.swift | 99 +++++++++++++++++++ .../Presentation/View/BenchmarkView.swift | 78 +-------------- .../BenchmarkDirectoryEntryViewModel.swift | 45 +++++++++ .../ViewModel/BenchmarkViewModel.swift | 22 +---- 7 files changed, 187 insertions(+), 113 deletions(-) create mode 100644 Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkDirectoryEntryView.swift create mode 100644 Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkDirectoryEntryViewModel.swift diff --git a/Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift b/Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift index 3419e0af..0cffc32f 100644 --- a/Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift +++ b/Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift @@ -1,11 +1,13 @@ protocol BenchmarkModuleType { func provide() -> BenchmarkViewModel + func provide(directory: BenchmarkDirectory) -> BenchmarkDirectoryEntryViewModel func provide() -> ConfigurationViewModel func provide() -> AddDirectoryViewModel } class BenchmarkModule: BenchmarkModuleType { private var benchmarkSettingsRepository: BenchmarkSettingsRepository? + private var benchmarkManager: BenchmarkManager? static let shared: BenchmarkModuleType = BenchmarkModule() @@ -26,9 +28,15 @@ class BenchmarkModule: BenchmarkModuleType { } private func component() -> BenchmarkManager { - MultiFileContextBenchmarkManager( - benchmarkSettingsRepository: component() - ) + if let manager = benchmarkManager { + return manager + } else { + let manager = MultiFileContextBenchmarkManager( + benchmarkSettingsRepository: component() + ) + benchmarkManager = manager + return manager + } } func provide() -> BenchmarkViewModel { @@ -38,6 +46,14 @@ class BenchmarkModule: BenchmarkModuleType { ) } + func provide(directory: BenchmarkDirectory) -> BenchmarkDirectoryEntryViewModel { + BenchmarkDirectoryEntryViewModel( + directory: directory, + benchmarkSettingsRepository: component(), + benchmarkManager: component() + ) + } + func provide() -> ConfigurationViewModel { ConfigurationViewModel( benchmarkSettingsRepository: component() diff --git a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift index a2de146e..c24bffee 100644 --- a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift @@ -20,8 +20,8 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { var isMultiFileEnabled: AnyPublisher { isMultiFileEnabledSubject.eraseToAnyPublisher() } - private let taskStatesSubject = CurrentValueSubject<[TaskStatus], Never>([]) - var taskStates: AnyPublisher<[TaskStatus], Never> { + private let taskStatesSubject = CurrentValueSubject<[BenchmarkDirectory: [TaskStatus]], Never>([:]) + var taskStates: AnyPublisher<[BenchmarkDirectory: [TaskStatus]], Never> { taskStatesSubject.eraseToAnyPublisher() } private var openAIKey: String? @@ -54,7 +54,7 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { return } let initialTaskStates = taskPaths.map { _ in TaskStatus.notStarted } - Task { await self?.updateTaskStates(initialTaskStates) } + Task { await self?.updateTaskStates(in: directory, newValue: initialTaskStates) } } }.store(in: &cancellables) @@ -66,7 +66,7 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { func getCodeSuggestions(at benchmarkDirectory: BenchmarkDirectory) async throws { let taskPaths: [URL] = getTaskFolders(in: benchmarkDirectory.url) let initialTaskStates = taskPaths.map { _ in TaskStatus.scheduled } - await updateTaskStates(initialTaskStates) + await updateTaskStates(in: benchmarkDirectory, newValue: initialTaskStates) for (index, _) in taskPaths.prefix(1).enumerated() { await runTask(at: index, in: benchmarkDirectory) try await Task.sleep(nanoseconds: 3_000_000_000) @@ -156,12 +156,12 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { func runTask(at index: Int, in benchmarkDirectory: BenchmarkDirectory) async { let taskPath = getTaskFolders(in: benchmarkDirectory.url)[index] - await updateTaskStatus(.running, at: index) + await updateTaskStatus(in: benchmarkDirectory, state: .running, at: index) if case .githubCopilot = selectedGenAIModelSubject.value, let suggestion = await getCodeSuggestionFromService(at: taskPath, from: benchmarkDirectory.url) { await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) - await updateTaskStatus(.success, at: index) + await updateTaskStatus(in: benchmarkDirectory, state: .success, at: index) } else if let openAIKey = openAIKey, case let .openAI(model) = selectedGenAIModelSubject.value, let suggestion = await getCodeSuggestionFromOpenAI( @@ -172,9 +172,9 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { ) { await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) - await updateTaskStatus(.success, at: index) + await updateTaskStatus(in: benchmarkDirectory, state: .success, at: index) } else { - await updateTaskStatus(.failure, at: index) + await updateTaskStatus(in: benchmarkDirectory, state: .failure, at: index) } await cleanUp() } @@ -427,15 +427,19 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { } @MainActor - private func updateTaskStates(_ newValue: [TaskStatus]) { - taskStatesSubject.send(newValue) + private func updateTaskStates(in directory: BenchmarkDirectory, newValue: [TaskStatus]) { + var currentStates = taskStatesSubject.value + currentStates[directory] = newValue + taskStatesSubject.send(currentStates) } @MainActor - private func updateTaskStatus(_ newValue: TaskStatus, at index: Int) { - var updatedValue = taskStatesSubject.value - updatedValue[index] = newValue - taskStatesSubject.send(updatedValue) + private func updateTaskStatus(in directory: BenchmarkDirectory, state: TaskStatus, at index: Int) { + var currentStates = taskStatesSubject.value + guard var currentStatesInDirectory = currentStates[directory] else { return } + currentStatesInDirectory[index] = state + currentStates[directory] = currentStatesInDirectory + taskStatesSubject.send(currentStates) } } diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index 59af9f63..3bfe48ac 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -2,7 +2,7 @@ import Combine protocol BenchmarkManager { var isMultiFileEnabled: AnyPublisher { get } - var taskStates: AnyPublisher<[TaskStatus], Never> { get } + var taskStates: AnyPublisher<[BenchmarkDirectory: [TaskStatus]], Never> { get } var selectedGenAIModel: AnyPublisher { get } func getCodeSuggestions(at benchmarkDirectory: BenchmarkDirectory) async throws func runTask(at index: Int, in benchmarkDirectory: BenchmarkDirectory) async diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkDirectoryEntryView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkDirectoryEntryView.swift new file mode 100644 index 00000000..0eb20293 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkDirectoryEntryView.swift @@ -0,0 +1,99 @@ +import SwiftUI + +struct BenchmarkDirectoryEntryView: View { + @StateObject private var viewModel: BenchmarkDirectoryEntryViewModel + @State private var isShowingTaskList = false + + init(directory: BenchmarkDirectory, module: BenchmarkModuleType) { + _viewModel = StateObject(wrappedValue: module.provide(directory: directory)) + } + + + var body: some View { + VStack { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + DirectoryNameView(directory: viewModel.directory) + .frame(maxWidth: .infinity, alignment: .leading) + Button { + Task { + try? await viewModel.runBenchmark(for: viewModel.directory) + } + } label: { + Label("Run all Tasks", systemImage: "play") + } + Button { + NSWorkspace.shared.open(viewModel.directory.url) + } label: { + Image(systemName: "folder") + } + Button { + viewModel.deleteDirectory(viewModel.directory) + } label: { + Image(systemName: "trash") + .foregroundColor(.red) + } + Button { + withAnimation { + isShowingTaskList.toggle() + } + } label: { + Image(systemName: isShowingTaskList ? "chevron.up.circle" : "chevron.down.circle") + } + .buttonStyle(.borderless) + .accessibilityLabel(isShowingTaskList ? "Hide tasks" : "Show tasks") + .accessibilityHint("Tap to toggle task list visibility") + .padding(.horizontal, 10) + } + } + if isShowingTaskList { + taskList(directory: viewModel.directory) + } + } + } + + func taskList(directory: BenchmarkDirectory) -> some View { + VStack(alignment: .leading, spacing: 8) { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(Array(viewModel.taskStates.enumerated()), id: \.offset) { index, taskStatus in + HStack(spacing: 20) { + Button { + Task { + await viewModel.runTask(index: index, in: directory) + } + } label: { + Image(systemName: "play") + } + + Text("Task \(index + 1)") + .frame(maxWidth: .infinity, alignment: .leading) + + Group { + switch taskStatus { + case .notStarted: + Text("") + .accessibilityLabel("Not started") + case .scheduled: + Text("🕒") + .accessibilityLabel("Scheduled") + case .running: + ProgressView() + .scaleEffect(0.6) + .accessibilityLabel("Running") + case .success: + Text("✅") + .accessibilityLabel("Success") + case .failure: + Text("❌") + .accessibilityLabel("Failed") + } + } + .frame(width: 24, height: 24, alignment: .center) + } + .padding(.vertical, 2) + } + } + } + .padding(.leading, 20) + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift index 6d2a6509..523c359f 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift @@ -29,33 +29,10 @@ struct BenchmarkView: View { } .padding(.vertical) ForEach(viewModel.benchmarkDirectories, id: \.self) { directory in - VStack { - VStack(alignment: .leading, spacing: 10) { - HStack { - DirectoryNameView(directory: directory) - .frame(maxWidth: .infinity, alignment: .leading) - Button { - Task { - try? await viewModel.runBenchmark(for: directory) - } - } label: { - Label("Run all Tasks", systemImage: "play") - } - Button { - NSWorkspace.shared.open(directory.url) - } label: { - Image(systemName: "folder") - } - Button { - viewModel.deleteDirectory(directory) - } label: { - Image(systemName: "trash") - .foregroundColor(.red) - } - } - } - } - taskList(directory: directory) + BenchmarkDirectoryEntryView( + directory: directory, + module: module + ) } AddDirectoryButtonView(module: module) .padding(.vertical, 10) @@ -69,51 +46,4 @@ struct BenchmarkView: View { viewModel.loadBenchmarkDirectories() } } - - func taskList(directory: BenchmarkDirectory) -> some View { - VStack(alignment: .leading, spacing: 8) { -// Text("📋 Task Status") -// .font(.headline) - LazyVStack(alignment: .leading, spacing: 8) { - ForEach(Array(viewModel.taskStates.enumerated()), id: \.offset) { index, taskStatus in - HStack(spacing: 20) { - Button { - Task { - await viewModel.runTask(index: index, in: directory) - } - } label: { - Image(systemName: "play") - } - - Text("Task \(index + 1)") - .frame(maxWidth: .infinity, alignment: .leading) - - Group { - switch taskStatus { - case .notStarted: - Text("") - .accessibilityLabel("Not started") - case .scheduled: - Text("🕒") - .accessibilityLabel("Scheduled") - case .running: - ProgressView() - .scaleEffect(0.6) - .accessibilityLabel("Running") - case .success: - Text("✅") - .accessibilityLabel("Success") - case .failure: - Text("❌") - .accessibilityLabel("Failed") - } - } - .frame(width: 24, height: 24, alignment: .center) - } - .padding(.vertical, 2) - } - } - } - .padding(.leading, 20) - } } diff --git a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkDirectoryEntryViewModel.swift b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkDirectoryEntryViewModel.swift new file mode 100644 index 00000000..2d9b3579 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkDirectoryEntryViewModel.swift @@ -0,0 +1,45 @@ +import Combine +import Toast + +class BenchmarkDirectoryEntryViewModel: ObservableObject { + @Published private(set) var taskStates: [TaskStatus] = [] + + private(set) var directory: BenchmarkDirectory + private let benchmarkSettingsRepository: BenchmarkSettingsRepository + private let benchmarkManager: BenchmarkManager + private var cancellables = Set() + private var toast: ToastController { ToastControllerDependencyKey.liveValue } + + init( + directory: BenchmarkDirectory, + benchmarkSettingsRepository: BenchmarkSettingsRepository, + benchmarkManager: BenchmarkManager + ) { + self.directory = directory + self.benchmarkSettingsRepository = benchmarkSettingsRepository + self.benchmarkManager = benchmarkManager + benchmarkManager.taskStates + .map { $0[directory] ?? [] } + .assign(to: \.taskStates, on: self) + .store(in: &cancellables) + } + + func runBenchmark(for directory: BenchmarkDirectory) async throws { + print("Running benchmark for \(directory.url.path)") + try await benchmarkManager.getCodeSuggestions(at: directory) + } + + + func deleteDirectory(_ directory: BenchmarkDirectory) { + do { + try benchmarkSettingsRepository.deleteBenchmarkDirectory(directory) + toast.toast(content: "Directory deleted successfully.", level: .info) + } catch { + toast.toast(content: "Failed deleting directory.", level: .error) + } + } + + func runTask(index: Int, in directory: BenchmarkDirectory) async { + await benchmarkManager.runTask(at: index, in: directory) + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift index 26779660..618c6468 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift @@ -9,7 +9,7 @@ class BenchmarkViewModel: ObservableObject { Task { await benchmarkManager.updateMultiFileContextState(isMultiFileContextEnabled) } } } - @Published private(set) var taskStates: [TaskStatus] = [] + @Published private(set) var taskStates: [BenchmarkDirectory: [TaskStatus]] = [:] @Published var selectedLanguageModel: GenAILanguageModel = .defaultModel { didSet { guard oldValue != selectedLanguageModel else { return } @@ -42,24 +42,4 @@ class BenchmarkViewModel: ObservableObject { .assign(to: \.benchmarkDirectories, on: self) .store(in: &cancellables) } - - func runBenchmark(for directory: BenchmarkDirectory) async throws { - // TODO: Implement the logic to run the benchmark for the given directory - print("Running benchmark for \(directory.url.path)") - try await benchmarkManager.getCodeSuggestions(at: directory) - } - - - func deleteDirectory(_ directory: BenchmarkDirectory) { - do { - try benchmarkSettingsRepository.deleteBenchmarkDirectory(directory) - toast.toast(content: "Directory deleted successfully.", level: .info) - } catch { - toast.toast(content: "Failed deleting directory.", level: .error) - } - } - - func runTask(index: Int, in directory: BenchmarkDirectory) async { - await benchmarkManager.runTask(at: index, in: directory) - } } From 9cb10b847eb94a1c6b6bb57849ff1c4553f958e7 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Sat, 16 Aug 2025 22:09:00 +0200 Subject: [PATCH 47/66] limit relevant files to 10 for OpenAI requests to keep tokens low --- .../MultiFileContextBenchmarkManager.swift | 44 +++++++------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift index c24bffee..b3afab6a 100644 --- a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift @@ -81,18 +81,7 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkDirectory.path) let content: String = (try? String(contentsOf: entrypoint.fileURL, encoding: .utf8)) ?? "" - let multiFileContextManager = MultiFileContextManager( - workspaceProvider: ManualWorkspaceProvider(workspace: workspace), - parser: SwiftProgrammingLanguageSyntaxParser() - ) - let relevantSymbols: [SymbolContent] = await { - if isMultiFileEnabledSubject.value { - let symbols = await multiFileContextManager.retrieveRelevantSymbolsForFileContent(content: content) - return Array(symbols.values) - } else { - return [] - } - }() + let relevantSymbols: [SymbolContent] = await retrieveRelevantSymbolsForFileContent(content: content, workspace: workspace) let suggestionRequest = SuggestionProvider.SuggestionRequest( fileURL: entrypoint.fileURL, @@ -187,18 +176,8 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkDirectory.path) let content: String = (try? String(contentsOf: entrypoint.fileURL, encoding: .utf8)) ?? "" - let multiFileContextManager = MultiFileContextManager( - workspaceProvider: ManualWorkspaceProvider(workspace: workspace), - parser: SwiftProgrammingLanguageSyntaxParser() - ) - let relevantSymbols: [SymbolContent] = await { - if isMultiFileEnabledSubject.value { - let symbols = await multiFileContextManager.retrieveRelevantSymbolsForFileContent(content: content) - return Array(symbols.values) - } else { - return [] - } - }() + let relevantSymbols: [SymbolContent] = await retrieveRelevantSymbolsForFileContent(content: content, workspace: workspace) + let limitedRelevantSymbols = Array(relevantSymbols.prefix(10)) let suggestionRequest = SuggestionRequest( fileURL: entrypoint.fileURL, @@ -211,13 +190,11 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { tabSize: 4, indentSize: 4, usesTabsForIndentation: false, - relevantCodeSnippets: relevantSymbols.mapToRelevantCodeSnippets() + relevantCodeSnippets: limitedRelevantSymbols.mapToRelevantCodeSnippets() ) let repository = OpenAICompletionRepository(config: .init(apiKey: key, model: model)) do { - // only works when setting document version GitHubCopilotService to 0 let suggestion = try await repository.structuredEdit(for: suggestionRequest) -// let suggestions: [SuggestionBasic.CodeSuggestion]? = [exampleSuggestion] return .init( suggestion: suggestion.mapToCodeSuggestion( requestPosition: SuggestionRequest.CursorPosition( @@ -235,6 +212,19 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { } } + private func retrieveRelevantSymbolsForFileContent(content: String, workspace: Workspace) async -> [SymbolContent] { + let multiFileContextManager = MultiFileContextManager( + workspaceProvider: ManualWorkspaceProvider(workspace: workspace), + parser: SwiftProgrammingLanguageSyntaxParser() + ) + if isMultiFileEnabledSubject.value { + let symbols = await multiFileContextManager.retrieveRelevantSymbolsForFileContent(content: content) + return Array(symbols.values) + } else { + return [] + } + } + private func applyCodeSuggestion(suggestion: SuggestionBasic.CodeSuggestion, at fileURL: URL) async { do { let content = try String(contentsOf: fileURL, encoding: .utf8) From b4b95572e9ef60fca984ee6d4a0f0a2f90d031a2 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Sat, 16 Aug 2025 22:10:47 +0200 Subject: [PATCH 48/66] remove limit to run only certain tasks when running all tasks --- .../Data/Manager/MultiFileContextBenchmarkManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift index b3afab6a..dcc0dd8c 100644 --- a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift @@ -67,7 +67,7 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { let taskPaths: [URL] = getTaskFolders(in: benchmarkDirectory.url) let initialTaskStates = taskPaths.map { _ in TaskStatus.scheduled } await updateTaskStates(in: benchmarkDirectory, newValue: initialTaskStates) - for (index, _) in taskPaths.prefix(1).enumerated() { + for (index, _) in taskPaths.enumerated() { await runTask(at: index, in: benchmarkDirectory) try await Task.sleep(nanoseconds: 3_000_000_000) } From 5cb69a8acd2bb0a59390e27733672cce00123472 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Sat, 16 Aug 2025 23:14:46 +0200 Subject: [PATCH 49/66] filter current symbol and paths in MultiFileContextManager --- .../MultiFileContextBenchmarkManager.swift | 15 +++++++++----- .../MultiFileContext/Entity/FileContent.swift | 5 +++++ .../MultiFileContextManager.swift | 20 +++++++++++++++---- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift index dcc0dd8c..91134b5a 100644 --- a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift @@ -81,8 +81,10 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkDirectory.path) let content: String = (try? String(contentsOf: entrypoint.fileURL, encoding: .utf8)) ?? "" - let relevantSymbols: [SymbolContent] = await retrieveRelevantSymbolsForFileContent(content: content, workspace: workspace) - + let relevantSymbols: [SymbolContent] = await retrieveRelevantSymbolsForFileContent( + file: FileContent(fileURL: entrypoint.fileURL.path, content: content), + workspace: workspace + ) let suggestionRequest = SuggestionProvider.SuggestionRequest( fileURL: entrypoint.fileURL, relativePath: entrypoint.fileURL.path.replacingOccurrences(of: benchmarkDirectory.path, with: ""), @@ -176,7 +178,10 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkDirectory.path) let content: String = (try? String(contentsOf: entrypoint.fileURL, encoding: .utf8)) ?? "" - let relevantSymbols: [SymbolContent] = await retrieveRelevantSymbolsForFileContent(content: content, workspace: workspace) + let relevantSymbols: [SymbolContent] = await retrieveRelevantSymbolsForFileContent( + file: FileContent(fileURL: entrypoint.fileURL.path, content: content), + workspace: workspace + ) let limitedRelevantSymbols = Array(relevantSymbols.prefix(10)) let suggestionRequest = SuggestionRequest( @@ -212,13 +217,13 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { } } - private func retrieveRelevantSymbolsForFileContent(content: String, workspace: Workspace) async -> [SymbolContent] { + private func retrieveRelevantSymbolsForFileContent(file: FileContent, workspace: Workspace) async -> [SymbolContent] { let multiFileContextManager = MultiFileContextManager( workspaceProvider: ManualWorkspaceProvider(workspace: workspace), parser: SwiftProgrammingLanguageSyntaxParser() ) if isMultiFileEnabledSubject.value { - let symbols = await multiFileContextManager.retrieveRelevantSymbolsForFileContent(content: content) + let symbols = await multiFileContextManager.retrieveRelevantSymbolsForFileContent(file: file, ignoreWithinPaths: ["/Benchmark/"]) return Array(symbols.values) } else { return [] diff --git a/Core/Sources/Service/MultiFileContext/Entity/FileContent.swift b/Core/Sources/Service/MultiFileContext/Entity/FileContent.swift index 1e63d6e9..cf608b2b 100644 --- a/Core/Sources/Service/MultiFileContext/Entity/FileContent.swift +++ b/Core/Sources/Service/MultiFileContext/Entity/FileContent.swift @@ -1,4 +1,9 @@ public struct FileContent { let fileURL: String let content: String + + public init(fileURL: String, content: String) { + self.fileURL = fileURL + self.content = content + } } diff --git a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift index 0d1f0c9b..a68aeb9a 100644 --- a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift +++ b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift @@ -61,17 +61,29 @@ public class MultiFileContextManager { return result } - public func retrieveRelevantSymbolsForFileContent(content: String) async -> [String: SymbolContent] { + public func retrieveRelevantSymbolsForFileContent( + file: FileContent, + ignoreWithinPaths: [String] = [] + ) async -> [String: SymbolContent] { + let currentSymbol = parser.parse(file: file) let allSymbols = await classifyContentWithinFiles() var relevantSymbols: [String: SymbolContent] = [:] for (symbolName, symbolContent) in allSymbols { - if content.contains(symbolName) { + if file.content.contains(symbolName) { relevantSymbols[symbolName] = symbolContent } } - - return relevantSymbols + + let relevantSymbolsWithoutCurrentSymbol = relevantSymbols.filter { symbolName, symbolContent in + if let currentSymbolName = currentSymbol.first?.symbol.name, + currentSymbolName == symbolName { return false } + + let path = symbolContent.fileURL + let ignored = ignoreWithinPaths.contains { pathToIgnore in path.contains(pathToIgnore) } + return !ignored + } + return relevantSymbolsWithoutCurrentSymbol } private func mergeExtensionsIntoBaseDeclarations(_ symbols: inout [SymbolContent]) { From 35c3365cad2ae2b5d176a9cde486f584bb3cb768 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Sat, 16 Aug 2025 23:32:38 +0200 Subject: [PATCH 50/66] only include exact symbol matches this fixes View extension occurrences for ViewModels for example --- .../MultiFileContextManager.swift | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift index a68aeb9a..395e551b 100644 --- a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift +++ b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift @@ -70,7 +70,7 @@ public class MultiFileContextManager { var relevantSymbols: [String: SymbolContent] = [:] for (symbolName, symbolContent) in allSymbols { - if file.content.contains(symbolName) { + if file.content.containsExactIdentifier(symbolName) { relevantSymbols[symbolName] = symbolContent } } @@ -110,3 +110,28 @@ public class MultiFileContextManager { } } + +extension String { + /// Allow any character besides letters, numbers, and underscores as boundaries + func containsExactIdentifier(_ name: String) -> Bool { + guard !name.isEmpty else { return false } + + @inline(__always) + func isIdentChar(_ c: Character) -> Bool { c.isLetter || c.isNumber || c == "_" } + + var search = startIndex.. Date: Sun, 17 Aug 2025 13:43:01 +0200 Subject: [PATCH 51/66] filter right-away instead of after retrieving symbols --- .../MultiFileContextManager.swift | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift index 395e551b..f0e49818 100644 --- a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift +++ b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift @@ -65,25 +65,23 @@ public class MultiFileContextManager { file: FileContent, ignoreWithinPaths: [String] = [] ) async -> [String: SymbolContent] { - let currentSymbol = parser.parse(file: file) + let currentSymbolName = parser.parse(file: file).first?.symbol.name let allSymbols = await classifyContentWithinFiles() - var relevantSymbols: [String: SymbolContent] = [:] - for (symbolName, symbolContent) in allSymbols { - if file.content.containsExactIdentifier(symbolName) { - relevantSymbols[symbolName] = symbolContent + var relevant: [String: SymbolContent] = [:] + + for (name, content) in allSymbols { + if let current = currentSymbolName, current == name { continue } + if ignoreWithinPaths.contains(where: { content.fileURL.contains($0) }) { continue } + + if file.content.containsExactIdentifier(name) { + if relevant[name] == nil { + relevant[name] = content + } } } - - let relevantSymbolsWithoutCurrentSymbol = relevantSymbols.filter { symbolName, symbolContent in - if let currentSymbolName = currentSymbol.first?.symbol.name, - currentSymbolName == symbolName { return false } - - let path = symbolContent.fileURL - let ignored = ignoreWithinPaths.contains { pathToIgnore in path.contains(pathToIgnore) } - return !ignored - } - return relevantSymbolsWithoutCurrentSymbol + + return relevant } private func mergeExtensionsIntoBaseDeclarations(_ symbols: inout [SymbolContent]) { From 184236ee0d9313731fd1c040e22eb0a4a784721d Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Mon, 18 Aug 2025 01:01:02 +0200 Subject: [PATCH 52/66] WIP add used dependencies to multifile context --- .../MultiFileContextManager.swift | 338 +++++++++++++++++- 1 file changed, 325 insertions(+), 13 deletions(-) diff --git a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift index f0e49818..c0d0a8b1 100644 --- a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift +++ b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift @@ -4,6 +4,9 @@ public class MultiFileContextManager { private let workspaceProvider: WorkspaceProvider private let parser: ProgrammingLanguageSyntaxParser + // cache so we don’t re-parse the file every time +// private var cachedSwiftDependenciesKeys: [String: String]? + public init(workspaceProvider: WorkspaceProvider, parser: ProgrammingLanguageSyntaxParser) { self.workspaceProvider = workspaceProvider self.parser = parser @@ -11,7 +14,7 @@ public class MultiFileContextManager { /// List files within workspace recursively /// Retrieved from: https://stackoverflow.com/a/57640445 - public func listFilesInWorkspace() async -> [String] { + public func listFilesInWorkspace(ignoreWithinPaths: [String]) async -> [String] { guard let workspaceURL = try? await workspaceProvider.getProjectRootURL() else { return [] } var files = [String]() @@ -24,6 +27,7 @@ public class MultiFileContextManager { do { let fileAttributes = try fileURL.resourceValues(forKeys: [.isRegularFileKey]) if fileAttributes.isRegularFile ?? false, fileURL.pathExtension.lowercased() == "swift" { + if ignoreWithinPaths.contains(where: { fileURL.absoluteString.contains($0) }) { continue } files.append(fileURL.absoluteString) } } catch { print(error, fileURL) } @@ -32,8 +36,60 @@ public class MultiFileContextManager { return files } - public func readFileContents() async -> [FileContent] { - let fileURLs = await listFilesInWorkspace() + /// List files within dependency directories + /// This is currently not used as it is too expensive to run +// private func listDependencyFiles() async -> [String] { +// guard let root = try? await workspaceProvider.getProjectRootURL() else { return [] } +// var files: [String] = [] +// let fm = FileManager.default +// let depRoots: [URL] = [ +// root.appendingPathComponent("Tuist/.build/checkouts", isDirectory: true), +// root.appendingPathComponent("SourcePackages/checkouts", isDirectory: true), +// root.appendingPathComponent(".swiftpm/xcode/checkout", isDirectory: true) +// ].filter { fm.fileExists(atPath: $0.path) } +// +// for base in depRoots { +// if let e = fm.enumerator( +// at: base, +// includingPropertiesForKeys: [.isRegularFileKey], +// options: [.skipsPackageDescendants] // keep this to avoid .xcodeproj/.app bundles +// ) { +// for case let url as URL in e { +// guard url.pathExtension.lowercased() == "swift" else { continue } +// // (Optional) only Sources, to avoid Tests/Examples +// // guard url.path.contains("/Sources/") else { continue } +// files.append(url.absoluteString) +// } +// } +// } +// return files +// } + + /// Looks up `swift-dependencies/Sources/Dependencies/DependencyValues.swift` + /// under common checkout roots relative to the workspace. +// func loadSwiftDependenciesDependencyValuesFile() async throws -> [String: String] { +// guard let root = try? await workspaceProvider.getProjectRootURL() else { return [:] } +// +// // candidate locations (Tuist SPM, Xcode SPM, .swiftpm) +// let candidates: [URL] = [ +// root.appendingPathComponent("Tuist/.build/checkouts/swift-dependencies/Sources/Dependencies/DependencyValues.swift"), +// root.appendingPathComponent("SourcePackages/checkouts/swift-dependencies/Sources/Dependencies/DependencyValues.swift"), +// root.appendingPathComponent(".swiftpm/xcode/checkout/swift-dependencies/Sources/Dependencies/DependencyValues.swift"), +// ] +// +// let fm = FileManager.default +// guard let fileURL = candidates.first(where: { fm.fileExists(atPath: $0.path) }) else { +// return [:] +// } +// +// let src = try String(contentsOf: fileURL, encoding: .utf8) +// return parseDependencyValuesSource(src) +// } + + public func readFileContents(ignoreWithinPaths: [String]) async -> [FileContent] { + let workspaceURLs = await listFilesInWorkspace(ignoreWithinPaths: ignoreWithinPaths) +// let dependencyURLs = await listDependencyFiles() + let fileURLs = workspaceURLs // + dependencyURLs return fileURLs.compactMap { fileURLString in guard let fileURL = URL(string: fileURLString) else { return nil } do { @@ -46,11 +102,10 @@ public class MultiFileContextManager { } } - public func classifyContentWithinFiles() async -> [String: SymbolContent] { - let fileContents = await readFileContents() + public func classifyContentWithinFiles(files: [FileContent]) async -> [String: SymbolContent] { var result: [String: SymbolContent] = [:] - for file in fileContents { + for file in files { var symbols = parser.parse(file: file) mergeExtensionsIntoBaseDeclarations(&symbols) for symbol in symbols { @@ -66,13 +121,14 @@ public class MultiFileContextManager { ignoreWithinPaths: [String] = [] ) async -> [String: SymbolContent] { let currentSymbolName = parser.parse(file: file).first?.symbol.name - let allSymbols = await classifyContentWithinFiles() + let files = await readFileContents(ignoreWithinPaths: ignoreWithinPaths) + let allSymbols = await classifyContentWithinFiles(files: files) var relevant: [String: SymbolContent] = [:] for (name, content) in allSymbols { if let current = currentSymbolName, current == name { continue } - if ignoreWithinPaths.contains(where: { content.fileURL.contains($0) }) { continue } +// if ignoreWithinPaths.contains(where: { content.fileURL.contains($0) }) { continue } if file.content.containsExactIdentifier(name) { if relevant[name] == nil { @@ -80,10 +136,57 @@ public class MultiFileContextManager { } } } + + let usedDependencyKeys = await retrieveUsedDependencyKeys(file: file, allFiles: files) + for dependency in usedDependencyKeys { + if let content = allSymbols[dependency.symbolName], !relevant.keys.contains(dependency.symbolName) { + relevant[dependency.symbolName] = content + } + } return relevant } + func retrieveUsedDependencyKeys(file: FileContent, allFiles: [FileContent]) async -> [RegisteredDependencyValue] { + let dependencyValuesExtensions = await scanProjectForDependencyKeys(files: allFiles) + let dependecyVariablesAndTypes = dependencyValuesExtensions.flatMap { getDependencySymbolsFromExtensionContent($0.symbol.content) } + let usedDependencyKeys = file.content.extractDependencyReferences(dependencies: dependecyVariablesAndTypes) + return usedDependencyKeys + } + + +// public func retrieveRelevantSymbolsForFileContent( +// file: FileContent, +// ignoreWithinPaths: [String] = [] +// ) async -> [String: SymbolContent] { +// let currentSymbolName = parser.parse(file: file).first?.symbol.name +// let allSymbols = await classifyContentWithinFiles() +// +// // Build maps +// let propToType = await collectDependencyValueKeys(from: allSymbols) // "errorToastCoordinator" -> "ErrorToastCoordinator" +// let usedKeys = extractDependencyKeys(in: file.content) // e.g. {"errorToastCoordinator"} +// +// // Invert for quick lookup: type -> set of keys +// var keysByType: [String: Set] = [:] +// for (prop, type) in propToType { keysByType[type, default: []].insert(prop) } +// +// var relevant: [String: SymbolContent] = [:] +// +// for (typeName, content) in allSymbols { +// if let current = currentSymbolName, current == typeName { continue } +// if ignoreWithinPaths.contains(where: { content.fileURL.contains($0) }) { continue } +// +// var isReferenced = file.content.containsExactIdentifier(typeName) +// if !isReferenced, let keys = keysByType[typeName], !usedKeys.isDisjoint(with: keys) { +// isReferenced = true +// } +// +// if isReferenced { relevant[typeName] = content } +// } +// +// return relevant +// } + private func mergeExtensionsIntoBaseDeclarations(_ symbols: inout [SymbolContent]) { var indexesToRemove: [Int] = [] @@ -107,24 +210,194 @@ public class MultiFileContextManager { } } + /// Parses the DependencyValues.swift source and returns property -> type mapping. +// func parseDependencyValuesSource(_ src: String) -> [String: String] { +// var map: [String: String] = [:] +// +// // Typed properties +// if let re = try? NSRegularExpression( +// pattern: #"\bvar\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z_][A-Za-z0-9_?.<>]+)\s*\{"# +// ) { +// re.enumerateMatches(in: src, range: NSRange(src.startIndex..., in: src)) { m, _, _ in +// guard +// let m, +// let name = Range(m.range(at: 1), in: src).map({ String(src[$0]) }), +// let type = Range(m.range(at: 2), in: src).map({ String(src[$0]) }) +// else { return } +// map[name] = type +// } +// } +// +// // Getter/setter using self[TypeName.self] +// if let re2 = try? NSRegularExpression( +// pattern: #"\bvar\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{[^}]*?self\[\s*([A-Za-z_][A-Za-z0-9_]*)\.self\s*\]"#, +// options: [.dotMatchesLineSeparators] +// ) { +// re2.enumerateMatches(in: src, range: NSRange(src.startIndex..., in: src)) { m, _, _ in +// guard +// let m, +// let name = Range(m.range(at: 1), in: src).map({ String(src[$0]) }), +// let type = Range(m.range(at: 2), in: src).map({ String(src[$0]) }) +// else { return } +// if map[name] == nil { map[name] = type } +// } +// } +// +// return map +// } + + /// Builds map of DependencyValues property -> backing type. + /// 1) First tries from already parsed project symbols (your extensions). + /// 2) If empty, tries to load from swift-dependencies checkout quickly. +// func collectDependencyValueKeys(from symbols: [String: SymbolContent]) async -> [String: String] { +// var map: [String: String] = [:] +// +// // 1) Project-local extensions (what you already had) +// for (_, content) in symbols { +// guard content.symbol.kind == .extensionWord, +// content.symbol.name == "DependencyValues" else { continue } +// +// let src = content.content +// +// // Typed: var foo: TypeName { +// if let re = try? NSRegularExpression( +// pattern: #"\bvar\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z_][A-Za-z0-9_?.<>]+)\s*\{"# +// ) { +// re.enumerateMatches(in: src, range: NSRange(src.startIndex..., in: src)) { m, _, _ in +// guard +// let m, +// let name = Range(m.range(at: 1), in: src).map({ String(src[$0]) }), +// let type = Range(m.range(at: 2), in: src).map({ String(src[$0]) }) +// else { return } +// map[name] = type +// } +// } +// +// // Getter body: self[TypeName.self] +// if let re2 = try? NSRegularExpression( +// pattern: #"\bvar\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{[^}]*?self\[\s*([A-Za-z_][A-Za-z0-9_]*)\.self\s*\]"#, +// options: [.dotMatchesLineSeparators] +// ) { +// re2.enumerateMatches(in: src, range: NSRange(src.startIndex..., in: src)) { m, _, _ in +// guard +// let m, +// let name = Range(m.range(at: 1), in: src).map({ String(src[$0]) }), +// let type = Range(m.range(at: 2), in: src).map({ String(src[$0]) }) +// else { return } +// if map[name] == nil { map[name] = type } +// } +// } +// } +// +// if !map.isEmpty { return map } // ✅ found in project +// +// // 2) Fast fallback to the single file from swift-dependencies +// if let cached = cachedSwiftDependenciesKeys { return cached } +// +// if let fast = try? await loadSwiftDependenciesDependencyValuesFile(), +// !fast.isEmpty { +// cachedSwiftDependenciesKeys = fast +// return fast +// } +// +// return map // empty +// } + + // Extracts keys like `errorToastCoordinator` from `@Dependency(\.errorToastCoordinator)` +// private func extractDependencyKeys(in source: String) -> Set { +// // Matches: @Dependency(\.fooBar) +// let pattern = #"@Dependency\s*\(\s*\\\.\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)"# +// guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] } +// +// var keys = Set() +// let range = NSRange(source.startIndex..., in: source) +// regex.enumerateMatches(in: source, options: [], range: range) { match, _, _ in +// guard +// let match, +// let r = Range(match.range(at: 1), in: source) +// else { return } +// keys.insert(String(source[r])) +// } +// return keys +// } + + func scanProjectForDependencyKeys(files: [FileContent]) async -> [SymbolContent] { + let allSymbols: [SymbolContent] = files.flatMap { parser.parse(file: $0) } + let dependencyValueExtensions = allSymbols.filter { + $0.symbol.kind == .extensionWord && $0.symbol.name == "DependencyValues" + } + return dependencyValueExtensions + } + + /// Extract every `var : { ... }` and/or `self[.self]` from an + /// `extension DependencyValues { ... }` source string. + func getDependencySymbolsFromExtensionContent(_ content: String) -> [RegisteredDependencyValue] { + var results = Set() + + // 1) Typed property form: `var fooBar: TypeName {` + if let typed = try? NSRegularExpression( + pattern: #"\bvar\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z_][A-Za-z0-9_?.<>]+)\s*\{"#, + options: [] + ) { + let range = NSRange(content.startIndex..., in: content) + typed.enumerateMatches(in: content, options: [], range: range) { m, _, _ in + guard + let m, + let nameR = Range(m.range(at: 1), in: content), + let typeR = Range(m.range(at: 2), in: content) + else { return } + let varName = String(content[nameR]) + let typeName = String(content[typeR]) + results.insert(.init(variableName: varName, symbolName: typeName)) + } + } + + // 2) Getter/setter body form: `var foo { get { self[TypeName.self] } ... }` + // (dotMatchesLineSeparators so it works across newlines) +// if let body = try? NSRegularExpression( +// pattern: #"\bvar\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{[^}]*?self\[\s*([A-Za-z_][A-Za-z0-9_?.<>]+)\.self\s*\]"#, +// options: [.dotMatchesLineSeparators] +// ) { +// let range = NSRange(content.startIndex..., in: content) +// body.enumerateMatches(in: content, options: [], range: range) { m, _, _ in +// guard +// let m, +// let nameR = Range(m.range(at: 1), in: content), +// let typeR = Range(m.range(at: 2), in: content) +// else { return } +// let varName = String(content[nameR]) +// let typeName = String(content[typeR]) +// // Don’t overwrite a typed match if it already exists +// results.insert(.init(variableName: varName, symbolName: typeName)) +// } +// } + + return Array(results)//.flatMap { $0 } + } +} + +struct RegisteredDependencyValue: Hashable { + let variableName: String + let symbolName: String } extension String { - /// Allow any character besides letters, numbers, and underscores as boundaries + /// Checks if the string contains an exact symbol match. + /// Allows any character besides letters, numbers, and underscores as boundaries func containsExactIdentifier(_ name: String) -> Bool { guard !name.isEmpty else { return false } - + @inline(__always) func isIdentChar(_ c: Character) -> Bool { c.isLetter || c.isNumber || c == "_" } - + var search = startIndex..)` and returning the matching entries from `dependencies`. + /// - Returns: Ordered, de-duplicated list of RegisteredDependencyValue, in order of first appearance. + func extractDependencyReferences(dependencies: [RegisteredDependencyValue]) -> [RegisteredDependencyValue] { + // Build quick lookup: variableName -> RegisteredDependencyValue + let byName = Dictionary(uniqueKeysWithValues: dependencies.map { ($0.variableName, $0) }) + + // Regex: @Dependency(\.fooBar) + let pattern = #"@Dependency\s*\(\s*\\\.\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] } + + let ns = self as NSString + let range = NSRange(location: 0, length: ns.length) + + // Collect (key, location) to preserve order; de-dupe by key + var seen = Set() + var orderedKeys: [(key: String, location: Int)] = [] + + regex.enumerateMatches(in: self, options: [], range: range) { match, _, _ in + guard + let match, + match.numberOfRanges >= 2 + else { return } + let keyRange = match.range(at: 1) + guard keyRange.location != NSNotFound else { return } + let key = ns.substring(with: keyRange) + if !seen.contains(key) { + seen.insert(key) + orderedKeys.append((key, match.range.location)) + } + } + + // Sort by first occurrence in the file + orderedKeys.sort { $0.location < $1.location } + + // Map to known dependencies; unknown keys are ignored + return orderedKeys.compactMap { byName[$0.key] } + } } From a9f75b12ddfe8c817b8221ec3b86158fe76176f6 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Mon, 18 Aug 2025 01:06:22 +0200 Subject: [PATCH 53/66] cleanup after adding dependecies --- .../Entity/RegisteredDependencyValue.swift | 4 + .../MultiFileContextManager.swift | 232 +----------------- .../MultiFileContextManagerTests.swift | 50 ++-- 3 files changed, 38 insertions(+), 248 deletions(-) create mode 100644 Core/Sources/Service/MultiFileContext/Entity/RegisteredDependencyValue.swift diff --git a/Core/Sources/Service/MultiFileContext/Entity/RegisteredDependencyValue.swift b/Core/Sources/Service/MultiFileContext/Entity/RegisteredDependencyValue.swift new file mode 100644 index 00000000..90d1f199 --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/Entity/RegisteredDependencyValue.swift @@ -0,0 +1,4 @@ +struct RegisteredDependencyValue: Hashable { + let variableName: String + let symbolName: String +} diff --git a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift index c0d0a8b1..a79c019d 100644 --- a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift +++ b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift @@ -4,9 +4,6 @@ public class MultiFileContextManager { private let workspaceProvider: WorkspaceProvider private let parser: ProgrammingLanguageSyntaxParser - // cache so we don’t re-parse the file every time -// private var cachedSwiftDependenciesKeys: [String: String]? - public init(workspaceProvider: WorkspaceProvider, parser: ProgrammingLanguageSyntaxParser) { self.workspaceProvider = workspaceProvider self.parser = parser @@ -36,60 +33,9 @@ public class MultiFileContextManager { return files } - /// List files within dependency directories - /// This is currently not used as it is too expensive to run -// private func listDependencyFiles() async -> [String] { -// guard let root = try? await workspaceProvider.getProjectRootURL() else { return [] } -// var files: [String] = [] -// let fm = FileManager.default -// let depRoots: [URL] = [ -// root.appendingPathComponent("Tuist/.build/checkouts", isDirectory: true), -// root.appendingPathComponent("SourcePackages/checkouts", isDirectory: true), -// root.appendingPathComponent(".swiftpm/xcode/checkout", isDirectory: true) -// ].filter { fm.fileExists(atPath: $0.path) } -// -// for base in depRoots { -// if let e = fm.enumerator( -// at: base, -// includingPropertiesForKeys: [.isRegularFileKey], -// options: [.skipsPackageDescendants] // keep this to avoid .xcodeproj/.app bundles -// ) { -// for case let url as URL in e { -// guard url.pathExtension.lowercased() == "swift" else { continue } -// // (Optional) only Sources, to avoid Tests/Examples -// // guard url.path.contains("/Sources/") else { continue } -// files.append(url.absoluteString) -// } -// } -// } -// return files -// } - - /// Looks up `swift-dependencies/Sources/Dependencies/DependencyValues.swift` - /// under common checkout roots relative to the workspace. -// func loadSwiftDependenciesDependencyValuesFile() async throws -> [String: String] { -// guard let root = try? await workspaceProvider.getProjectRootURL() else { return [:] } -// -// // candidate locations (Tuist SPM, Xcode SPM, .swiftpm) -// let candidates: [URL] = [ -// root.appendingPathComponent("Tuist/.build/checkouts/swift-dependencies/Sources/Dependencies/DependencyValues.swift"), -// root.appendingPathComponent("SourcePackages/checkouts/swift-dependencies/Sources/Dependencies/DependencyValues.swift"), -// root.appendingPathComponent(".swiftpm/xcode/checkout/swift-dependencies/Sources/Dependencies/DependencyValues.swift"), -// ] -// -// let fm = FileManager.default -// guard let fileURL = candidates.first(where: { fm.fileExists(atPath: $0.path) }) else { -// return [:] -// } -// -// let src = try String(contentsOf: fileURL, encoding: .utf8) -// return parseDependencyValuesSource(src) -// } - public func readFileContents(ignoreWithinPaths: [String]) async -> [FileContent] { let workspaceURLs = await listFilesInWorkspace(ignoreWithinPaths: ignoreWithinPaths) -// let dependencyURLs = await listDependencyFiles() - let fileURLs = workspaceURLs // + dependencyURLs + let fileURLs = workspaceURLs return fileURLs.compactMap { fileURLString in guard let fileURL = URL(string: fileURLString) else { return nil } do { @@ -128,7 +74,6 @@ public class MultiFileContextManager { for (name, content) in allSymbols { if let current = currentSymbolName, current == name { continue } -// if ignoreWithinPaths.contains(where: { content.fileURL.contains($0) }) { continue } if file.content.containsExactIdentifier(name) { if relevant[name] == nil { @@ -147,45 +92,12 @@ public class MultiFileContextManager { return relevant } - func retrieveUsedDependencyKeys(file: FileContent, allFiles: [FileContent]) async -> [RegisteredDependencyValue] { + private func retrieveUsedDependencyKeys(file: FileContent, allFiles: [FileContent]) async -> [RegisteredDependencyValue] { let dependencyValuesExtensions = await scanProjectForDependencyKeys(files: allFiles) let dependecyVariablesAndTypes = dependencyValuesExtensions.flatMap { getDependencySymbolsFromExtensionContent($0.symbol.content) } let usedDependencyKeys = file.content.extractDependencyReferences(dependencies: dependecyVariablesAndTypes) return usedDependencyKeys } - - -// public func retrieveRelevantSymbolsForFileContent( -// file: FileContent, -// ignoreWithinPaths: [String] = [] -// ) async -> [String: SymbolContent] { -// let currentSymbolName = parser.parse(file: file).first?.symbol.name -// let allSymbols = await classifyContentWithinFiles() -// -// // Build maps -// let propToType = await collectDependencyValueKeys(from: allSymbols) // "errorToastCoordinator" -> "ErrorToastCoordinator" -// let usedKeys = extractDependencyKeys(in: file.content) // e.g. {"errorToastCoordinator"} -// -// // Invert for quick lookup: type -> set of keys -// var keysByType: [String: Set] = [:] -// for (prop, type) in propToType { keysByType[type, default: []].insert(prop) } -// -// var relevant: [String: SymbolContent] = [:] -// -// for (typeName, content) in allSymbols { -// if let current = currentSymbolName, current == typeName { continue } -// if ignoreWithinPaths.contains(where: { content.fileURL.contains($0) }) { continue } -// -// var isReferenced = file.content.containsExactIdentifier(typeName) -// if !isReferenced, let keys = keysByType[typeName], !usedKeys.isDisjoint(with: keys) { -// isReferenced = true -// } -// -// if isReferenced { relevant[typeName] = content } -// } -// -// return relevant -// } private func mergeExtensionsIntoBaseDeclarations(_ symbols: inout [SymbolContent]) { var indexesToRemove: [Int] = [] @@ -210,118 +122,7 @@ public class MultiFileContextManager { } } - /// Parses the DependencyValues.swift source and returns property -> type mapping. -// func parseDependencyValuesSource(_ src: String) -> [String: String] { -// var map: [String: String] = [:] -// -// // Typed properties -// if let re = try? NSRegularExpression( -// pattern: #"\bvar\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z_][A-Za-z0-9_?.<>]+)\s*\{"# -// ) { -// re.enumerateMatches(in: src, range: NSRange(src.startIndex..., in: src)) { m, _, _ in -// guard -// let m, -// let name = Range(m.range(at: 1), in: src).map({ String(src[$0]) }), -// let type = Range(m.range(at: 2), in: src).map({ String(src[$0]) }) -// else { return } -// map[name] = type -// } -// } -// -// // Getter/setter using self[TypeName.self] -// if let re2 = try? NSRegularExpression( -// pattern: #"\bvar\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{[^}]*?self\[\s*([A-Za-z_][A-Za-z0-9_]*)\.self\s*\]"#, -// options: [.dotMatchesLineSeparators] -// ) { -// re2.enumerateMatches(in: src, range: NSRange(src.startIndex..., in: src)) { m, _, _ in -// guard -// let m, -// let name = Range(m.range(at: 1), in: src).map({ String(src[$0]) }), -// let type = Range(m.range(at: 2), in: src).map({ String(src[$0]) }) -// else { return } -// if map[name] == nil { map[name] = type } -// } -// } -// -// return map -// } - - /// Builds map of DependencyValues property -> backing type. - /// 1) First tries from already parsed project symbols (your extensions). - /// 2) If empty, tries to load from swift-dependencies checkout quickly. -// func collectDependencyValueKeys(from symbols: [String: SymbolContent]) async -> [String: String] { -// var map: [String: String] = [:] -// -// // 1) Project-local extensions (what you already had) -// for (_, content) in symbols { -// guard content.symbol.kind == .extensionWord, -// content.symbol.name == "DependencyValues" else { continue } -// -// let src = content.content -// -// // Typed: var foo: TypeName { -// if let re = try? NSRegularExpression( -// pattern: #"\bvar\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z_][A-Za-z0-9_?.<>]+)\s*\{"# -// ) { -// re.enumerateMatches(in: src, range: NSRange(src.startIndex..., in: src)) { m, _, _ in -// guard -// let m, -// let name = Range(m.range(at: 1), in: src).map({ String(src[$0]) }), -// let type = Range(m.range(at: 2), in: src).map({ String(src[$0]) }) -// else { return } -// map[name] = type -// } -// } -// -// // Getter body: self[TypeName.self] -// if let re2 = try? NSRegularExpression( -// pattern: #"\bvar\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{[^}]*?self\[\s*([A-Za-z_][A-Za-z0-9_]*)\.self\s*\]"#, -// options: [.dotMatchesLineSeparators] -// ) { -// re2.enumerateMatches(in: src, range: NSRange(src.startIndex..., in: src)) { m, _, _ in -// guard -// let m, -// let name = Range(m.range(at: 1), in: src).map({ String(src[$0]) }), -// let type = Range(m.range(at: 2), in: src).map({ String(src[$0]) }) -// else { return } -// if map[name] == nil { map[name] = type } -// } -// } -// } -// -// if !map.isEmpty { return map } // ✅ found in project -// -// // 2) Fast fallback to the single file from swift-dependencies -// if let cached = cachedSwiftDependenciesKeys { return cached } -// -// if let fast = try? await loadSwiftDependenciesDependencyValuesFile(), -// !fast.isEmpty { -// cachedSwiftDependenciesKeys = fast -// return fast -// } -// -// return map // empty -// } - - // Extracts keys like `errorToastCoordinator` from `@Dependency(\.errorToastCoordinator)` -// private func extractDependencyKeys(in source: String) -> Set { -// // Matches: @Dependency(\.fooBar) -// let pattern = #"@Dependency\s*\(\s*\\\.\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)"# -// guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] } -// -// var keys = Set() -// let range = NSRange(source.startIndex..., in: source) -// regex.enumerateMatches(in: source, options: [], range: range) { match, _, _ in -// guard -// let match, -// let r = Range(match.range(at: 1), in: source) -// else { return } -// keys.insert(String(source[r])) -// } -// return keys -// } - - func scanProjectForDependencyKeys(files: [FileContent]) async -> [SymbolContent] { + private func scanProjectForDependencyKeys(files: [FileContent]) async -> [SymbolContent] { let allSymbols: [SymbolContent] = files.flatMap { parser.parse(file: $0) } let dependencyValueExtensions = allSymbols.filter { $0.symbol.kind == .extensionWord && $0.symbol.name == "DependencyValues" @@ -331,7 +132,7 @@ public class MultiFileContextManager { /// Extract every `var : { ... }` and/or `self[.self]` from an /// `extension DependencyValues { ... }` source string. - func getDependencySymbolsFromExtensionContent(_ content: String) -> [RegisteredDependencyValue] { + private func getDependencySymbolsFromExtensionContent(_ content: String) -> [RegisteredDependencyValue] { var results = Set() // 1) Typed property form: `var fooBar: TypeName {` @@ -352,35 +153,10 @@ public class MultiFileContextManager { } } - // 2) Getter/setter body form: `var foo { get { self[TypeName.self] } ... }` - // (dotMatchesLineSeparators so it works across newlines) -// if let body = try? NSRegularExpression( -// pattern: #"\bvar\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{[^}]*?self\[\s*([A-Za-z_][A-Za-z0-9_?.<>]+)\.self\s*\]"#, -// options: [.dotMatchesLineSeparators] -// ) { -// let range = NSRange(content.startIndex..., in: content) -// body.enumerateMatches(in: content, options: [], range: range) { m, _, _ in -// guard -// let m, -// let nameR = Range(m.range(at: 1), in: content), -// let typeR = Range(m.range(at: 2), in: content) -// else { return } -// let varName = String(content[nameR]) -// let typeName = String(content[typeR]) -// // Don’t overwrite a typed match if it already exists -// results.insert(.init(variableName: varName, symbolName: typeName)) -// } -// } - return Array(results)//.flatMap { $0 } } } -struct RegisteredDependencyValue: Hashable { - let variableName: String - let symbolName: String -} - extension String { /// Checks if the string contains an exact symbol match. /// Allows any character besides letters, numbers, and underscores as boundaries diff --git a/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift index d68d09cf..be94d8c8 100644 --- a/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift +++ b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift @@ -12,26 +12,35 @@ class MultiFileContextManagerTests: XCTestCase { ) } - func testListingFiles() async { - let sut = sut - let files = await sut.listFilesInWorkspace() - - XCTAssertNotEqual(files.count, 0) - } +// func testListingFiles() async { +// let sut = sut +// let files = await sut.listFilesInWorkspace() +// +// XCTAssertNotEqual(files.count, 0) +// } - func testRetrievingFileContent() async { - let sut = sut - let files = await sut.readFileContents() - - XCTAssertNotEqual(files.count, 0) - } +// func testRetrievingFileContent() async { +// let sut = sut +// let files = await sut.readFileContents() +// +// XCTAssertNotEqual(files.count, 0) +// } - func testClassifyingCode() async { - let sut = sut - let classifiedSymbols = await sut.classifyContentWithinFiles() - // symbols - XCTAssertNotEqual(classifiedSymbols.count, 0) - } +// func testClassifyingCode() async { +// let sut = sut +// let classifiedSymbols = await sut.classifyContentWithinFiles() +// // symbols +// XCTAssertNotEqual(classifiedSymbols.count, 0) +// } + +// func testScanningForDependencyKeys() async { +// let sut = sut +// let dependencyKeys = await sut.scanProjectForDependencyKeys() +// let registeredDependencies = dependencyKeys.map { dependencyKey in +// sut.getDependencySymbolsFromExtensionContent(dependencyKey.symbol.content) +// } +// XCTAssertNotEqual(dependencyKeys.count, 0) +// } } @@ -42,12 +51,13 @@ class WorkspaceProviderMock: WorkspaceProvider { // let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/ios-minttv") // let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/TwitchEndpoint.swift") // let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/Chat/IRC") - let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/Chat/Presentation/View") +// let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/Chat/Presentation/View")projectRootURL Foundation.URL "file:///Users/christopherknapp/repos/ModernCleanArchitectureSwiftUI/" + let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/ModernCleanArchitectureSwiftUI/") return Workspace(workspaceURL: workspaceURL) } func getProjectRootURL() async throws -> URL { - URL(filePath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/Chat/Presentation/View") + URL(filePath: "/Users/christopherknapp/repos/ModernCleanArchitectureSwiftUI/") } } From 8518bfe36740b35797dd0f704772597031aaaae1 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Mon, 18 Aug 2025 14:48:34 +0200 Subject: [PATCH 54/66] adjust date to match current time zone --- .../Manager/MultiFileContextBenchmarkManager.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift index 91134b5a..31e9f417 100644 --- a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift @@ -322,9 +322,13 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { } } - let timestamp = ISO8601DateFormatter().string(from: Date()) - let reformattedTimestamp = timestamp.replacingOccurrences(of: ":", with: "-") - let outputFileURL = contextFolder.appendingPathComponent("Task-\(taskNumber)-\(reformattedTimestamp).json") + let iso = ISO8601DateFormatter() + iso.timeZone = .current + iso.formatOptions = [.withInternetDateTime, .withDashSeparatorInDate, .withColonSeparatorInTime, .withTimeZone] + + let timestamp = iso.string(from: Date()) + let safe = timestamp.replacingOccurrences(of: ":", with: "-") // filenames: 2025-08-18T21-07-03+02-00 + let outputFileURL = contextFolder.appendingPathComponent("Task-\(taskNumber)-\(safe).json") do { let dto = suggestion.toStoredDTO(timestamp: timestamp) From 6a79c2e92212642a5106e19cc0572272eaabd6da Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Mon, 18 Aug 2025 15:11:39 +0200 Subject: [PATCH 55/66] fix removing closing brace if contains trailing newline after closing brace --- .../OpenAICompletionRepository.swift | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift index 8a755749..4c31403f 100644 --- a/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift +++ b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift @@ -79,14 +79,36 @@ struct OpenAICompletionRepository: CodeCompletionRepository { } private func removeClosingBraceIfNeeded(suggestion: String, promptCode: String, line: Int) -> String { - let lines = suggestion.components(separatedBy: "\n") - let promptCodeLines = promptCode.components(separatedBy: "\n") - let lineAfterCursor = promptCodeLines[line+1] - if lineAfterCursor == lines.last { - return lines.dropLast().joined(separator: "\n") - } else { - return suggestion + // Defensive: find the first line AFTER the cursor line + let promptLines = promptCode.components(separatedBy: "\n") + guard line + 1 < promptLines.count else { return suggestion } + let afterFirstLineTrimmed = promptLines[line + 1].trimmingCharacters(in: .whitespacesAndNewlines) + + // Work on line array of the suggestion + var lines = suggestion.components(separatedBy: "\n") + + // Find the last *non-empty* line (ignoring pure whitespace) + var lastNonEmptyIndex: Int? + for i in lines.indices.reversed() { + if !lines[i].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + lastNonEmptyIndex = i + break + } } + guard let idx = lastNonEmptyIndex else { return suggestion } // all whitespace? leave as-is + + let tailTrimmed = lines[idx].trimmingCharacters(in: .whitespacesAndNewlines) + + // If the model echoed the first AFTER line (e.g. a closing brace), drop it + if !afterFirstLineTrimmed.isEmpty, tailTrimmed == afterFirstLineTrimmed { + // Remove that line and any trailing blank lines after it + lines.removeSubrange(idx.. String { From 810a1b41d4eb1465f24e27d179c05deb0e7c427b Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Mon, 18 Aug 2025 23:12:10 +0200 Subject: [PATCH 56/66] increase timeout to 5 min to accept slower LLMs --- .../Data/Repository/OpenAICompletionRepository.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift index 4c31403f..2ff85d09 100644 --- a/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift +++ b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift @@ -35,9 +35,14 @@ struct OpenAICompletionRepository: CodeCompletionRepository { private let config: Config private let urlSession: URLSession - init(config: Config, session: URLSession = .shared) { + init(config: Config) { self.config = config - self.urlSession = session + + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 300 // 5 minutes + configuration.timeoutIntervalForResource = 300 // 5 minutes + + self.urlSession = URLSession(configuration: configuration) } func structuredEdit(for request: SuggestionRequest) async throws -> CodeEdit { From 123b28b3fc4093b17ff8436d790a178753e43555 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Mon, 18 Aug 2025 23:12:43 +0200 Subject: [PATCH 57/66] adjust temperature based on response --- .../Data/Repository/OpenAICompletionRepository.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift index 2ff85d09..311016e8 100644 --- a/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift +++ b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift @@ -55,7 +55,7 @@ struct OpenAICompletionRepository: CodeCompletionRepository { let payload = ChatPayload( model: config.model, messages: buildMessagesForStructuredEdit(from: request), - temperature: 0, + temperature: config.model == "gpt-5" ? 1 : 0, // gpt-5 only supports temperature 1 stream: false, response_format: JSONOnly() // ask for a single JSON object ) From 1abaeefeeeb7f55395286d0cd5808fec0749b63b Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Wed, 20 Aug 2025 01:20:02 +0200 Subject: [PATCH 58/66] register plugins before and unregister after running task this is important to kill the background LSP process and start a fresh one with each task --- .../MultiFileContextBenchmarkManager.swift | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift index 31e9f417..2742e87e 100644 --- a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift @@ -36,15 +36,6 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { BuiltinExtensionManager.shared.setupExtensions([ GitHubCopilotExtension(workspacePool: workspacePool) ]) - workspacePool.registerPlugin { - SuggestionServiceWorkspacePlugin(workspace: $0) { SuggestionService.service() } - } - workspacePool.registerPlugin { - GitHubCopilotWorkspacePlugin(workspace: $0) - } - workspacePool.registerPlugin { - BuiltinExtensionWorkspacePlugin(workspace: $0) - } self.workspacePool = workspacePool self.benchmarkSettingsRepository = benchmarkSettingsRepository @@ -63,6 +54,27 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { }.store(in: &cancellables) } + @WorkspaceActor + private func registerPlugins() { + workspacePool.registerPlugin { + SuggestionServiceWorkspacePlugin(workspace: $0) { SuggestionService.service() } + } + workspacePool.registerPlugin { + // contains nested GitHubCopilotBaseService which runs the LSP on constructor call + GitHubCopilotWorkspacePlugin(workspace: $0) + } + workspacePool.registerPlugin { + BuiltinExtensionWorkspacePlugin(workspace: $0) + } + } + + @WorkspaceActor + private func unregisterPlugins() { + workspacePool.unregisterPlugin(SuggestionServiceWorkspacePlugin.self) + workspacePool.unregisterPlugin(GitHubCopilotWorkspacePlugin.self) + workspacePool.unregisterPlugin(BuiltinExtensionWorkspacePlugin.self) + } + func getCodeSuggestions(at benchmarkDirectory: BenchmarkDirectory) async throws { let taskPaths: [URL] = getTaskFolders(in: benchmarkDirectory.url) let initialTaskStates = taskPaths.map { _ in TaskStatus.scheduled } @@ -148,11 +160,14 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { func runTask(at index: Int, in benchmarkDirectory: BenchmarkDirectory) async { let taskPath = getTaskFolders(in: benchmarkDirectory.url)[index] await updateTaskStatus(in: benchmarkDirectory, state: .running, at: index) - if case .githubCopilot = selectedGenAIModelSubject.value, - let suggestion = await getCodeSuggestionFromService(at: taskPath, from: benchmarkDirectory.url) { - await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) - await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) - await updateTaskStatus(in: benchmarkDirectory, state: .success, at: index) + if case .githubCopilot = selectedGenAIModelSubject.value { + await registerPlugins() + if let suggestion = await getCodeSuggestionFromService(at: taskPath, from: benchmarkDirectory.url) { + await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) + await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) + await updateTaskStatus(in: benchmarkDirectory, state: .success, at: index) + } + await unregisterPlugins() } else if let openAIKey = openAIKey, case let .openAI(model) = selectedGenAIModelSubject.value, let suggestion = await getCodeSuggestionFromOpenAI( From 75c4da6088363bdb0fb054c65c933ea3461a9d41 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Wed, 20 Aug 2025 01:21:33 +0200 Subject: [PATCH 59/66] fix bug where URL file strings could not be used to create a URL --- .../Manager/MultiFileContextBenchmarkManager.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift index 2742e87e..38346531 100644 --- a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift @@ -131,7 +131,8 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { private func openFilespaces(entrypoint: EntryPoint, relevantSymbols: [SymbolContent], in workspace: Workspace) async { for symbol in relevantSymbols { - if let filespace = try? await workspace.createFilespaceIfNeeded(fileURL: URL(fileURLWithPath: symbol.fileURL)) { + if let fileURL = symbol.fileURL.toFileURL(), + let filespace = try? await workspace.createFilespaceIfNeeded(fileURL: fileURL) { await workspace.didOpenFilespace(filespace) } } @@ -142,7 +143,9 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { private func closeFilespaces(entrypoint: EntryPoint, relevantSymbols: [SymbolContent], in workspace: Workspace) async { for symbol in relevantSymbols { - await workspace.didCloseFilespace(URL(fileURLWithPath: symbol.fileURL)) + if let fileURL = symbol.fileURL.toFileURL() { + await workspace.didCloseFilespace(fileURL) + } } await workspace.didCloseFilespace(entrypoint.fileURL) } @@ -591,3 +594,9 @@ extension CodeEdit { ) } } + +extension String { + func toFileURL() -> URL? { + hasPrefix("file://") ? URL(string: self) : URL(fileURLWithPath: self) + } +} From 7aac500a9c3b518070c45d6ea3b071266db6d9ee Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Wed, 20 Aug 2025 01:22:46 +0200 Subject: [PATCH 60/66] ignore open files since we are benchmarking other functionality and this impacts our results --- Tool/Sources/Workspace/OpenedFileRocoverableStorage.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Tool/Sources/Workspace/OpenedFileRocoverableStorage.swift b/Tool/Sources/Workspace/OpenedFileRocoverableStorage.swift index 06833867..b47dc13a 100644 --- a/Tool/Sources/Workspace/OpenedFileRocoverableStorage.swift +++ b/Tool/Sources/Workspace/OpenedFileRocoverableStorage.swift @@ -31,9 +31,10 @@ public final class OpenedFileRecoverableStorage { } public var openedFiles: [URL] { - let dict = userDefault.dictionary(forKey: key) ?? [:] - let openedFiles = dict[projectRootURL.path] as? [String] ?? [] - return openedFiles.map { URL(fileURLWithPath: $0) } +// let dict = userDefault.dictionary(forKey: key) ?? [:] +// let openedFiles = dict[projectRootURL.path] as? [String] ?? [] +// return openedFiles.map { URL(fileURLWithPath: $0) } + [] } } From 13a53906267366b32ab7ab33e33395b9fe28db68 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Wed, 20 Aug 2025 01:24:14 +0200 Subject: [PATCH 61/66] prevent service from doing any unwanted indexing whatsoever while benchmarking --- ExtensionService/AppDelegate.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index dc81bcd6..6514381d 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -56,13 +56,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { func applicationDidFinishLaunching(_: Notification) { if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] == "YES" { return } - _ = XcodeInspector.shared - service.start() - AXIsProcessTrustedWithOptions([ - kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: true, - ] as CFDictionary) - setupQuitOnUpdate() - setupQuitOnUserTerminated() +// _ = XcodeInspector.shared +// service.start() +// AXIsProcessTrustedWithOptions([ +// kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: true, +// ] as CFDictionary) +// setupQuitOnUpdate() +// setupQuitOnUserTerminated() xpcController = .init() Logger.service.info("XPC Service started.") NSApp.setActivationPolicy(.accessory) From 512cafef843a28637bf671bd9ef56d71bb4f5a51 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Mon, 8 Sep 2025 19:08:40 +0200 Subject: [PATCH 62/66] update README to reflect changes during thesis --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index 9d6a8d72..2bb85e3b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,36 @@ # GitHub Copilot for Xcode +## Note on this Fork + +This repository is a **fork** of [github/CopilotForXcode](https://github.com/github/CopilotForXcode). +It has been extended as part of my Master’s Thesis at HTW Berlin: +*“Enhancing a Generative AI Tool for Improved Code Generation and Automated Application in an IDE”*. + +### Additions in this Fork + +- **Multi-file Inline Completions**: Extended support to allow Copilot suggestions across multiple files, not just the opened tabs. +- **Benchmark Tool**: Added functionality to test code completion quality across several large language models (GitHub Copilot, multiple GPT variants from OpenAI) to be used together with a specifically designed dataset. +- **Evaluation Dataset**: Inspired by [HumanEval](https://arxiv.org/abs/2107.03374), but adapted to reflect realistic multi-file development scenarios. + +These extensions are research prototypes and not intended for merging upstream. They provide the basis for benchmarking and evaluating IDE integration with generative AI. + +### Related Repository + +This project is used in combination with another fork created for the thesis: +- [Modern Clean Architecture](https://github.com/chrisknapp98/ModernCleanArchitecture) – extended with a `Benchmark` module containing 15 tasks for reproducible evaluation. + +### Default Branch + +The default branch of this fork has been changed from `main` to `integration/multi-file-context-benchmark`. + +Reason: +This fork was extended with **SwiftSyntax** support and upgraded to **Swift 5.10**, while the upstream project is still on **Swift 5.9**. +To avoid constant “ping-pong” merge conflicts in `Package.swift` and related build files, this fork maintains its own stable branch. + +If you want the extended research prototype features (multi-file completions, benchmarks, dataset), use this fork’s default branch. + +--- + [GitHub Copilot](https://github.com/features/copilot) is an AI pair programmer tool that helps you write code faster and smarter. Copilot for Xcode is an Xcode extension that provides inline coding suggestions as you type and a chat assistant to answer your coding questions. From eeddabcfc2f29986fac4d6c2258a37c039d149c2 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Mon, 8 Sep 2025 19:09:28 +0200 Subject: [PATCH 63/66] fix related repo url --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2bb85e3b..71f9bef5 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ These extensions are research prototypes and not intended for merging upstream. ### Related Repository This project is used in combination with another fork created for the thesis: -- [Modern Clean Architecture](https://github.com/chrisknapp98/ModernCleanArchitecture) – extended with a `Benchmark` module containing 15 tasks for reproducible evaluation. +- [Modern Clean ArchitectureSwiftUI](https://github.com/chrisknapp98/ModernCleanArchitectureSwiftUI) – extended with a `Benchmark` module containing 15 tasks for reproducible evaluation. ### Default Branch From 46dcfbbdfce6c3718fc02cc20957bd61bb94114f Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Mon, 8 Sep 2025 21:10:14 +0200 Subject: [PATCH 64/66] add duration in seconds to output file --- .../Data/Entity/StoredSuggestionDTO.swift | 20 +++++++++++++ .../MultiFileContextBenchmarkManager.swift | 29 ++++++++++++++----- .../Entity/RelevantSymbolsSummary.swift | 6 ++++ .../Domain/Entity/SuggestionResponse.swift | 1 + 4 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 Core/Sources/HostApp/Benchmark/Domain/Entity/RelevantSymbolsSummary.swift diff --git a/Core/Sources/HostApp/Benchmark/Data/Entity/StoredSuggestionDTO.swift b/Core/Sources/HostApp/Benchmark/Data/Entity/StoredSuggestionDTO.swift index 4b5731ca..531c7729 100644 --- a/Core/Sources/HostApp/Benchmark/Data/Entity/StoredSuggestionDTO.swift +++ b/Core/Sources/HostApp/Benchmark/Data/Entity/StoredSuggestionDTO.swift @@ -1,3 +1,5 @@ +import Foundation + struct StoredSuggestionDTO: Codable { let fileURL: String let id: String @@ -7,6 +9,7 @@ struct StoredSuggestionDTO: Codable { let createdAt: String let relevantSymbols: [RelevantSymbolsDTO] let model: String + let relevantFileScanningDurationInSeconds: Double? struct CursorPositionDTO: Codable { let line: Int @@ -26,4 +29,21 @@ struct StoredSuggestionDTO: Codable { let endLine: Int let kind: String } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(fileURL, forKey: .fileURL) + try container.encode(id, forKey: .id) + try container.encode(suggestionText, forKey: .suggestionText) + try container.encode(position, forKey: .position) + try container.encode(range, forKey: .range) + try container.encode(createdAt, forKey: .createdAt) + try container.encode(relevantSymbols, forKey: .relevantSymbols) + try container.encode(model, forKey: .model) + + if let value = relevantFileScanningDurationInSeconds { + let rounded = Double(round(100 * value) / 100) + try container.encode(rounded, forKey: .relevantFileScanningDurationInSeconds) + } + } } diff --git a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift index 38346531..e7baee3e 100644 --- a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift @@ -93,10 +93,11 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkDirectory.path) let content: String = (try? String(contentsOf: entrypoint.fileURL, encoding: .utf8)) ?? "" - let relevantSymbols: [SymbolContent] = await retrieveRelevantSymbolsForFileContent( + let relevantSymbolsSummary: RelevantSymbolsSummary? = await retrieveRelevantSymbolsForFileContent( file: FileContent(fileURL: entrypoint.fileURL.path, content: content), workspace: workspace ) + let relevantSymbols = relevantSymbolsSummary?.symbolsAtLevel.flatMap { $0 } ?? [] let suggestionRequest = SuggestionProvider.SuggestionRequest( fileURL: entrypoint.fileURL, relativePath: entrypoint.fileURL.path.replacingOccurrences(of: benchmarkDirectory.path, with: ""), @@ -122,7 +123,8 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { suggestion: firstSuggestion, fileURL: entrypoint.fileURL, relevantSymbolsFromRequest: relevantSymbols, - model: selectedGenAIModelSubject.value + model: selectedGenAIModelSubject.value, + relevantFileScanningDurationInSeconds: relevantSymbolsSummary?.durationInSeconds ) } catch { return nil @@ -196,10 +198,11 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkDirectory.path) let content: String = (try? String(contentsOf: entrypoint.fileURL, encoding: .utf8)) ?? "" - let relevantSymbols: [SymbolContent] = await retrieveRelevantSymbolsForFileContent( + let relevantSymbolsSummary: RelevantSymbolsSummary? = await retrieveRelevantSymbolsForFileContent( file: FileContent(fileURL: entrypoint.fileURL.path, content: content), workspace: workspace ) + let relevantSymbols = relevantSymbolsSummary?.symbolsAtLevel.flatMap { $0 } ?? [] let limitedRelevantSymbols = Array(relevantSymbols.prefix(10)) let suggestionRequest = SuggestionRequest( @@ -227,7 +230,8 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { ), fileURL: entrypoint.fileURL, relevantSymbolsFromRequest: relevantSymbols, - model: selectedGenAIModelSubject.value + model: selectedGenAIModelSubject.value, + relevantFileScanningDurationInSeconds: relevantSymbolsSummary?.durationInSeconds ) } catch { print("CK \(error)") @@ -235,16 +239,24 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { } } - private func retrieveRelevantSymbolsForFileContent(file: FileContent, workspace: Workspace) async -> [SymbolContent] { + private func retrieveRelevantSymbolsForFileContent(file: FileContent, workspace: Workspace) async -> RelevantSymbolsSummary? { let multiFileContextManager = MultiFileContextManager( workspaceProvider: ManualWorkspaceProvider(workspace: workspace), parser: SwiftProgrammingLanguageSyntaxParser() ) if isMultiFileEnabledSubject.value { + let start = Date() let symbols = await multiFileContextManager.retrieveRelevantSymbolsForFileContent(file: file, ignoreWithinPaths: ["/Benchmark/"]) - return Array(symbols.values) + let end = Date() + let timeTakenInSeconds = end.timeIntervalSince(start) + return RelevantSymbolsSummary( + symbolsAtLevel: [ + Array(symbols.values) + ], + durationInSeconds: timeTakenInSeconds + ) } else { - return [] + return nil } } @@ -534,7 +546,8 @@ extension SuggestionResponse { ), createdAt: timestamp, relevantSymbols: relevantSymbolsFromRequest.map { $0.toStoredDTO() }, - model: model.id + model: model.id, + relevantFileScanningDurationInSeconds: relevantFileScanningDurationInSeconds ) } } diff --git a/Core/Sources/HostApp/Benchmark/Domain/Entity/RelevantSymbolsSummary.swift b/Core/Sources/HostApp/Benchmark/Domain/Entity/RelevantSymbolsSummary.swift new file mode 100644 index 00000000..d92340e7 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Domain/Entity/RelevantSymbolsSummary.swift @@ -0,0 +1,6 @@ +import Service + +struct RelevantSymbolsSummary { + let symbolsAtLevel: [[SymbolContent]] + let durationInSeconds: Double +} diff --git a/Core/Sources/HostApp/Benchmark/Domain/Entity/SuggestionResponse.swift b/Core/Sources/HostApp/Benchmark/Domain/Entity/SuggestionResponse.swift index 93841cd3..50ffb0ef 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Entity/SuggestionResponse.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Entity/SuggestionResponse.swift @@ -7,4 +7,5 @@ struct SuggestionResponse { let fileURL: URL let relevantSymbolsFromRequest: [SymbolContent] let model: GenAILanguageModel + let relevantFileScanningDurationInSeconds: Double? } From 52f76af78719bd12bb141d4f200c8585de61b5d9 Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Mon, 8 Sep 2025 21:17:11 +0200 Subject: [PATCH 65/66] add relevant symbol count to to output file --- .../Benchmark/Data/Entity/StoredSuggestionDTO.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/Benchmark/Data/Entity/StoredSuggestionDTO.swift b/Core/Sources/HostApp/Benchmark/Data/Entity/StoredSuggestionDTO.swift index 531c7729..e553e782 100644 --- a/Core/Sources/HostApp/Benchmark/Data/Entity/StoredSuggestionDTO.swift +++ b/Core/Sources/HostApp/Benchmark/Data/Entity/StoredSuggestionDTO.swift @@ -1,6 +1,6 @@ import Foundation -struct StoredSuggestionDTO: Codable { +struct StoredSuggestionDTO: Encodable { let fileURL: String let id: String let suggestionText: String @@ -8,6 +8,9 @@ struct StoredSuggestionDTO: Codable { let range: CursorRangeDTO let createdAt: String let relevantSymbols: [RelevantSymbolsDTO] + var relevantSymbolCount: Int { + relevantSymbols.count + } let model: String let relevantFileScanningDurationInSeconds: Double? @@ -30,6 +33,12 @@ struct StoredSuggestionDTO: Codable { let kind: String } + enum CodingKeys: String, CodingKey { + case fileURL, id, suggestionText, position, range, createdAt, relevantSymbols, model + case relevantFileScanningDurationInSeconds + case relevantSymbolCount // write, but ignore on decoding + } + func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(fileURL, forKey: .fileURL) @@ -39,6 +48,7 @@ struct StoredSuggestionDTO: Codable { try container.encode(range, forKey: .range) try container.encode(createdAt, forKey: .createdAt) try container.encode(relevantSymbols, forKey: .relevantSymbols) + try container.encode(relevantSymbolCount, forKey: .relevantSymbolCount) try container.encode(model, forKey: .model) if let value = relevantFileScanningDurationInSeconds { From c7e8e7989496c7a9fddcf0e5f3864ae4e890ae9c Mon Sep 17 00:00:00 2001 From: Christopher Knapp Date: Tue, 9 Sep 2025 01:30:34 +0200 Subject: [PATCH 66/66] add context level functionality --- .../MultiFileContextBenchmarkManager.swift | 51 ++++++++---- .../Domain/Entity/ContextLevelLimit.swift | 21 +++++ .../Entity/RelevantSymbolsSummary.swift | 6 -- .../Domain/Manager/BenchmarkManager.swift | 8 ++ .../Presentation/View/BenchmarkView.swift | 22 +++++ .../ViewModel/BenchmarkViewModel.swift | 18 ++++ .../Entity/RelevantSymbolsSummary.swift | 4 + .../MultiFileContextManager.swift | 83 ++++++++++++++++++- .../SwiftDeclarationCollector.swift | 23 +++-- 9 files changed, 208 insertions(+), 28 deletions(-) create mode 100644 Core/Sources/HostApp/Benchmark/Domain/Entity/ContextLevelLimit.swift delete mode 100644 Core/Sources/HostApp/Benchmark/Domain/Entity/RelevantSymbolsSummary.swift create mode 100644 Core/Sources/Service/MultiFileContext/Entity/RelevantSymbolsSummary.swift diff --git a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift index e7baee3e..d0214534 100644 --- a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift @@ -29,6 +29,17 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { var selectedGenAIModel: AnyPublisher { selectedGenAIModelSubject.eraseToAnyPublisher() } + + private let contextLevelLimitSubject = CurrentValueSubject(.firstLevel) + var contextLevelLimit: AnyPublisher { + contextLevelLimitSubject.eraseToAnyPublisher() + } + + private var contextFileAmountLimitSubject = CurrentValueSubject(nil) + var contextFileAmountLimit: AnyPublisher { + contextFileAmountLimitSubject.eraseToAnyPublisher() + } + private var cancellables = Set() init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { @@ -239,25 +250,23 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { } } - private func retrieveRelevantSymbolsForFileContent(file: FileContent, workspace: Workspace) async -> RelevantSymbolsSummary? { - let multiFileContextManager = MultiFileContextManager( + private func retrieveRelevantSymbolsForFileContent( + file: FileContent, + workspace: Workspace + ) async -> RelevantSymbolsSummary? { + guard isMultiFileEnabledSubject.value else { return nil } + + let manager = MultiFileContextManager( workspaceProvider: ManualWorkspaceProvider(workspace: workspace), parser: SwiftProgrammingLanguageSyntaxParser() ) - if isMultiFileEnabledSubject.value { - let start = Date() - let symbols = await multiFileContextManager.retrieveRelevantSymbolsForFileContent(file: file, ignoreWithinPaths: ["/Benchmark/"]) - let end = Date() - let timeTakenInSeconds = end.timeIntervalSince(start) - return RelevantSymbolsSummary( - symbolsAtLevel: [ - Array(symbols.values) - ], - durationInSeconds: timeTakenInSeconds - ) - } else { - return nil - } + + return await manager.expandRelevantSymbols( + from: file, + ignoreWithinPaths: ["/Benchmark/"], + maxDepth: contextLevelLimitSubject.value.indexLimit, // e.g. First=0, Second=1, …, nil=no limit + maxFiles: contextFileAmountLimitSubject.value // Int?, nil=no cap + ) } private func applyCodeSuggestion(suggestion: SuggestionBasic.CodeSuggestion, at fileURL: URL) async { @@ -470,6 +479,16 @@ class MultiFileContextBenchmarkManager: BenchmarkManager { currentStates[directory] = currentStatesInDirectory taskStatesSubject.send(currentStates) } + + @MainActor + func saveContextLevelLimit(_ limit: ContextLevelLimit) { + contextLevelLimitSubject.send(limit) + } + + @MainActor + func saveContextFileAmountLimit(_ limit: Int?) { + contextFileAmountLimitSubject.send(limit) + } } extension MetadataDTO { diff --git a/Core/Sources/HostApp/Benchmark/Domain/Entity/ContextLevelLimit.swift b/Core/Sources/HostApp/Benchmark/Domain/Entity/ContextLevelLimit.swift new file mode 100644 index 00000000..6ecaa19a --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Domain/Entity/ContextLevelLimit.swift @@ -0,0 +1,21 @@ +enum ContextLevelLimit: CaseIterable { + case firstLevel + case secondLevel + case thirdLevel + + var name: String { + switch self { + case .firstLevel: return "First Level" + case .secondLevel: return "Second Level" + case .thirdLevel: return "Third Level" + } + } + + var indexLimit: Int? { + switch self { + case .firstLevel: return 0 + case .secondLevel: return 1 + case .thirdLevel: return 2 + } + } +} diff --git a/Core/Sources/HostApp/Benchmark/Domain/Entity/RelevantSymbolsSummary.swift b/Core/Sources/HostApp/Benchmark/Domain/Entity/RelevantSymbolsSummary.swift deleted file mode 100644 index d92340e7..00000000 --- a/Core/Sources/HostApp/Benchmark/Domain/Entity/RelevantSymbolsSummary.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Service - -struct RelevantSymbolsSummary { - let symbolsAtLevel: [[SymbolContent]] - let durationInSeconds: Double -} diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift index 3bfe48ac..f04c1583 100644 --- a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -10,4 +10,12 @@ protocol BenchmarkManager { func changeGenAIModel(to newModel: GenAILanguageModel) @MainActor func updateMultiFileContextState(_ newValue: Bool) + + var contextLevelLimit: AnyPublisher { get } + @MainActor + func saveContextLevelLimit(_ limit: ContextLevelLimit) + + var contextFileAmountLimit: AnyPublisher { get } + @MainActor + func saveContextFileAmountLimit(_ limit: Int?) } diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift index 523c359f..c70f1c13 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift @@ -28,6 +28,28 @@ struct BenchmarkView: View { ConfigurationButtonView(module: module) } .padding(.vertical) + + HStack(spacing: 20) { + Spacer() + Picker("Context Level Limit: ", selection: $viewModel.contextLevelLimit) { + ForEach(ContextLevelLimit.allCases, id: \.self) { limit in + Text(limit.name).tag(limit) + } + } + .fixedSize(horizontal: true, vertical: false) + + Text("Context File Amount Limit:") + let numberFormatter: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .none + return f + }() + TextField("Optional", value: $viewModel.contextFileAmountLimit, formatter: numberFormatter) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .frame(width: 100) + } + .padding(.bottom) + ForEach(viewModel.benchmarkDirectories, id: \.self) { directory in BenchmarkDirectoryEntryView( directory: directory, diff --git a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift index 618c6468..f395d7d0 100644 --- a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift +++ b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift @@ -16,6 +16,18 @@ class BenchmarkViewModel: ObservableObject { Task { await benchmarkManager.changeGenAIModel(to: selectedLanguageModel) } } } + @Published var contextLevelLimit: ContextLevelLimit = .firstLevel { + didSet { + guard oldValue != contextLevelLimit else { return } + Task { await benchmarkManager.saveContextLevelLimit(contextLevelLimit) } + } + } + @Published var contextFileAmountLimit: Int? = nil { + didSet { + guard oldValue != contextFileAmountLimit else { return } + Task { await benchmarkManager.saveContextFileAmountLimit(contextFileAmountLimit) } + } + } private let benchmarkSettingsRepository: BenchmarkSettingsRepository private let benchmarkManager: BenchmarkManager @@ -35,6 +47,12 @@ class BenchmarkViewModel: ObservableObject { benchmarkManager.selectedGenAIModel .assign(to: \.selectedLanguageModel, on: self) .store(in: &cancellables) + benchmarkManager.contextLevelLimit + .assign(to: \.contextLevelLimit, on: self) + .store(in: &cancellables) + benchmarkManager.contextFileAmountLimit + .assign(to: \.contextFileAmountLimit, on: self) + .store(in: &cancellables) } func loadBenchmarkDirectories() { diff --git a/Core/Sources/Service/MultiFileContext/Entity/RelevantSymbolsSummary.swift b/Core/Sources/Service/MultiFileContext/Entity/RelevantSymbolsSummary.swift new file mode 100644 index 00000000..3bad56e0 --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/Entity/RelevantSymbolsSummary.swift @@ -0,0 +1,4 @@ +public struct RelevantSymbolsSummary { + public let symbolsAtLevel: [[SymbolContent]] + public let durationInSeconds: Double +} diff --git a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift index a79c019d..0caf99cc 100644 --- a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift +++ b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift @@ -62,6 +62,87 @@ public class MultiFileContextManager { return result } + private func readFile(_ urlString: String) -> FileContent? { + guard let url = URL(string: urlString) else { return nil } + guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil } + return FileContent(fileURL: urlString, content: content) + } + + public func expandRelevantSymbols( + from root: FileContent, + ignoreWithinPaths: [String] = [], + maxDepth: Int? = nil, + maxFiles: Int? = nil + ) async -> RelevantSymbolsSummary { + @inline(__always) + func isIgnored(_ url: String) -> Bool { + ignoreWithinPaths.contains { url.contains($0) } + } + + let start = Date() + + let level0 = await retrieveRelevantSymbolsForFileContent(file: root, ignoreWithinPaths: ignoreWithinPaths) + var level0Unique: [SymbolContent] = [] + var includedFiles = Set() + for s in level0.values { + if includedFiles.insert(s.fileURL).inserted { + level0Unique.append(s) + } + } + var levels: [[SymbolContent]] = [level0Unique] + + var visited = Set([root.fileURL]) + var depth = 0 + var remainingFiles = maxFiles + + while maxDepth == nil || depth < maxDepth! { + let prev = levels.last ?? [] + var nextURLs = Set(prev.map(\.fileURL)) + nextURLs.subtract(visited) + nextURLs = nextURLs.filter { !isIgnored($0) } + + if nextURLs.isEmpty { break } + + if let remaining = remainingFiles, nextURLs.count > remaining { + nextURLs = Set(nextURLs.prefix(remaining)) + } + + var rawNextLevel: [SymbolContent] = [] + for url in nextURLs { + visited.insert(url) + guard let fc = readFile(url) else { continue } + let dict = await retrieveRelevantSymbolsForFileContent(file: fc, ignoreWithinPaths: ignoreWithinPaths) + rawNextLevel.append(contentsOf: dict.values) + } + + var nextLevel: [SymbolContent] = [] + var seenThisLevel = Set() + for s in rawNextLevel { + guard !includedFiles.contains(s.fileURL) else { continue } + if seenThisLevel.insert(s.fileURL).inserted { + nextLevel.append(s) + } + } + + if nextLevel.isEmpty { break } + + includedFiles.formUnion(nextLevel.map(\.fileURL)) + + levels.append(nextLevel) + depth += 1 + + if remainingFiles != nil { + remainingFiles! -= nextURLs.count + if remainingFiles! <= 0 { break } + } + } + + return RelevantSymbolsSummary( + symbolsAtLevel: levels, + durationInSeconds: Date().timeIntervalSince(start) + ) + } + public func retrieveRelevantSymbolsForFileContent( file: FileContent, ignoreWithinPaths: [String] = [] @@ -171,7 +252,7 @@ extension String { let before = (range.lowerBound == startIndex) ? nil : self[index(before: range.lowerBound)] let after = (range.upperBound == endIndex) ? nil : self[range.upperBound] - let boundaryBefore = before.map { !isIdentChar($0) } ?? true + let boundaryBefore = before.map { !isIdentChar($0) && $0 != "." } ?? true let boundaryAfter = after.map { !isIdentChar($0) } ?? true if boundaryBefore && boundaryAfter { diff --git a/Core/Sources/Service/MultiFileContext/SwiftDeclarationCollector.swift b/Core/Sources/Service/MultiFileContext/SwiftDeclarationCollector.swift index 3b8d9c29..922c864c 100644 --- a/Core/Sources/Service/MultiFileContext/SwiftDeclarationCollector.swift +++ b/Core/Sources/Service/MultiFileContext/SwiftDeclarationCollector.swift @@ -10,43 +10,45 @@ class SwiftDeclarationCollector: SyntaxVisitor { self.sourceText = sourceText super.init(viewMode: .all) } - -// override func visit(_ node: ImportDeclSyntax) -> SyntaxVisitorContinueKind { -// recordSymbol(name: node.name.text, kind: "import", node: node) -// return .skipChildren -// } override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + guard !isPrivateOrFilePrivate(node.modifiers) else { return .skipChildren } recordSymbol(name: node.name.text, kind: ClassificationKeywords.classWord, node: node) return .skipChildren } override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + guard !isPrivateOrFilePrivate(node.modifiers) else { return .skipChildren } recordSymbol(name: node.name.text, kind: ClassificationKeywords.structWord, node: node) return .skipChildren } override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { + guard !isPrivateOrFilePrivate(node.modifiers) else { return .skipChildren } recordSymbol(name: node.name.text, kind: ClassificationKeywords.enumWord, node: node) return .skipChildren } override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { + guard !isPrivateOrFilePrivate(node.modifiers) else { return .skipChildren } recordSymbol(name: node.name.text, kind: ClassificationKeywords.protocolWord, node: node) return .skipChildren } override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { + guard !isPrivateOrFilePrivate(node.modifiers) else { return .skipChildren } recordSymbol(name: node.name.text, kind: ClassificationKeywords.actorWord, node: node) return .skipChildren } override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { + guard !isPrivateOrFilePrivate(node.modifiers) else { return .skipChildren } recordSymbol(name: node.name.text, kind: ClassificationKeywords.funcWord, node: node) return .skipChildren } override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { + guard !isPrivateOrFilePrivate(node.modifiers) else { return .skipChildren } guard let binding = node.bindings.first, let pattern = binding.pattern.as(IdentifierPatternSyntax.self), let keyword: ClassificationKeywords = ClassificationKeywords(rawValue: node.bindingSpecifier.text) else { @@ -58,11 +60,14 @@ class SwiftDeclarationCollector: SyntaxVisitor { override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind { let name = node.extendedType.trimmedDescription + guard !isPrivateOrFilePrivate(node.modifiers) && name != "View" else { return .skipChildren } + recordSymbol(name: name, kind: ClassificationKeywords.extensionWord, node: node) return .skipChildren } override func visit(_ node: TypeAliasDeclSyntax) -> SyntaxVisitorContinueKind { + guard !isPrivateOrFilePrivate(node.modifiers) else { return .skipChildren } recordSymbol(name: node.name.text, kind: ClassificationKeywords.typealiasWord, node: node) return .skipChildren } @@ -87,4 +92,12 @@ class SwiftDeclarationCollector: SyntaxVisitor { ) symbols.append(symbol) } + + private func isPrivateOrFilePrivate(_ modifiers: DeclModifierListSyntax?) -> Bool { + guard let modifiers else { return false } + return modifiers.contains { modifier in + let text = modifier.name.text + return text == "private" || text == "fileprivate" + } + } }