Standalone ShaderLab lexer, parser and syntax tree for .shader files.
Tesearis.ShaderLabParser turns ShaderLab source into an immutable, strongly-typed AST so you can inspect, tool, or lint .shader files.
- Zero dependencies: no runtime dependencies at all.
- Full ShaderLab grammar: Properties, nesting, passes, tags, command blocks, program blocks...
- Forward-compatible: commands the parser doesn't specifically recognize still parse as generic so newer syntax won't break parsing.
- Program directive scanning: preprocessor directives are recognized without parsing the shader code itself.
- Conditional resolution:
#if/#ifdef/#ifndef/#elif/#elseresolve against a caller-supplied macro environment, kept up to date automatically as#define/#undefare encountered across program/include blocks. - Best-effort recovery: never throws on malformed input; genuine syntax errors become Error diagnostics while parsing continues.
- Targeted:
netstandard2.0(for use in the Unity Editor) andnet8.0.
- It never reads files from disk.
- It never resolves
#include,Fallback,UsePass, orDependencytargets. - It doesn't tokenize or parse the CG/HLSL/GLSL code inside program blocks. Content is captured verbatim.
using Tesearis.ShaderLabParser.Parsing;
var result = ShaderLab.Parse(shaderSource, fileName: "Toon.shader");
// Parsing never throws, even on malformed input — check diagnostics instead.
if (result.HasErrors)
{
foreach (var diagnostic in result.Diagnostics)
Console.WriteLine(diagnostic); // "Toon.shader(7,13): error SL0104: ..."
}
ShaderNode shader = result.Shader; // null only if the source has no `Shader { ... }` at allusing Tesearis.ShaderLabParser.Syntax;
shader.Name; // "Custom/Toon"
foreach (var property in shader.Properties.Items)
Console.WriteLine($"{property.Name} : {property.TypeName} (\"{property.DisplayName}\")");
// AllSubShaders also flattens SubShaders nested inside legacy Category blocks.
foreach (var subShader in shader.AllSubShaders)
{
var renderType = subShader.Tags?.GetValue("RenderType"); // case-insensitive lookup
foreach (var pass in subShader.Passes.OfType<PassNode>())
Console.WriteLine($"Pass {pass.Name ?? "<unnamed>"}: {pass.ProgramBlocks.Count} program block(s)");
}CommandNode covers render-state and other commands (Blend, ZWrite, Stencil, ...), recognized or not: command.NameIs("ZWrite"), command.GetArgumentText(0), command.IsRecognized. Every node also exposes Span, Children, DescendantsAndSelf(), and FindNodeAt(position) for offset-based lookups — handy for editor tooling.
string tree = ShaderLabTreeDumper.Dump(shader, result.Source);
Console.WriteLine(tree);Shader "Custom/Toon" @1:1
Properties (2) @3:5
Property _MainTex : 2D = "white" {} ("Texture") @5:9
Property _Color : Color = (1, 1, 1, 1) ("Tint") @6:9
SubShader (LOD -1, 1 passes) @8:5
Tags (1) @10:9
Tag "RenderType" = "Opaque" @10:16
Pass <unnamed> @11:9
ProgramBlock Hlsl/Program (116 chars) @13:13
#pragma vertex vert @14
#pragma fragment frag @15
#include "UnityCG.cginc" @16
Subclass ShaderLabVisitor and override only what you need — call DefaultVisit to keep descending into a node's children:
sealed class PropertyNameCollector : ShaderLabVisitor
{
public List<string> Names { get; } = new();
public override void VisitProperty(PropertyNode node)
{
Names.Add(node.Name);
DefaultVisit(node);
}
}
var collector = new PropertyNameCollector();
collector.Visit(shader); // Names: ["_MainTex", "_Color"]#pragma/#include directives inside every ProgramBlockNode are scanned automatically during Parse — no separate call needed:
foreach (var block in pass.ProgramBlocks)
{
foreach (var pragma in block.Directives.Pragmas)
Console.WriteLine($"#pragma {pragma.Name} {string.Join(" ", pragma.Arguments)}");
foreach (var include in block.Directives.Includes)
Console.WriteLine(include.Path); // raw and unresolved, like Fallback/Dependency/UsePass
}Pass an isMacroDefined lookup to Parse to resolve #if/#ifdef/#ifndef/#elif/#else branches against your build's macros. It's tri-state (true/false/null = unknown), so partial knowledge is fine — a branch that can't be resolved is conservatively kept "live" rather than dropped:
var definedMacros = new HashSet<string> { "SHADER_API_MOBILE" };
var result = ShaderLab.Parse(shaderSource, "Toon.shader", name => definedMacros.Contains(name));
var block = result.Shader.AllSubShaders.First().Passes.OfType<PassNode>().First().ProgramBlocks[0];
foreach (var directive in block.Directives.All)
{
if (!directive.IsBranchLive) continue; // provably dead for this macro set
// ...
}ProgramDirectiveScanner.Scan(...) is also public directly, for re-scanning a block with different options (e.g. reportUnknownPragmas: true) or scanning raw text with no ProgramBlockNode of its own — such as an #include file's contents once you've resolved it yourself.
#define NAME / #define NAME(args) ... and #undef NAME are recognized as DefineDirectiveNode/UndefDirectiveNode — structurally only: the macro name (and, for #define, whether it's function-like) is captured, but the replacement text is kept verbatim and never expanded anywhere, matching this library's "no macro-value substitution" scope.
What is handled automatically: a #define/#undef seen in one program or include block resolves #if/#ifdef/#ifndef in a later block within the same file scope — e.g. a CGINCLUDE's #define gates a subsequent CGPROGRAM's #if in the same Pass. This is scoped correctly per SubShader/Pass/Category (a scope's own #defines are visible to its children but never leak to sibling scopes) and per shader language (Cg/HLSL/GLSL are tracked independently, since Unity never concatenates one into another). No extra wiring is needed beyond calling Parse.
For callers who want to seed platform/build macros up front instead of (or alongside) the bare isMacroDefined delegate, MacroTable is available as a structured, tri-state, pre-seedable macro environment.
dotnet build Tesearis.ShaderLabParser.slnx
dotnet test Tesearis.ShaderLabParser.slnx
MIT — see LICENSE.