From e5dda8142295430a08e0e1a160840b12dd17e83a Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 27 Sep 2026 22:16:47 +0800 Subject: [PATCH 1/3] Fix throttle demand buffering and emission scheduling --- .../Publishers/Publishers.Throttle.swift | 126 ++++--- .../PublisherTests/ThrottleTests.swift | 351 +++++++++++++++++- 2 files changed, 423 insertions(+), 54 deletions(-) diff --git a/Sources/OpenCombine/Publishers/Publishers.Throttle.swift b/Sources/OpenCombine/Publishers/Publishers.Throttle.swift index e0f8ec245..ac5315af7 100644 --- a/Sources/OpenCombine/Publishers/Publishers.Throttle.swift +++ b/Sources/OpenCombine/Publishers/Publishers.Throttle.swift @@ -144,15 +144,14 @@ extension Publishers.Throttle { private var state: State private let downstreamLock = UnfairRecursiveLock.allocate() - private var lastEmissionTime: Context.SchedulerTimeType? + private var nextEmissionTime: Context.SchedulerTimeType + private var hasScheduledOutput = false private var pendingInput: Input? private var pendingCompletion: Subscribers.Completion? private var demand: Subscribers.Demand = .none - private var lastTime: Context.SchedulerTimeType - init(interval: Context.SchedulerTimeType.Stride, scheduler: Context, latest: Bool, @@ -162,7 +161,7 @@ extension Publishers.Throttle { self.scheduler = scheduler self.latest = latest - self.lastTime = scheduler.now + self.nextEmissionTime = scheduler.now } deinit { @@ -177,16 +176,16 @@ extension Publishers.Throttle { subscription.cancel() return } - self.lastTime = scheduler.now + self.nextEmissionTime = scheduler.now state = .subscribed(subscription, downstream) lock.unlock() - subscription.request(.unlimited) - downstreamLock.lock() downstream.receive(subscription: self) downstreamLock.unlock() + + subscription.request(.unlimited) } func receive(_ input: Input) -> Subscribers.Demand { @@ -196,42 +195,36 @@ extension Publishers.Throttle { return .none } - let lastTime = scheduler.now - self.lastTime = lastTime + let now = scheduler.now + let emitImmediately = now >= nextEmissionTime - guard demand > .none else { - lock.unlock() - return .none - } - - let hasScheduledOutput = (pendingInput != nil || pendingCompletion != nil) - - if hasScheduledOutput && latest { + if latest { pendingInput = input - lock.unlock() - } else if !hasScheduledOutput { - let minimumEmissionTime = - lastEmissionTime.map { $0.advanced(by: interval) } - - let emissionTime = - minimumEmissionTime.map { Swift.max(lastTime, $0) } ?? lastTime - - demand -= 1 - + } else if emitImmediately { + // Each new window selects its first input, even without demand. + nextEmissionTime = now.advanced(by: interval) + pendingInput = input + } else if pendingInput == nil { pendingInput = input + } + + guard !hasScheduledOutput, demand > .none else { lock.unlock() + return .none + } - let action: () -> Void = { [weak self] in - self?.scheduledEmission() - } + hasScheduledOutput = true + let emissionTime = nextEmissionTime + lock.unlock() - if emissionTime == lastTime { - scheduler.schedule(action) - } else { - scheduler.schedule(after: emissionTime, action) + if emitImmediately { + scheduler.schedule { + self.scheduledEmission() } } else { - lock.unlock() + scheduler.schedule(after: emissionTime) { + self.scheduledEmission() + } } return .none @@ -240,27 +233,25 @@ extension Publishers.Throttle { func receive(completion: Subscribers.Completion) { lock.lock() guard case let .subscribed(subscription, downstream) = state else { + if !hasScheduledOutput { + state = .terminal + } lock.unlock() return } - let lastTime = scheduler.now - self.lastTime = lastTime + nextEmissionTime = scheduler.now state = .pendingTerminal(subscription, downstream) + pendingCompletion = completion - let hasScheduledOutput = (pendingInput != nil || pendingCompletion != nil) - - if hasScheduledOutput && pendingCompletion == nil { - pendingCompletion = completion + if hasScheduledOutput { lock.unlock() - } else if !hasScheduledOutput { - pendingCompletion = completion + } else { + hasScheduledOutput = true lock.unlock() - scheduler.schedule { [weak self] in - self?.scheduledEmission() + scheduler.schedule { + self.scheduledEmission() } - } else { - lock.unlock() } } @@ -278,15 +269,27 @@ extension Publishers.Throttle { downstream = foundDownstream } - if self.pendingInput != nil && self.pendingCompletion == nil { - lastEmissionTime = scheduler.now + guard hasScheduledOutput else { + lock.unlock() + return + } + + let pendingInput: Input? + if self.pendingInput != nil && demand > .none { + // Scheduled input can change until demand is consumed here. + demand -= 1 + pendingInput = self.pendingInput.take() + } else { + pendingInput = nil } - let pendingInput = self.pendingInput.take() + hasScheduledOutput = false let pendingCompletion = self.pendingCompletion.take() if pendingCompletion != nil { state = .terminal + } else { + nextEmissionTime = scheduler.now.advanced(by: interval) } lock.unlock() @@ -305,21 +308,37 @@ extension Publishers.Throttle { } downstreamLock.unlock() - guard newDemand > 0 else { return } + guard newDemand > 0, pendingCompletion == nil else { return } self.lock.lock() demand += newDemand self.lock.unlock() } func request(_ demand: Subscribers.Demand) { - guard demand > 0 else { return } lock.lock() guard case .subscribed = state else { lock.unlock() return } self.demand += demand + guard pendingInput != nil, !hasScheduledOutput else { + lock.unlock() + return + } + hasScheduledOutput = true + let now = scheduler.now + let emissionTime = nextEmissionTime lock.unlock() + + if now >= emissionTime { + scheduler.schedule { + self.scheduledEmission() + } + } else { + scheduler.schedule(after: emissionTime) { + self.scheduledEmission() + } + } } func cancel() { @@ -330,6 +349,9 @@ extension Publishers.Throttle { case let .subscribed(existingSubscription, _), let .pendingTerminal(existingSubscription, _): subscription = existingSubscription + pendingInput = nil + pendingCompletion = nil + demand = .none case .awaitingSubscription, .terminal: subscription = nil } diff --git a/Tests/OpenCombineTests/PublisherTests/ThrottleTests.swift b/Tests/OpenCombineTests/PublisherTests/ThrottleTests.swift index bbbccb817..089adac79 100644 --- a/Tests/OpenCombineTests/PublisherTests/ThrottleTests.swift +++ b/Tests/OpenCombineTests/PublisherTests/ThrottleTests.swift @@ -519,6 +519,353 @@ final class ThrottleTests: XCTestCase { XCTAssertEqual(subscription.history, [.requested(.unlimited)]) } + func testInputWithoutDemandIsHeld() throws { + for latest in [false, true] { + let scheduler = VirtualTimeScheduler() + let helper = OperatorTestHelper(publisherType: CustomPublisher.self, + initialDemand: nil, + receiveValueDemand: .none) { + $0.throttle(for: .seconds(1), scheduler: scheduler, latest: latest) + } + let subscription = try XCTUnwrap(helper.downstreamSubscription) + + XCTAssertEqual(helper.publisher.send(1), .none) + XCTAssertEqual(helper.publisher.send(2), .none) + XCTAssertEqual(helper.publisher.send(3), .none) + XCTAssertEqual(scheduler.scheduledDates, []) + + subscription.request(.max(1)) + scheduler.executeScheduledActions() + let first = latest ? 3 : 1 + XCTAssertEqual(Array(helper.tracking.inputs), [first]) + + XCTAssertEqual(helper.publisher.send(4), .none) + XCTAssertEqual(helper.publisher.send(5), .none) + XCTAssertEqual(scheduler.scheduledDates, []) + + subscription.request(.max(1)) + scheduler.executeScheduledActions() + XCTAssertEqual(Array(helper.tracking.inputs), [first, latest ? 5 : 4]) + XCTAssertEqual(helper.subscription.history, [.requested(.unlimited)]) + } + } + + func testFiniteDemandCoalescesScheduledInput() throws { + for latest in [false, true] { + for demand in [Subscribers.Demand.max(1), .max(2)] { + let scheduler = VirtualTimeScheduler() + let helper = OperatorTestHelper(publisherType: CustomPublisher.self, + initialDemand: demand, + receiveValueDemand: .none) { + $0.throttle(for: .seconds(1), scheduler: scheduler, latest: latest) + } + + XCTAssertEqual(helper.publisher.send(1), .none) + XCTAssertEqual(helper.publisher.send(2), .none) + XCTAssertEqual(helper.publisher.send(3), .none) + XCTAssertEqual(scheduler.scheduledDates, [.seconds(0)]) + scheduler.executeScheduledActions() + + let expected = [latest ? 3 : 1] + XCTAssertEqual(Array(helper.tracking.inputs), expected) + try XCTUnwrap(helper.downstreamSubscription).request(.max(1)) + XCTAssertEqual(scheduler.scheduledDates, []) + scheduler.executeScheduledActions() + XCTAssertEqual(Array(helper.tracking.inputs), expected) + } + } + } + + func testRequestWhileOutputIsScheduledDoesNotKeepStaleInput() throws { + for latest in [false, true] { + let scheduler = VirtualTimeScheduler() + let helper = OperatorTestHelper(publisherType: CustomPublisher.self, + initialDemand: .max(1), + receiveValueDemand: .none) { + $0.throttle(for: .seconds(1), scheduler: scheduler, latest: latest) + } + let subscription = try XCTUnwrap(helper.downstreamSubscription) + + XCTAssertEqual(helper.publisher.send(1), .none) + XCTAssertEqual(helper.publisher.send(2), .none) + subscription.request(.max(1)) + XCTAssertEqual(scheduler.scheduledDates, [.seconds(0)]) + scheduler.executeScheduledActions() + let first = latest ? 2 : 1 + XCTAssertEqual(Array(helper.tracking.inputs), [first]) + + XCTAssertEqual(helper.publisher.send(3), .none) + scheduler.executeScheduledActions() + XCTAssertEqual(Array(helper.tracking.inputs), [first, 3]) + + subscription.request(.max(1)) + XCTAssertEqual(scheduler.scheduledDates, []) + scheduler.executeScheduledActions() + XCTAssertEqual(Array(helper.tracking.inputs), [first, 3]) + } + } + + func testHeldInputRespectsWindowWhenDemandArrives() throws { + for latest in [false, true] { + let scheduler = VirtualTimeScheduler() + let helper = OperatorTestHelper(publisherType: CustomPublisher.self, + initialDemand: nil, + receiveValueDemand: .none) { + $0.throttle(for: .seconds(1), scheduler: scheduler, latest: latest) + } + + XCTAssertEqual(helper.publisher.send(1), .none) + try XCTUnwrap(helper.downstreamSubscription).request(.max(1)) + XCTAssertEqual(scheduler.scheduledDates, [.seconds(latest ? 0 : 1)]) + + scheduler.executeScheduledActions(until: .milliseconds(999)) + XCTAssertEqual(Array(helper.tracking.inputs), latest ? [1] : []) + scheduler.executeScheduledActions(until: .seconds(1)) + XCTAssertEqual(Array(helper.tracking.inputs), [1]) + } + } + + func testInputAfterDemandReplacesScheduledInput() throws { + for latest in [false, true] { + let scheduler = VirtualTimeScheduler() + let helper = OperatorTestHelper(publisherType: CustomPublisher.self, + initialDemand: nil, + receiveValueDemand: .none) { + $0.throttle(for: .seconds(1), scheduler: scheduler, latest: latest) + } + let subscription = try XCTUnwrap(helper.downstreamSubscription) + + XCTAssertEqual(helper.publisher.send(1), .none) + XCTAssertEqual(helper.publisher.send(2), .none) + subscription.request(.max(1)) + XCTAssertEqual(helper.publisher.send(3), .none) + scheduler.executeScheduledActions() + let expected = [latest ? 3 : 1] + XCTAssertEqual(Array(helper.tracking.inputs), expected) + + subscription.request(.max(1)) + XCTAssertEqual(scheduler.scheduledDates, []) + scheduler.executeScheduledActions() + XCTAssertEqual(Array(helper.tracking.inputs), expected) + } + } + + func testInputWithoutDemandStartsNewWindow() throws { + for latest in [false, true] { + let scheduler = VirtualTimeScheduler() + let helper = OperatorTestHelper(publisherType: CustomPublisher.self, + initialDemand: nil, + receiveValueDemand: .none) { + $0.throttle(for: .seconds(1), scheduler: scheduler, latest: latest) + } + + XCTAssertEqual(helper.publisher.send(1), .none) + scheduler.rewind(to: .milliseconds(200)) + XCTAssertEqual(helper.publisher.send(2), .none) + scheduler.rewind(to: .milliseconds(1200)) + XCTAssertEqual(helper.publisher.send(3), .none) + XCTAssertEqual(scheduler.scheduledDates, []) + + try XCTUnwrap(helper.downstreamSubscription).request(.max(1)) + XCTAssertEqual(scheduler.scheduledDates, + [.milliseconds(latest ? 1200 : 2200)]) + scheduler.executeScheduledActions() + XCTAssertEqual(Array(helper.tracking.inputs), [3]) + } + } + + func testReturnedDemandDoesNotScheduleReentrantInput() throws { + for latest in [false, true] { + let scheduler = VirtualTimeScheduler() + let helper = OperatorTestHelper(publisherType: CustomPublisher.self, + initialDemand: .max(1), + receiveValueDemand: .max(1)) { + $0.throttle(for: .seconds(1), scheduler: scheduler, latest: latest) + } + helper.tracking.onValue = { _ in + XCTAssertEqual(helper.publisher.send(3), .none) + } + + XCTAssertEqual(helper.publisher.send(1), .none) + XCTAssertEqual(helper.publisher.send(2), .none) + scheduler.executeScheduledActions() + helper.tracking.onValue = nil + let first = latest ? 2 : 1 + XCTAssertEqual(Array(helper.tracking.inputs), [first]) + XCTAssertEqual(scheduler.scheduledDates, []) + + try XCTUnwrap(helper.downstreamSubscription).request(.max(1)) + scheduler.executeScheduledActions() + XCTAssertEqual(Array(helper.tracking.inputs), [first, 3]) + } + } + + func testCompletionWithBufferedInput() throws { + for latest in [false, true] { + for demand in [Subscribers.Demand.none, .max(1)] { + for completion in [Subscribers.Completion.finished, + .failure(.oops)] { + let scheduler = VirtualTimeScheduler() + let helper = OperatorTestHelper(publisherType: CustomPublisher.self, + initialDemand: demand, + receiveValueDemand: .max(1)) { + $0.throttle(for: .seconds(1), + scheduler: scheduler, + latest: latest) + } + + XCTAssertEqual(helper.publisher.send(1), .none) + XCTAssertEqual(helper.publisher.send(2), .none) + helper.publisher.send(completion: completion) + try XCTUnwrap(helper.downstreamSubscription).request(.max(1)) + scheduler.executeScheduledActions() + + let expected = demand == .none ? [] : [latest ? 2 : 1] + XCTAssertEqual(Array(helper.tracking.inputs), expected) + XCTAssertEqual(Array(helper.tracking.completions), [completion]) + XCTAssertEqual(helper.publisher.send(3), .none) + XCTAssertEqual(scheduler.scheduledDates, []) + } + } + } + } + + func testSubscriptionIsDeliveredBeforeUpstreamRequest() { + for latest in [false, true] { + let scheduler = VirtualTimeScheduler() + let subscription = CustomSubscription() + let publisher = CustomPublisher(subscription: subscription) + let tracking = TrackingSubscriber( + receiveSubscription: { $0.request(.max(1)) } + ) + subscription.onRequest = { demand in + XCTAssertEqual(demand, .unlimited) + XCTAssertEqual(tracking.history, [.subscription("Throttle")]) + XCTAssertEqual(publisher.send(1), .none) + XCTAssertEqual(publisher.send(2), .none) + } + + publisher.throttle(for: .seconds(1), scheduler: scheduler, latest: latest) + .subscribe(tracking) + subscription.onRequest = nil + XCTAssertEqual(scheduler.scheduledDates, [.seconds(0)]) + scheduler.executeScheduledActions() + XCTAssertEqual(Array(tracking.inputs), [latest ? 2 : 1]) + tracking.cancel() + } + } + + func testOptionalInputWithoutDemand() throws { + for latest in [false, true] { + let scheduler = VirtualTimeScheduler() + let helper = OperatorTestHelper( + publisherType: CustomPublisherBase.self, + initialDemand: nil, + receiveValueDemand: .none + ) { + $0.throttle(for: .seconds(1), scheduler: scheduler, latest: latest) + } + let subscription = try XCTUnwrap(helper.downstreamSubscription) + + XCTAssertEqual(helper.publisher.send(nil), .none) + subscription.request(.max(1)) + scheduler.executeScheduledActions() + XCTAssertEqual(Array(helper.tracking.inputs), [nil]) + + XCTAssertEqual(helper.publisher.send(1), .none) + XCTAssertEqual(helper.publisher.send(nil), .none) + subscription.request(.max(1)) + scheduler.executeScheduledActions() + XCTAssertEqual(Array(helper.tracking.inputs), [nil, latest ? nil : 1]) + } + } + + func testCancelReleasesBufferedInput() throws { + final class Input {} + + for demand in [Subscribers.Demand.none, .max(1)] { + let scheduler = VirtualTimeScheduler() + let helper = OperatorTestHelper( + publisherType: CustomPublisherBase.self, + initialDemand: demand, + receiveValueDemand: .none + ) { + $0.throttle(for: .seconds(1), scheduler: scheduler, latest: true) + } + let subscription = try XCTUnwrap(helper.downstreamSubscription) + var input: Input? = Input() + weak var weakInput = input + XCTAssertEqual(helper.publisher.send(try XCTUnwrap(input)), .none) + input = nil + XCTAssertNotNil(weakInput) + + subscription.cancel() + XCTAssertNil(weakInput) + subscription.request(.max(1)) + scheduler.executeScheduledActions() + XCTAssertTrue(helper.tracking.inputs.isEmpty) + XCTAssertEqual(helper.subscription.history, [.requested(.unlimited), + .cancelled]) + } + } + + func testZeroDemandRequestDoesNotConsumeInput() throws { + for latest in [false, true] { + let scheduler = VirtualTimeScheduler() + let helper = OperatorTestHelper(publisherType: CustomPublisher.self, + initialDemand: nil, + receiveValueDemand: .none) { + $0.throttle(for: .seconds(1), scheduler: scheduler, latest: latest) + } + let subscription = try XCTUnwrap(helper.downstreamSubscription) + + XCTAssertEqual(helper.publisher.send(1), .none) + XCTAssertEqual(helper.publisher.send(2), .none) + subscription.request(.none) + XCTAssertEqual(scheduler.scheduledDates, [.seconds(latest ? 0 : 1)]) + scheduler.executeScheduledActions() + XCTAssertTrue(helper.tracking.inputs.isEmpty) + + subscription.request(.max(1)) + XCTAssertEqual(scheduler.scheduledDates, [.seconds(latest ? 1 : 2)]) + scheduler.executeScheduledActions() + XCTAssertEqual(Array(helper.tracking.inputs), [latest ? 2 : 1]) + } + } + + func testScheduledOutputRetainsSubscription() { + for sendCompletion in [false, true] { + let scheduler = VirtualTimeScheduler() + var value: Int? + var completion: Subscribers.Completion? + var subscriberReleased = false + do { + let publisher = CustomPublisher(subscription: CustomSubscription()) + let tracking = TrackingSubscriber( + receiveSubscription: { $0.request(.max(1)) }, + receiveValue: { value = $0; return .none }, + receiveCompletion: { completion = $0 }, + onDeinit: { subscriberReleased = true } + ) + publisher.throttle(for: .seconds(1), scheduler: scheduler, latest: true) + .subscribe(tracking) + if sendCompletion { + publisher.send(completion: .finished) + } else { + XCTAssertEqual(publisher.send(42), .none) + } + tracking.clearHistory() + publisher.cancel() + } + + XCTAssertFalse(subscriberReleased) + scheduler.executeScheduledActions() + XCTAssertEqual(value, sendCompletion ? nil : 42) + XCTAssertEqual(completion, sendCompletion ? .finished : nil) + XCTAssertTrue(subscriberReleased) + } + } + func testCancelWhileReceivingInput() throws { let scheduler = VirtualTimeScheduler() let subscription = CustomSubscription() @@ -704,7 +1051,7 @@ final class ThrottleTests: XCTestCase { helper.publisher.send(completion: .finished) } - func testWeakCaptureWhenSchedulingValue() { + func testCancelReleasesSubscriberWhenValueIsScheduled() { let scheduler = VirtualTimeScheduler() var value: Int? var subscriberReleased = false @@ -730,7 +1077,7 @@ final class ThrottleTests: XCTestCase { XCTAssertNil(value) } - func testWeakCaptureWhenSchedulingCompletion() { + func testCancelReleasesSubscriberWhenCompletionIsScheduled() { let scheduler = VirtualTimeScheduler() var completion: Subscribers.Completion? var subscriberReleased = false From 5eee9feeb7c312a654350749bbb5cc147f70e4b2 Mon Sep 17 00:00:00 2001 From: Kyle Date: Sun, 27 Sep 2026 22:31:10 +0800 Subject: [PATCH 2/3] Fix throttle test warning and lint baseline paths --- .../PublisherTests/ThrottleTests.swift | 23 +++++++++++++------ utils/lint.sh | 6 ++--- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/Tests/OpenCombineTests/PublisherTests/ThrottleTests.swift b/Tests/OpenCombineTests/PublisherTests/ThrottleTests.swift index 089adac79..84022bc9a 100644 --- a/Tests/OpenCombineTests/PublisherTests/ThrottleTests.swift +++ b/Tests/OpenCombineTests/PublisherTests/ThrottleTests.swift @@ -781,7 +781,15 @@ final class ThrottleTests: XCTestCase { } func testCancelReleasesBufferedInput() throws { - final class Input {} + final class Input { + let onDeinit: () -> Void + + init(onDeinit: @escaping () -> Void) { + self.onDeinit = onDeinit + } + + deinit { onDeinit() } + } for demand in [Subscribers.Demand.none, .max(1)] { let scheduler = VirtualTimeScheduler() @@ -793,14 +801,15 @@ final class ThrottleTests: XCTestCase { $0.throttle(for: .seconds(1), scheduler: scheduler, latest: true) } let subscription = try XCTUnwrap(helper.downstreamSubscription) - var input: Input? = Input() - weak var weakInput = input - XCTAssertEqual(helper.publisher.send(try XCTUnwrap(input)), .none) - input = nil - XCTAssertNotNil(weakInput) + var inputReleased = false + do { + let input = Input(onDeinit: { inputReleased = true }) + XCTAssertEqual(helper.publisher.send(input), .none) + } + XCTAssertFalse(inputReleased) subscription.cancel() - XCTAssertNil(weakInput) + XCTAssertTrue(inputReleased) subscription.request(.max(1)) scheduler.executeScheduledActions() XCTAssertTrue(helper.tracking.inputs.isEmpty) diff --git a/utils/lint.sh b/utils/lint.sh index d65abcbe4..c082d1497 100644 --- a/utils/lint.sh +++ b/utils/lint.sh @@ -24,6 +24,7 @@ python3 - "$baseline_dir" "$repo_root" <<'PY' import json import sys from pathlib import Path +from urllib.parse import unquote, urlsplit baseline_dir = Path(sys.argv[1]).resolve() repo_root = Path(sys.argv[2]).resolve() @@ -31,9 +32,8 @@ baseline_path = baseline_dir / "baseline.json" violations = json.loads(baseline_path.read_text()) for violation in violations: location = violation["violation"]["location"] - location["file"] = location["file"].replace( - baseline_dir.as_uri() + "/", repo_root.as_uri() + "/", 1 - ) + file_path = Path(unquote(urlsplit(location["file"]).path)).resolve() + location["file"] = (repo_root / file_path.relative_to(baseline_dir)).as_uri() baseline_path.write_text(json.dumps(violations)) PY From aad6ce789f8d8661e6a38c810ef4787fc56f0b4b Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 28 Sep 2026 00:54:42 +0800 Subject: [PATCH 3/3] Handle relative paths in SwiftLint baselines --- utils/lint.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/utils/lint.sh b/utils/lint.sh index c082d1497..082db3f4f 100644 --- a/utils/lint.sh +++ b/utils/lint.sh @@ -32,8 +32,20 @@ baseline_path = baseline_dir / "baseline.json" violations = json.loads(baseline_path.read_text()) for violation in violations: location = violation["violation"]["location"] - file_path = Path(unquote(urlsplit(location["file"]).path)).resolve() - location["file"] = (repo_root / file_path.relative_to(baseline_dir)).as_uri() + file = location["file"] + file_url = urlsplit(file) + if file_url.scheme == "file": + file_path = Path(unquote(file_url.path)) + else: + file_path = baseline_dir / file + if not file_path.is_file(): + # SwiftLint can omit the leading slash when temporary paths use aliases. + file_path = Path("/") / file + relative_file = file_path.resolve().relative_to(baseline_dir) + location["file"] = ( + (repo_root / relative_file).as_uri() + if file_url.scheme == "file" else str(relative_file) + ) baseline_path.write_text(json.dumps(violations)) PY