I've written this method to replace an older method that was much simpler, but used the regex split method and couldn't tell if a comma was in quotes/brackets/etc. and didn't read double quotes as single. I was hoping to cover most of the most used possible CSV formats except where a tab is used as a separator rather than a comma.
I haven't tested this yet, and I'm going to write a unit test later on, but I was hoping to get some feedback on it anyway. What I might be missing, what I might be able to do better or more concisely, etc.
It does seem a little long for my liking.
I used this as a guide while writing it.
private enum PositionState
{ inQuotes, inInvertedCommas, inBrackets, inSquareBrackets, inCurlyBrackets, inChevronBrackets }
private List<String> splitCSVLine(String toSplit)
{
List<String> entries = new ArrayList<String>();
boolean nextCharIsEscaped = false;
boolean lastCharIsEscapingQuote = false;
StringBuilder entryBuilder = new StringBuilder();
List<PositionState> positionStates = new ArrayList<PositionState>();
for(int i = 0; i < toSplit.length(); i++)
{
char iChar = toSplit.charAt(i);
boolean printCharacter = true; // Whether or not the character should be printed into the current entry.
switch(iChar)
{
case '\\':
{
if(!nextCharIsEscaped)
printCharacter = false;
nextCharIsEscaped = !nextCharIsEscaped;
} break;
case ',':
{
if(nextCharIsEscaped)
nextCharIsEscaped = false;
else if(positionStates.isEmpty())
{
entries.add(entryBuilder.toString());
entryBuilder = new StringBuilder();
printCharacter = false;
}
} break;
case '\'':
{
if(nextCharIsEscaped)
nextCharIsEscaped = false;
else
{
boolean isOpening = true;
for(int j = positionStates.size() - 1; j >= 0; j--)
if(positionStates.get(j) == PositionState.inInvertedCommas)
{
isOpening = false;
for(int k = positionStates.size() - 1; k >= j; k--)
positionStates.remove(k);
break;
}
if(isOpening)
positionStates.add(PositionState.inInvertedCommas);
}
} break;
case '"':
{
if(nextCharIsEscaped)
nextCharIsEscaped = false;
else if(lastCharIsEscapingQuote)
lastCharIsEscapingQuote = false;
else if(i < toSplit.length() - 1 && toSplit.charAt(i + 1) == '"')
lastCharIsEscapingQuote = true;
else
{
boolean isOpening = true;
for(int j = positionStates.size() - 1; j >= 0; j--)
if(positionStates.get(j) == PositionState.inQuotes)
{
isOpening = false;
for(int k = positionStates.size() - 1; k >= j; k--)
positionStates.remove(k);
break;
}
if(isOpening)
positionStates.add(PositionState.inQuotes);
}
} break;
case '(':
{
if(nextCharIsEscaped)
nextCharIsEscaped = false;
else
positionStates.add(PositionState.inBrackets);
} break;
case ')':
{
for(int j = positionStates.size() - 1; j >= 0; j--)
if(positionStates.get(j) == PositionState.inBrackets)
{
for(int k = positionStates.size() - 1; k >= j; k--)
positionStates.remove(k);
break;
}
} break;
case '[':
{
if(nextCharIsEscaped)
nextCharIsEscaped = false;
else
positionStates.add(PositionState.inSquareBrackets);
} break;
case ']':
{
for(int j = positionStates.size() - 1; j >= 0; j--)
if(positionStates.get(j) == PositionState.inSquareBrackets)
{
for(int k = positionStates.size() - 1; k >= j; k--)
positionStates.remove(k);
break;
}
} break;
case '{':
{
if(nextCharIsEscaped)
nextCharIsEscaped = false;
else
positionStates.add(PositionState.inCurlyBrackets);
} break;
case '}':
{
for(int j = positionStates.size() - 1; j >= 0; j--)
if(positionStates.get(j) == PositionState.inCurlyBrackets)
{
for(int k = positionStates.size() - 1; k >= j; k--)
positionStates.remove(k);
break;
}
} break;
}
if(printCharacter)
entryBuilder.append(iChar);
}
entries.add(entryBuilder.toString());
handleQuotes(entries);
return entries;
}
/**
* Removes the surrounding quotes from entries that are enclosed in them, and converts adjacent pairs of quotation
* marks into single ones, where they don't represent an empty field and the first quotation mark isn't escaped.
* @param entries The list of entries to handle quotes in.
*/
private void handleQuotes(List<String> entries)
{
for(int i = 0; i < entries.size(); i++)
{
String iEntry = entries.get(i);
String iEntryOriginal = iEntry;
iEntry = iEntry.trim();
if(iEntry.startsWith("\"") && iEntry.endsWith("\""))
iEntry = iEntry.substring(1, iEntry.length() - 1);
/*
Go through iEntry looking for double quotes (""). Double-quotes representing empty fields have already
been taken out with the previous check. Replace them with a single quote (") where the first quote isn't
escaped. That is, where they aren't preceded by an escape character (\) that isn't itself escaped.
This can be approximated by checking whether they're preceded by an odd or even number of the escape
character.
*/
for(int j = 0; j < iEntry.length() - 1; j++)
{
if(iEntry.charAt(j) == '"' && iEntry.charAt(j + 1) == '"')
{
int escapeCharacterCount = 0;
for(int k = j - 1; k >= 0; k--)
{
if(iEntry.charAt(k) == '\\')
escapeCharacterCount++;
else
break;
}
if(escapeCharacterCount % 2 == 0) // if escapeCharacterCount is even or 0
iEntry = new StringBuilder(iEntry).deleteCharAt(j + 1).toString();
}
}
if(!iEntry.equals(iEntryOriginal))
entries.set(i, iEntry);
}
}