Skip to content

Commit dbd4f4b

Browse files
authored
feat: callbackCallRegex (#580)
* feat: callbackCallRegex * feat: more tests * fix: issue with strings * fix: address callbackCallRegex review feedback
1 parent 87efb75 commit dbd4f4b

3 files changed

Lines changed: 314 additions & 0 deletions

File tree

docs/curriculum-helpers.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,54 @@ let match =
7070
match[1]; // "myFunc = arg1 => arg1; console.log();\n // captured, unfortunately"
7171
```
7272

73+
## callbackCallRegex
74+
75+
Generates a regex to match a method call with a callback assigned to a target variable. Useful for asserting that a learner used a specific array method with the right callback shape.
76+
77+
```javascript
78+
const regex = callbackCallRegex({
79+
target: "sumSquaredDifferences",
80+
source: "numbers",
81+
method: "reduce",
82+
params: ["acc", "el"],
83+
returns: /acc \+ el \*\* 2/,
84+
});
85+
86+
// Matches arrow expression
87+
regex.test(
88+
"sumSquaredDifferences = numbers.reduce((acc, el) => acc + el ** 2)",
89+
);
90+
// true
91+
92+
// Matches arrow block
93+
regex.test(
94+
"sumSquaredDifferences = numbers.reduce((acc, el) => { return acc + el ** 2; })",
95+
);
96+
// true
97+
98+
// Matches function expression
99+
regex.test(
100+
"sumSquaredDifferences = numbers.reduce(function (acc, el) { return acc + el ** 2; })",
101+
);
102+
// true
103+
104+
// Also works with const/let/var declarations
105+
regex.test(
106+
"const sumSquaredDifferences = numbers.reduce((acc, el) => acc + el ** 2)",
107+
);
108+
// true
109+
```
110+
111+
### Options
112+
113+
- `target: string` — The variable being assigned to.
114+
- `source: string` — The object the method is called on.
115+
- `method: string` — The method name (dot notation only).
116+
- `params: string[]` — The callback parameter names, in order.
117+
- `returns?: RegExp` — Optional. When provided, the regex also enforces the callback return expression. Non-stateful flags (`d`, `i`, `m`, `s`, `u`, `v`) are preserved; stateful flags (`g`, `y`) are ignored.
118+
119+
**Note:** Extra non-callback arguments (e.g. the initial value `, 0` in `reduce`) are not validated by this helper and should be asserted separately if needed.
120+
73121
## prepTestComponent
74122

75123
Renders a React component into a DOM element and returns a Promise containing the DOM element. The arguments are, respectively, the component to render and an (optional) object containing the props to pass to the component.

packages/helpers/lib/index.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,50 @@ export function getFunctionParams(code: string) {
686686
return [];
687687
}
688688

689+
/**
690+
* Generates a regex to match a method call with a callback, assigned to a target variable.
691+
* Supports arrow expression, arrow block, and function callbacks.
692+
* @param options.target - The variable being assigned to (e.g. `"result"`)
693+
* @param options.source - The object the method is called on (e.g. `"numbers"`)
694+
* @param options.method - The method name (e.g. `"reduce"`)
695+
* @param options.params - The callback parameter names (e.g. `["acc", "el"]`)
696+
* @param options.returns - Optional: enforces a return expression in the callback body
697+
*/
698+
export function callbackCallRegex(options: {
699+
target: string;
700+
source: string;
701+
method: string;
702+
params: string[];
703+
returns?: RegExp;
704+
}): RegExp {
705+
const { target, source, method, params, returns } = options;
706+
const identifierChar = "[\\w$]";
707+
708+
const targetPart = `(?:(?:const|let|var)\\s+)?(?<!${identifierChar})${escapeRegExp(
709+
target,
710+
)}(?!${identifierChar})\\s*=\\s*`;
711+
const methodPart = `(?<!${identifierChar})${escapeRegExp(
712+
source,
713+
)}(?!${identifierChar})\\.${escapeRegExp(method)}\\(\\s*`;
714+
const callbackSignature = functionRegex(null, params, { includeBody: false });
715+
716+
if (returns !== undefined) {
717+
const bodyPart = `\\s*(?:return\\s+)?(?:${returns.source})\\s*[;]?\\s*\\}?`;
718+
// Preserve non-stateful flags from `returns` so flag-dependent patterns keep
719+
// working when embedded in the assembled regex.
720+
const returnsFlags = returns.flags.replace(/[gy]/g, "");
721+
const { source } = concatRegex(
722+
targetPart,
723+
methodPart,
724+
callbackSignature,
725+
bodyPart,
726+
);
727+
return new RegExp(source, returnsFlags);
728+
}
729+
730+
return concatRegex(targetPart, methodPart, callbackSignature);
731+
}
732+
689733
/**
690734
* Retries a test function with a specified delay between attempts
691735
* Useful for testing DOM elements that may not be immediately available

packages/tests/curriculum-helper.test.tsx

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,228 @@ describe("functionRegex", () => {
717717
});
718718
});
719719

720+
describe("callbackCallRegex", () => {
721+
const { callbackCallRegex } = helper;
722+
const baseOptions = {
723+
target: "result",
724+
source: "numbers",
725+
method: "reduce",
726+
params: ["acc", "el"],
727+
};
728+
729+
it("returns a RegExp", () => {
730+
expect(callbackCallRegex(baseOptions)).toBeInstanceOf(RegExp);
731+
});
732+
733+
it("matches arrow expression callback", () => {
734+
const regex = callbackCallRegex({ ...baseOptions, returns: /acc \+ el/ });
735+
expect(regex.test("result = numbers.reduce((acc, el) => acc + el)")).toBe(
736+
true,
737+
);
738+
});
739+
740+
it("matches arrow block callback", () => {
741+
const regex = callbackCallRegex({ ...baseOptions, returns: /acc \+ el/ });
742+
expect(
743+
regex.test("result = numbers.reduce((acc, el) => { return acc + el; })"),
744+
).toBe(true);
745+
});
746+
747+
it("matches function callback", () => {
748+
const regex = callbackCallRegex({ ...baseOptions, returns: /acc \+ el/ });
749+
expect(
750+
regex.test(
751+
"result = numbers.reduce(function (acc, el) { return acc + el; })",
752+
),
753+
).toBe(true);
754+
});
755+
756+
it("fails when target is different", () => {
757+
const regex = callbackCallRegex(baseOptions);
758+
expect(regex.test("other = numbers.reduce((acc, el) => acc + el)")).toBe(
759+
false,
760+
);
761+
});
762+
763+
it("fails when target is only a suffix of another identifier", () => {
764+
const regex = callbackCallRegex(baseOptions);
765+
expect(regex.test("myresult = numbers.reduce((acc, el) => acc + el)")).toBe(
766+
false,
767+
);
768+
});
769+
770+
it("fails when source is different", () => {
771+
const regex = callbackCallRegex(baseOptions);
772+
expect(regex.test("result = other.reduce((acc, el) => acc + el)")).toBe(
773+
false,
774+
);
775+
});
776+
777+
it("fails when method is different", () => {
778+
const regex = callbackCallRegex(baseOptions);
779+
expect(regex.test("result = numbers.map((acc, el) => acc + el)")).toBe(
780+
false,
781+
);
782+
});
783+
784+
it("fails when callback params are different", () => {
785+
const regex = callbackCallRegex(baseOptions);
786+
expect(regex.test("result = numbers.reduce((x, y) => x + y)")).toBe(false);
787+
});
788+
789+
it("fails when callback param order is different", () => {
790+
const regex = callbackCallRegex(baseOptions);
791+
expect(regex.test("result = numbers.reduce((el, acc) => el + acc)")).toBe(
792+
false,
793+
);
794+
});
795+
796+
it("fails when return expression does not match returns", () => {
797+
const regex = callbackCallRegex({ ...baseOptions, returns: /acc \+ el/ });
798+
expect(regex.test("result = numbers.reduce((acc, el) => acc - el)")).toBe(
799+
false,
800+
);
801+
});
802+
803+
it("accepts a RegExp for returns", () => {
804+
const regex = callbackCallRegex({
805+
...baseOptions,
806+
returns: /acc\s*\+\s*el/,
807+
});
808+
expect(regex.test("result = numbers.reduce((acc, el) => acc + el)")).toBe(
809+
true,
810+
);
811+
expect(regex.test("result = numbers.reduce((acc, el) => acc + el)")).toBe(
812+
true,
813+
);
814+
expect(regex.test("result = numbers.reduce((acc, el) => acc - el)")).toBe(
815+
false,
816+
);
817+
});
818+
819+
it("scopes alternation in returns to the callback return expression", () => {
820+
const regex = callbackCallRegex({
821+
...baseOptions,
822+
returns: /acc \+ el|somethingElse/,
823+
});
824+
825+
expect(regex.test("result = numbers.reduce((acc, el) => acc + el)")).toBe(
826+
true,
827+
);
828+
expect(regex.test("somethingElse")).toBe(false);
829+
expect(
830+
regex.test(
831+
"result = numbers.reduce((acc, el) => acc - el); somethingElse",
832+
),
833+
).toBe(false);
834+
});
835+
836+
it("preserves non-stateful returns flags", () => {
837+
expect(() =>
838+
callbackCallRegex({
839+
...baseOptions,
840+
returns: /\p{L}+/u,
841+
}),
842+
).not.toThrow();
843+
844+
const regex = callbackCallRegex({
845+
...baseOptions,
846+
returns: /\p{L}+/u,
847+
});
848+
849+
expect(regex.flags).toContain("u");
850+
expect(regex.test("result = numbers.reduce((acc, el) => café)")).toBe(true);
851+
});
852+
853+
it("matches a function callback with returns", () => {
854+
const regex = callbackCallRegex({ ...baseOptions, returns: /acc \+ el/ });
855+
expect(
856+
regex.test(
857+
"result = numbers.reduce(function (acc, el) { return acc + el; })",
858+
),
859+
).toBe(true);
860+
expect(
861+
regex.test(
862+
"result = numbers.reduce(function (acc, el) { return acc - el; })",
863+
),
864+
).toBe(false);
865+
});
866+
867+
it("matches a complex return expression using a RegExp", () => {
868+
const regex = callbackCallRegex({
869+
...baseOptions,
870+
returns: /acc \+ el \*\* 2/,
871+
});
872+
expect(
873+
regex.test("result = numbers.reduce((acc, el) => acc + el ** 2)"),
874+
).toBe(true);
875+
expect(regex.test("result = numbers.reduce((acc, el) => acc + el)")).toBe(
876+
false,
877+
);
878+
});
879+
880+
it("matches with let and var declarations", () => {
881+
const regex = callbackCallRegex({ ...baseOptions, returns: /acc \+ el/ });
882+
expect(
883+
regex.test("let result = numbers.reduce((acc, el) => acc + el)"),
884+
).toBe(true);
885+
expect(
886+
regex.test("var result = numbers.reduce((acc, el) => acc + el)"),
887+
).toBe(true);
888+
});
889+
890+
it("works with extra whitespace in the callback signature", () => {
891+
const regex = callbackCallRegex({ ...baseOptions, returns: /acc \+ el/ });
892+
expect(
893+
regex.test("result = numbers.reduce( ( acc , el ) => acc + el )"),
894+
).toBe(true);
895+
});
896+
897+
it("works with multiline/whitespace-heavy code", () => {
898+
const regex = callbackCallRegex({ ...baseOptions, returns: /acc \+ el/ });
899+
const code = `const result = numbers.reduce(
900+
(acc, el) => {
901+
return acc + el;
902+
},
903+
0
904+
)`;
905+
expect(regex.test(code)).toBe(true);
906+
});
907+
908+
it("when returns is omitted, only call shape and callback signature is required", () => {
909+
const regex = callbackCallRegex(baseOptions);
910+
expect(regex.test("result = numbers.reduce((acc, el) => acc + el)")).toBe(
911+
true,
912+
);
913+
expect(regex.test("result = numbers.reduce((acc, el) => acc - el)")).toBe(
914+
true,
915+
);
916+
expect(
917+
regex.test(
918+
"result = numbers.reduce(function (acc, el) { return anything; })",
919+
),
920+
).toBe(true);
921+
});
922+
923+
it("matches an empty callback when params is []", () => {
924+
const regex = callbackCallRegex({
925+
target: "result",
926+
source: "numbers",
927+
method: "forEach",
928+
params: [],
929+
});
930+
expect(regex.test("result = numbers.forEach(() => doSomething())")).toBe(
931+
true,
932+
);
933+
expect(
934+
regex.test("result = numbers.forEach(function () { doSomething(); })"),
935+
).toBe(true);
936+
expect(
937+
regex.test("result = numbers.forEach((el) => doSomething(el))"),
938+
).toBe(false);
939+
});
940+
});
941+
720942
describe("prepTestComponent", () => {
721943
let MyComponent;
722944
beforeEach(() => {

0 commit comments

Comments
 (0)