diff --git a/reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/automaton/NFA.java b/reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/automaton/NFA.java index 07a44974..9314e1c1 100644 --- a/reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/automaton/NFA.java +++ b/reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/automaton/NFA.java @@ -162,36 +162,49 @@ public boolean hasStringStartAnchor() { * requiresStartAnchor() returns true. */ public boolean requiresStartAnchor() { - // BFS to find states with character transitions reachable without going through anchor - Set reachableWithoutAnchor = new HashSet<>(); - Queue queue = new LinkedList<>(); - queue.add(startState); - reachableWithoutAnchor.add(startState); + return requiresAnchorOnAllPaths(AnchorType.START, AnchorType.STRING_START); + } + /** + * Check if a multiline start anchor is REQUIRED to match this pattern. Returns true only if ALL + * paths to character transitions go through a {@code START_MULTILINE}, {@code START}, or {@code + * STRING_START} anchor. {@code START} and {@code STRING_START} are treated as sufficient barriers + * because a pattern anchored at the absolute start of input is trivially also anchored at a line + * start, so the same find()-position optimization applies. + * + *

This is used to optimize find() operations - we can skip positions not following '\n' only + * when all paths require the multiline anchor. + */ + public boolean requiresMultilineStartAnchor() { + boolean hasMultilineAnchor = + states.stream().anyMatch(s -> s.anchor == AnchorType.START_MULTILINE); + if (!hasMultilineAnchor) { + return false; + } + return requiresAnchorOnAllPaths( + AnchorType.START_MULTILINE, AnchorType.START, AnchorType.STRING_START); + } + + private boolean requiresAnchorOnAllPaths(AnchorType... barriers) { + Set barrierSet = EnumSet.copyOf(Arrays.asList(barriers)); + Set visited = new HashSet<>(); + Queue queue = new ArrayDeque<>(); + queue.add(startState); + visited.add(startState); while (!queue.isEmpty()) { NFAState state = queue.poll(); - - // If this state has an anchor, don't follow its transitions - // (paths through this state require the anchor) - if (state.anchor == AnchorType.START || state.anchor == AnchorType.STRING_START) { + if (barrierSet.contains(state.anchor)) { continue; } - - // If this state has character transitions, we can match without anchor if (!state.getTransitions().isEmpty()) { return false; } - - // Follow epsilon transitions (but not through anchor states) for (NFAState next : state.getEpsilonTransitions()) { - if (!reachableWithoutAnchor.contains(next)) { - reachableWithoutAnchor.add(next); + if (visited.add(next)) { queue.add(next); } } } - - // Couldn't find any character transitions reachable without going through an anchor return true; } diff --git a/reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/codegen/NFABytecodeGenerator.java b/reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/codegen/NFABytecodeGenerator.java index 35e1e727..f131b691 100644 --- a/reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/codegen/NFABytecodeGenerator.java +++ b/reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/codegen/NFABytecodeGenerator.java @@ -1885,11 +1885,12 @@ private void generateOptimizedEpsilonClosureWithPrecomputation( int posVar, LocalVariableAllocator allocator, EpsilonClosureSlots preAllocSlots) { - // Check if NFA has assertions - if so, use runtime closure - boolean hasAssertions = nfa.getStates().stream().anyMatch(s -> s.assertionType != null); + // Check if NFA has assertions or anchors - if so, use runtime closure + boolean hasAssertions = + nfa.getStates().stream().anyMatch(s -> s.assertionType != null || s.anchor != null); if (hasAssertions) { - // Assertions require runtime checks, use standard closure with pre-allocated slots + // Assertions/anchors require runtime checks, use standard closure with pre-allocated slots generateEpsilonClosure(mv, statesVar, inputVar, posVar, allocator, preAllocSlots); return; } @@ -2117,6 +2118,84 @@ private void generateEpsilonClosure( // No group tracking in this context (-1, -1) generateAssertionCheck( mv, state, inputVar, posVar, statesVar, worklistVar, stateIdVar, -1, -1, allocator); + } else if (state.anchor != null) { + // Inline position guard: only follow epsilon transitions if anchor passes + Label anchorPassed = new Label(); + switch (state.anchor) { + case START: + case STRING_START: + // pos == 0 + mv.visitVarInsn(ILOAD, posVar); + mv.visitJumpInsn(IFNE, worklistLoop); + break; + case START_MULTILINE: + // pos == 0 || input.charAt(pos-1) == '\n' + mv.visitVarInsn(ILOAD, posVar); + mv.visitJumpInsn(IFEQ, anchorPassed); + mv.visitVarInsn(ALOAD, inputVar); + mv.visitVarInsn(ILOAD, posVar); + mv.visitInsn(ICONST_1); + mv.visitInsn(ISUB); + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "charAt", "(I)C", false); + pushInt(mv, '\n'); + mv.visitJumpInsn(IF_ICMPNE, worklistLoop); + break; + case END: + case STRING_END_ABSOLUTE: + // pos == input.length() + mv.visitVarInsn(ILOAD, posVar); + mv.visitVarInsn(ALOAD, inputVar); + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "length", "()I", false); + mv.visitJumpInsn(IF_ICMPNE, worklistLoop); + break; + case STRING_END: + // \Z: pos == length || (pos == length-1 && charAt(pos) == '\n') + mv.visitVarInsn(ILOAD, posVar); + mv.visitVarInsn(ALOAD, inputVar); + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "length", "()I", false); + mv.visitJumpInsn(IF_ICMPEQ, anchorPassed); + mv.visitVarInsn(ALOAD, inputVar); + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "length", "()I", false); + mv.visitInsn(ICONST_1); + mv.visitInsn(ISUB); + mv.visitVarInsn(ILOAD, posVar); + mv.visitJumpInsn(IF_ICMPNE, worklistLoop); + mv.visitVarInsn(ALOAD, inputVar); + mv.visitVarInsn(ILOAD, posVar); + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "charAt", "(I)C", false); + pushInt(mv, '\n'); + mv.visitJumpInsn(IF_ICMPNE, worklistLoop); + break; + case END_MULTILINE: + // pos == input.length() || input.charAt(pos) == '\n' + mv.visitVarInsn(ILOAD, posVar); + mv.visitVarInsn(ALOAD, inputVar); + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "length", "()I", false); + mv.visitJumpInsn(IF_ICMPEQ, anchorPassed); + mv.visitVarInsn(ALOAD, inputVar); + mv.visitVarInsn(ILOAD, posVar); + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "charAt", "(I)C", false); + pushInt(mv, '\n'); + mv.visitJumpInsn(IF_ICMPNE, worklistLoop); + break; + default: + // Other anchor types (e.g. WORD_BOUNDARY): treat as always passing + break; + } + mv.visitLabel(anchorPassed); + // Anchor passed - add epsilon targets normally + for (NFA.NFAState target : state.getEpsilonTransitions()) { + Label alreadyVisited = new Label(); + checkStateInSetConst(mv, statesVar, target.id, allocator); + mv.visitJumpInsn(IFNE, alreadyVisited); + addStateToSet(mv, statesVar, target.id, allocator); + mv.visitVarInsn(ALOAD, worklistVar); + mv.visitVarInsn(ILOAD, worklistSizeVar); + pushInt(mv, target.id); + mv.visitInsn(IASTORE); + mv.visitIincInsn(worklistSizeVar, 1); + mv.visitLabel(alreadyVisited); + } } else { // No assertion - add epsilon targets normally for (NFA.NFAState target : state.getEpsilonTransitions()) { @@ -5233,13 +5312,13 @@ private boolean tryInlineEpsilonClosure( } } - // Don't inline if closure contains assertion states (they need runtime checking) + // Don't inline if closure contains assertion or anchor states (they need runtime checking) if (!followThroughAssertions) { for (Integer stateId : completeClosure) { NFA.NFAState state = nfa.getStates().stream().filter(s -> s.id == stateId).findFirst().orElse(null); - if (state != null && state.assertionType != null) { - return false; // Can't inline - need runtime assertion checking + if (state != null && (state.assertionType != null || state.anchor != null)) { + return false; // Can't inline - need runtime assertion/anchor checking } } } @@ -6824,14 +6903,15 @@ private void generateEpsilonClosureWithGroups( addStateToSetVar(mv, processedVar, stateIdVar); // Generate switch for O(log N) state lookup - // Collect all states that need processing (have groups, backrefs, assertions, or epsilon - // transitions) + // Collect all states that need processing (have groups, backrefs, assertions, anchors, or + // epsilon transitions) List statesToProcess = new ArrayList<>(); for (NFA.NFAState state : nfa.getStates()) { if (state.enterGroup != null || state.exitGroup != null || state.backrefCheck != null || state.assertionType != null + || state.anchor != null || !state.getEpsilonTransitions().isEmpty()) { statesToProcess.add(state); } @@ -6969,6 +7049,92 @@ else if (state.assertionType != null) { groupStartsVar, groupEndsVar, allocator); + } else if (state.anchor != null) { + // Inline position guard: only follow epsilon transitions if anchor passes + Label anchorPassedWG = new Label(); + switch (state.anchor) { + case START: + case STRING_START: + mv.visitVarInsn(ILOAD, posVar); + mv.visitJumpInsn(IFNE, worklistLoop); + break; + case START_MULTILINE: + mv.visitVarInsn(ILOAD, posVar); + mv.visitJumpInsn(IFEQ, anchorPassedWG); + mv.visitVarInsn(ALOAD, inputVar); + mv.visitVarInsn(ILOAD, posVar); + mv.visitInsn(ICONST_1); + mv.visitInsn(ISUB); + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "charAt", "(I)C", false); + pushInt(mv, '\n'); + mv.visitJumpInsn(IF_ICMPNE, worklistLoop); + break; + case END: + case STRING_END_ABSOLUTE: + mv.visitVarInsn(ILOAD, posVar); + mv.visitVarInsn(ALOAD, inputVar); + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "length", "()I", false); + mv.visitJumpInsn(IF_ICMPNE, worklistLoop); + break; + case STRING_END: + // \Z: pos == length || (pos == length-1 && charAt(pos) == '\n') + mv.visitVarInsn(ILOAD, posVar); + mv.visitVarInsn(ALOAD, inputVar); + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "length", "()I", false); + mv.visitJumpInsn(IF_ICMPEQ, anchorPassedWG); + mv.visitVarInsn(ALOAD, inputVar); + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "length", "()I", false); + mv.visitInsn(ICONST_1); + mv.visitInsn(ISUB); + mv.visitVarInsn(ILOAD, posVar); + mv.visitJumpInsn(IF_ICMPNE, worklistLoop); + mv.visitVarInsn(ALOAD, inputVar); + mv.visitVarInsn(ILOAD, posVar); + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "charAt", "(I)C", false); + pushInt(mv, '\n'); + mv.visitJumpInsn(IF_ICMPNE, worklistLoop); + break; + case END_MULTILINE: + mv.visitVarInsn(ILOAD, posVar); + mv.visitVarInsn(ALOAD, inputVar); + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "length", "()I", false); + mv.visitJumpInsn(IF_ICMPEQ, anchorPassedWG); + mv.visitVarInsn(ALOAD, inputVar); + mv.visitVarInsn(ILOAD, posVar); + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "charAt", "(I)C", false); + pushInt(mv, '\n'); + mv.visitJumpInsn(IF_ICMPNE, worklistLoop); + break; + default: + // Other anchor types (e.g. WORD_BOUNDARY): treat as always passing + break; + } + mv.visitLabel(anchorPassedWG); + // Anchor passed - follow epsilon transitions + for (NFA.NFAState target : state.getEpsilonTransitions()) { + Label alreadyVisitedA = new Label(); + checkStateInSetConst(mv, statesVar, target.id, allocator); + mv.visitJumpInsn(IFNE, alreadyVisitedA); + addStateToSet(mv, statesVar, target.id, allocator); + mv.visitVarInsn(ALOAD, worklistVar); + mv.visitVarInsn(ILOAD, worklistSizeVar); + pushInt(mv, target.id); + mv.visitInsn(IASTORE); + mv.visitIincInsn(worklistSizeVar, 1); + mv.visitLabel(alreadyVisitedA); + // Per-config tracking for POSIX last-match semantics (same as normal epsilon path) + if (usePosixLastMatch && configGroupStartsVar >= 0) { + int groupCount = nfa.getGroupCount(); + generateCopyConfigArray(mv, configGroupStartsVar, stateIdVar, target.id, groupCount); + generateCopyConfigArray(mv, configGroupEndsVar, stateIdVar, target.id, groupCount); + } + if (usePosixLastMatch && parentStateMapVar >= 0) { + mv.visitVarInsn(ALOAD, parentStateMapVar); + pushInt(mv, target.id); + mv.visitVarInsn(ILOAD, stateIdVar); + mv.visitInsn(IASTORE); + } + } } else if (!state.getEpsilonTransitions().isEmpty()) { for (NFA.NFAState target : state.getEpsilonTransitions()) { Label alreadyVisited = new Label(); diff --git a/reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/codegen/OnePassBytecodeGenerator.java b/reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/codegen/OnePassBytecodeGenerator.java index 87bdb9bb..da4ccfef 100644 --- a/reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/codegen/OnePassBytecodeGenerator.java +++ b/reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/codegen/OnePassBytecodeGenerator.java @@ -98,7 +98,7 @@ public class OnePassBytecodeGenerator { public OnePassBytecodeGenerator(NFA nfa) { this.nfa = nfa; this.groupCount = nfa.getGroupCount(); - this.hasMultilineStart = nfa.hasMultilineStartAnchor(); + this.hasMultilineStart = nfa.requiresMultilineStartAnchor(); this.hasStartAnchor = nfa.hasStartAnchor(); this.hasStringStartAnchor = nfa.hasStringStartAnchor(); this.hasEndAnchor = nfa.hasEndAnchor(); diff --git a/reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/codegen/RecursiveDescentBytecodeGenerator.java b/reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/codegen/RecursiveDescentBytecodeGenerator.java index 8b6111b5..1716bdf0 100644 --- a/reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/codegen/RecursiveDescentBytecodeGenerator.java +++ b/reggie-codegen/src/main/java/com/datadoghq/reggie/codegen/codegen/RecursiveDescentBytecodeGenerator.java @@ -3173,8 +3173,33 @@ private void generateSimpleConcat(ConcatNode node, int startIndex) { @Override public Void visitAnchor(AnchorNode node) { // Anchors: check position constraints - if (node.type == AnchorNode.Type.START || node.type == AnchorNode.Type.STRING_START) { - // ^ or \A: must be at start of input + if (node.type == AnchorNode.Type.START && node.multiline) { + // ^ in multiline mode: must be at start of input or after '\n' + // if (pos == 0 || input.charAt(pos-1) == '\n') pass; else return -1; + // S: [] + Label atLineStart = new Label(); + mv.visitVarInsn(ILOAD, 2); // pos + // S: [I] + mv.visitJumpInsn(IFEQ, atLineStart); // if pos == 0 goto pass + // S: [] + mv.visitVarInsn(ALOAD, 1); // input + mv.visitVarInsn(ILOAD, 2); // pos + mv.visitInsn(ICONST_1); + mv.visitInsn(ISUB); + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "charAt", "(I)C", false); + // S: [C] + mv.visitIntInsn(BIPUSH, '\n'); + // S: [C, I] + mv.visitJumpInsn(IF_ICMPEQ, atLineStart); // if charAt(pos-1) == '\n' goto pass + // S: [] + mv.visitInsn(ICONST_M1); + mv.visitInsn(IRETURN); + mv.visitLabel(atLineStart); + // S: [] + mv.visitVarInsn(ILOAD, 2); + mv.visitInsn(IRETURN); + } else if (node.type == AnchorNode.Type.START || node.type == AnchorNode.Type.STRING_START) { + // ^ (non-multiline) or \A: must be at start of input // if (pos != 0) return -1; // S: [] mv.visitVarInsn(ILOAD, 2); // pos @@ -3191,27 +3216,58 @@ public Void visitAnchor(AnchorNode node) { mv.visitVarInsn(ILOAD, 2); // S: [I] mv.visitInsn(IRETURN); + } else if (node.type == AnchorNode.Type.END && node.multiline) { + // $ in multiline mode: must be at end of input or before a '\n' + Label atLineEnd = new Label(); + mv.visitVarInsn(ILOAD, 2); // pos + mv.visitVarInsn(ILOAD, 3); // end + mv.visitJumpInsn(IF_ICMPEQ, atLineEnd); // if pos == end goto pass + mv.visitVarInsn(ALOAD, 1); // input + mv.visitVarInsn(ILOAD, 2); // pos + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "charAt", "(I)C", false); + mv.visitIntInsn(BIPUSH, '\n'); + mv.visitJumpInsn(IF_ICMPEQ, atLineEnd); // if charAt(pos) == '\n' goto pass + mv.visitInsn(ICONST_M1); + mv.visitInsn(IRETURN); + mv.visitLabel(atLineEnd); + mv.visitVarInsn(ILOAD, 2); + mv.visitInsn(IRETURN); + } else if (node.type == AnchorNode.Type.STRING_END) { + // \Z: matches at end of input OR one position before a terminal '\n' + Label atEnd = new Label(); + Label failLabel = new Label(); + mv.visitVarInsn(ILOAD, 2); // pos + mv.visitVarInsn(ILOAD, 3); // end + mv.visitJumpInsn(IF_ICMPEQ, atEnd); // if pos == end → pass + // Check pos == end-1 && input.charAt(pos) == '\n' + mv.visitVarInsn(ILOAD, 2); // pos + mv.visitVarInsn(ILOAD, 3); // end + mv.visitInsn(ICONST_1); + mv.visitInsn(ISUB); // end - 1 + mv.visitJumpInsn(IF_ICMPNE, failLabel); // if pos != end-1 → fail + mv.visitVarInsn(ALOAD, 1); // input + mv.visitVarInsn(ILOAD, 2); // pos + mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/String", "charAt", "(I)C", false); + mv.visitIntInsn(BIPUSH, '\n'); + mv.visitJumpInsn(IF_ICMPNE, failLabel); // if charAt(pos) != '\n' → fail + mv.visitJumpInsn(GOTO, atEnd); + mv.visitLabel(failLabel); + mv.visitInsn(ICONST_M1); + mv.visitInsn(IRETURN); + mv.visitLabel(atEnd); + mv.visitVarInsn(ILOAD, 2); + mv.visitInsn(IRETURN); } else if (node.type == AnchorNode.Type.END - || node.type == AnchorNode.Type.STRING_END || node.type == AnchorNode.Type.STRING_END_ABSOLUTE) { - // $ or \Z or \z: must be at end of input - // if (pos != end) return -1; - // S: [] + // $ (non-multiline) or \z: must be at end of input mv.visitVarInsn(ILOAD, 2); // pos - // S: [I] mv.visitVarInsn(ILOAD, 3); // end - // S: [I, I] Label atEnd = new Label(); mv.visitJumpInsn(IF_ICMPEQ, atEnd); - // S: [] mv.visitInsn(ICONST_M1); - // S: [I] mv.visitInsn(IRETURN); mv.visitLabel(atEnd); - // S: [] - // Return same position mv.visitVarInsn(ILOAD, 2); - // S: [I] mv.visitInsn(IRETURN); } diff --git a/reggie-processor/src/test/java/com/datadoghq/reggie/processor/parsing/RegexParserTest.java b/reggie-processor/src/test/java/com/datadoghq/reggie/processor/parsing/RegexParserTest.java index 2e542399..fd59a953 100644 --- a/reggie-processor/src/test/java/com/datadoghq/reggie/processor/parsing/RegexParserTest.java +++ b/reggie-processor/src/test/java/com/datadoghq/reggie/processor/parsing/RegexParserTest.java @@ -167,6 +167,42 @@ void testAnchors() throws Exception { assertEquals(AnchorNode.Type.WORD_BOUNDARY, ((AnchorNode) boundary).type); } + @Test + void testInlineMultilineFlagInsideCapturingGroupPropagatesToAnchor() throws Exception { + // \n((?m)^b) - (?m) inside capturing group should propagate multiline=true to the ^ anchor + RegexNode node = parser.parse("\n((?m)^b)"); + assertTrue(node instanceof ConcatNode); + ConcatNode concat = (ConcatNode) node; + // Second child is the capturing group + assertTrue(concat.children.get(1) instanceof GroupNode); + GroupNode group = (GroupNode) concat.children.get(1); + assertTrue(group.capturing); + // Group body is a concat of the anchor and 'b' + assertTrue(group.child instanceof ConcatNode); + ConcatNode groupBody = (ConcatNode) group.child; + assertTrue(groupBody.children.get(0) instanceof AnchorNode); + AnchorNode anchor = (AnchorNode) groupBody.children.get(0); + assertEquals(AnchorNode.Type.START, anchor.type); + assertTrue(anchor.multiline, "(?m) inside capturing group must set multiline=true on ^"); + } + + @Test + void testInlineMultilineFlagInsideCapturingGroupPropagatesToEndAnchor() throws Exception { + // ((?m)b$) - (?m) inside capturing group should propagate multiline=true to the $ anchor + RegexNode node = parser.parse("((?m)b$)"); + assertTrue(node instanceof GroupNode); + GroupNode group = (GroupNode) node; + assertTrue(group.capturing); + assertTrue(group.child instanceof ConcatNode); + ConcatNode groupBody = (ConcatNode) group.child; + // Last child should be the $ anchor + RegexNode lastChild = groupBody.children.get(groupBody.children.size() - 1); + assertTrue(lastChild instanceof AnchorNode); + AnchorNode anchor = (AnchorNode) lastChild; + assertEquals(AnchorNode.Type.END, anchor.type); + assertTrue(anchor.multiline, "(?m) inside capturing group must set multiline=true on $"); + } + @Test void testBackreference() throws Exception { RegexNode node = parser.parse("(a)\\1"); diff --git a/reggie-runtime/src/test/java/com/datadoghq/reggie/runtime/InlineModifiersTest.java b/reggie-runtime/src/test/java/com/datadoghq/reggie/runtime/InlineModifiersTest.java index 09f16f87..e3ef4923 100644 --- a/reggie-runtime/src/test/java/com/datadoghq/reggie/runtime/InlineModifiersTest.java +++ b/reggie-runtime/src/test/java/com/datadoghq/reggie/runtime/InlineModifiersTest.java @@ -353,6 +353,70 @@ public void testNonMultiline_EndAnchor() { assertFalse(m.find("abc\nx"), "Should NOT find before newline without (?m)"); } + @Test + public void testMultiline_InlineInCapturingGroup_StartAnchor() { + ReggieMatcher m = Reggie.compile("\n((?m)^b)"); + MatchResult result = m.findMatch("a\nb\n"); + assertNotNull(result, "Pattern \\n((?m)^b) must match \"a\\nb\\n\""); + assertEquals("b", result.group(1), "Group 1 must capture \"b\""); + } + + @Test + public void testMultiline_InlineInGroup_StartAnchor_NoNewline() { + ReggieMatcher m = Reggie.compile("\n((?m)^b)"); + assertNull(m.findMatch("ab")); + assertNull(m.findMatch("a b")); + } + + @Test + public void testMultiline_InlineInGroup_BothAnchors() { + ReggieMatcher m = Reggie.compile("((?m)^hello$)"); + MatchResult result = m.findMatch("first\nhello\nlast"); + assertNotNull(result); + assertEquals("hello", result.group(1)); + } + + @Test + public void testMultiline_InlineInGroup_PicksCorrectLine() { + ReggieMatcher m = Reggie.compile("\n((?m)^target)"); + MatchResult result = m.findMatch("first\ntarget\nlast"); + assertNotNull(result); + assertEquals("target", result.group(1)); + } + + @Test + public void testNonMultiline_CaretInGroup_DoesNotMatchAfterNewline() { + ReggieMatcher m = Reggie.compile("(^b)"); + assertNull(m.findMatch("a\nb")); + MatchResult atStart = m.findMatch("b"); + assertNotNull(atStart); + assertEquals("b", atStart.group(1)); + } + + @Test + public void testMultiline_InlineInCapturingGroup_EndAnchor() { + ReggieMatcher m = Reggie.compile("((?m)hello$)"); + MatchResult result = m.findMatch("hello\nworld"); + assertNotNull(result, "Pattern ((?m)hello$) must match \"hello\\nworld\""); + assertEquals("hello", result.group(1), "Group 1 must capture \"hello\""); + } + + @Test + public void testMultiline_InlineInCapturingGroup_EndAnchor_NoMatch() { + ReggieMatcher m = Reggie.compile("((?m)hello$)"); + assertNull(m.findMatch("helloworld"), "No match when 'hello' not at line end"); + assertNull( + m.findMatch("helloworldx\nhelloworldy"), "No match when 'hello' mid-word on every line"); + } + + @Test + public void testCombinedMultilineCaseInsensitive() { + ReggieMatcher m = Reggie.compile("(?im)^hello"); + MatchResult result = m.findMatch("first\nHELLO\nlast"); + assertNotNull(result, "(?im)^hello must match 'HELLO' after newline"); + assertEquals("HELLO", result.group(0), "Must match case-insensitively"); + } + // ==================== Dotall Mode Tests ==================== @Test