From c9fd6a5c94c5a8f27e12520e67cce3ab323ac2c9 Mon Sep 17 00:00:00 2001 From: Sergej Shafarenka Date: Sun, 23 Nov 2025 13:08:29 +0100 Subject: [PATCH] Fix the issue --- src/commonMain/kotlin/Csv.kt | 8 +- src/commonMain/kotlin/parseCsv.kt | 118 +++++++-------------- src/commonTest/kotlin/Issue35Test.kt | 31 ++++++ src/commonTest/kotlin/ReadmeExampleTest.kt | 4 +- 4 files changed, 80 insertions(+), 81 deletions(-) create mode 100644 src/commonTest/kotlin/Issue35Test.kt diff --git a/src/commonMain/kotlin/Csv.kt b/src/commonMain/kotlin/Csv.kt index 10595d9..d76537e 100644 --- a/src/commonMain/kotlin/Csv.kt +++ b/src/commonMain/kotlin/Csv.kt @@ -16,13 +16,15 @@ 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) @@ -30,7 +32,9 @@ public abstract class Csv( append(',') } } - append(newLine.value) + if (index != allRows.lastIndex || trailingNewLine) { + append(newLine.value) + } } } } diff --git a/src/commonMain/kotlin/parseCsv.kt b/src/commonMain/kotlin/parseCsv.kt index b643935..042d022 100644 --- a/src/commonMain/kotlin/parseCsv.kt +++ b/src/commonMain/kotlin/parseCsv.kt @@ -8,13 +8,14 @@ internal fun parseCsv( withHeaderRow: Boolean = true, ): Pair> { 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() var header: CsvHeaderRow? = null @@ -23,6 +24,7 @@ internal fun parseCsv( fun completeValue() { row.add(value.toString()) value.clear() + valueStarted = false } fun completeRow() { @@ -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() } @@ -164,7 +129,6 @@ internal fun parseCsv( } private enum class Lexer { - BeforeValue, - InsideValue, - InsideEscapedValue, -} + SimpleValue, + QuotedValue, +} \ No newline at end of file diff --git a/src/commonTest/kotlin/Issue35Test.kt b/src/commonTest/kotlin/Issue35Test.kt new file mode 100644 index 0000000..dd651b5 --- /dev/null +++ b/src/commonTest/kotlin/Issue35Test.kt @@ -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) + } +} diff --git a/src/commonTest/kotlin/ReadmeExampleTest.kt b/src/commonTest/kotlin/ReadmeExampleTest.kt index f11e6a5..ba92cb3 100644 --- a/src/commonTest/kotlin/ReadmeExampleTest.kt +++ b/src/commonTest/kotlin/ReadmeExampleTest.kt @@ -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 @@ -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()) } } \ No newline at end of file