001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  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 */
017package org.apache.juneau.marshall.csv;
018
019import java.io.*;
020import java.util.*;
021
022import org.apache.juneau.marshall.parser.*;
023
024/**
025 * Specialized reader for parsing CSV input.
026 *
027 * <p>
028 * Parses CSV data according to RFC 4180 with support for:
029 * <ul>
030 *   <li>Quoted fields (configurable quote character)
031 *   <li>Embedded delimiter characters within quoted fields
032 *   <li>Embedded newlines within quoted fields
033 *   <li>Embedded quote characters escaped by doubling (RFC 4180 style)
034 *   <li>Both CRLF and LF line endings
035 *   <li>Optional whitespace trimming
036 * </ul>
037 *
038 * <h5 class='section'>Notes:</h5><ul>
039 *    <li class='note'>This class is not intended for external use.
040 * </ul>
041 */
042@SuppressWarnings({
043   "resource" // CsvReader owns ParserReader; caller must close CsvReader
044})
045public class CsvReader implements Closeable {
046
047   private final ParserReader r;
048   private final char delimiter;
049   private final char quoteChar;
050   private final boolean trimStrings;
051   private boolean eof = false;
052
053   /**
054    * Constructor.
055    *
056    * @param r The parser reader to wrap.
057    *    <br>Must not be <jk>null</jk>.
058    * @param delimiter The field delimiter character (typically <js>','</js>).
059    * @param quoteChar The quote character (typically <js>'"'</js>).
060    * @param trimStrings If <jk>true</jk>, field values are trimmed of surrounding whitespace.
061    */
062   public CsvReader(ParserReader r, char delimiter, char quoteChar, boolean trimStrings) {
063      this.r = r;
064      this.delimiter = delimiter;
065      this.quoteChar = quoteChar;
066      this.trimStrings = trimStrings;
067   }
068
069   /**
070    * Creates a {@link CsvReader} from a {@link ParserPipe}.
071    *
072    * @param pipe The parser pipe.
073    *    <br>Must not be <jk>null</jk>.
074    * @param delimiter The delimiter character.
075    * @param quoteChar The quote character.
076    * @param trimStrings Whether to trim strings.
077    * @return A new {@link CsvReader}, or <jk>null</jk> if the pipe has no input.
078    * @throws IOException Thrown by underlying stream.
079    */
080   public static CsvReader from(ParserPipe pipe, char delimiter, char quoteChar, boolean trimStrings) throws IOException {
081      var pr = pipe.getParserReader();
082      if (pr == null)
083         return null;
084      return new CsvReader(pr, delimiter, quoteChar, trimStrings);
085   }
086
087   /**
088    * Reads a single row of CSV data, returning <jk>null</jk> at end of input.
089    *
090    * <p>
091    * Each call advances past one record (terminated by LF, CRLF, or end-of-file).
092    * Quoted fields may span multiple lines.
093    *
094    * @return A list of field values for the row, or <jk>null</jk> if end of input has been reached.
095    * @throws IOException Thrown by underlying stream.
096    * @throws ParseException If the CSV is malformed (e.g. an unclosed quoted field).
097    */
098   @SuppressWarnings({
099      "java:S1168", // null = end of input (distinct from empty row)
100      "java:S3776", // Cognitive complexity acceptable for CSV row parsing state machine
101      "java:S6541" // Brain method acceptable for CSV row parsing state machine
102   })
103   public List<String> readRow() throws IOException, ParseException {
104      if (eof)
105         return null;
106
107      // Skip blank lines between records
108      int c = r.read();
109      while (c == '\r' || c == '\n') {
110         if (c == '\r') {
111            int next = r.read();
112            if (next != '\n' && next != -1)
113               r.unread();
114         }
115         c = r.read();
116      }
117
118      if (c == -1) {
119         eof = true;
120         return null;
121      }
122
123      r.unread();
124
125      var fields = new ArrayList<String>();
126      var field = new StringBuilder();
127
128      while (true) {
129         c = r.read();
130
131         if (c == -1) {
132            eof = true;
133            fields.add(finishField(field));
134            return fields;
135         }
136
137         if (c == quoteChar) {
138            parseQuotedField(field);
139            // After a quoted field, read the next char: delimiter, newline, or EOF
140            int next = r.read();
141            if (next == -1) {
142               eof = true;
143               fields.add(finishField(field));
144               return fields;
145            } else if (next == delimiter) {
146               fields.add(finishField(field));
147               field = new StringBuilder();
148            } else if (next == '\r') {
149               int peek = r.read();
150               if (peek != '\n' && peek != -1)
151                  r.unread();
152               fields.add(finishField(field));
153               return fields;
154            } else if (next == '\n') {
155               fields.add(finishField(field));
156               return fields;
157            } else {
158               // Lenient: treat extra content after closing quote as unquoted
159               field.append((char) next);
160            }
161         } else if (c == delimiter) {
162            fields.add(finishField(field));
163            field = new StringBuilder();
164         } else if (c == '\r') {
165            int next = r.read();
166            if (next != '\n' && next != -1)
167               r.unread();
168            fields.add(finishField(field));
169            return fields;
170         } else if (c == '\n') {
171            fields.add(finishField(field));
172            return fields;
173         } else {
174            field.append((char) c);
175         }
176      }
177   }
178
179   /**
180    * Parses the contents of a quoted field.
181    *
182    * <p>
183    * The opening quote character has already been consumed.
184    * Handles RFC 4180 doubled-quote escaping: two consecutive quote characters inside a quoted field
185    * produce a single literal quote character.
186    *
187    * @param field The string builder to append field content to.
188    * @throws IOException Thrown by underlying stream.
189    * @throws ParseException If end of input is reached before the closing quote.
190    */
191   private void parseQuotedField(StringBuilder field) throws IOException, ParseException {
192      while (true) {
193         int c = r.read();
194         if (c == -1)
195            throw new ParseException("Unterminated quoted field in CSV input.");
196         if (c == quoteChar) {
197            int next = r.read();
198            if (next == quoteChar) {
199               // Doubled quote → literal quote
200               field.append(quoteChar);
201            } else {
202               // Closing quote; push back the following character
203               if (next != -1)
204                  r.unread();
205               return;
206            }
207         } else {
208            field.append((char) c);
209         }
210      }
211   }
212
213   private String finishField(StringBuilder sb) {
214      var s = sb.toString();
215      return trimStrings ? s.trim() : s;
216   }
217
218   /**
219    * Returns whether the end of input has been reached.
220    *
221    * @return <jk>true</jk> if end of input has been reached.
222    */
223   public boolean isEof() {
224      return eof;
225   }
226
227   @Override
228   public void close() throws IOException {
229      r.close();
230   }
231}