O bug silencioso de quebra de linha que corrompe dados no C#

Tratar uma quebra de linha no .NET parece simples, porque quase todo desenvolvedor assume que \r\n e \n cobrem todos os cenários possíveis.

Essa suposição funciona na maior parte do tempo, mas fica incompleta quando o texto vem de mainframes, editores de escritório ou pipelines que passaram por várias conversões de encoding.

O padrão Unicode define um conjunto maior de caracteres que encerram uma linha. Ignorar esse conjunto provoca falhas silenciosas em parsers, validadores e rotinas de importação.

Neste artigo, você vai conhecer todos esses terminadores e entender como cada API da plataforma lida com eles, do Regex.Split até a opção RegexOptions.AnyNewLine do .NET 11.

O que conta como quebra de linha no .NET

Os três terminadores mais conhecidos aparecem em praticamente qualquer projeto. O primeiro é o \r\n (CRLF, U+000D U+000A), padrão no Windows.

Em seguida vem o \n (LF, U+000A), adotado em sistemas Unix e Linux. Por fim, existe o \r (CR, U+000D), que era o padrão do macOS clássico e ainda surge em arquivos legados.

No entanto, a lista não termina aí. O Unicode Technical Standard #18, seção RL1.6, descreve os limites de linha que um motor de expressões regulares compatível com Unicode deve reconhecer.

Além dos três clássicos, esse documento inclui o \u0085 (NEL, Next Line), que nasceu no padrão EBCDIC e ainda aparece em textos exportados por mainframes IBM.

Também entram na lista o 
 (LS, Line Separator) e o 
 (PS, Paragraph Separator), ambos definidos pelo próprio Unicode para separar linhas e parágrafos.

Por fim, a expressão recomendada pela RL1.6 cobre ainda dois caracteres de controle: o \v (VT, Vertical Tab, U+000B) e o \f (FF, Form Feed, U+000C).

Dependendo do contexto, esses dois últimos podem ou não contar como quebra de linha no .NET, porque cada API da plataforma adota um conjunto ligeiramente diferente.

Esses caracteres não são exóticos. Textos copiados do Microsoft Word, do LibreOffice e de outras ferramentas de escritório podem conter NEL ou LS sem que ninguém perceba.

Da mesma forma, arquivos CSV exportados de sistemas antigos, ou transformados por várias etapas de encoding e normalização, carregam qualquer um desses terminadores.

Por que esses caracteres causam problemas reais

O risco principal surge na leitura linha a linha de arquivos ou entradas de usuário. Quando o código considera apenas \r\n ou \n como separadores, registros terminados em \u0085 ou 
 continuam grudados.

Esse erro de quebra de linha no .NET é especialmente danoso porque não gera exceção alguma. O programa continua rodando e produzindo resultados, mas os resultados estão errados.

Na prática, uma linha que deveria virar dois registros se transforma em um único registro inválido. Com isso, validações, relatórios e qualquer lógica dependente da separação correta deixam de funcionar.

Um cenário frequente envolve arquivos CSV recebidos de clientes ou parceiros que usam sistemas diferentes. Um mainframe IBM pode exportar o arquivo com NEL, enquanto outra ferramenta pode inserir LS no meio do conteúdo.

Nesse caso, qualquer parser baseado apenas em string.Split('\n') ou em StreamReader.ReadLine() falha sem emitir nenhum aviso. Por isso, vale conhecer as alternativas que a plataforma oferece para cada quebra de linha no .NET.

Se você ainda está montando a base sobre o assunto, o guia de manipulação de strings em C# e .NET ajuda a revisar os fundamentos antes de seguir.

Como dividir cada quebra de linha no .NET com Regex.Split

A abordagem mais segura para dividir texto em linhas usa uma expressão regular que contempla todos os terminadores Unicode.

O padrão (?>\r\n|[\n\v\f\r\u0085

]) usa um grupo atômico para tratar o CRLF como sequência indivisível. Em seguida, a classe de caracteres entre colchetes cobre os demais terminadores individualmente.

using System.Text.RegularExpressions;

string input = "A
B\u0085C\r\nD";

// Divide o texto em qualquer terminador de linha Unicode
string[] lines = Regex.Split(input, @"(?>\r\n|[\n\v\f\r\u0085

])");

// lines = ["A", "B", "C", "D"]

Repare que o texto de entrada mistura três tipos de quebra de linha no .NET. Mesmo assim, o resultado traz exatamente quatro linhas, sem nenhum elemento vazio.

Esse padrão corresponde à recomendação da RL1.6, então ele se comporta da mesma forma que o atalho \R de outros motores. Se você costuma reaproveitar expressões, o artigo sobre como organizar e reutilizar expressões regulares em C# mostra boas formas de centralizar padrões como esse.

Por que o grupo atômico importa

Na divisão de cada quebra de linha no .NET, o grupo atômico (?>...) impede o backtracking entre \r\n e \r. Sem ele, o motor poderia, em determinadas situações, combinar apenas o \r e deixar o \n como início de outra linha.

Nesse caso, o array de saída ganharia um elemento vazio indesejado entre dois registros. Assim, o grupo atômico garante que o motor consuma o CRLF inteiro antes de tentar os demais terminadores.

E o atalho \R

Motores como o PCRE oferecem o atalho \R, que representa qualquer terminador de linha Unicode em um único token.

O motor de expressões regulares do .NET, porém, não suporta esse atalho. Por isso, o padrão explícito continua necessário, principalmente nas versões anteriores ao .NET 11.

Evitando alocações com Regex.EnumerateSplits

O Regex.Split resolve a quebra de linha no .NET, mas tem um custo que nem sempre aparece nos testes. Segundo a documentação de novidades do .NET 9, ele aloca um array de strings e mais uma string para cada pedaço gerado.

Para resolver isso, o .NET 9 trouxe o método Regex.EnumerateSplits. Ele recebe um ReadOnlySpan<char> e devolve objetos Range que apontam para cada trecho do texto original.

using System;
using System.Text.RegularExpressions;

ReadOnlySpan<char> input = "A
B\u0085C\r\nD";

foreach (Range split in Regex.EnumerateSplits(input, @"(?>\r\n|[\n\v\f\r\u0085

])"))
{
    ReadOnlySpan<char> line = input[split];
    ProcessLine(line);
}

static void ProcessLine(ReadOnlySpan<char> line)
{
    // Processa a linha sem criar novas strings no heap
}

Como ReadOnlySpan<char> apenas aponta para a memória existente, o garbage collector não precisa coletar cada linha processada. Dessa forma, o processo sofre menos pausas e mantém a latência estável.

Além disso, a avaliação acontece de forma preguiçosa. A documentação explica que cada match só ocorre quando o enumerador avança, então o código processa uma linha por vez.

Existe também uma diferença de comportamento importante. Enquanto Regex.Split inclui o conteúdo de grupos de captura no resultado, EnumerateSplits ignora esses grupos.

Na prática, essa diferença não afeta o padrão deste artigo, porque o grupo atômico não captura nada. Ainda assim, vale lembrar disso ao adaptar outros padrões.

Em volumes altos, como logs com milhões de registros, tratar cada quebra de linha no .NET sem alocar gera uma economia significativa. O artigo sobre normalização de strings com Span aprofunda essa mesma técnica de evitar alocações.

Normalizando a quebra de linha no .NET com ReplaceLineEndings

Outra abordagem útil consiste em normalizar cada quebra de linha no .NET antes de processar o texto. Para isso, o .NET 6 introduziu o método string.ReplaceLineEndings.

Esse método troca todas as sequências de nova linha reconhecidas por um delimitador único, escolhido por você. Depois disso, um Split simples resolve o restante.

string input = "A
B\u0085C\r\nD";

// Converte qualquer terminador reconhecido em \n
string normalized = input.ReplaceLineEndings("\n");
string[] lines = normalized.Split('\n');

// normalized = "A\nB\nC\nD"
// lines = ["A", "B", "C", "D"]

A documentação oficial do método reconhece CR, LF, CRLF, NEL, LS, FF e PS como sequências de nova linha. Essa lista segue o Unicode Standard, seção 5.8, Recommendation R4.

Repare que o VT não aparece nessa lista, embora o padrão da RL1.6 o inclua. Portanto, cada quebra de linha no .NET pode ter tratamento diferente conforme a API escolhida.

Quando você chama o método sem argumento, ele usa o Environment.NewLine da plataforma atual. Assim, no Windows o resultado usa CRLF, enquanto no Linux usa apenas LF.

Se o argumento for uma string vazia, o método simplesmente remove todas as quebras. Além disso, a documentação garante complexidade linear em relação ao tamanho da entrada.

Quando não usar ReplaceLineEndings

A própria documentação desaconselha o método em parsers de protocolos de rede. Especificações como a do HTTP/1.1 exigem CRLF exato na linha de requisição, na linha de status e nos cabeçalhos.

Como o método aceita uma variedade ampla de terminadores, um parser de protocolo poderia aceitar entradas que os autores da especificação nunca previram. Nesse cenário, mantenha o tratamento estrito que o protocolo exige.

A novidade do .NET 11: RegexOptions.AnyNewLine

O .NET 11 introduziu a opção RegexOptions.AnyNewLine, que simplifica muito a quebra de linha no .NET em expressões regulares. Ela muda o comportamento de ^, $, \Z e . para reconhecer o conjunto completo de terminadores, e não apenas o \n.

Segundo a documentação de opções de expressões regulares, a opção reconhece \r\n, \r, \n, \u0085, 
 e 
, de forma consistente com a RL1.6.

using System.Text.RegularExpressions;

string text = "line1\r\nline2\u0085line3
line4";

// AnyNewLine faz ^ e $ reconhecerem todos os terminadores
MatchCollection matches = Regex.Matches(
    text,
    @"^line\d$",
    RegexOptions.Multiline | RegexOptions.AnyNewLine);

Console.WriteLine(matches.Count); // 4

Sem AnyNewLine, o $ em modo Multiline reconhece apenas \n. Isso significa que ele não faz match antes de \r\n, a menos que o padrão use o contorno \r?$.

Esse contorno para a quebra de linha no .NET, aliás, tem um efeito colateral. O \r passa a fazer parte do match, enquanto o AnyNewLine trata o CRLF como sequência atômica e deixa o \r de fora.

// Sem AnyNewLine, o \r final entra no resultado
var match = Regex.Match("foo\r\nbar", @".+$", RegexOptions.Multiline);
Console.WriteLine(match.Value); // "foo\r"

// Com AnyNewLine, o resultado fica limpo
var fixedMatch = Regex.Match("foo\r\nbar", @".+$",
    RegexOptions.Multiline | RegexOptions.AnyNewLine);
Console.WriteLine(fixedMatch.Value); // "foo"

Como cada construtor muda

A tabela abaixo resume o comportamento de cada construtor com e sem a nova opção, conforme a documentação oficial.

Construtor Comportamento padrão Com AnyNewLine
. Combina qualquer caractere exceto \n Combina qualquer caractere exceto \r, \n, \u0085, 
 e 

$ (Multiline) Combina antes de \n Combina antes de \r\n, \r, \n, \u0085, 
 e 

^ (Multiline) Combina após \n Combina após qualquer um desses terminadores
$ padrão e \Z Combina antes de \n no fim da string Combina antes de qualquer terminador no fim da string

As âncoras absolutas \A e \z continuam iguais. Assim, o início e o fim real da string não mudam de significado.

Restrições da nova opção

O AnyNewLine não pode ser combinado com RegexOptions.NonBacktracking nem com RegexOptions.ECMAScript. Caso você tente, o construtor lança uma ArgumentOutOfRangeException.

Quando Singleline e AnyNewLine aparecem juntos, o Singleline prevalece. Nesse caso, o . combina todos os caracteres, inclusive os terminadores.

Além disso, a opção não tem caractere inline equivalente. Portanto, você precisa informá-la pelo parâmetro options, e não dentro do padrão.

Os limites do ReadLine com cada quebra de linha no .NET

O método TextReader.ReadLine, e por consequência o StreamReader.ReadLine, define uma linha como uma sequência terminada por \r, \n ou \r\n.

Isso significa que essa API não trata \u0085, 
 ou 
 como delimitadores. Então, todo conteúdo separado por esses caracteres volta como uma única linha.

using System.IO;

using var reader = new StringReader("first
second\nthird");

string? line;
while ((line = reader.ReadLine()) is not null)
{
    // Imprime "first
second" e depois "third"
    Console.WriteLine(line);
}

Esse comportamento é documentado e intencional, porque o método prioriza compatibilidade com os sistemas operacionais mais comuns. Ainda assim, você precisa conhecer essa limitação ao escrever parsers para dados de origem variada.

A documentação também registra um detalhe útil: quando a entrada termina com uma quebra, o método não devolve uma linha vazia extra. Por exemplo, "line1\nline2\n" e "line1\nline2" produzem as mesmas duas linhas.

Para dados vindos de mainframes ou de pipelines de transformação, troque o ReadLine por uma das abordagens baseadas em Regex.Split ou Regex.EnumerateSplits. Se precisar revisar a leitura de arquivos em si, o artigo sobre como ler arquivos em C# cobre o básico.

Comparando as APIs de quebra de linha no .NET

Cada API cobre um conjunto diferente de terminadores de quebra de linha no .NET. Por isso, a tabela a seguir ajuda a escolher a ferramenta certa para cada cenário.

API Versão mínima Terminadores cobertos Aloca strings
TextReader.ReadLine Todas CR, LF e CRLF Sim
string.ReplaceLineEndings .NET 6 CR, LF, CRLF, NEL, LS, FF e PS Sim
Regex.Split com padrão explícito Todas Conjunto da RL1.6, incluindo VT e FF Sim
Regex.EnumerateSplits .NET 9 O que o padrão informado definir Não
RegexOptions.AnyNewLine .NET 11 CR, LF, CRLF, NEL, LS e PS Depende do uso

Em resumo, nenhuma API sozinha resolve todos os casos. A escolha depende do volume de dados, da versão da plataforma e da necessidade de preservar o texto original.

Boas práticas para tratar quebra de linha no .NET em produção

Aplicações que processam entradas externas, como APIs de terceiros, importações de arquivos e dados de usuário, ganham muito ao normalizar as quebras antes de qualquer parsing.

Essa normalização da quebra de linha no .NET isola o restante do código das variações de formato. Com isso, o comportamento fica previsível, independentemente da origem dos dados.

Para sistemas com requisitos de alta performance, como pipelines de ingestão ou processamento em lote, combine Regex.EnumerateSplits com ReadOnlySpan<char>. Assim, você elimina alocações e mantém a cobertura completa de terminadores.

Quando o terminador original precisa ser preservado para auditoria ou reprodução fiel, a biblioteca Meziantou.Framework oferece o método SplitLines. Ele devolve a linha e também o separador encontrado.

Segundo o autor da biblioteca, o método trabalha com três modos, chamados Standard, Unicode e UnicodeWithLegacyControls. Além disso, ele opera sobre ReadOnlySpan<char>, o que mantém o consumo de memória baixo.

Por fim, inclua nos testes automatizados entradas com NEL, LS e PS. Dessa forma, qualquer regressão no tratamento de quebra de linha no .NET aparece antes de chegar à produção.

Se o seu sistema gera arquivos para outros consumidores, documente o terminador adotado. Vale ler também o artigo sobre como gerar CSV em C#, que mostra um cenário em que essa decisão faz diferença.

Conclusão

O tratamento correto de uma quebra de linha no .NET exige ir além dos dois ou três terminadores que a maioria dos desenvolvedores conhece.

Afinal, o Unicode define pelo menos sete caracteres que encerram uma linha. Além disso, dados de sistemas legados, ferramentas de escritório e pipelines de transformação podem conter qualquer um deles.

A plataforma oferece uma ferramenta para cada cenário. O string.ReplaceLineEndings normaliza, o Regex.Split com padrão explícito divide de forma robusta e o Regex.EnumerateSplits processa sem alocações desde o .NET 9.

Já o RegexOptions.AnyNewLine, no .NET 11, faz as âncoras e o ponto reconhecerem os terminadores Unicode de forma transparente. Conhecer essas opções é o que separa um parser que apenas funciona de um parser correto.

FAQ

Por que o CRLF precisa ser tratado como uma sequência atômica?

Porque \r e \n também são terminadores individuais. Sem o grupo atômico, o motor poderia consumir só o \r e gerar uma linha vazia entre dois registros.

O ReplaceLineEndings pode ser usado em qualquer string?

Em geral sim, mas a documentação desaconselha seu uso em parsers de protocolos de rede. Protocolos como o HTTP/1.1 exigem CRLF exato em cabeçalhos e linhas de status.

Quando devo usar Regex.EnumerateSplits em vez de Regex.Split?

Quando o volume de texto é alto e o consumo de memória importa. O método trabalha sobre ReadOnlySpan<char> e não cria uma string para cada linha.

Em qual versão do .NET está o RegexOptions.AnyNewLine?

A opção chegou no .NET 11. Nas versões anteriores, use o padrão explícito (?>\r\n|[\n\v\f\r\u0085

]).

O TextReader.ReadLine reconhece terminadores Unicode?

Não. Ele reconhece apenas \r\n, \n e \r, então textos com NEL, LS ou PS exigem as abordagens baseadas em expressões regulares.

O atalho \R do PCRE funciona no .NET?

Não. O motor do .NET não suporta esse atalho, e o padrão explícito apresentado no artigo é a alternativa equivalente.

Qual a diferença entre LS e PS?

O 
 (LS) representa uma quebra de linha dentro do mesmo parágrafo, enquanto o 
 (PS) marca o fim de um parágrafo. Para dividir texto em linhas, trate os dois como terminadores.