diff --git a/.changeset/stupid-flowers-sin.md b/.changeset/stupid-flowers-sin.md new file mode 100644 index 0000000..99c9bec --- /dev/null +++ b/.changeset/stupid-flowers-sin.md @@ -0,0 +1,8 @@ +--- +"@getlang/parser": minor +"@getlang/utils": minor +"@getlang/get": minor +"@getlang/lib": minor +--- + +module call context diff --git a/biome.json b/biome.json index dc4c811..613f78d 100644 --- a/biome.json +++ b/biome.json @@ -33,7 +33,13 @@ "style": { "noUselessElse": "off", "noNonNullAssertion": "off", - "useBlockStatements": "error" + "useBlockStatements": "error", + "useImportType": { + "level": "error", + "options": { + "style": "separatedType" + } + } } } }, diff --git a/bun.lockb b/bun.lockb index a1604ed..fe03590 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/get/src/context.ts b/packages/get/src/context.ts new file mode 100644 index 0000000..2726df6 --- /dev/null +++ b/packages/get/src/context.ts @@ -0,0 +1,47 @@ +import type { CExpr, Expr } from '@getlang/parser/ast' +import type { RootScope } from '@getlang/parser/scope' +import type { TypeInfo } from '@getlang/parser/typeinfo' +import { Type } from '@getlang/parser/typeinfo' +import type { MaybePromise } from '@getlang/utils' +import { NullSelection } from '@getlang/utils' +import { assert } from './value.js' + +type Contextual = { value: any; typeInfo: TypeInfo } + +export async function withContext( + scope: RootScope, + node: CExpr, + visit: (node: Expr) => MaybePromise, + cb: (ctx?: Contextual) => MaybePromise, +): Promise { + async function unwrap( + context: Contextual | undefined, + cb: (ctx?: Contextual) => MaybePromise, + ): Promise { + if (context?.typeInfo.type === Type.List) { + const list = [] + for (const item of context.value) { + const itemCtx = { value: item, typeInfo: context.typeInfo.of } + list.push(await unwrap(itemCtx, cb)) + } + return list + } + + context && scope.pushContext(context.value) + const value = await cb(context) + context && scope.popContext() + return value + } + + let context: Contextual | undefined + if (node.context) { + let value = await visit(node.context) + const optional = node.typeInfo.type === Type.Maybe + value = optional ? value : assert(value) + if (value instanceof NullSelection) { + return value + } + context = { value, typeInfo: node.context.typeInfo } + } + return unwrap(context, cb) +} diff --git a/packages/get/src/execute.ts b/packages/get/src/execute.ts index b93c83e..8fcf8ae 100644 --- a/packages/get/src/execute.ts +++ b/packages/get/src/execute.ts @@ -1,81 +1,35 @@ import { cookies, headers, html, http, js, json } from '@getlang/lib' -import type { CExpr, Expr, Program, Stmt } from '@getlang/parser/ast' +import type { Node, Stmt } from '@getlang/parser/ast' import { isToken, NodeKind } from '@getlang/parser/ast' import { RootScope } from '@getlang/parser/scope' import type { TypeInfo } from '@getlang/parser/typeinfo' import { Type } from '@getlang/parser/typeinfo' -import { type AsyncInterpretVisitor, visit } from '@getlang/parser/visitor' -import type { Hooks, MaybePromise } from '@getlang/utils' -import { - ImportError, - invariant, +import type { AsyncInterpretVisitor } from '@getlang/parser/visitor' +import { visit } from '@getlang/parser/visitor' +import type { Hooks, Inputs } from '@getlang/utils' +import { invariant, NullSelection } from '@getlang/utils' +import * as errors from '@getlang/utils/errors' +import { withContext } from './context.js' +import { callModifier } from './modifiers.js' +import type { Execute } from './modules.js' +import { Modules } from './modules.js' +import { assert, toValue } from './value.js' + +const { NullInputError, - NullSelection, - NullSelectionError, QuerySyntaxError, SliceError, + UnknownInputsError, ValueReferenceError, -} from '@getlang/utils' -import { mapValues } from 'lodash-es' - -export type InternalHooks = Omit & { - import: (module: string) => MaybePromise -} - -type Contextual = { value: any; typeInfo: TypeInfo } - -function assert(value: any) { - if (value instanceof NullSelection) { - throw new NullSelectionError(value.selector) - } - return value -} - -function toValue(value: any, typeInfo: TypeInfo): any { - switch (typeInfo.type) { - case Type.Html: - return html.toValue(value) - case Type.Js: - return js.toValue(value) - case Type.Headers: - return headers.toValue(value) - case Type.Cookies: - return cookies.toValue(value) - case Type.List: - return value.map((item: any) => toValue(item, typeInfo.of)) - case Type.Struct: - return mapValues(value, (v, k) => toValue(v, typeInfo.schema[k]!)) - case Type.Maybe: - return toValue(value, typeInfo.option) - case Type.Value: - return value - } -} - -export class Modules { - private cache: Record> = {} - constructor(private importHook: InternalHooks['import']) {} - - import(module: string) { - this.cache[module] ??= this.importHook(module) - return this.cache[module] - } -} +} = errors export async function execute( - program: Program, - inputs: Record, - hooks: InternalHooks, - modules: Modules = new Modules(hooks.import), + rootModule: string, + rootInputs: Inputs, + hooks: Required, ) { - const scope = new RootScope() - - async function executeBody( - visit: (stmt: Stmt) => void, - body: Stmt[], - context?: any, - ) { - scope.push(context) + async function executeBody(visit: (stmt: Stmt) => void, body: Stmt[]) { + scope.push() for (const stmt of body) { await visit(stmt) if (stmt.kind === NodeKind.ExtractStmt) { @@ -85,256 +39,209 @@ export async function execute( return scope.pop() } - async function ctx( - node: CExpr, - visit: (node: Expr) => MaybePromise, - cb: (ctx?: Contextual) => MaybePromise, - ): Promise { - async function unwrap( - context: Contextual | undefined, - cb: (ctx?: Contextual) => MaybePromise, - ): Promise { - if (context?.typeInfo.type === Type.List) { - const list = [] - for (const item of context.value) { - const itemCtx = { value: item, typeInfo: context.typeInfo.of } - list.push(await unwrap(itemCtx, cb)) + const executeModule: Execute = async (entry, inputs) => { + const provided = new Set(Object.keys(inputs)) + const unknown = provided.difference(entry.inputs) + invariant(unknown.size === 0, new UnknownInputsError([...unknown])) + scope.push() + let ex: any + + await visit>(entry.program, { + /** + * Expression nodes + */ + TemplateExpr(node, path, origNode) { + const firstNull = node.elements.find(el => el instanceof NullSelection) + if (firstNull) { + const parents = path.slice(0, -1) + const isRoot = !parents.find(n => n.kind === NodeKind.TemplateExpr) + return isRoot ? firstNull : '' } - return list - } - - context && scope.pushContext(context.value) - const value = await cb(context) - context && scope.popContext() - return value - } - - let context: Contextual | undefined - if (node.context) { - let value = await visit(node.context) - const optional = node.typeInfo.type === Type.Maybe - value = optional ? value : assert(value) - if (value instanceof NullSelection) { - return value - } - context = { value, typeInfo: node.context.typeInfo } - } - return unwrap(context, cb) - } - - const visitor: AsyncInterpretVisitor = { - /** - * Expression nodes - */ - TemplateExpr(node, path, origNode) { - const firstNull = node.elements.find(el => el instanceof NullSelection) - if (firstNull) { - const parents = path.slice(0, -1) - const isRoot = !parents.find(n => n.kind === NodeKind.TemplateExpr) - return isRoot ? firstNull : '' - } - const els = node.elements.map((el, i) => { - const og = origNode.elements[i]! - return isToken(og) ? og.value : toValue(el, og.typeInfo) - }) - return els.join('') - }, - - IdentifierExpr(node) { - const value = scope.vars[node.value.value] - invariant(value !== undefined, new ValueReferenceError(node.value.value)) - return value - }, - - SliceExpr: { - async enter(node, visit) { - return ctx(node, visit, async context => { - const { slice } = node - try { - const value = await hooks.slice( - slice.value, - context ? toValue(context.value, context.typeInfo) : {}, - context?.value ?? {}, - ) - const ret = - value === undefined ? new NullSelection('') : value - const optional = node.typeInfo.type === Type.Maybe - return optional ? ret : assert(ret) - } catch (e) { - throw new SliceError({ cause: e }) - } + const els = node.elements.map((el, i) => { + const og = origNode.elements[i]! + return isToken(og) ? og.value : toValue(el, og.typeInfo) }) + return els.join('') }, - }, - - SelectorExpr: { - async enter(node, visit) { - return ctx(node, visit, async context => { - const selector = await visit(node.selector) - if (typeof selector !== 'string') { - return selector - } - const args = [context!.value, selector, node.expand] as const - function select(typeInfo: TypeInfo) { - switch (typeInfo.type) { - case Type.Maybe: - return select(typeInfo.option) - case Type.Html: - return html.select(...args) - case Type.Js: - return js.select(...args) - case Type.Headers: - return headers.select(...args) - case Type.Cookies: - return cookies.select(...args) - default: - return json.select(...args) - } - } - - const value = select(context!.typeInfo) - const optional = node.typeInfo.type === Type.Maybe - return optional ? value : assert(value) - }) + IdentifierExpr(node) { + const value = scope.vars[node.value.value] + invariant( + value !== undefined, + new ValueReferenceError(node.value.value), + ) + return value }, - }, - - CallExpr: { - async enter(node, visit) { - return ctx(node, visit, async context => { - const callee = node.callee.value - const inputs = await visit(node.inputs) - if (node.calltype === 'module') { - let external: Program + SliceExpr: { + async enter(node, visit) { + return withContext(scope, node, visit, async context => { + const { slice } = node try { - external = await modules.import(callee) + const value = await hooks.slice( + slice.value, + context ? toValue(context.value, context.typeInfo) : {}, + context?.value ?? {}, + ) + const ret = + value === undefined ? new NullSelection('') : value + const optional = node.typeInfo.type === Type.Maybe + return optional ? ret : assert(ret) } catch (e) { - throw new ImportError(`Failed to import module: ${callee}`, { - cause: e, - }) + throw new SliceError({ cause: e }) } - const raster = toValue(inputs, node.inputs.typeInfo) - return hooks.call(callee, inputs, raster, () => - execute(external, inputs, hooks, modules), - ) - } + }) + }, + }, - if (callee === 'link') { - const resolved = http.constructUrl( - scope.context, - inputs.base ?? undefined, - ) - return resolved ?? new NullSelection('@link') - } + SelectorExpr: { + async enter(node, visit) { + return withContext(scope, node, visit, async context => { + const selector = await visit(node.selector) + if (typeof selector !== 'string') { + return selector + } + const args = [context!.value, selector, node.expand] as const + + function select(typeInfo: TypeInfo) { + switch (typeInfo.type) { + case Type.Maybe: + return select(typeInfo.option) + case Type.Html: + return html.select(...args) + case Type.Js: + return js.select(...args) + case Type.Headers: + return headers.select(...args) + case Type.Cookies: + return cookies.select(...args) + default: + return json.select(...args) + } + } - const doc = toValue(context!.value, context!.typeInfo) - switch (callee) { - case 'html': - return html.parse(doc) - case 'js': - return js.parse(doc) - case 'json': - return json.parse(doc) - case 'cookies': - return cookies.parse(doc) - default: - throw new ValueReferenceError(`Unsupported modifier: ${callee}`) - } - }) + const value = select(context!.typeInfo) + const optional = node.typeInfo.type === Type.Maybe + return optional ? value : assert(value) + }) + }, + }, + + ModifierExpr: { + enter(node, visit) { + return withContext(scope, node, visit, async context => { + const args = await visit(node.args) + const { value, typeInfo } = context! + return callModifier(node, args, value, typeInfo) + }) + }, }, - }, - ObjectLiteralExpr: { - async enter(node, visit) { - return ctx(node, visit, async () => { - const obj: Record = {} - for (const entry of node.entries) { - const value = await visit(entry.value) - if (!(value instanceof NullSelection)) { - const key = await visit(entry.key) - obj[key] = value + ModuleExpr: { + enter(node, visit) { + return withContext(scope, node, visit, async context => { + const args = await visit(node.args) + return node.call + ? modules.call(node, args, context?.typeInfo) + : toValue(args, node.args.typeInfo) + }) + }, + }, + + ObjectLiteralExpr: { + async enter(node, visit) { + return withContext(scope, node, visit, async () => { + const obj: Record = {} + for (const entry of node.entries) { + const value = await visit(entry.value) + if (!(value instanceof NullSelection)) { + const key = await visit(entry.key) + obj[key] = value + } } - } - return obj - }) + return obj + }) + }, }, - }, - SubqueryExpr: { - async enter(node, visit) { - return ctx(node, visit, async () => { - const ex = await executeBody(visit, node.body, scope.context) - invariant( - ex, - new QuerySyntaxError('Subquery missing extract statement'), - ) - return ex - }) + SubqueryExpr: { + async enter(node, visit) { + return withContext(scope, node, visit, async () => { + const ex = await executeBody(visit, node.body) + const err = new QuerySyntaxError('Subquery must extract a value') + invariant(ex, err) + return ex + }) + }, }, - }, - async RequestExpr(node) { - const method = node.method.value - const url = node.url - const body = node.body ?? '' - return await http.request( - method, - url, - node.headers, - node.blocks, - body, - hooks.request, - ) - }, + async RequestExpr(node) { + const method = node.method.value + const url = node.url + const body = node.body ?? '' + return await http.request( + method, + url, + node.headers, + node.blocks, + body, + hooks.request, + ) + }, - /** - * Statement nodes - */ + /** + * Statement nodes + */ - DeclInputsStmt() {}, + DeclInputsStmt() {}, - InputDeclStmt: { - async enter(node, visit) { - const inputName = node.id.value - let inputValue = inputs[inputName] - if (inputValue === undefined) { - if (!node.optional) { - throw new NullInputError(inputName) + InputDeclStmt: { + async enter(node, visit) { + const inputName = node.id.value + let inputValue = inputs[inputName] + if (inputValue === undefined) { + if (!node.optional) { + throw new NullInputError(inputName) + } + inputValue = node.defaultValue + ? await visit(node.defaultValue) + : new NullSelection(`input:${inputName}`) } - inputValue = node.defaultValue - ? await visit(node.defaultValue) - : new NullSelection(`input:${inputName}`) - } - scope.vars[inputName] = inputValue + scope.vars[inputName] = inputValue + }, }, - }, - AssignmentStmt(node) { - scope.vars[node.name.value] = node.value - }, + AssignmentStmt(node) { + scope.vars[node.name.value] = node.value + }, - RequestStmt(node) { - scope.pushContext(node.request) - }, + RequestStmt(node) { + scope.pushContext(node.request) + }, - ExtractStmt(node) { - scope.extracted = assert(node.value) - }, + ExtractStmt(node) { + scope.extracted = assert(node.value) + }, - Program: { - async enter(node, visit) { - scope.extracted = await executeBody(visit, node.body) + Program: { + async enter(node, visit) { + ex = await executeBody(visit, node.body) + }, }, - }, + }) + + return ex } - await visit(program, visitor) + const scope = new RootScope() + const modules = new Modules(hooks, executeModule) + + const rootEntry = await modules.import(rootModule) + const ex = await executeModule(rootEntry, rootInputs) - const ex = scope.pop() - const retType: any = program.body.find( + const retType: any = rootEntry.program.body.find( stmt => stmt.kind === NodeKind.ExtractStmt, ) + return retType ? toValue(ex, retType.value.typeInfo) : ex } diff --git a/packages/get/src/hooks.spec.ts b/packages/get/src/hooks.spec.ts index 0e30c2b..d891193 100644 --- a/packages/get/src/hooks.spec.ts +++ b/packages/get/src/hooks.spec.ts @@ -1,5 +1,13 @@ import { describe, expect, mock, test } from 'bun:test' -import { type Hooks, invariant } from '@getlang/utils' +import type { + CallHook, + ExtractHook, + Hooks, + ImportHook, + RequestHook, + SliceHook, +} from '@getlang/utils' +import { invariant } from '@getlang/utils' import { execute } from './index.js' describe('hook', () => { @@ -11,7 +19,7 @@ describe('hook', () => { extract -> h1 ` - const requestHook = mock(async () => ({ + const requestHook = mock(async () => ({ status: 200, headers: new Headers({ 'content-type': 'text/html' }), body: '

test

', @@ -31,7 +39,7 @@ describe('hook', () => { }) test('on slice', async () => { - const sliceHook = mock(() => 3) + const sliceHook = mock(() => 3) const result = await execute('extract `1 + 2`', {}, { slice: sliceHook }) @@ -39,73 +47,85 @@ describe('hook', () => { expect(result).toEqual(3) }) - test('on import (cached) and call', async () => { + test('module lifecycle', async () => { const modules: Record = { - Top: `extract \`"top"\``, + Top: ` + inputs { inputA } + extract { value: \`"top::" + inputA\` } + `, Mid: ` set inputA = \`"bar"\` extract { - topValue: @Top({ $inputA }) - midValue: \`"mid"\` + value: { + topValue: @Top({ $inputA }) -> value + midValue: \`"mid"\` + } } `, } - const importHook = mock(async (module: string) => { - const src = modules[module] - invariant(src, `Unexpected import: ${module}`) - return src - }) - - const callHook = mock((_m, _i, _r, e) => e()) - const src = ` set inputA = \`"foo"\` extract { - topValue: @Top({ $inputA }) - midValue: @Mid + topValue: @Top({ $inputA }) -> value + midValue: @Mid -> value botValue: \`"bot"\` } ` - const hooks = { import: importHook, call: callHook } + const hooks: Hooks = { + import: mock(async (module: string) => { + const src = modules[module] + invariant(src, `Unexpected import: ${module}`) + return src + }), + call: mock(() => {}), + extract: mock(() => {}), + } const result = await execute(src, {}, hooks) expect(result).toEqual({ - topValue: 'top', + topValue: 'top::foo', midValue: { - topValue: 'top', + topValue: 'top::bar', midValue: 'mid', }, botValue: 'bot', }) - expect(importHook).toHaveBeenCalledTimes(2) - expect(importHook).toHaveBeenNthCalledWith(1, 'Top') - expect(importHook).toHaveBeenNthCalledWith(2, 'Mid') + expect(hooks.import).toHaveBeenCalledTimes(2) + expect(hooks.import).toHaveBeenNthCalledWith(1, 'Top') + expect(hooks.import).toHaveBeenNthCalledWith(2, 'Mid') - expect(callHook).toHaveBeenCalledTimes(3) - expect(callHook).toHaveBeenNthCalledWith( + expect(hooks.call).toHaveBeenCalledTimes(3) + expect(hooks.call).toHaveBeenNthCalledWith(1, 'Top', { inputA: 'foo' }) + expect(hooks.call).toHaveBeenNthCalledWith(2, 'Mid', {}) + expect(hooks.call).toHaveBeenNthCalledWith(3, 'Top', { inputA: 'bar' }) + + expect(hooks.extract).toHaveBeenCalledTimes(3) + expect(hooks.extract).toHaveBeenNthCalledWith( 1, 'Top', { inputA: 'foo' }, - { inputA: 'foo' }, - expect.any(Function), + { value: 'top::foo' }, ) - expect(callHook).toHaveBeenNthCalledWith( + expect(hooks.extract).toHaveBeenNthCalledWith( 2, - 'Mid', - {}, - {}, - expect.any(Function), - ) - expect(callHook).toHaveBeenNthCalledWith( - 3, 'Top', { inputA: 'bar' }, - { inputA: 'bar' }, - expect.any(Function), + { value: 'top::bar' }, + ) + expect(hooks.extract).toHaveBeenNthCalledWith( + 3, + 'Mid', + {}, + { + value: { + topValue: 'top::bar', + midValue: 'mid', + }, + }, ) }) }) diff --git a/packages/get/src/index.ts b/packages/get/src/index.ts index 1d6caf8..c98ee78 100644 --- a/packages/get/src/index.ts +++ b/packages/get/src/index.ts @@ -1,51 +1,44 @@ import { http, slice } from '@getlang/lib' -import { desugar, parse } from '@getlang/parser' -import type { Program } from '@getlang/parser/ast' -import type { UserHooks } from '@getlang/utils' -import { ImportError, invariant, wait } from '@getlang/utils' -import type { InternalHooks } from './execute.js' -import { execute as exec, Modules } from './execute.js' +import type { Hooks, Inputs } from '@getlang/utils' +import { invariant } from '@getlang/utils' +import { ImportError } from '@getlang/utils/errors' +import { execute as exec } from './execute.js' -function buildHooks(hooks: UserHooks = {}): InternalHooks { +function buildHooks(hooks: Hooks): Required { return { import: (module: string) => { invariant( hooks.import, new ImportError('Imports are not supported by the current runtime'), ) - return wait(hooks.import(module), src => desugar(parse(src))) + return hooks.import(module) }, - call: hooks.call ?? ((_module, _inputs, _raster, execute) => execute()), + call: hooks.call ?? (() => {}), request: hooks.request ?? http.requestHook, slice: hooks.slice ?? slice.runSlice, + extract: hooks.extract ?? (() => {}), } } export function execute( source: string, - inputs: Record = {}, - hooks?: UserHooks, + inputs: Inputs = {}, + hooks: Hooks = {}, ) { - const ast = parse(source) - const simplified = desugar(ast) - return exec(simplified, inputs, buildHooks(hooks)) -} - -export function executeAST( - ast: Program, - inputs: Record = {}, - hooks?: UserHooks, -) { - return exec(ast, inputs, buildHooks(hooks)) + const system = buildHooks(hooks) + return exec('Default', inputs, { + ...system, + import() { + this.import = system.import + return source + }, + }) } export async function executeModule( module: string, - inputs: Record = {}, - _hooks?: UserHooks, + inputs: Inputs = {}, + hooks: Hooks = {}, ) { - const hooks = buildHooks(_hooks) - const modules = new Modules(hooks.import) - const source = await modules.import(module) - return exec(source, inputs, hooks, modules) + return exec(module, inputs, buildHooks(hooks)) } diff --git a/packages/get/src/modifiers.ts b/packages/get/src/modifiers.ts new file mode 100644 index 0000000..b1bc7cd --- /dev/null +++ b/packages/get/src/modifiers.ts @@ -0,0 +1,43 @@ +import { cookies, html, js, json } from '@getlang/lib' +import type { ModifierExpr } from '@getlang/parser/ast' +import type { TypeInfo } from '@getlang/parser/typeinfo' +import { NullSelection } from '@getlang/utils' +import { ValueReferenceError } from '@getlang/utils/errors' +import { toValue } from './value.js' + +export function callModifier( + node: ModifierExpr, + args: any, + value: any, + typeInfo: TypeInfo, +) { + const mod = node.modifier.value + + if (mod === 'link') { + const tag = value.type === 'tag' ? value.name : undefined + if (tag === 'a') { + value = html.select(value, 'xpath:@href', false) + } else if (tag === 'img') { + value = html.select(value, 'xpath:@src', false) + } + } + + const doc = toValue(value, typeInfo) + + switch (mod) { + case 'link': + return doc + ? new URL(doc, args.base).toString() + : new NullSelection('@link') + case 'html': + return html.parse(doc) + case 'js': + return js.parse(doc) + case 'json': + return json.parse(doc) + case 'cookies': + return cookies.parse(doc) + default: + throw new ValueReferenceError(`Unsupported modifier: ${mod}`) + } +} diff --git a/packages/get/src/modules.ts b/packages/get/src/modules.ts new file mode 100644 index 0000000..5a07be8 --- /dev/null +++ b/packages/get/src/modules.ts @@ -0,0 +1,152 @@ +import { analyze, desugar, inference, parse } from '@getlang/parser' +import type { ModuleExpr, Program } from '@getlang/parser/ast' +import type { TypeInfo } from '@getlang/parser/typeinfo' +import { Type } from '@getlang/parser/typeinfo' +import type { Hooks, Inputs } from '@getlang/utils' +import { + ImportError, + RecursiveCallError, + ValueTypeError, +} from '@getlang/utils/errors' +import { partition } from 'lodash-es' +import { toValue } from './value.js' + +type Info = { + ast: Program + inputs: Set + imports: Set + isMacro: boolean +} + +type Entry = { + program: Program + inputs: Set + returnType: TypeInfo +} + +export type Execute = (entry: Entry, inputs: Inputs) => Promise + +function buildImportKey(module: string, typeInfo?: TypeInfo) { + function repr(ti: TypeInfo): string { + switch (ti.type) { + case Type.Maybe: + return `maybe<${repr(ti.option)}>` + case Type.List: + return `${repr(ti.of)}[]` + case Type.Struct: { + const fields = Object.entries(ti.schema) + .map(e => `${e[0]}: ${repr(e[1])};`) + .join(' ') + return `{ ${fields} }` + } + case Type.Context: + case Type.Never: + throw new ValueTypeError('Unsupported key type') + default: + return ti.type + } + } + + let key = module + if (typeInfo) { + key += `<${repr(typeInfo)}>` + } + return key +} + +export class Modules { + private info: Record> = {} + private entries: Record> = {} + + constructor( + private hooks: Required, + private execute: Execute, + ) {} + + async load(module: string): Promise { + const source = await this.hooks.import(module) + const ast = parse(source) + const info = analyze(ast) + return { ast, ...info } + } + + async getInfo(module: string) { + this.info[module] ??= this.load(module) + return this.info[module] + } + + async compile( + module: string, + stack: string[], + contextType?: TypeInfo, + ): Promise { + const { ast, inputs, imports } = await this.getInfo(module) + const macros: string[] = [] + for (const i of imports) { + const depInfo = await this.getInfo(i) + if (depInfo.isMacro) { + macros.push(i) + } + } + const { program: simplified, calls } = desugar(ast, macros) + + const returnTypes: Record = {} + for (const call of calls) { + const { returnType } = await this.import(call, stack) + returnTypes[call] = returnType + } + + const { program, returnType } = inference(simplified, { + returnTypes, + contextType, + }) + + return { program, inputs, returnType } + } + + import(module: string, prev: string[] = [], contextType?: TypeInfo) { + const stack = [...prev, module] + if (prev.includes(module)) { + throw new RecursiveCallError(stack) + } + const key = buildImportKey(module, contextType) + this.entries[key] ??= this.compile(module, stack, contextType) + return this.entries[key] + } + + async call(node: ModuleExpr, args: any, contextType?: TypeInfo) { + const module = node.module.value + let entry: Entry + try { + entry = await this.import(module, [], contextType) + } catch (e) { + const err = `Failed to import module: ${module}` + throw new ImportError(err, { cause: e }) + } + const [inputArgs, attrArgs] = partition(Object.entries(args), e => + entry.inputs.has(e[0]), + ) + const inputs = Object.fromEntries(inputArgs) + let extracted = await this.hooks.call(module, inputs) + if (typeof extracted === 'undefined') { + extracted = await this.execute(entry, inputs) + } + await this.hooks.extract(module, inputs, extracted) + + if (typeof extracted !== 'object' || entry.returnType.type !== Type.Value) { + if (attrArgs.length) { + const dropped = attrArgs.map(e => e[0]).join(', ') + const err = [ + `Module '${module}' returned a primitive`, + `dropping view attributes: ${dropped}`, + ].join(', ') + console.warn(err) + } + return extracted + } + + const attrs = Object.fromEntries(attrArgs) + const raster = toValue(attrs, node.args.typeInfo) + return { ...raster, ...extracted } + } +} diff --git a/packages/get/src/value.ts b/packages/get/src/value.ts new file mode 100644 index 0000000..446abac --- /dev/null +++ b/packages/get/src/value.ts @@ -0,0 +1,36 @@ +import { cookies, headers, html, js } from '@getlang/lib' +import type { TypeInfo } from '@getlang/parser/typeinfo' +import { Type } from '@getlang/parser/typeinfo' +import { NullSelection } from '@getlang/utils' +import { NullSelectionError, ValueTypeError } from '@getlang/utils/errors' +import { mapValues } from 'lodash-es' + +export function toValue(value: any, typeInfo: TypeInfo): any { + switch (typeInfo.type) { + case Type.Html: + return html.toValue(value) + case Type.Js: + return js.toValue(value) + case Type.Headers: + return headers.toValue(value) + case Type.Cookies: + return cookies.toValue(value) + case Type.List: + return value.map((item: any) => toValue(item, typeInfo.of)) + case Type.Struct: + return mapValues(value, (v, k) => toValue(v, typeInfo.schema[k]!)) + case Type.Maybe: + return toValue(value, typeInfo.option) + case Type.Value: + return value + default: + throw new ValueTypeError('Unsupported conversion type') + } +} + +export function assert(value: any) { + if (value instanceof NullSelection) { + throw new NullSelectionError(value.selector) + } + return value +} diff --git a/packages/lib/src/net/http.ts b/packages/lib/src/net/http.ts index d4352c6..159d637 100644 --- a/packages/lib/src/net/http.ts +++ b/packages/lib/src/net/http.ts @@ -1,20 +1,7 @@ -import { RequestError } from '@getlang/utils' -import type { Element } from 'domhandler' +import type { RequestHook } from '@getlang/utils' +import { RequestError } from '@getlang/utils/errors' type StringMap = Record -export type RequestHook = (url: string, opts: RequestOpts) => Promise - -type RequestOpts = { - method?: string - headers?: Headers - body?: string -} - -type Response = { - status: number - headers: Headers - body?: string -} type Blocks = { query?: StringMap @@ -32,29 +19,6 @@ const fixedEncodeURIComponent = (str: string) => { ) } -export const constructUrl = ( - elementOrString: string | Element, - base: string | undefined, -) => { - let href: string | undefined - if (typeof elementOrString === 'string') { - href = elementOrString - } else { - const el = elementOrString - if (el.type === 'tag') { - if (el.name === 'a') { - href = el.attribs.href - } else if (el.name === 'img') { - href = el.attribs.src - } - } - } - if (!href) { - return null - } - return new URL(href, base).toString() -} - export const requestHook: RequestHook = async (url, opts) => { const res = await fetch(url, opts) return { diff --git a/packages/lib/src/values/cookies.ts b/packages/lib/src/values/cookies.ts index bcd8608..92cd4bf 100644 --- a/packages/lib/src/values/cookies.ts +++ b/packages/lib/src/values/cookies.ts @@ -1,4 +1,5 @@ -import { invariant, NullSelection, QuerySyntaxError } from '@getlang/utils' +import { invariant, NullSelection } from '@getlang/utils' +import { QuerySyntaxError } from '@getlang/utils/errors' import { mapValues } from 'lodash-es' import * as scp from 'set-cookie-parser' diff --git a/packages/lib/src/values/html.ts b/packages/lib/src/values/html.ts index 7a5b2fd..487aa21 100644 --- a/packages/lib/src/values/html.ts +++ b/packages/lib/src/values/html.ts @@ -1,11 +1,7 @@ /// -import { - invariant, - NullSelection, - NullSelectionError, - SelectorSyntaxError, -} from '@getlang/utils' +import { invariant, NullSelection } from '@getlang/utils' +import { NullSelectionError, SelectorSyntaxError } from '@getlang/utils/errors' import xpath from '@getlang/xpath' import { selectAll, selectOne } from 'css-select' import { parse as parseCss } from 'css-what' diff --git a/packages/lib/src/values/js.ts b/packages/lib/src/values/js.ts index b887173..a935752 100644 --- a/packages/lib/src/values/js.ts +++ b/packages/lib/src/values/js.ts @@ -1,10 +1,9 @@ +import { invariant, NullSelection } from '@getlang/utils' import { ConversionError, - invariant, - NullSelection, SelectorSyntaxError, SliceSyntaxError, -} from '@getlang/utils' +} from '@getlang/utils/errors' import type { AnyNode } from 'acorn' import { parse as acorn } from 'acorn' import esquery from 'esquery' diff --git a/packages/parser/src/ast/ast.ts b/packages/parser/src/ast/ast.ts index 6068cc3..b925866 100644 --- a/packages/parser/src/ast/ast.ts +++ b/packages/parser/src/ast/ast.ts @@ -18,7 +18,8 @@ export enum NodeKind { TemplateExpr = 'TemplateExpr', IdentifierExpr = 'IdentifierExpr', SelectorExpr = 'SelectorExpr', - CallExpr = 'CallExpr', + ModifierExpr = 'ModifierExpr', + ModuleExpr = 'ModuleExpr', SubqueryExpr = 'SubqueryExpr', ObjectLiteralExpr = 'ObjectLiteralExpr', SliceExpr = 'SliceExpr', @@ -52,7 +53,7 @@ export type DeclInputsStmt = { inputs: InputDeclStmt[] } -type InputDeclStmt = { +export type InputDeclStmt = { kind: NodeKind.InputDeclStmt id: Token optional: boolean @@ -102,11 +103,19 @@ type SelectorExpr = { typeInfo: TypeInfo } -type CallExpr = { - kind: NodeKind.CallExpr - callee: Token - calltype: 'module' | 'modifier' - inputs: ObjectLiteralExpr +export type ModifierExpr = { + kind: NodeKind.ModifierExpr + modifier: Token + args: ObjectLiteralExpr + context?: Expr + typeInfo: TypeInfo +} + +export type ModuleExpr = { + kind: NodeKind.ModuleExpr + module: Token + call: boolean + args: ObjectLiteralExpr context?: Expr typeInfo: TypeInfo } @@ -145,7 +154,8 @@ export type Expr = | TemplateExpr | IdentifierExpr | SelectorExpr - | CallExpr + | ModifierExpr + | ModuleExpr | SubqueryExpr | ObjectLiteralExpr | SliceExpr @@ -237,15 +247,27 @@ const selectorExpr = ( context, }) -const callExpr = ( - callee: Token, +const modifierExpr = ( + modifier: Token, inputs: ObjectLiteralExpr = objectLiteralExpr([]), context?: Expr, -): CallExpr => ({ - kind: NodeKind.CallExpr, - callee, - calltype: /[A-Z]/.test(callee.value) ? 'module' : 'modifier', - inputs, +): ModifierExpr => ({ + kind: NodeKind.ModifierExpr, + modifier, + args: inputs, + typeInfo: { type: Type.Value }, + context, +}) + +const moduleExpr = ( + module: Token, + inputs: ObjectLiteralExpr = objectLiteralExpr([]), + context?: Expr, +): ModuleExpr => ({ + kind: NodeKind.ModuleExpr, + module, + call: false, + args: inputs, typeInfo: { type: Type.Value }, context, }) @@ -296,7 +318,8 @@ export const t = { // CONTEXTUAL EXPRESSIONS selectorExpr, - callExpr, + modifierExpr, + moduleExpr, sliceExpr, objectLiteralExpr, objectEntry, diff --git a/packages/parser/src/ast/print.ts b/packages/parser/src/ast/print.ts index ede889e..8e8f982 100644 --- a/packages/parser/src/ast/print.ts +++ b/packages/parser/src/ast/print.ts @@ -163,8 +163,19 @@ const printVisitor: InterpretVisitor = { return [node.context, indent([line, arrow, node.selector])] }, - CallExpr(node) { - const call: Doc[] = ['@', node.callee.value, '(', node.inputs, ')'] + ModifierExpr(node, _path, orig) { + const call: Doc[] = ['@', node.modifier.value] + if (orig.args.entries.length) { + call.push('(', node.args, ')') + } + return node.context ? [node.context, indent([line, '-> ', call])] : call + }, + + ModuleExpr(node, _path, orig) { + const call: Doc[] = ['@', node.module.value] + if (orig.args.entries.length) { + call.push('(', node.args, ')') + } return node.context ? [node.context, indent([line, '-> ', call])] : call }, diff --git a/packages/parser/src/ast/scope.ts b/packages/parser/src/ast/scope.ts index 0ed152e..60ebb1a 100644 --- a/packages/parser/src/ast/scope.ts +++ b/packages/parser/src/ast/scope.ts @@ -1,69 +1,80 @@ -import { invariant, ValueReferenceError } from '@getlang/utils' +import { invariant } from '@getlang/utils' +import { ValueReferenceError } from '@getlang/utils/errors' -export class Scope { - vars: Record +class Scope { extracted: T | undefined - contextStack: T[] + private contextStack: T[] = [] constructor( - parentVars: Record = Object.create(null), + public vars: Record, context?: T, ) { - this.vars = Object.create(parentVars) - this.contextStack = context ? [context] : [] + context && this.push(context) + } + + get context() { + return this.contextStack.at(-1) + } + + private update() { + if (this.context) { + this.vars[''] = this.context + } else { + delete this.vars[''] + } + } + + push(context: T) { + this.contextStack.push(context) + this.update() + } + + pop() { + this.contextStack.pop() + this.update() } } export class RootScope { - scopeStack: Scope[] = [new Scope()] + private scopeStack: Scope[] = [] - private get scope() { - const scope = this.scopeStack.at(-1) - invariant(scope, new ValueReferenceError('Corrupted scope stack')) - return scope + private get head() { + return this.scopeStack.at(-1) } - get vars() { - return this.scope.vars + private get ensure() { + const scope = this.head + invariant(scope, new ValueReferenceError('Invalid scope stack')) + return scope } - pushContext(context: T) { - this.scope.contextStack.push(context) - this.updateContext() + get vars() { + return this.ensure.vars } - popContext() { - this.scope.contextStack.pop() - this.updateContext() + get context() { + return this.head?.context } - get context() { - return this.scope.contextStack.at(-1) + pushContext(context: T) { + this.ensure.push(context) } - updateContext() { - if (this.context) { - this.vars[''] = this.context - } else { - delete this.vars[''] - } + popContext() { + this.ensure.pop() } set extracted(data: T) { - if (this.scope.extracted !== undefined) { - console.warn('Subqueries must contain a single extract statement') - } else { - this.scope.extracted = data - } + this.ensure.extracted = data } - push(context: T | undefined = this.context) { - this.scopeStack.push(new Scope(this.vars, context)) - this.updateContext() + push(context: T | undefined = this.head?.context) { + const vars = Object.create(this.head?.vars ?? null) + this.scopeStack.push(new Scope(vars, context)) } pop() { - const data = this.scope.extracted + const data = this.ensure.extracted this.scopeStack.pop() return data } diff --git a/packages/parser/src/ast/typeinfo.ts b/packages/parser/src/ast/typeinfo.ts index 680f9aa..741e427 100644 --- a/packages/parser/src/ast/typeinfo.ts +++ b/packages/parser/src/ast/typeinfo.ts @@ -4,6 +4,7 @@ export enum Type { Js = 'js', Headers = 'headers', Cookies = 'cookies', + Context = 'context', List = 'list', Struct = 'struct', Never = 'never', @@ -15,16 +16,16 @@ export type List = { of: TypeInfo } -export type Maybe = { - type: Type.Maybe - option: TypeInfo -} - export type Struct = { type: Type.Struct schema: Record } +export type Maybe = { + type: Type.Maybe + option: TypeInfo +} + type ScalarType = { type: Exclude } diff --git a/packages/parser/src/desugar/inference/links.ts b/packages/parser/src/desugar/inference/links.ts deleted file mode 100644 index 78263b4..0000000 --- a/packages/parser/src/desugar/inference/links.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { - invariant, - QuerySyntaxError, - ValueReferenceError, -} from '@getlang/utils' -import type { Expr, RequestExpr } from '../../ast/ast.js' -import { NodeKind, t } from '../../ast/ast.js' -import { RootScope } from '../../ast/scope.js' -import type { TransformVisitor } from '../../visitor/transform.js' -import type { RequestParsers } from '../reqparse.js' -import { traceVisitor } from '../trace.js' -import { render, tx } from '../utils.js' - -export function inferLinks(parsers: RequestParsers): TransformVisitor { - const scope = new RootScope() - - const bases = new Map() - function inherit(c: Expr, n: Expr) { - const base = bases.get(c) - base && bases.set(n, base) - } - - const trace = traceVisitor(scope) - - return { - ...trace, - - IdentifierExpr(node) { - const id = node.value.value - const value = scope.vars[id] - invariant(value, new ValueReferenceError(id)) - inherit(value, node) - return node - }, - - SelectorExpr: { - enter(node, visit) { - const xnode = trace.SelectorExpr.enter(node, visit) - invariant(xnode.context, new QuerySyntaxError('Unresolved context')) - inherit(xnode.context, xnode) - return xnode - }, - }, - - CallExpr: { - enter(node, visit) { - let tnode = node - if (tnode.calltype === 'module') { - tnode = { - ...tnode, - inputs: { - ...tnode.inputs, - entries: tnode.inputs.entries.map(e => { - if ( - render(e.key) !== '@link' || - (e.value.kind === NodeKind.CallExpr && - e.value.callee.value === 'link') - ) { - return e - } - const value = t.callExpr(tx.token('link'), undefined, e.value) - return { ...e, value } - }), - }, - } - } - - const xnode = trace.CallExpr.enter(tnode, visit) - - if (xnode.calltype === 'module') { - return xnode - } - - invariant( - xnode.kind === NodeKind.CallExpr && - xnode.inputs.kind === NodeKind.ObjectLiteralExpr, - new QuerySyntaxError('Modifier options must be an object'), - ) - - if (xnode.callee.value === 'link' && xnode.context) { - const contextBase = bases.get(xnode.context) - const hasBase = xnode.inputs.entries.some( - e => render(e.key) === 'base', - ) - if (contextBase && !hasBase) { - xnode.inputs.entries.push( - t.objectEntry( - tx.template('base'), - parsers.lookup(contextBase, 'url'), - ), - ) - } - } - invariant(xnode.context, new QuerySyntaxError('Unresolved context')) - inherit(xnode.context, xnode) - return xnode - }, - }, - - RequestExpr(node) { - parsers.visit(node) - bases.set(node, node) - return node - }, - - Program(node) { - return { ...node, body: parsers.insert(node.body) } - }, - - SubqueryExpr: { - enter(node, visit) { - const xnode = trace.SubqueryExpr.enter(node, visit) - return { ...xnode, body: parsers.insert(xnode.body) } - }, - }, - } -} diff --git a/packages/parser/src/desugar/simplified.ts b/packages/parser/src/desugar/simplified.ts deleted file mode 100644 index 840c579..0000000 --- a/packages/parser/src/desugar/simplified.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { invariant, QuerySyntaxError } from '@getlang/utils' -import type { Program } from '../ast/ast.js' -import { NodeKind } from '../ast/ast.js' -import { visit } from '../visitor/visitor.js' -import { inferContext } from './inference/context.js' -import { inferLinks } from './inference/links.js' -import { inferSliceDeps } from './inference/slicedeps.js' -import { inferTypeInfo } from './inference/typeinfo.js' -import { RequestParsers } from './reqparse.js' - -export function desugar(ast: Program): Program { - const parsers = new RequestParsers() - - const visitors = [ - inferContext(parsers), - inferLinks(parsers), - inferSliceDeps(), - inferTypeInfo(), - ] - - const simplified = visitors.reduce((prev, curr) => { - parsers.reset() - return visit(prev, curr) - }, ast) - - invariant( - simplified.kind === NodeKind.Program, - new QuerySyntaxError('Desugar encountered unexpected error'), - ) - - return simplified -} diff --git a/packages/parser/src/grammar.ts b/packages/parser/src/grammar.ts index a852b84..6dd8310 100644 --- a/packages/parser/src/grammar.ts +++ b/packages/parser/src/grammar.ts @@ -9,8 +9,8 @@ declare var request_block_name: any; declare var request_block_body: any; declare var request_block_body_end: any; declare var drill_arrow: any; -declare var call: any; declare var link: any; +declare var call: any; declare var literal: any; declare var interpvar: any; declare var slice: any; @@ -70,8 +70,8 @@ const grammar: Grammar = { {"name": "inputs", "symbols": [{"literal":"inputs"}, "__", {"literal":"{"}, "_", "input_decl", "inputs$ebnf$1", "_", {"literal":"}"}], "postprocess": p.declInputs}, {"name": "assignment$ebnf$1", "symbols": [{"literal":"?"}], "postprocess": id}, {"name": "assignment$ebnf$1", "symbols": [], "postprocess": () => null}, - {"name": "assignment", "symbols": [{"literal":"set"}, "__", (lexer.has("identifier") ? {type: "identifier"} : identifier), "assignment$ebnf$1", "_", {"literal":"="}, "_", "drill"], "postprocess": p.assignment}, - {"name": "extract", "symbols": [{"literal":"extract"}, "__", "drill"], "postprocess": p.extract}, + {"name": "assignment", "symbols": [{"literal":"set"}, "__", (lexer.has("identifier") ? {type: "identifier"} : identifier), "assignment$ebnf$1", "_", {"literal":"="}, "_", "expression"], "postprocess": p.assignment}, + {"name": "extract", "symbols": [{"literal":"extract"}, "__", "expression"], "postprocess": p.extract}, {"name": "input_decl$ebnf$1", "symbols": [{"literal":"?"}], "postprocess": id}, {"name": "input_decl$ebnf$1", "symbols": [], "postprocess": () => null}, {"name": "input_decl$ebnf$2$subexpression$1", "symbols": ["_", {"literal":"="}, "_", "input_default"]}, @@ -100,25 +100,28 @@ const grammar: Grammar = { {"name": "request_entry$ebnf$1", "symbols": ["request_entry$ebnf$1$subexpression$1"], "postprocess": id}, {"name": "request_entry$ebnf$1", "symbols": [], "postprocess": () => null}, {"name": "request_entry", "symbols": ["template", {"literal":":"}, "request_entry$ebnf$1"], "postprocess": p.requestEntry}, - {"name": "drill", "symbols": ["drill", "_", (lexer.has("drill_arrow") ? {type: "drill_arrow"} : drill_arrow), "_", "expression"], "postprocess": p.drill}, + {"name": "expression", "symbols": ["drill"], "postprocess": id}, + {"name": "expression$ebnf$1$subexpression$1", "symbols": ["drill", "_", (lexer.has("drill_arrow") ? {type: "drill_arrow"} : drill_arrow), "_"]}, + {"name": "expression$ebnf$1", "symbols": ["expression$ebnf$1$subexpression$1"], "postprocess": id}, + {"name": "expression$ebnf$1", "symbols": [], "postprocess": () => null}, + {"name": "expression", "symbols": ["expression$ebnf$1", (lexer.has("link") ? {type: "link"} : link), "_", "drill"], "postprocess": p.link}, + {"name": "drill", "symbols": ["drill", "_", (lexer.has("drill_arrow") ? {type: "drill_arrow"} : drill_arrow), "_", "bit"], "postprocess": p.drill}, {"name": "drill$ebnf$1$subexpression$1", "symbols": [(lexer.has("drill_arrow") ? {type: "drill_arrow"} : drill_arrow), "_"]}, {"name": "drill$ebnf$1", "symbols": ["drill$ebnf$1$subexpression$1"], "postprocess": id}, {"name": "drill$ebnf$1", "symbols": [], "postprocess": () => null}, - {"name": "drill", "symbols": ["drill$ebnf$1", "expression"], "postprocess": p.drillContext}, - {"name": "expression$subexpression$1", "symbols": ["template"]}, - {"name": "expression$subexpression$1", "symbols": ["slice"]}, - {"name": "expression$subexpression$1", "symbols": ["call"]}, - {"name": "expression$subexpression$1", "symbols": ["link"]}, - {"name": "expression$subexpression$1", "symbols": ["object"]}, - {"name": "expression$subexpression$1", "symbols": ["subquery"]}, - {"name": "expression", "symbols": ["expression$subexpression$1"], "postprocess": p.idd}, - {"name": "expression", "symbols": ["id_expr"], "postprocess": p.identifier}, + {"name": "drill", "symbols": ["drill$ebnf$1", "bit"], "postprocess": p.drillContext}, + {"name": "bit$subexpression$1", "symbols": ["template"]}, + {"name": "bit$subexpression$1", "symbols": ["slice"]}, + {"name": "bit$subexpression$1", "symbols": ["call"]}, + {"name": "bit$subexpression$1", "symbols": ["object"]}, + {"name": "bit$subexpression$1", "symbols": ["subquery"]}, + {"name": "bit", "symbols": ["bit$subexpression$1"], "postprocess": p.idd}, + {"name": "bit", "symbols": ["id_expr"], "postprocess": p.identifier}, {"name": "subquery", "symbols": [{"literal":"("}, "_", "statements", "_", {"literal":")"}], "postprocess": p.subquery}, {"name": "call$ebnf$1$subexpression$1", "symbols": [{"literal":"("}, "object", {"literal":")"}]}, {"name": "call$ebnf$1", "symbols": ["call$ebnf$1$subexpression$1"], "postprocess": id}, {"name": "call$ebnf$1", "symbols": [], "postprocess": () => null}, {"name": "call", "symbols": [(lexer.has("call") ? {type: "call"} : call), "call$ebnf$1"], "postprocess": p.call}, - {"name": "link", "symbols": [(lexer.has("link") ? {type: "link"} : link), "_", "drill"], "postprocess": p.link}, {"name": "object$ebnf$1", "symbols": []}, {"name": "object$ebnf$1$subexpression$1$ebnf$1$subexpression$1", "symbols": ["_", {"literal":","}]}, {"name": "object$ebnf$1$subexpression$1$ebnf$1", "symbols": ["object$ebnf$1$subexpression$1$ebnf$1$subexpression$1"], "postprocess": id}, @@ -130,7 +133,7 @@ const grammar: Grammar = { {"name": "object_entry$ebnf$1", "symbols": [], "postprocess": () => null}, {"name": "object_entry$ebnf$2", "symbols": [{"literal":"?"}], "postprocess": id}, {"name": "object_entry$ebnf$2", "symbols": [], "postprocess": () => null}, - {"name": "object_entry", "symbols": ["object_entry$ebnf$1", (lexer.has("identifier") ? {type: "identifier"} : identifier), "object_entry$ebnf$2", {"literal":":"}, "_", "drill"], "postprocess": p.objectEntry}, + {"name": "object_entry", "symbols": ["object_entry$ebnf$1", (lexer.has("identifier") ? {type: "identifier"} : identifier), "object_entry$ebnf$2", {"literal":":"}, "_", "expression"], "postprocess": p.objectEntry}, {"name": "object_entry$ebnf$3", "symbols": [{"literal":"?"}], "postprocess": id}, {"name": "object_entry$ebnf$3", "symbols": [], "postprocess": () => null}, {"name": "object_entry", "symbols": [(lexer.has("identifier") ? {type: "identifier"} : identifier), "object_entry$ebnf$3"], "postprocess": p.objectEntryShorthandSelect}, diff --git a/packages/parser/src/grammar/getlang.ne b/packages/parser/src/grammar/getlang.ne index 85b5244..bb0a758 100644 --- a/packages/parser/src/grammar/getlang.ne +++ b/packages/parser/src/grammar/getlang.ne @@ -3,29 +3,24 @@ import lexer from './grammar/lexer.js' import * as p from './grammar/parse.js' %} - @preprocessor typescript @lexer lexer - -### STRUCTURE +# structure program -> _ (inputs line_sep):? statements _ {% p.program %} statements -> statement (line_sep statement):* {% p.statements %} statement -> (request | assignment | extract) {% p.idd %} - -### KEYWORDS +# keyswords inputs -> "inputs" __ "{" _ input_decl (_ "," _ input_decl):* _ "}" {% p.declInputs %} -assignment -> "set" __ %identifier "?":? _ "=" _ drill {% p.assignment %} -extract -> "extract" __ drill {% p.extract %} +assignment -> "set" __ %identifier "?":? _ "=" _ expression {% p.assignment %} +extract -> "extract" __ expression {% p.extract %} - -### INPUTS +# inputs input_decl -> %identifier "?":? (_ "=" _ input_default):? {% p.inputDecl %} input_default -> slice {% id %} - -### REQUEST +# request request -> %request_verb template (line_sep request_block):? request_blocks {% p.request %} request_blocks -> (line_sep request_block_named):* (line_sep request_block_body):? {% p.requestBlocks %} request_block_named -> %request_block_name line_sep request_block {% p.requestBlockNamed %} @@ -33,42 +28,38 @@ request_block_body -> %request_block_body template %request_block_body_end {% p. request_block -> request_entry (line_sep request_entry):* {% p.requestBlock %} request_entry -> template ":" (__ template):? {% p.requestEntry %} +# expression +expression -> drill {% id %} +expression -> (drill _ %drill_arrow _):? %link _ drill {% p.link %} -### DRILL (left-associativity) -drill -> drill _ %drill_arrow _ expression {% p.drill %} -drill -> (%drill_arrow _):? expression {% p.drillContext %} - +# drill +drill -> drill _ %drill_arrow _ bit {% p.drill %} # left-associativity +drill -> (%drill_arrow _):? bit {% p.drillContext %} -### EXPR -expression -> (template | slice | call | link | object | subquery) {% p.idd %} -expression -> id_expr {% p.identifier %} +# drill bit +bit -> (template | slice | call | object | subquery) {% p.idd %} +bit -> id_expr {% p.identifier %} - -### SUBQUERIES +# subqueries subquery -> "(" _ statements _ ")" {% p.subquery %} - -### CALLS +# calls call -> %call ("(" object ")"):? {% p.call %} -link -> %link _ drill {% p.link %} - -### OBJECT LITERALS +# object literals object -> "{" _ (object_entry (_ ","):? _):* "}" {% p.object %} -object_entry -> "@":? %identifier "?":? ":" _ drill {% p.objectEntry %} +object_entry -> "@":? %identifier "?":? ":" _ expression {% p.objectEntry %} object_entry -> %identifier "?":? {% p.objectEntryShorthandSelect %} object_entry -> id_expr "?":? {% p.objectEntryShorthandIdent %} - -### LITERALS +# literals template -> (%literal | %interpvar | interp_expr | interp_tmpl):+ {% p.template %} interp_expr -> "${" _ %identifier _ "}" {% p.interpExpr %} interp_tmpl -> "$[" _ template _ "]" {% p.interpTmpl %} slice -> %slice {% p.slice %} id_expr -> %identifier_expr {% id %} - -### WHITESPACE +# whitespace line_sep -> (%ws | %comment):* %nl _ {% p.ws %} __ -> ws:+ {% p.ws %} _ -> ws:* {% p.ws %} diff --git a/packages/parser/src/grammar/lex/shared.ts b/packages/parser/src/grammar/lex/shared.ts index 7e010e1..30a5a87 100644 --- a/packages/parser/src/grammar/lex/shared.ts +++ b/packages/parser/src/grammar/lex/shared.ts @@ -4,6 +4,6 @@ export const patterns = { ws: /[ \t\r\f\v]+/, identifier: id, identifierExpr: new RegExp(`\\$(?:${id.source})?`), - call: new RegExp(`\\@${id.source}`), link: new RegExp(`\\@${id.source}\\)`), + call: new RegExp(`\\@${id.source}`), } diff --git a/packages/parser/src/grammar/lex/slice.ts b/packages/parser/src/grammar/lex/slice.ts index c52c1c6..3e54e5b 100644 --- a/packages/parser/src/grammar/lex/slice.ts +++ b/packages/parser/src/grammar/lex/slice.ts @@ -1,4 +1,5 @@ -import { invariant, QuerySyntaxError } from '@getlang/utils' +import { invariant } from '@getlang/utils' +import { QuerySyntaxError } from '@getlang/utils/errors' import { until } from './templates.js' const getSliceValue = (text: string, places = 1) => { diff --git a/packages/parser/src/grammar/parse.ts b/packages/parser/src/grammar/parse.ts index 4780fb1..0887948 100644 --- a/packages/parser/src/grammar/parse.ts +++ b/packages/parser/src/grammar/parse.ts @@ -1,6 +1,7 @@ -import { invariant, QuerySyntaxError } from '@getlang/utils' +import { invariant } from '@getlang/utils' +import { QuerySyntaxError } from '@getlang/utils/errors' import { isToken, NodeKind, t } from '../ast/ast.js' -import { tx } from '../desugar/utils.js' +import { tx } from '../utils.js' type PP = nearley.Postprocessor @@ -78,14 +79,24 @@ export const extract: PP = ([, , exports]) => t.extractStmt(exports) export const subquery: PP = ([, , stmts]) => t.subqueryExpr(stmts) -export const call: PP = ([callee, maybeInputs]) => - t.callExpr(callee, maybeInputs?.[1]) +export const call: PP = ([callee, maybeInputs]) => { + const inputs = maybeInputs?.[1] + return /^[a-z]/.test(callee.value) + ? t.modifierExpr(callee, inputs) + : t.moduleExpr(callee, inputs) +} -export const link: PP = ([callee, _, link]) => - t.callExpr( +export const link: PP = ([maybePrior, callee, _, link]) => { + const bit = t.moduleExpr( callee, t.objectLiteralExpr([t.objectEntry(tx.template('@link'), link, true)]), ) + if (!maybePrior) { + return bit + } + const [context, , arrow] = maybePrior + return drill([context, null, arrow, null, bit]) +} export const object: PP = d => { const entries = d[2].map((dd: any) => dd[0]) diff --git a/packages/parser/src/index.ts b/packages/parser/src/index.ts index 994b674..89e2827 100644 --- a/packages/parser/src/index.ts +++ b/packages/parser/src/index.ts @@ -1,13 +1,15 @@ -import { QuerySyntaxError } from '@getlang/utils' +import { invariant } from '@getlang/utils' +import { QuerySyntaxError } from '@getlang/utils/errors' import nearley from 'nearley' import type { Program } from './ast/ast.js' import lexer from './grammar/lexer.js' import grammar from './grammar.js' -export { print } from './ast/print.js' -export { desugar } from './desugar/simplified.js' - export { lexer } +export { print } from './ast/print.js' +export { analyze } from './passes/analyze.js' +export { desugar } from './passes/desugar.js' +export { inference } from './passes/inference.js' export function parse(source: string): Program { const parser = new nearley.Parser(nearley.Grammar.fromCompiled(grammar)) @@ -22,13 +24,8 @@ export function parse(source: string): Program { throw e } - const { results } = parser - switch (results.length) { - case 1: - return results[0] - case 0: - throw new QuerySyntaxError('Unexpected end of input') - default: - throw new QuerySyntaxError('Unexpected parsing error') - } + const [ast, ...rest] = parser.results + invariant(ast, new QuerySyntaxError('Unexpected end of input')) + invariant(!rest.length, new QuerySyntaxError('Unexpected parsing error')) + return ast } diff --git a/packages/parser/src/passes/analyze.ts b/packages/parser/src/passes/analyze.ts new file mode 100644 index 0000000..b78b697 --- /dev/null +++ b/packages/parser/src/passes/analyze.ts @@ -0,0 +1,49 @@ +import type { CExpr, Program } from '../ast/ast.js' +import { Type } from '../ast/typeinfo.js' +import type { TransformVisitor } from '../visitor/transform.js' +import { visit } from '../visitor/visitor.js' +import { traceVisitor } from './trace.js' + +export function analyze(ast: Program) { + const { scope, trace } = traceVisitor() + const inputs = new Set() + const imports = new Set() + let isMacro = false + + function checkMacro(node: CExpr) { + if (!node.context) { + const implicitType = scope.context?.typeInfo.type + isMacro ||= implicitType === Type.Context + } + } + + const visitor: TransformVisitor = { + ...trace, + InputDeclStmt(node) { + inputs.add(node.id.value) + return trace.InputDeclStmt(node) + }, + ModifierExpr: { + enter(node, visit) { + checkMacro(node) + return trace.ModifierExpr.enter(node, visit) + }, + }, + ModuleExpr: { + enter(node, visit) { + imports.add(node.module.value) + return trace.ModuleExpr.enter(node, visit) + }, + }, + SelectorExpr: { + enter(node, visit) { + checkMacro(node) + return trace.SelectorExpr.enter(node, visit) + }, + }, + } + + visit(ast, visitor) + + return { inputs, imports, isMacro } +} diff --git a/packages/parser/src/passes/desugar.ts b/packages/parser/src/passes/desugar.ts new file mode 100644 index 0000000..f999b5d --- /dev/null +++ b/packages/parser/src/passes/desugar.ts @@ -0,0 +1,41 @@ +import type { Program } from '../ast/ast.js' +import type { TransformVisitor } from '../visitor/visitor.js' +import { visit } from '../visitor/visitor.js' +import { resolveContext } from './desugar/context.js' +import { settleLinks } from './desugar/links.js' +import { RequestParsers } from './desugar/reqparse.js' +import { insertSliceDeps } from './desugar/slicedeps.js' +import { registerCalls } from './inference/calls.js' + +export type DesugarPass = (tools: { + parsers: RequestParsers + macros: string[] +}) => TransformVisitor + +function listCalls(ast: Program) { + const calls = new Set() + visit(ast, { + ModuleExpr(node) { + if (node.call) { + calls.add(node.module.value) + } + }, + } as TransformVisitor) + return calls +} + +export function desugar(ast: Program, macros: string[] = []) { + const parsers = new RequestParsers() + const visitors = [resolveContext, settleLinks, insertSliceDeps] + let program = visitors.reduce((ast, pass) => { + parsers.reset() + const visitor = pass({ parsers, macros }) + return visit(ast, visitor) + }, ast) + + // 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 } +} diff --git a/packages/parser/src/desugar/inference/acorn-globals.d.ts b/packages/parser/src/passes/desugar/acorn-globals.d.ts similarity index 100% rename from packages/parser/src/desugar/inference/acorn-globals.d.ts rename to packages/parser/src/passes/desugar/acorn-globals.d.ts diff --git a/packages/parser/src/desugar/inference/context.ts b/packages/parser/src/passes/desugar/context.ts similarity index 60% rename from packages/parser/src/desugar/inference/context.ts rename to packages/parser/src/passes/desugar/context.ts index 2f2b5a8..7519a4e 100644 --- a/packages/parser/src/desugar/inference/context.ts +++ b/packages/parser/src/passes/desugar/context.ts @@ -1,14 +1,13 @@ -import { invariant, QuerySyntaxError } from '@getlang/utils' +import { invariant } from '@getlang/utils' +import { QuerySyntaxError } from '@getlang/utils/errors' import type { CExpr, Expr } from '../../ast/ast.js' import { NodeKind } from '../../ast/ast.js' -import { RootScope } from '../../ast/scope.js' -import type { TransformVisitor } from '../../visitor/transform.js' -import type { RequestParsers } from '../reqparse.js' +import { tx } from '../../utils.js' +import type { DesugarPass } from '../desugar.js' import { traceVisitor } from '../trace.js' -import { tx } from '../utils.js' -export function inferContext(parsers: RequestParsers): TransformVisitor { - const scope = new RootScope() +export const resolveContext: DesugarPass = ({ parsers, macros }) => { + const { scope, trace } = traceVisitor() function infer(node: CExpr, mod?: string) { let resolved: Expr @@ -28,8 +27,6 @@ export function inferContext(parsers: RequestParsers): TransformVisitor { return { resolved, from } } - const trace = traceVisitor(scope) - return { ...trace, @@ -38,8 +35,11 @@ export function inferContext(parsers: RequestParsers): TransformVisitor { return node }, - Program(node) { - return { ...node, body: parsers.insert(node.body) } + Program: { + enter(node, visit) { + const xnode = trace.Program.enter(node, visit) + return { ...xnode, body: parsers.insert(xnode.body) } + }, }, SubqueryExpr: { @@ -56,16 +56,11 @@ export function inferContext(parsers: RequestParsers): TransformVisitor { }, }, - CallExpr: { + ModifierExpr: { enter(node, visit) { - const callee = node.callee.value - if (node.calltype === 'module') { - return trace.CallExpr.enter(node, visit) - } - - const { resolved: context, from } = infer(node, callee) - const xnode = trace.CallExpr.enter({ ...node, context }, visit) - + const modifier = node.modifier.value + const { resolved: context, from } = infer(node, modifier) + const xnode = trace.ModifierExpr.enter({ ...node, context }, visit) const onRequest = from?.kind === NodeKind.RequestExpr // when inferred to request parser, replace modifier if (onRequest) { @@ -75,5 +70,15 @@ export function inferContext(parsers: RequestParsers): TransformVisitor { return xnode }, }, + + ModuleExpr: { + enter(node, visit) { + const module = node.module.value + const context = macros.includes(module) + ? infer(node).resolved + : node.context + return trace.ModuleExpr.enter({ ...node, context }, visit) + }, + }, } } diff --git a/packages/parser/src/passes/desugar/links.ts b/packages/parser/src/passes/desugar/links.ts new file mode 100644 index 0000000..6925589 --- /dev/null +++ b/packages/parser/src/passes/desugar/links.ts @@ -0,0 +1,113 @@ +import { invariant } from '@getlang/utils' +import { QuerySyntaxError, ValueReferenceError } from '@getlang/utils/errors' +import type { Expr, RequestExpr } from '../../ast/ast.js' +import { NodeKind, t } from '../../ast/ast.js' +import { render, tx } from '../../utils.js' +import type { DesugarPass } from '../desugar.js' +import { traceVisitor } from '../trace.js' + +export const settleLinks: DesugarPass = ({ parsers }) => { + const { scope, trace } = traceVisitor() + + const bases = new Map() + function inherit(c: Expr, n: Expr) { + const base = bases.get(c) + base && bases.set(n, base) + } + + return { + ...trace, + + IdentifierExpr(node) { + const id = node.value.value + const value = scope.vars[id] + invariant(value, new ValueReferenceError(id)) + inherit(value, node) + return node + }, + + SelectorExpr: { + enter(node, visit) { + const xnode = trace.SelectorExpr.enter(node, visit) + invariant(xnode.context, new QuerySyntaxError('Unresolved context')) + inherit(xnode.context, xnode) + return xnode + }, + }, + + ModifierExpr: { + enter(node, visit) { + const xnode = trace.ModifierExpr.enter(node, visit) + invariant( + xnode.args.kind === NodeKind.ObjectLiteralExpr, + new QuerySyntaxError('Modifier options must be an object'), + ) + + if (xnode.modifier.value === 'link' && xnode.context) { + const contextBase = bases.get(xnode.context) + const hasBase = xnode.args.entries.some(e => render(e.key) === 'base') + if (contextBase && !hasBase) { + xnode.args.entries.push( + t.objectEntry( + tx.template('base'), + parsers.lookup(contextBase, 'url'), + ), + ) + } + } + + invariant(xnode.context, new QuerySyntaxError('Unresolved context')) + inherit(xnode.context, xnode) + return xnode + }, + }, + + ModuleExpr: { + enter(node, visit) { + const tnode = { + ...node, + args: { + ...node.args, + entries: node.args.entries.map(e => { + if ( + render(e.key) !== '@link' || + (e.value.kind === NodeKind.ModifierExpr && + e.value.modifier.value === 'link') + ) { + return e + } + const value = t.modifierExpr(tx.token('link'), undefined, e.value) + return { ...e, value } + }), + }, + } + return trace.ModuleExpr.enter(tnode, visit) + }, + }, + + RequestExpr(node) { + parsers.visit(node) + bases.set(node, node) + return node + }, + + Program: { + enter(node, visit) { + const xnode = trace.Program.enter(node, visit) + return { ...xnode, body: parsers.insert(xnode.body) } + }, + }, + + SubqueryExpr: { + enter(node, visit) { + let xnode = trace.SubqueryExpr.enter(node, visit) + const extracted = xnode.body.find( + stmt => stmt.kind === NodeKind.ExtractStmt, + ) + xnode = { ...xnode, body: parsers.insert(xnode.body) } + extracted && inherit(extracted.value, xnode) + return xnode + }, + }, + } +} diff --git a/packages/parser/src/desugar/reqparse.ts b/packages/parser/src/passes/desugar/reqparse.ts similarity index 86% rename from packages/parser/src/desugar/reqparse.ts rename to packages/parser/src/passes/desugar/reqparse.ts index c5f7e80..146051a 100644 --- a/packages/parser/src/desugar/reqparse.ts +++ b/packages/parser/src/passes/desugar/reqparse.ts @@ -1,7 +1,8 @@ -import { invariant, QuerySyntaxError } from '@getlang/utils' -import type { Expr, RequestExpr, Stmt } from '../ast/ast.js' -import { NodeKind, t } from '../ast/ast.js' -import { getContentField, tx } from './utils.js' +import { invariant } from '@getlang/utils' +import { QuerySyntaxError } from '@getlang/utils/errors' +import type { Expr, RequestExpr, Stmt } from '../../ast/ast.js' +import { NodeKind, t } from '../../ast/ast.js' +import { getContentField, tx } from '../../utils.js' type Parsers = Record @@ -52,7 +53,7 @@ export class RequestParsers { context = tx.select('body', reqId) } } - return t.callExpr(tx.token(field), undefined, context) + return t.modifierExpr(tx.token(field), undefined, context) } const id = this.id(idx, field) diff --git a/packages/parser/src/desugar/inference/slicedeps.ts b/packages/parser/src/passes/desugar/slicedeps.ts similarity index 82% rename from packages/parser/src/desugar/inference/slicedeps.ts rename to packages/parser/src/passes/desugar/slicedeps.ts index 6ab26cd..973620a 100644 --- a/packages/parser/src/desugar/inference/slicedeps.ts +++ b/packages/parser/src/passes/desugar/slicedeps.ts @@ -1,12 +1,15 @@ /// -import { invariant, SliceSyntaxError } from '@getlang/utils' -import { type Program, parse } from 'acorn' +import { invariant } from '@getlang/utils' +import { SliceSyntaxError } from '@getlang/utils/errors' +import type { Program } from 'acorn' +import { parse } from 'acorn' import detect from 'acorn-globals' import globals from 'globals' -import { type Expr, t } from '../../ast/ast.js' -import type { TransformVisitor } from '../../visitor/transform.js' -import { tx } from '../utils.js' +import type { Expr } from '../../ast/ast.js' +import { t } from '../../ast/ast.js' +import { tx } from '../../utils.js' +import type { DesugarPass } from '../desugar.js' const browserGlobals = [ ...Object.keys(globals.browser), @@ -57,7 +60,7 @@ const analyzeSlice = (_source: string, analyzeDeps: boolean) => { return { source, deps, usesContext } } -export function inferSliceDeps(): TransformVisitor { +export const insertSliceDeps: DesugarPass = () => { return { SliceExpr(node) { const stat = analyzeSlice(node.slice.value, !node.context) diff --git a/packages/parser/src/passes/inference.ts b/packages/parser/src/passes/inference.ts new file mode 100644 index 0000000..912797c --- /dev/null +++ b/packages/parser/src/passes/inference.ts @@ -0,0 +1,13 @@ +import type { Program } from '../ast/ast.js' +import type { TypeInfo } from '../ast/typeinfo.js' +import { resolveTypes } from './inference/typeinfo.js' + +type InferenceOptions = { + returnTypes: { [module: string]: TypeInfo } + contextType?: TypeInfo +} + +export function inference(ast: Program, options: InferenceOptions) { + const { program, returnType } = resolveTypes(ast, options) + return { program, returnType } +} diff --git a/packages/parser/src/passes/inference/calls.ts b/packages/parser/src/passes/inference/calls.ts new file mode 100644 index 0000000..1814127 --- /dev/null +++ b/packages/parser/src/passes/inference/calls.ts @@ -0,0 +1,67 @@ +import type { Expr, Program } from '../../ast/ast.js' +import { isToken, NodeKind } from '../../ast/ast.js' +import type { TransformVisitor } from '../../visitor/visitor.js' +import { visit } from '../../visitor/visitor.js' +import { traceVisitor } from '../trace.js' + +export function registerCalls(ast: Program, macros: string[] = []) { + const { scope, trace } = traceVisitor() + const mutable = visit(ast, {} as TransformVisitor) + + function registerCall(node?: Expr) { + switch (node?.kind) { + case NodeKind.IdentifierExpr: { + const value = scope.vars[node.value.value] + return registerCall(value) + } + case NodeKind.SubqueryExpr: { + const ex = node.body.find(s => s.kind === NodeKind.ExtractStmt) + return registerCall(ex?.value) + } + case NodeKind.ModuleExpr: { + node.call = true + } + } + } + + const visitor: TransformVisitor = { + ...trace, + + TemplateExpr: { + enter(node) { + for (const el of node.elements) { + if (!isToken(el)) { + registerCall(el) + } + } + return node + }, + }, + + SelectorExpr: { + enter(node, visit) { + registerCall(node.context) + return trace.SelectorExpr.enter(node, visit) + }, + }, + + ModifierExpr: { + enter(node, visit) { + registerCall(node.context) + return trace.ModifierExpr.enter(node, visit) + }, + }, + + ModuleExpr: { + enter(node, visit) { + const module = node.module.value + if (macros.includes(module)) { + registerCall(node) + } + return trace.ModuleExpr.enter(node, visit) + }, + }, + } + + return visit(mutable, visitor) +} diff --git a/packages/parser/src/desugar/inference/typeinfo.ts b/packages/parser/src/passes/inference/typeinfo.ts similarity index 66% rename from packages/parser/src/desugar/inference/typeinfo.ts rename to packages/parser/src/passes/inference/typeinfo.ts index 2bc0e9c..a9031a0 100644 --- a/packages/parser/src/desugar/inference/typeinfo.ts +++ b/packages/parser/src/passes/inference/typeinfo.ts @@ -1,18 +1,19 @@ -import { - invariant, - QuerySyntaxError, - ValueReferenceError, -} from '@getlang/utils' -import { type CExpr, type Expr, NodeKind, t } from '../../ast/ast.js' -import { RootScope } from '../../ast/scope.js' -import { Type, type TypeInfo } from '../../ast/typeinfo.js' +import { invariant } from '@getlang/utils' +import { QuerySyntaxError, ValueReferenceError } from '@getlang/utils/errors' +import type { CExpr, Program } from '../../ast/ast.js' +import { NodeKind, t } from '../../ast/ast.js' +import type { TypeInfo } from '../../ast/typeinfo.js' +import { Type } from '../../ast/typeinfo.js' +import { render, selectTypeInfo } from '../../utils.js' import type { TransformVisitor, Visit } from '../../visitor/transform.js' +import { visit } from '../../visitor/visitor.js' import { traceVisitor } from '../trace.js' -import { render, selectTypeInfo } from '../utils.js' const modTypeMap: Record = { html: { type: Type.Html }, js: { type: Type.Js }, + json: { type: Type.Value }, + link: { type: Type.Value }, headers: { type: Type.Headers }, cookies: { type: Type.Cookies }, } @@ -48,9 +49,37 @@ function rewrap( } } -export function inferTypeInfo(): TransformVisitor { - const scope = new RootScope() +function specialize(macroType: TypeInfo, contextType?: TypeInfo) { + function walk(ti: TypeInfo): TypeInfo { + switch (ti.type) { + case Type.Context: + invariant(contextType, 'Specialize requires context type') + return contextType + case Type.Maybe: + return { ...ti, option: walk(ti.option) } + case Type.List: + return { ...ti, of: walk(ti.of) } + case Type.Struct: { + const schema = Object.fromEntries( + Object.entries(ti.schema).map(e => [e[0], walk(e[1])]), + ) + return { ...ti, schema } + } + default: + return ti + } + } + return walk(macroType) +} + +type ResolveTypeOptions = { + returnTypes: { [module: string]: TypeInfo } + contextType?: TypeInfo +} +export function resolveTypes(ast: Program, options: ResolveTypeOptions) { + const { returnTypes, contextType } = options + const { scope, trace } = traceVisitor(contextType) let optional = false function setOptional(opt: boolean, cb: () => T): T { const last = optional @@ -60,7 +89,7 @@ export function inferTypeInfo(): TransformVisitor { return ret } - function ctx(cb: (tnode: C, ivisit: Visit) => C) { + function withContext(cb: (tnode: C, ivisit: Visit) => C) { return function enter(node: C, visit: Visit): C { if (!node.context) { return cb(node, visit) @@ -81,9 +110,7 @@ export function inferTypeInfo(): TransformVisitor { } } - const trace = traceVisitor(scope) - - return { + const visitor: TransformVisitor = { ...trace, InputDeclStmt: { @@ -130,7 +157,7 @@ export function inferTypeInfo(): TransformVisitor { }, SliceExpr: { - enter: ctx((node, visit) => { + enter: withContext((node, visit) => { const xnode = trace.SliceExpr.enter(node, visit) let typeInfo: TypeInfo = { type: Type.Value } if (optional) { @@ -141,7 +168,7 @@ export function inferTypeInfo(): TransformVisitor { }, SelectorExpr: { - enter: ctx((node, visit) => { + enter: withContext((node, visit) => { const xnode = trace.SelectorExpr.enter(node, visit) let typeInfo: TypeInfo = unwrap( xnode.context?.typeInfo ?? { type: Type.Value }, @@ -168,17 +195,30 @@ export function inferTypeInfo(): TransformVisitor { }), }, - CallExpr: { - enter: ctx((node, visit) => { - const xnode = trace.CallExpr.enter(node, visit) - const callee = xnode.callee.value - const typeInfo = modTypeMap[callee] ?? { type: Type.Value } + ModifierExpr: { + enter: withContext((node, visit) => { + const xnode = trace.ModifierExpr.enter(node, visit) + const typeInfo = modTypeMap[node.modifier.value] + invariant(typeInfo, 'Modifier type lookup failed') + return { ...xnode, typeInfo } + }), + }, + + ModuleExpr: { + enter: withContext((node, visit) => { + const xnode = trace.ModuleExpr.enter(node, visit) + if (!node.call) { + return { ...xnode, typeInfo: { type: Type.Value } } + } + const returnType = returnTypes[node.module.value] + invariant(returnType, 'Module return type lookup failed') + const typeInfo = specialize(returnType, xnode.context?.typeInfo) return { ...xnode, typeInfo } }), }, SubqueryExpr: { - enter: ctx((node, visit) => { + enter: withContext((node, visit) => { const xnode = trace.SubqueryExpr.enter(node, visit) const typeInfo = xnode.body.find( stmt => stmt.kind === NodeKind.ExtractStmt, @@ -188,7 +228,7 @@ export function inferTypeInfo(): TransformVisitor { }, ObjectLiteralExpr: { - enter: ctx((node, visit) => { + enter: withContext((node, visit) => { const xnode = trace.ObjectLiteralExpr.enter(node, child => { if (child === node.context) { return visit(child) @@ -220,4 +260,10 @@ export function inferTypeInfo(): TransformVisitor { }), }, } + + const program: Program = visit(ast, visitor) + const ex = program.body.find(s => s.kind === NodeKind.ExtractStmt) + const returnType = ex?.value.typeInfo ?? { type: Type.Never } + + return { program, returnType } } diff --git a/packages/parser/src/desugar/trace.ts b/packages/parser/src/passes/trace.ts similarity index 54% rename from packages/parser/src/desugar/trace.ts rename to packages/parser/src/passes/trace.ts index 5653c03..43780a6 100644 --- a/packages/parser/src/desugar/trace.ts +++ b/packages/parser/src/passes/trace.ts @@ -1,10 +1,18 @@ import type { CExpr, Expr } from '../ast/ast.js' -import { t } from '../ast/ast.js' -import type { RootScope } from '../ast/scope.js' +import { RootScope } from '../ast/scope.js' +import type { TypeInfo } from '../ast/typeinfo.js' +import { Type } from '../ast/typeinfo.js' +import { tx } from '../utils.js' import type { TransformVisitor, Visit } from '../visitor/transform.js' -export function traceVisitor(scope: RootScope) { - function ctx(node: C, visit: Visit, cb: (tnode: C) => C) { +export function traceVisitor(contextType: TypeInfo = { type: Type.Context }) { + const scope = new RootScope() + + function withContext( + node: C, + visit: Visit, + cb: (tnode: C) => C, + ) { if (!node.context) { return cb(node) } @@ -15,10 +23,10 @@ export function traceVisitor(scope: RootScope) { return xnode } - return { + const trace = { // statements with scope affect InputDeclStmt(node) { - scope.vars[node.id.value] = t.identifierExpr(node.id) + scope.vars[node.id.value] = tx.select('') return node }, @@ -37,10 +45,21 @@ export function traceVisitor(scope: RootScope) { return node }, + Program: { + enter(node, visit) { + const programContext = tx.select('') + programContext.typeInfo = contextType + scope.push(programContext) + const body = node.body.map(visit) + scope.pop() + return { ...node, body } + }, + }, + // contextual expressions SubqueryExpr: { enter(node, visit) { - return ctx(node, visit, node => { + return withContext(node, visit, node => { scope.push() const body = node.body.map(visit) scope.pop() @@ -51,7 +70,7 @@ export function traceVisitor(scope: RootScope) { ObjectLiteralExpr: { enter(node, visit) { - return ctx(node, visit, node => { + return withContext(node, visit, node => { const entries = node.entries.map(e => { const value = visit(e.value) return { ...e, value } @@ -63,16 +82,24 @@ export function traceVisitor(scope: RootScope) { SelectorExpr: { enter(node, visit) { - return ctx(node, visit, node => { + return withContext(node, visit, node => { return { ...node, selector: visit(node.selector) } }) }, }, - CallExpr: { + ModifierExpr: { enter(node, visit) { - return ctx(node, visit, node => { - return { ...node, inputs: visit(node.inputs) } + return withContext(node, visit, node => { + return { ...node, args: visit(node.args) } + }) + }, + }, + + ModuleExpr: { + enter(node, visit) { + return withContext(node, visit, node => { + return { ...node, args: visit(node.args) } }) }, }, @@ -80,8 +107,10 @@ export function traceVisitor(scope: RootScope) { SliceExpr: { enter(node, visit) { // contains no additional expressions (only .context) - return ctx(node, visit, node => node) + return withContext(node, visit, node => node) }, }, } satisfies TransformVisitor + + return { scope, trace } } diff --git a/packages/parser/src/desugar/utils.ts b/packages/parser/src/utils.ts similarity index 83% rename from packages/parser/src/desugar/utils.ts rename to packages/parser/src/utils.ts index 871e00f..1ff33cf 100644 --- a/packages/parser/src/desugar/utils.ts +++ b/packages/parser/src/utils.ts @@ -1,8 +1,8 @@ import { toPath } from 'lodash-es' -import type { Expr, RequestExpr } from '../ast/ast.js' -import { isToken, NodeKind, t } from '../ast/ast.js' -import type { Struct, TypeInfo } from '../ast/typeinfo.js' -import { Type } from '../ast/typeinfo.js' +import type { Expr, RequestExpr } from './ast/ast.js' +import { isToken, NodeKind, t } from './ast/ast.js' +import type { Struct, TypeInfo } from './ast/typeinfo.js' +import { Type } from './ast/typeinfo.js' export const render = (template: Expr) => { if (template.kind !== NodeKind.TemplateExpr) { @@ -60,7 +60,7 @@ function template(contents: string) { return t.templateExpr([token(contents)]) } -function select(selector: string, context: Expr) { +function select(selector: string, context?: Expr) { return t.selectorExpr(template(selector), false, context) } diff --git a/packages/utils/package.json b/packages/utils/package.json index 2db2d75..5731fea 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -4,8 +4,14 @@ "license": "Apache-2.0", "type": "module", "exports": { - "bun": "./src/index.ts", - "default": "./dist/index.js" + ".": { + "bun": "./src/index.ts", + "default": "./dist/index.js" + }, + "./errors": { + "bun": "./src/errors.ts", + "default": "./dist/errors.js" + } }, "bugs": { "url": "https://github.com/getlang-dev/get/issues" diff --git a/packages/utils/src/errors.ts b/packages/utils/src/errors.ts index e74fdfb..6ca609e 100644 --- a/packages/utils/src/errors.ts +++ b/packages/utils/src/errors.ts @@ -72,6 +72,15 @@ export class NullInputError extends RuntimeError { } } +export class UnknownInputsError extends RuntimeError { + public override name = 'UnknownInputError' + + constructor(inputNames: string[], options?: ErrorOptions) { + const noun = inputNames.length > 1 ? 'inputs' : 'input' + super(`Unknown ${noun} provided: ${inputNames.join(', ')}`, options) + } +} + export class RequestError extends RuntimeError { public override name = 'RequestError' @@ -84,11 +93,9 @@ export class ImportError extends RuntimeError { public override name = 'ImportError' } -export function invariant( - condition: unknown, - err: string | RuntimeError, -): asserts condition { - if (!condition) { - throw typeof err === 'string' ? new FatalError({ cause: err }) : err +export class RecursiveCallError extends RuntimeError { + public override name = 'RecursiveCallError' + constructor(chain: string[], options?: ErrorOptions) { + super(`Recursive call error: ${chain.join(' -> ')}`, options) } } diff --git a/packages/utils/src/hooks.ts b/packages/utils/src/hooks.ts index 3cbec55..24628fd 100644 --- a/packages/utils/src/hooks.ts +++ b/packages/utils/src/hooks.ts @@ -1,15 +1,14 @@ +import type { Inputs } from './index.js' import type { MaybePromise } from './wait.js' export type ImportHook = (module: string) => MaybePromise -export type CallHook = ( - module: string, - inputs: Record, - raster: Record, - execute: () => Promise, -) => Promise +export type CallHook = (module: string, inputs: Inputs) => MaybePromise -export type RequestHook = (url: string, opts: RequestInit) => Promise +export type RequestHook = ( + url: string, + opts: RequestInit, +) => MaybePromise export type SliceHook = ( slice: string, @@ -17,14 +16,19 @@ export type SliceHook = ( raw?: unknown, ) => MaybePromise -export type Hooks = { +export type ExtractHook = ( + module: string, + inputs: Inputs, + value: any, +) => MaybePromise + +export type Hooks = Partial<{ import: ImportHook request: RequestHook slice: SliceHook call: CallHook -} - -export type UserHooks = Partial + extract: ExtractHook +}> type RequestInit = { method?: string diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 3c2645b..2e308ac 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,7 +1,20 @@ -export * from './errors.js' +import type { RuntimeError } from './errors.js' +import { FatalError } from './errors.js' + export * from './hooks.js' export * from './wait.js' +export type Inputs = Record + export class NullSelection { constructor(public selector: string) {} } + +export function invariant( + condition: unknown, + err: string | RuntimeError, +): asserts condition { + if (!condition) { + throw typeof err === 'string' ? new FatalError({ cause: err }) : err + } +} diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json index 9536a0f..dafae0f 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -1,3 +1,4 @@ { - "extends": "../../tsconfig.base.json" + "extends": "../../tsconfig.base.json", + "include": ["src/index.ts", "src/errors.ts"] } diff --git a/test/calls.spec.ts b/test/calls.spec.ts new file mode 100644 index 0000000..5a872b9 --- /dev/null +++ b/test/calls.spec.ts @@ -0,0 +1,304 @@ +import { describe, expect, test } from 'bun:test' +import { RecursiveCallError } from '@getlang/utils/errors' +import { execute } from './helpers.js' + +describe('calls', () => { + test('modules', async () => { + const result = await execute({ + Auth: 'extract { token: `"abc"` }', + Home: `extract { + auth: @Auth -> token + }`, + }) + expect(result).toEqual({ + auth: 'abc', + }) + }) + + test('semantics', async () => { + const modules = { + Call: ` + inputs { called } + extract { called: \`true\` } + `, + Home: ` + set called = \`false\` + set as_var = @Call({ $called }) + set as_subquery = ( extract @Call({ $called }) ) + + extract { + select: @Call({ $called }) -> called + from_var: $as_var -> called + subquery: ( extract @Call({ $called }) ) -> called + from_subquery: $as_subquery -> called + } + `, + } + const result = await execute(modules) + expect(result).toEqual({ + select: true, + from_var: true, + subquery: true, + from_subquery: true, + }) + }) + + test.skip('semantics - object key', async () => { + const modules = { + Call: ` + inputs { called } + extract { called: \`true\` } + `, + Home: ` + set called = \`false\` + extract { + object: as_entry = { key: @Call({ $called }) } -> key -> called + } + `, + } + const result = await execute(modules) + expect(result).toEqual({ object: true }) + }) + + test('drill return value', async () => { + const modules = { + Req: ` + GET http://stub + + extract @html + `, + Home: ` + set req = @Req + extract $req -> { div, span } + `, + } + + const result = await execute( + modules, + {}, + () => new Response(`
x
y`), + ) + expect(result).toEqual({ div: 'x', span: 'y' }) + }) + + test.skip('drill returned request', async () => { + const modules = { + Req: ` + GET http://stub + + extract $ + `, + Home: ` + set req = @Req + extract $req -> { div, span } + `, + } + + const result = await execute( + modules, + {}, + () => new Response(`
x
y`), + ) + expect(result).toEqual({ div: 'x', span: 'y' }) + }) + + test('links', async () => { + const modules = { + Search: 'extract `1`', + Product: 'extract `2`', + Home: ` + inputs { query, page? } + + GET https://search.com/ + [query] + s: $query + page: $page + + set results = => li.result -> @Product({ + @link: a + name: a + desc: p.description + }) + + extract { + items: $results + pager: .pager -> { + next: @Search) a.next + prev: @Search) a.prev + } + } + `, + } + + const result = await execute( + modules, + { query: 'gifts' }, + () => + new Response(` + + +
+ +
+ `), + ) + expect(result).toEqual({ + items: [ + { + '@link': 'https://search.com/products/1', + name: 'Deck o cards', + desc: 'Casino grade playing cards', + }, + ], + pager: { + next: { + '@link': 'https://search.com/?s=gifts&page=2', + }, + prev: {}, + }, + }) + }) + + test('links pre/post-amble', async () => { + const modules = { + Home: ` + GET http://stub/x/y/z + + extract #a -> >#b -> @Link) >#c -> >#d + `, + Link: ` + extract { + _module: \`'Link'\` + } + `, + } + const result = await execute( + modules, + {}, + () => + new Response(` + + +
+
+
+ link +
+
+ + `), + ) + + expect(result).toEqual({ + '@link': 'http://stub/a/b/c/d', + }) + }) + + test('context propagation', async () => { + const modules = { + Home: ` + GET http://stub + + extract { + a: @Data({text: \`'first'\`}) + b: @Data({text: \`'second'\`}) + } + `, + Data: ` + inputs { text } + + extract xpath://div[p[contains(text(), '$text')]] + -> xpath:@data-json + -> @json + `, + } + + const result = await execute( + modules, + {}, + () => + new Response(` + +

first

+

second

+ `), + ) + + expect(result).toEqual({ + a: { x: 1 }, + b: { y: 2 }, + }) + }) + + test('drill macro return types', async () => { + const modules = { + Home: ` + GET http://stub + + extract @Data({text: \`'first'\`}) + -> xpath:@data-json + -> @json + `, + Data: ` + inputs { text } + + extract xpath://div[p[contains(text(), '$text')]] + `, + } + + const result = await execute( + modules, + {}, + () => new Response(`

first

`), + ) + + expect(result).toEqual({ x: 1 }) + }) + + test('recursive', async () => { + const modules = { + Home: ` + extract { + value: @Page -> value + } + `, + Page: ` + extract { + value: @Home -> value + } + `, + } + + const result = execute(modules) + return expect(result).rejects.toThrow( + new RecursiveCallError(['Home', 'Page', 'Home']), + ) + }) + + test('recursive link not called', async () => { + const modules = { + Home: ` + extract { + page: @Page -> value + } + `, + Page: ` + extract { + value: @Home({ + called: \`false\` + }) + } + `, + } + + const result = await execute(modules) + expect(result).toEqual({ + page: { called: false }, + }) + }) +}) diff --git a/test/helpers.ts b/test/helpers.ts index cacd3d3..c367483 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,94 +1,53 @@ -import { executeAST as exec } from '@getlang/get' +import { expect } from 'bun:test' +import { executeModule } from '@getlang/get' import { desugar, parse, print } from '@getlang/parser' -import { isToken, type Program } from '@getlang/parser/ast' -import type { UserHooks } from '@getlang/utils' +import type { Hooks, Inputs, MaybePromise } from '@getlang/utils' import { invariant } from '@getlang/utils' +import { ImportError } from '@getlang/utils/errors' import dedent from 'dedent' -import { dump } from 'js-yaml' import './expect.js' -export type Fetch = (req: Request) => Promise | Response +export type Fetch = (req: Request) => MaybePromise -const DEBUG = Boolean(process.env.AST) export const SELSYN = true -function printYaml(ast: Program) { - console.log('\n---- execute ast ----') - console.log( - dump(ast, { - indent: 4, - replacer(_, value) { - return isToken(value) ? `TOKEN(${value.value})` : value - }, - }), - ) +function testIdempotency(source: string) { + const print1 = print(desugar(parse(source)).program) + const print2 = print(desugar(parse(print1)).program) + expect(print1).toEqual(print2) } -export function helper() { - const collected: string[] = [] - - async function execute( - program: string | Record, - inputs?: Record, - fetch?: Fetch, - ): Promise { - const normalized = typeof program === 'string' ? { Home: program } : program - const modules: Record = {} - for (const [name, source] of Object.entries(normalized)) { - modules[name] = dedent(source) - collected.push(source) - } - - const hooks: UserHooks = { - call: async (_m, _i, raster, execute) => { - const value = await execute() - return { ...raster, ...value } - }, - import(module) { - const src = modules[module] - invariant(src, `Failed to import module: ${module}`) - return src - }, - async request(url, opts) { - invariant(fetch, `Fetch required: ${url}`) - const res = await fetch(new Request(url, opts)) - return { - status: res.status, - headers: res.headers, - body: await res.text(), - } - }, +export async function execute( + program: string | Record, + inputs?: Inputs, + fetch?: Fetch, + willThrow = false, +): Promise { + const normalized = typeof program === 'string' ? { Home: program } : program + const modules: Record = {} + for (const [name, source] of Object.entries(normalized)) { + modules[name] = dedent(source) + if (!willThrow) { + testIdempotency(source) } - - invariant(modules.Home, 'Expected module entry source') - const ast = desugar(parse(modules.Home)) - DEBUG && printYaml(ast) - - return exec(ast, inputs, hooks) } - function testIdempotency() { - // desugar, pretty-print, then desugar & pretty-print - // once again to make sure the output is equal - return collected.flatMap(src => { - let ast1: ReturnType - try { - ast1 = parse(src) - } catch (_e) { - // some sources may not compile, e.g. error tests - return [] + const hooks: Hooks = { + import(module) { + const src = modules[module] + invariant(src, new ImportError(`Failed to import module: ${module}`)) + return src + }, + async request(url, opts) { + invariant(fetch, `Fetch required: ${url}`) + const res = await fetch(new Request(url, opts)) + return { + status: res.status, + headers: res.headers, + body: await res.text(), } - - const simplified1 = desugar(ast1) - const print1 = print(simplified1) - - const ast2 = parse(print1) - const simplified2 = desugar(ast2) - const print2 = print(simplified2) - - return { a: print1, b: print2 } - }) + }, } - return { execute, testIdempotency } + return executeModule('Home', inputs, hooks) } diff --git a/test/modules.spec.ts b/test/modules.spec.ts index 027823f..30d9495 100644 --- a/test/modules.spec.ts +++ b/test/modules.spec.ts @@ -1,8 +1,6 @@ import { describe, expect, test } from 'bun:test' -import { NullInputError } from '@getlang/utils' -import { helper } from './helpers.js' - -const { execute, testIdempotency } = helper() +import { NullInputError, UnknownInputsError } from '@getlang/utils/errors' +import { execute } from './helpers.js' describe('modules', () => { test('extract', async () => { @@ -12,11 +10,16 @@ describe('modules', () => { }) test('syntax error', () => { - const result = execute(` + const result = execute( + ` GET https://test.com extrct { title } - `) + `, + {}, + undefined, + 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 ^', ) @@ -49,6 +52,16 @@ describe('modules', () => { return expect(result).rejects.toThrow(new NullInputError('value')) }) + test('unknown input provided', () => { + const result = execute('extract `123`', { x: 1 }) + return expect(result).rejects.toThrow(new UnknownInputsError(['x'])) + }) + + test('unknown inputs provided', () => { + const result = execute('extract `123`', { x: 1, y: 2 }) + return expect(result).rejects.toThrow(new UnknownInputsError(['x', 'y'])) + }) + test('optional input', async () => { const src = ` inputs { value? } @@ -82,93 +95,6 @@ describe('modules', () => { }) }) - test('calls', async () => { - const result = await execute({ - Auth: 'extract { token: `"abc"` }', - Home: 'extract { auth: @Auth }', - }) - expect(result).toEqual({ - auth: { - token: 'abc', - }, - }) - }) - - test('links', async () => { - const modules = { - Product: ` - extract { - _module: \`'Product'\` - } - `, - Search: ` - extract { - _module: \`'Search'\` - } - `, - Home: ` - inputs { query, page? } - - GET https://search.com/ - [query] - s: $query - page: $page - - set results = => li.result -> @Product({ - @link: a - name: a - desc: p.description - }) - - extract { - items: $results - pager: .pager -> { - next: @Search) a.next - prev: @Search) a.prev - } - } - `, - } - - const result = await execute( - modules, - { query: 'gifts' }, - () => - new Response(` - - -
- -
- `), - ) - expect(result).toEqual({ - items: [ - { - _module: 'Product', - '@link': 'https://search.com/products/1', - name: 'Deck o cards', - desc: 'Casino grade playing cards', - }, - ], - pager: { - next: { - _module: 'Search', - '@link': 'https://search.com/?s=gifts&page=2', - }, - prev: { - _module: 'Search', - '@link': undefined, - }, - }, - }) - }) - test('variables', async () => { const result = await execute(` set x = \`{ test: true }\` @@ -194,10 +120,4 @@ describe('modules', () => { `) expect(result).toEqual({ test: true }) }) - - test('idempotency', () => { - for (const { a, b } of testIdempotency()) { - expect(a).toEqual(b) - } - }) }) diff --git a/test/objects.spec.ts b/test/objects.spec.ts index 4b6bcbe..01265ac 100644 --- a/test/objects.spec.ts +++ b/test/objects.spec.ts @@ -1,7 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { helper } from './helpers.js' - -const { execute, testIdempotency } = helper() +import { execute } from './helpers.js' describe('objects', () => { test('inline', async () => { @@ -85,10 +83,4 @@ describe('objects', () => { }, }) }) - - test('idempotency', () => { - for (const { a, b } of testIdempotency()) { - expect(a).toEqual(b) - } - }) }) diff --git a/test/package.json b/test/package.json index a50841d..72258fa 100644 --- a/test/package.json +++ b/test/package.json @@ -9,8 +9,6 @@ "dedent": "^1.6.0" }, "devDependencies": { - "@types/js-yaml": "^4.0.9", - "jest-diff": "^30.0.5", - "js-yaml": "^4.1.0" + "jest-diff": "^30.0.5" } } diff --git a/test/request.spec.ts b/test/request.spec.ts index 0216c0e..2debf3d 100644 --- a/test/request.spec.ts +++ b/test/request.spec.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test' -import { type Fetch, helper } from './helpers.js' - -const { execute: _exec, testIdempotency } = helper() +import type { Inputs } from '@getlang/utils' +import type { Fetch } from './helpers.js' +import { execute as _exec } from './helpers.js' const mockFetch = mock( () => @@ -12,11 +12,8 @@ const mockFetch = mock( }), ) -const execute = ( - src: string, - inputs: Record = {}, - fetch: Fetch = mockFetch, -) => _exec(src, inputs, fetch) +const execute = (src: string, inputs: Inputs = {}, fetch: Fetch = mockFetch) => + _exec(src, inputs, fetch) beforeEach(() => { mockFetch.mockClear() @@ -397,7 +394,9 @@ describe('request', () => { extract { link1: link -> @link - link2: html -> @html -> a -> @link + link2: anchor -> @html -> a -> @link + link3: attr -> @html -> i -> xpath:@data-url -> @link + link4: img -> @html -> img -> @link } `, {}, @@ -405,14 +404,18 @@ describe('request', () => { new Response( JSON.stringify({ link: '../xyz.html', - html: "", + anchor: "", + attr: "
click here
", + img: "
", }), ), ) expect(result).toEqual({ link1: 'https://base.com/a/xyz.html', - link2: 'https://base.com/from/root', + link2: 'https://base.com/from/anchor', + link3: 'https://base.com/from/attr', + link4: 'https://base.com/from/img', }) }) @@ -510,10 +513,4 @@ describe('request', () => { expect(result).toEqual('jZDE5MDBhNzczNDMzMTk4') }) }) - - test('idempotency', () => { - for (const { a, b } of testIdempotency()) { - expect(a).toEqual(b) - } - }) }) diff --git a/test/slice.spec.ts b/test/slice.spec.ts index 5bf26cc..feb551a 100644 --- a/test/slice.spec.ts +++ b/test/slice.spec.ts @@ -1,8 +1,6 @@ import { describe, expect, it, test } from 'bun:test' -import { SliceError } from '@getlang/utils' -import { helper } from './helpers.js' - -const { execute, testIdempotency } = helper() +import { SliceError } from '@getlang/utils/errors' +import { execute } from './helpers.js' describe('slice', () => { it('evaluates javascript with implicit return', async () => { @@ -133,10 +131,4 @@ describe('slice', () => { ) }) }) - - test('idempotency', () => { - for (const { a, b } of testIdempotency()) { - expect(a).toEqual(b) - } - }) }) diff --git a/test/values.spec.ts b/test/values.spec.ts index 0be3f8a..ad6af88 100644 --- a/test/values.spec.ts +++ b/test/values.spec.ts @@ -3,10 +3,8 @@ import { ConversionError, NullSelectionError, SelectorSyntaxError, -} from '@getlang/utils' -import { helper, SELSYN } from './helpers.js' - -const { execute, testIdempotency } = helper() +} from '@getlang/utils/errors' +import { execute, SELSYN } from './helpers.js' describe('values', () => { test('into JS object', async () => { @@ -460,10 +458,4 @@ describe('values', () => { expect(result).toEqual({}) }) }) - - test('idempotency', () => { - for (const { a, b } of testIdempotency()) { - expect(a).toEqual(b) - } - }) }) diff --git a/tsconfig.base.json b/tsconfig.base.json index 694fc1b..2ce7e8e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -16,6 +16,6 @@ "outDir": "${configDir}/dist", "sourceMap": true, "declaration": true, - "lib": ["es2022", "dom", "dom.iterable"] + "lib": ["esnext", "dom", "dom.iterable"] } }