diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc90ca6..46e1027 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,13 +16,12 @@ jobs: fail-fast: false matrix: os: [macos-latest, ubuntu-latest] - swift: ["6.0"] + swift: ["6.2"] steps: - uses: actions/checkout@v4 - - name: Set up Swift (Linux) - if: runner.os != 'macOS' + - name: Set up Swift uses: swift-actions/setup-swift@v2 with: swift-version: ${{ matrix.swift }} @@ -31,12 +30,7 @@ jobs: id: swift-version run: | set -euo pipefail - if [ "${RUNNER_OS}" = "macOS" ]; then - SWIFT_BIN="$(xcrun -f swift)" - else - SWIFT_BIN="swift" - fi - SWIFT_VERSION="$("${SWIFT_BIN}" --version | head -n 1 | awk '{print $4}')" + SWIFT_VERSION="$(swift --version | head -n 1 | awk '{print $4}')" echo "version=${SWIFT_VERSION}" >> "${GITHUB_OUTPUT}" echo "Swift version: ${SWIFT_VERSION}" @@ -59,12 +53,16 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up Swift + uses: swift-actions/setup-swift@v2 + with: + swift-version: "6.2" + - name: Resolve Swift version id: swift-version run: | set -euo pipefail - SWIFT_BIN="$(xcrun -f swift)" - SWIFT_VERSION="$("${SWIFT_BIN}" --version | head -n 1 | awk '{print $4}')" + SWIFT_VERSION="$(swift --version | head -n 1 | awk '{print $4}')" echo "version=${SWIFT_VERSION}" >> "${GITHUB_OUTPUT}" echo "Swift version: ${SWIFT_VERSION}" diff --git a/Package.resolved b/Package.resolved index 6daa831..0e5fa73 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "c1d80200f88119effde84b1c627db1b232c2e0fa24e3ddd88b11522b1197465a", "pins" : [ { "identity" : "hdrhistogram-swift", @@ -45,6 +46,15 @@ "version" : "1.7.0" } }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", + "version" : "1.1.1" + } + }, { "identity" : "swift-atomics", "kind" : "remoteSourceControl", @@ -54,6 +64,33 @@ "version" : "1.3.0" } }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration", + "state" : { + "revision" : "9d5e40727efd060fb9b41b69932738f478abaa43", + "version" : "0.2.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", + "version" : "1.8.0" + } + }, { "identity" : "swift-numerics", "kind" : "remoteSourceControl", @@ -63,6 +100,15 @@ "version" : "1.1.1" } }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle", + "state" : { + "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348", + "version" : "2.9.1" + } + }, { "identity" : "swift-system", "kind" : "remoteSourceControl", @@ -82,5 +128,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/Package.swift b/Package.swift index 6c8d3f3..1050316 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -25,9 +25,19 @@ let package = Package( name: "ChromaDemo", targets: ["ChromaDemo"] ), + .executable( + name: "ca", + targets: ["Ca"] + ), ], dependencies: [ .package(url: "https://github.com/onevcat/Rainbow.git", from: "4.2.1"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.4.0"), + .package( + url: "https://github.com/apple/swift-configuration", + from: "0.2.0", + traits: ["JSONSupport"] + ), .package(url: "https://github.com/ordo-one/package-benchmark", from: "1.20.0"), ], targets: [ @@ -57,6 +67,15 @@ let package = Package( "ChromaBase46Themes", ] ), + .executableTarget( + name: "Ca", + dependencies: [ + "Chroma", + "ChromaBase46Themes", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Configuration", package: "swift-configuration"), + ] + ), .executableTarget( name: "ChromaBenchmarks", dependencies: [ @@ -68,5 +87,6 @@ let package = Package( .plugin(name: "BenchmarkPlugin", package: "package-benchmark"), ] ), - ] + ], + swiftLanguageModes: [.v5] ) diff --git a/README.md b/README.md index 51e0c20..c4c9152 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,29 @@ To list all supported languages: swift run ChromaDemo --list-languages ``` +### Running ca (Experimental) + +`ca` is an experimental Chroma-powered `cat` replacement: + +```bash +swift run ca Sources/Chroma/Highlighter.swift +``` + +On macOS, `ca` requires macOS 15 or newer. +Config file path: `~/.config/ca/config.json` + +```json +{ + "theme": { + "name": "tokyonight", + "appearance": "auto" + }, + "lineNumbers": true, + "paging": "auto", + "headers": true +} +``` + ### Installation Add Chroma as a dependency in your `Package.swift`: diff --git a/Sources/Ca/CaCommand.swift b/Sources/Ca/CaCommand.swift new file mode 100644 index 0000000..3bcbcd6 --- /dev/null +++ b/Sources/Ca/CaCommand.swift @@ -0,0 +1,92 @@ +import ArgumentParser +import Foundation + +@main +struct CaCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "ca", + abstract: "A Chroma-powered cat replacement with syntax highlighting." + ) + + @Argument(help: "Files to display. Use '-' to read from stdin.") + var paths: [String] = [] + + @Option(help: "Theme name (ChromaBase46Themes or 'dark'/'light').") + var theme: String? + + @Option(help: "Paging mode: auto, always, never.") + var paging: PagingMode? + + @Flag(inversion: .prefixedNo, help: "Show line numbers.") + var lineNumbers: Bool? + + @Flag(inversion: .prefixedNo, help: "Show file headers when rendering multiple inputs.") + var headers: Bool? + + @Option(help: "Config file path (default: ~/.config/ca/config.json).") + var config: String? + + mutating func run() async throws { + let loader = CaConfigLoader(filePathOverride: config) + var effectiveConfig = await loader.load() + + if let theme { + effectiveConfig.theme.name = theme + } + if let paging { + effectiveConfig.paging = paging + } + if let lineNumbers { + effectiveConfig.lineNumbers = lineNumbers + } + if let headers { + effectiveConfig.headers = headers + } + + do { + let inputs = try InputCollector().collect(paths: paths) + let theme = ThemeResolver().resolve(using: effectiveConfig) + let highlighter = HighlighterService(theme: theme, lineNumbers: effectiveConfig.lineNumbers) + let documents = try inputs.map { try highlighter.render($0) } + let lines = OutputComposer().compose(documents: documents, showHeaders: effectiveConfig.headers) + output(lines: lines, paging: effectiveConfig.paging) + } catch let error as CaError { + Diagnostics.printError(error.description) + throw ExitCode.failure + } catch { + Diagnostics.printError("Unexpected error: \(error)") + throw ExitCode.failure + } + } + + private func output(lines: [String], paging: PagingMode) { + switch paging { + case .never: + write(lines) + case .always: + if let pager = Pager(lines: lines) { + pager.run() + } else { + write(lines) + } + case .auto: + if shouldPage(lines: lines), let pager = Pager(lines: lines) { + pager.run() + } else { + write(lines) + } + } + } + + private func shouldPage(lines: [String]) -> Bool { + guard Terminal.isInteractive, let size = Terminal.size() else { return false } + return lines.count > size.rows + } + + private func write(_ lines: [String]) { + let output = lines.joined(separator: "\n") + if let data = output.data(using: .utf8) { + FileHandle.standardOutput.write(data) + } + } +} diff --git a/Sources/Ca/CaConfig.swift b/Sources/Ca/CaConfig.swift new file mode 100644 index 0000000..a149de7 --- /dev/null +++ b/Sources/Ca/CaConfig.swift @@ -0,0 +1,36 @@ +import ArgumentParser +import Chroma +import Foundation + +struct CaConfig: Equatable { + struct ThemeSelection: Equatable { + var name: String? + var appearance: ThemeAppearancePreference + } + + var theme: ThemeSelection + var lineNumbers: Bool + var paging: PagingMode + var headers: Bool + + static let `default` = CaConfig( + theme: .init(name: nil, appearance: .auto), + lineNumbers: true, + paging: .auto, + headers: true + ) +} + +enum ThemeAppearancePreference: String { + case auto + case dark + case light +} + +enum PagingMode: String, CaseIterable { + case auto + case always + case never +} + +extension PagingMode: ExpressibleByArgument {} diff --git a/Sources/Ca/CaError.swift b/Sources/Ca/CaError.swift new file mode 100644 index 0000000..db3e3b8 --- /dev/null +++ b/Sources/Ca/CaError.swift @@ -0,0 +1,21 @@ +import Foundation + +enum CaError: Error, CustomStringConvertible { + case missingInput + case fileNotFound(String) + case unreadableFile(String) + case directoryNotSupported(String) + + var description: String { + switch self { + case .missingInput: + return "No input provided. Pass a file path or pipe content into ca." + case .fileNotFound(let path): + return "File not found: \(path)" + case .unreadableFile(let path): + return "Unable to read file: \(path)" + case .directoryNotSupported(let path): + return "Directory input is not supported yet: \(path)" + } + } +} diff --git a/Sources/Ca/ConfigLoader.swift b/Sources/Ca/ConfigLoader.swift new file mode 100644 index 0000000..627245d --- /dev/null +++ b/Sources/Ca/ConfigLoader.swift @@ -0,0 +1,61 @@ +import Configuration +import Foundation +import SystemPackage + +struct CaConfigLoader { + let filePathOverride: String? + + func load() async -> CaConfig { +#if os(macOS) + if #available(macOS 15, *) { + return await loadWithConfiguration() + } else { + Diagnostics.printError("Config loading requires macOS 15 or newer. Using defaults.") + return .default + } +#else + return await loadWithConfiguration() +#endif + } + + @available(macOS 15, *) + private func loadWithConfiguration() async -> CaConfig { + let rawPath = filePathOverride ?? defaultConfigPath() + let filePath = expandTilde(rawPath) + if !FileManager.default.fileExists(atPath: filePath) { + return .default + } + do { + let provider = try await JSONProvider(filePath: FilePath(filePath)) + let reader = ConfigReader(provider: provider) + return CaConfig( + theme: .init( + name: reader.string(forKey: "theme.name") ?? reader.string(forKey: "theme"), + appearance: ThemeAppearancePreference( + rawValue: reader.string(forKey: "theme.appearance", default: "auto") + ) ?? .auto + ), + lineNumbers: reader.bool(forKey: "lineNumbers", default: CaConfig.default.lineNumbers), + paging: PagingMode( + rawValue: reader.string(forKey: "paging", default: CaConfig.default.paging.rawValue) + ) ?? CaConfig.default.paging, + headers: reader.bool(forKey: "headers", default: CaConfig.default.headers) + ) + } catch { + Diagnostics.printError("Failed to load config at \(filePath): \(error)") + return .default + } + } +} + +private func defaultConfigPath() -> String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return "\(home)/.config/ca/config.json" +} + +private func expandTilde(_ path: String) -> String { + if path.hasPrefix("~") { + return (path as NSString).expandingTildeInPath + } + return path +} diff --git a/Sources/Ca/Diagnostics.swift b/Sources/Ca/Diagnostics.swift new file mode 100644 index 0000000..7a296c4 --- /dev/null +++ b/Sources/Ca/Diagnostics.swift @@ -0,0 +1,10 @@ +import Foundation + +enum Diagnostics { + static func printError(_ message: String) { + let output = "\(message)\n" + if let data = output.data(using: .utf8) { + FileHandle.standardError.write(data) + } + } +} diff --git a/Sources/Ca/HighlighterService.swift b/Sources/Ca/HighlighterService.swift new file mode 100644 index 0000000..6bf6a85 --- /dev/null +++ b/Sources/Ca/HighlighterService.swift @@ -0,0 +1,43 @@ +import Chroma +import Foundation + +struct HighlightedDocument { + let title: String + let lines: [String] +} + +struct HighlighterService { + private let highlighter: Highlighter + private let options: HighlightOptions + + init(theme: Theme, lineNumbers: Bool) { + self.highlighter = Highlighter(theme: theme) + self.options = HighlightOptions( + colorMode: .auto(output: .stdout), + missingLanguageHandling: .fallbackToPlainText, + lineNumbers: lineNumbers ? LineNumberOptions() : .none + ) + } + + func render(_ input: InputFile) throws -> HighlightedDocument { + let output = try highlighter.highlight( + input.content, + language: input.language, + options: options + ) + return HighlightedDocument( + title: input.displayName, + lines: output.splitLinesPreservingEmpty() + ) + } +} + +private extension String { + func splitLinesPreservingEmpty() -> [String] { + let parts = split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + if parts.isEmpty { + return [""] + } + return parts.map(String.init) + } +} diff --git a/Sources/Ca/InputCollector.swift b/Sources/Ca/InputCollector.swift new file mode 100644 index 0000000..386ae79 --- /dev/null +++ b/Sources/Ca/InputCollector.swift @@ -0,0 +1,86 @@ +import Chroma +import Foundation + +struct InputFile { + let path: String? + let displayName: String + let content: String + let language: LanguageID? + let source: InputSource +} + +enum InputSource { + case file + case stdin +} + +struct InputCollector { + func collect(paths: [String]) throws -> [InputFile] { + if paths.isEmpty { + return [readStdin()] + } + + var inputs: [InputFile] = [] + var skippedDirectories: [String] = [] + + for path in paths { + if path == "-" { + inputs.append(readStdin()) + continue + } + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { + throw CaError.fileNotFound(path) + } + + if isDirectory.boolValue { + skippedDirectories.append(path) + continue + } + + do { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + let content = String(decoding: data, as: UTF8.self) + let displayName = URL(fileURLWithPath: path).lastPathComponent + inputs.append( + InputFile( + path: path, + displayName: displayName, + content: content, + language: LanguageID.fromFilePath(path), + source: .file + ) + ) + } catch { + throw CaError.unreadableFile(path) + } + } + + if inputs.isEmpty { + if let path = skippedDirectories.first { + throw CaError.directoryNotSupported(path) + } + throw CaError.missingInput + } + + if !skippedDirectories.isEmpty { + let list = skippedDirectories.joined(separator: ", ") + Diagnostics.printError("Skipped directories (not supported yet): \(list)") + } + + return inputs + } + + private func readStdin() -> InputFile { + let data = FileHandle.standardInput.readDataToEndOfFile() + let content = String(decoding: data, as: UTF8.self) + return InputFile( + path: nil, + displayName: "", + content: content, + language: nil, + source: .stdin + ) + } +} diff --git a/Sources/Ca/OutputComposer.swift b/Sources/Ca/OutputComposer.swift new file mode 100644 index 0000000..b5182b2 --- /dev/null +++ b/Sources/Ca/OutputComposer.swift @@ -0,0 +1,23 @@ +import Foundation + +struct OutputComposer { + func compose( + documents: [HighlightedDocument], + showHeaders: Bool + ) -> [String] { + guard !documents.isEmpty else { return [] } + let needsHeader = showHeaders && documents.count > 1 + var lines: [String] = [] + + for (index, doc) in documents.enumerated() { + if needsHeader { + lines.append("==> \(doc.title) <==") + } + lines.append(contentsOf: doc.lines) + if index < documents.count - 1 { + lines.append("") + } + } + return lines + } +} diff --git a/Sources/Ca/Pager.swift b/Sources/Ca/Pager.swift new file mode 100644 index 0000000..3468c11 --- /dev/null +++ b/Sources/Ca/Pager.swift @@ -0,0 +1,145 @@ +import Foundation + +#if os(Linux) +import Glibc +#else +import Darwin +#endif + +struct Pager { + let lines: [String] + let viewHeight: Int + + init?(lines: [String]) { + guard Terminal.isInteractive, let size = Terminal.size() else { return nil } + self.lines = lines + self.viewHeight = max(size.rows, 1) + } + + func run() { + let maxOffset = max(0, lines.count - viewHeight) + if maxOffset == 0 { + write(lines.joined(separator: "\n")) + return + } + + let terminalMode = TerminalMode() + terminalMode?.enableRawMode() + TerminalControl.enterAlternateScreen() + TerminalControl.hideCursor() + defer { + TerminalControl.showCursor() + TerminalControl.exitAlternateScreen() + terminalMode?.restore() + } + + var offset = 0 + var reader = KeyReader() + render(offset: offset) + + while let key = reader.readKey() { + let previous = offset + switch key { + case .quit: + return + case .up: + offset = max(0, offset - 1) + case .down: + offset = min(maxOffset, offset + 1) + case .pageUp: + offset = max(0, offset - viewHeight) + case .pageDown: + offset = min(maxOffset, offset + viewHeight) + case .home: + offset = 0 + case .end: + offset = maxOffset + case .none: + break + } + if offset != previous { + render(offset: offset) + } + } + } + + private func render(offset: Int) { + TerminalControl.clearScreen() + let endIndex = min(offset + viewHeight, lines.count) + let slice = lines[offset.. Key? { + guard let byte = readByte() else { return nil } + switch byte { + case 0x1B: + return readEscapeSequence() + case 0x71, 0x51: + return .quit + case 0x6A: + return .down + case 0x6B: + return .up + case 0x20: + return .pageDown + case 0x62: + return .pageUp + case 0x67: + return .home + case 0x47: + return .end + default: + return Key.none + } + } + + private func readEscapeSequence() -> Key { + guard let first = readByte() else { return Key.none } + guard first == 0x5B else { return Key.none } + guard let second = readByte() else { return Key.none } + switch second { + case 0x41: + return .up + case 0x42: + return .down + case 0x48: + return .home + case 0x46: + return .end + case 0x35: + _ = readByte() + return .pageUp + case 0x36: + _ = readByte() + return .pageDown + default: + return Key.none + } + } + + private func readByte() -> UInt8? { + var byte: UInt8 = 0 + let count = read(STDIN_FILENO, &byte, 1) + return count == 1 ? byte : nil + } +} diff --git a/Sources/Ca/Terminal.swift b/Sources/Ca/Terminal.swift new file mode 100644 index 0000000..1d3b9a7 --- /dev/null +++ b/Sources/Ca/Terminal.swift @@ -0,0 +1,100 @@ +import Foundation + +#if os(Linux) +import Glibc +#else +import Darwin +#endif + +struct TerminalSize: Equatable { + let rows: Int + let columns: Int +} + +enum Terminal { + static var isInteractive: Bool { + isatty(STDIN_FILENO) != 0 && isatty(STDOUT_FILENO) != 0 + } + + static func size() -> TerminalSize? { + var ws = winsize() +#if os(Linux) + let request = UInt(TIOCGWINSZ) +#else + let request = TIOCGWINSZ +#endif + if ioctl(STDOUT_FILENO, request, &ws) == 0 { + let rows = Int(ws.ws_row) + let columns = Int(ws.ws_col) + if rows > 0 && columns > 0 { + return TerminalSize(rows: rows, columns: columns) + } + } + return nil + } +} + +final class TerminalMode { + private var original: termios + private var active = false + + init?() { + var current = termios() + guard tcgetattr(STDIN_FILENO, ¤t) == 0 else { return nil } + self.original = current + } + + func enableRawMode() { + guard !active else { return } + var raw = original + cfmakeraw(&raw) + withUnsafeMutablePointer(to: &raw.c_cc) { ptr in + ptr.withMemoryRebound(to: cc_t.self, capacity: Int(NCCS)) { buffer in + buffer[Int(VMIN)] = 1 + buffer[Int(VTIME)] = 0 + } + } + _ = tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) + active = true + } + + func restore() { + guard active else { return } + var saved = original + _ = tcsetattr(STDIN_FILENO, TCSAFLUSH, &saved) + active = false + } + + deinit { + restore() + } +} + +enum TerminalControl { + static func enterAlternateScreen() { + send("\u{1B}[?1049h") + } + + static func exitAlternateScreen() { + send("\u{1B}[?1049l") + } + + static func clearScreen() { + send("\u{1B}[2J") + send("\u{1B}[H") + } + + static func hideCursor() { + send("\u{1B}[?25l") + } + + static func showCursor() { + send("\u{1B}[?25h") + } + + private static func send(_ sequence: String) { + if let data = sequence.data(using: .utf8) { + FileHandle.standardOutput.write(data) + } + } +} diff --git a/Sources/Ca/ThemeResolver.swift b/Sources/Ca/ThemeResolver.swift new file mode 100644 index 0000000..ec52998 --- /dev/null +++ b/Sources/Ca/ThemeResolver.swift @@ -0,0 +1,69 @@ +import Chroma +import ChromaBase46Themes +import Foundation + +struct ThemeResolver { + func resolve(using config: CaConfig) -> Theme { + let appearance = resolveAppearance(using: config) + if let name = config.theme.name { + if let theme = resolveNamedTheme(name) { + return theme + } + Diagnostics.printError("Unknown theme: \(name). Falling back to \(appearance == .light ? "light" : "dark") theme.") + } + switch appearance { + case .light: + return .light + case .dark, .unspecified: + return .dark + } + } + + private func resolveAppearance(using config: CaConfig) -> ThemeAppearance { + switch config.theme.appearance { + case .dark: + return .dark + case .light: + return .light + case .auto: + return TerminalThemeDetector.detectAppearance() ?? .unspecified + } + } + + private func resolveNamedTheme(_ name: String) -> Theme? { + let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines) + if normalized.isEmpty { + return nil + } + switch normalized.lowercased() { + case "dark": + return .dark + case "light": + return .light + default: + break + } + if let theme = Base46Themes.theme(named: normalized) { + return theme + } + let lowercased = normalized.lowercased() + return Base46Themes.all.first { $0.name.lowercased() == lowercased } + } +} + +enum TerminalThemeDetector { + static func detectAppearance() -> ThemeAppearance? { + if let colorfgbg = ProcessInfo.processInfo.environment["COLORFGBG"] { + if let appearance = appearanceFromColorFgBg(colorfgbg) { + return appearance + } + } + return nil + } + + private static func appearanceFromColorFgBg(_ value: String) -> ThemeAppearance? { + let parts = value.split(separator: ";").map(String.init) + guard let last = parts.last, let bg = Int(last) else { return nil } + return bg <= 6 ? .dark : .light + } +}