math-engine 0.6.4__tar.gz → 0.6.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {math_engine-0.6.4 → math_engine-0.6.6}/PKG-INFO +4 -3
  2. {math_engine-0.6.4 → math_engine-0.6.6}/README.md +1 -1
  3. {math_engine-0.6.4 → math_engine-0.6.6}/math_engine/__init__.py +158 -3
  4. math_engine-0.6.6/math_engine/calculator/AST_Node_Types.py +364 -0
  5. {math_engine-0.6.4 → math_engine-0.6.6}/math_engine/calculator/ScientificEngine.py +80 -36
  6. math_engine-0.6.6/math_engine/calculator/__init__.py +10 -0
  7. {math_engine-0.6.4 → math_engine-0.6.6}/math_engine/calculator/calculator.py +449 -74
  8. {math_engine-0.6.4 → math_engine-0.6.6}/math_engine/calculator/translator.py +208 -38
  9. math_engine-0.6.6/math_engine/cli/__init__.py +29 -0
  10. {math_engine-0.6.4 → math_engine-0.6.6}/math_engine/cli/cli.py +163 -8
  11. math_engine-0.6.6/math_engine/plugins/__init__.py +10 -0
  12. math_engine-0.6.6/math_engine/plugins/my_plugin.py +46 -0
  13. math_engine-0.6.6/math_engine/utility/__init__.py +11 -0
  14. {math_engine-0.6.4 → math_engine-0.6.6}/math_engine/utility/config_manager.py +91 -7
  15. math_engine-0.6.6/math_engine/utility/error.py +280 -0
  16. math_engine-0.6.6/math_engine/utility/non_decimal_utility.py +493 -0
  17. {math_engine-0.6.4 → math_engine-0.6.6}/math_engine/utility/plugin_manager.py +124 -8
  18. math_engine-0.6.6/math_engine/utility/utility.py +194 -0
  19. {math_engine-0.6.4 → math_engine-0.6.6}/math_engine.egg-info/PKG-INFO +4 -3
  20. {math_engine-0.6.4 → math_engine-0.6.6}/pyproject.toml +5 -2
  21. math_engine-0.6.4/math_engine/calculator/AST_Node_Types.py +0 -162
  22. math_engine-0.6.4/math_engine/calculator/__init__.py +0 -1
  23. math_engine-0.6.4/math_engine/cli/__init__.py +0 -16
  24. math_engine-0.6.4/math_engine/plugins/__init__.py +0 -3
  25. math_engine-0.6.4/math_engine/plugins/my_plugin.py +0 -24
  26. math_engine-0.6.4/math_engine/utility/__init__.py +0 -0
  27. math_engine-0.6.4/math_engine/utility/error.py +0 -181
  28. math_engine-0.6.4/math_engine/utility/non_decimal_utility.py +0 -240
  29. math_engine-0.6.4/math_engine/utility/utility.py +0 -97
  30. {math_engine-0.6.4 → math_engine-0.6.6}/LICENSE +0 -0
  31. {math_engine-0.6.4 → math_engine-0.6.6}/math_engine/config.json +0 -0
  32. {math_engine-0.6.4 → math_engine-0.6.6}/math_engine.egg-info/SOURCES.txt +0 -0
  33. {math_engine-0.6.4 → math_engine-0.6.6}/math_engine.egg-info/dependency_links.txt +0 -0
  34. {math_engine-0.6.4 → math_engine-0.6.6}/math_engine.egg-info/entry_points.txt +0 -0
  35. {math_engine-0.6.4 → math_engine-0.6.6}/math_engine.egg-info/requires.txt +0 -0
  36. {math_engine-0.6.4 → math_engine-0.6.6}/math_engine.egg-info/top_level.txt +0 -0
  37. {math_engine-0.6.4 → math_engine-0.6.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: math-engine
3
- Version: 0.6.4
3
+ Version: 0.6.6
4
4
  Summary: A fast and secure mathematical expression evaluator.
5
5
  Author-email: Jan Teske <jan.teske.06@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/JanTeske06/math_engine
@@ -17,9 +17,10 @@ Requires-Dist: prompt_toolkit
17
17
  Provides-Extra: test
18
18
  Requires-Dist: pytest>=7.0.0; extra == "test"
19
19
  Requires-Dist: pytest-cov; extra == "test"
20
+ Dynamic: license-file
20
21
 
21
22
 
22
- # Math Engine v0.6.4
23
+ # Math Engine v0.6.6
23
24
 
24
25
 
25
26
  [![PyPI version](https://badge.fury.io/py/math-engine.svg)](https://badge.fury.io/py/math-engine)
@@ -1,5 +1,5 @@
1
1
 
2
- # Math Engine v0.6.4
2
+ # Math Engine v0.6.6
3
3
 
4
4
 
5
5
  [![PyPI version](https://badge.fury.io/py/math-engine.svg)](https://badge.fury.io/py/math-engine)
@@ -1,3 +1,26 @@
1
+ """
2
+ math_engine — A fast, safe, and configurable mathematical expression evaluator.
3
+
4
+ This module is the public API surface for the math_engine package. It exposes
5
+ high-level functions for evaluating expressions, managing settings, and working
6
+ with an in-memory variable store.
7
+
8
+ Pipeline overview:
9
+ 1. Tokenizer — converts raw input strings into token lists
10
+ 2. Parser — builds an Abstract Syntax Tree (recursive descent)
11
+ 3. Evaluator — numeric evaluation or linear equation solving
12
+ 4. Formatter — renders results in the requested output type
13
+
14
+ Usage::
15
+
16
+ import math_engine
17
+
18
+ math_engine.evaluate("2 + 3") # Decimal('5')
19
+ math_engine.evaluate("hex: 255") # '0xff'
20
+ math_engine.evaluate("x + 3 = 10") # Decimal('7')
21
+ math_engine.evaluate("x + 1", x=4) # Decimal('5')
22
+ """
23
+
1
24
  from . import calculator
2
25
  from .calculator import ScientificEngine
3
26
  from .utility import config_manager as config_manager
@@ -7,14 +30,39 @@ from .utility import config_manager as config_manager
7
30
  from typing import Optional
8
31
  from typing import Union
9
32
  from typing import Any, Mapping
10
- __version__ = "0.6.4"
33
+
34
+ __version__ = "0.6.6"
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # In-memory variable store
38
+ # ---------------------------------------------------------------------------
39
+ # Variables stored here are automatically merged into every ``evaluate()``
40
+ # call. Keyword arguments passed directly to ``evaluate()`` take precedence
41
+ # over memory entries with the same key.
11
42
  memory = {}
12
43
 
13
44
  def set_memory(key_value: str, value:str):
45
+ """Store a key-value pair in the global in-memory variable store.
46
+
47
+ Memory variables are automatically included in the evaluation context
48
+ for all subsequent ``evaluate()`` calls.
49
+
50
+ Args:
51
+ key_value: The variable name to store (used as identifier in expressions).
52
+ value: The value to associate with the key.
53
+ """
14
54
  global memory
15
55
  memory[key_value] = value
16
56
 
17
57
  def delete_memory(key_value: str):
58
+ """Remove a variable from the in-memory store.
59
+
60
+ Args:
61
+ key_value: The key to remove. Pass ``"all"`` to clear the entire store.
62
+
63
+ Raises:
64
+ E.SyntaxError: If the key does not exist in memory (code ``4000``).
65
+ """
18
66
  global memory
19
67
  try:
20
68
  if key_value == "all":
@@ -25,10 +73,27 @@ def delete_memory(key_value: str):
25
73
  raise E.SyntaxError(f"Entry {key_value} does not exist.", code = "4000")
26
74
 
27
75
  def show_memory():
76
+ """Return the current contents of the in-memory variable store.
77
+
78
+ Returns:
79
+ dict: A dictionary mapping variable names to their stored values.
80
+ """
28
81
  return memory
29
82
 
30
83
 
31
84
  def change_setting(setting: str, new_value: Union[int, bool]):
85
+ """Modify a single configuration setting and persist it to disk.
86
+
87
+ The new value must match the type of the existing setting (with special
88
+ handling for bool/int interoperability).
89
+
90
+ Args:
91
+ setting: The setting key name (e.g., ``"decimal_places"``).
92
+ new_value: The new value to assign.
93
+
94
+ Returns:
95
+ int: ``1`` on success, ``-1`` on failure.
96
+ """
32
97
  saved_settings = config_manager.save_setting(setting, new_value)
33
98
 
34
99
  if saved_settings != -1:
@@ -37,6 +102,21 @@ def change_setting(setting: str, new_value: Union[int, bool]):
37
102
  return -1
38
103
 
39
104
  def load_preset(settings: dict):
105
+ """Replace all settings at once with a complete settings dictionary.
106
+
107
+ The dictionary must contain exactly the same keys as the current
108
+ configuration — no more, no fewer.
109
+
110
+ Args:
111
+ settings: A complete settings dictionary (all keys required).
112
+
113
+ Returns:
114
+ int: ``1`` on success.
115
+
116
+ Raises:
117
+ E.SyntaxError: If the dictionary contains unknown keys (code ``5003``)
118
+ or is missing required keys (code ``5004``).
119
+ """
40
120
  current = config_manager.load_setting_value("all")
41
121
  unknown = [k for k in settings.keys() if k not in current]
42
122
  if unknown:
@@ -51,10 +131,23 @@ def load_preset(settings: dict):
51
131
 
52
132
 
53
133
  def load_all_settings():
134
+ """Return the complete current settings dictionary.
135
+
136
+ Returns:
137
+ dict: All configuration key-value pairs.
138
+ """
54
139
  settings = config_manager.load_setting_value("all")
55
140
  return settings
56
141
 
57
142
  def load_one_setting(setting):
143
+ """Return the value of a single configuration setting.
144
+
145
+ Args:
146
+ setting: The setting key name.
147
+
148
+ Returns:
149
+ The setting value, or ``0`` if the key does not exist.
150
+ """
58
151
  settings = config_manager.load_setting_value(setting)
59
152
  return settings
60
153
 
@@ -62,7 +155,40 @@ def evaluate(expr: str,
62
155
  variables: Optional[Mapping[str, Any]] = None,
63
156
  is_cli: bool = False,
64
157
  **kwvars: Any) -> Any:
158
+ """Evaluate a mathematical expression and return the typed result.
159
+
160
+ This is the primary entry point for the library. The expression is
161
+ tokenized, parsed into an AST, evaluated (or solved if it is a linear
162
+ equation), and the result is formatted according to the active settings
163
+ and any output prefix present in the expression.
164
+
165
+ Variables can be supplied either as a mapping or as keyword arguments.
166
+ Keyword arguments take precedence over the mapping, and both take
167
+ precedence over values stored in the global memory.
168
+
169
+ Args:
170
+ expr: The expression string (e.g., ``"2 + 3"``, ``"hex: 255"``).
171
+ variables: Optional variable mapping (e.g., ``{"x": 5}``).
172
+ is_cli: When ``True``, error diagnostics use CLI-style formatting
173
+ (position pointer above the expression).
174
+ **kwvars: Additional variables as keyword arguments.
175
+
176
+ Returns:
177
+ The evaluation result. The concrete type depends on the expression
178
+ and the active output prefix:
65
179
 
180
+ - ``Decimal`` — default numeric results
181
+ - ``int`` — with ``int:`` prefix
182
+ - ``float`` — with ``float:`` prefix
183
+ - ``bool`` — with ``bool:`` prefix or equality expressions
184
+ - ``str`` — with ``str:``, ``hex:``, ``bin:``, ``oct:`` prefixes
185
+ - ``None`` — when ``readable_error=True`` and an error occurred
186
+
187
+ Raises:
188
+ E.MathError: (or a subclass) when ``readable_error=False`` and the
189
+ expression is invalid or cannot be evaluated.
190
+ """
191
+ # Merge variable sources: memory < variables dict < keyword args
66
192
  if variables is None:
67
193
  merged = dict(kwvars)
68
194
  else:
@@ -72,12 +198,15 @@ def evaluate(expr: str,
72
198
  merged = dict(list(memory.items()) + list(merged.items()))
73
199
  settings = load_all_settings()
74
200
 
75
-
201
+ # --- Path 1: Exception mode (readable_error=False) ---
202
+ # Exceptions propagate directly to the caller.
76
203
  if settings["readable_error"] == False:
77
204
  result = calculate(expr, merged,1) # 0 = Validate, 1 = Calculate
78
205
  return result
79
206
 
80
-
207
+ # --- Path 2: Visual diagnostics mode (readable_error=True) ---
208
+ # Errors are caught, a human-readable diagnostic is printed to stdout,
209
+ # and the function returns None.
81
210
  elif settings["readable_error"]== True:
82
211
  result = -1
83
212
  try:
@@ -87,12 +216,14 @@ def evaluate(expr: str,
87
216
 
88
217
  return result
89
218
  except E.MathError as e:
219
+ # Labels for the diagnostic output
90
220
  Errormessage = "Errormessage: "
91
221
  code = "Code: "
92
222
  Equation = "Equation: "
93
223
  positon_start = e.position_start
94
224
  positon_end = e.position_end
95
225
 
226
+ # --- CLI-style output: pointer appears above the expression ---
96
227
  if is_cli:
97
228
  if positon_start != -1:
98
229
  if positon_end == -1:
@@ -111,6 +242,8 @@ def evaluate(expr: str,
111
242
  print(Errormessage + str(e.message))
112
243
  print(Equation + str(e.equation))
113
244
  print(" ")
245
+
246
+ # --- Library-style output: underlined error segment in equation ---
114
247
  if is_cli == False:
115
248
  print(Errormessage + str(e.message))
116
249
  print(code + str(e.code))
@@ -118,6 +251,7 @@ def evaluate(expr: str,
118
251
  if positon_end == -1:
119
252
  positon_end = positon_start
120
253
  calc_equation = str(e.equation)
254
+ # Underline the problematic segment using ANSI escape codes
121
255
  print(
122
256
  Equation + calc_equation[:positon_start] + "\033[4m" + calc_equation[
123
257
  positon_start:positon_end + 1] + "\033[0m" + calc_equation[
@@ -138,6 +272,23 @@ def evaluate(expr: str,
138
272
  def validate(expr: str,
139
273
  variables: Optional[Mapping[str, Any]] = None,
140
274
  **kwvars: Any) -> Any:
275
+ """Parse and validate an expression without performing full evaluation.
276
+
277
+ Internally calls the calculator with ``validate=0``, which builds the
278
+ AST but skips the final numeric evaluation for pure expressions. This
279
+ is useful for checking whether an expression is syntactically valid.
280
+
281
+ On error, a visual diagnostic is always printed to stdout (regardless
282
+ of the ``readable_error`` setting).
283
+
284
+ Args:
285
+ expr: The expression string to validate.
286
+ variables: Optional variable mapping.
287
+ **kwvars: Additional variables as keyword arguments.
288
+
289
+ Returns:
290
+ The AST tree on success, or ``None`` if an error was caught.
291
+ """
141
292
  explanation = False
142
293
  if variables is None:
143
294
  merged = dict(kwvars)
@@ -182,6 +333,10 @@ def validate(expr: str,
182
333
 
183
334
 
184
335
  def reset_settings():
336
+ """Reset all settings to their factory defaults.
337
+
338
+ Overwrites the ``config.json`` file with the hardcoded default values.
339
+ """
185
340
  config_manager.reset_settings()
186
341
 
187
342
 
@@ -0,0 +1,364 @@
1
+ """
2
+ Abstract Syntax Tree (AST) node types for the math_engine expression parser.
3
+
4
+ The parser (:func:`calculator.calculator.ast`) builds a tree from three node
5
+ types:
6
+
7
+ - :class:`Number` — numeric literals (backed by ``decimal.Decimal``)
8
+ - :class:`Variable` — symbolic variable placeholders (e.g., ``"var0"``)
9
+ - :class:`BinOp` — binary operations with a left subtree, operator, and
10
+ right subtree
11
+
12
+ Each node implements:
13
+
14
+ - ``evaluate()`` — recursively compute the numeric value
15
+ - ``collect_term(var)`` — decompose the subtree into
16
+ ``(factor_of_var, constant)`` for the linear equation solver
17
+ """
18
+
19
+ from decimal import Decimal
20
+ from ..utility import error as E
21
+
22
+ class Number:
23
+ """AST node representing a numeric literal, backed by ``decimal.Decimal``.
24
+
25
+ A ``Number`` is always a leaf node in the AST. It stores a single
26
+ ``Decimal`` value and carries optional source-position information so
27
+ that error messages can point back to the original input string.
28
+
29
+ Attributes:
30
+ value (Decimal): The numeric value of the literal.
31
+ position_start (int): Character index where this literal begins
32
+ in the source string (``-1`` when unknown).
33
+ position_end (int): Character index where this literal ends
34
+ in the source string (``-1`` when unknown).
35
+ """
36
+
37
+ def __init__(self, value, position_start=-1, position_end=-1):
38
+ """Create a Number node.
39
+
40
+ Args:
41
+ value: Any numeric type. Non-Decimal values are first
42
+ converted to ``str`` before being passed to the ``Decimal``
43
+ constructor so that floating-point artifacts (e.g.
44
+ ``Decimal(0.1)`` producing ``0.1000000000000000055...``)
45
+ are avoided.
46
+ position_start: Start index in the source string.
47
+ position_end: End index in the source string.
48
+ """
49
+ # Always normalize input to Decimal via string to avoid float artifacts
50
+ if not isinstance(value, Decimal):
51
+ value = str(value)
52
+ self.value = Decimal(value)
53
+ self.position_start = position_start
54
+ self.position_end = position_end
55
+
56
+ def evaluate(self):
57
+ """Return the stored ``Decimal`` value.
58
+
59
+ Because a ``Number`` is a leaf, no recursion is required.
60
+
61
+ Returns:
62
+ Decimal: The numeric value of this literal.
63
+ """
64
+ return self.value
65
+
66
+ def collect_term(self, var_name):
67
+ """Decompose this node for the linear equation solver.
68
+
69
+ A numeric literal contains no variable term, so the factor is
70
+ always ``0`` and the constant is the literal's value.
71
+
72
+ Args:
73
+ var_name (str): The variable name being collected (unused
74
+ for ``Number`` nodes, but required by the interface).
75
+
76
+ Returns:
77
+ tuple[int, Decimal]: ``(0, self.value)`` -- zero coefficient
78
+ for the variable and the literal as the constant part.
79
+ """
80
+ # A plain number contributes nothing to the variable factor
81
+ # and its full value to the constant term.
82
+ return (0, self.value)
83
+
84
+ def __repr__(self):
85
+ """Return a human-readable representation, e.g. ``Number(3.14)``."""
86
+ try:
87
+ display_value = self.value.to_normal_string()
88
+ except AttributeError:
89
+ display_value = str(self.value)
90
+ return f"Number({display_value})"
91
+
92
+
93
+ class Variable:
94
+ """AST node representing a single symbolic variable (e.g. ``"var0"``).
95
+
96
+ A ``Variable`` is a leaf node that acts as a placeholder for an
97
+ unknown value. The tokenizer assigns internal names like ``"var0"``,
98
+ ``"var1"``, etc. -- these are the names stored in :attr:`name`.
99
+
100
+ Attributes:
101
+ name (str): Internal variable identifier assigned by the tokenizer
102
+ (e.g. ``"var0"``).
103
+ position_start (int): Character index where this variable begins
104
+ in the source string (``-1`` when unknown).
105
+ position_end (int): Character index where this variable ends
106
+ in the source string (``-1`` when unknown).
107
+ """
108
+
109
+ def __init__(self, name, position_start=-1, position_end=-1):
110
+ """Create a Variable node.
111
+
112
+ Args:
113
+ name: Internal variable identifier (e.g. ``"var0"``).
114
+ position_start: Start index in the source string.
115
+ position_end: End index in the source string.
116
+ """
117
+ self.name = name
118
+ self.position_start = position_start
119
+ self.position_end = position_end
120
+
121
+ def evaluate(self):
122
+ """Attempt to numerically evaluate this variable.
123
+
124
+ Variables have no numeric value on their own -- they must be
125
+ solved for via the equation solver. Calling ``evaluate()`` on a
126
+ ``Variable`` therefore always raises ``SolverError``.
127
+
128
+ Raises:
129
+ E.SolverError: Always raised (code ``3005``), indicating
130
+ that a numeric evaluation path encountered an unresolved
131
+ variable.
132
+ """
133
+ raise E.SolverError(f"Non linear problem.", code="3005", position_start=self.position_start)
134
+
135
+ def collect_term(self, var_name):
136
+ """Decompose this variable for the linear equation solver.
137
+
138
+ If this variable matches the one being solved for, it
139
+ contributes a coefficient of ``1`` and a constant of ``0``.
140
+ If it does *not* match, the expression contains multiple
141
+ distinct unknowns, which the linear solver cannot handle.
142
+
143
+ Args:
144
+ var_name (str): The internal variable name being collected
145
+ (e.g. ``"var0"``).
146
+
147
+ Returns:
148
+ tuple[int, int]: ``(1, 0)`` when ``self.name == var_name``.
149
+
150
+ Raises:
151
+ E.SolverError: When ``self.name != var_name`` (code ``3002``),
152
+ meaning the expression has more than one distinct variable.
153
+ """
154
+ if self.name == var_name:
155
+ # This is the variable we are solving for: coefficient = 1, constant = 0
156
+ return (1, 0)
157
+ else:
158
+ # A second, different variable was encountered -- not solvable linearly
159
+ raise E.SolverError(f"Multiple variables found: {self.name}", code="3002", position_start=self.position_start)
160
+
161
+ def __repr__(self):
162
+ """Return a human-readable representation, e.g. ``Variable('var0')``."""
163
+ return f"Variable('{self.name}')"
164
+
165
+
166
+ class BinOp:
167
+ """AST node for a binary operation: ``left <operator> right``.
168
+
169
+ Attributes:
170
+ left: Left subtree (Number, Variable, or BinOp).
171
+ operator: Operator string (``"+"``, ``"-"``, ``"*"``, ``"/"``,
172
+ ``"**"``, ``"="``, ``"&"``, ``"|"``, ``"^"``,
173
+ ``"<<"``, ``">>"``)
174
+ right: Right subtree.
175
+ position_start: Character index of the operator in the source string.
176
+ position_end: End index of the operator in the source string.
177
+ """
178
+
179
+ def __init__(self, left, operator, right, position_start=-1, position_end=-1):
180
+ """Create a BinOp node.
181
+
182
+ Args:
183
+ left: Left-hand subtree (``Number``, ``Variable``, or ``BinOp``).
184
+ operator (str): The operator string (e.g. ``"+"``, ``"*"``).
185
+ right: Right-hand subtree (``Number``, ``Variable``, or ``BinOp``).
186
+ position_start (int): Start index of the operator in the source.
187
+ position_end (int): End index of the operator in the source.
188
+ """
189
+ self.left = left
190
+ self.operator = operator
191
+ self.right = right
192
+ self.position_start = position_start
193
+ self.position_end = position_end
194
+
195
+ def evaluate(self):
196
+ """Recursively evaluate both subtrees and apply the binary operator.
197
+
198
+ Arithmetic operators (``+``, ``-``, ``*``, ``/``, ``**``) work on
199
+ ``Decimal`` values. Bitwise operators (``&``, ``|``, ``^``, ``<<``,
200
+ ``>>``) require integer operands and return ``Decimal`` results.
201
+ The equality operator (``=``) returns a Python ``bool``.
202
+
203
+ Returns:
204
+ Decimal or bool: The computed result.
205
+
206
+ Raises:
207
+ E.CalculationError: Division by zero (code ``3003``), non-integer
208
+ operands for bitwise ops (code ``3042``), or unknown operator
209
+ (code ``3004``).
210
+ """
211
+ # Recursively evaluate both child subtrees first (post-order traversal)
212
+ left_value = self.left.evaluate()
213
+ right_value = self.right.evaluate()
214
+
215
+ def check_int(val_l, val_r):
216
+ """Guard: bitwise operators require both operands to be integers."""
217
+ if val_l % 1 != 0 or val_r % 1 != 0:
218
+ raise E.CalculationError(f"Operator '{self.operator}' requires integers.", code="3042", position_start=self.position_start)
219
+
220
+ # --- Arithmetic operators (Decimal -> Decimal) ---
221
+ if self.operator == '+':
222
+ return left_value + right_value
223
+
224
+ elif self.operator == '-':
225
+ return left_value - right_value
226
+
227
+ # --- Bitwise operators (int -> Decimal) ---
228
+ elif self.operator == '&':
229
+ check_int(left_value, right_value)
230
+ return Decimal(int(left_value) & int(right_value))
231
+
232
+ elif self.operator == '|':
233
+ check_int(left_value, right_value)
234
+ return Decimal(int(left_value) | int(right_value))
235
+
236
+ elif self.operator == '^':
237
+ check_int(left_value, right_value)
238
+ return Decimal(int(left_value) ^ int(right_value))
239
+
240
+ elif self.operator == '<<':
241
+ check_int(left_value, right_value)
242
+ return Decimal(int(left_value) << int(right_value))
243
+
244
+ elif self.operator == '>>':
245
+ check_int(left_value, right_value)
246
+ return Decimal(int(left_value) >> int(right_value))
247
+
248
+ # --- Multiplicative / power operators ---
249
+ elif self.operator == '*':
250
+ return left_value * right_value
251
+
252
+ elif self.operator == '**':
253
+ return left_value ** right_value
254
+
255
+ elif self.operator == '/':
256
+ if right_value == 0:
257
+ raise E.CalculationError("Division by zero", code="3003", position_start=self.position_start)
258
+ return left_value / right_value
259
+
260
+ # --- Equality (returns bool, not Decimal) ---
261
+ elif self.operator == '=':
262
+ return left_value == right_value
263
+ else:
264
+ raise E.CalculationError(f"Unknown operator: {self.operator}", code="3004", position_start=self.position_start)
265
+
266
+ def collect_term(self, var_name):
267
+ """Collect linear terms on this subtree into ``(factor_of_var, constant)``.
268
+
269
+ Used by the linear equation solver. The subtree is decomposed into
270
+ the form ``factor * var_name + constant``.
271
+
272
+ Supported operators:
273
+ - ``+``, ``-``: factors and constants are added/subtracted
274
+ - ``*``: only ``constant * linear`` is allowed (not ``linear * linear``)
275
+ - ``/``: divisor must be constant (no division by variable)
276
+ - ``**``: always raises (non-linear)
277
+ - ``=``: should never appear inside a subtree
278
+
279
+ Args:
280
+ var_name: The internal variable name to collect (e.g., ``"var0"``).
281
+
282
+ Returns:
283
+ tuple: ``(factor, constant)`` where ``factor`` is the coefficient
284
+ of *var_name* and ``constant`` is the numeric remainder.
285
+
286
+ Raises:
287
+ E.SyntaxError: Non-linear multiplication (code ``3005``).
288
+ E.SolverError: Division by variable (``3006``), division by
289
+ zero (``3003``), power (``3007``), or ``=``
290
+ inside subtree (``3720``).
291
+ """
292
+ # ---- Linear decomposition core ----
293
+ # Each subtree is expressed as: factor * var_name + constant
294
+ # where "factor" is the coefficient of the unknown variable and
295
+ # "constant" is the purely numeric part.
296
+ #
297
+ # Example: the subtree (2*x + 3) yields (factor=2, constant=3).
298
+ (left_factor, left_constant) = self.left.collect_term(var_name)
299
+ (right_factor, right_constant) = self.right.collect_term(var_name)
300
+
301
+ # --- Addition: (a*x + b) + (c*x + d) = (a+c)*x + (b+d) ---
302
+ if self.operator == '+':
303
+ return (left_factor + right_factor, left_constant + right_constant)
304
+
305
+ # --- Subtraction: (a*x + b) - (c*x + d) = (a-c)*x + (b-d) ---
306
+ elif self.operator == '-':
307
+ return (left_factor - right_factor, left_constant - right_constant)
308
+
309
+ # --- Multiplication ---
310
+ # (a*x + b) * (c*x + d) is linear ONLY when at most one side
311
+ # contains the variable. If both sides have a non-zero factor
312
+ # the product introduces an x^2 term, which is non-linear.
313
+ elif self.operator == '*':
314
+ if left_factor != 0 and right_factor != 0:
315
+ # Both sides depend on x => x * x = x^2 => non-linear
316
+ raise E.SyntaxError("x^x Error (Non-linear).", code="3005", position_start=self.position_start)
317
+
318
+ elif left_factor == 0:
319
+ # Left side is a pure constant k.
320
+ # k * (c*x + d) = (k*c)*x + (k*d)
321
+ return (left_constant * right_factor, left_constant * right_constant)
322
+
323
+ elif right_factor == 0:
324
+ # Right side is a pure constant k.
325
+ # (a*x + b) * k = (k*a)*x + (k*b)
326
+ return (right_constant * left_factor, right_constant * left_constant)
327
+
328
+ elif left_factor == 0 and right_factor == 0:
329
+ # Both sides are pure constants (no variable at all).
330
+ # 0*x + b * 0*x + d = 0*x + b*d
331
+ return (0, right_constant * left_constant)
332
+
333
+ # --- Division ---
334
+ # (a*x + b) / (c*x + d) is linear only when the divisor is a
335
+ # pure constant (c == 0). Division BY the variable would make
336
+ # the expression non-linear (1/x term).
337
+ elif self.operator == '/':
338
+ if right_factor != 0:
339
+ # Divisor contains the variable => non-linear (e.g. 1/x)
340
+ raise E.SolverError("Non-linear equation (Division by variable).", code="3006", position_start=self.position_start)
341
+ elif right_constant == 0:
342
+ # Division by zero in the constant divisor
343
+ raise E.SolverError("Solver: Division by zero", code="3003", position_start=self.position_start)
344
+ else:
345
+ # Divisor is a non-zero constant k.
346
+ # (a*x + b) / k = (a/k)*x + (b/k)
347
+ return (left_factor / right_constant, left_constant / right_constant)
348
+
349
+ # --- Exponentiation: always non-linear for the solver ---
350
+ elif self.operator == '**':
351
+ raise E.SolverError("Powers are not supported by the linear solver.", code="3007", position_start=self.position_start)
352
+
353
+ # --- Equality inside a subtree should never occur ---
354
+ # The '=' is handled at the top level by the solver, not
355
+ # inside a recursive collect_term call.
356
+ elif self.operator == '=':
357
+ raise E.SolverError("Should not happen: '=' inside collect_terms", code="3720", position_start=self.position_start)
358
+
359
+ else:
360
+ raise E.CalculationError(f"Unknown operator: {self.operator}", code="3004", position_start=self.position_start)
361
+
362
+ def __repr__(self):
363
+ """Return a human-readable representation of the BinOp tree."""
364
+ return f"BinOp({self.operator!r}, left={self.left}, right={self.right})"