Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support ECMAScript modules (export/import statements, modules definition) #1054

Merged
merged 32 commits into from Mar 3, 2022

Conversation

christianrondeau
Copy link
Contributor

@christianrondeau christianrondeau commented Jan 17, 2022

This attempts to implement ECMAScript modules, to resolve #636. This is a draft and will definitely change, but as of now, I think the basic "export named" flow is mostly correct.

What I want to implement:

  • Full export specification
    • Export named
    • Export default
    • Export all
  • Create modules from C# (engine.DefineModule)
  • Import modules from C# (engine.ImportModule)
  • Create modules from a C# class
    • Create modules that import multiple C# classes in a single module, e.g. import { Class1, Class2 } from 'my-module';
  • Partial import specification (exclude promise import and focus on C#-defined modules)
  • File-based module loading (from @lahma branch)

I do not intend on implementing module file resolution (only modules specified in C#) for this PR, but I feel like there may not be much left to do once this works.

Note that I did (and will) use code from @lahma #1051 - the main difference is that I started with the assumption that I would only implement export, however, to make sure my implementation is correct, I need to implement at least one simple import statement.

@christianrondeau christianrondeau changed the title Export named declaration (#636) Support ECMAScript modules (export/import statements, modules definition) Jan 17, 2022
@sebastienros
Copy link
Owner

Was wondering why Christian's name was so familiar. It's because he's been active on Jint since 2015, way before Marko.

@christianrondeau
Copy link
Contributor Author

@sebastienros yeah it's been a while, I was glad to see that Jint was still alive and well!

So, I went much further than I initially planned to, but I felt that without all those pieces interacting together, I wouldn't be able to trust that my approach made sense (and indeed, by adding pieces I realized a few times I approached it wrong!)

There are a lot of things in this PR, so while I think a good review would be important, I'll try and raise some specific uncertainties I had myself so we can get them out of the way. I'll use the GitHub code comments to do so, I'll write a message when I reviewed it all myself at least once. After those are resolved, we can proceed with an actual review.

I don't think I completely mapped to the ECMAScript specifications, but to be honest I have trouble fully understanding it. I'll try and review it the best I can and raise questions if I'm not sure.

There are also a few things that are Jint-specific:

  1. The ability to create a module in C#, e.g. engine.DefineModule("export const x = 1;", "my-module")
  2. The ability to create a module from values directly, e.g. engine.DefineModule<MyClass>() or `engine.DefineModule("exportName", JsString.Create("Hello"), "my-module");
  3. The ability to import a module from C#, e.g. var ns = engine.ImportModule("specified") which allows reading the exports in C#
  4. I added a new DefaultModuleResolver, which is responsible for resolving paths and/or bare specifiers. This is a small breaking changes since now the DefaultModuleLoader is not responsible for doing the resolution, and receives a ResolvedSpecifier. This was necessary in order to have modules ./my-module and ../folder/my-module reference the same module instance.

I hope this PR will see the light of day and that I didn't do anything dumb :)

@christianrondeau
Copy link
Contributor Author

The tests fails because System.IO.DirectoryNotFoundException : Could not find a part of the path 'C:\Users\runneradmin\AppData\Local\Temp\2a69ed27-1218-4ccb-b5d7-45e8321e1e0a\2a69ed27-1218-4ccb-b5d7-45e8321e1e0a\assembly\Runtime\Scripts\modules\format-name.js'. - it seems the way I find the scripts folder is incorrect, I'll get back to this too later. I'm not sure why the Linux tests fail though.

@lahma lahma marked this pull request as ready for review January 19, 2022 18:09
@lahma lahma marked this pull request as draft January 19, 2022 18:10
@christianrondeau
Copy link
Contributor Author

I'm done with my first review; I added comments and questions on things that I'm not sure about. It's a pretty big PR for someone with limited knowledge of ASTs, language specifications etc but I feel like the vast majority of the code required was already written. I'm looking forward to hearing your thoughts on this!

Copy link
Collaborator

@lahma lahma left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good! As you can see most of my comments were stylistic nit-picking. The import/export API via c# engine still doesn't seem intuitive to me, but I'm just one person. You really went the extra mile with this.

I don't have good guidance whether this is spec-wise "perfect", when I read the relevant parts it seemed that there were some places where "magic just happens", so I'm happy we have an interpretation of behavior that passes tests!

@@ -0,0 +1,42 @@
using Xunit;

namespace Jint.Tests.Runtime
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can use file-scoped namespaces here, we are moving towards them

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this also belongs to Module folder that the next test file has?

[Fact]
public void CanExportNamed()
{
_engine.DefineModule(@"export const value = 'exported value';", "my-module");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DefineModule as a name is a bit different from the Execute we have for scripts, this is the first Define* as we know have Set. The method pretty much says what it's going to do though.

Here I'm just thinkin out loud how we should formulate final engine API for 3.x...


namespace Jint.Tests.Runtime
{
public class ModuleTests
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And why we need to manually test these and Test262 suite isn't helping. I did investigate a bit and seems that some module related tests light up only when async/await is implemented. So it's great that you created such extensive test cases!

[Fact]
public void ShouldDefineModuleFromJsValue()
{
_engine.DefineModule("value", JsString.Create("hello world"), "my-module");
Copy link
Collaborator

@lahma lahma Jan 20, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This API doesn't seem intuitive to me. Single value cannot be a module so here's something else going on here...

// export maybe part of the name?
engine.AddModuleExport("value", JsString.Create("hello world"), "my-module")
engine.Modules.AddExport("value", JsString.Create("hello world"), "my-module")
// maybe goes too far..
engine.Advanced.Modules.AddExport("value", JsString.Create("hello world"), "my-module")

Just throwing ideas here. The overload taking just object might bee too much also. Without it one can write:

_engine.DefineModule("value", "hello world", "my-module");
_engine.DefineModule("value", 123, "my-module");

If such functionality is needed I think it would be better to force API user to do the call JsValue.FromObject()?

These are advanced use cases I believe allowing cool stuff but not entirely necessary to be able to run stock ES6 code.

[Fact]
public void ShouldDefineModuleFromClrType()
{
_engine.DefineModule<ImportedClass>("imported-module");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem intuitive either. Module can export a class for sure, but it's not a module.

I see that the code ends into export path and this is somehow an imported module 🤷🏻‍♂️ So is the API for registering Import or an Export...

Jint/Runtime/Modules/ModuleResolutionException.cs Outdated Show resolved Hide resolved
@@ -2,6 +2,7 @@
using Esprima.Ast;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#nullable enable?

@@ -0,0 +1,11 @@
using System;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#nullable enable?

@@ -0,0 +1,28 @@
using System;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#nullable enable?

Jint/Engine.Modules.cs Show resolved Hide resolved
Copy link
Owner

@sebastienros sebastienros left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of my comments my collide with @lahma 's around naming.

public void ShouldExportNamed()
{
_engine.DefineModule(@"export const value = 'exported value';", "my-module");
var ns = _engine.ImportModule("my-module");
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does ImportModule actually import a module in the environment, or does it just return it to the caller, and doing an Execute('return value') would not work.

Jint.Tests/Runtime/ModuleTests.cs Outdated Show resolved Hide resolved
Jint.Tests/Runtime/ModuleTests.cs Outdated Show resolved Hide resolved
Jint/Engine.Modules.cs Outdated Show resolved Hide resolved
@@ -138,7 +138,7 @@ ObjectInstance IConstructor.Construct(JsValue[] arguments, JsValue newTarget)
{
try
{
var result = OrdinaryCallEvaluateBody(_engine._activeEvaluationContext, arguments, calleeContext);
var result = OrdinaryCallEvaluateBody(_engine._activeEvaluationContext ?? new EvaluationContext(_engine), arguments, calleeContext);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, this needs to be found. Please share the stacktrace when this happens.

Jint/Native/Object/ObjectInstance.cs Show resolved Hide resolved
engine.DefineModule("import { User } from './modules/user.js'; export const user = new User('John', 'Doe');", "my-module");
var ns = engine.ImportModule("my-module");

Assert.Equal("John Doe", ns["user"].AsObject()["name"].AsString());
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't it sufficient to let users access the module properties from the engine since the module was loaded? engine.GetValue("user") just to reduce the amounts of ways to do something, which could lead to inconsistencies, or discrepancies in behavior. So ImportModule would return nothing. (or Engine)

@christianrondeau
Copy link
Contributor Author

christianrondeau commented Jan 20, 2022

I will now add the ModuleBuilder idea that came up a few times in the reviews, I think it will slightly reduce the impact on Engine (remove DefineModule variants) and refactor the tests accordingly. The idea is to do something like this:

var module = new ModuleBuilder();
module.ExportClass<T>("name");
module.ExportValue("name", JsValue);
engine.LoadModule(module, "module-name");

If you disagree it's now or never :D In the future, that should also allow us to do things like parsing JavaScript expressions, create multiple bindings, etc.:

module.CreateImmutableBinding("varName", JsValue);
module.AddExpression("const test = varName + 1;");
module.AddExpression("import { stuff } from 'other-module';);
module.ExportBinding("test", "exportName1");

@christianrondeau
Copy link
Contributor Author

christianrondeau commented Jan 21, 2022

All right I think I have something I'm happy with that solves some of the comments. Here's how you can define modules now:

Method 1, using source code. Nothing new here, except the rename:

engine.AddModule("module-name", "export const x = 1;");

Method 2, using the builder. This allows you to build a module with code, objects, types, etc.

engine.AddModule("module-name", builder => builder
  .AddSource("export const value1 = 'hello';")
  .ExportType<MyType>()
  .ExportValue("value2", "world")
  .ExportObject("value3", new MyType())
  .ExportFunction("fn", args => Console.WriteLine(args.Length))
);

This is, I think, fairly discoverable (there's only AddModule there, and only two overloads for it), it allows building modules from multiple pieces of data, it can be extended later, and doesn't need to interact with engine twice.

Also, bonus, it really lazy loads modules now, so even custom-built modules won't be linked until they are loaded, making it much better if you wanted to offer a large set of modules to optionally import.

I feel like actually supporting node_modules wouldn't be a stretch, but I'm already way out of my initial need so shush me :D

Once a module has been added, you can use engine.ImportModule("module-name") to "run" the program. Note that there could be an argument for RunModule or ExecuteModule, but the reason I still stand behind Import is because you're actually acting like the parent module in the C# code. But I can live with ExecuteModule, as long as it still returns the module namespace.

In other words, I see two ways to use it:

  1. Like Node.js, e.g. node file.js where you run whatever's in file.js and don't really deal with "returned values" (considering stdout isn't really "returning"). This is where engine.ExecuteModule() makes the most sense.
  2. As the parent script, e.g. var userDefinedLogic = engine.ImportModule() where you want to invoke the user's code, and/or read output values, and need output.

Since ImportModule actually executes the module, I don't think we need both but we could have ExecuteModule() call ImportModule() and drop the result if you want.

@christianrondeau
Copy link
Contributor Author

christianrondeau commented Jan 24, 2022

Two quick questions.

  1. Do you want me to rebase or should we wait for all reviews/feedbacks to be completed? I just did it
  2. I was thinking of @sebastienros feedback on wanting to use "Execute" to run modules. While I still stand by my opinion that "modules should be separated from scripts", I thought of a few improvements to AddModule that I'd like to share. They won't change any of the existing code so it shouldn't invalidate any of the remaining reviews.

Right now to "execute" a module you need to "import" it in the C# context:

engine.AddModule("module-name", "export const x = 1;");
var ns = engine.ImportModule("module-name");
Console.WriteLine(ns.Get("x").AsString());

This is the equivalent of:

echo "export const x = 1;" > module-name.js
echo "import { x } from 'module-name.js'; console.log(x);" > run.js
node run.js

This is why it's confusing, we're actually creating AND running modules from the same context. However to create and run modules will probably be a pretty common pattern. So to simplify it, I had two new "sugar" APIs in mind.

  1. Fluent API to "Add and Import"

This keeps the same proposed semantics.

var ns = engine.AddModule("module-name", "export const x = 1;").ImportModule();
Console.WriteLine(ns.Get("x").AsString());
  1. Sugar API to "AddAndImport":

This adds a new word, "Execute" which here means "Add And Import". It also hides the module name, making it more in line with what you expect from Execute. However it does not add any of the lexical context into the "normal" Execute" method. The module name would be automatically generated (e.g. a GUID or something like "dynamic")

var ns = engine.ExecuteModule("export const x = 1;");
Console.WriteLine(ns.Get("x").AsString());

Let me know what you think :)

Addendum: engine.ExecuteModule(script) has an issue IMHO, since I'd expect to be able to do engine.ExecuteModule(moduleName), both have strings as their inputs and it's impossible to know which of the two will happen unless you look at the docs or try. Instead, we could support engine.ExecuteModule(builder => builder.AddSource("...")) and engine.ExecuteModule(moduleName). That would be more in line with the eventual node_modules support. Another thought is for the equivalent of npm run (something) that will eventually end up in the API, potentially causing more confusion around Run v.s. Execute. With that thought process, Execute in the context of modules would simply not exist, and only Run (shell scripts using node as the hashbang) or Import (reference a module and do stuff with it) would remain.
Long story short, while it looks good at first glance, I'm not sure ExecuteModule is a great choice. The "Fluent API" would be cheap to add if you like it though.

@christianrondeau
Copy link
Contributor Author

I may have greatly reduced availability soon; while I won't push for a merge (I'm completely aware of what maintaining an open-source project means) I'd like to know if you believe this will eventually get merged so that I can start building on top of it. If you're not sure yet, fair enough :) If it's just a matter of taking the time to fully review but you feel that the approach is sound, I'll temporarily work on this branch for my downstream projects.

Thanks :)

}

protected virtual string LoadModuleSourceCode(Uri location)
protected virtual string ReadAllText(Uri uri)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not reusable anymore in a sub-class. The fact that the checks are done outside will prevent custom implementation from loading urls.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll need this one fixed before merging. I will do it otherwise.

@sebastienros
Copy link
Owner

I added only one comment, but would like to see @lahma 's answered too.
When this is done I will merge it.

@lahma
Copy link
Collaborator

lahma commented Feb 12, 2022

@christianrondeau can we help out here somehow? would be nice to get this finalized and merged 🚀

@christianrondeau
Copy link
Contributor Author

christianrondeau commented Feb 12, 2022

@lahma as far as I know, all of your and @sebastienros comments have been solved or I had a follow-up question (see https://github.com/sebastienros/jint/pull/1054/files) but on my end, I'm comfortable with merging in its current state. I just rebased, all tests were green :) If I missed one question let me know, I'm also eager to see this merged! Also I had some proposals that were unanswered but those can always be improvements in future PRs, since I believe it'll either be naming changes or sugar, but the general idea should be stable.

@lahma
Copy link
Collaborator

lahma commented Feb 15, 2022

I checked the PR and there are some comments, can you mark resolved the ones are non-actionable etc?

@christianrondeau
Copy link
Contributor Author

@lahma I'm not allowed to mark comments as resolved :) I did go through all comments last time, most of them are still unanswered. I added a comment on as many as I could to mark the ones I think are not actionable, and deleted my own unanswered questions to reduce the scope. I don't think I can do anything more at this point (but I'm willing to!).

@lahma
Copy link
Collaborator

lahma commented Feb 16, 2022

I hope I'm not the broken phone here, current state is that it was mostly nit-picking and we can always fix/break things having the beta label. Pinging @sebastienros on the matter.

@christianrondeau
Copy link
Contributor Author

christianrondeau commented Feb 16, 2022

Haha by default I always take for granted that I'm the one missing something until proven wrong (or right) ;)

Here's what I am looking at:

But I reiterate I want to make things as simple as I can for you, if you see what I'm missing I'll do my best to fix it.

@christianrondeau
Copy link
Contributor Author

(in case it wasn't clear, the list I posted shows everything that I can see but I have no todos on my end as far as I can tell, I'm on hold)

@sebastienros sebastienros enabled auto-merge (squash) March 3, 2022 16:50
@sebastienros sebastienros merged commit fcc7c8d into sebastienros:main Mar 3, 2022
@lahma
Copy link
Collaborator

lahma commented Mar 3, 2022

@christianrondeau awesome work! thank you for pushing this through and being patient with us!

@christianrondeau
Copy link
Contributor Author

Woot! Thanks @lahma and @sebastienros ! Since I hope to contribute again, do you have any feedback on this PR? I know it was too large (I didn't find a way to reliably test the import without export and vice versa) but otherwise, is there something I could have done to make it easier for you guys? I feel like throwing tons of questions directly in the file changes might not have been the best approach :)

@christianrondeau christianrondeau deleted the export branch March 3, 2022 23:48
@lahma
Copy link
Collaborator

lahma commented Mar 4, 2022

I think the process was mostly long due to the feature being both large and had no specific spec to rely on at times (implementation specific). Hard to predict all the use cases and how people would like to interact with it "outside of JS".

Feedback loop is always a bit of a problem when people try to find some free time (which is taken away from something else) and are also on different time zones 🙂

@lahma
Copy link
Collaborator

lahma commented Mar 4, 2022

I'll try to do a quick round with more seamless test suite integration and allowing people to run scripts with imports without jumping many hoops. I'll create a separate PR for feedback.

@christianrondeau
Copy link
Contributor Author

@lahma I didn't mean that it wasn't fast enough, no worries!!! I just wanted to make sure my next PR is painless for you guys :) Thanks for helping me through!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support Esprima.Ast.ExportNamedDeclartion
3 participants