@@ -3,14 +3,21 @@ import {tryConstantEvaluate} from './evaluator'
33import { type GroqFunctionArity , namespaces , pipeFunctions } from './evaluator/functions'
44import { MarkProcessor , type MarkVisitor } from './markProcessor'
55import {
6+ type ArrayCoerceNode ,
67 type ArrayElementNode ,
8+ type DerefNode ,
79 type ExprNode ,
810 type FuncCallNode ,
11+ type FunctionDeclarationNode ,
12+ type InlineFuncCallNode ,
913 isSelectorNested ,
14+ type MapNode ,
1015 type ObjectAttributeNode ,
1116 type ObjectSplatNode ,
1217 type OpCall ,
18+ type ParameterNode ,
1319 type ParentNode ,
20+ type ProjectionNode ,
1421 type SelectNode ,
1522 type SelectorNode ,
1623 walk ,
@@ -50,9 +57,14 @@ class GroqQueryError extends Error {
5057 public override name = 'GroqQueryError'
5158}
5259
60+ type FunctionId = `${string } ::${string } `
61+ type CustomFunctions = Record < FunctionId , FunctionDeclarationNode >
62+
5363function createExpressionBuilder ( ) : {
5464 exprBuilder : MarkVisitor < ExprNode >
65+ customFunctions : CustomFunctions
5566} {
67+ const customFunctions : CustomFunctions = { }
5668 const exprBuilder : MarkVisitor < ExprNode > = {
5769 group ( p ) {
5870 const inner = p . process ( exprBuilder )
@@ -522,6 +534,37 @@ function createExpressionBuilder(): {
522534 name,
523535 }
524536 } ,
537+
538+ func_decl ( p ) {
539+ const namespace = p . processString ( )
540+ const name = p . processString ( )
541+ const params : ParameterNode [ ] = [ ]
542+ while ( p . getMark ( ) . name !== 'func_params_end' ) {
543+ const param = p . process ( exprBuilder )
544+ if ( param . type !== 'Parameter' ) throw new Error ( 'expected parameter' )
545+ params . push ( param )
546+ }
547+
548+ if ( params . length !== 1 ) {
549+ throw new GroqQueryError ( 'Custom functions can only have one parameter' )
550+ }
551+
552+ p . shift ( ) // func_params_end
553+
554+ const body = p . process ( exprBuilder )
555+
556+ const decl = {
557+ type : 'FuncDeclaration' ,
558+ namespace,
559+ name,
560+ params,
561+ body,
562+ } satisfies FunctionDeclarationNode
563+
564+ customFunctions [ `${ namespace } ::${ name } ` ] = decl
565+
566+ return p . process ( exprBuilder )
567+ } ,
525568 }
526569
527570 const OBJECT_BUILDER : MarkVisitor < ObjectAttributeNode > = {
@@ -860,7 +903,7 @@ function createExpressionBuilder(): {
860903 } ,
861904 }
862905
863- return { exprBuilder}
906+ return { exprBuilder, customFunctions }
864907}
865908
866909function extractPropertyKey ( node : ExprNode ) : string {
@@ -899,6 +942,71 @@ function validateArity(name: string, arity: GroqFunctionArity, count: number) {
899942 }
900943}
901944
945+ /**
946+ * The function body is one of the forms:
947+ * - $param{…}
948+ * - $param->{…}
949+ * - $param[]{…}
950+ * - $param[]->{…}
951+ *
952+ * https://github.com/sanity-io/go-groq/blob/b7fb57f5aefe080becff9e3522c0b7b52a79ffd0/parser/internal/parserv2/parser.go#L975-L981
953+ */
954+ function resolveFunctionParameter (
955+ parameter : ParameterNode ,
956+ funcDeclaration : FunctionDeclarationNode ,
957+ funcCall : InlineFuncCallNode ,
958+ ) {
959+ const index = funcDeclaration . params . findIndex ( ( p ) => p . name === parameter . name )
960+ if ( index === - 1 ) {
961+ throw new GroqQueryError ( `Missing argument for parameter ${ parameter . name } in function call` )
962+ }
963+ return funcCall . args [ index ]
964+ }
965+ function replaceCustomFunctionBody (
966+ funcDeclaration : FunctionDeclarationNode ,
967+ funcCall : InlineFuncCallNode ,
968+ ) : ExprNode {
969+ const { body} = funcDeclaration
970+
971+ if ( body . type === 'Projection' ) {
972+ if ( body . base . type === 'Parameter' ) {
973+ return {
974+ type : 'Projection' ,
975+ base : resolveFunctionParameter ( body . base , funcDeclaration , funcCall ) ,
976+ expr : body . expr ,
977+ }
978+ }
979+
980+ if ( body . base . type === 'Deref' ) {
981+ if ( body . base . base . type === 'Parameter' ) {
982+ return {
983+ type : 'Projection' ,
984+ base : {
985+ type : 'Deref' ,
986+ base : resolveFunctionParameter ( body . base . base , funcDeclaration , funcCall ) ,
987+ } ,
988+ expr : body . expr ,
989+ }
990+ }
991+ }
992+ }
993+
994+ if ( body . type === 'Map' && body . base . type === 'ArrayCoerce' ) {
995+ if ( body . base . base . type === 'Parameter' ) {
996+ return {
997+ type : 'Map' ,
998+ base : {
999+ type : 'ArrayCoerce' ,
1000+ base : resolveFunctionParameter ( body . base . base , funcDeclaration , funcCall ) ,
1001+ } ,
1002+ expr : body . expr ,
1003+ }
1004+ }
1005+ }
1006+
1007+ throw new GroqQueryError ( `Unexpected function body, must be a projection. Got "${ body . type } "` )
1008+ }
1009+
9021010function argumentShouldBeSelector ( namespace : string , functionName : string , argCount : number ) {
9031011 const functionsRequiringSelectors = [ 'changedAny' , 'changedOnly' ]
9041012
@@ -924,20 +1032,41 @@ export function parse(input: string, options: ParseOptions = {}): ExprNode {
9241032 throw new GroqSyntaxError ( result . position , result . message )
9251033 }
9261034 const processor = new MarkProcessor ( input , result . marks , options )
927- const { exprBuilder} = createExpressionBuilder ( )
1035+ const { exprBuilder, customFunctions } = createExpressionBuilder ( )
9281036 const procssed = processor . process ( exprBuilder )
929- const replaceInlineFuncCalls = createReplaceInlineFuncCalls ( options )
930- return walk ( procssed , replaceInlineFuncCalls )
1037+ const replaceInlineFuncCalls = createReplaceInlineFuncCalls ( options , customFunctions )
1038+ return walk ( procssed , ( node ) => replaceInlineFuncCalls ( node ) )
9311039}
9321040
933- function createReplaceInlineFuncCalls ( options : ParseOptions ) {
934- const replacer = ( node : ExprNode ) : ExprNode => {
1041+ function createReplaceInlineFuncCalls (
1042+ options : ParseOptions ,
1043+ customFunctions : Record < string , FunctionDeclarationNode > ,
1044+ ) {
1045+ const replacer = (
1046+ node : ExprNode ,
1047+ recurssion : Set < FunctionId > = new Set < FunctionId > ( ) ,
1048+ ) : ExprNode => {
9351049 if ( node . type !== 'InlineFuncCall' ) {
9361050 return node
9371051 }
9381052
9391053 const { namespace, name} = node
9401054
1055+ const functionId : FunctionId = `${ namespace } ::${ name } `
1056+ if ( recurssion . has ( functionId ) ) {
1057+ throw new GroqQueryError ( `Recursion detected in function ${ name } ` )
1058+ }
1059+
1060+ // Check for custom function first
1061+ const customFunction = customFunctions [ functionId ]
1062+ if ( customFunction ) {
1063+ validateArity ( name , customFunction . params . length , node . args . length )
1064+
1065+ return walk ( replaceCustomFunctionBody ( customFunction , node ) , ( node ) =>
1066+ replacer ( node , new Set ( [ ...recurssion , functionId ] ) ) ,
1067+ )
1068+ }
1069+
9411070 const funcs = namespaces [ namespace ]
9421071 if ( ! funcs ) {
9431072 throw new GroqQueryError ( `Undefined namespace: ${ namespace } ` )
0 commit comments