
Parses CSI/SGR ANSI escape sequences into styled text slices with color, intensity and style metadata; reconstructs plain text, iterates lines, and supports all 16 ANSI colors.
Categorise ANSI — Fast, zero-dependency ANSI escape sequence parser and categoriser for Kotlin Multiplatform.
A faithful line-by-line transliteration of Kurt Lawrence's cansi Rust crate.
Terminal applications, build tools, test runners, and command-line interfaces emit ANSI escape sequences to format text with colors, font weights, and text styling. However, when consuming this output—such as rendering into terminal user interfaces (TUIs), logging to disk, or displaying in web interfaces—you often need to:
cansi-kotlin provides high-performance, non-allocating parsing of ANSI CSI (Control Sequence Introducer) sequences, focusing specifically on SGR (Select Graphic Rendition) parameters. It avoids unnecessary abstractions, regex engines, and external dependencies.
start and end) for terminal buffer alignment.constructTextNoCodes.lineIter transparently maintains and propagates active styling across \n and \r\n lines.Add cansi-kotlin to your commonMain dependencies:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.kotlinmania:cansi-kotlin:0.1.6")
}
}
}<dependency>
<groupId>io.github.kotlinmania</groupId>
<artifactId>cansi-kotlin</artifactId>
<version>0.1.6</version>
</dependency>The codebase is organized around four core components designed for speed, type safety, and memory efficiency:
[Raw Terminal Text]
│
▼
┌───────────────┐
│ parse(text) │ ──▶ Scans byte sequence for CSI introducers (0x1b, 0x5b)
└───────────────┘ and extracts Match(start, end) byte positions.
│
▼
┌───────────────┐
│ handleSeq(m) │ ──▶ Parses semicolon-delimited decimal parameters
└───────────────┘ and builds an Sgr state model.
│
▼
┌──────────────────────┐
│ categoriseText(text) │ ──▶ Slices text between escape sequences, applying
└──────────────────────┘ accumulated Sgr style to each CategorisedSlice.
│
▼
[List<CategorisedSlice>] ──▶ Clean text spans with nullable Color & Intensity.
ANSI CSI sequences begin with the byte sequence ESC [ (0x1B, 0x5B) and terminate on any byte in the ASCII range 0x40..0x7E (such as 'm').
The Kotlin scanner inspects the UTF-8 byte representation of the input string:
31;1 in \u001b[31;1m).ANSI escape sequences alter terminal state cumulatively. An escape sequence like \u001b[1m enables bold, and subsequent sequences modify foreground or background colors without resetting prior styling unless explicitly reset with code 0.
The Kotlin engine incrementally applies ECMA-48 code numbers:
0: Clears all formatting to default/null.1 / 2 / 22: Sets intensity to Bold, Faint, or clears back to Normal.30..37 / 39: Standard foreground colors and reset.40..47 / 49: Standard background colors and reset.90..97: High-intensity / bright foreground colors.100..107: High-intensity / bright background colors.3–9 / 23–29: Boolean flags for italic, underline, blink, reversed, hidden, and strikethrough.Instead of parsing into an abstract syntax tree (AST), cansi-kotlin yields a flat List<CategorisedSlice>. Each slice contains:
null indicates standard/unaltered terminal default).When processing multi-line text (such as compiler errors or command logs), ANSI styles often wrap across line breaks. lineIter(text) returns an Iterator<List<CategorisedSlice>>:
\n or \r\n.List<CategorisedSlice>.import ai.solace.tui.cansi.Color
import ai.solace.tui.cansi.Intensity
import ai.solace.tui.cansi.categoriseText
fun main() {
// Red, bold "Hello" followed by unstyled ", " and green "World!"
val input = "\u001b[31;1mHello\u001b[0m, \u001b[32mWorld!\u001b[0m"
val slices = categoriseText(input)
for (slice in slices) {
println(
"Text: \"${slice.text}\" | " +
"Range: [${slice.start}, ${slice.end}) | " +
"FG: ${slice.fg} | " +
"Intensity: ${slice.intensity}"
)
}
}Output:
Text: "Hello" | Range: [0, 16) | FG: Red | Intensity: Bold
Text: ", " | Range: [16, 22) | FG: null | Intensity: null
Text: "World!" | Range: [22, 39) | FG: Green | Intensity: null
import ai.solace.tui.cansi.categoriseText
import ai.solace.tui.cansi.constructTextNoCodes
fun main() {
val styled = "\u001b[33mWarning:\u001b[0m Something \u001b[1mnotable\u001b[0m happened."
// Categorise, then reconstruct without escape codes
val slices = categoriseText(styled)
val plain = constructTextNoCodes(slices)
println(plain)
// Output: "Warning: Something notable happened."
}import ai.solace.tui.cansi.lineIter
fun main() {
// A blue style spans across lines
val logOutput = "\u001b[34m[INFO] Task started.\nProcessing step 1...\nProcessing step 2...\u001b[0m"
for ((index, line) in lineIter(logOutput).withIndex()) {
println("--- Line $index ---")
for (slice in line) {
println(" \"${slice.text}\" (Color: ${slice.fg})")
}
}
}data class CategorisedSlice(
val text: String,
val start: Int,
val end: Int,
val fg: Color? = null,
val bg: Color? = null,
val intensity: Intensity? = null,
val italic: Boolean? = null,
val underline: Boolean? = null,
val blink: Boolean? = null,
val reversed: Boolean? = null,
val hidden: Boolean? = null,
val strikethrough: Boolean? = null,
)Standard 16-color ANSI terminal palette:
| Standard (30-37 / 40-47) | Bright / High Intensity (90-97 / 100-107) |
|---|---|
Color.Black |
Color.BrightBlack |
Color.Red |
Color.BrightRed |
Color.Green |
Color.BrightGreen |
Color.Yellow |
Color.BrightYellow |
Color.Blue |
Color.BrightBlue |
Color.Magenta |
Color.BrightMagenta |
Color.Cyan |
Color.BrightCyan |
Color.White |
Color.BrightWhite |
Intensity.Normal: Standard weight (22).Intensity.Bold: Bold or increased intensity (1).Intensity.Faint: Faint, decreased intensity, or dim (2).| Function | Signature | Description |
|---|---|---|
categoriseText |
(String) -> List<CategorisedSlice> |
Deconstructs text into categorised slices. |
categoriseTextV3 |
(String) -> List<CategorisedSlice> |
Direct alias to categoriseText for v3 parity. |
constructTextNoCodes |
(List<CategorisedSlice>) -> String |
Builds an unstyled string by joining slice text. |
lineIter |
(String) -> Iterator<List<CategorisedSlice>> |
Iterates lines while preserving style state. |
terminatedByte |
(Byte) -> Boolean |
Returns true if the byte terminates a CSI sequence. |
cansi-kotlin builds for the complete Kotlin Multiplatform tier matrix:
| Platform | Target Identifiers |
|---|---|
| JVM |
jvm (Java 21+ toolchain) |
| Android |
android (AAR, API 24+), androidNativeArm64, androidNativeX64
|
| Apple |
macosArm64, iosArm64, iosSimulatorArm64, iosX64, tvosArm64, tvosSimulatorArm64, watchosArm64, watchosDeviceArm64, watchosSimulatorArm64
|
| Linux |
linuxX64, linuxArm64
|
| Windows | mingwX64 |
| Web |
js (Browser & Node.js), wasmJs (Browser & Node.js), wasmWasi (WASI) |
cansi-kotlin is a direct transliteration of the cansi Rust crate.
All architectural credit, escape sequence mappings, and design decisions originate from Kurt Lawrence's work.
This project is licensed under the MIT License matching the upstream project license.
Categorise ANSI — Fast, zero-dependency ANSI escape sequence parser and categoriser for Kotlin Multiplatform.
A faithful line-by-line transliteration of Kurt Lawrence's cansi Rust crate.
Terminal applications, build tools, test runners, and command-line interfaces emit ANSI escape sequences to format text with colors, font weights, and text styling. However, when consuming this output—such as rendering into terminal user interfaces (TUIs), logging to disk, or displaying in web interfaces—you often need to:
cansi-kotlin provides high-performance, non-allocating parsing of ANSI CSI (Control Sequence Introducer) sequences, focusing specifically on SGR (Select Graphic Rendition) parameters. It avoids unnecessary abstractions, regex engines, and external dependencies.
start and end) for terminal buffer alignment.constructTextNoCodes.lineIter transparently maintains and propagates active styling across \n and \r\n lines.Add cansi-kotlin to your commonMain dependencies:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.kotlinmania:cansi-kotlin:0.1.6")
}
}
}<dependency>
<groupId>io.github.kotlinmania</groupId>
<artifactId>cansi-kotlin</artifactId>
<version>0.1.6</version>
</dependency>The codebase is organized around four core components designed for speed, type safety, and memory efficiency:
[Raw Terminal Text]
│
▼
┌───────────────┐
│ parse(text) │ ──▶ Scans byte sequence for CSI introducers (0x1b, 0x5b)
└───────────────┘ and extracts Match(start, end) byte positions.
│
▼
┌───────────────┐
│ handleSeq(m) │ ──▶ Parses semicolon-delimited decimal parameters
└───────────────┘ and builds an Sgr state model.
│
▼
┌──────────────────────┐
│ categoriseText(text) │ ──▶ Slices text between escape sequences, applying
└──────────────────────┘ accumulated Sgr style to each CategorisedSlice.
│
▼
[List<CategorisedSlice>] ──▶ Clean text spans with nullable Color & Intensity.
ANSI CSI sequences begin with the byte sequence ESC [ (0x1B, 0x5B) and terminate on any byte in the ASCII range 0x40..0x7E (such as 'm').
The Kotlin scanner inspects the UTF-8 byte representation of the input string:
31;1 in \u001b[31;1m).ANSI escape sequences alter terminal state cumulatively. An escape sequence like \u001b[1m enables bold, and subsequent sequences modify foreground or background colors without resetting prior styling unless explicitly reset with code 0.
The Kotlin engine incrementally applies ECMA-48 code numbers:
0: Clears all formatting to default/null.1 / 2 / 22: Sets intensity to Bold, Faint, or clears back to Normal.30..37 / 39: Standard foreground colors and reset.40..47 / 49: Standard background colors and reset.90..97: High-intensity / bright foreground colors.100..107: High-intensity / bright background colors.3–9 / 23–29: Boolean flags for italic, underline, blink, reversed, hidden, and strikethrough.Instead of parsing into an abstract syntax tree (AST), cansi-kotlin yields a flat List<CategorisedSlice>. Each slice contains:
null indicates standard/unaltered terminal default).When processing multi-line text (such as compiler errors or command logs), ANSI styles often wrap across line breaks. lineIter(text) returns an Iterator<List<CategorisedSlice>>:
\n or \r\n.List<CategorisedSlice>.import ai.solace.tui.cansi.Color
import ai.solace.tui.cansi.Intensity
import ai.solace.tui.cansi.categoriseText
fun main() {
// Red, bold "Hello" followed by unstyled ", " and green "World!"
val input = "\u001b[31;1mHello\u001b[0m, \u001b[32mWorld!\u001b[0m"
val slices = categoriseText(input)
for (slice in slices) {
println(
"Text: \"${slice.text}\" | " +
"Range: [${slice.start}, ${slice.end}) | " +
"FG: ${slice.fg} | " +
"Intensity: ${slice.intensity}"
)
}
}Output:
Text: "Hello" | Range: [0, 16) | FG: Red | Intensity: Bold
Text: ", " | Range: [16, 22) | FG: null | Intensity: null
Text: "World!" | Range: [22, 39) | FG: Green | Intensity: null
import ai.solace.tui.cansi.categoriseText
import ai.solace.tui.cansi.constructTextNoCodes
fun main() {
val styled = "\u001b[33mWarning:\u001b[0m Something \u001b[1mnotable\u001b[0m happened."
// Categorise, then reconstruct without escape codes
val slices = categoriseText(styled)
val plain = constructTextNoCodes(slices)
println(plain)
// Output: "Warning: Something notable happened."
}import ai.solace.tui.cansi.lineIter
fun main() {
// A blue style spans across lines
val logOutput = "\u001b[34m[INFO] Task started.\nProcessing step 1...\nProcessing step 2...\u001b[0m"
for ((index, line) in lineIter(logOutput).withIndex()) {
println("--- Line $index ---")
for (slice in line) {
println(" \"${slice.text}\" (Color: ${slice.fg})")
}
}
}data class CategorisedSlice(
val text: String,
val start: Int,
val end: Int,
val fg: Color? = null,
val bg: Color? = null,
val intensity: Intensity? = null,
val italic: Boolean? = null,
val underline: Boolean? = null,
val blink: Boolean? = null,
val reversed: Boolean? = null,
val hidden: Boolean? = null,
val strikethrough: Boolean? = null,
)Standard 16-color ANSI terminal palette:
| Standard (30-37 / 40-47) | Bright / High Intensity (90-97 / 100-107) |
|---|---|
Color.Black |
Color.BrightBlack |
Color.Red |
Color.BrightRed |
Color.Green |
Color.BrightGreen |
Color.Yellow |
Color.BrightYellow |
Color.Blue |
Color.BrightBlue |
Color.Magenta |
Color.BrightMagenta |
Color.Cyan |
Color.BrightCyan |
Color.White |
Color.BrightWhite |
Intensity.Normal: Standard weight (22).Intensity.Bold: Bold or increased intensity (1).Intensity.Faint: Faint, decreased intensity, or dim (2).| Function | Signature | Description |
|---|---|---|
categoriseText |
(String) -> List<CategorisedSlice> |
Deconstructs text into categorised slices. |
categoriseTextV3 |
(String) -> List<CategorisedSlice> |
Direct alias to categoriseText for v3 parity. |
constructTextNoCodes |
(List<CategorisedSlice>) -> String |
Builds an unstyled string by joining slice text. |
lineIter |
(String) -> Iterator<List<CategorisedSlice>> |
Iterates lines while preserving style state. |
terminatedByte |
(Byte) -> Boolean |
Returns true if the byte terminates a CSI sequence. |
cansi-kotlin builds for the complete Kotlin Multiplatform tier matrix:
| Platform | Target Identifiers |
|---|---|
| JVM |
jvm (Java 21+ toolchain) |
| Android |
android (AAR, API 24+), androidNativeArm64, androidNativeX64
|
| Apple |
macosArm64, iosArm64, iosSimulatorArm64, iosX64, tvosArm64, tvosSimulatorArm64, watchosArm64, watchosDeviceArm64, watchosSimulatorArm64
|
| Linux |
linuxX64, linuxArm64
|
| Windows | mingwX64 |
| Web |
js (Browser & Node.js), wasmJs (Browser & Node.js), wasmWasi (WASI) |
cansi-kotlin is a direct transliteration of the cansi Rust crate.
All architectural credit, escape sequence mappings, and design decisions originate from Kurt Lawrence's work.
This project is licensed under the MIT License matching the upstream project license.