diff --git a/Benchmarks/ChromaBenchmarks/ChromaBenchmarks.swift b/Benchmarks/ChromaBenchmarks/ChromaBenchmarks.swift index 46690bb..892b0af 100644 --- a/Benchmarks/ChromaBenchmarks/ChromaBenchmarks.swift +++ b/Benchmarks/ChromaBenchmarks/ChromaBenchmarks.swift @@ -39,7 +39,7 @@ let curatedSwiftTokens = try! BenchmarkSupport.tokenize(curatedSwift, language: let curatedDiffTokens = try! BenchmarkSupport.tokenize(curatedDiff, language: .swift, registry: registry) let defaultOptions = HighlightOptions() -let diffOptions = HighlightOptions(diff: .patch) +let diffOptions = HighlightOptions(diff: .patch()) let shouldPrintMetrics = ProcessInfo.processInfo.environment["CHROMA_BENCH_PRINT_METRICS"] == "1" diff --git a/README.md b/README.md index 5a21313..5273217 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ let output = try Chroma.highlight( ### Diff highlighting (unified patch) -`Chroma` can highlight `+` / `-` lines with green / red background, following the common `git diff` patch rules. +`Chroma` can highlight `+` / `-` lines in unified patches, following the common `git diff` patch rules. ```swift let patch = """ @@ -111,11 +111,41 @@ index 1111111..2222222 100644 let output = try Chroma.highlight( patch, language: .swift, - options: .init(diff: .patch) + options: .init(diff: .patch()) ) print(output) ``` +Foreground-only diff (plain text, no token styling): + +```swift +let output = try Chroma.highlight( + patch, + language: .swift, + options: .init(diff: .patch(style: .foreground())) +) +``` + +Foreground diff with syntax-highlighted context lines: + +```swift +let output = try Chroma.highlight( + patch, + language: .swift, + options: .init(diff: .patch(style: .foreground(contextCode: .syntax))) +) +``` + +Background diff without code styling: + +```swift +let output = try Chroma.highlight( + patch, + language: .swift, + options: .init(diff: .patch(style: .background(diffCode: .plain))) +) +``` + ## Custom languages `Chroma` uses a regex-based tokenizer (similar to Prism / highlight.js) so new languages can be defined by rules. diff --git a/Sources/Chroma/Diff.swift b/Sources/Chroma/Diff.swift index 1b6bdbb..434b516 100644 --- a/Sources/Chroma/Diff.swift +++ b/Sources/Chroma/Diff.swift @@ -10,7 +10,31 @@ enum DiffLineKind { enum DiffDetector { static func looksLikePatch(_ code: String) -> Bool { - return looksLikePatch(lines: splitLines(code)) + var lineStart = code.startIndex + var index = lineStart + + func lineLooksLikePatch(start: String.Index, end: String.Index) -> Bool { + guard start < end else { return false } + let line = code[start.. Bool { diff --git a/Sources/Chroma/HighlightOptions.swift b/Sources/Chroma/HighlightOptions.swift index e389db6..73d8811 100644 --- a/Sources/Chroma/HighlightOptions.swift +++ b/Sources/Chroma/HighlightOptions.swift @@ -1,8 +1,18 @@ public struct HighlightOptions: Equatable { + public enum DiffCodeStyle: Equatable { + case syntax + case plain + } + + public enum DiffStyle: Equatable { + case background(diffCode: DiffCodeStyle = .syntax, contextCode: DiffCodeStyle = .syntax) + case foreground(contextCode: DiffCodeStyle = .plain) + } + public enum DiffHighlight: Equatable { case none - case auto - case patch + case auto(style: DiffStyle = .background()) + case patch(style: DiffStyle = .background()) } public var theme: Theme? @@ -18,7 +28,7 @@ public struct HighlightOptions: Equatable { public init( theme: Theme? = nil, - diff: DiffHighlight = .auto, + diff: DiffHighlight = .auto(), highlightLines: LineRangeSet = .init(), indent: Int = 0 ) { @@ -28,3 +38,74 @@ public struct HighlightOptions: Equatable { self.indent = max(0, indent) } } + +extension HighlightOptions { + struct DiffRendering: Equatable { + let style: DiffStyle + } + + var maySkipTokenization: Bool { + switch diff { + case .none: + return false + case let .auto(style), let .patch(style): + return style.diffCodeStyle == .plain && style.contextCodeStyle == .plain + } + } + + func diffRendering(for code: String) -> DiffRendering? { + diff.rendering(for: code) + } + + func shouldSkipTokenization(for code: String) -> Bool { + guard let rendering = diffRendering(for: code) else { return false } + let style = rendering.style + return style.diffCodeStyle == .plain && style.contextCodeStyle == .plain + } +} + +extension HighlightOptions.DiffHighlight { + func rendering(for code: String) -> HighlightOptions.DiffRendering? { + switch self { + case .none: + return nil + case let .patch(style): + return HighlightOptions.DiffRendering(style: style) + case let .auto(style): + guard DiffDetector.looksLikePatch(code) else { return nil } + return HighlightOptions.DiffRendering(style: style) + } + } + + func rendering(for lines: [Substring]) -> HighlightOptions.DiffRendering? { + switch self { + case .none: + return nil + case let .patch(style): + return HighlightOptions.DiffRendering(style: style) + case let .auto(style): + guard DiffDetector.looksLikePatch(lines: lines) else { return nil } + return HighlightOptions.DiffRendering(style: style) + } + } +} + +extension HighlightOptions.DiffStyle { + var diffCodeStyle: HighlightOptions.DiffCodeStyle { + switch self { + case let .background(diffCode, _): + return diffCode + case .foreground: + return .plain + } + } + + var contextCodeStyle: HighlightOptions.DiffCodeStyle { + switch self { + case let .background(_, contextCode): + return contextCode + case let .foreground(contextCode): + return contextCode + } + } +} diff --git a/Sources/Chroma/Highlighter.swift b/Sources/Chroma/Highlighter.swift index bcdbb46..535257e 100644 --- a/Sources/Chroma/Highlighter.swift +++ b/Sources/Chroma/Highlighter.swift @@ -24,8 +24,13 @@ public final class Highlighter { } let theme = options.theme ?? self.theme - let tokenizer = RegexTokenizer(rules: language.rules, fastPath: language.fastPath) let renderer = Renderer(theme: theme, options: options) + if options.maySkipTokenization && options.shouldSkipTokenization(for: code) { + let ns = code as NSString + let tokens = [Token(kind: .plain, range: NSRange(location: 0, length: ns.length))] + return renderer.render(code: code, tokens: tokens) + } + let tokenizer = RegexTokenizer(rules: language.rules, fastPath: language.fastPath) return renderer.render(code: code) { emit in tokenizer.scan(code, emit: emit) } diff --git a/Sources/Chroma/Renderer.swift b/Sources/Chroma/Renderer.swift index 72c3526..7758748 100644 --- a/Sources/Chroma/Renderer.swift +++ b/Sources/Chroma/Renderer.swift @@ -30,15 +30,8 @@ final class Renderer { tokenStream: (_ emit: (Token) -> Void) -> Void ) -> String { if !Rainbow.enabled && options.highlightLines.ranges.isEmpty && options.indent == 0 { - switch options.diff { - case .none: + if options.diff.rendering(for: code) == nil { return code - case .auto: - if !DiffDetector.looksLikePatch(code) { - return code - } - case .patch: - break } } @@ -54,7 +47,7 @@ final class Renderer { ) var atLineStart = true - if plan.hasLineBackgrounds { + if plan.hasLineOverrides { var currentLine = 1 tokenStream { token in appendTokenSegments( @@ -63,6 +56,8 @@ final class Renderer { kind: token.kind, currentLine: ¤tLine, lineBackgrounds: plan.lineBackgrounds, + lineForegrounds: plan.lineForegrounds, + linePlainStyles: plan.linePlainStyles, lineBreaks: plan.lineBreaks, indentPrefix: indentPrefix, plainStyle: plainStyle, @@ -93,6 +88,8 @@ final class Renderer { kind: TokenKind, currentLine: inout Int, lineBackgrounds: [BackgroundColorType?], + lineForegrounds: [ColorType?], + linePlainStyles: [Bool], lineBreaks: [Int], indentPrefix: String, plainStyle: TextStyle, @@ -101,7 +98,6 @@ final class Renderer { ) { guard range.length > 0 else { return } - let style = styleCache.style(for: kind) let end = range.location + range.length var location = range.location var lineIndex = currentLine - 1 @@ -111,10 +107,12 @@ final class Renderer { if let nextBreak, nextBreak < end { let pieceLength = nextBreak - location let background = backgroundForLine(currentLine, lineBackgrounds: lineBackgrounds) + let foreground = foregroundForLine(currentLine, lineForegrounds: lineForegrounds) if pieceLength == 0 { appendIndentIfNeeded( indentPrefix: indentPrefix, plainStyle: plainStyle, + foregroundOverride: foreground, backgroundOverride: background, atLineStart: &atLineStart, into: &writer @@ -122,14 +120,30 @@ final class Renderer { } if pieceLength > 0 { let piece = ns.substring(with: NSRange(location: location, length: pieceLength)) + let usePlainTextStyle = plainStyleForLine(currentLine, linePlainStyles: linePlainStyles) + let style = resolvedStyle(for: kind, usePlainTextStyle: usePlainTextStyle, plainStyle: plainStyle) appendIndentIfNeeded( indentPrefix: indentPrefix, plainStyle: plainStyle, + foregroundOverride: foreground, backgroundOverride: background, atLineStart: &atLineStart, into: &writer ) - writer.append(text: piece, style: style, backgroundOverride: background) + if let foreground { + writer.append( + text: piece, + style: style, + foregroundOverride: foreground, + backgroundOverride: background + ) + } else { + writer.append( + text: piece, + style: style, + backgroundOverride: background + ) + } atLineStart = false } @@ -143,14 +157,31 @@ final class Renderer { if pieceLength > 0 { let piece = ns.substring(with: NSRange(location: location, length: pieceLength)) let background = backgroundForLine(currentLine, lineBackgrounds: lineBackgrounds) + let foreground = foregroundForLine(currentLine, lineForegrounds: lineForegrounds) + let usePlainTextStyle = plainStyleForLine(currentLine, linePlainStyles: linePlainStyles) + let style = resolvedStyle(for: kind, usePlainTextStyle: usePlainTextStyle, plainStyle: plainStyle) appendIndentIfNeeded( indentPrefix: indentPrefix, plainStyle: plainStyle, + foregroundOverride: foreground, backgroundOverride: background, atLineStart: &atLineStart, into: &writer ) - writer.append(text: piece, style: style, backgroundOverride: background) + if let foreground { + writer.append( + text: piece, + style: style, + foregroundOverride: foreground, + backgroundOverride: background + ) + } else { + writer.append( + text: piece, + style: style, + backgroundOverride: background + ) + } atLineStart = false } break @@ -168,7 +199,7 @@ final class Renderer { into writer: inout AnsiWriter ) { guard range.length > 0 else { return } - let style = styleCache.style(for: kind) + let style = resolvedStyle(for: kind, usePlainTextStyle: false, plainStyle: plainStyle) let piece = ns.substring(with: range) if indentPrefix.isEmpty { writer.append(text: piece, style: style, backgroundOverride: nil) @@ -178,6 +209,7 @@ final class Renderer { appendTextWithIndent( piece, style: style, + foregroundOverride: nil, backgroundOverride: nil, indentPrefix: indentPrefix, plainStyle: plainStyle, @@ -192,6 +224,26 @@ final class Renderer { return lineBackgrounds[index] } + private func foregroundForLine(_ line: Int, lineForegrounds: [ColorType?]) -> ColorType? { + let index = line - 1 + guard index >= 0, index < lineForegrounds.count else { return nil } + return lineForegrounds[index] + } + + private func plainStyleForLine(_ line: Int, linePlainStyles: [Bool]) -> Bool { + let index = line - 1 + guard index >= 0, index < linePlainStyles.count else { return false } + return linePlainStyles[index] + } + + private func resolvedStyle( + for kind: TokenKind, + usePlainTextStyle: Bool, + plainStyle: TextStyle + ) -> TextStyle { + usePlainTextStyle ? plainStyle : styleCache.style(for: kind) + } + private func makeIndentPrefix() -> String { guard options.indent > 0 else { return "" } return String(repeating: " ", count: options.indent) @@ -200,18 +252,33 @@ final class Renderer { private func appendIndentIfNeeded( indentPrefix: String, plainStyle: TextStyle, + foregroundOverride: ColorType?, backgroundOverride: BackgroundColorType?, atLineStart: inout Bool, into writer: inout AnsiWriter ) { guard atLineStart, !indentPrefix.isEmpty else { return } - writer.append(text: indentPrefix, style: plainStyle, backgroundOverride: backgroundOverride) + if let foregroundOverride { + writer.append( + text: indentPrefix, + style: plainStyle, + foregroundOverride: foregroundOverride, + backgroundOverride: backgroundOverride + ) + } else { + writer.append( + text: indentPrefix, + style: plainStyle, + backgroundOverride: backgroundOverride + ) + } atLineStart = false } private func appendTextWithIndent( _ text: String, style: TextStyle, + foregroundOverride: ColorType?, backgroundOverride: BackgroundColorType?, indentPrefix: String, plainStyle: TextStyle, @@ -227,12 +294,26 @@ final class Renderer { appendIndentIfNeeded( indentPrefix: indentPrefix, plainStyle: plainStyle, + foregroundOverride: foregroundOverride, backgroundOverride: backgroundOverride, atLineStart: &atLineStart, into: &writer ) if !segment.isEmpty { - writer.append(text: String(segment), style: style, backgroundOverride: backgroundOverride) + if let foregroundOverride { + writer.append( + text: String(segment), + style: style, + foregroundOverride: foregroundOverride, + backgroundOverride: backgroundOverride + ) + } else { + writer.append( + text: String(segment), + style: style, + backgroundOverride: backgroundOverride + ) + } atLineStart = false } writer.appendPlain("\n") @@ -247,12 +328,26 @@ final class Renderer { appendIndentIfNeeded( indentPrefix: indentPrefix, plainStyle: plainStyle, + foregroundOverride: foregroundOverride, backgroundOverride: backgroundOverride, atLineStart: &atLineStart, into: &writer ) if !segment.isEmpty { - writer.append(text: String(segment), style: style, backgroundOverride: backgroundOverride) + if let foregroundOverride { + writer.append( + text: String(segment), + style: style, + foregroundOverride: foregroundOverride, + backgroundOverride: backgroundOverride + ) + } else { + writer.append( + text: String(segment), + style: style, + backgroundOverride: backgroundOverride + ) + } atLineStart = false } break @@ -262,51 +357,98 @@ final class Renderer { private struct RenderPlan { let lineBackgrounds: [BackgroundColorType?] - let hasLineBackgrounds: Bool + let lineForegrounds: [ColorType?] + let linePlainStyles: [Bool] + let hasLineOverrides: Bool let lineBreaks: [Int] } private func makePlan(for code: String) -> RenderPlan { - let lines = splitLines(code) + let diffRendering = options.diff.rendering(for: code) + if diffRendering == nil && options.highlightLines.ranges.isEmpty { + return RenderPlan( + lineBackgrounds: [], + lineForegrounds: [], + linePlainStyles: [], + hasLineOverrides: false, + lineBreaks: [] + ) + } - let diffEnabled: Bool = { - switch options.diff { - case .none: return false - case .patch: return true - case .auto: return DiffDetector.looksLikePatch(lines: lines) - } - }() + let lines = splitLines(code) - var lineBackgrounds = Array(repeating: nil, count: lines.count) - var hasLineBackgrounds = false - if diffEnabled { + var lineBackgrounds: [BackgroundColorType?] = [] + var lineForegrounds: [ColorType?] = [] + var linePlainStyles: [Bool] = [] + var hasLineOverrides = false + if let diffRendering { + let diffStyle = diffRendering.style + lineBackgrounds = [BackgroundColorType?](repeating: nil, count: lines.count) + lineForegrounds = [ColorType?](repeating: nil, count: lines.count) + linePlainStyles = Array(repeating: false, count: lines.count) for (index, line) in lines.enumerated() { - guard let kind = DiffDetector.kind(forLine: line) else { continue } + let kind = DiffDetector.kind(forLine: line) + let isDiffLine: Bool = { + switch kind { + case .added?, .removed?: + return true + default: + return false + } + }() + + let codeStyle = isDiffLine ? diffStyle.diffCodeStyle : diffStyle.contextCodeStyle + if codeStyle == .plain { + linePlainStyles[index] = true + hasLineOverrides = true + } + switch kind { case .added: - lineBackgrounds[index] = theme.diffAddedBackground - hasLineBackgrounds = true + switch diffStyle { + case .background(diffCode: _, contextCode: _): + lineBackgrounds[index] = theme.diffAddedBackground + hasLineOverrides = true + case .foreground: + lineForegrounds[index] = theme.diffAddedForeground + hasLineOverrides = true + } case .removed: - lineBackgrounds[index] = theme.diffRemovedBackground - hasLineBackgrounds = true - case .fileHeader, .hunkHeader, .meta: + switch diffStyle { + case .background(diffCode: _, contextCode: _): + lineBackgrounds[index] = theme.diffRemovedBackground + hasLineOverrides = true + case .foreground: + lineForegrounds[index] = theme.diffRemovedForeground + hasLineOverrides = true + } + case .fileHeader, .hunkHeader, .meta, .none: break } } } if !options.highlightLines.ranges.isEmpty { + if lineBackgrounds.isEmpty { + lineBackgrounds = [BackgroundColorType?](repeating: nil, count: lines.count) + } for (index, _) in lines.enumerated() { let lineNumber = index + 1 if options.highlightLines.contains(lineNumber) { lineBackgrounds[index] = theme.lineHighlightBackground - hasLineBackgrounds = true + hasLineOverrides = true } } } - let lineBreaks = hasLineBackgrounds ? lineBreakLocations(code) : [] - return RenderPlan(lineBackgrounds: lineBackgrounds, hasLineBackgrounds: hasLineBackgrounds, lineBreaks: lineBreaks) + let lineBreaks = hasLineOverrides ? lineBreakLocations(code) : [] + return RenderPlan( + lineBackgrounds: lineBackgrounds, + lineForegrounds: lineForegrounds, + linePlainStyles: linePlainStyles, + hasLineOverrides: hasLineOverrides, + lineBreaks: lineBreaks + ) } private func lineBreakLocations(_ code: String) -> [Int] { @@ -411,6 +553,72 @@ private struct AnsiWriter { result.append("\u{001B}[0m") } + mutating func append( + text: String, + style: TextStyle, + foregroundOverride: ColorType?, + backgroundOverride: BackgroundColorType? + ) { + guard let foregroundOverride else { + append(text: text, style: style, backgroundOverride: backgroundOverride) + return + } + + if text.isEmpty { + result.append(text) + return + } + + guard isEnabled else { + result.append(text) + return + } + + let color: ColorType? = foregroundOverride + let background = backgroundOverride ?? style.background + let styles = style.styles + + if color == nil && background == nil && styles == nil { + result.append(text) + return + } + + if let cached = prefixCache.first(where: { $0.matches(color: color, backgroundColor: background, styles: styles) }) { + result.append(cached.prefix) + result.append(text) + result.append("\u{001B}[0m") + return + } + + var codes: [UInt8] = [] + if let color { codes += color.value } + if let background { codes += background.value } + if let styles { codes += styles.flatMap { $0.value } } + + if codes.isEmpty { + result.append(text) + return + } + + var prefix = "\u{001B}[" + for (index, code) in codes.enumerated() { + if index > 0 { prefix.append(";") } + prefix.append(String(code)) + } + prefix.append("m") + prefixCache.append( + PrefixCacheEntry( + color: color, + backgroundColor: background, + styles: styles, + prefix: prefix + ) + ) + result.append(prefix) + result.append(text) + result.append("\u{001B}[0m") + } + func finish() -> String { result } diff --git a/Sources/Chroma/TextStyle.swift b/Sources/Chroma/TextStyle.swift index 0e8393d..ecfb6a5 100644 --- a/Sources/Chroma/TextStyle.swift +++ b/Sources/Chroma/TextStyle.swift @@ -15,13 +15,16 @@ public struct TextStyle: Equatable { self.styles = styles } - func makeSegment(text: String, backgroundOverride: BackgroundColorType? = nil) -> Rainbow.Segment { + func makeSegment( + text: String, + foregroundOverride: ColorType? = nil, + backgroundOverride: BackgroundColorType? = nil + ) -> Rainbow.Segment { Rainbow.Segment( text: text, - color: foreground, + color: foregroundOverride ?? foreground, backgroundColor: backgroundOverride ?? background, styles: styles ) } } - diff --git a/Sources/Chroma/Theme.swift b/Sources/Chroma/Theme.swift index 6a76651..269067b 100644 --- a/Sources/Chroma/Theme.swift +++ b/Sources/Chroma/Theme.swift @@ -13,18 +13,28 @@ public struct Theme: Equatable { /// Background used by `HighlightOptions.diff` for removed lines. public var diffRemovedBackground: BackgroundColorType + /// Foreground used by `HighlightOptions.diff` for added lines. + public var diffAddedForeground: ColorType + + /// Foreground used by `HighlightOptions.diff` for removed lines. + public var diffRemovedForeground: ColorType + public init( name: String, tokenStyles: [TokenKind: TextStyle], lineHighlightBackground: BackgroundColorType, diffAddedBackground: BackgroundColorType, - diffRemovedBackground: BackgroundColorType + diffRemovedBackground: BackgroundColorType, + diffAddedForeground: ColorType, + diffRemovedForeground: ColorType ) { self.name = name self.tokenStyles = tokenStyles self.lineHighlightBackground = lineHighlightBackground self.diffAddedBackground = diffAddedBackground self.diffRemovedBackground = diffRemovedBackground + self.diffAddedForeground = diffAddedForeground + self.diffRemovedForeground = diffRemovedForeground } public func style(for kind: TokenKind) -> TextStyle { @@ -69,7 +79,9 @@ public extension Theme { ], lineHighlightBackground: .named(.lightBlack), diffAddedBackground: .named(.green), - diffRemovedBackground: .named(.red) + diffRemovedBackground: .named(.red), + diffAddedForeground: .named(.lightGreen), + diffRemovedForeground: .named(.lightRed) ) static let light = Theme( @@ -88,6 +100,8 @@ public extension Theme { ], lineHighlightBackground: .named(.lightYellow), diffAddedBackground: .named(.lightGreen), - diffRemovedBackground: .named(.lightRed) + diffRemovedBackground: .named(.lightRed), + diffAddedForeground: .named(.green), + diffRemovedForeground: .named(.red) ) } diff --git a/Sources/ChromaDemo/ChromaDemo.swift b/Sources/ChromaDemo/ChromaDemo.swift index 6aed1fc..c3eaf86 100644 --- a/Sources/ChromaDemo/ChromaDemo.swift +++ b/Sources/ChromaDemo/ChromaDemo.swift @@ -178,7 +178,23 @@ struct ChromaDemo { print(try highlighter.highlight( Samples.patch, language: .swift, - options: .init(diff: .patch) + options: .init(diff: .patch()) + )) + print("") + + printSection("Diff Highlighting (foreground)") + print(try highlighter.highlight( + Samples.patch, + language: .swift, + options: .init(diff: .patch(style: .foreground())) + )) + print("") + + printSection("Diff Highlighting (background, plain code)") + print(try highlighter.highlight( + Samples.patch, + language: .swift, + options: .init(diff: .patch(style: .background(diffCode: .plain))) )) print("") diff --git a/Tests/ChromaTests/ChromaTests.swift b/Tests/ChromaTests/ChromaTests.swift index 7db6867..a013320 100644 --- a/Tests/ChromaTests/ChromaTests.swift +++ b/Tests/ChromaTests/ChromaTests.swift @@ -34,7 +34,9 @@ struct HighlighterOutputTests { ], lineHighlightBackground: .named(.lightYellow), diffAddedBackground: .named(.lightGreen), - diffRemovedBackground: .named(.lightRed) + diffRemovedBackground: .named(.lightRed), + diffAddedForeground: .named(.green), + diffRemovedForeground: .named(.red) ) let output = try highlightWithTestTheme( "// comment", @@ -77,7 +79,7 @@ struct HighlighterOutputTests { let output = try highlightWithTestTheme( patch, language: .swift, - options: .init(diff: .patch) + options: .init(diff: .patch()) ) let expectedAdded = renderExpected([ diff --git a/Tests/ChromaTests/HighlightOptionsTests.swift b/Tests/ChromaTests/HighlightOptionsTests.swift index 4a0cc84..8c8b976 100644 --- a/Tests/ChromaTests/HighlightOptionsTests.swift +++ b/Tests/ChromaTests/HighlightOptionsTests.swift @@ -7,7 +7,7 @@ struct HighlightOptionsTests { func defaults() { let options = HighlightOptions() #expect(options.theme == nil) - #expect(options.diff == .auto) + #expect(options.diff == .auto()) #expect(options.highlightLines == LineRangeSet()) #expect(options.indent == 0) } diff --git a/Tests/ChromaTests/PerformanceTests.swift b/Tests/ChromaTests/PerformanceTests.swift index f88035b..b813a92 100644 --- a/Tests/ChromaTests/PerformanceTests.swift +++ b/Tests/ChromaTests/PerformanceTests.swift @@ -52,7 +52,7 @@ struct PerformanceTests { _ = try highlightWithTestTheme( code, language: .swift, - options: .init(diff: .patch) + options: .init(diff: .patch()) ) let elapsed = clock.now - start diff --git a/Tests/ChromaTests/RendererTests.swift b/Tests/ChromaTests/RendererTests.swift index 70745be..3ded1db 100644 --- a/Tests/ChromaTests/RendererTests.swift +++ b/Tests/ChromaTests/RendererTests.swift @@ -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, diff: .patch(), highlightLines: [1...1]) let renderer = Renderer(theme: theme, options: options) let code = "+let a = 1" @@ -55,6 +55,139 @@ struct RendererTests { #expect(!output.contains(unexpected)) } + @Test("Diff foreground uses line foreground and ignores token styles") + func diffForegroundUsesPlainStyle() throws { + ensureRainbowEnabled() + let theme = TestThemes.stable + let options = HighlightOptions(theme: theme, diff: .patch(style: .foreground())) + let renderer = Renderer(theme: theme, options: options) + + let code = "let a\n+let value = 1" + let language = BuiltInLanguages.all.first { $0.id == .swift }! + let tokens = RegexTokenizer(rules: language.rules).tokenize(code) + + let output = renderer.render(code: code, tokens: tokens) + let expectedContextPlain = renderExpected([ + ExpectedToken(.plain, "let") + ]) + let expectedDiffPlain = renderExpected([ + ExpectedToken(.plain, "let", foreground: theme.diffAddedForeground) + ]) + let unexpectedContextKeyword = renderExpected([ + ExpectedToken(.keyword, "let") + ]) + let unexpectedKeyword = renderExpected([ + ExpectedToken(.keyword, "let", foreground: theme.diffAddedForeground) + ]) + + #expect(output.contains(expectedContextPlain)) + #expect(output.contains(expectedDiffPlain)) + #expect(!output.contains(unexpectedContextKeyword)) + #expect(!output.contains(unexpectedKeyword)) + } + + @Test("Diff foreground can keep syntax styling for context lines") + func diffForegroundKeepsContextSyntax() throws { + ensureRainbowEnabled() + let theme = TestThemes.stable + let options = HighlightOptions( + theme: theme, + diff: .patch(style: .foreground(contextCode: .syntax)) + ) + let renderer = Renderer(theme: theme, options: options) + + let code = "let a\n+let b" + let language = BuiltInLanguages.all.first { $0.id == .swift }! + let tokens = RegexTokenizer(rules: language.rules).tokenize(code) + + let output = renderer.render(code: code, tokens: tokens) + let expectedContext = renderExpected([ + ExpectedToken(.keyword, "let") + ]) + + #expect(output.contains(expectedContext)) + } + + @Test("Diff background can disable code styling") + func diffBackgroundPlainStyle() throws { + ensureRainbowEnabled() + let theme = TestThemes.stable + let options = HighlightOptions(theme: theme, diff: .patch(style: .background(diffCode: .plain))) + let renderer = Renderer(theme: theme, options: options) + + let code = "let a\n+let value = 1" + let language = BuiltInLanguages.all.first { $0.id == .swift }! + let tokens = RegexTokenizer(rules: language.rules).tokenize(code) + + let output = renderer.render(code: code, tokens: tokens) + let expectedContextKeyword = renderExpected([ + ExpectedToken(.keyword, "let") + ]) + let expectedDiffPlain = renderExpected([ + ExpectedToken(.plain, "let", background: theme.diffAddedBackground) + ]) + let unexpectedKeyword = renderExpected([ + ExpectedToken(.keyword, "let", background: theme.diffAddedBackground) + ]) + + #expect(output.contains(expectedContextKeyword)) + #expect(output.contains(expectedDiffPlain)) + #expect(!output.contains(unexpectedKeyword)) + } + + @Test("Diff background can keep context plain while diff uses syntax") + func diffBackgroundKeepsContextPlain() throws { + ensureRainbowEnabled() + let theme = TestThemes.stable + let options = HighlightOptions( + theme: theme, + diff: .patch(style: .background(diffCode: .syntax, contextCode: .plain)) + ) + let renderer = Renderer(theme: theme, options: options) + + let code = "let a\n+let b" + let language = BuiltInLanguages.all.first { $0.id == .swift }! + let tokens = RegexTokenizer(rules: language.rules).tokenize(code) + + let output = renderer.render(code: code, tokens: tokens) + let expectedContextPlain = renderExpected([ + ExpectedToken(.plain, "let") + ]) + let expectedDiffKeyword = renderExpected([ + ExpectedToken(.keyword, "let", background: theme.diffAddedBackground) + ]) + let unexpectedContextKeyword = renderExpected([ + ExpectedToken(.keyword, "let") + ]) + + #expect(output.contains(expectedContextPlain)) + #expect(output.contains(expectedDiffKeyword)) + #expect(!output.contains(unexpectedContextKeyword)) + } + + @Test("Diff background defaults to syntax for diff and context") + func diffBackgroundDefaultSyntax() throws { + ensureRainbowEnabled() + let theme = TestThemes.stable + let options = HighlightOptions(theme: theme, diff: .patch()) + let renderer = Renderer(theme: theme, options: options) + + let code = "let a\n+let b" + let language = BuiltInLanguages.all.first { $0.id == .swift }! + let tokens = RegexTokenizer(rules: language.rules).tokenize(code) + + let output = renderer.render(code: code, tokens: tokens) + let expectedContextKeyword = renderExpected([ + ExpectedToken(.keyword, "let") + ]) + let expectedDiffKeyword = renderExpected([ + ExpectedToken(.keyword, "let", background: theme.diffAddedBackground) + ]) + + #expect(output.contains(expectedContextKeyword)) + #expect(output.contains(expectedDiffKeyword)) + } + @Test("Indent applies to empty lines") func indentAppliesToEmptyLines() { let theme = Theme( @@ -62,7 +195,9 @@ struct RendererTests { tokenStyles: [.plain: .init()], lineHighlightBackground: .named(.black), diffAddedBackground: .named(.black), - diffRemovedBackground: .named(.black) + diffRemovedBackground: .named(.black), + diffAddedForeground: .named(.green), + diffRemovedForeground: .named(.red) ) let options = HighlightOptions(theme: theme, diff: .none, highlightLines: .init(), indent: 2) let renderer = Renderer(theme: theme, options: options) @@ -111,7 +246,7 @@ 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, 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 7e7ac5d..88d0b7c 100644 --- a/Tests/ChromaTests/Support/TestSupport.swift +++ b/Tests/ChromaTests/Support/TestSupport.swift @@ -19,18 +19,27 @@ enum TestThemes { ], lineHighlightBackground: .named(.lightYellow), diffAddedBackground: .named(.lightGreen), - diffRemovedBackground: .named(.lightRed) + diffRemovedBackground: .named(.lightRed), + diffAddedForeground: .named(.green), + diffRemovedForeground: .named(.red) ) } struct ExpectedToken: Equatable { let kind: TokenKind let text: String + let foreground: ColorType? let background: BackgroundColorType? - init(_ kind: TokenKind, _ text: String, background: BackgroundColorType? = nil) { + init( + _ kind: TokenKind, + _ text: String, + foreground: ColorType? = nil, + background: BackgroundColorType? = nil + ) { self.kind = kind self.text = text + self.foreground = foreground self.background = background } @@ -53,7 +62,11 @@ private enum RainbowTestState { func renderExpected(_ tokens: [ExpectedToken], theme: Theme = TestThemes.stable) -> String { ensureRainbowEnabled() let segments = tokens.map { token in - theme.style(for: token.kind).makeSegment(text: token.text, backgroundOverride: token.background) + theme.style(for: token.kind).makeSegment( + text: token.text, + foregroundOverride: token.foreground, + backgroundOverride: token.background + ) } return AnsiStringGenerator.generate(for: Rainbow.Entry(segments: segments)) } diff --git a/Tests/ChromaTests/TextStyleTests.swift b/Tests/ChromaTests/TextStyleTests.swift index 0e03392..53d4d77 100644 --- a/Tests/ChromaTests/TextStyleTests.swift +++ b/Tests/ChromaTests/TextStyleTests.swift @@ -4,7 +4,7 @@ import Testing @Suite("TextStyle") struct TextStyleTests { - @Test("makeSegment respects background override") + @Test("makeSegment respects overrides") func backgroundOverride() { let style = TextStyle( foreground: .named(.red), @@ -12,10 +12,14 @@ struct TextStyleTests { styles: [.bold] ) - let segment = style.makeSegment(text: "hi", backgroundOverride: .named(.green)) + let segment = style.makeSegment( + text: "hi", + foregroundOverride: .named(.yellow), + backgroundOverride: .named(.green) + ) #expect(segment.text == "hi") - #expect(segment.color == .named(.red)) + #expect(segment.color == .named(.yellow)) #expect(segment.backgroundColor == .named(.green)) #expect(segment.styles == [.bold]) } diff --git a/Tests/ChromaTests/ThemeTests.swift b/Tests/ChromaTests/ThemeTests.swift index c37ead8..d4c453d 100644 --- a/Tests/ChromaTests/ThemeTests.swift +++ b/Tests/ChromaTests/ThemeTests.swift @@ -13,7 +13,9 @@ struct ThemeTests { ], lineHighlightBackground: .named(.lightYellow), diffAddedBackground: .named(.lightGreen), - diffRemovedBackground: .named(.lightRed) + diffRemovedBackground: .named(.lightRed), + diffAddedForeground: .named(.green), + diffRemovedForeground: .named(.red) ) let style = theme.style(for: .keyword)