From 2b4bd4c1bb277370affa0fbf1ad7765b1da4b779 Mon Sep 17 00:00:00 2001 From: Logan Brown Date: Tue, 2 Jun 2026 13:59:45 -0400 Subject: [PATCH 1/3] Added find-first and find-last formatters with corresponding tests --- __tests__/plugins/formatters.core.test.ts | 72 +++++++++++++++++++ .../plugins/resources/f-find-first-1.html | 14 ++++ .../plugins/resources/f-find-first-2.html | 15 ++++ .../plugins/resources/f-find-last-1.html | 14 ++++ .../plugins/resources/f-find-last-2.html | 15 ++++ src/plugins/formatters.core.ts | 55 ++++++++++++++ 6 files changed, 185 insertions(+) create mode 100644 __tests__/plugins/resources/f-find-first-1.html create mode 100644 __tests__/plugins/resources/f-find-first-2.html create mode 100644 __tests__/plugins/resources/f-find-last-1.html create mode 100644 __tests__/plugins/resources/f-find-last-2.html diff --git a/__tests__/plugins/formatters.core.test.ts b/__tests__/plugins/formatters.core.test.ts index 5f32bcd..9490671 100644 --- a/__tests__/plugins/formatters.core.test.ts +++ b/__tests__/plugins/formatters.core.test.ts @@ -370,6 +370,78 @@ loader.paths('f-json-pretty-%N.html').forEach((path) => { test(`json pretty - ${path}`, () => loader.execute(path)); }); +test('find-first', () => { + // no args: return first element unconditionally + let vars = variables(['a', 'b', 'c']); + Core['find-first'].apply([], vars, CTX); + expect(vars[0].get()).toEqual('a'); + + // empty array → missing + vars = variables([]); + Core['find-first'].apply([], vars, CTX); + expect(vars[0].node.isMissing()).toBe(true); + + // non-array → missing + vars = variables('not-an-array'); + Core['find-first'].apply([], vars, CTX); + expect(vars[0].node.isMissing()).toBe(true); + + // no args: first object element + vars = variables([{ x: 1 }, { x: 2 }]); + Core['find-first'].apply([], vars, CTX); + expect(vars[0].get()).toEqual({ x: 1 }); + + // 1 arg: find first element where path is truthy + vars = variables([{ id: 'a', enabled: false }, { id: 'b', enabled: true }, { id: 'c', enabled: true }]); + Core['find-first'].apply(['enabled'], vars, CTX); + expect(vars[0].get()).toEqual({ id: 'b', enabled: true }); + + // 1 arg: none match → missing + vars = variables([{ id: 'a', enabled: false }, { id: 'b' }]); + Core['find-first'].apply(['enabled'], vars, CTX); + expect(vars[0].node.isMissing()).toBe(true); +}); + +loader.paths('f-find-first-%N.html').forEach((path) => { + test(`find-first - ${path}`, () => loader.execute(path)); +}); + +test('find-last', () => { + // no args: return last element unconditionally + let vars = variables(['a', 'b', 'c']); + Core['find-last'].apply([], vars, CTX); + expect(vars[0].get()).toEqual('c'); + + // empty array → missing + vars = variables([]); + Core['find-last'].apply([], vars, CTX); + expect(vars[0].node.isMissing()).toBe(true); + + // non-array → missing + vars = variables('not-an-array'); + Core['find-last'].apply([], vars, CTX); + expect(vars[0].node.isMissing()).toBe(true); + + // no args: last object element + vars = variables([{ x: 1 }, { x: 2 }]); + Core['find-last'].apply([], vars, CTX); + expect(vars[0].get()).toEqual({ x: 2 }); + + // 1 arg: find last element where path is truthy + vars = variables([{ id: 'a', enabled: true }, { id: 'b', enabled: true }, { id: 'c', enabled: false }]); + Core['find-last'].apply(['enabled'], vars, CTX); + expect(vars[0].get()).toEqual({ id: 'b', enabled: true }); + + // 1 arg: none match → missing + vars = variables([{ id: 'a', enabled: false }, { id: 'b' }]); + Core['find-last'].apply(['enabled'], vars, CTX); + expect(vars[0].node.isMissing()).toBe(true); +}); + +loader.paths('f-find-last-%N.html').forEach((path) => { + test(`find-last - ${path}`, () => loader.execute(path)); +}); + test('key-by', () => { let vars = variables([{ id: 1 }]); Core['key-by'].apply([], vars, CTX); diff --git a/__tests__/plugins/resources/f-find-first-1.html b/__tests__/plugins/resources/f-find-first-1.html new file mode 100644 index 0000000..40ada3d --- /dev/null +++ b/__tests__/plugins/resources/f-find-first-1.html @@ -0,0 +1,14 @@ +:JSON +{ + "items": [ + { "id": "a", "enabled": false }, + { "id": "b", "enabled": true }, + { "id": "c", "enabled": true } + ] +} + +:TEMPLATE +{.var @first items|find-first enabled}{@first.id} + +:OUTPUT +b diff --git a/__tests__/plugins/resources/f-find-first-2.html b/__tests__/plugins/resources/f-find-first-2.html new file mode 100644 index 0000000..4ddc9ec --- /dev/null +++ b/__tests__/plugins/resources/f-find-first-2.html @@ -0,0 +1,15 @@ +:JSON +{ + "keys": ["a", "b", "c"], + "map": { + "a": { "enabled": false }, + "b": { "enabled": true }, + "c": { "enabled": true } + } +} + +:TEMPLATE +{keys|find-first map enabled} + +:OUTPUT +b diff --git a/__tests__/plugins/resources/f-find-last-1.html b/__tests__/plugins/resources/f-find-last-1.html new file mode 100644 index 0000000..b3479da --- /dev/null +++ b/__tests__/plugins/resources/f-find-last-1.html @@ -0,0 +1,14 @@ +:JSON +{ + "items": [ + { "id": "a", "enabled": true }, + { "id": "b", "enabled": true }, + { "id": "c", "enabled": false } + ] +} + +:TEMPLATE +{.var @last items|find-last enabled}{@last.id} + +:OUTPUT +b diff --git a/__tests__/plugins/resources/f-find-last-2.html b/__tests__/plugins/resources/f-find-last-2.html new file mode 100644 index 0000000..6a22c85 --- /dev/null +++ b/__tests__/plugins/resources/f-find-last-2.html @@ -0,0 +1,15 @@ +:JSON +{ + "keys": ["a", "b", "c"], + "map": { + "a": { "enabled": true }, + "b": { "enabled": true }, + "c": { "enabled": false } + } +} + +:TEMPLATE +{keys|find-last map enabled} + +:OUTPUT +b diff --git a/src/plugins/formatters.core.ts b/src/plugins/formatters.core.ts index 48689d7..9298c7d 100644 --- a/src/plugins/formatters.core.ts +++ b/src/plugins/formatters.core.ts @@ -243,6 +243,59 @@ export class KeyByFormatter extends Formatter { } } +export class FindFirstFormatter extends Formatter { + apply(args: string[], vars: Variable[], ctx: Context): void { + const first = vars[0]; + if (first.node.type !== Type.ARRAY || first.node.value.length === 0) { + first.set(MISSING_NODE); + return; + } + if (args.length === 0) { + first.set(new Node(first.get()[0])); + return; + } + const hasLookup = args.length >= 2; + const lookup = hasLookup ? ctx.resolve(splitVariable(args[0])) : null; + const path = splitVariable(args[hasLookup ? 1 : 0]); + for (const val of first.get()) { + const element = new Node(val); + const candidate = hasLookup ? lookup!.path([element.asString()]) : element; + if (isTruthy(candidate.path(path))) { + first.set(element); + return; + } + } + first.set(MISSING_NODE); + } +} + +export class FindLastFormatter extends Formatter { + apply(args: string[], vars: Variable[], ctx: Context): void { + const first = vars[0]; + const arr: any[] = first.node.type === Type.ARRAY ? first.get() : null; + if (!arr || arr.length === 0) { + first.set(MISSING_NODE); + return; + } + if (args.length === 0) { + first.set(new Node(arr[arr.length - 1])); + return; + } + const hasLookup = args.length >= 2; + const lookup = hasLookup ? ctx.resolve(splitVariable(args[0])) : null; + const path = splitVariable(args[hasLookup ? 1 : 0]); + for (let i = arr.length - 1; i >= 0; i--) { + const element = new Node(arr[i]); + const candidate = hasLookup ? lookup!.path([element.asString()]) : element; + if (isTruthy(candidate.path(path))) { + first.set(element); + return; + } + } + first.set(MISSING_NODE); + } +} + const NEWLINE = /\n/g; export class LineBreaksFormatter extends Formatter { @@ -421,6 +474,8 @@ export const CORE_FORMATTERS: FormatterTable = { 'encode-space': new EncodeSpaceFormatter(), 'encode-uri': new EncodeUriFormatter(), 'encode-uri-component': new EncodeUriComponentFormatter(), + 'find-first': new FindFirstFormatter(), + 'find-last': new FindLastFormatter(), format: new FormatFormatter(), get: new GetFormatter(), html: new HtmlFormatter(), From 7457082e63f98e05a982946dd4426e19a2a2b18f Mon Sep 17 00:00:00 2001 From: Logan Brown Date: Thu, 4 Jun 2026 09:22:53 -0400 Subject: [PATCH 2/3] created helper function for shared logic + added find-nth --- __tests__/plugins/formatters.core.test.ts | 81 +++++++++++++++++++++++ src/plugins/formatters.core.ts | 56 +++++----------- src/plugins/util.find.ts | 46 +++++++++++++ 3 files changed, 142 insertions(+), 41 deletions(-) create mode 100644 src/plugins/util.find.ts diff --git a/__tests__/plugins/formatters.core.test.ts b/__tests__/plugins/formatters.core.test.ts index 9490671..59c382b 100644 --- a/__tests__/plugins/formatters.core.test.ts +++ b/__tests__/plugins/formatters.core.test.ts @@ -442,6 +442,87 @@ loader.paths('f-find-last-%N.html').forEach((path) => { test(`find-last - ${path}`, () => loader.execute(path)); }); +test('find-nth', () => { + // no filter: return element at index 0 + let vars = variables(['a', 'b', 'c']); + Core['find-nth'].apply(['0'], vars, CTX); + expect(vars[0].get()).toEqual('a'); + + // no filter: return element at index 1 + vars = variables(['a', 'b', 'c']); + Core['find-nth'].apply(['1'], vars, CTX); + expect(vars[0].get()).toEqual('b'); + + // no filter: negative index counts from end + vars = variables(['a', 'b', 'c']); + Core['find-nth'].apply(['-1'], vars, CTX); + expect(vars[0].get()).toEqual('c'); + + vars = variables(['a', 'b', 'c']); + Core['find-nth'].apply(['-2'], vars, CTX); + expect(vars[0].get()).toEqual('b'); + + // non-numeric index defaults to 0 + vars = variables(['a', 'b', 'c']); + Core['find-nth'].apply(['foo'], vars, CTX); + expect(vars[0].get()).toEqual('a'); + + // index out of bounds → missing + vars = variables(['a', 'b', 'c']); + Core['find-nth'].apply(['5'], vars, CTX); + expect(vars[0].node.isMissing()).toBe(true); + + // negative index out of bounds → missing + vars = variables(['a', 'b', 'c']); + Core['find-nth'].apply(['-5'], vars, CTX); + expect(vars[0].node.isMissing()).toBe(true); + + // empty array → missing + vars = variables([]); + Core['find-nth'].apply(['0'], vars, CTX); + expect(vars[0].node.isMissing()).toBe(true); + + // non-array → missing + vars = variables('not-an-array'); + Core['find-nth'].apply(['0'], vars, CTX); + expect(vars[0].node.isMissing()).toBe(true); + + // no filter: nth object element + vars = variables([{ x: 1 }, { x: 2 }, { x: 3 }]); + Core['find-nth'].apply(['2'], vars, CTX); + expect(vars[0].get()).toEqual({ x: 3 }); + + // with path: find nth element where path is truthy + vars = variables([ + { id: 'a', enabled: false }, + { id: 'b', enabled: true }, + { id: 'c', enabled: true }, + { id: 'd', enabled: true }, + ]); + Core['find-nth'].apply(['1', 'enabled'], vars, CTX); + expect(vars[0].get()).toEqual({ id: 'c', enabled: true }); + + // with path: index 0 of matching elements + vars = variables([{ id: 'a', enabled: false }, { id: 'b', enabled: true }, { id: 'c', enabled: true }]); + Core['find-nth'].apply(['0', 'enabled'], vars, CTX); + expect(vars[0].get()).toEqual({ id: 'b', enabled: true }); + + // with path: negative index among matching elements + vars = variables([{ id: 'a', enabled: true }, { id: 'b', enabled: false }, { id: 'c', enabled: true }]); + Core['find-nth'].apply(['-1', 'enabled'], vars, CTX); + expect(vars[0].get()).toEqual({ id: 'c', enabled: true }); + + // with path: none match → missing + vars = variables([{ id: 'a', enabled: false }, { id: 'b' }]); + Core['find-nth'].apply(['0', 'enabled'], vars, CTX); + expect(vars[0].node.isMissing()).toBe(true); + + // with path: index out of bounds among matches → missing + vars = variables([{ id: 'a', enabled: true }, { id: 'b', enabled: false }]); + Core['find-nth'].apply(['1', 'enabled'], vars, CTX); + expect(vars[0].node.isMissing()).toBe(true); +}); + test('key-by', () => { let vars = variables([{ id: 1 }]); Core['key-by'].apply([], vars, CTX); diff --git a/src/plugins/formatters.core.ts b/src/plugins/formatters.core.ts index 9298c7d..9702217 100644 --- a/src/plugins/formatters.core.ts +++ b/src/plugins/formatters.core.ts @@ -8,6 +8,7 @@ import { Variable } from '../variable'; import { Type } from '../types'; import { executeTemplate } from '../exec'; import { splitVariable } from '../util'; +import { findNthValidEntry, getLookupAndPath } from './util.find'; import { format } from './util.format'; import { escapeHtmlAttributes, escapeScriptTags, slugify, truncate } from './util.string'; import utf8 from 'utf8'; @@ -246,53 +247,25 @@ export class KeyByFormatter extends Formatter { export class FindFirstFormatter extends Formatter { apply(args: string[], vars: Variable[], ctx: Context): void { const first = vars[0]; - if (first.node.type !== Type.ARRAY || first.node.value.length === 0) { - first.set(MISSING_NODE); - return; - } - if (args.length === 0) { - first.set(new Node(first.get()[0])); - return; - } - const hasLookup = args.length >= 2; - const lookup = hasLookup ? ctx.resolve(splitVariable(args[0])) : null; - const path = splitVariable(args[hasLookup ? 1 : 0]); - for (const val of first.get()) { - const element = new Node(val); - const candidate = hasLookup ? lookup!.path([element.asString()]) : element; - if (isTruthy(candidate.path(path))) { - first.set(element); - return; - } - } - first.set(MISSING_NODE); + const { lookup, path } = getLookupAndPath(ctx, args); + first.set(findNthValidEntry(first.get(), path, lookup, 0)); } } export class FindLastFormatter extends Formatter { apply(args: string[], vars: Variable[], ctx: Context): void { const first = vars[0]; - const arr: any[] = first.node.type === Type.ARRAY ? first.get() : null; - if (!arr || arr.length === 0) { - first.set(MISSING_NODE); - return; - } - if (args.length === 0) { - first.set(new Node(arr[arr.length - 1])); - return; - } - const hasLookup = args.length >= 2; - const lookup = hasLookup ? ctx.resolve(splitVariable(args[0])) : null; - const path = splitVariable(args[hasLookup ? 1 : 0]); - for (let i = arr.length - 1; i >= 0; i--) { - const element = new Node(arr[i]); - const candidate = hasLookup ? lookup!.path([element.asString()]) : element; - if (isTruthy(candidate.path(path))) { - first.set(element); - return; - } - } - first.set(MISSING_NODE); + const { lookup, path } = getLookupAndPath(ctx, args); + first.set(findNthValidEntry(first.get(), path, lookup, -1)); + } +} + +export class FindNthFormatter extends Formatter { + apply(args: string[], vars: Variable[], ctx: Context): void { + const first = vars[0]; + const { lookup, path } = getLookupAndPath(ctx, args.slice(1)); + const n = parseInt(args[0], 10) || 0; + first.set(findNthValidEntry(first.get(), path, lookup, n)); } } @@ -476,6 +449,7 @@ export const CORE_FORMATTERS: FormatterTable = { 'encode-uri-component': new EncodeUriComponentFormatter(), 'find-first': new FindFirstFormatter(), 'find-last': new FindLastFormatter(), + 'find-nth': new FindNthFormatter(), format: new FormatFormatter(), get: new GetFormatter(), html: new HtmlFormatter(), diff --git a/src/plugins/util.find.ts b/src/plugins/util.find.ts new file mode 100644 index 0000000..0435c02 --- /dev/null +++ b/src/plugins/util.find.ts @@ -0,0 +1,46 @@ +import { Context } from "../context"; +import { MISSING_NODE, Node, isTruthy, toNode } from "../node"; +import { splitVariable } from "../util"; + +export const getLookupAndPath = (ctx: Context, args: string[]) => { + const argsCount = args.length; + const hasLookup = argsCount === 2; + const hasPath = argsCount >= 1; + const lookup = hasLookup ? ctx.resolve(splitVariable(args[0])) : null; + const path = hasPath ? splitVariable(args[hasLookup ? 1 : 0]) : null; + return { lookup, path }; +}; + +export const findNthValidEntry = ( + items: any[], + path: (string | number)[] | null, + lookup: Record | null, + n: number, +): Node => { + if (!Array.isArray(items) || items.length === 0) { + return MISSING_NODE; + } + let validEntries = []; + const hasPath = path !== null; + if (hasPath) { + for (const element of items) { + const node = toNode(element); + const lookupNode = lookup ? toNode(lookup).get(node.asString()) : node; + const candidate = lookupNode.path(path); + if (isTruthy(candidate)) { + validEntries.push(element); + } + } + } else { + validEntries = items; + } + const size = validEntries.length; + if (size == 0) { + return MISSING_NODE; + } + const index = n < 0 ? size + n : n; + if (index < 0 || index >= size) { + return MISSING_NODE; + } + return toNode(validEntries[index]); +}; From a912bb65a628830d1ad674cd94e6f9c62a030604 Mon Sep 17 00:00:00 2001 From: Logan Brown Date: Thu, 4 Jun 2026 11:07:55 -0400 Subject: [PATCH 3/3] removed find-nth formatter, optimized utils and converted all test to use fixtures --- __tests__/plugins/formatters.core.test.ts | 145 ------------------ .../plugins/resources/f-find-first-1.html | 4 + .../plugins/resources/f-find-first-3.html | 22 +++ .../plugins/resources/f-find-last-3.html | 22 +++ src/plugins/formatters.core.ts | 12 +- src/plugins/util.find.ts | 57 ++++--- 6 files changed, 77 insertions(+), 185 deletions(-) create mode 100644 __tests__/plugins/resources/f-find-first-3.html create mode 100644 __tests__/plugins/resources/f-find-last-3.html diff --git a/__tests__/plugins/formatters.core.test.ts b/__tests__/plugins/formatters.core.test.ts index 59c382b..b2f7a98 100644 --- a/__tests__/plugins/formatters.core.test.ts +++ b/__tests__/plugins/formatters.core.test.ts @@ -370,159 +370,14 @@ loader.paths('f-json-pretty-%N.html').forEach((path) => { test(`json pretty - ${path}`, () => loader.execute(path)); }); -test('find-first', () => { - // no args: return first element unconditionally - let vars = variables(['a', 'b', 'c']); - Core['find-first'].apply([], vars, CTX); - expect(vars[0].get()).toEqual('a'); - - // empty array → missing - vars = variables([]); - Core['find-first'].apply([], vars, CTX); - expect(vars[0].node.isMissing()).toBe(true); - - // non-array → missing - vars = variables('not-an-array'); - Core['find-first'].apply([], vars, CTX); - expect(vars[0].node.isMissing()).toBe(true); - - // no args: first object element - vars = variables([{ x: 1 }, { x: 2 }]); - Core['find-first'].apply([], vars, CTX); - expect(vars[0].get()).toEqual({ x: 1 }); - - // 1 arg: find first element where path is truthy - vars = variables([{ id: 'a', enabled: false }, { id: 'b', enabled: true }, { id: 'c', enabled: true }]); - Core['find-first'].apply(['enabled'], vars, CTX); - expect(vars[0].get()).toEqual({ id: 'b', enabled: true }); - - // 1 arg: none match → missing - vars = variables([{ id: 'a', enabled: false }, { id: 'b' }]); - Core['find-first'].apply(['enabled'], vars, CTX); - expect(vars[0].node.isMissing()).toBe(true); -}); - loader.paths('f-find-first-%N.html').forEach((path) => { test(`find-first - ${path}`, () => loader.execute(path)); }); -test('find-last', () => { - // no args: return last element unconditionally - let vars = variables(['a', 'b', 'c']); - Core['find-last'].apply([], vars, CTX); - expect(vars[0].get()).toEqual('c'); - - // empty array → missing - vars = variables([]); - Core['find-last'].apply([], vars, CTX); - expect(vars[0].node.isMissing()).toBe(true); - - // non-array → missing - vars = variables('not-an-array'); - Core['find-last'].apply([], vars, CTX); - expect(vars[0].node.isMissing()).toBe(true); - - // no args: last object element - vars = variables([{ x: 1 }, { x: 2 }]); - Core['find-last'].apply([], vars, CTX); - expect(vars[0].get()).toEqual({ x: 2 }); - - // 1 arg: find last element where path is truthy - vars = variables([{ id: 'a', enabled: true }, { id: 'b', enabled: true }, { id: 'c', enabled: false }]); - Core['find-last'].apply(['enabled'], vars, CTX); - expect(vars[0].get()).toEqual({ id: 'b', enabled: true }); - - // 1 arg: none match → missing - vars = variables([{ id: 'a', enabled: false }, { id: 'b' }]); - Core['find-last'].apply(['enabled'], vars, CTX); - expect(vars[0].node.isMissing()).toBe(true); -}); - loader.paths('f-find-last-%N.html').forEach((path) => { test(`find-last - ${path}`, () => loader.execute(path)); }); -test('find-nth', () => { - // no filter: return element at index 0 - let vars = variables(['a', 'b', 'c']); - Core['find-nth'].apply(['0'], vars, CTX); - expect(vars[0].get()).toEqual('a'); - - // no filter: return element at index 1 - vars = variables(['a', 'b', 'c']); - Core['find-nth'].apply(['1'], vars, CTX); - expect(vars[0].get()).toEqual('b'); - - // no filter: negative index counts from end - vars = variables(['a', 'b', 'c']); - Core['find-nth'].apply(['-1'], vars, CTX); - expect(vars[0].get()).toEqual('c'); - - vars = variables(['a', 'b', 'c']); - Core['find-nth'].apply(['-2'], vars, CTX); - expect(vars[0].get()).toEqual('b'); - - // non-numeric index defaults to 0 - vars = variables(['a', 'b', 'c']); - Core['find-nth'].apply(['foo'], vars, CTX); - expect(vars[0].get()).toEqual('a'); - - // index out of bounds → missing - vars = variables(['a', 'b', 'c']); - Core['find-nth'].apply(['5'], vars, CTX); - expect(vars[0].node.isMissing()).toBe(true); - - // negative index out of bounds → missing - vars = variables(['a', 'b', 'c']); - Core['find-nth'].apply(['-5'], vars, CTX); - expect(vars[0].node.isMissing()).toBe(true); - - // empty array → missing - vars = variables([]); - Core['find-nth'].apply(['0'], vars, CTX); - expect(vars[0].node.isMissing()).toBe(true); - - // non-array → missing - vars = variables('not-an-array'); - Core['find-nth'].apply(['0'], vars, CTX); - expect(vars[0].node.isMissing()).toBe(true); - - // no filter: nth object element - vars = variables([{ x: 1 }, { x: 2 }, { x: 3 }]); - Core['find-nth'].apply(['2'], vars, CTX); - expect(vars[0].get()).toEqual({ x: 3 }); - - // with path: find nth element where path is truthy - vars = variables([ - { id: 'a', enabled: false }, - { id: 'b', enabled: true }, - { id: 'c', enabled: true }, - { id: 'd', enabled: true }, - ]); - Core['find-nth'].apply(['1', 'enabled'], vars, CTX); - expect(vars[0].get()).toEqual({ id: 'c', enabled: true }); - - // with path: index 0 of matching elements - vars = variables([{ id: 'a', enabled: false }, { id: 'b', enabled: true }, { id: 'c', enabled: true }]); - Core['find-nth'].apply(['0', 'enabled'], vars, CTX); - expect(vars[0].get()).toEqual({ id: 'b', enabled: true }); - - // with path: negative index among matching elements - vars = variables([{ id: 'a', enabled: true }, { id: 'b', enabled: false }, { id: 'c', enabled: true }]); - Core['find-nth'].apply(['-1', 'enabled'], vars, CTX); - expect(vars[0].get()).toEqual({ id: 'c', enabled: true }); - - // with path: none match → missing - vars = variables([{ id: 'a', enabled: false }, { id: 'b' }]); - Core['find-nth'].apply(['0', 'enabled'], vars, CTX); - expect(vars[0].node.isMissing()).toBe(true); - - // with path: index out of bounds among matches → missing - vars = variables([{ id: 'a', enabled: true }, { id: 'b', enabled: false }]); - Core['find-nth'].apply(['1', 'enabled'], vars, CTX); - expect(vars[0].node.isMissing()).toBe(true); -}); - test('key-by', () => { let vars = variables([{ id: 1 }]); Core['key-by'].apply([], vars, CTX); diff --git a/__tests__/plugins/resources/f-find-first-1.html b/__tests__/plugins/resources/f-find-first-1.html index 40ada3d..a18b29c 100644 --- a/__tests__/plugins/resources/f-find-first-1.html +++ b/__tests__/plugins/resources/f-find-first-1.html @@ -8,7 +8,11 @@ } :TEMPLATE +{.var @first items|find-first}{@first.id} {.var @first items|find-first enabled}{@first.id} +{.var @first items|find-first disabled}{@first.id} {# Note: "disabled" is not a valid path, so it should return nothing #} :OUTPUT +a b + diff --git a/__tests__/plugins/resources/f-find-first-3.html b/__tests__/plugins/resources/f-find-first-3.html new file mode 100644 index 0000000..8e53240 --- /dev/null +++ b/__tests__/plugins/resources/f-find-first-3.html @@ -0,0 +1,22 @@ +:JSON +{ + "strings": ["a", "b", "c"], + "empty": [], + "notArray": "not-an-array", + "objects": [{ "x": 1 }, { "x": 2 }], + "noMatch": [{ "id": "a", "enabled": false }, { "id": "b" }] +} + +:TEMPLATE +{.var @r strings|find-first}{@r} +{.var @r empty|find-first}-{@r} +{.var @r notArray|find-first}-{@r} +{.var @r objects|find-first}{@r.x} +{.var @r noMatch|find-first enabled}-{@r} + +:OUTPUT +a +- +- +1 +- diff --git a/__tests__/plugins/resources/f-find-last-3.html b/__tests__/plugins/resources/f-find-last-3.html new file mode 100644 index 0000000..95d6222 --- /dev/null +++ b/__tests__/plugins/resources/f-find-last-3.html @@ -0,0 +1,22 @@ +:JSON +{ + "strings": ["a", "b", "c"], + "empty": [], + "notArray": "not-an-array", + "objects": [{ "x": 1 }, { "x": 2 }], + "noMatch": [{ "id": "a", "enabled": false }, { "id": "b" }] +} + +:TEMPLATE +{.var @r strings|find-last}{@r} +{.var @r empty|find-last}-{@r} +{.var @r notArray|find-last}-{@r} +{.var @r objects|find-last}{@r.x} +{.var @r noMatch|find-last enabled}-{@r} + +:OUTPUT +c +- +- +2 +- diff --git a/src/plugins/formatters.core.ts b/src/plugins/formatters.core.ts index 9702217..bdbc075 100644 --- a/src/plugins/formatters.core.ts +++ b/src/plugins/formatters.core.ts @@ -248,7 +248,7 @@ export class FindFirstFormatter extends Formatter { apply(args: string[], vars: Variable[], ctx: Context): void { const first = vars[0]; const { lookup, path } = getLookupAndPath(ctx, args); - first.set(findNthValidEntry(first.get(), path, lookup, 0)); + first.set(findNthValidEntry(first.get(), path, lookup, 1)); } } @@ -260,15 +260,6 @@ export class FindLastFormatter extends Formatter { } } -export class FindNthFormatter extends Formatter { - apply(args: string[], vars: Variable[], ctx: Context): void { - const first = vars[0]; - const { lookup, path } = getLookupAndPath(ctx, args.slice(1)); - const n = parseInt(args[0], 10) || 0; - first.set(findNthValidEntry(first.get(), path, lookup, n)); - } -} - const NEWLINE = /\n/g; export class LineBreaksFormatter extends Formatter { @@ -449,7 +440,6 @@ export const CORE_FORMATTERS: FormatterTable = { 'encode-uri-component': new EncodeUriComponentFormatter(), 'find-first': new FindFirstFormatter(), 'find-last': new FindLastFormatter(), - 'find-nth': new FindNthFormatter(), format: new FormatFormatter(), get: new GetFormatter(), html: new HtmlFormatter(), diff --git a/src/plugins/util.find.ts b/src/plugins/util.find.ts index 0435c02..945c943 100644 --- a/src/plugins/util.find.ts +++ b/src/plugins/util.find.ts @@ -1,6 +1,6 @@ -import { Context } from "../context"; -import { MISSING_NODE, Node, isTruthy, toNode } from "../node"; -import { splitVariable } from "../util"; +import { Context } from '../context'; +import { MISSING_NODE, Node, isTruthy, toNode } from '../node'; +import { splitVariable } from '../util'; export const getLookupAndPath = (ctx: Context, args: string[]) => { const argsCount = args.length; @@ -11,36 +11,35 @@ export const getLookupAndPath = (ctx: Context, args: string[]) => { return { lookup, path }; }; -export const findNthValidEntry = ( - items: any[], - path: (string | number)[] | null, - lookup: Record | null, - n: number, -): Node => { +export const findNthValidEntry = (items: Node, path: (string | number)[] | null, lookup: Node | null, nth: number): Node => { if (!Array.isArray(items) || items.length === 0) { return MISSING_NODE; } - let validEntries = []; - const hasPath = path !== null; - if (hasPath) { - for (const element of items) { - const node = toNode(element); - const lookupNode = lookup ? toNode(lookup).get(node.asString()) : node; - const candidate = lookupNode.path(path); - if (isTruthy(candidate)) { - validEntries.push(element); + const forward = nth > 0; + const start = forward ? 0 : items.length - 1; + const end = forward ? items.length : -1; + const step = forward ? 1 : -1; + + let count = 0; + const hasLookup = lookup != null; + const hasPath = path != null; + + for (let i = start; forward ? i < end : i > end; i += step) { + const node = toNode(items[i]); + if (!hasPath) { + count += step; + if (count == nth) { + return node; + } + } else { + const candidate = hasLookup ? lookup.path([node.asString()]) : node; + if (isTruthy(candidate.path(path))) { + count += step; + if (count == nth) { + return node; + } } } - } else { - validEntries = items; - } - const size = validEntries.length; - if (size == 0) { - return MISSING_NODE; - } - const index = n < 0 ? size + n : n; - if (index < 0 || index >= size) { - return MISSING_NODE; } - return toNode(validEntries[index]); + return MISSING_NODE; };