diff --git a/CHANGELOG.md b/CHANGELOG.md index 51bd20cfd..624639aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `--label-langs-priority` option to `diff` for choosing entity labels by language preference in the `pretty` format + ### Fixed - Fix problem with catalog file for compressed ontologies [#1281] diff --git a/docs/diff.md b/docs/diff.md index 0f04bb335..6b317e604 100644 --- a/docs/diff.md +++ b/docs/diff.md @@ -32,6 +32,29 @@ See [release-diff.txt](/examples/release-diff.txt) for an example. The default "plain" output is in OWL Functional syntax with IRIs. You can include entity labels with `--labels true`. In addition, Markdown and HTML diff formats (based on Manchester syntax) are available. You can select the desired format using the `--format` (or `-f`) option, with possible values `plain`, `pretty` (text with labels and CURIEs), `html`, or `markdown`. +### Label Languages + +When an entity has labels in more than one language, you can control which one is shown in the `pretty` format with `--label-langs-priority`, a comma-separated list of [language tags](https://www.rfc-editor.org/info/bcp47) in priority order: + + robot diff --left lang-left.owl \ + --right lang-right.owl \ + --format pretty \ + --label-langs-priority en-GB,en \ + --output results/lang-diff.txt + +Here the `dog` class carries `de`, `en`, and `en-GB` labels; with `en-GB,en` its British label `hound` is chosen. Selection rules: + +- A more general tag matches a more specific one — for example, `en` matches a label tagged `en-GB`. Each label is bound to the *most specific* tag in your list that it matches, and the label bound to the *earliest* listed tag wins. +- Listing a specific tag after a general one deprioritizes it. With `en,en-GB`, a label tagged `en-GB` binds to `en-GB` (position 2), while a label tagged `en-US` binds to `en` (position 1, by cascade) — so the `en-US` label is shown. To prefer British labels instead, list `en-GB` first. +- Use the token `none` to prefer labels that have no language tag (for example, `--label-langs-priority en,none`). +- Use `*` as a catch-all that matches any label (for example, `--label-langs-priority en,*` prefers English but falls back to any available label). `*` is the least specific match, so any listed language is preferred over it. +- When more than one label is bound to the winning tag (for example, two labels tagged `en`), the alphabetically first is chosen. +- If an entity has labels but none in a preferred language, its alphabetically first label is used, so a labelled entity is never shown as its IRI. Entities with no label at all are unaffected. + +Tag matching follows [RFC 4647](https://www.rfc-editor.org/rfc/rfc4647.html) Basic Filtering (a tag like `en` matches any label tagged `en` or beginning `en-`, and `*` matches anything); ROBOT then uses your list's priority order and the tie-breaks above to choose a single label. + +This option currently applies only to the `pretty` format; the `markdown` and `html` formats are not yet affected. + You can also compare ontologies by IRI with `--left-iri` and `--right-iri`. You may want to compare a local file to a release, in which case: ``` diff --git a/docs/examples/lang-diff.txt b/docs/examples/lang-diff.txt new file mode 100644 index 000000000..db34e0c2f --- /dev/null +++ b/docs/examples/lang-diff.txt @@ -0,0 +1,6 @@ +2 axioms in left ontology but not in right ontology: +- OntologyID(OntologyIRI() VersionIRI()) +- SubClassOf( [hound]) + +1 axioms in right ontology but not in left ontology: ++ OntologyID(OntologyIRI() VersionIRI()) diff --git a/docs/examples/lang-left.owl b/docs/examples/lang-left.owl new file mode 100644 index 000000000..45e8a28a5 --- /dev/null +++ b/docs/examples/lang-left.owl @@ -0,0 +1,20 @@ + + + + + + Hund + dog + hound + + + + + + diff --git a/docs/examples/lang-right.owl b/docs/examples/lang-right.owl new file mode 100644 index 000000000..62c102dcc --- /dev/null +++ b/docs/examples/lang-right.owl @@ -0,0 +1,18 @@ + + + + + + Hund + dog + hound + + + + \ No newline at end of file diff --git a/robot-command/src/main/java/org/obolibrary/robot/DiffCommand.java b/robot-command/src/main/java/org/obolibrary/robot/DiffCommand.java index 28ddecdb3..91c79839c 100644 --- a/robot-command/src/main/java/org/obolibrary/robot/DiffCommand.java +++ b/robot-command/src/main/java/org/obolibrary/robot/DiffCommand.java @@ -53,6 +53,16 @@ public DiffCommand() { null, "labels", true, "if true, append labels after entity IRIs in the text format output"); o.addOption( "f", "format", true, "format for diff output: plain (default) | pretty | html | markdown"); + o.addOption( + null, + LanguagePreference.OPTION_NAME, + true, + "comma-separated language tags, in priority order, for choosing entity labels in the" + + " 'pretty' format (e.g. 'en-GB,en," + + LanguagePreference.NO_LANG_TOKEN + + "'); use '" + + LanguagePreference.NO_LANG_TOKEN + + "' for values with no language tag"); options = o; } @@ -159,6 +169,11 @@ public CommandState execute(CommandState state, String[] args) throws Exception Map options = new HashMap<>(); options.put("labels", CommandLineHelper.getDefaultValue(line, "labels", "false")); options.put("format", CommandLineHelper.getDefaultValue(line, "format", "plain")); + String preferredLangs = + CommandLineHelper.getOptionalValue(line, LanguagePreference.OPTION_NAME); + if (preferredLangs != null) { + options.put(LanguagePreference.OPTION_NAME, preferredLangs); + } DiffOperation.compare(leftOntology, rightOntology, ioHelper, writer, options); writer.close(); diff --git a/robot-core/src/main/java/org/obolibrary/robot/DiffOperation.java b/robot-core/src/main/java/org/obolibrary/robot/DiffOperation.java index cdb0de596..ff5e0911d 100644 --- a/robot-core/src/main/java/org/obolibrary/robot/DiffOperation.java +++ b/robot-core/src/main/java/org/obolibrary/robot/DiffOperation.java @@ -13,10 +13,12 @@ import org.geneontology.owl.differ.render.MarkdownGroupedDiffRenderer; import org.geneontology.owl.differ.shortform.DoubleShortFormProvider; import org.geneontology.owl.differ.shortform.OBOShortenerShortFormProvider; +import org.obolibrary.robot.providers.PreferredLanguageShortFormProvider; import org.semanticweb.owlapi.apibinding.OWLManager; import org.semanticweb.owlapi.model.*; import org.semanticweb.owlapi.util.AnnotationValueShortFormProvider; import org.semanticweb.owlapi.util.DefaultPrefixManager; +import org.semanticweb.owlapi.util.ShortFormProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -104,6 +106,18 @@ public static boolean compare( format = "pretty"; } + // An ordered list of preferred label languages (empty means "no preference"). + List preferredLangs = + LanguagePreference.parse(OptionsHelper.getOption(options, LanguagePreference.OPTION_NAME)); + if (!preferredLangs.isEmpty() && !format.equals("pretty")) { + // Only the "pretty" format currently supports language-prioritized labels; the markdown and + // html renderers build their own label provider inside the owl-diff library. + logger.warn( + "The --{} option only affects the 'pretty' diff format; it is ignored for format '{}'.", + LanguagePreference.OPTION_NAME, + format); + } + Differ.BasicDiff diff = Differ.diff(ontology1, ontology2); if (diff.isEmpty()) { @@ -127,13 +141,19 @@ public static boolean compare( break; case "pretty": DefaultPrefixManager pm = ioHelper.getPrefixManager(); - AnnotationValueShortFormProvider labelProvider = - new AnnotationValueShortFormProvider( - ontologyProvider, - pm, - pm, - Collections.singletonList(OWLManager.getOWLDataFactory().getRDFSLabel()), - Collections.emptyMap()); + List labelProperties = + Collections.singletonList(OWLManager.getOWLDataFactory().getRDFSLabel()); + ShortFormProvider labelProvider; + if (preferredLangs.isEmpty()) { + // No language preference: keep the pre-existing behavior exactly. + labelProvider = + new AnnotationValueShortFormProvider( + ontologyProvider, pm, pm, labelProperties, Collections.emptyMap()); + } else { + labelProvider = + new PreferredLanguageShortFormProvider( + ontologyProvider, labelProperties, preferredLangs, pm); + } OBOShortenerShortFormProvider iriProvider = new OBOShortenerShortFormProvider(pm); DoubleShortFormProvider doubleProvider = new DoubleShortFormProvider(iriProvider, labelProvider); diff --git a/robot-core/src/main/java/org/obolibrary/robot/LanguagePreference.java b/robot-core/src/main/java/org/obolibrary/robot/LanguagePreference.java new file mode 100644 index 000000000..d791873c0 --- /dev/null +++ b/robot-core/src/main/java/org/obolibrary/robot/LanguagePreference.java @@ -0,0 +1,275 @@ +package org.obolibrary.robot; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Utilities for choosing an annotation value (typically an rdfs:label) for an entity based on a + * user-supplied, ordered list of preferred language tags. + * + *

Given a set of candidate values (each with a language tag), selection proceeds as: + * + *

    + *
  1. Each value is bound to the preference entry its language tag matches most + * specifically. A match is either exact (equal tags) or a cascade, in which + * a broader entry matches a more specific tag (e.g. {@code en} matches {@code en-GB}); when a + * tag matches several entries, the longest (most specific) entry wins. So with {@code en, + * en-GB} a value tagged {@code en-GB} binds to the {@code en-GB} entry, while a value tagged + * {@code en-US} binds by cascade to {@code en}. The {@link #WILDCARD} ({@code *}) matches any + * tag but is less specific than every real tag. + *
  2. The value bound to the earliest entry in the list wins. Listing a specific tag + * after a general one therefore deprioritizes it: with {@code en, en-GB}, {@code en-GB} + * labels are used only when nothing binds to {@code en}. + *
  3. Among values bound to the same entry, an exact match beats a cascade match; if they still + * tie, the alphanumerically-first value is chosen (a deterministic tie-break, matching + * ROBOT's existing convention in {@code OntologyHelper.getAnnotationString}). + *
  4. If no value matches any preferred language but at least one value exists, the + * alphanumerically-first value is chosen, so that a labelled entity never regresses to its + * IRI merely because its language is unlisted. + *
+ * + *

The per-tag match rule (exact, or a prefix followed by {@code -}, plus the {@code *} wildcard) + * is that of RFC 4647 Basic Filtering. + * This class then applies the list's priority order and the tie-breaks above to choose a single + * value; that single-value selection is not RFC 4647 "Lookup" (Lookup would never let {@code en} + * select an {@code en-GB} value), and the most-specific binding in rule 1 is a ROBOT extension. + * + *

The token {@link #NO_LANG_TOKEN} in a preference list refers to values that have no language + * tag (the OWL API represents these with an empty language string). + * + *

Both the CLI token for "no language" and the command-line option name are defined here as + * constants so that the spelling can be changed in one place. + */ +public class LanguagePreference { + + /** The token, used within a preference list, that matches values with no language tag. */ + public static final String NO_LANG_TOKEN = "none"; + + /** The long name of the command-line option used to supply a language preference list. */ + public static final String OPTION_NAME = "label-langs-priority"; + + /** + * The RFC 4647 basic-filtering wildcard: as a preference entry it matches any language tag. It is + * the least specific match, so any concrete tag match is preferred over it. + */ + public static final String WILDCARD = "*"; + + /** The OWL API's internal representation of "no language tag". */ + private static final String NO_LANG = ""; + + /** A candidate annotation value together with its language tag. */ + public static class Candidate { + + /** The language tag, or the empty string for no language tag. Never null. */ + final String lang; + + /** The lexical value. */ + final String value; + + /** + * Create a candidate value. + * + * @param lang the language tag (a null is treated as no language tag) + * @param value the lexical value + */ + public Candidate(String lang, String value) { + this.lang = (lang == null) ? NO_LANG : lang; + this.value = value; + } + } + + /** Prevent instantiation. */ + private LanguagePreference() {} + + /** + * Parse a comma-separated preference string into an ordered list of language tags. The special + * token {@link #NO_LANG_TOKEN} (case-insensitive) is mapped to the empty language tag. Blank + * entries are ignored. A null or empty input yields an empty list, which means "no preference". + * + * @param csv comma-separated language tags, or null + * @return an ordered list of language tags (the empty string represents "no language tag") + */ + public static List parse(String csv) { + List result = new ArrayList<>(); + if (csv == null) { + return result; + } + for (String raw : csv.split(",")) { + String token = raw.trim(); + if (token.isEmpty()) { + continue; + } + if (token.equalsIgnoreCase(NO_LANG_TOKEN)) { + result.add(NO_LANG); + } else { + result.add(token); + } + } + return result; + } + + /** + * Choose the best value from the candidates according to the preferred languages, falling back to + * the alphanumerically-first value if none match a preferred language. + * + * @param candidates the candidate values (may be empty) + * @param preferredLangs the ordered list of preferred language tags (may be empty) + * @return the chosen value, or null if there are no candidates + */ + public static String selectValue(List candidates, List preferredLangs) { + String preferred = selectPreferred(candidates, preferredLangs); + if (preferred != null) { + return preferred; + } + return selectFallback(candidates); + } + + /** + * Choose the best value whose language tag matches one of the preferred languages. Returns null + * if no candidate matches any preferred language (including when the preference list is empty). + * + * @param candidates the candidate values (may be empty) + * @param preferredLangs the ordered list of preferred language tags (may be empty) + * @return the best-matching value, or null if none matches a preferred language + */ + public static String selectPreferred(List candidates, List preferredLangs) { + if (candidates == null || preferredLangs == null || preferredLangs.isEmpty()) { + return null; + } + Candidate best = null; + int[] bestKey = null; + for (Candidate c : candidates) { + int[] key = matchKey(c.lang, preferredLangs); + if (key == null) { + continue; + } + if (best == null || compare(key, c.value, bestKey, best.value) < 0) { + best = c; + bestKey = key; + } + } + return (best == null) ? null : best.value; + } + + /** + * Choose the alphanumerically-first value, ignoring language. Used as a deterministic fallback + * when no candidate matches a preferred language. + * + * @param candidates the candidate values (may be empty) + * @return the alphanumerically-first value, or null if there are no candidates + */ + public static String selectFallback(List candidates) { + if (candidates == null) { + return null; + } + Candidate best = null; + for (Candidate c : candidates) { + if (best == null || c.value.compareTo(best.value) < 0) { + best = c; + } + } + return (best == null) ? null : best.value; + } + + /** + * Compute a sortable ranking key describing how well a language tag matches a preference list, or + * null if it does not match at all. Lower keys rank better. + * + *

The tag is first bound to the single preference entry it matches most specifically: + * among all matching entries (exact, cascade, or the {@link #WILDCARD}), the one with the longest + * tag is chosen, so a tag of {@code en-GB} binds to a listed {@code en-GB} rather than to a + * broader {@code en}, and a tag of {@code en-GB-scouse} binds to a listed {@code en-GB} rather + * than to {@code en}. This is what lets a specific tag be "deprioritized" by listing it after a + * general one: with {@code en, en-GB} an {@code en-GB} label binds to the second entry, while an + * {@code en-US} label binds (by cascade) to the first. The {@link #WILDCARD} matches any tag but + * is less specific than every real tag, so a concrete match always binds in preference to it. + * + *

The returned key then ranks that binding for the cross-value comparison, components in + * order: + * + *

    + *
  1. the index of the bound entry, so that a label bound to an earlier entry wins; + *
  2. match type: 0 for an exact match, 1 for a cascade (prefix) match, so that two labels + * bound to the same entry prefer the exact one. + *
+ * + * @param lang the language tag to score (the empty string for no language tag) + * @param preferredLangs the ordered list of preferred language tags + * @return a ranking key, or null if the tag matches no preference entry + */ + private static int[] matchKey(String lang, List preferredLangs) { + // Language tags are case-insensitive (BCP 47), and the OWL API reports them in lower case, so + // compare case-insensitively. + String lowerLang = lang.toLowerCase(Locale.ROOT); + int bestIndex = -1; + int bestType = 0; + // Length of the matched preference tag; longer = more specific. The wildcard is less specific + // than any real tag (including the empty "none" tag, whose length is 0), so it uses -1; the + // initial "nothing matched yet" value must be lower still. + int bestSpecificity = Integer.MIN_VALUE; + for (int i = 0; i < preferredLangs.size(); i++) { + String pref = preferredLangs.get(i).toLowerCase(Locale.ROOT); + int type; + int specificity; + if (pref.equals(WILDCARD)) { + // RFC 4647 basic filtering: "*" matches any tag. Least specific, and never an exact match. + type = 1; + specificity = -1; + } else if (lowerLang.equals(pref)) { + type = 0; // exact + specificity = pref.length(); + } else if (!pref.isEmpty() && lowerLang.startsWith(pref + "-")) { + type = 1; // cascade: a broader preference matches a more specific tag + specificity = pref.length(); + } else { + continue; + } + // Bind the tag to the most specific entry it matches. On a specificity tie prefer an exact + // match; iterating in order keeps the earliest entry when everything else is equal. + if (specificity > bestSpecificity || (specificity == bestSpecificity && type < bestType)) { + bestSpecificity = specificity; + bestType = type; + bestIndex = i; + } + } + if (bestIndex < 0) { + return null; + } + return new int[] {bestIndex, bestType}; + } + + /** + * Compare two ranking keys with the candidate value as a final tie-break. + * + * @param keyA first key + * @param valueA value for the first key + * @param keyB second key + * @param valueB value for the second key + * @return negative if A ranks before B, positive if after, zero if identical + */ + private static int compare(int[] keyA, String valueA, int[] keyB, String valueB) { + int cmp = compare(keyA, keyB); + if (cmp != 0) { + return cmp; + } + return valueA.compareTo(valueB); + } + + /** + * Compare two ranking keys component by component. + * + * @param keyA first key + * @param keyB second key + * @return negative if A ranks before B, positive if after, zero if identical + */ + private static int compare(int[] keyA, int[] keyB) { + for (int i = 0; i < keyA.length && i < keyB.length; i++) { + int cmp = Integer.compare(keyA[i], keyB[i]); + if (cmp != 0) { + return cmp; + } + } + return Integer.compare(keyA.length, keyB.length); + } +} diff --git a/robot-core/src/main/java/org/obolibrary/robot/providers/PreferredLanguageShortFormProvider.java b/robot-core/src/main/java/org/obolibrary/robot/providers/PreferredLanguageShortFormProvider.java new file mode 100644 index 000000000..50442c023 --- /dev/null +++ b/robot-core/src/main/java/org/obolibrary/robot/providers/PreferredLanguageShortFormProvider.java @@ -0,0 +1,113 @@ +package org.obolibrary.robot.providers; + +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import org.obolibrary.robot.LanguagePreference; +import org.semanticweb.owlapi.model.OWLAnnotationAssertionAxiom; +import org.semanticweb.owlapi.model.OWLAnnotationProperty; +import org.semanticweb.owlapi.model.OWLEntity; +import org.semanticweb.owlapi.model.OWLLiteral; +import org.semanticweb.owlapi.model.OWLOntology; +import org.semanticweb.owlapi.model.OWLOntologySetProvider; +import org.semanticweb.owlapi.util.ShortFormProvider; + +/** + * A {@link ShortFormProvider} that renders an entity using one of its annotation values (typically + * an rdfs:label), choosing among multiple values with an ordered list of preferred language tags. + * + *

The actual selection rules (exact vs. cascade matches, deterministic tie-breaking, and the + * fallback to any available value) live in {@link LanguagePreference}. This class is the OWL API + * adapter: it gathers the candidate literal values for an entity across the preferred annotation + * properties (respecting their priority order) and the imports closure, delegates the choice to + * {@code LanguagePreference}, and falls back to an alternate short form provider only when the + * entity has no such annotation at all — so entities that are genuinely unlabelled render + * exactly as they did before (their IRI/CURIE). + */ +public class PreferredLanguageShortFormProvider implements ShortFormProvider { + + private final OWLOntologySetProvider ontologySetProvider; + private final List annotationProperties; + private final List preferredLanguages; + private final ShortFormProvider alternateShortFormProvider; + + /** + * Construct a preferred-language short form provider. + * + * @param ontologySetProvider provides the ontologies whose annotation axioms are searched (the + * imports closure of each is included) + * @param annotationProperties the preferred annotation properties, highest priority first + * @param preferredLanguages the preferred language tags, highest priority first (the empty string + * denotes "no language tag"); see {@link LanguagePreference#parse(String)} + * @param alternateShortFormProvider used to render an entity that has none of the preferred + * annotation properties + */ + public PreferredLanguageShortFormProvider( + @Nonnull OWLOntologySetProvider ontologySetProvider, + @Nonnull List annotationProperties, + @Nonnull List preferredLanguages, + @Nonnull ShortFormProvider alternateShortFormProvider) { + this.ontologySetProvider = ontologySetProvider; + this.annotationProperties = annotationProperties; + this.preferredLanguages = preferredLanguages; + this.alternateShortFormProvider = alternateShortFormProvider; + } + + @Nonnull + @Override + public String getShortForm(@Nonnull OWLEntity entity) { + List firstNonEmpty = null; + // Visit the properties in order of preference. A preferred-language match on a higher-priority + // property wins outright; otherwise the fallback uses the first property that has any value. + for (OWLAnnotationProperty property : annotationProperties) { + List candidates = getCandidates(entity, property); + if (candidates.isEmpty()) { + continue; + } + if (firstNonEmpty == null) { + firstNonEmpty = candidates; + } + String preferred = LanguagePreference.selectPreferred(candidates, preferredLanguages); + if (preferred != null) { + return preferred; + } + } + if (firstNonEmpty != null) { + return LanguagePreference.selectFallback(firstNonEmpty); + } + // No label at all: preserve the pre-existing IRI/CURIE rendering. + return alternateShortFormProvider.getShortForm(entity); + } + + /** + * Gather the literal values of the given annotation property on the entity, across all provided + * ontologies and their imports closures. + * + * @param entity the entity to gather values for + * @param property the annotation property to gather + * @return the candidate literal values (possibly empty) + */ + private List getCandidates( + OWLEntity entity, OWLAnnotationProperty property) { + List candidates = new ArrayList<>(); + for (OWLOntology ontology : ontologySetProvider.getOntologies()) { + for (OWLOntology closure : ontology.getImportsClosure()) { + for (OWLAnnotationAssertionAxiom axiom : + closure.getAnnotationAssertionAxioms(entity.getIRI())) { + if (!axiom.getProperty().equals(property)) { + continue; + } + if (axiom.getValue() instanceof OWLLiteral) { + OWLLiteral literal = (OWLLiteral) axiom.getValue(); + candidates.add( + new LanguagePreference.Candidate(literal.getLang(), literal.getLiteral())); + } + } + } + } + return candidates; + } + + @Override + public void dispose() {} +} diff --git a/robot-core/src/test/java/org/obolibrary/robot/DiffOperationTest.java b/robot-core/src/test/java/org/obolibrary/robot/DiffOperationTest.java index bba25629b..0ca121f79 100644 --- a/robot-core/src/test/java/org/obolibrary/robot/DiffOperationTest.java +++ b/robot-core/src/test/java/org/obolibrary/robot/DiffOperationTest.java @@ -83,6 +83,110 @@ public void testCompareModifiedWithLabels() throws IOException { assertEquals(expected, writer.toString()); } + /** + * Compare two ontologies with the pretty format and a language preference, confirming the option + * is threaded through to the label short form provider. + * + * @throws IOException on file problem + * @throws OWLOntologyCreationException on ontology problem + */ + @Test + public void testCompareWithLanguagePreference() throws IOException, OWLOntologyCreationException { + String base = "http://example.org/"; + OWLClass dog = OWLManager.getOWLDataFactory().getOWLClass(IRI.create(base + "dog")); + OWLClass puppy = OWLManager.getOWLDataFactory().getOWLClass(IRI.create(base + "puppy")); + + OWLOntology left = buildLangOntology(base + "lang-left.owl", dog, puppy, true); + OWLOntology right = buildLangOntology(base + "lang-right.owl", dog, puppy, false); + + // German preference selects "Hund". + StringWriter deWriter = new StringWriter(); + Map deOptions = new HashMap<>(); + deOptions.put("format", "pretty"); + deOptions.put(LanguagePreference.OPTION_NAME, "de"); + DiffOperation.compare(left, right, new IOHelper(), deWriter, deOptions); + String deOutput = deWriter.toString(); + assertTrue("expected German label in output:\n" + deOutput, deOutput.contains("Hund")); + assertFalse("did not expect English label:\n" + deOutput, deOutput.contains("Canine")); + + // English preference selects "Canine". + StringWriter enWriter = new StringWriter(); + Map enOptions = new HashMap<>(); + enOptions.put("format", "pretty"); + enOptions.put(LanguagePreference.OPTION_NAME, "en"); + DiffOperation.compare(left, right, new IOHelper(), enWriter, enOptions); + String enOutput = enWriter.toString(); + assertTrue("expected English label in output:\n" + enOutput, enOutput.contains("Canine")); + assertFalse("did not expect German label:\n" + enOutput, enOutput.contains("Hund")); + } + + /** + * Confirm that a language preference is honored when labels are requested via {@code --labels + * true} (with the default "plain" format) rather than by explicitly selecting the "pretty" + * format. Internally {@code labels=true} upgrades "plain" to "pretty", so the language preference + * must take effect the same way. + * + * @throws IOException on file problem + * @throws OWLOntologyCreationException on ontology problem + */ + @Test + public void testCompareWithLanguagePreferenceViaLabelsOption() + throws IOException, OWLOntologyCreationException { + String base = "http://example.org/"; + OWLClass dog = OWLManager.getOWLDataFactory().getOWLClass(IRI.create(base + "dog")); + OWLClass puppy = OWLManager.getOWLDataFactory().getOWLClass(IRI.create(base + "puppy")); + + OWLOntology left = buildLangOntology(base + "lang-left.owl", dog, puppy, true); + OWLOntology right = buildLangOntology(base + "lang-right.owl", dog, puppy, false); + + // Request labels with --labels true (no explicit --format); German preference selects "Hund". + StringWriter deWriter = new StringWriter(); + Map deOptions = new HashMap<>(); + deOptions.put("labels", "true"); + deOptions.put(LanguagePreference.OPTION_NAME, "de"); + DiffOperation.compare(left, right, new IOHelper(), deWriter, deOptions); + String deOutput = deWriter.toString(); + assertTrue("expected German label in output:\n" + deOutput, deOutput.contains("Hund")); + assertFalse("did not expect English label:\n" + deOutput, deOutput.contains("Canine")); + + // The same input with an English preference selects "Canine". + StringWriter enWriter = new StringWriter(); + Map enOptions = new HashMap<>(); + enOptions.put("labels", "true"); + enOptions.put(LanguagePreference.OPTION_NAME, "en"); + DiffOperation.compare(left, right, new IOHelper(), enWriter, enOptions); + String enOutput = enWriter.toString(); + assertTrue("expected English label in output:\n" + enOutput, enOutput.contains("Canine")); + assertFalse("did not expect German label:\n" + enOutput, enOutput.contains("Hund")); + } + + /** + * Build a small ontology whose "dog" class carries a German and an English label. When {@code + * withSubClass} is true, "puppy" is asserted to be a subclass of "dog" (the axiom that will + * differ between the two ontologies). + */ + private OWLOntology buildLangOntology( + String iri, OWLClass dog, OWLClass puppy, boolean withSubClass) + throws OWLOntologyCreationException { + OWLOntologyManager m = OWLManager.createOWLOntologyManager(); + OWLDataFactory df = m.getOWLDataFactory(); + OWLOntology o = m.createOntology(IRI.create(iri)); + m.addAxiom(o, df.getOWLDeclarationAxiom(dog)); + m.addAxiom(o, df.getOWLDeclarationAxiom(puppy)); + m.addAxiom( + o, + df.getOWLAnnotationAssertionAxiom( + df.getRDFSLabel(), dog.getIRI(), df.getOWLLiteral("Hund", "de"))); + m.addAxiom( + o, + df.getOWLAnnotationAssertionAxiom( + df.getRDFSLabel(), dog.getIRI(), df.getOWLLiteral("Canine", "en"))); + if (withSubClass) { + m.addAxiom(o, df.getOWLSubClassOfAxiom(puppy, dog)); + } + return o; + } + /** * OWL API ontology equality only compares the ontology ID. This test confirms this and verifies * that we can use an identity-based set for collections of ontologies when needed. diff --git a/robot-core/src/test/java/org/obolibrary/robot/LanguagePreferenceTest.java b/robot-core/src/test/java/org/obolibrary/robot/LanguagePreferenceTest.java new file mode 100644 index 000000000..5b7ed60fb --- /dev/null +++ b/robot-core/src/test/java/org/obolibrary/robot/LanguagePreferenceTest.java @@ -0,0 +1,201 @@ +package org.obolibrary.robot; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Test; + +/** Tests for {@link LanguagePreference}. */ +public class LanguagePreferenceTest { + + /** Build a candidate list from alternating (lang, value) arguments. */ + private static List candidates(String... langValuePairs) { + if (langValuePairs.length % 2 != 0) { + throw new IllegalArgumentException("expected pairs of (lang, value)"); + } + LanguagePreference.Candidate[] cs = new LanguagePreference.Candidate[langValuePairs.length / 2]; + for (int i = 0; i < cs.length; i++) { + cs[i] = new LanguagePreference.Candidate(langValuePairs[2 * i], langValuePairs[2 * i + 1]); + } + return Arrays.asList(cs); + } + + /** Parsing splits, trims, drops blanks, and maps the no-lang token to the empty string. */ + @Test + public void testParse() { + assertEquals(Arrays.asList("en-GB", "en", "fr"), LanguagePreference.parse("en-GB, en ,fr")); + // The no-lang token becomes the empty string; case-insensitive. + assertEquals(Arrays.asList("en", ""), LanguagePreference.parse("en,NONE")); + // Blank entries (e.g. from a trailing comma) are dropped. + assertEquals(Arrays.asList("en", "fr"), LanguagePreference.parse("en,,fr,")); + // Null or empty means "no preference". + assertEquals(Collections.emptyList(), LanguagePreference.parse(null)); + assertEquals(Collections.emptyList(), LanguagePreference.parse(" ")); + } + + /** An exact language match is preferred over other languages. */ + @Test + public void testExactMatch() { + List cs = candidates("de", "Hund", "en", "dog", "fr", "chien"); + assertEquals("dog", LanguagePreference.selectValue(cs, Arrays.asList("en", "fr"))); + assertEquals("chien", LanguagePreference.selectValue(cs, Arrays.asList("fr", "en"))); + } + + /** A broad preference cascades to a more specific tag (en matches en-GB). */ + @Test + public void testCascadeMatch() { + List cs = candidates("en-GB", "colour", "de", "Farbe"); + assertEquals("colour", LanguagePreference.selectValue(cs, Collections.singletonList("en"))); + } + + /** Within a single preference entry, an exact match beats a cascade match. */ + @Test + public void testExactBeatsCascade() { + // Preferring just "en": the bare-"en" label matches exactly, while the en-US label only + // cascades from "en". Both attach to the same (only) entry, so the exact match wins. + List cs = candidates("en", "generic", "en-US", "regional"); + assertEquals("generic", LanguagePreference.selectValue(cs, Collections.singletonList("en"))); + // With "en,en-GB" and only an en-GB label present, it is matched by its own entry. + List gbOnly = candidates("en-GB", "colour"); + assertEquals("colour", LanguagePreference.selectValue(gbOnly, Arrays.asList("en", "en-GB"))); + } + + // A GB/US pair whose values are chosen so the en-GB label ("aluminium") sorts alphabetically + // *before* the en-US label ("aluminum"). This makes the tie-break discriminating: a broken + // implementation that merged both regions and fell back to alpha order would return the GB label, + // so asserting the US label proves the region logic (not the alpha accident) is doing the work. + private static List gbUsCandidates() { + return candidates("en-GB", "aluminium", "en-US", "aluminum"); + } + + /** An exact regional preference selects that region only, never the sibling region. */ + @Test + public void testExactRegionalPreference() { + assertEquals( + "aluminum", + LanguagePreference.selectValue(gbUsCandidates(), Collections.singletonList("en-US"))); + assertEquals( + "aluminium", + LanguagePreference.selectValue(gbUsCandidates(), Collections.singletonList("en-GB"))); + } + + /** + * A broad entry cascades only to the region that is not itself listed; a listed region + * stays bound to its own (lower-priority) entry. + */ + @Test + public void testBroadPreferenceSkipsListedRegion() { + // "en" (index 0) cascades to en-US (which is not listed), while en-GB is reserved for its own + // entry at index 1. Because the first matching entry wins, the en-US label is chosen even + // though it sorts after the en-GB label. + assertEquals( + "aluminum", LanguagePreference.selectValue(gbUsCandidates(), Arrays.asList("en", "en-GB"))); + } + + /** With only a broad entry and no listed region, both regions cascade and alpha order decides. */ + @Test + public void testBroadPreferenceAmongRegionsUsesAlphaTieBreak() { + assertEquals( + "aluminium", + LanguagePreference.selectValue(gbUsCandidates(), Collections.singletonList("en"))); + } + + /** + * A tag binds to its most specific matching entry even when that tag is not itself listed. With + * {@code en,en-GB}, a tag of {@code en-GB-scouse} binds (by cascade) to the more specific {@code + * en-GB} entry rather than to the earlier {@code en}, so an {@code en-US} label — which can only + * bind to {@code en} — is preferred. + */ + @Test + public void testMostSpecificCascadeBinding() { + // Values are chosen so that a naive "earliest matching entry, then alphabetical" rule would + // wrongly return "aaa" (the en-GB-scouse label). + List cs = candidates("en-GB-scouse", "aaa", "en-US", "zzz"); + assertEquals("zzz", LanguagePreference.selectValue(cs, Arrays.asList("en", "en-GB"))); + } + + /** The "*" wildcard (RFC 4647 basic filtering) matches any tag as a catch-all. */ + @Test + public void testWildcardCatchAll() { + List cs = candidates("de", "Zebra", "fr", "Apfel"); + // No en label exists, but "*" matches both; among them the alphanumerically-first wins. + assertEquals("Apfel", LanguagePreference.selectPreferred(cs, Arrays.asList("en", "*"))); + // Without the wildcard there is no preferred match at all. + assertNull(LanguagePreference.selectPreferred(cs, Collections.singletonList("en"))); + } + + /** A concrete tag is more specific than the wildcard, so a listed language is preferred. */ + @Test + public void testWildcardIsLeastSpecific() { + List cs = candidates("en", "dog", "de", "Hund"); + // "en" binds the English label (exact, index 0); the German label only matches "*" (index 1). + assertEquals("dog", LanguagePreference.selectValue(cs, Arrays.asList("en", "*"))); + } + + /** The wildcard also matches an untagged literal. */ + @Test + public void testWildcardMatchesUntagged() { + List cs = candidates((String) null, "plain"); + assertEquals("plain", LanguagePreference.selectValue(cs, Collections.singletonList("*"))); + } + + /** The no-lang token matches an untagged literal. */ + @Test + public void testNoLangToken() { + List cs = candidates("en", "tagged", null, "untagged"); + List prefs = LanguagePreference.parse("none,en"); + assertEquals("untagged", LanguagePreference.selectValue(cs, prefs)); + // An empty preference entry must not cascade-match everything. + List onlyTagged = candidates("de", "Hund"); + assertNull(LanguagePreference.selectPreferred(onlyTagged, LanguagePreference.parse("none"))); + } + + /** Multiple values in the same (preferred) language are broken alphanumerically. */ + @Test + public void testSameLanguageAlphaTieBreak() { + List cs = candidates("en", "zebra", "en", "apple", "en", "mango"); + assertEquals("apple", LanguagePreference.selectValue(cs, Collections.singletonList("en"))); + } + + /** When no candidate matches a preferred language, fall back to the alphanumerically-first. */ + @Test + public void testFallbackWhenNoPreferredMatch() { + List cs = candidates("de", "Zebra", "de", "Apfel"); + // No German preference given: selectPreferred finds nothing... + assertNull(LanguagePreference.selectPreferred(cs, Arrays.asList("en", "fr"))); + // ...but selectValue still returns a deterministic, non-null label. + assertEquals("Apfel", LanguagePreference.selectValue(cs, Arrays.asList("en", "fr"))); + } + + /** + * An empty preference list yields no preferred match; selectValue falls back deterministically. + */ + @Test + public void testEmptyPreferenceList() { + List cs = candidates("en", "beta", "fr", "alpha"); + assertNull(LanguagePreference.selectPreferred(cs, Collections.emptyList())); + assertEquals("alpha", LanguagePreference.selectValue(cs, Collections.emptyList())); + } + + /** Language-tag matching is case-insensitive (the OWL API reports tags in lower case). */ + @Test + public void testCaseInsensitive() { + // Candidate tag as the OWL API would report it (lower case), preference as a user might type + // it. + List cs = candidates("en-gb", "colour", "de", "Farbe"); + assertEquals("colour", LanguagePreference.selectValue(cs, Collections.singletonList("en-GB"))); + // And the reverse casing. + List cs2 = candidates("EN", "hi", "fr", "salut"); + assertEquals("hi", LanguagePreference.selectValue(cs2, Collections.singletonList("en"))); + } + + /** No candidates yields null (caller falls back to the IRI/CURIE short form). */ + @Test + public void testNoCandidates() { + assertNull(LanguagePreference.selectValue(Collections.emptyList(), Arrays.asList("en"))); + assertNull(LanguagePreference.selectFallback(Collections.emptyList())); + } +} diff --git a/robot-core/src/test/java/org/obolibrary/robot/providers/PreferredLanguageShortFormProviderTest.java b/robot-core/src/test/java/org/obolibrary/robot/providers/PreferredLanguageShortFormProviderTest.java new file mode 100644 index 000000000..092cbc22e --- /dev/null +++ b/robot-core/src/test/java/org/obolibrary/robot/providers/PreferredLanguageShortFormProviderTest.java @@ -0,0 +1,110 @@ +package org.obolibrary.robot.providers; + +import static org.junit.Assert.assertEquals; + +import java.util.Collections; +import java.util.List; +import org.junit.Test; +import org.obolibrary.robot.LanguagePreference; +import org.semanticweb.owlapi.apibinding.OWLManager; +import org.semanticweb.owlapi.model.IRI; +import org.semanticweb.owlapi.model.OWLAnnotationProperty; +import org.semanticweb.owlapi.model.OWLClass; +import org.semanticweb.owlapi.model.OWLDataFactory; +import org.semanticweb.owlapi.model.OWLOntology; +import org.semanticweb.owlapi.model.OWLOntologyCreationException; +import org.semanticweb.owlapi.model.OWLOntologyManager; +import org.semanticweb.owlapi.util.SimpleShortFormProvider; + +/** Tests for {@link PreferredLanguageShortFormProvider}. */ +public class PreferredLanguageShortFormProviderTest { + + private static final String BASE = "http://example.org/"; + private static final OWLDataFactory DF = OWLManager.getOWLDataFactory(); + + private final OWLClass dog = DF.getOWLClass(IRI.create(BASE + "dog")); + private final OWLClass fox = DF.getOWLClass(IRI.create(BASE + "fox")); + private final OWLClass cat = DF.getOWLClass(IRI.create(BASE + "cat")); + + private void addLabel( + OWLOntologyManager m, OWLOntology o, OWLClass c, String value, String lang) { + m.addAxiom( + o, + DF.getOWLAnnotationAssertionAxiom( + DF.getRDFSLabel(), c.getIRI(), DF.getOWLLiteral(value, lang))); + } + + /** + * Build, in a fresh manager, an ontology in which "dog" has four labels (de, en, en-GB, and an + * untagged one), "fox" has only a de and an en-GB label (no bare en), and "cat" has none. + */ + private OWLOntology ontology() throws OWLOntologyCreationException { + OWLOntologyManager m = OWLManager.createOWLOntologyManager(); + OWLOntology o = m.createOntology(IRI.create(BASE + "lang.owl")); + m.addAxiom(o, DF.getOWLDeclarationAxiom(dog)); + m.addAxiom(o, DF.getOWLDeclarationAxiom(fox)); + m.addAxiom(o, DF.getOWLDeclarationAxiom(cat)); + addLabel(m, o, dog, "Hund", "de"); + addLabel(m, o, dog, "dog", "en"); + addLabel(m, o, dog, "hound", "en-GB"); + // A plain (untagged) literal has an empty language tag. + m.addAxiom( + o, + DF.getOWLAnnotationAssertionAxiom( + DF.getRDFSLabel(), dog.getIRI(), DF.getOWLLiteral("plainlabel"))); + addLabel(m, o, fox, "Fuchs", "de"); + addLabel(m, o, fox, "fox (GB)", "en-GB"); + return o; + } + + private PreferredLanguageShortFormProvider provider(String prefs) + throws OWLOntologyCreationException { + OWLOntology o = ontology(); + List properties = Collections.singletonList(DF.getRDFSLabel()); + return new PreferredLanguageShortFormProvider( + o.getOWLOntologyManager(), + properties, + LanguagePreference.parse(prefs), + new SimpleShortFormProvider()); + } + + /** An exact language match wins, and beats an available cascade candidate. */ + @Test + public void testExactPreferred() throws OWLOntologyCreationException { + assertEquals("dog", provider("en,fr").getShortForm(dog)); + // "en" is exact for "dog"; it must not cascade to the en-GB "hound". + assertEquals("dog", provider("en").getShortForm(dog)); + } + + /** A specific tag selects the matching regional label. */ + @Test + public void testRegionalTag() throws OWLOntologyCreationException { + assertEquals("hound", provider("en-GB").getShortForm(dog)); + } + + /** A broad preference cascades to a regional label when no exact match exists. */ + @Test + public void testCascade() throws OWLOntologyCreationException { + // "fox" has no bare-en label, so a preference of "en" cascades to its en-GB label. + assertEquals("fox (GB)", provider("en").getShortForm(fox)); + } + + /** The no-lang token selects an untagged literal. */ + @Test + public void testNoLangToken() throws OWLOntologyCreationException { + assertEquals("plainlabel", provider("none").getShortForm(dog)); + } + + /** With no matching preferred language, fall back to the alphanumerically-first label. */ + @Test + public void testFallbackToAnyLabel() throws OWLOntologyCreationException { + // "es" matches nothing; among {Hund, dog, hound, plainlabel} the alpha-first is "Hund". + assertEquals("Hund", provider("es").getShortForm(dog)); + } + + /** An entity with no label defers to the alternate provider (its short form / IRI fragment). */ + @Test + public void testMissingLabelUsesAlternate() throws OWLOntologyCreationException { + assertEquals("cat", provider("en,fr").getShortForm(cat)); + } +}