Repository: fitbenchmarking/fitbenchmarking

Issue:
Refactor and add more tests for the FitbenchmarkParser class
There is a need to refactor the class and simplify/clean up the logic.

The test coverage needs to be increased to around 95%(preferably to 100%) for the `FitbenchmarkParser`. It is currently 76%.

This will avoid regression when adding the new feature to allow benchmarking with different starting conditions.



Discussion:
coveralls: 
[![Coverage Status](https://coveralls.io/builds/72050684/badge)](https://coveralls.io/builds/72050684)

coverage: 91.356% (+0.02%) from 91.338%
when pulling **6d90744c4b7152f08ed9c6f603423ceae47e764d on RabiyaF:1384-add-functionality-to-test-different-starting-conditions**
into **bc2e44149646cba56ef3f8270d8d27d00a40645d on fitbenchmarking:master**.

jess-farmer: @RabiyaF The windows integration tests are failing due to `PosixPath` giving the following error: `NotImplementedError: cannot instantiate 'PosixPath' on your system`. For testing on Windows, I think we need to use [`WindowsPath`](https://docs.python.org/3/library/pathlib.html#pathlib.WindowsPath) instead?
RabiyaF: I will rebase and have a look at this.
RabiyaF: I removed more dead code from the `parse` method. I am pretty confident that this PR should not break anything. The only thing I changed logically was the following condition because I think it should be `> 1`.

<img width="909" alt="image" src="https://github.com/user-attachments/assets/965db9c2-e160-48ce-93f1-0bcc1792fc85" /> 

Patch:
diff --git a/examples/benchmark_problems/Data_Assimilation/functions/lorentz.py b/examples/benchmark_problems/Data_Assimilation/functions/lorentz.py
index 62e4df07..0569875c 100644
--- a/examples/benchmark_problems/Data_Assimilation/functions/lorentz.py
+++ b/examples/benchmark_problems/Data_Assimilation/functions/lorentz.py
@@ -19,7 +19,7 @@ def lorentz3d(t, x, sigma, r, b):
         r (float): The value in the above equations
         b (float): The value in the above equations
     """
-    if len(x.shape) == 1:
+    if x.ndim == 1:
         return np.array([
             sigma * (x[1] - x[0]),
             r * x[0] - x[1] - x[0] * x[1],
diff --git a/examples/benchmark_problems/Data_Assimilation/functions/simplified_anac.py b/examples/benchmark_problems/Data_Assimilation/functions/simplified_anac.py
index 486eeac0..5fc1561d 100644
--- a/examples/benchmark_problems/Data_Assimilation/functions/simplified_anac.py
+++ b/examples/benchmark_problems/Data_Assimilation/functions/simplified_anac.py
@@ -18,7 +18,7 @@ def simplified_anac(t, x, gamma, mu):
         gamma (float): The value in the above equations
         mu (float): The value in the above equations
     """
-    if len(x.shape) == 1:
+    if x.ndim == 1:
         return np.array([
             x[1],
             gamma * x[0] + 2 * mu * x[0]**3
diff --git a/fitbenchmarking/parsing/fitbenchmark_parser.py b/fitbenchmarking/parsing/fitbenchmark_parser.py
index 00be8bb1..5ce088d4 100644
--- a/fitbenchmarking/parsing/fitbenchmark_parser.py
+++ b/fitbenchmarking/parsing/fitbenchmark_parser.py
@@ -3,9 +3,11 @@ This file implements a parser for the Fitbenchmark data format.
 """
 
 import importlib
-import os
 import re
 import sys
+from contextlib import suppress
+from functools import partialmethod
+from pathlib import Path
 from typing import Callable, Optional, Union
 
 import numpy as np
@@ -42,7 +44,6 @@ class FitbenchmarkParser(Parser):
         self.fitting_problem = FittingProblem(self.options)
 
         self._parsed_func = self._parse_function()
-        self._parsed_jac_func = self._parse_jac_function()
 
         self.fitting_problem.multifit = self._is_multifit()
 
@@ -54,42 +55,21 @@ class FitbenchmarkParser(Parser):
         self.fitting_problem.function = self._create_function()
         self.fitting_problem.format = self._entries["software"].lower()
 
-        plot_scale_options = ["loglog", "logy", "logx", "linear"]
-
-        if "plot_scale" in self._entries:
-            if self._entries["plot_scale"].lower() in plot_scale_options:
-                self.fitting_problem.plot_scale = self._entries[
-                    "plot_scale"
-                ].lower()
-            else:
-                raise ParsingError(
-                    "The plot scale should be one of these "
-                    f"options {plot_scale_options}"
-                )
-        else:
-            self.fitting_problem.plot_scale = "linear"
+        self.fitting_problem.plot_scale = self._get_plot_scale()
 
         # If using a multivariate function wrap the call to take a single
         # argument
-        if len(data_points[0]["x"].shape) > 1:
+        if data_points[0]["x"].ndim > 1:
             old_function = self.fitting_problem.function
-            all_data = []
-            count = 0
-            for dp in data_points:
-                all_data.append(dp["x"])
-                dp["x"] = np.arange(count, count + dp["x"].shape[0])
-                count = count + dp["x"].shape[0]
-            all_data = np.concatenate(all_data)
-
-            def new_function(x, *p):
-                inp = all_data[x]
-                return old_function(inp, *p)
-
-            self.fitting_problem.function = new_function
+            all_data = np.concatenate([dp["x"] for dp in data_points])
+
+            self.fitting_problem.function = lambda x, *p: old_function(
+                all_data[x], *p
+            )
             self.fitting_problem.multivariate = True
 
         # Set this flag if the output is non-scalar either
-        if len(data_points[0]["y"].shape) > 2:
+        if data_points[0]["y"].ndim > 1:
             self.fitting_problem.multivariate = True
 
         # EQUATION
@@ -101,16 +81,15 @@ class FitbenchmarkParser(Parser):
         # PARAMETER RANGES
         # Creates list containing tuples of lower and upper bounds
         # (lb,ub) for each parameter
-        vr = _parse_range(self._entries.get("parameter_ranges", ""))
-        if vr:
+        if vr := _parse_range(self._entries.get("parameter_ranges", "")):
             self.fitting_problem.set_value_ranges(vr)
 
         # FIT RANGES
         fit_ranges_str = self._entries.get("fit_ranges", "")
         # this creates a list of strs like '{key: val, ...}' and parses each
         fit_ranges = [
-            _parse_range("{" + r.split("}")[0] + "}")
-            for r in fit_ranges_str.split("{")[1:]
+            _parse_range(match)
+            for match in re.findall(r"\{[^}]*\}", fit_ranges_str)
         ]
 
         self._set_data_points(data_points, fit_ranges)
@@ -130,7 +109,7 @@ class FitbenchmarkParser(Parser):
         :return: True if the problem is a multi fit problem.
         :rtype: bool
         """
-        return False
+        return self._entries["input_file"].startswith("[")
 
     def _create_function(self) -> Callable:
         """
@@ -145,10 +124,11 @@ class FitbenchmarkParser(Parser):
         :return: The equation in the problem definition file.
         :rtype: str
         """
-        equation_count = len(self._parsed_func)
-        if equation_count == 1:
-            return self._parsed_func[0]["name"]
-        return f"{equation_count} Functions"
+        return (
+            self._parsed_func[0]["name"]
+            if len(self._parsed_func) == 1
+            else f"{len(self._parsed_func)} Functions"
+        )
 
     def _get_starting_values(self) -> list:
         """
@@ -157,19 +137,15 @@ class FitbenchmarkParser(Parser):
         :return: The starting values for the problem.
         :rtype: list
         """
-        # SasView functions can have reserved keywords so ignore these
-        ignore = ["name"]
-
-        starting_values = [
+        # Functions can have reserved "name" keyword so ignore this
+        return [
             {
-                name: val
-                for name, val in self._parsed_func[0].items()
-                if name not in ignore
+                key: val
+                for key, val in self._parsed_func[0].items()
+                if key != "name"
             }
         ]
 
-        return starting_values
-
     def _set_data_points(self, data_points: list, fit_ranges: list) -> None:
         """
         Sets the data points and fit range data in the fitting problem.
@@ -181,20 +157,35 @@ class FitbenchmarkParser(Parser):
         """
         self.fitting_problem.data_x = data_points[0]["x"]
         self.fitting_problem.data_y = data_points[0]["y"]
-        if "e" in data_points[0]:
-            self.fitting_problem.data_e = data_points[0]["e"]
+        self.fitting_problem.data_e = data_points[0].get("e", None)
 
         if fit_ranges and "x" in fit_ranges[0]:
             self.fitting_problem.start_x = fit_ranges[0]["x"][0]
             self.fitting_problem.end_x = fit_ranges[0]["x"][1]
 
+    def _get_plot_scale(self) -> str:
+        """
+        Gets the plot scale of the fitting problem.
+
+        :return: The plot scale. "linear" if not specified.
+        :rtype: str
+        """
+        valid_options = ["loglog", "logy", "logx", "linear"]
+        plot_scale = self._entries.get("plot_scale", "linear").lower()
+        if plot_scale not in valid_options:
+            raise ParsingError(
+                "The plot scale should be one of these "
+                f"options {valid_options}"
+            )
+        return plot_scale
+
     def _set_additional_info(self) -> None:
         """
         Sets any additional info for a fitting problem.
         """
         return
 
-    def _get_data_file(self):
+    def _get_data_file(self) -> list:
         """
         Find/create the (full) path to a data_file(s) specified in a
         FitBenchmark definition file, where the data_file is searched for in
@@ -203,124 +194,85 @@ class FitbenchmarkParser(Parser):
         :return: (full) path to a data file. Return None if not found
         :rtype: list<str>
         """
-        data_file_name = self._entries["input_file"]
-        if data_file_name.startswith("["):
-            # Parse list assuming filenames do not have quote symbols or commas
-            data_file_names = [
-                d.replace('"', "").replace("'", "").strip("[").strip("]")
-                for d in data_file_name.split(",")
-            ]
+        if self._is_multifit():
+            pattern = r"['\"]\s*([^'\"]+)\s*['\"]"
+            files = re.findall(pattern, self._entries["input_file"])
         else:
-            data_file_names = [data_file_name]
-
-        # find or search for path for data_file_name
-        data_files = []
-        for data_file_name in data_file_names:
-            data_file = None
-            for root, _, files in os.walk(os.path.dirname(self._filename)):
-                for name in files:
-                    if data_file_name == name:
-                        data_file = os.path.join(root, data_file_name)
-
-            if data_file is None:
-                LOGGER.error("Data file %s not found", data_file_name)
-
-            data_files.append(data_file)
+            files = [self._entries["input_file"]]
+
+        search_path = Path(self._filename).parent
+        subdirs = [d for d in search_path.iterdir() if d.is_dir()]
+
+        paths = []
+        for file in files:
+            # Tries to find the first file in the sub folders
+            # with the same name. The data file is usually
+            # stored in a sub folder called data_files or data.
+            # The logic will also be able to handle cases
+            # where the sub folder has a different name.
+            found_path = next(
+                (
+                    subdir_match
+                    for subdir in subdirs
+                    for subdir_match in subdir.rglob(file)
+                ),
+                None,
+            )
+            if found_path is None:
+                # Then look for it in parent directory of self._filename
+                found_path = next(search_path.rglob(file), None)
+                if found_path is None:
+                    LOGGER.error("Data file %s not found", file)
+            paths.append(found_path)
 
-        return data_files
+        return paths
 
-    def _get_data_problem_entries(self):
+    def _get_data_problem_entries(self) -> dict:
         """
         Get the problem entries from a problem definition file.
 
         :return: The entries from the file with string values
         :rtype: dict
         """
-
         entries = {}
         for line in self.file.readlines():
-            # Discard comments
-            line = line.split("#", 1)[0]
-            if line.strip() == "":
-                continue
-
-            lhs, rhs = line.split("=", 1)
-            key = lhs.strip()
-            value = rhs.strip().strip('"').strip("'")
-            if key == "name":
-                value = re.sub(r"[\\/]", "", value)
-            entries[key] = value
+            # Discard comment after #
+            line = (
+                text[1] if (text := re.search(r"^(.*?)\s*#", line)) else line
+            )
+            if line and (match := re.search(r"(\w+)\s*=\s*(.+)", line)):
+                key, value = match.groups()
+                if key == "name":
+                    value = re.sub(r"[\\/]", "", value)
+                entries[key] = re.sub(r"^\s*['\"]?|['\"]?\s*$", "", value)
 
         return entries
 
-    def _parse_function(self, func: Optional[str] = None):
+    def _parse_string(self, key: str, func: Optional[str] = None):
         """
-        Get the params from the function as a list of dicts from the data
-        file.
+        if key == "function"
+            Get the params from the function as a list of dicts
+            from the data file.
+        if key == "jac"
+            Get the (relative) path and the name of the jacobian
+            function from the data file. Returns a list of dicts
+            if these have been defined.
 
         :param func: The function to parse. Optional, defaults to
-                     self._entries['function']
+                     self._entries[key]
         :type func: str
         :return: Function definition in format:
                  [{name1: value1, name2: value2, ...}, ...]
         :rtype: list of dict
         """
-        function_def = []
-
-        if func is None:
-            func = self._entries["function"]
-
-        for f in func.split(";"):
-            func_dict = self._parse_single_function(f)
-            function_def.append(func_dict)
-
-        return function_def
-
-    def _parse_jac_function(self, func: Optional[str] = None):
-        """
-        Get the (relative) path and the name of the jacobian function
-        from the data file. Returns a list of dicts if these have been
-        defined. Returns None otherwise.
-
-        :param func: The function to parse. Optional, defaults to
-                     self._entries['jac']
-        :type func: str
-        :return: Function definition in format:
-                 [{name1: value1, name2: value2, ...}, ...]
-        :rtype: list of dict
-        """
-
-        function_def = []
-
-        if "jac" not in self._entries:
-            return None
-
-        if func is None:
-            func = self._entries["jac"]
-
-        for f in func.split(";"):
-            func_dict = self._parse_single_function(f)
-            function_def.append(func_dict)
-
+        func = func or self._entries[key]
+        function_def = [
+            self._parse_single_function(f) for f in func.split(";")
+        ]
         return function_def
 
-    def _dense_jacobian(self) -> Optional[Callable]:
-        """
-        Function to help getting dense jac.
-
-        :return: A callable function or None
-        :rtype: callable or None
-        """
-        return self._get_jacobian("dense_func")
-
-    def _sparse_jacobian(self) -> Optional[Callable]:
-        """
-        Function to help getting sparse jac.
-
-        :return: A callable function or None
-        :rtype: callable or None
-        """
-        return self._get_jacobian("sparse_func")
+    _parse_function = partialmethod(_parse_string, "function")
+    _parse_jac_function = partialmethod(_parse_string, "jac")
 
     def _get_jacobian(self, jac_type) -> Optional[Callable]:
         """
@@ -332,19 +284,21 @@ class FitbenchmarkParser(Parser):
         :return: A callable function or None
         :rtype: callable or None
         """
-
-        if self._parsed_jac_func is None:
+        if "jac" not in self._entries:
             return None
 
-        if jac_type not in self._parsed_jac_func[0]:
+        parsed_jac_func = self._parse_jac_function()
+        if jac_type not in parsed_jac_func[0]:
             return None
 
-        pf = self._parsed_jac_func[0]
-        path = os.path.join(os.path.dirname(self._filename), pf["module"])
-        sys.path.append(os.path.dirname(path))
-        module = importlib.import_module(os.path.basename(path))
-        func = getattr(module, pf[jac_type])
-        return func
+        pf = parsed_jac_func[0]
+        module_path = Path(self._filename).parent / pf["module"]
+        sys.path.append(str(module_path.parent))
+        module = importlib.import_module(module_path.stem)
+        return getattr(module, pf[jac_type])
+
+    _dense_jacobian = partialmethod(_get_jacobian, "dense_func")
+    _sparse_jacobian = partialmethod(_get_jacobian, "sparse_func")
 
     @classmethod
     def _parse_single_function(cls, func: str) -> dict:
@@ -361,7 +315,7 @@ class FitbenchmarkParser(Parser):
           - strings (not containing '[](),=' )
 
         Example:
-          a=1,b=3.2,c='foo',d=(e=true,f='bar'),h=[1.0,1.0,1.0]
+          a=1,b=3.2,c='foo',d=(e=true,f='bar'),g=[1.0,1.0,1.0]
 
         :param func: The definition to parse
         :type func: str
@@ -370,29 +324,33 @@ class FitbenchmarkParser(Parser):
         :return: The function as a dict of name, value pairs.
         :rtype: dict
         """
-        lhs, rhs = func.strip().split("=", 1)
-        name = lhs
-        if not re.match(r"^\w+$", name):
-            raise ParsingError(
-                f"Unexpected character in parameter name: {name}"
-            )
+        func_dict = {}
+        pattern = (
+            r"\s*([^=\s]+)\s*=\s*(\([^)]*\)|\[[^\]]*\]|'"
+            r"[^']*'|\"[^\"]*\"|[-\w\.\+e/]+)\s*(?:,|$)"
+        )
+        matches = re.findall(pattern, func)
 
-        if rhs[0] in "([":
-            value, rem = cls._parse_parens(rhs)
-        else:
-            value, _, rem = rhs.partition(",")
-            value = cls._parse_function_value(value)
+        for key, value in matches:
+            if not re.match(r"^\w+$", key):
+                raise ParsingError(
+                    f"Unexpected character in parameter name: {key}"
+                )
+
+            if value.startswith(("(", "[")):
+                value = cls._parse_parens(value)
+            else:
+                value = cls._parse_function_value(value.strip("'\""))
+
+            func_dict[key] = value
 
-        func_dict = {name: value}
-        if rem:
-            func_dict.update(cls._parse_single_function(rem))
         return func_dict
 
     @classmethod
     def _parse_parens(cls, string: str):
         """
         Parse a string starting with an opening bracket into the parsed
-        contents of the brackets and the remainder after the brackets.
+        contents of the brackets.
 
         If the string starts with '(' a dictionary is returned.
         If the string starts with '[' a list is returned.
@@ -401,34 +359,24 @@ class FitbenchmarkParser(Parser):
         :type string: str
         :raises ParsingError: If brackets remain unclosed
 
-        :return: The parsed value, the remainder of the string
-        :rtype: Union[dict, list], str
-        """
-        count = 0
-        delim = "[]" if string[0] == "[" else "()"
-
-        for i, c in enumerate(string):
-            if c == delim[0]:
-                count += 1
-            elif c == delim[1]:
-                count -= 1
-            if count == 0:
-                value = string[:i]
-                rem = string[i + 1 :].strip(",")
-                break
-        else:
+        :return: The parsed value
+        :rtype: Union[dict, list]
+        """
+        if any(
+            string.count(open_bracket) != string.count(close_bracket)
+            for open_bracket, close_bracket in {"(": ")", "[": "]"}.items()
+        ):
             raise ParsingError("Not all brackets are closed in function.")
 
-        if delim == "()":
-            value = cls._parse_single_function(value[1:])
-        else:  # []
-            value = [
-                cls._parse_function_value(v.strip())
-                for v in value[1:].split(",")
-                if v != ""
-            ]
+        if string.startswith("("):
+            value = cls._parse_single_function(
+                string.removeprefix("(").removesuffix(")")
+            )
+        else:  # string.startswith("[")
+            string = string.removeprefix("[").removesuffix("]")
+            value = [cls._parse_function_value(s) for s in string.split(",")]
 
-        return value, rem
+        return value
 
     @staticmethod
     def _parse_function_value(value: str) -> Union[int, float, bool, str]:
@@ -446,13 +394,11 @@ class FitbenchmarkParser(Parser):
         if value.lower() == "false":
             return False
         for convert in [int, float]:
-            try:
+            with suppress(ValueError, TypeError):
                 return convert(value)
-            except (ValueError, TypeError):
-                continue
         return value
 
-    def _get_data_points(self, data_file_path: str):
+    def _get_data_points(self, data_file_path: str) -> dict:
         """
         Get the data points of the problem from the data file.
 
@@ -467,42 +413,36 @@ class FitbenchmarkParser(Parser):
             data_text = f.readlines()
 
         first_row = _find_first_line(data_text)
-        dim = len(data_text[first_row].split())
+        data_lines = data_text[first_row:]
+        dim = len(data_lines[0].split())
         cols = _get_column_data(data_text, first_row, dim)
 
-        if not cols["x"] or not cols["y"]:
-            raise ParsingError("Input files need both X and Y values.")
-        if cols["e"] and len(cols["y"]) != len(cols["e"]):
-            raise ParsingError("Error must be of the same dimension as Y.")
-
-        data_points = np.zeros((len(data_text) - first_row, dim))
+        data_points = np.full((len(data_lines), dim), np.nan)
 
-        for idx, line in enumerate(data_text[first_row:]):
-            point_text = line.split()
+        for idx, line in enumerate(data_lines):
             # Skip any values that can't be represented
-            try:
-                point = [float(val) for val in point_text]
-            except ValueError:
-                point = [np.nan for _ in point_text]
-            data_points[idx, :] = point
+            with suppress(ValueError):
+                data_points[idx] = [float(val) for val in line.split()]
 
         # Strip all np.nan entries
-        data_points = data_points[~np.isnan(data_points[:, 0]), :]
+        data_points = data_points[~np.isnan(data_points).any(axis=1)]
 
         # Split into x, y, and e
-        data = {key: data_points[:, cols[key]] for key in ["x", "y"]}
-        if cols["e"]:
-            data["e"] = data_points[:, cols["e"]]
+        data = {
+            key: data_points[:, indices]
+            for key, indices in cols.items()
+            if indices
+        }
 
         # Flatten if the columns are 1D
-        for key, col in cols.items():
-            if len(col) == 1:
-                data[key] = data[key].flatten()
+        for key in data:
+            if data[key].shape[1] == 1:
+                data[key] = data[key].ravel()
 
         return data
 
 
-def _parse_range(range_str):
+def _parse_range(range_str: str) -> dict:
     """
     Parse a range string for the problem into a dict or list of dict if
     multi-fit.
@@ -515,46 +455,44 @@ def _parse_range(range_str):
              e.g. {'x': [0, 10]}
     :rtype: dict
     """
-    if not range_str:
-        return {}
-
     output_ranges = {}
-    range_str = range_str.strip("{").strip("}")
-    tmp_ranges = range_str.split(",")
-    ranges = []
-    cur_str = ""
-    for r in tmp_ranges:
-        cur_str += r
-        balanced = True
-        for lb, rb in ["[]", "{}", "()"]:
-            if cur_str.count(lb) > cur_str.count(rb):
-                balanced = False
-            elif cur_str.count(lb) < cur_str.count(rb):
-                raise ParsingError(f"Unbalanced brackets in range: {r}")
-        if balanced:
-            ranges.append(cur_str)
-            cur_str = ""
-        else:
-            cur_str += ","
 
-    for r in ranges:
-        name, val = r.split(":")
-        name = name.strip().strip('"').strip("'").lower()
+    if range_str:
+        range_str = range_str.removeprefix("{").removesuffix("}")
+        bracket_pairs = {"(": ")", "[": "]", "{": "}"}
+
+        if any(
+            range_str.count(open_bracket) != range_str.count(close_bracket)
+            for open_bracket, close_bracket in bracket_pairs.items()
+        ):
+            raise ParsingError(f"Unbalanced parentheses in {range_str}")
 
-        # Strip off brackets and split on comma
-        val = val.strip(" ")[1:-1].split(",")
-        val = [v.strip() for v in val]
-        try:
-            pair = [float(val[0]), float(val[1])]
-        except ValueError as e:
-            raise ParsingError(f"Expected floats in range: {r}") from e
+        pattern = (
+            r'\'?"?([\w\.\s]+)"?\'?\s*:\s*'
+            r"([\(\[\{])([\d\.]+),\s*([\d\.]+)([\)\]\}])"
+        )
 
-        if pair[0] >= pair[1]:
+        if not (matches := re.findall(pattern, range_str)):
             raise ParsingError(
-                "Min value must be smaller than max value in range: {r}"
+                f"Could not parse string '{range_str}'. "
+                "The range of variables should be defined as "
+                "'var': (min, max) where var should be the "
+                "variable name and min and max values of the "
+                "range should be defined as floats."
             )
 
-        output_ranges[name] = pair
+        for var, open_bracket, low, high, close_bracket in matches:
+            expected_closing = bracket_pairs.get(open_bracket, "")
+            if close_bracket != expected_closing:
+                raise ParsingError(f"Mismatched parentheses for '{var}'")
+
+            low, high = float(low), float(high)
+            if low >= high:
+                raise ParsingError(
+                    f"MIN must be smaller than MAX value for '{var}'"
+                )
+
+            output_ranges[var.lower().strip()] = [low, high]
 
     return output_ranges
 
@@ -570,22 +508,19 @@ def _find_first_line(file_lines: list[str]) -> int:
     :return: index of the first file line with data.
     :rtype: int
     """
-    for i, line in enumerate(file_lines):
-        line = line.strip()
-        if not line:
-            continue
-        try:
-            float(line.split()[0])
-        except ValueError:
-            continue
-        return i
-
-    raise ParsingError("Could not find data points")
+    index = (
+        i
+        for i, line in enumerate(file_lines)
+        if re.match(r"^\s*-?\d+(\.\d+)?", line)
+    )
+    if (data_start_index := next(index, None)) is None:
+        raise ParsingError("Could not find data points")
+    return data_start_index
 
 
-def _get_column_data(file_lines: list[str], first_row: int, dim: int) -> list:
+def _get_column_data(file_lines: list[str], first_row: int, dim: int) -> dict:
     """
-    Gets the data in the file as a dictionary of x, y and e data.
+    Gets the data columns in the file as a dictionary of x, y and e.
 
     :param file_lines: A list of lines from a file.
     :type file_lines: A list of strings.
@@ -594,33 +529,14 @@ def _get_column_data(file_lines: list[str], first_row: int, dim: int) -> list:
     :param dim: The number of columns in the file.
     :type dim: int
 
-    :return: index of the first file line with data.
-    :rtype: int
+    :return: A dictionary mapping "x", "y", and "e" to their
+             respective column indices.
+    :rtype: dict
     """
     cols = {"x": [], "y": [], "e": []}
-    num_cols = 0
-    if first_row != 0:
-        header = file_lines[0].split()
-        for heading in header:
-            if heading == "#":
-                continue
-            if heading[0] == "<" and heading[-1] == ">":
-                heading = heading[1:-1]
-            col_type = heading[0].lower()
-            if col_type in ["x", "y", "e"]:
-                cols[col_type].append(num_cols)
-                num_cols += 1
-            else:
-                raise ParsingError(
-                    "Unrecognised header line, header names must start with "
-                    '"x", "y", or "e".'
-                    "Examples are: "
-                    '"# X Y E", "#   x0 x1 y e", "# X0 X1 Y0 Y1 E0 E1", '
-                    '"<X> <Y> <E>", "<X0> <X1> <Y> <E>"...'
-                )
-        if dim != num_cols:
-            raise ParsingError("Could not match header to columns.")
-    else:
+
+    if first_row == 0:
+        # No header in data file
         cols["x"], cols["y"] = [0], [1]
         if dim == 3:
             cols["e"] = [2]
@@ -629,4 +545,30 @@ def _get_column_data(file_lines: list[str], first_row: int, dim: int) -> list:
                 "Cannot infer size of inputs and outputs in datafile. "
                 "Headers are required when not using 1D inputs and outputs."
             )
+    else:
+        pattern = r"\bX\d*\b|\bY\d*\b|\bE\d*\b"
+        matches = [
+            m.lower()
+            for m in re.findall(pattern, file_lines[0], re.IGNORECASE)
+        ]
+        if matches:
+            if dim != len(matches):
+                raise ParsingError("Could not match header to columns.")
+            for ix, col_name in enumerate(matches):
+                if (col_type := col_name[0]) in cols:
+                    cols[col_type].append(ix)
+        else:
+            raise ParsingError(
+                "Unrecognised header line, header names must start with "
+                '"x", "y", or "e".'
+                "Examples are: "
+                '"# X Y E", "#   x0 x1 y e", "# X0 X1 Y0 Y1 E0 E1", '
+                '"<X> <Y> <E>", "<X0> <X1> <Y> <E>"...'
+            )
+
+    if not (cols["x"] and cols["y"]):
+        raise ParsingError("Input files need both X and Y values.")
+    if cols["e"] and len(cols["y"]) != len(cols["e"]):
+        raise ParsingError("Error must be of the same dimension as Y.")
+
     return cols
diff --git a/fitbenchmarking/parsing/ivp_parser.py b/fitbenchmarking/parsing/ivp_parser.py
index ee125d3f..929427f4 100644
--- a/fitbenchmarking/parsing/ivp_parser.py
+++ b/fitbenchmarking/parsing/ivp_parser.py
@@ -52,7 +52,7 @@ class IVPParser(FitbenchmarkParser):
         self._starting_values = [{n: pf[n] for n in p_names}]
 
         def fitFunction(x, *p):
-            if len(x.shape) == 1:
+            if x.ndim == 1:
                 x = np.array([x])
             y = np.zeros_like(x)
             for i, inp in enumerate(x):
diff --git a/fitbenchmarking/parsing/mantiddev_parser.py b/fitbenchmarking/parsing/mantiddev_parser.py
index 0ff64986..2331766e 100644
--- a/fitbenchmarking/parsing/mantiddev_parser.py
+++ b/fitbenchmarking/parsing/mantiddev_parser.py
@@ -53,8 +53,7 @@ class MantidDevParser(FitbenchmarkParser):
         :return: the jacobian, or None
         :rtype: Callable or None
         """
-        jac = super()._dense_jacobian()
-        if jac is not None:
+        if (jac := super()._dense_jacobian()) is not None:
             return jac
 
         fp = self.fitting_problem
@@ -164,15 +163,6 @@ class MantidDevParser(FitbenchmarkParser):
 
         return wrapped
 
-    def _is_multifit(self) -> bool:
-        """
-        Returns true if the problem is a multi fit problem.
-
-        :return: True if the problem is a multi fit problem.
-        :rtype: bool
-        """
-        return self._entries["input_file"][0] == "["
-
     def _get_starting_values(self) -> list:
         """
         Returns the starting values for the problem.


Test patch:
diff --git a/fitbenchmarking/parsing/tests/ivp/functions/simplified_anac.py b/fitbenchmarking/parsing/tests/ivp/functions/simplified_anac.py
index ea69d132..6dd721cc 100644
--- a/fitbenchmarking/parsing/tests/ivp/functions/simplified_anac.py
+++ b/fitbenchmarking/parsing/tests/ivp/functions/simplified_anac.py
@@ -14,9 +14,9 @@ def simplified_anac(t, x, gamma, mu):
         gamma (float): The value in the above equations
         mu (float): The value in the above equations
     """
-    if len(x.shape) == 1:
+    if x.ndim == 1:
         return np.array([x[1], gamma * x[0] + 2 * mu * x[0] ** 3])
-    elif len(x.shape) == 2:
+    elif x.ndim == 2:
         return np.array([x[:, 1], gamma * x[:, 0] + 2 * mu * x[:, 0] ** 3])
     else:
         raise ValueError("x is the wrong shape in simplified_anac call.")
diff --git a/fitbenchmarking/parsing/tests/test_fitbenchmark_parser.py b/fitbenchmarking/parsing/tests/test_fitbenchmark_parser.py
new file mode 100644
index 00000000..f7348d82
--- /dev/null
+++ b/fitbenchmarking/parsing/tests/test_fitbenchmark_parser.py
@@ -0,0 +1,799 @@
+"""
+This file contains tests for the fitbenchmark parser.
+"""
+
+import sys
+from pathlib import Path
+from unittest import TestCase
+from unittest.mock import MagicMock, mock_open, patch
+
+import numpy as np
+from parameterized import parameterized
+
+from fitbenchmarking.parsing.fitbenchmark_parser import (
+    FitbenchmarkParser,
+    _find_first_line,
+    _get_column_data,
+    _parse_range,
+)
+from fitbenchmarking.parsing.fitting_problem import FittingProblem
+from fitbenchmarking.utils import exceptions
+from fitbenchmarking.utils.options import Options
+
+
+class TestFitbenchmarkParser(TestCase):
+    """
+    Tests the FitbenchmarkParser class and related functions.
+    """
+
+    @patch.object(FitbenchmarkParser, "__init__", lambda a, b, c: None)
+    def setUp(self):
+        """
+        Set up resources before each test case.
+        """
+        self.parser = FitbenchmarkParser("test_file.txt", {"parse"})
+
+    @parameterized.expand(
+        [
+            (["name = '\\sample_problem'"], {"name": "sample_problem"}),
+            (["name = 'sample_problem/'"], {"name": "sample_problem"}),
+            (['name = "sample_problem"'], {"name": "sample_problem"}),
+            (["name = '\\sample_problem/////'"], {"name": "sample_problem"}),
+            (["#fitbenchmarking"], {}),
+            ([" #fitbenchmarking"], {}),
+            (
+                ["name = 'sample_problem' # my important comment"],
+                {"name": "sample_problem"},
+            ),
+            (
+                ["function = 'Userdefined: a/4 ** 6'"],
+                {"function": "Userdefined: a/4 ** 6"},
+            ),
+            (
+                ["function = 'Userdefined = a/4 ** 6'"],
+                {"function": "Userdefined = a/4 ** 6"},
+            ),
+            (
+                ["fit_ranges = {'x': [29650.0, 30500.0]}"],
+                {"fit_ranges": "{'x': [29650.0, 30500.0]}"},
+            ),
+            (
+                ["parameter_ranges = {'f0.I':(1,2), 'f0.A':(3,4)}"],
+                {"parameter_ranges": "{'f0.I':(1,2), 'f0.A':(3,4)}"},
+            ),
+            (
+                ["function = 'name=FlatBackground, A0=30'"],
+                {"function": "name=FlatBackground, A0=30"},
+            ),
+        ]
+    )
+    def test_get_data_problem_entries(self, file_data, expected):
+        """
+        Verifies the output of _get_data_problem_entries() method.
+        """
+        self.parser.file = MagicMock()
+        self.parser.file.readlines.return_value = file_data
+        assert self.parser._get_data_problem_entries() == expected
+
+    @parameterized.expand(
+        [
+            (
+                {"function": "name=F1, A=0.04, B=0.03; name=F2, C=30"},
+                [
+                    {"name": "F1", "A": 0.04, "B": 0.03},
+                    {"name": "F2", "C": 30},
+                ],
+            ),
+            (
+                {"function": "name=F1, A=0.04, B=0.03"},
+                [{"name": "F1", "A": 0.04, "B": 0.03}],
+            ),
+        ]
+    )
+    def test_parse_function(self, entries, expected):
+        """
+        Verifies the output of _parse_function() method.
+        """
+        self.parser._entries = entries
+        assert self.parser._parse_function() == expected
+
+    @parameterized.expand(
+        [
+            ("True", True, bool),
+            ("TRUE", True, bool),
+            ("true", True, bool),
+            ("False", False, bool),
+            ("FALSE", False, bool),
+            ("false", False, bool),
+            ("BackToBackExponential", "BackToBackExponential", str),
+            ("1", 1, int),
+            ("100", 100, int),
+            ("2.0", 2.0, float),
+            ("200.0", 200.0, float),
+        ]
+    )
+    def test_parse_function_value(self, input, expected, expected_type):
+        """
+        Verifies the output of _parse_function_value() method.
+        """
+        result = self.parser._parse_function_value(input)
+        assert result == expected
+        assert isinstance(result, expected_type)
+
+    @parameterized.expand(
+        [
+            (
+                "name=BackToBackExponential, I=1.5e4, A=0.04, B=3",
+                {
+                    "name": "BackToBackExponential",
+                    "I": 15000.0,
+                    "A": 0.04,
+                    "B": 3,
+                },
+            ),
+            (
+                "name = BackToBackExponential, I = 1.5e4, A = 0.04, B = 3",
+                {
+                    "name": "BackToBackExponential",
+                    "I": 15000.0,
+                    "A": 0.04,
+                    "B": 3,
+                },
+            ),
+            (
+                "name= BackToBackExponential, I= 1.5e4, A= 0.04, B= 3",
+                {
+                    "name": "BackToBackExponential",
+                    "I": 15000.0,
+                    "A": 0.04,
+                    "B": 3,
+                },
+            ),
+            (
+                "name =BackToBackExponential, I =1.5e4, A =0.04, B =3",
+                {
+                    "name": "BackToBackExponential",
+                    "I": 15000.0,
+                    "A": 0.04,
+                    "B": 3,
+                },
+            ),
+            (
+                " name=FlatBackground, A0=30",
+                {"name": "FlatBackground", "A0": 30},
+            ),
+            (
+                "a=1,b=3.2,c='foo',d=(e=true,f='bar'),g=[1.0,1.0,1.0]",
+                {
+                    "a": 1,
+                    "b": 3.2,
+                    "c": "foo",
+                    "d": {"e": True, "f": "bar"},
+                    "g": [1.0, 1.0, 1.0],
+                },
+            ),
+            (
+                "d=(e=true,f='bar'),  g=[1.0,1.0,1.0]",
+                {"d": {"e": True, "f": "bar"}, "g": [1.0, 1.0, 1.0]},
+            ),
+            (
+                'd=(e=true ,f = "bar"),  g=[ 1.0, 1.0, 1.0]',
+                {"d": {"e": True, "f": "bar"}, "g": [1.0, 1.0, 1.0]},
+            ),
+            (
+                "module=func/anac,func=anac,step=0.1,gamma=15,mu=-0.5",
+                {
+                    "module": "func/anac",
+                    "func": "anac",
+                    "step": 0.1,
+                    "gamma": 15,
+                    "mu": -0.5,
+                },
+            ),
+        ]
+    )
+    def test_parse_single_function_with_valid_inputs(self, input, expected):
+        """
+        Verifies the output of _parse_single_function() method with valid
+        inputs.
+        """
+        assert self.parser._parse_single_function(input) == expected
+
+    @parameterized.expand(
+        [
+            "na+me= BackToBackExponential",
+            "na.me= BackToBackExponential",
+            "na$me = BackToBackExponential",
+            "na%me = BackToBackExponential",
+        ]
+    )
+    def test_parse_single_function_with_invalid_inputs(self, input):
+        """
+        Verifies the ParsingError is raised by _parse_single_function.
+        """
+        with self.assertRaises(exceptions.ParsingError):
+            _ = self.parser._parse_single_function(input)
+
+    @parameterized.expand(['d=(e=true ,f = "bar"', "g=1.0,1.0,1.0]"])
+    def test_parse_parens_with_invalid_inputs(self, input):
+        """
+        Verifies the ParsingError is raised by _parse_parens.
+        """
+        with self.assertRaises(exceptions.ParsingError):
+            _ = self.parser._parse_parens(input)
+
+    @parameterized.expand(
+        [
+            (
+                "{'f0.I':(1,2),'f0.A':(3, 4), 'f1.B':(5, 6)}",
+                {"f0.i": [1.0, 2.0], "f0.a": [3.0, 4.0], "f1.b": [5.0, 6.0]},
+            ),
+            (
+                "{'f0.I':[1,2],'f0.A':[3, 4], 'f1.B':[5, 6]}",
+                {"f0.i": [1.0, 2.0], "f0.a": [3.0, 4.0], "f1.b": [5.0, 6.0]},
+            ),
+            (
+                "{'f0.I':{1,2},'f0.A':{3, 4}, 'f1.B':{5, 6}}",
+                {"f0.i": [1.0, 2.0], "f0.a": [3.0, 4.0], "f1.b": [5.0, 6.0]},
+            ),
+            (
+                "{'f0.I':(1,2),'f0.A':[3, 4], 'f1.B':{5, 6}}",
+                {"f0.i": [1.0, 2.0], "f0.a": [3.0, 4.0], "f1.b": [5.0, 6.0]},
+            ),
+            (
+                '{"f0.I":(1,2),"f0.A":[3, 4], "f1.B":{5, 6}}',
+                {"f0.i": [1.0, 2.0], "f0.a": [3.0, 4.0], "f1.b": [5.0, 6.0]},
+            ),
+            (
+                '{"f0.I  ":(1,2),"f0.A ":[3, 4], " f1.B":{5, 6}}',
+                {"f0.i": [1.0, 2.0], "f0.a": [3.0, 4.0], "f1.b": [5.0, 6.0]},
+            ),
+        ]
+    )
+    def test_parse_range_with_valid_inputs(self, range_str, expected):
+        """
+        Verifies the output of _parse_range function with valid inputs.
+        """
+        assert _parse_range(range_str) == expected
+
+    @parameterized.expand(
+        [
+            "{'f0.I':(1,2),'f0.A':(3, 4, 'f1.B':(5, 6)}",
+            "{'f0.I':1,2],'f0.A':[3, 4], 'f1.B':[5, 6]}",
+            "{'f0.I':{1,2},'f0.A':{3, 4}, 'f1.B':{5, 6}",
+            "{'f0.I':(1,2,'f0.A':[3, 4, 'f1.B':{5, 6}",
+            "{'f0.I':(1,2],'f0.A':[3, 4}, 'f1.B':{5, 6)}",
+            "{'f0.I':(2,1),'f0.A':[3, 4], 'f1.B':{5, 6}}",
+            "{'f0.I':(1,1A),'f0.A':[55, 4], 'f1.B':{5, 6C}}",
+            "{'f0.I':(1,A),'f0.A':[55, B], 'f1.B':{5, C}}",
+        ]
+    )
+    def test_parse_range_with_invalid_inputs(self, range_str):
+        """
+        Verifies the ParsingError is raised by _parse_range.
+        """
+        with self.assertRaises(exceptions.ParsingError):
+            _ = _parse_range(range_str)
+
+    @parameterized.expand(
+        [
+            ("['multifit1.txt','multifit2.txt']", True),
+            ('["multifit1.txt", "multifit2.txt"]', True),
+            ("fit1.txt", False),
+        ]
+    )
+    def test_is_multifit(self, input_file, expected):
+        """
+        Verifies the output of _is_multifit() method.
+        """
+        self.parser._entries = {"input_file": input_file}
+        assert self.parser._is_multifit() == expected
+
+    @parameterized.expand(
+        [
+            (
+                "mantid",
+                "multifit.txt",
+                "['multifit1.txt','multifit2.txt']",
+                [
+                    Path("mantid") / "data_files" / "multifit1.txt",
+                    Path("mantid") / "data_files" / "multifit2.txt",
+                ],
+                0,
+            ),
+            (
+                "mantid",
+                "start_end_x.txt",
+                "mantid_start_end_x.dat",
+                [
+                    Path("mantid") / "data_files" / "mantid_start_end_x.dat",
+                ],
+                0,
+            ),
+            (
+                "mantid",
+                "multifit.txt",
+                "['multit.txt','multift2.txt']",
+                [None, None],
+                2,
+            ),
+            (
+                "mantid",
+                "multifit.txt",
+                "['multifit1.txt','multift2.txt']",
+                [
+                    Path("mantid") / "data_files" / "multifit1.txt",
+                    None,
+                ],
+                1,
+            ),
+            (
+                "mantid",
+                "start_end_x.txt",
+                "mant_start_end_x.dat",
+                [None],
+                1,
+            ),
+            (
+                "ivp",
+                "simplified_anac.txt",
+                "simplified_anac.txt",
+                [Path("ivp") / "data_files" / "simplified_anac.txt"],
+                0,
+            ),
+            (
+                "hogben",
+                "simple_sample.txt",
+                "simple_sample.txt",
+                [Path("hogben") / "data" / "simple_sample.txt"],
+                0,
+            ),
+            (
+                "hogben",
+                "simple_sample.txt",
+                "simple_sale.txt",
+                [None],
+                1,
+            ),
+        ]
+    )
+    @patch("fitbenchmarking.parsing.fitbenchmark_parser.LOGGER")
+    def test_get_data_file(
+        self, folder, file, input_file, expected, count, mock_logger
+    ):
+        """
+        Verifies the output of _get_data_file method.
+        """
+        self.parser._filename = Path(__file__).parent / folder / file
+        self.parser._entries = {"input_file": input_file}
+        paths = [
+            path.relative_to(Path(__file__).parent) if path else None
+            for path in self.parser._get_data_file()
+        ]
+        assert paths == expected
+        assert len(mock_logger.method_calls) == count
+
+    @parameterized.expand(
+        [
+            ({"plot_scale": "loglog"}, "loglog"),
+            ({"plot_scale": "LOGLOG"}, "loglog"),
+            ({"plot_scale": "logLOG"}, "loglog"),
+            ({"plot_scale": "logy"}, "logy"),
+            ({"plot_scale": "logY"}, "logy"),
+            ({"plot_scale": "LOGY"}, "logy"),
+            ({"plot_scale": "logx"}, "logx"),
+            ({"plot_scale": "logX"}, "logx"),
+            ({"plot_scale": "LOGX"}, "logx"),
+            ({"plot_scale": "linear"}, "linear"),
+            ({"plot_scale": "LINEAR"}, "linear"),
+            ({}, "linear"),
+        ]
+    )
+    def test_get_plot_scale_with_valid_inputs(self, entries, expected):
+        """
+        Verifies the output of _get_plot_scale() method.
+        """
+        self.parser._entries = entries
+        assert self.parser._get_plot_scale() == expected
+
+    @parameterized.expand(
+        [
+            ({"plot_scale": "logs"},),
+            ({"plot_scale": "ylog"},),
+            ({"plot_scale": "xlog"},),
+            ({"plot_scale": "linaer"},),
+        ]
+    )
+    def test_get_plot_scale_with_invalid_inputs(self, entries):
+        """
+        Verifies the ParsingError is raised by _get_plot_scale().
+        """
+        self.parser._entries = entries
+        with self.assertRaises(exceptions.ParsingError):
+            _ = self.parser._get_plot_scale()
+
+    @parameterized.expand(
+        [
+            (["# X Y Z\n", "", "1 2 3\n"], 2),
+            (["#   x0 x1 y e\n", "", "", "1 2 3 4\n"], 3),
+            (["# X0 X1 Y0 Y1 E0 E1\n", "", " 1 2 3 4 5"], 2),
+            (["<X> <Y> <E>", " 1  2  3 "], 1),
+            (["<X0> <X1> <Y> <E>", "", "1 2  3 4 "], 2),
+            (["# X Y Z\n", "", "-1 -2 -3\n"], 2),
+            (["# X Y Z\n", "1 2 -3\n"], 1),
+            (["# X Y\n", "1 2\n"], 1),
+        ]
+    )
+    def test_find_first_line_with_valid_inputs(self, file_lines, expected):
+        """
+        Verifies the output of _find_first_line().
+        """
+        assert _find_first_line(file_lines) == expected
+
+    @parameterized.expand(
+        [
+            (["# X Y Z\n", "", "\n"],),
+            (["#   x0 x1 y e\n"],),
+            (["<X> <Y> <E>"],),
+            ([],),
+            (["# X Y\n", "# comment", ""],),
+            (["# X Y\n", "# X Y\n"],),
+        ]
+    )
+    def test_find_first_line_with_invalid_inputs(self, file_lines):
+        """
+        Verifies the ParsingError is raised by _find_first_line().
+        """
+        with self.assertRaises(exceptions.ParsingError):
+            _ = _find_first_line(file_lines)
+
+    @parameterized.expand(
+        [
+            (["# X Y E\n"], 2, 3, {"x": [0], "y": [1], "e": [2]}),
+            (["# Y X E\n"], 2, 3, {"x": [1], "y": [0], "e": [2]}),
+            (["# E Y X\n"], 2, 3, {"x": [2], "y": [1], "e": [0]}),
+            (["# X   Y   E\n"], 2, 3, {"x": [0], "y": [1], "e": [2]}),
+            (["# X Y   E\n"], 2, 3, {"x": [0], "y": [1], "e": [2]}),
+            (["# x y e\n"], 2, 3, {"x": [0], "y": [1], "e": [2]}),
+            (
+                ["# X0 X1  Y0 Y1  E1 E2\n"],
+                2,
+                6,
+                {"x": [0, 1], "y": [2, 3], "e": [4, 5]},
+            ),
+            (["<x> <y> <e>\n"], 2, 3, {"x": [0], "y": [1], "e": [2]}),
+            (["<x> <y> <e>\n"], 2, 3, {"x": [0], "y": [1], "e": [2]}),
+            (["<X1> <Y1>\n"], 2, 2, {"x": [0], "y": [1], "e": []}),
+            (
+                ["<X0> <X1> <Y0> <Y1> <E1> <E2>\n"],
+                2,
+                6,
+                {"x": [0, 1], "y": [2, 3], "e": [4, 5]},
+            ),
+            (
+                ["<X0>    <X1>  <Y0>   <Y1>  <E1>  <E2>\n"],
+                2,
+                6,
+                {"x": [0, 1], "y": [2, 3], "e": [4, 5]},
+            ),
+            (["<Y> <X>\n"], 2, 2, {"x": [1], "y": [0], "e": []}),
+            ([""], 0, 2, {"x": [0], "y": [1], "e": []}),
+            ([""], 0, 3, {"x": [0], "y": [1], "e": [2]}),
+        ]
+    )
+    def test_get_column_data_valid_inputs(
+        self, file_lines, first_line, dim, expected
+    ):
+        """
+        Verifies the output of _get_column_data() function with valid inputs.
+        """
+        assert _get_column_data(file_lines, first_line, dim) == expected
+
+    @parameterized.expand(
+        [
+            (["# X Y E\n"], 2, 2),
+            (["# A B C\n"], 2, 3),
+            ([""], 0, 1),
+            ([""], 0, 4),
+            (["# X0 X1  Y0 Y1  E1\n"], 2, 5),
+            (["# X0 X1 E1\n"], 2, 3),
+        ]
+    )
+    def test_get_column_data_invalid_inputs(self, file_lines, first_line, dim):
+        """
+        Verifies the ParsingError is raised by _get_column_data().
+        """
+        with self.assertRaises(exceptions.ParsingError):
+            _ = _get_column_data(file_lines, first_line, dim)
+
+    @parameterized.expand(
+        [
+            (
+                ["# X Y E\n", "", "1 2 3\n"],
+                2,
+                {"x": [0], "y": [1], "e": [2]},
+                {"x": np.array([1]), "y": np.array([2]), "e": np.array([3])},
+            ),
+            (
+                ["# X Y E\n", "1 2 3\n", "4 B C\n"],
+                1,
+                {"x": [0], "y": [1], "e": [2]},
+                {"x": np.array([1]), "y": np.array([2]), "e": np.array([3])},
+            ),
+            (
+                ["# X Y E\n", "", "", "1 2 3\n", "4 5  6\n", "7  8 9\n"],
+                3,
+                {"x": [0], "y": [1], "e": [2]},
+                {
+                    "x": np.array([1, 4, 7]),
+                    "y": np.array([2, 5, 8]),
+                    "e": np.array([3, 6, 9]),
+                },
+            ),
+            (
+                ["1 2\n", "4 5\n", "7  8\n"],
+                0,
+                {"x": [0], "y": [1], "e": []},
+                {"x": np.array([1, 4, 7]), "y": np.array([2, 5, 8])},
+            ),
+            (
+                ["# X0 X1 Y0 Y1\n", "1 2 3 4\n", "5 6 7 8\n"],
+                1,
+                {"x": [0, 1], "y": [2, 3], "e": []},
+                {
+                    "x": np.array([[1, 2], [5, 6]]),
+                    "y": np.array([[3, 4], [7, 8]]),
+                },
+            ),
+        ]
+    )
+    @patch("fitbenchmarking.parsing.fitbenchmark_parser._find_first_line")
+    @patch("fitbenchmarking.parsing.fitbenchmark_parser._get_column_data")
+    @patch("builtins.open", new_callable=mock_open)
+    def test_get_data_points(
+        self,
+        file_lines,
+        first_line,
+        cols,
+        expected,
+        mock_open_file,
+        mock_get_column_data,
+        mock_find_first_line,
+    ):
+        """
+        Verifies the output of _get_data_points() method.
+        """
+        mock_find_first_line.return_value = first_line
+        mock_get_column_data.return_value = cols
+        mock_open_file.return_value.readlines.return_value = file_lines
+        result = self.parser._get_data_points("test")
+        for key in expected:
+            np.testing.assert_array_equal(result[key], expected[key])
+
+    @parameterized.expand(
+        [
+            (
+                [
+                    {"name": "BackToBackExponential"},
+                    {"name": "FlatBackground"},
+                ],
+                "2 Functions",
+            ),
+            (
+                [
+                    {"name": "BackToBackExponential"},
+                    {"name": "FlatBackground"},
+                    {"name": "GausOsc"},
+                ],
+                "3 Functions",
+            ),
+            ([{"name": "BackToBackExponential"}], "BackToBackExponential"),
+            ([{"name": "FlatBackground"}], "FlatBackground"),
+        ]
+    )
+    def test_get_equation(self, parsed_func, expected):
+        """
+        Verifies the output of _get_equation() method.
+        """
+        self.parser._parsed_func = parsed_func
+        assert self.parser._get_equation() == expected
+
+    @parameterized.expand(
+        [
+            (
+                [
+                    {
+                        "name": "BackToBackExponential",
+                        "I": 15000.0,
+                        "A": 0.04,
+                        "B": 0.03,
+                        "X0": 29950,
+                        "S": 12,
+                    },
+                ],
+                [{"I": 15000.0, "A": 0.04, "B": 0.03, "X0": 29950, "S": 12}],
+            ),
+            (
+                [
+                    {"name": "FlatBackground", "A0": 30},
+                ],
+                [{"A0": 30}],
+            ),
+        ]
+    )
+    def test_get_starting_values(self, parsed_func, expected):
+        """
+        Verifies the output of _get_starting_values() method.
+        """
+        self.parser._parsed_func = parsed_func
+        assert self.parser._get_starting_values() == expected
+
+    @parameterized.expand(
+        [
+            (
+                [
+                    {
+                        "x": np.array([0]),
+                        "y": np.array([1]),
+                        "e": np.array([2]),
+                    },
+                ],
+                [],
+            ),
+            (
+                [
+                    {
+                        "x": np.array([3]),
+                        "y": np.array([4]),
+                        "e": np.array([5]),
+                    },
+                ],
+                [{"x": [1, 4]}],
+            ),
+            (
+                [
+                    {
+                        "x": np.array([3]),
+                        "y": np.array([4]),
+                    },
+                ],
+                [],
+            ),
+        ]
+    )
+    def test_set_data_points(self, data_points, fit_ranges):
+        """
+        Verifies the output of _set_data_points() method.
+        """
+        self.parser.fitting_problem = FittingProblem(Options())
+        self.parser._set_data_points(data_points, fit_ranges)
+        assert self.parser.fitting_problem.data_x == data_points[0]["x"]
+        assert self.parser.fitting_problem.data_y == data_points[0]["y"]
+        assert self.parser.fitting_problem.data_e == data_points[0].get(
+            "e", None
+        )
+        if fit_ranges:
+            assert self.parser.fitting_problem.start_x == fit_ranges[0]["x"][0]
+            assert self.parser.fitting_problem.end_x == fit_ranges[0]["x"][1]
+
+    @patch.object(sys, "path", new_callable=list)
+    @patch("importlib.import_module")
+    def test_get_jacobian(
+        self,
+        mock_import_module,
+        mock_sys_path,
+    ):
+        """
+        Verifies the output of _get_jacobian().
+        """
+        self.parser._entries = {
+            "jac": "module=jac/sample, dense_func=d, sparse_func=s"
+        }
+        self.parser._filename = Path("test") / "problem.txt"
+
+        for jac in ["dense_func", "sparse_func"]:
+            mock_function, mock_module = MagicMock(), MagicMock()
+            setattr(mock_module, jac[0], mock_function)
+            mock_import_module.return_value = mock_module
+            result = self.parser._get_jacobian(jac)
+
+            self.assertTrue(
+                any("test" in path and "jac" in path for path in mock_sys_path)
+            )
+            self.assertIn(
+                ("sample",),
+                [call.args for call in mock_import_module.mock_calls],
+            )
+            self.assertEqual(result, mock_function)
+
+    @parameterized.expand(
+        [
+            ({"jac": "module=j/p/sample"},),
+            ({},),
+        ]
+    )
+    def test_get_jacobian_returns_none(self, entries):
+        """
+        Verifies the output of _get_jacobian() returns None.
+        """
+        self.parser._entries = entries
+        for jac in ["dense_func", "sparse_func"]:
+            result = self.parser._get_jacobian(jac)
+            assert result is None
+
+    def test_create_function(self):
+        """
+        Verifies the NotImplementedError is raised by _create_function().
+        """
+        with self.assertRaises(NotImplementedError):
+            _ = self.parser._create_function()
+
+    @patch(
+        "fitbenchmarking.parsing.fitbenchmark_parser.FitbenchmarkParser._get_data_points"
+    )
+    @patch(
+        "fitbenchmarking.parsing.fitbenchmark_parser.FitbenchmarkParser._get_data_file"
+    )
+    @patch(
+        "fitbenchmarking.parsing.fitbenchmark_parser.FitbenchmarkParser._parse_function"
+    )
+    @patch(
+        "fitbenchmarking.parsing.fitbenchmark_parser.FitbenchmarkParser._create_function"
+    )
+    @patch(
+        "fitbenchmarking.parsing.fitbenchmark_parser.FitbenchmarkParser._get_data_problem_entries"
+    )
+    def test_parse(
+        self,
+        mock_get_data_problem_entries,
+        mock_create_function,
+        mock_parse_function,
+        mock_get_data_file,
+        mock_get_data_points,
+    ):
+        """
+        Verifies the output of the parse() method.
+        """
+        mock_get_data_problem_entries.return_value = {
+            "software": "Mantid",
+            "name": "Basic MultiFit",
+            "description": "Test Parse",
+            "input_file": "['basic1.txt','basic2.txt']",
+            "function": "name=LinearBackground,A0=0,A1=0",
+            "parameter_ranges": "{'A0': (1,10), 'A1': (1,5)}",
+        }
+
+        mock_parse_function.return_value = [
+            {"name": "LinearBackground", "A0": "0", "A1": "0"}
+        ]
+
+        mock_get_data_file.return_value = ["basic1.txt", "basic2.txt"]
+        mock_x = np.array([1.0, 2.0, 3.0])
+        mock_y = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
+        mock_get_data_points.return_value = {"x": mock_x, "y": mock_y}
+        mock_create_function.return_value = ["mock_function"]
+
+        self.parser.options = Options()
+        result = self.parser.parse()
+
+        # Verify all the parameters are set correctly
+        assert not result.additional_info
+        assert not result.data_e
+        assert result.data_x.shape == (3,)
+        assert result.data_y.shape == (3, 2)
+        assert result.description == "Test Parse"
+        assert not result.end_x
+        assert result.equation == "LinearBackground"
+        assert result.format == "mantid"
+        assert result.function == ["mock_function"]
+        assert not result.hessian
+        assert not result.jacobian
+        assert result.multifit
+        assert result.multivariate
+        assert result.name == "Basic MultiFit"
+        assert result.param_names == ["A0", "A1"]
+        assert result.plot_scale == "linear"
+        assert not result.sorted_index
+        assert not result.sparse_jacobian
+        assert not result.start_x
+        assert result.value_ranges == [(1.0, 10.0), (1.0, 5.0)]
diff --git a/fitbenchmarking/parsing/tests/test_parsers.py b/fitbenchmarking/parsing/tests/test_parsers.py
index cac9e3fe..17277a2b 100644
--- a/fitbenchmarking/parsing/tests/test_parsers.py
+++ b/fitbenchmarking/parsing/tests/test_parsers.py
@@ -6,8 +6,8 @@ import os
 from importlib import import_module
 from inspect import getmembers, isabstract, isclass
 from json import load
+from pathlib import Path
 from unittest import TestCase
-from unittest.mock import MagicMock, patch
 
 import numpy as np
 from parameterized import parameterized
@@ -16,7 +16,6 @@ from scipy.sparse import issparse
 
 from conftest import run_for_test_types
 from fitbenchmarking.parsing.base_parser import Parser
-from fitbenchmarking.parsing.fitbenchmark_parser import FitbenchmarkParser
 from fitbenchmarking.parsing.fitting_problem import FittingProblem
 from fitbenchmarking.parsing.parser_factory import (
     ParserFactory,
@@ -505,7 +504,7 @@ class TestParserFactory(TestCase):
         Tests that the parser factory raises a NoParserError when an erroneous
         parser is requested.
         """
-        filename = os.path.join(os.getcwd(), "this_is_a_fake_parser.txt")
+        filename = Path.cwd() / "this_is_a_fake_parser.txt"
         with open(filename, "w", encoding="utf-8") as f:
             f.write("this_is_a_fake_parser")
 
@@ -519,7 +518,7 @@ class TestParserFactory(TestCase):
         """
         Tests the parse_problem_file method
         """
-        filename = os.path.join(os.path.dirname(__file__), "nist", "basic.dat")
+        filename = Path(__file__).parent / "nist" / "basic.dat"
         fitting_problem = parse_problem_file(filename, OPTIONS)
         self.assertEqual(fitting_problem.name, "basic")
 
@@ -534,24 +533,22 @@ class TestParserNoJac(TestCase):
         """
         Set up the tests.
         """
-        self.test_dir = os.path.dirname(__file__)
+        self.test_dir = Path(__file__).parent
 
-    def test_sparsej_returns_none(self):
+    @parameterized.expand(["simplified_anac.txt", "simplified_anac2.txt"])
+    def test_sparsej_returns_none(self, filename):
         """
         Test sparse_jacobian is None in two cases:
          - when no 'jac' line in prob def file
          - when there is a 'jac' line but no 'sparse_func' in it.
         """
-        for prob_def_file in ["simplified_anac.txt", "simplified_anac2.txt"]:
-            prob_def_file_path = os.path.join(
-                self.test_dir, "ivp", prob_def_file
-            )
+        prob_def_file_path = self.test_dir / "ivp" / filename
 
-            parser = ParserFactory.create_parser(prob_def_file_path)
-            with parser(prob_def_file_path, OPTIONS) as p:
-                fitting_problem = p.parse()
+        parser = ParserFactory.create_parser(prob_def_file_path)
+        with parser(prob_def_file_path, OPTIONS) as p:
+            fitting_problem = p.parse()
 
-            assert fitting_problem.sparse_jacobian is None
+        assert fitting_problem.sparse_jacobian is None
 
     @run_for_test_types(TEST_TYPE, "all")
     def test_mantid_jac_when_no_func_by_user(self):
@@ -559,14 +556,10 @@ class TestParserNoJac(TestCase):
         Tests that, for mantid problems, when no jacobian is provided
         by the user, the jacobian function from mantid is used.
         """
-        evaluations_file = "jacobian_evaluations.json"
         format_dir = "mantiddev"
+        file_path = self.test_dir / format_dir / "jacobian_evaluations.json"
 
-        evaluations_file_path = os.path.join(
-            self.test_dir, format_dir, evaluations_file
-        )
-
-        with open(evaluations_file_path, encoding="utf-8") as ef:
+        with open(file_path, encoding="utf-8") as ef:
             results = load(ef)
 
             for f, tests in results.items():
@@ -581,41 +574,3 @@ class TestParserNoJac(TestCase):
 
                     actual = fitting_problem.jacobian(x, r[1])
                     assert np.isclose(actual, r[2]).all()
-
-
-class TestFitbenchmarkParser(TestCase):
-    """
-    Tests the FitbenchmarkParser.
-    """
-
-    @parameterized.expand(
-        [
-            (["name = '\\sample_problem'"], {"name": "sample_problem"}),
-            (["name = 'sample_problem/'"], {"name": "sample_problem"}),
-            (["name = '\\sample_problem/////'"], {"name": "sample_problem"}),
-            (["#fitbenchmarking"], {}),
-            (
-                ["name = 'sample_problem' # my important comment"],
-                {"name": "sample_problem"},
-            ),
-            (
-                ["function = 'Userdefined: a/4 ** 6'"],
-                {"function": "Userdefined: a/4 ** 6"},
-            ),
-            (
-                ["function = 'Userdefined = a/4 ** 6'"],
-                {"function": "Userdefined = a/4 ** 6"},
-            ),
-        ]
-    )
-    @patch.object(FitbenchmarkParser, "__init__", lambda a, b, c: None)
-    def test_get_data_problem_entries(self, file_data, expected):
-        """
-        Verifies the output of _get_data_problem_entries() method.
-        """
-        parser = FitbenchmarkParser("test_file.txt", {"parse"})
-        parser.file = MagicMock()
-        parser.file.readlines.return_value = file_data
-        # pylint: disable=protected-access
-        result = parser._get_data_problem_entries()
-        assert result == expected