Some checks failed
Publish codemirror-helix / publish (push) Failing after 2m16s
181 lines
5.8 KiB
TypeScript
181 lines
5.8 KiB
TypeScript
import type { EditorState } from "@codemirror/state";
|
|
|
|
export type CharClass = "space" | "word" | "punct";
|
|
|
|
export function charAt(state: EditorState, i: number): string {
|
|
return i >= 0 && i < state.doc.length ? state.doc.sliceString(i, i + 1) : "";
|
|
}
|
|
|
|
export function classOf(ch: string): CharClass {
|
|
if (ch === "" || /\s/.test(ch)) return "space";
|
|
if (/[\p{L}\p{N}_]/u.test(ch)) return "word";
|
|
return "punct";
|
|
}
|
|
|
|
// For WORD (big) motions only whitespace separates tokens.
|
|
function bigClassOf(ch: string): CharClass {
|
|
return ch === "" || /\s/.test(ch) ? "space" : "word";
|
|
}
|
|
|
|
function classAt(state: EditorState, i: number, big: boolean): CharClass {
|
|
const ch = charAt(state, i);
|
|
return big ? bigClassOf(ch) : classOf(ch);
|
|
}
|
|
|
|
/** Helix `w`/`W`: move to the start of the next word. */
|
|
export function nextWordStart(state: EditorState, pos: number, big: boolean): number {
|
|
const len = state.doc.length;
|
|
let i = pos;
|
|
if (i >= len) return len;
|
|
if (classAt(state, i, big) === "space") {
|
|
while (i < len && classAt(state, i, big) === "space") i++;
|
|
} else {
|
|
const cls = classAt(state, i, big);
|
|
while (i < len && classAt(state, i, big) === cls) i++;
|
|
while (i < len && classAt(state, i, big) === "space") i++;
|
|
}
|
|
return i;
|
|
}
|
|
|
|
/** Helix `b`/`B`: move to the start of the previous word. */
|
|
export function prevWordStart(state: EditorState, pos: number, big: boolean): number {
|
|
let i = pos;
|
|
if (i <= 0) return 0;
|
|
i--;
|
|
while (i > 0 && classAt(state, i, big) === "space") i--;
|
|
const cls = classAt(state, i, big);
|
|
while (i > 0 && classAt(state, i - 1, big) === cls) i--;
|
|
return i;
|
|
}
|
|
|
|
/** Helix `e`/`E`: move to the end of the next word. */
|
|
export function nextWordEnd(state: EditorState, pos: number, big: boolean): number {
|
|
const len = state.doc.length;
|
|
let i = pos + 1;
|
|
while (i < len && classAt(state, i, big) === "space") i++;
|
|
const cls = classAt(state, i, big);
|
|
while (i < len && classAt(state, i, big) === cls) i++;
|
|
return Math.min(i, len);
|
|
}
|
|
|
|
export function firstNonBlank(state: EditorState, line: number): number {
|
|
const l = state.doc.line(line);
|
|
return l.from + (l.text.match(/^\s*/)?.[0].length ?? 0);
|
|
}
|
|
|
|
/** f/t/F/T: find `target` from `pos`. Returns null if not found on the line. */
|
|
export function findCharOnLine(
|
|
state: EditorState,
|
|
pos: number,
|
|
target: string,
|
|
kind: "f" | "t" | "F" | "T",
|
|
): number | null {
|
|
const line = state.doc.lineAt(pos);
|
|
const forward = kind === "f" || kind === "t";
|
|
const till = kind === "t" || kind === "T";
|
|
|
|
if (forward) {
|
|
let i = pos + 1;
|
|
// `t` should skip an adjacent target so repeated presses advance.
|
|
if (till && charAt(state, i) === target) i++;
|
|
for (; i <= line.to; i++) {
|
|
if (charAt(state, i) === target) return till ? i - 1 : i;
|
|
}
|
|
} else {
|
|
let i = pos - 1;
|
|
if (till && charAt(state, i) === target) i--;
|
|
for (; i >= line.from; i--) {
|
|
if (charAt(state, i) === target) return till ? i + 1 : i;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const PAIRS: Record<string, { open: string; close: string }> = {
|
|
"(": { open: "(", close: ")" },
|
|
")": { open: "(", close: ")" },
|
|
"[": { open: "[", close: "]" },
|
|
"]": { open: "[", close: "]" },
|
|
"{": { open: "{", close: "}" },
|
|
"}": { open: "{", close: "}" },
|
|
"<": { open: "<", close: ">" },
|
|
">": { open: "<", close: ">" },
|
|
};
|
|
|
|
export function pairFor(ch: string): { open: string; close: string } | null {
|
|
return PAIRS[ch] ?? null;
|
|
}
|
|
|
|
/** Scan forward for the close that matches an open at/after `pos` (nesting-aware). */
|
|
export function findClose(state: EditorState, from: number, open: string, close: string): number | null {
|
|
const len = state.doc.length;
|
|
let depth = 0;
|
|
for (let i = from; i < len; i++) {
|
|
const ch = charAt(state, i);
|
|
if (ch === open) depth++;
|
|
else if (ch === close) {
|
|
depth--;
|
|
if (depth === 0) return i;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Scan backward for the open that matches a close at/before `pos` (nesting-aware). */
|
|
export function findOpen(state: EditorState, from: number, open: string, close: string): number | null {
|
|
let depth = 0;
|
|
for (let i = from; i >= 0; i--) {
|
|
const ch = charAt(state, i);
|
|
if (ch === close) depth++;
|
|
else if (ch === open) {
|
|
depth--;
|
|
if (depth === 0) return i;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** `mm`: position of the bracket matching the one at/under `pos`, or null. */
|
|
export function matchingBracket(state: EditorState, pos: number): number | null {
|
|
for (const probe of [pos, pos - 1]) {
|
|
const ch = charAt(state, probe);
|
|
const pair = pairFor(ch);
|
|
if (!pair) continue;
|
|
if (ch === pair.open) return findClose2(state, probe, pair.open, pair.close);
|
|
return findOpen2(state, probe, pair.open, pair.close);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findClose2(state: EditorState, openPos: number, open: string, close: string): number | null {
|
|
const len = state.doc.length;
|
|
let depth = 0;
|
|
for (let i = openPos; i < len; i++) {
|
|
const ch = charAt(state, i);
|
|
if (ch === open) depth++;
|
|
else if (ch === close && --depth === 0) return i;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findOpen2(state: EditorState, closePos: number, open: string, close: string): number | null {
|
|
let depth = 0;
|
|
for (let i = closePos; i >= 0; i--) {
|
|
const ch = charAt(state, i);
|
|
if (ch === close) depth++;
|
|
else if (ch === open && --depth === 0) return i;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Paragraph bounds (blank-line delimited) containing `pos`. */
|
|
export function paragraphAt(state: EditorState, pos: number): { from: number; to: number } {
|
|
const { doc } = state;
|
|
let startLine = doc.lineAt(pos).number;
|
|
let endLine = startLine;
|
|
const blank = (n: number) => doc.line(n).text.trim() === "";
|
|
while (startLine > 1 && !blank(startLine - 1)) startLine--;
|
|
while (endLine < doc.lines && !blank(endLine + 1)) endLine++;
|
|
return { from: doc.line(startLine).from, to: doc.line(endLine).to };
|
|
}
|