Skip to content

Commit b07cbe8

Browse files
macmadeclaude
andcommitted
feat: Report formatter failures via the Xcode extension error banner
Previously every failure path called completionHandler(nil), so a failed format silently did nothing on save. Surface the failures a user can act on while keeping genuine no-ops silent. Add FormatterError and FormatterOutcome.classify in the Shared layer: a non-zero exit throws .failed carrying the formatter's stderr, a missing executable throws .executableNotFound, and undecodable/unchanged/blank output stays an .unchanged no-op. Make SourceEditorCommand.format throw and route errors to completionHandler so Xcode shows its error banner; keep the buffer mutation guarded behind the .formatted outcome. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6833a68 commit b07cbe8

6 files changed

Lines changed: 350 additions & 16 deletions

File tree

EditorExtension/SourceEditorCommand.swift

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -41,42 +41,56 @@ public class SourceEditorCommand: NSObject, XCSourceEditorCommand
4141

4242
configuration.withConfigurations
4343
{
44-
if uti == .swiftSource, let config = $0.swiftFormat?.path
44+
do
4545
{
46-
self.format( buffer: invocation.buffer, executable: "swiftformat", arguments: [ "--config", config ] )
46+
if uti == .swiftSource, let config = $0.swiftFormat?.path
47+
{
48+
try self.format( buffer: invocation.buffer, executable: "swiftformat", arguments: [ "--config", config ] )
49+
}
50+
else if let language = UncrustifyLanguage.argument( for: uti ), let config = $0.uncrustify?.path
51+
{
52+
try self.format( buffer: invocation.buffer, executable: "uncrustify", arguments: [ "-c", config, "-l", language, "-q" ] )
53+
}
54+
55+
$0.finished()
56+
completionHandler( nil )
4757
}
48-
else if let language = UncrustifyLanguage.argument( for: uti ), let config = $0.uncrustify?.path
58+
catch
4959
{
50-
self.format( buffer: invocation.buffer, executable: "uncrustify", arguments: [ "-c", config, "-l", language, "-q" ] )
60+
$0.finished()
61+
completionHandler( error )
5162
}
52-
53-
$0.finished()
54-
completionHandler( nil )
5563
}
5664
error:
5765
{
5866
completionHandler( nil )
5967
}
6068
}
6169

62-
private func format( buffer: XCSourceTextBuffer, executable: String, arguments: [ String ] )
70+
private func format( buffer: XCSourceTextBuffer, executable: String, arguments: [ String ] ) throws
6371
{
64-
guard let data = buffer.completeBuffer.data( using: .utf8 ),
65-
let task = Task.run( name: executable, arguments: arguments, input: data ),
66-
let status = task.terminationStatus,
67-
let out = String( data: task.standardOutput, encoding: .utf8 ),
68-
status == 0
72+
guard let data = buffer.completeBuffer.data( using: .utf8 )
6973
else
7074
{
7175
return
7276
}
7377

74-
if buffer.completeBuffer == out
78+
guard let task = Task.run( name: executable, arguments: arguments, input: data )
79+
else
7580
{
76-
return
81+
throw FormatterError.executableNotFound( executable )
82+
}
83+
84+
guard let status = task.terminationStatus
85+
else
86+
{
87+
throw FormatterError.failed( executable: executable, status: -1, message: "" )
7788
}
7889

79-
if out.trimmingCharacters( in: .whitespacesAndNewlines ).isEmpty
90+
let outcome = try FormatterOutcome.classify( executable: executable, input: buffer.completeBuffer, status: status, standardOutput: task.standardOutput, standardError: task.standardError )
91+
92+
guard case .formatted( let out ) = outcome
93+
else
8094
{
8195
return
8296
}

Shared/FormatterError.swift

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*******************************************************************************
2+
* The MIT License (MIT)
3+
*
4+
* Copyright (c) 2022, Jean-David Gadina - www.xs-labs.com
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the Software), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
******************************************************************************/
24+
25+
import Foundation
26+
27+
/// A user-actionable formatting failure, surfaced through Xcode's extension
28+
/// error banner.
29+
public enum FormatterError: LocalizedError, Equatable
30+
{
31+
/// The formatter executable could not be found / launched.
32+
case executableNotFound( String )
33+
34+
/// The formatter ran but exited with a non-zero status. `message` carries
35+
/// its captured standard-error text.
36+
case failed( executable: String, status: Int32, message: String )
37+
38+
public var errorDescription: String?
39+
{
40+
switch self
41+
{
42+
case .executableNotFound( let name ):
43+
44+
return "The formatter “\( name )” could not be found."
45+
46+
case .failed( let executable, let status, let message ):
47+
48+
let detail = message.trimmingCharacters( in: .whitespacesAndNewlines )
49+
50+
if detail.isEmpty
51+
{
52+
return "\( executable ) failed with status \( status )."
53+
}
54+
55+
return "\( executable ) failed with status \( status ):\n\( detail )"
56+
}
57+
}
58+
}

Shared/FormatterOutcome.swift

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*******************************************************************************
2+
* The MIT License (MIT)
3+
*
4+
* Copyright (c) 2022, Jean-David Gadina - www.xs-labs.com
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the Software), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
******************************************************************************/
24+
25+
import Foundation
26+
27+
/// The result of classifying a completed formatter run.
28+
public enum FormatterOutcome: Equatable
29+
{
30+
/// Nothing to apply — the formatter made no change (or produced no usable
31+
/// output). The buffer is left untouched and no error is reported.
32+
case unchanged
33+
34+
/// The formatter produced new, non-empty output that should replace the
35+
/// buffer's contents.
36+
case formatted( String )
37+
38+
/// Classifies a finished formatter run into an outcome, throwing a
39+
/// `FormatterError` for failures the user can act on.
40+
///
41+
/// - A non-zero `status` throws `.failed`, carrying the formatter's stderr.
42+
/// - Output that is undecodable, identical to the input, or empty after
43+
/// trimming is treated as `.unchanged` (a silent no-op).
44+
/// - Otherwise the new output is returned as `.formatted`.
45+
public static func classify( executable: String, input: String, status: Int32, standardOutput: Data, standardError: Data ) throws -> FormatterOutcome
46+
{
47+
guard status == 0
48+
else
49+
{
50+
throw FormatterError.failed( executable: executable, status: status, message: String( data: standardError, encoding: .utf8 ) ?? "" )
51+
}
52+
53+
guard let output = String( data: standardOutput, encoding: .utf8 )
54+
else
55+
{
56+
return .unchanged
57+
}
58+
59+
if output == input
60+
{
61+
return .unchanged
62+
}
63+
64+
if output.trimmingCharacters( in: .whitespacesAndNewlines ).isEmpty
65+
{
66+
return .unchanged
67+
}
68+
69+
return .formatted( output )
70+
}
71+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*******************************************************************************
2+
* The MIT License (MIT)
3+
*
4+
* Copyright (c) 2022, Jean-David Gadina - www.xs-labs.com
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the Software), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
******************************************************************************/
24+
25+
import Foundation
26+
import Testing
27+
28+
@Suite( "FormatterError" )
29+
struct FormatterErrorTests
30+
{
31+
@Test( "Failed error description includes executable, status and stderr" )
32+
func failedErrorDescription()
33+
{
34+
let error = FormatterError.failed( executable: "uncrustify", status: 2, message: " parse error\n" )
35+
let description = error.errorDescription
36+
37+
#expect( description?.contains( "uncrustify" ) == true )
38+
#expect( description?.contains( "2" ) == true )
39+
#expect( description?.contains( "parse error" ) == true )
40+
}
41+
42+
@Test( "Failed error description omits the colon when stderr is empty" )
43+
func failedErrorDescriptionWithoutStderr() throws
44+
{
45+
let error = FormatterError.failed( executable: "swiftformat", status: 1, message: " \n" )
46+
let description = try #require( error.errorDescription )
47+
48+
#expect( description.contains( "swiftformat" ) )
49+
#expect( description.contains( "1" ) )
50+
#expect( description.hasSuffix( ":" ) == false )
51+
}
52+
53+
@Test( "Executable-not-found error description includes the name" )
54+
func executableNotFoundDescription() throws
55+
{
56+
let error = FormatterError.executableNotFound( "swiftformat" )
57+
let description = try #require( error.errorDescription )
58+
59+
#expect( description.contains( "swiftformat" ) )
60+
}
61+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*******************************************************************************
2+
* The MIT License (MIT)
3+
*
4+
* Copyright (c) 2022, Jean-David Gadina - www.xs-labs.com
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the Software), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
******************************************************************************/
24+
25+
import Foundation
26+
import Testing
27+
28+
@Suite( "FormatterOutcome.classify" )
29+
struct FormatterOutcomeTests
30+
{
31+
private func data( _ string: String ) -> Data
32+
{
33+
Data( string.utf8 )
34+
}
35+
36+
@Test( "Returns .formatted when output differs and is non-empty" )
37+
func formattedWhenChanged() throws
38+
{
39+
let outcome = try FormatterOutcome.classify(
40+
executable: "swiftformat",
41+
input: "let x=1",
42+
status: 0,
43+
standardOutput: self.data( "let x = 1\n" ),
44+
standardError: Data()
45+
)
46+
47+
#expect( outcome == .formatted( "let x = 1\n" ) )
48+
}
49+
50+
@Test( "Returns .unchanged when output equals input" )
51+
func unchangedWhenIdentical() throws
52+
{
53+
let outcome = try FormatterOutcome.classify(
54+
executable: "swiftformat",
55+
input: "let x = 1\n",
56+
status: 0,
57+
standardOutput: self.data( "let x = 1\n" ),
58+
standardError: Data()
59+
)
60+
61+
#expect( outcome == .unchanged )
62+
}
63+
64+
@Test( "Returns .unchanged when output is empty after trimming" )
65+
func unchangedWhenBlankOutput() throws
66+
{
67+
let outcome = try FormatterOutcome.classify(
68+
executable: "swiftformat",
69+
input: "let x = 1\n",
70+
status: 0,
71+
standardOutput: self.data( " \n\t " ),
72+
standardError: Data()
73+
)
74+
75+
#expect( outcome == .unchanged )
76+
}
77+
78+
@Test( "Returns .unchanged when output is completely empty" )
79+
func unchangedWhenEmptyOutput() throws
80+
{
81+
let outcome = try FormatterOutcome.classify(
82+
executable: "swiftformat",
83+
input: "let x = 1\n",
84+
status: 0,
85+
standardOutput: Data(),
86+
standardError: Data()
87+
)
88+
89+
#expect( outcome == .unchanged )
90+
}
91+
92+
@Test( "Throws .failed with stderr text on a non-zero status" )
93+
func throwsOnNonZeroStatus()
94+
{
95+
#expect( throws: FormatterError.failed( executable: "uncrustify", status: 1, message: "bad config at line 3" ) )
96+
{
97+
try FormatterOutcome.classify(
98+
executable: "uncrustify",
99+
input: "int x;",
100+
status: 1,
101+
standardOutput: Data(),
102+
standardError: self.data( "bad config at line 3" )
103+
)
104+
}
105+
}
106+
}

0 commit comments

Comments
 (0)