Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/ts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* @module turbocsv
*/

export { CSVParser, type CSVParserOptions, type StepResult, type ChunkResult, type ParseMeta, type ParserHandle } from "./parser";
export { CSVParser, type CSVParserOptions, type StepResult, type ChunkResult, type ParseMeta, type ParserHandle, type CSVMeta } from "./parser";
export { CSVRow } from "./row";
export { DataFrame, type DataFrameOptions } from "./dataframe";
export { CSVWriter, ModificationLog, type CSVWriterOptions } from "./writer";
Expand Down
34 changes: 34 additions & 0 deletions src/ts/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,22 @@ export interface CSVParserOptions<T = Record<string, string>> {
chunkSize?: number;
}

/** Parse metadata (PapaParse-compatible) */
export interface CSVMeta {
/** Delimiter used for parsing */
delimiter: string;
/** Line ending used in the data */
linebreak: string;
/** Whether parsing was aborted */
aborted: boolean;
/** Whether output was truncated (e.g. by preview limit) */
truncated: boolean;
/** Header field names (null if no headers) */
fields: string[] | null;
/** Elapsed parse time in milliseconds */
elapsedMs: number;
}

/** Input source types */
type InputSource = string | ReadableStream | ArrayBuffer | Uint8Array;

Expand Down Expand Up @@ -133,6 +149,8 @@ export class CSVParser<T = Record<string, string>>
private headerRow: string[] | null = null;
private startTime: number = 0;
private closed: boolean = false;
private aborted: boolean = false;
private truncated: boolean = false;
private sourcePath: string | null = null;

// Error tracking
Expand Down Expand Up @@ -335,6 +353,22 @@ export class CSVParser<T = Record<string, string>>
return this.headerRow;
}

/**
* Get parse metadata (PapaParse-compatible).
* Returns information about the delimiter, linebreak, fields,
* and whether parsing was aborted or truncated.
*/
getMeta(): CSVMeta {
return {
delimiter: this.options.delimiter ?? ",",
linebreak: "\n",
aborted: this.aborted,
truncated: this.truncated,
fields: this.headerRow ? [...this.headerRow] : null,
elapsedMs: this.startTime > 0 ? performance.now() - this.startTime : 0,
};
}

/**
* Pause parsing.
*/
Expand Down
130 changes: 130 additions & 0 deletions test/unit/meta.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* Tests for Issue #14: meta output in parse results
*/

import { describe, test, expect } from "bun:test";
import { CSVParser } from "../../src/ts/parser";
import type { CSVMeta } from "../../src/ts/parser";

describe("getMeta()", () => {
test("returns delimiter used", () => {
const data = new TextEncoder().encode(
"name,age\nAlice,30\n"
);
const parser = new CSVParser(data);
for (const _row of parser) { /* consume */ }

const meta = parser.getMeta();
expect(meta.delimiter).toBe(",");
parser.close();
});

test("returns custom delimiter", () => {
const data = new TextEncoder().encode(
"name\tage\nAlice\t30\n"
);
const parser = new CSVParser(data, { delimiter: "\t" });
for (const _row of parser) { /* consume */ }

const meta = parser.getMeta();
expect(meta.delimiter).toBe("\t");
parser.close();
});

test("returns linebreak", () => {
const data = new TextEncoder().encode(
"name\nAlice\n"
);
const parser = new CSVParser(data);
const meta = parser.getMeta();

expect(meta.linebreak).toBe("\n");
parser.close();
});

test("returns fields array with header names", () => {
const data = new TextEncoder().encode(
"name,age,city\nAlice,30,NYC\n"
);
const parser = new CSVParser(data);
const meta = parser.getMeta();

expect(meta.fields).toEqual(["name", "age", "city"]);
parser.close();
});

test("returns null fields without headers", () => {
const data = new TextEncoder().encode(
"Alice,30\n"
);
const parser = new CSVParser(data, { hasHeader: false });
const meta = parser.getMeta();

expect(meta.fields).toBeNull();
parser.close();
});

test("aborted is false by default", () => {
const data = new TextEncoder().encode(
"name\nAlice\n"
);
const parser = new CSVParser(data);
for (const _row of parser) { /* consume */ }

const meta = parser.getMeta();
expect(meta.aborted).toBe(false);
parser.close();
});

test("truncated is false by default", () => {
const data = new TextEncoder().encode(
"name\nAlice\nBob\n"
);
const parser = new CSVParser(data);
for (const _row of parser) { /* consume */ }

const meta = parser.getMeta();
expect(meta.truncated).toBe(false);
parser.close();
});

test("elapsedMs is a positive number after parsing", () => {
const data = new TextEncoder().encode(
"name,age\nAlice,30\nBob,25\n"
);
const parser = new CSVParser(data);
for (const _row of parser) { /* consume */ }

const meta = parser.getMeta();
expect(meta.elapsedMs).toBeGreaterThanOrEqual(0);
parser.close();
});

test("fields is a copy, not a reference", () => {
const data = new TextEncoder().encode(
"name,age\nAlice,30\n"
);
const parser = new CSVParser(data);
const meta1 = parser.getMeta();
const meta2 = parser.getMeta();

// Should be equal but not the same reference
expect(meta1.fields).toEqual(meta2.fields);
expect(meta1.fields).not.toBe(meta2.fields);
parser.close();
});

test("meta available before iteration", () => {
const data = new TextEncoder().encode(
"name,age\nAlice,30\n"
);
const parser = new CSVParser(data);

// Meta should be available immediately after construction
const meta = parser.getMeta();
expect(meta.delimiter).toBe(",");
expect(meta.fields).toEqual(["name", "age"]);
expect(meta.aborted).toBe(false);
parser.close();
});
});
Loading