#! /usr/bin/python # SPDX-License-Identifier: GPL-3.0-or-later # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Class representing a BASIC program. This is a list of statements, ordered by line number. """ from basictoken import BASICToken as Token, BASICToken from basicparser import BASICParser from flowsignal import FlowSignal from lexer import Lexer class BASICData: def __init__(self): # array of line numbers to represent data statements self.__datastmts = {} # Data pointer self.__next_data = 0 def delete(self): self.__datastmts.clear() self.__next_data = 0 def delData(self,line_number): if self.__datastmts.get(line_number) != None: del self.__datastmts[line_number] def addData(self,line_number,tokenlist): """ Adds the supplied token list to the program's DATA store. If a token list with the same line number already exists, this is replaced. line_number: Basic program line number of DATA statement """ try: self.__datastmts[line_number] = tokenlist except TypeError as err: raise TypeError("Invalid line number: " + str(err)) def getTokens(self,line_number): """ returns the tokens from the program DATA statement line_number: Basic program line number of DATA statement """ return self.__datastmts.get(line_number) def readData(self,read_line_number): if len(self.__datastmts) == 0: raise RuntimeError('No DATA statements available to READ ' + 'in line ' + str(read_line_number)) data_values = [] line_numbers = list(self.__datastmts.keys()) line_numbers.sort() if self.__next_data == 0: self.__next_data = line_numbers[0] elif line_numbers.index(self.__next_data) < len(line_numbers)-1: self.__next_data = line_numbers[line_numbers.index(self.__next_data)+1] else: raise RuntimeError('No DATA statements available to READ ' + 'in line ' + str(read_line_number)) tokenlist = self.__datastmts[self.__next_data] sign = 1 for token in tokenlist[1:]: if token.category != Token.COMMA: #data_values.append(token.lexeme) if token.category == Token.STRING: data_values.append(token.lexeme) elif token.category == Token.UNSIGNEDINT: data_values.append(sign*int(token.lexeme)) elif token.category == Token.UNSIGNEDFLOAT: data_values.append(sign*eval(token.lexeme)) elif token.category == Token.MINUS: sign = -1 #else: #data_values.append(token.lexeme) else: sign = 1 return data_values def restore(self,restoreLineNo): if restoreLineNo == 0 or restoreLineNo in self.__datastmts: if restoreLineNo == 0: self.__next_data = restoreLineNo else: line_numbers = list(self.__datastmts.keys()) line_numbers.sort() indexln = line_numbers.index(restoreLineNo) if indexln == 0: self.__next_data = 0 else: self.__next_data = line_numbers[indexln-1] else: raise RuntimeError('Attempt to RESTORE but no DATA ' + 'statement at line ' + str(restoreLineNo)) class Program: def __init__(self): # Dictionary to represent program # statements, keyed by line number self.__program = {} # Program counter self.__next_stmt = 0 # Initialise return stack for subroutine returns self.__return_stack = [] # return dictionary for loop returns self.__return_loop = {} # WHILE loop tracking stack - separate from GOSUB return stack # Each entry contains the line number of the WHILE statement self.__while_stack = [] # Setup DATA object self.__data = BASICData() def __str__(self): program_text = "" line_numbers = self.line_numbers() for line_number in line_numbers: program_text += self.str_statement(line_number) return program_text def str_statement(self, line_number): line_text = str(line_number) + " " statement = self.__program[line_number] if statement[0].category == Token.DATA: statement = self.__data.getTokens(line_number) # Track operators and contexts where minus might be unary operators_and_contexts = { Token.PLUS, Token.MINUS, Token.TIMES, Token.DIVIDE, Token.MODULO, Token.ASSIGNOP, Token.EQUAL, Token.NOTEQUAL, Token.GREATER, Token.LESSER, Token.LESSEQUAL, Token.GREATEQUAL, Token.LEFTPAREN, Token.COMMA, Token.AND, Token.OR } for i, token in enumerate(statement): # Add in quotes for strings if token.category == Token.STRING: line_text += '"' + token.lexeme + '" ' else: # Check if this is a minus sign that should be treated as unary if (token.category == Token.MINUS and i + 1 < len(statement) and statement[i + 1].category in {Token.UNSIGNEDINT, Token.UNSIGNEDFLOAT} and (i == 0 or statement[i - 1].category in operators_and_contexts)): # This is a unary minus, don't add space after it line_text += token.lexeme else: # Normal token, add space after line_text += token.lexeme + " " line_text += "\n" return line_text def list(self, start_line=None, end_line=None): """Lists the program""" line_numbers = self.line_numbers() if not start_line: start_line = int(line_numbers[0]) if not end_line: end_line = int(line_numbers[-1]) for line_number in line_numbers: if int(line_number) >= start_line and int(line_number) <= end_line: print(self.str_statement(line_number), end="") def save(self, file): """Save the program :param file: The name and path of the save file, .bas is appended """ if not file.lower().endswith(".bas"): file += ".bas" try: with open(file, "w") as outfile: outfile.write(str(self)) except OSError: raise OSError("Could not save to file") def load(self, file): """Load the program :param file: The name and path of the file to be loaded, .bas is appended """ # New out the program self.delete() if not file.lower().endswith(".bas"): file += ".bas" try: lexer = Lexer() with open(file, "r") as infile: for line in infile: line = line.replace("\r", "").replace("\n", "").strip() tokenlist = lexer.tokenize(line) self.add_stmt(tokenlist) except OSError: raise OSError("Could not read file") def add_stmt(self, tokenlist): """ Adds the supplied token list to the program. The first token should be the line number. If a token list with the same line number already exists, this is replaced. :param tokenlist: List of BTokens representing a numbered program statement """ if len(tokenlist) > 0: try: line_number = int(tokenlist[0].lexeme) if tokenlist[1].lexeme == "DATA": self.__data.addData(line_number,tokenlist[1:]) self.__program[line_number] = [tokenlist[1],] else: self.__program[line_number] = tokenlist[1:] except TypeError as err: raise TypeError("Invalid line number: " + str(err)) def line_numbers(self): """Returns a list of all the line numbers for the program, sorted :return: A sorted list of program line numbers """ line_numbers = list(self.__program.keys()) line_numbers.sort() return line_numbers def __execute(self, line_number): """Execute the statement with the specified line number :param line_number: The line number :return: The FlowSignal to indicate to the program how to branch if necessary, None otherwise """ if line_number not in self.__program.keys(): raise RuntimeError("Line number " + line_number + " does not exist") statement = self.__program[line_number] try: return self.__parser.parse(statement, line_number) except RuntimeError as err: raise RuntimeError(str(err)) def __validate_while_wend(self): """Validate that all WHILE statements have matching WEND statements""" while_stack = [] line_numbers = self.line_numbers() for line_num in line_numbers: statement = self.__program[line_num] if statement and len(statement) > 0: if statement[0].category == Token.WHILE: while_stack.append(line_num) elif statement[0].category == Token.WEND: if not while_stack: raise RuntimeError(f"WEND without matching WHILE at line {line_num}") while_stack.pop() if while_stack: raise RuntimeError(f"WHILE without matching WEND at line {while_stack[0]}") def __clear_while_stack_on_goto(self, current_line, target_line): """Clear WHILE loop stack when GOTO jumps out of loops""" # Simple solution: GOTO clears the WHILE stack completely # This prevents infinite loops caused by corrupted loop state # Traditional BASIC behavior - GOTO breaks loop structures self.__while_stack.clear() def execute(self): """Execute the program""" self.__parser = BASICParser(self.__data) self.__data.restore(0) # reset data pointer # Validate WHILE-WEND pairs before execution self.__validate_while_wend() line_numbers = self.line_numbers() if len(line_numbers) > 0: # Set up an index into the ordered list # of line numbers that can be used for # sequential statement execution. The index # will be incremented by one, unless modified by # a jump index = 0 self.set_next_line_number(line_numbers[index]) # Run through the program until the # has line number has been reached while True: flowsignal = self.__execute(self.get_next_line_number()) self.__parser.last_flowsignal = flowsignal if flowsignal: if flowsignal.ftype == FlowSignal.SIMPLE_JUMP: # GOTO or conditional branch encountered # Clear WHILE loop stack if we're jumping out of loops target_line = flowsignal.ftarget current_line = self.get_next_line_number() self.__clear_while_stack_on_goto(current_line, target_line) try: index = line_numbers.index(flowsignal.ftarget) except ValueError: raise RuntimeError("Invalid line number supplied in GOTO or conditional branch: " + str(flowsignal.ftarget)) self.set_next_line_number(flowsignal.ftarget) elif flowsignal.ftype == FlowSignal.GOSUB: # Subroutine call encountered # Add line number of next instruction to # the return stack if index + 1 < len(line_numbers): self.__return_stack.append(line_numbers[index + 1]) else: raise RuntimeError("GOSUB at end of program, nowhere to return") # Set the index to be the subroutine start line # number try: index = line_numbers.index(flowsignal.ftarget) except ValueError: raise RuntimeError("Invalid line number supplied in subroutine call: " + str(flowsignal.ftarget)) self.set_next_line_number(flowsignal.ftarget) elif flowsignal.ftype == FlowSignal.RETURN: # Subroutine return encountered # Pop return address from the stack try: index = line_numbers.index(self.__return_stack.pop()) except ValueError: raise RuntimeError("Invalid subroutine return in line " + str(self.get_next_line_number())) except IndexError: raise RuntimeError("RETURN encountered without corresponding " + "subroutine call in line " + str(self.get_next_line_number())) self.set_next_line_number(line_numbers[index]) elif flowsignal.ftype == FlowSignal.STOP: break elif flowsignal.ftype == FlowSignal.LOOP_BEGIN: # Loop start encountered # Put loop line number on the stack so # that it can be returned to when the loop # repeats self.__return_loop[flowsignal.floop_var] = line_numbers[index] # Continue to the next statement in the loop index = index + 1 if index < len(line_numbers): self.set_next_line_number(line_numbers[index]) else: # Reached end of program raise RuntimeError("Program terminated within a loop") elif flowsignal.ftype == FlowSignal.LOOP_SKIP: # Loop variable has reached end value, so ignore # all statements within loop and move past the corresponding # NEXT statement index = index + 1 while index < len(line_numbers): next_line_number = line_numbers[index] temp_tokenlist = self.__program[next_line_number] if temp_tokenlist[0].category == Token.NEXT and \ len(temp_tokenlist) > 1: # Check the loop variable to ensure we have not found # the NEXT statement for a nested loop if temp_tokenlist[1].lexeme == flowsignal.ftarget: # Move the statement after this NEXT, if there # is one index = index + 1 if index < len(line_numbers): next_line_number = line_numbers[index] # Statement after the NEXT self.set_next_line_number(next_line_number) break index = index + 1 # Check we have not reached end of program if index >= len(line_numbers): # Terminate the program break elif flowsignal.ftype == FlowSignal.LOOP_REPEAT: # Loop repeat encountered # Pop the loop start address from the stack try: index = line_numbers.index(self.__return_loop.pop(flowsignal.floop_var)) except ValueError: raise RuntimeError("Invalid loop exit in line " + str(self.get_next_line_number())) except KeyError: raise RuntimeError("NEXT encountered without corresponding " + "FOR loop in line " + str(self.get_next_line_number())) self.set_next_line_number(line_numbers[index]) elif flowsignal.ftype == FlowSignal.WHILE_BEGIN: # WHILE loop start encountered # Put loop line number on the WHILE stack so # that it can be returned to when the loop repeats self.__while_stack.append(line_numbers[index]) # Continue to the next statement in the loop index = index + 1 if index < len(line_numbers): self.set_next_line_number(line_numbers[index]) else: # Reached end of program raise RuntimeError("Program terminated within a WHILE loop") elif flowsignal.ftype == FlowSignal.WHILE_SKIP: # WHILE condition is false, so skip # all statements within loop and move past the corresponding # WEND statement index = index + 1 wend_count = 0 while index < len(line_numbers): next_line_number = line_numbers[index] temp_tokenlist = self.__program[next_line_number] if temp_tokenlist[0].category == Token.WHILE: # Found nested WHILE, increment counter wend_count += 1 elif temp_tokenlist[0].category == Token.WEND: if wend_count == 0: # Found matching WEND statement # Move to the statement after this WEND, if there is one index = index + 1 if index < len(line_numbers): next_line_number = line_numbers[index] # Statement after the WEND self.set_next_line_number(next_line_number) break else: # This WEND belongs to a nested WHILE wend_count -= 1 index = index + 1 # Check we have not reached end of program if index >= len(line_numbers): # Terminate the program break elif flowsignal.ftype == FlowSignal.WHILE_REPEAT: # WHILE repeat encountered (WEND statement) # Pop the loop start address from the WHILE stack try: index = line_numbers.index(self.__while_stack.pop()) except ValueError: raise RuntimeError("Invalid WHILE loop exit in line " + str(self.get_next_line_number())) except IndexError: raise RuntimeError("WEND encountered without corresponding " + "WHILE loop in line " + str(self.get_next_line_number())) self.set_next_line_number(line_numbers[index]) else: index = index + 1 if index < len(line_numbers): self.set_next_line_number(line_numbers[index]) else: # Reached end of program break else: raise RuntimeError("No statements to execute") def delete(self): """Deletes the program by emptying the dictionary""" self.__program.clear() self.__data.delete() def delete_statement(self, line_number): """Deletes a statement from the program with the specified line number, if it exists :param line_number: The line number to be deleted """ self.__data.delData(line_number) try: del self.__program[line_number] except KeyError: raise KeyError("Line number does not exist") def get_next_line_number(self): """Returns the line number of the next statement to be executed :return: The line number """ return self.__next_stmt def set_next_line_number(self, line_number): """Sets the line number of the next statement to be executed :param line_number: The new line number """ self.__next_stmt = line_number def renumber(self, new_start=10, increment=10, old_start=None, old_end=None): """Renumber the program according to BASIC RENUMBER command specification :param new_start: First line number assigned during renumbering (default: 10) :param increment: Amount added to each successive line number (default: 10) :param old_start: Lowest line number to renumber (default: first line) :param old_end: Highest line number to renumber (default: last line) """ # Validate parameters if increment == 0: raise ValueError("Increment cannot be zero") if new_start < 1: raise ValueError("New start line number must be >= 1") if increment < 0: raise ValueError("Increment must be positive") line_numbers = self.line_numbers() if not line_numbers: return # No program to renumber # Set defaults for old_start and old_end if old_start is None: old_start = line_numbers[0] if old_end is None: old_end = line_numbers[-1] # Find lines within the renumbering range lines_to_renumber = [] for line_num in line_numbers: if old_start <= line_num <= old_end: lines_to_renumber.append(line_num) if not lines_to_renumber: return # No lines in range to renumber # Step 1: Create mapping of old line numbers to new line numbers line_mapping = {} new_line_num = new_start for old_line_num in lines_to_renumber: line_mapping[old_line_num] = new_line_num new_line_num += increment # Check for overflow (basic line numbers typically max at 65535) if new_line_num - increment > 65535: raise ValueError("Line numbers would exceed maximum value (65535)") # Check for conflicts with existing line numbers outside the range for new_num in line_mapping.values(): if new_num in line_numbers and new_num not in lines_to_renumber: # Find which old line this conflicts with for ln in line_numbers: if ln == new_num and ln not in lines_to_renumber: raise ValueError(f"New line number {new_num} conflicts with existing line {ln}") # Step 2: Update line number references in all program statements updated_program = {} updated_data = {} for line_num in line_numbers: statement = self.__program[line_num] # Update line number references within the statement updated_statement = self._update_line_references(statement, line_mapping) # Determine the new line number for this statement if line_num in line_mapping: new_line_num = line_mapping[line_num] else: new_line_num = line_num updated_program[new_line_num] = updated_statement # Handle DATA statements if statement and statement[0].category == Token.DATA: data_tokens = self.__data.getTokens(line_num) if data_tokens: updated_data[new_line_num] = data_tokens # Step 3: Replace the program with the updated version self.__program = updated_program # Update DATA storage self.__data.delete() for line_num, data_tokens in updated_data.items(): self.__data.addData(line_num, data_tokens) def _update_line_references(self, statement, line_mapping): """Update line number references within a statement :param statement: List of tokens representing the statement :param line_mapping: Dictionary mapping old line numbers to new ones :return: Updated statement with new line number references """ if not statement: return statement updated_statement = [] i = 0 while i < len(statement): token = statement[i] # Skip string literals and comments - they should not be modified if token.category == Token.STRING: updated_statement.append(token) i += 1 continue if token.category == Token.REM: # Everything after REM is a comment, copy rest as-is updated_statement.extend(statement[i:]) break # Check for line number references in specific contexts if self._is_line_number_reference(statement, i): if token.category == Token.UNSIGNEDINT: line_num = int(token.lexeme) if line_num in line_mapping: # Create new token with updated line number new_token = BASICToken(token.column, token.category, str(line_mapping[line_num])) updated_statement.append(new_token) else: updated_statement.append(token) else: updated_statement.append(token) else: updated_statement.append(token) i += 1 return updated_statement def _is_line_number_reference(self, statement, token_index): """Determine if the token at the given index is a line number reference :param statement: List of tokens representing the statement :param token_index: Index of the token to check :return: True if this token is a line number reference """ if token_index >= len(statement): return False token = statement[token_index] if token.category != Token.UNSIGNEDINT: return False # Check what comes before this number to determine context if token_index == 0: return False # First token is the line number itself, not a reference prev_token = statement[token_index - 1] # Direct line number references if prev_token.category in [Token.GOTO, Token.GOSUB, Token.THEN, Token.RESTORE]: return True # ON...GOTO and ON...GOSUB constructs if prev_token.category == Token.COMMA: # Look backward to find ON...GOTO or ON...GOSUB for j in range(token_index - 2, -1, -1): if statement[j].category == Token.ON: # Check if this is followed by GOTO or GOSUB for k in range(j + 1, token_index): if statement[k].category in [Token.GOTO, Token.GOSUB]: return True break elif statement[j].category not in [Token.UNSIGNEDINT, Token.COMMA, Token.NAME]: break # Check for ON...GOTO/GOSUB patterns if token_index >= 2: # Look for pattern: ON GOTO/GOSUB for j in range(token_index - 1, -1, -1): if statement[j].category == Token.ON: # Found ON, now look for GOTO or GOSUB before our number for k in range(j + 1, token_index): if statement[k].category in [Token.GOTO, Token.GOSUB]: return True break return False