diff --git a/src/main/java/io/github/treesitter/jtreesitter/Query.java b/src/main/java/io/github/treesitter/jtreesitter/Query.java index 690b751..8b81a5c 100644 --- a/src/main/java/io/github/treesitter/jtreesitter/Query.java +++ b/src/main/java/io/github/treesitter/jtreesitter/Query.java @@ -5,14 +5,13 @@ 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; @@ -25,7 +24,7 @@ @NullMarked public final class Query implements AutoCloseable { private final MemorySegment query; - private final MemorySegment cursor; + private final QueryCursorConfig cursorConfig = new QueryCursorConfig(); private final Arena arena; private final Language language; private final String source; @@ -85,7 +84,6 @@ public final class Query implements AutoCloseable { 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); var captureCount = ts_query_capture_count(this.query); captureNames = new ArrayList<>(captureCount); @@ -262,6 +260,10 @@ private static boolean invalidPredicateChar(char c) { return !(Character.isLetterOrDigit(c) || c == '_' || c == '-' || c == '.' || c == '?' || c == '!'); } + MemorySegment self() { + return query; + } + /** Get the number of patterns in the query. */ public @Unsigned int getPatternCount() { return ts_query_pattern_count(query); @@ -272,25 +274,30 @@ private static boolean invalidPredicateChar(char c) { return ts_query_capture_count(query); } + public List> getPredicates() { + return predicates.stream().map(Collections::unmodifiableList).toList(); + } + + public List getCaptureNames() { + return Collections.unmodifiableList(captureNames); + } + /** - * Get the maximum number of in-progress matches. + * Get the maximum number of in-progress matches of the default {@link QueryCursorConfig} * * @apiNote Defaults to {@code -1} (unlimited). */ public @Unsigned int getMatchLimit() { - return ts_query_cursor_match_limit(cursor); + return cursorConfig.getMatchLimit(); } /** - * Get the maximum number of in-progress matches. + * Set the maximum number of in-progress matches of the default {@link QueryCursorConfig} * * @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); + cursorConfig.setMatchLimit(matchLimit); return this; } @@ -300,9 +307,11 @@ public Query setMatchLimit(@Unsigned int matchLimit) throws IllegalArgumentExcep * * @apiNote Defaults to {@code 0} (unlimited). * @since 0.23.1 + * @deprecated */ + @Deprecated(forRemoval = true) public @Unsigned long getTimeoutMicros() { - return ts_query_cursor_timeout_micros(cursor); + return cursorConfig.getTimeoutMicros(); } /** @@ -310,9 +319,11 @@ public Query setMatchLimit(@Unsigned int matchLimit) throws IllegalArgumentExcep * execution should be allowed to take before halting. * * @since 0.23.1 + * @deprecated */ + @Deprecated(forRemoval = true) public Query setTimeoutMicros(@Unsigned long timeoutMicros) { - ts_query_cursor_set_timeout_micros(cursor, timeoutMicros); + cursorConfig.setTimeoutMicros(timeoutMicros); return this; } @@ -323,33 +334,22 @@ public Query setTimeoutMicros(@Unsigned long timeoutMicros) { *
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); + cursorConfig.setMaxStartDepth(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); + cursorConfig.setByteRange(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); - } + cursorConfig.setPointRange(startPoint, endPoint); 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); - } - /** * Disable a certain pattern within a query. * @@ -478,36 +478,77 @@ public Map> getPatternAssertions(@Unsigned int index, b } /** - * Iterate over all the matches in the order that they were found. + * Execute the query on a given node with the default {@link QueryCursorConfig}. + * @param node The node that the query will run on. + * @return A cursor that can be used to iterate over the matches. + */ + public QueryCursor execute(Node node) { + return new QueryCursor(this, node, cursorConfig); + } + + /** + * Execute the query on a given node with the given options. The options override the default options set on the query. + * @param node The node that the query will run on. + * @param options The options that will be used for this query. + * @return A cursor that can be used to iterate over the matches. + */ + public QueryCursor execute(Node node, QueryCursorConfig options) { + return new QueryCursor(this, node, options); + } + + /** + * Iterate over all the matches in the order that they were found. The lifetime of the native memory of the returned + * matches is bound to the lifetime of this query object. * * @param node The node that the query will run on. + * @implNote The stream is not created lazily such that there is no open {@link QueryCursor} instance left behind. + * For creating a lazy stream use {@link #execute(Node)} and {@link QueryCursor#matchStream()}. */ public Stream findMatches(Node node) { - return findMatches(node, null); + return findMatches(node, arena, null); } /** - * Iterate over all the matches in the order that they were found. + * Iterate over all the matches in the order that they were found. The lifetime of the native memory of the returned + * matches is bound to the lifetime of this query object. * *

Predicate Example

*

- * {@snippet lang="java" : + * {@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()); * }); - * } + *} * - * @param node The node that the query will run on. + * @param node The node that the query will run on. * @param predicate A function that handles custom predicates. + * @implNote The stream is not created lazily such that there is no open {@link QueryCursor} instance left behind. + * For creating a lazy stream use {@link #execute(Node)} and {@link QueryCursor#matchStream(BiPredicate)}. */ public Stream findMatches(Node node, @Nullable BiPredicate predicate) { - try (var alloc = Arena.ofConfined()) { - ts_query_cursor_exec(cursor, query, node.copy(alloc)); + return findMatches(node, arena, predicate); + } + + /** + * Like {@link #findMatches(Node, BiPredicate)} but the native memory of the returned matches is created using the + * given allocator. + * + * @param node The node that the query will run on. + * @param allocator The allocator that is used to allocate the native memory of the returned matches. + * @param predicate A function that handles custom predicates. + * @implNote The stream is not created lazily such that there is no open {@link QueryCursor} instance left behind. + * For creating a lazy stream use {@link #execute(Node)} and {@link QueryCursor#matchStream(SegmentAllocator, BiPredicate)}. + */ + public Stream findMatches( + Node node, SegmentAllocator allocator, @Nullable BiPredicate predicate) { + try (QueryCursor cursor = this.execute(node)) { + // make sure to load the entire stream into memory before closing the cursor. + // Otherwise, we call for nextMatch after closing the cursor which leads to an exception. + return cursor.matchStream(allocator, predicate).toList().stream(); } - return StreamSupport.stream(new MatchesIterator(node.getTree(), predicate), false); } @Override @@ -520,52 +561,10 @@ public String toString() { return "Query{language=%s, source=%s}".formatted(language, source); } - private 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); - }); - } - private void checkIndex(@Unsigned int index) throws IndexOutOfBoundsException { if (Integer.compareUnsigned(index, getPatternCount()) >= 0) { throw new 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; - - public MatchesIterator(Tree tree, @Nullable BiPredicate predicate) { - super(Long.MAX_VALUE, Spliterator.IMMUTABLE | Spliterator.NONNULL); - this.predicate = predicate; - this.tree = tree; - } - - @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(arena).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..2714b20 --- /dev/null +++ b/src/main/java/io/github/treesitter/jtreesitter/QueryCursor.java @@ -0,0 +1,225 @@ +package io.github.treesitter.jtreesitter; + +import static io.github.treesitter.jtreesitter.internal.TreeSitter.*; + +import io.github.treesitter.jtreesitter.internal.TSNode; +import io.github.treesitter.jtreesitter.internal.TSQueryCapture; +import io.github.treesitter.jtreesitter.internal.TSQueryMatch; +import io.github.treesitter.jtreesitter.internal.TreeSitter; +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SegmentAllocator; +import java.util.ArrayList; +import java.util.Optional; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.function.BiPredicate; +import java.util.function.Consumer; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Cursor for iterating over the matches produced by a {@link Query}. + *

+ * An instance of this class can be retrieved by calling {@link Query#execute(Node)}. + */ +@NullMarked +public class QueryCursor implements AutoCloseable { + + private final MemorySegment cursor; + private final Arena arena; + + private final Query query; + private final Tree tree; + + QueryCursor(Query query, Node cursorRootNode, @Nullable QueryCursorConfig config) { + arena = Arena.ofConfined(); + cursor = ts_query_cursor_new().reinterpret(arena, TreeSitter::ts_query_cursor_delete); + this.query = query; + this.tree = cursorRootNode.getTree(); + if (config != null) { + applyConfig(config); + } + + try (var alloc = Arena.ofConfined()) { + ts_query_cursor_exec(cursor, query.self(), cursorRootNode.copy(alloc)); + } + } + + private void applyConfig(QueryCursorConfig options) { + + if (options.getStartByte() >= 0 && options.getEndByte() >= 0) { + ts_query_cursor_set_byte_range(cursor, options.getStartByte(), options.getEndByte()); + } + + if (options.getStartPoint() != null && options.getEndPoint() != null) { + try (var alloc = Arena.ofConfined()) { + MemorySegment start = options.getStartPoint().into(alloc); + MemorySegment end = options.getEndPoint().into(alloc); + ts_query_cursor_set_point_range(cursor, start, end); + } + } + + if (options.getMaxStartDepth() >= 0) { + ts_query_cursor_set_max_start_depth(cursor, options.getMaxStartDepth()); + } + + if (options.getMatchLimit() >= 0) { + ts_query_cursor_set_match_limit(cursor, options.getMatchLimit()); + } + + if (options.getTimeoutMicros() >= 0) { + ts_query_cursor_set_timeout_micros(cursor, options.getTimeoutMicros()); + } + } + + /** + * Get the maximum number of in-progress matches. + * + * @apiNote Defaults to {@code -1} (unlimited). + */ + public @Unsigned int getMatchLimit() { + return ts_query_cursor_match_limit(cursor); + } + + /** + * Get the maximum duration in microseconds that query + * execution should be allowed to take before halting. + * + * @apiNote Defaults to {@code 0} (unlimited). + * @since 0.23.1 + * @deprecated + */ + @Deprecated(forRemoval = true) + public @Unsigned long getTimeoutMicros() { + return ts_query_cursor_timeout_micros(cursor); + } + + /** + * 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); + } + + /** + * Stream the matches produced by the query. The stream can not be consumed after the cursor is closed. The native + * nodes backing the matches are bound to the lifetime of the cursor. + * @return a stream of matches + */ + public Stream matchStream() { + return matchStream(null); + } + + /** + * Like {@link #matchStream()} but allows for custom predicates to be applied to the matches. + * @param predicate a function to handle custom predicates. + * @return a stream of matches + */ + public Stream matchStream(@Nullable BiPredicate predicate) { + return matchStream(arena, predicate); + } + + /** + * Like {@link #matchStream(BiPredicate)} but allows for a custom allocator to be used for allocating the native nodes. + * @param allocator allocator to use for allocating the native nodes backing the matches + * @param predicate a function to handle custom predicates. + * @return a stream of matches + */ + public Stream matchStream( + SegmentAllocator allocator, @Nullable BiPredicate predicate) { + return StreamSupport.stream(new MatchesIterator(this, allocator, predicate), false); + } + + /** + * Get the next match produced by the query. The native nodes backing the match are bound to the lifetime of the cursor. + * @return the next match, if available + */ + public Optional nextMatch() { + return nextMatch(null); + } + + /** + * Like {@link #nextMatch()} but allows for custom predicates to be applied to the matches. + * @param predicate a function to handle custom predicates. + * @return the next match, if available + */ + public Optional nextMatch(@Nullable BiPredicate predicate) { + return nextMatch(arena, predicate); + } + + /** + * Like {@link #nextMatch(BiPredicate)} but allows for a custom allocator to be used for allocating the native nodes. + * @param allocator allocator to use for allocating the native nodes backing the matches + * @param predicate a function to handle custom predicates. + * @return the next match, if available + */ + public Optional nextMatch( + SegmentAllocator allocator, @Nullable BiPredicate predicate) { + + 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 = query.getCaptureNames().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)) { + return Optional.of(result); + } + } + return Optional.empty(); + } + + private boolean matches(@Nullable BiPredicate predicate, QueryMatch match) { + return query.getPredicates().get(match.patternIndex()).stream().allMatch(p -> { + if (p.getClass() != QueryPredicate.class) return p.test(match); + return predicate == null || predicate.test(p, match); + }); + } + + @Override + public void close() { + arena.close(); + } + + private static final class MatchesIterator extends Spliterators.AbstractSpliterator { + + private final @Nullable BiPredicate predicate; + private final SegmentAllocator allocator; + + private final QueryCursor cursor; + + public MatchesIterator( + QueryCursor cursor, + SegmentAllocator allocator, + @Nullable BiPredicate predicate) { + super(Long.MAX_VALUE, Spliterator.IMMUTABLE | Spliterator.NONNULL); + this.predicate = predicate; + this.allocator = allocator; + this.cursor = cursor; + } + + @Override + public boolean tryAdvance(Consumer action) { + + if (!cursor.arena.scope().isAlive()) { + throw new IllegalStateException("The underlying QueryCursor is closed. Cannot produce more matches."); + } + + Optional queryMatch = cursor.nextMatch(allocator, predicate); + queryMatch.ifPresent(action); + return queryMatch.isPresent(); + } + } +} diff --git a/src/main/java/io/github/treesitter/jtreesitter/QueryCursorConfig.java b/src/main/java/io/github/treesitter/jtreesitter/QueryCursorConfig.java new file mode 100644 index 0000000..4d57bdc --- /dev/null +++ b/src/main/java/io/github/treesitter/jtreesitter/QueryCursorConfig.java @@ -0,0 +1,124 @@ +package io.github.treesitter.jtreesitter; + +import org.jspecify.annotations.Nullable; + +/** + * Configuration for creating a {@link QueryCursor}. + * + * @see Query#execute(Node, QueryCursorConfig) + */ +public class QueryCursorConfig { + private int matchLimit = -1; // Default to unlimited + private long timeoutMicros = 0; // Default to unlimited + private int maxStartDepth = -1; + private int startByte = -1; + private int endByte = -1; + private Point startPoint; + private Point endPoint; + + /** + * Get the maximum number of in-progress matches. + * + * @apiNote Defaults to {@code -1} (unlimited). + */ + public int getMatchLimit() { + return matchLimit; + } + + /** + * Set the maximum number of in-progress matches. + * + * @apiNote Defaults to {@code -1} (unlimited). + */ + public void setMatchLimit(int matchLimit) throws IllegalArgumentException { + if (matchLimit == 0) { + throw new IllegalArgumentException("The match limit cannot equal 0"); + } + this.matchLimit = matchLimit; + } + + /** + * Get the maximum duration in microseconds that query + * execution should be allowed to take before halting. + * + * @return the timeout in microseconds + * @deprecated + */ + @Deprecated(forRemoval = true) + public long getTimeoutMicros() { + return timeoutMicros; + } + + /** + * Set the maximum duration in microseconds that query execution + * should be allowed to take before halting. + * + * @param timeoutMicros the timeout in microseconds + * @deprecated + */ + @Deprecated(forRemoval = true) + public void setTimeoutMicros(long timeoutMicros) { + this.timeoutMicros = timeoutMicros; + } + + /** + * Get the maximum start depth for the query cursor + */ + public int getMaxStartDepth() { + return maxStartDepth; + } + + /** + * Set the maximum start depth for the query cursor. + * + *

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 void setMaxStartDepth(int maxStartDepth) { + this.maxStartDepth = maxStartDepth; + } + + /** + * Set the range of bytes in which the query will be executed. + */ + public void setByteRange(@Unsigned int startByte, @Unsigned int endByte) { + this.startByte = startByte; + this.endByte = endByte; + } + + /** + * Get the start byte of the range of bytes in which the query will be executed or -1 if not set. + */ + public int getStartByte() { + return startByte; + } + + /** + * Get the end byte of the range of bytes in which the query will be executed or -1 if not set. + */ + public int getEndByte() { + return endByte; + } + + /** + * Set the range of points in which the query will be executed. + */ + public void setPointRange(Point startPoint, Point endPoint) { + this.startPoint = startPoint; + this.endPoint = endPoint; + } + + /** + * Get the start point of the range of points in which the query will be executed or {@code null} if not set. + */ + public @Nullable Point getStartPoint() { + return startPoint; + } + + /** + * Get the end point of the range of points in which the query will be executed or {@code null} if not set. + */ + public @Nullable Point getEndPoint() { + return endPoint; + } +} diff --git a/src/main/java/io/github/treesitter/jtreesitter/QueryMatch.java b/src/main/java/io/github/treesitter/jtreesitter/QueryMatch.java index 40ab7d7..6ba288e 100644 --- a/src/main/java/io/github/treesitter/jtreesitter/QueryMatch.java +++ b/src/main/java/io/github/treesitter/jtreesitter/QueryMatch.java @@ -8,6 +8,7 @@ public record QueryMatch(@Unsigned int patternIndex, List captures) { /** Creates an instance of a QueryMatch record class. */ public QueryMatch(@Unsigned int patternIndex, List captures) { + this.patternIndex = patternIndex; this.captures = List.copyOf(captures); } diff --git a/src/main/java/io/github/treesitter/jtreesitter/TreeCursor.java b/src/main/java/io/github/treesitter/jtreesitter/TreeCursor.java index 18be2b3..0a38cd3 100644 --- a/src/main/java/io/github/treesitter/jtreesitter/TreeCursor.java +++ b/src/main/java/io/github/treesitter/jtreesitter/TreeCursor.java @@ -4,6 +4,7 @@ import java.lang.foreign.Arena; import java.lang.foreign.MemorySegment; +import java.lang.foreign.SegmentAllocator; import java.util.OptionalInt; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -36,7 +37,11 @@ private TreeCursor(TreeCursor cursor) { node = cursor.node; } - /** Get the current node of the cursor. */ + /** + * Get the current node of the cursor. Its native memory will be managed by the cursor. It will become invalid + * once the cursor is closed by calling {@link #close()}. + * @return the current node + */ public Node getCurrentNode() { if (this.node == null) { var node = ts_tree_cursor_current_node(arena, self); @@ -45,6 +50,16 @@ public Node getCurrentNode() { return this.node; } + /** + * Get the current node of the cursor. Its native memory will be managed by the given allocator. + * @param allocator the allocator to use for managing the native memory of the node + * @return the current node + */ + public Node getCurrentNode(SegmentAllocator allocator) { + var node = ts_tree_cursor_current_node(allocator, self); + return new Node(node, tree); + } + /** * Get the depth of the cursor's current node relative to * the original node that the cursor was constructed with. 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..418ac9b --- /dev/null +++ b/src/test/java/io/github/treesitter/jtreesitter/QueryCursorTest.java @@ -0,0 +1,251 @@ +package io.github.treesitter.jtreesitter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import io.github.treesitter.jtreesitter.languages.TreeSitterJava; +import java.lang.foreign.Arena; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class QueryCursorTest { + + private static Language language; + private static Parser parser; + + @BeforeAll + static void beforeAll() { + language = new Language(TreeSitterJava.language()); + parser = new Parser(language); + } + + @AfterAll + static void afterAll() { + parser.close(); + } + + private static void assertQueryCursor(String querySource, String source, Consumer assertions) { + assertQueryCursor(querySource, source, null, assertions); + } + + private static void assertQueryCursor( + String querySource, String source, QueryCursorConfig config, Consumer assertions) { + try (var tree = parser.parse(source).orElseThrow(); + Query query = new Query(language, querySource)) { + assertQueryCursor(query, tree.getRootNode(), config, assertions); + } catch (QueryError e) { + fail("Unexpected query error", e); + } + } + + private static void assertQueryCursor( + Query query, Node node, QueryCursorConfig config, Consumer assertions) { + try (var queryCursor = query.execute(node, config)) { + assertions.accept(queryCursor); + } + } + + @Test + void nextMatch() { + String querySource = "(identifier) @identifier"; + + String source = """ + int a = b; + """; + + assertQueryCursor(querySource, source, qc -> { + Optional currentMatch = qc.nextMatch(); + + assertTrue(currentMatch.isPresent()); + assertEquals("a", currentMatch.get().captures().getFirst().node().getText()); + + currentMatch = qc.nextMatch(); + assertTrue(currentMatch.isPresent()); + assertEquals("b", currentMatch.get().captures().getFirst().node().getText()); + + currentMatch = qc.nextMatch(); + assertFalse(currentMatch.isPresent()); + }); + } + + @Test + void matchStream() { + + String querySource = "(identifier) @identifier"; + + String source = """ + int a = b; + int c = 3; + """; + + assertQueryCursor(querySource, source, qc -> { + List queryMatches = qc.matchStream().toList(); + assertEquals(3, queryMatches.size()); + + List texts = queryMatches.stream() + .flatMap(qm -> qm.captures().stream()) + .map(cap -> cap.node().getText()) + .toList(); + assertEquals(List.of("a", "b", "c"), texts); + }); + } + + @Test + void didExceedMatchLimit() { + String querySource = "(identifier) @identifier"; + + String source = """ + int a = b; + int c = 3; + """; + + assertQueryCursor(querySource, source, qc -> { + assertFalse(qc.didExceedMatchLimit()); + }); + } + + @Test + void testByteRange() { + + String querySource = "(identifier) @identifier"; + + String source = """ + int a = b; + int c = 3; + """; + + QueryCursorConfig config = new QueryCursorConfig(); + config.setByteRange(6, 20); // should ignore a + + assertQueryCursor(querySource, source, config, qc -> { + List queryMatches = qc.matchStream().toList(); + assertEquals(2, queryMatches.size()); + + List texts = queryMatches.stream() + .flatMap(qm -> qm.captures().stream()) + .map(cap -> cap.node().getText()) + .toList(); + assertEquals(List.of("b", "c"), texts); + }); + } + + @Test + void testPointRange() { + String querySource = "(identifier) @identifier"; + + String source = """ + int a = b; + int c = 3; + """; + + QueryCursorConfig config = new QueryCursorConfig(); + config.setPointRange(new Point(0, 0), new Point(1, 0)); // should ignore c + + assertQueryCursor(querySource, source, config, qc -> { + List queryMatches = qc.matchStream().toList(); + assertEquals(2, queryMatches.size()); + + List texts = queryMatches.stream() + .flatMap(qm -> qm.captures().stream()) + .map(cap -> cap.node().getText()) + .toList(); + assertEquals(List.of("a", "b"), texts); + }); + } + + @Test + void testStartDepth() { + + String queryString = "(local_variable_declaration) @decl"; + + String source = + """ + int a = b; + void foo() { + int c = 3; + } + """; + + QueryCursorConfig config = new QueryCursorConfig(); + config.setMaxStartDepth(1); // should ignore second declaration as it is nested in method body + + assertQueryCursor(queryString, source, config, qc -> { + List queryMatches = qc.matchStream().toList(); + assertEquals(1, queryMatches.size()); + + List texts = queryMatches.stream() + .flatMap(qm -> qm.captures().stream()) + .map(cap -> cap.node().getText()) + .toList(); + assertEquals(List.of("int a = b;"), texts); + }); + } + + @Test + void testCustomAllocator() { + String querySource = "(identifier) @identifier"; + + String source = """ + int a = b; + int c = 3; + """; + + try (Arena arena = Arena.ofConfined()) { + + List matches = new ArrayList<>(); + + try (var tree = parser.parse(source).orElseThrow(); + Query query = new Query(language, querySource)) { + + try (var queryCursor = query.execute(tree.getRootNode())) { + List queryMatches = + queryCursor.matchStream(arena, null).toList(); + matches.addAll(queryMatches); + } + + // check that we can still work with the nodes after closing the cursor + List parentTexts = matches.stream() + .flatMap(qm -> qm.captures().stream()) + .flatMap(cap -> cap.node().getParent().stream()) + .map(Node::getText) + .toList(); + + assertEquals(List.of("a = b", "a = b", "c = 3"), parentTexts); + } catch (QueryError e) { + fail("Unexpected query error", e); + } + } + } + + @Test + void testUseMatchStreamAfterClose() { + String querySource = "(identifier) @identifier"; + + String source = """ + int a = b; + int c = 3; + """; + + try (var tree = parser.parse(source).orElseThrow(); + Query query = new Query(language, querySource)) { + + Stream queryStream = null; + try (var queryCursor = query.execute(tree.getRootNode())) { + queryStream = queryCursor.matchStream(); + } + // we cannot use the stream after closing the cursor + assertThrows(IllegalStateException.class, queryStream::toList); + } catch (QueryError e) { + fail("Unexpected query error", e); + } + } +} diff --git a/src/test/java/io/github/treesitter/jtreesitter/QueryTest.java b/src/test/java/io/github/treesitter/jtreesitter/QueryTest.java index f5a8bc0..6b4de54 100644 --- a/src/test/java/io/github/treesitter/jtreesitter/QueryTest.java +++ b/src/test/java/io/github/treesitter/jtreesitter/QueryTest.java @@ -132,11 +132,6 @@ void setPointRange() { }); } - @Test - void didExceedMatchLimit() { - assertQuery(query -> assertFalse(query.didExceedMatchLimit())); - } - @Test void disablePattern() { assertQuery(query -> { diff --git a/src/test/java/io/github/treesitter/jtreesitter/TreeCursorTest.java b/src/test/java/io/github/treesitter/jtreesitter/TreeCursorTest.java index 899f8d1..b05577f 100644 --- a/src/test/java/io/github/treesitter/jtreesitter/TreeCursorTest.java +++ b/src/test/java/io/github/treesitter/jtreesitter/TreeCursorTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.*; import io.github.treesitter.jtreesitter.languages.TreeSitterJava; +import java.lang.foreign.Arena; import org.junit.jupiter.api.*; class TreeCursorTest { @@ -39,6 +40,19 @@ void getCurrentNode() { assertSame(node, cursor.getCurrentNode()); } + @Test + void getCurrentNodeWithCustomAllocator() { + try (var arena = Arena.ofConfined()) { + Node node; + try (TreeCursor copied = cursor.clone()) { + node = copied.getCurrentNode(arena); + assertEquals(tree.getRootNode(), node); + } + // can still access node after cursor was closed + assertEquals(tree.getRootNode(), node); + } + } + @Test void getCurrentDepth() { assertEquals(0, cursor.getCurrentDepth());