diff --git a/src/adapters/chrome/content-script/suggestions/HostEditorBridgeProtocol.ts b/src/adapters/chrome/content-script/suggestions/HostEditorBridgeProtocol.ts index 73f210df..c3c85fc0 100644 --- a/src/adapters/chrome/content-script/suggestions/HostEditorBridgeProtocol.ts +++ b/src/adapters/chrome/content-script/suggestions/HostEditorBridgeProtocol.ts @@ -2,3 +2,5 @@ export const HOST_EDITOR_REQUEST_EVENT = "ft-host-editor-request"; export const HOST_EDITOR_REQUEST_ATTR = "data-ft-host-editor-request"; export const HOST_EDITOR_RESPONSE_ATTR = "data-ft-host-editor-response"; export const HOST_EDITOR_MAIN_WORLD_FLAG = "__ftHostEditorBridgeInstalled"; +export const CURSOR_MOVE_EVENT = "ft-cursor-move"; +export const CURSOR_MOVE_COUNT_ATTR = "data-ft-cursor-move-count"; diff --git a/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts b/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts index f565732f..2bdd1442 100644 --- a/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts +++ b/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts @@ -1,4 +1,6 @@ import { + CURSOR_MOVE_COUNT_ATTR, + CURSOR_MOVE_EVENT, HOST_EDITOR_MAIN_WORLD_FLAG, HOST_EDITOR_REQUEST_ATTR, HOST_EDITOR_REQUEST_EVENT, @@ -146,6 +148,34 @@ export function installHostEditorMainWorldBridge(doc: Document = document): void (win as Window & { [HOST_EDITOR_MAIN_WORLD_FLAG]?: boolean })[HOST_EDITOR_MAIN_WORLD_FLAG] = true; + // Cursor movement bridge: content script (isolated world) dispatches this + // event when it needs to reposition the cursor in the main world. Running + // Selection.modify() in the main world triggers native selectionchange events + // that React-based editors (Lexical, Slate) listen for to sync their internal + // selection state. + doc.addEventListener( + CURSOR_MOVE_EVENT, + (event) => { + const source = event.composedPath()[0]; + if (!(source instanceof HTMLElement)) { + return; + } + const rawCount = source.getAttribute(CURSOR_MOVE_COUNT_ATTR); + const count = rawCount ? parseInt(rawCount, 10) : 0; + if (!Number.isFinite(count) || count <= 0) { + return; + } + const sel = win.getSelection(); + if (!sel) { + return; + } + for (let i = 0; i < count; i++) { + sel.modify("move", "backward", "character"); + } + }, + true, + ); + doc.addEventListener( HOST_EDITOR_REQUEST_EVENT, (event) => { diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts index 5cb954c2..a5d7e1b7 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts @@ -3,6 +3,7 @@ import type { GrammarEdit } from "@core/domain/grammar/types"; import { SPACING_RULES, Spacing } from "@core/domain/spacingRules"; import { ContentEditableAdapter, type ContentEditableEditResult } from "./ContentEditableAdapter"; import { HostEditorAdapterResolver, type HostEditorSession } from "./HostEditorAdapterResolver"; +import { CURSOR_MOVE_COUNT_ATTR, CURSOR_MOVE_EVENT } from "./HostEditorBridgeProtocol"; import { TextTargetAdapter, type TextTarget } from "./TextTargetAdapter"; import { buildCaretTrace, clipTraceText, collapseTraceWhitespace } from "./traceUtils"; import type { @@ -546,12 +547,15 @@ export class SuggestionTextEditService { blockSourceText = `${blockContext.beforeCursor}${blockContext.afterCursor}`; expectedBlockText = blockSourceText; expectedBlockText = `${expectedBlockText.slice(0, blockReplaceStart)}${replacement}${expectedBlockText.slice(blockReplaceEnd)}`; - expectedBlockCursorAfter = this.resolveCursorAfterTextEdit( - blockCursor, - blockReplaceStart, - blockReplaceEnd, - replacement, - ); + expectedBlockCursorAfter = + edit.cursorOffset !== undefined + ? blockReplaceStart + Math.max(0, Math.min(replacement.length, edit.cursorOffset)) + : this.resolveCursorAfterTextEdit( + blockCursor, + blockReplaceStart, + blockReplaceEnd, + replacement, + ); blockCursorAfter = expectedBlockCursorAfter; replaceStart = Math.max(0, blockStart + blockCursor - deleteBackwards); @@ -578,12 +582,15 @@ export class SuggestionTextEditService { return { applied: false, didDispatchInput: false, suppressedByManualRevert: true }; } - const cursorAfter = this.resolveCursorAfterTextEdit( - snapshot.cursorOffset, - replaceStart, - replaceEnd, - replacement, - ); + const cursorAfter = + edit.cursorOffset !== undefined + ? replaceStart + Math.max(0, Math.min(replacement.length, edit.cursorOffset)) + : this.resolveCursorAfterTextEdit( + snapshot.cursorOffset, + replaceStart, + replaceEnd, + replacement, + ); const applyResult = !TextTargetAdapter.isTextValue(entry.elem) && activeBlock !== null && @@ -611,12 +618,54 @@ export class SuggestionTextEditService { cursorAfter, { preferDomMutation: this.shouldPreferDomMutationForGrammar(entry.elem) }, ); + // For edits with cursorOffset on contenteditable, schedule deferred cursor + // repositioning BEFORE the didMutateDom check. React-based editors (Lexical, + // Slate) handle beforeinput by calling preventDefault() and reconciling the + // DOM asynchronously via microtask. This means didMutateDom is false at check + // time even though the edit WILL be applied. The deferred callback validates + // that the expected text appeared before moving the cursor. + if (edit.cursorOffset !== undefined && !TextTargetAdapter.isTextValue(entry.elem)) { + const moveBackCount = replacement.length - edit.cursorOffset; + if (moveBackCount > 0) { + const targetElem = entry.elem as HTMLElement; + // React-based editors (Lexical, Slate) reconcile the DOM asynchronously + // and override cursor positions set via setCaret. Dispatch a bridge event + // to the main-world script which calls Selection.modify() there. Running + // in the main world triggers native selectionchange events that host + // editors detect and use to sync their internal selection state. + let applied = false; + const applyModify = () => { + if (applied || !targetElem.isConnected) { + return; + } + // Validate that the replacement text actually appeared in the DOM + // before moving the cursor. This distinguishes async host reconciliation + // (Lexical) from true edit rejection. + const currentText = targetElem.textContent ?? ""; + if (!currentText.includes(replacement)) { + return; + } + applied = true; + targetElem.setAttribute(CURSOR_MOVE_COUNT_ATTR, String(moveBackCount)); + targetElem.dispatchEvent( + new CustomEvent(CURSOR_MOVE_EVENT, { bubbles: true, composed: true }), + ); + targetElem.removeAttribute(CURSOR_MOVE_COUNT_ATTR); + }; + // Schedule at multiple timing points to cover different editor + // reconciliation strategies. Only the first successful one applies. + requestAnimationFrame(applyModify); + setTimeout(applyModify, 30); + } + } + if (!applyResult.didMutateDom) { return { applied: false, didDispatchInput: false, }; } + let postEditSnapshot: SuggestionSnapshot | null = !TextTargetAdapter.isTextValue(entry.elem) && activeBlock !== null && diff --git a/src/core/domain/grammar/GrammarEditSequencing.ts b/src/core/domain/grammar/GrammarEditSequencing.ts index 3c2179e5..92843309 100644 --- a/src/core/domain/grammar/GrammarEditSequencing.ts +++ b/src/core/domain/grammar/GrammarEditSequencing.ts @@ -14,7 +14,13 @@ export function applyGrammarEditToContext( after = after.slice(edit.deleteForwards); } - before += edit.replacement; + if (edit.cursorOffset !== undefined) { + const offset = Math.max(0, Math.min(edit.replacement.length, edit.cursorOffset)); + before += edit.replacement.slice(0, offset); + after = edit.replacement.slice(offset) + after; + } else { + before += edit.replacement; + } return { ...context, @@ -32,6 +38,7 @@ export function mergeSequentialGrammarEdits(edits: GrammarEdit[]): GrammarEdit[] let mergedConfidence: GrammarEdit["confidence"] | undefined; let mergedSourceRuleId: GrammarEdit["sourceRuleId"] | undefined; let mergedSafetyTier: GrammarEdit["safetyTier"] | undefined; + let mergedCursorOffset: number | undefined; let accumulatedString = ""; let baseDeleteBackwards = 0; @@ -53,6 +60,9 @@ export function mergeSequentialGrammarEdits(edits: GrammarEdit[]): GrammarEdit[] if (edit.safetyTier) { mergedSafetyTier = edit.safetyTier; } + if (edit.cursorOffset !== undefined) { + mergedCursorOffset = keepAccumulated + edit.cursorOffset; + } } return [ @@ -60,6 +70,7 @@ export function mergeSequentialGrammarEdits(edits: GrammarEdit[]): GrammarEdit[] replacement: accumulatedString, deleteBackwards: baseDeleteBackwards, deleteForwards: totalDeleteForwards, + ...(mergedCursorOffset !== undefined ? { cursorOffset: mergedCursorOffset } : {}), ...(mergedConfidence ? { confidence: mergedConfidence } : {}), ...(mergedSourceRuleId ? { sourceRuleId: mergedSourceRuleId } : {}), ...(mergedSafetyTier ? { safetyTier: mergedSafetyTier } : {}), diff --git a/src/core/domain/grammar/implementations/AutoBracketCloseRule.ts b/src/core/domain/grammar/implementations/AutoBracketCloseRule.ts new file mode 100644 index 00000000..2fe963d1 --- /dev/null +++ b/src/core/domain/grammar/implementations/AutoBracketCloseRule.ts @@ -0,0 +1,132 @@ +import type { GrammarContext, GrammarEdit, GrammarEventType, GrammarRule } from "../types"; +import { isDeleteInputAction } from "./helpers/GenericRuleShared"; + +const PAIRS = new Map([ + ["(", ")"], + ["[", "]"], + ["{", "}"], + ["'", "'"], + ['"', '"'], + ["`", "`"], + ["<", ">"], + ["«", "»"], +]); + +const CLOSING_TO_OPENING = new Map(); +for (const [open, close] of PAIRS) { + CLOSING_TO_OPENING.set(close, open); +} + +const SYMMETRIC_QUOTES = new Set(["'", '"', "`"]); + +const WORD_CHAR_REGEX = /[\p{L}\p{N}]/u; + +export class AutoBracketCloseRule implements GrammarRule { + readonly id = "autoBracketClose" as const; + readonly name = "Auto-close Brackets and Quotes"; + readonly triggers: GrammarEventType[] = ["insertChar"]; + + apply(context: GrammarContext): GrammarEdit | null { + if (isDeleteInputAction(context)) { + return null; + } + + const { beforeCursor, afterCursor } = context; + if (beforeCursor.length === 0) { + return null; + } + + const typed = beforeCursor[beforeCursor.length - 1]; + + // Check for overtype first: user typed a closing char and afterCursor starts with the same. + // For symmetric quotes (', ", `), both the opening and closing char are identical, + // so overtype fires whenever the same quote appears ahead — this is a heuristic that + // matches IDE behavior (e.g., VS Code) but may skip over non-auto-inserted quotes. + if (CLOSING_TO_OPENING.has(typed) && afterCursor.length > 0 && afterCursor[0] === typed) { + return this.handleOvertype(beforeCursor, typed); + } + + // Check for auto-close: user typed an opening char + const closeChar = PAIRS.get(typed); + if (closeChar) { + return this.handleAutoClose(context, typed, closeChar); + } + + return null; + } + + private handleAutoClose( + context: GrammarContext, + openChar: string, + closeChar: string, + ): GrammarEdit | null { + const { beforeCursor, afterCursor } = context; + const beforeOpener = beforeCursor.slice(0, -1); + + // For symmetric quotes (', ", `): don't auto-close when preceded by a word character + // (it's likely an apostrophe/contraction like "it's" or closing quote) + if (SYMMETRIC_QUOTES.has(openChar)) { + if (beforeOpener.length > 0 && WORD_CHAR_REGEX.test(beforeOpener[beforeOpener.length - 1])) { + return null; + } + } + + // For < bracket: don't auto-close when preceded by a word character + // (it's likely a comparison operator or HTML tag, not a quotation bracket) + if (openChar === "<") { + if (beforeOpener.length > 0 && WORD_CHAR_REGEX.test(beforeOpener[beforeOpener.length - 1])) { + return null; + } + } + + // Don't auto-close if afterCursor already starts with the matching close char + // (avoids doubling: typing ( when cursor is already before )) + if (afterCursor.length > 0 && afterCursor[0] === closeChar) { + return null; + } + + return { + replacement: openChar + closeChar, + deleteBackwards: 1, + deleteForwards: 0, + cursorOffset: 1, + confidence: "medium", + safetyTier: "advanced", + sourceRuleId: "autoBracketClose", + description: `Auto-closed ${openChar}${closeChar}`, + }; + } + + private handleOvertype(beforeCursor: string, closeChar: string): GrammarEdit | null { + const beforeTyped = beforeCursor.slice(0, -1); + + // For > specifically: don't overtype when preceded by certain patterns + // that suggest comparison/shift operators (e.g., "a>", "1>", ">>") + if (closeChar === ">") { + if (beforeTyped.length > 0 && WORD_CHAR_REGEX.test(beforeTyped[beforeTyped.length - 1])) { + return null; + } + } + + // For symmetric quotes: only overtype when preceded by a word character. + // This distinguishes "user closing a quote" (e.g., "hello"|) from + // "engine re-processing after auto-close" (e.g., "|) which would oscillate. + if (SYMMETRIC_QUOTES.has(closeChar)) { + if (beforeTyped.length === 0 || !WORD_CHAR_REGEX.test(beforeTyped[beforeTyped.length - 1])) { + return null; + } + } + + // Cursor naturally lands at end of the single-char replacement, + // which is the correct position (after the closing char). + return { + replacement: closeChar, + deleteBackwards: 1, + deleteForwards: 1, + confidence: "high", + safetyTier: "advanced", + sourceRuleId: "autoBracketClose", + description: `Skipped over auto-inserted ${closeChar}`, + }; + } +} diff --git a/src/core/domain/grammar/ruleCatalog.ts b/src/core/domain/grammar/ruleCatalog.ts index 713a9b32..0f287bf4 100644 --- a/src/core/domain/grammar/ruleCatalog.ts +++ b/src/core/domain/grammar/ruleCatalog.ts @@ -313,6 +313,19 @@ export const GRAMMAR_RULE_CATALOG: readonly GrammarRuleCatalogEntry[] = [ defaultEnabled: false, priority: 133, }, + { + id: "autoBracketClose", + name: "Auto-close brackets and quotes", + titleI18nKey: "grammar_rule_auto_bracket_close", + descriptionI18nKey: "grammar_rule_auto_bracket_close_desc", + exampleI18nKey: "grammar_rule_auto_bracket_close_example", + languageScope: "all", + safetyTier: "advanced", + defaultRollout: "off", + recommended: false, + defaultEnabled: false, + priority: 134, + }, ] as const; export type CatalogRuleId = (typeof GRAMMAR_RULE_CATALOG)[number]["id"]; diff --git a/src/core/domain/grammar/ruleFactory.ts b/src/core/domain/grammar/ruleFactory.ts index 1b2eb714..d6998f8d 100644 --- a/src/core/domain/grammar/ruleFactory.ts +++ b/src/core/domain/grammar/ruleFactory.ts @@ -24,6 +24,7 @@ import { EnglishYourWelcomeCorrectionRule } from "./implementations/EnglishYourW import { EnglishTheirThereBeVerbRule } from "./implementations/EnglishTheirThereBeVerbRule"; import { EnglishAlotCorrectionRule } from "./implementations/EnglishAlotCorrectionRule"; import { EnglishPronounVerbWhitelistAgreementRule } from "./implementations/EnglishPronounVerbWhitelistAgreementRule"; +import { AutoBracketCloseRule } from "./implementations/AutoBracketCloseRule"; export function createGrammarRuleCatalogRuntime(options: { insertSpaceAfterAutocomplete: boolean; @@ -66,6 +67,7 @@ export function createGrammarRuleCatalogRuntime(options: { emdashShortcut: new EmdashShortcutRule(), smartQuoteNormalization: new SmartQuoteNormalizationRule(), duplicatePunctuationCollapse: new DuplicatePunctuationCollapseRule(), + autoBracketClose: new AutoBracketCloseRule(), }; return GRAMMAR_RULE_CATALOG.slice() diff --git a/src/core/domain/grammar/types.ts b/src/core/domain/grammar/types.ts index 8f2cd23f..262b6a3e 100644 --- a/src/core/domain/grammar/types.ts +++ b/src/core/domain/grammar/types.ts @@ -25,6 +25,7 @@ export type GrammarRuleId = | "emdashShortcut" | "smartQuoteNormalization" | "duplicatePunctuationCollapse" + | "autoBracketClose" // Legacy ids kept for compatibility and migration handling. | "spacingRule" | "capitalizeFirstLetter"; @@ -46,6 +47,7 @@ export interface GrammarEdit { replacement: string; deleteBackwards: number; // Number of characters to delete before the cursor deleteForwards: number; // Number of characters to delete after the cursor + cursorOffset?: number; // If set, cursor is placed at replaceStart + cursorOffset instead of end of replacement confidence?: "high" | "medium"; sourceRuleId?: Exclude; safetyTier?: "safe" | "advanced"; diff --git a/src/ui/options/fluenttyperI18n.ts b/src/ui/options/fluenttyperI18n.ts index fcec49d3..a5a54a80 100644 --- a/src/ui/options/fluenttyperI18n.ts +++ b/src/ui/options/fluenttyperI18n.ts @@ -1381,6 +1381,39 @@ i18n.extend({ pl: 'Przykład: ",," -> ","', pr: 'Exemplo: ",," -> ","', }, + grammar_rule_auto_bracket_close: { + en: "Auto-close brackets and quotes", + fr: "Fermer automatiquement les crochets et guillemets", + hr: "Automatski zatvori zagrade i navodnike", + es: "Cerrar automáticamente paréntesis y comillas", + el: "Αυτόματο κλείσιμο αγκυλών και εισαγωγικών", + sv: "Stäng parenteser och citattecken automatiskt", + de: "Klammern und Anführungszeichen automatisch schließen", + pl: "Automatycznie zamykaj nawiasy i cudzysłowy", + pr: "Fechar parênteses e aspas automaticamente", + }, + grammar_rule_auto_bracket_close_desc: { + en: "Automatically inserts the matching closing bracket or quote when you type an opening one, and places the cursor between them.", + fr: "Insère automatiquement le crochet ou guillemet fermant correspondant lorsque vous tapez un ouvrant, et place le curseur entre les deux.", + hr: "Automatski umeće odgovarajuću zatvorenu zagradu ili navodnik kada upišete otvoreni, i postavlja kursor između njih.", + es: "Inserta automáticamente el paréntesis o comilla de cierre correspondiente cuando escribe uno de apertura, y coloca el cursor entre ellos.", + el: "Εισάγει αυτόματα την αντίστοιχη αγκύλη ή εισαγωγικό κλεισίματος όταν πληκτρολογείτε ένα ανοίγματος, και τοποθετεί τον δρομέα ανάμεσά τους.", + sv: "Infogar automatiskt den matchande avslutande parentesen eller citattecknet när du skriver ett öppnande, och placerar markören mellan dem.", + de: "Fügt automatisch die passende schließende Klammer oder das Anführungszeichen ein, wenn Sie ein öffnendes eingeben, und platziert den Cursor dazwischen.", + pl: "Automatycznie wstawia odpowiedni zamykający nawias lub cudzysłów po wpisaniu otwierającego i umieszcza kursor pomiędzy nimi.", + pr: "Insere automaticamente o parêntese ou aspa de fechamento correspondente quando você digita um de abertura, e coloca o cursor entre eles.", + }, + grammar_rule_auto_bracket_close_example: { + en: 'Example: "(" -> "(|)"', + fr: 'Exemple : "(" -> "(|)"', + hr: 'Primjer: "(" -> "(|)"', + es: 'Ejemplo: "(" -> "(|)"', + el: 'Παράδειγμα: "(" -> "(|)"', + sv: 'Exempel: "(" -> "(|)"', + de: 'Beispiel: "(" -> "(|)"', + pl: 'Przykład: "(" -> "(|)"', + pr: 'Exemplo: "(" -> "(|)"', + }, grammar_rule_spacing: { en: "Auto-Spacing around punctuation", fr: "Espacement automatique autour de la ponctuation", diff --git a/tests/e2e/coverage-baseline-ids.json b/tests/e2e/coverage-baseline-ids.json index d400f1fd..b3577713 100644 --- a/tests/e2e/coverage-baseline-ids.json +++ b/tests/e2e/coverage-baseline-ids.json @@ -70,6 +70,7 @@ "grammar_v3_advanced_shortcuts", "grammar_v3_advanced_context_guard", "grammar_v3_english_language_guard", + "grammar_auto_bracket_close", "grammar_v5_forced_migration", "input_type_eligibility", "disabled_input_dynamic_reattach", diff --git a/tests/e2e/coverage-matrix.json b/tests/e2e/coverage-matrix.json index 7a37d254..1e81e200 100644 --- a/tests/e2e/coverage-matrix.json +++ b/tests/e2e/coverage-matrix.json @@ -1120,6 +1120,37 @@ } ] }, + { + "id": "grammar_auto_bracket_close", + "description": "Auto-bracket-close rule inserts matching closing bracket/quote, positions cursor between them, and supports overtype skip-over.", + "coverage": [ + { + "layer": "e2e-full", + "file": "tests/e2e/full.e2e.test.ts", + "test": "Grammar Rule Engine auto-closes brackets and places cursor between them in #test-input" + }, + { + "layer": "e2e-full", + "file": "tests/e2e/full.e2e.test.ts", + "test": "Grammar Rule Engine auto-closes brackets in #test-contenteditable" + }, + { + "layer": "e2e-full", + "file": "tests/e2e/full.e2e.test.ts", + "test": "Grammar Rule Engine auto-closes brackets in Lexical editor" + }, + { + "layer": "unit", + "file": "tests/grammar/AutoBracketCloseRule.test.ts", + "test": "typing %s auto-closes to %s" + }, + { + "layer": "unit", + "file": "tests/grammar/AutoBracketCloseRule.test.ts", + "test": "auto-closes \" without oscillation" + } + ] + }, { "id": "grammar_v5_forced_migration", "description": "Settings migration V5 force-applies recommended grammar defaults with backup and marker.", diff --git a/tests/e2e/full.e2e.test.ts b/tests/e2e/full.e2e.test.ts index d0643adb..f9054621 100644 --- a/tests/e2e/full.e2e.test.ts +++ b/tests/e2e/full.e2e.test.ts @@ -5580,4 +5580,139 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => { }, browserTimeout(30000, 50000), ); + + test( + "Grammar Rule Engine auto-closes brackets and places cursor between them in #test-input", + async () => { + const selector = "#test-input"; + + await setSettingAndWaitStable( + worker!, + KEY_ENABLED_GRAMMAR_RULES, + ["autoBracketClose"], + 3, + browserTimeout(5000, 7000), + ); + await setSettingAndWait(worker!, KEY_LANGUAGE, "en_US"); + await setSettingAndWait(worker!, KEY_MIN_WORD_LENGTH_TO_PREDICT, 1); + await setSettingAndWait(worker!, KEY_ENABLED_LANGUAGES, SUPPORTED_PREDICTION_LANGUAGE_KEYS); + await applyConfigChange(browser, worker!); + + await gotoTestPage(page, { + enableCkEditor: false, + }); + await page.bringToFront(); + await waitForInputReady(page, selector); + + // Test auto-close for parentheses: typing "(" should produce "()" + await clearInputContent(page, selector); + await typeInInput(page, selector, "hello ("); + await waitForInputContentEqual(page, selector, "hello ()", browserTimeout(5000, 9000)); + + // Verify cursor is BETWEEN the brackets by typing a character: + // if cursor is inside "(|)", typing "x" produces "(x)". + // if cursor is at end "()|", typing "x" produces "()x". + await typeInInput(page, selector, "x"); + await waitForInputContentEqual(page, selector, "hello (x)", browserTimeout(5000, 9000)); + + // Test auto-close for double quotes + await clearInputContent(page, selector); + await typeInInput(page, selector, 'say "'); + await waitForInputContentEqual(page, selector, 'say ""', browserTimeout(5000, 9000)); + + await typeInInput(page, selector, "hi"); + await waitForInputContentEqual(page, selector, 'say "hi"', browserTimeout(5000, 9000)); + + // Test overtype: typing closing bracket when it's already ahead should skip over + await clearInputContent(page, selector); + await typeInInput(page, selector, "test ("); + await waitForInputContentEqual(page, selector, "test ()", browserTimeout(5000, 9000)); + await typeInInput(page, selector, "ok"); + await waitForInputContentEqual(page, selector, "test (ok)", browserTimeout(5000, 9000)); + await typeInInput(page, selector, ")"); + await waitForInputContentEqual(page, selector, "test (ok)", browserTimeout(5000, 9000)); + + await setSettingAndWait(worker!, KEY_ENABLED_GRAMMAR_RULES, []); + await applyConfigChange(browser, worker!); + }, + browserTimeout(30000, 50000), + ); + + test( + "Grammar Rule Engine auto-closes brackets in #test-contenteditable", + async () => { + const selector = "#test-contenteditable"; + + await setSettingAndWaitStable( + worker!, + KEY_ENABLED_GRAMMAR_RULES, + ["autoBracketClose"], + 3, + browserTimeout(5000, 7000), + ); + await setSettingAndWait(worker!, KEY_LANGUAGE, "en_US"); + await setSettingAndWait(worker!, KEY_MIN_WORD_LENGTH_TO_PREDICT, 1); + await setSettingAndWait(worker!, KEY_ENABLED_LANGUAGES, SUPPORTED_PREDICTION_LANGUAGE_KEYS); + await applyConfigChange(browser, worker!); + + await gotoTestPage(page, { + enableCkEditor: false, + enableQuill: false, + }); + await page.bringToFront(); + await waitForInputReady(page, selector); + + // Auto-close parentheses + await clearInputContent(page, selector); + await typeInInput(page, selector, "hello ("); + await waitForInputContentMatch(page, selector, /^hello \(\)$/, browserTimeout(5000, 9000)); + + // Verify cursor position: typing after auto-close should insert between brackets + await typeInInput(page, selector, "x"); + await waitForInputContentMatch(page, selector, /^hello \(x\)$/, browserTimeout(5000, 9000)); + + await setSettingAndWait(worker!, KEY_ENABLED_GRAMMAR_RULES, []); + await applyConfigChange(browser, worker!); + }, + browserTimeout(30000, 50000), + ); + + test( + "Grammar Rule Engine auto-closes brackets in Lexical editor", + async () => { + const selector = LEXICAL_SELECTOR; + + await setSettingAndWaitStable( + worker!, + KEY_ENABLED_GRAMMAR_RULES, + ["autoBracketClose"], + 3, + browserTimeout(5000, 7000), + ); + await setSettingAndWait(worker!, KEY_LANGUAGE, "en_US"); + await setSettingAndWait(worker!, KEY_MIN_WORD_LENGTH_TO_PREDICT, 1); + await setSettingAndWait(worker!, KEY_ENABLED_LANGUAGES, SUPPORTED_PREDICTION_LANGUAGE_KEYS); + await applyConfigChange(browser, worker!); + + await gotoTestPage(page, { enableLexical: true }); + await page.bringToFront(); + await waitForInputReady(page, selector); + + await page.focus(selector); + await page.keyboard.type("hello ("); + await waitForInputContentMatch(page, selector, /^hello \(\)$/, browserTimeout(5000, 9000)); + + // Wait for deferred cursor repositioning (rAF + setTimeout in content script) + await sleep(200); + + // Verify cursor is between brackets by typing a character: + // if cursor is inside "(|)", typing "x" produces "(x)". + await page.keyboard.type("x"); + await waitForInputContentMatch(page, selector, /^hello \(x\)$/, browserTimeout(5000, 9000)); + + await setSettingAndWait(worker!, KEY_ENABLED_GRAMMAR_RULES, []); + await applyConfigChange(browser, worker!); + }, + browserTimeout(30000, 50000), + ); }); diff --git a/tests/grammar/AutoBracketCloseRule.test.ts b/tests/grammar/AutoBracketCloseRule.test.ts new file mode 100644 index 00000000..20e640cd --- /dev/null +++ b/tests/grammar/AutoBracketCloseRule.test.ts @@ -0,0 +1,357 @@ +import { describe, expect, test } from "bun:test"; +import type { GrammarContext } from "../../src/core/domain/grammar/types"; +import { AutoBracketCloseRule } from "../../src/core/domain/grammar/implementations/AutoBracketCloseRule"; +import { + applyGrammarEditToContext, + mergeSequentialGrammarEdits, +} from "../../src/core/domain/grammar/GrammarEditSequencing"; +import { GrammarRuleEngine } from "../../src/core/domain/grammar/GrammarRuleEngine"; + +function context( + beforeCursor: string, + afterCursor = "", + hints?: GrammarContext["hints"], +): GrammarContext { + return { + beforeCursor, + afterCursor, + ...(hints ? { hints } : {}), + }; +} + +describe("AutoBracketCloseRule", () => { + const rule = new AutoBracketCloseRule(); + + describe("auto-close", () => { + test.each([ + ["(", "()", ")"], + ["[", "[]", "]"], + ["{", "{}", "}"], + ["'", "''", "'"], + ['"', '""', '"'], + ["`", "``", "`"], + ["<", "<>", ">"], + ["«", "«»", "»"], + ])("typing %s auto-closes to %s", (openChar, replacement) => { + const result = rule.apply(context(`Hello ${openChar}`, " world", { inputAction: "insert" })); + expect(result).toEqual({ + replacement, + deleteBackwards: 1, + deleteForwards: 0, + cursorOffset: 1, + confidence: "medium", + safetyTier: "advanced", + sourceRuleId: "autoBracketClose", + description: `Auto-closed ${replacement}`, + }); + }); + + test("auto-closes at start of text", () => { + const result = rule.apply(context("(", "", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe("()"); + expect(result!.cursorOffset).toBe(1); + }); + + test("auto-closes with empty afterCursor", () => { + const result = rule.apply(context("text[", "", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe("[]"); + }); + + test("does not auto-close on delete action", () => { + expect(rule.apply(context("(", "", { inputAction: "delete" }))).toBeNull(); + }); + + test("does not auto-close with empty beforeCursor", () => { + expect(rule.apply(context("", ")"))).toBeNull(); + }); + }); + + describe("suppression guards", () => { + test("does not auto-close single quote after word character (apostrophe)", () => { + expect(rule.apply(context("it'", "s a test", { inputAction: "insert" }))).toBeNull(); + }); + + test("does not auto-close double quote after word character", () => { + expect(rule.apply(context('word"', " more", { inputAction: "insert" }))).toBeNull(); + }); + + test("does not auto-close backtick after word character", () => { + expect(rule.apply(context("word`", " more", { inputAction: "insert" }))).toBeNull(); + }); + + test("auto-closes single quote after space", () => { + const result = rule.apply(context("hello '", "world", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe("''"); + }); + + test("auto-closes single quote at start of text", () => { + const result = rule.apply(context("'", "world", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe("''"); + }); + + test("does not auto-close < after word character", () => { + expect(rule.apply(context("value<", "3", { inputAction: "insert" }))).toBeNull(); + }); + + test("auto-closes < after space", () => { + const result = rule.apply(context("hello <", "world", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe("<>"); + }); + + test("does not auto-close when afterCursor starts with matching close char", () => { + expect(rule.apply(context("(", ")", { inputAction: "insert" }))).toBeNull(); + expect(rule.apply(context("[", "]", { inputAction: "insert" }))).toBeNull(); + expect(rule.apply(context("{", "}", { inputAction: "insert" }))).toBeNull(); + }); + + test("auto-closes when afterCursor starts with non-matching char", () => { + const result = rule.apply(context("(", "]rest", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe("()"); + }); + }); + + describe("overtype (skip-over)", () => { + test("skips over closing paren when it matches afterCursor", () => { + const result = rule.apply(context("hello())", ")", { inputAction: "insert" })); + expect(result).toEqual({ + replacement: ")", + deleteBackwards: 1, + deleteForwards: 1, + confidence: "high", + safetyTier: "advanced", + sourceRuleId: "autoBracketClose", + description: "Skipped over auto-inserted )", + }); + }); + + test("skips over closing bracket", () => { + const result = rule.apply(context("text]", "]more", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe("]"); + expect(result!.deleteBackwards).toBe(1); + expect(result!.deleteForwards).toBe(1); + }); + + test("skips over closing brace", () => { + const result = rule.apply(context("text}", "}more", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe("}"); + }); + + test("skips over closing single quote", () => { + const result = rule.apply(context("it's'", "'rest", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe("'"); + }); + + test("skips over closing double quote", () => { + const result = rule.apply(context(' "hello"', '"rest', { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe('"'); + }); + + test("skips over closing > after non-word context", () => { + const result = rule.apply(context(" >", ">rest", { inputAction: "insert" })); + expect(result).not.toBeNull(); + expect(result!.replacement).toBe(">"); + }); + + test("does not overtype > after word character (comparison operator)", () => { + expect(rule.apply(context("value>", ">3", { inputAction: "insert" }))).toBeNull(); + }); + + test("does not overtype symmetric quote after non-word char (prevents oscillation)", () => { + // After auto-close, engine re-evaluates: beforeCursor='"', afterCursor='"' + // The char before the quote is a space (or start of string) — NOT a closing quote scenario + expect(rule.apply(context(' "', '"rest', { inputAction: "insert" }))).toBeNull(); + expect(rule.apply(context('"', '"rest', { inputAction: "insert" }))).toBeNull(); + expect(rule.apply(context(" '", "'rest", { inputAction: "insert" }))).toBeNull(); + expect(rule.apply(context(" `", "`rest", { inputAction: "insert" }))).toBeNull(); + }); + + test("does not overtype when afterCursor does not match", () => { + expect(rule.apply(context("text)", "other", { inputAction: "insert" }))).toBeNull(); + }); + + test("does not overtype when afterCursor is empty", () => { + expect(rule.apply(context("text)", "", { inputAction: "insert" }))).toBeNull(); + }); + + test("does not overtype on delete action", () => { + expect(rule.apply(context("text)", ")", { inputAction: "delete" }))).toBeNull(); + }); + }); + + describe("rule metadata", () => { + test("has correct id", () => { + expect(rule.id).toBe("autoBracketClose"); + }); + + test("triggers on insertChar", () => { + expect(rule.triggers).toEqual(["insertChar"]); + }); + }); +}); + +describe("mergeSequentialGrammarEdits with cursorOffset", () => { + test("preserves cursorOffset from single edit", () => { + const result = mergeSequentialGrammarEdits([ + { + replacement: "()", + deleteBackwards: 1, + deleteForwards: 0, + cursorOffset: 1, + }, + ]); + expect(result).toHaveLength(1); + expect(result[0].cursorOffset).toBe(1); + }); + + test("rebases cursorOffset when preceded by another edit", () => { + const result = mergeSequentialGrammarEdits([ + { + replacement: " (", + deleteBackwards: 1, + deleteForwards: 0, + }, + { + replacement: "()", + deleteBackwards: 1, + deleteForwards: 0, + cursorOffset: 1, + }, + ]); + expect(result).toHaveLength(1); + // First edit: accumulatedString = " (" (len 2), keepAccumulated for 2nd = 2 - 1 + 0 = 1 + // So: accumulatedString = " " + "()" = " ()" (len 3) + // mergedCursorOffset = 1 + 1 = 2 + expect(result[0].replacement).toBe(" ()"); + expect(result[0].cursorOffset).toBe(2); + }); + + test("does not include cursorOffset when no edit sets it", () => { + const result = mergeSequentialGrammarEdits([ + { + replacement: "hello", + deleteBackwards: 3, + deleteForwards: 0, + }, + ]); + expect(result).toHaveLength(1); + expect(result[0].cursorOffset).toBeUndefined(); + }); +}); + +describe("applyGrammarEditToContext with cursorOffset", () => { + test("splits replacement at cursorOffset between beforeCursor and afterCursor", () => { + const result = applyGrammarEditToContext( + { beforeCursor: "hello(", afterCursor: "world" }, + { replacement: "()", deleteBackwards: 1, deleteForwards: 0, cursorOffset: 1 }, + ); + expect(result.beforeCursor).toBe("hello("); + expect(result.afterCursor).toBe(")world"); + }); + + test("places full replacement in beforeCursor when cursorOffset is absent", () => { + const result = applyGrammarEditToContext( + { beforeCursor: "hello(", afterCursor: "world" }, + { replacement: "()", deleteBackwards: 1, deleteForwards: 0 }, + ); + expect(result.beforeCursor).toBe("hello()"); + expect(result.afterCursor).toBe("world"); + }); + + test("handles cursorOffset at end of replacement (same as no offset)", () => { + const result = applyGrammarEditToContext( + { beforeCursor: "hello(", afterCursor: "world" }, + { replacement: "()", deleteBackwards: 1, deleteForwards: 0, cursorOffset: 2 }, + ); + expect(result.beforeCursor).toBe("hello()"); + expect(result.afterCursor).toBe("world"); + }); + + test("handles cursorOffset at start of replacement", () => { + const result = applyGrammarEditToContext( + { beforeCursor: "hello(", afterCursor: "world" }, + { replacement: "()", deleteBackwards: 1, deleteForwards: 0, cursorOffset: 0 }, + ); + expect(result.beforeCursor).toBe("hello"); + expect(result.afterCursor).toBe("()world"); + }); +}); + +describe("GrammarRuleEngine integration with AutoBracketCloseRule", () => { + function makeEngine(): GrammarRuleEngine { + const engine = new GrammarRuleEngine(); + engine.registerRule(new AutoBracketCloseRule()); + return engine; + } + + test("auto-closes ( without oscillation", () => { + const engine = makeEngine(); + const edits = engine.process("insertChar", { + beforeCursor: "hello (", + afterCursor: " world", + hints: { inputAction: "insert" }, + }); + expect(edits).toHaveLength(1); + expect(edits[0].replacement).toBe("()"); + expect(edits[0].cursorOffset).toBe(1); + expect(edits[0].deleteBackwards).toBe(1); + }); + + test('auto-closes " without oscillation — must NOT produce """"', () => { + const engine = makeEngine(); + const edits = engine.process("insertChar", { + beforeCursor: 'hello "', + afterCursor: " world", + hints: { inputAction: "insert" }, + }); + expect(edits).toHaveLength(1); + expect(edits[0].replacement).toBe('""'); + expect(edits[0].cursorOffset).toBe(1); + expect(edits[0].deleteBackwards).toBe(1); + }); + + test("auto-closes ' without oscillation", () => { + const engine = makeEngine(); + const edits = engine.process("insertChar", { + beforeCursor: "hello '", + afterCursor: " world", + hints: { inputAction: "insert" }, + }); + expect(edits).toHaveLength(1); + expect(edits[0].replacement).toBe("''"); + expect(edits[0].cursorOffset).toBe(1); + }); + + test("auto-closes ` without oscillation", () => { + const engine = makeEngine(); + const edits = engine.process("insertChar", { + beforeCursor: "hello `", + afterCursor: " world", + hints: { inputAction: "insert" }, + }); + expect(edits).toHaveLength(1); + expect(edits[0].replacement).toBe("``"); + expect(edits[0].cursorOffset).toBe(1); + }); + + test('overtypes closing " after word character', () => { + const engine = makeEngine(); + const edits = engine.process("insertChar", { + beforeCursor: '"hello"', + afterCursor: '"rest', + hints: { inputAction: "insert" }, + }); + expect(edits).toHaveLength(1); + expect(edits[0].replacement).toBe('"'); + expect(edits[0].deleteForwards).toBe(1); + }); +});