diff --git a/src/basis/ListBasis.test.ts b/src/basis/ListBasis.test.ts index f67a37c97..4a5e8f932 100644 --- a/src/basis/ListBasis.test.ts +++ b/src/basis/ListBasis.test.ts @@ -2,6 +2,8 @@ import { test, expect } from 'vitest'; import evaluateCode from '../runtime/evaluate'; test.each([ + ['[1 2 3 :[4 5 6]]', '[1 2 3 4 5 6]'], + ['[:[1 2 3] :[4 5 6]]', '[1 2 3 4 5 6]'], ['[1 2 3].add(4)', '[1 2 3 4]'], ['[1 2 3].has(4)', '⊥'], ['[1 2 3].has(3)', '⊤'], diff --git a/src/components/editor/SpreadView.svelte b/src/components/editor/SpreadView.svelte new file mode 100644 index 000000000..0f1d17d1f --- /dev/null +++ b/src/components/editor/SpreadView.svelte @@ -0,0 +1,10 @@ + + + + + diff --git a/src/components/editor/util/nodeToView.ts b/src/components/editor/util/nodeToView.ts index c10a82d87..073e5315a 100644 --- a/src/components/editor/util/nodeToView.ts +++ b/src/components/editor/util/nodeToView.ts @@ -1,3 +1,5 @@ +import type { ComponentType, SvelteComponent } from 'svelte'; + /* eslint-disable @typescript-eslint/ban-types */ import BlockView from '../BlockView.svelte'; import BorrowView from '../BorrowView.svelte'; @@ -80,6 +82,7 @@ import TranslationView from '../TranslationView.svelte'; import FormattedLiteralView from '../FormattedLiteralView.svelte'; import FormattedTranslationView from '../FormattedTranslationView.svelte'; import IsLocaleView from '../IsLocaleView.svelte'; +import SpreadView from '../SpreadView.svelte'; import type Node from '@nodes/Node'; import Program from '@nodes/Program'; @@ -164,7 +167,7 @@ import Translation from '@nodes/Translation'; import FormattedTranslation from '@nodes/FormattedTranslation'; import FormattedLiteral from '@nodes/FormattedLiteral'; import IsLocale from '@nodes/IsLocale'; -import type { ComponentType, SvelteComponent } from 'svelte'; +import Spread from '@nodes/Spread'; const nodeToView = new Map>(); @@ -240,6 +243,7 @@ nodeToView.set(SetType, SetTypeView); nodeToView.set(MapType, MapTypeView); nodeToView.set(ListLiteral, ListLiteralView); +nodeToView.set(Spread, SpreadView); nodeToView.set(ListAccess, ListAccessView); nodeToView.set(ListType, ListTypeView); diff --git a/src/components/palette/editOutput.ts b/src/components/palette/editOutput.ts index f09bfb166..3fb1998f2 100644 --- a/src/components/palette/editOutput.ts +++ b/src/components/palette/editOutput.ts @@ -21,6 +21,7 @@ import { } from '../../parser/Symbols'; import { toExpression } from '../../parser/parseExpression'; import { getPlaceExpression } from '../../output/getOrCreatePlace'; +import type Spread from '../../nodes/Spread'; export function getNumber(given: Expression): number | undefined { const measurement = @@ -186,7 +187,7 @@ export function reviseContent( db: Database, project: Project, list: ListLiteral, - newValues: Expression[] + newValues: (Expression | Spread)[] ) { db.Projects.revise(project, [[list, ListLiteral.make(newValues)]]); } diff --git a/src/examples/Adventure.wp b/src/examples/Adventure.wp index a51e50c7b..6c3970441 100644 --- a/src/examples/Adventure.wp +++ b/src/examples/Adventure.wp @@ -21,8 +21,11 @@ Stage( [ Group( Stack() - [Phrase(state.description duration: 0.5s name: state.action entering: Pose(offset: Place(0m 2m)))].append(options) - ) + [ + Phrase(state.description duration: 0.5s name: state.action entering: Pose(offset: Place(0m 2m))) + :options + ] + ) ] background: Color(0 0 0%°) color: Color(100% 0 0°) diff --git a/src/examples/Hira.wp b/src/examples/Hira.wp index 6718c2d8c..3e1c373b4 100644 --- a/src/examples/Hira.wp +++ b/src/examples/Hira.wp @@ -17,9 +17,7 @@ letters•[Output]: count → [].translate( Stage( - letters.append([ - Shape(Rectangle(-40m 0m 40m -2m)) - ]) + [ :letters Shape(Rectangle(-40m 0m 40m -2m))] gravity: gravity place: Place(0m 5m -20m) ) \ No newline at end of file diff --git a/src/examples/Layers.wp b/src/examples/Layers.wp index b1279767a..faa0aea2b 100644 --- a/src/examples/Layers.wp +++ b/src/examples/Layers.wp @@ -15,10 +15,13 @@ count: 10→[] ) Stage( - balls(0m).append(balls(-10m).append(balls(10m))).append([ + [ + :balls(0m) + :balls(-10m) + :balls(10m) Shape(Rectangle(-20m 0m 20m -1m 0m)) Shape(Rectangle(-20m 0m 20m -1m 10m) rotation: 25°) Shape(Rectangle(-20m 0m 20m -1m -10m) rotation: -10°) - ]) + ] place: Place(0m 10m -30m) ) \ No newline at end of file diff --git a/src/examples/Questions.wp b/src/examples/Questions.wp index 868c6968f..215f42caa 100644 --- a/src/examples/Questions.wp +++ b/src/examples/Questions.wp @@ -4,19 +4,22 @@ Questions pressed: ∆ Key() count•#: 0 … pressed … count + 1 -questions•[Question]: [] … pressed … questions.add(Question(count→"" Random(30 50) · 1m/s Random(30 50) · 1m/s Random(0 30) · 1°/s)) +questions•[Question]: [] … pressed … [:questions Question(count→"" Random(30 50) · 1m/s Random(30 50) · 1m/s Random(0 30) · 1°/s)] Stage( - [ Phrase('👨‍👨‍👧‍👦' place: Place(0m 0m) face: "Noto Emoji") Shape(Rectangle(-5m 0m 25m -1m))].append(questions.translate( - ƒ(q•Question index•#) ( - initialize: pressed & (index = questions.length()) - Phrase( - 'Q' - name: q.id - place: Motion(velocity: Velocity(q.vx q.vy q.va)) - matter: Matter() - resting: Pose(opacity: initialize ? 0% 100%) + [ + Phrase('👨‍👨‍👧‍👦' place: Place(0m 0m) face: "Noto Emoji") Shape(Rectangle(-5m 0m 25m -1m)) + :questions.translate( + ƒ(q•Question index•#) ( + initialize: pressed & (index = questions.length()) + Phrase( + 'Q' + name: q.id + place: Motion(velocity: Velocity(q.vx q.vy q.va)) + matter: Matter() + resting: Pose(opacity: initialize ? 0% 100%) + ) ) ) - )) + ] ) \ No newline at end of file diff --git a/src/locale/NodeTexts.ts b/src/locale/NodeTexts.ts index c0427c451..ddd6d0a40 100644 --- a/src/locale/NodeTexts.ts +++ b/src/locale/NodeTexts.ts @@ -440,6 +440,11 @@ type NodeTexts = { /** Placeholder label for an item in a list */ item: Template; }; + /** + * A way of spreading a list's values into a list literal, e.g., `[ [ 1 2 3]… 4 5]` + * Description inputs: none + */ + Spread: NodeText; /** * A map literal, e.g., `{1:1 2:2 3:3}` * Finish inputs: $1 = resulting value diff --git a/src/locale/en-US.json b/src/locale/en-US.json index 77272cf53..d4c9bf446 100644 --- a/src/locale/en-US.json +++ b/src/locale/en-US.json @@ -765,6 +765,14 @@ "finish": "I made a me! $1", "item": "item" }, + "Spread": { + "name": "list spread", + "emotion": "serious", + "doc": [ + "A help you make lists with the values of other lists. Like this:", + "\\list1: [1 2 3]\nlist2: [4 5 6]\nfinal: [list1… list2…]" + ] + }, "MapLiteral": { "name": "map", "description": "$1 pairing map", @@ -1913,8 +1921,10 @@ }, "append": { "doc": [ - "I create a new @List with my values, then all the values of the given @List.", - "\\['apple' 'banana' 'mango'].append(['watermelon' 'starfruit'])\\" + "I create a new @List with my values, then all the values of the given @List after me.", + "\\['apple' 'banana' 'mango'].append(['watermelon' 'starfruit'])\\", + "It's a little bit easier to use @Spread though, like this:", + "\\['apple' 'banana' 'mango' :['watermelon' 'starfruit']]\\" ], "names": ["append"], "inputs": [ diff --git a/src/nodes/ListLiteral.ts b/src/nodes/ListLiteral.ts index 5a307085d..5f80fe340 100644 --- a/src/nodes/ListLiteral.ts +++ b/src/nodes/ListLiteral.ts @@ -24,13 +24,15 @@ import type { BasisTypeName } from '../basis/BasisConstants'; import concretize from '../locale/concretize'; import Sym from './Sym'; import AnyType from './AnyType'; +import Spread from './Spread'; +import TypeException from '../values/TypeException'; export default class ListLiteral extends Expression { readonly open: Token; - readonly values: Expression[]; + readonly values: (Spread | Expression)[]; readonly close?: Token; - constructor(open: Token, values: Expression[], close?: Token) { + constructor(open: Token, values: (Spread | Expression)[], close?: Token) { super(); this.open = open; @@ -40,7 +42,7 @@ export default class ListLiteral extends Expression { this.computeChildren(); } - static make(values?: Expression[]) { + static make(values?: (Expression | Spread)[]) { return new ListLiteral( new ListOpenToken(), values ?? [], @@ -57,7 +59,7 @@ export default class ListLiteral extends Expression { { name: 'open', kind: node(Sym.ListOpen) }, { name: 'values', - kind: list(true, node(Expression)), + kind: list(true, node(Expression), node(Spread)), label: (translation: Locale) => translation.node.ListLiteral.item, // Only allow types to be inserted that are of the list's type, if provided. @@ -74,7 +76,7 @@ export default class ListLiteral extends Expression { clone(replace?: Replacement) { return new ListLiteral( this.replaceChild('open', this.open, replace), - this.replaceChild('values', this.values, replace), + this.replaceChild('values', this.values, replace), this.replaceChild('close', this.close, replace) ) as this; } @@ -117,7 +119,9 @@ export default class ListLiteral extends Expression { } getDependencies(): Expression[] { - return [...this.values]; + return this.values + .map((val) => (val instanceof Spread ? val.list : val)) + .filter((val): val is Expression => val !== undefined); } compile(evaluator: Evaluator, context: Context): Step[] { @@ -126,7 +130,11 @@ export default class ListLiteral extends Expression { ...this.values.reduce( (steps: Step[], item) => [ ...steps, - ...item.compile(evaluator, context), + ...(item instanceof Spread + ? item.list + ? item.list.compile(evaluator, context) + : [] + : item.compile(evaluator, context)), ], [] ), @@ -137,10 +145,34 @@ export default class ListLiteral extends Expression { evaluate(evaluator: Evaluator, prior: Value | undefined): Value { if (prior) return prior; + // Start with the list of values from the expression to help keep track of the ones that were handled. + const items = this.values.slice(); + // Pop all of the values. const values = []; - for (let i = 0; i < this.values.length; i++) - values.unshift(evaluator.popValue(this)); + for (let i = 0; i < this.values.length; i++) { + const value = evaluator.popValue(this); + let item; + do { + item = items.pop(); + } while (item instanceof Spread && item.list === undefined); + // Was this a spread value? Add all of its items to this list. + if (item instanceof Spread) { + if (value instanceof ListValue) { + // Add them in reverse order so they end up in the correct order. + for (let j = value.values.length - 1; j >= 0; j--) + values.unshift(value.values[j]); + } else + return new TypeException( + this, + evaluator, + ListType.make(), + value + ); + } + // Add the non-spread value. + else values.unshift(value); + } // Construct the new list. return new ListValue(this, values); diff --git a/src/nodes/Spread.ts b/src/nodes/Spread.ts new file mode 100644 index 000000000..1e8606faf --- /dev/null +++ b/src/nodes/Spread.ts @@ -0,0 +1,77 @@ +import Expression from './Expression'; +import type { Grammar, Replacement } from './Node'; +import Token from './Token'; +import type Locale from '@locale/Locale'; +import Glyphs from '../lore/Glyphs'; +import Purpose from '../concepts/Purpose'; +import type { BasisTypeName } from '../basis/BasisConstants'; +import Node, { node, optional } from './Node'; +import Sym from './Sym'; +import { BIND_SYMBOL } from '../parser/Symbols'; +import AnyType from './AnyType'; +import ListType from './ListType'; + +/** Inside a list literal, flattens values of a list value into a new list */ +export default class Spread extends Node { + readonly dots: Token; + readonly list: Expression | undefined; + + constructor(dots: Token, list: Expression | undefined) { + super(); + + this.dots = dots; + this.list = list; + + this.computeChildren(); + } + + static make(list: Expression) { + return new Spread(new Token(BIND_SYMBOL, Sym.Bind), list); + } + + static getPossibleNodes() { + return []; + } + + getGrammar(): Grammar { + return [ + { + name: 'dots', + kind: node(Sym.Bind), + }, + { + name: 'list', + kind: optional(node(Expression)), + getType: () => ListType.make(new AnyType()), + label: (translation: Locale) => translation.term.list, + }, + ]; + } + + clone(replace?: Replacement) { + return new Spread( + this.replaceChild('dots', this.dots, replace), + this.replaceChild('list', this.list, replace) + ) as this; + } + + getPurpose(): Purpose { + return Purpose.Value; + } + + getAffiliatedType(): BasisTypeName | undefined { + return 'list'; + } + + computeConflicts() { + return; + } + + getNodeLocale(locale: Locale) { + return locale.node.Spread; + } + + getGlyphs() { + return Glyphs.Stream; + } +} diff --git a/src/parser/parseExpression.ts b/src/parser/parseExpression.ts index c5dcc8b38..0664d5d00 100644 --- a/src/parser/parseExpression.ts +++ b/src/parser/parseExpression.ts @@ -58,6 +58,7 @@ import { toTokens } from './toTokens'; import Docs from '../nodes/Docs'; import parseDoc from './parseDoc'; import type Doc from '../nodes/Doc'; +import Spread from '../nodes/Spread'; export function toExpression(code: string): Expression { return parseExpression(toTokens(code)); @@ -451,18 +452,30 @@ function parseTranslation(tokens: Tokens): Translation { return new Translation(text, segments, close, language); } -/** LIST :: [ EXPRESSION* ] */ +/** LIST :: [ (SPREAD|EXPRESSION)* ] */ function parseList(tokens: Tokens): ListLiteral { const open = tokens.read(Sym.ListOpen); - const values: Expression[] = []; + const values: (Spread | Expression)[] = []; while ( tokens.hasNext() && tokens.nextIsnt(Sym.ListClose) && tokens.nextIsnt(Sym.Code) && !tokens.nextHasMoreThanOneLineBreak() - ) - values.push(parseExpression(tokens)); + ) { + // Is there a spread next? Parse it. + if (tokens.nextIs(Sym.Bind)) { + const dots = tokens.read(Sym.Bind); + const value = + tokens.hasNext() && + tokens.nextIsnt(Sym.ListClose) && + tokens.nextIsnt(Sym.Code) && + !tokens.nextHasMoreThanOneLineBreak() + ? parseExpression(tokens) + : undefined; + values.push(new Spread(dots, value)); + } else values.push(parseExpression(tokens)); + } const close = tokens.readIf(Sym.ListClose); diff --git a/static/locales/es-MX/es-MX.json b/static/locales/es-MX/es-MX.json index 66f0a7dd5..59917d34f 100644 --- a/static/locales/es-MX/es-MX.json +++ b/static/locales/es-MX/es-MX.json @@ -492,6 +492,11 @@ "finish": "$?", "item": "$?" }, + "Spread": { + "name": "$?", + "emotion": "serious", + "doc": "$?" + }, "MapLiteral": { "name": "índice", "description": "$?", diff --git a/static/locales/example/example.json b/static/locales/example/example.json index a92fa729e..e530368c2 100644 --- a/static/locales/example/example.json +++ b/static/locales/example/example.json @@ -492,6 +492,11 @@ "finish": "$?", "item": "$?" }, + "Spread": { + "name": "$?", + "emotion": "serious", + "doc": "$?" + }, "MapLiteral": { "name": "$?", "description": "$?", diff --git a/static/locales/zh-CN/zh-CN.json b/static/locales/zh-CN/zh-CN.json index 48252818a..88248e021 100644 --- a/static/locales/zh-CN/zh-CN.json +++ b/static/locales/zh-CN/zh-CN.json @@ -492,6 +492,11 @@ "finish": "$?", "item": "$?" }, + "Spread": { + "name": "$?", + "emotion": "serious", + "doc": "$?" + }, "MapLiteral": { "name": "$?", "description": "$?", diff --git a/static/schemas/Locale.json b/static/schemas/Locale.json index e8a722159..5776079d5 100644 --- a/static/schemas/Locale.json +++ b/static/schemas/Locale.json @@ -5255,6 +5255,10 @@ "$ref": "#/definitions/NodeText", "description": "A source file that contains a name and program." }, + "Spread": { + "$ref": "#/definitions/NodeText", + "description": "A way of spreading a list's values into a list literal, e.g., `[ [ 1 2 3]… 4 5]` Description inputs: none" + }, "StreamDefinition": { "additionalProperties": false, "description": "A stream definition. Not typically written, since all streams are defined internally, but basically like a structure definition, e.g., `… Key()`", @@ -6081,6 +6085,7 @@ "IsLocale", "ListAccess", "ListLiteral", + "Spread", "MapLiteral", "NumberLiteral", "InternalExpression", diff --git a/static/schemas/Tutorial.json b/static/schemas/Tutorial.json index abb1a8f5e..da4d9582b 100644 --- a/static/schemas/Tutorial.json +++ b/static/schemas/Tutorial.json @@ -68,6 +68,7 @@ "IsLocale", "ListAccess", "ListLiteral", + "Spread", "MapLiteral", "NumberLiteral", "InternalExpression",