Wednesday, May 19, 2010

Implementing Date Support with Quickfix using Xtext


Now that Xtext is at 1.0 RC1 I thought it was time to start using more of all the new features for Eclipse b3. One of the features I wanted to add was to support time stamps in a nice way in the editor. Internally, a time stamp is naturally stored as a java.util.Date so there is never a question about the exact UTC it is representing. When editing however, you may want to use some other format (if not copying an actual timestamp, you may want to use something like 'feb 10, 11:00:00am' .

The issue is that the reference to 'feb 10, 11:00:00am' in the source text has no time zone information, and the name of the month may not be in english etc. In order for the source to be valid everywhere, it would be required to fully specify the date format used, as well as the timezone and store this in the source. I choose a middle ground where the editor understands the more human friendly formats and offers to help to convert it to a format that is always possible to parse.

All of this may not be all that interesting, but it gave me opportunity to try some features of Xtext that I had not used. The rest of this blog is about my first iteration of the implementation, and it shows some Xtext techniques like:

  • Using an ecore data type in the grammar
  • A Date value converter
  • Overriding the SyntaxErrorMessageProvider
  • Providing a quick fix for a ValueConverterException

The Grammar

First step is to define the grammar that involves a time stamp

import "" as ecore
Entity : "timestamp" '=' TIMESTAMP ;
TIMESTAMP returns ecore::EDate : STRING ;
This simply declares that a language element 'Entity' has a 'timestamp'. The TIMESTAMP rule declares that it returns an ecore:EDate. Luckily we don't have to state more than the import of ecore to make use of it in our language. Also in our favour is that EDate is declared in ecore. If this was for a datatype not in ecore, we would need to create a model containing the definition of the data type. As this was not the case here, we can move on to the data converter.

Date Value Converter

This is almost boiler plate code, but there are some interesting details. Here is the converter method.
01   @ValueConverter(rule = "TIMESTAMP")
02   public IValueConverter<java.util.Date> TimestampValue() {
03     return new AbstractNullSafeConverter<Date>() {
05       @Override
06       protected String internalToString(Date value) {
07         SimpleDateFormat fmt = new SimpleDateFormat("yyyyMMddHHmmssZ");
08         fmt.setTimeZone(TimeZone.getTimeZone("UTC"));
09         return '"' + fmt.format(value) + '"';
10       }
12       @Override
13       protected Date internalToValue(String string, AbstractNode node) throws ValueConverterException {
14         string = string.substring(1, string.length() - 1);
16         // First choice, if a timestamp string, use it.
17         try {
18           // Allow non UTC strings since they are fully qualified with offset and can thus
19           // be parsed by anyone.
20           SimpleDateFormat fmt = new SimpleDateFormat("yyyyMMddHHmmssZ");
21           fmt.setTimeZone(TimeZone.getTimeZone("UTC"));
22           return fmt.parse(string);
23         }
24         catch(ParseException e) {
25           // ignore and try timestamp format
26         }
27         // Second choice - if using java default for the locale
28         // Needs special processing as it probably does not contain TZ in the string)
29         try {
30           // try the default locale style of Date Time and see if it parses
31           DateFormat.getDateTimeInstance().parse(string);
32           // if this parsed, it is not likely that the default is the full
33           // format with timezone offset, so flag this as a special error :)
34           // that is fixable
35           // Although simple, it makes sense from a user perspective, a time in
36           // local format can be entered and transformed to a timestamp.
37           throw new ValueConverterException("Not in timestamp format", node, new NonUTCTimestampException());
38         }
39         catch(ParseException e) {
40           DateFormat fmt = DateFormat.getDateTimeInstance();
41           String defaultFormat = (fmt instanceof SimpleDateFormat)
42               ? ((SimpleDateFormat) fmt).toLocalizedPattern()
43               : "Default format for the locale";
44           throw new ValueConverterException("Not in valid format: Use 'yyyyMMddHHmmssZ' or " + defaultFormat +
45               "Parse error:" + e.getMessage(), node, null);
47         }
48       }
49     };
50   }

The code first tries to convert the string entered by the user using the wanted timestamp format. If this fails, an attempt is made to use the default format. If this works, we know we have source text that (most likely) does not have the correct time zone information in it, and we want to offer a quick fix to convert the format. But how can that be done — the ValueConverterException does not allow us to specify a 'diagnostic code' that allows a quick fix to detect the particular problem. The ValueConverterException is also final (in the 1.0RC1 release at least), so the only option is to use a marker Exception as the cause (In this case NonUTCTimestampException).

The final attempt to convert (again using the preferred timestamp format) is there simply to catch the error (it could have been remembered from the first attempt).

As you will see later, the design can be improved further by supplying the actual format that was used to successfully parse the entered timestamp in the marker exception, but I left that for a later iteration.

Note that the error message includes the two valid formats as feedback to the user in case the entered text was unparsable. It would be easy to try several formats.

Overriding the Syntax Error Message Provider

The default SyntaxErrorMessageProvider is a class that hands out SyntaxError instances describing a problem occuring in a particular context. In my case I just wanted to add handling of the ValueConverterException with my special non-UTC cause Exception.

Here it is

1 public class BeeLangSyntaxErrorMessageProvider extends SyntaxErrorMessageProvider {
2   @Override
3   public SyntaxErrorMessage getSyntaxErrorMessage(IValueConverterErrorContext context) {
4     if(!(context.getValueConverterException().getCause() instanceof NonUTCTimestampException))
5       return super.getSyntaxErrorMessage(context);
6     return new SyntaxErrorMessage(context.getDefaultMessage(), IBeeLangDiagnostic.ISSUE_TIMESTAMP__NON_UTC);
8   }

As you can see, this is straight forward, simply return a SyntaxErrorMessage with a diagnostic code (a static string) that I called IBeeLangDiagnostic.ISSUE_TIMESTAMP__NON_UTC. At this point, non of the new code (except the data value conversion is in effect, and a bit of magic is needed to make it kick in.

Xtext makes good use of google guice dependency injection. In addition to the standard guice, there is also advanced so called 'polymorphic dispatching'. This means, that even if it is not apparent in the guice module Xtext generates for a DSL that something can be bound to a specialized class, it is still just as easy to bind almost anything by simply adding a method.

Here is the part that was added to the guice module for my DSL

1   public Class<? extends ISyntaxErrorMessageProvider> bindISyntaxErrorMessageProvider() {
2     return BeeLangSyntaxErrorMessageProvider.class;
3   }

This means that whenever the Xtext runtime wants an implementation of the ISyntaxErrorMessageProvider, it will now get an instance of the specialized class shown earlier.

The Quick Fix

The final part of the puzzle is to provide the quick fix. There really is not much to say but to show the code:

01   @Fix(IBeeLangDiagnostic.ISSUE_TIMESTAMP__NON_UTC)
02   public void transformDate(final Issue issue, IssueResolutionAcceptor acceptor) {
03     acceptor.accept(
04       issue, "Convert to timestamp", "Converts the valid Date/Time to a fully specified time", null,
05       new IModification() {
06         public void apply(IModificationContext context) throws Exception {
07           IXtextDocument xtextDocument = context.getXtextDocument();
08           String dateString;
09           dateString = xtextDocument.get(issue.getOffset(), issue.getLength());
10           if(dateString.length() <= 2)
11             return; // something is wrong, it should be at least ""
12           dateString = dateString.substring(1, dateString.length() - 1);
13           // try to convert and throw exception if it fails.
14           Date date = DateFormat.getDateTimeInstance().parse(dateString);
16           // reformat as timestamp using UTC
17           SimpleDateFormat fmt = new SimpleDateFormat("yyyyMMddHHmmssZ");
18           fmt.setTimeZone(TimeZone.getTimeZone("UTC"));
19           dateString = '"' + fmt.format(date) + '"';
21           xtextDocument.replace(issue.getOffset(), issue.getLength(), dateString);
22         }
23       });
24   }

This is pretty much bolier plate code for a quick fix (when generating a DSL with Xtext, there is a sample that shows ow it is done). The code above simply converts the source string using the default format in the value converter, turning it into a timestamp in the correct format. It the replaces the string in the input text.

An improvement would be to pass the date format used in the 'Issue' (it is possible to pass data with a diagnostic code), but I did not look into how to do this with the SyntaxError class yet.

A big thank you to Sebastian Zarnekow at Itemis for pointing me in the right direction

No comments: