Skip to content

Commit af4aa02

Browse files
committed
fix: handle 0, NULL, nullptr properly for codegen
1 parent 7b8da41 commit af4aa02

6 files changed

Lines changed: 449 additions & 17 deletions

File tree

src/NativeCodeGen.Core/Generation/ArgumentBuilder.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,17 @@ public static string GetArgumentExpression(NativeParameter param, ITypeMapper ty
5959
return $"{param.Name}.view";
6060
}
6161

62-
// Handle types - only class handles have a .handle property
62+
// Handle types - only class handles have a .handle property when UseTypedHandles is true
6363
// Non-class handles (ScrHandle, Prompt) are just numbers
64-
if (typeMapper.IsHandleType(param.Type) && TypeInfo.IsClassHandle(param.Type.Name) && !rawMode)
64+
if (typeMapper.IsHandleType(param.Type) && TypeInfo.IsClassHandle(param.Type.Name) && !rawMode && config.UseTypedHandles)
6565
{
66+
// If the parameter is nullable (default was 0/nullptr/NULL, now null), handle null -> 0
67+
if (mappedDefaultValue is "0" or "null" or "nullptr" or "NULL")
68+
{
69+
return config.UseOptionalChaining
70+
? $"{param.Name}?.handle ?? 0"
71+
: $"({param.Name} and {param.Name}.handle or 0)";
72+
}
6673
return $"{param.Name}.handle";
6774
}
6875

src/NativeCodeGen.Core/Generation/SharedClassGenerator.cs

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ private void EmitSingleMethod(
373373
: (returnTypeOverride ?? _emitter.TypeMapper.BuildCombinedReturnType(native.ReturnType, outputParamTypes));
374374

375375
// Generate documentation
376-
EmitMethodDoc(cb, native, inputParams, outputParams, returnType, chainable);
376+
EmitMethodDoc(cb, native, methodParams, outputParams, returnType, chainable);
377377

378378
// Emit method
379379
_emitter.EmitMethodStart(cb, className, methodName, methodParams, returnType, kind);
@@ -393,18 +393,21 @@ private void EmitSingleMethod(
393393
private void EmitMethodDoc(
394394
CodeBuilder cb,
395395
NativeDefinition native,
396-
List<NativeParameter> inputParams,
396+
List<MethodParameter> methodParams,
397397
List<NativeParameter> outputParams,
398398
string returnType,
399399
bool chainable)
400400
{
401401
var doc = _emitter.CreateDocBuilder()
402402
.AddDescription(MdxComponentParser.FormatDescriptionForCodeGen(native.Description));
403403

404-
foreach (var param in inputParams)
404+
// Match method params with native params to get descriptions
405+
var inputParams = native.Parameters.Where(p => !p.IsPureOutput && !p.IsThis).ToList();
406+
for (var i = 0; i < methodParams.Count && i < inputParams.Count; i++)
405407
{
406-
var type = _emitter.TypeMapper.MapType(param.Type, param.IsNullable);
407-
doc.AddParam(param.Name, type, MdxComponentParser.FormatDescriptionForCodeGen(param.Description));
408+
var methodParam = methodParams[i];
409+
var nativeParam = inputParams[i];
410+
doc.AddParam(methodParam.Name, methodParam.Type, MdxComponentParser.FormatDescriptionForCodeGen(nativeParam.Description));
408411
}
409412

410413
if (chainable)
@@ -455,11 +458,24 @@ private static string BuildReturnDescription(NativeDefinition native, List<Nativ
455458
}
456459

457460
private List<MethodParameter> BuildMethodParams(List<NativeParameter> inputParams) =>
458-
inputParams.Select(p => new MethodParameter(
459-
p.Name,
460-
_emitter.TypeMapper.MapType(p.Type, p.IsNullable),
461-
p.DefaultValue != null ? _emitter.MapDefaultValue(p.DefaultValue, p.Type) : null
462-
)).ToList();
461+
inputParams.Select(p =>
462+
{
463+
var type = _emitter.TypeMapper.MapType(p.Type, p.IsNullable);
464+
var defaultValue = p.DefaultValue != null ? _emitter.MapDefaultValue(p.DefaultValue, p.Type) : null;
465+
466+
// If a class handle type has a default of 0/nullptr/NULL, treat it as nullable
467+
// e.g., Entity = 0 becomes Entity | null = null (TS) or Entity|nil = nil (Lua)
468+
if (defaultValue is "0" or "nullptr" or "NULL" && TypeInfo.IsClassHandle(p.Type.Name))
469+
{
470+
var config = _emitter.Config;
471+
var nullSuffix = config.NullableSuffix;
472+
var nullValue = config.UseOptionalChaining ? "null" : "nil";
473+
type = $"{type}{nullSuffix}";
474+
defaultValue = nullValue;
475+
}
476+
477+
return new MethodParameter(p.Name, type, defaultValue);
478+
}).ToList();
463479

464480
private List<string> BuildInvokeArgs(string? firstArg, List<NativeParameter> parameters)
465481
{

src/NativeCodeGen.Core/TypeSystem/TypeMapperBase.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ public record LanguageConfig
5151
// Whether to use primitive type aliases (float, u8, etc.) for better documentation
5252
public required bool UsePrimitiveAliases { get; init; }
5353

54+
// Whether the language supports optional chaining (?.) and nullish coalescing (??)
55+
public required bool UseOptionalChaining { get; init; }
56+
5457
public static readonly LanguageConfig TypeScript = new()
5558
{
5659
VoidType = "void",
@@ -82,7 +85,8 @@ public record LanguageConfig
8285
UseHashWrapper = true,
8386
SupportsGetters = true,
8487
UseInlineDefaults = false,
85-
UsePrimitiveAliases = true
88+
UsePrimitiveAliases = true,
89+
UseOptionalChaining = true
8690
};
8791

8892
public static readonly LanguageConfig Lua = new()
@@ -98,7 +102,7 @@ public record LanguageConfig
98102
AnyType = "any",
99103
NullableSuffix = "|nil",
100104
HashType = "string|number",
101-
UseTypedHandles = false,
105+
UseTypedHandles = true,
102106

103107
InvokeAlias = "inv",
104108
ResultAsIntAlias = "rai",
@@ -116,7 +120,8 @@ public record LanguageConfig
116120
UseHashWrapper = true,
117121
SupportsGetters = false,
118122
UseInlineDefaults = true,
119-
UsePrimitiveAliases = false
123+
UsePrimitiveAliases = false,
124+
UseOptionalChaining = false
120125
};
121126
}
122127

src/NativeCodeGen.Core/Validation/TypeValidator.cs

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ public List<ParseError> ValidateNative(NativeDefinition native, string filePath)
3030
foreach (var param in native.Parameters)
3131
{
3232
ValidateType(param.Type, $"parameter '{param.Name}'", filePath, errors);
33+
34+
// Validate default values match parameter types
35+
if (param.HasDefaultValue)
36+
{
37+
ValidateDefaultValue(param, filePath, errors);
38+
}
3339
}
3440

3541
// Validate and resolve return type
@@ -38,6 +44,224 @@ public List<ParseError> ValidateNative(NativeDefinition native, string filePath)
3844
return errors;
3945
}
4046

47+
/// <summary>
48+
/// Validates that a default value is compatible with the parameter type.
49+
/// </summary>
50+
private static void ValidateDefaultValue(NativeParameter param, string filePath, List<ParseError> errors)
51+
{
52+
var type = param.Type;
53+
var defaultValue = param.DefaultValue!;
54+
var context = $"parameter '{param.Name}'";
55+
56+
var error = ValidateDefaultForType(type, defaultValue, context);
57+
if (error != null)
58+
{
59+
errors.Add(new ParseError
60+
{
61+
FilePath = filePath,
62+
Line = 1,
63+
Column = 1,
64+
Message = error
65+
});
66+
}
67+
}
68+
69+
/// <summary>
70+
/// Validates a default value against a type and returns an error message if invalid.
71+
/// </summary>
72+
public static string? ValidateDefaultForType(TypeInfo type, string defaultValue, string context)
73+
{
74+
// Handle pointers - string pointers can be null, other pointers shouldn't have defaults
75+
if (type.IsPointer && type.Name != "char")
76+
{
77+
return $"Pointer type '{type}' in {context} should not have a default value '{defaultValue}'";
78+
}
79+
80+
// Validate based on category
81+
return type.Category switch
82+
{
83+
TypeCategory.Primitive => ValidatePrimitiveDefault(type, defaultValue, context),
84+
TypeCategory.Handle => ValidateHandleDefault(type, defaultValue, context),
85+
TypeCategory.Hash => ValidateHashDefault(defaultValue, context),
86+
TypeCategory.String => ValidateStringDefault(defaultValue, context),
87+
TypeCategory.Enum => ValidateEnumDefault(defaultValue, context),
88+
TypeCategory.Vector2 or TypeCategory.Vector3 or TypeCategory.Vector4
89+
=> $"Vector type '{type}' in {context} should not have a default value",
90+
TypeCategory.Color => $"Color type in {context} should not have a default value",
91+
TypeCategory.Struct => $"Struct type '{type}' in {context} should not have a default value",
92+
TypeCategory.Any => null, // Any type can have any default
93+
TypeCategory.Void => $"Void type in {context} cannot have a default value",
94+
_ => null
95+
};
96+
}
97+
98+
private static string? ValidatePrimitiveDefault(TypeInfo type, string defaultValue, string context)
99+
{
100+
// Boolean types
101+
if (type.IsBool)
102+
{
103+
if (defaultValue is "true" or "false" or "TRUE" or "FALSE" or "0" or "1")
104+
return null;
105+
return $"Boolean {context} has invalid default '{defaultValue}'. Expected: true, false, 0, or 1";
106+
}
107+
108+
// Float types - allow numeric literals with optional decimal/scientific notation
109+
if (type.IsFloat)
110+
{
111+
if (IsNumericLiteral(defaultValue, allowDecimal: true))
112+
return null;
113+
return $"Float {context} has invalid default '{defaultValue}'. Expected: numeric literal (e.g., 0, 1.0, -3.14)";
114+
}
115+
116+
// Integer types - allow integer literals only
117+
if (IsNumericLiteral(defaultValue, allowDecimal: false))
118+
return null;
119+
120+
return $"Integer {context} has invalid default '{defaultValue}'. Expected: integer literal (e.g., 0, 1, -1)";
121+
}
122+
123+
private static string? ValidateHandleDefault(TypeInfo type, string defaultValue, string context)
124+
{
125+
// Handle types can have 0, nullptr, or NULL as default (null handle)
126+
if (defaultValue is "0" or "nullptr" or "NULL")
127+
return null;
128+
129+
// Provide helpful error for class handle vs non-class handle
130+
if (TypeInfo.IsClassHandle(type.Name))
131+
{
132+
return $"Class handle type '{type.Name}' in {context} has invalid default '{defaultValue}'. " +
133+
$"Only '0', 'nullptr', or 'NULL' is allowed (will be converted to null). Did you mean: {type.Name} {GetParamNameFromContext(context)} = 0";
134+
}
135+
136+
return $"Handle type '{type.Name}' in {context} has invalid default '{defaultValue}'. " +
137+
$"Only '0', 'nullptr', or 'NULL' is allowed for null handle";
138+
}
139+
140+
private static string? ValidateHashDefault(string defaultValue, string context)
141+
{
142+
// Hash can be a string literal or numeric
143+
if (IsStringLiteral(defaultValue) || IsNumericLiteral(defaultValue, allowDecimal: false))
144+
return null;
145+
146+
return $"Hash {context} has invalid default '{defaultValue}'. Expected: string literal (e.g., \"model\") or numeric hash";
147+
}
148+
149+
private static string? ValidateStringDefault(string defaultValue, string context)
150+
{
151+
// String can be a string literal or null pointer
152+
if (IsStringLiteral(defaultValue) || defaultValue is "nullptr" or "NULL" or "0")
153+
return null;
154+
155+
return $"String {context} has invalid default '{defaultValue}'. Expected: string literal (e.g., \"text\"), nullptr, or NULL";
156+
}
157+
158+
private static string? ValidateEnumDefault(string defaultValue, string context)
159+
{
160+
// Enum can be numeric (cast) or an identifier (enum member name)
161+
if (IsNumericLiteral(defaultValue, allowDecimal: false) || IsIdentifier(defaultValue))
162+
return null;
163+
164+
return $"Enum {context} has invalid default '{defaultValue}'. Expected: numeric value or enum member name";
165+
}
166+
167+
private static bool IsNumericLiteral(string value, bool allowDecimal)
168+
{
169+
if (string.IsNullOrEmpty(value))
170+
return false;
171+
172+
var span = value.AsSpan();
173+
var start = 0;
174+
175+
// Allow leading minus for negative numbers
176+
if (span[0] == '-')
177+
{
178+
if (span.Length == 1)
179+
return false;
180+
start = 1;
181+
}
182+
183+
// Check for hex literal (0x...)
184+
if (span.Length > start + 2 && span[start] == '0' && (span[start + 1] == 'x' || span[start + 1] == 'X'))
185+
{
186+
for (var i = start + 2; i < span.Length; i++)
187+
{
188+
if (!char.IsAsciiHexDigit(span[i]))
189+
return false;
190+
}
191+
return span.Length > start + 2;
192+
}
193+
194+
var hasDecimal = false;
195+
var hasExponent = false;
196+
197+
for (var i = start; i < span.Length; i++)
198+
{
199+
var c = span[i];
200+
201+
if (char.IsAsciiDigit(c))
202+
continue;
203+
204+
if (allowDecimal && c == '.' && !hasDecimal && !hasExponent)
205+
{
206+
hasDecimal = true;
207+
continue;
208+
}
209+
210+
if (allowDecimal && (c == 'e' || c == 'E') && !hasExponent && i > start)
211+
{
212+
hasExponent = true;
213+
// Allow optional sign after exponent
214+
if (i + 1 < span.Length && (span[i + 1] == '+' || span[i + 1] == '-'))
215+
i++;
216+
continue;
217+
}
218+
219+
// Allow 'f' suffix for float literals
220+
if (allowDecimal && (c == 'f' || c == 'F') && i == span.Length - 1)
221+
continue;
222+
223+
return false;
224+
}
225+
226+
return true;
227+
}
228+
229+
private static bool IsStringLiteral(string value)
230+
{
231+
return value.Length >= 2 &&
232+
((value.StartsWith('"') && value.EndsWith('"')) ||
233+
(value.StartsWith('\'') && value.EndsWith('\'')));
234+
}
235+
236+
private static bool IsIdentifier(string value)
237+
{
238+
if (string.IsNullOrEmpty(value))
239+
return false;
240+
241+
// First char must be letter or underscore
242+
if (!char.IsLetter(value[0]) && value[0] != '_')
243+
return false;
244+
245+
// Rest can be letter, digit, or underscore
246+
for (var i = 1; i < value.Length; i++)
247+
{
248+
if (!char.IsLetterOrDigit(value[i]) && value[i] != '_')
249+
return false;
250+
}
251+
252+
return true;
253+
}
254+
255+
private static string GetParamNameFromContext(string context)
256+
{
257+
// Extract param name from "parameter 'name'" format
258+
var start = context.IndexOf('\'');
259+
var end = context.LastIndexOf('\'');
260+
if (start >= 0 && end > start)
261+
return context[(start + 1)..end];
262+
return "param";
263+
}
264+
41265
private void ValidateType(TypeInfo type, string context, string filePath, List<ParseError> errors)
42266
{
43267
// First, try to resolve enum types

tests/NativeCodeGen.Tests/Models/TypeInfoTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ public void ResolveEnumType_HashBaseType_Preserved()
248248
[InlineData("@in")]
249249
public void ValidAttributes_ContainsKnownAttributes(string attr)
250250
{
251-
Assert.True(TypeInfo.ValidAttributes.Contains(attr));
251+
Assert.Contains(attr, (IEnumerable<string>)TypeInfo.ValidAttributes);
252252
}
253253

254254
[Theory]
@@ -259,7 +259,7 @@ public void ValidAttributes_ContainsKnownAttributes(string attr)
259259
[InlineData("nullable")]
260260
public void ValidAttributes_DoesNotContainUnknown(string attr)
261261
{
262-
Assert.False(TypeInfo.ValidAttributes.Contains(attr));
262+
Assert.DoesNotContain(attr, (IEnumerable<string>)TypeInfo.ValidAttributes);
263263
}
264264

265265
#endregion

0 commit comments

Comments
 (0)