Skip to content

ICU-23431 Add link detection/formatting tools#4048

Open
macchiati wants to merge 10 commits into
mainfrom
ICU-23431-Add-link-detection/formatting-tools
Open

ICU-23431 Add link detection/formatting tools#4048
macchiati wants to merge 10 commits into
mainfrom
ICU-23431-Add-link-detection/formatting-tools

Conversation

@macchiati

@macchiati macchiati commented Jun 27, 2026

Copy link
Copy Markdown
Member

ICU-23431

Notes

  • The bulk of the code is in an impl class, ported from UTS58. It could use some further cleanup (notably some code that is unused), but I suggest doing that in a later PR.
  • If and when the right properties are available in ICU, then hardcoded internals can be changed. This might not happen in v79.1, depending on resources. If not, it needs to be done before the v80.1.
  • There are three test cases that need to be fixed in UTS58 test data; those are listed at the top.
    • two of them are in minimal escaping; where the result is slightly less minimal than possible
    • one is in detection, because UTS48 checks for valid TLDs, and the testData should be neutral in that regard
    • when those are fixed (ideally in v18), the test data needs to be regenerated
  • UTS58 should have some additional description of certain strategies it uses for detection
    • local-parts and domain names are considered broken if they have bad dots:
      • local-parts: abc..def or .abc.def or abc.def.
      • domain names .abc.com or .abc..com
      • Broken parts are completely skipped. Eg john..smith.com does not detect as john.smith.com
    • domain names are also broken if they are invalid according to UTS46.
    • if there is a broken local-part in front of an @ or domain name after the @, then the whole is skipped.
      • Eg john..smith@example.com is not detected as john..smith@example.com

Checklist

  • Required: Issue filed: ICU-23431
  • Required: The PR title must be prefixed with a JIRA Issue number. Example: "ICU-NNNNN Fix xyz"
  • Required: Each commit message must be prefixed with a JIRA Issue number. Example: "ICU-NNNNN Fix xyz"
  • Issue accepted (done by Technical Committee after discussion)
  • Tests included, if applicable
  • API docs and/or User Guide docs changed or added, if applicable
  • Approver: Feel free to merge on my behalf

@macchiati
macchiati marked this pull request as draft June 27, 2026 10:19
@macchiati
macchiati marked this pull request as ready for review July 3, 2026 09:43
@macchiati
macchiati requested a review from eggrobin July 3, 2026 10:09
@macchiati

Copy link
Copy Markdown
Member Author

All, I think it is ready for review; it passes the UTS58 test data (with 3 cases noted in the description). I plan on further cleanup (mostly unused code), but suggest doing than in a subsequent PR.

@macchiati

Copy link
Copy Markdown
Member Author

Also, I wasn't quite sure about the conventions for where to put files nowadays (It's be a long time since I contributed to ICU!)

@richgillam richgillam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got to about line 200 of LinkHandlingUtilities before I gave up. My comments may or may not be helpful, but I don't think I ever really got into the meat of this class. Maybe I'm not a good reviewer for this stuff because all my Java experience is woefully out of date and I'm completely unfamiliar with more modern facilities and idioms. Maybe it's also that I chose to read this PR from top to bottom, so I was looking at utility classes before I ever got to the stuff that actually uses those utility classes. All I know is that I fairly quickly had no idea what was going on.

I think you probably want at least one reviewer more conversant in Java than I am at this point, such as @yumaoka , @echeran , or @mihnita .

PATH(QUERY, "[?\\^`\\{\\}]"),

/**
* path and<br>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing space?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are pasted in from the documentation in https://url.spec.whatwg.org/#percent-encoded-bytes just as a guide to the meaning.

The javadoc reads:

path and
U+002F (/), U+003A (:), U+003B (;), U+003D (=), U+0040 (@), U+005B ([) to U+005D (]), inclusive, and U+007C (|).

it is referring to the two arguments of the enum constructor, the path and a bunch of characters (in a UnicodeSet, so requiring \ quotes in Java).

USERINFO(PATH, "[/:;=@\[-\]|]"),

Hard("[\\p{whitespace}\\p{NChar}[\\p{C}-\\p{Cf}]\\p{deprecated}]"),
Soft("[\\p{Term}\\p{lb=qu}-\\p{deprecated}]"),
Close("[\\p{Bidi_Paired_Bracket_Type=Close}[>]-\\p{deprecated}]"),
Open("[\\p{Bidi_Paired_Bracket_Type=Open}[<]-\\p{deprecated}]"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's just me, but I had a hard time following all the brackets and subtractions in the Unicode set descriptions above. I think the addition of the ASCII < and > signs kind of exacerbated that. I wonder if parentheses can be used to clarify this, or constants that add the < and > symbols to the paired-bracket stuff they're always used with, or (failing all that) textual descriptions can be added?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that has a comment /** Defines the LinkTermination property TODO use real property */

It will replaces by calls on real ICU properties, once they are there.

The UnicodeSet syntax does get a bit ugly in Java because of the double escapes.

"[\\p{Bidi_Paired_Bracket_Type=Close}[>]-\\p{deprecated}]" is

\p{Bidi_Paired_Bracket_Type=Close}
union
[>]
set-difference
\p{deprecated}

That copies the spec for the derivation, but will be moot once we have real properties.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be easier to read (opinion), and allows comments:

    String s2 = "["
            + "\\p{Bidi_Paired_Bracket_Type=Close}"
            + "[>]"
            + "-" // set difference
            + "\\p{deprecated}"
            + "]"
            ;

return cp == '>' ? '<' : UCharacter.getBidiPairedBracket(cp);
}

/** Defines the LinkTermination property TODO use real property */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the properties you refer to in the TODOs real Unicode properties? Does Markus's recent Unicode 18 update add them?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are added to the extended UCD in version 18, but are not yet supported in ICU

Include(null), // all else
;

public final UnicodeSet base;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is always going to be the empty set, right? What does this declaration get you?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The base is set by the constructor, just below.

Enums in java have more features than in many other languages, with methods and fields.

} else {
this.base = new UnicodeSet(uset).freeze();
SOFAR.addAll(this.base);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm reading this class right, there's an undocumented order dependency here, where this constructor will only do the right thing if Include is the last constant in the enum. Is this okay, and should it at least be documented? And is SOFAR used anywhere else, or is it just an implementation detail of the construction of LinkTermination? If it's just used here, why is it defined so far away? And should the name be in all caps when it doesn't appear to actually be a constant?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a big ugly, ’tis true. Will disappear with the real properties.

It is basically to all the 'base' to be final in the enum. The code accumulates all the other values and then complements then for the final INCLUDE - which is basically "every other code point"

PROPERTY_MAP.putAll(lt.base, lt);
}
PROPERTY_MAP.freeze();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LinkTermination is hard-coded and should always construct the same thing, right? Is this consistency check something you want to be doing at run time?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is left over from the unicodetools code that has to derive the values. It will be replaced once we have a real property, but is a nice check to have in the meantime.

new UnicodeSet("[[a-zA-Z][0-9][_ \\- ! ? ' \\{ \\} * / \\& # % ` \\^ + = | ~ \\$]]")
.add('.')
.freeze();
static final UnicodeSet validEmailLocalPart =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't both this variable and the one right above it have all-caps names to indicate they're constants? Is EMAIL_ASCII_INCLUDES used in other places, or is it only part of the process of building validEmailLocalPart? If the latter, maybe consolidate them?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I changed the second to be all upper.

I hadn't marked these as temporary; added a comment.

    /** 
     * These will be replaced once we have a real property value in ICU.
     */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hadn't marked these as temporary; added a comment.

I've seen in other comments "will replace when we have real properties"
What do you think about adding some TODOs, that are easier to grep?


static final UnicodeSet EMAIL_ASCII_INCLUDES =
new UnicodeSet("[[a-zA-Z][0-9][_ \\- ! ? ' \\{ \\} * / \\& # % ` \\^ + = | ~ \\$]]")
.add('.')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the period being added separately?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to be very obvious. I have the comment above.

// We add dot (ascii '.'), and then check after for the special dot constraints.

}

Structure(String sub, String prefix) {
this(sub, prefix, null, null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So you're putting prefix in the sub2 spot? That feels weird...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed the fields to be:

        public final String sub;
        public final String prefix;
        public final String sub2; // only used for Query, with 2 values
        public final String prefix2;

Is that clearer?

single,
listP("/", "𝑷"),
listF(":~:", "𝑭"), // directive
listQ2("&", "𝑸", "=", "𝑽");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do the math alphanumeric characters in the constants above have some kind of special meaning?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, they are used in the test data, to stand out clearly.

@macchiati

macchiati commented Jul 8, 2026 via email

Copy link
Copy Markdown
Member Author

@richgillam

Copy link
Copy Markdown
Contributor

The property construction stuff is temporary, until Markus can add access to the real properties. He is stretched right now and may not be able to handle that before release.

Maybe it's enough to just say that in a TODO comment (or maybe you already did)...

@macchiati

Copy link
Copy Markdown
Member Author

I think you probably want at least one reviewer more conversant in Java than I am at this point, such as @yumaoka , @echeran , or @mihnita .

Good suggestions

*/
COMPONENT(USERINFO, "[\\$-\\&+,]");

public final UnicodeSet set;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: private + setter

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


/** Defines the LinkTermination property TODO use real property */
private enum LinkTermination {
Hard("[\\p{whitespace}\\p{NChar}[\\p{C}-\\p{Cf}]\\p{deprecated}]"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uppercase (HARD, SOFT, ...)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not really important, since this will be replaced by real properties. but did it anyway

* Based on https://url.spec.whatwg.org/#percent-encoded-bytes. Is the default for maximal
* encoding, but can be customized
*/
private enum WHATWG_PERCENT_ENCODED {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Camel case
WhatwgPercentEncoded or WhatWgPercentEncoded

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Include(null), // all else
;

public final UnicodeSet base;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: private + setter

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

toAppendTo.append(fixed);
}

/** We are guaranteed that string is all percent escaped utf8, %a3%c0 ... */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is everything escaped, including the ASCII range?
Because if not (for example "%a3%c0A%a3%c1") then I can see problems.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is used in the following:

            int start = toEscape.span(source, lastEnd, SpanCondition.NOT_CONTAINED);
            appendCheckingPercent(result, source, lastEnd, start);
            if (start == source.length()) {
                break;
            }

So it is checking for just certain characters.

Comment on lines 146 to +362
@@ -179,6 +192,7 @@ public Entry<K, V> next() {
Entry<K, Set<V>> e = it1.next();
entry.key = e.getKey();
it2 = e.getValue().iterator();
entry.value = it2.next();
}
return entry;
}
@@ -195,8 +209,8 @@ public void remove() {
}

private static class ReusableEntry<K, V> implements Entry<K, V> {
K key;
V value;
private K key;
private V value;

@Override
public K getKey() {
@@ -314,6 +328,10 @@ public Splitter(char c) {
this(Pattern.compile("\\Q" + c + "\\E"));
}

public Splitter(String s) {
this(Pattern.compile("\\Q" + s + "\\E"));
}

public Splitter(Pattern p) {
pattern = p;
}
@@ -322,6 +340,10 @@ public static Splitter on(char c) {
return new Splitter(c);
}

public static Splitter on(String sub) {
return new Splitter(sub);
}

public static Splitter on(Pattern p) {
return new Splitter(p);
}
@@ -336,6 +358,10 @@ public List<String> splitToList(String input) {
return Arrays.asList(items);
}

public Stream<String> splitToStream(String input) {
return splitToList(input).stream();

@mihnita mihnita Jul 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

splitToList creates an array, and from that it creates a list, to create a stream here.

We can create a stream directly:

return pattern.splitAsStream(input);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is old code, and I don't want to touch it except for errors.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm talking about public Stream<String> splitToStream(String input) { ... }, which GitHub shows as new method.

It calls splitToList which does a pattern.split to an array, uses that to build a List, and finally we make a stream from it.

pattern.splitAsStream(input) makes a stream directly.

}

/** Utilities because regular java doesn't have some nice Guava features */
public static <T> Set<T> SetofOrdered(T... source) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public static <T> Set<T> SetofOrdered(T... source) {
public static <T> Set<T> setOfOrdered(T... source) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

/** Not as good as Guava, since it makes a new set instead of a view */
public static <T> Set<T> setsDifference(Set<T> set1, Set<T> set2) {
return set1.stream()
.filter(element -> !set2.contains(element))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional:

Suggested change
.filter(element -> !set2.contains(element))
.filter(Predicate.not(set2::contains))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@macchiati
macchiati requested a review from mihnita July 19, 2026 14:44
@macchiati

Copy link
Copy Markdown
Member Author

I'd like to get this merged.

@mihnita mihnita left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a few nitpicks left, but if there is a rush then it's good enough.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants