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
8 changes: 6 additions & 2 deletions src/commonMain/kotlin/Csv.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,25 @@ public abstract class Csv(
*
* @param newLine the line separator to use
* @param escapeWhitespaces whether to escape whitespaces in values
* @param trailingNewLine whether to add a newline character after the last row
* @return the CSV-formatted string representation
*/
public fun toCsvText(
newLine: NewLine = NewLine.LF,
escapeWhitespaces: Boolean = false,
trailingNewLine: Boolean = false,
): String = buildString {
allRows.forEach { row ->
allRows.forEachIndexed { index, row ->
row.forEachIndexed { index, value ->
val escapedValue = value.escapeCsvValue(escapeWhitespaces)
append(escapedValue)
if (index < row.lastIndex) {
append(',')
}
}
append(newLine.value)
if (index != allRows.lastIndex || trailingNewLine) {
append(newLine.value)
}
}
}
}
Expand Down
118 changes: 41 additions & 77 deletions src/commonMain/kotlin/parseCsv.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ internal fun parseCsv(
withHeaderRow: Boolean = true,
): Pair<CsvHeaderRow?, List<CsvDataRow>> {
var pos = 0
var lexer: Lexer = BeforeValue
var lexer: Lexer = SimpleValue

fun nextChar(): Char? {
val nextPos = pos + 1
return if (nextPos < csvText.length) csvText[nextPos] else null
}

var valueStarted = false
val value = StringBuilder()
val row = mutableListOf<String>()
var header: CsvHeaderRow? = null
Expand All @@ -23,6 +24,7 @@ internal fun parseCsv(
fun completeValue() {
row.add(value.toString())
value.clear()
valueStarted = false
}

fun completeRow() {
Expand All @@ -37,122 +39,85 @@ internal fun parseCsv(
while (pos < csvText.length) {
val char = csvText[pos]
lexer = when (lexer) {
BeforeValue -> when (char) {
'"' -> InsideEscapedValue
SimpleValue -> when (char) {
'"' -> {
valueStarted = true
QuotedValue
}
',' -> {
completeValue()
BeforeValue
valueStarted = true
SimpleValue
}
'\r' -> {
if (nextChar() == '\n') {
pos++
}
if (valueStarted) {
completeValue()
}
if (row.isNotEmpty()) {
completeRow()
}
BeforeValue
valueStarted = false
SimpleValue
}
'\n' -> {
if (valueStarted) {
completeValue()
}
if (row.isNotEmpty()) {
completeRow()
}
BeforeValue
SimpleValue
}
else -> {
value.append(char)
InsideValue
SimpleValue
}
}
InsideValue -> when (char) {
',' -> {
completeValue()
BeforeValue
}
'\r' -> {
if (nextChar() == '\n') {
pos++
}
completeValue()
completeRow()
BeforeValue
}
'\n' -> {
completeValue()
completeRow()
BeforeValue
}
else -> {
value.append(char)
when (nextChar()) {
',' -> {
pos++
completeValue()
when (nextChar()) {
null -> { // EOF
completeValue()
completeRow()
}
}
BeforeValue
}
'\r' -> {
pos++
when (nextChar()) {
'\n' -> {
pos++
}
}
completeValue()
completeRow()
BeforeValue
}
'\n' -> {
pos++
completeValue()
completeRow()
BeforeValue
}
null -> {
completeValue()
completeRow()
BeforeValue
}
else -> {
InsideValue
}
}
}
}
InsideEscapedValue -> when (char) {
QuotedValue -> when (char) {
'"' -> when (nextChar()) {
'"' -> {
pos++
value.append(char)
InsideEscapedValue
QuotedValue
}
else -> {
else -> { // Quote closed, value complete
completeValue()
when (nextChar()) {
',' -> {
pos++
valueStarted = true
}
'\r' -> {
pos++
if (nextChar() == '\n') {
pos++
}
completeRow()
}
'\n' -> {
pos++
completeRow()
}
null -> {
completeRow()
}
}
BeforeValue
SimpleValue
}
}
else -> {
value.append(char)
InsideEscapedValue
QuotedValue
}
}
}
pos++
}

if (value.isNotEmpty()) {
if (valueStarted) {
completeValue()
}

Expand All @@ -164,7 +129,6 @@ internal fun parseCsv(
}

private enum class Lexer {
BeforeValue,
InsideValue,
InsideEscapedValue,
}
SimpleValue,
QuotedValue,
}
31 changes: 31 additions & 0 deletions src/commonTest/kotlin/Issue35Test.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */
package de.halfbit.csv

import kotlin.test.Test
import kotlin.test.assertEquals

class Issue35Test {

@Test
fun parseTrailingEmptyFields() {

val given =
"""
year,make,model,price
1997,Ford,E350,3000.00
1999,Chevy,Venture,
2001,VW,,
""".trimIndent()

val csv = CsvWithHeader.fromCsvText(given) as CsvWithHeader
val actual = csv.toCsvText()
val expected = """
year,make,model,price
1997,Ford,E350,3000.00
1999,Chevy,Venture,""
2001,VW,"",""
""".trimIndent()

assertEquals(expected, actual)
}
}
4 changes: 2 additions & 2 deletions src/commonTest/kotlin/ReadmeExampleTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class ReadmeExampleTest {

// (2) csv to text
val csvText = csv.toCsvText()
assertEquals("Code,Name\nDE,Deutschland\nBY,Belarus\n", csvText)
assertEquals("Code,Name\nDE,Deutschland\nBY,Belarus", csvText)

// (3) parse csv text
val csv2 = CsvWithHeader.fromCsvText(csvText) as CsvWithHeader
Expand All @@ -53,6 +53,6 @@ class ReadmeExampleTest {
}
}
)
assertEquals("Code,Name\nDE,Deutschland\nBY,Weißrussland\n", csv3.toCsvText())
assertEquals("Code,Name\nDE,Deutschland\nBY,Weißrussland", csv3.toCsvText())
}
}