Repository: kattouf/ProgressLine Branch: main Commit: 3ef298aeb9d8 Files: 48 Total size: 77.9 KB Directory structure: gitextract_76fia48v/ ├── .github/ │ ├── demo_activity_style.tape │ ├── demo_progressline_output.tape │ ├── demo_standard_output.tape │ └── workflows/ │ ├── checks.yml │ └── semantic-pr-lint.yml ├── .gitignore ├── .sake.yml ├── .swiftformat ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── SakeApp/ │ ├── .gitignore │ ├── BrewCommands.swift │ ├── Package.resolved │ ├── Package.swift │ ├── ReleaseCommands.swift │ └── Sakefile.swift ├── Sources/ │ ├── ANSI.swift │ ├── ActivityIndicator+CommandArgument.swift │ ├── ActivityIndicator.swift │ ├── ErrorMessage.swift │ ├── FileHandler+AsyncStream.swift │ ├── LogAllController.swift │ ├── MatchesController.swift │ ├── OriginalLogController.swift │ ├── Printer.swift │ ├── PrintersHolder.swift │ ├── ProgressLine.swift │ ├── ProgressLineController.swift │ ├── ProgressLineFormatter.swift │ ├── ProgressTracker.swift │ ├── String+ANSI.swift │ ├── Task+Periodic.swift │ ├── UnderProgressLineLogger.swift │ ├── Version.swift │ ├── WindowSizeObserver.swift │ └── isTTY.swift ├── Tests/ │ ├── assert.sh │ ├── integration_tests.sh │ ├── snapshots/ │ │ ├── default.snapshot │ │ ├── default_with_original_log.snapshot │ │ ├── default_with_original_log_original_log.snapshot │ │ ├── log_all.snapshot │ │ ├── log_matches.snapshot │ │ └── static_text.snapshot │ └── test_data_producer.swift └── cliff.toml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/demo_activity_style.tape ================================================ Output out.gif Set Shell "zsh" Set FontSize 32 Set Width 660 Set Height 300 Set TypingSpeed 75ms Type "progressline -s snake" Enter Sleep 8 ================================================ FILE: .github/demo_progressline_output.tape ================================================ Output progressline_output.gif Require progressline Set Shell "zsh" Set FontSize 32 Set Width 1600 Set Height 400 Set TypingSpeed 75ms Hide Type "make long-running-command" Show Sleep 1 Type " | progressline" Enter Sleep 11 ================================================ FILE: .github/demo_standard_output.tape ================================================ Output standard_output.gif Require progressline Set Shell "zsh" Set FontSize 32 Set Width 1600 Set Height 400 Set TypingSpeed 75ms Hide Type "make long-running-command" Show Sleep 1 Enter Sleep 11 ================================================ FILE: .github/workflows/checks.yml ================================================ name: Checks on: push: branches: - main pull_request: branches: - main jobs: build: name: Integration tests strategy: matrix: os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: swift-actions/setup-swift@v2 with: swift-version: "5.10" - name: Prepare test build run: swift build - name: Run tests run: ./Tests/integration_tests.sh .build/debug/progressline lint: runs-on: macos-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Run SwiftFormat Linting run: swiftformat Sources SakeApp Package.swift --lint ================================================ FILE: .github/workflows/semantic-pr-lint.yml ================================================ name: "Lint PR" on: pull_request_target: types: - opened - edited - synchronize - reopened permissions: pull-requests: read jobs: main: name: Validate PR title runs-on: ubuntu-latest steps: - uses: amannn/action-semantic-pull-request@v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: types: | fix feat chore feat fix test perf refactor doc project revert ================================================ FILE: .gitignore ================================================ .DS_Store /.build /.index-build /Packages xcuserdata/ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc TODO.md ================================================ FILE: .sake.yml ================================================ case_converting_strategy: toSnakeCase ================================================ FILE: .swiftformat ================================================ --swiftversion 5.10 --exclude .build,.index-build,SakeApp/.build,SakeApp/.index-build --maxwidth 140 --wraparguments before-first --wrapparameters before-first --wrapcollections before-first --enable isEmpty,wrapSwitchCases,wrapConditionalBodies,wrapEnumCases --disable redundantRawValues,redundantSelf,redundantStaticSelf,redundantType ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 Vasilii Ianguzin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Package.resolved ================================================ { "originHash" : "e759c45271facbb3650829c703702a2ac4817adf75a8116cc3d77eae8e3d3bae", "pins" : [ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { "revision" : "0fbc8848e389af3bb55c182bc19ca9d5dc2f255b", "version" : "1.4.0" } }, { "identity" : "swift-concurrency-extras", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras.git", "state" : { "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", "version" : "1.1.0" } }, { "identity" : "swift-tagged", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-tagged.git", "state" : { "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", "version" : "0.10.0" } } ], "version" : 3 } ================================================ FILE: Package.swift ================================================ // swift-tools-version: 5.10 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "ProgressLine", platforms: [ .macOS(.v10_15), ], products: [ .executable(name: "progressline", targets: ["progressline"]), ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.4.0"), .package(url: "https://github.com/pointfreeco/swift-tagged.git", from: "0.10.0"), .package(url: "https://github.com/pointfreeco/swift-concurrency-extras.git", from: "1.1.0"), ], targets: [ .executableTarget( name: "progressline", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "TaggedTime", package: "swift-tagged"), .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), ], swiftSettings: [ .enableExperimentalFeature("StrictConcurrency"), ] ), ] ) ================================================ FILE: README.md ================================================ ## ProgressLine ![](https://img.shields.io/badge/Platform-macOS-6464aa) ![](https://img.shields.io/badge/Platform-Linux-6464aa) [![Latest Release](https://img.shields.io/github/release/kattouf/ProgressLine.svg)](https://github.com/kattouf/ProgressLine/releases/latest) ![](https://github.com/kattouf/ProgressLine/actions/workflows/checks.yml/badge.svg?branch=main) Track commands progress in a compact one-line format. | ⏳ `progressline` output | |:--:| | ![](./.github/progressline_output.gif) | | 📝 standard output | |:--:| | ![](./.github/standard_output.gif) | [Usage](#usage) • [Features](#features) • [Installation](#installation) ## Usage Simply pipe your command output into `progressline` to start tracking: ```sh long-running-command | progressline ``` If the command you are executing also writes data to `stderr`, then you should probably use ["redirection"](https://www.gnu.org/software/bash/manual/html_node/Redirections.html) and send `stderr` messages to `stdout` so that they also go through the `progressline`: ``` sh long-running-command 2>&1 | progressline ``` ## Features ### Change activity indicator styles ProgressLine offers different styles to represent activity, they can be changed using `-s, --activity-style` option: ``` sh long-running-command | progressline --activity-style snake ``` Available styles: | dots (Default) | snake | [kitt](https://en.wikipedia.org/wiki/KITT) | spinner | |:--:|:--:|:--:|:--:| | ![](./.github/activity_style_dots.gif) | ![](./.github/activity_style_snake.gif) | ![](./.github/activity_style_kitt.gif) | ![](./.github/activity_style_spinner.gif) | ### Replace log output with custom text If you don't need to see the log output during execution, even in a single line, you can replace it with your own text using the `-t, --static-text` option. ``` sh long-running-command | progressline --static-text "Updating sources..." ``` ### Highlight important lines Log specific stdin lines above the progress line using the `-m, --log-matches` option: ``` sh long-running-command | progressline --log-matches "regex-1" --log-matches "regex-2" ``` ### Use progress line as an addition to standard output Log all stdin data above the progress line using the `-a, --log-all` option: ```sh long-running-command | progressline --log-all ``` ### Save original log You have two options for saving the full original log: 1. Using [tee](https://en.wikipedia.org/wiki/Tee_(command)) ``` sh long-running-command | tee original-log.txt | progressline ``` 2. Using `-l, --original-log-path` option: ``` sh long-running-command | progressline --original-log-path original-log.txt ``` ## Installation ### [Homebrew](https://brew.sh) (macOS / Linux) ``` sh brew install progressline ``` ### [Mint](https://github.com/yonaskolb/Mint) (macOS) ``` sh mint install kattouf/ProgressLine ``` ### [Mise](https://mise.jdx.dev) (macOS) ``` sh mise use -g spm:kattouf/ProgressLine ``` ### Manual Installation (macOS / Linux) Download the binary for your platform from the [releases page](https://github.com/kattouf/ProgressLine/releases), and place it in your executable path. ## Contributing Feel free to open a pull request or a discussion. ================================================ FILE: SakeApp/.gitignore ================================================ .DS_Store /.build /.index-build /Packages xcuserdata/ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc ================================================ FILE: SakeApp/BrewCommands.swift ================================================ import Sake import SwiftShell @CommandGroup struct BrewCommands { static var ensureSwiftFormatInstalled: Command { Command( description: "Ensure swiftformat is installed", skipIf: { _ in run("which", "swiftformat").succeeded }, run: { _ in try runAndPrint("brew", "install", "swiftformat") } ) } static var ensureGhInstalled: Command { Command( description: "Ensure gh is installed", skipIf: { _ in run("which", "gh").succeeded }, run: { _ in try runAndPrint("brew", "install", "gh") } ) } static var ensureGitCliffInstalled: Command { Command( description: "Ensure git-cliff is installed", skipIf: { _ in run("which", "git-cliff").succeeded }, run: { _ in try runAndPrint("brew", "install", "git-cliff") } ) } } ================================================ FILE: SakeApp/Package.resolved ================================================ { "originHash" : "564ae29a93959e0a64ff9ad1a401e5db179007f3ecef20571f852606328631f6", "pins" : [ { "identity" : "sake", "kind" : "remoteSourceControl", "location" : "https://github.com/kattouf/Sake", "state" : { "revision" : "f2c91c8ecb4f67f0c565b081deb7d180761a21d7", "version" : "0.2.2" } }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { "revision" : "41982a3656a71c768319979febd796c6fd111d5c", "version" : "1.5.0" } }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax", "state" : { "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", "version" : "509.1.1" } }, { "identity" : "swiftshell", "kind" : "remoteSourceControl", "location" : "https://github.com/kareman/SwiftShell", "state" : { "revision" : "99680b2efc7c7dbcace1da0b3979d266f02e213c", "version" : "5.1.0" } }, { "identity" : "yams", "kind" : "remoteSourceControl", "location" : "https://github.com/jpsim/Yams.git", "state" : { "revision" : "3036ba9d69cf1fd04d433527bc339dc0dc75433d", "version" : "5.1.3" } } ], "version" : 3 } ================================================ FILE: SakeApp/Package.swift ================================================ // swift-tools-version: 5.10 // The swift-tools-version declares the minimum version of Swift required to build this package. import CompilerPluginSupport import PackageDescription let package = Package( name: "SakeApp", platforms: [.macOS(.v10_15)], // Required by SwiftSyntax for the macro feature in Sake products: [ .executable(name: "SakeApp", targets: ["SakeApp"]), ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"), .package(url: "https://github.com/kattouf/Sake", from: "0.1.0"), .package(url: "https://github.com/kareman/SwiftShell", from: "5.1.0"), ], targets: [ .executableTarget( name: "SakeApp", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), "Sake", "SwiftShell", ], path: "." ), ] ) ================================================ FILE: SakeApp/ReleaseCommands.swift ================================================ import ArgumentParser import CryptoKit import Foundation import Sake import SwiftShell @CommandGroup struct ReleaseCommands { private struct BuildTarget { enum Arch { case x86 case arm } enum OS { case macos case linux } let arch: Arch let os: OS var triple: String { switch (arch, os) { case (.x86, .macos): "x86_64-apple-macosx" case (.arm, .macos): "arm64-apple-macosx" case (.x86, .linux): "x86_64-unknown-linux-gnu" case (.arm, .linux): "aarch64-unknown-linux-gnu" } } } private enum Constants { static let buildArtifactsDirectory = ".build/artifacts" static let swiftVersion = "6.0" static let buildTargets: [BuildTarget] = [ .init(arch: .arm, os: .macos), .init(arch: .x86, os: .macos), .init(arch: .x86, os: .linux), .init(arch: .arm, os: .linux), ] static let executableName = "progressline" } private struct ReleaseArguments: ParsableArguments { @Argument(help: "Version number") var version: String func validate() throws { guard version.range(of: #"^\d+\.\d+\.\d+$"#, options: .regularExpression) != nil else { throw ValidationError("Invalid version number. Should be in the format 'x.y.z'") } } } public static var brewRelease: Command { Command( description: "Brew to Homebrew", run: { context in let arguments = try ReleaseArguments.parse(context.arguments) try arguments.validate() let version = arguments.version try runAndPrint("brew", "bump-formula-pr", "--version=\(version)", "progressline") } ) } public static var githubRelease: Command { Command( description: "Release to GitHub", dependencies: [ bumpVersion, cleanReleaseArtifacts, buildReleaseArtifacts, calculateBuildArtifactsSha256, createAndPushTag, generateReleaseNotes, draftReleaseWithArtifacts, ] ) } static var bumpVersion: Command { Command( description: "Bump version", skipIf: { context in let arguments = try ReleaseArguments.parse(context.arguments) try arguments.validate() let version = arguments.version let versionFilePath = "Sources/Version.swift" let currentVersion = try String(contentsOfFile: versionFilePath) .split(separator: "\"")[1] if currentVersion == version { print("Version is already \(version). Skipping...") return true } else { return false } }, run: { context in let arguments = try ReleaseArguments.parse(context.arguments) try arguments.validate() let version = arguments.version let versionFilePath = "Sources/Version.swift" let versionFileContent = """ // This file is autogenerated. Do not edit. let progressLineVersion = "\(version)" """ try versionFileContent.write(toFile: versionFilePath, atomically: true, encoding: .utf8) try runAndPrint("git", "add", versionFilePath) try runAndPrint("git", "commit", "-m", "chore(release): Bump version to \(version)") print("Version bumped to \(version)") } ) } static var cleanReleaseArtifacts: Command { Command( description: "Clean release artifacts", run: { _ in try? runAndPrint("rm", "-rf", Constants.buildArtifactsDirectory) } ) } static var buildReleaseArtifacts: Command { Command( description: "Build release artifacts", skipIf: { context in let arguments = try ReleaseArguments.parse(context.arguments) try arguments.validate() let version = arguments.version let targetsWithExistingArtifacts = Constants.buildTargets.filter { target in let archivePath = executableArchivePath(target: target, version: version) return FileManager.default.fileExists(atPath: archivePath) } if targetsWithExistingArtifacts.count == Constants.buildTargets.count { print("Release artifacts already exist. Skipping...") return true } else { context.storage["existing-artifacts-triples"] = targetsWithExistingArtifacts.map(\.triple) return false } }, run: { context in let arguments = try ReleaseArguments.parse(context.arguments) try arguments.validate() let version = arguments.version try FileManager.default.createDirectory( atPath: Constants.buildArtifactsDirectory, withIntermediateDirectories: true, attributes: nil ) let existingArtifactsTriples = context.storage["existing-artifacts-triples"] as? [String] ?? [] for target in Constants.buildTargets { if existingArtifactsTriples.contains(target.triple) { print("Skipping \(target.triple) as artifacts already exist") continue } let (swiftBuild, swiftClean, strip, zip) = { let buildFlags = ["--disable-sandbox", "--configuration", "release", "--triple", target.triple] if target.os == .linux { let platform = target.arch == .arm ? "linux/arm64" : "linux/amd64" let dockerExec = "docker run --rm --volume \(context.projectRoot):/workdir --workdir /workdir --platform \(platform) swift:\(Constants.swiftVersion)" let buildFlags = (buildFlags + ["--static-swift-stdlib"]).joined(separator: " ") return ( "\(dockerExec) swift build \(buildFlags)", "\(dockerExec) swift package clean", "\(dockerExec) strip -s", "zip -j" ) } else { let buildFlags = buildFlags.joined(separator: " ") return ( "swift build \(buildFlags)", "swift package clean", "strip -rSTx", "zip -j" ) } }() try runAndPrint(bash: swiftClean) try runAndPrint(bash: swiftBuild) let binPath: String = run(bash: "\(swiftBuild) --show-bin-path").stdout if binPath.isEmpty { throw NSError(domain: "Fail to get bin path", code: -999) } let executablePath = binPath + "/\(Constants.executableName)" try runAndPrint(bash: "\(strip) \(executablePath)") let executableArchivePath = executableArchivePath(target: target, version: version) try runAndPrint( bash: "\(zip) \(executableArchivePath) \(executablePath.replacingOccurrences(of: "/workdir", with: context.projectRoot))" ) } print("Release artifacts built successfully at '\(Constants.buildArtifactsDirectory)'") } ) } static var calculateBuildArtifactsSha256: Command { @Sendable func shasumFilePath(version: String) -> String { ".build/artifacts/shasum-\(version)" } return Command( description: "Calculate SHA-256 checksums for build artifacts", skipIf: { context in let arguments = try ReleaseArguments.parse(context.arguments) try arguments.validate() let version = arguments.version let shasumFilePath = shasumFilePath(version: version) return FileManager.default.fileExists(atPath: shasumFilePath) }, run: { context in let arguments = try ReleaseArguments.parse(context.arguments) try arguments.validate() let version = arguments.version var shasumResults = [String]() for target in Constants.buildTargets { let archivePath = executableArchivePath(target: target, version: version) let file = FileHandle(forReadingAtPath: archivePath)! let shasum = SHA256.hash(data: file.readDataToEndOfFile()) let shasumString = shasum.compactMap { String(format: "%02x", $0) }.joined() shasumResults.append("\(shasumString) \(archivePath)") } FileManager.default.createFile( atPath: shasumFilePath(version: version), contents: shasumResults.joined(separator: "\n").data(using: .utf8) ) } ) } static var createAndPushTag: Command { Command( description: "Create and push a tag", skipIf: { context in let arguments = try ReleaseArguments.parse(context.arguments) try arguments.validate() let version = arguments.version let grepResult = run(bash: "git tag | grep \(arguments.version)") if grepResult.succeeded { print("Tag \(version) already exists. Skipping...") return true } else { return false } }, run: { context in let arguments = try ReleaseArguments.parse(context.arguments) try arguments.validate() let version = arguments.version print("Creating and pushing tag \(version)") try runAndPrint("git", "tag", version) try runAndPrint("git", "push", "origin", "tag", version) try runAndPrint("git", "push") // push local changes like version bump } ) } static var generateReleaseNotes: Command { Command( description: "Generate release notes", dependencies: [BrewCommands.ensureGitCliffInstalled], skipIf: { context in let arguments = try ReleaseArguments.parse(context.arguments) try arguments.validate() let version = arguments.version let releaseNotesPath = releaseNotesPath(version: version) if FileManager.default.fileExists(atPath: releaseNotesPath) { print("Release notes for \(version) already exist at \(releaseNotesPath). Skipping...") return true } else { return false } }, run: { context in let arguments = try ReleaseArguments.parse(context.arguments) try arguments.validate() let version = arguments.version let releaseNotesPath = releaseNotesPath(version: version) try runAndPrint("git", "cliff", "--latest", "--strip=all", "--tag", version, "--output", releaseNotesPath) print("Release notes generated at \(releaseNotesPath)") } ) } static var draftReleaseWithArtifacts: Command { Command( description: "Draft a release on GitHub", dependencies: [BrewCommands.ensureGhInstalled], skipIf: { context in let arguments = try ReleaseArguments.parse(context.arguments) try arguments.validate() let tagName = arguments.version let ghViewResult = run(bash: "gh release view \(tagName)") if ghViewResult.succeeded { print("Release \(tagName) already exists. Skipping...") return true } else { return false } }, run: { context in let arguments = try ReleaseArguments.parse(context.arguments) try arguments.validate() print("Drafting release \(arguments.version) on GitHub") let tagName = arguments.version let releaseTitle = arguments.version let draftReleaseCommand = "gh release create \(tagName) \(Constants.buildArtifactsDirectory)/*.zip --title '\(releaseTitle)' --draft --verify-tag --notes-file \(releaseNotesPath(version: tagName))" try runAndPrint(bash: draftReleaseCommand) } ) } private static func executableArchivePath(target: BuildTarget, version: String) -> String { "\(Constants.buildArtifactsDirectory)/\(Constants.executableName)-\(version)-\(target.triple).zip" } private static func releaseNotesPath(version: String) -> String { ".build/artifacts/release-notes-\(version).md" } } ================================================ FILE: SakeApp/Sakefile.swift ================================================ import ArgumentParser import Foundation import Sake import SwiftShell @main @CommandGroup struct Commands: SakeApp { public static let configuration = SakeAppConfiguration( commandGroups: [ TestCommands.self, ReleaseCommands.self, ] ) public static var lint: Command { Command( description: "Lint code", dependencies: [BrewCommands.ensureSwiftFormatInstalled], run: { _ in try runAndPrint("swiftformat", "Sources", "SakeApp", "Package.swift", "--lint") } ) } public static var format: Command { Command( description: "Format code", dependencies: [BrewCommands.ensureSwiftFormatInstalled], run: { _ in try runAndPrint("swiftformat", "Sources", "SakeApp", "Package.swift") } ) } } @CommandGroup struct TestCommands { public static var test: Command { Command( description: "Run tests", dependencies: [ensureDebugBuildIsUpToDate], run: { context in try runAndPrint( bash: "\(context.projectRoot)/Tests/integration_tests.sh \(context.projectRoot)/.build/debug/progressline" ) } ) } private static var ensureDebugBuildIsUpToDate: Command { Command( description: "Ensure debug build is up to date", run: { context in try runAndPrint(bash: "swift build --package-path \(context.projectRoot)") } ) } } extension Command.Context { var projectRoot: String { "\(appDirectory)/.." } } ================================================ FILE: Sources/ANSI.swift ================================================ enum ANSI { // Cursor controls static func cursorUp(_ count: Int) -> String { "\u{1B}[\(count)A" } static func cursorToColumn(_ column: Int) -> String { "\u{1B}[\(column)G" } static let eraseLine = "\u{1B}[2K" // Colors and styles static let noStyleMode = !isTTY static var red: String { noStyleMode ? "" : "\u{1B}[31m" } static var green: String { noStyleMode ? "" : "\u{1B}[32m" } static var yellow: String { noStyleMode ? "" : "\u{1B}[33m" } static var blue: String { noStyleMode ? "" : "\u{1B}[34m" } static var magenta: String { noStyleMode ? "" : "\u{1B}[35m" } static var bold: String { noStyleMode ? "" : "\u{1B}[1m" } static var reset: String { noStyleMode ? "" : "\u{1B}[0m" } } ================================================ FILE: Sources/ActivityIndicator+CommandArgument.swift ================================================ import ArgumentParser enum ActivityIndicatorStyle: String, CaseIterable, ExpressibleByArgument { case dots case kitt case snake case spinner } extension ActivityIndicator { static func make(style: ActivityIndicatorStyle) -> ActivityIndicator { switch style { case .dots: .dots case .kitt: .kitt case .snake: .snake case .spinner: .spinner } } } ================================================ FILE: Sources/ActivityIndicator.swift ================================================ import Foundation import TaggedTime final class ActivityIndicator: Sendable { struct Configuration { let refreshRate: Milliseconds let states: [String] } let configuration: Configuration init(configuration: Configuration) { self.configuration = configuration } func state(forDuration duration: Seconds) -> String { let iteration = Int(duration.milliseconds.rawValue / TimeInterval(configuration.refreshRate.rawValue)) % configuration.states.count return configuration.states[iteration] } } extension ActivityIndicator { static let dots: ActivityIndicator = { let configuration = Configuration( refreshRate: 125, states: [ "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", ] ) return ActivityIndicator(configuration: configuration) }() static let kitt: ActivityIndicator = { let configuration = Configuration( refreshRate: 125, states: [ "▰▱▱▱▱", "▰▰▱▱▱", "▰▰▰▱▱", "▱▰▰▰▱", "▱▱▰▰▰", "▱▱▱▰▰", "▱▱▱▱▰", "▱▱▱▰▰", "▱▱▰▰▰", "▱▰▰▰▱", "▰▰▰▱▱", "▰▰▱▱▱", ] ) return ActivityIndicator(configuration: configuration) }() static let snake: ActivityIndicator = { let configuration = Configuration( refreshRate: 125, states: [ "▰▱▱▱▱", "▰▰▱▱▱", "▰▰▰▱▱", "▱▰▰▰▱", "▱▱▰▰▰", "▱▱▱▰▰", "▱▱▱▱▰", "▱▱▱▱▱", ] ) return ActivityIndicator(configuration: configuration) }() static let spinner: ActivityIndicator = { let configuration = Configuration( refreshRate: 125, states: [ "\\", "|", "/", "-", ] ) return ActivityIndicator(configuration: configuration) }() } #if DEBUG extension ActivityIndicator { static func disabled() -> ActivityIndicator { .init( configuration: .init(refreshRate: 1_000_000_000, states: []) ) } } #endif ================================================ FILE: Sources/ErrorMessage.swift ================================================ enum ErrorMessage { static let canNotDecodeData = "\(ANSI.yellow)[!] progressline: Failed to decode stdin data as UTF-8\(ANSI.reset)" static func canNotCompileRegex(_ regex: String) -> String { "\(ANSI.yellow)[!] progressline: Failed to compile regular expression: \(regex)\(ANSI.reset)" } static func canNotOpenFile(_ path: String) -> String { "\(ANSI.yellow)[!] progressline: Failed to open file at path: \(path)\(ANSI.reset)" } } ================================================ FILE: Sources/FileHandler+AsyncStream.swift ================================================ #if os(Linux) // Linux implementation of FileHandle not Sendable @preconcurrency import Foundation #else import Foundation #endif extension FileHandle { var asyncStream: AsyncStream { AsyncStream { continuation in Task { while let data = try waitAndReadAvailableData() { continuation.yield(data) } continuation.finish() } } } private func waitAndReadAvailableData() throws -> Data? { let data = availableData guard !data.isEmpty else { return nil } return data } } ================================================ FILE: Sources/LogAllController.swift ================================================ import Foundation final class LogAllController { private let logger: AboveProgressLineLogger init(logger: AboveProgressLineLogger) { self.logger = logger } func didGetStdinDataChunk(_ data: Data) async { let text = String(data: data, encoding: .utf8) guard let text else { await logger.logError(ErrorMessage.canNotDecodeData) return } // we control newlines and whitespaces in the logger let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) await logger.log(trimmedText) } } ================================================ FILE: Sources/MatchesController.swift ================================================ import Foundation final class MatchesController { private let logger: AboveProgressLineLogger let regexps: [NSRegularExpression] init?(logger: AboveProgressLineLogger, regexps: [String]) async { self.logger = logger guard !regexps.isEmpty else { return nil } var invalidRegexps = [String]() self.regexps = regexps.compactMap { regexp in do { return try NSRegularExpression(pattern: regexp) } catch { invalidRegexps.append(regexp) return nil } } for invalidRegexp in invalidRegexps { await logger.logError(ErrorMessage.canNotCompileRegex(invalidRegexp)) } } func didGetStdinDataChunk(_ data: Data) async { let text = String(data: data, encoding: .utf8) guard let text else { await logger.logError(ErrorMessage.canNotDecodeData) return } for line in text.split(whereSeparator: \.isNewline) { let range = NSRange(location: 0, length: line.utf16.count) for regex in regexps { if regex.firstMatch(in: String(line), range: range) != nil { await logger.log(String(line)) break } } } } } ================================================ FILE: Sources/OriginalLogController.swift ================================================ import Foundation final class OriginalLogController { private let logger: AboveProgressLineLogger let fileHandle: FileHandle init?(logger: AboveProgressLineLogger, path: String) async { self.logger = logger do { let url = URL(fileURLWithPath: path) try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) _ = FileManager.default.createFile(atPath: path, contents: nil) self.fileHandle = try FileHandle(forWritingTo: url) fileHandle.seekToEndOfFile() } catch { await logger.logError(ErrorMessage.canNotOpenFile(path)) return nil } } deinit { fileHandle.closeFile() } func didGetStdinDataChunk(_ data: Data) async { fileHandle.write(data) } } ================================================ FILE: Sources/Printer.swift ================================================ import ConcurrencyExtras #if os(Linux) // Linux implementation of FileHandle not Sendable @preconcurrency import Foundation #else import Foundation #endif final class Printer: Sendable { private let fileHandle: LockIsolated private let buffer = LockIsolated(String()) private let _wasWritten = LockIsolated(false) var wasWritten: Bool { _wasWritten.value } init(fileHandle: FileHandle) { self.fileHandle = .init(fileHandle) } @discardableResult func writeln(_ text: String) -> Self { buffer.withValue { $0 += text + "\n" } return self } @discardableResult func write(_ text: String) -> Self { buffer.withValue { $0 += text } return self } @discardableResult func cursorToColumn(_ column: Int) -> Self { buffer.withValue { $0 += ANSI.cursorToColumn(column) } return self } @discardableResult func cursorUp(_ count: Int = 1) -> Self { buffer.withValue { $0 += ANSI.cursorUp(count) } return self } @discardableResult func eraseLine() -> Self { buffer.withValue { $0 += ANSI.eraseLine } return self } func flush() { fileHandle.withValue { $0.write(buffer.value.data(using: .utf8)!) try? $0.synchronize() } if !_wasWritten.value { _wasWritten.setValue(true) } buffer.setValue(String()) } } ================================================ FILE: Sources/PrintersHolder.swift ================================================ import Foundation // "Lock" access to printers to prevent write conflicts final actor PrintersHolder { private let printer: Printer private let errorsPrinter: Printer init(printer: Printer, errorsPrinter: Printer) { self.printer = printer self.errorsPrinter = errorsPrinter } func withPrinter(_ body: @Sendable (Printer) async throws -> T) async rethrows -> T { try await body(printer) } func withErrorsPrinter(_ body: @Sendable (Printer) async throws -> T) async rethrows -> T { try await body(errorsPrinter) } } ================================================ FILE: Sources/ProgressLine.swift ================================================ import ArgumentParser import ConcurrencyExtras import Foundation import TaggedTime @main struct ProgressLine: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "progressline", abstract: "A command-line tool for compactly tracking the progress of piped commands.", usage: "some-command | progressline", version: progressLineVersion ) @Option(name: [.long, .customShort("t")], help: "The static text to display instead of the latest stdin data.") var staticText: String? @Option(name: [.customLong("activity-style"), .customShort("s")], help: "The style of the activity indicator.") var activityIndicatorStyle: ActivityIndicatorStyle = .dots @Option(name: [.customLong("original-log-path"), .customShort("l")], help: "Save the original log to a file.") var originalLogPath: String? @Option( name: [.customLong("log-matches"), .customShort("m")], help: "Log above progress line lines matching the given regular expressions." ) var matchesToLog: [String] = [] @Flag(name: [.customLong("log-all"), .customShort("a")], help: "Log all lines above the progress line.") var shouldLogAll: Bool = false #if DEBUG @Flag(name: [.customLong("test-mode")], help: "Enable test mode. Activity indicator will be replaced with a static string.") var testMode: Bool = false #endif mutating func run() async throws { try validateConfiguration() let printers = PrintersHolder( printer: Printer(fileHandle: .standardOutput), errorsPrinter: Printer(fileHandle: .standardError) ) let logger = AboveProgressLineLogger(printers: printers) #if DEBUG let activityIndicator: ActivityIndicator = testMode ? .disabled() : .make(style: activityIndicatorStyle) #else let testMode = false let activityIndicator: ActivityIndicator = .make(style: activityIndicatorStyle) #endif let progressLineController = await ProgressLineController.buildAndStart( textMode: staticText.map { .staticText($0) } ?? .stdin, printers: printers, logger: logger, activityIndicator: activityIndicator, mockActivityAndDuration: testMode ) let originalLogController = if let originalLogPath { await OriginalLogController(logger: logger, path: originalLogPath) } else { OriginalLogController?.none } let matchesController = await MatchesController(logger: logger, regexps: matchesToLog) let logAllController = shouldLogAll ? LogAllController(logger: logger) : nil for await data in FileHandle.standardInput.asyncStream { await logAllController?.didGetStdinDataChunk(data) await matchesController?.didGetStdinDataChunk(data) await progressLineController.didGetStdinDataChunk(data) await originalLogController?.didGetStdinDataChunk(data) } await progressLineController.didReachEndOfStdin() } private func validateConfiguration() throws { guard !shouldLogAll || matchesToLog.isEmpty else { throw ValidationError("The --log-all and --log-matches options are mutually exclusive.") } } } ================================================ FILE: Sources/ProgressLineController.swift ================================================ import Foundation import TaggedTime final actor ProgressLineController { enum TextMode { case staticText(String) case stdin } // Dependencies private let textMode: TextMode private let printers: PrintersHolder private let logger: AboveProgressLineLogger private let progressLineFormatter: ProgressLineFormatter private let progressTracker: ProgressTracker // State private var renderLoopTask: Task? private var lastStdinLine: String? private var progress: Progress? private init( textMode: TextMode, printers: PrintersHolder, logger: AboveProgressLineLogger, progressLineFormatter: ProgressLineFormatter, progressTracker: ProgressTracker ) { self.textMode = textMode self.printers = printers self.logger = logger self.progressLineFormatter = progressLineFormatter self.progressTracker = progressTracker } // MARK: - Public static func buildAndStart( textMode: TextMode, printers: PrintersHolder, logger: AboveProgressLineLogger, activityIndicator: ActivityIndicator, mockActivityAndDuration: Bool = false ) async -> Self { let progressTracker = ProgressTracker.start() let windowSizeObserver = WindowSizeObserver.startObserving() let progressLineFormatter = ProgressLineFormatter( activityIndicator: activityIndicator, windowSizeObserver: windowSizeObserver, mockActivityAndDuration: mockActivityAndDuration ) let controller = Self( textMode: textMode, printers: printers, logger: logger, progressLineFormatter: progressLineFormatter, progressTracker: progressTracker ) await controller.startAnimationLoop(refreshRate: activityIndicator.configuration.refreshRate) return controller } // MARK: - Input func didGetStdinDataChunk(_ data: Data) async { guard case .stdin = textMode else { // we will redraw anyway to sync (prevent flickering) with other log controllers await redrawProgressLine() return } let stdinText = String(data: data, encoding: .utf8) guard let stdinText else { await logger.logError(ErrorMessage.canNotDecodeData) return } lastStdinLine = stdinText .split(whereSeparator: \.isNewline) .last { !$0.isEmpty } .map(String.init) await redrawProgressLine() } func didReachEndOfStdin() async { stopAnimationLoop() let progressLine = progressLineFormatter.finished(progress: progress) await printers.withPrinter { printer in if printer.wasWritten { printer .cursorUp() .eraseLine() } printer .writeln(progressLine) .flush() } } // MARK: - Private private func startAnimationLoop(refreshRate: Milliseconds) { renderLoopTask = Task.periodic(interval: refreshRate) { [weak self] in guard !Task.isCancelled else { return } await self?.redrawProgressLine() } } private func stopAnimationLoop() { renderLoopTask?.cancel() } private func redrawProgressLine() async { let lineText: String? = switch textMode { case let .staticText(text): text case .stdin: lastStdinLine } let progress = progressTracker.moveForward(lineText) let progressLine = progressLineFormatter.inProgress(progress: progress) self.progress = progress await printers.withPrinter { printer in if printer.wasWritten { printer .cursorUp() .eraseLine() } printer .writeln(progressLine) .flush() } } } ================================================ FILE: Sources/ProgressLineFormatter.swift ================================================ import Foundation import TaggedTime private enum Symbol { static let checkmark = "✓" static let prompt = "❯" } final class ProgressLineFormatter: Sendable { // Linux doesn't support DateComponentsFormatter #if os(macOS) private let durationFormatter: DateComponentsFormatter = { let durationFormatter = DateComponentsFormatter() durationFormatter.unitsStyle = .abbreviated durationFormatter.allowedUnits = [.hour, .minute, .second] durationFormatter.maximumUnitCount = 2 return durationFormatter }() #endif private let activityIndicator: ActivityIndicator private let windowSizeObserver: WindowSizeObserver? private let mockActivityAndDuration: Bool init( activityIndicator: ActivityIndicator, windowSizeObserver: WindowSizeObserver?, mockActivityAndDuration: Bool ) { self.activityIndicator = activityIndicator self.windowSizeObserver = windowSizeObserver self.mockActivityAndDuration = mockActivityAndDuration } func inProgress(progress: Progress) -> String { let activityIndicator = mockActivityAndDuration ? "" : activityIndicator.state(forDuration: progress.duration) let formattedDuration = mockActivityAndDuration ? "" : formatDuration(from: progress.duration) let styledActivityIndicator = ANSI.blue + activityIndicator + ANSI.reset let styledDuration = ANSI.bold + formattedDuration + ANSI.reset let styledPrompt = ANSI.blue + Symbol.prompt + ANSI.reset return buildResultString( styledActivityIndicator: styledActivityIndicator, styledDuration: styledDuration, styledPrompt: styledPrompt, progressLine: progress.line ) } func finished(progress: Progress?) -> String { let formattedDuration = mockActivityAndDuration ? "" : progress.map { formatDuration(from: $0.duration) } let styledActivityIndicator = ANSI.green + Symbol.checkmark + ANSI.reset let styledDuration = formattedDuration.map { ANSI.bold + $0 + ANSI.reset } let styledPrompt = ANSI.green + Symbol.prompt + ANSI.reset return buildResultString( styledActivityIndicator: styledActivityIndicator, styledDuration: styledDuration, styledPrompt: styledPrompt, progressLine: progress?.line ) } private func buildResultString( styledActivityIndicator: String, styledDuration: String?, styledPrompt: String, progressLine: String? ) -> String { let buildResultWithProgressLine = { (progressLine: String?) -> String in [styledActivityIndicator, styledDuration, styledPrompt, progressLine] .compactMap { $0 } .joined(separator: " ") } let result = buildResultWithProgressLine(progressLine) let notFittedToWindowLength = calculateStringNotFittedToWindowLength(result) if let progressLine, notFittedToWindowLength > 0 { let fittedProgressLine = String(progressLine.prefix(progressLine.count - notFittedToWindowLength)) return buildResultWithProgressLine(fittedProgressLine) } else { return result } } private func calculateStringNotFittedToWindowLength(_ string: String) -> Int { guard let windowSizeObserver else { return 0 } let stringWithoutANSI = string.withoutANSI() let windowWidth = windowSizeObserver.size.width return max(stringWithoutANSI.count - windowWidth, 0) } private func formatDuration(from duration: Seconds) -> String { #if os(Linux) duration.rawValue.formattedDuration() #else durationFormatter.string(from: duration.rawValue)! #endif } } #if os(Linux) extension TimeInterval { func formattedDuration() -> String { let totalSeconds = Int(self) let hours = totalSeconds / 3600 let minutes = (totalSeconds % 3600) / 60 let seconds = totalSeconds % 60 if hours >= 1 { return "\(hours)h \(minutes)m" } else if minutes >= 1 { return "\(minutes)m \(seconds)s" } else { return "\(seconds)s" } } } #endif ================================================ FILE: Sources/ProgressTracker.swift ================================================ import Foundation import TaggedTime struct Progress { let line: String? let duration: Seconds } final class ProgressTracker: Sendable { private let startTimestamp: Seconds private init(startTimestamp: Seconds) { self.startTimestamp = startTimestamp } static func start() -> ProgressTracker { ProgressTracker(startTimestamp: Seconds(Date().timeIntervalSince1970)) } func moveForward(_ line: String?) -> Progress { let duration = Seconds(Date().timeIntervalSince1970) - startTimestamp return Progress(line: line, duration: duration) } } ================================================ FILE: Sources/String+ANSI.swift ================================================ import Foundation extension String { private static let ansiRegex = try! NSRegularExpression(pattern: "\u{1B}(?:[@-Z\\-_]|\\[[0-?]*[ -/]*[@-~])") func withoutANSI() -> String { let range = NSRange(startIndex ..< endIndex, in: self) return Self.ansiRegex.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: "") } } ================================================ FILE: Sources/Task+Periodic.swift ================================================ import Foundation import TaggedTime extension Task where Success == Never, Failure == any Error { @discardableResult static func periodic(interval: Milliseconds, operation: @Sendable @escaping () async throws -> Void) -> Task { Task { while true { try Task.checkCancellation() try await operation() try await Task.sleep(nanoseconds: 1_000_000 * interval.rawValue) } } } } ================================================ FILE: Sources/UnderProgressLineLogger.swift ================================================ import Foundation final class AboveProgressLineLogger: Sendable { private let printers: PrintersHolder init(printers: PrintersHolder) { self.printers = printers } func log(_ text: String) async { await printers.withPrinter { printer in await log(printer: printer, text: text) } } func logError(_ text: String) async { await printers.withErrorsPrinter { errorsPrinter in await log(printer: errorsPrinter, text: text) } } private func log(printer: Printer, text: String) async { if printer.wasWritten { printer .cursorUp() .eraseLine() } printer .writeln(text) .writeln("") // Add an empty line after the message for delete it by progress line controller .flush() } } ================================================ FILE: Sources/Version.swift ================================================ // This file is autogenerated. Do not edit. let progressLineVersion = "0.2.4" ================================================ FILE: Sources/WindowSizeObserver.swift ================================================ import ConcurrencyExtras import Foundation final class WindowSizeObserver: Sendable { struct Size { let width: Int let height: Int } private let signalHandler: LockIsolated?> = .init(nil) private let _size: LockIsolated = .init(getTerminalSize()) var size: Size { _size.value } static func startObserving() -> WindowSizeObserver? { guard isTTY else { return nil } let observer = WindowSizeObserver() observer.setupSignalHandler() return observer } private init() {} private func setupSignalHandler() { let sigwinch = SIGWINCH let signalHandler = DispatchSource.makeSignalSource(signal: sigwinch) signal(sigwinch, SIG_IGN) signalHandler.setEventHandler { [weak self] in guard let self else { return } self.syncWindowSize() } signalHandler.resume() let uncheckedSendable = UncheckedSendable(signalHandler) self.signalHandler.setValue(uncheckedSendable) } private func syncWindowSize() { _size.setValue(Self.getTerminalSize()) } static func getTerminalSize() -> Size { var w = winsize() #if os(Linux) _ = ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &w) #else _ = ioctl(STDOUT_FILENO, TIOCGWINSZ, &w) #endif return Size(width: Int(w.ws_col), height: Int(w.ws_row)) } } ================================================ FILE: Sources/isTTY.swift ================================================ import Foundation let isTTY = isatty(STDOUT_FILENO) != 0 ================================================ FILE: Tests/assert.sh ================================================ #!/usr/bin/env bash # # Determine if the script is sourced or executed if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then # Script is being sourced SNAPSHOTS_DIR="$(cd "$(dirname "${BASH_SOURCE[1]}")" && pwd)" else # Script is being executed SNAPSHOTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" fi # takes a name for snapshot file and string to compare to reference. # If the snapshot file does not exist, it will be created. # If the snapshot file does exist, the string will be compared to the snapshot. # If the string is different, assertion will fail and print the diff. # For recording new snapshots, use the `SNAPSHOT_RECORD=true` environment variable. assert_snapshot() { local snapshot_name="$1" local snapshot_value="$2" local snapshot_file="$SNAPSHOTS_DIR/snapshots/$snapshot_name.snapshot" mkdir -p "$SNAPSHOTS_DIR" if [ "$SNAPSHOT_RECORD" = "true" ]; then echo "Recording snapshot $snapshot_name" >&2 echo "$snapshot_value" > "$snapshot_file" return fi if [ ! -f "$snapshot_file" ]; then echo "Snapshot $snapshot_name does not exist. Recording new snapshot." >&2 echo "$snapshot_value" > "$snapshot_file" return fi local snapshot_diff=$(diff -u "$snapshot_file" <(echo "$snapshot_value")) if [ -n "$snapshot_diff" ]; then echo "Snapshot $snapshot_name does not match reference." >&2 echo "$snapshot_diff" exit 1 fi echo "Snapshot $snapshot_name matches reference." >&2 } ================================================ FILE: Tests/integration_tests.sh ================================================ #!/usr/bin/env bash set -eo pipefail TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" source "$TESTS_DIR/assert.sh" # Parse arg: executable_path="$1" if [ -z "$executable_path" ]; then echo "Usage: $0 " exit 1 fi # Prepare test data test_data_producer_config="{ \"chunk_count\": 30, \"chunk_size\": 3, \"write_delay\": 10, }" test_data_producer_config_file="/tmp/progressline_test_data_producer_config.json" echo "$test_data_producer_config" > "$test_data_producer_config_file" # warmup test data producer swift "$TESTS_DIR"/test_data_producer.swift $test_data_producer_config_file > /dev/null generate_test_output="swift $TESTS_DIR/test_data_producer.swift $test_data_producer_config_file" # Test default mode output=$($generate_test_output | "$executable_path" --test-mode) assert_snapshot "default" "$output" # Test static text mode output=$($generate_test_output | "$executable_path" --test-mode --static-text "Static text") assert_snapshot "static_text" "$output" # Test default mode with save original log output=$($generate_test_output | "$executable_path" --test-mode --original-log-path /tmp/progressline_test_original_log.txt) assert_snapshot "default_with_original_log" "$output" assert_snapshot "default_with_original_log_original_log" "$(cat /tmp/progressline_test_original_log.txt)" rm /tmp/progressline_test_original_log.txt # Test log matches output=$($generate_test_output | "$executable_path" --test-mode --log-matches "Chunk number: \d+[1-5]{1}") assert_snapshot "log_matches" "$output" # Test log all output=$($generate_test_output | "$executable_path" --test-mode --log-all) assert_snapshot "log_all" "$output" ================================================ FILE: Tests/snapshots/default.snapshot ================================================ ❯  ❯ Chunk number: 1, Chunk Line: 3  ❯ Chunk number: 2, Chunk Line: 3  ❯ Chunk number: 3, Chunk Line: 3  ❯ Chunk number: 4, Chunk Line: 3  ❯ Chunk number: 5, Chunk Line: 3  ❯ Chunk number: 6, Chunk Line: 3  ❯ Chunk number: 7, Chunk Line: 3  ❯ Chunk number: 8, Chunk Line: 3  ❯ Chunk number: 9, Chunk Line: 3  ❯ Chunk number: 10, Chunk Line: 3  ❯ Chunk number: 11, Chunk Line: 3  ❯ Chunk number: 12, Chunk Line: 3  ❯ Chunk number: 13, Chunk Line: 3  ❯ Chunk number: 14, Chunk Line: 3  ❯ Chunk number: 15, Chunk Line: 3  ❯ Chunk number: 16, Chunk Line: 3  ❯ Chunk number: 17, Chunk Line: 3  ❯ Chunk number: 18, Chunk Line: 3  ❯ Chunk number: 19, Chunk Line: 3  ❯ Chunk number: 20, Chunk Line: 3  ❯ Chunk number: 21, Chunk Line: 3  ❯ Chunk number: 22, Chunk Line: 3  ❯ Chunk number: 23, Chunk Line: 3  ❯ Chunk number: 24, Chunk Line: 3  ❯ Chunk number: 25, Chunk Line: 3  ❯ Chunk number: 26, Chunk Line: 3  ❯ Chunk number: 27, Chunk Line: 3  ❯ Chunk number: 28, Chunk Line: 3  ❯ Chunk number: 29, Chunk Line: 3  ❯ Chunk number: 30, Chunk Line: 3 ✓ ❯ Chunk number: 30, Chunk Line: 3 ================================================ FILE: Tests/snapshots/default_with_original_log.snapshot ================================================ ❯  ❯ Chunk number: 1, Chunk Line: 3  ❯ Chunk number: 2, Chunk Line: 3  ❯ Chunk number: 3, Chunk Line: 3  ❯ Chunk number: 4, Chunk Line: 3  ❯ Chunk number: 5, Chunk Line: 3  ❯ Chunk number: 6, Chunk Line: 3  ❯ Chunk number: 7, Chunk Line: 3  ❯ Chunk number: 8, Chunk Line: 3  ❯ Chunk number: 9, Chunk Line: 3  ❯ Chunk number: 10, Chunk Line: 3  ❯ Chunk number: 11, Chunk Line: 3  ❯ Chunk number: 12, Chunk Line: 3  ❯ Chunk number: 13, Chunk Line: 3  ❯ Chunk number: 14, Chunk Line: 3  ❯ Chunk number: 15, Chunk Line: 3  ❯ Chunk number: 16, Chunk Line: 3  ❯ Chunk number: 17, Chunk Line: 3  ❯ Chunk number: 18, Chunk Line: 3  ❯ Chunk number: 19, Chunk Line: 3  ❯ Chunk number: 20, Chunk Line: 3  ❯ Chunk number: 21, Chunk Line: 3  ❯ Chunk number: 22, Chunk Line: 3  ❯ Chunk number: 23, Chunk Line: 3  ❯ Chunk number: 24, Chunk Line: 3  ❯ Chunk number: 25, Chunk Line: 3  ❯ Chunk number: 26, Chunk Line: 3  ❯ Chunk number: 27, Chunk Line: 3  ❯ Chunk number: 28, Chunk Line: 3  ❯ Chunk number: 29, Chunk Line: 3  ❯ Chunk number: 30, Chunk Line: 3 ✓ ❯ Chunk number: 30, Chunk Line: 3 ================================================ FILE: Tests/snapshots/default_with_original_log_original_log.snapshot ================================================ Chunk number: 1, Chunk Line: 1 Chunk number: 1, Chunk Line: 2 Chunk number: 1, Chunk Line: 3 Chunk number: 2, Chunk Line: 1 Chunk number: 2, Chunk Line: 2 Chunk number: 2, Chunk Line: 3 Chunk number: 3, Chunk Line: 1 Chunk number: 3, Chunk Line: 2 Chunk number: 3, Chunk Line: 3 Chunk number: 4, Chunk Line: 1 Chunk number: 4, Chunk Line: 2 Chunk number: 4, Chunk Line: 3 Chunk number: 5, Chunk Line: 1 Chunk number: 5, Chunk Line: 2 Chunk number: 5, Chunk Line: 3 Chunk number: 6, Chunk Line: 1 Chunk number: 6, Chunk Line: 2 Chunk number: 6, Chunk Line: 3 Chunk number: 7, Chunk Line: 1 Chunk number: 7, Chunk Line: 2 Chunk number: 7, Chunk Line: 3 Chunk number: 8, Chunk Line: 1 Chunk number: 8, Chunk Line: 2 Chunk number: 8, Chunk Line: 3 Chunk number: 9, Chunk Line: 1 Chunk number: 9, Chunk Line: 2 Chunk number: 9, Chunk Line: 3 Chunk number: 10, Chunk Line: 1 Chunk number: 10, Chunk Line: 2 Chunk number: 10, Chunk Line: 3 Chunk number: 11, Chunk Line: 1 Chunk number: 11, Chunk Line: 2 Chunk number: 11, Chunk Line: 3 Chunk number: 12, Chunk Line: 1 Chunk number: 12, Chunk Line: 2 Chunk number: 12, Chunk Line: 3 Chunk number: 13, Chunk Line: 1 Chunk number: 13, Chunk Line: 2 Chunk number: 13, Chunk Line: 3 Chunk number: 14, Chunk Line: 1 Chunk number: 14, Chunk Line: 2 Chunk number: 14, Chunk Line: 3 Chunk number: 15, Chunk Line: 1 Chunk number: 15, Chunk Line: 2 Chunk number: 15, Chunk Line: 3 Chunk number: 16, Chunk Line: 1 Chunk number: 16, Chunk Line: 2 Chunk number: 16, Chunk Line: 3 Chunk number: 17, Chunk Line: 1 Chunk number: 17, Chunk Line: 2 Chunk number: 17, Chunk Line: 3 Chunk number: 18, Chunk Line: 1 Chunk number: 18, Chunk Line: 2 Chunk number: 18, Chunk Line: 3 Chunk number: 19, Chunk Line: 1 Chunk number: 19, Chunk Line: 2 Chunk number: 19, Chunk Line: 3 Chunk number: 20, Chunk Line: 1 Chunk number: 20, Chunk Line: 2 Chunk number: 20, Chunk Line: 3 Chunk number: 21, Chunk Line: 1 Chunk number: 21, Chunk Line: 2 Chunk number: 21, Chunk Line: 3 Chunk number: 22, Chunk Line: 1 Chunk number: 22, Chunk Line: 2 Chunk number: 22, Chunk Line: 3 Chunk number: 23, Chunk Line: 1 Chunk number: 23, Chunk Line: 2 Chunk number: 23, Chunk Line: 3 Chunk number: 24, Chunk Line: 1 Chunk number: 24, Chunk Line: 2 Chunk number: 24, Chunk Line: 3 Chunk number: 25, Chunk Line: 1 Chunk number: 25, Chunk Line: 2 Chunk number: 25, Chunk Line: 3 Chunk number: 26, Chunk Line: 1 Chunk number: 26, Chunk Line: 2 Chunk number: 26, Chunk Line: 3 Chunk number: 27, Chunk Line: 1 Chunk number: 27, Chunk Line: 2 Chunk number: 27, Chunk Line: 3 Chunk number: 28, Chunk Line: 1 Chunk number: 28, Chunk Line: 2 Chunk number: 28, Chunk Line: 3 Chunk number: 29, Chunk Line: 1 Chunk number: 29, Chunk Line: 2 Chunk number: 29, Chunk Line: 3 Chunk number: 30, Chunk Line: 1 Chunk number: 30, Chunk Line: 2 Chunk number: 30, Chunk Line: 3 ================================================ FILE: Tests/snapshots/log_all.snapshot ================================================ ❯ Chunk number: 1, Chunk Line: 1 Chunk number: 1, Chunk Line: 2 Chunk number: 1, Chunk Line: 3  ❯ Chunk number: 1, Chunk Line: 3 Chunk number: 2, Chunk Line: 1 Chunk number: 2, Chunk Line: 2 Chunk number: 2, Chunk Line: 3  ❯ Chunk number: 2, Chunk Line: 3 Chunk number: 3, Chunk Line: 1 Chunk number: 3, Chunk Line: 2 Chunk number: 3, Chunk Line: 3  ❯ Chunk number: 3, Chunk Line: 3 Chunk number: 4, Chunk Line: 1 Chunk number: 4, Chunk Line: 2 Chunk number: 4, Chunk Line: 3  ❯ Chunk number: 4, Chunk Line: 3 Chunk number: 5, Chunk Line: 1 Chunk number: 5, Chunk Line: 2 Chunk number: 5, Chunk Line: 3  ❯ Chunk number: 5, Chunk Line: 3 Chunk number: 6, Chunk Line: 1 Chunk number: 6, Chunk Line: 2 Chunk number: 6, Chunk Line: 3  ❯ Chunk number: 6, Chunk Line: 3 Chunk number: 7, Chunk Line: 1 Chunk number: 7, Chunk Line: 2 Chunk number: 7, Chunk Line: 3  ❯ Chunk number: 7, Chunk Line: 3 Chunk number: 8, Chunk Line: 1 Chunk number: 8, Chunk Line: 2 Chunk number: 8, Chunk Line: 3  ❯ Chunk number: 8, Chunk Line: 3 Chunk number: 9, Chunk Line: 1 Chunk number: 9, Chunk Line: 2 Chunk number: 9, Chunk Line: 3  ❯ Chunk number: 9, Chunk Line: 3 Chunk number: 10, Chunk Line: 1 Chunk number: 10, Chunk Line: 2 Chunk number: 10, Chunk Line: 3  ❯ Chunk number: 10, Chunk Line: 3 Chunk number: 11, Chunk Line: 1 Chunk number: 11, Chunk Line: 2 Chunk number: 11, Chunk Line: 3  ❯ Chunk number: 11, Chunk Line: 3 Chunk number: 12, Chunk Line: 1 Chunk number: 12, Chunk Line: 2 Chunk number: 12, Chunk Line: 3  ❯ Chunk number: 12, Chunk Line: 3 Chunk number: 13, Chunk Line: 1 Chunk number: 13, Chunk Line: 2 Chunk number: 13, Chunk Line: 3  ❯ Chunk number: 13, Chunk Line: 3 Chunk number: 14, Chunk Line: 1 Chunk number: 14, Chunk Line: 2 Chunk number: 14, Chunk Line: 3  ❯ Chunk number: 14, Chunk Line: 3 Chunk number: 15, Chunk Line: 1 Chunk number: 15, Chunk Line: 2 Chunk number: 15, Chunk Line: 3  ❯ Chunk number: 15, Chunk Line: 3 Chunk number: 16, Chunk Line: 1 Chunk number: 16, Chunk Line: 2 Chunk number: 16, Chunk Line: 3  ❯ Chunk number: 16, Chunk Line: 3 Chunk number: 17, Chunk Line: 1 Chunk number: 17, Chunk Line: 2 Chunk number: 17, Chunk Line: 3  ❯ Chunk number: 17, Chunk Line: 3 Chunk number: 18, Chunk Line: 1 Chunk number: 18, Chunk Line: 2 Chunk number: 18, Chunk Line: 3  ❯ Chunk number: 18, Chunk Line: 3 Chunk number: 19, Chunk Line: 1 Chunk number: 19, Chunk Line: 2 Chunk number: 19, Chunk Line: 3  ❯ Chunk number: 19, Chunk Line: 3 Chunk number: 20, Chunk Line: 1 Chunk number: 20, Chunk Line: 2 Chunk number: 20, Chunk Line: 3  ❯ Chunk number: 20, Chunk Line: 3 Chunk number: 21, Chunk Line: 1 Chunk number: 21, Chunk Line: 2 Chunk number: 21, Chunk Line: 3  ❯ Chunk number: 21, Chunk Line: 3 Chunk number: 22, Chunk Line: 1 Chunk number: 22, Chunk Line: 2 Chunk number: 22, Chunk Line: 3  ❯ Chunk number: 22, Chunk Line: 3 Chunk number: 23, Chunk Line: 1 Chunk number: 23, Chunk Line: 2 Chunk number: 23, Chunk Line: 3  ❯ Chunk number: 23, Chunk Line: 3 Chunk number: 24, Chunk Line: 1 Chunk number: 24, Chunk Line: 2 Chunk number: 24, Chunk Line: 3  ❯ Chunk number: 24, Chunk Line: 3 Chunk number: 25, Chunk Line: 1 Chunk number: 25, Chunk Line: 2 Chunk number: 25, Chunk Line: 3  ❯ Chunk number: 25, Chunk Line: 3 Chunk number: 26, Chunk Line: 1 Chunk number: 26, Chunk Line: 2 Chunk number: 26, Chunk Line: 3  ❯ Chunk number: 26, Chunk Line: 3 Chunk number: 27, Chunk Line: 1 Chunk number: 27, Chunk Line: 2 Chunk number: 27, Chunk Line: 3  ❯ Chunk number: 27, Chunk Line: 3 Chunk number: 28, Chunk Line: 1 Chunk number: 28, Chunk Line: 2 Chunk number: 28, Chunk Line: 3  ❯ Chunk number: 28, Chunk Line: 3 Chunk number: 29, Chunk Line: 1 Chunk number: 29, Chunk Line: 2 Chunk number: 29, Chunk Line: 3  ❯ Chunk number: 29, Chunk Line: 3 Chunk number: 30, Chunk Line: 1 Chunk number: 30, Chunk Line: 2 Chunk number: 30, Chunk Line: 3  ❯ Chunk number: 30, Chunk Line: 3 ✓ ❯ Chunk number: 30, Chunk Line: 3 ================================================ FILE: Tests/snapshots/log_matches.snapshot ================================================ ❯  ❯ Chunk number: 1, Chunk Line: 3  ❯ Chunk number: 2, Chunk Line: 3  ❯ Chunk number: 3, Chunk Line: 3  ❯ Chunk number: 4, Chunk Line: 3  ❯ Chunk number: 5, Chunk Line: 3  ❯ Chunk number: 6, Chunk Line: 3  ❯ Chunk number: 7, Chunk Line: 3  ❯ Chunk number: 8, Chunk Line: 3  ❯ Chunk number: 9, Chunk Line: 3  ❯ Chunk number: 10, Chunk Line: 3 Chunk number: 11, Chunk Line: 1 Chunk number: 11, Chunk Line: 2 Chunk number: 11, Chunk Line: 3  ❯ Chunk number: 11, Chunk Line: 3 Chunk number: 12, Chunk Line: 1 Chunk number: 12, Chunk Line: 2 Chunk number: 12, Chunk Line: 3  ❯ Chunk number: 12, Chunk Line: 3 Chunk number: 13, Chunk Line: 1 Chunk number: 13, Chunk Line: 2 Chunk number: 13, Chunk Line: 3  ❯ Chunk number: 13, Chunk Line: 3 Chunk number: 14, Chunk Line: 1 Chunk number: 14, Chunk Line: 2 Chunk number: 14, Chunk Line: 3  ❯ Chunk number: 14, Chunk Line: 3 Chunk number: 15, Chunk Line: 1 Chunk number: 15, Chunk Line: 2 Chunk number: 15, Chunk Line: 3  ❯ Chunk number: 15, Chunk Line: 3  ❯ Chunk number: 16, Chunk Line: 3  ❯ Chunk number: 17, Chunk Line: 3  ❯ Chunk number: 18, Chunk Line: 3  ❯ Chunk number: 19, Chunk Line: 3  ❯ Chunk number: 20, Chunk Line: 3 Chunk number: 21, Chunk Line: 1 Chunk number: 21, Chunk Line: 2 Chunk number: 21, Chunk Line: 3  ❯ Chunk number: 21, Chunk Line: 3 Chunk number: 22, Chunk Line: 1 Chunk number: 22, Chunk Line: 2 Chunk number: 22, Chunk Line: 3  ❯ Chunk number: 22, Chunk Line: 3 Chunk number: 23, Chunk Line: 1 Chunk number: 23, Chunk Line: 2 Chunk number: 23, Chunk Line: 3  ❯ Chunk number: 23, Chunk Line: 3 Chunk number: 24, Chunk Line: 1 Chunk number: 24, Chunk Line: 2 Chunk number: 24, Chunk Line: 3  ❯ Chunk number: 24, Chunk Line: 3 Chunk number: 25, Chunk Line: 1 Chunk number: 25, Chunk Line: 2 Chunk number: 25, Chunk Line: 3  ❯ Chunk number: 25, Chunk Line: 3  ❯ Chunk number: 26, Chunk Line: 3  ❯ Chunk number: 27, Chunk Line: 3  ❯ Chunk number: 28, Chunk Line: 3  ❯ Chunk number: 29, Chunk Line: 3  ❯ Chunk number: 30, Chunk Line: 3 ✓ ❯ Chunk number: 30, Chunk Line: 3 ================================================ FILE: Tests/snapshots/static_text.snapshot ================================================ ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text  ❯ Static text ✓ ❯ Static text ================================================ FILE: Tests/test_data_producer.swift ================================================ /* Generates test data for the app. Features: - iterates over the given number and prints the iteration number - prints with a delay if the delay is greater than 0 - can produce a given number of lines per iteration Usage: test_data_producer.swift [] [] */ import Foundation struct Configuration: Decodable { let chunkCount: Int let chunkSize: Int let writeDelay: TimeInterval enum CodingKeys: String, CodingKey { case chunkCount = "chunk_count" case chunkSize = "chunk_size" case writeDelay = "write_delay" } } let arguments = CommandLine.arguments.dropFirst() guard arguments.count >= 1 else { print("Usage: \(CommandLine.arguments.first!) ") exit(1) } let filePath = arguments.first! let configuration = try JSONDecoder().decode(Configuration.self, from: try Data(contentsOf: URL(fileURLWithPath: filePath))) for i in 0 ..< configuration.chunkCount { let data = (0 ..< configuration.chunkSize) .map { "Chunk number: \(i + 1), Chunk Line: \($0 + 1)" } .joined(separator: "\n") print(data) fflush(stdout) if configuration.writeDelay > 0 { usleep(useconds_t(configuration.writeDelay * 1000)) } } ================================================ FILE: cliff.toml ================================================ # git-cliff ~ configuration file # https://git-cliff.org/docs/configuration [remote.github] owner = "kattouf" repo = "ProgressLine" # token = "" [changelog] # changelog header header = """ # Changelog\n """ # template for the changelog body # https://keats.github.io/tera/docs/#introduction body = """ {% if version %}\ {% if previous.version %}\ ## [{{ version | trim_start_matches(pat="v") }}]({{ self::remote_url() }}/compare/{{ previous.version }}..{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }} {% else %}\ ## {{ version | trim_start_matches(pat="v") }} - {{ timestamp | date(format="%Y-%m-%d") }} {% endif %}\ {% else %}\ ## Unreleased {% endif %}\ {% for group, commits in commits | group_by(attribute="group") %} ### {{ group | striptags | trim | upper_first }} {% for commit in commits | filter(attribute="scope") | sort(attribute="scope") -%} {% if commit.scope -%} * {{self::commit(commit=commit)}}\ {% endif -%} {% endfor -%} {% for commit in commits -%} {% if commit.scope -%} {% else -%} * {{self::commit(commit=commit)}}\ {% endif -%} {% endfor -%} {% endfor %} {%- if github -%} {% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} {% raw %}\n{% endraw -%} ## New Contributors {%- endif %}\ {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} * @{{ contributor.username }} made their first contribution {%- if contributor.pr_number %} in \ [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ {%- endif %} {%- endfor -%} {%- endif -%} {% if version %} {% if previous.version %} **Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }} {% endif %} {% else -%} {% raw %}\n{% endraw %} {% endif %} {%- macro remote_url() -%} https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} {%- endmacro -%} {% macro commit(commit) -%} {% if commit.scope %}**({{commit.scope}})** {% endif -%} {% if commit.breaking %}**breaking** {% endif -%} {{ commit.message | split(pat="\n") | first | trim }} by \ {% if commit.remote.username %}[@{{commit.remote.username}}](https://github.com/{{commit.remote.username}})\ {% else %}{{commit.author.name}}{% endif %} in \ {% if commit.remote.pr_number %}[#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }})\ {% else %}[{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }})\ {%- endif %} {% endmacro commit -%} """ # template for the changelog footer footer = """ """ # remove the leading and trailing whitespace from the template trim = true # postprocessors postprocessors = [] [git] # parse the commits based on https://www.conventionalcommits.org conventional_commits = true # filter out the commits that are not conventional filter_unconventional = false # process each line of a commit as an individual commit split_commits = false # regex for preprocessing the commit messages commit_preprocessors = [ # remove issue numbers from commits { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }, ] # regex for parsing and grouping commits commit_parsers = [ { message = '^chore\(release\): Bump version to', skip = true }, { message = '^(chore|fix)\(deps\):', group = ":package: Dependency Updates", scope = "" }, { message = '^feat', group = ":rocket: Features" }, { message = '^fix', group = ":bug: Bug Fixes" }, { message = '^test', group = ":test_tube: Testing" }, { message = '^perf', group = ":zap: Performance" }, { message = '^refactor', group = ":tractor: Refactoring" }, { message = '^doc', group = ":books: Documentation" }, { body = '.*security', group = ":shield: Security" }, { message = '^project', group = ":file_folder: Project" }, { message = '^revert', group = ":leftwards_arrow_with_hook: Revert" }, { message = '.', group = ":card_index_dividers: Other Changes" }, ] # filter out the commits that are not matched by commit parsers filter_commits = false # sort the tags topologically topo_order = false # sort the commits inside sections by oldest/newest order sort_commits = "newest"