From 927df167d7c0d4c376c76cfe2dcd6edbec36afc5 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 15 Dec 2023 20:29:15 +1000 Subject: [PATCH] Set output evaluation context for a single node (#8440) - Closes #8072 - Implement handlers for the corresponding buttons on the circular menu - Add missing icons and styles - Add functionality to match and extract ASTs # Important Notes None --- app/gui2/e2e/MockApp.vue | 2 +- app/gui2/e2e/main.ts | 2 +- app/gui2/e2e/setup.ts | 2 +- .../{e2e/mockEngine.ts => mock/engine.ts} | 0 app/gui2/mock/index.ts | 92 ++++ .../projectManager.ts} | 4 +- app/gui2/mock/providers.ts | 55 +++ app/gui2/mock/vue.ts | 19 + app/gui2/src/assets/icons.svg | 6 + app/gui2/src/components/CircularMenu.vue | 27 +- .../src/components/GraphEditor/GraphEdge.vue | 87 +--- .../src/components/GraphEditor/GraphNode.vue | 74 ++- .../src/components/GraphEditor/GraphNodes.vue | 16 +- .../GraphEditor/widgets/WidgetVector.vue | 2 +- app/gui2/src/components/ToggleIcon.vue | 2 +- app/gui2/src/composables/events.ts | 3 +- app/gui2/src/stores/graph/graphDatabase.ts | 37 +- app/gui2/src/stores/graph/index.ts | 24 +- app/gui2/src/stores/project/index.ts | 41 +- .../src/util/ast/__tests__/abstract.test.ts | 2 +- app/gui2/src/util/ast/__tests__/match.test.ts | 109 +++++ .../src/util/ast/__tests__/prefixes.test.ts | 79 +++ app/gui2/src/util/ast/__tests__/text.ts | 10 + app/gui2/src/util/ast/abstract.ts | 462 +++++++++++------- app/gui2/src/util/ast/match.ts | 98 ++++ app/gui2/src/util/ast/node.ts | 23 + app/gui2/src/util/ast/prefixes.ts | 60 +++ app/gui2/src/util/data/iterable.ts | 24 +- app/gui2/src/util/net.ts | 11 +- app/gui2/src/util/record.ts | 9 + app/gui2/src/util/theme.json | 16 + app/gui2/src/util/theme.ts | 60 +++ app/gui2/tsconfig.app.json | 5 +- app/gui2/tsconfig.node.json | 3 +- 34 files changed, 1121 insertions(+), 345 deletions(-) rename app/gui2/{e2e/mockEngine.ts => mock/engine.ts} (100%) create mode 100644 app/gui2/mock/index.ts rename app/gui2/{e2e/mockProjectManager.ts => mock/projectManager.ts} (97%) create mode 100644 app/gui2/mock/providers.ts create mode 100644 app/gui2/mock/vue.ts create mode 100644 app/gui2/src/util/ast/__tests__/match.test.ts create mode 100644 app/gui2/src/util/ast/__tests__/prefixes.test.ts create mode 100644 app/gui2/src/util/ast/__tests__/text.ts create mode 100644 app/gui2/src/util/ast/match.ts create mode 100644 app/gui2/src/util/ast/node.ts create mode 100644 app/gui2/src/util/ast/prefixes.ts create mode 100644 app/gui2/src/util/record.ts create mode 100644 app/gui2/src/util/theme.ts diff --git a/app/gui2/e2e/MockApp.vue b/app/gui2/e2e/MockApp.vue index fabb70bd0c0..265f5c8c42c 100644 --- a/app/gui2/e2e/MockApp.vue +++ b/app/gui2/e2e/MockApp.vue @@ -1,8 +1,8 @@ + + diff --git a/app/gui2/src/composables/events.ts b/app/gui2/src/composables/events.ts index c83626995dd..45fab4e2deb 100644 --- a/app/gui2/src/composables/events.ts +++ b/app/gui2/src/composables/events.ts @@ -273,7 +273,8 @@ export function usePointer( trackedPointer.value = e.pointerId // This is mostly SAFE, as virtually all `Element`s also extend `GlobalEventHandlers`. trackedElement = e.currentTarget as Element & GlobalEventHandlers - trackedElement.setPointerCapture(e.pointerId) + // `setPointerCapture` is not defined in tests. + trackedElement.setPointerCapture?.(e.pointerId) initialGrabPos = new Vec2(e.clientX, e.clientY) lastPos = initialGrabPos handler(computePosition(e, initialGrabPos, lastPos), e, 'start') diff --git a/app/gui2/src/stores/graph/graphDatabase.ts b/app/gui2/src/stores/graph/graphDatabase.ts index e8cf1b6f734..0738ba89bce 100644 --- a/app/gui2/src/stores/graph/graphDatabase.ts +++ b/app/gui2/src/stores/graph/graphDatabase.ts @@ -3,12 +3,14 @@ import { SuggestionDb, groupColorStyle, type Group } from '@/stores/suggestionDa import type { SuggestionEntry } from '@/stores/suggestionDatabase/entry' import { Ast, RawAst, RawAstExtended } from '@/util/ast' import { AliasAnalyzer } from '@/util/ast/aliasAnalysis' +import { nodeFromAst } from '@/util/ast/node' import { colorFromString } from '@/util/colors' import { MappedKeyMap, MappedSet } from '@/util/containers' import { arrayEquals, byteArraysEqual, tryGetIndex } from '@/util/data/array' import type { Opt } from '@/util/data/opt' import { Vec2 } from '@/util/data/vec2' import { ReactiveDb, ReactiveIndex, ReactiveMapping } from '@/util/database/reactiveDb' +import * as random from 'lib0/random' import * as set from 'lib0/set' import { methodPointerEquals, type MethodCall } from 'shared/languageServerTypes' import { @@ -310,17 +312,19 @@ export class GraphDb { return new GraphDb(db, ref([]), registry) } - mockNode(binding: string, id: ExprId, code?: string) { - const node = { + mockNode(binding: string, id: ExprId, code?: string): Node { + const pattern = Ast.parse(binding) + const node: Node = { outerExprId: id, - pattern: Ast.parse(binding), + pattern, rootSpan: Ast.parse(code ?? '0'), position: Vec2.Zero, vis: undefined, } - const bidingId = node.pattern.astId + const bindingId = pattern.astId this.nodeIdToNode.set(id, node) - this.bindings.bindings.set(bidingId, { identifier: binding, usages: new Set() }) + this.bindings.bindings.set(bindingId, { identifier: binding, usages: new Set() }) + return node } } @@ -332,25 +336,16 @@ export interface Node { vis: Opt } -function nodeFromAst(ast: Ast.Ast): Node { - const common = { - outerExprId: ast.exprId, +/** This should only be used for supplying as initial props when testing. + * Please do {@link GraphDb.mockNode} with a `useGraphStore().db` after mount. */ +export function mockNode(exprId?: ExprId): Node { + return { + outerExprId: exprId ?? (random.uuidv4() as ExprId), + pattern: undefined, + rootSpan: Ast.parse('0'), position: Vec2.Zero, vis: undefined, } - if (ast instanceof Ast.Assignment && ast.expression) { - return { - ...common, - pattern: ast.pattern ?? undefined, - rootSpan: ast.expression, - } - } else { - return { - ...common, - pattern: undefined, - rootSpan: ast, - } - } } function mathodCallEquals(a: MethodCall | undefined, b: MethodCall | undefined): boolean { diff --git a/app/gui2/src/stores/graph/index.ts b/app/gui2/src/stores/graph/index.ts index e620eeda7f6..0324ecf87b1 100644 --- a/app/gui2/src/stores/graph/index.ts +++ b/app/gui2/src/stores/graph/index.ts @@ -156,13 +156,13 @@ export const useGraphStore = defineStore('graph', () => { } function disconnectSource(edge: Edge) { - if (edge.target) - unconnectedEdge.value = { target: edge.target, disconnectedEdgeTarget: edge.target } + if (!edge.target) return + unconnectedEdge.value = { target: edge.target, disconnectedEdgeTarget: edge.target } } function disconnectTarget(edge: Edge) { - if (edge.source && edge.target) - unconnectedEdge.value = { source: edge.source, disconnectedEdgeTarget: edge.target } + if (!edge.source || !edge.target) return + unconnectedEdge.value = { source: edge.source, disconnectedEdgeTarget: edge.target } } function clearUnconnected() { @@ -230,20 +230,6 @@ export const useGraphStore = defineStore('graph', () => { proj.stopCapturingUndo() } - function replaceNodeSubexpression( - nodeId: ExprId, - range: ContentRange | undefined, - content: string, - ) { - const node = db.nodeIdToNode.get(nodeId) - if (!node) return - proj.module?.replaceExpressionContent(node.rootSpan.astId, content, range) - } - - function replaceExpressionContent(exprId: ExprId, content: string) { - proj.module?.replaceExpressionContent(exprId, content) - } - function setNodePosition(nodeId: ExprId, position: Vec2) { const node = db.nodeIdToNode.get(nodeId) if (!node) return @@ -338,8 +324,6 @@ export const useGraphStore = defineStore('graph', () => { deleteNode, setNodeContent, setExpressionContent, - replaceNodeSubexpression, - replaceExpressionContent, setNodePosition, setNodeVisualizationId, setNodeVisualizationVisible, diff --git a/app/gui2/src/stores/project/index.ts b/app/gui2/src/stores/project/index.ts index 3866ac93abe..aec6a54f68b 100644 --- a/app/gui2/src/stores/project/index.ts +++ b/app/gui2/src/stores/project/index.ts @@ -85,6 +85,9 @@ async function initializeLsRpcConnection( error, ) }, + }).catch((error) => { + console.error('Error initializing Language Server RPC:', error) + throw error }) const contentRoots = initialization.contentRoots return { connection, contentRoots } @@ -93,7 +96,10 @@ async function initializeLsRpcConnection( async function initializeDataConnection(clientId: Uuid, url: string) { const client = createWebsocketClient(url, { binaryType: 'arraybuffer', sendPings: false }) const connection = new DataServer(client) - await connection.initialize(clientId) + await connection.initialize(clientId).catch((error) => { + console.error('Error initializing data connection:', error) + throw error + }) return connection } @@ -439,8 +445,20 @@ export const useProjectStore = defineStore('project', () => { const clientId = random.uuidv4() as Uuid const lsUrls = resolveLsUrl(config.value) const initializedConnection = initializeLsRpcConnection(clientId, lsUrls.rpcUrl) - const lsRpcConnection = initializedConnection.then(({ connection }) => connection) - const contentRoots = initializedConnection.then(({ contentRoots }) => contentRoots) + const lsRpcConnection = initializedConnection.then( + ({ connection }) => connection, + (error) => { + console.error('Error getting Language Server connection:', error) + throw error + }, + ) + const contentRoots = initializedConnection.then( + ({ contentRoots }) => contentRoots, + (error) => { + console.error('Error getting content roots:', error) + throw error + }, + ) const dataConnection = initializeDataConnection(clientId, lsUrls.dataUrl) const rpcUrl = new URL(lsUrls.rpcUrl) @@ -514,7 +532,7 @@ export const useProjectStore = defineStore('project', () => { moduleDocGuid.value = guid } - projectModel.modules.observe((_) => tryReadDocGuid()) + projectModel.modules.observe(tryReadDocGuid) watchEffect(tryReadDocGuid) const module = computedAsync(async () => { @@ -537,8 +555,16 @@ export const useProjectStore = defineStore('project', () => { }) } - const firstExecution = lsRpcConnection.then((lsRpc) => - nextEvent(lsRpc, 'executionContext/executionComplete'), + const firstExecution = lsRpcConnection.then( + (lsRpc) => + nextEvent(lsRpc, 'executionContext/executionComplete').catch((error) => { + console.error('First execution failed:', error) + throw error + }), + (error) => { + console.error('Could not get Language Server for first execution:', error) + throw error + }, ) const executionContext = createExecutionContextForMain() const visualizationDataRegistry = new VisualizationDataRegistry(executionContext, dataConnection) @@ -603,6 +629,8 @@ export const useProjectStore = defineStore('project', () => { }) }) + const isOutputContextEnabled = computed(() => executionMode.value === 'live') + function stopCapturingUndo() { module.value?.undoManager.stopCapturing() } @@ -661,6 +689,7 @@ export const useProjectStore = defineStore('project', () => { lsRpcConnection: markRaw(lsRpcConnection), dataConnection: markRaw(dataConnection), useVisualizationData, + isOutputContextEnabled, stopCapturingUndo, executionMode, dataflowErrors, diff --git a/app/gui2/src/util/ast/__tests__/abstract.test.ts b/app/gui2/src/util/ast/__tests__/abstract.test.ts index 1bdb93a6013..034a6725f07 100644 --- a/app/gui2/src/util/ast/__tests__/abstract.test.ts +++ b/app/gui2/src/util/ast/__tests__/abstract.test.ts @@ -367,7 +367,7 @@ const parseCases = [ ] test.each(parseCases)('parse: %s', (testCase) => { const root = Ast.parse(testCase.code) - expect(Ast.debug(root)).toEqual(testCase.tree) + expect(Ast.tokenTree(root)).toEqual(testCase.tree) }) // TODO: Edits (#8367). diff --git a/app/gui2/src/util/ast/__tests__/match.test.ts b/app/gui2/src/util/ast/__tests__/match.test.ts new file mode 100644 index 00000000000..b88df8e0709 --- /dev/null +++ b/app/gui2/src/util/ast/__tests__/match.test.ts @@ -0,0 +1,109 @@ +import { Ast } from '@/util/ast' +import { Pattern } from '@/util/ast/match' +import { expect, test } from 'vitest' +import { MutableModule } from '../abstract' + +test.each([ + { target: 'a.b', pattern: '__', extracted: ['a.b'] }, + { target: 'a.b', pattern: 'a.__', extracted: ['b'] }, + { target: 'a.b', pattern: '__.b', extracted: ['a'] }, + { target: '1 + 1', pattern: '1 + 1', extracted: [] }, + { target: '1 + 2', pattern: '1 + __', extracted: ['2'] }, + { target: '1 + 2', pattern: '__ + 2', extracted: ['1'] }, + { target: '1 + 2', pattern: '__ + __', extracted: ['1', '2'] }, + { target: '1', pattern: '__', extracted: ['1'] }, + { target: '1', pattern: '(__)' }, + { target: '("a")', pattern: '(__)', extracted: ['"a"'] }, + { target: '[1, "a", True]', pattern: '[1, "a", True]', extracted: [] }, + { target: '[1, "a", True]', pattern: '[__, "a", __]', extracted: ['1', 'True'] }, + { target: '[1, "a", True]', pattern: '[1, "a", False]' }, + { target: '[1, "a", True]', pattern: '[1, "a", True, 2]' }, + { target: '[1, "a", True]', pattern: '[1, "a", True, __]' }, + { target: '(1)', pattern: '1' }, + { target: '(1)', pattern: '__', extracted: ['(1)'] }, // True because `__` matches any expression. + { target: '1 + 1', pattern: '1 + 2' }, + { target: '1 + 1', pattern: '"1" + 1' }, + { target: '1 + 1', pattern: '1 + "1"' }, + { target: '1 + 1 + 1', pattern: '(1 + 1) + 1' }, + { target: '1 + 1 + 1', pattern: '1 + (1 + 1)' }, + { target: '(1 + 1) + 1', pattern: '1 + 1 + 1' }, + { target: '1 + (1 + 1)', pattern: '1 + 1 + 1' }, + { + target: + 'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output "current_context_name" <| node1.fn', + pattern: + 'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output "current_context_name" <| __', + extracted: ['node1.fn'], + }, + { + target: + 'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output "current_context_name" <| node1.fn', + pattern: + 'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output __ <| __', + extracted: ['"current_context_name"', 'node1.fn'], + }, + { + target: + 'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output "current_context_name" <| node1.fn', + pattern: 'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output __', + }, + { + target: + 'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output "current_context_name" <| node1.fn', + pattern: + 'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output "current_context#name" <| node1.fn', + }, + { + target: + 'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output "current_context_name" <| node1.fn', + pattern: + 'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output "current_context_name" <| node2.fn', + }, + { + target: + 'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output "current_context_name" <| a + b', + pattern: + 'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output __ <| __', + extracted: ['"current_context_name"', 'a + b'], + }, + { + target: + "Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output 'current_context_name' <| a + b", + pattern: + 'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output __ <| __', + extracted: ["'current_context_name'", 'a + b'], + }, + { + target: + "Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output 'current_context_name' <| a + b", + pattern: 'Standard.Base.Runtime.__ Standard.Base.Runtime.Context.Output __ <| __', + extracted: ['with_enabled_context', "'current_context_name'", 'a + b'], + }, +])('`isMatch` and `extractMatches`', ({ target, pattern, extracted }) => { + const targetAst = Ast.parseLine(target) + const module = targetAst.module + const patternAst = Pattern.parse(pattern) + expect( + patternAst.match(targetAst) !== undefined, + `'${target}' has CST ${extracted != null ? '' : 'not '}matching '${pattern}'`, + ).toBe(extracted != null) + expect( + patternAst.match(targetAst)?.map((match) => module.get(match)?.code()), + extracted != null + ? `'${target}' matches '${pattern}' with '__'s corresponding to ${JSON.stringify(extracted) + .slice(1, -1) + .replace(/"/g, "'")}` + : `'${target}' does not match '${pattern}'`, + ).toStrictEqual(extracted) +}) + +test.each([ + { template: 'a __ c', source: 'b', result: 'a b c' }, + { template: 'a . __ . c', source: 'b', result: 'a . b . c' }, +])('instantiate', ({ template, source, result }) => { + const pattern = Pattern.parse(template) + const edit = MutableModule.Transient() + const intron = Ast.parse(source, edit) + const instantiated = pattern.instantiate(edit, [intron.exprId]) + expect(instantiated.code(edit)).toBe(result) +}) diff --git a/app/gui2/src/util/ast/__tests__/prefixes.test.ts b/app/gui2/src/util/ast/__tests__/prefixes.test.ts new file mode 100644 index 00000000000..34487d82ece --- /dev/null +++ b/app/gui2/src/util/ast/__tests__/prefixes.test.ts @@ -0,0 +1,79 @@ +import { Ast } from '@/util/ast/abstract' +import { Prefixes } from '@/util/ast/prefixes' +import { expect, test } from 'vitest' + +test.each([ + { + prefixes: { + a: 'a + __', + }, + modifications: { + a: [], + }, + source: 'b', + target: 'a + b', + }, + { + prefixes: { + a: 'a + __ + c', + }, + modifications: { + a: [], + }, + source: 'd', + target: 'a + d + c', + }, + { + prefixes: { + a: '__+e + __', + }, + modifications: { + a: ['d'], + }, + source: 'f', + target: 'd+e + f', + }, + { + prefixes: { + enable: + 'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output __ <| __', + }, + modifications: { + enable: ["'foo'"], + }, + source: 'a + b', + target: + "Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output 'foo' <| a + b", + }, + { + prefixes: { + a: '__+e + __', + }, + modifications: { + a: undefined, + }, + source: 'd+e + f', + target: 'f', + }, + { + prefixes: { + a: '__+e + __', + }, + modifications: { + a: undefined, + }, + source: 'd + e + f', + target: 'f', + }, +])('modify', ({ prefixes: lines, modifications, source, target }) => { + const prefixes = Prefixes.FromLines(lines as any) + const sourceAst = Ast.parseLine(source) + const edit = sourceAst.module.edit() + const modificationAsts = Object.fromEntries( + Object.entries(modifications).map(([k, v]) => [ + k, + v ? Array.from(v, (mod) => Ast.parse(mod, edit)) : undefined, + ]), + ) + expect(prefixes.modify(edit, sourceAst, modificationAsts).code(edit)).toBe(target) +}) diff --git a/app/gui2/src/util/ast/__tests__/text.ts b/app/gui2/src/util/ast/__tests__/text.ts new file mode 100644 index 00000000000..0a67ac6bb5f --- /dev/null +++ b/app/gui2/src/util/ast/__tests__/text.ts @@ -0,0 +1,10 @@ +import * as astText from '@/util/ast/text' +import { expect, test } from 'vitest' + +test.each([ + { string: 'abcdef_123', escaped: 'abcdef_123' }, + { string: '\t\r\n\v"\'`', escaped: '\\t\\r\\n\\v\\"\\\'``' }, + { string: '`foo` `bar` `baz`', escaped: '``foo`` ``bar`` ``baz``' }, +])('`escape`', ({ string, escaped }) => { + expect(astText.escape(string)).toBe(escaped) +}) diff --git a/app/gui2/src/util/ast/abstract.ts b/app/gui2/src/util/ast/abstract.ts index be79fc904ed..4006eb9a9ca 100644 --- a/app/gui2/src/util/ast/abstract.ts +++ b/app/gui2/src/util/ast/abstract.ts @@ -1,77 +1,92 @@ import * as RawAst from '@/generated/ast' import { parseEnso } from '@/util/ast' import { AstExtended as RawAstExtended } from '@/util/ast/extended' +import type { Opt } from '@/util/data/opt' import { Err, Ok, type Result } from '@/util/data/result' import type { LazyObject } from '@/util/parserSupport' +import { unsafeEntries } from '@/util/record' import * as random from 'lib0/random' import { IdMap, type ExprId } from 'shared/yjsModel' import { reactive } from 'vue' -interface Module { +export interface Module { + get raw(): MutableModule get(id: AstId): Ast | null getExtended(id: AstId): RawAstExtended | undefined + edit(): MutableModule + apply(module: MutableModule): void } -class Committed implements Module { - nodes: Map - astExtended: Map +export class MutableModule implements Module { + base: Module | null + nodes: Map + astExtended: Map | null - constructor() { - this.nodes = reactive(new Map()) - this.astExtended = reactive(new Map()) - } - - /** Returns a syntax node representing the current committed state of the given ID. */ - get(id: AstId): Ast | null { - return this.nodes.get(id) ?? null - } - - getExtended(id: AstId): RawAstExtended | undefined { - return this.astExtended.get(id) - } -} - -class Edit implements Module { - base: Committed - pending: Map - - constructor(base: Committed) { + constructor( + base: Module | null, + nodes: Map, + astExtended: Map | null, + ) { this.base = base - this.pending = new Map() + this.nodes = nodes + this.astExtended = astExtended } - /** Replace all committed values with the state of the uncommitted parse. */ - commit() { - for (const [id, ast] of this.pending.entries()) { + static Observable(): MutableModule { + const nodes = reactive(new Map()) + const astExtended = reactive(new Map()) + return new MutableModule(null, nodes, astExtended) + } + + static Transient(): MutableModule { + const nodes = new Map() + return new MutableModule(null, nodes, null) + } + + edit(): MutableModule { + const nodes = new Map() + return new MutableModule(this, nodes, null) + } + + get raw(): MutableModule { + return this + } + + apply(edit: MutableModule) { + for (const [id, ast] of edit.nodes.entries()) { if (ast === null) { - this.base.nodes.delete(id) + this.nodes.delete(id) } else { - this.base.nodes.set(id, ast) + this.nodes.set(id, ast) } } - this.pending.clear() + if (edit.astExtended) { + if (this.astExtended) + console.error(`Merging astExtended not implemented, probably doesn't make sense`) + this.astExtended = edit.astExtended + } } /** Returns a syntax node representing the current committed state of the given ID. */ get(id: AstId): Ast | null { - const editedNode = this.pending.get(id) + const editedNode = this.nodes.get(id) if (editedNode === null) { return null } else { - return editedNode ?? this.base.get(id) ?? null + return editedNode ?? this.base?.get(id) ?? null } } set(id: AstId, ast: Ast) { - this.pending.set(id, ast) + this.nodes.set(id, ast) } getExtended(id: AstId): RawAstExtended | undefined { - return this.base.astExtended.get(id) + return this.astExtended?.get(id) ?? this.base?.getExtended(id) } delete(id: AstId) { - this.pending.set(id, null) + this.nodes.set(id, null) } } @@ -102,13 +117,17 @@ function newTokenId(): TokenId { } export class Token { - private _code: string + code_: string exprId: TokenId - readonly _tokenType: RawAst.Token.Type - constructor(code: string, id: TokenId, type: RawAst.Token.Type) { - this._code = code + tokenType_: RawAst.Token.Type | undefined + constructor(code: string, id: TokenId, type: RawAst.Token.Type | undefined) { + this.code_ = code this.exprId = id - this._tokenType = type + this.tokenType_ = type + } + + static new(code: string) { + return new Token(code, newTokenId(), undefined) } // Compatibility wrapper for `exprId`. @@ -117,18 +136,19 @@ export class Token { } code(): string { - return this._code + return this.code_ } typeName(): string { - return RawAst.Token.typeNames[this._tokenType]! + if (this.tokenType_) return RawAst.Token.typeNames[this.tokenType_]! + else return 'Raw' } } export abstract class Ast { readonly treeType: RawAst.Tree.Type | undefined _id: AstId - readonly module: Committed + readonly module: Module // Deprecated interface for incremental integration of Ast API. Eliminate usages for #8367. get astExtended(): RawAstExtended | undefined { @@ -140,16 +160,12 @@ export abstract class Ast { } static deserialize(serialized: string): Ast { - const parsed: any = JSON.parse(serialized) - const nodes: NodeSpanMap = new Map(Object.entries(parsed.info.nodes)) - const tokens: TokenSpanMap = new Map(Object.entries(parsed.info.tokens)) - const module = new Committed() - const edit = new Edit(module) + const parsed: SerializedPrintedSource = JSON.parse(serialized) + const nodes = new Map(unsafeEntries(parsed.info.nodes)) + const tokens = new Map(unsafeEntries(parsed.info.tokens)) + const module = MutableModule.Transient() const tree = parseEnso(parsed.code) - type NodeSpanMap = Map - type TokenSpanMap = Map - const root = abstract(edit, tree, parsed.code, { nodes, tokens }).node - edit.commit() + const root = abstract(module, tree, parsed.code, { nodes, tokens }).node return module.get(root)! } @@ -176,8 +192,8 @@ export abstract class Ast { /** Returns child subtrees, including information about the whitespace between them. */ abstract concreteChildren(): IterableIterator - code(): string { - return print(this).code + code(module?: Module): string { + return print(this, module).code } repr(): string { @@ -189,17 +205,25 @@ export abstract class Ast { return RawAst.Tree.typeNames[this.treeType] } - static parse(source: PrintedSource | string): Ast { + static parse(source: PrintedSource | string, inModule?: MutableModule): Ast { const code = typeof source === 'object' ? source.code : source const ids = typeof source === 'object' ? source.info : undefined const tree = parseEnso(code) - const module = new Committed() - const edit = new Edit(module) - const newRoot = abstract(edit, tree, code, ids).node - edit.commit() + const module = inModule ?? MutableModule.Observable() + const newRoot = abstract(module, tree, code, ids).node return module.get(newRoot)! } + static parseLine(source: PrintedSource | string): Ast { + const ast = Ast.parse(source) + if (ast instanceof BodyBlock) { + const [expr] = ast.expressions() + return expr instanceof Ast ? expr : ast + } else { + return ast + } + } + visitRecursive(visit: (node: Ast | Token) => void) { visit(this) for (const child of this.concreteChildren()) { @@ -211,21 +235,23 @@ export abstract class Ast { } } - protected constructor(module: Edit, id?: AstId, treeType?: RawAst.Tree.Type) { - this.module = module.base + protected constructor(module: MutableModule, id?: AstId, treeType?: RawAst.Tree.Type) { + this.module = module this._id = id ?? newNodeId() this.treeType = treeType module.set(this._id, this) } - _print(info: InfoMap, offset: number, indent: string): string { + _print( + info: InfoMap, + offset: number, + indent: string, + moduleOverride?: Module | undefined, + ): string { + const module_ = moduleOverride ?? this.module let code = '' for (const child of this.concreteChildren()) { - if ( - child.node != null && - !(child.node instanceof Token) && - this.module.get(child.node) === null - ) + if (child.node != null && !(child.node instanceof Token) && module_.get(child.node) === null) continue if (child.whitespace != null) { code += child.whitespace @@ -237,7 +263,9 @@ export abstract class Ast { if (child.node instanceof Token) { code += child.node.code() } else { - code += this.module.get(child.node)!._print(info, offset + code.length, indent) + code += module_ + .get(child.node)! + ._print(info, offset + code.length, indent, moduleOverride) } } } @@ -253,12 +281,12 @@ export abstract class Ast { } export class App extends Ast { - private _func: NodeChild - private _leftParen: NodeChild | null - private _argumentName: NodeChild | null - private _equals: NodeChild | null - private _arg: NodeChild - private _rightParen: NodeChild | null + _func: NodeChild + _leftParen: NodeChild | null + _argumentName: NodeChild | null + _equals: NodeChild | null + _arg: NodeChild + _rightParen: NodeChild | null get function(): Ast { return this.module.get(this._func.node)! @@ -273,7 +301,7 @@ export class App extends Ast { } constructor( - module: Edit, + module: MutableModule, id: AstId | undefined, func: NodeChild, leftParen: NodeChild | null, @@ -303,8 +331,26 @@ export class App extends Ast { } } +const mapping: Record = { + '\b': '\\b', + '\f': '\\f', + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', + '\v': '\\v', + '"': '\\"', + "'": "\\'", + '`': '``', +} + +/** Escape a string so it can be safely spliced into an interpolated (`''`) Enso string. + * NOT USABLE to insert into raw strings. Does not include quotes. */ +export function escape(string: string) { + return string.replace(/[\0\b\f\n\r\t\v"'`]/g, (match) => mapping[match]!) +} + function positionalApp( - module: Edit, + module: MutableModule, id: AstId | undefined, func: NodeChild, arg: NodeChild, @@ -313,7 +359,7 @@ function positionalApp( } function namedApp( - module: Edit, + module: MutableModule, id: AstId | undefined, func: NodeChild, leftParen: NodeChild | null, @@ -336,8 +382,8 @@ function namedApp( } export class UnaryOprApp extends Ast { - private _opr: NodeChild - private _arg: NodeChild | null + _opr: NodeChild + _arg: NodeChild | null get operator(): Token { return this._opr.node @@ -349,7 +395,7 @@ export class UnaryOprApp extends Ast { } constructor( - module: Edit, + module: MutableModule, id: AstId | undefined, opr: NodeChild, arg: NodeChild | null, @@ -367,7 +413,7 @@ export class UnaryOprApp extends Ast { export class NegationOprApp extends UnaryOprApp { constructor( - module: Edit, + module: MutableModule, id: AstId | undefined, opr: NodeChild, arg: NodeChild | null, @@ -377,9 +423,9 @@ export class NegationOprApp extends UnaryOprApp { } export class OprApp extends Ast { - protected _lhs: NodeChild | null - protected _opr: NodeChild[] - protected _rhs: NodeChild | null + _lhs: NodeChild | null + _opr: NodeChild[] + _rhs: NodeChild | null get lhs(): Ast | null { return this._lhs ? this.module.get(this._lhs.node) : null @@ -399,7 +445,7 @@ export class OprApp extends Ast { } constructor( - module: Edit, + module: MutableModule, id: AstId | undefined, lhs: NodeChild | null, opr: NodeChild[], @@ -420,7 +466,7 @@ export class OprApp extends Ast { export class PropertyAccess extends OprApp { constructor( - module: Edit, + module: MutableModule, id: AstId | undefined, lhs: NodeChild | null, opr: NodeChild, @@ -432,9 +478,14 @@ export class PropertyAccess extends OprApp { /** Representation without any type-specific accessors, for tree types that don't require any special treatment. */ export class Generic extends Ast { - private readonly _children: NodeChild[] + _children: NodeChild[] - constructor(module: Edit, id?: AstId, children?: NodeChild[], treeType?: RawAst.Tree.Type) { + constructor( + module: MutableModule, + id?: AstId, + children?: NodeChild[], + treeType?: RawAst.Tree.Type, + ) { super(module, id, treeType) this._children = children ?? [] } @@ -447,39 +498,39 @@ export class Generic extends Ast { type MultiSegmentAppSegment = { header: NodeChild; body: NodeChild | null } export class Import extends Ast { - private polyglot_: MultiSegmentAppSegment | null - private from_: MultiSegmentAppSegment | null - private import__: MultiSegmentAppSegment - private all_: NodeChild | null - private as_: MultiSegmentAppSegment | null - private hiding_: MultiSegmentAppSegment | null + _polyglot: MultiSegmentAppSegment | null + _from: MultiSegmentAppSegment | null + _import: MultiSegmentAppSegment + _all: NodeChild | null + _as: MultiSegmentAppSegment | null + _hiding: MultiSegmentAppSegment | null get polyglot(): Ast | null { - return this.polyglot_?.body ? this.module.get(this.polyglot_.body.node) : null + return this._polyglot?.body ? this.module.get(this._polyglot.body.node) : null } get from(): Ast | null { - return this.from_?.body ? this.module.get(this.from_.body.node) : null + return this._from?.body ? this.module.get(this._from.body.node) : null } get import_(): Ast | null { - return this.import__?.body ? this.module.get(this.import__.body.node) : null + return this._import?.body ? this.module.get(this._import.body.node) : null } get all(): Token | null { - return this.all_?.node ?? null + return this._all?.node ?? null } get as(): Ast | null { - return this.as_?.body ? this.module.get(this.as_.body.node) : null + return this._as?.body ? this.module.get(this._as.body.node) : null } get hiding(): Ast | null { - return this.hiding_?.body ? this.module.get(this.hiding_.body.node) : null + return this._hiding?.body ? this.module.get(this._hiding.body.node) : null } constructor( - module: Edit, + module: MutableModule, id: AstId | undefined, polyglot: MultiSegmentAppSegment | null, from: MultiSegmentAppSegment | null, @@ -489,12 +540,12 @@ export class Import extends Ast { hiding: MultiSegmentAppSegment | null, ) { super(module, id, RawAst.Tree.Type.Import) - this.polyglot_ = polyglot - this.from_ = from - this.import__ = import_ - this.all_ = all - this.as_ = as - this.hiding_ = hiding + this._polyglot = polyglot + this._from = from + this._import = import_ + this._all = all + this._as = as + this._hiding = hiding } *concreteChildren(): IterableIterator { @@ -504,23 +555,23 @@ export class Import extends Ast { if (segment?.body) parts.push(segment.body) return parts } - yield* segment(this.polyglot_) - yield* segment(this.from_) - yield* segment(this.import__) - if (this.all_) yield this.all_ - yield* segment(this.as_) - yield* segment(this.hiding_) + yield* segment(this._polyglot) + yield* segment(this._from) + yield* segment(this._import) + if (this._all) yield this._all + yield* segment(this._as) + yield* segment(this._hiding) } } export class TextLiteral extends Ast { - private readonly open_: NodeChild | null - private readonly newline_: NodeChild | null - private readonly elements_: NodeChild[] - private readonly close_: NodeChild | null + _open: NodeChild | null + _newline: NodeChild | null + _elements: NodeChild[] + _close: NodeChild | null constructor( - module: Edit, + module: MutableModule, id: AstId | undefined, open: NodeChild | null, newline: NodeChild | null, @@ -528,62 +579,70 @@ export class TextLiteral extends Ast { close: NodeChild | null, ) { super(module, id, RawAst.Tree.Type.TextLiteral) - this.open_ = open - this.newline_ = newline - this.elements_ = elements - this.close_ = close + this._open = open + this._newline = newline + this._elements = elements + this._close = close + } + + static new(rawText: string): TextLiteral { + const module = MutableModule.Transient() + const text = Token.new(escape(rawText)) + return new TextLiteral(module, undefined, { node: Token.new("'") }, null, [{ node: text }], { + node: Token.new("'"), + }) } *concreteChildren(): IterableIterator { - if (this.open_) yield this.open_ - if (this.newline_) yield this.newline_ - yield* this.elements_ - if (this.close_) yield this.close_ + if (this._open) yield this._open + if (this._newline) yield this._newline + yield* this._elements + if (this._close) yield this._close } } export class Invalid extends Ast { - private readonly expression_: NodeChild + _expression: NodeChild - constructor(module: Edit, id: AstId | undefined, expression: NodeChild) { + constructor(module: MutableModule, id: AstId | undefined, expression: NodeChild) { super(module, id, RawAst.Tree.Type.Invalid) - this.expression_ = expression + this._expression = expression } *concreteChildren(): IterableIterator { - yield this.expression_ + yield this._expression } } export class Group extends Ast { - private readonly open_: NodeChild | undefined - private readonly expression_: NodeChild | null - private readonly close_: NodeChild | undefined + _open: NodeChild | undefined + _expression: NodeChild | null + _close: NodeChild | undefined constructor( - module: Edit, + module: MutableModule, id: AstId | undefined, open: NodeChild | undefined, expression: NodeChild | null, close: NodeChild | undefined, ) { super(module, id, RawAst.Tree.Type.Group) - this.open_ = open - this.expression_ = expression - this.close_ = close + this._open = open + this._expression = expression + this._close = close } *concreteChildren(): IterableIterator { - if (this.open_) yield this.open_ - if (this.expression_) yield this.expression_ - if (this.close_) yield this.close_ + if (this._open) yield this._open + if (this._expression) yield this._expression + if (this._close) yield this._close } } export class NumericLiteral extends Ast { - private readonly _tokens: NodeChild[] + _tokens: NodeChild[] - constructor(module: Edit, id: AstId | undefined, tokens: NodeChild[]) { + constructor(module: MutableModule, id: AstId | undefined, tokens: NodeChild[]) { super(module, id, RawAst.Tree.Type.Number) this._tokens = tokens ?? [] } @@ -594,11 +653,12 @@ export class NumericLiteral extends Ast { } type FunctionArgument = NodeChild[] + export class Function extends Ast { - private _name: NodeChild - private _args: FunctionArgument[] - private _equals: NodeChild - private _body: NodeChild | null + _name: NodeChild + _args: FunctionArgument[] + _equals: NodeChild + _body: NodeChild | null // FIXME for #8367: This should not be nullable. If the `ExprId` has been deleted, the same placeholder logic should be applied // here and in `rawChildren` (and indirectly, `print`). get name(): Ast | null { @@ -616,7 +676,7 @@ export class Function extends Ast { } } constructor( - module: Edit, + module: MutableModule, id: AstId | undefined, name: NodeChild, args: FunctionArgument[], @@ -640,9 +700,9 @@ export class Function extends Ast { } export class Assignment extends Ast { - private _pattern: NodeChild - private _equals: NodeChild - private _expression: NodeChild + _pattern: NodeChild + _equals: NodeChild + _expression: NodeChild get pattern(): Ast | null { return this.module.get(this._pattern.node) } @@ -650,7 +710,7 @@ export class Assignment extends Ast { return this.module.get(this._expression.node) } constructor( - module: Edit, + module: MutableModule, id: AstId | undefined, pattern: NodeChild, equals: NodeChild, // TODO: Edits (#8367): Allow undefined @@ -682,12 +742,13 @@ export class Assignment extends Ast { } } -type BlockLine = { +interface BlockLine { newline: NodeChild // Edits (#8367): Allow undefined expression: NodeChild | null } + export class BodyBlock extends Ast { - private _lines: BlockLine[]; + _lines: BlockLine[]; *expressions(): IterableIterator { for (const line of this._lines) { @@ -702,7 +763,7 @@ export class BodyBlock extends Ast { } } - constructor(module: Edit, id: AstId | undefined, lines: BlockLine[]) { + constructor(module: MutableModule, id: AstId | undefined, lines: BlockLine[]) { super(module, id, RawAst.Tree.Type.BodyBlock) this._lines = lines } @@ -730,16 +791,24 @@ export class BodyBlock extends Ast { } } - _print(info: InfoMap, offset: number, indent: string): string { + _print( + info: InfoMap, + offset: number, + indent: string, + moduleOverride?: Module | undefined, + ): string { + const module_ = moduleOverride ?? this.module let code = '' for (const line of this._lines) { - if (line.expression?.node != null && this.module.get(line.expression.node) === null) continue + if (line.expression?.node != null && module_.get(line.expression.node) === null) continue code += line.newline?.whitespace ?? '' code += line.newline?.node.code() ?? '\n' if (line.expression !== null) { code += line.expression.whitespace ?? indent if (line.expression.node !== null) { - code += this.module.get(line.expression.node)!._print(info, offset, indent + ' ') + code += module_ + .get(line.expression.node)! + ._print(info, offset, indent + ' ', moduleOverride) } } } @@ -757,7 +826,7 @@ export class BodyBlock extends Ast { export class Ident extends Ast { public token: NodeChild - constructor(module: Edit, id: AstId | undefined, token: NodeChild) { + constructor(module: MutableModule, id: AstId | undefined, token: NodeChild) { super(module, id, RawAst.Tree.Type.Ident) this.token = token } @@ -777,18 +846,16 @@ export class Ident extends Ast { export class Wildcard extends Ast { public token: NodeChild - constructor(module: Edit, id: AstId | undefined, token: NodeChild) { + constructor(module: MutableModule, id: AstId | undefined, token: NodeChild) { super(module, id, RawAst.Tree.Type.Wildcard) this.token = token } static new(): Wildcard { - const module = new Committed() - const edit = new Edit(module) - const ast = new Wildcard(edit, undefined, { + const module = MutableModule.Transient() + const ast = new Wildcard(module, undefined, { node: new Token('_', newTokenId(), RawAst.Token.Type.Wildcard), }) - edit.commit() return ast } @@ -798,9 +865,9 @@ export class Wildcard extends Ast { } export class RawCode extends Ast { - private _code: NodeChild + _code: NodeChild - constructor(module: Edit, id: AstId | undefined, code: NodeChild) { + constructor(module: MutableModule, id: AstId | undefined, code: NodeChild) { super(module, id) this._code = code } @@ -819,7 +886,7 @@ export class RawCode extends Ast { } function abstract( - module: Edit, + module: MutableModule, tree: RawAst.Tree, code: string, info: InfoMap | undefined, @@ -830,8 +897,9 @@ function abstract( const tokenIds = info?.tokens ?? new Map() return abstractTree(module, tree, code, nodesExpected, tokenIds) } + function abstractTree( - module: Edit, + module: MutableModule, tree: RawAst.Tree, code: string, nodesExpected: NodeSpanMap, @@ -858,8 +926,9 @@ function abstractTree( const whitespaceEnd = whitespaceStart + tree.whitespaceLengthInCodeParsed const codeStart = whitespaceEnd const codeEnd = codeStart + tree.childrenLengthInCodeParsed - // All node types use this value in the same way to obtain the ID type, but each node does so separately because we - // must pop the tree's span from the ID map *after* processing children. + // All node types use this value in the same way to obtain the ID type, + // but each node does so separately because we must pop the tree's span from the ID map + // *after* processing children. const spanKey = nodeKey(codeStart, codeEnd - codeStart, tree.type) let node: AstId switch (tree.type) { @@ -1030,54 +1099,68 @@ function abstractToken( return { whitespace, node } } -type NodeKey = string -type TokenKey = string -function nodeKey(start: number, length: number, type: RawAst.Tree.Type | undefined): NodeKey { +declare const nodeKeyBrand: unique symbol +type NodeKey = string & { [nodeKeyBrand]: never } +declare const tokenKeyBrand: unique symbol +type TokenKey = string & { [tokenKeyBrand]: never } +function nodeKey(start: number, length: number, type: Opt): NodeKey { const type_ = type?.toString() ?? '?' - return `${start}:${length}:${type_}` + return `${start}:${length}:${type_}` as NodeKey } function tokenKey(start: number, length: number): TokenKey { - return `${start}:${length}` + return `${start}:${length}` as TokenKey +} + +interface SerializedInfoMap { + nodes: Record + tokens: Record +} + +interface SerializedPrintedSource { + info: SerializedInfoMap + code: string } type NodeSpanMap = Map type TokenSpanMap = Map -export type InfoMap = { + +export interface InfoMap { nodes: NodeSpanMap tokens: TokenSpanMap } -type PrintedSource = { +interface PrintedSource { info: InfoMap code: string } /** Return stringification with associated ID map. This is only exported for testing. */ -export function print(ast: Ast): PrintedSource { +export function print(ast: Ast, module?: Module | undefined): PrintedSource { const info: InfoMap = { nodes: new Map(), tokens: new Map(), } - const code = ast._print(info, 0, '') + const code = ast._print(info, 0, '', module) return { info, code } } -type DebugTree = (DebugTree | string)[] -export function debug(root: Ast, universe?: Map): DebugTree { +export type TokenTree = (TokenTree | string)[] + +export function tokenTree(root: Ast): TokenTree { const module = root.module return Array.from(root.concreteChildren(), (child) => { if (child.node instanceof Token) { return child.node.code() } else { const node = module.get(child.node) - return node ? debug(node, universe) : '' + return node ? tokenTree(node) : '' } }) } // FIXME: We should use alias analysis to handle ambiguous names correctly. -export function findModuleMethod(module: Committed, name: string): Function | null { - for (const node of module.nodes.values()) { +export function findModuleMethod(module: Module, name: string): Function | null { + for (const node of module.raw.nodes.values()) { if (node instanceof Function) { if (node.name && node.name.code() === name) { return node @@ -1087,7 +1170,7 @@ export function findModuleMethod(module: Committed, name: string): Function | nu return null } -export function functionBlock(module: Committed, name: string): BodyBlock | null { +export function functionBlock(module: Module, name: string): BodyBlock | null { const method = findModuleMethod(module, name) if (!method || !(method.body instanceof BodyBlock)) return null return method.body @@ -1120,7 +1203,7 @@ export function parseTransitional(code: string, idMap: IdMap): Ast { idMap.finishAndSynchronize() const nodes = new Map() const tokens = new Map() - const astExtended = new Map() + const astExtended = reactive(new Map()) legacyAst.visitRecursive((nodeOrToken: RawAstExtended) => { const start = nodeOrToken.span()[0] const length = nodeOrToken.span()[1] - nodeOrToken.span()[0] @@ -1154,13 +1237,12 @@ export function parseTransitional(code: string, idMap: IdMap): Ast { return true }) const newRoot = Ast.parse({ info: { nodes, tokens }, code }) - newRoot.module.astExtended = astExtended + newRoot.module.raw.astExtended = astExtended return newRoot } -export function parse(source: PrintedSource | string): Ast { - return Ast.parse(source) -} +export const parse = Ast.parse +export const parseLine = Ast.parseLine export function deserialize(serialized: string): Ast { return Ast.deserialize(serialized) diff --git a/app/gui2/src/util/ast/match.ts b/app/gui2/src/util/ast/match.ts new file mode 100644 index 00000000000..460afffb908 --- /dev/null +++ b/app/gui2/src/util/ast/match.ts @@ -0,0 +1,98 @@ +import { Ast } from '@/util/ast' +import { MutableModule } from '@/util/ast/abstract' + +export class Pattern { + private readonly tokenTree: Ast.TokenTree + private readonly template: string + private readonly placeholder: string + + constructor(template: string, placeholder: string) { + this.tokenTree = Ast.tokenTree(Ast.parseLine(template)) + this.template = template + this.placeholder = placeholder + } + + /** Parse an expression template in which a specified identifier (by default `__`) + * may match any arbitrary subtree. */ + static parse(template: string, placeholder: string = '__'): Pattern { + return new Pattern(template, placeholder) + } + + /** If the given expression matches the pattern, return the subtrees that matched the holes in the pattern. */ + match(target: Ast.Ast): Ast.AstId[] | undefined { + const extracted: Ast.AstId[] = [] + if (this.tokenTree.length === 1 && this.tokenTree[0] === this.placeholder) { + return [target.exprId] + } + if ( + isMatch_( + this.tokenTree, + target.concreteChildren(), + target.module, + this.placeholder, + extracted, + ) + ) { + return extracted + } + } + + /** Create a new concrete example of the pattern, with the placeholders replaced with the given subtrees. + * The subtree IDs provided must be accessible in the `edit` module. */ + instantiate(edit: MutableModule, subtrees: Ast.AstId[]): Ast.Ast { + const ast = Ast.parse(this.template, edit) + for (const matched of placeholders(ast, this.placeholder)) { + const replacement = subtrees.shift() + if (replacement === undefined) break + matched.node = replacement + } + return ast + } +} + +function isMatch_( + pattern: Ast.TokenTree, + target: Iterator, + module: Ast.Module, + placeholder: string, + extracted: Ast.AstId[], +): boolean { + for (const subpattern of pattern) { + const next = target.next() + if (next.done) return false + const astOrToken = next.value.node + const isPlaceholder = typeof subpattern !== 'string' && subpattern[0] === placeholder + if (typeof subpattern === 'string') { + if (!(astOrToken instanceof Ast.Token) || astOrToken.code() !== subpattern) { + return false + } + } else if (astOrToken instanceof Ast.Token) { + return false + } else if (isPlaceholder) { + extracted.push(astOrToken) + } else { + const ast = module.get(astOrToken) + if (!ast) return false + if (!isMatch_(subpattern, ast.concreteChildren(), module, placeholder, extracted)) + return false + } + } + return true +} + +function placeholders(ast: Ast.Ast, placeholder: string, outIn?: Ast.NodeChild[]) { + const out = outIn ?? [] + for (const child of ast.concreteChildren()) { + if (!(child.node instanceof Ast.Token)) { + // The type of `child` has been determined by checking the type of `child.node` + const nodeChild = child as Ast.NodeChild + const subtree = ast.module.get(child.node)! + if (subtree instanceof Ast.Ident && subtree.code() === placeholder) { + out.push(nodeChild) + } else { + placeholders(subtree, placeholder, out) + } + } + } + return out +} diff --git a/app/gui2/src/util/ast/node.ts b/app/gui2/src/util/ast/node.ts new file mode 100644 index 00000000000..19585c4afba --- /dev/null +++ b/app/gui2/src/util/ast/node.ts @@ -0,0 +1,23 @@ +import type { Node } from '@/stores/graph' +import { Ast } from '@/util/ast' +import { Vec2 } from '@/util/data/vec2' + +export function nodeFromAst(ast: Ast.Ast): Node { + if (ast instanceof Ast.Assignment) { + return { + outerExprId: ast.astId, + pattern: ast.pattern ?? undefined, + rootSpan: ast.expression ?? ast, + position: Vec2.Zero, + vis: undefined, + } + } else { + return { + outerExprId: ast.astId, + pattern: undefined, + rootSpan: ast, + position: Vec2.Zero, + vis: undefined, + } + } +} diff --git a/app/gui2/src/util/ast/prefixes.ts b/app/gui2/src/util/ast/prefixes.ts new file mode 100644 index 00000000000..2f604c219fb --- /dev/null +++ b/app/gui2/src/util/ast/prefixes.ts @@ -0,0 +1,60 @@ +import { Ast } from '@/util/ast' +import { Pattern } from '@/util/ast/match' +import { unsafeKeys } from '@/util/record' + +type Matches = Record + +interface MatchResult { + innerExpr: Ast.Ast + matches: Record +} + +export class Prefixes> { + constructor( + /** Note that these are checked in order of definition. */ + public prefixes: T, + ) {} + + /** Note that these are checked in order of definition. */ + static FromLines(lines: Record) { + return new Prefixes( + Object.fromEntries( + Object.entries(lines).map(([name, line]) => [name, Pattern.parse(line)]), + ) as Record, + ) + } + + extractMatches(expression: Ast.Ast): MatchResult { + const matches = Object.fromEntries( + Object.entries(this.prefixes).map(([name, pattern]) => { + const matchIds = pattern.match(expression) + const matches = matchIds + ? Array.from(matchIds, (id) => expression.module.get(id)!) + : undefined + const lastMatch = matches != null ? matches[matches.length - 1] : undefined + if (lastMatch) expression = lastMatch + return [name, matches] + }), + ) as Matches + return { matches, innerExpr: expression } + } + + modify( + edit: Ast.MutableModule, + expression: Ast.Ast, + replacements: Partial>, + ) { + const matches = this.extractMatches(expression) + let result = matches.innerExpr + for (const key of unsafeKeys(this.prefixes).reverse()) { + if (key in replacements && !replacements[key]) continue + const replacement: Ast.Ast[] | undefined = replacements[key] ?? matches.matches[key] + if (!replacement) continue + const pattern = this.prefixes[key] + const parts = [...replacement, result] + const partsIds = Array.from(parts, (ast) => ast.exprId) + result = pattern.instantiate(edit, partsIds) + } + return result + } +} diff --git a/app/gui2/src/util/data/iterable.ts b/app/gui2/src/util/data/iterable.ts index 56f95938ee9..a30a6c2fa1c 100644 --- a/app/gui2/src/util/data/iterable.ts +++ b/app/gui2/src/util/data/iterable.ts @@ -36,15 +36,27 @@ export function* chain(...iters: Iterable[]) { export function* zip(left: Iterable, right: Iterable): Generator<[T, U]> { const leftIterator = left[Symbol.iterator]() const rightIterator = right[Symbol.iterator]() - while (true) { const leftResult = leftIterator.next() const rightResult = rightIterator.next() - - if (leftResult.done || rightResult.done) { - break - } - + if (leftResult.done || rightResult.done) break yield [leftResult.value, rightResult.value] } } + +export function* zipLongest( + left: Iterable, + right: Iterable, +): Generator<[T | undefined, U | undefined]> { + const leftIterator = left[Symbol.iterator]() + const rightIterator = right[Symbol.iterator]() + while (true) { + const leftResult = leftIterator.next() + const rightResult = rightIterator.next() + if (leftResult.done && rightResult.done) break + yield [ + leftResult.done ? undefined : leftResult.value, + rightResult.done ? undefined : rightResult.value, + ] + } +} diff --git a/app/gui2/src/util/net.ts b/app/gui2/src/util/net.ts index 73b23f727f8..63389d159d1 100644 --- a/app/gui2/src/util/net.ts +++ b/app/gui2/src/util/net.ts @@ -211,7 +211,16 @@ export class AsyncQueue { if (task == null) return this.taskRunning = true this.lastTask = this.lastTask - .then((state) => task(state)) + .then( + (state) => task(state), + (error) => { + console.error( + "AsyncQueue failed to run task '" + task.toString() + "' with error:", + error, + ) + throw error + }, + ) .finally(() => { this.taskRunning = false this.run() diff --git a/app/gui2/src/util/record.ts b/app/gui2/src/util/record.ts new file mode 100644 index 00000000000..752a3381f08 --- /dev/null +++ b/app/gui2/src/util/record.ts @@ -0,0 +1,9 @@ +/** Unsafe whe the record can have extra keys which are not in `K`. */ +export function unsafeEntries(obj: Record): [K, V][] { + return Object.entries(obj) as any +} + +/** Unsafe whe the record can have extra keys which are not in `K`. */ +export function unsafeKeys(obj: Record): K[] { + return Object.keys(obj) as any +} diff --git a/app/gui2/src/util/theme.json b/app/gui2/src/util/theme.json index 90aecec1ca8..2c76abc7f96 100644 --- a/app/gui2/src/util/theme.json +++ b/app/gui2/src/util/theme.json @@ -4,5 +4,21 @@ "corner_radius": 16, "vertical_gap": 32, "horizontal_gap": 32 + }, + "edge": { + "min_approach_height": 32, + "radius": 20, + "one_corner": { + "radius_y_adjustment": 29, + "radius_x_base": 20, + "radius_x_factor": 0.6, + "source_node_overlap": 4, + "minimum_tangent_exit_radius": 2 + }, + "three_corner": { + "radius_max": 20, + "backward_edge_arrow_threshold": 15, + "max_squeeze": 2 + } } } diff --git a/app/gui2/src/util/theme.ts b/app/gui2/src/util/theme.ts new file mode 100644 index 00000000000..d6cc6543239 --- /dev/null +++ b/app/gui2/src/util/theme.ts @@ -0,0 +1,60 @@ +import originalTheme from '@/util/theme.json' + +const theme: Theme = originalTheme +export default theme + +export interface Theme { + /** Configuration for node rendering. */ + node: NodeTheme + /** Configuration for edge rendering. */ + edge: EdgeTheme +} + +/** Configuration for node rendering. */ +export interface NodeTheme { + /** The default height of a node. */ + height: number + /** The maximum corner radius of a node. If the node is shorter than `2 * corner_radius`, + * the corner radius will be half of the node's height instead. */ + corner_radius: number + /** The vertical gap between nodes in automatic layout. */ + vertical_gap: number + /** The horizontal gap between nodes in automatic layout. */ + horizontal_gap: number +} + +/** Configuration for edge rendering. */ +export interface EdgeTheme { + /** Minimum height above the target the edge must approach it from. */ + min_approach_height: number + /** The preferred arc radius for corners, when an edge changes direction. */ + radius: number + /** Configuration for edges that change direction once. */ + one_corner: EdgeOneCornerTheme + /** Configuration for edges with change direction three times. */ + three_corner: EdgeThreeCornerTheme +} + +/** Configuration for edges that change direction once. */ +export interface EdgeOneCornerTheme { + /** The y-allocation for the radius will be the full available height minus this value. */ + radius_y_adjustment: number + /** The base x-allocation for the radius. */ + radius_x_base: number + /** Proportion (0-1) of extra x-distance allocated to the radius. */ + radius_x_factor: number + /** Distance for the line to continue under the node, to ensure that there isn't a gap. */ + source_node_overlap: number + /** Minimum arc radius at which we offset the source end to exit normal to the node's curve. */ + minimum_tangent_exit_radius: number +} + +/** Configuration for edges with change direction three times. */ +export interface EdgeThreeCornerTheme { + /** The maximum arc radius. */ + radius_max: number + backward_edge_arrow_threshold: number + /** The maximum radius reduction (from [`RADIUS_BASE`]) to allow when choosing whether to use + * the three-corner layout that doesn't use a backward corner. */ + max_squeeze: number +} diff --git a/app/gui2/tsconfig.app.json b/app/gui2/tsconfig.app.json index 511c7be1ffc..61df35bdb3a 100644 --- a/app/gui2/tsconfig.app.json +++ b/app/gui2/tsconfig.app.json @@ -6,7 +6,9 @@ "src/**/*.vue", "shared/**/*", "shared/**/*.vue", - "src/util/theme.json" + "src/util/theme.json", + "stories/mockSuggestions.json", + "mock/**/*" ], "exclude": ["src/**/__tests__/*", "shared/**/__tests__/*", "public/**/__tests__/*"], "compilerOptions": { @@ -15,6 +17,7 @@ "composite": true, "outDir": "../../node_modules/.cache/tsc", "baseUrl": ".", + "noEmit": true, "allowImportingTsExtensions": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, diff --git a/app/gui2/tsconfig.node.json b/app/gui2/tsconfig.node.json index adf8b513487..e7fd31f4b44 100644 --- a/app/gui2/tsconfig.node.json +++ b/app/gui2/tsconfig.node.json @@ -8,7 +8,8 @@ "histoire.config.ts", "e2e/**/*", "parser-codegen/**/*", - "node.env.d.ts" + "node.env.d.ts", + "mock/engine.ts" ], "compilerOptions": { "baseUrl": ".",