diff --git a/app/gui2/shared/ast/parse.ts b/app/gui2/shared/ast/parse.ts index aa04842dcb2..8b78515e66a 100644 --- a/app/gui2/shared/ast/parse.ts +++ b/app/gui2/shared/ast/parse.ts @@ -507,11 +507,11 @@ export function printBlock( ): string { let blockIndent: string | undefined let code = '' - for (const line of block.fields.get('lines')) { + block.fields.get('lines').forEach((line, index) => { 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 (offset || index || 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' @@ -533,7 +533,7 @@ export function printBlock( 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 diff --git a/app/gui2/shared/ast/tree.ts b/app/gui2/shared/ast/tree.ts index ef4305e5849..0bfcf855894 100644 --- a/app/gui2/shared/ast/tree.ts +++ b/app/gui2/shared/ast/tree.ts @@ -137,6 +137,15 @@ export abstract class Ast { return this.wrappingExpression()?.documentingAncestor() } + get isBindingStatement(): boolean { + const inner = this.wrappedExpression() + if (inner) { + return inner.isBindingStatement + } else { + return false + } + } + code(): string { return print(this).code } @@ -1904,6 +1913,10 @@ export class Function extends Ast { } } + get isBindingStatement(): boolean { + return true + } + *concreteChildren(_verbatim?: boolean): IterableIterator { const { name, argumentDefinitions, equals, body } = getAll(this.fields) yield name @@ -1992,6 +2005,10 @@ export class Assignment extends Ast { return this.module.get(this.fields.get('expression').node) } + get isBindingStatement(): boolean { + return true + } + *concreteChildren(verbatim?: boolean): IterableIterator { const { pattern, equals, expression } = getAll(this.fields) yield ensureUnspaced(pattern, verbatim) diff --git a/app/gui2/src/components/GraphEditor/GraphNodes.vue b/app/gui2/src/components/GraphEditor/GraphNodes.vue index ad79ae59ec2..e0a4d66e48f 100644 --- a/app/gui2/src/components/GraphEditor/GraphNodes.vue +++ b/app/gui2/src/components/GraphEditor/GraphNodes.vue @@ -48,7 +48,6 @@ const uploadingFiles = computed<[FileName, File][]>(() => { :node="node" :edited="id === graphStore.editedNodeInfo?.id" :graphNodeSelections="props.graphNodeSelections" - :data-node="id" @delete="graphStore.deleteNodes([id])" @dragging="nodeIsDragged(id, $event)" @draggingCommited="dragging.finishDrag()" diff --git a/app/gui2/src/composables/__tests__/nodeCreation.test.ts b/app/gui2/src/composables/__tests__/nodeCreation.test.ts new file mode 100644 index 00000000000..d990b06b228 --- /dev/null +++ b/app/gui2/src/composables/__tests__/nodeCreation.test.ts @@ -0,0 +1,43 @@ +import { insertNodeStatements } from '@/composables/nodeCreation' +import { Ast } from '@/util/ast' +import { identifier } from 'shared/ast' +import { initializeFFI } from 'shared/ast/ffi' +import { expect, test } from 'vitest' + +await initializeFFI() + +test.each([ + ['node1 = 123', '*'], + ['node1 = 123', '*', 'node1'], + ['node1 = 123', '', '*', 'node1'], + ['*', 'node1'], + ['', '*', 'node1'], + ['*', '## Return value', 'node1'], + ['*', '## Return value', '', 'node1'], + ['*', '## Block ends in documentation?!'], +])('New node location in block', (...linesWithInsertionPoint: string[]) => { + const inputLines = linesWithInsertionPoint.filter((line) => line !== '*') + const bodyBlock = Ast.parseBlock(inputLines.join('\n')) + insertNodeStatements(bodyBlock, [Ast.parse('newNodePositionMarker')]) + const lines = bodyBlock + .code() + .split('\n') + .map((line) => (line === 'newNodePositionMarker' ? '*' : line)) + expect(lines).toEqual(linesWithInsertionPoint) +}) + +// This is a special case because when a block is empty, adding a line requires adding *two* linebreaks. +test('Adding node to empty block', () => { + const module = Ast.MutableModule.Transient() + const func = Ast.Function.fromStatements(module, identifier('f')!, [], []) + const rootBlock = Ast.BodyBlock.new([], module) + rootBlock.push(func) + expect(rootBlock.code().trimEnd()).toBe('f =') + insertNodeStatements(func.bodyAsBlock(), [Ast.parse('newNode')]) + expect( + rootBlock + .code() + .split('\n') + .map((line) => line.trimEnd()), + ).toEqual(['f =', ' newNode']) +}) diff --git a/app/gui2/src/composables/nodeCreation.ts b/app/gui2/src/composables/nodeCreation.ts index d8e3daf524c..e03cc42f07a 100644 --- a/app/gui2/src/composables/nodeCreation.ts +++ b/app/gui2/src/composables/nodeCreation.ts @@ -115,14 +115,15 @@ export function useNodeCreation( } const created = new Set() graphStore.edit((edit) => { - const bodyBlock = edit.getVersion(methodAst).bodyAsBlock() + const statements = new Array() for (const options of placedNodes) { const { rootExpression, id } = newAssignmentNode(edit, options) - bodyBlock.push(rootExpression) + statements.push(rootExpression) created.add(id) assert(options.metadata?.position != null, 'Node should already be placed') graphStore.nodeRects.set(id, new Rect(Vec2.FromXY(options.metadata.position), Vec2.Zero)) } + insertNodeStatements(edit.getVersion(methodAst).bodyAsBlock(), statements) }) onCreated(created) } @@ -186,3 +187,17 @@ function typeToPrefix(type: Typename): string { return type.toLowerCase() } } + +/** Insert the given statements into the given block, at a location appropriate for new nodes. + * + * The location will be after any statements in the block that bind identifiers; if the block ends in an expression + * statement, the location will be before it so that the value of the block will not be affected. + */ +export function insertNodeStatements(bodyBlock: Ast.MutableBodyBlock, statements: Ast.Owned[]) { + const lines = bodyBlock.lines + const index = + lines[lines.length - 1]?.expression?.node.isBindingStatement !== false ? + lines.length + : lines.length - 1 + bodyBlock.insert(index, ...statements) +} diff --git a/app/gui2/src/composables/selection.ts b/app/gui2/src/composables/selection.ts index bae64939d07..f256d00cb9a 100644 --- a/app/gui2/src/composables/selection.ts +++ b/app/gui2/src/composables/selection.ts @@ -179,7 +179,7 @@ export function useGraphHover(isPortEnabled: (port: PortId) => boolean) { const hoveredNode = computed(() => { const element = hoveredElement.value?.closest('.GraphNode') if (!element) return undefined - return dataAttribute(element, 'node') + return dataAttribute(element, 'node-id') }) return { hoveredNode, hoveredPort } diff --git a/app/gui2/src/util/ast/__tests__/abstract.test.ts b/app/gui2/src/util/ast/__tests__/abstract.test.ts index ffef158eae8..c06207af848 100644 --- a/app/gui2/src/util/ast/__tests__/abstract.test.ts +++ b/app/gui2/src/util/ast/__tests__/abstract.test.ts @@ -369,6 +369,10 @@ const cases = [ ['value = foo', ' bar'].join('\n'), ['value = foo', ' +x', ' bar'].join('\n'), ['###', ' x'].join('\n'), + '\n', + '\n\n', + '\na', + '\n\na', ] test.each(cases)('parse/print round trip: %s', (code) => { // Get an AST.