Repository: cpisciotta/xcbeautify
Pull request #238 (2024-01-14 04:16:01)

Simplify Parser and Introduce Formatter
## Changes

### Major

Simplify `Parser` to exclusively implement its intended functionality: parsing. Separate formatting-related logic into a new type, `Formatter`.

Move the `beautify` method from `OutputRendering` to `Formatter`, delete the unnecessary extension on `String`, and update `JunitReporter` as necessary. Update `XCBeautifier` and the executable to wrap `Parser`, `Formatter`, and output-related logic.

### Minor

- Make types `package`-accessible, as necessary.
- Update unit tests to support new implementation.

## Testing

### Performance

| | Old (Seconds) | New (Seconds) | Difference (%) |
|:---:|:---:|:---:|:---:|
| Run 1 | 4.84 | 4.88 | - |
| Run 2 | 4.87 | 4.73 | - |
| Run 3 | 4.82 | 4.79 | - |
| Run 4 | 4.93 | 4.73 | - |
| Run 5 | 4.87 | 4.75 | - |
| Average | 4.866 | 4.776 | -1.87% |

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/cpisciotta/xcbeautify/pull/238?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Charles+Pisciotta) Report
Attention: Patch coverage is `75.00000%` with `14 lines` in your changes are missing coverage. Please review.
> Project coverage is 79.30%. Comparing base [(`7cb16db`)](https://app.codecov.io/gh/cpisciotta/xcbeautify/commit/7cb16db05c7089a3e5f6584f0bca97446688eedd?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Charles+Pisciotta) to head [(`1f8cf1b`)](https://app.codecov.io/gh/cpisciotta/xcbeautify/pull/238?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Charles+Pisciotta).

| [Files](https://app.codecov.io/gh/cpisciotta/xcbeautify/pull/238?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Charles+Pisciotta) | Patch % | Lines |
|---|---|---|
| [Sources/XcbeautifyLib/XCBeautifier.swift](https://app.codecov.io/gh/cpisciotta/xcbeautify/pull/238?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Charles+Pisciotta#diff-U291cmNlcy9YY2JlYXV0aWZ5TGliL1hDQmVhdXRpZmllci5zd2lmdA==) | 0.00% | [9 Missing :warning: ](https://app.codecov.io/gh/cpisciotta/xcbeautify/pull/238?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Charles+Pisciotta) |
| [Sources/XcbeautifyLib/Parser.swift](https://app.codecov.io/gh/cpisciotta/xcbeautify/pull/238?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Charles+Pisciotta#diff-U291cmNlcy9YY2JlYXV0aWZ5TGliL1BhcnNlci5zd2lmdA==) | 80.95% | [4 Missing :warning: ](https://app.codecov.io/gh/cpisciotta/xcbeautify/pull/238?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Charles+Pisciotta) |
| [Sources/XcbeautifyLib/CaptureGroups.swift](https://app.codecov.io/gh/cpisciotta/xcbeautify/pull/238?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Charles+Pisciotta#diff-U291cmNlcy9YY2JlYXV0aWZ5TGliL0NhcHR1cmVHcm91cHMuc3dpZnQ=) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/cpisciotta/xcbeautify/pull/238?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Charles+Pisciotta) |

<details><summary>Additional details and impacted files</summary>


```diff
@@                  Coverage Diff                  @@
##           DeleteTestSummary     #238      +/-   ##
=====================================================
- Coverage              87.20%   79.30%   -7.91%     
=====================================================
  Files                     13       13              
  Lines                   1407     1411       +4     
=====================================================
- Hits                    1227     1119     -108     
- Misses                   180      292     +112     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/cpisciotta/xcbeautify/pull/238?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Charles+Pisciotta).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Charles+Pisciotta).


Patch:
diff --git a/Sources/XcbeautifyLib/CaptureGroups.swift b/Sources/XcbeautifyLib/CaptureGroups.swift
index 320fe2d..946792d 100644
--- a/Sources/XcbeautifyLib/CaptureGroups.swift
+++ b/Sources/XcbeautifyLib/CaptureGroups.swift
@@ -1,11 +1,15 @@
 import Foundation
 
-protocol CaptureGroup {
+package protocol CaptureGroup {
     static var outputType: OutputType { get }
     static var regex: XcbeautifyLib.Regex { get }
     init?(groups: [String])
 }
 
+package extension CaptureGroup {
+    var outputType: OutputType { Self.outputType }
+}
+
 extension CaptureGroup {
     static var pattern: String { regex.pattern }
     var pattern: String { Self.regex.pattern }
diff --git a/Sources/XcbeautifyLib/Formatter.swift b/Sources/XcbeautifyLib/Formatter.swift
new file mode 100644
index 0000000..220942b
--- /dev/null
+++ b/Sources/XcbeautifyLib/Formatter.swift
@@ -0,0 +1,211 @@
+import Foundation
+
+package struct Formatter {
+    private let colored: Bool
+    private let renderer: any OutputRendering
+
+    package init(
+        colored: Bool,
+        renderer: Renderer,
+        additionalLines: @escaping () -> (String?)
+    ) {
+        self.colored = colored
+
+        switch renderer {
+        case .terminal:
+            self.renderer = TerminalRenderer(colored: colored, additionalLines: additionalLines)
+        case .gitHubActions:
+            self.renderer = GitHubActionsRenderer(colored: colored, additionalLines: additionalLines)
+        case .teamcity:
+            self.renderer = TeamCityRenderer(colored: colored, additionalLines: additionalLines)
+        }
+    }
+
+    package func format(captureGroup: any CaptureGroup) -> String? {
+        switch captureGroup {
+        case let group as AggregateTargetCaptureGroup:
+            return renderer.formatTargetCommand(command: "Aggregate", group: group)
+        case let group as AnalyzeCaptureGroup:
+            return renderer.formatAnalyze(group: group)
+        case let group as AnalyzeTargetCaptureGroup:
+            return renderer.formatTargetCommand(command: "Analyze", group: group)
+        case let group as BuildTargetCaptureGroup:
+            return renderer.formatTargetCommand(command: "Build", group: group)
+        case is CheckDependenciesCaptureGroup:
+            return renderer.formatCheckDependencies()
+        case let group as CheckDependenciesErrorsCaptureGroup:
+            return renderer.formatError(group: group)
+        case let group as ClangErrorCaptureGroup:
+            return renderer.formatError(group: group)
+        case let group as CleanRemoveCaptureGroup:
+            return renderer.formatCleanRemove(group: group)
+        case let group as CleanTargetCaptureGroup:
+            return renderer.formatTargetCommand(command: "Clean", group: group)
+        case let group as CodesignCaptureGroup:
+            return renderer.formatCodeSign(group: group)
+        case let group as CodesignFrameworkCaptureGroup:
+            return renderer.formatCodeSignFramework(group: group)
+        case let group as CompilationResultCaptureGroup:
+            return renderer.formatCompilationResult(group: group)
+        case let group as CompileCaptureGroup:
+            return renderer.formatCompile(group: group)
+        case let group as SwiftCompileCaptureGroup:
+            return renderer.formatCompile(group: group)
+        case let group as SwiftCompilingCaptureGroup:
+            return renderer.formatSwiftCompiling(group: group)
+        case let group as CompileCommandCaptureGroup:
+            return renderer.formatCompileCommand(group: group)
+        case let group as CompileErrorCaptureGroup:
+            return renderer.formatCompileError(group: group)
+        case let group as CompileStoryboardCaptureGroup:
+            return renderer.formatCompile(group: group)
+        case let group as CompileWarningCaptureGroup:
+            return renderer.formatCompileWarning(group: group)
+        case let group as CompileXibCaptureGroup:
+            return renderer.formatCompile(group: group)
+        case let group as CopyHeaderCaptureGroup:
+            return renderer.formatCopy(group: group)
+        case let group as CopyPlistCaptureGroup:
+            return renderer.formatCopy(group: group)
+        case let group as CopyStringsCaptureGroup:
+            return renderer.formatCopy(group: group)
+        case let group as CpresourceCaptureGroup:
+            return renderer.formatCopy(group: group)
+        case let group as CursorCaptureGroup:
+            return renderer.formatCursor(group: group)
+        case let group as DuplicateLocalizedStringKeyCaptureGroup:
+            return renderer.formatDuplicateLocalizedStringKey(group: group)
+        case let group as ExecutedWithoutSkippedCaptureGroup:
+            return renderer.formatExecutedWithoutSkipped(group: group)
+        case let group as ExecutedWithSkippedCaptureGroup:
+            return renderer.formatExecutedWithSkipped(group: group)
+        case let group as FailingTestCaptureGroup:
+            return renderer.formatFailingTest(group: group)
+        case let group as FatalErrorCaptureGroup:
+            return renderer.formatError(group: group)
+        case let group as FileMissingErrorCaptureGroup:
+            return renderer.formatFileMissingError(group: group)
+        case let group as GenerateCoverageDataCaptureGroup:
+            return renderer.formatGenerateCoverageData(group: group)
+        case let group as GeneratedCoverageReportCaptureGroup:
+            return renderer.formatCoverageReport(group: group)
+        case let group as GenerateDSYMCaptureGroup:
+            return renderer.formatGenerateDsym(group: group)
+        case let group as GenericWarningCaptureGroup:
+            return renderer.formatWarning(group: group)
+        case let group as LDErrorCaptureGroup:
+            return renderer.formatError(group: group)
+        case let group as LDWarningCaptureGroup:
+            return renderer.formatLdWarning(group: group)
+        case let group as LibtoolCaptureGroup:
+            return renderer.formatLibtool(group: group)
+        case let group as LinkerDuplicateSymbolsCaptureGroup:
+            return renderer.formatLinkerDuplicateSymbolsError(group: group)
+        case let group as LinkerDuplicateSymbolsLocationCaptureGroup:
+            return renderer.formatLinkerDuplicateSymbolsLocation(group: group)
+        case let group as LinkerUndefinedSymbolLocationCaptureGroup:
+            return renderer.formatLinkerUndefinedSymbolLocation(group: group)
+        case let group as LinkerUndefinedSymbolsCaptureGroup:
+            return renderer.formatLinkerUndefinedSymbolsError(group: group)
+        case let group as LinkingCaptureGroup:
+            return renderer.formatLinking(group: group)
+        case let group as ModuleIncludesErrorCaptureGroup:
+            return renderer.formatError(group: group)
+        case let group as NoCertificateCaptureGroup:
+            return renderer.formatError(group: group)
+        case let group as PackageCheckingOutCaptureGroup:
+            return renderer.formatPackageCheckingOut(group: group)
+        case let group as PackageFetchingCaptureGroup:
+            return renderer.formatPackageFetching(group: group)
+        case let group as PackageGraphResolvedItemCaptureGroup:
+            return renderer.formatPackageItem(group: group)
+        case is PackageGraphResolvingEndedCaptureGroup:
+            return renderer.formatPackageEnd()
+        case is PackageGraphResolvingStartCaptureGroup:
+            return renderer.formatPackageStart()
+        case let group as PackageUpdatingCaptureGroup:
+            return renderer.formatPackageUpdating(group: group)
+        case let group as ParallelTestCaseAppKitPassedCaptureGroup:
+            return renderer.formatParallelTestCaseAppKitPassed(group: group)
+        case let group as ParallelTestCaseFailedCaptureGroup:
+            return renderer.formatParallelTestCaseFailed(group: group)
+        case let group as ParallelTestCasePassedCaptureGroup:
+            return renderer.formatParallelTestCasePassed(group: group)
+        case let group as ParallelTestCaseSkippedCaptureGroup:
+            return renderer.formatParallelTestCaseSkipped(group: group)
+        case let group as ParallelTestingFailedCaptureGroup:
+            return renderer.formatParallelTestingFailed(group: group)
+        case let group as ParallelTestingPassedCaptureGroup:
+            return renderer.formatParallelTestingPassed(group: group)
+        case let group as ParallelTestingStartedCaptureGroup:
+            return renderer.formatParallelTestingStarted(group: group)
+        case let group as ParallelTestSuiteStartedCaptureGroup:
+            return renderer.formatParallelTestSuiteStarted(group: group)
+        case let group as PbxcpCaptureGroup:
+            return renderer.formatCopy(group: group)
+        case let group as PhaseScriptExecutionCaptureGroup:
+            return renderer.formatPhaseScriptExecution(group: group)
+        case let group as PhaseSuccessCaptureGroup:
+            return renderer.formatPhaseSuccess(group: group)
+        case let group as PodsErrorCaptureGroup:
+            return renderer.formatError(group: group)
+        case let group as PreprocessCaptureGroup:
+            return renderer.formatPreprocess(group: group)
+        case let group as ProcessInfoPlistCaptureGroup:
+            return renderer.formatProcessInfoPlist(group: group)
+        case let group as ProcessPchCaptureGroup:
+            return renderer.formatProcessPch(group: group)
+        case let group as ProcessPchCommandCaptureGroup:
+            return renderer.formatProcessPchCommand(group: group)
+        case let group as ProvisioningProfileRequiredCaptureGroup:
+            return renderer.formatError(group: group)
+        case let group as RestartingTestCaptureGroup:
+            return renderer.formatRestartingTest(group: group)
+        case let group as ShellCommandCaptureGroup:
+            return renderer.formatShellCommand(group: group)
+        case let group as SymbolReferencedFromCaptureGroup:
+            return renderer.formatSymbolReferencedFrom(group: group)
+        case let group as TestCaseMeasuredCaptureGroup:
+            return renderer.formatTestCaseMeasured(group: group)
+        case let group as TestCasePassedCaptureGroup:
+            return renderer.formatTestCasePassed(group: group)
+        case let group as TestCaseSkippedCaptureGroup:
+            return renderer.formatTestCaseSkipped(group: group)
+        case let group as TestCasePendingCaptureGroup:
+            return renderer.formatTestCasePending(group: group)
+        case let group as TestCaseStartedCaptureGroup:
+            return renderer.formatTestCasesStarted(group: group)
+        case let group as TestsRunCompletionCaptureGroup:
+            return renderer.formatTestsRunCompletion(group: group)
+        case let group as TestSuiteAllTestsFailedCaptureGroup:
+            return renderer.formatTestSuiteAllTestsFailed(group: group)
+        case let group as TestSuiteAllTestsPassedCaptureGroup:
+            return renderer.formatTestSuiteAllTestsPassed(group: group)
+        case let group as TestSuiteStartCaptureGroup:
+            return renderer.formatTestSuiteStart(group: group)
+        case let group as TestSuiteStartedCaptureGroup:
+            return renderer.formatTestSuiteStarted(group: group)
+        case let group as TIFFutilCaptureGroup:
+            return renderer.formatTIFFUtil(group: group)
+        case let group as TouchCaptureGroup:
+            return renderer.formatTouch(group: group)
+        case let group as UIFailingTestCaptureGroup:
+            return renderer.formatUIFailingTest(group: group)
+        case let group as UndefinedSymbolLocationCaptureGroup:
+            return renderer.formatUndefinedSymbolLocation(group: group)
+        case let group as WillNotBeCodeSignedCaptureGroup:
+            return renderer.formatWillNotBeCodesignWarning(group: group)
+        case let group as WriteAuxiliaryFileCaptureGroup:
+            return renderer.formatWriteAuxiliaryFile(group: group)
+        case let group as WriteFileCaptureGroup:
+            return renderer.formatWriteFile(group: group)
+        case let group as XcodebuildErrorCaptureGroup:
+            return renderer.formatError(group: group)
+        case let group as SwiftDriverJobDiscoveryEmittingModuleCaptureGroup:
+            return renderer.formatSwiftDriverJobDiscoveryEmittingModule(group: group)
+        default:
+            assertionFailure()
+            return nil
+        }
+    }
+}
diff --git a/Sources/XcbeautifyLib/JunitReporter.swift b/Sources/XcbeautifyLib/JunitReporter.swift
index 25bbbab..a9bde70 100644
--- a/Sources/XcbeautifyLib/JunitReporter.swift
+++ b/Sources/XcbeautifyLib/JunitReporter.swift
@@ -50,51 +50,51 @@ package final class JunitReporter {
     }
 
     private func generateFailingTest(line: String) -> TestCase? {
-        guard let _group: any CaptureGroup = line.captureGroup(with: FailingTestCaptureGroup.pattern) else { return nil }
-        guard let group = _group as? FailingTestCaptureGroup else { return nil }
+        let groups = FailingTestCaptureGroup.regex.captureGroups(for: line)
+        guard let group = FailingTestCaptureGroup(groups: groups) else { return nil }
         return TestCase(classname: group.testSuite, name: group.testCase, time: nil, failure: .init(message: "\(group.file) - \(group.reason)"))
     }
 
     private func generateRestartingTest(line: String) -> TestCase? {
-        guard let _group: any CaptureGroup = line.captureGroup(with: RestartingTestCaptureGroup.pattern) else { return nil }
-        guard let group = _group as? RestartingTestCaptureGroup else { return nil }
+        let groups = RestartingTestCaptureGroup.regex.captureGroups(for: line)
+        guard let group = RestartingTestCaptureGroup(groups: groups) else { return nil }
         return TestCase(classname: group.testSuite, name: group.testCase, time: nil, failure: .init(message: line))
     }
 
     private func generateParallelFailingTest(line: String) -> TestCase? {
         // Parallel tests do not provide meaningful failure messages
-        guard let _group: any CaptureGroup = line.captureGroup(with: ParallelTestCaseFailedCaptureGroup.pattern) else { return nil }
-        guard let group = _group as? ParallelTestCaseFailedCaptureGroup else { return nil }
+        let groups = ParallelTestCaseFailedCaptureGroup.regex.captureGroups(for: line)
+        guard let group = ParallelTestCaseFailedCaptureGroup(groups: groups) else { return nil }
         return TestCase(classname: group.suite, name: group.testCase, time: nil, failure: .init(message: "Parallel test failed"))
     }
 
     private func generatePassingTest(line: String) -> TestCase? {
-        guard let _group: any CaptureGroup = line.captureGroup(with: TestCasePassedCaptureGroup.pattern) else { return nil }
-        guard let group = _group as? TestCasePassedCaptureGroup else { return nil }
+        let groups = TestCasePassedCaptureGroup.regex.captureGroups(for: line)
+        guard let group = TestCasePassedCaptureGroup(groups: groups) else { return nil }
         return TestCase(classname: group.suite, name: group.testCase, time: group.time)
     }
 
     private func generateSkippedTest(line: String) -> TestCase? {
-        guard let _group: any CaptureGroup = line.captureGroup(with: TestCaseSkippedCaptureGroup.pattern) else { return nil }
-        guard let group = _group as? TestCaseSkippedCaptureGroup else { return nil }
+        let groups = TestCaseSkippedCaptureGroup.regex.captureGroups(for: line)
+        guard let group = TestCaseSkippedCaptureGroup(groups: groups) else { return nil }
         return TestCase(classname: group.suite, name: group.testCase, time: group.time, skipped: .init(message: nil))
     }
 
     private func generatePassingParallelTest(line: String) -> TestCase? {
-        guard let _group: any CaptureGroup = line.captureGroup(with: ParallelTestCasePassedCaptureGroup.pattern) else { return nil }
-        guard let group = _group as? ParallelTestCasePassedCaptureGroup else { return nil }
+        let groups = ParallelTestCasePassedCaptureGroup.regex.captureGroups(for: line)
+        guard let group = ParallelTestCasePassedCaptureGroup(groups: groups) else { return nil }
         return TestCase(classname: group.suite, name: group.testCase, time: group.time)
     }
 
     private func generateSkippedParallelTest(line: String) -> TestCase? {
-        guard let _group: any CaptureGroup = line.captureGroup(with: ParallelTestCaseSkippedCaptureGroup.pattern) else { return nil }
-        guard let group = _group as? ParallelTestCaseSkippedCaptureGroup else { return nil }
+        let groups = ParallelTestCaseSkippedCaptureGroup.regex.captureGroups(for: line)
+        guard let group = ParallelTestCaseSkippedCaptureGroup(groups: groups) else { return nil }
         return TestCase(classname: group.suite, name: group.testCase, time: group.time, skipped: .init(message: nil))
     }
 
     private func generateSuiteStart(line: String) -> String? {
-        guard let _group: any CaptureGroup = line.captureGroup(with: TestSuiteStartCaptureGroup.pattern) else { return nil }
-        guard let group = _group as? TestSuiteStartCaptureGroup else { return nil }
+        let groups = TestSuiteStartCaptureGroup.regex.captureGroups(for: line)
+        guard let group = TestSuiteStartCaptureGroup(groups: groups) else { return nil }
         return group.testSuiteName
     }
 
diff --git a/Sources/XcbeautifyLib/Parser.swift b/Sources/XcbeautifyLib/Parser.swift
index 20aff35..a664160 100644
--- a/Sources/XcbeautifyLib/Parser.swift
+++ b/Sources/XcbeautifyLib/Parser.swift
@@ -1,14 +1,6 @@
-package class Parser {
-    private let colored: Bool
-
-    private let renderer: any OutputRendering
-
-    private let additionalLines: () -> String?
-
-    private let preserveUnbeautifiedLines: Bool
-
-    package private(set) var outputType = OutputType.undefined
+import Foundation
 
+package final class Parser {
     private lazy var captureGroupTypes: [any CaptureGroup.Type] = [
         AnalyzeCaptureGroup.self,
         BuildTargetCaptureGroup.self,
@@ -104,40 +96,16 @@ package class Parser {
 
     // MARK: - Init
 
-    package init(
-        colored: Bool = true,
-        renderer: Renderer,
-        preserveUnbeautifiedLines: Bool = false,
-        additionalLines: @escaping () -> (String?)
-    ) {
-        self.colored = colored
-
-        switch renderer {
-        case .terminal:
-            self.renderer = TerminalRenderer(colored: colored, additionalLines: additionalLines)
-        case .gitHubActions:
-            self.renderer = GitHubActionsRenderer(colored: colored, additionalLines: additionalLines)
-        case .teamcity:
-            self.renderer = TeamCityRenderer(colored: colored, additionalLines: additionalLines)
-        }
-
-        self.preserveUnbeautifiedLines = preserveUnbeautifiedLines
-        self.additionalLines = additionalLines
-    }
+    package init() { }
 
-    package func parse(line: String) -> String? {
+    package func parse(line: String) -> (any CaptureGroup)? {
         if line.isEmpty {
-            outputType = .undefined
             return nil
         }
 
         // Find first parser that can parse specified string
         guard let idx = captureGroupTypes.firstIndex(where: { $0.regex.match(string: line) }) else {
-            // Some uncommon cases, which have additional logic and don't follow default flow
-
-            // Nothing found?
-            outputType = OutputType.undefined
-            return preserveUnbeautifiedLines ? line : nil
+            return nil
         }
 
         guard let captureGroupType = captureGroupTypes[safe: idx] else {
@@ -145,16 +113,15 @@ package class Parser {
             return nil
         }
 
-        let formattedOutput = renderer.beautify(
-            line: line,
-            pattern: captureGroupType.pattern
-        )
-
-        outputType = captureGroupType.outputType
+        let groups: [String] = captureGroupType.regex.captureGroups(for: line)
+        guard let captureGroup = captureGroupType.init(groups: groups) else {
+            assertionFailure()
+            return nil
+        }
 
         // Move found parser to the top, so next time it will be checked first
         captureGroupTypes.insert(captureGroupTypes.remove(at: idx), at: 0)
 
-        return formattedOutput
+        return captureGroup
     }
 }
diff --git a/Sources/XcbeautifyLib/Regex.swift b/Sources/XcbeautifyLib/Regex.swift
index 867a45c..7dc6838 100644
--- a/Sources/XcbeautifyLib/Regex.swift
+++ b/Sources/XcbeautifyLib/Regex.swift
@@ -2,7 +2,7 @@ import Foundation
 
 // `NSRegularExpression` is marked as `@unchecked Sendable`.
 // Match the definition here.
-final class Regex: @unchecked Sendable {
+package final class Regex: @unchecked Sendable {
     let pattern: String
 
     private lazy var regex: NSRegularExpression? = {
diff --git a/Sources/XcbeautifyLib/Renderers/OutputRendering.swift b/Sources/XcbeautifyLib/Renderers/OutputRendering.swift
index 8691d49..7ea4f81 100644
--- a/Sources/XcbeautifyLib/Renderers/OutputRendering.swift
+++ b/Sources/XcbeautifyLib/Renderers/OutputRendering.swift
@@ -4,8 +4,6 @@ protocol OutputRendering {
     var colored: Bool { get }
     var additionalLines: () -> String? { get }
 
-    func beautify(line: String, pattern: String) -> String?
-
     func formatAnalyze(group: AnalyzeCaptureGroup) -> String
     func formatCheckDependencies() -> String
     func formatCleanRemove(group: CleanRemoveCaptureGroup) -> String
@@ -80,206 +78,6 @@ protocol OutputRendering {
     func formatSwiftDriverJobDiscoveryEmittingModule(group: SwiftDriverJobDiscoveryEmittingModuleCaptureGroup) -> String?
 }
 
-extension OutputRendering {
-    func beautify(
-        line: String,
-        pattern: String
-    ) -> String? {
-        guard let group: any CaptureGroup = line.captureGroup(with: pattern) else {
-            assertionFailure("Expected a known CaptureGroup from the given pattern!")
-            return nil
-        }
-
-        assert(pattern == group.pattern)
-
-        switch group {
-        case let group as AggregateTargetCaptureGroup:
-            return formatTargetCommand(command: "Aggregate", group: group)
-        case let group as AnalyzeCaptureGroup:
-            return formatAnalyze(group: group)
-        case let group as AnalyzeTargetCaptureGroup:
-            return formatTargetCommand(command: "Analyze", group: group)
-        case let group as BuildTargetCaptureGroup:
-            return formatTargetCommand(command: "Build", group: group)
-        case is CheckDependenciesCaptureGroup:
-            return formatCheckDependencies()
-        case let group as CheckDependenciesErrorsCaptureGroup:
-            return formatError(group: group)
-        case let group as ClangErrorCaptureGroup:
-            return formatError(group: group)
-        case let group as CleanRemoveCaptureGroup:
-            return formatCleanRemove(group: group)
-        case let group as CleanTargetCaptureGroup:
-            return formatTargetCommand(command: "Clean", group: group)
-        case let group as CodesignCaptureGroup:
-            return formatCodeSign(group: group)
-        case let group as CodesignFrameworkCaptureGroup:
-            return formatCodeSignFramework(group: group)
-        case let group as CompilationResultCaptureGroup:
-            return formatCompilationResult(group: group)
-        case let group as CompileCaptureGroup:
-            return formatCompile(group: group)
-        case let group as SwiftCompileCaptureGroup:
-            return formatCompile(group: group)
-        case let group as SwiftCompilingCaptureGroup:
-            return formatSwiftCompiling(group: group)
-        case let group as CompileCommandCaptureGroup:
-            return formatCompileCommand(group: group)
-        case let group as CompileErrorCaptureGroup:
-            return formatCompileError(group: group)
-        case let group as CompileStoryboardCaptureGroup:
-            return formatCompile(group: group)
-        case let group as CompileWarningCaptureGroup:
-            return formatCompileWarning(group: group)
-        case let group as CompileXibCaptureGroup:
-            return formatCompile(group: group)
-        case let group as CopyHeaderCaptureGroup:
-            return formatCopy(group: group)
-        case let group as CopyPlistCaptureGroup:
-            return formatCopy(group: group)
-        case let group as CopyStringsCaptureGroup:
-            return formatCopy(group: group)
-        case let group as CpresourceCaptureGroup:
-            return formatCopy(group: group)
-        case let group as CursorCaptureGroup:
-            return formatCursor(group: group)
-        case let group as DuplicateLocalizedStringKeyCaptureGroup:
-            return formatDuplicateLocalizedStringKey(group: group)
-        case let group as ExecutedWithoutSkippedCaptureGroup:
-            return formatExecutedWithoutSkipped(group: group)
-        case let group as ExecutedWithSkippedCaptureGroup:
-            return formatExecutedWithSkipped(group: group)
-        case let group as FailingTestCaptureGroup:
-            return formatFailingTest(group: group)
-        case let group as FatalErrorCaptureGroup:
-            return formatError(group: group)
-        case let group as FileMissingErrorCaptureGroup:
-            return formatFileMissingError(group: group)
-        case let group as GenerateCoverageDataCaptureGroup:
-            return formatGenerateCoverageData(group: group)
-        case let group as GeneratedCoverageReportCaptureGroup:
-            return formatCoverageReport(group: group)
-        case let group as GenerateDSYMCaptureGroup:
-            return formatGenerateDsym(group: group)
-        case let group as GenericWarningCaptureGroup:
-            return formatWarning(group: group)
-        case let group as LDErrorCaptureGroup:
-            return formatError(group: group)
-        case let group as LDWarningCaptureGroup:
-            return formatLdWarning(group: group)
-        case let group as LibtoolCaptureGroup:
-            return formatLibtool(group: group)
-        case let group as LinkerDuplicateSymbolsCaptureGroup:
-            return formatLinkerDuplicateSymbolsError(group: group)
-        case let group as LinkerDuplicateSymbolsLocationCaptureGroup:
-            return formatLinkerDuplicateSymbolsLocation(group: group)
-        case let group as LinkerUndefinedSymbolLocationCaptureGroup:
-            return formatLinkerUndefinedSymbolLocation(group: group)
-        case let group as LinkerUndefinedSymbolsCaptureGroup:
-            return formatLinkerUndefinedSymbolsError(group: group)
-        case let group as LinkingCaptureGroup:
-            return formatLinking(group: group)
-        case let group as ModuleIncludesErrorCaptureGroup:
-            return formatError(group: group)
-        case let group as NoCertificateCaptureGroup:
-            return formatError(group: group)
-        case let group as PackageCheckingOutCaptureGroup:
-            return formatPackageCheckingOut(group: group)
-        case let group as PackageFetchingCaptureGroup:
-            return formatPackageFetching(group: group)
-        case let group as PackageGraphResolvedItemCaptureGroup:
-            return formatPackageItem(group: group)
-        case is PackageGraphResolvingEndedCaptureGroup:
-            return formatPackageEnd()
-        case is PackageGraphResolvingStartCaptureGroup:
-            return formatPackageStart()
-        case let group as PackageUpdatingCaptureGroup:
-            return formatPackageUpdating(group: group)
-        case let group as ParallelTestCaseAppKitPassedCaptureGroup:
-            return formatParallelTestCaseAppKitPassed(group: group)
-        case let group as ParallelTestCaseFailedCaptureGroup:
-            return formatParallelTestCaseFailed(group: group)
-        case let group as ParallelTestCasePassedCaptureGroup:
-            return formatParallelTestCasePassed(group: group)
-        case let group as ParallelTestCaseSkippedCaptureGroup:
-            return formatParallelTestCaseSkipped(group: group)
-        case let group as ParallelTestingFailedCaptureGroup:
-            return formatParallelTestingFailed(group: group)
-        case let group as ParallelTestingPassedCaptureGroup:
-            return formatParallelTestingPassed(group: group)
-        case let group as ParallelTestingStartedCaptureGroup:
-            return formatParallelTestingStarted(group: group)
-        case let group as ParallelTestSuiteStartedCaptureGroup:
-            return formatParallelTestSuiteStarted(group: group)
-        case let group as PbxcpCaptureGroup:
-            return formatCopy(group: group)
-        case let group as PhaseScriptExecutionCaptureGroup:
-            return formatPhaseScriptExecution(group: group)
-        case let group as PhaseSuccessCaptureGroup:
-            return formatPhaseSuccess(group: group)
-        case let group as PodsErrorCaptureGroup:
-            return formatError(group: group)
-        case let group as PreprocessCaptureGroup:
-            return formatPreprocess(group: group)
-        case let group as ProcessInfoPlistCaptureGroup:
-            return formatProcessInfoPlist(group: group)
-        case let group as ProcessPchCaptureGroup:
-            return formatProcessPch(group: group)
-        case let group as ProcessPchCommandCaptureGroup:
-            return formatProcessPchCommand(group: group)
-        case let group as ProvisioningProfileRequiredCaptureGroup:
-            return formatError(group: group)
-        case let group as RestartingTestCaptureGroup:
-            return formatRestartingTest(group: group)
-        case let group as ShellCommandCaptureGroup:
-            return formatShellCommand(group: group)
-        case let group as SymbolReferencedFromCaptureGroup:
-            return formatSymbolReferencedFrom(group: group)
-        case let group as TestCaseMeasuredCaptureGroup:
-            return formatTestCaseMeasured(group: group)
-        case let group as TestCasePassedCaptureGroup:
-            return formatTestCasePassed(group: group)
-        case let group as TestCaseSkippedCaptureGroup:
-            return formatTestCaseSkipped(group: group)
-        case let group as TestCasePendingCaptureGroup:
-            return formatTestCasePending(group: group)
-        case let group as TestCaseStartedCaptureGroup:
-            return formatTestCasesStarted(group: group)
-        case let group as TestsRunCompletionCaptureGroup:
-            return formatTestsRunCompletion(group: group)
-        case let group as TestSuiteAllTestsFailedCaptureGroup:
-            return formatTestSuiteAllTestsFailed(group: group)
-        case let group as TestSuiteAllTestsPassedCaptureGroup:
-            return formatTestSuiteAllTestsPassed(group: group)
-        case let group as TestSuiteStartCaptureGroup:
-            return formatTestSuiteStart(group: group)
-        case let group as TestSuiteStartedCaptureGroup:
-            return formatTestSuiteStarted(group: group)
-        case let group as TIFFutilCaptureGroup:
-            return formatTIFFUtil(group: group)
-        case let group as TouchCaptureGroup:
-            return formatTouch(group: group)
-        case let group as UIFailingTestCaptureGroup:
-            return formatUIFailingTest(group: group)
-        case let group as UndefinedSymbolLocationCaptureGroup:
-            return formatUndefinedSymbolLocation(group: group)
-        case let group as WillNotBeCodeSignedCaptureGroup:
-            return formatWillNotBeCodesignWarning(group: group)
-        case let group as WriteAuxiliaryFileCaptureGroup:
-            return formatWriteAuxiliaryFile(group: group)
-        case let group as WriteFileCaptureGroup:
-            return formatWriteFile(group: group)
-        case let group as XcodebuildErrorCaptureGroup:
-            return formatError(group: group)
-        case let group as SwiftDriverJobDiscoveryEmittingModuleCaptureGroup:
-            return formatSwiftDriverJobDiscoveryEmittingModule(group: group)
-        default:
-            assertionFailure()
-            return nil
-        }
-    }
-}
-
 extension OutputRendering {
     func formatAnalyze(group: AnalyzeCaptureGroup) -> String {
         let filename = group.filename
diff --git a/Sources/XcbeautifyLib/String+CapturedGroups.swift b/Sources/XcbeautifyLib/String+CapturedGroups.swift
deleted file mode 100644
index 422f7fd..0000000
--- a/Sources/XcbeautifyLib/String+CapturedGroups.swift
+++ /dev/null
@@ -1,113 +0,0 @@
-import Foundation
-
-extension String {
-    private static let captureGroups: [any CaptureGroup.Type] = [
-        AnalyzeCaptureGroup.self,
-        BuildTargetCaptureGroup.self,
-        AggregateTargetCaptureGroup.self,
-        AnalyzeTargetCaptureGroup.self,
-        CheckDependenciesCaptureGroup.self,
-        ShellCommandCaptureGroup.self,
-        CleanRemoveCaptureGroup.self,
-        CleanTargetCaptureGroup.self,
-        CodesignCaptureGroup.self,
-        CodesignFrameworkCaptureGroup.self,
-        CompilationResultCaptureGroup.self,
-        CompileCaptureGroup.self,
-        SwiftCompileCaptureGroup.self,
-        SwiftCompilingCaptureGroup.self,
-        CompileCommandCaptureGroup.self,
-        CompileXibCaptureGroup.self,
-        CompileStoryboardCaptureGroup.self,
-        CopyHeaderCaptureGroup.self,
-        CopyPlistCaptureGroup.self,
-        CopyStringsCaptureGroup.self,
-        CpresourceCaptureGroup.self,
-        ExecutedWithoutSkippedCaptureGroup.self,
-        ExecutedWithSkippedCaptureGroup.self,
-        FailingTestCaptureGroup.self,
-        UIFailingTestCaptureGroup.self,
-        RestartingTestCaptureGroup.self,
-        GenerateCoverageDataCaptureGroup.self,
-        GeneratedCoverageReportCaptureGroup.self,
-        GenerateDSYMCaptureGroup.self,
-        LibtoolCaptureGroup.self,
-        LinkingCaptureGroup.self,
-        TestCasePassedCaptureGroup.self,
-        TestCaseSkippedCaptureGroup.self,
-        TestCaseStartedCaptureGroup.self,
-        TestCasePendingCaptureGroup.self,
-        TestCaseMeasuredCaptureGroup.self,
-        ParallelTestCasePassedCaptureGroup.self,
-        ParallelTestCaseSkippedCaptureGroup.self,
-        ParallelTestCaseAppKitPassedCaptureGroup.self,
-        ParallelTestCaseFailedCaptureGroup.self,
-        ParallelTestingStartedCaptureGroup.self,
-        ParallelTestingPassedCaptureGroup.self,
-        ParallelTestingFailedCaptureGroup.self,
-        ParallelTestSuiteStartedCaptureGroup.self,
-        PhaseSuccessCaptureGroup.self,
-        PhaseScriptExecutionCaptureGroup.self,
-        ProcessPchCaptureGroup.self,
-        ProcessPchCommandCaptureGroup.self,
-        PreprocessCaptureGroup.self,
-        PbxcpCaptureGroup.self,
-        ProcessInfoPlistCaptureGroup.self,
-        TestsRunCompletionCaptureGroup.self,
-        TestSuiteStartedCaptureGroup.self,
-        TestSuiteStartCaptureGroup.self,
-        TestSuiteAllTestsPassedCaptureGroup.self,
-        TestSuiteAllTestsFailedCaptureGroup.self,
-        TIFFutilCaptureGroup.self,
-        TouchCaptureGroup.self,
-        WriteFileCaptureGroup.self,
-        WriteAuxiliaryFileCaptureGroup.self,
-        CompileWarningCaptureGroup.self,
-        LDWarningCaptureGroup.self,
-        GenericWarningCaptureGroup.self,
-        WillNotBeCodeSignedCaptureGroup.self,
-        DuplicateLocalizedStringKeyCaptureGroup.self,
-        ClangErrorCaptureGroup.self,
-        CheckDependenciesErrorsCaptureGroup.self,
-        ProvisioningProfileRequiredCaptureGroup.self,
-        NoCertificateCaptureGroup.self,
-        CompileErrorCaptureGroup.self,
-        CursorCaptureGroup.self,
-        FatalErrorCaptureGroup.self,
-        FileMissingErrorCaptureGroup.self,
-        LDErrorCaptureGroup.self,
-        LinkerDuplicateSymbolsLocationCaptureGroup.self,
-        LinkerDuplicateSymbolsCaptureGroup.self,
-        LinkerUndefinedSymbolLocationCaptureGroup.self,
-        LinkerUndefinedSymbolsCaptureGroup.self,
-        PodsErrorCaptureGroup.self,
-        SymbolReferencedFromCaptureGroup.self,
-        ModuleIncludesErrorCaptureGroup.self,
-        UndefinedSymbolLocationCaptureGroup.self,
-        PackageFetchingCaptureGroup.self,
-        PackageUpdatingCaptureGroup.self,
-        PackageCheckingOutCaptureGroup.self,
-        PackageGraphResolvingStartCaptureGroup.self,
-        PackageGraphResolvingEndedCaptureGroup.self,
-        PackageGraphResolvedItemCaptureGroup.self,
-        XcodebuildErrorCaptureGroup.self,
-        SwiftDriverJobDiscoveryEmittingModuleCaptureGroup.self,
-    ]
-
-    func captureGroup(with pattern: String) -> (any CaptureGroup)? {
-        let captureGroupType: (any CaptureGroup.Type)? = Self.captureGroups.first { captureGroup in
-            captureGroup.pattern == pattern
-        }
-
-        guard let captureGroupType else {
-            assertionFailure()
-            return nil
-        }
-
-        let groups: [String] = captureGroupType.regex.captureGroups(for: self)
-
-        let captureGroup = captureGroupType.init(groups: groups)
-        assert(captureGroup != nil)
-        return captureGroup
-    }
-}
diff --git a/Sources/XcbeautifyLib/XCBeautifier.swift b/Sources/XcbeautifyLib/XCBeautifier.swift
index 6e32b06..84ab3d9 100644
--- a/Sources/XcbeautifyLib/XCBeautifier.swift
+++ b/Sources/XcbeautifyLib/XCBeautifier.swift
@@ -1,7 +1,9 @@
 import Foundation
 
 public struct XCBeautifier {
-    private let parser: Parser
+    private let parser = Parser()
+    private let formatter: Formatter
+    private let preserveUnbeautifiedLines: Bool
 
     public init(
         colored: Bool,
@@ -9,15 +11,24 @@ public struct XCBeautifier {
         preserveUnbeautifiedLines: Bool,
         additionalLines: @escaping () -> String?
     ) {
-        parser = Parser(
+        formatter = Formatter(
             colored: colored,
             renderer: renderer,
-            preserveUnbeautifiedLines: preserveUnbeautifiedLines,
             additionalLines: additionalLines
         )
+
+        self.preserveUnbeautifiedLines = preserveUnbeautifiedLines
     }
 
     public func format(line: String) -> String? {
-        parser.parse(line: line)
+        guard let captureGroup = parser.parse(line: line) else {
+            if preserveUnbeautifiedLines {
+                return line
+            } else {
+                return nil
+            }
+        }
+
+        return formatter.format(captureGroup: captureGroup)
     }
 }
diff --git a/Sources/xcbeautify/Xcbeautify.swift b/Sources/xcbeautify/Xcbeautify.swift
index 09fee81..5ab352b 100644
--- a/Sources/xcbeautify/Xcbeautify.swift
+++ b/Sources/xcbeautify/Xcbeautify.swift
@@ -85,17 +85,25 @@ struct Xcbeautify: ParsableCommand {
             return line
         }
 
-        let parser = Parser(
+        let parser = Parser()
+
+        let formatter = XcbeautifyLib.Formatter(
             colored: !disableColoredOutput,
             renderer: renderer,
-            preserveUnbeautifiedLines: preserveUnbeautified,
             additionalLines: { readLine() }
         )
 
         while let line = readLine() {
             guard !line.isEmpty else { continue }
-            guard let formatted = parser.parse(line: line) else { continue }
-            output.write(parser.outputType, formatted)
+            guard let captureGroup = parser.parse(line: line) else {
+                if preserveUnbeautified {
+                    output.write(.undefined, line)
+                }
+
+                continue
+            }
+            guard let formatted = formatter.format(captureGroup: captureGroup) else { continue }
+            output.write(captureGroup.outputType, formatted)
         }
 
         if !report.isEmpty {


Test patch:
diff --git a/Tests/XcbeautifyLibTests/ParsingTests/ParsingTests.swift b/Tests/XcbeautifyLibTests/ParsingTests/ParsingTests.swift
index c1bfd70..2b17c09 100644
--- a/Tests/XcbeautifyLibTests/ParsingTests/ParsingTests.swift
+++ b/Tests/XcbeautifyLibTests/ParsingTests/ParsingTests.swift
@@ -8,18 +8,7 @@ final class ParsingTests: XCTestCase {
         var buildLog: [String] = try String(contentsOf: url)
             .components(separatedBy: .newlines)
 
-        let parser = Parser(
-            colored: false,
-            renderer: .terminal,
-            preserveUnbeautifiedLines: false,
-            additionalLines: {
-                guard !buildLog.isEmpty else {
-                    XCTFail("The build log should never be empty when fetching additional lines.")
-                    return nil
-                }
-                return buildLog.removeFirst()
-            }
-        )
+        let parser = Parser()
 
         var uncapturedOutput = 0
 
@@ -37,9 +26,9 @@ final class ParsingTests: XCTestCase {
         // Update this magic number whenever `uncapturedOutput` is less than the current magic number.
         // There's a regression whenever `uncapturedOutput` is greater than the current magic number.
         #if os(macOS)
-        XCTAssertEqual(uncapturedOutput, 1934)
+        XCTAssertEqual(uncapturedOutput, 437)
         #else
-        XCTAssertEqual(uncapturedOutput, 1950)
+        XCTAssertEqual(uncapturedOutput, 453)
         #endif
     }
 
@@ -49,18 +38,7 @@ final class ParsingTests: XCTestCase {
         var buildLog: [String] = try String(contentsOf: url)
             .components(separatedBy: .newlines)
 
-        let parser = Parser(
-            colored: false,
-            renderer: .terminal,
-            preserveUnbeautifiedLines: false,
-            additionalLines: {
-                guard !buildLog.isEmpty else {
-                    XCTFail("The build log should never be empty when fetching additional lines.")
-                    return nil
-                }
-                return buildLog.removeFirst()
-            }
-        )
+        let parser = Parser()
 
         var uncapturedOutput = 0
 
@@ -78,9 +56,9 @@ final class ParsingTests: XCTestCase {
         // Update this magic number whenever `uncapturedOutput` is less than the current magic number.
         // There's a regression whenever `uncapturedOutput` is greater than the current magic number.
         #if os(macOS)
-        XCTAssertEqual(uncapturedOutput, 67043)
+        XCTAssertEqual(uncapturedOutput, 14922)
         #else
-        XCTAssertEqual(uncapturedOutput, 67611)
+        XCTAssertEqual(uncapturedOutput, 15490)
         #endif
     }
 }
diff --git a/Tests/XcbeautifyLibTests/RendererTests/GitHubActionsRendererTests.swift b/Tests/XcbeautifyLibTests/RendererTests/GitHubActionsRendererTests.swift
index 51b57ce..4d30b1d 100644
--- a/Tests/XcbeautifyLibTests/RendererTests/GitHubActionsRendererTests.swift
+++ b/Tests/XcbeautifyLibTests/RendererTests/GitHubActionsRendererTests.swift
@@ -2,39 +2,44 @@ import XCTest
 @testable import XcbeautifyLib
 
 final class GitHubActionsRendererTests: XCTestCase {
-    var parser: Parser!
+    private var parser: Parser!
+    private var formatter: XcbeautifyLib.Formatter!
 
     override func setUpWithError() throws {
         try super.setUpWithError()
-        parser = Parser(colored: false, renderer: .gitHubActions, additionalLines: { nil })
+        parser = Parser()
+        formatter = Formatter(colored: false, renderer: .gitHubActions, additionalLines: { nil })
+    }
+
+    override func tearDownWithError() throws {
+        parser = nil
+        formatter = nil
+        try super.tearDownWithError()
     }
 
     private func logFormatted(_ string: String) -> String? {
-        parser.parse(line: string)
+        guard let captureGroup = parser.parse(line: string) else { return nil }
+        return formatter.format(captureGroup: captureGroup)
     }
 
     func testAggregateTarget() {
         let formatted = logFormatted("=== BUILD AGGREGATE TARGET Be Aggro OF PROJECT AggregateExample WITH CONFIGURATION Debug ===")
         XCTAssertEqual(formatted, "Aggregate target Be Aggro of project AggregateExample with configuration Debug")
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testAnalyze() {
         let formatted = logFormatted("AnalyzeShallow /Users/admin/CocoaLumberjack/Classes/DDTTYLogger.m normal x86_64 (in target: CocoaLumberjack-Static)")
         XCTAssertEqual(formatted, "[CocoaLumberjack-Static] Analyzing DDTTYLogger.m")
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testAnalyzeTarget() {
         let formatted = logFormatted("=== ANALYZE TARGET The Spacer OF PROJECT Pods WITH THE DEFAULT CONFIGURATION Debug ===")
         XCTAssertEqual(formatted, "Analyze target The Spacer of project Pods with configuration Debug")
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testBuildTarget() {
         let formatted = logFormatted("=== BUILD TARGET The Spacer OF PROJECT Pods WITH THE DEFAULT CONFIGURATION Debug ===")
         XCTAssertEqual(formatted, "Build target The Spacer of project Pods with configuration Debug")
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testCheckDependenciesErrors() { }
@@ -48,31 +53,26 @@ final class GitHubActionsRendererTests: XCTestCase {
     func testClangError() {
         let formatted = logFormatted("clang: error: linker command failed with exit code 1 (use -v to see invocation)")
         XCTAssertEqual(formatted, "::error ::clang: error: linker command failed with exit code 1 (use -v to see invocation)")
-        XCTAssertEqual(parser.outputType, .error)
     }
 
     func testCleanRemove() {
         let formatted = logFormatted("Clean.Remove clean /Users/admin/Library/Developer/Xcode/DerivedData/MyLibrary-abcd/Build/Intermediates/MyLibrary.build/Debug-iphonesimulator/MyLibraryTests.build")
         XCTAssertEqual(formatted, "Cleaning MyLibraryTests.build")
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testCleanTarget() {
         let formatted = logFormatted("=== ANALYZE TARGET The Spacer OF PROJECT Pods WITH THE DEFAULT CONFIGURATION Debug ===")
         XCTAssertEqual(formatted, "Analyze target The Spacer of project Pods with configuration Debug")
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testCodesignFramework() {
         let formatted = logFormatted("CodeSign build/Release/MyFramework.framework/Versions/A")
         XCTAssertEqual(formatted, "Signing build/Release/MyFramework.framework")
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testCodesign() {
         let formatted = logFormatted("CodeSign build/Release/MyApp.app")
         XCTAssertEqual(formatted, "Signing MyApp.app")
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testMultipleCodesigns() {
@@ -80,7 +80,6 @@ final class GitHubActionsRendererTests: XCTestCase {
         let formattedFramework = logFormatted("CodeSign build/Release/MyFramework.framework/Versions/A (in target 'X' from project 'Y')")
         XCTAssertEqual(formattedApp, "Signing MyApp.app")
         XCTAssertEqual(formattedFramework, "Signing build/Release/MyFramework.framework")
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testCompileCommand() { }
@@ -89,12 +88,10 @@ final class GitHubActionsRendererTests: XCTestCase {
         let inputError = "/path/file.swift:64:69: error: cannot find 'input' in scope"
         let outputError = "::error file=/path/file.swift,line=64,col=69::cannot find 'input' in scope\n\n"
         XCTAssertEqual(logFormatted(inputError), outputError)
-        XCTAssertEqual(parser.outputType, .error)
 
         let inputFatal = "/path/file.swift:64:69: fatal error: cannot find 'input' in scope"
         let outputFatal = "::error file=/path/file.swift,line=64,col=69::cannot find 'input' in scope\n\n"
         XCTAssertEqual(logFormatted(inputFatal), outputFatal)
-        XCTAssertEqual(parser.outputType, .error)
     }
 
     func testCompile() {
@@ -106,7 +103,6 @@ final class GitHubActionsRendererTests: XCTestCase {
         let output = "[xcbeautify] Compiling setup.swift"
         XCTAssertEqual(logFormatted(input1), output)
         XCTAssertEqual(logFormatted(input2), output)
-        XCTAssertEqual(parser.outputType, .task)
         #endif
     }
 
@@ -130,49 +126,42 @@ final class GitHubActionsRendererTests: XCTestCase {
     func testCompileStoryboard() {
         let formatted = logFormatted("CompileStoryboard /Users/admin/MyApp/MyApp/Main.storyboard (in target: MyApp)")
         XCTAssertEqual(formatted, "[MyApp] Compiling Main.storyboard")
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testCompileWarning() {
         let input = "/path/file.swift:64:69: warning: 'flatMap' is deprecated: Please use compactMap(_:) for the case where closure returns an optional value"
         let output = "::warning file=/path/file.swift,line=64,col=69::'flatMap' is deprecated: Please use compactMap(_:) for the case where closure returns an optional value\n\n"
         XCTAssertEqual(logFormatted(input), output)
-        XCTAssertEqual(parser.outputType, .warning)
     }
 
     func testCompileXib() {
         let input = "CompileXIB /path/file.xib (in target 'MyApp' from project 'MyProject')"
         let output = "[MyApp] Compiling file.xib"
         XCTAssertEqual(logFormatted(input), output)
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testCopyHeader() {
         let input = "CpHeader /path/to/destination/file.h /path/file.h (in target 'MyApp' from project 'MyProject')"
         let output = "[MyApp] Copying file.h"
         XCTAssertEqual(logFormatted(input), output)
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testCopyPlist() {
         let input = "CopyPlistFile /path/to/destination/file.plist /path/file.plist (in target 'MyApp' from project 'MyProject')"
         let output = "[MyApp] Copying file.plist"
         XCTAssertEqual(logFormatted(input), output)
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testCopyStrings() {
         let input = "CopyStringsFile /path/to/destination/file.strings /path/file.strings (in target 'MyApp' from project 'MyProject')"
         let output = "[MyApp] Copying file.strings"
         XCTAssertEqual(logFormatted(input), output)
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testCpresource() {
         let input = "CpResource /path/to/destination/file.ttf /path/file.ttf (in target 'MyApp' from project 'MyProject')"
         let output = "[MyApp] Copying file.ttf"
         XCTAssertEqual(logFormatted(input), output)
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testCursor() { }
@@ -221,57 +210,48 @@ final class GitHubActionsRendererTests: XCTestCase {
         let input = "fatal error: malformed or corrupted AST file: 'could not find file '/path/file.h' referenced by AST file' note: after modifying system headers, please delete the module cache at '/path/DerivedData/ModuleCache/M5WJ0FYE7N06'"
         let output = "::error ::fatal error: malformed or corrupted AST file: 'could not find file '/path/file.h' referenced by AST file' note: after modifying system headers, please delete the module cache at '/path/DerivedData/ModuleCache/M5WJ0FYE7N06'"
         XCTAssertEqual(logFormatted(input), output)
-        XCTAssertEqual(parser.outputType, .error)
     }
 
     func testFileMissingError() {
         let input = "<unknown>:0: error: no such file or directory: '/path/file.swift'"
         let output = "::error file=/path/file.swift::error: no such file or directory:"
         XCTAssertEqual(logFormatted(input), output)
-        XCTAssertEqual(parser.outputType, .error)
     }
 
     func testGenerateCoverageData() {
         let formatted = logFormatted("Generating coverage data...")
         XCTAssertEqual(formatted, "Generating code coverage data...")
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testGeneratedCoverageReport() {
         let formatted = logFormatted("Generated coverage report: /path/to/code coverage.xccovreport")
         XCTAssertEqual(formatted, "Generated code coverage report: /path/to/code coverage.xccovreport")
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testGenerateDsym() {
         let input = "GenerateDSYMFile /path/file.dSYM /path/to/file (in target 'MyApp' from project 'MyProject')"
         let output = "[MyApp] Generating file.dSYM"
         XCTAssertEqual(logFormatted(input), output)
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testGenericWarning() {
         let input = "warning: some warning here 123"
         let output = "::warning ::some warning here 123"
         XCTAssertEqual(logFormatted(input), output)
-        XCTAssertEqual(parser.outputType, .warning)
     }
 
     func testLdError() {
         let inputLdLibraryError = "ld: library not found for -lPods-Yammer"
         XCTAssertEqual(logFormatted(inputLdLibraryError), "::error ::ld: library not found for -lPods-Yammer")
-        XCTAssertEqual(parser.outputType, .error)
 
         let inputLdSymbolsError = "ld: symbol(s) not found for architecture x86_64"
         XCTAssertEqual(logFormatted(inputLdSymbolsError), "::error ::ld: symbol(s) not found for architecture x86_64")
-        XCTAssertEqual(parser.outputType, .error)
     }
 
     func testLdWarning() {
         let input = "ld: warning: embedded dylibs/frameworks only run on iOS 8 or later"
         let output = "::warning ::ld: embedded dylibs/frameworks only run on iOS 8 or later"
         XCTAssertEqual(logFormatted(input), output)
-        XCTAssertEqual(parser.outputType, .warning)
     }
 
     func testLibtool() { }
@@ -288,11 +268,9 @@ final class GitHubActionsRendererTests: XCTestCase {
         #if os(macOS)
         let formatted = logFormatted("Ld /Users/admin/Library/Developer/Xcode/DerivedData/xcbeautify-abcd/Build/Products/Debug/xcbeautify normal x86_64 (in target: xcbeautify)")
         XCTAssertEqual(formatted, "[xcbeautify] Linking xcbeautify")
-        XCTAssertEqual(parser.outputType, .task)
 
         let formatted2 = logFormatted("Ld /Users/admin/Library/Developer/Xcode/DerivedData/MyApp-abcd/Build/Intermediates.noindex/ArchiveIntermediates/MyApp/IntermediateBuildFilesPath/MyApp.build/Release-iphoneos/MyApp.build/Objects-normal/armv7/My\\ App normal armv7 (in target: MyApp)")
         XCTAssertEqual(formatted2, "[MyApp] Linking My\\ App")
-        XCTAssertEqual(parser.outputType, .task)
         #endif
     }
 
@@ -303,7 +281,6 @@ final class GitHubActionsRendererTests: XCTestCase {
     func testParallelTestCaseFailed() {
         let formatted = logFormatted("Test case 'XcbeautifyLibTests.testBuildTarget()' failed on 'xctest (49438)' (0.131 seconds)")
         XCTAssertEqual(formatted, "::error ::    testBuildTarget on 'xctest (49438)' (0.131 seconds)")
-        XCTAssertEqual(parser.outputType, .error)
     }
 
     func testParallelTestCasePassed() {
@@ -324,19 +301,16 @@ final class GitHubActionsRendererTests: XCTestCase {
     func testConcurrentDestinationTestCaseFailed() {
         let formatted = logFormatted("Test case 'XcbeautifyLibTests.testBuildTarget()' failed on 'iPhone X' (77.158 seconds)")
         XCTAssertEqual(formatted, "::error ::    testBuildTarget on 'iPhone X' (77.158 seconds)")
-        XCTAssertEqual(parser.outputType, .error)
     }
 
     func testConcurrentDestinationTestCasePassed() {
         let formatted = logFormatted("Test case 'XcbeautifyLibTests.testBuildTarget()' passed on 'iPhone X' (77.158 seconds)")
         XCTAssertEqual(formatted, "    ✔ [XcbeautifyLibTests] testBuildTarget on 'iPhone X' (77.158 seconds)")
-        XCTAssertEqual(parser.outputType, .testCase)
     }
 
     func testParallelTestCaseAppKitPassed() {
         let formatted = logFormatted("Test case '-[XcbeautifyLibTests.XcbeautifyLibTests testBuildTarget]' passed on 'xctest (49438)' (0.131 seconds).")
         XCTAssertEqual(formatted, "    ✔ [XcbeautifyLibTests.XcbeautifyLibTests] testBuildTarget (0.131 seconds)")
-        XCTAssertEqual(parser.outputType, .testCase)
     }
 
     func testParallelTestingStarted() {
@@ -417,38 +391,32 @@ final class GitHubActionsRendererTests: XCTestCase {
         let input = #"MyProject requires a provisioning profile. Select a provisioning profile for the "Debug" build configuration in the project editor."#
         let output = #"::error ::MyProject requires a provisioning profile. Select a provisioning profile for the "Debug" build configuration in the project editor."#
         XCTAssertEqual(logFormatted(input), output)
-        XCTAssertEqual(parser.outputType, .error)
     }
 
     func testRestartingTests() {
         let formatted = logFormatted("Restarting after unexpected exit, crash, or test timeout in HomePresenterTest.testIsCellPresented(); summary will include totals from previous launches.")
         XCTAssertEqual(formatted, "::error ::    Restarting after unexpected exit, crash, or test timeout in HomePresenterTest.testIsCellPresented(); summary will include totals from previous launches.")
-        XCTAssertEqual(parser.outputType, .test)
     }
 
     func testShellCommand() {
         let formatted = logFormatted("    cd /foo/bar/baz")
         XCTAssertNil(formatted)
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testSymbolReferencedFrom() {
         let formatted = logFormatted("  \"NetworkBusiness.ImageDownloadManager.saveImage(image: __C.UIImage, needWatermark: Swift.Bool, params: [Swift.String : Any], downloadHandler: (Swift.Bool) -> ()?) -> ()\", referenced from:")
         XCTAssertEqual(formatted, "::error ::  \"NetworkBusiness.ImageDownloadManager.saveImage(image: __C.UIImage, needWatermark: Swift.Bool, params: [Swift.String : Any], downloadHandler: (Swift.Bool) -> ()?) -> ()\", referenced from:")
-        XCTAssertEqual(parser.outputType, .error)
     }
 
     func testUndefinedSymbolLocation() {
         let formatted = logFormatted("      MediaBrowser.ChatGalleryViewController.downloadImage() -> () in MediaBrowser(ChatGalleryViewController.o)")
         XCTAssertEqual(formatted, "::warning ::      MediaBrowser.ChatGalleryViewController.downloadImage() -> () in MediaBrowser(ChatGalleryViewController.o)")
-        XCTAssertEqual(parser.outputType, .warning)
     }
 
     func testTestCaseMeasured() {
         #if os(macOS)
         let formatted = logFormatted(#"/Users/cyberbeni/Desktop/framework/TypedNotificationCenter/<compiler-generated>:54: Test Case '-[TypedNotificationCenterPerformanceTests.BridgedNotificationTests test_subscribing_2senders_notificationName]' measured [High Water Mark For Heap Allocations, KB] average: 5407.634, relative standard deviation: 45.772%, values: [9341.718750, 3779.468750, 3779.468750, 9630.344727, 3779.468750, 3779.468750, 3895.093750, 3779.468750, 8532.372070, 3779.468750], performanceMetricID:com.apple.XCTPerformanceMetric_HighWaterMarkForHeapAllocations, baselineName: "", baselineAverage: , polarity: prefers smaller, maxPercentRegression: 10.000%, maxPercentRelativeStandardDeviation: 10.000%, maxRegression: 1.000, maxStandardDeviation: 1.000"#)
         XCTAssertEqual(formatted, #"    ◷ test_subscribing_2senders_notificationName measured (5407.634 KB ±45.772% -- High Water Mark For Heap Allocations)"#)
-        XCTAssertEqual(parser.outputType, .testCase)
         #endif
     }
 
@@ -456,7 +424,6 @@ final class GitHubActionsRendererTests: XCTestCase {
         #if os(macOS)
         let formatted = logFormatted("Test Case '-[XcbeautifyLibTests.XcbeautifyLibTests testBuildTarget]' passed (0.131 seconds).")
         XCTAssertEqual(formatted, "    ✔ testBuildTarget (0.131 seconds)")
-        XCTAssertEqual(parser.outputType, .testCase)
         #endif
     }
 
@@ -496,26 +463,22 @@ final class GitHubActionsRendererTests: XCTestCase {
     func testTiffutil() {
         let input = "TiffUtil file.tiff"
         XCTAssertNil(logFormatted(input))
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testTouch() {
         let formatted = logFormatted("Touch /Users/admin/Library/Developer/Xcode/DerivedData/xcbeautify-dgnqmpfertotpceazwfhtfwtuuwt/Build/Products/Debug/XcbeautifyLib.framework (in target: XcbeautifyLib)")
         XCTAssertEqual(formatted, "[XcbeautifyLib] Touching XcbeautifyLib.framework")
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testUiFailingTest() {
         let formatted = logFormatted("    t =    10.13s Assertion Failure: <unknown>:0: App crashed in <external symbol>")
         XCTAssertEqual(formatted, "::error file=<unknown>,line=0::    App crashed in <external symbol>")
-        XCTAssertEqual(parser.outputType, .error)
     }
 
     func testWillNotBeCodeSigned() {
         let input = "FrameworkName will not be code signed because its settings don't specify a development team."
         let output = "::warning ::FrameworkName will not be code signed because its settings don't specify a development team."
         XCTAssertEqual(logFormatted(input), output)
-        XCTAssertEqual(parser.outputType, .warning)
     }
 
     func testWriteAuxiliaryFileGeneric() {
@@ -533,7 +496,6 @@ final class GitHubActionsRendererTests: XCTestCase {
     func testWriteFile() {
         let input = "write-file /path/file.SwiftFileList"
         XCTAssertNil(logFormatted(input))
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testPackageFetching() {
@@ -541,19 +503,16 @@ final class GitHubActionsRendererTests: XCTestCase {
         let output1 = "Fetching https://github.com/cpisciotta/xcbeautify"
         let formatted1 = logFormatted(input1)
         XCTAssertEqual(formatted1, output1)
-        XCTAssertEqual(parser.outputType, .task)
 
         let input2 = "Fetching from https://github.com/cpisciotta/xcbeautify (cached)"
         let output2 = "Fetching https://github.com/cpisciotta/xcbeautify (cached)"
         let formatted2 = logFormatted(input2)
         XCTAssertEqual(formatted2, output2)
-        XCTAssertEqual(parser.outputType, .task)
 
         let input3 = "Fetching from https://github.com/cpisciotta/xcbeautify.git"
         let output3 = "Fetching https://github.com/cpisciotta/xcbeautify.git"
         let formatted3 = logFormatted(input3)
         XCTAssertEqual(formatted3, output3)
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testPackageUpdating() {
@@ -561,55 +520,46 @@ final class GitHubActionsRendererTests: XCTestCase {
         let output1 = "Updating https://github.com/cpisciotta/xcbeautify"
         let formatted1 = logFormatted(input1)
         XCTAssertEqual(formatted1, output1)
-        XCTAssertEqual(parser.outputType, .task)
 
         let input2 = "Updating from https://github.com/cpisciotta/xcbeautify (cached)"
         let output2 = "Updating https://github.com/cpisciotta/xcbeautify (cached)"
         let formatted2 = logFormatted(input2)
         XCTAssertEqual(formatted2, output2)
-        XCTAssertEqual(parser.outputType, .task)
 
         let input3 = "Updating from https://github.com/cpisciotta/xcbeautify.git"
         let output3 = "Updating https://github.com/cpisciotta/xcbeautify.git"
         let formatted3 = logFormatted(input3)
         XCTAssertEqual(formatted3, output3)
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testPackageCheckingOut() {
         let input1 = "Cloning local copy of package 'xcbeautify'"
         let formatted1 = logFormatted(input1)
         XCTAssertNil(formatted1)
-        XCTAssertEqual(parser.outputType, .undefined)
 
         let input2 = "Checking out x.y.z of package 'xcbeautify'"
         let output2 = "Checking out 'xcbeautify' @ x.y.z"
         let formatted2 = logFormatted(input2)
         XCTAssertEqual(formatted2, output2)
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testPackageGraphResolved() {
         // Start
         let start = logFormatted("Resolve Package Graph")
         XCTAssertEqual(start, "Resolving Package Graph")
-        XCTAssertEqual(parser.outputType, .task)
 
         // Ended
         let ended = logFormatted("Resolved source packages:")
         XCTAssertEqual(ended, "Resolved source packages")
-        XCTAssertEqual(parser.outputType, .task)
 
         // Package
         let package = logFormatted("  StrasbourgParkAPI: https://github.com/yageek/StrasbourgParkAPI.git @ 3.0.2")
         XCTAssertEqual(package, "StrasbourgParkAPI - https://github.com/yageek/StrasbourgParkAPI.git @ 3.0.2")
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testXcodebuildError() {
         let formatted = logFormatted("xcodebuild: error: Existing file at -resultBundlePath \"/output/file.xcresult\"")
         XCTAssertEqual(formatted, "::error ::xcodebuild: error: Existing file at -resultBundlePath \"/output/file.xcresult\"")
-        XCTAssertEqual(parser.outputType, .error)
     }
 
     func testXcodeprojError() {
@@ -622,7 +572,6 @@ final class GitHubActionsRendererTests: XCTestCase {
 
         // Then
         XCTAssertEqual(actualFormatted, expectedFormatted)
-        XCTAssertEqual(parser.outputType, .error)
     }
 
     func testXcodeprojWarning() {
@@ -635,12 +584,10 @@ final class GitHubActionsRendererTests: XCTestCase {
 
         // Then
         XCTAssertEqual(actualFormatted, expectedFormatted)
-        XCTAssertEqual(parser.outputType, .warning)
     }
 
     func testDuplicateLocalizedStringKey() {
         let formatted = logFormatted(#"2022-12-07 16:26:40 --- WARNING: Key "duplicate" used with multiple values. Value "First" kept. Value "Second" ignored."#)
         XCTAssertEqual(formatted, #"::warning ::Key "duplicate" used with multiple values. Value "First" kept. Value "Second" ignored."#)
-        XCTAssertEqual(parser.outputType, .warning)
     }
 }
diff --git a/Tests/XcbeautifyLibTests/RendererTests/TeamCityRendererTests.swift b/Tests/XcbeautifyLibTests/RendererTests/TeamCityRendererTests.swift
index acc6e9c..732579f 100644
--- a/Tests/XcbeautifyLibTests/RendererTests/TeamCityRendererTests.swift
+++ b/Tests/XcbeautifyLibTests/RendererTests/TeamCityRendererTests.swift
@@ -2,15 +2,24 @@ import XCTest
 @testable import XcbeautifyLib
 
 final class TeamCityRendererTests: XCTestCase {
-    var parser: Parser!
+    private var parser: Parser!
+    private var formatter: XcbeautifyLib.Formatter!
 
     override func setUpWithError() throws {
         try super.setUpWithError()
-        parser = Parser(colored: false, renderer: .teamcity, additionalLines: { nil })
+        parser = Parser()
+        formatter = Formatter(colored: false, renderer: .teamcity, additionalLines: { nil })
+    }
+
+    override func tearDownWithError() throws {
+        parser = nil
+        formatter = nil
+        try super.tearDownWithError()
     }
 
     private func noColoredFormatted(_ string: String) -> String? {
-        parser.parse(line: string)
+        guard let captureGroup = parser.parse(line: string) else { return nil }
+        return formatter.format(captureGroup: captureGroup)
     }
 
     func testAggregateTarget() {
@@ -399,7 +408,6 @@ final class TeamCityRendererTests: XCTestCase {
         let input = #"MyProject requires a provisioning profile. Select a provisioning profile for the "Debug" build configuration in the project editor."#
         let output = "##teamcity[message text=\'Build error\' errorDetails=\'|[x|] MyProject requires a provisioning profile. Select a provisioning profile for the \"Debug\" build configuration in the project editor.\' status=\'ERROR\']\nBuild error"
         XCTAssertEqual(noColoredFormatted(input), output)
-        XCTAssertEqual(parser.outputType, .error)
     }
 
     func testRestartingTests() {
@@ -410,19 +418,16 @@ final class TeamCityRendererTests: XCTestCase {
     func testShellCommand() {
         let formatted = noColoredFormatted("    cd /foo/bar/baz")
         XCTAssertNil(formatted)
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testSymbolReferencedFrom() {
         let formatted = noColoredFormatted("  \"NetworkBusiness.ImageDownloadManager.saveImage(image: __C.UIImage, needWatermark: Swift.Bool, params: [Swift.String : Any], downloadHandler: (Swift.Bool) -> ()?) -> ()\", referenced from:")
         XCTAssertEqual(formatted, "[x]   \"NetworkBusiness.ImageDownloadManager.saveImage(image: __C.UIImage, needWatermark: Swift.Bool, params: [Swift.String : Any], downloadHandler: (Swift.Bool) -> ()?) -> ()\", referenced from:")
-        XCTAssertEqual(parser.outputType, .error)
     }
 
     func testUndefinedSymbolLocation() {
         let formatted = noColoredFormatted("      MediaBrowser.ChatGalleryViewController.downloadImage() -> () in MediaBrowser(ChatGalleryViewController.o)")
         XCTAssertEqual(formatted, "##teamcity[message text=\'Undefined symbol location|n|[!|]       MediaBrowser.ChatGalleryViewController.downloadImage() -> () in MediaBrowser(ChatGalleryViewController.o)\' status=\'WARNING\']\nUndefined symbol location")
-        XCTAssertEqual(parser.outputType, .warning)
     }
 
     func testTestCaseMeasured() {
@@ -468,7 +473,6 @@ final class TeamCityRendererTests: XCTestCase {
     func testTiffutil() {
         let input = "TiffUtil file.tiff"
         XCTAssertNil(noColoredFormatted(input))
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testTouch() {
@@ -502,7 +506,6 @@ final class TeamCityRendererTests: XCTestCase {
     func testWriteFile() {
         let input = "write-file /path/file.SwiftFileList"
         XCTAssertNil(noColoredFormatted(input))
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testPackageFetching() {
@@ -579,7 +582,6 @@ final class TeamCityRendererTests: XCTestCase {
 
         // Then
         XCTAssertEqual(actualFormatted, expectedFormatted)
-        XCTAssertEqual(parser.outputType, .error)
     }
 
     func testXcodeprojWarning() {
@@ -592,13 +594,11 @@ final class TeamCityRendererTests: XCTestCase {
 
         // Then
         XCTAssertEqual(actualFormatted, expectedFormatted)
-        XCTAssertEqual(parser.outputType, .warning)
     }
 
     func testDuplicateLocalizedStringKey() {
         let formatted = noColoredFormatted(#"2022-12-07 16:26:40 --- WARNING: Key "duplicate" used with multiple values. Value "First" kept. Value "Second" ignored."#)
         XCTAssertEqual(formatted, "##teamcity[message text=\'Duplicated localized string key|n|[!|] Key \"duplicate\" used with multiple values. Value \"First\" kept. Value \"Second\" ignored.\' status=\'WARNING\']\nDuplicated localized string key")
-        XCTAssertEqual(parser.outputType, .warning)
     }
 
     func testCommandProduceError() {
diff --git a/Tests/XcbeautifyLibTests/RendererTests/TerminalRendererTests.swift b/Tests/XcbeautifyLibTests/RendererTests/TerminalRendererTests.swift
index 57c16cd..33e8be1 100644
--- a/Tests/XcbeautifyLibTests/RendererTests/TerminalRendererTests.swift
+++ b/Tests/XcbeautifyLibTests/RendererTests/TerminalRendererTests.swift
@@ -2,15 +2,24 @@ import XCTest
 @testable import XcbeautifyLib
 
 final class TerminalRendererTests: XCTestCase {
-    var parser: Parser!
+    private var parser: Parser!
+    private var formatter: XcbeautifyLib.Formatter!
 
     override func setUpWithError() throws {
         try super.setUpWithError()
-        parser = Parser(colored: false, renderer: .terminal, additionalLines: { nil })
+        parser = Parser()
+        formatter = Formatter(colored: false, renderer: .terminal, additionalLines: { nil })
+    }
+
+    override func tearDownWithError() throws {
+        parser = nil
+        formatter = nil
+        try super.tearDownWithError()
     }
 
     private func noColoredFormatted(_ string: String) -> String? {
-        parser.parse(line: string)
+        guard let captureGroup = parser.parse(line: string) else { return nil }
+        return formatter.format(captureGroup: captureGroup)
     }
 
     func testAggregateTarget() {
@@ -401,7 +410,6 @@ final class TerminalRendererTests: XCTestCase {
         let input = #"MyProject requires a provisioning profile. Select a provisioning profile for the "Debug" build configuration in the project editor."#
         let output = #"[x] MyProject requires a provisioning profile. Select a provisioning profile for the "Debug" build configuration in the project editor."#
         XCTAssertEqual(noColoredFormatted(input), output)
-        XCTAssertEqual(parser.outputType, .error)
     }
 
     func testRestartingTests() {
@@ -412,19 +420,16 @@ final class TerminalRendererTests: XCTestCase {
     func testShellCommand() {
         let formatted = noColoredFormatted("    cd /foo/bar/baz")
         XCTAssertNil(formatted)
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testSymbolReferencedFrom() {
         let formatted = noColoredFormatted("  \"NetworkBusiness.ImageDownloadManager.saveImage(image: __C.UIImage, needWatermark: Swift.Bool, params: [Swift.String : Any], downloadHandler: (Swift.Bool) -> ()?) -> ()\", referenced from:")
         XCTAssertEqual(formatted, "[x]   \"NetworkBusiness.ImageDownloadManager.saveImage(image: __C.UIImage, needWatermark: Swift.Bool, params: [Swift.String : Any], downloadHandler: (Swift.Bool) -> ()?) -> ()\", referenced from:")
-        XCTAssertEqual(parser.outputType, .error)
     }
 
     func testUndefinedSymbolLocation() {
         let formatted = noColoredFormatted("      MediaBrowser.ChatGalleryViewController.downloadImage() -> () in MediaBrowser(ChatGalleryViewController.o)")
         XCTAssertEqual(formatted, "[!]       MediaBrowser.ChatGalleryViewController.downloadImage() -> () in MediaBrowser(ChatGalleryViewController.o)")
-        XCTAssertEqual(parser.outputType, .warning)
     }
 
     func testTestCaseMeasured() {
@@ -477,7 +482,6 @@ final class TerminalRendererTests: XCTestCase {
     func testTiffutil() {
         let input = "TiffUtil file.tiff"
         XCTAssertNil(noColoredFormatted(input))
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testTouch() {
@@ -511,7 +515,6 @@ final class TerminalRendererTests: XCTestCase {
     func testWriteFile() {
         let input = "write-file /path/file.SwiftFileList"
         XCTAssertNil(noColoredFormatted(input))
-        XCTAssertEqual(parser.outputType, .task)
     }
 
     func testPackageFetching() {
@@ -592,7 +595,6 @@ final class TerminalRendererTests: XCTestCase {
 
         // Then
         XCTAssertEqual(actualFormatted, expectedFormatted)
-        XCTAssertEqual(parser.outputType, .error)
     }
 
     func testXcodeprojWarning() {
@@ -609,12 +611,10 @@ final class TerminalRendererTests: XCTestCase {
 
         // Then
         XCTAssertEqual(actualFormatted, expectedFormatted)
-        XCTAssertEqual(parser.outputType, .warning)
     }
 
     func testDuplicateLocalizedStringKey() {
         let formatted = noColoredFormatted(#"2022-12-07 16:26:40 --- WARNING: Key "duplicate" used with multiple values. Value "First" kept. Value "Second" ignored."#)
         XCTAssertEqual(formatted, #"[!] Key "duplicate" used with multiple values. Value "First" kept. Value "Second" ignored."#)
-        XCTAssertEqual(parser.outputType, .warning)
     }
 }