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..bc7f0175 100644 --- a/iosMath.xcodeproj/project.pbxproj +++ b/iosMath.xcodeproj/project.pbxproj @@ -93,11 +93,11 @@ 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 = ""; }; 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; }; @@ -287,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 */, @@ -294,12 +295,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/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 c7375f0f..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; @@ -128,8 +118,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 @@ -138,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 cae2af97..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"; @@ -1075,14 +1076,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/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 ddada1e3..be833474 100644 --- a/iosMath/lib/MTMathListBuilder.m +++ b/iosMath/lib/MTMathListBuilder.m @@ -11,7 +11,7 @@ #import "MTMathListBuilder.h" #import "MTMathAtomFactory.h" -#import "MTMacroParameterAtom.h" +#import "MTMathAtomFactory+Internal.h" NSString *const MTParseError = @"ParseError"; @@ -47,11 +47,9 @@ - (instancetype)initWithName:(NSString*) name // far below the thousands of frames needed to overflow a 1 MB stack. static const NSInteger kMTMaxRecursionDepth = 150; -// Not in the public header, so template mode does not appear in the Swift module -// interface — only built-in macro templates use it. -@interface MTMathListBuilder () -+ (nullable MTMathList *)buildTemplate:(NSString *)str; -@end +// 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; @implementation MTMathListBuilder { unichar* _chars; @@ -62,7 +60,7 @@ @implementation MTMathListBuilder { MTFontStyle _currentFontStyle; BOOL _spacesAllowed; NSInteger _recursionDepth; - BOOL _templateMode; + NSInteger _macroExpansionDepth; // 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 @@ -162,11 +160,11 @@ - (BOOL) readOptionalAlignment:(MTFractionAlignment*)outAlignment return YES; } -// -buildInternal:YES 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 +// -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]; if (![self hasCharacters]) { @@ -193,7 +191,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 @@ -474,18 +472,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) { @@ -698,6 +684,96 @@ - (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; 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]; + if (first == '\\') { + return [@"\\" stringByAppendingString:[self readCommand]]; + } + if (first != '{') { + return [NSString stringWithCharacters:&first length:1]; + } + 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 == '#') { + // 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. + [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:'{']) { @@ -1142,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 @@ -1814,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 diff --git a/iosMathTests/MTMathListBuilderTest.m b/iosMathTests/MTMathListBuilderTest.m index 9a10fe9b..3ec7ce09 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` 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 e210bf3e..acbd0b2c 100644 --- a/iosMathTests/MTModularArithmeticTest.m +++ b/iosMathTests/MTModularArithmeticTest.m @@ -11,13 +11,13 @@ #import "MTMathList.h" #import "MTMathListBuilder.h" #import "MTMathAtomFactory.h" +#import "MTMathAtomFactory+Internal.h" #import "MTTypesetter.h" #import "MTFont+Internal.h" #import "MTFontManager.h" #import "MTMathListDisplay.h" #import "MTMathListDisplayInternal.h" #import "MTFontMathTable.h" -#import "MTMacroParameterAtom.h" @interface MTModularArithmeticTest : XCTestCase @property (nonatomic) MTFont* font; @@ -29,12 +29,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 +151,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 +216,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 +235,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 +266,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 +283,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,20 +388,26 @@ - (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"])); } +// 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 @@ -495,23 +418,34 @@ - (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); + @"\\%@ template must reference every declared argument", command); } } @@ -527,6 +461,83 @@ - (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 - 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