Java: dividir una cadena separada por comas pero ignorar comas entre comillas

249

Tengo una cadena vagamente así:

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

que quiero dividir por comas, pero necesito ignorar comas entre comillas. ¿Cómo puedo hacer esto? Parece que un enfoque regexp falla; Supongo que puedo escanear manualmente e ingresar a un modo diferente cuando veo una cita, pero sería bueno usar bibliotecas preexistentes. ( Editar : supongo que me refería a bibliotecas que ya forman parte del JDK o que ya forman parte de bibliotecas de uso común como Apache Commons).

la cadena anterior debe dividirse en:

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

nota: este NO es un archivo CSV, es una sola cadena contenida en un archivo con una estructura general más grande

Jason S
fuente

Respuestas:

435

Tratar:

public class Main { 
    public static void main(String[] args) {
        String line = "foo,bar,c;qual=\"baz,blurb\",d;junk=\"quux,syzygy\"";
        String[] tokens = line.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)", -1);
        for(String t : tokens) {
            System.out.println("> "+t);
        }
    }
}

Salida:

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

En otras palabras: dividir en la coma solo si esa coma tiene cero, o un número par de comillas delante de ella .

O, un poco más amigable para los ojos:

public class Main { 
    public static void main(String[] args) {
        String line = "foo,bar,c;qual=\"baz,blurb\",d;junk=\"quux,syzygy\"";

        String otherThanQuote = " [^\"] ";
        String quotedString = String.format(" \" %s* \" ", otherThanQuote);
        String regex = String.format("(?x) "+ // enable comments, ignore white spaces
                ",                         "+ // match a comma
                "(?=                       "+ // start positive look ahead
                "  (?:                     "+ //   start non-capturing group 1
                "    %s*                   "+ //     match 'otherThanQuote' zero or more times
                "    %s                    "+ //     match 'quotedString'
                "  )*                      "+ //   end group 1 and repeat it zero or more times
                "  %s*                     "+ //   match 'otherThanQuote'
                "  $                       "+ // match the end of the string
                ")                         ", // stop positive look ahead
                otherThanQuote, quotedString, otherThanQuote);

        String[] tokens = line.split(regex, -1);
        for(String t : tokens) {
            System.out.println("> "+t);
        }
    }
}

que produce lo mismo que el primer ejemplo.

EDITAR

Como mencionó @MikeFHay en los comentarios:

Prefiero usar Guava's Splitter , ya que tiene valores predeterminados más sanos (vea la discusión anterior sobre los partidos vacíos que se recortan String#split(), así que lo hice:

Splitter.on(Pattern.compile(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)"))
Bart Kiers
fuente
De acuerdo con RFC 4180: Sec 2.6: "Los campos que contienen saltos de línea (CRLF), comillas dobles y comas deben estar entre comillas dobles". Sec 2.7: "Si se usan comillas dobles para encerrar campos, entonces se debe escapar una comilla doble que aparece dentro de un campo precediéndola con otra comilla doble" Entonces, si String line = "equals: =,\"quote: \"\"\",\"comma: ,\""todo lo que necesita hacer es quitar la comilla doble extraña caracteres.
Paul Hanbury
@Bart: mi punto es que su solución aún funciona, incluso con citas incrustadas
Paul Hanbury
66
@Alex, sí, la coma se igualó, pero el partido no está vacía en el resultado. Añadir -1al método parámetro de división: line.split(regex, -1). Ver: docs.oracle.com/javase/6/docs/api/java/lang/…
Bart Kiers
2
¡Funciona genial! Prefiero usar el divisor de guayaba, ya que tiene valores predeterminados más sanos (vea la discusión anterior sobre las coincidencias vacías recortadas por String # split), así que lo hice Splitter.on(Pattern.compile(",(?=([^\"]*\"[^\"]*\")*[^\"]*$)")).
MikeFHay
2
¡¡¡¡ADVERTENCIA!!!! ¡Esta expresión regular es lenta! Tiene un comportamiento O (N ^ 2) en que la búsqueda anticipada en cada coma mira hasta el final de la cadena. El uso de esta expresión regular provocó una desaceleración 4x en trabajos grandes de Spark (por ejemplo, 45 minutos -> 3 horas). La alternativa más rápida es algo así como findAllIn("(?s)(?:\".*?\"|[^\",]*)*")en combinación con un paso de postprocesamiento para omitir el primer campo (siempre vacío) que sigue a cada campo no vacío.
Urban Vagabond
46

Si bien me gustan las expresiones regulares en general, para este tipo de tokenización dependiente del estado, creo que un analizador simple (que en este caso es mucho más simple de lo que esa palabra podría hacer sonar) es probablemente una solución más limpia, en particular con respecto a la mantenibilidad , p.ej:

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
    boolean atLastChar = (current == input.length() - 1);
    if(atLastChar) result.add(input.substring(start));
    else if (input.charAt(current) == ',' && !inQuotes) {
        result.add(input.substring(start, current));
        start = current + 1;
    }
}

Si no le importa preservar las comas dentro de las comillas, puede simplificar este enfoque (sin manejo del índice de inicio, sin el caso especial del último carácter ) al reemplazar sus comas entre comillas por otra cosa y luego dividirlas entre comas:

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(","));
Fabian Steeg
fuente
Las comillas deben eliminarse de los tokens analizados, después de analizar la cadena.
Sudhir N
Encontrado a través de Google, buen algoritmo hermano, simple y fácil de adaptar, de acuerdo. Las cosas con estado deben hacerse a través del analizador, regex es un desastre.
Rudolf Schmidt
2
Tenga en cuenta que si una coma es el último carácter, estará en el valor de la Cadena del último elemento.
Gabriel Gates
21

http://sourceforge.net/projects/javacsv/

https://github.com/pupi1985/JavaCSV-Reloaded (bifurcación de la biblioteca anterior que permitirá que la salida generada tenga terminadores de línea de Windows \r\ncuando no se ejecuta Windows)

http://opencsv.sourceforge.net/

API CSV para Java

¿Me puede recomendar una biblioteca Java para leer (y posiblemente escribir) archivos CSV?

¿Java lib o aplicación para convertir CSV a un archivo XML?

Jonathan Feinberg
fuente
3
Buena llamada reconociendo que el OP estaba analizando un archivo CSV. Una biblioteca externa es extremadamente apropiada para esta tarea.
Stefan Kendall
1
Pero la cadena es una cadena CSV; deberías poder usar una API CSV en esa cadena directamente.
Michael Brewer-Davis
Sí, pero esta tarea es lo suficientemente simple y una parte mucho más pequeña de una aplicación más grande, que no tengo ganas de tirar en otra biblioteca externa.
Jason S
77
no necesariamente ... mis habilidades son a menudo adecuadas, pero se benefician de ser perfeccionadas.
Jason S
9

No recomendaría una respuesta de regex de Bart, creo que la solución de análisis es mejor en este caso particular (como propuso Fabian). He intentado la solución regex y la implementación de análisis propio, he encontrado que:

  1. El análisis es mucho más rápido que dividir con expresiones regulares con referencias inversas: ~ 20 veces más rápido para cadenas cortas, ~ 40 veces más rápido para cadenas largas.
  2. Regex no puede encontrar una cadena vacía después de la última coma. Sin embargo, eso no estaba en la pregunta original, era un requisito mío.

Mi solución y prueba a continuación.

String tested = "foo,bar,c;qual=\"baz,blurb\",d;junk=\"quux,syzygy\",";
long start = System.nanoTime();
String[] tokens = tested.split(",(?=([^\"]*\"[^\"]*\")*[^\"]*$)");
long timeWithSplitting = System.nanoTime() - start;

start = System.nanoTime(); 
List<String> tokensList = new ArrayList<String>();
boolean inQuotes = false;
StringBuilder b = new StringBuilder();
for (char c : tested.toCharArray()) {
    switch (c) {
    case ',':
        if (inQuotes) {
            b.append(c);
        } else {
            tokensList.add(b.toString());
            b = new StringBuilder();
        }
        break;
    case '\"':
        inQuotes = !inQuotes;
    default:
        b.append(c);
    break;
    }
}
tokensList.add(b.toString());
long timeWithParsing = System.nanoTime() - start;

System.out.println(Arrays.toString(tokens));
System.out.println(tokensList.toString());
System.out.printf("Time with splitting:\t%10d\n",timeWithSplitting);
System.out.printf("Time with parsing:\t%10d\n",timeWithParsing);

Por supuesto, puede cambiar el cambio a else-ifs en este fragmento si se siente incómodo con su fealdad. Tenga en cuenta entonces la falta de descanso después del interruptor con separador. StringBuilder fue elegido en lugar de StringBuffer por diseño para aumentar la velocidad, donde la seguridad del hilo es irrelevante.

Marcin Kosinski
fuente
2
Punto interesante con respecto a la división del tiempo frente al análisis. Sin embargo, la declaración n. ° 2 es inexacta. Si agrega un -1método de división en la respuesta de Bart, capturará cadenas vacías (incluidas las cadenas vacías después de la última coma):line.split(regex, -1)
Peter
+1 porque es una mejor solución al problema para el que estaba buscando una solución: analizar una cadena de parámetro de cuerpo HTTP POST compleja
varontron
2

Prueba un lookaround como (?!\"),(?!\"). Esto debería coincidir con los ,que no están rodeados ".

Matthew Sowders
fuente
Bastante seguro de que se rompería para una lista como: "foo", bar, "baz"
Angelo Genovese
1
Creo que quisiste decir (?<!"),(?!"), pero todavía no funcionará. Dada la cadena one,two,"three,four", coincide correctamente con la coma one,two, pero también coincide con la coma "three,four"y no coincide con una two,"three.
Alan Moore
Parece que funciona perfectamente para mí, en mi humilde opinión, creo que esta es una mejor respuesta debido a que es más corta y más fácil de entender
Ordiel
2

Estás en esa zona límite molesta donde las expresiones regulares casi no funcionan (como ha sido señalado por Bart, escapar de las citas haría la vida difícil), y sin embargo, un analizador completo parece excesivo.

Si es probable que necesite una mayor complejidad en el corto plazo, iría a buscar una biblioteca de analizador. Por ejemplo este

djna
fuente
2

Estaba impaciente y decidí no esperar respuestas ... para referencia, no parece tan difícil hacer algo como esto (que funciona para mi aplicación, no necesito preocuparme por las comillas escapadas, como las cosas entre comillas se limita a algunas formas restringidas):

final static private Pattern splitSearchPattern = Pattern.compile("[\",]"); 
private List<String> splitByCommasNotInQuotes(String s) {
    if (s == null)
        return Collections.emptyList();

    List<String> list = new ArrayList<String>();
    Matcher m = splitSearchPattern.matcher(s);
    int pos = 0;
    boolean quoteMode = false;
    while (m.find())
    {
        String sep = m.group();
        if ("\"".equals(sep))
        {
            quoteMode = !quoteMode;
        }
        else if (!quoteMode && ",".equals(sep))
        {
            int toPos = m.start(); 
            list.add(s.substring(pos, toPos));
            pos = m.end();
        }
    }
    if (pos < s.length())
        list.add(s.substring(pos));
    return list;
}

(ejercicio para el lector: extienda el manejo de las comillas escapadas buscando también barras invertidas).

Jason S
fuente
1

El enfoque más simple es no hacer coincidir delimitadores, es decir, comas, con una lógica adicional compleja para que coincida con lo que realmente se pretende (los datos que podrían ser comillas), solo para excluir delimitadores falsos, sino que coinciden con los datos previstos en primer lugar.

El patrón consta de dos alternativas, una cadena entre comillas ( "[^"]*"o ".*?") o todo hasta la siguiente coma ( [^,]+). Para admitir celdas vacías, debemos permitir que el elemento no entrecomillado esté vacío y consumir la siguiente coma, si la hay, y usar el \\Gancla:

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

El patrón también contiene dos grupos de captura para obtener, el contenido de la cadena citada o el contenido sin formato.

Luego, con Java 9, podemos obtener una matriz como

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

mientras que las versiones anteriores de Java necesitan un bucle como

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

Agregar los elementos a una Listmatriz se deja como un impuesto especial al lector.

Para Java 8, puede usar la results()implementación de esta respuesta , para hacerlo como la solución Java 9.

Para contenido mixto con cadenas incrustadas, como en la pregunta, simplemente puede usar

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

Pero entonces, las cadenas se mantienen en su forma citada.

Holger
fuente
0

En lugar de usar lookahead y otro regex loco, solo saque las comillas primero. Es decir, para cada agrupación de cotizaciones, reemplace esa agrupación con __IDENTIFIER_1o algún otro indicador, y asigne esa agrupación a un mapa de cadena, cadena.

Después de dividir en coma, reemplace todos los identificadores asignados con los valores de cadena originales.

Stefan Kendall
fuente
y cómo encontrar agrupaciones de citas sin expresiones regulares locas?
Kai Huppmann
Para cada carácter, si el carácter es una cita, busque la siguiente cita y reemplácela por agrupación. Si no hay próxima cita, listo.
Stefan Kendall
0

¿Qué pasa con una línea usando String.split ()?

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

Haría algo como esto:

boolean foundQuote = false;

if(charAtIndex(currentStringIndex) == '"')
{
   foundQuote = true;
}

if(foundQuote == true)
{
   //do nothing
}

else 

{
  string[] split = currentString.split(',');  
}
Woot4Moo
fuente