From 39a94e06ed44991db162a845b0ebd65bd52e48a1 Mon Sep 17 00:00:00 2001 From: James Roome Date: Fri, 2 Oct 2026 14:55:41 +0530 Subject: [PATCH 1/2] api: wrap compiler options, module loading and global queries The idiomatic layer wrapped 14 of Slang's ~80 COM methods and none of its ~160 compiler options. This adds the three groups chosen for 0.0.11. Compiler options. SessionBuilder gains typed setters for the common ones -- optimization, debugInfo, warningsAsErrors, disableWarnings, languageVersion, capability (by name, resolved through findCapability) and bitfieldPacking, the last being Slang 2026.19's fix for MSVC-compatible packing -- plus raw option(name, value) overloads taking ffi.gen.CompilerOptionName constants for everything else. TargetOptions gets the same raw overloads, per target. Slang loads the session's entries before adding targets, so each target inherits them and its own entries override; the javadoc warns that FloatingPointMode, LineDirectiveMode and GLSLForceScalarLayout are re-applied from TargetDesc fields after inheritance, so those only work per target. New enums OptimizationLevel, DebugInfoLevel and BitfieldPacking follow Stage's value()/of(int)/UNKNOWN convention; passing UNKNOWN is refused. Module loading and introspection. Session.loadModule(name) resolves an import name through the search paths or a SlangFileSystem, and returns an already-loaded module rather than recompiling. Module gains name(), filePath(), dependencyFiles() (its source, #includes, and the sources of modules it imports -- checked, not assumed), entryPoints() (what Slang found: [shader(...)] functions and, as Slang infers, [numthreads]-only compute functions) and entryPoint(name, stage) for undecorated functions. Session.isIrUpToDate(path, ir) wraps isBinaryModuleUpToDate; its javadoc records that a session keeps the files it has read, so an edit is only seen from a fresh session. Global queries and whole-program code. GlobalSession gains findCapability, isTargetSupported, isPassThroughSupported and downstreamCompilerPath (the 2026.17 replacement for getDownstreamCompilerVersion), with a PassThrough enum; ComponentType.targetCode(target) returns code for every entry point at once. Entry points from getDefinedEntryPoint and findAndCheckEntryPoint are caller-owned, as upstream's detached ComPtrs make them. Tests: CompilerOptionsTest checks each typed option by its effect (debug info in the SPIR-V, NONE vs MAXIMAL code size, warnings failing or vanishing, MSVC bitfield storage in generated HLSL, per-target entries landing on one target only); ModuleLoadingTest and GlobalQueriesTest cover the rest, including that glslang resolves to the payload's library. 64 of 64 pass against the 2026.19 macos-aarch64 payload; javadoc builds warning-free. Co-Authored-By: Claude Opus 5.5 --- .../github/refux/slang/BitfieldPacking.java | 53 +++++ .../io/github/refux/slang/ComponentType.java | 13 ++ .../io/github/refux/slang/DebugInfoLevel.java | 46 +++++ .../java/io/github/refux/slang/Enums.java | 15 ++ .../io/github/refux/slang/GlobalSession.java | 38 ++++ .../java/io/github/refux/slang/Module.java | 58 +++++- .../github/refux/slang/OptimizationLevel.java | 47 +++++ .../io/github/refux/slang/OptionList.java | 45 +++++ .../io/github/refux/slang/PassThrough.java | 58 ++++++ .../java/io/github/refux/slang/Session.java | 37 ++++ .../io/github/refux/slang/SessionBuilder.java | 103 +++++++++- .../io/github/refux/slang/TargetOptions.java | 29 ++- .../refux/slang/ffi/CompilerOptionEntry.java | 43 ++++ .../refux/slang/ffi/IComponentType.java | 19 ++ .../refux/slang/ffi/IGlobalSession.java | 44 ++++ .../io/github/refux/slang/ffi/IModule.java | 66 ++++++ .../io/github/refux/slang/ffi/ISession.java | 47 +++++ .../github/refux/slang/ffi/SessionDesc.java | 9 + .../github/refux/slang/ffi/SlangNative.java | 7 + .../io/github/refux/slang/ffi/TargetDesc.java | 9 + .../refux/slang/CompilerOptionsTest.java | 177 ++++++++++++++++ .../github/refux/slang/GlobalQueriesTest.java | 57 ++++++ .../github/refux/slang/ModuleLoadingTest.java | 190 ++++++++++++++++++ 23 files changed, 1204 insertions(+), 6 deletions(-) create mode 100644 slang/src/main/java/io/github/refux/slang/BitfieldPacking.java create mode 100644 slang/src/main/java/io/github/refux/slang/DebugInfoLevel.java create mode 100644 slang/src/main/java/io/github/refux/slang/Enums.java create mode 100644 slang/src/main/java/io/github/refux/slang/OptimizationLevel.java create mode 100644 slang/src/main/java/io/github/refux/slang/OptionList.java create mode 100644 slang/src/main/java/io/github/refux/slang/PassThrough.java create mode 100644 slang/src/main/java/io/github/refux/slang/ffi/CompilerOptionEntry.java create mode 100644 slang/src/test/java/io/github/refux/slang/CompilerOptionsTest.java create mode 100644 slang/src/test/java/io/github/refux/slang/GlobalQueriesTest.java create mode 100644 slang/src/test/java/io/github/refux/slang/ModuleLoadingTest.java diff --git a/slang/src/main/java/io/github/refux/slang/BitfieldPacking.java b/slang/src/main/java/io/github/refux/slang/BitfieldPacking.java new file mode 100644 index 0000000..2f7d5cb --- /dev/null +++ b/slang/src/main/java/io/github/refux/slang/BitfieldPacking.java @@ -0,0 +1,53 @@ +package io.github.refux.slang; + +import io.github.refux.slang.ffi.gen.BitfieldPackingRules; +import java.util.HashMap; +import java.util.Map; + +/** + * How bitfield members are packed into storage ({@code slang::BitfieldPackingRules}), set with + * {@link SessionBuilder#bitfieldPacking}. {@link #value()} / {@link #of(int)} are escape hatches to + * the raw ABI values; unmapped (newer) values return {@link #UNKNOWN}. + */ +public enum BitfieldPacking { + /** Sentinel for ABI values this enum does not know (carries no raw value). */ + UNKNOWN(Integer.MIN_VALUE), + /** Least-significant bit first; fields may share storage across underlying type sizes. */ + DEFAULT(BitfieldPackingRules.Default), + /** + * As MSVC packs on little-endian platforms: least-significant bit first, and a change of + * underlying type size starts new storage. Rejects zero-width fields. + */ + MSVC(BitfieldPackingRules.MSVC), + /** + * What Slang's older {@code -msvc-style-bitfield-packing} flag did: most-significant bit first, + * new storage on a type-size change. Not what MSVC does; kept for existing data layouts only. + */ + LEGACY_MSB_FIRST_MSVC(BitfieldPackingRules.LegacyMSBFirstMSVC); + + private static final Map BY_VALUE = new HashMap<>(); + + static { + for (BitfieldPacking rules : values()) { + if (rules != UNKNOWN) { + BY_VALUE.put(rules.value, rules); + } + } + } + + private final int value; + + BitfieldPacking(int value) { + this.value = value; + } + + /** The raw {@code slang::BitfieldPackingRules} ABI value ({@code Integer.MIN_VALUE} for UNKNOWN). */ + public int value() { + return value; + } + + /** Maps a raw ABI value; unmapped (newer) values return {@link #UNKNOWN}. */ + public static BitfieldPacking of(int value) { + return BY_VALUE.getOrDefault(value, UNKNOWN); + } +} diff --git a/slang/src/main/java/io/github/refux/slang/ComponentType.java b/slang/src/main/java/io/github/refux/slang/ComponentType.java index 859ea68..93d502b 100644 --- a/slang/src/main/java/io/github/refux/slang/ComponentType.java +++ b/slang/src/main/java/io/github/refux/slang/ComponentType.java @@ -44,6 +44,19 @@ public byte[] entryPointCode(long entryPointIndex, long targetIndex) { return componentHandle().getEntryPointCode(entryPointIndex, targetIndex); } + /** + * Target code for a whole linked component — all its entry points in one artifact, such as a + * single SPIR-V module, or a WGSL or Metal library — where {@link #entryPointCode} gives one + * entry point at a time. + * + * @param targetIndex index into the session's target list, in {@link SessionBuilder#target} + * order + */ + public byte[] targetCode(long targetIndex) { + session.checkThread(); + return componentHandle().getTargetCode(targetIndex); + } + /** The lazy reflection tree for {@code targetIndex} (parameters, entry points, layouts). */ public ShaderReflection layout(long targetIndex) { session.checkThread(); diff --git a/slang/src/main/java/io/github/refux/slang/DebugInfoLevel.java b/slang/src/main/java/io/github/refux/slang/DebugInfoLevel.java new file mode 100644 index 0000000..f859666 --- /dev/null +++ b/slang/src/main/java/io/github/refux/slang/DebugInfoLevel.java @@ -0,0 +1,46 @@ +package io.github.refux.slang; + +import io.github.refux.slang.ffi.gen.SlangDebugInfoLevel; +import java.util.HashMap; +import java.util.Map; + +/** + * How much debug information Slang emits ({@code SlangDebugInfoLevel}), set with + * {@link SessionBuilder#debugInfo}. {@link #value()} / {@link #of(int)} are escape hatches to the + * raw ABI values; unmapped (newer) values return {@link #UNKNOWN}. + */ +public enum DebugInfoLevel { + /** Sentinel for ABI values this enum does not know (carries no raw value). */ + UNKNOWN(Integer.MIN_VALUE), + /** No debug information — Slang's default. */ + NONE(SlangDebugInfoLevel.SLANG_DEBUG_INFO_LEVEL_NONE), + MINIMAL(SlangDebugInfoLevel.SLANG_DEBUG_INFO_LEVEL_MINIMAL), + STANDARD(SlangDebugInfoLevel.SLANG_DEBUG_INFO_LEVEL_STANDARD), + MAXIMAL(SlangDebugInfoLevel.SLANG_DEBUG_INFO_LEVEL_MAXIMAL); + + private static final Map BY_VALUE = new HashMap<>(); + + static { + for (DebugInfoLevel level : values()) { + if (level != UNKNOWN) { + BY_VALUE.put(level.value, level); + } + } + } + + private final int value; + + DebugInfoLevel(int value) { + this.value = value; + } + + /** The raw {@code SlangDebugInfoLevel} ABI value ({@code Integer.MIN_VALUE} for UNKNOWN). */ + public int value() { + return value; + } + + /** Maps a raw ABI value; unmapped (newer) values return {@link #UNKNOWN}. */ + public static DebugInfoLevel of(int value) { + return BY_VALUE.getOrDefault(value, UNKNOWN); + } +} diff --git a/slang/src/main/java/io/github/refux/slang/Enums.java b/slang/src/main/java/io/github/refux/slang/Enums.java new file mode 100644 index 0000000..994bb93 --- /dev/null +++ b/slang/src/main/java/io/github/refux/slang/Enums.java @@ -0,0 +1,15 @@ +package io.github.refux.slang; + +/** Shared handling for the ABI-mirroring enums, whose {@code UNKNOWN} sentinel has no raw value. */ +final class Enums { + private Enums() {} + + /** {@code constant}'s raw ABI value, refusing the {@code UNKNOWN} sentinel, which carries none. */ + static int raw(Enum constant, int value) { + if (value == Integer.MIN_VALUE) { + throw new IllegalArgumentException( + constant.getDeclaringClass().getSimpleName() + "." + constant.name() + " has no raw value"); + } + return value; + } +} diff --git a/slang/src/main/java/io/github/refux/slang/GlobalSession.java b/slang/src/main/java/io/github/refux/slang/GlobalSession.java index 702a7a9..f650c71 100644 --- a/slang/src/main/java/io/github/refux/slang/GlobalSession.java +++ b/slang/src/main/java/io/github/refux/slang/GlobalSession.java @@ -1,7 +1,10 @@ package io.github.refux.slang; import io.github.refux.slang.ffi.IGlobalSession; +import io.github.refux.slang.ffi.SlangNative; +import java.nio.file.Path; import java.util.Arrays; +import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -54,6 +57,41 @@ public String buildTagString() { return ffi().getBuildTagString(); } + /** + * Looks up a capability id by name (e.g. {@code "spirv_1_5"}, {@code "SPV_KHR_ray_query"}). + * {@link SessionBuilder#capability(String)} takes the name directly; this is for checking one + * up front. Returns 0 ({@code SLANG_CAPABILITY_UNKNOWN}) for unknown names. + */ + public int findCapability(String name) { + return ffi().findCapability(name); + } + + /** + * Whether this Slang build can produce {@code target}: false when the target needs a downstream + * compiler that cannot be found (DXC for {@link CompileTarget#DXIL}, say). + */ + public boolean isTargetSupported(CompileTarget target) { + return SlangNative.succeeded(ffi().checkCompileTargetSupport(target.value())); + } + + /** Whether the downstream compiler {@code passThrough} is available to this Slang build. */ + public boolean isPassThroughSupported(PassThrough passThrough) { + return SlangNative.succeeded(ffi().checkPassThroughSupport(Enums.raw(passThrough, passThrough.value()))); + } + + /** + * Where the library Slang would load for the downstream compiler {@code passThrough} lives, + * found the same way compilation finds it — e.g. the {@code slang-glslang} in the natives + * payload for {@link PassThrough#GLSLANG}, or the exact NVRTC that would compile CUDA. + * + * @return the path, or empty when the compiler cannot be found or is not a shared library + * (executable-based compilers such as Clang, GCC or Metal) + */ + public Optional downstreamCompilerPath(PassThrough passThrough) { + return Optional.ofNullable(ffi().getDownstreamCompilerPath(Enums.raw(passThrough, passThrough.value()))) + .map(Path::of); + } + /** * The serialized-module version this Slang build writes — and therefore one it certainly reads. * diff --git a/slang/src/main/java/io/github/refux/slang/Module.java b/slang/src/main/java/io/github/refux/slang/Module.java index 8d3272b..1db5e2d 100644 --- a/slang/src/main/java/io/github/refux/slang/Module.java +++ b/slang/src/main/java/io/github/refux/slang/Module.java @@ -1,6 +1,7 @@ package io.github.refux.slang; import io.github.refux.slang.ffi.IModule; +import java.util.List; /** * A compiled Slang module. Modules are owned by their {@link Session} — this wrapper is @@ -24,7 +25,56 @@ public final class Module extends ComponentType { */ public EntryPoint entryPoint(String name) { session().checkThread(); - return new EntryPoint(session(), ((IModule) componentHandle()).findEntryPointByName(name)); + return new EntryPoint(session(), module().findEntryPointByName(name)); + } + + /** + * Finds a function by name and checks it as an entry point for {@code stage} — for functions + * without a {@code [shader("...")]} attribute (one that has it is returned as is). + * + * @throws SlangCompileException with the compiler's diagnostics when there is no such function or + * it is not a valid {@code stage} entry point + */ + public EntryPoint entryPoint(String name, Stage stage) { + session().checkThread(); + return new EntryPoint(session(), module().findAndCheckEntryPoint(name, Enums.raw(stage, stage.value()))); + } + + /** + * The entry points Slang found when checking this module, in declaration order: functions marked + * {@code [shader("...")]}, and compute functions marked only {@code [numthreads]}. Others need + * {@link #entryPoint(String, Stage)}. + */ + public List entryPoints() { + session().checkThread(); + return module().getDefinedEntryPoints().stream() + .map(entryPoint -> new EntryPoint(session(), entryPoint)) + .toList(); + } + + /** The module's name — what an {@code import} refers to it by. */ + public String name() { + session().checkThread(); + return module().getName(); + } + + /** + * The path the module was loaded from: the source file {@link Session#loadModule} found, or for + * {@link Session#loadModuleFromSource} the synthetic {@code .slang}. + */ + public String filePath() { + session().checkThread(); + return module().getFilePath(); + } + + /** + * The files this module was compiled from — its own source, everything it {@code #include}s, and + * the sources of the modules it {@code import}s — which is what a build system or hot-reload + * watcher should watch to know when to recompile it. + */ + public List dependencyFiles() { + session().checkThread(); + return List.copyOf(module().getDependencyFilePaths()); } /** @@ -35,6 +85,10 @@ public EntryPoint entryPoint(String name) { */ public byte[] serialize() { session().checkThread(); - return ((IModule) componentHandle()).serialize(); + return module().serialize(); + } + + private IModule module() { + return (IModule) componentHandle(); } } diff --git a/slang/src/main/java/io/github/refux/slang/OptimizationLevel.java b/slang/src/main/java/io/github/refux/slang/OptimizationLevel.java new file mode 100644 index 0000000..ac3447d --- /dev/null +++ b/slang/src/main/java/io/github/refux/slang/OptimizationLevel.java @@ -0,0 +1,47 @@ +package io.github.refux.slang; + +import io.github.refux.slang.ffi.gen.SlangOptimizationLevel; +import java.util.HashMap; +import java.util.Map; + +/** + * How hard Slang optimizes generated code ({@code SlangOptimizationLevel}), set with + * {@link SessionBuilder#optimization}. {@link #value()} / {@link #of(int)} are escape hatches to the + * raw ABI values; unmapped (newer) values return {@link #UNKNOWN}. + */ +public enum OptimizationLevel { + /** Sentinel for ABI values this enum does not know (carries no raw value). */ + UNKNOWN(Integer.MIN_VALUE), + NONE(SlangOptimizationLevel.SLANG_OPTIMIZATION_LEVEL_NONE), + /** Slang's default: balances code quality against compile time. */ + DEFAULT(SlangOptimizationLevel.SLANG_OPTIMIZATION_LEVEL_DEFAULT), + HIGH(SlangOptimizationLevel.SLANG_OPTIMIZATION_LEVEL_HIGH), + /** Includes optimizations that may take a very long time or be unsafe. */ + MAXIMAL(SlangOptimizationLevel.SLANG_OPTIMIZATION_LEVEL_MAXIMAL); + + private static final Map BY_VALUE = new HashMap<>(); + + static { + for (OptimizationLevel level : values()) { + if (level != UNKNOWN) { + BY_VALUE.put(level.value, level); + } + } + } + + private final int value; + + OptimizationLevel(int value) { + this.value = value; + } + + /** The raw {@code SlangOptimizationLevel} ABI value ({@code Integer.MIN_VALUE} for UNKNOWN). */ + public int value() { + return value; + } + + /** Maps a raw ABI value; unmapped (newer) values return {@link #UNKNOWN}. */ + public static OptimizationLevel of(int value) { + return BY_VALUE.getOrDefault(value, UNKNOWN); + } +} diff --git a/slang/src/main/java/io/github/refux/slang/OptionList.java b/slang/src/main/java/io/github/refux/slang/OptionList.java new file mode 100644 index 0000000..fe1bc3f --- /dev/null +++ b/slang/src/main/java/io/github/refux/slang/OptionList.java @@ -0,0 +1,45 @@ +package io.github.refux.slang; + +import io.github.refux.slang.ffi.CompilerOptionEntry; +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Compiler option entries collected by {@link SessionBuilder} and {@link TargetOptions}, kept in + * call order: Slang treats some options (capabilities, warning groups) as repeatable. + */ +final class OptionList { + private record Entry(int name, int int0, int int1, String string) {} + + private final List entries = new ArrayList<>(); + + void add(int name, int value0, int value1) { + entries.add(new Entry(name, value0, value1, null)); + } + + void add(int name, String value) { + entries.add(new Entry(name, 0, 0, Objects.requireNonNull(value, "value"))); + } + + int size() { + return entries.size(); + } + + /** Writes the entries as a native {@code CompilerOptionEntry[]} whose strings live in {@code arena}. */ + MemorySegment write(Arena arena) { + MemorySegment array = CompilerOptionEntry.allocateArray(arena, entries.size()); + for (int i = 0; i < entries.size(); i++) { + Entry entry = entries.get(i); + if (entry.string() == null) { + CompilerOptionEntry.setInt(array, i, entry.name(), entry.int0(), entry.int1()); + } else { + CompilerOptionEntry.setString( + array, i, entry.name(), arena.allocateFrom(entry.string()), MemorySegment.NULL); + } + } + return array; + } +} diff --git a/slang/src/main/java/io/github/refux/slang/PassThrough.java b/slang/src/main/java/io/github/refux/slang/PassThrough.java new file mode 100644 index 0000000..32da159 --- /dev/null +++ b/slang/src/main/java/io/github/refux/slang/PassThrough.java @@ -0,0 +1,58 @@ +package io.github.refux.slang; + +import io.github.refux.slang.ffi.gen.SlangPassThrough; +import java.util.HashMap; +import java.util.Map; + +/** + * The downstream compilers Slang can hand code to ({@code SlangPassThrough}), as queried by + * {@link GlobalSession#downstreamCompilerPath} and {@link GlobalSession#isPassThroughSupported}. + * {@link #value()} / {@link #of(int)} are escape hatches to the raw ABI values; unmapped (newer) + * values return {@link #UNKNOWN}. + */ +public enum PassThrough { + /** Sentinel for ABI values this enum does not know (carries no raw value). */ + UNKNOWN(Integer.MIN_VALUE), + NONE(SlangPassThrough.SLANG_PASS_THROUGH_NONE), + FXC(SlangPassThrough.SLANG_PASS_THROUGH_FXC), + DXC(SlangPassThrough.SLANG_PASS_THROUGH_DXC), + /** Backs GLSL-to-SPIR-V; shipped in the natives payload as {@code slang-glslang}. */ + GLSLANG(SlangPassThrough.SLANG_PASS_THROUGH_GLSLANG), + SPIRV_DIS(SlangPassThrough.SLANG_PASS_THROUGH_SPIRV_DIS), + CLANG(SlangPassThrough.SLANG_PASS_THROUGH_CLANG), + VISUAL_STUDIO(SlangPassThrough.SLANG_PASS_THROUGH_VISUAL_STUDIO), + GCC(SlangPassThrough.SLANG_PASS_THROUGH_GCC), + GENERIC_C_CPP(SlangPassThrough.SLANG_PASS_THROUGH_GENERIC_C_CPP), + NVRTC(SlangPassThrough.SLANG_PASS_THROUGH_NVRTC), + LLVM(SlangPassThrough.SLANG_PASS_THROUGH_LLVM), + SPIRV_OPT(SlangPassThrough.SLANG_PASS_THROUGH_SPIRV_OPT), + METAL(SlangPassThrough.SLANG_PASS_THROUGH_METAL), + TINT(SlangPassThrough.SLANG_PASS_THROUGH_TINT), + SPIRV_LINK(SlangPassThrough.SLANG_PASS_THROUGH_SPIRV_LINK); + + private static final Map BY_VALUE = new HashMap<>(); + + static { + for (PassThrough p : values()) { + if (p != UNKNOWN) { + BY_VALUE.put(p.value, p); + } + } + } + + private final int value; + + PassThrough(int value) { + this.value = value; + } + + /** The raw {@code SlangPassThrough} ABI value ({@code Integer.MIN_VALUE} for UNKNOWN). */ + public int value() { + return value; + } + + /** Maps a raw ABI value; unmapped (newer) values return {@link #UNKNOWN}. */ + public static PassThrough of(int value) { + return BY_VALUE.getOrDefault(value, UNKNOWN); + } +} diff --git a/slang/src/main/java/io/github/refux/slang/Session.java b/slang/src/main/java/io/github/refux/slang/Session.java index 86b5e72..3369b8d 100644 --- a/slang/src/main/java/io/github/refux/slang/Session.java +++ b/slang/src/main/java/io/github/refux/slang/Session.java @@ -43,6 +43,43 @@ public Module loadModuleFromSource(String name, String source) { return new Module(this, session.loadModuleFromSourceString(name, name + ".slang", source, onDiagnostics)); } + /** + * Loads a module by the name an {@code import} would use (e.g. {@code "lighting"}, or + * {@code "materials.pbr"} for {@code materials/pbr.slang}), finding its source through the + * session's {@link SessionBuilder#searchPath search paths} and + * {@link SessionBuilder#fileSystem file system}. A module already loaded under that name is + * returned again rather than recompiled. + * + * @throws SlangCompileException with the compiler's diagnostics when the module cannot be found + * or fails to compile; success-with-warnings text goes to the + * {@link SessionBuilder#onDiagnostics(java.util.function.Consumer)} consumer + */ + public Module loadModule(String name) { + checkThread(); + return new Module(this, session.loadModule(name, onDiagnostics)); + } + + /** + * Whether serialized IR is still current for this session: written by this Slang build with + * this session's compiler options, from source files whose contents have not changed since. A + * cache of {@link Module#serialize()} output can use it to decide between + * {@link #loadModuleFromIr} and recompiling. + * + *

Source files are read through this session, which keeps what it has already read: an edit + * made after the session read a file goes unseen, so check with a fresh session. And one case + * answers {@code true} without comparing the build or options: when the module's own source + * file cannot be found at all, Slang treats the IR as a standalone precompiled artifact. + * {@link #loadModuleFromIr} still refuses IR this build cannot read. + * + * @param modulePath where the serialized module lives (or would); the source files recorded in + * it are looked up relative to this and on the session's search paths + * @param ir the serialized module bytes + */ + public boolean isIrUpToDate(String modulePath, byte[] ir) { + checkThread(); + return session.isBinaryModuleUpToDate(modulePath, ir); + } + /** * Reads what {@code ir} declares about itself — its serialized-module version, the Slang build * that wrote it, and its name — without loading it. diff --git a/slang/src/main/java/io/github/refux/slang/SessionBuilder.java b/slang/src/main/java/io/github/refux/slang/SessionBuilder.java index 802504d..115dc31 100644 --- a/slang/src/main/java/io/github/refux/slang/SessionBuilder.java +++ b/slang/src/main/java/io/github/refux/slang/SessionBuilder.java @@ -4,6 +4,7 @@ import io.github.refux.slang.ffi.PreprocessorMacroDesc; import io.github.refux.slang.ffi.SessionDesc; import io.github.refux.slang.ffi.TargetDesc; +import io.github.refux.slang.ffi.gen.CompilerOptionName; import java.lang.foreign.Arena; import java.lang.foreign.MemorySegment; import java.nio.file.Path; @@ -16,15 +17,19 @@ /** * Configures and creates a compilation {@link Session}: code-generation targets, search paths - * for {@code import}/{@code #include}, preprocessor defines, and an optional diagnostics - * consumer for warnings. Slang copies all of it during {@code createSession}, so nothing built - * here outlives the {@link #create()} call. + * for {@code import}/{@code #include}, preprocessor defines, compiler options, and an optional + * diagnostics consumer for warnings. Slang copies all of it during {@code createSession}, so + * nothing built here outlives the {@link #create()} call. + * + *

Compiler options set here apply to every target; a target's own + * {@link TargetOptions#option(int, int) options} override them. */ public final class SessionBuilder { private final GlobalSession global; private final List targets = new ArrayList<>(); private final List searchPaths = new ArrayList<>(); private final Map defines = new LinkedHashMap<>(); + private final OptionList compilerOptions = new OptionList(); private Consumer onDiagnostics; private SlangFileSystem fileSystem; private MatrixLayout matrixLayout; @@ -92,6 +97,91 @@ public SessionBuilder fileSystem(SlangFileSystem fileSystem) { return this; } + /** How hard to optimize generated code; Slang's default is {@link OptimizationLevel#DEFAULT}. */ + public SessionBuilder optimization(OptimizationLevel level) { + return option(CompilerOptionName.Optimization, Enums.raw(level, level.value())); + } + + /** How much debug information to emit; Slang's default is {@link DebugInfoLevel#NONE}. */ + public SessionBuilder debugInfo(DebugInfoLevel level) { + return option(CompilerOptionName.DebugInformation, Enums.raw(level, level.value())); + } + + /** + * Turns warnings into errors, so code that warns fails to load with + * {@link SlangCompileException}: every warning when called with no arguments, otherwise the + * given ones, by code or name (e.g. {@code "30081"}). + */ + public SessionBuilder warningsAsErrors(String... warnings) { + return option(CompilerOptionName.WarningsAsErrors, warnings.length == 0 ? "all" : String.join(",", warnings)); + } + + /** Silences warnings, by code or name (e.g. {@code "30081"}). */ + public SessionBuilder disableWarnings(String... warnings) { + if (warnings.length == 0) { + throw new IllegalArgumentException("name at least one warning to disable"); + } + return option(CompilerOptionName.DisableWarnings, String.join(",", warnings)); + } + + /** + * The Slang language version to compile as, e.g. {@code 2026} — a {@code SlangLanguageVersion} + * value. The global session's default is 2025. + */ + public SessionBuilder languageVersion(int version) { + return option(CompilerOptionName.LanguageVersion, version); + } + + /** + * Requires a capability by name (e.g. {@code "spirv_1_5"}, {@code "SPV_KHR_ray_query"}); repeat + * for several. + * + * @throws IllegalArgumentException if this Slang build does not know the name + */ + public SessionBuilder capability(String name) { + int capability = global.findCapability(name); + if (capability == 0) { + throw new IllegalArgumentException("unknown capability: " + name); + } + return option(CompilerOptionName.Capability, capability); + } + + /** How bitfield members are packed; Slang's default is {@link BitfieldPacking#DEFAULT}. */ + public SessionBuilder bitfieldPacking(BitfieldPacking rules) { + return option(CompilerOptionName.BitfieldPackingRules, Enums.raw(rules, rules.value())); + } + + /** + * Sets a compiler option by its raw {@code slang::CompilerOptionName} value, as found in + * {@code io.github.refux.slang.ffi.gen.CompilerOptionName} — the escape hatch for options without a + * method of their own. The value must be the kind the option expects (its comment in + * {@code slang.h} says): this overload for int and enum options. + * + *

Options that {@code TargetDesc} also carries as fields — the floating-point mode, line + * directive mode and GLSL scalar layout — are reapplied from each target after the session's + * options, so set those per target with {@link TargetOptions#option(int, int)} instead. + */ + public SessionBuilder option(int name, int value) { + return option(name, value, 0); + } + + /** As {@link #option(int, int)}, for bool options. */ + public SessionBuilder option(int name, boolean value) { + return option(name, value ? 1 : 0); + } + + /** As {@link #option(int, int)}, for the few options that take two ints. */ + public SessionBuilder option(int name, int value0, int value1) { + compilerOptions.add(name, value0, value1); + return this; + } + + /** As {@link #option(int, int)}, for string options. */ + public SessionBuilder option(int name, String value) { + compilerOptions.add(name, value); + return this; + } + /** Creates the session. At least one {@link #target} is required. */ public Session create() { if (targets.isEmpty()) { @@ -116,6 +206,10 @@ public Session create() { if (options.forceGlslScalarBufferLayout != null) { TargetDesc.setForceGlslScalarBufferLayout(desc, options.forceGlslScalarBufferLayout); } + if (options.compilerOptions.size() > 0) { + TargetDesc.setCompilerOptionEntries( + desc, options.compilerOptions.write(arena), options.compilerOptions.size()); + } } MemorySegment sessionDesc = SessionDesc.allocate(arena); @@ -136,6 +230,9 @@ public Session create() { } SessionDesc.setPreprocessorMacros(sessionDesc, macros, defines.size()); } + if (compilerOptions.size() > 0) { + SessionDesc.setCompilerOptionEntries(sessionDesc, compilerOptions.write(arena), compilerOptions.size()); + } io.github.refux.slang.ffi.JavaFileSystem nativeFileSystem = null; if (fileSystem != null) { diff --git a/slang/src/main/java/io/github/refux/slang/TargetOptions.java b/slang/src/main/java/io/github/refux/slang/TargetOptions.java index ebf7f36..92cb18f 100644 --- a/slang/src/main/java/io/github/refux/slang/TargetOptions.java +++ b/slang/src/main/java/io/github/refux/slang/TargetOptions.java @@ -3,10 +3,12 @@ /** * Per-target configuration collected by {@link SessionBuilder#target}. Unset options keep the * C++ header defaults ({@code TargetDesc}'s initializers, including - * {@code flags = kDefaultTargetFlags}). + * {@code flags = kDefaultTargetFlags}), and compiler options set here override the session's for + * this target only. */ public final class TargetOptions { private final CompileTarget target; + final OptionList compilerOptions = new OptionList(); String profileName; Integer flags; Boolean forceGlslScalarBufferLayout; @@ -36,4 +38,29 @@ public TargetOptions forceGlslScalarBufferLayout(boolean force) { this.forceGlslScalarBufferLayout = force; return this; } + + /** + * Sets a compiler option for this target only, by its raw {@code slang::CompilerOptionName} + * value — see {@link SessionBuilder#option(int, int)}. This overload is for int and enum options. + */ + public TargetOptions option(int name, int value) { + return option(name, value, 0); + } + + /** As {@link #option(int, int)}, for bool options. */ + public TargetOptions option(int name, boolean value) { + return option(name, value ? 1 : 0); + } + + /** As {@link #option(int, int)}, for the few options that take two ints. */ + public TargetOptions option(int name, int value0, int value1) { + compilerOptions.add(name, value0, value1); + return this; + } + + /** As {@link #option(int, int)}, for string options. */ + public TargetOptions option(int name, String value) { + compilerOptions.add(name, value); + return this; + } } diff --git a/slang/src/main/java/io/github/refux/slang/ffi/CompilerOptionEntry.java b/slang/src/main/java/io/github/refux/slang/ffi/CompilerOptionEntry.java new file mode 100644 index 0000000..1e03417 --- /dev/null +++ b/slang/src/main/java/io/github/refux/slang/ffi/CompilerOptionEntry.java @@ -0,0 +1,43 @@ +package io.github.refux.slang.ffi; + +import io.github.refux.slang.ffi.gen.CompilerOptionValue; +import io.github.refux.slang.ffi.gen.CompilerOptionValueKind; +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; + +/** + * Convenience over the generated {@code ffi.gen.CompilerOptionEntry} layout (offsets + * clang-verified, size 40): a {@code CompilerOptionName} plus a {@code CompilerOptionValue} holding + * either two ints or two strings. {@link SessionDesc} and {@link TargetDesc} each point at an array + * of these, which Slang copies during {@code createSession} like the rest of the descriptor. + */ +public final class CompilerOptionEntry { + private CompilerOptionEntry() {} + + /** Allocates a zeroed {@code CompilerOptionEntry[count]}. */ + public static MemorySegment allocateArray(Arena arena, int count) { + return io.github.refux.slang.ffi.gen.CompilerOptionEntry.allocateArray(arena, count); + } + + /** Sets element {@code index} to an int-valued option; bool options take 0 or 1. */ + public static void setInt(MemorySegment array, int index, int name, int value0, int value1) { + MemorySegment value = start(array, index, name, CompilerOptionValueKind.Int); + CompilerOptionValue.setIntValue0(value, value0); + CompilerOptionValue.setIntValue1(value, value1); + } + + /** Sets element {@code index} to a string-valued option; {@code value1} may be {@code NULL}. */ + public static void setString(MemorySegment array, int index, int name, MemorySegment value0, MemorySegment value1) { + MemorySegment value = start(array, index, name, CompilerOptionValueKind.String); + CompilerOptionValue.setStringValue0(value, value0); + CompilerOptionValue.setStringValue1(value, value1); + } + + private static MemorySegment start(MemorySegment array, int index, int name, int kind) { + MemorySegment entry = io.github.refux.slang.ffi.gen.CompilerOptionEntry.element(array, index); + io.github.refux.slang.ffi.gen.CompilerOptionEntry.setName(entry, name); + MemorySegment value = io.github.refux.slang.ffi.gen.CompilerOptionEntry.value(entry); + CompilerOptionValue.setKind(value, kind); + return value; + } +} diff --git a/slang/src/main/java/io/github/refux/slang/ffi/IComponentType.java b/slang/src/main/java/io/github/refux/slang/ffi/IComponentType.java index 66a4d32..a622083 100644 --- a/slang/src/main/java/io/github/refux/slang/ffi/IComponentType.java +++ b/slang/src/main/java/io/github/refux/slang/ffi/IComponentType.java @@ -55,6 +55,25 @@ public MemorySegment getLayout(long targetIndex) { } } + /** + * Compiles (or fetches) target code for a whole linked component — every entry point it + * contains, in one artifact (e.g. one SPIR-V module, or one WGSL or Metal source file). + * + * @param targetIndex index into the session's {@code SessionDesc.targets} array + */ + public byte[] getTargetCode(long targetIndex) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment outCode = arena.allocate(ADDRESS); + MemorySegment outDiag = arena.allocate(ADDRESS); + int result = io.github.refux.slang.ffi.gen.IComponentType.getTargetCode( + segment(), targetIndex, outCode, outDiag); + Diagnostics.check("IComponentType::getTargetCode", result, outDiag); + try (ISlangBlob code = new ISlangBlob(outCode.get(ADDRESS, 0))) { + return code.toByteArray(); + } + } + } + /** * Compiles (or fetches) target code for one entry point of a fully linked component. * diff --git a/slang/src/main/java/io/github/refux/slang/ffi/IGlobalSession.java b/slang/src/main/java/io/github/refux/slang/ffi/IGlobalSession.java index 7078219..90d18c8 100644 --- a/slang/src/main/java/io/github/refux/slang/ffi/IGlobalSession.java +++ b/slang/src/main/java/io/github/refux/slang/ffi/IGlobalSession.java @@ -114,4 +114,48 @@ public int findProfile(String name) { public String getBuildTagString() { return SlangNative.readUtf8(io.github.refux.slang.ffi.gen.IGlobalSession.getBuildTagString(segment())); } + + /** {@code SLANG_OK} when this build can produce {@code compileTarget} (a {@code SlangCompileTarget}). */ + public int checkCompileTargetSupport(int compileTarget) { + return io.github.refux.slang.ffi.gen.IGlobalSession.checkCompileTargetSupport(segment(), compileTarget); + } + + /** {@code SLANG_OK} when the downstream compiler {@code passThrough} (a {@code SlangPassThrough}) is available. */ + public int checkPassThroughSupport(int passThrough) { + return io.github.refux.slang.ffi.gen.IGlobalSession.checkPassThroughSupport(segment(), passThrough); + } + + /** + * Looks up a capability id by name (e.g. {@code "spirv_1_5"}), as the {@code Capability} compiler + * option takes it. Returns {@code SLANG_CAPABILITY_UNKNOWN} (0) for unknown names. + */ + public int findCapability(String name) { + try (Arena arena = Arena.ofConfined()) { + return io.github.refux.slang.ffi.gen.IGlobalSession.findCapability(segment(), arena.allocateFrom(name)); + } + } + + /** + * The on-disk path of the library Slang would load for the downstream compiler + * {@code passThrough}, or null when there is none: {@code SLANG_E_NOT_FOUND} (not located) and + * {@code SLANG_E_NOT_AVAILABLE} (loaded, but not from a shared library — executable-based + * compilers such as Clang, GCC or Metal) both mean no path. Added in Slang 2026.17, in the vtable + * slot that held {@code getDownstreamCompilerVersion} before it. + */ + public String getDownstreamCompilerPath(int passThrough) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment outPath = arena.allocate(ADDRESS); + int result = io.github.refux.slang.ffi.gen.IGlobalSession.getDownstreamCompilerPath( + segment(), passThrough, outPath); + if (result == SlangNative.SLANG_E_NOT_FOUND || result == SlangNative.SLANG_E_NOT_AVAILABLE) { + return null; + } + if (!SlangNative.succeeded(result)) { + throw new SlangException("IGlobalSession::getDownstreamCompilerPath failed", result); + } + try (ISlangBlob path = new ISlangBlob(outPath.get(ADDRESS, 0))) { + return path.toUtf8String(); + } + } + } } diff --git a/slang/src/main/java/io/github/refux/slang/ffi/IModule.java b/slang/src/main/java/io/github/refux/slang/ffi/IModule.java index 127e823..ad58320 100644 --- a/slang/src/main/java/io/github/refux/slang/ffi/IModule.java +++ b/slang/src/main/java/io/github/refux/slang/ffi/IModule.java @@ -5,6 +5,8 @@ import io.github.refux.slang.SlangException; import java.lang.foreign.Arena; import java.lang.foreign.MemorySegment; +import java.util.ArrayList; +import java.util.List; /** * Wrapper for {@code slang::IModule}. Raw vtable dispatch lives in the generated @@ -35,6 +37,70 @@ public IEntryPoint findEntryPointByName(String name) { } } + /** + * Finds a function by name and checks it as an entry point for {@code stage} (a + * {@code SlangStage}) — for functions without a {@code [shader("...")]} attribute; one that has + * it is returned as is. Caller-owned, like {@link #findEntryPointByName}. + * + * @throws io.github.refux.slang.SlangCompileException with the compiler's diagnostics when there + * is no such function or it is not a valid entry point for the stage + */ + public IEntryPoint findAndCheckEntryPoint(String name, int stage) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(ADDRESS); + MemorySegment outDiag = arena.allocate(ADDRESS); + int result = io.github.refux.slang.ffi.gen.IModule.findAndCheckEntryPoint( + segment(), arena.allocateFrom(name), stage, out, outDiag); + Diagnostics.check("IModule::findAndCheckEntryPoint", result, outDiag); + return new IEntryPoint(out.get(ADDRESS, 0)); + } + } + + /** + * The entry points Slang found when checking this module — functions marked + * {@code [shader("...")]}, and compute functions marked only {@code [numthreads]} — as + * caller-owned wrappers. + */ + public List getDefinedEntryPoints() { + int count = io.github.refux.slang.ffi.gen.IModule.getDefinedEntryPointCount(segment()); + List entryPoints = new ArrayList<>(count); + try (Arena arena = Arena.ofConfined()) { + MemorySegment out = arena.allocate(ADDRESS); + for (int i = 0; i < count; i++) { + int result = io.github.refux.slang.ffi.gen.IModule.getDefinedEntryPoint(segment(), i, out); + if (!SlangNative.succeeded(result)) { + entryPoints.forEach(IUnknown::close); + throw new SlangException("IModule::getDefinedEntryPoint failed", result); + } + entryPoints.add(new IEntryPoint(out.get(ADDRESS, 0))); + } + } + return entryPoints; + } + + /** The module's name — what an {@code import} refers to it by. */ + public String getName() { + return SlangNative.readUtf8(io.github.refux.slang.ffi.gen.IModule.getName(segment())); + } + + /** The path the module was loaded from: a found source file, or the synthetic path it was given. */ + public String getFilePath() { + return SlangNative.readUtf8(io.github.refux.slang.ffi.gen.IModule.getFilePath(segment())); + } + + /** + * Every file the module's compilation depended on: its own source, anything it + * {@code #include}s, and the source files of the modules it {@code import}s. + */ + public List getDependencyFilePaths() { + int count = io.github.refux.slang.ffi.gen.IModule.getDependencyFileCount(segment()); + List paths = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + paths.add(SlangNative.readUtf8(io.github.refux.slang.ffi.gen.IModule.getDependencyFilePath(segment(), i))); + } + return paths; + } + /** * Serializes this module's checked IR to bytes that {@code ISession.loadModuleFromIrBlob} can * reload without re-parsing. The bytes are only readable by a compatible Slang build — key any diff --git a/slang/src/main/java/io/github/refux/slang/ffi/ISession.java b/slang/src/main/java/io/github/refux/slang/ffi/ISession.java index ae41bb3..ea0ff89 100644 --- a/slang/src/main/java/io/github/refux/slang/ffi/ISession.java +++ b/slang/src/main/java/io/github/refux/slang/ffi/ISession.java @@ -62,6 +62,53 @@ public IModule loadModuleFromSourceString( } } + /** + * Loads a module by the name an {@code import} would use, finding its source through the + * session's search paths and file system. A module already loaded under that name is returned + * again rather than recompiled. Borrowed, like {@link #loadModuleFromSourceString}'s result. + * + * @throws SlangCompileException with the compiler's diagnostics when the module cannot be found + * or fails to compile + */ + public IModule loadModule(String moduleName, Consumer onDiagnostics) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment outDiag = arena.allocate(ADDRESS); + MemorySegment module = io.github.refux.slang.ffi.gen.ISession.loadModule( + segment(), arena.allocateFrom(moduleName), outDiag); + String diagnostics = Diagnostics.consume(outDiag); + if (module.address() == 0) { + throw new SlangCompileException( + diagnostics != null ? diagnostics : "module load failed: " + moduleName, + SlangNative.SLANG_FAIL); + } + if (diagnostics != null && onDiagnostics != null) { + onDiagnostics.accept(diagnostics); + } + return new IModule(module); + } + } + + /** + * Wraps {@code isBinaryModuleUpToDate}: whether serialized IR still matches this Slang build, this + * session's compiler options and the current contents of the source files it was compiled from, + * which are looked up relative to {@code modulePath} and on the session's search paths. + */ + public boolean isBinaryModuleUpToDate(String modulePath, byte[] ir) { + if (ir.length == 0) { + return false; // not a module at all; slang_createBlob has nothing to wrap + } + try (Arena arena = Arena.ofConfined()) { + MemorySegment irData = arena.allocate(ir.length); + MemorySegment.copy(ir, 0, irData, ValueLayout.JAVA_BYTE, 0, ir.length); + // Slang only reads the blob during the call, so the creation reference is ours to drop. + try (ISlangBlob blob = + new ISlangBlob(io.github.refux.slang.ffi.gen.SlangAPI.slang_createBlob(irData, ir.length))) { + return io.github.refux.slang.ffi.gen.ISession.isBinaryModuleUpToDate( + segment(), arena.allocateFrom(modulePath), blob.segment()); + } + } + } + /** * Combines modules and entry points into one unit of shader code; the result (caller-owned) * is what gets {@link IComponentType#link() linked} and compiled. The order of components diff --git a/slang/src/main/java/io/github/refux/slang/ffi/SessionDesc.java b/slang/src/main/java/io/github/refux/slang/ffi/SessionDesc.java index 9de47a2..edfa586 100644 --- a/slang/src/main/java/io/github/refux/slang/ffi/SessionDesc.java +++ b/slang/src/main/java/io/github/refux/slang/ffi/SessionDesc.java @@ -53,6 +53,15 @@ public static void setDefaultMatrixLayoutMode(MemorySegment desc, int mode) { io.github.refux.slang.ffi.gen.SessionDesc.setDefaultMatrixLayoutMode(desc, mode); } + /** + * Points {@code compilerOptionEntries} at a {@link CompilerOptionEntry} array of {@code count}. + * Every target inherits these, and its own entries override them. + */ + public static void setCompilerOptionEntries(MemorySegment desc, MemorySegment entryArray, int count) { + io.github.refux.slang.ffi.gen.SessionDesc.setCompilerOptionEntries(desc, entryArray); + io.github.refux.slang.ffi.gen.SessionDesc.setCompilerOptionEntryCount(desc, count); + } + /** * Points {@code fileSystem} at an {@code ISlangFileSystem} COM pointer (e.g. a * {@link JavaFileSystem}'s {@code segment()}); the session add-refs it during creation. diff --git a/slang/src/main/java/io/github/refux/slang/ffi/SlangNative.java b/slang/src/main/java/io/github/refux/slang/ffi/SlangNative.java index b717505..bc15734 100644 --- a/slang/src/main/java/io/github/refux/slang/ffi/SlangNative.java +++ b/slang/src/main/java/io/github/refux/slang/ffi/SlangNative.java @@ -29,6 +29,13 @@ public static boolean succeeded(int slangResult) { /** {@code SLANG_FAIL} — the generic failure result (COM {@code E_FAIL}). */ public static final int SLANG_FAIL = 0x80004005; + /** + * {@code SLANG_E_NOT_FOUND} — {@code SLANG_MAKE_CORE_ERROR(5)} in slang.h, i.e. facility + * {@code 0x200}, code 5: what {@code getDownstreamCompilerPath} returns when the compiler cannot + * be located or loaded at all. + */ + public static final int SLANG_E_NOT_FOUND = 0x82000005; + /** * {@code SLANG_E_NOT_AVAILABLE} — {@code SLANG_MAKE_CORE_ERROR(7)} in slang.h, i.e. facility * {@code 0x200}, code 7. Since Slang 2026.18.3, {@code slang_loadModuleInfoFromIRBlob} returns it diff --git a/slang/src/main/java/io/github/refux/slang/ffi/TargetDesc.java b/slang/src/main/java/io/github/refux/slang/ffi/TargetDesc.java index c56236c..91a8e2f 100644 --- a/slang/src/main/java/io/github/refux/slang/ffi/TargetDesc.java +++ b/slang/src/main/java/io/github/refux/slang/ffi/TargetDesc.java @@ -55,6 +55,15 @@ public static void setFlags(MemorySegment desc, int flags) { io.github.refux.slang.ffi.gen.TargetDesc.setFlags(desc, flags); } + /** + * Points {@code compilerOptionEntries} at a {@link CompilerOptionEntry} array of {@code count}; + * they override the session's entries for this target. + */ + public static void setCompilerOptionEntries(MemorySegment desc, MemorySegment entryArray, int count) { + io.github.refux.slang.ffi.gen.TargetDesc.setCompilerOptionEntries(desc, entryArray); + io.github.refux.slang.ffi.gen.TargetDesc.setCompilerOptionEntryCount(desc, count); + } + public static void setForceGlslScalarBufferLayout(MemorySegment desc, boolean force) { io.github.refux.slang.ffi.gen.TargetDesc.setForceGLSLScalarBufferLayout(desc, force); } diff --git a/slang/src/test/java/io/github/refux/slang/CompilerOptionsTest.java b/slang/src/test/java/io/github/refux/slang/CompilerOptionsTest.java new file mode 100644 index 0000000..cf93147 --- /dev/null +++ b/slang/src/test/java/io/github/refux/slang/CompilerOptionsTest.java @@ -0,0 +1,177 @@ +package io.github.refux.slang; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.refux.slang.ffi.gen.CompilerOptionName; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.function.UnaryOperator; +import org.junit.jupiter.api.Test; + +/** Compiler options reach Slang — each typed setter checked by an effect it has on the output. */ +class CompilerOptionsTest { + + private static final String COMPUTE = """ + RWStructuredBuffer output; + float scaled(float x) { float y = x * 2.0; float z = y + 0.0; return z * 1.0; } + [shader("compute")] [numthreads(1,1,1)] + void main(uint3 tid : SV_DispatchThreadID) + { + float sum = 0; + for (int i = 0; i < 4; i++) sum += scaled(float(i)); + output[0] = sum; + } + """; + + /** Warns with E30081 (implicit float-to-int conversion) and nothing else. */ + private static final String WARNS = """ + [shader("compute")] [numthreads(1,1,1)] + void main(uint3 tid : SV_DispatchThreadID) { int truncated = 1.5; } + """; + + private static byte[] compile( + GlobalSession global, CompileTarget target, String source, UnaryOperator configure) { + try (Session session = + configure.apply(global.newSession().target(target)).create()) { + Module module = session.loadModuleFromSource("options_probe", source); + try (ComponentType linked = + session.composite(module, module.entryPoint("main")).link()) { + return linked.entryPointCode(0, 0); + } + } + } + + private static boolean hasDebugInfo(byte[] spirv) { + return new String(spirv, StandardCharsets.ISO_8859_1).contains("NonSemantic.Shader.DebugInfo"); + } + + @Test + void debugInfoEmitsSourceLevelDebugInformation() { + try (GlobalSession global = Slang.createGlobalSession()) { + assertFalse( + hasDebugInfo(compile(global, CompileTarget.SPIRV, COMPUTE, s -> s.debugInfo(DebugInfoLevel.NONE)))); + assertTrue(hasDebugInfo( + compile(global, CompileTarget.SPIRV, COMPUTE, s -> s.debugInfo(DebugInfoLevel.STANDARD)))); + } + } + + @Test + void optimizationShrinksTheCode() { + try (GlobalSession global = Slang.createGlobalSession()) { + int unoptimized = + compile(global, CompileTarget.SPIRV, COMPUTE, s -> s.optimization(OptimizationLevel.NONE)).length; + int maximal = compile(global, CompileTarget.SPIRV, COMPUTE, s -> s.optimization(OptimizationLevel.MAXIMAL)) + .length; + assertTrue(maximal < unoptimized, "MAXIMAL (" + maximal + " bytes) vs NONE (" + unoptimized + " bytes)"); + } + } + + @Test + void warningsAsErrorsFailsTheLoad() { + try (GlobalSession global = Slang.createGlobalSession()) { + List warnings = new ArrayList<>(); + compile(global, CompileTarget.SPIRV, WARNS, s -> s.onDiagnostics(warnings::add)); + assertTrue(String.join("", warnings).contains("30081"), "the probe source warns: " + warnings); + + assertThrows( + SlangCompileException.class, + () -> compile(global, CompileTarget.SPIRV, WARNS, SessionBuilder::warningsAsErrors)); + assertThrows( + SlangCompileException.class, + () -> compile(global, CompileTarget.SPIRV, WARNS, s -> s.warningsAsErrors("30081"))); + } + } + + @Test + void disableWarningsSilencesThem() { + try (GlobalSession global = Slang.createGlobalSession()) { + List warnings = new ArrayList<>(); + compile( + global, + CompileTarget.SPIRV, + WARNS, + s -> s.onDiagnostics(warnings::add).disableWarnings("30081")); + assertTrue(warnings.isEmpty(), "disabled warning still reported: " + warnings); + } + } + + @Test + void capabilitiesResolveByName() { + try (GlobalSession global = Slang.createGlobalSession()) { + assertTrue(compile(global, CompileTarget.SPIRV, COMPUTE, s -> s.capability("spirv_1_5")).length > 0); + assertThrows( + IllegalArgumentException.class, () -> global.newSession().capability("not_a_capability")); + } + } + + /** MSVC packing starts new storage when the underlying type size changes; Slang's default shares it. */ + @Test + void bitfieldPackingChangesTheStorageLayout() { + String source = """ + struct Packed { uint8_t a : 4; uint16_t b : 4; }; + RWStructuredBuffer output; + [shader("compute")] [numthreads(1,1,1)] + void main(uint3 tid : SV_DispatchThreadID) { Packed p; p.a = 1; p.b = 2; output[0] = p; } + """; + try (GlobalSession global = Slang.createGlobalSession()) { + assertFalse(declaresByteBacking( + compile(global, CompileTarget.HLSL, source, s -> s.bitfieldPacking(BitfieldPacking.DEFAULT)))); + assertTrue(declaresByteBacking( + compile(global, CompileTarget.HLSL, source, s -> s.bitfieldPacking(BitfieldPacking.MSVC)))); + } + } + + private static boolean declaresByteBacking(byte[] hlsl) { + return new String(hlsl, StandardCharsets.UTF_8) + .lines() + .anyMatch(line -> line.contains("uint8_t") && line.contains("bit_field_backing")); + } + + @Test + void languageVersionIsAccepted() { + try (GlobalSession global = Slang.createGlobalSession()) { + assertTrue(compile(global, CompileTarget.SPIRV, COMPUTE, s -> s.languageVersion(2026)).length > 0); + } + } + + /** The raw escape hatch, at session level and per target (where a target's entries land on its own desc). */ + @Test + void rawOptionsReachTheSessionAndEachTarget() { + try (GlobalSession global = Slang.createGlobalSession()) { + assertThrows( + SlangCompileException.class, + () -> compile( + global, + CompileTarget.SPIRV, + WARNS, + s -> s.option(CompilerOptionName.WarningsAsErrors, "all"))); + + try (Session session = global.newSession() + .target(CompileTarget.SPIRV) + .target( + CompileTarget.SPIRV, + t -> t.option(CompilerOptionName.DebugInformation, DebugInfoLevel.STANDARD.value())) + .create()) { + Module module = session.loadModuleFromSource("per_target", COMPUTE); + try (ComponentType linked = + session.composite(module, module.entryPoint("main")).link()) { + assertFalse(hasDebugInfo(linked.entryPointCode(0, 0)), "target 0 has no debug option"); + assertTrue(hasDebugInfo(linked.entryPointCode(0, 1)), "target 1 asked for debug info"); + } + } + } + } + + @Test + void unknownSentinelsAreRefused() { + try (GlobalSession global = Slang.createGlobalSession()) { + SessionBuilder builder = global.newSession(); + assertThrows(IllegalArgumentException.class, () -> builder.optimization(OptimizationLevel.UNKNOWN)); + assertThrows(IllegalArgumentException.class, () -> builder.debugInfo(DebugInfoLevel.UNKNOWN)); + assertThrows(IllegalArgumentException.class, () -> builder.bitfieldPacking(BitfieldPacking.UNKNOWN)); + } + } +} diff --git a/slang/src/test/java/io/github/refux/slang/GlobalQueriesTest.java b/slang/src/test/java/io/github/refux/slang/GlobalQueriesTest.java new file mode 100644 index 0000000..6d4d111 --- /dev/null +++ b/slang/src/test/java/io/github/refux/slang/GlobalQueriesTest.java @@ -0,0 +1,57 @@ +package io.github.refux.slang; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +/** What a global session can say about the Slang build and the downstream compilers it finds. */ +class GlobalQueriesTest { + + @Test + void findCapabilityKnowsCapabilityNames() { + try (GlobalSession global = Slang.createGlobalSession()) { + assertTrue(global.findCapability("spirv_1_5") > 0); + assertEquals(0, global.findCapability("not_a_capability")); + } + } + + /** SPIR-V is emitted directly, so needs no downstream compiler on any platform. */ + @Test + void spirvIsAlwaysSupported() { + try (GlobalSession global = Slang.createGlobalSession()) { + assertTrue(global.isTargetSupported(CompileTarget.SPIRV)); + } + } + + /** glslang ships in the natives payload, which the binding points Slang at. */ + @Test + void glslangIsTheLibraryInThePayload() { + try (GlobalSession global = Slang.createGlobalSession()) { + assertTrue(global.isPassThroughSupported(PassThrough.GLSLANG)); + + Path glslang = global.downstreamCompilerPath(PassThrough.GLSLANG).orElseThrow(); + assertTrue(Files.isRegularFile(glslang), glslang + " exists"); + assertTrue(glslang.getFileName().toString().contains("slang-glslang"), glslang.toString()); + } + } + + /** Clang runs as an executable when found at all, so it never has a library path. */ + @Test + void executableCompilersHaveNoLibraryPath() { + try (GlobalSession global = Slang.createGlobalSession()) { + assertTrue(global.downstreamCompilerPath(PassThrough.CLANG).isEmpty()); + } + } + + @Test + void unknownPassThroughIsRefused() { + try (GlobalSession global = Slang.createGlobalSession()) { + assertThrows(IllegalArgumentException.class, () -> global.downstreamCompilerPath(PassThrough.UNKNOWN)); + assertThrows(IllegalArgumentException.class, () -> global.isPassThroughSupported(PassThrough.UNKNOWN)); + } + } +} diff --git a/slang/src/test/java/io/github/refux/slang/ModuleLoadingTest.java b/slang/src/test/java/io/github/refux/slang/ModuleLoadingTest.java new file mode 100644 index 0000000..57004c3 --- /dev/null +++ b/slang/src/test/java/io/github/refux/slang/ModuleLoadingTest.java @@ -0,0 +1,190 @@ +package io.github.refux.slang; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Loading modules by name, and what a module reports about itself: name, files, entry points, code. */ +class ModuleLoadingTest { + + private static final String LIGHTING = """ + module lighting; + import helper; + #include "common.slangh" + RWStructuredBuffer output; + + [shader("compute")] [numthreads(1,1,1)] + void first(uint3 tid : SV_DispatchThreadID) { output[0] = twice(helperValue()); } + + [shader("compute")] [numthreads(1,1,1)] + void second(uint3 tid : SV_DispatchThreadID) { output[1] = 1.0; } + + float4 vertexMain(float3 position : POSITION) : SV_Position { return float4(position, 1.0); } + """; + + @TempDir + Path dir; + + private void writeSources() throws IOException { + Files.writeString(dir.resolve("lighting.slang"), LIGHTING); + Files.writeString(dir.resolve("common.slangh"), "float twice(float x) { return x * 2.0; }\n"); + Files.writeString(dir.resolve("helper.slang"), "module helper;\npublic float helperValue() { return 3.0; }\n"); + } + + private Session searchPathSession(GlobalSession global) { + return global.newSession().target(CompileTarget.SPIRV).searchPath(dir).create(); + } + + private static List fileNames(List paths) { + return paths.stream() + .map(path -> Path.of(path).getFileName().toString()) + .toList(); + } + + @Test + void loadModuleFindsItsSourceOnTheSearchPath() throws IOException { + writeSources(); + try (GlobalSession global = Slang.createGlobalSession(); + Session session = searchPathSession(global)) { + Module module = session.loadModule("lighting"); + + assertEquals("lighting", module.name()); + assertEquals( + "lighting.slang", Path.of(module.filePath()).getFileName().toString()); + assertEquals( + List.of("lighting.slang", "common.slangh", "helper.slang"), + fileNames(module.dependencyFiles()), + "its source, its #include, and the module it imports"); + } + } + + @Test + void loadModuleReturnsTheModuleAlreadyLoaded() throws IOException { + writeSources(); + try (GlobalSession global = Slang.createGlobalSession(); + Session session = searchPathSession(global)) { + Module first = session.loadModule("lighting"); + Module again = session.loadModule("lighting"); + + assertEquals( + first.componentHandle().segment().address(), + again.componentHandle().segment().address()); + } + } + + @Test + void loadModuleResolvesThroughAJavaFileSystem() { + try (GlobalSession global = Slang.createGlobalSession(); + Session session = global.newSession() + .target(CompileTarget.SPIRV) + .fileSystem(SlangFileSystem.ofMap(Map.of( + "lighting.slang", LIGHTING, + "common.slangh", "float twice(float x) { return x * 2.0; }\n", + "helper.slang", "module helper;\npublic float helperValue() { return 3.0; }\n"))) + .create()) { + assertEquals("lighting", session.loadModule("lighting").name()); + } + } + + @Test + void missingModuleThrows() { + try (GlobalSession global = Slang.createGlobalSession(); + Session session = searchPathSession(global)) { + assertThrows(SlangCompileException.class, () -> session.loadModule("no_such_module")); + } + } + + @Test + void entryPointsListsWhatSlangFoundAndStagedLookupFindsTheRest() throws IOException { + writeSources(); + try (GlobalSession global = Slang.createGlobalSession(); + Session session = searchPathSession(global)) { + Module module = session.loadModule("lighting"); + List entryPoints = module.entryPoints(); + assertEquals(2, entryPoints.size(), "the two [shader] functions, not the undecorated vertexMain"); + + List parts = new ArrayList<>(List.of(module)); + parts.addAll(entryPoints); + try (ComponentType linked = + session.composite(parts.toArray(ComponentType[]::new)).link()) { + assertEquals( + List.of("first", "second"), + linked.layout(0).entryPoints().stream() + .map(EntryPointReflection::name) + .toList()); + } + + try (ComponentType linked = session.composite(module, module.entryPoint("vertexMain", Stage.VERTEX)) + .link()) { + EntryPointReflection vertex = linked.layout(0).entryPoints().getFirst(); + assertEquals("vertexMain", vertex.name()); + assertEquals(Stage.VERTEX, vertex.stage()); + } + assertThrows(SlangCompileException.class, () -> module.entryPoint("noSuchFunction", Stage.COMPUTE)); + } + } + + @Test + void targetCodeCoversEveryEntryPoint() throws IOException { + writeSources(); + try (GlobalSession global = Slang.createGlobalSession(); + Session session = searchPathSession(global)) { + Module module = session.loadModule("lighting"); + try (ComponentType linked = session.composite( + module, module.entryPoint("first"), module.entryPoint("second")) + .link()) { + byte[] spirv = linked.targetCode(0); + + assertEquals( + 0x0723_0203, + ByteBuffer.wrap(spirv).order(ByteOrder.LITTLE_ENDIAN).getInt(0), + "SPIR-V magic"); + String text = new String(spirv, StandardCharsets.ISO_8859_1); + assertTrue(text.contains("first") && text.contains("second"), "one module holding both entry points"); + } + } + } + + /** Fresh sessions each time: a session keeps the source files it has read, so it would not see the edit. */ + @Test + void isIrUpToDateTracksSourcesAndOptions() throws IOException { + writeSources(); + Path irPath = dir.resolve("lighting.slang-module"); + try (GlobalSession global = Slang.createGlobalSession()) { + byte[] ir; + try (Session session = searchPathSession(global)) { + ir = session.loadModule("lighting").serialize(); + } + try (Session session = searchPathSession(global)) { + assertTrue(session.isIrUpToDate(irPath.toString(), ir), "nothing changed"); + } + try (Session session = global.newSession() + .target(CompileTarget.SPIRV) + .searchPath(dir) + .optimization(OptimizationLevel.MAXIMAL) + .create()) { + assertFalse(session.isIrUpToDate(irPath.toString(), ir), "compiler options changed"); + } + Files.writeString(dir.resolve("common.slangh"), "float twice(float x) { return x * 3.0; }\n"); + try (Session session = searchPathSession(global)) { + assertFalse(session.isIrUpToDate(irPath.toString(), ir), "an #included file changed"); + } + try (Session session = searchPathSession(global)) { + assertFalse(session.isIrUpToDate(irPath.toString(), new byte[0]), "no IR at all"); + } + } + } +} From 0abe2c69941f629bafa6709084c51b2bdaeb20d4 Mon Sep 17 00:00:00 2001 From: James Roome Date: Fri, 2 Oct 2026 15:04:18 +0530 Subject: [PATCH 2/2] session: link SessionBuilder's fileSystem method, not its private field Qodana (Declaration has problems in Javadoc references) flagged Session.loadModule's javadoc: {@link SessionBuilder#fileSystem} without a signature resolves to the builder's private fileSystem field, which is inaccessible from Session. Both links now name their method signatures, so neither depends on how a bare member name resolves. Co-Authored-By: Claude Opus 5.5 --- slang/src/main/java/io/github/refux/slang/Session.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/slang/src/main/java/io/github/refux/slang/Session.java b/slang/src/main/java/io/github/refux/slang/Session.java index 3369b8d..6d1628b 100644 --- a/slang/src/main/java/io/github/refux/slang/Session.java +++ b/slang/src/main/java/io/github/refux/slang/Session.java @@ -46,8 +46,8 @@ public Module loadModuleFromSource(String name, String source) { /** * Loads a module by the name an {@code import} would use (e.g. {@code "lighting"}, or * {@code "materials.pbr"} for {@code materials/pbr.slang}), finding its source through the - * session's {@link SessionBuilder#searchPath search paths} and - * {@link SessionBuilder#fileSystem file system}. A module already loaded under that name is + * session's {@link SessionBuilder#searchPath(java.nio.file.Path...) search paths} and + * {@link SessionBuilder#fileSystem(SlangFileSystem) file system}. A module already loaded under that name is * returned again rather than recompiled. * * @throws SlangCompileException with the compiler's diagnostics when the module cannot be found