From 5ef3a0da8878648e5f0364581d1d806f4c4a8b7f Mon Sep 17 00:00:00 2001 From: Kostub D Date: Fri, 21 Aug 2026 07:10:24 +0530 Subject: [PATCH 1/9] [item 4] Add the raw macro-argument scanner and expansion helpers Adds -readRawArgument (the math-mode sibling of -readTextArgument), -spliceTemplate:arguments:, and -parseExpansion:forCommand:, plus the _macroExpansionDepth ivar and kMTMaxMacroExpansionDepth cap. Nothing calls these yet; item 5 wires them into -macroAtomForCommand:. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CR3o91FfsodPmwJD1s8499 --- iosMath/lib/MTMathListBuilder.m | 96 +++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/iosMath/lib/MTMathListBuilder.m b/iosMath/lib/MTMathListBuilder.m index ddada1e3..8e826fd1 100644 --- a/iosMath/lib/MTMathListBuilder.m +++ b/iosMath/lib/MTMathListBuilder.m @@ -47,6 +47,10 @@ - (instancetype)initWithName:(NSString*) name // far below the thousands of frames needed to overflow a 1 MB stack. static const NSInteger kMTMaxRecursionDepth = 150; +// Separate from the parse-depth cap because a macro level costs a whole builder +// and a spliced string, not one stack frame. +static const NSInteger kMTMaxMacroExpansionDepth = 32; + // 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 () @@ -62,6 +66,7 @@ @implementation MTMathListBuilder { MTFontStyle _currentFontStyle; BOOL _spacesAllowed; NSInteger _recursionDepth; + NSInteger _macroExpansionDepth; 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 @@ -698,6 +703,97 @@ - (NSString*) readTextArgument return nil; } +// The math-mode sibling of -readTextArgument: reads one macro argument as source +// text without parsing it. Nothing is unescaped and no inner brace is dropped — +// whatever this returns gets spliced into a template and handed back to a parser. +// The caller has already skipped spaces and confirmed a character is available. +- (nullable NSString*) readRawArgument +{ + unichar first = [self getNextCharacter]; + if (first == '\\') { + return [@"\\" stringByAppendingString:[self readCommand]]; + } + if (first != '{') { + NSMutableString* token = [NSMutableString stringWithCharacters:&first length:1]; + if (first >= 0xD800 && first <= 0xDBFF && [self hasCharacters]) { + unichar low = [self getNextCharacter]; + if (low >= 0xDC00 && low <= 0xDFFF) { + [token appendFormat:@"%C", low]; + } else { + [self unlookCharacter]; + } + } + return token; + } + NSMutableString* body = [NSMutableString string]; + NSInteger depth = 0; + while ([self hasCharacters]) { + unichar c = [self getNextCharacter]; + if (c == '\\') { + if (![self hasCharacters]) { + [self setError:MTParseErrorMismatchBraces + message:@"Trailing \\ in a macro argument"]; + return nil; + } + // Both characters go through untouched, so \{ and \} leave depth alone. + [body appendFormat:@"%C%C", c, [self getNextCharacter]]; + continue; + } + if (c == '}') { + if (depth == 0) { + return body; + } + depth -= 1; + } else if (c == '{') { + depth += 1; + } + [body appendFormat:@"%C", c]; + } + [self setError:MTParseErrorMismatchBraces message:@"Unmatched { in a macro argument"]; + return nil; +} + +- (NSString*) spliceTemplate:(NSString*) templateString + arguments:(NSArray*) rawArguments +{ + NSMutableString* out = [NSMutableString stringWithCapacity:templateString.length]; + NSUInteger length = templateString.length; + for (NSUInteger i = 0; i < length; i++) { + unichar c = [templateString characterAtIndex:i]; + if (c != '#' || i + 1 >= length) { + [out appendFormat:@"%C", c]; + continue; + } + unichar digit = [templateString characterAtIndex:i + 1]; + if (digit < '1' || digit > '9' || (NSUInteger)(digit - '0') > rawArguments.count) { + // Rejected by the assertion in +addMacro:. With assertions compiled out + // the # survives here and the expansion fails to parse, which is loud. + [out appendFormat:@"%C", c]; + continue; + } + [out appendString:rawArguments[digit - '1']]; + i++; + } + return out; +} + +// A fresh builder, so the in-flight parse's state is never disturbed. Only the +// font style is carried across; _currentEnv and _currentInnerAtom are not, so a +// template that opens a group it does not close cannot parse (LLD §6). +- (nullable MTMathList*) parseExpansion:(NSString*) spliced forCommand:(NSString*) command +{ + MTMathListBuilder* builder = [[MTMathListBuilder alloc] initWithString:spliced]; + builder->_currentFontStyle = _currentFontStyle; + builder->_macroExpansionDepth = _macroExpansionDepth + 1; + MTMathList* expansion = [builder build]; + if (!expansion) { + [self setError:(MTParseErrors)builder.error.code + message:[NSString stringWithFormat:@"Expansion of \\%@ failed to parse: %@", + command, builder.error.localizedDescription]]; + } + return expansion; +} + - (NSString*) readColor { if (![self expectCharacter:'{']) { From 2828cc094332cfb8543f8f5c0137f0df087ff6f3 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Fri, 21 Aug 2026 07:13:27 +0530 Subject: [PATCH 2/9] [item 5] Expand macros by splicing raw argument text into the template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MTMacroAtom now stores rawExpansion (the parsed expansion, arguments already substituted) instead of a parsed template plus placeholder atoms. -macroAtomForCommand: collects each argument's raw source text, splices it into the macro's template string, and parses the spliced result once via -parseExpansion:forCommand: — so #N works anywhere in a template, not just at its top level. Deletes MTMacroParameterAtom, template mode, and +buildTemplate:, all now unnecessary. swift build is clean but swift test does not compile yet: the macro tests in MTModularArithmeticTest.m still call the old initWithCommand:arguments:templateExpression: initializer. Item 6 adapts them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CR3o91FfsodPmwJD1s8499 --- Package.swift | 3 - iosMath.xcodeproj/project.pbxproj | 3 - iosMath/lib/MTMathList.h | 32 +++---- iosMath/lib/MTMathList.m | 92 +++++---------------- iosMath/lib/MTMathListBuilder.m | 62 ++++---------- iosMath/lib/internal/MTMacroParameterAtom.h | 33 -------- 6 files changed, 50 insertions(+), 175 deletions(-) delete mode 100644 iosMath/lib/internal/MTMacroParameterAtom.h 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 1b2ad4e6..0bbd4217 100644 --- a/iosMath.xcodeproj/project.pbxproj +++ b/iosMath.xcodeproj/project.pbxproj @@ -97,7 +97,6 @@ 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; }; @@ -294,12 +293,10 @@ 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.h b/iosMath/lib/MTMathList.h index cd4a206f..20f01dbe 100644 --- a/iosMath/lib/MTMathList.h +++ b/iosMath/lib/MTMathList.h @@ -693,18 +693,14 @@ typedef NS_ENUM(NSUInteger, MTStrikeStyle) { @end -/** An unexpanded macro invocation. +/** A macro invocation, with its expansion already computed. - `\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. - - `#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. + `\pmod{n}` parses to exactly one `MTMacroAtom`. The expansion is computed once, + when the invocation is parsed: each argument's source text is spliced into the + template string and the result is parsed, so a `#N` anywhere in the template — + inside `\hat{#1}`, or carrying a script as in `#1^{#2}` — lands exactly where the + author would have typed it. `arguments` keeps each argument's source text only so + the invocation can be serialized back to `\command{…}` verbatim. @note Only `-[MTMathList finalized]` expands. `-[MTMacroAtom finalized]` on a lone atom returns another macro atom. @@ -714,17 +710,15 @@ typedef NS_ENUM(NSUInteger, MTStrikeStyle) { /** The command name without the leading backslash, e.g. `@"pmod"`. */ @property (nonatomic, copy, readonly) NSString* command; -/** 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; +/** Each argument's source text in invocation order, with the outer braces off. */ +@property (nonatomic, copy, readonly) NSArray* arguments; -/** The golden expansion template: a raw, argument-free list whose `#N` - references are internal placeholder atoms. */ -@property (nonatomic, strong, readonly) MTMathList* templateExpression; +/** The parsed expansion: raw (non-finalized), arguments already substituted. */ +@property (nonatomic, strong, readonly) MTMathList* rawExpansion; - (instancetype)initWithCommand:(NSString*)command - arguments:(NSArray*)arguments - templateExpression:(MTMathList*)templateExpression NS_DESIGNATED_INITIALIZER; + arguments:(NSArray*)arguments + rawExpansion:(MTMathList*)rawExpansion 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 a4c250fe..24c71f70 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) @@ -194,7 +193,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:arguments:templateExpression:] instead." + reason:@"A macro atom cannot be created by type. Use -[MTMacroAtom initWithCommand:arguments:rawExpansion:] instead." userInfo:nil]; default: @@ -1795,18 +1794,18 @@ - (id)copyWithZone:(NSZone *)zone @implementation MTMacroAtom - (instancetype)initWithCommand:(NSString*)command - arguments:(NSArray*)arguments - templateExpression:(MTMathList*)templateExpression + arguments:(NSArray*)arguments + rawExpansion:(MTMathList*)rawExpansion { NSParameterAssert(command); NSParameterAssert(arguments); - NSParameterAssert(templateExpression); + NSParameterAssert(rawExpansion); self = [super initWithType:kMTMathAtomMacro value:@""]; if (self) { _command = [command copy]; - // copyItems gives a deep copy: MTMathList's -copyWithZone: is deep. - _arguments = [[NSArray alloc] initWithArray:arguments copyItems:YES]; - _templateExpression = [templateExpression copy]; + // The strings are immutable, so a plain array copy is already deep. + _arguments = [arguments copy]; + _rawExpansion = [rawExpansion copy]; } return self; } @@ -1815,18 +1814,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:arguments:templateExpression: instead." + reason:@"[MTMacroAtom initWithType:value:] cannot be called. Use -initWithCommand:arguments:rawExpansion: instead." userInfo:nil]; } - (id)copyWithZone:(NSZone *)zone { // Not [super copyWithZone:], which would call the throwing -initWithType:value:. - // The designated initializer deep-copies arguments and template, so only the + // The designated initializer copies arguments and expansion, so only the // MTMathAtom fields need carrying over. MTMacroAtom* copy = [[[self class] allocWithZone:zone] initWithCommand:self.command arguments:self.arguments - templateExpression:self.templateExpression]; + rawExpansion:self.rawExpansion]; copy.subScript = [self.subScript copyWithZone:zone]; copy.superScript = [self.superScript copyWithZone:zone]; copy.indexRange = self.indexRange; @@ -1837,8 +1836,8 @@ - (id)copyWithZone:(NSZone *)zone - (NSString *)stringValue { NSMutableString* str = [NSMutableString stringWithFormat:@"\\%@", self.command]; - for (MTMathList* arg in self.arguments) { - [str appendFormat:@"{%@}", arg.stringValue]; + for (NSString* arg in self.arguments) { + [str appendFormat:@"{%@}", arg]; } if (self.superScript) { [str appendFormat:@"^{%@}", self.superScript.stringValue]; @@ -1851,46 +1850,25 @@ - (NSString *)stringValue - (void)appendLaTeXToString:(NSMutableString *)str { - // Command-faithful, argument-canonical: arguments are re-serialized by the - // usual serializer. +mathListToString: appends the ^{…}/_{…} tail. + // Command-faithful and exact: each argument is emitted as the author wrote it. + // +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]]; + for (NSString* arg in self.arguments) { + [str appendFormat:@"{%@}", arg]; } } - (MTMathList *)expansion { - // 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]; + // The copy is load-bearing: -expandMacros carries non-macro atoms across by + // reference, and transferScripts mutates one of them. Without it, a repeated + // -finalized would find scripts already hung on the stored expansion. + MTMathList* flat = [[self.rawExpansion copy] expandMacros]; [self transferScriptsToExpansion:flat]; return flat; } @@ -1925,33 +1903,3 @@ - (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/MTMathListBuilder.m b/iosMath/lib/MTMathListBuilder.m index 8e826fd1..531fa5d6 100644 --- a/iosMath/lib/MTMathListBuilder.m +++ b/iosMath/lib/MTMathListBuilder.m @@ -11,7 +11,6 @@ #import "MTMathListBuilder.h" #import "MTMathAtomFactory.h" -#import "MTMacroParameterAtom.h" NSString *const MTParseError = @"ParseError"; @@ -51,12 +50,6 @@ - (instancetype)initWithName:(NSString*) name // and a spliced string, not one stack frame. static const NSInteger kMTMaxMacroExpansionDepth = 32; -// 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; @@ -67,7 +60,6 @@ @implementation MTMathListBuilder { BOOL _spacesAllowed; NSInteger _recursionDepth; NSInteger _macroExpansionDepth; - 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 @@ -167,11 +159,11 @@ - (BOOL) readOptionalAlignment:(MTFractionAlignment*)outAlignment return YES; } -// -buildInternal:YES on its own is silently permissive: at EOF it returns an empty +// -readRawArgument on its own is silently permissive: at EOF it returns an empty // 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 +- (nullable NSString *)rawArgumentWithError:(MTParseErrors)error { [self skipSpaces]; if (![self hasCharacters]) { @@ -198,7 +190,7 @@ - (nullable MTMathList *)requiredArgumentWithError:(MTParseErrors)error } } // An empty {} is a valid, empty argument (LaTeX parity) — not a missing one. - return [self buildInternal:YES]; + return [self readRawArgument]; } // Restores the read position, so the caller can dispatch without consuming. Nil if @@ -479,18 +471,6 @@ - (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) { @@ -1238,32 +1218,33 @@ - (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 -// it is a macro whose arguments failed to parse. +// it is a macro whose arguments or expansion failed to parse. - (nullable MTMacroAtom*) macroAtomForCommand:(NSString*) command { MTMacroDefinition* def = [MTMathAtomFactory macroDefinitionForCommand:command]; if (!def) { return nil; } - NSMutableArray* arguments = [NSMutableArray arrayWithCapacity:def.argumentCount]; + if (_macroExpansionDepth >= kMTMaxMacroExpansionDepth) { + [self setError:MTParseErrorNestingTooDeep message:@"Macro expansion nested too deep"]; + return nil; + } + NSMutableArray* arguments = [NSMutableArray arrayWithCapacity:def.argumentCount]; for (NSUInteger i = 0; i < def.argumentCount; i++) { - MTMathList* argument = [self requiredArgumentWithError:MTParseErrorMissingArgument]; + NSString* argument = [self rawArgumentWithError:MTParseErrorMissingArgument]; if (!argument) { return nil; // _error already set } [arguments addObject:argument]; } - // A fresh builder, so the in-flight parse's state is never disturbed. - MTMathList* templateExpression = [MTMathListBuilder buildTemplate:def.templateString]; - if (!templateExpression) { - // Reachable from a template registered through +addMacro:, so this is the - // caller's error, not the library's. - [self setError:MTParseErrorInvalidCommand - message:[NSString stringWithFormat:@"Template for \\%@ failed to parse", command]]; - return nil; + NSString* spliced = [self spliceTemplate:def.templateString arguments:arguments]; + MTMathList* expansion = [self parseExpansion:spliced forCommand:command]; + if (!expansion) { + return nil; // _error already set } - return [[MTMacroAtom alloc] initWithCommand:command arguments:arguments - templateExpression:templateExpression]; + return [[MTMacroAtom alloc] initWithCommand:command + arguments:arguments + rawExpansion:expansion]; } - (MTMathAtom*) atomForCommand:(NSString*) command @@ -1910,15 +1891,6 @@ + (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/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 From 88130f21bee7bce87c31fcbe67547bbba2634838 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Fri, 21 Aug 2026 07:21:35 +0530 Subject: [PATCH 3/9] [item 6] Adapt the macro tests to the spliced representation MTMacroAtom now stores raw argument text and a parsed rawExpansion instead of parsed argument lists and a template with placeholder atoms, so the macro test suite is rewritten to match: helper functions that built the old template/argument shapes are gone, assertions read macro.arguments[N] as plain strings and macro.rawExpansion instead of macro.templateExpression, and tests that exercised buildTemplate:/ MTMacroParameterAtom directly are removed since that machinery no longer exists. Two expected serializations change to reflect that argument text is now preserved verbatim rather than re-derived from a parsed sub-list: \mod{ n } round-trips with its original whitespace, and \pmod{\frac} serializes back to itself instead of \pmod{\frac{}{}}. --- iosMathTests/MTMathListBuilderTest.m | 15 +- iosMathTests/MTModularArithmeticTest.m | 232 ++++++++----------------- 2 files changed, 77 insertions(+), 170 deletions(-) diff --git a/iosMathTests/MTMathListBuilderTest.m b/iosMathTests/MTMathListBuilderTest.m index 9a10fe9b..e3378a0d 100644 --- a/iosMathTests/MTMathListBuilderTest.m +++ b/iosMathTests/MTMathListBuilderTest.m @@ -1719,13 +1719,14 @@ - (void) testAlignedatWhitespaceArgument @[@"\\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 + // operands is not an error at all: `readRawArgument` captures the + // argument as raw, unparsed text ("\frac"), so `\frac`'s own operands + // aren't read here at all — that only happens once the raw text is + // spliced into \pmod's template and the whole thing is parsed + // together, and by then `\frac` sees no operands of its own, + // degrading the same way bare top-level `\frac` does. `\pmod{\frac}` + // therefore parses successfully and serializes back as `\pmod{\frac}` + // — verified directly; see // -testFracWithNoArgumentsIsNotAnErrorInsideMacroArgument in // MTModularArithmeticTest.m. Swapped in a genuinely malformed // argument (an unbalanced brace) that does propagate diff --git a/iosMathTests/MTModularArithmeticTest.m b/iosMathTests/MTModularArithmeticTest.m index e210bf3e..32ae6d61 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -17,7 +17,6 @@ #import "MTMathListDisplay.h" #import "MTMathListDisplayInternal.h" #import "MTFontMathTable.h" -#import "MTMacroParameterAtom.h" @interface MTModularArithmeticTest : XCTestCase @property (nonatomic) MTFont* font; @@ -29,12 +28,6 @@ @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); @@ -157,51 +150,30 @@ - (void)testDemotedBmodSerializes #pragma mark - MTMacroAtom -// \pod's template: Space8, Open "(", «#1», Close ")" -- 4 atoms. -static MTMathList* PodTemplate(void) -{ - return [MTMathListBuilder buildTemplate:@"\\mkern8mu(#1)"]; -} - -static MTMacroAtom* PodMacroWithArgument(NSString* latex) +// NSArray's -copy is shallow. The initializer must deep-copy the expansion, or a +// caller can mutate the list it handed in and silently mutate the atom. +- (void)testMacroAtomDeepCopiesExpansionAtInit { - MTMathList* arg = [MTMathListBuilder buildFromString:latex]; - return [[MTMacroAtom alloc] initWithCommand:@"pod" - arguments:@[ arg ] - templateExpression:PodTemplate()]; -} - -// 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* templateExpression = PodTemplate(); + MTMathList* expansion = [MTMathListBuilder buildFromString:@"\\mkern8mu(n)"]; MTMacroAtom* macro = [[MTMacroAtom alloc] initWithCommand:@"pod" - arguments:@[ arg ] - templateExpression:templateExpression]; - [arg addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; - [templateExpression addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; + arguments:@[ @"n" ] + rawExpansion:expansion]; + [expansion 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"); + XCTAssertEqual(macro.rawExpansion.atoms.count, 4ul, @"expansion was not deep-copied"); } - (void)testMacroAtomCopyIsDeep { - MTMacroAtom* macro = PodMacroWithArgument(@"n"); + MTMacroAtom* macro = (MTMacroAtom*)[MTMathListBuilder buildFromString:@"\\pod{n}"].atoms[0]; 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); + XCTAssertNotEqual(copy.rawExpansion, macro.rawExpansion); + XCTAssertEqual(copy.rawExpansion.atoms.count, 4ul); XCTAssertNotNil(copy.superScript); - - [macro.arguments[0] addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"z"]]; - XCTAssertEqual([copy.arguments[0] atoms].count, 1ul); } #pragma mark - Two-phase finalized @@ -243,27 +215,11 @@ - (void)testFinalizedUnchangedForMacroFreeLists #pragma mark - Macro expansion (phase 1) -// \mod's template: Space12, m, o, d (Roman Variables), Space6, «#1» -- 6 atoms. -static MTMathList* ModTemplate(void) -{ - return [MTMathListBuilder buildTemplate:@"\\mkern12mu\\mathrm{mod}\\mkern6mu#1"]; -} - -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, the // argument spliced into the template. - (void)testExpansionSplicesArgumentIntoTemplate { - MTMathList* list = [MTMathList new]; - [list addAtom:[MTMathAtom atomWithType:kMTMathAtomVariable value:@"x"]]; - [list addAtom:PodMacroWithArgument(@"n")]; - + MTMathList* list = [MTMathListBuilder buildFromString:@"x\\pod{n}"]; MTMathList* expanded = [list expandMacros]; XCTAssertEqual(expanded.atoms.count, 5ul); XCTAssertEqualObjects([expanded.atoms[0] nucleus], @"x"); @@ -278,34 +234,25 @@ - (void)testExpansionSplicesArgumentIntoTemplate } } -// Expansion must not consume the stored template or arguments: finalizing twice +// Expansion must not consume the stored expansion or arguments: finalizing twice // gives the same answer. - (void)testExpansionLeavesMacroAtomPristine { - MTMacroAtom* macro = PodMacroWithArgument(@"n"); - MTMathList* list = [MTMathList new]; - [list addAtom:macro]; + MTMathList* list = [MTMathListBuilder buildFromString:@"\\pod{n}"]; + MTMacroAtom* macro = (MTMacroAtom*)list.atoms[0]; NSString* first = [MTMathListBuilder mathListToString:list.finalized]; NSString* second = [MTMathListBuilder mathListToString:list.finalized]; XCTAssertEqualObjects(first, second); - XCTAssertEqual(macro.templateExpression.atoms.count, 4ul); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.arguments[0]], @"n"); + XCTAssertEqual(macro.rawExpansion.atoms.count, 4ul); + XCTAssertEqualObjects(macro.arguments[0], @"n"); } -// 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. +// 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* list = [MTMathListBuilder buildFromString:@"\\pod{\\pod{n}}"]; MTMathList* expanded = [list expandMacros]; // Space8 ( Space8 ( n ) ) XCTAssertEqual(expanded.atoms.count, 7ul); @@ -318,12 +265,7 @@ - (void)testExpansionRecursesIntoNestedMacros // 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]; + MTMathList* list = [MTMathListBuilder buildFromString:@"\\frac{\\pod{n}}{2}"]; // Phase 1 alone leaves the macro sitting in the numerator. MTFraction* rawFrac = (MTFraction*)[list expandMacros].atoms[0]; @@ -340,45 +282,19 @@ - (void)testExpansionDoesNotDescendButFinalizedStillExpandsNested // 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")]; + MTMathList* list = [MTMathListBuilder buildFromString:@"x\\mod{n}"]; for (MTMathAtom* atom in list.finalized.atoms) { XCTAssertNotEqual(atom.type, kMTMathAtomMacro); } } -// 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)"); - 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.finalized], @"\\mkern8.0mu(m)"); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pod{m}"); -} - #pragma mark - Script transfer // 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]; + MTMathList* list = [MTMathListBuilder buildFromString:@"\\pod{n}^{2}"]; NSString* first = [MTMathListBuilder mathListToString:list.finalized]; NSString* second = [MTMathListBuilder mathListToString:list.finalized]; @@ -471,15 +387,10 @@ - (void)testStopCommandAfterAMacroArgumentStillWorks - (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]; + // No built-in macro takes two arguments yet, so register one to exercise the + // splice. #2 appears before #1 and twice, covering reorder and reuse. + [MTMathAtomFactory addMacro:@"reorder" argumentCount:2 template:@"#2(#1#2"]; + MTMathList* list = [MTMathListBuilder buildFromString:@"\\reorder{x}{y}"]; MTMathList* expanded = [list expandMacros]; XCTAssertEqualObjects(ListSignature(expanded), ListSignature([MTMathListBuilder buildFromString:@"y(xy"])); @@ -495,20 +406,31 @@ - (void)testEveryRegisteredMacroParses @"varprojlim" ]) { MTMacroDefinition* def = [MTMathAtomFactory macroDefinitionForCommand:command]; XCTAssertNotNil(def, @"\\%@ is not registered", 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". + NSMutableString* latex = [NSMutableString stringWithFormat:@"\\%@", command]; + for (NSUInteger i = 0; i < def.argumentCount; i++) { + [latex appendString:@"{x}"]; + } + MTMathList* list = [MTMathListBuilder buildFromString:latex]; + XCTAssertNotNil(list, @"%@", latex); + XCTAssertNoThrow([list finalized], @"\\%@ expansion failed to parse", command); + + // An argument the template never mentions is silently dropped, so every + // built-in has to reference all of the arguments it declares. 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)]; + NSString* templateString = def.templateString; + for (NSUInteger i = 0; i + 1 < templateString.length; i++) { + if ([templateString characterAtIndex:i] != '#') { + continue; + } + unichar digit = [templateString characterAtIndex:i + 1]; + if (digit < '1' || digit > '9') { + continue; } + NSUInteger index = digit - '0'; + 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); @@ -540,14 +462,14 @@ - (void)testPmodParsesToASingleMacroAtom MTMacroAtom* macro = (MTMacroAtom*)last; XCTAssertEqualObjects(macro.command, @"pmod"); XCTAssertEqual(macro.arguments.count, 1ul); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.arguments[0]], @"n"); - XCTAssertEqual(macro.templateExpression.atoms.count, 8ul); + XCTAssertEqualObjects(macro.arguments[0], @"n"); + XCTAssertEqual(macro.rawExpansion.atoms.count, 8ul); } - (void)testUnbracedArgument { MTMacroAtom* macro = (MTMacroAtom*)[MTMathListBuilder buildFromString:@"\\pmod n"].atoms[0]; - XCTAssertEqualObjects([MTMathListBuilder mathListToString:macro.arguments[0]], @"n"); + XCTAssertEqualObjects(macro.arguments[0], @"n"); } - (void)testEmptyArgumentIsAllowed @@ -555,7 +477,7 @@ - (void)testEmptyArgumentIsAllowed MTMathList* list = [MTMathListBuilder buildFromString:@"\\pmod{}"]; XCTAssertNotNil(list); MTMacroAtom* macro = (MTMacroAtom*)list.atoms[0]; - XCTAssertEqual([macro.arguments[0] atoms].count, 0ul); + XCTAssertEqualObjects(macro.arguments[0], @""); } // Non-macro commands must be untouched: macroAtomForCommand: returns nil without @@ -734,8 +656,9 @@ - (void)testScriptOnAllSpaceArgumentIsNotDropped XCTAssertEqualObjects([MTMathListBuilder mathListToString:carrier.superScript], @"2"); } -// The argument is parsed under the enclosing font style; the parens and spaces come -// from the template, which is parsed by a fresh builder at default style (LLD §6). +// The whole expansion is parsed under the enclosing font style, since the macro's +// sub-builder is seeded with the caller's current font style (LLD §6). \mathrm in +// the template still overrides locally, for the "mod" letters. - (void)testMacroInsideFontStyleGroup { MTMathList* list = [MTMathListBuilder buildFromString:@"\\mathbf{x \\pmod{n}}"]; @@ -745,9 +668,10 @@ - (void)testMacroInsideFontStyleGroup 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 template. - XCTAssertEqual(macro.templateExpression.atoms[2].fontStyle, kMTFontStyleRoman); + // Space8, Open"(", m, o, d, Space6, n, Close")". + XCTAssertEqual(macro.rawExpansion.atoms[1].fontStyle, kMTFontStyleBold); + XCTAssertEqual(macro.rawExpansion.atoms[2].fontStyle, kMTFontStyleRoman); + XCTAssertEqual(macro.rawExpansion.atoms[6].fontStyle, kMTFontStyleBold); } #pragma mark - Serialization @@ -760,7 +684,7 @@ - (void)testSerializationRoundTrips @"\\pod{n}": @"\\pod{n}", @"\\mod{n+1}": @"\\mod{n+1}", @"\\pmod n": @"\\pmod{n}", // unbraced serializes canonically - @"\\mod{ n }": @"\\mod{n}", // whitespace is canonicalized + @"\\mod{ n }": @"\\mod{ n }", // argument text is preserved verbatim, not canonicalized @"\\pmod{n}^2": @"\\pmod{n}^{2}", @"\\pod{n}_k": @"\\pod{n}_{k}", @"\\pmod{}": @"\\pmod{}", @@ -795,15 +719,17 @@ - (void)testRawSerializesCommandFinalizedSerializesExpansion // 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. +// argument reader (`readRawArgument`) captures the argument as raw, unparsed +// text, so the raw list serializes back to exactly `\pmod{\frac}` — the +// argument string is echoed verbatim, not re-derived from a parsed sub-list. +// `\frac` with no braces after it still parses once the argument text is +// spliced into the template and the whole thing is parsed together, exactly +// like bare top-level `\frac` at EOF. - (void)testFracWithNoArgumentsIsNotAnErrorInsideMacroArgument { MTMathList* list = [MTMathListBuilder buildFromString:@"\\pmod{\\frac}"]; XCTAssertNotNil(list); - XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pmod{\\frac{}{}}"); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pmod{\\frac}"); XCTAssertNoThrow([list finalized]); } @@ -862,26 +788,6 @@ - (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); -} - #pragma mark - Zero-argument macros // The registry's argument-free templates, written out by hand. If these drift From 9967031ae0176b53d43edcb8c59dbcd0ed788284 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Fri, 21 Aug 2026 07:23:34 +0530 Subject: [PATCH 4/9] [item 7] Test #N inside a sub-list and carrying a script Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CR3o91FfsodPmwJD1s8499 --- iosMathTests/MTModularArithmeticTest.m | 34 ++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/iosMathTests/MTModularArithmeticTest.m b/iosMathTests/MTModularArithmeticTest.m index 32ae6d61..e1670f34 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -449,6 +449,40 @@ - (void)testAddMacroRegistersAndReplaces ListSignature([MTMathListBuilder buildFromString:@"\\frac{1}{3}"].finalized)); } +#pragma mark - #N anywhere in a template + +// The case this design exists for: a placeholder inside another command's braces. +- (void)testPlaceholderInsideASubList +{ + [MTMathAtomFactory addMacro:@"myhat" argumentCount:1 template:@"\\hat{#1}"]; + [MTMathAtomFactory addMacro:@"myfrac" argumentCount:2 template:@"\\frac{#1}{#2}"]; + + NSDictionary* cases = @{ + @"\\myhat{x}": @"\\hat{x}", + @"\\myfrac{1}{2}": @"\\frac{1}{2}", + @"\\myfrac{a+b}{c}": @"\\frac{a+b}{c}", + }; + for (NSString* input in cases) { + MTMathList* macroList = [MTMathListBuilder buildFromString:input]; + XCTAssertNotNil(macroList, @"%@", input); + MTMathList* writtenList = [MTMathListBuilder buildFromString:cases[input]]; + XCTAssertEqualObjects(ListSignature(macroList.finalized), + ListSignature(writtenList.finalized), @"%@", input); + } +} + +// A placeholder carrying a script — the case that decided the mechanism. The +// argument text lands where the parser would have read it inline, so the script +// attaches to it exactly as if the expansion had been typed out. +- (void)testPlaceholderCarryingAScript +{ + [MTMathAtomFactory addMacro:@"pow" argumentCount:2 template:@"#1^{#2}"]; + XCTAssertEqualObjects(ListSignature([MTMathListBuilder buildFromString:@"\\pow{x}{n}"].finalized), + ListSignature([MTMathListBuilder buildFromString:@"x^{n}"].finalized)); + XCTAssertEqualObjects(ListSignature([MTMathListBuilder buildFromString:@"\\pow{a+b}{2k}"].finalized), + ListSignature([MTMathListBuilder buildFromString:@"a+b^{2k}"].finalized)); +} + #pragma mark - Parsing the three macros - (void)testPmodParsesToASingleMacroAtom From de2c3d4f93259d67062e185d7f024b61301efe5d Mon Sep 17 00:00:00 2001 From: Kostub D Date: Fri, 21 Aug 2026 07:23:58 +0530 Subject: [PATCH 5/9] [item 8] Test the raw macro-argument scanner Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CR3o91FfsodPmwJD1s8499 --- iosMathTests/MTModularArithmeticTest.m | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/iosMathTests/MTModularArithmeticTest.m b/iosMathTests/MTModularArithmeticTest.m index e1670f34..dc573e85 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -483,6 +483,49 @@ - (void)testPlaceholderCarryingAScript ListSignature([MTMathListBuilder buildFromString:@"a+b^{2k}"].finalized)); } +#pragma mark - Reading an argument as raw text + +- (void)testRawArgumentScanner +{ + NSDictionary* cases = @{ + @"\\pod{a+b}": @"\\mkern8mu(a+b)", + @"\\pod n": @"\\mkern8mu(n)", + @"\\pod\\alpha": @"\\mkern8mu(\\alpha)", + @"\\pod{}": @"\\mkern8mu()", + @"\\pod{\\frac{1}{2}}": @"\\mkern8mu(\\frac{1}{2})", + // \{ and \} are two characters passed through, so they do not move the + // brace-depth counter. + @"\\pod{\\{x\\}}": @"\\mkern8mu(\\{x\\})", + }; + for (NSString* input in cases) { + MTMathList* macroList = [MTMathListBuilder buildFromString:input]; + XCTAssertNotNil(macroList, @"%@", input); + MTMathList* writtenList = [MTMathListBuilder buildFromString:cases[input]]; + XCTAssertEqualObjects(ListSignature(macroList.finalized), + ListSignature(writtenList.finalized), @"%@", input); + } +} + +- (void)testUnmatchedBraceInArgument +{ + NSError* error = nil; + XCTAssertNil([MTMathListBuilder buildFromString:@"\\pod{{n}" error:&error]); + XCTAssertEqual(error.code, MTParseErrorMismatchBraces); +} + +// \substack's shape. The argument is parsed inside the smallmatrix, never on its +// own — which is the whole reason arguments are stored as text. +- (void)testArgumentContainingRowSeparators +{ + [MTMathAtomFactory addMacro:@"substack" argumentCount:1 + template:@"\\begin{smallmatrix}#1\\end{smallmatrix}"]; + MTMathList* macroList = [MTMathListBuilder buildFromString:@"\\substack{i Date: Fri, 21 Aug 2026 07:25:19 +0530 Subject: [PATCH 6/9] [item 9] Test serialization exactness and the macro recursion cap Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CR3o91FfsodPmwJD1s8499 --- iosMathTests/MTModularArithmeticTest.m | 34 ++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/iosMathTests/MTModularArithmeticTest.m b/iosMathTests/MTModularArithmeticTest.m index dc573e85..934a1876 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -810,6 +810,19 @@ - (void)testFracWithNoArgumentsIsNotAnErrorInsideMacroArgument XCTAssertNoThrow([list finalized]); } +// Arguments are stored as source text, so serialization is a splice rather than a +// re-serialization: redundant braces and unusual spacing come back out verbatim. +// This replaces testFinalizedTracksArgumentMutation, which pinned the lazy +// re-derivation this design gives up. +- (void)testArgumentTextSerializesVerbatim +{ + for (NSString* input in @[ @"\\pod{{n}}", @"\\pod{ n }", @"\\pod{n + 1}", @"\\pod{\\frac{1}{2}}" ]) { + MTMathList* list = [MTMathListBuilder buildFromString:input]; + XCTAssertNotNil(list, @"%@", input); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], input); + } +} + #pragma mark - Rendering - (void)testPmodRendersUprightMod @@ -923,4 +936,25 @@ - (void)testZeroArgumentMacroSerializationRoundTrips } } +#pragma mark - Recursion cap + +// A self-referential macro must fail fast rather than overflow the stack. Each +// level is a whole sub-builder, so the cap is separate from the parse-depth one. +- (void)testSelfReferentialMacroHitsTheDepthCap +{ + [MTMathAtomFactory addMacro:@"loop" argumentCount:0 template:@"\\loop"]; + NSError* error = nil; + XCTAssertNil([MTMathListBuilder buildFromString:@"\\loop" error:&error]); + XCTAssertEqual(error.code, MTParseErrorNestingTooDeep); +} + +- (void)testMutuallyRecursiveMacrosHitTheDepthCap +{ + [MTMathAtomFactory addMacro:@"ping" argumentCount:0 template:@"\\pong"]; + [MTMathAtomFactory addMacro:@"pong" argumentCount:0 template:@"\\ping"]; + NSError* error = nil; + XCTAssertNil([MTMathListBuilder buildFromString:@"\\ping" error:&error]); + XCTAssertEqual(error.code, MTParseErrorNestingTooDeep); +} + @end From 21cc23fae7dff57227c1beacf86004c5cf28696c Mon Sep 17 00:00:00 2001 From: Kostub D Date: Sat, 22 Aug 2026 02:58:19 +0530 Subject: [PATCH 7/9] Support TeX's ## escape for a literal # in a template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validator rejected any # not followed by 1-9, so a template holding a colour literal — \color{#ff0000}, the one place a # legitimately appears — could not be registered at all. Follow TeX (TeXbook Ch. 20) instead: a # in a replacement text must be followed by 1-9 or by another #, and ## splices down to a single #. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CR3o91FfsodPmwJD1s8499 --- iosMath/lib/MTMathAtomFactory.h | 6 ++++-- iosMath/lib/MTMathAtomFactory.m | 13 ++++++++++--- iosMath/lib/MTMathListBuilder.m | 6 ++++++ iosMathTests/MTModularArithmeticTest.m | 11 +++++++++++ 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/iosMath/lib/MTMathAtomFactory.h b/iosMath/lib/MTMathAtomFactory.h index c7375f0f..9d10b313 100644 --- a/iosMath/lib/MTMathAtomFactory.h +++ b/iosMath/lib/MTMathAtomFactory.h @@ -128,8 +128,10 @@ FOUNDATION_EXPORT NSString *const MTSymbolDegree; + (void) addLatexSymbol:(NSString*) name value:(MTMathAtom*) atom; /** Define a macro: a command that expands to `templateString` with `#1`...`#9` replaced by - the arguments it is invoked with. Macros are looked up before every other command table, so - registering a name that already exists — a macro or a built-in command — shadows it. + the arguments it is invoked with. As in TeX, a literal `#` in the template is written `##`. + Macros are looked up before the symbol tables, so registering a name that already exists — + a macro or a built-in symbol — shadows it. `\limits`, the `\text…` commands and the + font-style commands are dispatched earlier and cannot be shadowed. e.g. `[MTMathAtomFactory addMacro:@"half" argumentCount:0 template:@"\\frac{1}{2}"]` Carries the same setup-time contract as `+addLatexSymbol:value:` — do not call this while diff --git a/iosMath/lib/MTMathAtomFactory.m b/iosMath/lib/MTMathAtomFactory.m index cae2af97..2cd6ae3b 100644 --- a/iosMath/lib/MTMathAtomFactory.m +++ b/iosMath/lib/MTMathAtomFactory.m @@ -1075,14 +1075,21 @@ + (nullable MTMathAtom*) arrayTableWithAlignments:(NSArray*) columnAl return macros; } +// TeX's rule for a replacement text (TeXbook Ch. 20): every # is followed by +// 1-9 or by another #, the latter standing for a literal # that +// -spliceTemplate:arguments: collapses. A trailing or otherwise bare # is a typo. + (BOOL) template:(NSString*) templateString referencesOnlyArgumentsUpTo:(NSUInteger) argumentCount { - for (NSUInteger i = 0; i + 1 < templateString.length; i++) { + for (NSUInteger i = 0; i < templateString.length; i++) { if ([templateString characterAtIndex:i] != '#') { continue; } - unichar digit = [templateString characterAtIndex:i + 1]; - if (digit < '1' || digit > '9' || (NSUInteger)(digit - '0') > argumentCount) { + if (i + 1 >= templateString.length) { + return NO; + } + unichar next = [templateString characterAtIndex:i + 1]; + if (next != '#' && + (next < '1' || next > '9' || (NSUInteger)(next - '0') > argumentCount)) { return NO; } i++; diff --git a/iosMath/lib/MTMathListBuilder.m b/iosMath/lib/MTMathListBuilder.m index 531fa5d6..355917f5 100644 --- a/iosMath/lib/MTMathListBuilder.m +++ b/iosMath/lib/MTMathListBuilder.m @@ -745,6 +745,12 @@ - (NSString*) spliceTemplate:(NSString*) templateString continue; } unichar digit = [templateString characterAtIndex:i + 1]; + if (digit == '#') { + // TeX's escape for a literal #, which \color{##ff0000} needs. + [out appendString:@"#"]; + i++; + continue; + } if (digit < '1' || digit > '9' || (NSUInteger)(digit - '0') > rawArguments.count) { // Rejected by the assertion in +addMacro:. With assertions compiled out // the # survives here and the expansion fails to parse, which is loud. diff --git a/iosMathTests/MTModularArithmeticTest.m b/iosMathTests/MTModularArithmeticTest.m index 934a1876..4f07dadc 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -396,6 +396,17 @@ - (void)testTemplateSplicesMultipleArgumentsInOrder ListSignature([MTMathListBuilder buildFromString:@"y(xy"])); } +// A colour literal is the only way a # reaches a template today, and it is also +// the case that made the old validator reject a legal template outright. +- (void)testDoubledHashSplicesToALiteralHash +{ + [MTMathAtomFactory addMacro:@"warn" argumentCount:1 template:@"\\color{##ff0000}{#1}"]; + MTMathList* list = [MTMathListBuilder buildFromString:@"\\warn{x}"]; + XCTAssertNotNil(list); + XCTAssertEqualObjects(ListSignature([list expandMacros]), + ListSignature([MTMathListBuilder buildFromString:@"\\color{#ff0000}{x}"])); +} + - (void)testEveryRegisteredMacroParses { // Named rather than enumerated: +addMacro: writes into the same global table From 95fe555fab2c01e60662564d753f7ae781db60e7 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Sat, 22 Aug 2026 03:43:43 +0530 Subject: [PATCH 8/9] Keep the macro registry's read side out of the public API +macroDefinitionForCommand: and MTMacroDefinition exist only so the builder can expand a command; no caller outside the library reads a definition back. Move both to MTMathAtomFactory+Internal.h, which the modulemap does not export. +addMacro:argumentCount:template: stays public as the write side. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CR3o91FfsodPmwJD1s8499 --- iosMath.xcodeproj/project.pbxproj | 2 ++ iosMath/lib/MTMathAtomFactory+Internal.h | 33 ++++++++++++++++++++++++ iosMath/lib/MTMathAtomFactory.h | 13 ---------- iosMath/lib/MTMathAtomFactory.m | 1 + iosMath/lib/MTMathListBuilder.m | 1 + iosMathTests/MTModularArithmeticTest.m | 1 + 6 files changed, 38 insertions(+), 13 deletions(-) create mode 100644 iosMath/lib/MTMathAtomFactory+Internal.h diff --git a/iosMath.xcodeproj/project.pbxproj b/iosMath.xcodeproj/project.pbxproj index 0bbd4217..bc7f0175 100644 --- a/iosMath.xcodeproj/project.pbxproj +++ b/iosMath.xcodeproj/project.pbxproj @@ -93,6 +93,7 @@ 492EECFA17DAED9000939107 /* MTFontManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MTFontManager.m; sourceTree = ""; }; 492EECFF17DAEDB500939107 /* MTMathListBuilder.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MTMathListBuilder.m; sourceTree = ""; }; 492EED0017DAEDB500939107 /* MTMathAtomFactory.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MTMathAtomFactory.h; sourceTree = ""; }; + A1B2C3D40000000000000101 /* MTMathAtomFactory+Internal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "MTMathAtomFactory+Internal.h"; sourceTree = ""; }; 492EED0117DAEDB500939107 /* MTMathAtomFactory.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MTMathAtomFactory.m; sourceTree = ""; }; 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 = ""; }; @@ -286,6 +287,7 @@ 49DEC8B51CF77B00000053CD /* MTMathListIndex.m */, 49DEC8B61CF77B00000053CD /* MTMathListIndex.h */, 492EED0017DAEDB500939107 /* MTMathAtomFactory.h */, + A1B2C3D40000000000000101 /* MTMathAtomFactory+Internal.h */, 492EED0117DAEDB500939107 /* MTMathAtomFactory.m */, 492EED0217DAEDB500939107 /* MTMathList.h */, 492EED0317DAEDB500939107 /* MTMathList.m */, diff --git a/iosMath/lib/MTMathAtomFactory+Internal.h b/iosMath/lib/MTMathAtomFactory+Internal.h new file mode 100644 index 00000000..15f870ce --- /dev/null +++ b/iosMath/lib/MTMathAtomFactory+Internal.h @@ -0,0 +1,33 @@ +// +// MTMathAtomFactory+Internal.h +// iosMath +// +// This software may be modified and distributed under the terms of the +// MIT license. See the LICENSE file for details. +// + +#import "MTMathAtomFactory.h" + +NS_ASSUME_NONNULL_BEGIN + +/// 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 + +/** Read side of the macro registry, used by the builder to expand a command. + `+addMacro:argumentCount:template:` is the public write side; nothing outside + the library needs to read a definition back. */ +@interface MTMathAtomFactory (Internal) + +/** The macro registered under `command`, or nil if it is not a macro. */ ++ (nullable MTMacroDefinition*) macroDefinitionForCommand:(NSString*) command; + +@end + +NS_ASSUME_NONNULL_END diff --git a/iosMath/lib/MTMathAtomFactory.h b/iosMath/lib/MTMathAtomFactory.h index 9d10b313..77415f3e 100644 --- a/iosMath/lib/MTMathAtomFactory.h +++ b/iosMath/lib/MTMathAtomFactory.h @@ -38,16 +38,6 @@ typedef NS_ENUM(NSUInteger, MTStackArgRole) { inheritsClass:(BOOL)inheritsClass; @end -/// 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 - FOUNDATION_EXPORT NSString *const MTSymbolMultiplication; FOUNDATION_EXPORT NSString *const MTSymbolDivision; FOUNDATION_EXPORT NSString *const MTSymbolFractionSlash; @@ -140,9 +130,6 @@ FOUNDATION_EXPORT NSString *const MTSymbolDegree; argumentCount:(NSUInteger) argumentCount template:(NSString*) templateString; -/** The macro registered under `command`, or nil if it is not a macro. */ -+ (nullable MTMacroDefinition*) macroDefinitionForCommand:(NSString*) command; - /** Returns a list of all supported lated symbols names. */ + (NSArray*) supportedLatexSymbolNames; diff --git a/iosMath/lib/MTMathAtomFactory.m b/iosMath/lib/MTMathAtomFactory.m index 2cd6ae3b..36967e70 100644 --- a/iosMath/lib/MTMathAtomFactory.m +++ b/iosMath/lib/MTMathAtomFactory.m @@ -10,6 +10,7 @@ // #import "MTMathAtomFactory.h" +#import "MTMathAtomFactory+Internal.h" #import "MTMathListBuilder.h" NSString *const MTSymbolMultiplication = @"\u00D7"; diff --git a/iosMath/lib/MTMathListBuilder.m b/iosMath/lib/MTMathListBuilder.m index 355917f5..d61e9c7c 100644 --- a/iosMath/lib/MTMathListBuilder.m +++ b/iosMath/lib/MTMathListBuilder.m @@ -11,6 +11,7 @@ #import "MTMathListBuilder.h" #import "MTMathAtomFactory.h" +#import "MTMathAtomFactory+Internal.h" NSString *const MTParseError = @"ParseError"; diff --git a/iosMathTests/MTModularArithmeticTest.m b/iosMathTests/MTModularArithmeticTest.m index 4f07dadc..1e8f19f5 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -11,6 +11,7 @@ #import "MTMathList.h" #import "MTMathListBuilder.h" #import "MTMathAtomFactory.h" +#import "MTMathAtomFactory+Internal.h" #import "MTTypesetter.h" #import "MTFont+Internal.h" #import "MTFontManager.h" From a97ac2f8eca216912dbed9a25e1efddd6aea53a1 Mon Sep 17 00:00:00 2001 From: Kostub D Date: Sat, 22 Aug 2026 03:48:56 +0530 Subject: [PATCH 9/9] Address review: drop dead code and fix three stale comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -readRawArgument's surrogate-pair branch is unreachable. Its only caller skips spaces first, and -skipSpaces consumes everything outside 0x21-0x7E, so a high surrogate never survives to be read as a braceless argument. The comment on -rawArgumentWithError: survived the rename unchanged and described none of what the method does: -readRawArgument returns a string rather than a list, does not return empty at EOF, and \sqrt does not route through it. The \pmod{\frac} comment claimed the expansion degrades like a bare trailing \frac. It does not — \frac takes its numerator from the template text after #1, which is the closing paren, so the paren is swallowed and the denominator is empty. Pinned with the finalized serialization in place of the XCTAssertNoThrow that could not see it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CR3o91FfsodPmwJD1s8499 --- iosMath/lib/MTMathListBuilder.m | 23 ++++++++--------------- iosMathTests/MTMathListBuilderTest.m | 8 ++++---- iosMathTests/MTModularArithmeticTest.m | 13 ++++++++----- 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/iosMath/lib/MTMathListBuilder.m b/iosMath/lib/MTMathListBuilder.m index d61e9c7c..be833474 100644 --- a/iosMath/lib/MTMathListBuilder.m +++ b/iosMath/lib/MTMathListBuilder.m @@ -160,10 +160,10 @@ - (BOOL) readOptionalAlignment:(MTFractionAlignment*)outAlignment return YES; } -// -readRawArgument on its own is silently permissive: at EOF it returns an empty -// 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. +// -readRawArgument reads unconditionally: it needs a character to be there, and it +// takes a }, ^, _ or & as a one-character argument rather than treating it as the +// end of the argument. A macro invocation with no argument has to be an error +// instead, so this wrapper rules both out first. Only macros route through this. - (nullable NSString *)rawArgumentWithError:(MTParseErrors)error { [self skipSpaces]; @@ -687,7 +687,9 @@ - (NSString*) readTextArgument // The math-mode sibling of -readTextArgument: reads one macro argument as source // text without parsing it. Nothing is unescaped and no inner brace is dropped — // whatever this returns gets spliced into a template and handed back to a parser. -// The caller has already skipped spaces and confirmed a character is available. +// The caller has already skipped spaces and confirmed a character is available; since +// -skipSpaces consumes everything outside 0x21-0x7E, that character is always ASCII, +// so a braceless argument is a single unichar and never half a surrogate pair. - (nullable NSString*) readRawArgument { unichar first = [self getNextCharacter]; @@ -695,16 +697,7 @@ - (nullable NSString*) readRawArgument return [@"\\" stringByAppendingString:[self readCommand]]; } if (first != '{') { - NSMutableString* token = [NSMutableString stringWithCharacters:&first length:1]; - if (first >= 0xD800 && first <= 0xDBFF && [self hasCharacters]) { - unichar low = [self getNextCharacter]; - if (low >= 0xDC00 && low <= 0xDFFF) { - [token appendFormat:@"%C", low]; - } else { - [self unlookCharacter]; - } - } - return token; + return [NSString stringWithCharacters:&first length:1]; } NSMutableString* body = [NSMutableString string]; NSInteger depth = 0; diff --git a/iosMathTests/MTMathListBuilderTest.m b/iosMathTests/MTMathListBuilderTest.m index e3378a0d..3ec7ce09 100644 --- a/iosMathTests/MTMathListBuilderTest.m +++ b/iosMathTests/MTMathListBuilderTest.m @@ -1723,10 +1723,10 @@ - (void) testAlignedatWhitespaceArgument // argument as raw, unparsed text ("\frac"), so `\frac`'s own operands // aren't read here at all — that only happens once the raw text is // spliced into \pmod's template and the whole thing is parsed - // together, and by then `\frac` sees no operands of its own, - // degrading the same way bare top-level `\frac` does. `\pmod{\frac}` - // therefore parses successfully and serializes back as `\pmod{\frac}` - // — verified directly; see + // together, and by then `\frac` takes its numerator from the + // template text following `#1`. `\pmod{\frac}` therefore parses + // successfully and serializes back as `\pmod{\frac}` — verified + // directly; see // -testFracWithNoArgumentsIsNotAnErrorInsideMacroArgument in // MTModularArithmeticTest.m. Swapped in a genuinely malformed // argument (an unbalanced brace) that does propagate diff --git a/iosMathTests/MTModularArithmeticTest.m b/iosMathTests/MTModularArithmeticTest.m index 1e8f19f5..acbd0b2c 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -445,7 +445,7 @@ - (void)testEveryRegisteredMacroParses [seen addObject:@(index)]; } XCTAssertEqual(seen.count, def.argumentCount, - @"\\%@ template must reference every declared argument at top level", command); + @"\\%@ template must reference every declared argument", command); } } @@ -811,15 +811,18 @@ - (void)testRawSerializesCommandFinalizedSerializesExpansion // argument reader (`readRawArgument`) captures the argument as raw, unparsed // text, so the raw list serializes back to exactly `\pmod{\frac}` — the // argument string is echoed verbatim, not re-derived from a parsed sub-list. -// `\frac` with no braces after it still parses once the argument text is -// spliced into the template and the whole thing is parsed together, exactly -// like bare top-level `\frac` at EOF. +// `\frac` reads its operands only when the spliced string is parsed as a whole, and +// by then what follows `#1` is the template's own closing `)`, which becomes the +// numerator. So the expansion is not the `\frac{}{}` a bare trailing `\frac` gives — +// the paren is swallowed and the denominator is empty. TeX does the same thing with +// the same definition, and `\pmod{\frac}` is degenerate input either way. - (void)testFracWithNoArgumentsIsNotAnErrorInsideMacroArgument { MTMathList* list = [MTMathListBuilder buildFromString:@"\\pmod{\\frac}"]; XCTAssertNotNil(list); XCTAssertEqualObjects([MTMathListBuilder mathListToString:list], @"\\pmod{\\frac}"); - XCTAssertNoThrow([list finalized]); + XCTAssertEqualObjects([MTMathListBuilder mathListToString:list.finalized], + @"\\mkern8.0mu(\\mathrm{mod}\\mkern6.0mu\\frac{)}{}"); } // Arguments are stored as source text, so serialization is a splice rather than a