Section 6: Modules

As mentioned in Section 1, a Keli program is actually a set of modules.

In fact, each module actually corresponds to one and only one Keli source file.

In the following section, the term importer means the file that is importing other files, while importee means the file that is being imported. In Keli, a source file can be both importer and importee and the same time.

6.1 Entry Point

Unlike languages like Haskell, C or Java, there are no main module required for a Keli program. Keli follows the approaches of languages like Python and JavaScript, where the file being interpreted is the entry point of the program.

6.2 Module naming convention

The Keli compiler shall warn the user if the module naming does not follows the PascalCase convention.

6.3 Encapsulation

By default, all declarations in a module are accessible by any other modules. To achieve encapsulation, Keli adopts a rather tolerant encapsulation mechanism, where the compiler would not trigger any compile error, but warnings instead.

To make a declaration invisible to other module, prefix the declaration with an underscore symbol. For example,

_pi = 3.142 // invisible to other module
(this Number)._square = undefined // this is invisible to other module
_Colors = choice // this is invisible to other module
.Red
.Blue

However, this does not means that the declarations above cannot be used by other modules, it will simply trigger compiler warning if the compiler found that other modules are using them.

6.4 Import syntax

6.4.1 Raw paths

To import a module, we should follow the grammar below:

module . import ( filePath )

where filePath is any valid Unix file paths, be it relative path or absolute path.

Examples as follows:

=module.import("Math.keli")
=module.import("./src/Server.keli")

Note that filePath must end with .keli , if not the compiler will raise an error, even though filePath points to a valid Keli source file.

6.4.2 Aliased paths

In larger projects, using raw paths might be cumbersome, especially when refactoring the folder structures. In such situation, we can use aliased paths, so that the imports can be based on the project root.

Path aliases must be prefixed with the dollar sign.

Path aliases can be defined via the compiler command line arguments or by telling the compiler to load a config file.

Path aliases are defined based on the entry point of a program.

Example of using aliased paths:

=module.import("$root/Math")

6.5 Scoping rule

Suppose we have the following folder structure:

- myapp
- Main.keli
- Src
- Math.keli
- Misc
- Shape.keli

Suppose the Main module imports Shape module:

Main.keli
=module.import("Src/Misc/Shape")

And Shape module imports Math :

Shape.keli
=module.import("../Math.keli")

Then, the following outcome should be observed:

  • All declarations in Shape will be visible to Main except private declarations.

  • All declarations in Math will be visible to Shape .

  • However, no declarations in Math will be visible to Main , although Main imported Shape which imported Math.

In a nutshell, the scoping rule are as follows:

Importee will only be visible to its corresponding direct importer. In another words, the importees of importee will be not be visible to the importer.

6.6 Import conflicts

This section shall define what is considered import conflicts in Keli, how the compiler should behave in such cases. The term identical will be defined first before defining what is a conflict.

6.6.1 Identical declarations

This section shall define how declarations are considered identical in Keli.

6.6.1.1 Mono-nominal declarations

Mono-nominal declarations means any declarations that can be identified by using only a single string token. In Keli, any non-function declaration are considered mono-nominal.

Two mono-nominal declarations are considered identical if their identifier (which appears on the left of the assignment operator) are lexically equal.

For example, all of the following mono-nominal declarations are considered identical:

Foo = 123
Foo = $.name(String) age(Int)
Foo = choice.Bar.Baz
Foo.of(A Type) = choice.Spam(A)

6.6.1.2 Poly-nominal declarations

Poly-nominal declarations are any declarations that cannot be identified by using only a single string token. In Keli, all function declarations are considered poly-nominal, this due to:

  • Multiple dispatch, it means two or more functions can bear the same identifiers without introducing compile error.

  • Function identifiers can be spitted into different parts, like the message syntax in Smalltalk.

Two function declarations, say X and Y, are considered identical if all of the following criteria is observed:

  1. The arguments length of X is equals to the arguments length of Y.

  2. The identifiers of X have the same length with the identifiers of Y (implementation does not need to include this checking, because criterion 1 implies this criterion.

  3. The first identifier of X is lexcially equivalent to the first identifier of Y, and so on and so forth for the rest of the identifiers.

  4. The type annotation of the first argument of X is identical to the type annotation of the first argument of Y, and so on and so forth for the rest of the arguments.

The following code snippets demonstrates identical function declarations.

(x Int).plus(y Int) = undefined
(this Int).plus(that Int) = undefined
(this String).replace(old String) with(new String) = undefined
(x String).replace(y String) with(z String) = undefined

The following code snippets demonstrates non-identical function declarations, with the reason commented on top of each snippet.

// Different argument length
(this Int).plus = undefined
(this Int).plus(that Int) = undefined
// Different identifiers
(this Int).foo = undefined
(this Int).bar
// Different argument type annotation
(x Int).plus(y Int) = undefined
(x Int).plus(y Float) = undefined
// Different identifiers (although would be identical after sorting)
(x Int).foo(y Int) bar(z Int) = undefined
(x Int).bar(y Int) foo(z Int) = undefined

6.6.2 Identical type annotations

In Keli, there are there kinds of type annotation (TA), namely, simple TA, compound TA and bounded type variables.

6.6.2.1 Simple type annotation (STA)

STA are those that consist of only a single string token identifier. For example, Int , String , etc. Two STA are considered identical is their identifier is lexically equal.

6.6.2.2 Compound type annotation (CTA)

CTA are those that does not only consist of a string token identifier, but also contains inner type annotations. Two CTA are considered identical if their identifier is lexcially equal to each other and their inner types are identical.

The following code snippets demonstrate identical CTA:

List.of(Int)
List.of(Int)
Map.key(String) value(Int)
Map.key(String) value(Int)

The following code snippets demonstrate non-identical CTA:

// Different identifer
List.of(Int)
Set.of(Int)
// Different inner type
List.of(String)
List.of(Int)

6.6.2.3 Bounded type variable (BTV)

BTV is primarily used for declaring generic functions. Two BTV are considered identical if their corresponding constraint are identical.

In the following snippet, A and B are identical BTV despite their name difference, because their constraint is identical.

{A Type}
{B Type}

However, in the following snippet, A and A are considered non-identical BTV because their constraint are different, albeit having identical identifier.

{A Type.extends(Comparable)}
{B Type.extends(Comparable)}

6.6.3 Definition of import conflicts

Import conflicts happen when an importer X, where its importees contain identical declarations when compared to each other.

6.6.4 Conflicts between importees

Suppose C imported A and B , but both A and B contains identical public declarations, say XA(declared in A) and XB(declared in B), the compiler should never give any kind of warning or errors, unless an identifier XC which is identical to XA and XB is used in C .

For example, suppose there are two modules as follows:

A.keli
(this Int).plus(that Int) = undefined
B.keli
(this Int).plus(that Int) = undefined

And a module C with imported both A and B as follows:

C.keli
= module.import("A.keli")
= module.import("B.keli")

When running C as entry point, no compiler error or warning will be shown, although A and B contains identical declarations, which is the plus function.

However, if a module D imported both A and B and uses the plus function as follows:

D.keli
= module.import("A.keli")
= module.import("B.keli")
= 1.plus(2)

When running D as entry point, the compiler will throw an error saying:

The function `(Int).plus(Int)` is ambiguous,
as it is declared in both "A.keli" and "B.Keli".

6.7 Conflict resolution

Import conflicts are sometimes unavoidable, albeit Keli supports multiple dispatch. To resolute conflicts, we can use the .using magic function.

Suppose we have the following files:

MathV1.keli
(this Int).square | Int = undefined
MathV2.keli
(this Int).square | Int = undefined

When we imports those files into another file, say Main.keli , no compile error would be introduced.

Main.keli
=module.import("MathV1.keli")
=module.import("MathV2.keli")
// No error

However, when we attempts to use the square function, which is defined in both MathV1 and MathV2 with the same parameter types, we will get a compile error:

Main.keli
=module.import("MathV1.keli")
=module.import("MathV2.keli")
=123.square
// Conflict error:
// There are 2 definitions of:
// (_ Int).square
//
// which are found in:
// MathV1
// MathV2

To resolve the error above, we can use the .using function as follows:

Main.keli
=module.import(MathV1)
=module.import(MathV2)
=123.square.using(MathV1)
=456.square.using(MathV2)
=123.square.using(MathV1).square.using(MathV2)
// No error

Warning

In the situation of naming conflicts, one shouldn't use conflict resolution straight away, but should properly investigate if there are code duplication, because it is very rare for two functions to bear the same name and the same parameter type but serves a different purpose.

Thus, despite using conflict resolution, one could choose to:

  1. Remove the duplicated function

  2. Rename the function that causes conflicts

6.9 Restrictions

In this section, we will discuss about the various types of restrictions imposed by the Keli module system.

6.9.1 Importing module with same name

Any Keli source file cannot import two or more modules that have the same name, even though they sits in different directory.

For example, suppose we have the following file structure:

DirA
Math.keli
DirB
Math.keli
Main.keli

And in Main.keli :

Main.keli
= module.import("DirA/Math.keli")
= module.import("DirB/Math.keli")

Will result in a compile error, because both imported modules have the same name, which is Math.keli.

6.9.2 Cyclic imports

Cyclical imports are not allowed, i.e., if a module A imports module B, then B cannot imports A, as doing so will form a cyclic graph.