zmk/docs/src/keymap-upgrade/textedit.ts
Joel Spadin 6ec7cebd5e feat(keymap-upgrader): Highlight changes
Updated the keymap upgrader to highlight which lines it changed as well
as indicate when nothing needed to be upgraded.

Also adjusted the line highlight colors to be more readable in both
light and dark color schemes.
2024-01-22 12:43:45 -06:00

166 lines
4.4 KiB
TypeScript

import type { SyntaxNode } from "web-tree-sitter";
export class Range {
constructor(public startIndex: number, public endIndex: number) {}
}
export class TextEdit extends Range {
newText: string;
/**
* Creates a text edit to replace a range with new text.
*/
constructor(startIndex: number, endIndex: number, newText: string) {
super(startIndex, endIndex);
this.newText = newText;
}
static fromNode(node: SyntaxNode | Range, newText: string) {
return new TextEdit(node.startIndex, node.endIndex, newText);
}
}
export type MatchFunc = (node: SyntaxNode, text: string) => boolean;
export type ReplaceFunc = (
node: SyntaxNode,
replacement: string | null
) => TextEdit[];
/**
* Gets a list of text edits to apply based on a node and a map of text
* replacements.
*
* If replaceHandler is given, it will be called if the node matches a
* deprecated value and it should return the text edits to apply.
* Otherwise, the full node is replaced by the new text.
*
* If isMatch is given, it will be called to check if a node matches a
* deprecated value. Otherwise, the node's text is matched against the
* deprecated text.
*
* @param {SyntaxNode} node
* @param {Record<string, string | null>} replacementMap
* @param {ReplaceFunc} [replaceHandler]
* @param {MatchFunc} [isMatch]
*/
export function getUpgradeEdits(
node: SyntaxNode,
replacementMap: Record<string, string | null>,
replaceHandler?: ReplaceFunc,
isMatch?: MatchFunc
) {
const defaultReplace: ReplaceFunc = (node, replacement) => [
TextEdit.fromNode(node, replacement ?? ""),
];
const defaultMatch: MatchFunc = (node, text) => node.text === text;
replaceHandler = replaceHandler ?? defaultReplace;
isMatch = isMatch ?? defaultMatch;
for (const [deprecated, replacement] of Object.entries(replacementMap)) {
if (isMatch(node, deprecated)) {
return replaceHandler(node, replacement);
}
}
return [];
}
/**
* Sorts a list of text edits in ascending order by position.
*/
function sortEdits(edits: TextEdit[]) {
return edits.sort((a, b) => a.startIndex - b.startIndex);
}
export interface EditResult {
text: string;
changedRanges: Range[];
}
interface TextChunk {
text: string;
changed?: boolean;
}
/**
* Returns a string with text replacements applied and a list of ranges within
* that string that were modified.
*/
export function applyEdits(text: string, edits: TextEdit[]) {
// If we are removing text and it's the only thing on a line, remove the whole line.
edits = edits.map((e) => (e.newText ? e : expandEditToLine(text, e)));
edits = sortEdits(edits);
const chunks: TextChunk[] = [];
let currentIndex = 0;
for (let edit of edits) {
if (edit.startIndex < currentIndex) {
console.warn("discarding overlapping edit", edit);
continue;
}
chunks.push({ text: text.substring(currentIndex, edit.startIndex) });
chunks.push({ text: edit.newText, changed: true });
currentIndex = edit.endIndex;
}
chunks.push({ text: text.substring(currentIndex) });
// Join all of the text chunks while recording the ranges of any chunks that were changed.
return chunks.reduce<EditResult>(
(prev, current) => {
return {
text: prev.text + current.text,
changedRanges: reduceChangedRanges(prev, current),
};
},
{ text: "", changedRanges: [] }
);
}
function reduceChangedRanges(prev: EditResult, current: TextChunk): Range[] {
if (current.changed) {
return [
...prev.changedRanges,
new Range(prev.text.length, prev.text.length + current.text.length),
];
}
return prev.changedRanges;
}
/**
* If the given edit is surrounded by only whitespace on a line, expands it to
* replace the entire line, else returns it unmodified.
*/
function expandEditToLine(text: string, edit: TextEdit) {
// Expand the selection to adjacent whitespace
let newStart = edit.startIndex;
let newEnd = edit.endIndex;
while (newStart > 0 && text[newStart - 1].match(/[ \t]/)) {
newStart--;
}
while (newEnd < text.length && text[newEnd].match(/[ \t]/)) {
newEnd++;
}
// Check that we selected the entire line
if (
(newEnd !== text.length && text[newEnd] !== "\n") ||
(newStart > 0 && text[newStart - 1] !== "\n")
) {
return edit;
}
// Select one of the line breaks to remove.
if (newEnd !== text.length) {
newEnd++;
} else if (newStart !== 0) {
newStart--;
}
return new TextEdit(newStart, newEnd, edit.newText);
}