Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,24 @@ let output = try Chroma.highlight(code, language: .swift)
print(output)
```

### Inferring Language IDs

Infer a language from file names, paths, or URLs:

```swift
let language = LanguageID.fromFileName("MyFile.swift")
let output = try Chroma.highlight(code, language: language)
```

`language` is optional; passing `nil` skips syntax highlighting and returns the original text.

Fallback to plain text when the language is unavailable:

```swift
let options = HighlightOptions(missingLanguageHandling: .fallbackToPlainText)
let output = try Chroma.highlight(code, language: "unknown", options: options)
```

### Themes

Chroma includes two built-in themes:
Expand Down
16 changes: 14 additions & 2 deletions Sources/Chroma/BenchmarkSupport.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Foundation

@_spi(Benchmarking)
public struct TokenBuffer {
fileprivate let tokens: [Token]
Expand All @@ -9,9 +11,14 @@ public struct TokenBuffer {
public enum BenchmarkSupport {
public static func tokenize(
_ code: String,
language: LanguageID,
language: LanguageID?,
registry: LanguageRegistry = .builtIn()
) throws -> TokenBuffer {
guard let language else {
let ns = code as NSString
return TokenBuffer(tokens: [Token(kind: .plain, range: NSRange(location: 0, length: ns.length))])
}

guard let language = registry.language(for: language) else {
throw Highlighter.Error.languageNotFound(language)
}
Expand All @@ -22,10 +29,15 @@ public enum BenchmarkSupport {

public static func tokenize(
_ code: String,
language: LanguageID,
language: LanguageID?,
registry: LanguageRegistry = .builtIn(),
metrics: inout TokenizerMetrics
) throws -> TokenBuffer {
guard let language else {
let ns = code as NSString
return TokenBuffer(tokens: [Token(kind: .plain, range: NSRange(location: 0, length: ns.length))])
}

guard let language = registry.language(for: language) else {
throw Highlighter.Error.languageNotFound(language)
}
Expand Down
6 changes: 3 additions & 3 deletions Sources/Chroma/Chroma.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,22 @@ public enum Chroma {
/// Convenience helper for one-off highlighting using `Chroma.shared`.
public static func highlight(
_ code: String,
language: LanguageID,
language: LanguageID?,
options: HighlightOptions = .init()
) throws -> String {
try shared.highlight(code, language: language, options: options)
}

public static func tokenize(
_ code: String,
language: LanguageID
language: LanguageID?
) throws -> [Token] {
try shared.tokenize(code, language: language)
}

public static func tokenize(
_ code: String,
language: LanguageID,
language: LanguageID?,
emit: (Token) -> Void
) throws {
try shared.tokenize(code, language: language, emit: emit)
Expand Down
8 changes: 8 additions & 0 deletions Sources/Chroma/HighlightOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public struct LineNumberOptions: Equatable {
}

public struct HighlightOptions: Equatable {
public enum MissingLanguageHandling: Equatable {
case error
case fallbackToPlainText
}

public enum DiffCodeStyle: Equatable {
case syntax
case plain
Expand All @@ -44,6 +49,7 @@ public struct HighlightOptions: Equatable {
}

public var theme: Theme?
public var missingLanguageHandling: MissingLanguageHandling
public var diff: DiffHighlight
public var highlightLines: LineRangeSet
public var lineNumbers: LineNumberOptions
Expand All @@ -57,12 +63,14 @@ public struct HighlightOptions: Equatable {

public init(
theme: Theme? = nil,
missingLanguageHandling: MissingLanguageHandling = .error,
diff: DiffHighlight = .auto(),
highlightLines: LineRangeSet = .init(),
lineNumbers: LineNumberOptions = .none,
indent: Int = 0
) {
self.theme = theme
self.missingLanguageHandling = missingLanguageHandling
self.diff = diff
self.highlightLines = highlightLines
self.lineNumbers = lineNumbers
Expand Down
48 changes: 33 additions & 15 deletions Sources/Chroma/Highlighter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,17 @@ public final class Highlighter {

public func highlight(
_ code: String,
language: LanguageID,
language: LanguageID?,
options: HighlightOptions = .init()
) throws -> String {
guard let language = registry.language(for: language) else {
guard let language else {
return code
}

guard let definition = registry.language(for: language) else {
if options.missingLanguageHandling == .fallbackToPlainText {
return code
}
throw Error.languageNotFound(language)
}

Expand All @@ -30,47 +37,58 @@ public final class Highlighter {
let tokens = [Token(kind: .plain, range: NSRange(location: 0, length: ns.length))]
return renderer.render(code: code, tokens: tokens)
}
if isMarkdown(language.id) {
let tokenizer = MarkdownTokenizer(rules: language.rules, registry: registry)
if isMarkdown(definition.id) {
let tokenizer = MarkdownTokenizer(rules: definition.rules, registry: registry)
return renderer.render(code: code, tokens: tokenizer.tokenize(code))
}
let tokenizer = RegexTokenizer(rules: language.rules, fastPath: language.fastPath)
let tokenizer = RegexTokenizer(rules: definition.rules, fastPath: definition.fastPath)
return renderer.render(code: code) { emit in
tokenizer.scan(code, emit: emit)
}
}

public func tokenize(
_ code: String,
language: LanguageID
language: LanguageID?
) throws -> [Token] {
guard let language = registry.language(for: language) else {
guard let language else {
let ns = code as NSString
return [Token(kind: .plain, range: NSRange(location: 0, length: ns.length))]
}

guard let definition = registry.language(for: language) else {
throw Error.languageNotFound(language)
}

if isMarkdown(language.id) {
let tokenizer = MarkdownTokenizer(rules: language.rules, registry: registry)
if isMarkdown(definition.id) {
let tokenizer = MarkdownTokenizer(rules: definition.rules, registry: registry)
return tokenizer.tokenize(code)
}
let tokenizer = RegexTokenizer(rules: language.rules, fastPath: language.fastPath)
let tokenizer = RegexTokenizer(rules: definition.rules, fastPath: definition.fastPath)
return tokenizer.tokenize(code)
}

public func tokenize(
_ code: String,
language: LanguageID,
language: LanguageID?,
emit: (Token) -> Void
) throws {
guard let language = registry.language(for: language) else {
guard let language else {
let ns = code as NSString
emit(Token(kind: .plain, range: NSRange(location: 0, length: ns.length)))
return
}

guard let definition = registry.language(for: language) else {
throw Error.languageNotFound(language)
}

if isMarkdown(language.id) {
let tokenizer = MarkdownTokenizer(rules: language.rules, registry: registry)
if isMarkdown(definition.id) {
let tokenizer = MarkdownTokenizer(rules: definition.rules, registry: registry)
tokenizer.scan(code, emit: emit)
return
}
let tokenizer = RegexTokenizer(rules: language.rules, fastPath: language.fastPath)
let tokenizer = RegexTokenizer(rules: definition.rules, fastPath: definition.fastPath)
tokenizer.scan(code, emit: emit)
}

Expand Down
105 changes: 105 additions & 0 deletions Sources/Chroma/LanguageID.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Foundation

public struct LanguageID: Hashable, RawRepresentable, ExpressibleByStringLiteral, CustomStringConvertible {
public var rawValue: String

Expand Down Expand Up @@ -59,3 +61,106 @@ public extension LanguageID {
static let dockerfile: Self = "dockerfile"
static let makefile: Self = "makefile"
}

public extension LanguageID {
/// Infers a language from a file name or returns `nil` when no match is found.
static func fromFileName(_ fileName: String) -> LanguageID? {
let trimmed = fileName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }

let name = trimmed
.split(whereSeparator: { $0 == "/" || $0 == "\\" })
.last
.map(String.init) ?? trimmed

let lowercased = name.lowercased()
if let direct = fileNameLookup[lowercased] {
return direct
}
if lowercased.hasPrefix("dockerfile.") {
return .dockerfile
}
if lowercased.hasPrefix("makefile.") {
return .makefile
}

guard let ext = fileExtension(from: lowercased),
let language = extensionLookup[ext] else {
return nil
}
return language
}

/// Infers a language from a file path or returns `nil` when no match is found.
static func fromFilePath(_ path: String) -> LanguageID? {
fromFileName(URL(fileURLWithPath: path).lastPathComponent)
}

/// Infers a language from a file URL or returns `nil` when no match is found.
static func fromURL(_ url: URL) -> LanguageID? {
fromFileName(url.lastPathComponent)
}
}

private extension LanguageID {
static let fileNameLookup: [String: LanguageID] = [
"makefile": .makefile,
"gnumakefile": .makefile,
"dockerfile": .dockerfile,
]

static let extensionLookup: [String: LanguageID] = [
"swift": .swift,
"m": .objectiveC,
"mm": .objectiveC,
"c": .c,
"cpp": .cpp,
"cxx": .cxx,
"cc": .cpp,
"c++": .cplusplus,
"hpp": .cpp,
"hxx": .cxx,
"hh": .cpp,
"js": .js,
"jsx": .jsx,
"ts": .ts,
"tsx": .tsx,
"py": .py,
"rb": .rb,
"go": .go,
"rs": .rust,
"kt": .kotlin,
"kts": .kotlin,
"java": .java,
"cs": .cs,
"php": .php,
"dart": .dart,
"lua": .lua,
"sh": .sh,
"bash": .bash,
"zsh": .zsh,
"sql": .sql,
"css": .css,
"scss": .scss,
"sass": .sass,
"less": .less,
"html": .html,
"htm": .html,
"xml": .xml,
"json": .json,
"yaml": .yaml,
"yml": .yml,
"toml": .toml,
"md": .md,
"markdown": .markdown,
"dockerfile": .dockerfile,
"mk": .makefile,
]

static func fileExtension(from name: String) -> String? {
guard let dotIndex = name.lastIndex(of: ".") else { return nil }
let nextIndex = name.index(after: dotIndex)
guard nextIndex < name.endIndex else { return nil }
return String(name[nextIndex...])
}
}
18 changes: 18 additions & 0 deletions Tests/ChromaTests/ChromaFacadeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,22 @@ struct ChromaFacadeTests {
_ = try Chroma.highlight("value", language: "unknown")
}
}

@Test("Chroma.highlight falls back to plain text when configured")
func unknownLanguageFallback() throws {
let code = "value"
let output = try Chroma.highlight(
code,
language: "unknown",
options: .init(missingLanguageHandling: .fallbackToPlainText)
)
#expect(output == code)
}

@Test("Chroma.highlight returns plain text when language is nil")
func nilLanguageReturnsPlainText() throws {
let code = "let value = 1"
let output = try Chroma.highlight(code, language: nil)
#expect(output == code)
}
}
1 change: 1 addition & 0 deletions Tests/ChromaTests/HighlightOptionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ struct HighlightOptionsTests {
func defaults() {
let options = HighlightOptions()
#expect(options.theme == nil)
#expect(options.missingLanguageHandling == .error)
#expect(options.diff == .auto())
#expect(options.highlightLines == LineRangeSet())
#expect(options.lineNumbers == .none)
Expand Down
21 changes: 21 additions & 0 deletions Tests/ChromaTests/IdentifierTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import Testing
@testable import Chroma

Expand All @@ -16,4 +17,24 @@ struct IdentifierTests {
#expect(kind.rawValue == "keyword")
#expect(kind.description == "keyword")
}

@Test("LanguageID infers from file names")
func languageIDFromFileName() {
#expect(LanguageID.fromFileName("MyFile.swift") == .swift)
#expect(LanguageID.fromFileName("hello.kt") == .kotlin)
#expect(LanguageID.fromFileName("Dockerfile") == .dockerfile)
#expect(LanguageID.fromFileName("Dockerfile.dev") == .dockerfile)
#expect(LanguageID.fromFileName("Makefile") == .makefile)
#expect(LanguageID.fromFileName("Makefile.local") == .makefile)
#expect(LanguageID.fromFileName("unknown.ext") == nil)
}

@Test("LanguageID infers from paths and URLs")
func languageIDFromPathAndURL() {
#expect(LanguageID.fromFilePath("/tmp/project/Foo.tsx") == .tsx)
#expect(LanguageID.fromFilePath("/tmp/project/GNUmakefile") == .makefile)

let url = URL(fileURLWithPath: "/tmp/project/App.jsx")
#expect(LanguageID.fromURL(url) == .jsx)
}
}
2 changes: 1 addition & 1 deletion Tests/ChromaTests/Support/TestSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func assertGolden(

func highlightWithTestTheme(
_ code: String,
language: LanguageID,
language: LanguageID?,
registry: LanguageRegistry = .builtIn(),
options: HighlightOptions = .init()
) throws -> String {
Expand Down