From d6119391321322cf22606fea39964c0cb7ec9e08 Mon Sep 17 00:00:00 2001 From: Kaz Wesley Date: Thu, 21 Nov 2024 15:42:20 -0800 Subject: [PATCH] New alias analysis API (#11621) Higher level API will be compatible with a future implementation that doesn't depend on `RawAst` (see: #10753). --- .../GraphEditor/__tests__/collapsing.test.ts | 12 +- .../graph/__tests__/graphDatabase.test.ts | 12 +- .../stores/graph/graphDatabase.ts | 154 ++++-------------- .../src/project-view/stores/graph/index.ts | 31 +--- app/gui/src/project-view/util/ast/abstract.ts | 8 +- app/gui/src/project-view/util/ast/bindings.ts | 92 +++++++++++ app/ydoc-shared/src/ast/parse.ts | 14 +- app/ydoc-shared/src/ast/sourceDocument.ts | 65 ++++---- app/ydoc-shared/src/util/data/text.ts | 10 +- 9 files changed, 190 insertions(+), 208 deletions(-) create mode 100644 app/gui/src/project-view/util/ast/bindings.ts diff --git a/app/gui/src/project-view/components/GraphEditor/__tests__/collapsing.test.ts b/app/gui/src/project-view/components/GraphEditor/__tests__/collapsing.test.ts index 8dd6de9f5cb9..89fc1c3b66a3 100644 --- a/app/gui/src/project-view/components/GraphEditor/__tests__/collapsing.test.ts +++ b/app/gui/src/project-view/components/GraphEditor/__tests__/collapsing.test.ts @@ -1,10 +1,11 @@ import { performCollapseImpl, prepareCollapsedInfo } from '@/components/GraphEditor/collapsing' import { GraphDb, type NodeId } from '@/stores/graph/graphDatabase' import { assert } from '@/util/assert' -import { Ast, RawAst } from '@/util/ast' +import { Ast } from '@/util/ast' import { findExpressions } from '@/util/ast/__tests__/testCase' import { unwrap } from '@/util/data/result' import { tryIdentifier } from '@/util/qualifiedName' +import * as iter from 'enso-common/src/utilities/data/iter' import { expect, test } from 'vitest' import { watchEffect } from 'vue' import { Identifier } from 'ydoc-shared/ast' @@ -15,15 +16,12 @@ import { nodeIdFromOuterAst } from '../../../stores/graph/graphDatabase' // =============================== function setupGraphDb(code: string, graphDb: GraphDb) { - const { root, toRaw, getSpan } = Ast.parseUpdatingIdMap(code) - const expressions = Array.from(root.statements()) - const func = expressions[0] + const { root, getSpan } = Ast.parseUpdatingIdMap(code) + const func = iter.first(root.statements()) assert(func instanceof Ast.FunctionDef) - const rawFunc = toRaw.get(func.id) - assert(rawFunc?.type === RawAst.Tree.Type.Function) graphDb.updateExternalIds(root) graphDb.updateNodes(func, { watchEffect }) - graphDb.updateBindings(func, rawFunc, code, getSpan) + graphDb.updateBindings(func, { text: code, getSpan }) } interface TestCase { diff --git a/app/gui/src/project-view/stores/graph/__tests__/graphDatabase.test.ts b/app/gui/src/project-view/stores/graph/__tests__/graphDatabase.test.ts index 3783689ed8fd..d0a436bcb6c4 100644 --- a/app/gui/src/project-view/stores/graph/__tests__/graphDatabase.test.ts +++ b/app/gui/src/project-view/stores/graph/__tests__/graphDatabase.test.ts @@ -1,5 +1,5 @@ import { asNodeId, GraphDb } from '@/stores/graph/graphDatabase' -import { Ast, RawAst } from '@/util/ast' +import { Ast } from '@/util/ast' import assert from 'assert' import { expect, test } from 'vitest' import { watchEffect } from 'vue' @@ -23,14 +23,14 @@ export function parseWithSpans>(code: stri idMap.insertKnownId(span, eid) } - const { root: ast, toRaw, getSpan } = Ast.parseUpdatingIdMap(code, idMap) + const { root: ast, getSpan } = Ast.parseUpdatingIdMap(code, idMap) const idFromExternal = new Map() ast.visitRecursive((ast) => { idFromExternal.set(ast.externalId, ast.id) }) const id = (name: keyof T) => idFromExternal.get(eid(name))! - return { ast, id, eid, toRaw, getSpan } + return { ast, id, eid, getSpan } } test('Reading graph from definition', () => { @@ -53,17 +53,15 @@ test('Reading graph from definition', () => { node3Content: [65, 74] as [number, number], } - const { ast, id, eid, toRaw, getSpan } = parseWithSpans(code, spans) + const { ast, id, eid, getSpan } = parseWithSpans(code, spans) const db = GraphDb.Mock() const expressions = Array.from(ast.statements()) const func = expressions[0] assert(func instanceof Ast.FunctionDef) - const rawFunc = toRaw.get(func.id) - assert(rawFunc?.type === RawAst.Tree.Type.Function) db.updateExternalIds(ast) db.updateNodes(func, { watchEffect }) - db.updateBindings(func, rawFunc, code, getSpan) + db.updateBindings(func, { text: code, getSpan }) expect(Array.from(db.nodeIdToNode.keys())).toEqual([ eid('parameter'), diff --git a/app/gui/src/project-view/stores/graph/graphDatabase.ts b/app/gui/src/project-view/stores/graph/graphDatabase.ts index 5e119892972a..dfac42f876ef 100644 --- a/app/gui/src/project-view/stores/graph/graphDatabase.ts +++ b/app/gui/src/project-view/stores/graph/graphDatabase.ts @@ -2,13 +2,11 @@ import { computeNodeColor } from '@/composables/nodeColors' import { ComputedValueRegistry, type ExpressionInfo } from '@/stores/project/computedValueRegistry' import { SuggestionDb, type Group } from '@/stores/suggestionDatabase' import type { SuggestionEntry } from '@/stores/suggestionDatabase/entry' -import { assert } from '@/util/assert' -import { Ast, RawAst } from '@/util/ast' +import { Ast } from '@/util/ast' import type { AstId, NodeMetadata } from '@/util/ast/abstract' import { MutableModule } from '@/util/ast/abstract' -import { AliasAnalyzer } from '@/util/ast/aliasAnalysis' +import { analyzeBindings, type BindingInfo } from '@/util/ast/bindings' import { inputNodeFromAst, nodeFromAst, nodeRootExpr } from '@/util/ast/node' -import { MappedKeyMap, MappedSet } from '@/util/containers' import { tryGetIndex } from '@/util/data/array' import { recordEqual } from '@/util/data/object' import { unwrap } from '@/util/data/result' @@ -24,10 +22,11 @@ import { import * as objects from 'enso-common/src/utilities/data/object' import * as set from 'lib0/set' import { reactive, ref, shallowReactive, type Ref, type WatchStopHandle } from 'vue' +import { type SourceDocument } from 'ydoc-shared/ast/sourceDocument' import type { MethodCall, StackItem } from 'ydoc-shared/languageServerTypes' import type { Opt } from 'ydoc-shared/util/data/opt' -import type { ExternalId, SourceRange, VisualizationMetadata } from 'ydoc-shared/yjsModel' -import { isUuid, sourceRangeKey, visMetadataEquals } from 'ydoc-shared/yjsModel' +import type { ExternalId, VisualizationMetadata } from 'ydoc-shared/yjsModel' +import { isUuid, visMetadataEquals } from 'ydoc-shared/yjsModel' export interface MethodCallInfo { methodCall: MethodCall @@ -35,110 +34,6 @@ export interface MethodCallInfo { suggestion: SuggestionEntry } -export interface BindingInfo { - identifier: string - usages: Set -} - -/** TODO: Add docs */ -export class BindingsDb { - bindings = new ReactiveDb() - identifierToBindingId = new ReactiveIndex(this.bindings, (id, info) => [[info.identifier, id]]) - - /** TODO: Add docs */ - readFunctionAst( - func: Ast.FunctionDef, - rawFunc: RawAst.Tree.Function | undefined, - moduleCode: string, - getSpan: (id: AstId) => SourceRange | undefined, - ) { - // TODO[ao]: Rename 'alias' to 'binding' in AliasAnalyzer and it's more accurate term. - const analyzer = new AliasAnalyzer(moduleCode, rawFunc) - analyzer.process() - - const [bindingRangeToTree, bindingIdToRange] = BindingsDb.rangeMappings(func, analyzer, getSpan) - - // Remove old keys. - for (const key of this.bindings.keys()) { - const range = bindingIdToRange.get(key) - if (range == null || !analyzer.aliases.has(range)) { - this.bindings.delete(key) - } - } - - // Add or update bindings. - for (const [bindingRange, usagesRanges] of analyzer.aliases) { - const aliasAst = bindingRangeToTree.get(bindingRange) - if (aliasAst == null) { - console.warn(`Binding not found`, bindingRange) - continue - } - const aliasAstId = aliasAst.id - const info = this.bindings.get(aliasAstId) - if (info == null) { - function* usageIds() { - for (const usageRange of usagesRanges) { - const usageAst = bindingRangeToTree.get(usageRange) - assert(usageAst != null) - if (usageAst != null) yield usageAst.id - } - } - this.bindings.set(aliasAstId, { - identifier: aliasAst.code(), - usages: new Set(usageIds()), - }) - } else { - const newIdentifier = aliasAst.code() - if (info.identifier != newIdentifier) info.identifier = newIdentifier - // Remove old usages. - for (const usage of info.usages) { - const range = bindingIdToRange.get(usage) - if (range == null || !usagesRanges.has(range)) info.usages.delete(usage) - } - // Add or update usages. - for (const usageRange of usagesRanges) { - const usageAst = bindingRangeToTree.get(usageRange) - if (usageAst != null && !info.usages.has(usageAst.id)) { - info.usages.add(usageAst.id) - } - } - } - } - } - - /** - * Create mappings between bindings' ranges and AST - * - * The AliasAnalyzer is general and returns ranges, but we're interested in AST nodes. This - * method creates mappings in both ways. For given range, only the shallowest AST node will be - * assigned (RawAst.Tree.Identifier, not RawAst.Token.Identifier). - */ - private static rangeMappings( - ast: Ast.Ast, - analyzer: AliasAnalyzer, - getSpan: (id: AstId) => SourceRange | undefined, - ): [MappedKeyMap, Map] { - const bindingRangeToTree = new MappedKeyMap(sourceRangeKey) - const bindingIdToRange = new Map() - const bindingRanges = new MappedSet(sourceRangeKey) - for (const [binding, usages] of analyzer.aliases) { - bindingRanges.add(binding) - for (const usage of usages) bindingRanges.add(usage) - } - ast.visitRecursive((ast) => { - const span = getSpan(ast.id) - assert(span != null) - if (bindingRanges.has(span)) { - bindingRangeToTree.set(span, ast) - bindingIdToRange.set(ast.id, span) - return false - } - return true - }) - return [bindingRangeToTree, bindingIdToRange] - } -} - /** TODO: Add docs */ export class GraphDb { nodeIdToNode = new ReactiveDb() @@ -146,7 +41,10 @@ export class GraphDb { private highestZIndex = 0 private readonly idToExternalMap = reactive(new Map()) private readonly idFromExternalMap = reactive(new Map()) - private bindings = new BindingsDb() + private readonly bindings = new ReactiveDb() + private readonly identifierToBindingId = new ReactiveIndex(this.bindings, (id, info) => [ + [info.identifier, id], + ]) /** TODO: Add docs */ constructor( @@ -167,7 +65,7 @@ export class GraphDb { return Array.from(exprs, (expr) => [id, expr]) }) - connections = new ReactiveIndex(this.bindings.bindings, (alias, info) => { + connections = new ReactiveIndex(this.bindings, (alias, info) => { const srcNode = this.getPatternExpressionNodeId(alias) ?? this.getExpressionNodeId(alias) if (srcNode == null) return [] return Array.from(this.connectionsFromBindings(info, alias, srcNode)) @@ -200,7 +98,7 @@ export class GraphDb { if (entry.pattern == null) return [] const ports = new Set() entry.pattern.visitRecursive((ast) => { - if (this.bindings.bindings.has(ast.id)) { + if (this.bindings.has(ast.id)) { ports.add(ast.id) return false } @@ -257,7 +155,7 @@ export class GraphDb { /** TODO: Add docs */ getIdentDefiningNode(ident: string): NodeId | undefined { - const binding = set.first(this.bindings.identifierToBindingId.lookup(ident)) + const binding = set.first(this.identifierToBindingId.lookup(ident)) return this.getPatternExpressionNodeId(binding) } @@ -269,12 +167,12 @@ export class GraphDb { /** TODO: Add docs */ getOutputPortIdentifier(source: AstId | undefined): string | undefined { - return source ? this.bindings.bindings.get(source)?.identifier : undefined + return source ? this.bindings.get(source)?.identifier : undefined } /** TODO: Add docs */ identifierUsed(ident: string): boolean { - return this.bindings.identifierToBindingId.hasKey(ident) + return this.identifierToBindingId.hasKey(ident) } /** TODO: Add docs */ @@ -467,13 +365,21 @@ export class GraphDb { } /** Deeply scan the function to perform alias-analysis. */ - updateBindings( - functionAst_: Ast.FunctionDef, - rawFunction: RawAst.Tree.Function | undefined, - moduleCode: string, - getSpan: (id: AstId) => SourceRange | undefined, - ) { - this.bindings.readFunctionAst(functionAst_, rawFunction, moduleCode, getSpan) + updateBindings(func: Ast.FunctionDef, moduleSource: Pick) { + const newBindings = analyzeBindings(func, moduleSource) + for (const id of this.bindings.keys()) { + if (!newBindings.has(id)) this.bindings.delete(id) + } + for (const [id, newInfo] of newBindings) { + const oldInfo = this.bindings.getUntracked(id) + if (oldInfo == null) { + this.bindings.set(id, newInfo) + } else { + const info = resumeReactivity(oldInfo) + if (oldInfo.identifier !== newInfo.identifier) info.identifier = newInfo.identifier + syncSetDiff(info.usages, oldInfo.usages, newInfo.usages) + } + } } /** TODO: Add docs */ @@ -567,7 +473,7 @@ export class GraphDb { } const bindingId = pattern.id this.nodeIdToNode.set(id, node) - this.bindings.bindings.set(bindingId, { identifier: binding, usages: new Set() }) + this.bindings.set(bindingId, { identifier: binding, usages: new Set() }) return node } } diff --git a/app/gui/src/project-view/stores/graph/index.ts b/app/gui/src/project-view/stores/graph/index.ts index 10376233d65e..d643fdb8b7d1 100644 --- a/app/gui/src/project-view/stores/graph/index.ts +++ b/app/gui/src/project-view/stores/graph/index.ts @@ -19,7 +19,6 @@ import { assert, bail } from '@/util/assert' import { Ast } from '@/util/ast' import type { AstId, Identifier, MutableModule } from '@/util/ast/abstract' import { isAstId, isIdentifier } from '@/util/ast/abstract' -import { RawAst, visitRecursive } from '@/util/ast/raw' import { reactiveModule } from '@/util/ast/reactive' import { partition } from '@/util/data/array' import { stringUnionToArray, type Events } from '@/util/data/observable' @@ -52,13 +51,8 @@ import type { MethodPointer, } from 'ydoc-shared/languageServerTypes' import { reachable } from 'ydoc-shared/util/data/graph' -import type { - LocalUserActionOrigin, - Origin, - SourceRangeKey, - VisualizationMetadata, -} from 'ydoc-shared/yjsModel' -import { defaultLocalOrigin, sourceRangeKey, visMetadataEquals } from 'ydoc-shared/yjsModel' +import type { LocalUserActionOrigin, Origin, VisualizationMetadata } from 'ydoc-shared/yjsModel' +import { defaultLocalOrigin, visMetadataEquals } from 'ydoc-shared/yjsModel' import { UndoManager } from 'yjs' const FALLBACK_BINDING_PREFIX = 'node' @@ -114,7 +108,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC const portInstances = shallowReactive(new Map>()) const editedNodeInfo = ref() - const moduleSource = reactive(SourceDocument.Empty()) + const moduleSource = SourceDocument.Empty(reactive) const moduleRoot = ref() const syncModule = computed(() => moduleRoot.value?.module as Ast.MutableModule | undefined) @@ -169,23 +163,8 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC }) watchEffect(() => { - if (!methodAst.value.ok || !moduleSource.text) return - const method = methodAst.value.value - const toRaw = new Map() - visitRecursive(Ast.rawParseModule(moduleSource.text), (node) => { - if (node.type === RawAst.Tree.Type.Function) { - const start = node.whitespaceStartInCodeParsed + node.whitespaceLengthInCodeParsed - const end = start + node.childrenLengthInCodeParsed - toRaw.set(sourceRangeKey([start, end]), node) - return false - } - return true - }) - const methodSpan = moduleSource.getSpan(method.id) - assert(methodSpan != null) - const rawFunc = toRaw.get(sourceRangeKey(methodSpan)) - const getSpan = (id: AstId) => moduleSource.getSpan(id) - db.updateBindings(method, rawFunc, moduleSource.text, getSpan) + if (methodAst.value.ok && moduleSource.text) + db.updateBindings(methodAst.value.value, moduleSource) }) function getExecutedMethodAst(module?: Ast.Module): Result { diff --git a/app/gui/src/project-view/util/ast/abstract.ts b/app/gui/src/project-view/util/ast/abstract.ts index 8d2bdd38cc6d..ec64613798d8 100644 --- a/app/gui/src/project-view/util/ast/abstract.ts +++ b/app/gui/src/project-view/util/ast/abstract.ts @@ -342,15 +342,15 @@ export function parseUpdatingIdMap( ) { const rawRoot = rawParseModule(code) const module = inModule ?? MutableModule.Transient() - const { root, spans, toRaw } = module.transact(() => { - const { root, spans, toRaw } = abstract(module, rawRoot, code) + const { root, spans } = module.transact(() => { + const { root, spans } = abstract(module, rawRoot, code) root.module.setRoot(root) if (idMap) setExternalIds(root.module, spans, idMap) - return { root, spans, toRaw } + return { root, spans } }) const getSpan = spanMapToSpanGetter(spans) const idMapOut = spanMapToIdMap(spans) - return { root, idMap: idMapOut, getSpan, toRaw } + return { root, idMap: idMapOut, getSpan } } declare const tokenKey: unique symbol diff --git a/app/gui/src/project-view/util/ast/bindings.ts b/app/gui/src/project-view/util/ast/bindings.ts new file mode 100644 index 000000000000..45aa1e163549 --- /dev/null +++ b/app/gui/src/project-view/util/ast/bindings.ts @@ -0,0 +1,92 @@ +import { Ast, RawAst } from '@/util/ast' +import { AliasAnalyzer } from '@/util/ast/aliasAnalysis' +import { visitRecursive } from '@/util/ast/raw' +import { MappedKeyMap, MappedSet } from '@/util/containers' +import type { AstId } from 'ydoc-shared/ast' +import type { SourceDocument } from 'ydoc-shared/ast/sourceDocument' +import { assert } from 'ydoc-shared/util/assert' +import { type SourceRange, sourceRangeKey, type SourceRangeKey } from 'ydoc-shared/yjsModel' + +/** A variable name, and information about its usages. */ +export interface BindingInfo { + identifier: string + usages: Set +} + +/** Find variables bound in the function, and their usages. */ +export function analyzeBindings( + func: Ast.FunctionDef, + moduleSource: Pick, +): Map { + const toRaw = new Map() + visitRecursive(Ast.rawParseModule(moduleSource.text), (node) => { + if (node.type === RawAst.Tree.Type.Function) { + const start = node.whitespaceStartInCodeParsed + node.whitespaceLengthInCodeParsed + const end = start + node.childrenLengthInCodeParsed + toRaw.set(sourceRangeKey([start, end]), node) + return false + } + return true + }) + const methodSpan = moduleSource.getSpan(func.id) + assert(methodSpan != null) + const rawFunc = toRaw.get(sourceRangeKey(methodSpan)) + const getSpan = (id: Ast.AstId) => moduleSource.getSpan(id) + const moduleCode = moduleSource.text + + // TODO[ao]: Rename 'alias' to 'binding' in AliasAnalyzer and it's more accurate term. + const analyzer = new AliasAnalyzer(moduleCode, rawFunc) + analyzer.process() + + const bindingRangeToTree = rangeMappings(func, analyzer, getSpan) + + const bindings = new Map() + for (const [bindingRange, usagesRanges] of analyzer.aliases) { + const aliasAst = bindingRangeToTree.get(bindingRange) + if (aliasAst == null) { + console.warn(`Binding not found`, bindingRange) + continue + } + const usages = new Set() + for (const usageRange of usagesRanges) { + const usageAst = bindingRangeToTree.get(usageRange) + assert(usageAst != null) + if (usageAst != null) usages.add(usageAst.id) + } + bindings.set(aliasAst.id, { + identifier: aliasAst.code(), + usages, + }) + } + return bindings +} + +/** + * Create mappings between bindings' ranges and AST + * + * The AliasAnalyzer is general and returns ranges, but we're interested in AST nodes. This + * method creates mappings in both ways. For given range, only the shallowest AST node will be + * assigned (RawAst.Tree.Identifier, not RawAst.Token.Identifier). + */ +function rangeMappings( + ast: Ast.Ast, + analyzer: AliasAnalyzer, + getSpan: (id: AstId) => SourceRange | undefined, +): MappedKeyMap { + const bindingRangeToTree = new MappedKeyMap(sourceRangeKey) + const bindingRanges = new MappedSet(sourceRangeKey) + for (const [binding, usages] of analyzer.aliases) { + bindingRanges.add(binding) + for (const usage of usages) bindingRanges.add(usage) + } + ast.visitRecursive((ast) => { + const span = getSpan(ast.id) + assert(span != null) + if (bindingRanges.has(span)) { + bindingRangeToTree.set(span, ast) + return false + } + return true + }) + return bindingRangeToTree +} diff --git a/app/ydoc-shared/src/ast/parse.ts b/app/ydoc-shared/src/ast/parse.ts index 77f54548aaa8..b25071f7cbe9 100644 --- a/app/ydoc-shared/src/ast/parse.ts +++ b/app/ydoc-shared/src/ast/parse.ts @@ -13,7 +13,6 @@ import type { LazyObject } from './parserSupport' import { Token } from './token' import type { Ast, - AstId, FunctionDefFields, MutableBodyBlock, MutableExpression, @@ -74,24 +73,24 @@ export function abstract( tree: RawAst.Tree.BodyBlock, code: string, substitutor?: (key: NodeKey) => Owned | undefined, -): { root: Owned; spans: SpanMap; toRaw: Map } +): { root: Owned; spans: SpanMap } export function abstract( module: MutableModule, tree: RawAst.Tree, code: string, substitutor?: (key: NodeKey) => Owned | undefined, -): { root: Owned; spans: SpanMap; toRaw: Map } +): { root: Owned; spans: SpanMap } /** Implementation of `abstract`. */ export function abstract( module: MutableModule, tree: RawAst.Tree, code: string, substitutor?: (key: NodeKey) => Owned | undefined, -): { root: Owned; spans: SpanMap; toRaw: Map } { +): { root: Owned; spans: SpanMap } { const abstractor = new Abstractor(module, code, substitutor) const root = abstractor.abstractTree(tree).node const spans = { tokens: abstractor.tokens, nodes: abstractor.nodes } - return { root: root as Owned, spans, toRaw: abstractor.toRaw } + return { root: root as Owned, spans } } /** Produces `Ast` types from `RawAst` parser output. */ @@ -101,7 +100,6 @@ class Abstractor { private readonly substitutor: ((key: NodeKey) => Owned | undefined) | undefined readonly nodes: NodeSpanMap readonly tokens: TokenSpanMap - readonly toRaw: Map /** * @param module - Where to allocate the new nodes. @@ -119,7 +117,6 @@ class Abstractor { this.substitutor = substitutor this.nodes = new Map() this.tokens = new Map() - this.toRaw = new Map() } abstractStatement(tree: RawAst.Tree): { @@ -310,7 +307,6 @@ class Abstractor { node = Generic.concrete(this.module, this.abstractChildren(tree)) } } - this.toRaw.set(node.id, tree) map.setIfUndefined(this.nodes, spanKey, (): Ast[] => []).unshift(node) return { node, whitespace } } @@ -543,7 +539,7 @@ export function parseInSameContext( module: MutableModule, code: string, ast: Ast, -): { root: Owned; spans: SpanMap; toRaw: Map } { +): { root: Owned; spans: SpanMap } { const rawParsed = rawParseInContext(code, getParseContext(ast)) return abstract(module, rawParsed, code) } diff --git a/app/ydoc-shared/src/ast/sourceDocument.ts b/app/ydoc-shared/src/ast/sourceDocument.ts index 96e2b27cff05..32a093625830 100644 --- a/app/ydoc-shared/src/ast/sourceDocument.ts +++ b/app/ydoc-shared/src/ast/sourceDocument.ts @@ -13,34 +13,34 @@ import type { AstId } from './tree' * that can be kept up-to-date by applying AST changes. */ export class SourceDocument { - private text_: string - private readonly spans: Map - private readonly observers: SourceDocumentObserver[] + private readonly observers: SourceDocumentObserver[] = [] + private constructor( + private readonly state: SourceDocumentState, + private readonly rawState: SourceDocumentState, + ) {} - private constructor(text: string, spans: Map) { - this.text_ = text - this.spans = spans - this.observers = [] - } - - /** Create an empty {@link SourceDocument}. */ - static Empty() { - return new this('', new Map()) + /** + * Create an empty {@link SourceDocument}. + * @param bless - A function adding reactivity to the document state + */ + static Empty(bless?: (state: SourceDocumentState) => SourceDocumentState) { + const state = { text: '', spans: new Map() } + return new this(bless ? bless(state) : state, state) } /** Reset this {@link SourceDocument} to an empty state. */ clear() { - if (this.spans.size !== 0) this.spans.clear() - if (this.text_ !== '') { - const range: SourceRange = [0, this.text_.length] - this.text_ = '' + if (this.state.spans.size !== 0) this.state.spans.clear() + if (this.state.text !== '') { + const range: SourceRange = [0, this.state.text.length] + this.state.text = '' this.notifyObservers([{ range, insert: '' }], undefined) } } /** Apply a {@link ModuleUpdate} and notify observers of the edits. */ applyUpdate(module: Module, update: ModuleUpdate) { - for (const id of update.nodesDeleted) this.spans.delete(id) + for (const id of update.nodesDeleted) this.state.spans.delete(id) const root = module.root() if (!root) return const subtreeTextEdits = new Array() @@ -48,11 +48,11 @@ export class SourceDocument { for (const [key, nodes] of printed.info.nodes) { const range = sourceRangeFromKey(key) for (const node of nodes) { - const oldSpan = this.spans.get(node.id) - if (!oldSpan || !rangeEquals(range, oldSpan)) this.spans.set(node.id, range) + const oldSpan = this.rawState.spans.get(node.id) + if (!oldSpan || !rangeEquals(range, oldSpan)) this.state.spans.set(node.id, range) if (update.updateRoots.has(node.id) && node.id !== root.id) { assertDefined(oldSpan) - const oldCode = this.text_.slice(oldSpan[0], oldSpan[1]) + const oldCode = this.rawState.text.slice(oldSpan[0], oldSpan[1]) const newCode = printed.code.slice(range[0], range[1]) const subedits = textChangeToEdits(oldCode, newCode).map(textEdit => offsetEdit(textEdit, oldSpan[0]), @@ -61,30 +61,31 @@ export class SourceDocument { } } } - if (printed.code !== this.text_) { + if (printed.code !== this.rawState.text) { const textEdits = update.updateRoots.has(root.id) ? - [{ range: [0, this.text_.length] satisfies SourceRange, insert: printed.code }] + [{ range: [0, this.rawState.text.length] satisfies SourceRange, insert: printed.code }] : subtreeTextEdits - this.text_ = printed.code + this.state.text = printed.code this.notifyObservers(textEdits, update.origin) } } /** Get the entire text representation of this module. */ get text(): string { - return this.text_ + return this.state.text } /** Get a span in this document by its {@link AstId}. */ getSpan(id: AstId): SourceRange | undefined { - return this.spans.get(id) + return this.state.spans.get(id) } /** Add a callback to be called with a list of edits on every update. */ observe(observer: SourceDocumentObserver) { this.observers.push(observer) - if (this.text_.length) observer([{ range: [0, 0], insert: this.text_ }], undefined) + if (this.rawState.text.length) + observer([{ range: [0, 0], insert: this.rawState.text }], undefined) } /** Remove a callback to no longer be called with a list of edits on every update. */ @@ -93,12 +94,20 @@ export class SourceDocument { if (index !== undefined) this.observers.splice(index, 1) } - private notifyObservers(textEdits: readonly SourceRangeEdit[], origin: Origin | undefined) { + private notifyObservers( + textEdits: ReadonlyArray>, + origin: Origin | undefined, + ) { for (const o of this.observers) o(textEdits, origin) } } +export interface SourceDocumentState { + text: string + readonly spans: Map +} + export type SourceDocumentObserver = ( - textEdits: readonly SourceRangeEdit[], + textEdits: ReadonlyArray>, origin: Origin | undefined, ) => void diff --git a/app/ydoc-shared/src/util/data/text.ts b/app/ydoc-shared/src/util/data/text.ts index 2a5165c8f53a..e38464c81d6c 100644 --- a/app/ydoc-shared/src/util/data/text.ts +++ b/app/ydoc-shared/src/util/data/text.ts @@ -5,11 +5,15 @@ import { rangeEncloses, rangeLength, type SourceRange } from '../../yjsModel' export type SourceRangeEdit = { range: SourceRange; insert: string } /** Given text and a set of `TextEdit`s, return the result of applying the edits to the text. */ -export function applyTextEdits(oldText: string, textEdits: SourceRangeEdit[]) { - textEdits.sort((a, b) => a.range[0] - b.range[0]) +export function applyTextEdits( + oldText: string, + textEdits: ReadonlyArray>, +) { + const editsOrdered = [...textEdits] + editsOrdered.sort((a, b) => a.range[0] - b.range[0]) let start = 0 let newText = '' - for (const textEdit of textEdits) { + for (const textEdit of editsOrdered) { newText += oldText.slice(start, textEdit.range[0]) newText += textEdit.insert start = textEdit.range[1]