001/* 002 * Java Genetic Algorithm Library (jenetics-9.1.0). 003 * Copyright (c) 2007-2026 Franz Wilhelmstötter 004 * 005 * Licensed under the Apache License, Version 2.0 (the "License"); 006 * you may not use this file except in compliance with the License. 007 * You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 * 017 * Author: 018 * Franz Wilhelmstötter (franz.wilhelmstoetter@gmail.com) 019 */ 020package io.jenetics.ext.util; 021 022import static java.util.Objects.requireNonNull; 023 024import java.io.IOException; 025import java.io.Reader; 026import java.io.UncheckedIOException; 027import java.nio.CharBuffer; 028import java.util.Arrays; 029import java.util.List; 030import java.util.Objects; 031import java.util.function.Function; 032import java.util.function.Supplier; 033import java.util.stream.Collector; 034import java.util.stream.Collectors; 035import java.util.stream.Stream; 036 037import io.jenetics.internal.util.Lifecycle.IOValue; 038 039/** 040 * This class contains helper classes, which are the building blocks for handling 041 * CSV files. 042 * <ul> 043 * <li>{@link LineReader}: This class allows you to read the lines of a 044 * CSV file. The result will be a {@link Stream} of CSV lines and are 045 * not split.</li> 046 * <li>{@link LineSplitter}: This class is responsible for splitting one 047 * CSV line into column values.</li> 048 * <li>{@link ColumnIndexes}: Allows defining the projection/embedding of 049 * the split/joined column values.</li> 050 * <li>{@link ColumnJoiner}: Joining a column array into a CSV line, which 051 * can be joined into a whole CSV string.</li> 052 * </ul> 053 * <p> 054 * Additionally, this class contains a set of helper methods for CSV handling 055 * using default configurations. 056 * <p> 057 * <b>Reading and splitting CSV lines</b> 058 * {@snippet class="Snippets" region="readRows"} 059 * <p> 060 * <b>Joining columns and creating CSV string</b> 061 * {@snippet class="Snippets" region="CsvSupportSnippets.collect"} 062 * <p> 063 * <b>Parsing CSV string</b> 064 * {@snippet class="Snippets" region="parseCsv"} 065 * <p> 066 * <b>Parsing double values, given as CSV string</b> 067 * <p> 068 * Another example is to parse double values, which are given as CSV string and 069 * use this data for running a regression analysis. 070 * {@snippet class="Snippets" region="DoublesParsingSnippets.parseDoubles"} 071 * 072 * @see <a href="https://tools.ietf.org/html/rfc4180">RFC-4180</a> 073 * 074 * @author <a href="mailto:franz.wilhelmstoetter@gmail.com">Franz Wilhelmstötter</a> 075 * @version 8.2 076 * @since 8.1 077 */ 078public final class CsvSupport { 079 080 /** 081 * Holds the CSV column <em>separator</em> character. 082 * 083 * @param value the separator character 084 * 085 * @version 8.1 086 * @since 8.1 087 */ 088 public record Separator(char value) { 089 090 /** 091 * The default separator character, '{@code ,}'. 092 */ 093 public static final Separator DEFAULT = new Separator(','); 094 095 /** 096 * Creates a new Separator char object. 097 * 098 * @param value the separator character 099 * @throws IllegalArgumentException if the given separator character is 100 * a line break character 101 */ 102 public Separator { 103 if (isLineBreak(value)) { 104 throw new IllegalArgumentException( 105 "Given separator char is a line break character." 106 ); 107 } 108 } 109 } 110 111 /** 112 * Holds the CSV column <em>quote</em> character. The following excerpt from 113 * <a href="https://tools.ietf.org/html/rfc4180">RFC-4180</a> defines when 114 * a quote character has to be used. 115 * <pre> 116 * 5. Each field may or may not be enclosed in double quotes (however 117 * some programs, such as Microsoft Excel, do not use double quotes 118 * at all). If fields are not enclosed with double quotes, then 119 * double quotes may not appear inside the fields. For example: 120 * 121 * "aaa","bbb","ccc" CRLF 122 * zzz,yyy,xxx 123 * 124 * 6. Fields containing line breaks (CRLF), double quotes, and commas 125 * should be enclosed in double-quotes. For example: 126 * 127 * "aaa","b CRLF 128 * bb","ccc" CRLF 129 * zzz,yyy,xxx 130 * 131 * 7. If double-quotes are used to enclose fields, then a double-quote 132 * appearing inside a field must be escaped by preceding it with 133 * another double quote. For example: 134 * 135 * "aaa","b""bb","ccc" 136 * </pre> 137 * 138 * @param value the quote character 139 * 140 * @version 8.1 141 * @since 8.1 142 */ 143 public record Quote(char value) { 144 145 /** 146 * The default quote character, '{@code "}'. 147 */ 148 public static final Quote DEFAULT = new Quote('"'); 149 150 /** 151 * The zero '\0' character. 152 */ 153 public static final Quote ZERO = new Quote('\0'); 154 155 /** 156 * Creates a new Quote char object. 157 * 158 * @param value the quote character 159 * @throws IllegalArgumentException if the given quote character is 160 * a line break character 161 */ 162 public Quote { 163 if (isLineBreak(value)) { 164 throw new IllegalArgumentException( 165 "Given quote char is a line break character." 166 ); 167 } 168 } 169 } 170 171 /** 172 * Holds the column indexes, which should be part of the split or join 173 * operation. When used in the {@link LineSplitter}, it lets you filter the 174 * split column and define its order. When used in the {@link ColumnJoiner}, 175 * it can be used to define the column index in the resulting CSV for a 176 * given row array. 177 * 178 * @apiNote 179 * The column indexes is <em>thread-safe</em> and can be shared between 180 * different threads. 181 * 182 * @see LineSplitter 183 * @see ColumnJoiner 184 * 185 * @param values the column indexes which are part of the split result 186 * 187 * @version 8.1 188 * @since 8.1 189 */ 190 public record ColumnIndexes(int... values) { 191 192 /** 193 * Indicating that <em>all</em> columns should be part of the split 194 * result. 195 */ 196 public static final ColumnIndexes ALL = new ColumnIndexes(); 197 198 /** 199 * Create a new column indexes object. 200 * 201 * @param values the column indexes 202 */ 203 public ColumnIndexes { 204 values = values.clone(); 205 } 206 207 @Override 208 public int[] values() { 209 return values.clone(); 210 } 211 212 @Override 213 public int hashCode() { 214 return Arrays.hashCode(values); 215 } 216 217 @Override 218 public boolean equals(final Object obj) { 219 return obj instanceof ColumnIndexes ci && 220 Arrays.equals(values, ci.values); 221 } 222 223 @Override 224 public String toString() { 225 return Arrays.toString(values); 226 } 227 } 228 229 /** 230 * The newline string used for writing the CSV file: {@code \r\n}. 231 */ 232 public static final String EOL = "\r\n"; 233 234 235 private CsvSupport() { 236 } 237 238 private static boolean isLineBreak(final char c) { 239 return switch (c) { 240 case '\n', '\r' -> true; 241 default -> false; 242 }; 243 } 244 245 /** 246 * Splits the CSV file, given by the {@code reader}, into a {@link Stream} 247 * of CSV lines. The CSV is split at line breaks, as long as they are not 248 * part of a quoted column. For reading the CSV lines, the default quote 249 * character, {@link Quote#DEFAULT}, is used. 250 * 251 * @apiNote 252 * The returned stream must be closed by the caller, which also closes the 253 * CSV {@code reader}. 254 * 255 * @see #readAllLines(Readable) 256 * 257 * @param reader the CSV source reader. The reader is automatically closed 258 * when the returned line stream is closed. 259 * @return the stream of CSV lines 260 * @throws NullPointerException if the given {@code reader} is {@code null} 261 */ 262 public static Stream<String> lines(final Readable reader) { 263 return LineReader.DEFAULT.read(reader); 264 } 265 266 /** 267 * Splits the CSV file, given by the {@code reader}, into a {@code Stream} 268 * of CSV rows. The CSV is split at line breaks, as long as they are not 269 * part of a quoted column. For reading the CSV lines, the default quote 270 * character, {@link Quote#DEFAULT}, is used. Then each line is split into 271 * its columns using the default separator character. 272 * 273 * @apiNote 274 * The returned stream must be closed by the caller, which also closes the 275 * CSV {@code reader}. 276 * 277 * @see #readAllRows(Readable) 278 * 279 * @param reader the CSV source reader. The reader is automatically closed 280 * when the returned line stream is closed. 281 * @return the stream of CSV rows 282 * @throws NullPointerException if the given {@code reader} is {@code null} 283 */ 284 public static Stream<String[]> rows(final Readable reader) { 285 final var splitter = new LineSplitter(); 286 return lines(reader).map(splitter::split); 287 } 288 289 /** 290 * Splits the CSV file, given by the {@code reader}, into a {@code List} 291 * of CSV lines. The CSV is split at line breaks, as long as they are not 292 * part of a quoted column. For reading the CSV lines, the default quote 293 * character, {@link Quote#DEFAULT}, is used. 294 * 295 * @see #lines(Readable) 296 * 297 * @param reader the reader stream to split into CSV lines 298 * @return the list of CSV lines 299 * @throws NullPointerException if the given {@code reader} is {@code null} 300 * @throws IOException if reading the CSV lines fails 301 */ 302 public static List<String> readAllLines(final Readable reader) 303 throws IOException 304 { 305 try (var lines = lines(reader)) { 306 return lines.toList(); 307 } catch (UncheckedIOException e) { 308 throw e.getCause(); 309 } 310 } 311 312 /** 313 * Splits the CSV file, given by the {@code reader}, into a {@code List} 314 * of CSV lines. The CSV is split at line breaks, as long as they are not 315 * part of a quoted column. For reading the CSV lines, the default quote 316 * character, {@link Quote#DEFAULT}, is used. Then each line is split into 317 * its columns using the default separator character. 318 * 319 * @see #rows(Readable) 320 * 321 * @param reader the reader stream to split into CSV lines 322 * @return the list of CSV rows 323 * @throws NullPointerException if the given {@code reader} is {@code null} 324 * @throws IOException if reading the CSV lines fails 325 */ 326 public static List<String[]> readAllRows(final Readable reader) 327 throws IOException 328 { 329 try (var rows = rows(reader)) { 330 return rows.toList(); 331 } catch (UncheckedIOException e) { 332 throw e.getCause(); 333 } 334 } 335 336 /** 337 * Parses the given CSV string into a list of <em>records</em>. The records 338 * are created from a <em>row</em> ({@code String[]} array) by applying the 339 * given {@code mapper}. 340 * 341 * @param csv the CSV string to parse 342 * @param mapper the record mapper 343 * @return the parsed record list 344 * @param <T> the record type 345 */ 346 public static <T> List<T> parse( 347 final CharSequence csv, 348 final Function<? super String[], ? extends T> mapper 349 ) { 350 requireNonNull(csv); 351 requireNonNull(mapper); 352 353 try (var rows = rows(CharBuffer.wrap(csv))) { 354 return rows 355 .map(mapper) 356 .collect(Collectors.toUnmodifiableList()); 357 } 358 } 359 360 /** 361 * Parses the given CSV string into a list of rows. 362 * 363 * @param csv the CSV string to parse 364 * @return the parsed CSV rows 365 */ 366 public static List<String[]> parse(final CharSequence csv) { 367 return parse(csv, Function.identity()); 368 } 369 370 /** 371 * Parses the given CSV string into a list of {@code double[]} array rows. 372 * 373 * @param csv the CSV string to parse 374 * @return the parsed double data 375 */ 376 public static List<double[]> parseDoubles(final CharSequence csv) { 377 return parse(csv, CsvSupport::toDoubles); 378 } 379 380 private static double[] toDoubles(final String[] values) { 381 final var result = new double[values.length]; 382 for (int i = 0; i < result.length; ++i) { 383 result[i] = Double.parseDouble(values[i].trim()); 384 } 385 return result; 386 } 387 388 /** 389 * Splits a given CSV {@code line} into columns. The default values for the 390 * separator and quote character are used ({@link Separator#DEFAULT}, 391 * {@link Quote#DEFAULT}) for splitting the line. 392 * 393 * @param line the CSV line to split 394 * @return the split CSV lines 395 * @throws NullPointerException if the given {@code line} is {@code null} 396 */ 397 public static String[] split(final CharSequence line) { 398 return new LineSplitter().split(line); 399 } 400 401 /** 402 * Joins the given CSV {@code columns} to one CSV line. The default values 403 * for the separator and quote character are used ({@link Separator#DEFAULT}, 404 * {@link Quote#DEFAULT}) for joining the columns. 405 * 406 * @see #join(Object[]) 407 * 408 * @param columns the CSV columns to join 409 * @return the CSV line, joined from the given {@code columns} 410 * @throws NullPointerException if the given {@code columns} is {@code null} 411 */ 412 public static String join(final Iterable<?> columns) { 413 return ColumnJoiner.DEFAULT.join(columns); 414 } 415 416 /** 417 * Joins the given CSV {@code columns} to one CSV line. The default values 418 * for the separator and quote character are used ({@link Separator#DEFAULT}, 419 * {@link Quote#DEFAULT}) for joining the columns. 420 * 421 * @see #join(Iterable) 422 * 423 * @param columns the CSV columns to join 424 * @return the CSV line, joined from the given {@code columns} 425 * @throws NullPointerException if the given {@code columns} is {@code null} 426 */ 427 public static String join(final Object[] columns) { 428 return ColumnJoiner.DEFAULT.join(columns); 429 } 430 431 /** 432 * Joins the given CSV {@code columns} to one CSV line. The default values 433 * for the separator and quote character are used ({@link Separator#DEFAULT}, 434 * {@link Quote#DEFAULT}) for joining the columns. 435 * 436 * @see #join(Iterable) 437 * @see #join(Object[]) 438 * 439 * @param columns the CSV columns to join 440 * @return the CSV line, joined from the given {@code columns} 441 * @throws NullPointerException if the given {@code columns} is {@code null} 442 */ 443 public static String join(final String... columns) { 444 return ColumnJoiner.DEFAULT.join(columns); 445 } 446 447 /** 448 * Converts the given {@code record} into its components. 449 * 450 * @param record the record to convert 451 * @return the record components 452 */ 453 public static Object[] toComponents(final Record record) { 454 try { 455 final var components = record.getClass().getRecordComponents(); 456 final var elements = new Object[components.length]; 457 for (int i = 0; i < elements.length; ++i) { 458 elements[i] = components[i].getAccessor().invoke(record); 459 } 460 461 return elements; 462 } catch (ReflectiveOperationException e) { 463 throw new IllegalArgumentException(e); 464 } 465 } 466 467 /** 468 * Return a collector for joining a list of CSV rows into one CSV string. 469 * 470 * @return a collector for joining a list of CSV rows into one CSV string 471 */ 472 public static Collector<CharSequence, ?, String> toCsv() { 473 return toCsv(EOL); 474 } 475 476 /** 477 * Return a collector for joining a list of CSV rows into one CSV string. 478 * For the line breaks, the given {@code eol} sequence is used. 479 * 480 * @param eol the end of line sequence used for line breaks 481 * @return a collector for joining a list of CSV rows into one CSV string 482 */ 483 public static Collector<CharSequence, ?, String> toCsv(String eol) { 484 if (eol.isEmpty()) { 485 throw new IllegalArgumentException("EOL must not be empty."); 486 } 487 for (int i = 0; i < eol.length(); ++i) { 488 if (!isLineBreak(eol.charAt(i))) { 489 throw new IllegalArgumentException( 490 "EOl contains non-linebreak char: '%s'.".formatted(eol) 491 ); 492 } 493 } 494 495 return Collectors.joining(eol, "", eol); 496 } 497 498 499 /* ************************************************************************* 500 * Base CSV classes. 501 * ************************************************************************/ 502 503 /** 504 * This class reads CSV files and splits it into lines. It takes a quote 505 * character as a parameter, which is necessary for not splitting on quoted 506 * line feeds. 507 * {@snippet lang="java": 508 * final var csv = """ 509 * 0.0,0.0000 510 * 0.1,0.0740 511 * 0.2,0.1120 512 * 0.3,0.1380 513 * 0.4,0.1760 514 * 0.5,0.2500 515 * 0.6,0.3840 516 * 0.7,0.6020 517 * 0.8,0.9280 518 * 0.9,1.3860 519 * 1.0,2.0000 520 * """; 521 * 522 * final var reader = new LineReader(new Quote('"')); 523 * try (Stream<String> lines = reader.read(CharBuffer.wrap(csv))) { 524 * lines.forEach(System.out::println); 525 * } 526 * } 527 * 528 * @apiNote 529 * This reader obeys <em>escaped</em> line breaks according 530 * <a href="https://tools.ietf.org/html/rfc4180">RFC-4180</a>. It is 531 * thread-safe and can be shared between different reading threads. 532 * 533 * @version 8.1 534 * @since 8.1 535 */ 536 public static final class LineReader { 537 538 private static final LineReader DEFAULT = new LineReader(Quote.DEFAULT); 539 540 private final Quote quote; 541 542 /** 543 * Create a new line-reader with the given {@code quote} character, 544 * which is used in the CSV file which is read. 545 * 546 * @param quote the quoting character 547 * @throws NullPointerException if the {@code quote} character is 548 * {@code null} 549 */ 550 public LineReader(final Quote quote) { 551 this.quote = requireNonNull(quote); 552 } 553 554 /** 555 * Create a new line reader with default quote character {@code '"'} 556 * ({@link Quote#DEFAULT}). 557 */ 558 public LineReader() { 559 this(Quote.DEFAULT); 560 } 561 562 /** 563 * Reads all CSV lines from the given {@code reader}. 564 * 565 * @apiNote 566 * This method must be used within a try-with-resources statement or 567 * similar control structure to ensure that the stream's open file is 568 * closed promptly after the stream's operations have completed. 569 * 570 * @param readable the readable from which to read the CSV content 571 * @return the CSV lines from the file as a {@code Stream} 572 */ 573 public Stream<String> read(final Readable readable) { 574 requireNonNull(readable); 575 576 final IOValue<Stream<String>> result = new IOValue<>(resources -> { 577 final Readable rdr = resources.use( 578 readable, 579 resource -> { 580 if (resource instanceof AutoCloseable closeable) { 581 try { 582 closeable.close(); 583 } catch (IOException | RuntimeException | Error e) { 584 throw e; 585 } catch (Exception e) { 586 throw new IOException(e); 587 } 588 } 589 } 590 ); 591 592 final var source = new CharCursor(rdr); 593 final var line = new CharAppender(); 594 595 final Supplier<String> nextLine = () -> { 596 line.reset(); 597 try { 598 return nextLine(source, line) ? line.toString() : null; 599 } catch (IOException e) { 600 throw new UncheckedIOException(e); 601 } 602 }; 603 604 return Stream.generate(nextLine) 605 .takeWhile(Objects::nonNull); 606 }); 607 608 return result.get().onClose(() -> 609 result.release(UncheckedIOException::new) 610 ); 611 } 612 613 private boolean nextLine(final CharCursor chars, final CharAppender line) 614 throws IOException 615 { 616 boolean quoted = false; 617 boolean escaped = false; 618 boolean eol = false; 619 620 int next = -2; 621 int i = 0; 622 623 while (next >= 0 || (i = chars.next()) != -1) { 624 final char current = next != -2 ? (char)next : (char)i; 625 next = -2; 626 627 if (current == '\r' || current == '\n') { 628 if (quoted) { 629 line.append(current); 630 } else { 631 eol = true; 632 } 633 } else if (current == quote.value) { 634 if (quoted) { 635 if (!escaped && (next = chars.next()) == quote.value) { 636 escaped = true; 637 } else { 638 if (escaped) { 639 escaped = false; 640 } else { 641 quoted = false; 642 } 643 } 644 } else { 645 quoted = true; 646 } 647 line.append(current); 648 } else { 649 line.append(current); 650 } 651 652 if (eol) { 653 eol = false; 654 if (line.nonEmpty()) { 655 return true; 656 } 657 } 658 } 659 660 if (quoted) { 661 throw new IllegalArgumentException( 662 "Unbalanced quote character: '%s'." 663 .formatted(toString(line)) 664 ); 665 } 666 return line.nonEmpty(); 667 } 668 669 private static String toString(final Object value) { 670 final var line = value.toString(); 671 return line.length() > 15 ? line.substring(0, 15) + "..." : line; 672 } 673 } 674 675 /** 676 * Splitting a CSV line into columns (records). 677 * <h2>Examples</h2> 678 * <b>Simple usage</b> 679 * {@snippet class="Snippets" region="LineSplitterSnippets.simpleSplit"} 680 * 681 * <b>Projecting and re-ordering columns</b> 682 * {@snippet class="Snippets" region="LineSplitterSnippets.projectingSplit"} 683 * 684 * @implNote 685 * The split {@code String[]} array will contain {@code null} value instead 686 * of empty strings. 687 * 688 * @apiNote 689 * A line splitter ist <b>not</b> thread-safe and can't be shared between 690 * different threads. 691 * 692 * @version 8.1 693 * @since 8.1 694 */ 695 public static final class LineSplitter { 696 private final Separator separator; 697 private final Quote quote; 698 699 private final ColumnList columns; 700 private final CharAppender column = new CharAppender(); 701 702 /** 703 * Create a new line splitter with the given parameters. 704 * 705 * @param separator the separator character used by the CSV line to split 706 * @param quote the quote character used by the CSV line to split 707 * @param projection the column indexes which should be part of the split 708 * result 709 * @throws NullPointerException if one of the parameters is {@code null} 710 */ 711 public LineSplitter( 712 final Separator separator, 713 final Quote quote, 714 final ColumnIndexes projection 715 ) { 716 if (separator.value == quote.value) { 717 throw new IllegalArgumentException( 718 "Separator and quote char must be different: %s == %s." 719 .formatted(separator.value, quote.value) 720 ); 721 } 722 723 this.separator = separator; 724 this.quote = quote; 725 this.columns = new ColumnList(projection); 726 } 727 728 /** 729 * Create a new line splitter with the given parameters. 730 * 731 * @param separator the separator character used by the CSV line to split 732 * @param quote the quote character used by the CSV line to split 733 * @throws NullPointerException if one of the parameters is {@code null} 734 */ 735 public LineSplitter(final Separator separator, final Quote quote) { 736 this(separator, quote, ColumnIndexes.ALL); 737 } 738 739 /** 740 * Create a new line splitter with the given parameters. The default 741 * quote character, {@link Quote#DEFAULT}, will be used by the created 742 * splitter. 743 * 744 * @param separator the separator character used by the CSV line to split 745 * @throws NullPointerException if one of the parameters is {@code null} 746 */ 747 public LineSplitter(final Separator separator) { 748 this(separator, Quote.DEFAULT, ColumnIndexes.ALL); 749 } 750 751 /** 752 * Create a new line splitter with the given parameters. The default 753 * separator character, {@link Separator#DEFAULT}, will be used by the 754 * created splitter. 755 * 756 * @param quote the quote character used by the CSV line to split 757 * @throws NullPointerException if one of the parameters is {@code null} 758 */ 759 public LineSplitter(final Quote quote) { 760 this(Separator.DEFAULT, quote, ColumnIndexes.ALL); 761 } 762 763 /** 764 * Create a new line splitter with the given parameters. Only the defined 765 * columns will be part of the split result and the default separator 766 * character, {@link Separator#DEFAULT}, and default quote character, 767 * {@link Quote#DEFAULT}, is used by the created splitter. 768 * 769 * @param projection the column indexes which should be part of the split 770 * result 771 * @throws NullPointerException if one of the parameters is {@code null} 772 */ 773 public LineSplitter(final ColumnIndexes projection) { 774 this(Separator.DEFAULT, Quote.DEFAULT, projection); 775 } 776 777 /** 778 * Create a new line splitter with default values. 779 */ 780 public LineSplitter() { 781 this(Separator.DEFAULT, Quote.DEFAULT, ColumnIndexes.ALL); 782 } 783 784 /** 785 * Splitting the given CSV {@code line} into its columns. 786 * 787 * @implNote 788 * The split {@code String[]} array will never contain {@code null} values. 789 * Empty columns will be returned as empty strings. 790 * 791 * @param line the CSV line to split 792 * @return the split CSV columns 793 * @throws NullPointerException if the CSV {@code line} is {@code null} 794 */ 795 public String[] split(final CharSequence line) { 796 final char[] chars = line.toString().toCharArray(); 797 final int length = chars.length; 798 799 columns.clear(); 800 column.reset(); 801 802 boolean quoted = false; 803 boolean escaped = false; 804 boolean full = false; 805 806 int quoteIndex = 0; 807 808 for (int i = 0; i < length && !full; ++i) { 809 final int previous = i > 0 ? chars[i - 1] : -1; 810 final char current = chars[i]; 811 final int next = i + 1 < length ? chars[i + 1] : -1; 812 813 if (current == quote.value) { 814 if (quoted) { 815 if (!escaped && quote.value == next) { 816 escaped = true; 817 } else { 818 if (escaped) { 819 column.append(quote.value); 820 escaped = false; 821 } else { 822 if (next != -1 && separator.value != next) { 823 throw new IllegalArgumentException( 824 """ 825 Only separator character, '%s', allowed \ 826 after quote, but found '%c': 827 %s 828 """.formatted( 829 separator.value, 830 next, 831 toErrorDesc(line, i + 1) 832 ) 833 ); 834 } 835 836 add(column); 837 full = columns.isFull(); 838 quoted = false; 839 } 840 } 841 } else { 842 if (previous != -1 && separator.value != previous) { 843 throw new IllegalArgumentException( 844 """ 845 Only separator character, '%s', allowed before \ 846 quote, but found '%c': 847 %s 848 """.formatted( 849 separator.value, 850 previous, 851 toErrorDesc(line, Math.max(i - 1, 0)) 852 ) 853 ); 854 } 855 856 quoted = true; 857 quoteIndex = i; 858 } 859 } else if (current == separator.value) { 860 if (quoted) { 861 column.append(current); 862 } else if (separator.value == previous || previous == -1) { 863 add(column); 864 full = columns.isFull(); 865 } 866 } else { 867 // Read till the next token separator. 868 int j = i; 869 char c; 870 while (j < length && 871 !((c = chars[j]) == separator.value || c == quote.value)) 872 { 873 column.append(c); 874 ++j; 875 } 876 if (j != i - 1) { 877 i = j - 1; 878 } 879 880 if (!quoted) { 881 add(column); 882 full = columns.isFull(); 883 } 884 } 885 } 886 887 if (quoted) { 888 throw new IllegalArgumentException( 889 """ 890 Unbalanced quote character. 891 %s 892 """.formatted(toErrorDesc(line, quoteIndex)) 893 ); 894 } 895 if (line.isEmpty() || 896 separator.value == chars[length - 1]) 897 { 898 add(column); 899 } 900 901 return columns.toArray(); 902 } 903 904 private void add(final CharAppender column) { 905 columns.add(column.toString()); 906 column.reset(); 907 } 908 909 private static String toErrorDesc(final CharSequence line, final int pos) { 910 return """ 911 %s 912 %s 913 """.formatted( 914 line.toString().stripTrailing(), 915 " ".repeat(pos) + "^" 916 ); 917 } 918 } 919 920 921 /** 922 * Column collection, which is backed up by a string list. 923 */ 924 static final class ColumnList { 925 private final StringList columns = new StringList(); 926 private final ColumnIndexes projection; 927 928 private int index = 0; 929 private int count = 0; 930 931 ColumnList(final ColumnIndexes projection) { 932 this.projection = requireNonNull(projection); 933 } 934 935 /** 936 * Appends a {@code column} to the column collection. 937 * 938 * @param column the column to add 939 */ 940 void add(String column) { 941 if (!isFull()) { 942 count += set(column, index); 943 ++index; 944 } 945 } 946 947 private int set(String element, int column) { 948 int updated = 0; 949 950 if (projection.values.length == 0) { 951 columns.add(element); 952 ++updated; 953 } else { 954 int pos = -1; 955 while ((pos = indexOf(projection.values, pos + 1, column)) != -1) { 956 for (int i = columns.size(); i <= pos; ++i) { 957 columns.add(null); 958 } 959 columns.set(pos, element); 960 ++updated; 961 } 962 } 963 964 return updated; 965 } 966 967 private static int indexOf(int[] array, int start, int value) { 968 for (int i = start; i < array.length; ++i) { 969 if (array[i] == value) { 970 return i; 971 } 972 } 973 974 return -1; 975 } 976 977 /** 978 * Checks whether another column can be added. 979 * 980 * @return {@code true} if another column can be added to this 981 * collection, {@code false} otherwise 982 */ 983 boolean isFull() { 984 return 985 projection.values.length > 0 && 986 projection.values.length <= count; 987 } 988 989 /** 990 * Removes all columns. 991 */ 992 public void clear() { 993 columns.clear(); 994 index = 0; 995 count = 0; 996 } 997 998 String[] toArray() { 999 for (int i = columns.size(); i < projection.values.length; ++i) { 1000 columns.add(null); 1001 } 1002 return columns.toArray(); 1003 } 1004 1005 } 1006 1007 /** 1008 * This class joins an array of columns into one CSV line. 1009 * 1010 * <h2>Examples</h2> 1011 * <b>Simple usage</b> 1012 * {@snippet class="Snippets" region="ColumnJoinerSnippets.simpleJoin"} 1013 * 1014 * <b>Embedding and re-ordering data</b> 1015 * {@snippet class="Snippets" region="ColumnJoinerSnippets.embedToCsv"} 1016 * 1017 * @apiNote 1018 * The column joiner is <em>thread-safe</em> and can be shared between 1019 * different threads. 1020 * 1021 * @version 8.1 1022 * @since 8.1 1023 */ 1024 public static final class ColumnJoiner { 1025 1026 /** 1027 * Default column joiner, which is using default separator character, 1028 * {@link Separator#DEFAULT}, and default quote character, 1029 * {@link Quote#DEFAULT}. 1030 */ 1031 public static final ColumnJoiner DEFAULT = new ColumnJoiner( 1032 Separator.DEFAULT, 1033 Quote.DEFAULT, 1034 ColumnIndexes.ALL 1035 ); 1036 1037 /** 1038 * The CSV line splitter parameter. 1039 * 1040 * @param separator the column separator char 1041 * @param quote the qute char 1042 * @param embedding the column indices to read. If empty, all split 1043 * columns are used. 1044 */ 1045 private record Param(char separator, char quote, int... embedding) { 1046 1047 private String escape(Object value) { 1048 if (value == null) { 1049 return ""; 1050 } else { 1051 final var quoteStr = String.valueOf(quote); 1052 var stringValue = value.toString(); 1053 var string = stringValue.replace(quoteStr, quoteStr + quoteStr); 1054 1055 if (stringValue.length() != string.length() || mustEscape(string)) { 1056 return quoteStr + string + quoteStr; 1057 } else { 1058 return stringValue; 1059 } 1060 } 1061 } 1062 1063 private boolean mustEscape(CharSequence value) { 1064 for (int i = 0; i < value.length(); ++i) { 1065 final char c = value.charAt(i); 1066 if (c == separator || isLineBreak(c)) { 1067 return true; 1068 } 1069 } 1070 return false; 1071 } 1072 } 1073 1074 private final Param param; 1075 private final int columnCount; 1076 1077 /** 1078 * Create a new column joiner with the given parameters. 1079 * 1080 * @param separator the CSV separator character used by the joiner 1081 * @param quote the CSV quote character used by the joiner 1082 * @param embedding the column indexes to join 1083 * @throws NullPointerException if one of the parameters is {@code null} 1084 */ 1085 public ColumnJoiner( 1086 final Separator separator, 1087 final Quote quote, 1088 final ColumnIndexes embedding 1089 ) { 1090 if (separator.value == quote.value) { 1091 throw new IllegalArgumentException( 1092 "Separator and quote char must be different: %s == %s." 1093 .formatted(separator.value, quote.value) 1094 ); 1095 } 1096 1097 param = new Param(separator.value, quote.value, embedding.values); 1098 columnCount = Math.max(max(param.embedding) + 1, 0); 1099 } 1100 1101 /** 1102 * Create a new column joiner with the given parameters. 1103 * 1104 * @param separator the CSV separator character used by the joiner 1105 * @param quote the CSV quote character used by the joiner 1106 * @throws NullPointerException if one of the parameters is {@code null} 1107 */ 1108 public ColumnJoiner(final Separator separator, final Quote quote) { 1109 this(separator, quote, ColumnIndexes.ALL); 1110 } 1111 1112 /** 1113 * Create a new column joiner with the given parameters. 1114 * 1115 * @param separator the CSV separator character used by the joiner 1116 * @throws NullPointerException if one of the parameters is {@code null} 1117 */ 1118 public ColumnJoiner(final Separator separator) { 1119 this(separator, Quote.DEFAULT, ColumnIndexes.ALL); 1120 } 1121 1122 /** 1123 * Create a new column joiner with the given parameters. 1124 * 1125 * @param separator the CSV separator character used by the joiner 1126 * @param embedding the column indexes to join 1127 * @throws NullPointerException if one of the parameters is {@code null} 1128 */ 1129 public ColumnJoiner(final Separator separator, final ColumnIndexes embedding) { 1130 this(separator, Quote.DEFAULT, embedding); 1131 } 1132 1133 1134 /** 1135 * Create a new column joiner with the given parameters. 1136 * 1137 * @param quote the CSV quote character used by the joiner 1138 * @throws NullPointerException if one of the parameters is {@code null} 1139 */ 1140 public ColumnJoiner(final Quote quote) { 1141 this(Separator.DEFAULT, quote, ColumnIndexes.ALL); 1142 } 1143 1144 /** 1145 * Create a new column joiner with the given <em>embedding</em> column 1146 * indexes. 1147 * 1148 * @param embedding the embedding column indexes 1149 */ 1150 public ColumnJoiner(final ColumnIndexes embedding) { 1151 this(Separator.DEFAULT, Quote.DEFAULT, embedding); 1152 } 1153 1154 /** 1155 * Create a new column joiner with the given parameters. 1156 * 1157 * @param quote the CSV quote character used by the joiner 1158 * @param embedding the column indexes to join 1159 * @throws NullPointerException if one of the parameters is {@code null} 1160 */ 1161 public ColumnJoiner(final Quote quote, final ColumnIndexes embedding) { 1162 this(Separator.DEFAULT, quote, embedding); 1163 } 1164 1165 private static int max(int[] array) { 1166 int max = Integer.MIN_VALUE; 1167 for (int value : array) { 1168 if (value > max) { 1169 max = value; 1170 } 1171 } 1172 return max; 1173 } 1174 1175 /** 1176 * Joins the given CSV {@code columns}, using the given separator and 1177 * quote character. 1178 * 1179 * @param columns the CSV columns to join 1180 * @return the joined CSV columns 1181 */ 1182 public String join(final Iterable<?> columns) { 1183 if (param.embedding.length == 0) { 1184 return join0(columns); 1185 } else { 1186 final var values = new Object[columnCount]; 1187 final var it = columns.iterator(); 1188 int i = 0; 1189 while (it.hasNext() && i < param.embedding.length) { 1190 final var col = it.next(); 1191 final var index = param.embedding[i++]; 1192 if (index >= 0) { 1193 values[index] = col; 1194 } 1195 } 1196 1197 return join0(Arrays.asList(values)); 1198 } 1199 } 1200 1201 private String join0(final Iterable<?> cols) { 1202 final var row = new StringBuilder(); 1203 final var it = cols.iterator(); 1204 while (it.hasNext()) { 1205 final var column = it.next(); 1206 row.append(param.escape(column)); 1207 if (it.hasNext()) { 1208 row.append(param.separator); 1209 } 1210 } 1211 1212 return row.toString(); 1213 } 1214 1215 /** 1216 * Joins the given CSV {@code columns}, using the given separator and 1217 * quote character. 1218 * 1219 * @param columns the CSV columns to join 1220 * @return the joined CSV columns 1221 */ 1222 public String join(final Object[] columns) { 1223 return join(Arrays.asList(columns)); 1224 } 1225 } 1226 1227 /** 1228 * Cursor <em>view</em> on a readable object. 1229 * 1230 * @since 8.2 1231 * @version 8.2 1232 */ 1233 static final class CharCursor { 1234 private static final int SIZE = 1024; 1235 private final Readable readable; 1236 1237 private final CharBuffer buffer; 1238 private final char[] array; 1239 1240 private int length; 1241 private int index; 1242 1243 CharCursor(final Readable readable) { 1244 this.readable = requireNonNull(readable); 1245 1246 if (readable instanceof Reader) { 1247 this.buffer = null; 1248 this.array = new char[SIZE]; 1249 } else { 1250 this.buffer = CharBuffer.allocate(SIZE).flip(); 1251 this.array = buffer.array(); 1252 } 1253 } 1254 1255 public int next() throws IOException { 1256 if (index == length && !fill()) { 1257 return -1; 1258 } 1259 1260 final int result = array[index]; 1261 ++index; 1262 return result; 1263 } 1264 1265 private boolean fill() throws IOException { 1266 int i = 0; 1267 1268 if (readable instanceof Reader reader) { 1269 do { 1270 length = reader.read(array); 1271 } while (length == 0 && i++ < 1000); // Make sure re-read will terminate. 1272 } else { 1273 buffer.clear(); 1274 do { 1275 length = readable.read(buffer); 1276 } while (length == 0 && i++ < 1000); // Make sure re-read will terminate. 1277 buffer.flip(); 1278 } 1279 1280 index = 0; 1281 length = Math.max(length, 0); 1282 return length > 0; 1283 } 1284 } 1285 1286 /** 1287 * Allows appending chars in bulks to {@link StringBuilder}. 1288 * 1289 * @since 8.2 1290 * @version 8.2 1291 */ 1292 static final class CharAppender { 1293 private static final int SIZE = 32; 1294 1295 private char[] buffer = new char[SIZE]; 1296 private int index = 0; 1297 1298 CharAppender() { 1299 } 1300 1301 boolean nonEmpty() { 1302 return index != 0; 1303 } 1304 1305 void append(final char c) { 1306 if (index == buffer.length) { 1307 increaseSize(buffer.length*2); 1308 } 1309 1310 buffer[index] = c; 1311 ++index; 1312 } 1313 1314 @Override 1315 public String toString() { 1316 return String.valueOf(buffer, 0, index); 1317 } 1318 1319 void reset() { 1320 index = 0; 1321 } 1322 1323 private void increaseSize(final int newSize) { 1324 final char[] newBuffer = new char[newSize]; 1325 System.arraycopy(buffer, 0, newBuffer, 0, index); 1326 buffer = newBuffer; 1327 } 1328 } 1329 1330 /** 1331 * Simple growing list of strings. 1332 * 1333 * @since 8.2 1334 * @version 8.2 1335 */ 1336 static final class StringList { 1337 private static final int SIZE = 16; 1338 1339 private String[] elements; 1340 private int length; 1341 1342 StringList() { 1343 length = 0; 1344 elements = new String[SIZE]; 1345 } 1346 1347 public int size() { 1348 return length; 1349 } 1350 1351 public void add(final String value) { 1352 if (length == elements.length) { 1353 increaseSize(elements.length*2); 1354 } 1355 elements[length] = value; 1356 ++length; 1357 } 1358 1359 public void set(final int index, final String value) { 1360 elements[index] = value; 1361 } 1362 1363 public void clear() { 1364 length = 0; 1365 } 1366 1367 public String[] toArray() { 1368 final var result = new String[length]; 1369 System.arraycopy(elements, 0, result, 0, length); 1370 return result; 1371 } 1372 1373 private void increaseSize(final int newSize) { 1374 final String[] newElements = new String[newSize]; 1375 System.arraycopy(elements, 0, newElements, 0, length); 1376 elements = newElements; 1377 } 1378 1379 } 1380 1381} 1382