diff --git a/.changeset/curly-moose-play.md b/.changeset/curly-moose-play.md new file mode 100644 index 0000000..ce6c0ec --- /dev/null +++ b/.changeset/curly-moose-play.md @@ -0,0 +1,8 @@ +--- +"@getlang/parser": minor +"@getlang/utils": minor +"@getlang/get": minor +"@getlang/lib": minor +--- + +slice dependencies analysis diff --git a/bun.lockb b/bun.lockb index fe03590..820b891 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index a02785a..c32c2c1 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,9 @@ "test" ], "devDependencies": { - "@biomejs/biome": "^2.1.4", + "@biomejs/biome": "^2.2.0", "@changesets/changelog-github": "^0.5.1", - "@changesets/cli": "^2.29.5", + "@changesets/cli": "^2.29.6", "@types/bun": "^1.2.20", "knip": "^5.62.0", "sherif": "^1.6.1", diff --git a/packages/get/src/execute.ts b/packages/get/src/execute.ts index 8fcf8ae..c8242e5 100644 --- a/packages/get/src/execute.ts +++ b/packages/get/src/execute.ts @@ -21,6 +21,7 @@ const { SliceError, UnknownInputsError, ValueReferenceError, + ValueTypeError, } = errors export async function execute( @@ -64,25 +65,13 @@ export async function execute( 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 withContext(scope, node, visit, async context => { const { slice } = node try { - const value = await hooks.slice( - slice.value, - context ? toValue(context.value, context.typeInfo) : {}, - context?.value ?? {}, - ) + const deps = context && toValue(context.value, context.typeInfo) + const value = await hooks.slice(slice.value, deps) const ret = value === undefined ? new NullSelection('') : value const optional = node.typeInfo.type === Type.Maybe @@ -94,13 +83,28 @@ export async function execute( }, }, + IdentifierExpr: { + async enter(node, visit) { + return withContext(scope, node, visit, async () => { + const id = node.id.value + const value = id ? scope.vars[id] : scope.context + invariant( + value !== undefined, + new ValueReferenceError(node.id.value), + ) + return value + }) + }, + }, + SelectorExpr: { async enter(node, visit) { return withContext(scope, node, visit, async context => { const selector = await visit(node.selector) - if (typeof selector !== 'string') { - return selector - } + invariant( + typeof selector === 'string', + new ValueTypeError('Expected selector string'), + ) const args = [context!.value, selector, node.expand] as const function select(typeInfo: TypeInfo) { diff --git a/packages/get/src/hooks.spec.ts b/packages/get/src/hooks.spec.ts index d891193..ac46c44 100644 --- a/packages/get/src/hooks.spec.ts +++ b/packages/get/src/hooks.spec.ts @@ -40,10 +40,8 @@ describe('hook', () => { test('on slice', async () => { const sliceHook = mock(() => 3) - const result = await execute('extract `1 + 2`', {}, { slice: sliceHook }) - - expect(sliceHook).toHaveBeenCalledWith('return 1 + 2', {}, {}) + expect(sliceHook).toHaveBeenCalledWith('return 1 + 2;;', undefined) expect(result).toEqual(3) }) diff --git a/packages/get/src/modules.ts b/packages/get/src/modules.ts index 5a07be8..0bb7e1a 100644 --- a/packages/get/src/modules.ts +++ b/packages/get/src/modules.ts @@ -26,27 +26,27 @@ type Entry = { 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 +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 } +} +function buildImportKey(module: string, typeInfo?: TypeInfo) { let key = module if (typeInfo) { key += `<${repr(typeInfo)}>` @@ -133,15 +133,24 @@ export class Modules { } await this.hooks.extract(module, inputs, extracted) - if (typeof extracted !== 'object' || entry.returnType.type !== Type.Value) { + function dropWarning(reason: string) { if (attrArgs.length) { const dropped = attrArgs.map(e => e[0]).join(', ') const err = [ - `Module '${module}' returned a primitive`, + `Module '${module}' ${reason}`, `dropping view attributes: ${dropped}`, ].join(', ') console.warn(err) } + } + + if (entry.returnType.type !== Type.Value) { + dropWarning(`returned ${repr(entry.returnType)}`) + return extracted + } + + if (typeof extracted !== 'object') { + dropWarning('returned a primitive') return extracted } diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index ff692c8..4ef2db5 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -5,8 +5,10 @@ export * as html from './values/html.js' export * as js from './values/js.js' export * as json from './values/json.js' -function runSlice(slice: string, context: unknown = {}, raw: unknown = {}) { - return new Function('$', '$$', slice)(context, raw) +const AsyncFunction: any = (async () => {}).constructor + +function runSlice(slice: string, context: unknown = {}) { + return new AsyncFunction('$', slice)(context) } export const slice = { runSlice } diff --git a/packages/lib/src/values/html/patch-dom.ts b/packages/lib/src/values/html/patch-dom.ts index 94821cc..2d762dd 100644 --- a/packages/lib/src/values/html/patch-dom.ts +++ b/packages/lib/src/values/html/patch-dom.ts @@ -19,12 +19,6 @@ function main() { return ds(this) } - Object.defineProperty(Element.prototype, 'outerHTML', { - get() { - return ds(this) - }, - }) - Object.defineProperty(Node.prototype, 'nodeName', { get: function () { return this.name diff --git a/packages/lib/src/values/js.ts b/packages/lib/src/values/js.ts index a935752..bb76c9b 100644 --- a/packages/lib/src/values/js.ts +++ b/packages/lib/src/values/js.ts @@ -10,7 +10,10 @@ import esquery from 'esquery' export const parse = (js: string): AnyNode => { try { - return acorn(js, { ecmaVersion: 'latest' }) + return acorn(js, { + ecmaVersion: 'latest', + allowAwaitOutsideFunction: true, + }) } catch (e) { throw new SliceSyntaxError('Could not parse slice', { cause: e }) } diff --git a/packages/parser/package.json b/packages/parser/package.json index 908d3a0..414fd00 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -43,7 +43,7 @@ "@types/moo": "^0.5.10", "@types/nearley": "^2.11.5", "acorn": "^8.15.0", - "acorn-globals": "^7.0.1", + "estree-toolkit": "^1.7.13", "globals": "^16.3.0", "lodash-es": "^4.17.21", "moo": "^0.5.2", diff --git a/packages/parser/src/ast/ast.ts b/packages/parser/src/ast/ast.ts index b925866..9c8486b 100644 --- a/packages/parser/src/ast/ast.ts +++ b/packages/parser/src/ast/ast.ts @@ -90,8 +90,10 @@ export type TemplateExpr = { type IdentifierExpr = { kind: NodeKind.IdentifierExpr - value: Token + id: Token + expand: boolean isUrlComponent: boolean + context?: Expr typeInfo: TypeInfo } @@ -228,11 +230,17 @@ const subqueryExpr = (body: Stmt[], context?: Expr): SubqueryExpr => ({ context, }) -const identifierExpr = (value: Token): IdentifierExpr => ({ +const identifierExpr = ( + id: Token, + expand = false, + context?: Expr, +): IdentifierExpr => ({ kind: NodeKind.IdentifierExpr, - value, - isUrlComponent: value.text.startsWith(':'), + id, + expand, + isUrlComponent: id.text.startsWith(':'), typeInfo: { type: Type.Value }, + context, }) const selectorExpr = ( @@ -303,20 +311,14 @@ const templateExpr = (elements: (Expr | Token)[]): TemplateExpr => ({ export const t = { program, - - // STATEMENTS assignmentStmt, declInputsStmt, inputDeclStmt, extractStmt, requestStmt, - - // EXPRESSIONS requestExpr, - identifierExpr, templateExpr, - - // CONTEXTUAL EXPRESSIONS + identifierExpr, selectorExpr, modifierExpr, moduleExpr, diff --git a/packages/parser/src/ast/print.ts b/packages/parser/src/ast/print.ts index 8e8f982..7dd41f5 100644 --- a/packages/parser/src/ast/print.ts +++ b/packages/parser/src/ast/print.ts @@ -1,4 +1,5 @@ import { builders, printer } from 'prettier/doc' +import { render } from '../utils.js' import type { InterpretVisitor } from '../visitor/visitor.js' import { visit } from '../visitor/visitor.js' import type { Node } from './ast.js' @@ -82,7 +83,6 @@ const printVisitor: InterpretVisitor = { }, ObjectLiteralExpr(node, _path, orig) { - const shorthand: Doc[] = [] const shouldBreak = orig.entries.some( e => e.value.kind === NodeKind.SelectorExpr, ) @@ -91,6 +91,15 @@ const printVisitor: InterpretVisitor = { if (!origEntry) { throw new Error('Unmatched object literal entry') } + + if (origEntry.value.kind === NodeKind.IdentifierExpr) { + const key = render(origEntry.key) + const value = origEntry.value.id.value + if (key === value || (key === '$' && value === '')) { + return entry.value + } + } + const keyGroup: Doc[] = [entry.key] if (entry.optional) { keyGroup.push('?') @@ -110,14 +119,13 @@ const printVisitor: InterpretVisitor = { shValue = shValue[0] } if (typeof shValue === 'string' && entry.key === shValue) { - shorthand[i] = [value, entry.optional ? '?' : ''] + return [value, entry.optional ? '?' : ''] } - return { ...entry, key: keyGroup, value } + return group([keyGroup, value]) }) - const inner = entries.map((e, i) => shorthand[i] || group([e.key, e.value])) const sep = ifBreak(line, [',', line]) - const obj = group(['{', indent([line, join(sep, inner)]), line, '}'], { + const obj = group(['{', indent([line, join(sep, entries)]), line, '}'], { shouldBreak, }) return node.context ? [node.context, indent([line, '-> ', obj])] : obj @@ -138,20 +146,24 @@ const printVisitor: InterpretVisitor = { throw new Error(`Unexpected template node: ${og?.kind}`) } - // strip the leading `$` character - let ret = el.slice(1) - + let id: Doc = [og.id.value] const nextEl = node.elements[i + 1] if (isToken(nextEl) && /^\w/.test(nextEl.value)) { // use ${id} syntax to delineate against next element in template - ret = ['{', ret, '}'] + id = ['{', id, '}'] } - return [og.isUrlComponent ? ':' : '$', ret] + return [og.isUrlComponent ? ':' : '$', id] }) }, IdentifierExpr(node) { - return ['$', node.value.value] + const id = node.id.value + if (!node.context) { + const arrow = node.expand ? '=> ' : '' + return [arrow, '$', id] + } + const arrow = node.expand ? '=> ' : '-> ' + return [node.context, indent([line, arrow, '$', node.id.value])] }, SelectorExpr(node) { @@ -181,7 +193,7 @@ const printVisitor: InterpretVisitor = { SliceExpr(node) { const { value } = node.slice - const quot = value.includes('`') ? '```' : '`' + const quot = value.includes('`') ? '|' : '`' const lines = value.split('\n') const slice = group([ quot, diff --git a/packages/parser/src/ast/scope.ts b/packages/parser/src/ast/scope.ts index 60ebb1a..52e9dbb 100644 --- a/packages/parser/src/ast/scope.ts +++ b/packages/parser/src/ast/scope.ts @@ -3,7 +3,7 @@ import { ValueReferenceError } from '@getlang/utils/errors' class Scope { extracted: T | undefined - private contextStack: T[] = [] + contextStack: T[] = [] constructor( public vars: Record, @@ -16,27 +16,17 @@ class Scope { 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 { - private scopeStack: Scope[] = [] + scopeStack: Scope[] = [] private get head() { return this.scopeStack.at(-1) diff --git a/packages/parser/src/grammar/lex/slice.ts b/packages/parser/src/grammar/lex/slice.ts index 3e54e5b..47d8780 100644 --- a/packages/parser/src/grammar/lex/slice.ts +++ b/packages/parser/src/grammar/lex/slice.ts @@ -2,8 +2,8 @@ import { invariant } from '@getlang/utils' import { QuerySyntaxError } from '@getlang/utils/errors' import { until } from './templates.js' -const getSliceValue = (text: string, places = 1) => { - const src = text.slice(places, -places).replace(/\\`/g, '`') +const getSliceValue = (text: string) => { + const src = text.slice(1, -1).replace(/\\`/g, '`') let lines = src.split('\n') const firstIdx = lines.findIndex(x => x.trim().length) invariant(firstIdx !== -1, new QuerySyntaxError('Slice must contain source')) @@ -15,16 +15,14 @@ const getSliceValue = (text: string, places = 1) => { return lines.join('\n').trim() } -const getSliceBlockValue = (text: string) => getSliceValue(text, 3) - export const slice_block = { defaultType: 'slice', - match: until(/```(?!`)/, { - prefix: /```/, + match: until(/\|/, { + prefix: /\|/, inclusive: true, }), lineBreaks: true, - value: getSliceBlockValue, + value: getSliceValue, pop: 1, } diff --git a/packages/parser/src/grammar/lexer.ts b/packages/parser/src/grammar/lexer.ts index 233ba78..3f03283 100644 --- a/packages/parser/src/grammar/lexer.ts +++ b/packages/parser/src/grammar/lexer.ts @@ -63,6 +63,11 @@ const expr = { match: /[{(]/, pop: 1, }, + template_interp: { + defaultType: 'ws', + match: /(?=\${)/, + next: 'template', + }, identifier_expr: { match: patterns.identifierExpr, value: (text: string) => text.slice(1), diff --git a/packages/parser/src/grammar/parse.ts b/packages/parser/src/grammar/parse.ts index 0887948..1255189 100644 --- a/packages/parser/src/grammar/parse.ts +++ b/packages/parser/src/grammar/parse.ts @@ -1,5 +1,6 @@ import { invariant } from '@getlang/utils' import { QuerySyntaxError } from '@getlang/utils/errors' +import type { CExpr, Expr, TemplateExpr } from '../ast/ast.js' import { isToken, NodeKind, t } from '../ast/ast.js' import { tx } from '../utils.js' @@ -106,7 +107,7 @@ export const object: PP = d => { export const objectEntry: PP = ([callkey, identifier, optional, , , value]) => { const key = { ...identifier, - value: `${callkey ? '@' : ''}${identifier.value}`, + value: `${callkey ? '@' : ''}${identifier.value || '$'}`, } return { key: t.templateExpr([key]), @@ -126,32 +127,24 @@ export const objectEntryShorthandIdent: PP = ([identifier, optional]) => { return objectEntry([null, identifier, optional, null, null, value]) } -const expandingSelectors = [NodeKind.TemplateExpr, NodeKind.IdentifierExpr] -export const drill: PP = ([context, , arrow, , bit]) => { - const expand = arrow.value.startsWith('=') - if (expandingSelectors.includes(bit.kind)) { - return t.selectorExpr(bit, expand, context) +function drillBase(bit: CExpr | TemplateExpr, arrow?: string, context?: Expr) { + const expand = arrow === '=>' + if (bit.kind === NodeKind.TemplateExpr) { + bit = t.selectorExpr(bit, expand) + } else if (bit.kind === NodeKind.IdentifierExpr) { + bit.expand = expand + } else if (expand) { + throw new QuerySyntaxError('Wide arrow drill requires selector on RHS') } - invariant( - !expand, - new QuerySyntaxError('Wide arrow drill requires selector on RHS'), - ) - invariant('context' in bit, new QuerySyntaxError('Invalid drill value')) bit.context = context return bit } -export const drillContext: PP = ([arrow, expr]) => { - const expand = arrow?.[0].value === '=>' - if (expr.kind === NodeKind.TemplateExpr) { - return t.selectorExpr(expr, expand) - } - invariant( - !expand, - new QuerySyntaxError('Wide arrow drill requires selector on RHS'), - ) - return expr -} +export const drill: PP = ([context, , arrow, , bit]) => + drillBase(bit, arrow.value, context) + +export const drillContext: PP = ([arrow, bit]) => + drillBase(bit, arrow?.[0].value) export const identifier: PP = ([id]) => { return t.identifierExpr(id) diff --git a/packages/parser/src/passes/analyze.ts b/packages/parser/src/passes/analyze.ts index b78b697..3bac519 100644 --- a/packages/parser/src/passes/analyze.ts +++ b/packages/parser/src/passes/analyze.ts @@ -23,6 +23,12 @@ export function analyze(ast: Program) { inputs.add(node.id.value) return trace.InputDeclStmt(node) }, + SelectorExpr: { + enter(node, visit) { + checkMacro(node) + return trace.SelectorExpr.enter(node, visit) + }, + }, ModifierExpr: { enter(node, visit) { checkMacro(node) @@ -35,12 +41,6 @@ export function analyze(ast: Program) { return trace.ModuleExpr.enter(node, visit) }, }, - SelectorExpr: { - enter(node, visit) { - checkMacro(node) - return trace.SelectorExpr.enter(node, visit) - }, - }, } visit(ast, visitor) diff --git a/packages/parser/src/passes/desugar/acorn-globals.d.ts b/packages/parser/src/passes/desugar/acorn-globals.d.ts deleted file mode 100644 index c78e831..0000000 --- a/packages/parser/src/passes/desugar/acorn-globals.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module 'acorn-globals' { - import type { Program } from 'acorn' - type Ref = { name: string } - function detect(source: Program): Ref[] - export default detect -} diff --git a/packages/parser/src/passes/desugar/links.ts b/packages/parser/src/passes/desugar/links.ts index 6925589..2024357 100644 --- a/packages/parser/src/passes/desugar/links.ts +++ b/packages/parser/src/passes/desugar/links.ts @@ -18,12 +18,15 @@ export const settleLinks: DesugarPass = ({ parsers }) => { return { ...trace, - IdentifierExpr(node) { - const id = node.value.value - const value = scope.vars[id] - invariant(value, new ValueReferenceError(id)) - inherit(value, node) - return node + IdentifierExpr: { + enter(node, visit) { + const id = node.id.value + const xnode = trace.IdentifierExpr.enter(node, visit) + const value = id ? scope.vars[id] : scope.context + invariant(value, new ValueReferenceError(id)) + inherit(value, xnode) + return xnode + }, }, SelectorExpr: { diff --git a/packages/parser/src/passes/desugar/reqparse.ts b/packages/parser/src/passes/desugar/reqparse.ts index 146051a..e535e6e 100644 --- a/packages/parser/src/passes/desugar/reqparse.ts +++ b/packages/parser/src/passes/desugar/reqparse.ts @@ -11,7 +11,7 @@ export class RequestParsers { private parsers: Parsers[] = [] private require(req: RequestExpr) { - const idx = this.requests.findIndex(r => r === req) + const idx = this.requests.indexOf(req) invariant(idx !== -1, new QuerySyntaxError('Unmapped request')) return idx } @@ -21,7 +21,7 @@ export class RequestParsers { } visit(req: RequestExpr) { - let idx = this.requests.findIndex(r => r === req) + let idx = this.requests.indexOf(req) if (idx === -1) { idx = this.requests.length this.requests.push(req) diff --git a/packages/parser/src/passes/desugar/slicedeps.ts b/packages/parser/src/passes/desugar/slicedeps.ts index 973620a..b87b147 100644 --- a/packages/parser/src/passes/desugar/slicedeps.ts +++ b/packages/parser/src/passes/desugar/slicedeps.ts @@ -1,14 +1,10 @@ -/// - 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 { parse as acorn } from 'acorn' +import { traverse } from 'estree-toolkit' import globals from 'globals' -import type { Expr } from '../../ast/ast.js' -import { t } from '../../ast/ast.js' -import { tx } from '../../utils.js' +import { NodeKind, t } from '../../ast/ast.js' +import { render, tx } from '../../utils.js' import type { DesugarPass } from '../desugar.js' const browserGlobals = [ @@ -16,66 +12,90 @@ const browserGlobals = [ ...Object.keys(globals.builtin), ] -const analyzeSlice = (_source: string, analyzeDeps: boolean) => { - let ast: Program +function parse(source: string) { try { - ast = parse(_source, { + return acorn(source, { ecmaVersion: 'latest', allowReturnOutsideFunction: true, + allowAwaitOutsideFunction: true, }) } catch (e) { throw new SliceSyntaxError('Could not parse slice', { cause: e }) } +} - let source = _source +const validAutoInserts = ['ExpressionStatement', 'BlockStatement'] - // auto-insert the return statement - if (ast.body.length === 1 && ast.body[0]?.type !== 'ReturnStatement') { - source = `return ${source}` - } +const analyzeSlice = (slice: string) => { + let source = slice - const deps: string[] = [] - if (analyzeDeps) { - for (const dep of detect(ast).map(id => id.name)) { - if (!browserGlobals.includes(dep)) { - deps.push(dep) - } - } + const ast = parse(slice) + if (ast.body.at(-1)?.type === 'EmptyStatement') { + return null } - const usesContext = deps.some(d => ['$', '$$'].includes(d)) - const usesVars = deps.some(d => !['$', '$$'].includes(d)) + const init = ast.body[0] + invariant(init, new SliceSyntaxError('Empty slice body')) + if (ast.body.length === 1 && init.type !== 'ReturnStatement') { + // auto-insert the return statement + invariant( + validAutoInserts.includes(init.type), + new SliceSyntaxError(`Invalid slice body: ${init.type}`), + ) + source = `return ${source}` + } - invariant( - !(usesContext && usesVars), - new SliceSyntaxError('Slice must not use context ($) and outer variables'), - ) + let ids: string[] = [] + traverse(ast, { + $: { scope: true }, + Program(path) { + ids = Object.keys(path.scope?.globalBindings ?? {}) + }, + }) + ids = ids.filter(id => !browserGlobals.includes(id)) + const usesVars = ids.some(d => d !== '$') + const deps = new Set(ids) if (usesVars) { - const contextVars = deps.join(', ') - const loadContext = `const { ${contextVars} } = $\n` - source = loadContext + source + const names = [...deps].join(', ') + source = `var { ${names} } = $\n${source}` } - return { source, deps, usesContext } + // add postmark to prevent slice from being re-processed + source = `${source};;` + return { source, deps, usesVars } } export const insertSliceDeps: DesugarPass = () => { return { SliceExpr(node) { - const stat = analyzeSlice(node.slice.value, !node.context) - const slice = tx.token(stat.source) - let context: Expr | undefined = node.context - if (!node.context) { - if (stat.usesContext) { - context = tx.ident('') - } else if (stat.deps.length) { - const deps = stat.deps.map(id => - t.objectEntry(tx.template(id), tx.ident(id)), - ) - context = t.objectLiteralExpr(deps) + const stat = analyzeSlice(node.slice.value) + if (!stat) { + return node + } + + const { source, deps, usesVars } = stat + const slice = tx.token(source) + let context = node.context + + if (usesVars) { + if (context?.kind !== NodeKind.ObjectLiteralExpr) { + context = t.objectLiteralExpr([], context) + } + const keys = new Set(context.entries.map(e => render(e.key))) + const missing = deps.difference(keys) + for (const dep of missing) { + const id = tx.token(dep, dep === '$' ? '' : dep) + context.entries.push({ + key: tx.template(dep), + value: t.identifierExpr(id), + optional: false, + }) } + } else if (deps.size === 1 && !context) { + context = t.identifierExpr(tx.token('$', ''), false, context) } + return { ...node, slice, context } }, } diff --git a/packages/parser/src/passes/inference/calls.ts b/packages/parser/src/passes/inference/calls.ts index 1814127..cd2eca9 100644 --- a/packages/parser/src/passes/inference/calls.ts +++ b/packages/parser/src/passes/inference/calls.ts @@ -11,8 +11,16 @@ export function registerCalls(ast: Program, macros: string[] = []) { function registerCall(node?: Expr) { switch (node?.kind) { case NodeKind.IdentifierExpr: { - const value = scope.vars[node.value.value] - return registerCall(value) + const id = node.id.value + if (id) { + return registerCall(scope.vars[id]) + } + const ctxs = scope.scopeStack.flatMap(s => s.contextStack) + return registerCall( + ctxs.findLast( + c => c.kind !== NodeKind.IdentifierExpr || c.id.value !== '', + ), + ) } case NodeKind.SubqueryExpr: { const ex = node.body.find(s => s.kind === NodeKind.ExtractStmt) diff --git a/packages/parser/src/passes/inference/typeinfo.ts b/packages/parser/src/passes/inference/typeinfo.ts index a9031a0..321d4e2 100644 --- a/packages/parser/src/passes/inference/typeinfo.ts +++ b/packages/parser/src/passes/inference/typeinfo.ts @@ -134,11 +134,18 @@ export function resolveTypes(ast: Program, options: ResolveTypeOptions) { }, }, - IdentifierExpr(node) { - const id = node.value.value - const value = scope.vars[id] - invariant(value, new ValueReferenceError(node.value.value)) - return { ...node, typeInfo: structuredClone(value.typeInfo) } + IdentifierExpr: { + enter: withContext((node, visit) => { + const id = node.id.value + const xnode = trace.IdentifierExpr.enter(node, visit) + const value = id ? scope.vars[id] : xnode.context || scope.context + invariant(value, new ValueReferenceError(id)) + let typeInfo = structuredClone(value.typeInfo) + if (xnode.expand) { + typeInfo = { type: Type.List, of: typeInfo } + } + return { ...xnode, typeInfo } + }), }, RequestExpr(node) { diff --git a/packages/parser/src/passes/trace.ts b/packages/parser/src/passes/trace.ts index 43780a6..1148610 100644 --- a/packages/parser/src/passes/trace.ts +++ b/packages/parser/src/passes/trace.ts @@ -104,9 +104,15 @@ export function traceVisitor(contextType: TypeInfo = { type: Type.Context }) { }, }, + // simple contextual expressions + IdentifierExpr: { + enter(node, visit) { + return withContext(node, visit, node => node) + }, + }, + SliceExpr: { enter(node, visit) { - // contains no additional expressions (only .context) return withContext(node, visit, node => node) }, }, diff --git a/packages/utils/src/hooks.ts b/packages/utils/src/hooks.ts index 24628fd..c5fe4f7 100644 --- a/packages/utils/src/hooks.ts +++ b/packages/utils/src/hooks.ts @@ -13,7 +13,6 @@ export type RequestHook = ( export type SliceHook = ( slice: string, context?: unknown, - raw?: unknown, ) => MaybePromise export type ExtractHook = ( diff --git a/test/slice.spec.ts b/test/slice.spec.ts index feb551a..b9136d0 100644 --- a/test/slice.spec.ts +++ b/test/slice.spec.ts @@ -57,13 +57,19 @@ describe('slice', () => { ` set html = \`'

title

para 1

para 2

'\` - extract $html -> @html => h1, p -> ( - set raw = \`$$.outerHTML\` - extract $raw - ) + extract $html -> @html => h1, p -> \`$\` `, ) - expect(result).toEqual(['

title

', '

para 1

', '

para 2

']) + expect(result).toEqual(['title', 'para 1', 'para 2']) + }) + + it('can reference both context and outer variables', async () => { + const result = await execute(` + set obj = \`{key:"x"}\` + set html = \`"
keyval
"\` -> @html + extract $html -> span -> \`obj[$]\` + `) + expect(result).toEqual('x') }) it('supports escaped backticks', async () => { @@ -71,8 +77,8 @@ describe('slice', () => { expect(result).toEqual('escaped') }) - it('triple backticks as delimiter allow non-escaped backticks', async () => { - const result = await execute('extract ```return `escaped````') + it('blocks allow non-escaped backticks', async () => { + const result = await execute('extract |return `escaped`|') expect(result).toEqual('escaped') }) @@ -84,14 +90,6 @@ describe('slice', () => { expect(result).toEqual('one') }) - it('provides a variable for raw context ($$)', async () => { - const result = await execute(` - set html = \`'
  • one
  • two
'\` - extract $html -> @html -> ul -> \`$$.outerHTML\` - `) - expect(result).toEqual('
  • one
  • two
') - }) - it('operates on list item context', async () => { const result = await execute(` set html = \`'
  • one
  • two
'\` diff --git a/test/values.spec.ts b/test/values.spec.ts index ad6af88..0d2d079 100644 --- a/test/values.spec.ts +++ b/test/values.spec.ts @@ -347,7 +347,7 @@ describe('values', () => { /* eslint-enable prefer-template */ const src = ` - set all = \`\`\`(${slice.toString()})()\`\`\` + set all = |(${slice.toString()})()| extract $all -> @json -> docHtml -> @html -> pre