diff --git a/modules/nextflow/src/main/groovy/nextflow/util/CsvWriter.groovy b/modules/nextflow/src/main/groovy/nextflow/util/CsvWriter.groovy index 2a40b220bc..9d024d969c 100644 --- a/modules/nextflow/src/main/groovy/nextflow/util/CsvWriter.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/util/CsvWriter.groovy @@ -40,17 +40,24 @@ class CsvWriter { final columns = columnHeaders(header, records) if( columns ) - path << columns.collect(column -> "\"${column}\"").join(sep) << '\n' + path << columns.collect(column -> formatCsvValue(column)).join(sep) << '\n' if( records.isEmpty() ) path << '' for( final record : records ) { final values = rowValues(record, columns) - path << values.collect(v -> "\"${toCsvString(v)}\"").join(sep) << '\n' + path << values.collect(v -> formatCsvValue(v)).join(sep) << '\n' } } + private String formatCsvValue(value) { + final str = toCsvString(value) + final escaped = str.replace('"', '""') + final needsQuote = escaped != str || str.contains(sep) || str.contains('\r') || str.contains('\n') + return needsQuote ? "\"${escaped}\"" : str + } + private static Collection columnHeaders(Object header, List records) { if( header instanceof List ) { return header diff --git a/modules/nextflow/src/test/groovy/nextflow/util/CsvWriterTest.groovy b/modules/nextflow/src/test/groovy/nextflow/util/CsvWriterTest.groovy index b7a88ca698..cbbc2dad5b 100644 --- a/modules/nextflow/src/test/groovy/nextflow/util/CsvWriterTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/util/CsvWriterTest.groovy @@ -38,22 +38,50 @@ class CsvWriterTest extends Specification { new CsvWriter([:]).apply(records, file) then: file.text == '''\ - "1","1_1.fastq","1_2.fastq" - "2","2_1.fastq","2_2.fastq" - "3","3_1.fastq","" + 1,1_1.fastq,1_2.fastq + 2,2_1.fastq,2_2.fastq + 3,3_1.fastq, '''.stripIndent() when: new CsvWriter([header: true]).apply(records, file) then: file.text == '''\ - "id","fastq_1","fastq_2" - "1","1_1.fastq","1_2.fastq" - "2","2_1.fastq","2_2.fastq" - "3","3_1.fastq","" + id,fastq_1,fastq_2 + 1,1_1.fastq,1_2.fastq + 2,2_1.fastq,2_2.fastq + 3,3_1.fastq, '''.stripIndent() } + def 'should quote only values that require it'() { + given: + def file = TestHelper.createInMemTempFile() + and: + def records = [ + ['plain', 'with,comma', 'with "quote"', 'with\nnewline', 'with\rcarriage', '', null] + ] + + when: + new CsvWriter([:]).apply(records, file) + then: + file.text == 'plain,"with,comma","with ""quote""","with\nnewline","with\rcarriage",,\n' + } + + def 'should quote headers and honor a custom separator'() { + given: + def file = TestHelper.createInMemTempFile() + and: + def records = [ + ['sample': 'S1', 'read|group': 'case|control'] + ] + + when: + new CsvWriter([header: ['sample', 'read|group'], sep: '|']).apply(records, file) + then: + file.text == 'sample|"read|group"\nS1|"case|control"\n' + } + def 'should write empty csv file'() { given: def file = TestHelper.createInMemTempFile()