@juspay/neurolink 9.94.1 → 9.94.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -61,9 +61,20 @@ export declare class JSONLoader extends TextLoader {
61
61
  export declare class CSVLoader extends TextLoader {
62
62
  load(source: string, options?: CSVLoaderOptions): Promise<MDocument>;
63
63
  canHandle(source: string): boolean;
64
- private parseCSVLine;
65
64
  private toMarkdownTable;
66
65
  private toTextTable;
66
+ /**
67
+ * Collapse embedded newlines to a single space so one logical CSV row
68
+ * renders as exactly one line in the text/markdown table output.
69
+ *
70
+ * The shared quote-aware parser (`splitCsvLines` + `splitCSVFields`)
71
+ * preserves newlines embedded inside a quoted field verbatim — correct
72
+ * for the `json` output format (where `JSON.stringify` escapes them), but
73
+ * left as-is here it would split a single record across multiple
74
+ * physical table rows. Mirrors the same normalization `CSVProcessor`
75
+ * already applies in its own markdown formatter.
76
+ */
77
+ private collapseEmbeddedNewlines;
67
78
  }
68
79
  /**
69
80
  * PDF file loader
@@ -27,6 +27,8 @@ import { existsSync } from "fs";
27
27
  import { readFile } from "fs/promises";
28
28
  import { basename, extname } from "path";
29
29
  import { logger } from "../../utils/logger.js";
30
+ import { CSVProcessor, stripBom, stripMetadataLine, splitCsvLines, } from "../../utils/csvProcessor.js";
31
+ import { splitCSVFields } from "../../utils/csvUtils.js";
30
32
  import { MDocument } from "./MDocument.js";
31
33
  /**
32
34
  * Text file loader
@@ -115,13 +117,28 @@ export class JSONLoader extends TextLoader {
115
117
  export class CSVLoader extends TextLoader {
116
118
  async load(source, options) {
117
119
  const content = await this.loadContent(source, options?.encoding);
118
- const { delimiter = ",", hasHeader = true, columns, outputFormat = "text", } = options || {};
119
- const lines = content.split("\n").filter((line) => line.trim());
120
+ const { delimiter, hasHeader = true, columns, outputFormat = "text", } = options || {};
121
+ // #361: an explicit delimiter always wins; otherwise detect it from the
122
+ // content + extension so a .tsv fed through RAG isn't collapsed into one
123
+ // comma-column (CSVLoader.canHandle already accepts .tsv).
124
+ const effectiveDelimiter = delimiter ?? CSVProcessor.detectDelimiter(content, extname(source));
125
+ // Route header/row parsing through the same shared, quote-aware path
126
+ // detectDelimiter() above already uses internally: a quote-aware logical
127
+ // row split (splitCsvLines — a newline inside a quoted field stays part
128
+ // of that field instead of breaking the row) with a leading Excel `sep=`
129
+ // preamble line stripped (stripMetadataLine), so it can't be parsed as
130
+ // the header. Field-level splitting also goes through the shared
131
+ // RFC-4180-aware splitCSVFields() instead of the loader's own naive
132
+ // backslash-escape parser, which didn't handle doubled-quote (`""`)
133
+ // escaping or quoted delimiters in the headerless-columns branch.
134
+ const { dataLines: strippedLines } = stripMetadataLine(splitCsvLines(stripBom(content)));
135
+ const lines = strippedLines.filter((line) => line.trim());
120
136
  const headers = hasHeader
121
- ? this.parseCSVLine(lines[0], delimiter)
122
- : columns || lines[0]?.split(delimiter).map((_, i) => `col${i + 1}`);
137
+ ? splitCSVFields(lines[0] ?? "", effectiveDelimiter).map((h) => h.trim())
138
+ : columns ||
139
+ splitCSVFields(lines[0] ?? "", effectiveDelimiter).map((_, i) => `col${i + 1}`);
123
140
  const dataLines = hasHeader ? lines.slice(1) : lines;
124
- const rows = dataLines.map((line) => this.parseCSVLine(line, delimiter));
141
+ const rows = dataLines.map((line) => splitCSVFields(line, effectiveDelimiter).map((field) => field.trim()));
125
142
  let formattedContent;
126
143
  switch (outputFormat) {
127
144
  case "json":
@@ -145,42 +162,39 @@ export class CSVLoader extends TextLoader {
145
162
  const ext = extname(source).toLowerCase();
146
163
  return ext === ".csv" || ext === ".tsv";
147
164
  }
148
- parseCSVLine(line, delimiter) {
149
- const result = [];
150
- let current = "";
151
- let inQuotes = false;
152
- for (let i = 0; i < line.length; i++) {
153
- const char = line[i];
154
- if (char === '"' && (i === 0 || line[i - 1] !== "\\")) {
155
- inQuotes = !inQuotes;
156
- }
157
- else if (char === delimiter && !inQuotes) {
158
- result.push(current.trim());
159
- current = "";
160
- }
161
- else {
162
- current += char;
163
- }
164
- }
165
- result.push(current.trim());
166
- return result;
167
- }
168
165
  toMarkdownTable(headers, rows) {
169
- const headerRow = `| ${headers.join(" | ")} |`;
170
- const separator = `| ${headers.map(() => "---").join(" | ")} |`;
171
- const dataRows = rows.map((row) => `| ${row.join(" | ")} |`);
166
+ const safeHeaders = headers.map((h) => this.collapseEmbeddedNewlines(h));
167
+ const headerRow = `| ${safeHeaders.join(" | ")} |`;
168
+ const separator = `| ${safeHeaders.map(() => "---").join(" | ")} |`;
169
+ const dataRows = rows.map((row) => `| ${row.map((cell) => this.collapseEmbeddedNewlines(cell)).join(" | ")} |`);
172
170
  return [headerRow, separator, ...dataRows].join("\n");
173
171
  }
174
172
  toTextTable(headers, rows) {
175
- const allRows = [headers, ...rows];
176
- const colWidths = headers.map((_, i) => Math.max(...allRows.map((row) => (row[i] || "").length)));
173
+ const safeHeaders = headers.map((h) => this.collapseEmbeddedNewlines(h));
174
+ const safeRows = rows.map((row) => row.map((cell) => this.collapseEmbeddedNewlines(cell)));
175
+ const allRows = [safeHeaders, ...safeRows];
176
+ const colWidths = safeHeaders.map((_, i) => Math.max(...allRows.map((row) => (row[i] || "").length)));
177
177
  const formatRow = (row) => row.map((cell, i) => (cell || "").padEnd(colWidths[i])).join(" | ");
178
178
  return [
179
- formatRow(headers),
179
+ formatRow(safeHeaders),
180
180
  colWidths.map((w) => "-".repeat(w)).join("-+-"),
181
- ...rows.map(formatRow),
181
+ ...safeRows.map(formatRow),
182
182
  ].join("\n");
183
183
  }
184
+ /**
185
+ * Collapse embedded newlines to a single space so one logical CSV row
186
+ * renders as exactly one line in the text/markdown table output.
187
+ *
188
+ * The shared quote-aware parser (`splitCsvLines` + `splitCSVFields`)
189
+ * preserves newlines embedded inside a quoted field verbatim — correct
190
+ * for the `json` output format (where `JSON.stringify` escapes them), but
191
+ * left as-is here it would split a single record across multiple
192
+ * physical table rows. Mirrors the same normalization `CSVProcessor`
193
+ * already applies in its own markdown formatter.
194
+ */
195
+ collapseEmbeddedNewlines(cell) {
196
+ return cell.replace(/\r\n|\n|\r/g, " ");
197
+ }
184
198
  }
185
199
  /**
186
200
  * PDF file loader
@@ -4,6 +4,50 @@
4
4
  * Uses streaming for memory efficiency with large files
5
5
  */
6
6
  import type { FileProcessingResult, CSVProcessorOptions } from "../types/index.js";
7
+ /**
8
+ * Detect if first line is CSV metadata (not actual data/headers)
9
+ * Common patterns:
10
+ * - Excel separator line: "SEP=,"
11
+ * - Lines with significantly different delimiter count than line 2
12
+ * - Lines that don't match CSV structure of subsequent lines
13
+ */
14
+ /**
15
+ * Strip a leading UTF-8 BOM (U+FEFF) if present. Exported so every entry
16
+ * point that sniffs raw text (including the RAG CSVLoader) normalizes BOMs
17
+ * the same way before metadata/delimiter detection.
18
+ */
19
+ export declare function stripBom(text: string): string;
20
+ /**
21
+ * Strip a leading metadata line (e.g. Excel's `sep=;` preamble) from
22
+ * already-split lines, and read the delimiter it explicitly declares.
23
+ * Excel semantics: an explicit `sep=<char>` always wins over frequency-based
24
+ * detection; a metadata line without a recognized delimiter character is
25
+ * simply dropped from the sample without forcing a delimiter. This is the
26
+ * single place process(), detectDelimiter(), parseCSVFile(), and
27
+ * parseCSVString() all go through, so none of them detect a delimiter over
28
+ * the raw, un-stripped lines (which lets the preamble skew detection).
29
+ * Exported so non-CSVProcessor callers (the RAG CSVLoader) share it too.
30
+ */
31
+ export declare function stripMetadataLine(lines: string[]): {
32
+ dataLines: string[];
33
+ hasMetadataLine: boolean;
34
+ explicitDelimiter?: string;
35
+ };
36
+ /**
37
+ * Split CSV text into logical rows for metadata detection and raw row limiting.
38
+ *
39
+ * Supports Unix (LF), Windows (CRLF), and classic Mac (CR) line endings, and is
40
+ * quote-aware: a line ending that appears *inside* a quoted field (RFC 4180) is
41
+ * kept as part of that field rather than treated as a row boundary. A doubled
42
+ * `""` inside quotes is the RFC-4180 escaped quote and does not toggle the quote
43
+ * state. For unquoted input this yields exactly the same result as a plain
44
+ * `split(/\r\n|\n|\r/)`.
45
+ *
46
+ * Exported so non-CSVProcessor callers (the RAG CSVLoader) get the same
47
+ * quote-aware row boundaries instead of a physical `content.split("\n")`,
48
+ * which would break rows on a newline embedded in a quoted field.
49
+ */
50
+ export declare function splitCsvLines(csvString: string): string[];
7
51
  /**
8
52
  * CSV processor for converting CSV data to LLM-optimized formats
9
53
  *
@@ -35,9 +79,14 @@ export declare class CSVProcessor {
35
79
  */
36
80
  static process(content: Buffer, options?: CSVProcessorOptions): Promise<FileProcessingResult>;
37
81
  /**
38
- * Parse CSV string into array of row objects using streaming
39
- * Memory-efficient for large files
82
+ * Detect the delimiter of DSV content (comma/tab/semicolon/pipe), respecting
83
+ * RFC-4180 quoting. An optional extension hint (`tsv` → tab, `psv` → pipe)
84
+ * breaks ambiguity. Used by callers (e.g. the RAG CSV loader) that parse
85
+ * DSV content themselves and need the same detection (#361). A leading
86
+ * Excel `sep=` metadata line is stripped before sampling so it can't skew
87
+ * detection, and an explicit `sep=<char>` value wins outright.
40
88
  */
89
+ static detectDelimiter(csvString: string, extensionHint?: string): string;
41
90
  /**
42
91
  * Parse CSV file from disk using streaming (memory efficient)
43
92
  *
@@ -54,7 +103,16 @@ export declare class CSVProcessor {
54
103
  * @param maxRows - Maximum rows to parse (default: 1000)
55
104
  * @returns Array of row objects
56
105
  */
57
- static parseCSVString(csvString: string, maxRows?: number): Promise<unknown[]>;
106
+ static parseCSVString(csvString: string, maxRows?: number, delimiter?: string): Promise<unknown[]>;
107
+ /**
108
+ * Stream-parse already-split, metadata-stripped data lines into row
109
+ * objects with a known delimiter. Shared core for parseCSVString() and
110
+ * process(), both of which need this after already splitting the content
111
+ * and resolving the delimiter once — calling parseCSVString() directly
112
+ * from process() would re-split and re-run metadata/delimiter detection
113
+ * over the same content a second time.
114
+ */
115
+ private static parseCSVLines;
58
116
  /**
59
117
  * Format parsed CSV data for LLM consumption
60
118
  * Only used for JSON and Markdown formats (raw format handled separately)
@@ -5,7 +5,9 @@
5
5
  */
6
6
  import csvParser from "csv-parser";
7
7
  import { Readable } from "stream";
8
+ import { extname } from "path";
8
9
  import { logger } from "./logger.js";
10
+ import { countCSVColumns, splitCSVFields, detectDelimiter as detectDelimiterUtil, CANDIDATE_DELIMITERS, } from "./csvUtils.js";
9
11
  // ============================================================================
10
12
  // Data Type Detection Patterns
11
13
  // ============================================================================
@@ -439,8 +441,12 @@ function detectHasHeaders(headerValues, dataRows) {
439
441
  * - Lines with significantly different delimiter count than line 2
440
442
  * - Lines that don't match CSV structure of subsequent lines
441
443
  */
442
- /** Strip a leading UTF-8 BOM (U+FEFF) if present. */
443
- function stripBom(text) {
444
+ /**
445
+ * Strip a leading UTF-8 BOM (U+FEFF) if present. Exported so every entry
446
+ * point that sniffs raw text (including the RAG CSVLoader) normalizes BOMs
447
+ * the same way before metadata/delimiter detection.
448
+ */
449
+ export function stripBom(text) {
444
450
  return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
445
451
  }
446
452
  /**
@@ -485,6 +491,31 @@ function isMetadataLine(lines) {
485
491
  }
486
492
  return false;
487
493
  }
494
+ /**
495
+ * Strip a leading metadata line (e.g. Excel's `sep=;` preamble) from
496
+ * already-split lines, and read the delimiter it explicitly declares.
497
+ * Excel semantics: an explicit `sep=<char>` always wins over frequency-based
498
+ * detection; a metadata line without a recognized delimiter character is
499
+ * simply dropped from the sample without forcing a delimiter. This is the
500
+ * single place process(), detectDelimiter(), parseCSVFile(), and
501
+ * parseCSVString() all go through, so none of them detect a delimiter over
502
+ * the raw, un-stripped lines (which lets the preamble skew detection).
503
+ * Exported so non-CSVProcessor callers (the RAG CSVLoader) share it too.
504
+ */
505
+ export function stripMetadataLine(lines) {
506
+ if (!isMetadataLine(lines)) {
507
+ return { dataLines: lines, hasMetadataLine: false };
508
+ }
509
+ const sepChar = lines[0]?.trim().match(/^sep=(.)/i)?.[1];
510
+ const explicitDelimiter = sepChar && CANDIDATE_DELIMITERS.includes(sepChar)
511
+ ? sepChar
512
+ : undefined;
513
+ return {
514
+ dataLines: lines.slice(1),
515
+ hasMetadataLine: true,
516
+ explicitDelimiter,
517
+ };
518
+ }
488
519
  /**
489
520
  * Split CSV text into logical rows for metadata detection and raw row limiting.
490
521
  *
@@ -494,8 +525,12 @@ function isMetadataLine(lines) {
494
525
  * `""` inside quotes is the RFC-4180 escaped quote and does not toggle the quote
495
526
  * state. For unquoted input this yields exactly the same result as a plain
496
527
  * `split(/\r\n|\n|\r/)`.
528
+ *
529
+ * Exported so non-CSVProcessor callers (the RAG CSVLoader) get the same
530
+ * quote-aware row boundaries instead of a physical `content.split("\n")`,
531
+ * which would break rows on a newline embedded in a quoted field.
497
532
  */
498
- function splitCsvLines(csvString) {
533
+ export function splitCsvLines(csvString) {
499
534
  const rows = [];
500
535
  let current = "";
501
536
  let inQuotes = false;
@@ -565,18 +600,22 @@ export class CSVProcessor {
565
600
  includeHeaders,
566
601
  });
567
602
  const csvString = content.toString("utf-8");
603
+ // #361: split once and strip a leading Excel `sep=` metadata line before
604
+ // detecting the delimiter (comma/tab/semicolon/pipe), so the preamble
605
+ // can't skew detection — then reuse dataLines below instead of
606
+ // re-splitting csvString for the raw-format row handling.
607
+ const lines = splitCsvLines(csvString);
608
+ const { dataLines, hasMetadataLine, explicitDelimiter } = stripMetadataLine(lines);
609
+ const delimiter = explicitDelimiter ??
610
+ detectDelimiterUtil(dataLines, extension ?? undefined);
568
611
  // For raw format, return CSV text with row limit (no parsing needed)
569
612
  // This preserves the CSV shape while normalizing line endings for row handling.
570
613
  if (formatStyle === "raw") {
571
- const lines = splitCsvLines(csvString);
572
- const hasMetadataLine = isMetadataLine(lines);
573
614
  if (hasMetadataLine) {
574
615
  logger.debug("[CSVProcessor] Detected metadata line, skipping first line");
575
616
  }
576
- // Skip metadata line if present, then take header + maxRows data rows
577
- const csvLines = hasMetadataLine
578
- ? lines.slice(1) // Skip metadata line
579
- : lines;
617
+ // dataLines already has the metadata line (if any) stripped.
618
+ const csvLines = dataLines;
580
619
  const limitedLines = csvLines.slice(0, 1 + maxRows); // header + data rows
581
620
  const limitedCSV = limitedLines.join("\n");
582
621
  const rowCount = limitedLines
@@ -597,11 +636,16 @@ export class CSVProcessor {
597
636
  logger.info("[CSVProcessor] ✅ Processed CSV file", {
598
637
  formatStyle: "raw",
599
638
  rowCount,
600
- columnCount: (limitedLines[0] || "").split(",").length,
639
+ columnCount: countCSVColumns(limitedLines[0] || "", delimiter),
601
640
  truncated: wasTruncated,
602
641
  });
603
- // Parse a sample for enhanced metadata analysis (raw format still benefits from column analysis)
604
- const sampleForAnalysis = await this.parseCSVString(limitedCSV, Math.min(rowCount, 500));
642
+ // Parse a sample for enhanced metadata analysis (raw format still
643
+ // benefits from column analysis). Reuse the already-split, already
644
+ // metadata-stripped `dataLines` via the shared parseCSVLines() core
645
+ // instead of routing `limitedCSV` back through parseCSVString(), which
646
+ // would re-split/re-join it and re-run a redundant BOM/metadata pass.
647
+ const sampleRows = Math.min(rowCount, 500);
648
+ const sampleForAnalysis = await this.parseCSVLines(dataLines.slice(0, 1 + sampleRows), sampleRows, delimiter);
605
649
  const { columnMetadata, dataQualityWarnings, dataQualityScore } = analyzeColumns(sampleForAnalysis);
606
650
  // Log data quality summary
607
651
  if (dataQualityWarnings.length > 0) {
@@ -619,13 +663,13 @@ export class CSVProcessor {
619
663
  size: content.length,
620
664
  rowCount,
621
665
  totalLines: limitedLines.length,
622
- columnCount: (limitedLines[0] || "").split(",").length,
666
+ columnCount: countCSVColumns(limitedLines[0] || "", delimiter),
623
667
  extension,
624
668
  columnMetadata,
625
669
  dataQualityWarnings,
626
670
  dataQualityScore,
627
- hasHeaders: detectHasHeaders((limitedLines[0] || "").split(","), undefined),
628
- detectedDelimiter: ",",
671
+ hasHeaders: detectHasHeaders(splitCSVFields(limitedLines[0] || "", delimiter), undefined),
672
+ detectedDelimiter: delimiter,
629
673
  },
630
674
  };
631
675
  }
@@ -634,7 +678,10 @@ export class CSVProcessor {
634
678
  formatStyle,
635
679
  maxRows,
636
680
  });
637
- const rows = await this.parseCSVString(csvString, maxRows);
681
+ // dataLines was already split and metadata-stripped above (alongside
682
+ // `delimiter`) — reuse it instead of routing through parseCSVString(),
683
+ // which would re-split csvString and re-run metadata detection.
684
+ const rows = await this.parseCSVLines(dataLines, maxRows, delimiter);
638
685
  // Filter out empty rows (empty objects or rows with only whitespace values from blank lines)
639
686
  const nonEmptyRows = rows.filter((row) => {
640
687
  if (!row || typeof row !== "object") {
@@ -701,14 +748,22 @@ export class CSVProcessor {
701
748
  dataQualityWarnings,
702
749
  dataQualityScore,
703
750
  hasHeaders: detectHasHeaders(columnNames, nonEmptyRows),
704
- detectedDelimiter: ",",
751
+ detectedDelimiter: delimiter,
705
752
  },
706
753
  };
707
754
  }
708
755
  /**
709
- * Parse CSV string into array of row objects using streaming
710
- * Memory-efficient for large files
756
+ * Detect the delimiter of DSV content (comma/tab/semicolon/pipe), respecting
757
+ * RFC-4180 quoting. An optional extension hint (`tsv` → tab, `psv` → pipe)
758
+ * breaks ambiguity. Used by callers (e.g. the RAG CSV loader) that parse
759
+ * DSV content themselves and need the same detection (#361). A leading
760
+ * Excel `sep=` metadata line is stripped before sampling so it can't skew
761
+ * detection, and an explicit `sep=<char>` value wins outright.
711
762
  */
763
+ static detectDelimiter(csvString, extensionHint) {
764
+ const { dataLines, explicitDelimiter } = stripMetadataLine(splitCsvLines(stripBom(csvString)));
765
+ return explicitDelimiter ?? detectDelimiterUtil(dataLines, extensionHint);
766
+ }
712
767
  /**
713
768
  * Parse CSV file from disk using streaming (memory efficient)
714
769
  *
@@ -734,9 +789,13 @@ export class CSVProcessor {
734
789
  let buffer = "";
735
790
  lineReader.on("data", (chunk) => {
736
791
  buffer += chunk.toString();
737
- const splitBuffer = buffer.endsWith("\r")
738
- ? buffer.slice(0, -1)
739
- : buffer;
792
+ // Strip a leading UTF-8 BOM before the `sep=` check below, same as
793
+ // process(), parseCSVString(), and detectDelimiter() — otherwise an
794
+ // Excel export starting with a BOM + "sep=;" fails the /^sep=/i match.
795
+ const normalizedBuffer = stripBom(buffer);
796
+ const splitBuffer = normalizedBuffer.endsWith("\r")
797
+ ? normalizedBuffer.slice(0, -1)
798
+ : normalizedBuffer;
740
799
  const lines = splitCsvLines(splitBuffer);
741
800
  if (lines.length >= 2) {
742
801
  firstLines.push(lines[0], lines[1]);
@@ -745,21 +804,32 @@ export class CSVProcessor {
745
804
  }
746
805
  });
747
806
  lineReader.on("end", () => resolve());
748
- // Metadata sniffing is best-effort — a read error here must not crash.
749
- lineReader.on("error", () => resolve());
807
+ // Metadata sniffing is best-effort — a read error here must not crash
808
+ // (the main read stream below will surface a real failure properly),
809
+ // but silently dropping it loses the diagnostic entirely, so log it.
810
+ lineReader.on("error", (error) => {
811
+ logger.warn(`[CSVProcessor] Metadata sniffing read failed for ${filePath}, proceeding without it: ${error.message}`, { cause: error });
812
+ resolve();
813
+ });
750
814
  });
751
815
  await fileHandle.close();
752
- const hasMetadataLine = isMetadataLine(firstLines);
816
+ const { dataLines, hasMetadataLine, explicitDelimiter } = stripMetadataLine(firstLines);
753
817
  const skipLines = hasMetadataLine ? 1 : 0;
818
+ // #361: pick the delimiter from the sniffed lines (metadata line
819
+ // stripped) + file extension so .tsv / semicolon / pipe files aren't
820
+ // collapsed into one column, and a `sep=` preamble can't skew detection.
821
+ const fileDelimiter = explicitDelimiter ?? detectDelimiterUtil(dataLines, extname(filePath));
754
822
  if (hasMetadataLine) {
755
823
  logger.debug("[CSVProcessor] Detected metadata line in file, will skip first line");
756
824
  }
757
825
  return new Promise((resolve, reject) => {
758
826
  const rows = [];
759
827
  let count = 0;
760
- let lineCount = 0;
761
828
  const source = fs.createReadStream(filePath, { encoding: "utf-8" });
762
- const parser = csvParser();
829
+ // Pass skipLines through to csv-parser itself so it skips the `sep=`
830
+ // metadata line BEFORE picking headers — otherwise csv-parser treats
831
+ // that line as the header row and the real header becomes a data row.
832
+ const parser = csvParser({ separator: fileDelimiter, skipLines });
763
833
  const abort = () => {
764
834
  source.destroy();
765
835
  parser.destroy();
@@ -773,10 +843,6 @@ export class CSVProcessor {
773
843
  source
774
844
  .pipe(parser)
775
845
  .on("data", (row) => {
776
- lineCount++;
777
- if (lineCount <= skipLines) {
778
- return;
779
- }
780
846
  rows.push(row);
781
847
  count++;
782
848
  if (count >= clampedMaxRows) {
@@ -803,32 +869,51 @@ export class CSVProcessor {
803
869
  * @param maxRows - Maximum rows to parse (default: 1000)
804
870
  * @returns Array of row objects
805
871
  */
806
- static async parseCSVString(csvString, maxRows = 1000) {
872
+ static async parseCSVString(csvString, maxRows = 1000, delimiter) {
807
873
  if (typeof csvString !== "string" || csvString.trim().length === 0) {
808
874
  throw new Error("CSVProcessor.parseCSVString: csvString must be a non-empty string");
809
875
  }
810
876
  // Strip a leading UTF-8 BOM (common in Excel exports) so it does not glue
811
877
  // onto the first column name and break downstream key lookups.
812
878
  const normalized = stripBom(csvString);
813
- const clampedMaxRows = Math.max(1, Math.min(10000, maxRows));
814
879
  logger.debug("[CSVProcessor] Starting string parsing", {
815
880
  inputLength: normalized.length,
816
- maxRows: clampedMaxRows,
881
+ maxRows,
817
882
  });
818
- // Detect and skip metadata line
883
+ // Detect and skip a leading metadata line before detecting the
884
+ // delimiter, so a `sep=` preamble can't skew detection (#361).
819
885
  const lines = splitCsvLines(normalized);
820
- const hasMetadataLine = isMetadataLine(lines);
821
- const csvData = hasMetadataLine
822
- ? lines.slice(1).join("\n")
823
- : lines.join("\n");
886
+ const { dataLines, hasMetadataLine, explicitDelimiter } = stripMetadataLine(lines);
887
+ // Caller-provided delimiter wins, then an explicit `sep=` declaration,
888
+ // then frequency-based detection over the post-metadata lines.
889
+ const effectiveDelimiter = delimiter ?? explicitDelimiter ?? detectDelimiterUtil(dataLines);
824
890
  if (hasMetadataLine) {
825
891
  logger.debug("[CSVProcessor] Detected metadata line in string, skipping");
826
892
  }
893
+ return this.parseCSVLines(dataLines, maxRows, effectiveDelimiter);
894
+ }
895
+ /**
896
+ * Stream-parse already-split, metadata-stripped data lines into row
897
+ * objects with a known delimiter. Shared core for parseCSVString() and
898
+ * process(), both of which need this after already splitting the content
899
+ * and resolving the delimiter once — calling parseCSVString() directly
900
+ * from process() would re-split and re-run metadata/delimiter detection
901
+ * over the same content a second time.
902
+ */
903
+ static parseCSVLines(dataLines, maxRows, delimiter) {
904
+ const clampedMaxRows = Math.max(1, Math.min(10000, maxRows));
905
+ const csvData = dataLines.join("\n");
906
+ if (csvData.trim().length === 0) {
907
+ // Shared core for both process() and parseCSVString() — use a generic
908
+ // message rather than naming one specific entry point, since either
909
+ // caller can land here with empty post-metadata data lines.
910
+ return Promise.reject(new Error("CSVProcessor: CSV data must be a non-empty string"));
911
+ }
827
912
  return new Promise((resolve, reject) => {
828
913
  const rows = [];
829
914
  let count = 0;
830
915
  const source = Readable.from([csvData]);
831
- const parser = csvParser();
916
+ const parser = csvParser({ separator: delimiter });
832
917
  const abort = () => {
833
918
  source.destroy();
834
919
  parser.destroy();
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared, RFC-4180-aware delimiter-separated-value helpers.
3
+ *
4
+ * Consolidates quote-aware field counting/splitting that was previously
5
+ * duplicated across csvProcessor.ts and fileDetector.ts (#359), and adds
6
+ * delimiter detection so TSV / semicolon / pipe files parse correctly (#361).
7
+ * Keeping these in one module prevents the same quoting edge case from having
8
+ * to be fixed in three places and drifting out of sync.
9
+ */
10
+ /** Delimiter candidates, in tie-break preference order (comma first). */
11
+ export declare const CANDIDATE_DELIMITERS: readonly [",", "\t", ";", "|"];
12
+ /**
13
+ * Split a single line into fields, respecting RFC-4180 quoting: a delimiter
14
+ * inside a `"…"` quoted field does not split, and `""` is an escaped quote.
15
+ */
16
+ export declare function splitCSVFields(line: string, delimiter?: string): string[];
17
+ /** Count fields in a single line, respecting RFC-4180 quoting. */
18
+ export declare function countCSVColumns(line: string, delimiter?: string): number;
19
+ /**
20
+ * Detect the delimiter of DSV content by testing comma/tab/semicolon/pipe
21
+ * against up to 5 sampled non-empty lines and keeping the candidate with the
22
+ * most consistent (>1) column count. Comma wins ties and the "nothing splits"
23
+ * case, so plain CSVs are never reclassified. An optional extension hint
24
+ * (`tsv` → tab, `psv` → pipe) breaks ambiguity in the file's favor.
25
+ */
26
+ export declare function detectDelimiter(lines: string[], extensionHint?: string): string;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Shared, RFC-4180-aware delimiter-separated-value helpers.
3
+ *
4
+ * Consolidates quote-aware field counting/splitting that was previously
5
+ * duplicated across csvProcessor.ts and fileDetector.ts (#359), and adds
6
+ * delimiter detection so TSV / semicolon / pipe files parse correctly (#361).
7
+ * Keeping these in one module prevents the same quoting edge case from having
8
+ * to be fixed in three places and drifting out of sync.
9
+ */
10
+ /** Delimiter candidates, in tie-break preference order (comma first). */
11
+ export const CANDIDATE_DELIMITERS = [",", "\t", ";", "|"];
12
+ /**
13
+ * Split a single line into fields, respecting RFC-4180 quoting: a delimiter
14
+ * inside a `"…"` quoted field does not split, and `""` is an escaped quote.
15
+ */
16
+ export function splitCSVFields(line, delimiter = ",") {
17
+ const fields = [];
18
+ let field = "";
19
+ let inQuotes = false;
20
+ for (let i = 0; i < line.length; i++) {
21
+ const ch = line[i];
22
+ if (ch === '"') {
23
+ if (inQuotes && line[i + 1] === '"') {
24
+ field += '"';
25
+ i++; // consume the escaped quote
26
+ continue;
27
+ }
28
+ inQuotes = !inQuotes;
29
+ }
30
+ else if (ch === delimiter && !inQuotes) {
31
+ fields.push(field);
32
+ field = "";
33
+ }
34
+ else {
35
+ field += ch;
36
+ }
37
+ }
38
+ fields.push(field);
39
+ return fields;
40
+ }
41
+ /** Count fields in a single line, respecting RFC-4180 quoting. */
42
+ export function countCSVColumns(line, delimiter = ",") {
43
+ return splitCSVFields(line, delimiter).length;
44
+ }
45
+ /**
46
+ * Detect the delimiter of DSV content by testing comma/tab/semicolon/pipe
47
+ * against up to 5 sampled non-empty lines and keeping the candidate with the
48
+ * most consistent (>1) column count. Comma wins ties and the "nothing splits"
49
+ * case, so plain CSVs are never reclassified. An optional extension hint
50
+ * (`tsv` → tab, `psv` → pipe) breaks ambiguity in the file's favor.
51
+ */
52
+ export function detectDelimiter(lines, extensionHint) {
53
+ const sample = lines.filter((l) => l.trim() !== "").slice(0, 5);
54
+ if (sample.length === 0) {
55
+ return ",";
56
+ }
57
+ const hint = extensionHint?.toLowerCase().replace(/^\./, "");
58
+ const hinted = hint === "tsv" ? "\t" : hint === "psv" ? "|" : undefined;
59
+ let best = ",";
60
+ let bestScore = -1;
61
+ for (const delim of CANDIDATE_DELIMITERS) {
62
+ const counts = sample.map((l) => countCSVColumns(l, delim));
63
+ const headerCols = counts[0];
64
+ if (headerCols <= 1) {
65
+ continue; // this delimiter doesn't split the header — not the delimiter
66
+ }
67
+ const consistent = counts.every((c) => c === headerCols);
68
+ // Prefer: a matching extension hint, then consistency, then more columns,
69
+ // with a small comma bias so comma stays the default on ties.
70
+ const score = (delim === hinted ? 1000 : 0) +
71
+ (consistent ? 100 : 0) +
72
+ headerCols +
73
+ (delim === "," ? 0.5 : 0);
74
+ if (score > bestScore) {
75
+ bestScore = score;
76
+ best = delim;
77
+ }
78
+ }
79
+ // Nothing split multi-column, but the extension says otherwise — honor it.
80
+ if (bestScore < 0 && hinted) {
81
+ return hinted;
82
+ }
83
+ return best;
84
+ }
85
+ //# sourceMappingURL=csvUtils.js.map
@@ -61,9 +61,20 @@ export declare class JSONLoader extends TextLoader {
61
61
  export declare class CSVLoader extends TextLoader {
62
62
  load(source: string, options?: CSVLoaderOptions): Promise<MDocument>;
63
63
  canHandle(source: string): boolean;
64
- private parseCSVLine;
65
64
  private toMarkdownTable;
66
65
  private toTextTable;
66
+ /**
67
+ * Collapse embedded newlines to a single space so one logical CSV row
68
+ * renders as exactly one line in the text/markdown table output.
69
+ *
70
+ * The shared quote-aware parser (`splitCsvLines` + `splitCSVFields`)
71
+ * preserves newlines embedded inside a quoted field verbatim — correct
72
+ * for the `json` output format (where `JSON.stringify` escapes them), but
73
+ * left as-is here it would split a single record across multiple
74
+ * physical table rows. Mirrors the same normalization `CSVProcessor`
75
+ * already applies in its own markdown formatter.
76
+ */
77
+ private collapseEmbeddedNewlines;
67
78
  }
68
79
  /**
69
80
  * PDF file loader