diff --git a/src/patches/slashCommands.test.ts b/src/patches/slashCommands.test.ts new file mode 100644 index 00000000..21db08ac --- /dev/null +++ b/src/patches/slashCommands.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { + findSlashCommandListEndPosition, + writeSlashCommandDefinition, +} from './slashCommands'; + +const anchor = + 'var Cmd0={type:"local",name:"clear",description:"Clear the conversation"};'; +const items = Array.from({ length: 31 }, (_, i) => `c${i}`).join(','); + +const arrowForm = `${anchor}Cmds=memo9(()=>[${items},...Fa?[Fa]:[]])`; +const returnForm = `${anchor}function $tS(){return[${items},...Fa?[Fa]:[]]}`; + +describe('findSlashCommandListEndPosition', () => { + it('finds the closing bracket of an arrow-returned command array', () => { + const end = findSlashCommandListEndPosition(arrowForm); + + expect(end).not.toBeNull(); + expect(arrowForm[end as number]).toBe(']'); + expect(arrowForm.slice(end as number)).toBe('])'); + }); + + it('finds the closing bracket of a function-returned command array', () => { + const end = findSlashCommandListEndPosition(returnForm); + + expect(end).not.toBeNull(); + expect(returnForm[end as number]).toBe(']'); + expect(returnForm.slice(end as number)).toBe(']}'); + }); + + it('ignores small arrays and returns null when no command list exists', () => { + expect( + findSlashCommandListEndPosition(`${anchor}function f(){return[a,b,c]}`) + ).toBeNull(); + }); + + it('ignores large arrays that are not near command metadata', () => { + expect( + findSlashCommandListEndPosition(`function f(){return[${items}]}`) + ).toBeNull(); + }); + + it('picks the largest candidate when several arrays qualify', () => { + const smaller = Array.from({ length: 30 }, (_, i) => `s${i}`).join(','); + const file = `${anchor}function a(){return[${smaller}]}function b(){return[${items}]}`; + const end = findSlashCommandListEndPosition(file); + + expect(end).not.toBeNull(); + expect(file.slice(end as number)).toBe(']}'); + expect(file.slice(0, end as number)).toContain('c30'); + }); +}); + +describe('writeSlashCommandDefinition', () => { + it('inserts the definition before the closing bracket of an arrow form', () => { + const result = writeSlashCommandDefinition(arrowForm, ',NEW_CMD'); + + expect(result).not.toBeNull(); + expect(result).toContain(',NEW_CMD])'); + }); + + it('inserts the definition before the closing bracket of a return form', () => { + const result = writeSlashCommandDefinition(returnForm, ',NEW_CMD'); + + expect(result).not.toBeNull(); + expect(result).toContain(',NEW_CMD]}'); + }); + + it('returns null when the command list cannot be located', () => { + expect(writeSlashCommandDefinition('var a=1;', ',NEW_CMD')).toBeNull(); + }); +}); diff --git a/src/patches/slashCommands.ts b/src/patches/slashCommands.ts index 11c2b30c..f93dc121 100644 --- a/src/patches/slashCommands.ts +++ b/src/patches/slashCommands.ts @@ -60,24 +60,27 @@ const analyzeArrayFromOpenBracket = ( /** * Find the end position of the slash command array using stack machine. * - * Supports both pre-2.1.138 form (plain `=>[ID,ID,...]` with 30+ bare - * identifiers) and 2.1.138+ form where the array uses spread operators for + * Supports the pre-2.1.138 form (plain `=>[ID,ID,...]` with 30+ bare + * identifiers), the 2.1.138+ form where the array uses spread operators for * conditionally-included commands, e.g.: * =L8(()=>[AUK,pL4,DX4,y64,...gT4?[gT4]:[],Qj4,lI6,vL4,...,W94(),...]) + * and the 2.1.227+ form where the list moved into a named function whose body + * returns the array, e.g.: + * function $tS(){return[uOd,Wza,lHf,...t5n("fleetFork"),nEf,...]} * * The candidate must also sit in slash-command-specific code. The bundle keeps * slash-command definitions near command metadata such as name/userFacingName, - * so this rejects unrelated large arrow-return arrays. + * so this rejects unrelated large arrays. */ export const findSlashCommandListEndPosition = ( fileContents: string ): number | null => { - // Walk every `=>[` candidate. The slash command array is the (only) array - // following an arrow-return that contains >= 30 top-level items. - const arrowPattern = /=>\s*\[/g; + // Walk every `=>[` and `return[` candidate. The slash command array is the + // (only) array returned from a function that contains >= 30 top-level items. + const candidatePattern = /(?:=>|return)\s*\[/g; let m: RegExpExecArray | null; let best: { closing: number; items: number } | null = null; - while ((m = arrowPattern.exec(fileContents)) !== null) { + while ((m = candidatePattern.exec(fileContents)) !== null) { const bracketIndex = m.index + m[0].length - 1; // position of '[' const anchorWindow = fileContents.slice( Math.max(0, m.index - 12000),