Karol Wrótniak
Posted on August 1, 2024
Introduction
Welcome, Flutter developers and all enthusiasts venturing into this innovative terrain! This article is a comprehensive guide to creating a Dart lint rule. That rule will check ARB files with translations. Dart lint is a static analysis tool for the Dart programming language that helps developers identify and address potential issues and style violations in their code. It provides a set of rules and guidelines to ensure code consistency, maintainability, and best practices in Flutter and Dart projects.
The rule we’ll develop detects both missing and redundant plural quantity categories. The plural quantity category refers to a linguistic feature that distinguishes between singular and plural forms of nouns and associated words in a language. It involves variations in morphology, such as adding suffixes or changing the form of the noun, to indicate whether the quantity is singular (one, e.g. apple) or plural (more than one, e.g. apples).
At Droids On Roids, we regularly apply lint rules in our app development. They have been crucial for ensuring high-quality translations and linguistic accuracy across our diverse applications, helping us deliver globally adaptable and top-tier apps.
Understanding plural categories
A plural category (also called quantity), in the context of localization, refers to the numerical value. It dictates the plural form of a word. This concept is crucial in applications that support many languages. That’s because the rules for pluralization can vary among different languages.
For example, in English, the rule is relatively straightforward: a singular noun takes the form of month when the category is , and months when the category is anything than one.
However, in other languages, the rules for pluralization are more complex and can depend on various factors. For example, in Polish, the word month translates to miesiąc. The plural forms vary depending on the quantity:
1 miesiąc (1 month)
2–4 miesiące (2–4 months)
5–21 miesięcy (5–21 months)
1,5 miesiąca (1.5 months)
Other languages might have more categories and/or different number ranges associated with them. For example, In Irish we have:
1 bhróg, 1 uair (1 shoe, 1 hour)
2 bhróig, 2 uair (2 shoes, 2 hours)
3 bhróg, 3 huaire (3 shoes, 3 hours)
7 mbróg, 7 n-uaire (7 shoes, 7 hours)
11 bróg, 11 uair (11 shoes, 11 hours)
Note that plural forms may apply not to the noun located next to a number, but to some other word. What is more, a word near a number may not change at all. For example:
The plural form may even be seen even further away from a number like in these examples:
You have 1 salmon in your cart; do you want to buy it?
You have 2 salmon in your cart; do you want to buy them?
Determining plural categories
In the world of programming, handling plurals across different languages can be challenging. Languages vary in their approach to plurals. The numbers of categories are very different. As well as the assignment of particular numbers to each category.
To address this complexity, the Unicode provides a standardized way to categorize plurals. The categorization is based on mnemonic tags.
Happily, the number of categories in any particular language never exceeds six.
So the CLDR (Common Locale Data Repository) defines six quantity categories for plurals:
zero (zero and sometimes other numbers ending with 0),
one (singular),
two (dual),
few (paucal, a small inexactly numbered group),
many (the least remaining quantities),
other (required, general form, or the only one).
The CLDR mnemonics may not match the grammatical categories or the actual numbers. They are based on the changes required in a phrase or sentence if a numeric placeholder changes its value. Take a look at this example in French:
un heure (one hour, category one),
zéro heure (zero hours, also category one, not zero!).
Keep in mind those categories can also contain fractions. In some cases, for instance, such as our previous example of “other” in Polish from the previous paragraph (1,5 miesiąca), they may even contain only fractions.
Understanding behavior specific to Dart intl in handling plurals
When working with plurals in Dart, you can use the intl package. It provides a powerful and convenient way to handle different quantity categories. Dart’s intl package follows the CLDR plural rules. But, it also offers specific behaviors for categories zero, one and two.
You can specify them even for languages that don’t distinguish those categories. For instance, you can use the following plurals in English translations:
The intl will use category zero, despite there being no such category in the CLDR rules for English. A quantity zero is in the other **category in English. This is not the case in native Android. The **zero category in English will never be used and Android’s lint will complain about that.
Moreover, unlike Android, Dart intl supports categories written as =0, =1 and =2. They are equivalents to zero, one and two respectively.
Decoding the importance of detecting plural categories
The correct handling of plural quantity categories is a crucial aspect of app internationalization. It is important for the UX and readability for end-users. The quality of plural quantities impacts the accuracy of translations. So, it is very convenient to check if they are correct during a static code analysis.
Android’s native lint, understanding the importance of this, has built-in rules. What is more, lint enables them by default. The UnusedQuantity rule detects redundant categories, ensuring they will never be used. And the MissingQuantity rule checks for entities that are required but not provided. The latter is an error because native apps will crash in such scenarios.
In contrast to Android’s native lint, Flutter currently does not have these rules in place. Recognizing this gap, we decided to develop these rules. And now, we’re thrilled to share them with you. By ensuring the correct plural quantities in your app, you can enhance the code quality.
Crafting your own lint rules in Dart
Creating your own lint rules in Dart involves utilizing specific tools.
There are official packages like analyzer and analyzer_plugin provided by the Dart team. But, using them may be quite difficult. An analyzer
is a big package, requiring client-server architecture. In the case of analyzer_plugin
, support is not currently available for general use.
It is much more convenient to use the custom_lint package. It wraps all the complex low-level API interfaces, providing you with a better developer experience. You can focus on developing the business logic of your lint rules.
Designing custom lint check architecture
There are several elements needed for plural_lint
to work. Let's describe them all.
The plugin consists of two lint rules:
MissingQuantityLintRule
- detecting missing, but required categories,UnusedQuantityLintRule
- detecting redundant categories.
I’ve chosen the “quantity” phrasing to be consistent with the Android native lint.
The rules have to compare the actual translations in the app with the expected CLDR rules. To extract data from the app, you would also need the ARB file parser. CLDR rules on the other hand are available in the machine-readable XML format, in the official Unicode repository, but you would need to parse them as well.
Let’s dive into all those parts one by one!
Mastering ARB files parsing
Flutter apps often use the Application Resource Bundle (ARB) format for localization. These are JSON files with several extra features. Only a tiny subset of them is relevant to Flutter apps. And only a tiny part of it is interesting in terms of plurals. Take a look at the following sample:
{
"subscriptionEndReminder": "{count, plural, Your subscription ends in one{one day} other{{count} days}}",
"@subscriptionEndReminder": {
"description": "Number of days until the end of the subscription",
"placeholders": {
"count": {}
}
}
}
In the code above, you have data encoded in the value of the subscriptionEndReminder
field. It is an embedded fragment beyond the JSON specification. You have to parse it additionally. The second key is an optional description, which is useful for translators to get the correct context. It is irrelevant for the lint. So, we'll focus on the first part.
Parsing JSON in Flutter is a very common task. There are plenty of libraries for that, even in the official dart:convert package. But, we have to parse plural entries like this: {count, plural, one{thing} other{things}}
.
There are plenty of tools using ARB files as the sources for generating the Dart code. However, none of them expose API at the desired level of abstraction. At least, at the time of writing this article.
For example, the intl_utils package from localizely supports plurals. It can build the Dart code out of ARB with them. However, it provides no public API to get data about individual plurals along with their categories. It exposes only high-level operations like producing the ready-to-use Dart files. Yet, intl_utils is open-source and contains a very useful IcuParser class. We can use it as the base for our ARB parser.
Unleashing the power of PetitParser
Writing such a parser from scratch can be very tricky. Fortunately, there are ready to use packages which we can use to simplify that task. One of them is PetitParser. It’s a dynamic and versatile parsing library for Dart. It enables developers to construct a wide array of grammar in the Dart code.
Describing all the features of PetitParser is a very wide topic, and beyond the scope of this article. But, here you can see a quick example:
void main() {
final input = stdin.readLineSync();
final result = PatternDefinition().build().parse(input ?? '');
if (result is Success) {
print('Pattern found, result: ${result.value}');
} else {
print('Pattern not found');
}
}
class PatternDefinition extends GrammarDefinition<int?> {
@override
Parser<int?> start() => ref0(value).end(); // 1
Parser<int?> value() => seq3( // 2
char('{').trim(), // 2a
digit().plus().flatten(), // 2b
char('}').trim(), // 2c
).map3((_, digits, __) => int.tryParse(digits)); // 3
}
In the code above you have:
Expecting the value (defined below) at the end of an input.
Expecting the sequence of 3 elements:
– Opening curly brace, optionally surrounded by whitespaces.
– 1 or more digits along with flattening them to a single string.
– Closing curly brace, optionally surrounded by whitespaces.Mapping the second element (string with digits) to an int and discarding the first and third elements.
The complete JSON grammar definition is available as the official PetitParser example. Furthermore, the definition of a single ARB entry can be taken from the mentioned IcuParser in the intl_utils repository.
There is one very important detail which is not present in any of those existing source codes. We need the position of each plural in the source file. It is necessary for lint to report potential issues in the correct lines of an ARB file. We can use the callCC method to obtain those positions. You can find the complete ARB parser code in the project repository.
XML parsing in Dart
Parsing CLDR plural rules is much easier to implement. All the data we need is in the standard XML format and the space-separated texts. There’s no need to use custom grammar with PetitParser. We can use the xml package for that.
It supports DOM-based models and XPath queries. So, parsing it is pretty straightforward. The CLDR XML looks like this:
<supplementalData>
<plurals type="cardinal">
<pluralRules locales="bs hr sh sr">
<pluralRule count="one">
<pluralRule count="few">
Values and closing tags are omitted for brevity. You can find the complete file in the CLDR repository.
We need to know which locale supports each category ( count). We are only interested in the locales and count attribute values. The output we want is a map of the locale (language code) to a list of categories like this:
{
bs: [one, few],
bm: [other], ...
}
Thanks to the XPath, the entire XML parsing logic fits in less than 20 lines of code. And that’s even taking formatting into account! Look at the code:
Map.fromEntries(XmlDocument.parse(
await Resource('package:plural_lint/src/cldr/plurals.xml') // 1
.readAsString(encoding: utf8))
.xpath('/supplementalData/plurals[@type="cardinal"]/pluralRules') // 2
.map((item) => item
.xpath('@locales')
.first
.value! // 3
.split(' ') // 4
.map((locale) => MapEntry(
locale,
item
.xpath('pluralRule/@count') // 5
.map((e) => e.value!)
.toList(growable: false))) // 6
)
.expand((item) => item)); // 7
In the code above, we are:
Reading the local XML file contents into String using the resource_portable package.
Extracting the list of pluralRules tags along with their children.
Extracting the value of the first locales attribute (there will always be only one).
Splitting that value by space to get a list of individual language codes.
Extracting the list of count attributes.
Building a list of values of those attributes to get the list of individual categories.
Building a list of map entries out of iterables.
You can find the complete code in the project repository.
Note the resource loading at the beginning. We cannot just read the XML file stored along with the dart source files. To access it at runtime, we have to wrap it in the resource.
Connecting all the ingredients together
There are a lot of articles and videos about creating your own rules with custom_lint. So, we’ll focus on the properties specific to the plural quantity checks.
First of all, the class of your rule has to extend the LintRule instead of DartLintRule. That’s because we’re working with .arb files (not .dart ones). The exact files to analyze depend on the return value of filesToAnalyze getter. When it comes to our rules, we should use:
@override
List<String> get filesToAnalyze => const ['**.arb'];
The next important element is the rule initialization process. XML file reading by resource_portable package is asynchronous. The file contents arrive as an instance of the Future class. But, the analysis in the run() method must be synchronous. To deal with that, you can load the XML file in the asynchronous startUp() method.
It is not strictly necessary but you can read and parse the XML file with CLDR rules only once. Then, you can store the parsed form in the memory and use that version in subsequent analyses. This can be implemented using the singleton pattern and Completer. See the complete code in a plural_lint repository.
Implementing business logic in your Dart lint plugin
Now you know how to load the CLDR rules and the analyzed ARB file. Before you start checking plurals, you have to determine the locale (language) of the given ARB file. There are several ways to indicate a locale. The intl_utils package checks the following places in order:
@@locale field,
_locale field,
filename suffix (between the last underscore and extension).
When looking for redundant quantity categories, keep in mind that intl supports zero, one and two for all the locales. So, you should not consider them redundant, even if they are not present in the CLDR rules for a given locale.
The other category exists for all the locales in CLDR rules. However, some of them (as with Polish, for instance) use them only for fractions. They are much less common in real life than integers in terms of plurals. Nevertheless, the plural_lint project strictly follows the CLDR rules, so it treats the other category as required.
Error handling
ARB files are provided by users, so you have to take invalid data into account. A file might be not parsable as JSON. Or, it may be impossible to determine the locale if none of the three supported locale indicators are present.
You have to handle all those unhappy scenarios gracefully. The analysis process will fail otherwise. To inform users about errors, just print them. The printed messages will appear in the custom_lint.log file as well as in the output of dart run custom_lint invocation in the terminal. However, they won't show in the IDE Dart Analysis pane.
Testing lint rules
The custom_lint provides an excellent way of testing the rules. You can create a sample project containing issues and comment them with expect_lint. If the lint detects the issues, they will be ignored and analysis will succeed. But, if it doesn't find an issue in the commented lines, it'll report an error.
Unfortunately, this only works for .dart files. In the case of .arb, you have to write tests explicitly. The custom_lint package exposes a function called customLint, which runs all the lints in a given project.
That function prints the lints results to the standard output. So, to compare them with the expected values we have to use a zone and capture the standard output using the runZoned method. You can find the complete tests and a standard output implementation with capturing in the project repository.
Creating a Dart lint plugin — Wrap up
Creating a Dart lint plugin is a valuable addition to any Flutter developer’s toolkit. At Droids On Roids, we frequently implement lint rules in our projects and can attest to its effectiveness. With the resources provided in this article, you are well-equipped to craft your own lint rules. You can also enhance the internationalization of your Flutter apps. All this will lead to a better user experience. We hope that you enjoyed this article. Happy coding!
The full source code is available on GitHub.
The package is available on pub.dev.
Originally published at https://www.thedroidsonroids.com on November 28, 2023.
Also published at Medium.
Posted on August 1, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.