CHAPTER 16

Script Inclusion

Technically, the module system is sufficient to allow code organization at any scale. However, when it comes to a scripted language, convenience is an equally critical factor. Admittedly, putting a helper method in a static class to be placed in a file declared as module X and thrown under a module path, even the default module path, is a rather tedious piece of work. When putting together a medium-sized project that, say, handles the automation of a small part of the work flow, without compromising a good programmer's intrinsic predisposition for code sharing, we do want something faster.

To compensate for this, Julian allows script inclusion, which bears some superficial resemblance to the include statement in C and C++ languages, but is fundamentally a different mechanism that has its unique features as well as restrictions.

Include another script

To include one or more scripts, one simply add, perhaps in their script main.jul, a few include statements:

import ...;
include "path/to/my/sript.jul";
include "../another/path/to/my/other/script.jul";
include ...;

... // (script code, such as type definitions and sequential logic)

Through the rest of this chapter, we will refer to the script where include is used, as the includer, and the script being included, as the includee. The exact include statement which appears in an includer to include the includee is referred to as the inclusion site. In the example above, main.jul is the includer, where path/to/my/sript.jul and ../another/path/to/my/other/script.jul are the includees.

Both includer and includee must be a loose script, i.e. they cannot start with module statement. Attempting to include anything from a module script, or including a module script from a loose script, causes fatal error, terminating the engine.

The path can be absolute, or be relative to the includer.

The communication between includer and includee

By design, there are few means of communication between the two scripts, as this syntax is mainly intended for sharing function and type definitions. In particular, the includer can't see the return value, and their shared access of global values are strictly limited. If you want a more straightforward data exchange, you will have to use the more flexible evaluate() method, which will be discussed in the last section of this chapter.

Global variables

Global variables defined in includer, prior to the inclusion site, will be accessible by includee. However, since no regular code is allowed before include statement, such variables consist of only the default script arguments (arguments), and variables bound by script engine API.

Global variables defined in includee won't become accessible by the includer upon return.

// main.jul
//
include "a.jul";
Console.println(gol); // ERROR! Global variale gol is not defined.

// a.jul
//
// Refer to arguments, an automatically defined global variable.
string val = arguments[0];
// Define a global variable.
int gol = 5;

Functions and types

Functions and types defined in both includer and includee are sharing the same namespace (<default>), so they can refer to those defined previously by each other. They must also not redefine with same name.

In a deduction, when an includer includes multiple files, the files being included later could refer to types defined in a previous includee, even though these two files do not form an relationship by inclusion.

// main.jul
include "a.jul";
include "b.jul";

// a.jul
class A { ... }

// b.jul
void execute(A a) {
  ...
}

If b.jul is included in a way shown above, its definition of function execute() will be accepted since type A is resolvable at that point. This, however, is merely a side effect of the fact that included scripts share the same namespace. If you do intend to let b.jul to refer to type A, do include a.jul in b.jul as well. As we will see soon, inclusion is always cached, so you don't need to worry about re-inclusion.

Return value

Technically, an includee can return some value through return statement. But as of 0.1.34 there is no way to refer to this value from the includer.

Exceptions

Exceptions thrown by the includee will be propagated to the includer. The stacktrace left by such traversal of files has a distinct format. As an example,

System.DivByZeroException: Cannot divide by zero.
  on including  (/Users/Adrian/jse/proj1/math.jul, 3)
  from  (/Users/Adrian/jse/proj1/main.jul, 2)

Note the line started with on including, where usually you would expect a function call trace that starts with at and is followed by the function's signature.

Effect of inclusion

The script engine guarantees that inclusion for the same location happens once only, sparing any burden on the end of developers to guard against double include. This also has the implication that a cyclic inclusion is allowed, as any attempt at re-including the same file would be skipped.

// main.jul
include "a.jul";

// a.jul
include "b.jul";

// b.jul
include "c.jul";

// c.jul
include "a.jul"; // Allowed

However, when c.jul includes a.jul, none of the types defined in a.jul or b.jul have been admitted into the current runtime's type system, so c.jul would not be able to refer to these types in function signature or class dependencies. It still can use these types inside the body of methods or functions.

Inclusion effectively happens in the main thread at the very beginning of the user code. Once the main script finishes including other scripts, there won't be a second chance to include anything.

A more subtle issue arises from importing a module in the includee. In this case, the module will be discovered and scanned in the includee, and its info added into the current engine runtime. Upon return, however, the includer cannot refer to these types by the simple name, and must use fully qualified name instead. This is essentially due to the dual purposes of import statement: (1) adding a module into search path so that it can be loaded on demand, and (2) introducing a namespace. While the includee indeed loads the module into the current engine session, the includer still doesn't have namespace declared in its source file, so it can only get access to the new types by specifying the full type name.

Assume module X.Y has a type Item, then,

// main.jul
include "a.jul";

Item i = null; // ERROR! Item cannot be resolved.
X.Y.Item i2 = null; // OK. FQ name is provided.

// a.jul
import X.Y; // Loads the module into runtime.

Without including a.jul, main.jul won't be able to refer to X.Y.Item, even if the module X.Y is in one of its module paths.

Built-in scripts

Julian ships with a few scripts that can be used out of box. These scripts mainly define functions for reducing typing, or unifying operations against Object and primitive values. Most of these scripts can be provided externally, so this is mostly to avoid re-invention of wheels. For example, instead of the typing the wordy routine of Console.println(...), one may include print.jul, which defines a global function println() that provides the same function as the static method from Console class.

// main.jul
include "print.jul";
println("Hello World!");

Note the path to built-in files, which looks like a relative path. That's because they are indeed relative path. If you put a file with name print.jul alongside main.jul, then your script will be included instead of the built-in version. Depending on whether you also defined a function of same signature, the call to println() may or may not fail, and may very well do something other than printing out the intended text. This is by design: when using double quotes to refer to a path, the script engine will try to resolve the path locally first. Only when that fails will it fall back to the built-in script. This is an imitation of C/C++'s include syntax, where an alternative pair of quotes, angle brackets (< and >), are allowed to search system header paths first. JSE has not implemented this alternative yet, as of 0.1.34.

For a full list of the scripts, refer to the API page. It's worth to mention though that if you include all.jul you will get all of the scripts and the functions defined within. There are not a lot of them anyway.

Evaluate scripts dynamically

At the end of this chapter let's have a brief tour on a very powerful feature of JSE that we in fact discourage you to use. The only reason we provided this feature, even though we want you to stay away from it, is to bring Julian to a functional parity to other established script languages such as JavaScript and Perl.

In Julian, programmers can dynamically evaluate arbitrary script, via evaluate(). The first parameter of this method is a configuration object with which you can tweak the surrounding factors of your call, such as:

  • ShareScopeIf true, the changes to global variables will remain in effect after the evaluation finishes, and any new global variables defined in the evaluated script will stay. This doesn't affect module loading and type definition. All scripts evaluated this way always share the same type system so once an evaluated script introduced new types the following one will always be able to use the without loading again.
  • UseDefaultRootBy default, a relative script path will be resolved against the current script's path. However, with this setting one can make it resolve against the default module directory instead. The default module directory refers to jse_modules directory residing alongside the entrance script.
  • ReturnExceptionBy default, if the evaluated script throws, its exception will be propagated back to the callsite. With this set to `true`, the exception will be returned as a regular value.

The second parameter is the path to the script, while the last one the arguments that will be used to populate arguments inside the script. The script will be interpreted in the current thread.

A few restrictions apply to this special API. First, it must be called outside any function or function-like definition. In other words, you cannot call it from a method or lambda. Doing so results in runtime error. Nonetheless, exception does exist - the two functions defined in script.jul, incl() and eval(), which are as well invoking this method, are exempted.

Second, it must be called in the main thread. Indeed, the first clause already disables calling it from any other threads, as threading/promise API provided by Julian always requires a callable construct.

Third, this function can only be called from the outermost lexical scope in the script, namely outside any enclosure by { and }. For examples:

Environment.evaluate(config, "script1.jul", null); // OK

for (int i = 0; i <= 5; i++) {
  Environment.evaluate(config, "script" + i + ".jul", null); // ERROR! It is being called from inside an enclosure.
}

{
  Environment.evaluate(config, "script1.jul", null); // ERROR! It is being called from inside an enclosure.
}

This restriction is intended to make the code more predictable. Recall the impact of the script evaluation - it can load new modules and types. It would be a nightmare for developers to maintain some code that will load a certain type only if an if branch is hit.

Unsurprisingly, this method doesn't cache anything. Calling it against the same script will cause repeated interpretation. Whether that will work depends on what you do in the evaluated script. For example, scripts which define types are doomed to fail if getting evaluated twice.

The script inclusion via include is actually built on top of this API, but with some special handling such as the result caching. We only recommend incorporating other scripts using include statement, as it's cleaner syntactically and easier to maintain. As the (in)famous JavaScript adage goes, eval is evil - think three times before you decide to call this method to interpret another script file.