Refactoring before text edits (#8956)

Changes in preparation for #8238 features.

# Important Notes
Changed edit APIs:
- **`graph.astModule` is deprecated.** It will be removed in my next PR.
- Prefer `graph.edit` to start and commit an edit.
- Use `graph.startEdit` / `graph.commitEdit` if the edit can't be confined to one scope.
This commit is contained in:
Kaz Wesley 2024-02-06 06:43:17 -08:00 committed by GitHub
parent 6517384bbb
commit 06f18864f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 492 additions and 364 deletions

View File

@ -1,6 +1,14 @@
import * as map from 'lib0/map'
import type { AstId, NodeChild, Owned } from '.'
import { Token, asOwned, parentId, subtreeRoots } from '.'
import {
Token,
asOwned,
isTokenId,
parentId,
subtreeRoots,
type AstId,
type NodeChild,
type Owned,
} from '.'
import { assert, assertDefined, assertEqual } from '../util/assert'
import type { SourceRange, SourceRangeKey } from '../yjsModel'
import { IdMap, isUuid, sourceRangeFromKey, sourceRangeKey } from '../yjsModel'
@ -52,32 +60,212 @@ export function abstract(
tree: RawAst.Tree,
code: string,
): { root: Owned; spans: SpanMap; toRaw: Map<AstId, RawAst.Tree> } {
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 }
const abstractor = new Abstractor(module, code)
const root = abstractor.abstractTree(tree).node
const spans = { tokens: abstractor.tokens, nodes: abstractor.nodes }
return { root, spans, toRaw: abstractor.toRaw }
}
function abstractTree(
module: MutableModule,
tree: RawAst.Tree,
code: string,
nodesOut: NodeSpanMap,
tokensOut: TokenSpanMap,
toRaw: Map<AstId, RawAst.Tree>,
): { 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) => {
class Abstractor {
private readonly module: MutableModule
private readonly code: string
readonly nodes: NodeSpanMap
readonly tokens: TokenSpanMap
readonly toRaw: Map<AstId, RawAst.Tree>
constructor(module: MutableModule, code: string) {
this.module = module
this.code = code
this.nodes = new Map()
this.tokens = new Map()
this.toRaw = new Map()
}
abstractTree(tree: RawAst.Tree): { whitespace: string | undefined; node: Owned } {
const whitespaceStart = tree.whitespaceStartInCodeParsed
const whitespaceEnd = whitespaceStart + tree.whitespaceLengthInCodeParsed
const whitespace = this.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 = this.abstractToken(line.newline)
const expression = line.expression ? this.abstractTree(line.expression) : undefined
return { newline, expression }
})
node = BodyBlock.concrete(this.module, lines)
break
}
case RawAst.Tree.Type.Function: {
const name = this.abstractTree(tree.name)
const argumentDefinitions = Array.from(tree.args, (arg) => this.abstractChildren(arg))
const equals = this.abstractToken(tree.equals)
const body = tree.body !== undefined ? this.abstractTree(tree.body) : undefined
node = Function.concrete(this.module, name, argumentDefinitions, equals, body)
break
}
case RawAst.Tree.Type.Ident: {
const token = this.abstractToken(tree.token)
node = Ident.concrete(this.module, token)
break
}
case RawAst.Tree.Type.Assignment: {
const pattern = this.abstractTree(tree.pattern)
const equals = this.abstractToken(tree.equals)
const value = this.abstractTree(tree.expr)
node = Assignment.concrete(this.module, pattern, equals, value)
break
}
case RawAst.Tree.Type.App: {
const func = this.abstractTree(tree.func)
const arg = this.abstractTree(tree.arg)
node = App.concrete(this.module, func, undefined, undefined, arg)
break
}
case RawAst.Tree.Type.NamedApp: {
const func = this.abstractTree(tree.func)
const open = tree.open ? this.abstractToken(tree.open) : undefined
const name = this.abstractToken(tree.name)
const equals = this.abstractToken(tree.equals)
const arg = this.abstractTree(tree.arg)
const close = tree.close ? this.abstractToken(tree.close) : undefined
const parens = open && close ? { open, close } : undefined
const nameSpecification = { name, equals }
node = App.concrete(this.module, func, parens, nameSpecification, arg)
break
}
case RawAst.Tree.Type.UnaryOprApp: {
const opr = this.abstractToken(tree.opr)
const arg = tree.rhs ? this.abstractTree(tree.rhs) : undefined
if (arg && opr.node.code() === '-') {
node = NegationApp.concrete(this.module, opr, arg)
} else {
node = UnaryOprApp.concrete(this.module, opr, arg)
}
break
}
case RawAst.Tree.Type.OprApp: {
const lhs = tree.lhs ? this.abstractTree(tree.lhs) : undefined
const opr = tree.opr.ok
? [this.abstractToken(tree.opr.value)]
: Array.from(tree.opr.error.payload.operators, this.abstractToken.bind(this))
const rhs = tree.rhs ? this.abstractTree(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(this.module, lhs, opr[0], rhs_)
} else {
node = OprApp.concrete(this.module, lhs, opr, rhs)
}
break
}
case RawAst.Tree.Type.Number: {
const tokens = []
if (tree.base) tokens.push(this.abstractToken(tree.base))
if (tree.integer) tokens.push(this.abstractToken(tree.integer))
if (tree.fractionalDigits) {
tokens.push(this.abstractToken(tree.fractionalDigits.dot))
tokens.push(this.abstractToken(tree.fractionalDigits.digits))
}
node = NumericLiteral.concrete(this.module, tokens)
break
}
case RawAst.Tree.Type.Wildcard: {
const token = this.abstractToken(tree.token)
node = Wildcard.concrete(this.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: this.abstractTree(tree.ast).node }
case RawAst.Tree.Type.Invalid: {
const expression = this.abstractTree(tree.ast)
node = Invalid.concrete(this.module, expression)
break
}
case RawAst.Tree.Type.Group: {
const open = tree.open ? this.abstractToken(tree.open) : undefined
const expression = tree.body ? this.abstractTree(tree.body) : undefined
const close = tree.close ? this.abstractToken(tree.close) : undefined
node = Group.concrete(this.module, open, expression, close)
break
}
case RawAst.Tree.Type.TextLiteral: {
const open = tree.open ? this.abstractToken(tree.open) : undefined
const newline = tree.newline ? this.abstractToken(tree.newline) : undefined
const elements = []
for (const e of tree.elements) {
elements.push(...this.abstractChildren(e))
}
const close = tree.close ? this.abstractToken(tree.close) : undefined
node = TextLiteral.concrete(this.module, open, newline, elements, close)
break
}
case RawAst.Tree.Type.Documented: {
const open = this.abstractToken(tree.documentation.open)
const elements = []
for (const e of tree.documentation.elements) {
elements.push(...this.abstractChildren(e))
}
const newlines = Array.from(tree.documentation.newlines, this.abstractToken.bind(this))
const expression = tree.expression ? this.abstractTree(tree.expression) : undefined
node = Documented.concrete(this.module, open, elements, newlines, expression)
break
}
case RawAst.Tree.Type.Import: {
const recurseBody = (tree: RawAst.Tree) => {
const body = this.abstractTree(tree)
if (body.node instanceof Invalid && body.node.code() === '') return undefined
return body
}
const recurseSegment = (segment: RawAst.MultiSegmentAppSegment) => ({
header: this.abstractToken(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 ? this.abstractToken(tree.all) : undefined
const as = tree.as ? recurseSegment(tree.as) : undefined
const hiding = tree.hiding ? recurseSegment(tree.hiding) : undefined
node = Import.concrete(this.module, polyglot, from, import_, all, as, hiding)
break
}
default: {
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 }
}
private abstractToken(token: RawAst.Token): { whitespace: string; node: Token } {
const whitespaceStart = token.whitespaceStartInCodeBuffer
const whitespaceEnd = whitespaceStart + token.whitespaceLengthInCodeBuffer
const whitespace = this.code.substring(whitespaceStart, whitespaceEnd)
const codeStart = token.startInCodeBuffer
const codeEnd = codeStart + token.lengthInCodeBuffer
const tokenCode = this.code.substring(codeStart, codeEnd)
const key = tokenKey(codeStart, codeEnd - codeStart)
const node = Token.new(tokenCode, token.type)
this.tokens.set(key, node)
return { whitespace, node }
}
private abstractChildren(tree: LazyObject) {
const children: NodeChild<Owned | Token>[] = []
const visitor = (child: LazyObject) => {
if (RawAst.Tree.isInstance(child)) {
children.push(recurseTree(child))
children.push(this.abstractTree(child))
} else if (RawAst.Token.isInstance(child)) {
children.push(recurseToken(child))
children.push(this.abstractToken(child))
} else {
child.visitChildren(visitor)
}
@ -85,185 +273,6 @@ function abstractTree(
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
@ -325,6 +334,87 @@ export function print(ast: Ast): PrintedSource {
return { info, code }
}
/** @internal Used by `Ast.printSubtree`. Note that some AST types have overrides. */
export function printAst(
ast: Ast,
info: SpanMap,
offset: number,
parentIndent: string | undefined,
verbatim?: boolean,
): string {
let code = ''
for (const child of ast.concreteChildren(verbatim)) {
if (!isTokenId(child.node) && ast.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 = ast.module.getToken(child.node)
const span = tokenKey(tokenStart, token.code().length)
info.tokens.set(span, token)
code += token.code()
} else {
const childNode = ast.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) !== ast.id) {
console.error(`Inconsistent parent pointer (expected ${ast.id})`, childNode)
}
assertEqual(parentId(childNode), ast.id)
}
}
const span = nodeKey(offset, code.length)
map.setIfUndefined(info.nodes, span, (): Ast[] => []).unshift(ast)
return code
}
/** @internal Use `Ast.code()' to stringify. */
export function printBlock(
block: BodyBlock,
info: SpanMap,
offset: number,
parentIndent: string | undefined,
verbatim?: boolean,
): string {
let blockIndent: string | undefined
let code = ''
for (const line of block.fields.get('lines')) {
code += line.newline.whitespace ?? ''
const newlineCode = block.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 = block.module.checkedGet(line.expression.node)
assertEqual(lineNode.id, line.expression.node)
assertEqual(parentId(lineNode), block.id)
code += lineNode.printSubtree(info, offset + code.length, blockIndent, verbatim)
}
}
const span = nodeKey(offset, code.length)
map.setIfUndefined(info.nodes, span, (): Ast[] => []).unshift(block)
return code
}
/** Parse the input as a block. */
export function parseBlock(code: string, inModule?: MutableModule) {
return parseBlockWithSpans(code, inModule).root

View File

@ -1,4 +1,3 @@
import * as map from 'lib0/map'
import type {
Identifier,
IdentifierOrOperatorIdentifier,
@ -18,12 +17,12 @@ import {
isToken,
isTokenId,
newExternalId,
nodeKey,
parentId,
parse,
parseBlock,
print,
tokenKey,
printAst,
printBlock,
} from '.'
import { assert, assertDefined, assertEqual, bail } from '../util/assert'
import type { Result } from '../util/data/result'
@ -123,35 +122,7 @@ export abstract class Ast {
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
return printAst(this, info, offset, parentIndent, verbatim)
}
/** Returns child subtrees, without information about the whitespace between them. */
@ -237,6 +208,12 @@ export abstract class MutableAst extends Ast {
return old
}
replaceValueChecked<T extends MutableAst>(replacement: Owned<T>): Owned<typeof this> {
const parentId = this.fields.get('parent')
assertDefined(parentId)
return this.replaceValue(replacement)
}
/** Replace the parent of this object with a reference to a new placeholder object.
* Returns the object, now parentless, and the placeholder. */
takeToReplace(): Removed<this> {
@ -354,11 +331,15 @@ interface AppFields {
}
export class App extends Ast {
declare fields: FixedMap<AstFields & AppFields>
constructor(module: Module, fields: FixedMapView<AstFields & AppFields>) {
super(module, fields)
}
static tryParse(source: string, module?: MutableModule): Owned<MutableApp> | undefined {
const parsed = parse(source, module)
if (parsed instanceof MutableApp) return parsed
}
static concrete(
module: MutableModule,
func: NodeChild<Owned>,
@ -486,6 +467,11 @@ export class UnaryOprApp extends Ast {
super(module, fields)
}
static tryParse(source: string, module?: MutableModule): Owned<MutableUnaryOprApp> | undefined {
const parsed = parse(source, module)
if (parsed instanceof MutableUnaryOprApp) return parsed
}
static concrete(
module: MutableModule,
operator: NodeChild<Token>,
@ -549,6 +535,11 @@ export class NegationApp extends Ast {
super(module, fields)
}
static tryParse(source: string, module?: MutableModule): Owned<MutableNegationApp> | undefined {
const parsed = parse(source, module)
if (parsed instanceof MutableNegationApp) return parsed
}
static concrete(module: MutableModule, operator: NodeChild<Token>, argument: NodeChild<Owned>) {
const base = module.baseObject('NegationApp')
const id_ = base.get('id')
@ -606,6 +597,11 @@ export class OprApp extends Ast {
super(module, fields)
}
static tryParse(source: string, module?: MutableModule): Owned<MutableOprApp> | undefined {
const parsed = parse(source, module)
if (parsed instanceof MutableOprApp) return parsed
}
static concrete(
module: MutableModule,
lhs: NodeChild<Owned> | undefined,
@ -693,6 +689,14 @@ export class PropertyAccess extends Ast {
super(module, fields)
}
static tryParse(
source: string,
module?: MutableModule,
): Owned<MutablePropertyAccess> | undefined {
const parsed = parse(source, module)
if (parsed instanceof MutablePropertyAccess) return parsed
}
static new(module: MutableModule, lhs: Owned, rhs: IdentLike) {
const dot = unspaced(Token.new('.', RawAst.Token.Type.Operator))
return this.concrete(
@ -894,6 +898,11 @@ export class Import extends Ast {
super(module, fields)
}
static tryParse(source: string, module?: MutableModule): Owned<MutableImport> | undefined {
const parsed = parse(source, module)
if (parsed instanceof MutableImport) return parsed
}
get polyglot(): Ast | undefined {
return this.module.checkedGet(this.fields.get('polyglot')?.body?.node)
}
@ -1022,17 +1031,17 @@ export class MutableImport extends Import implements MutableAst {
replaceChild<T extends MutableAst>(target: AstId, replacement: Owned<T>) {
const { polyglot, from, import: import_, as, hiding } = getAll(this.fields)
;(polyglot?.body?.node === target
? this.setPolyglot
polyglot?.body?.node === target
? this.setPolyglot(replacement)
: from?.body?.node === target
? this.setFrom
? this.setFrom(replacement)
: import_.body?.node === target
? this.setImport
? this.setImport(replacement)
: as?.body?.node === target
? this.setAs
? this.setAs(replacement)
: hiding?.body?.node === target
? this.setHiding
: bail(`Failed to find child ${target} in node ${this.externalId}.`))(replacement)
? this.setHiding(replacement)
: bail(`Failed to find child ${target} in node ${this.externalId}.`)
}
}
export interface MutableImport extends Import, MutableAst {
@ -1074,6 +1083,11 @@ export class TextLiteral extends Ast {
super(module, fields)
}
static tryParse(source: string, module?: MutableModule): Owned<MutableTextLiteral> | undefined {
const parsed = parse(source, module)
if (parsed instanceof MutableTextLiteral) return parsed
}
static concrete(
module: MutableModule,
open: NodeChild<Token> | undefined,
@ -1134,6 +1148,11 @@ export class Documented extends Ast {
super(module, fields)
}
static tryParse(source: string, module?: MutableModule): Owned<MutableDocumented> | undefined {
const parsed = parse(source, module)
if (parsed instanceof MutableDocumented) return parsed
}
static concrete(
module: MutableModule,
open: NodeChild<Token> | undefined,
@ -1259,6 +1278,11 @@ export class Group extends Ast {
super(module, fields)
}
static tryParse(source: string, module?: MutableModule): Owned<MutableGroup> | undefined {
const parsed = parse(source, module)
if (parsed instanceof MutableGroup) return parsed
}
static concrete(
module: MutableModule,
open: NodeChild<Token> | undefined,
@ -1315,6 +1339,14 @@ export class NumericLiteral extends Ast {
super(module, fields)
}
static tryParse(
source: string,
module?: MutableModule,
): Owned<MutableNumericLiteral> | undefined {
const parsed = parse(source, module)
if (parsed instanceof MutableNumericLiteral) return parsed
}
static concrete(module: MutableModule, tokens: NodeChild<Token>[]) {
const base = module.baseObject('NumericLiteral')
const fields = setAll(base, { tokens })
@ -1365,6 +1397,11 @@ export class Function extends Ast {
super(module, fields)
}
static tryParse(source: string, module?: MutableModule): Owned<MutableFunction> | undefined {
const parsed = parse(source, module)
if (parsed instanceof MutableFunction) return parsed
}
get name(): Ast {
return this.module.checkedGet(this.fields.get('name').node)
}
@ -1506,6 +1543,11 @@ export class Assignment extends Ast {
super(module, fields)
}
static tryParse(source: string, module?: MutableModule): Owned<MutableAssignment> | undefined {
const parsed = parse(source, module)
if (parsed instanceof MutableAssignment) return parsed
}
static concrete(
module: MutableModule,
pattern: NodeChild<Owned>,
@ -1580,6 +1622,11 @@ export class BodyBlock extends Ast {
super(module, fields)
}
static tryParse(source: string, module?: MutableModule): Owned<MutableBodyBlock> | undefined {
const parsed = parse(source, module)
if (parsed instanceof MutableBodyBlock) return parsed
}
static concrete(module: MutableModule, lines: OwnedBlockLine[]) {
const base = module.baseObject('BodyBlock')
const id_ = base.get('id')
@ -1616,38 +1663,7 @@ export class BodyBlock extends Ast {
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
return printBlock(this, info, offset, parentIndent, verbatim)
}
}
export class MutableBodyBlock extends BodyBlock implements MutableAst {
@ -1772,6 +1788,11 @@ export class Ident extends Ast {
super(module, fields)
}
static tryParse(source: string, module?: MutableModule): Owned<MutableIdent> | undefined {
const parsed = parse(source, module)
if (parsed instanceof MutableIdent) return parsed
}
get token(): IdentifierToken {
return this.module.getToken(this.fields.get('token').node) as IdentifierToken
}
@ -1825,6 +1846,11 @@ export class Wildcard extends Ast {
super(module, fields)
}
static tryParse(source: string, module?: MutableModule): Owned<MutableWildcard> | undefined {
const parsed = parse(source, module)
if (parsed instanceof MutableWildcard) return parsed
}
get token(): Token {
return this.module.getToken(this.fields.get('token').node)
}

View File

@ -265,34 +265,36 @@ const graphBindingsHandler = graphBindings.handler({
bail(`Cannot get the method name for the current execution stack item. ${currentMethod}`)
}
const currentFunctionEnv = environmentForNodes(selected.values())
const module = graphStore.astModule
const topLevel = graphStore.topLevel
if (!topLevel) {
bail('BUG: no top level, collapsing not possible.')
}
const edit = module.edit()
const { refactoredNodeId, collapsedNodeIds, outputNodeId } = performCollapse(
info,
edit.getVersion(topLevel),
graphStore.db,
currentMethodName,
)
const collapsedFunctionEnv = environmentForNodes(collapsedNodeIds.values())
// For collapsed function, only selected nodes would affect placement of the output node.
collapsedFunctionEnv.nodeRects = collapsedFunctionEnv.selectedNodeRects
const { position } = collapsedNodePlacement(DEFAULT_NODE_SIZE, currentFunctionEnv)
edit
.checkedGet(refactoredNodeId)
.mutableNodeMetadata()
.set('position', { x: position.x, y: position.y })
if (outputNodeId != null) {
const { position } = previousNodeDictatedPlacement(DEFAULT_NODE_SIZE, collapsedFunctionEnv)
graphStore.edit((edit) => {
const { refactoredNodeId, collapsedNodeIds, outputNodeId } = performCollapse(
info,
edit.getVersion(topLevel),
graphStore.db,
currentMethodName,
)
const collapsedFunctionEnv = environmentForNodes(collapsedNodeIds.values())
// For collapsed function, only selected nodes would affect placement of the output node.
collapsedFunctionEnv.nodeRects = collapsedFunctionEnv.selectedNodeRects
edit
.checkedGet(outputNodeId)
.checkedGet(refactoredNodeId)
.mutableNodeMetadata()
.set('position', { x: position.x, y: position.y })
}
graphStore.commitEdit(edit)
if (outputNodeId != null) {
const { position } = previousNodeDictatedPlacement(
DEFAULT_NODE_SIZE,
collapsedFunctionEnv,
)
edit
.checkedGet(outputNodeId)
.mutableNodeMetadata()
.set('position', { x: position.x, y: position.y })
}
})
} catch (err) {
console.log('Error while collapsing, this is not normal.', err)
}

View File

@ -56,7 +56,7 @@ const editingEdge: Interaction = {
interaction.setWhen(() => graph.unconnectedEdge != null, editingEdge)
function disconnectEdge(target: PortId) {
graph.commitDirect((edit) => {
graph.edit((edit) => {
if (!graph.updatePortValue(edit, target, undefined)) {
if (isAstId(target)) {
console.warn(`Failed to disconnect edge from port ${target}, falling back to direct edit.`)
@ -79,7 +79,7 @@ function createEdge(source: AstId, target: PortId) {
return console.error(`Failed to connect edge, source or target node not found.`)
}
const edit = graph.astModule.edit()
const edit = graph.startEdit()
const reorderResult = graph.ensureCorrectNodeOrder(edit, sourceNode, targetNode)
if (reorderResult === 'circular') {
// Creating this edge would create a circular dependency. Prevent that and display error.

View File

@ -31,11 +31,9 @@ const observedLayoutTransitions = new Set([
])
function handleWidgetUpdates(update: WidgetUpdate) {
const edit = update.edit ?? graph.startEdit()
if (update.portUpdate) {
const {
edit,
portUpdate: { value, origin },
} = update
const { value, origin } = update.portUpdate
if (Ast.isAstId(origin)) {
const ast =
value instanceof Ast.Ast
@ -48,7 +46,7 @@ function handleWidgetUpdates(update: WidgetUpdate) {
console.error(`[UPDATE ${origin}] Invalid top-level origin. Expected expression ID.`)
}
}
graph.commitEdit(update.edit)
graph.commitEdit(edit)
// This handler is guaranteed to be the last handler in the chain.
return true
}

View File

@ -17,7 +17,7 @@ const value = computed({
return WidgetInput.valueRepr(props.input)?.endsWith('True') ?? false
},
set(value) {
const edit = graph.astModule.edit()
const edit = graph.startEdit()
if (props.input.value instanceof Ast.Ast) {
setBoolNode(
edit.getVersion(props.input.value),

View File

@ -162,10 +162,8 @@ const widgetConfiguration = computed(() => {
function handleArgUpdate(update: WidgetUpdate): boolean {
const app = application.value
if (update.portUpdate && app instanceof ArgumentApplication) {
const {
edit,
portUpdate: { value, origin },
} = update
const { value, origin } = update.portUpdate
const edit = update.edit ?? graph.startEdit()
// Find the updated argument by matching origin port/expression with the appropriate argument.
// We are interested only in updates at the top level of the argument AST. Updates from nested
// widgets do not need to be processed at the function application level.

View File

@ -16,7 +16,6 @@ const value = computed({
},
set(value) {
props.onUpdate({
edit: graph.astModule.edit(),
portUpdate: { value: value.toString(), origin: asNot<TokenId>(props.input.portId) },
})
},

View File

@ -10,6 +10,7 @@ import { useGraphStore } from '@/stores/graph'
import { requiredImports, type RequiredImport } from '@/stores/graph/imports.ts'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { type SuggestionEntry } from '@/stores/suggestionDatabase/entry.ts'
import { Ast } from '@/util/ast'
import type { TokenId } from '@/util/ast/abstract.ts'
import { ArgumentInfoKey } from '@/util/callTree'
import { asNot } from '@/util/data/types.ts'
@ -117,9 +118,11 @@ function toggleDropdownWidget() {
// When the selected index changes, we update the expression content.
watch(selectedIndex, (_index) => {
const edit = graph.astModule.edit()
if (selectedTag.value?.requiredImports)
let edit: Ast.MutableModule | undefined
if (selectedTag.value?.requiredImports) {
edit = graph.startEdit()
graph.addMissingImports(edit, selectedTag.value.requiredImports)
}
props.onUpdate({
edit,
portUpdate: {

View File

@ -16,7 +16,6 @@ const value = computed({
},
set(value) {
props.onUpdate({
edit: graph.astModule.edit(),
portUpdate: { value: value.toString(), origin: asNot<TokenId>(props.input.portId) },
})
},

View File

@ -36,9 +36,7 @@ const value = computed({
set(value) {
// TODO[ao]: here we re-create AST. It would be better to reuse existing AST nodes.
const newCode = `[${value.map((item) => item.code()).join(', ')}]`
const edit = graph.astModule.edit()
props.onUpdate({
edit,
portUpdate: { value: newCode, origin: asNot<TokenId>(props.input.portId) },
})
},

View File

@ -55,8 +55,12 @@ export function useStackNavigator() {
}
function enterNode(id: AstId) {
const node = graphStore.astModule.get(id)!
const expressionInfo = graphStore.db.getExpressionInfo(id)
const externalId = graphStore.db.idToExternal(id)
if (externalId == null) {
console.debug("Cannot enter node that hasn't been committed yet.")
return
}
const expressionInfo = graphStore.db.getExpressionInfo(externalId)
if (expressionInfo == null || expressionInfo.methodCall == null) {
console.debug('Cannot enter node that has no method call.')
return
@ -71,7 +75,7 @@ export function useStackNavigator() {
console.debug('Cannot enter node that is not defined on current module.')
return
}
projectStore.executionContext.push(node.externalId)
projectStore.executionContext.push(externalId)
graphStore.updateState()
breadcrumbs.value = projectStore.executionContext.desiredStack.slice()
}

View File

@ -141,7 +141,7 @@ export interface WidgetProps<T> {
* is committed in {@link NodeWidgetTree}.
*/
export interface WidgetUpdate {
edit: MutableModule
edit?: MutableModule | undefined
portUpdate?: {
value: Ast.Owned | string | undefined
origin: PortId

View File

@ -537,8 +537,8 @@ if (import.meta.vitest) {
})
const parseImport = (code: string): Import | null => {
const ast: Ast.Ast = Ast.parse(code)
return ast instanceof Ast.Import ? recognizeImport(ast) : null
const ast = Ast.Import.tryParse(code)
return ast ? recognizeImport(ast) : null
}
test.each([

View File

@ -212,22 +212,21 @@ export const useGraphStore = defineStore('graph', () => {
const mod = proj.module
if (!mod) return
const ident = generateUniqueIdent()
const edit = astModule.value.edit()
if (withImports) addMissingImports(edit, withImports)
const currentFunc = 'main'
const method = Ast.findModuleMethod(topLevel.value!, currentFunc)
if (!method) {
console.error(`BUG: Cannot add node: No current function.`)
return
}
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)
return asNodeId(rhs.id)
return edit((edit) => {
if (withImports) addMissingImports(edit, withImports)
const rhs = Ast.parse(expression, edit)
rhs.setNodeMetadata(metadata)
const assignment = Ast.Assignment.new(edit, ident, rhs)
edit.getVersion(method).bodyAsBlock().push(assignment)
return asNodeId(rhs.id)
})
}
function addMissingImports(edit: MutableModule, newImports: RequiredImport[]) {
@ -244,21 +243,25 @@ export const useGraphStore = defineStore('graph', () => {
}
function deleteNodes(ids: NodeId[]) {
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)
edit(
(edit) => {
for (const id of ids) {
const node = db.nodeIdToNode.get(id)
if (!node) continue
const outerExpr = edit.get(node.outerExprId)
if (outerExpr) Ast.deleteFromParentBlock(outerExpr)
nodeRects.delete(id)
}
},
true,
true,
)
}
function setNodeContent(id: NodeId, content: string) {
const node = db.nodeIdToNode.get(id)
if (!node) return
commitDirect((edit) => {
edit((edit) => {
edit.getVersion(node.rootSpan).replaceValue(Ast.parse(content, edit))
})
}
@ -276,12 +279,9 @@ export const useGraphStore = defineStore('graph', () => {
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)
editNodeMetadata(nodeAst, (metadata) =>
metadata.set('position', { x: position.x, y: position.y }),
)
}
}
@ -297,25 +297,23 @@ export const useGraphStore = defineStore('graph', () => {
function setNodeVisualizationId(nodeId: NodeId, vis: Opt<VisualizationIdentifier>) {
const nodeAst = astModule.value?.get(nodeId)
if (!nodeAst) return
commitDirect((edit) => {
const metadata = edit.getVersion(nodeAst).mutableNodeMetadata()
editNodeMetadata(nodeAst, (metadata) =>
metadata.set(
'visualization',
normalizeVisMetadata(vis, metadata.get('visualization')?.visible),
)
}, true)
),
)
}
function setNodeVisualizationVisible(nodeId: NodeId, visible: boolean) {
const nodeAst = astModule.value?.get(nodeId)
if (!nodeAst) return
commitDirect((edit) => {
const metadata = edit.getVersion(nodeAst).mutableNodeMetadata()
editNodeMetadata(nodeAst, (metadata) =>
metadata.set(
'visualization',
normalizeVisMetadata(metadata.get('visualization')?.identifier, visible),
)
}, true)
),
)
}
function updateNodeRect(nodeId: NodeId, rect: Rect) {
@ -329,12 +327,9 @@ export const useGraphStore = defineStore('graph', () => {
screenBounds: Rect.Zero,
mousePosition: Vec2.Zero,
})
commitDirect((edit) => {
edit
.getVersion(nodeAst)
.mutableNodeMetadata()
.set('position', { x: position.x, y: position.y })
}, true)
editNodeMetadata(nodeAst, (metadata) =>
metadata.set('position', { x: position.x, y: position.y }),
)
nodeRects.set(nodeId, new Rect(position, rect.size))
} else {
nodeRects.set(nodeId, rect)
@ -405,6 +400,10 @@ export const useGraphStore = defineStore('graph', () => {
return true
}
function startEdit(): MutableModule {
return astModule.value.edit()
}
/** 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`
@ -422,23 +421,34 @@ export const useGraphStore = defineStore('graph', () => {
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.
/** Edit the AST module.
*
* @param skipTreeRepair - If the edit is known not to require any parenthesis insertion, this may be set to `true`
* for better performance.
* Optimization options: These are safe to use for metadata-only edits; otherwise, they require extreme caution.
*
* @param skipTreeRepair - If the edit is certain not to produce incorrect or non-canonical syntax, this may be set
* to `true` for better performance.
* @param direct - Apply all changes directly to the synchronized module; they will be committed even if the callback
* exits by throwing an exception.
*/
function commitDirect(f: (edit: MutableModule) => void, skipTreeRepair?: boolean) {
const edit = syncModule.value
function edit<T>(f: (edit: MutableModule) => T, skipTreeRepair?: boolean, direct?: boolean): T {
const edit = direct ? syncModule.value : syncModule.value?.edit()
assert(edit != null)
let result
edit.ydoc.transact(() => {
f(edit)
result = f(edit)
if (!skipTreeRepair) {
const root = edit.root()
assert(root instanceof Ast.BodyBlock)
Ast.repair(root, edit)
}
}, 'local')
if (!direct)
Y.applyUpdateV2(syncModule.value!.ydoc, Y.encodeStateAsUpdateV2(edit.ydoc), 'local')
return result!
}
function editNodeMetadata(ast: Ast.Ast, f: (metadata: Ast.MutableNodeMetadata) => void) {
edit((edit) => f(edit.getVersion(ast).mutableNodeMetadata()), true, true)
}
function mockExpressionUpdate(
@ -564,8 +574,9 @@ export const useGraphStore = defineStore('graph', () => {
updatePortValue,
setEditedNode,
updateState,
startEdit,
commitEdit,
commitDirect,
edit,
addMissingImports,
}
})