Contextual

About

Contextual is a small Scala library for defining your own string
interpolators—prefixed string literals like url”https://propensive.com/”, which
determine how they are interpreted at compile-time, including any custom checks
and compile errors that should be reported, while only writing very ordinary
”user” code: no macros!

How does it work?

Scala offers the facility to implement custom string interpolators, and while
these may be implemented with a simple method definition, the compiler imposes
no restrictions on using macros. This allows the constant parts of an
interpolated string to be inspected at compile-time, along with the types of
the expressions substituted into it.

Note: Scala also allows the definition of string interpolators which make use
of generics (i.e. accepting type parameters). Unfortunatly it’s not possible to
define a generic string interpolator using Contextual, and the macro would need
to be defined manually in order to achieve that.

Contextual provides a generalized macro for interpolating strings (with a
prefix of your choice) that calls into a simple API for defining the
compile-time checks and runtime implementation of the interpolated string.

This can be done without you writing any macro code.

Concepts

Interpolators

An Interpolator defines how an interpolated string should be understood, both
at compile-time, and runtime. Often, these are similar operations, as both will
work on the same sequence of constant literal parts to the interpolated string,
but will differ in how much is known about the holes; that is, the expressions
being interpolated amongst the constant parts of the interpolated string. At
runtime we have the evaluated substituted values available, whereas at
compile-time the values are unknown, though we do have access to certain
meta-information about the substitutions, which allows some useful constraints
to be placed on substitutions.

The contextualize method

Interpolators have one abstract method which needs implementing to provide any compile-time checking or parsing functionality:

def contextualize(interpolation: StaticInterpolation): Seq[Context]

The contextualize method requires an implementation which inspects the
literal parts and holes of the interpolated string. These are provided by the
parts member of the interpolation parameter. interpolation is an instance
of StaticInterpolation, and also provides methods for reporting errors and
warnings at compile-time.

The evaluate method

The runtime implementation of the interpolator would typically be provided by
defining an implementation of evaluate. This method is not part of the
subtyping API, so does not have to conform to an exact shape; it will be called
with a single Contextual[RuntimePart] parameter whenever an interpolator is
expanded, but may take type parameters or implicit parameters (as long as these
can be inferred), and may return a value of any type.

The StaticInterpolation and RuntimeInterpolation types

We represent the information about the interpolated string known at
compile-time and runtime with the StaticInterpolation and
RuntimeInterpolation types, respectively. These provide access to the
constant literal parts of the interpolated string, metadata about the holes and
the means to report errors and warnings at compile-time; and at runtime, the
values substituted into the interpolated string, converted into a common
”input” type. Normally String would be chosen for the input type, but it’s
not required.

Perhaps the most useful method of the interpolation types is the parts method
which gives the sequence of parts representing each section of the interpolated
string: alternating Literal values with either Holes (at compile-time) or
Substitutions at runtime.

Contexts

When checking an interpolated string containing some DSL, holes may appear in
different contexts within the string. For example, in a XML interpolated
string, a substitution may be inside a pair of (matching) tags, or as a
parameter to an attribute, for example, xml”<tag attribute=$att>$content</tag>”.
In order for the XML to be valid, the string
att must be delimited by quotes, whereas the string code does not require
the quotes; both will require escaping. This difference is modeled with the
concept of Contexts: user-defined objects which represent the position within
a parsed interpolated string where a hole is, and which may be used to
distinguish between alternative ways of making a substitution.

This idea is fundamental to any advanced implementation of the contextualize
method: besides performing compile-time checks, the method should return a
sequence of Contexts corresponding to each hole in the interpolated string.
In the XML example above, this might be the sequence, Seq(Attribute, Inline),
referencing objects (defined at the same time as the Interpolator) which
provide context to the substitutions of the att and content values.

Generalizing Substitutions

A typical interpolator will allow only certain types to be used as
substitutions. This may include a few common types like Ints, Booleans and
Strings, but Contextual supports ad-hoc extension with typeclasses, making it
possible for user-defined types to be supported as substitutions, too. However,
in order for the interpolator to understand how to work with arbitrary types,
which may not yet have been defined, the interpolator must agree on a common
interface for all substitutions. This is the Input type, defined on the
Interpolator, and every typeclass instance representing how a particular type
should be embedded in an interpolated string must define how that type is
converted to the common Input type.

Often, it is easy and sufficient to use String as the Input type.

Embedding types

Different types are embedded by defining an implicit Embedder typeclass
instance, which specifies with a number of Case instances how the type should
be converted to the interpolator’s Input type. For example, given a
hypothetical XML interpolator, Symbols could be embedded using,

where the conversion to Strings are defined for three different contexts,
AttributeKey, AttributeVal, and Content. Whilst in the first two cases,
the context changes, in the final case, the context is unchanged by making the
substitution.

Attaching the interpolator to a prefix

Finally, in order to make a new string interpolator available through a prefix
on a string, the Scala compiler needs to be able to “see” that prefix on
Scala’s built-in StringContext object. This is very easily done by specifying
a new Prefix value with the desired name on an implicit class that wraps
StringContext, as in the example above,

The Prefix constructor takes only two parameters: the Interpolator object
(and it must be an object, otherwise the macro will not be able to invoke it
at compile time), and the StringContext instance that we are extending.