Skip to content

Commit e4ca7bc

Browse files
committed
LDEV-1402 add supportsFunctionBreakpoints support
1 parent b476213 commit e4ca7bc

File tree

3 files changed

+247
-18
lines changed

3 files changed

+247
-18
lines changed

luceedebug/src/main/java/luceedebug/DapServer.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ public CompletableFuture<Capabilities> initialize(InitializeRequestArguments arg
330330
c.setSupportsBreakpointLocationsRequest(true);
331331
c.setSupportsSetVariable(true);
332332
c.setSupportsCompletionsRequest(true);
333+
c.setSupportsFunctionBreakpoints(true);
333334
Log.debug("Returning capabilities with exceptionBreakpointFilters: " + java.util.Arrays.toString(c.getExceptionBreakpointFilters()));
334335

335336
return CompletableFuture.completedFuture(c);
@@ -794,6 +795,52 @@ public CompletableFuture<SetExceptionBreakpointsResponse> setExceptionBreakpoint
794795
return CompletableFuture.completedFuture(new SetExceptionBreakpointsResponse());
795796
}
796797

798+
/**
799+
* Handle function breakpoints - break when a function with a given name is called.
800+
* Supports:
801+
* - Simple names: "onRequestStart" matches any function with that name
802+
* - Qualified names: "User.save" matches save() in User.cfc only
803+
* - Wildcards: "on*" matches onRequestStart, onError, etc.
804+
* - Conditions: "onRequestStart" with condition "cgi.script_name contains '/api/'"
805+
*/
806+
@Override
807+
public CompletableFuture<SetFunctionBreakpointsResponse> setFunctionBreakpoints(
808+
SetFunctionBreakpointsArguments args) {
809+
FunctionBreakpoint[] bps = args.getBreakpoints();
810+
Log.debug("setFunctionBreakpoints: " + (bps != null ? bps.length : 0) + " breakpoints");
811+
812+
if (bps == null || bps.length == 0) {
813+
NativeDebuggerListener.clearFunctionBreakpoints();
814+
return CompletableFuture.completedFuture(new SetFunctionBreakpointsResponse());
815+
}
816+
817+
String[] names = new String[bps.length];
818+
String[] conditions = new String[bps.length];
819+
820+
for (int i = 0; i < bps.length; i++) {
821+
names[i] = bps[i].getName();
822+
conditions[i] = bps[i].getCondition();
823+
Log.debug(" Function breakpoint: " + names[i] +
824+
(conditions[i] != null ? " condition=" + conditions[i] : ""));
825+
}
826+
827+
NativeDebuggerListener.setFunctionBreakpoints(names, conditions);
828+
829+
// Build response - mark all as verified (we can't validate until runtime)
830+
Breakpoint[] result = new Breakpoint[bps.length];
831+
for (int i = 0; i < bps.length; i++) {
832+
Breakpoint bp = new Breakpoint();
833+
bp.setId(i + 1);
834+
bp.setVerified(true);
835+
bp.setMessage("Function breakpoint: " + names[i]);
836+
result[i] = bp;
837+
}
838+
839+
SetFunctionBreakpointsResponse response = new SetFunctionBreakpointsResponse();
840+
response.setBreakpoints(result);
841+
return CompletableFuture.completedFuture(response);
842+
}
843+
797844
/**
798845
* Returns exception details when stopped due to an exception.
799846
* VSCode calls this after receiving a stopped event with reason="exception".

luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java

Lines changed: 180 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,23 @@ public static String getName() {
5858
private static int bpMaxLine = Integer.MIN_VALUE;
5959
private static int bpMaxPathLen = 0;
6060

61+
/**
62+
* Function breakpoint storage - parallel arrays for fast lookup.
63+
* Writers synchronize on funcBpLock, readers just access the arrays.
64+
*/
65+
private static final Object funcBpLock = new Object();
66+
private static String[] funcBpNames = new String[0]; // lowercase function names
67+
private static String[] funcBpComponents = new String[0]; // lowercase component names (null = any)
68+
private static String[] funcBpConditions = new String[0]; // CFML conditions (null = unconditional)
69+
private static boolean[] funcBpIsWildcard = new boolean[0]; // true if name ends with *
70+
71+
/**
72+
* Pre-computed bounds for fast rejection in onFunctionEntry().
73+
*/
74+
private static int funcBpMinLen = Integer.MAX_VALUE;
75+
private static int funcBpMaxLen = Integer.MIN_VALUE;
76+
private static volatile boolean hasFuncBps = false;
77+
6178
/**
6279
* Map of Java thread ID -> WeakReference<PageContext> for natively suspended threads.
6380
* Used to call debuggerResume() when DAP continue is received.
@@ -190,7 +207,7 @@ private static class CachedExecutableLines {
190207
*/
191208
private static void updateHasSuspendConditions() {
192209
hasSuspendConditions = dapClientConnected &&
193-
(bpLines.length > 0 || breakOnUncaughtExceptions || !steppingThreads.isEmpty() || !threadsToPause.isEmpty());
210+
(bpLines.length > 0 || hasFuncBps || breakOnUncaughtExceptions || !steppingThreads.isEmpty() || !threadsToPause.isEmpty());
194211
}
195212

196213
/**
@@ -875,19 +892,23 @@ public static boolean shouldSuspend(PageContext pc, String file, int line) {
875892
/**
876893
* Evaluate a CFML condition expression and return its boolean result.
877894
* Returns false if evaluation fails (exception, timeout, etc.).
895+
* Uses reflection to call Lucee's Evaluate function to avoid classloader issues.
878896
*/
879897
private static boolean evaluateCondition(PageContext pc, String condition) {
880898
try {
881-
// Use Evaluate.call() directly on PageContext - same approach as DebugManager
882-
Object result = lucee.runtime.functions.dynamicEvaluation.Evaluate.call(
883-
pc,
884-
new String[]{ condition }
885-
);
886-
return lucee.runtime.op.Caster.toBoolean(result);
899+
// Use reflection to call Evaluate.call() through Lucee's classloader
900+
ClassLoader luceeLoader = pc.getClass().getClassLoader();
901+
Class<?> evaluateClass = luceeLoader.loadClass("lucee.runtime.functions.dynamicEvaluation.Evaluate");
902+
java.lang.reflect.Method callMethod = evaluateClass.getMethod("call", PageContext.class, Object[].class);
903+
Object result = callMethod.invoke(null, pc, new Object[]{ condition });
904+
905+
// Cast result to boolean using Lucee's Caster
906+
Class<?> casterClass = luceeLoader.loadClass("lucee.runtime.op.Caster");
907+
java.lang.reflect.Method toBooleanMethod = casterClass.getMethod("toBoolean", Object.class);
908+
return (Boolean) toBooleanMethod.invoke(null, result);
887909
} catch (Exception e) {
888910
// Condition evaluation failed - don't suspend
889-
// Log but don't spam - conditions may intentionally reference undefined vars
890-
Log.debug("Condition evaluation failed: " + e.getMessage());
911+
Log.error("Condition evaluation failed: " + e.getMessage());
891912
return false;
892913
}
893914
}
@@ -1080,4 +1101,154 @@ private static PageContext createTemporaryPageContext() {
10801101
}
10811102
return null;
10821103
}
1104+
1105+
// ========== Function Breakpoints ==========
1106+
1107+
/**
1108+
* Check if we should suspend on function entry.
1109+
* Called from Lucee's pushDebuggerFrame via DebuggerListener.onFunctionEntry().
1110+
* Must be blazing fast - every UDF call hits this.
1111+
*/
1112+
public static boolean onFunctionEntry( PageContext pc, String functionName,
1113+
String componentName, String file, int startLine ) {
1114+
// Fast path - no function breakpoints
1115+
if ( !hasFuncBps || !dapClientConnected ) {
1116+
return false;
1117+
}
1118+
1119+
int len = functionName.length();
1120+
1121+
// Length bounds check - rejects 99% of calls instantly
1122+
if ( len < funcBpMinLen || len > funcBpMaxLen ) {
1123+
return false;
1124+
}
1125+
1126+
// Normalize for case-insensitive matching
1127+
String lowerFunc = functionName.toLowerCase();
1128+
String lowerComp = componentName != null ? componentName.toLowerCase() : null;
1129+
1130+
// Check each breakpoint
1131+
String[] names = funcBpNames;
1132+
String[] comps = funcBpComponents;
1133+
String[] conds = funcBpConditions;
1134+
boolean[] wilds = funcBpIsWildcard;
1135+
1136+
for ( int i = 0; i < names.length; i++ ) {
1137+
// Check component qualifier first (if specified)
1138+
if ( comps[i] != null && ( lowerComp == null || !lowerComp.equals( comps[i] ) ) ) {
1139+
continue;
1140+
}
1141+
1142+
// Check function name match
1143+
boolean match;
1144+
if ( wilds[i] ) {
1145+
// Wildcard: "on*" matches "onRequestStart"
1146+
String prefix = names[i].substring( 0, names[i].length() - 1 );
1147+
match = lowerFunc.startsWith( prefix );
1148+
}
1149+
else {
1150+
match = lowerFunc.equals( names[i] );
1151+
}
1152+
1153+
if ( match ) {
1154+
// Check condition if present
1155+
if ( conds[i] != null ) {
1156+
if ( !evaluateCondition( pc, conds[i] ) ) {
1157+
continue;
1158+
}
1159+
}
1160+
Log.info( "Function breakpoint hit: " + functionName +
1161+
( componentName != null ? " in " + componentName : "" ) );
1162+
return true;
1163+
}
1164+
}
1165+
1166+
return false;
1167+
}
1168+
1169+
/**
1170+
* Set function breakpoints (replaces all existing).
1171+
* Called from DapServer.setFunctionBreakpoints().
1172+
*/
1173+
public static void setFunctionBreakpoints( String[] names, String[] conditions ) {
1174+
synchronized ( funcBpLock ) {
1175+
int count = names.length;
1176+
String[] newNames = new String[count];
1177+
String[] newComps = new String[count];
1178+
String[] newConds = new String[count];
1179+
boolean[] newWilds = new boolean[count];
1180+
1181+
int minLen = Integer.MAX_VALUE;
1182+
int maxLen = Integer.MIN_VALUE;
1183+
1184+
for ( int i = 0; i < count; i++ ) {
1185+
String name = names[i].trim();
1186+
String condition = ( conditions != null && i < conditions.length && conditions[i] != null && !conditions[i].isEmpty() )
1187+
? conditions[i] : null;
1188+
1189+
// Parse qualified name: "Component.method" or just "method"
1190+
int dot = name.lastIndexOf( '.' );
1191+
String compName = null;
1192+
String funcName = name;
1193+
if ( dot > 0 ) {
1194+
compName = name.substring( 0, dot ).toLowerCase();
1195+
funcName = name.substring( dot + 1 );
1196+
}
1197+
1198+
// Check for wildcard
1199+
boolean isWild = funcName.endsWith( "*" );
1200+
1201+
// Store lowercase for case-insensitive matching
1202+
newNames[i] = funcName.toLowerCase();
1203+
newComps[i] = compName;
1204+
newConds[i] = condition;
1205+
newWilds[i] = isWild;
1206+
1207+
// Update bounds (for wildcards, use prefix length)
1208+
int effectiveLen = isWild ? funcName.length() - 1 : funcName.length();
1209+
if ( !isWild ) {
1210+
// Exact match: bounds are exact length
1211+
if ( effectiveLen < minLen ) minLen = effectiveLen;
1212+
if ( effectiveLen > maxLen ) maxLen = effectiveLen;
1213+
}
1214+
else {
1215+
// Wildcard: any length >= prefix is possible
1216+
if ( effectiveLen < minLen ) minLen = effectiveLen;
1217+
maxLen = Integer.MAX_VALUE; // can't bound max for wildcards
1218+
}
1219+
1220+
Log.info( "Function breakpoint: " + name +
1221+
( compName != null ? " (component: " + compName + ")" : "" ) +
1222+
( isWild ? " (wildcard)" : "" ) +
1223+
( condition != null ? " condition: " + condition : "" ) );
1224+
}
1225+
1226+
funcBpNames = newNames;
1227+
funcBpComponents = newComps;
1228+
funcBpConditions = newConds;
1229+
funcBpIsWildcard = newWilds;
1230+
funcBpMinLen = count > 0 ? minLen : Integer.MAX_VALUE;
1231+
funcBpMaxLen = count > 0 ? maxLen : Integer.MIN_VALUE;
1232+
}
1233+
hasFuncBps = funcBpNames.length > 0;
1234+
updateHasSuspendConditions();
1235+
Log.info( "Function breakpoints set: " + funcBpNames.length );
1236+
}
1237+
1238+
/**
1239+
* Clear all function breakpoints.
1240+
*/
1241+
public static void clearFunctionBreakpoints() {
1242+
synchronized ( funcBpLock ) {
1243+
funcBpNames = new String[0];
1244+
funcBpComponents = new String[0];
1245+
funcBpConditions = new String[0];
1246+
funcBpIsWildcard = new boolean[0];
1247+
funcBpMinLen = Integer.MAX_VALUE;
1248+
funcBpMaxLen = Integer.MIN_VALUE;
1249+
}
1250+
hasFuncBps = false;
1251+
updateHasSuspendConditions();
1252+
Log.debug( "Function breakpoints cleared" );
1253+
}
10831254
}

luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -220,21 +220,32 @@ private static boolean registerNativeDebuggerListener(ClassLoader luceeLoader, C
220220
pageContextClass, Throwable.class, boolean.class);
221221
final Method onOutputMethod = nativeListenerClass.getMethod("onOutput",
222222
String.class, boolean.class);
223+
final Method onFunctionEntryMethod = nativeListenerClass.getMethod("onFunctionEntry",
224+
pageContextClass, String.class, String.class, String.class, int.class);
223225

224226
// Create proxy in Lucee's classloader, delegating to extension's implementation
225227
Object listenerProxy = java.lang.reflect.Proxy.newProxyInstance(
226228
luceeLoader,
227229
new Class<?>[] { listenerInterface },
228230
(proxy, method, args) -> {
229-
switch (method.getName()) {
230-
case "getName": return getNameMethod.invoke(null);
231-
case "isClientConnected": return isDapClientConnectedMethod.invoke(null);
232-
case "onSuspend": return onSuspendMethod.invoke(null, args);
233-
case "onResume": return onResumeMethod.invoke(null, args);
234-
case "shouldSuspend": return shouldSuspendMethod.invoke(null, args);
235-
case "onException": return onExceptionMethod.invoke(null, args);
236-
case "onOutput": return onOutputMethod.invoke(null, args);
237-
default: return null;
231+
try {
232+
switch (method.getName()) {
233+
case "getName": return getNameMethod.invoke(null);
234+
case "isClientConnected": return isDapClientConnectedMethod.invoke(null);
235+
case "onSuspend": return onSuspendMethod.invoke(null, args);
236+
case "onResume": return onResumeMethod.invoke(null, args);
237+
case "shouldSuspend": return shouldSuspendMethod.invoke(null, args);
238+
case "onException": return onExceptionMethod.invoke(null, args);
239+
case "onOutput": return onOutputMethod.invoke(null, args);
240+
case "onFunctionEntry": return onFunctionEntryMethod.invoke(null, args);
241+
default: return null;
242+
}
243+
} catch (java.lang.reflect.InvocationTargetException e) {
244+
Throwable cause = e.getCause();
245+
Log.error("Proxy invocation failed for " + method.getName(), cause);
246+
// Return safe defaults for boolean methods
247+
if (method.getReturnType() == boolean.class) return false;
248+
return null;
238249
}
239250
}
240251
);

0 commit comments

Comments
 (0)