├── .github └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── RobloxCS.CLI ├── Program.cs └── RobloxCS.CLI.csproj ├── RobloxCS.Luau ├── AST │ ├── AST.cs │ ├── AnonymousFunction.cs │ ├── Argument.cs │ ├── ArgumentList.cs │ ├── ArrayType.cs │ ├── Assignment.cs │ ├── AssignmentTarget.cs │ ├── AttributeList.cs │ ├── BaseVariable.cs │ ├── BinaryOperator.cs │ ├── Block.cs │ ├── Break.cs │ ├── BuiltInAttribute.cs │ ├── Call.cs │ ├── Comment.cs │ ├── Continue.cs │ ├── ElementAccess.cs │ ├── Expression.cs │ ├── ExpressionStatement.cs │ ├── FieldType.cs │ ├── For.cs │ ├── Function.cs │ ├── FunctionType.cs │ ├── GenericName.cs │ ├── IdentifierName.cs │ ├── If.cs │ ├── IfExpression.cs │ ├── IndexCall.cs │ ├── InterfaceType.cs │ ├── InterpolatedString.cs │ ├── Interpolation.cs │ ├── KeyOfCall.cs │ ├── Literal.cs │ ├── MappedType.cs │ ├── MemberAccess.cs │ ├── MultiLineComment.cs │ ├── MultipleVariable.cs │ ├── Name.cs │ ├── NoOp.cs │ ├── NoOpExpression.cs │ ├── Node.cs │ ├── NumericFor.cs │ ├── OptionalType.cs │ ├── Parameter.cs │ ├── ParameterList.cs │ ├── ParameterType.cs │ ├── Parenthesized.cs │ ├── QualifiedName.cs │ ├── Repeat.cs │ ├── Return.cs │ ├── ScopedBlock.cs │ ├── SimpleName.cs │ ├── SingleLineComment.cs │ ├── Statement.cs │ ├── TableInitializer.cs │ ├── TypeAlias.cs │ ├── TypeCast.cs │ ├── TypeOfCall.cs │ ├── TypeRef.cs │ ├── UnaryOperator.cs │ ├── Variable.cs │ ├── VariableList.cs │ └── While.cs ├── AstUtility.cs ├── BaseWriter.cs ├── FileCompilation.cs ├── LuauWriter.cs ├── Macros.cs ├── OccupiedIdentifiersStack.cs ├── Prerequisites.cs ├── README.md ├── RobloxCS.Luau.csproj └── SymbolMetadataManager.cs ├── RobloxCS.Shared ├── Analysis.cs ├── Config.cs ├── ConfigReader.cs ├── Constants.cs ├── FileUtility.cs ├── Logger.cs ├── RobloxCS.Shared.csproj ├── RojoReader.cs └── StandardUtility.cs ├── RobloxCS.Tests ├── .lune │ └── RuntimeLibTest.luau ├── AstUtilityTest.cs ├── Base │ └── Generation.cs ├── GenerationTest.cs ├── LuauTests.cs ├── MacroTests │ ├── ExtraTest.cs │ ├── HashSetMacrosTest.cs │ ├── ListMacrosTest.cs │ └── ObjectMacrosTest.cs ├── RenderingTest.cs ├── RobloxCS.Tests.csproj ├── StandardUtilityTest.cs ├── TransformerTests │ └── MainTransformerTest.cs ├── WholeFileRenderingTest.cs ├── lune └── lune.exe ├── RobloxCS.sln ├── RobloxCS ├── Analyzer.cs ├── BaseGenerator.cs ├── Constants.cs ├── Include │ ├── RuntimeLib.luau │ └── Signal.luau ├── LuauGenerator.cs ├── README.md ├── RobloxCS.csproj ├── Transformers │ ├── BaseTransformer.cs │ ├── BuiltInTransformers.cs │ └── MainTransformer.cs ├── Transpiler.cs └── TranspilerUtility.cs ├── nuget.config └── roblox-cs.png /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | on: 3 | pull_request: 4 | push: 5 | branches: [ "master" ] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Setup .NET 13 | uses: actions/setup-dotnet@v4 14 | with: 15 | dotnet-version: 9.0.x 16 | 17 | - name: Install xmllint 18 | run: sudo apt-get update && sudo apt-get install -y libxml2-utils 19 | 20 | - name: Add RobloxCS GitHub packages source to NuGet 21 | run: dotnet nuget add source --store-password-in-clear-text -u roblox-csharp -p ${{ secrets.GITHUB_TOKEN }} -n rbxcs "https://nuget.pkg.github.com/roblox-csharp/index.json" 22 | 23 | - name: Extract version from .csproj 24 | id: extract_version 25 | run: | 26 | VERSION=$(xmllint --xpath "string(//Project/PropertyGroup/Version)" RobloxCS/RobloxCS.csproj) 27 | echo "PACKAGE_VERSION=$VERSION" >> $GITHUB_ENV 28 | 29 | - name: Package the project 30 | run: dotnet pack -c Release 31 | 32 | - name: Run tests 33 | run: | 34 | chmod +x ./RobloxCS.Tests/lune 35 | dotnet test 36 | 37 | - name: Check if version is already published 38 | id: check_version 39 | run: | 40 | VERSION_EXISTS=$(dotnet nuget search roblox-cs --source "rbxcs" | grep -c "${{ env.PACKAGE_VERSION }}" || true) 41 | echo "VERSION_EXISTS=$VERSION_EXISTS" >> $GITHUB_ENV 42 | 43 | - name: Find and publish the latest package 44 | if: env.VERSION_EXISTS == '0' 45 | run: | 46 | PACKAGE_PATH=$(find ./RobloxCS/bin/Release -name "*.nupkg" -type f -print0 | xargs -0 ls -1t | head -n 1) 47 | dotnet nuget push "$PACKAGE_PATH" --api-key ${{ secrets.GITHUB_TOKEN }} --source "rbxcs" --skip-duplicate 48 | 49 | - name: Version already published 50 | if: env.VERSION_EXISTS != '0' 51 | run: echo "The version is already published. Skipping publish step." 52 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: [ "master" ] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Setup .NET 13 | uses: actions/setup-dotnet@v4 14 | with: 15 | dotnet-version: 9.0.x 16 | 17 | - name: Add RobloxCS GitHub packages source to NuGet 18 | run: dotnet nuget add source --store-password-in-clear-text -u roblox-csharp -p ${{ secrets.GITHUB_TOKEN }} -n rbxcs "https://nuget.pkg.github.com/roblox-csharp/index.json" 19 | 20 | - name: Install ReportGenerator 21 | run: dotnet tool install --global dotnet-reportgenerator-globaltool --version 5.* 22 | 23 | - name: Configure PATH for tools 24 | run: echo "/home/runner/.dotnet/tools" >> $GITHUB_PATH 25 | 26 | - name: Run tests and generate coverage 27 | run: | 28 | chmod +x ./RobloxCS.Tests/lune 29 | dotnet test -c Release --collect:"XPlat Code Coverage" 30 | 31 | - name: Convert coverage to LCOV 32 | run: | 33 | reportgenerator -reports:"**/TestResults/**/*.cobertura.xml" -targetdir:"coverage" -reporttypes:lcov 34 | env: 35 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true 36 | DOTNET_CLI_TELEMETRY_OPTOUT: true 37 | 38 | - name: Report coverage 39 | continue-on-error: true 40 | uses: coverallsapp/github-action@v2.3.4 41 | with: 42 | github-token: ${{ secrets.GITHUB_TOKEN }} 43 | file: coverage/lcov.info 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vs/ 2 | .idea/ 3 | Properties/ 4 | 5 | RobloxCS.Tests/**/out 6 | RobloxCS.Tests/**/include 7 | RobloxCS.Tests/**/package-lock.json 8 | *.DotSettings.user 9 | **/test.cs 10 | RobloxCS/GlobalUsings.cs 11 | 12 | obj/ 13 | bin/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Riley "Runic" Peel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | roblox-cs 2 | 3 |

roblox-cs

4 |

A C# to Luau transpiler for Roblox

5 |
6 | 7 |
8 | Discord server 9 | CI Status 10 | CD Status 11 | Coverage Status 12 |
13 | 14 | ### Introduction 15 | 16 | roblox-cs is a [C#](https://learn.microsoft.com/en-us/dotnet/csharp/) to [Luau](https://luau.org/) transpiler, which means we effectively translate C# code into 17 | Luau. This is done by taking the C# AST and converting it into a Luau AST (that is functionally the same) and then finally rendering the Luau AST into Luau 18 | source code. 19 | 20 | ### Examples 21 | 22 | #### Hello, world! 23 | 24 | ```cs 25 | print("Hello, roblox-cs!"); 26 | ``` 27 | ```luau 28 | -- Compiled with roblox-cs v2.0.0 29 | 30 | print("Hello, roblox-cs!") 31 | return nil 32 | ``` 33 | 34 | #### Classes 35 | **Note:** In the future this example will import the `CS` library. It will also probably abandon `typeof()`. 36 | 37 | ```cs 38 | var myClass = new MyClass(69); 39 | myClass.DoSomething(); 40 | print(myClass.MyProperty); // 69 41 | print(myClass.MyField); // 0 42 | 43 | class MyClass(int value) 44 | { 45 | public readonly int MyField; 46 | public int MyProperty { get; } = value; 47 | 48 | public void DoSomething() => 49 | print("doing something!"); 50 | } 51 | ``` 52 | ```luau 53 | -- Compiled with roblox-cs v2.0.0 54 | 55 | local MyClass 56 | do 57 | MyClass = setmetatable({}, { 58 | __tostring = function(): string 59 | return "MyClass" 60 | end 61 | }) 62 | MyClass.__index = MyClass 63 | MyClass.__className = "MyClass" 64 | function MyClass.new(value: number): MyClass 65 | local self = (setmetatable({}, MyClass) :: any) :: MyClass 66 | return self:MyClass(value) or self 67 | end 68 | function MyClass:DoSomething(): () 69 | return print("doing something!") 70 | end 71 | function MyClass:MyClass(value: number): MyClass? 72 | return nil 73 | end 74 | end 75 | CS.defineGlobal("MyClass", MyClass) 76 | type MyClass = typeof(MyClass) 77 | 78 | local myClass = MyClass.new(69) 79 | myClass:DoSomething() 80 | print(myClass.MyProperty) 81 | print(myClass.MyField) 82 | return nil 83 | ``` 84 | 85 | #### Type Reflection 86 | **Note:** `Object.GetType()` is not supported. 87 | 88 | ```cs 89 | var intType = typeof(int); 90 | print(intType.Name); // Int32 91 | print(intType.Namespace); // System 92 | print(intType.BaseType.Name); // ValueType 93 | ``` 94 | ```luau 95 | local intType = { --[[ insert type info here ]] }; 96 | print(intType.Name); 97 | print(intType.Namespace); 98 | print(intType.BaseType.Name); 99 | ``` 100 | 101 | ### Join the Community! 102 | Discord server 103 | -------------------------------------------------------------------------------- /RobloxCS.CLI/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Xml.Linq; 3 | using CommandLine; 4 | using RobloxCS; 5 | using RobloxCS.Shared; 6 | 7 | Parser.Default 8 | .ParseArguments(args) 9 | .WithParsed(HandleOptions); 10 | 11 | static void HandleOptions(Options opts) 12 | { 13 | if (opts.Version) 14 | { 15 | var assembly = typeof(Transpiler).Assembly; 16 | var informationalVersionAttribute = assembly.GetCustomAttribute(); 17 | Console.WriteLine(informationalVersionAttribute?.InformationalVersion.Split('+').First()); 18 | return; 19 | } 20 | 21 | if (opts.SingleFile != null) 22 | { 23 | var transpiledLuau = Transpiler.TranspileSources([opts.SingleFile], 24 | new RojoProject(), 25 | ConfigReader.UnitTestingConfig); 26 | 27 | Console.WriteLine(transpiledLuau.First().Output); 28 | return; 29 | } 30 | 31 | if (!Directory.Exists(opts.ProjectDirectory)) 32 | throw Logger.Error($"Project directory does not exist at '{opts.ProjectDirectory}'"); 33 | 34 | var config = ConfigReader.Read(opts.ProjectDirectory); 35 | Transpiler.Transpile(opts.ProjectDirectory, config, opts.Verbose); 36 | } 37 | 38 | internal class Options 39 | { 40 | [Option('v', "version", 41 | Required = false, 42 | HelpText = "Returns the compiler version.")] 43 | public required bool Version { get; init; } 44 | 45 | [Option("verbose", 46 | Required = false, 47 | HelpText = "Verbosely outputs transpilation process.")] 48 | public required bool Verbose { get; init; } 49 | 50 | [Option('f', "single-file", 51 | Required = false, 52 | HelpText = "Transpiles a single file and spits the emitted Luau out into the console.")] 53 | public required string? SingleFile { get; init; } 54 | 55 | [Option('p', "project", 56 | Required = false, 57 | HelpText = "Explicitly specify the project directory to compile. If none specified, automatically attempts to locate one.")] 58 | public required string ProjectDirectory { get; init; } = "."; 59 | } -------------------------------------------------------------------------------- /RobloxCS.CLI/RobloxCS.CLI.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net9.0 6 | enable 7 | enable 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/AST.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class AST : Node 4 | { 5 | public List Statements { get; } 6 | 7 | public AST(List statements) 8 | { 9 | Statements = statements; 10 | AddChildren(Statements); 11 | } 12 | 13 | public override void Render(LuauWriter luau) 14 | { 15 | foreach (var statement in Statements) statement.Render(luau); 16 | 17 | luau.WriteReturn(); 18 | } 19 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/AnonymousFunction.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class AnonymousFunction : Expression 4 | { 5 | public ParameterList ParameterList { get; } 6 | public Block? Body { get; } 7 | public TypeRef? ReturnType { get; } 8 | public List AttributeLists { get; } 9 | public List? TypeParameters { get; } 10 | 11 | public AnonymousFunction(ParameterList parameterList, 12 | TypeRef? returnType = null, 13 | Block? body = null, 14 | List? attributeLists = null, 15 | List? typeParameters = null) 16 | { 17 | ParameterList = parameterList; 18 | Body = body; 19 | TypeParameters = typeParameters; 20 | Body = body; 21 | ReturnType = returnType; 22 | AttributeLists = attributeLists ?? []; 23 | AddChild(ParameterList); 24 | if (ReturnType != null) AddChild(ReturnType); 25 | if (Body != null) AddChild(Body); 26 | 27 | AddChildren(AttributeLists); 28 | if (typeParameters != null) AddChildren(typeParameters); 29 | } 30 | 31 | public override void Render(LuauWriter luau) => 32 | luau.WriteFunction(null, 33 | false, 34 | ParameterList, 35 | ReturnType, 36 | Body, 37 | AttributeLists, 38 | inlineAttributes: true, 39 | createNewline: false); 40 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Argument.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class Argument : Expression 4 | { 5 | public Expression Expression { get; } 6 | 7 | public Argument(Expression expression) 8 | { 9 | Expression = expression; 10 | AddChild(Expression); 11 | } 12 | 13 | public override void Render(LuauWriter luau) => Expression.Render(luau); 14 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/ArgumentList.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class ArgumentList : Expression 4 | { 5 | public static readonly ArgumentList Empty = new([]); 6 | 7 | public ArgumentList(List arguments) 8 | { 9 | Arguments = arguments; 10 | AddChildren(Arguments); 11 | } 12 | 13 | public List Arguments { get; } 14 | 15 | public override void Render(LuauWriter luau) 16 | { 17 | luau.Write('('); 18 | luau.WriteNodesCommaSeparated(Arguments); 19 | luau.Write(')'); 20 | } 21 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/ArrayType.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class ArrayType : TypeRef 4 | { 5 | public TypeRef ElementType { get; } 6 | 7 | public ArrayType(TypeRef elementType) 8 | : base("{ " + elementType.Path + " }", true) 9 | { 10 | ElementType = elementType; 11 | AddChild(ElementType); 12 | } 13 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Assignment.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public sealed class Assignment : Statement 4 | { 5 | public AssignmentTarget Target { get; } 6 | public Expression Value { get; } 7 | 8 | public Assignment(AssignmentTarget target, Expression value) 9 | { 10 | Target = target; 11 | Value = value; 12 | AddChildren([Target, Value]); 13 | } 14 | 15 | public override void Render(LuauWriter luau) => luau.WriteAssignment(Target, Value); 16 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/AssignmentTarget.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public abstract class AssignmentTarget : Expression; -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/AttributeList.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class AttributeList : Statement 4 | { 5 | public List Attributes { get; } 6 | public bool Inline { get; set; } = false; 7 | 8 | public AttributeList(List attributes) 9 | { 10 | Attributes = attributes; 11 | AddChildren(Attributes); 12 | } 13 | 14 | public override void Render(LuauWriter luau) 15 | { 16 | foreach (var attributeNode in Attributes) 17 | { 18 | if (attributeNode is BuiltInAttribute attribute) 19 | { 20 | attribute.Render(luau); 21 | 22 | if (Inline || attribute.Inline) continue; 23 | 24 | luau.WriteLine(); 25 | } 26 | else 27 | { 28 | // TODO: user-defined attribute stuff 29 | } 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/BaseVariable.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public abstract class BaseVariable : Statement 4 | { 5 | protected BaseVariable(bool isLocal, TypeRef? type) 6 | { 7 | IsLocal = isLocal; 8 | Type = type; 9 | if (Type != null) AddChild(Type); 10 | } 11 | 12 | public bool IsLocal { get; } 13 | public TypeRef? Type { get; } 14 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/BinaryOperator.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class BinaryOperator : Expression 4 | { 5 | public Expression Left { get; } 6 | public string Operator { get; } 7 | public Expression Right { get; } 8 | 9 | public BinaryOperator(Expression left, string @operator, Expression right) 10 | { 11 | Left = left; 12 | Operator = @operator; 13 | Right = right; 14 | AddChildren([Left, Right]); 15 | } 16 | 17 | public override void Render(LuauWriter luau) 18 | { 19 | Left.Render(luau); 20 | luau.Write($" {Operator} "); 21 | Right.Render(luau); 22 | } 23 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Block.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class Block : Statement 4 | { 5 | public Block(List statements) 6 | { 7 | Statements = statements; 8 | AddChildren(Statements); 9 | } 10 | 11 | public List Statements { get; } 12 | 13 | public override void Render(LuauWriter luau) => luau.WriteNodes(Statements); 14 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Break.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class Break : Statement 4 | { 5 | public override void Render(LuauWriter luau) => luau.WriteLine("break"); 6 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/BuiltInAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class BuiltInAttribute : Statement 4 | { 5 | public Name Name { get; } 6 | public bool Inline { get; } 7 | 8 | public BuiltInAttribute(Name name, bool inline = false) 9 | { 10 | Name = name; 11 | Inline = inline; 12 | AddChild(name); 13 | } 14 | 15 | public override void Render(LuauWriter luau) 16 | { 17 | luau.Write('@'); 18 | Name.Render(luau); 19 | } 20 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Call.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public sealed class Call : Expression 4 | { 5 | public Call(Expression callee, ArgumentList? argumentList = null) 6 | { 7 | // monkey patch for https://github.com/roblox-csharp/roblox-cs/issues/44 8 | if (callee is Name name) callee = AstUtility.GetNonGenericName(name); 9 | 10 | Callee = callee; 11 | ArgumentList = argumentList ?? ArgumentList.Empty; 12 | AddChildren([Callee, ArgumentList]); 13 | } 14 | 15 | public Expression Callee { get; } 16 | public ArgumentList ArgumentList { get; } 17 | 18 | public override void Render(LuauWriter luau) 19 | { 20 | Callee.Render(luau); 21 | luau.Write('('); 22 | luau.WriteNodesCommaSeparated(ArgumentList.Arguments); 23 | luau.Write(')'); 24 | } 25 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Comment.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public abstract class Comment(string contents) : Statement 4 | { 5 | public string Contents { get; } = contents; 6 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Continue.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class Continue : Statement 4 | { 5 | public override void Render(LuauWriter luau) => luau.WriteLine("continue"); 6 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/ElementAccess.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public sealed class ElementAccess : AssignmentTarget 4 | { 5 | public Expression Expression { get; } 6 | public Expression Index { get; set; } 7 | 8 | public ElementAccess(Expression expression, Expression index) 9 | { 10 | Expression = expression; 11 | Index = index; 12 | AddChildren([Expression, Index]); 13 | } 14 | 15 | public override void Render(LuauWriter luau) 16 | { 17 | Expression.Render(luau); 18 | luau.Write('['); 19 | Index.Render(luau); 20 | luau.Write(']'); 21 | } 22 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Expression.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public abstract class Expression : Node; -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/ExpressionStatement.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class ExpressionStatement : Statement 4 | { 5 | public Expression Expression { get; } 6 | 7 | public ExpressionStatement(Expression expression) 8 | { 9 | Expression = expression; 10 | AddChild(Expression); 11 | } 12 | 13 | public override void Render(LuauWriter luau) 14 | { 15 | Expression.Render(luau); 16 | 17 | if (Expression is NoOpExpression) return; 18 | 19 | luau.WriteLine(); 20 | } 21 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/FieldType.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class FieldType : TypeRef 4 | { 5 | public string Name { get; } 6 | public TypeRef ValueType { get; } 7 | public bool IsReadOnly { get; } 8 | 9 | public FieldType(string name, TypeRef valueType, bool isReadOnly) 10 | : base($"{(isReadOnly ? "read " : "")}{name}: {valueType.Path};", true) 11 | { 12 | Name = name; 13 | ValueType = valueType; 14 | IsReadOnly = isReadOnly; 15 | AddChild(ValueType); 16 | } 17 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/For.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class For : Statement 4 | { 5 | public List Names { get; } 6 | public Expression Iterable { get; } 7 | public Statement Body { get; } 8 | 9 | public For(List names, Expression iterable, Statement body) 10 | { 11 | Names = names; 12 | Iterable = iterable; 13 | Body = body; 14 | AddChildren(Names); 15 | AddChild(Iterable); 16 | AddChild(Body); 17 | } 18 | 19 | public override void Render(LuauWriter luau) 20 | { 21 | luau.Write("for "); 22 | luau.WriteNodesCommaSeparated(Names); 23 | 24 | luau.Write(" in "); 25 | Iterable.Render(luau); 26 | luau.WriteLine(" do"); 27 | luau.PushIndent(); 28 | 29 | Body.Render(luau); 30 | luau.PopIndent(); 31 | luau.WriteLine("end"); 32 | } 33 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Function.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class Function : Statement 4 | { 5 | public Name Name { get; } 6 | public bool IsLocal { get; } 7 | public ParameterList ParameterList { get; } 8 | public Block? Body { get; } 9 | public TypeRef? ReturnType { get; } 10 | public List AttributeLists { get; } 11 | public List? TypeParameters { get; } 12 | 13 | public Function(Name name, 14 | bool isLocal, 15 | ParameterList parameterList, 16 | TypeRef? returnType = null, 17 | Block? body = null, 18 | List? attributeLists = null, 19 | List? typeParameters = null) 20 | { 21 | Name = name; 22 | IsLocal = isLocal; 23 | ParameterList = parameterList; 24 | Body = body; 25 | ReturnType = returnType; 26 | AttributeLists = attributeLists ?? []; 27 | TypeParameters = typeParameters; 28 | 29 | AddChild(Name); 30 | AddChild(ParameterList); 31 | if (ReturnType != null) AddChild(ReturnType); 32 | if (Body != null) AddChild(Body); 33 | 34 | AddChildren(AttributeLists); 35 | if (typeParameters != null) AddChildren(typeParameters); 36 | } 37 | 38 | public override void Render(LuauWriter luau) => 39 | luau.WriteFunction(Name, 40 | IsLocal, 41 | ParameterList, 42 | ReturnType, 43 | Body, 44 | AttributeLists, 45 | TypeParameters); 46 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/FunctionType.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class FunctionType : TypeRef 4 | { 5 | public static readonly FunctionType NoOp = new([], new TypeRef("()")); 6 | 7 | public FunctionType(List parameterTypes, TypeRef returnType) 8 | : base("", true) 9 | { 10 | ParameterTypes = parameterTypes; 11 | ReturnType = returnType; 12 | Path = $"({string.Join(", ", parameterTypes.Select(type => type.Path))}) -> {returnType.Path}"; 13 | } 14 | 15 | public List ParameterTypes { get; } 16 | public TypeRef ReturnType { get; } 17 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/GenericName.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class GenericName(string text, List typeArguments) : SimpleName 4 | { 5 | public string Text { get; } = text; 6 | public List TypeArguments { get; } = typeArguments; 7 | 8 | public override void Render(LuauWriter luau) => luau.Write(ToString()); 9 | 10 | public override string ToString() => Text + '<' + string.Join(", ", TypeArguments) + '>'; 11 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/IdentifierName.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class IdentifierName(string text) : SimpleName 4 | { 5 | public string Text { get; } = text; 6 | 7 | public override void Render(LuauWriter luau) => luau.Write(Text); 8 | 9 | public override string ToString() => Text; 10 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/If.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class If : Statement 4 | { 5 | public Expression Condition { get; } 6 | public Block Body { get; } 7 | public Block? ElseBranch { get; } 8 | 9 | public If(Expression condition, Block body, Block? elseBranch = null) 10 | { 11 | Condition = condition; 12 | Body = body; 13 | ElseBranch = elseBranch; 14 | 15 | AddChildren([Condition, Body]); 16 | if (ElseBranch != null) AddChild(ElseBranch); 17 | } 18 | 19 | public override void Render(LuauWriter luau) 20 | { 21 | luau.Write("if "); 22 | Condition.Render(luau); 23 | luau.Write(" then"); 24 | 25 | var compact = ElseBranch == null 26 | && Body.Statements.Count == 1 27 | && Body.Statements.First() is Return { Expression: null or Literal { ValueText: "nil" } } or Break or Continue; 28 | 29 | luau.Write(compact ? ' ' : '\n'); 30 | if (!compact) luau.PushIndent(); 31 | 32 | (compact ? Body.Statements.First() : Body).Render(luau); 33 | if (compact) 34 | { 35 | luau.Remove(1); 36 | luau.Write(' '); 37 | } 38 | 39 | var isElseIf = ElseBranch is { Statements.Count: 1 } && ElseBranch.Statements.First() is If; 40 | if (ElseBranch != null) 41 | { 42 | luau.PopIndent(); 43 | luau.Write("else" + (isElseIf ? "" : '\n')); 44 | if (!isElseIf) luau.PushIndent(); 45 | 46 | ElseBranch.Render(luau); 47 | } 48 | 49 | if (!compact) luau.PopIndent(); 50 | 51 | if (isElseIf) return; 52 | 53 | luau.WriteLine("end"); 54 | } 55 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/IfExpression.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class IfExpression : Expression 4 | { 5 | public Expression Condition { get; } 6 | public Expression Body { get; } 7 | public Expression? ElseBranch { get; } 8 | public bool IsCompact { get; } 9 | 10 | public IfExpression(Expression condition, Expression body, Expression? elseBranch = null, bool isCompact = false) 11 | { 12 | Condition = condition; 13 | Body = body; 14 | ElseBranch = elseBranch; 15 | IsCompact = isCompact; 16 | 17 | AddChild(Condition); 18 | AddChild(Body); 19 | if (ElseBranch != null) AddChild(ElseBranch); 20 | } 21 | 22 | public override void Render(LuauWriter luau) 23 | { 24 | luau.Write("if "); 25 | Condition.Render(luau); 26 | luau.Write(" then"); 27 | luau.Write(IsCompact ? ' ' : '\n'); 28 | if (!IsCompact) luau.PushIndent(); 29 | 30 | Node body = IsCompact ? Body : new ExpressionStatement(Body); // for the newline 31 | body.Render(luau); 32 | 33 | var isElseIf = ElseBranch is IfExpression; 34 | 35 | if (ElseBranch == null) return; 36 | 37 | if (IsCompact) 38 | luau.Write(' '); 39 | else 40 | luau.PopIndent(); 41 | 42 | luau.Write("else"); 43 | if (IsCompact && !isElseIf) luau.Write(' '); 44 | if (!isElseIf && !IsCompact) 45 | { 46 | luau.WriteLine(); 47 | luau.PushIndent(); 48 | } 49 | 50 | Node elseBranch = IsCompact ? ElseBranch : new ExpressionStatement(ElseBranch); 51 | elseBranch.Render(luau); 52 | if (elseBranch is Statement) luau.Remove(1); 53 | 54 | if (isElseIf || IsCompact) return; 55 | 56 | luau.PopIndent(); 57 | } 58 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/IndexCall.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class IndexCall(TypeRef typeRef, TypeRef key) 4 | : TypeRef(typeRef.Path) 5 | { 6 | public TypeRef TypeRef { get; } = typeRef; 7 | public TypeRef Key { get; } = key; 8 | 9 | public override void Render(LuauWriter luau) 10 | { 11 | luau.Write("index<"); 12 | TypeRef.Render(luau); 13 | luau.Write(", "); 14 | Key.Render(luau); 15 | luau.Write(">"); 16 | } 17 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/InterfaceType.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class InterfaceType : TypeRef 4 | { 5 | public HashSet Fields { get; } 6 | public MappedType? ExtraMapping { get; } 7 | public bool IsCompact { get; } 8 | 9 | public InterfaceType(HashSet fields, MappedType? extraMapping = null, bool isCompact = true) 10 | : base("", true) 11 | { 12 | Fields = fields; 13 | ExtraMapping = extraMapping; 14 | IsCompact = Fields.Count == 0 && isCompact; 15 | Path = ToString(); 16 | } 17 | 18 | public string ToString(int indent = 0) 19 | { 20 | var tabsOutside = new string(' ', indent * BaseWriter.IndentSize); 21 | var tabsInside = new string(' ', (indent + 1) * BaseWriter.IndentSize); 22 | var newline = IsCompact ? "" : "\n"; 23 | 24 | return tabsOutside 25 | + "{" 26 | + newline 27 | + string.Join(newline, Fields.Select(field => tabsInside + field.Path)) 28 | + tabsOutside 29 | + newline 30 | + "}"; 31 | } 32 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/InterpolatedString.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class InterpolatedString : Expression 4 | { 5 | public List Parts { get; } 6 | 7 | public InterpolatedString(List parts) 8 | { 9 | Parts = parts; 10 | AddChildren(Parts); 11 | } 12 | 13 | public override void Render(LuauWriter luau) 14 | { 15 | luau.Write('`'); 16 | foreach (var part in Parts) part.Render(luau); 17 | 18 | luau.Write('`'); 19 | } 20 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Interpolation.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | /// 4 | /// An interpolated section of an 5 | /// 6 | public class Interpolation : Expression 7 | { 8 | public Expression Expression { get; } 9 | 10 | public Interpolation(Expression expression) 11 | { 12 | Expression = expression; 13 | AddChild(Expression); 14 | } 15 | 16 | public override void Render(LuauWriter luau) 17 | { 18 | luau.Write('{'); 19 | Expression.Render(luau); 20 | luau.Write('}'); 21 | } 22 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/KeyOfCall.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public sealed class KeyOfCall : TypeRef 4 | { 5 | public TypeRef TypeRef { get; } 6 | 7 | public KeyOfCall(TypeRef typeRef) 8 | : base(typeRef.Path) 9 | { 10 | TypeRef = typeRef; 11 | AddChild(TypeRef); 12 | } 13 | 14 | public override void Render(LuauWriter luau) 15 | { 16 | luau.Write("keyof<"); 17 | TypeRef.Render(luau); 18 | luau.Write(">"); 19 | } 20 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Literal.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class Literal(string valueText) : Expression 4 | { 5 | public string ValueText { get; } = valueText; 6 | 7 | public override void Render(LuauWriter luau) => luau.Write(ValueText); 8 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/MappedType.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class MappedType : TypeRef 4 | { 5 | public TypeRef KeyType { get; } 6 | public TypeRef ValueType { get; } 7 | 8 | public MappedType(TypeRef keyType, TypeRef valueType) 9 | : base($"{{ [{keyType.Path}]: " + valueType.Path + " }", true) 10 | { 11 | KeyType = keyType; 12 | ValueType = valueType; 13 | AddChildren([KeyType, ValueType]); 14 | } 15 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/MemberAccess.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class MemberAccess : AssignmentTarget 4 | { 5 | public MemberAccess(Expression expression, SimpleName name, char @operator = '.') 6 | { 7 | Expression = expression; 8 | Operator = @operator; 9 | Name = name; 10 | AddChildren([Expression, Name]); 11 | } 12 | 13 | public Expression Expression { get; } 14 | public char Operator { get; } 15 | public SimpleName Name { get; } 16 | 17 | public override void Render(LuauWriter luau) 18 | { 19 | Expression.Render(luau); 20 | luau.Write(Operator); 21 | Name.Render(luau); 22 | } 23 | 24 | public MemberAccess WithOperator(char @operator) => new(Expression, Name, @operator); 25 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/MultiLineComment.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class MultiLineComment(string contents) 4 | : Comment(contents) 5 | { 6 | public override void Render(LuauWriter luau) 7 | { 8 | luau.WriteLine("--[["); 9 | luau.WriteLine(Contents); 10 | luau.WriteLine("]]"); 11 | } 12 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/MultipleVariable.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public sealed class MultipleVariable : BaseVariable 4 | { 5 | public MultipleVariable(HashSet names, bool isLocal, List initializers, TypeRef? type = null) 6 | : base(isLocal, type) 7 | { 8 | Names = names; 9 | Initializers = initializers; 10 | 11 | AddChildren(Names); 12 | AddChildren(Initializers); 13 | } 14 | 15 | public HashSet Names { get; } 16 | public List Initializers { get; } 17 | 18 | public override void Render(LuauWriter luau) => luau.WriteVariable(Names, IsLocal, Initializers, Type); 19 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Name.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public abstract class Name : AssignmentTarget 4 | { 5 | public abstract override string ToString(); 6 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/NoOp.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | /// Optionally renders a newline. 4 | public sealed class NoOp(bool createNewline = true) : Statement 5 | { 6 | public override void Render(LuauWriter luau) 7 | { 8 | if (!createNewline) return; 9 | 10 | luau.WriteLine(); 11 | } 12 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/NoOpExpression.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | /// Only meant for use with macros. 4 | public sealed class NoOpExpression : Expression 5 | { 6 | public override void Render(LuauWriter luau) 7 | { 8 | } 9 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Node.cs: -------------------------------------------------------------------------------- 1 | using RobloxCS.Macros; 2 | using RobloxCS.Shared; 3 | 4 | namespace RobloxCS.Luau; 5 | 6 | public abstract class Node 7 | { 8 | public Node? Parent { get; private set; } 9 | public List Children { get; } = []; 10 | public MacroKind? ExpandedByMacro { get; private set; } 11 | 12 | private List? _descendants; 13 | 14 | public List Descendants 15 | { 16 | get 17 | { 18 | if (_descendants != null) return _descendants; 19 | 20 | _descendants = []; 21 | foreach (var child in Children) 22 | { 23 | _descendants.Add(child); 24 | _descendants.AddRange(child.Descendants); 25 | } 26 | 27 | return _descendants; 28 | } 29 | set => _descendants = value; 30 | } 31 | 32 | public abstract void Render(LuauWriter luau); 33 | 34 | public void MarkExpanded(MacroKind macroKind) 35 | { 36 | if (ExpandedByMacro != null) 37 | throw Logger.CompilerError($""" 38 | Attempted to mark already macro-expanded node as expanded. 39 | Current macro kind: {ExpandedByMacro} 40 | Attempted expanding macro kind: {macroKind} 41 | """.Trim()); 42 | 43 | ExpandedByMacro = macroKind; 44 | } 45 | 46 | protected void AddChild(Node child) 47 | { 48 | child.Parent = this; 49 | Children.Add(child); 50 | } 51 | 52 | protected void AddChildren(IEnumerable children) 53 | { 54 | foreach (var child in children) AddChild(child); 55 | } 56 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/NumericFor.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class NumericFor : Statement 4 | { 5 | public IdentifierName Name { get; } 6 | public Expression Minimum { get; } 7 | public Expression Maximum { get; } 8 | public Expression? IncrementBy { get; } 9 | public Statement Body { get; } 10 | 11 | public NumericFor(IdentifierName name, Expression minimum, Expression maximum, Expression? incrementBy, Statement body) 12 | { 13 | Name = name; 14 | Minimum = minimum; 15 | Maximum = maximum; 16 | IncrementBy = incrementBy; 17 | Body = body; 18 | 19 | AddChildren([Name, Minimum, Maximum]); 20 | if (IncrementBy != null) AddChild(IncrementBy); 21 | 22 | AddChild(Body); 23 | } 24 | 25 | public override void Render(LuauWriter luau) 26 | { 27 | luau.Write("for "); 28 | Name.Render(luau); 29 | luau.Write(" = "); 30 | Minimum.Render(luau); 31 | luau.Write(", "); 32 | Maximum.Render(luau); 33 | if (IncrementBy != null) 34 | { 35 | luau.Write(", "); 36 | IncrementBy.Render(luau); 37 | } 38 | 39 | luau.WriteLine(" do"); 40 | luau.PushIndent(); 41 | 42 | Body.Render(luau); 43 | 44 | luau.PopIndent(); 45 | luau.WriteLine("end"); 46 | } 47 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/OptionalType.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class OptionalType : TypeRef 4 | { 5 | public TypeRef NonNullableType { get; } 6 | 7 | public OptionalType(TypeRef nonNullableType) 8 | : base(nonNullableType.Path + "?", true) 9 | { 10 | NonNullableType = nonNullableType; 11 | AddChild(nonNullableType); 12 | } 13 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Parameter.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class Parameter : Statement 4 | { 5 | public IdentifierName Name { get; } 6 | public Expression? Initializer { get; } 7 | public TypeRef? Type { get; } 8 | public bool IsVararg { get; } 9 | 10 | // parameter initializers are not true to luau, but we have not used prerequisite statements anywhere yet. so when we do, this will likely be phased out. 11 | public Parameter(IdentifierName name, bool isVararg = false, Expression? initializer = null, TypeRef? type = null) 12 | { 13 | Name = name; 14 | Initializer = initializer; 15 | Type = type; 16 | IsVararg = isVararg; 17 | 18 | AddChild(Name); 19 | if (Initializer != null) AddChild(Initializer); 20 | if (Type != null) AddChild(FixType(Type)); 21 | } 22 | 23 | public override void Render(LuauWriter luau) 24 | { 25 | if (IsVararg) 26 | luau.Write("..."); 27 | else 28 | Name.Render(luau); 29 | 30 | if (Type == null) return; 31 | 32 | luau.Write(": "); 33 | Type.Render(luau); 34 | } 35 | 36 | private TypeRef FixType(TypeRef type) 37 | { 38 | while (true) 39 | { 40 | if (type is ArrayType arrayType && IsVararg) 41 | { 42 | type = arrayType.ElementType; 43 | 44 | continue; 45 | } 46 | 47 | var isOptional = type is OptionalType; 48 | if (Initializer != null || isOptional) 49 | { 50 | return isOptional ? type : new OptionalType(type); 51 | } 52 | 53 | return type; 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/ParameterList.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class ParameterList : Statement 4 | { 5 | public static readonly ParameterList Empty = new([]); 6 | 7 | public ParameterList(List parameters) 8 | { 9 | Parameters = parameters; 10 | AddChildren(Parameters); 11 | } 12 | 13 | public List Parameters { get; } 14 | 15 | public override void Render(LuauWriter luau) 16 | { 17 | luau.Write('('); 18 | luau.WriteNodesCommaSeparated(Parameters); 19 | luau.Write(')'); 20 | } 21 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/ParameterType.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class ParameterType : TypeRef 4 | { 5 | public string? Name { get; } 6 | public TypeRef Type { get; } 7 | 8 | public ParameterType(string? name, TypeRef type) 9 | : base(name != null ? $"{name}: {type.Path}" : type.Path, true) 10 | { 11 | Name = name; 12 | Type = type; 13 | } 14 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Parenthesized.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class Parenthesized : Expression 4 | { 5 | public Expression Expression { get; } 6 | 7 | public Parenthesized(Expression expression) 8 | { 9 | Expression = expression; 10 | } 11 | 12 | public override void Render(LuauWriter luau) 13 | { 14 | luau.Write('('); 15 | Expression.Render(luau); 16 | luau.Write(')'); 17 | } 18 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/QualifiedName.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class QualifiedName : Name 4 | { 5 | public QualifiedName(Name left, SimpleName right, char @operator = '.') 6 | { 7 | Left = left; 8 | Right = right; 9 | Operator = @operator; 10 | AddChildren([Left, Right]); 11 | } 12 | 13 | public Name Left { get; } 14 | public char Operator { get; } 15 | public SimpleName Right { get; } 16 | 17 | public override void Render(LuauWriter luau) 18 | { 19 | Left.Render(luau); 20 | luau.Write(Operator); 21 | Right.Render(luau); 22 | } 23 | 24 | public override string ToString() => Left.ToString() + Operator + Right; 25 | 26 | public QualifiedName WithOperator(char @operator) => new(Left, Right, @operator); 27 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Repeat.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class Repeat : Statement 4 | { 5 | public Expression UntilCondition { get; } 6 | public Statement Body { get; } 7 | 8 | public Repeat(Expression untilCondition, Statement body) 9 | { 10 | UntilCondition = untilCondition; 11 | Body = body; 12 | AddChildren([UntilCondition, Body]); 13 | } 14 | 15 | public override void Render(LuauWriter luau) 16 | { 17 | luau.WriteLine("repeat "); 18 | luau.PushIndent(); 19 | Body.Render(luau); 20 | luau.PopIndent(); 21 | luau.Write("until "); 22 | UntilCondition.Render(luau); 23 | luau.WriteLine(""); // is there a better way to add a new line? 24 | } 25 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Return.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau 2 | { 3 | public class Return : Statement 4 | { 5 | public Expression? Expression { get; } 6 | 7 | public Return(Expression? expression = null) 8 | { 9 | Expression = expression; 10 | if (Expression != null) AddChild(Expression); 11 | } 12 | 13 | public override void Render(LuauWriter luau) => luau.WriteReturn(Expression); 14 | } 15 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/ScopedBlock.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class ScopedBlock(List statements) 4 | : Block(statements) 5 | { 6 | public override void Render(LuauWriter luau) 7 | { 8 | luau.WriteLine("do"); 9 | luau.PushIndent(); 10 | base.Render(luau); 11 | luau.PopIndent(); 12 | luau.WriteLine("end"); 13 | } 14 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/SimpleName.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | /// 4 | /// A name that is singular and not a combination of multiple names 5 | /// 6 | public abstract class SimpleName : Name 7 | { 8 | public abstract override string ToString(); 9 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/SingleLineComment.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class SingleLineComment(string contents) 4 | : Comment(contents) 5 | { 6 | public override void Render(LuauWriter luau) 7 | { 8 | luau.Write("-- "); 9 | luau.Write(Contents); 10 | } 11 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Statement.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public abstract class Statement : Node; -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/TableInitializer.cs: -------------------------------------------------------------------------------- 1 | using RobloxCS.Shared; 2 | 3 | namespace RobloxCS.Luau; 4 | 5 | public class TableInitializer : Expression 6 | { 7 | public static readonly TableInitializer Empty = new(); 8 | 9 | public TableInitializer(List? values = null, 10 | List? keys = null) 11 | { 12 | Values = values ?? []; 13 | Keys = keys ?? []; 14 | 15 | KeyValuePairs = []; 16 | for (var i = 0; i < Math.Max(Values.Count, Keys.Count); i++) 17 | { 18 | var key = Keys.ElementAtOrDefault(i); 19 | var value = Values.ElementAtOrDefault(i); 20 | 21 | if (key == null || value == null) continue; 22 | 23 | KeyValuePairs.Add(KeyValuePair.Create(key, value)); 24 | } 25 | 26 | AddChildren(Values); 27 | AddChildren(Keys); 28 | } 29 | 30 | public List Values { get; } 31 | public List Keys { get; } 32 | public List> KeyValuePairs { get; } 33 | 34 | public static TableInitializer Union(TableInitializer a, TableInitializer b) 35 | { 36 | var kvpComparer = new StandardUtility.KeyValuePairEqualityComparer(); 37 | var pairs = a.KeyValuePairs.Union(b.KeyValuePairs, kvpComparer).ToDictionary(); 38 | 39 | return new TableInitializer(pairs.Values.ToList(), pairs.Keys.ToList()); 40 | } 41 | 42 | public override void Render(LuauWriter luau) 43 | { 44 | var hasAnyKeys = Keys.Count > 0; 45 | 46 | luau.Write('{'); 47 | if (hasAnyKeys) 48 | { 49 | luau.WriteLine(); 50 | luau.PushIndent(); 51 | } 52 | 53 | for (var i = 0; i < Values.Count; i++) 54 | { 55 | var value = Values[i]; 56 | var key = Keys.ElementAtOrDefault(i); 57 | if (key != null) 58 | { 59 | if (key is not IdentifierName) luau.Write('['); 60 | 61 | key.Render(luau); 62 | if (key is not IdentifierName) luau.Write(']'); 63 | 64 | luau.Write(" = "); 65 | } 66 | 67 | value.Render(luau); 68 | 69 | if (i == Values.Count - 1) continue; 70 | 71 | luau.Write(','); 72 | luau.Write(hasAnyKeys ? '\n' : ' '); 73 | } 74 | 75 | if (hasAnyKeys) 76 | { 77 | luau.PopIndent(); 78 | luau.WriteLine(); 79 | } 80 | 81 | luau.Write('}'); 82 | } 83 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/TypeAlias.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public sealed class TypeAlias : Statement 4 | { 5 | public SimpleName Name { get; } 6 | public TypeRef Type { get; } 7 | 8 | public TypeAlias(SimpleName name, TypeRef type) 9 | { 10 | Name = name; 11 | Type = type; 12 | 13 | AddChildren([Name, Type]); 14 | } 15 | 16 | public override void Render(LuauWriter luau) 17 | { 18 | luau.Write("type "); 19 | Name.Render(luau); 20 | luau.Write(" = "); 21 | Type.Render(luau); 22 | luau.WriteLine(); 23 | } 24 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/TypeCast.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class TypeCast : Expression 4 | { 5 | public Expression Expression { get; } 6 | public TypeRef Type { get; } 7 | 8 | public TypeCast(Expression expression, TypeRef type) 9 | { 10 | Expression = expression; 11 | Type = type; 12 | AddChildren([Expression, Type]); 13 | } 14 | 15 | public override void Render(LuauWriter luau) => luau.WriteTypeCast(Expression, Type); 16 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/TypeOfCall.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public sealed class TypeOfCall : TypeRef 4 | { 5 | public TypeOfCall(Expression expression) 6 | : base("") 7 | { 8 | Expression = expression; 9 | AddChild(Expression); 10 | } 11 | 12 | public Expression Expression { get; } 13 | 14 | public override void Render(LuauWriter luau) 15 | { 16 | luau.Write("typeof("); 17 | Expression.Render(luau); 18 | luau.Write(")"); 19 | } 20 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/TypeRef.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class TypeRef(string path, bool rawPath = false) : Name 4 | { 5 | public string Path { get; set; } = (rawPath ? path : AstUtility.CreateTypeRef(path)!.Path).Trim(); 6 | 7 | public override void Render(LuauWriter luau) => luau.Write(Path); 8 | 9 | public override string ToString() => Path; 10 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/UnaryOperator.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class UnaryOperator : Expression 4 | { 5 | public string Operator { get; } 6 | public Expression Operand { get; } 7 | 8 | public UnaryOperator(string @operator, Expression operand) 9 | { 10 | Operator = @operator; 11 | Operand = operand; 12 | AddChild(Operand); 13 | } 14 | 15 | public override void Render(LuauWriter luau) 16 | { 17 | luau.Write(Operator); 18 | Operand.Render(luau); 19 | } 20 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/Variable.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public sealed class Variable : BaseVariable 4 | { 5 | public Variable(IdentifierName name, bool isLocal, Expression? initializer = null, TypeRef? type = null) 6 | : base(isLocal, type) 7 | { 8 | Name = name; 9 | Initializer = initializer; 10 | 11 | AddChild(Name); 12 | if (Initializer != null) AddChild(Initializer); 13 | } 14 | 15 | public IdentifierName Name { get; } 16 | public Expression? Initializer { get; } 17 | 18 | public override void Render(LuauWriter luau) => luau.WriteVariable([Name], IsLocal, Initializer != null ? [Initializer] : [], Type); 19 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/VariableList.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class VariableList : Statement 4 | { 5 | public List Variables { get; } 6 | 7 | public VariableList(List variables) 8 | { 9 | Variables = variables; 10 | AddChildren(Variables); 11 | } 12 | 13 | public override void Render(LuauWriter luau) => luau.WriteNodes(Variables); 14 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/AST/While.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class While : Statement 4 | { 5 | public Expression Condition { get; } 6 | public Statement Body { get; } 7 | 8 | public While(Expression condition, Statement body) 9 | { 10 | Condition = condition; 11 | Body = body; 12 | AddChildren([Condition, Body]); 13 | } 14 | 15 | public override void Render(LuauWriter luau) 16 | { 17 | luau.Write("while "); 18 | Condition.Render(luau); 19 | luau.WriteLine(" do"); 20 | luau.PushIndent(); 21 | Body.Render(luau); 22 | luau.PopIndent(); 23 | luau.WriteLine("end"); 24 | } 25 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/BaseWriter.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | 3 | namespace RobloxCS.Luau; 4 | 5 | public class BaseWriter 6 | { 7 | public const int IndentSize = 2; 8 | private readonly StringBuilder _output = new(); 9 | private int _indent; 10 | 11 | public override string ToString() => _output.ToString(); 12 | public void PushIndent() => _indent++; 13 | public void PopIndent() => _indent--; 14 | public void WriteLine() => Write('\n'); 15 | public void WriteLine(char text) => WriteLine(text.ToString()); 16 | 17 | public void WriteLine(string text) 18 | { 19 | if (string.IsNullOrEmpty(text)) 20 | { 21 | _output.AppendLine(); 22 | 23 | return; 24 | } 25 | 26 | WriteIndent(); 27 | _output.Append(text); 28 | _output.Append('\n'); 29 | } 30 | 31 | public void Write(char text) => Write(text.ToString()); 32 | 33 | public void Write(string text) 34 | { 35 | WriteIndent(); 36 | _output.Append(text); 37 | } 38 | 39 | public void Remove(int amount) => _output.Remove(_output.Length - 1, amount); 40 | 41 | private void WriteIndent() => _output.Append(WasLastCharacter('\n') ? string.Concat(Enumerable.Repeat(" ", IndentSize * _indent)) : ""); 42 | 43 | private bool WasLastCharacter(char character) 44 | { 45 | if (_output.Length == 0) return false; 46 | 47 | return _output[^1] == character; 48 | } 49 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/FileCompilation.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | using RobloxCS.Shared; 3 | 4 | namespace RobloxCS.Luau; 5 | 6 | public class FileCompilation 7 | { 8 | public required SyntaxTree Tree { get; set; } 9 | public required ConfigData Config { get; init; } 10 | public required RojoProject? RojoProject { get; init; } 11 | public Prerequisites Prerequisites { get; } = new(); 12 | public OccupiedIdentifiersStack OccupiedIdentifiers { get; } = new(); 13 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/LuauWriter.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Luau; 2 | 3 | public class LuauWriter : BaseWriter 4 | { 5 | public string Render(AST ast) 6 | { 7 | ast.Render(this); 8 | 9 | return ToString(); 10 | } 11 | 12 | public void WriteNodesCommaSeparated(IEnumerable nodes) 13 | where TNode : Node 14 | { 15 | var nodesList = nodes.ToList(); 16 | var index = 0; 17 | foreach (var node in nodesList) 18 | { 19 | node.Render(this); 20 | if (index++ != nodesList.Count - 1) Write(", "); 21 | } 22 | } 23 | 24 | public void WriteNodes(List nodes) 25 | where TNode : Node 26 | { 27 | foreach (var node in nodes) node.Render(this); 28 | } 29 | 30 | public void WriteRequire(string requirePath) => WriteLine($"require({requirePath})"); 31 | 32 | public void WriteFunction(Name? name, 33 | bool isLocal, 34 | ParameterList parameterList, 35 | TypeRef? returnType = null, 36 | Block? body = null, 37 | List? attributeLists = null, 38 | List? typeParameters = null, 39 | bool inlineAttributes = false, 40 | bool createNewline = true) 41 | { 42 | foreach (var attributeList in attributeLists ?? []) 43 | { 44 | attributeList.Inline = inlineAttributes; 45 | attributeList.Render(this); 46 | } 47 | 48 | if (isLocal) Write("local "); 49 | 50 | Write("function"); 51 | if (name != null) 52 | { 53 | Write(' '); 54 | name.Render(this); 55 | } 56 | 57 | if (typeParameters != null) 58 | { 59 | Write('<'); 60 | WriteNodes(typeParameters); 61 | Write('>'); 62 | } 63 | 64 | parameterList.Render(this); 65 | WriteTypeAnnotation(returnType); 66 | WriteLine(); 67 | PushIndent(); 68 | 69 | body ??= new Block([]); 70 | foreach (var parameter in parameterList.Parameters) 71 | if (parameter.IsVararg) 72 | { 73 | var type = parameter.Type != null ? AstUtility.CreateTypeRef(parameter.Type.Path + "[]") : null; 74 | var value = new TableInitializer([AstUtility.Vararg]); 75 | body.Statements.Insert(0, new Variable(parameter.Name, true, value, type)); 76 | } 77 | else if (parameter.Initializer != null) 78 | { 79 | body.Statements.Insert(0, AstUtility.DefaultValueInitializer(parameter.Name, parameter.Initializer)); 80 | } 81 | 82 | body.Render(this); 83 | 84 | PopIndent(); 85 | if (createNewline) 86 | WriteLine("end"); 87 | else 88 | Write("end"); 89 | } 90 | 91 | public void WriteAssignment(Expression name, Expression initializer) 92 | { 93 | name.Render(this); 94 | Write(" = "); 95 | initializer.Render(this); 96 | WriteLine(); 97 | } 98 | 99 | public void WriteVariable(HashSet names, bool isLocal, List initializers, TypeRef? type = null) 100 | { 101 | if (isLocal) Write("local "); 102 | 103 | WriteNodesCommaSeparated(names); 104 | WriteTypeAnnotation(type); 105 | if (initializers.Count > 0) 106 | { 107 | Write(" = "); 108 | WriteNodesCommaSeparated(initializers); 109 | } 110 | 111 | WriteLine(); 112 | } 113 | 114 | public void WriteReturn(Expression? expression = null) 115 | { 116 | Write("return "); 117 | (expression ?? new Literal("nil")).Render(this); 118 | WriteLine(); 119 | } 120 | 121 | public void WriteTypeAnnotation(TypeRef? type) 122 | { 123 | if (type == null) return; 124 | 125 | Write(": "); 126 | type.Render(this); 127 | } 128 | 129 | public void WriteTypeCast(Expression expression, TypeRef type) 130 | { 131 | expression.Render(this); 132 | Write(" :: "); 133 | type.Render(this); 134 | } 135 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/OccupiedIdentifiersStack.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | using RobloxCS.Luau; 3 | 4 | namespace RobloxCS; 5 | 6 | public class OccupiedIdentifiersStack : Stack> 7 | { 8 | public void Push() 9 | { 10 | List newList = []; 11 | Push(newList); 12 | } 13 | 14 | public string GetDuplicateText(string text) 15 | { 16 | text = text.Replace("@", ""); 17 | 18 | var occurrences = CountOccurrences(text) - 1; 19 | var newText = occurrences > 0 ? "_" + occurrences : ""; 20 | var halves = text.Split('<'); // generics, poopoo. 21 | var duplicateText = halves.First() + newText + (halves.Length > 1 ? "<" + halves.Last() : ""); 22 | 23 | return duplicateText; 24 | } 25 | 26 | public int CountOccurrences(string text) => Peek().Count(identifier => identifier.Text == text); 27 | 28 | public IdentifierName AddIdentifier(SyntaxNode node, string text) => AddIdentifier(node.GetFirstToken(), text); 29 | public IdentifierName AddIdentifier(SyntaxToken token) => AddIdentifier(token, token.Text); 30 | 31 | public IdentifierName AddIdentifier(SyntaxToken token, string text) 32 | { 33 | var verbatim = text.Replace("@", ""); 34 | 35 | if (AstUtility.CheckReservedName(token, verbatim)) return null!; 36 | 37 | return AddIdentifier(verbatim); 38 | } 39 | 40 | /// Note: Skips reserved Luau name checks 41 | public IdentifierName AddIdentifier(string text) 42 | { 43 | AddIdentifier(new IdentifierName(text.Replace("@", ""))); 44 | 45 | return new IdentifierName(GetDuplicateText(text)); 46 | } 47 | 48 | private void AddIdentifier(IdentifierName identifierName) => Peek().Add(identifierName); 49 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/Prerequisites.cs: -------------------------------------------------------------------------------- 1 | using RobloxCS.Shared; 2 | 3 | namespace RobloxCS.Luau; 4 | 5 | public class Prerequisites 6 | { 7 | private readonly Stack> _statementsStack = []; 8 | 9 | public void Add(Statement statement) => _statementsStack.Peek().Add(statement); 10 | public void AddList(List statements) => _statementsStack.Peek().AddRange(statements); 11 | 12 | public List CaptureOnlyPrereqs(Action callback) 13 | { 14 | PushStatementsStack(); 15 | callback(); 16 | 17 | return PopStatementsStack(); 18 | } 19 | 20 | public (T, List) Capture(Func callback) 21 | { 22 | T? value = default; 23 | var prereqs = CaptureOnlyPrereqs(() => value = callback()); 24 | 25 | return (value!, prereqs); 26 | } 27 | 28 | public Expression NoPrereqs(Func callback) 29 | { 30 | Expression? expression = null; 31 | var statements = CaptureOnlyPrereqs(() => expression = callback()); 32 | if (statements.Count > 0) 33 | Logger.CompilerError("Assertion of no prereqs failed for " + (expression?.ToString() ?? "expression")); 34 | 35 | return expression!; 36 | } 37 | 38 | private void PushStatementsStack() 39 | { 40 | List statements = []; 41 | _statementsStack.Push(statements); 42 | } 43 | 44 | private List PopStatementsStack() 45 | { 46 | if (!_statementsStack.TryPop(out var popped)) 47 | Logger.CompilerError("Failed to pop prereq statements stack"); 48 | 49 | return popped!; 50 | } 51 | } -------------------------------------------------------------------------------- /RobloxCS.Luau/README.md: -------------------------------------------------------------------------------- 1 | # Luau AST 2 | 3 | This is where Luau AST nodes and their text representations reside. -------------------------------------------------------------------------------- /RobloxCS.Luau/RobloxCS.Luau.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Library 5 | net9.0 6 | RobloxCS.Luau 7 | enable 8 | enable 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /RobloxCS.Luau/SymbolMetadataManager.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | 3 | namespace RobloxCS.Luau; 4 | 5 | public class SymbolMetadata 6 | { 7 | public IdentifierName? EventConnectionName { get; set; } 8 | public Dictionary>? MethodOverloads { get; set; } 9 | } 10 | 11 | public static class SymbolMetadataManager 12 | { 13 | private static readonly Dictionary _metadata = []; 14 | 15 | public static SymbolMetadata Get(ISymbol symbol) 16 | { 17 | var metadata = _metadata.GetValueOrDefault(symbol); 18 | if (metadata == null) _metadata.Add(symbol, metadata = new SymbolMetadata()); 19 | 20 | return metadata; 21 | } 22 | } -------------------------------------------------------------------------------- /RobloxCS.Shared/Analysis.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Shared; 2 | 3 | public sealed class AnalysisResult 4 | { 5 | public TypeClassInfo TypeClassInfo { get; } = new(); 6 | public MemberClassInfo MemberClassInfo { get; } = new(); 7 | public MethodBaseClassInfo MethodBaseClassInfo { get; } = new(); 8 | public AssemblyClassInfo AssemblyClassInfo { get; } = new(); 9 | public ModuleClassInfo ModuleClassInfo { get; } = new(); 10 | public PropertyClassInfo PropertyClassInfo { get; } = new(); 11 | public CustomAttributeDataClassInfo CustomAttributeDataClassInfo { get; } = new(); 12 | } 13 | 14 | public abstract class BaseClassInfo 15 | { 16 | public HashSet MemberUses { get; } = []; 17 | } 18 | 19 | // temporary or something idk 20 | public sealed class TypeClassInfo : BaseClassInfo; 21 | public sealed class MemberClassInfo : BaseClassInfo; 22 | public sealed class MethodBaseClassInfo : BaseClassInfo; 23 | public sealed class AssemblyClassInfo : BaseClassInfo; 24 | public sealed class ModuleClassInfo : BaseClassInfo; 25 | public sealed class PropertyClassInfo : BaseClassInfo; 26 | public sealed class CustomAttributeDataClassInfo : BaseClassInfo; -------------------------------------------------------------------------------- /RobloxCS.Shared/Config.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS.Shared; 2 | 3 | public sealed class ConfigData 4 | { 5 | public required string SourceFolder { get; init; } 6 | public required string OutputFolder { get; init; } 7 | public required object[] EntryPointArguments { get; init; } = []; 8 | public required string RojoProjectName { get; init; } = "default"; 9 | 10 | public bool IsValid() => 11 | !string.IsNullOrEmpty(SourceFolder) 12 | && !string.IsNullOrEmpty(OutputFolder) 13 | && !string.IsNullOrEmpty(RojoProjectName); 14 | } -------------------------------------------------------------------------------- /RobloxCS.Shared/ConfigReader.cs: -------------------------------------------------------------------------------- 1 | using YamlDotNet.Serialization; 2 | using YamlDotNet.Serialization.NamingConventions; 3 | 4 | namespace RobloxCS.Shared; 5 | 6 | public static class ConfigReader 7 | { 8 | public static ConfigData UnitTestingConfig { get; } = new() 9 | { 10 | SourceFolder = "test-src", 11 | OutputFolder = "test-dist", 12 | RojoProjectName = "UNIT_TESTING", 13 | EntryPointArguments = [] 14 | }; 15 | 16 | private const string _fileName = "roblox-cs.yml"; 17 | 18 | public static ConfigData Read(string inputDirectory) 19 | { 20 | var configPath = inputDirectory + "/" + _fileName; 21 | ConfigData? config = null; 22 | var ymlContent = ""; 23 | 24 | try 25 | { 26 | ymlContent = File.ReadAllText(configPath); 27 | } 28 | catch (Exception e) 29 | { 30 | FailToRead(e.Message); 31 | } 32 | 33 | var deserializer = new DeserializerBuilder() 34 | .WithNamingConvention(PascalCaseNamingConvention.Instance) 35 | .WithAttemptingUnquotedStringTypeDeserialization() 36 | .WithDuplicateKeyChecking() 37 | .Build(); 38 | 39 | try 40 | { 41 | config = deserializer.Deserialize(ymlContent); 42 | } 43 | catch (Exception e) 44 | { 45 | FailToRead(e.ToString()); 46 | } 47 | 48 | if (config == null || !config.IsValid()) 49 | FailToRead($"Invalid {_fileName}! Make sure it has all required fields."); 50 | 51 | return config!; 52 | } 53 | 54 | private static void FailToRead(string message) => 55 | throw Logger.Error($"Failed to read {_fileName}!\n{message}"); 56 | } -------------------------------------------------------------------------------- /RobloxCS.Shared/Constants.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis.CSharp; 2 | 3 | namespace RobloxCS.Shared; 4 | 5 | public static class Constants 6 | { 7 | public const string IncludeFolderName = "Include"; 8 | 9 | public static readonly HashSet UNSUPPORTED_BITWISE_TYPES = ["UInt128", "ulong", "long", "Int128"]; 10 | 11 | public static readonly HashSet LENGTH_READABLE_TYPES = ["String", "string", "Array"]; 12 | 13 | public static readonly HashSet MEMBER_PARENT_SYNTAXES = 14 | [ 15 | SyntaxKind.NamespaceDeclaration, SyntaxKind.ClassDeclaration, SyntaxKind.InterfaceDeclaration, SyntaxKind.StructDeclaration 16 | ]; 17 | 18 | public static readonly HashSet GLOBAL_LIBRARIES = 19 | [ 20 | "task", 21 | "math", 22 | "table", 23 | "os", 24 | "buffer", 25 | "coroutine", 26 | "utf8", 27 | "debug" 28 | ]; 29 | 30 | public static readonly HashSet METAMETHODS = 31 | [ 32 | "__tostring", 33 | "__add", 34 | "__sub", 35 | "__mul", 36 | "__div", 37 | "__idiv", 38 | "__mod", 39 | "__pow", 40 | "__unm", 41 | "__eq", 42 | "__le", 43 | "__lte", 44 | "__len", 45 | "__iter", 46 | "__call", 47 | "__concat", 48 | "__mode", 49 | "__index", 50 | "__newindex", 51 | "__metatable" 52 | ]; 53 | 54 | public static readonly HashSet LUAU_KEYWORDS = 55 | [ 56 | "local", 57 | "and", 58 | "or", 59 | "if", 60 | "else", 61 | "elseif", 62 | "then", 63 | "do", 64 | "end", 65 | "function", 66 | "for", 67 | "while", 68 | "in", 69 | "export", 70 | "type", 71 | "typeof" 72 | ]; 73 | 74 | public static readonly HashSet RESERVED_IDENTIFIERS = ["CS", "next", ..LUAU_KEYWORDS]; 75 | 76 | public static readonly HashSet DECIMAL_TYPES = ["float", "double", "Single", "Double"]; 77 | 78 | public static readonly HashSet INTEGER_TYPES = 79 | [ 80 | "sbyte", 81 | "byte", 82 | "short", 83 | "ushort", 84 | "int", 85 | "uint", 86 | "long", 87 | "ulong", 88 | "SByte", 89 | "Byte", 90 | "Int16", 91 | "Int32", 92 | "Int64", 93 | "Int128", 94 | "UInt16", 95 | "UInt32", 96 | "UInt64", 97 | "UInt128" 98 | ]; 99 | } -------------------------------------------------------------------------------- /RobloxCS.Shared/FileUtility.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Text.RegularExpressions; 3 | using System.Xml.Linq; 4 | using Microsoft.CodeAnalysis; 5 | using RobloxCS.Shared; 6 | using Path = System.IO.Path; 7 | 8 | namespace RobloxCS; 9 | 10 | public static class FileUtility 11 | { 12 | private const string _runtimeAssemblyName = "Roblox"; 13 | 14 | public static string? GetRbxcsDirectory() 15 | { 16 | var directoryName = Path.GetDirectoryName(GetAssemblyDirectory()); 17 | 18 | return directoryName == null ? null : FixPathSeparator(directoryName); 19 | } 20 | 21 | public static List GetCompilationReferences() 22 | { 23 | var runtimeLibAssemblyPath = string.Join('/', GetAssemblyDirectory(), _runtimeAssemblyName + ".dll"); 24 | if (!File.Exists(runtimeLibAssemblyPath)) 25 | { 26 | var directoryName = Path.GetDirectoryName(runtimeLibAssemblyPath); 27 | var location = directoryName == null 28 | ? "(could not find assembly directory)" 29 | : FixPathSeparator(directoryName); 30 | 31 | Logger.Error($"Failed to find {_runtimeAssemblyName}.dll in {location}"); 32 | } 33 | 34 | return 35 | [ 36 | MetadataReference.CreateFromFile(runtimeLibAssemblyPath), 37 | ..GetCoreLibReferences() 38 | ]; 39 | } 40 | 41 | private static HashSet GetCoreLibReferences() 42 | { 43 | var coreLib = typeof(object).Assembly.Location; 44 | HashSet coreDlls = ["System.Runtime.dll", "System.Core.dll", "System.Collections.dll"]; 45 | HashSet references = [MetadataReference.CreateFromFile(coreLib)]; 46 | 47 | foreach (var dllPath in coreDlls.Select(coreDll => Path.Combine(Path.GetDirectoryName(coreLib)!, coreDll))) 48 | references.Add(MetadataReference.CreateFromFile(dllPath)); 49 | 50 | references.Add(MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location)); 51 | references.Add(MetadataReference.CreateFromFile(typeof(List<>).Assembly.Location)); 52 | 53 | // TODO: add references to installed packages 54 | return references; 55 | } 56 | 57 | private static string FixPathSeparator(string path) 58 | { 59 | var cleanedPath = Path.TrimEndingDirectorySeparator(path) 60 | .Replace(@"\\", "/") 61 | .Replace('\\', '/') 62 | .Replace("//", "/"); 63 | 64 | return Regex.Replace(cleanedPath, @"(? Log(message, ConsoleColor.Green, "OK"); 22 | 23 | public static void Info(string message) => Log(message, ConsoleColor.Cyan, "INFO"); 24 | 25 | public static CleanExitException Error(string message) 26 | { 27 | Log(message, ConsoleColor.Red, "ERROR"); 28 | return new CleanExitException(message); 29 | } 30 | 31 | public static CleanExitException CompilerError(string message, SyntaxNode node) => 32 | CodegenError(node, message + _compilerError); 33 | 34 | public static CleanExitException CompilerError(string message, SyntaxToken token) => 35 | CodegenError(token, message + _compilerError); 36 | 37 | public static CleanExitException CompilerError(string message) => Error(message + _compilerError); 38 | 39 | public static CleanExitException CodegenError(SyntaxToken token, string message) => 40 | Error($"{message}\n\t- {FormatLocation(token.GetLocation().GetLineSpan())}"); 41 | 42 | public static void CodegenWarning(SyntaxToken token, string message) 43 | { 44 | var lineSpan = token.GetLocation().GetLineSpan(); 45 | Warn($"{message}\n\t- {FormatLocation(lineSpan)}"); 46 | } 47 | 48 | public static CleanExitException UnsupportedError(SyntaxNode node, string subject, bool useIs = false, bool useYet = true) => 49 | UnsupportedError(node.GetFirstToken(), subject, useIs, useYet); 50 | 51 | public static CleanExitException UnsupportedError(SyntaxToken token, string subject, bool useIs = false, bool useYet = true) => 52 | CodegenError(token, $"{subject} {(useIs ? "is" : "are")} not {(useYet ? "yet " : "")}supported, sorry!"); 53 | 54 | public static CleanExitException CodegenError(SyntaxNode node, string message) => 55 | CodegenError(node.GetFirstToken(), message); 56 | 57 | public static void CodegenWarning(SyntaxNode node, string message) => 58 | CodegenWarning(node.GetFirstToken(), message); 59 | 60 | public static void HandleDiagnostic(Diagnostic diagnostic) 61 | { 62 | HashSet ignoredCodes = ["CS7022", "CS0017" /* more than one entry point */]; 63 | 64 | if (ignoredCodes.Contains(diagnostic.Id)) return; 65 | 66 | var lineSpan = diagnostic.Location.GetLineSpan(); 67 | var diagnosticMessage = $"{diagnostic.Id}: {diagnostic.GetMessage()}"; 68 | var location = $"\n\t- {FormatLocation(lineSpan)}"; 69 | switch (diagnostic.Severity) 70 | { 71 | case DiagnosticSeverity.Error: 72 | { 73 | Error(diagnosticMessage + location); 74 | break; 75 | } 76 | case DiagnosticSeverity.Warning: 77 | { 78 | if (diagnostic.IsWarningAsError) 79 | Error(diagnosticMessage + location); 80 | else 81 | Warn(diagnosticMessage + location); 82 | 83 | break; 84 | } 85 | case DiagnosticSeverity.Info: 86 | { 87 | Info(diagnosticMessage); 88 | break; 89 | } 90 | } 91 | } 92 | 93 | public static void Warn(string message) => Log(message, ConsoleColor.Yellow, "WARN"); 94 | 95 | public static void Debug(string message) => Log(message, ConsoleColor.Magenta, "DEBUG"); 96 | 97 | private static void Log(string message, ConsoleColor color, string level) 98 | { 99 | var originalColor = Console.ForegroundColor; 100 | Console.ForegroundColor = color; 101 | Console.WriteLine($"[{level}] {message}"); 102 | Console.ForegroundColor = originalColor; 103 | } 104 | 105 | private static string FormatLocation(FileLinePositionSpan lineSpan) => 106 | $"{(lineSpan.Path == "" ? "" : lineSpan.Path)}:{lineSpan.StartLinePosition.Line + 1}:{lineSpan.StartLinePosition.Character + 1}"; 107 | } -------------------------------------------------------------------------------- /RobloxCS.Shared/RobloxCS.Shared.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | RobloxCS.Shared 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /RobloxCS.Shared/RojoReader.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace RobloxCS.Shared; 6 | 7 | public sealed class RojoProject 8 | { 9 | [JsonPropertyName("name")] public string Name { get; init; } 10 | 11 | [JsonPropertyName("tree")] public InstanceDescription Tree { get; init; } 12 | 13 | [JsonPropertyName("servePort")] public int ServePort { get; init; } = 34872; 14 | 15 | [JsonPropertyName("servePlaceIds")] public List ServePlaceIDs { get; init; } = []; 16 | 17 | [JsonPropertyName("placeId")] public string? PlaceId { get; init; } 18 | 19 | [JsonPropertyName("gameId")] public string? GameId { get; init; } 20 | 21 | [JsonPropertyName("serveAddress")] public string? ServeAddress { get; init; } 22 | 23 | [JsonPropertyName("globIgnorePaths")] public List GlobIgnorePaths { get; init; } = []; 24 | 25 | [JsonPropertyName("emitLegacyScripts")] 26 | public bool EmitLegacyScripts { get; init; } = true; 27 | 28 | public bool IsValid() => !string.IsNullOrEmpty(Name) && Tree != null; 29 | } 30 | 31 | public sealed class InstanceDescription 32 | { 33 | [JsonPropertyName("$className")] 34 | public string? ClassName { get; init; } 35 | 36 | [JsonPropertyName("$path")] 37 | public string? Path { get; init; } 38 | 39 | [JsonPropertyName("$properties")] 40 | public Dictionary? Properties { get; init; } 41 | 42 | [JsonPropertyName("$ignoreUnknownInstances")] 43 | public bool IgnoreUnknownInstances { get; init; } = true; 44 | public Dictionary Instances { get; init; } = []; 45 | 46 | [JsonExtensionData] 47 | public IDictionary AdditionalData { get; init; } = new Dictionary(); 48 | 49 | public void OnDeserialized() 50 | { 51 | foreach (var kvp in AdditionalData) 52 | { 53 | var childInstance = kvp.Value.Deserialize()!; 54 | Instances[kvp.Key] = childInstance; 55 | childInstance.OnDeserialized(); 56 | } 57 | } 58 | } 59 | 60 | public static class RojoReader 61 | { 62 | public static RojoProject Read(string projectPath) 63 | { 64 | var jsonContent = ""; 65 | RojoProject? project = null; 66 | 67 | try 68 | { 69 | jsonContent = File.ReadAllText(projectPath); 70 | } 71 | catch (Exception e) 72 | { 73 | FailToRead(projectPath, e.Message); 74 | } 75 | 76 | try 77 | { 78 | project = JsonSerializer.Deserialize(jsonContent); 79 | } 80 | catch (Exception e) 81 | { 82 | FailToRead(projectPath, e.ToString()); 83 | } 84 | 85 | if (project == null || !project.IsValid()) 86 | FailToRead(projectPath, "Invalid Rojo project! Make sure it has all required fields ('name' and 'tree')."); 87 | 88 | UpdateChildInstances(project!.Tree); 89 | return project!; 90 | } 91 | 92 | public static RojoProject? ReadFromDirectory(string inputDirectory, string projectName) 93 | { 94 | if (projectName == "UNIT_TESTING") return null; 95 | 96 | var path = FindProjectPath(inputDirectory, projectName); 97 | if (path == null) 98 | throw Logger.Error($"Failed to find Rojo project file \"{projectName}.project.json\"!"); 99 | 100 | return Read(path); 101 | } 102 | 103 | public static string? ResolveInstancePath(RojoProject project, string filePath) 104 | { 105 | var path = TraverseInstanceTree(project.Tree, StandardUtility.FixPathSeparator(filePath)); 106 | return path == null ? null : FormatInstancePath(StandardUtility.FixPathSeparator(path)); 107 | } 108 | 109 | private static string? FindProjectPath(string directoryPath, string projectName) => 110 | Directory.GetFiles(directoryPath) 111 | .FirstOrDefault(file => Path.GetFileName(file) == $"{projectName}.project.json"); 112 | 113 | private static string? TraverseInstanceTree(InstanceDescription instance, string filePath) 114 | { 115 | var instancePath = instance.Path != null ? StandardUtility.FixPathSeparator(instance.Path) : null; 116 | if (instancePath != null && filePath.StartsWith(instancePath)) 117 | { 118 | var remainingPath = filePath[(instancePath.Length + 1)..]; // +1 to omit '/' 119 | return Path.ChangeExtension(remainingPath, null); 120 | } 121 | 122 | foreach (var (leftName, value) in instance.Instances) 123 | { 124 | var result = TraverseInstanceTree(value, filePath); 125 | if (result == null) continue; 126 | 127 | return $"{leftName}/{result}"; 128 | } 129 | 130 | return null; 131 | } 132 | 133 | private static string FormatInstancePath(string path) 134 | { 135 | var segments = path.Split('/'); 136 | var formattedPath = new StringBuilder(); 137 | 138 | foreach (var segment in segments) 139 | { 140 | if (segment == segments.First()) 141 | { 142 | formattedPath.Append(segment); 143 | } 144 | else 145 | { 146 | formattedPath.Append(formattedPath.Length > 0 ? "[\"" : ""); 147 | formattedPath.Append(segment); 148 | formattedPath.Append("\"]"); 149 | } 150 | } 151 | 152 | return formattedPath.ToString(); 153 | } 154 | 155 | private static void UpdateChildInstances(InstanceDescription instance) 156 | { 157 | instance.OnDeserialized(); 158 | foreach (var childInstance in instance.Instances.Values) 159 | UpdateChildInstances(childInstance); 160 | } 161 | 162 | private static void FailToRead(string configPath, string message) => 163 | throw Logger.Error($"Failed to read {configPath}!\nReason: {message}"); 164 | } -------------------------------------------------------------------------------- /RobloxCS.Shared/StandardUtility.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Text.RegularExpressions; 3 | using Microsoft.CodeAnalysis; 4 | using Microsoft.CodeAnalysis.CSharp; 5 | using Microsoft.CodeAnalysis.CSharp.Syntax; 6 | using static RobloxCS.Shared.Constants; 7 | 8 | namespace RobloxCS.Shared; 9 | 10 | public static class StandardUtility 11 | { 12 | public static Type GetRuntimeType(SemanticModel semanticModel, SyntaxNode node, ITypeSymbol typeSymbol) 13 | { 14 | var fullyQualifiedName = GetFullSymbolName(typeSymbol); 15 | 16 | Type? type = null; 17 | var assemblyContainsError = false; 18 | using (var memoryStream = new MemoryStream()) 19 | { 20 | // var emitResult = 21 | semanticModel.Compilation.Emit(memoryStream); 22 | 23 | // var errors = emitResult.Diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ToList(); 24 | // if (errors.Count > 0) 25 | // Logger.CodegenWarning(node, $"[GetRuntimeType()] Semantic model compilation had errors:\n{string.Join('\n', errors.Select(e => e.GetMessage()))}"); 26 | 27 | memoryStream.Seek(0, SeekOrigin.Begin); 28 | Assembly? assembly = null; 29 | try 30 | { 31 | assembly = Assembly.Load(memoryStream.ToArray()); 32 | } 33 | catch (Exception e) 34 | { 35 | assemblyContainsError = true; 36 | 37 | // temporarily commented 38 | // throw Logger.CodegenError(node, $"Failed to resolve runtime type '{fullyQualifiedName}' because the assembly could not be loaded: {e.Message}"); 39 | } 40 | 41 | if (assembly != null) 42 | type = assembly.GetType(fullyQualifiedName); // get the type from the loaded assembly 43 | } 44 | 45 | type ??= Type.GetType(fullyQualifiedName); 46 | if (type == null && !assemblyContainsError) 47 | throw Logger.CodegenError(node, $"[GetRuntimeType()]: Unable to resolve type '{fullyQualifiedName}'."); 48 | 49 | return type!; 50 | } 51 | 52 | public static string GetFullSymbolName(ISymbol symbol) 53 | { 54 | var containerName = symbol.ContainingNamespace != null || symbol.ContainingType != null 55 | ? GetFullSymbolName(symbol.ContainingNamespace ?? (ISymbol)symbol.ContainingType) 56 | : null; 57 | 58 | return (!string.IsNullOrEmpty(containerName) ? containerName + "." : "") + symbol.Name; 59 | } 60 | 61 | public static bool DoesTypeInheritFrom(ITypeSymbol? derived, string typeName) 62 | { 63 | if (derived == null) 64 | return false; 65 | 66 | return derived.Name == typeName 67 | || derived.BaseType != null 68 | && DoesTypeInheritFrom(derived.BaseType, typeName); 69 | } 70 | 71 | public static bool DoesTypeInheritFrom(ITypeSymbol derived, ITypeSymbol baseType) 72 | { 73 | var current = derived; 74 | while (current != null) 75 | { 76 | if (SymbolEqualityComparer.Default.Equals(current, baseType)) return true; 77 | 78 | current = current.BaseType; 79 | } 80 | 81 | return false; 82 | } 83 | 84 | public static string GetDefaultValueForType(string typeName) 85 | { 86 | if (INTEGER_TYPES.Contains(typeName) || DECIMAL_TYPES.Contains(typeName)) return "0"; 87 | 88 | return typeName switch 89 | { 90 | "char" or "Char" or "string" or "String" => "\"\"", 91 | "bool" or "Boolean" => "false", 92 | _ => "nil" 93 | }; 94 | } 95 | 96 | public static ISymbol? FindMember(INamespaceSymbol namespaceSymbol, string memberName) 97 | { 98 | var member = namespaceSymbol.GetMembers().FirstOrDefault(member => member?.Name == memberName, null); 99 | if (member == null && namespaceSymbol.ContainingNamespace != null) member = FindMember(namespaceSymbol.ContainingNamespace, memberName); 100 | 101 | return member; 102 | } 103 | 104 | public static ISymbol? FindMemberDeep(INamedTypeSymbol namedTypeSymbol, string memberName) 105 | { 106 | var member = namedTypeSymbol.GetMembers().FirstOrDefault(member => member.Name == memberName); 107 | if (namedTypeSymbol.BaseType != null && member == null) return FindMemberDeep(namedTypeSymbol.BaseType, memberName); 108 | 109 | return member; 110 | } 111 | 112 | public static string FixPathSeparator(string path) 113 | { 114 | path = Path.TrimEndingDirectorySeparator(path) 115 | .Replace(@"\\", "/") 116 | .Replace('\\', '/') 117 | .Replace("//", "/"); 118 | 119 | return Regex.Replace(path, @"(? nil"; 154 | } 155 | 156 | if (csharpType.StartsWith("Func<")) 157 | { 158 | var typeArgs = ExtractTypeArguments(csharpType).ConvertAll(GetMappedType); 159 | var returnType = typeArgs.Last(); 160 | typeArgs = typeArgs.SkipLast(1).ToList(); 161 | 162 | return $"({string.Join(", ", typeArgs)}) -> {returnType}"; 163 | } 164 | 165 | if (csharpType.StartsWith("Dictionary<")) 166 | { 167 | var typeArgs = ExtractTypeArguments(csharpType).ConvertAll(GetMappedType); 168 | var keyType = typeArgs.First(); 169 | var valueType = typeArgs.Last(); 170 | 171 | return $"{{ [{keyType}]: {valueType} }}"; 172 | } 173 | 174 | if (csharpType.StartsWith("IEnumerator<")) 175 | { 176 | var elementType = GetMappedType(ExtractTypeArguments(csharpType).First()); 177 | return $"CS.IEnumerator<{elementType}>"; 178 | } 179 | 180 | if (csharpType.StartsWith("HashSet<")) 181 | { 182 | var elementType = GetMappedType(ExtractTypeArguments(csharpType).First()); 183 | return $"{{ [{elementType}]: boolean }}"; 184 | } 185 | 186 | if (csharpType.StartsWith("Roblox.Enum")) 187 | return GetMappedType(csharpType.Replace("Roblox.Enum", "Enum")); 188 | 189 | return csharpType switch 190 | { 191 | "Object" or "object" or "dynamic" => "any", 192 | "void" or "Void" => "()", 193 | "null" => "nil", 194 | "char" or "Char" or "String" => "string", 195 | "Boolean" or "bool" => "boolean", 196 | "System.Index" or "Index" => "number", 197 | "Roblox.Buffer" or "Buffer" => "buffer", 198 | "System.Type" or "Type" => "any", // "CS.Type", 199 | _ => INTEGER_TYPES.Contains(csharpType) || DECIMAL_TYPES.Contains(csharpType) 200 | ? "number" 201 | : csharpType 202 | }; 203 | } 204 | 205 | public static string? GetBit32MethodName(string bitOp) => 206 | bitOp switch 207 | { 208 | "&=" or "&" => "band", 209 | "|=" or "|" => "bor", 210 | "^=" or "^" => "bxor", 211 | ">>=" or ">>" => "rshift", 212 | ">>>=" or ">>>" => "arshift", 213 | "<<=" or "<<" => "lshift", 214 | "~" => "bnot", 215 | _ => null 216 | }; 217 | 218 | public static string GetMappedOperator(string op) => 219 | op switch 220 | { 221 | "++" => "+=", 222 | "--" => "-=", 223 | "!" => "not ", 224 | "!=" => "~=", 225 | "&&" => "and", 226 | "||" => "or", 227 | _ => op 228 | }; 229 | 230 | public static bool IsFromSystemNamespace(ISymbol? typeSymbol) 231 | { 232 | if (typeSymbol is not { ContainingNamespace: not null }) return false; 233 | 234 | return typeSymbol.ContainingNamespace.Name == "System" || IsFromSystemNamespace(typeSymbol.ContainingNamespace); 235 | } 236 | 237 | public static List ExtractTypeArguments(string input) 238 | { 239 | var match = Regex.Match(input, "<(?(?:[^<>]+|<(?)|>(?<-open>))*)>"); 240 | 241 | if (!match.Success) return []; 242 | 243 | var argumentsRaw = match.Groups[1].Value; 244 | var arguments = SplitGenericArguments(argumentsRaw); 245 | 246 | return arguments.Select(arg => arg.Trim()).ToList(); 247 | } 248 | 249 | private static List SplitGenericArguments(string input) 250 | { 251 | var args = new List(); 252 | var depth = 0; 253 | var lastSplit = 0; 254 | 255 | for (var i = 0; i < input.Length; i++) 256 | { 257 | var c = input[i]; 258 | switch (c) 259 | { 260 | case '<': 261 | depth++; 262 | 263 | break; 264 | case '>': 265 | depth--; 266 | 267 | break; 268 | case ',' when depth == 0: 269 | args.Add(input.Substring(lastSplit, i - lastSplit)); 270 | lastSplit = i + 1; 271 | 272 | break; 273 | } 274 | } 275 | 276 | args.Add(input[lastSplit..]); 277 | 278 | return args; 279 | } 280 | 281 | public static bool IsGlobal(SyntaxNode node) => node.Parent.IsKind(SyntaxKind.GlobalStatement) || node.Parent.IsKind(SyntaxKind.CompilationUnit); 282 | 283 | public static NameSyntax GetNameNode(List pieces) 284 | { 285 | if (pieces.Count <= 1) return SyntaxFactory.IdentifierName(pieces.FirstOrDefault() ?? ""); 286 | 287 | var left = GetNameNode(pieces.SkipLast(1).ToList()); 288 | var right = SyntaxFactory.IdentifierName(pieces.Last()); 289 | 290 | return SyntaxFactory.QualifiedName(left, right); 291 | } 292 | 293 | public static List GetNamesFromNode(SyntaxNode? node, bool noGenerics = false) 294 | { 295 | if (node is BaseExpressionSyntax) return [""]; 296 | 297 | List names = []; 298 | 299 | if (node == null) return names; 300 | 301 | List addGenerics(List currentNames) 302 | { 303 | var typeParametersProperty = node.GetType().GetProperty("TypeParameterList"); 304 | var typeParametersValue = typeParametersProperty?.GetValue(node); 305 | 306 | if (typeParametersProperty != null && typeParametersValue is TypeParameterListSyntax typeParameterList) 307 | return currentNames 308 | .Append('<' 309 | + string.Join(", ", typeParameterList.Parameters.Select(p => GetNamesFromNode(p).First())) 310 | + '>') 311 | .ToList(); 312 | 313 | return currentNames; 314 | } 315 | 316 | var nameProperty = node.GetType().GetProperty("Name"); 317 | var nameValue = nameProperty?.GetValue(node); 318 | 319 | if (nameProperty != null && nameValue is NameSyntax nameNode) return GetNamesFromNode(nameNode); 320 | 321 | var identifierProperty = node.GetType().GetProperty("Identifier"); 322 | var identifierValue = identifierProperty?.GetValue(node); 323 | if (identifierProperty != null && identifierValue is SyntaxToken token) 324 | { 325 | names.Add(token.ValueText.Trim()); 326 | 327 | return noGenerics ? names : addGenerics(names); 328 | } 329 | 330 | var childNodes = node.ChildNodes().ToList(); 331 | var qualifiedNameNodes = (node is QualifiedNameSyntax qualifiedName 332 | ? [qualifiedName] 333 | : childNodes.OfType()).ToList(); 334 | 335 | var simpleNameNodes = (node is SimpleNameSyntax simpleName 336 | ? [simpleName] 337 | : childNodes.OfType()).ToList(); 338 | 339 | if (simpleNameNodes.Count <= 1) 340 | foreach (var qualifiedNameNode in qualifiedNameNodes) 341 | { 342 | names.AddRange(GetNamesFromNode(qualifiedNameNode.Left).Select(name => name.Trim())); 343 | names.AddRange(GetNamesFromNode(qualifiedNameNode.Right).Select(name => name.Trim())); 344 | } 345 | 346 | if (qualifiedNameNodes.Count <= 1) names.AddRange(simpleNameNodes.Select(simpleNameNode => simpleNameNode.ToString().Trim())); 347 | 348 | return noGenerics ? names : addGenerics(names); 349 | } 350 | 351 | public class KeyValuePairEqualityComparer : IEqualityComparer> 352 | { 353 | private readonly IEqualityComparer _keyComparer; 354 | private readonly IEqualityComparer _valueComparer; 355 | 356 | public KeyValuePairEqualityComparer(IEqualityComparer keyComparer = null!, 357 | IEqualityComparer valueComparer = null!) 358 | { 359 | _keyComparer = keyComparer ?? EqualityComparer.Default; 360 | _valueComparer = valueComparer ?? EqualityComparer.Default; 361 | } 362 | 363 | public bool Equals(KeyValuePair x, KeyValuePair y) => 364 | _keyComparer.Equals(x.Key, y.Key) && _valueComparer.Equals(x.Value, y.Value); 365 | 366 | public int GetHashCode(KeyValuePair obj) 367 | { 368 | var hashKey = _keyComparer.GetHashCode(obj.Key!); 369 | var hashValue = _valueComparer.GetHashCode(obj.Value!); 370 | 371 | return hashKey ^ hashValue; 372 | } 373 | } 374 | } -------------------------------------------------------------------------------- /RobloxCS.Tests/.lune/RuntimeLibTest.luau: -------------------------------------------------------------------------------- 1 | local CS = require("../../RobloxCS/Include/RuntimeLib") 2 | 3 | -- CS.is() 4 | assert(true, CS.is("abc", "string")) 5 | assert(true, CS.is(123, "number")) 6 | assert(true, CS.is(false, "boolean")) 7 | assert(true, CS.is(nil, "nil")) 8 | assert(true, CS.is(function() end, "function")) 9 | 10 | local MyClass: CS.Class = { __className = "MyClass" } 11 | local myClass = { __className = "MyClass" } 12 | assert(true, CS.is(myClass, MyClass)) 13 | 14 | local parent = {} 15 | parent.__index = parent 16 | local child = setmetatable({}, parent) 17 | assert(true, CS.is(child, parent)) 18 | 19 | -- CS.defineGlobal() / CS.getGlobal() 20 | CS.defineGlobal("MyValue", 69420) 21 | assert(69420, CS.getGlobal("MyValue")) 22 | 23 | -------------------------------------------------------------------------------- /RobloxCS.Tests/AstUtilityTest.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis.CSharp; 2 | using RobloxCS.Luau; 3 | using RobloxCS.Shared; 4 | 5 | namespace RobloxCS.Tests; 6 | 7 | public class AstUtilityTest 8 | { 9 | public AstUtilityTest() => Logger.Exit = false; 10 | 11 | [Theory] 12 | [InlineData("CS")] 13 | [InlineData("then")] 14 | [InlineData("and")] 15 | [InlineData("end")] 16 | [InlineData("do")] 17 | [InlineData("typeof")] 18 | [InlineData("type")] 19 | [InlineData("export")] 20 | public void ThrowsWithReservedIdentifier(string identifier) 21 | { 22 | Assert.Throws(() => AstUtility.CreateSimpleName(SyntaxFactory.LiteralExpression(SyntaxKind 23 | .NullLiteralExpression), 24 | identifier)); 25 | } 26 | 27 | [Fact] 28 | public void AddOne() 29 | { 30 | var expression = AstUtility.AddOne(new IdentifierName("a")); 31 | Assert.IsType(expression); 32 | 33 | var binaryOperator = (BinaryOperator)expression; 34 | Assert.IsType(binaryOperator.Left); 35 | Assert.IsType(binaryOperator.Right); 36 | Assert.Equal("+", binaryOperator.Operator); 37 | 38 | var identifier = (IdentifierName)binaryOperator.Left; 39 | var literal = (Literal)binaryOperator.Right; 40 | Assert.Equal("a", identifier.Text); 41 | Assert.Equal("1", literal.ValueText); 42 | } 43 | 44 | [Fact] 45 | public void AddOne_AddsToLiteralValue() 46 | { 47 | var sum = AstUtility.AddOne(new Literal("1")); 48 | Assert.IsType(sum); 49 | 50 | var literal = (Literal)sum; 51 | Assert.Equal("2", literal.ValueText); 52 | } 53 | 54 | [Fact] 55 | public void SubtractOne() 56 | { 57 | var expression = AstUtility.SubtractOne(new IdentifierName("a")); 58 | Assert.IsType(expression); 59 | 60 | var binaryOperator = (BinaryOperator)expression; 61 | Assert.IsType(binaryOperator.Left); 62 | Assert.IsType(binaryOperator.Right); 63 | Assert.Equal("-", binaryOperator.Operator); 64 | 65 | var identifier = (IdentifierName)binaryOperator.Left; 66 | var literal = (Literal)binaryOperator.Right; 67 | Assert.Equal("a", identifier.Text); 68 | Assert.Equal("1", literal.ValueText); 69 | } 70 | 71 | [Fact] 72 | public void SubtractOne_SubtractsFromLiteralValue() 73 | { 74 | var sum = AstUtility.SubtractOne(new Literal("2")); 75 | Assert.IsType(sum); 76 | 77 | var literal = (Literal)sum; 78 | Assert.Equal("1", literal.ValueText); 79 | } 80 | 81 | [Theory] 82 | [InlineData("var")] 83 | [InlineData(null)] 84 | public void CreateTypeRef_ReturnsNull(string? path) => Assert.Null(AstUtility.CreateTypeRef(path)); 85 | 86 | [Theory] 87 | [InlineData("string?", typeof(OptionalType))] 88 | [InlineData("{ number }", typeof(ArrayType))] 89 | [InlineData("{ [string]: number }", typeof(MappedType))] 90 | public void CreateTypeRef_ReturnsCorrectTypeNode(string? path, Type typeNodeType) 91 | { 92 | var typeRef = AstUtility.CreateTypeRef(path); 93 | Assert.NotNull(typeRef); 94 | Assert.IsType(typeNodeType, typeRef); 95 | } 96 | 97 | [Fact] 98 | public void CreateTypeRef_HandlesNestedPatterns() 99 | { 100 | const string rawPath = "{ [string]: { bool } }??"; 101 | var typeRef = AstUtility.CreateTypeRef(rawPath); 102 | Assert.NotNull(typeRef); 103 | Assert.IsType(typeRef); 104 | 105 | var optionalType = (OptionalType)typeRef; 106 | Assert.IsType(optionalType.NonNullableType); 107 | 108 | var mappedType = (MappedType)optionalType.NonNullableType; 109 | Assert.Equal("string", mappedType.KeyType.Path); 110 | Assert.IsType(mappedType.ValueType); 111 | 112 | var arrayType = (ArrayType)mappedType.ValueType; 113 | Assert.Equal("boolean", arrayType.ElementType.Path); 114 | } 115 | } -------------------------------------------------------------------------------- /RobloxCS.Tests/Base/Generation.cs: -------------------------------------------------------------------------------- 1 | using RobloxCS.Luau; 2 | using RobloxCS.Shared; 3 | 4 | namespace RobloxCS.Tests.Base; 5 | 6 | public abstract class Generation 7 | { 8 | protected static AST Generate(string source) 9 | { 10 | var config = ConfigReader.UnitTestingConfig; 11 | var file = TranspilerUtility.ParseAndTransformTree(source.Trim(), new RojoProject(), config); 12 | var compiler = TranspilerUtility.GetCompiler([file.Tree], config); 13 | 14 | return TranspilerUtility.GetLuauAST(file, compiler); 15 | } 16 | } -------------------------------------------------------------------------------- /RobloxCS.Tests/LuauTests.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Reflection; 3 | using System.Runtime.InteropServices; 4 | using Xunit.Abstractions; 5 | 6 | namespace RobloxCS.Tests; 7 | 8 | public class LuauTests(ITestOutputHelper testOutputHelper) 9 | { 10 | private readonly string _cwd = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(Assembly.GetExecutingAssembly() 11 | .Location))))!; 12 | 13 | [Theory] 14 | [InlineData("RuntimeLibTest")] 15 | public void LuauTests_Pass(string scriptName) 16 | { 17 | var lunePath = Path.GetFullPath("lune" + (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : ""), _cwd); 18 | var runScriptArguments = $"run {scriptName}"; 19 | var process = new Process 20 | { 21 | StartInfo = new ProcessStartInfo 22 | { 23 | FileName = lunePath, 24 | Arguments = runScriptArguments, 25 | RedirectStandardOutput = true, 26 | RedirectStandardError = true, 27 | UseShellExecute = false, 28 | CreateNoWindow = true, 29 | WorkingDirectory = _cwd 30 | } 31 | }; 32 | 33 | try 34 | { 35 | process.Start(); 36 | 37 | var output = process.StandardOutput.ReadToEnd(); 38 | var error = process.StandardError.ReadToEnd(); 39 | process.WaitForExit(); 40 | 41 | testOutputHelper.WriteLine($"{scriptName}.luau Errors:"); 42 | testOutputHelper.WriteLine(error); 43 | Assert.True(string.IsNullOrWhiteSpace(error)); 44 | testOutputHelper.WriteLine($"{scriptName}.luau Output:"); 45 | testOutputHelper.WriteLine(output); 46 | Assert.True(string.IsNullOrWhiteSpace(output)); 47 | Assert.Equal(0, process.ExitCode); 48 | } 49 | finally 50 | { 51 | process.Dispose(); 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /RobloxCS.Tests/MacroTests/ExtraTest.cs: -------------------------------------------------------------------------------- 1 | using RobloxCS.Luau; 2 | 3 | namespace RobloxCS.Tests.MacroTests; 4 | 5 | public class ExtraTest : Base.Generation 6 | { 7 | [Fact] 8 | public void CallMacros_Discard_IfUnused() 9 | { 10 | var ast = Generate("HashSet set = []; set.Add(69);"); 11 | Assert.NotEmpty(ast.Statements); 12 | 13 | var statements = ast.Statements.Skip(2).ToList(); 14 | Assert.IsType(statements[2]); 15 | 16 | var variable = (Variable)statements[2]; 17 | Assert.Equal("_", variable.Name.ToString()); 18 | } 19 | 20 | [Fact] 21 | public void CallMacros_DoNotDiscard_IfUsed() 22 | { 23 | var ast = Generate("HashSet set = []; var abc = set.Add(69);"); 24 | Assert.NotEmpty(ast.Statements); 25 | 26 | var statements = ast.Statements.Skip(2).ToList(); 27 | Assert.IsType(statements[2]); 28 | 29 | var variableList = (VariableList)statements[2]; 30 | Assert.Equal("abc", variableList.Variables.First().Name.ToString()); 31 | } 32 | } -------------------------------------------------------------------------------- /RobloxCS.Tests/MacroTests/HashSetMacrosTest.cs: -------------------------------------------------------------------------------- 1 | using RobloxCS.Luau; 2 | using RobloxCS.Macros; 3 | 4 | namespace RobloxCS.Tests.MacroTests; 5 | 6 | public class HashSetMacrosTest : Base.Generation 7 | { 8 | [Fact] 9 | public void Macros_Add() 10 | { 11 | var ast = Generate("HashSet set = []; set.Add(69);"); 12 | Assert.NotEmpty(ast.Statements); 13 | 14 | var statements = ast.Statements.Skip(2).ToList(); 15 | Assert.IsType(statements[0]); 16 | Assert.IsType(statements[1]); 17 | Assert.IsType(statements[2]); 18 | 19 | var variable = (Variable)statements[0]; 20 | Assert.Equal("_wasAdded", variable.Name.Text); 21 | Assert.IsType(variable.Initializer); 22 | 23 | var binaryOperator = (BinaryOperator)variable.Initializer; 24 | Assert.Equal("==", binaryOperator.Operator); 25 | Assert.IsType(binaryOperator.Left); 26 | Assert.IsType(binaryOperator.Right); 27 | 28 | var assignment = (Assignment)statements[1]; 29 | Assert.IsType(assignment.Target); 30 | Assert.IsType(assignment.Value); 31 | 32 | var assignmentValue = (Literal)assignment.Value; 33 | Assert.Equal("true", assignmentValue.ValueText); 34 | 35 | var elementAccess = (ElementAccess)assignment.Target; 36 | Assert.IsType(elementAccess.Expression); 37 | Assert.IsType(elementAccess.Index); 38 | Assert.Equal("set", elementAccess.Expression.ToString()); 39 | 40 | var index = (Literal)elementAccess.Index; 41 | Assert.Equal("69", index.ValueText); 42 | 43 | var expression = ((Variable)statements[2]).Initializer; 44 | Assert.NotNull(expression); 45 | Assert.NotNull(expression.ExpandedByMacro); 46 | Assert.Equal(MacroKind.HashSetMethod, expression.ExpandedByMacro); 47 | Assert.IsType(expression); 48 | Assert.Equal("_wasAdded", expression.ToString()); 49 | } 50 | 51 | [Fact] 52 | public void Macros_Remove() 53 | { 54 | var ast = Generate("HashSet set = []; set.Remove(69);"); 55 | Assert.NotEmpty(ast.Statements); 56 | 57 | var statements = ast.Statements.Skip(2).ToList(); 58 | Assert.IsType(statements[0]); 59 | Assert.IsType(statements[1]); 60 | Assert.IsType(statements[2]); 61 | 62 | 63 | var variable = (Variable)statements[0]; 64 | Assert.Equal("_wasRemoved", variable.Name.Text); 65 | Assert.IsType(variable.Initializer); 66 | 67 | var binaryOperator = (BinaryOperator)variable.Initializer; 68 | Assert.Equal("~=", binaryOperator.Operator); 69 | Assert.IsType(binaryOperator.Left); 70 | Assert.IsType(binaryOperator.Right); 71 | 72 | var assignment = (Assignment)statements[1]; 73 | Assert.IsType(assignment.Target); 74 | Assert.IsType(assignment.Value); 75 | 76 | var typeCast = (TypeCast)assignment.Value; 77 | Assert.IsType(typeCast.Expression); 78 | 79 | var assignmentValue = (Literal)typeCast.Expression; 80 | Assert.Equal("nil", assignmentValue.ValueText); 81 | 82 | var elementAccess = (ElementAccess)assignment.Target; 83 | Assert.IsType(elementAccess.Expression); 84 | Assert.IsType(elementAccess.Index); 85 | Assert.Equal("set", elementAccess.Expression.ToString()); 86 | 87 | var index = (Literal)elementAccess.Index; 88 | Assert.Equal("69", index.ValueText); 89 | 90 | var expression = ((Variable)statements[2]).Initializer; 91 | Assert.NotNull(expression); 92 | Assert.NotNull(expression.ExpandedByMacro); 93 | Assert.Equal(MacroKind.HashSetMethod, expression.ExpandedByMacro); 94 | Assert.IsType(expression); 95 | Assert.Equal("_wasRemoved", expression.ToString()); 96 | } 97 | 98 | [Fact] 99 | public void Macros_Contains() 100 | { 101 | var ast = Generate("HashSet set = []; set.Contains(69);"); 102 | Assert.NotEmpty(ast.Statements); 103 | 104 | var statement = ast.Statements.Skip(2).First(); 105 | Assert.IsType(statement); 106 | 107 | var expression = ((Variable)statement).Initializer; 108 | Assert.IsType(expression); 109 | 110 | var elementAccess = (ElementAccess)expression; 111 | Assert.NotNull(elementAccess.ExpandedByMacro); 112 | Assert.Equal(MacroKind.HashSetMethod, elementAccess.ExpandedByMacro); 113 | Assert.IsType(elementAccess.Expression); 114 | Assert.IsType(elementAccess.Index); 115 | Assert.Equal("set", elementAccess.Expression.ToString()); 116 | 117 | var index = (Literal)elementAccess.Index; 118 | Assert.Equal("69", index.ValueText); 119 | } 120 | 121 | [Fact] 122 | public void Macros_Clear() 123 | { 124 | var ast = Generate("HashSet set = []; set.Clear();"); 125 | Assert.NotEmpty(ast.Statements); 126 | 127 | var statement = ast.Statements.Skip(2).First(); 128 | Assert.IsType(statement); 129 | 130 | var expression = ((ExpressionStatement)statement).Expression; 131 | Assert.IsType(expression); 132 | 133 | var call = (Call)expression; 134 | Assert.NotNull(call.ExpandedByMacro); 135 | Assert.Equal(MacroKind.HashSetMethod, call.ExpandedByMacro); 136 | Assert.Single(call.ArgumentList.Arguments); 137 | Assert.IsType(call.Callee); 138 | 139 | var selfArgument = call.ArgumentList.Arguments.First().Expression; 140 | Assert.IsType(selfArgument); 141 | Assert.Equal("set", selfArgument.ToString()); 142 | 143 | var memberAccess = (MemberAccess)call.Callee; 144 | Assert.IsType(memberAccess.Expression); 145 | Assert.IsType(memberAccess.Name); 146 | Assert.Equal("table", memberAccess.Expression.ToString()); 147 | Assert.Equal("clear", memberAccess.Name.ToString()); 148 | } 149 | } -------------------------------------------------------------------------------- /RobloxCS.Tests/MacroTests/ListMacrosTest.cs: -------------------------------------------------------------------------------- 1 | using RobloxCS.Luau; 2 | using RobloxCS.Macros; 3 | 4 | namespace RobloxCS.Tests.MacroTests; 5 | 6 | public class ListMacrosTest : Base.Generation 7 | { 8 | [Fact] 9 | public void Macros_Add() 10 | { 11 | var ast = Generate("List l = []; l.Add(69);"); 12 | Assert.NotEmpty(ast.Statements); 13 | 14 | var statement = ast.Statements.Skip(2).First(); 15 | Assert.IsType(statement); 16 | 17 | var expression = ((ExpressionStatement)statement).Expression; 18 | Assert.IsType(expression); 19 | 20 | var call = (Call)expression; 21 | Assert.NotNull(call.ExpandedByMacro); 22 | Assert.Equal(MacroKind.ListMethod, call.ExpandedByMacro); 23 | Assert.Equal(2, call.ArgumentList.Arguments.Count); 24 | Assert.IsType(call.Callee); 25 | 26 | var selfArgument = call.ArgumentList.Arguments.First().Expression; 27 | var elementArgument = call.ArgumentList.Arguments.Last().Expression; 28 | Assert.IsType(selfArgument); 29 | Assert.IsType(elementArgument); 30 | Assert.Equal("l", selfArgument.ToString()); 31 | 32 | var element = (Literal)elementArgument; 33 | Assert.Equal("69", element.ValueText); 34 | 35 | var memberAccess = (MemberAccess)call.Callee; 36 | Assert.IsType(memberAccess.Expression); 37 | Assert.IsType(memberAccess.Name); 38 | Assert.Equal("table", memberAccess.Expression.ToString()); 39 | Assert.Equal("insert", memberAccess.Name.ToString()); 40 | } 41 | 42 | [Fact] 43 | public void Macros_Insert() 44 | { 45 | var ast = Generate("List l = []; l.Insert(0, 69);"); 46 | Assert.NotEmpty(ast.Statements); 47 | 48 | var statement = ast.Statements.Skip(2).First(); 49 | Assert.IsType(statement); 50 | 51 | var expression = ((ExpressionStatement)statement).Expression; 52 | Assert.IsType(expression); 53 | 54 | var call = (Call)expression; 55 | Assert.NotNull(call.ExpandedByMacro); 56 | Assert.Equal(MacroKind.ListMethod, call.ExpandedByMacro); 57 | Assert.Equal(3, call.ArgumentList.Arguments.Count); 58 | Assert.IsType(call.Callee); 59 | 60 | var selfArgument = call.ArgumentList.Arguments[0].Expression; 61 | var indexArgument = call.ArgumentList.Arguments[1].Expression; 62 | var elementArgument = call.ArgumentList.Arguments[2].Expression; 63 | Assert.IsType(selfArgument); 64 | Assert.IsType(indexArgument); 65 | Assert.IsType(elementArgument); 66 | Assert.Equal("l", selfArgument.ToString()); 67 | 68 | var index = (Literal)indexArgument; 69 | Assert.Equal("1", index.ValueText); 70 | 71 | var element = (Literal)elementArgument; 72 | Assert.Equal("69", element.ValueText); 73 | 74 | var memberAccess = (MemberAccess)call.Callee; 75 | Assert.IsType(memberAccess.Expression); 76 | Assert.IsType(memberAccess.Name); 77 | Assert.Equal("table", memberAccess.Expression.ToString()); 78 | Assert.Equal("insert", memberAccess.Name.ToString()); 79 | } 80 | 81 | [Fact] 82 | public void Macros_Clear() 83 | { 84 | var ast = Generate("List l = []; l.Clear();"); 85 | Assert.NotEmpty(ast.Statements); 86 | 87 | var statement = ast.Statements.Skip(2).First(); 88 | Assert.IsType(statement); 89 | 90 | var expression = ((ExpressionStatement)statement).Expression; 91 | Assert.IsType(expression); 92 | 93 | var call = (Call)expression; 94 | Assert.NotNull(call.ExpandedByMacro); 95 | Assert.Equal(MacroKind.ListMethod, call.ExpandedByMacro); 96 | Assert.Single(call.ArgumentList.Arguments); 97 | Assert.IsType(call.Callee); 98 | 99 | var selfArgument = call.ArgumentList.Arguments.First().Expression; 100 | Assert.IsType(selfArgument); 101 | Assert.Equal("l", selfArgument.ToString()); 102 | 103 | var memberAccess = (MemberAccess)call.Callee; 104 | Assert.IsType(memberAccess.Expression); 105 | Assert.IsType(memberAccess.Name); 106 | Assert.Equal("table", memberAccess.Expression.ToString()); 107 | Assert.Equal("clear", memberAccess.Name.ToString()); 108 | } 109 | 110 | [Fact] 111 | public void Macros_Contains() 112 | { 113 | var ast = Generate("List l = []; l.Contains(69);"); 114 | Assert.NotEmpty(ast.Statements); 115 | 116 | var statement = ast.Statements.Skip(2).First(); 117 | Assert.IsType(statement); 118 | 119 | var expression = ((Variable)statement).Initializer; 120 | Assert.IsType(expression); 121 | 122 | var binaryOperator = (BinaryOperator)expression; 123 | Assert.NotNull(binaryOperator.ExpandedByMacro); 124 | Assert.Equal(MacroKind.ListMethod, binaryOperator.ExpandedByMacro); 125 | Assert.Equal("~=", binaryOperator.Operator); 126 | Assert.IsType(binaryOperator.Left); 127 | Assert.IsType(binaryOperator.Right); 128 | 129 | var call = (Call)binaryOperator.Left; 130 | Assert.Equal(2, call.ArgumentList.Arguments.Count); 131 | Assert.IsType(call.Callee); 132 | 133 | var selfArgument = call.ArgumentList.Arguments.First().Expression; 134 | var elementArgument = call.ArgumentList.Arguments.Last().Expression; 135 | Assert.IsType(selfArgument); 136 | Assert.IsType(elementArgument); 137 | Assert.Equal("l", selfArgument.ToString()); 138 | 139 | var memberAccess = (MemberAccess)call.Callee; 140 | Assert.IsType(memberAccess.Expression); 141 | Assert.IsType(memberAccess.Name); 142 | Assert.Equal("table", memberAccess.Expression.ToString()); 143 | Assert.Equal("find", memberAccess.Name.ToString()); 144 | } 145 | 146 | [Fact] 147 | public void Macros_Remove() 148 | { 149 | var ast = Generate("List l = []; l.Remove(69);"); 150 | Assert.NotEmpty(ast.Statements); 151 | 152 | var statement = ast.Statements.Skip(2).First(); 153 | Assert.IsType(statement); 154 | 155 | var expression = ((Variable)statement).Initializer; 156 | Assert.IsType(expression); 157 | 158 | var call = (Call)expression; 159 | Assert.NotNull(call.ExpandedByMacro); 160 | Assert.Equal(MacroKind.ListMethod, call.ExpandedByMacro); 161 | Assert.Equal(2, call.ArgumentList.Arguments.Count); 162 | Assert.IsType(call.Callee); 163 | 164 | var selfArgument = call.ArgumentList.Arguments.First().Expression; 165 | var indexArgument = call.ArgumentList.Arguments.Last().Expression; 166 | Assert.Equal("l", selfArgument.ToString()); 167 | Assert.IsType(selfArgument); 168 | Assert.IsType(indexArgument); 169 | 170 | var indexCall = (Call)indexArgument; 171 | Assert.Equal(2, indexCall.ArgumentList.Arguments.Count); 172 | Assert.IsType(indexCall.Callee); 173 | 174 | var elementArgument = indexCall.ArgumentList.Arguments.Last().Expression; 175 | var element = (Literal)elementArgument; 176 | Assert.Equal("69", element.ValueText); 177 | 178 | var indexMemberAccess = (MemberAccess)indexCall.Callee; 179 | Assert.IsType(indexMemberAccess.Expression); 180 | Assert.IsType(indexMemberAccess.Name); 181 | Assert.Equal("table", indexMemberAccess.Expression.ToString()); 182 | Assert.Equal("find", indexMemberAccess.Name.ToString()); 183 | 184 | var memberAccess = (MemberAccess)call.Callee; 185 | Assert.IsType(memberAccess.Expression); 186 | Assert.IsType(memberAccess.Name); 187 | Assert.Equal("table", memberAccess.Expression.ToString()); 188 | Assert.Equal("remove", memberAccess.Name.ToString()); 189 | } 190 | 191 | [Fact] 192 | public void Macros_RemoveAt() 193 | { 194 | var ast = Generate("List l = []; l.RemoveAt(0);"); 195 | Assert.NotEmpty(ast.Statements); 196 | 197 | var statement = ast.Statements.Skip(2).First(); 198 | Assert.IsType(statement); 199 | 200 | var expression = ((ExpressionStatement)statement).Expression; 201 | Assert.IsType(expression); 202 | 203 | var call = (Call)expression; 204 | Assert.NotNull(call.ExpandedByMacro); 205 | Assert.Equal(MacroKind.ListMethod, call.ExpandedByMacro); 206 | Assert.Equal(2, call.ArgumentList.Arguments.Count); 207 | Assert.IsType(call.Callee); 208 | 209 | var selfArgument = call.ArgumentList.Arguments.First().Expression; 210 | var indexArgument = call.ArgumentList.Arguments.Last().Expression; 211 | Assert.Equal("l", selfArgument.ToString()); 212 | Assert.IsType(selfArgument); 213 | Assert.IsType(indexArgument); 214 | 215 | var index = (Literal)indexArgument; 216 | Assert.Equal("1", index.ValueText); 217 | 218 | var memberAccess = (MemberAccess)call.Callee; 219 | Assert.IsType(memberAccess.Expression); 220 | Assert.IsType(memberAccess.Name); 221 | Assert.Equal("table", memberAccess.Expression.ToString()); 222 | Assert.Equal("remove", memberAccess.Name.ToString()); 223 | } 224 | 225 | [Fact] 226 | public void Macros_AsReadOnly() 227 | { 228 | var ast = Generate("List l = []; l.AsReadOnly();"); 229 | Assert.NotEmpty(ast.Statements); 230 | 231 | var statement = ast.Statements.Skip(2).First(); 232 | Assert.IsType(statement); 233 | 234 | var expression = ((Variable)statement).Initializer; 235 | Assert.IsType(expression); 236 | 237 | var call = (Call)expression; 238 | Assert.NotNull(call.ExpandedByMacro); 239 | Assert.Equal(MacroKind.ListMethod, call.ExpandedByMacro); 240 | Assert.Single(call.ArgumentList.Arguments); 241 | Assert.IsType(call.Callee); 242 | 243 | var selfArgument = call.ArgumentList.Arguments.First().Expression; 244 | Assert.IsType(selfArgument); 245 | Assert.Equal("l", selfArgument.ToString()); 246 | 247 | var memberAccess = (MemberAccess)call.Callee; 248 | Assert.IsType(memberAccess.Expression); 249 | Assert.IsType(memberAccess.Name); 250 | Assert.Equal("table", memberAccess.Expression.ToString()); 251 | Assert.Equal("freeze", memberAccess.Name.ToString()); 252 | } 253 | } -------------------------------------------------------------------------------- /RobloxCS.Tests/MacroTests/ObjectMacrosTest.cs: -------------------------------------------------------------------------------- 1 | using RobloxCS.Luau; 2 | using RobloxCS.Macros; 3 | 4 | namespace RobloxCS.Tests.MacroTests; 5 | 6 | public class ObjectMacrosTest : Base.Generation 7 | { 8 | [Fact] 9 | public void Macros_ToString() 10 | { 11 | var ast = Generate("(123).ToString();"); 12 | Assert.NotEmpty(ast.Statements); 13 | 14 | var statement = ast.Statements.Skip(1).First(); 15 | Assert.IsType(statement); 16 | 17 | var expression = ((Variable)statement).Initializer; 18 | Assert.NotNull(expression); 19 | 20 | var call = (Call)expression; 21 | Assert.NotNull(call.ExpandedByMacro); 22 | Assert.Equal(MacroKind.ObjectMethod, call.ExpandedByMacro); 23 | Assert.IsType(call.Callee); 24 | 25 | var identifierName = (IdentifierName)call.Callee; 26 | Assert.Equal("tostring", identifierName.ToString()); 27 | 28 | var argument = call.ArgumentList.Arguments.First().Expression; 29 | Assert.IsType(argument); 30 | 31 | var parenthesized = (Parenthesized)argument; 32 | Assert.IsType(parenthesized.Expression); 33 | 34 | var literal = (Literal)parenthesized.Expression; 35 | Assert.Equal("123", literal.ValueText); 36 | } 37 | } -------------------------------------------------------------------------------- /RobloxCS.Tests/RenderingTest.cs: -------------------------------------------------------------------------------- 1 | using RobloxCS.Luau; 2 | 3 | namespace RobloxCS.Tests; 4 | 5 | public class RenderingTest 6 | { 7 | [Fact] 8 | public void Renders_AST() 9 | { 10 | var statement = new ExpressionStatement(AstUtility.PrintCall(AstUtility.String("bruh"))); 11 | var ast = new AST([statement]); 12 | var output = Render(ast); 13 | const string expectedOutput = """ 14 | print("bruh") 15 | return nil 16 | 17 | """; 18 | 19 | Assert.Equal(expectedOutput.Replace("\r", ""), output.Replace("\r", "")); 20 | } 21 | 22 | [Fact] 23 | public void Renders_Block() 24 | { 25 | var statements = Enumerable.Repeat(new ExpressionStatement(AstUtility.PrintCall(AstUtility.String("bruh"))), 5) 26 | .ToList(); 27 | 28 | var block = new Block(statements); 29 | var output = Render(block); 30 | const string expectedOutput = """ 31 | print("bruh") 32 | print("bruh") 33 | print("bruh") 34 | print("bruh") 35 | print("bruh") 36 | 37 | """; 38 | 39 | Assert.Equal(expectedOutput.Replace("\r", ""), output.Replace("\r", "")); 40 | } 41 | 42 | [Fact] 43 | public void Renders_ScopedBlock() 44 | { 45 | var statements = Enumerable.Repeat(new ExpressionStatement(AstUtility.PrintCall(AstUtility.String("bruh"))), 5) 46 | .ToList(); 47 | 48 | var block = new ScopedBlock(statements); 49 | var output = Render(block); 50 | const string expectedOutput = """ 51 | do 52 | print("bruh") 53 | print("bruh") 54 | print("bruh") 55 | print("bruh") 56 | print("bruh") 57 | end 58 | 59 | """; 60 | 61 | Assert.Equal(expectedOutput.Replace("\r", ""), output.Replace("\r", "")); 62 | } 63 | 64 | [Fact] 65 | public void Renders_MultiLineLineComment() 66 | { 67 | var comment = new MultiLineComment(string.Join('\n', Enumerable.Repeat("roblox-cs is the best!", 5))); 68 | var output = Render(comment); 69 | const string expectedOutput = """ 70 | --[[ 71 | roblox-cs is the best! 72 | roblox-cs is the best! 73 | roblox-cs is the best! 74 | roblox-cs is the best! 75 | roblox-cs is the best! 76 | ]] 77 | 78 | """; 79 | 80 | Assert.Equal(expectedOutput.Replace("\r", ""), output.Replace("\r", "")); 81 | } 82 | 83 | [Fact] 84 | public void Renders_SingleLineComment() 85 | { 86 | var comment = new SingleLineComment("roblox-cs is the best!"); 87 | var output = Render(comment); 88 | const string expectedOutput = "-- roblox-cs is the best!"; 89 | 90 | Assert.Equal(expectedOutput.Replace("\r", ""), output.Replace("\r", "")); 91 | } 92 | 93 | [Fact] 94 | public void Renders_IterativeFor() 95 | { 96 | var valueName = new IdentifierName("value"); 97 | var iterable = new IdentifierName("abc"); 98 | var body = new ExpressionStatement(AstUtility.PrintCall(valueName)); 99 | var forStatement = new For([AstUtility.DiscardName, valueName], iterable, body); 100 | var output = Render(forStatement); 101 | const string expectedOutput = """ 102 | for _, value in abc do 103 | print(value) 104 | end 105 | 106 | """; 107 | 108 | Assert.Equal(expectedOutput.Replace("\r", ""), output.Replace("\r", "")); 109 | } 110 | 111 | [Fact] 112 | public void Renders_NumericFor() 113 | { 114 | var name = new IdentifierName("i"); 115 | var minimum = new Literal("420"); 116 | var maximum = new Literal("69"); 117 | var increment = new Literal("-1"); 118 | var body = new ExpressionStatement(AstUtility.PrintCall(new Literal("\"balls\""))); 119 | var forStatement = new NumericFor(name, 120 | minimum, 121 | maximum, 122 | increment, 123 | body); 124 | 125 | var output = Render(forStatement); 126 | const string expectedOutput = """ 127 | for i = 420, 69, -1 do 128 | print("balls") 129 | end 130 | 131 | """; 132 | 133 | Assert.Equal(expectedOutput.Replace("\r", ""), output.Replace("\r", "")); 134 | } 135 | 136 | [Fact] 137 | public void Renders_Repeat() 138 | { 139 | var condition = new IdentifierName("balls"); 140 | var body = new ExpressionStatement(AstUtility.PrintCall(new Literal("\"rah\""))); 141 | var repeatStatement = new Repeat(condition, body); 142 | var output = Render(repeatStatement); 143 | const string expectedOutput = """ 144 | repeat 145 | print("rah") 146 | until balls 147 | 148 | """; 149 | 150 | Assert.Equal(expectedOutput.Replace("\r", ""), output.Replace("\r", "")); 151 | } 152 | 153 | [Fact] 154 | public void Renders_While() 155 | { 156 | var condition = new IdentifierName("balls"); 157 | var body = new ExpressionStatement(AstUtility.PrintCall(new Literal("\"rah\""))); 158 | var whileStatement = new While(condition, body); 159 | var output = Render(whileStatement); 160 | const string expectedOutput = """ 161 | while balls do 162 | print("rah") 163 | end 164 | 165 | """; 166 | 167 | Assert.Equal(expectedOutput.Replace("\r", ""), output.Replace("\r", "")); 168 | } 169 | 170 | [Fact] 171 | public void Renders_IfExpression() 172 | { 173 | var condition = new IdentifierName("runicIsCool"); 174 | var body = new Literal("\"im tha best\""); 175 | var elseBranch = new Literal("\"im washed\""); 176 | var ifExpression = new IfExpression(condition, body, elseBranch, true); 177 | var output = Render(ifExpression); 178 | Assert.Equal("if runicIsCool then \"im tha best\" else \"im washed\"", output); 179 | } 180 | 181 | [Fact] 182 | public void Renders_If() 183 | { 184 | var identifier = new IdentifierName("balls"); 185 | var condition1 = new BinaryOperator(identifier, "==", new Literal("69")); 186 | var condition2 = new BinaryOperator(identifier, "==", new Literal("420")); 187 | var body = new Block([new ExpressionStatement(AstUtility.PrintCall(new Literal("\"im tha best\"")))]); 188 | var elseBody = new Block([new ExpressionStatement(AstUtility.PrintCall(new Literal("\"uhhhh\"")))]); 189 | var elseifBody = new Block([new ExpressionStatement(AstUtility.PrintCall(new Literal("\"im washed\"")))]); 190 | var elseIfBranch = new Block([new If(condition2, elseifBody, elseBody)]); 191 | var ifStatement = new If(condition1, body, elseIfBranch); 192 | var output = Render(ifStatement); 193 | const string expectedOutput = """ 194 | if balls == 69 then 195 | print("im tha best") 196 | elseif balls == 420 then 197 | print("im washed") 198 | else 199 | print("uhhhh") 200 | end 201 | """; 202 | 203 | Assert.Equal(expectedOutput.Replace("\r", "").Trim(), output.Replace("\r", "").Trim()); 204 | } 205 | 206 | [Fact] 207 | public void Renders_EmptyTableInitializer() 208 | { 209 | var tableInitializer = new TableInitializer(); 210 | var output = Render(tableInitializer); 211 | 212 | Assert.Equal("{}", output); 213 | } 214 | 215 | [Fact] 216 | public void Renders_ArrayTableInitializer() 217 | { 218 | var tableInitializer = new TableInitializer([new Literal("69"), new Literal("420"), AstUtility.String("abc")]); 219 | var output = Render(tableInitializer); 220 | 221 | Assert.Equal("{69, 420, \"abc\"}", output); 222 | } 223 | 224 | [Fact] 225 | public void Renders_DictionaryTableInitializer() 226 | { 227 | var tableInitializer = new TableInitializer([new Literal("69"), new Literal("420"), AstUtility.String("abc")], 228 | [new IdentifierName("foo"), new IdentifierName("bar"), AstUtility.String("baz")]); 229 | 230 | var output = Render(tableInitializer); 231 | const string expectedOutput = """ 232 | { 233 | foo = 69, 234 | bar = 420, 235 | ["baz"] = "abc" 236 | } 237 | """; 238 | 239 | Assert.Equal(expectedOutput.Replace("\r", ""), output.Replace("\r", "")); 240 | } 241 | 242 | [Fact] 243 | public void Renders_Calls() 244 | { 245 | var arguments = AstUtility.CreateArgumentList([new Literal("69"), new Literal("420"), AstUtility.String("abc")]); 246 | var call = new Call(new IdentifierName("bigMen"), arguments); 247 | var output = Render(call); 248 | 249 | Assert.Equal("bigMen(69, 420, \"abc\")", output); 250 | } 251 | 252 | [Fact] 253 | public void Renders_TypeOfCall() 254 | { 255 | var typeOfCall = new TypeOfCall(new IdentifierName("bigMen")); 256 | var output = Render(typeOfCall); 257 | 258 | Assert.Equal("typeof(bigMen)", output); 259 | } 260 | 261 | [Fact] 262 | public void Renders_IndexCall() 263 | { 264 | var indexCall = new IndexCall(new TypeRef("MyRecord"), new TypeRef("string")); 265 | var output = Render(indexCall); 266 | 267 | Assert.Equal("index", output); 268 | } 269 | 270 | [Fact] 271 | public void Renders_KeyOfCall() 272 | { 273 | var keyOfCall = new KeyOfCall(new TypeRef("MyRecord")); 274 | var output = Render(keyOfCall); 275 | 276 | Assert.Equal("keyof", output); 277 | } 278 | 279 | [Fact] 280 | public void Renders_ArgumentLists() 281 | { 282 | var arguments = AstUtility.CreateArgumentList([new Literal("69"), new Literal("420"), AstUtility.String("abc")]); 283 | var output = Render(arguments); 284 | 285 | Assert.Equal("(69, 420, \"abc\")", output); 286 | } 287 | 288 | [Fact] 289 | public void Renders_BuiltInAttributes() 290 | { 291 | var attribute = new AttributeList([new BuiltInAttribute(new IdentifierName("native"))]); 292 | var output = Render(attribute); 293 | 294 | Assert.Equal("@native\n", output.Replace("\r", "")); 295 | } 296 | 297 | [Fact] 298 | public void Renders_MappedTypes() 299 | { 300 | var mappedType = new MappedType(new TypeRef("string"), new TypeRef("number")); 301 | var output = Render(mappedType); 302 | 303 | Assert.Equal("{ [string]: number }", output); 304 | } 305 | 306 | [Fact] 307 | public void Renders_InterfaceTypes() 308 | { 309 | var interfaceType = new InterfaceType([new FieldType("myField", new TypeRef("string"), true)], 310 | null, 311 | false); 312 | 313 | var output = Render(interfaceType); 314 | const string expectedOutput = """ 315 | { 316 | read myField: string; 317 | } 318 | """; 319 | 320 | Assert.Equal(expectedOutput.Replace("\r", ""), output.Replace("\r", "")); 321 | } 322 | 323 | [Fact] 324 | public void Renders_OptionalTypes() 325 | { 326 | var optionalType = new OptionalType(new TypeRef("boolean")); 327 | var output = Render(optionalType); 328 | 329 | Assert.Equal("boolean?", output); 330 | } 331 | 332 | [Fact] 333 | public void Renders_FunctionTypes() 334 | { 335 | var functionType = new FunctionType([new ParameterType("myParam", new TypeRef("number"))], 336 | new TypeRef("boolean")); 337 | 338 | var output = Render(functionType); 339 | Assert.Equal("(myParam: number) -> boolean", output); 340 | } 341 | 342 | [Fact] 343 | public void Renders_ArrayTypes() 344 | { 345 | var arrayType = new ArrayType(new TypeRef("string")); 346 | var output = Render(arrayType); 347 | 348 | Assert.Equal("{ string }", output); 349 | } 350 | 351 | [Fact] 352 | public void Renders_TypeAliases() 353 | { 354 | var name = new IdentifierName("MyType"); 355 | var value = new TypeRef("string"); 356 | var typeAlias = new TypeAlias(name, value); 357 | var output = Render(typeAlias); 358 | 359 | Assert.Equal("type MyType = string\n", output); 360 | } 361 | 362 | [Fact] 363 | public void Renders_TypeCasts() 364 | { 365 | var value = new IdentifierName("myValue"); 366 | var typeRef = new TypeRef("MyType"); 367 | var typeCast = new TypeCast(value, typeRef); 368 | var output = Render(typeCast); 369 | 370 | Assert.Equal("myValue :: MyType", output); 371 | } 372 | 373 | [Fact] 374 | public void Renders_ElementAccess() 375 | { 376 | var elementAccess = new ElementAccess(new IdentifierName("a"), new Literal("123")); 377 | var output = Render(elementAccess); 378 | 379 | Assert.Equal("a[123]", output); 380 | } 381 | 382 | [Fact] 383 | public void Renders_MemberAccess() 384 | { 385 | var memberAccess = new MemberAccess(new IdentifierName("a"), new IdentifierName("b")); 386 | var output = Render(memberAccess); 387 | 388 | Assert.Equal("a.b", output); 389 | } 390 | 391 | [Fact] 392 | public void Renders_UnaryOperators() 393 | { 394 | var operand = new IdentifierName("isActive"); 395 | var unaryOp = new UnaryOperator("not ", operand); 396 | var output = Render(unaryOp); 397 | 398 | Assert.Equal("not isActive", output); 399 | } 400 | 401 | [Fact] 402 | public void Renders_BinaryOperators() 403 | { 404 | var left = new Literal("69"); 405 | var right = new Literal("420"); 406 | var binaryOp = new BinaryOperator(left, "+", right); 407 | var output = Render(binaryOp); 408 | 409 | Assert.Equal("69 + 420", output); 410 | } 411 | 412 | [Fact] 413 | public void Renders_Assignment() 414 | { 415 | var target = new ElementAccess(new IdentifierName("a"), new Literal("69")); 416 | var value = new Literal("420"); 417 | var assignment = new Assignment(target, value); 418 | var output = Render(assignment); 419 | Assert.Equal("a[69] = 420\n", output); 420 | } 421 | 422 | [Fact] 423 | public void Renders_QualifiedName() 424 | { 425 | const string result = "Abc:myMethod"; 426 | var name = new QualifiedName(new IdentifierName("Abc"), new IdentifierName("myMethod"), ':'); 427 | var output = Render(name); 428 | 429 | Assert.Equal(result, output); 430 | Assert.Equal(result, name.ToString()); 431 | } 432 | 433 | [Fact] 434 | public void Renders_GenericName() 435 | { 436 | const string result = "Abc"; 437 | var name = new GenericName("Abc", ["T", "U"]); 438 | var output = Render(name); 439 | 440 | Assert.Equal(result, output); 441 | Assert.Equal(result, name.ToString()); 442 | } 443 | 444 | [Fact] 445 | public void Renders_IdentifierName() 446 | { 447 | const string text = "Abc"; 448 | var name = new IdentifierName(text); 449 | var output = Render(name); 450 | 451 | Assert.Equal(text, output); 452 | Assert.Equal(text, name.ToString()); 453 | } 454 | 455 | [Fact] 456 | public void Renders_Parenthesized() 457 | { 458 | var parenthesized = new Parenthesized(AstUtility.String("bruh")); 459 | var output = Render(parenthesized); 460 | 461 | Assert.Equal("(\"bruh\")", output); 462 | } 463 | 464 | [Fact] 465 | public void Renders_InterpolatedStrings() 466 | { 467 | var stringInterpolation = new InterpolatedString([new Literal("hello, "), new Interpolation(new IdentifierName("name")), new Literal("!"),]); 468 | 469 | var output = Render(stringInterpolation); 470 | Assert.Equal("`hello, {name}!`", output); 471 | } 472 | 473 | [Theory] 474 | [InlineData(true)] 475 | [InlineData(false)] 476 | public void Renders_Variables(bool isLocal) 477 | { 478 | var identifier = new IdentifierName("abc"); 479 | var value = new Literal("69"); 480 | var typeRef = new TypeRef("number"); 481 | var variable = new Variable(identifier, isLocal, value, typeRef); 482 | var output = Render(variable); 483 | Assert.Equal($"{(isLocal ? "local " : "")}abc: number = 69\n", output); 484 | } 485 | 486 | [Fact] 487 | public void Renders_VariableLists() 488 | { 489 | var identifier = new IdentifierName("abc"); 490 | var value = new Literal("69"); 491 | var typeRef = new TypeRef("number"); 492 | var variables = Enumerable.Repeat(new Variable(identifier, true, value, typeRef), 5).ToList(); 493 | var variableList = new VariableList(variables); 494 | var output = Render(variableList); 495 | const string expectedOutput = """ 496 | local abc: number = 69 497 | local abc: number = 69 498 | local abc: number = 69 499 | local abc: number = 69 500 | local abc: number = 69 501 | 502 | """; 503 | 504 | Assert.Equal(expectedOutput.Replace("\r", ""), output.Replace("\r", "")); 505 | } 506 | 507 | [Fact] 508 | public void Renders_ParametersWithDefault() 509 | { 510 | var identifier = new IdentifierName("myFunction"); 511 | var parameterIdentifier = new IdentifierName("x"); 512 | var parameterType = new OptionalType(new TypeRef("number")); 513 | var parameterDefault = new Literal("69"); 514 | var body = new Block([]); 515 | var parameter = new Parameter(parameterIdentifier, false, parameterDefault, parameterType); 516 | var parameters = new ParameterList([parameter]); 517 | var function = new Function(identifier, 518 | true, 519 | parameters, 520 | null, 521 | body); 522 | 523 | var output = Render(function); 524 | const string expectedOutput = """ 525 | local function myFunction(x: number?) 526 | if x == nil then 527 | x = 69 528 | end 529 | end 530 | 531 | """; 532 | 533 | Assert.Equal(expectedOutput.Replace("\r", ""), output.Replace("\r", "")); 534 | } 535 | 536 | [Fact] 537 | public void Renders_StandardParameters() 538 | { 539 | var identifier = new IdentifierName("myFunction"); 540 | var parameterIdentifier = new IdentifierName("x"); 541 | var parameterType = new TypeRef("number"); 542 | var body = new Block([]); 543 | var parameter = new Parameter(parameterIdentifier, false, null, parameterType); 544 | var parameters = new ParameterList([parameter]); 545 | var function = new Function(identifier, 546 | true, 547 | parameters, 548 | null, 549 | body); 550 | 551 | var output = Render(function); 552 | const string expectedOutput = """ 553 | local function myFunction(x: number) 554 | end 555 | 556 | """; 557 | 558 | Assert.Equal(expectedOutput.Replace("\r", ""), output.Replace("\r", "")); 559 | } 560 | 561 | [Fact] 562 | public void Renders_VarargParameters() 563 | { 564 | var identifier = new IdentifierName("myFunction"); 565 | var parameterIdentifier = new IdentifierName("args"); 566 | var parameterType = new TypeRef("number"); 567 | var body = new Block([]); 568 | var parameter = new Parameter(parameterIdentifier, true, null, parameterType); 569 | var parameters = new ParameterList([parameter]); 570 | var function = new Function(identifier, 571 | true, 572 | parameters, 573 | null, 574 | body); 575 | 576 | var output = Render(function); 577 | const string expectedOutput = """ 578 | local function myFunction(...: number) 579 | local args: { number } = {...} 580 | end 581 | 582 | """; 583 | 584 | Assert.Equal(expectedOutput.Replace("\r", ""), output.Replace("\r", "")); 585 | } 586 | 587 | [Fact] 588 | public void Renders_AnonymousFunctions() 589 | { 590 | var body = new Block([new Return(new Literal("69"))]); 591 | var function = new AnonymousFunction(new ParameterList([]), new TypeRef("number"), body); 592 | var output = Render(function); 593 | const string expectedOutput = """ 594 | function(): number 595 | return 69 596 | end 597 | """; 598 | 599 | Assert.Equal(expectedOutput.Replace("\r", ""), output.Replace("\r", "")); 600 | } 601 | 602 | [Theory] 603 | [InlineData(true)] 604 | [InlineData(false)] 605 | public void Renders_Functions(bool isLocal) 606 | { 607 | var identifier = new IdentifierName("myFunction"); 608 | var body = new Block([new Return(new Literal("69"))]); 609 | var returnType = new TypeRef("number"); 610 | var function = new Function(identifier, 611 | isLocal, 612 | new ParameterList([]), 613 | returnType, 614 | body); 615 | 616 | var output = Render(function); 617 | var expectedOutput = $""" 618 | {(isLocal ? "local " : "")}function myFunction(): number 619 | return 69 620 | end 621 | 622 | """; 623 | 624 | Assert.Equal(expectedOutput.Replace("\r", ""), output.Replace("\r", "")); 625 | } 626 | 627 | [Fact] 628 | public void Renders_Continue() 629 | { 630 | var @continue = new Continue(); 631 | var output = Render(@continue); 632 | 633 | Assert.Equal("continue\n", output.Replace("\r", "")); 634 | } 635 | 636 | [Fact] 637 | public void Renders_Break() 638 | { 639 | var @break = new Break(); 640 | var output = Render(@break); 641 | 642 | Assert.Equal("break\n", output.Replace("\r", "")); 643 | } 644 | 645 | private static string Render(Node node) 646 | { 647 | var writer = new LuauWriter(); 648 | node.Render(writer); 649 | 650 | return writer.ToString(); 651 | } 652 | } -------------------------------------------------------------------------------- /RobloxCS.Tests/RobloxCS.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /RobloxCS.Tests/StandardUtilityTest.cs: -------------------------------------------------------------------------------- 1 | using RobloxCS.Shared; 2 | 3 | namespace RobloxCS.Tests; 4 | 5 | public class StandardUtilityTest 6 | { 7 | [Theory] 8 | [InlineData("float", "0")] 9 | [InlineData("double", "0")] 10 | [InlineData("int", "0")] 11 | [InlineData("uint", "0")] 12 | [InlineData("short", "0")] 13 | [InlineData("ushort", "0")] 14 | [InlineData("byte", "0")] 15 | [InlineData("sbyte", "0")] 16 | [InlineData("string", "\"\"")] 17 | [InlineData("char", "\"\"")] 18 | [InlineData("bool", "false")] 19 | [InlineData("nil", "nil")] 20 | [InlineData("WhatTheFuck", "nil")] 21 | public void GetDefaultValueForType(string typeName, string expectedValueText) 22 | { 23 | var valueText = StandardUtility.GetDefaultValueForType(typeName); 24 | Assert.Equal(expectedValueText, valueText); 25 | } 26 | 27 | [Theory] 28 | [InlineData("abc", "Abc")] 29 | [InlineData("Abc", "Abc")] 30 | [InlineData("ABC", "ABC")] 31 | [InlineData("", "")] 32 | public void Capitalize(string input, string expectedOutput) 33 | { 34 | var output = StandardUtility.Capitalize(input); 35 | Assert.Equal(expectedOutput, output); 36 | } 37 | 38 | [Theory] 39 | [InlineData("&", "band")] 40 | [InlineData("&=", "band")] 41 | [InlineData("|", "bor")] 42 | [InlineData("|=", "bor")] 43 | [InlineData("^", "bxor")] 44 | [InlineData("^=", "bxor")] 45 | [InlineData(">>", "rshift")] 46 | [InlineData(">>=", "rshift")] 47 | [InlineData("<<", "lshift")] 48 | [InlineData("<<=", "lshift")] 49 | [InlineData(">>>", "arshift")] 50 | [InlineData(">>>=", "arshift")] 51 | [InlineData("~", "bnot")] 52 | public void GetBit32MethodName(string input, string expectedOutput) 53 | { 54 | var output = StandardUtility.GetBit32MethodName(input); 55 | Assert.Equal(expectedOutput, output); 56 | } 57 | 58 | [Theory] 59 | [InlineData("A", "B", "C")] 60 | [InlineData("A", "B")] 61 | [InlineData("A>", "B")] 62 | [InlineData("A>", "D", "B")] 63 | [InlineData("A, D>", "B", "D")] 64 | [InlineData("A, D>", "B", "D")] 65 | public void ExtractTypeArguments(string input, params string[] expectedOutput) 66 | { 67 | var output = StandardUtility.ExtractTypeArguments(input); 68 | Assert.Equal(expectedOutput.Length, output.Count); 69 | 70 | for (var i = 0; i < expectedOutput.Length; i++) 71 | Assert.Equal(expectedOutput[i], output[i]); 72 | } 73 | 74 | [Theory] 75 | [InlineData("++", "+=")] 76 | [InlineData("--", "-=")] 77 | [InlineData("!=", "~=")] 78 | [InlineData("!", "not ")] 79 | [InlineData("&&", "and")] 80 | [InlineData("||", "or")] 81 | [InlineData("*", "*")] 82 | public void GetMappedOperator(string input, string expectedOutput) 83 | { 84 | var output = StandardUtility.GetMappedOperator(input); 85 | Assert.Equal(expectedOutput, output); 86 | } 87 | } -------------------------------------------------------------------------------- /RobloxCS.Tests/TransformerTests/MainTransformerTest.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis.CSharp; 2 | using Microsoft.CodeAnalysis.CSharp.Syntax; 3 | using RobloxCS.Luau; 4 | using RobloxCS.Shared; 5 | using RobloxCS.Transformers; 6 | 7 | namespace RobloxCS.Tests.TransformerTests; 8 | 9 | public class MainTransformerTest 10 | { 11 | [Fact] 12 | public void Transforms_PrimaryConstructors() 13 | { 14 | const string source = """ 15 | class MyClass(int abc, string def) 16 | { 17 | public int Abc { get; } = abc; 18 | public string Def { get; } = def; 19 | } 20 | """; 21 | 22 | var compilationUnit = Transform(source); 23 | var classDecl = compilationUnit.Members.OfType().Single(); 24 | Assert.Null(classDecl.ParameterList); // removed primary ctor 25 | 26 | var properties = classDecl.Members.OfType().ToArray(); 27 | Assert.Equal(2, properties.Length); 28 | Assert.Contains(properties, p => p.Identifier.Text == "Abc"); 29 | Assert.Contains(properties, p => p.Identifier.Text == "Def"); 30 | Assert.All(properties, p => Assert.Null(p.Initializer)); // props using primary params have no initializer 31 | 32 | var constructors = classDecl.Members.OfType().ToList(); 33 | Assert.Single(constructors); 34 | 35 | var constructor = constructors.First(); 36 | Assert.Equal("MyClass", constructor.Identifier.Text); 37 | 38 | var parameters = constructor.ParameterList.Parameters; 39 | Assert.Equal(2, parameters.Count); 40 | Assert.Equal("abc", parameters[0].Identifier.Text); 41 | Assert.Equal("def", parameters[1].Identifier.Text); 42 | 43 | var statements = constructor.Body!.Statements.OfType().ToArray(); 44 | Assert.Equal(2, statements.Length); 45 | 46 | var assignments = statements.Select(s => s.Expression).OfType().ToArray(); 47 | Assert.All(assignments, 48 | assign => 49 | { 50 | Assert.Equal(SyntaxKind.SimpleAssignmentExpression, assign.Kind()); 51 | Assert.IsType(assign.Left); 52 | Assert.IsType(assign.Right); 53 | }); 54 | 55 | var assignedProperties = assignments.Select(a => ((MemberAccessExpressionSyntax)a.Left).Name.Identifier.Text).ToArray(); 56 | 57 | Assert.Contains("Abc", assignedProperties); 58 | Assert.Contains("Def", assignedProperties); 59 | } 60 | 61 | [Fact] 62 | public void Transforms_FileScopedNamespaces() 63 | { 64 | var compilationUnit = Transform("namespace Abc;"); 65 | Assert.Single(compilationUnit.Members); 66 | Assert.IsType(compilationUnit.Members.First()); 67 | 68 | var @namespace = (NamespaceDeclarationSyntax)compilationUnit.Members.First(); 69 | Assert.Equal("Abc", @namespace.Name.ToString()); 70 | } 71 | 72 | [Fact] 73 | public void AddsExtraUsings() 74 | { 75 | var compilationUnit = Transform(""); 76 | Assert.Equal(5, compilationUnit.Usings.Count); 77 | 78 | var usingSystemCollectionsGeneric = compilationUnit.Usings[0]; 79 | var usingSystemCollections = compilationUnit.Usings[1]; 80 | var usingSystemLinq = compilationUnit.Usings[2]; 81 | var usingRoblox = compilationUnit.Usings[3]; 82 | var usingRobloxGlobals = compilationUnit.Usings[4]; 83 | Assert.Equal("System.Collections.Generic", usingSystemCollectionsGeneric.Name?.ToString()); 84 | Assert.Equal("System.Collections", usingSystemCollections.Name?.ToString()); 85 | Assert.Equal("System.Linq", usingSystemLinq.Name?.ToString()); 86 | Assert.Equal("Roblox", usingRoblox.Name?.ToString()); 87 | #pragma warning disable xUnit2002 88 | Assert.NotNull(usingRobloxGlobals.StaticKeyword); 89 | #pragma warning restore xUnit2002 90 | Assert.Equal("Roblox.Globals", usingRobloxGlobals.Name?.ToString()); 91 | } 92 | 93 | private static CompilationUnitSyntax Transform(string source) 94 | { 95 | var cleanTree = SyntaxFactory.ParseSyntaxTree(source); 96 | var transform = BuiltInTransformers.Main(); 97 | var compilation = new FileCompilation 98 | { 99 | Tree = cleanTree, 100 | RojoProject = null, 101 | Config = ConfigReader.UnitTestingConfig 102 | }; 103 | 104 | var transformedTree = transform(compilation); 105 | return transformedTree.GetCompilationUnitRoot(); 106 | } 107 | } -------------------------------------------------------------------------------- /RobloxCS.Tests/WholeFileRenderingTest.cs: -------------------------------------------------------------------------------- 1 | using RobloxCS.Luau; 2 | using RobloxCS.Shared; 3 | 4 | namespace RobloxCS.Tests; 5 | 6 | public class WholeFileRenderingTest 7 | { 8 | [Fact] 9 | public void Renders_BasicFunctionsAndVariables() 10 | { 11 | const string source = """ 12 | var n = 42; 13 | var doubled = DoSomethingElse(n); 14 | var mainDoubled = DoSomething(); 15 | 16 | int DoSomething() => DoSomethingElse(69); 17 | int DoSomethingElse(int x) 18 | { 19 | print("x:", x); 20 | return x * 2; 21 | } 22 | """; 23 | 24 | const string expectedOutput = """ 25 | local function DoSomethingElse(x: number): number 26 | print("x:", x) 27 | return x * 2 28 | end 29 | local function DoSomething(): number 30 | return DoSomethingElse(69) 31 | end 32 | local n = 42 33 | local doubled = DoSomethingElse(n) 34 | local mainDoubled = DoSomething() 35 | return nil 36 | """; 37 | 38 | var output = Emit(source); 39 | Assert.Equal(expectedOutput.Replace("\r", ""), string.Join('\n', output.Replace("\r", "").Split('\n').Skip(1)).Trim()); 40 | } 41 | 42 | private static string Emit(string source) 43 | { 44 | var config = ConfigReader.UnitTestingConfig; 45 | var file = TranspilerUtility.ParseAndTransformTree(source.Trim(), new RojoProject(), config); 46 | var compiler = TranspilerUtility.GetCompiler([file.Tree], config); 47 | var ast = TranspilerUtility.GetLuauAST(file, compiler); 48 | var writer = new LuauWriter(); 49 | ast.Render(writer); 50 | 51 | return writer.ToString(); 52 | } 53 | } -------------------------------------------------------------------------------- /RobloxCS.Tests/lune: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roblox-csharp/roblox-cs/be97691d1610450becdac6efae3fce3e5adb98f6/RobloxCS.Tests/lune -------------------------------------------------------------------------------- /RobloxCS.Tests/lune.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roblox-csharp/roblox-cs/be97691d1610450becdac6efae3fce3e5adb98f6/RobloxCS.Tests/lune.exe -------------------------------------------------------------------------------- /RobloxCS.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.8.34330.188 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RobloxCS", "RobloxCS\RobloxCS.csproj", "{B4BA2EC2-60E0-4510-8366-EC2737FC3E9D}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RobloxCS.Luau", "RobloxCS.Luau\RobloxCS.Luau.csproj", "{F0B0E304-CFB3-4B58-891E-3B6DAE23E597}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution", "Solution", "{44CF4B5D-C4EB-41BF-8D70-20D5213AFA4A}" 11 | ProjectSection(SolutionItems) = preProject 12 | .github\workflows\ci.yml = .github\workflows\ci.yml 13 | .github\workflows\cd.yml = .github\workflows\cd.yml 14 | README.md = README.md 15 | .gitignore = .gitignore 16 | nuget.config = nuget.config 17 | EndProjectSection 18 | EndProject 19 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RobloxCS.Tests", "RobloxCS.Tests\RobloxCS.Tests.csproj", "{D63E7B66-97C2-4948-8BAE-FA548C066DCE}" 20 | EndProject 21 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RobloxCS.Shared", "RobloxCS.Shared\RobloxCS.Shared.csproj", "{1299164F-89DC-42B9-B11C-9C8DC2FF8D5D}" 22 | EndProject 23 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RobloxCS.CLI", "RobloxCS.CLI\RobloxCS.CLI.csproj", "{24068826-2AEE-4C59-B881-F97CD6EB1298}" 24 | EndProject 25 | Global 26 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 27 | Debug|Any CPU = Debug|Any CPU 28 | Release|Any CPU = Release|Any CPU 29 | EndGlobalSection 30 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 31 | {B4BA2EC2-60E0-4510-8366-EC2737FC3E9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 32 | {B4BA2EC2-60E0-4510-8366-EC2737FC3E9D}.Debug|Any CPU.Build.0 = Debug|Any CPU 33 | {B4BA2EC2-60E0-4510-8366-EC2737FC3E9D}.Release|Any CPU.ActiveCfg = Release|Any CPU 34 | {B4BA2EC2-60E0-4510-8366-EC2737FC3E9D}.Release|Any CPU.Build.0 = Release|Any CPU 35 | {F0B0E304-CFB3-4B58-891E-3B6DAE23E597}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 36 | {F0B0E304-CFB3-4B58-891E-3B6DAE23E597}.Debug|Any CPU.Build.0 = Debug|Any CPU 37 | {F0B0E304-CFB3-4B58-891E-3B6DAE23E597}.Release|Any CPU.ActiveCfg = Release|Any CPU 38 | {F0B0E304-CFB3-4B58-891E-3B6DAE23E597}.Release|Any CPU.Build.0 = Release|Any CPU 39 | {D63E7B66-97C2-4948-8BAE-FA548C066DCE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 40 | {D63E7B66-97C2-4948-8BAE-FA548C066DCE}.Debug|Any CPU.Build.0 = Debug|Any CPU 41 | {D63E7B66-97C2-4948-8BAE-FA548C066DCE}.Release|Any CPU.ActiveCfg = Release|Any CPU 42 | {D63E7B66-97C2-4948-8BAE-FA548C066DCE}.Release|Any CPU.Build.0 = Release|Any CPU 43 | {1299164F-89DC-42B9-B11C-9C8DC2FF8D5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 44 | {1299164F-89DC-42B9-B11C-9C8DC2FF8D5D}.Debug|Any CPU.Build.0 = Debug|Any CPU 45 | {1299164F-89DC-42B9-B11C-9C8DC2FF8D5D}.Release|Any CPU.ActiveCfg = Release|Any CPU 46 | {1299164F-89DC-42B9-B11C-9C8DC2FF8D5D}.Release|Any CPU.Build.0 = Release|Any CPU 47 | {24068826-2AEE-4C59-B881-F97CD6EB1298}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 48 | {24068826-2AEE-4C59-B881-F97CD6EB1298}.Debug|Any CPU.Build.0 = Debug|Any CPU 49 | {24068826-2AEE-4C59-B881-F97CD6EB1298}.Release|Any CPU.ActiveCfg = Release|Any CPU 50 | {24068826-2AEE-4C59-B881-F97CD6EB1298}.Release|Any CPU.Build.0 = Release|Any CPU 51 | EndGlobalSection 52 | GlobalSection(SolutionProperties) = preSolution 53 | HideSolutionNode = FALSE 54 | EndGlobalSection 55 | GlobalSection(ExtensibilityGlobals) = postSolution 56 | SolutionGuid = {FCD1F95A-141B-4496-A2DF-AFCE8AB0E1E5} 57 | EndGlobalSection 58 | EndGlobal 59 | -------------------------------------------------------------------------------- /RobloxCS/Analyzer.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | using Microsoft.CodeAnalysis.CSharp; 3 | using Microsoft.CodeAnalysis.CSharp.Syntax; 4 | using RobloxCS.Luau; 5 | using RobloxCS.Shared; 6 | 7 | namespace RobloxCS; 8 | 9 | public sealed class Analyzer(FileCompilation file, CSharpCompilation compiler) : CSharpSyntaxWalker 10 | { 11 | private readonly AnalysisResult _result = new(); 12 | private readonly SemanticModel _semanticModel = compiler.GetSemanticModel(file.Tree); 13 | 14 | public AnalysisResult Analyze(SyntaxNode? root) 15 | { 16 | Visit(root); 17 | return _result; 18 | } 19 | 20 | public override void VisitMemberAccessExpression(MemberAccessExpressionSyntax node) 21 | { 22 | if (node.Name is not IdentifierNameSyntax propertyName) return; 23 | 24 | var expressionTypeSymbol = _semanticModel.GetTypeInfo(node.Expression).Type; 25 | var nameText = propertyName.Identifier.Text; 26 | switch (expressionTypeSymbol) 27 | { 28 | case { ContainingNamespace: { ContainingNamespace.Name: "System", Name: "Reflection" }, Name: "PropertyInfo" }: 29 | { 30 | _result.PropertyClassInfo.MemberUses.Add(nameText); 31 | break; 32 | } 33 | case { ContainingNamespace: { ContainingNamespace.Name: "System", Name: "Reflection" }, Name: "MemberInfo" }: 34 | { 35 | _result.MemberClassInfo.MemberUses.Add(nameText); 36 | break; 37 | } 38 | case { ContainingNamespace: { ContainingNamespace.Name: "System", Name: "Reflection" }, Name: "CustomAttributeData" }: 39 | { 40 | _result.CustomAttributeDataClassInfo.MemberUses.Add(nameText); 41 | break; 42 | } 43 | case { ContainingNamespace: { ContainingNamespace.Name: "System", Name: "Reflection" }, Name: "Assembly" }: 44 | { 45 | _result.AssemblyClassInfo.MemberUses.Add(nameText); 46 | break; 47 | } 48 | case { ContainingNamespace: { ContainingNamespace.Name: "System", Name: "Reflection" }, Name: "Module" }: 49 | { 50 | 51 | _result.ModuleClassInfo.MemberUses.Add(nameText); 52 | break; 53 | } 54 | case { ContainingNamespace.Name: "System", Name: "Type" }: 55 | { 56 | _result.TypeClassInfo.MemberUses.Add(nameText); 57 | break; 58 | } 59 | } 60 | 61 | base.VisitMemberAccessExpression(node); 62 | } 63 | } -------------------------------------------------------------------------------- /RobloxCS/BaseGenerator.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | using Microsoft.CodeAnalysis.CSharp; 3 | using Microsoft.CodeAnalysis.CSharp.Syntax; 4 | using RobloxCS.Luau; 5 | using RobloxCS.Shared; 6 | 7 | namespace RobloxCS; 8 | 9 | /// Basically just defines utility methods for LuauGenerator 10 | public class BaseGenerator(FileCompilation file, CSharpCompilation compiler) : CSharpSyntaxVisitor 11 | { 12 | private readonly SyntaxKind[] _commentSyntaxes = 13 | [ 14 | SyntaxKind.SingleLineCommentTrivia, 15 | SyntaxKind.SingleLineDocumentationCommentTrivia, 16 | SyntaxKind.MultiLineCommentTrivia, 17 | SyntaxKind.MultiLineDocumentationCommentTrivia 18 | ]; 19 | protected readonly FileCompilation _file = file; 20 | 21 | private readonly HashSet _multiLineCommentSyntaxes = [SyntaxKind.MultiLineCommentTrivia, SyntaxKind.MultiLineDocumentationCommentTrivia]; 22 | protected SemanticModel _semanticModel = compiler.GetSemanticModel(file.Tree); 23 | 24 | protected TNode Visit(SyntaxNode? node) 25 | where TNode : Node? => 26 | (TNode)Visit(node)!; 27 | 28 | /// Generates a Luau class constructor from a C# class declaration 29 | protected Function GenerateConstructor(ClassDeclarationSyntax classDeclaration, 30 | ParameterList parameterList, 31 | Block? body = null, 32 | List? attributeLists = null) 33 | { 34 | parameterList.Parameters.Insert(0, new Parameter(new("self"), type: new TypeRef(classDeclaration.Identifier.Text))); 35 | var className = AstUtility.CreateSimpleName(classDeclaration); 36 | var nonGenericName = AstUtility.GetNonGenericName(className); 37 | body ??= new Block([]); 38 | 39 | // visit fields/properties being assigned a value outside the constructor (aka non-static & with initializers) 40 | var nonStaticFields = classDeclaration.Members 41 | .OfType() 42 | .Where(field => !HasSyntax(field.Modifiers, SyntaxKind.StaticKeyword)); 43 | 44 | var nonStaticProperties = classDeclaration.Members 45 | .OfType() 46 | .Where(field => !HasSyntax(field.Modifiers, SyntaxKind.StaticKeyword)); 47 | 48 | var nonStaticEvents = classDeclaration.Members 49 | .OfType() 50 | .Where(field => !HasSyntax(field.Modifiers, SyntaxKind.StaticKeyword)); 51 | 52 | foreach (var field in nonStaticFields) 53 | { 54 | foreach (var declarator in field.Declaration.Variables) 55 | { 56 | var initializer = GetFieldOrPropertyInitializer(classDeclaration, field.Declaration.Type, declarator.Initializer); 57 | if (initializer == null) continue; 58 | 59 | body.Statements.Insert(0, 60 | new Assignment(new MemberAccess(new IdentifierName("self"), 61 | AstUtility.CreateSimpleName(declarator)), 62 | initializer)); 63 | } 64 | } 65 | 66 | foreach (var property in nonStaticProperties) 67 | { 68 | var initializer = GetFieldOrPropertyInitializer(classDeclaration, property.Type, property.Initializer); 69 | if (initializer == null) continue; 70 | 71 | body.Statements.Insert(0, 72 | new Assignment(new MemberAccess(new IdentifierName("self"), 73 | AstUtility.CreateSimpleName(property)), 74 | initializer)); 75 | } 76 | 77 | foreach (var eventField in nonStaticEvents) 78 | { 79 | foreach (var declarator in eventField.Declaration.Variables) 80 | body.Statements.Insert(0, 81 | new Assignment(new MemberAccess(new IdentifierName("self"), 82 | AstUtility.CreateSimpleName(declarator)), 83 | AstUtility.NewSignal())); 84 | } 85 | 86 | // add an explicit return (for strict mode) if there isn't one 87 | if (!body.Statements.Any(statement => statement is Return)) 88 | body.Statements.Add(new Return(AstUtility.Nil)); 89 | 90 | return new Function(new IdentifierName("constructor"), 91 | true, 92 | parameterList, 93 | new OptionalType(AstUtility.CreateTypeRef(className.ToString())!), 94 | body, 95 | attributeLists); 96 | } 97 | 98 | protected Expression? GetFieldOrPropertyInitializer(ClassDeclarationSyntax classDeclaration, TypeSyntax type, EqualsValueClauseSyntax? initializer) 99 | { 100 | var explicitInitializer = Visit(initializer); 101 | if (initializer != null) 102 | return explicitInitializer; 103 | 104 | var typeSymbol = _semanticModel.GetTypeInfo(type).Type; 105 | if (typeSymbol == null) 106 | return explicitInitializer; 107 | 108 | var symbol = _semanticModel.GetSymbolInfo(type).Symbol; 109 | if (symbol != null && IsInitializedInConstructor(classDeclaration, symbol)) 110 | return null; 111 | 112 | var defaultValue = new Literal(StandardUtility.GetDefaultValueForType(typeSymbol.Name)); 113 | return explicitInitializer ?? defaultValue; 114 | } 115 | 116 | protected static string GetName(SyntaxNode node) => StandardUtility.GetNamesFromNode(node).First(); 117 | 118 | protected static string? TryGetName(SyntaxNode? node) => StandardUtility.GetNamesFromNode(node).FirstOrDefault(); 119 | 120 | protected static bool IsStatic(MemberDeclarationSyntax node) => IsParentClassStatic(node) || HasSyntax(node.Modifiers, SyntaxKind.StaticKeyword); 121 | 122 | protected static bool HasSyntax(SyntaxTokenList tokens, SyntaxKind syntax) => tokens.Any(token => token.IsKind(syntax)); 123 | 124 | protected static T? FindFirstAncestor(SyntaxNode node) 125 | where T : SyntaxNode => 126 | GetAncestors(node).FirstOrDefault(); 127 | 128 | private bool IsInitializedInConstructor(ClassDeclarationSyntax classDeclaration, ISymbol symbol) 129 | { 130 | var constructors = classDeclaration.Members 131 | .OfType() 132 | .Where(c => !HasSyntax(c.Modifiers, SyntaxKind.StaticKeyword)) 133 | .ToList(); 134 | 135 | if (constructors.Count == 0) 136 | return false; 137 | 138 | foreach (var constructor in constructors) 139 | { 140 | if (constructor.ExpressionBody == null || constructor.Body == null) continue; 141 | 142 | var flow = constructor.ExpressionBody != null 143 | ? _semanticModel.AnalyzeDataFlow(constructor.ExpressionBody) 144 | : _semanticModel.AnalyzeDataFlow(constructor.Body); 145 | 146 | if (flow == null) continue; 147 | if (flow.DefinitelyAssignedOnExit.Contains(symbol)) continue; 148 | 149 | return false; 150 | } 151 | 152 | return true; 153 | } 154 | 155 | private static List GetAncestors(SyntaxNode node) 156 | where T : SyntaxNode => 157 | node.Ancestors().OfType().ToList(); 158 | 159 | private static bool IsParentClassStatic(SyntaxNode node) => 160 | node.Parent is ClassDeclarationSyntax classDeclaration && HasSyntax(classDeclaration.Modifiers, SyntaxKind.StaticKeyword); 161 | } -------------------------------------------------------------------------------- /RobloxCS/Constants.cs: -------------------------------------------------------------------------------- 1 | namespace RobloxCS; 2 | 3 | internal static class Constants 4 | { 5 | public const string HeaderComment = "Compiled with roblox-cs v2.0.0"; 6 | 7 | public static readonly HashSet DISALLOWED_BASE_TYPES = ["Array", "IEnumerable", "ISet", "HashSet"]; 8 | } -------------------------------------------------------------------------------- /RobloxCS/Include/RuntimeLib.luau: -------------------------------------------------------------------------------- 1 | --!strict 2 | --!native 3 | local CS = {} 4 | 5 | type GlobalNamespace = { [string]: NamespaceMember } 6 | type IEnum = { [K & string]: V & number } 7 | type Enum = IEnum 8 | 9 | export type Namespace = { [string]: any } 10 | type NamespaceMember = Namespace | Class | Enum 11 | 12 | CS.globalNamespace = {} :: GlobalNamespace 13 | 14 | function CS.defineGlobal(name: string, value: NamespaceMember): () 15 | CS.globalNamespace[name] = value 16 | end 17 | 18 | function CS.getGlobal(name: string): NamespaceMember 19 | return CS.globalNamespace[name] 20 | end 21 | 22 | type IClass = { 23 | __className: string; -- prob temporary 24 | new: T; 25 | } 26 | 27 | export type Class = IClass<() -> {}> 28 | 29 | function CS.is(object: any, class: Class | string): boolean 30 | if typeof(class) == "table" and typeof(class.__className) == "string" then 31 | return typeof(object) == "table" and typeof(object.__className) == "string" and object.__className == class.__className 32 | end 33 | 34 | -- metatable check 35 | if typeof(object) == "table" then 36 | object = getmetatable(object) 37 | 38 | while object ~= nil do 39 | if object == class then 40 | return true 41 | end 42 | 43 | local mt = getmetatable(object) :: { __index: {} } 44 | object = if mt then mt.__index else nil 45 | end 46 | end 47 | 48 | if typeof(class) == "string" then 49 | return if typeof(object) == "Instance" then 50 | object:IsA(class) 51 | else 52 | typeof(object) == class 53 | end 54 | 55 | return false 56 | end 57 | 58 | function CS.unpackTuple(tuple: { [K & string]: T }): ...T 59 | local list = {} 60 | local i: number 61 | for k, v in tuple do 62 | i = tonumber(k:sub(5)) or error(`Invalid tuple index: {k}`) 63 | list[i] = v 64 | end 65 | return table.unpack(list) 66 | end 67 | 68 | -- Enumerator class 69 | type EnumerationFunction = () -> { () -> T } 70 | export type EnumeratorClass = IClass<(items: { T } | EnumerationFunction) -> IEnumerator>; 71 | export type IEnumerator = EnumeratorClass & { 72 | _index: number; 73 | read _isAdvanced: boolean; 74 | Current: T; 75 | MoveNext: (IEnumerator) -> (); 76 | } 77 | type Enumerator = IEnumerator 78 | 79 | local Enumerator 80 | do 81 | local className = "Enumerator" 82 | Enumerator = (setmetatable({}, { 83 | __tostring = function(): string 84 | return className 85 | end 86 | }) :: any) :: Enumerator 87 | Enumerator.__index = Enumerator 88 | Enumerator.__className = className 89 | 90 | function Enumerator.new(items: { T } | EnumerationFunction): IEnumerator 91 | local isAdvanced = typeof(items) == "function" 92 | return (setmetatable({ 93 | _items = if isAdvanced then (items :: EnumerationFunction)() else items, 94 | _isAdvanced = isAdvanced, 95 | _index = 0 96 | }, Enumerator) :: any) :: IEnumerator 97 | end 98 | 99 | function Enumerator:MoveNext(): boolean 100 | self._index += 1 101 | local gotValue = self._index <= #self._items 102 | local item = if gotValue then self._items[self._index] else nil 103 | self.Current = if self._isAdvanced and typeof(item) == "function" then 104 | item(function() 105 | self._index = #self._items + 1 106 | end) 107 | else 108 | item 109 | return gotValue 110 | end 111 | 112 | function Enumerator:Reset(): () 113 | self._index = 0 114 | self.Current = nil :: any 115 | end 116 | 117 | function Enumerator:Dispose(): () 118 | self._items = nil 119 | setmetatable(self, nil) 120 | end 121 | 122 | -- internal function to collect into table 123 | function Enumerator:_collect() 124 | local function iter() 125 | self:MoveNext() 126 | return self.Current 127 | end 128 | 129 | local result = {} 130 | for v in iter do 131 | table.insert(result, v) 132 | end 133 | 134 | return result 135 | end 136 | end 137 | CS.defineGlobal("Enumerator", Enumerator) 138 | CS.Enumerator = Enumerator 139 | 140 | -- Exception class 141 | type ExceptionClass = Class & { 142 | __index: ExceptionClass; 143 | new: (message: string?) -> Exception; 144 | } 145 | export type Exception = ExceptionClass & { 146 | Message: string; 147 | Throw: (Exception) -> (); 148 | } 149 | 150 | local Exception 151 | do 152 | Exception = (setmetatable({}, { 153 | __tostring = function(self: Exception): string 154 | return `{self.__className}: {self.Message}` 155 | end 156 | }) :: any) :: Exception 157 | Exception.__index = Exception 158 | Exception.__className = "Exception" 159 | 160 | function Exception.new(message: string?): Exception 161 | message = message or "An error occurred" 162 | 163 | return (setmetatable({ Message = message }, Exception) :: any) :: Exception 164 | end 165 | 166 | function Exception:Throw(): () 167 | error(self) 168 | end 169 | end 170 | CS.defineGlobal("Exception", Exception) 171 | CS.Exception = Exception 172 | 173 | type CatchBlock = { 174 | exceptionClass: string; 175 | block: (ex: Exception?, rethrow: () -> ()) -> (); 176 | } 177 | 178 | function CS.try(block: () -> nil, finallyBlock: (() -> ())?, catchBlocks: { CatchBlock }): () 179 | local success: boolean, result: Exception | string | nil = pcall(block) 180 | if not success then 181 | if typeof(result) == "string" then 182 | result = if Exception == nil --[[ never ]] then nil :: any else Exception.new(result) 183 | end 184 | for _, catchBlock in catchBlocks do 185 | if catchBlock.exceptionClass ~= result.__className then continue end 186 | catchBlock.block(result :: Exception, result.Throw) 187 | end 188 | end 189 | if finallyBlock ~= nil then 190 | finallyBlock() 191 | end 192 | end 193 | 194 | return CS -------------------------------------------------------------------------------- /RobloxCS/Include/Signal.luau: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- 2 | -- Batched Yield-Safe Signal Implementation -- 3 | -- This is a Signal class which has effectively identical behavior to a -- 4 | -- normal RBXScriptSignal, with the only difference being a couple extra -- 5 | -- stack frames at the bottom of the stack trace when an error is thrown. -- 6 | -- This implementation caches runner coroutines, so the ability to yield in -- 7 | -- the signal handlers comes at minimal extra cost over a naive signal -- 8 | -- implementation that either always or never spawns a thread. -- 9 | -- -- 10 | -- API: -- 11 | -- local Signal = require(THIS MODULE) -- 12 | -- local sig = Signal.new() -- 13 | -- local connection = sig:Connect(function(arg1, arg2, ...) ... end) -- 14 | -- sig:Fire(arg1, arg2, ...) -- 15 | -- connection:Disconnect() -- 16 | -- sig:DisconnectAll() -- 17 | -- local arg1, arg2, ... = sig:Wait() -- 18 | -- -- 19 | -- Licence: -- 20 | -- Licenced under the MIT licence. -- 21 | -- -- 22 | -- Authors: -- 23 | -- stravant - July 31st, 2021 - Created the file. -- 24 | -------------------------------------------------------------------------------- 25 | --!nocheck 26 | 27 | -- The currently idle thread to run the next handler on 28 | local freeRunnerThread = nil 29 | 30 | -- Function which acquires the currently idle handler runner thread, runs the 31 | -- function fn on it, and then releases the thread, returning it to being the 32 | -- currently idle one. 33 | -- If there was a currently idle runner thread already, that's okay, that old 34 | -- one will just get thrown and eventually GCed. 35 | local function acquireRunnerThreadAndCallEventHandler(fn, ...) 36 | local acquiredRunnerThread = freeRunnerThread 37 | freeRunnerThread = nil 38 | fn(...) 39 | -- The handler finished running, this runner thread is free again. 40 | freeRunnerThread = acquiredRunnerThread 41 | end 42 | 43 | -- Coroutine runner that we create coroutines of. The coroutine can be 44 | -- repeatedly resumed with functions to run followed by the argument to run 45 | -- them with. 46 | local function runEventHandlerInFreeThread() 47 | -- Note: We cannot use the initial set of arguments passed to 48 | -- runEventHandlerInFreeThread for a call to the handler, because those 49 | -- arguments would stay on the stack for the duration of the thread's 50 | -- existence, temporarily leaking references. Without access to raw bytecode 51 | -- there's no way for us to clear the "..." references from the stack. 52 | while true do 53 | acquireRunnerThreadAndCallEventHandler(coroutine.yield()) 54 | end 55 | end 56 | 57 | -- Connection class 58 | local Connection = {} 59 | Connection.__index = Connection 60 | 61 | function Connection.new(signal, fn) 62 | return setmetatable({ 63 | _connected = true, 64 | _signal = signal, 65 | _fn = fn, 66 | _next = false, 67 | }, Connection) 68 | end 69 | 70 | function Connection:Disconnect() 71 | self._connected = false 72 | 73 | -- Unhook the node, but DON'T clear it. That way any fire calls that are 74 | -- currently sitting on this node will be able to iterate forwards off of 75 | -- it, but any subsequent fire calls will not hit it, and it will be GCed 76 | -- when no more fire calls are sitting on it. 77 | if self._signal._handlerListHead == self then 78 | self._signal._handlerListHead = self._next 79 | else 80 | local prev = self._signal._handlerListHead 81 | while prev and prev._next ~= self do 82 | prev = prev._next 83 | end 84 | if prev then 85 | prev._next = self._next 86 | end 87 | end 88 | end 89 | 90 | -- Make Connection strict 91 | setmetatable(Connection, { 92 | __index = function(_, key) 93 | error(("Attempt to get Connection::%s (not a valid member)"):format(tostring(key)), 2) 94 | end, 95 | __newindex = function(_, key) 96 | error(("Attempt to set Connection::%s (not a valid member)"):format(tostring(key)), 2) 97 | end, 98 | }) 99 | 100 | export type Connection = { 101 | Disconnect: (self: Connection) -> (), 102 | } 103 | 104 | export type Signal = { 105 | Connect: (self: Signal, callback: (T...) -> ()) -> Connection, 106 | Once: (self: Signal, callback: (T...) -> ()) -> Connection, 107 | Fire: (self: Signal, T...) -> (), 108 | Wait: (self: Signal) -> (), 109 | } 110 | 111 | -- Signal class 112 | local Signal = {} 113 | Signal.__index = Signal 114 | 115 | function Signal.new(): Signal 116 | return setmetatable({ 117 | _handlerListHead = false, 118 | }, Signal) :: any 119 | end 120 | 121 | function Signal:Connect(fn) 122 | local connection = Connection.new(self, fn) 123 | if self._handlerListHead then 124 | connection._next = self._handlerListHead 125 | self._handlerListHead = connection 126 | else 127 | self._handlerListHead = connection 128 | end 129 | return connection 130 | end 131 | 132 | -- Disconnect all handlers. Since we use a linked list it suffices to clear the 133 | -- reference to the head handler. 134 | function Signal:DisconnectAll() 135 | self._handlerListHead = false 136 | end 137 | 138 | -- Signal:Fire(...) implemented by running the handler functions on the 139 | -- coRunnerThread, and any time the resulting thread yielded without returning 140 | -- to us, that means that it yielded to the Roblox scheduler and has been taken 141 | -- over by Roblox scheduling, meaning we have to make a new coroutine runner. 142 | function Signal:Fire(...) 143 | local item = self._handlerListHead 144 | while item do 145 | if item._connected then 146 | if not freeRunnerThread then 147 | freeRunnerThread = coroutine.create(runEventHandlerInFreeThread) 148 | -- Get the freeRunnerThread to the first yield 149 | coroutine.resume(freeRunnerThread) 150 | end 151 | task.spawn(freeRunnerThread, item._fn, ...) 152 | end 153 | item = item._next 154 | end 155 | end 156 | 157 | -- Implement Signal:Wait() in terms of a temporary connection using 158 | -- a Signal:Connect() which disconnects itself. 159 | function Signal:Wait() 160 | local waitingCoroutine = coroutine.running() 161 | local cn 162 | cn = self:Connect(function(...) 163 | cn:Disconnect() 164 | task.spawn(waitingCoroutine, ...) 165 | end) 166 | return coroutine.yield() 167 | end 168 | 169 | -- Implement Signal:Once() in terms of a connection which disconnects 170 | -- itself before running the handler. 171 | function Signal:Once(fn) 172 | local cn 173 | cn = self:Connect(function(...) 174 | if cn._connected then 175 | cn:Disconnect() 176 | end 177 | fn(...) 178 | end) 179 | return cn 180 | end 181 | 182 | -- Make signal strict 183 | setmetatable(Signal, { 184 | __index = function(_, key) 185 | error(("Attempt to get Signal::%s (not a valid member)"):format(tostring(key)), 2) 186 | end, 187 | __newindex = function(_, key) 188 | error(("Attempt to set Signal::%s (not a valid member)"):format(tostring(key)), 2) 189 | end, 190 | }) 191 | 192 | return Signal 193 | -------------------------------------------------------------------------------- /RobloxCS/README.md: -------------------------------------------------------------------------------- 1 | # RobloxCS 2 | 3 | ## To-do 4 | 5 | Key: 6 | 7 | - `?` -> will maybe be added 8 | 9 | In no particular order: 10 | 11 | - [ ] disallow method grouping with methods from objects/classes 12 | - [ ] only generate interface declarations & inherit from interfaces if they have methods with implementations 13 | - [ ] map `dynamic` to no type at all 14 | - [ ] a LOT more testing 15 | - [ ] generation tests (started) 16 | - [ ] luau rendering tests (mostly done) 17 | - [ ] transformer tests 18 | - [ ] main transformer (started) 19 | - [ ] runtime library tests (started) 20 | - [ ] utility tests 21 | - [ ] standard utility (started) 22 | - [ ] ast utility (started) 23 | - [ ] file utility 24 | - [ ] save navigation (`a?.b?.c`) 25 | - [ ] macro `ToNumber()`, `ToUInt()`, `ToFloat()`, etc. (defined in Roblox.cs in RobloxCS.Types) to `tonumber()` 26 | - this may be replaced with the task below 27 | - [ ] macro primitive type static properties/methods (e.g. `int.TryParse()`, `int.MaxValue`, `string.Empty`) 28 | - [ ] prefix increment/decrement (`++a`, `--a`) 29 | - [ ] transform primary constructors (e.g. `class Vector4(float x = 0, float y = 0, float z = 0, float w = 0)`) into 30 | regular class declarations with a constructor 31 | - [ ] member access or qualified names under the Roblox namespace should be unqualified (e.g. `Roblox.Enum.KeyCode.Z` -> 32 | `Enum.KeyCode.Z`) 33 | - [ ] emit comments? 34 | - [ ] method overloading 35 | - [ ] patterns 36 | - [ ] `is` 37 | - [x] `not` 38 | - [x] type 39 | - [ ] nested types 40 | - [ ] declaration 41 | - [x] relational 42 | - [ ] control flow 43 | - [x] if 44 | - [x] while 45 | - [x] for 46 | - [x] foreach 47 | - [x] do 48 | - [x] switch statements 49 | - [ ] switch expressions 50 | - [x] `return` 51 | - [x] `break` 52 | - [x] `continue` 53 | - [x] types 54 | - [x] map primitives to luau 55 | - [x] generics 56 | - [x] type hoisting 57 | - [x] namespaces 58 | - [x] enums 59 | - [ ] interfaces 60 | - [ ] partial classes/structs/interfaces 61 | - [ ] classes 62 | - [x] `new` 63 | - [x] constructors 64 | - [ ] destructors/finalizers? 65 | - [x] fields 66 | - [x] basic properties 67 | - [ ] property getters 68 | - [ ] property setters 69 | - [x] methods 70 | - [ ] constructor overloading 71 | - [ ] operator overloading 72 | - [ ] inheritance 73 | - [ ] reflection 74 | - [x] nameof 75 | - [ ] typeof (mostly done!) 76 | 77 | ## Will not be supported 78 | 79 | - Structs 80 | - `object.GetType()` (sorry, we can't access C# types during runtime!) 81 | - Any unsafe context (pointers, `unsafe` keyword, `stackalloc`, etc.) 82 | -------------------------------------------------------------------------------- /RobloxCS/RobloxCS.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Library 5 | net9.0 6 | RobloxCS 7 | enable 8 | enable 9 | 2.0.0-pre10 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /RobloxCS/Transformers/BaseTransformer.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | using Microsoft.CodeAnalysis.CSharp; 3 | using RobloxCS.Luau; 4 | using RobloxCS.Shared; 5 | 6 | namespace RobloxCS.Transformers; 7 | 8 | public abstract class BaseTransformer(FileCompilation file) : CSharpSyntaxRewriter 9 | { 10 | protected readonly FileCompilation _file = file; 11 | 12 | public SyntaxTree TransformTree() => _file.Tree = _file.Tree.WithRootAndOptions(Visit(_file.Tree.GetRoot()), _file.Tree.Options); 13 | protected static string? TryGetName(SyntaxNode node) => StandardUtility.GetNamesFromNode(node).FirstOrDefault(); 14 | protected static string GetName(SyntaxNode node) => StandardUtility.GetNamesFromNode(node).First(); 15 | protected static bool HasSyntax(SyntaxTokenList tokens, SyntaxKind syntax) => tokens.Any(token => token.IsKind(syntax)); 16 | } -------------------------------------------------------------------------------- /RobloxCS/Transformers/BuiltInTransformers.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | using RobloxCS.Luau; 3 | using RobloxCS.Shared; 4 | 5 | namespace RobloxCS.Transformers; 6 | 7 | using TransformMethod = Func; 8 | 9 | public static class BuiltInTransformers 10 | { 11 | public static TransformMethod Main() => file => new MainTransformer(file).TransformTree(); 12 | 13 | private static TransformMethod FailedToGetTransformer(string name) => throw Logger.Error($"No built-in transformer \"{name}\" exists (roblox-cs.yml)"); 14 | } -------------------------------------------------------------------------------- /RobloxCS/Transformers/MainTransformer.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis; 2 | using Microsoft.CodeAnalysis.CSharp; 3 | using Microsoft.CodeAnalysis.CSharp.Syntax; 4 | using RobloxCS.Luau; 5 | using RobloxCS.Shared; 6 | 7 | namespace RobloxCS.Transformers; 8 | 9 | public sealed class MainTransformer(FileCompilation file) 10 | : BaseTransformer(file) 11 | { 12 | // Add some implicit usings to the file 13 | public override SyntaxNode? VisitCompilationUnit(CompilationUnitSyntax node) 14 | { 15 | var usings = node.Usings; 16 | usings = usings 17 | .Add(SyntaxFactory.UsingDirective(SyntaxFactory.QualifiedName(SyntaxFactory.QualifiedName(SyntaxFactory.IdentifierName("System"), 18 | SyntaxFactory.IdentifierName("Collections")), 19 | SyntaxFactory.IdentifierName("Generic")))) 20 | .Add(SyntaxFactory.UsingDirective(SyntaxFactory.QualifiedName(SyntaxFactory.IdentifierName("System"), 21 | SyntaxFactory.IdentifierName("Collections")))) 22 | .Add(SyntaxFactory.UsingDirective(SyntaxFactory.QualifiedName(SyntaxFactory.IdentifierName("System"), 23 | SyntaxFactory.IdentifierName("Linq")))) 24 | .Add(SyntaxFactory.UsingDirective(SyntaxFactory.IdentifierName("Roblox"))) 25 | .Add(SyntaxFactory.UsingDirective(SyntaxFactory.Token(SyntaxKind.StaticKeyword), 26 | null, 27 | SyntaxFactory.QualifiedName(SyntaxFactory.IdentifierName("Roblox"), 28 | SyntaxFactory.IdentifierName("Globals")))); 29 | 30 | return base.VisitCompilationUnit(node.WithUsings(usings)); 31 | } 32 | 33 | public override SyntaxNode? VisitIdentifierName(IdentifierNameSyntax node) 34 | { 35 | if (node.Identifier.Text is not ("Enum" or "Buffer")) 36 | return base.VisitIdentifierName(node); 37 | 38 | var parent = node.Parent; 39 | if (parent is QualifiedNameSyntax { Left: IdentifierNameSyntax { Identifier.Text: "Roblox" } }) return node; 40 | 41 | // replace with a qualified name: Roblox.Enum or Roblox.Buffer 42 | var qualifiedName = SyntaxFactory.QualifiedName(SyntaxFactory.IdentifierName("Roblox"), 43 | SyntaxFactory.IdentifierName(node.Identifier)); 44 | 45 | return VisitQualifiedName(qualifiedName.WithTriviaFrom(node)); 46 | } 47 | 48 | // Turn file-scoped namespaces into regular namespaces (to reduce code duplication) 49 | public override SyntaxNode? VisitFileScopedNamespaceDeclaration(FileScopedNamespaceDeclarationSyntax node) => 50 | VisitNamespaceDeclaration(SyntaxFactory.NamespaceDeclaration(node.AttributeLists, 51 | node.Modifiers, 52 | node.Name, 53 | node.Externs, 54 | node.Usings, 55 | node.Members)); 56 | 57 | public override SyntaxNode? VisitNamespaceDeclaration(NamespaceDeclarationSyntax node) 58 | { 59 | if (node.Name is not QualifiedNameSyntax qualifiedName) return base.VisitNamespaceDeclaration(node); 60 | 61 | var pieces = StandardUtility.GetNamesFromNode(qualifiedName); 62 | var firstName = pieces.First(); 63 | var newFullName = StandardUtility.GetNameNode(pieces.Skip(1).ToList()); 64 | var childNamespace = node.WithName(newFullName); 65 | 66 | node = node 67 | .WithName(SyntaxFactory.IdentifierName(firstName)) 68 | .WithExterns([]) 69 | .WithUsings([]) 70 | .WithMembers([childNamespace]); 71 | 72 | return base.VisitNamespaceDeclaration(node); 73 | } 74 | 75 | public override SyntaxNode? VisitClassDeclaration(ClassDeclarationSyntax node) 76 | { 77 | if (node.ParameterList == null) 78 | return base.VisitClassDeclaration(node); 79 | 80 | var parameterList = node.ParameterList; 81 | var parameterNames = parameterList.Parameters.Select(p => p.Identifier.Text).ToHashSet(); 82 | 83 | // Fix properties: remove initializers that use primary parameters 84 | var newMembers = new List(); 85 | 86 | foreach (var member in node.Members) 87 | if (member is PropertyDeclarationSyntax property) 88 | { 89 | if (property.Initializer != null 90 | && property.Initializer.Value is IdentifierNameSyntax identifier 91 | && parameterNames.Contains(identifier.Identifier.Text)) 92 | 93 | // Remove initializer (because parameter won't be in scope anymore) 94 | property = property.WithInitializer(null) 95 | .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)); 96 | 97 | newMembers.Add(property); 98 | } 99 | else 100 | { 101 | newMembers.Add(member); 102 | } 103 | 104 | // Create assignments inside the constructor 105 | var assignments = new List(); 106 | foreach (var param in parameterList.Parameters) 107 | { 108 | var paramName = param.Identifier.Text; 109 | var matchingProperty = node.Members 110 | .OfType() 111 | .FirstOrDefault(p => string.Equals(p.Identifier.Text, 112 | StandardUtility.Capitalize(paramName), 113 | StringComparison.OrdinalIgnoreCase)); 114 | 115 | if (matchingProperty == null) continue; 116 | 117 | var assignment = SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, 118 | SyntaxFactory.MemberAccessExpression(SyntaxKind 119 | .SimpleMemberAccessExpression, 120 | SyntaxFactory 121 | .ThisExpression(), 122 | SyntaxFactory 123 | .IdentifierName(matchingProperty 124 | .Identifier)), 125 | SyntaxFactory.IdentifierName(paramName))); 126 | 127 | assignments.Add(assignment); 128 | } 129 | 130 | var constructor = SyntaxFactory.ConstructorDeclaration(node.Identifier) 131 | .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) 132 | .WithParameterList(parameterList) 133 | .WithBody(SyntaxFactory.Block(assignments)); 134 | 135 | var newNode = node 136 | .WithParameterList(null) 137 | .WithMembers(SyntaxFactory.List(newMembers.Append(constructor))); 138 | 139 | return base.VisitClassDeclaration(newNode); 140 | } 141 | 142 | // Return an IsPatternExpression if the binary operator is `is` 143 | public override SyntaxNode? VisitBinaryExpression(BinaryExpressionSyntax node) 144 | { 145 | if (node.OperatorToken.Text != "is") 146 | return base.VisitBinaryExpression(node); 147 | 148 | var pattern = SyntaxFactory.TypePattern(SyntaxFactory.ParseTypeName(((IdentifierNameSyntax)node.Right).Identifier.Text)); 149 | 150 | return SyntaxFactory.IsPatternExpression(node.Left, pattern); 151 | } 152 | 153 | // Fix conditional accesses so that they return the AST you expect them to 154 | public override SyntaxNode? VisitConditionalAccessExpression(ConditionalAccessExpressionSyntax node) 155 | { 156 | var whenNotNull = ProcessWhenNotNull(node.Expression, node.WhenNotNull); 157 | var newNode = whenNotNull != null ? node.WithWhenNotNull(whenNotNull) : node; 158 | 159 | return base.VisitConditionalAccessExpression(newNode); 160 | } 161 | 162 | public override SyntaxNode? VisitForEachStatement(ForEachStatementSyntax node) => 163 | base.VisitForEachStatement(SyntaxFactory.ForEachStatement(node.ForEachKeyword, 164 | node.OpenParenToken, 165 | node.Type, 166 | node.Identifier, 167 | node.InKeyword, 168 | node.Expression, 169 | node.CloseParenToken, 170 | Blockify(node.Statement))); 171 | 172 | public override SyntaxNode? VisitForStatement(ForStatementSyntax node) => 173 | base.VisitForStatement(SyntaxFactory.ForStatement(node.Declaration, 174 | node.Initializers, 175 | node.Condition, 176 | node.Incrementors, 177 | Blockify(node.Statement))); 178 | 179 | public override SyntaxNode? VisitWhileStatement(WhileStatementSyntax node) => 180 | base.VisitWhileStatement(SyntaxFactory.WhileStatement(node.WhileKeyword, 181 | node.OpenParenToken, 182 | node.Condition, 183 | node.CloseParenToken, 184 | Blockify(node.Statement))); 185 | 186 | public override SyntaxNode? VisitIfStatement(IfStatementSyntax node) => 187 | base.VisitIfStatement(SyntaxFactory.IfStatement(node.Condition, Blockify(node.Statement), node.Else)); 188 | 189 | public override SyntaxNode? VisitElseClause(ElseClauseSyntax node) => base.VisitElseClause(SyntaxFactory.ElseClause(node.ElseKeyword, Blockify(node.Statement))); 190 | 191 | private static StatementSyntax Blockify(StatementSyntax statement) => 192 | statement is BlockSyntax 193 | ? statement 194 | : SyntaxFactory.Block(SyntaxList.Create([statement])); 195 | 196 | private ExpressionSyntax? ProcessWhenNotNull(ExpressionSyntax expression, ExpressionSyntax? whenNotNull) 197 | { 198 | if (whenNotNull == null) return null; 199 | 200 | return whenNotNull switch 201 | { 202 | MemberAccessExpressionSyntax memberAccess => 203 | SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, expression, memberAccess.Name), 204 | 205 | MemberBindingExpressionSyntax memberBinding => 206 | SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, expression, memberBinding.Name), 207 | 208 | ConditionalAccessExpressionSyntax conditionalAccess => conditionalAccess 209 | .WithExpression(ProcessWhenNotNull(expression, 210 | conditionalAccess 211 | .Expression) 212 | ?? conditionalAccess.Expression) 213 | .WithWhenNotNull(ProcessWhenNotNull(expression, 214 | conditionalAccess 215 | .WhenNotNull) 216 | ?? conditionalAccess.WhenNotNull), 217 | 218 | // dumb nested switch 219 | InvocationExpressionSyntax invocation => invocation.WithExpression((invocation.Expression switch 220 | { 221 | MemberAccessExpressionSyntax memberAccess => 222 | SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, expression, memberAccess.Name), 223 | 224 | MemberBindingExpressionSyntax memberBinding => 225 | SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, expression, memberBinding.Name), 226 | 227 | ConditionalAccessExpressionSyntax nestedConditional => ProcessWhenNotNull(nestedConditional.WhenNotNull, 228 | expression), 229 | 230 | _ => SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, 231 | expression, 232 | (Visit(invocation.Expression) as SimpleNameSyntax)!) 233 | })!), 234 | 235 | _ => null 236 | }; 237 | } 238 | } -------------------------------------------------------------------------------- /RobloxCS/Transpiler.cs: -------------------------------------------------------------------------------- 1 | using RobloxCS.Shared; 2 | using Path = System.IO.Path; 3 | 4 | namespace RobloxCS; 5 | 6 | /// This class contains everything needed to transpile C# to Luau. 7 | public static class Transpiler 8 | { 9 | private static readonly HashSet _ignoredDiagnostics = []; 10 | 11 | public static void Transpile(string directoryPath, ConfigData config, bool verbose) 12 | { 13 | var rojoProject = RojoReader.ReadFromDirectory(directoryPath, config.RojoProjectName); 14 | if (rojoProject == null) 15 | throw Logger.Error("Rojo project name 'UNIT_TESTING' is reserved for internal use."); 16 | 17 | var sourceDirectory = Path.Join(directoryPath, config.SourceFolder); 18 | var outputDirectory = Path.Join(directoryPath, config.OutputFolder); 19 | var sourceFilePaths = Directory.GetFiles(sourceDirectory, 20 | "*.cs", 21 | SearchOption.AllDirectories); 22 | 23 | // this is prettyyy ass 24 | foreach (var (sourcePath, output) in TranspileSources(sourceFilePaths, rojoProject, config)) 25 | { 26 | var outputPath = sourcePath.Replace(".cs", ".luau").Replace(sourceDirectory, outputDirectory); 27 | if (verbose) 28 | Logger.Info($"Transpiling '{Path.GetRelativePath(directoryPath, sourcePath)}' into '{Path.GetRelativePath(directoryPath, outputPath)}'..."); 29 | 30 | var directory = Path.GetDirectoryName(outputPath); 31 | if (!string.IsNullOrEmpty(directory)) 32 | Directory.CreateDirectory(directory); 33 | 34 | File.WriteAllText(outputPath, output); 35 | } 36 | } 37 | 38 | public static List<(string Path, string Output)> TranspileSources(IEnumerable sourceFilePaths, RojoProject rojoProject, ConfigData config) 39 | { 40 | try 41 | { 42 | var files = sourceFilePaths 43 | .Select(path => (Path: path, Compilation: TranspilerUtility.ParseAndTransformTree(File.ReadAllText(path), rojoProject, config, path))) 44 | .ToList(); 45 | 46 | var trees = files.ConvertAll(file => file.Compilation.Tree); 47 | var compiler = TranspilerUtility.GetCompiler(trees, config); 48 | var diagnostics = compiler.GetDiagnostics() 49 | .Where(diagnostic => !_ignoredDiagnostics.Contains(diagnostic.Id)); 50 | 51 | foreach (var diagnostic in diagnostics) 52 | Logger.HandleDiagnostic(diagnostic); 53 | 54 | return files.ConvertAll(file => (file.Path, TranspilerUtility.GenerateLuau(file.Compilation, compiler))); 55 | } 56 | catch (CleanExitException) 57 | { 58 | return []; 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /RobloxCS/TranspilerUtility.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Xml.Linq; 3 | using Microsoft.CodeAnalysis; 4 | using Microsoft.CodeAnalysis.CSharp; 5 | using Microsoft.CodeAnalysis.CSharp.Syntax; 6 | using RobloxCS.Luau; 7 | using RobloxCS.Shared; 8 | using RobloxCS.Transformers; 9 | using Path = System.IO.Path; 10 | 11 | namespace RobloxCS; 12 | 13 | using TransformMethod = Func; 14 | 15 | public static class TranspilerUtility 16 | { 17 | public static string GenerateLuau(FileCompilation file, CSharpCompilation compiler) 18 | { 19 | var luauAST = GetLuauAST(file, compiler); 20 | var luau = new LuauWriter(); 21 | 22 | return luau.Render(luauAST); 23 | } 24 | 25 | public static AST GetLuauAST(FileCompilation file, CSharpCompilation compiler) 26 | { 27 | var analyzer = new Analyzer(file, compiler); 28 | var analysisResult = analyzer.Analyze(file.Tree.GetRoot()); 29 | var generator = new LuauGenerator(file, compiler, analysisResult); 30 | 31 | return generator.GetLuauAST(); 32 | } 33 | 34 | public static CSharpCompilation GetCompiler(IEnumerable trees, ConfigData config) 35 | { 36 | var compilationOptions = new CSharpCompilationOptions(OutputKind.ConsoleApplication); 37 | 38 | return CSharpCompilation.Create("RobloxGame", // probably temporary until i set up msbuild (hell) 39 | trees, 40 | FileUtility.GetCompilationReferences(), 41 | compilationOptions); 42 | } 43 | 44 | // didn't change `source` to `path` cuz this is used in tests. this needs a big refactor 45 | public static FileCompilation ParseAndTransformTree(string source, RojoProject? rojoProject, ConfigData config, string path = "TestFile.cs") 46 | { 47 | var tree = ParseTree(source, path); 48 | var file = GetFileCompilation(tree, rojoProject, config); 49 | HashSet transformers = [BuiltInTransformers.Main()]; 50 | 51 | TransformTree(file, transformers); 52 | return file; 53 | } 54 | 55 | private static FileCompilation GetFileCompilation(SyntaxTree tree, RojoProject? rojoProject, ConfigData config) => 56 | new() { Tree = tree, RojoProject = rojoProject, Config = config }; 57 | 58 | private static SyntaxTree TransformTree(FileCompilation file, HashSet transformMethods) => 59 | // config ??= ConfigReader.UnitTestingConfig; 60 | transformMethods.Aggregate(file.Tree, (_, transform) => transform(file)); 61 | 62 | private static SyntaxTree ParseTree(string source, string sourceFile) 63 | { 64 | var cleanTree = CSharpSyntaxTree.ParseText(source); 65 | var compilationUnit = (CompilationUnitSyntax)cleanTree.GetRoot(); 66 | var usingDirective = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("System")); 67 | var newRoot = compilationUnit.AddUsings(usingDirective); 68 | 69 | return cleanTree 70 | .WithRootAndOptions(newRoot, cleanTree.Options) 71 | .WithFilePath(sourceFile); 72 | } 73 | } -------------------------------------------------------------------------------- /nuget.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /roblox-cs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roblox-csharp/roblox-cs/be97691d1610450becdac6efae3fce3e5adb98f6/roblox-cs.png --------------------------------------------------------------------------------