A comprehensive Gradle plugin that teaches Gradle the Ecstasy programming language, enabling seamless compilation, testing, and execution of XTC projects. This documents describes how it works, and goes into some technical detail, but also serves as a usage guide. There are several XTC project examples on GitHub that will show you the basics of setting up and XTC build and execution environment with the build DSL. If you are just looking for the simplest possible XTC "HelloWorld" setup, take a look at the examples repository.
- Overview
- Architecture
- Configuration
- Build Lifecycle
- Module Path Resolution
- Configuration Cache Compatibility
- Performance Optimization
- Troubleshooting
The Ecstasy Gradle Plugin integrates the Ecstasy language into Gradle's build ecosystem by providing:
- Source Set Integration: XTC source directories alongside Java/Kotlin code
- Dependency Management: Transitive dependencies between XTC modules
- Multiple Launchers: Native binaries, forked JVMs, or in-process execution
- Programmatic API Access: Direct method calls to compiler without reflection
- Configuration Cache: Full support for Gradle's configuration cache
- Incremental Compilation: Smart up-to-date checking for fast rebuilds
- Flexible Module Path: Custom module path resolution for complex project structures
The main plugin entry point that:
- Applies Java plugin as a foundation
- Creates XTC-specific configurations for dependencies
- Registers source sets and compilation tasks
- Sets up the launcher framework
- Configures project extensions
Each Gradle source set (main, test, etc.) gets:
- An
xtcsource directory (e.g.,src/main/xtc) - A compilation task (
compileXtc,compileTestXtc) - Output directories under
build/xtc/<sourceSet>/ - Dependency configurations for XTC modules
Key task types:
- XtcCompileTask: Compiles XTC source files to
.xtcmodules - XtcRunTask: Executes XTC applications
- XtcTestTask: Executes XUnit tests for a module
- XtcDisassembleTask: Disassembles XTC modules for debugging
- XtcLauncherTask: Abstract base for all launcher-based tasks
The plugin uses JavaClasspathLauncher for all XTC tool execution, providing optimal performance and flexibility.
File: org.xtclang.plugin.launchers.JavaClasspathLauncher
Invokes javatools classes directly, either in-process or in a forked JVM based on the fork setting. Supports detached background processes for long-running applications.
Execution Modes:
-
In-Process (fork=false) - Default for compilation
- Instant startup (~0ms)
- Shares Gradle daemon JVM
- Full IDE debugging support
- Configuration cache compatible
-
Forked Process (fork=true) - For runtime isolation
- Complete isolation from Gradle JVM
- Independent JVM arguments
- ~1-2s JVM startup time
- Supports JDWP remote debugging
-
Detached Process (detach=true) - For background services
- Automatically enables forking
- Process continues after Gradle exits
- Output redirected to timestamped log file
- Returns immediately without waiting
Implementation Details:
The plugin has compile-time access to javatools types through a compileOnly dependency:
// plugin/build.gradle.kts
dependencies {
compileOnly(libs.javatools) // Type information only, not bundled
}At runtime, the plugin loads javatools.jar dynamically:
// Direct invocation with full type safety
XtcJavaToolsRuntime.withJavaTools(javaToolsJar, logger, () -> {
Compiler.launch(args); // No reflection!
return result;
});Configuration:
xtcCompile {
fork.set(false) // In-process (fast, default)
}
xtcRun {
fork.set(true) // Separate process (isolation)
// OR
detach.set(true) // Background process (fork automatically enabled)
}Benefits:
- Direct type-safe calls (
Compiler.launch(args)) - no reflection - Full IDE debugging support (fork=false)
- JDWP remote debugging support (fork=true)
- Configuration cache compatible
- Single launcher for all scenarios
The plugin supports debugging XTC code through standard Java debugging tools.
When using fork=false (default for compilation), you can debug directly in your IDE by attaching to the Gradle daemon:
-
Start Gradle with debug enabled:
./gradlew compileXtc --no-daemon -Dorg.gradle.debug=true
-
Gradle will wait for debugger connection on port 5005
-
Attach your IDE debugger to
localhost:5005
This allows stepping through both plugin code and javatools (compiler/runtime) code.
For forked processes, use standard JDWP arguments with jvmArgs:
xtcRun {
fork.set(true)
jvmArgs(
"-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005"
)
module {
moduleName = "MyApp"
}
}JDWP Parameters:
transport=dt_socket: Use TCP/IP socketsserver=y: Listen for debugger connectionsuspend=y: Wait for debugger before starting (usesuspend=nto start immediately)address=5005: Port number for debugger connection
Steps:
- Run the task:
./gradlew runXtc - The process will suspend and wait for debugger
- Attach your IDE debugger to
localhost:5005 - Debug your XTC code as it executes
Example with Different Port:
xtcCompile {
fork.set(true)
jvmArgs("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=8000")
}- In-Process (fork=false): Best for debugging plugin code and compiler internals
- Forked (fork=true): Best for debugging XTC application code in isolation
- Detached Mode: Not recommended for debugging (process runs in background)
- Multiple Modules: When running multiple modules sequentially, debugger will attach to each execution
The plugin can directly call javatools methods without reflection, providing significant benefits:
Compile-Time Types:
// Plugin declares compile-only dependency
dependencies {
compileOnly(libs.javatools) // Provides types at compile time
}Runtime Loading:
// Plugin loads javatools.jar dynamically at runtime
XtcJavaToolsRuntime.ensureJavaToolsInClasspath(
projectVersion, javaToolsConfig, xdkFileTree, logger);Direct Invocation:
// Call compiler directly - no reflection!
Compiler.launch(args);
Runner.launch(args);
Disassembler.launch(args);Developer Experience:
- IDE Integration: Full autocomplete and type checking for javatools APIs
- Compile-Time Safety: Catches API misuse at compile time
- Refactoring: Safe renames across plugin and javatools
- Debugging: Step through compiler code directly in IDE
Performance:
- No Reflection Overhead: Direct method calls
- JIT Optimization: HotSpot can inline across plugin/javatools boundary
- In-Process Execution: Zero overhead when fork=false
Maintainability:
- Clear API Surface: Explicit dependencies make architecture obvious
- Type Safety: Compiler verifies all javatools calls
- Easy Testing: Direct method calls simplify unit tests
Apply the plugin in your build.gradle.kts:
plugins {
id("org.xtclang.xtc-plugin") version "X.Y.Z"
}
// Optional: Configure compilation
xtcCompile {
verbose.set(true)
fork.set(false) // In-process execution (default)
}Extension: XtcCompilerExtension
Note: Module path is automatically resolved from your xtcModule(...) dependencies. Manual configuration is rarely needed.
xtcCompile {
// Compiler verbosity
verbose.set(true)
// Show XTC version during compilation
showVersion.set(true)
// Launcher configuration
fork.set(false) // In-process execution (default)
// JVM arguments (only used when fork=true)
jvmArgs("-Xmx2g", "-Xms512m")
}
// Advanced: Custom module path (overrides automatic resolution)
// Only use if you need non-standard module locations
xtcCompile {
modulePath.from(files("custom/modules"))
}Extension: XtcRunExtension
Note: In most cases, you don't need to configure the module path manually. The plugin automatically resolves:
- All dependencies declared with
xtcModule(...)in your build file - Compiled XTC modules from all project dependencies (including composite builds)
- Build output directories from the current project and its dependencies
Minimal Configuration (recommended for most projects):
xtcRun {
// Main module and method - this is usually all you need!
module.set("myapp.xtc")
method.set("run")
// Optional: Program arguments
programArgs("arg1", "arg2")
}Full Configuration (for advanced scenarios):
xtcRun {
// Main module and method
module.set("myapp.xtc")
method.set("run")
// Program arguments
programArgs("arg1", "arg2")
// Custom module path (only if you need to override automatic resolution)
modulePath.from(files("runtime/modules"))
// JVM arguments (only used when fork=true)
jvmArgs("-Xmx1g")
// Execution mode
fork.set(false) // In-process (default)
}Extension: XtcTestExtension
Note: In most cases, you don't need to configure the module path manually. The plugin automatically resolves:
- All dependencies declared with
xtcModule(...)in your build file - Compiled XTC modules from all project dependencies (including composite builds)
- Build output directories from the current project and its dependencies
Minimal Configuration (recommended for most projects):
xtcTest {
// Test module - this is usually all you need!
module.set("myapp.xtc")
}Skipping Tests:
The plugin supports standard Gradle/Maven conventions for skipping tests during development:
| Flag | Effect |
|---|---|
-PskipTests |
Skips all XTC tests (testXtc) across all projects (recommended) |
-PskipAllTests |
Skips both Java tests (test) and XTC tests (testXtc) |
-x :project:testXtc |
Excludes a specific project's XTC test task only (not subprojects) |
-x test |
Excludes Java tests only |
Note: The
-xflag only excludes the specifically named task, not tasks in subprojects. Use-PskipTeststo skip all XTC tests across all projects in a multi-project build.
Examples:
# Skip all XTC tests during rapid iteration (recommended)
./gradlew build -PskipTests
# Skip all tests (Java and XTC) during development
./gradlew build -PskipAllTests
# Exclude a specific project's test task only
./gradlew build -x :myproject:testXtcCommon Scenarios:
| Scenario | Configuration | Description |
|---|---|---|
| Fast development builds | fork=false (default) |
In-process, instant startup |
| Debugging compiler/plugin | fork=false |
Attach to Gradle daemon |
| Debugging XTC code | fork=true + jvmArgs |
JDWP remote debugging |
| Memory isolation | fork=true |
Separate JVM process |
| Background services | detach=true |
Runs after Gradle exits |
| CI/CD builds | fork=false |
Fastest for compilation |
Execution Flow:
JavaClasspathLauncher
├─ if (fork=false) → In-process execution (DEFAULT)
├─ if (fork=true, detach=false) → Forked process, wait for completion
└─ if (detach=true) → Forked process, background execution
-
Configuration Phase:
- Plugin creates source sets and tasks
- Captures configuration-time data for cache compatibility
- Registers javatools dependency
-
Compilation Phase (
compileXtc):- Resolves full module path (XDK + dependencies + source sets)
- Loads javatools.jar into plugin classloader
- Selects appropriate launcher
- Compiles XTC sources to
build/xtc/main/ - Validates module dependencies
-
Test Compilation (
compileTestXtc):- Compiles test sources with test dependencies
- Links against main module output
-
Packaging (
jar):- Includes compiled XTC modules in JAR
- Preserves module structure
compileJava
↓
compileXtc → processResources → classes → jar
↓
compileTestXtc → test
The plugin uses Gradle's up-to-date checking based on:
- Input files: XTC source files
- Input configuration: Module path, compiler args, launcher settings
- Output files: Generated
.xtcmodules
Change any input → task re-executes.
The module path determines where the XTC compiler and runtime look for dependencies.
Important: The plugin automatically resolves the module path for you. You rarely need to configure it manually.
The plugin automatically builds the module path from:
-
XDK Contents: Core XTC libraries (ecstasy.xtc, etc.)
- Resolved from XDK configuration
- Always included first
-
XTC Module Dependencies:
- All dependencies declared with
xtcModule(...)in your build file - Includes project dependencies (composite builds)
- Includes external dependencies (Maven/local)
- Transitive dependencies automatically included
- One configuration per source set
- All dependencies declared with
-
Project Build Outputs:
- Compiled
.xtcmodules from the current project's build directories - Build outputs from all dependent projects (for composite builds)
- Output directories for each source set
- Compiled
This means:
- For single projects: Just declare dependencies, the plugin handles the rest
- For composite builds: All project dependencies are automatically discovered and included
- For runtime:
xtcRunincludes everything compiled by your dependencies
Only specify a custom module path if you need non-standard module locations:
- Custom Module Path (when explicitly specified):
- User-provided directories/modules
- Overrides automatic dependency resolution
- Use only for special aggregator projects or custom layouts
For a project with dependencies:
dependencies {
xtcModule("org.xtclang:lib-json:1.0.0")
testXtcModule("org.xtclang:lib-test:1.0.0")
}Module Path (main compilation):
[
xdk/contents/lib/ecstasy.xtc,
build/dependencies/lib-json.xtc,
build/xtc/main/
]
Module Path (test compilation):
[
xdk/contents/lib/ecstasy.xtc,
build/dependencies/lib-json.xtc,
build/dependencies/lib-test.xtc,
build/xtc/main/,
build/xtc/test/
]
The plugin is fully compatible with Gradle's configuration cache through careful design:
-
No Project Access During Execution:
- All project state captured at configuration time
- Stored in Provider/Property types
-
Lazy Configuration:
- Use
Provider<T>andProperty<T>for deferred values - Avoid calling
.get()during configuration
- Use
-
Serializable State:
- No lambda captures
- No script object references
- Injected services for execution
Configuration-Time Capture:
// Captured at construction (configuration phase)
this.xdkContentsDir = XtcProjectDelegate.getXdkContentsDir(project);
this.sourceSetNames = sourceSets.stream().map(SourceSet::getName).toList();
// Used at execution (no Project access)
@TaskAction
public void executeTask() {
File xdkDir = xdkContentsDir.get().getAsFile();
// ... compilation logic
}Injected Services:
@Inject
public abstract ExecOperations getExecOperations();
@TaskAction
public void executeTask() {
// Use injected service, not project.exec()
getExecOperations().javaexec(spec -> {
// ...
});
}Run with --configuration-cache to enable. Note that configuration cache should always be enabled
in the gradle.properties for all projects, including the XVM build, to ensure a much faster build
process, and to force the programmer (or your AI) to create compatible code.
./gradlew compileXtc --configuration-cacheGradle will report any violations:
Project.getLogger()calls during execution- Direct project property access
- Non-serializable task state
The Ecstasy Gradle Plugin is fully compatible with Gradle's standard performance features:
- Configuration Cache: Dramatically speeds up subsequent builds by caching configuration phase
- Build Cache: Reuses outputs from previous builds or shared across machines
- Parallel Execution: Compiles multiple modules concurrently
These features are fully supported and should be enabled in your gradle.properties:
org.gradle.configuration-cache=true
org.gradle.caching=true
org.gradle.parallel=trueUse In-Process Execution (default):
xtcCompile {
fork.set(false) // In-process execution - instant startup
}Optimize for CI Builds:
// CI/CD builds work best with default settings (fork=false)
xtcCompile {
fork.set(false) // In-process, fastest compilation
verbose.set(false) // Reduce log noise
}Adjust Memory for Large Projects:
# gradle.properties
org.gradle.jvmargs=-Xmx4gError: XTC Compilation Failed (exit code 1)
Solution:
- Enable verbose logging:
xtcCompile { verbose.set(true) } - Check module path resolution in logs
- Verify dependencies are available
- Check for XTC syntax errors in source files
Error: Configuration cache problems found
Solution:
- Ensure no custom task code accesses
Projectduring execution - Use injected services instead of direct project access
- Capture configuration at task creation time
- Report issues to plugin maintainers
Error: Forked process fails or hangs
Solution:
- Check JVM arguments are valid:
xtcRun { fork.set(true) jvmArgs("-Xmx1g") // Verify memory settings } - Enable verbose logging to see process output
- Try in-process mode first to isolate the issue:
fork.set(false) - For debugging, add JDWP args and attach debugger
Error: Duplicate module on path
Solution:
- Check dependency tree:
./gradlew dependencies --configuration xtcModule - Exclude transitive dependencies:
dependencies { xtcModule("org.xtclang:lib-json:1.0.0") { exclude(group = "org.xtclang", module = "lib-net") } } - Use custom module path to override
When modifying the plugin, follow these guidelines:
- Configuration Cache First: Always design for configuration cache compatibility
- Capture Early: Capture all configuration state in task constructors, but it's better to find a way where this doesn't matter
- No Project in Actions: Never access
Projectin@TaskActionmethods - Newlines: Always add newline at end of files (enforced by
CLAUDE.md) - Final State: Fields are final if they don't MUST be anything else. Try to create all state as final during construction
- Run tests:
./gradlew plugin:test - Test configuration cache:
./gradlew compileXtc --configuration-cache - Test all launcher types
- Verify multi-module scenarios
When profiling XTC compilation or execution, you have several options ranging from simple to advanced. These techniques are particularly useful for understanding where time is spent during the first compilation of large modules like lib-ecstasy.
Build scans provide a cloud-hosted timeline view of your build with task timing, dependency resolution, and performance insights.
Usage:
./gradlew build --scanAfter the build completes, you'll receive a URL to view the detailed scan online. This shows:
- Task execution times
- Dependency resolution performance
- Configuration phase timing
- Build cache effectiveness
Best for: Quick overview of build performance, identifying slow tasks, sharing results with team.
Gradle includes a local profiler that generates an HTML report with detailed timing information.
Usage:
./gradlew build --profileOutput: build/reports/profile/profile-<timestamp>.html
The report includes:
- Task execution breakdown
- Dependency resolution timing
- Configuration vs. execution time
- Project-level performance metrics
Best for: Local analysis, CI/CD integration, offline viewing.
JFR is built into the JVM and provides low-overhead (typically <1%) method-level profiling with rich data about:
- CPU usage by method
- Memory allocations
- Thread activity
- I/O operations
- JVM internals (GC, JIT compilation)
Usage:
./gradlew build \
-Dorg.gradle.jvmargs="-XX:StartFlightRecording=filename=recording.jfr,dumponexit=true,settings=profile"Analyzing Results:
Option A: Command-line (basic text output):
# Print summary
java -version # Requires JDK 9+
jdk.jfr.tool.Main print recording.jfr
# Or with JDK 11+:
jfr print recording.jfrOption B: JDK Mission Control (GUI - recommended):
# Download JMC from https://jdk.java.net/jmc/
# Then open the .jfr file
jmcAdvanced Options:
# Custom duration limit (60 seconds)
-XX:StartFlightRecording=filename=recording.jfr,duration=60s,settings=profile
# Maximum size limit (100MB)
-XX:StartFlightRecording=filename=recording.jfr,maxsize=100m,settings=profile
# Custom settings (default or profile)
# 'default' = ~1% overhead, 'profile' = ~2% overhead with more detail
-XX:StartFlightRecording=filename=recording.jfr,settings=defaultBest for: Deep method-level analysis, production environments, finding hot paths, memory allocation analysis.
Async-profiler is a low-overhead sampling profiler that generates flame graphs showing where time is spent. It supports:
- CPU profiling (method execution time)
- Allocation profiling (heap allocations)
- Lock profiling (contention analysis)
- Native code profiling (JNI calls)
Setup:
# Download from https://github.com/async-profiler/async-profiler/releases
# Extract to a location, e.g., ~/tools/async-profiler
# macOS example:
wget https://github.com/async-profiler/async-profiler/releases/latest/download/async-profiler-2.9-macos.zip
unzip async-profiler-2.9-macos.zip -d ~/tools/Usage:
CPU Profiling (generates interactive HTML flame graph):
./gradlew build \
-Dorg.gradle.jvmargs="-agentpath:$HOME/tools/async-profiler-2.9-macos/lib/libasyncProfiler.so=start,event=cpu,file=profile.html"Allocation Profiling (track heap allocations):
./gradlew build \
-Dorg.gradle.jvmargs="-agentpath:$HOME/tools/async-profiler-2.9-macos/lib/libasyncProfiler.so=start,event=alloc,file=alloc-profile.html"Advanced Options:
# Customize sampling interval (default 10ms)
-agentpath:/path/to/libasyncProfiler.so=start,event=cpu,interval=1ms,file=profile.html
# Generate both flame graph and collapsed stacks
-agentpath:/path/to/libasyncProfiler.so=start,event=cpu,file=profile.html,collapsed
# Profile specific Java packages only
-agentpath:/path/to/libasyncProfiler.so=start,event=cpu,file=profile.html,include='org/xvm/*'Reading Flame Graphs:
- X-axis: Proportion of samples (wider = more time spent)
- Y-axis: Call stack depth (bottom = entry point, top = leaf methods)
- Colors: Different packages/classes
- Click to zoom into specific code paths
Best for: Finding CPU hotspots, memory allocation patterns, visual analysis, performance optimization.
If you suspect garbage collection is impacting build time, enable GC logging:
Java 9+ (Unified Logging):
./gradlew build \
-Dorg.gradle.jvmargs="-Xlog:gc*:file=gc.log:time,level,tags"Analysis:
# View GC events
cat gc.log
# Summary statistics
grep "Pause" gc.log | awk '{sum+=$NF; count++} END {print "Average GC pause:", sum/count "ms"}'Best for: Diagnosing memory issues, tuning GC parameters, identifying excessive allocations.
| Tool | Setup Effort | Detail Level | Best Use Case |
|---|---|---|---|
| Build Scan | None | Task-level | Quick overview, team sharing |
--profile |
None | Task-level | Local analysis, CI reports |
| JFR | Minimal | Method-level | Production profiling, comprehensive analysis |
| Async-profiler | Download | Method-level | Performance optimization, flame graphs |
| GC Logging | Minimal | GC events | Memory/GC tuning |
Recommended Workflow:
- Start with
--profileor--scanto identify slow tasks - Use JFR for detailed method-level analysis of slow tasks
- Use async-profiler when optimizing specific hot paths
- Enable GC logging if memory pressure is suspected
Update this README when:
- Adding new configuration options
- Introducing new launcher types
- Changing module path resolution
- Adding performance optimizations