Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Copilot Instructions for RoslynTestKit

## What This Is

A NuGet library (`ALCops.RoslynTestKit`) for unit-testing Roslyn-based diagnostic analyzers, code fixes, refactorings, and completion providers targeting the **AL Language** (Microsoft Dynamics 365 Business Central). Forked from [cezarypiatek/RoslynTestKit](https://github.com/cezarypiatek/RoslynTestKit), it replaces `Microsoft.CodeAnalysis` types with their `Microsoft.Dynamics.Nav.CodeAnalysis` equivalents.

## Build

```bash
dotnet restore src/RoslynTestKit.sln
dotnet build src/RoslynTestKit.sln --configuration Release
dotnet pack src/RoslynTestKit/RoslynTestKit.csproj --configuration Release --output ./artifacts
```

There is no test project in this repository. The library itself is the deliverable.

The project multi-targets `netstandard2.1` and `net8.0`. Each TFM references a different version of the BC DevTools DLLs (`Microsoft.Dynamics.Nav.CodeAnalysis.dll` and `Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces.dll`) from a local path. CI downloads these via the custom `.github/actions/setup-bc-devtools` composite action.

## Architecture

### Namespace aliasing pattern

Throughout the codebase, `Microsoft.Dynamics.Nav.CodeAnalysis` types shadow their `Microsoft.CodeAnalysis` counterparts via `using` aliases. This is intentional and central to the design:

```csharp
using AdditionalText = Microsoft.Dynamics.Nav.CodeAnalysis.AdditionalText;
using CompilationOptions = Microsoft.Dynamics.Nav.CodeAnalysis.CompilationOptions;
using Document = Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces.Document;
using LanguageNames = Microsoft.Dynamics.Nav.CodeAnalysis.LanguageNames;
using ParseOptions = Microsoft.Dynamics.Nav.CodeAnalysis.ParseOptions;
```

When adding or modifying code, always use the Nav.CodeAnalysis types, not the Microsoft.CodeAnalysis originals, unless there is no Nav equivalent (e.g., `MetadataReference`).

### Fixture hierarchy

```
BaseTestFixture (abstract, creates AdhocWorkspace + Document from code)
├── AnalyzerTestFixture (abstract, HasDiagnostic / NoDiagnostic assertions)
├── CodeFixTestFixture (abstract, TestCodeFix / NoCodeFix assertions)
├── CodeRefactoringTestFixture (abstract, TestCodeRefactoring assertions)
└── CompletionProviderFixture (abstract, completion assertions)
```

Each abstract fixture has a **Configurable** counterpart (`ConfigurableAnalyzerTestFixture`, etc.) that is `internal` and bridges a config object to the fixture's `protected virtual` properties. Users never instantiate these directly.

### Factory entry point

`RoslynFixtureFactory.Create<T>()` is the only public API consumers use. It instantiates the analyzer/provider via `new T()`, wraps it in the matching Configurable fixture, and returns the abstract base type. All overloads accept an optional config object (`AnalyzerTestFixtureConfig`, `CodeFixTestFixtureConfig`, etc.) that inherits from `BaseTestFixtureConfig`.

### Config hierarchy

```
BaseTestFixtureConfig (Language, References, AdditionalFiles, RuleSetPath, PackageCachePaths, CompilationOptions, ParseOptions, ProjectInfoCustomizer)
├── AnalyzerTestFixtureConfig
├── CodeFixTestFixtureConfig (+AdditionalAnalyzers)
├── CodeRefactoringTestFixtureConfig
└── CompletionProviderTestFixtureConfig
```

### Code markup for test spans

Test input uses `[|` and `|]` markers to denote diagnostic/refactoring spans. `CodeMarkup` parses these, strips the markers from the code, and produces `IDiagnosticLocator` instances. Multiple markers per file are supported via `AllLocators`. Prefer `HasDiagnosticAtAllMarkers` / `NoDiagnosticAtAllMarkers` over single-marker methods.

### Multi-document tests

Use the `/*EOD*/` separator in a single code string to define multiple documents in one test project. The last segment becomes the "main" document.

### NavCodeAnalysisBase

An NUnit base class for test projects consuming this library. It detects the loaded `Microsoft.Dynamics.Nav.CodeAnalysis` assembly version at runtime and provides helpers (`RequireMinimumVersion`, `SkipTestIfVersionIsTooLow`, etc.) to conditionally skip tests based on AL DevTools version.

## Conventions

- **Nullable reference types** are enabled (`<Nullable>enable</Nullable>`).
- The single namespace is `RoslynTestKit` for all public types. Helper/utility types also live in `RoslynTestKit` or `RoslynTestKit.Utils` / `RoslynTestKit.CodeActionLocators`.
- `Configurable*` fixture classes are `internal`. Only the abstract base fixtures and `RoslynFixtureFactory` are public API.
- `ProjectSettings` is `internal` and carries config values from the fixture down to `AdhocWorkspace.AddProject`.
- Code comparison on failure uses DiffPlex for inline diffs and ApprovalTests for external diff tool launch (when a debugger is attached).
- Versioning uses [GitVersion](https://gitversion.net/) in Mainline mode. Version is derived from git history, not manually maintained.

## CI/CD

- **Pull requests**: build + pack + validate (via `dotnet-validate`). Skips draft PRs.
- **Main/master push**: build + pack + validate + create GitHub Release + publish to GitHub Packages + publish to NuGet.org.
- BC DevTools DLLs are downloaded in CI from the VS Code Marketplace via the custom `setup-bc-devtools` action. Two versions are fetched: `12.0.779795` (netstandard2.0) and `16.0.1463980` (net8.0).
84 changes: 84 additions & 0 deletions .github/instructions/filesystem-support.instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
applyTo: 'src/RoslynTestKit/**'
---

# FileSystem Support in RoslynTestKit

## Purpose

Some AL analyzers depend on `Compilation.FileSystem` to read workspace files at analysis time (e.g. XLIFF translation files, configuration files). The `FileSystem` config property lets tests inject a virtual file system into the compilation so these analyzers can run without touching disk.

## How it works

1. `BaseTestFixtureConfig` exposes `IFileSystem? FileSystem` (default: `null`).
2. All `Configurable*` fixture classes wire this property from the config to the base fixture.
3. `AnalyzerTestFixture.GetDiagnostics()` and `CodeFixTestFixture.GetAllReportedDiagnostics()` call `compilation.WithFileSystem(FileSystem)` before creating `CompilationWithAnalyzers`, replacing any existing file system on the compilation.
4. When `FileSystem` is `null`, no replacement occurs and the compilation retains its default (typically `null`).

## SDK's MemoryFileSystem

The `Microsoft.Dynamics.Nav.CodeAnalysis` SDK includes a public `MemoryFileSystem` class:

```csharp
public class MemoryFileSystem : IFileSystem
{
public MemoryFileSystem(IDictionary<string, byte[]> files);
public string GetDirectoryPath(); // always returns ""
public IEnumerable<string> GetFiles(string glob);
public Stream OpenRead(string path);
}
```

Key behaviors:
- Constructor uses `PackagePathComparer.Instance` internally (case-insensitive on Windows).
- `GetDirectoryPath()` returns `""` (empty string). Analyzers that use this for path resolution get an empty workspace path.
- `GetFiles(string glob)` supports single-parameter glob matching (e.g. `Translations/*.xlf`). Do NOT use the two-parameter overload as it may not behave the same way.
- Keys should use forward slashes: `Translations/MyApp.da-DK.xlf`.

## Usage pattern in tests

```csharp
var xliffContent = """
<?xml version="1.0" encoding="utf-8"?>
<xliff version="1.2">
<file datatype="xml" source-language="en-US" target-language="da-DK">
<body />
</file>
</xliff>
""";

var files = new Dictionary<string, byte[]>
{
["Translations/TestApp.da-DK.xlf"] = Encoding.UTF8.GetBytes(xliffContent)
};

var fixture = RoslynFixtureFactory.Create<MyAnalyzer>(
new AnalyzerTestFixtureConfig
{
FileSystem = new MemoryFileSystem(files)
});
```

## Design decisions

| Decision | Choice | Rationale |
|---|---|---|
| Property location | `BaseTestFixtureConfig` | All fixture types (analyzer, codefix, refactoring, completion) may need FileSystem |
| Null default | `null` | Backward compatible; only analyzers that need FileSystem will use it |
| Injection point | Before `CompilationWithAnalyzers` creation | FileSystem must be set before analyzer callbacks fire |
| No custom MemoryFileSystem | Use SDK's built-in class | Avoids maintenance burden; SDK class is public and stable |

## Affected files

- `BaseTestFixtureConfig.cs`: `IFileSystem? FileSystem` property declaration
- `BaseTestFixture.cs`: Virtual `IFileSystem? FileSystem` property
- `AnalyzerTestFixture.cs`: `compilation.WithFileSystem(FileSystem)` in `GetDiagnostics()`
- `CodeFixTestFixture.cs`: `compilation.WithFileSystem(FileSystem)` in `GetAllReportedDiagnostics()`
- `ConfigurableAnalyzerTestFixture.cs`: Wires from config
- `ConfigurableCodeFixTestFixture.cs`: Wires from config
- `ConfigurableCodeRefactoringTestFixture.cs`: Wires from config
- `ConfigurableCompletionProviderTestFixture.cs`: Wires from config

## Known issues

- `ManifestHelper.GetManifest(compilation)` in ALCops.Common throws `FileNotFoundException` for `Microsoft.Dynamics.Nav.Analyzers.Common` in test contexts because that assembly isn't available. Analyzers using `ManifestHelper` must wrap the call in try-catch. This is NOT a RoslynTestKit issue but affects analyzers that use FileSystem-dependent features alongside ManifestHelper.
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ Every `RoslynFixtureFactory.Create<T>()` overload accepts an optional config obj
| `CompilationOptions` | `CompilationOptions?` | `null` | Override the default `CompilationOptions`. |
| `ParseOptions` | `ParseOptions?` | `null` | Override the default `ParseOptions`. |
| `ProjectInfoCustomizer` | `Func<ProjectInfo, ProjectInfo>?` | `null` | Escape hatch to set any `ProjectInfo` property not exposed above. |
| `FileSystem` | `IFileSystem?` | `null` | Virtual file system injected into the compilation via `Compilation.WithFileSystem()`. Use for analyzers that read files from the workspace (e.g. XLIFF translations, config files). |

#### CodeFixTestFixtureConfig extras

Expand Down Expand Up @@ -283,6 +284,39 @@ var fixture = RoslynFixtureFactory.Create<FlowFieldsShouldNotBeEditableCodeFixPr
fixture.TestCodeFix(currentCode, expectedCode, DiagnosticDescriptors.FlowFieldsShouldNotBeEditable);
```

#### Example: provide in-memory files via FileSystem

Analyzers that depend on `Compilation.FileSystem` (e.g. to read XLIFF translation files) require a virtual file system during tests. The SDK ships a `MemoryFileSystem` class that accepts a dictionary of path-to-content mappings.

```csharp
using System.Text;
using Microsoft.Dynamics.Nav.CodeAnalysis;

var xliff = """
<?xml version="1.0" encoding="utf-8"?>
<xliff version="1.2">
<file datatype="xml" source-language="en-US" target-language="da-DK">
<body />
</file>
</xliff>
""";

var files = new Dictionary<string, byte[]>
{
["Translations/MyApp.da-DK.xlf"] = Encoding.UTF8.GetBytes(xliff)
};

var fixture = RoslynFixtureFactory.Create<TranslatableTextAnalyzer>(
new AnalyzerTestFixtureConfig
{
FileSystem = new MemoryFileSystem(files)
});

fixture.HasDiagnosticAtAllMarkers(code, DiagnosticIds.MissingTranslation);
```

> **Note:** `MemoryFileSystem.GetDirectoryPath()` always returns `""`. Keys should use forward slashes and match the glob patterns your analyzer passes to `IFileSystem.GetFiles()` (e.g. `Translations/*.xlf`).

#### Example: escape hatch for advanced ProjectInfo settings

When you need to configure a `ProjectInfo` property that has no dedicated config option, use `ProjectInfoCustomizer`.
Expand Down
5 changes: 5 additions & 0 deletions src/RoslynTestKit/AnalyzerTestFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ private ImmutableArray<Diagnostic> GetDiagnostics(Document document)
return ImmutableArray<Diagnostic>.Empty;
}

if (FileSystem != null)
{
compilation = compilation.WithFileSystem(FileSystem);
}

var compilationWithAnalyzers = compilation.WithAnalyzers(analyzers, options: AdditionalFiles != null ? new AnalyzerOptions(AdditionalFiles.ToImmutableArray()) : null, cancellationToken: CancellationToken.None);
var discarded = compilation.GetDiagnostics(CancellationToken.None);
var errorsInDocument = discarded.Where(x => x.Severity == DiagnosticSeverity.Error).ToArray();
Expand Down
7 changes: 7 additions & 0 deletions src/RoslynTestKit/BaseTestFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.Dynamics.Nav.CodeAnalysis;
using Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces;
using AdditionalText = Microsoft.Dynamics.Nav.CodeAnalysis.AdditionalText;
using CompilationOptions = Microsoft.Dynamics.Nav.CodeAnalysis.CompilationOptions;
Expand Down Expand Up @@ -52,6 +53,12 @@
/// </summary>
protected virtual Func<ProjectInfo, ProjectInfo>? ProjectInfoCustomizer => null;

/// <summary>
/// Optional <see cref="IFileSystem"/> injected into the <see cref="Compilation"/> before analyzers
/// run. When non-null, the compilation is recreated via <c>Compilation.WithFileSystem()</c>.
/// </summary>
protected virtual IFileSystem? FileSystem => null;

protected Document CreateDocumentFromCode(string code)
{
return CreateDocumentFromCode(code, LanguageName, References ?? Array.Empty<MetadataReference>());
Expand Down Expand Up @@ -83,7 +90,7 @@
throw new ArgumentException("Code cannot be empty after splitting", nameof(code));
}

var project = new AdhocWorkspace()

Check warning on line 93 in src/RoslynTestKit/BaseTestFixture.cs

View workflow job for this annotation

GitHub Actions / Build, Pack and Validate

Dereference of a possibly null reference.

Check warning on line 93 in src/RoslynTestKit/BaseTestFixture.cs

View workflow job for this annotation

GitHub Actions / Build, Pack and Validate

Dereference of a possibly null reference.
.AddProject("TestProject", languageName, settings)
.WithCompilationOptions(compilationOptions)
.AddMetadataReferences(frameworkReferences)
Expand Down
9 changes: 9 additions & 0 deletions src/RoslynTestKit/BaseTestFixtureConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.Dynamics.Nav.CodeAnalysis;
using Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces;
using AdditionalText = Microsoft.Dynamics.Nav.CodeAnalysis.AdditionalText;
using CompilationOptions = Microsoft.Dynamics.Nav.CodeAnalysis.CompilationOptions;
Expand Down Expand Up @@ -48,5 +49,13 @@ public abstract class BaseTestFixtureConfig
/// directly on the config.
/// </summary>
public Func<ProjectInfo, ProjectInfo>? ProjectInfoCustomizer { get; set; } = null;

/// <summary>
/// Optional <see cref="IFileSystem"/> injected into the <see cref="Compilation"/> before analyzers
/// run. Use the SDK's <see cref="MemoryFileSystem"/> to provide in-memory files (e.g. XLIFF
/// translation files) that analyzers read via <c>Compilation.FileSystem</c>.
/// When null, the compilation keeps whatever file system the workspace assigns (typically none).
/// </summary>
public IFileSystem? FileSystem { get; set; } = null;
}
}
5 changes: 5 additions & 0 deletions src/RoslynTestKit/CodeFixTestFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,11 @@ private IEnumerable<Diagnostic> GetAllReportedDiagnostics(Document document)
throw new InvalidOperationException("Unable to get compilation from document project.");
}

if (FileSystem != null)
{
compilation = compilation.WithFileSystem(FileSystem);
}

return compilation
.WithAnalyzers(additionalAnalyzers.ToImmutableArray(), new AnalyzerOptions(this.AdditionalFiles?.ToImmutableArray() ?? ImmutableArray<AdditionalText>.Empty))
.GetAnalyzerDiagnosticsAsync().GetAwaiter().GetResult()
Expand Down
2 changes: 2 additions & 0 deletions src/RoslynTestKit/ConfigurableAnalyzerTestFixture.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using Microsoft.CodeAnalysis;
using Microsoft.Dynamics.Nav.CodeAnalysis;
using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics;
using Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces;
using AdditionalText = Microsoft.Dynamics.Nav.CodeAnalysis.AdditionalText;
Expand Down Expand Up @@ -30,5 +31,6 @@ public ConfigurableAnalyzerTestFixture(DiagnosticAnalyzer diagnosticAnalyzer, An
protected override CompilationOptions? CustomCompilationOptions => _config.CompilationOptions;
protected override ParseOptions? ParseOptions => _config.ParseOptions;
protected override Func<ProjectInfo, ProjectInfo>? ProjectInfoCustomizer => _config.ProjectInfoCustomizer;
protected override IFileSystem? FileSystem => _config.FileSystem;
}
}
2 changes: 2 additions & 0 deletions src/RoslynTestKit/ConfigurableCodeFixTestFixture.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using Microsoft.CodeAnalysis;
using Microsoft.Dynamics.Nav.CodeAnalysis;
using Microsoft.Dynamics.Nav.CodeAnalysis.CodeFixes;
using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics;
using Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces;
Expand Down Expand Up @@ -32,5 +33,6 @@ public ConfigurableCodeFixTestFixture(CodeFixProvider provider, CodeFixTestFixtu
protected override CompilationOptions? CustomCompilationOptions => _config.CompilationOptions;
protected override ParseOptions? ParseOptions => _config.ParseOptions;
protected override Func<ProjectInfo, ProjectInfo>? ProjectInfoCustomizer => _config.ProjectInfoCustomizer;
protected override IFileSystem? FileSystem => _config.FileSystem;
}
}
2 changes: 2 additions & 0 deletions src/RoslynTestKit/ConfigurableCodeRefactoringTestFixture.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using Microsoft.CodeAnalysis;
using Microsoft.Dynamics.Nav.CodeAnalysis;
using Microsoft.Dynamics.Nav.CodeAnalysis.CodeRefactoring;
using Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces;
using AdditionalText = Microsoft.Dynamics.Nav.CodeAnalysis.AdditionalText;
Expand Down Expand Up @@ -30,5 +31,6 @@ public ConfigurableCodeRefactoringTestFixture(CodeRefactoringProvider provider,
protected override CompilationOptions? CustomCompilationOptions => _config.CompilationOptions;
protected override ParseOptions? ParseOptions => _config.ParseOptions;
protected override Func<ProjectInfo, ProjectInfo>? ProjectInfoCustomizer => _config.ProjectInfoCustomizer;
protected override IFileSystem? FileSystem => _config.FileSystem;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using Microsoft.CodeAnalysis;
using Microsoft.Dynamics.Nav.CodeAnalysis;
using Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces;
using Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces.Completion;
using AdditionalText = Microsoft.Dynamics.Nav.CodeAnalysis.AdditionalText;
Expand Down Expand Up @@ -30,5 +31,6 @@ public ConfigurableCompletionProviderTestFixture(CompletionProvider provider, Co
protected override CompilationOptions? CustomCompilationOptions => _config.CompilationOptions;
protected override ParseOptions? ParseOptions => _config.ParseOptions;
protected override Func<ProjectInfo, ProjectInfo>? ProjectInfoCustomizer => _config.ProjectInfoCustomizer;
protected override IFileSystem? FileSystem => _config.FileSystem;
}
}
Loading