Alongside imports, you commonly find function, class or constant/variable declarations.
Aside from imports, in Java the only one of those declarations allowed at file scope level is a class declaration.
This comes with the caveat that only one class can exist at file scope level per file.
In C# there are particularly restrictions on functions at file scope level, but no such restriction on the number of class declarations — which may also be wrapped in namespaces.
Adherence vs AbstractionConsider I’m writing my code in TypeScript, but I want to sempile it to Java.
I have two options:Write declarations at file scope level that may be illegal in Java, but have some compiler middleware transform my code to something legal (abstraction)Adhere to Java restrictions in my TypeScript code, and transpile it without the need for additional transformations to make the code legal (adherence)Choosing between these two distinct approaches depends on the use case.
You might choose adherence in the following scenarios:Single target.
If the code is only going to run on one target platform then adhering to those restrictions from the get go might be sensible (albeit, at the expense of future portability)Return on investment.
If there is extra effort involved in finding or writing the requisite transformers, then it may be cheaper to adhere to restrictions imposed by the target platformUnfeasible transformation.
There is no analog transformation I can perform on a particular piece of code to make it legal, because fundamentally it radically opposes the semantics of the target platformSimilarly you may opt for abstraction in the following scenarios:Multi target.
The code being written will run on multiple target platforms so you want to abstract away the differencesFeasible transformation.
There are one or more automatic transformations that can occur on the code in order to make it semantically legal in the context of the target platform.
Adherence StrategiesIn the case of adherence the strategy is simple.
Sempiler will automatically map diagnostic messages from the target compiler (eg.
javac) back to the problematic lines in your source text.
Adhering to the target semantics is simply a case of responding to these diagnostic messages as they pop up in your code, and changing your source code accordingly (until the target compiler is happy).
Abstraction StrategiesAbstraction strategies are far more interesting because they require transformations on the code.
Democratising compilation facilitates writing custom transformations and plugging them straight in with no mess.
DirectivesOne planned enhancement to Sempiler will allow for preprocessed directives.
In this context, such directives would toggle the inclusion of a particular code block (or the dynamic generation of it) based on a query.
Typically a query might be of the form:Is the target Java?And the associated code block would be omitted if the target emitter/consumer is not Java.
Or a different code block would be included, one that is legal with regard to Java semantics.
And these queries could stack like if/else statements.
Which is essential for something like a x-platform log function:Example syntax for x-platform logging function using directivesOnly the code inside matching queries would be included in the compiled artefact — and subsequently emitted/consumed by the target compiler (where the symbols exist in context).
Such directives might leverage the values in the applied Sempiler config (also known as the recipe) or the shape of the source code itself (the statements and expressions used by the author) — and thus you can extrapolate from that thought, and imagine supplying extra data in the config that can be used in these directives.
Moreover, they facilitate metaprogramming — namely code generation using primitives such as loops.
Transformations commonly involve some form of code generation, so hooking into this via directives might save on authoring or maintaining additional transformer middleware for a project.
The less code you have the maintain, the easier your life is usually!In addition, we can overload the directives feature to have a dual purpose:Expressing compile time instructions to control transformations (as described above)Allowing authors to express directives that will be ignored by the compiler middleware (passthrough) and emitted as directives in the target code (assuming the target code supports directives)So we end up with a feature that can be utilised by users, both in app code and plugin code.
Lastly, by far the most interesting application of directives I’ve seen of late is those proposed in JAI.
Specifically compile time execution of functions, and expressing build settings in the source code (without need for a separate config file).
This is definitely something I’d like to explore in due course.
InlinerA transformation I have been working on recently is Inliner.
The motivation here is to take the kind of code constructs you would naturally write in TypeScript, and transform them to something functionally equivalent but legal in a target context like Java.
Basically it takes all of your TypeScript files, and puts the statements at the file scope level inside nested classes inside one giant class declaration:The contents of src/User.
tsThe contents of src/Account.
tsThe example contents of an inlined file for the given sources (in no particular target language)This sounds ugly, but in Java and C# a class can contain class, function and constant/variable declarations.
This is a workaround to the aforementioned restrictions on file scope level statements inherent to those languages.
Also bear in mind that the author never has to gaze upon this collated, inlined output.
It is to satisfy the target compiler rules only, and any diagnostics will be mapped back to the original (prettier?!) source code automatically (which is where the author does their work).
The transformation also fully qualifies identifiers that were imported from other files, because now they exist in nested sibling classes in the same file.
This speaks to another type of transformation that will probably be required — namely, rewriting imports so they too are legal.
The complexity here is symbol resolution in arbitrarily nested scopes.
That sounds fancy, but simply means being able to find where the imported symbols are referenced in the source file, and rewriting them as fully qualified names in the inlined output.
Lastly the entrypoint to the application needs wiring up, such that the runtime reaches into the giant Everything class and executes the relevant part on startup.
For example, in our TypeScript this might have been the code in our index file (root file), that now has been inlined somewhere in the giant Everything class.
A transformation would have to expose and wire that up to the entrypoint of the application.
The nice thing about transformations like Inliner is that emitters do not need to care whether inlining took place or not.
They will be given an AST to emit as normal, and do not need to care how it was constructed — whether by the code author, or a sequence of transformations, or both.
Closing RemarksThe Sempiler project is still very much in it’s infancy, and I expect best practices to emerge from the experience of writing x-platform software with it over time.
The above examples are just a subset of ways to think about writing code that can run in myriad target contexts, whilst still benefitting from truly native output — the underlying goal of Sempiler.
Lastly, I will be sharing an interesting usage of the Inliner plugin in due course.
As you may have guessed, the motivation for writing it was not purely academic!.