Refactor widgets to handle dynamic config in input (#8624)

* Merged ArgumentAst and ArgumentPlaceholder into single class.
* Created `AnyWidget` input being a "general use" widget input. Most wigets try to match with it; the `Argument` input is now solely for WidgetArgument(Name) or those handling arguments in a specific way (like selector which want to show on arg name click).
* dynamic config is now part of widget input, and is properly propagated through vector editor/function widgets.

# Important Notes
The widgets still does not work perfectly:
* The chosen options often don't have argument placeholders - that's because we don't display them for constructors. Needs to be added on our side, or engine should send us methodCall info for constructors.
* There are issues with engine's messages sent to us. This makes widgets does not set up (so there is no drop-down, or vector adds `_` instead of default). I'm investigating them and going to fill issues.
This commit is contained in:
Adam Obuchowicz 2024-01-03 14:37:52 +01:00 committed by GitHub
parent cfab344fbe
commit 2e7d71d459
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 684 additions and 447 deletions

View File

@ -7,7 +7,7 @@ export interface SuggestionEntryArgument {
/** The argument name. */
name: string
/** The argument type. String 'Any' is used to specify generic types. */
type: string
reprType: string
/** Indicates whether the argument is lazy. */
isSuspended: boolean
/** Indicates whether the argument has default value. */

View File

@ -9,7 +9,7 @@ import { computed, onMounted } from 'vue'
const props = defineProps<{
config: ApplicationConfig
accessToken: string
accessToken: string | null
metadata: object
unrecognizedOptions: string[]
}>()

View File

@ -30,10 +30,11 @@ import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import * as set from 'lib0/set'
import type { ExprId, NodeMetadata } from 'shared/yjsModel'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue3-toastify'
import { computed, onMounted, onScopeDispose, ref, watch } from 'vue'
import { toast, type Id as ToastId } from 'vue3-toastify'
import { type Usage } from './ComponentBrowser/input'
const STARTUP_TOAST_DELAY_MS = 100
const EXECUTION_MODES = ['design', 'live']
// Assumed size of a newly created node. This is used to place the component browser.
const DEFAULT_NODE_SIZE = new Vec2(0, 24)
@ -52,19 +53,13 @@ const suggestionDb = useSuggestionDbStore()
const interaction = provideInteractionHandler()
function initStartupToast() {
const startupToast = toast.info('Initializing the project. This can take up to one minute.', {
let startupToast = toast.info('Initializing the project. This can take up to one minute.', {
autoClose: false,
})
projectStore.firstExecution.then(() => {
if (startupToast != null) {
toast.remove(startupToast)
}
})
onUnmounted(() => {
if (startupToast != null) {
toast.remove(startupToast)
}
})
const removeToast = () => toast.remove(startupToast)
projectStore.firstExecution.then(removeToast)
onScopeDispose(removeToast)
}
onMounted(() => {
@ -548,14 +543,6 @@ function handleEdgeDrop(source: ExprId, position: Vec2) {
@dragover.prevent
@drop.prevent="handleFileDrop($event)"
>
<ToastContainer
position="top-center"
theme="light"
closeOnClick="false"
draggable="false"
toastClassName="text-sm leading-170 bg-frame-selected rounded-2xl backdrop-blur-3xl"
transition="Vue-Toastification__bounce"
/>
<div :style="{ transform: graphNavigator.transform }" class="htmlLayer">
<GraphNodes
@nodeOutputPortDoubleClick="handleNodeOutputPortDoubleClick"

View File

@ -1,7 +1,6 @@
<script setup lang="ts">
import type { PortId } from '@/providers/portInfo'
import { injectWidgetRegistry, type WidgetInput } from '@/providers/widgetRegistry'
import type { WidgetConfiguration } from '@/providers/widgetRegistry/configuration'
import { injectWidgetTree } from '@/providers/widgetTree'
import {
injectWidgetUsageInfo,
@ -14,7 +13,6 @@ import { computed, proxyRefs } from 'vue'
const props = defineProps<{
input: WidgetInput
nest?: boolean
dynamicConfig?: WidgetConfiguration | undefined
/**
* A function that intercepts and handles a value update emitted by this widget. When it returns
* `false`, the update continues to be propagated to the parent widget. When it returns `true`,
@ -43,7 +41,6 @@ const selectedWidget = computed(() => {
return registry.select(
{
input: props.input,
config: props.dynamicConfig ?? undefined,
nesting: nesting.value,
},
sameInputParentWidgets.value,
@ -95,7 +92,6 @@ const spanStart = computed(() => {
v-if="selectedWidget"
ref="rootNode"
:input="props.input"
:config="dynamicConfig"
:nesting="nesting"
:data-span-start="spanStart"
@update="updateHandler"

View File

@ -2,6 +2,7 @@
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import { useTransitioning } from '@/composables/animation'
import { ForcePort, type PortId } from '@/providers/portInfo'
import { AnyWidget } from '@/providers/widgetRegistry'
import { provideWidgetTree } from '@/providers/widgetTree'
import { useGraphStore } from '@/stores/graph'
import { Ast } from '@/util/ast'
@ -11,9 +12,10 @@ import { computed, toRef } from 'vue'
const props = defineProps<{ ast: Ast.Ast }>()
const graph = useGraphStore()
const rootPort = computed(() => {
const input = AnyWidget.Ast(props.ast)
return props.ast instanceof Ast.Ident && !graph.db.isKnownFunctionCall(props.ast.exprId)
? new ForcePort(props.ast)
: props.ast
? new ForcePort(input)
: input
})
const observedLayoutTransitions = new Set([

View File

@ -1,14 +1,18 @@
<script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import { ForcePort } from '@/providers/portInfo'
import { defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { AnyWidget, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { Ast } from '@/util/ast'
import { ArgumentApplication } from '@/util/callTree'
import { ArgumentApplication, ArgumentAst, ArgumentPlaceholder } from '@/util/callTree'
import { computed } from 'vue'
const props = defineProps(widgetProps(widgetDefinition))
const targetMaybePort = computed(() =>
props.input.target instanceof Ast.Ast ? new ForcePort(props.input.target) : props.input.target,
props.input.target instanceof ArgumentPlaceholder || props.input.target instanceof ArgumentAst
? new ForcePort(props.input.target.toAnyWidget())
: props.input.target instanceof Ast.Ast
? AnyWidget.Ast(props.input.target)
: props.input.target,
)
const appClass = computed(() => {
@ -39,7 +43,7 @@ export const widgetDefinition = defineWidget(ArgumentApplication, {
<div v-if="props.input.infixOperator" class="infixOp" :style="operatorStyle">
<NodeWidget :input="props.input.infixOperator" />
</div>
<NodeWidget :input="props.input.argument" :dynamicConfig="props.config" />
<NodeWidget :input="props.input.argument" />
</span>
</template>

View File

@ -13,5 +13,5 @@ export const widgetDefinition = defineWidget(ArgumentAst, {
</script>
<template>
<NodeWidget :input="props.input.ast" nest />
<NodeWidget :input="props.input.toAnyWidget()" nest />
</template>

View File

@ -22,24 +22,23 @@ const primary = computed(() => props.nesting < 2)
</script>
<script lang="ts">
export const widgetDefinition = defineWidget([ArgumentPlaceholder, ArgumentAst], {
export const widgetDefinition = defineWidget([ArgumentAst.matchWithArgInfo, ArgumentPlaceholder], {
priority: 1000,
score: (props) =>
props.input.info != null &&
(props.input instanceof ArgumentPlaceholder ||
(props.nesting < 2 && props.input.kind === ApplicationKind.Prefix))
? Score.Perfect
: Score.Mismatch,
score: (props) => {
const isPlaceholder = props.input instanceof ArgumentPlaceholder
const isTopArg = props.nesting < 2 && props.input.kind === ApplicationKind.Prefix
return isPlaceholder || isTopArg ? Score.Perfect : Score.Mismatch
},
})
</script>
<template>
<div class="WidgetArgumentName" :class="{ placeholder, primary }">
<template v-if="showArgumentValue">
<span class="value">{{ props.input.info!.name }}</span
<span class="value">{{ props.input.argInfo.name }}</span
><NodeWidget :input="props.input" />
</template>
<template v-else>{{ props.input.info!.name }}</template>
<template v-else>{{ props.input.argInfo.name }}</template>
</div>
</template>

View File

@ -1,14 +1,17 @@
<script setup lang="ts">
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { AnyWidget, Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { Ast } from '@/util/ast'
const _props = defineProps(widgetProps(widgetDefinition))
</script>
<script lang="ts">
export const widgetDefinition = defineWidget(Ast.Wildcard, {
priority: 10,
score: Score.Good,
})
export const widgetDefinition = defineWidget(
(input) => input instanceof AnyWidget && input.ast instanceof Ast.Wildcard,
{
priority: 10,
score: Score.Good,
},
)
</script>
<template>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import CheckboxWidget from '@/components/widgets/CheckboxWidget.vue'
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { AnyWidget, Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { Ast } from '@/util/ast'
import { computed } from 'vue'
@ -8,10 +8,11 @@ const props = defineProps(widgetProps(widgetDefinition))
const value = computed({
get() {
return props.input.code().endsWith('True') ?? false
return props.input.ast?.code().endsWith('True') ?? false
},
set(value) {
const node = getRawBoolNode(props.input)
if (props.input.ast == null) return // TODO[ao] set value on placeholder here.
const node = getRawBoolNode(props.input.ast)
if (node != null) {
props.onUpdate(value ? 'True' : 'False', node.exprId)
}
@ -29,18 +30,15 @@ function getRawBoolNode(ast: Ast.Ast) {
return null
}
export const widgetDefinition = defineWidget(
(input) => input instanceof Ast.PropertyAccess || input instanceof Ast.Ident,
{
priority: 10,
score: (props) => {
if (getRawBoolNode(props.input) != null) {
return Score.Perfect
}
return Score.Mismatch
},
export const widgetDefinition = defineWidget(AnyWidget, {
priority: 10,
score: (props) => {
if (props.input.ast == null)
return props.input.argInfo?.reprType === 'Standard.Base.Bool' ? Score.Good : Score.Mismatch
if (getRawBoolNode(props.input.ast) != null) return Score.Perfect
return Score.Mismatch
},
)
})
</script>
<template>

View File

@ -2,13 +2,22 @@
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import { injectFunctionInfo, provideFunctionInfo } from '@/providers/functionInfo'
import type { PortId } from '@/providers/portInfo'
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { widgetConfigurationSchema } from '@/providers/widgetRegistry/configuration'
import { AnyWidget, Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import {
argsWidgetConfigurationSchema,
functionCallConfiguration,
} from '@/providers/widgetRegistry/configuration'
import { useGraphStore } from '@/stores/graph'
import { useProjectStore, type NodeVisualizationConfiguration } from '@/stores/project'
import { assert, assertUnreachable } from '@/util/assert'
import { assert } from '@/util/assert'
import { Ast } from '@/util/ast'
import { ArgumentApplication, ArgumentAst, ArgumentPlaceholder } from '@/util/callTree'
import {
ArgumentApplication,
ArgumentAst,
ArgumentPlaceholder,
getAccessOprSubject,
interpretCall,
} from '@/util/callTree'
import type { Opt } from '@/util/data/opt'
import type { ExprId } from 'shared/yjsModel'
import { computed, proxyRefs } from 'vue'
@ -19,17 +28,16 @@ const project = useProjectStore()
provideFunctionInfo(
proxyRefs({
callId: computed(() => props.input.exprId),
callId: computed(() => props.input.ast.exprId),
}),
)
const methodCallInfo = computed(() => {
const input: Ast.Ast = props.input
return graph.db.getMethodCallInfo(input.exprId)
return graph.db.getMethodCallInfo(props.input.ast.exprId)
})
const interpreted = computed(() => {
return ArgumentApplication.Interpret(props.input, methodCallInfo.value == null)
return interpretCall(props.input.ast, methodCallInfo.value == null)
})
const application = computed(() => {
@ -38,13 +46,19 @@ const application = computed(() => {
const noArgsCall = call.kind === 'prefix' ? graph.db.getMethodCall(call.func.exprId) : undefined
const info = methodCallInfo.value
return ArgumentApplication.FromInterpretedWithInfo(
const application = ArgumentApplication.FromInterpretedWithInfo(
call,
noArgsCall,
info?.methodCall,
info?.suggestion,
{
noArgsCall,
appMethodCall: info?.methodCall,
suggestion: info?.suggestion,
widgetCfg: widgetConfiguration.value,
},
!info?.staticallyApplied,
)
return application instanceof ArgumentApplication
? application
: AnyWidget.Ast(application, props.input.dynamicConfig, props.input.argInfo)
})
const escapeString = (str: string): string => {
@ -53,19 +67,26 @@ const escapeString = (str: string): string => {
}
const makeArgsList = (args: string[]) => '[' + args.map(escapeString).join(', ') + ']'
const selfArgumentExprId = computed<Opt<ExprId>>(() => {
const analyzed = ArgumentApplication.Interpret(props.input, true)
const selfArgumentAstId = computed<Opt<ExprId>>(() => {
const analyzed = interpretCall(props.input.ast, true)
if (analyzed.kind === 'infix') {
return analyzed.lhs?.exprId
} else {
return analyzed.args[0]?.argument.exprId
const knownArguments = methodCallInfo.value?.suggestion?.arguments
const selfArgument =
knownArguments?.[0]?.name === 'self'
? getAccessOprSubject(analyzed.func)
: analyzed.args[0]?.argument
return selfArgument?.exprId
}
})
const visualizationConfig = computed<Opt<NodeVisualizationConfiguration>>(() => {
const tree = props.input
const expressionId = selfArgumentExprId.value
const astId = tree.exprId
// If we inherit dynamic config, there is no point in attaching visualization.
if (props.input.dynamicConfig) return null
const expressionId = selfArgumentAstId.value
const astId = props.input.ast.exprId
if (astId == null || expressionId == null) return null
const info = graph.db.getMethodCallInfo(astId)
if (!info) return null
@ -86,11 +107,12 @@ const visualizationConfig = computed<Opt<NodeVisualizationConfiguration>>(() =>
const visualizationData = project.useVisualizationData(visualizationConfig)
const widgetConfiguration = computed(() => {
if (props.input.dynamicConfig?.kind === 'FunctionCall') return props.input.dynamicConfig
const data = visualizationData.value
if (data != null && data.ok) {
const parseResult = widgetConfigurationSchema.safeParse(data.value)
const parseResult = argsWidgetConfigurationSchema.safeParse(data.value)
if (parseResult.success) {
return parseResult.data
return functionCallConfiguration(parseResult.data)
} else {
console.error('Unable to parse widget configuration.', data, parseResult.error)
}
@ -123,7 +145,7 @@ function handleArgUpdate(value: unknown, origin: PortId): boolean {
console.error(`Don't know how to put this in a tree`, value)
return true
}
const name = argApp.argument.insertAsNamed ? argApp.argument.info.name : null
const name = argApp.argument.insertAsNamed ? argApp.argument.argInfo.name : null
const ast = Ast.App.new(argApp.appTree, name, newArg, edit)
props.onUpdate(ast, argApp.appTree.exprId)
return true
@ -168,10 +190,7 @@ function handleArgUpdate(value: unknown, origin: PortId): boolean {
} else {
// Process an argument to the right of the removed argument.
assert(innerApp.appTree instanceof Ast.App)
const infoName =
innerApp.argument instanceof ArgumentAst && innerApp.argument.info != null
? innerApp.argument.info?.name ?? null
: null
const infoName = innerApp.argument.argInfo?.name ?? null
if (newArgs.length || (!innerApp.appTree.argumentName && infoName)) {
// Positional arguments following the deleted argument must all be rewritten to named.
newArgs.unshift({
@ -204,10 +223,10 @@ function handleArgUpdate(value: unknown, origin: PortId): boolean {
}
</script>
<script lang="ts">
export const widgetDefinition = defineWidget([Ast.App, Ast.Ident, Ast.OprApp], {
export const widgetDefinition = defineWidget(AnyWidget.matchFunctionCall, {
priority: -10,
score: (props, db) => {
const ast = props.input
const ast = props.input.ast
if (ast.exprId == null) return Score.Mismatch
const prevFunctionState = injectFunctionInfo(true)
@ -229,5 +248,5 @@ export const widgetDefinition = defineWidget([Ast.App, Ast.Ident, Ast.OprApp], {
</script>
<template>
<NodeWidget :input="application" :dynamicConfig="widgetConfiguration" @update="handleArgUpdate" />
<NodeWidget :input="application" @update="handleArgUpdate" />
</template>

View File

@ -1,35 +1,35 @@
<script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import { ForcePort } from '@/providers/portInfo'
import type { WidgetInput } from '@/providers/widgetRegistry'
import { defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { AnyWidget, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { Ast } from '@/util/ast'
import { computed } from 'vue'
const props = defineProps(widgetProps(widgetDefinition))
const spanClass = computed(() => props.input.typeName())
const children = computed(() => [...props.input.children()])
const spanClass = computed(() => props.input.ast.typeName())
const children = computed(() => [...props.input.ast.children()])
function transformChild(child: WidgetInput) {
if (!(props.input instanceof Ast.Ast)) return child
if (props.input instanceof Ast.PropertyAccess) {
if (child === props.input.lhs) {
return new ForcePort(child)
function transformChild(child: Ast.Ast | Ast.Token) {
if (child instanceof Ast.Token) return child
const childInput = AnyWidget.Ast(child)
if (props.input.ast instanceof Ast.PropertyAccess) {
if (child === props.input.ast.lhs) {
return new ForcePort(childInput)
}
} else if (props.input instanceof Ast.OprApp) {
if (child === props.input.rhs || child === props.input.lhs) {
return new ForcePort(child)
} else if (props.input.ast instanceof Ast.OprApp) {
if (child === props.input.ast.rhs || child === props.input.ast.lhs) {
return new ForcePort(childInput)
}
} else if (props.input instanceof Ast.UnaryOprApp && child === props.input.argument) {
return new ForcePort(child)
} else if (props.input.ast instanceof Ast.UnaryOprApp && child === props.input.ast.argument) {
return new ForcePort(childInput)
}
return child
return childInput
}
</script>
<script lang="ts">
export const widgetDefinition = defineWidget(Ast.Ast, {
export const widgetDefinition = defineWidget(AnyWidget.matchAst, {
priority: 1001,
})
</script>

View File

@ -1,30 +1,40 @@
<script setup lang="ts">
import SliderWidget from '@/components/widgets/SliderWidget.vue'
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { AnyWidget, Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { Ast } from '@/util/ast'
import { computed } from 'vue'
const props = defineProps(widgetProps(widgetDefinition))
const value = computed({
get() {
return parseFloat(props.input.code())
const valueStr = props.input.ast?.code() ?? props.input.argInfo?.defaultValue ?? ''
return valueStr ? parseFloat(valueStr) : 0
},
set(value) {
props.onUpdate(value.toString(), props.input.exprId)
props.onUpdate(value.toString(), props.input.portId)
},
})
</script>
<script lang="ts">
export const widgetDefinition = defineWidget(
(input): input is Ast.NumericLiteral | Ast.NegationOprApp =>
input instanceof Ast.NumericLiteral ||
(input instanceof Ast.NegationOprApp && input.argument instanceof Ast.NumericLiteral),
{
priority: 10,
score: Score.Perfect,
export const widgetDefinition = defineWidget(AnyWidget, {
priority: 10,
score: (props) => {
if (
props.input.ast instanceof Ast.NumericLiteral ||
(props.input.ast instanceof Ast.NegationOprApp &&
props.input.ast.argument instanceof Ast.NumericLiteral)
)
return Score.Perfect
if (
props.input.argInfo?.reprType === 'Standard.Base.Data.Number' ||
props.input.argInfo?.reprType === 'Standard.Base.Data.Numbers.Integer' ||
props.input.argInfo?.reprType === 'Standard.Data.Numbers.Float'
)
return Score.Perfect
return Score.Mismatch
},
)
})
</script>
<template>

View File

@ -5,8 +5,7 @@ import { useResizeObserver } from '@/composables/events'
import { injectGraphNavigator } from '@/providers/graphNavigator'
import { injectGraphSelection } from '@/providers/graphSelection'
import { ForcePort, injectPortInfo, providePortInfo, type PortId } from '@/providers/portInfo'
import type { WidgetInput } from '@/providers/widgetRegistry'
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { AnyWidget, Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { injectWidgetTree } from '@/providers/widgetTree'
import { PortViewInstance, useGraphStore } from '@/stores/graph'
import { Ast } from '@/util/ast'
@ -14,6 +13,7 @@ import { ArgumentAst, ArgumentPlaceholder } from '@/util/callTree'
import { Rect } from '@/util/data/rect'
import { cachedGetter } from '@/util/reactivity'
import { uuidv4 } from 'lib0/random'
import type { ExprId } from 'shared/yjsModel'
import {
computed,
markRaw,
@ -27,7 +27,7 @@ import {
watchEffect,
} from 'vue'
const props = defineProps(widgetProps<WidgetInput>(widgetDefinition))
const props = defineProps(widgetProps(widgetDefinition))
const graph = useGraphStore()
@ -37,7 +37,9 @@ const selection = injectGraphSelection(true)
const isHovered = ref(false)
const hasConnection = computed(() => graph.db.connections.reverseLookup(portId.value).size > 0)
const hasConnection = computed(
() => graph.db.connections.reverseLookup(portId.value as ExprId).size > 0,
)
const isCurrentEdgeHoverTarget = computed(
() => isHovered.value && graph.unconnectedEdge != null && selection?.hoveredPort === portId.value,
)
@ -74,12 +76,6 @@ const innerWidget = computed(() =>
props.input instanceof ForcePort ? props.input.inner : props.input,
)
providePortInfo(
proxyRefs({
portId,
connected: hasConnection,
}),
)
providePortInfo(proxyRefs({ portId, connected: hasConnection }))
watch(nodeSize, updateRect)
@ -115,39 +111,51 @@ function updateRect() {
<script lang="ts">
function portIdOfInput(input: unknown): PortId | undefined {
return input instanceof Ast.Ast
? (input.exprId as string as PortId)
: input instanceof ForcePort
? (input.inner.exprId as string as PortId)
return input instanceof AnyWidget
? input.portId
: input instanceof ForcePort && input.inner.ast != null
? (input.inner.ast.exprId as string as PortId)
: input instanceof ArgumentPlaceholder || input instanceof ArgumentAst
? input.portId
: undefined
}
export const widgetDefinition = defineWidget(
[
ForcePort,
ArgumentAst,
ArgumentPlaceholder,
(ast) =>
ast instanceof Ast.Invalid ||
ast instanceof Ast.BodyBlock ||
ast instanceof Ast.Group ||
ast instanceof Ast.NumericLiteral ||
ast instanceof Ast.OprApp ||
ast instanceof Ast.UnaryOprApp ||
ast instanceof Ast.Wildcard ||
ast instanceof Ast.TextLiteral,
],
[ForcePort, AnyWidget.matchAst, ArgumentAst, ArgumentPlaceholder],
{
priority: 0,
score: (props, _db) => {
const portInfo = injectPortInfo(true)
if (portInfo != null && portInfo.portId === portIdOfInput(props.input)) {
const ast =
props.input instanceof ArgumentPlaceholder
? undefined
: props.input instanceof ForcePort
? props.input.inner.ast
: props.input.ast
if (portInfo != null && portInfo.portId === ast?.exprId) {
return Score.Mismatch
} else {
return Score.Perfect
}
if (
props.input instanceof ForcePort ||
props.input instanceof ArgumentAst ||
props.input instanceof ArgumentPlaceholder
)
return Score.Perfect
if (
props.input.ast instanceof Ast.Invalid ||
props.input.ast instanceof Ast.BodyBlock ||
props.input.ast instanceof Ast.Group ||
props.input.ast instanceof Ast.NumericLiteral ||
props.input.ast instanceof Ast.OprApp ||
props.input.ast instanceof Ast.UnaryOprApp ||
props.input.ast instanceof Ast.Wildcard ||
props.input.ast instanceof Ast.TextLiteral
)
return Score.Perfect
return Score.Mismatch
},
},
)
@ -168,7 +176,7 @@ export const widgetDefinition = defineWidget(
@pointerenter="isHovered = true"
@pointerleave="isHovered = false"
>
<NodeWidget :input="innerWidget" :dynamicConfig="props.config" />
<NodeWidget :input="innerWidget" />
</div>
</template>

View File

@ -1,29 +1,26 @@
<script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import DropdownWidget from '@/components/widgets/DropdownWidget.vue'
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { AnyWidget, Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import {
functionCallConfiguration,
type ArgumentWidgetConfiguration,
} from '@/providers/widgetRegistry/configuration'
import { ArgumentAst, ArgumentPlaceholder } from '@/util/callTree'
import { qnJoin, qnSegments, tryQualifiedName } from '@/util/qualifiedName'
import { computed, ref, watch } from 'vue'
const props = defineProps(widgetProps(widgetDefinition))
/** Static selection entry, label and value are the same. */
interface StaticTag {
kind: 'Static'
interface Tag {
label: string
/** If not set, the value is same as label */
value?: string
parameters?: ArgumentWidgetConfiguration[]
}
/** Dynamic selection entry, label and value can be different. */
interface DynamicTag {
kind: 'Dynamic'
label: string
value: string
}
type Tag = StaticTag | DynamicTag
const staticTags = computed<Tag[]>(() => {
const tags = props.input.info?.tagValues
const tags = props.input.argInfo?.tagValues
if (tags == null) return []
return tags.map((tag) => {
const qualifiedName = tryQualifiedName(tag)
@ -31,35 +28,55 @@ const staticTags = computed<Tag[]>(() => {
const segments = qnSegments(qualifiedName.value).slice(-2)
if (segments[0] == undefined) return { kind: 'Static', label: tag }
if (segments[1] == undefined) return { kind: 'Static', label: segments[0] }
return { kind: 'Static', label: qnJoin(segments[0], segments[1]) }
return { label: qnJoin(segments[0], segments[1]) }
})
})
const dynamicTags = computed<Tag[]>(() => {
const config = props.config
if (config == null) return []
const [_, widgetConfig] = config.find(([name]) => name === props.input.info?.name) ?? []
if (widgetConfig && widgetConfig.kind == 'Single_Choice') {
return widgetConfig.values.map((value) => ({
kind: 'Dynamic',
label: value.label || value.value,
value: value.value,
}))
} else {
return []
}
const config = props.input.dynamicConfig
if (config?.kind !== 'Single_Choice') return []
return config.values.map((value) => ({
label: value.label || value.value,
value: value.value,
parameters: value.parameters,
}))
})
const tags = computed(() => (dynamicTags.value.length > 0 ? dynamicTags.value : staticTags.value))
const tagLabels = computed(() => tags.value.map((tag) => tag.label))
const tagValues = computed(() => {
return tags.value.map((tag) => (tag.kind == 'Static' ? tag.label : tag.value))
})
const selectedIndex = ref<number>()
const selectedTag = computed(() =>
selectedIndex.value != null ? tags.value[selectedIndex.value] : undefined,
)
const selectedValue = computed(() => {
if (selectedIndex.value == null) return props.input.info?.defaultValue ?? ''
return tagValues.value[selectedIndex.value] ?? ''
if (selectedTag.value == null) return props.input.argInfo?.defaultValue ?? ''
return selectedTag.value.value ?? selectedTag.value.label
})
const innerWidgetInput = computed(() => {
if (selectedTag.value == null) return props.input
const parameters = selectedTag.value.parameters
if (!parameters) return props.input
const config = functionCallConfiguration(parameters)
if (props.input instanceof AnyWidget)
return new AnyWidget(props.input.portId, props.input.ast, config, props.input.argInfo)
else if (props.input instanceof ArgumentAst)
return new ArgumentAst(
props.input.ast,
props.input.index,
props.input.argInfo,
props.input.kind,
config,
)
else
return new ArgumentPlaceholder(
props.input.callId,
props.input.index,
props.input.argInfo,
props.input.kind,
props.input.insertAsNamed,
config,
)
})
const showDropdownWidget = ref(false)
@ -69,30 +86,25 @@ function toggleDropdownWidget() {
// When the selected index changes, we update the expression content.
watch(selectedIndex, (_index) => {
// TODO: Handle the case for ArgumentPlaceholder once the AST has been updated,
const id = props.input instanceof ArgumentAst ? props.input.ast.exprId : undefined
const expression = selectedValue.value ?? ''
if (id) props.onUpdate(expression, id)
props.onUpdate(selectedValue.value, props.input.portId)
showDropdownWidget.value = false
})
</script>
<script lang="ts">
export const widgetDefinition = defineWidget([ArgumentPlaceholder, ArgumentAst], {
export const widgetDefinition = defineWidget([AnyWidget, ArgumentAst, ArgumentPlaceholder], {
priority: 999,
score: (props) => {
const tags = props.input.info?.tagValues
const [_, dynamicConfig] = props.config?.find(([name]) => name === props.input.info?.name) ?? []
const isSuitableDynamicConfig = dynamicConfig && dynamicConfig.kind === 'Single_Choice'
if (tags == null && !isSuitableDynamicConfig) return Score.Mismatch
return Score.Perfect
if (props.input.dynamicConfig?.kind === 'Single_Choice') return Score.Perfect
if (props.input.argInfo?.tagValues != null) return Score.Perfect
return Score.Mismatch
},
})
</script>
<template>
<div class="WidgetSelection" @pointerdown="toggleDropdownWidget">
<NodeWidget :input="props.input" />
<NodeWidget :input="innerWidgetInput" />
<DropdownWidget
v-if="showDropdownWidget"
class="dropdownContainer"

View File

@ -18,7 +18,7 @@ export const widgetDefinition = defineWidget([ArgumentAst, ArgumentPlaceholder],
<template>
<span class="WidgetTopLevelArgument">
<NodeWidget :input="props.input" :dynamicConfig="props.config" nest />
<NodeWidget :input="props.input" nest />
</span>
</template>

View File

@ -3,46 +3,62 @@ import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import ListWidget from '@/components/widgets/ListWidget.vue'
import { injectGraphNavigator } from '@/providers/graphNavigator'
import { ForcePort } from '@/providers/portInfo'
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { AnyWidget, Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { Ast, RawAst } from '@/util/ast'
import { computed } from 'vue'
const props = defineProps(widgetProps(widgetDefinition))
const itemConfig = computed(() =>
props.input.dynamicConfig?.kind === 'Vector_Editor'
? props.input.dynamicConfig.item_editor
: undefined,
)
const defaultItem = computed(() => {
if (props.input.dynamicConfig?.kind === 'Vector_Editor') {
return Ast.parse(props.input.dynamicConfig.item_default)
} else {
return Ast.Wildcard.new()
}
})
const value = computed({
get() {
return Array.from(props.input.children()).filter(
if (props.input.ast == null) return []
return Array.from(props.input.ast.children()).filter(
(child): child is Ast.Ast => child instanceof Ast.Ast,
)
},
set(value) {
const newCode = `[${value.map((item) => item.code()).join(', ')}]`
props.onUpdate(newCode, props.input.exprId)
props.onUpdate(newCode, props.input.portId)
},
})
const navigator = injectGraphNavigator(true)
function itemPort(item: Ast.Ast) {
return new ForcePort(item)
}
</script>
<script lang="ts">
export const widgetDefinition = defineWidget(Ast.Ast, {
export const widgetDefinition = defineWidget(AnyWidget, {
priority: 1000,
score: (props) =>
props.input.treeType === RawAst.Tree.Type.Array ? Score.Perfect : Score.Mismatch,
score: (props) => {
if (props.input.dynamicConfig?.kind === 'Vector_Editor') return Score.Perfect
else if (props.input.argInfo?.reprType.startsWith('Standard.Base.Data.Vector.Vector'))
return Score.Good
else
return props.input.ast?.treeType === RawAst.Tree.Type.Array ? Score.Perfect : Score.Mismatch
},
})
</script>
<template>
<ListWidget
v-model="value"
:default="Ast.Wildcard.new"
:getKey="(item: Ast.Ast) => item.exprId"
:default="() => defaultItem"
:getKey="(ast: Ast.Ast) => ast.exprId"
dragMimeType="application/x-enso-ast-node"
:toPlainText="(item: Ast.Ast) => item.code()"
:toPlainText="(ast: Ast.Ast) => ast.code()"
:toDragPayload="(ast: Ast.Ast) => ast.serialize()"
:fromDragPayload="Ast.deserialize"
:toDragPosition="(p) => navigator?.clientToScenePos(p) ?? p"
@ -50,7 +66,7 @@ export const widgetDefinition = defineWidget(Ast.Ast, {
contenteditable="false"
>
<template #default="{ item }">
<NodeWidget :input="itemPort(item)" />
<NodeWidget :input="new ForcePort(AnyWidget.Ast(item, itemConfig))" />
</template>
</ListWidget>
</template>

View File

@ -1,4 +1,5 @@
import {
AnyWidget,
Score,
WidgetRegistry,
defineWidget,
@ -6,12 +7,12 @@ import {
type WidgetInput,
type WidgetModule,
} from '@/providers/widgetRegistry'
import { DisplayMode, widgetConfigurationSchema } from '@/providers/widgetRegistry/configuration'
import { GraphDb } from '@/stores/graph/graphDatabase'
import { Ast } from '@/util/ast'
import { ApplicationKind, ArgumentPlaceholder } from '@/util/callTree'
import { describe, expect, test } from 'vitest'
import { defineComponent } from 'vue'
import { DisplayMode, argsWidgetConfigurationSchema } from '../widgetRegistry/configuration'
describe('WidgetRegistry', () => {
function makeMockWidget<T extends WidgetInput>(
@ -26,7 +27,7 @@ describe('WidgetRegistry', () => {
const widgetA = makeMockWidget(
'A',
defineWidget(Ast.Ast, {
defineWidget(AnyWidget, {
priority: 1,
}),
)
@ -48,23 +49,25 @@ describe('WidgetRegistry', () => {
const widgetD = makeMockWidget(
'D',
defineWidget(Ast.Ast, {
defineWidget(AnyWidget, {
priority: 20,
score: (props) => (props.input.code() === '_' ? Score.Perfect : Score.Mismatch),
score: (props) => (props.input.ast?.code() === '_' ? Score.Perfect : Score.Mismatch),
}),
)
const someAst = Ast.parse('foo')
const blankAst = Ast.parse('_')
const someAst = AnyWidget.Ast(Ast.parse('foo'))
const blankAst = AnyWidget.Ast(Ast.parse('_'))
const somePlaceholder = new ArgumentPlaceholder(
'57d429dc-df85-49f8-b150-567c7d1fb502',
0,
{
name: 'foo',
type: 'Any',
reprType: 'Any',
isSuspended: false,
hasDefault: false,
},
ApplicationKind.Prefix,
false,
)
const mockGraphDb = GraphDb.Mock()
@ -75,19 +78,16 @@ describe('WidgetRegistry', () => {
registry.registerWidgetModule(widgetD)
test('selects a widget based on the input type', () => {
const forAst = registry.select({ input: someAst, config: undefined, nesting: 0 })
const forArg = registry.select({ input: somePlaceholder, config: undefined, nesting: 0 })
const forAst = registry.select({ input: someAst, nesting: 0 })
const forArg = registry.select({ input: somePlaceholder, nesting: 0 })
expect(forAst).toStrictEqual(widgetA)
expect(forArg).toStrictEqual(widgetB)
})
test('selects a widget outside of the excluded set', () => {
const forAst = registry.select(
{ input: someAst, config: undefined, nesting: 0 },
new Set([widgetA.default]),
)
const forAst = registry.select({ input: someAst, nesting: 0 }, new Set([widgetA.default]))
const forArg = registry.select(
{ input: somePlaceholder, config: undefined, nesting: 0 },
{ input: somePlaceholder, nesting: 0 },
new Set([widgetB.default]),
)
expect(forAst).toStrictEqual(widgetC)
@ -96,7 +96,7 @@ describe('WidgetRegistry', () => {
test('returns undefined when all options are exhausted', () => {
const selected = registry.select(
{ input: someAst, config: undefined, nesting: 0 },
{ input: someAst, nesting: 0 },
new Set([widgetA.default, widgetC.default]),
)
expect(selected).to.be.undefined
@ -104,11 +104,11 @@ describe('WidgetRegistry', () => {
test('prefers low priority perfect over good high priority', () => {
const selectedFirst = registry.select(
{ input: blankAst, config: undefined, nesting: 0 },
{ input: blankAst, nesting: 0 },
new Set([widgetA.default]),
)
const selectedNext = registry.select(
{ input: blankAst, config: undefined, nesting: 0 },
{ input: blankAst, nesting: 0 },
new Set([widgetA.default, widgetD.default]),
)
expect(selectedFirst).toStrictEqual(widgetD)
@ -179,7 +179,7 @@ describe('Engine-provided configuration', () => {
{ input: [singleChoiceData], expected: [singleChoiceExpected] },
{ input: [vectorEditorData], expected: [vectorEditorExpected] },
])('Testing engine configuration', ({ input, expected }) => {
const res = widgetConfigurationSchema.safeParse(input)
const res = argsWidgetConfigurationSchema.safeParse(input)
expect(res).toMatchObject({ success: true, data: expected })
})
})

View File

@ -1,6 +1,6 @@
import { createContextStore } from '@/providers'
import type { AnyWidget } from '@/providers/widgetRegistry'
import { GetUsageKey } from '@/providers/widgetUsageInfo'
import { Ast } from '@/util/ast'
import { identity } from '@vueuse/core'
import type { ExprId } from 'shared/yjsModel'
@ -20,7 +20,7 @@ const { provideFn, injectFn } = createContextStore('Port info', identity<PortInf
* even if it wouldn't normally be rendered as such.
*/
export class ForcePort {
constructor(public inner: Ast.Ast) {
constructor(public inner: AnyWidget) {
if (inner instanceof ForcePort) throw new Error('ForcePort cannot be nested')
}
[GetUsageKey]() {

View File

@ -1,11 +1,71 @@
import { createContextStore } from '@/providers'
import type { PortId } from '@/providers/portInfo'
import { type WidgetConfiguration } from '@/providers/widgetRegistry/configuration'
import type { WidgetConfiguration } from '@/providers/widgetRegistry/configuration'
import type { GraphDb } from '@/stores/graph/graphDatabase'
import type { SuggestionEntryArgument } from '@/stores/suggestionDatabase/entry'
import { Ast } from '@/util/ast'
import { computed, shallowReactive, type Component, type PropType } from 'vue'
export type WidgetComponent<T extends WidgetInput> = Component<WidgetProps<T>>
/**
* A WidgetInput variant meant to match wide range of "general" widgets.
*
* Any widget which wants to work in different contexts (inside function calls, constructors, list
* elements) should match with this, using provided information.
*
* When your widget want to display some non-specific subwidget (like WidgetVector which displays
* elements of any type), this should be provided as their input, passing as much information as
* possible.
*/
export class AnyWidget {
constructor(
/** A port id to refer when updating changes */
public portId: PortId,
/**
* Ast represented by widget. May be not defined if widget is a placeholder for
* not-yet-written argument
*/
public ast: Ast.Ast | undefined,
/** Configuration retrieved from the backend */
public dynamicConfig?: WidgetConfiguration | undefined,
/** The information about argument of some function call, which this widget is setting (if any) */
public argInfo?: SuggestionEntryArgument | undefined,
) {}
static Ast(
ast: Ast.Ast,
dynamicConfig?: WidgetConfiguration | undefined,
argInfo?: SuggestionEntryArgument | undefined,
) {
return new AnyWidget(ast.exprId, ast, dynamicConfig, argInfo)
}
isPlaceholder() {
return this.ast == null
}
static matchPlaceholder(input: WidgetInput): input is AnyWidget & { ast: undefined } {
return input instanceof AnyWidget && input.isPlaceholder()
}
static matchAst(input: WidgetInput): input is AnyWidget & { ast: Ast.Ast } {
return input instanceof AnyWidget && !input.isPlaceholder()
}
static matchFunctionCall(
input: WidgetInput,
): input is AnyWidget & { ast: Ast.App | Ast.Ident | Ast.OprApp } {
return (
input instanceof AnyWidget &&
(input.ast instanceof Ast.App ||
input.ast instanceof Ast.Ident ||
input.ast instanceof Ast.OprApp)
)
}
}
declare const AnyWidgetKey: unique symbol
/**
* A type representing any kind of input that can have a widget attached to it. It is defined as an
* interface to allow for extension by widgets themselves. The actual input received by the widget
@ -27,7 +87,9 @@ export type WidgetComponent<T extends WidgetInput> = Component<WidgetProps<T>>
* All declared widget input types must have unique symbols, and all values must be objects.
* Declarations that do not follow these rules will be ignored or will cause type errors.
*/
export interface WidgetInputTypes {}
export interface WidgetInputTypes {
[AnyWidgetKey]: AnyWidget
}
/**
* An union of all possible widget input types. A collection of all correctly declared value types
@ -60,7 +122,6 @@ export enum Score {
export interface WidgetProps<T> {
input: T
config?: WidgetConfiguration | undefined
nesting: number
}
@ -76,7 +137,6 @@ export function widgetProps<T extends WidgetInput>(_def: WidgetDefinition<T>) {
type: Object as PropType<T>,
required: true,
},
config: { type: Object as PropType<WidgetConfiguration | undefined>, required: false },
nesting: { type: Number, required: true },
onUpdate: {
type: Function as PropType<(value: unknown | undefined, origin: PortId) => void>,
@ -290,7 +350,6 @@ export class WidgetRegistry {
best = widgetModule
}
}
// Once we've checked all widgets, return the best match found, if any.
return best
}
}

View File

@ -1,15 +1,5 @@
import { z } from 'zod'
/**
* An external configuration for a widget retreived from the language server.
*
* The expected configuration type is defined as Enso type `Widget` in the following file:
* distribution/lib/Standard/Base/0.0.0-dev/src/Metadata.enso
*
* To avoid ruining forward compatibility, only fields that are used by the IDE are defined here.
*/
export type WidgetConfiguration = z.infer<typeof widgetConfigurationSchema>
/** Intermediate step in the parsing process, when we rename `constructor` field to `kind`.
*
* It helps to avoid issues with TypeScript, which considers `constructor` as a reserved keyword in many contexts.
@ -56,10 +46,17 @@ const choiceSchema = z.object({
})
export type Choice = z.infer<typeof choiceSchema>
/** Defining widget definition type explicitly is necessary because of recursive structure
* of some variants, like VectorEditor. Zod cant handle type inference by itself.
/**
* An external configuration for a widget retreived from the language server.
*
* The expected configuration type is defined as Enso type `Widget` in the following file:
* distribution/lib/Standard/Base/0.0.0-dev/src/Metadata.enso
*
* To avoid ruining forward compatibility, only fields that are used by the IDE are defined here.
*/
type WidgetDefinition =
// Defining widget definition type explicitly is necessary because of recursive structure
// of some variants, like VectorEditor. Zod cant handle type inference by itself.
export type WidgetConfiguration =
| SingleChoice
| VectorEditor
| MultiChoice
@ -69,10 +66,11 @@ type WidgetDefinition =
| TextInput
| FolderBrowse
| FileBrowse
| FunctionCall
export interface VectorEditor {
kind: 'Vector_Editor'
item_editor: WidgetDefinition
item_editor: WidgetConfiguration
item_default: string
}
@ -110,37 +108,53 @@ export interface SingleChoice {
values: Choice[]
}
const widgetDefinitionSchema: z.ZodType<WidgetDefinition & WithDisplay, z.ZodTypeDef, any> =
withKindSchema.pipe(
z.discriminatedUnion('kind', [
z
.object({
kind: z.literal('Single_Choice'),
label: z.string().nullable(),
values: z.array(choiceSchema),
})
.merge(withDisplay),
z
.object({
kind: z.literal('Vector_Editor'),
/* eslint-disable camelcase */
item_editor: z.lazy(() => widgetDefinitionSchema),
item_default: z.string(),
/* eslint-enable camelcase */
})
.merge(withDisplay),
z.object({ kind: z.literal('Multi_Choice') }).merge(withDisplay),
z.object({ kind: z.literal('Code_Input') }).merge(withDisplay),
z.object({ kind: z.literal('Boolean_Input') }).merge(withDisplay),
z.object({ kind: z.literal('Numeric_Input') }).merge(withDisplay),
z.object({ kind: z.literal('Text_Input') }).merge(withDisplay),
z.object({ kind: z.literal('Folder_Browse') }).merge(withDisplay),
z.object({ kind: z.literal('File_Browse') }).merge(withDisplay),
]),
)
export interface FunctionCall {
kind: 'FunctionCall'
parameters: Map<string, (WidgetConfiguration & WithDisplay) | null>
}
export const widgetConfigurationSchema: z.ZodType<
WidgetConfiguration & WithDisplay,
z.ZodTypeDef,
any
> = withKindSchema.pipe(
z.discriminatedUnion('kind', [
z
.object({
kind: z.literal('Single_Choice'),
label: z.string().nullable(),
values: z.array(choiceSchema),
})
.merge(withDisplay),
z
.object({
kind: z.literal('Vector_Editor'),
/* eslint-disable camelcase */
item_editor: z.lazy(() => widgetConfigurationSchema),
item_default: z.string(),
/* eslint-enable camelcase */
})
.merge(withDisplay),
z.object({ kind: z.literal('Multi_Choice') }).merge(withDisplay),
z.object({ kind: z.literal('Code_Input') }).merge(withDisplay),
z.object({ kind: z.literal('Boolean_Input') }).merge(withDisplay),
z.object({ kind: z.literal('Numeric_Input') }).merge(withDisplay),
z.object({ kind: z.literal('Text_Input') }).merge(withDisplay),
z.object({ kind: z.literal('Folder_Browse') }).merge(withDisplay),
z.object({ kind: z.literal('File_Browse') }).merge(withDisplay),
]),
)
const argNameSchema = z.string()
const argumentSchema = z.tuple([argNameSchema, widgetDefinitionSchema.nullable()])
export type Argument = z.infer<typeof argumentSchema>
const argumentSchema = z.tuple([argNameSchema, widgetConfigurationSchema.nullable()])
export type ArgumentWidgetConfiguration = z.infer<typeof argumentSchema>
export const widgetConfigurationSchema = z.array(argumentSchema)
export const argsWidgetConfigurationSchema = z.array(argumentSchema)
export type ArgsWidgetConfiguration = z.infer<typeof argsWidgetConfigurationSchema>
export function functionCallConfiguration(parameters: ArgumentWidgetConfiguration[]): FunctionCall {
return {
kind: 'FunctionCall',
parameters: new Map(parameters),
}
}

View File

@ -1,4 +1,3 @@
import type { PortId } from '@/providers/portInfo'
import { ComputedValueRegistry, type ExpressionInfo } from '@/stores/project/computedValueRegistry'
import { SuggestionDb, groupColorStyle, type Group } from '@/stores/suggestionDatabase'
import type { SuggestionEntry } from '@/stores/suggestionDatabase/entry'
@ -138,7 +137,7 @@ export class GraphDb {
// Display connection starting from existing node.
//TODO[ao]: When implementing input nodes, they should be taken into account here.
if (srcNode == null) return []
function* allTargets(db: GraphDb): Generator<[ExprId, PortId]> {
function* allTargets(db: GraphDb): Generator<[ExprId, ExprId]> {
for (const usage of info.usages) {
const targetNode = db.getExpressionNodeId(usage)
// Display only connections to existing targets and different than source node

View File

@ -191,7 +191,7 @@ test('Adding new argument', () => {
const test = new Fixture()
const newArg: lsTypes.SuggestionEntryArgument = {
name: 'c',
type: 'Any',
reprType: 'Any',
hasDefault: false,
isSuspended: false,
}
@ -212,13 +212,13 @@ test('Adding new argument', () => {
test('Modifying arguments', () => {
const newArg1 = {
name: 'c',
type: 'Standard.Base.Number',
reprType: 'Standard.Base.Number',
isSuspended: true,
hasDefault: false,
}
const newArg2 = {
name: 'b',
type: 'Any',
reprType: 'Any',
isSuspended: false,
hasDefault: true,
defaultValue: 'Nothing',
@ -275,14 +275,14 @@ class Fixture {
]
arg1 = {
name: 'a',
type: 'Any',
reprType: 'Any',
isSuspended: false,
hasDefault: true,
defaultValue: 'Nothing',
}
arg2 = {
name: 'b',
type: 'Any',
reprType: 'Any',
isSuspended: false,
hasDefault: false,
}

View File

@ -184,3 +184,12 @@ export function makeLocal(
assert(isQualifiedName(returnType))
return makeSimpleEntry(SuggestionKind.Local, definedIn, name, returnType)
}
export function makeArgument(name: string, type: string = 'Any'): SuggestionEntryArgument {
return {
name,
reprType: type,
isSuspended: false,
hasDefault: false,
}
}

View File

@ -304,7 +304,7 @@ function applyArgumentsUpdate(
const nameUpdate = applyPropertyUpdate('name', arg, update)
if (!nameUpdate.ok) return nameUpdate
const typeUpdate = applyFieldUpdate('reprType', update, (type) => {
arg.type = type
arg.reprType = type
})
if (!typeUpdate.ok) return typeUpdate
const isSuspendedUpdate = applyPropertyUpdate('isSuspended', arg, update)

View File

@ -1,76 +1,132 @@
import { SuggestionKind, type SuggestionEntry } from '@/stores/suggestionDatabase/entry'
import * as widgetCfg from '@/providers/widgetRegistry/configuration'
import { makeArgument, makeMethod, makeModuleMethod } from '@/stores/suggestionDatabase/entry'
import { Ast } from '@/util/ast'
import { ArgumentApplication, ArgumentPlaceholder } from '@/util/callTree'
import {
ArgumentApplication,
ArgumentAst,
ArgumentPlaceholder,
interpretCall,
} from '@/util/callTree'
import { isSome } from '@/util/data/opt'
import { type Identifier, type QualifiedName } from '@/util/qualifiedName'
import type { MethodCall } from 'shared/languageServerTypes'
import { assert, expect, test } from 'vitest'
const mockSuggestion: SuggestionEntry = {
kind: SuggestionKind.Method,
name: 'func' as Identifier,
definedIn: 'Foo.Bar' as QualifiedName,
selfType: 'Foo.Bar',
returnType: 'Any',
arguments: [
{ name: 'self', type: 'Any', isSuspended: false, hasDefault: false },
{ name: 'a', type: 'Any', isSuspended: false, hasDefault: false },
{ name: 'b', type: 'Any', isSuspended: false, hasDefault: false },
{ name: 'c', type: 'Any', isSuspended: false, hasDefault: false },
{ name: 'd', type: 'Any', isSuspended: false, hasDefault: false },
],
documentation: [],
isPrivate: false,
isUnstable: false,
aliases: [],
annotations: [],
const prefixFixture = {
mockSuggestion: {
...makeModuleMethod('local.Foo.Bar.func'),
arguments: ['self', 'a', 'b', 'c', 'd'].map((name) => makeArgument(name)),
},
argsParameters: new Map<string, widgetCfg.WidgetConfiguration & widgetCfg.WithDisplay>([
['a', { kind: 'Multi_Choice', display: widgetCfg.DisplayMode.Always }],
['b', { kind: 'Code_Input', display: widgetCfg.DisplayMode.Always }],
['c', { kind: 'Boolean_Input', display: widgetCfg.DisplayMode.Always }],
]),
methodPointer: {
name: 'func',
definedOnType: 'Foo.Bar',
module: 'local.Foo.Bar',
},
}
function testArgs(paddedExpression: string, pattern: string) {
const expression = paddedExpression.trim()
const notAppliedArguments = pattern
.split(' ')
.map((p) =>
p.startsWith('?') ? mockSuggestion.arguments.findIndex((k) => p.slice(1) === k.name) : null,
)
.filter(isSome)
const infixFixture = {
mockSuggestion: {
...makeMethod('local.Foo.Bar.Buz.+'),
arguments: ['lhs', 'rhs'].map((name) => makeArgument(name)),
},
argsParameters: new Map<string, widgetCfg.WidgetConfiguration & widgetCfg.WithDisplay>([
['lhs', { kind: 'Multi_Choice', display: widgetCfg.DisplayMode.Always }],
['rhs', { kind: 'Code_Input', display: widgetCfg.DisplayMode.Always }],
]),
methodPointer: {
name: '+',
definedOnType: 'local.Foo.Bar.Buz',
module: 'local.Foo.Bar',
},
}
test(`argument list: ${paddedExpression} ${pattern}`, () => {
const ast = Ast.parse(expression)
interface TestData {
expression: string
expectedPattern: string
fixture: typeof prefixFixture | typeof infixFixture
}
test.each`
expression | expectedPattern | fixture
${'func '} | ${'?a ?b ?c ?d'} | ${prefixFixture}
${'func a=x c=x '} | ${'=a ?b =c ?d'} | ${prefixFixture}
${'func a=x x c=x '} | ${'=a @b =c ?d'} | ${prefixFixture}
${'func a=x d=x '} | ${'=a ?b ?c =d'} | ${prefixFixture}
${'func a=x d=x b=x '} | ${'=a =d =b ?c'} | ${prefixFixture}
${'func a=x d=x c=x '} | ${'=a =d ?b =c'} | ${prefixFixture}
${'func a=x c=x d=x '} | ${'=a ?b =c =d'} | ${prefixFixture}
${'func b=x '} | ${'?a =b ?c ?d'} | ${prefixFixture}
${'func b=x c=x '} | ${'?a =b =c ?d'} | ${prefixFixture}
${'func b=x x x '} | ${'=b @a @c ?d'} | ${prefixFixture}
${'func c=x b=x x '} | ${'=c =b @a ?d'} | ${prefixFixture}
${'func d=x '} | ${'?a ?b ?c =d'} | ${prefixFixture}
${'func d=x a c=x '} | ${'=d @a ?b =c'} | ${prefixFixture}
${'func d=x x '} | ${'=d @a ?b ?c'} | ${prefixFixture}
${'func d=x x '} | ${'=d @a ?b ?c'} | ${prefixFixture}
${'func d=x x x '} | ${'=d @a @b ?c'} | ${prefixFixture}
${'func d=x x x x '} | ${'=d @a @b @c'} | ${prefixFixture}
${'func x '} | ${'@a ?b ?c ?d'} | ${prefixFixture}
${'func x b=x c=x '} | ${'@a =b =c ?d'} | ${prefixFixture}
${'func x b=x x '} | ${'@a =b @c ?d'} | ${prefixFixture}
${'func x d=x '} | ${'@a ?b ?c =d'} | ${prefixFixture}
${'func x x '} | ${'@a @b ?c ?d'} | ${prefixFixture}
${'func x x x '} | ${'@a @b @c ?d'} | ${prefixFixture}
${'func x x x x '} | ${'@a @b @c @d'} | ${prefixFixture}
${'func a=x x m=x '} | ${'=a @b =m ?c ?d'} | ${prefixFixture}
${'x + y'} | ${'@lhs @rhs'} | ${infixFixture}
${'x +'} | ${'@lhs ?rhs'} | ${infixFixture}
`(
"Creating argument application's info: $expression $expectedPattern",
({
expression,
expectedPattern,
fixture: { mockSuggestion, argsParameters, methodPointer },
}: TestData) => {
const expectedArgs = expectedPattern.split(' ')
const notAppliedArguments = expectedArgs
.map((p: string) =>
p.startsWith('?') ? mockSuggestion.arguments.findIndex((k) => p.slice(1) === k.name) : null,
)
.filter(isSome)
const ast = Ast.parse(expression.trim())
const methodCall: MethodCall = {
methodPointer: {
name: 'func',
definedOnType: 'Foo.Bar',
module: 'Foo.Bar',
},
methodPointer,
notAppliedArguments,
}
const funcMethodCall: MethodCall = {
methodPointer: {
name: 'func',
definedOnType: 'Foo.Bar',
module: 'Foo.Bar',
},
notAppliedArguments: [1, 2, 3, 4],
methodPointer,
notAppliedArguments: Array.from(expectedArgs, (_, i) => i + 1),
}
const interpreted = ArgumentApplication.Interpret(ast, false)
const call = ArgumentApplication.FromInterpretedWithInfo(
interpreted,
funcMethodCall,
methodCall,
mockSuggestion,
)
const configuration: widgetCfg.FunctionCall = {
kind: 'FunctionCall',
parameters: argsParameters,
}
const interpreted = interpretCall(ast, true)
const call = ArgumentApplication.FromInterpretedWithInfo(interpreted, {
appMethodCall: methodCall,
noArgsCall: funcMethodCall,
suggestion: mockSuggestion,
widgetCfg: configuration,
})
assert(call instanceof ArgumentApplication)
expect(printArgPattern(call)).toEqual(pattern)
})
}
expect(printArgPattern(call)).toEqual(expectedPattern)
checkArgsConfig(call, argsParameters)
},
)
function printArgPattern(application: ArgumentApplication | Ast.Ast) {
const parts: string[] = []
let current: ArgumentApplication['target'] = application
while (current instanceof ArgumentApplication) {
const sigil =
current.argument instanceof ArgumentPlaceholder
@ -78,35 +134,23 @@ function printArgPattern(application: ArgumentApplication | Ast.Ast) {
: current.appTree instanceof Ast.App && current.appTree.argumentName
? '='
: '@'
const argInfo = 'info' in current.argument ? current.argument.info : undefined
parts.push(sigil + (argInfo?.name ?? '_'))
parts.push(sigil + (current.argument.argInfo?.name ?? '_'))
current = current.target
}
if (current instanceof ArgumentPlaceholder) parts.push(`?${current.argInfo.name}`)
if (current instanceof ArgumentAst) parts.push(`@${current.argInfo?.name}`)
return parts.reverse().join(' ')
}
testArgs('func ', '?a ?b ?c ?d')
testArgs('func a=x c=x ', '=a ?b =c ?d')
testArgs('func a=x x c=x ', '=a @b =c ?d')
testArgs('func a=x d=x ', '=a ?b ?c =d')
testArgs('func a=x d=x b=x ', '=a =d =b ?c')
testArgs('func a=x d=x c=x ', '=a =d ?b =c')
testArgs('func a=x c=x d=x ', '=a ?b =c =d')
testArgs('func b=x ', '?a =b ?c ?d')
testArgs('func b=x c=x ', '?a =b =c ?d')
testArgs('func b=x x x ', '=b @a @c ?d')
testArgs('func c=x b=x x ', '=c =b @a ?d')
testArgs('func d=x ', '?a ?b ?c =d')
testArgs('func d=x a c=x ', '=d @a ?b =c')
testArgs('func d=x x ', '=d @a ?b ?c')
testArgs('func d=x x ', '=d @a ?b ?c')
testArgs('func d=x x x ', '=d @a @b ?c')
testArgs('func d=x x x x ', '=d @a @b @c')
testArgs('func x ', '@a ?b ?c ?d')
testArgs('func x b=x c=x ', '@a =b =c ?d')
testArgs('func x b=x x ', '@a =b @c ?d')
testArgs('func x d=x ', '@a ?b ?c =d')
testArgs('func x x ', '@a @b ?c ?d')
testArgs('func x x x ', '@a @b @c ?d')
testArgs('func x x x x ', '@a @b @c @d')
testArgs('func a=x x m=x ', '=a @b =m ?c ?d')
function checkArgsConfig(
application: ArgumentApplication | Ast.Ast,
argConfig: Map<string, widgetCfg.WidgetConfiguration | widgetCfg.WithDisplay>,
) {
let current: ArgumentApplication['target'] = application
while (current instanceof ArgumentApplication) {
const argName = current.argument.argInfo?.name
const expected = argName ? argConfig.get(argName) : undefined
expect(current.argument.dynamicConfig).toEqual(expected)
current = current.target
}
}

View File

@ -1532,11 +1532,9 @@ export function deserialize(serialized: string): Ast {
return Ast.deserialize(serialized)
}
declare const AstKey: unique symbol
declare const TokenKey: unique symbol
declare module '@/providers/widgetRegistry' {
export interface WidgetInputTypes {
[AstKey]: Ast
[TokenKey]: Token
}
}

View File

@ -225,10 +225,3 @@ class AstExtendedCtx<HasIdMap extends boolean> {
)
}
}
declare const AstExtendedKey: unique symbol
declare module '@/providers/widgetRegistry' {
export interface WidgetInputTypes {
[AstExtendedKey]: AstExtended
}
}

View File

@ -1,4 +1,7 @@
import type { PortId } from '@/providers/portInfo'
import { AnyWidget, type WidgetInput } from '@/providers/widgetRegistry'
import type { WidgetConfiguration } from '@/providers/widgetRegistry/configuration'
import * as widgetCfg from '@/providers/widgetRegistry/configuration'
import type { SuggestionEntry, SuggestionEntryArgument } from '@/stores/suggestionDatabase/entry'
import { Ast } from '@/util/ast'
import { tryGetIndex } from '@/util/data/array'
@ -17,11 +20,29 @@ export class ArgumentPlaceholder {
constructor(
public callId: string,
public index: number,
public info: SuggestionEntryArgument,
public argInfo: SuggestionEntryArgument,
public kind: ApplicationKind,
public insertAsNamed: boolean,
public dynamicConfig?: WidgetConfiguration | undefined,
) {}
static WithRetrievedConfig(
callId: string,
index: number,
info: SuggestionEntryArgument,
kind: ApplicationKind,
insertAsNamed: boolean,
functionCallConfig: widgetCfg.FunctionCall | undefined,
) {
const cfg =
info != null ? functionCallConfig?.parameters.get(info.name) ?? undefined : undefined
return new ArgumentPlaceholder(callId, index, info, kind, insertAsNamed, cfg)
}
toAnyWidget(): AnyWidget {
return new AnyWidget(this.portId, undefined, this.dynamicConfig, this.argInfo)
}
get portId(): PortId {
return `${this.callId}[${this.index}]` as PortId
}
@ -31,12 +52,35 @@ export class ArgumentAst {
constructor(
public ast: Ast.Ast,
public index: number | undefined,
public info: SuggestionEntryArgument | undefined,
public argInfo: SuggestionEntryArgument | undefined,
public kind: ApplicationKind,
public dynamicConfig?: WidgetConfiguration | undefined,
) {}
static WithRetrievedConfig(
ast: Ast.Ast,
index: number | undefined,
info: SuggestionEntryArgument | undefined,
kind: ApplicationKind,
functionCallConfig: widgetCfg.FunctionCall | undefined,
) {
const cfg =
info != null ? functionCallConfig?.parameters.get(info.name) ?? undefined : undefined
return new ArgumentAst(ast, index, info, kind, cfg)
}
toAnyWidget(): AnyWidget {
return new AnyWidget(this.portId, this.ast, this.dynamicConfig, this.argInfo)
}
get portId(): PortId {
return this.ast.exprId as string as PortId
return this.ast.exprId
}
static matchWithArgInfo(
input: WidgetInput,
): input is ArgumentAst & { argInfo: SuggestionEntryArgument } {
return input instanceof ArgumentAst && input.argInfo != null
}
}
@ -62,78 +106,87 @@ interface FoundApplication {
argName: string | undefined
}
export function interpretCall(callRoot: Ast.Ast, allowInterpretAsInfix: boolean): InterpretedCall {
if (allowInterpretAsInfix && callRoot instanceof Ast.OprApp) {
// Infix chains are handled one level at a time. Each application may have at most 2 arguments.
return {
kind: 'infix',
appTree: callRoot,
operator: callRoot.operator.ok ? callRoot.operator.value : undefined,
lhs: callRoot.lhs ?? undefined,
rhs: callRoot.rhs ?? undefined,
}
} else {
// Prefix chains are handled all at once, as they may have arbitrary number of arguments.
const foundApplications: FoundApplication[] = []
let nextApplication = callRoot
// Traverse the AST and find all arguments applied in sequence to the same function.
while (nextApplication instanceof Ast.App) {
foundApplications.push({
appTree: nextApplication,
argument: nextApplication.argument,
argName: nextApplication.argumentName?.code() ?? undefined,
})
nextApplication = nextApplication.function
}
return {
kind: 'prefix',
func: nextApplication,
// The applications are peeled away from outer to inner, so arguments are in reverse order. We
// need to reverse them back to match them with the order in suggestion entry.
args: foundApplications.reverse(),
}
}
}
interface CallInfo {
noArgsCall?: MethodCall | undefined
appMethodCall?: MethodCall | undefined
suggestion?: SuggestionEntry | undefined
widgetCfg?: widgetCfg.FunctionCall | undefined
}
export class ArgumentApplication {
private constructor(
public appTree: Ast.Ast,
public target: ArgumentApplication | Ast.Ast | ArgumentPlaceholder | ArgumentAst,
public infixOperator: Ast.Token | undefined,
public argument: Ast.Ast | ArgumentAst | ArgumentPlaceholder,
public argument: ArgumentAst | ArgumentPlaceholder,
) {}
static Interpret(callRoot: Ast.Ast, allowInterpretAsInfix: boolean): InterpretedCall {
if (allowInterpretAsInfix && callRoot instanceof Ast.OprApp) {
// Infix chains are handled one level at a time. Each application may have at most 2 arguments.
return {
kind: 'infix',
appTree: callRoot,
operator: callRoot.operator.ok ? callRoot.operator.value : undefined,
lhs: callRoot.lhs ?? undefined,
rhs: callRoot.rhs ?? undefined,
}
} else {
// Prefix chains are handled all at once, as they may have arbitrary number of arguments.
const foundApplications: FoundApplication[] = []
let nextApplication = callRoot
// Traverse the AST and find all arguments applied in sequence to the same function.
while (nextApplication instanceof Ast.App) {
foundApplications.push({
appTree: nextApplication,
argument: nextApplication.argument,
argName: nextApplication.argumentName?.code() ?? undefined,
})
nextApplication = nextApplication.function
}
return {
kind: 'prefix',
func: nextApplication,
// The applications are peeled away from outer to inner, so arguments are in reverse order. We
// need to reverse them back to match them with the order in suggestion entry.
args: foundApplications.reverse(),
}
private static FromInterpretedInfix(interpreted: InterpretedInfix, callInfo: CallInfo) {
// Access infixes are not real infix calls.
if (isAccessOperator(interpreted.operator)) return interpreted.appTree
const { suggestion, widgetCfg } = callInfo
const kind = ApplicationKind.Infix
const callId = interpreted.appTree.exprId
const argFor = (key: 'lhs' | 'rhs', index: number) => {
const tree = interpreted[key]
const info = tryGetIndex(suggestion?.arguments, index) ?? unknownArgInfoNamed(key)
return tree != null
? ArgumentAst.WithRetrievedConfig(tree, index, info, kind, widgetCfg)
: ArgumentPlaceholder.WithRetrievedConfig(callId, index, info, kind, false, widgetCfg)
}
return new ArgumentApplication(
interpreted.appTree,
argFor('lhs', 0),
interpreted.operator,
argFor('rhs', 1),
)
}
static FromInterpretedWithInfo(
interpreted: InterpretedCall,
noArgsCall: MethodCall | undefined,
appMethodCall: MethodCall | undefined,
suggestion: SuggestionEntry | undefined,
stripSelfArgument: boolean = false,
): ArgumentApplication | Ast.Ast {
private static FromInterpretedPrefix(
interpreted: InterpretedPrefix,
callInfo: CallInfo,
stripSelfArgument: boolean,
) {
const { noArgsCall, appMethodCall, suggestion, widgetCfg } = callInfo
const kind = ApplicationKind.Prefix
const callId = interpreted.func.exprId
const knownArguments = suggestion?.arguments
const callId =
interpreted.kind === 'infix' ? interpreted.appTree.exprId : interpreted.func.exprId
if (interpreted.kind === 'infix') {
const isAccess = isAccessOperator(interpreted.operator)
const argFor = (key: 'lhs' | 'rhs', index: number) => {
const tree = interpreted[key]
const info = tryGetIndex(knownArguments, index) ?? unknownArgInfoNamed(key)
return tree != null
? isAccess
? tree
: new ArgumentAst(tree, index, info, ApplicationKind.Infix)
: new ArgumentPlaceholder(callId, index, info, ApplicationKind.Infix, false)
}
return new ArgumentApplication(
interpreted.appTree,
argFor('lhs', 0),
interpreted.operator,
argFor('rhs', 1),
)
}
const notAppliedArguments = appMethodCall?.notAppliedArguments ?? []
const placeholdersToInsert = notAppliedArguments.slice()
const notAppliedSet = new Set(notAppliedArguments)
@ -168,12 +221,13 @@ export class ArgumentApplication {
if (argIndex != null && argInfo != null) {
prefixArgsToDisplay.push({
appTree,
argument: new ArgumentPlaceholder(
argument: ArgumentPlaceholder.WithRetrievedConfig(
callId,
argIndex,
argInfo,
ApplicationKind.Prefix,
!canInsertPositional,
widgetCfg,
),
})
@ -188,13 +242,12 @@ export class ArgumentApplication {
if (argIndex != null) insertPlaceholdersUpto(argIndex, realArg.appTree.function)
prefixArgsToDisplay.push({
appTree: realArg.appTree,
argument: new ArgumentAst(
argument: ArgumentAst.WithRetrievedConfig(
realArg.argument,
argIndex,
// If we have more arguments applied than we know about, display that argument anyway, but
// mark it as unknown.
tryGetIndex(knownArguments, argIndex),
ApplicationKind.Prefix,
kind,
widgetCfg,
),
})
} else {
@ -203,15 +256,17 @@ export class ArgumentApplication {
const name = realArg.argName
const foundIdx = argumentsLeftToMatch.findIndex((i) => knownArguments?.[i]?.name === name)
const argIndex = foundIdx === -1 ? undefined : argumentsLeftToMatch.splice(foundIdx, 1)[0]
if (argIndex != null && foundIdx === 0)
insertPlaceholdersUpto(argIndex, realArg.appTree.function)
prefixArgsToDisplay.push({
appTree: realArg.appTree,
argument: new ArgumentAst(
argument: ArgumentAst.WithRetrievedConfig(
realArg.argument,
argIndex,
tryGetIndex(knownArguments, argIndex) ?? unknownArgInfoNamed(name),
ApplicationKind.Prefix,
kind,
widgetCfg,
),
})
}
@ -227,6 +282,18 @@ export class ArgumentApplication {
)
}
static FromInterpretedWithInfo(
interpreted: InterpretedCall,
callInfo: CallInfo = {},
stripSelfArgument: boolean = false,
): ArgumentApplication | Ast.Ast {
if (interpreted.kind === 'infix') {
return ArgumentApplication.FromInterpretedInfix(interpreted, callInfo)
} else {
return ArgumentApplication.FromInterpretedPrefix(interpreted, callInfo, stripSelfArgument)
}
}
*iterApplications(): IterableIterator<ArgumentApplication> {
let current: typeof this.target = this
while (current instanceof ArgumentApplication) {
@ -238,12 +305,12 @@ export class ArgumentApplication {
const unknownArgInfoNamed = (name: string) => ({
name,
type: 'Any',
reprType: 'Any',
isSuspended: false,
hasDefault: false,
})
function getAccessOprSubject(app: Ast.Ast): Ast.Ast | undefined {
export function getAccessOprSubject(app: Ast.Ast): Ast.Ast | undefined {
if (app instanceof Ast.PropertyAccess) return app.lhs ?? undefined
}
@ -252,8 +319,8 @@ function isAccessOperator(opr: Ast.Token | undefined): boolean {
}
declare const ArgumentApplicationKey: unique symbol
declare const ArgumentPlaceholderKey: unique symbol
declare const ArgumentAstKey: unique symbol
declare const ArgumentPlaceholderKey: unique symbol
declare module '@/providers/widgetRegistry' {
export interface WidgetInputTypes {
[ArgumentApplicationKey]: ArgumentApplication

View File

@ -451,7 +451,7 @@ interface SuggestionEntryArgument {
/** The argument name. */
name: string;
/** The argument type. String 'Any' is used to specify generic types. */
type: string;
reprType: string;
/** Indicates whether the argument is lazy. */
isSuspended: boolean;
/** Indicates whether the argument has default value. */