/** * @license * Copyright 2019 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * The class representing an AST node. * Used to traverse the Blockly AST. * * @class */ // Former goog.module ID: Blockly.ASTNode import type {Block} from '../block.js'; import type {Connection} from '../connection.js'; import {ConnectionType} from '../connection_type.js'; import type {Field} from '../field.js'; import type {Input} from '../inputs/input.js'; import type {IASTNodeLocation} from '../interfaces/i_ast_node_location.js'; import type {IASTNodeLocationWithBlock} from '../interfaces/i_ast_node_location_with_block.js'; import {Coordinate} from '../utils/coordinate.js'; import type {Workspace} from '../workspace.js'; /** * Class for an AST node. * It is recommended that you use one of the createNode methods instead of * creating a node directly. */ export class ASTNode { /** * True to navigate to all fields. False to only navigate to clickable fields. */ static NAVIGATE_ALL_FIELDS = false; /** * The default y offset to use when moving the cursor from a stack to the * workspace. */ private static readonly DEFAULT_OFFSET_Y: number = -20; private readonly type: string; private readonly isConnectionLocation: boolean; private readonly location: IASTNodeLocation; /** The coordinate on the workspace. */ // AnyDuringMigration because: Type 'null' is not assignable to type // 'Coordinate'. private wsCoordinate: Coordinate = null as AnyDuringMigration; /** * @param type The type of the location. * Must be in ASTNode.types. * @param location The position in the AST. * @param opt_params Optional dictionary of options. */ constructor(type: string, location: IASTNodeLocation, opt_params?: Params) { if (!location) { throw Error('Cannot create a node without a location.'); } /** * The type of the location. * One of ASTNode.types */ this.type = type; /** Whether the location points to a connection. */ this.isConnectionLocation = ASTNode.isConnectionType(type); /** The location of the AST node. */ this.location = location; this.processParams(opt_params || null); } /** * Parse the optional parameters. * * @param params The user specified parameters. */ private processParams(params: Params | null) { if (!params) { return; } if (params.wsCoordinate) { this.wsCoordinate = params.wsCoordinate; } } /** * Gets the value pointed to by this node. * It is the callers responsibility to check the node type to figure out what * type of object they get back from this. * * @returns The current field, connection, workspace, or block the cursor is * on. */ getLocation(): IASTNodeLocation { return this.location; } /** * The type of the current location. * One of ASTNode.types * * @returns The type of the location. */ getType(): string { return this.type; } /** * The coordinate on the workspace. * * @returns The workspace coordinate or null if the location is not a * workspace. */ getWsCoordinate(): Coordinate { return this.wsCoordinate; } /** * Whether the node points to a connection. * * @returns [description] * @internal */ isConnection(): boolean { return this.isConnectionLocation; } /** * Given an input find the next editable field or an input with a non null * connection in the same block. The current location must be an input * connection. * * @returns The AST node holding the next field or connection or null if there * is no editable field or input connection after the given input. */ private findNextForInput(): ASTNode | null { const location = this.location as Connection; const parentInput = location.getParentInput(); const block = parentInput!.getSourceBlock(); // AnyDuringMigration because: Argument of type 'Input | null' is not // assignable to parameter of type 'Input'. const curIdx = block!.inputList.indexOf(parentInput as AnyDuringMigration); for (let i = curIdx + 1; i < block!.inputList.length; i++) { const input = block!.inputList[i]; const fieldRow = input.fieldRow; for (let j = 0; j < fieldRow.length; j++) { const field = fieldRow[j]; if (field.isClickable() || ASTNode.NAVIGATE_ALL_FIELDS) { return ASTNode.createFieldNode(field); } } if (input.connection) { return ASTNode.createInputNode(input); } } return null; } /** * Given a field find the next editable field or an input with a non null * connection in the same block. The current location must be a field. * * @returns The AST node pointing to the next field or connection or null if * there is no editable field or input connection after the given input. */ private findNextForField(): ASTNode | null { const location = this.location as Field; const input = location.getParentInput(); const block = location.getSourceBlock(); if (!block) { throw new Error( 'The current AST location is not associated with a block', ); } const curIdx = block.inputList.indexOf(input); let fieldIdx = input.fieldRow.indexOf(location) + 1; for (let i = curIdx; i < block.inputList.length; i++) { const newInput = block.inputList[i]; const fieldRow = newInput.fieldRow; while (fieldIdx < fieldRow.length) { if (fieldRow[fieldIdx].isClickable() || ASTNode.NAVIGATE_ALL_FIELDS) { return ASTNode.createFieldNode(fieldRow[fieldIdx]); } fieldIdx++; } fieldIdx = 0; if (newInput.connection) { return ASTNode.createInputNode(newInput); } } return null; } /** * Given an input find the previous editable field or an input with a non null * connection in the same block. The current location must be an input * connection. * * @returns The AST node holding the previous field or connection. */ private findPrevForInput(): ASTNode | null { const location = this.location as Connection; const parentInput = location.getParentInput(); const block = parentInput!.getSourceBlock(); // AnyDuringMigration because: Argument of type 'Input | null' is not // assignable to parameter of type 'Input'. const curIdx = block!.inputList.indexOf(parentInput as AnyDuringMigration); for (let i = curIdx; i >= 0; i--) { const input = block!.inputList[i]; if (input.connection && input !== parentInput) { return ASTNode.createInputNode(input); } const fieldRow = input.fieldRow; for (let j = fieldRow.length - 1; j >= 0; j--) { const field = fieldRow[j]; if (field.isClickable() || ASTNode.NAVIGATE_ALL_FIELDS) { return ASTNode.createFieldNode(field); } } } return null; } /** * Given a field find the previous editable field or an input with a non null * connection in the same block. The current location must be a field. * * @returns The AST node holding the previous input or field. */ private findPrevForField(): ASTNode | null { const location = this.location as Field; const parentInput = location.getParentInput(); const block = location.getSourceBlock(); if (!block) { throw new Error( 'The current AST location is not associated with a block', ); } const curIdx = block.inputList.indexOf(parentInput); let fieldIdx = parentInput.fieldRow.indexOf(location) - 1; for (let i = curIdx; i >= 0; i--) { const input = block.inputList[i]; if (input.connection && input !== parentInput) { return ASTNode.createInputNode(input); } const fieldRow = input.fieldRow; while (fieldIdx > -1) { if (fieldRow[fieldIdx].isClickable() || ASTNode.NAVIGATE_ALL_FIELDS) { return ASTNode.createFieldNode(fieldRow[fieldIdx]); } fieldIdx--; } // Reset the fieldIdx to the length of the field row of the previous // input. if (i - 1 >= 0) { fieldIdx = block.inputList[i - 1].fieldRow.length - 1; } } return null; } /** * Navigate between stacks of blocks on the workspace. * * @param forward True to go forward. False to go backwards. * @returns The first block of the next stack or null if there are no blocks * on the workspace. */ private navigateBetweenStacks(forward: boolean): ASTNode | null { let curLocation = this.getLocation(); // TODO(#6097): Use instanceof checks to exit early for values of // curLocation that don't make sense. if ((curLocation as IASTNodeLocationWithBlock).getSourceBlock) { const block = (curLocation as IASTNodeLocationWithBlock).getSourceBlock(); if (block) { curLocation = block; } } // TODO(#6097): Use instanceof checks to exit early for values of // curLocation that don't make sense. const curLocationAsBlock = curLocation as Block; if (!curLocationAsBlock || curLocationAsBlock.isDeadOrDying()) { return null; } const curRoot = curLocationAsBlock.getRootBlock(); const topBlocks = curRoot.workspace.getTopBlocks(true); for (let i = 0; i < topBlocks.length; i++) { const topBlock = topBlocks[i]; if (curRoot.id === topBlock.id) { const offset = forward ? 1 : -1; const resultIndex = i + offset; if (resultIndex === -1 || resultIndex === topBlocks.length) { return null; } return ASTNode.createStackNode(topBlocks[resultIndex]); } } throw Error( "Couldn't find " + (forward ? 'next' : 'previous') + ' stack?!', ); } /** * Finds the top most AST node for a given block. * This is either the previous connection, output connection or block * depending on what kind of connections the block has. * * @param block The block that we want to find the top connection on. * @returns The AST node containing the top connection. */ private findTopASTNodeForBlock(block: Block): ASTNode | null { const topConnection = getParentConnection(block); if (topConnection) { return ASTNode.createConnectionNode(topConnection); } else { return ASTNode.createBlockNode(block); } } /** * Get the AST node pointing to the input that the block is nested under or if * the block is not nested then get the stack AST node. * * @param block The source block of the current location. * @returns The AST node pointing to the input connection or the top block of * the stack this block is in. */ private getOutAstNodeForBlock(block: Block): ASTNode | null { if (!block) { return null; } // If the block doesn't have a previous connection then it is the top of the // substack. const topBlock = block.getTopStackBlock(); const topConnection = getParentConnection(topBlock); // If the top connection has a parentInput, create an AST node pointing to // that input. if ( topConnection && topConnection.targetConnection && topConnection.targetConnection.getParentInput() ) { // AnyDuringMigration because: Argument of type 'Input | null' is not // assignable to parameter of type 'Input'. return ASTNode.createInputNode( topConnection.targetConnection.getParentInput() as AnyDuringMigration, ); } else { // Go to stack level if you are not underneath an input. return ASTNode.createStackNode(topBlock); } } /** * Find the first editable field or input with a connection on a given block. * * @param block The source block of the current location. * @returns An AST node pointing to the first field or input. * Null if there are no editable fields or inputs with connections on the * block. */ private findFirstFieldOrInput(block: Block): ASTNode | null { const inputs = block.inputList; for (let i = 0; i < inputs.length; i++) { const input = inputs[i]; const fieldRow = input.fieldRow; for (let j = 0; j < fieldRow.length; j++) { const field = fieldRow[j]; if (field.isClickable() || ASTNode.NAVIGATE_ALL_FIELDS) { return ASTNode.createFieldNode(field); } } if (input.connection) { return ASTNode.createInputNode(input); } } return null; } /** * Finds the source block of the location of this node. * * @returns The source block of the location, or null if the node is of type * workspace. */ getSourceBlock(): Block | null { if (this.getType() === ASTNode.types.BLOCK) { return this.getLocation() as Block; } else if (this.getType() === ASTNode.types.STACK) { return this.getLocation() as Block; } else if (this.getType() === ASTNode.types.WORKSPACE) { return null; } else { return (this.getLocation() as IASTNodeLocationWithBlock).getSourceBlock(); } } /** * Find the element to the right of the current element in the AST. * * @returns An AST node that wraps the next field, connection, block, or * workspace. Or null if there is no node to the right. */ next(): ASTNode | null { switch (this.type) { case ASTNode.types.STACK: return this.navigateBetweenStacks(true); case ASTNode.types.OUTPUT: { const connection = this.location as Connection; return ASTNode.createBlockNode(connection.getSourceBlock()); } case ASTNode.types.FIELD: return this.findNextForField(); case ASTNode.types.INPUT: return this.findNextForInput(); case ASTNode.types.BLOCK: { const block = this.location as Block; const nextConnection = block.nextConnection; if (!nextConnection) return null; return ASTNode.createConnectionNode(nextConnection); } case ASTNode.types.PREVIOUS: { const connection = this.location as Connection; return ASTNode.createBlockNode(connection.getSourceBlock()); } case ASTNode.types.NEXT: { const connection = this.location as Connection; const targetConnection = connection.targetConnection; return ASTNode.createConnectionNode(targetConnection!); } } return null; } /** * Find the element one level below and all the way to the left of the current * location. * * @returns An AST node that wraps the next field, connection, workspace, or * block. Or null if there is nothing below this node. */ in(): ASTNode | null { switch (this.type) { case ASTNode.types.WORKSPACE: { const workspace = this.location as Workspace; const topBlocks = workspace.getTopBlocks(true); if (topBlocks.length > 0) { return ASTNode.createStackNode(topBlocks[0]); } break; } case ASTNode.types.STACK: { const block = this.location as Block; return this.findTopASTNodeForBlock(block); } case ASTNode.types.BLOCK: { const block = this.location as Block; return this.findFirstFieldOrInput(block); } case ASTNode.types.INPUT: { const connection = this.location as Connection; const targetConnection = connection.targetConnection; return ASTNode.createConnectionNode(targetConnection!); } } return null; } /** * Find the element to the left of the current element in the AST. * * @returns An AST node that wraps the previous field, connection, workspace * or block. Or null if no node exists to the left. null. */ prev(): ASTNode | null { switch (this.type) { case ASTNode.types.STACK: return this.navigateBetweenStacks(false); case ASTNode.types.OUTPUT: return null; case ASTNode.types.FIELD: return this.findPrevForField(); case ASTNode.types.INPUT: return this.findPrevForInput(); case ASTNode.types.BLOCK: { const block = this.location as Block; const topConnection = getParentConnection(block); if (!topConnection) return null; return ASTNode.createConnectionNode(topConnection); } case ASTNode.types.PREVIOUS: { const connection = this.location as Connection; const targetConnection = connection.targetConnection; if (targetConnection && !targetConnection.getParentInput()) { return ASTNode.createConnectionNode(targetConnection); } break; } case ASTNode.types.NEXT: { const connection = this.location as Connection; return ASTNode.createBlockNode(connection.getSourceBlock()); } } return null; } /** * Find the next element that is one position above and all the way to the * left of the current location. * * @returns An AST node that wraps the next field, connection, workspace or * block. Or null if we are at the workspace level. */ out(): ASTNode | null { switch (this.type) { case ASTNode.types.STACK: { const block = this.location as Block; const blockPos = block.getRelativeToSurfaceXY(); // TODO: Make sure this is in the bounds of the workspace. const wsCoordinate = new Coordinate( blockPos.x, blockPos.y + ASTNode.DEFAULT_OFFSET_Y, ); return ASTNode.createWorkspaceNode(block.workspace, wsCoordinate); } case ASTNode.types.OUTPUT: { const connection = this.location as Connection; const target = connection.targetConnection; if (target) { return ASTNode.createConnectionNode(target); } return ASTNode.createStackNode(connection.getSourceBlock()); } case ASTNode.types.FIELD: { const field = this.location as Field; const block = field.getSourceBlock(); if (!block) { throw new Error( 'The current AST location is not associated with a block', ); } return ASTNode.createBlockNode(block); } case ASTNode.types.INPUT: { const connection = this.location as Connection; return ASTNode.createBlockNode(connection.getSourceBlock()); } case ASTNode.types.BLOCK: { const block = this.location as Block; return this.getOutAstNodeForBlock(block); } case ASTNode.types.PREVIOUS: { const connection = this.location as Connection; return this.getOutAstNodeForBlock(connection.getSourceBlock()); } case ASTNode.types.NEXT: { const connection = this.location as Connection; return this.getOutAstNodeForBlock(connection.getSourceBlock()); } } return null; } /** * Whether an AST node of the given type points to a connection. * * @param type The type to check. One of ASTNode.types. * @returns True if a node of the given type points to a connection. */ private static isConnectionType(type: string): boolean { switch (type) { case ASTNode.types.PREVIOUS: case ASTNode.types.NEXT: case ASTNode.types.INPUT: case ASTNode.types.OUTPUT: return true; } return false; } /** * Create an AST node pointing to a field. * * @param field The location of the AST node. * @returns An AST node pointing to a field. */ static createFieldNode(field: Field): ASTNode | null { if (!field) { return null; } return new ASTNode(ASTNode.types.FIELD, field); } /** * Creates an AST node pointing to a connection. If the connection has a * parent input then create an AST node of type input that will hold the * connection. * * @param connection This is the connection the node will point to. * @returns An AST node pointing to a connection. */ static createConnectionNode(connection: Connection): ASTNode | null { if (!connection) { return null; } const type = connection.type; if (type === ConnectionType.INPUT_VALUE) { // AnyDuringMigration because: Argument of type 'Input | null' is not // assignable to parameter of type 'Input'. return ASTNode.createInputNode( connection.getParentInput() as AnyDuringMigration, ); } else if ( type === ConnectionType.NEXT_STATEMENT && connection.getParentInput() ) { // AnyDuringMigration because: Argument of type 'Input | null' is not // assignable to parameter of type 'Input'. return ASTNode.createInputNode( connection.getParentInput() as AnyDuringMigration, ); } else if (type === ConnectionType.NEXT_STATEMENT) { return new ASTNode(ASTNode.types.NEXT, connection); } else if (type === ConnectionType.OUTPUT_VALUE) { return new ASTNode(ASTNode.types.OUTPUT, connection); } else if (type === ConnectionType.PREVIOUS_STATEMENT) { return new ASTNode(ASTNode.types.PREVIOUS, connection); } return null; } /** * Creates an AST node pointing to an input. Stores the input connection as * the location. * * @param input The input used to create an AST node. * @returns An AST node pointing to a input. */ static createInputNode(input: Input): ASTNode | null { if (!input || !input.connection) { return null; } return new ASTNode(ASTNode.types.INPUT, input.connection); } /** * Creates an AST node pointing to a block. * * @param block The block used to create an AST node. * @returns An AST node pointing to a block. */ static createBlockNode(block: Block): ASTNode | null { if (!block) { return null; } return new ASTNode(ASTNode.types.BLOCK, block); } /** * Create an AST node of type stack. A stack, represented by its top block, is * the set of all blocks connected to a top block, including the top * block. * * @param topBlock A top block has no parent and can be found in the list * returned by workspace.getTopBlocks(). * @returns An AST node of type stack that points to the top block on the * stack. */ static createStackNode(topBlock: Block): ASTNode | null { if (!topBlock) { return null; } return new ASTNode(ASTNode.types.STACK, topBlock); } /** * Creates an AST node pointing to a workspace. * * @param workspace The workspace that we are on. * @param wsCoordinate The position on the workspace for this node. * @returns An AST node pointing to a workspace and a position on the * workspace. */ static createWorkspaceNode( workspace: Workspace | null, wsCoordinate: Coordinate | null, ): ASTNode | null { if (!wsCoordinate || !workspace) { return null; } const params = {wsCoordinate}; return new ASTNode(ASTNode.types.WORKSPACE, workspace, params); } /** * Creates an AST node for the top position on a block. * This is either an output connection, previous connection, or block. * * @param block The block to find the top most AST node on. * @returns The AST node holding the top most position on the block. */ static createTopNode(block: Block): ASTNode | null { let astNode; const topConnection = getParentConnection(block); if (topConnection) { astNode = ASTNode.createConnectionNode(topConnection); } else { astNode = ASTNode.createBlockNode(block); } return astNode; } } export namespace ASTNode { export interface Params { wsCoordinate: Coordinate; } export enum types { FIELD = 'field', BLOCK = 'block', INPUT = 'input', OUTPUT = 'output', NEXT = 'next', PREVIOUS = 'previous', STACK = 'stack', WORKSPACE = 'workspace', } } export type Params = ASTNode.Params; // No need to export ASTNode.types from the module at this time because (1) it // wasn't automatically converted by the automatic migration script, (2) the // name doesn't follow the styleguide. /** * Gets the parent connection on a block. * This is either an output connection, previous connection or undefined. * If both connections exist return the one that is actually connected * to another block. * * @param block The block to find the parent connection on. * @returns The connection connecting to the parent of the block. */ function getParentConnection(block: Block): Connection | null { let topConnection = block.outputConnection; if ( !topConnection || (block.previousConnection && block.previousConnection.isConnected()) ) { topConnection = block.previousConnection; } return topConnection; }