This is an automated email from the ASF dual-hosted git repository.

rzo1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opennlp.git


The following commit(s) were added to refs/heads/main by this push:
     new e286928a1 GH-1458: Parse name-finder tags and parse-tree strings 
without regular expressions (#1459)
e286928a1 is described below

commit e286928a162a8097ced8c11fe9c433843de8fc3e
Author: Richard Zowalla <[email protected]>
AuthorDate: Fri Oct 9 19:51:20 2026 +0200

    GH-1458: Parse name-finder tags and parse-tree strings without regular 
expressions (#1459)
    
    * GH-1458: Parse name-finder tags and parse-tree strings without regular 
expressions
    
    * GH-1458: Name the separator characters of start tags and parse strings
    
    Replaces the character literals in the new scans with named constants and 
reuses
    UnicodeWhitespace.SPACE and CARRIAGE_RETURN in StringUtil.isAsciiWhitespace.
    
    * GH-1458: Split the start tag and constituent oracle tests by outcome
    
    Valid inputs, the rejected empty type and invalid inputs are now separate 
tests with
    explicit expected values; the former regular expressions remain as 
cross-checks.
    
    * GH-1458: Use TAG_END for the closing character of a start tag
    
    * GH-1458: Phrase the start tag check as Checks whether
---
 .../java/opennlp/tools/namefind/NameSample.java    |  50 +++++-
 .../src/main/java/opennlp/tools/parser/Parse.java  | 109 +++++++++----
 .../main/java/opennlp/tools/util/StringUtil.java   |  16 ++
 .../java/opennlp/tools/util/StringUtilTest.java    |  26 ++-
 .../opennlp/tools/namefind/NameSampleTest.java     |  67 ++++++++
 .../test/java/opennlp/tools/parser/ParseTest.java  | 181 +++++++++++++++++++++
 6 files changed, 413 insertions(+), 36 deletions(-)

diff --git a/opennlp-api/src/main/java/opennlp/tools/namefind/NameSample.java 
b/opennlp-api/src/main/java/opennlp/tools/namefind/NameSample.java
index 4007a4e78..23bf06f32 100644
--- a/opennlp-api/src/main/java/opennlp/tools/namefind/NameSample.java
+++ b/opennlp-api/src/main/java/opennlp/tools/namefind/NameSample.java
@@ -24,13 +24,12 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
 
 import opennlp.tools.commons.Sample;
 import opennlp.tools.tokenize.WhitespaceTokenizer;
 import opennlp.tools.util.ParamChecks;
 import opennlp.tools.util.Span;
+import opennlp.tools.util.StringUtil;
 
 /**
  * Encapsulates names for a single unit of text.
@@ -44,6 +43,12 @@ public class NameSample implements Sample {
   public static final String START_TAG = "<START>";
   public static final String END_TAG = "<END>";
 
+  /** Separates {@code <START} from the name type in a start tag. */
+  private static final char TYPE_SEPARATOR = ':';
+
+  /** Ends a start tag. */
+  private static final char TAG_END = '>';
+
 
   private final String id;
   private final List<String> sentence;
@@ -266,8 +271,42 @@ public class NameSample implements Sample {
     return errorString.toString();
   }
 
-  private static final Pattern START_TAG_PATTERN = 
Pattern.compile("<START(:([^:>\\s]*))?>");
+  /**
+   * Checks whether a token is a start tag, either {@code <START>} or {@code 
<START:type>}, where
+   * the possibly empty type contains neither {@code ':'}, {@code '>'} nor 
ASCII whitespace.
+   *
+   * @param token The token to test.
+   * @return {@code true} if {@code token} is a start tag.
+   */
+  private static boolean isStartTag(String token) {
+    if (START_TAG.equals(token)) {
+      return true;
+    }
+    if (!token.startsWith(START_TAG_PREFIX) || token.length() <= 
START_TAG_PREFIX.length()
+        || token.charAt(token.length() - 1) != TAG_END) {
+      return false;
+    }
+    for (int i = START_TAG_PREFIX.length(); i < token.length() - 1; i++) {
+      char c = token.charAt(i);
+      if (c == TYPE_SEPARATOR || c == TAG_END || 
StringUtil.isAsciiWhitespace(c)) {
+        return false;
+      }
+    }
+    return true;
+  }
 
+  /**
+   * Extracts the type of a start tag accepted by {@link #isStartTag(String)}.
+   *
+   * @param startTag A start tag.
+   * @return The type, which is empty for {@code <START:>}, or {@code null} 
for {@code <START>}.
+   */
+  private static String startTagType(String startTag) {
+    if (START_TAG.equals(startTag)) {
+      return null;
+    }
+    return startTag.substring(START_TAG_PREFIX.length(), startTag.length() - 
1);
+  }
 
   /**
    * Parses given input into a {@link NameSample}.
@@ -310,15 +349,14 @@ public class NameSample implements Sample {
     boolean catchingName = false;
 
     for (int pi = 0; pi < parts.length; pi++) {
-      Matcher startMatcher = START_TAG_PATTERN.matcher(parts[pi]);
-      if (startMatcher.matches()) {
+      if (isStartTag(parts[pi])) {
         if (catchingName) {
           throw new IOException("Found unexpected annotation" +
               " while handling a name sequence: " + 
errorTokenWithContext(parts, pi));
         }
         catchingName = true;
         startIndex = wordIndex;
-        String nameTypeFromSample = startMatcher.group(2);
+        String nameTypeFromSample = startTagType(parts[pi]);
         if (nameTypeFromSample != null) {
           if (nameTypeFromSample.length() == 0) {
             throw new IOException("Missing a name type: " + 
errorTokenWithContext(parts, pi));
diff --git a/opennlp-api/src/main/java/opennlp/tools/parser/Parse.java 
b/opennlp-api/src/main/java/opennlp/tools/parser/Parse.java
index f4e3c2f53..dc921f837 100644
--- a/opennlp-api/src/main/java/opennlp/tools/parser/Parse.java
+++ b/opennlp-api/src/main/java/opennlp/tools/parser/Parse.java
@@ -27,14 +27,13 @@ import java.util.Objects;
 import java.util.Set;
 import java.util.Stack;
 import java.util.TreeSet;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import opennlp.tools.util.ParamChecks;
 import opennlp.tools.util.Span;
+import opennlp.tools.util.StringUtil;
 
 /**
  * Data structure for holding parse constituents.
@@ -49,6 +48,21 @@ public class Parse implements Cloneable, Comparable<Parse> {
   public static final String BRACKET_LSB = "[";
   public static final String BRACKET_RSB = "]";
 
+  /** Opens a constituent in the bracketed parse string. */
+  private static final char OPENING_BRACKET = BRACKET_LRB.charAt(0);
+
+  /** Closes a constituent in the bracketed parse string. */
+  private static final char CLOSING_BRACKET = BRACKET_RRB.charAt(0);
+
+  /** Separates a constituent label from its token. */
+  private static final char LABEL_SEPARATOR = ' ';
+
+  /** Separates a constituent type from its function tag, as in {@code 
NP-SBJ}. */
+  private static final char FUNCTION_TAG_SEPARATOR = '-';
+
+  /** Separates a constituent label from its co-index, as in {@code NP=2}. */
+  private static final char INDEX_SEPARATOR = '=';
+
   /**
    * The text string on which this parse is based.
    * This object is shared among all parses for the same sentence.
@@ -107,22 +121,6 @@ public class Parse implements Cloneable, Comparable<Parse> 
{
    */
   private boolean isChunk;
 
-  /**
-   * The pattern used to find the base constituent label of a
-   * Penn Treebank labeled constituent.
-   */
-  private static final Pattern typePattern = Pattern.compile("^([^ =-]+)");
-
-  /**
-   * The pattern used to find the function tags.
-   */
-  private static final Pattern funTypePattern = Pattern.compile("^[^ =-]+-([^ 
=-]+)");
-
-  /**
-   * The patter used to identify tokens in Penn Treebank labeled constituents.
-   */
-  private static final Pattern tokenPattern = Pattern.compile("^[^ ()]+ ([^ 
()]+)\\s*\\)");
-
   /**
    * The set of punctuation parses which are between this parse and the 
previous parse.
    */
@@ -669,14 +667,13 @@ public class Parse implements Cloneable, 
Comparable<Parse> {
     } else if (rest.startsWith("-NONE-")) {
       return "-NONE-";
     } else {
-      Matcher typeMatcher = typePattern.matcher(rest);
-      if (typeMatcher.find()) {
-        String type = typeMatcher.group(1);
-        if (useFunctionTags) {
-          Matcher funMatcher = funTypePattern.matcher(rest);
-          if (funMatcher.find()) {
-            String ftag = funMatcher.group(1);
-            type = type + "-" + ftag;
+      int typeEnd = endOfLabel(rest, 0);
+      if (typeEnd > 0) {
+        String type = rest.substring(0, typeEnd);
+        if (useFunctionTags && typeEnd < rest.length() && rest.charAt(typeEnd) 
== FUNCTION_TAG_SEPARATOR) {
+          int ftagEnd = endOfLabel(rest, typeEnd + 1);
+          if (ftagEnd > typeEnd + 1) {
+            type = type + FUNCTION_TAG_SEPARATOR + rest.substring(typeEnd + 1, 
ftagEnd);
           }
         }
         return type;
@@ -685,6 +682,47 @@ public class Parse implements Cloneable, Comparable<Parse> 
{
     return null;
   }
 
+  /**
+   * Finds the end of the run of label characters that starts at {@code from}. 
A label
+   * character is any character except space, {@code '='} and {@code '-'}, 
which separate a
+   * constituent label from its function tags and co-index.
+   *
+   * @param text The text to scan.
+   * @param from The offset the run starts at.
+   * @return The offset after the last label character, or {@code from} if 
there is none.
+   */
+  private static int endOfLabel(String text, int from) {
+    int i = from;
+    while (i < text.length()) {
+      char c = text.charAt(i);
+      if (c == LABEL_SEPARATOR || c == INDEX_SEPARATOR || c == 
FUNCTION_TAG_SEPARATOR) {
+        break;
+      }
+      i++;
+    }
+    return i;
+  }
+
+  /**
+   * Finds the end of the run of token characters that starts at {@code from}. 
A token
+   * character is any character except space and parentheses.
+   *
+   * @param text The text to scan.
+   * @param from The offset the run starts at.
+   * @return The offset after the last token character, or {@code from} if 
there is none.
+   */
+  private static int endOfTokenText(String text, int from) {
+    int i = from;
+    while (i < text.length()) {
+      char c = text.charAt(i);
+      if (c == LABEL_SEPARATOR || c == OPENING_BRACKET || c == 
CLOSING_BRACKET) {
+        break;
+      }
+      i++;
+    }
+    return i;
+  }
+
   private static String encodeToken(String token) {
     if (BRACKET_LRB.equals(token)) {
       return "-LRB-";
@@ -727,9 +765,22 @@ public class Parse implements Cloneable, Comparable<Parse> 
{
    * string or {@code null} if the portion of the parse string does not 
represent a token.
    */
   private static String getToken(String rest) {
-    Matcher tokenMatcher = tokenPattern.matcher(rest);
-    if (tokenMatcher.find()) {
-      return decodeToken(tokenMatcher.group(1));
+    // Expected form: type, one space, token, optional ASCII whitespace, 
closing parenthesis.
+    int typeEnd = endOfTokenText(rest, 0);
+    if (typeEnd == 0 || typeEnd == rest.length() || rest.charAt(typeEnd) != 
LABEL_SEPARATOR) {
+      return null;
+    }
+    int tokenStart = typeEnd + 1;
+    int tokenEnd = endOfTokenText(rest, tokenStart);
+    if (tokenEnd == tokenStart) {
+      return null;
+    }
+    int i = tokenEnd;
+    while (i < rest.length() && StringUtil.isAsciiWhitespace(rest.charAt(i))) {
+      i++;
+    }
+    if (i < rest.length() && rest.charAt(i) == CLOSING_BRACKET) {
+      return decodeToken(rest.substring(tokenStart, tokenEnd));
     }
     return null;
   }
diff --git a/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java 
b/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
index 72d34f620..6b7839d10 100644
--- a/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
+++ b/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
@@ -31,6 +31,9 @@ public class StringUtil {
 
   private static final char BYTE_ORDER_MARK = '\uFEFF';
 
+  /** U+0009, the first of the contiguous ASCII whitespace controls U+0009 to 
U+000D. */
+  private static final int CHARACTER_TABULATION = 0x0009;
+
   private static final Logger logger = 
LoggerFactory.getLogger(StringUtil.class);
 
   /**
@@ -383,6 +386,19 @@ public class StringUtil {
     return codePoint >= '0' && codePoint <= '9';
   }
 
+  /**
+   * Tests for an ASCII whitespace character: space, tab, line feed, vertical 
tab, form feed
+   * or carriage return. This is the set matched by {@code \s} in a
+   * {@link java.util.regex.Pattern} compiled without {@code 
UNICODE_CHARACTER_CLASS}.
+   *
+   * @param codePoint The code point to test.
+   * @return {@code true} if {@code codePoint} is an ASCII whitespace 
character.
+   */
+  public static boolean isAsciiWhitespace(int codePoint) {
+    return codePoint == UnicodeWhitespace.SPACE
+        || (codePoint >= CHARACTER_TABULATION && codePoint <= 
UnicodeWhitespace.CARRIAGE_RETURN);
+  }
+
   /**
    * Finds the end of the run of ASCII digits that starts at {@code from}.
    *
diff --git a/opennlp-api/src/test/java/opennlp/tools/util/StringUtilTest.java 
b/opennlp-api/src/test/java/opennlp/tools/util/StringUtilTest.java
index e71ab8675..1aa05bb8b 100644
--- a/opennlp-api/src/test/java/opennlp/tools/util/StringUtilTest.java
+++ b/opennlp-api/src/test/java/opennlp/tools/util/StringUtilTest.java
@@ -20,6 +20,7 @@ package opennlp.tools.util;
 import java.nio.CharBuffer;
 import java.util.Arrays;
 import java.util.List;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 import java.util.stream.Stream;
@@ -202,7 +203,7 @@ public class StringUtilTest {
   }
 
   // -------------------------------------------------------------------------
-  // isAsciiLetter, isAsciiLowerCase, isAsciiDigit, endOfAsciiDigits
+  // isAsciiLetter, isAsciiLowerCase, isAsciiDigit, isAsciiWhitespace, 
endOfAsciiDigits
   // -------------------------------------------------------------------------
 
   @ParameterizedTest
@@ -246,6 +247,29 @@ public class StringUtilTest {
     Assertions.assertFalse(StringUtil.isAsciiDigit(codePoint));
   }
 
+  @ParameterizedTest
+  @ValueSource(ints = {' ', '\t', '\n', 0x0B, '\f', '\r'})
+  void testIsAsciiWhitespaceAccepts(int codePoint) {
+    Assertions.assertTrue(StringUtil.isAsciiWhitespace(codePoint));
+  }
+
+  @ParameterizedTest
+  // the neighbors of the range, the information separators, NEL, no-break 
space,
+  // ideographic space, line separator
+  @ValueSource(ints = {0x08, 0x0E, 0x1C, 0x1F, '!', 0x85, 0xA0, 0x3000, 
0x2028, 0, -1})
+  void testIsAsciiWhitespaceRejects(int codePoint) {
+    Assertions.assertFalse(StringUtil.isAsciiWhitespace(codePoint));
+  }
+
+  @Test
+  void testIsAsciiWhitespaceMatchesRegexClass() {
+    Pattern whitespace = Pattern.compile("\\s");
+    for (int cp = 0; cp <= Character.MAX_CODE_POINT; cp++) {
+      
Assertions.assertEquals(whitespace.matcher(Character.toString(cp)).matches(),
+          StringUtil.isAsciiWhitespace(cp), Integer.toHexString(cp));
+    }
+  }
+
   @ParameterizedTest
   @MethodSource("asciiDigitRuns")
   void testEndOfAsciiDigits(String text, int from, int expected) {
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/namefind/NameSampleTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/namefind/NameSampleTest.java
index 8ea8b879e..1a0526f3d 100644
--- 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/namefind/NameSampleTest.java
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/namefind/NameSampleTest.java
@@ -24,9 +24,13 @@ import java.io.ObjectInput;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutput;
 import java.io.ObjectOutputStream;
+import java.util.regex.Pattern;
 
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import opennlp.tools.util.Span;
 
@@ -302,6 +306,69 @@ public class NameSampleTest {
     return createSimpleNameSample(false);
   }
 
+  /**
+   * The regular expression that matched start tags before 3.0.0. The tests 
below assert that
+   * it agrees with the current parser on every input.
+   */
+  private static final Pattern START_TAG_ORACLE = 
Pattern.compile("<START(:([^:>\\s]*))?>");
+
+  /**
+   * Checks that a valid start tag opens a name of the given type. A tag 
without a type uses
+   * the default type passed to the parser.
+   *
+   * @param tag The start tag.
+   * @param expectedType The expected name type.
+   * @throws IOException Thrown if parsing fails.
+   */
+  @ParameterizedTest
+  @CsvSource(delimiter = '|', value = {
+      "<START>|fallback",
+      "<START:person>|person",
+      "<START:type-1>|type-1",
+      "<START:NP-SBJ=2>|NP-SBJ=2",
+      "<START:a.b_c/d;e,f&g%h$i>|a.b_c/d;e,f&g%h$i",
+      "<START:a\u001Cb>|a\u001Cb",
+      "<START:\u00E9t\u00E9>|\u00E9t\u00E9",
+      "<START:\uD83D\uDE00>|\uD83D\uDE00"})
+  void testValidStartTagOpensName(String tag, String expectedType) throws 
IOException {
+    Assertions.assertTrue(START_TAG_ORACLE.matcher(tag).matches());
+
+    NameSample sample = NameSample.parse(tag + " word <END>", "fallback", 
false);
+    Assertions.assertArrayEquals(new String[] {"word"}, sample.getSentence());
+    Assertions.assertArrayEquals(new Span[] {new Span(0, 1, expectedType)}, 
sample.getNames());
+  }
+
+  /**
+   * Checks that a start tag with an empty type is rejected.
+   */
+  @Test
+  void testStartTagWithEmptyTypeIsRejected() {
+    Assertions.assertTrue(START_TAG_ORACLE.matcher("<START:>").matches());
+
+    Assertions.assertThrows(IOException.class,
+        () -> NameSample.parse("<START:> word <END>", false));
+  }
+
+  /**
+   * Checks that a token which is not a valid start tag is kept as an ordinary 
token and does
+   * not open a name.
+   *
+   * @param token The token that resembles a start tag.
+   * @throws IOException Thrown if parsing fails.
+   */
+  @ParameterizedTest
+  @ValueSource(strings = {
+      "<START:a:b>", "<START::>", "<START:a>b>", "<START:a>>", "<START>>", 
"<START>a>",
+      "<START", "<START:", "<START:person", "START:person>", "<start:person>", 
"<STARTX>",
+      "<START;person>", "x<START>", "<START:person>x", "<END:x>"})
+  void testInvalidStartTagIsKeptAsToken(String token) throws IOException {
+    Assertions.assertFalse(START_TAG_ORACLE.matcher(token).matches());
+
+    NameSample sample = NameSample.parse(token + " word", false);
+    Assertions.assertArrayEquals(new String[] {token, "word"}, 
sample.getSentence());
+    Assertions.assertEquals(0, sample.getNames().length);
+  }
+
   @Test
   void testRejectsNullSentence() {
     IllegalArgumentException e = 
Assertions.assertThrows(IllegalArgumentException.class,
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/parser/ParseTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/parser/ParseTest.java
index bdf5b6de5..a88f571cc 100644
--- 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/parser/ParseTest.java
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/parser/ParseTest.java
@@ -17,8 +17,17 @@
 
 package opennlp.tools.parser;
 
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import opennlp.tools.util.Span;
 
@@ -176,4 +185,176 @@ public class ParseTest {
     Assertions.assertEquals(1, p.getChildren().length);
     Assertions.assertEquals(new Span(0, 5), p.getChildren()[0].getSpan());
   }
+
+  /**
+   * Reference expressions for the constituent label, the function tag and the 
token of a
+   * tree-bank constituent before 3.0.0; the constituent tests below assert 
that they agree
+   * with the current parser.
+   */
+  private static final Pattern TYPE_ORACLE = Pattern.compile("^([^ =-]+)");
+  private static final Pattern FUNCTION_TAG_ORACLE = Pattern.compile("^[^ 
=-]+-([^ =-]+)");
+  private static final Pattern TOKEN_ORACLE = Pattern.compile("^[^ ()]+ ([^ 
()]+)\\s*\\)");
+
+  private static final Map<String, String> BRACKETS = Map.of("-LRB-", "(", 
"-RRB-", ")",
+      "-LCB-", "{", "-RCB-", "}", "-LSB-", "[", "-RSB-", "]");
+
+  /**
+   * Supplies constituents with a valid label: the input after the opening 
parenthesis, the
+   * expected label without and with function tags, and the expected token.
+   *
+   * @return The constituents and their expected label and token.
+   */
+  private static Stream<Arguments> constituentLabels() {
+    return Stream.of(
+        Arguments.of("NN word)", "NN", "NN", "word"),
+        Arguments.of("NP-SBJ word)", "NP", "NP-SBJ", "word"),
+        Arguments.of("NP-SBJ=2 word)", "NP", "NP-SBJ", "word"),
+        Arguments.of("NP-SBJ-1 word)", "NP", "NP-SBJ", "word"),
+        Arguments.of("NP-SBJ-TMP=3 word)", "NP", "NP-SBJ", "word"),
+        Arguments.of("NP=2 word)", "NP", "NP", "word"),
+        Arguments.of("NP--X word)", "NP", "NP", "word"),
+        Arguments.of("NP- word)", "NP", "NP", "word"),
+        Arguments.of("PRP$ her)", "PRP$", "PRP$", "her"),
+        Arguments.of("-NONE- *T*-1)", "-NONE-", "-NONE-", "*T*-1"),
+        Arguments.of("-LRB- -LRB-)", "-LRB-", "-LRB-", "("),
+        Arguments.of("-RRB- -RRB-)", "-RRB-", "-RRB-", ")"),
+        Arguments.of("-LCB- -LCB-)", "-LCB-", "-LCB-", "{"),
+        Arguments.of("-RSB- -RSB-)", "-RSB-", "-RSB-", "]"),
+        Arguments.of(". .)", ".", ".", "."),
+        Arguments.of(", ,)", ",", ",", ","),
+        Arguments.of("NN a=b-c)", "NN", "NN", "a=b-c"));
+  }
+
+  /**
+   * Checks the label and token of a valid constituent, without and with 
function tags.
+   *
+   * @param rest The constituent text after the opening parenthesis.
+   * @param type The expected label without function tags.
+   * @param typeWithFunctionTag The expected label with function tags.
+   * @param token The expected token.
+   */
+  @ParameterizedTest
+  @MethodSource("constituentLabels")
+  void testConstituentLabel(String rest, String type, String 
typeWithFunctionTag, String token) {
+    Assertions.assertEquals(type, oracleType(rest, false));
+    Assertions.assertEquals(typeWithFunctionTag, oracleType(rest, true));
+
+    assertConstituent(rest, false, type, token);
+    assertConstituent(rest, true, typeWithFunctionTag, token);
+  }
+
+  /**
+   * Supplies constituents whose token is followed by whitespace or contains 
non-ASCII
+   * characters: the input after the opening parenthesis and the expected 
token. Only ASCII
+   * whitespace between the token and the closing parenthesis is skipped.
+   *
+   * @return The constituents and their expected token.
+   */
+  private static Stream<Arguments> constituentTokens() {
+    return Stream.of(
+        Arguments.of("NN word )", "word"),
+        Arguments.of("NN word   )", "word"),
+        Arguments.of("NN word \t)", "word"),
+        Arguments.of("NN word \u000B\f\r\n)", "word"),
+        Arguments.of("NN word\t)", "word\t"),
+        Arguments.of("NN word )", "word "),
+        Arguments.of("NN été)", "été"),
+        Arguments.of("NN 😀)", "😀"));
+  }
+
+  /**
+   * Checks the token of a constituent whose token ends in whitespace or 
contains non-ASCII
+   * characters.
+   *
+   * @param rest The constituent text after the opening parenthesis.
+   * @param token The expected token.
+   */
+  @ParameterizedTest
+  @MethodSource("constituentTokens")
+  void testConstituentToken(String rest, String token) {
+    Assertions.assertEquals(token, oracleToken(rest));
+
+    assertConstituent(rest, false, "NN", token);
+    assertConstituent(rest, true, "NN", token);
+  }
+
+  /**
+   * Checks that an invalid constituent yields no node, without and with 
function tags.
+   *
+   * @param rest The constituent text after the opening parenthesis.
+   */
+  @ParameterizedTest
+  @ValueSource(strings = {
+      "NN word  )", "NN word \u001C)", "NN word x)", "NN  word)", "NN)", "NN 
word",
+      "NN word ", "NN ", "NN"})
+  void testInvalidConstituentYieldsNoNode(String rest) {
+    Assertions.assertNull(oracleToken(rest));
+
+    for (boolean functionTags : new boolean[] {false, true}) {
+      Parse.useFunctionTags(functionTags);
+      try {
+        Assertions.assertEquals(0, Parse.parseParse("(" + 
rest).getChildren().length, rest);
+      } finally {
+        Parse.useFunctionTags(false);
+      }
+    }
+  }
+
+  /**
+   * Parses a single constituent and checks that it yields one node with the 
given label and
+   * token.
+   *
+   * @param rest The constituent text after the opening parenthesis.
+   * @param functionTags Whether function tags are appended to the label.
+   * @param type The expected label.
+   * @param token The expected token.
+   */
+  private static void assertConstituent(String rest, boolean functionTags, 
String type,
+                                        String token) {
+    Parse.useFunctionTags(functionTags);
+    try {
+      Parse p = Parse.parseParse("(" + rest);
+      Assertions.assertEquals(1, p.getChildren().length, rest);
+      Assertions.assertEquals(type, p.getChildren()[0].getType(), rest);
+      Assertions.assertEquals(token, p.getChildren()[0].getCoveredText(), 
rest);
+    } finally {
+      Parse.useFunctionTags(false);
+    }
+  }
+
+  /**
+   * Computes the expected label of a constituent with the reference 
expressions.
+   *
+   * @param rest The constituent text after the opening parenthesis.
+   * @param functionTags Whether function tags are appended to the label.
+   * @return The expected label, or {@code null} if there is none.
+   */
+  private static String oracleType(String rest, boolean functionTags) {
+    for (String bracket : new String[] {"-LCB-", "-RCB-", "-LRB-", "-RRB-", 
"-RSB-", "-LSB-",
+        "-NONE-"}) {
+      if (rest.startsWith(bracket)) {
+        return bracket;
+      }
+    }
+    Matcher type = TYPE_ORACLE.matcher(rest);
+    if (!type.find()) {
+      return null;
+    }
+    Matcher functionTag = FUNCTION_TAG_ORACLE.matcher(rest);
+    if (functionTags && functionTag.find()) {
+      return type.group(1) + "-" + functionTag.group(1);
+    }
+    return type.group(1);
+  }
+
+  /**
+   * Computes the expected token of a constituent with the reference 
expression.
+   *
+   * @param rest The constituent text after the opening parenthesis.
+   * @return The expected decoded token, or {@code null} if there is none.
+   */
+  private static String oracleToken(String rest) {
+    Matcher token = TOKEN_ORACLE.matcher(rest);
+    return token.find() ? BRACKETS.getOrDefault(token.group(1), 
token.group(1)) : null;
+  }
 }

Reply via email to