diff --git a/Package.swift b/Package.swift index bc4e509a..67775b70 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,6 @@ let package = Package( cSettings: [ .headerSearchPath("."), .headerSearchPath("lib"), - .headerSearchPath("lib/internal"), .headerSearchPath("render"), .headerSearchPath("render/internal"), ] @@ -37,7 +36,6 @@ let package = Package( cSettings: [ .headerSearchPath("../iosMath"), .headerSearchPath("../iosMath/lib"), - .headerSearchPath("../iosMath/lib/internal"), .headerSearchPath("../iosMath/render"), .headerSearchPath("../iosMath/render/internal"), ] @@ -49,7 +47,6 @@ 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 967807e4..9f0cb1f8 100644 --- a/iosMath.xcodeproj/project.pbxproj +++ b/iosMath.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* 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 */; }; @@ -78,8 +77,6 @@ /* 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 = ""; }; - C01DEC0DE20260726000001 /* MTMacroParameterAtom.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MTMacroParameterAtom.h; 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 = ""; }; @@ -261,7 +258,6 @@ C01DEC0DE20260722000104 /* MTMathUILabelSizingTest.m */, 49B83EF517CF046A0014B739 /* MTMathListTest.m */, 490465BE1D23DA8400F82033 /* MTTypesetterTest.m */, - C01DEC0DE20260726000002 /* MTModularArithmeticTest.m */, 49A1B2C31D23DA8400F82033 /* MTInkWidthTest.m */, 490465C01D23DA8400F82033 /* MTFontManagerTest.m */, 49965F2417CBBA2700A555C5 /* Supporting Files */, @@ -289,7 +285,7 @@ 492EED0317DAEDB500939107 /* MTMathList.m */, 492EED0417DAEDB500939107 /* MTMathListBuilder.h */, 492EECFF17DAEDB500939107 /* MTMathListBuilder.m */, - C01DEC0DE20260726000004 /* internal */, + 49DA6BC319A05F850086B19F /* MTUnicode.h */, 49DA6BC619A062A30086B19F /* MTUnicode.m */, ); path = lib; @@ -347,15 +343,6 @@ name = fonts; sourceTree = ""; }; - C01DEC0DE20260726000004 /* internal */ = { - isa = PBXGroup; - children = ( - C01DEC0DE20260726000001 /* MTMacroParameterAtom.h */, - 49DA6BC319A05F850086B19F /* MTUnicode.h */, - ); - path = internal; - sourceTree = ""; - }; 49EEFD791D19B616002D15C4 /* internal */ = { isa = PBXGroup; children = ( @@ -550,7 +537,6 @@ 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 */, ); @@ -649,7 +635,6 @@ HEADER_SEARCH_PATHS = ( "$(SRCROOT)/iosMath", "$(SRCROOT)/iosMath/lib", - "$(SRCROOT)/iosMath/lib/internal", "$(SRCROOT)/iosMath/render", "$(SRCROOT)/iosMath/render/internal", ); @@ -702,7 +687,6 @@ HEADER_SEARCH_PATHS = ( "$(SRCROOT)/iosMath", "$(SRCROOT)/iosMath/lib", - "$(SRCROOT)/iosMath/lib/internal", "$(SRCROOT)/iosMath/render", "$(SRCROOT)/iosMath/render/internal", ); diff --git a/iosMath/lib/MTMathList.h b/iosMath/lib/MTMathList.h index 9a973d06..2ecf8a0e 100644 --- a/iosMath/lib/MTMathList.h +++ b/iosMath/lib/MTMathList.h @@ -72,12 +72,6 @@ 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 @@ -694,52 +688,6 @@ 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 carries an argument-free `templateExpression` holding `#N` placeholders — - parsed once, at parse time — so it is not a second source of truth for - `arguments`: the expansion is re-derived from (`templateExpression`, current - `arguments`) 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 Templates must be flat with respect to their placeholders (see - `templateExpression`). A composite template such as `\frac{#1}{#2}` cannot be - expressed, since substitution only reaches the top level. The built-in templates - all satisfy this — `\mkern8mu(\mathrm{mod}\mkern6mu#1)` keeps `#1` at the top - level because `(` and `)` are separate atoms — but a future user-facing - `\newcommand` would need substitution to descend into sub-lists first. - */ -@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 golden expansion template: a raw (non-finalized) expression whose `#N` - references are placeholder atoms. Argument-free, and flat with respect to those - placeholders — a `#N` nested inside a sub-list trips an assert in the - initializer. */ -@property (nonatomic, strong, readonly) MTMathList* templateExpression; - -- (instancetype)initWithCommand:(NSString*)command - arguments:(NSArray*)arguments - templateExpression:(MTMathList*)templateExpression NS_DESIGNATED_INITIALIZER; - -/// A macro atom has no valid zero-argument construction — command, arguments and -/// template 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 8106a20d..901916b5 100644 --- a/iosMath/lib/MTMathList.m +++ b/iosMath/lib/MTMathList.m @@ -12,7 +12,6 @@ #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) @@ -71,8 +70,6 @@ static BOOL isNotBinaryOperator(MTMathAtom* prevNode) return @"Box"; case kMTMathAtomOrdGroup: return @"Ord Group"; - case kMTMathAtomMacro: - return @"Macro"; case kMTMathAtomBoundary: return @"Boundary"; case kMTMathAtomSpace: @@ -88,100 +85,6 @@ 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]; -} - -/** YES if `object` is, or transitively holds, an MTMacroParameterAtom. - - Duck-typed on the accessor names the containers in MTMathList.h share, rather - than switched on -type: -innerList alone is declared on nine unrelated classes - with no common protocol, and a container added later that follows the same - naming is covered for free. */ -static BOOL MTContainsMacroParameter(id object) -{ - if ([object isKindOfClass:[MTMacroParameterAtom class]]) { - return YES; - } - if ([object isKindOfClass:[MTMathList class]]) { - for (MTMathAtom* atom in [(MTMathList*)object atoms]) { - if (MTContainsMacroParameter(atom)) { - return YES; - } - } - return NO; - } - if ([object isKindOfClass:[NSArray class]]) { - for (id element in (NSArray*)object) { - if (MTContainsMacroParameter(element)) { - return YES; - } - } - return NO; - } - // Keys and their selectors are both resolved once: this runs per visited object, - // and NSSelectorFromString on every key on every visit is pure overhead. - static NSArray* childKeys = nil; - static SEL* childSelectors = NULL; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - childKeys = @[ @"innerList", @"numerator", @"denominator", @"radicand", - @"degree", @"superScript", @"subScript", @"cells", - @"over", @"under", @"list", @"arguments", - @"templateExpression" ]; - childSelectors = calloc(childKeys.count, sizeof(SEL)); - for (NSUInteger i = 0; i < childKeys.count; i++) { - childSelectors[i] = NSSelectorFromString(childKeys[i]); - } - }); - for (NSUInteger i = 0; i < childKeys.count; i++) { - if ([object respondsToSelector:childSelectors[i]] - && MTContainsMacroParameter([object valueForKey:childKeys[i]])) { - return YES; - } - } - return NO; -} - -/** How deep macro expansion may nest before it is declared runaway. Expansion - recurses once per level of macro-inside-macro-argument nesting, so without a - budget a deep enough chain runs the stack out instead of reporting anything. Real - templates nest one or two levels. */ -static const NSUInteger kMTMaxMacroExpansionDepth = 32; - -@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; - -/** @param depth how many macro expansions are already on the stack. */ -- (MTMathList *)expandMacrosAtDepth:(NSUInteger)depth; - -@end - -@interface MTMacroAtom () - -/** The RAW (non-finalized) atom stream this invocation stands for. */ -- (MTMathList *)expansion; - -/** @param depth how many macro expansions are already on the stack. Throws once - it reaches kMTMaxMacroExpansionDepth. */ -- (MTMathList *)expansionAtDepth:(NSUInteger)depth; - -/** 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; @@ -268,14 +171,7 @@ + (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:templateExpression:] instead." - userInfo:nil]; - + default: return [[MTMathAtom alloc] initWithType:type value:value]; } @@ -1781,30 +1677,13 @@ - (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 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); - NSAssert(![atom isKindOfClass:[MTMacroParameterAtom class]], - @"Macro parameter placeholder %@ escaped a template; expansion must consume it.", - atom.nucleus); + for (MTMathAtom* atom in self.atoms) { MTMathAtom* newNode = [atom finalized]; // Each character is given a separate index. if (NSEqualRanges(zeroRange, atom.indexRange)) { @@ -1849,33 +1728,6 @@ - (MTMathList *)finalized return finalized; } -- (MTMathList *)expandMacros -{ - return [self expandMacrosAtDepth:0]; -} - -- (MTMathList *)expandMacrosAtDepth:(NSUInteger)depth -{ - 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. Same idiom as the MTMacroParameterAtom check above. - 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 expansionAtDepth:depth]]; - } - return expanded; -} - #pragma mark NSCopying // Makes a deep copy of the list @@ -1887,212 +1739,3 @@ - (id)copyWithZone:(NSZone *)zone } @end - -#pragma mark - MTMacroAtom - -@implementation MTMacroAtom - -- (instancetype)initWithCommand:(NSString*)command - arguments:(NSArray*)arguments - templateExpression:(MTMathList*)templateExpression -{ - NSParameterAssert(command); - NSParameterAssert(arguments); - NSParameterAssert(templateExpression); - // -expansion substitutes #N only at the top level of the template, so a - // placeholder buried in a sub-list (\frac{#1}{2}, {#1}, x^{#1}) would survive - // into the finalized list and render as a literal "#1". Catch it here, where the - // mistake was made, rather than letting it surface one recursion level down. - // - // An assert, not an exception: templates are authored in this library, never by - // the person writing the LaTeX, so a nested placeholder is a programming error to - // be caught in testing. If assertions are compiled out, construction proceeds and - // the stray placeholder renders as a visible literal "#1" — wrong, but never - // silently dropped. - for (MTMathAtom* templateAtom in templateExpression.atoms) { - if ([templateAtom isKindOfClass:[MTMacroParameterAtom class]]) { - continue; - } - NSAssert(!MTContainsMacroParameter(templateAtom), - @"Template for \\%@ nests a #N placeholder inside %@; macro templates must keep their argument references at the top level.", - command, NSStringFromClass([templateAtom class])); - } - self = [super initWithType:kMTMathAtomMacro value:@""]; - if (self) { - _command = [command copy]; - _arguments = MTDeepCopyMathListArray(arguments); - _templateExpression = [templateExpression 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 template - // 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:templateExpression: 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 both - // arguments and template, so only the MTMathAtom fields need carrying over. - MTMacroAtom* copy = [[[self class] allocWithZone:zone] initWithCommand:self.command - arguments:self.arguments - templateExpression:self.templateExpression]; - 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 -{ - return [self expansionAtDepth:0]; -} - -- (MTMathList *)expansionAtDepth:(NSUInteger)depth -{ - if (depth >= kMTMaxMacroExpansionDepth) { - // Note this does NOT catch a cyclic atom graph ([macro.arguments[0] - // addAtom:macro]). That overflows in -copyWithZone: before expansion runs, - // exactly as [group.innerList addAtom:group] does for MTMathGroup — every - // container here assumes a tree, which is a broader concern than macros. - @throw [NSException exceptionWithName:@"RunawayMacroExpansion" - reason:[NSString stringWithFormat:@"Expansion of \\%@ exceeded the maximum depth of %lu; check for a macro nested inside its own argument.", self.command, (unsigned long)kMTMaxMacroExpansionDepth] - userInfo:nil]; - } - MTMathList* out = [MTMathList new]; - for (MTMathAtom* templateAtom in self.templateExpression.atoms) { - if (![templateAtom isKindOfClass:[MTMacroParameterAtom class]]) { - // Deep copies throughout, so the stored template and arguments stay - // pristine for serialization, for post-parse mutation, and for repeated - // -finalized calls. - [out addAtom:[templateAtom copy]]; - continue; - } - NSUInteger index = [(MTMacroParameterAtom*)templateAtom argumentIndex]; - if (index < 1 || index > self.arguments.count) { - // Template and invocation disagree on arity — a bug in the built-in macro - // table, not something the LaTeX author can cause, so it asserts. With - // assertions compiled out the placeholder is carried through instead of - // dropped, so the mismatch renders as a visible literal "#2" rather than - // making the argument silently vanish. - NSAssert(NO, @"Macro \\%@ template references #%lu but %lu argument(s) were parsed.", - self.command, (unsigned long)index, (unsigned long)self.arguments.count); - [out addAtom:[templateAtom copy]]; - continue; - } - [out append:[self.arguments[index - 1] copy]]; - } - // The template, or 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 expandMacrosAtDepth:depth + 1]; - [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 - -#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 must not — -finalized asserts), 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/MTUnicode.h b/iosMath/lib/MTUnicode.h similarity index 100% rename from iosMath/lib/internal/MTUnicode.h rename to iosMath/lib/MTUnicode.h diff --git a/iosMath/lib/internal/MTMacroParameterAtom.h b/iosMath/lib/internal/MTMacroParameterAtom.h deleted file mode 100644 index 5fd5d5c9..00000000 --- a/iosMath/lib/internal/MTMacroParameterAtom.h +++ /dev/null @@ -1,33 +0,0 @@ -// -// 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 diff --git a/iosMath/module.modulemap b/iosMath/module.modulemap index e13b9335..2f415206 100644 --- a/iosMath/module.modulemap +++ b/iosMath/module.modulemap @@ -1,6 +1,6 @@ // Explicit module map for iosMath. // Only the intended public headers are listed here. Internal headers -// (MTConfig.h, MTLabel.h, lib/internal/*, render/internal/*, category files) +// (MTConfig.h, MTLabel.h, MTUnicode.h, render/internal/*, category files) // are deliberately excluded and will not appear in the Swift module interface. module iosMath { // Primary rendering API diff --git a/iosMath/render/internal/MTTypesetter.m b/iosMath/render/internal/MTTypesetter.m index e55a43a1..a448d22e 100644 --- a/iosMath/render/internal/MTTypesetter.m +++ b/iosMath/render/internal/MTTypesetter.m @@ -12,7 +12,7 @@ #import "MTFont+Internal.h" #import "MTFontManager.h" #import "MTMathListDisplayInternal.h" -#import "MTUnicode.h" +#import "../../lib/MTUnicode.h" #pragma mark Inter Element Spacing @@ -616,9 +616,7 @@ - (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/MTModularArithmeticTest.m b/iosMathTests/MTModularArithmeticTest.m index 9bd15d80..f4efd9d6 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -3,6 +3,7 @@ // iosMath // // Tests for \bmod, \pmod, \mod, \pod. +// Design: docs/lld/2026-07-13-modular-arithmetic.md // #import @@ -16,21 +17,11 @@ #import "MTFontManager.h" #import "MTMathListDisplay.h" #import "MTMathListDisplayInternal.h" -#import "internal/MTMacroParameterAtom.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); - @implementation MTModularArithmeticTest - (void)setUp @@ -148,758 +139,4 @@ - (void)testDemotedBmodSerializes XCTAssertEqualObjects([MTMathListBuilder mathListToString:finalized], @"\\bmod 5"); } -#pragma mark - MTMacroParameterAtom - -- (void)testMacroParameterAtomBasics -{ - MTMacroParameterAtom* p = [[MTMacroParameterAtom alloc] initWithArgumentIndex:3]; - XCTAssertEqual(p.argumentIndex, 3ul); - // Type stays Ordinary: the placeholder is a sentinel that never survives - // expansion, so it deliberately adds no value to the public MTMathAtomType enum. - XCTAssertEqual(p.type, kMTMathAtomOrdinary); - XCTAssertEqualObjects(p.nucleus, @"#3"); -} - -- (void)testMacroParameterAtomCopyPreservesIndex -{ - MTMacroParameterAtom* p = [[MTMacroParameterAtom alloc] initWithArgumentIndex:1]; - MTMacroParameterAtom* copy = [p copy]; - XCTAssertTrue([copy isKindOfClass:[MTMacroParameterAtom class]]); - XCTAssertEqual(copy.argumentIndex, 1ul); - XCTAssertEqualObjects(copy.nucleus, @"#1"); -} - -// A template list is deep-copied wholesale during expansion, so the placeholder -// must survive MTMathList's deep copy too (MTMathList.m:216-220 copies items). -- (void)testMacroParameterAtomSurvivesListCopy -{ - MTMathList* list = [MTMathList new]; - [list addAtom:[[MTMacroParameterAtom alloc] initWithArgumentIndex:2]]; - MTMathList* copy = [list copy]; - MTMathAtom* copied = copy.atoms[0]; - XCTAssertTrue([copied isKindOfClass:[MTMacroParameterAtom class]]); - XCTAssertEqual([(MTMacroParameterAtom*)copied argumentIndex], 2ul); -} - -#pragma mark - MTMacroAtom - -// Builds the golden template list for \pod: [Space8, Open "(", #1, Close ")"]. -// Hand-built so PR 2 is independent of the parser (which lands in PR 3). -static MTMathList* PodTemplate(void) -{ - MTMathList* t = [MTMathList new]; - [t addAtom:[[MTMathSpace alloc] initWithSpace:8]]; - [t addAtom:[MTMathAtom atomWithType:kMTMathAtomOpen value:@"("]]; - [t addAtom:[[MTMacroParameterAtom alloc] initWithArgumentIndex:1]]; - [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 ] - templateExpression:PodTemplate()]; -} - -- (void)testMacroAtomBasics -{ - MTMacroAtom* macro = PodMacroWithArgument(@"n"); - XCTAssertEqual(macro.type, kMTMathAtomMacro); - XCTAssertEqualObjects(macro.command, @"pod"); - XCTAssertEqual(macro.arguments.count, 1ul); - XCTAssertEqual(macro.templateExpression.atoms.count, 4ul); - // 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* templ = PodTemplate(); - MTMacroAtom* macro = [[MTMacroAtom alloc] initWithCommand:@"pod" - arguments:@[ arg ] - templateExpression:templ]; - [arg addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; - [templ addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; - - 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 -{ - 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.templateExpression, macro.templateExpression); - XCTAssertEqual(copy.templateExpression.atoms.count, 4ul); - 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]); -} - -// -expansion substitutes #N only at the top level, so a nested placeholder would -// otherwise reach the finalized list and render as a literal "#1". Caught by an -// assert at construction, which raises in this (assertions-enabled) build. -- (void)testMacroAtomRejectsNestedPlaceholderInTemplate -{ - MTMathGroup* group = [[MTMathGroup alloc] init]; - group.innerList = [MTMathList new]; - [group.innerList addAtom:[[MTMacroParameterAtom alloc] initWithArgumentIndex:1]]; - MTMathList* nested = [MTMathList new]; - [nested addAtom:group]; - XCTAssertThrows([[MTMacroAtom alloc] initWithCommand:@"bad" - arguments:@[ [MTMathListBuilder buildFromString:@"n"] ] - templateExpression:nested]); - - // Also below a script, and below a fraction. - MTFraction* frac = [[MTFraction alloc] init]; - frac.numerator = [MTMathList new]; - [frac.numerator addAtom:[[MTMacroParameterAtom alloc] initWithArgumentIndex:1]]; - frac.denominator = [MTMathListBuilder buildFromString:@"2"]; - MTMathList* nestedFrac = [MTMathList new]; - [nestedFrac addAtom:frac]; - XCTAssertThrows([[MTMacroAtom alloc] initWithCommand:@"bad" - arguments:@[ [MTMathListBuilder buildFromString:@"n"] ] - templateExpression:nestedFrac]); - - MTMathAtom* scripted = [MTMathAtom atomWithType:kMTMathAtomVariable value:@"x"]; - scripted.superScript = [MTMathList new]; - [scripted.superScript addAtom:[[MTMacroParameterAtom alloc] initWithArgumentIndex:1]]; - MTMathList* nestedScript = [MTMathList new]; - [nestedScript addAtom:scripted]; - XCTAssertThrows([[MTMacroAtom alloc] initWithCommand:@"bad" - arguments:@[ [MTMathListBuilder buildFromString:@"n"] ] - templateExpression:nestedScript]); -} - -// A top-level #N is exactly what the initializer must accept. -- (void)testMacroAtomAcceptsTopLevelPlaceholder -{ - XCTAssertNoThrow(PodMacroWithArgument(@"n")); -} - -// 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 ] - templateExpression:PodTemplate()]; - } - return macro; -} - -// Expansion recurses once per nesting level, so an absurdly deep chain must fail -// loud rather than run the stack out. -- (void)testRunawayExpansionDepthThrows -{ - MTMathList* deep = [MTMathList new]; - [deep addAtom:NestedPodChain(64)]; - XCTAssertThrows([deep finalized]); - - // Realistic nesting stays well inside the budget. - MTMathList* shallow = [MTMathList new]; - [shallow addAtom:NestedPodChain(8)]; - XCTAssertNoThrow([shallow finalized]); - - // Pin the boundary itself: the outermost macro expands at depth 0, so a chain of - // exactly kMTMaxMacroExpansionDepth (32) reaches depth 31 and is allowed, while - // 33 reaches the limit and fails. - MTMathList* atLimit = [MTMathList new]; - [atLimit addAtom:NestedPodChain(32)]; - XCTAssertNoThrow([atLimit finalized]); - - MTMathList* pastLimit = [MTMathList new]; - [pastLimit addAtom:NestedPodChain(33)]; - XCTAssertThrows([pastLimit finalized]); -} - -// An arity mismatch between template and invocation is a bug in the macro table, -// so it asserts (which raises here, where assertions are enabled). Builds with -// NS_BLOCK_ASSERTIONS keep the placeholder instead, rendering a literal "#2". -- (void)testExpansionRejectsOutOfRangeArgumentIndex -{ - MTMathList* templ = [MTMathList new]; - [templ addAtom:[[MTMacroParameterAtom alloc] initWithArgumentIndex:2]]; - MTMacroAtom* macro = [[MTMacroAtom alloc] initWithCommand:@"arity" - arguments:@[ [MTMathListBuilder buildFromString:@"n"] ] - templateExpression:templ]; - MTMathList* list = [MTMathList new]; - [list addAtom:macro]; - XCTAssertThrows([list finalized]); -} - -// \noargs with nothing to terminate the command name would re-parse as \noargsx. -- (void)testZeroArgumentMacroSerializesWithSeparator -{ - MTMathList* templ = [MTMathListBuilder buildFromString:@"1"]; - MTMacroAtom* macro = [[MTMacroAtom alloc] initWithCommand:@"noargs" - arguments:@[] - templateExpression:templ]; - 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) - -// The \mod template: [Space12, m, o, d (Roman Variables), Space6, #1]. -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]]; - [t addAtom:[[MTMacroParameterAtom alloc] initWithArgumentIndex:1]]; - return t; -} - -static MTMacroAtom* ModMacroWithArgument(NSString* latex) -{ - return [[MTMacroAtom alloc] initWithCommand:@"mod" - arguments:@[ [MTMathListBuilder buildFromString:latex] ] - templateExpression:ModTemplate()]; -} - -// Phase 1 produces RAW atoms — no reclassification yet. \pod{n} -> 4 atoms with the -// placeholder replaced by a copy of the argument. -- (void)testExpansionSplicesArgumentIntoPlaceholder -{ - 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) { - XCTAssertFalse([atom isKindOfClass:[MTMacroParameterAtom class]]); - 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 template 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.templateExpression.atoms.count, 4ul); - XCTAssertTrue([macro.templateExpression.atoms[2] isKindOfClass:[MTMacroParameterAtom class]]); - 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 ] - templateExpression:PodTemplate()]; - 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); - XCTAssertFalse([atom isKindOfClass:[MTMacroParameterAtom class]]); - } -} - -// Mutating a parsed argument must change what renders, not just what serializes. -// 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 -// 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 -// templates (all end in a scriptable atom), but a user-defined \newcommand would -// make it reachable, so it is covered here with a spaces-only template. -- (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:@[] - templateExpression:spacesOnly]; - 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 ModTemplate() 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:@""]); -} - @end