@pierre/diffs
Version:
365 lines (364 loc) • 15.5 kB
JavaScript
//#region src/editor/editPrediction.ts
const EDITABLE_TOKENS = 350;
const CONTEXT_TOKENS = 150;
const MAX_EDITABLE_TOKENS = 512;
const MAX_CONTEXT_TOKENS = 662;
const MAX_REQUEST_BYTES = 128 * 1024;
const MAX_CAPTURE_BYTES = 6144;
const COALESCE_MS = 1e3;
const COALESCE_LINES = 8;
const DIFF_CONTEXT_LINES = 3;
const CAPTURE_CONTEXT_OPTIONS = [11, DIFF_CONTEXT_LINES];
const textEncoder = new TextEncoder();
function lineStarts(text) {
const starts = [0];
for (let index = 0; index < text.length; index++) {
if (text.charCodeAt(index) === 13 && text.charCodeAt(index + 1) === 10) index++;
if (text.charCodeAt(index) === 10 || text.charCodeAt(index) === 13) starts.push(index + 1);
}
return starts;
}
function diffLineCount(text, starts) {
if (text.length === 0) return 0;
return starts.at(-1) === text.length ? starts.length - 1 : starts.length;
}
function lineEnd(text, starts, line) {
const next = starts[line + 1];
return next === void 0 ? text.length : next - (text.charCodeAt(next - 1) === 10 && text.charCodeAt(next - 2) === 13 ? 2 : 1);
}
function linesEqual(left, leftStarts, leftLine, right, rightStarts, rightLine) {
const leftStart = leftStarts[leftLine];
const rightStart = rightStarts[rightLine];
const length = lineEnd(left, leftStarts, leftLine) - leftStart;
if (length !== lineEnd(right, rightStarts, rightLine) - rightStart) return false;
for (let index = 0; index < length; index++) if (left.charCodeAt(leftStart + index) !== right.charCodeAt(rightStart + index)) return false;
return true;
}
function lineDiffBounds(oldText, oldStarts, newText, newStarts) {
const oldLineCount = diffLineCount(oldText, oldStarts);
const newLineCount = diffLineCount(newText, newStarts);
let prefixLines = 0;
while (prefixLines < oldLineCount && prefixLines < newLineCount) {
if (!linesEqual(oldText, oldStarts, prefixLines, newText, newStarts, prefixLines)) break;
prefixLines++;
}
let suffixLines = 0;
while (suffixLines < oldLineCount - prefixLines && suffixLines < newLineCount - prefixLines) {
if (!linesEqual(oldText, oldStarts, oldLineCount - 1 - suffixLines, newText, newStarts, newLineCount - 1 - suffixLines)) break;
suffixLines++;
}
return {
oldLineCount,
newLineCount,
prefixLines,
suffixLines
};
}
function formatEditHunk(path, oldText, newText, lineOffset = 0) {
if (oldText === newText) return;
const oldStarts = lineStarts(oldText);
const newStarts = lineStarts(newText);
const bounds = lineDiffBounds(oldText, oldStarts, newText, newStarts);
if (bounds.prefixLines === bounds.oldLineCount && bounds.prefixLines === bounds.newLineCount) return;
const oldChangedEnd = bounds.oldLineCount - bounds.suffixLines;
const newChangedEnd = bounds.newLineCount - bounds.suffixLines;
const oldChanged = oldText.slice(oldStarts[bounds.prefixLines] ?? oldText.length, oldStarts[oldChangedEnd] ?? oldText.length);
const newChanged = newText.slice(newStarts[bounds.prefixLines] ?? newText.length, newStarts[newChangedEnd] ?? newText.length);
if (textEncoder.encode(oldChanged).byteLength > MAX_CAPTURE_BYTES || textEncoder.encode(newChanged).byteLength > MAX_CAPTURE_BYTES) return;
const start = Math.max(0, bounds.prefixLines - DIFF_CONTEXT_LINES);
const oldEnd = Math.min(bounds.oldLineCount, oldChangedEnd + DIFF_CONTEXT_LINES);
const newEnd = Math.min(bounds.newLineCount, newChangedEnd + DIFF_CONTEXT_LINES);
const oldCount = oldEnd - start;
const newCount = newEnd - start;
const line = start + lineOffset;
const output = [
`--- a/${path}`,
`+++ b/${path}`,
`@@ -${oldCount === 0 ? line : line + 1},${oldCount} +${newCount === 0 ? line : line + 1},${newCount} @@`
];
for (let line = start; line < bounds.prefixLines; line++) output.push(` ${oldText.slice(oldStarts[line], lineEnd(oldText, oldStarts, line))}`);
for (let line = bounds.prefixLines; line < oldChangedEnd; line++) output.push(`-${oldText.slice(oldStarts[line], lineEnd(oldText, oldStarts, line))}`);
for (let line = bounds.prefixLines; line < newChangedEnd; line++) output.push(`+${newText.slice(newStarts[line], lineEnd(newText, newStarts, line))}`);
for (let line = oldChangedEnd; line < oldEnd; line++) output.push(` ${oldText.slice(oldStarts[line], lineEnd(oldText, oldStarts, line))}`);
const hunk = output.join("\n");
return textEncoder.encode(hunk).byteLength <= MAX_CAPTURE_BYTES ? {
hunk,
bounds
} : void 0;
}
function applyEditsToSlice(text, sliceStart, edits) {
const chunks = [];
let offset = 0;
for (const edit of edits) {
const start = edit.start - sliceStart;
const end = edit.end - sliceStart;
if (start < offset || end < start || end > text.length) return;
chunks.push(text.slice(offset, start), edit.text);
offset = end;
}
chunks.push(text.slice(offset));
return chunks.join("");
}
function captureEditPredictionTransaction(document, transaction) {
const inverseEdits = transaction.inverseEdits;
if (inverseEdits.length === 0) return;
let changedStart = inverseEdits[0].start;
let changedEnd = inverseEdits[0].end;
for (let index = 1; index < inverseEdits.length; index++) {
changedStart = Math.min(changedStart, inverseEdits[index].start);
changedEnd = Math.max(changedEnd, inverseEdits[index].end);
}
const [startPosition, endPosition] = document.positionsAt([changedStart, changedEnd]);
for (const contextLines of CAPTURE_CONTEXT_OPTIONS) {
const startLine = Math.max(0, startPosition.line - contextLines);
const endLine = Math.min(document.lineCount - 1, endPosition.line + contextLines);
const afterStart = document.offsetAt({
line: startLine,
character: 0
});
const afterEnd = endLine + 1 < document.lineCount ? document.offsetAt({
line: endLine + 1,
character: 0
}) : document.offsetAt({
line: endLine,
character: document.getLineLength(endLine)
});
if (afterEnd - afterStart > MAX_CAPTURE_BYTES) continue;
const afterText = document.getTextSlice(afterStart, afterEnd);
if (textEncoder.encode(afterText).byteLength > MAX_CAPTURE_BYTES) continue;
const beforeText = applyEditsToSlice(afterText, afterStart, inverseEdits);
if (beforeText === void 0 || beforeText.length > MAX_CAPTURE_BYTES || textEncoder.encode(beforeText).byteLength > MAX_CAPTURE_BYTES) continue;
return {
beforeText,
afterText,
startOffset: afterStart,
startLine,
bounds: lineDiffBounds(beforeText, lineStarts(beforeText), afterText, lineStarts(afterText))
};
}
}
function recordEditPrediction(history, path, document, transaction, source, at = Date.now()) {
const kept = history.slice(-10);
const fragment = captureEditPredictionTransaction(document, transaction);
if (fragment === void 0) {
const previous = kept.at(-1);
if (previous?.fragment !== void 0) kept[kept.length - 1] = {
...previous,
fragment: void 0
};
return kept;
}
if (fragment.beforeText === fragment.afterText) return kept;
const changedStartLine = fragment.startLine + fragment.bounds.prefixLines;
const beforeChangedEndLine = fragment.startLine + fragment.bounds.oldLineCount - fragment.bounds.suffixLines;
const last = kept.at(-1);
const gap = last !== void 0 && changedStartLine > last.end ? changedStartLine - last.end : last !== void 0 && last.start > beforeChangedEndLine ? last.start - beforeChangedEndLine : 0;
if (last !== void 0 && last.fragment !== void 0 && last.path === path && last.source === source && at - last.at < COALESCE_MS && gap <= COALESCE_LINES) {
const previous = last.fragment;
const beforeEnd = fragment.startOffset + fragment.beforeText.length;
const overlapStart = Math.max(previous.currentStart, fragment.startOffset);
const overlapEnd = Math.min(previous.currentEnd, beforeEnd);
if (overlapStart <= overlapEnd && previous.currentText.slice(overlapStart - previous.currentStart, overlapEnd - previous.currentStart) === fragment.beforeText.slice(overlapStart - fragment.startOffset, overlapEnd - fragment.startOffset)) {
const unionStart = Math.min(previous.currentStart, fragment.startOffset);
const currentText = previous.currentStart <= fragment.startOffset ? previous.currentText + fragment.beforeText.slice(Math.max(0, previous.currentEnd - fragment.startOffset)) : fragment.beforeText + previous.currentText.slice(Math.max(0, beforeEnd - previous.currentStart));
const prefix = currentText.slice(0, previous.currentStart - unionStart);
const suffix = currentText.slice(previous.currentEnd - unionStart);
const baseText = prefix + previous.baseText + suffix;
const nextText = applyEditsToSlice(currentText, unionStart, transaction.appliedEdits);
const startLine = previous.currentStart <= fragment.startOffset ? previous.startLine : fragment.startLine;
if (nextText !== void 0 && baseText.length <= MAX_CAPTURE_BYTES && nextText.length <= MAX_CAPTURE_BYTES && textEncoder.encode(baseText).byteLength <= MAX_CAPTURE_BYTES && textEncoder.encode(nextText).byteLength <= MAX_CAPTURE_BYTES) {
if (baseText === nextText) {
kept.pop();
return kept;
}
const formatted = formatEditHunk(path, baseText, nextText, startLine);
if (formatted !== void 0) {
kept[kept.length - 1] = {
path,
hunk: formatted.hunk,
start: startLine + formatted.bounds.prefixLines,
end: startLine + formatted.bounds.newLineCount - formatted.bounds.suffixLines,
at,
source,
fragment: {
baseText,
currentText: nextText,
currentStart: unionStart,
currentEnd: unionStart + nextText.length,
startLine
}
};
return kept;
}
}
}
}
const previous = kept.at(-1);
if (previous?.fragment !== void 0) kept[kept.length - 1] = {
...previous,
fragment: void 0
};
const formatted = formatEditHunk(path, fragment.beforeText, fragment.afterText, fragment.startLine);
if (formatted === void 0) return kept;
kept.push({
path,
hunk: formatted.hunk,
start: changedStartLine,
end: fragment.startLine + fragment.bounds.newLineCount - fragment.bounds.suffixLines,
at,
source,
fragment: {
baseText: fragment.beforeText,
currentText: fragment.afterText,
currentStart: fragment.startOffset,
currentEnd: fragment.startOffset + fragment.afterText.length,
startLine: fragment.startLine
}
});
return kept.slice(-10);
}
function expandLinewise(lineCount, costForLine, canExpandTo, first, last, remaining) {
while (remaining > 0 && (first > 0 || last < lineCount - 1)) {
let expanded = false;
if (first > 0 && canExpandTo(first - 1)) {
const cost = costForLine(first - 1);
if (cost <= remaining) {
first--;
remaining -= cost;
expanded = true;
}
}
if (last < lineCount - 1 && canExpandTo(last + 1)) {
const cost = costForLine(last + 1);
if (cost <= remaining) {
last++;
remaining -= cost;
expanded = true;
}
}
if (!expanded) break;
}
return {
first,
last
};
}
function buildEditPredictionRequest(path, document, cursorOffset, history, isLineEditable) {
if (document.lineCount <= 0) return;
const lastLine = document.lineCount - 1;
const documentLength = document.offsetAt({
line: lastLine,
character: document.getLineLength(lastLine)
});
let cursor = Math.max(0, Math.min(Number.isFinite(cursorOffset) ? Math.trunc(cursorOffset) : 0, documentLength));
const previous = document.charAt(cursor - 1).charCodeAt(0);
const next = document.charAt(cursor).charCodeAt(0);
if (cursor > 0 && cursor < documentLength && (previous === 13 && next === 10 || previous >= 55296 && previous <= 56319 && next >= 56320 && next <= 57343)) cursor--;
const cursorLine = document.positionAt(cursor).line;
const editableLines = /* @__PURE__ */ new Map();
const canEdit = (line) => {
if (line < 0 || line >= document.lineCount) return false;
let editable = editableLines.get(line);
if (editable == null) {
editable = isLineEditable(line);
editableLines.set(line, editable);
}
return editable;
};
if (!canEdit(cursorLine)) return;
const tokenCosts = /* @__PURE__ */ new Map();
const costForLine = (line) => {
const cached = tokenCosts.get(line);
if (cached !== void 0) return cached;
const lineLength = document.getLineLength(line);
if (Math.floor(lineLength / 3) > MAX_CONTEXT_TOKENS) {
const cost = 663;
tokenCosts.set(line, cost);
return cost;
}
const cost = Math.max(1, Math.floor(textEncoder.encode(document.getLineText(line)).byteLength / 3));
tokenCosts.set(line, cost);
return cost;
};
let editableFirst = cursorLine;
let editableLast = cursorLine;
const initialBudget = Math.floor(EDITABLE_TOKENS * 3 / 4);
let remaining = Math.max(0, initialBudget - costForLine(cursorLine));
while (remaining > 0 && (canEdit(editableFirst - 1) || canEdit(editableLast + 1))) {
if (canEdit(editableLast + 1)) {
const cost = costForLine(editableLast + 1);
if (cost > remaining) break;
editableLast++;
remaining -= cost;
}
if (canEdit(editableFirst - 1) && remaining > 0) {
const cost = costForLine(editableFirst - 1);
if (cost > remaining) break;
editableFirst--;
remaining -= cost;
}
}
remaining += EDITABLE_TOKENS - initialBudget;
({first: editableFirst, last: editableLast} = expandLinewise(document.lineCount, costForLine, canEdit, editableFirst, editableLast, remaining));
let contextFirst = editableFirst;
let contextLast = editableLast;
({first: contextFirst, last: contextLast} = expandLinewise(document.lineCount, costForLine, () => true, contextFirst, contextLast, CONTEXT_TOKENS));
let editableTokens = 0;
for (let line = editableFirst; line <= editableLast; line++) editableTokens += costForLine(line);
let contextTokens = 0;
for (let line = contextFirst; line <= contextLast; line++) contextTokens += costForLine(line);
if (editableTokens > MAX_EDITABLE_TOKENS || contextTokens > MAX_CONTEXT_TOKENS) return;
const contextStart = document.offsetAt({
line: contextFirst,
character: 0
});
const contextEnd = document.offsetAt({
line: contextLast,
character: document.getLineLength(contextLast)
});
const excerptText = document.getTextSlice(contextStart, contextEnd);
const request = {
path,
version: document.version,
eol: document.eol,
excerptText,
excerptStartLine: contextFirst,
cursorOffsetInExcerpt: cursor - contextStart,
editableRange: {
start: document.offsetAt({
line: editableFirst,
character: 0
}) - contextStart,
end: document.offsetAt({
line: editableLast,
character: document.getLineLength(editableLast)
}) - contextStart
},
editHistory: history.slice(-10).map(({ hunk, source }) => ({
diff: hunk,
source
}))
};
return textEncoder.encode(JSON.stringify(request)).byteLength <= MAX_REQUEST_BYTES ? request : void 0;
}
function matchesEditPredictionPattern(path, pattern) {
if (typeof pattern !== "string") return new RegExp(pattern.source, pattern.flags).test(path);
pattern = pattern.replaceAll("\\", "/");
let source = "^";
for (let index = 0; index < pattern.length; index++) {
const character = pattern[index];
if (character === "*") if (pattern[index + 1] === "*") {
index++;
if (pattern[index + 1] === "/") {
index++;
source += "(?:.*/)?";
} else source += ".*";
} else source += "[^/]*";
else if (character === "?") source += "[^/]";
else source += /[\\^$.*+?()[\]{}|]/.test(character) ? `\\${character}` : character;
}
return new RegExp(`${source}$`).test(path);
}
//#endregion
export { buildEditPredictionRequest, matchesEditPredictionPattern, recordEditPrediction };
//# sourceMappingURL=editPrediction.js.map