Skip to content

Commit 4c5e858

Browse files
authored
feat: add BindCommand and BindInteraction source-generated bindings (#13)
1 parent 8bb90f7 commit 4c5e858

1,049 files changed

Lines changed: 26155 additions & 6177 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.editorconfig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ dotnet_diagnostic.CA1056.severity = suggestion # URI properties should not be st
213213
dotnet_diagnostic.CA1060.severity = error # Move P/Invokes to NativeMethods class
214214
dotnet_diagnostic.CA1061.severity = error # Do not hide base class methods
215215
dotnet_diagnostic.CA1062.severity = error # Validate arguments of public methods
216+
dotnet_code_quality.CA1062.null_check_validation_methods = ThrowIfNull|ThrowIfNullOrEmpty|ThrowIfNullOrWhiteSpace|ThrowIfNullWithMessage
216217
dotnet_diagnostic.CA1063.severity = error # Implement IDisposable correctly
217218
dotnet_diagnostic.CA1064.severity = error # Exceptions should be public
218219
dotnet_diagnostic.CA1065.severity = error # Do not raise exceptions in unexpected locations

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,5 +451,8 @@ src/Tools/
451451
/app
452452
.dotnet/
453453

454+
# Test results and coverage reports
455+
test_results/
456+
454457
# Claude Settings
455458
.claude/

CLAUDE.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,27 @@ The `--treenode-filter` follows the pattern: `/{AssemblyName}/{Namespace}/{Class
9393
- Generator tests use **Verify.SourceGenerators** for snapshot testing
9494
- Snapshots stored as `*.verified.cs` files alongside test classes
9595
- To accept new/changed snapshots:
96-
1. Enable `VerifierSettings.AutoVerify()` in `ModuleInitializer.cs`
96+
1. Enable `VerifierSettings.AutoVerify()` in `AssemblySetup.cs`
9797
2. Run tests to accept all snapshots
9898
3. Disable `VerifierSettings.AutoVerify()` after accepting
9999
4. Re-run tests to confirm they pass without AutoVerify
100100

101+
### Generator Test Language Versions (Critical)
102+
103+
Generator tests use a **two-tier language version** strategy to verify generated output compiles under C# 7.3 (the minimum supported version for consumer projects):
104+
105+
- **Default: C# 7.3**`TestHelper.CreateCompilation()` and `RunGenerator()` default to `LanguageVersion.CSharp7_3`. This ensures generated output contains no C# 8+ syntax (no nullable reference type annotations, no `static` lambdas, no `#nullable enable`).
106+
- **CallerArgumentExpression tests: explicit C# 10** — Tests that verify `CallerArgumentExpression`-based dispatch (the primary dispatch mechanism for C# 10+ projects) must pass `LanguageVersion.CSharp10` explicitly. These are the majority of snapshot tests.
107+
- **CallerFilePath fallback tests: explicit C# 7.3** — Tests that verify `CallerFilePath + CallerLineNumber` dispatch (the fallback for pre-C# 10 projects) pass `LanguageVersion.CSharp7_3` explicitly to document intent, even though it matches the default.
108+
- **Edge case tests** (`RunGenerator` without snapshot verification) — These use the C# 7.3 default. They verify the generator doesn't crash on invalid lambdas and produces no dispatch code. They don't call `CompilationSucceeds()` since their test source may contain C# 8+ features that are only diagnostically invalid under C# 7.3.
109+
- **Runtime execution tests** — These use `LanguageVersion.CSharp10` because their inline source uses C# 8+ features and they call `CompilationSucceeds()`.
110+
111+
**When adding new generator tests:**
112+
1. If the test verifies CallerArgumentExpression dispatch → pass `LanguageVersion.CSharp10`
113+
2. If the test verifies CallerFilePath fallback dispatch → pass `LanguageVersion.CSharp7_3`
114+
3. If the test verifies the generator skips invalid input → use the default (no parameter)
115+
4. If the test compiles and loads the generated assembly → pass `LanguageVersion.CSharp10`
116+
101117
### Code Coverage
102118

103119
Code coverage uses **Microsoft.Testing.Extensions.CodeCoverage** configured in `src/testconfig.json`. Coverage is collected for production assemblies only (test projects and TestModels are excluded).
@@ -296,7 +312,8 @@ There are **two distinct C# language contexts** in this project:
296312
- Raw string literals (C# 11)
297313
- File-scoped namespaces (C# 10)
298314
- `init` setters (C# 9)
299-
- Generated output CAN use `#nullable enable` and `object?` (pragma-based, works in any C# version)
315+
- Generated output must NOT use `#nullable enable` or nullable reference type annotations (`T?` where T is a reference type) — these are C# 8+ features
316+
- `static` lambdas are C# 9 — do not use in generated output
300317

301318
### Analyzer Separation (Roslyn Best Practice)
302319

@@ -339,10 +356,10 @@ The analyzer project links shared files from the generator project:
339356

340357
### Accepting Snapshot Changes
341358

342-
1. Enable `VerifierSettings.AutoVerify()` in `ModuleInitializer.cs`
359+
1. Enable `VerifierSettings.AutoVerify()` in `AssemblySetup.cs`
343360
2. Run tests: `dotnet test --project tests/ReactiveUI.Binding.SourceGenerators.Tests/... -c Release`
344-
3. Remove `VerifierSettings.AutoVerify()` from `ModuleInitializer.cs`
345-
4. Re-run tests to confirm they pass
361+
3. Disable `VerifierSettings.AutoVerify()` in `AssemblySetup.cs`
362+
4. Re-run tests to confirm they pass without AutoVerify
346363

347364
## What to Avoid
348365

scripts/coverage.sh

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
#!/usr/bin/env bash
2+
# coverage.sh - Clean build, run tests with coverage, and generate a human-readable report.
3+
# Usage: ./scripts/coverage.sh [--project <test-project-csproj>] [--open]
4+
# --project <path> Run coverage for a single test project (relative to src/)
5+
# --open Open HTML report in browser after generation
6+
set -euo pipefail
7+
8+
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
9+
SRC_DIR="$REPO_ROOT/src"
10+
RESULTS_DIR="$REPO_ROOT/test_results"
11+
COVERAGE_DIR="$RESULTS_DIR/coverage"
12+
REPORT_DIR="$RESULTS_DIR/report"
13+
SOLUTION="ReactiveUI.Binding.SourceGenerators.slnx"
14+
15+
# Parse arguments
16+
PROJECT=""
17+
OPEN_REPORT=false
18+
while [[ $# -gt 0 ]]; do
19+
case "$1" in
20+
--project)
21+
PROJECT="$2"
22+
shift 2
23+
;;
24+
--open)
25+
OPEN_REPORT=true
26+
shift
27+
;;
28+
*)
29+
echo "Unknown argument: $1" >&2
30+
exit 1
31+
;;
32+
esac
33+
done
34+
35+
# ── Step 1: Clean ──────────────────────────────────────────────────────────────
36+
echo "=== Cleaning ==="
37+
cd "$SRC_DIR"
38+
dotnet clean "$SOLUTION" -c Release --verbosity quiet 2>/dev/null || true
39+
40+
echo "Removing bin/ and obj/ directories..."
41+
find "$SRC_DIR" -type d \( -name bin -o -name obj \) -exec rm -rf {} + 2>/dev/null || true
42+
43+
# Clean previous results
44+
rm -rf "$RESULTS_DIR"
45+
mkdir -p "$COVERAGE_DIR" "$REPORT_DIR"
46+
47+
# ── Step 2: Run tests with coverage ───────────────────────────────────────────
48+
echo ""
49+
echo "=== Building and running tests with coverage ==="
50+
cd "$SRC_DIR"
51+
52+
if [[ -n "$PROJECT" ]]; then
53+
echo "Project: $PROJECT"
54+
dotnet test --project "$PROJECT" -c Release \
55+
--results-directory "$COVERAGE_DIR" \
56+
-- --coverage --coverage-output-format cobertura
57+
else
58+
echo "Solution: $SOLUTION"
59+
dotnet test --solution "$SOLUTION" -c Release \
60+
--results-directory "$COVERAGE_DIR" \
61+
-- --coverage --coverage-output-format cobertura
62+
fi
63+
64+
# ── Step 3: Find cobertura files ──────────────────────────────────────────────
65+
echo ""
66+
echo "=== Locating coverage files ==="
67+
68+
# Coverage files may land in --results-directory or in per-project TestResults/
69+
COBERTURA_FILES=""
70+
while IFS= read -r -d '' file; do
71+
COBERTURA_FILES="${COBERTURA_FILES};${file}"
72+
done < <(find "$COVERAGE_DIR" "$SRC_DIR" -name "*.cobertura.xml" -print0 2>/dev/null)
73+
74+
# Strip leading semicolon
75+
COBERTURA_FILES="${COBERTURA_FILES#;}"
76+
77+
if [[ -z "$COBERTURA_FILES" ]]; then
78+
echo "ERROR: No cobertura coverage files found." >&2
79+
exit 1
80+
fi
81+
82+
FILE_COUNT=$(echo "$COBERTURA_FILES" | tr ';' '\n' | wc -l)
83+
echo "Found $FILE_COUNT cobertura file(s)"
84+
85+
# ── Step 4: Generate reports ──────────────────────────────────────────────────
86+
echo ""
87+
echo "=== Generating coverage reports ==="
88+
89+
reportgenerator \
90+
-reports:"$COBERTURA_FILES" \
91+
-targetdir:"$REPORT_DIR" \
92+
-reporttypes:"Html;TextSummary;MarkdownSummaryGithub"
93+
94+
# ── Step 5: Display summary ──────────────────────────────────────────────────
95+
echo ""
96+
echo "========================================================================"
97+
echo " COVERAGE SUMMARY"
98+
echo "========================================================================"
99+
echo ""
100+
cat "$REPORT_DIR/Summary.txt"
101+
102+
# ── Step 6: Extract uncovered lines for agent consumption ─────────────────────
103+
echo ""
104+
echo "========================================================================"
105+
echo " UNCOVERED LINES / BRANCHES"
106+
echo "========================================================================"
107+
echo ""
108+
109+
# Parse cobertura XML to find files with < 100% coverage and list uncovered lines.
110+
# Uses xmllint (libxml2) if available, otherwise falls back to awk.
111+
generate_uncovered_report() {
112+
local cobertura_file="$1"
113+
114+
# Use python3 for reliable XML parsing — available on virtually all Linux systems
115+
python3 - "$cobertura_file" <<'PYEOF'
116+
import sys
117+
import xml.etree.ElementTree as ET
118+
from collections import defaultdict
119+
120+
tree = ET.parse(sys.argv[1])
121+
root = tree.getroot()
122+
123+
uncovered = defaultdict(lambda: {"lines": [], "branches": []})
124+
125+
for package in root.iter("package"):
126+
for cls in package.iter("class"):
127+
filename = cls.get("filename", "")
128+
# Make path relative if possible
129+
for prefix in ["/home/", "/src/"]:
130+
idx = filename.find(prefix)
131+
if idx >= 0:
132+
filename = filename[idx:]
133+
break
134+
135+
for line in cls.iter("line"):
136+
line_num = line.get("number")
137+
hits = int(line.get("hits", "0"))
138+
condition = line.get("condition-coverage", "")
139+
140+
if hits == 0:
141+
uncovered[filename]["lines"].append(int(line_num))
142+
elif condition and "100%" not in condition:
143+
uncovered[filename]["branches"].append(
144+
(int(line_num), condition)
145+
)
146+
147+
if not uncovered:
148+
print("All lines and branches are covered!")
149+
sys.exit(0)
150+
151+
# Sort files for stable output
152+
for filename in sorted(uncovered.keys()):
153+
info = uncovered[filename]
154+
if not info["lines"] and not info["branches"]:
155+
continue
156+
print(f"\n--- {filename}")
157+
if info["lines"]:
158+
# Collapse consecutive lines into ranges
159+
lines = sorted(set(info["lines"]))
160+
ranges = []
161+
start = lines[0]
162+
end = lines[0]
163+
for n in lines[1:]:
164+
if n == end + 1:
165+
end = n
166+
else:
167+
ranges.append(f"{start}" if start == end else f"{start}-{end}")
168+
start = end = n
169+
ranges.append(f"{start}" if start == end else f"{start}-{end}")
170+
print(f" Uncovered lines: {', '.join(ranges)}")
171+
if info["branches"]:
172+
for line_num, cond in sorted(set(info["branches"])):
173+
print(f" Partial branch at line {line_num}: {cond}")
174+
PYEOF
175+
}
176+
177+
# Process each cobertura file
178+
IFS=';' read -ra FILES <<< "$COBERTURA_FILES"
179+
for f in "${FILES[@]}"; do
180+
if [[ -f "$f" ]]; then
181+
generate_uncovered_report "$f"
182+
fi
183+
done
184+
185+
# ── Step 7: Summary footer ───────────────────────────────────────────────────
186+
echo ""
187+
echo "========================================================================"
188+
echo " HTML report: $REPORT_DIR/index.html"
189+
echo " Markdown: $REPORT_DIR/SummaryGithub.md"
190+
echo " Text: $REPORT_DIR/Summary.txt"
191+
echo "========================================================================"
192+
193+
if $OPEN_REPORT; then
194+
xdg-open "$REPORT_DIR/index.html" 2>/dev/null || \
195+
open "$REPORT_DIR/index.html" 2>/dev/null || \
196+
echo "Could not open browser. Open manually: $REPORT_DIR/index.html"
197+
fi

src/Directory.Build.props

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,17 @@
3131

3232
<!-- Test targets -->
3333
<BindingTestTargets>net8.0;net9.0;net10.0</BindingTestTargets>
34+
35+
<!-- Windows-specific test targets: use Windows TFMs on Windows, core TFMs elsewhere -->
36+
<BindingTestWindowsTargets>$(BindingTestTargets)</BindingTestWindowsTargets>
37+
<BindingTestWindowsTargets Condition="$([MSBuild]::IsOsPlatform('Windows'))">$(BindingWindowsTargets)</BindingTestWindowsTargets>
3438
</PropertyGroup>
3539

3640
<PropertyGroup>
3741
<GenerateDocumentationFile>true</GenerateDocumentationFile>
3842
<Nullable>enable</Nullable>
3943
<LangVersion>latest</LangVersion>
44+
<ImplicitUsings>enable</ImplicitUsings>
4045
<Platform>AnyCPU</Platform>
4146
<IsTestProject>$(MSBuildProjectName.Contains('Tests'))</IsTestProject>
4247
<DebugType>embedded</DebugType>

src/Directory.Packages.props

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@
66

77
<!-- MAUI version varies by target framework -->
88
<PropertyGroup>
9-
<MauiVersion Condition="$(TargetFramework.StartsWith('net10'))">10.0.40</MauiVersion>
9+
<MauiVersion Condition="$(TargetFramework.StartsWith('net10'))">10.0.41</MauiVersion>
1010
<MauiVersion Condition="$(TargetFramework.StartsWith('net9'))">9.0.120</MauiVersion>
1111
</PropertyGroup>
1212

1313
<ItemGroup>
1414
<!-- Testing Framework -->
15-
<PackageVersion Include="TUnit" Version="1.17.36" />
16-
<PackageVersion Include="Verify.TUnit" Version="31.13.0" />
15+
<PackageVersion Include="NSubstitute" Version="5.3.0" />
16+
<PackageVersion Include="TUnit" Version="1.19.0" />
17+
<PackageVersion Include="Verify.TUnit" Version="31.13.2" />
1718
<PackageVersion Include="Verify.SourceGenerators" Version="2.5.0" />
1819

1920
<!--
@@ -32,7 +33,7 @@
3233
<PackageVersion Include="Basic.Reference.Assemblies.Net80" Version="1.8.4" />
3334

3435
<!-- Dependencies -->
35-
<PackageVersion Include="Splat" Version="19.2.1" />
36+
<PackageVersion Include="Splat" Version="19.3.1" />
3637
<PackageVersion Include="System.Reactive" Version="6.1.0" />
3738

3839
<!-- Build Tools -->
@@ -49,7 +50,7 @@
4950
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
5051

5152
<!-- Test App Dependencies-->
52-
<PackageVersion Include="ReactiveUI" Version="23.1.0-beta.8" />
53+
<PackageVersion Include="ReactiveUI" Version="23.1.8" />
5354
<PackageVersion Include="ReactiveUI.SourceGenerators" Version="2.6.1" />
5455
</ItemGroup>
5556
</Project>

src/ReactiveUI.Binding.Analyzer/AnalyzerReleases.Unshipped.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ RXUIBIND003 | Usage | Warning | Expression contains private/protected member
88
RXUIBIND004 | Usage | Warning | Type does not support before-change notifications
99
RXUIBIND005 | Usage | Info | Source type implements INotifyDataErrorInfo; validation binding requires runtime engine
1010
RXUIBIND006 | Usage | Warning | Expression contains unsupported path segment (indexer, field, or method call)
11+
RXUIBIND007 | Usage | Warning | Control has no bindable event
12+
RXUIBIND008 | Usage | Warning | Property is not an IInteraction

0 commit comments

Comments
 (0)