import { QWERTY_GRAPH } from "../../graphs/qwerty/qwerty.js"; import { validateGraph } from "../../graphs/validate-graph.js"; import type { KeyboardGraph, KeyboardGraphMap, KeyboardMatch, } from "../types.js"; export interface KeyboardMatchOptions { graphs?: KeyboardGraphMap; } /** * `keyboardMatch` — find every substring that traces a walk on a keyboard * adjacency graph per Wheeler 2016 section 3.5. Each match carries its graph * name, number of direction changes (`turns`), and number of shift-required * presses (`shiftedCount`). */ export function keyboardMatch( password: string, opts: KeyboardMatchOptions = {} ): KeyboardMatch[] { const graphs: KeyboardGraphMap = opts.graphs ?? { qwerty: QWERTY_GRAPH }; for (const [name, graph] of Object.entries(graphs)) { validateGraph(name, graph); } const out: KeyboardMatch[] = []; for (const [graphName, graph] of Object.entries(graphs)) { out.push(...walkGraph(password, graphName, graph)); } return out; } interface SlotHit { slot: number; shifted: boolean; } function findSlot( graph: KeyboardGraph, prev: string, next: string ): SlotHit | undefined { const adjacencies = graph[prev]; if (!adjacencies) return undefined; for (let s = 0; s < adjacencies.length; s++) { const slot = adjacencies[s]; if (slot === null || slot === undefined) continue; const unshifted = slot[0]; const shifted = slot[1]; if (next === unshifted) return { slot: s, shifted: false }; if (next === shifted) return { slot: s, shifted: true }; } return undefined; } function isShifted(graph: KeyboardGraph, ch: string): boolean { if (graph[ch]) return false; for (const adjs of Object.values(graph)) { for (const slot of adjs) { if (slot && slot[1] === ch) return true; } } return false; } function walkGraph( password: string, graphName: string, graph: KeyboardGraph ): KeyboardMatch[] { const out: KeyboardMatch[] = []; if (password.length < 2) return out; let i = 0; while (i < password.length - 1) { let j = i; let lastSlot = -1; let turns = 0; let shiftedCount = isShifted(graph, password[i] as string) ? 1 : 0; while (j < password.length - 1) { const cur = (password[j] as string).toLowerCase(); const next = password[j + 1] as string; const hit = findSlot(graph, cur, next); if (!hit) break; if (lastSlot !== -1 && hit.slot !== lastSlot) turns++; lastSlot = hit.slot; if (hit.shifted) shiftedCount++; j++; } if (j - i + 1 >= 2) { out.push({ pattern: "spatial", i, j, token: password.slice(i, j + 1), graph: graphName, turns, shiftedCount, }); } i = j + 1; } return out; }