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://github.com/kattouf/ProgressLine/releases/latest)

Track commands progress in a compact one-line format.
| ⏳ `progressline` output |
|:--:|
|  |
| 📝 standard output |
|:--:|
|  |
[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 |
|:--:|:--:|:--:|:--:|
|  |  |  |  |
### 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
```
</details>
### [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<UInt64>
let states: [String]
}
let configuration: Configuration
init(configuration: Configuration) {
self.configuration = configuration
}
func state(forDuration duration: Seconds<TimeInterval>) -> 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<Data> {
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<FileHandle>
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<T: Sendable>(_ body: @Sendable (Printer) async throws -> T) async rethrows -> T {
try await body(printer)
}
func withErrorsPrinter<T: Sendable>(_ 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<Never, any Error>?
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<UInt64>) {
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 ? "<activity>" : activityIndicator.state(forDuration: progress.duration)
let formattedDuration = mockActivityAndDuration ? "<duration>" : 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 ? "<duration>" : 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<TimeInterval>) -> 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<TimeInterval>
}
final class ProgressTracker: Sendable {
private let startTimestamp: Seconds<TimeInterval>
private init(startTimestamp: Seconds<TimeInterval>) {
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<UInt64>, operation: @Sendable @escaping () async throws -> Void) -> Task {
Task {
while true {
try Task<Never, Never>.checkCancellation()
try await operation()
try await Task<Never, Never>.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<UncheckedSendable<DispatchSourceSignal>?> = .init(nil)
private let _size: LockIsolated<Size> = .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>
executable_path="$1"
if [ -z "$executable_path" ]; then
echo "Usage: $0 <executable path>"
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
================================================
<activity> <duration> ❯
[1A[2K<activity> <duration> ❯ Chunk number: 1, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 2, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 3, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 4, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 5, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 6, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 7, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 8, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 9, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 10, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 11, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 12, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 13, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 14, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 15, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 16, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 17, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 18, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 19, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 20, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 21, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 22, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 23, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 24, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 25, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 26, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 27, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 28, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 29, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 30, Chunk Line: 3
[1A[2K✓ <duration> ❯ Chunk number: 30, Chunk Line: 3
================================================
FILE: Tests/snapshots/default_with_original_log.snapshot
================================================
<activity> <duration> ❯
[1A[2K<activity> <duration> ❯ Chunk number: 1, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 2, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 3, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 4, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 5, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 6, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 7, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 8, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 9, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 10, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 11, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 12, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 13, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 14, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 15, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 16, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 17, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 18, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 19, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 20, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 21, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 22, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 23, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 24, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 25, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 26, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 27, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 28, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 29, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 30, Chunk Line: 3
[1A[2K✓ <duration> ❯ 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
================================================
<activity> <duration> ❯
[1A[2KChunk number: 1, Chunk Line: 1
Chunk number: 1, Chunk Line: 2
Chunk number: 1, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 1, Chunk Line: 3
[1A[2KChunk number: 2, Chunk Line: 1
Chunk number: 2, Chunk Line: 2
Chunk number: 2, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 2, Chunk Line: 3
[1A[2KChunk number: 3, Chunk Line: 1
Chunk number: 3, Chunk Line: 2
Chunk number: 3, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 3, Chunk Line: 3
[1A[2KChunk number: 4, Chunk Line: 1
Chunk number: 4, Chunk Line: 2
Chunk number: 4, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 4, Chunk Line: 3
[1A[2KChunk number: 5, Chunk Line: 1
Chunk number: 5, Chunk Line: 2
Chunk number: 5, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 5, Chunk Line: 3
[1A[2KChunk number: 6, Chunk Line: 1
Chunk number: 6, Chunk Line: 2
Chunk number: 6, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 6, Chunk Line: 3
[1A[2KChunk number: 7, Chunk Line: 1
Chunk number: 7, Chunk Line: 2
Chunk number: 7, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 7, Chunk Line: 3
[1A[2KChunk number: 8, Chunk Line: 1
Chunk number: 8, Chunk Line: 2
Chunk number: 8, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 8, Chunk Line: 3
[1A[2KChunk number: 9, Chunk Line: 1
Chunk number: 9, Chunk Line: 2
Chunk number: 9, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 9, Chunk Line: 3
[1A[2KChunk number: 10, Chunk Line: 1
Chunk number: 10, Chunk Line: 2
Chunk number: 10, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 10, Chunk Line: 3
[1A[2KChunk number: 11, Chunk Line: 1
Chunk number: 11, Chunk Line: 2
Chunk number: 11, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 11, Chunk Line: 3
[1A[2KChunk number: 12, Chunk Line: 1
Chunk number: 12, Chunk Line: 2
Chunk number: 12, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 12, Chunk Line: 3
[1A[2KChunk number: 13, Chunk Line: 1
Chunk number: 13, Chunk Line: 2
Chunk number: 13, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 13, Chunk Line: 3
[1A[2KChunk number: 14, Chunk Line: 1
Chunk number: 14, Chunk Line: 2
Chunk number: 14, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 14, Chunk Line: 3
[1A[2KChunk number: 15, Chunk Line: 1
Chunk number: 15, Chunk Line: 2
Chunk number: 15, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 15, Chunk Line: 3
[1A[2KChunk number: 16, Chunk Line: 1
Chunk number: 16, Chunk Line: 2
Chunk number: 16, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 16, Chunk Line: 3
[1A[2KChunk number: 17, Chunk Line: 1
Chunk number: 17, Chunk Line: 2
Chunk number: 17, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 17, Chunk Line: 3
[1A[2KChunk number: 18, Chunk Line: 1
Chunk number: 18, Chunk Line: 2
Chunk number: 18, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 18, Chunk Line: 3
[1A[2KChunk number: 19, Chunk Line: 1
Chunk number: 19, Chunk Line: 2
Chunk number: 19, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 19, Chunk Line: 3
[1A[2KChunk number: 20, Chunk Line: 1
Chunk number: 20, Chunk Line: 2
Chunk number: 20, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 20, Chunk Line: 3
[1A[2KChunk number: 21, Chunk Line: 1
Chunk number: 21, Chunk Line: 2
Chunk number: 21, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 21, Chunk Line: 3
[1A[2KChunk number: 22, Chunk Line: 1
Chunk number: 22, Chunk Line: 2
Chunk number: 22, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 22, Chunk Line: 3
[1A[2KChunk number: 23, Chunk Line: 1
Chunk number: 23, Chunk Line: 2
Chunk number: 23, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 23, Chunk Line: 3
[1A[2KChunk number: 24, Chunk Line: 1
Chunk number: 24, Chunk Line: 2
Chunk number: 24, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 24, Chunk Line: 3
[1A[2KChunk number: 25, Chunk Line: 1
Chunk number: 25, Chunk Line: 2
Chunk number: 25, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 25, Chunk Line: 3
[1A[2KChunk number: 26, Chunk Line: 1
Chunk number: 26, Chunk Line: 2
Chunk number: 26, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 26, Chunk Line: 3
[1A[2KChunk number: 27, Chunk Line: 1
Chunk number: 27, Chunk Line: 2
Chunk number: 27, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 27, Chunk Line: 3
[1A[2KChunk number: 28, Chunk Line: 1
Chunk number: 28, Chunk Line: 2
Chunk number: 28, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 28, Chunk Line: 3
[1A[2KChunk number: 29, Chunk Line: 1
Chunk number: 29, Chunk Line: 2
Chunk number: 29, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 29, Chunk Line: 3
[1A[2KChunk number: 30, Chunk Line: 1
Chunk number: 30, Chunk Line: 2
Chunk number: 30, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 30, Chunk Line: 3
[1A[2K✓ <duration> ❯ Chunk number: 30, Chunk Line: 3
================================================
FILE: Tests/snapshots/log_matches.snapshot
================================================
<activity> <duration> ❯
[1A[2K<activity> <duration> ❯ Chunk number: 1, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 2, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 3, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 4, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 5, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 6, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 7, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 8, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 9, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 10, Chunk Line: 3
[1A[2KChunk number: 11, Chunk Line: 1
[1A[2KChunk number: 11, Chunk Line: 2
[1A[2KChunk number: 11, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 11, Chunk Line: 3
[1A[2KChunk number: 12, Chunk Line: 1
[1A[2KChunk number: 12, Chunk Line: 2
[1A[2KChunk number: 12, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 12, Chunk Line: 3
[1A[2KChunk number: 13, Chunk Line: 1
[1A[2KChunk number: 13, Chunk Line: 2
[1A[2KChunk number: 13, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 13, Chunk Line: 3
[1A[2KChunk number: 14, Chunk Line: 1
[1A[2KChunk number: 14, Chunk Line: 2
[1A[2KChunk number: 14, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 14, Chunk Line: 3
[1A[2KChunk number: 15, Chunk Line: 1
[1A[2KChunk number: 15, Chunk Line: 2
[1A[2KChunk number: 15, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 15, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 16, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 17, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 18, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 19, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 20, Chunk Line: 3
[1A[2KChunk number: 21, Chunk Line: 1
[1A[2KChunk number: 21, Chunk Line: 2
[1A[2KChunk number: 21, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 21, Chunk Line: 3
[1A[2KChunk number: 22, Chunk Line: 1
[1A[2KChunk number: 22, Chunk Line: 2
[1A[2KChunk number: 22, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 22, Chunk Line: 3
[1A[2KChunk number: 23, Chunk Line: 1
[1A[2KChunk number: 23, Chunk Line: 2
[1A[2KChunk number: 23, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 23, Chunk Line: 3
[1A[2KChunk number: 24, Chunk Line: 1
[1A[2KChunk number: 24, Chunk Line: 2
[1A[2KChunk number: 24, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 24, Chunk Line: 3
[1A[2KChunk number: 25, Chunk Line: 1
[1A[2KChunk number: 25, Chunk Line: 2
[1A[2KChunk number: 25, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 25, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 26, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 27, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 28, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 29, Chunk Line: 3
[1A[2K<activity> <duration> ❯ Chunk number: 30, Chunk Line: 3
[1A[2K✓ <duration> ❯ Chunk number: 30, Chunk Line: 3
================================================
FILE: Tests/snapshots/static_text.snapshot
================================================
<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K<activity> <duration> ❯ Static text
[1A[2K✓ <duration> ❯ 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 <iterations> [<delay in ms>] [<lines>]
*/
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!) <configuration file path>")
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 = """
<!-- generated by git-cliff -->
"""
# 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 = "<!-- 99 -->:package: Dependency Updates", scope = "" },
{ message = '^feat', group = "<!-- 00 -->:rocket: Features" },
{ message = '^fix', group = "<!-- 01 -->:bug: Bug Fixes" },
{ message = '^test', group = "<!-- 02 -->:test_tube: Testing" },
{ message = '^perf', group = "<!-- 03 -->:zap: Performance" },
{ message = '^refactor', group = "<!-- 04 -->:tractor: Refactoring" },
{ message = '^doc', group = "<!-- 05 -->:books: Documentation" },
{ body = '.*security', group = "<!-- 06 -->:shield: Security" },
{ message = '^project', group = "<!-- 07 -->:file_folder: Project" },
{ message = '^revert', group = "<!-- 39 -->:leftwards_arrow_with_hook: Revert" },
{ message = '.', group = "<!-- 49 -->: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"
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
Condensed preview — 48 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (89K chars).
[
{
"path": ".github/demo_activity_style.tape",
"chars": 144,
"preview": "Output out.gif\n\nSet Shell \"zsh\"\nSet FontSize 32\nSet Width 660\nSet Height 300\nSet TypingSpeed 75ms\n\nType \"progressline -s"
},
{
"path": ".github/demo_progressline_output.tape",
"chars": 229,
"preview": "Output progressline_output.gif\n\nRequire progressline\n\nSet Shell \"zsh\"\nSet FontSize 32\nSet Width 1600\nSet Height 400\nSet "
},
{
"path": ".github/demo_standard_output.tape",
"chars": 202,
"preview": "Output standard_output.gif\n\nRequire progressline\n\nSet Shell \"zsh\"\nSet FontSize 32\nSet Width 1600\nSet Height 400\nSet Typi"
},
{
"path": ".github/workflows/checks.yml",
"chars": 735,
"preview": "name: Checks\non:\n push:\n branches:\n - main\n pull_request:\n branches:\n - main\njobs:\n build:\n name: "
},
{
"path": ".github/workflows/semantic-pr-lint.yml",
"chars": 584,
"preview": "name: \"Lint PR\"\n\non:\n pull_request_target:\n types:\n - opened\n - edited\n - synchronize\n - reopene"
},
{
"path": ".gitignore",
"chars": 181,
"preview": ".DS_Store\n/.build\n/.index-build\n/Packages\nxcuserdata/\nDerivedData/\n.swiftpm/configuration/registries.json\n.swiftpm/xcode"
},
{
"path": ".sake.yml",
"chars": 38,
"preview": "case_converting_strategy: toSnakeCase\n"
},
{
"path": ".swiftformat",
"chars": 340,
"preview": "--swiftversion 5.10\n\n--exclude .build,.index-build,SakeApp/.build,SakeApp/.index-build\n\n--maxwidth 140\n--wraparguments b"
},
{
"path": "LICENSE",
"chars": 1073,
"preview": "MIT License\n\nCopyright (c) 2024 Vasilii Ianguzin\n\nPermission is hereby granted, free of charge, to any person obtaining "
},
{
"path": "Package.resolved",
"chars": 984,
"preview": "{\n \"originHash\" : \"e759c45271facbb3650829c703702a2ac4817adf75a8116cc3d77eae8e3d3bae\",\n \"pins\" : [\n {\n \"identit"
},
{
"path": "Package.swift",
"chars": 1134,
"preview": "// swift-tools-version: 5.10\n// The swift-tools-version declares the minimum version of Swift required to build this pac"
},
{
"path": "README.md",
"chars": 3225,
"preview": "## ProgressLine\n\n\n -> String {\n \"\\u{1B}[\\(count)A\"\n }\n\n"
},
{
"path": "Sources/ActivityIndicator+CommandArgument.swift",
"chars": 467,
"preview": "import ArgumentParser\n\nenum ActivityIndicatorStyle: String, CaseIterable, ExpressibleByArgument {\n case dots\n case"
},
{
"path": "Sources/ActivityIndicator.swift",
"chars": 2583,
"preview": "import Foundation\nimport TaggedTime\n\nfinal class ActivityIndicator: Sendable {\n struct Configuration {\n let re"
},
{
"path": "Sources/ErrorMessage.swift",
"chars": 470,
"preview": "enum ErrorMessage {\n static let canNotDecodeData = \"\\(ANSI.yellow)[!] progressline: Failed to decode stdin data as UT"
},
{
"path": "Sources/FileHandler+AsyncStream.swift",
"chars": 653,
"preview": "#if os(Linux)\n // Linux implementation of FileHandle not Sendable\n @preconcurrency import Foundation\n#else\n imp"
},
{
"path": "Sources/LogAllController.swift",
"chars": 596,
"preview": "import Foundation\n\nfinal class LogAllController {\n private let logger: AboveProgressLineLogger\n\n init(logger: Abov"
},
{
"path": "Sources/MatchesController.swift",
"chars": 1351,
"preview": "import Foundation\n\nfinal class MatchesController {\n private let logger: AboveProgressLineLogger\n let regexps: [NSR"
},
{
"path": "Sources/OriginalLogController.swift",
"chars": 866,
"preview": "import Foundation\n\nfinal class OriginalLogController {\n private let logger: AboveProgressLineLogger\n let fileHandl"
},
{
"path": "Sources/Printer.swift",
"chars": 1486,
"preview": "import ConcurrencyExtras\n#if os(Linux)\n // Linux implementation of FileHandle not Sendable\n @preconcurrency import"
},
{
"path": "Sources/PrintersHolder.swift",
"chars": 608,
"preview": "import Foundation\n\n// \"Lock\" access to printers to prevent write conflicts\nfinal actor PrintersHolder {\n private let "
},
{
"path": "Sources/ProgressLine.swift",
"chars": 3365,
"preview": "import ArgumentParser\nimport ConcurrencyExtras\nimport Foundation\nimport TaggedTime\n\n@main\nstruct ProgressLine: AsyncPars"
},
{
"path": "Sources/ProgressLineController.swift",
"chars": 4140,
"preview": "import Foundation\nimport TaggedTime\n\nfinal actor ProgressLineController {\n enum TextMode {\n case staticText(St"
},
{
"path": "Sources/ProgressLineFormatter.swift",
"chars": 4484,
"preview": "import Foundation\nimport TaggedTime\n\nprivate enum Symbol {\n static let checkmark = \"✓\"\n static let prompt = \"❯\"\n}\n"
},
{
"path": "Sources/ProgressTracker.swift",
"chars": 650,
"preview": "import Foundation\nimport TaggedTime\n\nstruct Progress {\n let line: String?\n let duration: Seconds<TimeInterval>\n}\n\n"
},
{
"path": "Sources/String+ANSI.swift",
"chars": 368,
"preview": "import Foundation\n\nextension String {\n private static let ansiRegex = try! NSRegularExpression(pattern: \"\\u{1B}(?:[@-"
},
{
"path": "Sources/Task+Periodic.swift",
"chars": 509,
"preview": "import Foundation\nimport TaggedTime\n\nextension Task where Success == Never, Failure == any Error {\n @discardableResul"
},
{
"path": "Sources/UnderProgressLineLogger.swift",
"chars": 875,
"preview": "import Foundation\n\nfinal class AboveProgressLineLogger: Sendable {\n private let printers: PrintersHolder\n\n init(pr"
},
{
"path": "Sources/Version.swift",
"chars": 78,
"preview": "// This file is autogenerated. Do not edit.\nlet progressLineVersion = \"0.2.4\"\n"
},
{
"path": "Sources/WindowSizeObserver.swift",
"chars": 1541,
"preview": "import ConcurrencyExtras\nimport Foundation\n\nfinal class WindowSizeObserver: Sendable {\n struct Size {\n let wid"
},
{
"path": "Sources/isTTY.swift",
"chars": 58,
"preview": "import Foundation\n\nlet isTTY = isatty(STDOUT_FILENO) != 0\n"
},
{
"path": "Tests/assert.sh",
"chars": 1468,
"preview": "#!/usr/bin/env bash\n#\n# Determine if the script is sourced or executed\nif [[ \"${BASH_SOURCE[0]}\" != \"${0}\" ]]; then\n "
},
{
"path": "Tests/integration_tests.sh",
"chars": 1716,
"preview": "#!/usr/bin/env bash\n\nset -eo pipefail\n\nTESTS_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" &>/dev/null && pwd)\"\n\nsource \"$T"
},
{
"path": "Tests/snapshots/default.snapshot",
"chars": 1990,
"preview": "<activity> <duration> ❯\n\u001b[1A\u001b[2K<activity> <duration> ❯ Chunk number: 1, Chunk Line: 3\n\u001b[1A\u001b[2K<activity> <duration> ❯ C"
},
{
"path": "Tests/snapshots/default_with_original_log.snapshot",
"chars": 1990,
"preview": "<activity> <duration> ❯\n\u001b[1A\u001b[2K<activity> <duration> ❯ Chunk number: 1, Chunk Line: 3\n\u001b[1A\u001b[2K<activity> <duration> ❯ C"
},
{
"path": "Tests/snapshots/default_with_original_log_original_log.snapshot",
"chars": 2853,
"preview": "Chunk number: 1, Chunk Line: 1\nChunk number: 1, Chunk Line: 2\nChunk number: 1, Chunk Line: 3\nChunk number: 2, Chunk Line"
},
{
"path": "Tests/snapshots/log_all.snapshot",
"chars": 5113,
"preview": "<activity> <duration> ❯\n\u001b[1A\u001b[2KChunk number: 1, Chunk Line: 1\nChunk number: 1, Chunk Line: 2\nChunk number: 1, Chunk Lin"
},
{
"path": "Tests/snapshots/log_matches.snapshot",
"chars": 3220,
"preview": "<activity> <duration> ❯\n\u001b[1A\u001b[2K<activity> <duration> ❯ Chunk number: 1, Chunk Line: 3\n\u001b[1A\u001b[2K<activity> <duration> ❯ C"
},
{
"path": "Tests/snapshots/static_text.snapshot",
"chars": 1391,
"preview": "<activity> <duration> ❯ Static text\n\u001b[1A\u001b[2K<activity> <duration> ❯ Static text\n\u001b[1A\u001b[2K<activity> <duration> ❯ Static t"
},
{
"path": "Tests/test_data_producer.swift",
"chars": 1267,
"preview": "/*\n Generates test data for the app.\n Features:\n - iterates over the given number and prints the iteration number\n - pri"
},
{
"path": "cliff.toml",
"chars": 4454,
"preview": "# git-cliff ~ configuration file\n# https://git-cliff.org/docs/configuration\n\n[remote.github]\nowner = \"kattouf\"\nrepo = \"P"
}
]
About this extraction
This page contains the full source code of the kattouf/ProgressLine GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 48 files (77.9 KB), approximately 22.7k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.