diff --git a/Sources/AppBundle/command/format.swift b/Sources/AppBundle/command/format.swift index 65199ad0..47459162 100644 --- a/Sources/AppBundle/command/format.swift +++ b/Sources/AppBundle/command/format.swift @@ -1,4 +1,5 @@ import Common +import Foundation struct WindowWithPrefetchedTitle { let window: Window @@ -46,12 +47,12 @@ enum AeroObj { extension [AeroObj] { @MainActor - func format(_ format: [InterToken]) -> Result<[String], String> { + func format(_ format: [InterToken]) -> Result<[String], [InterVarExpansionError]> { var cellTable: [[Cell]] = [] for obj in self { var line: [Cell] = [] var curCell: String = "" - var errors: [String] = [] + var errors = [InterVarExpansionError]() for token in format { switch token { case .interVar(.plainInterVar(.rightPadding)): @@ -66,7 +67,7 @@ extension [AeroObj] { } } } - if !errors.isEmpty { return .failure(errors.joinErrors()) } + if !errors.isEmpty { return .failure(errors) } line.append(Cell(value: curCell, rightPadding: false)) cellTable.append(line) } @@ -134,7 +135,7 @@ private struct Cell { } extension FormatVar { - @MainActor func expandFormatVar(obj: AeroObj) -> Result { + @MainActor func expandFormatVar(obj: AeroObj) -> Result { switch (obj, self) { case (.window(let w), .workspace): return w.window.nodeWorkspace.flatMap(AeroObj.workspace).map(expandFormatVar) ?? .success(.string("NULL-WORKSPACE")) @@ -184,23 +185,43 @@ extension FormatVar { } default: break } - return .failure(unknownInterpolationVariable(variable: rawValue, obj)) + return .failure(.unknownInterpolationVariable(unknownInterpolationVariable(variable: rawValue, obj))) } } +enum InterVarExpansionError: LocalizedError, CustomStringConvertible { + case unknownInterpolationVariable(String) + case nullParent(String) + case notPossible(String) + case windowParentIllegalRelation(String) + case rightPaddingCannotBeExpanded(String) + + public var description: String { + switch self { + case .unknownInterpolationVariable(let msg): msg + case .nullParent(let msg): msg + case .notPossible(let msg): msg + case .windowParentIllegalRelation(let msg): msg + case .rightPaddingCannotBeExpanded(let msg): msg + } + } + + public var errorDescription: String? { description } +} + extension PlainInterVar { - @MainActor func expandFormatVar() -> Result { + @MainActor func expandFormatVar() -> Result { switch self { case .newline: .success(.string("\n")) case .tab: .success(.string("\t")) case .rightPadding: - .failure("\(PlainInterVar.rightPadding.rawValue.singleQuoted) interpolation variable cannot be expanded") + .failure(.rightPaddingCannotBeExpanded("\(PlainInterVar.rightPadding.rawValue.singleQuoted) interpolation variable cannot be expanded")) } } } extension InterVar { - @MainActor func expandFormatVar(obj: AeroObj) -> Result { + @MainActor func expandFormatVar(obj: AeroObj) -> Result { switch self { case .formatVar(let it): it.expandFormatVar(obj: obj) case .plainInterVar(let it): it.expandFormatVar() @@ -222,8 +243,8 @@ private func toLayoutString(tc: TilingContainer) -> String { } } -private func toLayoutResult(w: Window) -> Result { - guard let parent = w.parent else { return .failure("NULL-PARENT") } +private func toLayoutResult(w: Window) -> Result { + guard let parent = w.parent else { return .failure(.nullParent("NULL-PARENT")) } return switch getChildParentRelation(child: w, parent: parent) { case .tiling(let tc): .success(.string(toLayoutString(tc: tc))) case .floatingWindow: .success(.string(LayoutCmdArgs.LayoutDescription.floating.rawValue)) @@ -232,7 +253,7 @@ private func toLayoutResult(w: Window) -> Result { case .macosNativeMinimizedWindow: .success(.string("macos_native_minimized")) case .macosPopupWindow: .success(.string("NULL-WINDOW-LAYOUT")) - case .rootTilingContainer: .failure("Not possible") - case .shimContainerRelation: .failure("Window cannot have a shim container relation") + case .rootTilingContainer: .failure(.notPossible("Not possible")) + case .shimContainerRelation: .failure(.windowParentIllegalRelation("Window cannot have a shim container relation")) } } diff --git a/Sources/AppBundle/command/formatToJson.swift b/Sources/AppBundle/command/formatToJson.swift index f68fba7f..5b4ac0c7 100644 --- a/Sources/AppBundle/command/formatToJson.swift +++ b/Sources/AppBundle/command/formatToJson.swift @@ -16,7 +16,7 @@ extension [AeroObj] { case .interVar(let varName): switch varName.expandFormatVar(obj: richObj) { case .success(let expanded): rawObj[varName.rawValue] = expanded - case .failure(let error): return .failure(error) + case .failure(let error): return .failure(error.description) } } } diff --git a/Sources/AppBundle/command/impl/EchoCommand.swift b/Sources/AppBundle/command/impl/EchoCommand.swift index 44ee3971..05b40429 100644 --- a/Sources/AppBundle/command/impl/EchoCommand.swift +++ b/Sources/AppBundle/command/impl/EchoCommand.swift @@ -14,10 +14,15 @@ struct EchoCommand: Command { obj.append(AeroObj.workspace(target.workspace)) } for argWithInterVars in args.args.val { - guard let strs = obj.format(argWithInterVars).getIgnoringErrorsOrNil() else { - io.err(noWindowIsFocused) - return .fail - } + guard let strs = obj.format(argWithInterVars).getOrNil(onFailure: { errs in + for err in errs { + switch err { + case .unknownInterpolationVariable: io.err(noWindowIsFocused) + case .notPossible, .nullParent, + .rightPaddingCannotBeExpanded, .windowParentIllegalRelation: io.err(err.description) + } + } + }) else { return .fail } for str in strs { switch args.isStderr { case true: io.err(str) diff --git a/Sources/AppBundle/command/impl/ListAppsCommand.swift b/Sources/AppBundle/command/impl/ListAppsCommand.swift index b3eba343..2d636df4 100644 --- a/Sources/AppBundle/command/impl/ListAppsCommand.swift +++ b/Sources/AppBundle/command/impl/ListAppsCommand.swift @@ -23,7 +23,7 @@ struct ListAppsCommand: Command { default: switch list.format(args.format) { case .success(let lines): .succ(io.out(lines)) - case .failure(let msg): .fail(io.err(msg)) + case .failure(let msg): .fail(io.err(msg.map(\.description).joinErrors())) } } } diff --git a/Sources/AppBundle/command/impl/ListMonitorsCommand.swift b/Sources/AppBundle/command/impl/ListMonitorsCommand.swift index 79912a63..8b582f3f 100644 --- a/Sources/AppBundle/command/impl/ListMonitorsCommand.swift +++ b/Sources/AppBundle/command/impl/ListMonitorsCommand.swift @@ -28,7 +28,7 @@ struct ListMonitorsCommand: Command { default: switch list.format(args.format) { case .success(let lines): .succ(io.out(lines)) - case .failure(let msg): .fail(io.err(msg)) + case .failure(let msg): .fail(io.err(msg.map(\.description).joinErrors())) } } } diff --git a/Sources/AppBundle/command/impl/ListWindowsCommand.swift b/Sources/AppBundle/command/impl/ListWindowsCommand.swift index 3bf9f1be..4a1b75dd 100644 --- a/Sources/AppBundle/command/impl/ListWindowsCommand.swift +++ b/Sources/AppBundle/command/impl/ListWindowsCommand.swift @@ -59,7 +59,7 @@ struct ListWindowsCommand: Command { } else { return switch list.format(args.format) { case .success(let lines): .succ(io.out(lines)) - case .failure(let msg): .fail(io.err(msg)) + case .failure(let msg): .fail(io.err(msg.map(\.description).joinErrors())) } } } diff --git a/Sources/AppBundle/command/impl/ListWorkspacesCommand.swift b/Sources/AppBundle/command/impl/ListWorkspacesCommand.swift index 33425959..56ceac12 100644 --- a/Sources/AppBundle/command/impl/ListWorkspacesCommand.swift +++ b/Sources/AppBundle/command/impl/ListWorkspacesCommand.swift @@ -31,7 +31,7 @@ struct ListWorkspacesCommand: Command { default: switch list.format(args.format) { case .success(let lines): .succ(io.out(lines)) - case .failure(let msg): .fail(io.err(msg)) + case .failure(let msg): .fail(io.err(msg.map(\.description).joinErrors())) } } } diff --git a/Sources/AppBundle/command/impl/TestCommand.swift b/Sources/AppBundle/command/impl/TestCommand.swift index 9da3db19..ba5e563a 100644 --- a/Sources/AppBundle/command/impl/TestCommand.swift +++ b/Sources/AppBundle/command/impl/TestCommand.swift @@ -8,15 +8,18 @@ struct TestCommand: Command { func run(_ env: CmdEnv, _ io: CmdIo) async throws -> ConditionalExitCode { guard let target = args.resolveTargetOrReportError(env, io) else { return .fail } - let _lhs: Result = switch target.windowOrNil { + let _lhs: Result = switch target.windowOrNil { case let window?: args.lhs.val.expandFormatVar(obj: .window(try await .resolveWindow(window, for: args.lhs.val))) case nil: args.lhs.val.expandFormatVar(obj: .workspace(target.workspace)) } - guard let lhs = _lhs.getIgnoringErrorsOrNil() else { - io.err(noWindowIsFocused) - return .fail - } + guard let lhs = _lhs.getOrNil(onFailure: { err in + switch err { + case .unknownInterpolationVariable: io.err(noWindowIsFocused) + case .notPossible, .nullParent, + .rightPaddingCannotBeExpanded, .windowParentIllegalRelation: io.err(err.description) + } + }) else { return .fail } let infixOperator = args.infixOperator.val let rhs = args.rhs.val diff --git a/Sources/AppBundleTests/command/FormatTest.swift b/Sources/AppBundleTests/command/FormatTest.swift index c88514a0..b43929d0 100644 --- a/Sources/AppBundleTests/command/FormatTest.swift +++ b/Sources/AppBundleTests/command/FormatTest.swift @@ -3,7 +3,7 @@ import Common import Foundation import XCTest -private func assertPrimitive(_ actual: Result, _ expected: Primitive, file: StaticString = #filePath, line: UInt = #line) { +private func assertPrimitive(_ actual: Result, _ expected: Primitive, file: StaticString = #filePath, line: UInt = #line) { switch actual { case .failure: failExpectedActual("Result.success(\(expected.toString()))", actual, file: file, line: line) case .success(let primitive): @@ -53,7 +53,7 @@ final class FormatTest: XCTestCase { func testFormatEmptyInput() { let result: [AeroObj] = [] - assertEquals(result.format([.interVar(.formatVar(.window(.windowId)))]), .success([])) + assertSucc(result.format([.interVar(.formatVar(.window(.windowId)))]), []) } func testFormatWithNewlineAndTab() { @@ -66,7 +66,7 @@ final class FormatTest: XCTestCase { .interVar(.plainInterVar(.newline)), .literal("end"), ]) - assertEquals(result, .success(["42\tX\nend"])) + assertSucc(result, ["42\tX\nend"]) } func testFormatMultipleRightPaddingColumns() { @@ -83,10 +83,10 @@ final class FormatTest: XCTestCase { .interVar(.plainInterVar(.rightPadding)), .literal(" END"), ]) - assertEquals(result, .success([ + assertSucc(result, [ "2 | a END", "100 | bb END", - ])) + ]) } } @@ -101,6 +101,7 @@ final class FormatTest: XCTestCase { switch result { case .success: XCTFail("expected failure") case .failure(let msg): + let msg = msg.map(\.description).joined(separator: "\n") assertTrue(msg.contains("Unknown interpolation variable 'window-id'")) assertTrue(msg.contains("Unknown interpolation variable 'window-title'")) } @@ -207,13 +208,6 @@ final class FormatTest: XCTestCase { assertPrimitive(FormatVar.window(.windowLayout).expandFormatVar(obj: obj), .string("NULL-WINDOW-LAYOUT")) } - func testExpandWindowLayoutNullParent() { - let window = TestWindow.new(id: 1, parent: Workspace.get(byName: name).rootTilingContainer) - window.unbindFromParent() - let obj = AeroObj.window(.forTest(window: window, title: nil)) - assertFail(FormatVar.window(.windowLayout).expandFormatVar(obj: obj), "NULL-PARENT") - } - func testExpandWindowToWorkspaceWhenWindowHasWorkspace() { let window = TestWindow.new(id: 1, parent: Workspace.get(byName: name).rootTilingContainer) let obj = AeroObj.window(.forTest(window: window, title: nil)) @@ -289,32 +283,9 @@ final class FormatTest: XCTestCase { assertPrimitive(FormatVar.app(.appBundlePath).expandFormatVar(obj: obj), .string("NULL-APP-BUNDLE-PATH")) } - func testExpandMismatchedReturnsUnknownInterpolation() { - let workspace = AeroObj.workspace(Workspace.get(byName: name)) - let result = FormatVar.window(.windowId).expandFormatVar(obj: workspace) - switch result { - case .success: XCTFail("expected failure") - case .failure(let msg): - assertTrue(msg.starts(with: "Unknown interpolation variable 'window-id'.")) - assertTrue(msg.contains("Possible values:")) - assertTrue(msg.contains("workspace")) - } - - let app = AeroObj.app(TestApp.shared) - assertFail(FormatVar.window(.windowId).expandFormatVar(obj: app)) - assertFail(FormatVar.workspace(.workspaceName).expandFormatVar(obj: app)) - assertFail(FormatVar.monitor(.monitorName).expandFormatVar(obj: app)) - - let monitor = AeroObj.monitor(mainMonitor) - assertFail(FormatVar.window(.windowId).expandFormatVar(obj: monitor)) - assertFail(FormatVar.workspace(.workspaceName).expandFormatVar(obj: monitor)) - assertFail(FormatVar.app(.appName).expandFormatVar(obj: monitor)) - } - func testPlainInterVarExpand() { assertPrimitive(PlainInterVar.newline.expandFormatVar(), .string("\n")) assertPrimitive(PlainInterVar.tab.expandFormatVar(), .string("\t")) - assertFail(PlainInterVar.rightPadding.expandFormatVar(), "'right-padding' interpolation variable cannot be expanded") } func testInterVarExpandDelegates() { diff --git a/Sources/AppBundleTests/command/ListWindowsTest.swift b/Sources/AppBundleTests/command/ListWindowsTest.swift index 932caf55..f2b5d691 100644 --- a/Sources/AppBundleTests/command/ListWindowsTest.swift +++ b/Sources/AppBundleTests/command/ListWindowsTest.swift @@ -54,7 +54,7 @@ final class ListWindowsTest: XCTestCase { AeroObj.window(.forTest(window: TestWindow.new(id: 2, parent: $0), title: "non-empty")), AeroObj.window(.forTest(window: TestWindow.new(id: 1, parent: $0), title: "")), ] - assertEquals(windows.format([.interVar(.formatVar(.window(.windowTitle)))]), .success(["non-empty", ""])) + assertSucc(windows.format([.interVar(.formatVar(.window(.windowTitle)))]), ["non-empty", ""]) } Workspace.get(byName: name).rootTilingContainer.apply { @@ -62,7 +62,7 @@ final class ListWindowsTest: XCTestCase { AeroObj.window(.forTest(window: TestWindow.new(id: 2, parent: $0), title: "non-empty")), AeroObj.window(.forTest(window: TestWindow.new(id: 10, parent: $0), title: "")), ] - assertEquals(windows.format([.interVar(.formatVar(.window(.windowId))), .interVar(.plainInterVar(.rightPadding)), .interVar(.formatVar(.window(.windowTitle)))]), .success(["2 non-empty", "10"])) + assertSucc(windows.format([.interVar(.formatVar(.window(.windowId))), .interVar(.plainInterVar(.rightPadding)), .interVar(.formatVar(.window(.windowTitle)))]), ["2 non-empty", "10"]) } Workspace.get(byName: name).rootTilingContainer.apply { @@ -70,7 +70,7 @@ final class ListWindowsTest: XCTestCase { AeroObj.window(.forTest(window: TestWindow.new(id: 2, parent: $0), title: "title1")), AeroObj.window(.forTest(window: TestWindow.new(id: 10, parent: $0), title: "title2")), ] - assertEquals(windows.format([.interVar(.formatVar(.window(.windowId))), .interVar(.plainInterVar(.rightPadding)), .literal(" | "), .interVar(.formatVar(.window(.windowTitle)))]), .success(["2 | title1", "10 | title2"])) + assertSucc(windows.format([.interVar(.formatVar(.window(.windowId))), .interVar(.plainInterVar(.rightPadding)), .literal(" | "), .interVar(.formatVar(.window(.windowTitle)))]), ["2 | title1", "10 | title2"]) } }