본문으로 건너뛰기

비교 결과

diffText는 두 문서 사이에서 무엇이 달라졌는지 계산해 돌려줍니다. React도 DOM도위젯을 건드리지 않아서 진입점이 따로 있습니다화면에서든 빌드 스크립트나 아이솔레이트에서든 똑같이 씁니다. 요약 한 줄, 배지의 숫자, 워커에서 돌리는화면을 그리는 스레드 밖에서 돌리는 계산에는 화면이 아니라 값이 필요하기 때문입니다.

ts
import { diffText } from 'diffine-react/diff';

const result = diffText(saved, draft);

result.changes.length; // 3
result.stats; // { unchanged: 41, changed: 5, inserted: 2, deleted: 1 }
dart
final DiffResult result = diffText(saved, draft);

result.changes.length; // 3
result.stats; // unchanged 41, changed 5, inserted 2, deleted 1

돌려주는 것

필드무엇인지
before, after각 문서를 줄 단위로 자른 것. 줄바꿈 문자는 뺍니다.
rows두 문서의 처음부터 한 행씩 늘어놓은 비교 결과.
changes변경 목록. 나타나는 순서대로 들어 있습니다.
stats어느 쪽에 얼마나 들어갔는지 집계.
complete최소 편집을 찾았는지, 도중에 포기했는지.

한 행은 줄이 있는 쪽만 채워집니다. equal은 양쪽 다, insertafter만, deletebefore만 있고, replace는 마주 보는 두 줄이 서로 다른 경우입니다.

ts
for (const row of result.rows) {
  if (row.kind === 'equal') {
    continue;
  }

  console.log(row.kind, row.before?.text ?? '', row.after?.text ?? '');
}
dart
for (final DiffRow row in result.rows) {
  if (row.kind == DiffRowKind.equal) {
    continue;
  }

  debugPrint('${row.kind.name} ${row.before?.text ?? ''} ${row.after?.text ?? ''}');
}

좌우 비교 화면은 그 null 자리에 빈 칸을 그립니다. 화면이 아닌 곳에서 읽는다면 그냥 건너뛰면 됩니다.

줄에는 자기 문서에서 몇 번째 줄인지 나타내는 index(0부터), 쓰인 그대로의 text, 그리고 segments가 있습니다.

조각

segments는 한 줄을 바뀐 부분과 안 바뀐 부분으로 쪼갠 것입니다. 그 줄이 속한 쪽의 조각만 들어 있어서, 이어 붙이면 원래 줄이 나옵니다.

ts
const [row] = diffText('the quick fox', 'the slow fox').rows;

row.before.segments;
// [{ kind: 'equal', text: 'the ' }, { kind: 'delete', text: 'quick' }, { kind: 'equal', text: ' fox' }]
row.after.segments;
// [{ kind: 'equal', text: 'the ' }, { kind: 'insert', text: 'slow' }, { kind: 'equal', text: ' fox' }]
dart
final DiffRow row = diffText('the quick fox', 'the slow fox').rows.first;

row.before!.segments;
// equal 'the ', delete 'quick', equal ' fox'
row.after!.segments;
// equal 'the ', insert 'slow', equal ' fox'

빈 배열이면 비교할 상대가 없었거나, 짝이 너무 안 닮아서 짚어 줄 가치가 없다고 판단한 경우입니다. 어느 쪽이든 그 줄은 행이 말하는 그대로 처음부터 끝까지 하나입니다.

변경

변경 하나는 함께 바뀐 줄들의 묶음입니다. "변경이 몇 건이냐"를 셀 때의 단위가 이것입니다. 안 바뀐 줄들은 목록에 없고, 변경과 변경 사이의 빈 자리가 그것입니다.

각 항목은 양쪽에서 어느 줄들을 덮는지와 어느 행을 차지하는지를 갖고 있습니다.

ts
for (const change of result.changes) {
  console.log(`${change.kind}: before ${change.beforeStart + 1}~${change.beforeEnd}줄`);
}
dart
for (final DiffChange change in result.changes) {
  debugPrint('${change.kind.name}: before '
      '${change.beforeStart + 1}~${change.beforeEnd}줄');
}

rowStartrowEnd는 다음 변경으로 건너뛰거나 여백에 띠를 그릴 때 쓰는 값입니다.

비교 방식

두 번째 인자가 정합니다. 빠뜨린 항목은 기본값을 쓰므로 옵션 하나만 줘도 됩니다.

inline

통째로 바뀐 게 아니라 고쳐진 줄 짝 안에서 무엇을 비교할지 정합니다.

기본값 word가 대개 원하는 결과입니다. 원래 있던 단어와 글자 몇 개가 겹친다고 그것까지 짚는 대신 옮겨간 단어를 짚습니다. character는 한 단계 더 내려가 자소 하나씩 비교하므로 숫자 가운데 한 자리가 바뀐 경우에 알맞습니다. none은 바뀐 줄을 바뀐 줄로만 두고 더 말하지 않습니다.

whitespace

공백을 얼마나 따질지 정합니다. 기본은 exact이고 trailing, surrounding, amount, all 순으로 점점 더 무시합니다.

무시한 공백도 화면에는 그대로 그립니다. 어떤 줄을 같다고 볼지만 바뀌고 읽는 사람이 보는 것은 그대로입니다. 들여쓰기만 다시 한 파일에 surrounding이 쓸모 있는 이유입니다.

ignoreCase

Titletitle을 같은 줄로 볼지 정합니다. 기본은 끔입니다.

켜면 엔진이 같다고 판단한 구간이 실제로는 서로 다른 두 문자열일 수 있습니다. 조각을 한 목록이 아니라 양쪽으로 나눠 돌려주는 이유가 이것입니다. 목록이 하나라면 Titletitle 중 하나만 담을 수 있고, 담기지 않은 쪽 문서에는 없는 글자를 보여 주게 됩니다.

inlineThreshold

줄 안의 단어를 짚어 줄 만큼 두 줄이 닮았는지 판단하는 기준입니다. 0에서 1 사이이고 기본은 0.3입니다.

고쳐진 두 줄은 단어 대부분이 겹칩니다. 그냥 마주 보게 된 두 줄은 쉼표 하나와 모음 몇 개가 겹칠 뿐이고, 그것까지 짚으면 의미 없는 조각이 줄 곳곳에 흩어집니다. 기준 아래로 내려가면 양쪽 모두 그냥 바뀐 줄 하나로 그립니다.

ignore

일치하는 부분을 비교에서 빼는 패턴 목록입니다. 기본값은 빈 목록입니다.

타임스탬프가 박힌 스냅샷, 요청 아이디가 붙은 로그, 파일 이름에 해시가 든 빌드 결과물. 매번 달라지는 줄 하나 때문에 파일 전체가 바뀐 것처럼 나오는 경우입니다. 각 패턴을 양쪽 줄에서 찾아 그 부분을 빼고 비교하므로, 그 안에서만 다른 두 줄은 같은 줄이 됩니다.

ts
diffText(saved, rendered, { ignore: [/\d{4}-\d{2}-\d{2}T[\d:.]+Z/, /\bid=\w+/] });
dart
diffText(
  saved,
  rendered,
  DiffOptions(
    ignore: <RegExp>[RegExp(r'\d{4}-\d{2}-\d{2}T[\d:.]+Z'), RegExp(r'\bid=\w+')],
  ),
);

찾은 부분은 지우는 것이 아니라 자리를 남긴 채 치웁니다. 그래서 타임스탬프가 있는 줄과 아예 없는 줄은 여전히 다른 줄입니다. 치운 부분도 화면에는 그대로 그려집니다. whitespace와 같습니다.

판단하는 단위는 줄입니다. 바뀐 두 줄 안에서는 단어를 쓰인 그대로 비교합니다. 줄을 두고 쓴 패턴은 그 줄의 단어 하나에 대한 패턴이 아니기 때문입니다.

maxCost

포기하기 전까지 감당할 차이의 크기입니다. 기본은 5000입니다.

최소 편집을 찾는 비용은 대략 두 문서의 크기 곱하기 그 사이 편집 횟수입니다. 그래서 공통점이 없는 큰 문서 둘이 가장 비싼 경우인데, 이 경우의 답은 "전부 바뀌었다"이고 그걸 기다릴 이유가 없습니다. 한도를 넘으면 그 구간은 통째로 교체된 것으로 돌아오고 completefalse가 됩니다.

더 작은 단위

diffWordsdiffCharacters는 문서 없이 두 줄만 비교합니다. 제목, 이름, 표의 한 칸 같은 것들입니다.

ts
import { diffWords } from 'diffine-react/diff';

const { before, after, similarity } = diffWords('the quick fox', 'the slow fox');
dart
final DiffInlineResult result = diffWords('the quick fox', 'the slow fox');
// result.before, result.after, result.similarity

similarity는 짝지을 수 있었던 비율을 글자 수로 센 값입니다. inlineThreshold가 재는 대상이 이 값입니다.

diffSequence는 엔진 그 자체입니다. 비교 단위가 줄도 단어도 아닌 경우에 쓰세요.

ts
import { diffSequence } from 'diffine-react/diff';

diffSequence(['a', 'b', 'c'], ['a', 'c']);
// [
//   { kind: 'equal',  beforeStart: 0, beforeEnd: 1, afterStart: 0, afterEnd: 1 },
//   { kind: 'delete', beforeStart: 1, beforeEnd: 2, afterStart: 1, afterEnd: 1 },
//   { kind: 'equal',  beforeStart: 2, beforeEnd: 3, afterStart: 1, afterEnd: 2 }
// ]
dart
diffSequence(<String>['a', 'b', 'c'], <String>['a', 'c']);
// equal  0..1 / 0..1
// delete 1..2 / 1..1
// equal  2..3 / 1..2

양쪽 다 문자열로 비교하므로, 토큰이 무엇이든 그것을 식별하는 문자열로 넘기면 됩니다. 돌아온 편집 목록은 두 배열을 순서대로 빠짐없이 한 번씩 덮습니다.

패치

패치는 바뀐 줄과 그 앞뒤 몇 줄만 담은 형식입니다. git diff가 쓰는 것이고 어느 코드 호스트나 읽는 것입니다. parsePatch는 이것을 diffText가 돌려주는 값과 똑같은 형태로 바꿔 줍니다. 비교를 이미 끝내 둔 서버라면 문서 두 벌 대신 패치만 보내면 됩니다.

ts
import { parsePatch } from 'diffine-react/patch';

const [file] = parsePatch(await response.text());

file.before; // `---` 줄에 적힌 이름, 여기서는 'a/src/index.ts'
file.result; // `diffText`가 돌려주는 것과 같은 형태
dart
final DiffPatchFile file = parsePatch(response.body).first;

file.before; // `---` 줄에 적힌 이름, 여기서는 'a/lib/main.dart'
file.result; // `diffText`가 돌려주는 것과 같은 형태

패치가 다루는 파일마다 하나씩, 적힌 순서대로 돌아옵니다. 두 번째 인자는 diffText가 받는 옵션 그대로라, 바뀐 두 줄 안에서 단어를 골라내는 기준을 양쪽 페이지에서 똑같이 맞출 수 있습니다.

형식에 없는 것을 지어내지는 않습니다. 한 헝크가 끝나고 다음 헝크가 시작하는 사이의 줄은 패치에 없으므로 그 지점에서 줄 번호가 건너뜁니다. 각 줄의 index는 원래 파일에서의 번호 그대로이고, result.before는 패치가 실어 온 줄만 담습니다. 뷰어는 그 지점에 몇 줄이 빠져 있는지 적힌 띠를 그립니다. 헝크를 둘러싼 나머지는 읽지 않고 넘깁니다. diff --git 줄, 모드와 인덱스 줄, 파일이 개행으로 끝나지 않는다는 표시가 여기에 해당합니다.

반대 방향은 formatPatch입니다. 내보내기 버튼이나, 비교 결과를 다른 도구에 넘겨야 하는 경우에 씁니다.

ts
import { formatPatch } from 'diffine-react/patch';

formatPatch(diffText(saved, draft), {
  before: 'a/src/index.ts',
  after: 'b/src/index.ts'
});
dart
formatPatch(
  diffText(saved, draft),
  const DiffPatchOptions(before: 'a/lib/main.dart', after: 'b/lib/main.dart'),
);
옵션설명기본값
context변경 앞뒤로 남길 그대로인 줄 수.3
before--- 줄에 적을 이름.'before'
after+++ 줄에 적을 이름.'after'

두 문서가 같으면 헤더만 있는 문자열이 아니라 빈 문자열이 돌아옵니다. 쓸 내용이 있었는지를 값 자체가 말해 줍니다. git applypatch로 적용할 패치라면 두 줄 모두에 실제 경로가 있어야 하는데, 그 자리가 이 두 이름입니다.

동작 방식

Eugene Myers가 1986년에 발표한 방법을 그 논문 후반부의 형태로 구현했습니다. 편집 그래프를 앞에서부터, 그리고 뒤에서부터 동시에 걸어가다가 둘이 만나는 지점에서 멈추고, 그 지점의 일치 구간을 기준으로 양옆을 다시 같은 방법으로 나눕니다. 한 걸음마다 두 문서를 한 번씩 훑고 그래프 전체가 아니라 한 줄만 들고 있기 때문에 큰 파일도 열립니다.

그 위에 두 가지가 얹혀 있습니다. 줄이 빠지고 들어온 구간 안에서는 위에서부터 차례로 짝을 짓지 않고 닮은 줄끼리 짝을 짓습니다. 그러지 않으면 줄을 고치면서 동시에 끼워 넣은 구간에서 끼워 넣은 줄 아래가 전부 어긋납니다. 그리고 짝지어진 두 줄 안에서 같은 탐색이 단어나 자소 단위로 한 번 더 돕니다.

Released under the MIT License