diff --git a/README.md b/README.md index 2652cf8..3124a22 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,24 @@ serializations of the same instrumentation underneath. script calls into, for free, wherever the platform already wraps the JVM (e.g. debugging an inline Maven script). -Full details, screenshots and known limitations: [`plugin/README.md`](plugin/README.md). +Full details and known limitations: [`plugin/README.md`](plugin/README.md). + +### Screenshots + +![BeanShell plugin screenshots](plugin/docs/images/showcase.gif) + +Click a thumbnail to open it full-size: + +

+ Editor + Completion + Navigation + Debugger + Maven injection + Maven completion + Inspection + Maven debugger +

## Editors: VS Code, Neovim, Eclipse diff --git a/agent/CHANGELOG.md b/agent/CHANGELOG.md index 747bd7a..b120526 100644 --- a/agent/CHANGELOG.md +++ b/agent/CHANGELOG.md @@ -8,3 +8,12 @@ VS Code, Neovim and Eclipse DAP transports. The copy of this jar bundled inside IDEA plugin has its own release cycle -- see [`../plugin/CHANGELOG.md`](../plugin/CHANGELOG.md). ## [Unreleased] + +## [0.2.0] - 2026-08-04 + +### Added + +- `SCOPES` now reports a `Block`/`Closure` level for each `for`/`if` body or captured + closure namespace between a frame's `Locals` and `Global`, each carrying only its own + directly-declared variables, instead of `Locals` flattening the whole parent chain into + one group. See [`docs/PROTOCOL.md`](../docs/PROTOCOL.md#0x11-scopes--answers-0x04). diff --git a/agent/README.md b/agent/README.md index 8ea225d..874de37 100644 --- a/agent/README.md +++ b/agent/README.md @@ -24,7 +24,7 @@ nested jar. The goal is debugging BeanShell inside **third-party libraries that already bundle bsh** — Maven plugins such as maven-enforcer being the motivating case. The code is -fixed; only runtime behaviour can be changed. That rules out patching BeanShell and +fixed; only runtime behavior can be changed. That rules out patching BeanShell and makes an agent the only vehicle. **JDWP is not usable, and the reason is a language property rather than an @@ -113,14 +113,14 @@ the system loader cannot define a second copy. are the init list, the condition, the update list *and* the body, all reporting the `for` line. The child layouts were read off real parse trees: - | node | children | statement position | - |---|---|---| - | `BSHIfStatement` | `[cond, then, else?]` | index ≥ 1 | - | `BSHWhileStatement` (`while`) | `[cond, body]` | last | - | `BSHWhileStatement` (`do`) | `[body, cond]` | **first** | - | `BSHForStatement` | `[init?, cond?, update?, body]` | last | - | `BSHEnhancedForStatement` | `[type?, iterable, body]` | last | - | `BSHSwitchStatement` | `[expr, label, stmt, …]` | index ≥ 1 | + | node | children | statement position | + |-------------------------------|---------------------------------|--------------------| + | `BSHIfStatement` | `[cond, then, else?]` | index ≥ 1 | + | `BSHWhileStatement` (`while`) | `[cond, body]` | last | + | `BSHWhileStatement` (`do`) | `[body, cond]` | **first** | + | `BSHForStatement` | `[init?, cond?, update?, body]` | last | + | `BSHEnhancedForStatement` | `[type?, iterable, body]` | last | + | `BSHSwitchStatement` | `[expr, label, stmt, …]` | index ≥ 1 | `do` and `while` are the *same node type* (`DoStatement() #WhileStatement`) with opposite child order, separated by the package-private `isDoStatement` field. @@ -138,9 +138,9 @@ the system loader cannot define a second copy. ### Not bit-transparent -Behaviour is unchanged — every fixture produces identical output with and without +Behavior is unchanged — every fixture produces identical output with and without the agent — but **identity hash codes shift** deterministically (`Point@279f2327` -becomes `Point@30f39991` and stays there), because initialising the hook on the +becomes `Point@30f39991` and stays there), because initializing the hook on the interpreter thread advances that thread's identity-hash sequence. Nothing correct depends on those values, but a script printing a default `toString()` shows different digits. @@ -190,7 +190,7 @@ valid until the next resume and the table is dropped there, so the IDE can never hold a reference into a script that has moved on — no stale-object problem to solve, no cleanup protocol to get wrong. That is [DAP's `variablesReference`](https://microsoft.github.io/debug-adapter-protocol/specification#Types_Variable) -in a smaller encoding: adopting DAP later changes the serialisation, not the design. +in a smaller encoding: adopting DAP later changes the serialization, not the design. Requests are served **on the thread they concern**, from inside the same loop where it waits for `RESUME`. Not a shortcut: that thread is parked there anyway, it owns the @@ -218,9 +218,9 @@ does, so a filter is not optional: BeanShell's own commands (`print`, `pwd`, … `.bsh` files on the classpath, and without a filter the session stops inside `print.bsh` on every `print()` call. Two properties, ORed: -| property | match | for | -|---|---|---| -| `bsh.debug.sources` | comma-separated, `endsWith` | a script that has a file name | +| property | match | for | +|--------------------------|------------------------------------------------|--------------------------------------| +| `bsh.debug.sources` | comma-separated, `endsWith` | a script that has a file name | | `bsh.debug.sources.file` | a file of prefixes, one per line, `startsWith` | a script handed over as a **string** | The second exists because a string has no file name. BeanShell invents one: @@ -300,7 +300,7 @@ correct in the CLI and does nothing in a library. ./gradlew :agent:samples:runHostWithAgent # the same, under the agent ``` -The two must agree, which is what pins down "behaviour unchanged". The README +The two must agree, which is what pins down "behavior unchanged". The README there lists the three differences that are legitimate. The transport itself can be exercised without the IDE, and the instrumentation diff --git a/agent/checks/02-maven-plugin-realm.sh b/agent/checks/02-maven-plugin-realm.sh index 887984f..fd43bce 100755 --- a/agent/checks/02-maven-plugin-realm.sh +++ b/agent/checks/02-maven-plugin-realm.sh @@ -34,7 +34,7 @@ MAVEN_OPTS="-javaagent:$AGENT_JAR -Dbsh.debug.trace=1" \ mvn -o -q -f "$POM" validate > "$CHECK_TMP/bh.txt" 2>&1 grep 'bsh-agent' "$CHECK_TMP/bh.txt" > "$CHECK_TMP/bh-agent.txt" || true -assert_contains "$CHECK_TMP/bh-agent.txt" 'src=inline evaluation of: ``prefix = project.getArtifactId();' \ +assert_contains "$CHECK_TMP/bh-agent.txt" 'src=inline evaluation of: ``name = project.artifactId + ":" + project.version;' \ "build-helper: the inline is instrumented inside the plugin realm" "$CHECK_TMP/bh.txt" assert_contains "$CHECK_TMP/bh-agent.txt" 'line=1 src=inline evaluation of' \ "build-helper: lines are snippet-relative (first statement is line 1)" "$CHECK_TMP/bh.txt" diff --git a/agent/checks/03-scopes-and-introspection.sh b/agent/checks/03-scopes-and-introspection.sh index 7a7b1fa..2adef6f 100755 --- a/agent/checks/03-scopes-and-introspection.sh +++ b/agent/checks/03-scopes-and-introspection.sh @@ -5,11 +5,12 @@ # Drives the real transport -- mock-ide.py is the IDE end -- so this covers the socket conversation # as well as the values, which no unit test on either side does alone. # -# The two things asserted here are easy to regress invisibly. A bsh.This must expand to the +# The three things asserted here are easy to regress invisibly. A bsh.This must expand to the # *namespace* it stands for rather than to its Java fields (that is what makes a closure's captured -# scope, a scripted instance's _bshThis... field, and a This handed back to Java all readable), and -# Global must appear when stopped inside a method, since that is where a script's top-level state -# would otherwise become invisible. +# scope, a scripted instance's _bshThis... field, and a This handed back to Java all readable), Global +# must appear when stopped inside a method, since that is where a script's top-level state would +# otherwise become invisible, and a `for` loop's own namespace must appear as its own level rather +# than being lost inside Locals' or absorbed into Global. source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" @@ -79,4 +80,46 @@ assert_contains "$CHECK_TMP/ide.txt" 'count = 0 (int)' \ assert_not_contains "$CHECK_TMP/ide.txt" 'declaringInterpreter' \ "expanding a This does not leak bsh.XThis's own Java fields" +# --- a `for` loop's own namespace is its own scope level ------------------------------------------ +# +# BSHForStatement wraps the loop in a BlockNameSpace of its own (holding the init variable) and the +# body runs in a second, subordinate BlockNameSpace -- two levels below the script's own Locals used +# to be flattened into one, hiding a typed loop variable declared in the `for`'s own init behind +# whichever scope's ancestor-walk happened to reach it first. + +cat > "$CHECK_TMP/forloop.bsh" <<'EOF' +total = 0; +for (int i = 1; i <= 3; i++) { + total += i; + print("step " + i); +} +EOF + +PORT2=$((20000 + RANDOM % 20000)) +python3 "$REPO_ROOT/plugin/tools/mock-ide.py" "$PORT2" \ + --breakpoints forloop.bsh:3 --expand > "$CHECK_TMP/for-ide.txt" 2>&1 & +FOR_IDE_PID=$! + +for _ in $(seq 50); do + grep -q 'listening on' "$CHECK_TMP/for-ide.txt" 2>/dev/null && break + sleep 0.1 +done + +"$JAVA" -javaagent:"$AGENT_JAR" -Dbsh.debug.port="$PORT2" -Dbsh.debug.sources=forloop.bsh \ + -cp "$BSH_CLASSPATH" bsh.Interpreter "$CHECK_TMP/forloop.bsh" \ + > "$CHECK_TMP/for-script.txt" 2>&1 +wait "$FOR_IDE_PID" 2>/dev/null || true + +assert_contains "$CHECK_TMP/for-ide.txt" 'Block:' \ + "the for-loop's own namespace is offered as a level of its own" +assert_contains "$CHECK_TMP/for-ide.txt" 'i = 1 (int)' \ + "the loop variable is visible, in the Block level it was actually declared in" "$CHECK_TMP/for-ide.txt" + +# Global's own slice of the report, isolated so the assertion below cannot pass just because "i" +# legitimately appears a few lines up, under Block. +sed -n '/ Global:/,/^\[mock-ide\] STOPPED\|^\[mock-ide\] agent disconnected/p' "$CHECK_TMP/for-ide.txt" \ + > "$CHECK_TMP/for-global-only.txt" +assert_not_contains "$CHECK_TMP/for-global-only.txt" 'i = 1 (int)' \ + "the loop variable is not repeated in Global -- each level reports only its own" "$CHECK_TMP/for-ide.txt" + finish diff --git a/agent/checks/04-behaviour-unchanged.sh b/agent/checks/04-behavior-unchanged.sh similarity index 81% rename from agent/checks/04-behaviour-unchanged.sh rename to agent/checks/04-behavior-unchanged.sh index eec5cb9..a65cd32 100755 --- a/agent/checks/04-behaviour-unchanged.sh +++ b/agent/checks/04-behavior-unchanged.sh @@ -6,17 +6,17 @@ # comparison has to allow exactly three differences, all documented in agent/samples/README.md, and # allowing them by pattern rather than by eye is the point of automating it: # -# * identity hash codes shift (Point@279f2327 -> Point@30f39991), because initialising the hook on +# * identity hash codes shift (Point@279f2327 -> Point@30f39991), because initializing the hook on # the interpreter thread advances that thread's identity-hash sequence; # * the interleaving of the two threads in scenario 5 is not deterministic in either run; # * bsh.NameSpace@... addresses, for the same reason as the first. # -# Anything else differing means the agent changed behaviour, which is a bug however useful the +# Anything else differing means the agent changed behavior, which is a bug however useful the # debugger is. source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -banner "04 — behaviour is unchanged with the agent attached" +banner "04 — behavior is unchanged with the agent attached" if ! "$GRADLEW" -q -p "$REPO_ROOT" :agent:samples:runHost > "$CHECK_TMP/plain.txt" 2>"$CHECK_TMP/plain.err"; then fail "the uninstrumented fixtures run" "$(tail -3 "$CHECK_TMP/plain.err")" @@ -31,28 +31,28 @@ fi pass "both runs completed" -# Normalise the three legitimate differences away, then require equality. Sorting the thread-5 lines +# Normalize the three legitimate differences away, then require equality. Sorting the thread-5 lines # is what makes the interleaving irrelevant without hiding a missing line: a dropped or extra line # still changes the sorted text. -normalise() { +normalize() { sed -E \ -e 's/@[0-9a-f]{6,}/@HASH/g' \ -e 's/bsh\.NameSpace: [^ ]+ \(bsh\.NameSpace@HASH\)/bsh.NameSpace@HASH/g' \ "$1" | LC_ALL=C sort } -normalise "$CHECK_TMP/plain.txt" > "$CHECK_TMP/plain.norm" -normalise "$CHECK_TMP/agent.txt" > "$CHECK_TMP/agent.norm" +normalize "$CHECK_TMP/plain.txt" > "$CHECK_TMP/plain.norm" +normalize "$CHECK_TMP/agent.txt" > "$CHECK_TMP/agent.norm" if diff -q "$CHECK_TMP/plain.norm" "$CHECK_TMP/agent.norm" >/dev/null; then - pass "output is identical once identity hashes and thread interleaving are normalised" + pass "output is identical once identity hashes and thread interleaving are normalized" else fail "output differs beyond the three documented differences" \ "$(diff "$CHECK_TMP/plain.norm" "$CHECK_TMP/agent.norm" | head -20)" fi # A weaker but independent assertion: the same number of lines, which catches a fixture that silently -# stopped early under the agent even if normalisation were too generous. +# stopped early under the agent even if normalization were too generous. plain_lines=$(wc -l < "$CHECK_TMP/plain.txt") agent_lines=$(wc -l < "$CHECK_TMP/agent.txt") assert_equals "$plain_lines" "$agent_lines" "both runs produced the same number of output lines" diff --git a/agent/checks/07-dap-transport.sh b/agent/checks/07-dap-transport.sh index 2338de5..00ad08a 100755 --- a/agent/checks/07-dap-transport.sh +++ b/agent/checks/07-dap-transport.sh @@ -7,7 +7,7 @@ # keeps the native protocol, which check 03 and 05 cover. # # What this asserts is that a real DAP conversation works end to end -- the handshake in the right -# order, breakpoints honoured, a stack with more than one frame, both scopes, and an expression +# order, breakpoints honored, a stack with more than one frame, both scopes, and an expression # evaluated in the stopped frame. Between them those cover every translation the DAP channel makes, # and each one has a specific way of going wrong: # diff --git a/agent/checks/README.md b/agent/checks/README.md index 67f4877..92c2c6e 100644 --- a/agent/checks/README.md +++ b/agent/checks/README.md @@ -8,22 +8,22 @@ End-to-end checks for the debug agent, as standalone bash scripts. ``` Each script builds what it needs, prints one `PASS`/`FAIL` line per assertion, and exits -non-zero if any failed. `JAVA_HOME` is honoured; the agent targets Java 8, so anything +non-zero if any failed. `JAVA_HOME` is honored; the agent targets Java 8, so anything 8+ works for the debugged JVM. ## Why these are not Gradle tests Each one needs something a JVM test cannot arrange from inside itself: -| check | needs | -|---|---| -| `01-inline-eval-source-name.sh` | a JVM launched with `-javaagent`, so the interpreter is instrumented before it loads | -| `02-maven-plugin-realm.sh` | a **real `mvn` process**, because the thing under test is a Maven plugin's own classloader | -| `03-scopes-and-introspection.sh` | two processes and a socket between them — the actual wire protocol | -| `04-behaviour-unchanged.sh` | the same fixtures run twice, in separate JVMs, one with the agent | -| `05-two-script-threads.sh` | two real threads, suspended at the same time over one socket | -| `06-suspend-all.sh` | a thread stopping at a line that has no breakpoint on it | -| `07-dap-transport.sh` | a real DAP conversation, handshake included, over a socket | +| check | needs | +|----------------------------------|--------------------------------------------------------------------------------------------| +| `01-inline-eval-source-name.sh` | a JVM launched with `-javaagent`, so the interpreter is instrumented before it loads | +| `02-maven-plugin-realm.sh` | a **real `mvn` process**, because the thing under test is a Maven plugin's own classloader | +| `03-scopes-and-introspection.sh` | two processes and a socket between them — the actual wire protocol | +| `04-behavior-unchanged.sh` | the same fixtures run twice, in separate JVMs, one with the agent | +| `05-two-script-threads.sh` | two real threads, suspended at the same time over one socket | +| `06-suspend-all.sh` | a thread stopping at a line that has no breakpoint on it | +| `07-dap-transport.sh` | a real DAP conversation, handshake included, over a socket | They are also the checks you want *while* changing the agent, one at a time, with the output in front of you — which is the other reason they are scripts. @@ -64,14 +64,14 @@ only observation that distinguishes the round-up from ordinary per-thread suspen thread its own code so the breakpoint can belong to one of them alone. **`07` — the DAP transport.** That the same debugger works over DAP: the handshake in the right -order (a client that never sees `initialized` configures nothing), breakpoints honoured, a stack +order (a client that never sees `initialized` configures nothing), breakpoints honored, a stack with depth, both scopes, and an expression evaluated in the stopped frame. Between them those cover every translation `DapChannel` performs. `dap-client.py` beside it is a standalone DAP client for driving a session by hand, the way `mock-ide.py` is for the native protocol. -**`04` — behaviour is unchanged.** The agent must not change what a script does. Allows +**`04` — behavior is unchanged.** The agent must not change what a script does. Allows exactly the three differences documented in [`../samples/README.md`](../samples/README.md) -(identity hashes, `NameSpace` addresses, thread interleaving) by normalising them, and +(identity hashes, `NameSpace` addresses, thread interleaving) by normalizing them, and requires equality otherwise. ## Adding one diff --git a/agent/checks/dap-client.py b/agent/checks/dap-client.py index d847479..aa92e3e 100755 --- a/agent/checks/dap-client.py +++ b/agent/checks/dap-client.py @@ -23,7 +23,7 @@ [--evaluate 'expr'] [--set name=value] [--json] Prints one line per protocol step, which is what the checks assert on. `--json` additionally -dumps every message, for when the disagreement is about the wire rather than the behaviour. +dumps every message, for when the disagreement is about the wire rather than the behavior. """ import argparse import json @@ -171,7 +171,7 @@ def main(): conn.request("configurationDone") print("[dap] configurationDone", flush=True) - for index in range(args.stops): + for _ in range(args.stops): stopped = conn.wait_event("stopped", timeout=30) if stopped is None: print("[dap] no further stops", flush=True) diff --git a/agent/checks/run-all.sh b/agent/checks/run-all.sh index 807a126..c515006 100755 --- a/agent/checks/run-all.sh +++ b/agent/checks/run-all.sh @@ -7,10 +7,11 @@ # diagnosis. set -uo pipefail -cd "$(dirname "${BASH_SOURCE[0]}")" +cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1 +files=( [0-9][0-9]-*.sh ) failed=() -for check in [0-9][0-9]-*.sh; do +for check in "${files[@]}"; do if ! bash "$check"; then failed+=("$check") fi diff --git a/agent/hook/build.gradle.kts b/agent/hook/build.gradle.kts index 3f5682f..08a7ac3 100644 --- a/agent/hook/build.gradle.kts +++ b/agent/hook/build.gradle.kts @@ -14,8 +14,19 @@ plugins { group = "cz.loplex.bsh" description = "Bootstrap-loaded hook invoked by the instrumented BeanShell interpreter" +// See agent/instrument/build.gradle.kts for why the toolchain forks an actual JDK 8 javac +// (no `--release` flag: that's a JDK 9+ flag javac 8 doesn't understand -- the toolchain's +// own source/target default already is 8), and why sourceCompatibility is declared here too: +// IntelliJ's Gradle sync doesn't evaluate `configureEach {}` blocks. +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(8)) + } + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + tasks.withType().configureEach { - options.release.set(8) options.encoding = "UTF-8" } diff --git a/agent/hook/src/main/java/cz/loplex/bsh/hook/BshHook.java b/agent/hook/src/main/java/cz/loplex/bsh/hook/BshHook.java index 89e1ab7..dfe9987 100644 --- a/agent/hook/src/main/java/cz/loplex/bsh/hook/BshHook.java +++ b/agent/hook/src/main/java/cz/loplex/bsh/hook/BshHook.java @@ -1,16 +1,15 @@ package cz.loplex.bsh.hook; -import java.io.BufferedOutputStream; import java.io.BufferedReader; -import java.io.DataInputStream; import java.io.DataOutputStream; -import java.io.FileInputStream; import java.io.IOException; import java.io.InputStreamReader; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; -import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -33,9 +32,10 @@ * reflection throughout. Configuration arrives through system properties for the same reason. * *

The wire format is protocol 3, specified in {@code docs/PROTOCOL.md}. Per reported statement - * the hook writes {@link #EVT_STOPPED} with the call stack and then blocks that thread, - * answering whatever the IDE asks about its suspended frames — scopes, variables, an expression to - * evaluate, a value to change — until it is resumed. Other script threads keep running and may + * the hook writes via {@link DebugChannel#sendStopped} with the call stack and then blocks + * that thread, answering whatever the IDE asks about its suspended frames — scopes, + * variables, an expression to evaluate, a value to change — until it is resumed. Other script + * threads keep running and may * report alongside; see {@link ThreadState} and {@link #readerLoop} for how that works, and * {@link #report} for why only the reporting thread suspends. Failure handling is described on * {@link #onEval}. @@ -116,7 +116,7 @@ public final class BshHook { /** * Upper bound on the children of one value. Lazy expansion removes the cost of unopened * objects, not the cost of an opened one, and a million-element list would still stall the - * interpreter thread while it serialised. + * interpreter thread while it serialized. */ private static final int MAX_CHILDREN = 1000; @@ -134,7 +134,7 @@ public final class BshHook { * own right: {@code BSHBlock} would add a stop on the {@code {} of every braced body, and * {@code BSHSwitchLabel} is a {@code case} label, not a statement. */ - private static final Set NEVER_REPORTED = new HashSet(Arrays.asList( + private static final Set NEVER_REPORTED = new HashSet<>(Arrays.asList( "BSHBlock", "BSHSwitchLabel")); @@ -159,7 +159,7 @@ public final class BshHook { * indices. * * This is per-node-type knowledge, which the transformer deliberately avoids — but here it - * degrades gracefully. An unrecognised container simply reports none of its direct children, + * degrades gracefully. An unrecognized container simply reports none of its direct children, * exactly as before this rule existed, so a future BeanShell that renames or reshapes a node * loses brace-less-body coverage instead of misbehaving. */ @@ -170,54 +170,6 @@ public final class BshHook { private static final String SWITCH_STATEMENT = "BSHSwitchStatement"; private static final String BLOCK = "BSHBlock"; - /* - * Commands the IDE may send on the return channel. RESUME releases a reported statement; any - * number of the others may precede it. - * - * Until the IDE sends SET_BREAKPOINTS at least once every statement is reported, because an IDE - * that configures nothing must not go blind. Once it does, the agent falls silent while running - * and speaks up only at a breakpoint, which removes the round-trip per statement that made a - * plain loop crawl. - */ - private static final int CMD_RESUME = 0x01; - private static final int CMD_SET_BREAKPOINTS = 0x02; - private static final int CMD_SET_RUN_MODE = 0x03; - - /** - * Turns "report everything, on every thread" on and off, so the IDE can round up the other - * threads when a breakpoint says Suspend: All. Global rather than per thread — that is what it - * means. See {@link #catchAll}. - */ - private static final int CMD_SET_CATCH_ALL = 0x08; - - /** - * Requests the IDE may issue while a statement is suspended, each answered with the matching - * {@code EVT_*} reply before the loop goes back to waiting. - * - *

They are served on the interpreter thread, from inside the same command loop that waits - * for {@link #CMD_RESUME}. That is not a shortcut: the thread is parked there anyway, it is - * the thread that owns the BeanShell state being inspected, and answering anywhere else would - * need a lock BeanShell does not offer. - */ - private static final int CMD_SCOPES = 0x04; - private static final int CMD_VARIABLES = 0x05; - private static final int CMD_EVALUATE = 0x06; - private static final int CMD_SET_VARIABLE = 0x07; - - /* - * The agent-to-IDE direction is opcode-tagged as of protocol 2. It used to be a bare stream of - * statement reports, which left no room for a reply to travel back the other way. - * - * There is no negotiation and no fallback to the old shape, because there is nothing to - * negotiate with: the agent jar ships inside the plugin, so both ends are always the same - * build. The tools in plugin/tools speak this format too. - */ - private static final int EVT_STOPPED = 0x10; - private static final int EVT_SCOPES = 0x11; - private static final int EVT_VARIABLES = 0x12; - private static final int EVT_EVALUATED = 0x13; - private static final int EVT_VARIABLE_SET = 0x14; - /** * Handle 0 is never issued, so the IDE can use it to mean "this value has no children" without * a separate flag on every variable. @@ -254,10 +206,10 @@ private static final class ThreadState { /** Commands the reader thread has handed to this thread, in arrival order. */ final BlockingQueue mailbox = - new LinkedBlockingQueue(); + new LinkedBlockingQueue<>(); /** Objects the IDE may expand, valid only for this thread's current stop. */ - final Map handles = new HashMap(); + final Map handles = new HashMap<>(); /** The frames of this thread's current stop, innermost first. Empty while running. */ Object[] frames = new Object[0]; @@ -285,7 +237,7 @@ private static final class ThreadState { /** Live thread states by protocol id, for the reader thread to dispatch into. */ private static final Map threadsById = - new ConcurrentHashMap(); + new ConcurrentHashMap<>(); /** * This thread's state, created on first use. @@ -293,7 +245,7 @@ private static final class ThreadState { *

A {@code ThreadLocal} rather than a lookup by {@code Thread.currentThread()} because * {@link #onEval} consults it on every instrumented node — the hottest path in the agent. */ - private static final ThreadLocal STATE = new ThreadLocal(); + private static final ThreadLocal STATE = new ThreadLocal<>(); private static final AtomicInteger nextThreadId = new AtomicInteger(1); @@ -313,24 +265,12 @@ private static final class ThreadState { * overflowed. It stays set for the whole of {@link #report}, so everything served while * suspended is covered, including an expression that calls a script method. */ - private static final ThreadLocal REPORTING = new ThreadLocal(); + private static final ThreadLocal REPORTING = new ThreadLocal<>(); - /** - * Serialises writes to the socket, and nothing else. - * - *

This is the whole of what used to be {@code LOCK}. Before threads, one lock covered - * connecting, writing, and being suspended — which is precisely why two threads could not be - * suspended at once: the first held it for the duration of its stop. Now a stop holds no lock at - * all; it parks on its own mailbox, and this guards only the moments when bytes are being put on - * the wire, so a second thread can report while the first is still suspended. - * - *

A message must be written under a single acquisition, or two threads' fields would - * interleave into an unparseable stream. - */ /** * Whether every thread should report its next statement, whatever the breakpoints say. * - *

How Suspend: All is honoured without pretending to be JDWP. A thread cannot be frozen from + *

How Suspend: All is honored without pretending to be JDWP. A thread cannot be frozen from * outside — it only ever stops where it calls the hook — so "suspend all" is implemented as * "everyone reports at the next statement, and the IDE decides who stays stopped". The IDE sets * this when a Suspend: All breakpoint is hit and clears it on resume. @@ -350,9 +290,7 @@ private static final class ThreadState { */ private static final long CONFIGURATION_TIMEOUT_MS = 30_000L; - private static final Object WRITE_LOCK = new Object(); - - /** Guards {@link #connect} and the reader-thread start, which must happen exactly once. */ + /** Guards {@link DebugChannel#connect} and the reader-thread start, which must happen exactly once. */ private static final Object CONNECT_LOCK = new Object(); private static final int port; @@ -367,7 +305,7 @@ private static final class ThreadState { * channel decides how to encode it. That is what makes a second protocol a matter of one more * implementation rather than a second debugger. */ - private static DebugChannel channel; + private static final DebugChannel channel; // Reflection handles, resolved once. Every BSH* node inherits these from the // package-private bsh.SimpleNode, so a single Method works for all of them. @@ -378,7 +316,6 @@ private static final class ThreadState { private static Method nodeGetChild; private static Field whileIsDoStatement; private static Method callStackDepth; - private static Method callStackTop; private static Method nameSpaceGetVariableNames; private static Method nameSpaceGetVariable; private static Method nameSpaceGetParent; @@ -393,14 +330,6 @@ private static final class ThreadState { private static Method primitiveGetType; private static boolean reflectionFailed; - /** - * Objects the IDE may ask to expand, valid only for the current stop. - * - *

Discarded on every resume, which is the whole reason handles are safe: the IDE can never - * hold a reference into a script that has moved on, so there is no stale-object problem to - * solve and no cleanup protocol to get wrong. This mirrors DAP, where a - * {@code variablesReference} is explicitly invalid once execution continues. - */ static { int parsed = -1; String portProperty = System.getProperty(PORT_PROPERTY); @@ -447,6 +376,7 @@ private BshHook() { } /** A port property, or [fallback] when unset or unparseable. */ + @SuppressWarnings("SameParameterValue") private static int parsedPort(String property, int fallback) { String value = System.getProperty(property); if (value == null || value.trim().isEmpty()) { @@ -512,7 +442,7 @@ public static void onEval(Object node, Object callstack, Object interpreter) { } report(state, line, sourceFile, callstack, interpreter); } catch (Throwable t) { - // Never let a debugging problem change the behaviour of the debugged script. + // Never let a debugging problem change the behavior of the debugged script. disabled = true; System.err.println("[bsh-agent] disabling instrumentation after an internal error: " + t); close(); @@ -545,22 +475,24 @@ private static boolean isStatement(Object node) throws Exception { if (index < 0) { return false; } - if (IF_STATEMENT.equals(parentName) || SWITCH_STATEMENT.equals(parentName)) { - return index >= 1; - } - if (FOR_STATEMENT.equals(parentName) || ENHANCED_FOR_STATEMENT.equals(parentName)) { - return index == count - 1; - } - if (WHILE_STATEMENT.equals(parentName)) { - return isDoStatement(parent) ? index == 0 : index == count - 1; + switch (parentName) { + case IF_STATEMENT: + case SWITCH_STATEMENT: + return index >= 1; + case FOR_STATEMENT: + case ENHANCED_FOR_STATEMENT: + return index == count - 1; + case WHILE_STATEMENT: + return isDoStatement(parent) ? index == 0 : index == count - 1; + default: + return false; } - return false; } /** Identity search: nodes have no usable equals(), and the same subtree never repeats. */ private static int indexOfChild(Object parent, Object child, int count) throws Exception { for (int i = 0; i < count; i++) { - if (nodeGetChild.invoke(parent, Integer.valueOf(i)) == child) { + if (nodeGetChild.invoke(parent, i) == child) { return i; } } @@ -612,15 +544,15 @@ private static boolean shouldReport(ThreadState state, String sourceFile, int li if (configured == null || state.runMode != MODE_RUN) { return true; } - List files = configured.get(Integer.valueOf(line)); + List files = configured.get(line); if (files == null) { return false; } if (sourceFile == null) { return false; } - for (int i = 0; i < files.size(); i++) { - if (pathsMatch(sourceFile, files.get(i))) { + for (String file : files) { + if (pathsMatch(sourceFile, file)) { return true; } } @@ -683,6 +615,7 @@ private static void drainMailbox(ThreadState state) { * {@link #applyCommand}. That was never a shortcut: only that thread can safely touch its own * BeanShell state, and answering from here would need a lock BeanShell does not offer. */ + @SuppressWarnings("ResultOfMethodCallIgnored") // mailbox is an unbounded queue; offer() cannot fail private static void readerLoop() { try { while (true) { @@ -707,7 +640,7 @@ private static void readerLoop() { default: break; } - ThreadState target = threadsById.get(Integer.valueOf(command.threadId)); + ThreadState target = threadsById.get(command.threadId); if (target == null) { // A command for a thread that has exited. Dropping it is right: there is nobody // to answer for it, and the client will have been told the thread is gone. @@ -735,7 +668,7 @@ private static void readerLoop() { /** * Applies one command on the thread that owns the state, and answers it if it expects an answer. * - *

Returns true when the thread should stop waiting. An unrecognised command counts as a + *

Returns true when the thread should stop waiting. An unrecognized command counts as a * release: the worst case is a script that keeps running, whereas ignoring it could leave a thread * parked for good. */ @@ -802,6 +735,7 @@ private static void awaitResume(ThreadState state) throws IOException { } /** One place for "the IDE went away", which must never abort the host program. */ + @SuppressWarnings("ResultOfMethodCallIgnored") // mailbox is an unbounded queue; offer() cannot fail private static void sessionLost(IOException ex) { if (!disabled) { System.err.println("[bsh-agent] debug session disconnected; continuing without debugging (" @@ -810,7 +744,7 @@ private static void sessionLost(IOException ex) { disabled = true; close(); // Release everyone parked on a mailbox, or a suspended thread would wait for an IDE that is - // gone. An empty message array is read as "unrecognised", which applyCommand treats as a + // gone. An empty message array is read as "unrecognized", which applyCommand treats as a // release. for (ThreadState waiting : threadsById.values()) { waiting.mailbox.offer(DebugChannel.Command.simple( @@ -820,14 +754,10 @@ private static void sessionLost(IOException ex) { /** Replaces the breakpoint set with the one the client just sent. */ private static void applyBreakpoints(DebugChannel.Command command) { - Map> parsed = new HashMap>(); + Map> parsed = new HashMap<>(); for (int i = 0; i < command.breakpointLines.length; i++) { - Integer key = Integer.valueOf(command.breakpointLines[i]); - List files = parsed.get(key); - if (files == null) { - files = new ArrayList(2); - parsed.put(key, files); - } + Integer key = command.breakpointLines[i]; + List files = parsed.computeIfAbsent(key, k -> new ArrayList<>(2)); files.add(command.breakpointFiles[i]); } // Published as a whole so a concurrent shouldReport() never sees a half-built map. @@ -866,36 +796,27 @@ private static boolean isReportedSource(String sourceFile) { * Reads the prefix list, or returns null when there is none to read. * *

An unreadable file returns null — "report everything" — rather than failing. A filter is an - * optimisation over reporting every statement and letting the IDE decide; losing it costs speed, + * optimization over reporting every statement and letting the IDE decide; losing it costs speed, * whereas throwing here would abort somebody's build over a missing temp file. */ private static String[] readSourcePrefixes(String path) { if (path == null || path.trim().isEmpty()) { return null; } - BufferedReader reader = null; - try { - reader = new BufferedReader(new InputStreamReader(new FileInputStream(path), "UTF-8")); - List lines = new ArrayList(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(Files.newInputStream(Paths.get(path)), StandardCharsets.UTF_8))) { + List lines = new ArrayList<>(); String line; while ((line = reader.readLine()) != null) { if (!line.isEmpty()) { lines.add(line); } } - return lines.isEmpty() ? null : lines.toArray(new String[lines.size()]); + return lines.isEmpty() ? null : lines.toArray(new String[0]); } catch (IOException ex) { System.err.println("[bsh-agent] cannot read " + SOURCE_PREFIXES_FILE_PROPERTY + "='" + path + "', reporting every source: " + ex); return null; - } finally { - if (reader != null) { - try { - reader.close(); - } catch (IOException ignored) { - // Nothing useful to do about a failed close on a file we only read. - } - } } } @@ -931,7 +852,7 @@ private static void report(ThreadState state, int line, String sourceFile, Objec state.frames = frames; state.interpreter = interpreter; try { - List reported = new ArrayList(frames.length); + List reported = new ArrayList<>(frames.length); for (int i = 0; i < frames.length; i++) { // Frame 0 sits at the statement being reported; every outer frame sits at the call // site recorded by the frame below it. Reading getInvocationLine() off the frame @@ -979,11 +900,7 @@ private static boolean ensureConnected() { + port + " (" + ex + "); aborting"); System.exit(EXIT_DEBUG_UNAVAILABLE); } - Thread reader = new Thread(new Runnable() { - public void run() { - readerLoop(); - } - }, "bsh-agent-reader"); + Thread reader = new Thread(BshHook::readerLoop, "bsh-agent-reader"); reader.setDaemon(true); reader.start(); } @@ -1002,6 +919,7 @@ public void run() { * program. Timing out means the script runs on unfiltered, which is the same outcome as an IDE that * never sends a breakpoint set. */ + @SuppressWarnings("BusyWait") // bounded 20ms poll against a 30s deadline, not an unbounded spin private static boolean waitForConfiguration() { if (!(channel instanceof DapChannel)) { return true; @@ -1038,7 +956,7 @@ private static ThreadState stateFor() { Thread current = Thread.currentThread(); ThreadState created = new ThreadState(nextThreadId.getAndIncrement(), current.getName()); STATE.set(created); - threadsById.put(Integer.valueOf(created.id), created); + threadsById.put(created.id, created); return created; } @@ -1102,41 +1020,78 @@ private static String nullToEmpty(String value) { } /** - * Answers {@link #CMD_SCOPES}: the scopes of one frame, each a handle the IDE can expand. + * Answers {@link DebugChannel.Command.Kind#SCOPES}: the scopes of one frame, each a handle the + * IDE can expand. * - *

Two scopes, and the second is the point of having the level at all: Global is the - * interpreter's own namespace, which is where a script's top-level state lives once execution has - * descended into a method. Without it, stopping inside a method shows the method's locals and - * nothing else — the script's own globals become invisible exactly when they are most likely to - * be what is wrong. + *

One scope per level of the namespace chain, innermost first, each reporting only the + * variables declared directly in it — not its own view of everything an enclosing scope also + * holds. A {@code for} loop's own namespace and the block inside it are two such levels, so a + * loop variable declared in the {@code for}'s init no longer gets lost inside a "Locals" group + * whose own reporting already walked past it while gathering something else. * - *

Global is omitted when it is the frame's namespace (a stop at top level, where the two - * are the same object) and when there is no interpreter to ask — the rewriting path, which is - * handed a namespace only. + *

Global — the interpreter's own namespace, where a script's top-level state lives once + * execution has descended into a method — is the last level reached, identified by object + * identity rather than by position: without it, stopping inside a method would show the method's + * locals and nothing else, exactly when the script's own globals are most likely to be what is + * wrong. It is naturally absent when the walk never reaches it — the rewriting path, which is + * handed a namespace only and has no interpreter to ask. */ private static List collectScopes(ThreadState state, int frameId) { Object namespace = frame(state, frameId); Object global = globalNameSpace(state); - boolean hasGlobal = global != null && global != namespace; - List scopes = new ArrayList(2); - if (namespace != null) { - scopes.add(new DebugChannel.Scope("Locals", handleFor(state, namespace))); - } - if (hasGlobal) { - scopes.add(new DebugChannel.Scope("Global", handleFor(state, global))); + List scopes = new ArrayList<>(); + Object level = namespace; + boolean innermost = true; + while (level != null) { + String name = innermost ? "Locals" : level == global ? "Global" : levelName(level); + scopes.add(new DebugChannel.Scope(name, handleFor(state, new ScopeLevel(level)))); + if (level == global) { + break; + } + innermost = false; + level = parentOf(level); } return scopes; } + /** A block ("for", "if", …) or a closure's own namespace, once its parent has already been walked. */ + private static String levelName(Object namespace) { + return "BlockNameSpace".equals(simpleName(namespace)) ? "Block" : "Closure"; + } + + /** {@code NameSpace.getParent()}, or null once the chain ends or cannot be read further. */ + private static Object parentOf(Object namespace) { + try { + return nameSpaceGetParent.invoke(namespace); + } catch (Throwable t) { + return null; + } + } + + /** + * One level of {@link #collectScopes}'s namespace chain, so {@link #collectVariables} can tell it + * apart from a raw {@code NameSpace} — which still reports its whole parent chain, for expanding a + * closure's captured scope as a single value rather than as a list of levels. + */ + private static final class ScopeLevel { + final Object namespace; + + ScopeLevel(Object namespace) { + this.namespace = namespace; + } + } + /** * The children of one handle, each with a handle of its own when it can be expanded further. */ private static List collectVariables(ThreadState state, int handle) { - Object target = state.handles.get(Integer.valueOf(handle)); - List children = new ArrayList(); - List values = new ArrayList(); + Object target = state.handles.get(handle); + List children = new ArrayList<>(); + List values = new ArrayList<>(); try { - if (isNameSpace(target)) { + if (target instanceof ScopeLevel) { + collectNamespaceLevel(((ScopeLevel) target).namespace, children, values); + } else if (isNameSpace(target)) { collectNamespace(target, children, values); } else if (isThis(target)) { // Expand a This as the scope it stands for, not as a Java object. Its Java fields are @@ -1155,7 +1110,7 @@ private static List collectVariables(ThreadState state, i } catch (Throwable ignored) { // Send whatever was gathered; an unreadable object is not worth failing the session. } - List variables = new ArrayList(children.size()); + List variables = new ArrayList<>(children.size()); for (int i = 0; i < children.size(); i++) { String[] entry = children.get(i); variables.add(new DebugChannel.Variable(entry[0], entry[1], entry[2], @@ -1198,7 +1153,7 @@ static Outcome failed(String reason) { } /** - * Answers {@link #CMD_EVALUATE}: runs an expression in one frame's scope. + * Answers {@link DebugChannel.Command.Kind#EVALUATE}: runs an expression in one frame's scope. * *

The interpreter does the evaluating, so a watch expression sees exactly what the script * sees at that point — its variables, its methods, its imports — rather than a reimplementation @@ -1228,7 +1183,8 @@ private static Outcome evaluate(ThreadState state, int frameId, String expressio } /** - * Answers {@link #CMD_SET_VARIABLE}: evaluates an expression and stores it into {@code handle}. + * Answers {@link DebugChannel.Command.Kind#SET_VARIABLE}: evaluates an expression and stores it + * into {@code handle}. * *

A variable in scope is assigned by evaluating the assignment itself, so BeanShell applies * its own rules rather than this code guessing at them: a typed variable refuses an @@ -1242,7 +1198,7 @@ private static Outcome assign(ThreadState state, int frameId, int handle, String if (frame(state, frameId) == null) { return Outcome.failed("No frame " + frameId + " at this stop"); } - Object target = state.handles.get(Integer.valueOf(handle)); + Object target = state.handles.get(handle); if (target == null) { return Outcome.failed("This value is no longer available"); } @@ -1327,7 +1283,7 @@ private static Outcome storeInMap(ThreadState state, Map map, St return Outcome.failed("No entry " + name + " in this map"); } // Written through the map rather than through Map.Entry.setValue, which not every - // implementation honours once iteration has finished. + // implementation honors once iteration has finished. map.put(key, value); return Outcome.of(state, map.get(key)); } @@ -1373,7 +1329,7 @@ private static String reason(Throwable error) { * stack trace on further lines. Only the tail of the first line belongs in an IDE error field. * *

The search for the echo's closing quotes starts past the expression, so quotes inside what - * the user typed cannot split the message in the wrong place. An unrecognised shape falls back + * the user typed cannot split the message in the wrong place. An unrecognized shape falls back * to the whole first line, which is verbose rather than wrong. */ private static String describe(Throwable error, String expression) { @@ -1407,7 +1363,7 @@ private static String describe(Throwable error, String expression) { */ private static void collectNamespace(Object namespace, List children, List values) throws Exception { - Set seen = new HashSet(); + Set seen = new HashSet<>(); while (namespace != null) { Object names = nameSpaceGetVariableNames.invoke(namespace); if (names instanceof String[]) { @@ -1428,6 +1384,31 @@ private static void collectNamespace(Object namespace, List children, } } + /** + * Variables declared directly in one namespace — not its parent's, unlike {@link + * #collectNamespace}. Used for a {@link ScopeLevel} handle, where the parent is already its own + * separate level in {@link #collectScopes}'s list; walking past it here would report it twice. + */ + private static void collectNamespaceLevel(Object namespace, List children, List values) + throws Exception { + Object names = nameSpaceGetVariableNames.invoke(namespace); + if (!(names instanceof String[])) { + return; + } + for (String name : (String[]) names) { + if (name == null || name.equals("bsh")) { + continue; + } + Object value; + try { + value = nameSpaceGetVariable.invoke(namespace, name); + } catch (Throwable t) { + value = ""; + } + add(children, values, name, value); + } + } + /** Children of an ordinary value: array elements, collection entries, map entries, or fields. */ private static void collectValue(Object target, List children, List values) { if (target.getClass().isArray()) { @@ -1439,8 +1420,7 @@ private static void collectValue(Object target, List children, List) target).entrySet()) { - Map.Entry entry = (Map.Entry) o; + for (Map.Entry entry : ((Map) target).entrySet()) { add(children, values, String.valueOf(entry.getKey()), entry.getValue()); if (++i >= MAX_CHILDREN) { break; @@ -1551,7 +1531,7 @@ private static Class primitiveType(Object primitive) { } private static boolean isNameSpace(Object candidate) { - return candidate != null && nameSpaceClass != null && nameSpaceClass.isInstance(candidate); + return nameSpaceClass != null && nameSpaceClass.isInstance(candidate); } /** @@ -1565,7 +1545,7 @@ private static boolean isNameSpace(Object candidate) { * Java. */ private static boolean isThis(Object candidate) { - return candidate != null && thisClass != null && thisClass.isInstance(candidate); + return thisClass != null && thisClass.isInstance(candidate); } /** @@ -1609,11 +1589,11 @@ private static Object globalNameSpace(ThreadState state) { private static int handleFor(ThreadState state, Object value) { for (Map.Entry entry : state.handles.entrySet()) { if (entry.getValue() == value) { - return entry.getKey().intValue(); + return entry.getKey(); } } int handle = nextHandle.getAndIncrement(); - state.handles.put(Integer.valueOf(handle), value); + state.handles.put(handle, value); return handle; } @@ -1649,7 +1629,7 @@ private static boolean resolveReflection(Object node, Object callstack) { Class callStackClass = callstack.getClass(); callStackDepth = accessible(callStackClass.getMethod("depth")); - callStackTop = accessible(callStackClass.getMethod("top")); + Method callStackTop = accessible(callStackClass.getMethod("top")); callStackToArray = accessible(callStackClass.getMethod("toArray")); nameSpaceClass = callStackTop.invoke(callstack).getClass(); diff --git a/agent/hook/src/main/java/cz/loplex/bsh/hook/DapChannel.java b/agent/hook/src/main/java/cz/loplex/bsh/hook/DapChannel.java index 7c7af2c..91535cf 100644 --- a/agent/hook/src/main/java/cz/loplex/bsh/hook/DapChannel.java +++ b/agent/hook/src/main/java/cz/loplex/bsh/hook/DapChannel.java @@ -7,6 +7,7 @@ import java.io.OutputStream; import java.net.ServerSocket; import java.net.Socket; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -64,7 +65,7 @@ final class DapChannel implements DebugChannel { private final AtomicInteger nextSeq = new AtomicInteger(1); /** Breakpoints per source path, since DAP replaces a whole source's set at a time. */ - private final Map breakpointsBySource = new ConcurrentHashMap(); + private final Map breakpointsBySource = new ConcurrentHashMap<>(); /** * The stack most recently reported per thread, so {@code stackTrace} can be answered from it. @@ -73,10 +74,10 @@ final class DapChannel implements DebugChannel { * native protocol sends it inline. Remembering it here is what bridges that, and it costs nothing: * it is the same list that was just sent. */ - private final Map> stacks = new ConcurrentHashMap>(); + private final Map> stacks = new ConcurrentHashMap<>(); /** Thread names, for the {@code threads} request, which may arrive at any time. */ - private final Map threadNames = new ConcurrentHashMap(); + private final Map threadNames = new ConcurrentHashMap<>(); /** * DAP request seq per pending hook request id, so a reply can be addressed to the right request. @@ -84,7 +85,7 @@ final class DapChannel implements DebugChannel { *

The hook's request ids and DAP's {@code seq} numbers are different namespaces, and both ends * insist on their own, so one map is unavoidable. */ - private final Map pending = new ConcurrentHashMap(); + private final Map pending = new ConcurrentHashMap<>(); private final AtomicInteger nextRequestId = new AtomicInteger(1); @@ -141,68 +142,67 @@ public void close() { public void sendStopped(int threadId, String threadName, int line, int callDepth, List frames) throws IOException { - boolean firstSighting = threadNames.put(Integer.valueOf(threadId), threadName) == null; - stacks.put(Integer.valueOf(threadId), frames); + boolean firstSighting = threadNames.put(threadId, threadName) == null; + stacks.put(threadId, frames); if (firstSighting) { // DAP clients build their thread list from these; a thread that never announces itself may // not be selectable. Sent before the stop so the client knows the thread it names. - Map body = new LinkedHashMap(); + Map body = new LinkedHashMap<>(); body.put("reason", "started"); - body.put("threadId", Integer.valueOf(threadId)); + body.put("threadId", threadId); sendEvent("thread", body); } - Map body = new LinkedHashMap(); + Map body = new LinkedHashMap<>(); // "breakpoint" vs "step" is the client's cue for how to present the stop. The hook does not // distinguish, and guessing wrong is cosmetic, so the honest generic reason is used. body.put("reason", "pause"); - body.put("threadId", Integer.valueOf(threadId)); + body.put("threadId", threadId); body.put("allThreadsStopped", Boolean.FALSE); sendEvent("stopped", body); } public void sendScopes(int requestId, List scopes) throws IOException { - Pending request = pending.remove(Integer.valueOf(requestId)); + Pending request = pending.remove(requestId); if (request == null) { return; } - List rendered = new ArrayList(); - for (int i = 0; i < scopes.size(); i++) { - Map scope = new LinkedHashMap(); - scope.put("name", scopes.get(i).name); - scope.put("variablesReference", Integer.valueOf(scopes.get(i).handle)); + List rendered = new ArrayList<>(); + for (Scope item : scopes) { + Map scope = new LinkedHashMap<>(); + scope.put("name", item.name); + scope.put("variablesReference", item.handle); // Locals is worth expanding on arrival; Global usually is not, and saying so keeps a // client from opening a large namespace nobody asked about. - scope.put("expensive", Boolean.valueOf(!"Locals".equals(scopes.get(i).name))); + scope.put("expensive", !"Locals".equals(item.name)); rendered.add(scope); } - Map body = new LinkedHashMap(); + Map body = new LinkedHashMap<>(); body.put("scopes", rendered); sendResponse(request.seq, request.command, true, body, null); } public void sendVariables(int requestId, List variables) throws IOException { - Pending request = pending.remove(Integer.valueOf(requestId)); + Pending request = pending.remove(requestId); if (request == null) { return; } - List rendered = new ArrayList(); - for (int i = 0; i < variables.size(); i++) { - Variable variable = variables.get(i); - Map entry = new LinkedHashMap(); + List rendered = new ArrayList<>(); + for (Variable variable : variables) { + Map entry = new LinkedHashMap<>(); entry.put("name", variable.name); entry.put("value", variable.value); entry.put("type", variable.type); - entry.put("variablesReference", Integer.valueOf(variable.childHandle)); + entry.put("variablesReference", variable.childHandle); rendered.add(entry); } - Map body = new LinkedHashMap(); + Map body = new LinkedHashMap<>(); body.put("variables", rendered); sendResponse(request.seq, request.command, true, body, null); } public void sendEvaluated(int requestId, boolean setVariable, boolean ok, String value, String type, int childHandle) throws IOException { - Pending request = pending.remove(Integer.valueOf(requestId)); + Pending request = pending.remove(requestId); if (request == null) { return; } @@ -213,11 +213,11 @@ public void sendEvaluated(int requestId, boolean setVariable, boolean ok, String sendResponse(request.seq, request.command, false, null, value); return; } - Map body = new LinkedHashMap(); + Map body = new LinkedHashMap<>(); // setVariable's body calls it "value"; evaluate's calls it "result". body.put(setVariable ? "value" : "result", value); body.put("type", type); - body.put("variablesReference", Integer.valueOf(childHandle)); + body.put("variablesReference", childHandle); sendResponse(request.seq, request.command, true, body, null); } @@ -276,7 +276,7 @@ public Command readCommand() throws IOException { } if ("continue".equals(command)) { int threadId = Json.getInt(args, "threadId", lastStoppedThread()); - Map body = new LinkedHashMap(); + Map body = new LinkedHashMap<>(); body.put("allThreadsContinued", Boolean.FALSE); sendResponse(seq, command, true, body, null); return Command.simple(Command.Kind.RESUME, threadId); @@ -285,7 +285,7 @@ public Command readCommand() throws IOException { return handleStep(seq, command, args); } if ("pause".equals(command)) { - // Cannot be honoured: a thread is only ever stopped where it calls the hook, so there is + // Cannot be honored: a thread is only ever stopped where it calls the hook, so there is // nothing to interrupt. Refusing with a reason beats accepting and doing nothing. sendResponse(seq, command, false, null, "Pause is not supported: a BeanShell thread can only stop at a statement"); @@ -302,7 +302,7 @@ public Command readCommand() throws IOException { } private Command handleInitialize(int seq, String command) throws IOException { - Map capabilities = new LinkedHashMap(); + Map capabilities = new LinkedHashMap<>(); // Only what is actually true. A capability claimed and then not delivered is worse than one // never claimed: the client builds UI for it and the user finds it broken. capabilities.put("supportsConfigurationDoneRequest", Boolean.TRUE); @@ -314,31 +314,31 @@ private Command handleInitialize(int seq, String command) throws IOException { sendResponse(seq, command, true, capabilities, null); // DAP requires this before the client may send breakpoints. The order matters: a client that // has not seen `initialized` will not configure anything. - sendEvent("initialized", new LinkedHashMap()); + sendEvent("initialized", new LinkedHashMap<>()); return Command.simple(Command.Kind.HANDLED, 0); } private Command handleSetBreakpoints(int seq, String command, Object args) throws IOException { Object source = Json.get(args, "source"); String path = Json.getString(source, "path", Json.getString(source, "name", "")); - List requested = Json.getList(args, "breakpoints"); + List requested = Json.getList(args, "breakpoints"); int[] lines = new int[requested.size()]; - List verified = new ArrayList(); + List verified = new ArrayList<>(); for (int i = 0; i < requested.size(); i++) { lines[i] = Json.getInt(requested.get(i), "line", 0); - Map entry = new LinkedHashMap(); + Map entry = new LinkedHashMap<>(); // Claimed verified without checking: the agent has no parse tree for the file and cannot // know whether a line holds a statement until execution reaches it. Reporting them all as // verified is the honest answer to "I cannot tell", and matches what the native protocol // does -- the IDE decides placement there too. entry.put("verified", Boolean.TRUE); - entry.put("line", Integer.valueOf(lines[i])); + entry.put("line", lines[i]); verified.add(entry); } breakpointsBySource.put(path, lines); - Map body = new LinkedHashMap(); + Map body = new LinkedHashMap<>(); body.put("breakpoints", verified); sendResponse(seq, command, true, body, null); @@ -362,14 +362,14 @@ private Command handleSetBreakpoints(int seq, String command, Object args) throw } private Command handleThreads(int seq, String command) throws IOException { - List rendered = new ArrayList(); + List rendered = new ArrayList<>(); for (Map.Entry entry : threadNames.entrySet()) { - Map thread = new LinkedHashMap(); + Map thread = new LinkedHashMap<>(); thread.put("id", entry.getKey()); thread.put("name", entry.getValue()); rendered.add(thread); } - Map body = new LinkedHashMap(); + Map body = new LinkedHashMap<>(); body.put("threads", rendered); sendResponse(seq, command, true, body, null); return Command.simple(Command.Kind.HANDLED, 0); @@ -384,18 +384,18 @@ private Command handleThreads(int seq, String command) throws IOException { */ private Command handleStackTrace(int seq, String command, Object args) throws IOException { int threadId = Json.getInt(args, "threadId", lastStoppedThread()); - List frames = stacks.get(Integer.valueOf(threadId)); - List rendered = new ArrayList(); + List frames = stacks.get(threadId); + List rendered = new ArrayList<>(); if (frames != null) { for (int i = 0; i < frames.size(); i++) { Frame frame = frames.get(i); - Map entry = new LinkedHashMap(); - entry.put("id", Integer.valueOf(threadId * FRAME_ID_STRIDE + i)); + Map entry = new LinkedHashMap<>(); + entry.put("id", threadId * FRAME_ID_STRIDE + i); entry.put("name", frame.name.isEmpty() ? "?" : frame.name); - entry.put("line", Integer.valueOf(frame.line)); - entry.put("column", Integer.valueOf(1)); + entry.put("line", frame.line); + entry.put("column", 1); if (!frame.sourceFile.isEmpty()) { - Map source = new LinkedHashMap(); + Map source = new LinkedHashMap<>(); source.put("path", frame.sourceFile); source.put("name", shortName(frame.sourceFile)); entry.put("source", source); @@ -403,14 +403,14 @@ private Command handleStackTrace(int seq, String command, Object args) throws IO rendered.add(entry); } } - Map body = new LinkedHashMap(); + Map body = new LinkedHashMap<>(); body.put("stackFrames", rendered); - body.put("totalFrames", Integer.valueOf(rendered.size())); + body.put("totalFrames", rendered.size()); sendResponse(seq, command, true, body, null); return Command.simple(Command.Kind.HANDLED, 0); } - private Command handleSetVariable(int seq, String command, Object args) throws IOException { + private Command handleSetVariable(int seq, String command, Object args) { int reference = Json.getInt(args, "variablesReference", 0); String name = Json.getString(args, "name", ""); String value = Json.getString(args, "value", ""); @@ -443,7 +443,6 @@ private Command handleStep(int seq, String command, Object args) throws IOExcept private volatile int pendingStepThread; /** Run modes as the hook understands them; DAP's step requests are mapped onto these. */ - static final int MODE_RUN = 0; static final int MODE_OVER = 1; static final int MODE_INTO = 2; static final int MODE_OUT = 3; @@ -470,7 +469,7 @@ boolean isConfigured() { private Command register(int seq, String command, Command hookCommand) { int requestId = nextRequestId.getAndIncrement(); - pending.put(Integer.valueOf(requestId), new Pending(seq, command)); + pending.put(requestId, new Pending(seq, command)); return withRequestId(hookCommand, requestId); } @@ -496,8 +495,8 @@ private int lastStoppedThread() { // thread it is exact; with several, a client that means another one says so explicitly. int candidate = 0; for (Integer id : stacks.keySet()) { - if (id.intValue() > candidate) { - candidate = id.intValue(); + if (id > candidate) { + candidate = id; } } return candidate; @@ -517,8 +516,8 @@ private static String shortName(String path) { } private void sendEvent(String event, Map body) throws IOException { - Map message = new LinkedHashMap(); - message.put("seq", Integer.valueOf(nextSeq.getAndIncrement())); + Map message = new LinkedHashMap<>(); + message.put("seq", nextSeq.getAndIncrement()); message.put("type", "event"); message.put("event", event); message.put("body", body); @@ -527,11 +526,11 @@ private void sendEvent(String event, Map body) throws IOExceptio private void sendResponse(int requestSeq, String command, boolean success, Map body, String message) throws IOException { - Map response = new LinkedHashMap(); - response.put("seq", Integer.valueOf(nextSeq.getAndIncrement())); + Map response = new LinkedHashMap<>(); + response.put("seq", nextSeq.getAndIncrement()); response.put("type", "response"); - response.put("request_seq", Integer.valueOf(requestSeq)); - response.put("success", Boolean.valueOf(success)); + response.put("request_seq", requestSeq); + response.put("success", success); response.put("command", command); if (message != null) { response.put("message", message); @@ -543,10 +542,10 @@ private void sendResponse(int requestSeq, String command, boolean success, Map message) throws IOException { - byte[] payload = Json.write(message).getBytes("UTF-8"); + byte[] payload = Json.write(message).getBytes(StandardCharsets.UTF_8); // Content-Length framing, as DAP specifies: the length counts bytes, not characters, which is // why the payload is encoded before it is measured. - byte[] header = ("Content-Length: " + payload.length + "\r\n\r\n").getBytes("UTF-8"); + byte[] header = ("Content-Length: " + payload.length + "\r\n\r\n").getBytes(StandardCharsets.UTF_8); synchronized (writeLock) { out.write(header); out.write(payload); @@ -587,7 +586,7 @@ private Object readMessage() throws IOException { } read += count; } - String text = new String(payload, "UTF-8"); + String text = new String(payload, StandardCharsets.UTF_8); try { return Json.parse(text); } catch (RuntimeException malformed) { diff --git a/agent/hook/src/main/java/cz/loplex/bsh/hook/DebugChannel.java b/agent/hook/src/main/java/cz/loplex/bsh/hook/DebugChannel.java index 1528aa9..5d31f40 100644 --- a/agent/hook/src/main/java/cz/loplex/bsh/hook/DebugChannel.java +++ b/agent/hook/src/main/java/cz/loplex/bsh/hook/DebugChannel.java @@ -9,7 +9,7 @@ *

This interface is where the two transports part company, and the split is deliberately placed so * that nothing interesting lives on the transport side. Deciding what counts as a statement, * walking the call stack, rendering values, handing out handles, evaluating in a frame's namespace — - * all of that produces {@link Answer}s and is written once. Only the last step, turning an answer into + * all of that produces answers and is written once. Only the last step, turning an answer into * bytes, exists twice: {@link NativeChannel} as the compact binary protocol IntelliJ speaks, and * {@link DapChannel} as JSON over Content-Length framing for anything else. * @@ -17,7 +17,7 @@ * the right way round even though DAP is the standard: the native one is the narrower of the two, so * everything it needs DAP can express, while the reverse is not true. Where DAP wants something the * hook does not track — a source reference, a variable's {@code evaluateName} — the DAP channel - * synthesises it rather than the hook learning about it. + * synthesizes it rather than the hook learning about it. */ interface DebugChannel { @@ -63,7 +63,7 @@ void sendEvaluated(int requestId, boolean setVariable, boolean ok, String value, /** * Blocks until the next command arrives, or returns null at end of stream. * - *

Called only from the hook's single reader thread, so it needs no synchronisation of its own. + *

Called only from the hook's single reader thread, so it needs no synchronization of its own. */ Command readCommand() throws IOException; @@ -107,7 +107,7 @@ final class Variable { } /** - * A command, normalised across the two encodings. + * A command, normalized across the two encodings. * *

Deliberately one flat type rather than a hierarchy: there are seven of them, each carrying at * most a handful of ints and a string or two, and the hook switches on {@link #kind} in one place. diff --git a/agent/hook/src/main/java/cz/loplex/bsh/hook/Json.java b/agent/hook/src/main/java/cz/loplex/bsh/hook/Json.java index 1003765..68b8f2b 100644 --- a/agent/hook/src/main/java/cz/loplex/bsh/hook/Json.java +++ b/agent/hook/src/main/java/cz/loplex/bsh/hook/Json.java @@ -44,7 +44,7 @@ private static void writeTo(StringBuilder sb, Object value) { } else if (value instanceof String) { writeString(sb, (String) value); } else if (value instanceof Boolean) { - sb.append(((Boolean) value).booleanValue() ? "true" : "false"); + sb.append(value); } else if (value instanceof Integer || value instanceof Long) { sb.append(value); } else if (value instanceof Number) { @@ -62,8 +62,7 @@ private static void writeTo(StringBuilder sb, Object value) { } else if (value instanceof Map) { sb.append('{'); boolean first = true; - for (Object entryObject : ((Map) value).entrySet()) { - Map.Entry entry = (Map.Entry) entryObject; + for (Map.Entry entry : ((Map) value).entrySet()) { if (!first) { sb.append(','); } @@ -121,7 +120,7 @@ private static void writeString(StringBuilder sb, String text) { // Control characters must be escaped; a script's own value may well contain them. // Everything else goes out as-is and is UTF-8 encoded by the writer. if (c < 0x20) { - sb.append(String.format("\\u%04x", Integer.valueOf(c))); + sb.append(String.format("\\u%04x", (int) c)); } else { sb.append(c); } @@ -162,17 +161,11 @@ static String getString(Object object, String key, String fallback) { return value instanceof String ? (String) value : fallback; } - /** A member as a boolean, or [fallback] when absent or not a boolean. */ - static boolean getBoolean(Object object, String key, boolean fallback) { - Object value = get(object, key); - return value instanceof Boolean ? ((Boolean) value).booleanValue() : fallback; - } - /** A member as a list, or an empty list when absent or not an array. */ - @SuppressWarnings("unchecked") - static List getList(Object object, String key) { + @SuppressWarnings("SameParameterValue") + static List getList(Object object, String key) { Object value = get(object, key); - return value instanceof List ? (List) value : new ArrayList(); + return value instanceof List ? (List) value : new ArrayList<>(); } private static final class Parser { @@ -222,7 +215,7 @@ Object value() { } private Map object() { - Map result = new LinkedHashMap(); + Map result = new LinkedHashMap<>(); position++; // '{' skipWhitespace(); if (peek() == '}') { @@ -252,7 +245,7 @@ private Map object() { } private List array() { - List result = new ArrayList(); + List result = new ArrayList<>(); position++; // '[' skipWhitespace(); if (peek() == ']') { diff --git a/agent/hook/src/main/java/cz/loplex/bsh/hook/NativeChannel.java b/agent/hook/src/main/java/cz/loplex/bsh/hook/NativeChannel.java index 71482b2..01dfb8c 100644 --- a/agent/hook/src/main/java/cz/loplex/bsh/hook/NativeChannel.java +++ b/agent/hook/src/main/java/cz/loplex/bsh/hook/NativeChannel.java @@ -36,7 +36,7 @@ final class NativeChannel implements DebugChannel { private final int port; - /** Serialises writes. A message must go out under one acquisition or two threads' fields interleave. */ + /** Serializes writes. A message must go out under one acquisition or two threads' fields interleave. */ private final Object writeLock = new Object(); private Socket socket; @@ -80,8 +80,7 @@ public void sendStopped(int threadId, String threadName, int line, int callDepth out.writeInt(line); out.writeInt(callDepth); out.writeInt(frames.size()); - for (int i = 0; i < frames.size(); i++) { - Frame frame = frames.get(i); + for (Frame frame : frames) { out.writeUTF(frame.name); out.writeUTF(frame.sourceFile); out.writeInt(frame.line); @@ -95,9 +94,9 @@ public void sendScopes(int requestId, List scopes) throws IOException { out.writeByte(EVT_SCOPES); out.writeInt(requestId); out.writeInt(scopes.size()); - for (int i = 0; i < scopes.size(); i++) { - out.writeUTF(scopes.get(i).name); - out.writeInt(scopes.get(i).handle); + for (Scope scope : scopes) { + out.writeUTF(scope.name); + out.writeInt(scope.handle); } out.flush(); } @@ -108,8 +107,7 @@ public void sendVariables(int requestId, List variables) throws IOExce out.writeByte(EVT_VARIABLES); out.writeInt(requestId); out.writeInt(variables.size()); - for (int i = 0; i < variables.size(); i++) { - Variable variable = variables.get(i); + for (Variable variable : variables) { out.writeUTF(variable.name); out.writeUTF(variable.value); out.writeUTF(variable.type); @@ -136,8 +134,8 @@ public void sendEvaluated(int requestId, boolean setVariable, boolean ok, String * Reads one command. * *

Every opcode's shape has to be known even for the ones this method does not interpret: there - * are no length prefixes, so an opcode it could not decode would desynchronise the stream for - * good. An unrecognised one therefore reads its thread id and stops there, which keeps the stream + * are no length prefixes, so an opcode it could not decode would desynchronize the stream for + * good. An unrecognized one therefore reads its thread id and stops there, which keeps the stream * aligned for anything carrying no further fields. */ public Command readCommand() throws IOException { diff --git a/agent/instrument/build.gradle.kts b/agent/instrument/build.gradle.kts index f0922ff..81c57b4 100644 --- a/agent/instrument/build.gradle.kts +++ b/agent/instrument/build.gradle.kts @@ -43,9 +43,29 @@ dependencies { } // Java 8: the agent loads into whatever JVM the host library runs on, so the floor is set -// as low as the tooling allows. +// as low as the tooling allows -- java.lang.instrument (Instrumentation, premain) doesn't +// exist before Java 5, and Gradle toolchain provisioning doesn't reach below 8, so 8 is the +// practical floor either way. +// +// The toolchain makes the Gradle daemon fork an actual JDK 8 javac for this subproject, +// rather than JDK 21's javac emulating one via `release` -- which is also why this avoids +// the "source/target 8 is obsolete" warning javac 20+ prints when asked to emulate 8 itself. +// No `release` flag here: that's a JDK 9+ flag javac 8 doesn't understand, and the +// toolchain's own source/target default already is 8. +// +// sourceCompatibility/targetCompatibility stay alongside the toolchain because IntelliJ's +// Gradle sync reads them to set the module's language level -- it does not evaluate +// `configureEach {}` blocks, so without this line the IDE assumes the project default (21) +// and its inspections suggest syntax this module can't actually compile. +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(8)) + } + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + tasks.withType().configureEach { - options.release.set(8) options.encoding = "UTF-8" } diff --git a/agent/instrument/src/main/java/cz/loplex/bsh/agent/BshAgentMain.java b/agent/instrument/src/main/java/cz/loplex/bsh/agent/BshAgentMain.java index 039150a..a289986 100644 --- a/agent/instrument/src/main/java/cz/loplex/bsh/agent/BshAgentMain.java +++ b/agent/instrument/src/main/java/cz/loplex/bsh/agent/BshAgentMain.java @@ -1,11 +1,11 @@ package cz.loplex.bsh.agent; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.lang.instrument.Instrumentation; +import java.nio.file.Files; import java.util.ArrayList; import java.util.List; import java.util.jar.JarFile; @@ -112,15 +112,12 @@ private static boolean publishHookToBootstrap(Instrumentation inst) { try { File extracted = File.createTempFile("bsh-debug-hook", ".jar"); extracted.deleteOnExit(); - OutputStream target = new FileOutputStream(extracted); - try { + try (OutputStream target = Files.newOutputStream(extracted.toPath())) { byte[] buffer = new byte[8192]; int read; while ((read = source.read(buffer)) > 0) { target.write(buffer, 0, read); } - } finally { - target.close(); } inst.appendToBootstrapClassLoaderSearch(new JarFile(extracted)); return true; @@ -145,7 +142,7 @@ private static void retransformLoadedBshClasses(Instrumentation inst) { if (!inst.isRetransformClassesSupported()) { return; } - List> targets = new ArrayList>(); + List> targets = new ArrayList<>(); for (Class candidate : inst.getAllLoadedClasses()) { if (isBshClass(candidate) && inst.isModifiableClass(candidate)) { targets.add(candidate); diff --git a/agent/samples/README.md b/agent/samples/README.md index 1dd2621..f865d4c 100644 --- a/agent/samples/README.md +++ b/agent/samples/README.md @@ -23,7 +23,7 @@ appears to work in the CLI and does nothing in an embedded library, or the rever ### Checking that the agent changes nothing -`runHost` and `runHostWithAgent` must produce the same output, after normalising +`runHost` and `runHostWithAgent` must produce the same output, after normalizing three things that legitimately differ: ```bash @@ -33,7 +33,7 @@ diff <(norm plain.txt) <(norm agent.txt) - The **JVM warning** about class-data sharing appears because the agent appends the hook jar to the bootstrap classpath. Unavoidable and harmless. -- **Identity hash codes** shift deterministically — initialising the hook on the +- **Identity hash codes** shift deterministically — initializing the hook on the interpreter thread advances that thread's identity-hash sequence. See "Not bit-transparent" in [`agent/README.md`](../README.md). - **Thread interleaving** in `06_callbacks_threads.bsh` varies between runs *without* @@ -41,37 +41,37 @@ diff <(norm plain.txt) <(norm agent.txt) ## Hook point coverage -| Hook point | Exercised by | -|---|---| -| `Interpreter.java:659` — eval loop | `DebugHost` 1, 2, 6; every `source()` | -| `Interpreter.java:471` — interactive loop | any script run via the CLI | -| `BSHBlock.java:105` — statement in a block | all, esp. `01_basic` BP:6, BP:8 | -| `BshMethod.java:204` — scripted method entry | `02_methods`, all of `04_closures` | -| `BshMethod.java:256` — `invokeImpl` under lock | `02_methods` BP:9 (`synchronized`) | -| `Name.java:885` — `invokeLocalMethod`, unqualified | `03_invocation_forms` group A | -| `Name.java:778` — `invokeMethod`, dotted name | `03_invocation_forms` group B | -| `BSHPrimarySuffix.java:170` — **bypasses `Name`** | `03_invocation_forms` group C | -| `Reflect.java:103` — the single `method.invoke()` | `03_invocation_forms` B and C | -| `Reflect.java:895` — compiled Java command | `03_invocation_forms` (uncomment `dir`) | -| `This.java:208` — scripted object / proxy entry | `04_closures`, `06_*`, `DebugHost` 3, 4 | +| Hook point | Exercised by | +|------------------------------------------------------|------------------------------------------| +| `Interpreter.java:659` — eval loop | `DebugHost` 1, 2, 6; every `source()` | +| `Interpreter.java:471` — interactive loop | any script run via the CLI | +| `BSHBlock.java:105` — statement in a block | all, esp. `01_basic` BP:6, BP:8 | +| `BshMethod.java:204` — scripted method entry | `02_methods`, all of `04_closures` | +| `BshMethod.java:256` — `invokeImpl` under lock | `02_methods` BP:9 (`synchronized`) | +| `Name.java:885` — `invokeLocalMethod`, unqualified | `03_invocation_forms` group A | +| `Name.java:778` — `invokeMethod`, dotted name | `03_invocation_forms` group B | +| `BSHPrimarySuffix.java:170` — **bypasses `Name`** | `03_invocation_forms` group C | +| `Reflect.java:103` — the single `method.invoke()` | `03_invocation_forms` B and C | +| `Reflect.java:895` — compiled Java command | `03_invocation_forms` (uncomment `dir`) | +| `This.java:208` — scripted object / proxy entry | `04_closures`, `06_*`, `DebugHost` 3, 4 | | `This.java:236` — `JAVACODE` substitution, line `-1` | `06_callbacks_threads`, `DebugHost` 3, 4 | -| `ClassGeneratorUtil.java:388` — bytecode shim | `05_scripted_class`, `DebugHost` 6 | -| fresh `CallStack` per thread | `06_callbacks_threads`, `DebugHost` 5 | -| `BSHTryStatement.java:173` — finally / ReturnControl | `08_exceptions` BP:5-8 | +| `ClassGeneratorUtil.java:388` — bytecode shim | `05_scripted_class`, `DebugHost` 6 | +| fresh `CallStack` per thread | `06_callbacks_threads`, `DebugHost` 5 | +| `BSHTryStatement.java:173` — finally / ReturnControl | `08_exceptions` BP:5-8 | ## Files -| File | Covers | Watch out for | -|---|---|---| -| `01_basic.bsh` | top-level statements, loops, block scoping | statements in loop bodies do **not** re-enter the `Interpreter` loop | -| `02_methods.bsh` | frames, recursion, `synchronized`, `throws` | redefinition does not replace — see the defects note below | -| `03_invocation_forms.bsh` | the three call forms | group C never touches `Name` | -| `04_closures.bsh` | `This`, closures, scope chain | variables live in *parent* namespaces, not one flat frame | -| `05_scripted_class.bsh` | generated bytecode + shim | constructors must be `public` — see the defects note below | -| `06_callbacks_threads.bsh` | proxies, threads, Java→script | outermost frame has line `-1` | -| `07_eval_and_source.bsh` + `07_aux.bsh` | `source()` vs `eval()` identity | `eval()` reparses every call | -| `08_exceptions.bsh` | TargetError / EvalError / parse error | `finally` eats return values — see the defects note below | -| `DebugHost.java` | 8 embedded scenarios | the realistic third-party-library entry pattern | +| File | Covers | Watch out for | +|-----------------------------------------|---------------------------------------------|----------------------------------------------------------------------| +| `01_basic.bsh` | top-level statements, loops, block scoping | statements in loop bodies do **not** re-enter the `Interpreter` loop | +| `02_methods.bsh` | frames, recursion, `synchronized`, `throws` | redefinition does not replace — see the defects note below | +| `03_invocation_forms.bsh` | the three call forms | group C never touches `Name` | +| `04_closures.bsh` | `This`, closures, scope chain | variables live in *parent* namespaces, not one flat frame | +| `05_scripted_class.bsh` | generated bytecode + shim | constructors must be `public` — see the defects note below | +| `06_callbacks_threads.bsh` | proxies, threads, Java→script | outermost frame has line `-1` | +| `07_eval_and_source.bsh` + `07_aux.bsh` | `source()` vs `eval()` identity | `eval()` reparses every call | +| `08_exceptions.bsh` | TargetError / EvalError / parse error | `finally` eats return values — see the defects note below | +| `DebugHost.java` | 8 embedded scenarios | the realistic third-party-library entry pattern | ## Three BeanShell defects these confirmed diff --git a/agent/samples/build.gradle.kts b/agent/samples/build.gradle.kts index a73a291..7d846f5 100644 --- a/agent/samples/build.gradle.kts +++ b/agent/samples/build.gradle.kts @@ -21,8 +21,19 @@ dependencies { implementation(libs.bsh) } +// See agent/instrument/build.gradle.kts for why the toolchain forks an actual JDK 8 javac +// (no `release` flag: that's a JDK 9+ flag javac 8 doesn't understand), and why +// sourceCompatibility is declared here too: IntelliJ's Gradle sync doesn't evaluate +// `configureEach {}` blocks. +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(8)) + } + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + tasks.withType().configureEach { - options.release.set(8) options.encoding = "UTF-8" } diff --git a/agent/samples/src/main/java/DebugHost.java b/agent/samples/src/main/java/DebugHost.java index 1b56cc2..6a955b8 100644 --- a/agent/samples/src/main/java/DebugHost.java +++ b/agent/samples/src/main/java/DebugHost.java @@ -1,18 +1,18 @@ /** * Embeds BeanShell the way a third-party library does, so the agent under test * sees a realistic entry pattern rather than the CLI one. - * + *

* Why this matters: running a script with `java bsh.Interpreter foo.bsh` goes * through Interpreter.run() (Interpreter.java:471). Everything a library does * instead goes through Interpreter.eval(Reader, NameSpace, String) * (Interpreter.java:659). Those are two SEPARATE loops -- an agent that hooks * only one will look like it works in the CLI and do nothing in production, or * vice versa. Scenario 1 below covers eval(); run the CLI runner for the other. - * + *

* Run from the repository root: * ./gradlew :agent:samples:runHost # all scenarios * ./gradlew :agent:samples:runHostWithAgent # the same, instrumented - * + *

* Both tasks set the working directory to scripts/, because the fixtures source() * each other by bare name. To pick a single scenario, append --args='3'. */ @@ -27,6 +27,7 @@ import java.util.Comparator; import java.util.List; +@SuppressWarnings("JavaPrintToLogpoint") public class DebugHost { /** Directory holding the .bsh fixtures. Override with -Dsamples=/path. */ @@ -49,6 +50,7 @@ public static void main(String[] args) throws Exception { } private static Interpreter fresh() { + @SuppressWarnings("UnnecessaryLocalVariable") Interpreter i = new Interpreter(); // Uncomment to see the built-in tracing for comparison with your agent. // Interpreter.TRACE = true; @@ -64,7 +66,7 @@ private static void banner(String s) { } /** - * 1. Plain source() of a file. This is the common library pattern and it + * 1. Plain source() of a file. This is the common library pattern, and it * lands in Interpreter.eval(Reader, NameSpace, String) -- NOT in run(). */ private static void scenario1_sourceFile() throws Exception { @@ -97,7 +99,7 @@ private static void scenario3_callScriptMethodFromJava() throws EvalError { Interpreter i = fresh(); i.eval("compute(a, b) { inner = a * b; return inner + 1; }"); NameSpace global = i.getNameSpace(); - Object result = global.invokeMethod("compute", new Object[]{6, 7}, i); + Object result = global.invokeMethod("compute", new Object[] { 6 , 7 }, i); System.out.println("[host] compute(6,7) = " + result); } @@ -115,13 +117,14 @@ private static void scenario4_scriptImplementsInterface() throws Exception { + " return this;" + "}"); This scripted = (This) i.eval("makeCmp();"); - Comparator cmp = (Comparator) scripted.getInterface(Comparator.class); + @SuppressWarnings("unchecked") // BeanShell's This.getInterface predates generics + Comparator cmp = (Comparator) scripted.getInterface(Comparator.class); - List words = new ArrayList(); + List words = new ArrayList<>(); words.add("ccc"); words.add("a"); words.add("bb"); - java.util.Collections.sort(words, cmp); // Java drives the script + words.sort(cmp); // Java drives the script System.out.println("[host] sorted = " + words); } @@ -153,17 +156,13 @@ private static void scenario5_callOnOtherThread() throws Exception { b.join(); } - private static Runnable makeJob( - final Interpreter i, final NameSpace ns, final String tag) { - return new Runnable() { - public void run() { - try { - Object r = ns.invokeMethod( - "work", new Object[]{tag, 3}, i); - System.out.println("[host] " + r); - } catch (EvalError e) { - throw new RuntimeException(e); - } + private static Runnable makeJob(Interpreter i, NameSpace ns, String tag) { + return () -> { + try { + Object r = ns.invokeMethod("work", new Object[] { tag, 3 }, i); + System.out.println("[host] " + r); + } catch (EvalError e) { + throw new RuntimeException(e); } }; } @@ -183,8 +182,7 @@ private static void scenario6_scriptedClassAsJavaObject() throws Exception { System.out.println("[host] java class = " + p.getClass().getName()); System.out.println("[host] toString = " + p); // Reflective call straight into the generated shim. - Object d2 = p.getClass().getMethod("distanceSquared", new Class[0]) - .invoke(p, new Object[0]); + Object d2 = p.getClass().getMethod("distanceSquared").invoke(p); System.out.println("[host] distanceSquared = " + d2 + " (expect 169)"); } diff --git a/docs/BEANSHELL-DEFECTS.md b/docs/BEANSHELL-DEFECTS.md index a0e77ec..d2d6dc4 100644 --- a/docs/BEANSHELL-DEFECTS.md +++ b/docs/BEANSHELL-DEFECTS.md @@ -40,7 +40,7 @@ at runtime and package-private access fails. Even an empty `finally`. The root cause is one line, `BSHTryStatement.java:173`: -```java +```jshelllanguage if (finallyBlock != null) ret = finallyBlock.eval(callstack, interpreter); ``` @@ -49,6 +49,7 @@ It overwrites `ret` unconditionally, discarding the `ReturnControl` produced by try/catch block. Minimal repro: ```java +@SuppressWarnings("EmptyFinallyBlock") f() { try { return "ok"; } finally { } } f(); // yields void ``` diff --git a/docs/FUTURE_WORK.md b/docs/FUTURE_WORK.md index d1975e1..02872a6 100644 --- a/docs/FUTURE_WORK.md +++ b/docs/FUTURE_WORK.md @@ -71,7 +71,7 @@ event, so routing IntelliJ through DAP would have lost the thread support the na It came out cheap for the reason predicted here — only the last step of the hook turns an answer into bytes, so `DebugChannel` splits the transport off and everything above it is written once. Adopting DAP's vocabulary first (`stackTrace`/`scopes`/`variables`/`evaluate`/ -`setVariable`, handles as `variablesReference`) is what made it a serialisation change rather +`setVariable`, handles as `variablesReference`) is what made it a serialization change rather than a redesign. Three translations live in `DapChannel` and are worth knowing about: frame ids have to be made @@ -129,7 +129,7 @@ headless VS Code (`@vscode/test-electron` + Mocha, under Xvfb) starting an actua session against the fixture in `src/test/fixtures/workspace/`, asserting on the DAP traffic via a `DebugAdapterTracker`. The fixture, breakpoint line and evaluate expression are the same ones `07` already proved work over `DapChannel` — deliberately, so the two checks are provably -exercising the same behaviour one layer apart rather than two fixtures that could quietly drift. +exercising the same behavior one layer apart rather than two fixtures that could quietly drift. **The one real gap `07` cannot reach**, and the reason this is `launch` rather than `attach`: `dap-client.py` connects to a JVM the check already started, so it never touches this @@ -168,7 +168,7 @@ in [`editors/vscode/README.md`](../editors/vscode/README.md#testing). (`nvim --headless -l`, no display server needed) against the same fixture, breakpoint line and evaluate expression `agent/checks/07-dap-transport.sh` and the VS Code test already prove work over `DapChannel` — deliberately, for the same reason the VS Code fixture matches `07`'s: so the -three checks are provably exercising the same behaviour, not three fixtures that could drift. +three checks are provably exercising the same behavior, not three fixtures that could drift. Covers what `07`'s `dap-client.py` cannot: `bsh-dap.lua`'s own `launch()` (the `jobstart` spawn, the `DAP: listening` stdout watch), the Neovim counterpart to what the VS Code GUI test found for `BshDebugAdapterDescriptorFactory.launch()`. @@ -219,3 +219,81 @@ pull request, on `ubuntu-latest` with JDK 21 — nothing exotic needed since `mv already on the image. The VS Code and Neovim extension tests (Xvfb + Electron, and `nvim -l`) are not wired into CI yet — each needs its own runner setup (a display for the former, `nvim` and `git` on `PATH` for the latter) and is left for a follow-up job. + +### Scripted-class `super(...)` calls never resolve + +Found by manually enabling the (disabled-by-default) `BshUnresolvedMethod` inspection against +the debugger fixtures in `agent/samples/scripts/` — it flagged `super(x, y)` in +`05_scripted_class.bsh`'s `Point3D` constructor as an unresolved method call. + +`super` is not a keyword in `BshTokenTypes` — it lexes as a plain `IDENTIFIER`, so `super(x, y)` +parses as an ordinary `METHOD_INVOCATION` on a call named `"super"`. `BshParser.tryClassDeclaration` +only skips past `extends ` while parsing a class declaration; it never attaches that name to +the class node. `BshResolver` therefore has nothing to walk: `classMember` only collects elements +from a class's own subtree, and the resolve chain in `BshResolver.resolve` searches file/project +methods and classes by name, never a superclass's members. The result is that `super(...)` inside +*any* scripted class that subclasses another scripted class — a normal, supported BeanShell +pattern, not a fixture curiosity — resolves to nothing. + +Not a release blocker: the inspection ships `enabledByDefault="false"` regardless, since BeanShell +scripts routinely call unmodeled Java library methods and built-in commands. But it is a distinct, +fixable gap rather than one more instance of that same disclaimer — the `extends` name is fully +available in the AST, the plugin just never records or walks it. Fixing it needs three things: +capture the superclass name on the class declaration node (parser), have `BshResolver`/ +`classMember` follow it when a member is not found locally, and special-case bare +`super(...)`/`this(...)` constructor-delegation calls so they resolve to a constructor rather than +being looked up as a method literally named `super`. + +### Language-inject `eval("...")`/`source("...")` string literals + +Same review that found the `super` gap also flagged `ghost()` in `07_eval_and_source.bsh` as +unresolved — it is defined by `eval("ghost() { return \"ghost speaking\"; }");`, a method that +exists only inside a string literal, never as a PSI declaration. For that specific case the flag +is correct: the method genuinely does not exist anywhere resolution can see it. + +But the argument here is a constant string, not one assembled at runtime from variables or I/O — +its contents are fully known at edit time, same as the rest of the file. The plugin already has +the mechanism for exactly this: `BshMavenInjector` (`injection/BshMavenInjector.kt`) injects the +`BeanShell` language into inline ` + greet @@ -25,17 +36,6 @@ run - - - diff --git a/plugin/samples/maven/build-helper/pom.xml b/plugin/samples/maven/build-helper/pom.xml index 5866efa..76224ea 100644 --- a/plugin/samples/maven/build-helper/pom.xml +++ b/plugin/samples/maven/build-helper/pom.xml @@ -17,23 +17,28 @@ org.codehaus.mojo build-helper-maven-plugin 3.6.0 + + + + buildTag + + - compute-timestamp + default-cli validate bsh-property - - - - buildTag - - diff --git a/plugin/src/main/java/cz/loplex/intellij/bsh/debug/agent/BshDebugAgent.java b/plugin/src/main/java/cz/loplex/intellij/bsh/debug/agent/BshDebugAgent.java index 86ff4ee..254388e 100644 --- a/plugin/src/main/java/cz/loplex/intellij/bsh/debug/agent/BshDebugAgent.java +++ b/plugin/src/main/java/cz/loplex/intellij/bsh/debug/agent/BshDebugAgent.java @@ -66,9 +66,9 @@ public final class BshDebugAgent { * *

A {@code WeakHashMap} so a finished script thread does not keep its {@code Thread} object * alive. Losing an entry costs nothing: a thread that never reports again never needs its id, and - * one that does gets a fresh id, which the IDE treats as a new thread. + * one that reports again gets a fresh id, which the IDE treats as a new thread. */ - private static final Map threadIds = new WeakHashMap(); + private static final Map threadIds = new WeakHashMap<>(); private static final int port; private static boolean disabled; @@ -161,10 +161,10 @@ private static int threadId(Thread thread) { synchronized (threadIds) { Integer existing = threadIds.get(thread); if (existing != null) { - return existing.intValue(); + return existing; } int assigned = threadIds.size() + 1; - threadIds.put(thread, Integer.valueOf(assigned)); + threadIds.put(thread, assigned); return assigned; } } @@ -194,7 +194,7 @@ private static int threadId(Thread thread) { *

Running an expression needs a {@code bsh.Interpreter}, and a rewritten script hands the hook * a {@code bsh.NameSpace} — which cannot evaluate anything. The IDE is told as much up front and * offers neither Watches nor Set Value here, so this reply is the belt to that braces: an - * unrecognised opcode is treated as a resume, and silently continuing a script because the IDE + * unrecognized opcode is treated as a resume, and silently continuing a script because the IDE * asked a question this path cannot answer would be much worse than an error message. */ private static final String NOT_SUPPORTED = @@ -217,14 +217,14 @@ private static void serveUntilResume(Object namespace) throws IOException { if (command == CMD_SET_CATCH_ALL) { // Suspend: All rounds up the *other* threads, which this path has no way to reach -- // it holds one lock for the whole of a stop, so they are already waiting in step(). - // The byte still has to be consumed or the stream desynchronises for good. + // The byte still has to be consumed or the stream desynchronizes for good. in.readByte(); continue; } if (command == CMD_SET_BREAKPOINTS) { // Carries no thread id. Read and discarded: this path never filters, so it keeps // reporting every statement -- but the bytes have to be consumed or the stream - // desynchronises for good. + // desynchronizes for good. int count = in.readInt(); for (int i = 0; i < count; i++) { in.readUTF(); @@ -246,7 +246,7 @@ private static void serveUntilResume(Object namespace) throws IOException { int requestId = in.readInt(); int handle = in.readInt(); Map variables = - handle == NAMESPACE_HANDLE ? readVariables(namespace) : Collections.emptyMap(); + handle == NAMESPACE_HANDLE ? readVariables(namespace) : Collections.emptyMap(); out.writeByte(EVT_VARIABLES); out.writeInt(requestId); out.writeInt(variables.size()); @@ -274,7 +274,7 @@ private static void serveUntilResume(Object namespace) throws IOException { } else if (command == CMD_SET_RUN_MODE) { in.readByte(); // mode; this path never filters, so nothing to apply } else { - // CMD_RESUME, and anything unrecognised, which must not be able to wedge a script. + // CMD_RESUME, and anything unrecognized, which must not be able to wedge a script. return; } } diff --git a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/BshFileTypeDetector.kt b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/BshFileTypeDetector.kt index dcd3334..db83a2d 100644 --- a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/BshFileTypeDetector.kt +++ b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/BshFileTypeDetector.kt @@ -6,7 +6,7 @@ import com.intellij.openapi.util.io.ByteSequence import com.intellij.openapi.vfs.VirtualFile /** - * Recognises extensionless scripts as BeanShell from their header. A file that + * Recognizes extensionless scripts as BeanShell from their header. A file that * begins with a shebang is treated as BeanShell when either: * * - the shebang runs a BeanShell interpreter directly, e.g. @@ -37,10 +37,8 @@ class BshFileTypeDetector : FileTypeRegistry.FileTypeDetector { } override fun getDesiredContentPrefixLength(): Int = 256 - - companion object { - private const val MAX_HEADER_LINES = 6 - private val INTERPRETER = Regex("""\bbsh\.Interpreter\b""") - private val INTERPRETER_NAME = Regex("""\b(?:beanshell|bsh)\b""", RegexOption.IGNORE_CASE) - } } + +private const val MAX_HEADER_LINES = 6 +private val INTERPRETER = Regex("""\bbsh\.Interpreter\b""") +private val INTERPRETER_NAME = Regex("""\b(?:beanshell|bsh)\b""", RegexOption.IGNORE_CASE) diff --git a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/BshLanguage.kt b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/BshLanguage.kt index 4acf2fa..2f4109f 100644 --- a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/BshLanguage.kt +++ b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/BshLanguage.kt @@ -7,7 +7,7 @@ import com.intellij.lang.Language * * BeanShell is a small, embeddable Java source interpreter with a Java-like * syntax plus a number of loosely typed scripting conveniences. The grammar - * modelled by this plugin follows the BeanShell 3.0 `bsh.jjt` grammar. + * modeled by this plugin follows the BeanShell 3.0 `bsh.jjt` grammar. */ object BshLanguage : Language("BeanShell") { private fun readResolve(): Any = BshLanguage diff --git a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/BshParserDefinition.kt b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/BshParserDefinition.kt index 6ea97bc..7598373 100644 --- a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/BshParserDefinition.kt +++ b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/BshParserDefinition.kt @@ -47,8 +47,6 @@ class BshParserDefinition : ParserDefinition { } override fun createFile(viewProvider: FileViewProvider): PsiFile = BshFile(viewProvider) - - companion object { - val FILE = IFileElementType(BshLanguage) - } } + +val FILE = IFileElementType(BshLanguage) diff --git a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/completion/BshInlayParameterHintsProvider.kt b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/completion/BshInlayParameterHintsProvider.kt index 85ef466..592eff2 100644 --- a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/completion/BshInlayParameterHintsProvider.kt +++ b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/completion/BshInlayParameterHintsProvider.kt @@ -1,43 +1,58 @@ package cz.loplex.intellij.bsh.completion -import com.intellij.codeInsight.hints.HintInfo -import com.intellij.codeInsight.hints.InlayInfo -import com.intellij.codeInsight.hints.InlayParameterHintsProvider +import com.intellij.codeInsight.hints.declarative.HintColorKind +import com.intellij.codeInsight.hints.declarative.HintFormat +import com.intellij.codeInsight.hints.declarative.InlayHintsCollector +import com.intellij.codeInsight.hints.declarative.InlayHintsProvider +import com.intellij.codeInsight.hints.declarative.InlayTreeSink +import com.intellij.codeInsight.hints.declarative.InlineInlayPosition +import com.intellij.codeInsight.hints.declarative.SharedBypassCollector +import com.intellij.openapi.editor.Editor import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile import cz.loplex.intellij.bsh.psi.BshAmbiguousName import cz.loplex.intellij.bsh.psi.BshMethodDeclaration +import cz.loplex.intellij.bsh.psi.BshTokenTypes import cz.loplex.intellij.bsh.psi.BshElementTypes as E /** Shows `name:` hints before the arguments of a resolved `method(...)` call. */ -class BshInlayParameterHintsProvider : InlayParameterHintsProvider { +class BshInlayParameterHintsProvider : InlayHintsProvider { - override fun getParameterHints(element: PsiElement): List { - if (element.node.elementType !== E.METHOD_INVOCATION) return emptyList() + override fun createCollector(file: PsiFile, editor: Editor): InlayHintsCollector = Collector - val name = element.children.filterIsInstance().firstOrNull() ?: return emptyList() - val method = name.reference?.resolve() as? BshMethodDeclaration ?: return emptyList() + private object Collector : SharedBypassCollector { - val paramNames = parameterNames(method) - if (paramNames.isEmpty()) return emptyList() + override fun collectFromElement(element: PsiElement, sink: InlayTreeSink) { + if (element.node.elementType !== E.METHOD_INVOCATION) return - val argument = element.node.findChildByType(E.ARGUMENTS) ?: return emptyList() - val args = argument.getChildren(null) - .filter { it.psi.firstChild != null } // argument expressions (composite) - .map { it.psi } + val name = element.children.filterIsInstance().firstOrNull() ?: return + val method = name.reference?.resolve() as? BshMethodDeclaration ?: return - return args.mapIndexedNotNull { index, arg -> - paramNames.getOrNull(index)?.let { InlayInfo(it, arg.textRange.startOffset) } - } - } + val paramNames = parameterNames(method) + if (paramNames.isEmpty()) return - override fun getDefaultBlackList(): Set = emptySet() + val argument = element.node.findChildByType(E.ARGUMENTS) ?: return + val args = argument.getChildren(null) + .filter { it.psi.firstChild != null } // argument expressions (composite) + .map { it.psi } - override fun getHintInfo(element: PsiElement): HintInfo? = null + args.forEachIndexed { index, arg -> + val paramName = paramNames.getOrNull(index) ?: return@forEachIndexed + sink.addPresentation( + InlineInlayPosition(arg.textRange.startOffset, relatedToPrevious = false), + tooltip = null, + hintFormat = HintFormat.default.withColorKind(HintColorKind.Parameter), + ) { + text("$paramName:") + } + } + } - private fun parameterNames(method: BshMethodDeclaration): List { - val params = method.node.findChildByType(E.FORMAL_PARAMETERS) ?: return emptyList() - return params.getChildren(null) - .filter { it.elementType === E.FORMAL_PARAMETER } - .mapNotNull { it.findChildByType(cz.loplex.intellij.bsh.psi.BshTokenTypes.IDENTIFIER)?.text } + private fun parameterNames(method: BshMethodDeclaration): List { + val params = method.node.findChildByType(E.FORMAL_PARAMETERS) ?: return emptyList() + return params.getChildren(null) + .filter { it.elementType === E.FORMAL_PARAMETER } + .mapNotNull { it.findChildByType(BshTokenTypes.IDENTIFIER)?.text } + } } } diff --git a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/completion/BshParameterInfoHandler.kt b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/completion/BshParameterInfoHandler.kt index 27c4e11..f838c9e 100644 --- a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/completion/BshParameterInfoHandler.kt +++ b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/completion/BshParameterInfoHandler.kt @@ -1,8 +1,6 @@ package cz.loplex.intellij.bsh.completion -import com.intellij.codeInsight.lookup.LookupElement import com.intellij.lang.parameterInfo.CreateParameterInfoContext -import com.intellij.lang.parameterInfo.ParameterInfoContext import com.intellij.lang.parameterInfo.ParameterInfoHandler import com.intellij.lang.parameterInfo.ParameterInfoUIContext import com.intellij.lang.parameterInfo.UpdateParameterInfoContext @@ -19,10 +17,6 @@ import cz.loplex.intellij.bsh.psi.BshElementTypes as E */ class BshParameterInfoHandler : ParameterInfoHandler { - override fun couldShowInLookup(): Boolean = true - - override fun getParametersForLookup(item: LookupElement?, context: ParameterInfoContext?): Array? = null - override fun findElementForParameterInfo(context: CreateParameterInfoContext): PsiElement? { val arguments = argumentsAt(context.file.findElementAt(context.offset)) ?: return null val method = resolveCallTarget(arguments) ?: return null diff --git a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugFrames.kt b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugFrames.kt index ff9674f..51df5d1 100644 --- a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugFrames.kt +++ b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugFrames.kt @@ -20,6 +20,7 @@ import com.intellij.xdebugger.frame.XStackFrame import com.intellij.xdebugger.frame.XSuspendContext import com.intellij.xdebugger.frame.XValue import com.intellij.xdebugger.frame.XValueChildrenList +import com.intellij.xdebugger.frame.XValueGroup import com.intellij.xdebugger.frame.XValueModifier import com.intellij.xdebugger.frame.XValueNode import com.intellij.xdebugger.frame.XValuePlace @@ -151,9 +152,16 @@ class BshStackFrame( override fun computeChildren(node: XCompositeNode) { val children = XValueChildrenList() - for ((_, handle) in source.scopes(threadId, info.id)) { - for (variable in source.variables(threadId, handle)) { - children.add(variable.name, BshValue(variable, source, threadId, info.id, handle)) + // Locals and Global overlap: a method namespace's parent chain already reaches Global (bsh + // closure semantics), so anything declared at script level is reported by both scopes. Locals + // is listed first by `scopes`, so keeping its entry over Global's matches BeanShell's own + // inner-scope-wins lookup order. What is left of a scope after that is what its group shows; + // a scope fully shadowed by an earlier one contributes no group at all. + val seen = mutableSetOf() + for ((scopeName, handle) in source.scopes(threadId, info.id)) { + val scopeVariables = source.variables(threadId, handle).filter { seen.add(it.name) } + if (scopeVariables.isNotEmpty()) { + children.addTopGroup(BshScopeGroup(scopeName, scopeVariables, source, threadId, info.id, handle)) } } node.addChildren(children, true) @@ -174,6 +182,33 @@ class BshStackFrame( } } +/** + * One agent-reported scope ("Locals"/"Global"), labeled instead of flattened into the frame. + * + * Auto-expanded because [BshStackFrame.computeChildren] already filtered [variables] down to what + * this scope alone contributes — nothing here is redundant with a group shown above it, so there is + * no reason to make the user click through to see it. + */ +private class BshScopeGroup( + name: String, + private val variables: List, + private val source: BshValueSource, + private val threadId: Int, + private val frameId: Int, + private val handle: Int, +) : XValueGroup(name) { + + override fun isAutoExpand(): Boolean = true + + override fun computeChildren(node: XCompositeNode) { + val children = XValueChildrenList() + for (variable in variables) { + children.add(variable.name, BshValue(variable, source, threadId, frameId, handle)) + } + node.addChildren(children, true) + } +} + class BshValue( private val variable: BshVariable, private val source: BshValueSource, @@ -280,9 +315,9 @@ private class BshValueModifier( * BeanShell literal too. A `toString()` is not generally an expression, so offering `Point@1c2f` * back would hand the user something that cannot even parse; those open empty instead. */ - override fun getInitialValueEditorText(): String? = when { - variable.type == "String" -> quotedLiteral(variable.value) - variable.type in LITERAL_TYPES -> variable.value + override fun getInitialValueEditorText(): String? = when (variable.type) { + "String" -> quotedLiteral(variable.value) + in LITERAL_TYPES -> variable.value else -> null } } @@ -315,6 +350,7 @@ private fun quotedLiteral(value: String): String = buildString(value.length + 2) // Wrapped explicitly rather than passed as a lambda: executeOnPooledThread is overloaded for // Runnable and Callable, and a Kotlin `() -> Unit` fits both. +@Suppress("RedundantSamConstructor") private fun onPooledThread(work: () -> Unit) { ApplicationManager.getApplication().executeOnPooledThread(Runnable { work() }) } diff --git a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugInstrumentation.kt b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugInstrumentation.kt index d374242..719f551 100644 --- a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugInstrumentation.kt +++ b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugInstrumentation.kt @@ -81,21 +81,21 @@ enum class BshInstrumentationMode( val DEFAULT: BshInstrumentationMode = AGENT /** - * Reads a stored name, tolerating anything it does not recognise. + * Reads a stored name, tolerating anything it does not recognize. * * Run-configuration options are persisted as text in the project, so this has to survive a * value written by a different version of the plugin — or edited by hand. Falling back to * [DEFAULT] costs the user their choice; refusing to launch would cost them the session. */ - fun of(name: String?): BshInstrumentationMode = values().firstOrNull { it.name == name } ?: DEFAULT + fun of(name: String?): BshInstrumentationMode = entries.firstOrNull { it.name == name } ?: DEFAULT } } /** * Locates the instrumenting agent jar. * - * The jar is shipped as a plugin resource (see the `agentJar` configuration in - * `build.gradle.kts`) and extracted to a temp file on first use, so it can be passed to a forked + * The jar is shipped as a plugin resource (see the `agentJar` configuration in `build.gradle.kts`) + * and extracted to a temp file on first use, so it can be passed to a forked * JVM as `-javaagent:`. Same mechanism as [BshMavenExt], and for the same reason: the jar has to * exist as a file on disk, but its classes must not join the IDE's own classpath. * @@ -108,13 +108,6 @@ object BshDebugAgentJar { /** Override, mainly for development and for the command-line tools. */ const val PATH_PROPERTY: String = "bsh.debug.agent.jar" - /** - * Restricts the agent to the named sources, by file-name suffix (`cz.loplex.bsh.hook.BshHook`'s - * `bsh.debug.sources`). Repeated here as a literal because the agent ships as a plugin - * *resource*, not a dependency — its classes are deliberately not on the IDE's classpath. - */ - const val SOURCES_PROPERTY: String = "bsh.debug.sources" - /** * Restricts the agent to sources whose name starts with one of the prefixes in the named file * (`bsh.debug.sources.file`). What an inline script needs: handed a string, BeanShell names the diff --git a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugInstrumenter.kt b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugInstrumenter.kt index c910d42..3a7ca18 100644 --- a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugInstrumenter.kt +++ b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugInstrumenter.kt @@ -17,7 +17,7 @@ import cz.loplex.intellij.bsh.psi.BshElementTypes as E * hook is simply one extra statement executed just before the original one, which * keeps the transformation semantics-preserving. * - * The line number baked into each hook is chosen by [lineFor]. For a standalone + * The line number baked into each hook is chosen by the `lineFor` callback. For a standalone * `.bsh` file it is the statement's own line, so breakpoints map directly. For an * inline script injected into a pom.xml the caller bakes the **host pom.xml line** * instead, so every instrumented snippet in the build reports absolute pom lines to diff --git a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugProcess.kt b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugProcess.kt index acb22cd..f82b361 100644 --- a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugProcess.kt +++ b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugProcess.kt @@ -57,7 +57,7 @@ private const val EVAL_TIMEOUT_MS = 30_000L * * Variables are pulled rather than pushed. The agent hands out an opaque handle per expandable * value, valid only until the next resume, which is what lets a nested object be opened one level - * at a time instead of every variable being serialised on every step. It is deliberately DAP's + * at a time instead of every variable being serialized on every step. It is deliberately DAP's * `variablesReference` model, so adopting DAP later is a change of encoding rather than of design. */ class BshDebugProcess( @@ -138,7 +138,7 @@ class BshDebugProcess( * The thread the user's last action applied to. * * Resume and the step commands arrive from the platform with an `XSuspendContext`, whose active - * execution stack says which thread is selected -- but the platform may also pass null, so the + * execution stack says which thread is selected. But the platform may also pass null, so the * thread that reported the stop being looked at is remembered as the fallback. */ @Volatile private var lastStoppedThread: Int = 0 @@ -284,7 +284,7 @@ class BshDebugProcess( } } - /** Serialises writes: the platform calls resume/step and breakpoint changes from any thread. */ + /** Serializes writes: the platform calls resume/step and breakpoint changes from any thread. */ private fun writeToAgent(write: (DataOutputStream) -> Unit) { val out = commands ?: return try { @@ -327,15 +327,9 @@ class BshDebugProcess( } private fun readStopped(input: DataInputStream) { - val threadId = input.readInt() - val threadName = input.readUTF() - val line = input.readInt() - val depth = input.readInt() - val frames = (0 until input.readInt()).map { index -> - BshFrameInfo(index, input.readUTF(), input.readUTF(), input.readInt()) - } - val session = threads.computeIfAbsent(threadId) { ThreadSession(threadId, threadName) } - handleStep(session, line, depth, frames) + val report = BshDebugWireCodec.readStopped(input) + val session = threads.computeIfAbsent(report.threadId) { ThreadSession(report.threadId, report.threadName) } + handleStep(session, report.line, report.depth, report.frames) } /** @@ -350,17 +344,13 @@ class BshDebugProcess( } private fun readScopesReply(input: DataInputStream): ScopesReply = - ScopesReply((0 until input.readInt()).map { input.readUTF() to input.readInt() }) + ScopesReply(BshDebugWireCodec.readScopes(input)) private fun readVariablesReply(input: DataInputStream): VariablesReply = - VariablesReply( - (0 until input.readInt()).map { - BshVariable(input.readUTF(), input.readUTF(), input.readUTF(), input.readInt()) - }, - ) + VariablesReply(BshDebugWireCodec.readVariables(input)) private fun readEvalReply(input: DataInputStream): BshEvalResult = - BshEvalResult(input.readBoolean(), input.readUTF(), input.readUTF(), input.readInt()) + BshDebugWireCodec.readEvalResult(input) private class ScopesReply(val scopes: List>) private class VariablesReply(val variables: List) @@ -411,7 +401,7 @@ class BshDebugProcess( /** * Sends one request and waits for the reply that carries its id. * - * No longer serialised, and that is the point: two threads may be suspended, so the variables + * No longer serialized, and that is the point: two threads may be suspended, so the variables * panel for one and a watch expression on another can legitimately be in flight at the same time. * Each caller registers a queue under its own request id, and [deliver] wakes exactly that one. * @@ -499,27 +489,9 @@ class BshDebugProcess( private fun stackFor(thread: ThreadSession): BshThreadStack = BshThreadStack(thread.id, thread.name, thread.frames, sourceFile, ::frameLine, this) - /** - * Where a frame sits in [sourceFile], or -1 when it sits somewhere else. - * - * With a [lineMapper] the decision is entirely its own — it knows the reported source names and - * answers -1 for anything foreign, so the injected-pom case needs no name matching here. - * - * Without one, the innermost frame is taken on trust (the agent's own source filter, or a - * rewritten script, already guarantees it is ours) while outer frames must be shown to be in - * this file: a `source()`d script or a frame entered from Java has no position in it. - */ - private fun frameLine(frame: BshFrameInfo): Int { - lineMapper?.let { return it(frame.sourceFile, frame.line) } - return when { - frame.id == 0 -> frame.line - frame.line >= 1 && inSourceFile(frame.sourceFile) -> frame.line - else -> -1 - } - } - - private fun inSourceFile(reported: String): Boolean = - reported.isNotEmpty() && (reported.endsWith(sourceFile.name) || sourceFile.path.endsWith(reported)) + /** Where a frame sits in [sourceFile], or -1 when it sits somewhere else. See [resolveFrameLine]. */ + private fun frameLine(frame: BshFrameInfo): Int = + resolveFrameLine(frame, sourceFile.name, sourceFile.path, lineMapper) private inner class BshBreakpointHandler : XBreakpointHandler>>(BshLineBreakpointType::class.java) { diff --git a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugProtocol.kt b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugProtocol.kt index 6a2ccd7..29d3e1a 100644 --- a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugProtocol.kt +++ b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugProtocol.kt @@ -3,7 +3,7 @@ package cz.loplex.intellij.bsh.debug /* * The debug wire protocol, version 3. The full specification -- framing, every field, the * invariants and the failure modes -- is `docs/PROTOCOL.md`; this file is the IDE end's copy of the - * opcodes plus the notes that matter when reading the code around them. + * opcodes plus the notes that matter when reading the surrounding code. * * Both ends of it live in this repository -- the instrumenting agent in `agent/`, the rewriting * fallback in `debug/agent/BshDebugAgent.java` -- and the agent jar ships inside the plugin, so @@ -33,7 +33,7 @@ package cz.loplex.intellij.bsh.debug * * A handle is opaque and valid only until the next resume, which is what makes it safe: the IDE * can never hold a reference into a script that has moved on. This is DAP's `variablesReference` - * in a smaller encoding, so adopting DAP later changes the serialisation and not the design. + * in a smaller encoding, so adopting DAP later changes the serialization and not the design. * * The two failable requests answer with `ok`, and on failure carry the reason in `value` rather * than dropping the connection: a mistyped watch expression is ordinary use, not a protocol error. diff --git a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugRunner.kt b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugRunner.kt index a2527f8..381eb49 100644 --- a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugRunner.kt +++ b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugRunner.kt @@ -86,7 +86,7 @@ class BshDebugRunner : GenericProgramRunner() { } // When Java debugging is available, run the JVM under JDWP so breakpoints in the - // Java code called from the script are honoured by a second (Java) debug session. + // Java code called from the script are honored by a second (Java) debug session. val javaDebug = BshJavaDebugAttach.isAvailable() val jdwpPort = if (javaDebug) freePort() else -1 @@ -101,7 +101,7 @@ class BshDebugRunner : GenericProgramRunner() { ) } if (useAgent) { - commandLine.withParameters("-javaagent:${agentJar!!.absolutePath}") + commandLine.withParameters("-javaagent:${agentJar.absolutePath}") // Instrumenting the interpreter reaches strictly more code than rewriting one script // does, so without this the session would also stop inside BeanShell's own commands -- // print and friends are .bsh files on the classpath. diff --git a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugWireCodec.kt b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugWireCodec.kt new file mode 100644 index 0000000..10c1338 --- /dev/null +++ b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshDebugWireCodec.kt @@ -0,0 +1,68 @@ +package cz.loplex.intellij.bsh.debug + +import java.io.DataInputStream + +/** + * Decodes what the agent sends (see `BshDebugProtocol.kt` for the framing), split out of + * [BshDebugProcess] so the decoding can be pinned down by a test without a live socket. + */ +internal object BshDebugWireCodec { + + /** One [EVT_STOPPED] report, before [BshDebugProcess] turns it into a dispatch decision. */ + data class StoppedReport( + val threadId: Int, + val threadName: String, + val line: Int, + val depth: Int, + val frames: List, + ) + + fun readStopped(input: DataInputStream): StoppedReport { + val threadId = input.readInt() + val threadName = input.readUTF() + val line = input.readInt() + val depth = input.readInt() + val frames = (0 until input.readInt()).map { index -> + BshFrameInfo(index, input.readUTF(), input.readUTF(), input.readInt()) + } + return StoppedReport(threadId, threadName, line, depth, frames) + } + + fun readScopes(input: DataInputStream): List> = + (0 until input.readInt()).map { input.readUTF() to input.readInt() } + + fun readVariables(input: DataInputStream): List = + (0 until input.readInt()).map { + BshVariable(input.readUTF(), input.readUTF(), input.readUTF(), input.readInt()) + } + + fun readEvalResult(input: DataInputStream): BshEvalResult = + BshEvalResult(input.readBoolean(), input.readUTF(), input.readUTF(), input.readInt()) +} + +/** + * Where a frame the agent reported sits in the file being debugged, or -1 when it sits elsewhere. + * + * With a [lineMapper] the decision is entirely its own -- it knows the reported source names and + * answers -1 for anything foreign, so the injected-pom case needs no name matching here. + * + * Without one, the innermost frame ([BshFrameInfo.id] 0) is taken on trust -- the agent's own source + * filter, or a rewritten script, already guarantees it is ours -- while outer frames must be shown to + * be in this file: a `source()`d script or a frame entered from Java has no position in it. + */ +internal fun resolveFrameLine( + frame: BshFrameInfo, + sourceFileName: String, + sourceFilePath: String, + lineMapper: ((sourceFile: String, line: Int) -> Int)?, +): Int { + lineMapper?.let { return it(frame.sourceFile, frame.line) } + return when { + frame.id == 0 -> frame.line + frame.line >= 1 && isInSourceFile(frame.sourceFile, sourceFileName, sourceFilePath) -> frame.line + else -> -1 + } +} + +private fun isInSourceFile(reported: String, sourceFileName: String, sourceFilePath: String): Boolean = + reported.isNotEmpty() && (reported.endsWith(sourceFileName) || sourceFilePath.endsWith(reported)) diff --git a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshJavaDebugAttach.kt b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshJavaDebugAttach.kt index 2506444..e69a66d 100644 --- a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshJavaDebugAttach.kt +++ b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshJavaDebugAttach.kt @@ -9,7 +9,7 @@ import com.intellij.openapi.project.Project /** * Optionally attaches IntelliJ's Java (JDWP) debugger to the forked BeanShell JVM - * so that breakpoints in the Java code invoked from a script are honoured. + * so that breakpoints in the Java code invoked from a script are honored. * * Implemented purely through platform API plus the "Remote" run-configuration * type (id `Remote`) contributed by the Java plugin, looked up by id — so this diff --git a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshLineBreakpointType.kt b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshLineBreakpointType.kt index c19bf1d..fb520e4 100644 --- a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshLineBreakpointType.kt +++ b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshLineBreakpointType.kt @@ -3,7 +3,6 @@ package cz.loplex.intellij.bsh.debug import com.intellij.lang.injection.InjectedLanguageManager import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile -import com.intellij.psi.PsiFile import com.intellij.psi.PsiLanguageInjectionHost import com.intellij.psi.PsiManager import com.intellij.psi.util.PsiTreeUtil @@ -37,7 +36,7 @@ class BshLineBreakpointType : val element = psiFile.findElementAt(offset) val host = element?.let { PsiTreeUtil.getParentOfType(it, PsiLanguageInjectionHost::class.java, false) } if (host != null && isBeanShellInjected(manager, host)) return true - offset = (element?.textRange?.endOffset ?: offset + 1).coerceAtLeast(offset + 1) + offset = (element?.textRange?.endOffset ?: (offset + 1)).coerceAtLeast(offset + 1) } return false } diff --git a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshStepLogic.kt b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshStepLogic.kt index 8e677fa..d47e4b3 100644 --- a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshStepLogic.kt +++ b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/BshStepLogic.kt @@ -11,8 +11,7 @@ enum class BshStepMode { RUN, INTO, OVER, OUT } */ object BshStepLogic { fun shouldPause(mode: BshStepMode, stepDepth: Int, currentDepth: Int, atBreakpoint: Boolean): Boolean { - if (atBreakpoint) return true - return when (mode) { + return atBreakpoint || when (mode) { BshStepMode.RUN -> false BshStepMode.INTO -> true // next statement, wherever it is BshStepMode.OVER -> currentDepth <= stepDepth // skip descents into called methods diff --git a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/maven/BshMavenCreateRunConfigurationAction.kt b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/maven/BshMavenCreateRunConfigurationAction.kt index f8735b7..f02c1f9 100644 --- a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/maven/BshMavenCreateRunConfigurationAction.kt +++ b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/maven/BshMavenCreateRunConfigurationAction.kt @@ -38,9 +38,8 @@ class BshMavenCreateRunConfigurationAction : AnAction() { val name = "${mavenProject.mavenId.artifactId ?: "maven"} [${goals.joinToString(" ")}] (bsh)" val settings = runManager.createConfiguration(name, factory) - (settings.configuration as BshMavenRunConfiguration).setRunnerParameters( - MavenRunnerParameters(false, mavenProject.directory, mavenProject.file.name, goals, emptyList(), emptyList()), - ) + (settings.configuration as BshMavenRunConfiguration).runnerParameters = + MavenRunnerParameters(false, mavenProject.directory, mavenProject.file.name, goals, emptyList(), emptyList()) if (RunDialog.editConfiguration(project, settings, "Create BeanShell-Enhanced Maven Configuration")) { runManager.addConfiguration(settings) diff --git a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/maven/BshMavenDebugSupport.kt b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/maven/BshMavenDebugSupport.kt index 7e7c3dd..8a4e1eb 100644 --- a/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/maven/BshMavenDebugSupport.kt +++ b/plugin/src/main/kotlin/cz/loplex/intellij/bsh/debug/maven/BshMavenDebugSupport.kt @@ -39,7 +39,7 @@ object BshMavenDebugSupport { private const val NAME_LEAD = "inline evaluation of: ``" /** - * How much of the script goes into the prefix used to recognise it. + * How much of the script goes into the prefix used to recognize it. * * Below BeanShell's own 80-character cut so the elision can never fall inside the prefix, and * well above the length at which two `