diff --git a/.prettierignore b/.prettierignore index f96934c69e..72a0488608 100644 --- a/.prettierignore +++ b/.prettierignore @@ -38,6 +38,7 @@ app/ide-desktop/lib/dashboard/playwright/.cache/ app/ide-desktop/lib/dashboard/dist/ app/gui/view/documentation/assets/stylesheet.css app/gui2/rust-ffi/pkg +app/gui2/rust-ffi/node-pkg app/gui2/src/assets/font-*.css Cargo.lock build.json diff --git a/app/gui2/e2e/edgeRendering.spec.ts b/app/gui2/e2e/edgeRendering.spec.ts index c90de9bc10..5691d0c69a 100644 --- a/app/gui2/e2e/edgeRendering.spec.ts +++ b/app/gui2/e2e/edgeRendering.spec.ts @@ -1,4 +1,4 @@ -import { expect, Page, test } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' import * as actions from './actions' import * as locate from './locate' @@ -13,28 +13,29 @@ async function edgesToNodeWithBinding(page: Page, binding: string) { return page.locator(`[data-target-node-id="${nodeId}"]`) } +// For each outgoing edge we expect two elements: an element for io and an element for the rendered edge itself. +const EDGE_PARTS = 2 + test('Existence of edges between nodes', async ({ page }) => { await actions.goToGraph(page) - // For each outgoing edge we expect three elements: an element for io, an element for the arrow, and an element for the rendered edge itself. - await expect(await edgesFromNodeWithBinding(page, 'aggregated')).toHaveCount(0) await expect(await edgesFromNodeWithBinding(page, 'filtered')).toHaveCount(0) - await expect(await edgesFromNodeWithBinding(page, 'data')).toHaveCount(6) + await expect(await edgesFromNodeWithBinding(page, 'data')).toHaveCount(2 * EDGE_PARTS) await expect(await edgesFromNodeWithBinding(page, 'list')).toHaveCount(0) await expect(await edgesFromNodeWithBinding(page, 'final')).toHaveCount(0) - await expect(await edgesFromNodeWithBinding(page, 'prod')).toHaveCount(3) - await expect(await edgesFromNodeWithBinding(page, 'sum')).toHaveCount(3) - await expect(await edgesFromNodeWithBinding(page, 'ten')).toHaveCount(3) - await expect(await edgesFromNodeWithBinding(page, 'five')).toHaveCount(3) + await expect(await edgesFromNodeWithBinding(page, 'prod')).toHaveCount(EDGE_PARTS) + await expect(await edgesFromNodeWithBinding(page, 'sum')).toHaveCount(EDGE_PARTS) + await expect(await edgesFromNodeWithBinding(page, 'ten')).toHaveCount(EDGE_PARTS) + await expect(await edgesFromNodeWithBinding(page, 'five')).toHaveCount(EDGE_PARTS) - await expect(await edgesToNodeWithBinding(page, 'aggregated')).toHaveCount(3) - await expect(await edgesToNodeWithBinding(page, 'filtered')).toHaveCount(3) + await expect(await edgesToNodeWithBinding(page, 'aggregated')).toHaveCount(EDGE_PARTS) + await expect(await edgesToNodeWithBinding(page, 'filtered')).toHaveCount(EDGE_PARTS) await expect(await edgesToNodeWithBinding(page, 'data')).toHaveCount(0) await expect(await edgesToNodeWithBinding(page, 'list')).toHaveCount(0) - await expect(await edgesToNodeWithBinding(page, 'final')).toHaveCount(3) - await expect(await edgesToNodeWithBinding(page, 'prod')).toHaveCount(3) - await expect(await edgesToNodeWithBinding(page, 'sum')).toHaveCount(6) + await expect(await edgesToNodeWithBinding(page, 'final')).toHaveCount(EDGE_PARTS) + await expect(await edgesToNodeWithBinding(page, 'prod')).toHaveCount(EDGE_PARTS) + await expect(await edgesToNodeWithBinding(page, 'sum')).toHaveCount(2 * EDGE_PARTS) await expect(await edgesToNodeWithBinding(page, 'ten')).toHaveCount(0) await expect(await edgesToNodeWithBinding(page, 'five')).toHaveCount(0) }) @@ -42,18 +43,21 @@ test('Existence of edges between nodes', async ({ page }) => { test('Hover behaviour of edges', async ({ page }) => { await actions.goToGraph(page) - // One for interaction, one for rendering, one for the arrow element. - await expect(await edgesFromNodeWithBinding(page, 'ten')).toHaveCount(3) + const edgeElements = await edgesFromNodeWithBinding(page, 'ten') + await expect(edgeElements).toHaveCount(EDGE_PARTS) - const targetEdge = page.locator('path:nth-child(4)') + const targetEdge = edgeElements.first() + await expect(targetEdge).toHaveClass('edge io') + // It is not currently possible to interact with edges in the default node layout. + // See: https://github.com/enso-org/enso/issues/8938 + /* // Hover over edge to the right of node with binding `ten`. await targetEdge.hover({ - position: { x: 65, y: 135.0 }, - force: true, + position: { x: 60, y: 45 }, // source node }) // Expect an extra edge for the split rendering. - const edgeElements = await edgesFromNodeWithBinding(page, 'ten') - await expect(edgeElements).toHaveCount(4) + const hoveredEdgeElements = await edgesFromNodeWithBinding(page, 'ten') + await expect(hoveredEdgeElements).toHaveCount(2 * EDGE_PARTS) // Expect the top edge part to be dimmed const topEdge = page.locator('path:nth-child(4)') @@ -61,4 +65,5 @@ test('Hover behaviour of edges', async ({ page }) => { // Expect the bottom edge part not to be dimmed const bottomEdge = page.locator('path:nth-child(6)') await expect(bottomEdge).toHaveClass('edge visible') + */ }) diff --git a/app/gui2/eslint.config.js b/app/gui2/eslint.config.js index c1808f9bb4..5939eaf687 100644 --- a/app/gui2/eslint.config.js +++ b/app/gui2/eslint.config.js @@ -9,7 +9,7 @@ const DIR_NAME = path.dirname(url.fileURLToPath(import.meta.url)) const conf = [ { - ignores: ['rust-ffi/pkg', 'dist', 'src/generated', 'templates'], + ignores: ['rust-ffi/pkg', 'rust-ffi/node-pkg', 'dist', 'src/generated', 'templates'], }, ...compat.extends('plugin:vue/vue3-recommended'), eslintJs.configs.recommended, diff --git a/app/gui2/mock/index.ts b/app/gui2/mock/index.ts index 41e5aafb8b..ea6492549e 100644 --- a/app/gui2/mock/index.ts +++ b/app/gui2/mock/index.ts @@ -4,6 +4,7 @@ import { useGraphStore } from '@/stores/graph' import { GraphDb, mockNode } from '@/stores/graph/graphDatabase' import { useProjectStore } from '@/stores/project' import { ComputedValueRegistry } from '@/stores/project/computedValueRegistry' +import { Ast } from '@/util/ast' import { MockTransport, MockWebSocket } from '@/util/net' import { getActivePinia } from 'pinia' import { ref, type App } from 'vue' @@ -65,7 +66,11 @@ export function projectStore() { const projectStore = useProjectStore(getActivePinia()) const mod = projectStore.projectModel.createNewModule('Main.enso') mod.doc.ydoc.emit('load', []) - mod.doc.setCode('main =\n') + const syncModule = new Ast.MutableModule(mod.doc.ydoc) + mod.transact(() => { + const root = Ast.parseBlock('main =\n', syncModule) + syncModule.replaceRoot(root) + }) return projectStore } diff --git a/app/gui2/package.json b/app/gui2/package.json index 4f4c284139..19aef93107 100644 --- a/app/gui2/package.json +++ b/app/gui2/package.json @@ -23,10 +23,11 @@ "typecheck": "vue-tsc --noEmit -p tsconfig.app.json --composite false", "lint": "eslint .", "format": "prettier --version && prettier --write src/ && eslint . --fix", - "build-rust-ffi": "wasm-pack build ./rust-ffi --release --target web", - "generate-ast-schema": "cargo run -p enso-parser-schema > src/generated/ast-schema.json", - "generate-ast-types": "tsx ./parser-codegen/index.ts src/generated/ast-schema.json src/generated/ast.ts", - "preinstall": "npm run build-rust-ffi && npm run generate-ast-schema && npm run generate-ast-types && npm run generate-metadata && npm run download-fonts", + "clean-old-generated-directory": "rimraf src/generated", + "build-rust-ffi": "wasm-pack build ./rust-ffi --release --target web && wasm-pack build ./rust-ffi --out-dir node-pkg --target nodejs", + "generate-ast-schema": "cargo run -p enso-parser-schema > shared/ast/generated/ast-schema.json", + "generate-ast-types": "tsx ./parser-codegen/index.ts shared/ast/generated/ast-schema.json shared/ast/generated/ast.ts", + "preinstall": "npm run clean-old-generated-directory && npm run build-rust-ffi && npm run generate-ast-schema && npm run generate-ast-types && npm run generate-metadata && npm run download-fonts", "postinstall": "playwright install", "generate-metadata": "node scripts/generateIconMetadata.js", "download-fonts": "node scripts/downloadFonts.js" @@ -64,6 +65,7 @@ "pinia": "^2.1.6", "postcss-inline-svg": "^6.0.0", "postcss-nesting": "^12.0.1", + "rimraf": "^5.0.5", "semver": "^7.5.4", "sucrase": "^3.34.0", "vue": "^3.3.4", diff --git a/app/gui2/parser-codegen/codegen.ts b/app/gui2/parser-codegen/codegen.ts index bd0d15af02..f6d39ece9d 100644 --- a/app/gui2/parser-codegen/codegen.ts +++ b/app/gui2/parser-codegen/codegen.ts @@ -60,7 +60,7 @@ export function implement(schema: Schema.Schema): string { ), ), ), - tsf.createStringLiteral('@/util/parserSupport', true), + tsf.createStringLiteral('../parserSupport', true), undefined, ), ) diff --git a/app/gui2/shared/ast/ffi.ts b/app/gui2/shared/ast/ffi.ts new file mode 100644 index 0000000000..a24318efe6 --- /dev/null +++ b/app/gui2/shared/ast/ffi.ts @@ -0,0 +1,22 @@ +import init, { + is_ident_or_operator, + parse_doc_to_json, + parse as parse_tree, +} from '../../rust-ffi/pkg/rust_ffi' +import { isNode } from '../util/detect' + +export async function initializeFFI(path?: string | undefined) { + if (isNode) { + const fs = await import('node:fs/promises') + const buffer = fs.readFile(path ?? './rust-ffi/pkg/rust_ffi_bg.wasm') + await init(buffer) + } else { + await init() + } +} + +// TODO[ao]: We cannot to that, because the ffi is used by cjs modules. +// await initializeFFI() + +// eslint-disable-next-line camelcase +export { is_ident_or_operator, parse_doc_to_json, parse_tree } diff --git a/app/gui2/src/generated/.gitkeep b/app/gui2/shared/ast/generated/.gitkeep similarity index 100% rename from app/gui2/src/generated/.gitkeep rename to app/gui2/shared/ast/generated/.gitkeep diff --git a/app/gui2/shared/ast/index.ts b/app/gui2/shared/ast/index.ts new file mode 100644 index 0000000000..5833a292a8 --- /dev/null +++ b/app/gui2/shared/ast/index.ts @@ -0,0 +1,70 @@ +import * as random from 'lib0/random' +import type { ExternalId } from '../yjsModel' +import type { Module } from './mutableModule' +import type { SyncTokenId } from './token' +import type { AstId } from './tree' +import { Ast, MutableAst } from './tree' + +export * from './mutableModule' +export * from './parse' +export * from './token' +export * from './tree' + +declare const brandOwned: unique symbol +/** Used to mark references required to be unique. + * + * Note that the typesystem cannot stop you from copying an `Owned`, + * but that is an easy mistake to see (because it occurs locally). + * + * We can at least require *obtaining* an `Owned`, + * which statically prevents the otherwise most likely usage errors when rearranging ASTs. + */ +export type Owned = T & { [brandOwned]: never } +/** @internal */ +export function asOwned(t: T): Owned { + return t as Owned +} + +export type NodeChild = { whitespace?: string | undefined; node: T } + +export function newExternalId(): ExternalId { + return random.uuidv4() as ExternalId +} + +/** @internal */ +export function parentId(ast: Ast): AstId | undefined { + return ast.fields.get('parent') +} + +/** Returns the given IDs, and the IDs of all their ancestors. */ +export function subtrees(module: Module, ids: Iterable) { + const subtrees = new Set() + for (const id of ids) { + let ast = module.get(id) + while (ast != null && !subtrees.has(ast.id)) { + subtrees.add(ast.id) + ast = ast.parent() + } + } + return subtrees +} + +/** Returns the IDs of the ASTs that are not descendants of any others in the given set. */ +export function subtreeRoots(module: Module, ids: Set) { + const roots = new Array() + for (const id of ids) { + const astInModule = module.get(id) + if (!astInModule) continue + let ast = astInModule.parent() + let hasParentInSet + while (ast != null) { + if (ids.has(ast.id)) { + hasParentInSet = true + break + } + ast = ast.parent() + } + if (!hasParentInSet) roots.push(id) + } + return roots +} diff --git a/app/gui2/shared/ast/mutableModule.ts b/app/gui2/shared/ast/mutableModule.ts new file mode 100644 index 0000000000..2ceb1e5c25 --- /dev/null +++ b/app/gui2/shared/ast/mutableModule.ts @@ -0,0 +1,364 @@ +import * as random from 'lib0/random' +import * as Y from 'yjs' +import type { AstId, Owned, SyncTokenId } from '.' +import { Token, asOwned, isTokenId, newExternalId } from '.' +import { assert, assertDefined } from '../util/assert' +import type { ExternalId, SourceRange } from '../yjsModel' +import type { AstFields, FixedMap, Mutable } from './tree' +import { + Ast, + Invalid, + MutableAst, + MutableInvalid, + Wildcard, + invalidFields, + materializeMutable, + setAll, +} from './tree' + +export interface Module { + edit(): MutableModule + root(): Ast | undefined + get(id: AstId): Ast | undefined + get(id: AstId | undefined): Ast | undefined + + ///////////////////////////////// + + checkedGet(id: AstId): Ast + checkedGet(id: AstId | undefined): Ast | undefined + getToken(token: SyncTokenId): Token + getToken(token: SyncTokenId | undefined): Token | undefined + getAny(node: AstId | SyncTokenId): Ast | Token + has(id: AstId): boolean + getSpan(id: AstId): SourceRange | undefined +} + +export interface ModuleUpdate { + nodesAdded: AstId[] + nodesDeleted: AstId[] + fieldsUpdated: { id: AstId; fields: (readonly [string, unknown])[] }[] + metadataUpdated: { id: AstId; changes: Map }[] +} + +type YNode = FixedMap +type YNodes = Y.Map + +export class MutableModule implements Module { + private readonly nodes: YNodes + + get ydoc() { + const ydoc = this.nodes.doc + assert(ydoc != null) + return ydoc + } + + /** Return this module's copy of `ast`, if this module was created by cloning `ast`'s module. */ + getVersion(ast: T): Mutable { + const instance = this.checkedGet(ast.id) + return instance as Mutable + } + + edit(): MutableModule { + const doc = new Y.Doc() + Y.applyUpdateV2(doc, Y.encodeStateAsUpdateV2(this.ydoc)) + return new MutableModule(doc) + } + + root(): MutableAst | undefined { + return this.rootPointer()?.expression + } + + replaceRoot(newRoot: Owned | undefined): Owned | undefined { + if (newRoot) { + const rootPointer = this.rootPointer() + if (rootPointer) { + return rootPointer.expression.replace(newRoot) + } else { + invalidFields(this, this.baseObject('Invalid', undefined, ROOT_ID), { + whitespace: '', + node: newRoot, + }) + return undefined + } + } else { + const oldRoot = this.root() + if (!oldRoot) return + this.nodes.delete(ROOT_ID) + oldRoot.fields.set('parent', undefined) + return asOwned(oldRoot) + } + } + + syncRoot(root: Owned) { + this.replaceRoot(root) + this.gc() + } + + private gc() { + const live = new Set() + const active = new Array() + let next: Ast | undefined = this.root() + while (next) { + for (const child of next.children()) { + if (child instanceof Ast) active.push(child) + } + live.add(next.id) + next = active.pop() + } + const all = Array.from(this.nodes.keys()) + for (const id of all) { + if (id === ROOT_ID) continue + assert(isAstId(id)) + if (!live.has(id)) this.nodes.delete(id) + } + } + + /** Copy the given node into the module. */ + copy(ast: T): Owned> { + const id = newAstId(ast.typeName()) + const fields = ast.fields.clone() + this.nodes.set(id, fields as any) + fields.set('id', id) + fields.set('parent', undefined) + const ast_ = materializeMutable(this, fields) + ast_.importReferences(ast.module) + return ast_ as Owned> + } + + static Transient() { + return new this(new Y.Doc()) + } + + observe(observer: (update: ModuleUpdate) => void) { + this.nodes.observeDeep((events) => observer(this.observeEvents(events))) + } + + getStateAsUpdate(): ModuleUpdate { + const updateBuilder = new UpdateBuilder(this.nodes) + for (const id of this.nodes.keys()) updateBuilder.addNode(id as AstId) + return updateBuilder + } + + applyUpdate(update: Uint8Array, origin?: string): ModuleUpdate | undefined { + let summary: ModuleUpdate | undefined + const observer = (events: Y.YEvent[]) => { + summary = this.observeEvents(events) + } + this.nodes.observeDeep(observer) + Y.applyUpdate(this.ydoc, update, origin) + this.nodes.unobserveDeep(observer) + return summary + } + + private observeEvents(events: Y.YEvent[]): ModuleUpdate { + const updateBuilder = new UpdateBuilder(this.nodes) + for (const event of events) { + if (event.target === this.nodes) { + // Updates to the node map. + for (const [key, change] of event.changes.keys) { + const id = key as AstId + switch (change.action) { + case 'add': + updateBuilder.addNode(id) + break + case 'update': + updateBuilder.updateAllFields(id) + break + case 'delete': + updateBuilder.deleteNode(id) + break + } + } + } else if (event.target.parent === this.nodes) { + // Updates to a node's fields. + assert(event.target instanceof Y.Map) + const id = event.target.get('id') as AstId + const node = this.nodes.get(id) + if (!node) continue + const changes: (readonly [string, unknown])[] = Array.from(event.changes.keys, ([key]) => [ + key, + node.get(key as any), + ]) + updateBuilder.updateFields(id, changes) + } else if (event.target.parent.parent === this.nodes) { + // Updates to fields of a metadata object within a node. + const id = event.target.parent.get('id') as AstId + const node = this.nodes.get(id) + if (!node) continue + const metadata = node.get('metadata') as unknown as Map + const changes: (readonly [string, unknown])[] = Array.from(event.changes.keys, ([key]) => [ + key, + metadata.get(key as any), + ]) + updateBuilder.updateMetadata(id, changes) + } + } + return updateBuilder + } + + clear() { + this.nodes.clear() + } + + checkedGet(id: AstId): Mutable + checkedGet(id: AstId | undefined): Mutable | undefined + checkedGet(id: AstId | undefined): Mutable | undefined { + if (!id) return undefined + const ast = this.get(id) + assert(ast !== undefined, 'id in module') + return ast + } + + get(id: AstId): Mutable | undefined + get(id: AstId | undefined): Mutable | undefined + get(id: AstId | undefined): Mutable | undefined { + if (!id) return undefined + const nodeData = this.nodes.get(id) + if (!nodeData) return undefined + const fields = nodeData as any + return materializeMutable(this, fields) + } + + replace(id: AstId, value: Owned): Owned | undefined { + return this.get(id)?.replace(value) + } + + replaceValue(id: AstId, value: Owned): Owned | undefined { + return this.get(id)?.replaceValue(value) + } + + take(id: AstId): Owned { + return this.replace(id, Wildcard.new(this)) || asOwned(this.checkedGet(id)) + } + + updateValue(id: AstId, f: (x: Owned) => Owned): T | undefined { + return this.get(id)?.updateValue(f) + } + + ///////////////////////////////////////////// + + getSpan(id: AstId) { + return undefined + } + + constructor(doc: Y.Doc) { + this.nodes = doc.getMap('nodes') + } + + private rootPointer(): MutableRootPointer | undefined { + const rootPointer = this.get(ROOT_ID) + if (rootPointer) return rootPointer as MutableRootPointer + } + + /** @internal */ + baseObject(type: string, externalId?: ExternalId, overrideId?: AstId): FixedMap { + const map = new Y.Map() + const map_ = map as unknown as FixedMap<{}> + const id = overrideId ?? newAstId(type) + const metadata = new Y.Map() as unknown as FixedMap<{}> + const metadataFields = setAll(metadata, { + externalId: externalId ?? newExternalId(), + }) + const fields = setAll(map_, { + id, + type: type, + parent: undefined, + metadata: metadataFields, + }) + this.nodes.set(id, fields) + return fields + } + + /** @internal */ + getToken(token: SyncTokenId): Token + getToken(token: SyncTokenId | undefined): Token | undefined + getToken(token: SyncTokenId | undefined): Token | undefined { + if (!token) return token + if (token instanceof Token) return token + return Token.withId(token.code_, token.tokenType_, token.id) + } + + getAny(node: AstId | SyncTokenId): MutableAst | Token { + return isTokenId(node) ? this.getToken(node) : this.checkedGet(node) + } + + /** @internal Copy a node into the module, if it is bound to a different module. */ + copyIfForeign(ast: Owned): Owned + copyIfForeign(ast: Owned | undefined): Owned | undefined { + if (!ast) return ast + if (ast.module === this) return ast + return this.copy(ast) as any + } + + /** @internal */ + delete(id: AstId) { + this.nodes.delete(id) + } + + /** @internal */ + has(id: AstId) { + return this.nodes.has(id) + } +} + +type MutableRootPointer = MutableInvalid & { get expression(): MutableAst | undefined } +/** @internal */ +export interface RootPointer extends Invalid {} + +function newAstId(type: string): AstId { + return `ast:${type}#${random.uint53()}` as AstId +} +/** Checks whether the input looks like an AstId. */ +export function isAstId(value: string): value is AstId { + return /ast:[A-Za-z]*#[0-9]*/.test(value) +} +export const ROOT_ID = `Root` as AstId + +class UpdateBuilder implements ModuleUpdate { + readonly nodesAdded: AstId[] = [] + readonly nodesDeleted: AstId[] = [] + readonly fieldsUpdated: { id: AstId; fields: (readonly [string, unknown])[] }[] = [] + readonly metadataUpdated: { id: AstId; changes: Map }[] = [] + + private readonly nodes: YNodes + + constructor(nodes: YNodes) { + this.nodes = nodes + } + + addNode(id: AstId) { + this.nodesAdded.push(id) + this.updateAllFields(id) + } + + updateAllFields(id: AstId) { + this.updateFields(id, this.nodes.get(id)!.entries()) + } + + updateFields(id: AstId, changes: Iterable) { + const fields = new Array() + let metadataChanges = undefined + for (const entry of changes) { + const [key, value] = entry + if (key === 'metadata') { + assert(value instanceof Y.Map) + metadataChanges = new Map(value.entries()) + } else { + assert(!(value instanceof Y.AbstractType)) + fields.push(entry) + } + } + if (fields.length !== 0) this.fieldsUpdated.push({ id, fields }) + if (metadataChanges) this.metadataUpdated.push({ id, changes: metadataChanges }) + } + + updateMetadata(id: AstId, changes: Iterable) { + const changeMap = new Map() + for (const [key, value] of changes) changeMap.set(key, value) + this.metadataUpdated.push({ id, changes: changeMap }) + } + + deleteNode(id: AstId) { + this.nodesDeleted.push(id) + } +} diff --git a/app/gui2/shared/ast/parse.ts b/app/gui2/shared/ast/parse.ts new file mode 100644 index 0000000000..521b9bdd55 --- /dev/null +++ b/app/gui2/shared/ast/parse.ts @@ -0,0 +1,514 @@ +import * as map from 'lib0/map' +import type { AstId, NodeChild, Owned } from '.' +import { Token, asOwned, parentId, subtreeRoots } from '.' +import { assert, assertDefined, assertEqual } from '../util/assert' +import type { SourceRange, SourceRangeKey } from '../yjsModel' +import { IdMap, isUuid, sourceRangeFromKey, sourceRangeKey } from '../yjsModel' +import { parse_tree } from './ffi' +import * as RawAst from './generated/ast' +import { MutableModule } from './mutableModule' +import type { LazyObject } from './parserSupport' +import { + App, + Assignment, + Ast, + BodyBlock, + Documented, + Function, + Generic, + Group, + Ident, + Import, + Invalid, + MutableBodyBlock, + MutableIdent, + NegationApp, + NumericLiteral, + OprApp, + PropertyAccess, + TextLiteral, + UnaryOprApp, + Wildcard, +} from './tree' + +export function parseEnso(code: string): RawAst.Tree { + const blob = parse_tree(code) + return RawAst.Tree.read(new DataView(blob.buffer), blob.byteLength - 4) +} + +export function normalize(rootIn: Ast): Ast { + const printed = print(rootIn) + const idMap = spanMapToIdMap(printed.info) + const module = MutableModule.Transient() + const tree = parseEnso(printed.code) + const { root: parsed, spans } = abstract(module, tree, printed.code) + module.replaceRoot(parsed) + setExternalIds(module, spans, idMap) + return parsed +} + +export function abstract( + module: MutableModule, + tree: RawAst.Tree, + code: string, +): { root: Owned; spans: SpanMap; toRaw: Map } { + const tokens = new Map() + const nodes = new Map() + const toRaw = new Map() + const root = abstractTree(module, tree, code, nodes, tokens, toRaw).node + const spans = { tokens, nodes } + return { root, spans, toRaw } +} + +function abstractTree( + module: MutableModule, + tree: RawAst.Tree, + code: string, + nodesOut: NodeSpanMap, + tokensOut: TokenSpanMap, + toRaw: Map, +): { whitespace: string | undefined; node: Owned } { + const recurseTree = (tree: RawAst.Tree) => + abstractTree(module, tree, code, nodesOut, tokensOut, toRaw) + const recurseToken = (token: RawAst.Token.Token) => abstractToken(token, code, tokensOut) + const visitChildren = (tree: LazyObject) => { + const children: NodeChild[] = [] + const visitor = (child: LazyObject) => { + if (RawAst.Tree.isInstance(child)) { + children.push(recurseTree(child)) + } else if (RawAst.Token.isInstance(child)) { + children.push(recurseToken(child)) + } else { + child.visitChildren(visitor) + } + } + tree.visitChildren(visitor) + return children + } + const whitespaceStart = tree.whitespaceStartInCodeParsed + const whitespaceEnd = whitespaceStart + tree.whitespaceLengthInCodeParsed + const whitespace = code.substring(whitespaceStart, whitespaceEnd) + const codeStart = whitespaceEnd + const codeEnd = codeStart + tree.childrenLengthInCodeParsed + const spanKey = nodeKey(codeStart, codeEnd - codeStart) + let node: Owned + switch (tree.type) { + case RawAst.Tree.Type.BodyBlock: { + const lines = Array.from(tree.statements, (line) => { + const newline = recurseToken(line.newline) + const expression = line.expression ? recurseTree(line.expression) : undefined + return { newline, expression } + }) + node = BodyBlock.concrete(module, lines) + break + } + case RawAst.Tree.Type.Function: { + const name = recurseTree(tree.name) + const argumentDefinitions = Array.from(tree.args, (arg) => visitChildren(arg)) + const equals = recurseToken(tree.equals) + const body = tree.body !== undefined ? recurseTree(tree.body) : undefined + node = Function.concrete(module, name, argumentDefinitions, equals, body) + break + } + case RawAst.Tree.Type.Ident: { + const token = recurseToken(tree.token) + node = Ident.concrete(module, token) + break + } + case RawAst.Tree.Type.Assignment: { + const pattern = recurseTree(tree.pattern) + const equals = recurseToken(tree.equals) + const value = recurseTree(tree.expr) + node = Assignment.concrete(module, pattern, equals, value) + break + } + case RawAst.Tree.Type.App: { + const func = recurseTree(tree.func) + const arg = recurseTree(tree.arg) + node = App.concrete(module, func, undefined, undefined, arg) + break + } + case RawAst.Tree.Type.NamedApp: { + const func = recurseTree(tree.func) + const open = tree.open ? recurseToken(tree.open) : undefined + const name = recurseToken(tree.name) + const equals = recurseToken(tree.equals) + const arg = recurseTree(tree.arg) + const close = tree.close ? recurseToken(tree.close) : undefined + const parens = open && close ? { open, close } : undefined + const nameSpecification = { name, equals } + node = App.concrete(module, func, parens, nameSpecification, arg) + break + } + case RawAst.Tree.Type.UnaryOprApp: { + const opr = recurseToken(tree.opr) + const arg = tree.rhs ? recurseTree(tree.rhs) : undefined + if (arg && opr.node.code() === '-') { + node = NegationApp.concrete(module, opr, arg) + } else { + node = UnaryOprApp.concrete(module, opr, arg) + } + break + } + case RawAst.Tree.Type.OprApp: { + const lhs = tree.lhs ? recurseTree(tree.lhs) : undefined + const opr = tree.opr.ok + ? [recurseToken(tree.opr.value)] + : Array.from(tree.opr.error.payload.operators, recurseToken) + const rhs = tree.rhs ? recurseTree(tree.rhs) : undefined + if (opr.length === 1 && opr[0]?.node.code() === '.' && rhs?.node instanceof MutableIdent) { + // Propagate type. + const rhs_ = { ...rhs, node: rhs.node } + node = PropertyAccess.concrete(module, lhs, opr[0], rhs_) + } else { + node = OprApp.concrete(module, lhs, opr, rhs) + } + break + } + case RawAst.Tree.Type.Number: { + const tokens = [] + if (tree.base) tokens.push(recurseToken(tree.base)) + if (tree.integer) tokens.push(recurseToken(tree.integer)) + if (tree.fractionalDigits) { + tokens.push(recurseToken(tree.fractionalDigits.dot)) + tokens.push(recurseToken(tree.fractionalDigits.digits)) + } + node = NumericLiteral.concrete(module, tokens) + break + } + case RawAst.Tree.Type.Wildcard: { + const token = recurseToken(tree.token) + node = Wildcard.concrete(module, token) + break + } + // These expression types are (or will be) used for backend analysis. + // The frontend can ignore them, avoiding some problems with expressions sharing spans + // (which makes it impossible to give them unique IDs in the current IdMap format). + case RawAst.Tree.Type.OprSectionBoundary: + case RawAst.Tree.Type.TemplateFunction: + return { whitespace, node: recurseTree(tree.ast).node } + case RawAst.Tree.Type.Invalid: { + const expression = recurseTree(tree.ast) + node = Invalid.concrete(module, expression) + break + } + case RawAst.Tree.Type.Group: { + const open = tree.open ? recurseToken(tree.open) : undefined + const expression = tree.body ? recurseTree(tree.body) : undefined + const close = tree.close ? recurseToken(tree.close) : undefined + node = Group.concrete(module, open, expression, close) + break + } + case RawAst.Tree.Type.TextLiteral: { + const open = tree.open ? recurseToken(tree.open) : undefined + const newline = tree.newline ? recurseToken(tree.newline) : undefined + const elements = [] + for (const e of tree.elements) { + elements.push(...visitChildren(e)) + } + const close = tree.close ? recurseToken(tree.close) : undefined + node = TextLiteral.concrete(module, open, newline, elements, close) + break + } + case RawAst.Tree.Type.Documented: { + const open = recurseToken(tree.documentation.open) + const elements = [] + for (const e of tree.documentation.elements) { + elements.push(...visitChildren(e)) + } + const newlines = Array.from(tree.documentation.newlines, recurseToken) + const expression = tree.expression ? recurseTree(tree.expression) : undefined + node = Documented.concrete(module, open, elements, newlines, expression) + break + } + case RawAst.Tree.Type.Import: { + const recurseBody = (tree: RawAst.Tree) => { + const body = recurseTree(tree) + if (body.node instanceof Invalid && body.node.code() === '') return undefined + return body + } + const recurseSegment = (segment: RawAst.MultiSegmentAppSegment) => ({ + header: recurseToken(segment.header), + body: segment.body ? recurseBody(segment.body) : undefined, + }) + const polyglot = tree.polyglot ? recurseSegment(tree.polyglot) : undefined + const from = tree.from ? recurseSegment(tree.from) : undefined + const import_ = recurseSegment(tree.import) + const all = tree.all ? recurseToken(tree.all) : undefined + const as = tree.as ? recurseSegment(tree.as) : undefined + const hiding = tree.hiding ? recurseSegment(tree.hiding) : undefined + node = Import.concrete(module, polyglot, from, import_, all, as, hiding) + break + } + default: { + node = Generic.concrete(module, visitChildren(tree)) + } + } + toRaw.set(node.id, tree) + map.setIfUndefined(nodesOut, spanKey, (): Ast[] => []).unshift(node) + return { node, whitespace } +} + +function abstractToken( + token: RawAst.Token, + code: string, + tokensOut: TokenSpanMap, +): { whitespace: string; node: Token } { + const whitespaceStart = token.whitespaceStartInCodeBuffer + const whitespaceEnd = whitespaceStart + token.whitespaceLengthInCodeBuffer + const whitespace = code.substring(whitespaceStart, whitespaceEnd) + const codeStart = token.startInCodeBuffer + const codeEnd = codeStart + token.lengthInCodeBuffer + const tokenCode = code.substring(codeStart, codeEnd) + const key = tokenKey(codeStart, codeEnd - codeStart) + const node = Token.new(tokenCode, token.type) + tokensOut.set(key, node) + return { whitespace, node } +} + +declare const nodeKeyBrand: unique symbol +export type NodeKey = SourceRangeKey & { [nodeKeyBrand]: never } +declare const tokenKeyBrand: unique symbol +export type TokenKey = SourceRangeKey & { [tokenKeyBrand]: never } +export function nodeKey(start: number, length: number): NodeKey { + return sourceRangeKey([start, start + length]) as NodeKey +} +export function tokenKey(start: number, length: number): TokenKey { + return sourceRangeKey([start, start + length]) as TokenKey +} + +export type NodeSpanMap = Map +export type TokenSpanMap = Map + +export interface SpanMap { + nodes: NodeSpanMap + tokens: TokenSpanMap +} + +interface PrintedSource { + info: SpanMap + code: string +} + +export function spanMapToIdMap(spans: SpanMap): IdMap { + const idMap = new IdMap() + for (const [key, token] of spans.tokens.entries()) { + assert(isUuid(token.id)) + idMap.insertKnownId(sourceRangeFromKey(key), token.id) + } + for (const [key, asts] of spans.nodes.entries()) { + for (const ast of asts) { + assert(isUuid(ast.externalId)) + idMap.insertKnownId(sourceRangeFromKey(key), ast.externalId) + } + } + return idMap +} + +export function spanMapToSpanGetter(spans: SpanMap): (id: AstId) => SourceRange | undefined { + const reverseMap = new Map() + for (const [key, asts] of spans.nodes) { + for (const ast of asts) { + reverseMap.set(ast.id, sourceRangeFromKey(key)) + } + } + return (id) => reverseMap.get(id) +} + +/** Return stringification with associated ID map. This is only exported for testing. */ +export function print(ast: Ast): PrintedSource { + const info: SpanMap = { + nodes: new Map(), + tokens: new Map(), + } + const code = ast.printSubtree(info, 0, undefined) + return { info, code } +} + +/** Parse the input as a block. */ +export function parseBlock(code: string, inModule?: MutableModule) { + return parseBlockWithSpans(code, inModule).root +} + +/** Parse the input. If it contains a single expression at the top level, return it; otherwise, return a block. */ +export function parse(code: string, module?: MutableModule): Owned { + const module_ = module ?? MutableModule.Transient() + const ast = parseBlock(code, module_) + const [expr] = ast.statements() + if (!expr) return ast + const parent = parentId(expr) + if (parent) module_.delete(parent) + expr.fields.set('parent', undefined) + return asOwned(expr) +} + +export function parseBlockWithSpans( + code: string, + inModule?: MutableModule, +): { root: Owned; spans: SpanMap } { + const tree = parseEnso(code) + const module = inModule ?? MutableModule.Transient() + return fromRaw(tree, code, module) +} + +function fromRaw( + tree: RawAst.Tree, + code: string, + inModule?: MutableModule, +): { + root: Owned + spans: SpanMap + toRaw: Map +} { + const module = inModule ?? MutableModule.Transient() + const ast = abstract(module, tree, code) + const spans = ast.spans + // The root of the tree produced by the parser is always a `BodyBlock`. + const root = ast.root as Owned + return { root, spans, toRaw: ast.toRaw } +} + +export function parseExtended(code: string, idMap?: IdMap | undefined, inModule?: MutableModule) { + const rawRoot = parseEnso(code) + const module = inModule ?? MutableModule.Transient() + const { root, spans, toRaw, idMapUpdates } = module.ydoc.transact(() => { + const { root, spans, toRaw } = fromRaw(rawRoot, code, module) + root.module.replaceRoot(root) + const idMapUpdates = idMap ? setExternalIds(root.module, spans, idMap) : 0 + return { root, spans, toRaw, idMapUpdates } + }, 'local') + const getSpan = spanMapToSpanGetter(spans) + const idMapOut = spanMapToIdMap(spans) + return { root, idMap: idMapOut, getSpan, toRaw, idMapUpdates } +} + +export function setExternalIds(edit: MutableModule, spans: SpanMap, ids: IdMap) { + let astsMatched = 0 + let idsUnmatched = 0 + let asts = 0 + edit.root()?.visitRecursiveAst((_ast) => (asts += 1)) + for (const [key, externalId] of ids.entries()) { + const asts = spans.nodes.get(key as NodeKey) + if (asts) { + for (const ast of asts) { + astsMatched += 1 + const editAst = edit.getVersion(ast) + if (editAst.externalId !== externalId) editAst.setExternalId(externalId) + } + } else { + idsUnmatched += 1 + } + } + return edit.root() ? asts - astsMatched : 0 +} + +function checkSpans(expected: NodeSpanMap, encountered: NodeSpanMap, code: string) { + const lost = new Array() + for (const [key, asts] of expected) { + const outermostPrinted = asts[0] + if (!outermostPrinted) continue + for (let i = 1; i < asts.length; ++i) assertEqual(asts[i]?.parentId, asts[i - 1]?.id) + const encounteredAsts = encountered.get(key) + if (encounteredAsts === undefined) lost.push([key, outermostPrinted]) + } + const lostInline = new Array() + const lostBlock = new Array() + for (const [key, ast] of lost) { + const [start, end] = sourceRangeFromKey(key) + ;(code.substring(start, end).match(/[\r\n]/) ? lostBlock : lostInline).push(ast) + } + return { lostInline, lostBlock } +} + +/** If the input tree's concrete syntax has precedence errors (i.e. its expected code would not parse back to the same + * structure), try to fix it. If possible, it will be repaired by inserting parentheses; if that doesn't fix it, the + * affected subtree will be re-synced to faithfully represent the source code the incorrect tree prints to. + */ +export function repair( + root: BodyBlock, + module?: MutableModule, +): { code: string; fixes: MutableModule | undefined } { + // Print the input to see what spans its nodes expect to have in the output. + const printed = print(root) + // Parse the printed output to see what spans actually correspond to nodes in the printed code. + const reparsed = parseBlockWithSpans(printed.code) + // See if any span we expected to be a node isn't; if so, it likely merged with its parent due to wrong precedence. + const { lostInline, lostBlock } = checkSpans( + printed.info.nodes, + reparsed.spans.nodes, + printed.code, + ) + if (lostInline.length === 0) { + if (lostBlock.length !== 0) { + console.warn(`repair: Bad block elements, but all inline elements OK?`) + const fixes = module ?? root.module.edit() + resync(lostBlock, printed.info.nodes, reparsed.spans.nodes, fixes) + return { code: printed.code, fixes } + } + return { code: printed.code, fixes: undefined } + } + + // Wrap any "lost" nodes in parentheses. + const fixes = module ?? root.module.edit() + for (const ast of lostInline) { + if (ast instanceof Group) continue + fixes.getVersion(ast).update((ast) => Group.new(fixes, ast)) + } + + // Verify that it's fixed. + const printed2 = print(fixes.getVersion(root)) + const reparsed2 = parseBlockWithSpans(printed2.code) + const { lostInline: lostInline2, lostBlock: lostBlock2 } = checkSpans( + printed2.info.nodes, + reparsed2.spans.nodes, + printed2.code, + ) + if (lostInline2.length !== 0 || lostBlock2.length !== 0) + resync([...lostInline2, ...lostBlock2], printed2.info.nodes, reparsed2.spans.nodes, fixes) + + return { code: printed2.code, fixes } +} + +/** + * Replace subtrees in the module to ensure that the module contents are consistent with the module's code. + * + * @param badAsts - ASTs that, if printed, would not parse to exactly their current content. + * @param badSpans - Span map produced by printing the `badAsts` nodes and all their parents. + * @param goodSpans - Span map produced by parsing the code from the module of `badAsts`. + * @param edit - Module to apply the fixes to; must contain all ASTs in `badAsts`. + */ +function resync( + badAsts: Iterable, + badSpans: NodeSpanMap, + goodSpans: NodeSpanMap, + edit: MutableModule, +) { + const parentsOfBadSubtrees = new Set() + const badAstIds = new Set(Array.from(badAsts, (ast) => ast.id)) + for (const id of subtreeRoots(edit, badAstIds)) { + const parent = edit.checkedGet(id)?.parentId + if (parent) parentsOfBadSubtrees.add(parent) + } + + const spanOfBadParent = new Array() + for (const [span, asts] of badSpans) { + for (const ast of asts) { + if (parentsOfBadSubtrees.has(ast.id)) spanOfBadParent.push([ast.id, span]) + } + } + // All ASTs in the module of badAsts should have entries in badSpans. + assertEqual(spanOfBadParent.length, parentsOfBadSubtrees.size) + + for (const [id, span] of spanOfBadParent) { + const parent = edit.checkedGet(id) + const goodAst = goodSpans.get(span)?.[0] + // The parent of the root of a bad subtree must be a good AST. + assertDefined(goodAst) + parent.replaceValue(edit.copy(goodAst)) + } + + console.warn( + `repair: Replaced ${parentsOfBadSubtrees.size} subtrees with their reparsed equivalents.`, + parentsOfBadSubtrees, + ) +} diff --git a/app/gui2/src/util/parserSupport.ts b/app/gui2/shared/ast/parserSupport.ts similarity index 95% rename from app/gui2/src/util/parserSupport.ts rename to app/gui2/shared/ast/parserSupport.ts index 7859073e87..d0334720a8 100644 --- a/app/gui2/src/util/parserSupport.ts +++ b/app/gui2/shared/ast/parserSupport.ts @@ -1,8 +1,8 @@ -/** This file supports the module in `../generated/ast.ts` that is produced by `parser-codegen`. */ +/** This file supports the module in `generated/ast.ts` that is produced by `parser-codegen`. */ -export { type Result } from '@/util/data/result' -import { bail } from '@/util/assert' -import { Err, Ok, type Result } from '@/util/data/result' +export { type Result } from '../util/data/result' +import { bail } from '../util/assert' +import { Err, Ok, type Result } from '../util/data/result' export type ObjectVisitor = (object: LazyObject) => boolean | void export type ObjectAddressVisitor = (view: DataView, address: number) => boolean | void diff --git a/app/gui2/shared/ast/token.ts b/app/gui2/shared/ast/token.ts new file mode 100644 index 0000000000..d4397fccb3 --- /dev/null +++ b/app/gui2/shared/ast/token.ts @@ -0,0 +1,122 @@ +import type { AstId, Owned } from '.' +import { Ast, newExternalId } from '.' +import { assert } from '../util/assert' +import type { ExternalId } from '../yjsModel' +import { isUuid } from '../yjsModel' +import { is_ident_or_operator } from './ffi' +import * as RawAst from './generated/ast' + +export function isToken(t: unknown): t is Token { + return t instanceof Token +} + +declare const brandTokenId: unique symbol +export type TokenId = ExternalId & { [brandTokenId]: never } + +function newTokenId(): TokenId { + return newExternalId() as TokenId +} + +/** @internal */ +export interface SyncTokenId { + readonly id: TokenId + code_: string + tokenType_: RawAst.Token.Type | undefined +} +export class Token implements SyncTokenId { + readonly id: TokenId + code_: string + tokenType_: RawAst.Token.Type | undefined + + private constructor(code: string, type: RawAst.Token.Type | undefined, id: TokenId) { + this.id = id + this.code_ = code + this.tokenType_ = type + } + + get externalId(): TokenId { + return this.id + } + + static new(code: string, type?: RawAst.Token.Type) { + return new this(code, type, newTokenId()) + } + + static withId(code: string, type: RawAst.Token.Type | undefined, id: TokenId) { + assert(isUuid(id)) + return new this(code, type, id) + } + + code(): string { + return this.code_ + } + + typeName(): string { + if (this.tokenType_) return RawAst.Token.typeNames[this.tokenType_]! + else return 'Raw' + } +} +// We haven't had much need to distinguish token types, but it's useful to know that an identifier token's code is a +// valid string for an identifier. +export interface IdentifierOrOperatorIdentifierToken extends Token { + code(): IdentifierOrOperatorIdentifier +} +export interface IdentifierToken extends Token { + code(): Identifier +} + +declare const qualifiedNameBrand: unique symbol +declare const identifierBrand: unique symbol +declare const operatorBrand: unique symbol + +/** A string representing a valid qualified name of our language. + * + * In our language, the segments are separated by `.`. All the segments except the last must be lexical identifiers. The + * last may be an identifier or a lexical operator. A single identifier is also a valid qualified name. + */ +export type QualifiedName = string & { [qualifiedNameBrand]: never } + +/** A string representing a lexical identifier. */ +export type Identifier = string & { [identifierBrand]: never; [qualifiedNameBrand]: never } + +/** A string representing a lexical operator. */ +export type Operator = string & { [operatorBrand]: never; [qualifiedNameBrand]: never } + +/** A string that can be parsed as an identifier in some contexts. + * + * If it is lexically an identifier (see `StrictIdentifier`), it can be used as identifier anywhere. + * + * If it is lexically an operator (see `Operator`), it takes the syntactic role of an identifier if it is the RHS of + * a `PropertyAccess`, or it is the name of a `Function` being defined within a type. In all other cases, it is not + * valid to use a lexical operator as an identifier (rather, it will usually parse as an `OprApp` or `UnaryOprApp`). + */ +export type IdentifierOrOperatorIdentifier = Identifier | Operator + +/** Returns true if `code` can be used as an identifier in some contexts. + * + * If it is lexically an identifier (see `isIdentifier`), it can be used as identifier anywhere. + * + * If it is lexically an operator (see `isOperator`), it takes the syntactic role of an identifier if it is the RHS of + * a `PropertyAccess`, or it is the name of a `Function` being defined within a type. In all other cases, it is not + * valid to use a lexical operator as an identifier (rather, it will usually parse as an `OprApp` or `UnaryOprApp`). + */ +export function isIdentifierOrOperatorIdentifier( + code: string, +): code is IdentifierOrOperatorIdentifier { + return is_ident_or_operator(code) !== 0 +} + +/** Returns true if `code` is lexically an identifier. */ +export function isIdentifier(code: string): code is Identifier { + return is_ident_or_operator(code) === 1 +} + +/** Returns true if `code` is lexically an operator. */ +export function isOperator(code: string): code is Operator { + return is_ident_or_operator(code) === 2 +} + +/** @internal */ +export function isTokenId(t: SyncTokenId | AstId | Ast | Owned | Owned): t is SyncTokenId { + return typeof t === 'object' && !(t instanceof Ast) +} diff --git a/app/gui2/shared/ast/tree.ts b/app/gui2/shared/ast/tree.ts new file mode 100644 index 0000000000..7efe833e2b --- /dev/null +++ b/app/gui2/shared/ast/tree.ts @@ -0,0 +1,2125 @@ +import * as map from 'lib0/map' +import type { + Identifier, + IdentifierOrOperatorIdentifier, + IdentifierOrOperatorIdentifierToken, + IdentifierToken, + Module, + NodeChild, + Owned, + SpanMap, + SyncTokenId, +} from '.' +import { + MutableModule, + Token, + asOwned, + isIdentifier, + isToken, + isTokenId, + newExternalId, + nodeKey, + parentId, + parse, + parseBlock, + print, + tokenKey, +} from '.' +import { assert, assertDefined, assertEqual, bail } from '../util/assert' +import type { Result } from '../util/data/result' +import { Err, Ok } from '../util/data/result' +import type { ExternalId, SourceRange, VisualizationMetadata } from '../yjsModel' +import * as RawAst from './generated/ast' + +declare const brandAstId: unique symbol +export type AstId = string & { [brandAstId]: never } + +/** @internal */ +export interface MetadataFields { + externalId: ExternalId +} +export interface NodeMetadataFields { + position?: { x: number; y: number } | undefined + visualization?: VisualizationMetadata | undefined +} +export type NodeMetadata = FixedMapView +export type MutableNodeMetadata = FixedMap +export function asNodeMetadata(map: Map): NodeMetadata { + return map as unknown as NodeMetadata +} +/** @internal */ +export interface AstFields { + id: AstId + type: string + parent: AstId | undefined + metadata: FixedMap +} +export abstract class Ast { + readonly module: Module + /** @internal */ + readonly fields: FixedMapView + + get id(): AstId { + return this.fields.get('id') + } + + get externalId(): ExternalId { + const id = this.fields.get('metadata').get('externalId') + assert(id != null) + return id + } + + get nodeMetadata(): NodeMetadata { + const metadata = this.fields.get('metadata') + return metadata as FixedMapView + } + + typeName(): string { + return this.fields.get('type') + } + + /** + * Return whether `this` and `other` are the same object, possibly in different modules. + */ + is(other: T): boolean { + return this.id === other.id + } + + /** Return this node's span, if it belongs to a module with an associated span map. */ + get span(): SourceRange | undefined { + return this.module.getSpan(this.id) + } + + innerExpression(): Ast { + // TODO: Override this in `Documented`, `Annotated`, `AnnotatedBuiltin` + return this + } + + code(): string { + return print(this).code + } + + visitRecursive(visit: (node: Ast | Token) => void): void { + visit(this) + for (const child of this.children()) { + if (isToken(child)) { + visit(child) + } else { + child.visitRecursive(visit) + } + } + } + + visitRecursiveAst(visit: (ast: Ast) => void): void { + visit(this) + for (const child of this.children()) { + if (!isToken(child)) child.visitRecursiveAst(visit) + } + } + + printSubtree( + info: SpanMap, + offset: number, + parentIndent: string | undefined, + verbatim?: boolean, + ): string { + let code = '' + for (const child of this.concreteChildren(verbatim)) { + if (!isTokenId(child.node) && this.module.checkedGet(child.node) === undefined) continue + if (child.whitespace != null) { + code += child.whitespace + } else if (code.length != 0) { + code += ' ' + } + if (isTokenId(child.node)) { + const tokenStart = offset + code.length + const token = this.module.getToken(child.node) + const span = tokenKey(tokenStart, token.code().length) + info.tokens.set(span, token) + code += token.code() + } else { + const childNode = this.module.checkedGet(child.node) + assert(childNode != null) + code += childNode.printSubtree(info, offset + code.length, parentIndent, verbatim) + // Extra structural validation. + assertEqual(childNode.id, child.node) + if (parentId(childNode) !== this.id) { + console.error(`Inconsistent parent pointer (expected ${this.id})`, childNode) + } + assertEqual(parentId(childNode), this.id) + } + } + const span = nodeKey(offset, code.length) + map.setIfUndefined(info.nodes, span, (): Ast[] => []).unshift(this) + return code + } + + /** Returns child subtrees, without information about the whitespace between them. */ + *children(): IterableIterator { + for (const child of this.concreteChildren()) { + if (isTokenId(child.node)) { + yield this.module.getToken(child.node) + } else { + const node = this.module.checkedGet(child.node) + if (node) yield node + } + } + } + + get parentId(): AstId | undefined { + const parentId = this.fields.get('parent') + if (parentId !== 'ROOT_ID') return parentId + } + + parent(): Ast | undefined { + return this.module.checkedGet(this.parentId) + } + + static parseBlock(source: string, inModule?: MutableModule) { + return parseBlock(source, inModule) + } + + static parse(source: string, module?: MutableModule) { + return parse(source, module) + } + + //////////////////// + + protected constructor(module: Module, fields: FixedMapView) { + this.module = module + this.fields = fields + } + + /** @internal + * Returns child subtrees, including information about the whitespace between them. + */ + abstract concreteChildren(verbatim?: boolean): IterableIterator +} +export interface MutableAst {} +export abstract class MutableAst extends Ast { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + setExternalId(id: ExternalId) { + this.fields.get('metadata').set('externalId', id) + } + + mutableNodeMetadata(): MutableNodeMetadata { + const metadata = this.fields.get('metadata') + return metadata as FixedMap + } + + setNodeMetadata(nodeMeta: NodeMetadataFields) { + const metadata = this.fields.get('metadata') as unknown as Map + for (const [key, value] of Object.entries(nodeMeta)) + if (value !== undefined) metadata.set(key, value) + } + + /** Modify the parent of this node to refer to a new object instead. Return the object, which now has no parent. */ + replace(replacement: Owned): Owned { + const parentId = this.fields.get('parent') + if (parentId) { + const parent = this.module.checkedGet(parentId) + parent.replaceChild(this.id, replacement) + this.fields.set('parent', undefined) + } + return asOwned(this) + } + + /** Change the value of the object referred to by the `target` ID. (The initial ID of `replacement` will be ignored.) + * Returns the old value, with a new (unreferenced) ID. + */ + replaceValue(replacement: Owned): Owned { + const replacement_ = this.module.copyIfForeign(replacement) + const old = this.replace(replacement_) + replacement_.fields.set('metadata', old.fields.get('metadata').clone()) + old.setExternalId(newExternalId()) + return old + } + + /** Replace the parent of this object with a reference to a new placeholder object. + * Returns the object, now parentless, and the placeholder. */ + takeToReplace(): Removed { + if (parentId(this)) { + const placeholder = Wildcard.new(this.module) + const node = this.replace(placeholder) + return { node, placeholder } + } else { + return { node: asOwned(this), placeholder: undefined } + } + } + + /** Replace the parent of this object with a reference to a new placeholder object. + * Returns the object, now parentless. */ + take(): Owned { + return this.replace(Wildcard.new(this.module)) + } + + takeIfParented(): Owned { + const parent = parentId(this) + if (parent) { + const parentAst = this.module.checkedGet(parent) + const placeholder = Wildcard.new(this.module) + parentAst.replaceChild(this.id, placeholder) + this.fields.set('parent', undefined) + } + return asOwned(this) + } + + /** Replace the value assigned to the given ID with a placeholder. + * Returns the removed value, with a new unreferenced ID. + **/ + takeValue(): Removed { + const placeholder = Wildcard.new(this.module) + const node = this.replaceValue(placeholder) + return { node, placeholder } + } + + /** Take this node from the tree, and replace it with the result of applying the given function to it. + * + * Note that this is a modification of the *parent* node. Any `Ast` objects or `AstId`s that pointed to the old value + * will still point to the old value. + */ + update(f: (x: Owned) => Owned): T { + const taken = this.takeToReplace() + assertDefined(taken.placeholder, 'To replace an `Ast`, it must have a parent.') + const replacement = f(taken.node) + taken.placeholder.replace(replacement) + return replacement + } + + /** Take this node from the tree, and replace it with the result of applying the given function to it; transfer the + * metadata from this node to the replacement. + * + * Note that this is a modification of the *parent* node. Any `Ast` objects or `AstId`s that pointed to the old value + * will still point to the old value. + */ + updateValue(f: (x: Owned) => Owned): T { + const taken = this.takeValue() + assertDefined(taken.placeholder, 'To replace an `Ast`, it must have a parent.') + const replacement = f(taken.node) + taken.placeholder.replaceValue(replacement) + return replacement + } + + mutableParent(): MutableAst | undefined { + const parentId = this.fields.get('parent') + if (parentId === 'ROOT_ID') return + return this.module.checkedGet(parentId) + } + + /////////////////// + + /** @internal */ + importReferences(module: Module) { + if (module === this.module) return + for (const child of this.concreteChildren()) { + if (!isTokenId(child.node)) { + const childInForeignModule = module.checkedGet(child.node) + assert(childInForeignModule !== undefined) + const importedChild = this.module.copy(childInForeignModule) + importedChild.fields.set('parent', undefined) + this.replaceChild(child.node, asOwned(importedChild)) + } + } + } + + /** @internal */ + abstract replaceChild(target: AstId, replacement: Owned): void + + protected claimChild(child: Owned): AstId + protected claimChild(child: Owned | undefined): AstId | undefined + protected claimChild(child: Owned | undefined): AstId | undefined { + return child ? claimChild(this.module, child, this.id) : undefined + } +} + +function applyMixins(derivedCtor: any, constructors: any[]) { + constructors.forEach((baseCtor) => { + Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => { + Object.defineProperty( + derivedCtor.prototype, + name, + Object.getOwnPropertyDescriptor(baseCtor.prototype, name) || Object.create(null), + ) + }) + }) +} + +interface AppFields { + function: NodeChild + parens: { open: NodeChild; close: NodeChild } | undefined + nameSpecification: { name: NodeChild; equals: NodeChild } | undefined + argument: NodeChild +} +export class App extends Ast { + declare fields: FixedMap + + constructor(module: Module, fields: FixedMapView) { + super(module, fields) + } + + static concrete( + module: MutableModule, + func: NodeChild, + parens: { open: NodeChild; close: NodeChild } | undefined, + nameSpecification: { name: NodeChild; equals: NodeChild } | undefined, + argument: NodeChild, + ) { + const base = module.baseObject('App') + const id_ = base.get('id') + const fields = setAll(base, { + function: concreteChild(module, func, id_), + parens, + nameSpecification, + argument: concreteChild(module, argument, id_), + }) + return asOwned(new MutableApp(module, fields)) + } + + static new( + module: MutableModule, + func: Owned, + argumentName: StrictIdentLike | undefined, + argument: Owned, + ) { + return App.concrete( + module, + unspaced(func), + undefined, + nameSpecification(argumentName), + autospaced(argument), + ) + } + + get function(): Ast { + return this.module.checkedGet(this.fields.get('function').node) + } + get argumentName(): Token | undefined { + return this.module.getToken(this.fields.get('nameSpecification')?.name.node) + } + get argument(): Ast { + return this.module.checkedGet(this.fields.get('argument').node) + } + + *concreteChildren(verbatim?: boolean): IterableIterator { + const { function: function_, parens, nameSpecification, argument } = getAll(this.fields) + yield function_ + if (parens) yield parens.open + const spacedEquals = !!parens && !!nameSpecification?.equals.whitespace + if (nameSpecification) { + yield ensureSpacedIf(nameSpecification.name, !parens, verbatim) + yield ensureSpacedOnlyIf(nameSpecification.equals, spacedEquals, verbatim) + } + yield ensureSpacedOnlyIf(argument, !nameSpecification || spacedEquals, verbatim) + if (parens) yield parens.close + } + + printSubtree( + info: SpanMap, + offset: number, + parentIndent: string | undefined, + verbatim?: boolean, + ): string { + const verbatim_ = + verbatim ?? (this.function instanceof Invalid || this.argument instanceof Invalid) + return super.printSubtree(info, offset, parentIndent, verbatim_) + } +} +function ensureSpacedIf( + child: NodeChild, + condition: boolean, + verbatim: boolean | undefined, +): NodeChild { + return condition ? ensureSpaced(child, verbatim) : child +} +function ensureSpacedOnlyIf( + child: NodeChild, + condition: boolean, + verbatim: boolean | undefined, +): NodeChild { + return condition ? ensureSpaced(child, verbatim) : ensureUnspaced(child, verbatim) +} +function ensureSpaced(child: NodeChild, verbatim: boolean | undefined): NodeChild { + if (verbatim && child.whitespace != null) return child + return child.whitespace ? child : { whitespace: ' ', ...child } +} +function ensureUnspaced(child: NodeChild, verbatim: boolean | undefined): NodeChild { + if (verbatim && child.whitespace != null) return child + return child.whitespace === '' ? child : { whitespace: '', ...child } +} +export class MutableApp extends App implements MutableAst { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + setFunction(value: Owned) { + setNode(this.fields, 'function', this.claimChild(value)) + } + setArgumentName(name: StrictIdentLike | undefined) { + this.fields.set('nameSpecification', nameSpecification(name)) + } + setArgument(value: Owned) { + setNode(this.fields, 'argument', this.claimChild(value)) + } + + replaceChild(target: AstId, replacement: Owned) { + if (this.fields.get('function').node === target) { + this.setFunction(replacement) + } else if (this.fields.get('argument').node === target) { + this.setArgument(replacement) + } + } +} +export interface MutableApp extends App, MutableAst { + get function(): MutableAst + get argument(): MutableAst +} +applyMixins(MutableApp, [MutableAst]) + +interface UnaryOprAppFields { + operator: NodeChild + argument: NodeChild | undefined +} +export class UnaryOprApp extends Ast { + declare fields: FixedMapView + constructor(module: Module, fields: FixedMapView) { + super(module, fields) + } + + static concrete( + module: MutableModule, + operator: NodeChild, + argument: NodeChild | undefined, + ) { + const base = module.baseObject('UnaryOprApp') + const id_ = base.get('id') + const fields = setAll(base, { + operator, + argument: concreteChild(module, argument, id_), + }) + return asOwned(new MutableUnaryOprApp(module, fields)) + } + + static new(module: MutableModule, operator: Token, argument: Owned | undefined) { + return this.concrete(module, unspaced(operator), argument ? autospaced(argument) : undefined) + } + + get operator(): Token { + return this.module.getToken(this.fields.get('operator').node) + } + get argument(): Ast | undefined { + return this.module.checkedGet(this.fields.get('argument')?.node) + } + + *concreteChildren(verbatim?: boolean): IterableIterator { + const { operator, argument } = getAll(this.fields) + yield operator + if (argument) yield argument + } +} +export class MutableUnaryOprApp extends UnaryOprApp implements MutableAst { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + setOperator(value: Token) { + this.fields.set('operator', unspaced(value)) + } + setArgument(argument: Owned | undefined) { + setNode(this.fields, 'argument', this.claimChild(argument)) + } + + replaceChild(target: AstId, replacement: Owned) { + if (this.fields.get('argument')?.node === target) { + this.setArgument(replacement) + } + } +} +export interface MutableUnaryOprApp extends UnaryOprApp, MutableAst { + get argument(): MutableAst | undefined +} +applyMixins(MutableUnaryOprApp, [MutableAst]) + +interface NegationAppFields { + operator: NodeChild + argument: NodeChild +} +export class NegationApp extends Ast { + declare fields: FixedMapView + constructor(module: Module, fields: FixedMapView) { + super(module, fields) + } + + static concrete(module: MutableModule, operator: NodeChild, argument: NodeChild) { + const base = module.baseObject('NegationApp') + const id_ = base.get('id') + const fields = setAll(base, { + operator, + argument: concreteChild(module, argument, id_), + }) + return asOwned(new MutableNegationApp(module, fields)) + } + + static new(module: MutableModule, operator: Token, argument: Owned) { + return this.concrete(module, unspaced(operator), autospaced(argument)) + } + + get operator(): Token { + return this.module.getToken(this.fields.get('operator').node) + } + get argument(): Ast { + return this.module.checkedGet(this.fields.get('argument').node) + } + + *concreteChildren(verbatim?: boolean): IterableIterator { + const { operator, argument } = getAll(this.fields) + yield operator + if (argument) yield argument + } +} +export class MutableNegationApp extends NegationApp implements MutableAst { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + setArgument(value: Owned) { + setNode(this.fields, 'argument', this.claimChild(value)) + } + + replaceChild(target: AstId, replacement: Owned) { + if (this.fields.get('argument')?.node === target) { + this.setArgument(replacement) + } + } +} +export interface MutableNegationApp extends NegationApp, MutableAst { + get argument(): MutableAst +} +applyMixins(MutableNegationApp, [MutableAst]) + +interface OprAppFields { + lhs: NodeChild | undefined + operators: NodeChild[] + rhs: NodeChild | undefined +} +export class OprApp extends Ast { + declare fields: FixedMapView + constructor(module: Module, fields: FixedMapView) { + super(module, fields) + } + + static concrete( + module: MutableModule, + lhs: NodeChild | undefined, + operators: NodeChild[], + rhs: NodeChild | undefined, + ) { + const base = module.baseObject('OprApp') + const id_ = base.get('id') + const fields = setAll(base, { + lhs: concreteChild(module, lhs, id_), + operators, + rhs: concreteChild(module, rhs, id_), + }) + return asOwned(new MutableOprApp(module, fields)) + } + + static new( + module: MutableModule, + lhs: Owned | undefined, + operator: Token, + rhs: Owned | undefined, + ) { + return OprApp.concrete(module, unspaced(lhs), [autospaced(operator)], autospaced(rhs)) + } + + get lhs(): Ast | undefined { + return this.module.checkedGet(this.fields.get('lhs')?.node) + } + get operator(): Result[]> { + const operators = this.fields.get('operators') + const operators_ = operators.map((child) => ({ + ...child, + node: this.module.getToken(child.node), + })) + const [opr] = operators_ + return opr ? Ok(opr.node) : Err(operators_) + } + get rhs(): Ast | undefined { + return this.module.checkedGet(this.fields.get('rhs')?.node) + } + + *concreteChildren(verbatim?: boolean): IterableIterator { + const { lhs, operators, rhs } = getAll(this.fields) + if (lhs) yield lhs + yield* operators + if (rhs) yield rhs + } +} +export class MutableOprApp extends OprApp implements MutableAst { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + setLhs(value: Owned) { + setNode(this.fields, 'lhs', this.claimChild(value)) + } + setOperator(value: Token) { + this.fields.set('operators', [unspaced(value)]) + } + setRhs(value: Owned) { + setNode(this.fields, 'rhs', this.claimChild(value)) + } + + replaceChild(target: AstId, replacement: Owned) { + if (this.fields.get('lhs')?.node === target) { + this.setLhs(replacement) + } else if (this.fields.get('rhs')?.node === target) { + this.setRhs(replacement) + } + } +} +export interface MutableOprApp extends OprApp, MutableAst { + get lhs(): MutableAst | undefined + get rhs(): MutableAst | undefined +} +applyMixins(MutableOprApp, [MutableAst]) + +interface PropertyAccessFields { + lhs: NodeChild | undefined + operator: NodeChild + rhs: NodeChild +} +export class PropertyAccess extends Ast { + declare fields: FixedMapView + constructor(module: Module, fields: FixedMapView) { + super(module, fields) + } + + static new(module: MutableModule, lhs: Owned, rhs: IdentLike) { + const dot = unspaced(Token.new('.', RawAst.Token.Type.Operator)) + return this.concrete( + module, + unspaced(lhs), + dot, + unspaced(Ident.newAllowingOperators(module, toIdent(rhs))), + ) + } + + static Sequence( + segments: [StrictIdentLike, ...StrictIdentLike[]], + module: MutableModule, + ): Owned | Owned + static Sequence( + segments: [StrictIdentLike, ...StrictIdentLike[], IdentLike], + module: MutableModule, + ): Owned | Owned + static Sequence( + segments: IdentLike[], + module: MutableModule, + ): Owned | Owned | undefined + static Sequence( + segments: IdentLike[], + module: MutableModule, + ): Owned | Owned | undefined { + let path: Owned | Owned | undefined + let operatorInNonFinalSegment = false + segments.forEach((s, i) => { + const t = toIdent(s) + if (i !== segments.length - 1 && !isIdentifier(t.code())) operatorInNonFinalSegment = true + path = path ? this.new(module, path, t) : Ident.newAllowingOperators(module, t) + }) + if (!operatorInNonFinalSegment) return path + } + + static concrete( + module: MutableModule, + lhs: NodeChild | undefined, + operator: NodeChild, + rhs: NodeChild>, + ) { + const base = module.baseObject('PropertyAccess') + const id_ = base.get('id') + const fields = setAll(base, { + lhs: concreteChild(module, lhs, id_), + operator, + rhs: concreteChild(module, rhs, id_), + }) + return asOwned(new MutablePropertyAccess(module, fields)) + } + + get lhs(): Ast | undefined { + return this.module.checkedGet(this.fields.get('lhs')?.node) + } + get operator(): Token { + return this.module.getToken(this.fields.get('operator').node) + } + get rhs(): IdentifierOrOperatorIdentifierToken { + const ast = this.module.checkedGet(this.fields.get('rhs').node) + assert(ast instanceof Ident) + return ast.token as IdentifierOrOperatorIdentifierToken + } + + *concreteChildren(verbatim?: boolean): IterableIterator { + const { lhs, operator, rhs } = getAll(this.fields) + if (lhs) yield lhs + yield operator + yield rhs + } +} +export class MutablePropertyAccess extends PropertyAccess implements MutableAst { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + setLhs(value: Owned | undefined) { + setNode(this.fields, 'lhs', this.claimChild(value)) + } + setRhs(ident: IdentLike) { + const node = this.claimChild(Ident.newAllowingOperators(this.module, ident)) + const old = this.fields.get('rhs') + this.fields.set('rhs', old ? { ...old, node } : unspaced(node)) + } + + replaceChild(target: AstId, replacement: Owned) { + if (this.fields.get('lhs')?.node === target) { + this.setLhs(replacement) + } else if (this.fields.get('rhs')?.node === target) { + assert(replacement instanceof MutableIdent) + this.setRhs(replacement.token) + } + } +} +export interface MutablePropertyAccess extends PropertyAccess, MutableAst { + get lhs(): MutableAst | undefined +} +applyMixins(MutablePropertyAccess, [MutableAst]) + +interface GenericFields { + children: NodeChild[] +} +export class Generic extends Ast { + declare fields: FixedMapView + constructor(module: Module, fields: FixedMapView) { + super(module, fields) + } + + static concrete(module: MutableModule, children: NodeChild[]) { + const base = module.baseObject('Generic') + const id_ = base.get('id') + const fields = setAll(base, { + children: children.map((child) => concreteChild(module, child, id_)), + }) + return asOwned(new MutableGeneric(module, fields)) + } + + concreteChildren(verbatim?: boolean): IterableIterator { + return this.fields.get('children')[Symbol.iterator]() + } +} +export class MutableGeneric extends Generic implements MutableAst { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + replaceChild(target: AstId, replacement: Owned) { + const replacement_ = autospaced(this.claimChild(replacement)) + this.fields.set( + 'children', + this.fields.get('children').map((child) => (child.node === target ? replacement_ : child)), + ) + } +} +export interface MutableGeneric extends Generic, MutableAst {} +applyMixins(MutableGeneric, [MutableAst]) + +interface RawMultiSegmentAppSegment { + header: NodeChild + body: NodeChild | undefined +} +interface OwnedMultiSegmentAppSegment { + header: NodeChild + body: NodeChild | undefined +} +function multiSegmentAppSegment( + header: string, + body: Owned, +): OwnedMultiSegmentAppSegment +function multiSegmentAppSegment( + header: string, + body: Owned | undefined, +): OwnedMultiSegmentAppSegment | undefined +function multiSegmentAppSegment( + header: string, + body: Owned | undefined, +): OwnedMultiSegmentAppSegment | undefined { + return { + header: { node: Token.new(header, RawAst.Token.Type.Ident) }, + body: spaced(body ? (body as any) : undefined), + } +} + +function multiSegmentAppSegmentToRaw( + module: MutableModule, + msas: OwnedMultiSegmentAppSegment, + parent: AstId, +): RawMultiSegmentAppSegment +function multiSegmentAppSegmentToRaw( + module: MutableModule, + msas: OwnedMultiSegmentAppSegment, + parent: AstId, +): RawMultiSegmentAppSegment +function multiSegmentAppSegmentToRaw( + module: MutableModule, + msas: OwnedMultiSegmentAppSegment | undefined, + parent: AstId, +): RawMultiSegmentAppSegment | undefined +function multiSegmentAppSegmentToRaw( + module: MutableModule, + msas: OwnedMultiSegmentAppSegment | undefined, + parent: AstId, +): RawMultiSegmentAppSegment | undefined { + if (!msas) return undefined + return { + ...msas, + body: concreteChild(module, msas.body, parent), + } +} +interface ImportFields { + polyglot: RawMultiSegmentAppSegment | undefined + from: RawMultiSegmentAppSegment | undefined + import: RawMultiSegmentAppSegment + all: NodeChild | undefined + as: RawMultiSegmentAppSegment | undefined + hiding: RawMultiSegmentAppSegment | undefined +} +export class Import extends Ast { + declare fields: FixedMapView + constructor(module: Module, fields: FixedMapView) { + super(module, fields) + } + + get polyglot(): Ast | undefined { + return this.module.checkedGet(this.fields.get('polyglot')?.body?.node) + } + get from(): Ast | undefined { + return this.module.checkedGet(this.fields.get('from')?.body?.node) + } + get import_(): Ast | undefined { + return this.module.checkedGet(this.fields.get('import').body?.node) + } + get all(): Token | undefined { + return this.module.getToken(this.fields.get('all')?.node) + } + get as(): Ast | undefined { + return this.module.checkedGet(this.fields.get('as')?.body?.node) + } + get hiding(): Ast | undefined { + return this.module.checkedGet(this.fields.get('hiding')?.body?.node) + } + + static concrete( + module: MutableModule, + polyglot: OwnedMultiSegmentAppSegment | undefined, + from: OwnedMultiSegmentAppSegment | undefined, + import_: OwnedMultiSegmentAppSegment, + all: NodeChild | undefined, + as: OwnedMultiSegmentAppSegment | undefined, + hiding: OwnedMultiSegmentAppSegment | undefined, + ) { + const base = module.baseObject('Import') + const id_ = base.get('id') + const fields = setAll(base, { + polyglot: multiSegmentAppSegmentToRaw(module, polyglot, id_), + from: multiSegmentAppSegmentToRaw(module, from, id_), + import: multiSegmentAppSegmentToRaw(module, import_, id_), + all, + as: multiSegmentAppSegmentToRaw(module, as, id_), + hiding: multiSegmentAppSegmentToRaw(module, hiding, id_), + }) + return asOwned(new MutableImport(module, fields)) + } + + static Qualified(path: IdentLike[], module: MutableModule): Owned | undefined { + const path_ = PropertyAccess.Sequence(path, module) + if (!path_) return + return MutableImport.concrete( + module, + undefined, + undefined, + multiSegmentAppSegment('import', path_), + undefined, + undefined, + undefined, + ) + } + + static Unqualified( + path: IdentLike[], + name: IdentLike, + module: MutableModule, + ): Owned | undefined { + const path_ = PropertyAccess.Sequence(path, module) + if (!path_) return + const name_ = Ident.newAllowingOperators(module, name) + return MutableImport.concrete( + module, + undefined, + multiSegmentAppSegment('from', path_), + multiSegmentAppSegment('import', name_), + undefined, + undefined, + undefined, + ) + } + + *concreteChildren(verbatim?: boolean): IterableIterator { + const segment = (segment: RawMultiSegmentAppSegment | undefined) => { + const parts = [] + if (segment) parts.push(segment.header) + if (segment?.body) parts.push(segment.body) + return parts + } + const { polyglot, from, import: import_, all, as, hiding } = getAll(this.fields) + yield* segment(polyglot) + yield* segment(from) + yield* segment(import_) + if (all) yield all + yield* segment(as) + yield* segment(hiding) + } +} +export class MutableImport extends Import implements MutableAst { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + private toRaw(msas: OwnedMultiSegmentAppSegment): RawMultiSegmentAppSegment + private toRaw( + msas: OwnedMultiSegmentAppSegment | undefined, + ): RawMultiSegmentAppSegment | undefined + private toRaw( + msas: OwnedMultiSegmentAppSegment | undefined, + ): RawMultiSegmentAppSegment | undefined { + return multiSegmentAppSegmentToRaw(this.module, msas, this.id) + } + + setPolyglot(value: Owned | undefined) { + this.fields.set( + 'polyglot', + value ? this.toRaw(multiSegmentAppSegment('polyglot', value)) : undefined, + ) + } + setFrom(value: Owned | undefined) { + this.fields.set('from', value ? this.toRaw(multiSegmentAppSegment('from', value)) : value) + } + setImport(value: Owned) { + this.fields.set('import', this.toRaw(multiSegmentAppSegment('import', value))) + } + setAll(value: Token | undefined) { + this.fields.set('all', spaced(value)) + } + setAs(value: Owned | undefined) { + this.fields.set('as', this.toRaw(multiSegmentAppSegment('as', value))) + } + setHiding(value: Owned | undefined) { + this.fields.set('hiding', this.toRaw(multiSegmentAppSegment('hiding', value))) + } + + replaceChild(target: AstId, replacement: Owned) { + const { polyglot, from, import: import_, as, hiding } = getAll(this.fields) + ;(polyglot?.body?.node === target + ? this.setPolyglot + : from?.body?.node === target + ? this.setFrom + : import_.body?.node === target + ? this.setImport + : as?.body?.node === target + ? this.setAs + : hiding?.body?.node === target + ? this.setHiding + : bail(`Failed to find child ${target} in node ${this.externalId}.`))(replacement) + } +} +export interface MutableImport extends Import, MutableAst { + get polyglot(): MutableAst | undefined + get from(): MutableAst | undefined + get import_(): MutableAst | undefined + get as(): MutableAst | undefined + get hiding(): MutableAst | undefined +} +applyMixins(MutableImport, [MutableAst]) + +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. */ +function escape(string: string) { + return string.replace(/[\0\b\f\n\r\t\v"'`]/g, (match) => mapping[match]!) +} + +interface TextLiteralFields { + open: NodeChild | undefined + newline: NodeChild | undefined + elements: NodeChild[] + close: NodeChild | undefined +} +export class TextLiteral extends Ast { + declare fields: FixedMapView + constructor(module: Module, fields: FixedMapView) { + super(module, fields) + } + + static concrete( + module: MutableModule, + open: NodeChild | undefined, + newline: NodeChild | undefined, + elements: NodeChild[], + close: NodeChild | undefined, + ) { + const base = module.baseObject('TextLiteral') + const id_ = base.get('id') + const fields = setAll(base, { + open, + newline, + elements: elements.map((elem) => concreteChild(module, elem, id_)), + close, + }) + return asOwned(new MutableTextLiteral(module, fields)) + } + + static new(rawText: string, module: MutableModule) { + const open = unspaced(Token.new("'")) + const elements = [unspaced(Token.new(escape(rawText)))] + const close = unspaced(Token.new("'")) + return this.concrete(module, open, undefined, elements, close) + } + + *concreteChildren(verbatim?: boolean): IterableIterator { + const { open, newline, elements, close } = getAll(this.fields) + if (open) yield open + if (newline) yield newline + yield* elements + if (close) yield close + } +} +export class MutableTextLiteral extends TextLiteral implements MutableAst { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + replaceChild(target: AstId, replacement: Owned) { + const replacement_ = autospaced(this.claimChild(replacement)) + this.fields.set( + 'elements', + this.fields.get('elements').map((child) => (child.node === target ? replacement_ : child)), + ) + } +} +export interface MutableTextLiteral extends TextLiteral, MutableAst {} +applyMixins(MutableTextLiteral, [MutableAst]) + +interface DocumentedFields { + open: NodeChild | undefined + elements: NodeChild[] + newlines: NodeChild[] + expression: NodeChild | undefined +} +export class Documented extends Ast { + declare fields: FixedMapView + constructor(module: Module, fields: FixedMapView) { + super(module, fields) + } + + static concrete( + module: MutableModule, + open: NodeChild | undefined, + elements: NodeChild[], + newlines: NodeChild[], + expression: NodeChild | undefined, + ) { + const base = module.baseObject('Documented') + const id_ = base.get('id') + const fields = setAll(base, { + open, + elements: elements.map((elem) => concreteChild(module, elem, id_)), + newlines, + expression: concreteChild(module, expression, id_), + }) + return asOwned(new MutableDocumented(module, fields)) + } + + get expression(): Ast | undefined { + return this.module.checkedGet(this.fields.get('expression')?.node) + } + + *concreteChildren(verbatim?: boolean): IterableIterator { + const { open, elements, newlines, expression } = getAll(this.fields) + if (open) yield open + yield* elements + yield* newlines + if (expression) yield expression + } +} +export class MutableDocumented extends Documented implements MutableAst { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + setExpression(value: Owned | undefined) { + this.fields.set('expression', unspaced(this.claimChild(value))) + } + + replaceChild(target: AstId, replacement: Owned) { + if (this.fields.get('expression')?.node === target) { + this.setExpression(replacement) + } else { + const replacement_ = unspaced(this.claimChild(replacement)) + this.fields.set( + 'elements', + this.fields.get('elements').map((child) => (child.node === target ? replacement_ : child)), + ) + } + } +} +export interface MutableDocumented extends Documented, MutableAst { + get expression(): MutableAst | undefined +} +applyMixins(MutableDocumented, [MutableAst]) + +interface InvalidFields { + expression: NodeChild +} +export class Invalid extends Ast { + declare fields: FixedMapView + constructor(module: Module, fields: FixedMapView) { + super(module, fields) + } + + static concrete(module: MutableModule, expression: NodeChild) { + const base = module.baseObject('Invalid') + return asOwned(new MutableInvalid(module, invalidFields(module, base, expression))) + } + + get expression(): Ast { + return this.module.checkedGet(this.fields.get('expression').node) + } + + *concreteChildren(verbatim?: boolean): IterableIterator { + yield this.fields.get('expression') + } + + printSubtree( + info: SpanMap, + offset: number, + parentIndent: string | undefined, + verbatim?: boolean, + ): string { + return super.printSubtree(info, offset, parentIndent, true) + } +} +export function invalidFields( + module: MutableModule, + base: FixedMap, + expression: NodeChild, +): FixedMap { + const id_ = base.get('id') + return setAll(base, { expression: concreteChild(module, expression, id_) }) +} +export class MutableInvalid extends Invalid implements MutableAst { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + /** Private, because it makes more sense to `.replace` the `Invalid` node. */ + private setExpression(value: Owned) { + this.fields.set('expression', unspaced(this.claimChild(value))) + } + + replaceChild(target: AstId, replacement: Owned) { + assertEqual(this.fields.get('expression').node, target) + this.setExpression(replacement) + } +} +export interface MutableInvalid extends Invalid, MutableAst { + /** The `expression` getter is intentionally not narrowed to provide mutable access: + * It makes more sense to `.replace` the `Invalid` node. */ +} +applyMixins(MutableInvalid, [MutableAst]) + +interface GroupFields { + open: NodeChild | undefined + expression: NodeChild | undefined + close: NodeChild | undefined +} +export class Group extends Ast { + declare fields: FixedMapView + constructor(module: Module, fields: FixedMapView) { + super(module, fields) + } + + static concrete( + module: MutableModule, + open: NodeChild | undefined, + expression: NodeChild | undefined, + close: NodeChild | undefined, + ) { + const base = module.baseObject('Group') + const id_ = base.get('id') + const fields = setAll(base, { open, expression: concreteChild(module, expression, id_), close }) + return asOwned(new MutableGroup(module, fields)) + } + + static new(module: MutableModule, expression: Owned) { + const open = unspaced(Token.new('(', RawAst.Token.Type.OpenSymbol)) + const close = unspaced(Token.new(')', RawAst.Token.Type.CloseSymbol)) + return this.concrete(module, open, unspaced(expression), close) + } + + get expression(): Ast | undefined { + return this.module.checkedGet(this.fields.get('expression')?.node) + } + + *concreteChildren(verbatim?: boolean): IterableIterator { + const { open, expression, close } = getAll(this.fields) + if (open) yield open + if (expression) yield expression + if (close) yield close + } +} +export class MutableGroup extends Group implements MutableAst { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + setExpression(value: Owned | undefined) { + this.fields.set('expression', unspaced(this.claimChild(value))) + } + + replaceChild(target: AstId, replacement: Owned) { + assertEqual(this.fields.get('expression')?.node, target) + this.setExpression(replacement) + } +} +export interface MutableGroup extends Group, MutableAst { + get expression(): MutableAst | undefined +} +applyMixins(MutableGroup, [MutableAst]) + +interface NumericLiteralFields { + tokens: NodeChild[] +} +export class NumericLiteral extends Ast { + declare fields: FixedMapView + constructor(module: Module, fields: FixedMapView) { + super(module, fields) + } + + static concrete(module: MutableModule, tokens: NodeChild[]) { + const base = module.baseObject('NumericLiteral') + const fields = setAll(base, { tokens }) + return asOwned(new MutableNumericLiteral(module, fields)) + } + + concreteChildren(verbatim?: boolean): IterableIterator { + return this.fields.get('tokens')[Symbol.iterator]() + } +} +export class MutableNumericLiteral extends NumericLiteral implements MutableAst { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + replaceChild(target: AstId, replacement: Owned) {} +} +export interface MutableNumericLiteral extends NumericLiteral, MutableAst {} +applyMixins(MutableNumericLiteral, [MutableAst]) + +/** The actual contents of an `ArgumentDefinition` are complex, but probably of more interest to the compiler than the + * GUI. We just need to represent them faithfully and create the simple cases. */ +type ArgumentDefinition = NodeChild[] +type RawArgumentDefinition = NodeChild[] +type OwnedArgumentDefinition = NodeChild[] + +function argumentDefinitionsToRaw( + module: MutableModule, + defs: OwnedArgumentDefinition[], + parent: AstId, +): RawArgumentDefinition[] { + return defs.map((def) => + def.map((part) => ({ + ...part, + node: part.node instanceof Token ? part.node : claimChild(module, part.node, parent), + })), + ) +} + +interface FunctionFields { + name: NodeChild + argumentDefinitions: RawArgumentDefinition[] + equals: NodeChild + body: NodeChild | undefined +} +export class Function extends Ast { + declare fields: FixedMapView + constructor(module: Module, fields: FixedMapView) { + super(module, fields) + } + + get name(): Ast { + return this.module.checkedGet(this.fields.get('name').node) + } + get body(): Ast | undefined { + return this.module.checkedGet(this.fields.get('body')?.node) + } + get argumentDefinitions(): ArgumentDefinition[] { + return this.fields.get('argumentDefinitions').map((raw) => + raw.map((part) => ({ + ...part, + node: this.module.getAny(part.node), + })), + ) + } + + static concrete( + module: MutableModule, + name: NodeChild, + argumentDefinitions: OwnedArgumentDefinition[], + equals: NodeChild, + body: NodeChild | undefined, + ) { + const base = module.baseObject('Function') + const id_ = base.get('id') + const fields = setAll(base, { + name: concreteChild(module, name, id_), + argumentDefinitions: argumentDefinitionsToRaw(module, argumentDefinitions, id_), + equals, + body: concreteChild(module, body, id_), + }) + return asOwned(new MutableFunction(module, fields)) + } + + static new( + module: MutableModule, + name: IdentLike, + argumentDefinitions: OwnedArgumentDefinition[], + body: Owned, + ): Owned { + // Note that a function name may not be an operator if the function is not in the body of a type definition, but we + // can't easily enforce that because we don't currently make a syntactic distinction between top-level functions and + // type methods. + return MutableFunction.concrete( + module, + unspaced(Ident.newAllowingOperators(module, name)), + argumentDefinitions, + spaced(makeEquals()), + autospaced(body), + ) + } + + /** Construct a function with simple (name-only) arguments and a body block. */ + static fromStatements( + module: MutableModule, + name: IdentLike, + argumentNames: StrictIdentLike[], + statements: Owned[], + ): Owned { + const statements_: OwnedBlockLine[] = statements.map((statement) => ({ + expression: unspaced(statement), + })) + const argumentDefinitions = argumentNames.map((name) => [spaced(Ident.new(module, name))]) + const body = BodyBlock.new(statements_, module) + return MutableFunction.new(module, name, argumentDefinitions, body) + } + + *bodyExpressions(): IterableIterator { + const body = this.body + if (body instanceof BodyBlock) { + yield* body.statements() + } else if (body) { + yield body + } + } + + *concreteChildren(verbatim?: boolean): IterableIterator { + const { name, argumentDefinitions, equals, body } = getAll(this.fields) + yield name + for (const def of argumentDefinitions) yield* def + yield { whitespace: equals.whitespace ?? ' ', node: this.module.getToken(equals.node) } + if (body) + yield ensureSpacedOnlyIf(body, !(this.module.get(body.node) instanceof BodyBlock), verbatim) + } +} +export class MutableFunction extends Function implements MutableAst { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + setName(value: Owned) { + this.fields.set('name', unspaced(this.claimChild(value))) + } + setBody(value: Owned | undefined) { + this.fields.set('body', unspaced(this.claimChild(value))) + } + setArgumentDefinitions(defs: OwnedArgumentDefinition[]) { + this.fields.set('argumentDefinitions', argumentDefinitionsToRaw(this.module, defs, this.id)) + } + + /** Returns the body, after converting it to a block if it was empty or an inline expression. */ + bodyAsBlock(): MutableBodyBlock { + const oldBody = this.body + if (oldBody instanceof MutableBodyBlock) return oldBody + const newBody = BodyBlock.new([], this.module) + if (oldBody) newBody.push(oldBody.take()) + return newBody + } + + replaceChild(target: AstId, replacement: Owned) { + const { name, argumentDefinitions, body } = getAll(this.fields) + if (name.node === target) { + this.setName(replacement) + } else if (body?.node === target) { + this.setBody(replacement) + } else { + const replacement_ = this.claimChild(replacement) + const replaceChild = (child: NodeChild) => + child.node === target ? { ...child, node: replacement_ } : child + this.fields.set( + 'argumentDefinitions', + argumentDefinitions.map((def) => def.map(replaceChild)), + ) + } + } +} +export interface MutableFunction extends Function, MutableAst { + get name(): MutableAst + get body(): MutableAst | undefined +} +applyMixins(MutableFunction, [MutableAst]) + +interface AssignmentFields { + pattern: NodeChild + equals: NodeChild + expression: NodeChild +} +export class Assignment extends Ast { + declare fields: FixedMapView + constructor(module: Module, fields: FixedMapView) { + super(module, fields) + } + + static concrete( + module: MutableModule, + pattern: NodeChild, + equals: NodeChild, + expression: NodeChild, + ) { + const base = module.baseObject('Assignment') + const id_ = base.get('id') + const fields = setAll(base, { + pattern: concreteChild(module, pattern, id_), + equals, + expression: concreteChild(module, expression, id_), + }) + return asOwned(new MutableAssignment(module, fields)) + } + + static new(module: MutableModule, ident: StrictIdentLike, expression: Owned) { + return Assignment.concrete( + module, + unspaced(Ident.new(module, ident)), + spaced(makeEquals()), + spaced(expression), + ) + } + + get pattern(): Ast { + return this.module.checkedGet(this.fields.get('pattern').node) + } + get expression(): Ast { + return this.module.checkedGet(this.fields.get('expression').node) + } + + *concreteChildren(verbatim?: boolean): IterableIterator { + const { pattern, equals, expression } = getAll(this.fields) + yield pattern + yield ensureSpacedOnlyIf(equals, expression.whitespace !== '', verbatim) + yield expression + } +} +export class MutableAssignment extends Assignment implements MutableAst { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + setPattern(value: Owned) { + this.fields.set('pattern', unspaced(this.claimChild(value))) + } + setExpression(value: Owned) { + setNode(this.fields, 'expression', this.claimChild(value)) + } + + replaceChild(target: AstId, replacement: Owned) { + const { pattern, expression } = getAll(this.fields) + if (pattern.node === target) { + this.setPattern(replacement) + } else if (expression.node === target) { + this.setExpression(replacement) + } + } +} +export interface MutableAssignment extends Assignment, MutableAst { + get pattern(): MutableAst + get expression(): MutableAst +} +applyMixins(MutableAssignment, [MutableAst]) + +interface BodyBlockFields { + lines: RawBlockLine[] +} +export class BodyBlock extends Ast { + declare fields: FixedMapView + constructor(module: Module, fields: FixedMapView) { + super(module, fields) + } + + static concrete(module: MutableModule, lines: OwnedBlockLine[]) { + const base = module.baseObject('BodyBlock') + const id_ = base.get('id') + const fields = setAll(base, { + lines: lines.map((line) => lineToRaw(line, module, id_)), + }) + return asOwned(new MutableBodyBlock(module, fields)) + } + + static new(lines: OwnedBlockLine[], module: MutableModule) { + return BodyBlock.concrete(module, lines) + } + + get lines(): BlockLine[] { + return this.fields.get('lines').map((line) => lineFromRaw(line, this.module)) + } + + *statements(): IterableIterator { + for (const line of this.lines) { + if (line.expression) yield line.expression.node + } + } + + *concreteChildren(verbatim?: boolean): IterableIterator { + for (const line of this.fields.get('lines')) { + yield line.newline ?? { node: Token.new('\n', RawAst.Token.Type.Newline) } + if (line.expression) yield line.expression + } + } + + printSubtree( + info: SpanMap, + offset: number, + parentIndent: string | undefined, + verbatim?: boolean, + ): string { + let blockIndent: string | undefined + let code = '' + for (const line of this.fields.get('lines')) { + code += line.newline.whitespace ?? '' + const newlineCode = this.module.getToken(line.newline.node).code() + // Only print a newline if this isn't the first line in the output, or it's a comment. + if (offset || code || newlineCode.startsWith('#')) { + // If this isn't the first line in the output, but there is a concrete newline token: + // if it's a zero-length newline, ignore it and print a normal newline. + code += newlineCode || '\n' + } + if (line.expression) { + if (blockIndent === undefined) { + if ((line.expression.whitespace?.length ?? 0) > (parentIndent?.length ?? 0)) { + blockIndent = line.expression.whitespace! + } else if (parentIndent !== undefined) { + blockIndent = parentIndent + ' ' + } else { + blockIndent = '' + } + } + const validIndent = (line.expression.whitespace?.length ?? 0) > (parentIndent?.length ?? 0) + code += validIndent ? line.expression.whitespace : blockIndent + const lineNode = this.module.checkedGet(line.expression.node) + assertEqual(lineNode.id, line.expression.node) + assertEqual(parentId(lineNode), this.id) + code += lineNode.printSubtree(info, offset + code.length, blockIndent, verbatim) + } + } + const span = nodeKey(offset, code.length) + map.setIfUndefined(info.nodes, span, (): Ast[] => []).unshift(this) + return code + } +} +export class MutableBodyBlock extends BodyBlock implements MutableAst { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + updateLines(map: (lines: OwnedBlockLine[]) => OwnedBlockLine[]) { + return this.setLines(map(this.takeLines())) + } + takeLines(): OwnedBlockLine[] { + return this.fields.get('lines').map((line) => ownedLineFromRaw(line, this.module)) + } + setLines(lines: OwnedBlockLine[]) { + this.fields.set( + 'lines', + lines.map((line) => lineToRaw(line, this.module, this.id)), + ) + } + + /** Insert the given statement(s) starting at the specified line index. */ + insert(index: number, ...statements: (Owned | undefined)[]) { + const before = this.fields.get('lines').slice(0, index) + const insertions = statements.map((statement) => ({ + newline: unspaced(Token.new('\n', RawAst.Token.Type.Newline)), + expression: statement && unspaced(this.claimChild(statement)), + })) + const after = this.fields.get('lines').slice(index) + this.fields.set('lines', [...before, ...insertions, ...after]) + } + + push(statement: Owned) { + const oldLines = this.fields.get('lines') + const newLine = { + newline: unspaced(Token.new('\n', RawAst.Token.Type.Newline)), + expression: unspaced(this.claimChild(statement)), + } + this.fields.set('lines', [...oldLines, newLine]) + } + + filter(keep: (ast: MutableAst) => boolean) { + const oldLines = this.fields.get('lines') + const filteredLines = oldLines.filter((line) => { + if (!line.expression) return true + return keep(this.module.checkedGet(line.expression.node)) + }) + this.fields.set('lines', filteredLines) + } + + replaceChild(target: AstId, replacement: Owned) { + const replacement_ = this.claimChild(replacement) + const updateLine = (line: RawBlockLine) => + line.expression?.node === target + ? { ...line, expression: { ...line.expression, node: replacement_ } } + : line + this.fields.set('lines', this.fields.get('lines').map(updateLine)) + } +} +export interface MutableBodyBlock extends BodyBlock, MutableAst { + statements(): IterableIterator +} +applyMixins(MutableBodyBlock, [MutableAst]) + +interface RawLine { + newline: NodeChild + expression: NodeChild | undefined +} +interface Line { + newline?: NodeChild | undefined + expression: NodeChild | undefined +} + +type RawBlockLine = RawLine +export type BlockLine = Line +export type OwnedBlockLine = Line + +function lineFromRaw(raw: RawBlockLine, module: Module): BlockLine { + const expression = raw.expression ? module.checkedGet(raw.expression.node) : undefined + return { + newline: { ...raw.newline, node: module.getToken(raw.newline.node) }, + expression: expression + ? { + whitespace: raw.expression?.whitespace, + node: expression, + } + : undefined, + } +} + +function ownedLineFromRaw(raw: RawBlockLine, module: MutableModule): OwnedBlockLine { + const expression = raw.expression + ? module.checkedGet(raw.expression.node).takeIfParented() + : undefined + return { + newline: { ...raw.newline, node: module.getToken(raw.newline.node) }, + expression: expression + ? { + whitespace: raw.expression?.whitespace, + node: expression, + } + : undefined, + } +} + +function lineToRaw(line: OwnedBlockLine, module: MutableModule, block: AstId): RawBlockLine { + return { + newline: line.newline ?? unspaced(Token.new('\n', RawAst.Token.Type.Newline)), + expression: line.expression + ? { + whitespace: line.expression?.whitespace, + node: claimChild(module, line.expression.node, block), + } + : undefined, + } +} + +interface IdentFields { + token: NodeChild +} +export class Ident extends Ast { + declare fields: FixedMapView + constructor(module: Module, fields: FixedMapView) { + super(module, fields) + } + + get token(): IdentifierToken { + return this.module.getToken(this.fields.get('token').node) as IdentifierToken + } + + static concrete(module: MutableModule, token: NodeChild) { + const base = module.baseObject('Ident') + const fields = setAll(base, { token }) + return asOwned(new MutableIdent(module, fields)) + } + + static new(module: MutableModule, ident: StrictIdentLike) { + return Ident.concrete(module, unspaced(toIdentStrict(ident))) + } + + /** @internal */ + static newAllowingOperators(module: MutableModule, ident: IdentLike) { + return Ident.concrete(module, unspaced(toIdent(ident))) + } + + *concreteChildren(verbatim?: boolean): IterableIterator { + yield this.fields.get('token') + } + + code(): Identifier { + return this.token.code() as Identifier + } +} +export class MutableIdent extends Ident implements MutableAst { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + setToken(ident: IdentLike) { + this.fields.set('token', unspaced(toIdent(ident))) + } + + replaceChild(target: AstId, replacement: Owned) {} + + code(): Identifier { + return this.token.code() + } +} +export interface MutableIdent extends Ident, MutableAst {} +applyMixins(MutableIdent, [MutableAst]) + +interface WildcardFields { + token: NodeChild +} +export class Wildcard extends Ast { + declare fields: FixedMapView + constructor(module: Module, fields: FixedMapView) { + super(module, fields) + } + + get token(): Token { + return this.module.getToken(this.fields.get('token').node) + } + + static concrete(module: MutableModule, token: NodeChild) { + const base = module.baseObject('Wildcard') + const fields = setAll(base, { token }) + return asOwned(new MutableWildcard(module, fields)) + } + + static new(module: MutableModule) { + const token = Token.new('_', RawAst.Token.Type.Wildcard) + return this.concrete(module, unspaced(token)) + } + + *concreteChildren(verbatim?: boolean): IterableIterator { + yield this.fields.get('token') + } +} + +export class MutableWildcard extends Wildcard implements MutableAst { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + replaceChild(target: AstId, replacement: Owned) {} +} +export interface MutableWildcard extends Wildcard, MutableAst {} +applyMixins(MutableWildcard, [MutableAst]) + +export type Mutable = T extends App + ? MutableApp + : T extends Assignment + ? MutableAssignment + : T extends BodyBlock + ? MutableBodyBlock + : T extends Documented + ? MutableDocumented + : T extends Function + ? MutableFunction + : T extends Generic + ? MutableGeneric + : T extends Group + ? MutableGroup + : T extends Ident + ? MutableIdent + : T extends Import + ? MutableImport + : T extends Invalid + ? MutableInvalid + : T extends NegationApp + ? MutableNegationApp + : T extends NumericLiteral + ? MutableNumericLiteral + : T extends OprApp + ? MutableOprApp + : T extends PropertyAccess + ? MutablePropertyAccess + : T extends TextLiteral + ? MutableTextLiteral + : T extends UnaryOprApp + ? MutableUnaryOprApp + : T extends Wildcard + ? MutableWildcard + : MutableAst + +export function materializeMutable(module: MutableModule, fields: FixedMap): MutableAst { + const type = fields.get('type') + const fieldsForType = fields as FixedMap + switch (type) { + case 'App': + return new MutableApp(module, fieldsForType) + case 'UnaryOprApp': + return new MutableUnaryOprApp(module, fieldsForType) + case 'NegationApp': + return new MutableNegationApp(module, fieldsForType) + case 'OprApp': + return new MutableOprApp(module, fieldsForType) + case 'PropertyAccess': + return new MutablePropertyAccess(module, fieldsForType) + case 'Generic': + return new MutableGeneric(module, fieldsForType) + case 'Import': + return new MutableImport(module, fieldsForType) + case 'TextLiteral': + return new MutableTextLiteral(module, fieldsForType) + case 'Documented': + return new MutableDocumented(module, fieldsForType) + case 'Invalid': + return new MutableInvalid(module, fieldsForType) + case 'Group': + return new MutableGroup(module, fieldsForType) + case 'NumericLiteral': + return new MutableNumericLiteral(module, fieldsForType) + case 'Function': + return new MutableFunction(module, fieldsForType) + case 'Assignment': + return new MutableAssignment(module, fieldsForType) + case 'BodyBlock': + return new MutableBodyBlock(module, fieldsForType) + case 'Ident': + return new MutableIdent(module, fieldsForType) + case 'Wildcard': + return new MutableWildcard(module, fieldsForType) + } + bail(`Invalid type: ${type}`) +} + +export function materialize(module: Module, fields: FixedMapView): Ast { + const type = fields.get('type') + const fields_ = fields as FixedMapView + switch (type) { + case 'App': + return new App(module, fields_) + case 'UnaryOprApp': + return new UnaryOprApp(module, fields_) + case 'NegationApp': + return new NegationApp(module, fields_) + case 'OprApp': + return new OprApp(module, fields_) + case 'PropertyAccess': + return new PropertyAccess(module, fields_) + case 'Generic': + return new Generic(module, fields_) + case 'Import': + return new Import(module, fields_) + case 'TextLiteral': + return new TextLiteral(module, fields_) + case 'Documented': + return new Documented(module, fields_) + case 'Invalid': + return new Invalid(module, fields_) + case 'Group': + return new Group(module, fields_) + case 'NumericLiteral': + return new NumericLiteral(module, fields_) + case 'Function': + return new Function(module, fields_) + case 'Assignment': + return new Assignment(module, fields_) + case 'BodyBlock': + return new BodyBlock(module, fields_) + case 'Ident': + return new Ident(module, fields_) + case 'Wildcard': + return new Wildcard(module, fields_) + } + bail(`Invalid type: ${type}`) +} + +export interface FixedMapView { + get(key: Key): Fields[Key] + entries(): IterableIterator + clone(): FixedMap + has(key: string): boolean +} + +export interface FixedMap extends FixedMapView { + set(key: Key, value: Fields[Key]): void +} + +function getAll(map: FixedMapView): Fields { + return Object.fromEntries(map.entries()) as Fields +} + +/** Modifies the input `map`. Returns the same object with an extended type. */ +export function setAll( + map: FixedMap, + fields: Fields2, +): FixedMap { + const map_ = map as FixedMap + for (const [k, v] of Object.entries(fields)) { + const k_ = k as string & (keyof Fields1 | keyof Fields2) + map_.set(k_, v) + } + return map_ +} + +function claimChild( + module: MutableModule, + child: Owned, + parent: AstId, +): AstId { + if (child.module === module) assertEqual(child.fields.get('parent'), undefined) + const child_ = module.copyIfForeign(child) + child_.fields.set('parent', parent) + return child_.id +} + +function concreteChild( + module: MutableModule, + child: NodeChild, + parent: AstId, +): NodeChild +function concreteChild( + module: MutableModule, + child: NodeChild | undefined, + parent: AstId, +): NodeChild | undefined +function concreteChild( + module: MutableModule, + child: NodeChild, + parent: AstId, +): NodeChild +function concreteChild( + module: MutableModule, + child: NodeChild | undefined, + parent: AstId, +): NodeChild | undefined +function concreteChild( + module: MutableModule, + child: NodeChild | undefined, + parent: AstId, +): NodeChild | undefined { + if (!child) return undefined + if (isTokenId(child.node)) return child as NodeChild + return { ...child, node: claimChild(module, child.node, parent) } +} + +type StrictIdentLike = Identifier | IdentifierToken +function toIdentStrict(ident: StrictIdentLike): IdentifierToken +function toIdentStrict(ident: StrictIdentLike | undefined): IdentifierToken | undefined +function toIdentStrict(ident: StrictIdentLike | undefined): IdentifierToken | undefined { + return ident + ? isToken(ident) + ? ident + : (Token.new(ident, RawAst.Token.Type.Ident) as IdentifierToken) + : undefined +} + +type IdentLike = IdentifierOrOperatorIdentifier | IdentifierOrOperatorIdentifierToken +function toIdent(ident: IdentLike): IdentifierOrOperatorIdentifierToken +function toIdent(ident: IdentLike | undefined): IdentifierOrOperatorIdentifierToken | undefined +function toIdent(ident: IdentLike | undefined): IdentifierOrOperatorIdentifierToken | undefined { + return ident + ? isToken(ident) + ? ident + : (Token.new(ident, RawAst.Token.Type.Ident) as IdentifierOrOperatorIdentifierToken) + : undefined +} + +function makeEquals(): Token { + return Token.new('=', RawAst.Token.Type.Operator) +} + +function nameSpecification( + name: StrictIdentLike | undefined, +): { name: NodeChild; equals: NodeChild } | undefined { + return name && { name: autospaced(toIdentStrict(name)), equals: unspaced(makeEquals()) } +} + +type KeysOfFieldType = { + [K in keyof Fields]: Fields[K] extends T ? K : never +}[keyof Fields] +function setNode>>( + map: FixedMap, + key: Key, + node: AstId, +): void +function setNode< + Fields, + Key extends string & KeysOfFieldType | undefined>, +>(map: FixedMap, key: Key, node: AstId | undefined): void +function setNode< + Fields, + Key extends string & KeysOfFieldType | undefined>, +>(map: FixedMap, key: Key, node: AstId | undefined): void { + // The signature correctly only allows this function to be called if `Fields[Key] instanceof NodeChild`, + // but it doesn't prove that property to TSC, so we have to cast here. + const old = map.get(key as string & keyof Fields) + const updated = old ? { ...old, node } : autospaced(node) + map.set(key, updated as Fields[Key]) +} + +function spaced(node: T): NodeChild +function spaced(node: T | undefined): NodeChild | undefined +function spaced(node: T | undefined): NodeChild | undefined { + if (node === undefined) return node + return { whitespace: ' ', node } +} + +function unspaced(node: T): NodeChild +function unspaced(node: T | undefined): NodeChild | undefined +function unspaced(node: T | undefined): NodeChild | undefined { + if (node === undefined) return node + return { whitespace: '', node } +} + +function autospaced(node: T): NodeChild +function autospaced(node: T | undefined): NodeChild | undefined +function autospaced(node: T | undefined): NodeChild | undefined { + if (node === undefined) return node + return { node } +} + +export interface Removed { + node: Owned + placeholder: MutableWildcard | undefined +} diff --git a/app/gui2/shared/util/assert.ts b/app/gui2/shared/util/assert.ts new file mode 100644 index 0000000000..288a559945 --- /dev/null +++ b/app/gui2/shared/util/assert.ts @@ -0,0 +1,69 @@ +export function assertNever(x: never): never { + bail('Unexpected object: ' + JSON.stringify(x)) +} + +export function assert(condition: boolean, message?: string): asserts condition { + if (!condition) bail(message ? `Assertion failed: ${message}` : 'Assertion failed') +} + +/** + * Checks if the given iterable has the specified length and throws an assertion error + * if the lengths do not match. + * + * @param iterable The iterable to check. + * @param length The expected length of the iterable. + * @param message Optional message for the assertion error. + * @return void + * @throws Error Will throw an error if the length does not match. + * + * The first five elements of the iterable will be displayed in the error message + * if the assertion fails. If the iterable contains more than five elements, + * the remaining elements will be represented as '...'. + */ +export function assertLength(iterable: Iterable, length: number, message?: string): void { + const convertedArray = Array.from(iterable) + const messagePrefix = message ? message + ' ' : '' + const elementRepresentation = + convertedArray.length > 5 + ? `${convertedArray.slice(0, 5).join(', ')},...` + : convertedArray.join(', ') + assert( + convertedArray.length === length, + `${messagePrefix}Expected iterable of length ${length}, got length ${convertedArray.length}. Elements: [${elementRepresentation}]`, + ) +} + +export function assertEmpty(iterable: Iterable, message?: string): void { + assertLength(iterable, 0, message) +} + +export function assertEqual(actual: T, expected: T, message?: string) { + const messagePrefix = message ? message + ' ' : '' + assert(actual === expected, `${messagePrefix}Expected ${expected}, got ${actual}.`) +} + +export function assertNotEqual(actual: T, unexpected: T, message?: string) { + const messagePrefix = message ? message + ' ' : '' + assert(actual !== unexpected, `${messagePrefix}Expected not ${unexpected}, got ${actual}.`) +} + +export function assertDefined(x: T | undefined, message?: string): asserts x is T { + const messagePrefix = message ? message + ' ' : '' + assert(x !== undefined, `${messagePrefix}Expected value to be defined.`) +} + +export function assertUnreachable(): never { + bail('Unreachable code') +} + +/** + * Throw an error with provided message. + * + * It is convenient to use at the end of a nullable chain: + * ```ts + * const x = foo?.bar.baz?.() ?? bail('Expected foo.bar.baz to exist') + * ``` + */ +export function bail(message: string): never { + throw new Error(message) +} diff --git a/app/gui2/shared/util/data/opt.ts b/app/gui2/shared/util/data/opt.ts new file mode 100644 index 0000000000..968c03b25b --- /dev/null +++ b/app/gui2/shared/util/data/opt.ts @@ -0,0 +1,25 @@ +/** @file A value that may be `null` or `undefined`. */ + +/** Optional value type. This is a replacement for `T | null | undefined` that is more + * convenient to use. We do not select a single value to represent "no value", because we are using + * libraries that disagree whether `null` (e.g. Yjs) or `undefined` (e.g. Vue) should be used for + * that purpose. We want to be compatible with both without needless conversions. In our own code, + * we should return `undefined` for "no value", since that is the default value for empty or no + * `return` expression. In order to test whether an `Opt` is defined or not, use `x == null` or + * `isSome` function. + * + * Note: For JSON-serialized data, prefer explicit `null` over `undefined`, since `undefined` is + * not serializable. Alternatively, use optional field syntax (e.g. `{ x?: number }`). */ +export type Opt = T | null | undefined + +export function isSome(value: Opt): value is T { + return value != null +} + +export function isNone(value: Opt): value is null | undefined { + return value == null +} + +export function mapOr(optional: Opt, fallback: R, mapper: (value: T) => R): R { + return isSome(optional) ? mapper(optional) : fallback +} diff --git a/app/gui2/shared/util/data/result.ts b/app/gui2/shared/util/data/result.ts new file mode 100644 index 0000000000..fad4702370 --- /dev/null +++ b/app/gui2/shared/util/data/result.ts @@ -0,0 +1,85 @@ +/** @file A generic type that can either hold a value representing a successful result, + * or an error. */ + +import { isSome, type Opt } from './opt' + +export type Result = + | { ok: true; value: T } + | { ok: false; error: ResultError } + +export function Ok(data: T): Result { + return { ok: true, value: data } +} + +export function Err(error: E): Result { + return { ok: false, error: new ResultError(error) } +} + +export function okOr(data: Opt, error: E): Result { + if (isSome(data)) return Ok(data) + else return Err(error) +} + +export function unwrap(result: Result): T { + if (result.ok) return result.value + else throw result.error +} + +export function mapOk(result: Result, f: (value: T) => U): Result { + if (result.ok) return Ok(f(result.value)) + else return result +} + +export function isResult(v: unknown): v is Result { + return ( + v != null && + typeof v === 'object' && + 'ok' in v && + typeof v.ok === 'boolean' && + ('value' in v || ('error' in v && v.error instanceof ResultError)) + ) +} + +export class ResultError { + payload: E + context: (() => string)[] + + constructor(payload: E) { + this.payload = payload + this.context = [] + } + + log(preamble: string = 'Error') { + const ctx = + this.context.length > 0 ? `\n${Array.from(this.context, (ctx) => ctx()).join('\n')}` : '' + console.error(`${preamble}: ${this.payload}${ctx}`) + } +} + +export function withContext(context: () => string, f: () => Result): Result { + const result = f() + if (result == null) { + throw new Error('withContext: f() returned null or undefined') + } + if (!result.ok) result.error.context.splice(0, 0, context) + return result +} + +/** + * Catch promise rejection of provided types and convert them to a Result type. + */ +export function rejectionToResult any>( + errorKinds: ErrorKind | ErrorKind[], +): (promise: Promise) => Promise>> { + const errorKindArray = Array.isArray(errorKinds) ? errorKinds : [errorKinds] + return async (promise) => { + try { + return Ok(await promise) + } catch (error) { + for (const errorKind of errorKindArray) { + if (error instanceof errorKind) return Err(error) + } + throw error + } + } +} diff --git a/app/gui2/src/util/detect.ts b/app/gui2/shared/util/detect.ts similarity index 100% rename from app/gui2/src/util/detect.ts rename to app/gui2/shared/util/detect.ts diff --git a/app/gui2/shared/yjsModel.ts b/app/gui2/shared/yjsModel.ts index 571f645066..bd333ae84d 100644 --- a/app/gui2/shared/yjsModel.ts +++ b/app/gui2/shared/yjsModel.ts @@ -96,39 +96,12 @@ export class DistributedProject { } } -export interface NodeMetadata { - x: number - y: number - vis: VisualizationMetadata | null -} - export class ModuleDoc { ydoc: Y.Doc - metadata: Y.Map - data: Y.Map + nodes: Y.Map constructor(ydoc: Y.Doc) { this.ydoc = ydoc - this.metadata = ydoc.getMap('metadata') - this.data = ydoc.getMap('data') - } - - setIdMap(map: IdMap) { - const oldMap = new IdMap(this.data.get('idmap') ?? []) - if (oldMap.isEqual(map)) return - this.data.set('idmap', map.entries()) - } - - getIdMap(): IdMap { - const map = this.data.get('idmap') - return new IdMap(map ?? []) - } - - setCode(code: string) { - this.data.set('code', code) - } - - getCode(): string { - return this.data.get('code') ?? '' + this.nodes = ydoc.getMap('nodes') } } @@ -144,26 +117,13 @@ export class DistributedModule { constructor(ydoc: Y.Doc) { this.doc = new ModuleDoc(ydoc) - this.undoManager = new Y.UndoManager([this.doc.data, this.doc.metadata]) + this.undoManager = new Y.UndoManager([this.doc.nodes]) } transact(fn: () => T): T { return this.doc.ydoc.transact(fn, 'local') } - updateNodeMetadata(id: ExternalId, meta: Partial): void { - const existing = this.doc.metadata.get(id) ?? { x: 0, y: 0, vis: null } - this.transact(() => this.doc.metadata.set(id, { ...existing, ...meta })) - } - - getNodeMetadata(id: ExternalId): NodeMetadata | null { - return this.doc.metadata.get(id) ?? null - } - - getIdMap(): IdMap { - return new IdMap(this.doc.data.get('idmap') ?? []) - } - dispose(): void { this.doc.ydoc.destroy() } diff --git a/app/gui2/src/App.vue b/app/gui2/src/App.vue index 9ad28b6759..1c59d9519b 100644 --- a/app/gui2/src/App.vue +++ b/app/gui2/src/App.vue @@ -5,8 +5,8 @@ import { provideGuiConfig } from '@/providers/guiConfig' import { useSuggestionDbStore } from '@/stores/suggestionDatabase' import { configValue, type ApplicationConfig, type ApplicationConfigValue } from '@/util/config' import ProjectView from '@/views/ProjectView.vue' +import { isDevMode } from 'shared/util/detect' import { computed, onMounted, toRaw } from 'vue' -import { isDevMode } from './util/detect' const props = defineProps<{ config: ApplicationConfig diff --git a/app/gui2/src/components/ComponentBrowser/__tests__/filtering.test.ts b/app/gui2/src/components/ComponentBrowser/__tests__/filtering.test.ts index 5c2fbda5d2..91d87a8c3a 100644 --- a/app/gui2/src/components/ComponentBrowser/__tests__/filtering.test.ts +++ b/app/gui2/src/components/ComponentBrowser/__tests__/filtering.test.ts @@ -13,6 +13,9 @@ import { } from '@/stores/suggestionDatabase/entry' import { unwrap } from '@/util/data/result' import { tryQualifiedName, type QualifiedName } from '@/util/qualifiedName' +import { initializeFFI } from 'shared/ast/ffi' + +await initializeFFI() test.each([ { ...makeModuleMethod('Standard.Base.Data.read'), groupIndex: 0 }, diff --git a/app/gui2/src/components/GraphEditor.vue b/app/gui2/src/components/GraphEditor.vue index c65478d04c..2303ac29c0 100644 --- a/app/gui2/src/components/GraphEditor.vue +++ b/app/gui2/src/components/GraphEditor.vue @@ -28,13 +28,12 @@ import type { RequiredImport } from '@/stores/graph/imports' import { useProjectStore } from '@/stores/project' import { groupColorVar, useSuggestionDbStore } from '@/stores/suggestionDatabase' import { bail } from '@/util/assert' -import type { AstId } from '@/util/ast/abstract.ts' +import type { AstId, NodeMetadataFields } from '@/util/ast/abstract' import { colorFromString } from '@/util/colors' import { Rect } from '@/util/data/rect' import { Vec2 } from '@/util/data/vec2' import * as set from 'lib0/set' import { toast } from 'react-toastify' -import type { NodeMetadata } from 'shared/yjsModel' import { computed, onMounted, onScopeDispose, onUnmounted, ref, watch } from 'vue' import { ProjectManagerEvents } from '../../../ide-desktop/lib/dashboard/src/utilities/ProjectManager' import { type Usage } from './ComponentBrowser/input' @@ -281,14 +280,19 @@ const graphBindingsHandler = graphBindings.handler({ const collapsedFunctionEnv = environmentForNodes(collapsedNodeIds.values()) // For collapsed function, only selected nodes would affect placement of the output node. collapsedFunctionEnv.nodeRects = collapsedFunctionEnv.selectedNodeRects - const meta = new Map>() const { position } = collapsedNodePlacement(DEFAULT_NODE_SIZE, currentFunctionEnv) - meta.set(refactoredNodeId, { x: Math.round(position.x), y: -Math.round(position.y) }) + edit + .checkedGet(refactoredNodeId) + .mutableNodeMetadata() + .set('position', { x: position.x, y: position.y }) if (outputNodeId != null) { const { position } = previousNodeDictatedPlacement(DEFAULT_NODE_SIZE, collapsedFunctionEnv) - meta.set(outputNodeId, { x: Math.round(position.x), y: -Math.round(position.y) }) + edit + .checkedGet(outputNodeId) + .mutableNodeMetadata() + .set('position', { x: position.x, y: position.y }) } - graphStore.commitEdit(edit, meta) + graphStore.commitEdit(edit) } catch (err) { console.log('Error while collapsing, this is not normal.', err) } @@ -489,7 +493,7 @@ interface ClipboardData { /** Node data that is copied to the clipboard. Used for serializing and deserializing the node information. */ interface CopiedNode { expression: string - metadata: NodeMetadata | undefined + metadata: NodeMetadataFields | undefined } /** Copy the content of the selected node to the clipboard. */ @@ -498,7 +502,11 @@ function copyNodeContent() { const node = graphStore.db.nodeIdToNode.get(id) if (!node) return const content = node.rootSpan.code() - const metadata = projectStore.module?.getNodeMetadata(id) ?? undefined + const nodeMetadata = node.rootSpan.nodeMetadata + const metadata = { + position: nodeMetadata.get('position'), + visualization: nodeMetadata.get('visualization'), + } const copiedNode: CopiedNode = { expression: content, metadata } const clipboardData: ClipboardData = { nodes: [copiedNode] } const jsonItem = new Blob([JSON.stringify(clipboardData)], { type: ENSO_MIME_TYPE }) diff --git a/app/gui2/src/components/GraphEditor/GraphEdge.vue b/app/gui2/src/components/GraphEditor/GraphEdge.vue index 15591e3525..a376f7e5a1 100644 --- a/app/gui2/src/components/GraphEditor/GraphEdge.vue +++ b/app/gui2/src/components/GraphEditor/GraphEdge.vue @@ -9,8 +9,6 @@ import theme from '@/util/theme' import { clamp } from '@vueuse/core' import { computed, ref } from 'vue' -const DEBUG = false - const selection = injectGraphSelection(true) const navigator = injectGraphNavigator(true) const graph = useGraphStore() @@ -55,11 +53,7 @@ const targetRect = computed(() => { const expr = targetExpr.value if (expr != null && targetNode.value != null && targetNodeRect.value != null) { const targetRectRelative = graph.getPortRelativeRect(expr) - if (targetRectRelative == null) { - // This seems to happen for some `Ast.Ident` ports while processing updates, but is quickly fixed. - if (DEBUG) console.warn(`No relative rect found for ${expr}.`) - return - } + if (targetRectRelative == null) return return targetRectRelative.offsetBy(targetNodeRect.value.pos) } else if (navigator?.sceneMousePos != null) { return new Rect(navigator.sceneMousePos, Vec2.Zero) diff --git a/app/gui2/src/components/GraphEditor/GraphEdges.vue b/app/gui2/src/components/GraphEditor/GraphEdges.vue index ce333fded1..c0f333f9c6 100644 --- a/app/gui2/src/components/GraphEditor/GraphEdges.vue +++ b/app/gui2/src/components/GraphEditor/GraphEdges.vue @@ -35,7 +35,6 @@ const editingEdge: Interaction = { } const target = graph.unconnectedEdge.target ?? selection?.hoveredPort const targetNode = target && graph.getPortNodeId(target) - console.log(source, target, targetNode) graph.transact(() => { if (source != null && sourceNode != targetNode) { if (target == null) { @@ -57,7 +56,7 @@ const editingEdge: Interaction = { interaction.setWhen(() => graph.unconnectedEdge != null, editingEdge) function disconnectEdge(target: PortId) { - graph.editScope((edit) => { + graph.commitDirect((edit) => { if (!graph.updatePortValue(edit, target, undefined)) { if (isAstId(target)) { console.warn(`Failed to disconnect edge from port ${target}, falling back to direct edit.`) @@ -77,7 +76,6 @@ function createEdge(source: AstId, target: PortId) { const sourceNode = graph.db.getPatternExpressionNodeId(source) const targetNode = graph.getPortNodeId(target) if (sourceNode == null || targetNode == null) { - console.log(sourceNode, targetNode, source, target) return console.error(`Failed to connect edge, source or target node not found.`) } diff --git a/app/gui2/src/components/GraphEditor/NodeWidgetTree.vue b/app/gui2/src/components/GraphEditor/NodeWidgetTree.vue index 0f439d32d1..d32d370b4d 100644 --- a/app/gui2/src/components/GraphEditor/NodeWidgetTree.vue +++ b/app/gui2/src/components/GraphEditor/NodeWidgetTree.vue @@ -7,8 +7,6 @@ import { useGraphStore } from '@/stores/graph' import { Ast } from '@/util/ast' import { computed, toRef } from 'vue' -const DEBUG = false - const props = defineProps<{ ast: Ast.Ast }>() const graph = useGraphStore() const rootPort = computed(() => { @@ -33,7 +31,6 @@ const observedLayoutTransitions = new Set([ ]) function handleWidgetUpdates(update: WidgetUpdate) { - if (DEBUG) console.log('Widget Update: ', update) if (update.portUpdate) { const { edit, diff --git a/app/gui2/src/components/GraphEditor/collapsing.ts b/app/gui2/src/components/GraphEditor/collapsing.ts index 19099ab1a3..35cd980f24 100644 --- a/app/gui2/src/components/GraphEditor/collapsing.ts +++ b/app/gui2/src/components/GraphEditor/collapsing.ts @@ -204,14 +204,8 @@ export function performCollapse( outputNodeId = asNodeId(ident.id) } const argNames = info.extracted.inputs - const collapsedFunction = Ast.Function.fromStatements( - edit, - collapsedName, - argNames, - collapsed, - true, - ) - topLevel.insert(posToInsert, collapsedFunction) + const collapsedFunction = Ast.Function.fromStatements(edit, collapsedName, argNames, collapsed) + topLevel.insert(posToInsert, collapsedFunction, undefined) return { refactoredNodeId, collapsedNodeIds, outputNodeId } } @@ -244,6 +238,8 @@ function findInsertionPos(topLevel: Ast.BodyBlock, currentMethodName: string): n if (import.meta.vitest) { const { test, expect } = import.meta.vitest + const { initializeFFI } = await import('shared/ast/ffi') + await initializeFFI() function setupGraphDb(code: string, graphDb: GraphDb) { const { root, toRaw, getSpan } = Ast.parseExtended(code) @@ -252,7 +248,7 @@ if (import.meta.vitest) { assert(func instanceof Ast.Function) const rawFunc = toRaw.get(func.id) assert(rawFunc?.type === RawAst.Tree.Type.Function) - graphDb.readFunctionAst(func, rawFunc, code, (_id) => undefined, getSpan) + graphDb.readFunctionAst(func, rawFunc, code, getSpan, new Set()) } interface TestCase { diff --git a/app/gui2/src/main.ts b/app/gui2/src/main.ts index 9655a978b0..4675765f40 100644 --- a/app/gui2/src/main.ts +++ b/app/gui2/src/main.ts @@ -1,9 +1,10 @@ import { baseConfig, configValue, mergeConfig } from '@/util/config' -import { isDevMode } from '@/util/detect' import { urlParams } from '@/util/urlParams' import { isOnLinux } from 'enso-common/src/detect' import * as dashboard from 'enso-dashboard' import 'enso-dashboard/src/tailwind.css' +import { initializeFFI } from 'shared/ast/ffi' +import { isDevMode } from 'shared/util/detect' const INITIAL_URL_KEY = `Enso-initial-url` const SCAM_WARNING_TIMEOUT = 1000 @@ -50,6 +51,7 @@ export interface StringConfig { } async function runApp(config: StringConfig | null, accessToken: string | null, metadata?: object) { + await initializeFFI() running = true const { mountProjectApp } = await import('./createApp') if (!running) return diff --git a/app/gui2/src/stores/graph/__tests__/graphDatabase.test.ts b/app/gui2/src/stores/graph/__tests__/graphDatabase.test.ts index 9426df6962..3a5390e4da 100644 --- a/app/gui2/src/stores/graph/__tests__/graphDatabase.test.ts +++ b/app/gui2/src/stores/graph/__tests__/graphDatabase.test.ts @@ -41,7 +41,7 @@ test('Reading graph from definition', () => { assert(func instanceof Ast.Function) const rawFunc = toRaw.get(func.id) assert(rawFunc?.type === RawAst.Tree.Type.Function) - db.readFunctionAst(func, rawFunc, code, (_) => ({ x: 0.0, y: 0.0, vis: null }), getSpan) + db.readFunctionAst(func, rawFunc, code, getSpan, new Set()) const idFromExternal = new Map() ast.visitRecursiveAst((ast) => idFromExternal.set(ast.externalId, ast.id)) diff --git a/app/gui2/src/stores/graph/graphDatabase.ts b/app/gui2/src/stores/graph/graphDatabase.ts index 6c6e03db98..f241e0dc53 100644 --- a/app/gui2/src/stores/graph/graphDatabase.ts +++ b/app/gui2/src/stores/graph/graphDatabase.ts @@ -3,7 +3,8 @@ import { SuggestionDb, groupColorStyle, type Group } from '@/stores/suggestionDa import type { SuggestionEntry } from '@/stores/suggestionDatabase/entry' import { assert } from '@/util/assert' import { Ast, RawAst } from '@/util/ast' -import type { AstId } from '@/util/ast/abstract' +import type { AstId, NodeMetadata } from '@/util/ast/abstract' +import { subtrees } from '@/util/ast/abstract' import { AliasAnalyzer } from '@/util/ast/aliasAnalysis' import { nodeFromAst } from '@/util/ast/node' import { colorFromString } from '@/util/colors' @@ -20,7 +21,6 @@ import { sourceRangeKey, visMetadataEquals, type ExternalId, - type NodeMetadata, type SourceRange, type VisualizationMetadata, } from 'shared/yjsModel' @@ -130,6 +130,7 @@ export class GraphDb { private readonly idToExternalMap = reactive(new Map()) private readonly idFromExternalMap = reactive(new Map()) private bindings = new BindingsDb() + private currentFunction: AstId | undefined = undefined constructor( private suggestionDb: SuggestionDb, @@ -328,31 +329,37 @@ export class GraphDb { functionAst_: Ast.Function, rawFunction: RawAst.Tree.Function, moduleCode: string, - getMeta: (id: ExternalId) => NodeMetadata | undefined, getSpan: (id: AstId) => SourceRange | undefined, + dirtyNodes: Set, ) { + const functionChanged = functionAst_.id !== this.currentFunction + const knownDirtySubtrees = functionChanged ? null : subtrees(functionAst_.module, dirtyNodes) + const subtreeDirty = (id: AstId) => !knownDirtySubtrees || knownDirtySubtrees.has(id) + this.currentFunction = functionAst_.id const currentNodeIds = new Set() for (const nodeAst of functionAst_.bodyExpressions()) { const newNode = nodeFromAst(nodeAst) const nodeId = asNodeId(newNode.rootSpan.id) const node = this.nodeIdToNode.get(nodeId) - const externalId = this.idToExternal(nodeId) - const nodeMeta = externalId && getMeta(externalId) + const nodeMeta = (node ?? newNode).rootSpan.nodeMetadata currentNodeIds.add(nodeId) if (node == null) { + // We are notified of new or changed metadata by `updateMetadata`, so we only need to read existing metadata + // when we switch to a different function. + if (functionChanged) { + const pos = nodeMeta.get('position') ?? { x: 0, y: 0 } + newNode.position = new Vec2(pos.x, pos.y) + newNode.vis = nodeMeta.get('visualization') + } this.nodeIdToNode.set(nodeId, newNode) } else { - node.pattern = newNode.pattern - if (node.outerExprId !== newNode.outerExprId) { - node.outerExprId = newNode.outerExprId - } - node.rootSpan = newNode.rootSpan - } - if (nodeMeta) { - this.assignUpdatedMetadata(node ?? newNode, nodeMeta) + const differentOrDirty = (a: Ast.Ast | undefined, b: Ast.Ast | undefined) => + a?.id !== b?.id || (a && subtreeDirty(a.id)) + if (differentOrDirty(node.pattern, newNode.pattern)) node.pattern = newNode.pattern + if (node.outerExprId !== newNode.outerExprId) node.outerExprId = newNode.outerExprId + if (differentOrDirty(node.rootSpan, newNode.rootSpan)) node.rootSpan = newNode.rootSpan } } - for (const nodeId of this.nodeIdToNode.keys()) { if (!currentNodeIds.has(nodeId)) { this.nodeIdToNode.delete(nodeId) @@ -377,13 +384,15 @@ export class GraphDb { updateMap(this.idFromExternalMap, idFromExternalNew) } - assignUpdatedMetadata(node: Node, meta: NodeMetadata) { - const newPosition = new Vec2(meta.x, -meta.y) - if (!node.position.equals(newPosition)) { - node.position = newPosition - } - if (!visMetadataEquals(node.vis, meta.vis)) { - node.vis = meta.vis + updateMetadata(id: Ast.AstId, changes: NodeMetadata) { + const node = this.nodeIdToNode.get(id as NodeId) + if (!node) return + const newPos = changes.get('position') + const newPosVec = newPos && new Vec2(newPos.x, newPos.y) + if (newPosVec && !newPosVec.equals(node.position)) node.position = newPosVec + if (changes.has('visualization')) { + const newVis = changes.get('visualization') + if (!visMetadataEquals(newVis, node.vis)) node.vis = newVis } } diff --git a/app/gui2/src/stores/graph/imports.ts b/app/gui2/src/stores/graph/imports.ts index b2a27b4231..1fa8a4031d 100644 --- a/app/gui2/src/stores/graph/imports.ts +++ b/app/gui2/src/stores/graph/imports.ts @@ -329,6 +329,9 @@ export function filterOutRedundantImports( if (import.meta.vitest) { const { test, expect } = import.meta.vitest + const { initializeFFI } = await import('shared/ast/ffi') + + await initializeFFI() test.each([ { diff --git a/app/gui2/src/stores/graph/index.ts b/app/gui2/src/stores/graph/index.ts index 57eb1e01cc..8ab8e878f2 100644 --- a/app/gui2/src/stores/graph/index.ts +++ b/app/gui2/src/stores/graph/index.ts @@ -1,7 +1,7 @@ import { nonDictatedPlacement } from '@/components/ComponentBrowser/placement' import type { PortId } from '@/providers/portInfo' import type { WidgetUpdate } from '@/providers/widgetRegistry' -import { asNodeId, GraphDb, type NodeId } from '@/stores/graph/graphDatabase' +import { GraphDb, type NodeId } from '@/stores/graph/graphDatabase' import { addImports, filterOutRedundantImports, @@ -11,15 +11,9 @@ import { import { useProjectStore } from '@/stores/project' import { useSuggestionDbStore } from '@/stores/suggestionDatabase' import { assert, bail } from '@/util/assert' -import { Ast, RawAst } from '@/util/ast' -import { - isIdentifier, - MutableModule, - ReactiveModule, - type AstId, - type Module, -} from '@/util/ast/abstract' -import { useObserveYjs } from '@/util/crdt' +import { Ast, RawAst, visitRecursive } from '@/util/ast' +import type { AstId, Module, NodeMetadata, NodeMetadataFields } from '@/util/ast/abstract' +import { MutableModule, ReactiveModule, isIdentifier } from '@/util/ast/abstract' import { partition } from '@/util/data/array' import type { Opt } from '@/util/data/opt' import { Rect } from '@/util/data/rect' @@ -28,21 +22,18 @@ import { map, set } from 'lib0' import { defineStore } from 'pinia' import type { ExpressionUpdate, StackItem } from 'shared/languageServerTypes' import { - IdMap, + sourceRangeKey, visMetadataEquals, - type ExternalId, - type NodeMetadata, type SourceRange, + type SourceRangeKey, type VisualizationIdentifier, type VisualizationMetadata, } from 'shared/yjsModel' -import { computed, markRaw, reactive, ref, toRef, watch, type Ref, type ShallowRef } from 'vue' +import { computed, markRaw, reactive, ref, toRef, watch, type ShallowRef } from 'vue' import * as Y from 'yjs' export { type Node, type NodeId } from '@/stores/graph/graphDatabase' -const DEBUG = false - export interface NodeEditInfo { id: NodeId initialCursorPos: number @@ -62,39 +53,11 @@ export const useGraphStore = defineStore('graph', () => { proj.setObservedFileName('Main.enso') - const data = computed(() => proj.module?.doc.data) - const metadata = computed(() => proj.module?.doc.metadata) + const syncModule = computed(() => proj.module && new MutableModule(proj.module.doc.ydoc)) - const moduleCode = ref(proj.module?.doc.getCode()) - // We need casting here, as type changes in Ref when class has private fields. - // see https://github.com/vuejs/core/issues/2557 - const idMap = ref(proj.module?.doc.getIdMap()) as Ref - const syncModule = MutableModule.Transient() - const astModule = new ReactiveModule(syncModule) - const moduleRoot = computed(() => astModule.root()?.id) - let moduleDirty = false const nodeRects = reactive(new Map()) const vizRects = reactive(new Map()) - const topLevel = computed(() => { - // The top level of the module is always a block. - const root = moduleRoot.value - const topLevel = root != null ? astModule.get(root) : null - if (topLevel != null && !(topLevel instanceof Ast.BodyBlock)) { - return null - } else { - return topLevel - } - }) - - // Initialize text and idmap once module is loaded (data != null) - watch(data, () => { - if (!moduleCode.value) { - moduleCode.value = proj.module?.doc.getCode() - idMap.value = proj.module?.doc.getIdMap() - } - }) - const db = new GraphDb( suggestionDb.entries, toRef(suggestionDb, 'groups'), @@ -107,82 +70,91 @@ export const useGraphStore = defineStore('graph', () => { const unconnectedEdge = ref() - useObserveYjs(data, (event) => { - moduleDirty = false - if (!event.changes.keys.size) return - const code = proj.module?.doc.getCode() - if (code) moduleCode.value = code - const ids = proj.module?.doc.getIdMap() - if (ids) idMap.value = ids + const moduleCode = ref() + const moduleData = ref<{ + getSpan: (id: Ast.AstId) => SourceRange | undefined + toRaw: (id: Ast.AstId) => RawAst.Tree.Tree | undefined + }>() + const astModule = ref(new Ast.EmptyModule()) + const moduleRoot = ref() + const topLevel = ref() + + watch(syncModule, (syncModule) => { + if (!syncModule) return + const _astModule = new ReactiveModule(syncModule, [handleModuleUpdate]) }) - let prevCode: string | undefined = undefined - const moduleData: { - getSpan?: (id: Ast.AstId) => SourceRange | undefined - toRaw?: Map - } = {} - watch([moduleCode, idMap], ([code, ids]) => { - if (!code || !ids) return - if (DEBUG && code === prevCode) console.info(`moduleData: Code is unchanged.`) - const edit = syncModule.edit() - const { - root, - idMap: parsedIds, - getSpan, - toRaw, - idMapUpdates, - } = Ast.parseExtended(code, ids, edit) - Y.applyUpdateV2(syncModule.ydoc, Y.encodeStateAsUpdateV2(root.module.ydoc)) - db.updateExternalIds(root) - moduleData.toRaw = toRaw - moduleData.getSpan = getSpan - prevCode = code - proj.module!.transact(() => { - if (idMapUpdates) proj.module!.doc.setIdMap(parsedIds) - updateState() - }) - }) + function handleModuleUpdate( + module: Module, + dirtyNodes: Iterable, + metadataUpdates: { id: AstId; changes: Map }[], + ) { + const moduleChanged = astModule.value !== reactive(module) + if (moduleChanged) { + if (astModule.value instanceof ReactiveModule) astModule.value.disconnect() + astModule.value = module + } + const root = module.root() + if (!root) return + moduleRoot.value = root + if (root instanceof Ast.BodyBlock) topLevel.value = root + // We can cast maps of unknown metadata fields to `NodeMetadata` because all `NodeMetadata` fields are optional. + const nodeMetadataUpdates = metadataUpdates as any as { id: AstId; changes: NodeMetadata }[] + const dirtyNodeSet = new Set(dirtyNodes) + if (moduleChanged || dirtyNodeSet.size !== 0) { + const { code, info } = Ast.print(root) + moduleCode.value = code + db.updateExternalIds(root) + const getSpan = Ast.spanMapToSpanGetter(info) + const toRawMap = new Map() + visitRecursive(Ast.parseEnso(code), (node) => { + if (node.type === RawAst.Tree.Type.Function) { + const start = node.whitespaceStartInCodeParsed + node.whitespaceLengthInCodeParsed + const end = start + node.childrenLengthInCodeParsed + toRawMap.set(sourceRangeKey([start, end]), node) + } + return true + }) + const toRaw = (id: Ast.AstId) => { + const span = getSpan(id) + if (!span) return + return toRawMap.get(sourceRangeKey(span)) + } + moduleData.value = { toRaw, getSpan } + updateState(dirtyNodeSet) + } + if (nodeMetadataUpdates.length !== 0) { + for (const { id, changes } of nodeMetadataUpdates) db.updateMetadata(id, changes) + } + } - function updateState() { - if (!moduleData.toRaw || !moduleData.getSpan) return + function updateState(dirtyNodes?: Set) { + if (!moduleData.value) return const module = proj.module if (!module) return - const meta = module.doc.metadata const textContentLocal = moduleCode.value if (!textContentLocal) return - methodAst.value = methodAstInModule(astModule) + methodAst.value = methodAstInModule(astModule.value) if (methodAst.value) { - const rawFunc = moduleData.toRaw.get(methodAst.value.id) + const rawFunc = moduleData.value.toRaw(methodAst.value.id) assert(rawFunc?.type === RawAst.Tree.Type.Function) currentNodeIds.value = db.readFunctionAst( methodAst.value, rawFunc, textContentLocal, - (id) => meta.get(id), - moduleData.getSpan, + moduleData.value.getSpan, + dirtyNodes ?? new Set(), ) } } function methodAstInModule(mod: Module) { const topLevel = mod.root() + if (!topLevel) return assert(topLevel instanceof Ast.BodyBlock) return getExecutedMethodAst(topLevel, proj.executionContext.getStackTop(), db) } - useObserveYjs(metadata, (event) => { - const meta = event.target - for (const [key, op] of event.changes.keys) { - if (op.action === 'update' || op.action === 'add') { - const externalId = key as ExternalId - const data = meta.get(externalId) - const id = db.idFromExternal(externalId) - const node = id && db.nodeIdToNode.get(asNodeId(id)) - if (data && node) db.assignUpdatedMetadata(node, data) - } - } - }) - function generateUniqueIdent() { for (;;) { const ident = randomIdent() @@ -229,20 +201,13 @@ export const useGraphStore = defineStore('graph', () => { function createNode( position: Vec2, expression: string, - metadata: NodeMetadata | undefined = undefined, + metadata: NodeMetadataFields = {}, withImports: RequiredImport[] | undefined = undefined, ): Opt { const mod = proj.module if (!mod) return - const meta = metadata ?? { - x: position.x, - y: -position.y, - vis: null, - } - meta.x = position.x - meta.y = -position.y const ident = generateUniqueIdent() - const edit = astModule.edit() + const edit = astModule.value.edit() if (withImports) addMissingImports(edit, withImports) const currentFunc = 'main' const method = Ast.findModuleMethod(topLevel.value!, currentFunc) @@ -252,14 +217,16 @@ export const useGraphStore = defineStore('graph', () => { } const functionBlock = edit.getVersion(method).bodyAsBlock() const rhs = Ast.parse(expression, edit) + metadata.position = { x: position.x, y: position.y } + rhs.setNodeMetadata(metadata) const assignment = Ast.Assignment.new(edit, ident, rhs) functionBlock.push(assignment) - commitEdit(edit, new Map([[rhs.id, meta]])) + commitEdit(edit) } function addMissingImports(edit: MutableModule, newImports: RequiredImport[]) { if (!newImports.length) return - const topLevel = edit.get(moduleRoot.value) + const topLevel = edit.getVersion(moduleRoot.value!) if (!(topLevel instanceof Ast.MutableBodyBlock)) { console.error(`BUG: Cannot add required imports: No BodyBlock module root.`) return @@ -271,24 +238,23 @@ export const useGraphStore = defineStore('graph', () => { } function deleteNodes(ids: NodeId[]) { - const edit = astModule.edit() - for (const id of ids) { - const node = db.nodeIdToNode.get(id) - if (!node) return - proj.module?.doc.metadata.delete(node.rootSpan.externalId) - const outerExpr = edit.get(node.outerExprId) - if (outerExpr) Ast.deleteFromParentBlock(outerExpr) - nodeRects.delete(id) - } - commitEdit(edit) + commitDirect((edit) => { + for (const id of ids) { + const node = db.nodeIdToNode.get(id) + if (!node) return + const outerExpr = edit.get(node.outerExprId) + if (outerExpr) Ast.deleteFromParentBlock(outerExpr) + nodeRects.delete(id) + } + }, true) } function setNodeContent(id: NodeId, content: string) { const node = db.nodeIdToNode.get(id) if (!node) return - const edit = astModule.edit() - edit.getVersion(node.rootSpan).replaceValue(Ast.parse(content, edit)) - commitEdit(edit) + commitDirect((edit) => { + edit.getVersion(node.rootSpan).replaceValue(Ast.parse(content, edit)) + }) } function transact(fn: () => void) { @@ -300,56 +266,56 @@ export const useGraphStore = defineStore('graph', () => { } function setNodePosition(nodeId: NodeId, position: Vec2) { - const externalId = db.idToExternal(nodeId) - if (!externalId) { - if (DEBUG) console.warn(`setNodePosition: Node not found.`) - return + const nodeAst = astModule.value?.get(nodeId) + if (!nodeAst) return + const oldPos = nodeAst.nodeMetadata.get('position') + if (oldPos?.x !== position.x || oldPos?.y !== position.y) { + commitDirect((edit) => { + edit + .getVersion(nodeAst) + .mutableNodeMetadata() + .set('position', { x: position.x, y: position.y }) + }, true) } - proj.module?.updateNodeMetadata(externalId, { x: position.x, y: -position.y }) } function normalizeVisMetadata( id: Opt, visible: boolean | undefined, - ): VisualizationMetadata | null { + ): VisualizationMetadata | undefined { const vis: VisualizationMetadata = { identifier: id ?? null, visible: visible ?? false } - if (visMetadataEquals(vis, { identifier: null, visible: false })) return null + if (visMetadataEquals(vis, { identifier: null, visible: false })) return undefined else return vis } function setNodeVisualizationId(nodeId: NodeId, vis: Opt) { - const node = db.nodeIdToNode.get(nodeId) - if (!node) return - const externalId = db.idToExternal(nodeId) - if (!externalId) { - if (DEBUG) console.warn(`setNodeVisualizationId: Node not found.`) - return - } - proj.module?.updateNodeMetadata(externalId, { - vis: normalizeVisMetadata(vis, node.vis?.visible), - }) + const nodeAst = astModule.value?.get(nodeId) + if (!nodeAst) return + commitDirect((edit) => { + const metadata = edit.getVersion(nodeAst).mutableNodeMetadata() + metadata.set( + 'visualization', + normalizeVisMetadata(vis, metadata.get('visualization')?.visible), + ) + }, true) } function setNodeVisualizationVisible(nodeId: NodeId, visible: boolean) { - const node = db.nodeIdToNode.get(nodeId) - if (!node) return - const externalId = db.idToExternal(nodeId) - if (!externalId) { - if (DEBUG) console.warn(`setNodeVisualizationId: Node not found.`) - return - } - proj.module?.updateNodeMetadata(externalId, { - vis: normalizeVisMetadata(node.vis?.identifier, visible), - }) + const nodeAst = astModule.value?.get(nodeId) + if (!nodeAst) return + commitDirect((edit) => { + const metadata = edit.getVersion(nodeAst).mutableNodeMetadata() + metadata.set( + 'visualization', + normalizeVisMetadata(metadata.get('visualization')?.identifier, visible), + ) + }, true) } function updateNodeRect(nodeId: NodeId, rect: Rect) { - const externalId = db.idToExternal(nodeId) - if (!externalId) { - if (DEBUG) console.warn(`updateNodeRect: Node not found.`) - return - } - if (rect.pos.equals(Vec2.Zero) && !metadata.value?.has(externalId)) { + const nodeAst = astModule.value?.get(nodeId) + if (!nodeAst) return + if (rect.pos.equals(Vec2.Zero) && !nodeAst.nodeMetadata.get('position')) { const { position } = nonDictatedPlacement(rect.size, { nodeRects: [...nodeRects.entries()] .filter(([id]) => db.nodeIdToNode.get(id)) @@ -359,12 +325,12 @@ export const useGraphStore = defineStore('graph', () => { screenBounds: Rect.Zero, mousePosition: Vec2.Zero, }) - const node = db.nodeIdToNode.get(nodeId) - metadata.value?.set(externalId, { - x: position.x, - y: -position.y, - vis: node?.vis ?? null, - }) + commitDirect((edit) => { + edit + .getVersion(nodeAst) + .mutableNodeMetadata() + .set('position', { x: position.x, y: position.y }) + }, true) nodeRects.set(nodeId, new Rect(position, rect.size)) } else { nodeRects.set(nodeId, rect) @@ -435,32 +401,40 @@ export const useGraphStore = defineStore('graph', () => { return true } - function commitEdit(edit: MutableModule, metadataUpdates?: Map>) { + /** Apply the given `edit` to the state. + * + * @param skipTreeRepair - If the edit is known not to require any parenthesis insertion, this may be set to `true` + * for better performance. + */ + function commitEdit(edit: MutableModule, skipTreeRepair?: boolean) { const root = edit.root() - if (!root) { - console.error(`BUG: Cannot commit edit: No module root.`) + if (!(root instanceof Ast.BodyBlock)) { + console.error(`BUG: Cannot commit edit: No module root block.`) return } - const printed = Ast.print(root) const module_ = proj.module if (!module_) return - if (moduleDirty) { - console.warn( - `An edit has been committed before a previous edit has been observed. The new edit will supersede the previous edit.`, - ) - } - moduleDirty = true - module_.transact(() => { - const idMap = Ast.spanMapToIdMap(printed.info) - module_.doc.setIdMap(idMap) - module_.doc.setCode(printed.code) - if (DEBUG) console.info(`commitEdit`, idMap, printed.code) - if (metadataUpdates) { - for (const [id, meta] of metadataUpdates) { - module_.updateNodeMetadata(edit.checkedGet(id).externalId, meta) - } + if (!skipTreeRepair) Ast.repair(root, edit) + Y.applyUpdateV2(syncModule.value!.ydoc, Y.encodeStateAsUpdateV2(edit.ydoc), 'local') + } + + /** Run the given callback with direct access to the document module. Any edits to the module will be committed + * unconditionally; use with caution to avoid committing partial edits. + * + * @param skipTreeRepair - If the edit is known not to require any parenthesis insertion, this may be set to `true` + * for better performance. + */ + function commitDirect(f: (edit: MutableModule) => void, skipTreeRepair?: boolean) { + const edit = syncModule.value + assert(edit != null) + edit.ydoc.transact(() => { + f(edit) + if (!skipTreeRepair) { + const root = edit.root() + assert(root instanceof Ast.BodyBlock) + Ast.repair(root, edit) } - }) + }, 'local') } function mockExpressionUpdate( @@ -496,12 +470,6 @@ export const useGraphStore = defineStore('graph', () => { proj.computedValueRegistry.processUpdates([update_]) } - function editScope(scope: (edit: MutableModule) => Map> | void) { - const edit = astModule.edit() - const metadataUpdates = scope(edit) - commitEdit(edit, metadataUpdates ?? undefined) - } - /** * Reorders nodes so the `targetNodeId` node is placed after `sourceNodeId`. Does nothing if the * relative order is already correct. @@ -593,7 +561,7 @@ export const useGraphStore = defineStore('graph', () => { setEditedNode, updateState, commitEdit, - editScope, + commitDirect, addMissingImports, } }) diff --git a/app/gui2/src/stores/suggestionDatabase/__tests__/lsUpdate.test.ts b/app/gui2/src/stores/suggestionDatabase/__tests__/lsUpdate.test.ts index df94be31e6..f1c2a66c21 100644 --- a/app/gui2/src/stores/suggestionDatabase/__tests__/lsUpdate.test.ts +++ b/app/gui2/src/stores/suggestionDatabase/__tests__/lsUpdate.test.ts @@ -4,9 +4,12 @@ import { applyUpdates } from '@/stores/suggestionDatabase/lsUpdate' import { unwrap } from '@/util/data/result' import { parseDocs } from '@/util/docParser' import { tryIdentifier, tryQualifiedName, type QualifiedName } from '@/util/qualifiedName' +import { initializeFFI } from 'shared/ast/ffi' import * as lsTypes from 'shared/languageServerTypes/suggestions' import { expect, test } from 'vitest' +await initializeFFI() + test('Adding suggestion database entries', () => { const test = new Fixture() const db = new SuggestionDb() diff --git a/app/gui2/src/stores/suggestionDatabase/documentation.ts b/app/gui2/src/stores/suggestionDatabase/documentation.ts index 4fcc28a5ec..f859129818 100644 --- a/app/gui2/src/stores/suggestionDatabase/documentation.ts +++ b/app/gui2/src/stores/suggestionDatabase/documentation.ts @@ -65,6 +65,8 @@ export function documentationData( if (import.meta.vitest) { const { test, expect } = import.meta.vitest + const { initializeFFI } = await import('shared/ast/ffi') + await initializeFFI() test.each([ ['ALIAS Bar', 'Bar'], diff --git a/app/gui2/src/util/assert.ts b/app/gui2/src/util/assert.ts index 288a559945..a223c842f9 100644 --- a/app/gui2/src/util/assert.ts +++ b/app/gui2/src/util/assert.ts @@ -1,69 +1 @@ -export function assertNever(x: never): never { - bail('Unexpected object: ' + JSON.stringify(x)) -} - -export function assert(condition: boolean, message?: string): asserts condition { - if (!condition) bail(message ? `Assertion failed: ${message}` : 'Assertion failed') -} - -/** - * Checks if the given iterable has the specified length and throws an assertion error - * if the lengths do not match. - * - * @param iterable The iterable to check. - * @param length The expected length of the iterable. - * @param message Optional message for the assertion error. - * @return void - * @throws Error Will throw an error if the length does not match. - * - * The first five elements of the iterable will be displayed in the error message - * if the assertion fails. If the iterable contains more than five elements, - * the remaining elements will be represented as '...'. - */ -export function assertLength(iterable: Iterable, length: number, message?: string): void { - const convertedArray = Array.from(iterable) - const messagePrefix = message ? message + ' ' : '' - const elementRepresentation = - convertedArray.length > 5 - ? `${convertedArray.slice(0, 5).join(', ')},...` - : convertedArray.join(', ') - assert( - convertedArray.length === length, - `${messagePrefix}Expected iterable of length ${length}, got length ${convertedArray.length}. Elements: [${elementRepresentation}]`, - ) -} - -export function assertEmpty(iterable: Iterable, message?: string): void { - assertLength(iterable, 0, message) -} - -export function assertEqual(actual: T, expected: T, message?: string) { - const messagePrefix = message ? message + ' ' : '' - assert(actual === expected, `${messagePrefix}Expected ${expected}, got ${actual}.`) -} - -export function assertNotEqual(actual: T, unexpected: T, message?: string) { - const messagePrefix = message ? message + ' ' : '' - assert(actual !== unexpected, `${messagePrefix}Expected not ${unexpected}, got ${actual}.`) -} - -export function assertDefined(x: T | undefined, message?: string): asserts x is T { - const messagePrefix = message ? message + ' ' : '' - assert(x !== undefined, `${messagePrefix}Expected value to be defined.`) -} - -export function assertUnreachable(): never { - bail('Unreachable code') -} - -/** - * Throw an error with provided message. - * - * It is convenient to use at the end of a nullable chain: - * ```ts - * const x = foo?.bar.baz?.() ?? bail('Expected foo.bar.baz to exist') - * ``` - */ -export function bail(message: string): never { - throw new Error(message) -} +export * from 'shared/util/assert' diff --git a/app/gui2/src/util/ast/__tests__/abstract.test.ts b/app/gui2/src/util/ast/__tests__/abstract.test.ts index d58ec82dd4..7eaec91867 100644 --- a/app/gui2/src/util/ast/__tests__/abstract.test.ts +++ b/app/gui2/src/util/ast/__tests__/abstract.test.ts @@ -1,8 +1,11 @@ import { assert } from '@/util/assert' import { Ast } from '@/util/ast' +import { initializeFFI } from 'shared/ast/ffi' import { expect, test } from 'vitest' import { MutableModule, escape, unescape, type Identifier } from '../abstract' +await initializeFFI() + //const disabledCases = [ // ' a', // 'a ', @@ -356,6 +359,9 @@ test.each(cases)('parse/print round trip: %s', (code) => { const idMap = Ast.spanMapToIdMap(printed.info) idMap.validate() + // Parsed tree shouldn't need any repair. + expect(Ast.repair(root).fixes).toBe(undefined) + // Re-parse. const { root: root1, spans: spans1 } = Ast.parseBlockWithSpans(printed.code) Ast.setExternalIds(root1.module, spans1, idMap) @@ -474,6 +480,24 @@ test('Splice', () => { expect(spliced.code()).toBe('foo') }) +test('Construct app', () => { + const edit = MutableModule.Transient() + const app = Ast.App.new( + edit, + Ast.Ident.new(edit, 'func' as Identifier), + undefined, + Ast.Ident.new(edit, 'arg' as Identifier), + ) + expect(app.code()).toBe('func arg') + const namedApp = Ast.App.new( + edit, + Ast.Ident.new(edit, 'func' as Identifier), + 'argName' as Identifier, + Ast.Ident.new(edit, 'arg' as Identifier), + ) + expect(namedApp.code()).toBe('func argName=arg') +}) + test.each([ ['Hello, World!', 'Hello, World!'], ['Hello\t\tWorld!', 'Hello\\t\\tWorld!'], @@ -490,3 +514,54 @@ test.each([ expect(escaped).toBe(expectedEscaped) expect(unescape(escaped)).toBe(original) }) + +test('Automatic parenthesis', () => { + const block = Ast.parseBlock('main = func arg1 arg2') + let arg1: Ast.MutableAst | undefined + block.visitRecursiveAst((ast) => { + if (ast instanceof Ast.MutableIdent && ast.code() === 'arg1') { + assert(!arg1) + arg1 = ast + } + }) + assert(arg1 != null) + arg1.replace(Ast.parse('innerfunc innerarg', block.module)) + const correctCode = 'main = func (innerfunc innerarg) arg2' + // This assertion will fail when smart printing handles this case. + // At that point we should test tree repair separately. + assert(block.code() !== correctCode) + Ast.repair(block, block.module) + expect(block.code()).toBe(correctCode) +}) + +test('Resync', () => { + const root = Ast.parseBlock('main = func arg1 arg2') + const module = root.module + module.replaceRoot(root) + const arg1 = 'arg1' as Identifier + const func = Ast.Function.fromStatements( + module, + 'func' as Identifier, + [arg1], + [Ast.Ident.new(module, arg1)], + ) + // Add a trailing line to the function's block. This is syntactically non-canonical; it should belong to the parent. + func.bodyAsBlock().insert(1, undefined) + expect(func.bodyAsBlock().lines.length).toBe(2) + root.insert(0, func) + const codeBeforeRepair = root.code() + const rootExternalIdBeforeRepair = root.externalId + const funcExternalIdBeforeRepair = func.externalId + Ast.repair(root, module) + const repairedRoot = module.root() + assert(repairedRoot instanceof Ast.BodyBlock) + const repairedFunc = repairedRoot.statements().next().value + assert(repairedFunc instanceof Ast.Function) + assert(repairedFunc.body instanceof Ast.BodyBlock) + // The function's body has been corrected. + expect(repairedFunc.body.lines.length).toBe(1) + expect(repairedRoot.code()).toBe(codeBeforeRepair) + expect(repairedRoot.externalId).toBe(rootExternalIdBeforeRepair) + // The resync operation loses metadata within the non-canonical subtree. + expect(repairedFunc.body?.externalId).not.toBe(funcExternalIdBeforeRepair) +}) diff --git a/app/gui2/src/util/ast/__tests__/abstractFileIo.test.ts b/app/gui2/src/util/ast/__tests__/abstractFileIo.test.ts index 5fd48ee9fa..70c9336903 100644 --- a/app/gui2/src/util/ast/__tests__/abstractFileIo.test.ts +++ b/app/gui2/src/util/ast/__tests__/abstractFileIo.test.ts @@ -1,5 +1,6 @@ import { Ast } from '@/util/ast' import * as fs from 'fs' +import { initializeFFI } from 'shared/ast/ffi' import { splitFileContents } from 'shared/ensoFile' import { expect, test } from 'vitest' @@ -7,6 +8,8 @@ import { expect, test } from 'vitest' // file format handling to shared and create a test utility for easy *.enso file fixture loading. import { deserializeIdMap } from '../../../../ydoc-server/serialization' +await initializeFFI() + test('full file IdMap round trip', () => { const content = fs.readFileSync(__dirname + '/fixtures/stargazers.enso').toString() const { code, idMapJson, metadataJson: _ } = splitFileContents(content) @@ -20,4 +23,7 @@ test('full file IdMap round trip', () => { expect(Ast.tokenTreeWithIds(ast2), 'Print/parse preserves IDs').toStrictEqual(astTT) expect(Ast.tokenTreeWithIds(ast_), 'All node IDs come from IdMap').toStrictEqual(astTT) expect([...idMap.entries()].sort()).toStrictEqual([...idMapOriginal.entries()].sort()) + + // Parsed tree shouldn't need any repair. + expect(Ast.repair(ast).fixes).toBe(undefined) }) diff --git a/app/gui2/src/util/ast/__tests__/ast.test.ts b/app/gui2/src/util/ast/__tests__/ast.test.ts index d956fce465..ab7d7c5f1a 100644 --- a/app/gui2/src/util/ast/__tests__/ast.test.ts +++ b/app/gui2/src/util/ast/__tests__/ast.test.ts @@ -1,4 +1,3 @@ -import { Token, Tree } from '@/generated/ast' import { astContainingChar, childrenAstNodes, @@ -10,9 +9,13 @@ import { readTokenSpan, walkRecursive, } from '@/util/ast' -import type { LazyObject } from '@/util/parserSupport' +import { initializeFFI } from 'shared/ast/ffi' +import { Token, Tree } from 'shared/ast/generated/ast' +import type { LazyObject } from 'shared/ast/parserSupport' import { assert, expect, test } from 'vitest' +await initializeFFI() + function validateSpans(obj: LazyObject, initialPos?: number): number { const state = { pos: initialPos ?? 0 } const visitor = (value: LazyObject) => { diff --git a/app/gui2/src/util/ast/__tests__/callTree.test.ts b/app/gui2/src/util/ast/__tests__/callTree.test.ts index 865dcf2ac8..baffad8c61 100644 --- a/app/gui2/src/util/ast/__tests__/callTree.test.ts +++ b/app/gui2/src/util/ast/__tests__/callTree.test.ts @@ -7,8 +7,11 @@ import { ArgumentPlaceholder, interpretCall, } from '@/util/callTree' +import { initializeFFI } from 'shared/ast/ffi' import { assert, expect, test } from 'vitest' +await initializeFFI() + const prefixFixture = { allowInfix: false, mockSuggestion: { diff --git a/app/gui2/src/util/ast/__tests__/match.test.ts b/app/gui2/src/util/ast/__tests__/match.test.ts index 755fefb959..3792b8a8cd 100644 --- a/app/gui2/src/util/ast/__tests__/match.test.ts +++ b/app/gui2/src/util/ast/__tests__/match.test.ts @@ -1,8 +1,11 @@ import { Ast } from '@/util/ast' import { Pattern } from '@/util/ast/match' +import { initializeFFI } from 'shared/ast/ffi' import { expect, test } from 'vitest' import { MutableModule } from '../abstract' +await initializeFFI() + test.each([ { target: 'a.b', pattern: '__', extracted: ['a.b'] }, { target: 'a.b', pattern: 'a.__', extracted: ['b'] }, diff --git a/app/gui2/src/util/ast/__tests__/prefixes.test.ts b/app/gui2/src/util/ast/__tests__/prefixes.test.ts index c191b95647..ed7be7a530 100644 --- a/app/gui2/src/util/ast/__tests__/prefixes.test.ts +++ b/app/gui2/src/util/ast/__tests__/prefixes.test.ts @@ -1,7 +1,10 @@ import { Ast } from '@/util/ast/abstract' import { Prefixes } from '@/util/ast/prefixes' +import { initializeFFI } from 'shared/ast/ffi' import { expect, test } from 'vitest' +await initializeFFI() + test.each([ { prefixes: { diff --git a/app/gui2/src/util/ast/__tests__/reactive.test.ts b/app/gui2/src/util/ast/__tests__/reactive.test.ts index ca3df0f58a..89e52707ee 100644 --- a/app/gui2/src/util/ast/__tests__/reactive.test.ts +++ b/app/gui2/src/util/ast/__tests__/reactive.test.ts @@ -1,17 +1,37 @@ import { Ast } from '@/util/ast' -import { MutableModule, ReactiveModule } from '@/util/ast/abstract' +import { MutableModule, newExternalId, ReactiveModule } from '@/util/ast/abstract' +import { initializeFFI } from 'shared/ast/ffi' import { expect, test } from 'vitest' import * as Y from 'yjs' -test('Reactive module observes Y.Js changes', () => { +await initializeFFI() + +test('Reactive module observes tree changes', () => { const syncModule = MutableModule.Transient() const reactiveModule = new ReactiveModule(syncModule) const { root } = Ast.parseExtended('main =\n 23') Y.applyUpdateV2(syncModule.ydoc, Y.encodeStateAsUpdateV2(root.module.ydoc)) expect(reactiveModule.root()?.code()).toBe('main =\n 23') + expect(reactiveModule.root()?.externalId).toBe(root.externalId) const edit = syncModule.edit() const { root: root2 } = Ast.parseExtended('main = 42', undefined, edit) Y.applyUpdateV2(syncModule.ydoc, Y.encodeStateAsUpdateV2(root2.module.ydoc)) expect(reactiveModule.root()?.code()).toBe('main = 42') + expect(reactiveModule.root()?.externalId).toBe(root2.externalId) +}) + +test('Reactive module observes metadata changes', () => { + const syncModule = MutableModule.Transient() + const reactiveModule = new ReactiveModule(syncModule) + const { root } = Ast.parseExtended('main =\n 23') + Y.applyUpdateV2(syncModule.ydoc, Y.encodeStateAsUpdateV2(root.module.ydoc)) + expect(reactiveModule.root()?.code()).toBe('main =\n 23') + expect(reactiveModule.root()?.externalId).toBe(root.externalId) + + const edit = syncModule.edit() + const newId = newExternalId() + edit.getVersion(root).setExternalId(newId) + Y.applyUpdateV2(syncModule.ydoc, Y.encodeStateAsUpdateV2(edit.ydoc)) + expect(reactiveModule.root()?.externalId).toBe(newId) }) diff --git a/app/gui2/src/util/ast/abstract.ts b/app/gui2/src/util/ast/abstract.ts index 0677a22593..2af1b19140 100644 --- a/app/gui2/src/util/ast/abstract.ts +++ b/app/gui2/src/util/ast/abstract.ts @@ -1,2785 +1,36 @@ -import * as RawAst from '@/generated/ast' -import { assert, assertDefined, assertEqual, bail } from '@/util/assert' +import { assertDefined, bail } from '@/util/assert' import { parseEnso } from '@/util/ast' -import { Err, Ok, type Result } from '@/util/data/result' -import { is_ident_or_operator } from '@/util/ffi' -import type { LazyObject } from '@/util/parserSupport' import { swapKeysAndValues, unsafeEntries } from '@/util/record' -import * as map from 'lib0/map' -import * as random from 'lib0/random' -import { reactive } from 'vue' -import * as Y from 'yjs' +import type { + AstFields, + AstId, + FixedMapView, + Module, + ModuleUpdate, + MutableAst, + NodeKey, + Owned, + RootPointer, + SyncTokenId, + TokenId, + TokenKey, +} from 'shared/ast' import { - IdMap, - isUuid, - sourceRangeFromKey, - sourceRangeKey, - type ExternalId, - type SourceRange, - type SourceRangeKey, -} from '../../../shared/yjsModel' - -const DEBUG = false - -declare const brandOwned: unique symbol -/** Used to mark references required to be unique. - * - * Note that the typesystem cannot stop you from copying an `Owned`, - * but that is an easy mistake to see (because it occurs locally). - * - * We can at least require *obtaining* an `Owned`, - * which statically prevents the otherwise most likely usage errors when rearranging ASTs. - */ -export type Owned = T & { [brandOwned]: never } -function asOwned(t: T): Owned { - return t as Owned -} - -export function normalize(rootIn: Ast): Ast { - const printed = print(rootIn) - const idMap = spanMapToIdMap(printed.info) - const module = MutableModule.Transient() - const tree = parseEnso(printed.code) - const { root: parsed, spans } = abstract(module, tree, printed.code) - module.replaceRoot(parsed) - setExternalIds(module, spans, idMap) - return parsed -} - -export type NodeChild = { whitespace?: string | undefined; node: T } - -declare const brandTokenId: unique symbol -declare const brandAstId: unique symbol -export type TokenId = ExternalId & { [brandTokenId]: never } -export type AstId = string & { [brandAstId]: never } - -function newExternalId(): ExternalId { - return random.uuidv4() as ExternalId -} -function newTokenId(): TokenId { - return newExternalId() as TokenId -} -function newAstId(type: string): AstId { - return `ast:${type}#${random.uint53()}` as AstId -} -/** Checks whether the input looks like an AstId. */ -export function isAstId(value: string): value is AstId { - return /ast:[A-Za-z]*#[0-9]*/.test(value) -} -export const ROOT_ID = `Root` as AstId - -/** @internal */ -export interface SyncTokenId { - readonly id: TokenId - code_: string - tokenType_: RawAst.Token.Type | undefined -} -export class Token implements SyncTokenId { - readonly id: TokenId - code_: string - tokenType_: RawAst.Token.Type | undefined - - private constructor(code: string, type: RawAst.Token.Type | undefined, id: TokenId) { - this.id = id - this.code_ = code - this.tokenType_ = type - } - - get externalId(): TokenId { - return this.id - } - - static new(code: string, type?: RawAst.Token.Type) { - return new this(code, type, newTokenId()) - } - - static withId(code: string, type: RawAst.Token.Type | undefined, id: TokenId) { - assert(isUuid(id)) - return new this(code, type, id) - } - - code(): string { - return this.code_ - } - - typeName(): string { - if (this.tokenType_) return RawAst.Token.typeNames[this.tokenType_]! - else return 'Raw' - } -} -// We haven't had much need to distinguish token types, but it's useful to know that an identifier token's code is a -// valid string for an identifier. -export interface IdentifierOrOperatorIdentifierToken extends Token { - code(): IdentifierOrOperatorIdentifier -} -export interface IdentifierToken extends Token { - code(): Identifier -} - -declare const qualifiedNameBrand: unique symbol -declare const identifierBrand: unique symbol -declare const operatorBrand: unique symbol - -/** A string representing a valid qualified name of our language. - * - * In our language, the segments are separated by `.`. All the segments except the last must be lexical identifiers. The - * last may be an identifier or a lexical operator. A single identifier is also a valid qualified name. - */ -export type QualifiedName = string & { [qualifiedNameBrand]: never } - -/** A string representing a lexical identifier. */ -export type Identifier = string & { [identifierBrand]: never; [qualifiedNameBrand]: never } - -/** A string representing a lexical operator. */ -export type Operator = string & { [operatorBrand]: never; [qualifiedNameBrand]: never } - -/** A string that can be parsed as an identifier in some contexts. - * - * If it is lexically an identifier (see `StrictIdentifier`), it can be used as identifier anywhere. - * - * If it is lexically an operator (see `Operator`), it takes the syntactic role of an identifier if it is the RHS of - * a `PropertyAccess`, or it is the name of a `Function` being defined within a type. In all other cases, it is not - * valid to use a lexical operator as an identifier (rather, it will usually parse as an `OprApp` or `UnaryOprApp`). - */ -export type IdentifierOrOperatorIdentifier = Identifier | Operator - -/** Returns true if `code` can be used as an identifier in some contexts. - * - * If it is lexically an identifier (see `isStrictIdentifier`), it can be used as identifier anywhere. - * - * If it is lexically an operator (see `isOperator`), it takes the syntactic role of an identifier if it is the RHS of - * a `PropertyAccess`, or it is the name of a `Function` being defined within a type. In all other cases, it is not - * valid to use a lexical operator as an identifier (rather, it will usually parse as an `OprApp` or `UnaryOprApp`). - */ -export function isIdentifierOrOperatorIdentifier( - code: string, -): code is IdentifierOrOperatorIdentifier { - return is_ident_or_operator(code) !== 0 -} - -/** Returns true if `code` is lexically an identifier. */ -export function isIdentifier(code: string): code is Identifier { - return is_ident_or_operator(code) === 1 -} - -/** Returns true if `code` is lexically an operator. */ -export function isOperator(code: string): code is Operator { - return is_ident_or_operator(code) === 2 -} - -function clone(value: T): T { - if (value instanceof Array) { - return Array.from(value, clone) as T - } - if (value && typeof value === 'object') { - return cloneObject(value) - } - return value -} -function cloneObject(object: T): T { - const mapEntry = ([name, value]: [string, unknown]) => [name, clone(value)] - const properties = Object.fromEntries(Object.entries(object).map(mapEntry)) - return Object.assign(Object.create(Object.getPrototypeOf(object)), properties) -} - -function spaced(node: T): NodeChild -function spaced(node: T | undefined): NodeChild | undefined -function spaced(node: T | undefined): NodeChild | undefined { - if (node === undefined) return node - return { whitespace: ' ', node } -} - -function unspaced(node: T): NodeChild -function unspaced(node: T | undefined): NodeChild | undefined -function unspaced(node: T | undefined): NodeChild | undefined { - if (node === undefined) return node - return { whitespace: '', node } -} - -function autospaced(node: T): NodeChild -function autospaced(node: T | undefined): NodeChild | undefined -function autospaced(node: T | undefined): NodeChild | undefined { - if (node === undefined) return node - return { node } -} - -function spacedIf(node: T, isSpaced: boolean): NodeChild { - return { whitespace: isSpaced ? ' ' : '', node } -} - -export interface Module { - edit(): MutableModule - root(): Ast | undefined - get(id: AstId): Ast | undefined - get(id: AstId | undefined): Ast | undefined - - ///////////////////////////////// - - checkedGet(id: AstId): Ast - checkedGet(id: AstId | undefined): Ast | undefined - getToken(token: SyncTokenId): Token - getToken(token: SyncTokenId | undefined): Token | undefined - getAny(node: AstId | SyncTokenId): Ast | Token - has(id: AstId): boolean - getSpan(id: AstId): SourceRange | undefined -} - -interface ModuleUpdate { - addNodes: AstId[] - deleteNodes: AstId[] - updateNodes: { id: AstId; fields: (readonly [string, unknown])[] }[] -} - -type YNode = FixedMap -type YNodes = Y.Map - -export class MutableModule implements Module { - private readonly nodes: YNodes - - get ydoc() { - const ydoc = this.nodes.doc - assert(ydoc != null) - return ydoc - } - - /** Return this module's copy of `ast`, if this module was created by cloning `ast`'s module. */ - getVersion(ast: T): Mutable { - const instance = this.checkedGet(ast.id) - return instance as Mutable - } - - edit(): MutableModule { - const state = Y.encodeStateAsUpdateV2(this.ydoc) - const doc = new Y.Doc() - Y.applyUpdateV2(doc, state) - return new MutableModule(doc) - } - - root(): MutableAst | undefined { - return this.rootPointer()?.expression - } - - replaceRoot(newRoot: Owned | undefined): Owned | undefined { - if (newRoot) { - const rootPointer = this.rootPointer() - if (rootPointer) { - return rootPointer.expression.replace(newRoot) - } else { - invalidFields(this, this.baseObject('Invalid', undefined, ROOT_ID), unspaced(newRoot)) - return undefined - } - } else { - const oldRoot = this.root() - if (!oldRoot) return - this.nodes.delete(ROOT_ID) - oldRoot.fields.set('parent', undefined) - return asOwned(oldRoot) - } - } - - /** Copy the given node into the module. */ - copy(ast: T): Owned> { - const id = newAstId(ast.typeName()) - const fields = ast.fields.clone() - this.nodes.set(id, fields as any) - fields.set('id', id) - fields.set('parent', undefined) - const ast_ = materializeMutable(this, fields) - ast_.importReferences(ast.module) - return ast_ as Owned> - } - - static Transient() { - return new this(new Y.Doc()) - } - - observe(observer: (update: ModuleUpdate) => void) { - this.nodes.observeDeep((events) => { - const addNodes = [] - const deleteNodes = [] - const updateNodes = [] - for (const event of events) { - if (event.target === this.nodes) { - for (const [key, change] of event.changes.keys) { - const id = key as AstId - switch (change.action) { - case 'add': - addNodes.push(id) - updateNodes.push({ id, fields: Array.from(this.nodes.get(id)!.entries()) }) - break - case 'update': - updateNodes.push({ id, fields: Array.from(this.nodes.get(id)!.entries()) }) - break - case 'delete': - deleteNodes.push(id) - break - } - } - } else { - assert(event.target.parent === this.nodes) - assert(event.target instanceof Y.Map) - const id = event.target.get('id') as AstId - const node = this.nodes.get(id) - assertDefined(node) - const fields: [string, unknown][] = [] - for (const [key, change] of event.changes.keys) { - switch (change.action) { - case 'add': - case 'update': { - assert((node as Y.Map).has(key as any)) - const value: unknown = node.get(key as any) - fields.push([key, value]) - break - } - case 'delete': - fields.push([key, undefined]) - break - } - } - updateNodes.push({ id, fields }) - } - } - observer({ addNodes, deleteNodes, updateNodes }) - }) - } - - clear() { - this.nodes.clear() - } - - checkedGet(id: AstId): Mutable - checkedGet(id: AstId | undefined): Mutable | undefined - checkedGet(id: AstId | undefined): Mutable | undefined { - if (!id) return undefined - const ast = this.get(id) - assert(ast !== undefined, 'id in module') - return ast - } - - get(id: AstId): Mutable | undefined - get(id: AstId | undefined): Mutable | undefined - get(id: AstId | undefined): Mutable | undefined { - if (!id) return undefined - const nodeData = this.nodes.get(id) - if (!nodeData) return undefined - const fields = nodeData as any - return materializeMutable(this, fields) - } - - replace(id: AstId, value: Owned): Owned | undefined { - return this.get(id)?.replace(value) - } - - replaceValue(id: AstId, value: Owned): Owned | undefined { - return this.get(id)?.replaceValue(value) - } - - take(id: AstId): Owned { - return this.replace(id, Wildcard.new(this)) || asOwned(this.checkedGet(id)) - } - - updateValue(id: AstId, f: (x: Owned) => Owned): T | undefined { - return this.get(id)?.updateValue(f) - } - - ///////////////////////////////////////////// - - getSpan(id: AstId) { - return undefined - } - - constructor(doc: Y.Doc) { - this.nodes = doc.getMap('nodes') - } - - private rootPointer(): MutableRootPointer | undefined { - const rootPointer = this.get(ROOT_ID) - if (rootPointer) return rootPointer as MutableRootPointer - } - - /** @internal */ - baseObject(type: string, externalId?: ExternalId, overrideId?: AstId): FixedMap { - const map = new Y.Map() - const map_ = map as unknown as FixedMap<{}> - const id = overrideId ?? newAstId(type) - const fields = setAll(map_, { - id, - externalId: externalId ?? newExternalId(), - type: type, - parent: undefined, - }) - this.nodes.set(id, fields) - return fields - } - - /** @internal */ - getToken(token: SyncTokenId): Token - getToken(token: SyncTokenId | undefined): Token | undefined - getToken(token: SyncTokenId | undefined): Token | undefined { - if (!token) return token - if (token instanceof Token) return token - return Token.withId(token.code_, token.tokenType_, token.id) - } - - getAny(node: AstId | SyncTokenId): MutableAst | Token { - return isTokenId(node) ? this.getToken(node) : this.checkedGet(node) - } - - /** @internal Copy a node into the module, if it is bound to a different module. */ - copyIfForeign(ast: Owned): Owned - copyIfForeign(ast: Owned | undefined): Owned | undefined { - if (!ast) return ast - if (ast.module === this) return ast - return this.copy(ast) as any - } - - /** @internal */ - delete(id: AstId) { - this.nodes.delete(id) - } - - /** @internal */ - has(id: AstId) { - return this.nodes.has(id) - } -} - -export type Mutable = T extends App - ? MutableApp - : T extends Assignment - ? MutableAssignment - : T extends BodyBlock - ? MutableBodyBlock - : T extends Documented - ? MutableDocumented - : T extends Function - ? MutableFunction - : T extends Generic - ? MutableGeneric - : T extends Group - ? MutableGroup - : T extends Ident - ? MutableIdent - : T extends Import - ? MutableImport - : T extends Invalid - ? MutableInvalid - : T extends NegationApp - ? MutableNegationApp - : T extends NumericLiteral - ? MutableNumericLiteral - : T extends OprApp - ? MutableOprApp - : T extends PropertyAccess - ? MutablePropertyAccess - : T extends TextLiteral - ? MutableTextLiteral - : T extends UnaryOprApp - ? MutableUnaryOprApp - : T extends Wildcard - ? MutableWildcard - : MutableAst - -function materializeMutable(module: MutableModule, fields: FixedMap): MutableAst { - const type = fields.get('type') - switch (type) { - case 'App': - return new MutableApp(module, fields) - case 'UnaryOprApp': - return new MutableUnaryOprApp(module, fields) - case 'NegationApp': - return new MutableNegationApp(module, fields) - case 'OprApp': - return new MutableOprApp(module, fields) - case 'PropertyAccess': - return new MutablePropertyAccess(module, fields) - case 'Generic': - return new MutableGeneric(module, fields) - case 'Import': - return new MutableImport(module, fields) - case 'TextLiteral': - return new MutableTextLiteral(module, fields) - case 'Documented': - return new MutableDocumented(module, fields) - case 'Invalid': - return new MutableInvalid(module, fields) - case 'Group': - return new MutableGroup(module, fields) - case 'NumericLiteral': - return new MutableNumericLiteral(module, fields) - case 'Function': - return new MutableFunction(module, fields) - case 'Assignment': - return new MutableAssignment(module, fields) - case 'BodyBlock': - return new MutableBodyBlock(module, fields) - case 'Ident': - return new MutableIdent(module, fields) - case 'Wildcard': - return new MutableWildcard(module, fields) - } - bail(`Invalid type: ${type}`) -} - -/** @internal */ -export type FixedMapView = { - get(key: Key): Fields[Key] - entries(): IterableIterator - clone(): FixedMap -} - -type FixedMap = FixedMapView & { - set(key: Key, value: Fields[Key]): void -} - -function getAll(map: FixedMapView): Fields { - return Object.fromEntries(map.entries()) as Fields -} - -/** Modifies the input `map`. Returns the same object with an extended type. */ -function setAll( - map: FixedMap, - fields: Fields2, -): FixedMap { - const map_ = map as FixedMap - for (const [k, v] of Object.entries(fields)) { - const k_ = k as string & (keyof Fields1 | keyof Fields2) - map_.set(k_, v) - } - return map_ -} - -type Removed = { node: Owned; placeholder: MutableWildcard | undefined } - -/** @internal */ -export type AstFields = { - id: AstId - externalId: ExternalId - type: string - parent: AstId | undefined -} -function parentId(ast: Ast): AstId | undefined { - return ast.fields.get('parent') -} - -/** @internal */ -export function isTokenId(t: SyncTokenId | AstId | Ast | Owned | Owned): t is SyncTokenId { - return typeof t === 'object' && !(t instanceof Ast) -} - -function isToken(t: unknown): t is Token { - return t instanceof Token -} - -export abstract class Ast { - readonly module: Module - /** @internal */ - readonly fields: FixedMapView - - get id(): AstId { - return this.fields.get('id') - } - - get externalId(): ExternalId { - const id = this.fields.get('externalId') - assert(id != null) - return id - } - - typeName(): string { - return this.fields.get('type') - } - - /** - * Return whether `this` and `other` are the same object, possibly in different modules. - */ - is(other: T): boolean { - return this.id === other.id - } - - /** Return this node's span, if it belongs to a module with an associated span map. */ - get span(): SourceRange | undefined { - return this.module.getSpan(this.id) - } - - innerExpression(): Ast { - // TODO: Override this in `Documented`, `Annotated`, `AnnotatedBuiltin` - return this - } - - code(): string { - return print(this).code - } - - visitRecursive(visit: (node: Ast | Token) => void): void { - visit(this) - for (const child of this.children()) { - if (isToken(child)) { - visit(child) - } else { - child.visitRecursive(visit) - } - } - } - - visitRecursiveAst(visit: (ast: Ast) => void): void { - visit(this) - for (const child of this.children()) { - if (!isToken(child)) child.visitRecursiveAst(visit) - } - } - - printSubtree(info: SpanMap, offset: number, parentIndent: string | undefined): string { - let code = '' - for (const child of this.concreteChildren()) { - if (!isTokenId(child.node) && this.module.checkedGet(child.node) === undefined) continue - if (child.whitespace != null) { - code += child.whitespace - } else if (code.length != 0) { - code += ' ' - } - if (isTokenId(child.node)) { - const tokenStart = offset + code.length - const token = this.module.getToken(child.node) - const span = tokenKey(tokenStart, token.code().length) - info.tokens.set(span, token) - code += token.code() - } else { - const childNode = this.module.checkedGet(child.node) - assert(childNode != null) - code += childNode.printSubtree(info, offset + code.length, parentIndent) - // Extra structural validation. - assertEqual(childNode.id, child.node) - if (parentId(childNode) !== this.id) { - console.error(`Inconsistent parent pointer (expected ${this.id})`, childNode) - } - assertEqual(parentId(childNode), this.id) - } - } - const span = nodeKey(offset, code.length) - const infos = map.setIfUndefined(info.nodes, span, (): Ast[] => []) - infos.push(this) - return code - } - - /** Returns child subtrees, without information about the whitespace between them. */ - *children(): IterableIterator { - for (const child of this.concreteChildren()) { - if (isTokenId(child.node)) { - yield this.module.getToken(child.node) - } else { - const node = this.module.checkedGet(child.node) - if (node) yield node - } - } - } - - parent(): Ast | undefined { - const parentId = this.fields.get('parent') - if (parentId === 'ROOT_ID') return - return this.module.checkedGet(parentId) - } - - static parseBlock(source: string, inModule?: MutableModule) { - return parseBlock(source, inModule) - } - - static parse(source: string, module?: MutableModule) { - return parse(source, module) - } - - //////////////////// - - protected constructor(module: Module, fields: FixedMapView) { - this.module = module - this.fields = fields - } - - /** @internal - * Returns child subtrees, including information about the whitespace between them. - */ - abstract concreteChildren(): IterableIterator -} - -export interface MutableAst {} -export abstract class MutableAst extends Ast { - declare readonly module: MutableModule - declare readonly fields: FixedMap - - setExternalId(id: ExternalId) { - this.fields.set('externalId', id) - } - - /** Modify the parent of this node to refer to a new object instead. Return the object, which now has no parent. */ - replace(replacement: Owned): Owned { - const parentId = this.fields.get('parent') - if (parentId) { - const parent = this.module.checkedGet(parentId) - parent.replaceChild(this.id, replacement) - this.fields.set('parent', undefined) - } - return asOwned(this) - } - - /** Change the value of the object referred to by the `target` ID. (The initial ID of `replacement` will be ignored.) - * Returns the old value, with a new (unreferenced) ID. - */ - replaceValue(replacement: Owned): Owned { - const replacement_ = this.module.copyIfForeign(replacement) - const old = this.replace(replacement_) - replacement_.setExternalId(old.externalId) - old.setExternalId(newExternalId()) - return old - } - - /** Replace the parent of this object with a reference to a new placeholder object. - * Returns the object, now parentless, and the placeholder. */ - takeToReplace(): Removed { - if (parentId(this)) { - const placeholder = Wildcard.new(this.module) - const node = this.replace(placeholder) - return { node, placeholder } - } else { - return { node: asOwned(this), placeholder: undefined } - } - } - - /** Replace the parent of this object with a reference to a new placeholder object. - * Returns the object, now parentless. */ - take(): Owned { - return this.replace(Wildcard.new(this.module)) - } - - takeIfParented(): Owned { - const parent = parentId(this) - if (parent) { - const parentAst = this.module.checkedGet(parent) - const placeholder = Wildcard.new(this.module) - parentAst.replaceChild(this.id, placeholder) - this.fields.set('parent', undefined) - } - return asOwned(this) - } - - /** Replace the value assigned to the given ID with a placeholder. - * Returns the removed value, with a new unreferenced ID. - **/ - takeValue(): Removed { - const placeholder = Wildcard.new(this.module) - const node = this.replaceValue(placeholder) - return { node, placeholder } - } - - /** Take this node from the tree, and replace it with the result of applying the given function to it. - * - * Note that this is a modification of the *parent* node. Any `Ast` objects or `AstId`s that pointed to the old value - * will still point to the old value. - */ - update(f: (x: Owned) => Owned): T { - const taken = this.takeToReplace() - assertDefined(taken.placeholder, 'To replace an `Ast`, it must have a parent.') - const replacement = f(taken.node) - taken.placeholder.replace(replacement) - return replacement - } - - /** Take this node from the tree, and replace it with the result of applying the given function to it; transfer the - * metadata from this node to the replacement. - * - * Note that this is a modification of the *parent* node. Any `Ast` objects or `AstId`s that pointed to the old value - * will still point to the old value. - */ - updateValue(f: (x: Owned) => Owned): T { - const taken = this.takeValue() - assertDefined(taken.placeholder, 'To replace an `Ast`, it must have a parent.') - const replacement = f(taken.node) - taken.placeholder.replaceValue(replacement) - return replacement - } - - mutableParent(): MutableAst | undefined { - const parentId = this.fields.get('parent') - if (parentId === 'ROOT_ID') return - return this.module.checkedGet(parentId) - } - - /////////////////// - - /** @internal */ - importReferences(module: Module) { - if (module === this.module) return - for (const child of this.concreteChildren()) { - if (!isTokenId(child.node)) { - const childInForeignModule = module.checkedGet(child.node) - assert(childInForeignModule !== undefined) - const importedChild = this.module.copy(childInForeignModule) - importedChild.fields.set('parent', undefined) - this.replaceChild(child.node, asOwned(importedChild)) - } - } - } - - /** @internal */ - abstract replaceChild(target: AstId, replacement: Owned): void - - protected claimChild(child: Owned): AstId - protected claimChild(child: Owned | undefined): AstId | undefined - protected claimChild(child: Owned | undefined): AstId | undefined { - return child ? claimChild(this.module, child, this.id) : undefined - } -} - -function applyMixins(derivedCtor: any, constructors: any[]) { - constructors.forEach((baseCtor) => { - Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => { - Object.defineProperty( - derivedCtor.prototype, - name, - Object.getOwnPropertyDescriptor(baseCtor.prototype, name) || Object.create(null), - ) - }) - }) -} - -function claimChild( - module: MutableModule, - child: Owned, - parent: AstId, -): AstId { - if (child.module === module) assertEqual(child.fields.get('parent'), undefined) - const child_ = module.copyIfForeign(child) - child_.fields.set('parent', parent) - return child_.id -} - -function concreteChild( - module: MutableModule, - child: NodeChild, - parent: AstId, -): NodeChild -function concreteChild( - module: MutableModule, - child: NodeChild | undefined, - parent: AstId, -): NodeChild | undefined -function concreteChild( - module: MutableModule, - child: NodeChild, - parent: AstId, -): NodeChild -function concreteChild( - module: MutableModule, - child: NodeChild | undefined, - parent: AstId, -): NodeChild | undefined -function concreteChild( - module: MutableModule, - child: NodeChild | undefined, - parent: AstId, -): NodeChild | undefined { - if (!child) return undefined - if (isTokenId(child.node)) return child as NodeChild - return { ...child, node: claimChild(module, child.node, parent) } -} - -type StrictIdentLike = Identifier | IdentifierToken -function toIdentStrict(ident: StrictIdentLike): IdentifierToken -function toIdentStrict(ident: StrictIdentLike | undefined): IdentifierToken | undefined -function toIdentStrict(ident: StrictIdentLike | undefined): IdentifierToken | undefined { - return ident - ? isToken(ident) - ? ident - : (Token.new(ident, RawAst.Token.Type.Ident) as IdentifierToken) - : undefined -} - -type IdentLike = IdentifierOrOperatorIdentifier | IdentifierOrOperatorIdentifierToken -function toIdent(ident: IdentLike): IdentifierOrOperatorIdentifierToken -function toIdent(ident: IdentLike | undefined): IdentifierOrOperatorIdentifierToken | undefined -function toIdent(ident: IdentLike | undefined): IdentifierOrOperatorIdentifierToken | undefined { - return ident - ? isToken(ident) - ? ident - : (Token.new(ident, RawAst.Token.Type.Ident) as IdentifierOrOperatorIdentifierToken) - : undefined -} - -function makeEquals(): Token { - return Token.new('=', RawAst.Token.Type.Operator) -} - -function nameSpecification( - name: StrictIdentLike | undefined, -): { name: NodeChild; equals: NodeChild } | undefined { - return name && { name: autospaced(toIdentStrict(name)), equals: unspaced(makeEquals()) } -} - -type AppFields = { - function: NodeChild - parens: { open: NodeChild; close: NodeChild } | undefined - nameSpecification: { name: NodeChild; equals: NodeChild } | undefined - argument: NodeChild -} - -export class App extends Ast { - declare fields: FixedMap - - constructor(module: Module, fields: FixedMapView) { - super(module, fields) - } - - static concrete( - module: MutableModule, - func: NodeChild, - parens: { open: NodeChild; close: NodeChild } | undefined, - nameSpecification: { name: NodeChild; equals: NodeChild } | undefined, - argument: NodeChild, - ) { - const base = module.baseObject('App') - const id_ = base.get('id') - const fields = setAll(base, { - function: concreteChild(module, func, id_), - parens, - nameSpecification, - argument: concreteChild(module, argument, id_), - }) - return asOwned(new MutableApp(module, fields)) - } - - static new( - module: MutableModule, - func: Owned, - argumentName: StrictIdentLike | undefined, - argument: Owned, - ) { - const name = nameSpecification(argumentName) - return App.concrete(module, unspaced(func), undefined, name, { - node: argument, - whitespace: name ? '' : ' ', - }) - } - - get function(): Ast { - return this.module.checkedGet(this.fields.get('function').node) - } - get argumentName(): Token | undefined { - return this.module.getToken(this.fields.get('nameSpecification')?.name.node) - } - get argument(): Ast { - return this.module.checkedGet(this.fields.get('argument').node) - } - - *concreteChildren(): IterableIterator { - const { function: function_, parens, nameSpecification, argument } = getAll(this.fields) - yield function_ - if (parens) yield parens.open - if (nameSpecification) { - yield nameSpecification.name - yield nameSpecification.equals - } - yield argument - if (parens) yield parens.close - } -} - -type KeysOfFieldType = { - [K in keyof Fields]: Fields[K] extends T ? K : never -}[keyof Fields] -function setNode>>( - map: FixedMap, - key: Key, - node: AstId, -): void -function setNode< - Fields, - Key extends string & KeysOfFieldType | undefined>, ->(map: FixedMap, key: Key, node: AstId | undefined): void -function setNode< - Fields, - Key extends string & KeysOfFieldType | undefined>, ->(map: FixedMap, key: Key, node: AstId | undefined): void { - // The signature correctly only allows this function to be called if `Fields[Key] instanceof NodeChild`, - // but it doesn't prove that property to TSC, so we have to cast here. - const old = map.get(key as string & keyof Fields) - const updated = old ? { ...old, node } : autospaced(node) - map.set(key, updated as Fields[Key]) -} - -export class MutableApp extends App implements MutableAst { - declare readonly module: MutableModule - declare readonly fields: FixedMap - - setFunction(value: Owned) { - setNode(this.fields, 'function', this.claimChild(value)) - } - setArgumentName(name: StrictIdentLike | undefined) { - this.fields.set('nameSpecification', nameSpecification(name)) - } - setArgument(value: Owned) { - setNode(this.fields, 'argument', this.claimChild(value)) - } - - replaceChild(target: AstId, replacement: Owned) { - if (this.fields.get('function').node === target) { - this.setFunction(replacement) - } else if (this.fields.get('argument').node === target) { - this.setArgument(replacement) - } - } -} -export interface MutableApp extends App, MutableAst { - get function(): MutableAst - get argument(): MutableAst -} -applyMixins(MutableApp, [MutableAst]) - -type UnaryOprAppFields = { - operator: NodeChild - argument: NodeChild | undefined -} -export class UnaryOprApp extends Ast { - declare fields: FixedMapView - constructor(module: Module, fields: FixedMapView) { - super(module, fields) - } - - static concrete( - module: MutableModule, - operator: NodeChild, - argument: NodeChild | undefined, - ) { - const base = module.baseObject('UnaryOprApp') - const id_ = base.get('id') - const fields = setAll(base, { - operator, - argument: concreteChild(module, argument, id_), - }) - return asOwned(new MutableUnaryOprApp(module, fields)) - } - - static new(module: MutableModule, operator: Token, argument: Owned | undefined) { - return this.concrete(module, unspaced(operator), argument ? autospaced(argument) : undefined) - } - - get operator(): Token { - return this.module.getToken(this.fields.get('operator').node) - } - get argument(): Ast | undefined { - return this.module.checkedGet(this.fields.get('argument')?.node) - } - - *concreteChildren(): IterableIterator { - const { operator, argument } = getAll(this.fields) - yield operator - if (argument) yield argument - } -} - -export class MutableUnaryOprApp extends UnaryOprApp implements MutableAst { - declare readonly module: MutableModule - declare readonly fields: FixedMap - - setOperator(value: Token) { - this.fields.set('operator', unspaced(value)) - } - setArgument(argument: Owned | undefined) { - setNode(this.fields, 'argument', this.claimChild(argument)) - } - - replaceChild(target: AstId, replacement: Owned) { - if (this.fields.get('argument')?.node === target) { - this.setArgument(replacement) - } - } -} -export interface MutableUnaryOprApp extends UnaryOprApp, MutableAst { - get argument(): MutableAst | undefined -} -applyMixins(MutableUnaryOprApp, [MutableAst]) - -type NegationAppFields = { - operator: NodeChild - argument: NodeChild -} -export class NegationApp extends Ast { - declare fields: FixedMapView - constructor(module: Module, fields: FixedMapView) { - super(module, fields) - } - - static concrete(module: MutableModule, operator: NodeChild, argument: NodeChild) { - const base = module.baseObject('NegationApp') - const id_ = base.get('id') - const fields = setAll(base, { - operator, - argument: concreteChild(module, argument, id_), - }) - return asOwned(new MutableNegationApp(module, fields)) - } - - static new(module: MutableModule, operator: Token, argument: Owned) { - return this.concrete(module, unspaced(operator), autospaced(argument)) - } - - get operator(): Token { - return this.module.getToken(this.fields.get('operator').node) - } - get argument(): Ast { - return this.module.checkedGet(this.fields.get('argument').node) - } - - *concreteChildren(): IterableIterator { - const { operator, argument } = getAll(this.fields) - yield operator - if (argument) yield argument - } -} - -export class MutableNegationApp extends NegationApp implements MutableAst { - declare readonly module: MutableModule - declare readonly fields: FixedMap - - setArgument(value: Owned) { - setNode(this.fields, 'argument', this.claimChild(value)) - } - - replaceChild(target: AstId, replacement: Owned) { - if (this.fields.get('argument')?.node === target) { - this.setArgument(replacement) - } - } -} -export interface MutableNegationApp extends NegationApp, MutableAst { - get argument(): MutableAst -} -applyMixins(MutableNegationApp, [MutableAst]) - -type OprAppFields = { - lhs: NodeChild | undefined - operators: NodeChild[] - rhs: NodeChild | undefined -} -export class OprApp extends Ast { - declare fields: FixedMapView - constructor(module: Module, fields: FixedMapView) { - super(module, fields) - } - - static concrete( - module: MutableModule, - lhs: NodeChild | undefined, - operators: NodeChild[], - rhs: NodeChild | undefined, - ) { - const base = module.baseObject('OprApp') - const id_ = base.get('id') - const fields = setAll(base, { - lhs: concreteChild(module, lhs, id_), - operators, - rhs: concreteChild(module, rhs, id_), - }) - return asOwned(new MutableOprApp(module, fields)) - } - - static new( - module: MutableModule, - lhs: Owned | undefined, - operator: Token, - rhs: Owned | undefined, - ) { - return OprApp.concrete(module, unspaced(lhs), [autospaced(operator)], autospaced(rhs)) - } - - get lhs(): Ast | undefined { - return this.module.checkedGet(this.fields.get('lhs')?.node) - } - get operator(): Result[]> { - const operators = this.fields.get('operators') - const operators_ = operators.map((child) => ({ - ...child, - node: this.module.getToken(child.node), - })) - const [opr] = operators_ - return opr ? Ok(opr.node) : Err(operators_) - } - get rhs(): Ast | undefined { - return this.module.checkedGet(this.fields.get('rhs')?.node) - } - - *concreteChildren(): IterableIterator { - const { lhs, operators, rhs } = getAll(this.fields) - if (lhs) yield lhs - yield* operators - if (rhs) yield rhs - } -} - -export class MutableOprApp extends OprApp implements MutableAst { - declare readonly module: MutableModule - declare readonly fields: FixedMap - - setLhs(value: Owned) { - setNode(this.fields, 'lhs', this.claimChild(value)) - } - setOperator(value: Token) { - this.fields.set('operators', [unspaced(value)]) - } - setRhs(value: Owned) { - setNode(this.fields, 'rhs', this.claimChild(value)) - } - - replaceChild(target: AstId, replacement: Owned) { - if (this.fields.get('lhs')?.node === target) { - this.setLhs(replacement) - } else if (this.fields.get('rhs')?.node === target) { - this.setRhs(replacement) - } - } -} -export interface MutableOprApp extends OprApp, MutableAst { - get lhs(): MutableAst | undefined - get rhs(): MutableAst | undefined -} -applyMixins(MutableOprApp, [MutableAst]) - -type PropertyAccessFields = { - lhs: NodeChild | undefined - operator: NodeChild - rhs: NodeChild -} -export class PropertyAccess extends Ast { - declare fields: FixedMapView - constructor(module: Module, fields: FixedMapView) { - super(module, fields) - } - - static new(module: MutableModule, lhs: Owned, rhs: IdentLike) { - const dot = unspaced(Token.new('.', RawAst.Token.Type.Operator)) - return this.concrete( - module, - unspaced(lhs), - dot, - unspaced(Ident.newAllowingOperators(module, toIdent(rhs))), - ) - } - - static Sequence( - segments: [StrictIdentLike, ...StrictIdentLike[]], - module: MutableModule, - ): Owned | Owned - static Sequence( - segments: [StrictIdentLike, ...StrictIdentLike[], IdentLike], - module: MutableModule, - ): Owned | Owned - static Sequence( - segments: IdentLike[], - module: MutableModule, - ): Owned | Owned | undefined - static Sequence( - segments: IdentLike[], - module: MutableModule, - ): Owned | Owned | undefined { - let path: Owned | Owned | undefined - let operatorInNonFinalSegment = false - segments.forEach((s, i) => { - const t = toIdent(s) - if (i !== segments.length - 1 && !isIdentifier(t.code())) operatorInNonFinalSegment = true - path = path ? this.new(module, path, t) : Ident.newAllowingOperators(module, t) - }) - if (!operatorInNonFinalSegment) return path - } - - static concrete( - module: MutableModule, - lhs: NodeChild | undefined, - operator: NodeChild, - rhs: NodeChild>, - ) { - const base = module.baseObject('PropertyAccess') - const id_ = base.get('id') - const fields = setAll(base, { - lhs: concreteChild(module, lhs, id_), - operator, - rhs: concreteChild(module, rhs, id_), - }) - return asOwned(new MutablePropertyAccess(module, fields)) - } - - get lhs(): Ast | undefined { - return this.module.checkedGet(this.fields.get('lhs')?.node) - } - get operator(): Token { - return this.module.getToken(this.fields.get('operator').node) - } - get rhs(): IdentifierOrOperatorIdentifierToken { - const ast = this.module.checkedGet(this.fields.get('rhs').node) - assert(ast instanceof Ident) - return ast.token as IdentifierOrOperatorIdentifierToken - } - - *concreteChildren(): IterableIterator { - const { lhs, operator, rhs } = getAll(this.fields) - if (lhs) yield lhs - yield operator - yield rhs - } -} -export class MutablePropertyAccess extends PropertyAccess implements MutableAst { - declare readonly module: MutableModule - declare readonly fields: FixedMap - - setLhs(value: Owned | undefined) { - setNode(this.fields, 'lhs', this.claimChild(value)) - } - setRhs(ident: IdentLike) { - const node = this.claimChild(Ident.newAllowingOperators(this.module, ident)) - const old = this.fields.get('rhs') - this.fields.set('rhs', old ? { ...old, node } : unspaced(node)) - } - - replaceChild(target: AstId, replacement: Owned) { - if (this.fields.get('lhs')?.node === target) { - this.setLhs(replacement) - } else if (this.fields.get('rhs')?.node === target) { - assert(replacement instanceof MutableIdent) - this.setRhs(replacement.token) - } - } -} -export interface MutablePropertyAccess extends PropertyAccess, MutableAst { - get lhs(): MutableAst | undefined -} -applyMixins(MutablePropertyAccess, [MutableAst]) - -type GenericFields = { - children: NodeChild[] -} -export class Generic extends Ast { - declare fields: FixedMapView - constructor(module: Module, fields: FixedMapView) { - super(module, fields) - } - - static concrete(module: MutableModule, children: NodeChild[]) { - const base = module.baseObject('Generic') - const id_ = base.get('id') - const fields = setAll(base, { - children: children.map((child) => concreteChild(module, child, id_)), - }) - return asOwned(new MutableGeneric(module, fields)) - } - - concreteChildren(): IterableIterator { - return this.fields.get('children')[Symbol.iterator]() - } -} - -export class MutableGeneric extends Generic implements MutableAst { - declare readonly module: MutableModule - declare readonly fields: FixedMap - - replaceChild(target: AstId, replacement: Owned) { - const replacement_ = autospaced(this.claimChild(replacement)) - this.fields.set( - 'children', - this.fields.get('children').map((child) => (child.node === target ? replacement_ : child)), - ) - } -} -export interface MutableGeneric extends Generic, MutableAst {} -applyMixins(MutableGeneric, [MutableAst]) - -type RawMultiSegmentAppSegment = { - header: NodeChild - body: NodeChild | undefined -} -type OwnedMultiSegmentAppSegment = { - header: NodeChild - body: NodeChild | undefined -} -function multiSegmentAppSegment( - header: string, - body: Owned, -): OwnedMultiSegmentAppSegment -function multiSegmentAppSegment( - header: string, - body: Owned | undefined, -): OwnedMultiSegmentAppSegment | undefined -function multiSegmentAppSegment( - header: string, - body: Owned | undefined, -): OwnedMultiSegmentAppSegment | undefined { - return { - header: { node: Token.new(header, RawAst.Token.Type.Ident) }, - body: spaced(body ? (body as any) : undefined), - } -} - -function multiSegmentAppSegmentToRaw( - module: MutableModule, - msas: OwnedMultiSegmentAppSegment, - parent: AstId, -): RawMultiSegmentAppSegment -function multiSegmentAppSegmentToRaw( - module: MutableModule, - msas: OwnedMultiSegmentAppSegment, - parent: AstId, -): RawMultiSegmentAppSegment -function multiSegmentAppSegmentToRaw( - module: MutableModule, - msas: OwnedMultiSegmentAppSegment | undefined, - parent: AstId, -): RawMultiSegmentAppSegment | undefined -function multiSegmentAppSegmentToRaw( - module: MutableModule, - msas: OwnedMultiSegmentAppSegment | undefined, - parent: AstId, -): RawMultiSegmentAppSegment | undefined { - if (!msas) return undefined - return { - ...msas, - body: concreteChild(module, msas.body, parent), - } -} - -type ImportFields = { - polyglot: RawMultiSegmentAppSegment | undefined - from: RawMultiSegmentAppSegment | undefined - import: RawMultiSegmentAppSegment - all: NodeChild | undefined - as: RawMultiSegmentAppSegment | undefined - hiding: RawMultiSegmentAppSegment | undefined -} -export class Import extends Ast { - declare fields: FixedMapView - constructor(module: Module, fields: FixedMapView) { - super(module, fields) - } - - get polyglot(): Ast | undefined { - return this.module.checkedGet(this.fields.get('polyglot')?.body?.node) - } - get from(): Ast | undefined { - return this.module.checkedGet(this.fields.get('from')?.body?.node) - } - get import_(): Ast | undefined { - return this.module.checkedGet(this.fields.get('import').body?.node) - } - get all(): Token | undefined { - return this.module.getToken(this.fields.get('all')?.node) - } - get as(): Ast | undefined { - return this.module.checkedGet(this.fields.get('as')?.body?.node) - } - get hiding(): Ast | undefined { - return this.module.checkedGet(this.fields.get('hiding')?.body?.node) - } - - static concrete( - module: MutableModule, - polyglot: OwnedMultiSegmentAppSegment | undefined, - from: OwnedMultiSegmentAppSegment | undefined, - import_: OwnedMultiSegmentAppSegment, - all: NodeChild | undefined, - as: OwnedMultiSegmentAppSegment | undefined, - hiding: OwnedMultiSegmentAppSegment | undefined, - ) { - const base = module.baseObject('Import') - const id_ = base.get('id') - const fields = setAll(base, { - polyglot: multiSegmentAppSegmentToRaw(module, polyglot, id_), - from: multiSegmentAppSegmentToRaw(module, from, id_), - import: multiSegmentAppSegmentToRaw(module, import_, id_), - all, - as: multiSegmentAppSegmentToRaw(module, as, id_), - hiding: multiSegmentAppSegmentToRaw(module, hiding, id_), - }) - return asOwned(new MutableImport(module, fields)) - } - - static Qualified(path: IdentLike[], module: MutableModule): Owned | undefined { - const path_ = PropertyAccess.Sequence(path, module) - if (!path_) return - return MutableImport.concrete( - module, - undefined, - undefined, - multiSegmentAppSegment('import', path_), - undefined, - undefined, - undefined, - ) - } - - static Unqualified( - path: IdentLike[], - name: IdentLike, - module: MutableModule, - ): Owned | undefined { - const path_ = PropertyAccess.Sequence(path, module) - if (!path_) return - const name_ = Ident.newAllowingOperators(module, name) - return MutableImport.concrete( - module, - undefined, - multiSegmentAppSegment('from', path_), - multiSegmentAppSegment('import', name_), - undefined, - undefined, - undefined, - ) - } - - *concreteChildren(): IterableIterator { - const segment = (segment: RawMultiSegmentAppSegment | undefined) => { - const parts = [] - if (segment) parts.push(segment.header) - if (segment?.body) parts.push(segment.body) - return parts - } - const { polyglot, from, import: import_, all, as, hiding } = getAll(this.fields) - yield* segment(polyglot) - yield* segment(from) - yield* segment(import_) - if (all) yield all - yield* segment(as) - yield* segment(hiding) - } -} - -export class MutableImport extends Import implements MutableAst { - declare readonly module: MutableModule - declare readonly fields: FixedMap - - private toRaw(msas: OwnedMultiSegmentAppSegment): RawMultiSegmentAppSegment - private toRaw( - msas: OwnedMultiSegmentAppSegment | undefined, - ): RawMultiSegmentAppSegment | undefined - private toRaw( - msas: OwnedMultiSegmentAppSegment | undefined, - ): RawMultiSegmentAppSegment | undefined { - return multiSegmentAppSegmentToRaw(this.module, msas, this.id) - } - - setPolyglot(value: Owned | undefined) { - this.fields.set( - 'polyglot', - value ? this.toRaw(multiSegmentAppSegment('polyglot', value)) : undefined, - ) - } - setFrom(value: Owned | undefined) { - this.fields.set('from', value ? this.toRaw(multiSegmentAppSegment('from', value)) : value) - } - setImport(value: Owned) { - this.fields.set('import', this.toRaw(multiSegmentAppSegment('import', value))) - } - setAll(value: Token | undefined) { - this.fields.set('all', spaced(value)) - } - setAs(value: Owned | undefined) { - this.fields.set('as', this.toRaw(multiSegmentAppSegment('as', value))) - } - setHiding(value: Owned | undefined) { - this.fields.set('hiding', this.toRaw(multiSegmentAppSegment('hiding', value))) - } - - replaceChild(target: AstId, replacement: Owned) { - const { polyglot, from, import: import_, as, hiding } = getAll(this.fields) - ;(polyglot?.body?.node === target - ? this.setPolyglot - : from?.body?.node === target - ? this.setFrom - : import_.body?.node === target - ? this.setImport - : as?.body?.node === target - ? this.setAs - : hiding?.body?.node === target - ? this.setHiding - : bail(`Failed to find child ${target} in node ${this.externalId}.`))(replacement) - } -} -export interface MutableImport extends Import, MutableAst { - get polyglot(): MutableAst | undefined - get from(): MutableAst | undefined - get import_(): MutableAst | undefined - get as(): MutableAst | undefined - get hiding(): MutableAst | undefined -} -applyMixins(MutableImport, [MutableAst]) - -type TextLiteralFields = { - open: NodeChild | undefined - newline: NodeChild | undefined - elements: NodeChild[] - close: NodeChild | undefined -} -export class TextLiteral extends Ast { - declare fields: FixedMapView - constructor(module: Module, fields: FixedMapView) { - super(module, fields) - } - - static concrete( - module: MutableModule, - open: NodeChild | undefined, - newline: NodeChild | undefined, - elements: NodeChild[], - close: NodeChild | undefined, - ) { - const base = module.baseObject('TextLiteral') - const id_ = base.get('id') - const fields = setAll(base, { - open, - newline, - elements: elements.map((elem) => concreteChild(module, elem, id_)), - close, - }) - return asOwned(new MutableTextLiteral(module, fields)) - } - - static new(rawText: string, module: MutableModule) { - const open = unspaced(Token.new("'")) - const elements = [unspaced(Token.new(escape(rawText)))] - const close = unspaced(Token.new("'")) - return this.concrete(module, open, undefined, elements, close) - } - - *concreteChildren(): IterableIterator { - const { open, newline, elements, close } = getAll(this.fields) - if (open) yield open - if (newline) yield newline - yield* elements - if (close) yield close - } -} - -const mapping: Record = { - '\b': '\\b', - '\f': '\\f', - '\n': '\\n', - '\r': '\\r', - '\t': '\\t', - '\v': '\\v', - '"': '\\"', - "'": "\\'", - '`': '``', -} - -const reverseMapping = swapKeysAndValues(mapping) - -/** 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]!) -} - -/** The reverse of `escape`: transform the string into human-readable form, not suitable for interpolation. */ -export function unescape(string: string) { - return string.replace(/\\[0bfnrtv"']|``/g, (match) => reverseMapping[match]!) -} - -export class MutableTextLiteral extends TextLiteral implements MutableAst { - declare readonly module: MutableModule - declare readonly fields: FixedMap - - replaceChild(target: AstId, replacement: Owned) { - const replacement_ = autospaced(this.claimChild(replacement)) - this.fields.set( - 'elements', - this.fields.get('elements').map((child) => (child.node === target ? replacement_ : child)), - ) - } -} -export interface MutableTextLiteral extends TextLiteral, MutableAst {} -applyMixins(MutableTextLiteral, [MutableAst]) - -type DocumentedFields = { - open: NodeChild | undefined - elements: NodeChild[] - newlines: NodeChild[] - expression: NodeChild | undefined -} -export class Documented extends Ast { - declare fields: FixedMapView - constructor(module: Module, fields: FixedMapView) { - super(module, fields) - } - - static concrete( - module: MutableModule, - open: NodeChild | undefined, - elements: NodeChild[], - newlines: NodeChild[], - expression: NodeChild | undefined, - ) { - const base = module.baseObject('Documented') - const id_ = base.get('id') - const fields = setAll(base, { - open, - elements: elements.map((elem) => concreteChild(module, elem, id_)), - newlines, - expression: concreteChild(module, expression, id_), - }) - return asOwned(new MutableDocumented(module, fields)) - } - - get expression(): Ast | undefined { - return this.module.checkedGet(this.fields.get('expression')?.node) - } - - *concreteChildren(): IterableIterator { - const { open, elements, newlines, expression } = getAll(this.fields) - if (open) yield open - yield* elements - yield* newlines - if (expression) yield expression - } -} - -export class MutableDocumented extends Documented implements MutableAst { - declare readonly module: MutableModule - declare readonly fields: FixedMap - - setExpression(value: Owned | undefined) { - this.fields.set('expression', unspaced(this.claimChild(value))) - } - - replaceChild(target: AstId, replacement: Owned) { - if (this.fields.get('expression')?.node === target) { - this.setExpression(replacement) - } else { - const replacement_ = unspaced(this.claimChild(replacement)) - this.fields.set( - 'elements', - this.fields.get('elements').map((child) => (child.node === target ? replacement_ : child)), - ) - } - } -} -export interface MutableDocumented extends Documented, MutableAst { - get expression(): MutableAst | undefined -} -applyMixins(MutableDocumented, [MutableAst]) - -type InvalidFields = { expression: NodeChild } -export class Invalid extends Ast { - declare fields: FixedMapView - constructor(module: Module, fields: FixedMapView) { - super(module, fields) - } - - static concrete(module: MutableModule, expression: NodeChild) { - const base = module.baseObject('Invalid') - return asOwned(new MutableInvalid(module, invalidFields(module, base, expression))) - } - - get expression(): Ast { - return this.module.checkedGet(this.fields.get('expression').node) - } - - *concreteChildren(): IterableIterator { - yield this.fields.get('expression') - } -} - -function invalidFields( - module: MutableModule, - base: FixedMap, - expression: NodeChild, -): FixedMap { - const id_ = base.get('id') - return setAll(base, { expression: concreteChild(module, expression, id_) }) -} - -export class MutableInvalid extends Invalid implements MutableAst { - declare readonly module: MutableModule - declare readonly fields: FixedMap - - /** Private, because it makes more sense to `.replace` the `Invalid` node. */ - private setExpression(value: Owned) { - this.fields.set('expression', unspaced(this.claimChild(value))) - } - - replaceChild(target: AstId, replacement: Owned) { - assertEqual(this.fields.get('expression').node, target) - this.setExpression(replacement) - } -} -export interface MutableInvalid extends Invalid, MutableAst { - /** The `expression` getter is intentionally not narrowed to provide mutable access: - * It makes more sense to `.replace` the `Invalid` node. */ -} -applyMixins(MutableInvalid, [MutableAst]) - -type MutableRootPointer = MutableInvalid & { get expression(): MutableAst | undefined } -/** @internal */ -export type RootPointer = Invalid - -type GroupFields = { - open: NodeChild | undefined - expression: NodeChild | undefined - close: NodeChild | undefined -} -export class Group extends Ast { - declare fields: FixedMapView - constructor(module: Module, fields: FixedMapView) { - super(module, fields) - } - - static concrete( - module: MutableModule, - open: NodeChild | undefined, - expression: NodeChild | undefined, - close: NodeChild | undefined, - ) { - const base = module.baseObject('Group') - const id_ = base.get('id') - const fields = setAll(base, { open, expression: concreteChild(module, expression, id_), close }) - return asOwned(new MutableGroup(module, fields)) - } - - get expression(): Ast | undefined { - return this.module.checkedGet(this.fields.get('expression')?.node) - } - - *concreteChildren(): IterableIterator { - const { open, expression, close } = getAll(this.fields) - if (open) yield open - if (expression) yield expression - if (close) yield close - } -} - -export class MutableGroup extends Group implements MutableAst { - declare readonly module: MutableModule - declare readonly fields: FixedMap - - setExpression(value: Owned | undefined) { - this.fields.set('expression', unspaced(this.claimChild(value))) - } - - replaceChild(target: AstId, replacement: Owned) { - assertEqual(this.fields.get('expression')?.node, target) - this.setExpression(replacement) - } -} -export interface MutableGroup extends Group, MutableAst { - get expression(): MutableAst | undefined -} -applyMixins(MutableGroup, [MutableAst]) - -type NumericLiteralFields = { - tokens: NodeChild[] -} -export class NumericLiteral extends Ast { - declare fields: FixedMapView - constructor(module: Module, fields: FixedMapView) { - super(module, fields) - } - - static concrete(module: MutableModule, tokens: NodeChild[]) { - const base = module.baseObject('NumericLiteral') - const fields = setAll(base, { tokens }) - return asOwned(new MutableNumericLiteral(module, fields)) - } - - concreteChildren(): IterableIterator { - return this.fields.get('tokens')[Symbol.iterator]() - } -} - -export class MutableNumericLiteral extends NumericLiteral implements MutableAst { - declare readonly module: MutableModule - declare readonly fields: FixedMap - - replaceChild(target: AstId, replacement: Owned) {} -} -export interface MutableNumericLiteral extends NumericLiteral, MutableAst {} -applyMixins(MutableNumericLiteral, [MutableAst]) - -/** The actual contents of an `ArgumentDefinition` are complex, but probably of more interest to the compiler than the - * GUI. We just need to represent them faithfully and create the simple cases. */ -type ArgumentDefinition = NodeChild[] -type RawArgumentDefinition = NodeChild[] -type OwnedArgumentDefinition = NodeChild[] - -function argumentDefinitionsToRaw( - module: MutableModule, - defs: OwnedArgumentDefinition[], - parent: AstId, -): RawArgumentDefinition[] { - return defs.map((def) => - def.map((part) => ({ - ...part, - node: part.node instanceof Token ? part.node : claimChild(module, part.node, parent), - })), - ) -} - -type FunctionFields = { - name: NodeChild - argumentDefinitions: RawArgumentDefinition[] - equals: NodeChild - body: NodeChild | undefined -} -export class Function extends Ast { - declare fields: FixedMapView - constructor(module: Module, fields: FixedMapView) { - super(module, fields) - } - - get name(): Ast { - return this.module.checkedGet(this.fields.get('name').node) - } - get body(): Ast | undefined { - return this.module.checkedGet(this.fields.get('body')?.node) - } - get argumentDefinitions(): ArgumentDefinition[] { - return this.fields.get('argumentDefinitions').map((raw) => - raw.map((part) => ({ - ...part, - node: this.module.getAny(part.node), - })), - ) - } - - static concrete( - module: MutableModule, - name: NodeChild, - argumentDefinitions: OwnedArgumentDefinition[], - equals: NodeChild, - body: NodeChild | undefined, - ) { - const base = module.baseObject('Function') - const id_ = base.get('id') - const fields = setAll(base, { - name: concreteChild(module, name, id_), - argumentDefinitions: argumentDefinitionsToRaw(module, argumentDefinitions, id_), - equals, - body: concreteChild(module, body, id_), - }) - return asOwned(new MutableFunction(module, fields)) - } - - static new( - module: MutableModule, - name: IdentLike, - argumentDefinitions: OwnedArgumentDefinition[], - body: Owned, - ): Owned { - // Note that a function name may not be an operator if the function is not in the body of a type definition, but we - // can't easily enforce that because we don't currently make a syntactic distinction between top-level functions and - // type methods. - return MutableFunction.concrete( - module, - unspaced(Ident.newAllowingOperators(module, name)), - argumentDefinitions, - spaced(makeEquals()), - autospaced(body), - ) - } - - /** Construct a function with simple (name-only) arguments and a body block. */ - static fromStatements( - module: MutableModule, - name: IdentLike, - argumentNames: StrictIdentLike[], - statements: Owned[], - trailingNewline?: boolean, - ): Owned { - const statements_: OwnedBlockLine[] = statements.map((statement) => ({ - expression: unspaced(statement), - })) - if (trailingNewline) { - statements_.push({ expression: undefined }) - } - const argumentDefinitions = argumentNames.map((name) => [spaced(Ident.new(module, name))]) - const body = BodyBlock.new(statements_, module) - return MutableFunction.new(module, name, argumentDefinitions, body) - } - - *bodyExpressions(): IterableIterator { - const body = this.body - if (body instanceof BodyBlock) { - yield* body.statements() - } else if (body) { - yield body - } - } - - *concreteChildren(): IterableIterator { - const { name, argumentDefinitions, equals, body } = getAll(this.fields) - yield name - for (const def of argumentDefinitions) yield* def - yield { whitespace: equals.whitespace ?? ' ', node: this.module.getToken(equals.node) } - if (body) yield body - } -} - -export class MutableFunction extends Function implements MutableAst { - declare readonly module: MutableModule - declare readonly fields: FixedMap - - setName(value: Owned) { - this.fields.set('name', unspaced(this.claimChild(value))) - } - setBody(value: Owned | undefined) { - this.fields.set('body', unspaced(this.claimChild(value))) - } - setArgumentDefinitions(defs: OwnedArgumentDefinition[]) { - this.fields.set('argumentDefinitions', argumentDefinitionsToRaw(this.module, defs, this.id)) - } - - /** Returns the body, after converting it to a block if it was empty or an inline expression. */ - bodyAsBlock(): MutableBodyBlock { - const oldBody = this.body - if (oldBody instanceof MutableBodyBlock) return oldBody - const newBody = BodyBlock.new([], this.module) - if (oldBody) newBody.push(oldBody.take()) - return newBody - } - - replaceChild(target: AstId, replacement: Owned) { - const { name, argumentDefinitions, body } = getAll(this.fields) - if (name.node === target) { - this.setName(replacement) - } else if (body?.node === target) { - this.setBody(replacement) - } else { - const replacement_ = this.claimChild(replacement) - const replaceChild = (child: NodeChild) => - child.node === target ? { ...child, node: replacement_ } : child - this.fields.set( - 'argumentDefinitions', - argumentDefinitions.map((def) => def.map(replaceChild)), - ) - } - } -} -export interface MutableFunction extends Function, MutableAst { - get name(): MutableAst - get body(): MutableAst | undefined -} -applyMixins(MutableFunction, [MutableAst]) - -type AssignmentFields = { - pattern: NodeChild - equals: NodeChild - expression: NodeChild -} -export class Assignment extends Ast { - declare fields: FixedMapView - constructor(module: Module, fields: FixedMapView) { - super(module, fields) - } - - static concrete( - module: MutableModule, - pattern: NodeChild, - equals: NodeChild, - expression: NodeChild, - ) { - const base = module.baseObject('Assignment') - const id_ = base.get('id') - const fields = setAll(base, { - pattern: concreteChild(module, pattern, id_), - equals, - expression: concreteChild(module, expression, id_), - }) - return asOwned(new MutableAssignment(module, fields)) - } - - static new(module: MutableModule, ident: StrictIdentLike, expression: Owned) { - return Assignment.concrete( - module, - unspaced(Ident.new(module, ident)), - spaced(makeEquals()), - spaced(expression), - ) - } - - get pattern(): Ast { - return this.module.checkedGet(this.fields.get('pattern').node) - } - get expression(): Ast { - return this.module.checkedGet(this.fields.get('expression').node) - } - - *concreteChildren(): IterableIterator { - const { pattern, equals, expression } = getAll(this.fields) - yield pattern - yield { - whitespace: equals.whitespace ?? expression.whitespace ?? ' ', - node: equals.node, - } - yield expression - } -} - -export class MutableAssignment extends Assignment implements MutableAst { - declare readonly module: MutableModule - declare readonly fields: FixedMap - - setPattern(value: Owned) { - this.fields.set('pattern', unspaced(this.claimChild(value))) - } - setExpression(value: Owned) { - setNode(this.fields, 'expression', this.claimChild(value)) - } - - replaceChild(target: AstId, replacement: Owned) { - const { pattern, expression } = getAll(this.fields) - if (pattern.node === target) { - this.setPattern(replacement) - } else if (expression.node === target) { - this.setExpression(replacement) - } - } -} -export interface MutableAssignment extends Assignment, MutableAst { - get pattern(): MutableAst - get expression(): MutableAst -} -applyMixins(MutableAssignment, [MutableAst]) - -type BodyBlockFields = { - lines: RawBlockLine[] -} -export class BodyBlock extends Ast { - declare fields: FixedMapView - constructor(module: Module, fields: FixedMapView) { - super(module, fields) - } - - static concrete(module: MutableModule, lines: OwnedBlockLine[]) { - const base = module.baseObject('BodyBlock') - const id_ = base.get('id') - const fields = setAll(base, { - lines: lines.map((line) => lineToRaw(line, module, id_)), - }) - return asOwned(new MutableBodyBlock(module, fields)) - } - - static new(lines: OwnedBlockLine[], module: MutableModule) { - return BodyBlock.concrete(module, lines) - } - - get lines(): BlockLine[] { - return this.fields.get('lines').map((line) => lineFromRaw(line, this.module)) - } - - *statements(): IterableIterator { - for (const line of this.lines) { - if (line.expression) yield line.expression.node - } - } - - *concreteChildren(): IterableIterator { - for (const line of this.fields.get('lines')) { - yield line.newline ?? { node: Token.new('\n', RawAst.Token.Type.Newline) } - if (line.expression) yield line.expression - } - } - - printSubtree(info: SpanMap, offset: number, parentIndent: string | undefined): string { - let blockIndent: string | undefined - let code = '' - for (const line of this.fields.get('lines')) { - code += line.newline.whitespace ?? '' - const newlineCode = this.module.getToken(line.newline.node).code() - // Only print a newline if this isn't the first line in the output, or it's a comment. - if (offset || code || newlineCode.startsWith('#')) { - // If this isn't the first line in the output, but there is a concrete newline token: - // if it's a zero-length newline, ignore it and print a normal newline. - code += newlineCode || '\n' - } - if (line.expression) { - if (blockIndent === undefined) { - if ((line.expression.whitespace?.length ?? 0) > (parentIndent?.length ?? 0)) { - blockIndent = line.expression.whitespace! - } else if (parentIndent !== undefined) { - blockIndent = parentIndent + ' ' - } else { - blockIndent = '' - } - } - const validIndent = (line.expression.whitespace?.length ?? 0) > (parentIndent?.length ?? 0) - code += validIndent ? line.expression.whitespace : blockIndent - const lineNode = this.module.checkedGet(line.expression.node) - assertEqual(lineNode.id, line.expression.node) - assertEqual(parentId(lineNode), this.id) - code += lineNode.printSubtree(info, offset + code.length, blockIndent) - } - } - const span = nodeKey(offset, code.length) - map.setIfUndefined(info.nodes, span, (): Ast[] => []).push(this) - return code - } -} - -export class MutableBodyBlock extends BodyBlock implements MutableAst { - declare readonly module: MutableModule - declare readonly fields: FixedMap - - updateLines(map: (lines: OwnedBlockLine[]) => OwnedBlockLine[]) { - return this.setLines(map(this.takeLines())) - } - takeLines(): OwnedBlockLine[] { - return this.fields.get('lines').map((line) => ownedLineFromRaw(line, this.module)) - } - setLines(lines: OwnedBlockLine[]) { - this.fields.set( - 'lines', - lines.map((line) => lineToRaw(line, this.module, this.id)), - ) - } - - /** Insert the given statement(s) starting at the specified line index. */ - insert(index: number, ...statements: Owned[]) { - const before = this.fields.get('lines').slice(0, index) - const insertions = statements.map((statement) => ({ - newline: unspaced(Token.new('\n', RawAst.Token.Type.Newline)), - expression: unspaced(this.claimChild(statement)), - })) - const after = this.fields.get('lines').slice(index) - this.fields.set('lines', [...before, ...insertions, ...after]) - } - - push(statement: Owned) { - const oldLines = this.fields.get('lines') - const newLine = { - newline: unspaced(Token.new('\n', RawAst.Token.Type.Newline)), - expression: unspaced(this.claimChild(statement)), - } - this.fields.set('lines', [...oldLines, newLine]) - } - - filter(keep: (ast: MutableAst) => boolean) { - const oldLines = this.fields.get('lines') - const filteredLines = oldLines.filter((line) => { - if (!line.expression) return true - return keep(this.module.checkedGet(line.expression.node)) - }) - this.fields.set('lines', filteredLines) - } - - replaceChild(target: AstId, replacement: Owned) { - const replacement_ = this.claimChild(replacement) - const updateLine = (line: RawBlockLine) => - line.expression?.node === target - ? { ...line, expression: { ...line.expression, node: replacement_ } } - : line - this.fields.set('lines', this.fields.get('lines').map(updateLine)) - } -} -export interface MutableBodyBlock extends BodyBlock, MutableAst { - statements(): IterableIterator -} -applyMixins(MutableBodyBlock, [MutableAst]) - -type RawLine = { - newline: NodeChild - expression: NodeChild | undefined -} -type Line = { - newline?: NodeChild | undefined - expression: NodeChild | undefined -} - -type RawBlockLine = RawLine -export type BlockLine = Line -export type OwnedBlockLine = Line - -function lineFromRaw(raw: RawBlockLine, module: Module): BlockLine { - const expression = raw.expression ? module.checkedGet(raw.expression.node) : undefined - return { - newline: { ...raw.newline, node: module.getToken(raw.newline.node) }, - expression: expression - ? { - whitespace: raw.expression?.whitespace, - node: expression, - } - : undefined, - } -} - -function ownedLineFromRaw(raw: RawBlockLine, module: MutableModule): OwnedBlockLine { - const expression = raw.expression - ? module.checkedGet(raw.expression.node).takeIfParented() - : undefined - return { - newline: { ...raw.newline, node: module.getToken(raw.newline.node) }, - expression: expression - ? { - whitespace: raw.expression?.whitespace, - node: expression, - } - : undefined, - } -} - -function lineToRaw(line: OwnedBlockLine, module: MutableModule, block: AstId): RawBlockLine { - return { - newline: line.newline ?? unspaced(Token.new('\n', RawAst.Token.Type.Newline)), - expression: line.expression - ? { - whitespace: line.expression?.whitespace, - node: claimChild(module, line.expression.node, block), - } - : undefined, - } -} - -type IdentFields = { - token: NodeChild -} -export class Ident extends Ast { - declare fields: FixedMapView - constructor(module: Module, fields: FixedMapView) { - super(module, fields) - } - - get token(): IdentifierToken { - return this.module.getToken(this.fields.get('token').node) as IdentifierToken - } - - static concrete(module: MutableModule, token: NodeChild) { - const base = module.baseObject('Ident') - const fields = setAll(base, { token }) - return asOwned(new MutableIdent(module, fields)) - } - - static new(module: MutableModule, ident: StrictIdentLike) { - return Ident.concrete(module, unspaced(toIdentStrict(ident))) - } - - /** @internal */ - static newAllowingOperators(module: MutableModule, ident: IdentLike) { - return Ident.concrete(module, unspaced(toIdent(ident))) - } - - *concreteChildren(): IterableIterator { - yield this.fields.get('token') - } - - code(): Identifier { - return this.token.code() as Identifier - } -} -export class MutableIdent extends Ident implements MutableAst { - declare readonly module: MutableModule - declare readonly fields: FixedMap - - setToken(ident: IdentLike) { - this.fields.set('token', unspaced(toIdent(ident))) - } - - replaceChild(target: AstId, replacement: Owned) {} - - code(): Identifier { - return this.token.code() - } -} -export interface MutableIdent extends Ident, MutableAst {} -applyMixins(MutableIdent, [MutableAst]) - -type WildcardFields = { - token: NodeChild -} -export class Wildcard extends Ast { - declare fields: FixedMapView - constructor(module: Module, fields: FixedMapView) { - super(module, fields) - } - - get token(): Token { - return this.module.getToken(this.fields.get('token').node) - } - - static concrete(module: MutableModule, token: NodeChild) { - const base = module.baseObject('Wildcard') - const fields = setAll(base, { token }) - return asOwned(new MutableWildcard(module, fields)) - } - - static new(module: MutableModule) { - const token = Token.new('_', RawAst.Token.Type.Wildcard) - return this.concrete(module, unspaced(token)) - } - - *concreteChildren(): IterableIterator { - yield this.fields.get('token') - } -} - -export class MutableWildcard extends Wildcard implements MutableAst { - declare readonly module: MutableModule - declare readonly fields: FixedMap - - replaceChild(target: AstId, replacement: Owned) {} -} -export interface MutableWildcard extends Wildcard, MutableAst {} -applyMixins(MutableWildcard, [MutableAst]) - -export function abstract( - module: MutableModule, - tree: RawAst.Tree, - code: string, -): { root: Owned; spans: SpanMap; toRaw: Map } { - const tokens = new Map() - const nodes = new Map() - const toRaw = new Map() - const root = abstractTree(module, tree, code, nodes, tokens, toRaw).node - const spans = { tokens, nodes } - return { root, spans, toRaw } -} - -function abstractTree( - module: MutableModule, - tree: RawAst.Tree, - code: string, - nodesOut: NodeSpanMap, - tokensOut: TokenSpanMap, - toRaw: Map, -): { whitespace: string | undefined; node: Owned } { - const recurseTree = (tree: RawAst.Tree) => - abstractTree(module, tree, code, nodesOut, tokensOut, toRaw) - const recurseToken = (token: RawAst.Token.Token) => abstractToken(token, code, tokensOut) - const visitChildren = (tree: LazyObject) => { - const children: NodeChild[] = [] - const visitor = (child: LazyObject) => { - if (RawAst.Tree.isInstance(child)) { - children.push(recurseTree(child)) - } else if (RawAst.Token.isInstance(child)) { - children.push(recurseToken(child)) - } else { - child.visitChildren(visitor) - } - } - tree.visitChildren(visitor) - return children - } - const whitespaceStart = tree.whitespaceStartInCodeParsed - const whitespaceEnd = whitespaceStart + tree.whitespaceLengthInCodeParsed - const whitespace = code.substring(whitespaceStart, whitespaceEnd) - const codeStart = whitespaceEnd - const codeEnd = codeStart + tree.childrenLengthInCodeParsed - const spanKey = nodeKey(codeStart, codeEnd - codeStart) - let node: Owned - switch (tree.type) { - case RawAst.Tree.Type.BodyBlock: { - const lines = Array.from(tree.statements, (line) => { - const newline = recurseToken(line.newline) - const expression = line.expression ? recurseTree(line.expression) : undefined - return { newline, expression } - }) - node = BodyBlock.concrete(module, lines) - break - } - case RawAst.Tree.Type.Function: { - const name = recurseTree(tree.name) - const argumentDefinitions = Array.from(tree.args, (arg) => visitChildren(arg)) - const equals = recurseToken(tree.equals) - const body = tree.body !== undefined ? recurseTree(tree.body) : undefined - node = Function.concrete(module, name, argumentDefinitions, equals, body) - break - } - case RawAst.Tree.Type.Ident: { - const token = recurseToken(tree.token) - node = Ident.concrete(module, token) - break - } - case RawAst.Tree.Type.Assignment: { - const pattern = recurseTree(tree.pattern) - const equals = recurseToken(tree.equals) - const value = recurseTree(tree.expr) - node = Assignment.concrete(module, pattern, equals, value) - break - } - case RawAst.Tree.Type.App: { - const func = recurseTree(tree.func) - const arg = recurseTree(tree.arg) - node = App.concrete(module, func, undefined, undefined, arg) - break - } - case RawAst.Tree.Type.NamedApp: { - const func = recurseTree(tree.func) - const open = tree.open ? recurseToken(tree.open) : undefined - const name = recurseToken(tree.name) - const equals = recurseToken(tree.equals) - const arg = recurseTree(tree.arg) - const close = tree.close ? recurseToken(tree.close) : undefined - const parens = open && close ? { open, close } : undefined - const nameSpecification = { name, equals } - node = App.concrete(module, func, parens, nameSpecification, arg) - break - } - case RawAst.Tree.Type.UnaryOprApp: { - const opr = recurseToken(tree.opr) - const arg = tree.rhs ? recurseTree(tree.rhs) : undefined - if (arg && opr.node.code() === '-') { - node = NegationApp.concrete(module, opr, arg) - } else { - node = UnaryOprApp.concrete(module, opr, arg) - } - break - } - case RawAst.Tree.Type.OprApp: { - const lhs = tree.lhs ? recurseTree(tree.lhs) : undefined - const opr = tree.opr.ok - ? [recurseToken(tree.opr.value)] - : Array.from(tree.opr.error.payload.operators, recurseToken) - const rhs = tree.rhs ? recurseTree(tree.rhs) : undefined - if (opr.length === 1 && opr[0]?.node.code() === '.' && rhs?.node instanceof MutableIdent) { - // Propagate type. - const rhs_ = { ...rhs, node: rhs.node } - node = PropertyAccess.concrete(module, lhs, opr[0], rhs_) - } else { - node = OprApp.concrete(module, lhs, opr, rhs) - } - break - } - case RawAst.Tree.Type.Number: { - const tokens = [] - if (tree.base) tokens.push(recurseToken(tree.base)) - if (tree.integer) tokens.push(recurseToken(tree.integer)) - if (tree.fractionalDigits) { - tokens.push(recurseToken(tree.fractionalDigits.dot)) - tokens.push(recurseToken(tree.fractionalDigits.digits)) - } - node = NumericLiteral.concrete(module, tokens) - break - } - case RawAst.Tree.Type.Wildcard: { - const token = recurseToken(tree.token) - node = Wildcard.concrete(module, token) - break - } - // These expression types are (or will be) used for backend analysis. - // The frontend can ignore them, avoiding some problems with expressions sharing spans - // (which makes it impossible to give them unique IDs in the current IdMap format). - case RawAst.Tree.Type.OprSectionBoundary: - case RawAst.Tree.Type.TemplateFunction: - return { whitespace, node: recurseTree(tree.ast).node } - case RawAst.Tree.Type.Invalid: { - const expression = recurseTree(tree.ast) - node = Invalid.concrete(module, expression) - break - } - case RawAst.Tree.Type.Group: { - const open = tree.open ? recurseToken(tree.open) : undefined - const expression = tree.body ? recurseTree(tree.body) : undefined - const close = tree.close ? recurseToken(tree.close) : undefined - node = Group.concrete(module, open, expression, close) - break - } - case RawAst.Tree.Type.TextLiteral: { - const open = tree.open ? recurseToken(tree.open) : undefined - const newline = tree.newline ? recurseToken(tree.newline) : undefined - const elements = [] - for (const e of tree.elements) { - elements.push(...visitChildren(e)) - } - const close = tree.close ? recurseToken(tree.close) : undefined - node = TextLiteral.concrete(module, open, newline, elements, close) - break - } - case RawAst.Tree.Type.Documented: { - const open = recurseToken(tree.documentation.open) - const elements = [] - for (const e of tree.documentation.elements) { - elements.push(...visitChildren(e)) - } - const newlines = Array.from(tree.documentation.newlines, recurseToken) - const expression = tree.expression ? recurseTree(tree.expression) : undefined - node = Documented.concrete(module, open, elements, newlines, expression) - break - } - case RawAst.Tree.Type.Import: { - const recurseBody = (tree: RawAst.Tree) => { - const body = recurseTree(tree) - if (body.node instanceof Invalid && body.node.code() === '') return undefined - return body - } - const recurseSegment = (segment: RawAst.MultiSegmentAppSegment) => ({ - header: recurseToken(segment.header), - body: segment.body ? recurseBody(segment.body) : undefined, - }) - const polyglot = tree.polyglot ? recurseSegment(tree.polyglot) : undefined - const from = tree.from ? recurseSegment(tree.from) : undefined - const import_ = recurseSegment(tree.import) - const all = tree.all ? recurseToken(tree.all) : undefined - const as = tree.as ? recurseSegment(tree.as) : undefined - const hiding = tree.hiding ? recurseSegment(tree.hiding) : undefined - node = Import.concrete(module, polyglot, from, import_, all, as, hiding) - break - } - default: { - node = Generic.concrete(module, visitChildren(tree)) - } - } - toRaw.set(node.id, tree) - map.setIfUndefined(nodesOut, spanKey, (): Ast[] => []).push(node) - return { node, whitespace } -} - -function abstractToken( - token: RawAst.Token, - code: string, - tokensOut: TokenSpanMap, -): { whitespace: string; node: Token } { - const whitespaceStart = token.whitespaceStartInCodeBuffer - const whitespaceEnd = whitespaceStart + token.whitespaceLengthInCodeBuffer - const whitespace = code.substring(whitespaceStart, whitespaceEnd) - const codeStart = token.startInCodeBuffer - const codeEnd = codeStart + token.lengthInCodeBuffer - const tokenCode = code.substring(codeStart, codeEnd) - const key = tokenKey(codeStart, codeEnd - codeStart) - const node = Token.new(tokenCode, token.type) - tokensOut.set(key, node) - return { whitespace, node } -} - -declare const nodeKeyBrand: unique symbol -export type NodeKey = SourceRangeKey & { [nodeKeyBrand]: never } -declare const tokenKeyBrand: unique symbol -export type TokenKey = SourceRangeKey & { [tokenKeyBrand]: never } -function nodeKey(start: number, length: number): NodeKey { - return sourceRangeKey([start, start + length]) as NodeKey -} -function tokenKey(start: number, length: number): TokenKey { - return sourceRangeKey([start, start + length]) as TokenKey -} - -type NodeSpanMap = Map -type TokenSpanMap = Map - -interface SpanMap { - nodes: NodeSpanMap - tokens: TokenSpanMap -} - -interface PrintedSource { - info: SpanMap - code: string -} - -export function spanMapToIdMap(spans: SpanMap): IdMap { - const idMap = new IdMap() - for (const [key, token] of spans.tokens.entries()) { - assert(isUuid(token.id)) - idMap.insertKnownId(sourceRangeFromKey(key), token.id) - } - for (const [key, asts] of spans.nodes.entries()) { - for (const ast of asts) { - assert(isUuid(ast.externalId)) - idMap.insertKnownId(sourceRangeFromKey(key), ast.externalId) - } - } - return idMap -} - -function spanMapToSpanGetter(spans: SpanMap): (id: AstId) => SourceRange | undefined { - const reverseMap = new Map() - for (const [key, asts] of spans.nodes) { - for (const ast of asts) { - reverseMap.set(ast.id, sourceRangeFromKey(key)) - } - } - return (id) => reverseMap.get(id) -} - -/** Return stringification with associated ID map. This is only exported for testing. */ -export function print(ast: Ast): PrintedSource { - const info: SpanMap = { - nodes: new Map(), - tokens: new Map(), - } - const code = ast.printSubtree(info, 0, undefined) - return { info, code } -} - -/** Parse the input as a block. */ -export function parseBlock(code: string, inModule?: MutableModule) { - return parseBlockWithSpans(code, inModule).root -} - -/** Parse the input. If it contains a single expression at the top level, return it; otherwise, return a block. */ -export function parse(code: string, module?: MutableModule): Owned { - const module_ = module ?? MutableModule.Transient() - const ast = parseBlock(code, module_) - const [expr] = ast.statements() - if (!expr) return ast - const parent = parentId(expr) - if (parent) module_.delete(parent) - expr.fields.set('parent', undefined) - return asOwned(expr) -} - -export function parseBlockWithSpans( - code: string, - inModule?: MutableModule, -): { root: Owned; spans: SpanMap } { - const tree = parseEnso(code) - const module = inModule ?? MutableModule.Transient() - return fromRaw(tree, code, module) -} - -function fromRaw( - tree: RawAst.Tree, - code: string, - inModule?: MutableModule, -): { - root: Owned - spans: SpanMap - toRaw: Map -} { - const module = inModule ?? MutableModule.Transient() - const ast = abstract(module, tree, code) - const spans = ast.spans - // The root of the tree produced by the parser is always a `BodyBlock`. - const root = ast.root as Owned - return { root, spans, toRaw: ast.toRaw } -} - -export function parseExtended(code: string, idMap?: IdMap | undefined, inModule?: MutableModule) { - const rawRoot = parseEnso(code) - const module = inModule ?? MutableModule.Transient() - const { root, spans, toRaw, idMapUpdates } = module.ydoc.transact(() => { - const { root, spans, toRaw } = fromRaw(rawRoot, code, module) - root.module.replaceRoot(root) - const idMapUpdates = idMap ? setExternalIds(root.module, spans, idMap) : 0 - return { root, spans, toRaw, idMapUpdates } - }) - const getSpan = spanMapToSpanGetter(spans) - const idMapOut = spanMapToIdMap(spans) - return { root, idMap: idMapOut, getSpan, toRaw, idMapUpdates } -} - -export function setExternalIds(module: MutableModule, spans: SpanMap, ids: IdMap) { - let astsMatched = 0 - let idsUnmatched = 0 - let asts = 0 - module.root()?.visitRecursiveAst((_ast) => (asts += 1)) - for (const [key, externalId] of ids.entries()) { - const asts = spans.nodes.get(key as NodeKey) - if (asts) { - for (const ast of asts) { - astsMatched += 1 - module.getVersion(ast).setExternalId(externalId) - } - } else { - idsUnmatched += 1 - } - } - if (DEBUG) - console.info( - `asts=${asts}, astsMatched=${astsMatched}, idsUnmatched=${idsUnmatched}, haveRoot=${!!module.root()}`, - ) - return module.root() ? asts - astsMatched : 0 -} - -///////////////// UI extensions //////////////////////// + Ast, + BodyBlock, + Function, + MutableBodyBlock, + MutableModule, + ROOT_ID, + Token, + abstract, + isTokenId, + materialize, + print, +} from 'shared/ast' +import { sourceRangeFromKey, type SourceRange } from 'shared/yjsModel' +import { reactive } from 'vue' +export * from 'shared/ast' export class ReactiveModule implements Module { edit(): MutableModule { @@ -2796,26 +47,52 @@ export class ReactiveModule implements Module { ///////////////////////////////// private readonly ymodule: MutableModule - private readonly nodes: Map> - private readonly spans: Map + private readonly nodes: Map> = reactive(new Map()) + private readonly spans: Map = reactive(new Map()) + private readonly updateHooks: UpdateHandler[] = [] - constructor(base: MutableModule) { + constructor(base: MutableModule, updateHooks?: UpdateHandler[]) { this.ymodule = base - this.nodes = reactive(new Map()) - this.spans = reactive(new Map()) - base.observe((update) => { - for (const id of update.addNodes) this.nodes.set(id, new Map() as any) - for (const id of update.deleteNodes) this.nodes.delete(id) - for (const { id, fields } of update.updateNodes) { - const node = this.nodes.get(id) - assertDefined(node) - for (const [key, value] of fields) { - const node_ = node as unknown as Map - node_.set(key, value) + this.updateHooks = [...(updateHooks ?? [])] + // Attach the observer first, so that if an update hook causes changes in `base` in reaction to the initial state + // update, we won't miss them. + base.observe(this.handleUpdate.bind(this)) + this.handleUpdate(base.getStateAsUpdate()) + } + + disconnect() { + this.updateHooks.length = 0 + } + + private handleUpdate(update: ModuleUpdate) { + for (const id of update.nodesAdded) this.nodes.set(id, new Map() as any) + for (const id of update.nodesDeleted) this.nodes.delete(id) + for (const { id, fields } of update.fieldsUpdated) { + const node = this.nodes.get(id) + assertDefined(node) + for (const [key, value] of fields) { + const node_ = node as unknown as Map + node_.set(key, value) + } + } + for (const { id, changes } of update.metadataUpdated) { + const node = this.nodes.get(id) + assertDefined(node) + if (!node.has('metadata')) + (node as unknown as Map).set('metadata', new Map()) + const metadata = node.get('metadata') as unknown as Map + for (const [key, value] of changes) { + if (value === undefined) { + metadata.delete(key) + } else { + metadata.set(key, value) } } - this.rebuildSpans(update.deleteNodes) - }) + } + this.rebuildSpans(update.nodesDeleted) + const dirtyNodes = new Set() + for (const { id } of update.fieldsUpdated) dirtyNodes.add(id) + for (const hook of this.updateHooks) hook(this, dirtyNodes, update.metadataUpdated) } private rebuildSpans(deleted: AstId[]) { @@ -2865,46 +142,71 @@ export class ReactiveModule implements Module { } } -function materialize(module: Module, fields: FixedMapView): Ast { - const type = fields.get('type') - const fields_ = fields as FixedMapView - switch (type) { - case 'App': - return new App(module, fields_) - case 'UnaryOprApp': - return new UnaryOprApp(module, fields_) - case 'NegationApp': - return new NegationApp(module, fields_) - case 'OprApp': - return new OprApp(module, fields_) - case 'PropertyAccess': - return new PropertyAccess(module, fields_) - case 'Generic': - return new Generic(module, fields_) - case 'Import': - return new Import(module, fields_) - case 'TextLiteral': - return new TextLiteral(module, fields_) - case 'Documented': - return new Documented(module, fields_) - case 'Invalid': - return new Invalid(module, fields_) - case 'Group': - return new Group(module, fields_) - case 'NumericLiteral': - return new NumericLiteral(module, fields_) - case 'Function': - return new Function(module, fields_) - case 'Assignment': - return new Assignment(module, fields_) - case 'BodyBlock': - return new BodyBlock(module, fields_) - case 'Ident': - return new Ident(module, fields_) - case 'Wildcard': - return new Wildcard(module, fields_) +export interface SetView { + readonly size: number + has(key: Key): boolean + [Symbol.iterator](): IterableIterator +} + +type UpdateHandler = ( + module: ReactiveModule, + dirtyNodes: SetView, + metadataChanges: { id: AstId; changes: Map }[], +) => void + +export class EmptyModule implements Module { + edit(): never { + bail(`EmptyModule cannot be edited.`) } - bail(`Invalid type: ${type}`) + root(): undefined { + return + } + get(_id: AstId | undefined): undefined { + return + } + checkedGet(id: AstId): never + checkedGet(id: AstId | undefined): undefined { + if (id) bail(`${id} is not in an EmptyModule.`) + } + getToken(token: SyncTokenId): never + getToken(token: SyncTokenId | undefined): undefined { + if (token) bail(`EmptyModule contains no tokens.`) + return + } + getAny(node: AstId | SyncTokenId): never { + bail(`EmptyModule does not contain ${node}.`) + } + has(_id: AstId): false { + return false + } + getSpan(_id: AstId): undefined { + return + } +} + +const mapping: Record = { + '\b': '\\b', + '\f': '\\f', + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', + '\v': '\\v', + '"': '\\"', + "'": "\\'", + '`': '``', +} + +const reverseMapping = swapKeysAndValues(mapping) + +/** 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]!) +} + +/** The reverse of `escape`: transform the string into human-readable form, not suitable for interpolation. */ +export function unescape(string: string) { + return string.replace(/\\[0bfnrtv"']|``/g, (match) => reverseMapping[match]!) } export function deserialize(serialized: string): Owned { @@ -2994,9 +296,9 @@ export function deleteFromParentBlock(ast: MutableAst) { parent.updateLines((lines) => lines.filter((line) => line.expression?.node.id !== ast.id)) } -declare const TokenKey: unique symbol +declare const tokenKey: unique symbol declare module '@/providers/widgetRegistry' { export interface WidgetInputTypes { - [TokenKey]: Token + [tokenKey]: Token } } diff --git a/app/gui2/src/util/ast/aliasAnalysis.ts b/app/gui2/src/util/ast/aliasAnalysis.ts index 7fb7e80a1a..0353048dac 100644 --- a/app/gui2/src/util/ast/aliasAnalysis.ts +++ b/app/gui2/src/util/ast/aliasAnalysis.ts @@ -8,9 +8,12 @@ import { readTokenSpan, } from '@/util/ast' import { MappedKeyMap, MappedSet, NonEmptyStack } from '@/util/containers' -import type { LazyObject } from '@/util/parserSupport' +import { initializeFFI } from 'shared/ast/ffi' +import type { LazyObject } from 'shared/ast/parserSupport' import { rangeIsBefore, sourceRangeKey, type SourceRange } from 'shared/yjsModel' +await initializeFFI() + const ACCESSOR_OPERATOR = '.' const LAMBDA_OPERATOR = '->' diff --git a/app/gui2/src/util/ast/extended.ts b/app/gui2/src/util/ast/extended.ts index a62c59e177..a3918648e0 100644 --- a/app/gui2/src/util/ast/extended.ts +++ b/app/gui2/src/util/ast/extended.ts @@ -1,5 +1,3 @@ -import * as Ast from '@/generated/ast' -import { Token, Tree } from '@/generated/ast' import { assert } from '@/util/assert' import { childrenAstNodesOrTokens, @@ -14,6 +12,8 @@ import type { Opt } from '@/util/data/opt' import * as encoding from 'lib0/encoding' import * as sha256 from 'lib0/hash/sha256' import * as map from 'lib0/map' +import * as Ast from 'shared/ast/generated/ast' +import { Token, Tree } from 'shared/ast/generated/ast' import type { ExternalId, IdMap, SourceRange } from 'shared/yjsModel' import { markRaw } from 'vue' diff --git a/app/gui2/src/util/ast/index.ts b/app/gui2/src/util/ast/index.ts index 7a8c858fee..f46cb995f3 100644 --- a/app/gui2/src/util/ast/index.ts +++ b/app/gui2/src/util/ast/index.ts @@ -1,28 +1,23 @@ -import * as RawAst from '@/generated/ast' import { assert } from '@/util/assert' import * as Ast from '@/util/ast/abstract' +import { parseEnso } from '@/util/ast/abstract' import { AstExtended as RawAstExtended } from '@/util/ast/extended' import { isResult, mapOk } from '@/util/data/result' -import { parse } from '@/util/ffi' -import { LazyObject, LazySequence } from '@/util/parserSupport' import * as map from 'lib0/map' +import * as RawAst from 'shared/ast/generated/ast' +import { LazyObject, LazySequence } from 'shared/ast/parserSupport' import type { SourceRange } from 'shared/yjsModel' -export { Ast, RawAst, RawAstExtended } +export { Ast, RawAst, RawAstExtended, parseEnso } export type HasAstRange = SourceRange | RawAst.Tree | RawAst.Token -export function parseEnso(code: string): RawAst.Tree { - const blob = parse(code) - return RawAst.Tree.read(new DataView(blob.buffer), blob.byteLength - 4) -} - /** Read a single line of code * * Is meant to be a helper for tests. If the code is multiline, an exception is raised. */ export function parseEnsoLine(code: string): RawAst.Tree { - const block = parseEnso(code) + const block = Ast.parseEnso(code) assert(block.type === RawAst.Tree.Type.BodyBlock) const statements = block.statements[Symbol.iterator]() const firstLine = statements.next() diff --git a/app/gui2/src/util/ast/opr.ts b/app/gui2/src/util/ast/opr.ts index c9de9f4808..e0664ed0b1 100644 --- a/app/gui2/src/util/ast/opr.ts +++ b/app/gui2/src/util/ast/opr.ts @@ -155,6 +155,9 @@ export function* operandsOfLeftAssocOprChain( if (import.meta.vitest) { const { test, expect } = import.meta.vitest + const { initializeFFI } = await import('shared/ast/ffi') + + await initializeFFI() test.each([ { code: '2 + 3', result: ['2', '+', '3'] }, diff --git a/app/gui2/src/util/config.ts b/app/gui2/src/util/config.ts index a9349380e8..789f5c670c 100644 --- a/app/gui2/src/util/config.ts +++ b/app/gui2/src/util/config.ts @@ -143,9 +143,12 @@ export type ConfigValue = Config> = ConfigOptionValues ConfigGroupValues export function configValue>(config: T): ConfigValue { + // The object may be undefined if we pass an incomplete `ApplicationConfigValue` to `provideGuiConfig._mock`. + const options = config.options ?? {} + const groups = config.groups ?? {} return Object.fromEntries([ - ...Object.entries(config.options).map(([k, v]) => [k, optionValue(v as Option)]), - ...Object.entries(config.groups).map(([k, v]) => [k, groupValue(v as Group)]), + ...Object.entries(options).map(([k, v]) => [k, optionValue(v as Option)]), + ...Object.entries(groups).map(([k, v]) => [k, groupValue(v as Group)]), ]) satisfies ConfigValue as any } diff --git a/app/gui2/src/util/data/opt.ts b/app/gui2/src/util/data/opt.ts index 968c03b25b..764aac2374 100644 --- a/app/gui2/src/util/data/opt.ts +++ b/app/gui2/src/util/data/opt.ts @@ -1,25 +1 @@ -/** @file A value that may be `null` or `undefined`. */ - -/** Optional value type. This is a replacement for `T | null | undefined` that is more - * convenient to use. We do not select a single value to represent "no value", because we are using - * libraries that disagree whether `null` (e.g. Yjs) or `undefined` (e.g. Vue) should be used for - * that purpose. We want to be compatible with both without needless conversions. In our own code, - * we should return `undefined` for "no value", since that is the default value for empty or no - * `return` expression. In order to test whether an `Opt` is defined or not, use `x == null` or - * `isSome` function. - * - * Note: For JSON-serialized data, prefer explicit `null` over `undefined`, since `undefined` is - * not serializable. Alternatively, use optional field syntax (e.g. `{ x?: number }`). */ -export type Opt = T | null | undefined - -export function isSome(value: Opt): value is T { - return value != null -} - -export function isNone(value: Opt): value is null | undefined { - return value == null -} - -export function mapOr(optional: Opt, fallback: R, mapper: (value: T) => R): R { - return isSome(optional) ? mapper(optional) : fallback -} +export * from 'shared/util/data/opt' diff --git a/app/gui2/src/util/data/result.ts b/app/gui2/src/util/data/result.ts index 70869cfef6..e1cd8123ef 100644 --- a/app/gui2/src/util/data/result.ts +++ b/app/gui2/src/util/data/result.ts @@ -1,85 +1 @@ -/** @file A generic type that can either hold a value representing a successful result, - * or an error. */ - -import { isSome, type Opt } from '@/util/data/opt' - -export type Result = - | { ok: true; value: T } - | { ok: false; error: ResultError } - -export function Ok(data: T): Result { - return { ok: true, value: data } -} - -export function Err(error: E): Result { - return { ok: false, error: new ResultError(error) } -} - -export function okOr(data: Opt, error: E): Result { - if (isSome(data)) return Ok(data) - else return Err(error) -} - -export function unwrap(result: Result): T { - if (result.ok) return result.value - else throw result.error -} - -export function mapOk(result: Result, f: (value: T) => U): Result { - if (result.ok) return Ok(f(result.value)) - else return result -} - -export function isResult(v: unknown): v is Result { - return ( - v != null && - typeof v === 'object' && - 'ok' in v && - typeof v.ok === 'boolean' && - ('value' in v || ('error' in v && v.error instanceof ResultError)) - ) -} - -export class ResultError { - payload: E - context: (() => string)[] - - constructor(payload: E) { - this.payload = payload - this.context = [] - } - - log(preamble: string = 'Error') { - const ctx = - this.context.length > 0 ? `\n${Array.from(this.context, (ctx) => ctx()).join('\n')}` : '' - console.error(`${preamble}: ${this.payload}${ctx}`) - } -} - -export function withContext(context: () => string, f: () => Result): Result { - const result = f() - if (result == null) { - throw new Error('withContext: f() returned null or undefined') - } - if (!result.ok) result.error.context.splice(0, 0, context) - return result -} - -/** - * Catch promise rejection of provided types and convert them to a Result type. - */ -export function rejectionToResult any>( - errorKinds: ErrorKind | ErrorKind[], -): (promise: Promise) => Promise>> { - const errorKindArray = Array.isArray(errorKinds) ? errorKinds : [errorKinds] - return async (promise) => { - try { - return Ok(await promise) - } catch (error) { - for (const errorKind of errorKindArray) { - if (error instanceof errorKind) return Err(error) - } - throw error - } - } -} +export * from 'shared/util/data/result' diff --git a/app/gui2/src/util/docParser.ts b/app/gui2/src/util/docParser.ts index cfaf46f64f..bf30dc0121 100644 --- a/app/gui2/src/util/docParser.ts +++ b/app/gui2/src/util/docParser.ts @@ -1,4 +1,4 @@ -import { parse_doc_to_json } from '@/util/ffi' +import { parse_doc_to_json } from 'shared/ast/ffi' export function parseDocs(docs: string): Doc.Section[] { const json = parse_doc_to_json(docs) diff --git a/app/gui2/src/util/ffi.ts b/app/gui2/src/util/ffi.ts deleted file mode 100644 index 79ece109b8..0000000000 --- a/app/gui2/src/util/ffi.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { isNode } from '@/util/detect' -import init, { is_ident_or_operator, parse, parse_doc_to_json } from 'rust-ffi/pkg/rust_ffi' - -if (isNode) { - const fs = await import('node:fs/promises') - const buffer = await fs.readFile('./rust-ffi/pkg/rust_ffi_bg.wasm') - await init(buffer) -} else { - await init() -} - -// eslint-disable-next-line camelcase -export { is_ident_or_operator, parse, parse_doc_to_json } diff --git a/app/gui2/src/util/qualifiedName.ts b/app/gui2/src/util/qualifiedName.ts index f534d4fa58..4667a971e9 100644 --- a/app/gui2/src/util/qualifiedName.ts +++ b/app/gui2/src/util/qualifiedName.ts @@ -100,6 +100,8 @@ export function qnIsTopElement(name: QualifiedName): boolean { if (import.meta.vitest) { const { test, expect } = import.meta.vitest + const { initializeFFI } = await import('shared/ast/ffi') + await initializeFFI() const validIdentifiers = [ 'A', diff --git a/app/gui2/stories/MockProjectStoreWrapper.vue b/app/gui2/stories/MockProjectStoreWrapper.vue index 61b8dc58c6..3ddd19a8c2 100644 --- a/app/gui2/stories/MockProjectStoreWrapper.vue +++ b/app/gui2/stories/MockProjectStoreWrapper.vue @@ -1,7 +1,7 @@