diff --git a/.github/workflows/test-dap.yml b/.github/workflows/test-dap.yml new file mode 100644 index 0000000..4e0099b --- /dev/null +++ b/.github/workflows/test-dap.yml @@ -0,0 +1,506 @@ +# DAP Tests for luceedebug +# +# Tests the Debug Adapter Protocol functionality against multiple Lucee versions. +# For 7.1 (native debugger branch), we build from source. +# +# Architecture: +# - Debuggee: Lucee Express (Tomcat) with luceedebug extension, DAP on port 10000, HTTP on 8888 +# - Test Runner: script-runner instance running TestBox tests, connects to debuggee + +name: DAP Tests + +on: [push, pull_request, workflow_dispatch] + +env: + EXPRESS_TEMPLATE_URL: https://cdn.lucee.org/express-templates/lucee-tomcat-11.0.13-template.zip + DAP_PORT: 10000 + DEBUGGEE_HTTP_PORT: 8888 + +jobs: + # Prime Maven cache by running script-runner once + # This ensures the cache is populated for subsequent jobs + prime-maven-cache: + name: Prime Maven cache + runs-on: ubuntu-latest + steps: + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2 + key: maven-cache + - name: write tmp cfm file + run: echo '#now()#' > prime-cache.cfm + - name: Prime cache with script-runner + uses: lucee/script-runner@main + with: + webroot: ${{ github.workspace }}/ + execute: /prime-cache.cfm + luceeVersionQuery: 7.0/all/light + + # Build Lucee 7.1 from the native debugger branch + build-lucee-71: + name: Build Lucee 7.1 (native debugger) + runs-on: ubuntu-latest + + steps: + - name: Checkout Lucee (native debugger branch) + uses: actions/checkout@v4 + with: + repository: zspitzer/Lucee + ref: LDEV-1402-native-debugger + path: lucee + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2 + key: lucee-maven-${{ hashFiles('lucee/**/pom.xml') }} + restore-keys: | + lucee-maven- + + - name: Build Lucee with ant fast + working-directory: lucee/loader + run: ant fast + + - name: Find built JAR + id: find-jar + working-directory: lucee/loader/target + run: | + JAR_FILE=$(ls lucee-*.jar | head -1) + echo "jar_name=$JAR_FILE" >> $GITHUB_OUTPUT + echo "Built JAR: $JAR_FILE" + + - name: Upload Lucee 7.1 JAR + uses: actions/upload-artifact@v4 + with: + name: lucee-71-jar + path: lucee/loader/target/lucee-*.jar + retention-days: 1 + + # Build luceedebug extension + build-extension: + name: Build luceedebug extension + runs-on: ubuntu-latest + + steps: + - name: Checkout luceedebug + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle- + + - name: Build extension with Gradle + run: ./gradlew buildExtension + + - name: Upload extension + uses: actions/upload-artifact@v4 + with: + name: luceedebug-extension + path: luceedebug/build/extension/*.lex + retention-days: 1 + + # Test against Lucee 7.1 (native debugger branch) + test-lucee-71: + name: Test DAP - Lucee 7.1 (native) + runs-on: ubuntu-latest + needs: [build-lucee-71, build-extension] + + steps: + - name: Checkout luceedebug + uses: actions/checkout@v4 + + - name: Checkout Lucee (for test framework) + uses: actions/checkout@v4 + with: + repository: lucee/lucee + path: lucee + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Download Lucee 7.1 JAR + uses: actions/download-artifact@v4 + with: + name: lucee-71-jar + path: lucee-jar + + - name: Download extension + uses: actions/download-artifact@v4 + with: + name: luceedebug-extension + path: extension + + - name: Find Lucee JAR + id: find-jar + run: | + JAR_FILE=$(ls lucee-jar/lucee-*.jar | head -1) + echo "jar_path=$JAR_FILE" >> $GITHUB_OUTPUT + echo "jar_name=$(basename $JAR_FILE)" >> $GITHUB_OUTPUT + echo "Using Lucee JAR: $JAR_FILE" + + # Set up Lucee Express for debuggee + - name: Download Lucee Express template + run: | + curl -L -o express-template.zip "$EXPRESS_TEMPLATE_URL" + unzip -q express-template.zip -d debuggee + + - name: Install Lucee JAR into Express + run: | + # Remove any existing Lucee JAR + rm -f debuggee/lib/lucee-*.jar + # Copy our built JAR + cp ${{ steps.find-jar.outputs.jar_path }} debuggee/lib/ + + - name: Install extension into Express + run: | + mkdir -p debuggee/lucee-server/deploy + cp extension/*.lex debuggee/lucee-server/deploy/ + + - name: Copy test artifacts to debuggee webroot + run: | + mkdir -p debuggee/webapps/ROOT/test/cfml + cp -r test/cfml/artifacts debuggee/webapps/ROOT/test/cfml/ + + - name: Configure debuggee setenv.sh + run: | + echo 'export LUCEE_DAP_SECRET=testing' >> debuggee/bin/setenv.sh + echo 'export LUCEE_DAP_PORT=10000' >> debuggee/bin/setenv.sh + echo 'export LUCEE_LOGGING_FORCE_LEVEL=trace' >> debuggee/bin/setenv.sh + # Enable Felix OSGi debug logging to diagnose bundle unload + echo 'export FELIX_LOG_LEVEL=debug' >> debuggee/bin/setenv.sh + # Enable luceedebug internal debug logging + echo 'export LUCEE_DAP_DEBUG=true' >> debuggee/bin/setenv.sh + chmod +x debuggee/bin/setenv.sh + + - name: Warmup debuggee (Lucee Express) + run: | + cd debuggee + # Configure Tomcat to use port 8888 + sed -i 's/port="8080"/port="8888"/g' conf/server.xml + # Run warmup first - this compiles everything then exits + echo "Running Lucee warmup..." + export LUCEE_ENABLE_WARMUP=true + ./bin/catalina.sh run + echo "Warmup complete" + + - name: Start debuggee (Lucee Express) + run: | + cd debuggee + # Start as daemon - writes stdout to logs/catalina.out + echo "Starting debuggee..." + ./bin/catalina.sh start + echo "Debuggee started" + + - name: Wait for debuggee to be ready + run: | + echo "Waiting for HTTP on port 8888..." + for i in {1..30}; do + if curl -s -o /dev/null -w "%{http_code}" http://localhost:8888/ | grep -q "200\|302\|404"; then + echo "HTTP ready after $i seconds" + break + fi + sleep 1 + done + # Verify artifact is accessible - fail fast if not + echo "Testing artifact access..." + STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8888/test/cfml/artifacts/breakpoint-target.cfm) + echo "Artifact HTTP status: $STATUS" + if [ "$STATUS" != "200" ]; then + echo "ERROR: Artifact not accessible!" + exit 1 + fi + echo "Waiting for DAP on port 10000..." + DAP_READY=false + for i in {1..10}; do + # Try both IPv4 and IPv6 (Java may bind to either depending on system config) + if nc -z 127.0.0.1 10000 2>/dev/null || nc -z ::1 10000 2>/dev/null; then + echo "DAP ready after $i seconds" + DAP_READY=true + break + fi + sleep 1 + done + if [ "$DAP_READY" != "true" ]; then + echo "ERROR: DAP port 10000 not listening!" + # Debug: show what's listening + echo "Listening ports:" + ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null || true + # Debug: dump luceedebug thread state + echo "Luceedebug thread state:" + curl -s http://localhost:8888/test/cfml/artifacts/debug-threads.cfm || echo "Failed to fetch thread dump" + exit 1 + fi + + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2 + key: maven-cache + + + - name: Cache Lucee downloads + uses: actions/cache@v4 + with: + path: _actions/lucee/script-runner/main/lucee-download-cache + key: lucee-downloads-71 + + # Run tests using script-runner (separate Lucee instance) + # Test runner uses 7.0 stable - it just needs to connect to the debuggee + # NOTE: Don't install luceedebug extension in test runner - only debuggee needs it + - name: Run DAP Tests + uses: lucee/script-runner@main + with: + webroot: ${{ github.workspace }}/lucee/test + execute: /bootstrap-tests.cfm + luceeVersionQuery: 7.0/all/light + env: + testLabels: dap + testAdditional: ${{ github.workspace }}/test/cfml + testDebug: "true" + DAP_HOST: localhost + DAP_PORT: "10000" + DAP_SECRET: testing + DEBUGGEE_HTTP: http://localhost:8888 + DEBUGGEE_ARTIFACT_PATH: ${{ github.workspace }}/debuggee/webapps/ROOT/test/cfml/artifacts/ + + - name: Stop debuggee + if: always() + run: | + cd debuggee + ./bin/shutdown.sh || true + + - name: Show catalina.out + if: always() + run: | + echo "=== catalina.out ===" + cat debuggee/logs/catalina.out || echo "No catalina.out found" + + - name: Upload debuggee logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: debuggee-logs-71 + path: | + debuggee/logs/ + debuggee/lucee-server/context/logs/ + debuggee/lucee-server/context/cfclasses/ + + # Test against Lucee 7.0 (stable) - agent mode + # Note: Does NOT use the extension (requires 7.1+), uses luceedebug as Java agent instead + test-lucee-70: + name: Test DAP - Lucee 7.0 (agent) + runs-on: ubuntu-latest + + steps: + - name: Checkout luceedebug + uses: actions/checkout@v4 + + - name: Checkout Lucee (for test framework) + uses: actions/checkout@v4 + with: + repository: lucee/lucee + path: lucee + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + # Set up Lucee Express for debuggee + # Note: Extension is NOT installed for 7.0 - it requires Lucee 7.1+ + # Agent mode uses luceedebug JAR as a Java agent instead + - name: Download Lucee Express template + run: | + curl -L -o express-template.zip "$EXPRESS_TEMPLATE_URL" + unzip -q express-template.zip -d debuggee + + - name: Download Lucee 7.0 JAR + run: | + # Get latest 7.0 snapshot filename via update API + LUCEE_FILENAME=$(curl -s "https://update.lucee.org/rest/update/provider/latest/7.0/all/jar/filename") + # Strip quotes if present (API returns quoted string) + LUCEE_FILENAME=$(echo "$LUCEE_FILENAME" | tr -d '"') + if [ -z "$LUCEE_FILENAME" ] || [[ "$LUCEE_FILENAME" == *"error"* ]]; then + LUCEE_FILENAME="lucee-7.0.2.1-SNAPSHOT.jar" + fi + LUCEE_URL="https://cdn.lucee.org/$LUCEE_FILENAME" + echo "Downloading Lucee from: $LUCEE_URL" + curl -L -f -o lucee.jar "$LUCEE_URL" + # Validate JAR is not corrupt + if ! unzip -t lucee.jar > /dev/null 2>&1; then + echo "ERROR: Downloaded JAR is corrupt!" + exit 1 + fi + # Remove existing and copy new + rm -f debuggee/lib/lucee-*.jar + cp lucee.jar debuggee/lib/ + + - name: Copy test artifacts to debuggee webroot + run: | + mkdir -p debuggee/webapps/ROOT/test/cfml + cp -r test/cfml/artifacts debuggee/webapps/ROOT/test/cfml/ + + - name: Build luceedebug agent JAR + run: | + ./gradlew shadowJar + AGENT_JAR=$(ls luceedebug/build/libs/luceedebug-*.jar | grep -v sources | head -1) + echo "AGENT_JAR=$AGENT_JAR" >> $GITHUB_ENV + cp $AGENT_JAR debuggee/ + + - name: Upload agent JAR + uses: actions/upload-artifact@v4 + with: + name: luceedebug-agent + path: luceedebug/build/libs/luceedebug-*.jar + retention-days: 1 + + - name: Configure debuggee for agent mode + run: | + AGENT_JAR_NAME=$(basename $AGENT_JAR) + # Add JVM args for JDWP and luceedebug agent + # Secret is read from env var at connection time, not javaagent args + echo "export LUCEE_DAP_SECRET=testing" >> debuggee/bin/setenv.sh + echo "export LUCEE_LOGGING_FORCE_LEVEL=trace" >> debuggee/bin/setenv.sh + # Enable luceedebug internal debug logging + echo "export LUCEE_DAP_DEBUG=true" >> debuggee/bin/setenv.sh + echo "export CATALINA_OPTS=\"\$CATALINA_OPTS -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=localhost:9999\"" >> debuggee/bin/setenv.sh + echo "export CATALINA_OPTS=\"\$CATALINA_OPTS -javaagent:\$CATALINA_HOME/$AGENT_JAR_NAME=jdwpHost=localhost,jdwpPort=9999,debugHost=0.0.0.0,debugPort=10000,jarPath=\$CATALINA_HOME/$AGENT_JAR_NAME\"" >> debuggee/bin/setenv.sh + chmod +x debuggee/bin/setenv.sh + + - name: Warmup debuggee (Lucee Express) + run: | + cd debuggee + # Configure Tomcat to use port 8888 + sed -i 's/port="8080"/port="8888"/g' conf/server.xml + # Run warmup first - this compiles everything then exits + echo "Running Lucee warmup..." + export LUCEE_ENABLE_WARMUP=true + ./bin/catalina.sh run + echo "Warmup complete" + + - name: Start debuggee (Lucee Express) + run: | + cd debuggee + # Start as daemon - writes stdout to logs/catalina.out + echo "Starting debuggee..." + ./bin/catalina.sh start + echo "Debuggee started" + + - name: Wait for debuggee to be ready + run: | + echo "Waiting for HTTP on port 8888..." + for i in {1..30}; do + if curl -s -o /dev/null -w "%{http_code}" http://localhost:8888/ | grep -q "200\|302\|404"; then + echo "HTTP ready after $i seconds" + break + fi + sleep 1 + done + # Verify artifact is accessible - fail fast if not + echo "Testing artifact access..." + STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8888/test/cfml/artifacts/breakpoint-target.cfm) + echo "Artifact HTTP status: $STATUS" + if [ "$STATUS" != "200" ]; then + echo "ERROR: Artifact not accessible!" + exit 1 + fi + echo "Waiting for DAP on port 10000..." + DAP_READY=false + for i in {1..10}; do + # Try both IPv4 and IPv6 (Java may bind to either depending on system config) + if nc -z 127.0.0.1 10000 2>/dev/null || nc -z ::1 10000 2>/dev/null; then + echo "DAP ready after $i seconds" + DAP_READY=true + break + fi + sleep 1 + done + if [ "$DAP_READY" != "true" ]; then + echo "ERROR: DAP port 10000 not listening!" + # Debug: show what's listening + echo "Listening ports:" + ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null || true + # Debug: dump luceedebug thread state + echo "Luceedebug thread state:" + curl -s http://localhost:8888/test/cfml/artifacts/debug-threads.cfm || echo "Failed to fetch thread dump" + exit 1 + fi + + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2 + key: maven-cache + + - name: Cache Lucee downloads + uses: actions/cache@v4 + with: + path: _actions/lucee/script-runner/main/lucee-download-cache + key: lucee-downloads-70 + + # NOTE: Don't install luceedebug extension in test runner - only debuggee needs it + - name: Run DAP Tests + uses: lucee/script-runner@main + with: + webroot: ${{ github.workspace }}/lucee/test + execute: /bootstrap-tests.cfm + luceeVersionQuery: 7.0/all/light + env: + testLabels: dap + testAdditional: ${{ github.workspace }}/test/cfml + testDebug: "true" + DAP_HOST: localhost + DAP_PORT: "10000" + DAP_SECRET: testing + DEBUGGEE_HTTP: http://localhost:8888 + DEBUGGEE_ARTIFACT_PATH: ${{ github.workspace }}/debuggee/webapps/ROOT/test/cfml/artifacts/ + + - name: Stop debuggee + if: always() + run: | + cd debuggee + ./bin/shutdown.sh || true + + - name: Show catalina.out + if: always() + run: | + echo "=== catalina.out ===" + cat debuggee/logs/catalina.out || echo "No catalina.out found" + + - name: Upload debuggee logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: debuggee-logs-70 + path: | + debuggee/logs/ + debuggee/lucee-server/context/logs/ + debuggee/lucee-server/context/cfclasses/ diff --git a/.gitignore b/.gitignore index cc6b4af..9dd5919 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,13 @@ build .metals .bloop generated/ -test/scratch \ No newline at end of file +test/scratch +/profiling/output +/test-output +/profiling/test-output +.vscode/settings.json +.claude/ +*.md +!README.md +build-output.txt +test-output.txt diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 0be1c0c..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "java.configuration.updateBuildConfiguration": "automatic", - "java.compile.nullAnalysis.mode": "automatic" -} \ No newline at end of file diff --git a/extension/META-INF/MANIFEST.MF b/extension/META-INF/MANIFEST.MF new file mode 100644 index 0000000..c9994c2 --- /dev/null +++ b/extension/META-INF/MANIFEST.MF @@ -0,0 +1,10 @@ +Manifest-Version: 1.0 +id: FA79A831-7D30-4D8A-B7F300DECEB00001 +name: "Luceedebug" +symbolic-name: "luceedebug" +description: "Native CFML debugger for VS Code - no Java agent required" +version: "3.0.0-BETA" +lucee-core-version: "7.1.0.6" +start-bundles: true +release-type: server +startup-hook: [{"class": "luceedebug.extension.ExtensionActivator", "bundle-name": "luceedebug-osgi", "bundle-version": "3.0.0.0-BETA"}] diff --git a/luceedebug/build.gradle.kts b/luceedebug/build.gradle.kts index 88d028f..a73ea69 100644 --- a/luceedebug/build.gradle.kts +++ b/luceedebug/build.gradle.kts @@ -89,14 +89,14 @@ tasks.jar { "Premain-Class" to "luceedebug.Agent", "Can-Redefine-Classes" to "true", "Bundle-SymbolicName" to "luceedebug-osgi", - "Bundle-Version" to "2.0.1.1", + "Bundle-Version" to "3.0.0.0-BETA", "Export-Package" to "luceedebug.*" ) ) } } -val luceedebugVersion = "2.0.15" +val luceedebugVersion = "3.0.0-BETA" val libfile = "luceedebug-" + luceedebugVersion + ".jar" // TODO: this should, but does not currently, participate in the `clean` task, so the generated file sticks around after invoking `clean`. @@ -117,3 +117,27 @@ tasks.shadowJar { relocationPrefix = "luceedebug_shadow" archiveFileName.set(libfile) } + +// Extension packaging task - creates .lex file for Lucee extension deployment +val extensionVersion = "3.0.0" +val extensionFile = "luceedebug-extension-${extensionVersion}.lex" + +tasks.register("buildExtension") { + dependsOn("shadowJar") + archiveFileName.set(extensionFile) + destinationDirectory.set(file("${layout.buildDirectory.get()}/extension")) + + // Include the shadow JAR as the main library + from(tasks.shadowJar.get().outputs) { + into("jars") + } + + // Include the extension manifest + from("${rootProject.projectDir}/extension/META-INF") { + into("META-INF") + } + + doLast { + println("Built extension: ${destinationDirectory.get()}/${extensionFile}") + } +} diff --git a/luceedebug/src/main/java/luceedebug/Agent.java b/luceedebug/src/main/java/luceedebug/Agent.java index d8390ab..c7c4bc0 100644 --- a/luceedebug/src/main/java/luceedebug/Agent.java +++ b/luceedebug/src/main/java/luceedebug/Agent.java @@ -186,10 +186,23 @@ private static Map linearizedCoreInjectClasses() { result.put("luceedebug.coreinject.frame.Frame$FrameContext", 1); result.put("luceedebug.coreinject.frame.Frame$FrameContext$SupplierOrNull", 1); result.put("luceedebug.coreinject.frame.DummyFrame", 1); - + result.put("luceedebug.coreinject.frame.NativeDebugFrame", 1); + + // Native debugger classes - not used in agent mode but need to be in the map + result.put("luceedebug.coreinject.NativeLuceeVm", 0); + result.put("luceedebug.coreinject.NativeLuceeVm$1", 0); + result.put("luceedebug.coreinject.NativeLuceeVm$2", 0); + result.put("luceedebug.coreinject.NativeLuceeVm$3", 0); + result.put("luceedebug.coreinject.NativeDebuggerListener", 0); + result.put("luceedebug.coreinject.NativeDebuggerListener$1", 0); + result.put("luceedebug.coreinject.NativeDebuggerListener$CachedExecutableLines", 0); + result.put("luceedebug.coreinject.NativeDebuggerListener$StepState", 0); + result.put("luceedebug.coreinject.NativeDebuggerListener$SuspendLocation", 0); + result.put("luceedebug.coreinject.StepMode", 0); + return result; } - + public static Comparator comparator() { final Map ordering = linearizedCoreInjectClasses(); return Comparator.comparing(injection -> { diff --git a/luceedebug/src/main/java/luceedebug/Config.java b/luceedebug/src/main/java/luceedebug/Config.java index 15f68ec..c6e62ed 100644 --- a/luceedebug/src/main/java/luceedebug/Config.java +++ b/luceedebug/src/main/java/luceedebug/Config.java @@ -8,8 +8,23 @@ public class Config { // but for now it's configurable private boolean stepIntoUdfDefaultValueInitFrames_ = false; - Config(boolean fsIsCaseSensitive) { + /** + * Static cache of filesystem case sensitivity. + * Set once at startup when Config is instantiated. + * Used by canonicalizeFileName() to skip lowercase on case-sensitive filesystems. + */ + private static volatile boolean staticFsIsCaseSensitive = false; + + /** + * Base path prefix for shortening paths in log output. + * Set from pathTransforms when DAP client attaches. + */ + private static volatile String basePath = null; + + public Config(boolean fsIsCaseSensitive) { this.fsIsCaseSensitive_ = fsIsCaseSensitive; + // Cache for static access + staticFsIsCaseSensitive = fsIsCaseSensitive; } public boolean getStepIntoUdfDefaultValueInitFrames() { @@ -51,7 +66,37 @@ public boolean getFsIsCaseSensitive() { } public static String canonicalizeFileName(String s) { - return s.replaceAll("[\\\\/]+", "/").toLowerCase(); + // Normalize slashes (always needed) + String normalized = s.replaceAll("[\\\\/]+", "/"); + // Only lowercase on case-insensitive filesystems (Windows) + return staticFsIsCaseSensitive ? normalized : normalized.toLowerCase(); + } + + /** + * Set the base path for shortening paths in log output. + */ + public static void setBasePath(String path) { + basePath = path != null ? canonicalizeFileName(path) : null; + } + + /** + * Shorten a path for display by removing the base path prefix. + * Returns the relative path (with leading /) if it starts with basePath, otherwise the full path. + */ + public static String shortenPath(String path) { + if (basePath == null || path == null) { + return path; + } + String canon = canonicalizeFileName(path); + if (canon.startsWith(basePath)) { + String relative = canon.substring(basePath.length()); + // Ensure leading slash + if (!relative.startsWith("/")) { + relative = "/" + relative; + } + return relative; + } + return path; } } diff --git a/luceedebug/src/main/java/luceedebug/DapServer.java b/luceedebug/src/main/java/luceedebug/DapServer.java index 2fcf32d..de37e14 100644 --- a/luceedebug/src/main/java/luceedebug/DapServer.java +++ b/luceedebug/src/main/java/luceedebug/DapServer.java @@ -24,8 +24,9 @@ import org.eclipse.lsp4j.jsonrpc.services.JsonRequest; import org.eclipse.lsp4j.jsonrpc.util.ToStringBuilder; -import com.sun.jdi.ObjectCollectedException; - +import luceedebug.coreinject.NativeDebuggerListener; +import luceedebug.coreinject.NativeLuceeVm; +import luceedebug.generated.Constants; import luceedebug.strong.CanonicalServerAbsPath; import luceedebug.strong.RawIdePath; @@ -33,11 +34,18 @@ public class DapServer implements IDebugProtocolServer { private final ILuceeVm luceeVm_; private final Config config_; private ArrayList pathTransforms = new ArrayList<>(); + private boolean evaluationEnabled = true; - // for dev, system.out was fine, in some containers, others totally suppress it and it doesn't even + // for dev, system.out was fine, in some containers, others totally suppress it and it doesn't even // end up in log files. // this is all jacked up, on runwar builds it spits out two lines per call to `logger.info(...)` message, the first one being [ERROR] which is not right private static final Logger logger = Logger.getLogger("luceedebug"); + + // Static reference for shutdown within same classloader + private static volatile ServerSocket activeServerSocket; + + // System property key for tracking the server socket across classloaders (OSGi bundle reload) + private static final String SOCKET_PROPERTY = "luceedebug.dap.serverSocket"; private String applyPathTransformsIdeToCf(String s) { for (var transform : pathTransforms) { @@ -72,16 +80,16 @@ private DapServer(ILuceeVm luceeVm, Config config) { this.luceeVm_ = luceeVm; this.config_ = config; - this.luceeVm_.registerStepEventCallback(jdwpThreadID -> { - final var i32_threadID = (int)(long)jdwpThreadID.get(); + this.luceeVm_.registerStepEventCallback(threadID -> { + final var i32_threadID = (int)(long)threadID; var event = new StoppedEventArguments(); event.setReason("step"); event.setThreadId(i32_threadID); clientProxy_.stopped(event); }); - this.luceeVm_.registerBreakpointEventCallback((jdwpThreadID, bpID) -> { - final int i32_threadID = (int)(long)jdwpThreadID.get(); + this.luceeVm_.registerBreakpointEventCallback((threadID, bpID) -> { + final int i32_threadID = (int)(long)threadID; var event = new StoppedEventArguments(); event.setReason("breakpoint"); event.setThreadId(i32_threadID); @@ -113,6 +121,49 @@ private DapServer(ILuceeVm luceeVm, Config config) { clientProxy_.breakpoint(bpEvent); } }); + + // Register native breakpoint callback (Lucee7+ native suspend) + // Uses Java thread ID directly since native suspend doesn't go through JDWP + this.luceeVm_.registerNativeBreakpointEventCallback((javaThreadId, label) -> { + // Use Java thread ID directly as DAP thread ID for native breakpoints + // This is different from JDWP breakpoints which use JDWP thread IDs + final int i32_threadID = (int)(long)javaThreadId; + var event = new StoppedEventArguments(); + event.setReason("breakpoint"); + event.setThreadId(i32_threadID); + // Set label as description if provided (from programmatic breakpoint("label") calls) + if (label != null && !label.isEmpty()) { + event.setDescription(label); + } + clientProxy_.stopped(event); + Log.debug("Stopped event sent: thread=" + javaThreadId + (label != null ? " label=" + label : "")); + }); + + // Register native exception callback (Lucee7+ uncaught exception) + this.luceeVm_.registerExceptionEventCallback(javaThreadId -> { + final int i32_threadID = (int)(long)javaThreadId; + var event = new StoppedEventArguments(); + event.setReason("exception"); + event.setThreadId(i32_threadID); + // Get exception details for the description + Throwable ex = luceeVm_.getExceptionForThread(javaThreadId); + if (ex != null) { + event.setDescription(ex.getClass().getSimpleName() + ": " + ex.getMessage()); + event.setText(ex.getMessage()); + } + clientProxy_.stopped(event); + Log.debug("Sent DAP stopped event for exception, thread=" + javaThreadId + (ex != null ? " exception=" + ex.getClass().getName() : "")); + }); + + // Register native pause callback (user clicked pause button) + this.luceeVm_.registerPauseEventCallback(javaThreadId -> { + final int i32_threadID = (int)(long)javaThreadId; + var event = new StoppedEventArguments(); + event.setReason("pause"); + event.setThreadId(i32_threadID); + clientProxy_.stopped(event); + Log.debug("Sent DAP stopped event for pause, thread=" + javaThreadId); + }); } static class DapEntry { @@ -125,35 +176,162 @@ private DapEntry(DapServer server, Launcher launcher) { } static public DapEntry createForSocket(ILuceeVm luceeVm, Config config, String host, int port) { - try (var server = new ServerSocket()) { + // Log immediately to confirm we entered the method + System.out.println("[luceedebug] createForSocket entered: host=" + host + ", port=" + port); + + // Shut down any existing server first (handles extension reinstall within same classloader) + shutdown(); + + ServerSocket server = null; + try { + System.out.println("[luceedebug] Creating ServerSocket..."); + server = new ServerSocket(); var addr = new InetSocketAddress(host, port); server.setReuseAddress(true); + System.out.println("[luceedebug] Binding to " + host + ":" + port); logger.finest("binding cf dap server socket on " + host + ":" + port); - server.bind(addr); + // Try to bind, with retries for port in use (OSGi bundle reload race condition) + int maxRetries = 5; + java.net.BindException lastBindError = null; + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try { + server.bind(addr); + lastBindError = null; + break; + } catch (java.net.BindException e) { + lastBindError = e; + if (attempt < maxRetries) { + Log.info("Port " + port + " in use, retrying in 1s (attempt " + attempt + "/" + maxRetries + ")"); + try { java.lang.Thread.sleep(1000); } catch (InterruptedException ie) { break; } + } + } + } + if (lastBindError != null) { + throw lastBindError; + } + activeServerSocket = server; + // Store in system properties so it survives classloader changes (OSGi bundle reload) + System.getProperties().put(SOCKET_PROPERTY, server); + System.out.println("[luceedebug] DAP server socket bound successfully on " + host + ":" + port); logger.finest("dap server socket bind OK"); while (true) { + System.out.println("[luceedebug] Waiting for DAP client connection on " + host + ":" + port + "..."); + System.out.println("[luceedebug] ServerSocket state: bound=" + server.isBound() + ", closed=" + server.isClosed() + ", localPort=" + server.getLocalPort()); logger.finest("listening for inbound debugger connection on " + host + ":" + port + "..."); + System.out.println("[luceedebug] Calling server.accept()..."); var socket = server.accept(); + System.out.println("[luceedebug] accept() returned!"); + var clientAddr = socket.getInetAddress().getHostAddress(); + var clientPort = socket.getPort(); - logger.finest("accepted debugger connection"); + Log.info("DAP client connected from " + clientAddr + ":" + clientPort); + logger.finest("accepted debugger connection from " + clientAddr + ":" + clientPort); - var dapEntry = create(luceeVm, config, socket.getInputStream(), socket.getOutputStream()); - var future = dapEntry.launcher.startListening(); - future.get(); // block until the connection closes + // Mark DAP client as connected - enables breakpoint() BIF to suspend (native mode only) + if (luceeVm instanceof NativeLuceeVm) { + luceedebug.coreinject.NativeDebuggerListener.setDapClientConnected(true); + } + + try { + var rawIn = socket.getInputStream(); + var rawOut = socket.getOutputStream(); + var dapEntry = create(luceeVm, config, rawIn, rawOut); + // Enable DAP output for this client + Log.setDapClient(dapEntry.server.clientProxy_); + var future = dapEntry.launcher.startListening(); + try { + future.get(); // block until the connection closes + } catch (Exception e) { + Log.error("Launcher future exception", e); + } + Log.debug("DAP client disconnected from " + clientAddr + ":" + clientPort); + } catch (Exception e) { + Log.error("DAP client error: " + e.getClass().getName(), e); + } finally { + // Clear DAP client FIRST to avoid broken pipe when setDapClientConnected logs + Log.setDapClient(null); + // Mark DAP client as disconnected - disables breakpoint() BIF suspension (native mode only) + if (luceeVm instanceof NativeLuceeVm) { + luceedebug.coreinject.NativeDebuggerListener.setDapClientConnected(false); + } + try { socket.close(); } catch (Exception ignored) {} + System.out.println("[luceedebug] Client socket closed, returning to accept loop"); + } logger.finest("debugger connection closed"); } } + catch (java.net.SocketException e) { + // Expected when shutdown() closes the socket + System.out.println("[luceedebug] DAP server SocketException: " + e.getMessage()); + Log.info("DAP server socket closed"); + return null; + } catch (Throwable e) { + System.out.println("[luceedebug] DAP server fatal error: " + e.getClass().getName() + ": " + e.getMessage()); + e.printStackTrace(System.out); e.printStackTrace(); System.exit(1); return null; } + finally { + System.out.println("[luceedebug] DAP server finally block executing"); + // Only clear if we're still the active server (avoid race with new server starting) + if (activeServerSocket == server) { + activeServerSocket = null; + System.getProperties().remove(SOCKET_PROPERTY); + } + } + } + + /** + * Shutdown the DAP server. Called on extension uninstall/reinstall. + * Uses System.getProperties() to find socket from previous classloaders. + * Uses reflection to avoid OSGi classloader identity issues. + */ + public static void shutdown() { + // Log who's calling shutdown with a stack trace + System.out.println("[luceedebug] DapServer.shutdown() called"); + System.out.println("[luceedebug] shutdown() caller stack trace:"); + for (StackTraceElement ste : java.lang.Thread.currentThread().getStackTrace()) { + System.out.println("[luceedebug] " + ste); + } + System.out.flush(); + + // Try to get socket from JVM-wide properties (survives classloader changes) + Object storedSocket = System.getProperties().get(SOCKET_PROPERTY); + if (storedSocket != null) { + // Use reflection - instanceof may fail across OSGi classloaders + System.out.println("[luceedebug] shutdown() - found socket in system properties, closing via reflection..."); + Log.debug("shutdown() - found socket in system properties, closing via reflection..."); + try { + java.lang.reflect.Method closeMethod = storedSocket.getClass().getMethod("close"); + closeMethod.invoke(storedSocket); + System.out.println("[luceedebug] shutdown() - socket closed"); + Log.debug("shutdown() - socket closed"); + } catch (Exception e) { + System.out.println("[luceedebug] shutdown() - socket close error: " + e); + Log.error("shutdown() - socket close error", e); + } + System.getProperties().remove(SOCKET_PROPERTY); + } else { + System.out.println("[luceedebug] shutdown() - no socket in system properties"); + Log.debug("shutdown() - no socket in system properties"); + } + + // Also close our local static reference if set (same classloader case) + if (activeServerSocket != null) { + System.out.println("[luceedebug] shutdown() - closing activeServerSocket"); + try { + activeServerSocket.close(); + } catch (Exception ignored) {} + activeServerSocket = null; + } } static public DapEntry create(ILuceeVm luceeVm, Config config, InputStream in, OutputStream out) { @@ -165,6 +343,7 @@ static public DapEntry create(ILuceeVm luceeVm, Config config, InputStream in, O @Override public CompletableFuture initialize(InitializeRequestArguments args) { + Log.debug("initialize() called with args: " + args); var c = new Capabilities(); c.setSupportsEvaluateForHovers(true); c.setSupportsConfigurationDoneRequest(true); @@ -174,6 +353,26 @@ public CompletableFuture initialize(InitializeRequestArguments arg c.setSupportsHitConditionalBreakpoints(false); // still shows UI for it though c.setSupportsLogPoints(false); // still shows UI for it though + // Native-mode-only capabilities (require Lucee 7.1+ DebuggerRegistry) + // Also check if debugger is actually enabled (LUCEE_DAP_BREAKPOINT not set to false) + boolean isNativeMode = luceeVm_ instanceof NativeLuceeVm && EnvUtil.isDebuggerEnabled(); + + // Exception breakpoint filters - only supported in native mode with debugger enabled + if (isNativeMode) { + var uncaughtFilter = new ExceptionBreakpointsFilter(); + uncaughtFilter.setFilter("uncaught"); + uncaughtFilter.setLabel("Uncaught Exceptions"); + uncaughtFilter.setDescription("Break when an exception is not caught by a try/catch block"); + c.setExceptionBreakpointFilters(new ExceptionBreakpointsFilter[] { uncaughtFilter }); + } + c.setSupportsExceptionInfoRequest(isNativeMode); + c.setSupportsBreakpointLocationsRequest(isNativeMode); + c.setSupportsSetVariable(isNativeMode); + c.setSupportsCompletionsRequest(isNativeMode); + c.setSupportsFunctionBreakpoints(isNativeMode); + + Log.debug("Returning capabilities (nativeMode=" + isNativeMode + ") with exceptionBreakpointFilters: " + java.util.Arrays.toString(c.getExceptionBreakpointFilters())); + return CompletableFuture.completedFuture(c); } @@ -237,47 +436,188 @@ private boolean getAsBool(Object obj, boolean defaultValue) { @Override public CompletableFuture attach(Map args) { + // Configure logging from launch.json (before other logging) + configureLogging(args); + + // Log version info first + String luceeVersion = getLuceeVersion(); + Log.info("luceedebug " + Constants.version + " connected to Lucee " + luceeVersion); + + // Validate secret from launch.json + if (!validateSecret(args)) { + var result = new CompletableFuture(); + var error = new ResponseError(ResponseErrorCode.InvalidRequest, "Invalid or missing secret", null); + result.completeExceptionally(new ResponseErrorException(error)); + return result; + } + pathTransforms = tryMungePathTransforms(args.get("pathTransforms")); config_.setStepIntoUdfDefaultValueInitFrames(getBoolOrFalseIfNonBool(args.get("stepIntoUdfDefaultValueInitFrames"))); + evaluationEnabled = getAsBool(args.get("evaluation"), true); + if (!evaluationEnabled) { + Log.info("Expression evaluation disabled"); + } + clientProxy_.initialized(); if (pathTransforms.size() == 0) { - logger.finest("attached to frontend, using path transforms "); + Log.info("No path transforms configured"); } else { - logger.finest("attached to frontend, using path transforms:"); for (var transform : pathTransforms) { - logger.finest(transform.asTraceString()); + Log.info(transform.asTraceString()); + // Set base path for shortening paths in logs (use first transform's server prefix) + if (transform instanceof PrefixPathTransform) { + Config.setBasePath(((PrefixPathTransform)transform).getServerPrefix()); + } } } return CompletableFuture.completedFuture(null); } + // Track whether secret has been validated for this session + private boolean secretValidated = false; + + /** + * Validate the secret from launch.json. + * Works for both native mode (via ExtensionActivator) and agent mode (direct validation). + * + * @param args The attach arguments containing the secret + * @return true if secret is valid, false otherwise + */ + private boolean validateSecret(Map args) { + Object secretObj = args.get("secret"); + String clientSecret = (secretObj instanceof String) ? ((String) secretObj).trim() : null; + + if (clientSecret == null || clientSecret.isEmpty()) { + Log.error("No secret provided in launch.json"); + return false; + } + + // Try native mode first (Lucee 7.1+ extension) + // The class exists in both modes (shadow JAR), but isNativeModeActive() returns true + // only if the extension was actually loaded by Lucee's startup-hook mechanism. + try { + Class activatorClass = Class.forName("luceedebug.extension.ExtensionActivator"); + java.lang.reflect.Method isNativeMethod = activatorClass.getMethod("isNativeModeActive"); + Boolean isNative = (Boolean) isNativeMethod.invoke(null); + + if (isNative) { + // We're in native mode - use ExtensionActivator to register + java.lang.reflect.Method registerMethod = activatorClass.getMethod("registerListener", String.class); + Boolean registered = (Boolean) registerMethod.invoke(null, clientSecret); + if (registered) { + secretValidated = true; + return true; + } else { + Log.error("Failed to register debugger - invalid secret"); + return false; + } + } + // Not in native mode, fall through to agent mode validation + } catch (ClassNotFoundException e) { + // Class not found - shouldn't happen with shadow JAR but fall through anyway + } catch (Exception e) { + Log.error("Error checking native mode status", e); + return false; + } + + // Agent mode - validate secret directly + String expectedSecret = EnvUtil.getDebuggerSecret(); + if (expectedSecret == null) { + // No secret configured on server - allow any secret for backwards compatibility? + // No - require secret to be set for security + Log.error("LUCEE_DAP_SECRET not set on server"); + return false; + } + + if (!expectedSecret.equals(clientSecret)) { + Log.error("Invalid secret"); + return false; + } + + secretValidated = true; + return true; + } + + /** + * Helper to reject requests when not authorized. + * Returns a failed CompletableFuture with appropriate error message. + */ + private CompletableFuture notAuthorized() { + var result = new CompletableFuture(); + var error = new ResponseError(ResponseErrorCode.InvalidRequest, "Not authorized - call 'attach' with valid secret first", null); + result.completeExceptionally(new ResponseErrorException(error)); + return result; + } + + /** + * Simple boolean conversion without depending on Lucee's Cast utility. + * Handles Boolean, String ("true"/"false"), and defaults. + */ + private static boolean toBooleanValue(Object obj, boolean defaultValue) { + if (obj == null) return defaultValue; + if (obj instanceof Boolean) return (Boolean) obj; + if (obj instanceof String) { + String s = ((String) obj).toLowerCase().trim(); + if ("true".equals(s) || "yes".equals(s) || "1".equals(s)) return true; + if ("false".equals(s) || "no".equals(s) || "0".equals(s)) return false; + } + return defaultValue; + } + + /** + * Configure logging from launch.json settings. + * Supports: logColor (boolean), logLevel (error|info|debug), logExceptions (boolean), logSystemOutput (boolean) + */ + private void configureLogging(Map args) { + // logColor - default true + Log.setColorLogs(toBooleanValue(args.get("logColor"), true)); + + // logLevel - error, info, debug + Object logLevel = args.get("logLevel"); + if (logLevel instanceof String) { + String level = ((String) logLevel).toLowerCase(); + switch (level) { + case "error": + Log.setLogLevel(Log.LogLevel.ERROR); + break; + case "debug": + Log.setLogLevel(Log.LogLevel.DEBUG); + break; + case "info": + default: + Log.setLogLevel(Log.LogLevel.INFO); + break; + } + } + + // logExceptions - default false + Log.setLogExceptions(toBooleanValue(args.get("logExceptions"), false)); + + // consoleOutput - default false (streams System.out/err to debug console) + // Only available in native mode + if (luceeVm_ instanceof NativeLuceeVm) { + NativeDebuggerListener.setConsoleOutput(toBooleanValue(args.get("consoleOutput"), false)); + } + } + static final Pattern threadNamePrefixAndDigitSuffix = Pattern.compile("^(.+?)(\\d+)$"); @Override public CompletableFuture threads() { + if (!secretValidated) return notAuthorized(); + var lspThreads = new ArrayList(); - for (var threadRef : luceeVm_.getThreadListing()) { - try { - var lspThread = new org.eclipse.lsp4j.debug.Thread(); - lspThread.setId((int)threadRef.uniqueID()); // <<<<----------------@fixme, ObjectCollectedExceptions here - lspThread.setName(threadRef.name()); // <<<<----------------@fixme, ObjectCollectedExceptions here - lspThreads.add(lspThread); - } - catch (ObjectCollectedException e) { - // Discard this exception. - // We really shouldn't be dealing in terms of jdi thread refs here. - // The luceevm should return a list of names and IDs rather than actual threadrefs. - } - catch (Throwable e) { - e.printStackTrace(); - System.exit(1); - } + for (var threadInfo : luceeVm_.getThreadListing()) { + var lspThread = new org.eclipse.lsp4j.debug.Thread(); + lspThread.setId((int)threadInfo.id); + lspThread.setName(threadInfo.name); + lspThreads.add(lspThread); } // a lot of thread names like "Thread-Foo-1" and "Thread-Foo-12" which we'd like to order in a nice way @@ -313,12 +653,17 @@ public CompletableFuture threads() { @Override public CompletableFuture stackTrace(StackTraceArguments args) { + if (!secretValidated) return notAuthorized(); + var lspFrames = new ArrayList(); for (var cfFrame : luceeVm_.getStackTrace(args.getThreadId())) { final var source = new Source(); - source.setPath(applyPathTransformsServerToIde(cfFrame.getSourceFilePath())); - + String rawPath = cfFrame.getSourceFilePath(); + String transformedPath = applyPathTransformsServerToIde(rawPath); + Log.debug("stackTrace: raw=" + rawPath + " -> transformed=" + transformedPath); + source.setPath(transformedPath); + final var lspFrame = new org.eclipse.lsp4j.debug.StackFrame(); lspFrame.setId((int)cfFrame.getId()); lspFrame.setName(cfFrame.getName()); @@ -337,6 +682,8 @@ public CompletableFuture stackTrace(StackTraceArguments args @Override public CompletableFuture scopes(ScopesArguments args) { + if (!secretValidated) return notAuthorized(); + var scopes = new ArrayList(); for (var entity : luceeVm_.getScopes(args.getFrameId())) { var scope = new Scope(); @@ -354,6 +701,8 @@ public CompletableFuture scopes(ScopesArguments args) { @Override public CompletableFuture variables(VariablesArguments args) { + if (!secretValidated) return notAuthorized(); + var variables = new ArrayList(); IDebugEntity[] entities = args.getFilter() == null ? luceeVm_.getVariables(args.getVariablesReference()) @@ -377,11 +726,60 @@ public CompletableFuture variables(VariablesArguments args) { return CompletableFuture.completedFuture(result); } + @Override + public CompletableFuture setVariable(SetVariableArguments args) { + Log.debug("setVariable() called: variablesReference=" + args.getVariablesReference() + ", name=" + args.getName() + ", value=" + args.getValue()); + + if (!secretValidated) return notAuthorized(); + + return luceeVm_ + .setVariable(args.getVariablesReference(), args.getName(), args.getValue(), 0) + .collapse( + errMsg -> { + Log.info("setVariable() - error: " + errMsg); + var exceptionalResult = new CompletableFuture(); + var error = new ResponseError(ResponseErrorCode.InternalError, errMsg, null); + exceptionalResult.completeExceptionally(new ResponseErrorException(error)); + return exceptionalResult; + }, + someResult -> { + return someResult.collapse( + bridgeObj -> { + // Complex object returned + IDebugEntity value = bridgeObj.maybeNull_asValue(args.getName()); + var response = new SetVariableResponse(); + if (value == null) { + response.setValue(""); + response.setVariablesReference(0); + } else { + response.setValue(value.getValue()); + response.setVariablesReference((int) value.getVariablesReference()); + response.setNamedVariables(bridgeObj.getNamedVariablesCount()); + response.setIndexedVariables(bridgeObj.getIndexedVariablesCount()); + } + Log.debug("setVariable() - success (object): value=" + response.getValue() + ", ref=" + response.getVariablesReference()); + return CompletableFuture.completedFuture(response); + }, + stringValue -> { + // Simple value returned + var response = new SetVariableResponse(); + response.setValue(stringValue); + response.setVariablesReference(0); + Log.debug("setVariable() - success (simple): value=" + stringValue); + return CompletableFuture.completedFuture(response); + } + ); + } + ); + } + @Override public CompletableFuture setBreakpoints(SetBreakpointsArguments args) { + if (!secretValidated) return notAuthorized(); + final var idePath = new RawIdePath(args.getSource().getPath()); final var serverAbsPath = new CanonicalServerAbsPath(applyPathTransformsIdeToCf(args.getSource().getPath())); - + logger.finest("bp for " + idePath.get() + " -> " + serverAbsPath.get()); final int size = args.getBreakpoints().length; @@ -396,7 +794,7 @@ public CompletableFuture setBreakpoints(SetBreakpointsAr for (IBreakpoint bp : luceeVm_.bindBreakpoints(idePath, serverAbsPath, lines, exprs)) { result.add(map_cfBreakpoint_to_lsp4jBreakpoint(bp)); } - + var response = new SetBreakpointsResponse(); response.setBreakpoints(result.toArray(len -> new Breakpoint[len])); @@ -411,6 +809,39 @@ private Breakpoint map_cfBreakpoint_to_lsp4jBreakpoint(IBreakpoint cfBreakpoint) return bp; } + @Override + public CompletableFuture breakpointLocations(BreakpointLocationsArguments args) { + if (!secretValidated) return notAuthorized(); + + var response = new BreakpointLocationsResponse(); + + // Only works in native mode with NativeLuceeVm + if (!(luceeVm_ instanceof luceedebug.coreinject.NativeLuceeVm)) { + response.setBreakpoints(new BreakpointLocation[0]); + return CompletableFuture.completedFuture(response); + } + + var nativeVm = (luceedebug.coreinject.NativeLuceeVm) luceeVm_; + String serverPath = applyPathTransformsIdeToCf(args.getSource().getPath()); + int[] executableLines = nativeVm.getExecutableLines(serverPath); + + // Filter to requested line range + int startLine = args.getLine(); + int endLine = args.getEndLine() != null ? args.getEndLine() : startLine; + + var locations = new java.util.ArrayList(); + for (int line : executableLines) { + if (line >= startLine && line <= endLine) { + var loc = new BreakpointLocation(); + loc.setLine(line); + locations.add(loc); + } + } + + response.setBreakpoints(locations.toArray(new BreakpointLocation[0])); + return CompletableFuture.completedFuture(response); + } + /** * We don't really support this, but not sure how to say that; there doesn't seem to be a "supports exception breakpoints" * flag in the init response? vscode always sends this? @@ -425,22 +856,144 @@ private Breakpoint map_cfBreakpoint_to_lsp4jBreakpoint(IBreakpoint cfBreakpoint) */ @Override public CompletableFuture setExceptionBreakpoints(SetExceptionBreakpointsArguments args) { - // set success false? + if (!secretValidated) return notAuthorized(); + + // Check if "uncaught" is in the filters + String[] filters = args.getFilters(); + Log.debug("setExceptionBreakpoints: filters=" + java.util.Arrays.toString(filters)); + boolean breakOnUncaught = false; + if (filters != null) { + for (String filter : filters) { + if ("uncaught".equals(filter)) { + breakOnUncaught = true; + break; + } + } + } + // Only works in native mode - NativeDebuggerListener doesn't exist in agent mode + if (luceeVm_ instanceof NativeLuceeVm) { + NativeDebuggerListener.setBreakOnUncaughtExceptions(breakOnUncaught); + } return CompletableFuture.completedFuture(new SetExceptionBreakpointsResponse()); } + /** + * Handle function breakpoints - break when a function with a given name is called. + * Supports: + * - Simple names: "onRequestStart" matches any function with that name + * - Qualified names: "User.save" matches save() in User.cfc only + * - Wildcards: "on*" matches onRequestStart, onError, etc. + * - Conditions: "onRequestStart" with condition "cgi.script_name contains '/api/'" + */ + @Override + public CompletableFuture setFunctionBreakpoints( + SetFunctionBreakpointsArguments args) { + if (!secretValidated) return notAuthorized(); + + // Only works in native mode - NativeDebuggerListener doesn't exist in agent mode + if (!(luceeVm_ instanceof NativeLuceeVm)) { + return CompletableFuture.completedFuture(new SetFunctionBreakpointsResponse()); + } + + FunctionBreakpoint[] bps = args.getBreakpoints(); + Log.debug("setFunctionBreakpoints: " + (bps != null ? bps.length : 0) + " breakpoints"); + + if (bps == null || bps.length == 0) { + NativeDebuggerListener.clearFunctionBreakpoints(); + return CompletableFuture.completedFuture(new SetFunctionBreakpointsResponse()); + } + + String[] names = new String[bps.length]; + String[] conditions = new String[bps.length]; + + for (int i = 0; i < bps.length; i++) { + names[i] = bps[i].getName(); + conditions[i] = bps[i].getCondition(); + Log.debug(" Function breakpoint: " + names[i] + + (conditions[i] != null ? " condition=" + conditions[i] : "")); + } + + NativeDebuggerListener.setFunctionBreakpoints(names, conditions); + + // Build response - mark all as verified (we can't validate until runtime) + Breakpoint[] result = new Breakpoint[bps.length]; + for (int i = 0; i < bps.length; i++) { + Breakpoint bp = new Breakpoint(); + bp.setId(i + 1); + bp.setVerified(true); + bp.setMessage("Function breakpoint: " + names[i]); + result[i] = bp; + } + + SetFunctionBreakpointsResponse response = new SetFunctionBreakpointsResponse(); + response.setBreakpoints(result); + return CompletableFuture.completedFuture(response); + } + + /** + * Returns exception details when stopped due to an exception. + * VSCode calls this after receiving a stopped event with reason="exception". + */ + @Override + public CompletableFuture exceptionInfo(ExceptionInfoArguments args) { + if (!secretValidated) return notAuthorized(); + + Log.debug("exceptionInfo() called for thread " + args.getThreadId()); + Throwable ex = luceeVm_.getExceptionForThread(args.getThreadId()); + var response = new ExceptionInfoResponse(); + if (ex != null) { + response.setExceptionId(ex.getClass().getName()); + response.setDescription(ex.getMessage()); + response.setBreakMode(ExceptionBreakMode.UNHANDLED); + // Build detailed stack trace + var details = new ExceptionDetails(); + // Include detail if available (Lucee PageException has getDetail()) + String message = ex.getMessage(); + String detail = ExceptionUtil.getDetail(ex); + if (detail != null && !detail.isEmpty()) { + message = message + "\n\nDetail: " + detail; + } + details.setMessage(message); + details.setTypeName(ex.getClass().getName()); + details.setStackTrace(ExceptionUtil.getCfmlStackTraceOrFallback(ex)); + // Include inner exception if present + if (ex.getCause() != null) { + var inner = new ExceptionDetails(); + inner.setMessage(ex.getCause().getMessage()); + inner.setTypeName(ex.getCause().getClass().getName()); + details.setInnerException(new ExceptionDetails[] { inner }); + } + response.setDetails(details); + Log.debug("exceptionInfo() returning: " + ex.getClass().getName() + " - " + ex.getMessage()); + } else { + response.setExceptionId("unknown"); + response.setDescription("No exception information available"); + response.setBreakMode(ExceptionBreakMode.UNHANDLED); + Log.debug("exceptionInfo() - no exception found for thread"); + } + return CompletableFuture.completedFuture(response); + } + /** - * Can we disable the UI for this in the client plugin? - * - * @unsupported + * Pause a running thread. In native mode, this is cooperative - the thread + * will pause at the next CFML instrumentation point (next line of CFML code). + * Won't pause threads stuck in pure Java code (JDBC, HTTP, sleep, etc.). */ + @Override public CompletableFuture pause(PauseArguments args) { - // set success false? + if (!secretValidated) return notAuthorized(); + + long threadId = args.getThreadId(); + Log.info("pause() called for thread " + threadId); + // Thread ID 0 means "pause all threads" - this happens when user clicks + // pause button without a specific thread selected + luceeVm_.pause(threadId); return CompletableFuture.completedFuture(null); } @Override public CompletableFuture disconnect(DisconnectArguments args) { + Log.info("DAP client disconnected"); luceeVm_.clearAllBreakpoints(); luceeVm_.continueAll(); return CompletableFuture.completedFuture(null); @@ -448,24 +1001,28 @@ public CompletableFuture disconnect(DisconnectArguments args) { @Override public CompletableFuture continue_(ContinueArguments args) { + if (!secretValidated) return notAuthorized(); luceeVm_.continue_(args.getThreadId()); return CompletableFuture.completedFuture(new ContinueResponse()); } @Override public CompletableFuture next(NextArguments args) { + if (!secretValidated) return notAuthorized(); luceeVm_.stepOver(args.getThreadId()); return CompletableFuture.completedFuture(null); } @Override public CompletableFuture stepIn(StepInArguments args) { + if (!secretValidated) return notAuthorized(); luceeVm_.stepIn(args.getThreadId()); return CompletableFuture.completedFuture(null); } @Override public CompletableFuture stepOut(StepOutArguments args) { + if (!secretValidated) return notAuthorized(); luceeVm_.stepOut(args.getThreadId()); return CompletableFuture.completedFuture(null); } @@ -544,6 +1101,7 @@ public boolean equals(final Object obj) { @JsonRequest CompletableFuture dump(DumpArguments args) { + if (!secretValidated) return notAuthorized(); final var response = new DumpResponse(); response.setContent(luceeVm_.dump(args.variablesReference)); return CompletableFuture.completedFuture(response); @@ -551,11 +1109,28 @@ CompletableFuture dump(DumpArguments args) { @JsonRequest CompletableFuture dumpAsJSON(DumpArguments args) { + if (!secretValidated) return notAuthorized(); final var response = new DumpResponse(); response.setContent(luceeVm_.dumpAsJSON(args.variablesReference)); return CompletableFuture.completedFuture(response); } + @JsonRequest + CompletableFuture getMetadata(DumpArguments args) { + if (!secretValidated) return notAuthorized(); + final var response = new DumpResponse(); + response.setContent(luceeVm_.getMetadata(args.variablesReference)); + return CompletableFuture.completedFuture(response); + } + + @JsonRequest + CompletableFuture getApplicationSettings(DumpArguments args) { + if (!secretValidated) return notAuthorized(); + final var response = new DumpResponse(); + response.setContent(luceeVm_.getApplicationSettings()); + return CompletableFuture.completedFuture(response); + } + class DebugBreakpointBindingsResponse { /** as we see them on the server, after fs canonicalization */ private String[] canonicalFilenames; @@ -644,6 +1219,7 @@ class DebugBreakpointBindingsArguments { @JsonRequest CompletableFuture debugBreakpointBindings(DebugBreakpointBindingsArguments args) { + if (!secretValidated) return notAuthorized(); final var response = new DebugBreakpointBindingsResponse(); response.setCanonicalFilenames(luceeVm_.getTrackedCanonicalFileNames()); response.setBreakpoints(luceeVm_.getBreakpointDetail()); @@ -737,6 +1313,7 @@ public boolean equals(final Object obj) { @JsonRequest CompletableFuture getSourcePath(GetSourcePathArguments args) { + if (!secretValidated) return notAuthorized(); final var response = new GetSourcePathResponse(); final var serverPath = luceeVm_.getSourcePathForVariablesRef(args.getVariablesReference()); @@ -753,7 +1330,21 @@ CompletableFuture getSourcePath(GetSourcePathArguments ar static private AtomicLong anonymousID = new AtomicLong(); public CompletableFuture evaluate(EvaluateArguments args) { + if (!secretValidated) return notAuthorized(); + + final String expr = args.getExpression(); + final String context = args.getContext(); // "hover", "watch", "repl", or null + final boolean isHover = "hover".equals(context); + + if (!evaluationEnabled) { + final var exceptionalResult = new CompletableFuture(); + final var error = new ResponseError(ResponseErrorCode.InvalidRequest, "evaluation disabled", null); + exceptionalResult.completeExceptionally(new ResponseErrorException(error)); + return exceptionalResult; + } + if (args.getFrameId() == null) { + if (!isHover) { Log.info("evaluate(\"" + expr + "\") - error: missing frameID"); } final var exceptionalResult = new CompletableFuture(); final var error = new ResponseError(ResponseErrorCode.InvalidRequest, "missing frameID", null); exceptionalResult.completeExceptionally(new ResponseErrorException(error)); @@ -761,9 +1352,10 @@ public CompletableFuture evaluate(EvaluateArguments args) { } else { return luceeVm_ - .evaluate(args.getFrameId(), args.getExpression()) + .evaluate(args.getFrameId(), expr) .collapse( errMsg -> { + if (!isHover) { Log.info("evaluate(\"" + expr + "\") - error: " + errMsg); } final var exceptionalResult = new CompletableFuture(); final var error = new ResponseError(ResponseErrorCode.InternalError, errMsg, null); exceptionalResult.completeExceptionally(new ResponseErrorException(error)); @@ -776,12 +1368,14 @@ public CompletableFuture evaluate(EvaluateArguments args) { final var response = new EvaluateResponse(); if (value == null) { // some problem, or we tried to get a function from a cfc maybe? this needs work. + Log.info("evaluate(\"" + expr + "\") = ???"); response.setVariablesReference(0); response.setIndexedVariables(0); response.setNamedVariables(0); response.setResult("???"); } else { + Log.info("evaluate(\"" + expr + "\") = " + value.getValue()); response.setVariablesReference((int)(long)value.getVariablesReference()); response.setIndexedVariables(value.getIndexedVariables()); response.setNamedVariables(value.getNamedVariables()); @@ -791,6 +1385,7 @@ public CompletableFuture evaluate(EvaluateArguments args) { return CompletableFuture.completedFuture(response); }, string -> { + Log.info("evaluate(\"" + expr + "\") = " + string); final var response = new EvaluateResponse(); response.setResult(string); return CompletableFuture.completedFuture(response); @@ -798,5 +1393,63 @@ public CompletableFuture evaluate(EvaluateArguments args) { } ); } - } + } + + @Override + public CompletableFuture completions(CompletionsArguments args) { + if (!secretValidated) return notAuthorized(); + + final String text = args.getText(); + final int column = args.getColumn(); + final Integer frameId = args.getFrameId(); + + // Parse the text to find what we're completing + // column is 1-based by default, text up to cursor + int cursorPos = Math.min(column - 1, text.length()); + String prefix = text.substring(0, cursorPos); + + // Find the start of the current "word" we're completing + // CFML variables can contain: letters, digits, underscores, dots, brackets + int wordStart = cursorPos; + while (wordStart > 0) { + char c = prefix.charAt(wordStart - 1); + if (Character.isLetterOrDigit(c) || c == '_' || c == '.' || c == '[' || c == ']' || c == '"' || c == '\'') { + wordStart--; + } else { + break; + } + } + + String partialExpr = prefix.substring(wordStart); + + // Get completions from the VM + CompletionItem[] items = luceeVm_.getCompletions(frameId != null ? frameId : 0, partialExpr); + + // Set start and length on each item so VSCode replaces the partial expression + // start is 0-based position in the text where replacement begins + // length is how many characters to replace (the partial expression length) + for (CompletionItem item : items) { + item.setStart(wordStart); // 0-based position where partial expr starts + item.setLength(partialExpr.length()); // Replace the typed prefix + } + + CompletionsResponse response = new CompletionsResponse(); + response.setTargets(items); + return CompletableFuture.completedFuture(response); + } + + /** + * Get the Lucee version string (e.g., "7.0.1.7-ALPHA"). + * In agent mode, Lucee classes may not be accessible, so we catch Throwable. + */ + private static String getLuceeVersion() { + try { + lucee.Info info = lucee.loader.engine.CFMLEngineFactory.getInstance().getInfo(); + return info.getVersion().toString(); + } catch (Throwable t) { + // NoClassDefFoundError is an Error, not Exception - need to catch Throwable + // This happens in agent mode where Lucee's classloader is separate + return "unknown"; + } + } } diff --git a/luceedebug/src/main/java/luceedebug/EnvUtil.java b/luceedebug/src/main/java/luceedebug/EnvUtil.java new file mode 100644 index 0000000..63cc77d --- /dev/null +++ b/luceedebug/src/main/java/luceedebug/EnvUtil.java @@ -0,0 +1,87 @@ +package luceedebug; + +/** + * Utility class for reading environment variables and system properties + * in Lucee's naming convention. + */ +public final class EnvUtil { + + private EnvUtil() {} + + /** + * Get system property or environment variable. + * System property takes precedence. Env var name is derived from property name + * by uppercasing and replacing dots with underscores. + * + * @param propertyName e.g. "lucee.debugger.enabled" + * @return the value, or null if not set + */ + public static String getSystemPropOrEnvVar(String propertyName) { + // Try system property first + String value = System.getProperty(propertyName); + if (value != null && !value.isEmpty()) { + return value; + } + // Try env var (lucee.dap.port -> LUCEE_DAP_PORT) + String envName = propertyName.toUpperCase().replace('.', '_'); + return System.getenv(envName); + } + + /** + * Get DAP secret from environment/system property. + * Checks "lucee.dap.secret" / "LUCEE_DAP_SECRET". + * + * @return the secret, or null if not set (debugger disabled) + */ + public static String getDebuggerSecret() { + String secret = getSystemPropOrEnvVar("lucee.dap.secret"); + if (secret != null && !secret.trim().isEmpty()) { + return secret.trim(); + } + return null; + } + + /** + * Check if DAP breakpoint support is enabled. + * Reads ConfigImpl.DEBUGGER static field via reflection to match Lucee's state. + * + * @return true if DAP breakpoint support is enabled + */ + public static boolean isDebuggerEnabled() { + try { + Class configImpl = Class.forName("lucee.runtime.config.ConfigImpl"); + java.lang.reflect.Field field = configImpl.getField("DEBUGGER"); + return (boolean) field.get(null); + } catch (Exception e) { + // Fallback to env var check if reflection fails (e.g. older Lucee) + if (getDebuggerSecret() == null) { + return false; + } + String bp = getSystemPropOrEnvVar("lucee.dap.breakpoint"); + return bp == null || "true".equalsIgnoreCase(bp.trim()); + } + } + + /** + * Get DAP port from environment/system property. + * Checks "lucee.dap.port" / "LUCEE_DAP_PORT". + * Defaults to 9999 if secret is set but port is not. + * + * @return the port number, or -1 if debugger disabled (no secret) + */ + public static int getDebuggerPort() { + // No port if no secret + if (getDebuggerSecret() == null) { + return -1; + } + String port = getSystemPropOrEnvVar("lucee.dap.port"); + if (port == null || port.isEmpty()) { + return 9999; // default port + } + try { + return Integer.parseInt(port); + } catch (NumberFormatException e) { + return 9999; + } + } +} diff --git a/luceedebug/src/main/java/luceedebug/ExceptionUtil.java b/luceedebug/src/main/java/luceedebug/ExceptionUtil.java new file mode 100644 index 0000000..26bf807 --- /dev/null +++ b/luceedebug/src/main/java/luceedebug/ExceptionUtil.java @@ -0,0 +1,154 @@ +package luceedebug; + +/** + * Utility class for extracting information from Lucee exceptions. + * Uses reflection to handle OSGi classloader isolation. + */ +public final class ExceptionUtil { + + private ExceptionUtil() {} + + /** + * Get the first CFML location from an exception's stack trace. + * @return "template:line" or null if no CFML frame found + */ + public static String getFirstCfmlLocation(Throwable ex) { + // First try tagContext (more accurate for Lucee PageExceptions) + String tagContextLocation = getFirstTagContextLocation(ex); + if (tagContextLocation != null) { + return tagContextLocation; + } + // Fallback to Java stack trace + for (StackTraceElement ste : ex.getStackTrace()) { + if (ste.getClassName().endsWith("$cf")) { + return ste.getFileName() + ":" + ste.getLineNumber(); + } + } + return null; + } + + /** + * Get the full CFML stack trace from a PageException's tagContext. + * @return Multi-line string of "template:line" entries, or null if not available + */ + public static String getCfmlStackTrace(Throwable ex) { + try { + // Get Config via reflection (OSGi classloader isolation) + ClassLoader loader = ex.getClass().getClassLoader(); + Class tlpcClass = loader.loadClass("lucee.runtime.engine.ThreadLocalPageContext"); + java.lang.reflect.Method getConfig = tlpcClass.getMethod("getConfig"); + Object config = getConfig.invoke(null); + if (config == null) { + return null; + } + // Check if it's a PageException with getTagContext(Config) + Class configClass = loader.loadClass("lucee.runtime.config.Config"); + java.lang.reflect.Method getTagContext = ex.getClass().getMethod("getTagContext", configClass); + Object tagContext = getTagContext.invoke(ex, config); + if (tagContext == null) { + return null; + } + // tagContext is a lucee.runtime.type.Array + StringBuilder sb = new StringBuilder(); + java.lang.reflect.Method size = tagContext.getClass().getMethod("size"); + java.lang.reflect.Method getE = tagContext.getClass().getMethod("getE", int.class); + int len = (Integer) size.invoke(tagContext); + + // Get KeyImpl.init for creating keys + Class keyImplClass = loader.loadClass("lucee.runtime.type.KeyImpl"); + java.lang.reflect.Method keyInit = keyImplClass.getMethod("init", String.class); + Object templateKey = keyInit.invoke(null, "template"); + Object lineKey = keyInit.invoke(null, "line"); + + // Get the Struct.get(Key, defaultValue) method + Class keyClass = loader.loadClass("lucee.runtime.type.Collection$Key"); + + for (int i = 1; i <= len; i++) { + Object item = getE.invoke(tagContext, i); + // item is a Struct with template, line, codePrintPlain + java.lang.reflect.Method get = item.getClass().getMethod("get", keyClass, Object.class); + String template = (String) get.invoke(item, templateKey, ""); + Object lineObj = get.invoke(item, lineKey, 0); + int line = lineObj instanceof Number ? ((Number) lineObj).intValue() : 0; + sb.append(template).append(":").append(line).append("\n"); + } + return sb.toString(); + } catch (Exception e) { + Log.debug("getCfmlStackTrace failed: " + e.getMessage()); + return null; + } + } + + /** + * Get the first location from tagContext. + */ + private static String getFirstTagContextLocation(Throwable ex) { + try { + ClassLoader loader = ex.getClass().getClassLoader(); + Class tlpcClass = loader.loadClass("lucee.runtime.engine.ThreadLocalPageContext"); + java.lang.reflect.Method getConfig = tlpcClass.getMethod("getConfig"); + Object config = getConfig.invoke(null); + if (config == null) { + return null; + } + Class configClass = loader.loadClass("lucee.runtime.config.Config"); + java.lang.reflect.Method getTagContext = ex.getClass().getMethod("getTagContext", configClass); + Object tagContext = getTagContext.invoke(ex, config); + if (tagContext == null) { + return null; + } + java.lang.reflect.Method size = tagContext.getClass().getMethod("size"); + int len = (Integer) size.invoke(tagContext); + if (len == 0) { + return null; + } + java.lang.reflect.Method getE = tagContext.getClass().getMethod("getE", int.class); + Object item = getE.invoke(tagContext, 1); + + // Create keys for struct access + Class keyImplClass = loader.loadClass("lucee.runtime.type.KeyImpl"); + java.lang.reflect.Method keyInit = keyImplClass.getMethod("init", String.class); + Object templateKey = keyInit.invoke(null, "template"); + Object lineKey = keyInit.invoke(null, "line"); + Class keyClass = loader.loadClass("lucee.runtime.type.Collection$Key"); + + java.lang.reflect.Method get = item.getClass().getMethod("get", keyClass, Object.class); + String template = (String) get.invoke(item, templateKey, ""); + Object lineObj = get.invoke(item, lineKey, 0); + int line = lineObj instanceof Number ? ((Number) lineObj).intValue() : 0; + return template + ":" + line; + } catch (Exception e) { + return null; + } + } + + /** + * Get the CFML stack trace, falling back to filtered Java stack trace. + */ + public static String getCfmlStackTraceOrFallback(Throwable ex) { + String stackTrace = getCfmlStackTrace(ex); + if (stackTrace != null && !stackTrace.isEmpty()) { + return stackTrace; + } + // Fallback to Java stack trace filtered to CFML frames + StringBuilder sb = new StringBuilder(); + for (StackTraceElement ste : ex.getStackTrace()) { + if (ste.getClassName().endsWith("$cf")) { + sb.append(ste.getFileName()).append(":").append(ste.getLineNumber()).append("\n"); + } + } + return sb.toString(); + } + + /** + * Get exception detail from PageException.getDetail() via reflection. + */ + public static String getDetail(Throwable ex) { + try { + java.lang.reflect.Method getDetail = ex.getClass().getMethod("getDetail"); + return (String) getDetail.invoke(ex); + } catch (Exception e) { + return null; + } + } +} diff --git a/luceedebug/src/main/java/luceedebug/ILuceeVm.java b/luceedebug/src/main/java/luceedebug/ILuceeVm.java index b3c6759..19d0b65 100644 --- a/luceedebug/src/main/java/luceedebug/ILuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/ILuceeVm.java @@ -3,16 +3,30 @@ import java.util.function.BiConsumer; import java.util.function.Consumer; -import com.sun.jdi.*; - import luceedebug.strong.DapBreakpointID; -import luceedebug.strong.JdwpThreadID; import luceedebug.strong.CanonicalServerAbsPath; import luceedebug.strong.RawIdePath; public interface ILuceeVm { - public void registerStepEventCallback(Consumer cb); - public void registerBreakpointEventCallback(BiConsumer cb); + /** + * Register callback for step events. + * Called with thread ID when a step completes. + */ + public void registerStepEventCallback(Consumer cb); + + /** + * Register callback for JDWP breakpoint events. + * Called with thread ID and breakpoint ID when a JDWP breakpoint is hit. + * For NativeLuceeVm, this is unused (use registerNativeBreakpointEventCallback instead). + */ + public void registerBreakpointEventCallback(BiConsumer cb); + + /** + * Register callback for native breakpoint events (Lucee7+). + * Called with Java thread ID and optional label when a thread hits a native breakpoint. + * Label is non-null for programmatic breakpoint("label") calls, null otherwise. + */ + public void registerNativeBreakpointEventCallback(BiConsumer cb); public static class BreakpointsChangedEvent { IBreakpoint[] newBreakpoints = new IBreakpoint[0]; @@ -27,8 +41,8 @@ public static BreakpointsChangedEvent justChanges(IBreakpoint[] changes) { } public void registerBreakpointsChangedCallback(Consumer cb); - public ThreadReference[] getThreadListing(); - public IDebugFrame[] getStackTrace(long jdwpThreadID); + public ThreadInfo[] getThreadListing(); + public IDebugFrame[] getStackTrace(long threadID); public IDebugEntity[] getScopes(long frameID); /** @@ -44,19 +58,36 @@ public static BreakpointsChangedEvent justChanges(IBreakpoint[] changes) { public IBreakpoint[] bindBreakpoints(RawIdePath idePath, CanonicalServerAbsPath serverAbsPath, int[] lines, String[] exprs); - public void continue_(long jdwpThreadID); + public void continue_(long threadID); public void continueAll(); - public void stepIn(long jdwpThreadID); - public void stepOver(long jdwpThreadID); - public void stepOut(long jdwpThreadID); + public void stepIn(long threadID); + public void stepOver(long threadID); + public void stepOut(long threadID); + + /** + * Request a thread to pause at the next CFML line. + * In native mode, this is cooperative - the thread pauses at the next instrumentation point. + * @param threadID The thread ID to pause + */ + public void pause(long threadID); public void clearAllBreakpoints(); public String dump(int dapVariablesReference); public String dumpAsJSON(int dapVariablesReference); + public String getMetadata(int dapVariablesReference); + public String getApplicationSettings(); + + /** + * Get completion suggestions for the debug console. + * @param frameId The stack frame ID for context (0 for global scope) + * @param partialExpr The partial expression to complete (e.g., "local.fo" or "variables.") + * @return Array of CompletionItems + */ + public org.eclipse.lsp4j.debug.CompletionItem[] getCompletions(int frameId, String partialExpr); public String[] getTrackedCanonicalFileNames(); /** @@ -71,4 +102,34 @@ public static BreakpointsChangedEvent justChanges(IBreakpoint[] changes) { public String getSourcePathForVariablesRef(int variablesRef); public Either> evaluate(int frameID, String expr); + + /** + * Set a variable value. + * @param variablesReference The parent container's variablesReference + * @param name The variable name within the container + * @param value The new value as a string expression + * @param frameId The frame ID for context (used to get PageContext) + * @return Either an error message (Left), or the new value as ICfValueDebuggerBridge or String (Right) + */ + public Either> setVariable(long variablesReference, String name, String value, long frameId); + + /** + * Register callback for exception events (native mode only). + * Called with Java thread ID when a thread stops due to an uncaught exception. + */ + public void registerExceptionEventCallback(java.util.function.Consumer cb); + + /** + * Register callback for pause events (native mode only). + * Called with Java thread ID when a thread stops due to user pause request. + */ + public void registerPauseEventCallback(java.util.function.Consumer cb); + + /** + * Get the exception that caused a thread to suspend. + * Returns null if the thread is not suspended due to an exception. + * @param threadId The Java thread ID + * @return The exception, or null + */ + public Throwable getExceptionForThread(long threadId); } diff --git a/luceedebug/src/main/java/luceedebug/Log.java b/luceedebug/src/main/java/luceedebug/Log.java new file mode 100644 index 0000000..7ea6994 --- /dev/null +++ b/luceedebug/src/main/java/luceedebug/Log.java @@ -0,0 +1,314 @@ +package luceedebug; + +import org.eclipse.lsp4j.debug.OutputEventArguments; +import org.eclipse.lsp4j.debug.OutputEventArgumentsCategory; +import org.eclipse.lsp4j.debug.services.IDebugProtocolClient; + +import lucee.loader.engine.CFMLEngineFactory; + +/** + * Centralized logging for luceedebug. + * Routes all log messages through a common method that: + * - Writes to System.out with [luceedebug] prefix + * - Optionally sends to DAP OutputEvent when a client is connected + * - Supports ANSI colors (configurable via launch.json colorLogs, default true) + * - Respects log level (configurable via launch.json logLevel, default info) + * - Writes errors to Lucee's exception.log when available + */ +public class Log { + private static final String PREFIX = "[luceedebug] "; + private static final String APP_NAME = "luceedebug"; + + // ANSI escape codes (for console/tomcat output) + private static final String ANSI_RESET = "\u001b[0m"; + private static final String ANSI_RED = "\u001b[31m"; + private static final String ANSI_YELLOW = "\u001b[33m"; + private static final String ANSI_CYAN = "\u001b[36m"; + private static final String ANSI_DIM = "\u001b[2m"; + + // DAP client for sending OutputEvents (set when client connects) + private static volatile IDebugProtocolClient dapClient = null; + + // Runtime settings from launch.json + private static volatile boolean colorLogs = true; + private static volatile LogLevel logLevel = LogLevel.INFO; + private static volatile boolean logExceptions = false; + private static volatile boolean consoleOutput = false; + + // Internal debugging - only enabled via env var LUCEE_DEBUGGER_DEBUG + private static final boolean internalDebug; + static { + String env = System.getenv("LUCEE_DEBUGGER_DEBUG"); + internalDebug = env != null && !env.isEmpty() && !env.equals("0") && !env.equalsIgnoreCase("false"); + } + + public enum LogLevel { + ERROR(0), + INFO(1), + DEBUG(2), + TRACE(3); + + private final int level; + + LogLevel(int level) { + this.level = level; + } + + public boolean isEnabled(LogLevel threshold) { + return this.level <= threshold.level; + } + } + + /** + * Set the DAP client to receive log messages as OutputEvents. + * Pass null to disable DAP output (e.g., on disconnect). + */ + public static void setDapClient(IDebugProtocolClient client) { + dapClient = client; + } + + /** + * Set color logs setting from launch.json. + */ + public static void setColorLogs(boolean enabled) { + colorLogs = enabled; + } + + /** + * Set log level from launch.json. + */ + public static void setLogLevel(LogLevel level) { + logLevel = level; + } + + /** + * Set exception logging from launch.json. + */ + public static void setLogExceptions(boolean enabled) { + logExceptions = enabled; + } + + /** + * Set console output streaming from launch.json. + * When enabled, we skip sending directly to DAP since System.out/err + * will be captured and forwarded via systemOutput(). + */ + public static void setConsoleOutput(boolean enabled) { + consoleOutput = enabled; + } + + /** + * Log an info message to console and optionally to DAP client. + * Only logged if log level is INFO or higher. + */ + public static void info(String message) { + if (!LogLevel.INFO.isEnabled(logLevel)) { + return; + } + // When consoleOutput is enabled, skip System.out (it gets captured and + // forwarded to DAP, causing double-logging). Send directly to DAP instead. + if (!consoleOutput) { + String consoleMsg; + if (colorLogs) { + consoleMsg = ANSI_CYAN + PREFIX + ANSI_RESET + message; + } else { + consoleMsg = PREFIX + message; + } + System.out.println(consoleMsg); + } + sendToDap(message, OutputEventArgumentsCategory.CONSOLE); + } + + /** + * Log an error message. Always logged regardless of log level. + * Also logs to Lucee's exception.log when available. + */ + public static void error(String message) { + if (!consoleOutput) { + String consoleMsg; + if (colorLogs) { + consoleMsg = ANSI_RED + PREFIX + "ERROR: " + message + ANSI_RESET; + } else { + consoleMsg = PREFIX + "ERROR: " + message; + } + System.out.println(consoleMsg); + } + sendToDap("ERROR: " + message, OutputEventArgumentsCategory.STDERR); + logToLuceeException(message); + } + + /** + * Log an error with exception. + * Also logs to Lucee's exception.log when available. + */ + public static void error(String message, Throwable t) { + error(message + ": " + t.getMessage()); + t.printStackTrace(); + logToLuceeException(message, t); + } + + /** + * Log a debug message. + * Only printed if LUCEE_DEBUGGER_DEBUG env var is set. + * Uses STDOUT category in DAP for normal (non-highlighted) display. + */ + public static void debug(String message) { + if (!internalDebug) { + return; + } + if (!consoleOutput) { + String consoleMsg; + if (colorLogs) { + consoleMsg = ANSI_DIM + PREFIX + "DEBUG: " + message + ANSI_RESET; + } else { + consoleMsg = PREFIX + "DEBUG: " + message; + } + System.out.println(consoleMsg); + } + sendToDap("DEBUG: " + message, OutputEventArgumentsCategory.STDOUT); + } + + /** + * Log a trace message. + * Only printed if LUCEE_DEBUGGER_DEBUG env var is set. + * Uses STDOUT category in DAP for normal (non-highlighted) display. + */ + public static void trace(String message) { + if (!internalDebug) { + return; + } + if (!consoleOutput) { + String consoleMsg; + if (colorLogs) { + consoleMsg = ANSI_DIM + PREFIX + "TRACE: " + message + ANSI_RESET; + } else { + consoleMsg = PREFIX + "TRACE: " + message; + } + System.out.println(consoleMsg); + } + sendToDap("TRACE: " + message, OutputEventArgumentsCategory.STDOUT); + } + + /** + * Log a warning message. + * Only logged if log level is INFO or higher. + * Uses IMPORTANT category in DAP for highlighted display. + */ + public static void warn(String message) { + if (!LogLevel.INFO.isEnabled(logLevel)) { + return; + } + if (!consoleOutput) { + String consoleMsg; + if (colorLogs) { + consoleMsg = ANSI_YELLOW + PREFIX + "WARN: " + message + ANSI_RESET; + } else { + consoleMsg = PREFIX + "WARN: " + message; + } + System.out.println(consoleMsg); + } + sendToDap("WARN: " + message, OutputEventArgumentsCategory.IMPORTANT); + } + + /** + * Log an exception to the debug console (if logExceptions is enabled). + * Only sends to DAP, not to System.out. + */ + public static void exception(Throwable t) { + if (!logExceptions) { + return; + } + StringBuilder sb = new StringBuilder(); + sb.append(t.getClass().getSimpleName()).append(": ").append(t.getMessage()); + + // Get full CFML stack trace + String stackTrace = ExceptionUtil.getCfmlStackTraceOrFallback(t); + if (stackTrace != null && !stackTrace.isEmpty()) { + for (String line : stackTrace.split("\n")) { + if (!line.isEmpty()) { + sb.append("\n at ").append(line); + } + } + } else { + sb.append("\n at unknown"); + } + sendToDap(sb.toString(), OutputEventArgumentsCategory.STDERR); + } + + /** + * Forward System.out/err output to DAP client. + * Called by NativeDebuggerListener.onOutput() when consoleOutput is enabled. + * Does NOT echo to console (would cause infinite loop). + * + * @param text The text that was written + * @param isStdErr true if stderr, false if stdout + */ + public static void systemOutput(String text, boolean isStdErr) { + IDebugProtocolClient client = dapClient; + if (client != null) { + try { + var args = new OutputEventArguments(); + args.setCategory(isStdErr ? OutputEventArgumentsCategory.STDERR : OutputEventArgumentsCategory.STDOUT); + // Pass through as-is - the source already includes newlines + args.setOutput(text); + client.output(args); + } catch (Exception e) { + // Silently ignore - can't log here or we'd recurse + } + } + } + + /** + * Send log to DAP client if connected. + */ + private static void sendToDap(String message, String category) { + IDebugProtocolClient client = dapClient; + if (client != null) { + try { + var args = new OutputEventArguments(); + args.setCategory(category); + args.setOutput("[luceedebug] " + message + "\n"); + client.output(args); + } catch (Exception e) { + // Don't recursively log - just print to console + System.out.println(PREFIX + "Failed to send to DAP: " + e.getMessage()); + } + } + } + + /** + * Get Lucee's exception log if available. + * Returns null if Lucee is not running or log cannot be obtained. + */ + private static lucee.commons.io.log.Log getLuceeExceptionLog() { + try { + var config = CFMLEngineFactory.getInstance().getThreadConfig(); + if (config != null) { + return config.getLog("exception"); + } + } catch (Throwable t) { + // Lucee not available or not initialized yet - silently ignore + } + return null; + } + + /** + * Log an error to Lucee's exception.log if available. + */ + private static void logToLuceeException(String message) { + var luceeLog = getLuceeExceptionLog(); + if (luceeLog != null) { + luceeLog.error(APP_NAME, message); + } + } + + /** + * Log an error with throwable to Lucee's exception.log if available. + */ + private static void logToLuceeException(String message, Throwable t) { + var luceeLog = getLuceeExceptionLog(); + if (luceeLog != null) { + luceeLog.error(APP_NAME, message, t); + } + } +} diff --git a/luceedebug/src/main/java/luceedebug/LuceeTransformer.java b/luceedebug/src/main/java/luceedebug/LuceeTransformer.java index 875d3a8..bd65cf2 100644 --- a/luceedebug/src/main/java/luceedebug/LuceeTransformer.java +++ b/luceedebug/src/main/java/luceedebug/LuceeTransformer.java @@ -2,12 +2,10 @@ import java.lang.instrument.*; import java.lang.reflect.Method; +import java.security.ProtectionDomain; import org.objectweb.asm.*; -import java.security.ProtectionDomain; -import java.util.ArrayList; - public class LuceeTransformer implements ClassFileTransformer { private final String jdwpHost; private final int jdwpPort; @@ -236,4 +234,5 @@ protected ClassLoader getClassLoader() { return null; } } + } diff --git a/luceedebug/src/main/java/luceedebug/PrefixPathTransform.java b/luceedebug/src/main/java/luceedebug/PrefixPathTransform.java index 1f861e7..3b15742 100644 --- a/luceedebug/src/main/java/luceedebug/PrefixPathTransform.java +++ b/luceedebug/src/main/java/luceedebug/PrefixPathTransform.java @@ -28,8 +28,15 @@ public Optional ideToServer(String s) { return replacePrefix(s, caseAndPathSepLenient_idePrefixPattern, unadjusted_serverPrefix); } + public String getServerPrefix() { + return unadjusted_serverPrefix; + } + public String asTraceString() { - return "PrefixPathTransform{idePrefix='" + unadjusted_idePrefix + "', serverPrefix='" + unadjusted_serverPrefix + "'}"; + if (unadjusted_idePrefix.equals(unadjusted_serverPrefix)) { + return "Path: " + unadjusted_idePrefix; + } + return "Path mapping: IDE='" + unadjusted_idePrefix + "' -> Server='" + unadjusted_serverPrefix + "'"; } /** diff --git a/luceedebug/src/main/java/luceedebug/ThreadInfo.java b/luceedebug/src/main/java/luceedebug/ThreadInfo.java new file mode 100644 index 0000000..10bb565 --- /dev/null +++ b/luceedebug/src/main/java/luceedebug/ThreadInfo.java @@ -0,0 +1,15 @@ +package luceedebug; + +/** + * Simple thread information for DAP. + * Replaces JDWP ThreadReference dependency in ILuceeVm. + */ +public class ThreadInfo { + public final long id; + public final String name; + + public ThreadInfo(long id, String name) { + this.id = id; + this.name = name; + } +} diff --git a/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java b/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java index d8c410e..3ea95c1 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/CfValueDebuggerBridge.java @@ -11,7 +11,6 @@ import com.google.common.cache.CacheBuilder; import lucee.runtime.Component; -import lucee.runtime.exp.PageException; import lucee.runtime.type.Array; import luceedebug.ICfValueDebuggerBridge; import luceedebug.IDebugEntity; @@ -33,15 +32,28 @@ public static void pin(Object obj) { } private final Frame frame; + private final ValTracker valTracker; public final Object obj; public final long id; public CfValueDebuggerBridge(Frame frame, Object obj) { this.frame = Objects.requireNonNull(frame); + this.valTracker = frame.valTracker; this.obj = Objects.requireNonNull(obj); this.id = frame.valTracker.idempotentRegisterObject(obj).id; } + /** + * Constructor for use with native Lucee7 debugger frames where we don't have a Frame object. + * The valTracker is stored directly since we don't have a Frame. + */ + public CfValueDebuggerBridge(ValTracker valTracker, Object obj) { + this.frame = null; // Not available for native frames + this.valTracker = Objects.requireNonNull(valTracker); + this.obj = Objects.requireNonNull(obj); + this.id = valTracker.idempotentRegisterObject(obj).id; + } + public long getID() { return id; } @@ -59,34 +71,57 @@ public Scope(Map scopelike) { * @maybeNull_which --> null means "any type" */ public static IDebugEntity[] getAsDebugEntity(Frame frame, Object obj, IDebugEntity.DebugEntityType maybeNull_which) { - return getAsDebugEntity(frame.valTracker, obj, maybeNull_which); + return getAsDebugEntity(frame.valTracker, obj, maybeNull_which, null); } public static IDebugEntity[] getAsDebugEntity(ValTracker valTracker, Object obj, IDebugEntity.DebugEntityType maybeNull_which) { + return getAsDebugEntity(valTracker, obj, maybeNull_which, null); + } + + /** + * Get debug entities for an object's children. + * @param valTracker The value tracker + * @param obj The parent object to expand + * @param maybeNull_which Filter for named/indexed variables, or null for all + * @param parentPath The variable path of the parent (e.g., "local.foo"), or null if not tracked + */ + public static IDebugEntity[] getAsDebugEntity(ValTracker valTracker, Object obj, IDebugEntity.DebugEntityType maybeNull_which, String parentPath) { + return getAsDebugEntity(valTracker, obj, maybeNull_which, parentPath, null); + } + + /** + * Get debug entities for an object's children. + * @param valTracker The value tracker + * @param obj The parent object to expand + * @param maybeNull_which Filter for named/indexed variables, or null for all + * @param parentPath The variable path of the parent (e.g., "local.foo"), or null if not tracked + * @param frameId The frame ID for setVariable support, or null if not tracked + */ + public static IDebugEntity[] getAsDebugEntity(ValTracker valTracker, Object obj, IDebugEntity.DebugEntityType maybeNull_which, String parentPath, Long frameId) { final boolean namedOK = maybeNull_which == null || maybeNull_which == IDebugEntity.DebugEntityType.NAMED; final boolean indexedOK = maybeNull_which == null || maybeNull_which == IDebugEntity.DebugEntityType.INDEXED; if (obj instanceof MarkerTrait.Scope && namedOK) { @SuppressWarnings("unchecked") var m = (Map)(((MarkerTrait.Scope)obj).scopelike); - return getAsMaplike(valTracker, m); + return getAsMaplike(valTracker, m, parentPath, frameId); } else if (obj instanceof Map && namedOK) { if (obj instanceof Component) { return new IDebugEntity[] { - maybeNull_asValue(valTracker, "this", obj, true, true), - maybeNull_asValue(valTracker, "variables", ((Component)obj).getComponentScope()), - maybeNull_asValue(valTracker, "static", ((Component)obj).staticScope()) + maybeNull_asValue(valTracker, "this", obj, true, true, parentPath, frameId), + maybeNull_asValue(valTracker, "variables", ((Component)obj).getComponentScope(), parentPath, frameId), + maybeNull_asValue(valTracker, "static", ((Component)obj).staticScope(), parentPath, frameId) }; } else { @SuppressWarnings("unchecked") var m = (Map)obj; - return getAsMaplike(valTracker, m); + return getAsMaplike(valTracker, m, parentPath, frameId); } } else if (obj instanceof Array && indexedOK) { - return getAsCfArray(valTracker, (Array)obj); + return getAsCfArray(valTracker, (Array)obj, parentPath, frameId); } else { return new IDebugEntity[0]; @@ -95,16 +130,50 @@ else if (obj instanceof Array && indexedOK) { private static Comparator xscopeByName = Comparator.comparing((IDebugEntity v) -> v.getName().toLowerCase()); - private static IDebugEntity[] getAsMaplike(ValTracker valTracker, Map map) { + /** + * Check if an object is a "noisy" component function that should be hidden in debug output. + * Uses class name comparison to avoid ClassNotFoundException in OSGi extension mode. + */ + private static boolean isNoisyComponentFunction(Object obj) { + String className = obj.getClass().getName(); + // Discard UDFGetterProperty, UDFSetterProperty, UDFImpl (noisy) + // But retain Lambda and Closure (useful) + boolean isNoisyUdf = className.equals("lucee.runtime.type.UDFGetterProperty") + || className.equals("lucee.runtime.type.UDFSetterProperty") + || className.equals("lucee.runtime.type.UDFImpl"); + boolean isLambdaOrClosure = className.equals("lucee.runtime.type.Lambda") + || className.equals("lucee.runtime.type.Closure"); + return isNoisyUdf && !isLambdaOrClosure; + } + + /** + * Check class by name to avoid ClassNotFoundException in OSGi extension mode. + * Some Lucee core classes aren't visible to the extension classloader. + */ + private static boolean isInstanceOf(Object obj, String className) { + if (obj == null) return false; + Class clazz = obj.getClass(); + while (clazz != null) { + if (clazz.getName().equals(className)) return true; + // Check interfaces + for (Class iface : clazz.getInterfaces()) { + if (iface.getName().equals(className)) return true; + } + clazz = clazz.getSuperclass(); + } + return false; + } + + private static IDebugEntity[] getAsMaplike(ValTracker valTracker, Map map, String parentPath, Long frameId) { ArrayList results = new ArrayList<>(); - + Set> entries = map.entrySet(); // We had been showing member functions on component instances, but it's really just noise. Maybe this could be a configurable option. final var skipNoisyComponentFunctions = true; - + for (Map.Entry entry : entries) { - IDebugEntity val = maybeNull_asValue(valTracker, entry.getKey(), entry.getValue(), skipNoisyComponentFunctions, false); + IDebugEntity val = maybeNull_asValue(valTracker, entry.getKey(), entry.getValue(), skipNoisyComponentFunctions, false, parentPath, frameId); if (val != null) { results.add(val); } @@ -117,17 +186,17 @@ private static IDebugEntity[] getAsMaplike(ValTracker valTracker, Map result = new ArrayList<>(); // cf 1-indexed for (int i = 1; i <= array.size(); ++i) { - IDebugEntity val = maybeNull_asValue(valTracker, Integer.toString(i), array.get(i, null)); + IDebugEntity val = maybeNull_asValue(valTracker, Integer.toString(i), array.get(i, null), parentPath, frameId); if (val != null) { result.add(val); } @@ -137,7 +206,7 @@ private static IDebugEntity[] getAsCfArray(ValTracker valTracker, Array array) { } public IDebugEntity maybeNull_asValue(String name) { - return maybeNull_asValue(frame.valTracker, name, obj, true, false); + return maybeNull_asValue(valTracker, name, obj, true, false, null, null); } /** @@ -146,21 +215,27 @@ public IDebugEntity maybeNull_asValue(String name) { * which is used to cut down on noise from CFC getters/setters/member-functions which aren't too useful for debugging. * Maybe such things should be optionally included as per some configuration. */ - private static IDebugEntity maybeNull_asValue(ValTracker valTracker, String name, Object obj) { - return maybeNull_asValue(valTracker, name, obj, true, false); + private static IDebugEntity maybeNull_asValue(ValTracker valTracker, String name, Object obj, String parentPath, Long frameId) { + return maybeNull_asValue(valTracker, name, obj, true, false, parentPath, frameId); } /** * @markDiscoveredComponentsAsIterableThisRef if true, a Component will be marked as if it were any normal Map. This drives discovery of variables; * showing the "top level" of a component we want to show its "inner scopes" (this, variables, and static) + * @param parentPath The variable path of the parent container (e.g., "local"), or null if not tracked + * @param frameId The frame ID for setVariable support, or null if not tracked */ private static IDebugEntity maybeNull_asValue( ValTracker valTracker, String name, Object obj, boolean skipNoisyComponentFunctions, - boolean treatDiscoveredComponentsAsScopes + boolean treatDiscoveredComponentsAsScopes, + String parentPath, + Long frameId ) { + // Build the full path for this variable + String childPath = (parentPath != null) ? parentPath + "." + name : null; DebugEntity val = new DebugEntity(); val.name = name; @@ -182,14 +257,14 @@ else if (obj instanceof java.util.Date) { else if (obj instanceof Array) { int len = ((Array)obj).size(); val.value = "Array (" + len + ")"; - val.variablesReference = valTracker.idempotentRegisterObject(obj).id; + val.variablesReference = valTracker.registerObjectWithPathAndFrameId(obj, childPath, frameId).id; } else if ( /* // retain the lambbda/closure types var lambda = () => {} // lucee.runtime.type.Lambda var closure = function() {} // lucee.runtime.type.Closure - + // discard component function types, they're mostly noise in debug output component accessors=true { property name="foo"; // lucee.runtime.type.UDFGetterProperty / lucee.runtime.type.UDFSetterProperty @@ -197,32 +272,29 @@ function foo() {} // lucee.runtime.type.UDFImpl } */ skipNoisyComponentFunctions - && (obj instanceof lucee.runtime.type.UDFGetterProperty - || obj instanceof lucee.runtime.type.UDFSetterProperty - || obj instanceof lucee.runtime.type.UDFImpl) - && !( - obj instanceof lucee.runtime.type.Lambda - || obj instanceof lucee.runtime.type.Closure - ) + && isNoisyComponentFunction(obj) ) { return null; } - else if (obj instanceof lucee.runtime.type.QueryImpl) { + else if (isInstanceOf(obj, "lucee.runtime.type.QueryImpl")) { + // Handle Query - use reflection to avoid ClassNotFoundException in OSGi try { - lucee.runtime.type.query.QueryArray queryAsArrayOfStructs = lucee.runtime.type.query.QueryArray.toQueryArray((lucee.runtime.type.QueryImpl)obj); - val.value = "Query (" + queryAsArrayOfStructs.size() + " rows)"; - + java.lang.reflect.Method toQueryArrayMethod = Class.forName("lucee.runtime.type.query.QueryArray", true, obj.getClass().getClassLoader()) + .getMethod("toQueryArray", Class.forName("lucee.runtime.type.QueryImpl", true, obj.getClass().getClassLoader())); + Object queryAsArrayOfStructs = toQueryArrayMethod.invoke(null, obj); + java.lang.reflect.Method sizeMethod = queryAsArrayOfStructs.getClass().getMethod("size"); + int size = (int) sizeMethod.invoke(queryAsArrayOfStructs); + val.value = "Query (" + size + " rows)"; + pin(queryAsArrayOfStructs); - val.variablesReference = valTracker.idempotentRegisterObject(queryAsArrayOfStructs).id; + val.variablesReference = valTracker.registerObjectWithPathAndFrameId(queryAsArrayOfStructs, childPath, frameId).id; } - catch (PageException e) { - // - // duplicative w/ catch-all else block - // + catch (Throwable e) { + // Fall back to generic display try { val.value = obj.getClass().toString(); - val.variablesReference = valTracker.idempotentRegisterObject(obj).id; + val.variablesReference = valTracker.registerObjectWithPathAndFrameId(obj, childPath, frameId).id; } catch (Throwable x) { val.value = " (no string representation available)"; @@ -236,22 +308,22 @@ else if (obj instanceof Map) { if (treatDiscoveredComponentsAsScopes) { var v = new MarkerTrait.Scope((Component)obj); ((ComponentScopeMarkerTraitShim)obj).__luceedebug__pinComponentScopeMarkerTrait(v); - val.variablesReference = valTracker.idempotentRegisterObject(v).id; + val.variablesReference = valTracker.registerObjectWithPathAndFrameId(v, childPath, frameId).id; } else { - val.variablesReference = valTracker.idempotentRegisterObject(obj).id; + val.variablesReference = valTracker.registerObjectWithPathAndFrameId(obj, childPath, frameId).id; } } else { int len = ((Map)obj).size(); val.value = "{} (" + len + " members)"; - val.variablesReference = valTracker.idempotentRegisterObject(obj).id; + val.variablesReference = valTracker.registerObjectWithPathAndFrameId(obj, childPath, frameId).id; } } else { try { val.value = obj.getClass().toString(); - val.variablesReference = valTracker.idempotentRegisterObject(obj).id; + val.variablesReference = valTracker.registerObjectWithPathAndFrameId(obj, childPath, frameId).id; } catch (Throwable x) { val.value = " (no string representation available)"; @@ -272,7 +344,7 @@ public int getNamedVariablesCount() { } public int getIndexedVariablesCount() { - if (obj instanceof lucee.runtime.type.scope.Argument) { + if (isInstanceOf(obj, "lucee.runtime.type.scope.Argument")) { // `arguments` scope is both an Array and a Map, which represents the possiblity that a function is called with named args or positional args. // It seems like saner default behavior to report it only as having named variables, and zero indexed variables. return 0; @@ -292,11 +364,33 @@ public static String getSourcePath(Object obj) { if (obj instanceof Component) { return ((Component)obj).getPageSource().getPhyscalFile().getAbsolutePath(); } - else if (obj instanceof lucee.runtime.type.UDFImpl) { - return ((lucee.runtime.type.UDFImpl)obj).properties.getPageSource().getPhyscalFile().getAbsolutePath(); + else if (isInstanceOf(obj, "lucee.runtime.type.UDFImpl")) { + // Use reflection to avoid ClassNotFoundException in OSGi + try { + java.lang.reflect.Field propsField = obj.getClass().getField("properties"); + Object props = propsField.get(obj); + java.lang.reflect.Method getPageSourceMethod = props.getClass().getMethod("getPageSource"); + Object pageSource = getPageSourceMethod.invoke(props); + java.lang.reflect.Method getPhyscalFileMethod = pageSource.getClass().getMethod("getPhyscalFile"); + Object file = getPhyscalFileMethod.invoke(pageSource); + java.lang.reflect.Method getAbsolutePathMethod = file.getClass().getMethod("getAbsolutePath"); + return (String) getAbsolutePathMethod.invoke(file); + } catch (Throwable e) { + return null; + } } - else if (obj instanceof lucee.runtime.type.UDFGSProperty) { - return ((lucee.runtime.type.UDFGSProperty)obj).getPageSource().getPhyscalFile().getAbsolutePath(); + else if (isInstanceOf(obj, "lucee.runtime.type.UDFGSProperty")) { + // Use reflection to avoid ClassNotFoundException in OSGi + try { + java.lang.reflect.Method getPageSourceMethod = obj.getClass().getMethod("getPageSource"); + Object pageSource = getPageSourceMethod.invoke(obj); + java.lang.reflect.Method getPhyscalFileMethod = pageSource.getClass().getMethod("getPhyscalFile"); + Object file = getPhyscalFileMethod.invoke(pageSource); + java.lang.reflect.Method getAbsolutePathMethod = file.getClass().getMethod("getAbsolutePath"); + return (String) getAbsolutePathMethod.invoke(file); + } catch (Throwable e) { + return null; + } } else { return null; diff --git a/luceedebug/src/main/java/luceedebug/coreinject/DebugManager.java b/luceedebug/src/main/java/luceedebug/coreinject/DebugManager.java index ba008bd..76548de 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/DebugManager.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/DebugManager.java @@ -13,8 +13,6 @@ import java.util.concurrent.TimeUnit; import java.util.function.Supplier; -import javax.servlet.ServletException; - import com.google.common.collect.MapMaker; import com.sun.jdi.Bootstrap; import com.sun.jdi.VirtualMachine; @@ -75,7 +73,12 @@ public void spawnWorker(Config config, String jdwpHost, int jdwpPort, String deb new Thread(() -> { System.out.println("[luceedebug] jdwp self connect OK"); - DapServer.createForSocket(luceeVm, config, debugHost, debugPort); + try { + DapServer.createForSocket(luceeVm, config, debugHost, debugPort); + } catch (Throwable t) { + System.out.println("[luceedebug] DAP server thread failed: " + t.getMessage()); + t.printStackTrace(); + } }, threadName).start(); } @@ -196,7 +199,7 @@ public PageContextAndOutputStream(PageContext pageContext, ByteArrayOutputStream } // is there a way to conjure up a new PageContext without having some other page context? - public static PageContextAndOutputStream ephemeralPageContextFromOther(PageContext pc) throws ServletException { + public static PageContextAndOutputStream ephemeralPageContextFromOther(PageContext pc) throws Exception { final var outputStream = new ByteArrayOutputStream(); PageContext freshEphemeralPageContext = lucee.runtime.util.PageContextUtil.getPageContext( /*Config config*/ pc.getConfig(), @@ -205,7 +208,7 @@ public static PageContextAndOutputStream ephemeralPageContextFromOther(PageConte /*String host*/ "", /*String scriptName*/ "", /*String queryString*/ "", - /*Cookie[] cookies*/ new javax.servlet.http.Cookie[] {}, + /*Cookie[] cookies*/ null, /*Map headers*/ new HashMap<>(), /*Map parameters*/ new HashMap<>(), /*Map attributes*/ new HashMap<>(), @@ -499,10 +502,17 @@ synchronized public IDebugEntity[] getVariables(long id, IDebugEntity.DebugEntit } synchronized public IDebugFrame[] getCfStack(Thread thread) { + System.out.println("[luceedebug] getCfStack: looking for thread=" + thread.getName() + " (id=" + thread.getId() + ") identity=" + System.identityHashCode(thread)); + System.out.println("[luceedebug] getCfStack: cfStackByThread has " + cfStackByThread.size() + " entries:"); + for (var entry : cfStackByThread.entrySet()) { + Thread t = entry.getKey(); + System.out.println("[luceedebug] thread=" + t.getName() + " (id=" + t.getId() + ") identity=" + System.identityHashCode(t) + " frames=" + entry.getValue().size()); + } ArrayList stack = cfStackByThread.get(thread); - if (stack == null) { - System.out.println("getCfStack called, frames was null, frames is " + cfStackByThread + ", passed thread was " + thread); - System.out.println(" thread=" + thread + " this=" + this); + + // Agent mode: only use bytecode-instrumented frames, no native fallback + if (stack == null || stack.isEmpty()) { + System.out.println("[luceedebug] getCfStack: no instrumented frames for thread " + thread); return new Frame[0]; } @@ -512,9 +522,11 @@ synchronized public IDebugFrame[] getCfStack(Thread thread) { // go backwards, "most recent first" for (int i = stack.size() - 1; i >= 0; --i) { DebugFrame frame = stack.get(i); + System.out.println("[luceedebug] getCfStack: frame[" + i + "] line=" + frame.getLine() + " source=" + frame.getSourceFilePath()); if (frame.getLine() == 0) { - // ???? should we just not push such frames on the stack? - // what does this mean? + // Frame line not yet set - step notification hasn't run yet + // This can happen when breakpoint fires before first line executes + System.out.println("[luceedebug] getCfStack: skipping frame with line=0"); continue; } else { @@ -564,6 +576,7 @@ public void registerStepRequest(Thread thread, int type) { // fallthrough case CfStepRequest.STEP_OUT: { stepRequestByThread.put(thread, new CfStepRequest(frame.getDepth(), type)); + hasAnyStepRequests = true; return; } default: { @@ -577,15 +590,27 @@ public void registerStepRequest(Thread thread, int type) { // This holds strongrefs to Thread objects, but requests should be cleared out after their completion // It doesn't make sense to have a step request for thread that would otherwise be reclaimable but for our reference to it here private ConcurrentHashMap stepRequestByThread = new ConcurrentHashMap<>(); + // Fast-path flag: volatile read is cheaper than ConcurrentHashMap.isEmpty() or .get() + private volatile boolean hasAnyStepRequests = false; public void clearStepRequest(Thread thread) { stepRequestByThread.remove(thread); + hasAnyStepRequests = !stepRequestByThread.isEmpty(); } public void luceedebug_stepNotificationEntry_step(int lineNumber) { - final int minDistanceToLuceedebugStepNotificationEntryFrame = 0; Thread currentThread = Thread.currentThread(); - DebugFrame frame = maybeUpdateTopmostFrame(currentThread, lineNumber); // should be "definite update topmost frame", we 100% expect there to be a frame + + // ALWAYS update the frame's line number, even when not stepping + // This is required for breakpoints to work - they need to know the current line + DebugFrame frame = maybeUpdateTopmostFrame(currentThread, lineNumber); + + // Fast path: if not stepping, we're done after updating line number + if (!hasAnyStepRequests) { + return; + } + + final int minDistanceToLuceedebugStepNotificationEntryFrame = 0; CfStepRequest request = stepRequestByThread.get(currentThread); if (request == null) { @@ -606,10 +631,15 @@ else if (frame instanceof Frame) { * So we want the debugger to return to the callsite in the normal case, but jump to any catch/finally blocks in the exceptional case. */ public void luceedebug_stepNotificationEntry_stepAfterCompletedUdfCall() { + // Fast path: single volatile read when not stepping (99.9% of the time) + if (!hasAnyStepRequests) { + return; + } + final int minDistanceToLuceedebugStepNotificationEntryFrame = 0; Thread currentThread = Thread.currentThread(); - DebugFrame frame = getTopmostFrame(Thread.currentThread()); + DebugFrame frame = getTopmostFrame(currentThread); if (frame == null) { // just popped last frame? @@ -690,6 +720,8 @@ private DebugFrame getTopmostFrame(Thread thread) { } public void pushCfFrame(PageContext pageContext, String sourceFilePath) { + Thread t = Thread.currentThread(); + System.out.println("[luceedebug] pushCfFrame: thread=" + t.getName() + " (id=" + t.getId() + ") identity=" + System.identityHashCode(t) + " file=" + sourceFilePath); maybe_pushCfFrame_worker(pageContext, sourceFilePath); } diff --git a/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java index e4e4ba2..cf75d2e 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/LuceeVm.java @@ -461,6 +461,7 @@ public LuceeVm(Config config, VirtualMachine vm) { // We'll have set done=true prior to resuming this thread. while (!done.get()); // about ~8ms to queueWork + wait for work to complete }); + } /** @@ -471,15 +472,21 @@ public LuceeVm(Config config, VirtualMachine vm) { */ private static enum SteppingState { stepping, finalizingViaAwaitedBreakpoint } private ConcurrentMap steppingStatesByThread = new ConcurrentHashMap<>(); - private Consumer stepEventCallback = null; - private BiConsumer breakpointEventCallback = null; + private Consumer stepEventCallback = null; + + /** + * Callback for native breakpoint events (Lucee7+ native suspend). + * Called with Java thread ID and optional label when a thread hits a native breakpoint. + */ + private BiConsumer nativeBreakpointEventCallback = null; + private BiConsumer breakpointEventCallback = null; private Consumer breakpointsChangedCallback = null; - public void registerStepEventCallback(Consumer cb) { + public void registerStepEventCallback(Consumer cb) { stepEventCallback = cb; } - public void registerBreakpointEventCallback(BiConsumer cb) { + public void registerBreakpointEventCallback(BiConsumer cb) { breakpointEventCallback = cb; } @@ -487,6 +494,14 @@ public void registerBreakpointsChangedCallback(Consumer this.breakpointsChangedCallback = cb; } + /** + * Register callback for native breakpoint events (Lucee7+). + * Called with Java thread ID and optional label when a thread hits a native breakpoint. + */ + public void registerNativeBreakpointEventCallback(BiConsumer cb) { + nativeBreakpointEventCallback = cb; + } + private void initEventPump() { new java.lang.Thread(() -> { try { @@ -666,7 +681,7 @@ private void handleBreakpointEvent(BreakpointEvent event) { // We would delete the breakpoint request here, // but it should have been registered with an eventcount filter of 1, // meaning that it has auto-expired - stepEventCallback.accept(JdwpThreadID.of(event.thread())); + stepEventCallback.accept(threadID.get()); } } else { @@ -692,23 +707,31 @@ private void handleBreakpointEvent(BreakpointEvent event) { if (breakpointEventCallback != null) { final var bpID = (DapBreakpointID) request.getProperty(LUCEEDEBUG_BREAKPOINT_ID); - breakpointEventCallback.accept(threadID, bpID); + breakpointEventCallback.accept(threadID.get(), bpID); } } } - public ThreadReference[] getThreadListing() { - var result = new ArrayList(); + public ThreadInfo[] getThreadListing() { + var result = new ArrayList(); for (var threadRef : threadMap_.threadRefByThread.values()) { - result.add(threadRef); + try { + result.add(new ThreadInfo(threadRef.uniqueID(), threadRef.name())); + } + catch (ObjectCollectedException e) { + // Thread was garbage collected, skip it + } } - return result.toArray(size -> new ThreadReference[size]); + return result.toArray(size -> new ThreadInfo[size]); } public IDebugFrame[] getStackTrace(long jdwpThreadId) { var thread = threadMap_.getThreadByJdwpIdOrFail(new JdwpThreadID(jdwpThreadId)); - return GlobalIDebugManagerHolder.debugManager.getCfStack(thread); + System.out.println("[luceedebug] getStackTrace: jdwpThreadId=" + jdwpThreadId + " -> thread=" + thread.getName() + " (id=" + thread.getId() + ") identity=" + System.identityHashCode(thread)); + var frames = GlobalIDebugManagerHolder.debugManager.getCfStack(thread); + System.out.println("[luceedebug] getStackTrace: returning " + frames.length + " frames"); + return frames; } public IDebugEntity[] getScopes(long frameID) { @@ -786,6 +809,18 @@ private BpLineAndId[] freshBpLineAndIdRecordsFromLines(RawIdePath idePath, Canon } public IBreakpoint[] bindBreakpoints(RawIdePath idePath, CanonicalServerAbsPath serverPath, int[] lines, String[] exprs) { + if (NativeDebuggerListener.isNativeMode()) { + NativeDebuggerListener.clearBreakpointsForFile(serverPath.get()); + for (int line : lines) { + NativeDebuggerListener.addBreakpoint(serverPath.get(), line); + } + var lineInfo = freshBpLineAndIdRecordsFromLines(idePath, serverPath, lines, exprs); + IBreakpoint[] result = new Breakpoint[lineInfo.length]; + for (int i = 0; i < lineInfo.length; i++) { + result[i] = Breakpoint.Bound(lineInfo[i].line, lineInfo[i].id); + } + return result; + } return __internal__bindBreakpoints(serverPath, freshBpLineAndIdRecordsFromLines(idePath, serverPath, lines, exprs)); } @@ -915,6 +950,10 @@ private void clearExistingBreakpoints(CanonicalServerAbsPath absPath) { } public void clearAllBreakpoints() { + if (NativeDebuggerListener.isNativeMode()) { + NativeDebuggerListener.clearAllBreakpoints(); + return; + } replayableBreakpointRequestsByAbsPath_.clear(); vm_.eventRequestManager().deleteAllBreakpoints(); } @@ -956,6 +995,10 @@ private void continue_(ThreadReference threadRef) { } public void continueAll() { + if (NativeDebuggerListener.isNativeMode()) { + NativeDebuggerListener.resumeAllNativeThreads(); + return; + } // avoid concurrent modification exceptions, calling continue_ mutates `suspendedThreads` Arrays // TODO: Set.toArray(sz -> new T[sz]) is not typesafe, changing the type of Set @@ -977,8 +1020,12 @@ public void stepIn(long jdwpThreadID) { stepIn(new JdwpThreadID(jdwpThreadID)); } - public void continue_(long jdwpThreadID) { - continue_(new JdwpThreadID(jdwpThreadID)); + public void continue_(long threadID) { + if (NativeDebuggerListener.isNativeMode()) { + NativeDebuggerListener.resumeNativeThread(threadID); + return; + } + continue_(new JdwpThreadID(threadID)); } public void stepIn(JdwpThreadID jdwpThreadID) { @@ -1076,6 +1123,21 @@ public String dumpAsJSON(int dapVariablesReference) { return GlobalIDebugManagerHolder.debugManager.doDumpAsJSON(getSuspendedThreadListForDumpWorker(), dapVariablesReference); } + public String getMetadata(int dapVariablesReference) { + // Not implemented for JDWP mode - would need IDebugManager extension + return "\"getMetadata not supported in JDWP mode\""; + } + + public String getApplicationSettings() { + // Not implemented for JDWP mode - would need IDebugManager extension + return "\"getApplicationSettings not supported in JDWP mode\""; + } + + public org.eclipse.lsp4j.debug.CompletionItem[] getCompletions(int frameId, String partialExpr) { + // Not implemented for JDWP mode + return new org.eclipse.lsp4j.debug.CompletionItem[0]; + } + public String[] getTrackedCanonicalFileNames() { final var result = new ArrayList(); for (var klassMap : klassMap_.values()) { @@ -1107,4 +1169,30 @@ public String getSourcePathForVariablesRef(int variablesRef) { public Either> evaluate(int frameID, String expr) { return GlobalIDebugManagerHolder.debugManager.evaluate((Long)(long)frameID, expr); } + + public Either> setVariable(long variablesReference, String name, String value, long frameId) { + // setVariable not yet implemented for JDWP mode + // Would need to use DebugManager to evaluate and set the value + return Either.Left("setVariable not yet supported in JDWP mode - use native debugger mode instead"); + } + + // Not used in JDWP mode - exception handling uses JDWP events + public void registerExceptionEventCallback(Consumer cb) { + // no-op for JDWP mode + } + + public void registerPauseEventCallback(Consumer cb) { + // no-op for JDWP mode - could use ThreadReference.suspend() but not implemented + } + + public void pause(long threadID) { + // TODO: Could use ThreadReference.suspend() for JDWP mode + // For now, just log that it's not supported + System.out.println("[luceedebug] pause() not implemented for JDWP mode"); + } + + public Throwable getExceptionForThread(long threadId) { + // JDWP mode doesn't use NativeDebuggerListener for exceptions + return null; + } } diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java new file mode 100644 index 0000000..9486d91 --- /dev/null +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeDebuggerListener.java @@ -0,0 +1,1280 @@ +package luceedebug.coreinject; + +import java.lang.ref.WeakReference; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import lucee.runtime.PageContext; +import luceedebug.Config; +import luceedebug.Log; + +/** + * Step mode for stepping operations. + */ +enum StepMode { + NONE, + STEP_INTO, + STEP_OVER, + STEP_OUT +} + +/** + * Implementation of Lucee's DebuggerListener interface for native breakpoint support. + * This allows luceedebug to receive suspend/resume callbacks and manage breakpoints + * without JDWP instrumentation. + * + * Note: This class implements the interface via reflection since it's in Lucee core, + * not in the loader. We create a dynamic proxy that forwards calls to this class. + * + * TODO: Consider using DAP OutputEvent to show debug messages in VS Code Debug Console + * instead of System.out.println. This would give users visibility into native breakpoint + * activity within the IDE. + */ +public class NativeDebuggerListener { + + /** + * Name for this debugger (shown in Lucee logs). + */ + public static String getName() { + return NativeDebuggerListener.class.getName(); + } + + /** + * Breakpoint storage - parallel arrays for fast lookup. + * Writers synchronize on breakpointLock, readers just access the arrays. + * The volatile hasSuspendConditions provides the memory barrier for visibility. + */ + private static final Object breakpointLock = new Object(); + private static int[] bpLines = new int[0]; + private static String[] bpFiles = new String[0]; + private static String[] bpConditions = new String[0]; // null entries for unconditional + + /** + * Pre-computed bounds for fast rejection in shouldSuspend(). + * Updated whenever breakpoints change. + */ + private static int bpMinLine = Integer.MAX_VALUE; + private static int bpMaxLine = Integer.MIN_VALUE; + private static int bpMaxPathLen = 0; + + /** + * Function breakpoint storage - parallel arrays for fast lookup. + * Writers synchronize on funcBpLock, readers just access the arrays. + */ + private static final Object funcBpLock = new Object(); + private static String[] funcBpNames = new String[0]; // lowercase function names + private static String[] funcBpComponents = new String[0]; // lowercase component names (null = any) + private static String[] funcBpConditions = new String[0]; // CFML conditions (null = unconditional) + private static boolean[] funcBpIsWildcard = new boolean[0]; // true if name ends with * + + /** + * Pre-computed bounds for fast rejection in onFunctionEntry(). + */ + private static int funcBpMinLen = Integer.MAX_VALUE; + private static int funcBpMaxLen = Integer.MIN_VALUE; + private static volatile boolean hasFuncBps = false; + + /** + * Map of Java thread ID -> WeakReference for natively suspended threads. + * Used to call debuggerResume() when DAP continue is received. + * Note: We use PageContext (loader interface) not PageContextImpl to avoid class loading cycles. + */ + private static final ConcurrentHashMap> nativelySuspendedThreads = new ConcurrentHashMap<>(); + + /** + * Suspend location info for threads (file and line where suspended). + * Used when there are no native DebuggerFrames (top-level code). + */ + private static final ConcurrentHashMap suspendLocations = new ConcurrentHashMap<>(); + + /** + * Simple holder for file/line/label at suspend point. + */ + public static class SuspendLocation { + public final String file; + public final int line; + public final String label; + public final Throwable exception; // non-null if suspended due to exception + public SuspendLocation( String file, int line, String label ) { + this( file, line, label, null ); + } + public SuspendLocation( String file, int line, String label, Throwable exception ) { + this.file = file; + this.line = line; + this.label = label; + this.exception = exception; + } + } + + /** + * Pending exception for a thread - stored when onException returns true, + * then consumed when onSuspend is called. + */ + private static final ConcurrentHashMap pendingExceptions = new ConcurrentHashMap<>(); + + /** + * Cache for executable lines - keyed by file path, stores compileTime and lines. + * Avoids re-decoding bitmap on every breakpoint set if file hasn't changed. + */ + private static class CachedExecutableLines { + final long compileTime; + final int[] lines; + CachedExecutableLines( long compileTime, int[] lines ) { + this.compileTime = compileTime; + this.lines = lines; + } + } + private static final ConcurrentHashMap executableLinesCache = new ConcurrentHashMap<>(); + + /** + * Callback to notify LuceeVm when a thread suspends via native breakpoint. + * Called with Java thread ID and optional label. Used for "breakpoint" stop reason in DAP. + * Label is non-null for programmatic breakpoint("label") calls, null otherwise. + */ + private static volatile BiConsumer onNativeSuspendCallback = null; + + /** + * Callback to notify LuceeVm when a thread stops after a step. + * Called with Java thread ID. Used for "step" stop reason in DAP. + */ + private static volatile Consumer onNativeStepCallback = null; + + /** + * Callback to notify LuceeVm when a thread stops due to an exception. + * Called with Java thread ID. Used for "exception" stop reason in DAP. + */ + private static volatile Consumer onNativeExceptionCallback = null; + + /** + * Native mode flag - when true, use Lucee's DebuggerRegistry API. + */ + private static volatile boolean nativeMode = false; + + /** + * Flag indicating a DAP client is actually connected. + * Set to true when DAP client connects, false when it disconnects. + * Distinct from callbacks being registered (which happens at extension startup). + */ + private static volatile boolean dapClientConnected = false; + + /** + * Flag to break on uncaught exceptions. + * Set via DAP setExceptionBreakpoints request. + */ + private static volatile boolean breakOnUncaughtExceptions = false; + + /** + * Flag to forward System.out/err to DAP client. + * Set via launch.json consoleOutput option. + */ + private static volatile boolean consoleOutput = false; + + /** + * Fast-path flag: true when there's anything that could cause a suspend. + * When false, shouldSuspend() returns immediately without any work. + * Updated whenever breakpoints, exceptions, or stepping state changes. + */ + private static volatile boolean hasSuspendConditions = false; + + /** + * Per-thread stepping state. + */ + private static final ConcurrentHashMap steppingThreads = new ConcurrentHashMap<>(); + + /** + * Threads that have been requested to pause. + * Checked in shouldSuspend() - when a thread is in this set it will pause at the next CFML line. + */ + private static final ConcurrentHashMap threadsToPause = new ConcurrentHashMap<>(); + + /** + * Threads that suspended due to a pause request. + * Set in shouldSuspend() when consuming a pause request, consumed in onSuspend(). + */ + private static final ConcurrentHashMap pausedThreads = new ConcurrentHashMap<>(); + + /** + * Callback to notify when a thread pauses due to user-initiated pause request. + * Called with Java thread ID. Used for "pause" stop reason in DAP. + */ + private static volatile Consumer onNativePauseCallback = null; + + /** + * Update hasSuspendConditions flag based on current state. + * Called whenever breakpoints, exception settings, stepping, or pause state changes. + */ + private static void updateHasSuspendConditions() { + hasSuspendConditions = dapClientConnected && + (bpLines.length > 0 || hasFuncBps || breakOnUncaughtExceptions || !steppingThreads.isEmpty() || !threadsToPause.isEmpty()); + } + + /** + * Rebuild breakpoint bounds after any modification. + * Must be called while holding breakpointLock. + */ + private static void rebuildBreakpointBounds() { + int min = Integer.MAX_VALUE; + int max = Integer.MIN_VALUE; + int maxLen = 0; + for (int i = 0; i < bpLines.length; i++) { + if (bpLines[i] < min) min = bpLines[i]; + if (bpLines[i] > max) max = bpLines[i]; + if (bpFiles[i].length() > maxLen) maxLen = bpFiles[i].length(); + } + bpMinLine = min; + bpMaxLine = max; + bpMaxPathLen = maxLen; + } + + /** + * Stepping state for a single thread. + */ + private static class StepState { + final StepMode mode; + final int startDepth; + + StepState(StepMode mode, int startDepth) { + this.mode = mode; + this.startDepth = startDepth; + } + } + + /** + * Cached reflection method for DebuggerFrame.getLine(). + * Initialized lazily on first use in getStackDepth(). + */ + private static volatile java.lang.reflect.Method debuggerFrameGetLineMethod = null; + + public static void setNativeMode(boolean enabled) { + nativeMode = enabled; + Log.info("Native mode: " + enabled); + } + + public static boolean isNativeMode() { + return nativeMode; + } + + /** + * Set the callback for native suspend events (breakpoints). + * LuceeVm should register this to receive notifications and send DAP stopped events. + */ + public static void setOnNativeSuspendCallback(BiConsumer callback) { + onNativeSuspendCallback = callback; + } + + /** + * Set the callback for native step events. + * LuceeVm should register this to receive notifications and send DAP step events. + */ + public static void setOnNativeStepCallback(Consumer callback) { + onNativeStepCallback = callback; + } + + /** + * Set the callback for native exception events. + * LuceeVm should register this to receive notifications and send DAP exception events. + */ + public static void setOnNativeExceptionCallback(Consumer callback) { + onNativeExceptionCallback = callback; + } + + /** + * Set the callback for native pause events. + * LuceeVm should register this to receive notifications and send DAP pause events. + */ + public static void setOnNativePauseCallback(Consumer callback) { + onNativePauseCallback = callback; + } + + /** + * Add a breakpoint at the given file and line. + */ + public static void addBreakpoint(String file, int line) { + addBreakpoint(file, line, null); + } + + /** + * Add a breakpoint at the given file and line with optional condition. + * @param condition CFML expression to evaluate, or null for unconditional breakpoint + */ + public static void addBreakpoint(String file, int line, String condition) { + String canonFile = Config.canonicalizeFileName(file); + String newCondition = (condition != null && !condition.isEmpty()) ? condition : null; + synchronized (breakpointLock) { + // Check if already exists - update condition if so + for (int i = 0; i < bpLines.length; i++) { + if (bpLines[i] == line && bpFiles[i].equals(canonFile)) { + // Copy-on-write: create new array for thread safety + String[] newConditions = bpConditions.clone(); + newConditions[i] = newCondition; + bpConditions = newConditions; + Log.info("Breakpoint updated: " + Config.shortenPath(canonFile) + ":" + line); + return; + } + } + // Add new breakpoint + int len = bpLines.length; + int[] newLines = new int[len + 1]; + String[] newFiles = new String[len + 1]; + String[] newConditions = new String[len + 1]; + System.arraycopy(bpLines, 0, newLines, 0, len); + System.arraycopy(bpFiles, 0, newFiles, 0, len); + System.arraycopy(bpConditions, 0, newConditions, 0, len); + newLines[len] = line; + newFiles[len] = canonFile; + newConditions[len] = newCondition; + bpLines = newLines; + bpFiles = newFiles; + bpConditions = newConditions; + rebuildBreakpointBounds(); + } + updateHasSuspendConditions(); + Log.info("Breakpoint set: " + Config.shortenPath(canonFile) + ":" + line + + (newCondition != null ? " condition=" + newCondition : "")); + } + + /** + * Remove a breakpoint at the given file and line. + */ + public static void removeBreakpoint(String file, int line) { + String canonFile = Config.canonicalizeFileName(file); + synchronized (breakpointLock) { + int idx = -1; + for (int i = 0; i < bpLines.length; i++) { + if (bpLines[i] == line && bpFiles[i].equals(canonFile)) { + idx = i; + break; + } + } + if (idx < 0) return; // not found + + int len = bpLines.length; + int[] newLines = new int[len - 1]; + String[] newFiles = new String[len - 1]; + String[] newConditions = new String[len - 1]; + System.arraycopy(bpLines, 0, newLines, 0, idx); + System.arraycopy(bpLines, idx + 1, newLines, idx, len - idx - 1); + System.arraycopy(bpFiles, 0, newFiles, 0, idx); + System.arraycopy(bpFiles, idx + 1, newFiles, idx, len - idx - 1); + System.arraycopy(bpConditions, 0, newConditions, 0, idx); + System.arraycopy(bpConditions, idx + 1, newConditions, idx, len - idx - 1); + bpLines = newLines; + bpFiles = newFiles; + bpConditions = newConditions; + rebuildBreakpointBounds(); + } + updateHasSuspendConditions(); + Log.info("Breakpoint removed: " + Config.shortenPath(canonFile) + ":" + line); + } + + /** + * Clear all breakpoints for a given file. + */ + public static void clearBreakpointsForFile(String file) { + String canonFile = Config.canonicalizeFileName(file); + synchronized (breakpointLock) { + // Count how many to keep + int keepCount = 0; + for (int i = 0; i < bpFiles.length; i++) { + if (!bpFiles[i].equals(canonFile)) keepCount++; + } + if (keepCount == bpFiles.length) return; // nothing to remove + + int[] newLines = new int[keepCount]; + String[] newFiles = new String[keepCount]; + String[] newConditions = new String[keepCount]; + int j = 0; + for (int i = 0; i < bpFiles.length; i++) { + if (!bpFiles[i].equals(canonFile)) { + newLines[j] = bpLines[i]; + newFiles[j] = bpFiles[i]; + newConditions[j] = bpConditions[i]; + j++; + } + } + bpLines = newLines; + bpFiles = newFiles; + bpConditions = newConditions; + rebuildBreakpointBounds(); + } + updateHasSuspendConditions(); + Log.debug("Breakpoints cleared: " + Config.shortenPath(file)); + } + + /** + * Clear all breakpoints. + */ + public static void clearAllBreakpoints() { + synchronized (breakpointLock) { + bpLines = new int[0]; + bpFiles = new String[0]; + bpConditions = new String[0]; + bpMinLine = Integer.MAX_VALUE; + bpMaxLine = Integer.MIN_VALUE; + bpMaxPathLen = 0; + } + updateHasSuspendConditions(); + Log.debug("Breakpoints cleared: all"); + } + + /** + * Get breakpoint count (for debugging). + */ + public static int getBreakpointCount() { + return bpLines.length; + } + + /** + * Get breakpoint details as array of [file, line] pairs. + * Used by debugBreakpointBindings command. + * @return Array of [serverPath, "line:N"] pairs + */ + public static String[][] getBreakpointDetails() { + int[] lines = bpLines; + String[] files = bpFiles; + String[][] result = new String[lines.length][2]; + for (int i = 0; i < lines.length; i++) { + result[i][0] = files[i]; + result[i][1] = "line:" + lines[i]; + } + return result; + } + + /** + * Check if a thread is natively suspended. + */ + public static boolean isNativelySuspended(long javaThreadId) { + return nativelySuspendedThreads.containsKey(javaThreadId); + } + + /** + * Get all suspended thread IDs. + * Used by NativeLuceeVm to include suspended threads in thread listing. + */ + public static java.util.Set getSuspendedThreadIds() { + return new java.util.HashSet<>(nativelySuspendedThreads.keySet()); + } + + /** + * Get any PageContext from the suspended threads map. + * Used to bootstrap access to CFMLFactory for thread listing. + * @return a PageContext if any thread is suspended, null otherwise + */ + public static PageContext getAnyPageContext() { + for (WeakReference ref : nativelySuspendedThreads.values()) { + PageContext pc = ref.get(); + if (pc != null) { + return pc; + } + } + return null; + } + + /** + * Get PageContext for a specific suspended thread. + * Used by NativeLuceeVm to get stack frames. + * @param javaThreadId The Java thread ID + * @return the PageContext if found and still valid, null otherwise + */ + public static PageContext getPageContext(long javaThreadId) { + WeakReference ref = nativelySuspendedThreads.get(javaThreadId); + if (ref == null) { + Log.warn("getPageContext: thread " + javaThreadId + " not in map! Map=" + nativelySuspendedThreads.keySet()); + return null; + } + PageContext pc = ref.get(); + if (pc == null) { + Log.warn("getPageContext: PageContext for thread " + javaThreadId + " was GC'd!"); + } + return pc; + } + + /** + * Get suspend location for a specific thread. + * Used to create synthetic frame for top-level code. + * @param javaThreadId The Java thread ID + * @return the SuspendLocation if thread is suspended, null otherwise + */ + public static SuspendLocation getSuspendLocation(long javaThreadId) { + return suspendLocations.get(javaThreadId); + } + + /** + * Resume a natively suspended thread by calling debuggerResume() on its PageContext. + * Uses reflection since debuggerResume() is a Lucee7+ method not in the loader interface. + * @return true if the thread was found and resumed, false otherwise + */ + public static boolean resumeNativeThread(long javaThreadId) { + Log.info("resumeNativeThread: thread=" + javaThreadId + ", map=" + nativelySuspendedThreads.keySet()); + WeakReference pcRef = nativelySuspendedThreads.remove(javaThreadId); + if (pcRef == null) { + Log.warn("resumeNativeThread: thread " + javaThreadId + " not in map!"); + return false; + } + PageContext pc = pcRef.get(); + if (pc == null) { + Log.warn("resumeNativeThread: PageContext for thread " + javaThreadId + " was GC'd!"); + return false; + } + Log.info("resumeNativeThread: calling debuggerResume() for thread " + javaThreadId); + try { + // Call debuggerResume() via reflection (Lucee7+ method) + java.lang.reflect.Method resumeMethod = pc.getClass().getMethod("debuggerResume"); + resumeMethod.invoke(pc); + Log.info("resumeNativeThread: debuggerResume() completed for thread " + javaThreadId); + return true; + } catch (NoSuchMethodException e) { + Log.error("debuggerResume() not available (pre-Lucee7?)"); + return false; + } catch (Exception e) { + Log.error("Error calling debuggerResume()", e); + return false; + } + } + + /** + * Resume all natively suspended threads. + */ + public static void resumeAllNativeThreads() { + for (Long threadId : nativelySuspendedThreads.keySet()) { + resumeNativeThread(threadId); + } + } + + // ========== Pause methods ========== + + /** + * Virtual thread ID for "All Threads" - used when no specific thread is targeted. + * Thread ID 0 means "all threads" in DAP, and we also use 1 as an alias for the + * "All CFML Threads" entry shown in the VSCode threads panel. + * Must match ALL_THREADS_VIRTUAL_ID in NativeLuceeVm. + */ + private static final long ALL_THREADS_VIRTUAL_ID = 1; + + /** + * Request a thread to pause at the next CFML line. + * The thread will suspend cooperatively when it hits the next instrumentation point. + * @param threadId The Java thread ID to pause, or 0/1 to pause all threads + */ + public static void requestPause(long threadId) { + if (threadId == 0 || threadId == ALL_THREADS_VIRTUAL_ID) { + // Pause all - we set a flag that shouldSuspend() will check for any thread + threadsToPause.put(0L, Boolean.TRUE); + } else { + threadsToPause.put(threadId, Boolean.TRUE); + } + updateHasSuspendConditions(); + Log.info("Pause requested for thread: " + (threadId == 0 || threadId == ALL_THREADS_VIRTUAL_ID ? "all" : threadId)); + } + + /** + * Check if a thread has a pending pause request. + * Clears the request after checking (pause is consumed). + */ + private static boolean consumePauseRequest(long threadId) { + // Check for specific thread first, then "pause all" + if (threadsToPause.remove(threadId) != null) { + updateHasSuspendConditions(); + return true; + } + if (threadsToPause.remove(0L) != null) { + updateHasSuspendConditions(); + return true; + } + return false; + } + + // ========== Stepping methods ========== + + /** + * Start stepping for a thread. + * @param threadId The Java thread ID + * @param mode The step mode (STEP_INTO, STEP_OVER, STEP_OUT) + * @param currentDepth The current stack depth when stepping started + */ + public static void startStepping(long threadId, StepMode mode, int currentDepth) { + steppingThreads.put(threadId, new StepState(mode, currentDepth)); + updateHasSuspendConditions(); + Log.debug("Start stepping: thread=" + threadId + " mode=" + mode + " depth=" + currentDepth); + } + + /** + * Stop stepping for a thread. + */ + public static void stopStepping(long threadId) { + steppingThreads.remove(threadId); + updateHasSuspendConditions(); + } + + /** + * Get the current stack depth for a PageContext. + * Uses reflection to get debugger frames. + * Only counts frames with line > 0 to match NativeDebugFrame.getNativeFrames() filtering. + */ + public static int getStackDepth(PageContext pc) { + try { + java.lang.reflect.Method getFrames = pc.getClass().getMethod("getDebuggerFrames"); + Object[] frames = (Object[]) getFrames.invoke(pc); + if (frames == null || frames.length == 0) return 0; + + // Cache the getLine method on first use + java.lang.reflect.Method getLine = debuggerFrameGetLineMethod; + if (getLine == null) { + getLine = frames[0].getClass().getMethod("getLine"); + debuggerFrameGetLineMethod = getLine; + } + + // Count only frames with line > 0 (matching getNativeFrames filtering) + // Frames start with line=0 before first ExecutionLog.start() call + int count = 0; + for (Object frame : frames) { + int line = (int) getLine.invoke(frame); + if (line > 0) count++; + } + return count; + } catch (Exception e) { + // Log error - silent failure could cause incorrect step behavior + Log.error("Error getting stack depth: " + e.getMessage()); + return 0; + } + } + + // ========== DebuggerListener interface methods ========== + + /** + * Called by Lucee when a thread is about to suspend. + * This is invoked on the suspending thread's stack, before it blocks. + */ + public static void onSuspend(PageContext pc, String file, int line, String label) { + long threadId = Thread.currentThread().getId(); + Log.info("onSuspend: thread=" + threadId + " file=" + Config.shortenPath(file) + " line=" + line); + + // Check if we were stepping BEFORE clearing state + StepState stepState = steppingThreads.remove(threadId); + boolean wasStepping = (stepState != null); + + // Check if we paused due to user pause request + boolean wasPaused = pausedThreads.remove(threadId) != null; + + // Check if we hit a breakpoint (breakpoint wins over step/pause) + boolean hitBreakpoint = hasBreakpoint(file, line); + + // Check if there's a pending exception for this thread (from onException) + Throwable pendingException = pendingExceptions.remove(threadId); + + // Track the suspended thread so we can resume it later + // We store PageContext (not PageContextImpl) to avoid class loading cycles + nativelySuspendedThreads.put(threadId, new WeakReference<>(pc)); + Log.info("onSuspend: added thread " + threadId + " to map, map=" + nativelySuspendedThreads.keySet()); + + // Store suspend location for stack trace (needed when no native DebuggerFrames exist) + // Include the exception if we're suspending due to one + suspendLocations.put(threadId, new SuspendLocation(file, line, label, pendingException)); + + // Fire appropriate callback - exception takes precedence, then breakpoint, then pause, then step + if (pendingException != null) { + // Stopped due to uncaught exception + Consumer callback = onNativeExceptionCallback; + if (callback != null) { + callback.accept(threadId); + } + } else if (hitBreakpoint) { + // Stopped at breakpoint (no label for line breakpoints) + BiConsumer callback = onNativeSuspendCallback; + if (callback != null) { + callback.accept(threadId, null); + } + } else if (wasPaused) { + // Stopped due to user pause request + Consumer callback = onNativePauseCallback; + if (callback != null) { + callback.accept(threadId); + } + } else if (wasStepping) { + // Stopped due to stepping + Consumer callback = onNativeStepCallback; + if (callback != null) { + callback.accept(threadId); + } + } else { + // Programmatic breakpoint() call or other suspend - pass the label + BiConsumer callback = onNativeSuspendCallback; + if (callback != null) { + callback.accept(threadId, label); + } + } + } + + /** + * Called by Lucee when a thread resumes after suspension. + */ + public static void onResume(PageContext pc) { + long threadId = Thread.currentThread().getId(); + Log.debug("Resume: thread=" + threadId); + + // Remove from suspended threads map and location + nativelySuspendedThreads.remove(threadId); + suspendLocations.remove(threadId); + } + + /** + * Check if a DAP client is connected and ready to handle breakpoints. + * Uses explicit flag set when DAP client connects, not just callback registration. + */ + public static boolean isDapClientConnected() { + return dapClientConnected; + } + + /** + * Set the DAP client connected state. + * Called by DapServer when client connects/disconnects. + */ + public static void setDapClientConnected(boolean connected) { + dapClientConnected = connected; + if (!connected) { + onClientDisconnect(); + } + updateHasSuspendConditions(); + Log.info("DAP client connected: " + connected); + } + + /** + * Clean up state when DAP client disconnects. + * Resumes any suspended threads to prevent deadlocks. + */ + private static void onClientDisconnect() { + // Resume any suspended threads so they're not stuck forever + resumeAllNativeThreads(); + + // Clear stepping state + steppingThreads.clear(); + + // Clear pause state + threadsToPause.clear(); + pausedThreads.clear(); + + // Clear pending exceptions + pendingExceptions.clear(); + + // Reset exception settings + breakOnUncaughtExceptions = false; + consoleOutput = false; + + // Note: We intentionally keep breakpoints - they'll be inactive + // since dapClientConnected=false, and will be replaced on next connect + + Log.info("DAP client disconnected - cleanup complete"); + } + + /** + * Set whether to break on uncaught exceptions. + * Called from DapServer when handling setExceptionBreakpoints request. + */ + public static void setBreakOnUncaughtExceptions(boolean enabled) { + breakOnUncaughtExceptions = enabled; + updateHasSuspendConditions(); + Log.info("Exception breakpoints: " + (enabled ? "uncaught" : "none")); + } + + /** + * Check if we should break on uncaught exceptions. + */ + public static boolean shouldBreakOnUncaughtExceptions() { + return breakOnUncaughtExceptions && dapClientConnected; + } + + /** + * Set whether to forward System.out/err to DAP client. + * Called from DapServer when handling attach request. + */ + public static void setConsoleOutput(boolean enabled) { + consoleOutput = enabled; + Log.setConsoleOutput(enabled); + } + + /** + * Called by Lucee's DebuggerPrintStream when output is written to System.out/err. + * Forwards to DAP client if consoleOutput is enabled. + * + * @param text The text that was written + * @param isStdErr true if stderr, false if stdout + */ + public static void onOutput(String text, boolean isStdErr) { + if (!consoleOutput || !dapClientConnected) { + return; + } + Log.systemOutput(text, isStdErr); + } + + /** + * Called by Lucee when an exception is about to be handled. + * Returns true if we should suspend to let the debugger inspect. + * + * @param pc The PageContext + * @param exception The exception + * @param caught true if caught by try/catch, false if uncaught + * @return true to suspend execution + */ + public static boolean onException(PageContext pc, Throwable exception, boolean caught) { + Log.debug("onException called: caught=" + caught + ", exception=" + exception.getClass().getName() + ", breakOnUncaught=" + breakOnUncaughtExceptions + ", dapConnected=" + dapClientConnected); + + // Log exception to debug console if enabled (both caught and uncaught) + Log.exception(exception); + + // Only handle uncaught exceptions for now + if (caught) { + return false; + } + boolean shouldSuspend = shouldBreakOnUncaughtExceptions(); + if (shouldSuspend) { + // Store exception for this thread - will be consumed in onSuspend + long threadId = Thread.currentThread().getId(); + pendingExceptions.put(threadId, exception); + } + Log.debug("onException returning: " + shouldSuspend); + return shouldSuspend; + } + + /** + * Called by Lucee's DebuggerExecutionLog.start() on every line. + * Checks breakpoints AND stepping state. + * Must be fast - this is on the hot path. + */ + public static boolean shouldSuspend(PageContext pc, String file, int line) { + // Fast path - nothing could possibly cause a suspend + if (!hasSuspendConditions) { + return false; + } + + // Check breakpoints with fast bounds rejection + // Grab local refs - volatile hasSuspendConditions provides memory barrier + int[] lines = bpLines; + if (lines.length > 0) { + // Bounds check - reject 99% of lines instantly + if (line >= bpMinLine && line <= bpMaxLine) { + // Line is in range - check for matching line numbers first + // Only canonicalize file path if we find a line match (rare) + String[] files = bpFiles; + String[] conditions = bpConditions; + String canonFile = null; // lazy init + for (int i = 0; i < lines.length; i++) { + if (lines[i] == line) { + // Line matches - now check file (canonicalize once) + if (canonFile == null) { + // Length check before expensive canonicalization + if (file.length() > bpMaxPathLen) { + break; // file too long, can't match any breakpoint + } + canonFile = Config.canonicalizeFileName(file); + } + if (files[i].equals(canonFile)) { + // Hit! Check condition if present + String condition = conditions[i]; + if (condition != null) { + return evaluateCondition(pc, condition); + } + return true; + } + } + } + } + } + + long threadId = Thread.currentThread().getId(); + + // Check for pause request (user clicked pause button) + if (consumePauseRequest(threadId)) { + pausedThreads.put(threadId, Boolean.TRUE); + return true; + } + + // Check stepping state + StepState stepState = steppingThreads.get(threadId); + if (stepState == null) { + return false; + } + + int currentDepth = getStackDepth(pc); + + switch (stepState.mode) { + case STEP_INTO: + // Always stop on next line + return true; + + case STEP_OVER: + // Stop when at same or shallower depth + return currentDepth <= stepState.startDepth; + + case STEP_OUT: + // Stop when shallower than start depth + return currentDepth < stepState.startDepth; + + default: + return false; + } + } + + /** + * Evaluate a CFML condition expression and return its boolean result. + * Returns false if evaluation fails (exception, timeout, etc.). + * Uses reflection to call Lucee's Evaluate function to avoid classloader issues. + */ + private static boolean evaluateCondition(PageContext pc, String condition) { + try { + // Use reflection to call Evaluate.call() through Lucee's classloader + ClassLoader luceeLoader = pc.getClass().getClassLoader(); + Class evaluateClass = luceeLoader.loadClass("lucee.runtime.functions.dynamicEvaluation.Evaluate"); + java.lang.reflect.Method callMethod = evaluateClass.getMethod("call", PageContext.class, Object[].class); + Object result = callMethod.invoke(null, pc, new Object[]{ condition }); + + // Cast result to boolean using Lucee's Caster + Class casterClass = luceeLoader.loadClass("lucee.runtime.op.Caster"); + java.lang.reflect.Method toBooleanMethod = casterClass.getMethod("toBoolean", Object.class); + return (Boolean) toBooleanMethod.invoke(null, result); + } catch (Exception e) { + // Condition evaluation failed - don't suspend + Log.error("Condition evaluation failed: " + e.getMessage()); + return false; + } + } + + /** + * Check if a breakpoint exists at the given file and line. + */ + public static boolean hasBreakpoint(String file, int line) { + String canonFile = Config.canonicalizeFileName(file); + int[] lines = bpLines; + String[] files = bpFiles; + for (int i = 0; i < lines.length; i++) { + if (lines[i] == line && files[i].equals(canonFile)) { + return true; + } + } + return false; + } + + /** + * Get executable line numbers for a file. + * Triggers compilation if the file hasn't been compiled yet. + * Uses caching based on compileTime to avoid repeated bitmap decoding. + * + * @param absolutePath The absolute file path + * @return Array of line numbers where breakpoints can be set, or empty array if file has errors + */ + public static int[] getExecutableLines( String absolutePath ) { + // Try suspended threads first, then any active PageContext, then create temp + PageContext pc = getAnyPageContext(); + if ( pc == null ) { + pc = getAnyActivePageContext(); + } + if ( pc == null ) { + pc = createTemporaryPageContext(); + } + if ( pc == null ) { + Log.debug( "getExecutableLines: no PageContext available" ); + return new int[0]; + } + + try { + // Get the webroot from the PageContext's servlet context + Object servletContext = pc.getClass().getMethod( "getServletContext" ).invoke( pc ); + String webroot = (String) servletContext.getClass().getMethod( "getRealPath", String.class ).invoke( servletContext, "/" ); + + // Convert absolute path to relative path by stripping webroot prefix + String normalizedAbsPath = absolutePath.replace( '\\', '/' ).toLowerCase(); + String normalizedWebroot = webroot.replace( '\\', '/' ).toLowerCase(); + if ( !normalizedWebroot.endsWith( "/" ) ) normalizedWebroot += "/"; + + String relativePath; + if ( normalizedAbsPath.startsWith( normalizedWebroot ) ) { + relativePath = "/" + absolutePath.substring( webroot.length() ).replace( '\\', '/' ); + // Handle case where webroot didn't have trailing slash + if ( relativePath.startsWith( "//" ) ) relativePath = relativePath.substring( 1 ); + } + else { + // File is outside webroot - can't load it via PageSource + Log.debug( "getExecutableLines: file outside webroot: " + absolutePath ); + return new int[0]; + } + + // Use reflection for PageContextImpl.getPageSource() - core class not visible to OSGi bundle + java.lang.reflect.Method getPageSourceMethod = pc.getClass().getMethod( "getPageSource", String.class ); + Object ps = getPageSourceMethod.invoke( pc, relativePath ); + if ( ps == null ) { + Log.debug( "getExecutableLines: no PageSource for " + absolutePath ); + return new int[0]; + } + + // Load/compile the page via PageSource.loadPage(PageContext, boolean) + java.lang.reflect.Method loadPageMethod = ps.getClass().getMethod( "loadPage", PageContext.class, boolean.class ); + Object page = loadPageMethod.invoke( ps, pc, false ); + if ( page == null ) { + Log.debug( "getExecutableLines: failed to load page " + absolutePath ); + return new int[0]; + } + + // Get executable lines from compiled Page class - returns Object[] {compileTime, lines} + java.lang.reflect.Method getExecLinesMethod = page.getClass().getMethod( "getExecutableLines" ); + Object result = getExecLinesMethod.invoke( page ); + + // Handle old Lucee versions that return int[] directly + if ( result instanceof int[] ) { + return (int[]) result; + } + + // New format: Object[] {compileTime (Long), lines (int[] or null)} + if ( result instanceof Object[] ) { + Object[] arr = (Object[]) result; + long compileTime = ( (Long) arr[0] ).longValue(); + int[] lines = (int[]) arr[1]; + + // Check cache + CachedExecutableLines cached = executableLinesCache.get( absolutePath ); + if ( cached != null && cached.compileTime == compileTime ) { + Log.debug( "getExecutableLines: cache hit for " + absolutePath ); + return cached.lines; + } + + // Cache miss or stale - update cache + int[] resultLines = lines != null ? lines : new int[0]; + executableLinesCache.put( absolutePath, new CachedExecutableLines( compileTime, resultLines ) ); + Log.debug( "getExecutableLines: cached " + resultLines.length + " lines for " + absolutePath ); + return resultLines; + } + + // Unexpected return type + Log.debug( "getExecutableLines: unexpected return type: " + ( result != null ? result.getClass().getName() : "null" ) ); + return new int[0]; + } + catch ( NoSuchMethodException e ) { + // Method only exists in debug-compiled classes - this shouldn't happen in debugger mode + Log.error( "getExecutableLines: compiled class for [" + absolutePath + "] is missing debug info (should be auto-generated in debugger mode)" ); + return new int[0]; + } + catch ( java.lang.reflect.InvocationTargetException e ) { + // Unwrap the real exception from reflection + Throwable cause = e.getCause(); + Log.debug( "getExecutableLines failed for " + absolutePath + ": " + + ( cause != null ? cause.getClass().getName() + ": " + cause.getMessage() : e.getMessage() ) ); + return new int[0]; + } + catch ( Exception e ) { + Log.debug( "getExecutableLines failed for " + absolutePath + ": " + e.getClass().getName() + ": " + e.getMessage() ); + return new int[0]; + } + } + + /** + * Get any active PageContext from running requests. + * Used when no thread is suspended but we need a PageContext for compilation. + */ + private static PageContext getAnyActivePageContext() { + try { + Object engine = lucee.loader.engine.CFMLEngineFactory.getInstance(); + java.lang.reflect.Method getEngineMethod = engine.getClass().getMethod("getEngine"); + Object engineImpl = getEngineMethod.invoke(engine); + + java.lang.reflect.Method getFactoriesMethod = engineImpl.getClass().getMethod("getCFMLFactories"); + @SuppressWarnings("unchecked") + java.util.Map factoriesMap = (java.util.Map) getFactoriesMethod.invoke(engineImpl); + + for (Object factory : factoriesMap.values()) { + try { + java.lang.reflect.Method getActiveMethod = factory.getClass().getMethod("getActivePageContexts"); + @SuppressWarnings("unchecked") + java.util.Map activeContexts = (java.util.Map) getActiveMethod.invoke(factory); + + for (Object pc : activeContexts.values()) { + if (pc instanceof PageContext) { + return (PageContext) pc; + } + } + } catch (Exception e) { + // Skip this factory + } + } + } catch (Exception e) { + Log.debug("getAnyActivePageContext failed: " + e.getMessage()); + } + return null; + } + + /** + * Create a temporary PageContext for compilation when no active request exists. + * Uses CFMLEngineFactory.getInstance().createPageContext() like LSPUtil does. + */ + private static PageContext createTemporaryPageContext() { + try { + Object engine = lucee.loader.engine.CFMLEngineFactory.getInstance(); + + // Find and call createPageContext(File, String, String, String, Cookie[], Map, Map, Map, OutputStream, long, boolean) + for (java.lang.reflect.Method m : engine.getClass().getMethods()) { + if (m.getName().equals("createPageContext") && m.getParameterCount() == 11) { + Class[] params = m.getParameterTypes(); + if (params[0].getName().equals("java.io.File")) { + java.io.File contextRoot = new java.io.File("."); + java.io.OutputStream devNull = new java.io.ByteArrayOutputStream(); + Object pc = m.invoke(engine, contextRoot, "localhost", "/", "", null, null, null, null, devNull, -1L, false); + if (pc instanceof PageContext) { + return (PageContext) pc; + } + } + } + } + } catch (Exception e) { + Log.debug("createTemporaryPageContext failed: " + e.getMessage()); + } + return null; + } + + // ========== Function Breakpoints ========== + + /** + * Check if we should suspend on function entry. + * Called from Lucee's pushDebuggerFrame via DebuggerListener.onFunctionEntry(). + * Must be blazing fast - every UDF call hits this. + */ + public static boolean onFunctionEntry( PageContext pc, String functionName, + String componentName, String file, int startLine ) { + // Fast path - no function breakpoints + if ( !hasFuncBps || !dapClientConnected ) { + return false; + } + + int len = functionName.length(); + + // Length bounds check - rejects 99% of calls instantly + if ( len < funcBpMinLen || len > funcBpMaxLen ) { + return false; + } + + // Normalize for case-insensitive matching + String lowerFunc = functionName.toLowerCase(); + String lowerComp = componentName != null ? componentName.toLowerCase() : null; + + // Check each breakpoint + String[] names = funcBpNames; + String[] comps = funcBpComponents; + String[] conds = funcBpConditions; + boolean[] wilds = funcBpIsWildcard; + + for ( int i = 0; i < names.length; i++ ) { + // Check component qualifier first (if specified) + if ( comps[i] != null && ( lowerComp == null || !lowerComp.equals( comps[i] ) ) ) { + continue; + } + + // Check function name match + boolean match; + if ( wilds[i] ) { + // Wildcard: "on*" matches "onRequestStart" + String prefix = names[i].substring( 0, names[i].length() - 1 ); + match = lowerFunc.startsWith( prefix ); + } + else { + match = lowerFunc.equals( names[i] ); + } + + if ( match ) { + // Check condition if present + if ( conds[i] != null ) { + if ( !evaluateCondition( pc, conds[i] ) ) { + continue; + } + } + Log.info( "Function breakpoint hit: " + functionName + + ( componentName != null ? " in " + componentName : "" ) ); + return true; + } + } + + return false; + } + + /** + * Set function breakpoints (replaces all existing). + * Called from DapServer.setFunctionBreakpoints(). + */ + public static void setFunctionBreakpoints( String[] names, String[] conditions ) { + synchronized ( funcBpLock ) { + int count = names.length; + String[] newNames = new String[count]; + String[] newComps = new String[count]; + String[] newConds = new String[count]; + boolean[] newWilds = new boolean[count]; + + int minLen = Integer.MAX_VALUE; + int maxLen = Integer.MIN_VALUE; + + for ( int i = 0; i < count; i++ ) { + String name = names[i].trim(); + String condition = ( conditions != null && i < conditions.length && conditions[i] != null && !conditions[i].isEmpty() ) + ? conditions[i] : null; + + // Parse qualified name: "Component.method" or just "method" + int dot = name.lastIndexOf( '.' ); + String compName = null; + String funcName = name; + if ( dot > 0 ) { + compName = name.substring( 0, dot ).toLowerCase(); + funcName = name.substring( dot + 1 ); + } + + // Check for wildcard + boolean isWild = funcName.endsWith( "*" ); + + // Store lowercase for case-insensitive matching + newNames[i] = funcName.toLowerCase(); + newComps[i] = compName; + newConds[i] = condition; + newWilds[i] = isWild; + + // Update bounds (for wildcards, use prefix length) + int effectiveLen = isWild ? funcName.length() - 1 : funcName.length(); + if ( !isWild ) { + // Exact match: bounds are exact length + if ( effectiveLen < minLen ) minLen = effectiveLen; + if ( effectiveLen > maxLen ) maxLen = effectiveLen; + } + else { + // Wildcard: any length >= prefix is possible + if ( effectiveLen < minLen ) minLen = effectiveLen; + maxLen = Integer.MAX_VALUE; // can't bound max for wildcards + } + + Log.info( "Function breakpoint: " + name + + ( compName != null ? " (component: " + compName + ")" : "" ) + + ( isWild ? " (wildcard)" : "" ) + + ( condition != null ? " condition: " + condition : "" ) ); + } + + funcBpNames = newNames; + funcBpComponents = newComps; + funcBpConditions = newConds; + funcBpIsWildcard = newWilds; + funcBpMinLen = count > 0 ? minLen : Integer.MAX_VALUE; + funcBpMaxLen = count > 0 ? maxLen : Integer.MIN_VALUE; + } + hasFuncBps = funcBpNames.length > 0; + updateHasSuspendConditions(); + Log.info( "Function breakpoints set: " + funcBpNames.length ); + } + + /** + * Clear all function breakpoints. + */ + public static void clearFunctionBreakpoints() { + synchronized ( funcBpLock ) { + funcBpNames = new String[0]; + funcBpComponents = new String[0]; + funcBpConditions = new String[0]; + funcBpIsWildcard = new boolean[0]; + funcBpMinLen = Integer.MAX_VALUE; + funcBpMaxLen = Integer.MIN_VALUE; + } + hasFuncBps = false; + updateHasSuspendConditions(); + Log.debug( "Function breakpoints cleared" ); + } +} diff --git a/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java new file mode 100644 index 0000000..7a6001a --- /dev/null +++ b/luceedebug/src/main/java/luceedebug/coreinject/NativeLuceeVm.java @@ -0,0 +1,988 @@ +package luceedebug.coreinject; + +import java.lang.ref.Cleaner; +import java.util.ArrayList; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import lucee.runtime.PageContext; + +import luceedebug.*; +import luceedebug.coreinject.frame.NativeDebugFrame; +import luceedebug.strong.DapBreakpointID; +import luceedebug.strong.CanonicalServerAbsPath; +import luceedebug.strong.RawIdePath; + +/** + * Native implementation of ILuceeVm that uses only Lucee7+ native debugging APIs. + * No JDWP connection, no bytecode instrumentation, no agent required. + * + * This is for extension-only deployment where luceedebug runs as a Lucee extension + * rather than a Java agent. + */ +public class NativeLuceeVm implements ILuceeVm { + + private final Config config_; + private static ClassLoader luceeClassLoader; + private static final Cleaner cleaner = Cleaner.create(); + private final ValTracker valTracker = new ValTracker(cleaner); + + private Consumer stepEventCallback = null; + private BiConsumer breakpointEventCallback = null; + private BiConsumer nativeBreakpointEventCallback = null; + private Consumer exceptionEventCallback = null; + private Consumer pauseEventCallback = null; + private Consumer breakpointsChangedCallback = null; + + private AtomicInteger breakpointID = new AtomicInteger(); + + // Cache of frame ID -> frame for scope/variable lookups + private final ConcurrentHashMap frameCache = new ConcurrentHashMap<>(); + + /** + * Set the Lucee classloader for reflection access to Lucee core classes. + * Must be called before creating NativeLuceeVm in extension mode. + */ + public static void setLuceeClassLoader(ClassLoader cl) { + luceeClassLoader = cl; + } + + public NativeLuceeVm(Config config) { + this.config_ = config; + + // Enable native mode + NativeDebuggerListener.setNativeMode(true); + + // Register native breakpoint suspend callback + NativeDebuggerListener.setOnNativeSuspendCallback((javaThreadId, label) -> { + if (nativeBreakpointEventCallback != null) { + nativeBreakpointEventCallback.accept(javaThreadId, label); + } + }); + + // Register native step callback + NativeDebuggerListener.setOnNativeStepCallback(javaThreadId -> { + if (stepEventCallback != null) { + stepEventCallback.accept(javaThreadId); + } + }); + + // Register native exception callback + NativeDebuggerListener.setOnNativeExceptionCallback(javaThreadId -> { + if (exceptionEventCallback != null) { + exceptionEventCallback.accept(javaThreadId); + } + }); + + // Register native pause callback + NativeDebuggerListener.setOnNativePauseCallback(javaThreadId -> { + if (pauseEventCallback != null) { + pauseEventCallback.accept(javaThreadId); + } + }); + } + + private DapBreakpointID nextDapBreakpointID() { + return new DapBreakpointID(breakpointID.incrementAndGet()); + } + + // ========== Callback registration ========== + + @Override + public void registerStepEventCallback(Consumer cb) { + stepEventCallback = cb; + } + + @Override + public void registerBreakpointEventCallback(BiConsumer cb) { + // Not used in native-only mode - native breakpoints don't have JDWP breakpoint IDs + breakpointEventCallback = cb; + } + + @Override + public void registerNativeBreakpointEventCallback(BiConsumer cb) { + nativeBreakpointEventCallback = cb; + } + + @Override + public void registerBreakpointsChangedCallback(Consumer cb) { + breakpointsChangedCallback = cb; + } + + // ========== Thread operations ========== + + // Virtual thread ID for "All Threads" - used when no specific thread is targeted + // Thread ID 0 means "all threads" in DAP, but VSCode needs a visible thread to send pause + // We use 1 as a safe ID that won't conflict with real Java thread IDs (which start much higher) + private static final long ALL_THREADS_VIRTUAL_ID = 1; + + @Override + public ThreadInfo[] getThreadListing() { + var result = new ArrayList(); + var seenThreadIds = new java.util.HashSet(); + + // First, add any suspended threads (these are most important for debugging) + for (Long threadId : NativeDebuggerListener.getSuspendedThreadIds()) { + Thread thread = findThreadById(threadId); + if (thread != null) { + result.add(new ThreadInfo(thread.getId(), thread.getName() + " (suspended)")); + seenThreadIds.add(threadId); + } + } + + try { + // Get CFMLEngine via the loader's factory (available to extension classloader) + Object engine = lucee.loader.engine.CFMLEngineFactory.getInstance(); + + // The factory returns CFMLEngineWrapper - unwrap to get CFMLEngineImpl + java.lang.reflect.Method getEngineMethod = engine.getClass().getMethod("getEngine"); + Object engineImpl = getEngineMethod.invoke(engine); + + // Get all CFMLFactory instances from the engine impl + // CFMLEngineImpl has getCFMLFactories() returning Map + java.lang.reflect.Method getFactoriesMethod = engineImpl.getClass().getMethod("getCFMLFactories"); + @SuppressWarnings("unchecked") + java.util.Map factoriesMap = (java.util.Map) getFactoriesMethod.invoke(engineImpl); + Object[] factories = factoriesMap.values().toArray(); + + for (Object factory : factories) { + try { + // Call getActivePageContexts() - it's in CFMLFactoryImpl + java.lang.reflect.Method getActiveMethod = factory.getClass().getMethod("getActivePageContexts"); + @SuppressWarnings("unchecked") + java.util.Map activeContexts = (java.util.Map) getActiveMethod.invoke(factory); + + // Each PageContext has a getThread() method + for (Object pc : activeContexts.values()) { + try { + java.lang.reflect.Method getThreadMethod = pc.getClass().getMethod("getThread"); + Thread thread = (Thread) getThreadMethod.invoke(pc); + if (thread != null && !seenThreadIds.contains(thread.getId())) { + result.add(new ThreadInfo(thread.getId(), thread.getName())); + seenThreadIds.add(thread.getId()); + } + } catch (Exception e) { + // Skip this context if we can't get its thread + } + } + } catch (Exception e) { + // Skip this factory + } + } + } catch (Exception e) { + Log.error("Error getting thread listing", e); + } + + // Always show a virtual "All Threads" entry so VSCode has something to target with pause + // This allows pause to work even when no specific request thread is visible + // When paused with this ID, all CFML threads will pause at their next instrumentation point + if (!seenThreadIds.contains(ALL_THREADS_VIRTUAL_ID)) { + result.add(0, new ThreadInfo(ALL_THREADS_VIRTUAL_ID, "All CFML Threads")); + } + + Log.debug("Thread listing: " + result.size() + " threads"); + return result.toArray(new ThreadInfo[0]); + } + + @Override + public IDebugFrame[] getStackTrace(long threadID) { + // In native mode, get frames from the suspended thread's PageContext + PageContext pc = NativeDebuggerListener.getPageContext(threadID); + if (pc == null) { + Log.debug("getStackTrace: no PageContext for thread " + threadID); + return new IDebugFrame[0]; + } + + // Use NativeDebugFrame to get the CFML stack from PageContext + // Pass threadID so it can create synthetic frame for top-level code + IDebugFrame[] frames = NativeDebugFrame.getNativeFrames(pc, valTracker, threadID, luceeClassLoader); + if (frames == null) { + Log.debug("getStackTrace: no native frames for thread " + threadID); + return new IDebugFrame[0]; + } + + // Cache frames for later scope/variable lookups + for (IDebugFrame frame : frames) { + frameCache.put(frame.getId(), frame); + } + + Log.trace("getStackTrace: returning " + frames.length + " frames for thread " + threadID); + return frames; + } + + private Thread findThreadById(long threadId) { + for (Thread t : Thread.getAllStackTraces().keySet()) { + if (t.getId() == threadId) { + return t; + } + } + return null; + } + + // ========== Variable operations ========== + + @Override + public IDebugEntity[] getScopes(long frameID) { + // Look up frame from cache + IDebugFrame frame = frameCache.get(frameID); + if (frame == null) { + Log.debug("getScopes: frame " + frameID + " not found in cache"); + return new IDebugEntity[0]; + } + return frame.getScopes(); + } + + @Override + public IDebugEntity[] getVariables(long ID) { + return getVariablesImpl(ID, null); + } + + @Override + public IDebugEntity[] getNamedVariables(long ID) { + return getVariablesImpl(ID, IDebugEntity.DebugEntityType.NAMED); + } + + @Override + public IDebugEntity[] getIndexedVariables(long ID) { + return getVariablesImpl(ID, IDebugEntity.DebugEntityType.INDEXED); + } + + private IDebugEntity[] getVariablesImpl(long variablesReference, IDebugEntity.DebugEntityType which) { + // Look up the object by its variablesReference ID + var maybeObj = valTracker.maybeGetFromId(variablesReference); + if (maybeObj.isEmpty()) { + Log.debug("getVariables: variablesReference " + variablesReference + " not found"); + return new IDebugEntity[0]; + } + Object obj = maybeObj.get().obj; + // Get the parent's path and frameId for setVariable support + String parentPath = valTracker.getPath(variablesReference); + Long frameId = valTracker.getFrameId(variablesReference); + return CfValueDebuggerBridge.getAsDebugEntity(valTracker, obj, which, parentPath, frameId); + } + + // ========== Breakpoint operations ========== + + @Override + public IBreakpoint[] bindBreakpoints(RawIdePath idePath, CanonicalServerAbsPath serverPath, int[] lines, String[] exprs) { + // Clear existing native breakpoints for this file + NativeDebuggerListener.clearBreakpointsForFile(serverPath.get()); + + // Get executable lines to validate breakpoints + int[] executableLines = getExecutableLines(serverPath.get()); + java.util.Set validLines = new java.util.HashSet<>(); + for (int line : executableLines) { + validLines.add(line); + } + + // Add native breakpoints with optional conditions + IBreakpoint[] result = new Breakpoint[lines.length]; + for (int i = 0; i < lines.length; i++) { + String condition = (exprs != null && i < exprs.length) ? exprs[i] : null; + int requestedLine = lines[i]; + + if (validLines.contains(requestedLine)) { + // Valid executable line - add breakpoint and mark as bound + NativeDebuggerListener.addBreakpoint(serverPath.get(), requestedLine, condition); + result[i] = Breakpoint.Bound(requestedLine, nextDapBreakpointID()); + } else { + // Not an executable line - mark as unbound (unverified) + result[i] = Breakpoint.Unbound(requestedLine, nextDapBreakpointID()); + } + } + + return result; + } + + @Override + public void clearAllBreakpoints() { + NativeDebuggerListener.clearAllBreakpoints(); + } + + // ========== Execution control ========== + + @Override + public void continue_(long threadID) { + NativeDebuggerListener.resumeNativeThread(threadID); + } + + @Override + public void continueAll() { + NativeDebuggerListener.resumeAllNativeThreads(); + } + + @Override + public void stepIn(long threadID) { + int currentDepth = getStackDepthForThread(threadID); + NativeDebuggerListener.startStepping(threadID, StepMode.STEP_INTO, currentDepth); + continue_(threadID); + } + + @Override + public void stepOver(long threadID) { + int currentDepth = getStackDepthForThread(threadID); + NativeDebuggerListener.startStepping(threadID, StepMode.STEP_OVER, currentDepth); + continue_(threadID); + } + + @Override + public void stepOut(long threadID) { + int currentDepth = getStackDepthForThread(threadID); + NativeDebuggerListener.startStepping(threadID, StepMode.STEP_OUT, currentDepth); + continue_(threadID); + } + + /** + * Get the current stack depth for a thread using native debugger frames. + * Uses NativeDebuggerListener.getStackDepth() to count only real frames (not synthetic). + */ + private int getStackDepthForThread(long threadID) { + PageContext pc = NativeDebuggerListener.getPageContext(threadID); + return pc != null ? NativeDebuggerListener.getStackDepth(pc) : 0; + } + + // ========== Debug utilities ========== + + @Override + public String dump(int dapVariablesReference) { + return doDumpNative(dapVariablesReference, false); + } + + @Override + public String dumpAsJSON(int dapVariablesReference) { + return doDumpNative(dapVariablesReference, true); + } + + @Override + public String getMetadata(int dapVariablesReference) { + // Get the object from valTracker + var maybeObj = valTracker.maybeGetFromId(dapVariablesReference); + if (maybeObj.isEmpty()) { + return "\"Variable not found\""; + } + Object obj = maybeObj.get().obj; + + // Unwrap MarkerTrait.Scope if needed + if (obj instanceof CfValueDebuggerBridge.MarkerTrait.Scope) { + obj = ((CfValueDebuggerBridge.MarkerTrait.Scope) obj).scopelike; + } + + // Get PageContext from a cached frame + PageContext pc = null; + Long frameId = valTracker.getFrameId(dapVariablesReference); + if (frameId != null) { + IDebugFrame frame = frameCache.get(frameId); + if (frame instanceof NativeDebugFrame) { + pc = ((NativeDebugFrame) frame).getPageContext(); + } + } + + // Fallback: try any suspended frame's PageContext + if (pc == null) { + for (IDebugFrame frame : frameCache.values()) { + if (frame instanceof NativeDebugFrame) { + pc = ((NativeDebugFrame) frame).getPageContext(); + if (pc != null) break; + } + } + } + + if (pc == null) { + return "\"No PageContext available\""; + } + + return doGetMetadataWithPageContext(pc, obj); + } + + /** + * Execute getMetadata on a separate thread (required for PageContext registration). + */ + private String doGetMetadataWithPageContext(PageContext sourcePC, Object target) { + final var result = new Object() { + String value = "\"getMetadata failed\""; + }; + + final PageContext pc = sourcePC; + final Object obj = target; + + Thread thread = new Thread(() -> { + try { + ClassLoader cl = luceeClassLoader != null ? luceeClassLoader : pc.getClass().getClassLoader(); + + // Register the existing PageContext with ThreadLocal + Class tlpcClass = cl.loadClass("lucee.runtime.engine.ThreadLocalPageContext"); + java.lang.reflect.Method registerMethod = tlpcClass.getMethod("register", PageContext.class); + java.lang.reflect.Method releaseMethod = tlpcClass.getMethod("release"); + registerMethod.invoke(null, pc); + + try { + // Call GetMetaData.call(PageContext, Object) + Class getMetaDataClass = cl.loadClass("lucee.runtime.functions.system.GetMetaData"); + java.lang.reflect.Method callMethod = getMetaDataClass.getMethod("call", + PageContext.class, Object.class); + Object metadata = callMethod.invoke(null, pc, obj); + + // Serialize the metadata to JSON + Class serializeClass = cl.loadClass("lucee.runtime.functions.conversion.SerializeJSON"); + java.lang.reflect.Method serializeMethod = serializeClass.getMethod("call", + PageContext.class, Object.class, Object.class); + result.value = (String) serializeMethod.invoke(null, pc, metadata, "struct"); + } finally { + releaseMethod.invoke(null); + } + } catch (Throwable e) { + Log.debug("getMetadata failed: " + e.getMessage()); + result.value = "\"Error: " + e.getMessage().replace("\"", "\\\"") + "\""; + } + }); + + thread.start(); + try { + thread.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + return result.value; + } + + /** + * Native mode dump implementation using reflection to call Lucee functions. + * @param dapVariablesReference The variablesReference from DAP + * @param asJson If true, returns JSON; if false, returns HTML dump + */ + private String doDumpNative(int dapVariablesReference, boolean asJson) { + // Get the object from valTracker + var maybeObj = valTracker.maybeGetFromId(dapVariablesReference); + if (maybeObj.isEmpty()) { + return asJson ? "\"Variable not found\"" : "
Variable not found
"; + } + Object obj = maybeObj.get().obj; + + // Unwrap MarkerTrait.Scope if needed + if (obj instanceof CfValueDebuggerBridge.MarkerTrait.Scope) { + obj = ((CfValueDebuggerBridge.MarkerTrait.Scope) obj).scopelike; + } + + // Get the frameId for this variablesReference to get its PageContext + Long frameId = valTracker.getFrameId(dapVariablesReference); + PageContext pc = null; + if (frameId != null) { + IDebugFrame frame = frameCache.get(frameId); + if (frame instanceof NativeDebugFrame) { + pc = ((NativeDebugFrame) frame).getPageContext(); + } + } + + // If no PageContext from frame, try to find any suspended frame's PageContext + if (pc == null) { + for (IDebugFrame frame : frameCache.values()) { + if (frame instanceof NativeDebugFrame) { + pc = ((NativeDebugFrame) frame).getPageContext(); + if (pc != null) break; + } + } + } + + if (pc == null) { + return asJson ? "\"No PageContext available\"" : "
No PageContext available
"; + } + + return doDumpWithPageContext(pc, obj, asJson); + } + + /** + * Execute dump on a separate thread (required for PageContext registration). + */ + private String doDumpWithPageContext(PageContext sourcePC, Object someDumpable, boolean asJson) { + final var result = new Object() { + String value = asJson ? "\"dump failed\"" : "
dump failed
"; + }; + + final PageContext pc = sourcePC; + final Object dumpable = someDumpable; + + Thread thread = new Thread(() -> { + try { + ClassLoader cl = luceeClassLoader != null ? luceeClassLoader : pc.getClass().getClassLoader(); + + // Register the existing PageContext with ThreadLocal + Class tlpcClass = cl.loadClass("lucee.runtime.engine.ThreadLocalPageContext"); + java.lang.reflect.Method registerMethod = tlpcClass.getMethod("register", PageContext.class); + java.lang.reflect.Method releaseMethod = tlpcClass.getMethod("release"); + registerMethod.invoke(null, pc); + + try { + if (asJson) { + // Call SerializeJSON + Class serializeClass = cl.loadClass("lucee.runtime.functions.conversion.SerializeJSON"); + java.lang.reflect.Method callMethod = serializeClass.getMethod("call", + PageContext.class, Object.class, Object.class); + result.value = (String) callMethod.invoke(null, pc, dumpable, "struct"); + } else { + // Use DumpUtil to get DumpData, then HTMLDumpWriter to render + result.value = wrapDumpInHtmlDoc(dumpObjectAsHtml(pc, cl, dumpable)); + } + } finally { + releaseMethod.invoke(null); + } + } catch (Throwable e) { + Log.debug("dump failed: " + e.getMessage()); + result.value = asJson + ? "\"Error: " + e.getMessage().replace("\"", "\\\"") + "\"" + : "
Error: " + e.getMessage() + "
"; + } + }); + + thread.start(); + try { + thread.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + return result.value; + } + + /** + * Dump an object to HTML string using Lucee's HTMLDumpWriter. + */ + private String dumpObjectAsHtml(PageContext pc, ClassLoader cl, Object obj) throws Exception { + // Use DumpUtil to get DumpData, then HTMLDumpWriter to render + Class dumpUtilClass = cl.loadClass("lucee.runtime.dump.DumpUtil"); + Class dumpPropertiesClass = cl.loadClass("lucee.runtime.dump.DumpProperties"); + Class dumpDataClass = cl.loadClass("lucee.runtime.dump.DumpData"); + + // Get default dump properties - use DEFAULT_RICH field + java.lang.reflect.Field defaultField = dumpPropertiesClass.getField("DEFAULT_RICH"); + Object dumpProps = defaultField.get(null); + + // toDumpData(PageContext, Object, int maxlevel, DumpProperties) + java.lang.reflect.Method toDumpDataMethod = dumpUtilClass.getMethod("toDumpData", + PageContext.class, Object.class, int.class, dumpPropertiesClass); + Object dumpData = toDumpDataMethod.invoke(null, pc, obj, 9999, dumpProps); + + // Create HTMLDumpWriter and render + Class htmlDumpWriterClass = cl.loadClass("lucee.runtime.dump.HTMLDumpWriter"); + Object htmlWriter = htmlDumpWriterClass.getConstructor().newInstance(); + + // DumpWriter.toString(PageContext, DumpData) + java.lang.reflect.Method toStringMethod = htmlDumpWriterClass.getMethod("toString", + PageContext.class, dumpDataClass); + return (String) toStringMethod.invoke(htmlWriter, pc, dumpData); + } + + private static String wrapDumpInHtmlDoc(String dumpHtml) { + return "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + dumpHtml + + "\n" + + "\n"; + } + + @Override + public String[] getTrackedCanonicalFileNames() { + // No class tracking in native mode + return new String[0]; + } + + @Override + public String[][] getBreakpointDetail() { + return NativeDebuggerListener.getBreakpointDetails(); + } + + @Override + public String getApplicationSettings() { + // Get PageContext from any suspended frame + PageContext pc = null; + for (IDebugFrame frame : frameCache.values()) { + if (frame instanceof NativeDebugFrame) { + pc = ((NativeDebugFrame) frame).getPageContext(); + if (pc != null) break; + } + } + + if (pc == null) { + return "\"No PageContext available\""; + } + + return doGetApplicationSettingsWithPageContext(pc); + } + + /** + * Execute getApplicationSettings on a separate thread (required for PageContext registration). + */ + private String doGetApplicationSettingsWithPageContext(PageContext sourcePC) { + final var result = new Object() { + String value = "\"getApplicationSettings failed\""; + }; + + final PageContext pc = sourcePC; + + Thread thread = new Thread(() -> { + try { + ClassLoader cl = luceeClassLoader != null ? luceeClassLoader : pc.getClass().getClassLoader(); + + // Register the existing PageContext with ThreadLocal + Class tlpcClass = cl.loadClass("lucee.runtime.engine.ThreadLocalPageContext"); + java.lang.reflect.Method registerMethod = tlpcClass.getMethod("register", PageContext.class); + java.lang.reflect.Method releaseMethod = tlpcClass.getMethod("release"); + registerMethod.invoke(null, pc); + + try { + // Call GetApplicationSettings.call(PageContext) + Class getAppSettingsClass = cl.loadClass("lucee.runtime.functions.system.GetApplicationSettings"); + java.lang.reflect.Method callMethod = getAppSettingsClass.getMethod("call", PageContext.class); + Object settings = callMethod.invoke(null, pc); + + // Serialize the settings to JSON + Class serializeClass = cl.loadClass("lucee.runtime.functions.conversion.SerializeJSON"); + java.lang.reflect.Method serializeMethod = serializeClass.getMethod("call", + PageContext.class, Object.class, Object.class); + result.value = (String) serializeMethod.invoke(null, pc, settings, "struct"); + } finally { + releaseMethod.invoke(null); + } + } catch (Throwable e) { + Log.debug("getApplicationSettings failed: " + e.getMessage()); + result.value = "\"Error: " + e.getMessage().replace("\"", "\\\"") + "\""; + } + }); + + thread.start(); + try { + thread.join(5000); // 5 second timeout + } catch (InterruptedException e) { + return "\"Timeout getting application settings\""; + } + + return result.value; + } + + @Override + public String getSourcePathForVariablesRef(int variablesRef) { + return valTracker + .maybeGetFromId(variablesRef) + .map(taggedObj -> CfValueDebuggerBridge.getSourcePath(taggedObj.obj)) + .orElse(null); + } + + @Override + public org.eclipse.lsp4j.debug.CompletionItem[] getCompletions(int frameId, String partialExpr) { + // Get PageContext from frame or any suspended frame + PageContext pc = null; + IDebugFrame frame = frameCache.get((long) frameId); + if (frame instanceof NativeDebugFrame) { + pc = ((NativeDebugFrame) frame).getPageContext(); + } + if (pc == null) { + for (IDebugFrame f : frameCache.values()) { + if (f instanceof NativeDebugFrame) { + pc = ((NativeDebugFrame) f).getPageContext(); + if (pc != null) break; + } + } + } + + if (pc == null) { + return new org.eclipse.lsp4j.debug.CompletionItem[0]; + } + + return doGetCompletionsWithPageContext(pc, partialExpr); + } + + private org.eclipse.lsp4j.debug.CompletionItem[] doGetCompletionsWithPageContext(PageContext pc, String partialExpr) { + final java.util.List results = new java.util.ArrayList<>(); + + try { + ClassLoader cl = luceeClassLoader != null ? luceeClassLoader : pc.getClass().getClassLoader(); + + // Parse the expression: "local.foo.ba" -> base="local.foo", prefix="ba" + // Or just "va" -> base=null, prefix="va" + String base = null; + String prefix = partialExpr.toLowerCase(); + int lastDot = partialExpr.lastIndexOf('.'); + + if (lastDot > 0) { + base = partialExpr.substring(0, lastDot); + prefix = partialExpr.substring(lastDot + 1).toLowerCase(); + } + + if (base != null) { + // Evaluate the base to get keys + try { + Class tlpcClass = cl.loadClass("lucee.runtime.engine.ThreadLocalPageContext"); + java.lang.reflect.Method registerMethod = tlpcClass.getMethod("register", PageContext.class); + java.lang.reflect.Method releaseMethod = tlpcClass.getMethod("release"); + Class evaluateClass = cl.loadClass("lucee.runtime.functions.dynamicEvaluation.Evaluate"); + java.lang.reflect.Method callMethod = evaluateClass.getMethod("call", PageContext.class, Object[].class); + + registerMethod.invoke(null, pc); + try { + Object result = callMethod.invoke(null, pc, new Object[]{base}); + if (result instanceof java.util.Map) { + @SuppressWarnings("unchecked") + java.util.Map map = (java.util.Map) result; + for (Object key : map.keySet()) { + String keyStr = String.valueOf(key); + if (keyStr.toLowerCase().startsWith(prefix)) { + var item = new org.eclipse.lsp4j.debug.CompletionItem(); + item.setLabel(keyStr); + item.setType(org.eclipse.lsp4j.debug.CompletionItemType.PROPERTY); + results.add(item); + } + } + } + } finally { + releaseMethod.invoke(null); + } + } catch (Exception e) { + // Evaluation failed, return empty + Log.debug("Completion evaluation failed: " + e.getMessage()); + } + } else { + // No base - complete from scope names and top-level scope variables + String[] scopes = {"variables", "local", "arguments", "form", "url", "cgi", "cookie", "session", "application", "server", "request", "this"}; + for (String scope : scopes) { + if (scope.toLowerCase().startsWith(prefix)) { + var item = new org.eclipse.lsp4j.debug.CompletionItem(); + item.setLabel(scope); + item.setType(org.eclipse.lsp4j.debug.CompletionItemType.MODULE); + results.add(item); + } + } + + // Also try to complete from variables scope + try { + Object variablesScope = pc.variablesScope(); + if (variablesScope instanceof java.util.Map) { + @SuppressWarnings("unchecked") + java.util.Map map = (java.util.Map) variablesScope; + for (Object key : map.keySet()) { + String keyStr = String.valueOf(key); + if (keyStr.toLowerCase().startsWith(prefix)) { + var item = new org.eclipse.lsp4j.debug.CompletionItem(); + item.setLabel(keyStr); + item.setType(org.eclipse.lsp4j.debug.CompletionItemType.VARIABLE); + results.add(item); + } + } + } + } catch (Exception e) { + // Ignore scope access errors + } + } + } catch (Exception e) { + Log.debug("Completion failed: " + e.getMessage()); + } + + // Sort by label and limit + results.sort((a, b) -> a.getLabel().compareToIgnoreCase(b.getLabel())); + + Log.info("Completions for '" + partialExpr + "': returning " + results.size() + " items"); + for (var item : results) { + Log.debug(" - " + item.getLabel()); + } + + if (results.size() > 100) { + return results.subList(0, 100).toArray(new org.eclipse.lsp4j.debug.CompletionItem[0]); + } + return results.toArray(new org.eclipse.lsp4j.debug.CompletionItem[0]); + } + + @Override + public Either> evaluate(int frameID, String expr) { + // For native mode, use the frame's PageContext to evaluate expressions + IDebugFrame frame = frameCache.get((long) frameID); + if (frame == null) { + return Either.Left("Frame not found: " + frameID); + } + + if (!(frame instanceof NativeDebugFrame)) { + // Fall back to JDWP mode if available + if (GlobalIDebugManagerHolder.debugManager != null) { + return GlobalIDebugManagerHolder.debugManager.evaluate((Long)(long)frameID, expr); + } + return Either.Left("evaluate only supported for native frames"); + } + + NativeDebugFrame nativeFrame = (NativeDebugFrame) frame; + PageContext pc = nativeFrame.getPageContext(); + if (pc == null) { + return Either.Left("No PageContext available for frame"); + } + + try { + // Use reflection to access Lucee classes - in extension mode, direct class access fails + ClassLoader cl = luceeClassLoader != null ? luceeClassLoader : pc.getClass().getClassLoader(); + + // Get ThreadLocalPageContext class and methods via reflection + Class tlpcClass = cl.loadClass("lucee.runtime.engine.ThreadLocalPageContext"); + java.lang.reflect.Method registerMethod = tlpcClass.getMethod("register", PageContext.class); + java.lang.reflect.Method releaseMethod = tlpcClass.getMethod("release"); + + // Get Evaluate class and call method via reflection + Class evaluateClass = cl.loadClass("lucee.runtime.functions.dynamicEvaluation.Evaluate"); + java.lang.reflect.Method callMethod = evaluateClass.getMethod("call", PageContext.class, Object[].class); + + // Register PageContext with ThreadLocal so Lucee functions work + registerMethod.invoke(null, pc); + + try { + // Evaluate the expression + Object result = callMethod.invoke(null, pc, new Object[]{expr}); + + // Return the result as a debug entity + if (result == null) { + return Either.Right(Either.Right("null")); + } else if (result instanceof String) { + return Either.Right(Either.Right("\"" + ((String)result).replaceAll("\"", "\\\\\"") + "\"")); + } else if (result instanceof Number || result instanceof Boolean) { + return Either.Right(Either.Right(result.toString())); + } else { + // Complex object - wrap it for display + CfValueDebuggerBridge bridge = new CfValueDebuggerBridge(valTracker, result); + return Either.Right(Either.Left(bridge)); + } + } finally { + releaseMethod.invoke(null); + } + } catch (Throwable e) { + // Unwrap InvocationTargetException to get the real cause + Throwable cause = e; + if (e instanceof java.lang.reflect.InvocationTargetException && e.getCause() != null) { + cause = e.getCause(); + } + String msg = cause.getMessage(); + if (msg == null) { + msg = cause.getClass().getName(); + } + return Either.Left("Evaluation error: " + msg); + } + } + + @Override + public Either> setVariable(long variablesReference, String name, String value, long frameIdHint) { + // Get the frame to access PageContext + // First try using the frameId from ValTracker (associated with the variablesReference) + Long trackedFrameId = valTracker.getFrameId(variablesReference); + long actualFrameId = (trackedFrameId != null) ? trackedFrameId : frameIdHint; + + IDebugFrame frame = frameCache.get(actualFrameId); + if (frame == null) { + return Either.Left("Frame not found: " + actualFrameId); + } + + if (!(frame instanceof NativeDebugFrame)) { + return Either.Left("setVariable only supported for native frames"); + } + + NativeDebugFrame nativeFrame = (NativeDebugFrame) frame; + PageContext pc = nativeFrame.getPageContext(); + if (pc == null) { + return Either.Left("No PageContext available for frame"); + } + + // Get the parent path from ValTracker + String parentPath = valTracker.getPath(variablesReference); + if (parentPath == null) { + return Either.Left("Cannot determine variable path for variablesReference: " + variablesReference); + } + + // Build the full variable path + String fullPath = parentPath + "." + name; + Log.debug("setVariable: " + fullPath + " = " + value); + + try { + // Use reflection to access Lucee classes - in extension mode, direct class access fails + ClassLoader cl = luceeClassLoader != null ? luceeClassLoader : pc.getClass().getClassLoader(); + + // Get ThreadLocalPageContext class and methods via reflection + Class tlpcClass = cl.loadClass("lucee.runtime.engine.ThreadLocalPageContext"); + java.lang.reflect.Method registerMethod = tlpcClass.getMethod("register", PageContext.class); + java.lang.reflect.Method releaseMethod = tlpcClass.getMethod("release"); + + // Get Evaluate class and call method via reflection + Class evaluateClass = cl.loadClass("lucee.runtime.functions.dynamicEvaluation.Evaluate"); + java.lang.reflect.Method callMethod = evaluateClass.getMethod("call", PageContext.class, Object[].class); + + // Register PageContext with ThreadLocal so Lucee functions work + registerMethod.invoke(null, pc); + + try { + // First, evaluate the value expression to get the actual object + Object evaluatedValue = callMethod.invoke(null, pc, new Object[]{value}); + + // Use Lucee's setVariable to set the value + Object result = pc.setVariable(fullPath, evaluatedValue); + + // Return the result as a debug entity + if (result == null) { + return Either.Right(Either.Right("null")); + } else if (result instanceof String) { + return Either.Right(Either.Right("\"" + ((String)result).replaceAll("\"", "\\\\\"") + "\"")); + } else if (result instanceof Number || result instanceof Boolean) { + return Either.Right(Either.Right(result.toString())); + } else { + // Complex object - wrap it for display + CfValueDebuggerBridge bridge = new CfValueDebuggerBridge(valTracker, result); + return Either.Right(Either.Left(bridge)); + } + } finally { + releaseMethod.invoke(null); + } + } catch (Throwable e) { + // Unwrap InvocationTargetException to get the real cause + Throwable cause = e; + if (e instanceof java.lang.reflect.InvocationTargetException && e.getCause() != null) { + cause = e.getCause(); + } + String msg = cause.getMessage(); + if (msg == null) { + msg = cause.getClass().getName(); + } + Log.debug("setVariable failed: " + msg); + return Either.Left("Error setting variable: " + msg); + } + } + + @Override + public void registerExceptionEventCallback(Consumer cb) { + exceptionEventCallback = cb; + } + + @Override + public void registerPauseEventCallback(Consumer cb) { + pauseEventCallback = cb; + } + + @Override + public void pause(long threadID) { + NativeDebuggerListener.requestPause(threadID); + } + + @Override + public Throwable getExceptionForThread(long threadId) { + NativeDebuggerListener.SuspendLocation loc = NativeDebuggerListener.getSuspendLocation(threadId); + return loc != null ? loc.exception : null; + } + + /** + * Get executable line numbers for a file. + * Used by DAP breakpointLocations request. + * + * @param serverPath The server-side absolute file path + * @return Array of line numbers where breakpoints can be set + */ + public int[] getExecutableLines(String serverPath) { + return NativeDebuggerListener.getExecutableLines(serverPath); + } +} diff --git a/luceedebug/src/main/java/luceedebug/coreinject/ValTracker.java b/luceedebug/src/main/java/luceedebug/coreinject/ValTracker.java index 3fe4d77..6b43d1d 100644 --- a/luceedebug/src/main/java/luceedebug/coreinject/ValTracker.java +++ b/luceedebug/src/main/java/luceedebug/coreinject/ValTracker.java @@ -21,8 +21,22 @@ public class ValTracker { private final Map wrapperByObj = Collections.synchronizedMap(new WeakHashMap<>()); private final Map wrapperByID = new ConcurrentHashMap<>(); + /** + * Track the variable path for each registered object ID. + * Used by setVariable to build the full path like "local.foo.bar". + * Path is the dot-separated path from the scope root (e.g., "local", "local.myStruct", "local.myStruct.nested"). + */ + private final Map pathById = new ConcurrentHashMap<>(); + + /** + * Track the frame ID for each registered object ID. + * Used by setVariable to get the correct PageContext. + */ + private final Map frameIdById = new ConcurrentHashMap<>(); + private static class WeakTaggedObject { - private static final AtomicLong nextId = new AtomicLong(); + // Start at 1, not 0 - DAP uses variablesReference=0 to mean "no children" + private static final AtomicLong nextId = new AtomicLong(1); public final long id; public final WeakReference wrapped; public WeakTaggedObject(Object obj) { @@ -67,8 +81,10 @@ public void run() { // It would be nice to assert that wrapperByObj().size() == wrapperByID.size() after we're done here, but the entries for wrapperByObj // are cleaned non-deterministically (in the google guava case, the java sync'd WeakHashMap seems much more deterministic but maybe // not guaranteed to be so), so there's no guarantee that the sizes sync up. - + wrapperByID.remove(id); + pathById.remove(id); + frameIdById.remove(id); // __debug_updatedTracker("remove", id); } @@ -121,6 +137,78 @@ public Optional maybeGetFromId(long id) { return weakTaggedObj.maybeToStrong(); } + /** + * Register or update the variable path for an object ID. + * Called when registering scopes (path = scope name) or when expanding children (path = parent.childKey). + * @param id The variablesReference ID + * @param path The dot-separated path from scope root (e.g., "local", "local.foo", "local.foo.bar") + */ + public void setPath(long id, String path) { + if (path != null) { + pathById.put(id, path); + } + } + + /** + * Get the variable path for an object ID. + * @param id The variablesReference ID + * @return The path, or null if not tracked + */ + public String getPath(long id) { + return pathById.get(id); + } + + /** + * Register an object and set its path in one call. + * @param obj The object to register + * @param path The variable path for this object + * @return TaggedObject with the ID + */ + public TaggedObject registerObjectWithPath(Object obj, String path) { + TaggedObject tagged = idempotentRegisterObject(obj); + if (path != null) { + pathById.put(tagged.id, path); + } + return tagged; + } + + /** + * Register an object and set its path and frameId in one call. + * @param obj The object to register + * @param path The variable path for this object + * @param frameId The frame ID for this object (for setVariable support) + * @return TaggedObject with the ID + */ + public TaggedObject registerObjectWithPathAndFrameId(Object obj, String path, Long frameId) { + TaggedObject tagged = idempotentRegisterObject(obj); + if (path != null) { + pathById.put(tagged.id, path); + } + if (frameId != null) { + frameIdById.put(tagged.id, frameId); + } + return tagged; + } + + /** + * Set the frame ID for an object ID. + * Used by setVariable to get the correct PageContext. + * @param id The variablesReference ID + * @param frameId The frame ID + */ + public void setFrameId(long id, long frameId) { + frameIdById.put(id, frameId); + } + + /** + * Get the frame ID for an object ID. + * @param id The variablesReference ID + * @return The frame ID, or null if not tracked + */ + public Long getFrameId(long id) { + return frameIdById.get(id); + } + /** * debug/sanity check that tracked values are being cleaned up in both maps in response to gc events */ diff --git a/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java b/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java new file mode 100644 index 0000000..f3c623b --- /dev/null +++ b/luceedebug/src/main/java/luceedebug/coreinject/frame/NativeDebugFrame.java @@ -0,0 +1,476 @@ +package luceedebug.coreinject.frame; + +import lucee.runtime.PageContext; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +import luceedebug.*; +import luceedebug.coreinject.CfValueDebuggerBridge; +import luceedebug.coreinject.DebugEntity; +import luceedebug.coreinject.ValTracker; +import luceedebug.coreinject.CfValueDebuggerBridge.MarkerTrait; + +/** + * Adapter that wraps Lucee7's native DebuggerFrame to implement IDebugFrame. + * Uses reflection to access the new Lucee7 APIs so we can compile against older Lucee versions. + * + * This is used when Lucee's DEBUGGER_ENABLED=true and provides the CFML frame stack + * without requiring bytecode instrumentation. + */ +public class NativeDebugFrame implements IDebugFrame { + static private AtomicLong nextId = new AtomicLong( 0 ); + + // Reflection cache - initialized once + private static volatile Boolean nativeFrameSupportAvailable = null; + private static Method getDebuggerFramesMethod = null; + private static Method getLineMethod = null; + private static Method setLineMethod = null; + private static Field localField = null; + private static Field argumentsField = null; + private static Field variablesField = null; + private static Field pageSourceField = null; + private static Field functionNameField = null; + private static Method getDisplayPathMethod = null; + + private final Object nativeFrame; // PageContextImpl.DebuggerFrame (null for synthetic top-level frame) + private final PageContext pageContext; + private final ValTracker valTracker; + private final String sourceFilePath; + private final String functionName; + private final long id; + private final int depth; + private int syntheticLine; // For synthetic frames only + private final Throwable exception; // Non-null if this frame is for an exception suspend + + // Scope references from the native frame + private final Object local; // lucee.runtime.type.scope.Local + private final Object arguments; // lucee.runtime.type.scope.Argument + private final Object variables; // lucee.runtime.type.scope.Variables + + // lazy initialized on request for scopes + private LinkedHashMap scopes_ = null; + + // Constructor for real native frames (wrapping DebuggerFrame) + private NativeDebugFrame( Object nativeFrame, PageContext pageContext, ValTracker valTracker, int depth, Throwable exception ) throws Exception { + this.nativeFrame = nativeFrame; + this.pageContext = pageContext; + this.valTracker = valTracker; + this.id = nextId.incrementAndGet(); + this.depth = depth; + this.exception = exception; + + // Extract fields using reflection + this.local = localField.get( nativeFrame ); + this.arguments = argumentsField.get( nativeFrame ); + this.variables = variablesField.get( nativeFrame ); + Object pageSource = pageSourceField.get( nativeFrame ); + this.sourceFilePath = (String) getDisplayPathMethod.invoke( pageSource ); + this.functionName = (String) functionNameField.get( nativeFrame ); + } + + // Constructor for synthetic top-level frame (no DebuggerFrame exists) + private NativeDebugFrame( PageContext pageContext, ValTracker valTracker, String file, int line, String label, Throwable exception ) { + this.nativeFrame = null; // synthetic - no native frame + this.pageContext = pageContext; + this.valTracker = valTracker; + this.id = nextId.incrementAndGet(); + this.depth = 0; + this.syntheticLine = line; + this.sourceFilePath = file; + this.exception = exception; + + // Build frame name - use label if provided, otherwise try to get request URL + if ( label != null && !label.isEmpty() ) { + this.functionName = label; + } else { + // Try to get request URL for more useful frame name + String requestUrl = getRequestUrl( pageContext ); + this.functionName = (requestUrl != null) ? requestUrl : ""; + } + + // For top-level code, use PageContext scopes directly + this.local = null; + this.arguments = null; + try { + this.variables = pageContext.variablesScope(); + } catch ( Exception e ) { + throw new RuntimeException( e ); + } + } + + /** + * Try to get the request URL from PageContext's CGI scope. + */ + private static String getRequestUrl( PageContext pc ) { + try { + Object cgiScope = pc.cgiScope(); + if ( cgiScope instanceof Map ) { + @SuppressWarnings("unchecked") + Map cgi = (Map) cgiScope; + // Try script_name first (just the path), then request_url + Object scriptName = cgi.get( "script_name" ); + if ( scriptName == null ) { + // Try with Key object if direct string lookup fails + for ( Map.Entry entry : cgi.entrySet() ) { + String keyStr = entry.getKey().toString().toLowerCase(); + if ( "script_name".equals( keyStr ) ) { + scriptName = entry.getValue(); + break; + } + } + } + if ( scriptName != null && !scriptName.toString().isEmpty() ) { + return scriptName.toString(); + } + } + } catch ( Exception e ) { + // Ignore - fall back to default + } + return null; + } + + @Override + public String getSourceFilePath() { + return sourceFilePath; + } + + @Override + public long getId() { + return id; + } + + @Override + public String getName() { + if ( functionName == null ) { + return "??"; + } + // Don't add () for synthetic frames (start with < or /) or exception labels + if ( functionName.startsWith( "<" ) || functionName.startsWith( "/" ) || functionName.contains( ":" ) ) { + return functionName; + } + return functionName + "()"; + } + + @Override + public int getDepth() { + return depth; + } + + @Override + public int getLine() { + if ( nativeFrame == null ) { + // Synthetic frame - return stored line + return syntheticLine; + } + try { + return (int) getLineMethod.invoke( nativeFrame ); + } catch ( Exception e ) { + return 0; + } + } + + @Override + public void setLine( int line ) { + if ( nativeFrame == null ) { + // Synthetic frame - update stored line + syntheticLine = line; + return; + } + try { + setLineMethod.invoke( nativeFrame, line ); + } catch ( Exception e ) { + // ignore + } + } + + /** + * Get the PageContext for this frame. + * Used by setVariable to execute Lucee code in the correct context. + */ + public PageContext getPageContext() { + return pageContext; + } + + private void checkedPutScopeRef( String name, Object scope ) { + if ( scope != null && scope instanceof Map ) { + var v = new MarkerTrait.Scope( (Map) scope ); + CfValueDebuggerBridge.pin( v ); + var bridge = new CfValueDebuggerBridge( valTracker, v ); + // Track the path for setVariable support - scope name is the root path + valTracker.setPath( bridge.id, name ); + // Track the frame ID for setVariable support - needed to get PageContext + valTracker.setFrameId( bridge.id, id ); + scopes_.put( name, bridge ); + } + } + + private void lazyInitScopeRefs() { + if ( scopes_ != null ) { + return; + } + + scopes_ = new LinkedHashMap<>(); + + // If this frame has an exception, add cfcatch scope first (most relevant when debugging exceptions) + if ( exception != null ) { + addCfcatchScope(); + } + + // Frame-specific scopes from native DebuggerFrame + checkedPutScopeRef( "local", local ); + checkedPutScopeRef( "arguments", arguments ); + checkedPutScopeRef( "variables", variables ); + + // Global scopes from PageContext - these are shared across frames + try { + checkedPutScopeRef( "application", pageContext.applicationScope() ); + } catch ( Throwable e ) { /* scope not available */ } + + try { + checkedPutScopeRef( "form", pageContext.formScope() ); + } catch ( Throwable e ) { /* scope not available */ } + + try { + checkedPutScopeRef( "request", pageContext.requestScope() ); + } catch ( Throwable e ) { /* scope not available */ } + + try { + if ( pageContext.getApplicationContext().isSetSessionManagement() ) { + checkedPutScopeRef( "session", pageContext.sessionScope() ); + } + } catch ( Throwable e ) { /* scope not available */ } + + try { + checkedPutScopeRef( "server", pageContext.serverScope() ); + } catch ( Throwable e ) { /* scope not available */ } + + try { + checkedPutScopeRef( "url", pageContext.urlScope() ); + } catch ( Throwable e ) { /* scope not available */ } + + // Try to get 'this' scope from variables if it's a ComponentScope + try { + if ( variables != null && variables.getClass().getName().equals( "lucee.runtime.ComponentScope" ) ) { + Method getComponentMethod = variables.getClass().getMethod( "getComponent" ); + Object component = getComponentMethod.invoke( variables ); + checkedPutScopeRef( "this", component ); + } + } catch ( Throwable e ) { /* scope not available */ } + } + + /** + * Add a cfcatch scope with exception details. + * Mimics the structure of CFML's cfcatch variable. + */ + private void addCfcatchScope() { + // Build a map with cfcatch-like properties + Map cfcatch = new LinkedHashMap<>(); + + // Basic exception properties + cfcatch.put( "type", getExceptionType( exception ) ); + cfcatch.put( "message", exception.getMessage() != null ? exception.getMessage() : "" ); + + // Get detail if it's a PageException + String detail = ""; + String errorCode = ""; + String extendedInfo = ""; + if ( exception instanceof lucee.runtime.exp.PageException ) { + lucee.runtime.exp.PageException pe = (lucee.runtime.exp.PageException) exception; + detail = pe.getDetail() != null ? pe.getDetail() : ""; + errorCode = pe.getErrorCode() != null ? pe.getErrorCode() : ""; + extendedInfo = pe.getExtendedInfo() != null ? pe.getExtendedInfo() : ""; + } + cfcatch.put( "detail", detail ); + cfcatch.put( "errorCode", errorCode ); + cfcatch.put( "extendedInfo", extendedInfo ); + + // Java exception info + cfcatch.put( "javaClass", exception.getClass().getName() ); + + // Stack trace as string + java.io.StringWriter sw = new java.io.StringWriter(); + exception.printStackTrace( new java.io.PrintWriter( sw ) ); + cfcatch.put( "stackTrace", sw.toString() ); + + // Add as scope - pin both the wrapper and the inner map to prevent GC + var v = new MarkerTrait.Scope( cfcatch ); + CfValueDebuggerBridge.pin( cfcatch ); + CfValueDebuggerBridge.pin( v ); + var bridge = new CfValueDebuggerBridge( valTracker, v ); + // Track the path for setVariable support + valTracker.setPath( bridge.id, "cfcatch" ); + // Track the frame ID for setVariable support + valTracker.setFrameId( bridge.id, id ); + scopes_.put( "cfcatch", bridge ); + } + + /** + * Get the CFML-style type for an exception. + */ + private String getExceptionType( Throwable t ) { + if ( t instanceof lucee.runtime.exp.PageException ) { + lucee.runtime.exp.PageException pe = (lucee.runtime.exp.PageException) t; + String type = pe.getTypeAsString(); + if ( type != null && !type.isEmpty() ) { + return type; + } + } + // Fall back to Java exception type + return t.getClass().getSimpleName(); + } + + @Override + public IDebugEntity[] getScopes() { + lazyInitScopeRefs(); + IDebugEntity[] result = new DebugEntity[scopes_.size()]; + int i = 0; + for ( var kv : scopes_.entrySet() ) { + String name = kv.getKey(); + CfValueDebuggerBridge entityRef = kv.getValue(); + var entity = new DebugEntity(); + entity.name = name; + entity.namedVariables = entityRef.getNamedVariablesCount(); + entity.indexedVariables = entityRef.getIndexedVariablesCount(); + entity.expensive = true; + entity.variablesReference = entityRef.id; + result[i] = entity; + i += 1; + } + return result; + } + + /** + * Initialize reflection handles for Lucee7's native debugger frame support. + * Returns true if initialization succeeded (Lucee7 with DEBUGGER_ENABLED=true). + * @param luceeClassLoader ClassLoader to use for loading Lucee core classes (required in OSGi extension mode) + */ + private static synchronized boolean initReflection( ClassLoader luceeClassLoader ) { + if ( nativeFrameSupportAvailable != null ) { + return nativeFrameSupportAvailable; + } + + try { + // Check if DAP debugger is enabled (via LUCEE_DAP_SECRET env var) + if ( !EnvUtil.isDebuggerEnabled() ) { + Log.info( "Native frame support disabled: LUCEE_DAP_SECRET not set" ); + nativeFrameSupportAvailable = false; + return false; + } + + // Use provided classloader, fall back to PageContext's classloader + ClassLoader cl = luceeClassLoader; + if ( cl == null ) { + cl = PageContext.class.getClassLoader(); + } + + // Load PageContextImpl via reflection (not directly accessible in OSGi extension mode) + Class pciClass = cl.loadClass( "lucee.runtime.PageContextImpl" ); + + // Get the getDebuggerFrames method + getDebuggerFramesMethod = pciClass.getMethod( "getDebuggerFrames" ); + + // Get DebuggerFrame class (inner class of PageContextImpl) + Class debuggerFrameClass = cl.loadClass( "lucee.runtime.PageContextImpl$DebuggerFrame" ); + + // Get DebuggerFrame fields and methods + localField = debuggerFrameClass.getField( "local" ); + argumentsField = debuggerFrameClass.getField( "arguments" ); + variablesField = debuggerFrameClass.getField( "variables" ); + pageSourceField = debuggerFrameClass.getField( "pageSource" ); + functionNameField = debuggerFrameClass.getField( "functionName" ); + getLineMethod = debuggerFrameClass.getMethod( "getLine" ); + setLineMethod = debuggerFrameClass.getMethod( "setLine", int.class ); + + // Get PageSource.getDisplayPath method + Class pageSourceClass = cl.loadClass( "lucee.runtime.PageSource" ); + getDisplayPathMethod = pageSourceClass.getMethod( "getDisplayPath" ); + + nativeFrameSupportAvailable = true; + return true; + + } catch ( Throwable e ) { + // Lucee version doesn't have native debugger frame support + Log.error( "Failed to initialize native frame support: " + e.getMessage() ); + nativeFrameSupportAvailable = false; + return false; + } + } + + /** + * Check if native debugger frames are available in this Lucee version. + * Returns true if DEBUGGER_ENABLED is true in Lucee7+. + * @param luceeClassLoader ClassLoader to use for loading Lucee core classes + */ + public static boolean isNativeFrameSupportAvailable( ClassLoader luceeClassLoader ) { + return initReflection( luceeClassLoader ); + } + + /** + * Get frames from Lucee's native debugger frame stack. + * If no native DebuggerFrames exist (top-level code), creates a synthetic frame using the suspend location. + * @param pageContext The PageContext + * @param valTracker Value tracker for scope references + * @param threadId Java thread ID to look up suspend location (for synthetic frames) + * @param luceeClassLoader ClassLoader to use for loading Lucee core classes + * @return Array of debug frames, or null if not available + */ + public static IDebugFrame[] getNativeFrames( PageContext pageContext, ValTracker valTracker, long threadId, ClassLoader luceeClassLoader ) { + if ( !isNativeFrameSupportAvailable( luceeClassLoader ) ) { + Log.debug( "getNativeFrames: native frame support not available" ); + return null; + } + + try { + // pageContext is actually a PageContextImpl, invoke method via reflection + Object[] nativeFrames = (Object[]) getDebuggerFramesMethod.invoke( pageContext ); + + // Get suspend location - may contain exception info + var location = luceedebug.coreinject.NativeDebuggerListener.getSuspendLocation( threadId ); + Throwable exception = (location != null) ? location.exception : null; + + // Convert to IDebugFrame array, filtering frames with line 0 + ArrayList result = new ArrayList<>(); + + if ( nativeFrames != null && nativeFrames.length > 0 ) { + // Native frames are in push order (oldest first), DAP expects newest first + for ( int i = nativeFrames.length - 1; i >= 0; i-- ) { + Object nf = nativeFrames[i]; + int line = (int) getLineMethod.invoke( nf ); + + // Skip frames with line 0 (not yet stepped into) + if ( line == 0 ) { + continue; + } + + // Only pass exception to the topmost frame (first one added to result) + Throwable frameException = result.isEmpty() ? exception : null; + result.add( new NativeDebugFrame( nf, pageContext, valTracker, i, frameException ) ); + } + } + + // If no frames from native stack, try to create synthetic frame from suspend location + if ( result.isEmpty() && threadId >= 0 ) { + Log.trace( "Checking suspend location for thread " + threadId + ": " + (location != null ? location.file + ":" + location.line : "null") ); + if ( location != null && location.file != null && location.line > 0 ) { + Log.trace( "Creating synthetic frame for top-level code: " + location.file + ":" + location.line + (location.label != null ? " label=" + location.label : "") ); + result.add( new NativeDebugFrame( pageContext, valTracker, location.file, location.line, location.label, exception ) ); + } + } + + if ( result.isEmpty() ) { + return null; + } + + return result.toArray( new IDebugFrame[0] ); + + } catch ( Throwable e ) { + System.err.println( "[luceedebug] Error getting native frames: " + e.getMessage() ); + return null; + } + } +} diff --git a/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java b/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java new file mode 100644 index 0000000..0a78449 --- /dev/null +++ b/luceedebug/src/main/java/luceedebug/extension/ExtensionActivator.java @@ -0,0 +1,320 @@ +package luceedebug.extension; + +import java.lang.reflect.Method; + +import lucee.runtime.config.Config; + +import luceedebug.DapServer; +import luceedebug.EnvUtil; +import luceedebug.Log; +import luceedebug.coreinject.NativeLuceeVm; + +/** + * Extension startup hook - instantiated by Lucee when the extension loads. + * Uses Lucee's startup-hook mechanism (manifest attribute). + * + * Native-only mode: requires Lucee 7.1+ with DebuggerRegistry API. + * No JDWP, no Java agent, no bytecode instrumentation. + */ +public class ExtensionActivator { + private static NativeLuceeVm luceeVm; + private static ClassLoader luceeLoader; + private static ClassLoader extensionLoader; + private static boolean listenerRegistered = false; + private static boolean alreadyActivated = false; + // Keep a static reference to prevent GC from collecting the instance + // (Lucee's startup-hook discards its reference immediately) + private static ExtensionActivator instance; + + /** + * Constructor called by Lucee's startup-hook mechanism. + * Lucee passes the Config object automatically. + * May be called multiple times (ConfigServer + each ConfigWeb). + */ + public ExtensionActivator(Config luceeConfig) { + // Only activate once + if (alreadyActivated) { + return; + } + alreadyActivated = true; + // Keep a reference to prevent GC + instance = this; + + try { + // Get debug port - if not set, debugger is disabled + int debugPort = EnvUtil.getDebuggerPort(); + if (debugPort < 0) { + Log.info("Debugger disabled - set LUCEE_DAP_SECRET to enable"); + return; + } + Log.info("Extension activating"); + + // Store classloaders for later listener registration + extensionLoader = this.getClass().getClassLoader(); + luceeLoader = luceeConfig.getClass().getClassLoader(); + + // Determine filesystem case sensitivity from Lucee's config location + String configPath = luceeConfig.getConfigDir().getAbsolutePath(); + boolean fsCaseSensitive = luceedebug.Config.checkIfFileSystemIsCaseSensitive(configPath); + + // Create luceedebug config + luceedebug.Config config = new luceedebug.Config(fsCaseSensitive); + + // Set Lucee classloader for reflection access to core classes + NativeLuceeVm.setLuceeClassLoader(luceeLoader); + + // Create NativeLuceeVm + luceeVm = new NativeLuceeVm(config); + + // Start DAP server in background thread (createForSocket blocks forever) + // Listener registration is deferred until DAP client connects with secret + final int port = debugPort; + final luceedebug.Config finalConfig = config; + Thread dapThread = new Thread(() -> { + // Use System.out directly to ensure we see output even if Log class has issues + System.out.println("[luceedebug] DAP server thread started"); + System.out.flush(); + try { + System.out.println("[luceedebug] Calling DapServer.createForSocket on port " + port); + System.out.flush(); + DapServer.createForSocket(luceeVm, finalConfig, "localhost", port); + // This line should never be reached - createForSocket loops forever + System.out.println("[luceedebug] DAP server createForSocket returned unexpectedly"); + } catch (Throwable t) { + System.out.println("[luceedebug] DAP server thread failed: " + t.getClass().getName() + ": " + t.getMessage()); + t.printStackTrace(System.out); + Log.error("DAP server thread failed", t); + } + System.out.println("[luceedebug] DAP server thread exiting"); + }, "luceedebug-dap-server"); + dapThread.setDaemon(true); + dapThread.setUncaughtExceptionHandler((t, e) -> { + System.out.println("[luceedebug] DAP thread died with uncaught exception: " + e.getClass().getName() + ": " + e.getMessage()); + e.printStackTrace(System.out); + }); + dapThread.start(); + + Log.info("Native mode, DAP server starting on localhost:" + debugPort); + } catch (Throwable t) { + System.out.println("[luceedebug] Extension activation failed: " + t.getClass().getName() + ": " + t.getMessage()); + t.printStackTrace(System.out); + Log.error("Extension activation failed", t); + } + } + + /** + * Register the debugger listener with Lucee using the client-provided secret. + * Called from DapServer.attach() when client connects. + * Secret is validated on every connection, not just the first one. + * + * @param secret The secret from launch.json + * @return true if registration succeeded + */ + public static synchronized boolean registerListener(String secret) { + if (luceeLoader == null || extensionLoader == null) { + Log.error("Cannot register listener - extension not initialized"); + return false; + } + if (secret == null || secret.trim().isEmpty()) { + Log.error("Cannot register listener - no secret provided"); + return false; + } + // Always validate secret, even if already registered + String expectedSecret = EnvUtil.getDebuggerSecret(); + if (expectedSecret == null || !expectedSecret.equals(secret.trim())) { + Log.error("Invalid secret"); + return false; + } + // Only register with Lucee once + if (!listenerRegistered) { + if (registerNativeDebuggerListener(luceeLoader, extensionLoader, secret.trim())) { + listenerRegistered = true; + } else { + return false; + } + } + return true; + } + + /** + * Check if listener is already registered. + */ + public static boolean isListenerRegistered() { + return listenerRegistered; + } + + /** + * Check if the extension was actually activated by Lucee (native mode). + * In agent mode, the class exists but wasn't activated via startup-hook. + */ + public static boolean isNativeModeActive() { + return alreadyActivated; + } + + /** + * Enable DebuggerExecutionLog via ConfigAdmin. + * This triggers template recompilation with exeLogStart()/exeLogEnd() bytecode + * which calls DebuggerRegistry.shouldSuspend() on each line. + * + * Note: During startup-hook, we receive ConfigServer (not ConfigWeb). + * We need to find a ConfigAdmin.newInstance() method that works with ConfigServer. + */ + private void enableDebuggerExecutionLog(Config luceeConfig, ClassLoader luceeLoader) { + try { + // Load ConfigAdmin class from Lucee core + Class configAdminClass = luceeLoader.loadClass("lucee.runtime.config.ConfigAdmin"); + Class classDefClass = luceeLoader.loadClass("lucee.runtime.db.ClassDefinition"); + Class classDefImplClass = luceeLoader.loadClass("lucee.transformer.library.ClassDefinitionImpl"); + Class structClass = luceeLoader.loadClass("lucee.runtime.type.Struct"); + Class structImplClass = luceeLoader.loadClass("lucee.runtime.type.StructImpl"); + + // Find the right newInstance method - try different signatures + Object configAdmin = null; + + // Try to find a method that accepts our config type + for (Method m : configAdminClass.getMethods()) { + if (m.getName().equals("newInstance") && m.getParameterCount() >= 2) { + Class[] params = m.getParameterTypes(); + // Look for (Config/ConfigServer, String/Password, boolean) or similar + if (params[0].isAssignableFrom(luceeConfig.getClass())) { + try { + if (params.length == 2) { + // (Config, Password) + configAdmin = m.invoke(null, luceeConfig, null); + } else if (params.length == 3 && params[2] == boolean.class) { + // (Config, Password, optionalPW) + configAdmin = m.invoke(null, luceeConfig, null, true); + } + if (configAdmin != null) { + Log.info("Created ConfigAdmin using " + m); + break; + } + } catch (Exception e) { + // Try next method + } + } + } + } + + if (configAdmin == null) { + Log.error("Could not create ConfigAdmin - no compatible newInstance method found"); + Log.info("Available newInstance methods:"); + for (Method m : configAdminClass.getMethods()) { + if (m.getName().equals("newInstance")) { + Log.info(" " + m); + } + } + return; + } + + // Create ClassDefinition for DebuggerExecutionLog + java.lang.reflect.Constructor cdConstructor = classDefImplClass.getConstructor(String.class); + Object classDefinition = cdConstructor.newInstance("lucee.runtime.engine.DebuggerExecutionLog"); + + // Create empty Struct for arguments + Object emptyStruct = structImplClass.getConstructor().newInstance(); + + // admin.updateExecutionLog(classDefinition, arguments, enabled=true) + Method updateMethod = configAdminClass.getMethod("updateExecutionLog", + classDefClass, structClass, boolean.class); + updateMethod.invoke(configAdmin, classDefinition, emptyStruct, true); + + // Persist and reload config - this triggers template recompilation + Method storeMethod = configAdminClass.getMethod("storeAndReload"); + storeMethod.invoke(configAdmin); + + Log.info("Enabled DebuggerExecutionLog - templates will recompile with debugger bytecode"); + } catch (ClassNotFoundException e) { + Log.error("ConfigAdmin not found - cannot enable execution log: " + e.getMessage()); + } catch (Throwable e) { + Log.error("Failed to enable execution log", e); + } + } + + /** + * Register native debugger listener using cross-classloader proxy. + * DebuggerRegistry and DebuggerListener are in Lucee's core (luceeLoader). + * NativeDebuggerListener is in our extension bundle (extensionLoader). + * Requires the correct secret to register. + */ + private static boolean registerNativeDebuggerListener(ClassLoader luceeLoader, ClassLoader extensionLoader, String secret) { + try { + // Load Lucee core classes + Class registryClass = luceeLoader.loadClass("lucee.runtime.debug.DebuggerRegistry"); + Class listenerInterface = luceeLoader.loadClass("lucee.runtime.debug.DebuggerListener"); + Class pageContextClass = luceeLoader.loadClass("lucee.runtime.PageContext"); + + // Load our implementation from extension bundle + Class nativeListenerClass = extensionLoader.loadClass( + "luceedebug.coreinject.NativeDebuggerListener"); + + // Cache method lookups + final Method getNameMethod = nativeListenerClass.getMethod("getName"); + final Method onSuspendMethod = nativeListenerClass.getMethod("onSuspend", + pageContextClass, String.class, int.class, String.class); + final Method onResumeMethod = nativeListenerClass.getMethod("onResume", pageContextClass); + final Method shouldSuspendMethod = nativeListenerClass.getMethod("shouldSuspend", + pageContextClass, String.class, int.class); + final Method isDapClientConnectedMethod = nativeListenerClass.getMethod("isDapClientConnected"); + final Method onExceptionMethod = nativeListenerClass.getMethod("onException", + pageContextClass, Throwable.class, boolean.class); + final Method onOutputMethod = nativeListenerClass.getMethod("onOutput", + String.class, boolean.class); + final Method onFunctionEntryMethod = nativeListenerClass.getMethod("onFunctionEntry", + pageContextClass, String.class, String.class, String.class, int.class); + + // Create proxy in Lucee's classloader, delegating to extension's implementation + Object listenerProxy = java.lang.reflect.Proxy.newProxyInstance( + luceeLoader, + new Class[] { listenerInterface }, + (proxy, method, args) -> { + try { + switch (method.getName()) { + case "getName": return getNameMethod.invoke(null); + case "isClientConnected": return isDapClientConnectedMethod.invoke(null); + case "onSuspend": return onSuspendMethod.invoke(null, args); + case "onResume": return onResumeMethod.invoke(null, args); + case "shouldSuspend": return shouldSuspendMethod.invoke(null, args); + case "onException": return onExceptionMethod.invoke(null, args); + case "onOutput": return onOutputMethod.invoke(null, args); + case "onFunctionEntry": return onFunctionEntryMethod.invoke(null, args); + default: return null; + } + } catch (java.lang.reflect.InvocationTargetException e) { + Throwable cause = e.getCause(); + Log.error("Proxy invocation failed for " + method.getName(), cause); + // Return safe defaults for boolean methods + if (method.getReturnType() == boolean.class) return false; + return null; + } + } + ); + + // Register with Lucee (requires secret) + Method setListener = registryClass.getMethod("setListener", listenerInterface, String.class); + Boolean success = (Boolean) setListener.invoke(null, listenerProxy, secret); + + if (success) { + Log.info("Registered native debugger listener"); + return true; + } else { + Log.error("Debugger registration rejected - secret mismatch"); + return false; + } + } catch (ClassNotFoundException e) { + Log.info("DebuggerRegistry not found - requires Lucee 7.1+"); + return false; + } catch (NoSuchMethodException e) { + Log.error("DebuggerRegistry.setListener(listener, secret) not found - requires updated Lucee 7.1+"); + return false; + } catch (Throwable e) { + Log.error("Failed to register listener", e); + return false; + } + } + + // Note: finalize() was removed - it was being called by GC prematurely + // and shutting down the DAP server. Now we keep a static reference to + // prevent GC, and rely on DapServer.shutdown() being called explicitly + // (via system properties) when the extension is reinstalled. +} diff --git a/test/cfml/AuthTest.cfc b/test/cfml/AuthTest.cfc new file mode 100644 index 0000000..5a5afce --- /dev/null +++ b/test/cfml/AuthTest.cfc @@ -0,0 +1,87 @@ +/** + * Tests for DAP authentication - verifies proper errors when attach() not called. + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + function afterEach() { + teardownDap(); + } + + // ========== Auth Tests ========== + + /** + * Verify that setBreakpoints returns error when attach() not called. + */ + function testSetBreakpointsWithoutAttachReturnsError() { + setupDap( attach = false ); + + var threw = false; + var errorMessage = ""; + try { + dap.setBreakpoints( "/some/file.cfm", [ 10 ] ); + } catch ( any e ) { + threw = true; + errorMessage = e.message; + } + + expect( threw ).toBeTrue( "setBreakpoints should throw when not attached" ); + expect( errorMessage ).toInclude( "Not authorized" ); + } + + /** + * Verify that threads returns error when attach() not called. + */ + function testThreadsWithoutAttachReturnsError() { + setupDap( attach = false ); + + var threw = false; + var errorMessage = ""; + try { + dap.threads(); + } catch ( any e ) { + threw = true; + errorMessage = e.message; + } + + expect( threw ).toBeTrue( "threads should throw when not attached" ); + expect( errorMessage ).toInclude( "Not authorized" ); + } + + /** + * Verify that attach with wrong secret returns error. + */ + function testAttachWithWrongSecretReturnsError() { + setupDap( attach = false ); + + var threw = false; + var errorMessage = ""; + try { + dap.attach( "wrong-secret-value" ); + } catch ( any e ) { + threw = true; + errorMessage = e.message; + } + + expect( threw ).toBeTrue( "attach with wrong secret should throw" ); + expect( errorMessage ).toInclude( "Invalid" ); + } + + /** + * Verify that attach with correct secret works. + */ + function testAttachWithCorrectSecretSucceeds() { + setupDap( attach = false ); + + var threw = false; + try { + dap.attach( variables.dapSecret ); + } catch ( any e ) { + threw = true; + } + + expect( threw ).toBeFalse( "attach with correct secret should succeed" ); + } + +} diff --git a/test/cfml/BreakpointLocationsTest.cfc b/test/cfml/BreakpointLocationsTest.cfc new file mode 100644 index 0000000..1159d70 --- /dev/null +++ b/test/cfml/BreakpointLocationsTest.cfc @@ -0,0 +1,126 @@ +/** + * Tests for breakpointLocations request (valid breakpoint lines in a file). + * + * This is a native-only feature that uses Page.getExecutableLines() from Lucee core. + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + function beforeAll() { + setupDap(); + variables.targetFile = getArtifactPath( "breakpoint-target.cfm" ); + } + + function afterAll() { + teardownDap(); + } + + // ========== Basic Breakpoint Locations ========== + + function testBreakpointLocationsReturnsResults() skip="notSupportsBreakpointLocations" { + var response = dap.breakpointLocations( variables.targetFile, 1 ); + + expect( response.body ).toHaveKey( "breakpoints" ); + expect( response.body.breakpoints ).toBeArray(); + } + + function testBreakpointLocationsForSingleLine() skip="notSupportsBreakpointLocations" { + // Line 12 is inside simpleFunction - should be executable + var response = dap.breakpointLocations( variables.targetFile, 12 ); + + expect( response.body.breakpoints.len() ).toBeGTE( 1, "Line 12 should be executable" ); + + // Verify the returned location + var location = response.body.breakpoints[ 1 ]; + expect( location.line ).toBe( 12 ); + } + + function testBreakpointLocationsForRange() skip="notSupportsBreakpointLocations" { + // Request locations for lines 10-20 (covers simpleFunction and conditionalFunction) + var response = dap.breakpointLocations( variables.targetFile, 10, 20 ); + + expect( response.body.breakpoints.len() ).toBeGT( 1, "Should have multiple executable lines in range" ); + + // All returned lines should be within the requested range + for ( var loc in response.body.breakpoints ) { + expect( loc.line ).toBeGTE( 10, "Line should be >= 10" ); + expect( loc.line ).toBeLTE( 20, "Line should be <= 20" ); + } + } + + // ========== Non-Executable Lines ========== + + function testBreakpointLocationsEmptyForCommentLine() skip="notSupportsBreakpointLocations" { + // Lines 1-9 are comments/function declaration - may not all be executable + // Line 3 specifically is a comment line + var response = dap.breakpointLocations( variables.targetFile, 3, 3 ); + + // Comment lines should not be executable + // Either empty or the response adjusts to nearest executable line + if ( response.body.breakpoints.len() > 0 ) { + // If it returns a location, it should be adjusted to a different line + expect( response.body.breakpoints[ 1 ].line ).notToBe( 3, "Comment line should not be directly executable" ); + } + } + + function testBreakpointLocationsEmptyForBlankLine() skip="notSupportsBreakpointLocations" { + // Test a blank line (if there is one in the file) + // For breakpoint-target.cfm, let's check line 28 area + var response = dap.breakpointLocations( variables.targetFile, 28, 28 ); + + // This is the last line with content - should be executable or empty + // The test is mainly to ensure the request doesn't error + expect( response.body ).toHaveKey( "breakpoints" ); + } + + // ========== Executable Lines Verification ========== + + function testAllReturnedLocationsAreValid() skip="notSupportsBreakpointLocations" { + // Get all locations in the file + var response = dap.breakpointLocations( variables.targetFile, 1, 50 ); + + // Each returned location should allow setting a breakpoint + for ( var loc in response.body.breakpoints ) { + var bpResponse = dap.setBreakpoints( variables.targetFile, [ loc.line ] ); + expect( bpResponse.body.breakpoints[ 1 ].verified ).toBeTrue( + "Breakpoint at line #loc.line# should be verified" + ); + } + + // Clean up + dap.setBreakpoints( variables.targetFile, [] ); + } + + // ========== Different File Types ========== + + function testBreakpointLocationsForSteppingTarget() skip="notSupportsBreakpointLocations" { + var steppingFile = getArtifactPath( "stepping-target.cfm" ); + var response = dap.breakpointLocations( steppingFile, 1, 30 ); + + expect( response.body.breakpoints.len() ).toBeGT( 0, "Should have executable lines" ); + + // Lines inside functions should be executable + var lines = response.body.breakpoints.map( function( loc ) { return loc.line; } ); + + // Line 12 (inside innerFunc) should be executable + expect( lines ).toInclude( 12, "Line 12 in innerFunc should be executable" ); + + // Line 17 (inside outerFunc) should be executable + expect( lines ).toInclude( 17, "Line 17 in outerFunc should be executable" ); + } + + function testBreakpointLocationsForVariablesTarget() skip="notSupportsBreakpointLocations" { + var variablesFile = getArtifactPath( "variables-target.cfm" ); + var response = dap.breakpointLocations( variablesFile, 1, 50 ); + + expect( response.body.breakpoints.len() ).toBeGT( 0, "Should have executable lines" ); + + // Line 35 (debugLine assignment) should be executable + var lines = response.body.breakpoints.map( function( loc ) { return loc.line; } ); + expect( lines ).toInclude( 35, "Line 35 should be executable" ); + } + +} diff --git a/test/cfml/BreakpointsTest.cfc b/test/cfml/BreakpointsTest.cfc new file mode 100644 index 0000000..346a638 --- /dev/null +++ b/test/cfml/BreakpointsTest.cfc @@ -0,0 +1,147 @@ +/** + * Tests for basic breakpoint functionality. + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + // Line numbers in breakpoint-target.cfm - keep in sync with the file + // These are validated in testValidateLineNumbers() using breakpointLocations (native mode only) + variables.lines = { + conditionalFunctionStart: 12, // var result = 0; + ifBlockBody: 14, // result = arguments.value * 2; + elseBlockBody: 16, // result = arguments.value + 5; + writeOutput: 24 // writeOutput( "Done: ..." ); + }; + + function beforeAll() { + setupDap(); + variables.targetFile = getArtifactPath( "breakpoint-target.cfm" ); + } + + // Validate our line number assumptions using breakpointLocations (native mode only) + function testValidateLineNumbers_breakpointTarget() skip="notSupportsBreakpointLocations" { + var locations = dap.breakpointLocations( variables.targetFile, 1, 30 ); + var validLines = locations.body.breakpoints.map( function( bp ) { return bp.line; } ); + + systemOutput( "#variables.targetFile# valid lines: #serializeJSON( validLines )#", true ); + + for ( var key in variables.lines ) { + var line = variables.lines[ key ]; + expect( validLines ).toInclude( line, "#variables.targetFile# line #line# (#key#) should be a valid breakpoint location" ); + } + } + + function afterEach() { + clearBreakpoints( variables.targetFile ); + } + + // ========== Basic Breakpoints ========== + + function testSetBreakpointReturnsVerified() { + var response = dap.setBreakpoints( variables.targetFile, [ lines.conditionalFunctionStart ] ); + + expect( response.body ).toHaveKey( "breakpoints" ); + expect( response.body.breakpoints ).toHaveLength( 1 ); + expect( response.body.breakpoints[ 1 ].verified ).toBeTrue( "Breakpoint should be verified" ); + } + + function testSetMultipleBreakpoints() { + var response = dap.setBreakpoints( variables.targetFile, [ + lines.conditionalFunctionStart, + lines.ifBlockBody, + lines.writeOutput + ] ); + + expect( response.body.breakpoints ).toHaveLength( 3 ); + for ( var bp in response.body.breakpoints ) { + expect( bp.verified ).toBeTrue( "All breakpoints should be verified" ); + } + } + + function testBreakpointHits() { + var bpResponse = dap.setBreakpoints( variables.targetFile, [ lines.conditionalFunctionStart ] ); + + systemOutput( "testBreakpointHits: targetFile=#variables.targetFile#", true ); + systemOutput( "testBreakpointHits: response=#serializeJSON( bpResponse )#", true ); + + expect( bpResponse.body.breakpoints ).toHaveLength( 1, "Should have 1 breakpoint" ); + expect( bpResponse.body.breakpoints[ 1 ].verified ).toBeTrue( "Breakpoint should be verified" ); + + triggerArtifact( "breakpoint-target.cfm" ); + sleep( 500 ); + systemOutput( "testBreakpointHits: httpResult=#serializeJSON( variables.httpResult )#", true ); + expect( variables.httpResult ).notToHaveKey( "error" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + + expect( stopped.body.reason ).toBe( "breakpoint" ); + + var frame = getTopFrame( stopped.body.threadId ); + expect( frame.line ).toBe( lines.conditionalFunctionStart ); + + cleanupThread( stopped.body.threadId ); + } + + function testClearBreakpoints() { + dap.setBreakpoints( variables.targetFile, [ lines.conditionalFunctionStart, lines.ifBlockBody ] ); + + var response = dap.setBreakpoints( variables.targetFile, [] ); + + expect( response.body.breakpoints ).toHaveLength( 0 ); + } + + function testReplaceBreakpoints() { + dap.setBreakpoints( variables.targetFile, [ lines.conditionalFunctionStart, lines.ifBlockBody ] ); + + var response = dap.setBreakpoints( variables.targetFile, [ lines.writeOutput ] ); + + expect( response.body.breakpoints ).toHaveLength( 1 ); + expect( response.body.breakpoints[ 1 ].line ).toBe( lines.writeOutput ); + } + + // ========== Conditional Breakpoints ========== + + function testConditionalBreakpointHits() skip="notSupportsConditionalBreakpoints" { + // Set conditional breakpoint on if block - only hit when value > 10 + dap.setBreakpoints( variables.targetFile, [ lines.ifBlockBody ], [ "arguments.value > 10" ] ); + + // Trigger - conditionalFunction is called with 15 + triggerArtifact( "breakpoint-target.cfm" ); + sleep( 500 ); + systemOutput( "testConditionalBreakpointHits: httpResult=#serializeJSON( variables.httpResult )#", true ); + expect( variables.httpResult ).notToHaveKey( "error" ); + + // Should hit because 15 > 10 + var stopped = dap.waitForEvent( "stopped", 2000 ); + + expect( stopped.body.reason ).toBe( "breakpoint" ); + + var frame = getTopFrame( stopped.body.threadId ); + expect( frame.line ).toBe( lines.ifBlockBody ); + + cleanupThread( stopped.body.threadId ); + } + + function testConditionalBreakpointSkipsWhenFalse() skip="notSupportsConditionalBreakpoints" { + // Set conditional breakpoint that should NOT hit + dap.setBreakpoints( variables.targetFile, [ lines.ifBlockBody ], [ "arguments.value > 100" ] ); + + // Trigger - conditionalFunction is called with 15 + triggerArtifact( "breakpoint-target.cfm" ); + sleep( 500 ); + systemOutput( "testConditionalBreakpointSkipsWhenFalse: httpResult=#serializeJSON( variables.httpResult )#", true ); + expect( variables.httpResult ).notToHaveKey( "error" ); + + // Should NOT hit because 15 is not > 100 + sleep( 2000 ); + + var hasStoppedEvent = dap.hasEvent( "stopped" ); + expect( hasStoppedEvent ).toBeFalse( "Should not stop when condition is false" ); + + waitForHttpComplete(); + } + +} diff --git a/test/cfml/CompletionsTest.cfc b/test/cfml/CompletionsTest.cfc new file mode 100644 index 0000000..9ddb2de --- /dev/null +++ b/test/cfml/CompletionsTest.cfc @@ -0,0 +1,202 @@ +/** + * Tests for debug console completions/autocomplete functionality. + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + // Line numbers in completions-target.cfm - keep in sync with the file + // These are validated in testValidateLineNumbers() using breakpointLocations (native mode only) + variables.lines = { + debugLine: 22 // var stopHere = true; + }; + + function beforeAll() { + setupDap(); + variables.targetFile = getArtifactPath( "completions-target.cfm" ); + } + + // Validate our line number assumptions using breakpointLocations (native mode only) + function testValidateLineNumbers_completionsTarget() skip="notSupportsBreakpointLocations" { + var locations = dap.breakpointLocations( variables.targetFile, 1, 35 ); + var validLines = locations.body.breakpoints.map( function( bp ) { return bp.line; } ); + + systemOutput( "#variables.targetFile# valid lines: #serializeJSON( validLines )#", true ); + + for ( var key in variables.lines ) { + var line = variables.lines[ key ]; + expect( validLines ).toInclude( line, "#variables.targetFile# line #line# (#key#) should be a valid breakpoint location" ); + } + } + + function afterEach() { + clearBreakpoints( variables.targetFile ); + } + + // ========== Basic Completions ========== + + function testCompletionsReturnsResults() skip="notSupportsCompletions" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "completions-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Request completions for "my" + var completionsResponse = dap.completions( frame.id, "my" ); + + expect( completionsResponse.body ).toHaveKey( "targets" ); + expect( completionsResponse.body.targets.len() ).toBeGT( 0, "Should return completions" ); + + cleanupThread( threadId ); + } + + function testCompletionsForLocalVariables() skip="notSupportsCompletions" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "completions-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Request completions for "my" - should include myString, myNumber, myStruct, etc. + var completionsResponse = dap.completions( frame.id, "my" ); + var targets = completionsResponse.body.targets; + + var labels = targets.map( function( t ) { return t.label; } ); + + expect( labels ).toInclude( "myString" ); + expect( labels ).toInclude( "myNumber" ); + expect( labels ).toInclude( "myStruct" ); + expect( labels ).toInclude( "myArray" ); + + cleanupThread( threadId ); + } + + // ========== Dot-Notation Completions ========== + + function testCompletionsForStructKeys() skip="notSupportsCompletions" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "completions-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Request completions for "myStruct." - should show struct keys + var completionsResponse = dap.completions( frame.id, "myStruct." ); + var targets = completionsResponse.body.targets; + + var labels = targets.map( function( t ) { return t.label; } ); + + expect( labels ).toInclude( "firstName" ); + expect( labels ).toInclude( "lastName" ); + expect( labels ).toInclude( "address" ); + + cleanupThread( threadId ); + } + + function testCompletionsForNestedStructKeys() skip="notSupportsCompletions" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "completions-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Request completions for "myStruct.address." - should show nested keys + var completionsResponse = dap.completions( frame.id, "myStruct.address." ); + var targets = completionsResponse.body.targets; + + var labels = targets.map( function( t ) { return t.label; } ); + + expect( labels ).toInclude( "street" ); + expect( labels ).toInclude( "city" ); + + cleanupThread( threadId ); + } + + // ========== Partial Completions ========== + + function testCompletionsWithPartialInput() skip="notSupportsCompletions" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "completions-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Request completions for "myStruct.f" - should filter to firstName + var completionsResponse = dap.completions( frame.id, "myStruct.f" ); + var targets = completionsResponse.body.targets; + + var labels = targets.map( function( t ) { return t.label; } ); + + expect( labels ).toInclude( "firstName" ); + // lastName shouldn't be included (doesn't start with 'f') + expect( labels ).notToInclude( "lastName" ); + + cleanupThread( threadId ); + } + + // ========== Component Method Completions ========== + + function testCompletionsForComponentMethods() skip="notSupportsCompletions" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "completions-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Request completions for "myComponent." - should show component methods + var completionsResponse = dap.completions( frame.id, "myComponent." ); + var targets = completionsResponse.body.targets; + + var labels = targets.map( function( t ) { return t.label; } ); + + // DapClient has methods like connect, disconnect, initialize, etc. + expect( labels ).toInclude( "connect" ); + expect( labels ).toInclude( "disconnect" ); + + cleanupThread( threadId ); + } + + // ========== Empty/Invalid Input ========== + + function testCompletionsWithEmptyInput() skip="notSupportsCompletions" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "completions-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Request completions for empty string - should return local variables + var completionsResponse = dap.completions( frame.id, "" ); + var targets = completionsResponse.body.targets; + + // Should have some completions (local variables, etc.) + expect( targets.len() ).toBeGT( 0, "Empty input should return completions" ); + + cleanupThread( threadId ); + } + +} diff --git a/test/cfml/ConsoleOutputTest.cfc b/test/cfml/ConsoleOutputTest.cfc new file mode 100644 index 0000000..30c7e94 --- /dev/null +++ b/test/cfml/ConsoleOutputTest.cfc @@ -0,0 +1,125 @@ +/** + * Tests for console output streaming (systemOutput to debug console). + * Native mode only - requires consoleOutput attach option. + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + // Line numbers in console-output-target.cfm - keep in sync with the file + // These are validated in testValidateLineNumbers() using breakpointLocations (native mode only) + variables.lines = { + debugLine: 14 // var stopHere = true; + }; + + function beforeAll() { + // Enable consoleOutput for these tests + setupDap( consoleOutput = true ); + variables.targetFile = getArtifactPath( "console-output-target.cfm" ); + } + + // Validate our line number assumptions using breakpointLocations (native mode only) + function testValidateLineNumbers_consoleOutputTarget() skip="notSupportsBreakpointLocations" { + var locations = dap.breakpointLocations( variables.targetFile, 1, 25 ); + var validLines = locations.body.breakpoints.map( function( bp ) { return bp.line; } ); + + systemOutput( "#variables.targetFile# valid lines: #serializeJSON( validLines )#", true ); + + for ( var key in variables.lines ) { + var line = variables.lines[ key ]; + expect( validLines ).toInclude( line, "#variables.targetFile# line #line# (#key#) should be a valid breakpoint location" ); + } + } + + function afterEach() { + clearBreakpoints( variables.targetFile ); + // Drain any remaining output events + dap.drainEvents(); + } + + // ========== Console Output Events ========== + + function testSystemOutputSendsOutputEvent() skip="notNativeMode" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + // Trigger with a unique message + var testMessage = "test-output-#createUUID()#"; + triggerArtifact( "console-output-target.cfm", { message: testMessage } ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Check for output event containing our message + // The systemOutput should have been captured before we hit the breakpoint + var events = dap.drainEvents(); + var foundOutput = false; + + for ( var event in events ) { + if ( event.event == "output" && event.body.output contains testMessage ) { + foundOutput = true; + systemOutput( "Found output event: #serializeJSON( event )#", true ); + break; + } + } + + expect( foundOutput ).toBeTrue( "Should receive output event with test message" ); + + cleanupThread( threadId ); + } + + function testOutputEventHasStdoutCategory() skip="notNativeMode" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + var testMessage = "category-test-#createUUID()#"; + triggerArtifact( "console-output-target.cfm", { message: testMessage } ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var events = dap.drainEvents(); + var outputEvent = {}; + + for ( var event in events ) { + if ( event.event == "output" && event.body.output contains testMessage ) { + outputEvent = event; + break; + } + } + + expect( outputEvent ).toHaveKey( "body" ); + expect( outputEvent.body ).toHaveKey( "category" ); + expect( outputEvent.body.category ).toBe( "stdout" ); + + cleanupThread( threadId ); + } + + function testMultipleOutputEvents() skip="notNativeMode" { + // This test verifies that multiple systemOutput calls each generate events + // The target file only has one systemOutput, so we'll just verify the mechanism works + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + var testMessage = "multi-test-#createUUID()#"; + triggerArtifact( "console-output-target.cfm", { message: testMessage } ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var events = dap.drainEvents(); + var outputCount = 0; + + for ( var event in events ) { + if ( event.event == "output" ) { + outputCount++; + systemOutput( "Output event ##: #outputCount# - #event.body.output#", true ); + } + } + + // Should have at least one output event + expect( outputCount ).toBeGTE( 1, "Should have at least one output event" ); + + cleanupThread( threadId ); + } + +} diff --git a/test/cfml/DapClient.cfc b/test/cfml/DapClient.cfc new file mode 100644 index 0000000..4740623 --- /dev/null +++ b/test/cfml/DapClient.cfc @@ -0,0 +1,433 @@ +/** + * DAP (Debug Adapter Protocol) client for testing luceedebug. + * + * Usage: + * dap = new DapClient(); + * dap.connect( "localhost", 10000 ); + * dap.initialize(); + * dap.setBreakpoints( "/path/to/file.cfm", [ 10, 20 ] ); + * dap.configurationDone(); + * // ... trigger breakpoint via HTTP ... + * event = dap.waitForEvent( "stopped", 2000 ); + * stack = dap.stackTrace( event.body.threadId ); + * dap.continueThread( event.body.threadId ); + * dap.disconnect(); + */ +component { + + variables.socket = javacast( "null", 0 ); + variables.inputStream = javacast( "null", 0 ); + variables.outputStream = javacast( "null", 0 ); + variables.seq = 0; + variables.eventQueue = []; + variables.pendingResponses = {}; + variables.debug = false; + + public function init( boolean debug = false ) { + variables.debug = arguments.debug; + return this; + } + + // ========== Connection ========== + + public function connect( required string host, required numeric port ) { + variables.socket = createObject( "java", "java.net.Socket" ).init( arguments.host, arguments.port ); + variables.socket.setSoTimeout( 100 ); // 100ms read timeout for polling + variables.inputStream = variables.socket.getInputStream(); + variables.outputStream = variables.socket.getOutputStream(); + debugLog( "Connected to #arguments.host#:#arguments.port#" ); + } + + public function disconnect() { + if ( !isNull( variables.socket ) ) { + variables.socket.close(); + variables.socket = javacast( "null", 0 ); + debugLog( "Disconnected" ); + } + } + + public boolean function isConnected() { + return !isNull( variables.socket ) && variables.socket.isConnected() && !variables.socket.isClosed(); + } + + // ========== DAP Commands ========== + + public struct function initialize() { + var response = sendRequest( "initialize", { + "clientID": "cfml-dap-test", + "adapterID": "luceedebug", + "pathFormat": "path", + "linesStartAt1": true, + "columnsStartAt1": true + } ); + return response; + } + + public struct function attach( required string secret, struct pathTransforms = {}, boolean consoleOutput = false ) { + var args = { + "secret": arguments.secret + }; + if ( !structIsEmpty( arguments.pathTransforms ) ) { + args[ "pathTransforms" ] = arguments.pathTransforms; + } + if ( arguments.consoleOutput ) { + args[ "consoleOutput" ] = true; + } + var response = sendRequest( "attach", args ); + // Wait for initialized event from server + waitForEvent( "initialized", 5000 ); + return response; + } + + public struct function setBreakpoints( required string path, required array lines, array conditions = [] ) { + var breakpoints = []; + for ( var i = 1; i <= arguments.lines.len(); i++ ) { + var bp = { "line": arguments.lines[ i ] }; + if ( arguments.conditions.len() >= i && len( arguments.conditions[ i ] ) ) { + bp[ "condition" ] = arguments.conditions[ i ]; + } + breakpoints.append( bp ); + } + var response = sendRequest( "setBreakpoints", { + "source": { "path": arguments.path }, + "breakpoints": breakpoints + } ); + systemOutput( "setBreakpoints: path=#arguments.path# lines=#serializeJSON( arguments.lines )# response=#serializeJSON( response )#", true ); + return response; + } + + public struct function setFunctionBreakpoints( required array names, array conditions = [] ) { + var breakpoints = []; + for ( var i = 1; i <= arguments.names.len(); i++ ) { + var bp = { "name": arguments.names[ i ] }; + if ( arguments.conditions.len() >= i && len( arguments.conditions[ i ] ) ) { + bp[ "condition" ] = arguments.conditions[ i ]; + } + breakpoints.append( bp ); + } + var response = sendRequest( "setFunctionBreakpoints", { + "breakpoints": breakpoints + } ); + return response; + } + + public struct function setExceptionBreakpoints( required array filters ) { + var response = sendRequest( "setExceptionBreakpoints", { + "filters": arguments.filters + } ); + return response; + } + + public struct function breakpointLocations( required string path, required numeric line, numeric endLine = 0 ) { + var args = { + "source": { "path": arguments.path }, + "line": arguments.line + }; + if ( arguments.endLine > 0 ) { + args[ "endLine" ] = arguments.endLine; + } + var response = sendRequest( "breakpointLocations", args ); + return response; + } + + public struct function configurationDone() { + return sendRequest( "configurationDone", {} ); + } + + public struct function threads() { + return sendRequest( "threads", {} ); + } + + public struct function stackTrace( required numeric threadId, numeric startFrame = 0, numeric levels = 20 ) { + return sendRequest( "stackTrace", { + "threadId": arguments.threadId, + "startFrame": arguments.startFrame, + "levels": arguments.levels + } ); + } + + public struct function scopes( required numeric frameId ) { + return sendRequest( "scopes", { + "frameId": arguments.frameId + } ); + } + + public struct function getVariables( required numeric variablesReference ) { + return sendRequest( "variables", { + "variablesReference": arguments.variablesReference + } ); + } + + public struct function setVariable( required numeric variablesReference, required string name, required string value ) { + return sendRequest( "setVariable", { + "variablesReference": arguments.variablesReference, + "name": arguments.name, + "value": arguments.value + } ); + } + + public struct function continueThread( required numeric threadId ) { + systemOutput( "continueThread: threadId=#arguments.threadId#", true ); + var response = sendRequest( "continue", { + "threadId": arguments.threadId + } ); + systemOutput( "continueThread: response=#serializeJSON( response )#", true ); + return response; + } + + public struct function stepOver( required numeric threadId ) { + systemOutput( "stepOver: threadId=#arguments.threadId#", true ); + var response = sendRequest( "next", { + "threadId": arguments.threadId + } ); + systemOutput( "stepOver: response=#serializeJSON( response )#", true ); + return response; + } + + public struct function stepIn( required numeric threadId ) { + systemOutput( "stepIn: threadId=#arguments.threadId#", true ); + var response = sendRequest( "stepIn", { + "threadId": arguments.threadId + } ); + systemOutput( "stepIn: response=#serializeJSON( response )#", true ); + return response; + } + + public struct function stepOut( required numeric threadId ) { + systemOutput( "stepOut: threadId=#arguments.threadId#", true ); + var response = sendRequest( "stepOut", { + "threadId": arguments.threadId + } ); + systemOutput( "stepOut: response=#serializeJSON( response )#", true ); + return response; + } + + public struct function evaluate( required numeric frameId, required string expression, string context = "watch" ) { + return sendRequest( "evaluate", { + "frameId": arguments.frameId, + "expression": arguments.expression, + "context": arguments.context + } ); + } + + public struct function completions( required numeric frameId, required string text, numeric column = 0 ) { + return sendRequest( "completions", { + "frameId": arguments.frameId, + "text": arguments.text, + "column": arguments.column > 0 ? arguments.column : len( arguments.text ) + 1 + } ); + } + + public struct function exceptionInfo( required numeric threadId ) { + return sendRequest( "exceptionInfo", { + "threadId": arguments.threadId + } ); + } + + public struct function dapDisconnect() { + return sendRequest( "disconnect", {} ); + } + + // ========== Event Handling ========== + + /** + * Wait for a specific event type. + * @eventType The event type to wait for (e.g., "stopped", "thread") + * @timeoutMs Maximum time to wait in milliseconds + * @return The event struct, or throws if timeout + */ + public struct function waitForEvent( required string eventType, numeric timeoutMs = 5000 ) { + var startTime = getTickCount(); + + // Give the server a moment to process and send the event + sleep( 50 ); + + while ( getTickCount() - startTime < arguments.timeoutMs ) { + // Poll for new messages first + pollMessages(); + + // Check queued events + for ( var i = 1; i <= variables.eventQueue.len(); i++ ) { + if ( variables.eventQueue[ i ].event == arguments.eventType ) { + var event = variables.eventQueue[ i ]; + variables.eventQueue.deleteAt( i ); + systemOutput( "waitForEvent: found #arguments.eventType# event=#serializeJSON( event )#", true ); + return event; + } + } + + sleep( 10 ); + } + + throw( type="DapClient.Timeout", message="Timeout waiting for event: #arguments.eventType#" ); + } + + /** + * Check if any events of a type are queued. + */ + public boolean function hasEvent( required string eventType ) { + pollMessages(); + for ( var event in variables.eventQueue ) { + if ( event.event == arguments.eventType ) { + return true; + } + } + return false; + } + + /** + * Get all queued events (clears the queue). + */ + public array function drainEvents() { + pollMessages(); + var events = variables.eventQueue; + variables.eventQueue = []; + return events; + } + + // ========== Protocol Implementation ========== + + private struct function sendRequest( required string command, required struct args ) { + var requestSeq = ++variables.seq; + var dapRequest = { + "seq": requestSeq, + "type": "request", + "command": arguments.command, + "arguments": arguments.args + }; + + sendMessage( dapRequest ); + + // Wait for response with matching request_seq + var startTime = getTickCount(); + var timeout = 10000; // 10 second timeout for responses + + while ( getTickCount() - startTime < timeout ) { + pollMessages(); + + if ( variables.pendingResponses.keyExists( requestSeq ) ) { + var response = variables.pendingResponses[ requestSeq ]; + variables.pendingResponses.delete( requestSeq ); + + if ( !response.success ) { + throw( type="DapClient.Error", message="DAP error: #response.message ?: 'unknown'#" ); + } + + return response; + } + + sleep( 10 ); + } + + throw( type="DapClient.Timeout", message="Timeout waiting for response to: #arguments.command#" ); + } + + private void function sendMessage( required struct message ) { + var json = serializeJSON( arguments.message ); + var bytes = json.getBytes( "UTF-8" ); + var CRLF = chr( 13 ) & chr( 10 ); + var header = "Content-Length: #arrayLen( bytes )#" & CRLF & CRLF; + + debugLog( ">>> #json#" ); + + variables.outputStream.write( header.getBytes( "UTF-8" ) ); + variables.outputStream.write( bytes ); + variables.outputStream.flush(); + } + + private void function pollMessages() { + try { + while ( variables.inputStream.available() > 0 ) { + var message = readMessage(); + if ( !isNull( message ) ) { + handleMessage( message ); + } + } + } catch ( any e ) { + // Socket timeout is expected, ignore + if ( !e.message contains "timed out" && !e.message contains "Read timed out" ) { + rethrow; + } + } + } + + private any function readMessage() { + // Read headers until empty line + var headers = {}; + var headerLine = readLine(); + + while ( headerLine != "" ) { + var colonPos = headerLine.find( ":" ); + if ( colonPos > 0 ) { + var key = headerLine.left( colonPos - 1 ).trim(); + var value = headerLine.mid( colonPos + 1, headerLine.len() ).trim(); + headers[ key ] = value; + } + headerLine = readLine(); + } + + if ( !headers.keyExists( "Content-Length" ) ) { + return javacast( "null", 0 ); + } + + // Read body + var contentLength = val( headers[ "Content-Length" ] ); + var bodyBytes = createObject( "java", "java.io.ByteArrayOutputStream" ).init(); + var remaining = contentLength; + + while ( remaining > 0 ) { + var b = variables.inputStream.read(); + if ( b == -1 ) { + throw( type="DapClient.Error", message="Unexpected end of stream" ); + } + bodyBytes.write( b ); + remaining--; + } + + var json = bodyBytes.toString( "UTF-8" ); + debugLog( "<<< #json#" ); + + return deserializeJSON( json ); + } + + private string function readLine() { + var line = createObject( "java", "java.lang.StringBuilder" ).init(); + var prevChar = 0; + + while ( true ) { + var b = variables.inputStream.read(); + if ( b == -1 ) { + break; + } + var c = chr( b ); + if ( c == chr( 10 ) ) { // LF + break; + } + if ( c != chr( 13 ) ) { // Skip CR + line.append( c ); + } + } + + return line.toString(); + } + + private void function handleMessage( required struct message ) { + switch ( arguments.message.type ) { + case "response": + variables.pendingResponses[ arguments.message.request_seq ] = arguments.message; + break; + case "event": + variables.eventQueue.append( arguments.message ); + break; + default: + debugLog( "Unknown message type: #arguments.message.type#" ); + } + } + + private void function debugLog( required string msg ) { + if ( variables.debug ) { + systemOutput( "[DapClient] #arguments.msg#", true ); + } + } + +} diff --git a/test/cfml/DapTestCase.cfc b/test/cfml/DapTestCase.cfc new file mode 100644 index 0000000..8db9ddd --- /dev/null +++ b/test/cfml/DapTestCase.cfc @@ -0,0 +1,222 @@ +/** + * Base test case for DAP (Debug Adapter Protocol) tests. + * + * Provides connection management, feature detection, and helper methods. + * + * Configuration via environment variables: + * DAP_HOST - debugger host (default: localhost) + * DAP_PORT - debugger port (default: 10000) + * DEBUGGEE_HTTP - HTTP URL of debuggee (default: http://localhost:8888) + * DEBUGGEE_ARTIFACT_PATH - filesystem path to artifacts on debuggee (default: same as test runner) + * DAP_DEBUG - enable debug logging (default: false) + */ +component extends="org.lucee.cfml.test.LuceeTestCase" { + + variables.dap = javacast( "null", 0 ); + variables.capabilities = {}; + variables.dapHost = ""; + variables.dapPort = 0; + variables.debuggeeHttp = ""; + variables.debuggeeArtifactPath = ""; + variables.httpThread = ""; + variables.httpResult = {}; + + function beforeAll() { + // Load config from environment + variables.dapHost = server.system.environment.DAP_HOST ?: "localhost"; + variables.dapPort = val( server.system.environment.DAP_PORT ?: 10000 ); + variables.debuggeeHttp = server.system.environment.DEBUGGEE_HTTP ?: "http://localhost:8888"; + variables.debuggeeArtifactPath = server.system.environment.DEBUGGEE_ARTIFACT_PATH ?: ""; + var debug = ( server.system.environment.DAP_DEBUG ?: "false" ) == "true"; + + systemOutput( "DapTestCase: Connecting to #variables.dapHost#:#variables.dapPort#", true ); + systemOutput( "DapTestCase: Debuggee HTTP at #variables.debuggeeHttp#", true ); + if ( len( variables.debuggeeArtifactPath ) ) { + systemOutput( "DapTestCase: Debuggee artifact path: #variables.debuggeeArtifactPath#", true ); + } + + // Connect to DAP server + variables.dap = new DapClient( debug = debug ); + variables.dap.connect( variables.dapHost, variables.dapPort ); + + // Initialize and store capabilities + var initResponse = variables.dap.initialize(); + variables.capabilities = initResponse.body ?: {}; + + systemOutput( "DapTestCase: Capabilities: #serializeJSON( variables.capabilities )#", true ); + + // Send configurationDone + variables.dap.configurationDone(); + } + + function afterAll() { + if ( !isNull( variables.dap ) && variables.dap.isConnected() ) { + try { + variables.dap.dapDisconnect(); + } catch ( any e ) { + // Ignore disconnect errors + } + variables.dap.disconnect(); + } + } + + // ========== Capability Detection ========== + + public struct function getCapabilities() { + return variables.capabilities; + } + + public boolean function supportsConditionalBreakpoints() { + return variables.capabilities.supportsConditionalBreakpoints ?: false; + } + + public boolean function supportsSetVariable() { + return variables.capabilities.supportsSetVariable ?: false; + } + + public boolean function supportsCompletions() { + return variables.capabilities.supportsCompletionsRequest ?: false; + } + + public boolean function supportsFunctionBreakpoints() { + return variables.capabilities.supportsFunctionBreakpoints ?: false; + } + + public boolean function supportsBreakpointLocations() { + return variables.capabilities.supportsBreakpointLocationsRequest ?: false; + } + + public boolean function supportsExceptionInfo() { + return variables.capabilities.supportsExceptionInfoRequest ?: false; + } + + public boolean function supportsEvaluate() { + return variables.capabilities.supportsEvaluateForHovers ?: false; + } + + // ========== Test Helpers ========== + + /** + * Get the server path for a test artifact. + * Uses DEBUGGEE_ARTIFACT_PATH if set, otherwise assumes same location as test runner. + */ + public string function getArtifactPath( required string filename ) { + if ( len( variables.debuggeeArtifactPath ) ) { + return variables.debuggeeArtifactPath & arguments.filename; + } + var testDir = getDirectoryFromPath( getCurrentTemplatePath() ); + return testDir & "artifacts/" & arguments.filename; + } + + /** + * Get the HTTP URL for a test artifact. + */ + public string function getArtifactUrl( required string filename ) { + return variables.debuggeeHttp & "/test/cfml/artifacts/" & arguments.filename; + } + + /** + * Trigger an HTTP request to an artifact in a background thread. + * Use waitForHttpComplete() to wait for completion. + */ + public void function triggerArtifact( required string filename, struct params = {} ) { + var url = getArtifactUrl( arguments.filename ); + var queryString = ""; + + for ( var key in arguments.params ) { + queryString &= ( len( queryString ) ? "&" : "?" ) & encodeForURL( key ) & "=" & encodeForURL( arguments.params[ key ] ); + } + + url &= queryString; + variables.httpResult = {}; + variables.httpThread = "httpTrigger_" & createUUID(); + + thread name="#variables.httpThread#" url=url httpResult=variables.httpResult { + try { + http url="#url#" result="local.r" timeout=60; + httpResult.status = local.r.statusCode; + httpResult.content = local.r.fileContent; + } catch ( any e ) { + httpResult.error = e.message; + } + } + } + + /** + * Wait for the background HTTP request to complete. + */ + public struct function waitForHttpComplete( numeric timeout = 30000 ) { + if ( len( variables.httpThread ) ) { + threadJoin( variables.httpThread, arguments.timeout ); + variables.httpThread = ""; + } + return variables.httpResult; + } + + /** + * Get the top stack frame from a stopped thread. + */ + public struct function getTopFrame( required numeric threadId ) { + var stackResponse = variables.dap.stackTrace( arguments.threadId ); + var frames = stackResponse.body.stackFrames ?: []; + if ( frames.len() == 0 ) { + throw( type="DapTestCase.Error", message="No stack frames available" ); + } + return frames[ 1 ]; + } + + /** + * Get a scope by name from a frame. + */ + public struct function getScopeByName( required numeric frameId, required string scopeName ) { + var scopesResponse = variables.dap.scopes( arguments.frameId ); + var scopes = scopesResponse.body.scopes ?: []; + for ( var scope in scopes ) { + if ( scope.name == arguments.scopeName ) { + return scope; + } + } + throw( type="DapTestCase.Error", message="Scope not found: #arguments.scopeName#" ); + } + + /** + * Get a variable by name from a variables reference. + */ + public struct function getVariableByName( required numeric variablesReference, required string name ) { + var varsResponse = variables.dap.getVariables( arguments.variablesReference ); + var vars = varsResponse.body.variables ?: []; + for ( var v in vars ) { + if ( v.name == arguments.name ) { + return v; + } + } + throw( type="DapTestCase.Error", message="Variable not found: #arguments.name#" ); + } + + /** + * Clean up after a test - ensure thread is continued. + */ + public void function cleanupThread( required numeric threadId ) { + try { + variables.dap.continueThread( arguments.threadId ); + } catch ( any e ) { + // Ignore - thread may already be running + } + waitForHttpComplete(); + } + + /** + * Clear all breakpoints for a file. + */ + public void function clearBreakpoints( required string path ) { + variables.dap.setBreakpoints( arguments.path, [] ); + } + + /** + * Clear all function breakpoints. + */ + public void function clearFunctionBreakpoints() { + variables.dap.setFunctionBreakpoints( [] ); + } + +} diff --git a/test/cfml/DapTestCase.cfm b/test/cfml/DapTestCase.cfm new file mode 100644 index 0000000..793a56c --- /dev/null +++ b/test/cfml/DapTestCase.cfm @@ -0,0 +1,239 @@ + +/** + * Includable DAP test helper functions. + * Include this in tests that extend LuceeTestCase directly. + */ + +variables.dap = javacast( "null", 0 ); +variables.capabilities = {}; +variables.dapHost = ""; +variables.dapPort = 0; +variables.debuggeeHttp = ""; +// Initialize debuggeeArtifactPath at include-time so getArtifactPath() works before setupDap() +variables.debuggeeArtifactPath = server.system.environment.DEBUGGEE_ARTIFACT_PATH ?: ""; +variables.httpThread = ""; +variables.httpResult = {}; + +function setupDap( boolean attach = true, boolean consoleOutput = false ) { + variables.dapHost = server.system.environment.DAP_HOST ?: "localhost"; + variables.dapPort = val( server.system.environment.DAP_PORT ?: 10000 ); + variables.debuggeeHttp = server.system.environment.DEBUGGEE_HTTP ?: "http://localhost:8888"; + variables.debuggeeArtifactPath = server.system.environment.DEBUGGEE_ARTIFACT_PATH ?: ""; + variables.dapSecret = server.system.environment.DAP_SECRET ?: "testing"; + var debug = ( server.system.environment.DAP_DEBUG ?: "false" ) == "true"; + + // Clean up any stale connection from a previous test that may have failed + if ( structKeyExists( server, "_dapTestClient" ) && !isNull( server._dapTestClient ) ) { + systemOutput( "DapTestCase: Cleaning up stale DAP connection", true ); + try { + if ( server._dapTestClient.isConnected() ) { + try { server._dapTestClient.dapDisconnect(); } catch ( any e ) {} + server._dapTestClient.disconnect(); + } + } catch ( any e ) { + // Ignore cleanup errors + } + server._dapTestClient = javacast( "null", 0 ); + } + + systemOutput( "DapTestCase: Connecting to #variables.dapHost#:#variables.dapPort#", true ); + systemOutput( "DapTestCase: Debuggee HTTP at #variables.debuggeeHttp#", true ); + if ( len( variables.debuggeeArtifactPath ) ) { + systemOutput( "DapTestCase: Debuggee artifact path: #variables.debuggeeArtifactPath#", true ); + } + + variables.dap = new DapClient( debug = debug ); + variables.dap.connect( variables.dapHost, variables.dapPort ); + + // Stash in server scope for cleanup by next test if this one fails + server._dapTestClient = variables.dap; + + var initResponse = variables.dap.initialize(); + variables.capabilities = initResponse.body ?: {}; + + systemOutput( "DapTestCase: Capabilities: #serializeJSON( variables.capabilities )#", true ); + + if ( arguments.attach ) { + variables.dap.attach( variables.dapSecret, {}, arguments.consoleOutput ); + variables.dap.configurationDone(); + } +} + +function teardownDap() { + // Optional - can still be called explicitly, but setupDap handles cleanup too + if ( !isNull( variables.dap ) && variables.dap.isConnected() ) { + try { + variables.dap.dapDisconnect(); + } catch ( any e ) { + } + variables.dap.disconnect(); + } + if ( structKeyExists( server, "_dapTestClient" ) ) { + server._dapTestClient = javacast( "null", 0 ); + } +} + +function getCapabilities() { + return variables.capabilities; +} + +function supportsConditionalBreakpoints() { + return variables.capabilities.supportsConditionalBreakpoints ?: false; +} +function notSupportsConditionalBreakpoints() { + return !supportsConditionalBreakpoints(); +} + +function supportsSetVariable() { + return variables.capabilities.supportsSetVariable ?: false; +} +function notSupportsSetVariable() { + return !supportsSetVariable(); +} + +function supportsCompletions() { + return variables.capabilities.supportsCompletionsRequest ?: false; +} +function notSupportsCompletions() { + return !supportsCompletions(); +} + +function supportsFunctionBreakpoints() { + return variables.capabilities.supportsFunctionBreakpoints ?: false; +} +function notSupportsFunctionBreakpoints() { + return !supportsFunctionBreakpoints(); +} + +function supportsBreakpointLocations() { + return variables.capabilities.supportsBreakpointLocationsRequest ?: false; +} +function notSupportsBreakpointLocations() { + return !supportsBreakpointLocations(); +} + +function supportsExceptionInfo() { + return variables.capabilities.supportsExceptionInfoRequest ?: false; +} +function notSupportsExceptionInfo() { + return !supportsExceptionInfo(); +} + +function supportsExceptionBreakpoints() { + var filters = variables.capabilities.exceptionBreakpointFilters ?: []; + return arrayLen( filters ) > 0; +} +function notSupportsExceptionBreakpoints() { + return !supportsExceptionBreakpoints(); +} + +function supportsEvaluate() { + return variables.capabilities.supportsEvaluateForHovers ?: false; +} +function notSupportsEvaluate() { + return !supportsEvaluate(); +} + +// Native mode (Lucee 7.1+) - has breakpointLocations and consoleOutput support +function isNativeMode() { + return variables.capabilities.supportsBreakpointLocationsRequest ?: false; +} +function notNativeMode() { + return !isNativeMode(); +} + +function getArtifactPath( required string filename ) { + if ( len( variables.debuggeeArtifactPath ) ) { + return variables.debuggeeArtifactPath & arguments.filename; + } + var testDir = getDirectoryFromPath( getCurrentTemplatePath() ); + return testDir & "artifacts/" & arguments.filename; +} + +function getArtifactUrl( required string filename ) { + return variables.debuggeeHttp & "/test/cfml/artifacts/" & arguments.filename; +} + +function triggerArtifact( required string filename, struct params = {}, boolean allowErrors = false ) { + var requestUrl = getArtifactUrl( arguments.filename ); + var queryString = ""; + + for ( var key in arguments.params ) { + queryString &= ( len( queryString ) ? "&" : "?" ) & urlEncodedFormat( key ) & "=" & urlEncodedFormat( arguments.params[ key ] ); + } + + requestUrl &= queryString; + variables.httpResult = {}; + variables.httpThread = "httpTrigger_" & createUUID(); + + thread name="#variables.httpThread#" requestUrl=requestUrl httpResult=variables.httpResult allowErrors=arguments.allowErrors { + try { + systemOutput( "triggerArtifact: #attributes.requestUrl#", true ); + http url="#attributes.requestUrl#" result="local.r" timeout=60 throwonerror=!attributes.allowErrors; + + httpResult.status = local.r.statusCode; + httpResult.content = local.r.fileContent; + } catch ( any e ) { + systemOutput( "triggerArtifact HTTP error: #e.message#", true ); + systemOutput( e, true ); + httpResult.error = e.message; + } + } +} + +function waitForHttpComplete( numeric timeout = 30000 ) { + if ( len( variables.httpThread ) ) { + threadJoin( variables.httpThread, arguments.timeout ); + variables.httpThread = ""; + } + return variables.httpResult; +} + +function getTopFrame( required numeric threadId ) { + var stackResponse = variables.dap.stackTrace( arguments.threadId ); + systemOutput( "getTopFrame: threadId=#arguments.threadId# response=#serializeJSON( stackResponse )#", true ); + var frames = stackResponse.body.stackFrames ?: []; + if ( frames.len() == 0 ) { + throw( type="DapTestCase.Error", message="No stack frames available" ); + } + return frames[ 1 ]; +} + +function getScopeByName( required numeric frameId, required string scopeName ) { + var scopesResponse = variables.dap.scopes( arguments.frameId ); + var scopes = scopesResponse.body.scopes ?: []; + for ( var scope in scopes ) { + if ( scope.name == arguments.scopeName ) { + return scope; + } + } + throw( type="DapTestCase.Error", message="Scope not found: #arguments.scopeName#, available scopes: #serializeJSON(scopes)#" ); +} + +function getVariableByName( required numeric variablesReference, required string name ) { + var varsResponse = variables.dap.getVariables( arguments.variablesReference ); + var vars = varsResponse.body.variables ?: []; + for ( var v in vars ) { + if ( v.name == arguments.name ) { + return v; + } + } + throw( type="DapTestCase.Error", message="Variable not found: #arguments.name#, available variables: #serializeJSON(vars)#" ); +} + +function cleanupThread( required numeric threadId ) { + try { + variables.dap.continueThread( arguments.threadId ); + } catch ( any e ) { + } + waitForHttpComplete(); +} + +function clearBreakpoints( required string path ) { + variables.dap.setBreakpoints( arguments.path, [] ); +} + +function clearFunctionBreakpoints() { + variables.dap.setFunctionBreakpoints( [] ); +} + diff --git a/test/cfml/EvaluateTest.cfc b/test/cfml/EvaluateTest.cfc new file mode 100644 index 0000000..5eb7b91 --- /dev/null +++ b/test/cfml/EvaluateTest.cfc @@ -0,0 +1,246 @@ +/** + * Tests for evaluate/expression functionality. + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + // Line numbers in evaluate-target.cfm - keep in sync with the file + // These are validated in testValidateLineNumbers() using breakpointLocations (native mode only) + variables.lines = { + debugLine: 26 // return localVar & " - " & dataName; + }; + + function beforeAll() { + setupDap(); + variables.targetFile = getArtifactPath( "evaluate-target.cfm" ); + } + + // Validate our line number assumptions using breakpointLocations (native mode only) + function testValidateLineNumbers_evaluateTarget() skip="notSupportsBreakpointLocations" { + var locations = dap.breakpointLocations( variables.targetFile, 1, 40 ); + var validLines = locations.body.breakpoints.map( function( bp ) { return bp.line; } ); + + systemOutput( "#variables.targetFile# valid lines: #serializeJSON( validLines )#", true ); + + for ( var key in variables.lines ) { + var line = variables.lines[ key ]; + expect( validLines ).toInclude( line, "#variables.targetFile# line #line# (#key#) should be a valid breakpoint location" ); + } + } + + function afterEach() { + clearBreakpoints( variables.targetFile ); + } + + // ========== Basic Evaluate ========== + + function testEvaluateSimpleExpression() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate a simple expression + var evalResponse = dap.evaluate( frame.id, "1 + 1" ); + + expect( evalResponse.body.result ).toBe( "2" ); + + cleanupThread( threadId ); + } + + function testEvaluateLocalVariable() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate local variable + var evalResponse = dap.evaluate( frame.id, "localVar" ); + + expect( evalResponse.body.result ).toBe( '"local-value"' ); + + cleanupThread( threadId ); + } + + function testEvaluateNumericVariable() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate numeric variable + var evalResponse = dap.evaluate( frame.id, "localNum" ); + + expect( evalResponse.body.result ).toBe( "100" ); + + cleanupThread( threadId ); + } + + // ========== Struct/Array Access ========== + + function testEvaluateStructKey() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate struct key access + var evalResponse = dap.evaluate( frame.id, "localStruct.key1" ); + + expect( evalResponse.body.result ).toBe( '"value1"' ); + + cleanupThread( threadId ); + } + + function testEvaluateNestedStructKey() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate nested struct access + var evalResponse = dap.evaluate( frame.id, "localStruct.nested.inner" ); + + expect( evalResponse.body.result ).toBe( '"deep"' ); + + cleanupThread( threadId ); + } + + function testEvaluateArrayElement() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate array element + var evalResponse = dap.evaluate( frame.id, "localArray[2]" ); + + expect( evalResponse.body.result ).toBe( "20" ); + + cleanupThread( threadId ); + } + + // ========== Function Arguments ========== + + function testEvaluateArgumentsScope() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate argument access + var evalResponse = dap.evaluate( frame.id, "arguments.data.name" ); + + expect( evalResponse.body.result ).toBe( '"test-input"' ); + + cleanupThread( threadId ); + } + + // ========== Expressions ========== + + function testEvaluateStringConcatenation() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate string concatenation + var evalResponse = dap.evaluate( frame.id, "localVar & ' - ' & dataName" ); + + expect( evalResponse.body.result ).toBe( '"local-value - test-input"' ); + + cleanupThread( threadId ); + } + + function testEvaluateMathExpression() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate math expression + var evalResponse = dap.evaluate( frame.id, "localNum * 2 + 50" ); + + expect( evalResponse.body.result ).toBe( "250" ); + + cleanupThread( threadId ); + } + + // ========== Built-in Functions ========== + + function testEvaluateBuiltInFunction() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate built-in function + var evalResponse = dap.evaluate( frame.id, "len(localVar)" ); + + expect( evalResponse.body.result ).toBe( "11" ); // "local-value" = 11 chars + + cleanupThread( threadId ); + } + + function testEvaluateArrayLen() skip="notSupportsEvaluate" { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "evaluate-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + + // Evaluate array length + var evalResponse = dap.evaluate( frame.id, "arrayLen(localArray)" ); + + expect( evalResponse.body.result ).toBe( "3" ); + + cleanupThread( threadId ); + } + +} diff --git a/test/cfml/ExceptionBreakpointsTest.cfc b/test/cfml/ExceptionBreakpointsTest.cfc new file mode 100644 index 0000000..43b8ce9 --- /dev/null +++ b/test/cfml/ExceptionBreakpointsTest.cfc @@ -0,0 +1,188 @@ +/** + * Tests for exception breakpoints (break on uncaught exceptions). + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + function beforeAll() { + setupDap(); + variables.targetFile = getArtifactPath( "exception-target.cfm" ); + } + + function afterEach() { + // Clear exception breakpoints + dap.setExceptionBreakpoints( [] ); + clearBreakpoints( variables.targetFile ); + } + + // ========== Set Exception Breakpoints ========== + + function testSetExceptionBreakpoints() { + var response = dap.setExceptionBreakpoints( [ "uncaught" ] ); + + // Response should indicate success + expect( response.success ).toBeTrue(); + } + + function testClearExceptionBreakpoints() { + // Set then clear + dap.setExceptionBreakpoints( [ "uncaught" ] ); + var response = dap.setExceptionBreakpoints( [] ); + + expect( response.success ).toBeTrue(); + } + + // ========== Uncaught Exception Breakpoint ========== + + function testUncaughtExceptionBreakpointHits() skip="notSupportsExceptionBreakpoints" { + dap.setExceptionBreakpoints( [ "uncaught" ] ); + + // Trigger uncaught exception + triggerArtifact( "exception-target.cfm", { throwException: true, catchException: false }, true ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + + expect( stopped.body.reason ).toBe( "exception" ); + + cleanupThread( stopped.body.threadId ); + } + + function testCaughtExceptionDoesNotTriggerUncaughtBreakpoint() skip="notSupportsExceptionBreakpoints" { + dap.setExceptionBreakpoints( [ "uncaught" ] ); + + // Trigger caught exception + triggerArtifact( "exception-target.cfm", { throwException: true, catchException: true } ); + + // Should NOT stop + sleep( 2000 ); + var hasStoppedEvent = dap.hasEvent( "stopped" ); + expect( hasStoppedEvent ).toBeFalse( "Caught exception should not trigger uncaught breakpoint" ); + + waitForHttpComplete(); + } + + function testNoExceptionDoesNotTriggerBreakpoint() skip="notSupportsExceptionBreakpoints" { + dap.setExceptionBreakpoints( [ "uncaught" ] ); + + // Trigger without exception + triggerArtifact( "exception-target.cfm", { throwException: false, catchException: false } ); + + // Should NOT stop + sleep( 2000 ); + var hasStoppedEvent = dap.hasEvent( "stopped" ); + expect( hasStoppedEvent ).toBeFalse( "No exception should not trigger breakpoint" ); + + waitForHttpComplete(); + } + + // ========== Exception Info ========== + + function testExceptionInfoReturnsDetails() skip="notSupportsExceptionBreakpoints" { + dap.setExceptionBreakpoints( [ "uncaught" ] ); + + // Trigger uncaught exception + triggerArtifact( "exception-target.cfm", { throwException: true, catchException: false }, true ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Get exception info + var exceptionInfo = dap.exceptionInfo( threadId ); + + expect( exceptionInfo.body ).toHaveKey( "exceptionId" ); + expect( exceptionInfo.body ).toHaveKey( "description" ); + + // Should contain our test exception message + expect( exceptionInfo.body.description ).toInclude( "Intentional test exception" ); + + cleanupThread( threadId ); + } + + function testExceptionInfoIncludesType() skip="notSupportsExceptionBreakpoints" { + dap.setExceptionBreakpoints( [ "uncaught" ] ); + + // Trigger uncaught exception + triggerArtifact( "exception-target.cfm", { throwException: true, catchException: false }, true ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Get exception info + var exceptionInfo = dap.exceptionInfo( threadId ); + + // Exception ID should be the type + expect( exceptionInfo.body.exceptionId ).toInclude( "TestException" ); + + cleanupThread( threadId ); + } + + // ========== Stack Trace at Exception ========== + + function testStackTraceAtException() skip="notSupportsExceptionBreakpoints" { + dap.setExceptionBreakpoints( [ "uncaught" ] ); + + // Trigger uncaught exception + triggerArtifact( "exception-target.cfm", { throwException: true, catchException: false }, true ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Get stack trace + var stackResponse = dap.stackTrace( threadId ); + var frames = stackResponse.body.stackFrames; + + expect( frames.len() ).toBeGTE( 2, "Should have at least 2 stack frames" ); + + // Top frame should be in riskyFunction where exception was thrown + expect( frames[ 1 ].name ).toInclude( "riskyFunction" ); + + cleanupThread( threadId ); + } + + // ========== Variables at Exception ========== + + function testVariablesAtException() skip="notSupportsExceptionBreakpoints" { + dap.setExceptionBreakpoints( [ "uncaught" ] ); + + // Trigger uncaught exception + triggerArtifact( "exception-target.cfm", { throwException: true, catchException: false }, true ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Should be able to inspect variables even at exception + var frame = getTopFrame( threadId ); + var argsScope = getScopeByName( frame.id, "Arguments" ); + + var shouldThrow = getVariableByName( argsScope.variablesReference, "shouldThrow" ); + expect( shouldThrow.value.lcase() ).toBe( "true" ); + + cleanupThread( threadId ); + } + + // ========== Continue After Exception ========== + + function testContinueAfterException() skip="notSupportsExceptionBreakpoints" { + dap.setExceptionBreakpoints( [ "uncaught" ] ); + + // Trigger uncaught exception + triggerArtifact( "exception-target.cfm", { throwException: true, catchException: false }, true ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Continue should let the exception propagate + dap.continueThread( threadId ); + + // HTTP request should complete (with error) + var result = waitForHttpComplete(); + + // The request should have failed with an error status or error response + // (Depends on how Lucee handles uncaught exceptions) + expect( result ).toHaveKey( "status" ); + } + +} diff --git a/test/cfml/FunctionBreakpointsTest.cfc b/test/cfml/FunctionBreakpointsTest.cfc new file mode 100644 index 0000000..b2a28f8 --- /dev/null +++ b/test/cfml/FunctionBreakpointsTest.cfc @@ -0,0 +1,190 @@ +/** + * Tests for function breakpoints (break on function name without file/line). + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + function beforeAll() { + setupDap(); + variables.targetFile = getArtifactPath( "function-bp-target.cfm" ); + } + + function afterEach() { + clearFunctionBreakpoints(); + clearBreakpoints( variables.targetFile ); + } + + // ========== Basic Function Breakpoints ========== + + function testSetFunctionBreakpoint() skip="notSupportsFunctionBreakpoints" { + var response = dap.setFunctionBreakpoints( [ "targetFunction" ] ); + + expect( response.body ).toHaveKey( "breakpoints" ); + expect( response.body.breakpoints ).toHaveLength( 1 ); + // Function breakpoints may not be verified until hit + } + + function testFunctionBreakpointHits() skip="notSupportsFunctionBreakpoints" { + dap.setFunctionBreakpoints( [ "targetFunction" ] ); + + triggerArtifact( "function-bp-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + + expect( stopped.body.reason ).toBe( "function breakpoint" ); + + // Verify we're in targetFunction + var frame = getTopFrame( stopped.body.threadId ); + expect( frame.name ).toInclude( "targetFunction" ); + + cleanupThread( stopped.body.threadId ); + } + + // ========== Case Insensitivity ========== + + function testFunctionBreakpointCaseInsensitive() skip="notSupportsFunctionBreakpoints" { + // Set breakpoint with different case + dap.setFunctionBreakpoints( [ "TARGETFUNCTION" ] ); + + triggerArtifact( "function-bp-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + + expect( stopped.body.reason ).toBe( "function breakpoint" ); + + var frame = getTopFrame( stopped.body.threadId ); + expect( frame.name.lcase() ).toInclude( "targetfunction" ); + + cleanupThread( stopped.body.threadId ); + } + + // ========== Wildcard Function Breakpoints ========== + + function testWildcardFunctionBreakpoint() skip="notSupportsFunctionBreakpoints" { + // Set wildcard breakpoint for onRequest* + dap.setFunctionBreakpoints( [ "onRequest*" ] ); + + triggerArtifact( "function-bp-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + + expect( stopped.body.reason ).toBe( "function breakpoint" ); + + // Should hit onRequestStart + var frame = getTopFrame( stopped.body.threadId ); + expect( frame.name.lcase() ).toInclude( "onrequest" ); + + // Continue to see if we hit onRequestEnd too + dap.continueThread( stopped.body.threadId ); + + // May hit onRequestEnd or other onRequest* functions + try { + stopped = dap.waitForEvent( "stopped", 2000 ); + var frame2 = getTopFrame( stopped.body.threadId ); + expect( frame2.name.lcase() ).toInclude( "onrequest" ); + cleanupThread( stopped.body.threadId ); + } catch ( any e ) { + // It's ok if there's no second hit + waitForHttpComplete(); + } + } + + // ========== Multiple Function Breakpoints ========== + + function testMultipleFunctionBreakpoints() skip="notSupportsFunctionBreakpoints" { + dap.setFunctionBreakpoints( [ "targetFunction", "anotherFunction" ] ); + + triggerArtifact( "function-bp-target.cfm" ); + + // Should hit one of the functions + var stopped = dap.waitForEvent( "stopped", 2000 ); + var frame = getTopFrame( stopped.body.threadId ); + + var hitFunction = frame.name.lcase(); + expect( hitFunction contains "targetfunction" || hitFunction contains "anotherfunction" ) + .toBeTrue( "Should hit one of the breakpoint functions" ); + + dap.continueThread( stopped.body.threadId ); + + // Should hit the other function + try { + stopped = dap.waitForEvent( "stopped", 2000 ); + var frame2 = getTopFrame( stopped.body.threadId ); + var hitFunction2 = frame2.name.lcase(); + expect( hitFunction2 contains "targetfunction" || hitFunction2 contains "anotherfunction" ) + .toBeTrue( "Should hit the other breakpoint function" ); + cleanupThread( stopped.body.threadId ); + } catch ( any e ) { + waitForHttpComplete(); + } + } + + // ========== Conditional Function Breakpoints ========== + + function testConditionalFunctionBreakpoint() skip="notSupportsFunctionBreakpoints" { + // Set conditional function breakpoint + dap.setFunctionBreakpoints( [ "targetFunction" ], [ "arguments.input == 'test-value'" ] ); + + triggerArtifact( "function-bp-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + + expect( stopped.body.reason ).toBe( "function breakpoint" ); + + // Verify we can access arguments + var frame = getTopFrame( stopped.body.threadId ); + var argsScope = getScopeByName( frame.id, "Arguments" ); + var inputVar = getVariableByName( argsScope.variablesReference, "input" ); + + expect( inputVar.value ).toBe( '"test-value"' ); + + cleanupThread( stopped.body.threadId ); + } + + // ========== Clear Function Breakpoints ========== + + function testClearFunctionBreakpoints() skip="notSupportsFunctionBreakpoints" { + // Set function breakpoint + dap.setFunctionBreakpoints( [ "targetFunction" ] ); + + // Clear it + var response = dap.setFunctionBreakpoints( [] ); + + expect( response.body.breakpoints ).toHaveLength( 0 ); + + // Verify it doesn't hit + triggerArtifact( "function-bp-target.cfm" ); + + sleep( 2000 ); + var hasStoppedEvent = dap.hasEvent( "stopped" ); + expect( hasStoppedEvent ).toBeFalse( "Should not stop after clearing breakpoints" ); + + waitForHttpComplete(); + } + + // ========== Replace Function Breakpoints ========== + + function testReplaceFunctionBreakpoints() skip="notSupportsFunctionBreakpoints" { + // Set initial breakpoint + dap.setFunctionBreakpoints( [ "targetFunction" ] ); + + // Replace with different function + var response = dap.setFunctionBreakpoints( [ "anotherFunction" ] ); + + expect( response.body.breakpoints ).toHaveLength( 1 ); + + triggerArtifact( "function-bp-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var frame = getTopFrame( stopped.body.threadId ); + + // Should hit anotherFunction, not targetFunction + expect( frame.name.lcase() ).toInclude( "anotherfunction" ); + + cleanupThread( stopped.body.threadId ); + } + +} diff --git a/test/cfml/README.md b/test/cfml/README.md new file mode 100644 index 0000000..a5f9a51 --- /dev/null +++ b/test/cfml/README.md @@ -0,0 +1,77 @@ +# DAP Tests for luceedebug + +TestBox-based tests for the Debug Adapter Protocol functionality. + +## Architecture + +These tests require **two separate Lucee instances**: + +1. **Debuggee** - Lucee with luceedebug enabled + - DAP server on port 10000 + - HTTP server on port 8888 + - Serves the test artifacts + +2. **Test Runner** - Lucee running TestBox tests + - Connects to debuggee via DAP + - Triggers HTTP requests to artifacts + - **Must NOT have luceedebug enabled** (or it would freeze when breakpoints hit) + +## Running Tests Locally + +### Step 1: Start the Debuggee + +Use your existing Lucee dev server with luceedebug, or use Lucee Express: + +1. Download express template from [Lucee Express Templates](https://update.lucee.org/rest/update/provider/expressTemplates) +2. Drop your Lucee JAR in `lib/` +3. Set env vars: `LUCEE_DAP_SECRET=testing` and `LUCEE_DAP_PORT=10000` +4. Start Tomcat on port 8888 + +### Step 2: Run the Tests + +```bash +cd test/cfml +test.bat +``` + +Or filter to specific tests: +```bash +set testFilter=BreakpointsTest +test.bat +``` + +## Test Files + +- `DapClient.cfc` - DAP protocol client +- `DapTestCase.cfc` - Base test class with helpers +- `*Test.cfc` - Individual test suites +- `artifacts/*.cfm` - Target files that get debugged + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `DAP_HOST` | localhost | Debuggee DAP host | +| `DAP_PORT` | 10000 | Debuggee DAP port | +| `DEBUGGEE_HTTP` | http://localhost:8888 | Debuggee HTTP URL | +| `DAP_DEBUG` | false | Enable DAP client debug logging | + +## Feature Detection + +Tests use capability-based skipping. If a capability isn't supported: +- Native-only features skip on agent mode +- Version-specific features skip on older Lucee + +Example: +```cfml +function testSetVariable() skip="!supportsSetVariable()" { + // Only runs if setVariable is supported +} +``` + +## Adding Tests + +1. Create `YourFeatureTest.cfc` extending `DapTestCase` +2. Add label `labels="dap"` +3. Add target artifact in `artifacts/` if needed +4. Use `skip` attribute for capability-based skipping diff --git a/test/cfml/SetVariableTest.cfc b/test/cfml/SetVariableTest.cfc new file mode 100644 index 0000000..2ce7971 --- /dev/null +++ b/test/cfml/SetVariableTest.cfc @@ -0,0 +1,154 @@ +/** + * Tests for setVariable functionality (modifying variables at runtime). + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + // Line numbers in set-variable-target.cfm - keep in sync with the file + // These are validated in testValidateLineNumbers() using breakpointLocations (native mode only) + variables.lines = { + checkpoint1: 14, // var checkpoint1 = true; + checkpoint2: 20 // var checkpoint2 = true; + }; + + function beforeAll() { + setupDap(); + variables.targetFile = getArtifactPath( "set-variable-target.cfm" ); + } + + // Validate our line number assumptions using breakpointLocations (native mode only) + function testValidateLineNumbers_setVariableTarget() skip="notSupportsBreakpointLocations" { + var locations = dap.breakpointLocations( variables.targetFile, 1, 30 ); + var validLines = locations.body.breakpoints.map( function( bp ) { return bp.line; } ); + + systemOutput( "#variables.targetFile# valid lines: #serializeJSON( validLines )#", true ); + + for ( var key in variables.lines ) { + var line = variables.lines[ key ]; + expect( validLines ).toInclude( line, "#variables.targetFile# line #line# (#key#) should be a valid breakpoint location" ); + } + } + + function afterEach() { + clearBreakpoints( variables.targetFile ); + } + + // ========== Set String Variable ========== + + function testSetStringVariable() skip="notSupportsSetVariable" { + // Set breakpoint at first checkpoint + dap.setBreakpoints( variables.targetFile, [ lines.checkpoint1 ] ); + + triggerArtifact( "set-variable-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + var localScope = getScopeByName( frame.id, "Local" ); + + // Verify original value + var modifiable = getVariableByName( localScope.variablesReference, "modifiable" ); + expect( modifiable.value ).toBe( '"original"' ); + + // Set new value + var setResponse = dap.setVariable( localScope.variablesReference, "modifiable", '"modified"' ); + + expect( setResponse.body.value ).toBe( '"modified"' ); + + // Verify change persisted + var updatedVar = getVariableByName( localScope.variablesReference, "modifiable" ); + expect( updatedVar.value ).toBe( '"modified"' ); + + cleanupThread( threadId ); + } + + // ========== Set Numeric Variable ========== + + function testSetNumericVariable() skip="notSupportsSetVariable" { + dap.setBreakpoints( variables.targetFile, [ lines.checkpoint1 ] ); + + triggerArtifact( "set-variable-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + var localScope = getScopeByName( frame.id, "Local" ); + + // Verify original value + var numericVar = getVariableByName( localScope.variablesReference, "numericVar" ); + expect( numericVar.value ).toBe( "100" ); + + // Set new value + var setResponse = dap.setVariable( localScope.variablesReference, "numericVar", "999" ); + + expect( setResponse.body.value ).toBe( "999" ); + + cleanupThread( threadId ); + } + + // ========== Verify Runtime Effect ========== + + function testSetVariableAffectsExecution() skip="notSupportsSetVariable" { + // Set breakpoints at both checkpoints + dap.setBreakpoints( variables.targetFile, [ lines.checkpoint1, lines.checkpoint2 ] ); + + triggerArtifact( "set-variable-target.cfm" ); + + // Hit first breakpoint + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + var localScope = getScopeByName( frame.id, "Local" ); + + // Modify the variable + dap.setVariable( localScope.variablesReference, "modifiable", '"CHANGED"' ); + dap.setVariable( localScope.variablesReference, "numericVar", "555" ); + + // Continue to second breakpoint + dap.continueThread( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + threadId = stopped.body.threadId; + + // At second checkpoint, verify the result variable reflects our changes + frame = getTopFrame( threadId ); + localScope = getScopeByName( frame.id, "Local" ); + + var result = getVariableByName( localScope.variablesReference, "result" ); + // result = modifiable & " - " & numericVar = "CHANGED - 555" + expect( result.value ).toBe( '"CHANGED - 555"' ); + + cleanupThread( threadId ); + } + + // ========== Set Variable Types ========== + + function testSetBooleanVariable() skip="notSupportsSetVariable" { + dap.setBreakpoints( variables.targetFile, [ lines.checkpoint1 ] ); + + triggerArtifact( "set-variable-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + var localScope = getScopeByName( frame.id, "Local" ); + + // checkpoint1 is a boolean + var checkpoint = getVariableByName( localScope.variablesReference, "checkpoint1" ); + expect( checkpoint.value.lcase() ).toBe( "true" ); + + // Set to false + var setResponse = dap.setVariable( localScope.variablesReference, "checkpoint1", "false" ); + + expect( setResponse.body.value.lcase() ).toBe( "false" ); + + cleanupThread( threadId ); + } + +} diff --git a/test/cfml/SimpleTest.cfc b/test/cfml/SimpleTest.cfc new file mode 100644 index 0000000..4585160 --- /dev/null +++ b/test/cfml/SimpleTest.cfc @@ -0,0 +1,10 @@ +/** + * Simple test to verify test discovery works. + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + function testSimple() { + expect( 1 + 1 ).toBe( 2 ); + } + +} diff --git a/test/cfml/SteppingTest.cfc b/test/cfml/SteppingTest.cfc new file mode 100644 index 0000000..d5b940b --- /dev/null +++ b/test/cfml/SteppingTest.cfc @@ -0,0 +1,283 @@ +/** + * Tests for stepping functionality (stepIn, stepOver, stepOut). + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + // Line numbers in stepping-target.cfm - keep in sync with the file + // Agent mode (JDWP) stops at function declaration line, native mode stops at first statement + variables.lines = {}; + + function initLines() { + // Base line numbers (native mode - stops at first executable statement) + variables.lines = { + innerFuncDecl: 8, // function innerFunc(...) - agent mode stops here on step-in + innerFuncBody: 9, // var doubled = arguments.x * 2; + innerFuncReturn: 10, // return doubled; + outerFuncDecl: 13, // function outerFunc(...) - agent mode stops here on step-in + outerFuncBody: 14, // var intermediate = arguments.value + 10; + outerFuncCallInner: 15, // var result = innerFunc( intermediate ); + outerFuncReturn: 16, // return result; + mainCallOuter: 21, // finalResult = outerFunc( startValue ); + mainWriteOutput: 23, // writeOutput( "Result: #finalResult#" ); + mainSecondCall: 26, // secondResult = outerFunc( 100 ); + mainSecondOutput: 28 // writeOutput( " Second: #secondResult#" ); + }; + } + + // Get expected line for step-in: agent mode stops at function declaration, native at first statement + function getStepInLine( funcBodyLine, funcDeclLine ) { + if ( isNativeMode() ) { + return funcBodyLine; + } + // Agent mode (JDWP) stops at method entry = function declaration line + return funcDeclLine; + } + + // Get expected line for step-over from a function CALL (not from inside a function) + // Agent mode step-over from a function call stays on same line (JDWP quirk) + // But step-over from inside a function advances normally + function getStepOverFromCallLine( expectedLine, currentLine ) { + if ( isNativeMode() ) { + return expectedLine; + } + // Agent mode step-over from a function call stays on same line + return currentLine; + } + + // Get expected line for step-out: agent mode returns to the call line, native returns to next line + function getStepOutLine( nextLineAfterCall, callLine ) { + if ( isNativeMode() ) { + return nextLineAfterCall; + } + // Agent mode step-out returns to the call line (before it completes) + return callLine; + } + + function run( testResults, testBox ) { + variables.targetFile = getArtifactPath( "stepping-target.cfm" ); + + describe( "Stepping Tests", function() { + + beforeEach( function() { + // Fresh DAP connection for each test - disconnect triggers continueAll() on server + setupDap(); + initLines(); + } ); + + afterEach( function() { + clearBreakpoints( variables.targetFile ); + teardownDap(); + } ); + + it( title="validates line numbers in stepping-target", skip=notSupportsBreakpointLocations(), body=function() { + var locations = dap.breakpointLocations( variables.targetFile, 1, 35 ); + var validLines = locations.body.breakpoints.map( function( bp ) { return bp.line; } ); + + systemOutput( "#variables.targetFile# valid lines: #serializeJSON( validLines )#", true ); + + for ( var key in variables.lines ) { + var line = variables.lines[ key ]; + expect( validLines ).toInclude( line, "#variables.targetFile# line #line# (#key#) should be a valid breakpoint location. Valid lines: #serializeJSON( validLines )#" ); + } + } ); + + // ========== Step Over ========== + + it( "stepOver skips function call", function() { + // Set breakpoint at call to outerFunc + dap.setBreakpoints( variables.targetFile, [ lines.mainCallOuter ] ); + + triggerArtifact( "stepping-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Verify we're at mainCallOuter + var frame = getTopFrame( threadId ); + expect( frame.line ).toBe( lines.mainCallOuter, "Should start at mainCallOuter" ); + + // Step over - should skip into outerFunc and land on mainWriteOutput + dap.stepOver( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + + frame = getTopFrame( threadId ); + var expectedLine = getStepOverFromCallLine( lines.mainWriteOutput, lines.mainCallOuter ); + expect( frame.line ).toBe( expectedLine, "Step over should land on line #expectedLine#" ); + + cleanupThread( threadId ); + } ); + + // ========== Step In ========== + + it( "stepIn enters function", function() { + // Set breakpoint at call to outerFunc + dap.setBreakpoints( variables.targetFile, [ lines.mainCallOuter ] ); + + triggerArtifact( "stepping-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Verify we're at mainCallOuter + var frame = getTopFrame( threadId ); + expect( frame.line ).toBe( lines.mainCallOuter, "Should start at mainCallOuter" ); + + // Step in - should enter outerFunc + dap.stepIn( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + + frame = getTopFrame( threadId ); + var expectedLine = getStepInLine( lines.outerFuncBody, lines.outerFuncDecl ); + expect( frame.line ).toBe( expectedLine, "Step in should enter outerFunc at line #expectedLine#" ); + + cleanupThread( threadId ); + } ); + + it( "stepIn enters nested function", function() { + // Set breakpoint at call to outerFunc + dap.setBreakpoints( variables.targetFile, [ lines.mainCallOuter ] ); + + triggerArtifact( "stepping-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Step into outerFunc + dap.stepIn( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + + var frame = getTopFrame( threadId ); + var expectedLine = getStepInLine( lines.outerFuncBody, lines.outerFuncDecl ); + expect( frame.line ).toBe( expectedLine, "Should be at outerFunc entry (line #expectedLine#)" ); + + // Step over to advance within outerFunc + // In native mode: from line 14 to 15; in agent mode: from line 13 to 14 + dap.stepOver( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + + frame = getTopFrame( threadId ); + // After step-over from entry point, we should be at the first/next statement + // Native: was at 14, now at 15 (outerFuncCallInner) + // Agent: was at 13 (func decl), now at 14 (outerFuncBody) + if ( isNativeMode() ) { + expectedLine = lines.outerFuncCallInner; + } else { + expectedLine = lines.outerFuncBody; + } + expect( frame.line ).toBe( expectedLine, "Should be at line #expectedLine#" ); + + // Agent mode needs an extra step to get to the innerFunc call line + if ( !isNativeMode() ) { + dap.stepOver( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + frame = getTopFrame( threadId ); + expect( frame.line ).toBe( lines.outerFuncCallInner, "Agent mode: should now be at outerFuncCallInner" ); + } + + // Step into innerFunc + dap.stepIn( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + + frame = getTopFrame( threadId ); + expectedLine = getStepInLine( lines.innerFuncBody, lines.innerFuncDecl ); + expect( frame.line ).toBe( expectedLine, "Step in should enter innerFunc at line #expectedLine#" ); + + cleanupThread( threadId ); + } ); + + // ========== Step Out ========== + + it( "stepOut exits function", function() { + // Set breakpoint at call to outerFunc + dap.setBreakpoints( variables.targetFile, [ lines.mainCallOuter ] ); + + triggerArtifact( "stepping-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Step into outerFunc + dap.stepIn( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + + var frame = getTopFrame( threadId ); + var expectedLine = getStepInLine( lines.outerFuncBody, lines.outerFuncDecl ); + expect( frame.line ).toBe( expectedLine, "Should be inside outerFunc at line #expectedLine#" ); + + // Step out - should return to caller + // Native mode: returns to line after call (mainWriteOutput = 23) + // Agent mode: returns to call line (mainCallOuter = 21) + dap.stepOut( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + + frame = getTopFrame( threadId ); + expectedLine = getStepOutLine( lines.mainWriteOutput, lines.mainCallOuter ); + expect( frame.line ).toBe( expectedLine, "Step out should return to line #expectedLine#" ); + + cleanupThread( threadId ); + } ); + + it( "stepOut from nested function returns to caller", function() { + // Set breakpoint inside innerFunc + dap.setBreakpoints( variables.targetFile, [ lines.innerFuncBody ] ); + + triggerArtifact( "stepping-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Verify we're in innerFunc + var frame = getTopFrame( threadId ); + expect( frame.line ).toBe( lines.innerFuncBody, "Should be at innerFuncBody" ); + + // Step out - should return to outerFunc + // Native mode: returns to line after innerFunc call (outerFuncReturn = 16) + // Agent mode: returns to call line (outerFuncCallInner = 15) + dap.stepOut( threadId ); + stopped = dap.waitForEvent( "stopped", 2000 ); + + frame = getTopFrame( threadId ); + var expectedLine = getStepOutLine( lines.outerFuncReturn, lines.outerFuncCallInner ); + expect( frame.line ).toBe( expectedLine, "Step out should return to line #expectedLine#" ); + + cleanupThread( threadId ); + } ); + + // ========== Stack Trace ========== + + it( "stack trace shows call hierarchy", function() { + // Set breakpoint inside innerFunc + dap.setBreakpoints( variables.targetFile, [ lines.innerFuncBody ] ); + + triggerArtifact( "stepping-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + // Get full stack trace + var stackResponse = dap.stackTrace( threadId ); + var frames = stackResponse.body.stackFrames; + + // Should have at least 2 frames: innerFunc -> outerFunc + // Note: Native mode only shows UDF frames, not the top-level "main" frame + // Agent mode may show 3 frames including top-level code + expect( frames.len() ).toBeGTE( 2, "Should have at least 2 stack frames, got #serializeJSON( frames )#" ); + + // Top frame should be innerFunc + expect( frames[ 1 ].name ).toInclude( "innerFunc" ); + expect( frames[ 1 ].line ).toBe( lines.innerFuncBody ); + + // Second frame should be outerFunc + expect( frames[ 2 ].name ).toInclude( "outerFunc" ); + + cleanupThread( threadId ); + } ); + + } ); + } + +} diff --git a/test/cfml/VariablesTest.cfc b/test/cfml/VariablesTest.cfc new file mode 100644 index 0000000..624df3f --- /dev/null +++ b/test/cfml/VariablesTest.cfc @@ -0,0 +1,236 @@ +/** + * Tests for variables and scopes inspection. + */ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="dap" { + + include "DapTestCase.cfm"; + + variables.targetFile = ""; + + // Line numbers in variables-target.cfm - keep in sync with the file + // These are validated in testValidateLineNumbers() using breakpointLocations (native mode only) + variables.lines = { + debugLine: 35 // var debugLine = "inspect here"; + }; + + function beforeAll() { + setupDap(); + variables.targetFile = getArtifactPath( "variables-target.cfm" ); + } + + // Validate our line number assumptions using breakpointLocations (native mode only) + function testValidateLineNumbers_variablesTarget() skip="notSupportsBreakpointLocations" { + var locations = dap.breakpointLocations( variables.targetFile, 1, 45 ); + var validLines = locations.body.breakpoints.map( function( bp ) { return bp.line; } ); + + systemOutput( "#variables.targetFile# valid lines: #serializeJSON( validLines )#", true ); + + for ( var key in variables.lines ) { + var line = variables.lines[ key ]; + expect( validLines ).toInclude( line, "#variables.targetFile# line #line# (#key#) should be a valid breakpoint location" ); + } + } + + function afterEach() { + clearBreakpoints( variables.targetFile ); + } + + // ========== Scopes ========== + + function testScopesReturnsExpectedScopes() { + // Set breakpoint inside testVariables + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "variables-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + var scopesResponse = dap.scopes( frame.id ); + var scopes = scopesResponse.body.scopes; + + // Should have Local, Arguments, and other scopes + var scopeNames = scopes.map( function( s ) { return s.name; } ); + + expect( scopeNames ).toInclude( "Local", "Should have Local scope" ); + expect( scopeNames ).toInclude( "Arguments", "Should have Arguments scope" ); + + cleanupThread( threadId ); + } + + // ========== Local Variables ========== + + function testLocalVariablesShowCorrectTypes() { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "variables-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + var localScope = getScopeByName( frame.id, "Local" ); + var varsResponse = dap.getVariables( localScope.variablesReference ); + var vars = varsResponse.body.variables; + + // Create a lookup map + var varMap = {}; + for ( var v in vars ) { + varMap[ v.name ] = v; + } + + // Check string + expect( varMap ).toHaveKey( "localString" ); + expect( varMap.localString.value ).toBe( '"hello"' ); + + // Check number + expect( varMap ).toHaveKey( "localNumber" ); + expect( varMap.localNumber.value ).toBe( "42" ); + + // Check boolean + expect( varMap ).toHaveKey( "localBoolean" ); + expect( varMap.localBoolean.value.lcase() ).toBe( "true" ); + + // Check array has variablesReference for expansion + expect( varMap ).toHaveKey( "localArray" ); + expect( varMap.localArray.variablesReference ).toBeGT( 0, "Array should be expandable" ); + + // Check struct has variablesReference for expansion + expect( varMap ).toHaveKey( "localStruct" ); + expect( varMap.localStruct.variablesReference ).toBeGT( 0, "Struct should be expandable" ); + + cleanupThread( threadId ); + } + + // ========== Arguments ========== + + function testArgumentsScope() { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "variables-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + var argsScope = getScopeByName( frame.id, "Arguments" ); + var varsResponse = dap.getVariables( argsScope.variablesReference ); + var vars = varsResponse.body.variables; + + // Create a lookup map + var varMap = {}; + for ( var v in vars ) { + varMap[ v.name ] = v; + } + + // Check arg1 + expect( varMap ).toHaveKey( "arg1" ); + expect( varMap.arg1.value ).toBe( '"arg-value"' ); + + // Check arg2 + expect( varMap ).toHaveKey( "arg2" ); + expect( varMap.arg2.value ).toBe( "999" ); + + cleanupThread( threadId ); + } + + // ========== Nested Structures ========== + + function testExpandNestedStruct() { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "variables-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + var localScope = getScopeByName( frame.id, "Local" ); + var varsResponse = dap.getVariables( localScope.variablesReference ); + + // Find localStruct + var localStruct = {}; + for ( var v in varsResponse.body.variables ) { + if ( v.name == "localStruct" ) { + localStruct = v; + break; + } + } + + expect( localStruct.variablesReference ).toBeGT( 0, "localStruct should be expandable" ); + + // Expand the struct + var nestedResponse = dap.getVariables( localStruct.variablesReference ); + var nestedVars = nestedResponse.body.variables; + + var nestedMap = {}; + for ( var v in nestedVars ) { + nestedMap[ v.name ] = v; + } + + expect( nestedMap ).toHaveKey( "name" ); + expect( nestedMap ).toHaveKey( "nested" ); + expect( nestedMap.nested.variablesReference ).toBeGT( 0, "nested should be expandable" ); + + // Expand nested.nested + var deepResponse = dap.getVariables( nestedMap.nested.variablesReference ); + var deepVars = deepResponse.body.variables; + + var deepMap = {}; + for ( var v in deepVars ) { + deepMap[ v.name ] = v; + } + + expect( deepMap ).toHaveKey( "deep" ); + expect( deepMap.deep.value ).toBe( '"value"' ); + + cleanupThread( threadId ); + } + + // ========== Arrays ========== + + function testExpandArray() { + dap.setBreakpoints( variables.targetFile, [ lines.debugLine ] ); + + triggerArtifact( "variables-target.cfm" ); + + var stopped = dap.waitForEvent( "stopped", 2000 ); + var threadId = stopped.body.threadId; + + var frame = getTopFrame( threadId ); + var localScope = getScopeByName( frame.id, "Local" ); + var varsResponse = dap.getVariables( localScope.variablesReference ); + + // Find localArray + var localArray = {}; + for ( var v in varsResponse.body.variables ) { + if ( v.name == "localArray" ) { + localArray = v; + break; + } + } + + expect( localArray.variablesReference ).toBeGT( 0, "localArray should be expandable" ); + + // Expand the array + var arrayResponse = dap.getVariables( localArray.variablesReference ); + var arrayVars = arrayResponse.body.variables; + + // Should have indexed elements + expect( arrayVars.len() ).toBeGTE( 5, "Array should have at least 5 elements" ); + + // Check first few elements + var arrayMap = {}; + for ( var v in arrayVars ) { + arrayMap[ v.name ] = v; + } + + expect( arrayMap[ "1" ].value ).toBe( "1" ); + expect( arrayMap[ "2" ].value ).toBe( "2" ); + expect( arrayMap[ "4" ].value ).toBe( '"four"' ); + + cleanupThread( threadId ); + } + +} diff --git a/test/cfml/artifacts/breakpoint-target.cfm b/test/cfml/artifacts/breakpoint-target.cfm new file mode 100644 index 0000000..1076e3e --- /dev/null +++ b/test/cfml/artifacts/breakpoint-target.cfm @@ -0,0 +1,25 @@ + +/** + * Target file for breakpoint tests. + */ + +function simpleFunction( required string name ) { + var greeting = "Hello, " & arguments.name; + return greeting; +} + +function conditionalFunction( required numeric value ) { + var result = 0; + if ( arguments.value > 10 ) { + result = arguments.value * 2; + } else { + result = arguments.value + 5; + } + return result; +} + +output1 = simpleFunction( "Test" ); +output2 = conditionalFunction( 15 ); + +writeOutput( "Done: #output1# / #output2#" ); + diff --git a/test/cfml/artifacts/completions-target.cfm b/test/cfml/artifacts/completions-target.cfm new file mode 100644 index 0000000..2212d7c --- /dev/null +++ b/test/cfml/artifacts/completions-target.cfm @@ -0,0 +1,30 @@ + +/** + * Target file for completions/autocomplete tests. + * + * Tests debug console autocomplete functionality. + */ + +function testCompletions() { + var myString = "hello"; + var myNumber = 42; + var myStruct = { + "firstName": "John", + "lastName": "Doe", + "address": { + "street": "123 Main St", + "city": "Springfield" + } + }; + var myArray = [ 1, 2, 3 ]; + var myQuery = queryNew( "id,name", "integer,varchar", [ { id: 1, name: "test" } ] ); + + var stopHere = true; + + return myString; +} + +result = testCompletions(); + +writeOutput( "Done: #result#" ); + diff --git a/test/cfml/artifacts/console-output-target.cfm b/test/cfml/artifacts/console-output-target.cfm new file mode 100644 index 0000000..8f73a1a --- /dev/null +++ b/test/cfml/artifacts/console-output-target.cfm @@ -0,0 +1,22 @@ + +/** + * Target file for console output tests. + * + * Tests that systemOutput() streams to debug console via DAP output events. + */ + +function testOutput( required string message ) { + var prefix = "ConsoleOutputTest"; + + // Output to stdout + systemOutput( "#prefix#: #arguments.message#", true ); + + var stopHere = true; + + return arguments.message; +} + +result = testOutput( url.message ?: "default-message" ); + +writeOutput( "Done: #result#" ); + diff --git a/test/cfml/artifacts/debug-threads.cfm b/test/cfml/artifacts/debug-threads.cfm new file mode 100644 index 0000000..62d2a4e --- /dev/null +++ b/test/cfml/artifacts/debug-threads.cfm @@ -0,0 +1,27 @@ + +// Dump luceedebug threads for debugging DAP server issues +threads = createObject( "java", "java.lang.management.ManagementFactory" ) + .getThreadMXBean() + .dumpAllThreads( true, true ); + +found = false; +for ( t in threads ) { + if ( t.getThreadName() contains "luceedebug" ) { + found = true; + echo( "Thread: #t.getThreadName()#" & chr(10) ); + echo( "State: #t.getThreadState().name()#" & chr(10) ); + for ( f in t.getStackTrace() ) { + echo( " at #f.toString()#" & chr(10) ); + } + echo( chr(10) ); + } +} + +if ( !found ) { + echo( "No luceedebug threads found!" & chr(10) ); + echo( chr(10) & "All threads:" & chr(10) ); + for ( t in threads ) { + echo( "#t.getThreadName()# - #t.getThreadState().name()#" & chr(10) ); + } +} + diff --git a/test/cfml/artifacts/evaluate-target.cfm b/test/cfml/artifacts/evaluate-target.cfm new file mode 100644 index 0000000..8573c5a --- /dev/null +++ b/test/cfml/artifacts/evaluate-target.cfm @@ -0,0 +1,37 @@ + +/** + * Target file for evaluate/expression tests. + * + * Tests evaluate functionality in watch/repl context. + */ + +function testEvaluate( required struct data ) { + var localVar = "local-value"; + var localNum = 100; + var localArray = [ 10, 20, 30 ]; + var localStruct = { + "key1": "value1", + "key2": 42, + "nested": { + "inner": "deep" + } + }; + + // Access argument + var dataName = arguments.data.name; + var dataValue = arguments.data.value; + + var stopHere = true; + + return localVar & " - " & dataName; +} + +inputData = { + "name": "test-input", + "value": 12345 +}; + +result = testEvaluate( inputData ); + +writeOutput( "Result: #result#" ); + diff --git a/test/cfml/artifacts/exception-target.cfm b/test/cfml/artifacts/exception-target.cfm new file mode 100644 index 0000000..01c7f3d --- /dev/null +++ b/test/cfml/artifacts/exception-target.cfm @@ -0,0 +1,36 @@ + +/** + * Target file for exception breakpoint tests. + * + * Tests breaking on uncaught exceptions. + */ + +param name="url.throwException" default="false"; +param name="url.catchException" default="false"; + +function riskyFunction( required boolean shouldThrow ) { + if ( arguments.shouldThrow ) { + throw( type="TestException", message="Intentional test exception", detail="This is for testing" ); + } + return "success"; +} + +function wrapperFunction( required boolean shouldThrow, required boolean shouldCatch ) { + if ( arguments.shouldCatch ) { + try { + return riskyFunction( arguments.shouldThrow ); + } catch ( any e ) { + return "caught: " & e.message; + } + } else { + return riskyFunction( arguments.shouldThrow ); + } +} + +result = wrapperFunction( + shouldThrow = url.throwException == "true", + shouldCatch = url.catchException == "true" +); + +writeOutput( "Result: #result#" ); + diff --git a/test/cfml/artifacts/function-bp-target.cfm b/test/cfml/artifacts/function-bp-target.cfm new file mode 100644 index 0000000..f24dda6 --- /dev/null +++ b/test/cfml/artifacts/function-bp-target.cfm @@ -0,0 +1,40 @@ + +/** + * Target file for function breakpoint tests. + * + * Tests breaking on function names without specifying file/line. + * + * Function breakpoint targets: + * - targetFunction (exact match) + * - onRequest* (wildcard) + */ + +function targetFunction( required string input ) { + var result = "processed: " & arguments.input; + return result; +} + +function onRequestStart() { + // Simulates Application.cfc callback + return true; +} + +function onRequestEnd() { + // Simulates Application.cfc callback + return true; +} + +function anotherFunction() { + return "another"; +} + +// Execution +onRequestStart(); + +result1 = targetFunction( "test-value" ); +result2 = anotherFunction(); + +onRequestEnd(); + +writeOutput( "Results: #result1# / #result2#" ); + diff --git a/test/cfml/artifacts/set-variable-target.cfm b/test/cfml/artifacts/set-variable-target.cfm new file mode 100644 index 0000000..5699fe6 --- /dev/null +++ b/test/cfml/artifacts/set-variable-target.cfm @@ -0,0 +1,28 @@ + +/** + * Target file for setVariable tests. + * + * Tests modifying variables at runtime. + */ + +function testSetVariable( required string input ) { + var modifiable = "original"; + var numericVar = 100; + var structVar = { "key": "original-value" }; + + // First breakpoint - modify variables here + var checkpoint1 = true; + + // Use the (potentially modified) values + var result = modifiable & " - " & numericVar; + + // Second checkpoint after potential modification + var checkpoint2 = true; + + return result; +} + +output = testSetVariable( "test-input" ); + +writeOutput( "Output: #output#" ); + diff --git a/test/cfml/artifacts/stepping-target.cfm b/test/cfml/artifacts/stepping-target.cfm new file mode 100644 index 0000000..72f2edd --- /dev/null +++ b/test/cfml/artifacts/stepping-target.cfm @@ -0,0 +1,29 @@ + +/** + * Target file for stepping tests (stepIn, stepOver, stepOut). + * + * Call hierarchy: main -> outerFunc -> innerFunc + */ + +function innerFunc( required numeric x ) { + var doubled = arguments.x * 2; + return doubled; +} + +function outerFunc( required numeric value ) { + var intermediate = arguments.value + 10; + var result = innerFunc( intermediate ); + return result; +} + +// Main execution +startValue = 5; +finalResult = outerFunc( startValue ); + +writeOutput( "Result: #finalResult#" ); + +// Second call for additional stepping tests +secondResult = outerFunc( 100 ); + +writeOutput( " Second: #secondResult#" ); + diff --git a/test/cfml/artifacts/variables-target.cfm b/test/cfml/artifacts/variables-target.cfm new file mode 100644 index 0000000..7173d25 --- /dev/null +++ b/test/cfml/artifacts/variables-target.cfm @@ -0,0 +1,43 @@ + +/** + * Target file for variables and scopes tests. + * + * Tests various data types and scope access. + */ + +// Set up test data in various scopes +url.urlVar = "from-url"; +form.formVar = "from-form"; +request.requestVar = "from-request"; + +function testVariables( required string arg1, required numeric arg2 ) { + // Local variables of different types + var localString = "hello"; + var localNumber = 42; + var localFloat = 3.14159; + var localBoolean = true; + var localArray = [ 1, 2, 3, "four", { nested: true } ]; + var localStruct = { + "name": "test", + "value": 123, + "nested": { + "deep": "value" + } + }; + var localDate = now(); + var localNull = javacast( "null", 0 ); + + // Access various scopes + var fromUrl = url.urlVar; + var fromForm = form.formVar; + var fromRequest = request.requestVar; + + var debugLine = "inspect here"; + + return localString & " " & localNumber; +} + +result = testVariables( "arg-value", 999 ); + +writeOutput( "Done: #result#" ); + diff --git a/test/cfml/test.bat b/test/cfml/test.bat new file mode 100644 index 0000000..87ad096 --- /dev/null +++ b/test/cfml/test.bat @@ -0,0 +1,8 @@ +cls +SET JAVA_HOME=C:\Program Files\Eclipse Adoptium\jdk-21.0.5.11-hotspot +set testLabels=dap +set testFilter= +set LUCEE_LOGGING_FORCE_APPENDER=console +set LUCEE_LOGGING_FORCE_LEVEL=info + +ant -buildfile="D:/work/script-runner/build.xml" -Dwebroot="D:/work/lucee7/test" -Dexecute="bootstrap-tests.cfm" -DluceeVersionQuery="7.0/all/light" -DtestAdditional="D:\work\lucee-extensions\luceedebug\test\cfml" -DtestLabels="%testLabels%" -DtestFilter="%testFilter%" -DtestDebug="false" diff --git a/vscode-client/package.json b/vscode-client/package.json index b2f5691..9c4082a 100644 --- a/vscode-client/package.json +++ b/vscode-client/package.json @@ -55,6 +55,14 @@ { "when": "debugType == 'cfml'", "command": "luceedebug.openFileForVariableSourcePath" + }, + { + "when": "debugType == 'cfml'", + "command": "luceedebug.getMetadata" + }, + { + "when": "debugType == 'cfml'", + "command": "luceedebug.getApplicationSettings" } ] }, @@ -98,6 +106,11 @@ "command": "luceedebug.openFileForVariableSourcePath", "title": "luceedebug: open defining file", "enablement": "debugType == 'cfml'" + }, + { + "command": "luceedebug.getMetadata", + "title": "luceedebug: get metadata", + "enablement": "debugType == 'cfml'" } ], "debuggers": [ @@ -160,6 +173,40 @@ ], "default": "auto", "description": "How paths returned from the debugger should be normalized (none, auto, posix, or windows)." + }, + "logColor": { + "type": "boolean", + "default": true, + "description": "Enable ANSI colors in debugger log output." + }, + "logLevel": { + "type": "string", + "enum": [ + "error", + "info", + "debug" + ], + "default": "info", + "description": "Debugger log verbosity level." + }, + "logExceptions": { + "type": "boolean", + "default": false, + "description": "Log exceptions to the debug console." + }, + "consoleOutput": { + "type": "boolean", + "default": false, + "description": "Stream console output to the debug console (Lucee 7.1+)." + }, + "secret": { + "type": "string", + "description": "Secret that must match LUCEE_DAP_SECRET environment variable on the server." + }, + "evaluation": { + "type": "boolean", + "default": true, + "description": "Enable expression evaluation in debug console, watch panel, and hover tooltips." } } } @@ -170,14 +217,15 @@ "request": "attach", "name": "Attach to server", "hostName": "localhost", + "port": 9999, + "secret": "", "pathTransforms": [ { "idePrefix": "${workspaceFolder}", "serverPrefix": "/app" } ], - "pathSeparator": "auto", - "port": 8000 + "pathSeparator": "auto" } ], "configurationSnippets": [ @@ -189,14 +237,15 @@ "request": "attach", "name": "Attach to server", "hostName": "localhost", + "port": 9999, + "secret": "", "pathTransforms": [ { "idePrefix": "^\"\\${workspaceFolder}\"", "serverPrefix": "/app" } ], - "pathSeparator": "auto", - "port": 8000 + "pathSeparator": "auto" } } ], diff --git a/vscode-client/src/extension.ts b/vscode-client/src/extension.ts index b14020e..197d059 100644 --- a/vscode-client/src/extension.ts +++ b/vscode-client/src/extension.ts @@ -100,9 +100,9 @@ export function activate(context: vscode.ExtensionContext) { if (!currentDebugSession || args?.variable === undefined || args.variable.variablesReference === 0) { return; } - + const result : DumpResponse = await currentDebugSession.customRequest("dumpAsJSON", {variablesReference: args.variable.variablesReference}); - + let obj : any; try { obj = JSON.parse(result.content); @@ -115,7 +115,58 @@ export function activate(context: vscode.ExtensionContext) { const text = JSON.stringify(obj, undefined, 4); luceedebugTextDocumentProvider.addOrReplaceTextDoc(uri, text); - + + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + }), + vscode.commands.registerCommand("luceedebug.getMetadata", async (args?: Partial) => { + if (!currentDebugSession || args?.variable === undefined || args.variable.variablesReference === 0) { + return; + } + + const result : DumpResponse = await currentDebugSession.customRequest("getMetadata", {variablesReference: args.variable.variablesReference}); + + let obj : any; + try { + obj = JSON.parse(result.content); + } + catch { + obj = "Failed to parse the following JSON:\n" + result.content; + } + + const uri = vscode.Uri.from({scheme: "luceedebug", path: args.variable.name + ".metadata", fragment: args.variable.variablesReference.toString()}); + const text = JSON.stringify(obj, undefined, 4); + + luceedebugTextDocumentProvider.addOrReplaceTextDoc(uri, text); + + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + }), + vscode.commands.registerCommand("luceedebug.getApplicationSettings", async (args?: Partial) => { + if (!currentDebugSession) { + return; + } + // Only allow for the top-level application scope + if (!args?.container || args.container.name !== "application") { + vscode.window.showWarningMessage("getApplicationSettings is only available for the top-level application scope"); + return; + } + + const result : DumpResponse = await currentDebugSession.customRequest("getApplicationSettings", {variablesReference: args.container.variablesReference}); + + let obj : any; + try { + obj = JSON.parse(result.content); + } + catch { + obj = "Failed to parse the following JSON:\n" + result.content; + } + + const uri = vscode.Uri.from({scheme: "luceedebug", path: "applicationSettings", fragment: Date.now().toString()}); + const text = JSON.stringify(obj, undefined, 4); + + luceedebugTextDocumentProvider.addOrReplaceTextDoc(uri, text); + const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); })