diff --git a/scripts/jextract.ps1 b/scripts/jextract.ps1 index 2cdb01c..3c38f1a 100755 --- a/scripts/jextract.ps1 +++ b/scripts/jextract.ps1 @@ -2,16 +2,21 @@ $package = 'io.github.treesitter.jtreesitter.internal' $output = "$($args[1])/generated-sources/jextract" $lib = "$($args[0])/core/lib" -& jextract.bat ` +& jextract.ps1 ` --include-struct TSInput ` --include-struct TSInputEdit ` --include-struct TSLogger ` --include-struct TSNode ` + --include-struct TSParseOptions ` + --include-struct TSParseState ` --include-struct TSPoint ` --include-struct TSQueryCapture ` + --include-struct TSQueryCursorOptions ` + --include-struct TSQueryCursorState ` --include-struct TSQueryMatch ` --include-struct TSQueryPredicateStep ` --include-struct TSQueryPredicateStepType ` + --include-struct TSLanguageMetadata ` --include-struct TSRange ` --include-struct TSTreeCursor ` --include-function free ` @@ -19,18 +24,22 @@ $lib = "$($args[0])/core/lib" --include-function calloc ` --include-function realloc ` --include-function ts_set_allocator ` + --include-function ts_language_abi_version ` --include-function ts_language_copy ` --include-function ts_language_delete ` --include-function ts_language_field_count ` --include-function ts_language_field_id_for_name ` --include-function ts_language_field_name_for_id ` + --include-function ts_language_metadata ` + --include-function ts_language_name ` --include-function ts_language_next_state ` --include-function ts_language_state_count ` + --include-function ts_language_subtypes ` + --include-function ts_language_supertypes ` --include-function ts_language_symbol_count ` --include-function ts_language_symbol_for_name ` --include-function ts_language_symbol_name ` --include-function ts_language_symbol_type ` - --include-function ts_language_version ` --include-function ts_lookahead_iterator_current_symbol ` --include-function ts_lookahead_iterator_current_symbol_name ` --include-function ts_lookahead_iterator_delete ` @@ -90,6 +99,7 @@ $lib = "$($args[0])/core/lib" --include-function ts_parser_parse ` --include-function ts_parser_parse_string ` --include-function ts_parser_parse_string_encoding ` + --include-function ts_parser_parse_with_options ` --include-function ts_parser_print_dot_graphs ` --include-function ts_parser_reset ` --include-function ts_parser_set_cancellation_flag ` @@ -104,6 +114,7 @@ $lib = "$($args[0])/core/lib" --include-function ts_query_cursor_delete ` --include-function ts_query_cursor_did_exceed_match_limit ` --include-function ts_query_cursor_exec ` + --include-function ts_query_cursor_exec_with_options ` --include-function ts_query_cursor_match_limit ` --include-function ts_query_cursor_new ` --include-function ts_query_cursor_next_capture ` @@ -157,7 +168,9 @@ $lib = "$($args[0])/core/lib" --include-function ts_tree_root_node_with_offset ` --include-constant TREE_SITTER_LANGUAGE_VERSION ` --include-constant TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION ` - --include-constant TSInputEncodingUTF16 ` + --include-constant TSInputEncodingCustom ` + --include-constant TSInputEncodingUTF16BE ` + --include-constant TSInputEncodingUTF16LE ` --include-constant TSInputEncodingUTF8 ` --include-constant TSLogTypeLex ` --include-constant TSLogTypeParse ` @@ -180,6 +193,7 @@ $lib = "$($args[0])/core/lib" --include-constant TSSymbolTypeAuxiliary ` --include-constant TSSymbolTypeRegular ` --include-constant TSSymbolTypeSupertype ` + --include-typedef DecodeFunction ` --header-class-name TreeSitter ` --output $output ` -t $package ` diff --git a/scripts/jextract.sh b/scripts/jextract.sh index a31a2a6..a280eb0 100755 --- a/scripts/jextract.sh +++ b/scripts/jextract.sh @@ -9,11 +9,16 @@ exec jextract \ --include-struct TSInputEdit \ --include-struct TSLogger \ --include-struct TSNode \ + --include-struct TSParseOptions \ + --include-struct TSParseState \ --include-struct TSPoint \ --include-struct TSQueryCapture \ + --include-struct TSQueryCursorOptions \ + --include-struct TSQueryCursorState \ --include-struct TSQueryMatch \ --include-struct TSQueryPredicateStep \ --include-struct TSQueryPredicateStepType \ + --include-struct TSLanguageMetadata \ --include-struct TSRange \ --include-struct TSTreeCursor \ --include-function free \ @@ -21,18 +26,22 @@ exec jextract \ --include-function calloc \ --include-function realloc \ --include-function ts_set_allocator \ + --include-function ts_language_abi_version \ --include-function ts_language_copy \ --include-function ts_language_delete \ --include-function ts_language_field_count \ --include-function ts_language_field_id_for_name \ --include-function ts_language_field_name_for_id \ + --include-function ts_language_metadata \ + --include-function ts_language_name \ --include-function ts_language_next_state \ --include-function ts_language_state_count \ + --include-function ts_language_subtypes \ + --include-function ts_language_supertypes \ --include-function ts_language_symbol_count \ --include-function ts_language_symbol_for_name \ --include-function ts_language_symbol_name \ --include-function ts_language_symbol_type \ - --include-function ts_language_version \ --include-function ts_lookahead_iterator_current_symbol \ --include-function ts_lookahead_iterator_current_symbol_name \ --include-function ts_lookahead_iterator_delete \ @@ -92,6 +101,7 @@ exec jextract \ --include-function ts_parser_parse \ --include-function ts_parser_parse_string \ --include-function ts_parser_parse_string_encoding \ + --include-function ts_parser_parse_with_options \ --include-function ts_parser_print_dot_graphs \ --include-function ts_parser_reset \ --include-function ts_parser_set_cancellation_flag \ @@ -106,6 +116,7 @@ exec jextract \ --include-function ts_query_cursor_delete \ --include-function ts_query_cursor_did_exceed_match_limit \ --include-function ts_query_cursor_exec \ + --include-function ts_query_cursor_exec_with_options \ --include-function ts_query_cursor_match_limit \ --include-function ts_query_cursor_new \ --include-function ts_query_cursor_next_capture \ @@ -159,7 +170,9 @@ exec jextract \ --include-function ts_tree_root_node_with_offset \ --include-constant TREE_SITTER_LANGUAGE_VERSION \ --include-constant TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION \ - --include-constant TSInputEncodingUTF16 \ + --include-constant TSInputEncodingCustom \ + --include-constant TSInputEncodingUTF16BE \ + --include-constant TSInputEncodingUTF16LE \ --include-constant TSInputEncodingUTF8 \ --include-constant TSLogTypeLex \ --include-constant TSLogTypeParse \ @@ -182,6 +195,7 @@ exec jextract \ --include-constant TSSymbolTypeAuxiliary \ --include-constant TSSymbolTypeRegular \ --include-constant TSSymbolTypeSupertype \ + --include-typedef DecodeFunction \ --header-class-name TreeSitter \ --output "$output" \ -t "$package" \ diff --git a/src/main/java/io/github/treesitter/jtreesitter/CapturesIterator.java b/src/main/java/io/github/treesitter/jtreesitter/CapturesIterator.java new file mode 100644 index 0000000..4f94027 --- /dev/null +++ b/src/main/java/io/github/treesitter/jtreesitter/CapturesIterator.java @@ -0,0 +1,55 @@ +package io.github.treesitter.jtreesitter; + +import static io.github.treesitter.jtreesitter.internal.TreeSitter.C_INT; +import static io.github.treesitter.jtreesitter.internal.TreeSitter.ts_query_cursor_next_capture; + +import io.github.treesitter.jtreesitter.internal.TSQueryMatch; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SegmentAllocator; +import java.util.AbstractMap.SimpleImmutableEntry; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.function.BiPredicate; +import java.util.function.Consumer; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +class CapturesIterator extends Spliterators.AbstractSpliterator> { + private final @Nullable BiPredicate predicate; + private final Tree tree; + private final SegmentAllocator allocator; + private final Query query; + private final MemorySegment cursor; + + public CapturesIterator( + Query query, + MemorySegment cursor, + Tree tree, + SegmentAllocator allocator, + @Nullable BiPredicate predicate) { + super(Long.MAX_VALUE, Spliterator.IMMUTABLE | Spliterator.NONNULL); + this.predicate = predicate; + this.tree = tree; + this.allocator = allocator; + this.query = query; + this.cursor = cursor; + } + + @Override + public boolean tryAdvance(Consumer> action) { + var hasNoText = tree.getText() == null; + MemorySegment match = allocator.allocate(TSQueryMatch.layout()); + MemorySegment index = allocator.allocate(C_INT); + var captureNames = query.getCaptureNames(); + while (ts_query_cursor_next_capture(cursor, match, index)) { + var result = QueryMatch.from(match, captureNames, tree, allocator); + if (hasNoText || query.matches(predicate, result)) { + var entry = new SimpleImmutableEntry<>(index.get(C_INT, 0), result); + action.accept(entry); + return true; + } + } + return false; + } +} diff --git a/src/main/java/io/github/treesitter/jtreesitter/InputEncoding.java b/src/main/java/io/github/treesitter/jtreesitter/InputEncoding.java index 7deb512..d794b4c 100644 --- a/src/main/java/io/github/treesitter/jtreesitter/InputEncoding.java +++ b/src/main/java/io/github/treesitter/jtreesitter/InputEncoding.java @@ -9,8 +9,18 @@ public enum InputEncoding { /** UTF-8 encoding. */ UTF_8(StandardCharsets.UTF_8), - /** UTF-16 encoding. */ - UTF_16(ByteOrder.nativeOrder() == ByteOrder.BIG_ENDIAN ? StandardCharsets.UTF_16BE : StandardCharsets.UTF_16LE); + /** + * UTF-16 little endian encoding. + * + * @since 0.25.0 + */ + UTF_16LE(StandardCharsets.UTF_16LE), + /** + * UTF-16 big endian encoding. + * + * @since 0.25.0 + */ + UTF_16BE(StandardCharsets.UTF_16BE); private final @NonNull Charset charset; @@ -22,17 +32,23 @@ Charset charset() { return charset; } + private static final boolean IS_BIG_ENDIAN = ByteOrder.nativeOrder().equals(ByteOrder.BIG_ENDIAN); + /** * Convert a standard {@linkplain Charset} to an {@linkplain InputEncoding}. * - * @param charset one of {@link StandardCharsets#UTF_8} or {@link StandardCharsets#UTF_16} ({@link StandardCharsets#UTF_16LE UTF_16LE} and {@link StandardCharsets#UTF_16BE UTF_16BE} will work too, but native byte order will be used) + * @param charset one of {@link StandardCharsets#UTF_8}, {@link StandardCharsets#UTF_16BE}, + * {@link StandardCharsets#UTF_16LE}, or {@link StandardCharsets#UTF_16} (native byte order). * @throws IllegalArgumentException If the character set is invalid. */ + @SuppressWarnings("SameParameterValue") static @NonNull InputEncoding valueOf(@NonNull Charset charset) throws IllegalArgumentException { if (charset.equals(StandardCharsets.UTF_8)) return InputEncoding.UTF_8; - if (charset.equals(StandardCharsets.UTF_16BE) - || charset.equals(StandardCharsets.UTF_16LE) - || charset.equals(StandardCharsets.UTF_16)) return InputEncoding.UTF_16; + if (charset.equals(StandardCharsets.UTF_16BE)) return InputEncoding.UTF_16BE; + if (charset.equals(StandardCharsets.UTF_16LE)) return InputEncoding.UTF_16LE; + if (charset.equals(StandardCharsets.UTF_16)) { + return IS_BIG_ENDIAN ? InputEncoding.UTF_16BE : InputEncoding.UTF_16LE; + } throw new IllegalArgumentException("Invalid character set: %s".formatted(charset)); } } diff --git a/src/main/java/io/github/treesitter/jtreesitter/Language.java b/src/main/java/io/github/treesitter/jtreesitter/Language.java index c6ca223..efc0272 100644 --- a/src/main/java/io/github/treesitter/jtreesitter/Language.java +++ b/src/main/java/io/github/treesitter/jtreesitter/Language.java @@ -2,6 +2,7 @@ import static io.github.treesitter.jtreesitter.internal.TreeSitter.*; +import io.github.treesitter.jtreesitter.internal.TSLanguageMetadata; import java.lang.foreign.*; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -40,7 +41,7 @@ public final class Language implements Cloneable { */ public Language(MemorySegment self) throws IllegalArgumentException { this.self = self.asReadOnly(); - version = ts_language_version(this.self); + version = ts_language_abi_version(this.self); if (version < MIN_COMPATIBLE_LANGUAGE_VERSION || version > LANGUAGE_VERSION) { throw new IllegalArgumentException(String.format( "Incompatible language version %d. Must be between %d and %d.", @@ -87,13 +88,51 @@ MemorySegment segment() { /** * Get the ABI version number for this language. * - *

When a language is generated by the Tree-sitter CLI, it is assigned - * an ABI version number that corresponds to the current CLI version. + *

This version number is used to ensure that languages + * were generated by a compatible version of Tree-sitter. + * + * @since 0.25.0 + */ + public @Unsigned int getAbiVersion() { + return version; + } + + /** + * Get the ABI version number for this language. + * + * @deprecated Use {@link #getAbiVersion} instead. */ + @Deprecated(since = "0.25.0", forRemoval = true) public @Unsigned int getVersion() { return version; } + /** Get the name of this language, if available. */ + public @Nullable String getName() { + var name = ts_language_name(self); + return name.equals(MemorySegment.NULL) ? null : name.getString(0); + } + + /** + * Get the metadata for this language, if available. + * + * @apiNote This information is generated by the Tree-sitter + * CLI and relies on the language author providing the correct + * metadata in the language's {@code tree-sitter.json} file. + * + * @since 0.25.0 + */ + public @Nullable LanguageMetadata getMetadata() { + var metadata = ts_language_metadata(self); + if (metadata.equals(MemorySegment.NULL)) return null; + + short major = TSLanguageMetadata.major_version(metadata); + short minor = TSLanguageMetadata.minor_version(metadata); + short patch = TSLanguageMetadata.patch_version(metadata); + var version = new LanguageMetadata.Version(major, minor, patch); + return new LanguageMetadata(version); + } + /** Get the number of distinct node types in this language. */ public @Unsigned int getSymbolCount() { return ts_language_symbol_count(self); @@ -109,6 +148,35 @@ MemorySegment segment() { return ts_language_field_count(self); } + /** + * Get all supertype symbols for the language. + * + * @since 0.25.0 + */ + public @Unsigned short[] getSupertypes() { + try (var alloc = Arena.ofConfined()) { + var length = alloc.allocate(C_INT.byteSize(), C_INT.byteAlignment()); + var supertypes = ts_language_supertypes(self, length); + var isEmpty = length.get(C_INT, 0) == 0; + return isEmpty ? new short[0] : supertypes.toArray(C_SHORT); + } + } + + /** + * Get all symbols for a given supertype symbol. + * + * @since 0.25.0 + * @see #getSupertypes() + */ + public @Unsigned short[] getSubtypes(@Unsigned short supertype) { + try (var alloc = Arena.ofConfined()) { + var length = alloc.allocate(C_INT.byteSize(), C_INT.byteAlignment()); + var subtypes = ts_language_subtypes(self, supertype, length); + var isEmpty = length.get(C_INT, 0) == 0; + return isEmpty ? new short[0] : subtypes.toArray(C_SHORT); + } + } + /** Get the node type for the given numerical ID. */ public @Nullable String getSymbolName(@Unsigned short symbol) { var name = ts_language_symbol_name(self, symbol); @@ -187,7 +255,9 @@ public LookaheadIterator lookaheadIterator(@Unsigned short state) throws Illegal * Create a new query from a string containing one or more S-expression patterns. * * @throws QueryError If an error occurred while creating the query. + * @deprecated Use the {@link Query#Query(Language, String) Query} constructor instead. */ + @Deprecated(since = "0.25.0") public Query query(String source) throws QueryError { return new Query(this, source); } diff --git a/src/main/java/io/github/treesitter/jtreesitter/LanguageMetadata.java b/src/main/java/io/github/treesitter/jtreesitter/LanguageMetadata.java new file mode 100644 index 0000000..3bd7909 --- /dev/null +++ b/src/main/java/io/github/treesitter/jtreesitter/LanguageMetadata.java @@ -0,0 +1,26 @@ +package io.github.treesitter.jtreesitter; + +import org.jspecify.annotations.NullMarked; + +/** + * The metadata associated with a {@linkplain Language}. + * + * @since 0.25.0 + */ +@NullMarked +public record LanguageMetadata(Version version) { + /** + * The Semantic Version of the {@linkplain Language}. + * + *

This version information may be used to signal if a given parser + * is incompatible with existing queries when upgrading between versions. + * + * @since 0.25.0 + */ + public record Version(@Unsigned short major, @Unsigned short minor, @Unsigned short patch) { + @Override + public String toString() { + return "%d.%d.%d".formatted(major, minor, patch); + } + } +} diff --git a/src/main/java/io/github/treesitter/jtreesitter/MatchesIterator.java b/src/main/java/io/github/treesitter/jtreesitter/MatchesIterator.java new file mode 100644 index 0000000..0de4e24 --- /dev/null +++ b/src/main/java/io/github/treesitter/jtreesitter/MatchesIterator.java @@ -0,0 +1,51 @@ +package io.github.treesitter.jtreesitter; + +import static io.github.treesitter.jtreesitter.internal.TreeSitter.ts_query_cursor_next_match; + +import io.github.treesitter.jtreesitter.internal.TSQueryMatch; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SegmentAllocator; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.function.BiPredicate; +import java.util.function.Consumer; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +class MatchesIterator extends Spliterators.AbstractSpliterator { + private final @Nullable BiPredicate predicate; + private final Tree tree; + private final SegmentAllocator allocator; + private final Query query; + private final MemorySegment cursor; + + public MatchesIterator( + Query query, + MemorySegment cursor, + Tree tree, + SegmentAllocator allocator, + @Nullable BiPredicate predicate) { + super(Long.MAX_VALUE, Spliterator.IMMUTABLE | Spliterator.NONNULL); + this.predicate = predicate; + this.tree = tree; + this.allocator = allocator; + this.query = query; + this.cursor = cursor; + } + + @Override + public boolean tryAdvance(Consumer action) { + var hasNoText = tree.getText() == null; + MemorySegment match = allocator.allocate(TSQueryMatch.layout()); + var captureNames = query.getCaptureNames(); + while (ts_query_cursor_next_match(cursor, match)) { + var result = QueryMatch.from(match, captureNames, tree, allocator); + if (hasNoText || query.matches(predicate, result)) { + action.accept(result); + return true; + } + } + return false; + } +} diff --git a/src/main/java/io/github/treesitter/jtreesitter/NativeLibraryLookup.java b/src/main/java/io/github/treesitter/jtreesitter/NativeLibraryLookup.java index a84c033..445b7aa 100644 --- a/src/main/java/io/github/treesitter/jtreesitter/NativeLibraryLookup.java +++ b/src/main/java/io/github/treesitter/jtreesitter/NativeLibraryLookup.java @@ -9,6 +9,7 @@ * by listing their fully qualified class name in a resource file named * {@code META-INF/services/io.github.treesitter.jtreesitter.NativeLibraryLookup}. * + * @since 0.25.0 * @see java.util.ServiceLoader */ @FunctionalInterface @@ -17,6 +18,7 @@ public interface NativeLibraryLookup { * Get the {@link SymbolLookup} to be used for the tree-sitter native library. * * @param arena The arena that will manage the native memory. + * @since 0.25.0 */ SymbolLookup get(Arena arena); } diff --git a/src/main/java/io/github/treesitter/jtreesitter/Node.java b/src/main/java/io/github/treesitter/jtreesitter/Node.java index c5348bf..23c1728 100644 --- a/src/main/java/io/github/treesitter/jtreesitter/Node.java +++ b/src/main/java/io/github/treesitter/jtreesitter/Node.java @@ -434,7 +434,7 @@ public void edit(InputEdit edit) { children = null; } - /** Create a new tree cursor starting from this node. */ + /** Create a new {@linkplain TreeCursor tree cursor} starting from this node. */ public TreeCursor walk() { return new TreeCursor(this, tree); } diff --git a/src/main/java/io/github/treesitter/jtreesitter/ParseCallback.java b/src/main/java/io/github/treesitter/jtreesitter/ParseCallback.java index 6b8c575..b9a58fe 100644 --- a/src/main/java/io/github/treesitter/jtreesitter/ParseCallback.java +++ b/src/main/java/io/github/treesitter/jtreesitter/ParseCallback.java @@ -12,8 +12,7 @@ public interface ParseCallback extends BiFunction { * * @param offset the current byte offset * @param point the current point - * @return A chunk of text or {@code null} - * to indicate the end of the document. + * @return A chunk of text or {@code null} to indicate the end of the document. */ @Override @Nullable diff --git a/src/main/java/io/github/treesitter/jtreesitter/Parser.java b/src/main/java/io/github/treesitter/jtreesitter/Parser.java index ae700cf..feb9eb2 100644 --- a/src/main/java/io/github/treesitter/jtreesitter/Parser.java +++ b/src/main/java/io/github/treesitter/jtreesitter/Parser.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Optional; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Predicate; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -53,7 +54,10 @@ public Parser setLanguage(Language language) { /** * Get the maximum duration in microseconds that * parsing should be allowed to take before halting. + * + * @deprecated Use {@link Options} instead. */ + @Deprecated(since = "0.25.0") public @Unsigned long getTimeoutMicros() { return ts_parser_timeout_micros(self); } @@ -61,7 +65,11 @@ public Parser setLanguage(Language language) { /** * Set the maximum duration in microseconds that * parsing should be allowed to take before halting. + * + * @deprecated Use {@link Options} instead. */ + @Deprecated(since = "0.25.0") + @SuppressWarnings("DeprecatedIsStillUsed") public Parser setTimeoutMicros(@Unsigned long timeoutMicros) { ts_parser_set_timeout_micros(self, timeoutMicros); return this; @@ -105,7 +113,11 @@ public Parser setLogger(@Nullable Logger logger) { * *

The parser will periodically read from this flag during parsing. * If it reads a non-zero value, it will halt early. + * + * @deprecated Use {@link Options} instead. */ + @Deprecated(since = "0.25.0") + @SuppressWarnings("DeprecatedIsStillUsed") public synchronized Parser setCancellationFlag(CancellationFlag cancellationFlag) { ts_parser_set_cancellation_flag(self, cancellationFlag.segment); return this; @@ -254,8 +266,19 @@ public Optional parse(String source, InputEncoding encoding, @Nullable Tre * @return An optional {@linkplain Tree} which is empty if parsing was halted. * @throws IllegalStateException If the parser does not have a language assigned. */ - public Optional parse(ParseCallback callback, InputEncoding encoding) throws IllegalStateException { - return parse(callback, encoding, null); + public Optional parse(ParseCallback parseCallback, InputEncoding encoding) throws IllegalStateException { + return parse(parseCallback, encoding, null, null); + } + + /** + * Parse source code from a callback and create a syntax tree. + * + * @return An optional {@linkplain Tree} which is empty if parsing was halted. + * @throws IllegalStateException If the parser does not have a language assigned. + */ + public Optional parse(ParseCallback parseCallback, InputEncoding encoding, Options options) + throws IllegalStateException { + return parse(parseCallback, encoding, null, options); } /** @@ -271,20 +294,20 @@ public Optional parse(ParseCallback callback, InputEncoding encoding) thro * @throws IllegalStateException If the parser does not have a language assigned. */ @SuppressWarnings("unused") - public Optional parse(ParseCallback callback, InputEncoding encoding, @Nullable Tree oldTree) + public Optional parse( + ParseCallback parseCallback, InputEncoding encoding, @Nullable Tree oldTree, @Nullable Options options) throws IllegalStateException { if (language == null) { throw new IllegalStateException("The parser has no language assigned"); } - // FIXME: callbacks cannot be cancelled var input = TSInput.allocate(arena); TSInput.payload(input, MemorySegment.NULL); TSInput.encoding(input, encoding.ordinal()); // NOTE: can't use _ because of palantir/palantir-java-format#934 var read = TSInput.read.allocate( (payload, index, point, bytes) -> { - var result = callback.apply(index, Point.from(point)); + var result = parseCallback.apply(index, Point.from(point)); if (result == null) { bytes.set(C_INT, 0, 0); return MemorySegment.NULL; @@ -296,8 +319,22 @@ public Optional parse(ParseCallback callback, InputEncoding encoding, @Nul arena); TSInput.read(input, read); - var old = oldTree == null ? MemorySegment.NULL : oldTree.segment(); - var tree = ts_parser_parse(self, old, input); + MemorySegment tree, old = oldTree == null ? MemorySegment.NULL : oldTree.segment(); + if (options == null) { + tree = ts_parser_parse(self, old, input); + } else { + var parseOptions = TSParseOptions.allocate(arena); + TSParseOptions.payload(parseOptions, MemorySegment.NULL); + var progress = TSParseOptions.progress_callback.allocate( + (payload) -> { + var offset = TSParseState.current_byte_offset(payload); + var hasError = TSParseState.has_error(payload); + return options.progressCallback(new State(offset, hasError)); + }, + arena); + TSParseOptions.progress_callback(parseOptions, progress); + tree = ts_parser_parse_with_options(self, old, input, parseOptions); + } if (tree.equals(MemorySegment.NULL)) return Optional.empty(); return Optional.of(new Tree(tree, language, null, null)); } @@ -305,9 +342,8 @@ public Optional parse(ParseCallback callback, InputEncoding encoding, @Nul /** * Instruct the parser to start the next {@linkplain #parse parse} from the beginning. * - * @apiNote If the parser previously stopped because of a {@linkplain #setTimeoutMicros timeout} - * or {@linkplain #setCancellationFlag cancellation}, it will resume where it left off. - *
If you intend to parse another document instead, you must call this method first. + * @apiNote If parsing was previously halted, the parser will resume where it left off. + * If you intend to parse another document instead, you must call this method first. */ public void reset() { ts_parser_reset(self); @@ -323,7 +359,63 @@ public String toString() { return "Parser{language=%s}".formatted(language); } - /** A class representing a cancellation flag. */ + /** + * A class representing the current state of the parser. + * + * @since 0.25.0 + */ + public static final class State { + private final @Unsigned int currentByteOffset; + private final boolean hasError; + + private State(@Unsigned int currentByteOffset, boolean hasError) { + this.currentByteOffset = currentByteOffset; + this.hasError = hasError; + } + + /** Get the current byte offset of the parser. */ + public @Unsigned int getCurrentByteOffset() { + return currentByteOffset; + } + + /** Check if the parser has encountered an error. */ + public boolean hasError() { + return hasError; + } + + @Override + public String toString() { + return String.format( + "Parser.State{currentByteOffset=%s, hasError=%s}", + Integer.toUnsignedString(currentByteOffset), hasError); + } + } + + /** + * A class representing the parser options. + * + * @since 0.25.0 + */ + @NullMarked + public static final class Options { + private final Predicate progressCallback; + + public Options(Predicate progressCallback) { + this.progressCallback = progressCallback; + } + + private boolean progressCallback(State state) { + return progressCallback.test(state); + } + } + + /** + * A class representing a cancellation flag. + * + * @deprecated Use {@link Options} instead. + */ + @Deprecated(since = "0.25.0") + @SuppressWarnings("DeprecatedIsStillUsed") public static class CancellationFlag { private final Arena arena = Arena.ofAuto(); private final MemorySegment segment = arena.allocate(C_LONG_LONG); diff --git a/src/main/java/io/github/treesitter/jtreesitter/Query.java b/src/main/java/io/github/treesitter/jtreesitter/Query.java index 0489fce..240eeeb 100644 --- a/src/main/java/io/github/treesitter/jtreesitter/Query.java +++ b/src/main/java/io/github/treesitter/jtreesitter/Query.java @@ -5,15 +5,12 @@ import io.github.treesitter.jtreesitter.internal.*; import java.lang.foreign.Arena; import java.lang.foreign.MemorySegment; -import java.lang.foreign.SegmentAllocator; import java.util.*; import java.util.function.BiPredicate; -import java.util.function.Consumer; import java.util.function.Supplier; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; import java.util.stream.Stream; -import java.util.stream.StreamSupport; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -21,74 +18,39 @@ * A class that represents a set of patterns which match * {@linkplain Node nodes} in a {@linkplain Tree syntax tree}. * - * @see Query Syntax + * @see Query Syntax */ @NullMarked public final class Query implements AutoCloseable { - private final MemorySegment query; - private final MemorySegment cursor; + private final MemorySegment self; private final Arena arena; private final Language language; private final String source; private final List captureNames; + private final List stringValues; private final List> predicates; private final List>> settings; private final List>> positiveAssertions; private final List>> negativeAssertions; - Query(Language language, String source) throws QueryError { + /** + * Create a new query from a string containing one or more S-expression patterns. + * + * @throws QueryError If an error occurred while creating the query. + */ + public Query(Language language, String source) throws QueryError { arena = Arena.ofShared(); var string = arena.allocateFrom(source); var errorOffset = arena.allocate(C_INT); var errorType = arena.allocate(C_INT); var query = ts_query_new(language.segment(), string, source.length(), errorOffset, errorType); - if (query.equals(MemorySegment.NULL)) { - long start = 0, row = 0; - int offset = errorOffset.get(C_INT, 0); - for (var line : source.split("\n")) { - long end = start + line.length() + 1; - if (end > offset) break; - start = end; - row += 1; - } - long column = offset - start, type = errorType.get(C_INT, 0); - if (type == TSQueryErrorSyntax()) { - if (offset >= source.length()) throw new QueryError.Syntax(); - throw new QueryError.Syntax(row, column); - } else if (type == TSQueryErrorCapture()) { - int index = 0, length = source.length(); - var suffix = source.subSequence(offset, length); - for (; index < length; ++index) { - if (invalidPredicateChar(suffix.charAt(index))) break; - } - throw new QueryError.Capture(row, column, suffix.subSequence(0, index)); - } else if (type == TSQueryErrorNodeType()) { - int index = 0, length = source.length(); - var suffix = source.subSequence(offset, length); - for (; index < length; ++index) { - if (invalidIdentifierChar(suffix.charAt(index))) break; - } - throw new QueryError.NodeType(row, column, suffix.subSequence(0, index)); - } else if (type == TSQueryErrorField()) { - int index = 0, length = source.length(); - var suffix = source.subSequence(offset, length); - for (; index < length; ++index) { - if (invalidIdentifierChar(suffix.charAt(index))) break; - } - throw new QueryError.Field(row, column, suffix.subSequence(0, index)); - } else if (type == TSQueryErrorStructure()) { - throw new QueryError.Structure(row, column); - } else { - throw new IllegalStateException("Unexpected query error"); - } - } + if (query.equals(MemorySegment.NULL)) handleError(source, errorOffset, errorType); this.language = language; this.source = source; - this.query = query.reinterpret(arena, TreeSitter::ts_query_delete); - cursor = ts_query_cursor_new().reinterpret(arena, TreeSitter::ts_query_cursor_delete); + this.self = query.reinterpret(arena, TreeSitter::ts_query_delete); - var captureCount = ts_query_capture_count(this.query); + var captureCount = ts_query_capture_count(this.self); captureNames = new ArrayList<>(captureCount); try (var alloc = Arena.ofConfined()) { for (int i = 0; i < captureCount; ++i) { @@ -101,14 +63,14 @@ public final class Query implements AutoCloseable { } } - var patternCount = ts_query_pattern_count(this.query); + var patternCount = ts_query_pattern_count(this.self); predicates = generate(ArrayList::new, patternCount); settings = generate(HashMap::new, patternCount); positiveAssertions = generate(HashMap::new, patternCount); negativeAssertions = generate(HashMap::new, patternCount); - var stringCount = ts_query_string_count(this.query); - List stringValues = new ArrayList<>(stringCount); + var stringCount = ts_query_string_count(this.self); + stringValues = new ArrayList<>(stringCount); try (var alloc = Arena.ofConfined()) { for (int i = 0; i < stringCount; ++i) { var length = alloc.allocate(C_INT); @@ -120,6 +82,66 @@ public final class Query implements AutoCloseable { } } + handlePredicates(source, query, patternCount); + } + + private static void handleError(String source, MemorySegment errorOffset, MemorySegment errorType) + throws QueryError { + long start = 0, row = 0; + int offset = errorOffset.get(C_INT, 0); + for (var line : source.split("\n")) { + long end = start + line.length() + 1; + if (end > offset) break; + start = end; + row += 1; + } + long column = offset - start, type = errorType.get(C_INT, 0); + if (type == TSQueryErrorSyntax()) { + if (offset >= source.length()) throw new QueryError.Syntax(); + throw new QueryError.Syntax(row, column); + } else if (type == TSQueryErrorCapture()) { + int index = 0, length = source.length(); + var suffix = source.subSequence(offset, length); + for (; index < length; ++index) { + if (invalidPredicateChar(suffix.charAt(index))) break; + } + throw new QueryError.Capture(row, column, suffix.subSequence(0, index)); + } else if (type == TSQueryErrorNodeType()) { + int index = 0, length = source.length(); + var suffix = source.subSequence(offset, length); + for (; index < length; ++index) { + if (invalidIdentifierChar(suffix.charAt(index))) break; + } + throw new QueryError.NodeType(row, column, suffix.subSequence(0, index)); + } else if (type == TSQueryErrorField()) { + int index = 0, length = source.length(); + var suffix = source.subSequence(offset, length); + for (; index < length; ++index) { + if (invalidIdentifierChar(suffix.charAt(index))) break; + } + throw new QueryError.Field(row, column, suffix.subSequence(0, index)); + } else if (type == TSQueryErrorStructure()) { + throw new QueryError.Structure(row, column); + } else { + throw new IllegalStateException("Unexpected query error"); + } + } + + private static List generate(Supplier supplier, int limit) { + return Stream.generate(supplier).limit(limit).toList(); + } + + private static boolean invalidIdentifierChar(char c) { + return !Character.isLetterOrDigit(c) && c != '_'; + } + + private static boolean invalidPredicateChar(char c) { + return !(Character.isLetterOrDigit(c) || c == '_' || c == '-' || c == '.' || c == '?' || c == '!'); + } + + @SuppressWarnings("DuplicatedCode") + private void handlePredicates(String source, MemorySegment query, @Unsigned int patternCount) + throws QueryError.Predicate { try (var alloc = Arena.ofConfined()) { for (int i = 0, steps; i < patternCount; ++i) { var count = alloc.allocate(C_INT); @@ -251,104 +273,41 @@ public final class Query implements AutoCloseable { } } - private static List generate(Supplier supplier, int limit) { - return Stream.generate(supplier).limit(limit).toList(); - } - - private static boolean invalidIdentifierChar(char c) { - return !Character.isLetterOrDigit(c) && c != '_'; - } - - private static boolean invalidPredicateChar(char c) { - return !(Character.isLetterOrDigit(c) || c == '_' || c == '-' || c == '.' || c == '?' || c == '!'); + MemorySegment segment() { + return self; } /** Get the number of patterns in the query. */ public @Unsigned int getPatternCount() { - return ts_query_pattern_count(query); - } - - /** Get the number of captures in the query. */ - public @Unsigned int getCaptureCount() { - return ts_query_capture_count(query); + return ts_query_pattern_count(self); } /** - * Get the maximum number of in-progress matches. + * Get the number of captures in the query. * - * @apiNote Defaults to {@code -1} (unlimited). + * @deprecated Use {@code getCaptureNames().size()} instead. */ - public @Unsigned int getMatchLimit() { - return ts_query_cursor_match_limit(cursor); - } - - /** - * Get the maximum number of in-progress matches. - * - * @throws IllegalArgumentException If {@code matchLimit == 0}. - */ - public Query setMatchLimit(@Unsigned int matchLimit) throws IllegalArgumentException { - if (matchLimit == 0) { - throw new IllegalArgumentException("The match limit cannot equal 0"); - } - ts_query_cursor_set_match_limit(cursor, matchLimit); - return this; + @Deprecated(since = "0.25.0") + public @Unsigned int getCaptureCount() { + return ts_query_capture_count(self); } /** - * Get the maximum duration in microseconds that query - * execution should be allowed to take before halting. + * Get the names of the captures used in the query. * - * @apiNote Defaults to {@code 0} (unlimited). - * @since 0.23.1 + * @since 0.25.0 */ - public @Unsigned long getTimeoutMicros() { - return ts_query_cursor_timeout_micros(cursor); + public List getCaptureNames() { + return Collections.unmodifiableList(captureNames); } /** - * Set the maximum duration in microseconds that query - * execution should be allowed to take before halting. + * Get the string literals used in the query. * - * @since 0.23.1 + * @since 0.25.0 */ - public Query setTimeoutMicros(@Unsigned long timeoutMicros) { - ts_query_cursor_set_timeout_micros(cursor, timeoutMicros); - return this; - } - - /** - * Set the maximum start depth for the query. - * - *

This prevents cursors from exploring children nodes at a certain depth. - *
Note that if a pattern includes many children, then they will still be checked. - */ - public Query setMaxStartDepth(@Unsigned int maxStartDepth) { - ts_query_cursor_set_max_start_depth(cursor, maxStartDepth); - return this; - } - - /** Set the range of bytes in which the query will be executed. */ - public Query setByteRange(@Unsigned int startByte, @Unsigned int endByte) { - ts_query_cursor_set_byte_range(cursor, startByte, endByte); - return this; - } - - /** Set the range of points in which the query will be executed. */ - public Query setPointRange(Point startPoint, Point endPoint) { - try (var alloc = Arena.ofConfined()) { - MemorySegment start = startPoint.into(alloc), end = endPoint.into(alloc); - ts_query_cursor_set_point_range(cursor, start, end); - } - return this; - } - - /** - * Check if the query exceeded its maximum number of - * in-progress matches during its last execution. - */ - public boolean didExceedMatchLimit() { - return ts_query_cursor_did_exceed_match_limit(cursor); + public List getStringValues() { + return Collections.unmodifiableList(stringValues); } /** @@ -361,7 +320,7 @@ public boolean didExceedMatchLimit() { */ public void disablePattern(@Unsigned int index) throws IndexOutOfBoundsException { checkIndex(index); - ts_query_disable_pattern(query, index); + ts_query_disable_pattern(self, index); } /** @@ -377,7 +336,7 @@ public void disableCapture(String name) throws NoSuchElementException { throw new NoSuchElementException("Capture @%s does not exist".formatted(name)); } try (var alloc = Arena.ofConfined()) { - ts_query_disable_capture(query, alloc.allocateFrom(name), name.length()); + ts_query_disable_capture(self, alloc.allocateFrom(name), name.length()); } } @@ -389,7 +348,7 @@ public void disableCapture(String name) throws NoSuchElementException { */ public @Unsigned int startByteForPattern(@Unsigned int index) throws IndexOutOfBoundsException { checkIndex(index); - return ts_query_start_byte_for_pattern(query, index); + return ts_query_start_byte_for_pattern(self, index); } /** @@ -401,7 +360,7 @@ public void disableCapture(String name) throws NoSuchElementException { */ public @Unsigned int endByteForPattern(@Unsigned int index) throws IndexOutOfBoundsException { checkIndex(index); - return ts_query_end_byte_for_pattern(query, index); + return ts_query_end_byte_for_pattern(self, index); } /** @@ -412,7 +371,7 @@ public void disableCapture(String name) throws NoSuchElementException { */ public boolean isPatternRooted(@Unsigned int index) throws IndexOutOfBoundsException { checkIndex(index); - return ts_query_is_pattern_rooted(query, index); + return ts_query_is_pattern_rooted(self, index); } /** @@ -428,7 +387,7 @@ public boolean isPatternRooted(@Unsigned int index) throws IndexOutOfBoundsExcep */ public boolean isPatternNonLocal(@Unsigned int index) throws IndexOutOfBoundsException { checkIndex(index); - return ts_query_is_pattern_non_local(query, index); + return ts_query_is_pattern_non_local(self, index); } /** @@ -441,13 +400,13 @@ public boolean isPatternGuaranteedAtStep(@Unsigned int offset) throws IndexOutOf throw new IndexOutOfBoundsException( "Byte offset %s exceeds EOF".formatted(Integer.toUnsignedString(offset))); } - return ts_query_is_pattern_guaranteed_at_step(query, offset); + return ts_query_is_pattern_guaranteed_at_step(self, offset); } /** * Get the property settings for the given pattern index. * - *

Properties are set using the {@code #set!} predicate. + *

Properties are set using the {@code #set!} directive. * * @param index The index of a pattern within the query. * @return A map of property keys with optional values. @@ -478,53 +437,6 @@ public Map> getPatternAssertions(@Unsigned int index, b return Collections.unmodifiableMap(assertions.get(index)); } - /** - * Iterate over all the matches in the order that they were found. - * - * @implNote The lifetime of the matches is bound to that of the query. - * - * @param node The node that the query will run on. - */ - public Stream findMatches(Node node) { - return findMatches(node, arena, null); - } - - /** - * Iterate over all the matches in the order that they were found. - * - *

Predicate Example

- *

- * {@snippet lang="java" : - * Stream matches = query.findMatches(tree.getRootNode(), (predicate, match) -> { - * if (!predicate.getName().equals("ieq?")) return true; - * List args = predicate.getArgs(); - * Node node = match.findNodes(args.getFirst().value()).getFirst(); - * return args.getLast().value().equalsIgnoreCase(node.getText()); - * }); - * } - * - * @implNote The lifetime of the matches is bound to that of the query. - * - * @param node The node that the query will run on. - * @param predicate A function that handles custom predicates. - */ - public Stream findMatches(Node node, @Nullable BiPredicate predicate) { - return findMatches(node, arena, predicate); - } - - /** - * Iterate over all the matches in the order that they were found, using the given allocator. - * - * @see #findMatches(Node, BiPredicate) - */ - public Stream findMatches( - Node node, SegmentAllocator allocator, @Nullable BiPredicate predicate) { - try (var alloc = Arena.ofConfined()) { - ts_query_cursor_exec(cursor, query, node.copy(alloc)); - } - return StreamSupport.stream(new MatchesIterator(node.getTree(), allocator, predicate), false); - } - @Override public void close() throws RuntimeException { arena.close(); @@ -535,7 +447,7 @@ public String toString() { return "Query{language=%s, source=%s}".formatted(language, source); } - private boolean matches(@Nullable BiPredicate predicate, QueryMatch match) { + boolean matches(@Nullable BiPredicate predicate, QueryMatch match) { return predicates.get(match.patternIndex()).stream().allMatch(p -> { if (p.getClass() != QueryPredicate.class) return p.test(match); return predicate == null || predicate.test(p, match); @@ -548,42 +460,4 @@ private void checkIndex(@Unsigned int index) throws IndexOutOfBoundsException { "Pattern index %s is out of bounds".formatted(Integer.toUnsignedString(index))); } } - - private final class MatchesIterator extends Spliterators.AbstractSpliterator { - private final @Nullable BiPredicate predicate; - private final Tree tree; - private final SegmentAllocator allocator; - - public MatchesIterator( - Tree tree, SegmentAllocator allocator, @Nullable BiPredicate predicate) { - super(Long.MAX_VALUE, Spliterator.IMMUTABLE | Spliterator.NONNULL); - this.predicate = predicate; - this.tree = tree; - this.allocator = allocator; - } - - @Override - public boolean tryAdvance(Consumer action) { - var hasNoText = tree.getText() == null; - MemorySegment match = arena.allocate(TSQueryMatch.layout()); - while (ts_query_cursor_next_match(cursor, match)) { - var count = Short.toUnsignedInt(TSQueryMatch.capture_count(match)); - var matchCaptures = TSQueryMatch.captures(match); - var captureList = new ArrayList(count); - for (int i = 0; i < count; ++i) { - var capture = TSQueryCapture.asSlice(matchCaptures, i); - var name = captureNames.get(TSQueryCapture.index(capture)); - var node = TSNode.allocate(allocator).copyFrom(TSQueryCapture.node(capture)); - captureList.add(new QueryCapture(name, new Node(node, tree))); - } - var patternIndex = TSQueryMatch.pattern_index(match); - var result = new QueryMatch(patternIndex, captureList); - if (hasNoText || matches(predicate, result)) { - action.accept(result); - return true; - } - } - return false; - } - } } diff --git a/src/main/java/io/github/treesitter/jtreesitter/QueryCursor.java b/src/main/java/io/github/treesitter/jtreesitter/QueryCursor.java new file mode 100644 index 0000000..2e2b2e1 --- /dev/null +++ b/src/main/java/io/github/treesitter/jtreesitter/QueryCursor.java @@ -0,0 +1,322 @@ +package io.github.treesitter.jtreesitter; + +import static io.github.treesitter.jtreesitter.internal.TreeSitter.*; + +import io.github.treesitter.jtreesitter.internal.*; +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SegmentAllocator; +import java.util.AbstractMap.SimpleImmutableEntry; +import java.util.function.BiPredicate; +import java.util.function.Predicate; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * A class that can be used to execute a {@linkplain Query query} + * on a {@linkplain Tree syntax tree}. + * + * @since 0.25.0 + */ +@NullMarked +public class QueryCursor implements AutoCloseable { + private final MemorySegment self; + private final Arena arena; + private final Query query; + + /** Create a new cursor for the given query. */ + public QueryCursor(Query query) { + this.query = query; + arena = Arena.ofShared(); + self = ts_query_cursor_new().reinterpret(arena, TreeSitter::ts_query_cursor_delete); + } + + /** + * Get the maximum number of in-progress matches. + * + * @apiNote Defaults to {@code -1} (unlimited). + */ + public @Unsigned int getMatchLimit() { + return ts_query_cursor_match_limit(self); + } + + /** + * Get the maximum number of in-progress matches. + * + * @throws IllegalArgumentException If {@code matchLimit == 0}. + */ + public QueryCursor setMatchLimit(@Unsigned int matchLimit) throws IllegalArgumentException { + if (matchLimit == 0) { + throw new IllegalArgumentException("The match limit cannot equal 0"); + } + ts_query_cursor_set_match_limit(self, matchLimit); + return this; + } + + /** + * Get the maximum duration in microseconds that query + * execution should be allowed to take before halting. + * + * @apiNote Defaults to {@code 0} (unlimited). + * + * @deprecated Use {@link Options} instead. + */ + @Deprecated(since = "0.25.0") + public @Unsigned long getTimeoutMicros() { + return ts_query_cursor_timeout_micros(self); + } + + /** + * Set the maximum duration in microseconds that query + * execution should be allowed to take before halting. + * + * @deprecated Use {@link Options} instead. + */ + @Deprecated(since = "0.25.0") + public QueryCursor setTimeoutMicros(@Unsigned long timeoutMicros) { + ts_query_cursor_set_timeout_micros(self, timeoutMicros); + return this; + } + + /** + * Set the maximum start depth for the query. + * + *

This prevents cursors from exploring children nodes at a certain depth. + *
Note that if a pattern includes many children, then they will still be checked. + */ + public QueryCursor setMaxStartDepth(@Unsigned int maxStartDepth) { + ts_query_cursor_set_max_start_depth(self, maxStartDepth); + return this; + } + + /** + * Set the range of bytes in which the query will be executed. + *

The query cursor will return matches that intersect with the given range. + * This means that a match may be returned even if some of its captures fall + * outside the specified range, as long as at least part of the match + * overlaps with the range. + * + *

For example, if a query pattern matches a node that spans a larger area + * than the specified range, but part of that node intersects with the range, + * the entire match will be returned. + * + * @throws IllegalArgumentException If `endByte > startByte`. + */ + public QueryCursor setByteRange(@Unsigned int startByte, @Unsigned int endByte) throws IllegalArgumentException { + if (!ts_query_cursor_set_byte_range(self, startByte, endByte)) { + throw new IllegalArgumentException("Invalid byte range"); + } + return this; + } + + /** + * Set the range of points in which the query will be executed. + * + *

The query cursor will return matches that intersect with the given range. + * This means that a match may be returned even if some of its captures fall + * outside the specified range, as long as at least part of the match + * overlaps with the range. + * + *

For example, if a query pattern matches a node that spans a larger area + * than the specified range, but part of that node intersects with the range, + * the entire match will be returned. + * + * @throws IllegalArgumentException If `endPoint > startPoint`. + */ + public QueryCursor setPointRange(Point startPoint, Point endPoint) throws IllegalArgumentException { + try (var alloc = Arena.ofConfined()) { + MemorySegment start = startPoint.into(alloc), end = endPoint.into(alloc); + if (!ts_query_cursor_set_point_range(self, start, end)) { + throw new IllegalArgumentException("Invalid point range"); + } + } + return this; + } + + /** + * Check if the query exceeded its maximum number of + * in-progress matches during its last execution. + */ + public boolean didExceedMatchLimit() { + return ts_query_cursor_did_exceed_match_limit(self); + } + + private void exec(Node node, @Nullable Options options) { + try (var alloc = Arena.ofConfined()) { + if (options == null || options.progressCallback == null) { + ts_query_cursor_exec(self, query.segment(), node.copy(alloc)); + } else { + var cursorOptions = TSQueryCursorOptions.allocate(alloc); + TSQueryCursorOptions.payload(cursorOptions, MemorySegment.NULL); + var progress = TSQueryCursorOptions.progress_callback.allocate( + (payload) -> { + var offset = TSQueryCursorState.current_byte_offset(payload); + return options.progressCallback.test(new State(offset)); + }, + alloc); + TSQueryCursorOptions.progress_callback(cursorOptions, progress); + ts_query_cursor_exec_with_options(self, query.segment(), node.copy(alloc), cursorOptions); + } + } + } + + /** + * Iterate over all the captures in the order that they were found. + * + *

This is useful if you don't care about which pattern matched, + * and just want a single, ordered sequence of captures. + * + * @param node The node that the query will run on. + * + * @implNote The lifetime of the matches is bound to that of the cursor. + */ + public Stream> findCaptures(Node node) { + return findCaptures(node, arena, new Options(null, null)); + } + + /** + * Iterate over all the captures in the order that they were found. + * + *

This is useful if you don't care about which pattern matched, + * and just want a single, ordered sequence of captures. + * + * @param node The node that the query will run on. + * + * @implNote The lifetime of the matches is bound to that of the cursor. + */ + public Stream> findCaptures(Node node, Options options) { + return findCaptures(node, arena, options); + } + + /** + * Iterate over all the captures in the order that they were found. + * + *

This is useful if you don't care about which pattern matched, + * and just want a single, ordered sequence of captures. + * + * @param node The node that the query will run on. + */ + public Stream> findCaptures( + Node node, SegmentAllocator allocator, Options options) { + exec(node, options); + var iterator = new CapturesIterator(query, self, node.getTree(), allocator, options.predicateCallback); + return StreamSupport.stream(iterator, false); + } + + /** + * Iterate over all the matches in the order that they were found. + * + *

Because multiple patterns can match the same set of nodes, one match may contain + * captures that appear before some of the captures from a previous match. + * + * @param node The node that the query will run on. + * + * @implNote The lifetime of the matches is bound to that of the cursor. + */ + public Stream findMatches(Node node) { + return findMatches(node, arena, new Options(null, null)); + } + + /** + * Iterate over all the matches in the order that they were found. + * + *

Because multiple patterns can match the same set of nodes, one match may contain + * captures that appear before some of the captures from a previous match. + * + *

Predicate Example

+ *

+ * {@snippet lang = "java": + * QueryCursor.Options options = new QueryCursor.Options((predicate, match) -> { + * if (!predicate.getName().equals("ieq?")) return true; + * List args = predicate.getArgs(); + * Node node = match.findNodes(args.getFirst().value()).getFirst(); + * return args.getLast().value().equalsIgnoreCase(node.getText()); + * }); + * Stream matches = self.findMatches(tree.getRootNode(), options); + *} + * + * @param node The node that the query will run on. + * + * @implNote The lifetime of the matches is bound to that of the cursor. + */ + public Stream findMatches(Node node, Options options) { + return findMatches(node, arena, options); + } + + /** + * Iterate over all the matches in the order that they were found, using the given allocator. + * + *

Because multiple patterns can match the same set of nodes, one match may contain + * captures that appear before some of the captures from a previous match. + * + * @param node The node that the query will run on. + * + * @see #findMatches(Node, Options) + */ + public Stream findMatches(Node node, SegmentAllocator allocator, Options options) { + exec(node, options); + var iterator = new MatchesIterator(query, self, node.getTree(), allocator, options.predicateCallback); + return StreamSupport.stream(iterator, false); + } + + @Override + public void close() throws RuntimeException { + arena.close(); + } + + /** A class representing the current state of the query cursor. */ + public static final class State { + private final @Unsigned int currentByteOffset; + + private State(@Unsigned int currentByteOffset) { + this.currentByteOffset = currentByteOffset; + } + + /** Get the current byte offset of the cursor. */ + public @Unsigned int getCurrentByteOffset() { + return currentByteOffset; + } + + @Override + public String toString() { + return String.format( + "QueryCursor.State{currentByteOffset=%s}", Integer.toUnsignedString(currentByteOffset)); + } + } + + /** A class representing the query cursor options. */ + @NullMarked + public static class Options { + private final @Nullable Predicate progressCallback; + private final @Nullable BiPredicate predicateCallback; + + /** + * @param progressCallback Progress handler. + * @param predicateCallback Custom predicate handler. + */ + private Options( + @Nullable Predicate progressCallback, + @Nullable BiPredicate predicateCallback) { + this.progressCallback = progressCallback; + this.predicateCallback = predicateCallback; + } + + /** + * @param progressCallback Progress handler. + */ + public Options(Predicate progressCallback) { + this.progressCallback = progressCallback; + this.predicateCallback = null; + } + + /** + * @param predicateCallback Custom predicate handler. + */ + public Options(BiPredicate predicateCallback) { + this.progressCallback = null; + this.predicateCallback = predicateCallback; + } + } +} diff --git a/src/main/java/io/github/treesitter/jtreesitter/QueryMatch.java b/src/main/java/io/github/treesitter/jtreesitter/QueryMatch.java index 40ab7d7..4a14e86 100644 --- a/src/main/java/io/github/treesitter/jtreesitter/QueryMatch.java +++ b/src/main/java/io/github/treesitter/jtreesitter/QueryMatch.java @@ -1,5 +1,9 @@ package io.github.treesitter.jtreesitter; +import io.github.treesitter.jtreesitter.internal.*; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SegmentAllocator; +import java.util.ArrayList; import java.util.List; import org.jspecify.annotations.NullMarked; @@ -12,6 +16,20 @@ public QueryMatch(@Unsigned int patternIndex, List captures) { this.captures = List.copyOf(captures); } + static QueryMatch from(MemorySegment match, List captureNames, Tree tree, SegmentAllocator allocator) { + var count = Short.toUnsignedInt(TSQueryMatch.capture_count(match)); + var matchCaptures = TSQueryMatch.captures(match); + var captureList = new ArrayList(count); + for (int i = 0; i < count; ++i) { + var capture = TSQueryCapture.asSlice(matchCaptures, i); + var name = captureNames.get(TSQueryCapture.index(capture)); + var node = TSNode.allocate(allocator).copyFrom(TSQueryCapture.node(capture)); + captureList.add(new QueryCapture(name, new Node(node, tree))); + } + var patternIndex = TSQueryMatch.pattern_index(match); + return new QueryMatch(patternIndex, captureList); + } + /** Find the nodes that are captured by the given capture name. */ public List findNodes(String capture) { return captures.stream() diff --git a/src/main/java/io/github/treesitter/jtreesitter/QueryPredicate.java b/src/main/java/io/github/treesitter/jtreesitter/QueryPredicate.java index bf943a2..1f4a817 100644 --- a/src/main/java/io/github/treesitter/jtreesitter/QueryPredicate.java +++ b/src/main/java/io/github/treesitter/jtreesitter/QueryPredicate.java @@ -8,7 +8,7 @@ /** * A query predicate that associates conditions (or arbitrary metadata) with a pattern. * - * @see Predicates + * @see Predicates */ @NullMarked public sealed class QueryPredicate permits QueryPredicate.AnyOf, QueryPredicate.Eq, QueryPredicate.Match { diff --git a/src/main/java/io/github/treesitter/jtreesitter/TreeCursor.java b/src/main/java/io/github/treesitter/jtreesitter/TreeCursor.java index 3ba8013..8929555 100644 --- a/src/main/java/io/github/treesitter/jtreesitter/TreeCursor.java +++ b/src/main/java/io/github/treesitter/jtreesitter/TreeCursor.java @@ -9,7 +9,12 @@ import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; -/** A class that can be used to efficiently walk a {@linkplain Tree syntax tree}. */ +/** + * A class that can be used to efficiently walk a {@linkplain Tree syntax tree}. + * + * @apiNote The node the cursor was constructed with is considered the + * root of the cursor, and the cursor cannot walk outside this node. + */ @NullMarked public final class TreeCursor implements AutoCloseable, Cloneable { private final MemorySegment self; @@ -168,8 +173,8 @@ public void gotoDescendant(@Unsigned int index) { } /** - * Move the cursor to the first child of its current - * node that extends beyond the given byte offset. + * Move the cursor to the first child of its current node + * that contains or starts after the given byte offset. * * @return The index of the child node, if found. */ @@ -181,8 +186,8 @@ public void gotoDescendant(@Unsigned int index) { } /** - * Move the cursor to the first child of its current - * node that extends beyond the given point. + * Move the cursor to the first child of its current node + * that contains or starts after the given point. * * @return The index of the child node, if found. */ diff --git a/src/test/java/io/github/treesitter/jtreesitter/LanguageTest.java b/src/test/java/io/github/treesitter/jtreesitter/LanguageTest.java index 9ddecef..4c3528c 100644 --- a/src/test/java/io/github/treesitter/jtreesitter/LanguageTest.java +++ b/src/test/java/io/github/treesitter/jtreesitter/LanguageTest.java @@ -15,8 +15,18 @@ static void beforeAll() { } @Test - void getVersion() { - assertEquals(14, language.getVersion()); + void getAbiVersion() { + assertEquals(14, language.getAbiVersion()); + } + + @Test + void getName() { + assertNull(language.getName()); + } + + @Test + void getMetadata() { + assertNull(language.getMetadata()); } @Test @@ -46,6 +56,16 @@ void getSymbolForName() { assertEquals((short) 0, language.getSymbolForName("$", false)); } + @Test + void getSupertypes() { + assertArrayEquals(new short[0], language.getSupertypes()); + } + + @Test + void getSubtypes() { + assertArrayEquals(new short[0], language.getSubtypes((short) 1)); + } + @Test void isNamed() { assertTrue(language.isNamed((short) 1)); @@ -84,11 +104,6 @@ void lookaheadIterator() { }); } - @Test - void query() { - assertDoesNotThrow(() -> language.query("(identifier) @ident").close()); - } - @Test void testEquals() { var other = new Language(TreeSitterJava.language()); diff --git a/src/test/java/io/github/treesitter/jtreesitter/ParserTest.java b/src/test/java/io/github/treesitter/jtreesitter/ParserTest.java index 1044893..231bf78 100644 --- a/src/test/java/io/github/treesitter/jtreesitter/ParserTest.java +++ b/src/test/java/io/github/treesitter/jtreesitter/ParserTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.*; import io.github.treesitter.jtreesitter.languages.TreeSitterJava; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.concurrent.*; @@ -38,28 +39,11 @@ void setLanguage() { assertEquals(language, parser.getLanguage()); } - @Test - void getTimeoutMicros() { - assertEquals(0L, parser.getTimeoutMicros()); - } - - @Test - void setTimeoutMicros() { - assertSame(parser, parser.setTimeoutMicros(10L)); - assertEquals(10L, parser.getTimeoutMicros()); - } - @Test void setLogger() { assertSame(parser, parser.setLogger(null)); } - @Test - void setCancellationFlag() { - var flag = new Parser.CancellationFlag(); - assertSame(parser, parser.setCancellationFlag(flag)); - } - @Test void getIncludedRanges() { assertEquals(1, parser.getIncludedRanges().size()); @@ -90,7 +74,8 @@ void parseUtf8() { @DisplayName("parse(utf16)") void parseUtf16() { parser.setLanguage(language); - try (var tree = parser.parse("var java = \"💩\";", InputEncoding.UTF_16).orElseThrow()) { + var encoding = InputEncoding.valueOf(StandardCharsets.UTF_16); + try (var tree = parser.parse("var java = \"💩\";", encoding).orElseThrow()) { var rootNode = tree.getRootNode(); assertEquals(32, rootNode.getEndByte()); @@ -132,6 +117,7 @@ void parseCallback() { @Test @DisplayName("parse(timeout)") + @SuppressWarnings("deprecation") void parseTimeout() { var source = "}".repeat(1024); // NOTE: can't use _ because of palantir/palantir-java-format#934 @@ -143,6 +129,7 @@ void parseTimeout() { @Test @DisplayName("parse(cancellation)") + @SuppressWarnings("deprecation") void parseCancellation() { var source = "}".repeat(1024 * 1024); // NOTE: can't use _ because of palantir/palantir-java-format#934 @@ -167,10 +154,27 @@ void parseCancellation() { } } + @Test + @DisplayName("parse(options)") + void parseOptions() { + var source = "}".repeat(1024); + // NOTE: can't use _ because of palantir/palantir-java-format#934 + ParseCallback callback = (offset, p) -> source.substring(offset, Integer.min(source.length(), offset + 1)); + var options = new Parser.Options((state) -> state.getCurrentByteOffset() <= 1000); + + parser.setLanguage(language); + assertTrue(parser.parse(callback, InputEncoding.UTF_8, options).isEmpty()); + } + @Test void reset() { - parser.setLanguage(language).setTimeoutMicros(1L); - parser.parse("{".repeat(1024)); + var source = "class foo bar() {}"; + // NOTE: can't use _ because of palantir/palantir-java-format#934 + ParseCallback callback = (offset, p) -> source.substring(offset, Integer.min(source.length(), offset + 1)); + var options = new Parser.Options(Parser.State::hasError); + + parser.setLanguage(language); + parser.parse(callback, InputEncoding.UTF_8, options); parser.reset(); try (var tree = parser.parse("String foo;").orElseThrow()) { assertFalse(tree.getRootNode().hasError()); diff --git a/src/test/java/io/github/treesitter/jtreesitter/QueryCursorTest.java b/src/test/java/io/github/treesitter/jtreesitter/QueryCursorTest.java new file mode 100644 index 0000000..aed0c73 --- /dev/null +++ b/src/test/java/io/github/treesitter/jtreesitter/QueryCursorTest.java @@ -0,0 +1,179 @@ +package io.github.treesitter.jtreesitter; + +import static org.junit.jupiter.api.Assertions.*; + +import io.github.treesitter.jtreesitter.languages.TreeSitterJava; +import java.util.function.Consumer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +public class QueryCursorTest { + private static Language language; + private static Parser parser; + private static final String source = + """ + (identifier) @identifier + + (class_declaration + name: (identifier) @class + (class_body) @body) + """ + .stripIndent(); + + @BeforeAll + static void beforeAll() { + language = new Language(TreeSitterJava.language()); + parser = new Parser(language); + } + + @AfterAll + static void afterAll() { + parser.close(); + } + + private static void assertCursor(Consumer assertions) { + assertCursor(source, assertions); + } + + private static void assertCursor(String source, Consumer assertions) { + try (var query = new Query(language, source)) { + try (var cursor = new QueryCursor(query)) { + assertions.accept(cursor); + } + } + } + + @Test + void getMatchLimit() { + assertCursor(cursor -> assertEquals(-1, cursor.getMatchLimit())); + } + + @Test + void setMatchLimit() { + assertCursor(cursor -> { + assertSame(cursor, cursor.setMatchLimit(10)); + assertEquals(10, cursor.getMatchLimit()); + }); + } + + @Test + void setMaxStartDepth() { + assertCursor(cursor -> assertSame(cursor, cursor.setMaxStartDepth(10))); + } + + @Test + void setByteRange() { + assertCursor(cursor -> assertSame(cursor, cursor.setByteRange(1, 10))); + } + + @Test + void setPointRange() { + assertCursor(cursor -> { + Point start = new Point(0, 1), end = new Point(1, 10); + assertSame(cursor, cursor.setPointRange(start, end)); + }); + } + + @Test + void didExceedMatchLimit() { + assertCursor(cursor -> assertFalse(cursor.didExceedMatchLimit())); + } + + @Test + void findCaptures() { + try (var tree = parser.parse("class Foo {}").orElseThrow()) { + assertCursor(cursor -> { + var matches = cursor.findCaptures(tree.getRootNode()).toList(); + assertEquals(3, matches.size()); + assertEquals(0, matches.get(0).getKey()); + assertEquals(0, matches.get(1).getKey()); + assertNotEquals(matches.get(0).getValue(), matches.get(1).getValue()); + }); + } + } + + @Test + void findMatches() { + try (var tree = parser.parse("class Foo {}").orElseThrow()) { + assertCursor(cursor -> { + var matches = cursor.findMatches(tree.getRootNode()).toList(); + assertEquals(2, matches.size()); + assertEquals(0, matches.getFirst().patternIndex()); + assertEquals(1, matches.getLast().patternIndex()); + }); + } + + try (var tree = parser.parse("int y = x + 1;").orElseThrow()) { + var source = + """ + ((variable_declarator + (identifier) @y + (binary_expression + (identifier) @x)) + (#not-eq? @y @x)) + """ + .stripIndent(); + assertCursor(source, cursor -> { + var matches = cursor.findMatches(tree.getRootNode()).toList(); + assertEquals(1, matches.size()); + assertEquals( + "y", matches.getFirst().captures().getFirst().node().getText()); + }); + } + + try (var tree = parser.parse("class Foo{}\nclass Bar {}").orElseThrow()) { + var source = """ + ((identifier) @foo + (#eq? @foo "Foo")) + """ + .stripIndent(); + assertCursor(source, cursor -> { + var matches = cursor.findMatches(tree.getRootNode()).toList(); + assertEquals(1, matches.size()); + assertEquals( + "Foo", matches.getFirst().captures().getFirst().node().getText()); + }); + + source = """ + ((identifier) @name + (#not-any-of? @name "Foo" "Bar")) + """ + .stripIndent(); + assertCursor(source, cursor -> { + var matches = cursor.findMatches(tree.getRootNode()).toList(); + assertTrue(matches.isEmpty()); + }); + + source = """ + ((identifier) @foo + (#ieq? @foo "foo")) + """ + .stripIndent(); + assertCursor(source, cursor -> { + var options = new QueryCursor.Options((predicate, match) -> { + if (!predicate.getName().equals("ieq?")) return true; + var args = predicate.getArgs(); + var node = match.findNodes(args.getFirst().value()).getFirst(); + return args.getLast().value().equalsIgnoreCase(node.getText()); + }); + var matches = cursor.findMatches(tree.getRootNode(), options).toList(); + assertEquals(1, matches.size()); + assertEquals( + "Foo", matches.getFirst().captures().getFirst().node().getText()); + }); + } + + // Verify that capture count is treated as `uint16_t` and not as signed Java `short` + try (var tree = parser.parse(";".repeat(Short.MAX_VALUE + 1)).orElseThrow()) { + var source = """ + ";"+ @capture + """; + assertCursor(source, cursor -> { + var matches = cursor.findMatches(tree.getRootNode()).toList(); + assertEquals(1, matches.size()); + assertEquals(Short.MAX_VALUE + 1, matches.getFirst().captures().size()); + }); + } + } +} diff --git a/src/test/java/io/github/treesitter/jtreesitter/QueryTest.java b/src/test/java/io/github/treesitter/jtreesitter/QueryTest.java index f5a8bc0..612fcd4 100644 --- a/src/test/java/io/github/treesitter/jtreesitter/QueryTest.java +++ b/src/test/java/io/github/treesitter/jtreesitter/QueryTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.*; import io.github.treesitter.jtreesitter.languages.TreeSitterJava; +import java.util.List; import java.util.NoSuchElementException; import java.util.function.Consumer; import org.junit.jupiter.api.AfterAll; @@ -84,57 +85,18 @@ void getPatternCount() { } @Test - void getCaptureCount() { - assertQuery(query -> assertEquals(3, query.getCaptureCount())); + void getCaptureNames() { + assertQuery(query -> assertIterableEquals(List.of("identifier", "class", "body"), query.getCaptureNames())); } @Test - void getMatchLimit() { - assertQuery(query -> assertEquals(-1, query.getMatchLimit())); - } - - @Test - void setMatchLimit() { - assertQuery(query -> { - assertSame(query, query.setMatchLimit(10)); - assertEquals(10, query.getMatchLimit()); - }); - } - - @Test - void getTimeoutMicros() { - assertQuery(query -> assertEquals(0, query.getTimeoutMicros())); - } - - @Test - void setTimeoutMicros() { - assertQuery(query -> { - assertSame(query, query.setTimeoutMicros(10)); - assertEquals(10, query.getTimeoutMicros()); - }); - } - - @Test - void setMaxStartDepth() { - assertQuery(query -> assertSame(query, query.setMaxStartDepth(10))); - } - - @Test - void setByteRange() { - assertQuery(query -> assertSame(query, query.setByteRange(1, 10))); - } - - @Test - void setPointRange() { - assertQuery(query -> { - Point start = new Point(0, 1), end = new Point(1, 10); - assertSame(query, query.setPointRange(start, end)); - }); - } - - @Test - void didExceedMatchLimit() { - assertQuery(query -> assertFalse(query.didExceedMatchLimit())); + void getStringValues() { + var source = """ + ((identifier) @foo + (#eq? @foo "Foo")) + """ + .stripIndent(); + assertQuery(source, query -> assertIterableEquals(List.of("eq?", "Foo"), query.getStringValues())); } @Test @@ -216,89 +178,4 @@ void getPatternAssertions() { assertEquals("FOO", assertions.get("foo").orElse(null)); }); } - - @Test - void findMatches() { - try (var tree = parser.parse("class Foo {}").orElseThrow()) { - assertQuery(query -> { - var matches = query.findMatches(tree.getRootNode()).toList(); - assertEquals(2, matches.size()); - assertEquals(0, matches.getFirst().patternIndex()); - assertEquals(1, matches.getLast().patternIndex()); - }); - } - - try (var tree = parser.parse("int y = x + 1;").orElseThrow()) { - var source = - """ - ((variable_declarator - (identifier) @y - (binary_expression - (identifier) @x)) - (#not-eq? @y @x)) - """ - .stripIndent(); - assertQuery(source, query -> { - var matches = query.findMatches(tree.getRootNode()).toList(); - assertEquals(1, matches.size()); - assertEquals( - "y", matches.getFirst().captures().getFirst().node().getText()); - }); - } - - try (var tree = parser.parse("class Foo{}\nclass Bar {}").orElseThrow()) { - var source = """ - ((identifier) @foo - (#eq? @foo "Foo")) - """ - .stripIndent(); - assertQuery(source, query -> { - var matches = query.findMatches(tree.getRootNode()).toList(); - assertEquals(1, matches.size()); - assertEquals( - "Foo", matches.getFirst().captures().getFirst().node().getText()); - }); - - source = - """ - ((identifier) @name - (#not-any-of? @name "Foo" "Bar")) - """ - .stripIndent(); - assertQuery(source, query -> { - var matches = query.findMatches(tree.getRootNode()).toList(); - assertTrue(matches.isEmpty()); - }); - - source = """ - ((identifier) @foo - (#ieq? @foo "foo")) - """ - .stripIndent(); - assertQuery(source, query -> { - var matches = query.findMatches(tree.getRootNode(), (predicate, match) -> { - if (!predicate.getName().equals("ieq?")) return true; - var args = predicate.getArgs(); - var node = match.findNodes(args.getFirst().value()).getFirst(); - return args.getLast().value().equalsIgnoreCase(node.getText()); - }) - .toList(); - assertEquals(1, matches.size()); - assertEquals( - "Foo", matches.getFirst().captures().getFirst().node().getText()); - }); - } - - // Verify that capture count is treated as `uint16_t` and not as signed Java `short` - try (var tree = parser.parse(";".repeat(Short.MAX_VALUE + 1)).orElseThrow()) { - var source = """ - ";"+ @capture - """; - assertQuery(source, query -> { - var matches = query.findMatches(tree.getRootNode()).toList(); - assertEquals(1, matches.size()); - assertEquals(Short.MAX_VALUE + 1, matches.getFirst().captures().size()); - }); - } - } }