Java: dividindo uma sequência separada por vírgula, mas ignorando vírgulas entre aspas

249

Eu tenho uma string vagamente assim:

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

que eu quero dividir por vírgulas - mas preciso ignorar vírgulas entre aspas. Como posso fazer isso? Parece que uma abordagem regexp falha; Suponho que posso digitalizar manualmente e entrar em um modo diferente quando vejo uma cotação, mas seria bom usar bibliotecas preexistentes. ( editar : eu acho que quis dizer bibliotecas que já fazem parte do JDK ou que já fazem parte de bibliotecas comumente usadas como o Apache Commons.)

a string acima deve ser dividida em:

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

nota: este NÃO é um arquivo CSV, é uma única sequência contida em um arquivo com uma estrutura geral maior

Jason S
fonte

Respostas:

435

Experimentar:

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);
        }
    }
}

Resultado:

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

Em outras palavras: divida na vírgula apenas se ela tiver zero ou um número par de aspas à frente .

Ou, um pouco mais amigável para os olhos:

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 produz o mesmo que o primeiro exemplo.

EDITAR

Como mencionado por @MikeFHay nos comentários:

Prefiro usar o Guava's Splitter , pois possui padrões mais saudáveis ​​(veja a discussão acima sobre partidas vazias sendo cortadas por String#split(), então fiz:

Splitter.on(Pattern.compile(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)"))
Bart Kiers
fonte
De acordo com a RFC 4180: Seção 2.6: "Os campos que contêm quebras de linha (CRLF), aspas e vírgulas devem ser colocados entre aspas". Seção 2.7: "Se aspas duplas forem usadas para colocar campos, uma aspas dupla que aparece dentro de um campo deve ser escapada precedendo-a com outra aspas duplas" Portanto, se String line = "equals: =,\"quote: \"\"\",\"comma: ,\""tudo o que você precisa fazer é retirar as aspas duplas estranhas personagens.
21139 Paul Hanbury
@Bart: o meu ponto é que a sua solução ainda funciona, mesmo com citações embutidas
Paul Hanbury
6
@ Alex, sim, a vírgula é correspondida, mas a correspondência vazia não está no resultado. Adicionar -1ao param método split: line.split(regex, -1). Veja: docs.oracle.com/javase/6/docs/api/java/lang/…
Bart Kiers
2
Funciona bem! Eu prefiro usar o Divisor do Guava, pois ele tem padrões mais saudáveis ​​(veja a discussão acima sobre correspondências vazias sendo cortadas pela String # split), por isso fiz Splitter.on(Pattern.compile(",(?=([^\"]*\"[^\"]*\")*[^\"]*$)")).
precisa saber é o seguinte
2
AVISO!!!! Este regexp é lento !!! Ele possui um comportamento O (N ^ 2) em que a cabeça de impressão em cada vírgula parece até o final da cadeia. O uso dessa regexp causou uma desaceleração de 4x em grandes trabalhos do Spark (por exemplo, 45 minutos -> 3 horas). A alternativa mais rápida é algo findAllIn("(?s)(?:\".*?\"|[^\",]*)*")em combinação com uma etapa de pós-processamento para pular o primeiro campo (sempre vazio) após cada campo não vazio.
Urban Vagabond
46

Embora eu goste de expressões regulares em geral, para esse tipo de tokenização dependente do estado, acredito que um analisador simples (que neste caso é muito mais simples do que essa palavra possa parecer) é provavelmente uma solução mais limpa, principalmente no que diz respeito à manutenção , por exemplo:

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;
    }
}

Se você não se preocupa em preservar as vírgulas entre aspas, pode simplificar essa abordagem (sem manipulação do índice inicial, sem o caso especial de último caractere ) substituindo suas vírgulas entre aspas por outra coisa e depois dividindo em vírgulas:

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
fonte
As aspas devem ser removidas dos tokens analisados, após a sequência ser analisada.
Sudhir N
Encontrado via google, bom algoritmo mano, simples e fácil de adaptar, concordo. coisas com estado devem ser feitas via analisador, regex é uma bagunça.
Rudolf Schmidt
2
Lembre-se de que, se uma vírgula for o último caractere, ele estará no valor String do último item.
Gabriel Gates
21

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

https://github.com/pupi1985/JavaCSV-Reloaded (bifurcação da biblioteca anterior que permitirá que a saída gerada tenha terminadores de linha do Windows \r\nquando não estiver executando o Windows)

http://opencsv.sourceforge.net/

API CSV para Java

Você pode recomendar uma biblioteca Java para ler (e possivelmente gravar) arquivos CSV?

Java lib ou aplicativo para converter CSV para arquivo XML?

Jonathan Feinberg
fonte
3
Boa chamada, reconhecendo que o OP estava analisando um arquivo CSV. Uma biblioteca externa é extremamente apropriada para esta tarefa.
27613 Stefan Kendall
1
Mas a sequência é uma sequência CSV; você poderá usar uma API CSV nessa sequência diretamente.
Michael Brewer-Davis
Sim, mas essa tarefa é bastante simples e uma parte muito menor de um aplicativo maior, que não me apetece puxar outra biblioteca externa.
Jason S
7
não necessariamente ... minhas habilidades costumam ser adequadas, mas elas se beneficiam por serem aprimoradas.
18139 Jason S
9

Eu não recomendaria uma resposta regex de Bart, acho a solução de análise melhor neste caso específico (como Fabian propôs). Eu tentei a solução regex e a própria implementação de análise, descobri que:

  1. A análise é muito mais rápida do que a divisão com regex com referências anteriores - ~ 20 vezes mais rápida para seqüências curtas, ~ 40 vezes mais rápida para seqüências longas.
  2. O Regex falha ao encontrar a sequência vazia após a última vírgula. Isso não estava na pergunta original, era minha exigência.

Minha solução e teste abaixo.

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);

É claro que você pode alterar a opção para else-ifs neste trecho se não se sentir à vontade com a feiura. Observe a falta de interrupção após o interruptor com separador. Em vez disso, o StringBuilder foi escolhido como StringBuffer por design para aumentar a velocidade, onde a segurança do thread é irrelevante.

Marcin Kosinski
fonte
2
Ponto interessante sobre divisão de tempo vs análise. No entanto, a declaração 2 é imprecisa. Se você adicionar um -1ao método de divisão na resposta de Bart, você pegará cadeias vazias (incluindo cadeias vazias após a última vírgula):line.split(regex, -1)
Peter
+1 porque é uma melhor solução para o problema para o qual eu estava procurando por uma solução: analisar uma complexa seqüência de parâmetro HTTP POST corpo
varontron
2

Tente um lookaround como (?!\"),(?!\"). Isso deve corresponder ao ,que não está cercado ".

Matthew Sowders
fonte
Certeza de que iria quebrar em uma lista como: "foo", bar, "baz"
Angelo Genovese
1
Eu acho que você quis dizer (?<!"),(?!"), mas ainda não vai funcionar. Dada a sequência one,two,"three,four", ela corresponde corretamente à vírgula one,two, mas também corresponde à vírgula "three,four"e falha ao corresponder uma two,"three.
Alan Moore
Ele emendas para funcionar perfeitamente para mim, IMHO penso que esta é uma resposta melhor devido desde a sua mais curto e mais facilmente compreensível
Ordiel
2

Você está naquela área de fronteira irritante onde regexps quase não funciona (como apontado por Bart, escapar das aspas dificultaria a vida), e ainda assim um analisador completo parece um exagero.

Se você provavelmente precisar de maior complexidade em breve, procurarei uma biblioteca de analisadores. Por exemplo, este

djna
fonte
2

Fiquei impaciente e optei por não esperar por respostas ... para referência, não parece tão difícil fazer algo assim (que funciona para o meu aplicativo, não preciso me preocupar com aspas escapadas, como as coisas entre aspas está limitado a algumas formas restritas):

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;
}

(exercício para o leitor: estenda o tratamento de aspas escapadas, procurando também por barras invertidas.)

Jason S
fonte
1

A abordagem mais simples é não combinar delimitadores, ou seja, vírgulas, com uma lógica adicional complexa para corresponder ao que realmente é pretendido (os dados que podem ser citados), apenas para excluir delimitadores falsos, mas, em primeiro lugar, corresponder aos dados pretendidos.

O padrão consiste em duas alternativas, uma string entre aspas ( "[^"]*"ou ".*?") ou tudo até a próxima vírgula ( [^,]+). Para suportar células vazias, precisamos permitir que o item não citado fique vazio e consumir a próxima vírgula, se houver, e usar a \\Gâncora:

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

O padrão também contém dois grupos de captura para obter o conteúdo da sequência de caracteres citada ou o conteúdo simples.

Em seguida, com o Java 9, podemos obter uma matriz como

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

enquanto as versões Java mais antigas precisam de um loop como

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

A adição dos itens a uma Listou a uma matriz é deixada como um imposto especial de consumo para o leitor.

No Java 8, você pode usar a results()implementação desta resposta , para fazê-lo como a solução Java 9.

Para conteúdo misto com sequências incorporadas, como na pergunta, você pode simplesmente usar

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

Mas então, as cadeias são mantidas em sua forma citada.

Holger
fonte
0

Em vez de usar lookahead e outras regex loucas, basta puxar as aspas primeiro. Ou seja, para cada agrupamento de cotações, substitua esse agrupamento por __IDENTIFIER_1ou algum outro indicador e mapeie esse agrupamento para um mapa de sequência, sequência.

Depois de dividir por vírgula, substitua todos os identificadores mapeados pelos valores da sequência original.

Stefan Kendall
fonte
e como encontrar agrupamentos de cotações sem regexS malucos?
Kai Huppmann
Para cada caractere, se o caractere for aspas, encontre a próxima aspas e substitua por agrupamento. Se nenhuma próxima citação, feito.
27613 Stefan Kendall
0

que tal um one-liner usando String.split ()?

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

Eu faria algo assim:

boolean foundQuote = false;

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

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

else 

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