From 566c7d37d2a8badf6e733d3252d4eb7f1eeadc48 Mon Sep 17 00:00:00 2001 From: Jeff Lerman Date: Sat, 4 Jul 2026 13:56:35 -0700 Subject: [PATCH 1/6] working version of label lang-preference support, with wiring into robot diff's "pretty" format --- .../obolibrary/robot/LanguagePreference.java | 231 ++++++++++++++++++ .../PreferredLanguageShortFormProvider.java | 113 +++++++++ .../robot/LanguagePreferenceTest.java | 130 ++++++++++ ...referredLanguageShortFormProviderTest.java | 110 +++++++++ 4 files changed, 584 insertions(+) create mode 100644 robot-core/src/main/java/org/obolibrary/robot/LanguagePreference.java create mode 100644 robot-core/src/main/java/org/obolibrary/robot/providers/PreferredLanguageShortFormProvider.java create mode 100644 robot-core/src/test/java/org/obolibrary/robot/LanguagePreferenceTest.java create mode 100644 robot-core/src/test/java/org/obolibrary/robot/providers/PreferredLanguageShortFormProviderTest.java 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..4b9b7f6b0 --- /dev/null +++ b/robot-core/src/main/java/org/obolibrary/robot/LanguagePreference.java @@ -0,0 +1,231 @@ +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. Prefer a value whose language tag matches an entry earlier in the preference list. + *
  2. An exact language-tag match is preferred over a cascade match (e.g. a + * preference of {@code en} matches a value tagged {@code en-GB}); among cascade matches, a + * more specific preference entry (e.g. {@code en-GB}) is preferred over a less specific one + * (e.g. {@code en}). Consequently {@code en} only "captures" {@code en-GB} when {@code en-GB} + * is not itself listed. + *
  3. Among values that tie on language, 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 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 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 key components are, in order: + * + *

    + *
  1. match type: 0 for an exact match, 1 for a cascade (prefix) match; + *
  2. specificity: 0 for exact, otherwise the negated length of the matching prefix so that a + * longer (more specific) preference entry ranks better; + *
  3. the index of the matching entry in the preference list. + *
+ * + * @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[] best = null; + for (int i = 0; i < preferredLangs.size(); i++) { + String pref = preferredLangs.get(i).toLowerCase(Locale.ROOT); + int[] key; + if (lowerLang.equals(pref)) { + // Exact match. + key = new int[] {0, 0, i}; + } else if (!pref.isEmpty() && lowerLang.startsWith(pref + "-")) { + // Cascade match: a broader preference matches a more specific tag. + key = new int[] {1, -pref.length(), i}; + } else { + continue; + } + if (best == null || compare(key, best) < 0) { + best = key; + } + } + return best; + } + + /** + * 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/LanguagePreferenceTest.java b/robot-core/src/test/java/org/obolibrary/robot/LanguagePreferenceTest.java new file mode 100644 index 000000000..8650e9dcf --- /dev/null +++ b/robot-core/src/test/java/org/obolibrary/robot/LanguagePreferenceTest.java @@ -0,0 +1,130 @@ +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"))); + } + + /** An exact match beats a cascade match regardless of list position. */ + @Test + public void testExactBeatsCascade() { + List cs = candidates("en-GB", "colour", "en", "generic"); + // "en" (index 0) cascades to en-GB, but the exact "en-GB" (index 1) wins. + assertEquals("generic", LanguagePreference.selectValue(cs, Arrays.asList("en", "en-GB"))); + // The en-GB literal should be attributed to its own exact entry, not swept up by "en". + List gbOnly = candidates("en-GB", "colour"); + assertEquals("colour", LanguagePreference.selectValue(gbOnly, Arrays.asList("en", "en-GB"))); + } + + /** Among cascade matches, the more specific preference entry wins. */ + @Test + public void testCascadeSpecificity() { + List cs = candidates("en-GB-oxendict", "posh"); + // Both "en" and "en-GB" cascade; the more specific "en-GB" should be chosen even though "en" + // appears earlier in the list. + assertEquals("posh", LanguagePreference.selectValue(cs, Arrays.asList("en", "en-GB"))); + } + + /** 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)); + } +} From 72fad99ab9f9f1153a85a2b79d46d5614a4c8bc8 Mon Sep 17 00:00:00 2001 From: Jeff Lerman Date: Sun, 5 Jul 2026 12:31:02 -0700 Subject: [PATCH 2/6] update diff command to accept label-language preference-list. works only for "pretty" format (for now). Also: - update README & diff-command documentation --- CHANGELOG.md | 4 ++ docs/diff.md | 19 ++++++ .../org/obolibrary/robot/DiffCommand.java | 15 +++++ .../org/obolibrary/robot/DiffOperation.java | 34 ++++++++-- .../obolibrary/robot/DiffOperationTest.java | 64 +++++++++++++++++++ 5 files changed, 129 insertions(+), 7 deletions(-) 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..67e7d82d7 100644 --- a/docs/diff.md +++ b/docs/diff.md @@ -32,6 +32,25 @@ 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 edit.owl \ + --right release.owl \ + --labels true \ + --label-langs-priority en-GB,en,fr \ + --output results/release-diff.txt + +Selection rules: + +- The first language in the list that has a matching label wins. A more general tag matches a more specific one — for example, `en` matches `en-GB` — unless the specific tag is itself listed. +- Use the token `none` to prefer labels that have no language tag (for example, `--label-langs-priority en,none`). +- When more than one label matches (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. + +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/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/test/java/org/obolibrary/robot/DiffOperationTest.java b/robot-core/src/test/java/org/obolibrary/robot/DiffOperationTest.java index bba25629b..636ac83d1 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,70 @@ 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")); + } + + /** + * 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. From af873d82f11cecc72f1ebfd170bd9045f3ae0f08 Mon Sep 17 00:00:00 2001 From: Jeff Lerman Date: Sun, 5 Jul 2026 22:18:23 -0700 Subject: [PATCH 3/6] fix test-failure and add additional coverage --- docs/diff.md | 12 +++--- .../obolibrary/robot/DiffOperationTest.java | 40 +++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/docs/diff.md b/docs/diff.md index 67e7d82d7..cd54fe651 100644 --- a/docs/diff.md +++ b/docs/diff.md @@ -36,13 +36,13 @@ The default "plain" output is in OWL Functional syntax with IRIs. You can includ 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 edit.owl \ - --right release.owl \ - --labels true \ - --label-langs-priority en-GB,en,fr \ - --output results/release-diff.txt + robot diff --left lang-left.owl \ + --right lang-right.owl \ + --format pretty \ + --label-langs-priority en-GB,en \ + --output results/lang-diff.txt -Selection rules: +Here the `dog` class carries `de`, `en`, and `en-GB` labels; with `en-GB,en` its British label `hound` is chosen. Selection rules: - The first language in the list that has a matching label wins. A more general tag matches a more specific one — for example, `en` matches `en-GB` — unless the specific tag is itself listed. - Use the token `none` to prefer labels that have no language tag (for example, `--label-langs-priority en,none`). 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 636ac83d1..0ca121f79 100644 --- a/robot-core/src/test/java/org/obolibrary/robot/DiffOperationTest.java +++ b/robot-core/src/test/java/org/obolibrary/robot/DiffOperationTest.java @@ -120,6 +120,46 @@ public void testCompareWithLanguagePreference() throws IOException, OWLOntologyC 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 From 83f3527f7c782766ea1e801a61247dafae31e4a9 Mon Sep 17 00:00:00 2001 From: Jeff Lerman Date: Sun, 5 Jul 2026 22:40:15 -0700 Subject: [PATCH 4/6] add missing test files --- docs/examples/lang-diff.txt | 6 ++++++ docs/examples/lang-left.owl | 20 ++++++++++++++++++++ docs/examples/lang-right.owl | 18 ++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 docs/examples/lang-diff.txt create mode 100644 docs/examples/lang-left.owl create mode 100644 docs/examples/lang-right.owl 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 From abd5ed40e772c4caf732e69d7bcf0b6c6ac3f7ce Mon Sep 17 00:00:00 2001 From: Jeff Lerman Date: Sun, 5 Jul 2026 23:16:08 -0700 Subject: [PATCH 5/6] repair language-preference cascade behavior and corresponding tests --- docs/diff.md | 5 +- .../obolibrary/robot/LanguagePreference.java | 67 +++++++++++------- .../robot/LanguagePreferenceTest.java | 68 ++++++++++++++++--- 3 files changed, 104 insertions(+), 36 deletions(-) diff --git a/docs/diff.md b/docs/diff.md index cd54fe651..352ae75ed 100644 --- a/docs/diff.md +++ b/docs/diff.md @@ -44,9 +44,10 @@ When an entity has labels in more than one language, you can control which one i Here the `dog` class carries `de`, `en`, and `en-GB` labels; with `en-GB,en` its British label `hound` is chosen. Selection rules: -- The first language in the list that has a matching label wins. A more general tag matches a more specific one — for example, `en` matches `en-GB` — unless the specific tag is itself listed. +- 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`). -- When more than one label matches (for example, two labels tagged `en`), the alphabetically first is chosen. +- 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. This option currently applies only to the `pretty` format; the `markdown` and `html` formats are not yet affected. diff --git a/robot-core/src/main/java/org/obolibrary/robot/LanguagePreference.java b/robot-core/src/main/java/org/obolibrary/robot/LanguagePreference.java index 4b9b7f6b0..6fd1cd53b 100644 --- a/robot-core/src/main/java/org/obolibrary/robot/LanguagePreference.java +++ b/robot-core/src/main/java/org/obolibrary/robot/LanguagePreference.java @@ -11,15 +11,18 @@ *

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

    - *
  1. Prefer a value whose language tag matches an entry earlier in the preference list. - *
  2. An exact language-tag match is preferred over a cascade match (e.g. a - * preference of {@code en} matches a value tagged {@code en-GB}); among cascade matches, a - * more specific preference entry (e.g. {@code en-GB}) is preferred over a less specific one - * (e.g. {@code en}). Consequently {@code en} only "captures" {@code en-GB} when {@code en-GB} - * is not itself listed. - *
  3. Among values that tie on language, the alphanumerically-first value is chosen (a - * deterministic tie-break, matching ROBOT's existing convention in {@code - * OntologyHelper.getAnnotationString}). + *
  4. 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}. + *
  5. 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}. + *
  6. 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}). *
  7. 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. @@ -158,13 +161,23 @@ public static String selectFallback(List candidates) { /** * 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 key components are, in order: + * 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 or cascade), 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 returned key then ranks that binding for the cross-value comparison, components in + * order: * *

      - *
    1. match type: 0 for an exact match, 1 for a cascade (prefix) match; - *
    2. specificity: 0 for exact, otherwise the negated length of the matching prefix so that a - * longer (more specific) preference entry ranks better; - *
    3. the index of the matching entry in the preference list. + *
    4. the index of the bound entry, so that a label bound to an earlier entry wins; + *
    5. 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) @@ -175,24 +188,32 @@ 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[] best = null; + int bestIndex = -1; + int bestType = 0; + int bestSpecificity = -1; // length of the matched preference tag; longer = more specific for (int i = 0; i < preferredLangs.size(); i++) { String pref = preferredLangs.get(i).toLowerCase(Locale.ROOT); - int[] key; + int type; if (lowerLang.equals(pref)) { - // Exact match. - key = new int[] {0, 0, i}; + type = 0; // exact } else if (!pref.isEmpty() && lowerLang.startsWith(pref + "-")) { - // Cascade match: a broader preference matches a more specific tag. - key = new int[] {1, -pref.length(), i}; + type = 1; // cascade: a broader preference matches a more specific tag } else { continue; } - if (best == null || compare(key, best) < 0) { - best = key; + // 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. + int specificity = pref.length(); + if (specificity > bestSpecificity || (specificity == bestSpecificity && type < bestType)) { + bestSpecificity = specificity; + bestType = type; + bestIndex = i; } } - return best; + if (bestIndex < 0) { + return null; + } + return new int[] {bestIndex, bestType}; } /** diff --git a/robot-core/src/test/java/org/obolibrary/robot/LanguagePreferenceTest.java b/robot-core/src/test/java/org/obolibrary/robot/LanguagePreferenceTest.java index 8650e9dcf..ac8eb1c92 100644 --- a/robot-core/src/test/java/org/obolibrary/robot/LanguagePreferenceTest.java +++ b/robot-core/src/test/java/org/obolibrary/robot/LanguagePreferenceTest.java @@ -51,24 +51,70 @@ public void testCascadeMatch() { assertEquals("colour", LanguagePreference.selectValue(cs, Collections.singletonList("en"))); } - /** An exact match beats a cascade match regardless of list position. */ + /** Within a single preference entry, an exact match beats a cascade match. */ @Test public void testExactBeatsCascade() { - List cs = candidates("en-GB", "colour", "en", "generic"); - // "en" (index 0) cascades to en-GB, but the exact "en-GB" (index 1) wins. - assertEquals("generic", LanguagePreference.selectValue(cs, Arrays.asList("en", "en-GB"))); - // The en-GB literal should be attributed to its own exact entry, not swept up by "en". + // 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"))); } - /** Among cascade matches, the more specific preference entry wins. */ + // 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 testCascadeSpecificity() { - List cs = candidates("en-GB-oxendict", "posh"); - // Both "en" and "en-GB" cascade; the more specific "en-GB" should be chosen even though "en" - // appears earlier in the list. - assertEquals("posh", LanguagePreference.selectValue(cs, Arrays.asList("en", "en-GB"))); + 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 no-lang token matches an untagged literal. */ From 65d42af3e18f2ff6d59bbc9a4ee5263fe5e129d8 Mon Sep 17 00:00:00 2001 From: Jeff Lerman Date: Sun, 5 Jul 2026 23:40:16 -0700 Subject: [PATCH 6/6] add wildcard support and document relationship to RFC-4647 --- docs/diff.md | 3 ++ .../obolibrary/robot/LanguagePreference.java | 43 ++++++++++++++----- .../robot/LanguagePreferenceTest.java | 25 +++++++++++ 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/docs/diff.md b/docs/diff.md index 352ae75ed..6b317e604 100644 --- a/docs/diff.md +++ b/docs/diff.md @@ -47,9 +47,12 @@ Here the `dog` class carries `de`, `en`, and `en-GB` labels; with `en-GB,en` its - 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/robot-core/src/main/java/org/obolibrary/robot/LanguagePreference.java b/robot-core/src/main/java/org/obolibrary/robot/LanguagePreference.java index 6fd1cd53b..d791873c0 100644 --- a/robot-core/src/main/java/org/obolibrary/robot/LanguagePreference.java +++ b/robot-core/src/main/java/org/obolibrary/robot/LanguagePreference.java @@ -16,7 +16,8 @@ * 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}. + * {@code en-US} binds by cascade to {@code en}. The {@link #WILDCARD} ({@code *}) matches any + * tag but is less specific than every real tag. *
  8. 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}. @@ -28,6 +29,12 @@ * 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). * @@ -42,6 +49,12 @@ public class LanguagePreference { /** 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 = ""; @@ -164,12 +177,13 @@ public static String selectFallback(List candidates) { * 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 or cascade), 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. + * 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: @@ -190,20 +204,29 @@ private static int[] matchKey(String lang, List preferredLangs) { String lowerLang = lang.toLowerCase(Locale.ROOT); int bestIndex = -1; int bestType = 0; - int bestSpecificity = -1; // length of the matched preference tag; longer = more specific + // 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; - if (lowerLang.equals(pref)) { + 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. - int specificity = pref.length(); if (specificity > bestSpecificity || (specificity == bestSpecificity && type < bestType)) { bestSpecificity = specificity; bestType = type; diff --git a/robot-core/src/test/java/org/obolibrary/robot/LanguagePreferenceTest.java b/robot-core/src/test/java/org/obolibrary/robot/LanguagePreferenceTest.java index ac8eb1c92..5b7ed60fb 100644 --- a/robot-core/src/test/java/org/obolibrary/robot/LanguagePreferenceTest.java +++ b/robot-core/src/test/java/org/obolibrary/robot/LanguagePreferenceTest.java @@ -117,6 +117,31 @@ public void testMostSpecificCascadeBinding() { 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() {