From 30b34e615f604fe6c64e2827cbf9eb3c8376bed3 Mon Sep 17 00:00:00 2001 From: "Amy J. Ko" Date: Sat, 29 Jun 2024 18:21:40 -0700 Subject: [PATCH] Fixwd #455. Replaced `Bind`s with `Input`'s in `Evaluate` to prevent invalid bind metadata in evaluations. --- CHANGELOG.md | 1 + src/basis/Basis.test.ts | 7 +- src/components/annotations/Annotations.svelte | 3 +- src/components/editor/InputView.svelte | 12 ++ src/components/editor/Menu.svelte | 13 +- src/components/editor/util/nodeToView.ts | 3 + src/components/palette/editOutput.ts | 3 +- .../project/SourceTileToggle.svelte | 3 +- src/concepts/Templates.ts | 3 + src/conflicts/Conflict.ts | 2 +- src/conflicts/MisplacedInput.ts | 44 ----- src/conflicts/UnexpectedInput.ts | 18 +- src/conflicts/UnknownInput.ts | 16 +- src/conflicts/UnparsableConflict.ts | 10 +- src/edit/Autocomplete.ts | 2 + src/edit/OutputExpression.ts | 3 +- src/examples/examples.test.ts | 3 +- src/locale/NodeTexts.ts | 5 +- src/locale/en-US.json | 17 +- src/models/Project.ts | 3 +- src/nodes/Bind.ts | 25 --- src/nodes/Context.ts | 5 - src/nodes/Evaluate.test.ts | 6 +- src/nodes/Evaluate.ts | 50 +++--- src/nodes/ExpressionPlaceholder.ts | 24 ++- src/nodes/Input.ts | 156 ++++++++++++++++++ src/parser/Parser.test.ts | 3 +- src/parser/Tokens.ts | 5 + src/parser/parseExpression.ts | 14 +- static/locales/es-MX/es-MX.json | 8 +- static/locales/example/example.json | 8 +- static/locales/ko-KR/ko-KR.json | 8 +- static/locales/zh-CN/zh-CN.json | 8 +- static/locales/zh-TW/zh-TW.json | 8 +- static/schemas/Locale.json | 61 ++++++- static/schemas/Tutorial.json | 1 + 36 files changed, 398 insertions(+), 163 deletions(-) create mode 100644 src/components/editor/InputView.svelte delete mode 100644 src/conflicts/MisplacedInput.ts create mode 100644 src/nodes/Input.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aa869a58c..310c8d6e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Dates are in `YYYY-MM-DD` format and versions are in [semantic versioning](http: - [#504](https://github.com/wordplaydev/wordplay/issues/504). Account for non-fixed-width characters in caret positioning. - [#488](https://github.com/wordplaydev/wordplay/issues/488). Added animations off indicator on stage. - [#500](https://github.com/wordplaydev/wordplay/issues/500). Improved explanation when there's a space between an evaluation's name and inputs. +- [#455](https://github.com/wordplaydev/wordplay/issues/455). Replaced `Bind`s with `Input`'s in `Evaluate` to prevent invalid bind metadata in evaluations. ### Maintenance diff --git a/src/basis/Basis.test.ts b/src/basis/Basis.test.ts index 8061cf233..a99c51d25 100644 --- a/src/basis/Basis.test.ts +++ b/src/basis/Basis.test.ts @@ -9,6 +9,7 @@ import Project from '../models/Project'; import Example from '../nodes/Example'; import { Basis } from './Basis'; import DefaultLocale, { DefaultLocales } from '../locale/DefaultLocale'; +import Templates from '@concepts/Templates'; const basis = Basis.getLocalizedBasis(DefaultLocales); @@ -43,7 +44,9 @@ function checkBasisNodes(node: Node) { !(conflict instanceof UnusedBind) && !context .getRoot(node) - ?.getAncestors(conflict.getConflictingNodes().primary.node) + ?.getAncestors( + conflict.getConflictingNodes(Templates).primary.node, + ) .some((n) => n instanceof Example), ); @@ -52,7 +55,7 @@ function checkBasisNodes(node: Node) { conflicts .map((c) => c - .getConflictingNodes() + .getConflictingNodes(Templates) .primary.explanation(DefaultLocales, context) .toText(), ) diff --git a/src/components/annotations/Annotations.svelte b/src/components/annotations/Annotations.svelte index 2553ce9b7..b604f60dc 100644 --- a/src/components/annotations/Annotations.svelte +++ b/src/components/annotations/Annotations.svelte @@ -41,6 +41,7 @@ import Context from '@nodes/Context'; import CommandButton from '@components/widgets/CommandButton.svelte'; import Expander from '@components/widgets/Expander.svelte'; + import Templates from '@concepts/Templates'; /** The project for which annotations should be shown */ export let project: Project; @@ -138,7 +139,7 @@ // Conflict all of the active conflicts to a list of annotations. annotations = conflicts .map((conflict: Conflict) => { - const nodes = conflict.getConflictingNodes(); + const nodes = conflict.getConflictingNodes(Templates); const primary = nodes.primary; const secondary = nodes.secondary; // Based on the primary and secondary nodes given, decide what to show. diff --git a/src/components/editor/InputView.svelte b/src/components/editor/InputView.svelte new file mode 100644 index 000000000..9d522b0ff --- /dev/null +++ b/src/components/editor/InputView.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/src/components/editor/Menu.svelte b/src/components/editor/Menu.svelte index db6797d7c..1d24c4241 100644 --- a/src/components/editor/Menu.svelte +++ b/src/components/editor/Menu.svelte @@ -13,6 +13,7 @@ import Token from '../../nodes/Token'; import Bind from '../../nodes/Bind'; import Evaluate from '../../nodes/Evaluate'; + import Input from '@nodes/Input'; export let menu: Menu; /* What to run when hiding the menu */ @@ -48,13 +49,13 @@ let evaluateBind: Bind | undefined; $: if ( selectedRevision instanceof Revision && - newNode instanceof Bind && + newNode instanceof Input && newParent instanceof Evaluate ) { const fun = newParent.getFunction(selectedRevision.context); evaluateBind = fun?.inputs.find( (input) => - newNode instanceof Bind && input.hasName(newNode.getNames()[0]) + newNode instanceof Input && input.hasName(newNode.getName()), ); } $: selectedConcept = @@ -108,11 +109,11 @@ .some( (node) => node instanceof Token && - node.getText().startsWith(event.key) + node.getText().startsWith(event.key), ) : $locales .get((l) => l.term[revision.purpose]) - .startsWith(event.key) + .startsWith(event.key), ); if (match) menu = menu.inSubmenu() @@ -194,8 +195,8 @@ `/${$locales.get((l) => entry instanceof RevisionSet ? l.term[entry.purpose] - : '' - )}…/` + : '', + )}…/`, )} /> {/if} diff --git a/src/components/editor/util/nodeToView.ts b/src/components/editor/util/nodeToView.ts index 69bf601e7..f0774c212 100644 --- a/src/components/editor/util/nodeToView.ts +++ b/src/components/editor/util/nodeToView.ts @@ -84,6 +84,7 @@ import FormattedTranslationView from '../FormattedTranslationView.svelte'; import IsLocaleView from '../IsLocaleView.svelte'; import SpreadView from '../SpreadView.svelte'; import MatchView from '../MatchView.svelte'; +import InputView from '../InputView.svelte'; import type Node from '@nodes/Node'; import Program from '@nodes/Program'; @@ -172,6 +173,7 @@ import Spread from '@nodes/Spread'; import NoneOrView from '../OtherwiseView.svelte'; import Otherwise from '@nodes/Otherwise'; import Match from '@nodes/Match'; +import Input from '@nodes/Input'; const nodeToView = new Map>(); @@ -217,6 +219,7 @@ nodeToView.set(TextType, TextTypeView); nodeToView.set(FunctionDefinition, FunctionDefinitionView); nodeToView.set(FunctionType, FunctionTypeView); nodeToView.set(Evaluate, EvaluateView); +nodeToView.set(Input, InputView); nodeToView.set(ExpressionPlaceholder, ExpressionPlaceholderView); nodeToView.set(BinaryEvaluate, BinaryEvaluateView); diff --git a/src/components/palette/editOutput.ts b/src/components/palette/editOutput.ts index ee8a9bf4b..57badaf68 100644 --- a/src/components/palette/editOutput.ts +++ b/src/components/palette/editOutput.ts @@ -22,12 +22,13 @@ import { toExpression } from '../../parser/parseExpression'; import { getPlaceExpression } from '../../output/getOrCreatePlace'; import type Spread from '../../nodes/Spread'; import type Locales from '../../locale/Locales'; +import Input from '@nodes/Input'; export function getNumber(given: Expression): number | undefined { const measurement = given instanceof NumberLiteral ? given - : given instanceof Bind && given.value instanceof NumberLiteral + : given instanceof Input && given.value instanceof NumberLiteral ? given.value : given instanceof UnaryEvaluate && given.isNegation() && diff --git a/src/components/project/SourceTileToggle.svelte b/src/components/project/SourceTileToggle.svelte index 4e5a0c0a5..85b1f694f 100644 --- a/src/components/project/SourceTileToggle.svelte +++ b/src/components/project/SourceTileToggle.svelte @@ -6,6 +6,7 @@ import Toggle from '../widgets/Toggle.svelte'; import { locales } from '../../db/Database'; import Emoji from '@components/app/Emoji.svelte'; + import Templates from '@concepts/Templates'; export let source: Source; export let expanded: boolean; @@ -22,7 +23,7 @@ secondaryCount = 0; if ($conflicts) { for (const conflict of $conflicts) { - const nodes = conflict.getConflictingNodes(); + const nodes = conflict.getConflictingNodes(Templates); if (source.has(nodes.primary.node)) { if (!conflict.isMinor()) primaryCount++; else secondaryCount++; diff --git a/src/concepts/Templates.ts b/src/concepts/Templates.ts index c3bd71cdc..19a1be2e9 100644 --- a/src/concepts/Templates.ts +++ b/src/concepts/Templates.ts @@ -76,11 +76,14 @@ import Borrow from '../nodes/Borrow'; import Otherwise from '@nodes/Otherwise'; import Match from '@nodes/Match'; import Spread from '@nodes/Spread'; +import Input from '@nodes/Input'; /** These are ordered by appearance in the docs. */ const Templates: Node[] = [ // Evaluation Evaluate.make(ExpressionPlaceholder.make(), []), + Input.make('_', ExpressionPlaceholder.make()), + FunctionDefinition.make( undefined, Names.make(['_']), diff --git a/src/conflicts/Conflict.ts b/src/conflicts/Conflict.ts index 294d20a3b..541a408fe 100644 --- a/src/conflicts/Conflict.ts +++ b/src/conflicts/Conflict.ts @@ -28,7 +28,7 @@ export default abstract class Conflict { * and "secondary" ones, which are involved. We use this distiction in the editor to decide what to highlight, * but also how to position the various parties involved in the visual portrayal of the conflict. */ - abstract getConflictingNodes(): { + abstract getConflictingNodes(concepts: Node[]): { primary: ConflictingNode; secondary?: ConflictingNode; resolutions?: Resolution[]; diff --git a/src/conflicts/MisplacedInput.ts b/src/conflicts/MisplacedInput.ts deleted file mode 100644 index c1080156a..000000000 --- a/src/conflicts/MisplacedInput.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type Evaluate from '@nodes/Evaluate'; -import Conflict from './Conflict'; -import type Bind from '@nodes/Bind'; -import type FunctionDefinition from '@nodes/FunctionDefinition'; -import type StructureDefinition from '@nodes/StructureDefinition'; -import type StreamDefinition from '../nodes/StreamDefinition'; -import concretize from '../locale/concretize'; -import type Locales from '../locale/Locales'; - -export default class MisplacedInput extends Conflict { - readonly func: FunctionDefinition | StructureDefinition | StreamDefinition; - readonly evaluate: Evaluate; - readonly expected: Bind; - readonly given: Bind; - - constructor( - func: FunctionDefinition | StructureDefinition | StreamDefinition, - evaluate: Evaluate, - expected: Bind, - given: Bind - ) { - super(false); - - this.func = func; - this.evaluate = evaluate; - this.expected = expected; - this.given = given; - } - - getConflictingNodes() { - return { - primary: { - node: this.evaluate, - explanation: (locales: Locales) => - concretize( - locales, - locales.get( - (l) => l.node.Evaluate.conflict.MisplacedInput - ) - ), - }, - }; - } -} diff --git a/src/conflicts/UnexpectedInput.ts b/src/conflicts/UnexpectedInput.ts index 0ef7e7e8b..124850bcf 100644 --- a/src/conflicts/UnexpectedInput.ts +++ b/src/conflicts/UnexpectedInput.ts @@ -1,7 +1,6 @@ import type Evaluate from '@nodes/Evaluate'; import Conflict from './Conflict'; import type Expression from '@nodes/Expression'; -import type Bind from '@nodes/Bind'; import type BinaryEvaluate from '@nodes/BinaryEvaluate'; import type StructureDefinition from '@nodes/StructureDefinition'; import type FunctionDefinition from '@nodes/FunctionDefinition'; @@ -14,12 +13,12 @@ import type Locales from '../locale/Locales'; export default class UnexpectedInputs extends Conflict { readonly func: FunctionDefinition | StructureDefinition | StreamDefinition; readonly evaluate: Evaluate | BinaryEvaluate; - readonly input: Expression | Bind; + readonly input: Expression; constructor( func: FunctionDefinition | StructureDefinition | StreamDefinition, evaluate: Evaluate | BinaryEvaluate, - input: Expression | Bind + input: Expression, ) { super(false); this.func = func; @@ -30,28 +29,29 @@ export default class UnexpectedInputs extends Conflict { getConflictingNodes() { return { primary: { - node: this.evaluate, + node: this.input, explanation: (locales: Locales, context: Context) => concretize( locales, locales.get( (l) => - l.node.Evaluate.conflict.UnexpectedInput.primary + l.node.Evaluate.conflict.UnexpectedInput + .primary, ), - new NodeRef(this.input, locales, context) + new NodeRef(this.input, locales, context), ), }, secondary: { - node: this.input, + node: this.func.names, explanation: (locales: Locales, context: Context) => concretize( locales, locales.get( (l) => l.node.Evaluate.conflict.UnexpectedInput - .secondary + .secondary, ), - new NodeRef(this.input, locales, context) + new NodeRef(this.input, locales, context), ), }, }; diff --git a/src/conflicts/UnknownInput.ts b/src/conflicts/UnknownInput.ts index c1b9d8ef0..07eb2db49 100644 --- a/src/conflicts/UnknownInput.ts +++ b/src/conflicts/UnknownInput.ts @@ -1,22 +1,24 @@ import type Evaluate from '@nodes/Evaluate'; import Conflict from './Conflict'; -import type Bind from '@nodes/Bind'; import type StructureDefinition from '@nodes/StructureDefinition'; import type FunctionDefinition from '@nodes/FunctionDefinition'; import type StreamDefinition from '../nodes/StreamDefinition'; import concretize from '../locale/concretize'; import type BinaryEvaluate from '../nodes/BinaryEvaluate'; import type Locales from '../locale/Locales'; +import type Input from '@nodes/Input'; +import NodeRef from '@locale/NodeRef'; +import Context from '@nodes/Context'; export default class UnknownInput extends Conflict { readonly func: FunctionDefinition | StructureDefinition | StreamDefinition; readonly evaluate: Evaluate | BinaryEvaluate; - readonly given: Bind; + readonly given: Input; constructor( func: FunctionDefinition | StructureDefinition | StreamDefinition, evaluate: Evaluate | BinaryEvaluate, - given: Bind, + given: Input, ) { super(false); @@ -28,18 +30,19 @@ export default class UnknownInput extends Conflict { getConflictingNodes() { return { primary: { - node: this.given.names, - explanation: (locales: Locales) => + node: this.given.name, + explanation: (locales: Locales, context: Context) => concretize( locales, locales.get( (l) => l.node.Evaluate.conflict.UnknownInput.primary, ), + new NodeRef(this.func, locales, context), ), }, secondary: { - node: this.given.names, + node: this.func.names, explanation: (locales: Locales) => concretize( locales, @@ -47,6 +50,7 @@ export default class UnknownInput extends Conflict { (l) => l.node.Evaluate.conflict.UnknownInput.secondary, ), + this.given.name.getText(), ), }, }; diff --git a/src/conflicts/UnparsableConflict.ts b/src/conflicts/UnparsableConflict.ts index 16b3e14d8..297698a29 100644 --- a/src/conflicts/UnparsableConflict.ts +++ b/src/conflicts/UnparsableConflict.ts @@ -10,6 +10,7 @@ import { toTokens } from '@parser/toTokens'; import { Any, IsA } from '@nodes/Node'; import Token from '@nodes/Token'; import NodeRef from '@locale/NodeRef'; +import type Node from '@nodes/Node'; export class UnparsableConflict extends Conflict { readonly unparsable: UnparsableType | UnparsableExpression; @@ -24,7 +25,7 @@ export class UnparsableConflict extends Conflict { this.context = context; } - getConflictingNodes() { + getConflictingNodes(nodes: Node[]) { return { primary: { node: this.unparsable, @@ -39,11 +40,11 @@ export class UnparsableConflict extends Conflict { this.unparsable instanceof UnparsableExpression, ), }, - resolutions: this.getLikelyIntensions(), + resolutions: this.getLikelyIntentions(nodes), }; } - getLikelyIntensions(): Resolution[] { + getLikelyIntentions(templates: Node[]): Resolution[] { // Construct a set of tokens that weren't parseable so that we can find overlaps between this and possible templates. const unparsableTokens = new Set( this.unparsable.unparsables.map((t) => t.toWordplay()), @@ -51,8 +52,7 @@ export class UnparsableConflict extends Conflict { // Scan through templates of possible expressions in the language, scoring them by number of overlapping tokens. return ( - this.context - .getTemplates() + templates // Only consider expressions .filter( (template): template is Expression => diff --git a/src/edit/Autocomplete.ts b/src/edit/Autocomplete.ts index f1971b6df..a111f7561 100644 --- a/src/edit/Autocomplete.ts +++ b/src/edit/Autocomplete.ts @@ -75,6 +75,7 @@ import Changed from '../nodes/Changed'; import type Locales from '../locale/Locales'; import Otherwise from '@nodes/Otherwise'; import Match from '@nodes/Match'; +import Input from '@nodes/Input'; /** A logging flag, helpful for analyzing the control flow of autocomplete when debugging. */ const LOG = false; @@ -570,6 +571,7 @@ const PossibleNodes = [ BinaryEvaluate, UnaryEvaluate, Evaluate, + Input, Convert, Insert, Select, diff --git a/src/edit/OutputExpression.ts b/src/edit/OutputExpression.ts index ecacc3795..06a0102b0 100644 --- a/src/edit/OutputExpression.ts +++ b/src/edit/OutputExpression.ts @@ -13,6 +13,7 @@ import getGroupProperties from './GroupProperties'; import getPhraseProperties from './PhraseProperties'; import getShapeProperties from './getShapeProperties'; import type Locales from '../locale/Locales'; +import Input from '@nodes/Input'; /** * Represents the value of a property. If given is true, it means its set explicitly. @@ -119,7 +120,7 @@ export default class OutputExpression { const expression = binding.given === undefined ? binding.expected.value - : binding.given instanceof Bind + : binding.given instanceof Input ? binding.given.value : binding.given; diff --git a/src/examples/examples.test.ts b/src/examples/examples.test.ts index 067692ad6..b16ab722a 100644 --- a/src/examples/examples.test.ts +++ b/src/examples/examples.test.ts @@ -13,6 +13,7 @@ import { SupportedLocales, getLocaleLanguage } from '@locale/Locale'; import type LanguageCode from '@locale/LanguageCode'; import Names from '@nodes/Names'; import Evaluate from '@nodes/Evaluate'; +import Templates from '@concepts/Templates'; function readProjects(dir: string): SerializedProject[] { const proj: SerializedProject[] = []; @@ -49,7 +50,7 @@ test.each([...projects, ...templates])( ).flat(); const messages: string[] = []; for (const conflict of conflicts) { - const conflictingNodes = conflict.getConflictingNodes(); + const conflictingNodes = conflict.getConflictingNodes(Templates); messages.push( conflictingNodes.primary .explanation(DefaultLocales, context) diff --git a/src/locale/NodeTexts.ts b/src/locale/NodeTexts.ts index c659d0eea..8b054a7ab 100644 --- a/src/locale/NodeTexts.ts +++ b/src/locale/NodeTexts.ts @@ -342,10 +342,6 @@ type NodeTexts = { * Description inputs: $1 = definition given, $2: type given * */ UnexpectedTypeInput: ConflictText; - /** - * When an input is given, but in the wrong order. - */ - MisplacedInput: InternalConflictText; /** * When an input is expected, but not given. * Description inputs: $1 = missing input, $2: evaluate that is missing input @@ -380,6 +376,7 @@ type NodeTexts = { */ FunctionException: ExceptionText; }>; + Input: DescriptiveNodeText & SimpleExpressionText; /** * An expression placeholder, e.g., `1 + _` * Description inputs: $1: type or undefined diff --git a/src/locale/en-US.json b/src/locale/en-US.json index aaecb2888..fef7f097c 100644 --- a/src/locale/en-US.json +++ b/src/locale/en-US.json @@ -610,7 +610,6 @@ "primary": "I wasn't expecting this type input", "secondary": "oh, am I not supposed to be here?" }, - "MisplacedInput": "this input is out of order.", "MissingInput": { "primary": "I'm missing $1, can you add it?", "secondary": "this input is required, but $1 didn't provide it" @@ -621,8 +620,8 @@ "secondary": "Oh, am I not supposed to be here?" }, "UnknownInput": { - "primary": "I don't know of an input by this name", - "secondary": "I don't think I belong here" + "primary": "I don't know of an input by this name in $1", + "secondary": "I don't have an input with the name $1" }, "InputListMustBeLast": "list of inputs must be last", "SeparatedEvaluate": "Is $1 the name of a $2[$structure|$function] you're trying to evaluate? Try removing the space after me, so I know it's an @Evaluate and not a separate @Block." @@ -634,6 +633,18 @@ } } }, + "Input": { + "name": "Input", + "description": "named input", + "emotion": "serious", + "doc": [ + "I'm an input given to an @Evaluate. My name corresponds to the name of input in the @FunctionDefinition or @StructureDefinition being evaluated.", + "I'm helpful with functions that have many default values, where you just want to override a specific input, without giving everything else.", + "For example, @Phrase has many, many default values to control it's style. Let's say you wanted to give some @Text and a @Color, but nothing else in its input list. You can use me to do that:", + "\\Phrase('I am purple!' color: Color(50% 52 300°))\\" + ], + "start": "Let's evaluate my value." + }, "ExpressionPlaceholder": { "name": "placeholder", "description": "$1[$1|placeholder]", diff --git a/src/models/Project.ts b/src/models/Project.ts index 159f770a1..fdda03bcc 100644 --- a/src/models/Project.ts +++ b/src/models/Project.ts @@ -42,6 +42,7 @@ import { import Name from '@nodes/Name'; import Doc from '@nodes/Doc'; import type Definition from '@nodes/Definition'; +import Templates from '@concepts/Templates'; /** * How we store projects in memory, mirroring the data in the deserialized form. @@ -311,7 +312,7 @@ export default class Project { // Build conflict indices by going through each conflict, asking for the conflicting nodes // and adding to the conflict to each node's list of conflicts. for (const conflict of this.analysis.conflicts) { - const complicitNodes = conflict.getConflictingNodes(); + const complicitNodes = conflict.getConflictingNodes(Templates); this.analysis.primary.set(complicitNodes.primary.node, [ ...(this.analysis.primary.get( complicitNodes.primary.node, diff --git a/src/nodes/Bind.ts b/src/nodes/Bind.ts index 513b2e802..36a7e9fac 100644 --- a/src/nodes/Bind.ts +++ b/src/nodes/Bind.ts @@ -44,7 +44,6 @@ import concretize from '../locale/concretize'; import getConcreteExpectedType from './Generics'; import type Node from './Node'; import ExpressionPlaceholder from './ExpressionPlaceholder'; -import Refer from '../edit/Refer'; import UnknownType from './UnknownType'; import type Locales from '../locale/Locales'; import DocumentedExpression from './DocumentedExpression'; @@ -139,30 +138,6 @@ export default class Bind extends Expression { ExpressionPlaceholder.make(), ), ]; - } - // Evaluate, and the anchor is the open or an input? Offer binds to unset properties. - else if ( - parent instanceof Evaluate && - (anchor === parent.open || - (anchor instanceof Expression && - parent.inputs.includes(anchor))) - ) { - const mapping = parent.getInputMapping(context); - return mapping?.inputs - .filter((input) => input.given === undefined) - .map( - (input) => - new Refer( - (name) => - Bind.make( - undefined, - Names.make([name]), - undefined, - ExpressionPlaceholder.make(), - ), - input.expected, - ), - ); } else return []; } } diff --git a/src/nodes/Context.ts b/src/nodes/Context.ts index 74ddf152c..55ad1450f 100644 --- a/src/nodes/Context.ts +++ b/src/nodes/Context.ts @@ -8,7 +8,6 @@ import type Reference from './Reference'; import type PropertyReference from './PropertyReference'; import type Definition from './Definition'; import type StreamDefinition from './StreamDefinition'; -import Templates from '@concepts/Templates'; /** Passed around during type inference and conflict detection to facilitate program analysis and cycle-detection. */ export default class Context { @@ -104,8 +103,4 @@ export default class Context { getStreamType(type: Type): StreamDefinition | undefined { return this.streamTypes.get(type); } - - getTemplates() { - return Templates; - } } diff --git a/src/nodes/Evaluate.test.ts b/src/nodes/Evaluate.test.ts index aceb1ae78..311c896d7 100644 --- a/src/nodes/Evaluate.test.ts +++ b/src/nodes/Evaluate.test.ts @@ -4,7 +4,6 @@ import IncompatibleInput from '@conflicts/IncompatibleInput'; import NotInstantiable from '@conflicts/NotInstantiable'; import Evaluate from './Evaluate'; import MissingInput from '@conflicts/MissingInput'; -import MisplacedInput from '@conflicts/MisplacedInput'; import NumberType from './NumberType'; import SetType from './SetType'; import MapType from './MapType'; @@ -14,6 +13,7 @@ import type Node from './Node'; import type Conflict from '../conflicts/Conflict'; import evaluateCode from '../runtime/evaluate'; import BinaryEvaluate from './BinaryEvaluate'; +import UnexpectedInput from '@conflicts/UnexpectedInput'; test.each([ [ @@ -44,7 +44,7 @@ test.each([ 'x: ƒ(a•# b•#) a - b\nx(1 2)', 'ƒ x(a•# b•#) a - b\nx(a:1 c:2)', Evaluate, - MisplacedInput, + UnexpectedInput, ], [ 'x: ƒ(a•# b•#) a - b\nx(1 2)', @@ -56,7 +56,7 @@ test.each([ 'x: ƒ(a•# b•#) a - b\nx(1 2)', 'ƒ x(a•# b•#) a - b\nx(a:1 a:2)', Evaluate, - MisplacedInput, + UnexpectedInput, ], [ 'x: ƒ(num…•#) a - b\nx(1 2 3)', diff --git a/src/nodes/Evaluate.ts b/src/nodes/Evaluate.ts index 726ba342d..406482d63 100644 --- a/src/nodes/Evaluate.ts +++ b/src/nodes/Evaluate.ts @@ -1,6 +1,5 @@ import Bind from '@nodes/Bind'; import type Conflict from '@conflicts/Conflict'; -import MisplacedInput from '@conflicts/MisplacedInput'; import MissingInput from '@conflicts/MissingInput'; import UnexpectedInput from '@conflicts/UnexpectedInput'; import IncompatibleInput from '@conflicts/IncompatibleInput'; @@ -64,6 +63,7 @@ import StructureDefinitionType from './StructureDefinitionType'; import Block from './Block'; import Reference from './Reference'; import SeparatedEvaluate from '@conflicts/SeparatedEvaluate'; +import Input from './Input'; type Mapping = { expected: Bind; @@ -320,9 +320,11 @@ export default class Evaluate extends Expression { else { // Is there a named input that matches? const bind = givenInputs.find( - (i) => i instanceof Bind && i.sharesName(expectedInput), + (i) => + i instanceof Input && + expectedInput.hasName(i.getName()), ); - if (bind instanceof Bind) { + if (bind instanceof Input) { // Remove it from the given inputs list. givenInputs.splice(givenInputs.indexOf(bind), 1); mapping.given = bind; @@ -330,7 +332,7 @@ export default class Evaluate extends Expression { // If there wasn't a named input matching, see if the next non-bind expression matches the type. else if ( givenInputs.length > 0 && - !(givenInputs[0] instanceof Bind) + !(givenInputs[0] instanceof Input) ) { mapping.given = givenInputs.shift(); } @@ -356,7 +358,7 @@ export default class Evaluate extends Expression { const mapping = this.getInputMapping(context); const given = mapping?.inputs.find((input) => input.expected === bind) ?.given; - return given instanceof Bind ? given.value : given; + return given instanceof Input ? given.value : given; } getLastInput(): Expression | undefined { @@ -376,10 +378,8 @@ export default class Evaluate extends Expression { const mapping = this.getMappingFor(bind, context); if (mapping === undefined) return this; - // If we'replacing with nothing - // If it's already bound, replace the binding. - if (mapping.given instanceof Bind) { + if (mapping.given instanceof Input) { if (expression === undefined) return this.replace(mapping.given, expression); else if (mapping.given.value) @@ -391,14 +391,7 @@ export default class Evaluate extends Expression { else if (mapping.given === undefined && expression !== undefined) { return this.replace(this.inputs, [ ...this.inputs, - named - ? Bind.make( - undefined, - Names.make([bind.getNames()[0]]), - undefined, - expression, - ) - : expression, + named ? Input.make(bind.getNames()[0], expression) : expression, ]); } @@ -493,9 +486,12 @@ export default class Evaluate extends Expression { ), ]; - // Given a bind with an incompatible name? Conflict. - if (given instanceof Bind && !expected.sharesName(given)) - return [new MisplacedInput(fun, this, expected, given)]; + // Given a named with an incompatible name? Conflict. + if ( + given instanceof Input && + !expected.hasName(given.getName()) + ) + return [new UnexpectedInput(fun, this, given)]; // Concretize the expected type. const expectedType = getConcreteExpectedType( @@ -509,7 +505,7 @@ export default class Evaluate extends Expression { if (given instanceof Expression) { // Don't rely on the bind's specified type, since it's not a reliable source of type information. Ask it's value directly. const givenType = - given instanceof Bind + given instanceof Input ? given.value?.getType(context) ?? new NoExpressionType(given) : given.getType(context); @@ -562,17 +558,15 @@ export default class Evaluate extends Expression { // See if any of the remaining given inputs are bound to unknown names. for (const given of mapping.extra) { if ( - given instanceof Bind && - !fun.inputs.some((expected) => expected.sharesName(given)) - ) + given instanceof Input && + !fun.inputs.some((expected) => + expected.hasName(given.getName()), + ) + ) { conflicts.push(new UnknownInput(fun, this, given)); + } else conflicts.push(new UnexpectedInput(fun, this, given)); } - // If there are remaining given inputs that didn't match anything, something's wrong. - if (mapping.extra.length > 0) - for (const extra of mapping.extra) - conflicts.push(new UnexpectedInput(fun, this, extra)); - // Check type if (!(fun instanceof StreamDefinition)) { const expected = fun.types; diff --git a/src/nodes/ExpressionPlaceholder.ts b/src/nodes/ExpressionPlaceholder.ts index 2b4208ec3..0053434e6 100644 --- a/src/nodes/ExpressionPlaceholder.ts +++ b/src/nodes/ExpressionPlaceholder.ts @@ -29,6 +29,7 @@ import FunctionDefinition from './FunctionDefinition'; import Sym from './Sym'; import Purpose from '../concepts/Purpose'; import type Locales from '../locale/Locales'; +import Input from './Input'; export default class ExpressionPlaceholder extends SimpleExpression { readonly placeholder: Token | undefined; @@ -124,9 +125,21 @@ export default class ExpressionPlaceholder extends SimpleExpression { // Try to infer from surroundings. const parent = context.getRoot(this)?.getParent(this); + const evaluate = + parent instanceof Evaluate + ? parent + : parent instanceof BinaryEvaluate + ? parent + : parent && parent instanceof Input + ? context.getRoot(this)?.getParent(parent) + : undefined; + // In an evaluate? Infer from the function's bind type. - if (parent instanceof Evaluate || parent instanceof BinaryEvaluate) { - const fun = parent.getFunction(context); + if ( + evaluate instanceof Evaluate || + evaluate instanceof BinaryEvaluate + ) { + const fun = evaluate.getFunction(context); if (fun) { const bind = parent instanceof Evaluate @@ -136,7 +149,12 @@ export default class ExpressionPlaceholder extends SimpleExpression { ?.expected : fun.inputs[0]; if (bind) { - return getConcreteExpectedType(fun, bind, parent, context); + return getConcreteExpectedType( + fun, + bind, + evaluate, + context, + ); } } } else if (parent instanceof Bind) return parent.getType(context); diff --git a/src/nodes/Input.ts b/src/nodes/Input.ts new file mode 100644 index 000000000..78fdd7246 --- /dev/null +++ b/src/nodes/Input.ts @@ -0,0 +1,156 @@ +import Purpose from '@concepts/Purpose'; +import type Conflict from '@conflicts/Conflict'; +import type Locales from '@locale/Locales'; +import type { NodeText, DescriptiveNodeText } from '@locale/NodeTexts'; +import type Evaluator from '@runtime/Evaluator'; +import type Step from '@runtime/Step'; +import type Value from '@values/Value'; +import type Glyph from '../lore/Glyph'; +import type Context from './Context'; +import Expression from './Expression'; +import type { GuardContext } from './Expression'; +import type Node from './Node'; +import { node, type Grammar, type Replacement } from './Node'; +import type Type from './Type'; +import type TypeSet from './TypeSet'; +import Token from './Token'; +import Sym from './Sym'; +import BindToken from './BindToken'; +import Glyphs from '../lore/Glyphs'; +import concretize from '@locale/concretize'; +import SimpleExpression from './SimpleExpression'; +import Evaluate from './Evaluate'; +import Refer from '@edit/Refer'; +import ExpressionPlaceholder from './ExpressionPlaceholder'; + +export default class Input extends SimpleExpression { + readonly name: Token; + readonly bind: Token; + readonly value: Expression; + + constructor(name: Token, bind: Token, value: Expression) { + super(); + this.name = name; + this.bind = bind; + this.value = value; + } + + getDescriptor() { + return 'Input'; + } + + static make(name: Token | string, value: Expression) { + return new Input( + typeof name === 'string' ? new Token(name, Sym.Name) : name, + new BindToken(), + value, + ); + } + + static getPossibleNodes( + expectedType: Type | undefined, + anchor: Node, + isBeingReplaced: boolean, + context: Context, + ) { + const parent = anchor.getParent(context); + // Evaluate, and the anchor is the open or an input? Offer binds to unset properties. + if ( + parent instanceof Evaluate && + (anchor === parent.open || + (anchor instanceof Expression && + parent.inputs.includes(anchor))) + ) { + const mapping = parent.getInputMapping(context); + return mapping?.inputs + .filter((input) => input.given === undefined) + .map( + (input) => + new Refer( + (name) => + Input.make(name, ExpressionPlaceholder.make()), + input.expected, + ), + ); + } else return []; + } + + getGrammar(): Grammar { + return [ + { + name: 'name', + kind: node(Sym.Name), + }, + { + name: 'bind', + kind: node(Sym.Bind), + }, + { + name: 'value', + kind: node(Expression), + space: true, + }, + ]; + } + + clone(replace?: Replacement) { + return new Input( + this.replaceChild('name', this.name, replace), + this.replaceChild('bind', this.bind, replace), + this.replaceChild('value', this.value, replace), + ) as this; + } + + getPurpose() { + return Purpose.Evaluate; + } + + computeType(context: Context): Type { + return this.value.getType(context); + } + + getDependencies(): Expression[] { + return [this.value]; + } + + evaluateTypeGuards(current: TypeSet, guard: GuardContext): TypeSet { + return this.value.evaluateTypeGuards(current, guard); + } + + getName() { + return this.name.getText(); + } + + computeConflicts(): void | Conflict[] {} + + compile(evaluator: Evaluator, context: Context): Step[] { + return this.value.compile(evaluator, context); + } + + evaluate(evaluator: Evaluator, prior: Value | undefined): Value { + return prior ?? evaluator.popValue(this); + } + + getStart(): Node { + return this.name; + } + + getFinish(): Node { + return this.bind; + } + + getStartExplanations(locales: Locales) { + return concretize( + locales, + locales.get((l) => l.node.Input.start), + ); + } + + getGlyphs(): Glyph { + return Glyphs.Bind; + } + + getNodeLocale(locales: Locales): NodeText | DescriptiveNodeText { + return locales.get((l) => l.node.Input); + } +} diff --git a/src/parser/Parser.test.ts b/src/parser/Parser.test.ts index dd54518a4..297c0117a 100644 --- a/src/parser/Parser.test.ts +++ b/src/parser/Parser.test.ts @@ -70,6 +70,7 @@ import parseDoc from './parseDoc'; import { parseBlock } from './parseExpression'; import Otherwise from '@nodes/Otherwise'; import getPreferredSpaces from './getPreferredSpaces'; +import Input from '@nodes/Input'; test('Parse programs', () => { expect(toProgram('')).toBeInstanceOf(Program); @@ -176,7 +177,7 @@ test.each([ ['ƒ⸨T⸩(a: T b: T) a + b', FunctionDefinition, 'types', TypeVariables], ['a()', Evaluate, 'fun', Reference, 'a'], ['a(1 2)', Evaluate, 'inputs', Array, 2], - ['a(b:2 c:2)', Evaluate, 'inputs', Array, Bind], + ['a(b:2 c:2)', Evaluate, 'inputs', Array, Input], ['a⸨Cat⸩(b c)', Evaluate, 'types', TypeInputs], ['a⸨Cat #⸩(b c)', Evaluate, 'types', TypeInputs], ["→ # '' meow()", ConversionDefinition, 'output', TextType], diff --git a/src/parser/Tokens.ts b/src/parser/Tokens.ts index 7cee43f02..3c8d7bdf2 100644 --- a/src/parser/Tokens.ts +++ b/src/parser/Tokens.ts @@ -123,6 +123,11 @@ export default class Tokens { ); } + afterNextIs(type: Sym) { + const after = this.#unread[1]; + return after !== undefined && after.isSymbol(type); + } + hasAfter(): boolean { const after = this.#unread[1]; return ( diff --git a/src/parser/parseExpression.ts b/src/parser/parseExpression.ts index dd07cb3da..3522f772c 100644 --- a/src/parser/parseExpression.ts +++ b/src/parser/parseExpression.ts @@ -61,6 +61,7 @@ import type Doc from '../nodes/Doc'; import Spread from '../nodes/Spread'; import Otherwise from '@nodes/Otherwise'; import Match from '@nodes/Match'; +import Input from '@nodes/Input'; export function toExpression(code: string): Expression { return parseExpression(toTokens(code)); @@ -827,8 +828,9 @@ function parseEvaluate(left: Expression, tokens: Tokens): Evaluate { tokens.nextIsnt(Sym.EvalClose), () => inputs.push( - nextIsBind(tokens, true) - ? parseBind(tokens) + tokens.nextIsOneOf(Sym.Name, Sym.Operator) && + tokens.afterNextIs(Sym.Bind) + ? parseInput(tokens) : parseExpression(tokens), ), ); @@ -838,6 +840,14 @@ function parseEvaluate(left: Expression, tokens: Tokens): Evaluate { return new Evaluate(left, types, open, inputs, close); } +function parseInput(tokens: Tokens): Expression { + const name = tokens.read(); + const bind = tokens.read(Sym.Bind); + const value = parseExpression(tokens); + + return new Input(name, bind, value); +} + function parseConversion(tokens: Tokens): ConversionDefinition { const docs = tokens.nextIs(Sym.Doc) ? parseDocs(tokens) : undefined; const convert = tokens.read(Sym.Convert); diff --git a/static/locales/es-MX/es-MX.json b/static/locales/es-MX/es-MX.json index 50939504e..6d2758c98 100644 --- a/static/locales/es-MX/es-MX.json +++ b/static/locales/es-MX/es-MX.json @@ -590,7 +590,6 @@ "primary": "No esperaba este tipo de input", "secondary": "oh, ¿no debería estar aquí?" }, - "MisplacedInput": "este input está fuera de lugar.", "MissingInput": { "primary": "Me falta $1, ¿puedes agregarlo?", "secondary": "este input es necesario, pero $1 no lo proporcionó" @@ -614,6 +613,13 @@ } } }, + "Input": { + "name": "$?", + "description": "$?", + "emotion": "$?", + "doc": "$?", + "start": "$?" + }, "ExpressionPlaceholder": { "name": "marcador de posición", "description": "$1[$1|marcador de posición]", diff --git a/static/locales/example/example.json b/static/locales/example/example.json index 20ac01c7c..6fa7f1aa7 100644 --- a/static/locales/example/example.json +++ b/static/locales/example/example.json @@ -395,7 +395,6 @@ "conflict": { "IncompatibleInput": { "primary": "$?", "secondary": "$?" }, "UnexpectedTypeInput": { "primary": "$?", "secondary": "$?" }, - "MisplacedInput": "$?", "MissingInput": { "primary": "$?", "secondary": "$?" }, "NotInstantiable": "$?", "UnexpectedInput": { "primary": "$?", "secondary": "$?" }, @@ -410,6 +409,13 @@ } } }, + "Input": { + "name": "$?", + "description": "$?", + "emotion": "$?", + "doc": "$?", + "start": "$?" + }, "ExpressionPlaceholder": { "name": "$?", "description": "$?", diff --git a/static/locales/ko-KR/ko-KR.json b/static/locales/ko-KR/ko-KR.json index edae5fc75..24972f4ea 100644 --- a/static/locales/ko-KR/ko-KR.json +++ b/static/locales/ko-KR/ko-KR.json @@ -610,7 +610,6 @@ "primary": "난 이런 입력 수 를 예상 하지 않았어", "secondary": "이런, 내가 여기 있어야 하는것이 아니니?" }, - "MisplacedInput": "이 입력은 순서가 잘못 돼었어.", "MissingInput": { "primary": "$1가 필요한데, 넣어 줄 수 있어?", "secondary": "이 입력이 필요한데, $2는 그걸 제공하지 않았어" @@ -634,6 +633,13 @@ } } }, + "Input": { + "name": "$?", + "description": "$?", + "emotion": "$?", + "doc": "$?", + "start": "$?" + }, "ExpressionPlaceholder": { "name": "자리 표시자", "description": "$1[$1|자리 표시자]", diff --git a/static/locales/zh-CN/zh-CN.json b/static/locales/zh-CN/zh-CN.json index 9c7d7f1cf..99495328e 100644 --- a/static/locales/zh-CN/zh-CN.json +++ b/static/locales/zh-CN/zh-CN.json @@ -611,7 +611,6 @@ "primary": "我没料到会得到这种类型的输入", "secondary": "哦,我这里不应该出现吗?" }, - "MisplacedInput": "这个输入放错位置了。", "MissingInput": { "primary": "我缺少 $1,你能添加吗?", "secondary": "这个输入是必需的,但 $2 没有提供它" @@ -635,6 +634,13 @@ } } }, + "Input": { + "name": "$?", + "description": "$?", + "emotion": "$?", + "doc": "$?", + "start": "$?" + }, "ExpressionPlaceholder": { "name": "表达式占位符", "description": "$1[$1|占位符]", diff --git a/static/locales/zh-TW/zh-TW.json b/static/locales/zh-TW/zh-TW.json index 264dab1ab..856e00f24 100644 --- a/static/locales/zh-TW/zh-TW.json +++ b/static/locales/zh-TW/zh-TW.json @@ -611,7 +611,6 @@ "primary": "我沒料到會得到這種類型的輸入", "secondary": "哦,我這裡不應該出現嗎?" }, - "MisplacedInput": "這個輸入放錯位置了。", "MissingInput": { "primary": "我缺少 $1,你能加嗎?", "secondary": "這個輸入是必要的,但 $2 沒有提供它" @@ -635,6 +634,13 @@ } } }, + "Input": { + "name": "$?", + "description": "$?", + "emotion": "$?", + "doc": "$?", + "start": "$?" + }, "ExpressionPlaceholder": { "name": "表達式佔位符", "description": "$1[$1|佔位符]", diff --git a/static/schemas/Locale.json b/static/schemas/Locale.json index f3708a7e5..bad8a2918 100644 --- a/static/schemas/Locale.json +++ b/static/schemas/Locale.json @@ -3483,10 +3483,6 @@ "$ref": "#/definitions/InternalConflictText", "description": "When a list of inputs is given but isn't last." }, - "MisplacedInput": { - "$ref": "#/definitions/InternalConflictText", - "description": "When an input is given, but in the wrong order." - }, "MissingInput": { "$ref": "#/definitions/ConflictText", "description": "When an input is expected, but not given. Description inputs: $1 = missing input, $2: evaluate that is missing input" @@ -3515,7 +3511,6 @@ "required": [ "IncompatibleInput", "UnexpectedTypeInput", - "MisplacedInput", "MissingInput", "NotInstantiable", "UnexpectedInput", @@ -3853,6 +3848,61 @@ "$ref": "#/definitions/NodeText", "description": "Whether the evaluation happening is the first one, e.g., `◆` in `◆ ? 'first' 'later'`" }, + "Input": { + "additionalProperties": false, + "properties": { + "description": { + "$ref": "#/definitions/Template", + "description": "A precise description of the node's contents, more specific than a name. If not provided, name is used." + }, + "doc": { + "$ref": "#/definitions/DocText", + "description": "Documentation text that appears in the documentation view" + }, + "emotion": { + "description": "The emotion that should be conveyed in animations of the node type", + "enum": [ + "$?", + "angry", + "arrogant", + "bored", + "cheerful", + "curious", + "confused", + "eager", + "excited", + "grumpy", + "happy", + "insecure", + "kind", + "neutral", + "sad", + "scared", + "serious", + "shy", + "surprised", + "precise" + ], + "type": "string" + }, + "name": { + "description": "The name that should be used to refer to the node type", + "type": "string" + }, + "start": { + "$ref": "#/definitions/Template", + "description": "The text shown when this expression type first begins evaluating." + } + }, + "required": [ + "description", + "doc", + "emotion", + "name", + "start" + ], + "type": "object" + }, "Insert": { "additionalProperties": false, "description": "Inserting a table row, e.g., `table ⎡+ 1⎦` Start inputs: $1 = table expression Finish inputs: $1: resulting value", @@ -6521,6 +6571,7 @@ "Delete", "DocumentedExpression", "Evaluate", + "Input", "ExpressionPlaceholder", "FunctionDefinition", "Iteration", diff --git a/static/schemas/Tutorial.json b/static/schemas/Tutorial.json index a71f4a649..a93e707b0 100644 --- a/static/schemas/Tutorial.json +++ b/static/schemas/Tutorial.json @@ -213,6 +213,7 @@ "Delete", "DocumentedExpression", "Evaluate", + "Input", "ExpressionPlaceholder", "FunctionDefinition", "Iteration",