Skip to content

Commit 3e3bdd5

Browse files
committed
feat: add package generation support
Fix usedByNatives, add a way to define what game a native refers to
1 parent da211d0 commit 3e3bdd5

21 files changed

Lines changed: 846 additions & 61 deletions

esbuild-plugin/native-treeshake.js

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* esbuild plugin for tree-shaking RDR3/FiveM natives
3+
*
4+
* Usage:
5+
* import { nativeTreeshake } from './native-treeshake.js';
6+
*
7+
* esbuild.build({
8+
* entryPoints: ['src/index.ts'],
9+
* bundle: true,
10+
* plugins: [nativeTreeshake({
11+
* natives: './natives.ts', // Path to generated natives file
12+
* globals: true // Optional: expose as globalThis (default: false)
13+
* })],
14+
* });
15+
*
16+
* In your code, just use natives directly:
17+
* const coords = GetEntityCoords(entity);
18+
* const ped = CreatePed(model, x, y, z, heading, false, false);
19+
*
20+
* The plugin will automatically:
21+
* 1. Detect which natives you're using
22+
* 2. Import only those from the natives file
23+
* 3. Tree-shake the rest
24+
*/
25+
26+
import { readFileSync } from 'fs';
27+
import { resolve, dirname } from 'path';
28+
29+
/**
30+
* Extract all exported function names from the natives file
31+
*/
32+
function extractNativeNames(nativesPath) {
33+
const content = readFileSync(nativesPath, 'utf-8');
34+
const exportRegex = /export\s+function\s+(\w+)\s*\(/g;
35+
const names = new Set();
36+
let match;
37+
while ((match = exportRegex.exec(content)) !== null) {
38+
names.add(match[1]);
39+
}
40+
return names;
41+
}
42+
43+
/**
44+
* Find all potential native calls in source code
45+
*/
46+
function findUsedNatives(code, allNatives) {
47+
const used = new Set();
48+
// Match function calls: FunctionName(
49+
const callRegex = /\b([A-Z][A-Za-z0-9_]*)\s*\(/g;
50+
let match;
51+
while ((match = callRegex.exec(code)) !== null) {
52+
const name = match[1];
53+
if (allNatives.has(name)) {
54+
used.add(name);
55+
}
56+
}
57+
return used;
58+
}
59+
60+
export function nativeTreeshake(options = {}) {
61+
const {
62+
natives: nativesPath = './natives.ts',
63+
globals = false
64+
} = options;
65+
66+
let allNatives = null;
67+
let resolvedNativesPath = null;
68+
69+
return {
70+
name: 'native-treeshake',
71+
72+
setup(build) {
73+
const workingDir = build.initialOptions.absWorkingDir || process.cwd();
74+
resolvedNativesPath = resolve(workingDir, nativesPath);
75+
76+
// Lazily load native names
77+
const getNatives = () => {
78+
if (!allNatives) {
79+
allNatives = extractNativeNames(resolvedNativesPath);
80+
}
81+
return allNatives;
82+
};
83+
84+
// Virtual module that re-exports only used natives
85+
build.onResolve({ filter: /^@natives$/ }, args => {
86+
return {
87+
path: args.path,
88+
namespace: 'native-inject',
89+
pluginData: { importer: args.importer }
90+
};
91+
});
92+
93+
build.onLoad({ filter: /.*/, namespace: 'native-inject' }, async args => {
94+
// This is called when @natives is imported
95+
// We need to figure out what natives the importing file uses
96+
// For now, export everything and let esbuild tree-shake
97+
const natives = getNatives();
98+
const exports = [...natives].map(name => ` ${name}`).join(',\n');
99+
100+
return {
101+
contents: `export {\n${exports}\n} from '${resolvedNativesPath.replace(/\\/g, '/')}';`,
102+
loader: 'ts',
103+
resolveDir: dirname(resolvedNativesPath)
104+
};
105+
});
106+
107+
// Transform user code to add imports and optionally globalize
108+
build.onLoad({ filter: /\.(ts|js|tsx|jsx)$/ }, async args => {
109+
// Skip the natives file itself
110+
if (args.path === resolvedNativesPath) {
111+
return null;
112+
}
113+
114+
const source = readFileSync(args.path, 'utf-8');
115+
const natives = getNatives();
116+
const usedNatives = findUsedNatives(source, natives);
117+
118+
if (usedNatives.size === 0) {
119+
return null; // No natives used, don't transform
120+
}
121+
122+
// Generate import statement
123+
const importList = [...usedNatives].join(', ');
124+
const importStatement = `import { ${importList} } from '${resolvedNativesPath.replace(/\\/g, '/')}';\n`;
125+
126+
// Optionally add global assignments
127+
let globalAssignments = '';
128+
if (globals) {
129+
globalAssignments = [...usedNatives]
130+
.map(name => `globalThis.${name} = ${name};`)
131+
.join('\n') + '\n';
132+
}
133+
134+
const loader = args.path.endsWith('.tsx') ? 'tsx'
135+
: args.path.endsWith('.jsx') ? 'jsx'
136+
: args.path.endsWith('.ts') ? 'ts'
137+
: 'js';
138+
139+
return {
140+
contents: importStatement + globalAssignments + source,
141+
loader
142+
};
143+
});
144+
}
145+
};
146+
}
147+
148+
export default nativeTreeshake;

src/NativeCodeGen.Cli/Program.cs

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,53 @@ static async Task<int> Main(string[] args)
5454
description: "Treat warnings as errors",
5555
getDefaultValue: () => false);
5656

57+
var exportsOption = new Option<bool>(
58+
aliases: new[] { "--exports" },
59+
description: "Use ES module exports for tree-shaking (requires --raw --single-file)",
60+
getDefaultValue: () => false);
61+
62+
var packageOption = new Option<bool>(
63+
aliases: new[] { "--package" },
64+
description: "Generate a complete npm package with esbuild plugin",
65+
getDefaultValue: () => false);
66+
67+
var packageNameOption = new Option<string?>(
68+
aliases: new[] { "--package-name" },
69+
description: "npm package name (e.g., @nativewrappers/natives-rdr3)");
70+
71+
var packageVersionOption = new Option<string?>(
72+
aliases: new[] { "--package-version" },
73+
description: "npm package version (e.g., 1.0.0)",
74+
getDefaultValue: () => "0.0.1");
75+
5776
generateCommand.AddOption(inputOption);
5877
generateCommand.AddOption(outputOption);
5978
generateCommand.AddOption(formatOption);
6079
generateCommand.AddOption(namespacesOption);
6180
generateCommand.AddOption(rawOption);
6281
generateCommand.AddOption(singleFileOption);
6382
generateCommand.AddOption(strictOption);
83+
generateCommand.AddOption(exportsOption);
84+
generateCommand.AddOption(packageOption);
85+
generateCommand.AddOption(packageNameOption);
86+
generateCommand.AddOption(packageVersionOption);
6487

65-
generateCommand.SetHandler(async (input, output, format, namespaces, raw, singleFile, strict) =>
88+
generateCommand.SetHandler(async (context) =>
6689
{
67-
await Generate(input, output, format, namespaces, raw, singleFile, strict);
68-
}, inputOption, outputOption, formatOption, namespacesOption, rawOption, singleFileOption, strictOption);
90+
var input = context.ParseResult.GetValueForOption(inputOption)!;
91+
var output = context.ParseResult.GetValueForOption(outputOption)!;
92+
var format = context.ParseResult.GetValueForOption(formatOption)!;
93+
var namespaces = context.ParseResult.GetValueForOption(namespacesOption);
94+
var raw = context.ParseResult.GetValueForOption(rawOption);
95+
var singleFile = context.ParseResult.GetValueForOption(singleFileOption);
96+
var strict = context.ParseResult.GetValueForOption(strictOption);
97+
var exports = context.ParseResult.GetValueForOption(exportsOption);
98+
var package_ = context.ParseResult.GetValueForOption(packageOption);
99+
var packageName = context.ParseResult.GetValueForOption(packageNameOption);
100+
var packageVersion = context.ParseResult.GetValueForOption(packageVersionOption);
101+
102+
await Generate(input, output, format, namespaces, raw, singleFile, strict, exports, package_, packageName, packageVersion);
103+
});
69104

70105
// Validate command
71106
var validateCommand = new Command("validate", "Validate MDX files without generating output");
@@ -94,7 +129,7 @@ static async Task<int> Main(string[] args)
94129
return await rootCommand.InvokeAsync(args);
95130
}
96131

97-
static async Task Generate(string input, string output, string format, string[]? namespaces, bool raw, bool singleFile, bool strict)
132+
static async Task Generate(string input, string output, string format, string[]? namespaces, bool raw, bool singleFile, bool strict, bool exports, bool package_, string? packageName, string? packageVersion)
98133
{
99134
Console.WriteLine($"Generating {format} output...");
100135
Console.WriteLine($"Input: {input}");
@@ -107,6 +142,30 @@ static async Task Generate(string input, string output, string format, string[]?
107142
return;
108143
}
109144

145+
if (exports && (!raw || !singleFile))
146+
{
147+
Console.Error.WriteLine("Error: --exports requires --raw --single-file");
148+
Environment.ExitCode = 1;
149+
return;
150+
}
151+
152+
if (package_)
153+
{
154+
if (string.IsNullOrEmpty(packageName))
155+
{
156+
Console.Error.WriteLine("Error: --package requires --package-name");
157+
Environment.ExitCode = 1;
158+
return;
159+
}
160+
161+
// If raw mode is specified with package, also enable single-file and exports
162+
if (raw)
163+
{
164+
singleFile = true;
165+
exports = true;
166+
}
167+
}
168+
110169
var (db, errors, warnings) = await ParseAllFiles(input);
111170

112171
// Report issues
@@ -142,6 +201,10 @@ static async Task Generate(string input, string output, string format, string[]?
142201
Raw = raw,
143202
SingleFile = singleFile,
144203
Strict = strict,
204+
UseExports = exports,
205+
Package = package_,
206+
PackageName = packageName,
207+
PackageVersion = packageVersion ?? "0.0.1",
145208
Namespaces = namespaces?.Length > 0
146209
? new HashSet<string>(namespaces.SelectMany(n => n.Split(',')), StringComparer.OrdinalIgnoreCase)
147210
: null
@@ -320,23 +383,44 @@ static async Task Validate(string input, bool strict)
320383
.GroupBy(n => n.Namespace, StringComparer.OrdinalIgnoreCase)
321384
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
322385

323-
// Track struct usage - check parameter types for struct references
386+
// Track struct and enum usage
324387
var structDict = structRegistry.GetAllStructs();
388+
var enumDict = enumRegistry.GetAllEnums();
389+
325390
foreach (var native in allNatives)
326391
{
392+
// Check parameter types
327393
foreach (var param in native.Parameters)
328394
{
329-
// Check if parameter type matches a known struct
330395
var typeName = param.Type.Name;
396+
397+
// Check struct usage
331398
if (structDict.TryGetValue(typeName, out var structDef))
332399
{
333-
// Avoid duplicates
334-
if (!structDef.UsedByNatives.Any(u => u.Hash == native.Hash))
400+
if (!structDef.UsedByNatives.Contains(native.Hash))
401+
{
402+
structDef.UsedByNatives.Add(native.Hash);
403+
}
404+
}
405+
406+
// Check enum usage (when Category is Enum, Name holds the enum name)
407+
if (param.Type.Category == TypeCategory.Enum && enumDict.TryGetValue(param.Type.Name, out var enumDef))
408+
{
409+
if (!enumDef.UsedByNatives.Contains(native.Hash))
335410
{
336-
structDef.UsedByNatives.Add((native.Name, native.Hash));
411+
enumDef.UsedByNatives.Add(native.Hash);
337412
}
338413
}
339414
}
415+
416+
// Check return type for enum usage
417+
if (native.ReturnType.Category == TypeCategory.Enum && enumDict.TryGetValue(native.ReturnType.Name, out var returnEnumDef))
418+
{
419+
if (!returnEnumDef.UsedByNatives.Contains(native.Hash))
420+
{
421+
returnEnumDef.UsedByNatives.Add(native.Hash);
422+
}
423+
}
340424
}
341425

342426
// Convert to namespace list

src/NativeCodeGen.Core/Export/BaseExporter.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ public void Export(NativeDatabase db, string outputPath, ExportOptions options)
2727
var generatorOptions = new GeneratorOptions
2828
{
2929
UseClasses = !options.Raw,
30-
SingleFile = options.SingleFile
30+
SingleFile = options.SingleFile,
31+
UseExports = options.UseExports,
32+
Package = options.Package,
33+
PackageName = options.PackageName,
34+
PackageVersion = options.PackageVersion
3135
};
3236
Generator.Generate(db, outputPath, generatorOptions);
3337

src/NativeCodeGen.Core/Export/DatabaseConverter.cs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,10 @@ private static ExportEnum ConvertEnum(EnumDefinition enumDef)
136136
{
137137
Name = m.Name,
138138
Value = m.Value
139-
}).ToList()
139+
}).ToList(),
140+
UsedByNatives = enumDef.UsedByNatives.Count > 0
141+
? enumDef.UsedByNatives
142+
: null
140143
};
141144
}
142145

@@ -147,11 +150,7 @@ private static ExportStruct ConvertStruct(StructDefinition structDef)
147150
Name = structDef.Name,
148151
DefaultAlignment = structDef.DefaultAlignment,
149152
UsedByNatives = structDef.UsedByNatives.Count > 0
150-
? structDef.UsedByNatives.Select(u => new ExportNativeReference
151-
{
152-
Name = u.Name,
153-
Hash = u.Hash
154-
}).ToList()
153+
? structDef.UsedByNatives
155154
: null,
156155
Fields = structDef.Fields.Select(ConvertStructField).ToList()
157156
};

src/NativeCodeGen.Core/Export/ExportModels.cs

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ public partial class ExportEnum
117117
[ProtoMember(3)]
118118
public List<ExportEnumMember> Members { get; set; } = new();
119119

120+
[ProtoMember(4)]
121+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
122+
public List<string>? UsedByNatives { get; set; }
120123
}
121124

122125
[ProtoContract]
@@ -141,7 +144,7 @@ public partial class ExportStruct
141144

142145
[ProtoMember(3)]
143146
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
144-
public List<ExportNativeReference>? UsedByNatives { get; set; }
147+
public List<string>? UsedByNatives { get; set; }
145148

146149
[ProtoMember(4)]
147150
public int? DefaultAlignment { get; set; }
@@ -177,16 +180,6 @@ public partial class ExportStructField
177180
public int? Alignment { get; set; }
178181
}
179182

180-
[ProtoContract]
181-
public partial class ExportNativeReference
182-
{
183-
[ProtoMember(1)]
184-
public string Name { get; set; } = string.Empty;
185-
186-
[ProtoMember(2)]
187-
public string Hash { get; set; } = string.Empty;
188-
}
189-
190183
[ProtoContract]
191184
public partial class ExportSharedExample
192185
{

0 commit comments

Comments
 (0)