diff --git a/README.md b/README.md index 2a884de..a1607f7 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,26 @@ let output = try Chroma.highlight( ) ``` +### Color Output + +Chroma defaults to auto-detecting ANSI output based on TTY, `TERM=dumb`, and common environment variables (`NO_COLOR`, `CHROMA_NO_COLOR`, `FORCE_COLOR`). + +```swift +// Force ANSI output +let output = try Chroma.highlight( + code, + language: .swift, + options: .init(colorMode: .always) +) + +// Auto-detect for stderr output +let output = try Chroma.highlight( + code, + language: .swift, + options: .init(colorMode: .auto(output: .stderr)) +) +``` + ### Output Indentation Indent the entire output by a specified number of spaces: diff --git a/Sources/Chroma/ColorMode.swift b/Sources/Chroma/ColorMode.swift new file mode 100644 index 0000000..e0c5583 --- /dev/null +++ b/Sources/Chroma/ColorMode.swift @@ -0,0 +1,101 @@ +import Foundation +import Rainbow +#if os(Linux) +import Glibc +#else +import Darwin +#endif + +/// Output stream used for auto color detection. +public enum ColorOutput: Equatable { + case stdout + case stderr +} + +/// Controls when ANSI colors are emitted. +public enum ColorMode: Equatable { + /// Decide automatically using TTY detection, TERM, and color-related environment variables. + case auto(output: ColorOutput) + /// Always emit ANSI colors. + case always + /// Never emit ANSI colors. + case never + /// Defer to `Rainbow.enabled`. + case inheritRainbowEnabled +} + +extension ColorMode { + /// Resolve whether ANSI colors should be enabled in the current process environment. + public func isEnabled() -> Bool { + resolve( + rainbowEnabled: Rainbow.enabled, + environment: ProcessInfo.processInfo.environment, + isTTY: Self.isTTY + ) + } + + func resolve( + rainbowEnabled: Bool, + environment: [String: String], + isTTY: (ColorOutput) -> Bool + ) -> Bool { + switch self { + case .always: + return true + case .never: + return false + case .inheritRainbowEnabled: + return rainbowEnabled + case let .auto(output): + return ColorEnvironment.shouldEnableColor( + output: output, + rainbowEnabled: rainbowEnabled, + environment: environment, + isTTY: isTTY + ) + } + } + + private static func isTTY(_ output: ColorOutput) -> Bool { + switch output { + case .stdout: + return isatty(fileno(stdout)) != 0 + case .stderr: + return isatty(fileno(stderr)) != 0 + } + } +} + +private enum ColorEnvironment { + static func shouldEnableColor( + output: ColorOutput, + rainbowEnabled: Bool, + environment: [String: String], + isTTY: (ColorOutput) -> Bool + ) -> Bool { + guard rainbowEnabled else { return false } + if hasNonEmptyValue("CHROMA_NO_COLOR", environment: environment) { + return false + } + if hasTruthyValue("FORCE_COLOR", environment: environment) { + return true + } + if hasNonEmptyValue("NO_COLOR", environment: environment) { + return false + } + if let term = environment["TERM"], term.lowercased() == "dumb" { + return false + } + return isTTY(output) + } + + private static func hasNonEmptyValue(_ key: String, environment: [String: String]) -> Bool { + guard let value = environment[key] else { return false } + return !value.isEmpty + } + + private static func hasTruthyValue(_ key: String, environment: [String: String]) -> Bool { + guard let value = environment[key] else { return false } + return !value.isEmpty && value != "0" + } +} diff --git a/Sources/Chroma/HighlightOptions.swift b/Sources/Chroma/HighlightOptions.swift index 292097a..90f8b87 100644 --- a/Sources/Chroma/HighlightOptions.swift +++ b/Sources/Chroma/HighlightOptions.swift @@ -49,6 +49,8 @@ public struct HighlightOptions: Equatable { } public var theme: Theme? + /// Controls whether ANSI colors are emitted. + public var colorMode: ColorMode public var missingLanguageHandling: MissingLanguageHandling public var diff: DiffHighlight public var highlightLines: LineRangeSet @@ -63,6 +65,7 @@ public struct HighlightOptions: Equatable { public init( theme: Theme? = nil, + colorMode: ColorMode = .auto(output: .stdout), missingLanguageHandling: MissingLanguageHandling = .error, diff: DiffHighlight = .auto(), highlightLines: LineRangeSet = .init(), @@ -70,6 +73,7 @@ public struct HighlightOptions: Equatable { indent: Int = 0 ) { self.theme = theme + self.colorMode = colorMode self.missingLanguageHandling = missingLanguageHandling self.diff = diff self.highlightLines = highlightLines diff --git a/Sources/Chroma/Renderer.swift b/Sources/Chroma/Renderer.swift index 0061b03..2359ea9 100644 --- a/Sources/Chroma/Renderer.swift +++ b/Sources/Chroma/Renderer.swift @@ -29,7 +29,8 @@ final class Renderer { estimatedSegments: Int?, tokenStream: (_ emit: (Token) -> Void) -> Void ) -> String { - if !Rainbow.enabled && + let colorEnabled = options.colorMode.isEnabled() + if !colorEnabled && options.highlightLines.ranges.isEmpty && options.indent == 0 && !options.lineNumbers.isEnabled { @@ -46,7 +47,7 @@ final class Renderer { var writer = AnsiWriter( estimatedTextLength: code.count, estimatedSegments: estimatedSegments ?? max(16, code.count / 8), - isEnabled: Rainbow.enabled + isEnabled: colorEnabled ) var atLineStart = true diff --git a/Sources/ChromaDemo/ChromaDemo.swift b/Sources/ChromaDemo/ChromaDemo.swift index 803adbc..75eaf63 100644 --- a/Sources/ChromaDemo/ChromaDemo.swift +++ b/Sources/ChromaDemo/ChromaDemo.swift @@ -1562,8 +1562,7 @@ struct DemoArguments { } var theme: ThemeChoice = .dark - var forceColor: Bool = false - var noColor: Bool = false + var colorMode: ColorMode = .auto(output: .stdout) var listLanguages: Bool = false var lang: String? var showComponents: Bool = true @@ -1590,9 +1589,9 @@ struct DemoArguments { case "--light": parsed.theme = .light case "--force-color": - parsed.forceColor = true + parsed.colorMode = .always case "--no-color": - parsed.noColor = true + parsed.colorMode = .never case "--list-languages": parsed.listLanguages = true case "--lang": @@ -1638,11 +1637,8 @@ struct ChromaDemo { let args = Array(CommandLine.arguments.dropFirst()) let options = try DemoArguments.parse(args) - if options.noColor { - Rainbow.enabled = false - } else if options.forceColor { - Rainbow.enabled = true - } + let colorMode = options.colorMode + Rainbow.enabled = colorMode.isEnabled() let theme: Theme = (options.theme == .light) ? .light : .dark let registry = LanguageRegistry.builtIn() @@ -1663,6 +1659,7 @@ struct ChromaDemo { registry: registry, highlighter: highlighter, theme: theme, + colorMode: colorMode, showHeader: options.showComponents ) } catch DemoError.invalidArguments(let message) { @@ -1696,6 +1693,7 @@ struct ChromaDemo { registry: LanguageRegistry, highlighter: Highlighter, theme: Theme, + colorMode: ColorMode, showHeader: Bool ) throws { let displayName = registry.language(for: demo.id)?.displayName ?? demo.id.rawValue @@ -1707,7 +1705,11 @@ struct ChromaDemo { } printSection("Sample") - print(try highlighter.highlight(demo.sample, language: demo.id)) + print(try highlighter.highlight( + demo.sample, + language: demo.id, + options: .init(colorMode: colorMode) + )) print("") printSection("Sample + Line Numbers + Highlights") @@ -1715,6 +1717,7 @@ struct ChromaDemo { demo.sample, language: demo.id, options: .init( + colorMode: colorMode, highlightLines: demo.highlightLines, lineNumbers: .init(start: 1) ) @@ -1726,6 +1729,7 @@ struct ChromaDemo { demo.patch, language: demo.id, options: .init( + colorMode: colorMode, diff: .patch(style: .background(diffCode: .plain), presentation: .verbose), lineNumbers: .init(start: 1) ) @@ -1737,6 +1741,7 @@ struct ChromaDemo { demo.patch, language: demo.id, options: .init( + colorMode: colorMode, diff: .patch(style: .foreground(contextCode: .plain), presentation: .compact), lineNumbers: .init(start: 1) ) diff --git a/Tests/ChromaTests/ChromaFacadeTests.swift b/Tests/ChromaTests/ChromaFacadeTests.swift index 7e0f9c6..32d2e88 100644 --- a/Tests/ChromaTests/ChromaFacadeTests.swift +++ b/Tests/ChromaTests/ChromaFacadeTests.swift @@ -10,7 +10,7 @@ struct ChromaFacadeTests { let output = try Chroma.highlight( "let value = 1", language: .swift, - options: .init(theme: theme) + options: .init(theme: theme, colorMode: .always) ) let expected = renderExpected([ diff --git a/Tests/ChromaTests/ColorModeTests.swift b/Tests/ChromaTests/ColorModeTests.swift new file mode 100644 index 0000000..67555a6 --- /dev/null +++ b/Tests/ChromaTests/ColorModeTests.swift @@ -0,0 +1,75 @@ +import Testing +@testable import Chroma + +@Suite("ColorMode") +struct ColorModeTests { + @Test("Auto disables when NO_COLOR is set") + func autoDisablesOnNoColor() { + let enabled = ColorMode.auto(output: .stdout).resolve( + rainbowEnabled: true, + environment: ["NO_COLOR": "1"], + isTTY: { _ in true } + ) + #expect(!enabled) + } + + @Test("Auto disables when CHROMA_NO_COLOR is set") + func autoDisablesOnChromaNoColor() { + let enabled = ColorMode.auto(output: .stderr).resolve( + rainbowEnabled: true, + environment: ["CHROMA_NO_COLOR": "true"], + isTTY: { _ in true } + ) + #expect(!enabled) + } + + @Test("Auto disables for dumb terminals") + func autoDisablesOnDumbTerm() { + let enabled = ColorMode.auto(output: .stdout).resolve( + rainbowEnabled: true, + environment: ["TERM": "dumb"], + isTTY: { _ in true } + ) + #expect(!enabled) + } + + @Test("Auto disables when not a TTY") + func autoDisablesOnNonTTY() { + let enabled = ColorMode.auto(output: .stdout).resolve( + rainbowEnabled: true, + environment: [:], + isTTY: { _ in false } + ) + #expect(!enabled) + } + + @Test("Auto respects FORCE_COLOR even without TTY") + func autoHonorsForceColor() { + let enabled = ColorMode.auto(output: .stdout).resolve( + rainbowEnabled: true, + environment: ["FORCE_COLOR": "1"], + isTTY: { _ in false } + ) + #expect(enabled) + } + + @Test("CHROMA_NO_COLOR overrides FORCE_COLOR") + func chromaNoColorOverridesForceColor() { + let enabled = ColorMode.auto(output: .stdout).resolve( + rainbowEnabled: true, + environment: ["CHROMA_NO_COLOR": "1", "FORCE_COLOR": "1"], + isTTY: { _ in true } + ) + #expect(!enabled) + } + + @Test("Auto respects Rainbow.enabled") + func autoRespectsRainbowEnabled() { + let enabled = ColorMode.auto(output: .stdout).resolve( + rainbowEnabled: false, + environment: [:], + isTTY: { _ in true } + ) + #expect(!enabled) + } +} diff --git a/Tests/ChromaTests/HighlightOptionsTests.swift b/Tests/ChromaTests/HighlightOptionsTests.swift index 007a580..b7cf409 100644 --- a/Tests/ChromaTests/HighlightOptionsTests.swift +++ b/Tests/ChromaTests/HighlightOptionsTests.swift @@ -7,6 +7,7 @@ struct HighlightOptionsTests { func defaults() { let options = HighlightOptions() #expect(options.theme == nil) + #expect(options.colorMode == .auto(output: .stdout)) #expect(options.missingLanguageHandling == .error) #expect(options.diff == .auto()) #expect(options.highlightLines == LineRangeSet()) diff --git a/Tests/ChromaTests/RendererTests.swift b/Tests/ChromaTests/RendererTests.swift index b730d14..67f5ed7 100644 --- a/Tests/ChromaTests/RendererTests.swift +++ b/Tests/ChromaTests/RendererTests.swift @@ -8,7 +8,7 @@ struct RendererTests { func appliesLineHighlight() { ensureRainbowEnabled() let theme = TestThemes.stable - let options = HighlightOptions(theme: theme, diff: .none, highlightLines: [2...2]) + let options = HighlightOptions(theme: theme, colorMode: .always, diff: .none, highlightLines: [2...2]) let renderer = Renderer(theme: theme, options: options) let code = "let a\nlet b" @@ -36,7 +36,7 @@ struct RendererTests { func highlightOverridesDiff() throws { ensureRainbowEnabled() let theme = TestThemes.stable - let options = HighlightOptions(theme: theme, diff: .patch(), highlightLines: [1...1]) + let options = HighlightOptions(theme: theme, colorMode: .always, diff: .patch(), highlightLines: [1...1]) let renderer = Renderer(theme: theme, options: options) let code = "+let a = 1" @@ -59,7 +59,7 @@ struct RendererTests { func diffForegroundUsesPlainStyle() throws { ensureRainbowEnabled() let theme = TestThemes.stable - let options = HighlightOptions(theme: theme, diff: .patch(style: .foreground())) + let options = HighlightOptions(theme: theme, colorMode: .always, diff: .patch(style: .foreground())) let renderer = Renderer(theme: theme, options: options) let code = "let a\n+let value = 1" @@ -92,6 +92,7 @@ struct RendererTests { let theme = TestThemes.stable let options = HighlightOptions( theme: theme, + colorMode: .always, diff: .patch(style: .foreground(contextCode: .syntax)) ) let renderer = Renderer(theme: theme, options: options) @@ -112,7 +113,11 @@ struct RendererTests { func diffBackgroundPlainStyle() throws { ensureRainbowEnabled() let theme = TestThemes.stable - let options = HighlightOptions(theme: theme, diff: .patch(style: .background(diffCode: .plain))) + let options = HighlightOptions( + theme: theme, + colorMode: .always, + diff: .patch(style: .background(diffCode: .plain)) + ) let renderer = Renderer(theme: theme, options: options) let code = "let a\n+let value = 1" @@ -141,6 +146,7 @@ struct RendererTests { let theme = TestThemes.stable let options = HighlightOptions( theme: theme, + colorMode: .always, diff: .patch(style: .background(diffCode: .syntax, contextCode: .plain)) ) let renderer = Renderer(theme: theme, options: options) @@ -169,7 +175,7 @@ struct RendererTests { func diffBackgroundDefaultSyntax() throws { ensureRainbowEnabled() let theme = TestThemes.stable - let options = HighlightOptions(theme: theme, diff: .patch()) + let options = HighlightOptions(theme: theme, colorMode: .always, diff: .patch()) let renderer = Renderer(theme: theme, options: options) let code = "let a\n+let b" @@ -194,6 +200,7 @@ struct RendererTests { let theme = TestThemes.stable let options = HighlightOptions( theme: theme, + colorMode: .always, diff: .patch(presentation: .compact), lineNumbers: .init(start: 1) ) @@ -225,6 +232,7 @@ struct RendererTests { let theme = TestThemes.stable let options = HighlightOptions( theme: theme, + colorMode: .always, diff: .patch(presentation: .compact), lineNumbers: .init(start: 1) ) @@ -257,6 +265,7 @@ struct RendererTests { let theme = TestThemes.stable let options = HighlightOptions( theme: theme, + colorMode: .always, diff: .patch(presentation: .verbose), lineNumbers: .init(start: 1) ) @@ -282,7 +291,12 @@ struct RendererTests { func lineNumbersRender() { ensureRainbowEnabled() let theme = TestThemes.stable - let options = HighlightOptions(theme: theme, diff: .none, lineNumbers: .init(start: 1)) + let options = HighlightOptions( + theme: theme, + colorMode: .always, + diff: .none, + lineNumbers: .init(start: 1) + ) let renderer = Renderer(theme: theme, options: options) let code = "let a\nlet b" @@ -309,6 +323,7 @@ struct RendererTests { let theme = TestThemes.stable let options = HighlightOptions( theme: theme, + colorMode: .always, diff: .none, lineNumbers: .init(start: 9), indent: 2 @@ -339,7 +354,12 @@ struct RendererTests { func lineNumbersFollowDiffHunks() { ensureRainbowEnabled() let theme = TestThemes.stable - let options = HighlightOptions(theme: theme, diff: .none, lineNumbers: .init(start: 1)) + let options = HighlightOptions( + theme: theme, + colorMode: .always, + diff: .none, + lineNumbers: .init(start: 1) + ) let renderer = Renderer(theme: theme, options: options) let code = """ @@ -377,7 +397,12 @@ struct RendererTests { func lineNumbersUseWhiteOnDiffBackground() { ensureRainbowEnabled() let theme = TestThemes.stable - let options = HighlightOptions(theme: theme, diff: .patch(), lineNumbers: .init(start: 1)) + let options = HighlightOptions( + theme: theme, + colorMode: .always, + diff: .patch(), + lineNumbers: .init(start: 1) + ) let renderer = Renderer(theme: theme, options: options) let code = """ @@ -416,6 +441,7 @@ struct RendererTests { let theme = TestThemes.stable let options = HighlightOptions( theme: theme, + colorMode: .always, diff: .patch(style: .foreground()), lineNumbers: .init(start: 1) ) @@ -453,7 +479,13 @@ struct RendererTests { diffRemovedForeground: .named(.red), lineNumberForeground: .named(.white) ) - let options = HighlightOptions(theme: theme, diff: .none, highlightLines: .init(), indent: 2) + let options = HighlightOptions( + theme: theme, + colorMode: .always, + diff: .none, + highlightLines: .init(), + indent: 2 + ) let renderer = Renderer(theme: theme, options: options) let code = "let a\n\nlet b\n" @@ -470,7 +502,13 @@ struct RendererTests { func indentAppliesLineHighlight() { ensureRainbowEnabled() let theme = TestThemes.stable - let options = HighlightOptions(theme: theme, diff: .none, highlightLines: [2...2], indent: 2) + let options = HighlightOptions( + theme: theme, + colorMode: .always, + diff: .none, + highlightLines: [2...2], + indent: 2 + ) let renderer = Renderer(theme: theme, options: options) let code = "let a\nlet b" @@ -500,7 +538,13 @@ struct RendererTests { func indentAppliesDiffBackgrounds() { ensureRainbowEnabled() let theme = TestThemes.stable - let options = HighlightOptions(theme: theme, diff: .patch(), highlightLines: .init(), indent: 1) + let options = HighlightOptions( + theme: theme, + colorMode: .always, + diff: .patch(), + highlightLines: .init(), + indent: 1 + ) let renderer = Renderer(theme: theme, options: options) let code = "+let a\n-let b" diff --git a/Tests/ChromaTests/Support/TestSupport.swift b/Tests/ChromaTests/Support/TestSupport.swift index 960ec0f..159d0e4 100644 --- a/Tests/ChromaTests/Support/TestSupport.swift +++ b/Tests/ChromaTests/Support/TestSupport.swift @@ -99,5 +99,6 @@ func highlightWithTestTheme( if options.theme == nil { options.theme = theme } + options.colorMode = .always return try highlighter.highlight(code, language: language, options: options) }