Skip to content

Commit 7457082

Browse files
committed
created helper function for shared logic + added find-nth
1 parent 2b4bd4c commit 7457082

3 files changed

Lines changed: 142 additions & 41 deletions

File tree

__tests__/plugins/formatters.core.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,87 @@ loader.paths('f-find-last-%N.html').forEach((path) => {
442442
test(`find-last - ${path}`, () => loader.execute(path));
443443
});
444444

445+
test('find-nth', () => {
446+
// no filter: return element at index 0
447+
let vars = variables(['a', 'b', 'c']);
448+
Core['find-nth'].apply(['0'], vars, CTX);
449+
expect(vars[0].get()).toEqual('a');
450+
451+
// no filter: return element at index 1
452+
vars = variables(['a', 'b', 'c']);
453+
Core['find-nth'].apply(['1'], vars, CTX);
454+
expect(vars[0].get()).toEqual('b');
455+
456+
// no filter: negative index counts from end
457+
vars = variables(['a', 'b', 'c']);
458+
Core['find-nth'].apply(['-1'], vars, CTX);
459+
expect(vars[0].get()).toEqual('c');
460+
461+
vars = variables(['a', 'b', 'c']);
462+
Core['find-nth'].apply(['-2'], vars, CTX);
463+
expect(vars[0].get()).toEqual('b');
464+
465+
// non-numeric index defaults to 0
466+
vars = variables(['a', 'b', 'c']);
467+
Core['find-nth'].apply(['foo'], vars, CTX);
468+
expect(vars[0].get()).toEqual('a');
469+
470+
// index out of bounds → missing
471+
vars = variables(['a', 'b', 'c']);
472+
Core['find-nth'].apply(['5'], vars, CTX);
473+
expect(vars[0].node.isMissing()).toBe(true);
474+
475+
// negative index out of bounds → missing
476+
vars = variables(['a', 'b', 'c']);
477+
Core['find-nth'].apply(['-5'], vars, CTX);
478+
expect(vars[0].node.isMissing()).toBe(true);
479+
480+
// empty array → missing
481+
vars = variables([]);
482+
Core['find-nth'].apply(['0'], vars, CTX);
483+
expect(vars[0].node.isMissing()).toBe(true);
484+
485+
// non-array → missing
486+
vars = variables('not-an-array');
487+
Core['find-nth'].apply(['0'], vars, CTX);
488+
expect(vars[0].node.isMissing()).toBe(true);
489+
490+
// no filter: nth object element
491+
vars = variables([{ x: 1 }, { x: 2 }, { x: 3 }]);
492+
Core['find-nth'].apply(['2'], vars, CTX);
493+
expect(vars[0].get()).toEqual({ x: 3 });
494+
495+
// with path: find nth element where path is truthy
496+
vars = variables([
497+
{ id: 'a', enabled: false },
498+
{ id: 'b', enabled: true },
499+
{ id: 'c', enabled: true },
500+
{ id: 'd', enabled: true },
501+
]);
502+
Core['find-nth'].apply(['1', 'enabled'], vars, CTX);
503+
expect(vars[0].get()).toEqual({ id: 'c', enabled: true });
504+
505+
// with path: index 0 of matching elements
506+
vars = variables([{ id: 'a', enabled: false }, { id: 'b', enabled: true }, { id: 'c', enabled: true }]);
507+
Core['find-nth'].apply(['0', 'enabled'], vars, CTX);
508+
expect(vars[0].get()).toEqual({ id: 'b', enabled: true });
509+
510+
// with path: negative index among matching elements
511+
vars = variables([{ id: 'a', enabled: true }, { id: 'b', enabled: false }, { id: 'c', enabled: true }]);
512+
Core['find-nth'].apply(['-1', 'enabled'], vars, CTX);
513+
expect(vars[0].get()).toEqual({ id: 'c', enabled: true });
514+
515+
// with path: none match → missing
516+
vars = variables([{ id: 'a', enabled: false }, { id: 'b' }]);
517+
Core['find-nth'].apply(['0', 'enabled'], vars, CTX);
518+
expect(vars[0].node.isMissing()).toBe(true);
519+
520+
// with path: index out of bounds among matches → missing
521+
vars = variables([{ id: 'a', enabled: true }, { id: 'b', enabled: false }]);
522+
Core['find-nth'].apply(['1', 'enabled'], vars, CTX);
523+
expect(vars[0].node.isMissing()).toBe(true);
524+
});
525+
445526
test('key-by', () => {
446527
let vars = variables([{ id: 1 }]);
447528
Core['key-by'].apply([], vars, CTX);

src/plugins/formatters.core.ts

Lines changed: 15 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Variable } from '../variable';
88
import { Type } from '../types';
99
import { executeTemplate } from '../exec';
1010
import { splitVariable } from '../util';
11+
import { findNthValidEntry, getLookupAndPath } from './util.find';
1112
import { format } from './util.format';
1213
import { escapeHtmlAttributes, escapeScriptTags, slugify, truncate } from './util.string';
1314
import utf8 from 'utf8';
@@ -246,53 +247,25 @@ export class KeyByFormatter extends Formatter {
246247
export class FindFirstFormatter extends Formatter {
247248
apply(args: string[], vars: Variable[], ctx: Context): void {
248249
const first = vars[0];
249-
if (first.node.type !== Type.ARRAY || first.node.value.length === 0) {
250-
first.set(MISSING_NODE);
251-
return;
252-
}
253-
if (args.length === 0) {
254-
first.set(new Node(first.get()[0]));
255-
return;
256-
}
257-
const hasLookup = args.length >= 2;
258-
const lookup = hasLookup ? ctx.resolve(splitVariable(args[0])) : null;
259-
const path = splitVariable(args[hasLookup ? 1 : 0]);
260-
for (const val of first.get()) {
261-
const element = new Node(val);
262-
const candidate = hasLookup ? lookup!.path([element.asString()]) : element;
263-
if (isTruthy(candidate.path(path))) {
264-
first.set(element);
265-
return;
266-
}
267-
}
268-
first.set(MISSING_NODE);
250+
const { lookup, path } = getLookupAndPath(ctx, args);
251+
first.set(findNthValidEntry(first.get(), path, lookup, 0));
269252
}
270253
}
271254

272255
export class FindLastFormatter extends Formatter {
273256
apply(args: string[], vars: Variable[], ctx: Context): void {
274257
const first = vars[0];
275-
const arr: any[] = first.node.type === Type.ARRAY ? first.get() : null;
276-
if (!arr || arr.length === 0) {
277-
first.set(MISSING_NODE);
278-
return;
279-
}
280-
if (args.length === 0) {
281-
first.set(new Node(arr[arr.length - 1]));
282-
return;
283-
}
284-
const hasLookup = args.length >= 2;
285-
const lookup = hasLookup ? ctx.resolve(splitVariable(args[0])) : null;
286-
const path = splitVariable(args[hasLookup ? 1 : 0]);
287-
for (let i = arr.length - 1; i >= 0; i--) {
288-
const element = new Node(arr[i]);
289-
const candidate = hasLookup ? lookup!.path([element.asString()]) : element;
290-
if (isTruthy(candidate.path(path))) {
291-
first.set(element);
292-
return;
293-
}
294-
}
295-
first.set(MISSING_NODE);
258+
const { lookup, path } = getLookupAndPath(ctx, args);
259+
first.set(findNthValidEntry(first.get(), path, lookup, -1));
260+
}
261+
}
262+
263+
export class FindNthFormatter extends Formatter {
264+
apply(args: string[], vars: Variable[], ctx: Context): void {
265+
const first = vars[0];
266+
const { lookup, path } = getLookupAndPath(ctx, args.slice(1));
267+
const n = parseInt(args[0], 10) || 0;
268+
first.set(findNthValidEntry(first.get(), path, lookup, n));
296269
}
297270
}
298271

@@ -476,6 +449,7 @@ export const CORE_FORMATTERS: FormatterTable = {
476449
'encode-uri-component': new EncodeUriComponentFormatter(),
477450
'find-first': new FindFirstFormatter(),
478451
'find-last': new FindLastFormatter(),
452+
'find-nth': new FindNthFormatter(),
479453
format: new FormatFormatter(),
480454
get: new GetFormatter(),
481455
html: new HtmlFormatter(),

src/plugins/util.find.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Context } from "../context";
2+
import { MISSING_NODE, Node, isTruthy, toNode } from "../node";
3+
import { splitVariable } from "../util";
4+
5+
export const getLookupAndPath = (ctx: Context, args: string[]) => {
6+
const argsCount = args.length;
7+
const hasLookup = argsCount === 2;
8+
const hasPath = argsCount >= 1;
9+
const lookup = hasLookup ? ctx.resolve(splitVariable(args[0])) : null;
10+
const path = hasPath ? splitVariable(args[hasLookup ? 1 : 0]) : null;
11+
return { lookup, path };
12+
};
13+
14+
export const findNthValidEntry = (
15+
items: any[],
16+
path: (string | number)[] | null,
17+
lookup: Record<string, any> | null,
18+
n: number,
19+
): Node => {
20+
if (!Array.isArray(items) || items.length === 0) {
21+
return MISSING_NODE;
22+
}
23+
let validEntries = [];
24+
const hasPath = path !== null;
25+
if (hasPath) {
26+
for (const element of items) {
27+
const node = toNode(element);
28+
const lookupNode = lookup ? toNode(lookup).get(node.asString()) : node;
29+
const candidate = lookupNode.path(path);
30+
if (isTruthy(candidate)) {
31+
validEntries.push(element);
32+
}
33+
}
34+
} else {
35+
validEntries = items;
36+
}
37+
const size = validEntries.length;
38+
if (size == 0) {
39+
return MISSING_NODE;
40+
}
41+
const index = n < 0 ? size + n : n;
42+
if (index < 0 || index >= size) {
43+
return MISSING_NODE;
44+
}
45+
return toNode(validEntries[index]);
46+
};

0 commit comments

Comments
 (0)