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": ".",