org.cubeengine:dirigent

A readable and compact message composing library


Keywords
cubeengine, formatting, java, library
License
MIT

Documentation

Dirigent

Maven Central Build Status

The Dirigent project is a compact message formatting framework. It can be used similar to Java's Formatter system or printf-like systems. These systems seem to be made for programmers, but those working on these messages usually don't have programming experience and that should not be necessary. Dirigent focused providing the maximum of context to those how need it: Translators. All with the goal to improve translation quality in the long run. All macros (our term for text placeholders) don't use numbers or some weird %-syntax, but simple readable names that describe their content and fit into the message.

Beyond that, all macros are completely locale aware and with advanced features like custom formatters, additional context, message builders and post processors, the final output can be customized to any application. From simply inserting strings into a message all the way to complex object trees that carry consistent text styling information and other metadata.

Macro

A macro is a special sequence of letters which is replaced with an input parameter from Dirigent using a Formatter. It must be placed within a string message. Furthermore it's possible to specify more than one macro in a single message. In this context macros follow this syntax:

{[[<position>:]type[#<label>][:<args>]]} or just {[position]}

Where [...] denote optional parts and <...> denote required parts.

Ensuing from the syntax here are the possibilities:

  1. Using default macros: "Hello {} {}, how are you doing?"; A default macro only uses a default formatter which can be specified with the Dirigent constructor. It doesn't provide any contextual information. The input parameters are inserted into the message in the same order as they're specified.
  2. Using indexed macros: "Hello {0} {1}, how are you doing?"; When translating a message with multiple arguments the translator might have to change the order in which the macros occur. This is a common problem as different languages tend to have different sentence structures. Therefore it must be possible to specify the position of the parameter in the input parameter list. Keep in mind positions do start with 0 not with 1! Simple indexed parameters use the default formatter as well.
  3. Using a different formatter: "Hello {name}, how are you doing?"; In this example the macro value will be generated by a formatter handling the name name. This formatter will receive the input value according to its position together with a composition context (containing for example locale information). The formatter will then produce it's most appropriate representation of the given input.
  4. Using a different formatter and an index: "Hello {0:name}, how are you doing?"; It's also possible to add an optional position to the formatter. This has the same effect as for the default macro.
  5. Using a formatter with special arguments: "Date: {date:format=yyyy-MM-dd}"; A formatter can get different arguments to do the job. Every argument is split with a :. There are two types of different arguments. A parameter argument maps an argument name to a value like the example illustrates it. A value argument only has a value. This could be a flag.
  6. Providing additional information: "Hello {0:name#name of the user:some-argument}, how are you doing?"; A label is a kind of contextual information for the localizer. This won't be available within the code and only helps the localizer to understand the meaning of the placeholder. A label can be used without specifying an argument, too.

The label as well as the arguments are allowed to contain any character except :, } and \ which have to be escaped using \. A formatter name follows this rule but must respect the label separator # additionally. An argument also must pay attention to the value separator = which is used to separate the name of an argument from the respective value.

Process

The Dirigent process can be started by calling one of the methods Dirigent#compose(Context, String, Object...) or Dirigent#compose(String, Object...). The latter one will create an empty context and call the first method. The process consists of three independent steps:

  1. The first one divides the message into elements using a parser. Here are two different types. Text elements representing static text and macros which must be processed.
  2. The next step converts these elements into components. The main goal of this step is to resolve macros to their formatters. ResolvedMacros will be joined by their formatter and matching input value, UnresolvableMacros will signal a missing formatter. Formatter can be registered at the Dirigent instance using Dirigent#registerFormatter(Formatter). To load the correct formatter for a macro, a formatter has a method Formatter#getNames returning a set of names of a macro triggering this formatter. Additionally the Formatter#isApplicable(Object) method is used to check whether the formatter is able to handle the type of the message input value. If a macro doesn't have a name, a default formatter will be used which was specified at Dirigent creation time. By default it is the StringFormatter, which is described below. This default formatter must handle all object types. The Formatter#isApplicable(Object) method is not checked at this point! An element will be converted into an UnresolvableMacro component if a converter couldn't be found. This can have two reasons. The first reason is that there isn't any registered formatter handling the used name of the macro. The second one represents the case that there is a formatter for the macro, but it doesn't handle the actual type of the message input value. Both reasons are represented with a MacroResolutionState. After converting an element to a component, the registered PostProcessors of the Dirigent instance will be called. They are allowed to manipulate the components. More about it can be found in the PostProcessor section of this documentation. All the components will be grouped in a component group.
  3. The last step composes these components into the final message. While the previous steps are already handled by the Dirigent library, this final step is up to you by sub-classing the AbstractDirigent class. The Dirigent framework provides the BuilderDirigent implementation using a MessageBuilder to compose the final message. This builder has two generic types. The type of the actual message and the type of the builder (a kind of intermediary object) to use. The StringMessageBuilder composes String messages using a StringBuilder. The components of a component group will be loaded and processed individually. The text of Text components are appended without any modification. Resolved macro components are converted to another component by calling the actual formatter. Unresolved macro components are appended as a {{unresolved: <macro-name>}} string. All other kind of components will result in an IllegalStateException. To change one of this behaviours the responsible method can be overwritten. In the end the final message object will be returned.

Context

The Dirigent process can be started with a special compose context. This context includes information for the formatter and post processor which can be evaluated by them. The context is expandable dynamically. Specific entries relate to a specific ContextProperty. This framework provides entries for a Locale, a TimeZone and a Currency within the static context of the Contexts helper class. Every ContextProperty contains a DefaultProvider which is used for getting a default value of the property if it isn't specified. To create a PropertyMapping, which is necessary to create a compose context, the method ContextProperty#with(T) can be used. The creation of a new context should be done by using the Contexts class. Besides a few properties it provides methods for creating contexts.

Formatter

Formatter are needed to format the messages input value. By default the Dirigent instance only has a default formatter, but macros having a name can't be processed. For that reason the Dirigent instance has a method called registerFormatter(Formatter) which must be used to register a formatter formatting macros with specific names. Furthermore the default formatter can be overwritten by providing it at the Dirigent constructor.

Formatter is an abstract class providing the functionality to handle post processors already. Every implementation must implement the method getNames returning a set of strings representing the macro names which will be handled by the formatter, the method isApplicable(Object) checks whether the specified object can be handled by this formatter and the method format(T, Context, Arguments) returns a component representing the actual formatting result. The type parameter T represents the message's input parameter. Context is the compose context and Arguments contains the arguments of the macro. The available implementations AbstractFormatter and ReflectedFormatter help implementing formatters faster. The AbstractFormatter can be used to handle a specific object type like Integer or Date. The object type is read from the generic type of the class which is used for the implementation of the isApplicable(Object) method. Furthermore a method for getNames exists as well. The names must be provided as constructor parameters. The ReflectedFormatter uses annotations to get the details. An implementation class must have the @Names annotation at the class definition. Additionally it can provide several format methods having an object parameter and optionally a compose context and an arguments object. The methods must be marked with the Format annotation. The ReflectedFormatter checks the input parameter types and looks for the format implementation to use at runtime.

In addition to the described formatter, there are also ConstantFormatters. A constant formatter is special formatter type which doesn't consume any message input values. Instead it only uses the context and the macro arguments to produce its output.

Available Formatters

The Dirigent framework provides a few formatters already. They all provide default macro names. Those can be changed by providing new names at the constructor.

StringFormatter

The StringFormatter can be used to format any object by calling the String#valueOf(Object) method. This formatter is also used as the default formatter. It is possible to provide one of the flags uppercase or lowercase to return the uppercase or lowercase version of the string. The formatter only has one default name which is string.

Example:

  • dirigent.compose("Hello {string}", "Stefan") will result in Hello Stefan
  • dirigent.compose("Hello {string:uppercase}", "Stefan") will result in Hello STEFAN

NumberFormatter

The NumberFormatter can be used to format any Number object. This is done by using a NumberFormat. By default this class uses the NumberFormat#getInstance method to format the number.

This behaviour can be changed by providing arguments. The parameter format lets you specify an individual format which creates a DecimalFormat instance. Additionally it's possible to specify one of the flags integer, currency, percent. These result in of the methods NumberFormat#getIntegerInstance, NumberFormat#getCurrencyInstance and NumberFormat#getPercentInstance.

Every instance will be created with the value of the property Contexts.LOCALE which is specified in the compose context. Furthermore the value of Contexts.CURRENCY will be considered and used as the Currency of the NumberFormat.

In addition it's possible to change the default behaviour of this formatter by specifying one of the modes INTEGER, CURRENCY and PERCENT at the constructor. This causes that it represents the respective flag automatically.

The default names are number, decimal, double and float.

Example:

  • dirigent.compose("Result: {number}", 12345.344) will result in Result: 12,345.344 with Locale en-US
  • dirigent.compose("Result: {number:format=#,###.0}", 36.4567) will result in Result: 36.5 with Locale en-US
  • dirigent.compose("Result: {number:integer}", 36.4567) will result in Result: 36 with Locale en-US
  • dirigent.compose("Result: {number:currency}", 12345) will result in Result: $12,345.00 with Locale en-US
  • dirigent.compose("Result: {number:currency}", 12345) will result in Result: EUR12,345.00 with Locale en-US and Currency Euro
  • dirigent.compose("Result: {number:percent}", 0.12) will result in Result: 12% with Locale en-US

IntegerFormatter

The IntegerFormatter overwrites the NumberFormat and sets the mode to INTEGER. The default names are integer, long, short, amount and count.

Example:

  • dirigent.compose("Result: {integer}", 36.4567) will result in Result: 36 with Locale en-US

CurrencyFormatter

The CurrencyFormatter overwrites the NumberFormat and sets the mode to CURRENCY. The default names are currency, money and finance.

Example:

  • dirigent.compose("Result: {currency}", 12345) will result in Result: $12,345.00 with Locale en-US
  • dirigent.compose("Result: {currency}", 12345) will result in Result: EUR12,345.00 with Locale en-US and Currency Euro

PercentFormatter

The PercentFormatter overwrites the NumberFormat and sets the mode to PERCENT. The default names are percent and percentage.

Example:

  • dirigent.compose("Result: {percent}", 0.12) will result in Result: 12% with Locale en-US

DateTimeFormatter

The DateTimeFormatter can be used to format a Date object. This is done by using a DateFormat. The behaviour of this class must be specified using one of the modes DATE_TIME, DATE or TIME. The first mode is responsible for formatting a date to a date and the time. The second mode only returns the date part and the last mode represents the time. In contrary to the NumberFormatter here the mode must be specified to archive proper results. By default the DATE_TIME mode is used.

The DATE_TIME mode loads the formatter with the DateFormat.getDateTimeInstance method. As styles it uses the default style. Of course this can be changed by specifying parameters. Does the macro contain one of the flags short, medium, long or full the related style will be used for both the date and the time. Additionally it's possible to style the two date parts separately. This can be done with the parameters date and time with a value which is similar to the flags.

The other two modes DATE and TIME result in a DateFormat loaded with DateFormat.getDateInstance or DateFormat.getTimeInstance. The macro arguments work the same way. Of course here it doesn't make sense to provide one of the parameters date and time, the flag is enough, nevertheless it's possible.

Furthermore the format parameter is supported as well. The format results in a respective SimpleDateFormat which doesn't care about the actual mode of the formatter.

Every instance will be created with the value of the property Contexts.LOCALE which is specified in the compose context. Furthermore the value of Contexts.TIMEZONE will be considered and used as the TimeZone of the DateFormat.

The formatter only has one default name datetime.

Example: assuming the date object represents May 25, 2017 3:13:21 PM UTC

  • dirigent.compose("Result: {datetime}", date) will result in Result: May 25, 2017 3:13:21 PM with Locale en-US
  • dirigent.compose("Result: {datetime}", date) will result in Result: May 25, 2017 5:13:21 PM with Locale en-US and time zone Europe/Berlin
  • dirigent.compose("Result: {datetime:full}", date) will result in Result: Thursday, May 25, 2017 3:13:21 PM UTC with Locale en-US
  • dirigent.compose("Result: {datetime:long}", date) will result in Result: May 25, 2017 3:13:21 PM UTC with Locale en-US
  • dirigent.compose("Result: {datetime:medium}", date) will result in Result: May 25, 2017 3:13:21 PM with Locale en-US
  • dirigent.compose("Result: {datetime:short}", date) will result in Result: 5/25/17 3:13 PM with Locale en-US
  • dirigent.compose("Result: {datetime:date=short:time=medium}", date) will result in Result: 5/25/17 3:13:21 PM with Locale en-US
  • dirigent.compose("Result: {datetime:short:time=medium}", date) will result in Result: 5/25/17 3:13:21 PM with Locale en-US
  • dirigent.compose("Result: {datetime:format=YYYY.MM.dd}", date) will result in Result: 2017.05.25 with Locale en-US
  • dirigent.compose("Result: {datetime:format=YYYY.MM.dd HH\:mm\:ss}", date) will result in Result: 2017.05.25 3:13:21 with Locale en-US. Keep in mind that the : character must be escaped, because otherwise it would be used to separate arguments.

DateFormatter

The DateFormatter overwrites the DateTimeFormatter and sets the mode to DATE. The default name is date.

Example:

  • dirigent.compose("Result: {date}", date) will result in Result: May 25, 2017 with Locale en-US
  • dirigent.compose("Result: {date:short}", date) will result in Result: 5/25/17 with Locale en-US
  • dirigent.compose("Result: {date:format=HH:mm:ss}", date) will result in Result: 15:13:21 with Locale en-US

TimeFormatter

The TimeFormatter overwrites the DateTimeFormatter and sets the mode to TIME. The default name is time.

Example:

  • dirigent.compose("Result: {time}", date) will result in Result: 3:13:21 PM with Locale en-US
  • dirigent.compose("Result: {time:short}", date) will result in Result: 3:13 PM with Locale en-US
  • dirigent.compose("Result: {time:format=YYYY.MM.dd}", date) will result in Result: 2017.05.25 with Locale en-US

StaticTextFormatter

The StaticTextFormatter is a constant formatter which doesn't consume an input parameter. Instead it only writes the text of the first argument directly to the message. This could be used to indicate text parts which shouldn't be formatted for example. The default name of the formatter is text.

Example:

  • dirigent.compose("The value of the property is {text:true}") will result in The value of the property is true
  • dirigent.compose("This framework is {text:incredible awesome}") will result in This framework is incredible awesome

Pro tip: With post processors, which are described in the next section, this static text can be styled in a special way. For example it could be displayed bold or italic. This might be a use-case as well.

Post Processors

A post processor can be used to manipulate a macro after it was created by a formatter or by the Dirigent instance. Therefore the interface PostProcessor provides a method process(Component, Context, Arguments) getting the created component, the current compose context and the arguments of the macro. The result of the method is a component again. The input component will be replaced with the output component. They're allowed to be the same object of course.

An example use-case would be to add a color option to every macro which allows to set the text color of the resulting text. Formatter specific post processors could be used to adapt pre-existing formatters to new component types.

The registration of post processors has handled on two levels:

  1. Globally using the method Dirigent#addPostProcessor(PostProcessor)
  2. Per Formatter instance using Formatter#addPostProcessor(PostProcessor)

Post processors, just like formatters, may return custom component implementations, but note that own implementations might require specific handling in the MessageBuilder, so you have to overwrite it. Instead, using a TextComponent (or the implementation Text) or a ComponentGroup might be enough as well. ComponentGroups allow arbitrary nesting of components and as such are very powerful.

The Dirigent frameworks provides a WrappingPostProcessor wrapping an input component with static components using a ComponentGroup.