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}