diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9e01d93..d06aaac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 1.0 - 2025.06.07
+
+- Update to Kotlin 2.2.0
+- Change: Final API refinement (incompatible, see readme and tests for usage examples)
+
## 0.17 - 2025-05-04
### Fixed
- Issue #24 (Improve headers and rows detection)
diff --git a/README.md b/README.md
index d07224e..9aeb3ab 100644
--- a/README.md
+++ b/README.md
@@ -9,43 +9,59 @@ Small, fast and convenient multiplatform CSV parser and builder written for one
# Architecture
-
+
# Usage
Here is what you can do with the library:
```kotlin
-// (1) Build a CSV file using simple DSL
+// (1) build csv
val csv = buildCsv {
- row {
- value("Code")
- value("Name")
+ header {
+ column("Code")
+ column("Name")
}
- row {
+ data {
value("DE")
- value("Germany")
+ value("Deutschland")
}
- row {
+ data {
value("BY")
value("Belarus")
}
-}
+} as CsvWithHeader
+
+val code = csv.header.columnByName("Code") as CsvColumn
+val name = csv.header.columnByName("Name") as CsvColumn
+
+assertEquals(code, CsvColumn(0, "Code"))
+assertEquals(name, CsvColumn(1, "Name"))
+assertEquals(csv.data[0][code], "DE")
+assertEquals(csv.data[0][name], "Deutschland")
+assertEquals(csv.data[1][code], "BY")
+assertEquals(csv.data[1][name], "Belarus")
-// (2) Export a CSV object to a CSV string
+// (2) csv to text
val csvText = csv.toCsvText()
+assertEquals("Code,Name\nDE,Deutschland\nBY,Belarus\n", csvText)
-// (3) Parse a CSV string to get a CSV object
-val csv2 = Csv.parseCsvText(csvText)
+// (3) parse csv text
+val csv2 = CsvWithHeader.parseCsvText(csvText) as CsvWithHeader
-// There are the data structures supported by the library
-val allRows: List = csv2.rows
-val header: HeaderRow = csv2.header
-val data: List = csv2.data
+assertEquals(csv.header, csv2.header)
+assertEquals(csv.data, csv2.data)
+assertEquals(csv.allRows, csv2.allRows)
-// (4) Transform CSV data
-val codes = data.map { it.value("Code") } // ["DE", "BY"]
-val names = data.map { it.value("Name") } // ["Germany", "Belarus"]
+// (4) transform csv
+val csv3 = csv.copy(
+ data = csv.data.map { row ->
+ row.mapValueOf(name) { value ->
+ if (value == "Belarus") "Weißrussland" else value
+ }
+ }
+)
+assertEquals("Code,Name\nDE,Deutschland\nBY,Weißrussland\n", csv3.toCsvText())
```
# Dependencies
@@ -53,8 +69,8 @@ val names = data.map { it.value("Name") } // ["Germany", "Belarus"]
In `gradle/libs.versions.toml`
```toml
[versions]
-kotlin = "2.1.20"
-csv = "0.17"
+kotlin = "2.2.0"
+csv = "1.0"
[libraries]
csv = { module = "de.halfbit:csv", version.ref = "csv" }
diff --git a/build.gradle.kts b/build.gradle.kts
index c1d7eec..efbf122 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,7 +8,7 @@ plugins {
}
group = "de.halfbit"
-version = "0.17"
+version = "1.0"
repositories {
mavenCentral()
@@ -67,15 +67,7 @@ if (canPublishToMaven) {
}
maven {
name = "central"
- url = uri("https://oss.sonatype.org/service/local/staging/deploy/maven2/")
- credentials {
- username = project.getPropertyOrEmptyString("publishing.nexus.user")
- password = project.getPropertyOrEmptyString("publishing.nexus.password")
- }
- }
- maven {
- name = "snapshot"
- url = uri("https://oss.sonatype.org/content/repositories/snapshots")
+ url = uri("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/")
credentials {
username = project.getPropertyOrEmptyString("publishing.nexus.user")
password = project.getPropertyOrEmptyString("publishing.nexus.password")
diff --git a/documentation/architecture.v3.iuml b/documentation/architecture.v3.iuml
new file mode 100644
index 0000000..64b1107
--- /dev/null
+++ b/documentation/architecture.v3.iuml
@@ -0,0 +1,31 @@
+@startuml
+skinparam packageStyle rectangle
+hide empty members
+hide stereotype
+hide circle
+
+class Csv {
+ allRows: List
+ ---
+ toCsvText(): String
+}
+
+class CsvNoHeader {
+ data: List
+ --
+ fromCsvText(): CsvNoHeader
+ fromLists(): CsvNoHeader
+}
+
+class CsvWithHeader {
+ header: CsvHeaderRow
+ data: List
+ --
+ fromCsvText(): CsvWithHeader?
+ fromLists(): CsvWithHeader?
+}
+
+CsvWithHeader -up-|> Csv
+CsvNoHeader -up-|> Csv
+
+@enduml
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index caf2236..40602e4 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,5 +1,5 @@
[versions]
-kotlin = "2.1.20"
+kotlin = "2.2.0"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 29d2731..40957b2 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
\ No newline at end of file
diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock
index b6f76bb..2af455a 100644
--- a/kotlin-js-store/yarn.lock
+++ b/kotlin-js-store/yarn.lock
@@ -1871,11 +1871,6 @@ type-is@~1.6.18:
media-typer "0.3.0"
mime-types "~2.1.24"
-typescript@5.5.4:
- version "5.5.4"
- resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba"
- integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==
-
ua-parser-js@^0.7.30:
version "0.7.40"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.40.tgz#c87d83b7bb25822ecfa6397a0da5903934ea1562"
diff --git a/src/commonMain/kotlin/Csv.kt b/src/commonMain/kotlin/Csv.kt
index 8c57d13..10595d9 100644
--- a/src/commonMain/kotlin/Csv.kt
+++ b/src/commonMain/kotlin/Csv.kt
@@ -1,18 +1,23 @@
-/** Copyright 2023 Halfbit GmbH, Sergej Shafarenka */
+/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */
package de.halfbit.csv
-import de.halfbit.csv.BaseCsv.Row
-
/**
- * Object with comma-separated values stored as list of [Row]'s. Each row is a list
- * of strings. Empty values are empty strings.
+ * Base class for CSV data, containing all rows of the CSV file.
*
- * If your CSV-data has header row, use [BaseCsv] instead of this base type.
+ * @property allRows all rows in the CSV file, including header if present
*/
-public interface BaseCsv {
- public val allRows: List
+public abstract class Csv(
+ public val allRows: List,
+) {
+ public abstract val data: List
- // Multiline issue: https://stackoverflow.com/questions/2668678/importing-csv-with-line-breaks-in-excel-2007
+ /**
+ * Converts the CSV data to a CSV-formatted string.
+ *
+ * @param newLine the line separator to use
+ * @param escapeWhitespaces whether to escape whitespaces in values
+ * @return the CSV-formatted string representation
+ */
public fun toCsvText(
newLine: NewLine = NewLine.LF,
escapeWhitespaces: Boolean = false,
@@ -28,61 +33,28 @@ public interface BaseCsv {
append(newLine.value)
}
}
-
- public interface Row : List {
- public fun replaceValue(valueIndex: Int, newValue: String): Row
- }
}
-/**
- * CSV-object by with a mandatory header row. It has more convenient methods
- * for working with columns by their names.
- */
-public interface Csv : BaseCsv {
- public val header: HeaderRow
- public val data: List
-
- public interface HeaderRow : Row {
- public fun indexOfColumn(name: String): Int
- }
-
- public interface DataRow : Row {
- public fun value(columnName: String): String
- public fun replaceValue(columnName: String, newValue: String): DataRow
- }
-
- public companion object {
- /** Use it for parsing a cvs-formatted text. */
- public fun parserCsvText(csvText: String): Csv = parseCsv(csvText)
+/** Represents a single row in a CSV file as a list of string values. */
+public typealias CsvRow = List
- @Deprecated(
- message = "Replaced with the more consistently named 'parserCsvText()' method." +
- " This method will be removed in 0.18, please migrate.",
- replaceWith = ReplaceWith("parserCsvText(csvText)"),
- )
- public fun parserText(csvText: String): Csv = parseCsv(csvText)
-
- public fun fromLists(allRows: List>): BaseCsv {
- return BaseCsv(allRows.map { DefaultRow(it) })
- }
-
- public fun fromLists(header: List, data: List>): Csv {
- val headerRow = DefaultHeaderRow(header)
- return Csv(
- header = headerRow,
- data = data.map { DefaultDataRow(it, headerRow) },
- )
- }
- }
-}
+/** Represents a data row in a CSV file, excluding the header row. */
+public typealias CsvDataRow = List
+/**
+ * Specifies the line separator used in CSV output.
+ *
+ * @property value the string value of the line separator
+ */
public enum class NewLine(
public val value: String,
) {
/** Line feed as the line terminator */
LF("\n"),
+
/** Carriage Return + Line feed as the line terminator */
CRLF("\r\n"),
+
/** Carriage Return as the line terminator */
CR("\r")
}
diff --git a/src/commonMain/kotlin/CsvNoHeader.kt b/src/commonMain/kotlin/CsvNoHeader.kt
new file mode 100644
index 0000000..1a61245
--- /dev/null
+++ b/src/commonMain/kotlin/CsvNoHeader.kt
@@ -0,0 +1,44 @@
+/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */
+package de.halfbit.csv
+
+/**
+ * Represents CSV data without a header row.
+ *
+ * @property data the data rows
+ */
+public data class CsvNoHeader(
+ public override val data: List,
+) : Csv(data) {
+
+ override fun toString(): String =
+ buildString {
+ data.forEach { data ->
+ append(data)
+ append("\n")
+ }
+ }
+
+ public companion object {
+
+ /**
+ * Parses CSV text into a [CsvNoHeader] instance.
+ *
+ * @param csvText the CSV-formatted string
+ * @return a [CsvNoHeader] instance
+ */
+ public fun fromCsvText(csvText: String): CsvNoHeader {
+ val (_, data) = parseCsv(csvText, false)
+ return CsvNoHeader(data)
+ }
+
+ /**
+ * Creates a [CsvNoHeader] from a list of rows.
+ *
+ * @param allRows all data rows
+ * @return a [CsvNoHeader] instance
+ */
+ public fun fromLists(allRows: List>): CsvNoHeader {
+ return CsvNoHeader(allRows)
+ }
+ }
+}
diff --git a/src/commonMain/kotlin/CsvWithHeader.kt b/src/commonMain/kotlin/CsvWithHeader.kt
new file mode 100644
index 0000000..b5cbeea
--- /dev/null
+++ b/src/commonMain/kotlin/CsvWithHeader.kt
@@ -0,0 +1,171 @@
+/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */
+package de.halfbit.csv
+
+/**
+ * Represents CSV data with a header row.
+ *
+ * @property header the header row of the CSV
+ * @property data the data rows
+ */
+public data class CsvWithHeader(
+ public val header: CsvHeaderRow,
+ public override val data: List,
+) : Csv(listOf(header.map { it.name }) + data) {
+
+ override fun toString(): String =
+ buildString {
+ append(header)
+ append("\n")
+ data.forEach { data ->
+ append(data)
+ append("\n")
+ }
+ }
+
+ public companion object {
+
+ /**
+ * Parses CSV text into a [CsvWithHeader] instance.
+ *
+ * @param csvText the CSV-formatted string
+ * @return a [CsvWithHeader] if parsing succeeds and the header row is present, or `null` otherwise.
+ */
+ public fun fromCsvText(csvText: String): CsvWithHeader? {
+ val (header, data) = parseCsv(csvText, true)
+ return if (header == null) null else {
+ CsvWithHeader(header, data)
+ }
+ }
+
+ /**
+ * Creates a [CsvWithHeader] from a list of rows, where the first row is the header.
+ *
+ * @param allRows all rows, with the first row as header
+ * @return a [CsvWithHeader] if header is present, or `null` otherwise.
+ */
+ public fun fromLists(allRows: List>): CsvWithHeader? {
+ val headerRowNames = allRows.getOrNull(0)
+ return if (headerRowNames == null) null else {
+ val headerRow = headerRowNames.toCsvHeaderRow()
+ CsvWithHeader(headerRow, allRows.subList(1, allRows.size))
+ }
+ }
+
+ /**
+ * Creates a [CsvWithHeader] from a list of header and data rows.
+ *
+ * @param header the header rows
+ * @param header data rows
+ * @return a [CsvWithHeader] if header row is not empty, or `null` otherwise.
+ */
+ public fun fromLists(header: List, data: List>): CsvWithHeader? {
+ return if (header.isEmpty()) null else {
+ val headerRow = header.toCsvHeaderRow()
+ CsvWithHeader(headerRow, data)
+ }
+ }
+ }
+}
+
+/**
+ * Represents the header row of a CSV file, containing column definitions.
+ *
+ * @constructor Creates a header row from a list of [CsvColumn]s.
+ * @property columns list of columns in the header row
+ */
+public data class CsvHeaderRow(
+ @PublishedApi internal val columns: List,
+) : List by columns {
+ private val columnsByName = columns.associateBy { it.name }
+
+ /** Returns the [CsvColumn] with the given [name], or `null` if not found. */
+ public fun columnByName(name: String): CsvColumn? = columnsByName[name]
+
+ /** Returns the [CsvColumn] at the given [index], or `null` if out of bounds. */
+ public fun columnByIndex(index: Int): CsvColumn? = columns.getOrNull(index)
+}
+
+/**
+ * Represents a column in a [CsvHeaderRow] of a CSV file.
+ *
+ * @property index Zero-based index of the column inside its [CsvHeaderRow].
+ * @property name Name (label) of the column as defined in [CsvHeaderRow].
+ */
+public data class CsvColumn(
+
+ /** Zero-based index of the column inside its [CsvHeaderRow]. */
+ public val index: Int,
+
+ /** Name (label) of the column as it is defined in [CsvHeaderRow]. */
+ public val name: String,
+)
+
+/**
+ * Returns the value from this row corresponding to the given [column].
+ *
+ * @param column the column to retrieve the value for
+ * @return the value at the column's index
+ */
+public operator fun CsvDataRow.get(column: CsvColumn): String =
+ get(column.index)
+
+/**
+ * Returns the value from this row for the given [column], or `null` if
+ * the index is out of bounds.
+ *
+ * @param column the column to retrieve the value for
+ * @return the value at the column's index, or `null` if not present
+ */
+public fun CsvDataRow.getOrNull(column: CsvColumn): String? =
+ getOrNull(column.index)
+
+/**
+ * Returns the value from this row for the given [column], or an
+ * empty string if not present.
+ *
+ * @param column the column to retrieve the value for
+ * @return the value at the column's index, or an empty string if not present
+ */
+public fun CsvDataRow.getOrEmpty(column: CsvColumn): String? =
+ getOrNull(column.index) ?: ""
+
+/**
+ * Returns the value from this row for the given [column], or computes a
+ * default value if not present.
+ *
+ * @param column the column to retrieve the value for
+ * @param defaultValue function to compute a default value if the column is
+ * missing from the dataset
+ * @return the value at the column's index, or the result of [defaultValue]
+ */
+public inline fun CsvDataRow.getOrElse(column: CsvColumn, defaultValue: (CsvColumn) -> String): String =
+ getOrElse(column.index) { defaultValue(column) }
+
+/**
+ * Applies [transform] to the value at the given [column], returning
+ * a new list with the transformed value.
+ *
+ * @param column the column whose value will be transformed
+ * @param transform function to apply to the value
+ * @return a new list with the transformed value at the specified column
+ *
+ * Usage example: replace value "Belarus" with "Weißrussland" in the "Name" column
+ * ```
+ * val name = csv.header.columnByName("Name") as CsvColumn
+ * val transformedCsv = csv.copy(
+ * data = csv.data.map { row ->
+ * row.mapValueOf(name) { value ->
+ * if (value == "Belarus") "Weißrussland" else value
+ * }
+ * }
+ * )
+ * ```
+ */
+public inline fun CsvDataRow.mapValueOf(column: CsvColumn, transform: (String) -> String): List =
+ mapIndexed { index, value ->
+ if (index == column.index) {
+ transform(value)
+ } else {
+ value
+ }
+ }
diff --git a/src/commonMain/kotlin/buildCsv.kt b/src/commonMain/kotlin/buildCsv.kt
index 6fd603e..695a3bf 100644
--- a/src/commonMain/kotlin/buildCsv.kt
+++ b/src/commonMain/kotlin/buildCsv.kt
@@ -1,134 +1,64 @@
-/** Copyright 2023 Halfbit GmbH, Sergej Shafarenka */
+/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */
package de.halfbit.csv
-import de.halfbit.csv.BaseCsv.Row
-import de.halfbit.csv.Csv.DataRow
-import de.halfbit.csv.Csv.HeaderRow
-import kotlin.math.max
-
@DslMarker
public annotation class CsvDsl
-public fun buildCsv(
+public inline fun buildCsv(
block: CsvBuilder.() -> Unit
): Csv {
- val header = mutableListOf()
- val data = mutableListOf()
- block(CsvBuilder(header, data))
- val headerRow = header.getOrNull(0) ?: DefaultHeaderRow(emptyList())
- return Csv(headerRow, data)
+ val headerRows = mutableListOf()
+ val dataRows = mutableListOf()
+ block(CsvBuilder(headerRows, dataRows))
+ val headerRow = headerRows.getOrNull(0)
+ return if (headerRow == null) {
+ CsvNoHeader(dataRows)
+ } else {
+ CsvWithHeader(headerRow, dataRows)
+ }
}
@CsvDsl
-public class CsvBuilder internal constructor(
- private val header: MutableList,
- private val data: MutableList,
+public class CsvBuilder @PublishedApi internal constructor(
+ @PublishedApi internal val headerRows: MutableList,
+ @PublishedApi internal val dataRows: MutableList,
) {
- public fun row(block: CsvRowBuilder.() -> Unit) {
+ public inline fun header(block: CsvDataHeaderBuilder.() -> Unit) {
val row = mutableListOf()
- val scope = CsvRowBuilder(row)
- block(scope)
- if (header.isEmpty()) {
- header += DefaultHeaderRow(row)
- } else {
- data += DefaultDataRow(row, header[0])
- }
+ block(CsvDataHeaderBuilder(row))
+ headerRows.clear()
+ headerRows += row.toCsvHeaderRow()
+ }
+
+ public inline fun data(block: CsvDataRowBuilder.() -> Unit) {
+ val row = mutableListOf()
+ block(CsvDataRowBuilder(row))
+ dataRows += row
}
}
@CsvDsl
-public class CsvRowBuilder internal constructor(
+public class CsvDataHeaderBuilder @PublishedApi internal constructor(
private val row: MutableList,
) {
- public fun value(value: String) {
+ public fun column(value: String) {
row.add(value)
}
}
-// implementations
-
-public fun BaseCsv(allRows: List): BaseCsv = object : BaseCsv {
- override val allRows: List get() = allRows
- override fun toString(): String =
- buildString {
- allRows.forEach { data ->
- append(data)
- append("\n")
- }
- }
-}
-
-public fun Csv(
- header: HeaderRow,
- data: List,
-): Csv = object : Csv {
- override val header: HeaderRow get() = header
- override val data: List get() = data
- override val allRows: List by lazy {
- if (header.isEmpty()) data else listOf(header) + data
- }
-
- override fun toString(): String =
- buildString {
- append(header)
- append("\n")
- data.forEach { data ->
- append(data)
- append("\n")
- }
- }
-}
-
-internal open class DefaultRow(protected val row: List) : Row, List by row {
-
- override fun replaceValue(valueIndex: Int, newValue: String): Row {
- val thisRow = this
- val newRow = buildList {
- for (index in 0..max(thisRow.size, valueIndex)) {
- if (index == valueIndex) {
- add(newValue)
- } else {
- add(thisRow.getOrNull(index) ?: "")
- }
- }
- }
- return DefaultRow(newRow)
+@CsvDsl
+public class CsvDataRowBuilder @PublishedApi internal constructor(
+ private val row: MutableList,
+) {
+ public fun value(value: String) {
+ row.add(value)
}
-
- override fun toString(): String = row.toString()
}
-internal class DefaultHeaderRow(row: List) : HeaderRow, DefaultRow(row) {
- private val indexOfColumn: MutableMap by lazy { mutableMapOf() }
-
- override fun indexOfColumn(name: String): Int =
- indexOfColumn[name] ?: run {
- val index = indexOf(name)
- indexOfColumn[name] = index
- index
- }
-}
-
-internal class DefaultDataRow(
- row: List,
- private val header: HeaderRow
-) : DataRow, DefaultRow(row) {
-
- override fun value(columnName: String): String =
- row.getOrNull(header.indexOfColumn(columnName)) ?: ""
-
- override fun replaceValue(columnName: String, newValue: String): DataRow {
- val replaceIndex = header.indexOfColumn(columnName)
- if (replaceIndex < 0) return this
- val newRow = buildList {
- for (index in 0..max(row.lastIndex, replaceIndex)) {
- if (index == replaceIndex) {
- add(newValue)
- } else {
- add(row.getOrNull(index) ?: "")
- }
- }
+@PublishedApi
+internal fun List.toCsvHeaderRow(): CsvHeaderRow =
+ CsvHeaderRow(
+ mapIndexed { index, name ->
+ CsvColumn(index, name)
}
- return DefaultDataRow(newRow, header)
- }
-}
+ )
diff --git a/src/commonMain/kotlin/parseCsv.kt b/src/commonMain/kotlin/parseCsv.kt
index c01a38d..5cb5040 100644
--- a/src/commonMain/kotlin/parseCsv.kt
+++ b/src/commonMain/kotlin/parseCsv.kt
@@ -1,11 +1,12 @@
-/** Copyright 2023 Halfbit GmbH, Sergej Shafarenka */
+/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */
package de.halfbit.csv
-import de.halfbit.csv.Csv.DataRow
-import de.halfbit.csv.Csv.HeaderRow
import de.halfbit.csv.Lexer.*
-internal fun parseCsv(csvText: String): Csv {
+internal fun parseCsv(
+ csvText: String,
+ withHeaderRow: Boolean = true,
+): Pair> {
var pos = 0
var lexer: Lexer = BeforeValue
@@ -16,8 +17,8 @@ internal fun parseCsv(csvText: String): Csv {
val value = StringBuilder()
val row = mutableListOf()
- val header = mutableListOf()
- val data = mutableListOf()
+ var header: CsvHeaderRow? = null
+ val data = mutableListOf()
fun completeValue() {
row.add(value.toString())
@@ -25,10 +26,10 @@ internal fun parseCsv(csvText: String): Csv {
}
fun completeRow() {
- if (header.isEmpty()) {
- header += DefaultHeaderRow(row.toList())
+ if (withHeaderRow && header == null) {
+ header = row.toCsvHeaderRow()
} else {
- data += DefaultDataRow(row.toList(), header[0])
+ data += row.toList()
}
row.clear()
}
@@ -150,8 +151,7 @@ internal fun parseCsv(csvText: String): Csv {
completeRow()
}
- val headerRow = header.getOrNull(0) ?: DefaultHeaderRow(emptyList())
- return Csv(headerRow, data)
+ return header to data
}
private enum class Lexer {
diff --git a/src/commonTest/kotlin/BuildCsvTest.kt b/src/commonTest/kotlin/BuildCsvTest.kt
index 677e2fe..fa5c776 100644
--- a/src/commonTest/kotlin/BuildCsvTest.kt
+++ b/src/commonTest/kotlin/BuildCsvTest.kt
@@ -1,7 +1,9 @@
+/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */
package de.halfbit.csv
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertNull
class BuildCsvTest {
@@ -9,77 +11,80 @@ class BuildCsvTest {
fun buildCsv() {
val csv = buildCsv {
- row {
- value("NAME")
- value("CODE")
- value("DESCRIPTION")
+ header {
+ column("NAME")
+ column("CODE")
+ column("DESCRIPTION")
}
- row {
+ data {
value("BEVERLY HILLS BLACK")
value("BVH.1-NAR.S8")
}
- row {
+ data {
value("BOAVISTA BLACK GRADIENT")
value("BVS.1-NAR.S5")
value("")
}
- row {
+ data {
value("GXP EYEWEAR PACKAGING")
value("PKG.2")
value("PU pouch for eyewear in black cardboard box")
}
- }
+ } as CsvWithHeader
val header = csv.header
- checkNotNull(header)
- assertEquals(header.indexOf("NAME"), 0)
- assertEquals(header.indexOf("CODE"), 1)
- assertEquals(header.indexOf("DESCRIPTION"), 2)
+ val name = header.columnByName("NAME") as CsvColumn
+ assertEquals(CsvColumn(0, "NAME"), name)
+
+ val code = header.columnByName("CODE") as CsvColumn
+ assertEquals(CsvColumn(1, "CODE"), code)
+
+ val description = header.columnByName("DESCRIPTION") as CsvColumn
+ assertEquals(CsvColumn(2, "DESCRIPTION"), description)
assertEquals(csv.data.size, 3)
- assertEquals("BEVERLY HILLS BLACK", csv.data[0].value("NAME"))
- assertEquals("BVH.1-NAR.S8", csv.data[0].value("CODE"))
- assertEquals("", csv.data[0].value("DESCRIPTION"))
- assertEquals("BOAVISTA BLACK GRADIENT", csv.data[1].value("NAME"))
- assertEquals("BVS.1-NAR.S5", csv.data[1].value("CODE"))
- assertEquals("", csv.data[1].value("DESCRIPTION"))
+ assertEquals("BEVERLY HILLS BLACK", csv.data[0].getOrEmpty(name))
+ assertEquals("BVH.1-NAR.S8", csv.data[0].getOrEmpty(code))
+ assertNull(csv.data[0].getOrNull(description))
- assertEquals("GXP EYEWEAR PACKAGING", csv.data[2].value("NAME"))
- assertEquals("PKG.2", csv.data[2].value("CODE"))
- assertEquals("PU pouch for eyewear in black cardboard box", csv.data[2].value("DESCRIPTION"))
+ assertEquals("BOAVISTA BLACK GRADIENT", csv.data[1].getOrEmpty(name))
+ assertEquals("BVS.1-NAR.S5", csv.data[1].getOrEmpty(code))
+ assertEquals("", csv.data[1].get(description))
+
+ assertEquals("GXP EYEWEAR PACKAGING", csv.data[2].getOrEmpty(name))
+ assertEquals("PKG.2", csv.data[2].getOrEmpty(code))
+ assertEquals("PU pouch for eyewear in black cardboard box", csv.data[2].get(description))
}
@Test
fun mapCsvNotNull() {
val csv = buildCsv {
- row {
- value("NAME")
- value("CODE")
- value("DESCRIPTION")
+ header {
+ column("NAME")
+ column("CODE")
+ column("DESCRIPTION")
}
- row {
+ data {
value("BEVERLY HILLS BLACK")
value("BVH.1-NAR.S8")
}
- row {
+ data {
value("BOAVISTA BLACK GRADIENT")
value("BVS.1-NAR.S5")
value("")
}
- row {
+ data {
value("GXP EYEWEAR PACKAGING")
value("PKG.2")
value("PU pouch for eyewear in black cardboard box")
}
- }
-
- val descriptions = csv.data.map {
- it.value("DESCRIPTION")
- }
+ } as CsvWithHeader
- val expectedDescriptions = listOf("", "", "PU pouch for eyewear in black cardboard box")
+ val description = csv.header.columnByName("DESCRIPTION") as CsvColumn
+ val descriptions = csv.data.map { it.getOrNull(description) }
+ val expectedDescriptions = listOf(null, "", "PU pouch for eyewear in black cardboard box")
assertEquals(expectedDescriptions, descriptions)
}
}
\ No newline at end of file
diff --git a/src/commonTest/kotlin/CsvTest.kt b/src/commonTest/kotlin/CsvTest.kt
index d8ca318..696e772 100644
--- a/src/commonTest/kotlin/CsvTest.kt
+++ b/src/commonTest/kotlin/CsvTest.kt
@@ -1,4 +1,4 @@
-/** Copyright 2023 Halfbit GmbH, Sergej Shafarenka */
+/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */
package de.halfbit.csv
import kotlin.test.Test
@@ -21,7 +21,7 @@ class CsvTest {
Western Sahara,EH
""".trimIndent()
- val csv = parseCsv(givenCsvString)
+ val csv = CsvNoHeader.fromCsvText(givenCsvString)
val csvString = csv.toCsvText().trim()
assertEquals(givenCsvString, csvString)
@@ -35,7 +35,7 @@ class CsvTest {
Western Sahara,EH
""".trimIndent()
- val csv = parseCsv(givenCsvString)
+ val csv = CsvNoHeader.fromCsvText(givenCsvString)
val csvString = csv.toCsvText().trim()
assertEquals(givenCsvString, csvString)
@@ -49,7 +49,7 @@ class CsvTest {
VE,"Venezuela, Bolivarian Republic of"
""".trimIndent()
- val csv = parseCsv(givenCsvString)
+ val csv = CsvNoHeader.fromCsvText(givenCsvString)
val csvString = csv.toCsvText().trim()
assertEquals(givenCsvString, csvString)
@@ -66,7 +66,7 @@ class CsvTest {
""".trimIndent()
.replace("\n", "\r\n")
- val csv = parseCsv(givenCsvString)
+ val csv = CsvWithHeader.fromCsvText(givenCsvString) as CsvWithHeader
val header = csv.allRows[0]
println(csv.toCsvText())
@@ -90,7 +90,7 @@ class CsvTest {
"Venezuela, Bolivarian Republic of",VE
""".trimIndent()
- val csv = parseCsv(givenCsvString)
+ val csv = CsvNoHeader.fromCsvText(givenCsvString)
val csvString = csv.toCsvText().trim()
assertEquals(givenCsvString, csvString)
@@ -352,7 +352,7 @@ class CsvTest {
ZW,Zimbabwe
""".trimIndent()
- val csv = parseCsv(givenCsvString)
+ val csv = CsvWithHeader.fromCsvText(givenCsvString) as CsvWithHeader
val csvString = csv.toCsvText(escapeWhitespaces = true).trim()
assertEquals(givenCsvString, csvString)
diff --git a/src/commonTest/kotlin/Issue1Test.kt b/src/commonTest/kotlin/Issue1Test.kt
index 4a561a8..97ec9cd 100644
--- a/src/commonTest/kotlin/Issue1Test.kt
+++ b/src/commonTest/kotlin/Issue1Test.kt
@@ -1,4 +1,4 @@
-/** Copyright 2023 Halfbit GmbH, Sergej Shafarenka */
+/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */
package de.halfbit.csv
import kotlin.test.Test
@@ -17,7 +17,7 @@ class Issue1Test {
1999,Chevy,Venture,
""".trimIndent()
- val csv = parseCsv(givenCsvString)
+ val csv = CsvWithHeader.fromCsvText(givenCsvString) as CsvWithHeader
assertEquals(3, csv.allRows.size)
assertContentEquals(listOf("1999", "Chevy", "Venture", ""), csv.allRows[2])
}
diff --git a/src/commonTest/kotlin/Issue24Test.kt b/src/commonTest/kotlin/Issue24Test.kt
index af65a62..1d32bfa 100644
--- a/src/commonTest/kotlin/Issue24Test.kt
+++ b/src/commonTest/kotlin/Issue24Test.kt
@@ -1,7 +1,6 @@
-/** Copyright 2025 Halfbit GmbH, Sergej Shafarenka */
+/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */
package de.halfbit.csv
-import de.halfbit.csv.Csv.Companion.parserCsvText
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
@@ -12,7 +11,7 @@ class Issue24Test {
fun rn_can_be_used_as_a_line_terminator() {
val givenCsvString = "date,amount,user_id,id\r\n2025-04-14,250.00,2,2\r\n2025-04-15,100.50,1,1\r\n"
- val csv = parserCsvText(givenCsvString)
+ val csv = CsvNoHeader.fromCsvText(givenCsvString)
assertEquals(3, csv.allRows.size)
assertContentEquals(listOf("date", "amount", "user_id", "id"), csv.allRows[0])
@@ -24,7 +23,7 @@ class Issue24Test {
fun r_can_be_used_as_a_line_terminator() {
val givenCsvString = "date,amount,user_id,id\r2025-04-14,250.00,2,2\r2025-04-15,100.50,1,1\r"
- val csv = parserCsvText(givenCsvString)
+ val csv = CsvNoHeader.fromCsvText(givenCsvString)
assertEquals(3, csv.allRows.size)
assertContentEquals(listOf("date", "amount", "user_id", "id"), csv.allRows[0])
@@ -36,7 +35,7 @@ class Issue24Test {
fun last_row_can_have_no_line_terminators() {
val givenCsvString = "date,amount,user_id,id\r\n2025-04-14,250.00,2,2"
- val csv = parserCsvText(givenCsvString)
+ val csv = CsvNoHeader.fromCsvText(givenCsvString)
assertEquals(2, csv.allRows.size)
assertContentEquals(listOf("date", "amount", "user_id", "id"), csv.allRows[0])
diff --git a/src/commonTest/kotlin/MapColumnTest.kt b/src/commonTest/kotlin/MapColumnTest.kt
new file mode 100644
index 0000000..7f4347b
--- /dev/null
+++ b/src/commonTest/kotlin/MapColumnTest.kt
@@ -0,0 +1,42 @@
+/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */
+package de.halfbit.csv
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class MapColumnTest {
+
+ @Test
+ fun mapValue() {
+
+ val givenCsvString =
+ """
+ NAME,CODE,PRICE,DESCRIPTION
+ BURGUNDY PINK,4PK.GD-S7,100,Pink burgundy
+ HILLS PINK,BVH.5-NAR.S8,120,Pink hills
+ PACKAGING,PKG.1,0.55,Eyewear packages. Models: all
+ DARK BLUE,EVG.6,101,"Matte stainless steel.
+ Line 2.
+ Line 3"
+ """.trimIndent()
+
+ val csv = CsvWithHeader.fromCsvText(givenCsvString) as CsvWithHeader
+ val code = csv.header.columnByName("CODE") as CsvColumn
+ val description = csv.header.columnByName("DESCRIPTION") as CsvColumn
+
+ val csv2 = csv.copy(
+ data = csv.data.map { row ->
+ if (!row[code].startsWith("PKG.")) {
+ row.mapValueOf(description) { "" }
+ } else {
+ row
+ }
+ }
+ )
+ println(csv2.toCsvText())
+
+ assertEquals("", csv2.data[0][description.index])
+ assertEquals("", csv2.data[1].getOrElse(description.index) { "" })
+ assertEquals("Eyewear packages. Models: all", csv2.data[2][description.index])
+ }
+}
diff --git a/src/commonTest/kotlin/ReadmeExampleTest.kt b/src/commonTest/kotlin/ReadmeExampleTest.kt
new file mode 100644
index 0000000..f11e6a5
--- /dev/null
+++ b/src/commonTest/kotlin/ReadmeExampleTest.kt
@@ -0,0 +1,58 @@
+package de.halfbit.csv
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class ReadmeExampleTest {
+
+ @Test
+ fun buildCsvObject() {
+
+ // (1) build csv
+ val csv = buildCsv {
+ header {
+ column("Code")
+ column("Name")
+ }
+ data {
+ value("DE")
+ value("Deutschland")
+ }
+ data {
+ value("BY")
+ value("Belarus")
+ }
+ } as CsvWithHeader
+
+ val code = csv.header.columnByName("Code") as CsvColumn
+ val name = csv.header.columnByName("Name") as CsvColumn
+
+ assertEquals(code, CsvColumn(0, "Code"))
+ assertEquals(name, CsvColumn(1, "Name"))
+ assertEquals(csv.data[0][code], "DE")
+ assertEquals(csv.data[0][name], "Deutschland")
+ assertEquals(csv.data[1][code], "BY")
+ assertEquals(csv.data[1][name], "Belarus")
+
+ // (2) csv to text
+ val csvText = csv.toCsvText()
+ assertEquals("Code,Name\nDE,Deutschland\nBY,Belarus\n", csvText)
+
+ // (3) parse csv text
+ val csv2 = CsvWithHeader.fromCsvText(csvText) as CsvWithHeader
+
+ assertEquals(csv.header, csv2.header)
+ assertEquals(csv.data, csv2.data)
+ assertEquals(csv.allRows, csv2.allRows)
+
+ // (4) transform csv
+ val csv3 = csv.copy(
+ data = csv.data.map { row ->
+ row.mapValueOf(name) { value ->
+ if (value == "Belarus") "Weißrussland" else value
+ }
+ }
+ )
+ assertEquals("Code,Name\nDE,Deutschland\nBY,Weißrussland\n", csv3.toCsvText())
+ }
+}
\ No newline at end of file
diff --git a/src/commonTest/kotlin/ReplaceValueTest.kt b/src/commonTest/kotlin/ReplaceValueTest.kt
deleted file mode 100644
index 5030ef7..0000000
--- a/src/commonTest/kotlin/ReplaceValueTest.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-package de.halfbit.csv
-
-import de.halfbit.csv.Csv.Companion.parserCsvText
-import kotlin.test.Test
-import kotlin.test.assertEquals
-
-class ReplaceValueTest {
-
- @Test
- fun replaceValue() {
-
- val givenCsvString =
- """
- NAME,CODE,PRICE,DESCRIPTION
- BURGUNDY PINK,4PK.GD-S7,100,Pink burgundy
- HILLS PINK,BVH.5-NAR.S8,120Pink hills
- PACKAGING,PKG.1,0.55,Eyewear packages. Models: all
- DARK BLUE,EVG.6,101,"Matte stainless steel.
- Line 2.
- Line 3"
- """.trimIndent()
-
- val csv = parserCsvText(givenCsvString)
- val data = csv.data
- .map {
- val code = it.code
- if (!code.startsWith("PKG.")) {
- it.replaceValue("DESCRIPTION", "")
- } else it
- }
-
- val csv2 = Csv(csv.header, data)
- println(csv2.toCsvText())
-
- assertEquals("", csv2.data[0].description)
- assertEquals("", csv2.data[1].description)
- assertEquals("Eyewear packages. Models: all", csv2.data[2].description)
- }
-}
-
-private val Csv.DataRow.code: String
- get() = value("CODE")
-
-private val Csv.DataRow.description: String
- get() = value("DESCRIPTION")
\ No newline at end of file