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