Skip to content

Commit a022d90

Browse files
authored
feat(inspector): choose the CSV escape character (doubled quote or backslash) (#1944)
* feat(inspector): choose the CSV escape character (doubled quote or backslash) * fix(inspector): make the CSV doubled-quote escape track the selected quote character
1 parent 98f9160 commit a022d90

9 files changed

Lines changed: 134 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- In the CSV editor, split a column into several by a delimiter or regular expression, and merge a column with the one next to it using a separator. Both are single undo steps. (#1469)
1717
- In the CSV editor, switch the first row between header and data with `Cmd+Shift+H` when auto-detection guesses wrong. Files without a header are no longer saved with a generated header row. (#1469)
1818
- In the CSV editor, set the delimiter, quote character, encoding, and line ending by hand from Edit > Set CSV Properties…, then Reload to re-read the file with those settings. (#1469)
19+
- In the CSV editor, choose the escape character (a doubled quote or a backslash) in Set CSV Properties…, so files that escape quotes with a backslash read and save correctly. (#1469)
1920
- Add llama.cpp and MLX as local AI providers. Each preset points at the server's default local endpoint and needs no API key, alongside the existing Ollama and custom OpenAI-compatible options. (#1777)
2021

2122
### Fixed

Plugins/CSVInspectorPlugin/CSVWriter.swift

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,8 @@ struct CSVWriter {
7272
func encodeRow(_ cells: [String]) -> String {
7373
let delimiterScalar = UnicodeScalar(dialect.delimiter)
7474
let quoteScalar = UnicodeScalar(dialect.quoteChar)
75+
let escapeScalar = UnicodeScalar(dialect.escapeChar)
7576
let delimiter = String(delimiterScalar)
76-
let quote = String(quoteScalar)
77-
let doubledQuote = quote + quote
7877

7978
var line = ""
8079
for (index, field) in cells.enumerated() {
@@ -85,8 +84,7 @@ struct CSVWriter {
8584
field,
8685
delimiterScalar: delimiterScalar,
8786
quoteScalar: quoteScalar,
88-
quote: quote,
89-
doubledQuote: doubledQuote
87+
escapeScalar: escapeScalar
9088
)
9189
}
9290
return line
@@ -108,13 +106,19 @@ struct CSVWriter {
108106
_ field: String,
109107
delimiterScalar: UnicodeScalar,
110108
quoteScalar: UnicodeScalar,
111-
quote: String,
112-
doubledQuote: String
109+
escapeScalar: UnicodeScalar
113110
) -> String {
114111
let needsQuoting = field.unicodeScalars.contains { scalar in
115112
scalar == delimiterScalar || scalar == quoteScalar || scalar == "\n" || scalar == "\r"
116113
}
117114
guard needsQuoting else { return field }
118-
return quote + field.replacingOccurrences(of: quote, with: doubledQuote) + quote
115+
let quote = String(quoteScalar)
116+
if escapeScalar == quoteScalar {
117+
return quote + field.replacingOccurrences(of: quote, with: quote + quote) + quote
118+
}
119+
let escape = String(escapeScalar)
120+
var body = field.replacingOccurrences(of: escape, with: escape + escape)
121+
body = body.replacingOccurrences(of: quote, with: escape + quote)
122+
return quote + body + quote
119123
}
120124
}

Plugins/TableProPluginKit/CSVDialect.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public struct CSVDialect: Equatable, Sendable {
1717

1818
public var delimiter: UInt8
1919
public var quoteChar: UInt8
20+
public var escapeChar: UInt8
2021
public var encoding: String.Encoding
2122
public var lineEnding: LineEnding
2223
public var hasBom: Bool
@@ -30,6 +31,7 @@ public struct CSVDialect: Equatable, Sendable {
3031
) {
3132
self.delimiter = delimiter
3233
self.quoteChar = quoteChar
34+
self.escapeChar = quoteChar
3335
self.encoding = encoding
3436
self.lineEnding = lineEnding
3537
self.hasBom = hasBom

Plugins/TableProPluginKit/CSVStreamingParser.swift

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public struct CSVStreamingParser: Sendable {
1010
public func indexRows(_ bytes: UnsafeBufferPointer<UInt8>) -> [Range<Int>] {
1111
var ranges: [Range<Int>] = []
1212
let quote = dialect.quoteChar
13+
let escape = dialect.escapeChar
1314
let delimiter = dialect.delimiter
1415
let count = bytes.count
1516
var i = bomSkip(in: bytes)
@@ -20,8 +21,12 @@ public struct CSVStreamingParser: Sendable {
2021
while i < count {
2122
let byte = bytes[i]
2223
if insideQuotes {
24+
if escape != quote, byte == escape, i + 1 < count {
25+
i += 2
26+
continue
27+
}
2328
if byte == quote {
24-
if i + 1 < count, bytes[i + 1] == quote {
29+
if escape == quote, i + 1 < count, bytes[i + 1] == quote {
2530
i += 2
2631
continue
2732
}
@@ -69,6 +74,7 @@ public struct CSVStreamingParser: Sendable {
6974
var fields: [String] = []
7075
var field: [UInt8] = []
7176
let quote = dialect.quoteChar
77+
let escape = dialect.escapeChar
7278
let delimiter = dialect.delimiter
7379
var insideQuotes = false
7480
var i = range.lowerBound
@@ -77,8 +83,13 @@ public struct CSVStreamingParser: Sendable {
7783
while i < end {
7884
let byte = bytes[i]
7985
if insideQuotes {
86+
if escape != quote, byte == escape, i + 1 < end {
87+
field.append(bytes[i + 1])
88+
i += 2
89+
continue
90+
}
8091
if byte == quote {
81-
if i + 1 < end, bytes[i + 1] == quote {
92+
if escape == quote, i + 1 < end, bytes[i + 1] == quote {
8293
field.append(quote)
8394
i += 2
8495
continue
@@ -115,6 +126,7 @@ public struct CSVStreamingParser: Sendable {
115126
public func field(_ bytes: UnsafeBufferPointer<UInt8>, range: Range<Int>, column: Int) -> String {
116127
guard column >= 0 else { return "" }
117128
let quote = dialect.quoteChar
129+
let escape = dialect.escapeChar
118130
let delimiter = dialect.delimiter
119131
var insideQuotes = false
120132
var i = range.lowerBound
@@ -126,8 +138,13 @@ public struct CSVStreamingParser: Sendable {
126138
while i < end {
127139
let byte = bytes[i]
128140
if insideQuotes {
141+
if escape != quote, byte == escape, i + 1 < end {
142+
if currentColumn == column { field.append(bytes[i + 1]) }
143+
i += 2
144+
continue
145+
}
129146
if byte == quote {
130-
if i + 1 < end, bytes[i + 1] == quote {
147+
if escape == quote, i + 1 < end, bytes[i + 1] == quote {
131148
if currentColumn == column { field.append(quote) }
132149
i += 2
133150
continue

TablePro/Views/Inspector/CSVPropertiesSheet.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ struct CSVPropertiesSheet: View {
1313

1414
@State private var delimiterIndex: Int
1515
@State private var quoteIndex: Int
16+
@State private var escapeIndex: Int
1617
@State private var encodingIndex: Int
1718
@State private var lineEndingIndex: Int
1819

@@ -26,6 +27,7 @@ struct CSVPropertiesSheet: View {
2627
self.onCancel = onCancel
2728
_delimiterIndex = State(initialValue: CSVPropertyOptions.delimiterIndex(for: dialect.delimiter))
2829
_quoteIndex = State(initialValue: CSVPropertyOptions.quoteIndex(for: dialect.quoteChar))
30+
_escapeIndex = State(initialValue: CSVPropertyOptions.escapeIndex(for: dialect.escapeChar))
2931
_encodingIndex = State(initialValue: CSVPropertyOptions.encodingIndex(for: dialect.encoding))
3032
_lineEndingIndex = State(initialValue: CSVPropertyOptions.lineEndingIndex(for: dialect.lineEnding))
3133
}
@@ -48,6 +50,11 @@ struct CSVPropertiesSheet: View {
4850
Text(CSVPropertyOptions.quotes[index].label).tag(index)
4951
}
5052
}
53+
Picker("Escape character", selection: $escapeIndex) {
54+
ForEach(CSVPropertyOptions.escapes.indices, id: \.self) { index in
55+
Text(CSVPropertyOptions.escapes[index].label).tag(index)
56+
}
57+
}
5158
Picker("Encoding", selection: $encodingIndex) {
5259
ForEach(CSVPropertyOptions.encodings.indices, id: \.self) { index in
5360
Text(CSVPropertyOptions.encodings[index].label).tag(index)
@@ -77,6 +84,7 @@ struct CSVPropertiesSheet: View {
7784
base: baseDialect,
7885
delimiterIndex: delimiterIndex,
7986
quoteIndex: quoteIndex,
87+
escapeIndex: escapeIndex,
8088
encodingIndex: encodingIndex,
8189
lineEndingIndex: lineEndingIndex
8290
)

TablePro/Views/Inspector/CSVPropertyOptions.swift

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ enum CSVPropertyOptions {
2121
(String(localized: "Single Quote '"), 0x27),
2222
]
2323

24+
static let escapes: [(label: String, byte: UInt8)] = [
25+
(String(localized: "Doubled Quote"), 0x22),
26+
(String(localized: "Backslash \\"), 0x5C),
27+
]
28+
29+
static let backslashEscapeIndex = 1
30+
2431
static let encodings: [(label: String, encoding: String.Encoding)] = [
2532
("UTF-8", .utf8),
2633
("UTF-16 LE", .utf16LittleEndian),
@@ -43,6 +50,10 @@ enum CSVPropertyOptions {
4350
quotes.firstIndex { $0.byte == byte } ?? 0
4451
}
4552

53+
static func escapeIndex(for byte: UInt8) -> Int {
54+
byte == escapes[backslashEscapeIndex].byte ? backslashEscapeIndex : 0
55+
}
56+
4657
static func encodingIndex(for encoding: String.Encoding) -> Int {
4758
encodings.firstIndex { $0.encoding == encoding } ?? 0
4859
}
@@ -55,15 +66,20 @@ enum CSVPropertyOptions {
5566
base: CSVDialect,
5667
delimiterIndex: Int,
5768
quoteIndex: Int,
69+
escapeIndex: Int,
5870
encodingIndex: Int,
5971
lineEndingIndex: Int
6072
) -> CSVDialect {
61-
CSVDialect(
73+
var dialect = CSVDialect(
6274
delimiter: delimiters.indices.contains(delimiterIndex) ? delimiters[delimiterIndex].byte : base.delimiter,
6375
quoteChar: quotes.indices.contains(quoteIndex) ? quotes[quoteIndex].byte : base.quoteChar,
6476
encoding: encodings.indices.contains(encodingIndex) ? encodings[encodingIndex].encoding : base.encoding,
6577
lineEnding: lineEndings.indices.contains(lineEndingIndex) ? lineEndings[lineEndingIndex].value : base.lineEnding,
6678
hasBom: base.hasBom
6779
)
80+
dialect.escapeChar = escapeIndex == backslashEscapeIndex
81+
? escapes[backslashEscapeIndex].byte
82+
: dialect.quoteChar
83+
return dialect
6884
}
6985
}

TableProTests/Plugins/CSVInspectorTests.swift

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,18 @@ struct CSVDialectDetectionTests {
7777
let dialect = CSVDialect.detect(from: data)
7878
#expect(dialect.encoding == .windowsCP1252)
7979
}
80+
81+
@Test("Default escape character is the quote character")
82+
func defaultEscapeChar() {
83+
#expect(CSVDialect.csv.escapeChar == 0x22)
84+
let detected = CSVDialect.detect(from: "a,b\n1,2\n".data(using: .utf8)!)
85+
#expect(detected.escapeChar == 0x22)
86+
}
87+
88+
@Test("Escape character follows a non-default quote character")
89+
func escapeFollowsQuote() {
90+
#expect(CSVDialect(delimiter: 0x2C, quoteChar: 0x27).escapeChar == 0x27)
91+
}
8092
}
8193

8294
@Suite("CSVStreamingParser")
@@ -133,6 +145,37 @@ struct CSVStreamingParserTests {
133145
#expect(fields == ["a", #"say "hi""#, "c"])
134146
}
135147

148+
@Test("Backslash escape decodes an escaped quote to a single quote")
149+
func backslashEscapesQuote() {
150+
var dialect = CSVDialect.csv
151+
dialect.escapeChar = 0x5C
152+
let (data, ranges, parser) = parse(#""a\"b",c"# + "\n", dialect: dialect)
153+
#expect(ranges.count == 1)
154+
let fields = row(data, parser, ranges[0])
155+
#expect(fields == [#"a"b"#, "c"])
156+
}
157+
158+
@Test("Backslash escape decodes a doubled backslash to a single backslash")
159+
func backslashEscapesBackslash() {
160+
var dialect = CSVDialect.csv
161+
dialect.escapeChar = 0x5C
162+
let (data, ranges, parser) = parse(#""a\\b""# + "\n", dialect: dialect)
163+
let fields = row(data, parser, ranges[0])
164+
#expect(fields == [#"a\b"#])
165+
}
166+
167+
@Test("field(at:column:) honors the backslash escape inside a quoted field")
168+
func fieldBackslashEscape() {
169+
var dialect = CSVDialect.csv
170+
dialect.escapeChar = 0x5C
171+
let (data, ranges, parser) = parse(#""a\"b",c"# + "\n", dialect: dialect)
172+
let first = data.withUnsafeBytes { raw -> String in
173+
guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return "" }
174+
return parser.field(UnsafeBufferPointer(start: base, count: raw.count), range: ranges[0], column: 0)
175+
}
176+
#expect(first == #"a"b"#)
177+
}
178+
136179
@Test("Empty fields preserved")
137180
func emptyFields() {
138181
let (data, ranges, parser) = parse(",,,\n")
@@ -473,6 +516,20 @@ struct CSVWriterRoundTripTests {
473516
#expect(written.prefix(3) == Data([0xEF, 0xBB, 0xBF]))
474517
}
475518

519+
@Test("Writer doubles an embedded quote by default")
520+
func writerDefaultDoublesQuote() {
521+
let writer = CSVWriter(dialect: .csv)
522+
#expect(writer.encodeRow([#"a"b"#, "c"]) == #""a""b",c"#)
523+
}
524+
525+
@Test("Writer escapes an embedded quote with the escape character")
526+
func writerBackslashEscapesQuote() {
527+
var dialect = CSVDialect.csv
528+
dialect.escapeChar = 0x5C
529+
let writer = CSVWriter(dialect: dialect)
530+
#expect(writer.encodeRow([#"a"b"#, "c"]) == #""a\"b",c"#)
531+
}
532+
476533
@Test("A headerless file is written without a synthetic header row")
477534
func roundTripHeaderless() throws {
478535
let source = "1,2\n3,4\n"

TableProTests/Views/CSVPropertyOptionsTests.swift

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,36 @@ struct CSVPropertyOptionsTests {
2222
#expect(CSVPropertyOptions.delimiterIndex(for: 0x5E) == 0)
2323
}
2424

25-
@Test("Building a dialect from indices sets the four properties and keeps the BOM")
25+
@Test("Building a dialect from indices sets every property and keeps the BOM")
2626
func dialectRoundTrip() {
2727
let base = CSVDialect(delimiter: 0x2C, hasBom: true)
2828
let dialect = CSVPropertyOptions.dialect(
2929
base: base,
3030
delimiterIndex: CSVPropertyOptions.delimiterIndex(for: 0x09),
3131
quoteIndex: CSVPropertyOptions.quoteIndex(for: 0x27),
32+
escapeIndex: CSVPropertyOptions.escapeIndex(for: 0x5C),
3233
encodingIndex: CSVPropertyOptions.encodingIndex(for: .utf16LittleEndian),
3334
lineEndingIndex: CSVPropertyOptions.lineEndingIndex(for: .cr)
3435
)
3536
#expect(dialect.delimiter == 0x09)
3637
#expect(dialect.quoteChar == 0x27)
38+
#expect(dialect.escapeChar == 0x5C)
3739
#expect(dialect.encoding == .utf16LittleEndian)
3840
#expect(dialect.lineEnding == .cr)
3941
#expect(dialect.hasBom)
4042
}
43+
44+
@Test("The doubled-quote escape follows the selected quote character")
45+
func doubledQuoteEscapeTracksQuote() {
46+
let dialect = CSVPropertyOptions.dialect(
47+
base: CSVDialect(delimiter: 0x2C),
48+
delimiterIndex: 0,
49+
quoteIndex: CSVPropertyOptions.quoteIndex(for: 0x27),
50+
escapeIndex: 0,
51+
encodingIndex: 0,
52+
lineEndingIndex: 0
53+
)
54+
#expect(dialect.quoteChar == 0x27)
55+
#expect(dialect.escapeChar == 0x27)
56+
}
4157
}

docs/features/csv-inspector.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ When a file opens, TablePro detects:
2626
- **Line ending**: CRLF, LF, or CR, whichever appears first in the first 64 KB.
2727
- **Header row**: row one is treated as headers when at least half of its cells are non-empty and not numbers. Otherwise TablePro generates `Column 1`, `Column 2`, ... and treats every row as data.
2828

29-
When a guess is wrong, choose **Edit > Set CSV Properties…** to pick the delimiter, quote character, encoding, and line ending by hand, then **Reload** to re-read the file with those settings. Reload discards unsaved edits and asks first if you have any.
29+
When a guess is wrong, choose **Edit > Set CSV Properties…** to pick the delimiter, quote character, escape character, encoding, and line ending by hand, then **Reload** to re-read the file with those settings. The escape character is either a doubled quote (the RFC 4180 default) or a backslash. Reload discards unsaved edits and asks first if you have any.
3030

3131
## Pagination
3232

0 commit comments

Comments
 (0)