import Foundation /// **RFC 4180, both directions** — the delimited-text plumbing under the CSV converter, and nothing /// about boards at all (15-import-export.md ▸ The formats ▸ CSV). /// /// Its own type rather than two private helpers because the two halves have to agree exactly, and the /// agreement is the only thing worth testing here: a field this writer quotes is a field this reader /// unquotes, at every one of the four characters that force the issue. /// /// ### The writer's rules /// /// - A field is quoted when it contains a comma, a double quote, CR or LF — and left bare otherwise, so /// an ordinary export stays readable in a text editor. /// - An embedded double quote is doubled (`"` → `""`), which is RFC 4180's only escape. /// - **Records end with CRLF**, which the RFC requires and which is the one place this app's own /// LF-everywhere rule (01-storage-format.md § Encoding and line endings) deliberately does not reach: /// that rule is about the files a *board* is made of, and a CSV is an interchange document written for /// other people's tools. Every reader in the world takes both; the standard names one. /// - The text is UTF-8 with **no BOM**. Excel on Windows prefers one and every other consumer is worse /// off for it; the app's own encoding rule breaks the tie. /// /// ### The reader's rules /// /// Deliberately more forgiving than the writer, because it is reading somebody else's file: LF, CRLF and /// bare CR all end a record, a BOM is dropped, a quote appearing mid-field is literal text rather than a /// syntax error, and a final record with no terminator is still a record. A trailing terminator does not /// produce a phantom empty record. public enum CSVDocument { // MARK: - Write /// Records to text. Nothing here inspects the shape — a ragged table encodes as a ragged table, /// because the caller's rows are the caller's business. public static func encode(_ records: [[String]]) -> String { guard !records.isEmpty else { return "" } return records .map { $0.map(field(_:)).joined(separator: ",") } .joined(separator: "\r\n") + "\r\n" } /// One field, quoted only where RFC 4180 requires it. static func field(_ value: String) -> String { guard value.contains(where: { $0 == "," || $0 == "\"" || $0 == "\n" || $0 == "\r" }) else { return value } return "\"\(value.replacingOccurrences(of: "\"", with: "\"\""))\"" } // MARK: - Read /// Text to records — a single character scan, because a line-based reader cannot see a newline /// inside a quoted field, and a quoted field holding a card body is the whole reason this format /// can carry bodies at all. public static func decode(_ text: String) -> [[String]] { let characters = Array(stripBOM(text)) var records: [[String]] = [] var record: [String] = [] var field = "" var inQuotes = false var index = 0 func endField() { record.append(field) field = "" } func endRecord() { endField() records.append(record) record = [] } while index < characters.count { let character = characters[index] if inQuotes { if character == "\"" { // A doubled quote is one literal quote; a lone one closes the field. if index + 1 < characters.count, characters[index + 1] == "\"" { field.append("\"") index += 2 } else { inQuotes = false index += 1 } } else { field.append(character) index += 1 } continue } switch character { case "\"": // A quote that *opens* a field opens a quoted field — the shape the writer produces. // One appearing later is literal text (`say "hi"` written unquoted by a spreadsheet // that saw no reason to escape it), which is worth reading rather than refusing. if field.isEmpty { inQuotes = true } else { field.append(character) } index += 1 case ",": endField() index += 1 case "\n", "\r", "\r\n": // **`"\r\n"` is one `Character`**, not two: Swift's grapheme clustering merges a CR // immediately followed by an LF, so a scan over `[Character]` never sees the pair as a // sequence and a lookahead for it would never fire. Listing the cluster as its own case // is the whole of CRLF handling here — and it is why a bare `"\r"` reaching this switch // is genuinely a lone carriage return (old Mac line endings), never half of a pair. endRecord() index += 1 default: field.append(character) index += 1 } } // A final record with no terminator still counts; a terminator with nothing after it does not. if !record.isEmpty || !field.isEmpty { endRecord() } return records } private static func stripBOM(_ text: String) -> String { text.hasPrefix("\u{FEFF}") ? String(text.dropFirst()) : text } }