Java: splitting a comma-separated string but ignoring commas in quotes

Viewed 242835

I have a string vaguely like this:

foo,bar,c;qual="baz,blurb",d;junk="quux,syzygy"

that I want to split by commas -- but I need to ignore commas in quotes. How can I do this? Seems like a regexp approach fails; I suppose I can manually scan and enter a different mode when I see a quote, but it would be nice to use preexisting libraries. (edit: I guess I meant libraries that are already part of the JDK or already part of a commonly-used libraries like Apache Commons.)

the above string should split into:

foo
bar
c;qual="baz,blurb"
d;junk="quux,syzygy"

note: this is NOT a CSV file, it's a single string contained in a file with a larger overall structure

12 Answers

While I do like regular expressions in general, for this kind of state-dependent tokenization I believe a simple parser (which in this case is much simpler than that word might make it sound) is probably a cleaner solution, in particular with regards to maintainability, e.g.:

String input = "foo,bar,c;qual=\"baz,blurb\",d;junk=\"quux,syzygy\"";
List<String> result = new ArrayList<String>();
int start = 0;
boolean inQuotes = false;
for (int current = 0; current < input.length(); current++) {
    if (input.charAt(current) == '\"') inQuotes = !inQuotes; // toggle state
    else if (input.charAt(current) == ',' && !inQuotes) {
        result.add(input.substring(start, current));
        start = current + 1;
    }
}
result.add(input.substring(start));

If you don't care about preserving the commas inside the quotes you could simplify this approach (no handling of start index, no last character special case) by replacing your commas in quotes by something else and then split at commas:

String input = "foo,bar,c;qual=\"baz,blurb\",d;junk=\"quux,syzygy\"";
StringBuilder builder = new StringBuilder(input);
boolean inQuotes = false;
for (int currentIndex = 0; currentIndex < builder.length(); currentIndex++) {
    char currentChar = builder.charAt(currentIndex);
    if (currentChar == '\"') inQuotes = !inQuotes; // toggle state
    if (currentChar == ',' && inQuotes) {
        builder.setCharAt(currentIndex, ';'); // or '♡', and replace later
    }
}
List<String> result = Arrays.asList(builder.toString().split(","));

The simplest approach is not to match delimiters, i.e. commas, with a complex additional logic to match what is actually intended (the data which might be quoted strings), just to exclude false delimiters, but rather match the intended data in the first place.

The pattern consists of two alternatives, a quoted string ("[^"]*" or ".*?") or everything up to the next comma ([^,]+). To support empty cells, we have to allow the unquoted item to be empty and to consume the next comma, if any, and use the \\G anchor:

Pattern p = Pattern.compile("\\G\"(.*?)\",?|([^,]*),?");

The pattern also contains two capturing groups to get either, the quoted string’s content or the plain content.

Then, with Java 9, we can get an array as

String[] a = p.matcher(input).results()
    .map(m -> m.group(m.start(1)<0? 2: 1))
    .toArray(String[]::new);

whereas older Java versions need a loop like

for(Matcher m = p.matcher(input); m.find(); ) {
    String token = m.group(m.start(1)<0? 2: 1);
    System.out.println("found: "+token);
}

Adding the items to a List or an array is left as an excise to the reader.

For Java 8, you can use the results() implementation of this answer, to do it like the Java 9 solution.

For mixed content with embedded strings, like in the question, you can simply use

Pattern p = Pattern.compile("\\G((\"(.*?)\"|[^,])*),?");

But then, the strings are kept in their quoted form.

A regular expression is not capable of handling escaped characters. For my application, I needed the ability to escape quotes and spaces (my separator is spaces, but the code is the same).

Here is my solution in Kotlin (the language from this particular application), based on the one from Fabian Steeg:

fun parseString(input: String): List<String> {
    val result = mutableListOf<String>()
    var inQuotes = false
    var inEscape = false
    val current = StringBuilder()
    for (i in input.indices) {
        // If this character is escaped, add it without looking
        if (inEscape) {
            inEscape = false
            current.append(input[i])
            continue
        }
        when (val c = input[i]) {
            '\\' -> inEscape = true // escape the next character, \ isn't added to result
            ',' -> if (inQuotes) {
                current.append(c)
            } else {
                result += current.toString()
                current.clear()
            }
            '"' -> inQuotes = !inQuotes
            else -> current.append(c)
        }
    }
    if (current.isNotEmpty()) {
        result += current.toString()
    }
    return result
}

I think this is not a place to use regular expressions. Contrary to other opinions, I don't think a parser is overkill. It's about 20 lines and fairly easy to test.

what about a one-liner using String.split()?

String s = "foo,bar,c;qual=\"baz,blurb\",d;junk=\"quux,syzygy\"";
String[] split = s.split( "(?<!\".{0,255}[^\"]),|,(?![^\"].*\")" );
Related