From a8b15cf46c5fb27ca85755978879d53ee4392418 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:03:41 -0400 Subject: [PATCH] Reject duplicate headers in dictionary views --- SwiftCSV/Parser.swift | 9 ++++++ SwiftCSV/ParsingState.swift | 2 ++ SwiftCSVTests/DuplicateHeaderTests.swift | 37 ++++++++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 SwiftCSVTests/DuplicateHeaderTests.swift diff --git a/SwiftCSV/Parser.swift b/SwiftCSV/Parser.swift index e6db465..ffc1599 100644 --- a/SwiftCSV/Parser.swift +++ b/SwiftCSV/Parser.swift @@ -28,6 +28,8 @@ extension CSV { try Parser.enumerateAsArray(text: self.text, delimiter: self.delimiter, startAt: startAt, rowLimit: rowLimit, rowCallback: rowCallback) } + /// Enumerate each row as a dictionary keyed by its header names. + /// - Throws: `CSVParseError.duplicateHeader` if headers repeat, or an error thrown by `block`. public func enumerateAsDict(_ block: @escaping ([String : String]) throws -> ()) throws { try Parser.enumerateAsDict(header: self.header, content: self.text, delimiter: self.delimiter, block: block) @@ -125,8 +127,15 @@ enum Parser { } } + /// Parse rows as dictionaries. Duplicate header names are rejected because a dictionary cannot + /// preserve both columns. static func enumerateAsDict(header: [String], content: String, delimiter: CSVDelimiter, rowLimit: Int? = nil, block: @escaping ([String : String]) throws -> ()) throws { + var seenHeaders = Set() + for name in header where !seenHeaders.insert(name).inserted { + throw CSVParseError.duplicateHeader(name: name) + } + let enumeratedHeader = header.enumerated() // Start after the header diff --git a/SwiftCSV/ParsingState.swift b/SwiftCSV/ParsingState.swift index fc0e671..dc00ea2 100644 --- a/SwiftCSV/ParsingState.swift +++ b/SwiftCSV/ParsingState.swift @@ -9,6 +9,8 @@ public enum CSVParseError: Error { case generic(message: String) case quotation(message: String) + /// A duplicate header cannot be represented without losing a column in dictionary-based access. + case duplicateHeader(name: String) } /// State machine of parsing CSV contents character by character. diff --git a/SwiftCSVTests/DuplicateHeaderTests.swift b/SwiftCSVTests/DuplicateHeaderTests.swift new file mode 100644 index 0000000..61d38f2 --- /dev/null +++ b/SwiftCSVTests/DuplicateHeaderTests.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import SwiftCSV + +final class DuplicateHeaderTests: XCTestCase { + private let text = """ + en,comment,de,en,en-GB,fr,es,it + ,,,,,,, + Efficiency,,,,,,, + %@,,,,,,, + """ + + func testNamedViewRejectsDuplicateHeaders() { + XCTAssertThrowsError(try CSV(string: text)) { error in + guard case CSVParseError.duplicateHeader(name: "en") = error else { + return XCTFail("Expected a duplicate-header error, got \(error)") + } + } + } + + func testEnumeratedViewPreservesDuplicateHeaderColumns() throws { + let csv = try CSV(string: text) + + XCTAssertEqual(csv.header, ["en", "comment", "de", "en", "en-GB", "fr", "es", "it"]) + XCTAssertEqual(csv.rows[1], ["Efficiency", "", "", "", "", "", "", ""]) + XCTAssertEqual(csv.columns?.filter { $0.header == "en" }.count, 2) + } + + func testDictionaryEnumerationRejectsDuplicateHeaders() throws { + let csv = try CSV(string: text) + + XCTAssertThrowsError(try csv.enumerateAsDict { _ in }) { error in + guard case CSVParseError.duplicateHeader(name: "en") = error else { + return XCTFail("Expected a duplicate-header error, got \(error)") + } + } + } +}