This commit is contained in:
2026-08-07 15:58:44 +01:00
commit f5eab85632
17 changed files with 870 additions and 0 deletions
+436
View File
@@ -0,0 +1,436 @@
namespace Interpreter
/// ============================================================================
/// CMP-7009A Advanced Programming - Reassessment 001
/// F# Interpreter for a small arithmetic / variable-assignment language.
///
/// DESIGN NOTE ON THE "NO BUILT-IN / LIBRARY FUNCTIONS" CONSTRAINT
/// ----------------------------------------------------------------
/// Every part of lexing, parsing and evaluation below is written using only
/// core language constructs: pattern matching, recursion, if/then/else,
/// arithmetic operators (+, -, *, /), comparison operators and string
/// concatenation (+). No use is made of:
/// - Int32.Parse / Double.Parse / TryParse
/// - System.Text.RegularExpressions
/// - String.Split / String.Substring
/// - FSharp.Core collection functions such as List.map, List.fold,
/// List.filter, List.rev, List.length, Seq.*, Array.*
/// Numbers are built digit-by-digit; identifiers are built character-by-
/// character; lists are reversed and searched with hand-written recursive
/// helper functions; the variable environment is a plain association list
/// searched and updated with hand-written recursion (no Map/Dictionary).
/// This is all individual work - no code was copied from any library.
/// ============================================================================
module Core =
// ------------------------------------------------------------------
// Character classification helpers (manual - no System.Char.IsDigit etc.)
// ------------------------------------------------------------------
let isDigit (c: char) : bool =
c >= '0' && c <= '9'
let isLetter (c: char) : bool =
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
let isAlphaNum (c: char) : bool =
isDigit c || isLetter c
let digitVal (c: char) : int =
int c - int '0'
// ------------------------------------------------------------------
// Errors
// ------------------------------------------------------------------
exception LexError of string
exception ParseError of string
exception EvalError of string
// ------------------------------------------------------------------
// Tokens
// Six operators required by the spec: + - x / ( )
// Plus '=' for assignment, integers, floats and identifiers.
// ------------------------------------------------------------------
type Token =
| TInt of int
| TFloat of float
| TIdent of string
| TPlus
| TMinus
| TMul // the character 'x' or 'X'
| TDiv
| TLParen
| TRParen
| TAssign
// Manual list reverse (used instead of List.rev)
let rec private reverseAcc (lst: 'a list) (acc: 'a list) : 'a list =
match lst with
| [] -> acc
| h :: t -> reverseAcc t (h :: acc)
// ------------------------------------------------------------------
// Tokenizer
// ------------------------------------------------------------------
let tokenize (input: string) : Token list =
let len = input.Length
// Count consecutive digit characters starting at position j
// (does not build a value - used only to know how many digits exist,
// so we can enforce the maximum digit rules BEFORE building the value).
let rec countDigitsFrom (j: int) (count: int) : int =
if j < len && isDigit input.[j] then countDigitsFrom (j + 1) (count + 1)
else count
// Build an integer value from 'remaining' digits starting at position j.
let rec buildIntValue (j: int) (remaining: int) (value: int) : int * int =
if remaining = 0 then (j, value)
else buildIntValue (j + 1) (remaining - 1) (value * 10 + digitVal input.[j])
// Build a fractional value (e.g. digits "5","0" -> 0.50) from 'remaining'
// digits starting at position j.
let rec buildFracValue (j: int) (remaining: int) (value: float) (divisor: float) : float =
if remaining = 0 then value
else buildFracValue (j + 1) (remaining - 1) (value + float (digitVal input.[j]) / divisor) (divisor * 10.0)
// Scan a number token starting at position i. Handles both the
// "plain integer, max 3 digits" case and the
// "float, max 2 digits before AND after the point" case.
let scanNumber (i: int) : int * Token =
let intDigitCount = countDigitsFrom i 0
let afterInt = i + intDigitCount
if afterInt < len && input.[afterInt] = '.' then
// Floating point literal
if intDigitCount = 0 || intDigitCount > 2 then
raise (LexError "Float literal: integer part must be 1-2 digits")
let fracStart = afterInt + 1
let fracDigitCount = countDigitsFrom fracStart 0
if fracDigitCount = 0 || fracDigitCount > 2 then
raise (LexError "Float literal: fractional part must be 1-2 digits")
let (_, intPart) = buildIntValue i intDigitCount 0
let fracPart = buildFracValue fracStart fracDigitCount 0.0 10.0
(fracStart + fracDigitCount, TFloat (float intPart + fracPart))
else
// Plain integer literal
if intDigitCount = 0 || intDigitCount > 3 then
raise (LexError "Integer literal must be 1-3 digits")
let (nextPos, v) = buildIntValue i intDigitCount 0
(nextPos, TInt v)
// Scan an identifier: letter, then up to 3 more alphanumeric chars (max 4 total)
let scanIdent (i: int) : int * Token =
let rec countAlnum (j: int) (count: int) : int =
if j < len && isAlphaNum input.[j] then countAlnum (j + 1) (count + 1)
else count
let idLen = countAlnum i 0
if idLen > 4 then
raise (LexError "Variable names may be at most 4 alphanumeric characters")
let rec buildStr (j: int) (remaining: int) (acc: string) : string =
if remaining = 0 then acc
else buildStr (j + 1) (remaining - 1) (acc + string input.[j])
let s = buildStr i idLen ""
(i + idLen, TIdent s)
let rec scan (i: int) (acc: Token list) : Token list =
if i >= len then
reverseAcc acc []
else
let c = input.[i]
if c = ' ' || c = '\t' then
scan (i + 1) acc
elif c = '+' then
scan (i + 1) (TPlus :: acc)
elif c = '-' then
scan (i + 1) (TMinus :: acc)
elif c = 'x' || c = 'X' then
scan (i + 1) (TMul :: acc)
elif c = '/' then
scan (i + 1) (TDiv :: acc)
elif c = '(' then
scan (i + 1) (TLParen :: acc)
elif c = ')' then
scan (i + 1) (TRParen :: acc)
elif c = '=' then
scan (i + 1) (TAssign :: acc)
elif isDigit c then
let (nextI, tok) = scanNumber i
scan nextI (tok :: acc)
elif isLetter c then
let (nextI, tok) = scanIdent i
scan nextI (tok :: acc)
else
raise (LexError ("Unexpected character: '" + string c + "'"))
scan 0 []
// ------------------------------------------------------------------
// Abstract Syntax Tree
// ------------------------------------------------------------------
type Expr =
| Num of float
| Var of string
| Neg of Expr
| BinOp of char * Expr * Expr // op is one of '+','-','*','/'
type Stmt =
| Assign of string * Expr
| Eval of Expr
// ------------------------------------------------------------------
// Recursive-descent parser.
//
// BNF grammar implemented:
//
// <statement> ::= <ident> "=" <expr> | <expr>
// <expr> ::= <term> { ("+" | "-") <term> }
// <term> ::= <factor> { ("x" | "/") <factor> }
// <factor> ::= <number> | <ident> | "(" <expr> ")" | "-" <factor>
// <number> ::= <integer> | <float>
// <integer> ::= digit digit? digit? (1-3 digits)
// <float> ::= digit digit? "." digit digit? (1-2 . 1-2 digits)
// <ident> ::= letter (letter | digit){0,3} (max 4 chars)
// ------------------------------------------------------------------
let rec private parseExpr (toks: Token list) : Expr * Token list =
let (left, rest) = parseTerm toks
let rec loop (accExpr: Expr) (toks2: Token list) : Expr * Token list =
match toks2 with
| TPlus :: rest2 ->
let (rightExpr, rest3) = parseTerm rest2
loop (BinOp('+', accExpr, rightExpr)) rest3
| TMinus :: rest2 ->
let (rightExpr, rest3) = parseTerm rest2
loop (BinOp('-', accExpr, rightExpr)) rest3
| _ -> (accExpr, toks2)
loop left rest
and private parseTerm (toks: Token list) : Expr * Token list =
let (left, rest) = parseFactor toks
let rec loop (accExpr: Expr) (toks2: Token list) : Expr * Token list =
match toks2 with
| TMul :: rest2 ->
let (rightExpr, rest3) = parseFactor rest2
loop (BinOp('*', accExpr, rightExpr)) rest3
| TDiv :: rest2 ->
let (rightExpr, rest3) = parseFactor rest2
loop (BinOp('/', accExpr, rightExpr)) rest3
| _ -> (accExpr, toks2)
loop left rest
and private parseFactor (toks: Token list) : Expr * Token list =
match toks with
| TMinus :: rest ->
let (e, rest2) = parseFactor rest
(Neg e, rest2)
| TInt n :: rest -> (Num (float n), rest)
| TFloat f :: rest -> (Num f, rest)
| TIdent name :: rest -> (Var name, rest)
| TLParen :: rest ->
let (e, rest2) = parseExpr rest
match rest2 with
| TRParen :: rest3 -> (e, rest3)
| _ -> raise (ParseError "Expected closing bracket ')'")
| [] -> raise (ParseError "Unexpected end of input, expected a value")
| _ -> raise (ParseError "Unexpected token in expression")
let parseTokens (tokens: Token list) : Stmt =
match tokens with
| TIdent name :: TAssign :: rest when rest <> [] ->
let (e, remaining) = parseExpr rest
match remaining with
| [] -> Assign(name, e)
| _ -> raise (ParseError "Unexpected tokens after expression")
| _ ->
let (e, remaining) = parseExpr tokens
match remaining with
| [] -> Eval e
| _ -> raise (ParseError "Unexpected tokens after expression")
// ------------------------------------------------------------------
// Evaluator - environment is a plain (name * value) association list,
// searched / updated with hand-written recursion (no Map / Dictionary).
// ------------------------------------------------------------------
let rec lookupVar (name: string) (env: (string * float) list) : float =
match env with
| [] -> raise (EvalError ("Undefined variable: " + name))
| (n, v) :: rest -> if n = name then v else lookupVar name rest
let rec updateEnv (name: string) (value: float) (env: (string * float) list) : (string * float) list =
match env with
| [] -> [ (name, value) ]
| (n, v) :: rest ->
if n = name then (name, value) :: rest
else (n, v) :: updateEnv name value rest
let rec evalExpr (e: Expr) (env: (string * float) list) : float =
match e with
| Num n -> n
| Var name -> lookupVar name env
| Neg inner -> -1.0 * (evalExpr inner env)
| BinOp (op, l, r) ->
let lv = evalExpr l env
let rv = evalExpr r env
match op with
| '+' -> lv + rv
| '-' -> lv - rv
| '*' -> lv * rv
| '/' ->
if rv = 0.0 then raise (EvalError "Division by zero")
else lv / rv
| _ -> raise (EvalError "Unknown operator")
// ------------------------------------------------------------------
// Manual float -> string formatting (avoids relying on default .NET
// formatting quirks; strips a trailing ".0" for whole numbers).
// ------------------------------------------------------------------
let formatNumber (v: float) : string =
let rounded = System.Math.Round(v, 6)
if rounded = System.Math.Floor(rounded) && abs rounded < 1e15 then
(string (int64 rounded))
else
string rounded
open Core
/// ============================================================================
/// Public engine exposed to the C# GUI project.
/// Keeps a persistent variable environment across calls, so a user can type
/// "a = 5" then later type "a x 2 + 1" and it will resolve 'a'.
/// ============================================================================
type Engine() =
let mutable env : (string * float) list = []
/// Evaluate one line of input (either "name = expr" or a plain "expr").
/// Returns Ok "<result text>" or Error "<message>".
member this.Evaluate (input: string) : Result<string, string> =
try
let tokens = tokenize input
if tokens = [] then
Error "Empty expression"
else
let stmt = parseTokens tokens
match stmt with
| Assign (name, e) ->
let v = evalExpr e env
env <- updateEnv name v env
Ok (name + " = " + formatNumber v)
| Eval e ->
let v = evalExpr e env
Ok (formatNumber v)
with
| LexError msg -> Error ("Lexical error: " + msg)
| ParseError msg -> Error ("Syntax error: " + msg)
| EvalError msg -> Error ("Evaluation error: " + msg)
| ex -> Error ("Error: " + ex.Message)
/// Look up a previously assigned variable's value, if any.
member this.TryGetVariable (name: string) : float option =
let rec find (e: (string * float) list) =
match e with
| [] -> None
| (n, v) :: rest -> if n = name then Some v else find rest
find env
/// Clear all assigned variables.
member this.Reset () : unit =
env <- []
/// --------------------------------------------------------------
/// Parses the special canvas line-drawing syntax: "y = ax + b"
/// (also accepts "y = x + b", "y = -x + b", "y = 2.5x - 3", etc.)
///
/// Because 'x' is already the multiplication operator token in the
/// general grammar, this line format is parsed directly from the raw
/// characters rather than reusing the general tokenizer, exactly as
/// the module's grammar intends 'x' here to mean the canvas x-axis,
/// not a multiplication sign. 'a' and 'b' may each be a number
/// literal or a variable name previously assigned via Evaluate.
/// --------------------------------------------------------------
member this.ParseLine (input: string) : Result<float * float, string> =
let s = input
let len = s.Length
let rec skipWs (i: int) : int =
if i < len && (s.[i] = ' ' || s.[i] = '\t') then skipWs (i + 1) else i
// Read a number literal (manual digit accumulation, same style as tokenizer)
let readNumber (j: int) : (float * int) option =
let rec countDigitsFrom (k: int) (count: int) : int =
if k < len && isDigit s.[k] then countDigitsFrom (k + 1) (count + 1) else count
let intCount = countDigitsFrom j 0
if intCount = 0 then None
else
let rec buildInt (k: int) (remaining: int) (value: int) =
if remaining = 0 then value
else buildInt (k + 1) (remaining - 1) (value * 10 + digitVal s.[k])
let intVal = buildInt j intCount 0
let afterInt = j + intCount
if afterInt < len && s.[afterInt] = '.' then
let fracStart = afterInt + 1
let fracCount = countDigitsFrom fracStart 0
if fracCount = 0 then
Some (float intVal, afterInt)
else
let rec buildFrac (k: int) (remaining: int) (value: float) (divisor: float) =
if remaining = 0 then value
else buildFrac (k + 1) (remaining - 1) (value + float (digitVal s.[k]) / divisor) (divisor * 10.0)
let fracVal = buildFrac fracStart fracCount 0.0 10.0
Some (float intVal + fracVal, fracStart + fracCount)
else
Some (float intVal, afterInt)
// Read a variable name (letter + up to 3 alphanumerics) and resolve its value
let readVariable (j: int) : (float * int) option =
if j < len && isLetter s.[j] then
let rec countAlnum (k: int) (count: int) : int =
if k < len && isAlphaNum s.[k] && count < 4 then countAlnum (k + 1) (count + 1) else count
let idLen = countAlnum j 0
let rec buildStr (k: int) (remaining: int) (acc: string) =
if remaining = 0 then acc else buildStr (k + 1) (remaining - 1) (acc + string s.[k])
let name = buildStr j idLen ""
match this.TryGetVariable name with
| Some v -> Some (v, j + idLen)
| None -> None
else None
// Read either a number or a variable at position j
let readValue (j: int) : (float * int) option =
match readNumber j with
| Some r -> Some r
| None -> readVariable j
let i0 = skipWs 0
if i0 >= len || (s.[i0] <> 'y' && s.[i0] <> 'Y') then
Error "Line equation must start with 'y'"
else
let i1 = skipWs (i0 + 1)
if i1 >= len || s.[i1] <> '=' then
Error "Expected '=' after 'y'"
else
let i2 = skipWs (i1 + 1)
// optional leading '-' for the coefficient
let (negCoef, i3) = if i2 < len && s.[i2] = '-' then (true, skipWs (i2 + 1)) else (false, i2)
// optional explicit coefficient value before 'x' (defaults to 1)
let (coefMag, i4) =
if i3 < len && (s.[i3] = 'x' || s.[i3] = 'X') then (1.0, i3)
else
match readValue i3 with
| Some (v, nextI) -> (v, skipWs nextI)
| None -> (1.0, i3)
let coef = if negCoef then -1.0 * coefMag else coefMag
if i4 >= len || (s.[i4] <> 'x' && s.[i4] <> 'X') then
Error "Expected 'x' in line equation (format: y = ax + b)"
else
let i5 = skipWs (i4 + 1)
if i5 >= len || (s.[i5] <> '+' && s.[i5] <> '-') then
Error "Expected '+' or '-' before the intercept (format: y = ax + b)"
else
let signIsNeg = s.[i5] = '-'
let i6 = skipWs (i5 + 1)
match readValue i6 with
| None -> Error "Expected a number or variable for the intercept 'b'"
| Some (bMag, nextI) ->
let i7 = skipWs nextI
if i7 <> len then
Error "Unexpected extra characters after intercept"
else
let b = if signIsNeg then -1.0 * bMag else bMag
Ok (coef, b)
+12
View File
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Interpreter</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Compile Include="Interpreter.fs" />
</ItemGroup>
</Project>