From 916f5704e1e72a884e0c2f529aff1ed553933883 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Tue, 28 Jul 2026 02:08:09 +0530 Subject: [PATCH 01/11] Add modular-arithmetic macros: \pmod, \mod, \pod \bmod shipped in #264 as a plain symbol-table entry. The three remaining commands are macros: each takes one argument and expands to amsmath's exact inline form -- \pmod{n} to \mkern8mu(\mathrm{mod}\mkern6mu n). Rather than expanding at parse time, an invocation parses to a single MTMacroAtom holding the command name, its parsed arguments, and the fixed prefix/suffix LaTeX that brackets them. -[MTMathList finalized] expands every macro atom before the reclassifying pass runs, so the macro never reaches the typesetter, and the Bin/Unary boundary rules see the flat atom stream the macro stands for. Keeping the atom means \pmod{n} serializes back to \pmod{n} rather than to its expansion. Scripts written on the invocation transfer to the last script-capable atom of the expansion, so \pmod{n}^2 puts the 2 on the closing paren. On collision, or when there is no target, both scripts go on a fresh empty Ordinary -- the same fallback the builder already uses for x^2^3. The expansion halves are plain LaTeX parsed by the ordinary builder; there is no template syntax and no placeholder atom. That expresses one substitution region, which is what these three macros need. LLD 3.1/3.3 specified a #N template engine instead; 4.1 records why it was dropped. Commands that require an argument now fail loud when given none: MTParseErrorMissingArgument covers end-of-input, a }/^/_/& in argument position, and a stop command (\right, \\, \cr, \end, ...) which would otherwise end the enclosing list and be returned as the argument. Only macros route through the new check; \sqrt keeps its existing permissive behavior. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ts3f5UtUywaqimvE4U1rkw --- CHANGELOG.md | 3 + iosMath.xcodeproj/project.pbxproj | 4 + iosMath/lib/MTMathList.h | 53 + iosMath/lib/MTMathList.m | 222 +++- iosMath/lib/MTMathListBuilder.h | 13 + iosMath/lib/MTMathListBuilder.m | 209 +++- iosMath/render/internal/MTTypesetter.m | 2 + iosMathTests/MTMathListBuilderTest.m | 22 + iosMathTests/MTModularArithmeticTest.m | 1324 +++++++++++++++++++++++- 9 files changed, 1847 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 990a0a12..ab85dd5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## Changelog +### v2.6.0 (2026-07-28) +* Add **modular-arithmetic notation**: `\bmod` as a binary operator, and the `\pmod`, `\mod`, and `\pod` macros with amsmath's exact inline gaps and upright "mod" (#264, #265, #266). `a \equiv b \pmod{n}` now renders as it does in LaTeX. The macros expand through a new internal macro atom, so a macro invocation serializes back to the command the author wrote rather than to its expansion. amsmath's wider display-style gap (18mu instead of 8/12mu) is not reproduced: a macro expands at parse time, before the render style is known. + ### v2.5.0 (2026-07-14) * Add the LaTeX **`array` environment**: `\begin{array}{lcr}…\end{array}` with per-column alignment, `|` column rules, and `\hline` row rules (#251, #253, #254). * Add more matrix and alignment environments: `smallmatrix`, `gathered`, and `alignedat` (#246, #248). diff --git a/iosMath.xcodeproj/project.pbxproj b/iosMath.xcodeproj/project.pbxproj index 9f0cb1f8..a45e8de2 100644 --- a/iosMath.xcodeproj/project.pbxproj +++ b/iosMath.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 490465BF1D23DA8400F82033 /* MTTypesetterTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 490465BE1D23DA8400F82033 /* MTTypesetterTest.m */; }; + C01DEC0DE20260726000003 /* MTModularArithmeticTest.m in Sources */ = {isa = PBXBuildFile; fileRef = C01DEC0DE20260726000002 /* MTModularArithmeticTest.m */; }; 49A1B2C41D23DA8400F82033 /* MTInkWidthTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 49A1B2C31D23DA8400F82033 /* MTInkWidthTest.m */; }; 490465C11D23DA8400F82033 /* MTFontManagerTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 490465C01D23DA8400F82033 /* MTFontManagerTest.m */; }; 492EED0817DAEDD200939107 /* MTFontManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 492EECFA17DAED9000939107 /* MTFontManager.m */; }; @@ -77,6 +78,7 @@ /* Begin PBXFileReference section */ 490465BE1D23DA8400F82033 /* MTTypesetterTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MTTypesetterTest.m; sourceTree = ""; }; + C01DEC0DE20260726000002 /* MTModularArithmeticTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MTModularArithmeticTest.m; sourceTree = ""; }; 49A1B2C31D23DA8400F82033 /* MTInkWidthTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MTInkWidthTest.m; sourceTree = ""; }; 490465C01D23DA8400F82033 /* MTFontManagerTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MTFontManagerTest.m; sourceTree = ""; }; 492EECF317DAED9000939107 /* MTMathListDisplay.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MTMathListDisplay.h; sourceTree = ""; }; @@ -258,6 +260,7 @@ C01DEC0DE20260722000104 /* MTMathUILabelSizingTest.m */, 49B83EF517CF046A0014B739 /* MTMathListTest.m */, 490465BE1D23DA8400F82033 /* MTTypesetterTest.m */, + C01DEC0DE20260726000002 /* MTModularArithmeticTest.m */, 49A1B2C31D23DA8400F82033 /* MTInkWidthTest.m */, 490465C01D23DA8400F82033 /* MTFontManagerTest.m */, 49965F2417CBBA2700A555C5 /* Supporting Files */, @@ -537,6 +540,7 @@ C01DEC0DE20260722000103 /* MTMathUILabelSizingTest.m in Sources */, 498730A817D548190041B02B /* MTMathListTest.m in Sources */, 490465BF1D23DA8400F82033 /* MTTypesetterTest.m in Sources */, + C01DEC0DE20260726000003 /* MTModularArithmeticTest.m in Sources */, 49A1B2C41D23DA8400F82033 /* MTInkWidthTest.m in Sources */, 490465C11D23DA8400F82033 /* MTFontManagerTest.m in Sources */, ); diff --git a/iosMath/lib/MTMathList.h b/iosMath/lib/MTMathList.h index 2ecf8a0e..10a21c49 100644 --- a/iosMath/lib/MTMathList.h +++ b/iosMath/lib/MTMathList.h @@ -72,6 +72,12 @@ typedef NS_ENUM(NSUInteger, MTMathAtomType) /// sub-mlist (== TeX Ord noad with sub_mlist / KaTeX "ordgroup"). /// Script-capable (< kMTMathAtomBoundary); spaced as Ordinary. kMTMathAtomOrdGroup = 21, + /// An unexpanded macro invocation (\pmod, \mod, \pod). Holds the command name, + /// its parsed arguments, and an argument-free golden template. Expanded away by + /// -[MTMathList finalized], so it never reaches the typesetter. + /// Script-capable (< kMTMathAtomBoundary): ^/_ attaches at parse time and is + /// transferred onto the expansion. + kMTMathAtomMacro = 22, // Atoms after this point do not support subscripts or superscripts @@ -688,6 +694,53 @@ typedef NS_ENUM(NSUInteger, MTStrikeStyle) { @end +/** An unexpanded macro invocation. + + `\pmod{n}` parses to exactly one `MTMacroAtom`, which keeps the raw list small + (trivial serialization, and `^`/`_` attach through the builder's shared tail). + It expands to `prefix` + `arguments` + `suffix`, both halves being raw + (non-finalized) expressions parsed once, at parse time. They are not a second + source of truth for `arguments`: the expansion is re-derived from (`prefix`, + current `arguments`, `suffix`) every time `-[MTMathList finalized]` runs. + + @note Expansion happens in list context, so `-[MTMacroAtom finalized]` on a lone + atom returns another macro atom rather than the expansion. Only + `-[MTMathList finalized]` expands. + + @note This shape expresses one substitution region — everything fixed before the + arguments, everything fixed after — which is what the three modular-arithmetic + macros need. A composite expansion that interleaves fixed text between arguments + (`\frac{#1}{#2}`) cannot be expressed. A user-facing `\newcommand` would need + substitution that descends into sub-lists, which is a different mechanism, not a + wider table here. + */ +@interface MTMacroAtom : MTMathAtom + +/** The command name without the leading backslash, e.g. `@"pmod"`. */ +@property (nonatomic, copy, readonly) NSString* command; + +/** The parsed arguments, in order. The array identity is immutable; the contained + `MTMathList`s stay mutable and are owned by this atom (deep-copied at init). */ +@property (nonatomic, copy, readonly) NSArray* arguments; + +/** The fixed expansion text before the arguments. Raw (non-finalized); may be empty. */ +@property (nonatomic, strong, readonly) MTMathList* prefix; + +/** The fixed expansion text after the arguments. Raw (non-finalized); may be empty. */ +@property (nonatomic, strong, readonly) MTMathList* suffix; + +- (instancetype)initWithCommand:(NSString*)command + arguments:(NSArray*)arguments + prefix:(MTMathList*)prefix + suffix:(MTMathList*)suffix NS_DESIGNATED_INITIALIZER; + +/// A macro atom has no valid zero-argument construction — command, arguments and +/// expansion text are all required — so the generic initializer is unavailable. The +/// implementation additionally throws, to catch dynamic (`id`-typed) callers. +- (instancetype)initWithType:(MTMathAtomType)type value:(NSString*)value NS_UNAVAILABLE; + +@end + /** An atom representing an table element. This atom is not like other atoms and is not present in TeX. We use it to represent the `\halign` command in TeX with some simplifications. This is used for matrices, equation diff --git a/iosMath/lib/MTMathList.m b/iosMath/lib/MTMathList.m index 901916b5..f61bdcfd 100644 --- a/iosMath/lib/MTMathList.m +++ b/iosMath/lib/MTMathList.m @@ -70,6 +70,8 @@ static BOOL isNotBinaryOperator(MTMathAtom* prevNode) return @"Box"; case kMTMathAtomOrdGroup: return @"Ord Group"; + case kMTMathAtomMacro: + return @"Macro"; case kMTMathAtomBoundary: return @"Boundary"; case kMTMathAtomSpace: @@ -85,6 +87,40 @@ static BOOL isNotBinaryOperator(MTMathAtom* prevNode) } } +// NSArray's -copy is shallow: it copies the array but shares the (mutable) +// MTMathList elements. MTMathList's own -copyWithZone: is already deep. +static NSArray* MTDeepCopyMathListArray(NSArray* lists) +{ + NSMutableArray* copies = [NSMutableArray arrayWithCapacity:lists.count]; + for (MTMathList* list in lists) { + [copies addObject:[list copy]]; + } + return [copies copy]; +} + +@interface MTMathList () + +/** Returns a copy of this list with every top-level MTMacroAtom replaced by its + RAW (non-finalized) expansion. Non-macro atoms are carried over by reference. */ +- (MTMathList *)expandMacros; + +@end + +@interface MTMacroAtom () + +/** The RAW (non-finalized) atom stream this invocation stands for. + + Recursion here is bounded by the nesting the parser accepted: a macro can only + land inside another macro's argument because the input nested them, and + -buildInternal: caps that at kMTMaxRecursionDepth. No separate budget needed. */ +- (MTMathList *)expansion; + +/** Moves this atom's scripts onto the last scriptable atom of `expansion`, + appending an empty Ordinary when there is no free target. */ +- (void)transferScriptsToExpansion:(MTMathList *)expansion; + +@end + @interface MTMathListBuilder (MTMathListSerializationSupport) + (NSString*)delimToString:(MTMathAtom*)delim; @@ -171,7 +207,14 @@ + (instancetype)atomWithType:(MTMathAtomType)type value:(NSString *)value case kMTMathAtomColorbox: return [[MTMathColorbox alloc] init]; - + + case kMTMathAtomMacro: + // Falling through to the default would mint a plain MTMathAtom carrying + // type 22 — an atom that claims to be a macro but cannot expand. + @throw [NSException exceptionWithName:@"InvalidMethod" + reason:@"A macro atom cannot be created by type. Use -[MTMacroAtom initWithCommand:arguments:prefix:suffix:] instead." + userInfo:nil]; + default: return [[MTMathAtom alloc] initWithType:type value:value]; } @@ -1677,13 +1720,27 @@ - (NSString *)description return self.atoms.description; } +/** Reclassifies the list: demotes Bin to Unary at the boundaries where TeX does, + fuses adjacent numbers, and assigns index ranges. Macros are expanded first — + finalization is irreversible and context-dependent (a Bin demoted to Unary at one + boundary cannot be restored), so the reclassifying pass must see the flat raw + stream a macro stands for, not the macro atom. */ - (MTMathList *)finalized { + MTMathList* expanded = [self expandMacros]; + MTMathList* finalized = [MTMathList new]; NSRange zeroRange = NSMakeRange(0, 0); - + MTMathAtom* prevNode = nil; - for (MTMathAtom* atom in self.atoms) { + for (MTMathAtom* atom in expanded.atoms) { + // -expandMacros dispatches on class, so a real MTMacroAtom is already gone. + // What this catches is a plain MTMathAtom whose settable public -type was + // forced to kMTMathAtomMacro: it walks through expansion untouched and would + // otherwise reach the typesetter, which drops it. + NSAssert(atom.type != kMTMathAtomMacro, + @"Atom %@ claims to be a macro but is not an MTMacroAtom; -type must not be set to kMTMathAtomMacro.", + atom.stringValue); MTMathAtom* newNode = [atom finalized]; // Each character is given a separate index. if (NSEqualRanges(zeroRange, atom.indexRange)) { @@ -1728,6 +1785,28 @@ - (MTMathList *)finalized return finalized; } +- (MTMathList *)expandMacros +{ + MTMathList* expanded = [MTMathList new]; + for (MTMathAtom* atom in self.atoms) { + // isKindOfClass: rather than atom.type: `type` is a settable public property, + // so a plain MTMathAtom can carry kMTMathAtomMacro without responding to + // -expansion. The -type assert in -finalized catches that impostor. + if (![atom isKindOfClass:[MTMacroAtom class]]) { + // Carried through by reference and WITHOUT descending into sub-lists + // (numerator/denominator/radicand/innerList/cells/scripts). Every + // container's -finalized calls the public -finalized on its children, + // which expands those lists in turn — so this code needs to understand + // zero container types. Sharing is safe because -finalized copies every + // atom it keeps, via [atom finalized]. + [expanded addAtom:atom]; + continue; + } + [expanded append:[(MTMacroAtom*)atom expansion]]; + } + return expanded; +} + #pragma mark NSCopying // Makes a deep copy of the list @@ -1739,3 +1818,140 @@ - (id)copyWithZone:(NSZone *)zone } @end + +#pragma mark - MTMacroAtom + +@implementation MTMacroAtom + +- (instancetype)initWithCommand:(NSString*)command + arguments:(NSArray*)arguments + prefix:(MTMathList*)prefix + suffix:(MTMathList*)suffix +{ + NSParameterAssert(command); + NSParameterAssert(arguments); + NSParameterAssert(prefix); + NSParameterAssert(suffix); + self = [super initWithType:kMTMathAtomMacro value:@""]; + if (self) { + _command = [command copy]; + _arguments = MTDeepCopyMathListArray(arguments); + _prefix = [prefix copy]; + _suffix = [suffix copy]; + } + return self; +} + +- (instancetype)initWithType:(MTMathAtomType)type value:(NSString*)value +{ + // NS_UNAVAILABLE in the header already blocks statically typed callers; this + // catches the dynamic ones. Unlike MTInner/MTMathColorbox there is no valid + // zero-argument construction to fall back to — command, arguments and expansion + // text are all required — so it throws rather than redirecting to a bare -init. + @throw [NSException exceptionWithName:@"InvalidMethod" + reason:@"[MTMacroAtom initWithType:value:] cannot be called. Use -initWithCommand:arguments:prefix:suffix: instead." + userInfo:nil]; +} + +- (id)copyWithZone:(NSZone *)zone +{ + // Cannot route through [super copyWithZone:], which would call the throwing + // -initWithType:value:. The designated initializer already deep-copies the + // arguments and both expansion halves, so only the MTMathAtom fields need + // carrying over. + MTMacroAtom* copy = [[[self class] allocWithZone:zone] initWithCommand:self.command + arguments:self.arguments + prefix:self.prefix + suffix:self.suffix]; + copy.subScript = [self.subScript copyWithZone:zone]; + copy.superScript = [self.superScript copyWithZone:zone]; + copy.indexRange = self.indexRange; + copy.fontStyle = self.fontStyle; + return copy; +} + +- (NSString *)stringValue +{ + NSMutableString* str = [NSMutableString stringWithFormat:@"\\%@", self.command]; + for (MTMathList* arg in self.arguments) { + [str appendFormat:@"{%@}", arg.stringValue]; + } + if (self.superScript) { + [str appendFormat:@"^{%@}", self.superScript.stringValue]; + } + if (self.subScript) { + [str appendFormat:@"_{%@}", self.subScript.stringValue]; + } + return str; +} + +- (void)appendLaTeXToString:(NSMutableString *)str +{ + // Command-faithful, argument-canonical: the invocation round-trips as \pmod{…}, + // and the argument is re-serialized by the usual serializer rather than + // preserved character-for-character. +mathListToString: appends the ^{…}/_{…} + // tail for us. + [str appendFormat:@"\\%@", self.command]; + if (self.arguments.count == 0) { + // Nothing would terminate the command name otherwise, so a zero-argument + // \noargs followed by x would re-parse as the single command \noargsx. + [str appendString:@" "]; + } + for (MTMathList* arg in self.arguments) { + [str appendFormat:@"{%@}", [MTMathListBuilder mathListToString:arg]]; + } +} + +- (MTMathList *)expansion +{ + // Deep copies throughout, so the stored expansion halves and arguments stay + // pristine for serialization, for post-parse mutation, and for repeated + // -finalized calls. + MTMathList* out = [self.prefix copy]; + for (MTMathList* argument in self.arguments) { + [out append:[argument copy]]; + } + [out append:[self.suffix copy]]; + // An argument may itself contain a macro. Re-scan so the returned list is + // macro-free at its top level — and so script transfer below targets a real atom + // rather than an unexpanded MTMacroAtom. + MTMathList* flat = [out expandMacros]; + [self transferScriptsToExpansion:flat]; + return flat; +} + +- (void)transferScriptsToExpansion:(MTMathList *)expansion +{ + if (!self.superScript && !self.subScript) { + return; + } + MTMathAtom* target = nil; + for (MTMathAtom* candidate in expansion.atoms.reverseObjectEnumerator) { + // -scriptsAllowed is type < kMTMathAtomBoundary, which already excludes + // Space (201), Style (202) and every other non-noad (MTMathList.m:222-225). + if (candidate.scriptsAllowed) { + target = candidate; + break; + } + } + // Slots are evaluated as a unit: if there is no target, or either slot we need + // is taken, BOTH scripts go on a fresh empty Ordinary so a ^/_ pair is never + // split. This is what the builder already does for x^2^3 and for a leading ^2 + // (MTMathListBuilder.m:211-216, 224-228), so \mod{n^2}^3 behaves like + // \mod{n^2}{}^3 — iosMath's long-standing divergence from TeX, not a new one. + BOOL collides = (target == nil) + || (self.superScript && target.superScript) + || (self.subScript && target.subScript); + if (collides) { + target = [MTMathAtom atomWithType:kMTMathAtomOrdinary value:@""]; + [expansion addAtom:target]; + } + if (self.superScript) { + target.superScript = [self.superScript copy]; + } + if (self.subScript) { + target.subScript = [self.subScript copy]; + } +} + +@end diff --git a/iosMath/lib/MTMathListBuilder.h b/iosMath/lib/MTMathListBuilder.h index 36f2ee8b..159dae60 100644 --- a/iosMath/lib/MTMathListBuilder.h +++ b/iosMath/lib/MTMathListBuilder.h @@ -51,6 +51,16 @@ NS_ASSUME_NONNULL_BEGIN /// This converts the MTMathList to LaTeX. + (NSString *) mathListToString:(MTMathList *)ml; +/** The names of the supported one-argument macro commands (`pmod`, `mod`, `pod`), + without the leading backslash. + + These are macros, not symbols: they are absent from + `+[MTMathAtomFactory supportedLatexSymbolNames]`, which remains symbol-only. Check + both lists to enumerate everything the parser accepts. `\bmod` is a symbol and + appears in the symbol list, not here. + */ ++ (NSArray *) supportedMacroNames; + /** @typedef MTParseErrors @brief The error encountered when parsing a LaTeX string. @@ -97,6 +107,9 @@ typedef NS_ENUM(NSUInteger, MTParseErrors) { MTParseErrorMissingColumnSpec, /// An array column specification was empty or used an unsupported specifier. MTParseErrorInvalidColumnSpec, + /// A command that requires an argument was given none — end of input, or a + /// `}`/`^`/`_`/`&` where the argument should be. + MTParseErrorMissingArgument, }; @end diff --git a/iosMath/lib/MTMathListBuilder.m b/iosMath/lib/MTMathListBuilder.m index 835ff8cb..9f9458fd 100644 --- a/iosMath/lib/MTMathListBuilder.m +++ b/iosMath/lib/MTMathListBuilder.m @@ -41,6 +41,55 @@ - (instancetype)initWithName:(NSString*) name @end +#pragma mark - MTMacroDefinition + +// A built-in macro: how many arguments it takes, and the fixed LaTeX that brackets +// them. \pmod{n} expands to prefix + n + suffix. +// +// Both halves are ordinary LaTeX parsed by the ordinary builder — there is no +// template syntax and no placeholder atom. That buys one substitution region, which +// is what these macros need; an expansion that interleaves fixed text between +// several arguments is not expressible, and a user-facing \newcommand would need +// substitution that descends into sub-lists rather than a richer table here. +// +// Parser-owned and file-private on purpose. The model layer needs NO registry +// access: MTMacroAtom carries its already-parsed halves, so the dependency runs one +// way (MTMathListBuilder -> MTMacroDefinition, at parse time) and MTMathList.m never +// calls into the builder. A consequence worth keeping: an invocation is bound to the +// expansion as it existed when parsed, so a future mutable registry cannot +// retroactively change an already-parsed atom. +@interface MTMacroDefinition : NSObject + +@property (nonatomic, readonly) NSUInteger argumentCount; // declared, not inferred +@property (nonatomic, copy, readonly) NSString* prefix; +@property (nonatomic, copy, readonly) NSString* suffix; + +- (instancetype)initWithArgumentCount:(NSUInteger)argumentCount + prefix:(NSString*)prefix + suffix:(NSString*)suffix NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; + +@end + +@implementation MTMacroDefinition + +- (instancetype)initWithArgumentCount:(NSUInteger)argumentCount + prefix:(NSString*)prefix + suffix:(NSString*)suffix +{ + NSParameterAssert(prefix); + NSParameterAssert(suffix); + self = [super init]; + if (self) { + _argumentCount = argumentCount; + _prefix = [prefix copy]; + _suffix = [suffix copy]; + } + return self; +} + +@end + // Maximum recursion depth for -buildInternal:oneCharOnly:stopChar:. // 150 is comfortably deeper than any realistic human-authored expression yet // far below the thousands of frames needed to overflow a 1 MB stack. @@ -154,6 +203,64 @@ - (BOOL) readOptionalAlignment:(MTFractionAlignment*)outAlignment return YES; } +// Reads one mandatory argument, failing loud when there isn't one. +// +// -buildInternal:YES on its own is silently permissive: at EOF it returns an empty +// list with no error, and it leaves a following }/^/_/& unlooked for the caller +// (see -build and -buildInternal:oneCharOnly:stopChar: above). That is fine for +// \sqrt, which has always behaved that way, but a macro invocation with no +// argument must be an error. +// +// Factored out rather than inlined so future command categories can adopt it. +// Scope: only macros route through it today; migrating \sqrt and friends is a +// separate, behavior-affecting change. +- (nullable MTMathList *)requiredArgumentWithError:(MTParseErrors)error +{ + [self skipSpaces]; + if (![self hasCharacters]) { + [self setError:error message:@"Missing required argument at end of input"]; + return nil; + } + unichar ch = [self getNextCharacter]; + [self unlookCharacter]; + if (ch == '}' || ch == '^' || ch == '_' || ch == '&') { + [self setError:error + message:[NSString stringWithFormat:@"Missing required argument before '%c'", ch]]; + return nil; + } + if (ch == '\\') { + // A stop command terminates the enclosing list instead of producing an + // atom, so none of them can begin an argument. Without this check + // -buildInternal:YES hands them to -stopCommand:, which for \\ and \cr + // ends the row and returns it as the "argument" — silently swallowing a + // row break (`\begin{matrix}a\pmod\\b\end{matrix}` loses a row) — and for + // \right / \end returns an empty list with NO error. Wrong output rather + // than a diagnostic, which is exactly what this method exists to prevent. + NSString* command = [self peekCommand]; + if ([[MTMathListBuilder stopCommands] containsObject:command]) { + [self setError:error + message:[NSString stringWithFormat:@"Missing required argument before \\%@", command]]; + return nil; + } + } + // An empty {} is a valid, empty argument (LaTeX parity) — not a missing one. + return [self buildInternal:YES]; +} + +// Reads the command at the current position (which must be on the '\') and +// restores the read position, so the caller can dispatch on it without consuming +// it. Returns nil if there is no command there. +- (nullable NSString *)peekCommand +{ + int saved = _currentChar; + NSString* command = nil; + if ([self hasCharacters] && [self getNextCharacter] == '\\') { + command = [self readCommand]; + } + _currentChar = saved; + return command; +} + - (MTMathList *)build { MTMathList* list = [self buildInternal:false]; @@ -339,7 +446,15 @@ - (MTMathList*)buildInternal:(BOOL) oneCharOnly stopChar:(unichar) stop } continue; } - atom = [self atomForCommand:command]; + // Macros first: one MTMacroAtom flows through the shared script-attach + + // append + oneCharOnly tail below with no new logic (LLD §2.6). + atom = [self macroAtomForCommand:command]; + if (!atom) { + if (_error) { + return nil; + } + atom = [self atomForCommand:command]; + } if (atom == nil) { // this was an unknown command, // we flag an error and return @@ -1066,6 +1181,42 @@ - (MTMathAtom*) getBoundaryAtom:(NSString*) delimiterType return commands; } +// Returns nil WITHOUT setting an error when `command` is not a macro, so the caller +// can fall through to -atomForCommand:. Returns nil WITH _error set when the +// command is a macro whose arguments failed to parse. +- (nullable MTMacroAtom*) macroAtomForCommand:(NSString*) command +{ + MTMacroDefinition* def = [MTMathListBuilder builtinMacros][command]; + if (!def) { + return nil; + } + // Arguments are read with the EXISTING reader (the one \frac/\sqrt use), so + // they are parsed directly from the user's input — never re-parsed out of a + // generated string (LLD §4.1). + NSMutableArray* args = [NSMutableArray arrayWithCapacity:def.argumentCount]; + for (NSUInteger i = 0; i < def.argumentCount; i++) { + MTMathList* arg = [self requiredArgumentWithError:MTParseErrorMissingArgument]; + if (!arg) { + return nil; // _error already set + } + [args addObject:arg]; + } + // Each half is parsed with a FRESH builder instance, so the in-flight parse's + // state is never swapped or restored. + MTMathList* prefix = [MTMathListBuilder buildFromString:def.prefix]; + MTMathList* suffix = [MTMathListBuilder buildFromString:def.suffix]; + // Both are compile-time constants, never user input: failing to parse one is a + // programming mistake, so fail loud. + NSAssert(prefix && suffix, @"Built-in expansion for \\%@ failed to parse: %@ / %@", + command, def.prefix, def.suffix); + if (!prefix || !suffix) { + [self setError:MTParseErrorInternalError + message:[NSString stringWithFormat:@"Built-in expansion for \\%@ failed to parse", command]]; + return nil; + } + return [[MTMacroAtom alloc] initWithCommand:command arguments:args prefix:prefix suffix:suffix]; +} + - (MTMathAtom*) atomForCommand:(NSString*) command { MTMathAtom* atom = [MTMathAtomFactory atomForLatexSymbolName:command]; @@ -1319,6 +1470,20 @@ - (MTMathAtom*) atomForCommand:(NSString*) command } } +// Every command -stopCommand: below recognizes. These end the list being built +// rather than contributing an atom to it, so they are illegal in an argument +// slot — see -requiredArgumentWithError:. Keep in sync with -stopCommand:. ++ (NSSet*) stopCommands +{ + static NSSet* commands = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + commands = [NSSet setWithArray:@[ @"right", @"over", @"atop", @"choose", + @"brack", @"brace", @"\\", @"cr", @"end" ]]; + }); + return commands; +} + - (MTMathList*) stopCommand:(NSString*) command list:(MTMathList*) list stopChar:(unichar) stopChar oneChar:(BOOL) oneChar { static NSDictionary* fractionCommands = nil; @@ -1642,6 +1807,48 @@ + (NSDictionary*) spaceToCommands return fractionMacroCommands; } +// The built-in macro registry. Each entry is amsmath's exact INLINE expansion +// (LLD §3.3): the 8mu/12mu leading and 6mu inner gaps are amsmath's literal \mkern +// values, not approximations. What is not reproduced is amsmath's \if@display +// switch to an 18mu leading gap, because a macro expands at parse time, before the +// render style is known (LLD §4.2, PRD non-goal §3.1). +// +// The upright "mod" comes from \mathrm{mod} in the prefix — no manual Roman flag, +// and each half stays a readable LaTeX string. +// +// This dispatch_once builds STRINGS ONLY. Nothing is parsed inside it, so there is +// no reentrancy. That is also why the parsed halves are NOT cached here: parsing one +// reaches -macroAtomForCommand: (every command does), which calls back into this +// method — re-entering its own dispatch_once on the same thread deadlocks. They are +// re-parsed per invocation instead, which is ~8 atoms; MTMacroAtom deep-copies the +// result anyway. ++ (NSDictionary*) builtinMacros +{ + static NSDictionary* macros = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + macros = @{ + @"pmod": [[MTMacroDefinition alloc] initWithArgumentCount:1 + prefix:@"\\mkern8mu(\\mathrm{mod}\\mkern6mu" + suffix:@")"], + @"mod": [[MTMacroDefinition alloc] initWithArgumentCount:1 + prefix:@"\\mkern12mu\\mathrm{mod}\\mkern6mu" + suffix:@""], + @"pod": [[MTMacroDefinition alloc] initWithArgumentCount:1 + prefix:@"\\mkern8mu(" + suffix:@")"], + }; + }); + return macros; +} + ++ (NSArray *) supportedMacroNames +{ + // Sorted, not raw -allKeys: NSDictionary key order is unspecified and can vary + // between runs, which would make any ordered assertion by a caller flaky. + return [[MTMathListBuilder builtinMacros].allKeys sortedArrayUsingSelector:@selector(compare:)]; +} + + (NSDictionary*) styleToCommands { static NSDictionary* styleToCommands = nil; diff --git a/iosMath/render/internal/MTTypesetter.m b/iosMath/render/internal/MTTypesetter.m index a448d22e..ee580c76 100644 --- a/iosMath/render/internal/MTTypesetter.m +++ b/iosMath/render/internal/MTTypesetter.m @@ -616,7 +616,9 @@ - (void) createDisplayAtoms:(NSArray*) preprocessed case kMTMathAtomNumber: case kMTMathAtomVariable: case kMTMathAtomUnaryOperator: + case kMTMathAtomMacro: // These should never appear as they should have been removed by preprocessing + // (macros are expanded by -[MTMathList finalized] before typesetting). NSAssert(NO, @"These types should never show here as they are removed by preprocessing."); break; diff --git a/iosMathTests/MTMathListBuilderTest.m b/iosMathTests/MTMathListBuilderTest.m index f9336f2b..20ee19a1 100644 --- a/iosMathTests/MTMathListBuilderTest.m +++ b/iosMathTests/MTMathListBuilderTest.m @@ -1710,6 +1710,28 @@ - (void) testAlignedatWhitespaceArgument @[@"\\begin{array}{c} a", @(MTParseErrorMissingEnd)], @[@"\\hline a", @(MTParseErrorInvalidCommand)], @[@"\\begin{matrix} \\hline a \\end{matrix}", @(MTParseErrorInvalidCommand)], + // Item 15: modular-arithmetic macro parse errors + @[@"\\pmod", @(MTParseErrorMissingArgument)], + @[@"\\mod", @(MTParseErrorMissingArgument)], + @[@"\\pod", @(MTParseErrorMissingArgument)], + @[@"a \\pmod ", @(MTParseErrorMissingArgument)], // trailing space, still EOF + @[@"{\\pmod}", @(MTParseErrorMissingArgument)], + @[@"\\pmod^2", @(MTParseErrorMissingArgument)], + // NOTE: the plan's literal case here was `\pmod{\frac}`, expected to + // propagate MTParseErrorMismatchBraces. In fact `\frac` with no + // operands is not an error at all (same as bare top-level `\frac`, + // which degrades to `\frac{}{}`): `requiredArgumentWithError:` reads + // one argument via the SAME `buildInternal:YES` reader `\frac` itself + // uses, so `\frac`'s own numerator/denominator reads see the closing + // `}` immediately and each come back as an empty (not missing) + // argument. `\pmod{\frac}` therefore parses successfully to + // `\pmod{\frac{}{}}` — verified directly; see + // -testFracWithNoArgumentsIsNotAnErrorInsideMacroArgument in + // MTModularArithmeticTest.m. Swapped in a genuinely malformed + // argument (an unbalanced brace) that does propagate + // MTParseErrorMismatchBraces, which is what this row is actually + // meant to characterize (LLD §6: an inner parse error propagates). + @[@"\\pmod{{n}", @(MTParseErrorMismatchBraces)], ]; }; diff --git a/iosMathTests/MTModularArithmeticTest.m b/iosMathTests/MTModularArithmeticTest.m index f4efd9d6..a1f4499a 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -3,7 +3,6 @@ // iosMath // // Tests for \bmod, \pmod, \mod, \pod. -// Design: docs/lld/2026-07-13-modular-arithmetic.md // #import @@ -17,11 +16,27 @@ #import "MTFontManager.h" #import "MTMathListDisplay.h" #import "MTMathListDisplayInternal.h" +#import "MTFontMathTable.h" @interface MTModularArithmeticTest : XCTestCase @property (nonatomic) MTFont* font; @end +// Declared privately in MTMathList.m; redeclared here so the tests can drive +// macro expansion in isolation and observe RAW (unreclassified) output. +@interface MTMathList (MTMacroExpansionTesting) +- (MTMathList *)expandMacros; +@end + +// Defined under "Equivalence helpers" below. +static NSString* ListSignature(MTMathList* list); + +// Declared privately in MTMathListBuilder.m; redeclared here so the required-argument +// guard tests can drive it directly. +@interface MTMathListBuilder (MTRequiredArgumentTesting) +- (nullable MTMathList *)requiredArgumentWithError:(MTParseErrors)error; +@end + @implementation MTModularArithmeticTest - (void)setUp @@ -139,4 +154,1311 @@ - (void)testDemotedBmodSerializes XCTAssertEqualObjects([MTMathListBuilder mathListToString:finalized], @"\\bmod 5"); } +#pragma mark - MTMacroAtom + +// The two fixed halves of \pod's expansion: [Space8, Open "("] and [Close ")"]. +// Hand-built so PR 2 is independent of the parser (which lands in PR 3). +static MTMathList* PodPrefix(void) +{ + MTMathList* t = [MTMathList new]; + [t addAtom:[[MTMathSpace alloc] initWithSpace:8]]; + [t addAtom:[MTMathAtom atomWithType:kMTMathAtomOpen value:@"("]]; + return t; +} + +static MTMathList* PodSuffix(void) +{ + MTMathList* t = [MTMathList new]; + [t addAtom:[MTMathAtom atomWithType:kMTMathAtomClose value:@")"]]; + return t; +} + +static MTMacroAtom* PodMacroWithArgument(NSString* latex) +{ + MTMathList* arg = [MTMathListBuilder buildFromString:latex]; + return [[MTMacroAtom alloc] initWithCommand:@"pod" + arguments:@[ arg ] + prefix:PodPrefix() + suffix:PodSuffix()]; +} + +- (void)testMacroAtomBasics +{ + MTMacroAtom* macro = PodMacroWithArgument(@"n"); + XCTAssertEqual(macro.type, kMTMathAtomMacro); + XCTAssertEqualObjects(macro.command, @"pod"); + XCTAssertEqual(macro.arguments.count, 1ul); + XCTAssertEqual(macro.prefix.atoms.count, 2ul); + XCTAssertEqual(macro.suffix.atoms.count, 1ul); + // 22 sits just past kMTMathAtomOrdGroup (21), the last script-capable value, + // so a macro can carry ^/_ at parse time (MTMathList.h:74-78). + XCTAssertTrue(macro.scriptsAllowed); +} + +// NSArray's -copy is shallow. The initializer must deep-copy, or a caller can +// mutate the list it handed in and silently mutate the atom. +- (void)testMacroAtomDeepCopiesAtInit +{ + MTMathList* arg = [MTMathListBuilder buildFromString:@"n"]; + MTMathList* prefix = PodPrefix(); + MTMathList* suffix = PodSuffix(); + MTMacroAtom* macro = [[MTMacroAtom alloc] initWithCommand:@"pod" + arguments:@[ arg ] + prefix:prefix + suffix:suffix]; + [arg addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; + [prefix addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; + [suffix addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; + + XCTAssertEqual([macro.arguments[0] atoms].count, 1ul, @"argument was not deep-copied"); + XCTAssertEqual(macro.prefix.atoms.count, 2ul, @"prefix was not deep-copied"); + XCTAssertEqual(macro.suffix.atoms.count, 1ul, @"suffix was not deep-copied"); +} + +- (void)testMacroAtomCopyIsDeep +{ + MTMacroAtom* macro = PodMacroWithArgument(@"n"); + macro.superScript = [MTMathListBuilder buildFromString:@"2"]; + MTMacroAtom* copy = [macro copy]; + + XCTAssertTrue([copy isKindOfClass:[MTMacroAtom class]]); + XCTAssertEqualObjects(copy.command, @"pod"); + XCTAssertNotEqual(copy.arguments[0], macro.arguments[0]); + XCTAssertNotEqual(copy.prefix, macro.prefix); + XCTAssertEqual(copy.prefix.atoms.count, 2ul); + XCTAssertEqual(copy.suffix.atoms.count, 1ul); + XCTAssertNotNil(copy.superScript); + + [macro.arguments[0] addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; + XCTAssertEqual([copy.arguments[0] atoms].count, 1ul); +} + +// A macro atom has no valid zero-argument construction. NS_UNAVAILABLE stops +// statically typed callers at compile time; the runtime guard below is what +// catches an id-typed one, so it is exercised through an id on purpose. +- (void)testMacroAtomRejectsGenericInitializer +{ + id macro = [MTMacroAtom alloc]; + XCTAssertThrows([macro initWithType:kMTMathAtomMacro value:@""]); + XCTAssertThrows([macro initWithType:kMTMathAtomOrdinary value:@"x"]); +} + +// The generic factory has a case for every other structured type, so falling +// through to its default would mint a plain MTMathAtom carrying type 22 — one +// that claims to be a macro but dies on -expansion. +- (void)testAtomFactoryRejectsMacroType +{ + XCTAssertThrows([MTMathAtom atomWithType:kMTMathAtomMacro value:@""]); +} + +// +atomWithType: is not the only door: -type is a settable public property, so a +// plain MTMathAtom can be relabelled as a macro after the fact. Expansion dispatches +// on class and carries it through untouched, and the typesetter would silently drop +// it — so -finalized asserts on the way past. +- (void)testFinalizedRejectsNonMacroAtomTypedAsMacro +{ + MTMathAtom* impostor = [MTMathAtom atomWithType:kMTMathAtomVariable value:@"x"]; + impostor.type = kMTMathAtomMacro; + MTMathList* list = [MTMathList new]; + [list addAtom:impostor]; + XCTAssertThrows([list finalized]); +} + +// Nests `depth` \pod macros, each one the sole content of the next one's argument. +static MTMacroAtom* NestedPodChain(NSUInteger depth) +{ + MTMacroAtom* macro = PodMacroWithArgument(@"n"); + for (NSUInteger i = 1; i < depth; i++) { + MTMathList* arg = [MTMathList new]; + [arg addAtom:macro]; + macro = [[MTMacroAtom alloc] initWithCommand:@"pod" + arguments:@[ arg ] + prefix:PodPrefix() + suffix:PodSuffix()]; + } + return macro; +} + +// Expansion recurses once per nesting level, but the nesting it can see is whatever +// -buildInternal: already accepted, and that is capped at kMTMaxRecursionDepth. So +// expansion needs no depth budget of its own: anything the parser built must expand. +- (void)testDeeplyNestedMacrosExpand +{ + for (NSNumber* depth in @[ @8, @32, @33, @64 ]) { + MTMathList* list = [MTMathList new]; + [list addAtom:NestedPodChain(depth.unsignedIntegerValue)]; + XCTAssertNoThrow([list finalized], @"depth %@", depth); + } + + // The parser side of the same claim: nesting that survives -buildInternal: also + // survives -finalized. + NSMutableString* latex = [NSMutableString string]; + for (NSUInteger i = 0; i < 40; i++) { + [latex appendString:@"\\pod{"]; + } + [latex appendString:@"n"]; + for (NSUInteger i = 0; i < 40; i++) { + [latex appendString:@"}"]; + } + NSError* error = nil; + MTMathList* parsed = [MTMathListBuilder buildFromString:latex error:&error]; + XCTAssertNotNil(parsed); + XCTAssertNil(error); + XCTAssertNoThrow([parsed finalized]); +} + +// \noargs with nothing to terminate the command name would re-parse as \noargsx. +- (void)testZeroArgumentMacroSerializesWithSeparator +{ + MTMacroAtom* macro = [[MTMacroAtom alloc] initWithCommand:@"noargs" + arguments:@[] + prefix:[MTMathListBuilder buildFromString:@"1"] + suffix:[MTMathList new]]; + MTMathList* list = [MTMathList new]; + [list addAtom:macro]; + [list addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"x"]]; + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\noargs x"); +} + +- (void)testMacroAtomSerializesCommandFaithfully +{ + MTMathList* list = [MTMathList new]; + [list addAtom:PodMacroWithArgument(@"n+1")]; + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pod{n+1}"); +} + +- (void)testMacroAtomSerializesWithScripts +{ + MTMacroAtom* macro = PodMacroWithArgument(@"n"); + macro.superScript = [MTMathListBuilder buildFromString:@"2"]; + MTMathList* list = [MTMathList new]; + [list addAtom:macro]; + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pod{n}^{2}"); +} + +// The template is not a source of truth for the arguments: mutating a parsed +// argument must show up in serialization. +- (void)testMacroAtomSerializationTracksArgumentMutation +{ + MTMacroAtom* macro = PodMacroWithArgument(@"n"); + MTMathList* list = [MTMathList new]; + [list addAtom:macro]; + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pod{n}"); + + MTMathList* arg = macro.arguments[0]; + [arg removeAtomAtIndex:0]; + [arg addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"m"]]; + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pod{m}"); +} + +- (void)testMacroAtomStringValue +{ + XCTAssertEqualObjects([PodMacroWithArgument(@"n") stringValue], @"\\pod{n}"); +} + +#pragma mark - Two-phase finalized + +// Phase 2 must be the existing loop, unchanged: same Bin/Unary reclassification, +// same number fusion, same index ranges, on lists that contain no macros at all. +// Routing every list through macro expansion must not perturb the reclassifying +// pass. Asserted against literal signatures rather than serialization, because +// latexSymbolNameForAtom: maps Unary back through the Bin cell — the exact +// distinction under test would be invisible in a round-tripped string. +- (void)testFinalizedUnchangedForMacroFreeLists +{ + NSDictionary* expected = @{ + // 1 and 7 fuse into one Number; + keeps a left operand so it stays Bin. + @"17+5": @"[2:17, 5:+, 2:5]", + // No left operand at all -> Unary. + @"-x": @"[6:\u2212, 3:x]", + // Nothing follows -> Unary. + @"x+": @"[3:x, 6:+]", + // Follows an Open -> Unary. + @"(+3)": @"[8:(, 6:+, 2:3, 9:)]", + // Demotion happens independently inside each sub-list. + @"\\frac{1+2}{3-}": @"[10:numerator[2:1, 5:+, 2:2]denominator[2:3, 6:\u2212]]", + @"a\\equiv b": @"[3:a, 7:\u2261, 3:b]", + // 2 and 3 fuse; x stays Bin between two Numbers. + @"1\\times 23": @"[2:1, 5:\u00d7, 2:23]", + }; + for (NSString* latex in expected) { + MTMathList* list = [MTMathListBuilder buildFromString:latex]; + XCTAssertNotNil(list, @"%@", latex); + MTMathList* finalized = list.finalized; + // finalized must still be a fresh list, not the receiver. + XCTAssertNotEqual(finalized, list, @"%@", latex); + XCTAssertEqualObjects(ListSignature(finalized), expected[latex], @"%@", latex); + XCTAssertEqualObjects(ListSignature(list.finalized), expected[latex], + @"%@ is not idempotent across calls", latex); + } +} + +- (void)testExpandingMacrosCopiesListWithoutMacros +{ + MTMathList* list = [MTMathListBuilder buildFromString:@"1+2"]; + MTMathList* expanded = [list expandMacros]; + XCTAssertNotEqual(expanded, list); + XCTAssertEqual(expanded.atoms.count, 3ul); + // Non-macro atoms are carried over by reference; -finalized is what copies. + for (NSUInteger i = 0; i < list.atoms.count; i++) { + XCTAssertEqual(expanded.atoms[i], list.atoms[i]); + } +} + +#pragma mark - Macro expansion (phase 1) + +// \mod's prefix: [Space12, m, o, d (Roman Variables), Space6]. Its suffix is empty. +static MTMathList* ModPrefix(void) +{ + MTMathList* t = [MTMathList new]; + [t addAtom:[[MTMathSpace alloc] initWithSpace:12]]; + for (NSString* ch in @[ @"m", @"o", @"d" ]) { + MTMathAtom* atom = [MTMathAtom atomWithType:kMTMathAtomVariable value:ch]; + atom.fontStyle = kMTFontStyleRoman; + [t addAtom:atom]; + } + [t addAtom:[[MTMathSpace alloc] initWithSpace:6]]; + return t; +} + +static MTMacroAtom* ModMacroWithArgument(NSString* latex) +{ + return [[MTMacroAtom alloc] initWithCommand:@"mod" + arguments:@[ [MTMathListBuilder buildFromString:latex] ] + prefix:ModPrefix() + suffix:[MTMathList new]]; +} + +// Phase 1 produces RAW atoms — no reclassification yet. \pod{n} -> 4 atoms, the +// argument spliced between prefix and suffix. +- (void)testExpansionSplicesArgumentBetweenPrefixAndSuffix +{ + MTMathList* list = [MTMathList new]; + [list addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"x"]]; + [list addAtom:PodMacroWithArgument(@"n")]; + + MTMathList* expanded = [list expandMacros]; + XCTAssertEqual(expanded.atoms.count, 5ul); + XCTAssertEqualObjects([expanded.atoms[0] nucleus], @"x"); + XCTAssertEqual([expanded.atoms[1] type], kMTMathAtomSpace); + XCTAssertEqualWithAccuracy([(MTMathSpace*)expanded.atoms[1] space], 8, 0.001); + XCTAssertEqual([expanded.atoms[2] type], kMTMathAtomOpen); + XCTAssertEqualObjects([expanded.atoms[3] nucleus], @"n"); + XCTAssertEqual([expanded.atoms[4] type], kMTMathAtomClose); + + for (MTMathAtom* atom in expanded.atoms) { + XCTAssertNotEqual(atom.type, kMTMathAtomMacro); + } +} + +// A multi-atom argument is spliced inline, not wrapped. +- (void)testExpansionSplicesMultiAtomArgument +{ + MTMathList* list = [MTMathList new]; + [list addAtom:PodMacroWithArgument(@"n+1")]; + MTMathList* expanded = [list expandMacros]; + // Space8, "(", n, +, 1, ")" + XCTAssertEqual(expanded.atoms.count, 6ul); + XCTAssertEqual([expanded.atoms[3] type], kMTMathAtomBinaryOperator); +} + +// Expansion must not consume the stored halves or arguments: finalizing twice +// gives the same answer. +- (void)testExpansionLeavesMacroAtomPristine +{ + MTMacroAtom* macro = PodMacroWithArgument(@"n"); + MTMathList* list = [MTMathList new]; + [list addAtom:macro]; + + NSString* first = [MTMathListBuilder mathListToString:list.finalized]; + NSString* second = [MTMathListBuilder mathListToString:list.finalized]; + XCTAssertEqualObjects(first, second); + XCTAssertEqual(macro.prefix.atoms.count, 2ul); + XCTAssertEqual(macro.suffix.atoms.count, 1ul); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.arguments[0]], @"n"); +} + +// Re-finalizing an already-finalized list must be a no-op. The typesetter depends +// on it, and it is the invariant that would break if expansion left anything behind +// for a second reclassifying pass to act on. +- (void)testRefinalizingExpandedListIsIdempotent +{ + MTMathList* list = [MTMathList new]; + [list addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"x"]]; + [list addAtom:ModMacroWithArgument(@"n+")]; + + MTMathList* once = list.finalized; + MTMathList* twice = once.finalized; + XCTAssertNotEqual(twice, once); + XCTAssertEqualObjects(ListSignature(twice), ListSignature(once)); + // The trailing Bin was already demoted to Unary by the first pass; the second + // must find nothing left to reclassify. + XCTAssertEqual(once.atoms.lastObject.type, kMTMathAtomUnaryOperator); +} + +// A macro nested inside another macro's argument is expanded by the same pass +//: the inner atom is spliced into this list, then re-scanned. +- (void)testExpansionRecursesIntoNestedMacros +{ + MTMacroAtom* inner = PodMacroWithArgument(@"n"); + MTMathList* outerArg = [MTMathList new]; + [outerArg addAtom:inner]; + MTMacroAtom* outer = [[MTMacroAtom alloc] initWithCommand:@"pod" + arguments:@[ outerArg ] + prefix:PodPrefix() + suffix:PodSuffix()]; + MTMathList* list = [MTMathList new]; + [list addAtom:outer]; + + MTMathList* expanded = [list expandMacros]; + // Space8 ( Space8 ( n ) ) + XCTAssertEqual(expanded.atoms.count, 7ul); + for (MTMathAtom* atom in expanded.atoms) { + XCTAssertNotEqual(atom.type, kMTMathAtomMacro); + } +} + +// Phase 1 deliberately does NOT descend into sub-lists. Containers recurse through +// their own -finalized, which re-enters phase 1 + 2 per child list. +- (void)testExpansionDoesNotDescendButFinalizedStillExpandsNested +{ + MTFraction* frac = [[MTFraction alloc] init]; + frac.numerator = [MTMathList new]; + [frac.numerator addAtom:PodMacroWithArgument(@"n")]; + frac.denominator = [MTMathListBuilder buildFromString:@"2"]; + MTMathList* list = [MTMathList new]; + [list addAtom:frac]; + + // Phase 1 alone leaves the macro sitting in the numerator. + MTFraction* rawFrac = (MTFraction*)[list expandMacros].atoms[0]; + XCTAssertEqual([rawFrac.numerator.atoms[0] type], kMTMathAtomMacro); + + // The public -finalized still reaches it, via MTFraction's -finalized. + MTFraction* finalFrac = (MTFraction*)list.finalized.atoms[0]; + XCTAssertEqual(finalFrac.numerator.atoms.count, 4ul); + for (MTMathAtom* atom in finalFrac.numerator.atoms) { + XCTAssertNotEqual(atom.type, kMTMathAtomMacro); + } +} + +// The invariant, stated per list: no macro reaches the reclassifying pass. +- (void)testFinalizedContainsNoMacroAtoms +{ + MTMathList* list = [MTMathList new]; + [list addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"x"]]; + [list addAtom:ModMacroWithArgument(@"n")]; + for (MTMathAtom* atom in list.finalized.atoms) { + XCTAssertNotEqual(atom.type, kMTMathAtomMacro); + } +} + +// Mutating a parsed argument must change what renders, not just what serializes. +// PodPrefix() leads with an 8mu space, and 8 is not one of the named +// keywords in +[MTMathListBuilder spaceToCommands] (3/4/5/18/36/-3), so +// MTMathSpace correctly serializes it as "\mkern8.0mu" rather than being +// silently dropped. The plan's expected "(n)"/"(m)" omitted that prefix; the +// assertions below reflect the actual, correct serialization. +- (void)testFinalizedTracksArgumentMutation +{ + MTMacroAtom* macro = PodMacroWithArgument(@"n"); + MTMathList* list = [MTMathList new]; + [list addAtom:macro]; + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list.finalized], @"\\mkern8.0mu(n)"); + + MTMathList* arg = macro.arguments[0]; + [arg removeAtomAtIndex:0]; + [arg addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"m"]]; + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list.finalized], @"\\mkern8.0mu(m)"); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pod{m}"); +} + +#pragma mark - Script transfer + +// \pod{n}^2 -> the ")" carries the superscript. +- (void)testScriptTransfersToLastScriptableAtom +{ + MTMacroAtom* macro = PodMacroWithArgument(@"n"); + macro.superScript = [MTMathListBuilder buildFromString:@"2"]; + MTMathList* list = [MTMathList new]; + [list addAtom:macro]; + + MTMathList* expanded = [list expandMacros]; + XCTAssertEqual(expanded.atoms.count, 4ul); + MTMathAtom* close = expanded.atoms[3]; + XCTAssertEqual(close.type, kMTMathAtomClose); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:close.superScript], @"2"); + XCTAssertNil([expanded.atoms[2] superScript]); +} + +- (void)testSubscriptTransfers +{ + MTMacroAtom* macro = PodMacroWithArgument(@"n"); + macro.subScript = [MTMathListBuilder buildFromString:@"k"]; + MTMathList* list = [MTMathList new]; + [list addAtom:macro]; + MTMathAtom* close = [list expandMacros].atoms[3]; + XCTAssertEqualObjects([MTMathListBuilder mathListToString:close.subScript], @"k"); +} + +// \mod{n\;}^2: the trailing space is not scriptable, so the script skips it and +// lands on n. It is never dropped. +- (void)testScriptSkipsTrailingSpace +{ + MTMacroAtom* macro = ModMacroWithArgument(@"n\\;"); + macro.superScript = [MTMathListBuilder buildFromString:@"2"]; + MTMathList* list = [MTMathList new]; + [list addAtom:macro]; + + MTMathList* expanded = [list expandMacros]; + XCTAssertEqual([expanded.atoms.lastObject type], kMTMathAtomSpace); + XCTAssertNil([expanded.atoms.lastObject superScript]); + + MTMathAtom* n = expanded.atoms[expanded.atoms.count - 2]; + XCTAssertEqualObjects(n.nucleus, @"n"); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:n.superScript], @"2"); +} + +// Collision: n already has ^2, so ^3 goes on an appended empty Ordinary — exactly +// what the builder does for x^2^3 (MTMathListBuilder.m:211-216). \mod{n^2}^3 is +// therefore \mod{n^2}{}^3. +- (void)testSuperscriptCollisionAppendsEmptyOrdinary +{ + MTMacroAtom* macro = ModMacroWithArgument(@"n^2"); + macro.superScript = [MTMathListBuilder buildFromString:@"3"]; + MTMathList* list = [MTMathList new]; + [list addAtom:macro]; + + MTMathList* expanded = [list expandMacros]; + MTMathAtom* appended = expanded.atoms.lastObject; + XCTAssertEqual(appended.type, kMTMathAtomOrdinary); + XCTAssertEqualObjects(appended.nucleus, @""); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:appended.superScript], @"3"); + + MTMathAtom* n = expanded.atoms[expanded.atoms.count - 2]; + XCTAssertEqualObjects(n.nucleus, @"n"); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:n.superScript], @"2"); +} + +- (void)testSubscriptCollisionAppendsEmptyOrdinary +{ + MTMacroAtom* macro = ModMacroWithArgument(@"n_1"); + macro.subScript = [MTMathListBuilder buildFromString:@"2"]; + MTMathList* list = [MTMathList new]; + [list addAtom:macro]; + + MTMathAtom* appended = [list expandMacros].atoms.lastObject; + XCTAssertEqual(appended.type, kMTMathAtomOrdinary); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:appended.subScript], @"2"); +} + +// \mod{n^2}_3 is NOT a collision: the subscript slot on n is free. +- (void)testNonCollidingSubscriptAttachesDirectly +{ + MTMacroAtom* macro = ModMacroWithArgument(@"n^2"); + macro.subScript = [MTMathListBuilder buildFromString:@"3"]; + MTMathList* list = [MTMathList new]; + [list addAtom:macro]; + + MTMathList* expanded = [list expandMacros]; + MTMathAtom* n = expanded.atoms.lastObject; + XCTAssertEqualObjects(n.nucleus, @"n", @"no empty Ordinary should have been appended"); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:n.superScript], @"2"); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:n.subScript], @"3"); +} + +// Slots are evaluated as a unit: if either needed slot is taken, BOTH scripts move +// to the appended atom, so a ^/_ pair is never split across two atoms. +- (void)testCollidingPairStaysTogether +{ + MTMacroAtom* macro = ModMacroWithArgument(@"n^2"); + macro.superScript = [MTMathListBuilder buildFromString:@"3"]; + macro.subScript = [MTMathListBuilder buildFromString:@"k"]; + MTMathList* list = [MTMathList new]; + [list addAtom:macro]; + + MTMathList* expanded = [list expandMacros]; + MTMathAtom* appended = expanded.atoms.lastObject; + XCTAssertEqualObjects([MTMathListBuilder mathListToString:appended.superScript], @"3"); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:appended.subScript], @"k"); + + MTMathAtom* n = expanded.atoms[expanded.atoms.count - 2]; + XCTAssertNil(n.subScript, @"the pair must not be split across two atoms"); +} + +// No scriptable atom anywhere in the expansion. Unreachable for the three built-in +// macros (all end in a scriptable atom), but constructible directly, so it is +// covered here with a spaces-only expansion. +- (void)testNoScriptableTargetAppendsEmptyOrdinary +{ + MTMathList* spacesOnly = [MTMathList new]; + [spacesOnly addAtom:[[MTMathSpace alloc] initWithSpace:8]]; + [spacesOnly addAtom:[[MTMathSpace alloc] initWithSpace:6]]; + MTMacroAtom* macro = [[MTMacroAtom alloc] initWithCommand:@"spacesonly" + arguments:@[] + prefix:spacesOnly + suffix:[MTMathList new]]; + macro.superScript = [MTMathListBuilder buildFromString:@"2"]; + MTMathList* list = [MTMathList new]; + [list addAtom:macro]; + + MTMathList* expanded = [list expandMacros]; + XCTAssertEqual(expanded.atoms.count, 3ul); + MTMathAtom* appended = expanded.atoms.lastObject; + XCTAssertEqual(appended.type, kMTMathAtomOrdinary); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:appended.superScript], @"2"); +} + +// Transferring must not mutate the macro atom's own scripts: finalizing twice is +// stable, and serialization still reports \pod{n}^{2}. +- (void)testScriptTransferLeavesMacroAtomPristine +{ + MTMacroAtom* macro = PodMacroWithArgument(@"n"); + macro.superScript = [MTMathListBuilder buildFromString:@"2"]; + MTMathList* list = [MTMathList new]; + [list addAtom:macro]; + + NSString* first = [MTMathListBuilder mathListToString:list.finalized]; + NSString* second = [MTMathListBuilder mathListToString:list.finalized]; + XCTAssertEqualObjects(first, second); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pod{n}^{2}"); +} + +#pragma mark - Equivalence helpers + +// A structural fingerprint: type + nucleus + space value + font style + scripts. +// Serialization is not usable for this — latexSymbolNameForAtom: maps Unary back +// through the Bin cell, so "\bmod" and a demoted "\bmod" stringify identically and +// the Bin/Unary distinction (the whole point of these tests) would be invisible. +static NSString* AtomSignature(MTMathAtom* atom) +{ + NSMutableString* sig = [NSMutableString string]; + if (atom.type == kMTMathAtomSpace) { + [sig appendFormat:@"Space(%g)", [(MTMathSpace*)atom space]]; + } else { + [sig appendFormat:@"%lu:%@", (unsigned long)atom.type, atom.nucleus]; + } + if (atom.fontStyle != kMTFontStyleDefault) { + [sig appendFormat:@"/f%lu", (unsigned long)atom.fontStyle]; + } + if (atom.superScript) { + [sig appendFormat:@"^%@", ListSignature(atom.superScript)]; + } + if (atom.subScript) { + [sig appendFormat:@"_%@", ListSignature(atom.subScript)]; + } + // Container sublists, so a divergence inside a fraction/radical/group shows up + // too. Keyed by name because -innerList is declared on nine unrelated classes + // with no common protocol. + for (NSString* key in @[ @"numerator", @"denominator", @"degree", @"radicand", @"innerList" ]) { + if (![atom respondsToSelector:NSSelectorFromString(key)]) { + continue; + } + MTMathList* sub = [atom valueForKey:key]; + if (sub) { + [sig appendFormat:@"%@%@", key, ListSignature(sub)]; + } + } + return sig; +} + +static NSString* ListSignature(MTMathList* list) +{ + NSMutableArray* parts = [NSMutableArray arrayWithCapacity:list.atoms.count]; + for (MTMathAtom* atom in list.atoms) { + [parts addObject:AtomSignature(atom)]; + } + return [NSString stringWithFormat:@"[%@]", [parts componentsJoinedByString:@", "]]; +} + +#pragma mark - One-pass equivalence (model layer) + +// Wraps `latex` around a hand-built \mod macro and returns the finalized signature. +- (NSString*)signatureForModMacroWithArgument:(NSString*)arg + prefix:(NSString*)prefix + suffix:(NSString*)suffix +{ + MTMathList* list = [MTMathList new]; + [list append:[MTMathListBuilder buildFromString:prefix]]; + [list addAtom:ModMacroWithArgument(arg)]; + [list append:[MTMathListBuilder buildFromString:suffix]]; + return ListSignature(list.finalized); +} + +// The expansion typed out directly, for comparison. \mathrm{mod} is written as +// three Roman Variables to match ModPrefix() exactly. +- (NSString*)signatureForWrittenOutModWithArgument:(NSString*)arg + prefix:(NSString*)prefix + suffix:(NSString*)suffix +{ + NSString* latex = [NSString stringWithFormat:@"%@\\mkern12mu\\mathrm{mod}\\mkern6mu%@%@", + prefix, arg, suffix]; + MTMathList* list = [MTMathListBuilder buildFromString:latex]; + XCTAssertNotNil(list, @"%@", latex); + return ListSignature(list.finalized); +} + +// x\mod{n+}y : the + sits between n and y in the flat stream and stays Bin. A +// design that finalized each expansion on its own would demote it to Unary. +- (void)testTrailingBinaryOperatorInArgumentStaysBinary +{ + XCTAssertEqualObjects([self signatureForModMacroWithArgument:@"n+" prefix:@"x" suffix:@"y"], + [self signatureForWrittenOutModWithArgument:@"n+" prefix:@"x" suffix:@"y"]); +} + +// x\mod{-n}y : the leading - has no left operand inside the flat stream either +// (it follows a Space, whose predecessor is "mod"), so both sides must agree. +- (void)testLeadingUnaryInArgumentAgrees +{ + XCTAssertEqualObjects([self signatureForModMacroWithArgument:@"-n" prefix:@"x" suffix:@"y"], + [self signatureForWrittenOutModWithArgument:@"-n" prefix:@"x" suffix:@"y"]); +} + +// 1\mod{2}3 : number fusion must see the same neighbours on both sides. +- (void)testNumberFusionAcrossExpansionAgrees +{ + XCTAssertEqualObjects([self signatureForModMacroWithArgument:@"2" prefix:@"1" suffix:@"3"], + [self signatureForWrittenOutModWithArgument:@"2" prefix:@"1" suffix:@"3"]); +} + +- (void)testPlainExpansionsAgree +{ + for (NSString* arg in @[ @"n", @"n+1", @"2^k" ]) { + XCTAssertEqualObjects([self signatureForModMacroWithArgument:arg prefix:@"" suffix:@""], + [self signatureForWrittenOutModWithArgument:arg prefix:@"" suffix:@""], + @"arg %@", arg); + } +} + +- (void)testEquivalenceInsideCongruence +{ + XCTAssertEqualObjects([self signatureForModMacroWithArgument:@"n" prefix:@"a\\equiv b" suffix:@""], + [self signatureForWrittenOutModWithArgument:@"n" prefix:@"a\\equiv b" suffix:@""]); +} + +#pragma mark - Required-argument guard + +- (void)testRequiredArgumentReadsBracedArgument +{ + MTMathListBuilder* builder = [[MTMathListBuilder alloc] initWithString:@"{n+1}"]; + MTMathList* arg = [builder requiredArgumentWithError:MTParseErrorMissingArgument]; + XCTAssertNotNil(arg); + XCTAssertNil(builder.error); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:arg], @"n+1"); +} + +- (void)testRequiredArgumentReadsUnbracedToken +{ + MTMathListBuilder* builder = [[MTMathListBuilder alloc] initWithString:@"n"]; + MTMathList* arg = [builder requiredArgumentWithError:MTParseErrorMissingArgument]; + XCTAssertEqualObjects([MTMathListBuilder mathListToString:arg], @"n"); +} + +- (void)testRequiredArgumentAllowsEmptyBraces +{ + // \pmod{} is legal LaTeX and renders "( mod )" (LLD §4.4). + MTMathListBuilder* builder = [[MTMathListBuilder alloc] initWithString:@"{}"]; + MTMathList* arg = [builder requiredArgumentWithError:MTParseErrorMissingArgument]; + XCTAssertNotNil(arg); + XCTAssertNil(builder.error); + XCTAssertEqual(arg.atoms.count, 0ul); +} + +- (void)testRequiredArgumentFailsAtEOF +{ + for (NSString* input in @[ @"", @" " ]) { + MTMathListBuilder* builder = [[MTMathListBuilder alloc] initWithString:input]; + XCTAssertNil([builder requiredArgumentWithError:MTParseErrorMissingArgument], + @"input %@", input); + XCTAssertEqual(builder.error.code, MTParseErrorMissingArgument, @"input %@", input); + } +} + +- (void)testRequiredArgumentFailsOnDelimiterInArgumentPosition +{ + for (NSString* input in @[ @"}", @"^2", @"_2", @"&x" ]) { + MTMathListBuilder* builder = [[MTMathListBuilder alloc] initWithString:input]; + XCTAssertNil([builder requiredArgumentWithError:MTParseErrorMissingArgument], + @"input %@", input); + XCTAssertEqual(builder.error.code, MTParseErrorMissingArgument, @"input %@", input); + } +} + +- (void)testRequiredArgumentPropagatesInnerError +{ + MTMathListBuilder* builder = [[MTMathListBuilder alloc] initWithString:@"{\\notacommand}"]; + XCTAssertNil([builder requiredArgumentWithError:MTParseErrorMissingArgument]); + XCTAssertEqual(builder.error.code, MTParseErrorInvalidCommand); +} + +- (void)testRequiredArgumentFailsOnStopCommandInArgumentPosition +{ + // A stop command ends the enclosing list rather than producing an atom, so it + // cannot begin an argument. Every command -stopCommand: recognizes is covered. + for (NSString* input in @[ @"\\\\ y", @"\\cr y", @"\\right) ", @"\\end{matrix}", + @"\\over y", @"\\atop y", @"\\choose y", + @"\\brack y", @"\\brace y" ]) { + MTMathListBuilder* builder = [[MTMathListBuilder alloc] initWithString:input]; + XCTAssertNil([builder requiredArgumentWithError:MTParseErrorMissingArgument], + @"input %@", input); + XCTAssertEqual(builder.error.code, MTParseErrorMissingArgument, @"input %@", input); + } +} + +- (void)testRequiredArgumentAllowsNonStopCommand +{ + // The stop-command guard must not reject ordinary commands in argument position. + for (NSString* input in @[ @"\\alpha", @"\\frac{a}{b}", @"\\sqrt{2}", @"\\left(x\\right)" ]) { + MTMathListBuilder* builder = [[MTMathListBuilder alloc] initWithString:input]; + XCTAssertNotNil([builder requiredArgumentWithError:MTParseErrorMissingArgument], + @"input %@", input); + XCTAssertNil(builder.error, @"input %@", input); + } +} + +- (void)testStopCommandInMacroArgumentIsAnErrorNotSilentlyWrongOutput +{ + // Before the stop-command guard these all parsed "successfully" into wrong + // output: \left(\pmod\right) => \left( \pmod{}\right), x \pmod \\ y => + // \pmod{\\ y} (a table nested inside the parens), and the matrix case + // silently lost a row. Fail loud instead (see the repo's no-silent-degradation + // rule). + for (NSString* latex in @[ @"\\left(\\pmod\\right)", + @"x \\pmod \\\\ y", + @"\\begin{matrix}a\\pmod\\\\b\\end{matrix}", + @"\\begin{matrix}a\\mod\\\\b\\end{matrix}", + @"\\begin{matrix}a\\pod\\\\b\\end{matrix}" ]) { + NSError* error = nil; + XCTAssertNil([MTMathListBuilder buildFromString:latex error:&error], @"%@", latex); + XCTAssertEqual(error.code, MTParseErrorMissingArgument, @"%@", latex); + } +} + +- (void)testStopCommandAfterAMacroArgumentStillWorks +{ + // Only the argument POSITION is guarded — a stop command after a complete + // argument keeps its normal meaning, so the matrix still has two rows. + NSError* error = nil; + MTMathList* list = [MTMathListBuilder buildFromString:@"\\begin{matrix}a\\pmod{n}\\\\b\\end{matrix}" + error:&error]; + XCTAssertNil(error); + XCTAssertEqual(list.atoms.count, 1ul); + MTMathTable* table = (MTMathTable*) list.atoms[0]; + XCTAssertEqual(table.type, kMTMathAtomTable); + XCTAssertEqual(table.numRows, 2); +} + +#pragma mark - Expansion halves as parsed + +// The registry stores each half as ordinary LaTeX; these pin what it parses to. +- (void)testParsedPodHalves +{ + MTMacroAtom* m = (MTMacroAtom*)[MTMathListBuilder buildFromString:@"\\pod{n}"].atoms[0]; + XCTAssertEqual(m.prefix.atoms.count, 2ul); + XCTAssertEqual([m.prefix.atoms[0] type], kMTMathAtomSpace); + XCTAssertEqualWithAccuracy([(MTMathSpace*)m.prefix.atoms[0] space], 8, 0.001); + XCTAssertEqual([m.prefix.atoms[1] type], kMTMathAtomOpen); + XCTAssertEqual(m.suffix.atoms.count, 1ul); + XCTAssertEqual([m.suffix.atoms[0] type], kMTMathAtomClose); +} + +- (void)testParsedPmodHalves +{ + MTMacroAtom* m = (MTMacroAtom*)[MTMathListBuilder buildFromString:@"\\pmod{n}"].atoms[0]; + // Space8, "(", m, o, d, Space6 + XCTAssertEqual(m.prefix.atoms.count, 6ul); + XCTAssertEqualWithAccuracy([(MTMathSpace*)m.prefix.atoms[0] space], 8, 0.001); + XCTAssertEqual([m.prefix.atoms[1] type], kMTMathAtomOpen); + for (NSUInteger i = 2; i <= 4; i++) { + // \mathrm yields Roman-styled atoms; changeFont maps them to upright Latin + // at layout time (MTTypesetter.m:539-545). + XCTAssertEqual([m.prefix.atoms[i] fontStyle], kMTFontStyleRoman, @"atom %lu", (unsigned long)i); + } + XCTAssertEqualObjects([m.prefix.atoms[2] nucleus], @"m"); + XCTAssertEqualObjects([m.prefix.atoms[3] nucleus], @"o"); + XCTAssertEqualObjects([m.prefix.atoms[4] nucleus], @"d"); + XCTAssertEqualWithAccuracy([(MTMathSpace*)m.prefix.atoms[5] space], 6, 0.001); + XCTAssertEqual(m.suffix.atoms.count, 1ul); + XCTAssertEqual([m.suffix.atoms[0] type], kMTMathAtomClose); +} + +- (void)testParsedModHalves +{ + MTMacroAtom* m = (MTMacroAtom*)[MTMathListBuilder buildFromString:@"\\mod{n}"].atoms[0]; + XCTAssertEqual(m.prefix.atoms.count, 5ul); + XCTAssertEqualWithAccuracy([(MTMathSpace*)m.prefix.atoms[0] space], 12, 0.001); + XCTAssertEqualWithAccuracy([(MTMathSpace*)m.prefix.atoms[4] space], 6, 0.001); + // \mod has no closing text at all. + XCTAssertEqual(m.suffix.atoms.count, 0ul); +} + +// There is no template syntax, so '#' in user input keeps raising the same error it +// always has. +- (void)testEveryRegisteredMacroParses +{ + // Every registered macro's prefix and suffix parse and build an MTMacroAtom — + // the check that keeps the registry honest, since a malformed half would + // otherwise only surface as MTParseErrorInternalError at invocation. + for (NSString* name in [MTMathListBuilder supportedMacroNames]) { + NSError* error = nil; + NSString* latex = [NSString stringWithFormat:@"\\%@{n}", name]; + XCTAssertNotNil([MTMathListBuilder buildFromString:latex error:&error], @"%@", latex); + XCTAssertNil(error, @"%@", latex); + } +} + +#pragma mark - Macro registry + +- (void)testSupportedMacroNames +{ + NSArray* names = [MTMathListBuilder supportedMacroNames]; + XCTAssertEqual(names.count, 3ul); + for (NSString* name in @[ @"pmod", @"mod", @"pod" ]) { + XCTAssertTrue([names containsObject:name], @"missing %@", name); + } + // \bmod is a symbol, not a macro. The two discovery surfaces stay disjoint. + XCTAssertFalse([names containsObject:@"bmod"]); + XCTAssertTrue([[MTMathAtomFactory supportedLatexSymbolNames] containsObject:@"bmod"]); + for (NSString* name in names) { + XCTAssertFalse([[MTMathAtomFactory supportedLatexSymbolNames] containsObject:name], + @"%@ must not be a symbol too", name); + } +} + +#pragma mark - Parsing the three macros + +- (void)testPmodParsesToASingleMacroAtom +{ + MTMathList* list = [MTMathListBuilder buildFromString:@"a \\equiv b \\pmod{n}"]; + XCTAssertNotNil(list); + // a, ≡, b, macro — the macro is ONE atom in the raw list. + XCTAssertEqual(list.atoms.count, 4ul); + MTMathAtom* last = list.atoms[3]; + XCTAssertEqual(last.type, kMTMathAtomMacro); + MTMacroAtom* macro = (MTMacroAtom*)last; + XCTAssertEqualObjects(macro.command, @"pmod"); + XCTAssertEqual(macro.arguments.count, 1ul); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.arguments[0]], @"n"); + XCTAssertEqual(macro.prefix.atoms.count, 6ul); + XCTAssertEqual(macro.suffix.atoms.count, 1ul); +} + +- (void)testAllThreeMacrosParse +{ + // prefix length, suffix length + NSDictionary*>* expectedHalves = @{ + @"pmod": @[ @6, @1 ], // Space8 ( m o d Space6 / ) + @"mod": @[ @5, @0 ], // Space12 m o d Space6 / — + @"pod": @[ @2, @1 ], // Space8 ( / ) + }; + for (NSString* command in expectedHalves) { + NSString* latex = [NSString stringWithFormat:@"x \\%@{n}", command]; + MTMathList* list = [MTMathListBuilder buildFromString:latex]; + XCTAssertNotNil(list, @"%@", latex); + XCTAssertEqual(list.atoms.count, 2ul, @"%@", latex); + MTMacroAtom* macro = (MTMacroAtom*)list.atoms[1]; + XCTAssertEqual(macro.type, kMTMathAtomMacro, @"%@", latex); + XCTAssertEqualObjects(macro.command, command); + XCTAssertEqual(macro.prefix.atoms.count, + expectedHalves[command][0].unsignedIntegerValue, @"%@", latex); + XCTAssertEqual(macro.suffix.atoms.count, + expectedHalves[command][1].unsignedIntegerValue, @"%@", latex); + } +} + +// The shared dispatch tail attaches ^/_ to whatever single atom the chain yields — +// no new script logic needed for macros (LLD §2.6). +- (void)testMacroCarriesScriptsFromSharedTail +{ + MTMacroAtom* macro = (MTMacroAtom*)[MTMathListBuilder buildFromString:@"\\pmod{n}^2"].atoms[0]; + XCTAssertEqual(macro.type, kMTMathAtomMacro); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.superScript], @"2"); +} + +- (void)testUnbracedArgument +{ + MTMacroAtom* macro = (MTMacroAtom*)[MTMathListBuilder buildFromString:@"\\pmod n"].atoms[0]; + XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.arguments[0]], @"n"); +} + +- (void)testEmptyArgumentIsAllowed +{ + MTMathList* list = [MTMathListBuilder buildFromString:@"\\pmod{}"]; + XCTAssertNotNil(list); + MTMacroAtom* macro = (MTMacroAtom*)list.atoms[0]; + XCTAssertEqual([macro.arguments[0] atoms].count, 0ul); +} + +- (void)testMissingArgumentIsAnError +{ + for (NSString* latex in @[ @"\\pmod", @"\\mod", @"\\pod", @"a \\pmod ", + @"{\\pmod}", @"\\pmod^2" ]) { + NSError* error = nil; + XCTAssertNil([MTMathListBuilder buildFromString:latex error:&error], @"%@", latex); + XCTAssertEqual(error.code, MTParseErrorMissingArgument, @"%@", latex); + } +} + +- (void)testMalformedArgumentPropagates +{ + NSError* error = nil; + XCTAssertNil([MTMathListBuilder buildFromString:@"\\pmod{\\notacommand}" error:&error]); + XCTAssertEqual(error.code, MTParseErrorInvalidCommand); +} + +// Non-macro commands must be untouched: macroAtomForCommand: returns nil without +// setting an error, and dispatch falls through to atomForCommand:. +- (void)testNonMacroCommandsUnaffected +{ + XCTAssertNotNil([MTMathListBuilder buildFromString:@"\\frac{1}{2}"]); + XCTAssertNotNil([MTMathListBuilder buildFromString:@"\\sqrt{2}"]); + XCTAssertNotNil([MTMathListBuilder buildFromString:@"17 \\bmod 5"]); + NSError* error = nil; + XCTAssertNil([MTMathListBuilder buildFromString:@"\\notacommand" error:&error]); + XCTAssertEqual(error.code, MTParseErrorInvalidCommand); +} + +#pragma mark - End-to-end equivalence + +// The registry's templates, written out by hand. If these drift from +// +builtinMacros the equivalence tests fail — which is the point. +static NSString* WrittenOutExpansion(NSString* command, NSString* arg) +{ + if ([command isEqualToString:@"pmod"]) { + return [NSString stringWithFormat:@"\\mkern8mu(\\mathrm{mod}\\mkern6mu%@)", arg]; + } else if ([command isEqualToString:@"mod"]) { + return [NSString stringWithFormat:@"\\mkern12mu\\mathrm{mod}\\mkern6mu%@", arg]; + } else if ([command isEqualToString:@"pod"]) { + return [NSString stringWithFormat:@"\\mkern8mu(%@)", arg]; + } + return nil; +} + +- (void)assertEquivalentCommand:(NSString*)command + argument:(NSString*)arg + prefix:(NSString*)prefix + suffix:(NSString*)suffix +{ + NSString* macroLatex = [NSString stringWithFormat:@"%@\\%@{%@}%@", prefix, command, arg, suffix]; + NSString* writtenLatex = [NSString stringWithFormat:@"%@%@%@", + prefix, WrittenOutExpansion(command, arg), suffix]; + MTMathList* macroList = [MTMathListBuilder buildFromString:macroLatex]; + MTMathList* writtenList = [MTMathListBuilder buildFromString:writtenLatex]; + XCTAssertNotNil(macroList, @"%@", macroLatex); + XCTAssertNotNil(writtenList, @"%@", writtenLatex); + XCTAssertEqualObjects(ListSignature(macroList.finalized), + ListSignature(writtenList.finalized), + @"%@ != %@", macroLatex, writtenLatex); + // And the invariant: no macro survives. + for (MTMathAtom* atom in macroList.finalized.atoms) { + XCTAssertNotEqual(atom.type, kMTMathAtomMacro, @"%@", macroLatex); + } +} + +// The boundary-sensitive cases: a trailing Bin in the argument must stay Bin +// because it sees the following y in the flat stream; a leading - must agree; and +// numbers must fuse identically on both sides (LLD §7.1). +- (void)testEquivalenceBoundarySensitive +{ + for (NSString* command in @[ @"pmod", @"mod", @"pod" ]) { + [self assertEquivalentCommand:command argument:@"n+" prefix:@"x" suffix:@"y"]; + [self assertEquivalentCommand:command argument:@"-n" prefix:@"x" suffix:@"y"]; + [self assertEquivalentCommand:command argument:@"2" prefix:@"1" suffix:@"3"]; + } +} + +- (void)testEquivalencePlain +{ + for (NSString* command in @[ @"pmod", @"mod", @"pod" ]) { + for (NSString* arg in @[ @"n", @"n+1", @"2^k", @"\\frac{a}{b}", @"" ]) { + [self assertEquivalentCommand:command argument:arg prefix:@"" suffix:@""]; + } + } +} + +- (void)testEquivalenceInCongruence +{ + [self assertEquivalentCommand:@"pmod" argument:@"n" prefix:@"a\\equiv b" suffix:@""]; +} + +// Nested: phase 1 flattens both before phase 2, so no expansion is ever finalized +// on its own (LLD §7.4). +- (void)testNestedMacrosEquivalence +{ + MTMathList* nested = [MTMathListBuilder buildFromString:@"\\pmod{\\pmod{n}}"]; + XCTAssertNotNil(nested); + NSString* inner = WrittenOutExpansion(@"pmod", @"n"); + MTMathList* written = [MTMathListBuilder buildFromString:WrittenOutExpansion(@"pmod", inner)]; + XCTAssertNotNil(written); + XCTAssertEqualObjects(ListSignature(nested.finalized), ListSignature(written.finalized)); +} + +#pragma mark - Scripts end to end + +- (void)testScriptOnPmodLandsOnClosingParen +{ + MTMathList* finalized = [MTMathListBuilder buildFromString:@"\\pmod{n}^2"].finalized; + MTMathAtom* last = finalized.atoms.lastObject; + XCTAssertEqual(last.type, kMTMathAtomClose); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:last.superScript], @"2"); +} + +- (void)testSubscriptOnPodLandsOnClosingParen +{ + MTMathList* finalized = [MTMathListBuilder buildFromString:@"\\pod{n}_k"].finalized; + MTMathAtom* last = finalized.atoms.lastObject; + XCTAssertEqual(last.type, kMTMathAtomClose); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:last.subScript], @"k"); +} + +- (void)testScriptOnModSkipsTrailingSpace +{ + MTMathList* finalized = [MTMathListBuilder buildFromString:@"\\mod{n\\;}^2"].finalized; + XCTAssertEqual([finalized.atoms.lastObject type], kMTMathAtomSpace); + XCTAssertNil([finalized.atoms.lastObject superScript]); + MTMathAtom* n = finalized.atoms[finalized.atoms.count - 2]; + XCTAssertEqualObjects(n.nucleus, @"n"); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:n.superScript], @"2"); +} + +// \mod{n^2}^3 == \mod{n^2}{}^3 -- the same shape iosMath already produces for +// x^2^3 vs x^2{}^3 (getTestDataSuperScript() in MTMathListBuilderTest.m: both +// "x^2^3" and "{}^2" alone serialize to the same "{}^{...}" text). The two +// sides are NOT byte-identical at the ListSignature level: the collision path +// (MTMathListBuilder.m ^/_ handling) appends a bare kMTMathAtomOrdinary, while +// a literal "{}" in the source always builds an MTMathGroup (kMTMathAtomOrdGroup) +// -- same precedent the codebase already establishes for plain "^2" vs "{}^2". +// mathListToString is what unifies them (both render "{}"), so that -- not +// ListSignature -- is the right equivalence check here. +- (void)testScriptCollisionMatchesExistingEmptyOrdBehavior +{ + MTMathList* collided = [MTMathListBuilder buildFromString:@"\\mod{n^2}^3"]; + MTMathList* explicit = [MTMathListBuilder buildFromString:@"\\mod{n^2}{}^3"]; + XCTAssertEqualObjects([MTMathListBuilder mathListToString:collided.finalized], + [MTMathListBuilder mathListToString:explicit.finalized]); + + // And the precedent it mirrors. + MTMathList* precedent = [MTMathListBuilder buildFromString:@"x^2^3"]; + MTMathAtom* precedentLast = precedent.finalized.atoms.lastObject; + XCTAssertEqual(precedentLast.type, kMTMathAtomOrdinary); + XCTAssertEqualObjects(precedentLast.nucleus, @""); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:precedentLast.superScript], @"3"); +} + +// See testScriptCollisionMatchesExistingEmptyOrdBehavior above: same bare-Ordinary- +// vs-OrdGroup distinction, so mathListToString is the equivalence check, not +// ListSignature. +- (void)testSubscriptCollisionMatchesExistingBehavior +{ + XCTAssertEqualObjects( + [MTMathListBuilder mathListToString:[MTMathListBuilder buildFromString:@"\\mod{n_1}_2"].finalized], + [MTMathListBuilder mathListToString:[MTMathListBuilder buildFromString:@"\\mod{n_1}{}_2"].finalized]); +} + +// \mod{n^2}_3 is NOT a collision: the subscript slot on n is free. +- (void)testNonCollidingSubscriptEndToEnd +{ + MTMathList* finalized = [MTMathListBuilder buildFromString:@"\\mod{n^2}_3"].finalized; + MTMathAtom* last = finalized.atoms.lastObject; + XCTAssertEqualObjects(last.nucleus, @"n", @"no empty Ordinary should have been appended"); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:last.superScript], @"2"); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:last.subScript], @"3"); +} + +// See the plan's "Known LLD discrepancy" note: LLD §6 lists this as the +// no-scriptable-target case, but the \mod template's own "mod" letters ARE +// scriptable, so ^2 lands on the d. What matters — and what §6 was protecting — +// is that the script is never dropped. The genuine no-target branch is covered by +// -testNoScriptableTargetAppendsEmptyOrdinary (item 8). +- (void)testScriptOnAllSpaceArgumentIsNotDropped +{ + MTMathList* finalized = [MTMathListBuilder buildFromString:@"\\mod{\\;}^2"].finalized; + MTMathAtom* carrier = nil; + for (MTMathAtom* atom in finalized.atoms) { + if (atom.superScript) { carrier = atom; break; } + } + XCTAssertNotNil(carrier, @"the superscript was dropped"); + XCTAssertEqualObjects(carrier.nucleus, @"d", @"expected the last letter of \"mod\""); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:carrier.superScript], @"2"); +} + +// Degenerate but benign: one atom means oneCharOnly makes the whole macro the +// superscript. TeX errors here; iosMath renders it (documented divergence, LLD §6). +- (void)testMacroAsSuperscriptRendersBenignly +{ + MTMathList* list = [MTMathListBuilder buildFromString:@"x^\\pmod{n}"]; + XCTAssertNotNil(list); + XCTAssertEqual([list.atoms[0] superScript].atoms.count, 1ul); + XCTAssertEqual([[list.atoms[0] superScript].atoms[0] type], kMTMathAtomMacro); + XCTAssertNoThrow([list finalized]); +} + +// The argument is parsed under the enclosing font style; the parens and spaces come +// from the prefix, which is parsed by a fresh builder at default style (LLD §6). +- (void)testMacroInsideFontStyleGroup +{ + MTMathList* list = [MTMathListBuilder buildFromString:@"\\mathbf{x \\pmod{n}}"]; + XCTAssertNotNil(list); + MTMacroAtom* macro = nil; + for (MTMathAtom* atom in list.atoms) { + if (atom.type == kMTMathAtomMacro) { macro = (MTMacroAtom*)atom; break; } + } + XCTAssertNotNil(macro); + XCTAssertEqual([macro.arguments[0] atoms][0].fontStyle, kMTFontStyleBold); + // "mod" stays Roman regardless — it comes from \mathrm in the prefix. + XCTAssertEqual(macro.prefix.atoms[2].fontStyle, kMTFontStyleRoman); +} + +#pragma mark - Serialization + +- (void)testSerializationRoundTrips +{ + NSDictionary* cases = @{ + @"\\pmod{n}": @"\\pmod{n}", + @"\\mod{n}": @"\\mod{n}", + @"\\pod{n}": @"\\pod{n}", + @"\\mod{n+1}": @"\\mod{n+1}", + @"\\pmod n": @"\\pmod{n}", // unbraced serializes canonically + @"\\mod{ n }": @"\\mod{n}", // whitespace is canonicalized + @"\\pmod{n}^2": @"\\pmod{n}^{2}", + @"\\pod{n}_k": @"\\pod{n}_{k}", + @"\\pmod{}": @"\\pmod{}", + @"a\\equiv b\\pmod{n}": @"a\\equiv b\\pmod{n}", + }; + for (NSString* input in cases) { + MTMathList* list = [MTMathListBuilder buildFromString:input]; + XCTAssertNotNil(list, @"%@", input); + NSString* out = [MTMathListBuilder mathListToString:list]; + XCTAssertEqualObjects(out, cases[input], @"%@", input); + + // And it re-parses to the same thing — the round trip is stable. + MTMathList* reparsed = [MTMathListBuilder buildFromString:out]; + XCTAssertNotNil(reparsed, @"reparse of %@", out); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:reparsed], out, @"%@", input); + } +} + +// The raw list serializes the COMMAND; only the finalized list shows the expansion. +// This split is the seam the whole design rests on (LLD §2.7). +// +// Deviation from the plan's literal expected string: the plan expected "(n)", but +// the `\pod` template's leading 8mu kern has no named LaTeX command, so it +// serializes via the `\mkern%.1fmu` fallback (per the plan's documented known +// deviation) rather than disappearing. "\mkern8.0mu(n)" is the correct output. +- (void)testRawSerializesCommandFinalizedSerializesExpansion +{ + MTMathList* list = [MTMathListBuilder buildFromString:@"\\pod{n}"]; + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pod{n}"); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list.finalized], @"\\mkern8.0mu(n)"); +} + +// Deviation from the plan (see the NOTE next to the parse-error table in +// MTMathListBuilderTest.m): `\pmod{\frac}` is NOT a parse error. The macro +// argument reader (`requiredArgumentWithError:`) uses the same one-token +// `buildInternal:YES` reader that `\frac` itself uses to read its numerator and +// denominator, so `\frac`'s own reads immediately see the closing `}` and each +// come back as an empty argument — exactly like bare top-level `\frac` at EOF. +- (void)testFracWithNoArgumentsIsNotAnErrorInsideMacroArgument +{ + MTMathList* list = [MTMathListBuilder buildFromString:@"\\pmod{\\frac}"]; + XCTAssertNotNil(list); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pmod{\\frac{}{}}"); + XCTAssertNoThrow([list finalized]); +} + +#pragma mark - Rendering + +- (void)testPmodRendersUprightMod +{ + NSString* text = [self renderedTextForDisplay:[self displayForLaTeX:@"a \\equiv b \\pmod{n}"]]; + XCTAssertTrue([text containsString:@"mod"], @"got %@", text); + // \mathrm{mod} must not be remapped to the italic mathematical alphanumerics. + XCTAssertFalse([text containsString:@"\U0001D45A"], @"italic m in %@", text); // 𝑚 + XCTAssertFalse([text containsString:@"\U0001D45C"], @"italic o in %@", text); // 𝑜 + XCTAssertFalse([text containsString:@"\U0001D451"], @"italic d in %@", text); // 𝑑 +} + +// Geometry, not substring presence: the macro must lay out exactly like its +// expansion typed out by hand. +- (void)testMacroLayoutMatchesWrittenOutExpansion +{ + NSArray*>* cases = @[ + @[ @"pmod", @"n", @"a\\equiv b", @"" ], + @[ @"mod", @"n", @"x", @"" ], + @[ @"pod", @"n", @"x", @"" ], + @[ @"mod", @"n+1", @"x", @"y" ], + ]; + for (NSArray* c in cases) { + NSString* macroLatex = [NSString stringWithFormat:@"%@\\%@{%@}%@", c[2], c[0], c[1], c[3]]; + NSString* writtenLatex = [NSString stringWithFormat:@"%@%@%@", + c[2], WrittenOutExpansion(c[0], c[1]), c[3]]; + MTMathListDisplay* macroDisplay = [self displayForLaTeX:macroLatex]; + MTMathListDisplay* writtenDisplay = [self displayForLaTeX:writtenLatex]; + + XCTAssertEqualWithAccuracy(macroDisplay.width, writtenDisplay.width, 0.001, + @"width: %@", macroLatex); + XCTAssertEqualWithAccuracy(macroDisplay.ascent, writtenDisplay.ascent, 0.001, + @"ascent: %@", macroLatex); + XCTAssertEqualWithAccuracy(macroDisplay.descent, writtenDisplay.descent, 0.001, + @"descent: %@", macroLatex); + XCTAssertEqualObjects([self renderedTextForDisplay:macroDisplay], + [self renderedTextForDisplay:writtenDisplay], + @"text: %@", macroLatex); + + XCTAssertEqual(macroDisplay.subDisplays.count, writtenDisplay.subDisplays.count, + @"subdisplay count: %@", macroLatex); + for (NSUInteger i = 0; i < MIN(macroDisplay.subDisplays.count, + writtenDisplay.subDisplays.count); i++) { + MTDisplay* a = macroDisplay.subDisplays[i]; + MTDisplay* b = writtenDisplay.subDisplays[i]; + XCTAssertEqualWithAccuracy(a.position.x, b.position.x, 0.001, + @"subdisplay %lu x: %@", (unsigned long)i, macroLatex); + XCTAssertEqualWithAccuracy(a.position.y, b.position.y, 0.001, + @"subdisplay %lu y: %@", (unsigned long)i, macroLatex); + XCTAssertEqualWithAccuracy(a.width, b.width, 0.001, + @"subdisplay %lu width: %@", (unsigned long)i, macroLatex); + } + } +} + +// The leading gap really is 8mu / 12mu wider than the same expression with no gap. +- (void)testLeadingGapWidths +{ + CGFloat muUnit = self.font.mathTable.muUnit; + MTMathListDisplay* bare = [self displayForLaTeX:@"x(\\mathrm{mod}\\mkern6mu n)"]; + MTMathListDisplay* pmod = [self displayForLaTeX:@"x\\pmod{n}"]; + XCTAssertEqualWithAccuracy(pmod.width - bare.width, 8 * muUnit, 0.01); + + MTMathListDisplay* bareMod = [self displayForLaTeX:@"x\\mathrm{mod}\\mkern6mu n"]; + MTMathListDisplay* mod = [self displayForLaTeX:@"x\\mod{n}"]; + XCTAssertEqualWithAccuracy(mod.width - bareMod.width, 12 * muUnit, 0.01); +} + +- (void)testLeadingGapWidthsInScriptStyle +{ + // None of \pmod/\mod/\pod uses \nonscript, so in TeX the mu kerns survive into + // script style — but shrink with it, because mu is 1/18 of the CURRENT style's + // quad. iosMath matches: MTTypesetter reads _styleFont.mathTable.muUnit, and + // muUnit is fontSize/18 on the STYLE font. Locks in the shrink, which + // testLeadingGapWidths above (display style only) cannot see. + CGFloat scriptMu = self.font.mathTable.muUnit * self.font.mathTable.scriptScaleDown; + XCTAssertLessThan(scriptMu, self.font.mathTable.muUnit); + + MTMathListDisplay* bare = [self displayForLaTeX:@"x^{(\\mathrm{mod}\\mkern6mu n)}"]; + MTMathListDisplay* pmod = [self displayForLaTeX:@"x^{\\pmod{n}}"]; + XCTAssertEqualWithAccuracy(pmod.width - bare.width, 8 * scriptMu, 0.01); + + MTMathListDisplay* bareMod = [self displayForLaTeX:@"x^{\\mathrm{mod}\\mkern6mu n}"]; + MTMathListDisplay* mod = [self displayForLaTeX:@"x^{\\mod{n}}"]; + XCTAssertEqualWithAccuracy(mod.width - bareMod.width, 12 * scriptMu, 0.01); +} + +- (void)testMacrosBuildWithoutAsserting +{ + for (NSString* latex in @[ @"\\pmod{n}", @"\\mod{n}", @"\\pod{n}", + @"a \\equiv b \\pmod{n}", @"(\\pmod{n}", + @"\\pmod{n}^2", @"\\pmod{\\pmod{n}}", @"\\pmod{}" ]) { + XCTAssertNoThrow([self displayForLaTeX:latex], @"%@", latex); + } +} + @end From 12599526d48681e6d9310c413c5f68820f24509d Mon Sep 17 00:00:00 2001 From: Kostub D Date: Tue, 28 Jul 2026 02:08:40 +0530 Subject: [PATCH 02/11] Point the changelog at the rebuilt PR Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ts3f5UtUywaqimvE4U1rkw --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab85dd5f..63efc8dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## Changelog ### v2.6.0 (2026-07-28) -* Add **modular-arithmetic notation**: `\bmod` as a binary operator, and the `\pmod`, `\mod`, and `\pod` macros with amsmath's exact inline gaps and upright "mod" (#264, #265, #266). `a \equiv b \pmod{n}` now renders as it does in LaTeX. The macros expand through a new internal macro atom, so a macro invocation serializes back to the command the author wrote rather than to its expansion. amsmath's wider display-style gap (18mu instead of 8/12mu) is not reproduced: a macro expands at parse time, before the render style is known. +* Add **modular-arithmetic notation**: `\bmod` as a binary operator, and the `\pmod`, `\mod`, and `\pod` macros with amsmath's exact inline gaps and upright "mod" (#264, #268). `a \equiv b \pmod{n}` now renders as it does in LaTeX. The macros expand through a new internal macro atom, so a macro invocation serializes back to the command the author wrote rather than to its expansion. amsmath's wider display-style gap (18mu instead of 8/12mu) is not reproduced: a macro expands at parse time, before the render style is known. ### v2.5.0 (2026-07-14) * Add the LaTeX **`array` environment**: `\begin{array}{lcr}…\end{array}` with per-column alignment, `|` column rules, and `\hline` row rules (#251, #253, #254). From b33eb3c24608dd7bd7c426d7a61446aa684a8653 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Tue, 28 Jul 2026 02:49:33 +0530 Subject: [PATCH 03/11] Simplify: fix macro arity at one argument, trim comments MTMacroAtom held an NSArray of arguments, but every macro takes exactly one and nothing needed more. The array was the only reason MTMacroDefinition existed -- its sole non-string field was argumentCount -- so fixing arity at one collapses the registry to a plain command -> @[prefix, suffix] dictionary and removes the class, the deep-copy-array helper, the zero-argument serialization branch, and four loops. Also drops +supportedMacroNames, which had no caller outside its own tests. Comment density on the new code was 25-36% against a 3-14% baseline in these files. Cut roughly in half by removing the archaeology: notes arguing against the template engine that was never in this PR, LLD/PRD section citations, and line-number references that go stale. No behavior change. 524 tests, 0 failures. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ts3f5UtUywaqimvE4U1rkw --- iosMath/lib/MTMathList.h | 48 +++---- iosMath/lib/MTMathList.m | 124 ++++++------------- iosMath/lib/MTMathListBuilder.h | 10 -- iosMath/lib/MTMathListBuilder.m | 165 ++++++------------------- iosMathTests/MTModularArithmeticTest.m | 81 ++++-------- 5 files changed, 115 insertions(+), 313 deletions(-) diff --git a/iosMath/lib/MTMathList.h b/iosMath/lib/MTMathList.h index 10a21c49..f42f931a 100644 --- a/iosMath/lib/MTMathList.h +++ b/iosMath/lib/MTMathList.h @@ -72,9 +72,8 @@ typedef NS_ENUM(NSUInteger, MTMathAtomType) /// sub-mlist (== TeX Ord noad with sub_mlist / KaTeX "ordgroup"). /// Script-capable (< kMTMathAtomBoundary); spaced as Ordinary. kMTMathAtomOrdGroup = 21, - /// An unexpanded macro invocation (\pmod, \mod, \pod). Holds the command name, - /// its parsed arguments, and an argument-free golden template. Expanded away by - /// -[MTMathList finalized], so it never reaches the typesetter. + /// An unexpanded macro invocation (\pmod, \mod, \pod), expanded away by + /// -[MTMathList finalized] so it never reaches the typesetter. /// Script-capable (< kMTMathAtomBoundary): ^/_ attaches at parse time and is /// transferred onto the expansion. kMTMathAtomMacro = 22, @@ -694,49 +693,36 @@ typedef NS_ENUM(NSUInteger, MTStrikeStyle) { @end -/** An unexpanded macro invocation. - - `\pmod{n}` parses to exactly one `MTMacroAtom`, which keeps the raw list small - (trivial serialization, and `^`/`_` attach through the builder's shared tail). - It expands to `prefix` + `arguments` + `suffix`, both halves being raw - (non-finalized) expressions parsed once, at parse time. They are not a second - source of truth for `arguments`: the expansion is re-derived from (`prefix`, - current `arguments`, `suffix`) every time `-[MTMathList finalized]` runs. - - @note Expansion happens in list context, so `-[MTMacroAtom finalized]` on a lone - atom returns another macro atom rather than the expansion. Only - `-[MTMathList finalized]` expands. - - @note This shape expresses one substitution region — everything fixed before the - arguments, everything fixed after — which is what the three modular-arithmetic - macros need. A composite expansion that interleaves fixed text between arguments - (`\frac{#1}{#2}`) cannot be expressed. A user-facing `\newcommand` would need - substitution that descends into sub-lists, which is a different mechanism, not a - wider table here. +/** An unexpanded one-argument macro invocation. + + `\pmod{n}` parses to exactly one `MTMacroAtom` and expands to + `prefix` + `argument` + `suffix`. All three are raw (non-finalized) lists parsed + at parse time; the expansion is re-derived from them every time + `-[MTMathList finalized]` runs. + + @note Only `-[MTMathList finalized]` expands. `-[MTMacroAtom finalized]` on a lone + atom returns another macro atom. */ @interface MTMacroAtom : MTMathAtom /** The command name without the leading backslash, e.g. `@"pmod"`. */ @property (nonatomic, copy, readonly) NSString* command; -/** The parsed arguments, in order. The array identity is immutable; the contained - `MTMathList`s stay mutable and are owned by this atom (deep-copied at init). */ -@property (nonatomic, copy, readonly) NSArray* arguments; +/** The parsed argument. Mutable, and owned by this atom (deep-copied at init). */ +@property (nonatomic, strong, readonly) MTMathList* argument; -/** The fixed expansion text before the arguments. Raw (non-finalized); may be empty. */ +/** The fixed expansion text before the argument. May be empty. */ @property (nonatomic, strong, readonly) MTMathList* prefix; -/** The fixed expansion text after the arguments. Raw (non-finalized); may be empty. */ +/** The fixed expansion text after the argument. May be empty. */ @property (nonatomic, strong, readonly) MTMathList* suffix; - (instancetype)initWithCommand:(NSString*)command - arguments:(NSArray*)arguments + argument:(MTMathList*)argument prefix:(MTMathList*)prefix suffix:(MTMathList*)suffix NS_DESIGNATED_INITIALIZER; -/// A macro atom has no valid zero-argument construction — command, arguments and -/// expansion text are all required — so the generic initializer is unavailable. The -/// implementation additionally throws, to catch dynamic (`id`-typed) callers. +/// The implementation additionally throws, to catch dynamic (`id`-typed) callers. - (instancetype)initWithType:(MTMathAtomType)type value:(NSString*)value NS_UNAVAILABLE; @end diff --git a/iosMath/lib/MTMathList.m b/iosMath/lib/MTMathList.m index f61bdcfd..81b525c9 100644 --- a/iosMath/lib/MTMathList.m +++ b/iosMath/lib/MTMathList.m @@ -87,38 +87,19 @@ static BOOL isNotBinaryOperator(MTMathAtom* prevNode) } } -// NSArray's -copy is shallow: it copies the array but shares the (mutable) -// MTMathList elements. MTMathList's own -copyWithZone: is already deep. -static NSArray* MTDeepCopyMathListArray(NSArray* lists) -{ - NSMutableArray* copies = [NSMutableArray arrayWithCapacity:lists.count]; - for (MTMathList* list in lists) { - [copies addObject:[list copy]]; - } - return [copies copy]; -} - @interface MTMathList () -/** Returns a copy of this list with every top-level MTMacroAtom replaced by its - RAW (non-finalized) expansion. Non-macro atoms are carried over by reference. */ +/// A copy of this list with every top-level MTMacroAtom replaced by its raw +/// (non-finalized) expansion. Non-macro atoms are carried over by reference. - (MTMathList *)expandMacros; @end @interface MTMacroAtom () -/** The RAW (non-finalized) atom stream this invocation stands for. - - Recursion here is bounded by the nesting the parser accepted: a macro can only - land inside another macro's argument because the input nested them, and - -buildInternal: caps that at kMTMaxRecursionDepth. No separate budget needed. */ +/// The raw (non-finalized) atom stream this invocation stands for. - (MTMathList *)expansion; -/** Moves this atom's scripts onto the last scriptable atom of `expansion`, - appending an empty Ordinary when there is no free target. */ -- (void)transferScriptsToExpansion:(MTMathList *)expansion; - @end @interface MTMathListBuilder (MTMathListSerializationSupport) @@ -209,10 +190,10 @@ + (instancetype)atomWithType:(MTMathAtomType)type value:(NSString *)value return [[MTMathColorbox alloc] init]; case kMTMathAtomMacro: - // Falling through to the default would mint a plain MTMathAtom carrying - // type 22 — an atom that claims to be a macro but cannot expand. + // The default would mint a plain MTMathAtom carrying type 22 — an atom + // that claims to be a macro but cannot expand. @throw [NSException exceptionWithName:@"InvalidMethod" - reason:@"A macro atom cannot be created by type. Use -[MTMacroAtom initWithCommand:arguments:prefix:suffix:] instead." + reason:@"A macro atom cannot be created by type. Use -[MTMacroAtom initWithCommand:argument:prefix:suffix:] instead." userInfo:nil]; default: @@ -1721,10 +1702,8 @@ - (NSString *)description } /** Reclassifies the list: demotes Bin to Unary at the boundaries where TeX does, - fuses adjacent numbers, and assigns index ranges. Macros are expanded first — - finalization is irreversible and context-dependent (a Bin demoted to Unary at one - boundary cannot be restored), so the reclassifying pass must see the flat raw - stream a macro stands for, not the macro atom. */ + fuses adjacent numbers, and assigns index ranges. Macros are expanded first, so the + reclassifying pass sees the flat stream a macro stands for rather than the atom. */ - (MTMathList *)finalized { MTMathList* expanded = [self expandMacros]; @@ -1734,10 +1713,9 @@ - (MTMathList *)finalized MTMathAtom* prevNode = nil; for (MTMathAtom* atom in expanded.atoms) { - // -expandMacros dispatches on class, so a real MTMacroAtom is already gone. - // What this catches is a plain MTMathAtom whose settable public -type was - // forced to kMTMathAtomMacro: it walks through expansion untouched and would - // otherwise reach the typesetter, which drops it. + // -expandMacros dispatches on class, so a real MTMacroAtom is gone by now. + // This catches a plain MTMathAtom with -type forced to kMTMathAtomMacro, + // which walks through expansion untouched and would reach the typesetter. NSAssert(atom.type != kMTMathAtomMacro, @"Atom %@ claims to be a macro but is not an MTMacroAtom; -type must not be set to kMTMathAtomMacro.", atom.stringValue); @@ -1789,16 +1767,12 @@ - (MTMathList *)expandMacros { MTMathList* expanded = [MTMathList new]; for (MTMathAtom* atom in self.atoms) { - // isKindOfClass: rather than atom.type: `type` is a settable public property, - // so a plain MTMathAtom can carry kMTMathAtomMacro without responding to - // -expansion. The -type assert in -finalized catches that impostor. + // isKindOfClass: rather than atom.type: `type` is settable, so a plain + // MTMathAtom can carry kMTMathAtomMacro without responding to -expansion. if (![atom isKindOfClass:[MTMacroAtom class]]) { - // Carried through by reference and WITHOUT descending into sub-lists - // (numerator/denominator/radicand/innerList/cells/scripts). Every - // container's -finalized calls the public -finalized on its children, - // which expands those lists in turn — so this code needs to understand - // zero container types. Sharing is safe because -finalized copies every - // atom it keeps, via [atom finalized]. + // By reference, and without descending into sub-lists: every container's + // -finalized calls the public -finalized on its children, which expands + // those in turn. Safe to share because -finalized copies what it keeps. [expanded addAtom:atom]; continue; } @@ -1824,18 +1798,18 @@ - (id)copyWithZone:(NSZone *)zone @implementation MTMacroAtom - (instancetype)initWithCommand:(NSString*)command - arguments:(NSArray*)arguments + argument:(MTMathList*)argument prefix:(MTMathList*)prefix suffix:(MTMathList*)suffix { NSParameterAssert(command); - NSParameterAssert(arguments); + NSParameterAssert(argument); NSParameterAssert(prefix); NSParameterAssert(suffix); self = [super initWithType:kMTMathAtomMacro value:@""]; if (self) { _command = [command copy]; - _arguments = MTDeepCopyMathListArray(arguments); + _argument = [argument copy]; _prefix = [prefix copy]; _suffix = [suffix copy]; } @@ -1844,23 +1818,19 @@ - (instancetype)initWithCommand:(NSString*)command - (instancetype)initWithType:(MTMathAtomType)type value:(NSString*)value { - // NS_UNAVAILABLE in the header already blocks statically typed callers; this - // catches the dynamic ones. Unlike MTInner/MTMathColorbox there is no valid - // zero-argument construction to fall back to — command, arguments and expansion - // text are all required — so it throws rather than redirecting to a bare -init. + // NS_UNAVAILABLE blocks statically typed callers; this catches dynamic ones. @throw [NSException exceptionWithName:@"InvalidMethod" - reason:@"[MTMacroAtom initWithType:value:] cannot be called. Use -initWithCommand:arguments:prefix:suffix: instead." + reason:@"[MTMacroAtom initWithType:value:] cannot be called. Use -initWithCommand:argument:prefix:suffix: instead." userInfo:nil]; } - (id)copyWithZone:(NSZone *)zone { - // Cannot route through [super copyWithZone:], which would call the throwing - // -initWithType:value:. The designated initializer already deep-copies the - // arguments and both expansion halves, so only the MTMathAtom fields need - // carrying over. + // Not [super copyWithZone:], which would call the throwing -initWithType:value:. + // The designated initializer deep-copies the argument and both halves, so only + // the MTMathAtom fields need carrying over. MTMacroAtom* copy = [[[self class] allocWithZone:zone] initWithCommand:self.command - arguments:self.arguments + argument:self.argument prefix:self.prefix suffix:self.suffix]; copy.subScript = [self.subScript copyWithZone:zone]; @@ -1872,10 +1842,7 @@ - (id)copyWithZone:(NSZone *)zone - (NSString *)stringValue { - NSMutableString* str = [NSMutableString stringWithFormat:@"\\%@", self.command]; - for (MTMathList* arg in self.arguments) { - [str appendFormat:@"{%@}", arg.stringValue]; - } + NSMutableString* str = [NSMutableString stringWithFormat:@"\\%@{%@}", self.command, self.argument.stringValue]; if (self.superScript) { [str appendFormat:@"^{%@}", self.superScript.stringValue]; } @@ -1887,34 +1854,20 @@ - (NSString *)stringValue - (void)appendLaTeXToString:(NSMutableString *)str { - // Command-faithful, argument-canonical: the invocation round-trips as \pmod{…}, - // and the argument is re-serialized by the usual serializer rather than - // preserved character-for-character. +mathListToString: appends the ^{…}/_{…} - // tail for us. - [str appendFormat:@"\\%@", self.command]; - if (self.arguments.count == 0) { - // Nothing would terminate the command name otherwise, so a zero-argument - // \noargs followed by x would re-parse as the single command \noargsx. - [str appendString:@" "]; - } - for (MTMathList* arg in self.arguments) { - [str appendFormat:@"{%@}", [MTMathListBuilder mathListToString:arg]]; - } + // The argument is re-serialized by the usual serializer rather than preserved + // character-for-character. +mathListToString: appends the ^{…}/_{…} tail. + [str appendFormat:@"\\%@{%@}", self.command, [MTMathListBuilder mathListToString:self.argument]]; } - (MTMathList *)expansion { - // Deep copies throughout, so the stored expansion halves and arguments stay - // pristine for serialization, for post-parse mutation, and for repeated - // -finalized calls. + // Deep copies throughout, so the stored halves and argument stay pristine for + // serialization, for post-parse mutation, and for repeated -finalized calls. MTMathList* out = [self.prefix copy]; - for (MTMathList* argument in self.arguments) { - [out append:[argument copy]]; - } + [out append:[self.argument copy]]; [out append:[self.suffix copy]]; - // An argument may itself contain a macro. Re-scan so the returned list is - // macro-free at its top level — and so script transfer below targets a real atom - // rather than an unexpanded MTMacroAtom. + // The argument may itself contain a macro. Re-scan so the result is macro-free + // at its top level, and so script transfer targets a real atom. MTMathList* flat = [out expandMacros]; [self transferScriptsToExpansion:flat]; return flat; @@ -1927,18 +1880,13 @@ - (void)transferScriptsToExpansion:(MTMathList *)expansion } MTMathAtom* target = nil; for (MTMathAtom* candidate in expansion.atoms.reverseObjectEnumerator) { - // -scriptsAllowed is type < kMTMathAtomBoundary, which already excludes - // Space (201), Style (202) and every other non-noad (MTMathList.m:222-225). if (candidate.scriptsAllowed) { target = candidate; break; } } - // Slots are evaluated as a unit: if there is no target, or either slot we need - // is taken, BOTH scripts go on a fresh empty Ordinary so a ^/_ pair is never - // split. This is what the builder already does for x^2^3 and for a leading ^2 - // (MTMathListBuilder.m:211-216, 224-228), so \mod{n^2}^3 behaves like - // \mod{n^2}{}^3 — iosMath's long-standing divergence from TeX, not a new one. + // Slots are evaluated as a unit, so a ^/_ pair is never split across two atoms. + // The empty-Ordinary fallback is what the builder already does for x^2^3. BOOL collides = (target == nil) || (self.superScript && target.superScript) || (self.subScript && target.subScript); diff --git a/iosMath/lib/MTMathListBuilder.h b/iosMath/lib/MTMathListBuilder.h index 159dae60..58a64791 100644 --- a/iosMath/lib/MTMathListBuilder.h +++ b/iosMath/lib/MTMathListBuilder.h @@ -51,16 +51,6 @@ NS_ASSUME_NONNULL_BEGIN /// This converts the MTMathList to LaTeX. + (NSString *) mathListToString:(MTMathList *)ml; -/** The names of the supported one-argument macro commands (`pmod`, `mod`, `pod`), - without the leading backslash. - - These are macros, not symbols: they are absent from - `+[MTMathAtomFactory supportedLatexSymbolNames]`, which remains symbol-only. Check - both lists to enumerate everything the parser accepts. `\bmod` is a symbol and - appears in the symbol list, not here. - */ -+ (NSArray *) supportedMacroNames; - /** @typedef MTParseErrors @brief The error encountered when parsing a LaTeX string. diff --git a/iosMath/lib/MTMathListBuilder.m b/iosMath/lib/MTMathListBuilder.m index 9f9458fd..768c8a84 100644 --- a/iosMath/lib/MTMathListBuilder.m +++ b/iosMath/lib/MTMathListBuilder.m @@ -41,55 +41,6 @@ - (instancetype)initWithName:(NSString*) name @end -#pragma mark - MTMacroDefinition - -// A built-in macro: how many arguments it takes, and the fixed LaTeX that brackets -// them. \pmod{n} expands to prefix + n + suffix. -// -// Both halves are ordinary LaTeX parsed by the ordinary builder — there is no -// template syntax and no placeholder atom. That buys one substitution region, which -// is what these macros need; an expansion that interleaves fixed text between -// several arguments is not expressible, and a user-facing \newcommand would need -// substitution that descends into sub-lists rather than a richer table here. -// -// Parser-owned and file-private on purpose. The model layer needs NO registry -// access: MTMacroAtom carries its already-parsed halves, so the dependency runs one -// way (MTMathListBuilder -> MTMacroDefinition, at parse time) and MTMathList.m never -// calls into the builder. A consequence worth keeping: an invocation is bound to the -// expansion as it existed when parsed, so a future mutable registry cannot -// retroactively change an already-parsed atom. -@interface MTMacroDefinition : NSObject - -@property (nonatomic, readonly) NSUInteger argumentCount; // declared, not inferred -@property (nonatomic, copy, readonly) NSString* prefix; -@property (nonatomic, copy, readonly) NSString* suffix; - -- (instancetype)initWithArgumentCount:(NSUInteger)argumentCount - prefix:(NSString*)prefix - suffix:(NSString*)suffix NS_DESIGNATED_INITIALIZER; -- (instancetype)init NS_UNAVAILABLE; - -@end - -@implementation MTMacroDefinition - -- (instancetype)initWithArgumentCount:(NSUInteger)argumentCount - prefix:(NSString*)prefix - suffix:(NSString*)suffix -{ - NSParameterAssert(prefix); - NSParameterAssert(suffix); - self = [super init]; - if (self) { - _argumentCount = argumentCount; - _prefix = [prefix copy]; - _suffix = [suffix copy]; - } - return self; -} - -@end - // Maximum recursion depth for -buildInternal:oneCharOnly:stopChar:. // 150 is comfortably deeper than any realistic human-authored expression yet // far below the thousands of frames needed to overflow a 1 MB stack. @@ -203,17 +154,10 @@ - (BOOL) readOptionalAlignment:(MTFractionAlignment*)outAlignment return YES; } -// Reads one mandatory argument, failing loud when there isn't one. -// // -buildInternal:YES on its own is silently permissive: at EOF it returns an empty -// list with no error, and it leaves a following }/^/_/& unlooked for the caller -// (see -build and -buildInternal:oneCharOnly:stopChar: above). That is fine for -// \sqrt, which has always behaved that way, but a macro invocation with no -// argument must be an error. -// -// Factored out rather than inlined so future command categories can adopt it. -// Scope: only macros route through it today; migrating \sqrt and friends is a -// separate, behavior-affecting change. +// list with no error, and leaves a following }/^/_/& unlooked for the caller. That +// is fine for \sqrt, which has always behaved that way, but a macro invocation with +// no argument must be an error. Only macros route through this today. - (nullable MTMathList *)requiredArgumentWithError:(MTParseErrors)error { [self skipSpaces]; @@ -229,13 +173,10 @@ - (nullable MTMathList *)requiredArgumentWithError:(MTParseErrors)error return nil; } if (ch == '\\') { - // A stop command terminates the enclosing list instead of producing an - // atom, so none of them can begin an argument. Without this check - // -buildInternal:YES hands them to -stopCommand:, which for \\ and \cr - // ends the row and returns it as the "argument" — silently swallowing a - // row break (`\begin{matrix}a\pmod\\b\end{matrix}` loses a row) — and for - // \right / \end returns an empty list with NO error. Wrong output rather - // than a diagnostic, which is exactly what this method exists to prevent. + // A stop command ends the enclosing list instead of producing an atom, so + // none can begin an argument. Unchecked, -buildInternal:YES hands \\ and \cr + // to -stopCommand:, which ends the row and returns it as the "argument" — + // `\begin{matrix}a\pmod\\b\end{matrix}` silently loses a row. NSString* command = [self peekCommand]; if ([[MTMathListBuilder stopCommands] containsObject:command]) { [self setError:error @@ -247,9 +188,8 @@ - (nullable MTMathList *)requiredArgumentWithError:(MTParseErrors)error return [self buildInternal:YES]; } -// Reads the command at the current position (which must be on the '\') and -// restores the read position, so the caller can dispatch on it without consuming -// it. Returns nil if there is no command there. +// Restores the read position, so the caller can dispatch without consuming. Nil if +// there is no command at the current position. - (nullable NSString *)peekCommand { int saved = _currentChar; @@ -446,8 +386,7 @@ - (MTMathList*)buildInternal:(BOOL) oneCharOnly stopChar:(unichar) stop } continue; } - // Macros first: one MTMacroAtom flows through the shared script-attach + - // append + oneCharOnly tail below with no new logic (LLD §2.6). + // Macros first; one MTMacroAtom then flows through the shared tail below. atom = [self macroAtomForCommand:command]; if (!atom) { if (_error) { @@ -1182,39 +1121,30 @@ - (MTMathAtom*) getBoundaryAtom:(NSString*) delimiterType } // Returns nil WITHOUT setting an error when `command` is not a macro, so the caller -// can fall through to -atomForCommand:. Returns nil WITH _error set when the -// command is a macro whose arguments failed to parse. +// can fall through to -atomForCommand:. Returns nil WITH _error set when it is a +// macro whose argument failed to parse. - (nullable MTMacroAtom*) macroAtomForCommand:(NSString*) command { - MTMacroDefinition* def = [MTMathListBuilder builtinMacros][command]; - if (!def) { + NSArray* halves = [MTMathListBuilder builtinMacros][command]; + if (!halves) { return nil; } - // Arguments are read with the EXISTING reader (the one \frac/\sqrt use), so - // they are parsed directly from the user's input — never re-parsed out of a - // generated string (LLD §4.1). - NSMutableArray* args = [NSMutableArray arrayWithCapacity:def.argumentCount]; - for (NSUInteger i = 0; i < def.argumentCount; i++) { - MTMathList* arg = [self requiredArgumentWithError:MTParseErrorMissingArgument]; - if (!arg) { - return nil; // _error already set - } - [args addObject:arg]; - } - // Each half is parsed with a FRESH builder instance, so the in-flight parse's - // state is never swapped or restored. - MTMathList* prefix = [MTMathListBuilder buildFromString:def.prefix]; - MTMathList* suffix = [MTMathListBuilder buildFromString:def.suffix]; - // Both are compile-time constants, never user input: failing to parse one is a - // programming mistake, so fail loud. + MTMathList* argument = [self requiredArgumentWithError:MTParseErrorMissingArgument]; + if (!argument) { + return nil; // _error already set + } + // A fresh builder each, so the in-flight parse's state is never disturbed. + MTMathList* prefix = [MTMathListBuilder buildFromString:halves[0]]; + MTMathList* suffix = [MTMathListBuilder buildFromString:halves[1]]; + // Compile-time constants, so a parse failure here is a programming mistake. NSAssert(prefix && suffix, @"Built-in expansion for \\%@ failed to parse: %@ / %@", - command, def.prefix, def.suffix); + command, halves[0], halves[1]); if (!prefix || !suffix) { [self setError:MTParseErrorInternalError message:[NSString stringWithFormat:@"Built-in expansion for \\%@ failed to parse", command]]; return nil; } - return [[MTMacroAtom alloc] initWithCommand:command arguments:args prefix:prefix suffix:suffix]; + return [[MTMacroAtom alloc] initWithCommand:command argument:argument prefix:prefix suffix:suffix]; } - (MTMathAtom*) atomForCommand:(NSString*) command @@ -1470,9 +1400,7 @@ - (MTMathAtom*) atomForCommand:(NSString*) command } } -// Every command -stopCommand: below recognizes. These end the list being built -// rather than contributing an atom to it, so they are illegal in an argument -// slot — see -requiredArgumentWithError:. Keep in sync with -stopCommand:. +// Every command -stopCommand: below recognizes. Keep in sync with it. + (NSSet*) stopCommands { static NSSet* commands = nil; @@ -1807,48 +1735,27 @@ + (NSDictionary*) spaceToCommands return fractionMacroCommands; } -// The built-in macro registry. Each entry is amsmath's exact INLINE expansion -// (LLD §3.3): the 8mu/12mu leading and 6mu inner gaps are amsmath's literal \mkern -// values, not approximations. What is not reproduced is amsmath's \if@display -// switch to an 18mu leading gap, because a macro expands at parse time, before the -// render style is known (LLD §4.2, PRD non-goal §3.1). -// -// The upright "mod" comes from \mathrm{mod} in the prefix — no manual Roman flag, -// and each half stays a readable LaTeX string. +// Each entry is amsmath's exact inline expansion: @[prefix, suffix], bracketing the +// one argument. Not reproduced is amsmath's \if@display switch to an 18mu leading +// gap, because a macro expands at parse time, before the render style is known. // -// This dispatch_once builds STRINGS ONLY. Nothing is parsed inside it, so there is -// no reentrancy. That is also why the parsed halves are NOT cached here: parsing one -// reaches -macroAtomForCommand: (every command does), which calls back into this -// method — re-entering its own dispatch_once on the same thread deadlocks. They are -// re-parsed per invocation instead, which is ~8 atoms; MTMacroAtom deep-copies the -// result anyway. -+ (NSDictionary*) builtinMacros +// This dispatch_once builds strings only. Parsing one here would re-enter this +// method (every command reaches -macroAtomForCommand:) and deadlock, so the halves +// are re-parsed per invocation instead — ~8 atoms, and MTMacroAtom copies them anyway. ++ (NSDictionary*>*) builtinMacros { - static NSDictionary* macros = nil; + static NSDictionary*>* macros = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ macros = @{ - @"pmod": [[MTMacroDefinition alloc] initWithArgumentCount:1 - prefix:@"\\mkern8mu(\\mathrm{mod}\\mkern6mu" - suffix:@")"], - @"mod": [[MTMacroDefinition alloc] initWithArgumentCount:1 - prefix:@"\\mkern12mu\\mathrm{mod}\\mkern6mu" - suffix:@""], - @"pod": [[MTMacroDefinition alloc] initWithArgumentCount:1 - prefix:@"\\mkern8mu(" - suffix:@")"], + @"pmod": @[ @"\\mkern8mu(\\mathrm{mod}\\mkern6mu", @")" ], + @"mod": @[ @"\\mkern12mu\\mathrm{mod}\\mkern6mu", @"" ], + @"pod": @[ @"\\mkern8mu(", @")" ], }; }); return macros; } -+ (NSArray *) supportedMacroNames -{ - // Sorted, not raw -allKeys: NSDictionary key order is unspecified and can vary - // between runs, which would make any ordered assertion by a caller flaky. - return [[MTMathListBuilder builtinMacros].allKeys sortedArrayUsingSelector:@selector(compare:)]; -} - + (NSDictionary*) styleToCommands { static NSDictionary* styleToCommands = nil; diff --git a/iosMathTests/MTModularArithmeticTest.m b/iosMathTests/MTModularArithmeticTest.m index a1f4499a..d9a4db9f 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -177,7 +177,7 @@ - (void)testDemotedBmodSerializes { MTMathList* arg = [MTMathListBuilder buildFromString:latex]; return [[MTMacroAtom alloc] initWithCommand:@"pod" - arguments:@[ arg ] + argument:arg prefix:PodPrefix() suffix:PodSuffix()]; } @@ -187,11 +187,11 @@ - (void)testMacroAtomBasics MTMacroAtom* macro = PodMacroWithArgument(@"n"); XCTAssertEqual(macro.type, kMTMathAtomMacro); XCTAssertEqualObjects(macro.command, @"pod"); - XCTAssertEqual(macro.arguments.count, 1ul); + XCTAssertEqual(macro.argument.atoms.count, 1ul); XCTAssertEqual(macro.prefix.atoms.count, 2ul); XCTAssertEqual(macro.suffix.atoms.count, 1ul); // 22 sits just past kMTMathAtomOrdGroup (21), the last script-capable value, - // so a macro can carry ^/_ at parse time (MTMathList.h:74-78). + // so a macro can carry ^/_ at parse time. XCTAssertTrue(macro.scriptsAllowed); } @@ -203,14 +203,14 @@ - (void)testMacroAtomDeepCopiesAtInit MTMathList* prefix = PodPrefix(); MTMathList* suffix = PodSuffix(); MTMacroAtom* macro = [[MTMacroAtom alloc] initWithCommand:@"pod" - arguments:@[ arg ] + argument:arg prefix:prefix suffix:suffix]; [arg addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; [prefix addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; [suffix addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; - XCTAssertEqual([macro.arguments[0] atoms].count, 1ul, @"argument was not deep-copied"); + XCTAssertEqual([macro.argument atoms].count, 1ul, @"argument was not deep-copied"); XCTAssertEqual(macro.prefix.atoms.count, 2ul, @"prefix was not deep-copied"); XCTAssertEqual(macro.suffix.atoms.count, 1ul, @"suffix was not deep-copied"); } @@ -223,14 +223,14 @@ - (void)testMacroAtomCopyIsDeep XCTAssertTrue([copy isKindOfClass:[MTMacroAtom class]]); XCTAssertEqualObjects(copy.command, @"pod"); - XCTAssertNotEqual(copy.arguments[0], macro.arguments[0]); + XCTAssertNotEqual(copy.argument, macro.argument); XCTAssertNotEqual(copy.prefix, macro.prefix); XCTAssertEqual(copy.prefix.atoms.count, 2ul); XCTAssertEqual(copy.suffix.atoms.count, 1ul); XCTAssertNotNil(copy.superScript); - [macro.arguments[0] addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; - XCTAssertEqual([copy.arguments[0] atoms].count, 1ul); + [macro.argument addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; + XCTAssertEqual([copy.argument atoms].count, 1ul); } // A macro atom has no valid zero-argument construction. NS_UNAVAILABLE stops @@ -272,7 +272,7 @@ - (void)testFinalizedRejectsNonMacroAtomTypedAsMacro MTMathList* arg = [MTMathList new]; [arg addAtom:macro]; macro = [[MTMacroAtom alloc] initWithCommand:@"pod" - arguments:@[ arg ] + argument:arg prefix:PodPrefix() suffix:PodSuffix()]; } @@ -307,19 +307,6 @@ - (void)testDeeplyNestedMacrosExpand XCTAssertNoThrow([parsed finalized]); } -// \noargs with nothing to terminate the command name would re-parse as \noargsx. -- (void)testZeroArgumentMacroSerializesWithSeparator -{ - MTMacroAtom* macro = [[MTMacroAtom alloc] initWithCommand:@"noargs" - arguments:@[] - prefix:[MTMathListBuilder buildFromString:@"1"] - suffix:[MTMathList new]]; - MTMathList* list = [MTMathList new]; - [list addAtom:macro]; - [list addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"x"]]; - XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\noargs x"); -} - - (void)testMacroAtomSerializesCommandFaithfully { MTMathList* list = [MTMathList new]; @@ -336,7 +323,7 @@ - (void)testMacroAtomSerializesWithScripts XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pod{n}^{2}"); } -// The template is not a source of truth for the arguments: mutating a parsed +// The stored halves are not a source of truth for the argument: mutating a parsed // argument must show up in serialization. - (void)testMacroAtomSerializationTracksArgumentMutation { @@ -345,7 +332,7 @@ - (void)testMacroAtomSerializationTracksArgumentMutation [list addAtom:macro]; XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pod{n}"); - MTMathList* arg = macro.arguments[0]; + MTMathList* arg = macro.argument; [arg removeAtomAtIndex:0]; [arg addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"m"]]; XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pod{m}"); @@ -424,7 +411,7 @@ - (void)testExpandingMacrosCopiesListWithoutMacros static MTMacroAtom* ModMacroWithArgument(NSString* latex) { return [[MTMacroAtom alloc] initWithCommand:@"mod" - arguments:@[ [MTMathListBuilder buildFromString:latex] ] + argument:[MTMathListBuilder buildFromString:latex] prefix:ModPrefix() suffix:[MTMathList new]]; } @@ -462,7 +449,7 @@ - (void)testExpansionSplicesMultiAtomArgument XCTAssertEqual([expanded.atoms[3] type], kMTMathAtomBinaryOperator); } -// Expansion must not consume the stored halves or arguments: finalizing twice +// Expansion must not consume the stored halves or argument: finalizing twice // gives the same answer. - (void)testExpansionLeavesMacroAtomPristine { @@ -475,7 +462,7 @@ - (void)testExpansionLeavesMacroAtomPristine XCTAssertEqualObjects(first, second); XCTAssertEqual(macro.prefix.atoms.count, 2ul); XCTAssertEqual(macro.suffix.atoms.count, 1ul); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.arguments[0]], @"n"); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.argument], @"n"); } // Re-finalizing an already-finalized list must be a no-op. The typesetter depends @@ -504,7 +491,7 @@ - (void)testExpansionRecursesIntoNestedMacros MTMathList* outerArg = [MTMathList new]; [outerArg addAtom:inner]; MTMacroAtom* outer = [[MTMacroAtom alloc] initWithCommand:@"pod" - arguments:@[ outerArg ] + argument:outerArg prefix:PodPrefix() suffix:PodSuffix()]; MTMathList* list = [MTMathList new]; @@ -565,7 +552,7 @@ - (void)testFinalizedTracksArgumentMutation [list addAtom:macro]; XCTAssertEqualObjects([MTMathListBuilder mathListToString:list.finalized], @"\\mkern8.0mu(n)"); - MTMathList* arg = macro.arguments[0]; + MTMathList* arg = macro.argument; [arg removeAtomAtIndex:0]; [arg addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"m"]]; XCTAssertEqualObjects([MTMathListBuilder mathListToString:list.finalized], @"\\mkern8.0mu(m)"); @@ -694,7 +681,7 @@ - (void)testNoScriptableTargetAppendsEmptyOrdinary [spacesOnly addAtom:[[MTMathSpace alloc] initWithSpace:8]]; [spacesOnly addAtom:[[MTMathSpace alloc] initWithSpace:6]]; MTMacroAtom* macro = [[MTMacroAtom alloc] initWithCommand:@"spacesonly" - arguments:@[] + argument:[MTMathList new] prefix:spacesOnly suffix:[MTMathList new]]; macro.superScript = [MTMathListBuilder buildFromString:@"2"]; @@ -995,30 +982,14 @@ - (void)testParsedModHalves // always has. - (void)testEveryRegisteredMacroParses { - // Every registered macro's prefix and suffix parse and build an MTMacroAtom — - // the check that keeps the registry honest, since a malformed half would - // otherwise only surface as MTParseErrorInternalError at invocation. - for (NSString* name in [MTMathListBuilder supportedMacroNames]) { + // Keeps the registry honest: a malformed half would otherwise only surface as + // MTParseErrorInternalError at invocation. + for (NSString* name in @[ @"pmod", @"mod", @"pod" ]) { NSError* error = nil; NSString* latex = [NSString stringWithFormat:@"\\%@{n}", name]; XCTAssertNotNil([MTMathListBuilder buildFromString:latex error:&error], @"%@", latex); XCTAssertNil(error, @"%@", latex); - } -} - -#pragma mark - Macro registry - -- (void)testSupportedMacroNames -{ - NSArray* names = [MTMathListBuilder supportedMacroNames]; - XCTAssertEqual(names.count, 3ul); - for (NSString* name in @[ @"pmod", @"mod", @"pod" ]) { - XCTAssertTrue([names containsObject:name], @"missing %@", name); - } - // \bmod is a symbol, not a macro. The two discovery surfaces stay disjoint. - XCTAssertFalse([names containsObject:@"bmod"]); - XCTAssertTrue([[MTMathAtomFactory supportedLatexSymbolNames] containsObject:@"bmod"]); - for (NSString* name in names) { + // Macros and symbols stay disjoint surfaces. XCTAssertFalse([[MTMathAtomFactory supportedLatexSymbolNames] containsObject:name], @"%@ must not be a symbol too", name); } @@ -1036,8 +1007,8 @@ - (void)testPmodParsesToASingleMacroAtom XCTAssertEqual(last.type, kMTMathAtomMacro); MTMacroAtom* macro = (MTMacroAtom*)last; XCTAssertEqualObjects(macro.command, @"pmod"); - XCTAssertEqual(macro.arguments.count, 1ul); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.arguments[0]], @"n"); + XCTAssertEqual(macro.argument.atoms.count, 1ul); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.argument], @"n"); XCTAssertEqual(macro.prefix.atoms.count, 6ul); XCTAssertEqual(macro.suffix.atoms.count, 1ul); } @@ -1077,7 +1048,7 @@ - (void)testMacroCarriesScriptsFromSharedTail - (void)testUnbracedArgument { MTMacroAtom* macro = (MTMacroAtom*)[MTMathListBuilder buildFromString:@"\\pmod n"].atoms[0]; - XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.arguments[0]], @"n"); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.argument], @"n"); } - (void)testEmptyArgumentIsAllowed @@ -1085,7 +1056,7 @@ - (void)testEmptyArgumentIsAllowed MTMathList* list = [MTMathListBuilder buildFromString:@"\\pmod{}"]; XCTAssertNotNil(list); MTMacroAtom* macro = (MTMacroAtom*)list.atoms[0]; - XCTAssertEqual([macro.arguments[0] atoms].count, 0ul); + XCTAssertEqual([macro.argument atoms].count, 0ul); } - (void)testMissingArgumentIsAnError @@ -1303,7 +1274,7 @@ - (void)testMacroInsideFontStyleGroup if (atom.type == kMTMathAtomMacro) { macro = (MTMacroAtom*)atom; break; } } XCTAssertNotNil(macro); - XCTAssertEqual([macro.arguments[0] atoms][0].fontStyle, kMTFontStyleBold); + XCTAssertEqual([macro.argument atoms][0].fontStyle, kMTFontStyleBold); // "mod" stays Roman regardless — it comes from \mathrm in the prefix. XCTAssertEqual(macro.prefix.atoms[2].fontStyle, kMTFontStyleRoman); } From 45125228b72ad349293755875e505cf0bb7e3cb3 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Thu, 13 Aug 2026 00:35:21 +0530 Subject: [PATCH 04/11] [item 1] Trim macro tests to the regression-catching set --- iosMathTests/MTModularArithmeticTest.m | 586 +------------------------ 1 file changed, 5 insertions(+), 581 deletions(-) diff --git a/iosMathTests/MTModularArithmeticTest.m b/iosMathTests/MTModularArithmeticTest.m index d9a4db9f..d97784cc 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -31,12 +31,6 @@ - (MTMathList *)expandMacros; // Defined under "Equivalence helpers" below. static NSString* ListSignature(MTMathList* list); -// Declared privately in MTMathListBuilder.m; redeclared here so the required-argument -// guard tests can drive it directly. -@interface MTMathListBuilder (MTRequiredArgumentTesting) -- (nullable MTMathList *)requiredArgumentWithError:(MTParseErrors)error; -@end - @implementation MTModularArithmeticTest - (void)setUp @@ -182,19 +176,6 @@ - (void)testDemotedBmodSerializes suffix:PodSuffix()]; } -- (void)testMacroAtomBasics -{ - MTMacroAtom* macro = PodMacroWithArgument(@"n"); - XCTAssertEqual(macro.type, kMTMathAtomMacro); - XCTAssertEqualObjects(macro.command, @"pod"); - XCTAssertEqual(macro.argument.atoms.count, 1ul); - XCTAssertEqual(macro.prefix.atoms.count, 2ul); - XCTAssertEqual(macro.suffix.atoms.count, 1ul); - // 22 sits just past kMTMathAtomOrdGroup (21), the last script-capable value, - // so a macro can carry ^/_ at parse time. - XCTAssertTrue(macro.scriptsAllowed); -} - // NSArray's -copy is shallow. The initializer must deep-copy, or a caller can // mutate the list it handed in and silently mutate the atom. - (void)testMacroAtomDeepCopiesAtInit @@ -233,24 +214,6 @@ - (void)testMacroAtomCopyIsDeep XCTAssertEqual([copy.argument atoms].count, 1ul); } -// A macro atom has no valid zero-argument construction. NS_UNAVAILABLE stops -// statically typed callers at compile time; the runtime guard below is what -// catches an id-typed one, so it is exercised through an id on purpose. -- (void)testMacroAtomRejectsGenericInitializer -{ - id macro = [MTMacroAtom alloc]; - XCTAssertThrows([macro initWithType:kMTMathAtomMacro value:@""]); - XCTAssertThrows([macro initWithType:kMTMathAtomOrdinary value:@"x"]); -} - -// The generic factory has a case for every other structured type, so falling -// through to its default would mint a plain MTMathAtom carrying type 22 — one -// that claims to be a macro but dies on -expansion. -- (void)testAtomFactoryRejectsMacroType -{ - XCTAssertThrows([MTMathAtom atomWithType:kMTMathAtomMacro value:@""]); -} - // +atomWithType: is not the only door: -type is a settable public property, so a // plain MTMathAtom can be relabelled as a macro after the fact. Expansion dispatches // on class and carries it through untouched, and the typesetter would silently drop @@ -264,85 +227,6 @@ - (void)testFinalizedRejectsNonMacroAtomTypedAsMacro XCTAssertThrows([list finalized]); } -// Nests `depth` \pod macros, each one the sole content of the next one's argument. -static MTMacroAtom* NestedPodChain(NSUInteger depth) -{ - MTMacroAtom* macro = PodMacroWithArgument(@"n"); - for (NSUInteger i = 1; i < depth; i++) { - MTMathList* arg = [MTMathList new]; - [arg addAtom:macro]; - macro = [[MTMacroAtom alloc] initWithCommand:@"pod" - argument:arg - prefix:PodPrefix() - suffix:PodSuffix()]; - } - return macro; -} - -// Expansion recurses once per nesting level, but the nesting it can see is whatever -// -buildInternal: already accepted, and that is capped at kMTMaxRecursionDepth. So -// expansion needs no depth budget of its own: anything the parser built must expand. -- (void)testDeeplyNestedMacrosExpand -{ - for (NSNumber* depth in @[ @8, @32, @33, @64 ]) { - MTMathList* list = [MTMathList new]; - [list addAtom:NestedPodChain(depth.unsignedIntegerValue)]; - XCTAssertNoThrow([list finalized], @"depth %@", depth); - } - - // The parser side of the same claim: nesting that survives -buildInternal: also - // survives -finalized. - NSMutableString* latex = [NSMutableString string]; - for (NSUInteger i = 0; i < 40; i++) { - [latex appendString:@"\\pod{"]; - } - [latex appendString:@"n"]; - for (NSUInteger i = 0; i < 40; i++) { - [latex appendString:@"}"]; - } - NSError* error = nil; - MTMathList* parsed = [MTMathListBuilder buildFromString:latex error:&error]; - XCTAssertNotNil(parsed); - XCTAssertNil(error); - XCTAssertNoThrow([parsed finalized]); -} - -- (void)testMacroAtomSerializesCommandFaithfully -{ - MTMathList* list = [MTMathList new]; - [list addAtom:PodMacroWithArgument(@"n+1")]; - XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pod{n+1}"); -} - -- (void)testMacroAtomSerializesWithScripts -{ - MTMacroAtom* macro = PodMacroWithArgument(@"n"); - macro.superScript = [MTMathListBuilder buildFromString:@"2"]; - MTMathList* list = [MTMathList new]; - [list addAtom:macro]; - XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pod{n}^{2}"); -} - -// The stored halves are not a source of truth for the argument: mutating a parsed -// argument must show up in serialization. -- (void)testMacroAtomSerializationTracksArgumentMutation -{ - MTMacroAtom* macro = PodMacroWithArgument(@"n"); - MTMathList* list = [MTMathList new]; - [list addAtom:macro]; - XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pod{n}"); - - MTMathList* arg = macro.argument; - [arg removeAtomAtIndex:0]; - [arg addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"m"]]; - XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pod{m}"); -} - -- (void)testMacroAtomStringValue -{ - XCTAssertEqualObjects([PodMacroWithArgument(@"n") stringValue], @"\\pod{n}"); -} - #pragma mark - Two-phase finalized // Phase 2 must be the existing loop, unchanged: same Bin/Unary reclassification, @@ -357,16 +241,16 @@ - (void)testFinalizedUnchangedForMacroFreeLists // 1 and 7 fuse into one Number; + keeps a left operand so it stays Bin. @"17+5": @"[2:17, 5:+, 2:5]", // No left operand at all -> Unary. - @"-x": @"[6:\u2212, 3:x]", + @"-x": @"[6:−, 3:x]", // Nothing follows -> Unary. @"x+": @"[3:x, 6:+]", // Follows an Open -> Unary. @"(+3)": @"[8:(, 6:+, 2:3, 9:)]", // Demotion happens independently inside each sub-list. - @"\\frac{1+2}{3-}": @"[10:numerator[2:1, 5:+, 2:2]denominator[2:3, 6:\u2212]]", - @"a\\equiv b": @"[3:a, 7:\u2261, 3:b]", + @"\\frac{1+2}{3-}": @"[10:numerator[2:1, 5:+, 2:2]denominator[2:3, 6:−]]", + @"a\\equiv b": @"[3:a, 7:≡, 3:b]", // 2 and 3 fuse; x stays Bin between two Numbers. - @"1\\times 23": @"[2:1, 5:\u00d7, 2:23]", + @"1\\times 23": @"[2:1, 5:×, 2:23]", }; for (NSString* latex in expected) { MTMathList* list = [MTMathListBuilder buildFromString:latex]; @@ -380,18 +264,6 @@ - (void)testFinalizedUnchangedForMacroFreeLists } } -- (void)testExpandingMacrosCopiesListWithoutMacros -{ - MTMathList* list = [MTMathListBuilder buildFromString:@"1+2"]; - MTMathList* expanded = [list expandMacros]; - XCTAssertNotEqual(expanded, list); - XCTAssertEqual(expanded.atoms.count, 3ul); - // Non-macro atoms are carried over by reference; -finalized is what copies. - for (NSUInteger i = 0; i < list.atoms.count; i++) { - XCTAssertEqual(expanded.atoms[i], list.atoms[i]); - } -} - #pragma mark - Macro expansion (phase 1) // \mod's prefix: [Space12, m, o, d (Roman Variables), Space6]. Its suffix is empty. @@ -438,17 +310,6 @@ - (void)testExpansionSplicesArgumentBetweenPrefixAndSuffix } } -// A multi-atom argument is spliced inline, not wrapped. -- (void)testExpansionSplicesMultiAtomArgument -{ - MTMathList* list = [MTMathList new]; - [list addAtom:PodMacroWithArgument(@"n+1")]; - MTMathList* expanded = [list expandMacros]; - // Space8, "(", n, +, 1, ")" - XCTAssertEqual(expanded.atoms.count, 6ul); - XCTAssertEqual([expanded.atoms[3] type], kMTMathAtomBinaryOperator); -} - // Expansion must not consume the stored halves or argument: finalizing twice // gives the same answer. - (void)testExpansionLeavesMacroAtomPristine @@ -465,24 +326,6 @@ - (void)testExpansionLeavesMacroAtomPristine XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.argument], @"n"); } -// Re-finalizing an already-finalized list must be a no-op. The typesetter depends -// on it, and it is the invariant that would break if expansion left anything behind -// for a second reclassifying pass to act on. -- (void)testRefinalizingExpandedListIsIdempotent -{ - MTMathList* list = [MTMathList new]; - [list addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"x"]]; - [list addAtom:ModMacroWithArgument(@"n+")]; - - MTMathList* once = list.finalized; - MTMathList* twice = once.finalized; - XCTAssertNotEqual(twice, once); - XCTAssertEqualObjects(ListSignature(twice), ListSignature(once)); - // The trailing Bin was already demoted to Unary by the first pass; the second - // must find nothing left to reclassify. - XCTAssertEqual(once.atoms.lastObject.type, kMTMathAtomUnaryOperator); -} - // A macro nested inside another macro's argument is expanded by the same pass //: the inner atom is spliced into this list, then re-scanned. - (void)testExpansionRecursesIntoNestedMacros @@ -551,6 +394,7 @@ - (void)testFinalizedTracksArgumentMutation MTMathList* list = [MTMathList new]; [list addAtom:macro]; XCTAssertEqualObjects([MTMathListBuilder mathListToString:list.finalized], @"\\mkern8.0mu(n)"); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pod{n}"); MTMathList* arg = macro.argument; [arg removeAtomAtIndex:0]; @@ -561,140 +405,6 @@ - (void)testFinalizedTracksArgumentMutation #pragma mark - Script transfer -// \pod{n}^2 -> the ")" carries the superscript. -- (void)testScriptTransfersToLastScriptableAtom -{ - MTMacroAtom* macro = PodMacroWithArgument(@"n"); - macro.superScript = [MTMathListBuilder buildFromString:@"2"]; - MTMathList* list = [MTMathList new]; - [list addAtom:macro]; - - MTMathList* expanded = [list expandMacros]; - XCTAssertEqual(expanded.atoms.count, 4ul); - MTMathAtom* close = expanded.atoms[3]; - XCTAssertEqual(close.type, kMTMathAtomClose); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:close.superScript], @"2"); - XCTAssertNil([expanded.atoms[2] superScript]); -} - -- (void)testSubscriptTransfers -{ - MTMacroAtom* macro = PodMacroWithArgument(@"n"); - macro.subScript = [MTMathListBuilder buildFromString:@"k"]; - MTMathList* list = [MTMathList new]; - [list addAtom:macro]; - MTMathAtom* close = [list expandMacros].atoms[3]; - XCTAssertEqualObjects([MTMathListBuilder mathListToString:close.subScript], @"k"); -} - -// \mod{n\;}^2: the trailing space is not scriptable, so the script skips it and -// lands on n. It is never dropped. -- (void)testScriptSkipsTrailingSpace -{ - MTMacroAtom* macro = ModMacroWithArgument(@"n\\;"); - macro.superScript = [MTMathListBuilder buildFromString:@"2"]; - MTMathList* list = [MTMathList new]; - [list addAtom:macro]; - - MTMathList* expanded = [list expandMacros]; - XCTAssertEqual([expanded.atoms.lastObject type], kMTMathAtomSpace); - XCTAssertNil([expanded.atoms.lastObject superScript]); - - MTMathAtom* n = expanded.atoms[expanded.atoms.count - 2]; - XCTAssertEqualObjects(n.nucleus, @"n"); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:n.superScript], @"2"); -} - -// Collision: n already has ^2, so ^3 goes on an appended empty Ordinary — exactly -// what the builder does for x^2^3 (MTMathListBuilder.m:211-216). \mod{n^2}^3 is -// therefore \mod{n^2}{}^3. -- (void)testSuperscriptCollisionAppendsEmptyOrdinary -{ - MTMacroAtom* macro = ModMacroWithArgument(@"n^2"); - macro.superScript = [MTMathListBuilder buildFromString:@"3"]; - MTMathList* list = [MTMathList new]; - [list addAtom:macro]; - - MTMathList* expanded = [list expandMacros]; - MTMathAtom* appended = expanded.atoms.lastObject; - XCTAssertEqual(appended.type, kMTMathAtomOrdinary); - XCTAssertEqualObjects(appended.nucleus, @""); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:appended.superScript], @"3"); - - MTMathAtom* n = expanded.atoms[expanded.atoms.count - 2]; - XCTAssertEqualObjects(n.nucleus, @"n"); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:n.superScript], @"2"); -} - -- (void)testSubscriptCollisionAppendsEmptyOrdinary -{ - MTMacroAtom* macro = ModMacroWithArgument(@"n_1"); - macro.subScript = [MTMathListBuilder buildFromString:@"2"]; - MTMathList* list = [MTMathList new]; - [list addAtom:macro]; - - MTMathAtom* appended = [list expandMacros].atoms.lastObject; - XCTAssertEqual(appended.type, kMTMathAtomOrdinary); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:appended.subScript], @"2"); -} - -// \mod{n^2}_3 is NOT a collision: the subscript slot on n is free. -- (void)testNonCollidingSubscriptAttachesDirectly -{ - MTMacroAtom* macro = ModMacroWithArgument(@"n^2"); - macro.subScript = [MTMathListBuilder buildFromString:@"3"]; - MTMathList* list = [MTMathList new]; - [list addAtom:macro]; - - MTMathList* expanded = [list expandMacros]; - MTMathAtom* n = expanded.atoms.lastObject; - XCTAssertEqualObjects(n.nucleus, @"n", @"no empty Ordinary should have been appended"); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:n.superScript], @"2"); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:n.subScript], @"3"); -} - -// Slots are evaluated as a unit: if either needed slot is taken, BOTH scripts move -// to the appended atom, so a ^/_ pair is never split across two atoms. -- (void)testCollidingPairStaysTogether -{ - MTMacroAtom* macro = ModMacroWithArgument(@"n^2"); - macro.superScript = [MTMathListBuilder buildFromString:@"3"]; - macro.subScript = [MTMathListBuilder buildFromString:@"k"]; - MTMathList* list = [MTMathList new]; - [list addAtom:macro]; - - MTMathList* expanded = [list expandMacros]; - MTMathAtom* appended = expanded.atoms.lastObject; - XCTAssertEqualObjects([MTMathListBuilder mathListToString:appended.superScript], @"3"); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:appended.subScript], @"k"); - - MTMathAtom* n = expanded.atoms[expanded.atoms.count - 2]; - XCTAssertNil(n.subScript, @"the pair must not be split across two atoms"); -} - -// No scriptable atom anywhere in the expansion. Unreachable for the three built-in -// macros (all end in a scriptable atom), but constructible directly, so it is -// covered here with a spaces-only expansion. -- (void)testNoScriptableTargetAppendsEmptyOrdinary -{ - MTMathList* spacesOnly = [MTMathList new]; - [spacesOnly addAtom:[[MTMathSpace alloc] initWithSpace:8]]; - [spacesOnly addAtom:[[MTMathSpace alloc] initWithSpace:6]]; - MTMacroAtom* macro = [[MTMacroAtom alloc] initWithCommand:@"spacesonly" - argument:[MTMathList new] - prefix:spacesOnly - suffix:[MTMathList new]]; - macro.superScript = [MTMathListBuilder buildFromString:@"2"]; - MTMathList* list = [MTMathList new]; - [list addAtom:macro]; - - MTMathList* expanded = [list expandMacros]; - XCTAssertEqual(expanded.atoms.count, 3ul); - MTMathAtom* appended = expanded.atoms.lastObject; - XCTAssertEqual(appended.type, kMTMathAtomOrdinary); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:appended.superScript], @"2"); -} - // Transferring must not mutate the macro atom's own scripts: finalizing twice is // stable, and serialization still reports \pod{n}^{2}. - (void)testScriptTransferLeavesMacroAtomPristine @@ -757,151 +467,8 @@ - (void)testScriptTransferLeavesMacroAtomPristine return [NSString stringWithFormat:@"[%@]", [parts componentsJoinedByString:@", "]]; } -#pragma mark - One-pass equivalence (model layer) - -// Wraps `latex` around a hand-built \mod macro and returns the finalized signature. -- (NSString*)signatureForModMacroWithArgument:(NSString*)arg - prefix:(NSString*)prefix - suffix:(NSString*)suffix -{ - MTMathList* list = [MTMathList new]; - [list append:[MTMathListBuilder buildFromString:prefix]]; - [list addAtom:ModMacroWithArgument(arg)]; - [list append:[MTMathListBuilder buildFromString:suffix]]; - return ListSignature(list.finalized); -} - -// The expansion typed out directly, for comparison. \mathrm{mod} is written as -// three Roman Variables to match ModPrefix() exactly. -- (NSString*)signatureForWrittenOutModWithArgument:(NSString*)arg - prefix:(NSString*)prefix - suffix:(NSString*)suffix -{ - NSString* latex = [NSString stringWithFormat:@"%@\\mkern12mu\\mathrm{mod}\\mkern6mu%@%@", - prefix, arg, suffix]; - MTMathList* list = [MTMathListBuilder buildFromString:latex]; - XCTAssertNotNil(list, @"%@", latex); - return ListSignature(list.finalized); -} - -// x\mod{n+}y : the + sits between n and y in the flat stream and stays Bin. A -// design that finalized each expansion on its own would demote it to Unary. -- (void)testTrailingBinaryOperatorInArgumentStaysBinary -{ - XCTAssertEqualObjects([self signatureForModMacroWithArgument:@"n+" prefix:@"x" suffix:@"y"], - [self signatureForWrittenOutModWithArgument:@"n+" prefix:@"x" suffix:@"y"]); -} - -// x\mod{-n}y : the leading - has no left operand inside the flat stream either -// (it follows a Space, whose predecessor is "mod"), so both sides must agree. -- (void)testLeadingUnaryInArgumentAgrees -{ - XCTAssertEqualObjects([self signatureForModMacroWithArgument:@"-n" prefix:@"x" suffix:@"y"], - [self signatureForWrittenOutModWithArgument:@"-n" prefix:@"x" suffix:@"y"]); -} - -// 1\mod{2}3 : number fusion must see the same neighbours on both sides. -- (void)testNumberFusionAcrossExpansionAgrees -{ - XCTAssertEqualObjects([self signatureForModMacroWithArgument:@"2" prefix:@"1" suffix:@"3"], - [self signatureForWrittenOutModWithArgument:@"2" prefix:@"1" suffix:@"3"]); -} - -- (void)testPlainExpansionsAgree -{ - for (NSString* arg in @[ @"n", @"n+1", @"2^k" ]) { - XCTAssertEqualObjects([self signatureForModMacroWithArgument:arg prefix:@"" suffix:@""], - [self signatureForWrittenOutModWithArgument:arg prefix:@"" suffix:@""], - @"arg %@", arg); - } -} - -- (void)testEquivalenceInsideCongruence -{ - XCTAssertEqualObjects([self signatureForModMacroWithArgument:@"n" prefix:@"a\\equiv b" suffix:@""], - [self signatureForWrittenOutModWithArgument:@"n" prefix:@"a\\equiv b" suffix:@""]); -} - #pragma mark - Required-argument guard -- (void)testRequiredArgumentReadsBracedArgument -{ - MTMathListBuilder* builder = [[MTMathListBuilder alloc] initWithString:@"{n+1}"]; - MTMathList* arg = [builder requiredArgumentWithError:MTParseErrorMissingArgument]; - XCTAssertNotNil(arg); - XCTAssertNil(builder.error); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:arg], @"n+1"); -} - -- (void)testRequiredArgumentReadsUnbracedToken -{ - MTMathListBuilder* builder = [[MTMathListBuilder alloc] initWithString:@"n"]; - MTMathList* arg = [builder requiredArgumentWithError:MTParseErrorMissingArgument]; - XCTAssertEqualObjects([MTMathListBuilder mathListToString:arg], @"n"); -} - -- (void)testRequiredArgumentAllowsEmptyBraces -{ - // \pmod{} is legal LaTeX and renders "( mod )" (LLD §4.4). - MTMathListBuilder* builder = [[MTMathListBuilder alloc] initWithString:@"{}"]; - MTMathList* arg = [builder requiredArgumentWithError:MTParseErrorMissingArgument]; - XCTAssertNotNil(arg); - XCTAssertNil(builder.error); - XCTAssertEqual(arg.atoms.count, 0ul); -} - -- (void)testRequiredArgumentFailsAtEOF -{ - for (NSString* input in @[ @"", @" " ]) { - MTMathListBuilder* builder = [[MTMathListBuilder alloc] initWithString:input]; - XCTAssertNil([builder requiredArgumentWithError:MTParseErrorMissingArgument], - @"input %@", input); - XCTAssertEqual(builder.error.code, MTParseErrorMissingArgument, @"input %@", input); - } -} - -- (void)testRequiredArgumentFailsOnDelimiterInArgumentPosition -{ - for (NSString* input in @[ @"}", @"^2", @"_2", @"&x" ]) { - MTMathListBuilder* builder = [[MTMathListBuilder alloc] initWithString:input]; - XCTAssertNil([builder requiredArgumentWithError:MTParseErrorMissingArgument], - @"input %@", input); - XCTAssertEqual(builder.error.code, MTParseErrorMissingArgument, @"input %@", input); - } -} - -- (void)testRequiredArgumentPropagatesInnerError -{ - MTMathListBuilder* builder = [[MTMathListBuilder alloc] initWithString:@"{\\notacommand}"]; - XCTAssertNil([builder requiredArgumentWithError:MTParseErrorMissingArgument]); - XCTAssertEqual(builder.error.code, MTParseErrorInvalidCommand); -} - -- (void)testRequiredArgumentFailsOnStopCommandInArgumentPosition -{ - // A stop command ends the enclosing list rather than producing an atom, so it - // cannot begin an argument. Every command -stopCommand: recognizes is covered. - for (NSString* input in @[ @"\\\\ y", @"\\cr y", @"\\right) ", @"\\end{matrix}", - @"\\over y", @"\\atop y", @"\\choose y", - @"\\brack y", @"\\brace y" ]) { - MTMathListBuilder* builder = [[MTMathListBuilder alloc] initWithString:input]; - XCTAssertNil([builder requiredArgumentWithError:MTParseErrorMissingArgument], - @"input %@", input); - XCTAssertEqual(builder.error.code, MTParseErrorMissingArgument, @"input %@", input); - } -} - -- (void)testRequiredArgumentAllowsNonStopCommand -{ - // The stop-command guard must not reject ordinary commands in argument position. - for (NSString* input in @[ @"\\alpha", @"\\frac{a}{b}", @"\\sqrt{2}", @"\\left(x\\right)" ]) { - MTMathListBuilder* builder = [[MTMathListBuilder alloc] initWithString:input]; - XCTAssertNotNil([builder requiredArgumentWithError:MTParseErrorMissingArgument], - @"input %@", input); - XCTAssertNil(builder.error, @"input %@", input); - } -} - - (void)testStopCommandInMacroArgumentIsAnErrorNotSilentlyWrongOutput { // Before the stop-command guard these all parsed "successfully" into wrong @@ -936,48 +503,6 @@ - (void)testStopCommandAfterAMacroArgumentStillWorks #pragma mark - Expansion halves as parsed -// The registry stores each half as ordinary LaTeX; these pin what it parses to. -- (void)testParsedPodHalves -{ - MTMacroAtom* m = (MTMacroAtom*)[MTMathListBuilder buildFromString:@"\\pod{n}"].atoms[0]; - XCTAssertEqual(m.prefix.atoms.count, 2ul); - XCTAssertEqual([m.prefix.atoms[0] type], kMTMathAtomSpace); - XCTAssertEqualWithAccuracy([(MTMathSpace*)m.prefix.atoms[0] space], 8, 0.001); - XCTAssertEqual([m.prefix.atoms[1] type], kMTMathAtomOpen); - XCTAssertEqual(m.suffix.atoms.count, 1ul); - XCTAssertEqual([m.suffix.atoms[0] type], kMTMathAtomClose); -} - -- (void)testParsedPmodHalves -{ - MTMacroAtom* m = (MTMacroAtom*)[MTMathListBuilder buildFromString:@"\\pmod{n}"].atoms[0]; - // Space8, "(", m, o, d, Space6 - XCTAssertEqual(m.prefix.atoms.count, 6ul); - XCTAssertEqualWithAccuracy([(MTMathSpace*)m.prefix.atoms[0] space], 8, 0.001); - XCTAssertEqual([m.prefix.atoms[1] type], kMTMathAtomOpen); - for (NSUInteger i = 2; i <= 4; i++) { - // \mathrm yields Roman-styled atoms; changeFont maps them to upright Latin - // at layout time (MTTypesetter.m:539-545). - XCTAssertEqual([m.prefix.atoms[i] fontStyle], kMTFontStyleRoman, @"atom %lu", (unsigned long)i); - } - XCTAssertEqualObjects([m.prefix.atoms[2] nucleus], @"m"); - XCTAssertEqualObjects([m.prefix.atoms[3] nucleus], @"o"); - XCTAssertEqualObjects([m.prefix.atoms[4] nucleus], @"d"); - XCTAssertEqualWithAccuracy([(MTMathSpace*)m.prefix.atoms[5] space], 6, 0.001); - XCTAssertEqual(m.suffix.atoms.count, 1ul); - XCTAssertEqual([m.suffix.atoms[0] type], kMTMathAtomClose); -} - -- (void)testParsedModHalves -{ - MTMacroAtom* m = (MTMacroAtom*)[MTMathListBuilder buildFromString:@"\\mod{n}"].atoms[0]; - XCTAssertEqual(m.prefix.atoms.count, 5ul); - XCTAssertEqualWithAccuracy([(MTMathSpace*)m.prefix.atoms[0] space], 12, 0.001); - XCTAssertEqualWithAccuracy([(MTMathSpace*)m.prefix.atoms[4] space], 6, 0.001); - // \mod has no closing text at all. - XCTAssertEqual(m.suffix.atoms.count, 0ul); -} - // There is no template syntax, so '#' in user input keeps raising the same error it // always has. - (void)testEveryRegisteredMacroParses @@ -1013,38 +538,6 @@ - (void)testPmodParsesToASingleMacroAtom XCTAssertEqual(macro.suffix.atoms.count, 1ul); } -- (void)testAllThreeMacrosParse -{ - // prefix length, suffix length - NSDictionary*>* expectedHalves = @{ - @"pmod": @[ @6, @1 ], // Space8 ( m o d Space6 / ) - @"mod": @[ @5, @0 ], // Space12 m o d Space6 / — - @"pod": @[ @2, @1 ], // Space8 ( / ) - }; - for (NSString* command in expectedHalves) { - NSString* latex = [NSString stringWithFormat:@"x \\%@{n}", command]; - MTMathList* list = [MTMathListBuilder buildFromString:latex]; - XCTAssertNotNil(list, @"%@", latex); - XCTAssertEqual(list.atoms.count, 2ul, @"%@", latex); - MTMacroAtom* macro = (MTMacroAtom*)list.atoms[1]; - XCTAssertEqual(macro.type, kMTMathAtomMacro, @"%@", latex); - XCTAssertEqualObjects(macro.command, command); - XCTAssertEqual(macro.prefix.atoms.count, - expectedHalves[command][0].unsignedIntegerValue, @"%@", latex); - XCTAssertEqual(macro.suffix.atoms.count, - expectedHalves[command][1].unsignedIntegerValue, @"%@", latex); - } -} - -// The shared dispatch tail attaches ^/_ to whatever single atom the chain yields — -// no new script logic needed for macros (LLD §2.6). -- (void)testMacroCarriesScriptsFromSharedTail -{ - MTMacroAtom* macro = (MTMacroAtom*)[MTMathListBuilder buildFromString:@"\\pmod{n}^2"].atoms[0]; - XCTAssertEqual(macro.type, kMTMathAtomMacro); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.superScript], @"2"); -} - - (void)testUnbracedArgument { MTMacroAtom* macro = (MTMacroAtom*)[MTMathListBuilder buildFromString:@"\\pmod n"].atoms[0]; @@ -1059,23 +552,6 @@ - (void)testEmptyArgumentIsAllowed XCTAssertEqual([macro.argument atoms].count, 0ul); } -- (void)testMissingArgumentIsAnError -{ - for (NSString* latex in @[ @"\\pmod", @"\\mod", @"\\pod", @"a \\pmod ", - @"{\\pmod}", @"\\pmod^2" ]) { - NSError* error = nil; - XCTAssertNil([MTMathListBuilder buildFromString:latex error:&error], @"%@", latex); - XCTAssertEqual(error.code, MTParseErrorMissingArgument, @"%@", latex); - } -} - -- (void)testMalformedArgumentPropagates -{ - NSError* error = nil; - XCTAssertNil([MTMathListBuilder buildFromString:@"\\pmod{\\notacommand}" error:&error]); - XCTAssertEqual(error.code, MTParseErrorInvalidCommand); -} - // Non-macro commands must be untouched: macroAtomForCommand: returns nil without // setting an error, and dispatch falls through to atomForCommand:. - (void)testNonMacroCommandsUnaffected @@ -1252,17 +728,6 @@ - (void)testScriptOnAllSpaceArgumentIsNotDropped XCTAssertEqualObjects([MTMathListBuilder mathListToString:carrier.superScript], @"2"); } -// Degenerate but benign: one atom means oneCharOnly makes the whole macro the -// superscript. TeX errors here; iosMath renders it (documented divergence, LLD §6). -- (void)testMacroAsSuperscriptRendersBenignly -{ - MTMathList* list = [MTMathListBuilder buildFromString:@"x^\\pmod{n}"]; - XCTAssertNotNil(list); - XCTAssertEqual([list.atoms[0] superScript].atoms.count, 1ul); - XCTAssertEqual([[list.atoms[0] superScript].atoms[0] type], kMTMathAtomMacro); - XCTAssertNoThrow([list finalized]); -} - // The argument is parsed under the enclosing font style; the parens and spaces come // from the prefix, which is parsed by a fresh builder at default style (LLD §6). - (void)testMacroInsideFontStyleGroup @@ -1391,45 +856,4 @@ - (void)testMacroLayoutMatchesWrittenOutExpansion } } -// The leading gap really is 8mu / 12mu wider than the same expression with no gap. -- (void)testLeadingGapWidths -{ - CGFloat muUnit = self.font.mathTable.muUnit; - MTMathListDisplay* bare = [self displayForLaTeX:@"x(\\mathrm{mod}\\mkern6mu n)"]; - MTMathListDisplay* pmod = [self displayForLaTeX:@"x\\pmod{n}"]; - XCTAssertEqualWithAccuracy(pmod.width - bare.width, 8 * muUnit, 0.01); - - MTMathListDisplay* bareMod = [self displayForLaTeX:@"x\\mathrm{mod}\\mkern6mu n"]; - MTMathListDisplay* mod = [self displayForLaTeX:@"x\\mod{n}"]; - XCTAssertEqualWithAccuracy(mod.width - bareMod.width, 12 * muUnit, 0.01); -} - -- (void)testLeadingGapWidthsInScriptStyle -{ - // None of \pmod/\mod/\pod uses \nonscript, so in TeX the mu kerns survive into - // script style — but shrink with it, because mu is 1/18 of the CURRENT style's - // quad. iosMath matches: MTTypesetter reads _styleFont.mathTable.muUnit, and - // muUnit is fontSize/18 on the STYLE font. Locks in the shrink, which - // testLeadingGapWidths above (display style only) cannot see. - CGFloat scriptMu = self.font.mathTable.muUnit * self.font.mathTable.scriptScaleDown; - XCTAssertLessThan(scriptMu, self.font.mathTable.muUnit); - - MTMathListDisplay* bare = [self displayForLaTeX:@"x^{(\\mathrm{mod}\\mkern6mu n)}"]; - MTMathListDisplay* pmod = [self displayForLaTeX:@"x^{\\pmod{n}}"]; - XCTAssertEqualWithAccuracy(pmod.width - bare.width, 8 * scriptMu, 0.01); - - MTMathListDisplay* bareMod = [self displayForLaTeX:@"x^{\\mathrm{mod}\\mkern6mu n}"]; - MTMathListDisplay* mod = [self displayForLaTeX:@"x^{\\mod{n}}"]; - XCTAssertEqualWithAccuracy(mod.width - bareMod.width, 12 * scriptMu, 0.01); -} - -- (void)testMacrosBuildWithoutAsserting -{ - for (NSString* latex in @[ @"\\pmod{n}", @"\\mod{n}", @"\\pod{n}", - @"a \\equiv b \\pmod{n}", @"(\\pmod{n}", - @"\\pmod{n}^2", @"\\pmod{\\pmod{n}}", @"\\pmod{}" ]) { - XCTAssertNoThrow([self displayForLaTeX:latex], @"%@", latex); - } -} - @end From b956cab844b3399d3892fc5d0b234bae911573fd Mon Sep 17 00:00:00 2001 From: Kostub D Date: Thu, 13 Aug 2026 00:36:05 +0530 Subject: [PATCH 05/11] [item 2] Drop the finalized-loop macro-type assert; typesetter assert covers it --- iosMath/lib/MTMathList.m | 6 ------ iosMathTests/MTModularArithmeticTest.m | 13 ------------- 2 files changed, 19 deletions(-) diff --git a/iosMath/lib/MTMathList.m b/iosMath/lib/MTMathList.m index 81b525c9..81360d37 100644 --- a/iosMath/lib/MTMathList.m +++ b/iosMath/lib/MTMathList.m @@ -1713,12 +1713,6 @@ - (MTMathList *)finalized MTMathAtom* prevNode = nil; for (MTMathAtom* atom in expanded.atoms) { - // -expandMacros dispatches on class, so a real MTMacroAtom is gone by now. - // This catches a plain MTMathAtom with -type forced to kMTMathAtomMacro, - // which walks through expansion untouched and would reach the typesetter. - NSAssert(atom.type != kMTMathAtomMacro, - @"Atom %@ claims to be a macro but is not an MTMacroAtom; -type must not be set to kMTMathAtomMacro.", - atom.stringValue); MTMathAtom* newNode = [atom finalized]; // Each character is given a separate index. if (NSEqualRanges(zeroRange, atom.indexRange)) { diff --git a/iosMathTests/MTModularArithmeticTest.m b/iosMathTests/MTModularArithmeticTest.m index d97784cc..ce8052a5 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -214,19 +214,6 @@ - (void)testMacroAtomCopyIsDeep XCTAssertEqual([copy.argument atoms].count, 1ul); } -// +atomWithType: is not the only door: -type is a settable public property, so a -// plain MTMathAtom can be relabelled as a macro after the fact. Expansion dispatches -// on class and carries it through untouched, and the typesetter would silently drop -// it — so -finalized asserts on the way past. -- (void)testFinalizedRejectsNonMacroAtomTypedAsMacro -{ - MTMathAtom* impostor = [MTMathAtom atomWithType:kMTMathAtomVariable value:@"x"]; - impostor.type = kMTMathAtomMacro; - MTMathList* list = [MTMathList new]; - [list addAtom:impostor]; - XCTAssertThrows([list finalized]); -} - #pragma mark - Two-phase finalized // Phase 2 must be the existing loop, unchanged: same Bin/Unary reclassification, From 3e7a2e8d48715283048cf5ce7fe87a8e9452f41b Mon Sep 17 00:00:00 2001 From: Kostub D Date: Thu, 13 Aug 2026 00:40:53 +0530 Subject: [PATCH 06/11] [item 3] Restore MTMacroParameterAtom template sentinel --- Package.swift | 3 ++ iosMath.xcodeproj/project.pbxproj | 3 ++ iosMath/lib/MTMathList.m | 31 +++++++++++++++++++ iosMath/lib/internal/MTMacroParameterAtom.h | 33 +++++++++++++++++++++ 4 files changed, 70 insertions(+) create mode 100644 iosMath/lib/internal/MTMacroParameterAtom.h diff --git a/Package.swift b/Package.swift index 67775b70..bc4e509a 100644 --- a/Package.swift +++ b/Package.swift @@ -24,6 +24,7 @@ let package = Package( cSettings: [ .headerSearchPath("."), .headerSearchPath("lib"), + .headerSearchPath("lib/internal"), .headerSearchPath("render"), .headerSearchPath("render/internal"), ] @@ -36,6 +37,7 @@ let package = Package( cSettings: [ .headerSearchPath("../iosMath"), .headerSearchPath("../iosMath/lib"), + .headerSearchPath("../iosMath/lib/internal"), .headerSearchPath("../iosMath/render"), .headerSearchPath("../iosMath/render/internal"), ] @@ -47,6 +49,7 @@ let package = Package( cSettings: [ .headerSearchPath("../iosMath"), .headerSearchPath("../iosMath/lib"), + .headerSearchPath("../iosMath/lib/internal"), .headerSearchPath("../iosMath/render"), .headerSearchPath("../iosMath/render/internal"), ], diff --git a/iosMath.xcodeproj/project.pbxproj b/iosMath.xcodeproj/project.pbxproj index a45e8de2..300f1644 100644 --- a/iosMath.xcodeproj/project.pbxproj +++ b/iosMath.xcodeproj/project.pbxproj @@ -95,6 +95,7 @@ 492EED0217DAEDB500939107 /* MTMathList.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MTMathList.h; sourceTree = ""; }; 492EED0317DAEDB500939107 /* MTMathList.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MTMathList.m; sourceTree = ""; }; 492EED0417DAEDB500939107 /* MTMathListBuilder.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MTMathListBuilder.h; sourceTree = ""; }; + C01DEC0DE20260726000001 /* MTMacroParameterAtom.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MTMacroParameterAtom.h; sourceTree = ""; }; 4987307517D546800041B02B /* libIosMath.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libIosMath.a; sourceTree = BUILT_PRODUCTS_DIR; }; 498730AB17D548DB0041B02B /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; 49965EFC17CBBA2700A555C5 /* iosMathExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosMathExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -290,10 +291,12 @@ 492EECFF17DAEDB500939107 /* MTMathListBuilder.m */, 49DA6BC319A05F850086B19F /* MTUnicode.h */, 49DA6BC619A062A30086B19F /* MTUnicode.m */, + C01DEC0DE20260726000004 /* internal */, ); path = lib; sourceTree = ""; }; + C01DEC0DE20260726000004 /* internal */ = { isa = PBXGroup; children = ( C01DEC0DE20260726000001 /* MTMacroParameterAtom.h */, ); path = internal; sourceTree = ""; }; 49965F3917CBD02000A555C5 /* render */ = { isa = PBXGroup; children = ( diff --git a/iosMath/lib/MTMathList.m b/iosMath/lib/MTMathList.m index 81360d37..9825d6b7 100644 --- a/iosMath/lib/MTMathList.m +++ b/iosMath/lib/MTMathList.m @@ -12,6 +12,7 @@ #import "MTMathList.h" #import "MTMathListBuilder.h" #import "MTMathAtomFactory.h" +#import "MTMacroParameterAtom.h" // Returns true if the current binary operator is not really binary. static BOOL isNotBinaryOperator(MTMathAtom* prevNode) @@ -1897,3 +1898,33 @@ - (void)transferScriptsToExpansion:(MTMathList *)expansion } @end + +#pragma mark - MTMacroParameterAtom + +@implementation MTMacroParameterAtom + +- (instancetype)initWithArgumentIndex:(NSUInteger)argumentIndex +{ + NSParameterAssert(argumentIndex >= 1 && argumentIndex <= 9); + // Ordinary + a visible "#N" nucleus: if a placeholder ever did leak into a + // rendered list, it shows up as literal "#1" rather than crashing on an + // unhandled enum value. + self = [super initWithType:kMTMathAtomOrdinary + value:[NSString stringWithFormat:@"#%lu", (unsigned long)argumentIndex]]; + if (self) { + _argumentIndex = argumentIndex; + } + return self; +} + +- (id)copyWithZone:(NSZone *)zone +{ + // MTMathAtom's -copyWithZone: allocates [self class] and calls + // -initWithType:value:, which this class does not override — so the copy is a + // MTMacroParameterAtom with the right nucleus but a zero index. Restore it. + MTMacroParameterAtom* copy = [super copyWithZone:zone]; + copy->_argumentIndex = self.argumentIndex; + return copy; +} + +@end diff --git a/iosMath/lib/internal/MTMacroParameterAtom.h b/iosMath/lib/internal/MTMacroParameterAtom.h new file mode 100644 index 00000000..5fd5d5c9 --- /dev/null +++ b/iosMath/lib/internal/MTMacroParameterAtom.h @@ -0,0 +1,33 @@ +// +// MTMacroParameterAtom.h +// iosMath +// +// INTERNAL HEADER — deliberately not listed in iosMath/module.modulemap, so it +// does not appear in the Swift module interface. +// + +#import "MTMathList.h" + +NS_ASSUME_NONNULL_BEGIN + +/** A `#N` argument reference inside a macro's golden template. + + This is a sentinel: it exists only between "the template was parsed" and "the + macro was expanded", and every instance is consumed by + `-[MTMacroAtom expansion]`. It keeps type `kMTMathAtomOrdinary` rather than + claiming a new `MTMathAtomType`, because the public enum should not grow a value + that can never legally reach a finalized list. Detect it with `isKindOfClass:`. + */ +@interface MTMacroParameterAtom : MTMathAtom + +/** The 1-based argument this placeholder stands for (1...9). */ +@property (nonatomic, readonly) NSUInteger argumentIndex; + +// Deliberately NOT NS_DESIGNATED_INITIALIZER: -copyWithZone: depends on +// MTMathAtom's -initWithType:value: staying reachable to rebuild the copy, which +// is exactly what a designated initializer here would forbid. +- (instancetype)initWithArgumentIndex:(NSUInteger)argumentIndex; + +@end + +NS_ASSUME_NONNULL_END From 36208054029e98f899a2a6d0150645c652273eb4 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Thu, 13 Aug 2026 00:44:29 +0530 Subject: [PATCH 07/11] [item 4] Parse #N argument references in builder template mode --- iosMath/lib/MTMathListBuilder.m | 29 ++++++++++++++++++++++++++ iosMathTests/MTModularArithmeticTest.m | 27 ++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/iosMath/lib/MTMathListBuilder.m b/iosMath/lib/MTMathListBuilder.m index 768c8a84..ff393514 100644 --- a/iosMath/lib/MTMathListBuilder.m +++ b/iosMath/lib/MTMathListBuilder.m @@ -11,6 +11,7 @@ #import "MTMathListBuilder.h" #import "MTMathAtomFactory.h" +#import "MTMacroParameterAtom.h" NSString *const MTParseError = @"ParseError"; @@ -46,6 +47,12 @@ - (instancetype)initWithName:(NSString*) name // far below the thousands of frames needed to overflow a 1 MB stack. static const NSInteger kMTMaxRecursionDepth = 150; +// Not in the public header, so template mode does not appear in the Swift module +// interface — only built-in macro templates use it. +@interface MTMathListBuilder () ++ (nullable MTMathList *)buildTemplate:(NSString *)str; +@end + @implementation MTMathListBuilder { unichar* _chars; int _currentChar; @@ -55,6 +62,7 @@ @implementation MTMathListBuilder { MTFontStyle _currentFontStyle; BOOL _spacesAllowed; NSInteger _recursionDepth; + BOOL _templateMode; // Set to YES by stopCommand when a TeX group-transformation command (\over, // \atop, \choose, \brack, \brace) fires inside a {…} group. Checked in the // {…} branch to decide whether to wrap as MTMathGroup. Cleared at the top of @@ -466,6 +474,18 @@ - (MTMathList*)buildInternal:(BOOL) oneCharOnly stopChar:(unichar) stop } else if (ch == '~') { // Tilde is a non-breaking space in LaTeX; render it as an ordinary space. atom = [MTMathAtomFactory atomForLatexSymbolName:@" "]; + } else if (_templateMode && ch == '#') { + // #N argument reference. Malformed #X can only come from a built-in + // template string — a programming mistake, not user input. + unichar digit = [self hasCharacters] ? [self getNextCharacter] : 0; + NSAssert(digit >= '1' && digit <= '9', + @"Malformed #%C in a built-in macro template", digit); + if (digit < '1' || digit > '9') { + [self setError:MTParseErrorInternalError + message:@"Malformed #N in a built-in macro template"]; + return nil; + } + atom = [[MTMacroParameterAtom alloc] initWithArgumentIndex:digit - '0']; } else { atom = [MTMathAtomFactory atomForCharacter:ch]; if (!atom) { @@ -1808,6 +1828,15 @@ + (MTMathList *)buildFromString:(NSString *)str error:(NSError *__autoreleasing return output; } +// Parses a built-in macro template: ordinary LaTeX plus #N argument references. +// Template mode exists so that in user input # stays an invalid character. ++ (nullable MTMathList *)buildTemplate:(NSString *)str +{ + MTMathListBuilder* builder = [[MTMathListBuilder alloc] initWithString:str]; + builder->_templateMode = YES; + return [builder build]; +} + + (NSString*) delimToString:(MTMathAtom*) delim { NSString* command = [MTMathAtomFactory delimiterNameForBoundaryAtom:delim]; diff --git a/iosMathTests/MTModularArithmeticTest.m b/iosMathTests/MTModularArithmeticTest.m index ce8052a5..0df35395 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -17,6 +17,7 @@ #import "MTMathListDisplay.h" #import "MTMathListDisplayInternal.h" #import "MTFontMathTable.h" +#import "MTMacroParameterAtom.h" @interface MTModularArithmeticTest : XCTestCase @property (nonatomic) MTFont* font; @@ -28,6 +29,12 @@ @interface MTMathList (MTMacroExpansionTesting) - (MTMathList *)expandMacros; @end +// Declared privately in MTMathListBuilder.m; redeclared here to drive template +// parsing directly. +@interface MTMathListBuilder (MTTemplateTesting) ++ (nullable MTMathList *)buildTemplate:(NSString *)str; +@end + // Defined under "Equivalence helpers" below. static NSString* ListSignature(MTMathList* list); @@ -843,4 +850,24 @@ - (void)testMacroLayoutMatchesWrittenOutExpansion } } +#pragma mark - Template parsing + +- (void)testBuildTemplateParsesParameterAtoms +{ + MTMathList* list = [MTMathListBuilder buildTemplate:@"a#1+#2"]; + XCTAssertNotNil(list); + XCTAssertEqual(list.atoms.count, 4ul); + XCTAssertEqual(list.atoms[0].type, kMTMathAtomVariable); + XCTAssertTrue([list.atoms[1] isKindOfClass:[MTMacroParameterAtom class]]); + XCTAssertEqual([(MTMacroParameterAtom*)list.atoms[1] argumentIndex], 1ul); + XCTAssertEqual(list.atoms[2].type, kMTMathAtomBinaryOperator); + XCTAssertTrue([list.atoms[3] isKindOfClass:[MTMacroParameterAtom class]]); + XCTAssertEqual([(MTMacroParameterAtom*)list.atoms[3] argumentIndex], 2ul); + + // Outside template mode # stays an invalid character, exactly as before. + NSError* error = nil; + XCTAssertNil([MTMathListBuilder buildFromString:@"#1" error:&error]); + XCTAssertEqual(error.code, MTParseErrorInvalidCharacter); +} + @end From cbbe68374c46fea5349b0acdde04fdb1bb062eab Mon Sep 17 00:00:00 2001 From: Kostub D Date: Thu, 13 Aug 2026 00:51:47 +0530 Subject: [PATCH 08/11] [item 5] Swap macro halves for arguments + #N template expression --- iosMath/lib/MTMathList.h | 38 ++++--- iosMath/lib/MTMathList.m | 75 +++++++++----- iosMath/lib/MTMathListBuilder.m | 82 ++++++++++----- iosMathTests/MTModularArithmeticTest.m | 137 ++++++++++--------------- 4 files changed, 183 insertions(+), 149 deletions(-) diff --git a/iosMath/lib/MTMathList.h b/iosMath/lib/MTMathList.h index f42f931a..cd4a206f 100644 --- a/iosMath/lib/MTMathList.h +++ b/iosMath/lib/MTMathList.h @@ -693,34 +693,38 @@ typedef NS_ENUM(NSUInteger, MTStrikeStyle) { @end -/** An unexpanded one-argument macro invocation. +/** An unexpanded macro invocation. - `\pmod{n}` parses to exactly one `MTMacroAtom` and expands to - `prefix` + `argument` + `suffix`. All three are raw (non-finalized) lists parsed - at parse time; the expansion is re-derived from them every time - `-[MTMathList finalized]` runs. + `\pmod{n}` parses to exactly one `MTMacroAtom` and expands by splicing a deep + copy of each argument into the `#N` placeholders of `templateExpression`. All + stored lists are raw (non-finalized), parsed at parse time; the expansion is + re-derived from them every time `-[MTMathList finalized]` runs. - @note Only `-[MTMathList finalized]` expands. `-[MTMacroAtom finalized]` on a lone - atom returns another macro atom. + `#N` substitution reaches only the top level of the template. A placeholder + nested inside a sub-list (`\frac{#1}{2}`, `{#1}`, `x^{#1}`) is not substituted + and renders as a literal `#N` — built-in templates are all flat; user-defined + templates (`\newcommand`) need substitution that descends into sub-lists, which + does not exist yet. + + @note Only `-[MTMathList finalized]` expands. `-[MTMacroAtom finalized]` on a + lone atom returns another macro atom. */ @interface MTMacroAtom : MTMathAtom /** The command name without the leading backslash, e.g. `@"pmod"`. */ @property (nonatomic, copy, readonly) NSString* command; -/** The parsed argument. Mutable, and owned by this atom (deep-copied at init). */ -@property (nonatomic, strong, readonly) MTMathList* argument; - -/** The fixed expansion text before the argument. May be empty. */ -@property (nonatomic, strong, readonly) MTMathList* prefix; +/** The parsed arguments in invocation order. The lists are mutable, and owned by + this atom (deep-copied at init). */ +@property (nonatomic, copy, readonly) NSArray* arguments; -/** The fixed expansion text after the argument. May be empty. */ -@property (nonatomic, strong, readonly) MTMathList* suffix; +/** The golden expansion template: a raw, argument-free list whose `#N` + references are internal placeholder atoms. */ +@property (nonatomic, strong, readonly) MTMathList* templateExpression; - (instancetype)initWithCommand:(NSString*)command - argument:(MTMathList*)argument - prefix:(MTMathList*)prefix - suffix:(MTMathList*)suffix NS_DESIGNATED_INITIALIZER; + arguments:(NSArray*)arguments + templateExpression:(MTMathList*)templateExpression NS_DESIGNATED_INITIALIZER; /// The implementation additionally throws, to catch dynamic (`id`-typed) callers. - (instancetype)initWithType:(MTMathAtomType)type value:(NSString*)value NS_UNAVAILABLE; diff --git a/iosMath/lib/MTMathList.m b/iosMath/lib/MTMathList.m index 9825d6b7..5c0ca099 100644 --- a/iosMath/lib/MTMathList.m +++ b/iosMath/lib/MTMathList.m @@ -1793,20 +1793,18 @@ - (id)copyWithZone:(NSZone *)zone @implementation MTMacroAtom - (instancetype)initWithCommand:(NSString*)command - argument:(MTMathList*)argument - prefix:(MTMathList*)prefix - suffix:(MTMathList*)suffix + arguments:(NSArray*)arguments + templateExpression:(MTMathList*)templateExpression { NSParameterAssert(command); - NSParameterAssert(argument); - NSParameterAssert(prefix); - NSParameterAssert(suffix); + NSParameterAssert(arguments); + NSParameterAssert(templateExpression); self = [super initWithType:kMTMathAtomMacro value:@""]; if (self) { _command = [command copy]; - _argument = [argument copy]; - _prefix = [prefix copy]; - _suffix = [suffix copy]; + // copyItems gives a deep copy: MTMathList's -copyWithZone: is deep. + _arguments = [[NSArray alloc] initWithArray:arguments copyItems:YES]; + _templateExpression = [templateExpression copy]; } return self; } @@ -1815,19 +1813,18 @@ - (instancetype)initWithType:(MTMathAtomType)type value:(NSString*)value { // NS_UNAVAILABLE blocks statically typed callers; this catches dynamic ones. @throw [NSException exceptionWithName:@"InvalidMethod" - reason:@"[MTMacroAtom initWithType:value:] cannot be called. Use -initWithCommand:argument:prefix:suffix: instead." + reason:@"[MTMacroAtom initWithType:value:] cannot be called. Use -initWithCommand:arguments:templateExpression: instead." userInfo:nil]; } - (id)copyWithZone:(NSZone *)zone { // Not [super copyWithZone:], which would call the throwing -initWithType:value:. - // The designated initializer deep-copies the argument and both halves, so only - // the MTMathAtom fields need carrying over. + // The designated initializer deep-copies arguments and template, so only the + // MTMathAtom fields need carrying over. MTMacroAtom* copy = [[[self class] allocWithZone:zone] initWithCommand:self.command - argument:self.argument - prefix:self.prefix - suffix:self.suffix]; + arguments:self.arguments + templateExpression:self.templateExpression]; copy.subScript = [self.subScript copyWithZone:zone]; copy.superScript = [self.superScript copyWithZone:zone]; copy.indexRange = self.indexRange; @@ -1837,7 +1834,10 @@ - (id)copyWithZone:(NSZone *)zone - (NSString *)stringValue { - NSMutableString* str = [NSMutableString stringWithFormat:@"\\%@{%@}", self.command, self.argument.stringValue]; + NSMutableString* str = [NSMutableString stringWithFormat:@"\\%@", self.command]; + for (MTMathList* arg in self.arguments) { + [str appendFormat:@"{%@}", arg.stringValue]; + } if (self.superScript) { [str appendFormat:@"^{%@}", self.superScript.stringValue]; } @@ -1849,19 +1849,44 @@ - (NSString *)stringValue - (void)appendLaTeXToString:(NSMutableString *)str { - // The argument is re-serialized by the usual serializer rather than preserved - // character-for-character. +mathListToString: appends the ^{…}/_{…} tail. - [str appendFormat:@"\\%@{%@}", self.command, [MTMathListBuilder mathListToString:self.argument]]; + // Command-faithful, argument-canonical: arguments are re-serialized by the + // usual serializer. +mathListToString: appends the ^{…}/_{…} tail. + [str appendFormat:@"\\%@", self.command]; + if (self.arguments.count == 0) { + // Nothing would terminate the command name otherwise: a zero-argument + // \foo followed by x would re-parse as the single command \foox. + [str appendString:@" "]; + } + for (MTMathList* arg in self.arguments) { + [str appendFormat:@"{%@}", [MTMathListBuilder mathListToString:arg]]; + } } - (MTMathList *)expansion { - // Deep copies throughout, so the stored halves and argument stay pristine for - // serialization, for post-parse mutation, and for repeated -finalized calls. - MTMathList* out = [self.prefix copy]; - [out append:[self.argument copy]]; - [out append:[self.suffix copy]]; - // The argument may itself contain a macro. Re-scan so the result is macro-free + // Deep copies throughout, so the stored template and arguments stay pristine + // for serialization, for post-parse mutation, and for repeated -finalized calls. + MTMathList* out = [MTMathList new]; + for (MTMathAtom* templateAtom in self.templateExpression.atoms) { + if (![templateAtom isKindOfClass:[MTMacroParameterAtom class]]) { + [out addAtom:[templateAtom copy]]; + continue; + } + NSUInteger index = [(MTMacroParameterAtom*)templateAtom argumentIndex]; + // Arity disagreement is a bug in the macro table, not something the LaTeX + // author can cause. With assertions compiled out the placeholder is + // carried through and renders as a visible literal "#N" rather than + // making an argument silently vanish. + NSAssert(index >= 1 && index <= self.arguments.count, + @"Macro \\%@ template references #%lu but %lu argument(s) were parsed.", + self.command, (unsigned long)index, (unsigned long)self.arguments.count); + if (index < 1 || index > self.arguments.count) { + [out addAtom:[templateAtom copy]]; + continue; + } + [out append:[self.arguments[index - 1] copy]]; + } + // An argument may itself contain a macro. Re-scan so the result is macro-free // at its top level, and so script transfer targets a real atom. MTMathList* flat = [out expandMacros]; [self transferScriptsToExpansion:flat]; diff --git a/iosMath/lib/MTMathListBuilder.m b/iosMath/lib/MTMathListBuilder.m index ff393514..5341e542 100644 --- a/iosMath/lib/MTMathListBuilder.m +++ b/iosMath/lib/MTMathListBuilder.m @@ -47,6 +47,29 @@ - (instancetype)initWithName:(NSString*) name // far below the thousands of frames needed to overflow a 1 MB stack. static const NSInteger kMTMaxRecursionDepth = 150; +// Registry value: declared arity + the LaTeX template the expansion is parsed +// from. Arity is declared rather than inferred from the template because a +// future \newcommand declares [argc] and its body may ignore arguments. +@interface MTMacroDefinition : NSObject +@property (nonatomic, readonly) NSUInteger argumentCount; +@property (nonatomic, copy, readonly) NSString* templateString; +- (instancetype)initWithArgumentCount:(NSUInteger)argumentCount + templateString:(NSString*)templateString; +@end + +@implementation MTMacroDefinition +- (instancetype)initWithArgumentCount:(NSUInteger)argumentCount + templateString:(NSString*)templateString +{ + self = [super init]; + if (self) { + _argumentCount = argumentCount; + _templateString = [templateString copy]; + } + return self; +} +@end + // Not in the public header, so template mode does not appear in the Swift module // interface — only built-in macro templates use it. @interface MTMathListBuilder () @@ -1140,31 +1163,35 @@ - (MTMathAtom*) getBoundaryAtom:(NSString*) delimiterType return commands; } -// Returns nil WITHOUT setting an error when `command` is not a macro, so the caller -// can fall through to -atomForCommand:. Returns nil WITH _error set when it is a -// macro whose argument failed to parse. +// Returns nil WITHOUT setting an error when `command` is not a macro, so the +// caller can fall through to -atomForCommand:. Returns nil WITH _error set when +// it is a macro whose arguments failed to parse. - (nullable MTMacroAtom*) macroAtomForCommand:(NSString*) command { - NSArray* halves = [MTMathListBuilder builtinMacros][command]; - if (!halves) { + MTMacroDefinition* def = [MTMathListBuilder builtinMacros][command]; + if (!def) { return nil; } - MTMathList* argument = [self requiredArgumentWithError:MTParseErrorMissingArgument]; - if (!argument) { - return nil; // _error already set + NSMutableArray* arguments = [NSMutableArray arrayWithCapacity:def.argumentCount]; + for (NSUInteger i = 0; i < def.argumentCount; i++) { + MTMathList* argument = [self requiredArgumentWithError:MTParseErrorMissingArgument]; + if (!argument) { + return nil; // _error already set + } + [arguments addObject:argument]; } - // A fresh builder each, so the in-flight parse's state is never disturbed. - MTMathList* prefix = [MTMathListBuilder buildFromString:halves[0]]; - MTMathList* suffix = [MTMathListBuilder buildFromString:halves[1]]; + // A fresh builder, so the in-flight parse's state is never disturbed. + MTMathList* templateExpression = [MTMathListBuilder buildTemplate:def.templateString]; // Compile-time constants, so a parse failure here is a programming mistake. - NSAssert(prefix && suffix, @"Built-in expansion for \\%@ failed to parse: %@ / %@", - command, halves[0], halves[1]); - if (!prefix || !suffix) { + NSAssert(templateExpression, @"Built-in template for \\%@ failed to parse: %@", + command, def.templateString); + if (!templateExpression) { [self setError:MTParseErrorInternalError - message:[NSString stringWithFormat:@"Built-in expansion for \\%@ failed to parse", command]]; + message:[NSString stringWithFormat:@"Built-in template for \\%@ failed to parse", command]]; return nil; } - return [[MTMacroAtom alloc] initWithCommand:command argument:argument prefix:prefix suffix:suffix]; + return [[MTMacroAtom alloc] initWithCommand:command arguments:arguments + templateExpression:templateExpression]; } - (MTMathAtom*) atomForCommand:(NSString*) command @@ -1755,22 +1782,25 @@ + (NSDictionary*) spaceToCommands return fractionMacroCommands; } -// Each entry is amsmath's exact inline expansion: @[prefix, suffix], bracketing the -// one argument. Not reproduced is amsmath's \if@display switch to an 18mu leading -// gap, because a macro expands at parse time, before the render style is known. +// Each entry is amsmath's exact inline expansion as a #N template. Not reproduced +// is amsmath's \if@display switch to an 18mu leading gap, because a macro expands +// at parse time, before the render style is known. // // This dispatch_once builds strings only. Parsing one here would re-enter this -// method (every command reaches -macroAtomForCommand:) and deadlock, so the halves -// are re-parsed per invocation instead — ~8 atoms, and MTMacroAtom copies them anyway. -+ (NSDictionary*>*) builtinMacros +// method (every command reaches -macroAtomForCommand:) and deadlock, so templates +// are parsed per invocation instead — ~8 atoms, and MTMacroAtom copies them anyway. ++ (NSDictionary*) builtinMacros { - static NSDictionary*>* macros = nil; + static NSDictionary* macros = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ macros = @{ - @"pmod": @[ @"\\mkern8mu(\\mathrm{mod}\\mkern6mu", @")" ], - @"mod": @[ @"\\mkern12mu\\mathrm{mod}\\mkern6mu", @"" ], - @"pod": @[ @"\\mkern8mu(", @")" ], + @"pmod": [[MTMacroDefinition alloc] initWithArgumentCount:1 + templateString:@"\\mkern8mu(\\mathrm{mod}\\mkern6mu#1)"], + @"mod": [[MTMacroDefinition alloc] initWithArgumentCount:1 + templateString:@"\\mkern12mu\\mathrm{mod}\\mkern6mu#1"], + @"pod": [[MTMacroDefinition alloc] initWithArgumentCount:1 + templateString:@"\\mkern8mu(#1)"], }; }); return macros; diff --git a/iosMathTests/MTModularArithmeticTest.m b/iosMathTests/MTModularArithmeticTest.m index 0df35395..a3d0b9e3 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -35,6 +35,16 @@ @interface MTMathListBuilder (MTTemplateTesting) + (nullable MTMathList *)buildTemplate:(NSString *)str; @end +// Defined privately in MTMathListBuilder.m; redeclared for registry tests. +@interface MTMacroDefinition : NSObject +@property (nonatomic, readonly) NSUInteger argumentCount; +@property (nonatomic, copy, readonly) NSString* templateString; +@end + +@interface MTMathListBuilder (MTMacroRegistryTesting) ++ (NSDictionary*)builtinMacros; +@end + // Defined under "Equivalence helpers" below. static NSString* ListSignature(MTMathList* list); @@ -157,30 +167,18 @@ - (void)testDemotedBmodSerializes #pragma mark - MTMacroAtom -// The two fixed halves of \pod's expansion: [Space8, Open "("] and [Close ")"]. -// Hand-built so PR 2 is independent of the parser (which lands in PR 3). -static MTMathList* PodPrefix(void) +// \pod's template: Space8, Open "(", «#1», Close ")" -- 4 atoms. +static MTMathList* PodTemplate(void) { - MTMathList* t = [MTMathList new]; - [t addAtom:[[MTMathSpace alloc] initWithSpace:8]]; - [t addAtom:[MTMathAtom atomWithType:kMTMathAtomOpen value:@"("]]; - return t; -} - -static MTMathList* PodSuffix(void) -{ - MTMathList* t = [MTMathList new]; - [t addAtom:[MTMathAtom atomWithType:kMTMathAtomClose value:@")"]]; - return t; + return [MTMathListBuilder buildTemplate:@"\\mkern8mu(#1)"]; } static MTMacroAtom* PodMacroWithArgument(NSString* latex) { MTMathList* arg = [MTMathListBuilder buildFromString:latex]; return [[MTMacroAtom alloc] initWithCommand:@"pod" - argument:arg - prefix:PodPrefix() - suffix:PodSuffix()]; + arguments:@[ arg ] + templateExpression:PodTemplate()]; } // NSArray's -copy is shallow. The initializer must deep-copy, or a caller can @@ -188,19 +186,15 @@ - (void)testDemotedBmodSerializes - (void)testMacroAtomDeepCopiesAtInit { MTMathList* arg = [MTMathListBuilder buildFromString:@"n"]; - MTMathList* prefix = PodPrefix(); - MTMathList* suffix = PodSuffix(); + MTMathList* templateExpression = PodTemplate(); MTMacroAtom* macro = [[MTMacroAtom alloc] initWithCommand:@"pod" - argument:arg - prefix:prefix - suffix:suffix]; + arguments:@[ arg ] + templateExpression:templateExpression]; [arg addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; - [prefix addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; - [suffix addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; + [templateExpression addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; - XCTAssertEqual([macro.argument atoms].count, 1ul, @"argument was not deep-copied"); - XCTAssertEqual(macro.prefix.atoms.count, 2ul, @"prefix was not deep-copied"); - XCTAssertEqual(macro.suffix.atoms.count, 1ul, @"suffix was not deep-copied"); + XCTAssertEqual([macro.arguments[0] atoms].count, 1ul, @"argument was not deep-copied"); + XCTAssertEqual(macro.templateExpression.atoms.count, 4ul, @"template was not deep-copied"); } - (void)testMacroAtomCopyIsDeep @@ -211,14 +205,13 @@ - (void)testMacroAtomCopyIsDeep XCTAssertTrue([copy isKindOfClass:[MTMacroAtom class]]); XCTAssertEqualObjects(copy.command, @"pod"); - XCTAssertNotEqual(copy.argument, macro.argument); - XCTAssertNotEqual(copy.prefix, macro.prefix); - XCTAssertEqual(copy.prefix.atoms.count, 2ul); - XCTAssertEqual(copy.suffix.atoms.count, 1ul); + XCTAssertNotEqual(copy.arguments[0], macro.arguments[0]); + XCTAssertNotEqual(copy.templateExpression, macro.templateExpression); + XCTAssertEqual(copy.templateExpression.atoms.count, 4ul); XCTAssertNotNil(copy.superScript); - [macro.argument addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; - XCTAssertEqual([copy.argument atoms].count, 1ul); + [macro.arguments[0] addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; + XCTAssertEqual([copy.arguments[0] atoms].count, 1ul); } #pragma mark - Two-phase finalized @@ -260,31 +253,22 @@ - (void)testFinalizedUnchangedForMacroFreeLists #pragma mark - Macro expansion (phase 1) -// \mod's prefix: [Space12, m, o, d (Roman Variables), Space6]. Its suffix is empty. -static MTMathList* ModPrefix(void) +// \mod's template: Space12, m, o, d (Roman Variables), Space6, «#1» -- 6 atoms. +static MTMathList* ModTemplate(void) { - MTMathList* t = [MTMathList new]; - [t addAtom:[[MTMathSpace alloc] initWithSpace:12]]; - for (NSString* ch in @[ @"m", @"o", @"d" ]) { - MTMathAtom* atom = [MTMathAtom atomWithType:kMTMathAtomVariable value:ch]; - atom.fontStyle = kMTFontStyleRoman; - [t addAtom:atom]; - } - [t addAtom:[[MTMathSpace alloc] initWithSpace:6]]; - return t; + return [MTMathListBuilder buildTemplate:@"\\mkern12mu\\mathrm{mod}\\mkern6mu#1"]; } static MTMacroAtom* ModMacroWithArgument(NSString* latex) { return [[MTMacroAtom alloc] initWithCommand:@"mod" - argument:[MTMathListBuilder buildFromString:latex] - prefix:ModPrefix() - suffix:[MTMathList new]]; + arguments:@[ [MTMathListBuilder buildFromString:latex] ] + templateExpression:ModTemplate()]; } // Phase 1 produces RAW atoms — no reclassification yet. \pod{n} -> 4 atoms, the -// argument spliced between prefix and suffix. -- (void)testExpansionSplicesArgumentBetweenPrefixAndSuffix +// argument spliced into the template. +- (void)testExpansionSplicesArgumentIntoTemplate { MTMathList* list = [MTMathList new]; [list addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"x"]]; @@ -304,7 +288,7 @@ - (void)testExpansionSplicesArgumentBetweenPrefixAndSuffix } } -// Expansion must not consume the stored halves or argument: finalizing twice +// Expansion must not consume the stored template or arguments: finalizing twice // gives the same answer. - (void)testExpansionLeavesMacroAtomPristine { @@ -315,9 +299,8 @@ - (void)testExpansionLeavesMacroAtomPristine NSString* first = [MTMathListBuilder mathListToString:list.finalized]; NSString* second = [MTMathListBuilder mathListToString:list.finalized]; XCTAssertEqualObjects(first, second); - XCTAssertEqual(macro.prefix.atoms.count, 2ul); - XCTAssertEqual(macro.suffix.atoms.count, 1ul); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.argument], @"n"); + XCTAssertEqual(macro.templateExpression.atoms.count, 4ul); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.arguments[0]], @"n"); } // A macro nested inside another macro's argument is expanded by the same pass @@ -328,9 +311,8 @@ - (void)testExpansionRecursesIntoNestedMacros MTMathList* outerArg = [MTMathList new]; [outerArg addAtom:inner]; MTMacroAtom* outer = [[MTMacroAtom alloc] initWithCommand:@"pod" - argument:outerArg - prefix:PodPrefix() - suffix:PodSuffix()]; + arguments:@[ outerArg ] + templateExpression:PodTemplate()]; MTMathList* list = [MTMathList new]; [list addAtom:outer]; @@ -377,7 +359,7 @@ - (void)testFinalizedContainsNoMacroAtoms } // Mutating a parsed argument must change what renders, not just what serializes. -// PodPrefix() leads with an 8mu space, and 8 is not one of the named +// PodTemplate() leads with an 8mu space, and 8 is not one of the named // keywords in +[MTMathListBuilder spaceToCommands] (3/4/5/18/36/-3), so // MTMathSpace correctly serializes it as "\mkern8.0mu" rather than being // silently dropped. The plan's expected "(n)"/"(m)" omitted that prefix; the @@ -390,7 +372,7 @@ - (void)testFinalizedTracksArgumentMutation XCTAssertEqualObjects([MTMathListBuilder mathListToString:list.finalized], @"\\mkern8.0mu(n)"); XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pod{n}"); - MTMathList* arg = macro.argument; + MTMathList* arg = macro.arguments[0]; [arg removeAtomAtIndex:0]; [arg addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"m"]]; XCTAssertEqualObjects([MTMathListBuilder mathListToString:list.finalized], @"\\mkern8.0mu(m)"); @@ -495,22 +477,16 @@ - (void)testStopCommandAfterAMacroArgumentStillWorks XCTAssertEqual(table.numRows, 2); } -#pragma mark - Expansion halves as parsed +#pragma mark - Registry templates -// There is no template syntax, so '#' in user input keeps raising the same error it -// always has. +// Registry-sanity coverage (arity / top-level placeholder checks) is added in +// item 6; this pins that every registered template parses. - (void)testEveryRegisteredMacroParses { - // Keeps the registry honest: a malformed half would otherwise only surface as - // MTParseErrorInternalError at invocation. - for (NSString* name in @[ @"pmod", @"mod", @"pod" ]) { - NSError* error = nil; - NSString* latex = [NSString stringWithFormat:@"\\%@{n}", name]; - XCTAssertNotNil([MTMathListBuilder buildFromString:latex error:&error], @"%@", latex); - XCTAssertNil(error, @"%@", latex); - // Macros and symbols stay disjoint surfaces. - XCTAssertFalse([[MTMathAtomFactory supportedLatexSymbolNames] containsObject:name], - @"%@ must not be a symbol too", name); + NSDictionary* macros = [MTMathListBuilder builtinMacros]; + for (NSString* command in macros) { + MTMathList* templateExpression = [MTMathListBuilder buildTemplate:macros[command].templateString]; + XCTAssertNotNil(templateExpression, @"\\%@ template failed to parse", command); } } @@ -526,16 +502,15 @@ - (void)testPmodParsesToASingleMacroAtom XCTAssertEqual(last.type, kMTMathAtomMacro); MTMacroAtom* macro = (MTMacroAtom*)last; XCTAssertEqualObjects(macro.command, @"pmod"); - XCTAssertEqual(macro.argument.atoms.count, 1ul); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.argument], @"n"); - XCTAssertEqual(macro.prefix.atoms.count, 6ul); - XCTAssertEqual(macro.suffix.atoms.count, 1ul); + XCTAssertEqual(macro.arguments.count, 1ul); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.arguments[0]], @"n"); + XCTAssertEqual(macro.templateExpression.atoms.count, 8ul); } - (void)testUnbracedArgument { MTMacroAtom* macro = (MTMacroAtom*)[MTMathListBuilder buildFromString:@"\\pmod n"].atoms[0]; - XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.argument], @"n"); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.arguments[0]], @"n"); } - (void)testEmptyArgumentIsAllowed @@ -543,7 +518,7 @@ - (void)testEmptyArgumentIsAllowed MTMathList* list = [MTMathListBuilder buildFromString:@"\\pmod{}"]; XCTAssertNotNil(list); MTMacroAtom* macro = (MTMacroAtom*)list.atoms[0]; - XCTAssertEqual([macro.argument atoms].count, 0ul); + XCTAssertEqual([macro.arguments[0] atoms].count, 0ul); } // Non-macro commands must be untouched: macroAtomForCommand: returns nil without @@ -723,7 +698,7 @@ - (void)testScriptOnAllSpaceArgumentIsNotDropped } // The argument is parsed under the enclosing font style; the parens and spaces come -// from the prefix, which is parsed by a fresh builder at default style (LLD §6). +// from the template, which is parsed by a fresh builder at default style (LLD §6). - (void)testMacroInsideFontStyleGroup { MTMathList* list = [MTMathListBuilder buildFromString:@"\\mathbf{x \\pmod{n}}"]; @@ -733,9 +708,9 @@ - (void)testMacroInsideFontStyleGroup if (atom.type == kMTMathAtomMacro) { macro = (MTMacroAtom*)atom; break; } } XCTAssertNotNil(macro); - XCTAssertEqual([macro.argument atoms][0].fontStyle, kMTFontStyleBold); - // "mod" stays Roman regardless — it comes from \mathrm in the prefix. - XCTAssertEqual(macro.prefix.atoms[2].fontStyle, kMTFontStyleRoman); + XCTAssertEqual([macro.arguments[0] atoms][0].fontStyle, kMTFontStyleBold); + // "mod" stays Roman regardless — it comes from \mathrm in the template. + XCTAssertEqual(macro.templateExpression.atoms[2].fontStyle, kMTFontStyleRoman); } #pragma mark - Serialization From 3078e5447633359dc2ff2b981d06aac5434a56a2 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Thu, 13 Aug 2026 00:53:22 +0530 Subject: [PATCH 09/11] [item 6] Test multi-argument splice and registry template sanity Co-Authored-By: Claude Opus 5 --- iosMathTests/MTModularArithmeticTest.m | 37 +++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/iosMathTests/MTModularArithmeticTest.m b/iosMathTests/MTModularArithmeticTest.m index a3d0b9e3..243ca6bb 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -479,14 +479,45 @@ - (void)testStopCommandAfterAMacroArgumentStillWorks #pragma mark - Registry templates -// Registry-sanity coverage (arity / top-level placeholder checks) is added in -// item 6; this pins that every registered template parses. +- (void)testTemplateSplicesMultipleArgumentsInOrder +{ + // No built-in macro takes two arguments yet, so drive the splice directly. + // #2 appears before #1 and twice, covering reorder and reuse. + MTMathList* templateExpression = [MTMathListBuilder buildTemplate:@"#2(#1#2"]; + MTMacroAtom* macro = [[MTMacroAtom alloc] initWithCommand:@"test" + arguments:@[ [MTMathListBuilder buildFromString:@"x"], + [MTMathListBuilder buildFromString:@"y"] ] + templateExpression:templateExpression]; + MTMathList* list = [MTMathList new]; + [list addAtom:macro]; + MTMathList* expanded = [list expandMacros]; + XCTAssertEqualObjects(ListSignature(expanded), + ListSignature([MTMathListBuilder buildFromString:@"y(xy"])); +} + - (void)testEveryRegisteredMacroParses { NSDictionary* macros = [MTMathListBuilder builtinMacros]; + XCTAssertEqual(macros.count, 3ul); for (NSString* command in macros) { - MTMathList* templateExpression = [MTMathListBuilder buildTemplate:macros[command].templateString]; + MTMacroDefinition* def = macros[command]; + MTMathList* templateExpression = [MTMathListBuilder buildTemplate:def.templateString]; XCTAssertNotNil(templateExpression, @"\\%@ template failed to parse", command); + // Substitution does not descend into sub-lists, so every declared + // argument must be referenced at the template's top level — a nested #N + // would silently render as a literal "#N". + NSMutableSet* seen = [NSMutableSet set]; + for (MTMathAtom* atom in templateExpression.atoms) { + if ([atom isKindOfClass:[MTMacroParameterAtom class]]) { + NSUInteger index = [(MTMacroParameterAtom*)atom argumentIndex]; + XCTAssertTrue(index >= 1 && index <= def.argumentCount, + @"\\%@ references #%lu beyond its %lu argument(s)", + command, (unsigned long)index, (unsigned long)def.argumentCount); + [seen addObject:@(index)]; + } + } + XCTAssertEqual(seen.count, def.argumentCount, + @"\\%@ template must reference every declared argument at top level", command); } } From 3b5bec0428c513c5869770e97278b5f879c77e98 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Wed, 19 Aug 2026 02:07:57 +0530 Subject: [PATCH 10/11] [review] Add lib/internal to the Xcode header search paths Item 3 added iosMath/lib/internal/MTMacroParameterAtom.h and gave Package.swift a matching search path, but the Xcode project still listed only render/internal. SwiftPM found the header and xcodebuild did not, so CI failed to compile MTModularArithmeticTest.m. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TmCWfSMYeLRJmUEvSd5XoT --- iosMath.xcodeproj/project.pbxproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/iosMath.xcodeproj/project.pbxproj b/iosMath.xcodeproj/project.pbxproj index 300f1644..4215621d 100644 --- a/iosMath.xcodeproj/project.pbxproj +++ b/iosMath.xcodeproj/project.pbxproj @@ -642,6 +642,7 @@ HEADER_SEARCH_PATHS = ( "$(SRCROOT)/iosMath", "$(SRCROOT)/iosMath/lib", + "$(SRCROOT)/iosMath/lib/internal", "$(SRCROOT)/iosMath/render", "$(SRCROOT)/iosMath/render/internal", ); @@ -694,6 +695,7 @@ HEADER_SEARCH_PATHS = ( "$(SRCROOT)/iosMath", "$(SRCROOT)/iosMath/lib", + "$(SRCROOT)/iosMath/lib/internal", "$(SRCROOT)/iosMath/render", "$(SRCROOT)/iosMath/render/internal", ); From 973abce1dd72efdd6c3fc0a124b6bd772baca3e0 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Wed, 19 Aug 2026 02:07:57 +0530 Subject: [PATCH 11/11] [review] Point the macro-by-type throw at the current initializer The reason string still named initWithCommand:argument:prefix:suffix:, which item 5 replaced with initWithCommand:arguments:templateExpression:. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TmCWfSMYeLRJmUEvSd5XoT --- iosMath/lib/MTMathList.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iosMath/lib/MTMathList.m b/iosMath/lib/MTMathList.m index 5c0ca099..1f35ff40 100644 --- a/iosMath/lib/MTMathList.m +++ b/iosMath/lib/MTMathList.m @@ -194,7 +194,7 @@ + (instancetype)atomWithType:(MTMathAtomType)type value:(NSString *)value // The default would mint a plain MTMathAtom carrying type 22 — an atom // that claims to be a macro but cannot expand. @throw [NSException exceptionWithName:@"InvalidMethod" - reason:@"A macro atom cannot be created by type. Use -[MTMacroAtom initWithCommand:argument:prefix:suffix:] instead." + reason:@"A macro atom cannot be created by type. Use -[MTMacroAtom initWithCommand:arguments:templateExpression:] instead." userInfo:nil]; default: