Почему LF и CRLF по-разному работают с регулярным выражением / ^ \ s * $ / gm?

Я видел эту проблему в Windows. Когда я пытаюсь очистить пробелы в каждой строке в Unix:

const input =
`===

HELLO

WOLRD

===`
console.log(input.replace(/^\s+$/gm, ''))

Это дает то, что я ожидаю:

===

HELLO

WOLRD

===

i.e. if there were spaces on blank lines, they'd get removed. On the other hand, on Windows, the regex clears the WHOLE string. To illustrate:

const input =
`===

HELLO

WOLRD

===`.replace(/\r?\n/g, '\r\n')
console.log(input.replace(/^\s+$/gm, ''))

(template literals will always print only \n in JS, so I had to replace with \r\n to emulate Windows (? after \r just to be sure for those who don't believe). The result:

===
HELLO
WOLRD
===

Вся линия пропала! Но в моем регулярном выражении есть ^ и $ с установленным флагом m, так что это вроде /^-to-$/m. В чем разница между \r и \r\n, из-за чего они дают разные результаты?

когда я веду журнал

console.log(input.replace(/^\s*$/gm, (m) => {
  console.log('matched')
  return ''
}))

С \ r \ n я вижу

matched
matched
matched
matched
matched
matched
===
HELLO
WOLRD
===

и только с \ n

matched
matched
matched
===

HELLO

WOLRD

===

person zavr    schedule 17.03.2020    source источник
comment
Совершенно уверен, что $ будет соответствовать сразу перед концом строки, поэтому, если у вас есть text\r\n, тогда $ будет соответствовать до \n, но после \r.   -  person VLAZ    schedule 17.03.2020
comment
так почему он регистрируется 6 раз с \ r \ n.   -  person zavr    schedule 17.03.2020


Ответы (1)


TL; DR шаблон, включающий пробелы и разрывы строк, также будет соответствовать символам, входящим в последовательность \r\n, если вы позволите.

Прежде всего, давайте посмотрим, какие символы есть, а какие нет, когда вы делаете замену. Начиная со строки, в которой используются только переводы строки:

const inputLF =
`===

HELLO

WOLRD

===`.replace(/\r?\n/g, "\n");

console.log('------------ INPUT ')
console.log(inputLF);
console.log('------------')

debugPrint(inputLF, 2);
debugPrint(inputLF, 3);
debugPrint(inputLF, 4);
debugPrint(inputLF, 5);

const replaceLF = inputLF.replace(/^\s+$/gm, '');

console.log('------------ REPLACEMENT')
console.log(replaceLF);
console.log('------------')

debugPrint(replaceLF, 2);
debugPrint(replaceLF, 3);
debugPrint(replaceLF, 4);
debugPrint(replaceLF, 5);

console.log(`charcode ${replaceLF.charCodeAt(2)} : ${replaceLF.charAt(2)}`);
console.log(`charcode ${replaceLF.charCodeAt(3)} : ${replaceLF.charAt(3)}`);
console.log(`charcode ${replaceLF.charCodeAt(4)} : ${replaceLF.charAt(4)}`);
console.log(`charcode ${replaceLF.charCodeAt(5)} : ${replaceLF.charAt(5)}`);

console.log('------------')
console.log('inputLF === replaceLF :', inputLF === replaceLF)

function debugPrint(str, charIndex) {
  console.log(`index: ${charIndex}
   charcode: ${str.charCodeAt(charIndex)}
   character: ${str.charAt(charIndex)}`
 );
}

Каждая строка заканчивается символьным кодом 10, который является символом перевода строки (LF), представленным в строковом литерале с \n. До и после замены две строки одинаковы - не только выглядят одинаково, но и фактически равны друг другу, поэтому замена ничего не дала.

Теперь рассмотрим другой случай:

const inputCRLF =
`===

HELLO

WOLRD

===`.replace(/\r?\n/g, "\r\n")
console.log('------------ INPUT ')
console.log(inputCRLF);
console.log('------------')

debugPrint(inputCRLF, 2);
debugPrint(inputCRLF, 3);
debugPrint(inputCRLF, 4);
debugPrint(inputCRLF, 5);
debugPrint(inputCRLF, 6);
debugPrint(inputCRLF, 7);

const replaceCRLF = inputCRLF.replace(/^\s+$/gm, '');;

console.log('------------ REPLACEMENT')
console.log(replaceCRLF);
console.log('------------')

debugPrint(replaceCRLF, 2);
debugPrint(replaceCRLF, 3);
debugPrint(replaceCRLF, 4);
debugPrint(replaceCRLF, 5);

function debugPrint(str, charIndex) {
  console.log(`index: ${charIndex}
   charcode: ${str.charCodeAt(charIndex)}
   character: ${str.charAt(charIndex)}`
 );
}

На этот раз каждая строка заканчивается символьным кодом 13, который представляет собой символ возврата каретки (CR), который представлен в строковом литерале с помощью \r и , затем следует LF. После замены вместо последовательности =\r\n\r\nH это не просто =\r\nH. Посмотрим, почему.

Вот что говорит MDN о метасимвол ^:

Соответствует началу ввода. Если для многострочного флага установлено значение true, также соответствует сразу после символа разрыва строки.

А вот что MDN говорит о метасимволе $

Соответствует концу ввода. Если для многострочного флага установлено значение true, также совпадает непосредственно перед символом разрыва строки.

Таким образом, они соответствуют после и перед символом разрыва строки. В этом случае MDN означает LF или CR. Это можно увидеть, если мы протестируем строку, содержащую разные разрывы строк:

const stringLF = "hello\nworld";
const stringCRLF = "hello\r\nworld";

const regexStart = /^\s/m;
const regexEnd = /\s$/m;

console.log(regexStart.exec(stringLF));
console.log(regexStart.exec(stringCRLF));

console.log(regexEnd.exec(stringLF));
console.log(regexEnd.exec(stringCRLF));

Если мы попытаемся сопоставить пробелы рядом с разрывом строки, ничего не получится, если есть LF, но он действительно сопоставит CR с CRLF. Итак, в этом случае $ будет соответствовать здесь:

"hello\r\nworld"
        ^^ what `^\s` matches

"hello\r\nworld"
      ^^ what `\s$` matches

Таким образом, и ^, и $ распознают любую последовательность CRLF как конец строки. Это будет иметь значение, когда вы выполните поиск и замену. Поскольку ваше регулярное выражение определяет ^\s+$, это означает, что если у вас есть строка, полностью состоящая из \r\n, тогда она соответствует. Но по неочевидной причине:

const re = /^\s+$/m;

const sringLF = "hello\n\nworld";
const stringCRLF = "hello\r\n\r\nworld";


console.log(re.exec(sringLF));
console.log(re.exec(stringCRLF));

Таким образом, регулярное выражение соответствует не \r\n, а скорее \n\r (два символа пробела) между двумя другими символами разрыва строки. Это потому, что + нетерпелив и потребляет столько последовательности символов, сколько может. Вот что попробует движок регулярных выражений. Несколько упрощено для краткости:

input = "hello\r\n\r\nworld
regex = /^\s+$/

Step 1
hello[\r]\n\r\nworld
    matches `^`, symbol satisfied -> continue with next symbol in regex

Step 2
hello[\r\n]\r\nworld
    matches `^\s+` -> continue matching to satisfy `+` quantifier

Step 3
hello[\r\n\r]\nworld
    matches `^\s+` -> continue matching to satisfy `+` quantifier

Step 4
hello[\r\n\r\n]world
    matches `^\s+` -> continue matching to satisfy `+` quantifier

Step 5
hello[\r\n\r\nw]orld
    does not match `\s` -> backtrack

Step 6
hello[\r\n\r\n]world
    matches `^\s+`, quantifier satisfied -> continue to next symbol in regex

Step 7
hello[\r\n\r\nw]orld
    does not match `$` in `^\s+$` -> backtrack

Step 8
hello[\r\n\r\n]world
    matches `^\s+$`, last symbol satisfied -> finish

Наконец, здесь есть что-то слегка скрытое - важно, чтобы вы сопоставляли пробелы. Это связано с тем, что он будет вести себя иначе, чем большинство других символов, поскольку он явно соответствует символу разрыва строки, тогда как _ 22_ не будет:

Соответствует любому одиночному символу кроме конца строки

Итак, если вы укажете \s$, это будет соответствовать CR в \r\n, потому что механизм регулярных выражений вынужден искать совпадения для \s и $, поэтому он находит \r перед \n. Однако этого не произойдет для многих других шаблонов, поскольку $ обычно удовлетворяется, когда он до CR (или в конце строки).

То же самое с ^\s, он будет явно искать пробельный символ после переноса строки, который удовлетворяется LF в CRLF, однако, если вы этого не ищете, тогда он с радостью будет соответствовать после LF:

const stringLF = "hello\nworld";
const stringCRLF = "hello\r\nworld";

const regexStartAll = /^./mg;
const regexEndAll = /.$/gm;

console.log(stringLF.match(regexStartAll));
console.log(stringCRLF.match(regexStartAll));

console.log(stringLF.match(regexEndAll));
console.log(stringCRLF.match(regexEndAll));

Итак, все это означает, что ^\s+$ имеет некоторое неинтуитивное поведение, но при этом совершенно логично, если вы понимаете, что механизм регулярных выражений в точности соответствует тому, что вы ему указываете.

person VLAZ    schedule 17.03.2020
comment
Чтобы лучше понять это, рекомендуется заменить якоря двумя символами: console.log('===\r\n\r\nHELLO\r\n \r\nWOLRD\r\n==='.replace(/^$/gm, 'xy')) - person wp78de; 18.03.2020
comment
Я предлагаю вам добавить TL;DR; вверху. - person Wiktor Stribiżew; 18.03.2020
comment
Короче говоря, /^/m будет соответствовать \r в окнах вместо полного _3 _... спасибо за такой подробный ответ. - person zavr; 18.03.2020