Почему LF и CRLF ведут себя по-разному с / ^ \ s * $ / gm regex? - PullRequest
2 голосов
/ 17 марта 2020

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

const input =
`===

HELLO

WOLRD

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

Это приводит к тому, что я ожидаю:

===

HELLO

WOLRD

===

, т.е. если в пустых строках было пробелов они будут удалены. С другой стороны, Windows, регулярное выражение очищает ВЕСЬ строку. Для иллюстрации:

const input =
`===

HELLO

WOLRD

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

(литералы шаблона всегда будут печатать только \n в JS, поэтому мне пришлось заменить на \r\n для эмуляции Windows (? после \r просто конечно, для тех, кто не верит). Результат:

===
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

===

1 Ответ

3 голосов
/ 18 марта 2020

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

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

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 и затем НЧ следует. После замены вместо последовательности =\r\n\r\nH это не просто =\r\nH. Давайте посмотрим, почему.

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

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

И вот что 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"
      ^^ whay `\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

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

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

Итак, если вы укажете \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+$ имеет некоторое неинтуитивное поведение, но в то же время идеально согласованное, когда вы понимаете, что механизм регулярных выражений соответствует точно тому, что вы говорите это к.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...