import * as random from 'lib0/random' import * as Y from 'yjs' import type { AstId, NodeChild, Owned, RawNodeChild, SyncTokenId } from '.' import { Token, asOwned, isTokenId, newExternalId, subtreeRoots } from '.' import { assert, assertDefined } from '../util/assert' import type { SourceRangeEdit } from '../util/data/text' import { defaultLocalOrigin, tryAsOrigin, type ExternalId, type Origin } from '../yjsModel' import type { AstFields, FixedMap, Mutable } from './tree' import { Ast, MutableAst, MutableInvalid, Wildcard, composeFieldData, invalidFields, materializeMutable, setAll, } from './tree' export interface Module { edit(): MutableModule root(): Ast | undefined tryGet(id: AstId | undefined): Ast | undefined ///////////////////////////////// /** Return the specified AST. Throws an exception if no AST with the provided ID was found. */ get(id: AstId): Ast get(id: AstId | undefined): Ast | undefined getToken(token: SyncTokenId): Token getToken(token: SyncTokenId | undefined): Token | undefined getAny(node: AstId | SyncTokenId): Ast | Token getConcrete(child: RawNodeChild): NodeChild | NodeChild has(id: AstId): boolean } export interface ModuleUpdate { nodesAdded: Set nodesDeleted: Set nodesUpdated: Set updateRoots: Set metadataUpdated: { id: AstId; changes: Map }[] origin: Origin | undefined } type YNode = FixedMap type YNodes = Y.Map export class MutableModule implements Module { private readonly nodes: YNodes private 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.get(ast.id) return instance as Mutable } edit(): MutableModule { const doc = new Y.Doc() Y.applyUpdateV2(doc, Y.encodeStateAsUpdateV2(this.ydoc)) return new MutableModule(doc) } applyEdit(edit: MutableModule, origin: Origin = defaultLocalOrigin) { Y.applyUpdateV2(this.ydoc, Y.encodeStateAsUpdateV2(edit.ydoc), origin) } transact(f: () => T, origin: Origin = defaultLocalOrigin): T { return this.ydoc.transact(f, origin) } 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() } syncToCode(code: string) { const root = this.root() if (root) { root.syncToCode(code) } else { this.replaceRoot(Ast.parse(code, this)) } } /** Update the module according to changes to its corresponding source code. */ applyTextEdits(textEdits: SourceRangeEdit[], metadataSource?: Module) { const root = this.root() assertDefined(root) root.applyTextEdits(textEdits, metadataSource) } 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) { const handle = (events: Y.YEvent[], transaction: Y.Transaction) => { observer(this.observeEvents(events, tryAsOrigin(transaction.origin))) } // Attach the observer first, so that if an update hook causes changes in reaction to the initial state update, we // won't miss them. this.nodes.observeDeep(handle) observer(this.getStateAsUpdate()) return handle } unobserve(handle: ReturnType) { this.nodes.unobserveDeep(handle) } getStateAsUpdate(): ModuleUpdate { const updateBuilder = new UpdateBuilder(this, this.nodes, undefined) for (const id of this.nodes.keys()) updateBuilder.addNode(id as AstId) return updateBuilder.finish() } applyUpdate(update: Uint8Array, origin: Origin): ModuleUpdate | undefined { let summary: ModuleUpdate | undefined const observer = (events: Y.YEvent[]) => { summary = this.observeEvents(events, origin) } this.nodes.observeDeep(observer) Y.applyUpdate(this.ydoc, update, origin) this.nodes.unobserveDeep(observer) return summary } private observeEvents(events: Y.YEvent[], origin: Origin | undefined): ModuleUpdate { const updateBuilder = new UpdateBuilder(this, this.nodes, origin) 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.finish() } clear() { this.nodes.clear() } get(id: AstId): Mutable get(id: AstId | undefined): Mutable | undefined get(id: AstId | undefined): Mutable | undefined { if (!id) return undefined const ast = this.tryGet(id) assert(ast !== undefined, 'id in module') return ast } tryGet(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.tryGet(id)?.replace(value) } replaceValue(id: AstId, value: Owned): Owned | undefined { return this.tryGet(id)?.replaceValue(value) } take(id: AstId): Owned { return this.replace(id, Wildcard.new(this)) || asOwned(this.get(id)) } updateValue(id: AstId, f: (x: Owned) => Owned): T | undefined { return this.tryGet(id)?.updateValue(f) } ///////////////////////////////////////////// constructor(doc: Y.Doc) { this.nodes = doc.getMap('nodes') } private rootPointer(): MutableRootPointer | undefined { const rootPointer = this.tryGet(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, }) const fieldObject = composeFieldData(fields, {}) this.nodes.set(id, fieldObject) return fieldObject } /** @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.get(node) } getConcrete(child: RawNodeChild): NodeChild | NodeChild { if (isTokenId(child.node)) return { whitespace: child.whitespace, node: this.getToken(child.node) } else return { whitespace: child.whitespace, node: this.get(child.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 } 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 { readonly nodesAdded = new Set() readonly nodesDeleted = new Set() readonly nodesUpdated = new Set() readonly metadataUpdated: { id: AstId; changes: Map }[] = [] readonly origin: Origin | undefined private readonly module: Module private readonly nodes: YNodes constructor(module: Module, nodes: YNodes, origin: Origin | undefined) { this.module = module this.nodes = nodes this.origin = origin } addNode(id: AstId) { this.nodesAdded.add(id) } updateAllFields(id: AstId) { this.updateFields(id, this.nodes.get(id)!.entries()) } updateFields(id: AstId, changes: Iterable) { let fieldsChanged = false 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)) fieldsChanged = true } } if (fieldsChanged) this.nodesUpdated.add(id) 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.add(id) } finish(): ModuleUpdate { const dirtyNodes = new Set(this.nodesUpdated) this.nodesAdded.forEach((node) => dirtyNodes.add(node)) const updateRoots = subtreeRoots(this.module, dirtyNodes) return { ...this, updateRoots } } }