math2tex 1.0.0__py3-none-any.whl

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.
math2tex/parser.py ADDED
@@ -0,0 +1,967 @@
1
+ """
2
+ math2tex.parser — Recursive-descent parser converting tokens into an AST.
3
+ """
4
+
5
+ from dataclasses import dataclass, fields as dataclass_fields
6
+ from typing import List, Optional
7
+
8
+ from .config import (
9
+ ALL_FUNC_NAMES, BIG_OPS, BUILTIN_LATEX_FUNCS, GREEK_LETTERS,
10
+ IMPLICIT_MULT_STOP, MATRIX_FUNCS, MAX_DEPTH, SPECIAL_CONSTANTS,
11
+ WORD_ADD_OPS, WORD_MULT_OPS, WORD_RELATIONS,
12
+ levenshtein_distance,
13
+ )
14
+ from .ast_nodes import (
15
+ Abs, BigOp, BinOp, Derivative, Factorial, Fraction, Func, Group,
16
+ InnerProduct, Integral, IntervalNode, MatrixNode, Node, Norm, Num,
17
+ PiecewiseNode, Power, Prime, SetBuilder, Subscript, TextLiteral,
18
+ UnOp, Var,
19
+ )
20
+ from .lexer import ParseError, Token, _Backtrack
21
+
22
+ # =============================================================================
23
+ # Parser State
24
+ # =============================================================================
25
+
26
+ @dataclass
27
+ class _ParserState:
28
+ pos: int = 0
29
+ in_abs: int = 0
30
+ in_set_builder: bool = False
31
+ in_bound: bool = False
32
+ in_superscript: bool = False
33
+ depth: int = 0
34
+
35
+ _STATE_FIELDS = tuple(f.name for f in dataclass_fields(_ParserState))
36
+
37
+ # =============================================================================
38
+ # Depth Guard Decorator
39
+ # =============================================================================
40
+
41
+ def depth_guarded(func):
42
+ def wrapper(self, *args, **kwargs):
43
+ self.depth += 1
44
+ if self.depth > MAX_DEPTH:
45
+ raise ParseError('Expression too deeply nested')
46
+ try:
47
+ return func(self, *args, **kwargs)
48
+ finally:
49
+ self.depth -= 1
50
+ return wrapper
51
+
52
+ # =============================================================================
53
+ # Parser
54
+ # =============================================================================
55
+
56
+ class Parser:
57
+ def __init__(self, tokens: List[Token],
58
+ text_map: Optional[dict] = None,
59
+ macro_env: Optional[dict] = None):
60
+ # Immutable configuration
61
+ self.text_map = text_map or {}
62
+ self.macro_env = macro_env or {}
63
+ self.tokens = tokens
64
+
65
+ _defaults = _ParserState()
66
+ for fname in _STATE_FIELDS:
67
+ setattr(self, fname, getattr(_defaults, fname))
68
+
69
+ # --- Derivative token splitting ---
70
+ valid_words = (
71
+ ALL_FUNC_NAMES | BIG_OPS | GREEK_LETTERS | set(SPECIAL_CONSTANTS)
72
+ | set(WORD_RELATIONS) | set(WORD_MULT_OPS)
73
+ | set(WORD_ADD_OPS) | set(MATRIX_FUNCS.keys())
74
+ | {'if', 'otherwise', 'else', 'choose', 'from', 'to', 'of', 'as'}
75
+ | set(self.macro_env.keys())
76
+ )
77
+
78
+ new_tokens: List[Token] = []
79
+ for t in tokens:
80
+ val = t.value
81
+ is_deriv = False
82
+ if t.type == 'ID':
83
+ if (val.startswith('d') and len(val) > 1
84
+ and (val[1:] in valid_words
85
+ or (len(val[1:]) == 1 and val[1:].isalpha()))):
86
+ is_deriv = True
87
+ elif (val.startswith('partial') and len(val) > 7
88
+ and (val[7:] in valid_words
89
+ or (len(val[7:]) == 1 and val[7:].isalpha()))):
90
+ is_deriv = True
91
+
92
+ new_tokens.append(t)
93
+
94
+ self.tokens = new_tokens
95
+ self._valid_words = valid_words
96
+
97
+ # ------------------------------------------------------------------
98
+ # Diagnostic fuzzy matching
99
+ # ------------------------------------------------------------------
100
+
101
+ def _suggest_correction(self, token_value: str) -> str:
102
+ if len(token_value) < 4:
103
+ return ''
104
+ best_match = None
105
+ best_dist = 3
106
+ for word in self._valid_words:
107
+ dist = levenshtein_distance(token_value, word)
108
+ if dist <= 2 and dist < best_dist:
109
+ best_match = word
110
+ best_dist = dist
111
+ if best_match:
112
+ return f" (Did you mean '{best_match}'?)"
113
+ return ''
114
+
115
+ # ------------------------------------------------------------------
116
+ # Token access
117
+ # ------------------------------------------------------------------
118
+
119
+ @property
120
+ def current(self) -> Token:
121
+ return self.tokens[self.pos]
122
+
123
+ def consume(self, expected_type: str = None) -> Token:
124
+ tok = self.current
125
+ if expected_type and tok.type != expected_type:
126
+ raise SyntaxError(
127
+ f"Expected {expected_type}, got {tok.type} '{tok.value}'"
128
+ )
129
+ self.pos += 1
130
+ return tok
131
+
132
+ def match(self, ttype: str, val: str = None) -> bool:
133
+ if (self.current.type == ttype
134
+ and (val is None or self.current.value == val)):
135
+ self.pos += 1
136
+ return True
137
+ return False
138
+
139
+ def peek(self, offset: int = 1) -> Token:
140
+ idx = self.pos + offset
141
+ if idx >= len(self.tokens):
142
+ return self.tokens[-1]
143
+ return self.tokens[idx]
144
+
145
+ def rewind(self, n: int = 1) -> None:
146
+ self.pos -= n
147
+
148
+ # ------------------------------------------------------------------
149
+ # State snapshot / restore
150
+ # ------------------------------------------------------------------
151
+
152
+ def _snapshot_state(self) -> dict:
153
+ return {f: getattr(self, f) for f in _STATE_FIELDS}
154
+
155
+ def _restore_state(self, snapshot: dict) -> None:
156
+ for f, v in snapshot.items():
157
+ setattr(self, f, v)
158
+
159
+ def try_parse(self, parse_func):
160
+ snapshot = self._snapshot_state()
161
+ try:
162
+ return parse_func()
163
+ except _Backtrack:
164
+ self._restore_state(snapshot)
165
+ return None
166
+
167
+ # ------------------------------------------------------------------
168
+ # Entry
169
+ # ------------------------------------------------------------------
170
+
171
+ def parse(self) -> Node:
172
+ if self.current.type == 'EOF':
173
+ return Var('')
174
+ expr = self.parse_expression()
175
+ if self.current.type != 'EOF':
176
+ tok = self.current
177
+ hint = self._suggest_correction(tok.value) if tok.type == 'ID' else ''
178
+ raise ParseError(
179
+ f"Unexpected token at end: {tok}{hint}\n"
180
+ f"% Input: {' '.join(t.value for t in self.tokens)}"
181
+ )
182
+ return expr
183
+
184
+ # ------------------------------------------------------------------
185
+ # Precedence Levels
186
+ # ------------------------------------------------------------------
187
+
188
+ @depth_guarded
189
+ def parse_expression(self) -> Node:
190
+ left = self.parse_relational()
191
+ if self.current.type == 'ID' and self.current.value == 'if':
192
+ self.consume('ID')
193
+ right = self.parse_relational()
194
+ return BinOp('if', left, right)
195
+ if self.current.type == 'ID' and self.current.value == 'otherwise':
196
+ self.consume('ID')
197
+ return BinOp('otherwise', left, Var(''))
198
+ return left
199
+
200
+ @depth_guarded
201
+ def parse_relational(self) -> Node:
202
+ left = self.parse_choose()
203
+ while True:
204
+ if self.current.type in ('REL', 'ARROW', 'COLON'):
205
+ op = self.consume().value
206
+ right = self.parse_choose()
207
+ left = BinOp(op, left, right)
208
+ elif (self.current.type == 'ID'
209
+ and self.current.value in WORD_RELATIONS):
210
+ op = self.consume().value
211
+ right = self.parse_choose()
212
+ left = BinOp(op, left, right)
213
+ else:
214
+ break
215
+ return left
216
+
217
+ @depth_guarded
218
+ def parse_choose(self) -> Node:
219
+ left = self.parse_additive()
220
+ while self.match('ID', 'choose'):
221
+ right = self.parse_additive()
222
+ left = BinOp('choose', left, right)
223
+ return left
224
+
225
+ @depth_guarded
226
+ def parse_additive(self) -> Node:
227
+ left = self.parse_multiplicative()
228
+ while True:
229
+ if (self.current.type == 'OP'
230
+ and self.current.value in ('+', '-')):
231
+ op = self.consume().value
232
+ right = self.parse_multiplicative()
233
+ left = BinOp(op, left, right)
234
+ elif self.current.type == 'PM':
235
+ op = self.consume().value
236
+ right = self.parse_multiplicative()
237
+ left = BinOp(op, left, right)
238
+ elif (self.current.type == 'ID'
239
+ and self.current.value in WORD_ADD_OPS):
240
+ op = self.consume().value
241
+ right = self.parse_multiplicative()
242
+ left = BinOp(op, left, right)
243
+ else:
244
+ break
245
+ return left
246
+
247
+ @depth_guarded
248
+ def parse_multiplicative(self) -> Node:
249
+ left = self.parse_implicit()
250
+ while True:
251
+ if self.current.type == 'OP' and self.current.value == '*':
252
+ self.consume()
253
+ right = self.parse_implicit()
254
+ left = BinOp('*', left, right)
255
+ elif self.current.type == 'OP' and self.current.value == '/':
256
+ self.consume()
257
+ right = self.parse_postfix()
258
+ left = Fraction(left, right)
259
+ while (self.current.type in ('ID', 'NUMBER', 'LPAREN',
260
+ 'LBRACK', 'LBRACE', 'ELLIPSIS')
261
+ or (self.current.type in ('BAR', 'DBLBAR')
262
+ and self.in_abs == 0
263
+ and not self.in_set_builder)):
264
+ if (self.current.type == 'ID'
265
+ and self.current.value in IMPLICIT_MULT_STOP):
266
+ break
267
+ if (self.current.type == 'ID'
268
+ and self.current.value in BIG_OPS):
269
+ break
270
+ right = self.parse_postfix()
271
+ left = BinOp(' ', left, right)
272
+ elif (self.current.type == 'ID'
273
+ and self.current.value in WORD_MULT_OPS):
274
+ op = self.consume().value
275
+ right = self.parse_implicit()
276
+ left = BinOp(op, left, right)
277
+ else:
278
+ break
279
+ return left
280
+
281
+ @depth_guarded
282
+ def parse_implicit(self) -> Node:
283
+ # Check for big operators first
284
+ if (self.current.type == 'ID'
285
+ and self.current.value in BIG_OPS):
286
+ if self.current.value == 'inf':
287
+ if self.pos + 1 < len(self.tokens) and self.tokens[self.pos + 1].value == '_':
288
+ return self.parse_bigop()
289
+ else:
290
+ return self.parse_bigop()
291
+
292
+ left = self.parse_postfix()
293
+
294
+ # Implicit multiplication lookahead
295
+ while (self.current.type in ('ID', 'NUMBER', 'LPAREN',
296
+ 'LBRACK', 'LBRACE', 'ELLIPSIS')
297
+ or (self.current.type in ('BAR', 'DBLBAR')
298
+ and self.in_abs == 0
299
+ and not self.in_set_builder)):
300
+ if (self.current.type == 'ID'
301
+ and self.current.value in IMPLICIT_MULT_STOP):
302
+ break
303
+ if (self.current.type == 'ID'
304
+ and self.current.value in BIG_OPS):
305
+ break
306
+ right = self.parse_postfix()
307
+ left = BinOp(' ', left, right)
308
+ return left
309
+
310
+ @depth_guarded
311
+ def parse_postfix(self) -> Node:
312
+ node = self.parse_power()
313
+ while True:
314
+ if self.current.type == 'OP' and self.current.value == '!':
315
+ self.consume()
316
+ node = Factorial(node)
317
+ elif self.current.type == 'PRIME':
318
+ count = len(self.consume().value)
319
+ node = Prime(node, count)
320
+ elif (self.current.type == 'OP'
321
+ and self.current.value in ('^', '_')):
322
+ if self.current.value == '_' and self.in_superscript:
323
+ break
324
+ op = self.consume().value
325
+ if op == '^':
326
+ old_sup = self.in_superscript
327
+ self.in_superscript = True
328
+ node = Power(node, self.parse_power())
329
+ self.in_superscript = old_sup
330
+ else:
331
+ node = Subscript(node, self.parse_subscript())
332
+ else:
333
+ break
334
+ return node
335
+
336
+ @depth_guarded
337
+ def parse_subscript(self) -> Node:
338
+ node = self.parse_unary()
339
+ if self.current.type == 'OP' and self.current.value == '_':
340
+ self.consume()
341
+ node = Subscript(node, self.parse_subscript())
342
+ return node
343
+
344
+ @depth_guarded
345
+ def parse_power(self) -> Node:
346
+ node = self.parse_unary()
347
+ while (self.current.type == 'OP'
348
+ and self.current.value in ('^', '_')):
349
+ if self.current.value == '_' and self.in_superscript:
350
+ break
351
+
352
+ op = self.consume().value
353
+ if op == '^':
354
+ old_sup = self.in_superscript
355
+ self.in_superscript = True
356
+ node = Power(node, self.parse_power()) # right-associative power
357
+ self.in_superscript = old_sup
358
+ else:
359
+ # Subscripts are right-associative among themselves, but ^ binds left
360
+ node = Subscript(node, self.parse_subscript())
361
+ return node
362
+
363
+ @depth_guarded
364
+ def parse_unary(self) -> Node:
365
+ if self.current.type == 'OP' and self.current.value in ('+', '-'):
366
+ return UnOp(self.consume().value, self.parse_unary())
367
+ if self.current.type == 'PM':
368
+ op = self.consume().value
369
+ expr = self.parse_unary()
370
+ return BinOp(op, Var(''), expr)
371
+ return self.parse_primary()
372
+
373
+ @depth_guarded
374
+ def parse_primary(self) -> Node:
375
+ deriv = self.try_parse(self._parse_derivative)
376
+ if deriv is not None:
377
+ return deriv
378
+
379
+ inner = self.try_parse(self._parse_inner_product)
380
+ if inner is not None:
381
+ return inner
382
+
383
+ tok = self.current
384
+
385
+ if tok.type == 'NUMBER':
386
+ return Num(self.consume().value)
387
+
388
+ elif tok.type == 'ELLIPSIS':
389
+ self.consume()
390
+ return Var('...')
391
+
392
+ elif tok.type == 'ID':
393
+ val = self.consume().value
394
+
395
+ if val in self.text_map:
396
+ return TextLiteral(self.text_map[val])
397
+
398
+ if val in MATRIX_FUNCS and self.current.type == 'LBRACK':
399
+ self.consume('LBRACK')
400
+ matrix_node = self.parse_matrix_bracket()
401
+ if isinstance(matrix_node, MatrixNode):
402
+ return MatrixNode(matrix_node.rows,
403
+ kind=MATRIX_FUNCS[val])
404
+ elif isinstance(matrix_node, IntervalNode):
405
+ return MatrixNode(
406
+ [[matrix_node.left_expr, matrix_node.right_expr]],
407
+ kind=MATRIX_FUNCS[val])
408
+ return matrix_node
409
+
410
+ if val in BIG_OPS:
411
+ if val == 'inf':
412
+ if self.current.type == 'OP' and self.current.value == '_':
413
+ self.rewind(1)
414
+ return self.parse_bigop()
415
+ else:
416
+ self.rewind(1)
417
+ return self.parse_bigop()
418
+
419
+ snapshot = self._snapshot_state()
420
+ prime_count = 0
421
+ if self.current.type == 'PRIME':
422
+ prime_count = len(self.consume().value)
423
+
424
+ sub = None
425
+ power = None
426
+ if not self.in_bound:
427
+ while (self.current.type == 'OP'
428
+ and self.current.value in ('_', '^')):
429
+ op = self.consume().value
430
+ old = self.in_bound
431
+ self.in_bound = True
432
+ if op == '_':
433
+ sub = self.parse_primary()
434
+ elif op == '^':
435
+ old_sup = self.in_superscript
436
+ self.in_superscript = True
437
+ power = self.parse_postfix()
438
+ self.in_superscript = old_sup
439
+ self.in_bound = old
440
+
441
+ is_known = (val in ALL_FUNC_NAMES or val in self.macro_env
442
+ or val in self.text_map)
443
+
444
+ is_func = is_known or (self.current.type == 'LPAREN') or (val in ALL_FUNC_NAMES and self.current.type == 'ID' and self.current.value == 'of')
445
+
446
+ if not is_func:
447
+ self._restore_state(snapshot)
448
+ return Var(val)
449
+
450
+ if (self.current.type == 'LPAREN'
451
+ and (not self.in_bound or is_known)):
452
+ self.consume('LPAREN')
453
+ args = []
454
+ if self.current.type != 'RPAREN':
455
+ args.append(self.parse_relational())
456
+ while self.match('COMMA'):
457
+ args.append(self.parse_relational())
458
+ self.consume('RPAREN')
459
+
460
+ if val in self.macro_env:
461
+ from .macros import substitute
462
+ macro = self.macro_env[val]
463
+ if len(args) != len(macro.args):
464
+ raise ParseError(
465
+ f"Macro '{val}' expects {len(macro.args)} "
466
+ f"arguments, got {len(args)}."
467
+ )
468
+ bindings = dict(zip(macro.args, args))
469
+ node = substitute(macro.body, bindings)
470
+ if prime_count > 0:
471
+ node = Prime(node, prime_count)
472
+ if sub is not None:
473
+ node = Subscript(node, sub)
474
+ if power is not None:
475
+ node = Power(node, power)
476
+ return node
477
+
478
+ func_node = Func(val, args, sub, power, prime_count)
479
+
480
+ from .config import ACCENT_FUNCTIONS, FONT_FUNCTIONS
481
+ if (val in ACCENT_FUNCTIONS or val in FONT_FUNCTIONS) and self.current.type == 'LPAREN':
482
+ self.consume('LPAREN')
483
+ call_args = []
484
+ if self.current.type != 'RPAREN':
485
+ call_args.append(self.parse_relational())
486
+ while self.match('COMMA'):
487
+ call_args.append(self.parse_relational())
488
+ self.consume('RPAREN')
489
+
490
+ return Func(val, args, sub, power, prime_count, call_args=call_args)
491
+ return func_node
492
+
493
+ elif val in ALL_FUNC_NAMES and self.match('ID', 'of'):
494
+ arg = self.parse_relational()
495
+ return Func(val, [arg], sub, power, prime_count)
496
+ elif val in self.macro_env and self.match('ID', 'of'):
497
+ from .macros import substitute
498
+ arg = self.parse_relational()
499
+ macro = self.macro_env[val]
500
+ if len(macro.args) != 1:
501
+ raise ParseError(
502
+ f"Macro '{val}' expects {len(macro.args)} "
503
+ f"arguments, got 1."
504
+ )
505
+ node = substitute(macro.body, {macro.args[0]: arg})
506
+ if prime_count > 0:
507
+ node = Prime(node, prime_count)
508
+ if sub is not None:
509
+ node = Subscript(node, sub)
510
+ if power is not None:
511
+ node = Power(node, power)
512
+ return node
513
+
514
+ if val in self.macro_env:
515
+ from .macros import substitute
516
+ macro = self.macro_env[val]
517
+ if len(macro.args) > 0:
518
+ raise ParseError(
519
+ f"Macro '{val}' expects {len(macro.args)} "
520
+ f"arguments, but none were provided."
521
+ )
522
+ node = substitute(macro.body, {})
523
+ else:
524
+ node = Var(val)
525
+
526
+ if prime_count > 0:
527
+ node = Prime(node, prime_count)
528
+ if sub is not None:
529
+ node = Subscript(node, sub)
530
+ if power is not None:
531
+ node = Power(node, power)
532
+ return node
533
+
534
+ elif tok.type == 'LPAREN':
535
+ self.consume('LPAREN')
536
+ if self.current.type == 'RPAREN':
537
+ raise ParseError(
538
+ "Empty parentheses '()' are not allowed "
539
+ "outside of function calls."
540
+ )
541
+ expr = self.parse_relational()
542
+ if self.match('COMMA'):
543
+ expr2 = self.parse_relational()
544
+ if self.current.type == 'RPAREN':
545
+ self.consume('RPAREN')
546
+ return IntervalNode('(', expr, expr2, ')')
547
+ elif self.current.type == 'RBRACK':
548
+ self.consume('RBRACK')
549
+ return IntervalNode('(', expr, expr2, ']')
550
+ else:
551
+ raise ParseError("Expected ')' or ']' for interval")
552
+ self.consume('RPAREN')
553
+ return expr
554
+
555
+ elif tok.type == 'BAR':
556
+ return self._parse_abs_or_norm('abs', 'BAR', 'BAR')
557
+
558
+ elif tok.type == 'DBLBAR':
559
+ return self._parse_abs_or_norm('norm', 'DBLBAR', 'DBLBAR')
560
+
561
+ elif tok.type == 'LBRACK':
562
+ self.consume('LBRACK')
563
+
564
+ if self.current.type == 'LBRACK':
565
+ rows = []
566
+ while (self.current.type != 'RBRACK'
567
+ and self.current.type != 'EOF'):
568
+ self.consume('LBRACK')
569
+ row = []
570
+ if self.current.type != 'RBRACK':
571
+ row.append(self.parse_relational())
572
+ while self.match('COMMA'):
573
+ row.append(self.parse_relational())
574
+ self.consume('RBRACK')
575
+ rows.append(row)
576
+ self.match('COMMA')
577
+ self.consume('RBRACK')
578
+ return MatrixNode(rows)
579
+ else:
580
+ expr = self.parse_relational()
581
+ if self.match('COMMA'):
582
+ expr2 = self.parse_relational()
583
+ if self.current.type == 'RPAREN':
584
+ self.consume('RPAREN')
585
+ return IntervalNode('[', expr, expr2, ')')
586
+ elif self.current.type == 'RBRACK':
587
+ self.consume('RBRACK')
588
+ return IntervalNode('[', expr, expr2, ']')
589
+ else:
590
+ row = [expr, expr2]
591
+ rows: list = []
592
+ while True:
593
+ if self.match('COMMA'):
594
+ row.append(self.parse_relational())
595
+ elif self.match('SEMI'):
596
+ rows.append(row)
597
+ row = [self.parse_relational()]
598
+ else:
599
+ break
600
+ if row:
601
+ rows.append(row)
602
+ self.consume('RBRACK')
603
+ return MatrixNode(rows)
604
+ elif self.match('SEMI'):
605
+ row = [expr]
606
+ rows = []
607
+ while True:
608
+ expr = self.parse_relational()
609
+ row.append(expr)
610
+ if self.match('COMMA'):
611
+ pass
612
+ elif self.match('SEMI'):
613
+ rows.append(row)
614
+ row = []
615
+ else:
616
+ break
617
+ if row:
618
+ rows.append(row)
619
+ self.consume('RBRACK')
620
+ return MatrixNode(rows)
621
+ else:
622
+ self.consume('RBRACK')
623
+ return MatrixNode([[expr]])
624
+
625
+ elif tok.type == 'LBRACE':
626
+ return self.parse_brace_structure()
627
+
628
+ raise ParseError(f"Unexpected token: {tok}")
629
+
630
+ # ------------------------------------------------------------------
631
+ # Specialised Parsers
632
+ # ------------------------------------------------------------------
633
+
634
+ def _parse_derivative(self) -> Derivative:
635
+ val = self.current.value
636
+ is_attached = False
637
+ if self.current.type != 'ID':
638
+ raise _Backtrack()
639
+
640
+ if val in ('d', 'partial'):
641
+ pass
642
+ elif (val.startswith('d') and len(val) >= 2
643
+ and (val[1:] in GREEK_LETTERS
644
+ or (len(val[1:]) == 1 and val[1:].isalpha()))):
645
+ is_attached = True
646
+ elif (val.startswith('partial') and len(val) > 7
647
+ and (val[7:] in GREEK_LETTERS
648
+ or (len(val[7:]) == 1 and val[7:].isalpha()))):
649
+ is_attached = True
650
+ else:
651
+ raise _Backtrack()
652
+
653
+ try:
654
+ tok = self.consume('ID')
655
+ is_partial = tok.value.startswith('partial')
656
+
657
+ order = None
658
+ if self.match('OP', '^'):
659
+ order = self.parse_unary()
660
+
661
+ target = None
662
+ if is_attached:
663
+ if is_partial:
664
+ target = Var(tok.value[7:])
665
+ else:
666
+ target = Var(tok.value[1:])
667
+ else:
668
+ if not (self.current.type == 'OP'
669
+ and self.current.value == '/'):
670
+ target = self.parse_implicit()
671
+
672
+ if not self.match('OP', '/'):
673
+ raise _Backtrack()
674
+
675
+ den_tok = self.consume('ID')
676
+ var2 = None
677
+ if is_partial:
678
+ if (den_tok.value != 'partial'
679
+ and not den_tok.value.startswith('partial')):
680
+ raise _Backtrack()
681
+
682
+ if den_tok.value == 'partial':
683
+ var = self.consume('ID').value
684
+ else:
685
+ var = den_tok.value[7:]
686
+
687
+ if order and self.match('OP', '^'):
688
+ self.parse_unary()
689
+
690
+ if (self.current.type == 'ID'
691
+ and self.current.value.startswith('partial')):
692
+ tok2 = self.consume('ID')
693
+ if tok2.value == 'partial':
694
+ var2 = self.consume('ID').value
695
+ else:
696
+ var2 = tok2.value[7:]
697
+
698
+ if order and self.match('OP', '^'):
699
+ self.parse_unary()
700
+ else:
701
+ if den_tok.value == 'd':
702
+ var = self.consume('ID').value
703
+ elif (den_tok.value.startswith('d')
704
+ and len(den_tok.value) > 1):
705
+ var = den_tok.value[1:]
706
+ else:
707
+ raise _Backtrack()
708
+
709
+ if order and self.match('OP', '^'):
710
+ self.parse_unary()
711
+
712
+ if target:
713
+ expr = target
714
+ else:
715
+ if self.current.type != 'EOF':
716
+ expr = self.parse_implicit()
717
+ else:
718
+ expr = Var('')
719
+
720
+ return Derivative(expr, var, order, is_partial, var2)
721
+ except ParseError:
722
+ raise _Backtrack()
723
+
724
+ def _parse_bound(self) -> Node:
725
+ """Parse a single integral bound (handles fractions like pi/2)."""
726
+ node = self.parse_postfix()
727
+ if self.match('OP', '/'):
728
+ den = self.parse_postfix()
729
+ node = Fraction(node, den)
730
+ return node
731
+
732
+ @depth_guarded
733
+ def parse_bigop(self) -> Node:
734
+ op_name = self.consume('ID').value
735
+ if op_name == 'integrate':
736
+ op_name = 'integral'
737
+
738
+ self.match('ID', 'of')
739
+
740
+ if op_name in ('integral', 'int', 'iint', 'iiint',
741
+ 'oint', 'oiint'):
742
+ lower, upper = None, None
743
+
744
+ if self.match('ID', 'from'):
745
+ lower = self._parse_bound()
746
+ if self.match('ID', 'to'):
747
+ upper = self._parse_bound()
748
+ else:
749
+ while (self.current.type == 'OP'
750
+ and self.current.value in ('_', '^')):
751
+ op = self.consume().value
752
+ old = self.in_bound
753
+ self.in_bound = True
754
+ if op == '_':
755
+ lower = self.parse_unary()
756
+ else:
757
+ upper = self.parse_unary()
758
+ self.in_bound = old
759
+
760
+ try:
761
+ expr = self.parse_additive()
762
+ except ParseError:
763
+ if self.current.type == 'EOF':
764
+ expr = Var('')
765
+ else:
766
+ raise
767
+
768
+ if lower is None and upper is None:
769
+ if self.match('ID', 'from'):
770
+ lower = self._parse_bound()
771
+ if self.match('ID', 'to'):
772
+ upper = self._parse_bound()
773
+
774
+ kind_map = {
775
+ 'integral': 'single', 'int': 'single',
776
+ 'iint': 'double', 'iiint': 'triple',
777
+ 'oint': 'contour', 'oiint': 'double_contour',
778
+ }
779
+ return Integral(expr, lower, upper,
780
+ kind_map.get(op_name, 'single'))
781
+
782
+ elif op_name in ('sum', 'prod'):
783
+ lower, upper = None, None
784
+
785
+ while (self.current.type == 'OP'
786
+ and self.current.value in ('_', '^')):
787
+ op = self.consume().value
788
+ old = self.in_bound
789
+ self.in_bound = True
790
+ if op == '_':
791
+ lower = self.parse_unary()
792
+ else:
793
+ upper = self.parse_unary()
794
+ self.in_bound = old
795
+
796
+ if lower is not None or upper is not None:
797
+ try:
798
+ expr = self.parse_additive()
799
+ except ParseError:
800
+ if self.current.type == 'EOF':
801
+ expr = Var('')
802
+ else:
803
+ raise
804
+ return BigOp(op_name, lower, upper, expr)
805
+
806
+ try:
807
+ expr = self.parse_additive()
808
+ except ParseError:
809
+ if self.current.type == 'EOF':
810
+ expr = Var('')
811
+ else:
812
+ raise
813
+
814
+ if self.match('COMMA'):
815
+ lower = self.parse_relational()
816
+ if self.match('COMMA'):
817
+ upper = self.parse_relational()
818
+ elif self.match('ID', 'to'):
819
+ upper = self.parse_relational()
820
+ elif self.match('ID', 'from'):
821
+ lower = self.parse_relational()
822
+ if self.match('ID', 'to'):
823
+ upper = self.parse_relational()
824
+
825
+ return BigOp(op_name, lower, upper, expr)
826
+
827
+ elif op_name == 'lim':
828
+ lower = None
829
+
830
+ if self.match('OP', '_'):
831
+ lower = self.parse_unary()
832
+ expr = self.parse_multiplicative()
833
+ return BigOp(op_name, lower, None, expr)
834
+
835
+ try:
836
+ expr = self.parse_multiplicative()
837
+ except ParseError:
838
+ expr = Var('')
839
+
840
+ if self.match('ID', 'as'):
841
+ lower = self.parse_relational()
842
+
843
+ return BigOp(op_name, lower, None, expr)
844
+
845
+ lower, upper = None, None
846
+ while (self.current.type == 'OP'
847
+ and self.current.value in ('_', '^')):
848
+ op = self.consume().value
849
+ old = self.in_bound
850
+ self.in_bound = True
851
+ if op == '_':
852
+ lower = self.parse_unary()
853
+ else:
854
+ upper = self.parse_unary()
855
+ self.in_bound = old
856
+
857
+ try:
858
+ expr = self.parse_multiplicative()
859
+ except ParseError:
860
+ if self.current.type == 'EOF':
861
+ expr = Var('')
862
+ else:
863
+ raise
864
+ return BigOp(op_name, lower, upper, expr)
865
+
866
+ def _parse_abs_or_norm(self, kind: str,
867
+ open_tok: str, close_tok: str) -> Node:
868
+ self.consume(open_tok)
869
+ self.in_abs += 1
870
+ expr = self.parse_relational()
871
+ self.in_abs -= 1
872
+ self.consume(close_tok)
873
+ if kind == 'abs':
874
+ return Abs(expr)
875
+ return Norm(expr)
876
+
877
+ def _parse_inner_product(self) -> InnerProduct:
878
+ if not self.match('REL', '<'):
879
+ raise _Backtrack()
880
+ left = self.parse_additive()
881
+ if not self.match('COMMA'):
882
+ raise _Backtrack()
883
+ right = self.parse_additive()
884
+ if not self.match('REL', '>'):
885
+ raise _Backtrack()
886
+ return InnerProduct(left, right)
887
+
888
+ def parse_matrix_bracket(self) -> Node:
889
+ rows: list = []
890
+ if self.match('LBRACK'):
891
+ # Nested format: [[1,2],[3,4]]
892
+ while True:
893
+ row = [self.parse_relational()]
894
+ while self.match('COMMA'):
895
+ row.append(self.parse_relational())
896
+ self.consume('RBRACK')
897
+ rows.append(row)
898
+ if not self.match('COMMA'):
899
+ break
900
+ self.consume('LBRACK')
901
+ self.consume('RBRACK')
902
+ return MatrixNode(rows)
903
+
904
+ else:
905
+ # Flat list: [a, b] or [1,2; 3,4]
906
+ row = [self.parse_relational()]
907
+ while True:
908
+ if self.match('COMMA'):
909
+ row.append(self.parse_relational())
910
+ elif self.match('SEMI'):
911
+ rows.append(row)
912
+ row = [self.parse_relational()]
913
+ else:
914
+ break
915
+ if row:
916
+ rows.append(row)
917
+ self.consume('RBRACK')
918
+
919
+ if len(rows) == 1 and len(rows[0]) == 2:
920
+ return IntervalNode('[', rows[0][0], rows[0][1], ']')
921
+ return MatrixNode(rows)
922
+
923
+ def parse_brace_structure(self) -> Node:
924
+ self.consume('LBRACE')
925
+
926
+ old_in_set = self.in_set_builder
927
+ self.in_set_builder = True
928
+ first = self.parse_relational()
929
+ self.in_set_builder = old_in_set
930
+
931
+ # Set builder: {expr | condition}
932
+ if self.match('BAR'):
933
+ cond = self.parse_relational()
934
+ self.consume('RBRACE')
935
+ return SetBuilder(first, cond)
936
+
937
+ # Piecewise: {expr if cond, expr2 if cond2, ...}
938
+ conditions = []
939
+ is_piecewise = False
940
+ cond = None
941
+
942
+ if self.match('ID', 'if'):
943
+ cond = self.parse_relational()
944
+ is_piecewise = True
945
+ elif (self.current.type == 'ID'
946
+ and self.current.value in ('otherwise', 'else')):
947
+ self.consume('ID')
948
+ cond = Var(r'\text{otherwise}')
949
+ is_piecewise = True
950
+
951
+ if is_piecewise:
952
+ conditions.append((first, cond))
953
+ while self.match('COMMA'):
954
+ expr = self.parse_relational()
955
+ cond = None
956
+ if self.match('ID', 'if'):
957
+ cond = self.parse_relational()
958
+ elif (self.current.type == 'ID'
959
+ and self.current.value in ('otherwise', 'else')):
960
+ self.consume('ID')
961
+ cond = Var(r'\text{otherwise}')
962
+ conditions.append((expr, cond))
963
+ self.consume('RBRACE')
964
+ return PiecewiseNode(conditions)
965
+
966
+ self.consume('RBRACE')
967
+ return first