diff --git a/.changeset/blue-lands-joke.md b/.changeset/blue-lands-joke.md new file mode 100644 index 0000000..e5d6047 --- /dev/null +++ b/.changeset/blue-lands-joke.md @@ -0,0 +1,9 @@ +--- +"@getlang/parser": patch +"@getlang/walker": patch +"@getlang/utils": patch +"@getlang/ast": patch +"@getlang/get": patch +--- + +add custom modifiers diff --git a/packages/get/src/execute.ts b/packages/get/src/execute.ts index 74b3bd6..c77bb5d 100644 --- a/packages/get/src/execute.ts +++ b/packages/get/src/execute.ts @@ -10,7 +10,7 @@ import { callModifier } from './modifiers.js' import type { Execute } from './modules.js' import { Modules } from './modules.js' import type { RuntimeValue } from './value.js' -import { assert, toValue } from './value.js' +import { assert, materialize } from './value.js' const { NullInputError, @@ -99,7 +99,7 @@ export async function execute( return isRoot ? firstNull : '' } const els = node.elements.map(el => { - return isToken(el) ? el.value : toValue(el.data, el.typeInfo) + return isToken(el) ? el.value : materialize(el) }) const data = els.join('') return { data, typeInfo: node.typeInfo } @@ -108,7 +108,7 @@ export async function execute( async SliceExpr({ slice, typeInfo }) { try { const ctx = scope.context - const deps = ctx && toValue(ctx.data, ctx.typeInfo) + const deps = ctx && materialize(ctx) const ret = await hooks.slice(slice.value, deps) const data = ret === undefined ? new NullSelection('') : ret return { data, typeInfo } @@ -158,15 +158,14 @@ export async function execute( return { data, typeInfo: node.typeInfo } }, - ModifierExpr(node) { - invariant(scope.context, 'Unresolved context') + async ModifierExpr(node) { const mod = node.modifier.value const args = node.args.data - - return { - data: callModifier(mod, args, scope.context), - typeInfo: node.typeInfo, - } + const entry = await modules.importMod(mod) + const data = entry + ? entry.mod(scope.context?.data, args) + : callModifier(mod, args, scope.context) + return { data, typeInfo: node.typeInfo } }, ModuleExpr(node) { @@ -178,7 +177,7 @@ export async function execute( ) } return { - data: toValue(node.args.data, node.args.typeInfo), + data: materialize(node.args), typeInfo: node.typeInfo, } }, @@ -259,5 +258,5 @@ export async function execute( const modules = new Modules(hooks, executeModule) const rootEntry = await modules.import(rootModule) const ex = await executeModule(rootEntry, rootInputs) - return ex && toValue(ex.data, ex.typeInfo) + return ex && materialize(ex) } diff --git a/packages/get/src/index.ts b/packages/get/src/index.ts index c98ee78..f11d056 100644 --- a/packages/get/src/index.ts +++ b/packages/get/src/index.ts @@ -7,12 +7,11 @@ import { execute as exec } from './execute.js' function buildHooks(hooks: Hooks): Required { return { import: (module: string) => { - invariant( - hooks.import, - new ImportError('Imports are not supported by the current runtime'), - ) + const err = 'Imports are not supported by the current runtime' + invariant(hooks.import, new ImportError(err)) return hooks.import(module) }, + modifier: modifier => hooks.modifier?.(modifier), call: hooks.call ?? (() => {}), request: hooks.request ?? http.requestHook, slice: hooks.slice ?? slice.runSlice, diff --git a/packages/get/src/modifiers.ts b/packages/get/src/modifiers.ts index e446d48..edf3a09 100644 --- a/packages/get/src/modifiers.ts +++ b/packages/get/src/modifiers.ts @@ -2,24 +2,26 @@ import { cookies, html, js, json } from '@getlang/lib' import { invariant } from '@getlang/utils' import { ValueReferenceError } from '@getlang/utils/errors' import type { RuntimeValue } from './value.js' -import { toValue } from './value.js' +import { materialize } from './value.js' export function callModifier( mod: string, args: Record, - context: RuntimeValue, + context?: RuntimeValue, ) { - let { data, typeInfo } = context - if (mod === 'link') { + let ctx = context + if (context && mod === 'link') { + let { data, typeInfo } = context const tag = data.type === 'tag' ? data.name : undefined if (tag === 'a') { data = html.select(data, 'xpath:@href', false) } else if (tag === 'img') { data = html.select(data, 'xpath:@src', false) } + ctx = { data, typeInfo } } - const doc = toValue(data, typeInfo) + const doc = ctx && materialize(ctx) switch (mod) { case 'link': diff --git a/packages/get/src/modules.ts b/packages/get/src/modules.ts index 3465258..96a13cc 100644 --- a/packages/get/src/modules.ts +++ b/packages/get/src/modules.ts @@ -1,7 +1,7 @@ import type { Program, TypeInfo } from '@getlang/ast' import { Type } from '@getlang/ast' import { analyze, desugar, inference, parse } from '@getlang/parser' -import type { Hooks, Inputs } from '@getlang/utils' +import type { Hooks, Inputs, Modifier } from '@getlang/utils' import { ImportError, RecursiveCallError, @@ -9,7 +9,7 @@ import { } from '@getlang/utils/errors' import { partition } from 'lodash-es' import type { RuntimeValue } from './value.js' -import { toValue } from './value.js' +import { materialize } from './value.js' type Info = { ast: Program @@ -24,6 +24,11 @@ type Entry = { returnType: TypeInfo } +type ModEntry = { + mod: Modifier + returnType: TypeInfo +} + export type Execute = (entry: Entry, inputs: Inputs) => Promise function repr(ti: TypeInfo): string { @@ -57,6 +62,7 @@ function buildImportKey(module: string, typeInfo?: TypeInfo) { export class Modules { private info: Record> = {} private entries: Record> = {} + private modifiers: Record> = {} constructor( private hooks: Required, @@ -88,13 +94,19 @@ export class Modules { macros.push(i) } } - const { program: simplified, calls } = desugar(ast, macros) + const { program: simplified, calls, modifiers } = desugar(ast, macros) const returnTypes: Record = {} for (const call of calls) { const { returnType } = await this.import(call, stack) returnTypes[call] = returnType } + for (const mod of modifiers) { + const entry = await this.importMod(mod) + if (entry) { + returnTypes[mod] = entry.returnType + } + } const { program, returnType } = inference(simplified, { returnTypes, @@ -114,6 +126,19 @@ export class Modules { return this.entries[key] } + async compileMod(mod: string): Promise { + const compiled = await this.hooks.modifier(mod) + if (!compiled) { + return null + } + return { mod: compiled.modifier, returnType: { type: Type.Value } } + } + + importMod(mod: string) { + this.modifiers[mod] ??= this.compileMod(mod) + return this.modifiers[mod] + } + async call(module: string, args: RuntimeValue, contextType?: TypeInfo) { let entry: Entry try { @@ -153,8 +178,8 @@ export class Modules { return extracted } - const attrs = Object.fromEntries(attrArgs) - const raster = toValue(attrs, args.typeInfo) + const data = Object.fromEntries(attrArgs) + const raster = materialize({ data, typeInfo: args.typeInfo }) return { ...raster, ...extracted } } } diff --git a/packages/get/src/value.ts b/packages/get/src/value.ts index 2fcfab1..4556b05 100644 --- a/packages/get/src/value.ts +++ b/packages/get/src/value.ts @@ -10,24 +10,28 @@ export type RuntimeValue = { typeInfo: TypeInfo } -export function toValue(value: any, typeInfo: TypeInfo): any { +export function materialize({ data, typeInfo }: RuntimeValue): any { switch (typeInfo.type) { case Type.Html: - return html.toValue(value) + return html.toValue(data) case Type.Js: - return js.toValue(value) + return js.toValue(data) case Type.Headers: - return headers.toValue(value) + return headers.toValue(data) case Type.Cookies: - return cookies.toValue(value) + return cookies.toValue(data) case Type.List: - return value.map((item: any) => toValue(item, typeInfo.of)) + return data.map((item: any) => + materialize({ data: item, typeInfo: typeInfo.of }), + ) case Type.Struct: - return mapValues(value, (v, k) => toValue(v, typeInfo.schema[k]!)) + return mapValues(data, (v, k) => + materialize({ data: v, typeInfo: typeInfo.schema[k]! }), + ) case Type.Maybe: - return toValue(value, typeInfo.option) + return materialize({ data, typeInfo: typeInfo.option }) case Type.Value: - return value + return data default: throw new ValueTypeError('Unsupported conversion type') } diff --git a/packages/parser/src/passes/desugar.ts b/packages/parser/src/passes/desugar.ts index d41d805..d8d02c3 100644 --- a/packages/parser/src/passes/desugar.ts +++ b/packages/parser/src/passes/desugar.ts @@ -17,12 +17,16 @@ export type DesugarPass = ( function listCalls(ast: Program) { const calls = new Set() + const modifiers = new Set() transform(ast, { ModuleExpr(node) { node.call && calls.add(node.module.value) }, + ModifierExpr(node) { + modifiers.add(node.modifier.value) + }, }) - return calls + return { calls, modifiers } } const visitors = [resolveContext, settleLinks, insertSliceDeps, dropDrills] @@ -37,6 +41,6 @@ export function desugar(ast: Program, macros: string[] = []) { // inference pass `registerCalls` is included in the desugar phase // it produces the list of called modules required for type inference program = registerCalls(program, macros) - const calls = listCalls(program) - return { program, calls } + const { calls, modifiers } = listCalls(program) + return { program, calls, modifiers } } diff --git a/packages/parser/src/passes/inference/typeinfo.ts b/packages/parser/src/passes/inference/typeinfo.ts index 2cbc4da..4676980 100644 --- a/packages/parser/src/passes/inference/typeinfo.ts +++ b/packages/parser/src/passes/inference/typeinfo.ts @@ -209,7 +209,8 @@ export function resolveTypes(ast: Program, options: ResolveTypeOptions) { headers: { type: Type.Headers }, cookies: { type: Type.Cookies }, } - const typeInfo = modTypeMap[node.modifier.value] + const mod = node.modifier.value + const typeInfo = modTypeMap[mod] || returnTypes[mod] invariant(typeInfo, 'Modifier type lookup failed') return { ...node, typeInfo } }, diff --git a/packages/utils/src/hooks.ts b/packages/utils/src/hooks.ts index c5fe4f7..1a089f9 100644 --- a/packages/utils/src/hooks.ts +++ b/packages/utils/src/hooks.ts @@ -21,12 +21,18 @@ export type ExtractHook = ( value: any, ) => MaybePromise +export type Modifier = (context: any, options: Record) => any +export type ModifierHook = ( + modifier: string, +) => MaybePromise<{ modifier: Modifier } | undefined> + export type Hooks = Partial<{ import: ImportHook request: RequestHook slice: SliceHook call: CallHook extract: ExtractHook + modifier: ModifierHook }> type RequestInit = { diff --git a/test/calls.spec.ts b/test/calls.spec.ts index f5963f3..2e20226 100644 --- a/test/calls.spec.ts +++ b/test/calls.spec.ts @@ -113,7 +113,9 @@ describe('calls', () => { const result = await execute( modules, {}, - () => new Response(`
x
y`), + { + fetch: () => new Response(`
x
y`), + }, ) expect(result).toEqual({ div: 'x', span: 'y' }) }) @@ -134,7 +136,9 @@ describe('calls', () => { const result = await execute( modules, {}, - () => new Response(`
x
y`), + { + fetch: () => new Response(`
x
y`), + }, ) expect(result).toEqual({ div: 'x', span: 'y' }) }) @@ -170,8 +174,9 @@ describe('calls', () => { const result = await execute( modules, { query: 'gifts' }, - () => - new Response(` + { + fetch: () => + new Response(`
  • @@ -183,6 +188,7 @@ describe('calls', () => { `), + }, ) expect(result).toEqual({ items: [ @@ -217,8 +223,9 @@ describe('calls', () => { const result = await execute( modules, {}, - () => - new Response(` + { + fetch: () => + new Response(`
    @@ -229,6 +236,7 @@ describe('calls', () => {
    `), + }, ) expect(result).toEqual({ @@ -258,12 +266,14 @@ describe('calls', () => { const result = await execute( modules, {}, - () => - new Response(` + { + fetch: () => + new Response(`

    first

  • second

    `), + }, ) expect(result).toEqual({ @@ -291,7 +301,10 @@ describe('calls', () => { const result = await execute( modules, {}, - () => new Response(`

    first

    `), + { + fetch: () => + new Response(`

    first

    `), + }, ) expect(result).toEqual({ x: 1 }) diff --git a/test/helpers.ts b/test/helpers.ts index c367483..87e64c0 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,12 +1,18 @@ import { expect } from 'bun:test' import { executeModule } from '@getlang/get' import { desugar, parse, print } from '@getlang/parser' -import type { Hooks, Inputs, MaybePromise } from '@getlang/utils' +import type { Hooks, Inputs, MaybePromise, ModifierHook } from '@getlang/utils' import { invariant } from '@getlang/utils' import { ImportError } from '@getlang/utils/errors' import dedent from 'dedent' import './expect.js' +type ExecuteOptions = Partial<{ + fetch: Fetch + modifier: ModifierHook + willThrow: boolean +}> + export type Fetch = (req: Request) => MaybePromise export const SELSYN = true @@ -20,9 +26,9 @@ function testIdempotency(source: string) { export async function execute( program: string | Record, inputs?: Inputs, - fetch?: Fetch, - willThrow = false, + options: ExecuteOptions = {}, ): Promise { + const { fetch, modifier, willThrow } = options const normalized = typeof program === 'string' ? { Home: program } : program const modules: Record = {} for (const [name, source] of Object.entries(normalized)) { @@ -33,6 +39,7 @@ export async function execute( } const hooks: Hooks = { + modifier, import(module) { const src = modules[module] invariant(src, new ImportError(`Failed to import module: ${module}`)) diff --git a/test/modifiers.spec.ts b/test/modifiers.spec.ts new file mode 100644 index 0000000..dd96f31 --- /dev/null +++ b/test/modifiers.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'bun:test' +import type { Modifier } from '@getlang/utils' +import { invariant } from '@getlang/utils' +import { ValueTypeError } from '@getlang/utils/errors' +import { execute as exec } from './helpers.js' + +function execute(source: string, name: string, modifier: Modifier) { + return exec( + source, + {}, + { + modifier(mod) { + expect(mod).toEqual(name) + return { modifier } + }, + }, + ) +} + +describe('modifiers', () => { + test('hook', async () => { + const result = await execute('extract @rnd', 'rnd', () => 300) + expect(result).toEqual(300) + }) + + test('with context', async () => { + const result = await execute( + 'extract `1` -> @add_one', + 'add_one', + (ctx: number) => { + expect(ctx).toEqual(1) + return ctx + 1 + }, + ) + expect(result).toEqual(2) + }) + + test('with args', async () => { + const result = await execute( + 'extract @product({ a: `7`, b: `6` })', + 'product', + (_ctx, { a, b }) => { + invariant( + typeof a === 'number' && typeof b === 'number', + new ValueTypeError('@product expects two numbers'), + ) + expect(a).toEqual(7) + expect(b).toEqual(6) + return a * b + }, + ) + expect(result).toEqual(42) + }) +}) diff --git a/test/modules.spec.ts b/test/modules.spec.ts index f1ff3d2..aa1fe0d 100644 --- a/test/modules.spec.ts +++ b/test/modules.spec.ts @@ -17,8 +17,7 @@ describe('modules', () => { extrct { title } `, {}, - undefined, - true, + { willThrow: true }, ) return expect(result).rejects.toThrow( 'SyntaxError: Invalid token at line 3 col 1:\n\n1 GET https://test.com\n2 \n3 extrct { title }\n ^', diff --git a/test/request.spec.ts b/test/request.spec.ts index 79fd4a1..1c77671 100644 --- a/test/request.spec.ts +++ b/test/request.spec.ts @@ -13,7 +13,7 @@ const mockFetch = mock( ) const execute = (src: string, inputs: Inputs = {}, fetch: Fetch = mockFetch) => - _exec(src, inputs, fetch) + _exec(src, inputs, { fetch }) beforeEach(() => { mockFetch.mockClear() diff --git a/test/values.spec.ts b/test/values.spec.ts index 4b7b198..3fe9204 100644 --- a/test/values.spec.ts +++ b/test/values.spec.ts @@ -303,7 +303,9 @@ describe('values', () => { } `, {}, - () => new Response('

    test

    ', { headers }), + { + fetch: () => new Response('

    test

    ', { headers }), + }, ) expect(result).toEqual({ all: expect.objectContaining({ @@ -442,7 +444,7 @@ describe('values', () => { const result = await execute( src, {}, - () => new Response('

    test

    '), + { fetch: () => new Response('

    test

    ') }, ) expect(result).toEqual({}) })